news 2026/10/15 17:11:50

Struts2实战:登录注册与用户信息管理系统的搭建与避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Struts2实战:登录注册与用户信息管理系统的搭建与避坑

简介:一套基于 Struts2 的登录注册与用户信息管理系统,面向 Java Web 初学者与正在学习 MVC 架构的开发者。项目完整演示了用户注册、登录及信息维护的实现流程,涉及 Action 与 Result 映射、拦截器配置、表单验证、struts.xml 组织方式,以及通过 SQL Server 进行数据存储等关键环节,还包含登录状态跟踪与权限拦截等处理思路,能帮助读者将框架知识点落到具体页面和业务代码中。压缩包共 74 个文件,以 Java 源码、class 编译文件、JSP 页面、XML 配置文件为主,另含 SQL Server 数据库文件、jar 依赖、图片与图解文档,整体约 4.27MB,目录结构清晰,适合课程设计或 Struts2 入门实践。项目配套图解文档,能直观梳理 JSP 页面、Action 与数据库表之间的对应关系,方便模仿改造或直接用于课程设计。已有 843 人学习浏览,下载后可在 MyEclipse 环境中按文档配合源码对照学习。

1. 一个 Struts2 做的登录注册及用户信息管理系统,现在还值得折腾吗

我第一次被问这个问题是在接手一个老系统时。它后面拖着一个用了七八年的 Java Web 后台,登录、注册、用户信息维护全是 Struts2 写的,跑得比想象中稳。后来我带新人也常拿这个方向当入门练习:业务模型足够简单,又能把框架的 Action、struts.xml、拦截器、OGNL 全部串起来。这里先说个结论:Struts2 虽然在新项目里几乎不会再被选择,但读它、改它、照着写一遍登录注册和用户增删改查,在今天依然是理解 Java Web 框架演进的高性价比路径。这篇笔记就围绕这个方向,从搭建最小工程开始,一路讲到分页查询、会话保持和踩坑排错。

2. 拆开 Struts2 核心机制:Action、值栈与三大 Result 跳转

2.1 从请求到响应的一次完整旅程:拦截器栈里发生了什么

Struts2 的请求生命周期并不复杂,但它和 Servlet 时代写doGet/doPost的体验完全不同。你可以把 Struts2 看成一层包装:浏览器请求先打到StrutsPrepareAndExecuteFilter(web.xml 里配置的那个 Filter),这个 Filter 做两件事——解析 URL 找到对应的 Action 映射,然后创建 Action 实例并交给拦截器栈。

真正干活的是拦截器栈。默认的defaultStack里有十几个拦截器,我挑几个常用的讲顺序。params拦截器负责把请求参数填到 Action 的属性上,servletConfig负责把 session、request、application 这些 Servlet 对象注入到 Action 里,modelDriven负责处理 ModelDriven 接口的模型压栈。注意它们的执行顺序:modelDriven 在前,params 在后。这意味着请求参数是先被压入值栈的模型对象接收,而不是塞给 Action 自身属性。新手最容易在这里翻车,后面避坑章节会专门说。

拦截器栈执行完后,框架调用 Action 里的业务方法(默认是execute,也可以在 struts.xml 里指定method),方法返回一个字符串结果码,比如"success"、"input"、"error"。框架拿着结果码去 struts.xml 里找对应的<result>,执行跳转。最终数据都放在名为 ValueStack 的值栈里,JSP 页面通过 OGNL 表达式从中取值渲染。

2.2 struts.xml 的 Action 映射与 Result 类型选型

struts.xml 是 Struts2 的黑匣子钥匙。一个最小 Action 映射长这样:

<package name="user" namespace="/user" extends="struts-default"> <action name="register" class="com.demo.web.UserAction" method="register"> <result name="success" type="redirectAction"> <param name="actionName">list</param> <param name="namespace">/user</param> </result> <result name="input">/register.jsp</result> </action> </package>

这段配置里三个关键点。第一,namespace决定了 URL 的前缀,这里意味着访问路径是/user/register.action。访问时 action 名字后面有没有.action后缀都行,因为默认后缀就是action。第二,class指向 Action 类全限定名,框架用反射创建实例,所以必须保证类有无参构造。第三,method指定执行哪个方法,不写的话默认找execute()。

