10880 字
54 分钟
CatTodo项目

记录项目的文档,记录开发时的思路,方便查询。

CatTodo项目 01#

需求分析#

CatTodo目的是实现一个Todo计划的app,用户可以使用这个app创建todo和计划来规划自己。

与其他todo App不同的地方在于,CatTodo引入了ai功能,可以使用ai快速创建目标和任务项。

由于特殊的原因,项目需要添加一个团队功能。

项目通用功能:#

  1. 基于JWT的登录 / 注册

项目基本功能:#

  1. 任务项的创建
  2. 任务项设置循环和条件
  3. 前端任务项的显示

技术选型#

前端:Flutter

后端:Java + SpringBoot + Mybatis-plus + MySql + Redis

本次计划:#

  • 重新创建项目,使用单独一个SpringBoot项目 ✅ 2026-01-09
  • 完成用户登录功能的数据库设计,考虑团队功能。 ✅ 2026-01-09
  • 完成后端注册Service和接口的书写 ✅ 2026-01-10
  • 完成后端登录Service和接口的书写 ✅ 2026-01-11
  • 完成后端全局异常处理 ✅ 2026-01-10
  • 完成基本响应类型的封装 ✅ 2026-01-10
  • 前端完成基本的项目结构创建 ✅ 2026-01-13
  • 完成前后端联调 ✅ 2026-01-13
  • 前端完成登录页面 ✅ 2026-01-13
  • 前端使用JWT ✅ 2026-01-13

重新创建项目:#

创建后端,防止一次性多加很多内容导致项目很乱。 先添加一些需要连接和用上的依赖: mybatis和mybatis-plus 还有代码生成器 redis基本依赖

书写Redis配置类#

目的是防止直接查看时显示乱码

@Configuration
@Slf4j
public class RedisConfiguration {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
log.info("开始创建redis模板对象...");
RedisTemplate redisTemplate = new RedisTemplate();
//设置redis的连接工厂对象
redisTemplate.setConnectionFactory(redisConnectionFactory);
//设置redis key的序列化器
redisTemplate.setKeySerializer(new StringRedisSerializer());
return redisTemplate;
}
}

数据库设计:#

用户表:

  • user_id UUID 主键
  • username 用户名
  • email 邮箱
  • phone 手机号(用于短信提醒)
  • password 加密后的密码
  • avatar_url 头像
  • status 用户状态(激活 冻结)
  • deleted 是否删除
  • created_at
  • updated_at

用户表(1) —> 用户团队表(n) 团队表(1) —> 用户团队表(n)

一个用户会有多个团队,一个团队可能有多个用户,使用用户团队表进行关联

-- 用户表
CREATE TABLE users (
user_id varchar(36) NOT NULL COMMENT '主键ID(UUID)',
username VARCHAR(50) NOT NULL COMMENT '用户名',
password VARCHAR(100) NOT NULL COMMENT '加密后的密码',
nickname VARCHAR(50) COMMENT '昵称',
email VARCHAR(100) COMMENT '邮箱',
avatar_url VARCHAR(255) COMMENT '头像地址',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT(1) DEFAULT 0 COMMENT '逻辑删除 0表示存在 1表示删除',
PRIMARY KEY (user_id),
UNIQUE KEY uk_email (email)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户基础信息表';

使用mybatis-plus生成基本代码#

书写错误码和全局异常处理 创建自定义业务异常#

为项目引入Knife4j#

引入依赖#

<dependency>
<groupId>com.github.xingfudeshi</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.6.0</version>
</dependency>

[!IMPORTANT] 版本错误 这里因为springboot版本是3.5.9 不可以用官方那个最新版本4.5.0 会有各种错误 我这里用的是社区维护版本4.6.0

配置:#

springdoc:
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
api-docs:
path: /v3/api-docs
group-configs:
- group: 'default'
paths-to-match: '/**'
packages-to-scan: com.example.project.controller # 替换为你的Controller包路径

书写配置类#

package com.shine.blog.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class Knife4jConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("ShineBlog 后端接口文档")
.contact(new Contact().name("ShineAcZ").email("your@email.com"))
.version("1.0")
.description("基于 Spring Boot 3 + JWT + Redis 的博客系统接口文档"));
}
}

通过官方文档#

使用方法:

@RestController
@RequestMapping("body")
@Tag(name = "body参数")
public class BodyController {
@Operation(summary = "普通body请求")
@PostMapping("/body")
public ResponseEntity<FileResp> body(@RequestBody FileResp fileResp){
return ResponseEntity.ok(fileResp);
}
@Operation(summary = "普通body请求+Param+Header+Path")
@Parameters({
@Parameter(name = "id",description = "文件id",in = ParameterIn.PATH),
@Parameter(name = "token",description = "请求token",required = true,in = ParameterIn.HEADER),
@Parameter(name = "name",description = "文件名称",required = true,in=ParameterIn.QUERY)
})
@PostMapping("/bodyParamHeaderPath/{id}")
public ResponseEntity<FileResp> bodyParamHeaderPath(@PathVariable("id") String id,@RequestHeader("token") String token, @RequestParam("name")String name,@RequestBody FileResp fileResp){
fileResp.setName(fileResp.getName()+",receiveName:"+name+",token:"+token+",pathID:"+id);
return ResponseEntity.ok(fileResp);
}
}

访问Knife4j的文档地址:http://ip:port/doc.html 即可查看文档 由于我配置了

server:
port: 8080
servlet:
context-path: /api

所以要访问:http://ip:port/api/doc.html

配置mybatis-plus的逻辑删除#

实现JWT功能:#

编写JwtUtils工具类#

@Component
public class JwtUtils {
// 秘钥:确保足够长
private final String SECRET_STR = "your-super-secret-key-at-least-32-characters-long";
private final SecretKey KEY = Keys.hmacShaKeyFor(SECRET_STR.getBytes(StandardCharsets.UTF_8));
private final long EXPIRATION = 86400000; // 24小时
// 生成 Token:传入用户唯一 ID
public String createToken(Long userId) {
return Jwts.builder()
.setSubject(String.valueOf(userId)) // 将 ID 转为字符串存入 Subject
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
.signWith(KEY, SignatureAlgorithm.HS256)
.compact();
}
// 解析 Token 获取用户 ID
public Long getUserId(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(KEY)
.build()
.parseClaimsJws(token)
.getBody();
return Long.parseLong(claims.getSubject()); // 返回 Long 类型 ID
}
}

编写Jwt过滤器#

@Component
public class JwtFilter extends OncePerRequestFilter {
@Autowired
private JwtUtils jwtUtils;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 获取请求头中的Authorization
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
// 解析出用户 ID
Long userId = jwtUtils.getUserId(token);
// 将 ID 存入认证对象
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
userId, null, new ArrayList<>());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
// Token 无效或过期,不处理,Security 会自动拦截
}
}
filterChain.doFilter(request, response);
}
}

SecurityContextHolder.getContext().setAuthentication(auth); 是设置安全上下文

  • 将创建的认证对象设置到 Spring Security 的上下文中
  • 后续的代码可以通过 SecurityContextHolder 获取当前认证用户的信息
  • SecurityContextHolder 使用 ThreadLocal,每个请求线程独立

流程:

  1. 检查请求头 → 2. 提取 token → 3. 解析验证 token → 4. 创建认证对象 → 5. 设置安全上下文 → 6. 继续执行

实现注册功能:#

  1. 使用邮箱 密码 验证密码注册,通过邮箱发送验证码校验。
  2. 注册必须输入用户名,用户名不要重复(后端手动校验)。
  3. 密码至少8位 至少一位字母
  4. 邮箱要校验是否合法
  5. 密码不能过长,20位以内
  6. 校验验证码是否正确

配置邮箱功能#

1. 开启 QQ 邮箱 SMTP 服务(关键)#

  1. 登录网页版 QQ 邮箱。
  2. 点击顶部 设置 -> 账户
  3. 向下滚动找到 POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务
  4. 开启 POP3/SMTP服务
  5. 按提示发送短信验证,你会得到一串 16位授权码(记下来,下面配置要用)。

2. 添加依赖#

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>

3. 添加邮箱配置#

spring:
# Redis 配置
data:
redis:
host: localhost # 或者是你的 Redis 服务器 IP
port: 6379
database: 0
# 邮箱配置
mail:
host: smtp.qq.com
username: 12345678@qq.com # 你的完整 QQ 邮箱
password: xxxxxxxxxxxxxxxx # 注意!这里填第一步获取的【授权码】,不是QQ密码
default-encoding: UTF-8
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true

4. 编写邮箱服务类#

@Service
public class EmailService {
@Autowired
private JavaMailSender mailSender;
// 发送人需与配置文件一致
@Value("${spring.mail.username}")
private String from;
/**
* 发送纯文本验证码
* @param to 接收者邮箱
* @param code 验证码
*/
public void sendCode(String to, String code) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(from);
message.setTo(to);
message.setSubject("【你的App名称】注册验证码");
message.setText("尊敬的用户,您的注册验证码是:" + code + ",有效期为5分钟。请勿泄露给他人。");
mailSender.send(message);
}
}

5. 编写注册功能#

需要封装Dto对象

需要获取Redis那边存的验证码 代码见下方接口层(使用一样的key获取)

