Struts2登录注册与用户信息管理:拦截器、分页与越权校验实践

简介:这份面向Java Web初学者与课程实践者的Struts2项目包,围绕登录注册及用户信息管理展开,可用于理解MVC分层、Action映射、拦截器与struts.xml配置等核心机制。系统以JSP为视图层,结合SQL Server保存用户凭证与资料,涉及表单校验、会话跟踪、权限检查及异常处理等基础环节,适合课程设计、框架入门和功能拆解练习。压缩包共74个文件,以java源码、class编译文件、xml配置、jsp页面、jar依赖及gif/jpg示意图为主,另含数据库文件与说明文档,整体约4.27MB,便于导入开发环境后对照学习。目前已有845人学习下载,读者可梳理从请求映射到数据库读写的链路,借鉴登录注册模块的配置方式、页面组织与排错思路,并借助可运行原型减少环境搭建和配置理解上的障碍,对初次接触Struts2的开发者较为友好。

1. 需求先立住:Struts2 登录注册与用户信息管理要交出的三件事

验收的时候,老师或者面试官坐下来第一件事不会是看你的包结构,而是自己点一遍:注册一个账号、登录进去、在列表里改一条数据、再退出。这三步走不通,后面写再多都是空的。Struts2 登录注册这套东西看着不新,但它把 MVC 分层、拦截器、值栈传参、表单校验、会话管理全串在一条链路上,做课设毕设选它,工作量可控、逻辑闭合,答辩时每一条都能对应到具体代码。这份方案要解决的问题很具体:一套能演示的注册校验、登录会话保持、以及用户信息的增删改查加权限区分和分页筛选。适合两类人——正在做 Struts 登录注册课程设计、需要一个能跑通的骨架的人,以及要交一份用户信息管理系统完整方案、不想在架构上临时拍脑袋的人。

2. 需求拆解与总体架构:三层结构里 Action、Service、DAO 各管什么

2.1 角色与功能清单:三条主线和一份用例表

一个用户管理系统最容易被做偏的地方,是把「用户」当成一张表来处理,结果所有功能堆在一个 Action 里,到答辩演示的时候被问一句「普通用户能不能改别人的手机号」就卡住了。正确的做法是先划角色,再按角色切功能。通常的做法是分三类:游客(未登录)、普通用户、管理员。

游客能做的只有注册和登录两条路径;普通用户登录后能看自己的资料、改自己的昵称邮箱手机号、改自己的密码;管理员在此基础上多出用户列表、条件查询、分页浏览、编辑任意用户、启用/禁用账号、重置密码、删除(软删)。功能不多,但每条都要在数据库里找到落点。

角色核心用例涉及模块验收关注点
游客注册、登录注册 Action、登录 Action重名拦截、空值与格式校验、密码不明文
普通用户查看/修改本人资料、改密码、退出个人中心 Action不能越权改他人字段
管理员列表、条件查询、分页、编辑、禁用、重置密码、删除用户管理 Action分页边界、软删除后可恢复、操作有反馈
系统未登录访问拦截、会话超时登录拦截器未登录访问后台跳登录页而非 500

这张表看着朴素,但它是后面所有设计的锚点。我在做类似的课设时一般会把它贴在文档第一页,后面每写一个 Action 方法就回来对一下,避免写着写着多出一堆没人用的接口。

2.2 包结构与一次请求的完整链路

Struts2 项目的骨架如果一开始没定好,后面改起来非常痛苦,因为它很多东西靠约定和配置串起来,包一乱,struts.xml就会变成一坨谁都看不懂的配置。我一般按职责分这么几层:

com.example.user ├── action // 接收请求,只做参数组装和结果跳转 ├── service // 业务规则:唯一性、权限、状态流转 ├── service.impl ├── dao // 只写 SQL 和结果集映射 ├── dao.impl ├── entity // 与表一一对应的 PO ├── vo // PageBean、查询条件、返回给页面的视图对象 ├── interceptor // 登录校验、权限校验 └── util // 密码哈希、字符串、日期

一次请求穿过的链路是这样的:浏览器表单提交 →StrutsPrepareAndExecuteFilter接管 → 进入拦截器栈 →params拦截器把请求参数按名字灌进 Action 的属性(或者 ModelDriven 的模型对象)→validation拦截器触发validateXxx()把错误塞进fieldErrors→ 校验不过直接转input结果,根本进不了 Action 方法 → 校验通过才执行 Action 的业务方法 → 调 Service → 调 DAO 拿数据 → 返回结果字符串 →struts.xml里配的result决定是转发 JSP 还是重定向到另一个 Action。

