JSP+Servlet+JDBC实战:手把手开发蛋糕店购物商城系统
简介这是一份基于 JSP 与 Servlet 的 JavaWeb 蛋糕店售卖网站项目适合正在做课程设计或入门 JavaWeb 开发的学习者。项目采用 IDEA 2017.3.5 开发配套 Tomcat 8.5.35 与 MySQL 5.7前台包含商品分类展示、推荐位、购物车、登录注册、订单查询与关键字搜索后台则支持订单状态管理、用户管理、商品类目与商品信息维护能较好演示 Servlet JSP JDBC 的经典分层实现。资源包共 290 个文件压缩后 19.11MB其中 jsp 页面、java 类、xml 配置为主要代码jpg/png/gif 提供界面与展示素材另有 js/css 做前端交互、jar 包辅助连接池c3p0/DButil与 1 份 SQL 数据库脚本目录结构可直接导入 IDEA 运行。目前已有 126 人浏览学习。对希望快速获得可运行商城原型、并参考前后台完整流程来完善毕业设计或实训项目的读者是一份实用性较强的参考资源。1. 一个 Servlet 就会转起来的蛋糕店网站做蛋糕店售卖网站最常见的坑不是功能写不完而是框架选重。Spring Boot 全家桶配 MyBatis 确实成熟但一个“商品列表 → 加购物车 → 下单”的小站用 JSPServlet 反而更快不用等 Maven 拉几千个依赖也不用先去理解自动配置背后的运作逻辑。这个标题的技术点就三件事——JSP 渲染页面、Servlet 接收请求、JDBC 操作 MySQL把它们串成三层结构就是一个完整的 JavaWeb 项目案例。对新手来说它能把请求生命周期、Session、转发与重定向这些概念全部落到可运行的代码里对熟手来说把原生 Servlet 的映射和生命周期搞透再回头看 DispatcherServlet 与 Spring 的各种上下文很多“魔法”就不再是黑盒。2. 把工程立起来JSP 和 Servlet 的分工、映射与目录结构2.1 JSP 和 Servlet 谁管渲染谁管流程一次请求的分工链前端点击“查看蛋糕详情”浏览器发一个 GET 请求到/cake?actiondetailid3。在 JavaWeb 工程里这条链路通常是Servlet 容器按web.xml或注解找到对应的 Servlet 子类调用它的doGet()Servlet 负责读参数、调 Service、查数据库、把结果放到 request 作用域最后forward到 JSPJSP 拿到 request 里的数据渲染成 HTML 返回浏览器。这就是 JSP 和 Servlet 的核心分工。两者的本质关系是JSP 本身就是 Servlet。Tomcat 会把 JSP 翻译成一个 Java 类再编译成 class运行逻辑集中在_jspService()方法里。所以不存在“JSP 比 Servlet 高级”的说法只是 JSP 在模板表达上更舒服。反过来只用 Servlet 打印 HTML 字符串拼接页面改一个样式要改代码再重启维护成本很高。正确的落点是一张表格里能说清的关系关注点ServletJSP请求解析、参数校验、流程跳转主要负责不负责数据查询、业务计算、事务控制主要负责尽量避免页面结构、样式、表单渲染不方便主要负责编译时机容器加载后首次请求首次被访问时典型文件位置src/main/javasrc/main/webapp/WEB-INF/views搞清这条分工链之后再看热门的 DispatcherServlet 就不会懵它本质上也是一个 HttpServlet 子类Spring 只是在这个 Servlet 内部扩展了一堆组件比如 HandlerMapping、ModelAndView 解析把请求分发给 Controller 方法而已。原生 Servlet 里的映射规则、request 和 response 生命周期在框架里一模一样。把这条链路看明白后面调试 JavaWeb 项目会顺手很多。2.2 从 Eclipse 创建 Maven Servlet 项目开始目录与 web.xml 最小配置工程落地有很多方式常见的做法是 Eclipse 里选 Maven 的maven-archetype-webapp骨架然后补javax.servlet-api、JSTL、MySQL 驱动和 Druid 连接池依赖。注意javax.servlet-api的 scope 要写成provided因为 Tomcat 自己带一份 Servlet 实现打 WAR 包时不需要再塞进去否则容器启动可能出现类冲突。建议的依赖清单如下dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.20/version /dependency依赖里的 provided 是给打包工具看的标记编译、测试时使用运行时不输出到 WAR 的lib目录。JSTL 1.2 配 Servlet 4.0 是 JavaWeb 课程设计里最稳的组合版本再老一点会出现 EL 函数库加载不出来的情况。目录结构按照 Maven Web 项目标准来页面不直接放根目录而是放在WEB-INF/views下这样浏览器无法直接输地址访问 JSP只能通过 Servlet forward 进入等于多了一层入口控制src/main/java com.happycake.entity Cake, CartItem, User, Order com.happycake.dao CakeDao, OrderDao com.happycake.service CakeService, OrderService com.happycake.web CakeServlet, CartServlet, OrderServlet, LoginServlet com.happycake.filter LoginFilter src/main/webapp WEB-INF/web.xml WEB-INF/views/cakeList.jsp, cart.jsp, login.jsp, userCenter.jsp static/css/site.css static/js/cart.jsweb.xml里最关键的配置是 Servlet 映射和欢迎页。很多项目把 Servlet 写成注解WebServlet(/cake)这在 Servlet 3.0 之后完全可行两者选一种即可不要同一路径重复配置。这里用 web.xml 演示一下最保守的写法web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list servlet servlet-namecakeServlet/servlet-name servlet-classcom.happycake.web.CakeServlet/servlet-class load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namecakeServlet/servlet-name url-pattern/cake/url-pattern /servlet-mapping session-config session-timeout30/session-timeout /session-config /web-appload-on-startup设为 1表示容器启动时立即实例化这个 Servlet而不是等第一次请求适合在 Servlet 里初始化数据源或定时任务的场景。session-timeout单位是分钟不用写太短用户往购物车里加东西隔一阵回来发现 Session 没了体验很差。2.3 request.getContextPath()JSP 里路径不写死的唯一正解JSP 里最容易出现的一类 404是静态资源路径写成了/static/css/site.css。如果项目部署到 Tomcat 时上下文路径是/happycake浏览器会去访问http://localhost:8080/static/...而不是http://localhost:8080/happycake/static/...所以必须在所有资源路径前拼上应用上下文。最省事的写法是 JSTL 的c:set% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % c:set varctx value${pageContext.request.contextPath} / link relstylesheet href${ctx}/static/css/site.css script src${ctx}/static/js/cart.js/script${pageContext.request.contextPath}返回的就是部署后的上下文根路径比如/happycake。把这个值存成变量ctx整个 JSP 里所有 form 的action、图片的src、超链接的href都基于它拼接。这不是可选项不做上下文路径处理的项目换一台机器或者换个部署名就大面积 404属于典型的“本地能跑、部署就挂”。3. 蛋糕列表到购物车Servlet 取数、JSP 渲染、Session 装车3.1 cake 表与 Druid 连接池先让查询有数据源可用商品模块最少需要两张表分类表和蛋糕表。蛋糕表里status字段控制上下架避免删数据导致订单历史对不上价格用DECIMAL(10,2)不要用 float 或 double二进制浮点在做金额累加时会产生 0.1 0.2 不等于 0.3 这类问题。建表 SQL 如下CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE cake ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL, name VARCHAR(64) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, image VARCHAR(128), description VARCHAR(500), status TINYINT NOT NULL DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category_id), CONSTRAINT fk_cake_category FOREIGN KEY (category_id) REFERENCES category(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;utf8mb4是因为 MySQL 的 utf8 最多存 3 字节遇到 emoji 或生僻字会报错蛋糕名称里如果写了“”这类字符utf8 直接存不进去。外键约束在课程设计里建议保留能防止脏数据生产高并发场景通常会去掉外键把约束放到应用层但小站没必要一开始就玩这套。数据源用 Druid连接池参数怎么设取决于并发量。本地项目和课程设计阶段最关键的几个参数如下参数建议值说明initialSize5启动时初始化连接数maxActive20最大活跃连接数超过则排队maxWait10000获取连接最长等待时间毫秒minEvictableIdleTimeMillis60000空闲连接最小存活时间testWhileIdletrue空闲时检测连接是否可用DBUtil里做一个静态的数据源实例之后所有 DAO 都从这里拿连接public class DBUtil { private static final DruidDataSource dataSource new DruidDataSource(); static { dataSource.setUrl(jdbc:mysql://localhost:3306/happycake?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/Shanghai); dataSource.setUsername(root); dataSource.setPassword(123456); dataSource.setInitialSize(5); dataSource.setMaxActive(20); dataSource.setMaxWait(10000); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }连接串里的characterEncodingUTF-8控制 MySQL 客户端编码serverTimezone是 MySQL 8 驱动的要求不加会报时区异常。连接池的作用是复用连接而不是每次请求都新建新建连接的 TCP 握手和认证开销在并发上来以后会明显拖慢接口。3.2 只写一个 CakeServletaction 参数把列表、详情、搜索串起来商品模块没必要拆四五个 Servlet常见做法是保留一个CakeServlet用action参数区分操作路由在doGet()里集中判断。功能少的时候这种“入口分发”比拆多个类更直观也方便统一做权限校验WebServlet(/cake) public class CakeServlet extends HttpServlet { private final CakeService cakeService new CakeService(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action req.getParameter(action); if (action null) { action list; } switch (action) { case detail: showDetail(req, resp); break; case search: search(req, resp); break; default: list(req, resp); } } private void list(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int page 1; int pageSize 12; String p req.getParameter(page); if (p ! null p.matches(\\d)) { page Integer.parseInt(p); } ListCake cakes cakeService.findPage(page, pageSize); int total cakeService.count(); req.setAttribute(cakes, cakes); req.setAttribute(currentPage, page); req.setAttribute(totalPage, (total pageSize - 1) / pageSize); req.getRequestDispatcher(/WEB-INF/views/cakeList.jsp).forward(req, resp); } }这段代码有三个细节值得说。第一p.matches(\\d)在做数字转换前先校验格式避免用户传pageabc导致NumberFormatException这种参数校验是 Servlet 层的基本功。第二getRequestDispatcher().forward()是服务器内部跳转地址栏不变request 里的属性在 JSP 中能直接读取如果用sendRedirect()request 里的数据会全部丢失。第三JSP 放在WEB-INF/views下路径以/开头时基于应用根目录解析不以/开头时基于当前路径容易出问题规范写法是/WEB-INF/...全路径。3.3 商品卡片渲染JSTL 与 EL不要在 JSP 里写 Java列表页的数据已经放在 request 属性里JSP 里只做遍历和展示。很多老教程喜欢在 JSP 里写% for (...) %这种 scriptlet 写法的问题是页面里混入 Java 代码后修改样式可能不小心改动逻辑报错时错误行号指向 JSP 编译后的 Java 类不直观。推荐的做法是 JSTL 加 EL 表达式% page contentTypetext/html; charsetUTF-8 pageEncodingUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % div classcake-grid c:forEach items${cakes} varcake div classcake-card img src${ctx}/upload/${cake.image} alt${cake.name} h3${cake.name}/h3 pfmt:formatNumber value${cake.price} typecurrency currencySymbol¥//p p库存${cake.stock}/p a href${ctx}/buyer/cart?actionaddcakeId${cake.id}加入购物车/a /div /c:forEach /divc:forEach的items绑定 request 里的cakes集合varcake是循环变量名。fmt:formatNumber做金额格式化避免出现88.8和88.80显示不一致的问题。页面顶部taglib指令必须写否则 JSTL 标签不会被解析浏览器会把整段标签当普通文本输出。路径里的/buyer/cart前缀配合后面的 Filter 使用可以直接实现“未登录不能加购物车”。3.4 购物车不落库HttpSession 里的 Map 就是购物车购物车放 Session 是最常见的实现方式核心数据结构是一个MapInteger, CartItemkey 是蛋糕 idvalue 是商品和数量的组合。这样每个用户有自己的会话互不干扰也不需要设计购物车表。用 Session 存购物车服务器内存换开发效率代价是应用重启购物车丢失、集群部署时需要 Session 共享方案。小站项目完全可以接受。先定义购物车条目包含商品对象和数量以及计算小计的方法public class CartItem { private Cake cake; private int quantity; public CartItem(Cake cake, int quantity) { this.cake cake; this.quantity quantity; } public double getSubtotal() { return cake.getPrice() * quantity; } public Cake getCake() { return cake; } public int getQuantity() { return quantity; } public void setQuantity(int quantity) { this.quantity quantity; } }加入购物车的 Servlet 要做三步处理从 Session 取购物车没有就新建判断商品是否已在购物车已存在则数量加一否则放入新条目WebServlet(/buyer/cart) public class CartServlet extends HttpServlet { private final CakeService cakeService new CakeService(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action req.getParameter(action); if (add.equals(action)) { int cakeId Integer.parseInt(req.getParameter(cakeId)); HttpSession session req.getSession(); MapInteger, CartItem cart (MapInteger, CartItem) session.getAttribute(cart); if (cart null) { cart new HashMap(); session.setAttribute(cart, cart); } CartItem item cart.get(cakeId); if (item null) { Cake cake cakeService.findById(cakeId); cart.put(cakeId, new CartItem(cake, 1)); } else { item.setQuantity(item.getQuantity() 1); } resp.sendRedirect(req.getContextPath() /buyer/cart?actionview); } else { // actionview 时转发到购物车 JSP展示全部条目和总价 req.getRequestDispatcher(/WEB-INF/views/cart.jsp).forward(req, resp); } } }这里的sendRedirect不能写成相对路径/buyer/cart必须用req.getContextPath()拼接否则在部署路径不是根路径时会跳错。购物车 URL 前缀是/buyer/与登录 Filter 的拦截路径一致这是刻意设计的约定。3.5 登录校验 Filter 与个人信息展示页面如果用户没登录就点“加入购物车”就会直接进入CartServlet。拦截逻辑不需要写进每个 Servlet用 Filter 统一处理。Filter 的拦截路径设成/buyer/*这个前缀下的所有请求都先经过登录检查WebFilter(/buyer/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; Object user req.getSession().getAttribute(loginUser); if (user null) { resp.sendRedirect(req.getContextPath() /login.jsp?redirect req.getRequestURI()); return; } chain.doFilter(request, response); } }doFilter放行表示请求继续往下走不放行就重定向到登录页。redirect参数记录原始地址登录成功后再跳回来这是用户体验比较好的回跳做法。登录成功时把用户对象放进 Session 的loginUser属性这个属性的名字要和 Filter 里一致这是 JavaWeb 项目里最常见的低级错误。个人信息展示页面也就是热词里的“jsp个人信息展示页面”逻辑很简单登录后从 Session 取数据而不是再查一遍数据库。页面里用${sessionScope.loginUser.nickname}显示昵称、${sessionScope.loginUser.lastLoginTime}显示上次登录时间。sessionScope是 EL 内置作用域不需要在 Servlet 里手动 setAttribute 用户信息只要 Filter 保证访问该页面前已登录即可。4. 下单和扣库存Servlet 里那笔绕不开的事务与订单表设计4.1 订单两张表order 与 order_item 的字段取舍购物车里可以有多样商品所以订单需要主表和明细表分别存订单级信息和每个商品的信息。注意order是 SQL 保留字建表必须加反引号Java 实体类里对应Order类也要小心命名冲突CREATE TABLE order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, cake_id INT NOT NULL, cake_name VARCHAR(64) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, KEY idx_order (order_id), CONSTRAINT fk_order_item_order FOREIGN KEY (order_id) REFERENCES order(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;order_item里冗余了cake_name和price这是一个有意的设计商品表的数据随时可能被编辑或下架但订单属于历史数据必须保留下单那一刻的商品名和价格。如果不冗余商品改价后订单总额就说不清了。订单状态用 TINYINT 存数字含义由代码约定status含义0待支付1已支付2已发货3已完成4已取消order_no是面向用户的订单号不要直接用自增 id 展示。常见做法是时间戳拼接用户 id 加随机数比如20241201103015 userId 4 位随机数保证可读性和唯一性。4.2 下单事务同一个 Connection 上 insert 订单加扣库存下单操作要写两张表还要修改商品库存这三步必须在一个数据库事务里完成。典型失败场景是订单主表插入成功、明细表插入时出错或者库存扣减失败如果每步提交一次就会出现“有订单但库存没扣”或“钱扣了没订单”的脏数据。Servlet 层怎么控制事务核心逻辑是拿到一个 Connection关闭自动提交所有 DAO 方法都接收这个外部传入的连接最后统一 commit 或 rollbackOverride protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session req.getSession(); User user (User) session.getAttribute(loginUser); MapInteger, CartItem cart (MapInteger, CartItem) session.getAttribute(cart); if (cart null || cart.isEmpty()) { resp.sendRedirect(req.getContextPath() /buyer/cart?actionview); return; } Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); Order order new Order(); order.setOrderNo(System.currentTimeMillis() user.getId()); order.setUserId(user.getId()); order.setTotalAmount(calcTotal(cart)); long orderId orderDao.insert(conn, order); for (CartItem item : cart.values()) { int rows cakeDao.deductStock(conn, item.getCake().getId(), item.getQuantity()); if (rows 0) { throw new RuntimeException(库存不足 item.getCake().getName()); } orderItemDao.insert(conn, orderId, item); } conn.commit(); session.removeAttribute(cart); resp.sendRedirect(req.getContextPath() /buyer/order?actionsuccess); } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignored) {} } throw new ServletException(下单失败, e); } finally { if (conn ! null) { try { conn.setAutoCommit(true); } catch (SQLException ignored) {} try { conn.close(); } catch (SQLException ignored) {} } } }这里最容易被忽略的是orderDao.insert(conn, order)和cakeDao.deductStock(conn, ...)必须接收同一个外部连接。如果 DAO 内部自己调用DBUtil.getConnection()每个 DAO 拿到的是不同连接conn.commit()只对当前这个连接生效其他连接的写入会在方法返回时自动提交事务边界就失效了。orderDao.insert()返回自增主键时PreparedStatement 必须显式加Statement.RETURN_GENERATED_KEYS否则getGeneratedKeys()拿不到 id。finally里恢复setAutoCommit(true)是给连接池用的连接归还 Druid 时如果还处于手动提交状态下一次借出的事务行为会完全错乱。4.3 防超卖不是锁表UPDATE 带库存条件的原子扣减扣库存的 SQL 如果写成“先查库存判断够不够再扣”在并发场景下一定会超卖。比如库存剩 1两个用户同时查到库存 1都判断可以买两次 UPDATE 后库存变成 -1。正确做法是把判断条件写进 UPDATE 语句让数据库在行锁内完成判断和扣减UPDATE cake SET stock stock - ? WHERE id ? AND stock ?执行这条语句时受影响行数为 1 表示扣减成功为 0 表示库存不足。事务代码里判断rows 0后直接抛异常触发回滚订单和库存就不会产生不一致。代价是每次 UPDATE 会对蛋糕行加行锁高并发下会有短暂阻塞但蛋糕店售卖网站的单品并发量远没到需要引入 Redis 预扣库存的程度。同样效果的做法是乐观锁version字段适合并发量更高的秒杀场景悲观锁SELECT ... FOR UPDATE会把读操作也锁住事务持有锁时间更长在这类项目里属于过度设计。条件 UPDATE 写法简单且正确维护成本最低。5. 部署 Tomcat 后的 JSP 排查三件事编译缓存、EL 失效与页面离开拦截5.1 JSP 编译后的 class 存在哪work 目录怎么查JSP 是首次访问时才被 Tomcat 编译成 Java 类和 class 文件很多人改完 JSP 刷新浏览器看不到变化第一反应是 Tomcat 没重启其实先要去确认编译输出。默认情况下编译产物在 Tomcat 的work目录下find $CATALINA_HOME/work -name *cakeList_jsp*比如$CATALINA_HOME/work/Catalina/localhost/happycake/org/apache/jsp/cakeList_jsp.java和对应的.class。查看这个 Java 文件能直接定位 JSP 里的语法错误或 EL 表达式问题。若改了 JSP 仍然不生效先检查 Tomcat 的 Context 配置里reloadable是否为true设为false时容器不会监听 JSP 变化更直接的办法是删掉work目录下对应项目的编译缓存重启后强制重新编译。5.2 EL 表达式显示为原始文本以及按需拦截离开页面提示页面里${cake.name}原样输出而不是解析结果常见原因是 web.xml 里配置了 EL 忽略开关。检查WEB-INF/web.xml中jsp-config段落jsp-config jsp-property-group url-pattern*.jsp/url-pattern el-ignoredfalse/el-ignored /jsp-property-group /jsp-configel-ignored设为false表示启用 EL 解析这是 Servlet 2.4 之后 JSP 2.0 的默认行为但很多从老版本拷贝来的 web.xml 会带着true。另外如果web.xml的version低于 2.4EL 默认就是关闭的把根标签的版本提升到 4.0 并重新发布即可。页面离开提示的屏蔽也常出现在购物车和结算页用户改过数量后误关页面或者反过来页面一直弹“确定要离开吗”让人烦躁。现代浏览器的行为是只有调用了preventDefault()且设置了returnValue才触发确认弹窗而且弹窗里的文案由浏览器固定控制自定义文本早就不生效let cartDirty false; window.addEventListener(beforeunload, function (e) { if (!cartDirty) { return; } e.preventDefault(); e.returnValue ; }); document.getElementById(quantityInput) .addEventListener(change, function () { cartDirty true; });cartDirty标志位控制是否启用提示只有用户修改过数量才拦截避免正常离开页面也被询问。不想启用时直接把cartDirty保持false或移除监听器即可。5.3 扩展Servlet 同样能调外部接口给订单附一段推荐语JavaWeb 项目不是只能查自己数据库。比如下单成功页想给用户展示一段商品推荐语可以在 Servlet 里调用外部大模型接口把订单中的蛋糕名拼进 prompt拿返回结果放进 request 传给 JSP。Servlet 里调 REST 接口和普通 Java 程序没有区别用 JDK 自带的HttpClient就能实现String prompt 用一句话推荐蛋糕 cakeName 要求包含口味和场景; String payload {\model\:\你的模型名\,\messages\:[{\role\:\user\,\content\:\ prompt \}]}; HttpClient client HttpClient.newHttpClient(); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(https://模型服务地址/v1/chat/completions)) .header(Content-Type, application/json) .header(Authorization, Bearer apiKey) .POST(HttpRequest.BodyPublishers.ofString(payload)) .build(); HttpResponseString response client.send( request, HttpResponse.BodyHandlers.ofString());返回的 JSON 里对话内容通常嵌套在choices[0].message.content解析时用 JSONObject 一层层取注意导入org.json.JSONArray和org.json.JSONObject这两个类在 JSP 或 Servlet 里都能直接使用。超时和异常要单独控制这种外部接口调用不能影响主订单流程建议包在 try-catch 中失败时记录日志并返回兜底文案。这样一来原生 Servlet 的技能点也能和时下热门的模型接口调用衔接上。本文还有配套的精品资源点击获取