记录项目的文档,记录开发时的思路,方便查询。
CatTodo项目 01
需求分析
CatTodo目的是实现一个Todo计划的app,用户可以使用这个app创建todo和计划来规划自己。
与其他todo App不同的地方在于,CatTodo引入了ai功能,可以使用ai快速创建目标和任务项。
由于特殊的原因,项目需要添加一个团队功能。
项目通用功能:
- 基于JWT的登录 / 注册
项目基本功能:
- 任务项的创建
- 任务项设置循环和条件
- 前端任务项的显示
技术选型
前端: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@Slf4jpublic 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;
@Configurationpublic 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工具类
@Componentpublic 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过滤器
@Componentpublic 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,每个请求线程独立
流程:
- 检查请求头 → 2. 提取 token → 3. 解析验证 token → 4. 创建认证对象 → 5. 设置安全上下文 → 6. 继续执行
实现注册功能:
- 使用邮箱 密码 验证密码注册,通过邮箱发送验证码校验。
- 注册必须输入用户名,用户名不要重复(后端手动校验)。
- 密码至少8位 至少一位字母
- 邮箱要校验是否合法
- 密码不能过长,20位以内
- 校验验证码是否正确
配置邮箱功能
1. 开启 QQ 邮箱 SMTP 服务(关键)
- 登录网页版 QQ 邮箱。
- 点击顶部 设置 -> 账户。
- 向下滚动找到 POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务。
- 开启 POP3/SMTP服务。
- 按提示发送短信验证,你会得到一串 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: true4. 编写邮箱服务类
@Servicepublic 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获取)
@Overridepublic 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();}优化:
- 给ResultUtils新增了一个空方法,data返回true
- RedisTemplate写了一个简单的测试类确认可用
- 测试了一下注册功能可用
- 给发送验证码添加限制,每一分钟最多发一个验证码,做法:添加一个限制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-3秒),会让前端一直在转圈等待。
- 解决:在
EmailService的方法上加@Async注解,并在启动类加@EnableAsync,这样接口会瞬间返回,邮件在后台慢慢发。 - 修改后存在的问题:前端接受不到发送失败的错误了,因为controller层捕获不到service的异常
@Override@Asyncpublic 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()); }}实现登录功能:
-
使用双Token方式 核心逻辑说明: Access Token (AT):短效(如 30 分钟),用于接口调用的身份凭证,存放在内存或请求头。 Refresh Token (RT):长效(如 7 天),仅用于在 AT 过期时换取新的 AT,通常存放在数据库或 Redis 中,以便随时撤回(黑名单机制)。
-
完成三个接口:
- 普通登录接口:校验并生成双Token
- 刷新Token接口:使用RefreshToken换取新的Access Token
- 测试接口:测试是否校验通过
-
请求参数:
- 登录请求对象: 邮箱 密码
- 刷新请求对象: Refresh Token
-
响应数据:
- accessToken
- refreshToken
- expiresIn 过期时间
-
修改JwtUtil 使其可以生成两个token
-
修改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 */@Componentpublic 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 中获取当前用户 IDString userId = (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();过滤器代码:
- 获取请求头中的token
- 验证类型必须是accesss
- 提取用户id存入认证对象
- 各种错误处理
- 使用writeErrorResponse直接写入错误响应
- writeErrorResponse直接调用response对象执行setStatus 上下文信息 以及响应对象
/** * Jwt拦截过滤器 * * @author ShineAcZ * @date 2026/1/10 */@Component@Slf4jpublic 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 服务:
@Overridepublic 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 TokenString 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方法:
@Overridepublic 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服务:
@Overridepublic 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验证通过"); }- 运行:
POST http://localhost:8080/api/user/loginContent-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": ""}- 运行测试接口:
GET http://localhost:8080/api/user/helloAuthorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3MTIwZTlhZS1jMjYwLTQ1YzUtOTEwNC0zZWRiODdhNmQ4YjUiLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzY4MDY3MDU0LCJleHAiOjE3NjgwNzA2NTR9.jtUIrNjD0CMI38vtx_E0yZA7-Q4Q0t9wy8Kt3LR19Lw返回:
{ "code": 0, "data": "如果看到这个表明token验证通过", "message": "ok", "description": ""}- 运行refresh-token接口:
POST http://localhost:8080/api/user/refresh-tokenContent-Type: application/jsonAuthorization: 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/helloAuthorization: Bearer eyJhbGci1iJIUzI1NiJ9.eyJzdWIiOiI3MTIwZTlhZS1jMjYwLTQ1YzUtOTEwNC0zZWRiODdhNmQ4YjUiLCJ0eXBlIjoicmVmcmVzaCIsImlhdCI6MTc2ODA2NzA1NCwiZXhwIjoxNzY4NjcxODU0fQ.Wrruv3z2gCygn0GZNseRB-H576FyB6Jvef-fUX5tIqI返回:
{ "code": 20301, "data": null, "message": "Token无效", "description": "Token 格式错误"}Flutter前端初始化:
- 确认目前必须要的依赖有哪些
- 初始化一些基本的内容 比如Hive
- 确认使用Material Design 3风格的方法
- 书写基本的项目结构
- 配置dio
- 研究get使用方法
- 日志记录方法
- 书写App入口
确认依赖:
- 状态管理:GetX
- 网络请求:Dio
- 本地存储:Hive
- 日志:logger
- UI:动态主题:dynamic_color
- 权限:permission_handler
- UUID生成器:uuid
- toast:flutter_smart_dialog
- 屏幕适配:flutter_screenutil
开始写一些初始化项目的代码
- 引入Hive
- 引入i18n(使用GetX)
- 引入GetX
- 引入logger
- 引入骨架屏
- 引入Dio
- 引入Freezed
- 引入flutter_slidable
- 引入flutter_smart_dialog
书写初始化代码: 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
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?' } };}如何开发一个功能模块:
- 定义数据模型(仅用于测试)
// 需要先运行: flutter pub run build_runner buildimport 'package:freezed_annotation/freezed_annotation.dart';
part 'todo.freezed.dart';part 'todo.g.dart';
@freezedclass 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);}- 生成代码 在终端运行:
dart run build_runner build# 或者如果你想持续监听文件变化自动生成:dart run build_runner watch- 编写控制器
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}); }}- 绑定依赖
//`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()); }}- 编写界面
//结合 `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), ), ); }}测试运行项目:

Flutter实现登录注册功能:
确认需求:
- 书写各种实体类用于适配后端实体便于发请求和使用数据
- 要用上i18n多语言
- 需要对响应进行拦截,若code不为200,则弹窗
- 对请求进行拦截,加入token
- 布局要做屏幕适配
- 界面符合MD3
- 加入自动使用refresh_token刷新Token的功能
- 前端要对各种参数进行校验,确认没问题再发送到后端
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';
@freezedclass 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';
@freezedclass 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';
@freezedclass 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';
@freezedclass 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';
@freezedclass 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):
- 密码是否显示
- 加载
- 验证码倒计时
登录逻辑:
- 校验
- isLoading.value = true;
- 发送请求(如果失败将会被拦截器拦截然后弹窗)
- 如果成功则获取token 并保存到本地(调用saveToken)
- 跳转到主页
发送验证码逻辑:
- 判断是否在倒计时
- 去掉邮箱前后的空格
- 校验是否为空和邮箱是否合法
- 校验通过则调用sendCode接口发送请求
- 开始倒计时:
- 重置计数为60
- 清除旧倒计时(逻辑上不应该有 提高健壮性)
- 启动循环定时器
- 计时结束删掉倒计时
remainingSeconds.value = 60;_timer?.cancel();_timer = Timer.periodic(const Duration(seconds: 1), (timer) {if (remainingSeconds.value > 0) {remainingSeconds.value--;} else {timer.cancel();}});
注册逻辑:
- 邮箱 用户名 密码 校验密码 验证码校验
- isLoading.value = true
- 调用注册接口
- 返回登录页
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 初始化 ServicesGet.put(UserService());为什么UserService不使用懒加载:
-
启动鉴权依赖: 在 main.dart 中,我们的路由逻辑直接依赖了
UserService的状态:initialRoute: UserService.to.hasToken ? '/home' : '/login',这行代码在 App 启动瞬间就会执行。
-
异步初始化:
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 的魔法:
- 响应式监听:
isPasswordVisible是RxBool类型 - 当用户点击眼睛图标 →
togglePasswordVisibility()改变值 →Obx自动重建 Widget - 无需
setState,代码更简洁
6. 登录按钮 - 带加载状态
Obx(() => FilledButton( onPressed: controller.isLoading.value ? null : () => controller.login(), child: controller.isLoading.value ? CircularProgressIndicator(...) // 加载中显示转圈动画 : Text('login_btn'.tr), // 正常状态显示文字))交互逻辑:
- 按钮禁用:
isLoading为true时,onPressed设为null,按钮自动变灰不可点击 - 视觉反馈: 显示白色小圆圈 loading 动画,告诉用户正在处理
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, // "获取验证码" ), );}),工作流程:
-
初始状态 (
seconds == 0):- 按钮可点击,显示 “获取验证码”
- 点击后调用
controller.sendCode()
-
倒计时状态 (
seconds > 0):- 按钮禁用 (
onPressed: null) - 文字变为 “重新发送 (60秒)” → “重新发送 (59秒)” → …
- 每秒自动更新:
Obx监听remainingSeconds变化
- 按钮禁用 (
-
倒计时结束:
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(); // 返回登录页流程:
- 注册成功弹出提示
- 自动返回登录页
- 用户使用新账号登录
为什么不直接跳首页?
- 安全考虑:注册后立即登录可能被利用(自动化脚本)
- 用户习惯:大部分 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);}响应拦截器
- 记录日志
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自动刷新
// 检测 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(...); }}- 响应自动解包
if (code == GlobalConstants.successCode) { response.data = data['data']; // 把 {code, message, data} 解包成 data}- 错误处理
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); }}至此已完全写完了登录注册功能