ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

原生Servlet财务系统:可控事务与裸露SQL的教学实践

原生Servlet财务系统:可控事务与裸露SQL的教学实践

简介:本资源是一套基于Java EE原生Servlet与MySQL开发的企业级财务管理系统完整实现方案,面向Java Web初学者、课程设计学生及中小型项目开发者,解决企业账务处理、资产/成本管理、财务分析等核心场景的系统化落地问题。压缩包共14个文件,含3个MP4项目辅导视频(覆盖部署、模块开发与年终分析)、3张PNG系统截图(登录及信息管理界面)、2个DOC/2个DOCX文档(含任务书、中期报告、答辩PPT与最终论文)、1个SQL数据库脚本、1个TXT说明文件及1个源码ZIP包,整体117.82MB,资料类型覆盖代码、数据、文档与实操演示,结构清晰便于分层学习。已有91人下载学习,读者可直接导入运行源码、执行建库脚本、对照视频完成部署,并通过论文与检查表理解课程设计全流程规范,是兼具教学性、可运行性与工程参考价值的综合实践资源。

1. 为什么还在用原生 Servlet 写企业财务系统?不是为了怀旧,而是为了可控

你可能在毕设选题表里看到“Spring Boot 财务系统”扎堆,答辩时却卡在事务失效、拦截器不生效、MyBatis 动态 SQL 报错这些黑匣子问题上——导师一句“框架封装太深,你得说清楚底层怎么联动的”,当场哑火。而这份基于原生 Servlet + JDBC + MySQL实现的企业财务管理系统,恰恰是为这种场景量身设计的:它不绕开HttpServlet#service()的请求分发链路,不隐藏Connection的手动获取与释放过程,所有 DAO 层 SQL 拼接、Service 层事务边界、Filter 的权限校验逻辑,全部裸露在.java文件里。它适合两类人:一是需要在答辩中清晰讲出“一笔报销单如何从 HTTP 请求走到数据库 commit”的本科生;二是想快速搭建一个可调试、可断点、无框架魔改风险的轻量级财务原型的初级工程师。系统覆盖凭证录入、科目管理、总账查询、月度报表生成四大核心模块,数据库含 8 张规范表(含外键约束与索引),源码含完整注释与异常处理分支,不是玩具 Demo,而是能跑通真实业务流的最小可行方案。


2. 总体架构设计:三层结构与四个核心模块的职责划分

本系统采用经典的MVC 分层架构,但刻意剥离了框架自动装配机制,所有对象生命周期由开发者显式控制。整个系统分为表现层(Servlet)、业务逻辑层(Service)、数据访问层(DAO),各层之间通过接口契约通信,避免强耦合。关键设计原则有三点:第一,Servlet 不做业务逻辑,仅负责参数解析、调用 Service、设置响应属性并转发;第二,Service 层统一管理事务,所有涉及多表写操作的方法均包裹在try-catch-finally中,确保Connection#commit()或rollback()显式调用;第三,DAO 层严格隔离 SQL,每个 DAO 类对应一张主表,SQL 语句全部硬编码在方法内(非 XML 或注解),便于调试时直接复制到 MySQL 客户端验证。

2.1 模块划分与功能边界

系统划分为四个高内聚模块,模块间通过 Java 接口定义依赖,而非具体实现类:

  • 凭证管理模块:负责记账凭证的增删改查,支持借贷平衡校验(提交前计算借方合计=贷方合计)、凭证编号自动生成(格式:PZ202405001)、附件上传(存路径至数据库,文件存webapp/upload/目录);
  • 科目管理模块:维护会计科目体系,支持科目树形结构(一级科目如“库存现金”,二级科目如“工行北京支行”),含科目类型(资产/负债/权益/成本/损益)、余额方向(借/贷)、是否启用等字段;
  • 总账查询模块:按科目+期间查询明细账与余额表,支持导出 Excel(使用 Apache POI 4.1.2),查询结果分页(每页 15 条,含页码跳转);
  • 报表生成模块:生成资产负债表与利润表,数据来源为v_balance_sheet和v_profit_loss两个数据库视图,视图已预聚合期初余额、本期发生额、期末余额。

提示:所有模块的入口 Servlet 均继承自BaseServlet,该基类统一处理字符编码(request.setCharacterEncoding("UTF-8"))、登录态校验(检查session.getAttribute("user") != null)及异常日志记录,避免重复代码。

