Java Web入门项目:Servlet+JSP+JDBC实战骨架
1. 项目概述一个真实可跑、不注水的Java Web基础骨架“Java--小项目登录、增删改查、分页、搜索”——这行标题在初学Java Web的开发者社区里几乎每天都会被复制粘贴几十次。它不像“Spring Boot微服务架构设计”那样高大上也不像“AI模型推理优化”那样前沿炫技但它恰恰是横亘在“学完语法”和“能接活儿”之间最真实、最不可绕过的那道门槛。我带过不少刚从培训班出来的同学也帮某高校实验室的本科生调试过课程设计发现一个共性问题他们能手写HashMap遍历却卡在登录成功后跳转不到首页能背出JDBC四大步骤但一加个模糊搜索就SQL报错知道PageHelper能分页却搞不清为什么前端传了page2后端查出来还是第一页的数据。这个小项目表面看是五个功能点的拼凑实则是一套完整的Web请求生命周期演练从前端表单提交、后端参数绑定、数据库交互、结果封装到响应渲染环环相扣。它不追求技术栈的最新潮而是用最朴素的ServletJSP或轻量Thymeleaf JDBC MySQL组合把每个环节的“毛边”都暴露出来逼你亲手去打磨。适合谁不是给已经能独立开发后台管理系统的中级工程师看的而是给那些代码能跑通但心里没底、遇到报错只会百度前两行、对“为什么这样写”始终存疑的入门者。它解决的不是“能不能做”而是“为什么这么做才稳”。接下来我会带你从零开始不跳步、不省略任何一行关键代码把登录校验的密码加盐逻辑、增删改查里事务边界的划定、分页参数如何防篡改、搜索条件如何动态拼接这些教科书里一笔带过的细节全部摊开讲透。2. 整体架构与技术选型为什么拒绝“一步到位”的框架堆砌2.1 技术栈选择背后的现实考量很多人看到“小项目”第一反应就是“直接上Spring BootMyBatis Plus三分钟生成CRUD不香吗”香但容易飘。我试过用脚手架生成一套完整后台让一个刚学完Java基础的同学去理解其中的自动配置原理、代理对象生成、SQL执行器链路结果他花了三天时间只搞懂了application.yml里server.port8080这一行。所以这个项目我坚持用最“原始”的组合JDK 8 Tomcat 9 Servlet 4.0 JSP 2.3 JDBC MySQL 5.7 Bootstrap 4。这不是复古情怀而是基于三个硬性约束学习成本可控Servlet的doGet/doPost方法就是HTTP请求最直白的映射JSP里的% %表达式就是Java代码嵌入HTML的最直观形式。没有自动注入、没有AOP织入、没有启动时的上百个Bean初始化日志所有流程都在你眼皮底下发生。问题定位清晰当登录失败时你能立刻判断是request.getParameter(username)取不到值前端问题还是userDao.findByUsername()返回null数据库问题或是passwordEncoder.matches()校验失败密码逻辑问题。而如果用Spring Security一个403错误可能来自Filter链、Session管理、权限注解、甚至CSRF Token校验新手根本无从下手。资源占用极低整个WAR包打出来不到5MB部署到本地Tomcat启动时间3秒。对比Spring Boot内嵌Tomcat动辄200MB内存占用和15秒启动这种轻量级更适合在学生笔记本、老旧开发机上反复调试不卡顿。提示这里刻意避开Spring MVC不是因为它不好而是因为它的“约定优于配置”会掩盖很多底层细节。比如RequestParam自动类型转换会让人忽略String到Integer的NumberFormatException风险ResponseBody自动JSON序列化会让人忘记手动处理null值导致的空指针。我们先学会“造轮子”再用“高级轮子”路才走得稳。2.2 分层结构设计四层而非三层把“控制”真正交还给开发者传统教学常提MVC三层Model实体类、ViewJSP页面、ControllerServlet。但这在实际操作中极易混乱。我见过太多同学把数据库查询逻辑直接写在Servlet里或者在JSP里写% out.print(user.getName().toUpperCase()) %这种业务逻辑。因此本项目采用四层结构多出的一层叫“Service Facade”服务门面它像一道闸门明确划清各层职责Web层Servlet只做三件事——接收HTTP参数、调用Service层方法、决定跳转到哪个JSP。绝不碰SQL绝不做业务计算。Service层Service Facade处理核心业务规则。例如登录检查用户名是否为空、密码长度是否达标、账户是否被禁用、登录失败次数是否超限。它调用DAO但不关心DAO怎么实现。DAO层Data Access Object只做一件事——和数据库对话。insert(User user)、updateById(User user)、selectByCondition(MapString, Object params)。它不处理分页参数不分页是Service层的事。Model层POJO纯粹的数据载体只有字段和getter/setter不包含任何方法连toString()都不加避免序列化污染。这种分法带来的直接好处是当你需要把MySQL换成Oracle时只需重写DAO层的SQL语句Service和Web层代码一行不动当你想给搜索功能加全文索引只改DAO层的selectByCondition()方法其他层完全无感。我在某公司给实习生布置任务时就要求他们先用纯JDBC写完DAO再用MyBatis重写一遍对比两者的代码量和可读性——结果90%的人发现MyBatis的XML配置反而比手写JDBC更难调试因为错误堆栈指向的是SqlSessionTemplate而不是你自己的UserDaoImpl.java第37行。2.3 数据库设计从“够用”到“防坑”的字段定义很多人建表只图快user表随手写个id int primary key auto_increment, username varchar(20), password varchar(50)就完事。但实际运行中问题立刻浮现用户注册“admin --”导致SQL注入密码明文存储被拖库用户名重复插入报错却不友好提示。所以本项目的user表字段定义如下MySQL语法CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, username varchar(32) NOT NULL COMMENT 用户名唯一支持邮箱/手机号, password char(64) NOT NULL COMMENT BCRYPT加密后的密码固定64位, salt char(16) NOT NULL COMMENT 随机盐值用于密码加盐, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 状态0-禁用1-启用, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户信息表;关键点解析username用varchar(32)而非20现代系统用户名支持邮箱如zhang.sanexample.com和手机号1381234567832位足够覆盖。password用char(64)BCRYPT算法输出固定60字符加2位标识符如$2a$10$...64位精确匹配避免varchar的隐式转换开销。强制添加salt字段不是为了“更安全”而是为了可重现性。很多教程教“用UUID当盐”但UUID每次生成都不同导致同一密码加密后结果不同无法校验。这里的salt是用户注册时生成并存库的固定值确保BCryptPasswordEncoder.matches(rawPassword, storedHash)能稳定工作。status用tinyint(1)而非enumMySQL的enum在JDBC中映射为String需要额外转换tinyint直接映射为IntegerService层用if (user.getStatus() 0)判断干净利落。create_time和update_time用DEFAULT CURRENT_TIMESTAMP避免在Java代码里手动new Date()防止服务器时区不一致导致的时间错乱。3. 核心功能实现从登录校验到动态搜索的逐行拆解3.1 登录功能不止于“用户名密码匹配”而是完整的会话生命周期管理登录看似简单但它是整个项目安全性的基石。很多初学者只写SELECT * FROM user WHERE username? AND password?这存在三大致命缺陷密码明文传输、无防暴力破解、无会话超时。本项目采用以下方案第一步前端密码加盐哈希非必须但强烈推荐在登录页面的JS中不直接提交明文密码而是用bcryptjs在浏览器端预哈希!-- login.jsp -- script srchttps://cdn.jsdelivr.net/npm/bcryptjs2.4.3/dist/bcrypt.min.js/script script function submitLogin() { const rawPass document.getElementById(password).value; // 使用固定盐值实际应由后端下发动态盐此处简化 const salt my_static_salt_123; const hashedPass bcrypt.hashSync(rawPass salt, 12); // 12轮迭代 document.getElementById(password).value hashedPass; document.getElementById(loginForm).submit(); } /script注意这并非替代后端校验而是增加第一道防线。即使HTTPS被中间人劫持概率极低攻击者拿到的也是哈希值无法直接用于登录。真正的校验仍在后端进行。第二步后端双重校验与会话绑定LoginServlet.java的核心逻辑protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username request.getParameter(username); String clientHashedPass request.getParameter(password); // 前端传来的哈希值 // 1. 基础校验空值、长度 if (StringUtils.isBlank(username) || StringUtils.isBlank(clientHashedPass)) { request.setAttribute(error, 用户名或密码不能为空); request.getRequestDispatcher(/login.jsp).forward(request, response); return; } // 2. 查询用户含盐值 User user userDao.findByUsername(username); if (user null) { request.setAttribute(error, 用户名不存在); request.getRequestDispatcher(/login.jsp).forward(request, response); return; } // 3. 密码校验用数据库中的salt clientHashedPass 进行BCRYPT比对 // 注意这里不是比对两个哈希值而是用user.salt重新哈希clientHashedPass String serverHashedPass BCrypt.hashpw(clientHashedPass, user.getSalt()); if (!BCrypt.checkpw(clientHashedPass, user.getPassword())) { // 密码错误记录失败次数可扩展为Redis计数 request.setAttribute(error, 密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); return; } // 4. 状态校验 if (user.getStatus() 0) { request.setAttribute(error, 该账户已被禁用请联系管理员); request.getRequestDispatcher(/login.jsp).forward(request, response); return; } // 5. 登录成功创建HttpSession并绑定用户信息 HttpSession session request.getSession(true); session.setMaxInactiveInterval(30 * 60); // 30分钟无操作超时 session.setAttribute(currentUser, user); // 存储User对象非ID // 6. 重定向到主页避免F5刷新重复提交 response.sendRedirect(request.getContextPath() /home.jsp); }关键细节说明session.setMaxInactiveInterval(30 * 60)设置会话超时为30分钟这是Tomcat默认值但显式写出是为了强调其重要性。测试时可设为60秒快速验证。session.setAttribute(currentUser, user)存储整个User对象而非仅ID。理由后续所有页面如个人中心都需要用户姓名、头像等信息如果只存ID每次都要查库违背“一次登录全程可用”原则。内存占用可接受User对象通常1KB。重定向sendRedirect而非转发forward这是防止用户按F5刷新导致重复登录的关键。转发是在服务器内部跳转URL不变重定向是告诉浏览器去访问新URL刷新时只会刷新新页面。第三步全局登录拦截Filter实现创建LoginFilter.java拦截所有需要登录的请求如/user/list,/user/editpublic class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) res; // 放行登录页、静态资源、登录接口 String uri request.getRequestURI(); if (uri.contains(/login.jsp) || uri.contains(/login) || uri.contains(/css/) || uri.contains(/js/) || uri.contains(/images/)) { chain.doFilter(req, res); return; } // 检查Session中是否存在currentUser HttpSession session request.getSession(false); if (session null || session.getAttribute(currentUser) null) { // 未登录重定向到登录页并携带原请求URL作为参数 String originalUrl request.getRequestURL().toString(); response.sendRedirect(request.getContextPath() /login.jsp?redirect URLEncoder.encode(originalUrl, UTF-8)); return; } chain.doFilter(req, res); } }实操心得URLEncoder.encode()必不可少。如果用户直接访问/user/edit?id123不编码会导致重定向URL变成/login.jsp?redirecthttp://localhost:8080/myapp/user/edit?id123问号后的id123会被当成登录页的参数丢失原始意图。编码后变为%3Fid%3D123登录成功后再解码跳转体验无缝。3.2 增删改查CRUD事务边界与空值安全的实战守则CRUD是数据操作的核心但新手常犯的错误是新增后不刷新列表、删除时没确认弹窗、修改时忽略并发更新。本项目通过以下设计规避新增Create表单验证 主键回填 列表自动刷新AddUserServlet.javaprotected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 接收参数并封装为User对象 String username request.getParameter(username); String rawPassword request.getParameter(password); // 2. 服务层校验非空、长度、唯一性 String validateMsg userService.validateForAdd(username, rawPassword); if (validateMsg ! null) { request.setAttribute(error, validateMsg); request.getRequestDispatcher(/user/add.jsp).forward(request, response); return; } // 3. 生成盐值并加密密码 String salt RandomStringUtils.randomAlphanumeric(16); // Apache Commons Lang String hashedPass BCrypt.hashpw(rawPassword salt, BCrypt.gensalt(12)); User user new User(); user.setUsername(username); user.setPassword(hashedPass); user.setSalt(salt); // 4. 调用Service新增含事务 try { userService.addUser(user); // 新增成功后将新用户的ID回填到request供JSP显示 request.setAttribute(newUserId, user.getId()); request.setAttribute(success, 用户添加成功ID user.getId()); } catch (Exception e) { request.setAttribute(error, 添加失败 e.getMessage()); } request.getRequestDispatcher(/user/add.jsp).forward(request, response); }关键点userService.validateForAdd()在Service层统一校验避免在Servlet里写一堆if。校验逻辑包括username长度3-32、不能包含特殊字符正则^[a-zA-Z0-9_\\.-]$、数据库唯一性检查用SELECT COUNT(*)非INSERT ... ON DUPLICATE KEY因后者异常堆栈不友好。RandomStringUtils.randomAlphanumeric(16)生成16位随机盐比UUID.randomUUID().toString()更短且无横线存储更省空间。新增成功后user.getId()能获取到自增主键值前提是DAO层使用PreparedStatement.RETURN_GENERATED_KEYS// UserDaoImpl.java public void insert(User user) throws SQLException { String sql INSERT INTO user (username, password, salt) VALUES (?, ?, ?); try (PreparedStatement ps connection.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS)) { ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); ps.setString(3, user.getSalt()); ps.executeUpdate(); // 获取自增主键 try (ResultSet rs ps.getGeneratedKeys()) { if (rs.next()) { user.setId(rs.getLong(1)); // 将主键值赋给user对象 } } } }删除Delete软删除 二次确认 批量操作不物理删除而是更新status0// UserService.java public boolean deleteUser(Long id) { // 先检查是否为超级管理员ID1禁止删除 if (id 1L) { throw new IllegalArgumentException(超级管理员不可删除); } return userDao.updateStatus(id, 0) 0; // 返回影响行数 }前端JSP中删除按钮触发JS确认a hrefjavascript:void(0) onclickif(confirm(确定要删除用户ID ${user.id}此操作不可恢复)){location.href${pageContext.request.contextPath}/user/delete?id${user.id}}删除/a注意confirm()是阻塞式弹窗比Ajax异步弹窗更简单可靠。对于批量删除只需在列表页加复选框提交时用request.getParameterValues(ids)接收字符串数组再转为Long[]批量处理。修改Update乐观锁防并发 字段级更新为防A、B两人同时编辑同一用户A保存后B覆盖引入版本号字段versionALTER TABLE user ADD COLUMN version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号;更新SQL改为UPDATE user SET username?, password?, salt?, versionversion1 WHERE id? AND version?;DAO层执行后检查executeUpdate()返回值int rows ps.executeUpdate(); if (rows 0) { throw new OptimisticLockException(数据已被他人修改请刷新后重试); }JSP表单中将当前version作为隐藏域提交确保更新时校验。3.3 分页功能从“limit m,n”到“性能与体验的平衡术”分页是CRUD的标配但新手常写SELECT * FROM user LIMIT 0,10然后LIMIT 10,10LIMIT 20,10……这在数据量10万时LIMIT 10000,10会全表扫描前10001行慢得无法忍受。本项目采用游标分页Cursor-based Pagination与传统偏移分页Offset-based双模式方案一传统偏移分页适合中小数据量UserListServlet.java接收page1size10参数int page Math.max(1, Integer.parseInt(request.getParameter(page))); int size Math.min(100, Integer.parseInt(request.getParameter(size))); // 防恶意大size int offset (page - 1) * size; ListUser userList userDao.findUsersWithPagination(offset, size); long total userDao.countAll(); // 单独count不走分页SQL int totalPages (int) Math.ceil((double) total / size); // 封装分页对象 PageResultUser result new PageResult(); result.setList(userList); result.setPage(page); result.setPageSize(size); result.setTotal(total); result.setTotalPages(totalPages); request.setAttribute(pageResult, result);DAO层SQL-- 查数据 SELECT id, username, status, create_time FROM user ORDER BY id DESC LIMIT ?, ? -- 查总数注意不要用COUNT(*) OVER()MySQL 5.7不支持 SELECT COUNT(*) FROM user方案二游标分页推荐高性能当数据量大时用WHERE id ? ORDER BY id DESC LIMIT ?替代OFFSET。假设第一页查出最后一条记录id1000第二页就查WHERE id 1000。优势无需计算偏移量索引直达。缺点不能跳页如直接跳到第100页。本项目在list.jsp中提供两种切换!-- 分页导航 -- div classpagination c:if test${pageResult.page 1} a href?page${pageResult.page-1}size${pageResult.pageSize}上一页/a /c:if c:forEach begin1 end${pageResult.totalPages} varp c:if test${p pageResult.page} span classcurrent${p}/span /c:if c:if test${p ! pageResult.page} a href?page${p}size${pageResult.pageSize}${p}/a /c:if /c:forEach c:if test${pageResult.page pageResult.totalPages} a href?page${pageResult.page1}size${pageResult.pageSize}下一页/a /c:if /div实操心得分页参数必须严格校验。曾有学员把size9999999传进来导致LIMIT 0,9999999查出几百万条数据Tomcat直接OOM。所以Math.min(100, size)是保命线。另外totalPages计算用Math.ceil((double) total / size)而非(total size - 1) / size避免整数除法误差。3.4 搜索功能动态SQL拼接与SQL注入的“零容忍”防御搜索是用户最常用的功能但也是SQL注入的重灾区。新手常写// 危险拼接SQL String sql SELECT * FROM user WHERE 11; if (!StringUtils.isBlank(username)) { sql AND username LIKE % username %; }这等于把匕首递给黑客。本项目采用预编译参数化查询 动态条件构建DAO层用Map传递动态条件public ListUser findUsersByCondition(MapString, Object params) { StringBuilder sql new StringBuilder(SELECT id, username, status, create_time FROM user WHERE 11); ListObject values new ArrayList(); // 存放?对应的参数 if (params.containsKey(username) StringUtils.isNotBlank((String) params.get(username))) { sql.append( AND username LIKE ?); values.add(% params.get(username) %); } if (params.containsKey(status) params.get(status) instanceof Integer) { sql.append( AND status ?); values.add(params.get(status)); } if (params.containsKey(startTime) params.get(startTime) instanceof Date) { sql.append( AND create_time ?); values.add(params.get(startTime)); } sql.append( ORDER BY id DESC); // 执行查询需自行实现参数绑定此处略 return executeQuery(sql.toString(), values.toArray()); }Service层封装搜索入口public PageResultUser searchUsers(String username, Integer status, String startTimeStr, int page, int size) { MapString, Object params new HashMap(); if (StringUtils.isNotBlank(username)) { params.put(username, username.trim()); // 去首尾空格 } if (status ! null) { params.put(status, status); } if (StringUtils.isNotBlank(startTimeStr)) { try { Date startTime new SimpleDateFormat(yyyy-MM-dd).parse(startTimeStr); params.put(startTime, startTime); } catch (ParseException e) { // 解析失败忽略该条件 } } ListUser list userDao.findUsersByCondition(params); long total userDao.countByCondition(params); // 同样动态count return new PageResult(list, page, size, total); }前端搜索表单与重置逻辑!-- search.jsp -- form action${pageContext.request.contextPath}/user/search methodget 用户名input typetext nameusername value${param.username} placeholder支持模糊搜索 状态select namestatus option value全部/option option value1 ${param.status 1 ? selected : }启用/option option value0 ${param.status 0 ? selected : }禁用/option /select 创建时间input typedate namestartTime value${param.startTime} button typesubmit搜索/button a href${pageContext.request.contextPath}/user/list重置/a /form关键技巧a href.../list重置/a比button typereset更可靠因为reset只清空表单不改变URL参数用户刷新页面还会带着旧参数搜索。而/list是全新路径彻底清空。4. 常见问题与排查技巧实录那些只在深夜调试时才会浮现的坑4.1 中文乱码从URL到数据库的全链路攻防乱码是Java Web的“传统艺能”但根源往往不在编码设置而在环节遗漏。以下是真实排查记录现象可能原因排查步骤解决方案浏览器地址栏显示%E7%94%A8%E6%88%B7%E5%90%8D但页面正常URL编码正常无需处理在Chrome开发者工具Network标签页查看Request URL原始值无须处理这是标准URL编码表单提交后request.getParameter(username)得到????Tomcat默认ISO-8859-1解码在Servlet开头打印new String(request.getParameter(username).getBytes(ISO-8859-1), UTF-8)在web.xml中配置filter强制解码或在doPost开头统一request.setCharacterEncoding(UTF-8)数据库存入??但JSP显示正常MySQL连接URL未指定编码检查JDBC URLjdbc:mysql://localhost:3306/mydb?useUnicodetruecharacterEncodingUTF-8补全URL参数重启TomcatJSP页面中文显示为方块JSP文件本身编码非UTF-8用Notepad打开JSP查看右下角编码若为ANSI则转为UTF-8无BOM文件另存为UTF-8格式并在JSP顶部加% page contentTypetext/html;charsetUTF-8 %我踩过的坑某次部署到Linux服务器Tomcat启动脚本里JAVA_OPTS-Dfile.encodingUTF-8被注释掉了导致new String(bytes)默认用平台编码Linux是UTF-8Windows是GBK结果Windows开发正常Linux上线全乱码。解决方案永远在代码里显式指定编码不依赖系统默认值。4.2 会话失效30分钟超时背后的“隐形杀手”session.setMaxInactiveInterval(1800)设了30分钟但用户明明在操作页面却突然跳回登录页。常见原因浏览器禁用CookieSession ID通过Cookie传递禁用后每次请求都是新Session。解决方案在web.xml中配置session-configtracking-modeURL/tracking-mode/session-configTomcat会自动在URL后追加;jsessionidxxx。跨域请求丢失Cookie前端用Ajax调用/api/user但withCredentials: true未设置或后端未返回Access-Control-Allow-Credentials: true。解决方案前后端联调时用浏览器Network面板检查Request Headers是否有Cookie字段。Nginx反向代理未透传Cookie生产环境常用Nginx若配置中漏了proxy_cookie_path / /;会导致Cookie路径不匹配。解决方案在Nginx配置的location块中加入proxy_cookie_path / /; HttpOnly; Secure; proxy_set_header Cookie $http_cookie;4.3 分页错乱第2页数据显示第1页内容的诡异现象这是最让人抓狂的问题。典型场景点击“第2页”URL变成?page2size10但页面仍显示第1页的10条数据。排查顺序检查Servlet中page参数获取Integer.parseInt(request.getParameter(page))若参数为空会抛NumberFormatException导致后续逻辑跳过page保持默认值1。解决方案用NumberUtils.toInt(request.getParameter(page), 1)Apache Commons。检查DAO层offset计算(page-1)*size若page为字符串2 带空格Integer.parseInt会截断为2但2 转int是2计算正确但若pageabc则toInt返回0offset0永远查第1页。所以必须校验page0。检查SQL执行在DAO层打印最终SQL确认LIMIT 10,10是否真的执行。曾有学员在PreparedStatement中写错ps.setInt(1, offset); ps.setInt(2, size);但SQL是LIMIT ?, ?参数顺序正确却把offset和size值弄反了导致LIMIT 10,10执行成LIMIT 10,10没错但LIMIT 100,10执行成LIMIT 10,100查出100条数据。4.4 搜索失效输入关键词无结果的“幽灵bug”用户输入“张三”数据库有记录但搜索返回空。可能原因LIKE匹配方式错误写了username LIKE ?但传参是张三应为%张三%。解决方案在Service层统一加%DAO层只负责拼接。大小写敏感MySQL默认utf8mb4_general_ci排序规则是大小写不敏感的但若建表时用了utf8mb4_bin则zhangsan≠ZhangSan。解决方案在SQL中用LOWER(username) LIKE LOWER(?)或建表时指定COLLATE utf8mb4_unicode_ci。全角空格干扰用户复制粘贴的关键词含全角空格 而数据库存的是半角空格 。解决方案在Service层keyword keyword.replace( , ).trim()。最后分享一个小技巧在web.xml中配置error-page将500错误导向自定义页面并在页面中打印exception.stackTrace能极大提升调试效率。但切记上线前关闭避免泄露敏感信息。5. 项目扩展与进阶路径从“能跑”到“能用”的跃迁这个小项目不是终点而是起点。根据你的目标可以沿着不同方向延伸如果目标是求职面试给DAO层加上Druid连接池配置监控页面/druid/index.html展示SQL执行时间、慢SQL列表。将JSP替换为Thymeleaf实践th:each、th:if等模板语法体会前后端分离思想。用JUnit 4写单元测试覆盖UserService的addUser、deleteUser方法用Mock模拟DAO验证业务逻辑。如果目标是性能优化为username字段添加B树索引ALTER TABLE user ADD INDEX idx_username (username);将频繁查询的status1用户缓存到Redis设置TTL300秒DAO层先查缓存未命中再查库。用Logback配置logger namecom.xxx.dao levelDEBUG/开启MyBatis SQL日志分析慢查询。如果目标是工程化用Maven管理依赖pom.xml中定义properties统一spring.version、junit.version。将数据库配置抽离到src/main/resources/jdbc.properties用ResourceBundle.getBundle()加载。编写Shell脚本deploy.sh一键编译、打包、拷贝