@Override
public User register(UserRegisterDTO userRegisterDTO) {
// 1. 校验
if (userRegisterDTO == null) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "参数不能为空");
}
if (StrUtil.hasBlank(userRegisterDTO.getEmail(),userRegisterDTO.getPassword(),userRegisterDTO.getCheckPassword(),userRegisterDTO.getCode())){
throw new BusinessException(ErrorCode.PARAM_ERROR, "参数不能为空");
}
// 账号是否小于4位
if (userRegisterDTO.getUsername().length() < 4) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "用户名不能小于4位");
}
// 账号不大于16位
if (userRegisterDTO.getUsername().length() > 16) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "用户名不能大于16位");
}
// 用户名不能包含特殊字符
if (!Pattern.matches(RegexConstant.USERNAME_REGEX, userRegisterDTO.getUsername())) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "用户名只允许字母、数字、下划线、中文、横杠、中英文括号");
}
// 邮箱必须合法
if(!ReUtil.isMatch(RegexConstant.EMAIL_REGEX, userRegisterDTO.getEmail())){
throw new BusinessException(ErrorCode.PARAM_ERROR, "邮箱格式错误");
}
// 密码不能小于8位 不能超过20位 并且必须有至少一位字母
if(!ReUtil.isMatch(RegexConstant.PASSWORD_REGEX, userRegisterDTO.getPassword())){
throw new BusinessException(ErrorCode.PARAM_ERROR, "密码不合法");
}
// 密码和校验密码是否相同
if (!userRegisterDTO.getPassword().equals(userRegisterDTO.getCheckPassword())) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "密码和校验密码不相同");
}
// 验证码是否是6位
if (userRegisterDTO.getCode().length() != 6){
throw new BusinessException(ErrorCode.PARAM_ERROR, "验证码错误");
}
// 验证码是否正确
String key = "captcha:" + userRegisterDTO.getEmail();
//从 Redis 取出验证码
String cachedCode = redisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(cachedCode)){
throw new BusinessException(ErrorCode.CODE_EXPIRED,"验证码不存在或已过期");
}
if (!userRegisterDTO.getCode().equals(cachedCode)){
throw new BusinessException(ErrorCode.CODE_ERROR, "验证码错误");
}
// 用户名是否重复
// 检查数据库中是否重复 这里使用 count方法 QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("username", userRegisterDTO.getUsername());
long userCount = usersMapper.selectCount(queryWrapper);
if (userCount > 0) {
throw new BusinessException(ErrorCode.USER_HAS_EXISTED, "用户名已存在");
}
// 使key失效 防止重复使用
redisTemplate.delete(key);
// 2. 对密码进行加密
String encryptPassword = DigestUtils.md5DigestAsHex((SALT + userRegisterDTO.getPassword()).getBytes());
// 3. 插入数据
User user = new User();
// TODO 确认一下UUID在Flutter端是否能这样生成
user.setUserId(IdUtil.randomUUID());
user.setUsername(userRegisterDTO.getUsername());
user.setPassword(encryptPassword);
user.setEmail(userRegisterDTO.getEmail());
user.setUserRole(0);
user.setAvatarUrl("");
int result = usersMapper.insert(user);
if (result == 0) {
throw new BusinessException(ErrorCode.DATABASE_ERROR, "插入账号数据失败");
}
return user;
}

6. 编写注册接口层#

发送验证码接口

@PostMapping("/send-code")
public Result<String> sendCode(@RequestBody String email) {
// 简单校验邮箱格式(实际开发可用正则)
if (!ReUtil.isMatch(RegexConstant.EMAIL_REGEX, email)) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "邮箱格式错误");
}
// 生成 6 位随机数
String code = String.valueOf(new Random().nextInt(899999) + 100000);
// 生成 Redis 的 key,例如 "captcha:123@qq.com"
String key = "captcha:" + email;
// 【核心】存入 Redis,有效期 5 分钟
redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES);
// 发送邮件
try {
emailService.sendCode(email, code);
return ResultUtils.success("验证码已发送,请查收");
} catch (Exception e) {
e.printStackTrace();
throw new BusinessException(ErrorCode.CODE_SEND_ERROR, "邮件发送失败,请检查邮箱是否正确");
}
}

注册接口:

@Operation(summary = "用户注册", description = "校验邮箱验证码并完成账号创建")
@PostMapping("/register")
public Result<Boolean> register(@RequestBody UserRegisterDTO userRegisterDTO){
if (userRegisterDTO == null) throw new BusinessException(ErrorCode.PARAM_ERROR,"参数不能为空");
User user = userService.register(userRegisterDTO);
if (user == null) throw new BusinessException(ErrorCode.SYSTEM_ERROR,"用户注册异常");
return ResultUtils.success();
}

优化:

  1. 给ResultUtils新增了一个空方法,data返回true
  2. RedisTemplate写了一个简单的测试类确认可用
  3. 测试了一下注册功能可用
  4. 给发送验证码添加限制,每一分钟最多发一个验证码,做法:添加一个限制key 有效期1分钟,查这个是否过期。
@Operation(summary = "发送邮箱验证码", description = "验证码有效期5分钟,同一邮箱1分钟内只能发送一次")
@PostMapping("/send-code")
public Result<Boolean> sendCode(
@Parameter(description = "接收验证码的邮箱地址", required = true, example = "test@qq.com")
@RequestBody EmailCodeDTO emailCodeDTO) {
// 简单校验邮箱格式(实际开发可用正则)
if (!ReUtil.isMatch(RegexConstant.EMAIL_REGEX, emailCodeDTO.getEmail())) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "邮箱格式错误");
}
// 生成 Redis 的 key,例如 "captcha:123@qq.com"
String key = "captcha:" + emailCodeDTO.getEmail();
String limitKey = "captcha_limit:" + emailCodeDTO.getEmail();
// 判断1分钟内是否发过验证码
Long expire = redisTemplate.getExpire(limitKey, TimeUnit.SECONDS);
if (expire != null && expire > 0) {
// 还在限制期内,返回剩余时间
throw new BusinessException(ErrorCode.CODE_FREQUENT,
"请求过于频繁,请等待" + expire + "秒后再发送验证码");
}
// 生成 6 位随机数
String code = String.valueOf(new Random().nextInt(899999) + 100000);
// 【核心】存入限制key,有效期1分钟 用来标识是否可以再发送验证码
redisTemplate.opsForValue().set(limitKey, "1", 1, TimeUnit.MINUTES);
// 【核心】存入 Redis,有效期 5 分钟
redisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES);
// 发送邮件
try {
emailService.sendCode(emailCodeDTO.getEmail(), code);
return ResultUtils.success();
} catch (Exception e) {
e.printStackTrace();
throw new BusinessException(ErrorCode.CODE_SEND_ERROR, "邮件发送失败,请检查邮箱是否正确");
}
}
  1. 添加异步,
  • 异步发送: 邮件发送比较耗时(1-3秒),会让前端一直在转圈等待。
  • 解决:在 EmailService 的方法上加 @Async 注解,并在启动类加 @EnableAsync,这样接口会瞬间返回,邮件在后台慢慢发。
  • 修改后存在的问题:前端接受不到发送失败的错误了,因为controller层捕获不到service的异常
@Override
@Async
public void sendCode(String to, String code) {
String limitKey = "frequency_limit:" + to;
// 判断1分钟内是否发过验证码
Long expire = redisTemplate.getExpire(limitKey, TimeUnit.SECONDS);
if (expire != null && expire > 0) {
// 还在限制期内,返回剩余时间 并且不发送
log.info(to + "请求过于频繁,请等待" + expire + "秒后再发送验证码");
return;
}
try {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(emailProperties.getUsername());
message.setTo(to);
message.setSubject(EmailConstant.EMAIL_SUBJECT);
message.setText(EmailConstant.EMAIL_CONTENT_START + code + EmailConstant.EMAIL_CONTENT_END);
mailSender.send(message);
} catch (Exception e) {
// 记录日志,供运维排查
log.error("给用户 {} 发送邮件失败: {}", to, e.getMessage());
}
}

实现登录功能:#

  1. 使用双Token方式 核心逻辑说明: Access Token (AT):短效(如 30 分钟),用于接口调用的身份凭证,存放在内存或请求头。 Refresh Token (RT):长效(如 7 天),仅用于在 AT 过期时换取新的 AT,通常存放在数据库或 Redis 中,以便随时撤回(黑名单机制)。

  2. 完成三个接口:

    1. 普通登录接口:校验并生成双Token
    2. 刷新Token接口:使用RefreshToken换取新的Access Token
    3. 测试接口:测试是否校验通过
  3. 请求参数:

    1. 登录请求对象: 邮箱 密码
    2. 刷新请求对象: Refresh Token
  4. 响应数据:

    1. accessToken
    2. refreshToken
    3. expiresIn 过期时间
  5. 修改JwtUtil 使其可以生成两个token

  6. 修改Filter

1. 创建dto和vo#

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
// 1. 登录请求对象
@Data
@Schema(description = "用户登录请求参数")
public class LoginRequest {
@Schema(description = "用户名", example = "admin", requiredMode = Schema.RequiredMode.REQUIRED)
private String username;
@Schema(description = "密码", example = "123456", requiredMode = Schema.RequiredMode.REQUIRED)
private String password;
}
// 2. 刷新 Token 请求对象
@Data
@Schema(description = "刷新Token请求参数")
public class RefreshTokenRequest {
@Schema(description = "长效刷新令牌", example = "eyJhbGciOiJIUzI1...", requiredMode = Schema.RequiredMode.REQUIRED)
private String refreshToken;
}
// 3. 统一 Token 响应对象
@Data
@Schema(description = "认证响应信息")
public class TokenResponse {
@Schema(description = "访问令牌 (短效)", example = "eyJhbGciOiJIUzI1...")
private String accessToken;
@Schema(description = "刷新令牌 (长效)", example = "eyJhbGciOiJIUzI1...")
private String refreshToken;
@Schema(description = "过期时间(秒)", example = "1800")
private long expiresIn;
}

