简介:本资源为基于SSM框架的金融支付终端管理系统完整项目源码,面向Java Web初学者与进阶开发者,适合作为毕业设计、课程设计或SSM整合练习的参考案例。项目采用Spring、SpringMVC与MyBatis构建后端,结合JSP动态页面与Vue.js、ElementUI实现前端交互,涵盖用户权限、交易流程与数据持久化等典型业务场景。压缩包共1245个文件,约16.89MB,其中364个js脚本、146个css样式、112个java源码、96个jsp页面及172个png与141个gif图片资源,另含sql脚本、xml配置与properties文件,目录结构清晰,便于按模块查阅。目前已有40人学习下载。通过研读源码,读者可掌握SSM分层架构、JSP与Vue混合开发方式及ElementUI组件应用,并借鉴备份文件与配置组织思路,快速搭建可运行的金融支付管理环境。
1. 金融支付终端管理系统:从一台 POS 机的账目说起
一台支付终端每天要处理几百笔交易,终端状态、商户信息、交易流水、对账差错,这些数据如果散落在 Excel 和纸质台账里,不出三天就会乱成一锅粥。ssm790 这个标题指向的,就是一套基于 SSM(Spring + SpringMVC + MyBatis)技术栈的金融支付终端管理系统,前端用 JSP 渲染页面,后端用 Java 做业务逻辑,数据库存交易和终端档案。它要解决的问题很具体:把终端设备台账、商户绑定关系、交易流水查询、对账差错处理这几件事,收进一个后台系统里统一管。适合谁看?正在做 Java Web 课程设计、需要一套能跑通的支付类管理系统骨架的开发者,或者想理解 SSM 三层架构怎么落到真实业务表结构上的后端新手。下面我按实际搭建顺序,把选型、建表、配置、编码和排错一条条拆开讲。
2. 为什么支付终端管理还在用 SSM:选型理由与分层边界
2.1 支付终端业务对技术栈的三个硬要求
支付终端管理不是普通 CRUD,它对技术栈有三条硬约束。第一是事务一致性:一笔交易要同时写交易流水表、更新终端累计金额、写对账状态,任何一步失败都必须回滚,否则账目对不上。第二是查询条件复杂:运营人员经常要按终端编号、商户号、交易时间段、交易状态四个条件组合筛选,SQL 拼接必须可控。第三是部署环境保守:很多中小支付服务商的服务器还是 JDK 8 + Tomcat 8,不能随便上微服务或响应式框架。
SSM 恰好卡在这三条线上。Spring 的声明式事务用@Transactional就能把多表写入包成一个原子操作;MyBatis 的 XML 映射文件让复杂查询条件可以动态拼装,又不会像全自动 ORM 那样生成不可控的 SQL;SpringMVC 配合 JSP 在 Tomcat 上跑,部署包就是一个 war,扔进 webapps 就能启动。这不是说 SSM 比 Spring Boot 先进,而是在这套业务约束下,它的可控性和可预测性反而成了优势。
2.2 三层架构在支付终端系统里的职责划分
SSM 的三层在支付终端系统里对应得很清楚。Controller 层只做参数接收和视图跳转,比如接收终端编号查询请求,把结果塞进 Model 返回 JSP。Service 层承载业务规则,比如“终端注销前必须确认无未结算交易”这种判断只能写在 Service,不能写在 Controller 或 Mapper。Mapper 层只负责单表或简单关联查询,复杂统计走 XML 里的自定义 SQL。
我一般会额外加一个common包放工具类和统一返回结构,再加一个interceptor包做登录拦截。支付终端系统涉及金额和商户信息,权限拦截不能省。分层边界一旦模糊,后期改一个对账逻辑就要翻遍所有文件,血泪经验。
2.3 从零搭出可运行的 SSM 骨架:依赖与目录
先建 Maven 项目,pom.xml里锁定版本,避免依赖冲突。以下是核心依赖片段:
<!-- pom.xml 核心依赖,版本按 JDK8 环境锁定 --> <properties> <spring.version>5.3.30</spring.version> <mybatis.version>3.5.13</mybatis.version> </properties> <dependencies> <!-- Spring 核心:IoC 容器和事务管理 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <!-- SpringMVC:处理 web 请求和视图解析 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis:SQL 映射与结果集转换 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <!-- MyBatis 与 Spring 整合包,缺它事务不生效 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.2</version> </dependency> <!-- 数据库驱动,按实际数据库替换 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- JSP 相关,Tomcat 提供 scope --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies>逻辑说明:mybatis-spring这个整合包是事务生效的关键,很多人只引 mybatis 不引它,结果@Transactional完全没反应,查半天查不出原因。参数说明:Spring 5.3.x 是 JDK8 下最稳的版本线,MyBatis 3.5.x 对动态 SQL 支持完善。目录结构按controller / service / service.impl / mapper / entity / common分包,JSP 放WEB-INF/jsp/下,避免被直接访问。
3. 支付终端核心表怎么建:四张主表与索引设计
3.1 终端表、商户表、交易表、对账表的字段取舍
支付终端系统的数据模型围绕四张表展开。终端表存设备档案,商户表存商户主体信息,交易表存每笔流水,对账表存日终差错记录。字段设计有两个原则:金额一律用DECIMAL(12,2)不用 float,状态字段用 tinyint 加注释不用字符串。
-- 终端设备表:一台设备一条记录 CREATE TABLE t_terminal ( id BIGINT PRIMARY KEY AUTO_INCREMENT, terminal_no VARCHAR(32) NOT NULL COMMENT '终端编号,业务唯一键', merchant_id BIGINT NOT NULL COMMENT '绑定商户ID', terminal_status TINYINT DEFAULT 0 COMMENT '0未激活 1正常 2停用 3注销', bind_time DATETIME COMMENT '绑定时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_terminal_no (terminal_no), KEY idx_merchant (merchant_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 交易流水表:数据量最大,索引要精打细算 CREATE TABLE t_transaction ( id BIGINT PRIMARY KEY AUTO_INCREMENT, trade_no VARCHAR(64) NOT NULL COMMENT '交易流水号', terminal_no VARCHAR(32) NOT NULL, trade_amount DECIMAL(12,2) NOT NULL COMMENT '交易金额,禁止用float', trade_status TINYINT DEFAULT 0 COMMENT '0待处理 1成功 2失败 3已冲正', trade_time DATETIME NOT NULL, UNIQUE KEY uk_trade_no (trade_no), KEY idx_terminal_time (terminal_no, trade_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:终端表的terminal_no加唯一索引,防止重复录入同一台设备。交易表的联合索引idx_terminal_time是为“按终端查某时间段流水”这个高频查询准备的,单列索引在这里不够用。参数说明:金额字段用DECIMAL(12,2)支持最大约 100 亿,足够覆盖单笔交易;状态用 tinyint 省空间且查询快。
3.2 交易流水表的联合索引为什么按这个顺序建
联合索引的顺序不是随便定的。查询条件里terminal_no是等值匹配,trade_time是范围匹配,所以terminal_no必须放前面。如果反过来把时间放前面,范围查询之后的列索引会失效。这是 MySQL 最左前缀原则的典型应用,也是新手最容易翻车的地方。
我一般会在建完索引后用EXPLAIN验证一遍,看type是不是ref或range,看key有没有命中预期索引。如果type是ALL,说明走了全表扫描,交易表数据一多就会拖垮整个系统。对账表则按settle_date + merchant_id建索引,因为日终对账是按日期和商户维度跑的。
3.3 用 MyBatis 动态 SQL 实现多条件流水查询
运营查流水时条件不固定,可能只给终端号,也可能给终端号加时间段加状态。这种场景用 MyBatis 的<if>和<where>标签最合适:
<!-- TransactionMapper.xml 多条件动态查询 --> <select id="selectByCondition" resultMap="BaseResultMap"> SELECT trade_no, terminal_no, trade_amount, trade_status, trade_time FROM t_transaction <where> <!-- 终端号非空才拼条件,避免全表扫描 --> <if test="terminalNo != null and terminalNo != ''"> AND terminal_no = #{terminalNo} </if> <!-- 时间段用 >= 和 <= 双边界,防止漏掉边界数据 --> <if test="startTime != null"> AND trade_time >= #{startTime} </if> <if test="endTime != null"> AND trade_time <= #{endTime} </if> <if test="tradeStatus != null"> AND trade_status = #{tradeStatus} </if> </where> ORDER BY trade_time DESC LIMIT #{offset}, #{pageSize} </select>逻辑说明:<where>标签会自动处理第一个AND,不用手写WHERE 1=1。参数说明:startTime和endTime用双边界,避免只写>漏掉整点数据;分页用LIMIT offset, pageSize,offset 由页码减一乘每页条数算出。注意 XML 里<要写成<,否则解析报错,这个坑几乎每个人都踩过。
4. 交易写入与对账逻辑:事务、幂等与状态机
4.1 一笔交易从请求到落库的完整链路
一笔交易进来,Controller 接收参数后调 Service 的createTransaction方法。Service 里先做参数校验,再查终端状态是否正常,然后插入交易流水,最后更新终端累计金额。这三步必须在一个事务里,任何一步失败全部回滚。
// TransactionServiceImpl.java 交易写入核心逻辑 @Service public class TransactionServiceImpl implements TransactionService { @Autowired private TransactionMapper transactionMapper; @Autowired private TerminalMapper terminalMapper; // 声明式事务,rollbackFor 指定异常类型才回滚 @Override @Transactional(rollbackFor = Exception.class) public void createTransaction(TradeDTO dto) { // 第一步:校验终端状态,停用终端不允许交易 Terminal terminal = terminalMapper.selectByNo(dto.getTerminalNo()); if (terminal == null || terminal.getTerminalStatus() != 1) { throw new BizException("终端状态异常,拒绝交易"); } // 第二步:幂等校验,同一流水号只允许写入一次 int exist = transactionMapper.countByTradeNo(dto.getTradeNo()); if (exist > 0) { throw new BizException("重复交易,已忽略"); } // 第三步:插入流水 transactionMapper.insert(dto.toEntity()); // 第四步:更新终端累计金额 terminalMapper.addAmount(dto.getTerminalNo(), dto.getTradeAmount()); } }逻辑说明:@Transactional的rollbackFor = Exception.class必须显式写,因为 Spring 默认只对运行时异常回滚,受检异常不回滚,这是经典坑。参数说明:dto.getTradeNo()是上游系统生成的唯一流水号,用它做幂等键;addAmount在 SQL 里用SET amount = amount + #{amt}原子更新,避免先查后改的并发问题。
4.2 幂等控制:为什么不能只靠数据库唯一索引
有人觉得交易表trade_no加了唯一索引就够了,重复插入会抛异常自然拦截。但实际场景里,重复请求往往发生在插入之前——比如网络超时后客户端重试,此时第一笔可能还没提交。只靠唯一索引,第二笔会先通过校验再插入失败,用户体验差且日志混乱。
我一般会在 Service 层先做一次countByTradeNo查询,再靠唯一索引兜底。这是双保险:查询拦截大部分重复,唯一索引防止并发穿透。如果并发量再高,可以引入 Redis 做分布式锁,但支付终端系统通常单机部署,数据库唯一索引加 Service 校验已经够用。
4.3 对账状态机:从待处理到已冲正的流转规则
交易状态不能随便改,必须按状态机走。允许的流转是:待处理→成功、待处理→失败、成功→已冲正。不允许从失败直接跳到已冲正,也不允许成功回退到待处理。状态机校验写在 Service 里:
// 状态流转校验,非法流转直接抛异常 private void checkStatusTransfer(int from, int to) { // 定义合法流转集合 Map<Integer, List<Integer>> allowMap = new HashMap<>(); allowMap.put(0, Arrays.asList(1, 2)); // 待处理 -> 成功/失败 allowMap.put(1, Arrays.asList(3)); // 成功 -> 已冲正 allowMap.put(2, Collections.emptyList()); // 失败是终态 allowMap.put(3, Collections.emptyList()); // 已冲正是终态 List<Integer> allowed = allowMap.get(from); if (allowed == null || !allowed.contains(to)) { throw new BizException("非法状态流转: " + from + " -> " + to); } }逻辑说明:用 Map 定义流转规则,比一堆 if-else 清晰且易扩展。参数说明:状态码 0/1/2/3 与建表注释一致,改状态前先调checkStatusTransfer。对账时如果发现成功交易在银行侧不存在,就走冲正流程,状态从 1 改到 3,同时写对账差错表。
5. 避坑与排查:SSM 支付系统最常见的五类翻车
5.1 事务不生效:@Transactional 加了但没回滚
现象:Service 方法抛异常后,交易流水插入了但终端金额没更新,数据不一致。原因:@Transactional默认只对RuntimeException回滚,如果抛的是受检异常就不回滚;或者方法被同类内部调用,代理不生效。解决:显式写rollbackFor = Exception.class;确保事务方法由外部类调用,不要this.调用。
5.2 JSP 页面中文乱码:三处编码要统一
现象:终端名称在页面显示成问号。原因:JSP 文件编码、响应编码、数据库连接编码三处不一致。解决:JSP 顶部写<%@ page contentType="text/html;charset=UTF-8" %>;web.xml 配CharacterEncodingFilter设 UTF-8;数据库连接串加useUnicode=true&characterEncoding=utf8。三处缺一不可。
5.3 动态 SQL 条件拼错:查询结果莫名多出数据
现象:只传终端号查询,结果返回了所有终端的数据。原因:<if>里用了terminalNo != ''但没判 null,或者<where>标签漏写导致AND拼在WHERE前。解决:条件判断统一写!= null and != '';用<where>而不是手写WHERE 1=1;改完用日志打印最终 SQL 核对。
5.4 金额精度丢失:float 字段算出一堆小数
现象:交易金额累加后出现100.00000001这种值。原因:建表时金额用了 float 或 double。解决:金额字段一律DECIMAL(12,2),Java 侧用BigDecimal接收,禁止用 double 做金额运算。已经建错的表要改字段类型并清洗历史数据。
5.5 分页查询越翻越慢:LIMIT 大偏移量的代价
现象:交易表到几十万行后,翻到后面几页明显变慢。原因:LIMIT 100000, 20会先扫描前 10 万行再丢弃。解决:用游标分页,记住上一页最后一条的trade_time和id,下一页查WHERE trade_time < #{lastTime};或者限制最大可翻页数,运营查历史走导出而不是在线翻页。
6. 进阶技巧:把对账差错率压到千分之一以下
对账是支付终端系统里最考验细节的环节。我一般会做三件事把差错率压下来。第一,对账任务用定时器在凌晨低峰跑,避免和实时交易抢数据库连接。第二,对账结果分三类落库:本地有银行无、银行有本地无、金额不一致,每类单独出报表,不要混在一起。第三,对账差错表加处理状态字段,运营处理完标记关闭,未关闭的每天重新推送提醒。
验证对账逻辑是否正确,可以用一组构造数据跑一遍:造 100 笔本地成功交易,其中 95 笔银行侧也有且金额一致,3 笔银行侧缺失,2 笔金额差 0.01。跑完对账任务后检查差错表是否正好 5 条,且分类正确。这个验证方法比看日志靠谱得多。
-- 对账差错统计,按类型分组看数量 SELECT diff_type, COUNT(*) AS cnt FROM t_reconcile_diff WHERE settle_date = #{settleDate} AND handle_status = 0 GROUP BY diff_type;逻辑说明:handle_status = 0只统计未处理的差错,已关闭的不再干扰。参数说明:settle_date是对账日期,diff_type区分差错类型。我习惯每天早上一眼扫这个统计,数量突增就说明上游或终端侧有异常。
最后一个习惯:任何涉及金额的 SQL 改动,先在测试库用构造数据跑一遍对账验证,确认差错分类和数量都对,再上生产。这个习惯帮我挡掉过好几次金额计算错误,希望帮到你。
本文还有配套的精品资源,点击获取