这条链路值得反复看,因为后面绝大多数坑都出在这条链路的某一环上:参数没灌进去就是params的问题,校验没触发就是方法名没对上validateXxx,登录拦截没生效就是拦截器栈引用错了。理解了链路,排查就有方向,而不是靠改代码试。

2.3 技术选型:为什么这套方案还留在 Struts2

有人会问现在为什么还用 Struts2。答案很实际:课程设计和毕业设计的技术栈通常是给定的,遗留系统更不可能说换就换,而且 Struts2 在「表单 → 对象」这条路径上的自动化程度确实省事,<s:textfield name="user.username"/>提交上来直接就是嵌套属性,不用一个个getParameter。

配套我一般这样配:视图层用 JSP 加 Struts2 标签库和 JSTL,数据访问层用原生 JDBC 封一层 DAO(有 MyBatis 基础的话换成 Mapper 也可以,验证思路完全一致),数据库用 MySQL,字符集utf8mb4。这么配的好处是依赖少、启动快、每一步都能看到 SQL,答辩被追问「这条数据是怎么查出来的」时能直接翻到那段代码。

代价也要认:Struts2 的配置项多,动态方法调用、OGNL 表达式、拦截器顺序这几块都需要额外管住。下面这些配置我一般在项目初始化就一次性写死,不给自己留后悔药:

<struts> <!-- 全链路统一 UTF-8,中文乱码八成是这里没配 --> <constant name="struts.i18n.encoding" value="UTF-8"/> <!-- 生产/演示环境关掉 devMode,避免异常页把内部信息吐给前端 --> <constant name="struts.devMode" value="false"/> <!-- 关掉 DMI,防止用 !method 的方式绕过权限调用任意方法 --> <constant name="struts.enable.DynamicMethodInvocation" value="false"/> <package name="app" namespace="/" extends="struts-default"> <interceptors> <interceptor name="loginCheck" class="com.example.user.interceptor.LoginInterceptor"/> <!-- 自定义拦截器必须放在 defaultStack 之后,否则拿不到已经灌好的参数 --> <interceptor-stack name="appStack"> <interceptor-ref name="defaultStack"/> <interceptor-ref name="loginCheck"> <param name="excludeMethods">login,register,toLogin,toRegister</param> </interceptor-ref> </interceptor-stack> </interceptors> <default-interceptor-ref name="appStack"/> <global-results> <!-- 未登录统一重定向到登录页,用 redirectAction 避免刷新重复提交 --> <result name="login" type="redirectAction"> <param name="actionName">toLogin</param> </result> </global-results> </package> </struts>

三个常量的作用要分清:struts.i18n.encoding决定请求体解码方式,漏了它就是全站中文问号;struts.enable.DynamicMethodInvocation设成false是安全底线,否则形如userAction!delete的 URL 能直接打到任意公开方法上,权限形同虚设;excludeMethods里必须把登录注册这类「不需要登录就能访问」的方法排掉,少写一个就会出现「登录页自己要求先登录」的死循环。这套配置我一般直接抄到每个新项目里,改的只是拦截器类的全限定名。

3. 登录注册模块实现:从表单校验到会话保持

3.1 用户表设计:字段、约束和密码怎么存

表结构是整个系统最不该返工的部分,因为改表要连着改实体、改 SQL、改页面。先把字段定死:

CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL, -- 登录名,全局唯一 password VARCHAR(96) NOT NULL, -- 存的是加盐哈希,不是明文 salt VARCHAR(32) NOT NULL, -- 每个用户独立盐值 nickname VARCHAR(32), email VARCHAR(64), phone VARCHAR(20), role TINYINT NOT NULL DEFAULT 2, -- 1 管理员 / 2 普通用户 status TINYINT NOT NULL DEFAULT 1, -- 1 启用 / 0 禁用(软删除也用这个位) create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个设计点值得说清楚。用户名上必须建唯一索引,光靠代码里「先查再插」在两个请求同时到达时会翻车,唯一索引是最后一道保险。salt单独一列而不是拼在密码串里,是为了将来换算法时能平滑迁移。status同时承担「禁用」和「软删除」两个语义,看起来省事,但要注意列表查询默认要过滤掉status = 0,否则管理员删掉的用户会一直挂在列表里,演示时很容易被发现。