2. 修改JWT 工具类#

首先是要他能够支持生成两种Token AccessToken存入userId 和username RefreshToken存入userId 并且修改为获取配置文件中的参数而不是直接写死

/**
* JWT 工具类 * * @author ShineAcZ
* @date 2026/1/10
*/@Component
public class JwtUtils {
@Autowired
JwtProperties jwtProperties;
private Key key;
// 2. 初始化 Key 对象
// @PostConstruct 保证在 secret 注入后执行 @PostConstruct
public void init() {
// 使用 hmacShaKeyFor 替代 secretKeyFor
// 必须使用 UTF-8 转换,防止乱码 this.key = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8));
}
/**
* 生成 Access Token ** @param userId 用户UUID
* @param username 用户名 (放入 payload 显示用)
*/ public String createAccessToken(String userId, String username) {
return Jwts.builder()
.setSubject(userId) // 标准字段 sub 存 UUID
.claim("username", username) // 自定义字段存用户名
.claim("type", "access")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getAccessExpire()))
.signWith(key)
.compact();
}
/**
* 生成 Refresh Token * 仅存储 UUID 即可 */ public String createRefreshToken(String userId) {
return Jwts.builder()
.setSubject(userId)
.claim("type", "refresh")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getRefreshExpire()))
.signWith(key)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (Exception e) {
return false;
}
}
// 辅助方法:直接获取 UUID
public String getUserId(String token) {
return parseToken(token).getSubject();
}
}

3. 书写过滤器:#

获取请求中的token 解析出用户id并存在认证对象中,接着存到上下文里

UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
userId, null, new ArrayList<>());
SecurityContextHolder.getContext().setAuthentication(auth);

这样子需要用到时直接用下面的代码:

// 在 Controller 中获取当前用户 ID
String userId = (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();

过滤器代码:

  1. 获取请求头中的token
  2. 验证类型必须是accesss
  3. 提取用户id存入认证对象
  4. 各种错误处理
  5. 使用writeErrorResponse直接写入错误响应
  6. writeErrorResponse直接调用response对象执行setStatus 上下文信息 以及响应对象
/**
* Jwt拦截过滤器 * * @author ShineAcZ
* @date 2026/1/10
*/@Component
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
@Autowired
private JwtUtils jwtUtils;
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 获取请求头中的Authorization
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
// 解析 Token 并获取 Claims
Claims claims = jwtUtils.parseToken(token);
// 验证 Token 类型必须是 access
String tokenType = claims.get("type", String.class);
if (!"access".equals(tokenType)) {
log.warn("尝试使用非 Access Token 访问接口: type={}", tokenType);
writeErrorResponse(response, ErrorCode.TOKEN_INVALID, "请使用 Access Token 访问接口");
return;
}
// 提取用户 ID
String userId = claims.getSubject();
// 将 ID 存入认证对象
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
userId, null, new ArrayList<>());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (io.jsonwebtoken.ExpiredJwtException e) {
log.debug("Token 已过期: {}", e.getMessage());
writeErrorResponse(response, ErrorCode.TOKEN_EXPIRED);
return;
} catch (io.jsonwebtoken.SignatureException e) {
log.debug("Token 签名无效: {}", e.getMessage());
writeErrorResponse(response, ErrorCode.TOKEN_INVALID, "Token 签名无效");
return;
} catch (io.jsonwebtoken.MalformedJwtException e) {
log.debug("Token 格式错误: {}", e.getMessage());
writeErrorResponse(response, ErrorCode.TOKEN_INVALID, "Token 格式错误");
return;
} catch (Exception e) {
log.error("Token 验证失败: {}", e.getMessage(), e);
writeErrorResponse(response, ErrorCode.TOKEN_INVALID);
return;
}
}
filterChain.doFilter(request, response);
}
/**
* 写入错误响应 */ private void writeErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException {
writeErrorResponse(response, errorCode, errorCode.getDescription());
}
/**
* 写入错误响应 (自定义描述) */ private void writeErrorResponse(HttpServletResponse response, ErrorCode errorCode, String description)
throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
response.setContentType("application/json;charset=UTF-8");
Result<Void> result = new Result<>(errorCode, description);
String jsonResponse = objectMapper.writeValueAsString(result);
response.getOutputStream().write(jsonResponse.getBytes(StandardCharsets.UTF_8));
response.getOutputStream().flush();
}
}

4. 书写各种service实现和接口#

书写 login 服务:

@Override
public TokenVO login(UserLoginDTO userLoginDTO) {
// 1. 校验
if (userLoginDTO == null) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "参数不能为空");
}
if (StrUtil.hasBlank(userLoginDTO.getEmail(), userLoginDTO.getPassword())) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "参数不能为空");
}
// 邮箱必须合法
if (!ReUtil.isMatch(RegexConstant.EMAIL_REGEX, userLoginDTO.getEmail())) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "邮箱格式错误");
}
// 密码不能小于8位 不能超过20位 并且必须有至少一位字母
if (!ReUtil.isMatch(RegexConstant.PASSWORD_REGEX, userLoginDTO.getPassword())) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "密码不合法");
}
// 2. 对密码进行加密
String encryptPassword = DigestUtils.md5DigestAsHex((SALT + userLoginDTO.getPassword()).getBytes());
// 3. 查询用户是否存在
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("email", userLoginDTO.getEmail());
User user = usersMapper.selectOne(queryWrapper);
if (user == null) {
throw new BusinessException(ErrorCode.USER_NOT_FOUND, "用户不存在");
}
// 4. 密码是否正确
if (!Objects.equals(user.getPassword(), encryptPassword)) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "密码错误");
}
// 5. 生成双Token
String accessToken = jwtUtils.createAccessToken(user.getUserId());
String refreshToken = jwtUtils.createRefreshToken(user.getUserId());
long expiresIn = jwtProperties.getAccessExpire() / 3600;
// 6. redis 存储 Refresh Token
String refreshKey = RedisConstant.REFRESH_TOKEN_KEY + user.getUserId();
redisTemplate.opsForValue().set(refreshKey, refreshToken, jwtProperties.getRefreshExpire(),
java.util.concurrent.TimeUnit.MILLISECONDS);
TokenVO tokenVO = new TokenVO();
tokenVO.setAccessToken(accessToken);
tokenVO.setRefreshToken(refreshToken);
tokenVO.setExpiresIn(expiresIn);
return tokenVO;
}

书写login接口:

@Operation(summary = "用户登录", description = "使用邮箱和密码进行登录,成功后返回访问令牌对象")
@PostMapping("/login")
public Result<TokenVO> login(@RequestBody UserLoginDTO userLoginDTO) {
if (userLoginDTO == null) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "参数不能为空");
}
TokenVO tokenVO = userService.login(userLoginDTO);
return ResultUtils.success(tokenVO);
}

书写refreshToken方法:

@Override
public TokenVO refreshToken(RefreshTokenDTO refreshToken) {
// 1. 校验
if (refreshToken == null || StrUtil.isBlank(refreshToken.getRefreshToken())) {
throw new BusinessException(ErrorCode.PARAM_ERROR, "参数不能为空");
}
// 2. 解析 Refresh Token
String userId;
try {
userId = jwtUtils.parseToken(refreshToken.getRefreshToken()).getSubject();
} catch (Exception e) {
throw new BusinessException(ErrorCode.TOKEN_INVALID, "Refresh Token 无效");
}
// 3. 从 Redis 获取存储的 Refresh Token
String refreshKey = RedisConstant.REFRESH_TOKEN_KEY + userId;
String storedRefreshToken = redisTemplate.opsForValue().get(refreshKey);
if (StrUtil.isBlank(storedRefreshToken) ||
!storedRefreshToken.equals(refreshToken.getRefreshToken())) {
throw new BusinessException(ErrorCode.TOKEN_INVALID, "Refresh Token 无效");
}
// 4. 生成新的双 Token
String newAccessToken = jwtUtils.createAccessToken(userId);
String newRefreshToken = jwtUtils.createRefreshToken(userId);
long expiresIn = jwtProperties.getAccessExpire() / 3600;
// 5. 更新 Redis 中的 Refresh Token
redisTemplate.opsForValue().set(refreshKey, newRefreshToken, jwtProperties.getRefreshExpire(),
java.util.concurrent.TimeUnit.MILLISECONDS);
TokenVO tokenVO = new TokenVO();
tokenVO.setAccessToken(newAccessToken);
tokenVO.setRefreshToken(newRefreshToken);
tokenVO.setExpiresIn(expiresIn);
return tokenVO;
}

书写refreshToken接口:

    @Operation(summary = "刷新访问令牌", description = "使用刷新令牌获取新的访问令牌")
    @PostMapping("/refresh-token")
    public Result<TokenVO> refreshToken(
            @Parameter(description = "刷新令牌", required = true) @RequestBody RefreshTokenDTO refreshTokenDTO) {
        if (refreshTokenDTO == null || refreshTokenDTO.getRefreshToken().isEmpty()) {
            throw new BusinessException(ErrorCode.PARAM_ERROR, "刷新令牌不能为空");
        }
        TokenVO tokenVO = userService.refreshToken(refreshTokenDTO);
        return ResultUtils.success(tokenVO);
    }

