在实际的软件开发项目中,我们经常需要处理一些看似简单,但背后逻辑复杂的“日常”任务。例如,一个用户登录后的问候语功能,标题“小庄,下次见面,请先说你好。。”就暗示了一个典型的场景:系统需要记住用户上次的访问状态,并在下次见面时给出个性化的问候。这背后涉及到会话管理、状态持久化、个性化逻辑以及前后端交互等多个技术点。很多新手开发者可能会直接在前端写死一句问候语,或者简单地从后端返回一个固定字符串,但这样做既无法实现“下次见面”的识别,也无法应对多设备登录、会话过期等真实场景。
本文将围绕如何构建一个健壮的、可复现的“下次见面问候”功能展开。我们将从最基础的会话(Session)与令牌(Token)机制讲起,逐步深入到如何在后端持久化用户访问记录,并基于此生成动态的问候语。整个过程会使用一个典型的 Spring Boot + Vue 的前后端分离项目作为示例,但核心思路适用于任何技术栈。通过本文,你将能理解用户状态跟踪的常见方案,掌握从数据库设计到接口实现的全流程,并学会处理相关的边界情况和异常。
1. 理解“下次见面”背后的技术机制:会话、令牌与状态持久化
“下次见面”这个需求,本质上是一个用户状态识别与记忆的问题。在无状态的 HTTP 协议下,服务器需要一种机制来识别连续请求是否来自同一个用户,并记住该用户的一些信息(比如上次访问时间)。
1.1 会话(Session)与 Cookie 的经典模式
在最传统的 Web 应用中,服务器使用 Session 来维持用户状态。当用户第一次访问时,服务器会创建一个唯一的 Session ID,并将其通过 Set-Cookie 头部返回给浏览器。浏览器后续的每次请求都会自动携带这个 Cookie,服务器通过解析 Cookie 中的 Session ID 来找到对应的会话数据。
JAVA
2
HttpSession session = request.getSession(true);
3
session.setAttribute("lastVisitTime", new Date());
6
Date lastVisit = (Date) session.getAttribute("lastVisitTime");
7
if (lastVisit != null) {
这种模式的优点是对于开发者透明,使用简单。但其缺点也很明显:Session 数据默认存储在服务器内存中,在集群环境下需要做 Session 共享(如使用 Redis),否则用户请求落到不同服务器就会“失忆”。此外,Cookie 可能因浏览器禁用或跨域问题而失效。
1.2 基于令牌(Token)的无状态认证
在现代前后端分离和微服务架构中,更流行使用无状态的 Token 机制,如 JWT (JSON Web Token)。用户登录成功后,服务器生成一个包含用户标识和过期时间的 Token 返回给前端。前端后续请求在 Authorization 头部携带此 Token。服务器只需验证 Token 的签名和有效性,无需在服务器端存储会话。
JAVA
2
String token = Jwts.builder()
4
.setIssuedAt(new Date())
5
.setExpiration(new Date(System.currentTimeMillis() + 3600000))
6
.signWith(SignatureAlgorithm.HS512, secretKey)
Token 的 payload 部分可以自定义一些非敏感信息,比如 lastLoginIp,但不建议将上次访问时间这类频繁变化的数据放入 JWT,因为 Token 一旦签发,在过期前其内容是不可变的。要实现“下次见面”的判断,我们需要将用户的上次活动时间持久化到数据库或缓存中。
1.3 状态持久化的设计选择
为了可靠地记录用户“上次见面”的时间,我们需要一个持久化存储。常见的选择有:
- 用户表新增字段:在
user 表中增加 last_visit_time 字段。每次用户发起有效请求(通过认证的请求)时,更新此字段。
- 优点:结构简单,直接与用户绑定。
- 缺点:频繁更新对数据库有压力,且该字段的更新可能与其他用户信息更新产生锁竞争。
- 独立的用户行为表:创建
user_activity 表,记录每次请求的日志,然后通过查询最近一条记录来获取“上次见面”时间。
- 优点:数据维度丰富,可用于审计和分析。
- 缺点:数据量增长快,查询“最近一次”需要优化(如通过索引或定时汇总)。
- 缓存(如 Redis):以
user:${userId}:lastVisit 为 key,存储时间戳。设置合理的过期时间(如 30 天)。
- 优点:读写性能极高,适合高频更新。
- 缺点:数据可能丢失(取决于 Redis 持久化策略),需要处理缓存穿透/雪崩。
对于“小庄的日常”这类以用户为中心的功能,结合使用 数据库记录核心时间点 与 缓存提供快速访问 是一种平衡的方案。我们将采用在用户表记录 last_login_time(登录时更新)和 last_active_time(每次请求更新),同时在 Redis 缓存一份活跃状态的设计。
2. 环境准备与项目结构搭建
我们将构建一个 Spring Boot 后端和一个简单的 Vue 3 前端来演示。确保你的开发环境已就绪。
2.1 后端技术栈与依赖
- JDK: 17 或以上
- 构建工具: Maven 3.6+
- 主要框架: Spring Boot 3.x
- 数据库: MySQL 8.0,并安装 Redis 7.x 作为缓存。
- IDE: IntelliJ IDEA 或 VS Code
创建一个新的 Spring Boot 项目,或在你现有项目的 pom.xml 中添加以下关键依赖:
XML
4
<groupId>org.springframework.boot</groupId>
5
<artifactId>spring-boot-starter-web</artifactId>
9
<groupId>org.springframework.boot</groupId>
10
<artifactId>spring-boot-starter-data-jpa</artifactId>
14
<groupId>com.mysql</groupId>
15
<artifactId>mysql-connector-j</artifactId>
16
<scope>runtime</scope>
20
<groupId>org.springframework.boot</groupId>
21
<artifactId>spring-boot-starter-data-redis</artifactId>
25
<groupId>io.jsonwebtoken</groupId>
26
<artifactId>jjwt-api</artifactId>
27
<version>0.11.5</version>
30
<groupId>io.jsonwebtoken</groupId>
31
<artifactId>jjwt-impl</artifactId>
32
<version>0.11.5</version>
33
<scope>runtime</scope>
36
<groupId>io.jsonwebtoken</groupId>
37
<artifactId>jjwt-jackson</artifactId>
38
<version>0.11.5</version>
39
<scope>runtime</scope>
43
<groupId>org.springframework.boot</groupId>
44
<artifactId>spring-boot-starter-validation</artifactId>
48
<groupId>org.projectlombok</groupId>
49
<artifactId>lombok</artifactId>
50
<optional>true</optional>
2.2 前端技术栈与初始化
前端我们使用 Vue 3 的 Composition API 和 Element Plus 组件库快速搭建。
BASH
2
npm create vue@latest hello-xiaozhuang
6
npm install vue-router@4 pinia axios element-plus
8
npm install @element-plus/icons-vue
2.3 数据库表设计
在 MySQL 中创建数据库 demo_xiaozhuang,并执行以下 SQL 创建用户表:
SQL
1
CREATE TABLE `sys_user` (
2
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
3
`username` varchar(64) NOT NULL COMMENT '用户名',
4
`password` varchar(255) NOT NULL COMMENT '加密后的密码',
5
`nickname` varchar(64) DEFAULT NULL COMMENT '用户昵称(如“小庄”)',
6
`last_login_time` datetime DEFAULT NULL COMMENT '上次登录时间',
7
`last_active_time` datetime DEFAULT NULL COMMENT '上次活跃时间(任何请求)',
8
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
9
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
11
UNIQUE KEY `uk_username` (`username`)
12
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统用户表';
15
INSERT INTO `sys_user` (`username`, `password`, `nickname`) VALUES
16
('zhuang', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTV6UiC', '小庄');
注意:生产环境的密码必须使用 BCryptPasswordEncoder 这类强哈希算法加密,切勿明文存储。上述插入语句中的密文仅用于测试。
2.4 后端项目结构
创建完成后,你的后端项目核心结构应类似如下:
TEXT
1
src/main/java/com/example/demo/
2
├── DemoApplication.java
4
│ ├── JwtConfig.java # JWT 配置类
5
│ ├── RedisConfig.java # Redis 序列化配置
6
│ └── WebConfig.java # 跨域等 Web 配置
8
│ ├── AuthController.java # 认证相关接口(登录、登出)
9
│ └── UserController.java # 用户信息、问候语接口
11
│ └── SysUser.java # 用户实体类,对应数据库表
13
│ └── UserRepository.java # 用户数据访问层(JPA)
15
│ ├── AuthService.java # 认证服务
16
│ ├── UserService.java # 用户服务
19
│ ├── LoginRequest.java # 登录请求 DTO
20
│ ├── LoginResponse.java # 登录响应 DTO
21
│ └── UserProfile.java # 用户信息 DTO
23
│ └── JwtUtil.java # JWT 工具类
25
└── JwtInterceptor.java # JWT 令牌拦截器
3. 核心代码实现:从登录到生成问候语
现在,我们开始实现核心逻辑。整个过程分为:用户登录并颁发 Token、拦截器验证 Token 并更新活跃时间、提供接口返回个性化问候语。
3.1 JWT 工具类与配置
首先创建 JWT 工具类,用于生成和解析 Token。
JAVA
2
package com.example.demo.util;
4
import io.jsonwebtoken.*;
5
import io.jsonwebtoken.security.Keys;
6
import org.springframework.beans.factory.annotation.Value;
7
import org.springframework.stereotype.Component;
8
import javax.crypto.SecretKey;
12
public class JwtUtil {
14
@Value("${jwt.secret}")
15
private String secret;
16
@Value("${jwt.expiration}")
17
private Long expiration;
19
private SecretKey getSigningKey() {
21
return Keys.hmacShaKeyFor(secret.getBytes());
25
public String generateToken(String userId) {
26
Date now = new Date();
27
Date expiryDate = new Date(now.getTime() + expiration);
31
.setExpiration(expiryDate)
32
.signWith(getSigningKey(), SignatureAlgorithm.HS512)
37
public String getUserIdFromToken(String token) {
38
Claims claims = Jwts.parserBuilder()
39
.setSigningKey(getSigningKey())
41
.parseClaimsJws(token)
43
return claims.getSubject();
47
public boolean validateToken(String token) {
49
Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token);
51
} catch (JwtException | IllegalArgumentException e) {
在 application.yml 中配置 JWT 参数和数据库连接:
YAML
7
url: jdbc:mysql://localhost:3306/demo_xiaozhuang?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
9
password: your_password
10
driver-class-name: com.mysql.cj.jdbc.Driver
25
secret: "YourSuperSecretKeyHereMakeItLongAndComplexEnoughForHS512"
3.2 用户实体与数据访问层
创建用户实体类,并使用 JPA 进行映射。
JAVA
2
package com.example.demo.entity;
5
import org.hibernate.annotations.CreationTimestamp;
6
import org.hibernate.annotations.UpdateTimestamp;
7
import javax.persistence.*;
8
import java.time.LocalDateTime;
12
@Table(name = "sys_user")
13
public class SysUser {
15
@GeneratedValue(strategy = GenerationType.IDENTITY)
18
@Column(nullable = false, unique = true, length = 64)
19
private String username;
21
@Column(nullable = false, length = 255)
22
private String password;
25
private String nickname;
27
@Column(name = "last_login_time")
28
private LocalDateTime lastLoginTime;
30
@Column(name = "last_active_time")
31
private LocalDateTime lastActiveTime;
34
@Column(name = "create_time", updatable = false)
35
private LocalDateTime createTime;
38
@Column(name = "update_time")
39
private LocalDateTime updateTime;
创建对应的 Repository 接口。
JAVA
2
package com.example.demo.repository;
4
import com.example.demo.entity.SysUser;
5
import org.springframework.data.jpa.repository.JpaRepository;
6
import java.util.Optional;
8
public interface UserRepository extends JpaRepository<SysUser, Long> {
9
Optional<SysUser> findByUsername(String username);
3.3 认证服务与登录接口
实现登录逻辑,验证密码,更新登录时间,并生成 JWT Token。
JAVA
2
package com.example.demo.service.impl;
4
import com.example.demo.dto.LoginRequest;
5
import com.example.demo.dto.LoginResponse;
6
import com.example.demo.entity.SysUser;
7
import com.example.demo.repository.UserRepository;
8
import com.example.demo.service.AuthService;
9
import com.example.demo.util.JwtUtil;
10
import lombok.RequiredArgsConstructor;
11
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
12
import org.springframework.stereotype.Service;
13
import org.springframework.transaction.annotation.Transactional;
14
import java.time.LocalDateTime;
15
import java.util.Optional;
18
@RequiredArgsConstructor
19
public class AuthServiceImpl implements AuthService {
20
private final UserRepository userRepository;
21
private final JwtUtil jwtUtil;
22
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
26
public LoginResponse login(LoginRequest request) {
28
Optional<SysUser> userOpt = userRepository.findByUsername(request.getUsername());
29
if (userOpt.isEmpty()) {
30
throw new RuntimeException("用户名或密码错误");
32
SysUser user = userOpt.get();
35
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
36
throw new RuntimeException("用户名或密码错误");
40
LocalDateTime now = LocalDateTime.now();
41
user.setLastLoginTime(now);
42
user.setLastActiveTime(now);
43
userRepository.save(user);
46
String token = jwtUtil.generateToken(String.valueOf(user.getId()));
49
LoginResponse response = new LoginResponse();
50
response.setToken(token);
51
response.setUserId(user.getId());
52
response.setNickname(user.getNickname());
对应的 DTO 和 Controller。
JAVA
2
package com.example.demo.dto;
5
import javax.validation.constraints.NotBlank;
8
public class LoginRequest {
9
@NotBlank(message = "用户名不能为空")
10
private String username;
11
@NotBlank(message = "密码不能为空")
12
private String password;
JAVA
2
package com.example.demo.controller;
4
import com.example.demo.dto.LoginRequest;
5
import com.example.demo.dto.LoginResponse;
6
import com.example.demo.service.AuthService;
7
import lombok.RequiredArgsConstructor;
8
import org.springframework.validation.annotation.Validated;
9
import org.springframework.web.bind.annotation.PostMapping;
10
import org.springframework.web.bind.annotation.RequestBody;
11
import org.springframework.web.bind.annotation.RequestMapping;
12
import org.springframework.web.bind.annotation.RestController;
15
@RequestMapping("/api/auth")
16
@RequiredArgsConstructor
17
public class AuthController {
18
private final AuthService authService;
20
@PostMapping("/login")
21
public LoginResponse login(@Validated @RequestBody LoginRequest request) {
22
return authService.login(request);
3.4 JWT 拦截器与活跃时间更新
这是实现“下次见面”判断的核心。我们需要一个拦截器,在每次认证通过的请求中,更新用户的 last_active_time。
JAVA
2
package com.example.demo.interceptor;
4
import com.example.demo.service.UserService;
5
import com.example.demo.util.JwtUtil;
6
import lombok.RequiredArgsConstructor;
7
import org.springframework.stereotype.Component;
8
import org.springframework.web.servlet.HandlerInterceptor;
9
import javax.servlet.http.HttpServletRequest;
10
import javax.servlet.http.HttpServletResponse;
13
@RequiredArgsConstructor
14
public class JwtInterceptor implements HandlerInterceptor {
15
private final JwtUtil jwtUtil;
16
private final UserService userService;
19
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
21
String authHeader = request.getHeader("Authorization");
22
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
25
if (request.getRequestURI().startsWith("/api/private/")) {
26
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
32
String token = authHeader.substring(7);
35
if (!jwtUtil.validateToken(token)) {
36
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
41
String userId = jwtUtil.getUserIdFromToken(token);
44
request.setAttribute("userId", userId);
48
userService.updateLastActiveTime(Long.valueOf(userId));
注册拦截器到 Spring MVC。
JAVA
2
package com.example.demo.config;
4
import com.example.demo.interceptor.JwtInterceptor;
5
import lombok.RequiredArgsConstructor;
6
import org.springframework.context.annotation.Configuration;
7
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
8
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
11
@RequiredArgsConstructor
12
public class WebConfig implements WebMvcConfigurer {
13
private final JwtInterceptor jwtInterceptor;
16
public void addInterceptors(InterceptorRegistry registry) {
18
registry.addInterceptor(jwtInterceptor)
19
.addPathPatterns("/api/private/**")
20
.excludePathPatterns("/api/auth/**");
UserService 中实现更新活跃时间的方法。
JAVA
3
@RequiredArgsConstructor
4
public class UserServiceImpl implements UserService {
5
private final UserRepository userRepository;
9
public void updateLastActiveTime(Long userId) {
11
userRepository.findById(userId).ifPresent(user -> {
12
user.setLastActiveTime(LocalDateTime.now());
13
userRepository.save(user);
19
public String generateGreeting(Long userId) {
20
SysUser user = userRepository.findById(userId)
21
.orElseThrow(() -> new RuntimeException("用户不存在"));
22
LocalDateTime lastActive = user.getLastActiveTime();
23
LocalDateTime now = LocalDateTime.now();
25
if (lastActive == null) {
27
return String.format("%s,欢迎你首次到来!", user.getNickname());
30
long hoursBetween = Duration.between(lastActive, now).toHours();
32
if (hoursBetween < 1) {
33
greeting = "刚刚才见过,又想我啦?";
34
} else if (hoursBetween < 24) {
35
greeting = String.format("距离上次见面才过去 %d 小时。", hoursBetween);
36
} else if (hoursBetween < 24 * 7) {
37
greeting = String.format("哇,有 %d 天没见了!", hoursBetween / 24);
39
greeting = "好久不见,甚是想念!";
41
return String.format("%s,%s", user.getNickname(), greeting);
3.5 提供问候语接口
最后,创建一个受保护的接口,返回个性化的问候语。
JAVA
2
package com.example.demo.controller;
4
import com.example.demo.service.UserService;
5
import lombok.RequiredArgsConstructor;
6
import org.springframework.web.bind.annotation.GetMapping;
7
import org.springframework.web.bind.annotation.RequestAttribute;
8
import org.springframework.web.bind.annotation.RequestMapping;
9
import org.springframework.web.bind.annotation.RestController;
12
@RequestMapping("/api/private/user")
13
@RequiredArgsConstructor
14
public class UserController {
15
private final UserService userService;
17
@GetMapping("/greeting")
18
public String getGreeting(@RequestAttribute("userId") Long userId) {
20
return userService.generateGreeting(userId);
4. 前端实现与联调测试
后端完成后,我们实现一个简单的前端页面进行测试。
4.1 前端登录与 Token 管理
在 Vue 项目中,创建 src/utils/request.js 封装 Axios,并自动携带 Token。
JAVASCRIPT
2
import axios from 'axios';
3
import { ElMessage } from 'element-plus';
4
import router from '../router';
6
const service = axios.create({
7
baseURL: 'http://localhost:8080/api',
12
service.interceptors.request.use(
14
const token = localStorage.getItem('token');
16
config.headers['Authorization'] = `Bearer ${token}`;
21
return Promise.reject(error);
26
service.interceptors.response.use(
32
if (error.response.status === 401) {
33
ElMessage.error('登录已过期,请重新登录');
34
localStorage.removeItem('token');
35
router.push('/login');
37
ElMessage.error(error.response.data?.message || '请求失败');
40
ElMessage.error('网络错误或服务器无响应');
42
return Promise.reject(error);
46
export default service;
创建登录页面 src/views/Login.vue。
VUE
1
<!-- file: src/views/Login.vue -->
3
<div class="login-container">
4
<el-card class="login-card">
6
<el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="handleLogin">
7
<el-form-item prop="username">
8
<el-input v-model="form.username" placeholder="请输入用户名" prefix-icon="User" />
10
<el-form-item prop="password">
11
<el-input v-model="form.password" type="password" placeholder="请输入密码" prefix-icon="Lock" show-password />
14
<el-button type="primary" @click="handleLogin" :loading="loading" style="width:100%">登录</el-button>
22
import { ref, reactive } from 'vue';
23
import { useRouter } from 'vue-router';
24
import { ElMessage } from 'element-plus';
25
import request from '@/utils/request';
27
const router = useRouter();
28
const formRef = ref();
29
const loading = ref(false);
31
const form = reactive({
37
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
38
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
41
const handleLogin = async () => {
42
await formRef.value.validate();
45
const res = await request.post('/auth/login', form);
46
localStorage.setItem('token', res.token);
47
localStorage.setItem('nickname', res.nickname);
48
ElMessage.success('登录成功');
51
// 错误已在 request.js 中统一处理
53
loading.value = false;
61
justify-content: center;
64
background-color: #f5f7fa;
4.2 主页与问候语展示
创建主页 src/views/Home.vue,在页面加载时调用问候语接口。
VUE
1
<!-- file: src/views/Home.vue -->
3
<div class="home-container">
4
<el-card class="greeting-card">
5
<h1>{{ greeting }}</h1>
7
<el-button @click="refreshGreeting" :loading="loading">刷新问候语</el-button>
8
<el-button type="danger" @click="handleLogout">退出登录</el-button>
14
import { ref, onMounted } from 'vue';
15
import { useRouter } from 'vue-router';
16
import { ElMessage } from 'element-plus';
17
import request from '@/utils/request';
19
const router = useRouter();
20
const greeting = ref('');
21
const loading = ref(false);
23
const fetchGreeting = async () => {
26
const res = await request.get('/private/user/greeting');
31
loading.value = false;
35
const refreshGreeting = () => {
39
const handleLogout = () => {
40
localStorage.removeItem('token');
41
localStorage.removeItem('nickname');
42
ElMessage.success('已退出登录');
43
router.push('/login');
配置路由 src/router/index.js。
JAVASCRIPT
2
import { createRouter, createWebHistory } from 'vue-router';
3
import Login from '@/views/Login.vue';
4
import Home from '@/views/Home.vue';
7
{ path: '/', redirect: '/login' },
8
{ path: '/login', name: 'Login', component: Login },
9
{ path: '/home', name: 'Home', component: Home },
12
const router = createRouter({
13
history: createWebHistory(),
18
router.beforeEach((to, from, next) => {
19
const token = localStorage.getItem('token');
20
if (to.name !== 'Login' && !token) {
21
next({ name: 'Login' });
22
} else if (to.name === 'Login' && token) {
23
next({ name: 'Home' });
29
export default router;
4.3 运行与验证
- 启动后端:运行 Spring Boot 的
DemoApplication 主类。确保 MySQL 和 Redis 服务已启动。
- 启动前端:在终端进入前端项目目录,执行
npm run dev。
- 测试流程:
- 访问
http://localhost:5173 (Vite 默认端口) 会自动跳转到登录页。
- 使用测试账号
zhuang / 123456 登录。
- 登录成功后跳转到主页,会显示类似“小庄,欢迎你首次到来!”的问候语。
- 点击“刷新问候语”按钮,由于活跃时间刚更新,可能会显示“小庄,刚刚才见过,又想我啦?”。
- 关闭浏览器标签页,等待一段时间(或手动修改数据库中的
last_active_time 为更早的时间),重新打开页面并登录,问候语会根据时间差变化。
5. 常见问题排查与优化实践
将功能跑通只是第一步,在实际部署和运行中,你会遇到各种问题。以下是基于此项目的常见问题排查清单和优化建议。
5.1 常见问题排查表
| 问题现象 |
可能原因 |
检查点与解决方案 |
| 登录接口返回 500 或 “用户名密码错误” |
1. 数据库连接失败。 2. 用户数据不存在或密码不匹配。 3. BCrypt 密码加密不匹配。 |
1. 检查 application.yml 中的数据库连接信息,确认 MySQL 服务运行。 2. 检查 sys_user 表中是否存在对应用户,密码密文是否正确(可使用 BCryptPasswordEncoder 重新生成)。 3. 确认登录时前端传递的密码是明文,且与注册/插入时使用的加密算法一致。 |
前端请求 /private/user/greeting 返回 401 |
1. 前端未携带 Token 或 Token 格式错误。 2. Token 已过期。 3. 后端 JWT 密钥不匹配。 |
1. 打开浏览器开发者工具的 Network 面板,查看请求头中 Authorization 是否为 Bearer <token> 格式。 2. 检查 Token 是否过期(默认24小时)。重新登录获取新 Token。 3. 确认后端 jwt.secret 在应用重启后未改变,否则旧 Token 会验证失败。 |
| 问候语始终是“首次到来”或时间差计算不对 |
1. last_active_time 字段未成功更新。 2. 时区问题导致时间计算错误。 3. 拦截器未生效或更新逻辑有误。 |
1. 检查数据库 sys_user 表的 last_active_time 字段是否在每次请求后更新。查看后端日志中 SQL 更新语句。 2. 确保数据库、后端应用服务器使用统一的时区(如 Asia/Shanghai)。在 application.yml 的数据库 URL 中指定 serverTimezone。 3. 确认请求路径匹配拦截器的 addPathPatterns,且拦截器中的 updateLastActiveTime 方法被调用。 |
| 更新活跃时间导致数据库压力大 |
每次请求都更新数据库,高并发下会成为瓶颈。 |
1. 引入缓存:将活跃时间先写入 Redis,设置一个较短的过期时间(如5分钟),然后通过定时任务批量同步到数据库。 2. 异步更新:将更新操作放入消息队列(如 RabbitMQ)或线程池,避免阻塞主请求线程。 |
| 多设备登录时,问候语以哪个时间为准? |
当前设计以最后一次活跃请求为准,所有设备共享同一个 last_active_time。 |
如果业务需要区分设备,可以在用户-设备维度再建一张表,记录每个设备的上次活跃时间。生成问候语时,取当前设备的上次活跃时间。 |
5.2 生产环境优化建议
- Token 管理增强:
- 退出登录与 Token 黑名单:当前 JWT 是无状态的,服务端无法主动使其失效。实现退出功能时,可将未过期的 Token 加入 Redis 黑名单(key 为 Token 指纹,value 为过期时间),拦截器校验时需额外检查黑名单。
- 刷新 Token:实现双 Token 机制(Access Token 短时间有效,Refresh Token 较长时间有效),避免用户频繁重新登录。
- 活跃时间更新策略优化:
- 防抖更新:在前端或拦截器层,对短时间内(如1分钟内)的连续请求,只更新一次活跃时间。
- 分级更新:对不同的接口按重要性分级,只有核心用户行为接口才更新活跃时间,减少不必要的写操作。
- 问候语逻辑扩展:
- 个性化规则:将问候语规则(时间区间对应的文案)配置化,存储在数据库或配置中心,便于运营修改。
- 结合其他数据:问候语不仅可以基于时间,还可以结合用户历史行为(如上次购买商品、上次阅读文章)来生成更精准的文案。
- 安全加固:
- 密码安全:强制密码复杂度,定期提醒修改密码,记录密码错误次数以防暴力破解。
- 接口限流:对登录、刷新 Token 等接口进行限流,防止恶意攻击。
- SQL 注入与 XSS:确保使用 JPA 的参数化查询,对用户输入的昵称等字段进行适当的过滤或转义,防止 XSS 攻击。
6. 扩展方向与总结
通过这个“小庄的日常”项目,我们完整实现了一个基于用户活跃时间的动态问候系统。它虽然是一个简单的功能,但串联了 Web 开发中多个核心概念:用户认证(JWT)、状态管理(Session/Token)、数据持久化(JPA)、缓存思想、拦截器、前后端分离交互等。
你可以在此基础上进行更多探索:
- 扩展为完整的用户中心:加入注册、个人信息修改、头像上传、消息通知等功能。
- 实现实时在线状态:利用 WebSocket,当用户连接时标记为“在线”,断开后标记为“离线”,并在问候语中体现。
- 接入更复杂的推荐系统:根据用户活跃时间、访问频率、浏览内容等,生成更加个性化的每日问候或内容推荐。
- 微服务化改造:将用户服务、认证服务、问候语服务拆分为独立的微服务,通过 Spring Cloud 或 Dubbo 进行通信,体验分布式架构下的状态管理挑战。
关键点回顾:实现“下次见面”功能,技术选型上需要在无状态 Token 的便利性与有状态数据持久化的必要性之间取得平衡。JWT 解决了认证问题,但用户动态数据(如上次活跃时间)仍需存储在中心化的数据库或缓存中。在设计更新策略时,必须仔细权衡数据一致性、实时性与系统性能之间的关系。