密码存储这段我一般写成工具类,不散落在 Action 里:

public final class PasswordUtil { private static final int ITERATIONS = 1000; // 迭代次数,抬高离线穷举的成本 /** 每个用户一个独立盐,长度 16 字节 */ public static String newSalt() { byte[] buf = new byte[16]; new SecureRandom().nextBytes(buf); return toHex(buf); } /** 与盐一起做多轮 SHA-256,返回十六进制串 */ public static String hash(String raw, String salt) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] data = (salt + raw).getBytes(StandardCharsets.UTF_8); for (int i = 0; i < ITERATIONS; i++) { data = md.digest(data); // 每轮把上一轮的结果重新摘要 } return toHex(data); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("SHA-256 不可用", e); } } // toHex 用项目里已有的十六进制工具方法即可 }

ITERATIONS这个参数不要图快,测试机上设 1000 次单次登录的耗时通常在毫秒级,用户无感;如果为了演示流畅调到 1,就等于把哈希退化成单次摘要,加盐的意义大半没了。校验密码时不要直接比字符串,用hash(输入的明文, 库里的salt).equals(库里的password),全程不出现明文比较。

3.2 注册:validateXxx 校验与用户名唯一性

注册的校验要分两层,一层是格式层(必填、长度、邮箱格式、两次密码一致),交给 Struts2 的校验方法;一层是业务层(用户名是否已存在),交给 Service。混在一起写会让 Action 变得越来越长。

public class UserAction extends ActionSupport implements SessionAware, ModelDriven<User> { private User model = new User(); private String confirmPassword; private Map<String, Object> session; private final UserService userService = new UserServiceImpl(); @Override public User getModel() { return model; } @Override public void setSession(Map<String, Object> session) { this.session = session; } /** 注册提交:校验已由 validateRegister 完成,这里只做业务调用 */ public String register() { userService.register(model); // 内部补盐、哈希、写默认角色 addActionMessage("注册成功,请登录"); return SUCCESS; } /** 方法名必须是 validate + 首字母大写的方法名,只会被 register() 触发 */ public void validateRegister() { if (!StringUtils.hasText(model.getUsername())) { addFieldError("username", "用户名不能为空"); } else if (model.getUsername().trim().length() < 4 || model.getUsername().trim().length() > 20) { addFieldError("username", "用户名长度需在 4 到 20 个字符之间"); } if (!StringUtils.hasText(model.getPassword()) || model.getPassword().length() < 6) { addFieldError("password", "密码至少 6 位"); } if (!model.getPassword().equals(confirmPassword)) { addFieldError("confirmPassword", "两次输入的密码不一致"); } if (StringUtils.hasText(model.getEmail()) && !model.getEmail().matches("^[\\w.+-]+@[\\w-]+\\.[\\w.]+$")) { addFieldError("email", "邮箱格式不正确"); } } }

这里有两个细节处理不好会导致「明明校验不通过,却还是插了一条脏数据」:方法名必须是validateRegister而不是validate,因为validate()对所有 Action 方法生效,你在注册页填的密码规则会连带影响登录和编辑;另外addFieldError的第一个参数是字段名,要和 JSP 里<s:textfield name="username"/>对得上,对不上错误提示就永远不显示,页面看着像没反应。

业务层的唯一性检查放在 Service 里做,并且在插入时接住唯一索引抛出的异常:

public void register(User u) { if (userDao.existsByUsername(u.getUsername().trim())) { throw new BizException("用户名已被占用,请换一个"); } String salt = PasswordUtil.newSalt(); u.setSalt(salt); u.setPassword(PasswordUtil.hash(u.getPassword(), salt)); u.setRole(2); // 注册入口一律给普通用户,别信前端传来的 role u.setStatus(1); try { userDao.insert(u); } catch (DuplicateKeyException e) { // 并发下先查后插仍可能撞唯一索引 throw new BizException("用户名已被占用,请换一个"); } }

u.setRole(2)这一行是重点:注册表单里如果留了一个隐藏的role字段,攻击者改成 1 就能直接注册出管理员。所有「权限相关字段」在服务端赋值,这是原则问题。

3.3 登录:会话写入、拦截器与退出清理

登录的逻辑不复杂,但要注意三件事:密码校验用哈希比、会话里存什么、会话固定问题。

public String login() { User db = userService.findByUsername(model.getUsername().trim()); // 统一提示语,不区分"用户不存在"和"密码错误",避免被枚举账号 if (db == null || !PasswordUtil.hash(model.getPassword(), db.getSalt()) .equals(db.getPassword())) { addActionError("用户名或密码错误"); return INPUT; } if (db.getStatus() == 0) { addActionError("该账号已被禁用,请联系管理员"); return INPUT; } session.clear(); // 登录成功先清空旧会话,防会话固定 session.put("loginUser", toSafeUser(db)); // 只存必要字段,绝不存密码和盐 session.put("loginTime", new Date()); return SUCCESS; }

toSafeUser(db)这一步是把实体转成一个精简对象,只带 id、username、nickname、role,密码和盐一律不进会话。会话存在服务端内存里,但一旦被序列化或者被调试页面打印出来,明文字段就是事故。

登录校验交给拦截器,不要在每个 Action 方法里手写if (session.get("loginUser") == null),那种写法漏一个方法就是一个口子:

public class LoginInterceptor extends AbstractInterceptor { @Override public String intercept(ActionInvocation invocation) throws Exception { Map<String, Object> session = invocation.getInvocationContext().getSession(); Object loginUser = session.get("loginUser"); if (loginUser == null) { return "login"; // 对应 struts.xml 里 global-results 的 login 结果 } return invocation.invoke(); // 放行,继续走后面的拦截器和 Action } }

AbstractInterceptor的intercept方法里,invocation.invoke()就是「把请求往下传」,不调用它就等于直接截断。退出登录只需要session.clear()然后返回redirectAction到登录页,用重定向而不是转发,这样用户按浏览器后退键也不会看到缓存里的已登录页面。

4. 用户信息管理模块实现:列表分页、编辑与越权拦截

4.1 分页查询:PageBean 与两条 SQL 的配合

列表页是整个系统里最容易被问细节的地方:「一共 137 条数据,第 14 页怎么算出来的」「最后一页删掉一条数据之后会怎样」。分页不要用SELECT *全查出来再在 Java 里截取,数据量一上来就废了,标准做法是「count 查总数 + limit 查当页」两条 SQL。

public PageBean<User> query(UserQuery q, int pageNo, int pageSize) { int total = userDao.count(q); // 页码越界时自动回退到合法范围,避免前端翻页或删除数据后出现空白页 int totalPage = Math.max(1, (total + pageSize - 1) / pageSize); pageNo = Math.min(Math.max(pageNo, 1), totalPage); int offset = (pageNo - 1) * pageSize; PageBean<User> pb = new PageBean<>(); pb.setPageNo(pageNo); pb.setPageSize(pageSize); pb.setTotal(total); pb.setRows(userDao.list(q, offset, pageSize)); return pb; }

对应的 DAO 用条件拼接加占位符,绝不拼字符串:

public List<User> list(UserQuery q, int offset, int pageSize) { StringBuilder sql = new StringBuilder( "SELECT id, username, nickname, email, phone, role, status, create_time " + "FROM sys_user WHERE status <> 0 "); // 软删除的数据默认不出现在列表里 List<Object> args = new ArrayList<>(); if (StringUtils.hasText(q.getKeyword())) { sql.append("AND (username LIKE ? OR nickname LIKE ?) "); String like = "%" + q.getKeyword().trim() + "%"; args.add(like); args.add(like); } if (q.getRole() != null) { sql.append("AND role = ? "); args.add(q.getRole()); } sql.append("ORDER BY id DESC LIMIT ?, ?"); // MySQL 的 LIMIT 下标从 0 开始 args.add(offset); args.add(pageSize); return runQuery(sql.toString(), args); // 项目里封装的 JDBC 查询方法 }

count那条 SQL 必须和list用完全一致的WHERE条件,这是最常见的不一致来源:筛选条件只加在了列表查询上,总数还是全表的,结果就是第 3 页开始全是空的。我一般把条件拼装抽成一个私有方法,两条 SQL 共用,从源头上消掉这个可能。

PageBean里除了基本字段,把hasPrev/hasNext/totalPage都做成派生属性,JSP 里用<s:if test="pb.hasNext">直接判断,页面里就不会出现pageNo + 1 > totalPage这种算错的表达式。

4.2 编辑保存:越权校验与字段白名单

用户编辑这块,最典型的翻车是「普通用户能把 URL 里的 id 改成别人的,进而改掉别人的资料」。防御手段不是前端隐藏按钮,而是服务端从会话里取当前登录用户的 id 做判定。

public String update() { User current = (User) session.get("loginUser"); if (current == null) return "login"; boolean isAdmin = current.getRole() == 1; // 非管理员只能改自己,且 id 直接以会话中的为准,忽略表单里的 id Long targetId = isAdmin ? model.getId() : current.getId(); if (targetId == null) { addActionError("参数缺失"); return INPUT; } User patch = new User(); patch.setId(targetId); patch.setNickname(trimToNull(model.getNickname())); patch.setEmail(trimToNull(model.getEmail())); patch.setPhone(trimToNull(model.getPhone())); // role / status / password 不在白名单内,只能走专门的接口改 userService.updateProfile(patch, current, isAdmin); addActionMessage("保存成功"); return SUCCESS; }

这里的关键是先把表单提交的对象转成一个「只含可改字段」的补丁对象,再交给 Service。如果直接把整个model丢给UPDATE语句,前端只要多塞一个role=1参数就提权了。这段做法看着笨,但它把「可改什么」写死在代码里,比在 JSP 里藏字段靠谱一百倍。

Service 里再做一次判定,形成双保险:管理员可以改任何人,普通用户只能改自己,且普通用户的目标对象role必须保持不变。判定失败就抛业务异常,由统一的异常处理转成提示信息,Action 里不用写一长串 if-else。

4.3 删除与状态变更:软删除、幂等和重复提交

删除分两种:管理员删用户,用软删除(status = 0),因为用户表通常被其他业务表外键引用,物理删会连带一堆麻烦;用户改密码,属于状态变更,不涉及行消失。

public String remove() { User current = (User) session.get("loginUser"); if (current.getRole() != 1) { addActionError("没有权限执行该操作"); return INPUT; } // 幂等:已经删过的再删一次也返回成功,不报错 userService.disable(model.getId()); addActionMessage("已删除"); return SUCCESS; }

disable的 SQL 是UPDATE sys_user SET status = 0 WHERE id = ? AND status <> 0,影响行数为 0 也不当成错误,这样前端重复点击删除按钮不会弹出一堆红字。同时要挡一下「管理员删掉自己」——ID 相同就直接拒绝,否则演示现场很容易把自己锁在系统外面。

重复提交这块,Struts2 自带的 token 拦截器够用:JSP 表单里加<s:token/>,Action 上挂token拦截器(或者tokenSession),连续两次提交同一个 token 的第二次会被拦下来。我一般只给「注册」「编辑保存」「删除」这三类会改数据的表单加,查询表单不加,不然翻页都会被当成重复提交。

5. 避坑与常见问题排查:Struts2 项目里最容易翻车的五处

5.1 中文乱码:表单提交到数据库全链路

现象:注册页填「张三」,列表里显示成「??」,或者干脆存成问号后再也改不回来。

原因:编码问题从来不是单点,struts.i18n.encoding、JSP 的pageEncoding、JDBC 连接串的characterEncoding、建表时的字符集,四个位置任意一个没对上就出乱码,而且表现还不一样——有的只在 GET 请求里乱,有的只在写入时乱。

解决:按链路逐个对齐。struts.xml里配struts.i18n.encoding=UTF-8;JSP 首行写<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>;JDBC 连接串带上useUnicode=true&characterEncoding=utf8;建表和建库统一utf8mb4。四处对完还乱,就去数据库里直接SELECT看存进去的是什么,能立刻区分是「写坏了」还是「读出来才坏」。

5.2 值栈取不到值:页面一片空白但代码没报错

现象:Action 里明明setUserName("abc")了,JSP 里<s:property value="userName"/>啥也不显示,或者嵌套对象取出来是 null。

原因:Struts2 的取值走值栈,根对象是当前 Action,取不到才去#session、#request里找。嵌套属性user.username要求 Action 上有getUser()且返回对象不为 null;如果 Action 用了ModelDriven,取的是getModel()返回的对象,这时候再在 Action 上写同名的getUser()会互相打架。

解决:先确认 Action 有没有对应的 getter(只有 setter 没有 getter,提交能进、回显不出来,这是很典型的半截代码);再用<s:debug/>把值栈打出来看一眼,根对象里到底有什么,一目了然。ModelDriven和普通属性二选一,别混用。

5.3 拦截器配了不生效,或者把登录页也拦了

现象:没登录直接访问/user/list居然进去了;或者打开登录页被重定向到登录页,浏览器报重定向次数过多。

原因:要么自定义拦截器没被default-interceptor-ref引用,只是定义了却没人用;要么excludeMethods里的方法名和 Action 里的方法名对不上(大小写、首字母),把登录注册也拦了;还有一种是把自定义拦截器加在了defaultStack前面,参数还没灌进去就做判断。

解决:appStack里先defaultStack再自定义拦截器;excludeMethods用逗号分隔、区分大小写,写完对着 Action 的方法签名逐个念一遍;重定向死循环优先怀疑登录方法本身被拦。改完记得重启,Struts2 的配置改动不会热加载。

5.4 表单重复提交和刷新重放

现象:注册成功之后按 F5,控制台报唯一索引冲突;编辑保存点两下,数据库里出现两条一样的记录。

原因:result用了dispatcher转发到 JSP,浏览器地址栏还是那个提交地址,刷新就重发一次 POST。

解决:所有「改数据」的 Action 成功后一律用redirectAction重定向,让地址栏变成一个新的 GET 地址;配合 token 拦截器挡住连点。这两个一起上,基本不会再有重复数据。

5.5 密码明文出现在日志和异常栈里

现象:调试的时候在 Action 里System.out.println(model),控制台把整个对象连同密码打出来了;或者异常页面上把表单参数全部回显。

原因:实体的toString()是 IDE 自动生成的,把password、salt一起拼进去了;devMode=true时 Struts2 的异常页会展示更多上下文。

解决:实体重写toString(),显式排除敏感字段;上线和答辩演示前把struts.devMode关掉;日志里打印用户对象统一走一个脱敏方法。这种事平时看不出来,一旦演示时投屏,就很尴尬。

6. 验收与答辩:三步走查表和一份能被追问的演示路线

到验收这一步,能不能过往往不取决于你多写了几个功能,而取决于演示顺序对不对、被问到细节时能不能立刻翻到对应代码。我一般按这三个步骤走查:

第一步是「冷启动走查」。清空数据库、重启服务,从注册开始一路点到最后,中间不跳步。这一步能暴露绝大多数初始化问题——建表脚本漏字段、默认管理员没插、配置文件路径写死在本机。

第二步是「异常路径走查」。故意做错事:注册重名、密码填 5 位、不登录直接敲/user/list、把编辑 URL 里的 id 改成别人的、连续点两次删除。每一条都应该得到友好提示而不是 500 页面。这一步最能体现设计深度,答辩老师也最爱问。

第三步是「数据一致性走查」。改完一条数据回列表看是否同步、删掉的用户是否从列表消失但数据库里还在(status=0)、分页在第 14 页删一条后是否还停在合法页码。走完这三步,你的系统就是可以交付的状态。

答辩陈述有个具体技巧:不要按「我用了什么技术」讲,按「一个请求进来发生了什么」讲。从浏览器提交开始,讲过滤器、拦截器、Action、Service、DAO、结果跳转,一条线讲完,中间顺手点开struts.xml和登录拦截器的代码。这条线讲顺了,老师基本不会再问「你这个架构是怎么设计的」,因为他已经听到了答案。反过来,如果一上来就念技术栈清单,很容易被追问「那你这个拦截器是怎么配的」而卡住。

演示环节要展示的东西常见追问应答要点
注册校验失败提示、重名拦截密码怎么存的展示 PasswordUtil 和 salt 列
登录错误提示统一、禁用账号被拒会话里存了什么只存 id/username/nickname/role
未登录访问跳转登录页拦截器怎么生效打开拦截器栈配置
列表条件筛选 + 分页边界总数怎么算的count 与 list 共用条件
编辑改他人 id 被拒怎么防越权从 session 取当前用户
删除软删除、重复点击为什么不用物理删外键引用 + 可恢复

从那以后我每次交这类系统,都强制自己先把这套走查跑一遍再去演示,哪怕代码一行没改——因为真正让人翻车的从来不是功能没写完,而是你以为它在跑,实际上它只在你的机器上跑过那一次。希望帮到你。

本文还有配套的精品资源,点击获取