书写logout服务:

@Override
public void logout() {
// 使用抛异常的版本,确保用户已登录(如果没登录 这里会被过滤)
String userId = jwtUtils.getUserIdFromContext();
// 删除 Refresh Token
String refreshKey = RedisConstant.REFRESH_TOKEN_KEY + userId;
try {
redisTemplate.delete(refreshKey);
log.info("用户 {} 登出成功", userId);
} catch (Exception e) {
log.error("删除 Refresh Token 失败: userId={}", userId, e);
// 继续执行,不影响登出流程
}
// 清理 SecurityContext
SecurityContextHolder.clearContext();
}

书写logout接口:

@PostMapping("/logout")
@Operation(summary = "用户登出")
public Result<Boolean> logout() {
try {
// 直接退出 如果不抛异常就直接返回成功
userService.logout();
return ResultUtils.success();
} catch (BusinessException e) {
if (e.getCode() == ErrorCode.UNAUTHORIZED.getCode()) {
return ResultUtils.error(ErrorCode.UNAUTHORIZED);
}
throw e;
}
}

5. 测试是否可用#

添加测试接口:

    @Operation(summary = "测试接口", description = "用于测试Token验证是否通过")
    @GetMapping("/hello")
    public Result<String> hello() {
        return ResultUtils.success("如果看到这个表明token验证通过");
    }
  1. 运行:
POST http://localhost:8080/api/user/login
Content-Type: application/json
{
"email": "a1223030128@163.com",
"password": "a12345678"
}

返回:

{
"code": 0,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3MTIwZTlhZS1jMjYwLTQ1YzUtOTEwNC0zZWRiODdhNmQ4YjUiLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzY4MDY2Mzc0LCJleHAiOjE3NjgwNjk5NzR9.16MK6JLtjTovdj3RFWmefFAAttxrJFtOKuPCTO2iOWw",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3MTIwZTlhZS1jMjYwLTQ1YzUtOTEwNC0zZWRiODdhNmQ4YjUiLCJ0eXBlIjoicmVmcmVzaCIsImlhdCI6MTc2ODA2NjM3NCwiZXhwIjoxNzY4NjcxMTc0fQ.wHnMiu_nlWVUkuMzu1Wve9b2bHmqnfM-Y46xQomOsmY",
"expiresIn": 3600
},
"message": "ok",
"description": ""
}
  1. 运行测试接口:
GET http://localhost:8080/api/user/hello
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3MTIwZTlhZS1jMjYwLTQ1YzUtOTEwNC0zZWRiODdhNmQ4YjUiLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzY4MDY3MDU0LCJleHAiOjE3NjgwNzA2NTR9.jtUIrNjD0CMI38vtx_E0yZA7-Q4Q0t9wy8Kt3LR19Lw

返回:

{
"code": 0,
"data": "如果看到这个表明token验证通过",
"message": "ok",
"description": ""
}
  1. 运行refresh-token接口:
POST http://localhost:8080/api/user/refresh-token
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3MTIwZTlhZS1jMjYwLTQ1YzUtOTEwNC0zZWRiODdhNmQ4YjUiLCJ0eXBlIjoicmVmcmVzaCIsImlhdCI6MTc2ODA2NzA1NCwiZXhwIjoxNzY4NjcxODU0fQ.Wrruv3z2gCygn0GZNseRB-H576FyB6Jvef-fUX5tIqI
{
"refreshToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3MTIwZTlhZS1jMjYwLTQ1YzUtOTEwNC0zZWRiODdhNmQ4YjUiLCJ0eXBlIjoicmVmcmVzaCIsImlhdCI6MTc2ODA2NzA1NCwiZXhwIjoxNzY4NjcxODU0fQ.Wrruv3z2gCygn0GZNseRB-H576FyB6Jvef-fUX5tIqI"
}

返回

{
"code": 0,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3MTIwZTlhZS1jMjYwLTQ1YzUtOTEwNC0zZWRiODdhNmQ4YjUiLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzY4MDY3MzY1LCJleHAiOjE3NjgwNzA5NjV9.OQR3bbw9BzCnBIUowjBC-zTVUaMvaY9ME_AvhbjVu0c",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3MTIwZTlhZS1jMjYwLTQ1YzUtOTEwNC0zZWRiODdhNmQ4YjUiLCJ0eXBlIjoicmVmcmVzaCIsImlhdCI6MTc2ODA2NzM2NSwiZXhwIjoxNzY4NjcyMTY1fQ.EZfgLq1Brma2foH-g3wUlWQA55tdLH2Z_aUqblrVinI",
"expiresIn": 1000
},
"message": "ok",
"description": ""
}

传入错误的token测试:

GET http://localhost:8080/api/user/hello
Authorization: Bearer eyJhbGci1iJIUzI1NiJ9.eyJzdWIiOiI3MTIwZTlhZS1jMjYwLTQ1YzUtOTEwNC0zZWRiODdhNmQ4YjUiLCJ0eXBlIjoicmVmcmVzaCIsImlhdCI6MTc2ODA2NzA1NCwiZXhwIjoxNzY4NjcxODU0fQ.Wrruv3z2gCygn0GZNseRB-H576FyB6Jvef-fUX5tIqI

返回:

{
"code": 20301,
"data": null,
"message": "Token无效",
"description": "Token 格式错误"
}

Flutter前端初始化:#

  1. 确认目前必须要的依赖有哪些
  2. 初始化一些基本的内容 比如Hive
  3. 确认使用Material Design 3风格的方法
  4. 书写基本的项目结构
  5. 配置dio
  6. 研究get使用方法
  7. 日志记录方法
  8. 书写App入口

确认依赖:#

  1. 状态管理:GetX
  2. 网络请求:Dio
  3. 本地存储:Hive
  4. 日志:logger
  5. UI:动态主题:dynamic_color
  6. 权限:permission_handler
  7. UUID生成器:uuid
  8. toast:flutter_smart_dialog
  9. 屏幕适配:flutter_screenutil

开始写一些初始化项目的代码#

  1. 引入Hive
  2. 引入i18n(使用GetX)
  3. 引入GetX
  4. 引入logger
  5. 引入骨架屏
  6. 引入Dio
  7. 引入Freezed
  8. 引入flutter_slidable
  9. 引入flutter_smart_dialog

书写初始化代码: global.dart

lib/global.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:logger/logger.dart';
import 'dart:io';
class Global {
static Future<void> init() async {
WidgetsFlutterBinding.ensureInitialized();
// 1. 初始化 Hive
await Hive.initFlutter();
await Hive.openBox('settings'); // 存储简单的配置
// await Hive.openBox<TodoEntity>('todos'); // 以后存对象用
// 2. 初始化 Log (单例)
Get.put<Logger>(
Logger(printer: PrettyPrinter(methodCount: 0, printEmojis: true)),
);
// 3. 锁定竖屏 (可选)
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
// 4. 小白条、导航栏沉浸
if (Platform.isAndroid || Platform.isIOS) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
systemNavigationBarColor: Colors.transparent,
systemNavigationBarDividerColor: Colors.transparent,
statusBarColor: Colors.transparent,
),
);
}
}
}

书写主入口代码 集成集成 DynamicColor、GetX、SmartDialog 和 i18n。 main.dart

lib/main.dart
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'common/i18n/messages.dart'; // 下面会创建
import 'global.dart';
import 'modules/home/home_binding.dart'; // 下面会创建
import 'modules/home/home_view.dart'; // 下面会创建
void main() async {
await Global.init();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
// 1. 动态取色包裹
return DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) {
return GetMaterialApp(
title: 'Cat Todo',
debugShowCheckedModeBanner: false,
// 2. 主题配置 (MD3)
theme: ThemeData(
useMaterial3: true,
colorScheme: lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.blue),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.blue, brightness: Brightness.dark),
),
themeMode: ThemeMode.system,
// 3. 国际化配置
translations: Messages(),
locale: Get.deviceLocale,
fallbackLocale: const Locale('en', 'US'),
// 4. SmartDialog 初始化
builder: FlutterSmartDialog.init(),
navigatorObservers: [FlutterSmartDialog.observer],
// 5. 路由入口
initialRoute: '/home',
getPages: [
GetPage(name: '/home', page: () => const HomeView(), binding: HomeBinding()),
],
);
},
);
}
}

封装网络请求: 集成Dio和SmaetDialog(自动Loading)+ Logger http_utils.dart

import 'package:dio/dio.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart' as getx; // 避免冲突
import 'package:logger/logger.dart';
class HttpUtils {
static final HttpUtils _instance = HttpUtils._internal();
factory HttpUtils() => _instance;
late Dio _dio;
final Logger _logger = getx.Get.find<Logger>();
HttpUtils._internal() {
_dio = Dio(
BaseOptions(
baseUrl: "http://localhost:8080/api", // 替换你的后端地址
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
),
);
// 拦截器
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
// 这里加 Token
// options.headers['Authorization'] = 'Bearer ...';
return handler.next(options);
},
onResponse: (response, handler) {
// 可以在这里统一处理 {code: 200}
return handler.next(response);
},
onError: (DioException e, handler) {
_handleError(e);
return handler.next(e);
},
),
);
}
// GET 请求
Future<dynamic> get(
String path, {
Map<String, dynamic>? params,
bool showLoading = true,
}) async {
if (showLoading) SmartDialog.showLoading(msg: 'loading...'.tr); // 结合 i18n
try {
final response = await _dio.get(path, queryParameters: params);
return response.data;
} catch (e) {
rethrow;
} finally {
if (showLoading) SmartDialog.dismiss();
}
}
// 错误处理
void _handleError(DioException e) {
String msg = "未知错误";
if (e.type == DioExceptionType.connectionTimeout) {
msg = "连接超时";
} else if (e.response != null) {
msg = "服务器异常: ${e.response?.statusCode}";
}
_logger.e(msg);
SmartDialog.showToast(msg);
}
}