相关新闻

Artcraft:数字创作中的手作逻辑重构

Artcraft:数字创作中的手作逻辑重构

1. “Artcraft”不是新词,而是被低估的创作范式重构信号最近在多个设计社区、开源项目讨论区和创意工作坊里,“artcraft”这个词频繁跳出来,但没人给它下过准确定义。它不像“generative design”或“creative coding”那样有清晰的技术边界&…

2026/10/15 8:37:47 阅读更多 →
AI驱动智能家居落地指南:从架构、多模态感知到决策引擎

AI驱动智能家居落地指南:从架构、多模态感知到决策引擎

过去几年我帮不少朋友和客户搭过智能家居,听得最多的需求是“我喊一声,灯就亮”;今年开始,同样一批人跑来问我的问题变成了“我还没开口,它能不能就先知道我要干嘛”。从“你喊我动”到“懂你所需”,这中间…

2026/10/15 8:37:47 阅读更多 →
SpringBoot+Vue影院购票系统开发与高并发优化实践

SpringBoot+Vue影院购票系统开发与高并发优化实践

1. 项目背景与核心价值洋州影院购票管理系统是一个典型的全栈Web应用开发项目,采用当下企业级开发中最流行的SpringBootVue技术栈组合。这类系统在高校计算机相关专业的毕业设计中具有极高的选题率——根据国内主流代码托管平台统计,影院管理系统在近三年…