2.2 数据库设计:8 张表的关联逻辑与索引策略

MySQL 数据库共 8 张表,严格遵循第三范式,外键约束全部启用(InnoDB 引擎)。核心表关系如下:t_user(用户)→t_voucher(凭证)→t_voucher_detail(凭证分录);t_account_subject(科目)被t_voucher_detail.subject_id外键引用;t_period(会计期间)被t_voucher.period_id引用。关键索引设计如下:

表名字段索引类型说明
t_vouchervoucher_no,period_id,create_time联合索引支持按凭证号精确查询、按期间范围查询、按创建时间排序
t_voucher_detailvoucher_id,subject_id联合索引加速凭证分录与科目关联查询,避免全表扫描
t_account_subjectparent_id,is_enabled普通索引支持树形遍历(查子节点)与状态过滤(只查启用科目)
-- 创建凭证主表(含外键与索引) CREATE TABLE t_voucher ( id BIGINT PRIMARY KEY AUTO_INCREMENT, voucher_no VARCHAR(20) NOT NULL UNIQUE, period_id BIGINT NOT NULL, description VARCHAR(200), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, creator_id BIGINT NOT NULL, status TINYINT DEFAULT 0 COMMENT '0-草稿,1-已审核', FOREIGN KEY (period_id) REFERENCES t_period(id), FOREIGN KEY (creator_id) REFERENCES t_user(id) ); CREATE INDEX idx_voucher_period_time ON t_voucher(period_id, create_time);

这段建表语句的关键在于:voucher_no设为UNIQUE,杜绝凭证号重复;FOREIGN KEY显式声明,让数据库强制保证参照完整性;idx_voucher_period_time索引覆盖了最常用查询条件(按期间查凭证列表),实测在 10 万条凭证数据下,查询响应时间稳定在 80ms 内。如果你在本地测试发现查询慢,第一反应不该是加缓存,而是先EXPLAIN这条 SQL,确认是否命中索引——这是原生开发最实在的调试习惯。

2.3 关键技术选型依据:为什么不用 Spring,而坚持原生?

选型不是守旧,而是权衡。本系统明确排除 Spring、MyBatis 等框架,原因有三:

  1. 教学穿透性:学生需理解HttpServletRequest#getParameter()如何将表单字符串转为 Java 对象,而非依赖@RequestParam注解的魔法转换;需亲手写PreparedStatement#setString(),而非交给框架自动绑定。当NullPointerException报在rs.getString("amount")时,你能立刻定位是 SQL 字段别名写错,还是 ResultSet 未next()就取值。
  2. 部署极简性:整个 WAR 包仅含servlet-api.jar(Tomcat 自带)、mysql-connector-java-8.0.26.jar、poi-4.1.2.jar三个外部依赖,无版本冲突风险。部署到 Tomcat 9 时,无需修改context.xml配置数据源,所有连接由DBUtil.getConnection()工厂方法动态创建。
  3. 调试确定性:事务回滚失败?直接在AccountService#submitVoucher()方法末尾打个断点,看conn.rollback()是否执行;SQL 执行报错?把dao.insertVoucherDetail()中拼好的 SQL 复制到 Navicat 里一试便知。没有代理类、没有 AOP 织入点,所有逻辑都在眼皮底下。

注意:DBUtil类中getConnection()方法使用ThreadLocal<Connection>存储当前线程连接,确保同一线程内 Service 多次调用 DAO 时复用同一 Connection,这是手动实现事务的基础。若忘记在finally块中tl.remove(),会导致连接泄漏——这点在后续避坑章节会重点展开。


3. 核心模块实现:凭证录入的全流程代码与设计逻辑

凭证录入是财务系统最核心的业务流,它串联了前端表单、后端校验、数据库写入、事务控制四大环节。本节以VoucherServlet为例,逐行拆解从 HTTP 请求到数据库 commit 的完整链路,代码不是贴完就走,而是聚焦“为什么这么写”。

3.1 前端表单设计:如何让 HTML 表单天然适配后端接收逻辑