国际化字典: lib/common/i18n/messages.dart

import 'package:get/get.dart';
class Messages extends Translations {
@override
Map<String, Map<String, String>> get keys => {
'zh_CN': {
'app_name': '猫猫待办',
'loading': '加载中...',
'delete': '删除',
'edit': '编辑',
'confirm_delete': '确定要删除吗?'
},
'en_US': {
'app_name': 'Cat Todo',
'loading': 'Loading...',
'delete': 'Delete',
'edit': 'Edit',
'confirm_delete': 'Are you sure to delete?'
}
};
}

如何开发一个功能模块:#

  1. 定义数据模型(仅用于测试)
// 需要先运行: flutter pub run build_runner build
import 'package:freezed_annotation/freezed_annotation.dart';
part 'todo.freezed.dart';
part 'todo.g.dart';
@freezed
class Todo with _$Todo {
const factory Todo({
required String id,
required String title,
@Default(false) bool isDone,
}) = _Todo;
factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}
  1. 生成代码 在终端运行:
Terminal window
dart run build_runner build
# 或者如果你想持续监听文件变化自动生成:
dart run build_runner watch
  1. 编写控制器
import 'package:get/get.dart';
import '../../common/models/todo.dart';
import '../../common/utils/http_utils.dart';
class HomeController extends GetxController {
// 1. 状态定义 (.obs)
var todos = <Todo>[].obs;
var isLoading = true.obs;
@override
void onInit() {
super.onInit();
fetchTodos();
}
// 2. 业务逻辑
void fetchTodos() async {
// 模拟网络请求
await Future.delayed(const Duration(seconds: 1));
// 模拟数据
todos.value = [
const Todo(id: '1', title: '学习 Flutter'),
const Todo(id: '2', title: '写 Java 后端'),
];
isLoading.value = false;
}
void removeTodo(String id) {
todos.removeWhere((element) => element.id == id);
// 可以在这里调 API 删除
// HttpUtils().post('/delete', data: {'id': id});
}
}
  1. 绑定依赖
//`lib/modules/home/home_binding.dart`
import 'package:get/get.dart';
import 'home_controller.dart';
class HomeBinding extends Bindings {
@override
void dependencies() {
// 懒加载 Controller,只有进入页面才创建
Get.lazyPut(() => HomeController());
}
}
  1. 编写界面
lib/modules/home/home_view.dart
//结合 `flutter_slidable` 和 `MD3`。
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:get/get.dart';
import 'home_controller.dart';
class HomeView extends GetView<HomeController> { // 继承 GetView 可以直接用 controller 变量
const HomeView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('app_name'.tr)), // 使用 i18n
// 监听状态变化
body: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
return ListView.builder(
itemCount: controller.todos.length,
itemBuilder: (context, index) {
final todo = controller.todos[index];
// 使用 Slidable 实现侧滑
return Slidable(
key: ValueKey(todo.id),
endActionPane: ActionPane(
motion: const ScrollMotion(),
children: [
SlidableAction(
onPressed: (_) => controller.removeTodo(todo.id),
backgroundColor: Colors.red,
icon: Icons.delete,
label: 'delete'.tr,
),
],
),
child: ListTile(
title: Text(todo.title),
leading: Checkbox(
value: todo.isDone,
onChanged: (val) {
// 更新逻辑...
},
),
),
);
},
);
}),
floatingActionButton: FloatingActionButton(
onPressed: () {
// GetX 路由跳转示例
// Get.toNamed('/add');
},
child: const Icon(Icons.add),
),
);
}
}

测试运行项目: image.png

Flutter实现登录注册功能:#

确认需求:#

  1. 书写各种实体类用于适配后端实体便于发请求和使用数据
  2. 要用上i18n多语言
  3. 需要对响应进行拦截,若code不为200,则弹窗
  4. 对请求进行拦截,加入token
  5. 布局要做屏幕适配
  6. 界面符合MD3
  7. 加入自动使用refresh_token刷新Token的功能
  8. 前端要对各种参数进行校验,确认没问题再发送到后端

1. 定义实体#

定义Result实体

import 'package:freezed_annotation/freezed_annotation.dart';
part 'result.freezed.dart';
part 'result.g.dart';
/// 统一结果返回模型
/// [code] 状态码
/// [data] 返回数据
/// [message] 返回信息
/// [description] 返回描述
@Freezed(genericArgumentFactories: true)
class Result<T> with _$Result<T> {
const factory Result({
required int code,
T? data,
required String message,
String? description,
}) = _Result;
factory Result.fromJson(
Map<String, dynamic> json,
T Function(Object? json) fromJsonT,
) => _$ResultFromJson(json, fromJsonT);
}

定义Token实体:

import 'package:freezed_annotation/freezed_annotation.dart';
part 'token.freezed.dart';
part 'token.g.dart';
@freezed
class Token with _$Token {
const factory Token({
required String accessToken,
required String refreshToken,
required int expiresIn,
}) = _Token;
factory Token.fromJson(Map<String, dynamic> json) => _$TokenFromJson(json);
}

根据后端,定义需要用到的多个DTO email_code_dto:

import 'package:freezed_annotation/freezed_annotation.dart';
part 'email_code_dto.freezed.dart';
part 'email_code_dto.g.dart';
@freezed
class EmailCodeDTO with _$EmailCodeDTO {
const factory EmailCodeDTO({
required String email,
}) = _EmailCodeDTO;
factory EmailCodeDTO.fromJson(Map<String, dynamic> json) =>
_$EmailCodeDTOFromJson(json);
}

refresh_token_dto:

import 'package:freezed_annotation/freezed_annotation.dart';
part 'refresh_token_dto.freezed.dart';
part 'refresh_token_dto.g.dart';
@freezed
class RefreshTokenDTO with _$RefreshTokenDTO {
const factory RefreshTokenDTO({
required String refreshToken,
}) = _RefreshTokenDTO;
factory RefreshTokenDTO.fromJson(Map<String, dynamic> json) =>
_$RefreshTokenDTOFromJson(json);
}

user_login_dto:

import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_login_dto.freezed.dart';
part 'user_login_dto.g.dart';
@freezed
class UserLoginDTO with _$UserLoginDTO {
const factory UserLoginDTO({
required String email,
required String password,
}) = _UserLoginDTO;
factory UserLoginDTO.fromJson(Map<String, dynamic> json) =>
_$UserLoginDTOFromJson(json);
}

user_register_dto:

import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_register_dto.freezed.dart';
part 'user_register_dto.g.dart';
@freezed
class UserRegisterDTO with _$UserRegisterDTO {
const factory UserRegisterDTO({
required String email,
required String username,
required String password,
required String checkPassword,
required String code,
}) = _UserRegisterDTO;
factory UserRegisterDTO.fromJson(Map<String, dynamic> json) =>
_$UserRegisterDTOFromJson(json);
}

2. 书写user_api#

定义类用于发送user相关api请求(这里也改了Http_utlis的部分代码)

import '../models/auth/email_code_dto.dart';
import '../models/auth/refresh_token_dto.dart';
import '../models/auth/user_login_dto.dart';
import '../models/auth/user_register_dto.dart';
import '../models/Token/token.dart';
import '../utils/http_utils.dart';
class UserApi {
static Future<void> sendCode(EmailCodeDTO params) async {
await HttpUtils().post('/user/send-code', data: params.toJson());
}
static Future<void> register(UserRegisterDTO params) async {
await HttpUtils().post('/user/register', data: params.toJson());
}
static Future<Token> login(UserLoginDTO params) async {
// HttpUtils 已自动解包,直接返回 data (Map)
var result = await HttpUtils().post('/user/login', data: params.toJson());
// result 现在就是 TokenVO 的 Map
return Token.fromJson(result);
}
static Future<Token> refreshToken(String refreshToken) async {
var result = await HttpUtils().post(
'/user/refresh-token',
data: RefreshTokenDTO(refreshToken: refreshToken).toJson(),
showLoading: false, // 静默刷新
);
return Token.fromJson(result);
}
}

3. 编写控制器(controller)和业务(service)#

思考:页面需要什么数据?需要什么操作?

登录页面:需要邮箱和密码输入框的数据、是否显示密码操作、登录操作 注册页面:需要邮箱、用户名、密码、检查密码、邮箱验证码输入框的数据,需要注册操作、发送验证码操作、发送验证码之后要有倒计时。

什么情况下发生改变(obs):

  1. 密码是否显示
  2. 加载
  3. 验证码倒计时

登录逻辑:

  1. 校验
  2. isLoading.value = true;
  3. 发送请求(如果失败将会被拦截器拦截然后弹窗)
  4. 如果成功则获取token 并保存到本地(调用saveToken)
  5. 跳转到主页

