选型这东西很多教程上来就甩一张工具清单我看得脑壳疼。这次我换个路子直接跟你们聊聊我最近捣鼓的一个完整项目——基于 Spring Boot Vue 的投资组合管理系统。我把从数据库建表、后端接口再到前端页面联调整个过程的思路和踩坑记录都翻出来了。如果你是准备做毕业设计、课程设计或者单纯想学一下前后端分离项目怎么落地这篇文章应该能帮你少走不少弯路。我会把核心代码、配置和几个容易卡住的地方都摊开来讲保证不是那种只贴个目录的假大空教程。1. 先理清楚系统要干什么需求与模块拆解做项目最忌讳一上来就写代码我见过太多人连要做什么都没想清楚就开始 new SpringBoot 项目结果写到一半发现表结构不对、接口对不上返工返到怀疑人生。投资组合管理系统这名字听起来高大上拆开看核心就三件事管住用户、管住资产、管住交易。1.1 核心需求解析不是一个简单的记账本很多人把投资组合管理理解成记账这是最大的误区。记账只记录“花了多少钱”而组合管理要回答的问题是“我的钱现在分布在哪些资产上、整体赚了还是亏了、风险有多高”。所以我设计的功能清单如下用户注册与登录基于 JWT 的无状态认证用户只能操作自己的数据。投资组合管理用户可以创建多个组合比如“稳健理财”“激进成长”每个组合有独立的初始资金和现金余额。持仓管理组合下可以添加股票、基金、加密货币等资产记录买入价格、持仓数量系统按最新价格计算市值和浮动盈亏。交易模拟买入和卖出操作买入时从现金余额扣款卖出时回款每次交易生成流水记录。净值与收益分析根据交易记录和持仓市值计算出组合的历史净值曲线和累计收益率。风险指标展示包括最大回撤、波动率、夏普比率这些专业指标让系统看起来不那么“学生气”。1.2 技术栈选型为什么用 Spring Boot Vue 这对组合这套技术栈如今基本是前后端分离项目的“标准答案”了但它能成为标准答案是有原因的。后端用 Spring Boot看中的是它“约定大于配置”的理念。以前用 SSM 的时候光 XML 配置文件就能堆几百行Spring Boot 直接自动装配一个注解搞定大部分场景。再加上它有庞大的生态Spring Security、MyBatis Plus、Redis 这些都能无缝集成不用自己造轮子。前端用 Vue核心优势是组件化和响应式。投资仪表盘这种页面数据变化极其频繁Vue 的双向绑定让我不用频繁操作 DOM数据一更新视图自动跟着变。配合 Element Plus 组件库和 ECharts 图表库两三天就能搭出像模像样的后台界面。最终选定的具体版本组合如下技术组件版本选型理由JDK1.8稳定、生态兼容性最好避免高版本带来的各种坑Spring Boot2.7.142.x 系列中的成熟版本与 JDK 8 完美配合MyBatis Plus3.5.3通用 CRUD 不用手写 SQL单表操作效率极高MySQL8.0最主流的关系型数据库5.7 也很稳8.0 性能更强Vue3.4.x组合式 API 代码更简洁是当前主流版本Element Plus2.xVue 3 生态中最好用的 UI 组件库之一ECharts5.x开源图表库天花板投资图表需求全都能覆盖提示如果你对 Spring Boot 3 不熟悉第一次做项目建议不要选Spring Security 和部分第三方库在 3.x 上的配置方式有变化网上教程质量参差不齐容易劝退。1.3 数据库设计五张表搞定所有业务数据库设计是项目的根基表结构不合理后面全是泪。我设计了五张核心表这个数量不算多但覆盖了一个投资组合管理系统的所有核心链路。user 表存用户信息portfolio 表存投资组合因为一个用户可以创建多个组合所以是一对多关系。holding 表存持仓明细一个组合下有多只资产一个用户持有同一只资产只保留一条记录买入时累加数量、更新成本价。trade_record 表存每一笔交易流水这是后续计算净值曲线和收益率的依据。asset 表是资产字典表维护资产名称和代码。表名核心字段用途说明userid, username, password, nickname, created_at系统用户信息portfolioid, user_id, name, initial_capital, available_cash, created_at投资组合主表holdingid, portfolio_id, symbol, name, quantity, avg_cost, current_price, updated_at持仓明细表trade_recordid, portfolio_id, symbol, name, trade_type, price, quantity, amount, created_at交易流水表assetid, symbol, name, category可交易的资产字典这套设计基本遵循了第三范式能有效避免数据冗余和更新异常。注意一个细节initial_capital 和 available_cash 分开存因为初始资金是固定的而现金余额会随着买卖操作动态变化两者分开才能准确计算收益率。avg_cost 记录的是平均成本而不是买入价因为多次买入同一只股票价格可能不同后续计算盈亏时用平均成本才准确。2. 后端实现Spring Boot 工程搭建与核心接口环境准备是很多新手翻车的重灾区尤其是 JDK 版本和 Maven 依赖冲突这两个问题几乎每个人都会遇到。我当时的解决思路是固定一套组件的版本组合任何组件都不单独升版本问题一下就少了大半。2.1 项目初始化与依赖配置我用的是 IDEA 创建 Spring Initializr 项目选择 Web、MySQL Driver、MyBatis Plus 和 Lombok 这几个核心依赖。如果你对 IDEA 的 Spring Initializr 不熟也可以去 start.spring.io 网站生成效果一样。pom.xml 的核心依赖配置如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.14/version /parent dependencies !-- Web 支持 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis Plus 核心 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency !-- Lombok 简化实体类代码 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency !-- JWT 认证 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency /dependencies请注意MyBatis Plus 3.5.x 要求 JDK 8 以上JDK 8 完全兼容。如果你用的是 JDK 17建议 Maven 编译器插件显式指定 release 版本避免编译报错。2.2 application.yml 配置这些参数必须写对配置文件是连接项目的血脉我第一次写这个文件时在时区问题上卡了一下午。mysql-connector-java 8.x 对服务器时间有要求如果不指定时区连接 MySQL 时会直接抛异常。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/investment_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 jwt: secret: your-secret-key-please-change-it expiration: 604800000三个容易出错的点说一下serverTimezoneAsia/Shanghai不写这个MySQL 8.x 连接会报 CST 时区错误。map-underscore-to-camel-case: true让数据库的下划线字段自动映射到 Java 的驼峰属性比如 portfolio_id 映射成 portfolioId少写很多别名。逻辑删除配置加了这个之后调用 MyBatis Plus 的 deleteById 方法不会物理删除而是自动更新 deleted 字段。投资组合表里最好加一个 deleted 字段用户误删组合还有后悔药。2.3 JWT 认证体系用一个注解保护用户数据投资系统涉及到资金数据不能让用户 A 看到用户 B 的组合。用 JWT 做认证核心是解决“如何证明你是你”的问题。我实现了一个 JwtUtil 工具类负责从 token 中解析出用户 ID。前端在登录成功后拿到 token之后每次请求都在 header 中带 Authorization: Bearer 前缀的 token。后端用一个拦截器统一校验 token再把用户 ID 放进 ThreadLocal 中供后续业务代码获取。核心拦截器逻辑public class JwtInterceptor implements HandlerInterceptor { private final JwtUtil jwtUtil; public JwtInterceptor(JwtUtil jwtUtil) { this.jwtUtil jwtUtil; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录和注册接口 String uri request.getRequestURI(); if (uri.contains(/user/login) || uri.contains(/user/register)) { return true; } // 校验 token String header request.getHeader(Authorization); if (header null || !header.startsWith(Bearer )) { throw new BusinessException(401, 未登录或登录已过期); } String token header.substring(7); Long userId jwtUtil.parseToken(token); if (userId null) { throw new BusinessException(401, 登录凭证无效); } // 将当前登录用户 ID 存入 ThreadLocal UserContext.set(userId); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }拦截器注册完成后所有需要登录才能访问的接口在 Controller 中直接调用UserContext.get() portfolioId双重条件判断确保组合归属正确。项目完成后如果你还有余力可以再集成 Spring Security对用户权限做更精细的控制但对这个体量的项目来说拦截器已经完全够用了。2.4 组合与交易模块接口设计后端接口设计是我觉得最考验经验的环节接口设计得好不好直接影响前端开发的效率和后期扩展的灵活性。我最终沉淀出一套路径清晰、语义明确的 API 结构。接口路径方法功能说明/api/user/registerPOST用户注册密码 BCrypt 加密存储/api/user/loginPOST用户登录发放 JWT token/api/portfolioGET查询当前用户的全部组合列表/api/portfolioPOST创建新组合初始化初始资金和现金余额/api/portfolio/{id}GET查询组合详情含汇总资产、当日盈亏/api/portfolio/{id}/holdingGET查询组合下所有持仓明细/api/portfolio/{id}/tradePOST发起买/卖交易/api/portfolio/{id}/trade/recordsGET查询交易流水/api/portfolio/{id}/analysisGET查询组合的收益分析和风险指标/api/portfolio/{id}/navGET获取净值曲线数据这里重点说下交易接口的实现。买入和卖出不是只往 trade_record 表插一条记录就完事了还必须同步更新 holding 表和 portfolio 表的 available_cash这就是数据库事务的经典应用场景。Transactional(rollbackFor Exception.class) public void executeTrade(Long portfolioId, TradeRequest request) { // 1. 校验组合归属 Portfolio portfolio portfolioMapper.selectById(portfolioId); if (portfolio null || !portfolio.getUserId().equals(UserContext.get())) { throw new BusinessException(404, 投资组合不存在); } BigDecimal tradeAmount request.getPrice().multiply(request.getQuantity()); // 2. 校验现金余额 if (BUY.equals(request.getTradeType())) { if (portfolio.getAvailableCash().compareTo(tradeAmount) 0) { throw new BusinessException(400, 现金余额不足无法买入); } portfolio.setAvailableCash(portfolio.getAvailableCash().subtract(tradeAmount)); } else { // 先查持仓卖出数量不能超过持仓数量 Holding holding holdingMapper.selectByPortfolioAndSymbol(portfolioId, request.getSymbol()); if (holding null || holding.getQuantity().compareTo(request.getQuantity()) 0) { throw new BusinessException(400, 持仓数量不足无法卖出); } // 更新持仓信息 holding.setQuantity(holding.getQuantity().subtract(request.getQuantity())); if (holding.getQuantity().compareTo(BigDecimal.ZERO) 0) { holdingMapper.deleteById(holding.getId()); } else { holdingMapper.updateById(holding); } portfolio.setAvailableCash(portfolio.getAvailableCash().add(tradeAmount)); } // 3. 更新组合现金余额 portfolioMapper.updateById(portfolio); // 4. 记录交易流水 TradeRecord record new TradeRecord(); record.setPortfolioId(portfolioId); record.setSymbol(request.getSymbol()); record.setTradeType(request.getTradeType()); record.setPrice(request.getPrice()); record.setQuantity(request.getQuantity()); record.setAmount(tradeAmount); tradeRecordMapper.insert(record); }这个逻辑看起来简单但有几个细节很容易忽略金额计算必须用 BigDecimal 而不是 double否则会出现 0.1 0.2 0.30000000000000004 这种诡异结果买入时已持仓的资产要更新平均成本而不是简单覆盖卖出全部持仓时要物理删除或逻辑删除 holding 记录不能留下数量为 0 的脏数据。这些坑我都踩过写在这里给你们避雷。2.5 持仓平均成本的计算思路前面提到买入时已持仓的资产要更新平均成本这里补充一下实现细节。平均成本 原有持仓市值 本次买入金额/原持有数量 本次买入数量这个逻辑在《证券法》里叫移动加权平均法。public void updateHoldingWithBuy(Integer portfolioId, String symbol, String name, BigDecimal price, BigDecimal quantity) { Holding holding holdingMapper.selectByPortfolioAndSymbol(portfolioId, symbol); if (holding null) { // 首次买入直接新增持仓 holding new Holding(); holding.setPortfolioId(portfolioId); holding.setSymbol(symbol); holding.setName(name); holding.setQuantity(quantity); holding.setAvgCost(price); holding.setCurrentPrice(price); holdingMapper.insert(holding); } else { // 非首次买入重新计算平均成本 BigDecimal totalCost holding.getAvgCost().multiply(holding.getQuantity()) .add(price.multiply(quantity)); BigDecimal totalQuantity holding.getQuantity().add(quantity); BigDecimal newAvgCost totalCost.divide(totalQuantity, 4, RoundingMode.HALF_UP); holding.setAvgCost(newAvgCost); holding.setQuantity(totalQuantity); holdingMapper.updateById(holding); } }注意 divide 时要指定精度和舍入模式否则报 Non-terminating decimal expansion 异常。这个异常是 Java 开发中比较经典的坑源于 BigDecimal 的 divide 方法默认要求整除。3. 收益和风险指标计算让系统有“投资分析”的味道前端展示用的数据都来自后端接口其中收益和风险指标的计算是系统的灵魂。这也是投资组合管理跟普通记账软件拉开差距的核心所在。3.1 组合净值曲线的生成逻辑净值曲线的核心逻辑是用“期初本金”做基准在每个时间点算出“当前总资产 / 期初本金 × 初始净值通常设为 1”。计算公式当前总资产 现金余额 Σ(持仓数量 × 当前价格) 组合净值 当前总资产 / 初始资金 × 1要生成时间序列曲线我会从 trade_record 表拿出所有交易日回溯往前找最近的交易价格快照再算出每个交易日的资产估值。简化的实现逻辑public ListNavPoint generateNavCurve(Long portfolioId) { Portfolio portfolio portfolioMapper.selectById(portfolioId); ListNavPoint navPoints new ArrayList(); // 获取所有交易记录按时间升序排列 ListTradeRecord records tradeRecordMapper.selectByPortfolioIdOrderByTimeAsc(portfolioId); ListHolding holdings holdingMapper.selectByPortfolioId(portfolioId); // 当前现金余额 BigDecimal currentCash portfolio.getAvailableCash(); // 第一个时间点初始资产等于初始资金 navPoints.add(new NavPoint(portfolio.getCreatedAt(), BigDecimal.ONE)); // 遍历交易记录动态计算每个时间点的组合净值 for (TradeRecord record : records) { if (BUY.equals(record.getTradeType())) { currentCash currentCash.subtract(record.getAmount()); } else { currentCash currentCash.add(record.getAmount()); } BigDecimal marketValue BigDecimal.ZERO; for (Holding holding : holdings) { marketValue marketValue.add(holding.getQuantity().multiply(record.getPrice())); } BigDecimal totalAsset currentCash.add(marketValue); BigDecimal nav totalAsset.divide(portfolio.getInitialCapital(), 4, RoundingMode.HALF_UP); navPoints.add(new NavPoint(record.getCreatedAt(), nav)); } return navPoints; }真实场景中持仓的最新价格一般通过第三方行情接口获取比如天天基金、新浪财经的公开 API。为了演示方便我在系统里维护了一张 asset 表内置了几只常见标的的最新价格并用一个定时任务模拟价格波动。这样一来没有真实行情也能完成前后端联调不影响系统演示。3.2 最大回撤与夏普比率风险指标的计算公式最大回撤用来衡量“从高点买入后最大的亏损幅度”是很多投资策略风控的核心指标。我还是用净值曲线来计算遍历净值序列记录遍历过程中的最高点计算当前净值相对最高点的回撤幅度取最大值。public BigDecimal calculateMaxDrawdown(ListNavPoint navPoints) { BigDecimal maxDrawdown BigDecimal.ZERO; BigDecimal peak navPoints.get(0).getNav(); for (NavPoint point : navPoints) { if (point.getNav().compareTo(peak) 0) { peak point.getNav(); } else { BigDecimal drawdown point.getNav().subtract(peak) .divide(peak, 4, RoundingMode.HALF_UP); if (drawdown.abs().compareTo(maxDrawdown) 0) { maxDrawdown drawdown.abs(); } } } return maxDrawdown; }夏普比率衡量的是“每承担一单位风险能获得多少超额回报”公式是夏普比率 (组合年化收益率 - 无风险利率) / 组合年化波动率无风险利率通常取 10 年期国债收益率我这里简单用 2%。年化收益率 总收益率 × (365 / 持有天数)年化波动率 日收益率标准差 × √365。匹配不上没关系关键是把公式写对这是面试官常问的点。3.3 数据可视化接口设计前端要画净值曲线和资产分布饼图后端接口需要返回足够的数据结构。我设计了两个接口净值曲线接口返回类似 [{date: 2025-01-01, nav: 1.0000}, {date: 2025-01-02, nav: 1.0032}] 的数组。资产分布接口返回 [{name: 贵州茅台, value: 35000}, {name: 腾讯控股, value: 28000}] 的形式。前端拿到这种结构化的数据后几乎不需要二次处理直接绑定给 ECharts 的 series 就行联调效率能提升一倍。这也算是我做前后端分离项目的一点心得接口返回的数据结构一定要贴合前端组件的消费习惯不要封装一层又一层。4. 前端实现Vue 3 Element Plus 搭建投资仪表盘前端部分我用的是 Vue 3 的组合式 API 配合 Element Plus整体页面设计参考了券商 App 和一些开源后台管理模板的布局。如果你用的是 Vue 2 也没关系核心逻辑长得差不多只是 API 风格有差异。4.1 创建 Vue 工程和必要依赖创建 Vue 3 项目最方便的方式还是 Vite比 webpack 快不少。我在终端执行npm create vuelatest创建完成后安装 Element Plus、Axios、ECharts、Vue Router 和 Pinianpm install element-plus axios echarts vue-router4 pinia由于 Vue 3 不再支持 Vue 2 时代的选项式风格配置我们采用组合式 API 的方式写组件。main.js 里统一注册 Element Plus 和路由import { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue import router from ./router import { createPinia } from pinia const app createApp(App) app.use(ElementPlus) app.use(router) app.use(createPinia()) app.mount(#app)提示如果你在 npm install 时遇到 ERESOLVE 依赖冲突多半是某些包版本不兼容。最省事的方法是先把 node_modules 删掉再执行 npm install --legacy-peer-deps问题通常就能解决。4.2 Axios 封装与登录态管理axios 封装是我比较看重的一个模块。直接在每个组件里 import axios 然后调用代码会非常散乱而且公共逻辑没法统一处理。我把 axios 单独封装成了一个 request.js 工具模块统一管理 baseURL、token 注入和响应拦截import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: http://localhost:8080/api, timeout: 10000 }) // 请求拦截器自动携带 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理错误码 request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response error.response.status 401) { ElMessage.error(登录已过期请重新登录) window.location.href /login } else { ElMessage.error(error.message || 网络异常) } return Promise.reject(error) } ) export default request封装完成之后所有页面调用接口的代码都统一走这个模块。前端实现登录态管理之后以后要接 WebSocket 实时行情推送只需要在 request.js 里加一个全局 loading 处理就行不用每个组件都改一遍。4.3 仪表盘页面组合总览与图表绘制仪表盘是系统的门面也是最能体现“专业感”的页面。我的布局是顶部一行 4 个统计卡片展示总资产、今日盈亏、累计收益率、最大回撤下方左侧放净值曲线图折线图右侧放资产分布图饼图再往下是最近交易流水表格。ECharts 在 Vue 3 中的使用比 Vue 2 要简洁一些。我在组件挂载时初始化图表通过 watch 监听数据变化并触发 setOptionscript setup import { ref, onMounted, watch, nextTick } from vue import * as echarts from echarts import request from /utils/request const navData ref([]) const navChartRef ref(null) async function loadNavData() { const portfolioId route.params.id navData.value await request.get(/portfolio/${portfolioId}/nav) } function renderChart() { const chart echarts.init(navChartRef.value) chart.setOption({ title: { text: 组合净值走势 }, tooltip: { trigger: axis }, xAxis: { type: category, data: navData.value.map(item item.date) }, yAxis: { type: value, name: 净值 }, series: [{ data: navData.value.map(item item.nav), type: line, smooth: true, areaStyle: { opacity: 0.2 } }] }) } onMounted(async () { await loadNavData() await nextTick() renderChart() }) watch(navData, () { nextTick(() renderChart()) }, { deep: true }) /script这里有个坑我必须提醒直接用ref作为图表容器时组件挂载后 DOM 可能还没渲染完成此时echarts.init会报“Cannot read property getContext of undefined”。解决之道是await nextTick()确保 DOM 已渲染再初始化图表。如果涉及窗口大小变化还要在 window resize 事件里调用chart.resize()否则图表会变形。4.4 交易模拟页面表单校验与交互反馈交易页面是用户操作最频繁的地方我把买入和卖出分成了两个标签页共用同一个表单组件。核心字段就是资产代码、交易数量、交易价格前端在提交前先做一次基础校验后端再做一次业务校验双保险。el-form reftradeFormRef :modeltradeForm :rulestradeRules label-width100px el-form-item label资产代码 propsymbol el-select v-modeltradeForm.symbol placeholder请选择资产 filterable el-option v-forasset in assetList :keyasset.symbol :labelasset.name :valueasset.symbol / /el-select /el-form-item el-form-item label交易价格 propprice el-input-number v-modeltradeForm.price :min0.01 :precision2 :step0.1 / /el-form-item el-form-item label交易数量 propquantity el-input-number v-modeltradeForm.quantity :min1 :precision0 / /el-form-item el-form-item el-button typeprimary clicksubmitTrade确认提交/el-button /el-form-item /el-form提交成功后的用户体验也很重要。我在交易成功后立即刷新持仓列表和现金余额并用 ElMessage.success 给出明确提示。千万别学某些系统的做法交易成功还不清空表单用户连续交易时数字还留在输入框里特别容易误操作。5. 前后端联调与上线部署遇到问题如何排查前后端分离项目的调试难度比单体应用高不少因为一个请求要经过前端路由、axios、后端拦截器、Controller、Service 这么多层。以下是我在联调阶段遇到最多的几个问题整理出来给你们参考。5.1 跨域问题CORS 配置的三个办法前端跑在 5173 端口后端跑在 8080 端口端口不同就会出现跨域。解决跨域有三个思路按推荐优先级排列第一个是后端加 CORS 配置类一劳永逸适用于大多数场景Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }第二个是前端开启 Vite 的代理。在 vite.config.js 中配置server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }代理的好处是前端请求路径完全不用改而且规避了浏览器对跨域的限制。推荐开发环境用代理上线环境用 Nginx 反向代理后端 CORS 配置只在特殊情况下才开启。第三个是后端单个接口加 CrossOrigin 注解。这种方式适合临时调试但没有必要每个接口都加代码会很冗余。5.2 跨域配置后仍然报错排查思路加了 CORS 配置后仍然报跨域通常会伴随三条排查路径检查浏览器控制台的具体报错信息是 No Access-Control-Allow-Origin header 还是 Credentials 相关报错。后者通常是因为前端请求带了 credentials但 CORS 配置里没有配 allowCredentials。Spring Security 或拦截器是否拦截了 OPTIONS 预检请求。浏览器在发送真正的 POST 请求之前会先发一次 OPTIONS 请求探路。如果拦截器把 OPTIONS 请求也拦截了后端返回 401前端就会报跨域错误。解决方法是放行 OPTIONS 请求。检查是不是 Nginx 层的代理转发出了问题尤其是上线后跨域报错频繁优先排查 Nginx 的 proxy_pass 配置。5.3 数据库连接超时与连接池调优系统上线运行一段时间后偶尔会出现“Connection is not available, request timed out after 30000ms”的报错。这通常是因为 MySQL 默认的 wait_timeout 是 8 小时超过这个时间没有请求的连接会被服务端主动关闭而 HikariCP 连接池不知道这一情况仍然借出已经失效的连接。我当时的解决方法是双管齐下在 application.yml 中配置 HikariCP 的 max-lifetime 为 60000010 分钟比 MySQL 的 wait_timeout 短。在 MySQL 中执行 SET GLOBAL wait_timeout 28800 和 SET GLOBAL interactive_timeout 28800增加服务端连接存活时长。spring: datasource: hikari: # 等待连接池最大时长 connection-timeout: 30000 # 连接最大存活时间 max-lifetime: 600000 # 空闲连接存活时间 idle-timeout: 6000005.4 上线部署Maven 打包 Nginx 部署项目做完之后打包部署也是一门学问。后端部分在 IDEA 的 Maven 面板点击 clean 和 package等到 target 目录下生成 jar 包然后放到服务器上执行 java -jar 命令。记住一个关键点如果服务器内存不大务必加上 JVM 参数限制内存nohup java -Xms256m -Xmx512m -jar investment-system.jar --spring.profiles.activeprod app.log 21 这样即使只有 1G 内存的轻量服务器也能稳定运行。日志输出到 app.log 文件排查问题直接 tail -f app.log。前端部分先执行 npm run build将生成的 dist 目录上传到服务器在 Nginx 配置一个 server 块server { listen 80; server_name your-domain.com; root /var/www/investment-ui/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这个配置解决了 Vue Router 使用 history 模式时刷新页面 404 的问题核心是 try_files 把所有找不到的路径都回退到 index.html。API 请求反向代理到后端服务同时也自然解决了浏览器的跨域限制。6. 常见问题速查表与调试技巧最后把这个项目从开发到部署过程中遇到的高频问题整理成表格方便大家直接对照排查。这些问题我都亲自踩过有的花了很长时间才定位到根因看到就是赚到。问题现象根本原因解决方案MySQL 连接报 The server time zone value Öйú±ê׼ʱ¼ä数据库时区未指定url 中添加 serverTimezoneAsia/Shanghai前端请求 /api 接口报 404Nginx 代理路径未配置在 Nginx 中增加 location /api/ 的 proxy_passSQL 查询结果全是 null下划线字段无法映射驼峰属性开启 map-underscore-to-camel-case: trueBigDecimal 除法报 Non-terminating decimal expansion未指定精度和舍入模式调用 divide 时必须传 scale 和 RoundingMode打包时提示 Maven 依赖下载失败仓库地址网络问题更换阿里云镜像源或检查代理配置Vue 3 项目 npm install 报 ERESOLVE依赖版本兼容性问题使用 --legacy-peer-deps 参数重装ECharts 图表宽度为 0 或空白容器未渲染完成就初始化使用 nextTick 等待 DOM 渲染完成请求携带 token 导致跨域失败预检请求 OPTIONS 被拦截后端放行 OPTIONS 请求或前端用代理模式服务器部署后中文乱码项目编码不是 UTF-8Linux 环境变量 export LANGzh_CN.UTF-8调试时推荐大家一直开着两个工具IDEA 内置的 HTTP Client 和浏览器开发者工具的 Network 面板。后端接口是否正常直接用 HTTP Client 发请求就能验证前端页面的每一个请求是成功还是失败耗时多少返回了什么Network 面板一目了然。前后端沟通时截图 Network 面板的请求和响应比在群里发一百句描述都有用。做完这个项目之后我有一个很深的感受写代码本身不是最难的难的是在不知不觉间引入的那些小问题。所有让我凌晨一点还在排查的 bug回头看都是极其简单的原因——一个时区参数、一个漏掉的 nextTick、一个忘记放行的 OPTIONS 请求。所以遇到问题时别慌先复现、再分段排查、最后定位这个过程本身就是程序员成长最快的路径。
