☰
Struts销售管理系统实战:从订单录入到报表导出的完整业务闭环
2026/10/18 16:41:09 网站建设 项目流程

简介:这是一套基于Struts框架构建的企业级销售管理系统源码,面向Java Web开发者、课程设计学生及需要理解MVC分层架构的进阶学习者,用于解决销售流程中商品进出货与客户关系管理的自动化问题。资源包共298个文件,约2.05MB,以94个JSP页面、48个Java源文件与48个class文件为核心,辅以35个JS脚本、18个CSS样式及图片资源,完整呈现了Action、DAO与视图层的协作结构。系统围绕商品进出货管理和客户管理两大模块展开,涵盖入库出库记录、库存查询与盘点、客户信息维护、分类评级及售后服务等业务环节,并涉及销售报表与促销管理等扩展方向。已有259人学习该资源,读者可借此掌握Struts标签库、国际化支持及控制器协调模型与视图的实践方式,适合作为课程设计或企业级Web开发的参考案例。

1. 销售管理系统:从订单录入到报表导出的完整业务闭环

很多团队做后台系统时,最怕的不是业务复杂,而是流程断点——订单录进去了,库存没扣;库存扣了,回款状态又对不上。这套基于 Struts 的销售管理系统,解决的就是这个痛点。它把客户管理、商品维护、订单处理、销售统计串成一条线,适合中小型贸易公司或批发零售场景做内部进销存管理,也适合正在学 Struts 框架的开发者拿来做二次开发练手。项目采用经典的 MVC 分层,Action 负责调度、Service 管业务、DAO 碰数据库,JSP 做视图渲染,整体结构清晰,改起来不费劲。下面从目录结构开始,一步步拆到能跑、能改。

2. 项目结构与技术栈:Struts 分层到底怎么落地的

2.1 目录结构一览

拿到源码包后,先别急着配环境,花五分钟把目录扫一遍,后面改代码能省不少时间。典型的 Struts 项目结构长这样:

sales-management/ ├── src/ │ ├── com/sales/action/ # Struts Action 类,处理请求转发 │ ├── com/sales/service/ # 业务逻辑层,订单计算、库存校验 │ ├── com/sales/dao/ # 数据访问层,JDBC 或 Hibernate 封装 │ ├── com/sales/model/ # 实体类,Customer、Order、Product │ └── com/sales/util/ # 工具类,分页、日期格式化、MD5 ├── WebRoot/ │ ├── WEB-INF/ │ │ ├── web.xml # Struts 前端控制器配置 │ │ ├── struts-config.xml # Action 映射与跳转规则 │ │ └── jsp/ # 视图页面,按模块分目录 │ ├── css/ # 样式文件 │ ├── js/ # 前端校验脚本 │ └── images/ # 图标与产品图片 ├── lib/ # 第三方 jar 包 └── sql/ # 建表脚本与初始数据

这个结构的好处是职责边界清楚。Action 里不写 SQL,Service 里不碰 HttpServletRequest,DAO 只负责增删改查。我见过不少项目把业务逻辑全塞 Action 里,后期加个审批流就得推倒重来,血泪经验。

2.2 核心配置文件拆解

Struts 项目的灵魂在struts-config.xml,它决定了请求怎么找到对应的 Action,以及处理完跳哪个页面。下面是一个订单模块的典型配置:

<struts-config> <form-beans> <!-- 订单表单,字段与 JSP 表单 name 属性一一对应 --> <form-bean name="orderForm" type="com.sales.action.OrderForm"/> </form-beans> <action-mappings> <!-- 订单列表:查询后转发到列表页 --> <action path="/orderList" type="com.sales.action.OrderAction" parameter="method" scope="request"> <forward name="list" path="/WEB-INF/jsp/order/list.jsp"/> </action> <!-- 订单保存:提交后重定向,防止刷新重复提交 --> <action path="/orderSave" type="com.sales.action.OrderAction" parameter="method" scope="request" input="/WEB-INF/jsp/order/edit.jsp"> <forward name="success" path="/orderList.do" redirect="true"/> <forward name="failure" path="/WEB-INF/jsp/order/edit.jsp"/> </action> </action-mappings> <message-resources parameter="com.sales.util.ApplicationResources"/> </struts-config>