发送验证码逻辑:

  1. 判断是否在倒计时
  2. 去掉邮箱前后的空格
  3. 校验是否为空和邮箱是否合法
  4. 校验通过则调用sendCode接口发送请求
  5. 开始倒计时:
    1. 重置计数为60
    2. 清除旧倒计时(逻辑上不应该有 提高健壮性)
    3. 启动循环定时器
    4. 计时结束删掉倒计时
    remainingSeconds.value = 60;
    _timer?.cancel();
    _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
    if (remainingSeconds.value > 0) {
    remainingSeconds.value--;
    } else {
    timer.cancel();
    }
    });

注册逻辑:

  1. 邮箱 用户名 密码 校验密码 验证码校验
  2. isLoading.value = true
  3. 调用注册接口
  4. 返回登录页
Controller:#
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import '../../common/api/user_api.dart';
import '../../common/constants/regex_constants.dart';
import '../../common/models/auth/email_code_dto.dart';
import '../../common/models/auth/user_login_dto.dart';
import '../../common/models/auth/user_register_dto.dart';
import '../../common/services/user_service.dart';
class AuthController extends GetxController {
// 登录相关
final loginEmailController = TextEditingController();
final loginPasswordController = TextEditingController();
// 注册相关
final registerEmailController = TextEditingController();
final registerUsernameController = TextEditingController();
final registerPasswordController = TextEditingController();
final registerCheckPasswordController = TextEditingController();
final registerCodeController = TextEditingController();
// UI 状态
final isPasswordVisible = false.obs;
final isLoading = false.obs;
// 倒计时
final remainingSeconds = 0.obs;
Timer? _timer;
// 国际化
// 字符串使用 'key'.tr 获取
void togglePasswordVisibility() {
isPasswordVisible.value = !isPasswordVisible.value;
}
// --- 登录逻辑 ---
Future<void> login() async {
final email = loginEmailController.text.trim();
final password = loginPasswordController.text;
// 2.1 登录校验
if (email.isEmpty) {
SmartDialog.showToast('validation_email_empty'.tr);
return;
}
if (!RegExp(RegexConstants.email).hasMatch(email)) {
SmartDialog.showToast('validation_email_invalid'.tr);
return;
}
if (password.isEmpty) {
SmartDialog.showToast('validation_password_empty'.tr);
return;
}
// 密码格式校验 (要求 8-20 位,至少 1 个字母)
if (!RegExp(RegexConstants.password).hasMatch(password)) {
SmartDialog.showToast('validation_password_invalid'.tr);
return;
}
try {
isLoading.value = true;
final token = await UserApi.login(
UserLoginDTO(email: email, password: password),
);
await UserService.to.saveToken(token);
SmartDialog.showToast('validation_login_success'.tr);
Get.offAllNamed('/home');
} catch (e) {
// 错误已在 HttpUtils 统一处理弹窗,但如果需要特定逻辑可写这里
// SmartDialog.showToast(e.toString());
} finally {
isLoading.value = false;
}
}
// --- 发送验证码逻辑 ---
Future<void> sendCode() async {
if (remainingSeconds.value > 0) return; // 倒计时中
final email = registerEmailController.text.trim();
if (email.isEmpty) {
SmartDialog.showToast('validation_email_empty'.tr);
return;
}
if (!RegExp(RegexConstants.email).hasMatch(email)) {
SmartDialog.showToast('validation_email_invalid'.tr);
return;
}
try {
await UserApi.sendCode(EmailCodeDTO(email: email));
SmartDialog.showToast(
'validation_send_code_success'.trParams({'email': email}),
);
// 开始倒计时
remainingSeconds.value = 60;
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (remainingSeconds.value > 0) {
remainingSeconds.value--;
} else {
timer.cancel();
}
});
} catch (e) {
// 错误已由 HttpUtils 处理
}
}
// --- 注册逻辑 ---
Future<void> register() async {
final email = registerEmailController.text.trim();
final username = registerUsernameController.text.trim();
final password = registerPasswordController.text;
final checkPassword = registerCheckPasswordController.text;
final code = registerCodeController.text.trim();
// 2.2 注册校验
// Email 校验同上
if (email.isEmpty || !RegExp(RegexConstants.email).hasMatch(email)) {
SmartDialog.showToast('validation_email_invalid'.tr); // 简化提示
return;
}
// 用户名校验: 2-16位
if (!RegExp(RegexConstants.username).hasMatch(username)) {
SmartDialog.showToast('validation_username_invalid'.tr);
return;
}
// 密码校验同上
if (!RegExp(RegexConstants.password).hasMatch(password)) {
SmartDialog.showToast('validation_password_invalid'.tr);
return;
}
if (password != checkPassword) {
SmartDialog.showToast('validation_password_mismatch'.tr);
return;
}
if (code.isEmpty) {
SmartDialog.showToast('validation_code_empty'.tr);
return;
}
try {
isLoading.value = true;
await UserApi.register(
UserRegisterDTO(
email: email,
username: username,
password: password,
checkPassword: checkPassword,
code: code,
),
);
SmartDialog.showToast('validation_register_success'.tr);
Get.back(); // 返回登录页
} catch (e) {
// 错误已处理
} finally {
isLoading.value = false;
}
}
@override
void onClose() {
loginEmailController.dispose();
loginPasswordController.dispose();
registerEmailController.dispose();
registerUsernameController.dispose();
registerPasswordController.dispose();
registerCheckPasswordController.dispose();
registerCodeController.dispose();
_timer?.cancel();
super.onClose();
}
}
Service:#
import 'package:cattodo_flutter/common/constants/global_constants.dart';
import 'package:cattodo_flutter/common/constants/hive_constants.dart';
import 'package:get/get.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../models/Token/token.dart';
class UserService extends GetxService {
static UserService get to => Get.find();
final _token = Rxn<Token>();
Token? get token => _token.value;
bool get hasToken => _token.value != null;
late Box _box;
// GetxService 自带的初始化生命周期,这是同步的
@override
void onInit() {
super.onInit();
_box = Hive.box(HiveConstants.authBox); // 直接获取已打开的盒子
// Token 逻辑
final tokenMap = _box.get(GlobalConstants.tokenKey);
if (tokenMap != null && tokenMap is Map) {
try {
_token.value = Token.fromJson(Map<String, dynamic>.from(tokenMap));
} catch (e) {
_box.delete(GlobalConstants.tokenKey);
}
}
}
Future<void> saveToken(Token token) async {
_token.value = token;
await _box.put(GlobalConstants.tokenKey, token.toJson());
}
Future<void> logout() async {
_token.value = null;
await _box.delete(GlobalConstants.tokenKey);
Get.offAllNamed('/login');
}
}

记得在global中初始化

// 1. 初始化 Hive
await Hive.initFlutter();
await Hive.openBox(HiveConstants.authBox); // 存储认证信息
await Hive.openBox(HiveConstants.settingsBox); // 存储简单的配置
// 2.1 初始化 Services
Get.put(UserService());
为什么UserService不使用懒加载:#
  1. 启动鉴权依赖: 在 main.dart 中,我们的路由逻辑直接依赖了 UserService 的状态:

    initialRoute: UserService.to.hasToken ? '/home' : '/login',

    这行代码在 App 启动瞬间就会执行。

  2. 异步初始化UserService 的初始化 (init()) 包含异步操作(await Hive.openBox 读取本地存储)。

    • 当前做法 (putAsync)await Global.init() 会等待 Token 读取完毕才启动 App,确保 hasToken 状态是准确的。
    • 如果改为懒加载:App 会立即启动,此时 Token 尚未从磁盘读入(异步操作没完成),hasToken 默认为 false,会导致即使用户已登录,打开 App 也会跳到登录页

4. 编写登录注册界面(view)#

登录(使用Ai):#
1. 类定义与继承#
class LoginView extends GetView<AuthController> {
const LoginView({super.key});
  • GetView<AuthController>: GetX 提供的便捷基类

    • 自动注入 AuthController,通过 controller 属性访问
    • 不需要手动 Get.find<AuthController>()
    • 当页面销毁时,控制器会自动清理(如果没有其他地方使用)
  • const 构造函数: 优化性能,让 Widget 可以被缓存复用


2. 页面结构层次#
Scaffold // 页面骨架
└─ SafeArea // 适配刘海屏/底部导航栏
└─ Center // 内容居中
└─ SingleChildScrollView // 可滚动(键盘弹出时避免溢出)
└─ Column // 垂直布局
  • SafeArea: 确保内容不被系统 UI(状态栏、底部手势条)遮挡
  • SingleChildScrollView: 当键盘弹出导致内容超出屏幕时,用户可以滚动查看
  • padding: EdgeInsets.symmetric(horizontal: 24.w):
    • 24.w 是 ScreenUtil 提供的响应式宽度单位
    • 在不同屏幕尺寸上保持比例一致

3. Logo 区域#
Icon(Icons.pets, size: 80.w, color: Theme.of(context).colorScheme.primary)
  • Theme.of(context).colorScheme.primary:
    • 使用 MD3 主题色,自动适配浅色/深色模式
    • colorScheme 是 Material Design 3 的核心配色系统
Text('Cat Todo', style: Theme.of(context).textTheme.headlineMedium?.copyWith(...))
  • textTheme.headlineMedium: MD3 预定义的文字样式(符合设计规范)
  • copyWith: 在预设样式基础上微调(加粗、改颜色)
4. 输入框 - TextField#
TextField(
controller: controller.loginEmailController, // 绑定控制器
decoration: InputDecoration(
labelText: 'login_email_hint'.tr, // 国际化文本
prefixIcon: const Icon(Icons.email_outlined),
border: const OutlineInputBorder(), // MD3 轮廓框样式
),
keyboardType: TextInputType.emailAddress, // 调起邮箱键盘
)

关键点