2026/10/15 8:37:47 阅读更多 →

最新新闻

学Simulink——储能变流器(PCS)中 PWM 整流器的恒流充放电(CC Charge / CC Discharge)仿真

学Simulink——储能变流器(PCS)中 PWM 整流器的恒流充放电(CC Charge / CC Discharge)仿真

目录 手把手教你学Simulink——储能变流器(PCS)中 PWM 整流器的恒流充放电(CC Charge / CC Discharge)仿真 一、为什么储能 PCS 要做 恒流(CC)充放电双向 PWM 整流器 二、PCS 双向控制与 CC 模式原理** 2.1 功率平衡估算(用于 idref​折算) 2.2 模式定义 三、关键…

2026/10/15 10:38:32 阅读更多 →
TaoToken 视角下的示波器使用:measure/acquire/storage/cursor 四步实测

TaoToken 视角下的示波器使用:measure/acquire/storage/cursor 四步实测

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 10:38:32 阅读更多 →
学Simulink——三相 PWM 整流器在船舶岸电系统(Shore-to-Ship Power, Cold Ironing)中的并网控制仿真

学Simulink——三相 PWM 整流器在船舶岸电系统(Shore-to-Ship Power, Cold Ironing)中的并网控制仿真

目录 手把手教你学Simulink——三相 PWM 整流器在船舶岸电系统(Shore-to-Ship Power, Cold Ironing)中的并网控制仿真 一、为什么船舶岸电系统要用 三相 PWM 整流器/并网变流器 二、岸电系统典型参数(教学模型)** 三、Simulink 建模(手把手)** 3.1 Step 1️⃣ —— 港…

