
简介面向Java毕业设计与课程设计场景这套股票交易管理系统基于SSM框架SpringSpringMVCMyBatis与MySQL数据库完整实现了前台用户注册登录、股票信息浏览与推荐、收藏评论、个人资金账户管理以及买入卖出操作后台则涵盖用户管理、资金管理、股票与板块管理、交易记录管理等核心模块功能链路完整适合需要快速搭建可演示项目的学生参考。压缩包共收录1268个文件整体大小约19.72MB包含120个Java后端类、105个JSP页面、364个JavaScript脚本、146个CSS样式以及数量丰富的PNG/JPG/GIF图片素材、XML配置、SQL数据库脚本和说明文档目录划分清晰便于按模块导入Eclipse/IDEA学习。目前已有137人学习下载。随包附带说明文档与LW文档能够帮助理解数据库表结构、业务逻辑和前后台交互流程本地部署只需JDK1.8、MySQL5.7、Tomcat7及以上环境可作为毕业设计答辩或课程实践的项目蓝本。1. 股票交易管理系统从 SSM 骨架到可复用的交易闭环我见过不少同学把股票交易系统放进 Java 课程设计选题时第一反应是这里是不是要去调券商行情接口其实不用。这套基于 SSMSpring、Spring MVC、MyBatis和 MySQL 5.7 的源码把后台和前台全部写在同一个 Maven 工程里前台解决用户注册、股票浏览、金融资讯、在线交流后台解决用户管理、股票板块、买入卖出和账户资金管理属于典型的后台管理系统加一个交易前台。它真正值得复盘的是资金扣减、订单记录和 MyBatis 事务这些日常工作里躲不掉的环节。适合正在做 Java 毕业设计、课程设计或者想把 SSM 框架在真实场景里完整串一遍的开发者。2. SSM 分层、账户表设计先立住交易主链路的数据模型2.1 为什么这套系统用 SSM 而不是 Spring Boot这个项目没有选择 Spring Boot而是 SSM核心原因是它的运行环境是 Tomcat 7 和 JDK 1.8很多学校机房和老项目还停留在 JSP Servlet 的工作方式。Spring 负责管理 Service 和 DAO 的生命周期Spring MVC 的 DispatcherServlet 接收/stock/list这类请求MyBatis 则把 SQL 结果映射到实体类避免手写一堆 ResultSet 转换。三层边界一旦清楚后面增加接口或改数据库字段时不会牵一发动全身。对比直接用 Servlet 写业务SSM 的优势在于 SQL 被隔离在 Mapper 文件里Java 代码只需要关心对象和方法页面渲染交给 JSPController 只做参数接收、调用和转发。股票列表页、个人中心页和后台管理页共用同一套数据模型不需要拆两套后台这对只有一两个人维护的课程设计来说很重要。2.2 核心数据表用户、资金账户、股票和交易流水按照功能描述我把核心数据表梳理成下面这张关系表。实际你拿到源码后再对照字段业务含义不会跑偏。表名业务含义关键字段user前台用户id, id_card, password, name, phone, area, create_timeaccount资金账户id, user_id, balance, frozen_amount, update_timestock_block股票板块id, block_name, descriptionstock股票标的id, stock_code, stock_name, block_id, current_price, update_timetrade_record交易流水id, user_id, stock_id, trade_type, order_price, shares, amount, trade_timestock_favorite股票收藏id, user_id, stock_id, create_timestock_comment在线交流/评论id, user_id, stock_id, content, create_time这里把买入和卖出都放进 trade_record用 trade_type1 表示买入trade_type2 表示卖出。我倾向于合并成一张表而不是分成 buy_record 和 sell_record因为个人中心展示“最近交易”时只需要一次分页查询统计持仓盈亏时也少两次 join。账户表与用户表是一对一关系注册成功后必须同时插入用户和资金账户否则前台登录后查不到余额这一步骤在后面的事务里会看到写法。股票表通过 block_id 关联板块表后台的“股票管理”和“股票板块管理”维护的就是这两张表。数据库脚本请用 InnoDB 引擎后续的 FOR UPDATE 行锁和事务回滚都依赖这个引擎。2.3 搭建 Maven 工程并把 MyBatis 数据源接好工程结构按经验拆成 controller、service、dao、entity、interceptor 五层Model 层用简单 JavaBean。resources 里放四个 Spring 配置文件和一个 mapper 目录。做法如下。stock-trading/ ├── pom.xml ├── src/main/java/com/stock │ ├── controller │ ├── service │ ├── dao │ ├── entity │ └── interceptor ├── src/main/resources │ ├── jdbc.properties │ ├── spring-dao.xml │ ├── spring-service.xml │ ├── spring-mvc.xml │ ├── mybatis-config.xml │ └── mapper └── src/main/webapp ├── static └── WEB-INF/jspspring-dao.xml 里把数据源、SqlSessionFactory、Mapper 扫描器一次性配好。下面是这套源码最常见的配置写法。!-- 读取 jdbc.properties 中的数据库连接参数 -- context:property-placeholder locationclasspath:jdbc.properties/ bean iddataSource classcom.mchange.v2.c3p0.ComboPooledDataSource property namedriverClass value${jdbc.driver}/ property namejdbcUrl value${jdbc.url}/ property nameuser value${jdbc.username}/ property namepassword value${jdbc.password}/ property namemaxPoolSize value20/ property nameminPoolSize value5/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ property nametypeAliasesPackage valuecom.stock.entity/ property nameconfigLocation valueclasspath:mybatis-config.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.stock.dao/ /beanjdbc.properties 里有一项必须注意URL 要写成jdbc:mysql://localhost:3306/stock?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai。MySQL 5.7 本身没问题但 JDK 1.8 配新版 MySQL Connector/J 时不写 serverTimezone 会在启动时报时区错误。maxPoolSize 调成 20 对毕业设计足够如果代码里出现Connection is not available先看是不是连接池太小而不是马上改拆库。3. 注册登录与股票信息流从参数绑定到 MyBatis 查询3.1 注册查重和资金账户同时创建前台注册时用户提交身份证号、姓名、手机号、地区作为基本资料密码字段至少做一次摘要存储。注册接口的核心校验是先按身份证查用户是否存在存在则直接抛出业务异常不存在才插入。注意业务异常要在 Service 里抛出而不是在 Controller 里写 if/else这样事务边界才清晰。Service public class UserServiceImpl implements UserService { Autowired private UserDao userDao; Autowired private AccountDao accountDao; Override Transactional(rollbackFor Exception.class) public boolean register(UserVO vo) { // 身份证唯一校验防止重复注册 if (userDao.selectByIdCard(vo.getIdCard()) ! null) { throw new BusinessException(该身份证号已注册); } User user new User(); user.setIdCard(vo.getIdCard()); user.setName(vo.getName()); user.setPhone(vo.getPhone()); user.setArea(vo.getArea()); user.setPassword(DigestUtils.md5DigestAsHex( (vo.getPassword() SALT).getBytes())); userDao.insert(user); // 初始化资金账户默认给 10 万虚拟资金 Account account new Account(); account.setUserId(user.getId()); account.setBalance(BigDecimal.valueOf(100000)); accountDao.insert(account); return true; } }逻辑说明先查身份证是否重复再写入用户最后初始化资金账户。Transactional(rollbackFor Exception.class)保证用户插入成功但账户插入失败时两个 insert 都回滚不会出现“用户存在却没有资金账户”的脏数据。很多人只写Transactional遇到检查型异常时事务不会回滚这是一个高频面试点。SALT 是自定义常量密码存储不要用明文MD5 加盐虽然不算强但在课程设计环境里足够如果放到真实环境建议换 BCrypt。3.2 股票列表多条件查询与 PageHelper 分页前台的股票列表页需要支持按股票名称、股票代码、板块分类过滤。Controller 接收三个参数keyword、blockId、pageNum。分页采用 PageHelper因为它能在 MyBatis 执行器层自动改写 SQL比分页插件自己拼 LIMIT 更稳。RequestMapping(/stock/list) public String list(String keyword, Integer blockId, RequestParam(defaultValue 1) Integer pageNum, Model model) { // startPage 只对下一条 SELECT 生效 PageHelper.startPage(pageNum, 10); ListStock stockList stockDao.searchStock(keyword, blockId); PageInfoStock pageInfo new PageInfo(stockList); model.addAttribute(pageInfo, pageInfo); return stock/list; }对应的 Mapper XML 是动态 SQL 的标准写法。!-- 股票关键字与板块联合查询 -- select idsearchStock resultTypecom.stock.entity.Stock SELECT * FROM stock where if testkeyword ! null and keyword ! AND (stock_name LIKE CONCAT(%, #{keyword}, %) OR stock_code LIKE CONCAT(%, #{keyword}, %)) /if if testblockId ! null AND block_id #{blockId} /if /where ORDER BY id DESC /select参数说明keyword 为 null 时整个条件块被跳过blockId 同理。CONCAT(%, #{keyword}, %)是参数化拼接不能只写%#{keyword}%后者会被 MySQL 当作字符串常量处理查不出来数据。另外PageHelper 会在 startPage 后的第一条查询上加 limit所以 stockDao.searchStock 内部不要有多条 SELECT否则分页信息会被后执行的 SQL 覆盖。如果你不想依赖 PageHelper也可以直接用 MySQL 的 LIMIT #{offset}, #{pageSize}但需要手动计算 offset方案简单但对前端不友好。3.3 收藏和评论个人中心的聚合数据导航栏里的“股票”和“股票资讯”展示内容用户可以对单个股票做收藏和评论。收藏接口建议写成切换式即存在则取消不存在则新增避免前端连续点击产生重复记录。PostMapping(/favorite/toggle) ResponseBody public Result toggle(RequestParam Integer userId, RequestParam Integer stockId) { // 存在则取消不存在则新增保持接口幂等 int exists favoriteDao.checkExists(userId, stockId); if (exists 0) { favoriteDao.delete(userId, stockId); return Result.ok(取消收藏); } Favorite favorite new Favorite(); favorite.setUserId(userId); favorite.setStockId(stockId); favoriteDao.insert(favorite); return Result.ok(收藏成功); }这里的关键点是 checkExists 的 SQL 一定要写成SELECT COUNT(1)而不是SELECT *这样 MySQL 不需要回表拿整行数据查询成本低。个人中心里的“我的收藏”“我的评论”就是三张表按用户 ID 过滤后分页。评论内容注意在 JSP 里使用c:out输出防止用户输入脚本内容被浏览器解析。这个不是 SSM 特有的问题但交易系统的在线交流模块正好容易忽略。4. 买入卖出、账户余额与事务并发控制4.1 交易接口里的业务规则买入和卖出是这套系统真正有含金量的地方。我用一条买入接口来说明完整链路校验股票存在、校验数量为正整数、计算金额、校验余额、扣余额、写入交易流水、更新持仓。卖出与买入对称它先校验持仓是否足够再从持仓表扣减最后把卖出金额加回余额。有个经验是项目里至少要维护一张user_stock_position持仓表否则“个人中心-我的股票”没法直接展示当前持有数量。很多毕业设计只记录买卖流水不更新持仓做演示可以到了答辩环节会被问到“用户今天买了 100 股明天再卖 50 股剩余持仓从哪里来”就卡住了。所以我一般会在建表时加上一张持仓表字段为id, user_id, stock_id, shares, update_time。4.2 Transactional FOR UPDATE 锁住余额下面这段代码是买入接口的核心事务逻辑重点在 selectByUserIdForUpdate。Transactional(rollbackFor Exception.class) public void buy(BuyRequest request) { // 锁住账户行防止并发扣款超出余额 Account account accountDao.selectByUserIdForUpdate(request.getUserId()); Stock stock stockDao.selectByCode(request.getStockCode()); if (stock null) { throw new BusinessException(股票不存在); } // 金额用 BigDecimal 计算避免 double 精度丢失 BigDecimal orderAmount stock.getCurrentPrice() .multiply(BigDecimal.valueOf(request.getShares())); if (account.getBalance().compareTo(orderAmount) 0) { throw new BusinessException(可用余额不足); } account.setBalance(account.getBalance().subtract(orderAmount)); accountDao.updateBalance(account); // 写入交易流水确保与扣款在同一事务中 TradeRecord record new TradeRecord(); record.setUserId(account.getUserId()); record.setStockId(stock.getId()); record.setTradeType(1); record.setOrderPrice(stock.getCurrentPrice()); record.setShares(request.getShares()); record.setAmount(orderAmount); tradeRecordDao.insert(record); positionDao.increasePosition(account.getUserId(), stock.getId(), request.getShares()); }selectByUserIdForUpdate 对应的 SQL 是SELECT * FROM account WHERE user_id #{userId} FOR UPDATE。得到账户行后MySQL 会把这行的写锁一直持有到事务提交。这样两个线程同时买入时第二个线程会在 SELECT 处等待不会出现两人同时读到余额 1000 元然后各自扣 800 元成功导致余额变成 -600 的覆盖问题。金额计算必须用 BigDecimal不能用 double。股票现价 19.9 元、买 100 股double 算出来可能会带浮点尾巴存到 decimal(18,2) 虽然会被四舍五入但日志和订单金额中间结果会莫名其妙多出 0.00001。这种误差在金融系统里非常要命。compareTo 比较余额时也避免写balance amountBigDecimal 的 equals 还会比较精度compareTo 才是真正的数值比较。4.3 事务失效的三个隐蔽场景这段代码如果运行后出现“余额被扣但流水没写入”或“余额没被扣但页面提示成功”优先检查三个点。第一个是自调用。同类中 buy() 直接调 this.buy()Spring 事务代理不会拦截注解失效。正确做法是通过注入的 self 或者把事务方法放到另一个 Service 中调用。第二个是 try-catch 吞异常。业务里最常见的是try { accountDao.updateBalance(account); tradeDao.insert(record); } catch (Exception e) { return false; }异常被捕获后事务通知收到的不是异常而是正常返回回滚不会触发。正确做法是把异常继续往外抛由统一异常处理器转成 JSON 返回。第三个是数据库引擎表用了 MyISAM。MyBatis 和 Spring 的事务注解只管应用层底层表不支持事务时回滚就是空话。建议建表时直接写ENGINEInnoDB DEFAULT CHARSETutf8mb4另外注意 Transactional 只对 Spring 代理的 Bean 生效如果 Service 类没被 Spring 管理注解就是摆设。4.4 个人中心的交易流水查询个人中心需要同时展示买入、卖出历史并能在每条记录后面看到对应股票信息。用 trade_record 主表 left join stock 表实现避免一次取出所有字段导致 JSP 页面重复查库。select idselectPageByUserId resultTypecom.stock.dto.TradeRecordVO SELECT r.id, s.stock_code, s.stock_name, r.trade_type, r.order_price, r.shares, r.amount, r.trade_time FROM trade_record r LEFT JOIN stock s ON r.stock_id s.id WHERE r.user_id #{userId} ORDER BY r.id DESC /selecttrade_type 在页面显示为“买入”或“卖出”金额格式保留两位小数。如果这里出现股票名称为空先检查 stock 是否被后台删除最好在删除股票时做逻辑删除而不是物理删除否则历史订单的 join 结果会把整条记录丢给 java 的空指针。5. Navicat 导入、Tomcat 时区与线上验证技巧5.1 一条 SQL 验证资金和交易流水是否一致交易功能做完后不要只靠页面肉眼对比余额。用这条 SQL 直接把每个用户的账面余额和交易流水推导出的资金差拉出来对比。-- 对比账面余额与流水推导余额检查事务一致性 SELECT u.id, (SELECT SUM(CASE WHEN trade_type 1 THEN -amount ELSE amount END) FROM trade_record t WHERE t.user_id u.id) AS calc_balance, a.balance FROM user u JOIN account a ON a.user_id u.id;calc_balance 为负数说明流水累计买入金额大于卖出金额最终账面余额应该等于初始资金加上这一个差额。如果不一致重点检查买入/卖出金额是否重复记录以及事务回滚是否把流水回滚了但余额没回滚。这条 SQL 也可以作为 mysql 面试题里关联子查询的延伸养成输出数据而不是打印日志的习惯排错效率会高很多。5.2 部署前改掉的三处配置用 Navicat 11 导入脚本前先确认连接字符集是 utf8mb4。如果只查“股票”两个字没问题但评论里插入 emoji 变成问号大概率是库表用了 utf8而 utf8 在 MySQL 中最大只有 3 字节存不下 emoji。Tomcat 部署时把 jdbc.url 的 serverTimezone 值改成 Asia/Shanghai避免卖出时间比本地时间早 8 小时pom.xml 里最好把 finalName 改成 stock.war这样访问路径固定为 /stock。配置项推荐值原因jdbc.url 字符集characterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai中文乱码与 JDBC 8 时区Navicat 连接编码utf8mb4评论中 emoji 不落库Spring CharacterEncodingFilterUTF-8POST 表单中文参数不乱码5.3 日志里看到回滚再下结论把 Spring 事务的日志级别放出来排查余额问题时很快能定位到事务边界。# 打开事务日志观察回滚行为 log4j.logger.org.springframework.transactionDEBUG日志中出现 Transaction rolled back 时说明注解、异常机制是生效的问题在业务代码里如果日志什么都没输出但业务莫名其妙失败先查 Transactional 是否真的代理了 Service。把这块配置加好之后再遇到“余额扣了但订单没生成”的情况对着日志看就清楚是哪个环节抛出的异常不用一行行打断点。本文还有配套的精品资源点击获取