  • controller.loginEmailController:
    • TextEditingController 管理输入内容
    • AuthController 中定义和销毁
  • .tr: GetX 国际化扩展方法,自动根据系统语言显示对应文本
  • keyboardType: iOS/Android 会显示带 @ 符号的邮箱专用键盘

5. 响应式密码可见性切换#
Obx(() => TextField(
obscureText: !controller.isPasswordVisible.value, // 响应式控制
decoration: InputDecoration(
suffixIcon: IconButton(
icon: Icon(controller.isPasswordVisible.value
? Icons.visibility
: Icons.visibility_off),
onPressed: controller.togglePasswordVisibility,
),
),
))

Obx 的魔法

  • 响应式监听: isPasswordVisibleRxBool 类型
  • 当用户点击眼睛图标 → togglePasswordVisibility() 改变值 → Obx 自动重建 Widget
  • 无需 setState,代码更简洁

6. 登录按钮 - 带加载状态#
Obx(() => FilledButton(
onPressed: controller.isLoading.value ? null : () => controller.login(),
child: controller.isLoading.value
? CircularProgressIndicator(...) // 加载中显示转圈动画
: Text('login_btn'.tr), // 正常状态显示文字
))

交互逻辑

  1. 按钮禁用: isLoadingtrue 时,onPressed 设为 null,按钮自动变灰不可点击
  2. 视觉反馈: 显示白色小圆圈 loading 动画,告诉用户正在处理
  3. FilledButton: MD3 新组件,带填充色的强调按钮

7. 导航到注册页#
TextButton(
onPressed: () => Get.to(() => const RegisterView()),
child: Text('login_register_link'.tr),
)
  • Get.to(): GetX 路由跳转,比 Navigator.push 更简洁
  • TextButton: MD3 文本按钮,无背景色(适合次要操作)

8. 响应式设计 (ScreenUtil)#

代码中所有尺寸都使用 .w.h 后缀:

Icon(size: 80.w) // 宽度单位
SizedBox(height: 16.h) // 高度单位
padding: EdgeInsets.symmetric(horizontal: 24.w)

原理

  • 基于设计稿尺寸 Size(375, 812) (iPhone X)
  • 在不同设备上按比例缩放
  • iPhone SE 和 iPad 上都能保持视觉一致性
注册(使用Ai):#
1. 页面结构对比#
Scaffold
├─ AppBar // 新增:顶部导航栏(登录页没有)
└─ SafeArea
└─ SingleChildScrollView
└─ Column

与登录页的区别

  • AppBar(显示标题和返回按钮)
  • 没有 Center 包裹,内容靠顶部对齐
  • crossAxisAlignment: CrossAxisAlignment.stretch - 子元素横向拉伸填满宽度

2. 标题区域#
Text('register_create_account'.tr,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
)
  • headlineSmall: 比登录页的 headlineMedium 小一级
  • 副标题: 使用 onSurfaceVariant 颜色(次要文本的标准色)

3. 核心亮点 - 验证码按钮倒计时#
suffixIcon: Obx(() {
final seconds = controller.remainingSeconds.value;
return TextButton(
onPressed: seconds > 0 ? null : () => controller.sendCode(),
child: Text(
seconds > 0
? 'register_send_code_again'.trParams({'sec': '$seconds'}) // "重新发送 (60秒)"
: 'register_send_code'.tr, // "获取验证码"
),
);
}),

工作流程

  1. 初始状态 (seconds == 0):

    • 按钮可点击,显示 “获取验证码”
    • 点击后调用 controller.sendCode()
  2. 倒计时状态 (seconds > 0):

    • 按钮禁用 (onPressed: null)
    • 文字变为 “重新发送 (60秒)” → “重新发送 (59秒)” → …
    • 每秒自动更新: Obx 监听 remainingSeconds 变化
  3. 倒计时结束:

    • seconds 减到 0,按钮恢复可点击

.trParams({'sec': '$seconds'}) 的作用

// 中文 i18n 定义
'register_send_code_again': '重新发送 (@sec秒)'
// 运行时替换
.trParams({'sec': '45'}) → "重新发送 (45秒)"

4. 表单字段顺序设计#
1. 用户名输入框
2. 邮箱输入框 + 发送验证码按钮
3. 验证码输入框
4. 密码输入框(可见性切换)
5. 确认密码输入框(同步可见性)
6. 注册按钮

交互逻辑

  • 用户先输入邮箱 → 点击”获取验证码” → 60秒后才能重发
  • 防止恶意用户频繁请求验证码(前端 + 后端双重保护)

5. 密码可见性同步#
// 密码框
Obx(() => TextField(
obscureText: !controller.isPasswordVisible.value,
suffixIcon: IconButton(...), // 有切换按钮
))
// 确认密码框
Obx(() => TextField(
obscureText: !controller.isPasswordVisible.value,
// 没有 suffixIcon,跟随主密码框状态
))

用户体验细节

  • 两个密码框共享同一个 isPasswordVisible 状态
  • 用户点击第一个框的眼睛图标,两个框同时显示/隐藏密码
  • 符合用户直觉(不需要分别切换)

6. 注册按钮状态管理#
Obx(() => FilledButton(
onPressed: controller.isLoading.value ? null : () => controller.register(),
child: controller.isLoading.value
? CircularProgressIndicator(...) // 提交中
: Text('register_btn'.tr), // 正常状态
))

防重复提交

  • 点击后 isLoading 变为 true
  • 按钮禁用,显示转圈动画
  • 请求完成(成功/失败)后,finally 块中恢复 isLoading = false

7. 表单验证时机#

在 Controller 层执行(不在 View):

Future<void> register() async {
// 1. 邮箱格式校验
if (!RegExp(RegexConstants.email).hasMatch(email)) { ... }
// 2. 用户名长度校验(2-16位)
if (!RegExp(RegexConstants.username).hasMatch(username)) { ... }
// 3. 密码强度校验(8-20位,至少1个字母)
if (!RegExp(RegexConstants.password).hasMatch(password)) { ... }
// 4. 两次密码一致性校验
if (password != checkPassword) { ... }
// 5. 验证码非空校验
if (code.isEmpty) { ... }
// 通过后才发送请求
await UserApi.register(...);
}

设计原则

  • View 只负责展示,不包含业务逻辑
  • Controller 负责验证和请求,便于单元测试

8. 布局响应式适配#
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 24.h)
SizedBox(height: 32.h) // 大间距
SizedBox(height: 16.h) // 中间距
SizedBox(height: 8.h) // 小间距

间距层次

  • 32.h - 分组间距(标题与表单、表单与按钮)
  • 16.h - 字段间距(标准表单间距)
  • 8.h - 标题与副标题间距

9. 注册成功后的跳转#
// 在 AuthController.register() 中
SmartDialog.showToast('validation_register_success'.tr);
Get.back(); // 返回登录页

流程

  1. 注册成功弹出提示
  2. 自动返回登录页
  3. 用户使用新账号登录

为什么不直接跳首页

  • 安全考虑:注册后立即登录可能被利用(自动化脚本)
  • 用户习惯:大部分 App 要求注册后再次输入密码登录

5. 修改htpp_utils的代码#

目的: 使其支持自动拦截响应,如果有错误直接弹窗并且停止。 使其自动在请求中写入token 使其自动的用RefreshToken刷新token

单例模式#
class HttpUtils {
static final HttpUtils _instance = HttpUtils._internal();
factory HttpUtils() => _instance;
HttpUtils._internal() { /* 初始化 */ }
}
核心成员变量#
late Dio _dio; // HTTP 客户端
final Logger _logger; // 日志工具
bool _isRefreshing = false; // Token 刷新标志
Completer<void>? _refreshCompleter; // 并发等待器

Completer 的作用

  • 场景:3 个并发请求同时收到”Token 过期”
  • 不使用 Completer:3 个请求都去刷新 Token(浪费)
  • 使用 Completer:第 1 个请求刷新,2、3 号等待第 1 个完成后共享新 Token
Dio初始化与配置#
_dio = Dio(
BaseOptions(
baseUrl: GlobalConstants.baseUrl,
sendTimeout: const Duration(seconds: GlobalConstants.sendTimeout),
connectTimeout: const Duration(seconds: GlobalConstants.connectTimeout),
receiveTimeout: const Duration(seconds: GlobalConstants.receiveTimeout),
),
);

拦截器核心:

请求拦截器 自动添加Token#
onRequest: (options, handler) {
if (getx.Get.isRegistered<UserService>() && UserService.to.hasToken) {
options.headers['Authorization'] = 'Bearer ${UserService.to.token?.accessToken}';
}
return handler.next(options);
}
响应拦截器#
  1. 记录日志
final int? code = data['code'];
final String? message = data['message'];
final String? description = data['description'];
_logger.i('Response Code: $code, Message: $message, Description: $description');
  1. Token自动刷新
