简介:这是一套基于SSM框架的股票交易管理系统Java毕业设计源码,适合计算机相关专业学生用于毕业设计或课程设计,尤其适合已掌握Java基础、希望学习SSM整合开发的中级学习者,可快速搭建具备前台交易与后台管理完整流程的演示项目。压缩包共包含1268个文件,涵盖Java源码、JSP页面、JS脚本、CSS样式以及PNG/JPG/GIF等图片素材,整体大小约19.8MB,目录结构清晰。已有62人学习下载。内含前台用户注册登录、股票浏览收藏评论、个人资金账户管理、买入卖出操作,以及后台管理员对用户、股票、股票板块、资金流水、系统管理等模块的完整管理功能;同时附带数据库SQL脚本、项目文档和Maven配置,配合JDK1.8、MySQL5.7及以上、Tomcat7及以上环境即可直接导入运行,便于二次开发和学习SSM整合流程。每笔操作均有系统记录,便于理解股票交易与资金管理的完整业务流程。
1. 为什么说股票交易系统是检验SSM功底最好的项目
拿到一份“基于SSM的股票交易管理系统源码”,如果只是把它当成又一个增删改查的毕业设计,那确实浪费了。股票交易这个业务场景在技术上非常特殊:它既有典型的CRUD需求(用户管理、股票信息维护),又有真实业务系统才会遇到的并发账务问题(买入扣款、卖出加款、持仓校验),还牵扯到价格波动下的业务规则约束。也就是说,它恰好卡在“纯教学Demo”和“生产级交易系统”之间的位置,用来检验你对SSM框架的掌握程度,比单纯的学生管理系统、图书管理系统要扎实得多。
对准备面试的人来说,简历上写“基于SSM的股票交易系统”,面试官几乎必问“买入卖出时怎么保证持仓不被超卖”“多线程并发扣款怎么处理”。这比背Java面试八股文更能看出真实水平。对准备做毕业设计的同学来说,源码加LW(论文)的组合,意味着可以从数据库设计到项目结构有一条完整的参考线。本文不会假装是那份源码的官方文档,而是作为一线工程师,按SSM做股票交易系统最常见的做法,把关键设计、核心代码和部署排障一遍讲清楚。
2. SSM框架选型与项目整体结构规划
2.1 为什么毕业设计几乎都选SSM而不是Spring Boot
先说结论:SSM在2025年已经不是新项目的主流选择,但作为毕业设计、课程设计和面试手写项目,它仍然是最稳妥的方案。原因有几点。第一,SSM(Spring + Spring MVC + MyBatis)的配置全是显式的XML和注解,你能看到每个Bean是怎么被创建、怎么被注入的,这对理解Spring容器机制非常有帮助。Spring Boot把大量配置自动化了,反而让很多初学者对容器初始化过程一知半解。第二,技术面试中问MyBatis的Mapper动态代理、Spring的事务传播行为、Spring MVC的DispatcherServlet流程,这些在SSM项目中都是直接暴露出来的,不像Boot里被封装得那么深。第三,论文好写。SSM的架构层次清晰,逻辑上更接近经典三层架构,画图和描述都比较顺手。
所以,如果你拿到的这份源码是SSM而不是Spring Boot,不要觉得它“过时”。它对应的是Java基础、Java源码阅读能力和框架原理考察最密集的那个阶段。学习价值甚至比直接上手Spring Boot更高。但也要清楚:SSM只适合单体应用,股票交易系统这种场景恰好是单体应用能驾驭的边界。如果要做高并发撮合引擎,SSM并不合适,那是另一个话题了。
2.2 标准的Maven多模块还是单模块结构
常见的SSM毕业设计项目,绝大多数是单Maven工程,war包部署到Tomcat。结构大致如下:
stock-trading-system ├── pom.xml ├── src │ ├── main │ │ ├── java │ │ │ └── com/example/stock │ │ │ ├── controller │ │ │ │ ├── UserController.java │ │ │ │ ├── StockController.java │ │ │ │ └── TradeController.java │ │ │ ├── service │ │ │ │ ├── TradeService.java │ │ │ │ └── impl │ │ │ │ └── TradeServiceImpl.java │ │ │ ├── dao │ │ │ │ ├── UserMapper.java │ │ │ │ ├── StockMapper.java │ │ │ │ └── TradeRecordMapper.java │ │ │ ├── entity │ │ │ │ ├── User.java │ │ │ │ ├── Stock.java │ │ │ │ └── TradeRecord.java │ │ │ ├── common │ │ │ │ ├── Result.java │ │ │ │ └── PageBean.java │ │ │ └── interceptor │ │ │ └── LoginInterceptor.java │ │ ├── resources │ │ │ ├── jdbc.properties │ │ │ ├── mybatis-config.xml │ │ │ ├── spring-mvc.xml │ │ │ └── spring-mybatis.xml │ │ └── webapp │ │ ├── WEB-INF │ │ │ ├── web.xml │ │ │ └── views │ │ └── static这个结构的核心在于web.xml是整个应用的入口。SSM整合时,web.xml里需要配置两件事:Spring容器监听器和DispatcherServlet。前者负责加载Spring的根上下文,后者负责Spring MVC的子容器。业务Service、Mapper等Bean放在前者,Controller和视图解析器放在后者。需要注意,两个容器有父子关系,子容器可以访问父容器的Bean,反过来不行。这就是为什么事务配置必须放在Spring的根容器里,而不能只放在Spring MVC的配置文件中。
2.3 三个配置文件各自的职责边界
spring-mybatis.xml负责数据源和SqlSessionFactory。jdbc.properties里放数据库连接参数,最典型的就是下面这组。
jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/stock_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456重点说一下serverTimezone这个参数。MySQL 8.0以上版本如果不在连接串里显式指定时区,会直接报错提示Server returns invalid timezone。而useSSL=false是为了避免本地开发时因证书问题产生大量警告日志,不影响功能但非常干扰排查。如果使用MySQL 8.x驱动,driver类名应该写com.mysql.cj.jdbc.Driver,老驱动在8.0连接下会报ClassNotFoundException。
spring-mvc.xml里主要配三块:注解驱动、静态资源放行、视图解析器。注解驱动做的是注册RequestMappingHandlerAdapter和RequestMappingHandlerMapping,让@Controller和@RequestMapping生效。静态资源放行是SSM项目里新手最容易忽略的,不配置的话,CSS和JS全部404,页面丢样式。
<mvc:annotation-driven /> <mvc:resources mapping="/static/**" location="/static/" /> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/" /> <property name="suffix" value=".jsp" /> </bean>视图解析器把Controller里return "trade/list"解析成/WEB-INF/views/trade/list.jsp。把JSP放WEB-INF目录下是个好习惯,外部直接访问不到,必须经过Controller转发,这对股票交易这类涉及资金操作的页面来说是个低成本的安全隔离手段。
3. 股票交易核心业务的数据库设计与MyBatis实现
3.1 股票交易系统的核心表结构设计
股票交易系统的数据库设计比一般管理系统要复杂,关键在于四张核心表:用户表、股票表、持仓表、交易记录表。用户表保存账户资金和基本信息,股票表保存行情快照,持仓表记录每个用户当前持有哪只股票、成本价、数量,交易记录表保存每一笔委托和成交明细。其中持仓表是关键,因为买入、卖出时的可用数量校验,本质上是对持仓表的行级操作。
CREATE TABLE `t_user` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `password` VARCHAR(64) NOT NULL, `balance` DECIMAL(18,2) DEFAULT '1000000.00', PRIMARY KEY (`id`), UNIQUE KEY `idx_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `t_position` ( `id` INT NOT NULL AUTO_INCREMENT, `user_id` INT NOT NULL, `stock_code` VARCHAR(10) NOT NULL, `quantity` INT NOT NULL DEFAULT 0, `available_qty` INT NOT NULL DEFAULT 0, `cost_price` DECIMAL(18,2) DEFAULT '0.00', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_stock` (`user_id`, `stock_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;重点说两个字段。第一是available_qty和quantity分开设计。quantity是当前总持仓,available_qty是可卖数量。A股T+1制度下,当天买入的股票不能当天卖出,所以买入成交时只增加quantity,不增加available_qty;卖出成交时同时扣减两者。这个拆分直接影响后续交易逻辑的判断。第二是UNIQUE KEY uk_user_stock上的唯一索引,它保证同一个用户同一只股票只有一条持仓记录,这样并发更新时可以用INSERT ... ON DUPLICATE KEY UPDATE或者SELECT ... FOR UPDATE来做行级锁控制。
余额字段用DECIMAL(18,2)而不用FLOAT或DOUBLE,是金融系统的基本要求。浮点数在二进制中无法精确表示很多十进制小数,如果用户买股票后账户余额出现0.0000001的误差,对交易系统来说就是事故。这个知识点在Java面试中属于基础题,但真正在设计表时主动用对的人不多。
3.2 买入股票时如何保证资金不被超扣
买入操作的逻辑是:校验资金余额是否足够,扣减余额,增加持仓。这里最容易出问题的就是“先查询再更新”造成的竞态条件。如果两个请求同时读到余额是10000元,而股票价格是6000元,两个请求都判断余额充足,然后都执行扣款,最后余额就变成了负数。这是典型的丢失更新问题。
正确处理方式有两种。第一种是用数据库的乐观锁,在用户表加version字段,更新时带上版本号做条件。第二种是用行级锁,在事务内先SELECT ... FOR UPDATE锁住用户行,再判断余额和更新。我一般更推荐第二种,因为股票交易本身就是高频账务操作,行锁的冲突概率比乐观锁的无限重试更可控。
@Override @Transactional public void buyStock(Integer userId, String stockCode, Integer quantity) { User user = userMapper.selectByIdForUpdate(userId); Stock stock = stockMapper.selectByCode(stockCode); BigDecimal amount = stock.getPrice().multiply(BigDecimal.valueOf(quantity)); if (user.getBalance().compareTo(amount) < 0) { throw new BusinessException("资金余额不足"); } userMapper.decreaseBalance(userId, amount); positionMapper.addPosition(userId, stockCode, quantity, amount); tradeRecordMapper.insertBuyRecord(userId, stockCode, quantity, amount); }这段代码有几个细节值得说明。selectByIdForUpdate对应的SQL必须是SELECT * FROM t_user WHERE id = #{id} FOR UPDATE,这个FOR UPDATE是MySQL InnoDB的行级锁语法,事务提交后才释放。因为整个方法上有@Transactional,锁的持有时间就是整个方法执行的时间,所以其他事务对同一个用户发起的交易请求会阻塞等待。注意,这里和Java面试八股文里常说的ConcurrentHashMap、线程池并发完全是两个层面的问题,前者说的是Java内存级别,这里说的是数据库事务隔离级别,两个都要会区分。
另一个细节是decreaseBalance不能用balance = balance - #{amount}这种在Java里计算好的值传进去,而应该用balance = balance - #{amount}放在SQL里执行,让数据库自行计算。因为这个操作前面已经锁住了行,重新读到的值不会过期,但写成SQL自减是更保险的习惯,至少不会基于旧值覆盖新值。
3.3 卖出股票时T+1校验与持仓扣减
卖出逻辑比买入多一步T+1校验。持仓表里available_qty和quantity的差值就是当日新买入的数量。卖出时只能使用available_qty以内的数量。
@Override @Transactional public void sellStock(Integer userId, String stockCode, Integer quantity) { Position position = positionMapper.selectByUserIdAndStockForUpdate(userId, stockCode); if (position == null || position.getAvailableQty() < quantity) { throw new BusinessException("可卖持仓不足"); } BigDecimal amount = position.getCostPrice().multiply(BigDecimal.valueOf(quantity)); userMapper.increaseBalance(userId, amount); positionMapper.decreaseAvailableQty(userId, stockCode, quantity); tradeRecordMapper.insertSellRecord(userId, stockCode, quantity, amount); }这段逻辑里,selectByUserIdAndStockForUpdate锁定的是持仓行,不是用户行。这样设计的好处是:用户同时卖出A股票和B股票不会被同一个锁阻塞,只有操作同一只股票的请求才会互相等待。如果直接锁用户行,用户卖出A股票时会阻塞卖出B股票,并发能力明显下降。这是股票交易系统在SSM框架下能做出的比较细的性能优化,面试时主动讲出来非常加分。
decreaseAvailableQty对应的SQL如下。
UPDATE t_position SET available_qty = available_qty - #{quantity} WHERE user_id = #{userId} AND stock_code = #{stockCode} AND available_qty >= #{quantity}这个SQL里带了available_qty >= #{quantity}的条件,所以即使前面的行锁因为某种原因失效,数据库层面也能挡住超卖,相当于做了双重防护。更新后返回的影响行数是1表示成功,是0表示可卖数量不足。
3.4 交易记录表的分页查询与MyBatis参数传递
交易记录表结构相对简单,但要注意的是查询时按时间倒序和资金字段的精度。
CREATE TABLE `t_trade_record` ( `id` INT NOT NULL AUTO_INCREMENT, `user_id` INT NOT NULL, `stock_code` VARCHAR(10) NOT NULL, `trade_type` TINYINT NOT NULL COMMENT '1-买入 2-卖出', `price` DECIMAL(18,2) NOT NULL, `quantity` INT NOT NULL, `amount` DECIMAL(18,2) NOT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_time` (`user_id`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;MyBatis分页查询最常见的做法是PageHelper插件,但手写Limit也完全可行,而且更符合SSM原生的教学风格。在Mapper接口中定义方法如下。
List<TradeRecord> selectTradePage(@Param("userId") Integer userId, @Param("offset") Integer offset, @Param("pageSize") Integer pageSize);对应的XML里写:
<select id="selectTradePage" resultType="TradeRecord"> SELECT * FROM t_trade_record WHERE user_id = #{userId} ORDER BY create_time DESC, id DESC LIMIT #{offset}, #{pageSize} </select>这里有一个比较容易踩的坑:LIMIT后面不能直接用#{}的默认方式做预编译,但MyBatis的#{}本身就是预编译占位符,MySQL是支持的,所以这样写没有问题。但如果用${}拼接,就会有SQL注入风险,这是绝对不可取的。分页参数中,offset从0开始算,前端传页码时要转成(pageNum-1) * pageSize。排序上一定加id DESC作为第二排序条件,因为同一秒内可能有多次成交,create_time精度不够时顺序就会乱。
4. 登录权限与并发扣款的Transactional边界控制
4.1 登录拦截器与密码加密的具体配置
SSM项目里的登录拦截是经典的HandlerInterceptor实现。相比Spring Boot的HandlerInterceptor也没本质区别,但在SSM中需要手动在spring-mvc.xml里注册拦截器路径。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object user = session.getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } return true; } }在spring-mvc.xml里注册时,要注意拦截路径的写法。
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**" /> <mvc:exclude-mapping path="/login" /> <mvc:exclude-mapping path="/static/**" /> <mvc:exclude-mapping path="/user/register" /> </mvc:interceptor> </mvc:interceptors>排除路径里必须包含/static/**,否则CSS、JS、图片全被拦截,页面样式全部丢失。登录接口和注册接口要放行,密码验证放在Controller里用userService.login(username, password)完成。密码存储不要用明文,MD5不够,最少用加盐的SHA-256或BCrypt。SSM项目里引入spring-security-crypto做BCrypt加密并不复杂,如果源码里用的是简单MD5,可以自己升级一下。
4.2 为什么买股票和卖股票不能共用一个Service方法
很多同学写股票交易系统时,会把买入和卖出各写一个方法,但没有考虑事务边界。关键在于:数据库事务的默认隔离级别是REPEATABLE READ,InnoDB在这级别下使用Next-Key Lock防止幻读。如果买入和卖出各自直接开事务,那么一个请求在事务中先查询用户余额,另一个请求同时发起卖出成交后更新余额,两个事务之间的可见性就依赖隔离级别和锁的粒度。
推荐的做法是:所有涉及余额和持仓变动的操作,必须统一放到同一个Service方法中,并且方法上的@Transactional要显式指定rollbackFor。
@Transactional(rollbackFor = Exception.class)为什么一定要指定rollbackFor?Spring的默认事务行为是:RuntimeException和Error触发回滚,但受检异常不会回滚。如果买卖股票操作中调用了某个受检异常,比如IOException,默认情况下不会回滚,钱扣了但持仓没加,账就平不上了。这是SSM项目中最容易出现的隐含Bug。交易系统的任何异常都应当视为严重错误,所以必须显式声明所有异常都回滚。
4.3 事务失效的几种常见场景排查
事务在SSM项目中失效的情况非常典型,排查方法也值得再说一遍。第一,@Transactional标注在private方法上,Spring的AOP基于代理实现,私有方法不经过代理,所以事务完全不会生效。第二,同一个类中方法之间直接调用,比如TradeServiceImpl.buyStock被本类的另一个方法在内部直接调用,绕过了代理对象,事务同样失效。这时候可以注入自身代理或者把方法拆分到不同Service中。第三,MySQL表的存储引擎必须是InnoDB,如果误建成了MyISAM,InnoDB专属的行锁、事务全部失效,表面上程序不报错,但并发场景下数据会乱。连接串上检查useSSL=false和serverTimezone这类参数,不直接影响事务,但会影响连接稳定性。
排查事务是否生效的最快方法是看日志。在配置文件中开启MyBatis的SQL日志输出,如果事务提交前能看到Committing JDBC transaction on Connection [...],说明事务正常开启;如果直接出现Implicit commit或者没有提交事务的日志,说明事务边界没有控制住。
5. Tomcat部署与连接池调优的关键验证
5.1 war包部署到Tomcat的完整流程
SSM项目最终产物是war包。Maven打包前要确认pom.xml中packaging是war,并且spring-web和javax.servlet-api的scope是provided,让Tomcat提供Servlet容器环境。打包命令是:
mvn clean package -DskipTests构建成功后,target目录下生成stock-trading-system.war。部署到Tomcat时有两种方式:直接把war包丢到webapps目录下启动Tomcat自动解压,或者在conf/server.xml里的Host节点下配置Context指向外部目录。开发阶段推荐第一种,生产模拟推荐第二种,因为可以避免每次重新部署时webapps目录里的旧包混淆。
启动后访问路径由war包名决定。如果war包名是stock-trading-system.war,访问地址就是http://localhost:8080/stock-trading-system/。如果嫌路径长,可以改成ROOT.war部署到webapps下,直接通过http://localhost:8080/访问。
5.2 连接池参数和JVM内存分配的常见配置
SSM项目中数据源一般使用Druid或C3P0。Druid在国内用的最多,因为自带监控页面,方便查看SQL执行和连接获取情况。在spring-mybatis.xml中配置Druid参数时,有四个值需要注意。
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="initialSize" value="5" /> <property name="minIdle" value="5" /> <property name="maxActive" value="20" /> <property name="maxWait" value="60000" /> <property name="validationQuery" value="SELECT 1" /> </bean>initialSize是启动时初始连接数,minIdle是保底空闲连接,maxActive是最大活跃连接数,maxWait是获取连接的最长等待时间(毫秒)。股票交易系统里买卖操作相对频繁,如果同时在线用户较多,maxActive调到50左右,配合Druid的监控页面观察当前活跃连接数。如果经常出现wait millis 60000, active 20的报错,说明连接池满了,先看是否有慢SQL拖住连接,再决定加大连接池还是优化SQL。建议优先优化SQL,如果单条SQL执行超过2秒,先和执行计划对照,看是否缺索引。
JVM内存方面,Tomcat默认的堆内存偏小。部署后修改bin/catalina.sh(Linux)或catalina.bat(Windows),在CATALINA_OPTS中加入:
CATALINA_OPTS="-Xms256m -Xmx512m -XX:MaxMetaspaceSize=256m"-Xms是初始堆内存,-Xmx是最大堆内存。对SSM这种中小型项目,512M已经足够。设置过大不仅浪费本机资源,反而会拉长GC停顿时间。
5.3 部署后验证系统是否正常的一组操作
项目启动后不要急着点页面,先确认三件事。第一,看Tomcat日志,确认没有BeanCreationException和ClassNotFoundException。第二,确认数据库连接池成功初始化,Druid日志里能看到连接创建记录。第三,通过curl验证接口响应。
curl -X POST http://localhost:8080/stock-trading-system/user/login \ -d "username=admin&password=123456" \ -c cookies.txt如果登录接口返回JSON数据,说明Controller、Service、Mapper和数据库整条链路已经打通。接着可以带cookies请求交易接口:
curl -X POST http://localhost:8080/stock-trading-system/trade/buy \ -H "Content-Type: application/x-www-form-urlencoded" \ -b cookies.txt \ -d "stockCode=600001&quantity=100"这个时候观察返回结果是否符合预期,再连发几次买入和卖出,然后查询交易记录接口确认订单数量、余额和持仓的变化是否符合计算结果。
6. SSM项目里最值得升级的一个实操点:用AOP给交易接口加操作审计
6.1 为什么审计日志比想象中更重要
股票交易系统里,用户余额变动、买卖委托操作、管理员给股票调价,这几种操作出了问题时需要回溯。最简单的手段是设计一张操作审计表,每次请求成功后记录操作人、操作类型、操作时间、请求参数和响应结果。但如果在每个Controller方法里手写记日志的代码,体量会很大,也容易遗漏。SSM项目里做这个最适合的方案就是Spring AOP的环绕通知。
6.2 一个可直接复用的AuditAspect注解实现
在项目中新建一个@AuditLog注解,然后在需要审计的Controller方法上打上这个注解。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface AuditLog { String operation() default ""; }再写一个切面类,用环绕通知统一处理:
@Component @Aspect public class AuditAspect { private static final Logger logger = LoggerFactory.getLogger(AuditAspect.class); @Around("@annotation(auditLog)") public Object record(ProceedingJoinPoint joinPoint, AuditLog auditLog) throws Throwable { long startTime = System.currentTimeMillis(); Object result; try { result = joinPoint.proceed(); logger.info("operation={}, cost={}ms, args={}, result={}", auditLog.operation(), System.currentTimeMillis() - startTime, Arrays.toString(joinPoint.getArgs()), JSON.toJSONString(result)); return result; } catch (Exception e) { logger.error("operation={} failed, args={}", auditLog.operation(), Arrays.toString(joinPoint.getArgs()), e); throw e; } } }在spring-mvc.xml或spring-mybatis.xml中开启AOP支持:
<aop:aspectj-autoproxy />然后在买入和卖出的Controller方法上标注注解:
@AuditLog(operation = "股票买入") @PostMapping("/trade/buy") @ResponseBody public Result buy(...) { return tradeService.buy(...); }这段AOP代码的好处是:买卖交易的入参和结果全部被打点记录,发生资金纠纷时可以直接从日志里捞出完整链路。对面试来说,能主动用AOP做审计日志设计,说明你理解代理模式的实际应用场景。比单纯回答AOP概念深入很多。当然,日志输出到控制台只是基础,真正生产环境还需要配合Logstash或文件滚动策略做持久化,这一点SSM项目原生阉割得比较干净,自行扩展即可。
本文还有配套的精品资源,点击获取