做赛事类系统时,最容易陷入两种极端:一是只做后台 CRUD,页面像后台表格;二是堆概念——微服务、网关、真实支付——演示时全线崩盘。
「发令台 GUNSTART」(仓库名 marathon_system)走的是第三条路:业务闭环优先,外部能力可插拔,新技术旁路接入。它覆盖公开报名与组委会管理两条门户,串起报名、模拟支付、通知、取消退款、成绩排名,再把赛事助手拆成独立 AI 服务。这篇文章记录技术选型、关键设计和几处刻意取舍——适合当作毕设复盘,也适合当作同类管理系统的起步模板。
访问入口大致是:
|
角色 |
地址 |
说明 |
|
跑者公开平台 |
`http://localhost:8081/` |
报名、我的报名、排名、赛事助手 |
|
组委会管理台 |
`http://localhost:8081/admin.html` |
赛事/选手/成绩/导出 |
|
接口文档 |
`http://localhost:8081/doc.html` |
Knife4j |
|
AI 旁路 |
`http://localhost:8082` |
Spring AI 赛事助手 |
一、我们要解决什么问题
马拉松报名不是「插一条 registration」那么简单。真实链路里至少有这些约束:
因此系统目标被收敛成一句话:
用单体后端托住完整业务叙事;支付/短信用可审计的 mock;AI 用旁路服务增强体验,但不绑架主工程版本。
二、技术栈与总体架构
版本与环境清单
|
组件 |
版本 / 约定 |
说明 |
|
主工程 |
Java 8 · Spring Boot 2.7.14 · MyBatis 2.3.1 |
端口 8081 |
|
数据库 |
MySQL 8 · 库名 `marathon_system` |
基线 `docs/数据库脚本/`,演进 `sql/` |
|
缓存 |
Redis(Lettuce + commons-pool2) |
本地 `localhost:6379` |
|
鉴权 |
Apache Shiro 1.11 + JJWT 0.11.5 |
管理端无状态 JWT |
|
接口文档 |
Knife4j 4.4(OpenAPI2 / Swagger2) |
`http://localhost:8081/doc.html` |
|
导出 |
Apache POI 5.2.5 |
报名/成绩 Excel |
|
静态前端 |
原生 HTML/JS,无构建 |
`static/index.html` + `admin.html` |
|
AI 服务 |
Java 21 · Spring Boot 3.4.5 · Spring AI 1.0.9 · `qwen-plus` |
端口 8082,OpenAI 兼容 |
主工程(赛事核心)
Java 8 + Spring Boot 2.7.14,MyBatis 注解 SQL + MySQL 8;Shiro + JJWT 做管理端鉴权;Redis(Lettuce)管 OTP 与赛事/排名缓存;Knife4j 出接口文档;POI 出 Excel;前端两套静态页同 jar 托管,免 CORS 心智负担。