Result 的type是最容易被忽略的选型点。常见的有四种,我列个表说明区别和适用场景。

type底层行为典型场景注意事项
dispatcher服务端转发到 JSP打开列表页、编辑页URL 不变,刷新会重复提交表单
redirect重定向到任意 URL操作完成后防重复提交数据要手动通过 param 携带
redirectAction重定向到另一个 Action增删改后回到列表需要显式指定 namespace 和 actionName
chain服务端转发到另一个 Action两个 Action 共享值栈数据会将值栈整体传递,滥用容易数据混乱

我一般处理表单提交,比如注册成功后,一律用redirectAction而不是dispatcher。这样用户按 F5 刷新时会重新请求列表页,不会把同一个注册请求提交两遍。

2.3 值栈和 OGNL:JSP 里为什么能直接写属性名

如果你从 Servlet/JSP 转过来,第一眼看到<s:property value="username"/>可能会懵:这个 username 是从哪拿的?它不是 request 里的 attribute,也不是 session 里的 key,而是从值栈里按名称查找得到的。

值栈是一个有层级结构的栈,栈顶通常是 Action 实例本身,或实现了ModelDriven接口后被压入的模型对象。框架在渲染 JSP 时,OGNL 表达式会从栈顶往下找,碰到第一个同名属性就取值。所以 Action 里的getUsername()方法,页面上直接value="username"就能拿到。

跨作用域取值时,OGNL 提供前缀符号:#request.username取 request attribute,#session.loginUser取 session 里的对象,#application取应用级属性。习惯上我会把需要回显的数据放在 Action 属性里走值栈,把登录态和缓存信息放 session 里并用#session前缀取。这里有个实际经验:如果你在页面写value="user.username"却一直取不到值,先确认 Action 栈顶是不是被 ModelDriven 的 model 盖住了,优先用#request.xxx或完整对象路径去取,能少走很多弯路。

3. 从零搭建登录注册:最小工程与可复现代码

3.1 工程骨架与依赖配置:web.xml 挂载 Filter 的两种写法

先搭工程骨架。我习惯创建一个标准的 Maven war 工程,目录结构如下:

src/main/java com.demo.entity.User.java com.demo.dao.UserDao.java com.demo.service.UserService.java com.demo.web.UserAction.java com.demo.web.UserManagerAction.java com.demo.interceptor.LoginInterceptor.java src/main/resources/struts.xml src/main/webapp WEB-INF/web.xml login.jsp register.jsp user/list.jsp user/edit.jsp

如果你用 Maven,核心依赖只有一条,版本选一个你环境里稳定的 2.5.x 即可:

<dependency> <groupId>org.apache.struts</groupId> <artifactId>struts2-core</artifactId> <!-- 版本按你使用的稳定版填写,不要和共存的其他 Struts 相关 jar 版本冲突 --> </dependency>

如果你是手工往 WEB-INF/lib 里放 jar,注意别把多个版本的 struts2-core 混在一起,也别单独塞一个 xwork-core。2.5 版本之后 xwork 已经合并进 struts2-core,老教程里单独引 xwork-core 的做法在新版本会直接 ClassNotFound。

web.xml 里挂载 Filter 是老项目最容易翻车的地方之一。不同版本的 Filter 类全限定名不一样:

<filter> <filter-name>struts2</filter-name> <filter-class>org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter</filter-class> </filter> <filter-mapping> <filter-name>struts2</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

