
1. 项目全貌与管理系统定位股票交易管理系统光听名字可能觉得距离普通人有点远但它本质上就是一套“股票账户的进销存”——用户注册登录、查询股票行情、下单买入卖出、管理自己的持仓和资金流水。它和电商系统的差异在于多了两个核心概念用户资金账户和持仓记录交易动作发生时要同时更新这两块数据这也是整个系统里最容易出bug、最需要盯紧的点。我这次做的是一套基于SpringBoot SSM架构的股票交易管理系统代号ssm848工程把传统SSM项目里那些繁琐的XML配置全部交给SpringBoot自动装配来消化同时保留了MyBatis手写SQL的灵活性。整套系统下来我的感受是它不是一个只能跑通Demo的教学项目而是一个可以直接拿去答辩、甚至可以作为内部交易系统雏形的完整工程。这个项目适合谁参考第一类是正在做JavaWeb课程设计或者毕业设计的同学技术栈覆盖面很全从框架整合到数据库设计再到并发处理都有涉及第二类是工作上需要快速搭建一套带资金流转的业务系统的开发者股票交易只是个业务场景里面的账户设计、交易流水、持仓结算这些思路完全可以迁移到积分系统、钱包系统、预约订单系统上。2. 技术选型为什么是SpringBoot SSM这套组合2.1 先拆开看看SSM到底是什么SSM是Spring SpringMVC MyBatis三个框架的缩写。Spring管对象IoC和事务AOPSpringMVC管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.0InnoDB引擎字符集统一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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 iddeductBalance UPDATE user SET balance balance - #{amount} WHERE id #{userId} AND balance #{amount} /update insert idinsertTradeRecord 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;charsetUTF-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 interceptorcom.github.pagehelper.PageInterceptor property namehelperDialect valuemysql/ property namereasonable valuetrue/ /plugin /pluginsservice层查询时直接PageHelper.startPage(pageNum, pageSize); ListStockVO list stockMapper.selectStockList(stockNameKeyword); PageInfoStockVO 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse 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里必须带serverTimezoneAsia/Shanghai否则高版本MySQL驱动会报时区错误。useSSLfalse是为了排除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 stockMapperMapper扫描路径不对启动类加MapperScan(com.stock.dao)或在每个Mapper接口上标注Mapper查询中文乱码数据库连接未配置characterEncodingURL添加?useUnicodetruecharacterEncodingutf8日志打印SQL但控制台看不到数据没有配置MyBatis日志实现在application.yml配置log-impl: StdOutImpl分页查询返回总数为0PageHelper依赖版本冲突确认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.activeprod切换生产环境配置也可以用--server.port9090直接覆盖端口参数不用重新打包。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吞掉。这个项目的代码量和数据量都不大但麻雀虽小五脏俱全。做完之后我把交易模块的代码重构成了一套更通用的“资产操作模板”接下来如果做积分商城、优惠券钱包这类涉及余额和流水的业务基本上可以直接复用这套思路——前置校验、资金操作、流水记录、状态回填四步走完一个交易闭环。