1. 项目全貌与管理系统定位
股票交易管理系统,光听名字可能觉得距离普通人有点远,但它本质上就是一套“股票账户的进销存”——用户注册登录、查询股票行情、下单买入卖出、管理自己的持仓和资金流水。它和电商系统的差异在于多了两个核心概念:用户资金账户和持仓记录,交易动作发生时要同时更新这两块数据,这也是整个系统里最容易出bug、最需要盯紧的点。
我这次做的是一套基于SpringBoot + SSM架构的股票交易管理系统(代号ssm848工程),把传统SSM项目里那些繁琐的XML配置全部交给SpringBoot自动装配来消化,同时保留了MyBatis手写SQL的灵活性。整套系统下来,我的感受是:它不是一个只能跑通Demo的教学项目,而是一个可以直接拿去答辩、甚至可以作为内部交易系统雏形的完整工程。
这个项目适合谁参考?第一类是正在做JavaWeb课程设计或者毕业设计的同学,技术栈覆盖面很全,从框架整合到数据库设计再到并发处理都有涉及;第二类是工作上需要快速搭建一套带资金流转的业务系统的开发者,股票交易只是个业务场景,里面的账户设计、交易流水、持仓结算这些思路完全可以迁移到积分系统、钱包系统、预约订单系统上。
2. 技术选型:为什么是SpringBoot + SSM这套组合
2.1 先拆开看看SSM到底是什么
SSM是Spring + SpringMVC + MyBatis三个框架的缩写。Spring管对象(IoC)和事务(AOP),SpringMVC管HTTP请求的路由和参数绑定,MyBatis管数据库访问。三者的分工就像一家餐厅:Spring是店长,负责统筹全局和人员管理;SpringMVC是前台服务员,负责接待客人(请求)并传菜;MyBatis是后厨,专门负责按菜单(SQL)出菜。
传统SSM项目的痛点在于“配置地狱”:你得手动写web.xml、spring-mvc.xml、spring-mybatis.xml,每个文件里塞满bean定义和扫描路径,稍有不慎运行时报一堆ClassNotFoundException。2016年以后SpringBoot出现,把这一切默认化和约定化,内嵌Tomcat,一个main方法就能启动整个项目。我用SpringBoot作为基座,内部再按SSM的思维组织代码,等于保留了SSM的分层清晰度,又摆脱了配置的繁琐。
2.2 SpringBoot在项目里具体管了什么
在这个股票交易管理系统里,SpringBoot帮我做掉了这几件原本要花大量时间的事:
- 依赖版本管理:引入一个
spring-boot-starter-parent,所有jar包版本自动对齐,不需要手动核对版本兼容性。 - MVC配置简化:SpringBoot自动注册DispatcherServlet,只需要在配置类上标注
@EnableWebMvc(或者直接不标,默认也够用)。 - 数据源集成:
spring-boot-starter-jdbc加上application.yml里的几行配置,就能完成数据源和MyBatis的整合。 - 内嵌容器:本地开发不再需要单独装Tomcat,跑的也是同一个Tomcat,和线上环境完全一致。
2.3 为什么不直接用Spring Boot + JPA或者MyBatis-Plus
我承认JPA写起来更省代码,MyBatis-Plus用LambdaQueryWrapper也很香,但在这类交易系统中,手写SQL的可控性才是最重要的事。股票买卖涉及到资金和持仓的更新,多条复杂SQL需要精确到事务边界、行锁范围,JPA的自动SQL生成在这种场景下反倒是个障碍。MyBatis的XML里每一条SQL都看得见摸得着,出现问题可以直接复制到数据库客户端跑一遍定位。
再说MyBatis-Plus,它虽然好用,但很多毕业设计和中小型项目用它是为了“省事”,对原生的<select>、<insert>不熟悉。而这个项目我故意坚持手写Mapper XML,就是希望把MyBatis的核心能力过一遍:动态SQL、结果映射、关联查询。等这些搞明白了,再去用Plus是降维打击,反过来先用了Plus再回头学原生XML,那才是真的折磨。
3. 整体架构与业务模块设计
3.1 经典三层架构的落地方式
系统采用标准的SSM分层结构,从controller到service再到dao,方向是单向依赖,禁止反向调用。controllers包负责接收请求和参数校验,service层承载业务逻辑和事务控制,dao层就是MyBatis的Mapper接口加XML文件。实体类放在entity包,DTO和VO根据实际需要建包区分,避免把前端需要的字段直接暴露成数据库表的镜像。
在实际代码组织上有几个细节值得参考:
- Controller只做两件事:收参数、调service、返回统一结果对象。我自己定义了一个
Result类,包含code、message、data三个字段,成功返回200,业务异常返回500,参数校验失败返回400,前端拿到code之后统一拦截处理,不逐接口写状态判断。 - Service接口定义在接口里,而不是直接写类。这样做的原因是后面做事务代理和单元测试mock时方便,也符合SSM项目的传统规范。
- Mapper接口与XML文件必须同名且同包,这是MyBatis扫描的硬性要求。我在开发中因为Mapper XML的namespace写错找了好几个小时bug,这种细节才是真正决定项目能否跑通的关键。
3.2 核心模块功能清单
股票交易管理系统拆成六个核心模块,每个模块对应一个业务域:
- 用户模块:注册、登录、个人资料维护、密码修改。
- 股票信息模块:股票的基本面信息展示,包括股票代码、名称、当前价格、涨跌幅。
- 自选股模块:用户关注列表的新增和删除。
- 交易模块:买入、卖出委托的下单和撤单。
- 持仓模块:用户持有的股票明细、持仓成本、盈亏计算。
- 资金模块:账户余额、资金流水和明细查询。
3.3 为什么没有加实时行情推送
现在股市行情都是毫秒级波动,但如果在这个项目里引入WebSocket推送实时行情,会偏离“管理”二字的初衷,也会把项目的复杂度推向另一个极端——你得处理连接管理、心跳保活、消息推送失败重试,这对于一个以SSM框架整合为核心的工程来说不是加分项,反而是负担。
所以我设计的是模拟行情模式:股票价格由后台定时任务每隔几秒随机更新一次,前端通过轮询接口拉取最新价格。用一个@Scheduled注解的定时方法跑一个线程池任务,每次随机生成一个价格变动量,更新到数据库。这样做的好处是:完全够支撑交易功能的教学演示,又不用引入额外的消息中间件,让初学者能专注在SSM本身。
4. 数据库设计与核心表结构
4.1 六张核心业务表的设计思路
数据库设计是整个系统最值得认真讲的部分,我用的MySQL 8.0,InnoDB引擎,字符集统一utf8mb4。表结构如下:
CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(100) NOT NULL COMMENT '密码,BCrypt加密', `real_name` varchar(20) DEFAULT NULL COMMENT '真实姓名', `id_card` varchar(18) DEFAULT NULL COMMENT '身份证号', `balance` decimal(16,2) NOT NULL DEFAULT '0.00' COMMENT '账户余额', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1正常 0冻结', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';股票表:
CREATE TABLE `stock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `stock_code` varchar(10) NOT NULL COMMENT '股票代码', `stock_name` varchar(30) NOT NULL COMMENT '股票名称', `current_price` decimal(10,2) NOT NULL COMMENT '当前价格', `open_price` decimal(10,2) NOT NULL COMMENT '今开', `pre_close` decimal(10,2) NOT NULL COMMENT '昨收', `change_percent` decimal(6,2) DEFAULT NULL COMMENT '涨跌幅', `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`stock_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='股票表';持仓表和交易流水表是重点。持仓表必须以user_id + stock_code做唯一索引,一条记录代表某个用户持有某只股票的数量和成本。交易流水表只做追加,不做更新,记录每次买卖的数量、价格、金额、手续费和方向,一旦产生就不能改,这是做资金审计的核心表。
4.2 资金和持仓的强一致性设计
交易系统最怕的就是“钱对不上”。在这套设计里,我在数据库层面做了两层约束:
第一层,余额字段使用decimal(16,2),严禁用double或float。double和float有精度丢失问题,连续多次交易后账面金额和实际金额会出现几分钱或者几毛钱的差异,这在金融系统里是不允许的。
第二层,买入时先扣减余额,增加持仓;卖出时先增加余额,减少持仓。这两个操作必须在同一个事务里,并且要配合行级锁防止并发问题。我用的是SELECT ... FOR UPDATE对用户的资金行加锁:
SELECT balance FROM user WHERE id = #{userId} FOR UPDATE这样能保证两个线程同时操作同一个用户资金账户时,只有一个能读到余额并执行扣减,另一个必须等锁释放才能继续。配合MySQL默认的REPEATABLE READ隔离级别,基本不会出现超卖或者资金负数。
4.3 一张完整的SQL手写示例
买入股票的Mapper XML核心SQL长这样:
<update id="deductBalance"> UPDATE user SET balance = balance - #{amount} WHERE id = #{userId} AND balance >= #{amount} </update> <insert id="insertTradeRecord"> INSERT INTO trade_record ( user_id, stock_code, stock_name, trade_type, trade_price, trade_quantity, trade_amount, trade_time ) VALUES ( #{userId}, #{stockCode}, #{stockName}, #{tradeType}, #{tradePrice}, #{tradeQuantity}, #{tradeAmount}, NOW() ) </insert>扣减余额时直接在SQL里做条件判断balance >= #{amount},如果受影响行数为0,说明余额不足,业务层直接抛异常回滚整个事务。这是比先查询再判断更安全高效的做法,也是我从实际项目中总结出来的一个经验。
5. 核心功能实现与关键代码解析
5.1 登录与用户认证设计
登录使用SpringBoot内置的拦截器机制,自定义一个LoginInterceptor实现HandlerInterceptor接口,在preHandle方法中检查session中是否有登录用户:
@Component 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("user"); if (user == null) { // 未登录,重定向到登录页或者返回JSON提示 response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } return true; } }然后在WebMvcConfigurer实现类里注册拦截器并排除登录接口和静态资源:
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/**") .excludePathPatterns( "/user/login", "/user/register", "/static/**", "/stock/list" ); }密码加密用的是Spring Security框架中的BCryptPasswordEncoder,但并没有引入完整的Spring Security(以免复杂度失控),而是单独引入spring-security-crypto这个依赖。BCrypt加盐哈希的安全性远高于MD5,而且每次加密生成的密文都不同,就算数据库泄露也不能直接反推明文密码。
5.2 买卖交易的核心事务逻辑
买入操作的完整业务代码如下,注意@Transactional的用法:
@Transactional(rollbackFor = Exception.class) public Result buyStock(BuyRequest request) { User user = userMapper.selectByIdForUpdate(request.getUserId()); if (user == null) { return Result.error("用户不存在"); } Stock stock = stockMapper.selectByCode(request.getStockCode()); if (stock == null) { return Result.error("股票代码不存在"); } BigDecimal totalAmount = stock.getCurrentPrice() .multiply(BigDecimal.valueOf(request.getQuantity())); if (user.getBalance().compareTo(totalAmount) < 0) { return Result.error("余额不足,可用余额:" + user.getBalance()); } // 扣余额 int cnt = userMapper.deductBalance(user.getId(), totalAmount); if (cnt == 0) { throw new RuntimeException("扣款失败,余额不足"); } // 增加持仓,有记录则累加,无记录则新增 Holding holding = holdingMapper.selectByUserAndCode(user.getId(), request.getStockCode()); if (holding == null) { holdingMapper.insertHolding(user.getId(), request.getStockCode(), request.getQuantity(), stock.getCurrentPrice()); } else { // 在业务层计算新的持仓均价 BigDecimal totalQty = holding.getQuantity().add(BigDecimal.valueOf(request.getQuantity())); BigDecimal totalCost = holding.getCostPrice().multiply(holding.getQuantity()) .add(stock.getCurrentPrice().multiply(BigDecimal.valueOf(request.getQuantity()))); BigDecimal newCost = totalCost.divide(totalQty, 4, RoundingMode.HALF_UP); holdingMapper.updateHolding(user.getId(), request.getStockCode(), request.getQuantity(), newCost); } // 插入交易流水 tradeRecordMapper.insertTradeRecord(...); return Result.success("买入成功"); }事务里最容易踩坑的地方有两点。第一,Spring事务默认只对RuntimeException回滚,如果业务方法抛出的是一个checked exception(比如Exception),事务不会自动回滚。所以我在这里必须加上rollbackFor = Exception.class,并且在手动抛出new RuntimeException时才能触发回滚。第二,@Transactional只对通过Spring代理调用的public方法生效,同一个类内部方法之间互相调用的时候,事务是不生效的,这也是一个经典的面试题坑点。
计算持仓均价Excel表格里叫“移动加权平均法”,即每次买入后重新计算持仓成本价。我在代码里用divide方法保留了4位小数再四舍五入,避免除不尽导致精度异常。
5.3 行情展示与分页查询优化
股票列表页用到了MyBatis的分页插件PageHelper。在pom.xml引入依赖后,在MyBatis配置中增加插件:
<plugins> <plugin interceptor="com.github.pagehelper.PageInterceptor"> <property name="helperDialect" value="mysql"/> <property name="reasonable" value="true"/> </plugin> </plugins>service层查询时直接:
PageHelper.startPage(pageNum, pageSize); List<StockVO> list = stockMapper.selectStockList(stockNameKeyword); PageInfo<StockVO> pageInfo = new PageInfo<>(list);PageHelper的原理是拦截即将执行的SQL,在SQL末尾动态拼接LIMIT语句,同时自动生成一条COUNT查询。一定要记住:PageHelper.startPage()方法后面必须紧跟第一条Mapper查询,中间不能插入其他任何查询,否则分页会截错SQL。另外,如果查出来的list后续还要做集合映射、嵌套查询,分页数据可能会丢失,这时候应该先查完再处理。
5.4 SpringBoot的application.yml关键配置
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/stock_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 hikari: maximum-pool-size: 20 minimum-idle: 5 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.stock.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里有几个值得留意的细节。URL里必须带serverTimezone=Asia/Shanghai,否则高版本MySQL驱动会报时区错误。useSSL=false是为了排除localhost连接时的SSL警告。map-underscore-to-camel-case开启后,数据库里的create_time字段可以自动映射到实体类的createTime属性,省掉大量<resultMap>的手工编写。
6. 安全设计与风险防护
6.1 XSS攻击与前端参数过滤
股票交易系统的用户输入集中在搜索框、自选股添加、下单数量这几个位置。如果不做过滤,攻击者可以在股票名称搜索框内嵌入<script>标签,把脚本注入到页面中,窃取用户Cookie或者伪造操作请求。
我在项目中实现了一个全局XSS过滤器,用Jsoup库对请求参数做HTML标签清理:
public class XssFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { XssHttpServletRequestWrapper xssRequest = new XssHttpServletRequestWrapper((HttpServletRequest) request); chain.doFilter(xssRequest, response); } }在包装类中重写getParameter等方法,对参数值调用Jsoup.clean清理掉危险标签。注意这一步只对提交到后端的文本数据做转义,不影响正常的中文、数字和字母。
6.2 SQL注入防护
MyBatis框架本身用#{}预编译参数,能从根源上防住绝大部分SQL注入。但我看过很多初学者的代码,在ORDER BY子句中用了${},这就是一个典型的注入点——${}是字符串拼接,#{}是占位符。比如说:
// 危险写法 ORDER BY ${sortField} ${sortOrder} // 安全写法:枚举白名单校验 if (!"asc".equalsIgnoreCase(sortOrder)) { sortOrder = "asc"; }我自己在项目中的做法是:排序字段用Java代码做一个白名单校验,只有白名单内的字段名才能进SQL,其他一律返回默认排序。这个习惯比任何框架防注入都管用。
6.3 资金安全与异常兜底
交易接口不允许重复提交,前端按钮点击后置灰,后端还要做一层防重点击——在Redis里设置一个短期的操作锁,比如以trade_lock:{userId}为key,设置有效期为1秒,如果第二次请求来得太快就直接返回“操作过于频繁”。虽然这个项目的数据量不大,但把这种设计思路先做进去,后续要接真实行情时心里有底。
另外,全局异常处理用@RestControllerAdvice先统一兜底住整个接口层,防止500错误直接暴露给前端。日志里要记录完整的异常堆栈,并且打印用户ID、接口参数以便排查,注意不要让身份证号、密码这类敏感数据进日志。
7. 部署上线与常见问题排查
7.1 本地启动的完整步骤
整个项目从克隆代码到能跑起来,我整理成了一次性的操作清单:
- 安装JDK 1.8以上版本并配置JAVA_HOME环境变量。
- 安装MySQL 8.0,创建数据库
stock_db,执行项目里的sql/init.sql脚本。 - 用IDEA打开项目,等待Maven自动下载依赖,注意设置Maven镜像为阿里云镜像,否则在国内网络环境下装依赖会非常慢。
- 修改
application.yml中的数据库用户名和密码。 - 运行
StockApplication.java中的main方法。 - 浏览器访问
http://localhost:8080,系统会自动跳转到登录页面。
如果启动报端口被占用,在命令行执行netstat -ano | findstr 8080查一下进程ID,然后去任务管理器杀掉对应进程,或者直接在配置里把端口改成8081。
7.2 高频踩坑问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 启动报Error creating bean with name 'stockMapper' | Mapper扫描路径不对 | 启动类加@MapperScan("com.stock.dao")或在每个Mapper接口上标注@Mapper |
| 查询中文乱码 | 数据库连接未配置characterEncoding | URL添加?useUnicode=true&characterEncoding=utf8 |
| 日志打印SQL但控制台看不到数据 | 没有配置MyBatis日志实现 | 在application.yml配置log-impl: StdOutImpl |
| 分页查询返回总数为0 | PageHelper依赖版本冲突 | 确认mybatis-spring-boot-starter与pagehelper-spring-boot-starter版本兼容 |
| 事务不生效 | @Transactional和业务代码在同一个类里自调用 | 把方法拆到不同的Class,通过代理调用或者在自身注入代理对象 |
| 前端请求跨域 | 前后端分离时的CORS未配置 | WebMvcConfigurer中重写addCorsMappings方法 |
7.3 部署为可执行jar包
SpringBoot项目打包部署比SSM传统war包简单太多。Maven执行package命令后,在target目录会生成一个可直接运行的jar文件:
mvn clean package -DskipTests java -jar target/stock-system-1.0.0.jar如果服务器内存紧张,可以加JVM参数:
java -Xms256m -Xmx512m -jar target/stock-system-1.0.0.jar项目里内置了SpringBoot的application.yml外部化配置能力,线上部署时可以用--spring.profiles.active=prod切换生产环境配置,也可以用--server.port=9090直接覆盖端口参数,不用重新打包。
8. 我自己踩过的几个关键坑
从搭建框架到最终完整跑通业务,这个项目我前前后后改了四五轮。最深的体会是:写业务代码本身不难,难的是数据一致性和框架细节的掌控。
第一个坑是PageHelper分页和MyBatis一级缓存的碰撞。我写自选股列表时,先调用PageHelper.startPage(1, 10),然后做了一次股票查询,紧接着在同一个方法里又做了一次持仓查询,结果分页参数被应用到了第二次查询上,页面数据乱了。排查大半天才反应过来,PageHelper的分页参数只对紧随其后的第一条SQL生效,中间不能穿插别的查询。
第二个坑是BigDecimal的除法精度。计算持仓均价时,两个整数相除可能产生无限循环小数,比如持仓成本价是7.5元,买450股,总金额3375元,这个价格本身没有问题,但如果你遇到某个持仓均价除以数量出现除不尽的情况,不指定scale和RoundingMode,代码直接抛出ArithmeticException。我的修复方案是统一在除法时指定保留4位小数并采用HALF_UP四舍五入。
第三个坑是事务回滚不彻底。我已经加了@Transactional(rollbackFor = Exception.class),但是交易接口内部调用了另一个类的保存方法,这个保存方法也标注了@Transactional(propagation = Propagation.REQUIRED),由于传播级别是REQUIRED,它和外部事务共用同一个事务,理论上没问题。但有个隐藏问题就是:如果内部方法自己捕获了异常并吞掉了,不往外抛,那外部事务是感知不到异常发生的,数据就会处于一个只扣了款但持仓没更新的“中间状态”。所以服务层里所有的异常处理位置,我先判断是不是业务性异常(比如余额不足),如果是就直接抛出统一错误,绝对不try-catch吞掉。
这个项目的代码量和数据量都不大,但麻雀虽小五脏俱全。做完之后我把交易模块的代码重构成了一套更通用的“资产操作模板”,接下来如果做积分商城、优惠券钱包这类涉及余额和流水的业务,基本上可以直接复用这套思路——前置校验、资金操作、流水记录、状态回填,四步走完一个交易闭环。