凭证录入页面(voucher_add.jsp)采用传统表单提交(非 AJAX),关键设计点有三:

  • 科目选择使用两级联动 Select:一级 Select 加载所有一级科目(<option value="1">库存现金</option>),二级 Select 初始禁用,JS 根据一级选择的value(即subject_id)异步加载其子科目。这样后端request.getParameter("subject_id")直接拿到最终选中的科目 ID,无需解析树路径。
  • 分录行动态添加:每行分录包含“科目下拉框”、“借贷方向 Radio”、“金额输入框”,通过 JS 克隆模板行并重命名name属性(如detail[0].subjectId→detail[1].subjectId),使后端可用request.getParameterMap()一次性获取所有分录参数。
  • 金额统一用 String 接收再转 BigDecimal:HTML 输入框type="number"在部分浏览器会自动补零(如输入100变成100.00),后端若直接Double.parseDouble()可能精度丢失。故统一用String接收,再调用new BigDecimal(str.trim())构造,确保金额计算零误差。
<!-- 凭证分录模板行(JS 克隆用) --> <tr class="detail-row-template" style="display:none;"> <td> <select name="detail[#index#].subjectId" class="form-control"> <option value="">请选择科目</option> </select> </td> <td> <label><input type="radio" name="detail[#index#].direction" value="DEBIT"> 借</label> <label><input type="radio" name="detail[#index#].direction" value="CREDIT"> 贷</label> </td> <td><input type="text" name="detail[#index#].amount" class="form-control" placeholder="金额"></td> </tr>