2026/10/15 10:38:32 阅读更多 →
PP-HumanSeg Lite + NCNN C++ 部署实战:精准版本对齐与边缘落地

PP-HumanSeg Lite + NCNN C++ 部署实战:精准版本对齐与边缘落地

简介:本资源是一套面向嵌入式与移动端开发者的轻量级人像分割部署方案,聚焦PP-HumanSeg Lite模型在NCNN框架下的C端高效落地,适用于视频会议背景虚化、AR换装、实时抠图等低延迟场景,适合具备C基础和一定CV部署经验的中高级开发者…

2026/10/15 10:38:32 阅读更多 →
OpenGL正背面剔除与深度测试:原理、配置与穿模问题排查

OpenGL正背面剔除与深度测试:原理、配置与穿模问题排查

1. 从一个“穿模”现象说起:为什么需要正背面剔除与深度测试但凡做过3D渲染的人,几乎都遇到过这样的场景:一个封闭的立方体或者角色模型,旋转到某个角度后,本该被挡住的背面却透了出来,或者两个物体交叠的地…

2026/10/15 10:38:32 阅读更多 →
企业WordPress定制开发:核心价值与选型指南

企业WordPress定制开发:核心价值与选型指南

1. 为什么企业需要专业的WordPress定制开发服务 在数字化浪潮席卷各行各业的当下,企业官网已从简单的"电子名片"升级为集品牌展示、客户服务和业务转化于一体的核心平台。WordPress作为全球占比43%的内容管理系统,凭借其开源特性、丰富的插件生…

2026/10/15 10:37:31 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 9:49:14 阅读更多 →