☰
原生Servlet+MySQL企业财务系统:借贷平衡与期末结转实现
2026/10/31 2:19:54 网站建设 项目流程

简介:这是一套面向Java EE初学者、课程设计与毕业设计开发者的企业财务管理系统完整实践资料,围绕原生Servlet与MySQL构建,涵盖资产、负债、成本、财务分析等模块,可用于理解分层架构、请求处理与数据库脚本部署。资源包共14个文件,约117.82MB,包含3个mp4部署与功能讲解视频、3张项目界面截图、2份doc与2份docx论文及任务书类文档、1份ppt答辩稿、1个sql数据库脚本、1个源码压缩包及说明txt,覆盖从建库运行到功能演示的主要环节。已有91人学习。读者可据此获得可运行的源码与数据库脚本,对照视频完成项目创建、部门职工与工资、资产费用和年终分析等模块的调试,并借助论文、中期报告、任务书与答辩PPT梳理系统设计思路、表结构与功能流程,适合需要完整案例、文档素材和排错参考的学习者。

1. 先把账做平,再谈框架

去年帮一个做贸易的朋友看他们的内部账,需求列出来只有三行:凭证能录、账能查、报表能出。预算不高,服务器就一台 2 核 4G,团队里没人维护过 Spring 那一套。这种时候上微服务、上脚手架反而是负担。这套原生 Servlet + MySQL 的企业财务管理系统设计与实现,走的就是另一条路:JDK 8 + Servlet 3.1 + JSP + 原生 JDBC,一个 Tomcat 丢上去就能跑,任何会写 Java 的人打开源码都能在半小时内找到改哪一行。

它的价值不在技术新,在于把企业财务管理系统里最容易出事的几个点——借贷平衡、期末结转、科目层级汇总——用最直白的方式写了一遍。适合三类人:课程设计要做完整闭环的、小公司要一套能自己改的记账后台的、以及想搞清楚 Servlet 请求链路到底怎么走的新手。源码和数据库脚本是配套的,建表语句和初始科目都在里面。

2. 工程骨架:Servlet + JSP + MySQL 的分层怎么落地

2.1 目录结构决定后期改起来疼不疼

先把目录贴出来,这份结构基本决定了后面所有改动的成本。原生 Servlet 项目没有 Spring 的自动扫描和依赖注入,包结构一旦乱了,找一个 SQL 要去三个地方翻,血泪经验。

finance/ ├── src/main/java/com/demo/finance/ │ ├── entity/ 实体类,与数据库表一一对应 │ ├── dao/ 只放 SQL,不写业务判断 │ ├── service/ 业务逻辑与事务边界 │ ├── servlet/ 只做参数接收、调用、转发 │ ├── filter/ 编码、登录态、权限拦截 │ └── util/ DBUtil、MoneyUtil、DateUtil ├── src/main/resources/ │ └── db.properties 数据库连接四要素 └── src/main/webapp/ ├── WEB-INF/ │ ├── web.xml │ └── views/ JSP 全放这里 └── static/ css、js、图片

两个细节值得说。第一,JSP 放在WEB-INF/views/下面,浏览器无法直接敲 URL 访问,必须经 Servlet 转发进来,登录拦截才有效;如果 JSP 散落在 webapp 根目录,等于后台页面裸奔,这是很多人第一次部署后才发现的问题。第二,dao层坚持只写 SQL、不写 if-else 业务判断,因为财务系统的业务规则(比如借贷必须相等、已结账期间禁止改动)一旦渗到 DAO,同一个规则会在五个方法里各写一遍,改口径的时候必然漏改。

servlet层薄到只剩三件事:取参数、调 service、request.getRequestDispatcher(...).forward(...)。这层写胖了,后面想加接口或者换前端就得重写。

2.2 web.xml 与 @WebServlet:两种注册方式别混用

Servlet 3.0 之后可以用注解注册,很多人图省事全用@WebServlet。但这份项目保留了web.xml,因为财务系统里 URL 前缀是约定的一部分,集中在一个文件里能一眼看全所有入口。