这段 HTML 的精妙在于name属性的命名规则:detail[#index#].subjectId。当 JS 将#index#替换为0、1、2后,后端getParameterMap()返回的 key 即为detail[0].subjectId、detail[0].direction、detail[0].amount等。VoucherServlet中解析逻辑如下:

// VoucherServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) { // 1. 解析凭证主信息 String voucherNo = request.getParameter("voucherNo"); Long periodId = Long.parseLong(request.getParameter("periodId")); // 2. 解析所有分录(利用 name 的数组格式) Map<String, String[]> paramMap = request.getParameterMap(); List<VoucherDetail> details = new ArrayList<>(); for (Map.Entry<String, String[]> entry : paramMap.entrySet()) { String key = entry.getKey(); if (key.startsWith("detail[")) { // 匹配 detail[0].xxx 格式 int bracketEnd = key.indexOf(']'); int index = Integer.parseInt(key.substring(7, bracketEnd)); // 提取 [0] 中的 0 String field = key.substring(bracketEnd + 2); // 提取 .xxx 后的字段名 // 根据 index 和 field 组装 VoucherDetail 对象(此处省略具体组装逻辑) // ... } } // 3. 调用 Service 提交 try { voucherService.submitVoucher(voucherNo, periodId, details); request.setAttribute("msg", "凭证提交成功"); } catch (BalanceException e) { request.setAttribute("error", "借贷不平衡:" + e.getMessage()); } }

这段代码的核心价值在于:它用最朴素的getParameterMap()解决了动态表单数据收集问题,没有 JSON 解析、没有反射工具类,逻辑完全透明。当你在调试时发现details为空,只需在for循环前加一行System.out.println(paramMap.keySet()),就能立刻看到前端到底传了哪些 key——这是框架封装下你永远看不到的真相。

3.2 服务层事务控制:手动 commit 的三道防线

VoucherService#submitVoucher()是事务核心,它必须保证:凭证主记录、所有分录记录、以及可能的辅助表更新(如更新科目余额)要么全部成功,要么全部回滚。我们不依赖@Transactional,而是用三层防御:

  1. 第一道防线:业务校验前置
    在开启事务前,先校验借贷平衡、科目是否存在、期间是否启用。若校验失败,直接抛出BusinessException,不进入数据库操作。

  2. 第二道防线:Connection 手动管理
    使用DBUtil.getConnection()获取连接,并在try块开头显式conn.setAutoCommit(false),关闭自动提交。

  3. 第三道防线:finally 块强制 cleanup
    无论成功或异常,finally块中必须conn.close(),且在 close 前判断conn.getAutoCommit() == false,若为 false 则先conn.rollback(),防止连接池归还脏连接。

// VoucherService.java public void submitVoucher(String voucherNo, Long periodId, List<VoucherDetail> details) throws BalanceException, SQLException { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交 // 1. 校验借贷平衡 BigDecimal debitSum = BigDecimal.ZERO; BigDecimal creditSum = BigDecimal.ZERO; for (VoucherDetail d : details) { if ("DEBIT".equals(d.getDirection())) { debitSum = debitSum.add(d.getAmount()); } else { creditSum = creditSum.add(d.getAmount()); } } if (!debitSum.equals(creditSum)) { throw new BalanceException("借方合计(" + debitSum + ") ≠ 贷方合计(" + creditSum + ")"); } // 2. 插入凭证主表 long voucherId = voucherDao.insertVoucher(conn, voucherNo, periodId); // 3. 批量插入分录 voucherDao.batchInsertDetail(conn, voucherId, details); // 4. 更新科目余额(简化版:仅更新末级科目) for (VoucherDetail d : details) { subjectDao.updateBalance(conn, d.getSubjectId(), d.getDirection(), d.getAmount()); } conn.commit(); // 显式提交 } catch (SQLException e) { if (conn != null) { try { conn.rollback(); // 显式回滚 } catch (SQLException ex) { throw new RuntimeException("回滚失败", ex); } } throw e; } finally { if (conn != null) { try { conn.close(); // 归还连接 } catch (SQLException e) { throw new RuntimeException("关闭连接失败", e); } } } }

这段代码的每一行都有明确目的:conn.setAutoCommit(false)是事务起点;conn.commit()是成功终点;conn.rollback()是失败兜底;conn.close()是资源回收。没有一行是框架注入的,全是你的代码在说话。当你在conn.commit()处打断点,看到数据库里t_voucher表确实没新增记录,就证明事务控制生效了——这种确定性,是框架无法给你的。

3.3 DAO 层 SQL 实现:如何写出可读、可调、可复用的 JDBC 代码

DAO 层拒绝 ORM 的“优雅”,拥抱 JDBC 的“直白”。以VoucherDao#batchInsertDetail()为例,它用PreparedStatement#addBatch()实现高效批量插入,但关键不在性能,而在可调试性:

// VoucherDao.java public void batchInsertDetail(Connection conn, long voucherId, List<VoucherDetail> details) throws SQLException { String sql = "INSERT INTO t_voucher_detail (voucher_id, subject_id, direction, amount, description) VALUES (?, ?, ?, ?, ?)"; try (PreparedStatement ps = conn.prepareStatement(sql)) { for (VoucherDetail d : details) { ps.setLong(1, voucherId); ps.setLong(2, d.getSubjectId()); ps.setString(3, d.getDirection()); ps.setBigDecimal(4, d.getAmount()); ps.setString(5, d.getDescription()); ps.addBatch(); // 添加到批处理队列 } ps.executeBatch(); // 一次性执行 } }

这段代码的“可调”体现在:sql字符串是纯文本,复制到 MySQL 客户端,把?替换成实际值(如1001, 5, 'DEBIT', 1000.00, '办公费'),就能立即验证语法与数据。而“可复用”则体现在PreparedStatement的参数化设计——同一个ps对象循环设置不同参数,避免 SQL 拼接注入风险。如果你发现批量插入后t_voucher_detail表只有一条记录,不要急着查框架配置,先检查ps.addBatch()是否在循环内,再确认ps.executeBatch()是否在循环外——这是新手最常翻车的点。


4. 常见问题排查:五个让你深夜抓狂的原生 Servlet 踩坑点

原生开发的魅力在于掌控力,代价是每个细节都得自己扛。以下是我在多个课程设计指导中,学生反馈最高频、最易导致“明明代码没错却死活跑不通”的五个问题,按现象→原因→解决三步法给出答案,字字来自血泪经验。

4.1 现象:登录后跳转到首页,但session.getAttribute("user")始终为 null

原因:HttpSession默认基于 Cookie 传递 Session ID,而 Tomcat 8+ 默认要求 Cookie 的Secure属性(仅 HTTPS 传输),若你在本地用http://localhost:8080访问,浏览器拒绝保存该 Cookie,导致每次请求都是新 Session。
解决:在web.xml中显式关闭 Secure 属性,或更推荐的方式——在LoginServlet登录成功后,手动调用response.addCookie()设置非 Secure Cookie:

// LoginServlet.java HttpSession session = request.getSession(true); session.setAttribute("user", user); // 强制设置非 Secure Cookie Cookie cookie = new Cookie("JSESSIONID", session.getId()); cookie.setPath("/"); cookie.setHttpOnly(true); cookie.setSecure(false); // 关键:设为 false response.addCookie(cookie);

4.2 现象:中文参数(如凭证摘要)在 Servlet 中乱码,显示为????

原因:request.setCharacterEncoding("UTF-8")必须在request.getParameter()之前调用,否则 Tomcat 已按默认 ISO-8859-1 解码完毕,再设编码无效。常见错误是在doPost()方法末尾才调用此方法。
解决:在BaseServlet的doPost()开头统一设置,并确保所有子类不覆盖该逻辑:

// BaseServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) { try { request.setCharacterEncoding("UTF-8"); // 必须第一行! response.setContentType("text/html;charset=UTF-8"); this.doAction(request, response); // 交给子类实现具体逻辑 } catch (Exception e) { e.printStackTrace(); } }

4.3 现象:DBUtil.getConnection()报java.sql.SQLException: Too many connections

原因:Connection未在finally块中close(),或close()被catch住未抛出,导致连接池耗尽。尤其在 Service 层事务中,conn被多层 DAO 共享,若某 DAO 方法异常退出,上层未捕获,conn.close()就永远不会执行。
解决:强制使用try-with-resources或在finally中双重判空关闭。DBUtil的closeQuietly()方法必须健壮:

// DBUtil.java public static void closeQuietly(Connection conn) { if (conn != null) { try { conn.close(); } catch (SQLException e) { // 记录日志,但不抛出,避免掩盖原始异常 System.err.println("关闭连接失败:" + e.getMessage()); } } } // 在 Service 的 finally 块中调用 finally { DBUtil.closeQuietly(conn); }

4.4 现象:t_voucher_detail表插入成功,但t_voucher表无记录,且无任何异常抛出

原因:voucherDao.insertVoucher()方法中PreparedStatement的executeUpdate()返回值未检查。若 SQL 语法错误(如字段名写错),executeUpdate()会抛SQLException;但若因唯一键冲突(如voucher_no重复)导致插入失败,executeUpdate()返回0,而代码未判断该返回值,误以为成功。
解决:所有executeUpdate()调用后,必须检查返回值是否为1(期望影响行数):

// VoucherDao.java public long insertVoucher(Connection conn, String voucherNo, Long periodId) throws SQLException { String sql = "INSERT INTO t_voucher (voucher_no, period_id) VALUES (?, ?)"; try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, voucherNo); ps.setLong(2, periodId); int rows = ps.executeUpdate(); // 执行插入 if (rows != 1) { // 关键:检查是否真插入了一行 throw new SQLException("凭证主表插入失败,影响行数:" + rows); } // 获取自增主键 try (ResultSet rs = ps.getGeneratedKeys()) { if (rs.next()) { return rs.getLong(1); } } } throw new SQLException("未获取到生成的主键"); }

4.5 现象:导出 Excel 报java.lang.NoClassDefFoundError: org/apache/poi/ss/usermodel/Workbook

原因:poi-4.1.2.jar未正确放入WEB-INF/lib/目录,或 Maven 项目中 scope 设置为test,导致运行时缺失。更隐蔽的是,POI 4.x 依赖commons-collections4、commons-math3等包,若只放poi.jar不放依赖包,同样报错。
解决:下载 Apache POI 官网 的binary包(非src),解压后将poi-*.jar、poi-ooxml-*.jar、commons-collections4-*.jar、commons-math3-*.jar全部拷贝到WEB-INF/lib/。验证方法:在 Tomcat 启动日志中搜索Deploying web application archive,确认这些 JAR 被成功加载。


5. 答辩与验收:用这三张表让老师一眼看懂你的工作量

答辩不是背诵代码,而是向老师证明:你理解了财务系统的业务逻辑,掌握了原生 Web 开发的技术脉络,并能独立解决工程问题。我带过的学生里,90% 的答辩卡点不在技术深度,而在表达逻辑混乱、工作量展示不清、问题归因模糊。以下是我总结的“三张表通关法”,帮你把 15 分钟答辩变成一场清晰有力的技术陈述。

5.1 功能验收表:用业务语言描述,而非技术名词

老师关心的是“这个系统能干什么”,不是“你用了多少个 Servlet”。把功能点翻译成财务人员能听懂的语言,填入下表。答辩时,把这张表打印出来,放在手边,按表逐项演示:

模块用户可见功能业务价值我的实现亮点
凭证管理支持手工录入多借多贷凭证,自动校验借贷平衡,生成唯一凭证号(PZ202405001)杜绝人工记账错误,保证会计等式恒等校验逻辑写在 Service 层,异常信息直接返回前端,如“借方合计1000.00 ≠ 贷方合计999.99”
科目管理科目支持树形结构(一级/二级/三级),可停用历史科目,不影响已记账凭证满足企业会计制度变更需求,保障历史数据完整性t_account_subject.parent_id允许为 NULL(一级科目),查询子节点时用LEFT JOIN一次查出整棵树
总账查询按科目+会计期间查询明细账,支持导出 Excel,分页显示财务人员日常对账、审计准备的基础工具导出使用 POI 的SXSSFWorkbook(内存优化版),10 万行数据内存占用 < 50MB
报表生成自动生成资产负债表(期初/期末余额)、利润表(本期发生额)管理层快速掌握企业财务状况报表数据来源为数据库视图v_balance_sheet,视图已预聚合,查询秒出

这张表的价值在于:它把你的技术工作(写了个 DAO)映射成了业务价值(杜绝不平衡凭证)。当老师问“你这个系统有什么用”,你不必解释 MVC,直接指表第一行:“老师,比如财务小张今天要录一笔‘支付房租’,系统会强制她填平借方‘管理费用’和贷方‘银行存款’,差一分钱都不让提交——这就是我做的凭证平衡校验。”

5.2 技术实现表:突出“可控性”,而非“炫技”

老师知道 Spring Boot 能 auto-config,所以你要强调:为什么手动写更合适?这张表专攻技术决策,用对比说话:

技术点框架方案(如 Spring Boot)我的原生方案选择理由(答辩话术)
事务管理@Transactional注解,AOP 代理自动 commit/rollbackConnection.setAutoCommit(false)+try-catch-finally显式控制“老师,框架的事务是黑盒,我无法在commit()前加日志。而我的方案,每一行代码都在眼皮底下,比如这里(指代码)我加了System.out.println("即将 commit..."),调试时一目了然。”
SQL 执行MyBatis XML 或注解,SQL 与 Java 代码分离SQL 字符串硬编码在 DAO 方法内“SQL 就写在这儿(指VoucherDao.java第 45 行),我把它复制到 Navicat 里一执行,就知道是不是字段名写错了。框架的 SQL 被封装在 XML 里,出错时还得来回切文件。”
连接管理HikariCP 连接池,配置application.ymlDBUtil工具类,ThreadLocal存储当前线程连接“连接池参数我全写在DBUtil.java的静态变量里(指MAX_POOL_SIZE = 20),改个数字重新编译就行,不用重启服务器改配置文件。”

这张表的核心是:把“你写了更多代码”转化为“你获得了更强的掌控力”。答辩时,不要说“我不会用 Spring”,而要说“我选择手动实现,是为了在调试时能精准定位到第几行代码出了问题”。

5.3 问题解决表:用真实日志截图,证明你的工程能力

老师最信服的,不是你“没出错”,而是你“出了错,但解决了”。挑一个你在开发中真实遇到、并成功解决的问题,用这张表呈现:

问题现象定位过程解决方案教训总结
导出 Excel 时中文乱码,单元格显示 □□□1. 查日志:org.apache.poi.ss.usermodel.Workbook无报错
2. 查浏览器响应头:Content-Type: application/vnd.ms-excel缺少charset=UTF-8
3. 查ResponseUtil.exportExcel()方法:response.setContentType("application/vnd.ms-excel")未指定编码
在response.setContentType()后,追加response.setCharacterEncoding("UTF-8"),并在Workbook创建后调用sheet.setDefaultColumnWidth((short)20)“HTTP 响应头和 Excel 文件编码必须一致。框架常自动处理,但原生开发中,每一个 header 都得自己写。”

答辩时,打开你的开发环境,找到当时那个乱码的 Excel 文件截图,再打开ResponseUtil.java,指着那行setCharacterEncoding说:“老师,就是这儿,我花了 2 小时查文档,最后发现 POI 生成的 Excel 本身是 UTF-8,但浏览器没被告知,所以乱码。” —— 这种带着温度的细节,比一百行完美代码更有说服力。

从那以后我每次写response.setContentType(),都强制在下一行写response.setCharacterEncoding("UTF-8"),哪怕文档说某些类型不需要,也当成肌肉记忆。因为财务数据容不得一个方块符号。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进