做这个项目最初是因为看比赛的时候总喜欢猜谁赢弹幕上也是一堆人问“这波谁能赢”“打野差距太大了”。后来想着与其口头争论不如干脆把“AI预测赛事结果”这件事做成一个正经的系统。于是就有了这套电竞赛事中心前端用Vue做交互展示后端用Spring Boot搭服务中间再塞进AI能力做数据解读和结果预测。整套做完回头来看其实不算复杂但涉及的知识点多而杂从数据表设计到接口联调从AI接口接入到前端图表渲染每个环节都有值得记录的东西。这篇文章就把整个项目从0到1的完整过程拆开聊一遍包括需求怎么整理、技术选型怎么做取舍、后端接口怎么写、AI功能怎么接、前端页面怎么渲染以及我在实际开发中踩过的坑。适合正在做毕业设计、想练全栈项目、或者公司内部想快速搭一套“业务AI”演示系统的同学参考。我会尽量说人话把每个关键步骤背后的理由讲清楚代码也会拆开解释。1. 项目定位与核心需求拆解1.1 电竞赛事中心到底要解决什么问题任何一个项目开始之前先想清楚“这个东西给谁用、解决什么痛点”千万别上来就写代码。电竞赛事中心的用户画像其实很清晰赛事运营人员需要快速录入和查看赛程观众想看比赛数据、比分走势和战队信息分析师则需要更客观的数据洞察比如选手近期状态、队伍历史交锋记录。而传统的赛事官网往往只是把赛程和比分罗列出来数据死板没有解读更谈不上“智能”。所以这个项目的核心目标就三条第一把赛事信息结构化赛程、战队、选手、比分都能方便地查询和管理第二把历史比赛数据变成有价值的参考比如队伍胜率、选手KDA走势第三引入AI能力根据已有的数据自动生成赛前分析、胜负预测和赛后总结降低数据分析的门槛。说白了就是做一个“能帮人做判断”的赛事平台而不只是“能看信息的网站”。1.2 需求分级MVP功能与进阶功能的取舍我习惯先把需求拆成“必须有”和“最好有”两档。MVP最小可行产品阶段只做最核心的闭环赛事列表、赛程详情、战队与选手信息、比分记录、AI预测。把这些串起来用户就能完成“查看今天的比赛 → 看两支队伍的状态 → 看AI给出的胜负预测 → 赛后查看比分结果”这条完整路径。进阶功能我列了很多但没全做比如赛程日历订阅、弹幕互动、视频回放、社交分享、后台权限管理等等。这些功能不是不重要而是它们会消耗大量时间在非核心逻辑上。比如视频回放要处理流媒体弹幕要搞WebSocket长连接权限管理要设计RBAC模型这些对于验证“业务AI”这个核心命题来说都是干扰项。需求拆解的时候最好画一张简单的表格把每个功能模块、优先级、复杂度、依赖关系列出来这样做技术方案的时候会清晰很多。例如“AI预测”这个功能依赖“历史比赛数据”和“队伍数据”两张表的数据完整度如果数据都是空的AI模型再强也预测不出东西来。2. 系统架构与关键技术选型2.1 为什么是Spring Boot Vue的前后端分离架构前后端分离现在基本是中小型Web项目的默认选择了。后端Spring Boot只负责提供RESTful API不关心前端页面长什么样前端Vue工程独立开发通过HTTP请求和后端交互。这样做的好处是分工明确我可以同时维护两套代码也可以让前端同事和后端同事并行开发互不阻塞。Spring Boot选它最大的理由是生态成熟、上手快。内嵌Tomcat不用额外部署容器spring-boot-starter-web一键引入Web能力MyBatis-Plus把单表CRUD几乎变成了零SQLRedis做缓存也就是几行配置的事。对于“业务AI”这种需要快速迭代的中小项目这套组合非常顺手。Vue这边我选的是Vue 3 Vite Pinia Vue Router的组合Vite启动速度快组合式API写起来比Options API更直观Pinia替代Vuex之后状态管理也变得很轻量。2.2 AI能力到底怎么“塞”进系统AI在这套系统里不是独立存在的服务而是作为后端的一个“增强模块”存在。我的做法是在Spring Boot服务里封装一个AIService专门负责和大模型接口对话。前端并不直接调用AI厂商的API而是先请求后端接口后端把结构化数据比如两队的历史战绩、近期状态拼成提示词发给大模型接口再把返回结果解析成前端需要的JSON格式。为什么要包一层后端转发两个原因。第一是安全API密钥放在后端前端拿不到避免密钥泄露第二是数据组装AI需要的数据散落在MySQL、Redis里后端可以先查好、汇总好再一次性发给AI这样既节省token也让AI的回答更聚焦不会因为缺少上下文而胡说。实际测试下来同一个问题你把参赛双方的历史数据整理好喂给AI和直接问AI“谁赢”预测质量完全不是一个量级的。2.3 数据库设计核心表结构与字段规划数据库我用的是MySQL 8。核心表一共设计了6张队伍表、选手表、赛事表、比赛数据表、用户表、预测记录表。队伍表保存队名、简称、LOGO地址、所属赛区选手表保存姓名、游戏ID、位置、所属队伍ID、KDA等基础数据赛事表保存赛事名称、开始时间、状态未开始/进行中/已结束、当前阶段小组赛/淘汰赛/决赛比赛数据表是关键一场比赛的两支队伍、比分、比赛时长、比赛日期都在这张表里AI预测的主要数据源就是它用户表就是常规的账号体系预测记录表用来保存每次AI预测的结果方便之后对比命中率。这里有一个容易被忽略的细节比分字段不要设计成一场比赛一行而应该设计成一局一行。比如BO3的比赛三小局的比分、经济差、推塔数、击杀数都应该有独立字段因为AI要做的是单局维度的数据分析而不是只看到一个笼统的总比分。我是用match_id game_index联合查询来组织数据的。3. 后端Spring Boot核心实现讲解3.1 项目初始化与依赖配置创建项目我用的是Spring InitializrIDEA自带或start.spring.io都可以Java版本选的17Spring Boot版本选的2.7.x。为什么不用3.x因为3.x从javax迁移到了jakarta很多老教程和代码片段都不兼容对于做项目的同学来说遇到“包找不到”这种问题会非常浪费时间。2.7.x成熟稳定教程多踩坑少。核心依赖就装了这几个spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-java、spring-boot-starter-data-redis、lombok、spring-boot-starter-validation。AI调用用的是Spring自带的RestTemplate不需要额外引包。yml配置文件里主要配了数据源、Redis连接、MyBatis-Plus的日志和驼峰映射再留一个自定义的ai.api-key和ai.api-url配置项。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/esports?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl ai: api-key: sk-xxxx api-url: https://api.xxx.com/v1/chat/completions model: deepseek-chat3.2 赛事数据接口设计与实现后端接口设计我全部遵循RESTful风格按资源来定义URL。核心接口有这几个GET /api/matches赛事列表支持分页和状态筛选、GET /api/matches/{id}赛事详情包含双方战队信息和选手名单、GET /api/teams战队列表、GET /api/teams/{id}/stats战队统计数据、POST /api/predictions发起AI预测请求、GET /api/predictions/history预测历史记录。用户登录认证用的JWT。用户登录后拿到token前端把token存到localStorage每次请求在拦截器里带上Authorization头。后端用拦截器校验token同时在拦截器里把用户ID解析出来塞进ThreadLocal后续的接口可以直接取当前登录用户。这个方案不复杂但足够支撑这种规模的系统。Controller层尽量薄只做参数接收和结果返回业务逻辑放在Service层数据库操作放在Mapper层。比如预测请求的处理流程是Controller接收matchId和userId → Service查询比赛数据 → 拼接提示词 → 调用AIService.getPrediction() → 保存预测记录 → 返回结果给前端。整个过程看起来像一条流水线每一层职责清晰出了问题也容易定位。3.3 AI模块的封装提示词设计与响应解析AI模块是整个项目的技术亮点也是我花时间最多的地方。核心思路是把数据库中的比赛数据转成文本让大模型基于这些“事实”做分析而不是让它凭空发挥。举个例子我需要预测A队和B队的比赛结果会先从数据库查出两队最近的5场比赛数据包括比分、对手强度、比赛时长、KDA情况然后拼成一段提示词你是一名专业的电竞数据分析师。请根据以下数据预测两支队伍的比赛结果。 A队近5场比赛对手C队(强队)比分2:1胜KDA 1.8对手D队(弱队)比分2:0胜KDA 3.1... B队近5场比赛对手E队(强队)比分0:2败KDA 0.9对手F队(中游)比分1:2败KDA 1.2... 请从战队近期状态、选手个人发挥、队伍风格克制等角度分析给出你的胜负预测并说明理由。 请以JSON格式输出{winner: A队, confidence: 0.78, reason: ...}注意最后这句话请以JSON格式输出。这个非常关键。大模型天然会生成自然语言如果你不限定格式返回的文本就很难被程序解析。限定成JSON之后后端拿到的字符串可以直接用ObjectMapper转成PredictResult对象前端拿到就是干净的JSON。AI接口调用我用RestTemplate.postForObject()超时时间设置的是30秒因为大模型接口响应一般都在5到15秒之间。但我实际测试中发现如果数据量比较大、提示词比较长响应时间可能到20秒所以还要做一层兜底先把AI预测结果缓存到Redis同一个matchId的预测请求在24小时内直接返回缓存结果不重复调用AI接口。这样既省了token费用也大幅提升了接口响应速度。4. 前端Vue页面与交互实现4.1 Vue项目搭建与路由设计前端我用的是Vue 3 Vite。创建项目的命令很简单npm create vuelatest这个命令会生成一个带TypeScript选项的Vue项目模板但我实际开发时把TypeScript关掉了因为这种规模的演示项目用JavaScript写起来更快类型定义本身也要花时间维护。生成完项目之后还需要安装几个依赖vue-router做路由、pinia做状态管理、axios做HTTP请求、echarts做图表。路由设计上页面级路由一共6个首页/、赛事列表/matches、赛事详情/matches/:id、数据分析/stats、登录页/login、个人中心/profile。赛事详情页是最复杂的页面它下面还有tab切换分别是赛程信息、战队对比、AI预测、赛后复盘这四个tab我在组件层面拆分成了四个子组件用动态组件的方式切换确保页面切换时不重新请求接口。路由模式我选的是createWebHistory也就是HTML5 History模式URL看起来更干净没有#号。但这种模式有个坑部署到Nginx之后刷新非首页路由会404需要在Nginx配置一个fallback到index.html。具体配置在后面常见问题里细说。4.2 核心组件与页面交互细节赛事列表页的核心是一个“赛事卡片”组件每场比赛是一张卡片展示对阵双方队名、队标、比赛状态未开始/进行中/已结束、比分、开赛时间。点击卡片进入详情页。这个页面技术上其实没有难点主要工作在交互体验上。我做了两个细节第一卡片上的战队LOGO用的是后端返回的URL地址图片加载失败时通过onerror事件换成本地默认图避免出现裂图第二比赛状态用不同的颜色标签区分未开始是灰色、进行中是橙色、已结束是绿色用户扫一眼就能知道现在有哪些比赛在打。赛事详情页是这个系统信息量最大的页面。上半部分是双方战队的核心信息对比战队名称、近期胜率、最近5场胜负走势、平均KDA。下半部分拆成两个功能区块AI赛前分析调用预测接口展示结果和历史交锋记录。AI预测结果展示的时候我专门画了一个环形图ECharts的pie图左边是A队胜率右边是B队胜率中间显示AI预测的胜者底部用灰色小字展示AI的推理理由这样用户既能快速看到结论也能理解AI为什么这么判断。Axios统一封装在request.js文件里使用拦截器做了两件事请求拦截器统一把token拼到Authorization头响应拦截器统一处理HTTP状态码401跳转登录页其他错误用Element Plus的Message组件弹出错误提示。import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 30000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response response.data, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } else { ElMessage.error(error.response?.data?.message || 请求失败) } return Promise.reject(error) } ) export default request4.3 数据可视化的实现思路数据可视化用ECharts是因为它生态成熟散点图、折线图、饼图、热力图都有现成的组件不需要自己造轮子。我的数据分析页放了三个图表战队胜率趋势折线图按时间维度展示一支队伍最近N场比赛的胜率变化、选手KDA雷达图展示五个位置的选手综合能力、历史交锋战绩柱状图展示两队过往交手胜负分布。ECharts的图表数据是通过Axios从后端拉取的后端返回的是已经聚合好的JSON数组。前端拿到数据之后用map/forEach进行格式转换适配ECharts的data格式。这里有一个实用技巧ECharts实例在数据更新时要调用setOption而不是重新init否则会有动画闪烁和性能问题。监听窗口大小变化时调用chart.resize()可以让图表自适应容器宽度。5. 关键代码详解与项目落地记录5.1 登录鉴权模块代码解析登录接口的逻辑并不复杂前端提交用户名密码 → 后端校验用户名密码 → 生成JWT返回前端 → 前端保存token并跳转首页。但有几个细节值得注意密码绝对不能明文存储我用的BCryptPasswordEncoder做哈希加密注册的时候加密存库登录的时候用matches()方法比对生成token的时候签名密钥要放在配置文件中不能硬编码在代码里token过期时间设置成2小时每次请求如果token快过期了后端返回一个特定状态码让前端重新登录。核心代码如下这就是一个标准的Spring Boot登录接口RestController RequestMapping(/api/auth) public class AuthController { Autowired private AuthService authService; PostMapping(/login) public ResultLoginResponse login(RequestBody Valid LoginRequest request) { return Result.success(authService.login(request)); } PostMapping(/register) public ResultVoid register(RequestBody Valid RegisterRequest request) { authService.register(request); return Result.success(); } }Service public class AuthServiceImpl implements AuthService { Autowired private UserMapper userMapper; Autowired private JwtUtil jwtUtil; Autowired private PasswordEncoder passwordEncoder; Override public LoginResponse login(LoginRequest request) { User user userMapper.selectOne(new LambdaQueryWrapperUser() .eq(User::getUsername, request.getUsername())); if (user null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new BusinessException(用户名或密码错误); } String token jwtUtil.generateToken(user.getId(), user.getUsername()); return new LoginResponse(token, user.getUsername()); } }MyBatis-Plus的LambdaQueryWrapper用起来比手写SQL舒服很多至少不会出现字符串拼SQL的语法错误而且代码可读性也更好。加上map-underscore-to-camel-case配置之后数据库的create_time字段自动映射到Java对象的createTime属性省了一大堆ResultMap配置。5.2 实时赛程与Redis缓存策略赛事列表页需要频繁查询比赛数据如果在比赛日大量用户同时刷新页面数据库压力会很大。我用Redis做了两级缓存策略比赛列表缓存key为match:list:{page}:{size}:{status}过期时间5分钟比赛详情缓存key为match:detail:{id}过期时间30分钟。缓存更新的时机是后台管理员录入比赛数据时主动调用删除缓存接口下一次查询就会回源数据库拿到最新数据再写入缓存。Redis缓存逻辑我用的是一个简单的切面实现也可以用Spring Cache的Cacheable注解但那个有一些坑比如key生成规则不透明、缓存穿透时不好控制所以我更倾向于自己写一个CacheService方法非常直观Service public class MatchService { Autowired private StringRedisTemplate redisTemplate; Autowired private MatchMapper matchMapper; public MatchDetailVO getMatchDetail(Long matchId) { String cacheKey match:detail: matchId; String cachedJson redisTemplate.opsForValue().get(cacheKey); if (cachedJson ! null) { return JSON.parseObject(cachedJson, MatchDetailVO.class); } MatchDetailVO detail queryFromDb(matchId); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(detail), 30, TimeUnit.MINUTES); return detail; } }这套方案在演示环境和中小并发场景下完全够用。如果规模再大可以考虑引入Caffeine做本地缓存Redis做分布式缓存两级缓存的链路更复杂但对于当前项目来说没必要。5.3 AI预测服务的完整调用链AI预测是整个项目里链路最长、最容易出问题的模块。我把它拆成五步第一步参数校验确认比赛ID存在且比赛状态是“未开始”第二步组装上下文查询两支队伍的近几场比赛数据、近期交锋历史、选手名单第三步构建提示词把所有数据填充到模板里第四步调用大模型接口并解析返回结果第五步保存预测记录并把结果缓存到Redis。Service public class PredictionServiceImpl implements PredictionService { Autowired private MatchMapper matchMapper; Autowired private AiService aiService; Autowired private PredictionRecordMapper predictionRecordMapper; Override public PredictionResult predict(Long matchId, Long userId) { Match match matchMapper.selectById(matchId); if (match null) { throw new BusinessException(比赛不存在); } if (!SCHEDULED.equals(match.getStatus())) { throw new BusinessException(比赛已开始或已结束无法预测); } // 1. 查询两支队伍的近期比赛数据 ListMatch homeMatches matchMapper.selectRecentMatches(match.getHomeTeamId(), 5); ListMatch awayMatches matchMapper.selectRecentMatches(match.getAwayTeamId(), 5); // 2. 构建提示词 String prompt PromptBuilder.buildMatchPredictionPrompt(match, homeMatches, awayMatches); // 3. 调用AI接口 String aiResponse aiService.chat(prompt); PredictionResult result JSON.parseObject(aiResponse, PredictionResult.class); // 4. 保存记录 PredictionRecord record new PredictionRecord(); record.setMatchId(matchId); record.setUserId(userId); record.setWinner(result.getWinner()); record.setConfidence(result.getConfidence()); record.setReason(result.getReason()); predictionRecordMapper.insert(record); return result; } }Step 2的PromptBuilder是关键我单独写了一个工具类来构建提示词。这个类做的事情很简单把Java对象的字段拼成指定格式的文本但拼装顺序、措辞风格都经过多次调优。比如我试过直接在Prompt里写“分析一下这场比赛”得到的结果非常笼统改成“你是电竞分析师请严格根据以下数据输出JSON”之后结构化程度立刻提升。这里有一个值得分享的经验AI的输出不要完全信任。大模型偶尔会返回不合法JSON或字段缺失后端解析时要加try-catch兜底返回一个默认预测结果比如“两队实力接近建议等待赛前首发名单”这样前端不会因为AI返回异常而白屏。6. 常见问题与排查技巧实录6.1 前后端联调时的跨域与接口约定问题前后端分离开发最烦的事情就是跨域。前端在localhost:5173后端在localhost:8080浏览器默认是不允许跨域的。解决办法我推荐在Spring Boot里配置CORS全局拦截器而不是在前端开代理。配置方式如下Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }联调时我还遇到一个很典型的问题前端说“我传了参数为什么后端拿不到”后端说“我明明返回了数据为什么前端没渲染”。这种问题十有八九是字段名不匹配。Java后端习惯用驼峰命名homeTeamId前端JavaScript也习惯用驼峰但数据库字段可能存的是下划线home_team_id一旦MyBatis-Plus的驼峰映射没配置好返回给前端的字段就会变成home_team_id前端用homeTeamId去取就取不到。所以联调前先把返回的JSON打印出来看一遍比盲目加代码有效率得多。6.2 AI接口超时与流式响应的取舍大模型接口的响应速度从几秒到十几秒不等这取决于模型的负载和输入长度。如果直接在HTTP请求里同步等待前端页面会出现长时间loading用户体验很差。我最终的方案是折中的保留同步接口但把超时时间设置为30秒同时在后端加了一层结果缓存明确是同一个matchId的查询直接返回历史预测。如果追求更好的体验可以考虑改成异步任务后端把预测请求提交到线程池前端先显示“AI分析中”等预测完成之后通过WebSocket或者轮询接口拉取结果。这个方案我没有在MVP阶段实现因为它的技术复杂度会让整个项目周期拉长不少。对于演示场景30秒的同步等待用户是可以接受的。6.3 Vue项目部署到Nginx的路由404问题使用createWebHistory模式之后直接访问域名下的/matches/123这样的路径Nginx会去服务器上找对应的真实文件找不到就返回404。解决办法是在Nginx配置里添加一个try_files规则location / { root /usr/share/nginx/html; index index.html index.htm; try_files $uri $uri/ /index.html; }这个配置的意思是请求的URI如果在磁盘上找不到对应文件就回退到根目录的index.html由前端路由接管。这样刷新页面就不会出现404了。另外如果前端请求的API路径是/api开头的Nginx还需要把这类请求反向代理到后端服务location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }这两个配置放在同一个server块里一个管页面文件、一个管API转发各干各的互不干扰。开发环境可以用Vite的proxy配置解决生产环境必须要用Nginx这层处理。6.4 数据库分页查询的业务陷阱赛事列表肯定要分页MySQL在数据量大之后用LIMIT offset, size做深度分页会有性能问题offset越大查询越慢。更优的做法是使用游标分页或者基于主键ID的分页但对于赛事系统这种数据量最多几千条普通的LIMIT分页完全够用不用过度设计。不过分页参数校验要注意page不能小于1size不能大于50这些限制在后端必须写死校验逻辑否则有人恶意传一个size10000的请求数据库会被一下拉出所有数据接口直接卡死。我用的是spring-boot-starter-validation给Controller的参数DTO加Min注解简单有效public class PageQuery { Min(value 1, message 页码不能小于1) private Integer page 1; Min(value 1, message 每页条数不能小于1) Max(value 50, message 每页条数不能大于50) private Integer size 10; }写在最后一点项目复盘体会整个项目做下来最深的体会是AI应用开发的核心难点不在“调一个接口”而在于把业务数据转化为AI可理解、可利用的信息。大模型的API大家都能调但能把比赛历史数据整理成有分析价值的上下文、能把大模型输出的JSON稳定解析进业务系统、能给AI的异常输出做好兜底这些才是真正体现工程能力的地方。另一个想分享的细节是不要一上来就追求大而全的功能列表。最开始我也列了十几个模块后来砍掉了视频回放、社交分享、后台权限这些非核心项只留下一条最核心的赛事查询 → 数据分析 → AI预测链路。做完之后发现这条链路已经足够完整、足够演示、也足够写一篇清晰的技术复盘文章了。等到有真实用户反馈之后再去迭代扩展往往比一开始就堆功能要高效得多。如果你也想做类似的项目我建议先从一个最小的闭环开始建两张表、写一个查询接口、画一个页面然后围绕“AI预测”这条主线慢慢加东西。项目不怕小怕的是东一榔头西一棒锤最后所有模块都做了一半却没有一个真正跑通的。把最小的闭环跑扎实了后面每一步都会顺畅很多。