url-pattern必须用/*,不能只写/。原因很简单:Struts2 需要拦截所有请求来做 Action 分发,而url-pattern=/只匹配默认 servlet 的路径语义,静态资源和 JSP 直出都会被旁路。如果你接手的是一个 2.3 时代的老工程,Filter 类名要换成org.apache.struts2.dispatcher.ng.filter.StrutsPrepareAndExecuteFilter,这个差异会在第 5 章展开讲。

这个工程里我不用 Spring 整合,也不上 MyBatis,数据访问直接用 Apache Commons DbUtils 包一层 JDBC。原因只有一个:登录注册和用户信息管理的核心矛盾在页面跳转和请求分发,不在持久层。等到跑通了,你再去套 SSM 的壳子会轻松很多。

3.2 注册模块:表单校验、用户名查重与密码加密

注册模块我拆成三步:前端表单收集信息、Action 里做业务校验、通过后落库。先看 register.jsp 的表单核心片段:

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="s" uri="/struts-tags" %> <html> <body> <h2>用户注册</h2> <s:form action="register" namespace="/user" method="post"> <s:textfield name="user.username" label="用户名"/> <s:fielderror fieldName="username"/> <s:password name="user.password" label="密码"/> <s:textfield name="user.email" label="邮箱"/> <s:submit value="注册"/> </s:form> </body> </html>

表单里的name全部写成user.xxx,配合 ModelDriven 机制,提交后会被自动组装到 User 对象的对应属性上。namespace="/user"对应 struts.xml 里的命名空间,提交地址就是/user/register.action。

然后是 UserAction 的注册方法和自定义校验。Struts2 的校验有个特点:如果你定义了validateRegister()方法,框架会在执行register()方法前自动调用它。名字里的Register首字母必须大写,否则不生效,这是新手最容易踩的玄学点。

package com.demo.web; import com.demo.entity.User; import com.demo.service.UserService; import com.opensymphony.xwork2.ActionContext; import com.opensymphony.xwork2.ActionSupport; import com.opensymphony.xwork2.ModelDriven; import java.security.MessageDigest; import java.nio.charset.StandardCharsets; public class UserAction extends ActionSupport implements ModelDriven<User> { private User user = new User(); private UserService userService = new UserService(); @Override public User getModel() { return user; } public String register() throws Exception { if (userService.usernameExists(user.getUsername())) { addFieldError("username", "用户名已被占用"); return INPUT; } // 密码加盐后做 SHA-256 摘要,盐直接用用户名,简单且对用户无感知 user.setPassword(digest(user.getPassword(), user.getUsername())); userService.register(user); return SUCCESS; } public void validateRegister() { if (user.getUsername() == null || user.getUsername().trim().length() < 3) { addFieldError("username", "用户名至少 3 位"); } if (user.getPassword() == null || user.getPassword().length() < 6) { addFieldError("password", "密码至少 6 位"); } } private String digest(String raw, String salt) throws Exception { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] bytes = md.digest((raw + salt).getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02x", b & 0xFF)); } return sb.toString(); } }

代码逻辑说明:register()先查重,再加密,最后落库。密码不是直接存明文,而是先拼接用户名做盐,再做 SHA-256 摘要。加盐是为了防止两个相同密码产生相同的摘要值,也能规避常见的彩虹表碰撞。validateRegister()里用addFieldError收集错误,当存在字段错误时,Struts2 会自动返回INPUT结果码,页面通过<s:fielderror>展示错误信息。

参数说明:用户名最少 3 位、密码最少 6 位,这两个阈值是我做内部演示系统时的常见取值;如果要接正式业务,密码长度建议至少 8 位并混合字母数字。String.format("%02x", b & 0xFF)里的& 0xFF不是可省的小细节——如果 byte 是负数,不屏蔽高位会输出一大串ff,这是 Java 做 hex 的老坑。

3.3 登录会话:Session 管理、LoginAction 与跳转策略

登录模块比注册简单,但跳转策略值得多说两句。见代码:

public String login() throws Exception { User dbUser = userService.findByUsername(user.getUsername()); if (dbUser == null) { addActionError("用户不存在"); return INPUT; } String encoded = digest(user.getPassword(), user.getUsername()); if (!encoded.equals(dbUser.getPassword())) { addActionError("密码错误"); return INPUT; } // 登录成功后把用户放进 session,后续页面通过 #session.loginUser 读取 ActionContext.getContext().getSession().put("loginUser", dbUser); return SUCCESS; }

代码逻辑说明:先查用户是否存在,再比对加盐摘要后的密码。注意这里把密码摘要的计算方式跟注册时完全保持一致,否则代码写得再对也验不上。校验失败通过addActionError记录全局错误,返回INPUT展示在登录页顶部。成功后把用户对象放进 session,这步决定了下一次请求是否能识别登录态。

跳转配置长这样:

<action name="login" class="com.demo.web.UserAction" method="login"> <result name="success" type="redirectAction"> <param name="actionName">list</param> <param name="namespace">/user</param> </result> <result name="input">/login.jsp</result> </action>

参数说明:type="redirectAction"是登录成功时的标准做法,浏览器地址栏会变成/user/list.action。这么做有两个理由:其一,刷新页面不会重新提交登录表单;其二,登录成功后进入列表页,URL 语义更干净。新手容易图省事写成type="dispatcher",结果地址栏停在/user/login.action,一按 F5 就重新执行登录,这是典型翻车现场,后面避坑章节我会再展开。

4. 用户信息管理的增删改查与分页:照着一行行敲即可

4.1 列表页与分页:PageBean 封装和 LIMIT 查询

用户列表是整个系统的核心页面。数据不能一次全查出来,必须分页,否则用户量一多页面直接卡死。我先定义一个 PageBean 做分页数据的通用封装:

package com.demo.page; import java.util.List; public class PageBean<T> { private int currentPage; // 当前页 private int pageSize; // 每页条数 private int totalCount; // 总记录数 private int totalPages; // 总页数 private List<T> list; // 当前页数据 public PageBean(int currentPage, int pageSize, int totalCount) { this.currentPage = currentPage; this.pageSize = pageSize; this.totalCount = totalCount; this.totalPages = (int) Math.ceil(totalCount * 1.0 / pageSize); } // getter / setter 省略 }

逻辑说明:总页数通过Math.ceil向上取整,保证 10 条数据按每页 3 条分页时能得到 4 页而不是 3 页。这个类不依赖 Struts2 的任何 API,可以原样迁到别的框架里复用。

再写 UserDao 里的分页查询方法:

public PageBean<User> findPage(int currentPage, int pageSize, String keyword) throws Exception { String countSql = "SELECT COUNT(*) FROM t_user"; String listSql = "SELECT id, username, email, phone, create_time FROM t_user"; if (keyword != null && !keyword.trim().isEmpty()) { countSql += " WHERE username LIKE ?"; listSql += " WHERE username LIKE ?"; } listSql += " ORDER BY id DESC LIMIT ?, ?"; Long total = queryRunner.query(countSql, new ScalarHandler<Long>(), keyword == null ? null : "%" + keyword + "%"); List<User> users = queryRunner.query(listSql, new BeanListHandler<>(User.class), keyword == null ? null : "%" + keyword + "%", (currentPage - 1) * pageSize, pageSize); PageBean<User> pageBean = new PageBean<>(currentPage, pageSize, total.intValue()); pageBean.setList(users); return pageBean; }

这里的两个 SQL 我用的是同一套?占位符逻辑:第一条查总数用于算页数,第二条按LIMIT偏移量取当前页数据。(currentPage - 1) * pageSize是 LIMIT 的偏移量,比如当前第 2 页、每页 5 条,就从第 5 条开始取。这个偏移量公式是分页的核心,算错了会出现数据重复或漏行。

4.2 添加与编辑共用表单:ModelDriven 带来的简化与坑

用户信息管理里,添加和编辑可以共用同一个 JSP 表单页。关键在 Action 里用同一个save()方法,根据user.id是否为空决定新增还是更新:

public class UserManagerAction extends ActionSupport implements ModelDriven<User> { private User user = new User(); private PageBean<User> pageBean; private int currentPage = 1; private String keyword; private UserService userService = new UserService(); @Override public User getModel() { return user; } public String list() throws Exception { pageBean = userService.findPage(currentPage, 5, keyword); return SUCCESS; } public String toEdit() throws Exception { // 编辑时根据 id 查出原有记录填充表单,新增时 user 为空模型即可 if (user.getId() != null && user.getId() > 0) { User dbUser = userService.findById(user.getId()); if (dbUser == null) { return ERROR; } // 把查出的数据逐字段覆盖到 model 上,表单回显 user.setUsername(dbUser.getUsername()); user.setEmail(dbUser.getEmail()); user.setPhone(dbUser.getPhone()); } return SUCCESS; } public String save() throws Exception { if (user.getId() == null || user.getId() == 0) { userService.add(user); } else { userService.update(user); } return SUCCESS; } public String delete() throws Exception { userService.delete(user.getId()); return SUCCESS; } }

逻辑说明:list()是列表页入口,接收currentPage和keyword,这两个参数会被 params 拦截器自动从请求参数填充。toEdit()是打开编辑页的动作,编辑时按 id 查出数据库里的原数据覆盖到 model 上,表单里的<s:textfield name="user.username"/>就能自动回显。save()里判断有没有 id,有就是更新,没有就是新增。

表单页的写法:

<s:form action="save" namespace="/user" method="post"> <s:hidden name="user.id"/> <s:textfield name="user.username" label="用户名"/> <s:textfield name="user.email" label="邮箱"/> <s:textfield name="user.phone" label="手机号"/> <s:submit value="保存"/> </s:form>

注意<s:hidden name="user.id"/>:新增时 id 为空,保存走新增分支;编辑时这个隐藏字段带着原记录 id,保存走更新分支。这是共用一个表单页的关键桥梁,漏了这行,编辑就会变成新增出一条重复记录。

4.3 删除与条件查询:URL 传参与中文乱码处理

删除功能最简单,列表页每行放一个删除链接,链接指向/user/delete.action?id=某条记录id。URL 传参的方式决定了它是 GET 请求,那么随之而来的问题就是中文参数乱码。

列表页的搜索框和分页条是这样写的:

<s:form action="list" namespace="/user" method="post"> <s:textfield name="keyword" label="关键词"/> <s:submit value="搜索"/> </s:form> <table> <s:iterator value="pageBean.list" var="u"> <tr> <td><s:property value="#u.username"/></td> <td><s:property value="#u.email"/></td> <td><s:property value="#u.phone"/></td> <td> <a href="<s:url action='toEdit' namespace='/user'><s:param name='id' value='#u.id'/></s:url>">编辑</a> <a href="<s:url action='delete' namespace='/user'><s:param name='id' value='#u.id'/></s:url>" onclick="return confirm('确定删除该用户?')">删除</a> </td> </tr> </s:iterator> </table>

这里搜索我特意用的是 POST 提交,而不是 GET,就是为了避开 GET 参数的中文乱码问题。这是第一个选择,但分页条上的翻页链接没法走 POST,只能 GET。所以翻页链接必须用<s:url>标签来拼,它会对中文参数做 URL 编码,比手动拼字符串可靠得多。分页条就是一组链接,上一页、页码、下一页,每个链接都要把当前keyword带回去,否则翻页后搜索条件就丢了,列表会跳回全量数据。

删除功能里还藏着一个隐患:如果直接在地址栏输入/user/delete.action?id=1,这行记录就被删了。所以生产环境的删除一定要做权限拦截,不能让未登录用户直接调这个 URL。这个点在最后一章拦截器里会一并处理。

5. Struts2 避坑实录:5 个必踩的坑与排查路径

5.1 404 或 ClassNotFound:Filter 类名与 jar 包版本打架

现象:工程部署到 Tomcat 启动不报错,但访问任意.action路径都 404,控制台里出现ClassNotFoundException: org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter。

原因:Struts2 在 2.3 到 2.5 之间改了 Filter 的包路径。2.3 时代是org.apache.struts2.dispatcher.ng.filter.StrutsPrepareAndExecuteFilter,2.5 时代是org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter。网上教程新旧混杂,照着老教程配 web.xml,再用新版 jar,类名自然找不到。另一种常见原因是 lib 里同时混入了 struts2-core 的多个版本,或者单独塞了与版本不匹配的 xwork-core、javassist。

解决:先确认你用的具体版本,再写对应的类名。排除依赖时把 struts2-core 相关的 jar 全部清掉重新引入,保证只有一个版本。检查 xwork-core 是否被重复引入:2.5 之后不需要独立 xwork-core,它已经并入 struts2-core;如果存在旧版本 xwork,删掉。

5.2 列表数据查出来了,JSP 页面却空白

现象:Action 里pageBean已经有数据,控制台也能打出来,但页面表格只有表头,数据行完全不显示,也不报错。

原因:九成是取值路径错了。如果 Action 实现了ModelDriven,框架会把 model 对象压到值栈栈顶,你的pageBean属性在栈里被 model 盖住,OGNL 表达式直接写value="pageBean.list"时,框架优先在栈顶的 User 对象上找 pageBean,找不到就返回空。另一个常见原因是数据是放在 request 作用域里的,页面却用值栈的方式取值。

解决:页面改用完整作用域前缀,写成<s:iterator value="#request.pageBean.list">,或者在 Action 里确保getPageBean()存在,并把 JSP 取值表达式写成从 Action 属性拿。我的习惯是:所有列表数据统一走 request 作用域,用#request前缀取值,避免和 ModelDriven 的栈顶遮挡问题纠缠。

5.3 登录成功刷新一次又回登录页

现象:登录流程走通了,能进列表页,但只要一按 F5 刷新,浏览器回到登录页重新登录。

原因:这是 result 跳转类型选的错。登录成功后如果配置的是type="dispatcher",地址栏 URL 始终保持/user/login.action,刷新等于重新提交登录表单。如果登录方法里没有做"已登录直接跳过校验"的处理,刷新就会重新走一遍校验流程,大多数情况会失败并跳回登录页。如果配置了type="redirect"手动拼 URL,还可能因为没把 namespace 带上而跳到错误地址。

解决:登录成功全部使用redirectAction跳转,并显式写清楚 actionName 和 namespace。一个更彻底的兜底是在 login() 方法开头加一段判断:如果 session 里已经存在 loginUser,直接返回 SUCCESS,不做重复校验。这样即使 URL 停留在 login.action,刷新也只是重新跳转发,不会重复校验。

5.4 中文乱码:struts.i18n.encoding 治不了 GET 参数

现象:注册时输入的"张三",存进数据库后变成一串乱码;搜索框输入中文关键词,页面完全查不出数据。

原因:struts.i18n.encoding=UTF-8这个常量只对经过 Struts2 Filter 的 POST 请求体生效。GET 请求的参数编码由 Tomcat 的URIEncoding决定,而它默认不是 UTF-8。另外,如果你的 JDBC 连接串没有指定characterEncoding=UTF-8,即使请求到了 Java 层是正常的中文,写入数据库照样乱码。

解决:分三层处理。第一层,struts.xml 里配置struts.i18n.encoding为 UTF-8;第二层,在 Tomcat 的 server.xml 里给 Connector 加URIEncoding="UTF-8";第三层,JDBC 连接串带上useUnicode=true&characterEncoding=UTF-8。三个位置少任何一个,都可能在某个环节出现乱码。搜索条件尽量用 POST 表单提交,翻页链接用<s:url>标签自动编码,能避免大部分 GET 乱码问题。

5.5 chain 跳转后数据丢失:把 Request 作用域和值栈弄混了

现象:A 类的 Action 通过chain结果跳到 B 类 Action,B 里能拿到 A 的属性,但 A 里通过 request.setAttribute 放进去的数据在 B 里取不到。

原因:chain的类型是转发到另一个 Action,它共享的是值栈,不是 request 作用域。很多人把 chain 理解成"带数据重定向",这是误解。值栈里的是 Action 属性和 ModelDriven 模型,request attribute 是 Servlet 容器层面的数据,chain 不会帮你把 request attribute 复制到下一个 Action 的值栈里。

解决:需要跨 Action 传递业务数据时,优先用显式参数。比如编辑完成后跳转列表,用redirectAction并带上必要的参数。chain 只适合两个 Action 天然共享同一个领域模型的场景,如果你发现自己要用 chain 传一大堆散值,大概率该重构成一个 Action 里完成多个步骤。

6. 最后一道甜点:让登录拦截器接管一切,值得投入的最后一步

前面所有功能跑通,只代表"能用"。离"能给别人用"还差一道关键的坎:页面直连。比如把列表页 URL 复制给另一个人,对方不登录就能看到全部用户信息,删除接口同理。Struts2 的拦截器机制恰好就是为这件事设计的。

自定义拦截器只需要继承com.opensymphony.xwork2.interceptor.AbstractInterceptor,并覆写intercept方法:

package com.demo.interceptor; import com.opensymphony.xwork2.ActionContext; import com.opensymphony.xwork2.ActionInvocation; import com.opensymphony.xwork2.interceptor.AbstractInterceptor; import java.util.Map; public class LoginInterceptor extends AbstractInterceptor { @Override public String intercept(ActionInvocation invocation) throws Exception { // 获取当前请求对应的 action 名称和命名空间 String actionName = invocation.getProxy().getActionName(); String namespace = invocation.getProxy().getNamespace(); // 登录和注册两个入口放行,其他业务 action 必须校验登录态 boolean pass = ("/user".equals(namespace) && ("login".equals(actionName) || "register".equals(actionName))); if (!pass) { Map<String, Object> session = ActionContext.getContext().getSession(); Object loginUser = session.get("loginUser"); if (loginUser == null) { // 未登录统一跳回登录页,由 struts.xml 里的全局 result 承接 return "login"; } } return invocation.invoke(); } }

逻辑说明:getProxy().getActionName()拿到的是不带后缀的 Action 名,getProxy().getNamespace()拿到的是命名空间。白名单用pass变量控制,登录和注册放行,其余全部检查 session。未登录返回字符串"login",对应全局 result 跳转到登录页。注意这里的白名单写的是 namespace 加 actionName 的组合,比单纯判断 actionName 更严谨,避免别的 namespace 出现同名 action 被误放行。

接着把自定义拦截器挂进默认拦截器栈,这是最关键的一步:

<package name="user" namespace="/user" extends="struts-default"> <interceptors> <interceptor name="loginInterceptor" class="com.demo.interceptor.LoginInterceptor"/> <interceptor-stack name="authStack"> <interceptor-ref name="defaultStack"/> <interceptor-ref name="loginInterceptor"/> </interceptor-stack> </interceptors> <default-interceptor-ref name="authStack"/> <global-results> <result name="login">/login.jsp</result> </global-results> <!-- 已有的 action 配置保持不变 --> </package>

参数说明:default-interceptor-ref一旦自定义,就必须手动引用defaultStack,否则参数填充、模型驱动这些基础拦截器全部失效,表单提交的数据会莫名其妙为空。这是拦截器改造中最容易忽略的坑,配置完记得先测一次注册和登录再上线。

到这里,这个系统才算真正闭环:登录态由 serializer 检查,业务数据按登录用户隔离,密码不落明文。回看我带过的项目,凡是跳过这一步直接交付的,最后都会在被人扫出未授权访问时回来补课。不如最开始就把拦截器当成必做项,而不是可选项。至于验证码、登录失败次数锁定,这两个扩展点可以继续压到同一个拦截器栈里做;方向不会错,希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 17:10:02

Kumone灰歌解锁完全指南:如何听懂每一首“灰色“歌曲

【免费下载链接】kumone 雲の音 — Native macOS NetEase Cloud Music client built with SwiftUI 项目地址&#xff1a; https://gitcode.com/gh_mirrors/ku/kumone 点击查看 免费下载 如果你在使用网易云音乐客户端时&#xff0c;总是遇到无法播放的灰色歌曲——页面灰掉、点…

作者头像 李华
网站建设 2026/10/15 17:09:08

Basler相机C++ SDK封装实践:构建可复用的CameraWrapper类

简介&#xff1a;针对Basler工业相机二次开发需求&#xff0c;这份C封装类将SDK中分散的初始化、抓图、曝光调节等功能整理成面向对象的接口&#xff0c;让机器视觉或自动化项目中的开发者能快速完成相机调用。类设计同时覆盖了事件回调、错误捕获、线程同步和内存管理&#xf…

作者头像 李华
网站建设 2026/10/15 17:06:52

Qt自定义控件从绘制到事件:QPainter、paintEvent与性能优化实战

简介&#xff1a;Qt自定义控件主题资源包面向需要在Qt框架中实现个性化界面、突破内置控件局限的开发者&#xff0c;系统演示了基于QWidget及其子类进行派生、重写paintEvent完成自绘、拦截鼠标键盘事件、借助信号槽实现跨部件通信&#xff0c;以及支持QSS定制外观等完整实现思…

作者头像 李华
网站建设 2026/10/15 17:06:28

嵌入式 vsftpd 编译裁剪与资源优化实战指南

1. 为什么嵌入式设备上跑 vsftpd 不是“装个包就完事”&#xff1f;在某次为某工业数据采集终端做远程固件升级支持时&#xff0c;我接到一个看似简单的需求&#xff1a;“加个 FTP 功能&#xff0c;方便现场工程师传新固件”。团队里一位刚毕业的同事二话不说&#xff0c;在 B…

作者头像 李华