这里有几个关键点:parameter="method"让同一个 Action 类可以处理多个操作,通过 URL 参数?method=save来区分,避免类爆炸。redirect="true"用在保存成功后,防止用户按 F5 重复提交订单——这个坑我踩过,数据库里多出三条一样的订单,排查半天才发现是转发没加重定向。

web.xml里主要配 Struts 的ActionServlet和字符编码过滤器:

<web-app> <filter> <filter-name>encoding</filter-name> <filter-class>com.sales.util.EncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>encoding</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <servlet> <servlet-name>action</servlet-name> <servlet-class>org.apache.struts.action.ActionServlet</servlet-class> <init-param> <param-name>config</param-name> <param-value>/WEB-INF/struts-config.xml</param-value> </init-param> <load-on-startup>2</load-on-startup> </servlet> <servlet-mapping> <servlet-name>action</servlet-name> <url-pattern>*.do</url-pattern> </servlet-mapping> </web-app>

*.do是 Struts 的经典后缀,所有以.do结尾的请求都交给 ActionServlet 处理。字符编码过滤器必须放在最前面,否则中文商品名传到后台就是乱码,这个玄学问题困扰过不少人。

2.3 数据库表设计要点

销售系统的核心表就四张:客户表、商品表、订单主表、订单明细表。建表脚本在sql/目录下,导入后直接能用。

-- 订单主表:记录一次交易的整体信息 CREATE TABLE t_order ( order_id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, -- 业务单号,展示用 customer_id INT NOT NULL, -- 关联客户 order_date DATETIME DEFAULT NOW(), -- 下单时间 total_amount DECIMAL(12,2) DEFAULT 0, -- 订单总额,由明细汇总 status TINYINT DEFAULT 0, -- 0待付款 1已付款 2已发货 3已完成 remark VARCHAR(255), FOREIGN KEY (customer_id) REFERENCES t_customer(customer_id) ); -- 订单明细表:一个订单对应多条商品记录 CREATE TABLE t_order_item ( item_id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, unit_price DECIMAL(10,2) NOT NULL, -- 下单时的单价,防止商品调价影响历史订单 subtotal DECIMAL(12,2) NOT NULL, -- quantity * unit_price FOREIGN KEY (order_id) REFERENCES t_order(order_id), FOREIGN KEY (product_id) REFERENCES t_product(product_id) );

注意unit_price字段:商品表里的价格会变,但订单里的价格必须冻结在下单那一刻。我见过系统直接关联商品表取当前价,结果调价后历史订单金额全变了,财务对账时直接翻车。所以明细表里冗余单价是必要的,这不是反范式,是业务要求。

3. 核心模块拆解:一个订单从录入到入库的完整链路

3.1 Action 层:请求调度与表单校验

Struts 的 Action 是整个请求的入口。以订单保存为例,OrderAction继承DispatchAction,根据method参数决定执行哪个方法:

public class OrderAction extends DispatchAction { private OrderService orderService = new OrderServiceImpl(); /** * 保存订单:先校验表单,再调用 Service 落库 * mapping: Struts 封装的 ActionMapping,含跳转配置 * form: 页面提交的表单数据,已由 Struts 自动填充 */ public ActionForward save(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) { OrderForm orderForm = (OrderForm) form; // 服务端二次校验,防止绕过前端 JS 提交非法数据 ActionMessages errors = new ActionMessages(); if (orderForm.getCustomerId() == null || orderForm.getCustomerId() <= 0) { errors.add("customer", new ActionMessage("error.customer.required")); } if (orderForm.getItems() == null || orderForm.getItems().isEmpty()) { errors.add("items", new ActionMessage("error.items.empty")); } if (!errors.isEmpty()) { saveErrors(request, errors); return mapping.findForward("failure"); // 回填页面,保留用户输入 } try { // 组装业务对象,Action 不碰数据库 Order order = new Order(); order.setCustomerId(orderForm.getCustomerId()); order.setRemark(orderForm.getRemark()); order.setItems(convertItems(orderForm.getItems())); orderService.createOrder(order); // 事务在 Service 层控制 return mapping.findForward("success"); } catch (BusinessException e) { // 业务异常(如库存不足)友好提示,不暴露堆栈 errors.add(ActionMessages.GLOBAL_MESSAGE, new ActionMessage("error.business", e.getMessage())); saveErrors(request, errors); return mapping.findForward("failure"); } } }

这段代码有几个设计取舍值得说。第一,校验分两层:前端 JS 校验提升体验,服务端校验保证安全,缺一不可。第二,ActionMessages是 Struts 自带的错误收集器,配合<html:errors>标签能在页面直接显示错误信息,不用自己造轮子。第三,业务异常和系统异常分开处理,BusinessException是预期内的(库存不足、客户被冻结),直接提示用户;其他异常往上抛,由全局异常处理器记录日志。

3.2 Service 层:事务控制与库存扣减

Service 层是业务逻辑的核心,订单创建涉及多表写入,必须保证事务一致性:

public class OrderServiceImpl implements OrderService { private OrderDao orderDao = new OrderDaoImpl(); private ProductDao productDao = new ProductDaoImpl(); /** * 创建订单:扣库存、写主表、写明细,三步在一个事务里 * 任何一步失败,全部回滚 */ public void createOrder(Order order) throws BusinessException { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 逐条校验库存并扣减 for (OrderItem item : order.getItems()) { Product product = productDao.findById(conn, item.getProductId()); if (product == null) { throw new BusinessException("商品不存在:" + item.getProductId()); } if (product.getStock() < item.getQuantity()) { throw new BusinessException("库存不足:" + product.getName() + ",当前库存 " + product.getStock()); } // 扣减库存,用乐观锁防止并发超卖 int affected = productDao.reduceStock(conn, item.getProductId(), item.getQuantity(), product.getVersion()); if (affected == 0) { throw new BusinessException("库存扣减冲突,请重试"); } } // 2. 计算订单总额 BigDecimal total = BigDecimal.ZERO; for (OrderItem item : order.getItems()) { BigDecimal subtotal = item.getUnitPrice() .multiply(new BigDecimal(item.getQuantity())); item.setSubtotal(subtotal); total = total.add(subtotal); } order.setTotalAmount(total); order.setOrderNo(generateOrderNo()); // 生成业务单号 // 3. 写入主表和明细表 int orderId = orderDao.insert(conn, order); for (OrderItem item : order.getItems()) { item.setOrderId(orderId); orderDao.insertItem(conn, item); } conn.commit(); // 全部成功才提交 } catch (BusinessException e) { rollbackQuietly(conn); throw e; } catch (SQLException e) { rollbackQuietly(conn); throw new BusinessException("系统繁忙,请稍后重试", e); } finally { DBUtil.close(conn); } } }

库存扣减用乐观锁(version字段)而不是synchronized,因为销售系统可能多台服务器部署,JVM 锁跨不了进程。reduceStock的 SQL 是UPDATE t_product SET stock = stock - ?, version = version + 1 WHERE product_id = ? AND version = ? AND stock >= ?,靠数据库行锁保证原子性。如果affected == 0,说明版本号变了或者库存不够,让用户重试。

订单号生成也有讲究,常见做法是「日期 + 随机数 + 序列」,比如20250115-0001。不要用数据库自增 ID 直接当单号,否则竞争对手能通过单号推算出你的日销量,这个坑很多初创团队都踩过。

3.3 DAO 层:JDBC 封装与分页查询

DAO 层负责和数据库打交道,核心是减少重复代码。下面是一个通用的分页查询实现:

public class OrderDaoImpl implements OrderDao { /** * 分页查询订单列表 * @param conn 外部传入连接,支持事务复用 * @param customerId 客户筛选,null 表示不筛选 * @param status 状态筛选,null 表示不筛选 * @param pageNo 页码,从 1 开始 * @param pageSize 每页条数 */ public Page<Order> findByPage(Connection conn, Integer customerId, Integer status, int pageNo, int pageSize) throws SQLException { StringBuilder sql = new StringBuilder( "SELECT o.*, c.customer_name FROM t_order o " + "LEFT JOIN t_customer c ON o.customer_id = c.customer_id WHERE 1=1"); List<Object> params = new ArrayList<>(); if (customerId != null) { sql.append(" AND o.customer_id = ?"); params.add(customerId); } if (status != null) { sql.append(" AND o.status = ?"); params.add(status); } sql.append(" ORDER BY o.order_date DESC LIMIT ?, ?"); params.add((pageNo - 1) * pageSize); params.add(pageSize); PreparedStatement ps = conn.prepareStatement(sql.toString()); for (int i = 0; i < params.size(); i++) { ps.setObject(i + 1, params.get(i)); } ResultSet rs = ps.executeQuery(); List<Order> list = new ArrayList<>(); while (rs.next()) { list.add(mapRow(rs)); // 结果集转实体,字段映射集中管理 } // 单独查总数,用于计算总页数 int total = countByCondition(conn, customerId, status); return new Page<>(list, total, pageNo, pageSize); } }

分页用LIMIT ?, ?而不是在内存里subList,数据量大了内存分页直接 OOM。LEFT JOIN客户表是为了列表页直接显示客户名,避免 N+1 查询。mapRow方法把ResultSet转成实体,字段名和属性名的映射写在一处,改表结构时只动一个地方。

3.4 JSP 视图:数据展示与表单回填

视图层用 JSP + Struts 标签库,好处是能和 ActionForm 自动绑定。订单编辑页的核心片段:

<%@ taglib uri="http://struts.apache.org/tags-html" prefix="html" %> <%@ taglib uri="http://struts.apache.org/tags-bean" prefix="bean" %> <html:form action="/orderSave.do?method=save" onsubmit="return validateOrderForm()"> <table class="form-table"> <tr> <td>客户:</td> <td> <html:select property="customerId"> <html:option value="">--请选择--</html:option> <html:optionsCollection name="customerList" label="customerName" value="customerId"/> </html:select> <html:errors property="customer"/> </td> </tr> <tr> <td>备注:</td> <td><html:textarea property="remark" rows="3" cols="40"/></td> </tr> </table> <!-- 商品明细行,支持动态增删 --> <table id="itemTable"> <tr> <th>商品</th><th>数量</th><th>单价</th><th>小计</th><th>操作</th> </tr> <c:forEach items="${orderForm.items}" var="item" varStatus="st"> <tr> <td> <html:select property="items[${st.index}].productId"> <html:optionsCollection name="productList" label="productName" value="productId"/> </html:select> </td> <td><html:text property="items[${st.index}].quantity" size="5"/></td> <td><html:text property="items[${st.index}].unitPrice" size="8"/></td> <td class="subtotal">${item.subtotal}</td> <td><a href="javascript:delRow(${st.index})">删除</a></td> </tr> </c:forEach> </table> <input type="button" value="添加商品" onclick="addRow()"/> <html:submit value="保存订单"/> </html:form>

html:errors会自动读取 Action 里saveErrors存的消息,按property显示在对应字段旁边。明细行用items[${st.index}].quantity这种索引属性,Struts 会自动组装成 List。动态增删行靠 JS 操作 DOM,提交时索引要重新排列,否则后台接收的 List 会有空洞——这个细节不注意,保存时就会报NullPointerException。

4. 环境搭建与运行:从零把项目跑起来

4.1 依赖准备与数据库初始化

项目基于 Struts 1.x + JDBC,运行环境要求不高:

组件版本要求说明
JDK1.8+编译和运行
Tomcat7.0/8.5Web 容器,放 WebRoot 目录
MySQL5.7/8.0存储业务数据
Struts1.3.xlib 目录已含 jar 包

先把sql/init.sql导入 MySQL,会创建数据库sales_db和四张表,并插入几条测试数据。命令行操作:

mysql -u root -p -e "CREATE DATABASE sales_db DEFAULT CHARSET utf8mb4;" mysql -u root -p sales_db < sql/init.sql

然后改src/com/sales/util/DBUtil.java里的连接配置,换成你自己的账号密码:

private static final String URL = "jdbc:mysql://localhost:3306/sales_db?useUnicode=true&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "your_password";

注意characterEncoding=utf8不能省,否则中文客户名存进去变问号。MySQL 8.0 还要加serverTimezone=Asia/Shanghai,不然连接时报时区错误。

4.2 编译部署与启动验证

用 Eclipse 或 IDEA 导入项目,把lib/下的 jar 包加到 Build Path。编译后部署到 Tomcat:

# 假设 Tomcat 装在 /opt/tomcat cp -r WebRoot/* /opt/tomcat/webapps/sales/ cp -r build/classes/* /opt/tomcat/webapps/sales/WEB-INF/classes/ /opt/tomcat/bin/startup.sh

浏览器访问http://localhost:8080/sales/login.jsp,默认账号admin,密码123456(见t_user表)。登录后能看到左侧菜单:客户管理、商品管理、订单管理、销售报表。先加两个客户和几个商品,再下一单试试,订单列表能看到刚录入的数据就说明跑通了。

如果启动报ClassNotFoundException: org.apache.struts.action.ActionServlet,检查WEB-INF/lib下有没有struts.jar;如果页面 404,检查web.xml里url-pattern是不是*.do,访问地址要带.do后缀。

5. 避坑与常见问题:那些让我加班到凌晨的坑

5.1 中文乱码:从页面到数据库的全链路排查

现象:商品名输入「测试商品」,列表页显示「测试商å“」,数据库里也是乱码。

原因:乱码可能出现在三个环节——JSP 页面编码、请求编码、数据库连接编码。任何一处不是 UTF-8 都会出问题。

解决:JSP 头部加<%@ page contentType="text/html;charset=UTF-8" %>;web.xml配EncodingFilter并放在 filter 链最前;JDBC URL 加characterEncoding=utf8;MySQL 建库时指定DEFAULT CHARSET utf8mb4。四处都对齐后,乱码基本绝迹。

5.2 表单重复提交:刷新页面多出订单

现象:保存订单后按 F5,订单列表多出一条一模一样的记录。

原因:保存成功后用forward转发到列表页,浏览器地址栏还是/orderSave.do,刷新等于重新提交。

解决:struts-config.xml里保存成功的 forward 加redirect="true",让浏览器重定向到/orderList.do。更严谨的做法是在 Session 里放 token,提交时校验并清除,但重定向能解决 90% 的场景。

5.3 库存超卖:并发下单扣成负数

现象:两个用户同时买最后一件商品,库存变成 -1。

原因:先查库存再扣减,两步之间没有锁,并发时都查到库存为 1,都执行扣减。

解决:用UPDATE ... WHERE stock >= ? AND version = ?原子操作,根据affected rows判断是否成功。失败就抛业务异常让用户重试,不要用synchronized方法,集群环境下无效。

5.4 连接泄漏:跑一天后 Tomcat 卡死

现象:系统运行几小时后响应越来越慢,最后 Tomcat 报Too many connections。

原因:DAO 里Connection、PreparedStatement、ResultSet没在finally里关闭,异常时直接跳过关闭逻辑。

解决:统一用DBUtil.close(conn, ps, rs)封装关闭逻辑,finally块里调用。更好的做法是引入连接池(如 Druid),但老项目改造时先把手动关闭补上,能立刻止血。

5.5 日期格式:报表查询查不到当天数据

现象:销售报表选「2025-01-15 到 2025-01-15」,当天的订单查不出来。

原因:order_date是DATETIME,存的是2025-01-15 14:30:00,而查询条件BETWEEN '2025-01-15' AND '2025-01-15'实际是00:00:00到00:00:00,漏掉了当天其他时间。

解决:结束日期加一天,用<而不是<=:WHERE order_date >= '2025-01-15' AND order_date < '2025-01-16'。或者把查询条件补全成2025-01-15 23:59:59,但推荐前者,没有边界误差。

6. 二次开发改造:从能跑到好用的几个切入点

项目跑通之后,真正有意思的是按自己业务改。我一般从三个方向下手:权限、报表、接口。

权限控制:现在登录只校验用户名密码,所有菜单都能看。要加角色权限,可以在t_user表加role字段,登录成功后把角色存 Session,再写一个AuthFilter拦截.do请求,根据 URL 和角色判断是否放行。核心代码:

public class AuthFilter implements Filter { // 角色与可访问 URL 前缀的映射 private static final Map<String, List<String>> ROLE_URLS = new HashMap<>(); static { ROLE_URLS.put("admin", Arrays.asList("/customer", "/product", "/order", "/report")); ROLE_URLS.put("sales", Arrays.asList("/customer", "/order")); ROLE_URLS.put("viewer", Arrays.asList("/report")); } public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String uri = request.getRequestURI(); // 登录页和静态资源直接放行 if (uri.contains("login") || uri.contains("/css/") || uri.contains("/js/")) { chain.doFilter(req, resp); return; } User user = (User) request.getSession().getAttribute("currentUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } // 检查当前角色是否有权访问该 URL List<String> allowed = ROLE_URLS.get(user.getRole()); boolean permitted = allowed != null && allowed.stream().anyMatch(uri::contains); if (!permitted) { response.sendError(403, "无权访问"); return; } chain.doFilter(req, resp); } }

这个过滤器注册在web.xml里,放在编码过滤器之后。改造时注意把ROLE_URLS的映射关系抽到配置文件或数据库,硬编码在类里后期加角色要重新编译。

报表导出:销售报表现在只能页面看,业务方肯定要 Excel。用 Apache POI 在 Action 里生成,关键是把查询结果流式写入HttpServletResponse:

public ActionForward export(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) throws Exception { // 复用列表查询逻辑,不分页 List<Order> orders = orderService.findForExport(startDate, endDate); response.setContentType("application/vnd.ms-excel"); response.setHeader("Content-Disposition", "attachment;filename=orders_" + System.currentTimeMillis() + ".xls"); Workbook wb = new HSSFWorkbook(); Sheet sheet = wb.createSheet("订单明细"); // 表头 Row header = sheet.createRow(0); String[] titles = {"订单号", "客户", "下单时间", "金额", "状态"}; for (int i = 0; i < titles.length; i++) { header.createCell(i).setCellValue(titles[i]); } // 数据行 for (int i = 0; i < orders.size(); i++) { Order o = orders.get(i); Row row = sheet.createRow(i + 1); row.createCell(0).setCellValue(o.getOrderNo()); row.createCell(1).setCellValue(o.getCustomerName()); row.createCell(2).setCellValue(DateUtil.format(o.getOrderDate())); row.createCell(3).setCellValue(o.getTotalAmount().doubleValue()); row.createCell(4).setCellValue(statusText(o.getStatus())); } wb.write(response.getOutputStream()); response.getOutputStream().flush(); return null; // 直接写响应流,不跳转 }

注意return null,因为已经把数据写进 response 了,再 forward 会报IllegalStateException。导出数据量大时用SXSSFWorkbook替代HSSFWorkbook,它会把行数据刷到磁盘临时文件,避免内存溢出。

接口化改造:如果前端要换成 Vue 或 React,可以把 Action 改造成返回 JSON。不用大动,加一个BaseAction,在execute里判断请求头Accept,是application/json就用 Jackson 序列化结果写回,否则走原来的 forward。这样老页面和新接口能共存,逐步迁移。

改造完记得回归测试:下单流程、库存扣减、报表数字对得上,这三条过了基本就没大问题。从那以后我每次改完核心链路,都强制走一遍「下单-付款-发货-报表」全流程,哪怕只改了一个字段的显示格式。希望这些经验帮到你,少走点弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询