<!-- web.xml 片段:凭证模块统一走 /voucher/* 前缀 --> <servlet> <servlet-name>voucherServlet</servlet-name> <servlet-class>com.demo.finance.servlet.VoucherServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>voucherServlet</servlet-name> <url-pattern>/voucher/*</url-pattern> </servlet-mapping>
// 报表模块则用注解方式,路径直接写在类上 @WebServlet(name = "reportServlet", urlPatterns = "/report/*") public class ReportServlet extends HttpServlet { private final ReportService reportService = new ReportService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // action 参数区分具体操作,如 balance、detail、export String action = req.getParameter("action"); if ("balance".equals(action)) { showBalance(req, resp, reportService); } else { resp.sendError(HttpServletResponse.SC_NOT_FOUND); } } }

这里有个坑必须提前讲:同一个 Servlet 类不要既在 web.xml 里注册,又加@WebServlet。容器会把它当成两个实例,init()执行两次,如果init里有加载字典、建立缓存的逻辑,就会出现数据重复加载,或者两次拿到的静态变量互相覆盖,排查时非常像玄学。另外web.xml的<web-app>标签上如果写了metadata-complete="true",注解会整个失效,这类问题报错信息是"404 找不到路径",很容易往 Tomcat 配置上瞎想。

url-pattern用/voucher/*这种前缀匹配,好处是request.getPathInfo()能拿到剩余部分(比如/add、/edit),一个 Servlet 管一个模块的所有动作,不用为每个动作建一个类。参数说明:getPathInfo()在精确匹配模式下返回 null,只有前缀匹配才有值,写switch之前先判空。

2.3 过滤器:编码和登录态一次解决

中文乱码和未登录访问是这类项目最常被吐槽的两件事,用一个 Filter 全兜住最省事。

@WebFilter(urlPatterns = "/*") public class AuthFilter implements Filter { // 白名单:登录页、验证码、静态资源 private static final String[] WHITE = {"/login", "/captcha", "/static/"}; @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // 统一编码,必须在取参数之前设置 request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8"); String uri = request.getRequestURI().substring(request.getContextPath().length()); for (String w : WHITE) { if (uri.startsWith(w)) { chain.doFilter(req, resp); return; } } Object user = request.getSession().getAttribute("loginUser"); if (user == null) { // 关键:AJAX 请求不能重定向到登录页,否则前端拿到一段 HTML if ("XMLHttpRequest".equals(request.getHeader("X-Requested-With"))) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("{\"code\":401,\"msg\":\"登录超时\"}"); return; } response.sendRedirect(request.getContextPath() + "/login"); return; } chain.doFilter(req, resp); } }

逐段看。setCharacterEncoding只对 POST 请求体生效,放在getParameter之前才有意义,位置错了就等于没写。白名单用前缀匹配而不是equals,因为静态资源有几十个文件路径,逐个列举不现实。会话判断拿的是loginUser这个 key,登录成功时session.setAttribute("loginUser", user)写入,两边必须严格一致,改名字的时候要全局搜。

最值得说的是 AJAX 分支。默认的sendRedirect会让前端fetch拿到 200 状态加一段登录页 HTML,JSON.parse直接抛异常,控制台只显示"Unexpected token <",不知道是登录过期。返回 401 状态码后,前端统一拦截 401 弹登录框,问题就闭环了。这个细节在演示里看不出,上线一周就会被用户投诉。

2.4 数据源与事务边界:别让连接泄漏拖垮 Tomcat

原生项目最容易写出的 bug 是连接没关。常见做法是写一个DBUtil,内部用连接池(Druid 或 HikariCP 这类都行),再用ThreadLocal把同一个请求内的连接绑住,保证 Service 层多个 DAO 调用共用一条连接、一个事务。

public class DBUtil { private static DataSource dataSource; // 一个请求线程一条连接,Service 结束时统一释放 private static final ThreadLocal<Connection> HOLDER = new ThreadLocal<>(); static { try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("db.properties")) { Properties p = new Properties(); p.load(in); // 这里换成具体连接池的初始化代码 dataSource = createDataSource(p); } catch (Exception e) { throw new ExceptionInInitializerError("数据源初始化失败: " + e.getMessage()); } } public static Connection getConnection() throws SQLException { Connection conn = HOLDER.get(); if (conn == null || conn.isClosed()) { conn = dataSource.getConnection(); HOLDER.set(conn); } return conn; } // 事务提交后必须 remove,否则线程复用时会拿到上一条已关闭连接 public static void close() { Connection conn = HOLDER.get(); if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} HOLDER.remove(); } } }

参数和逻辑说明:db.properties里放jdbc.url、jdbc.username、jdbc.password、jdbc.maxActive,改数据库只动这一个文件。HOLDER.remove()这一行是重点,Tomcat 的线程是复用的,如果只close()不remove(),下一个请求进来getConnection()拿到的是一个已关闭的 Connection 对象,报"Connection is closed",而且时有时无,非常难查。

Service 层的事务写法固定成模板:

public void saveVoucher(Voucher v) throws SQLException { Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); voucherDao.insertHead(conn, v); voucherDao.insertEntries(conn, v.getEntries()); balanceDao.updatePeriodSum(conn, v); conn.commit(); } catch (SQLException e) { conn.rollback(); // 任何一步失败整体回滚 throw e; } finally { conn.setAutoCommit(true); DBUtil.close(); } }

setAutoCommit(false)之后必须显式commit,异常分支必须rollback,finally里恢复自动提交再关连接。少写rollback的后果是:凭证头插进去了、分录失败,数据库里留下一张只有头没有分录的"孤儿凭证",期初余额还更新了一半,这种数据修起来比重写还麻烦。

3. 数据库设计:财务系统最不能含糊的几张表

3.1 五张核心表与它们的职责边界

财务系统的表不在多,在于口径统一。这套项目的主干是五张表加一张余额汇总表:

表名作用关键字段
t_account账套,支持多套账id、name、start_period、status
t_subject会计科目树code、name、level、parent_code、direction
t_voucher凭证头id、account_id、period、voucher_no、status
t_voucher_entry凭证分录(借贷明细)voucher_id、subject_code、debit、credit
t_period_balance科目期间余额account_id、period、subject_code、begin、debit_sum、credit_sum、end
t_user用户与角色username、pwd_hash、salt、role、account_id

direction字段表示科目方向(借/贷),这是后面算余额的关键:资产类科目期末 = 期初 + 借方 - 贷方,负债类反过来。如果只存一个end而不存方向,出报表时就得在代码里写死科目前缀判断(1 开头是资产、2 开头是负债),新增科目立刻翻车。

t_period_balance是冗余汇总表,理论上每次都能从分录实时算出来,但凭证量到几万条之后,科目余额表的查询会慢到打不开页面。用汇总表换查询速度是财务系统的常规做法,代价是必须保证凭证保存、删除、审核三个动作都同步更新它,这也是最容易漏的地方。

3.2 分录表:金额字段类型和借贷约束

CREATE TABLE t_voucher_entry ( id BIGINT NOT NULL AUTO_INCREMENT, voucher_id BIGINT NOT NULL COMMENT '凭证头ID', subject_code VARCHAR(20) NOT NULL COMMENT '科目编码', summary VARCHAR(200) DEFAULT NULL COMMENT '摘要', debit DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '借方金额', credit DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '贷方金额', line_no INT NOT NULL DEFAULT 1 COMMENT '分录行号', PRIMARY KEY (id), KEY idx_voucher (voucher_id), KEY idx_subject_period (subject_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='凭证分录';

金额用DECIMAL(18,2)而不是DOUBLE。用浮点数存钱,做几百条汇总之后报表和手工账差几分钱,对账时能把人逼疯。实体类里对应BigDecimal,所有加减乘除走BigDecimal的方法,equals用compareTo代替,因为new BigDecimal("0.0").equals(new BigDecimal("0.00"))是 false,这个坑我见过不止一次。

分录表不设"借方或贷方"的单列,而是两列各存各的,非空默认 0。好处是 SQL 汇总时直接SUM(debit)、SUM(credit),不用写 CASE WHEN;校验借贷平衡也简单,一条语句搞定:

-- 校验某张凭证借贷是否相等,返回值不为 0 就拒绝保存 SELECT SUM(debit) - SUM(credit) AS diff FROM t_voucher_entry WHERE voucher_id = #{voucherId};

如果差值为负也要拦住,Java 侧用diff.compareTo(BigDecimal.ZERO) != 0判断。注意SUM在没有任何分录时返回 NULL,要先判空,否则空指针会在保存空凭证时冒出来。

3.3 期末结转:一条 SQL 定余额口径

月末结转是财务系统的心脏。t_period_balance的更新逻辑是:本期期末 = 期初 + 本期借方合计 - 本期贷方合计(贷方科目方向相反)。

-- 重算某账套某期间的科目余额,可重复执行(幂等) INSERT INTO t_period_balance (account_id, period, subject_code, begin_amt, debit_sum, credit_sum, end_amt) SELECT #{accountId}, #{period}, e.subject_code, IFNULL(b.end_amt, 0) AS begin_amt, SUM(e.debit) AS debit_sum, SUM(e.credit) AS credit_sum, IFNULL(b.end_amt, 0) + SUM(e.debit) - SUM(e.credit) AS end_amt FROM t_voucher_entry e JOIN t_voucher v ON v.id = e.voucher_id AND v.status = 1 -- 只统计已审核凭证 LEFT JOIN t_period_balance b ON b.account_id = #{accountId} AND b.subject_code = e.subject_code AND b.period = #{prevPeriod} WHERE v.account_id = #{accountId} AND v.period = #{period} GROUP BY e.subject_code, b.end_amt ON DUPLICATE KEY UPDATE begin_amt = VALUES(begin_amt), debit_sum = VALUES(debit_sum), credit_sum = VALUES(credit_sum), end_amt = VALUES(end_amt);

这段 SQL 有几个设计取舍。v.status = 1表示只统计已审核的凭证,草稿状态的凭证不能进账,这是财务的基本规矩,写在 SQL 里比写在 Java 里更保险。LEFT JOIN上期余额拿到期初,没有上期记录时IFNULL补 0,保证第一个月也能跑。ON DUPLICATE KEY UPDATE依赖(account_id, period, subject_code)上的唯一索引,所以建表时必须加这个联合唯一键,否则每次重算都会插重复行,余额越算越大。

参数说明:prevPeriod是上一期间的标识,格式建议用yyyyMM整数而不是字符串,比较和加减都方便。period由前端传,后端必须校验它所属的账套没有关账,已关账期间不允许重算,这个判断放在 Service 的第一行。

4. 核心模块拆解:一条请求从进入到返回的完整链路

4.1 登录:密码加盐与会话写入

@Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); UserService service = new UserService(); User user = service.findByUsername(username); // 用户不存在和密码错误返回同一句提示,避免被枚举账号 if (user == null || !service.checkPassword(password, user.getSalt(), user.getPwdHash())) { req.setAttribute("error", "用户名或密码错误"); req.getRequestDispatcher("/WEB-INF/views/login.jsp").forward(req, resp); return; } // 登录成功,旧会话作废再建新的,防会话固定攻击 req.getSession().invalidate(); HttpSession session = req.getSession(true); session.setAttribute("loginUser", user); session.setMaxInactiveInterval(30 * 60); // 30 分钟 resp.sendRedirect(req.getContextPath() + "/voucher/list"); }

checkPassword内部做的是hash(password + salt)再和库里的值比对,盐每个用户独立生成,注册时随密码一起存。明文或全库统一盐都不行,前者数据库一泄漏就全裸,后者撞库批量命中。invalidate()再getSession(true)这两行是防会话固定:攻击者先给受害者一个已知 sessionId,受害者登录后这个 id 依然有效,攻击者就能直接复用。旧会话作废后重新签发,这条路就断了。

setMaxInactiveInterval单位是秒,30 分钟对财务系统偏短,用户在填一张二十行的凭证时容易被踢出去。常见做法是 60 分钟,同时前端在剩余 5 分钟时提示续期。

4.2 凭证录入:前端表格到后端批量插入

凭证录入页面是一张动态表格,用户点"加一行"就多一组借贷输入框。表单提交时字段名带下标:entries[0].subjectCode、entries[0].debit这样。原生 Servlet 不支持自动绑定,得自己收敛参数。

private List<VoucherEntry> parseEntries(HttpServletRequest req) { List<VoucherEntry> list = new ArrayList<>(); for (int i = 0; ; i++) { String subject = req.getParameter("subject_" + i); if (subject == null) break; // 下标断了就结束 VoucherEntry e = new VoucherEntry(); e.setSubjectCode(subject); e.setSummary(req.getParameter("summary_" + i)); e.setDebit(parseMoney(req.getParameter("debit_" + i))); e.setCredit(parseMoney(req.getParameter("credit_" + i))); e.setLineNo(i + 1); list.add(e); } return list; } // 空串、null 统一按 0 处理,避免 BigDecimal 构造抛异常 private BigDecimal parseMoney(String raw) { if (raw == null || raw.trim().isEmpty()) return BigDecimal.ZERO; return new BigDecimal(raw.trim()).setScale(2, RoundingMode.HALF_UP); }

参数命名用subject_0、subject_1这种平铺方式,比entries[0].subjectCode在原生getParameter下更好解析,也不用引入任何绑定框架。循环里的break依赖下标连续,前端删行时必须重排下标,否则会漏掉后面的分录——这是前后端联调最常见的错位来源。

钱的处理用setScale(2, HALF_UP)统一两位小数,用户输入100.005会被四舍五入成100.01,而不是抛异常或截断。

保存前的校验顺序也很讲究:

public void validate(Voucher v) { if (v.getEntries() == null || v.getEntries().size() < 2) { throw new BizException("凭证至少需要两条分录"); } BigDecimal debit = BigDecimal.ZERO, credit = BigDecimal.ZERO; for (VoucherEntry e : v.getEntries()) { // 同一行不能既有借方又有贷方 if (e.getDebit().signum() > 0 && e.getCredit().signum() > 0) { throw new BizException("第" + e.getLineNo() + "行借贷不能同时有金额"); } debit = debit.add(e.getDebit()); credit = credit.add(e.getCredit()); } if (debit.compareTo(credit) != 0) { throw new BizException("借贷不平衡,差额 " + debit.subtract(credit)); } if (debit.signum() == 0) { throw new BizException("凭证金额不能为零"); } }

signum()返回 -1、0、1,比compareTo(ZERO)读起来清楚。校验放在 Service 而不是 Servlet,是因为凭证可能从导入、复制等多个入口进来,规则只写一遍才不会漏。BizException是自定义业务异常,Servlet 层捕获后统一转成 JSON 或错误页,别让它直接把堆栈吐给用户。

4.3 科目余额表:层级汇总与树形展开

科目有父子层级(1001 库存现金下面可能挂 100101、100102),报表要求父级显示的是子级汇总。两种做法:递归查库,或者一次查平再在内存里拼树。数据量在几千行以内,后者更快也不会打爆连接池。

// 一次查出本期所有科目余额,再按科目编码前缀归集到父级 public List<BalanceRow> buildTree(List<BalanceRow> flat) { Map<String, BalanceRow> index = new LinkedHashMap<>(); for (BalanceRow r : flat) index.put(r.getSubjectCode(), r); for (BalanceRow r : flat) { String parent = r.getParentCode(); // 逐级向上累加,科目层级一般不超过 4 级 while (parent != null && index.containsKey(parent)) { BalanceRow p = index.get(parent); p.setDebitSum(p.getDebitSum().add(r.getDebitSum())); p.setCreditSum(p.getCreditSum().add(r.getCreditSum())); p.setEndAmt(p.getEndAmt().add(r.getEndAmt())); parent = p.getParentCode(); } } return new ArrayList<>(index.values()); }

逻辑说明:LinkedHashMap保证输出顺序稳定,报表按科目编码排序是硬需求,用HashMap每次刷新顺序都可能变。内层while一路往上累加,所以三级科目会同时贡献给二级和一级,汇总数不会漏。前提是parentCode必须正确,如果建科目时父级编码填错导致出现环,这里会死循环,所以建表时parent_code最好加个外键或者保存时校验父级存在且层级比当前小。

前端展示用 JSP 循环输出<tr>,父级行加缩进样式,靠level字段控制。导出的时候直接复用同一个 List,不需要再查一次库。

4.4 分页与查询条件:把 LIMIT 参数化

凭证列表是最常被点开的页面,必须分页。原生 JDBC 里LIMIT ?, ?是可以传参的,别用字符串拼接。

public Page<Voucher> page(long accountId, String period, int pageNo, int pageSize) { int offset = (pageNo - 1) * pageSize; String sql = "SELECT id, voucher_no, period, voucher_date, summary " + "FROM t_voucher WHERE account_id = ? AND period = ? " + "ORDER BY voucher_no DESC LIMIT ?, ?"; // 先查总数算总页数,再查当前页 long total = countByPeriod(accountId, period); List<Voucher> rows = jdbcTemplate.query(sql, accountId, period, offset, pageSize); return new Page<>(rows, total, pageNo, pageSize); }

pageNo和pageSize来自前端,必须做边界处理:pageNo < 1归 1,pageSize限制在 10 到 100 之间。不限制的话,有人传pageSize=999999就能把整张表拉出来,内存直接顶到上限。

ORDER BY voucher_no DESC而不是按创建时间排,因为凭证号才是财务人员熟悉的顺序,且同一期间内凭证号唯一。分页和总数查询最好放在同一个事务里,虽然财务场景对这点不一致不敏感,但翻页时看到总数变了会让人怀疑数据被改。

5. 避坑清单:原生 Servlet 项目部署与并发最容易翻车的五处

5.1 首页能开,点任何功能都 404

现象:Tomcat 启动日志干净,访问http://localhost:8080/finance/能看到登录页,但一提交表单就 404,控制台没有任何异常。

原因基本是两个。一是web.xml的<url-pattern>和@WebServlet里写的路径对不上,比如前端表单action="voucher/add",而后端注册的是/voucher/*,少了一层或者多了个斜杠都会失配。二是把项目部署成了根路径(ROOT),代码里getContextPath()返回空串,所有跳转路径都少了前缀。

解决:先在浏览器开发者工具里看实际请求 URL,再对照web.xml逐字比。所有页面内的链接和表单 action 一律用${pageContext.request.contextPath}/xxx拼接,不要写死绝对路径。前缀匹配的 Servlet 记得检查getPathInfo()是不是 null。

5.2 中文全变问号,但数据库里数据是对的

现象:新增凭证时摘要写中文,列表页显示???,用客户端工具直接查数据库能看到正确汉字。

原因:POST 请求体没设编码,request.getParameter按容器默认的 ISO-8859-1 解码。Filter 里写了setCharacterEncoding但放在getParameter之后,等于没写。另外连接串如果没带characterEncoding=utf8,写入时也会出问题。

解决:确认 Filter 在chain.doFilter之前设置编码,并且 Filter 的url-pattern是/*。数据库连接 URL 加useUnicode=true&characterEncoding=utf8,库和表统一utf8mb4。GET 请求的中文在 Tomcat 8 以后默认按 UTF-8 解析,如果是更老的容器,需要在server.xml的 Connector 上加URIEncoding="UTF-8"。

5.3 用一段时间后整个系统卡死,重启才好

现象:上午还能用,下午所有请求都超时,日志里大量waiting for a connection,重启 Tomcat 立刻恢复。

原因:连接泄漏。某个 DAO 方法里getConnection()之后,中间抛了异常,close()没走到finally里;或者手动关了 Connection 但没调DBUtil.close(),ThreadLocal里还留着引用,线程池线程被复用时又去关一个已经关闭的连接。

解决:所有getConnection必须配try/finally,finally里统一调DBUtil.close()(它内部做close + remove)。连接池把maxActive设成 20,removeAbandoned=true、removeAbandonedTimeout=300作为兜底,但兜底只是救命,不能当常态。上线前用压测工具打 200 个并发,观察连接池活跃数能不能回落到 0,回不去就是有泄漏。

5.4 报表金额和手工账差几分钱

现象:凭证单条看都对,科目余额表的合计和财务手工算的对不上,差额通常是 0.01 到 0.1 之间。

原因:某处用了double或者float做中间计算,浮点误差累积。另一个常见原因是汇总时对SUM结果又做了一次四舍五入,但口径和明细不一致。

解决:全链路BigDecimal,数据库DECIMAL(18,2),Java 侧setScale(2, RoundingMode.HALF_UP)只在最终展示时做一次,中间过程不截断。比较金额用compareTo。对账时写一条校验 SQL,把汇总表和明细表逐个科目做差,差值非零的科目打印出来,比人眼一行行对快得多。

5.5 登录过期后点击按钮没反应

现象:页面开着不动半小时,再点"保存",按钮转两圈就停了,控制台报Unexpected token '<',没有任何提示。

原因:会话过期,Filter 拦截后sendRedirect到登录页,前端 AJAX 拿到的是 200 状态加一段 HTML 登录页,JSON.parse解析失败。用户看到的就是"点了没反应"。

解决:Filter 里判断X-Requested-With头(或者Accept包含application/json),返回 401 加 JSON 体。前端统一封装请求方法,遇到 401 就弹登录层并记住当前页面。另外,会话超时时间调长只是缓解,真正的解法是这套异常通道要通。

6. 二次开发:从"能跑"到"敢用"的三处改造

这套代码最容易被人接手后第一件事就是加功能,我一般建议先改三个地方,改完再谈业务扩展。

第一处,权限从"登录即可"改成"角色 + 数据范围"。现在的 Filter 只判断有没有登录,所有登录用户能看到所有账套。改造方式是给t_user加role和account_ids两个字段,Filter 里把当前用户能访问的账套 id 集合放进request属性,Service 层每个查询都带上account_id IN (...)条件。表结构改动如下:

字段类型说明
roleVARCHAR(20)ADMIN / ACCOUNTANT / VIEWER
account_idsVARCHAR(200)逗号分隔的账套 id,ADMIN 为空表示全部

VIEWER 只允许 GET 请求,在 Filter 里判断request.getMethod(),非 GET 直接拒绝。这样加一个只读的老板账号就不用改业务代码。

第二处,把报表口径抽到配置里。科目余额表现在用的是"期初 + 借 - 贷",但负债类科目应该是"期初 - 借 + 贷"。当前代码靠direction字段在 Service 里用 if 判断,新增科目类型就要改代码。更稳的做法是在t_subject里加一列balance_formula,值存+1/-1,计算时统一乘上去,公式变了改数据不改代码。

// 统一口径:资产类 direction=1,负债权益类 direction=-1 BigDecimal end = begin .add(debitSum.multiply(BigDecimal.valueOf(direction))) .subtract(creditSum.multiply(BigDecimal.valueOf(direction)));

验证方法很直接:找一个月初余额为零、当月只有一笔借方发生额的科目,跑一遍结转,看end_amt是不是等于借方金额;再把同一笔改成贷方,资产科目应该是负数,负债科目应该是正数。两个方向都对,公式才算立住。

第三处,把凭证号和期间关账做成不可逆操作。现在凭证号是查询最大值加一,两个人同时保存会撞号。改造方案是建一张t_sequence表,用UPDATE ... SET current = current + 1 WHERE name = 'voucher_202401'再加SELECT拿值,靠行锁保证唯一。关账同理,t_account.status改成"已关账"之后,Service 层所有写操作第一行就抛异常,不做任何商量。

这三处改完,系统才算从"能演示"走到"敢给财务用"。源码和数据库脚本都在包里,建表语句、初始科目、测试账号都是齐的,建议先把环境跑通、用测试账号录两张凭证、跑一次结转把报表数字对上,再动手改。我吃过一次亏:上来就改权限,改完发现余额表算错,两个问题缠在一起查了一整天。从那以后我每次接手这类项目,都强制先跑一遍"录凭证 → 审核 → 结转 → 对账"的完整链路,确认基线是对的,再动第一行代码。希望帮到你。

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

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

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

立即咨询