简介:面向Java毕业设计与课程设计场景,这套股票交易管理系统基于SSM框架(Spring+SpringMVC+MyBatis)与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 课程设计选题时,第一反应是:这里是不是要去调券商行情接口?其实不用。这套基于 SSM(Spring、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 代码只需要关心对象和方法;页面渲染交给 JSP,Controller 只做参数接收、调用和转发。股票列表页、个人中心页和后台管理页共用同一套数据模型,不需要拆两套后台,这对只有一两个人维护的课程设计来说很重要。
2.2 核心数据表:用户、资金账户、股票和交易流水
按照功能描述,我把核心数据表梳理成下面这张关系表。实际你拿到源码后再对照字段,业务含义不会跑偏。
| 表名 | 业务含义 | 关键字段 |
|---|---|---|
| user | 前台用户 | id, id_card, password, name, phone, area, create_time |
| account | 资金账户 | id, user_id, balance, frozen_amount, update_time |
| stock_block | 股票板块 | id, block_name, description |
| stock | 股票标的 | id, stock_code, stock_name, block_id, current_price, update_time |
| trade_record | 交易流水 | id, user_id, stock_id, trade_type, order_price, shares, amount, trade_time |
| stock_favorite | 股票收藏 | id, user_id, stock_id, create_time |
| stock_comment | 在线交流/评论 | id, user_id, stock_id, content, create_time |
这里把买入和卖出都放进 trade_record,用 trade_type=1 表示买入,trade_type=2 表示卖出。我倾向于合并成一张表,而不是分成 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 location="classpath:jdbc.properties"/> <bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource"> <property name="driverClass" value="${jdbc.driver}"/> <property name="jdbcUrl" value="${jdbc.url}"/> <property name="user" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> <property name="maxPoolSize" value="20"/> <property name="minPoolSize" value="5"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.stock.entity"/> <property name="configLocation" value="classpath:mybatis-config.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.stock.dao"/> </bean>jdbc.properties 里有一项必须注意:URL 要写成jdbc:mysql://localhost:3306/stock?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/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); List<Stock> stockList = stockDao.searchStock(keyword, blockId); PageInfo<Stock> pageInfo = new PageInfo<>(stockList); model.addAttribute("pageInfo", pageInfo); return "stock/list"; }对应的 Mapper XML 是动态 SQL 的标准写法。
<!-- 股票关键字与板块联合查询 --> <select id="searchStock" resultType="com.stock.entity.Stock"> SELECT * FROM stock <where> <if test="keyword != null and keyword != ''"> AND (stock_name LIKE CONCAT('%', #{keyword}, '%') OR stock_code LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="blockId != 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 >= amount,BigDecimal 的 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 的事务注解只管应用层,底层表不支持事务时,回滚就是空话。建议建表时直接写ENGINE=InnoDB DEFAULT CHARSET=utf8mb4,另外注意 @Transactional 只对 Spring 代理的 Bean 生效,如果 Service 类没被 Spring 管理,注解就是摆设。
4.4 个人中心的交易流水查询
个人中心需要同时展示买入、卖出历史,并能在每条记录后面看到对应股票信息。用 trade_record 主表 left join stock 表实现,避免一次取出所有字段导致 JSP 页面重复查库。
<select id="selectPageByUserId" resultType="com.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 </select>trade_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 字符集 | characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai | 中文乱码与 JDBC 8 时区 |
| Navicat 连接编码 | utf8mb4 | 评论中 emoji 不落库 |
| Spring CharacterEncodingFilter | UTF-8 | POST 表单中文参数不乱码 |
5.3 日志里看到回滚再下结论
把 Spring 事务的日志级别放出来,排查余额问题时很快能定位到事务边界。
# 打开事务日志,观察回滚行为 log4j.logger.org.springframework.transaction=DEBUG日志中出现 Transaction rolled back 时,说明注解、异常机制是生效的,问题在业务代码里;如果日志什么都没输出,但业务莫名其妙失败,先查 @Transactional 是否真的代理了 Service。把这块配置加好之后,再遇到“余额扣了但订单没生成”的情况,对着日志看就清楚是哪个环节抛出的异常,不用一行行打断点。
本文还有配套的精品资源,点击获取