图 1 主工程分层架构
AI 旁路(赛事助手)
Java 21 + Spring Boot 3.4 + Spring AI,通过 @Tool 调用主工程 /api/public/** 查赛事,结构化输出(赛事卡片 + 是否 grounded),前端直接渲染。
图 2 AI 旁路数据流
为什么拆开?
主工程锁在 Boot 2.7 / Java 8 时,硬升 Spring AI 成本极高。旁路隔离让「核心业务稳定」和「AI 能力尝鲜」可以并行——这是我认为最值得写进简历的一刀。
三、鉴权:管理端与跑者端故意不同
管理端:Shiro 过滤器链 + JWT
管理台走前后端分离:登录签发 JWT,请求头携带,Shiro 自定义 JwtFilter / JwtRealm 校验。过滤器链用 LinkedHashMap 保证顺序敏感(ShiroConfig 中真实配置的缩写版):
filterChainDefinitionMap.put("/api/login", "anon"); // 登录接口本身
filterChainDefinitionMap.put("/api/public/**", "anon"); // 选手端公开接口
filterChainDefinitionMap.put("/doc.html", "anon"); // Knife4j 文档
filterChainDefinitionMap.put("/webjars/**", "anon"); // Swagger 静态资源
filterChainDefinitionMap.put("/uploads/**", "anon"); // 封面 <img> 不带 token
filterChainDefinitionMap.put("/api/**", "jwt"); // 其余 API 必须 JWT
filterChainDefinitionMap.put("/**", "anon"); // 静态页面放行
好处是:过滤器链对「哪些匿名、哪些鉴权」一目了然;管理操作有明确身份边界;和 Knife4j、静态资源共存时规则好讲。
当前实现仍是**明文比对管理员密码**(演示友好)。生产必须上 BCrypt/Argon2,并把 JWT secret 挪出仓库——这点「不足」章节也会写到。
跑者端:手机号 OTP + Redis
跑者不适合强行注册「账号体系」。公开端用短信验证码登录(本地模式写 sms_log,可配置 expose-code 方便答辩),会话用 X-Runner-Token 轻量凭证,绑定手机号后访问「我的报名」。核心逻辑(PublicController 真实片段):
// 发码:Redis 存 5 分钟 TTL;local 通道落 sms_log
redisService.set("public:otp:" + phone, code, 5, TimeUnit.MINUTES);
smsService.send(phone, "otp_login", noticeContent, noticeTitle);
// 登录:校验后签发 runnerToken(UUID),Redis 映射选手 ID,7 天有效
redisService.set("public:runner:" + token, runner.getId(), 7, TimeUnit.DAYS);
这是体验优先的取舍:降低跑者门槛,同时用 Redis TTL 管验证码过期。若上线,OTP 必须关回显、加频控与风控。
四、业务闭环:从报名到排名
一条可演示的主路径:
组委会创建赛事(封面上传)
→ 跑者浏览报名中赛事
→ 提交报名(名额 / 防重校验)
→ 模拟收银台确认支付(写 payment_log)
→ 站内通知 / 确认函信息
→(可选)取消与退款窗口
→ 赛后录入成绩
→ 公开排名(Redis 缓存)
几个实现上的「小心机」:
1. 名额防重:先查后插 + 事后回写(当前实现)
报名核心校验在 RegistrationServiceImpl.submit():
if (registered >= quota)
throw new RuntimeException("赛事名额已满,报名失败");
if (registrationMapper.selectByEventAndRunner(eventId, runnerId) != null)
throw new RuntimeException("该选手已报名此赛事,请勿重复报名");
registrationMapper.insert(registration);
eventMapper.increaseRegisteredCount(eventId); // registered_count + 1
eventMapper.syncRegisteredCount(eventId); // 用 COUNT(status=1) 回写兜底
当前是「先查后插」:判断与自增不是原子操作,并发下存在名额漂移风险;syncRegisteredCount 的 COUNT 回写能在大部分场景兜底,但严格并发仍建议改造成条件更新(不足表第 4 行的落地版):
— 把「判断 + 自增」合成一条原子语句,受影响行数为 0 即名额已满
UPDATE marathon_event
SET registered_count = registered_count + 1
WHERE id = #{eventId}
AND registered_count < quota
AND status = 0;
2. 支付与短信:mock,但留审计表
配置里是 marathon.pay.gateway: mock、marathon.sms.provider: local。
关键不是「假装接了微信」,而是:
- 支付确认会改报名状态,并落 `payment_log`(含渠道、凭证、金额);
- 短信验证码与通知落 `sms_log`;
- 商户号、签名等配置位预留,真实 SDK 可替换。
答辩时这样表述通常比「已经对接微信支付」更经得起追问。
3. 封面上传:本地目录映射
marathon.upload.path 指向 uploads/,WebConfig 把 /uploads/** 映射出去;Shiro 对该路径匿名,否则 <img src> 带不上 token。类型与大小在上传接口校验,和 multipart 限制对齐。
现状核对:`uploads/` 目录与 `cover_image` 字段(`sql/add_cover_image.sql`)均已就绪,但示例素材与种子脚本尚未落地(见第七节待办)。
4. Redis:不只是「会用缓存」
赛事列表、公开排名读多写少,适合缓存;赛事变更时按模式失效(SCAN + 删除),避免只记得 set 忘了 del。OTP 同样吃 Redis TTL。这些点很适合面试追问「缓存一致性你怎么做的」。
5. 导出与统计
管理台需要「能交差」的运营能力:报名名单、成绩导出(POI),以及简单统计接口。对毕设来说,这比再堆一个微服务更有说服力。
五、前端:两套门户,一个静态资源目录
当前真正在用的是:
- `src/main/resources/static/index.html` + `public-app.js`:公开品牌站「发令台」
- `src/main/resources/static/admin.html` + `admin-app.js`:管理台
同 jar 托管的好处是部署简单、联调零跨域。代价是前端工程化弱(无打包流水线、无组件化测试),属于明确的下一阶段优化点。
docs/前端源文件/ 下的单文件旧稿是归档对照,不参与运行——版本管理的价值,往往在这种「两套前端」时才体现出来。
六、AI 助手:让模型「接地气」
大模型最怕一本正经地胡说。赛事助手的策略是:
@Tool 的定义(ai-service 中 EventQueryTools 真实片段):
@Tool(description = "查询正在报名中的马拉松赛事列表。当用户问「有什么比赛」「还能报名吗」时使用。")
public String listOpenEvents() {
return toJson(marathonEventClient.listOpenEvents()); // 走主工程 /api/public/**
}
@Tool(description = "按赛事 ID 查询单场赛事详情(报名费、剩余名额、地点、日期、规则等)。")
public String getEventById(@ToolParam(description = "赛事数字 ID") Long eventId) {
EventDetailView detail = marathonEventClient.getEventDetail(eventId);
return detail == null ? "{\\"error\\":\\"赛事不存在\\"}" : toJson(detail);
}
API Key 放在 ai-service/application-local.yml(gitignore),支持 DeepSeek / 通义等 OpenAI 兼容端点,换模型只改 base-url 与 model。
网硕互联帮助中心







评论前必须登录!
注册