// 检测 Token 失效的错误码
if (code == 10001 || code == 20301 || code == 20302 || code == 20303) {
// 防止死循环:刷新接口本身失败时直接退出
if (response.requestOptions.path.contains('/refresh-token')) {
return handler.reject(...);
}
// 场景1:已经有其他请求在刷新,等待它完成
if (_isRefreshing) {
await _refreshCompleter?.future; // 阻塞等待
final retryResponse = await _retry(response.requestOptions);
return handler.resolve(retryResponse); // 用新 Token 重试
}
// 场景2:我是第一个发现过期的,负责刷新
_isRefreshing = true;
_refreshCompleter = Completer<void>();
try {
// 调用后端刷新接口
final newToken = await UserApi.refreshToken(refreshToken);
await UserService.to.saveToken(newToken);
_refreshCompleter?.complete(); // 通知等待的请求:成功了!
_isRefreshing = false;
// 重试原请求
final retryResponse = await _retry(response.requestOptions);
return handler.resolve(retryResponse);
} catch (e) {
_refreshCompleter?.completeError(e); // 通知等待的请求:失败了
UserService.to.logout(); // 强制退出登录
return handler.reject(...);
}
}
  1. 响应自动解包
if (code == GlobalConstants.successCode) {
response.data = data['data']; // 把 {code, message, data} 解包成 data
}
  1. 错误处理
if (code != GlobalConstants.successCode) {
final errorMsg = (description != null && description.isNotEmpty)
? description
: (message ?? 'common_error'.tr);
SmartDialog.showToast(errorMsg); // 自动弹窗提示
return handler.reject(DioException(...));
}
// 错误拦截器
onError: (DioException e, handler) {
_handleError(e);
return handler.next(e);
}
重试机制#
Future<Response<dynamic>> _retry(RequestOptions requestOptions) async {
return _dio.request<dynamic>(
requestOptions.path,
data: requestOptions.data,
queryParameters: requestOptions.queryParameters,
options: Options(
method: requestOptions.method,
headers: requestOptions.headers, // 新 Token 会在 onRequest 中自动添加
),
);
}
对外 API#

Get:

Future<dynamic> get(String path, {
Map<String, dynamic>? params,
bool showLoading = true, // 是否显示转圈动画
}) async {
if (showLoading) SmartDialog.showLoading(msg: 'common_loading'.tr);
try {
final response = await _dio.get(path, queryParameters: params);
return response.data; // 已经被拦截器解包过了
} catch (e) {
rethrow; // 抛给上层处理
} finally {
if (showLoading) SmartDialog.dismiss();
}
}

Post:

Future<dynamic> post(String path, {
dynamic data,
Map<String, dynamic>? params,
bool showLoading = true,
}) async { /* 类似 GET */ }
错误处理函数#
void _handleError(DioException e) {
String msg = "common_error".tr;
if (e.type == DioExceptionType.connectionTimeout) {
msg = "Connect Timeout";
} else if (e.error is String) {
msg = e.error as String; // 业务错误已经在 onResponse 中处理过
} else {
msg = "Server Error: ${e.response?.statusCode}";
}
_logger.e(msg);
SmartDialog.showToast(msg);
}
拦截器完整代码:#
import 'dart:async';
import 'package:cattodo_flutter/common/api/user_api.dart';
import 'package:cattodo_flutter/common/constants/global_constants.dart';
import 'package:cattodo_flutter/common/services/user_service.dart';
import 'package:dio/dio.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart' as getx; // 避免冲突
import 'package:logger/logger.dart';
class HttpUtils {
static final HttpUtils _instance = HttpUtils._internal();
factory HttpUtils() => _instance;
late Dio _dio;
final Logger _logger = getx.Get.find<Logger>();
// 刷新 Token 标志位和 Completer
bool _isRefreshing = false;
Completer<void>? _refreshCompleter;
HttpUtils._internal() {
_dio = Dio(
BaseOptions(
baseUrl: GlobalConstants.baseUrl,
sendTimeout: const Duration(seconds: GlobalConstants.sendTimeout),
connectTimeout: const Duration(seconds: GlobalConstants.connectTimeout),
receiveTimeout: const Duration(seconds: GlobalConstants.receiveTimeout),
),
);
// 拦截器
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
// 这里加 Token
if (getx.Get.isRegistered<UserService>() && UserService.to.hasToken) {
options.headers['Authorization'] =
'Bearer ${UserService.to.token?.accessToken}';
}
return handler.next(options);
},
onResponse: (response, handler) async {
// 可以在这里统一处理 {code: 200}
final data = response.data;
// 假设后端返回结构为:code, message, data
if (data is Map<String, dynamic>) {
final int? code = data['code'];
final String? message = data['message'];
final String? description = data['description'];
// 输出日志
_logger.i(
'Response Code: $code, Message: $message, Description: $description',
);
// 处理 Token 过期 / 无效 / 未授权 (10001, 20301, 20302, 20303)
if (code == 10001 ||
code == 20301 ||
code == 20302 ||
code == 20303) {
// 如果是刷新 Token 接口本身报错,直接抛出,不进行重试以免死循环
if (response.requestOptions.path.contains('/refresh-token')) {
return handler.reject(
DioException(
requestOptions: response.requestOptions,
error: message,
type: DioExceptionType.badResponse,
response: response,
),
);
}
if (_isRefreshing) {
// 正在刷新中,等待完成
try {
await _refreshCompleter?.future;
// 刷新成功后重试
final retryResponse = await _retry(response.requestOptions);
return handler.resolve(retryResponse);
} catch (e) {
// 刷新失败,跟随失败
return handler.reject(
DioException(
requestOptions: response.requestOptions,
error: 'Token refresh failed',
type: DioExceptionType.badResponse,
response: response,
),
);
}
}
// 开始刷新
_isRefreshing = true;
_refreshCompleter = Completer<void>();
try {
final refreshToken = UserService.to.token?.refreshToken;
if (refreshToken == null) {
throw Exception("No refresh token available");
}
// 调用刷新接口
final newToken = await UserApi.refreshToken(refreshToken);
// 保存新 Token
await UserService.to.saveToken(newToken);
_refreshCompleter?.complete();
_isRefreshing = false;
// 重试当前请求
final retryResponse = await _retry(response.requestOptions);
return handler.resolve(retryResponse);
} catch (e) {
_logger.e("Auto refresh token failed: $e");
_refreshCompleter?.completeError(e);
_isRefreshing = false;
// 刷新失败,强制退出
UserService.to.logout();
return handler.reject(
DioException(
requestOptions: response.requestOptions,
error: 'Session expired, please login again.',
type: DioExceptionType.badResponse,
response: response,
),
);
}
}
// 200 表示成功,视后端定义而定。
if (code != null && code != GlobalConstants.successCode) {
// 抛出业务异常
// 优先显示 description, 如果没有才显示 message
final errorMsg = (description != null && description.isNotEmpty)
? description
: (message ?? 'common_error'.tr);
SmartDialog.showToast(errorMsg);
// 构造一个 DioException 抛给上层或者直接 reject
return handler.reject(
DioException(
requestOptions: response.requestOptions,
error: errorMsg,
type: DioExceptionType.badResponse,
response: response,
),
);
} else if (code == GlobalConstants.successCode) {
// 自动解包 Result,直接返回 data 字段
// 这样上层拿到的就是业务数据,而不是 {code, message, data}
response.data = data['data'];
}
}
return handler.next(response);
},
onError: (DioException e, handler) {
_handleError(e);
return handler.next(e);
},
),
);
}
/// 重试请求
Future<Response<dynamic>> _retry(RequestOptions requestOptions) async {
final options = Options(
method: requestOptions.method,
headers: requestOptions.headers,
);
// 重试时,Token 会在 onRequest 中重新获取最新的
return _dio.request<dynamic>(
requestOptions.path,
data: requestOptions.data,
queryParameters: requestOptions.queryParameters,
options: options,
);
}
/// GET 请求
Future<dynamic> get(
String path, {
Map<String, dynamic>? params,
bool showLoading = true,
}) async {
if (showLoading) {
SmartDialog.showLoading(msg: 'common_loading'.tr); // 结合 i18n
}
try {
final response = await _dio.get(path, queryParameters: params);
return response.data;
} catch (e) {
rethrow;
} finally {
if (showLoading) SmartDialog.dismiss();
}
}
/// POST 请求
Future<dynamic> post(
String path, {
dynamic data,
Map<String, dynamic>? params,
bool showLoading = true,
}) async {
if (showLoading) SmartDialog.showLoading(msg: 'common_loading'.tr);
try {
final response = await _dio.post(
path,
data: data,
queryParameters: params,
);
return response.data;
} catch (e) {
rethrow;
} finally {
if (showLoading) SmartDialog.dismiss();
}
}
// 错误处理
void _handleError(DioException e) {
String msg = "common_error".tr;
if (e.type == DioExceptionType.connectionTimeout) {
msg = "Connect Timeout";
} else if (e.response != null) {
// 这里的 response.data 可能是后端返回的 Result JSON,也可能是一个 error page
// 如果 onResponse 已经 reject 了,这里会接收到
// 如果是业务异常,msg 已经在 onResponse 中取过了,但 DioException 中 error 属性可能就是那个 msg
if (e.error is String) {
msg = e.error as String;
} else {
msg = "Server Error: ${e.response?.statusCode}";
}
} else if (e.error != null) {
msg = e.error.toString();
}
_logger.e(msg);
SmartDialog.showToast(msg);
}
}

至此已完全写完了登录注册功能

CatTodo项目
http://www.shineacz.top/posts/cattodo项目-01/
作者
shineAcZ
发布于
2026-01-09
许可协议
CC BY 4.0