用 Spring Boot 3.3.5 + Java 21 做了个旅行足迹大屏:6273 行代码、零外链的单文件前端、975 KB 离线底图,以及两个 AI 功能。
这篇不写教程,只写三件事 —— 我做了什么决定、付了什么代价、以及一次被"演示数据"掩盖的缺陷。
一句话背景
我做了个东西,解决的问题特别小:旅行花过的钱,记下来之后没人看。
于是把它做成了一张会亮的地图 —— 记一笔,那座城就在中国地图上亮起来;点亮得越多,飞线越密,3D 视角下还会有光柱。加上消费结构、年度足迹、花费排行、同行伙伴、照片墙,以及两个 AI 功能(一句话生成行程、拍照自动记账)。
技术栈是 Spring Boot 3.3.5 + Java 21 + JPA。规模不大,但该踩的坑一个没少:
| 项 | 数字 |
|---|---|
| Java | 59 个文件 / 6273 行 |
| 前端 | 6 个单文件 HTML,共约 195 KB |
| 单元测试 | 12 个 |
| 离线底图 | 975 KB GeoJSON,369 个地级市 + 34 个省级 |
| 发布产物 | jar 67.9 MB;Docker 镜像双架构 237.8 MB |
| CI 构建耗时 | 42 秒(热缓存) |
开源地址:github.com/Makezx/travel-screen(MIT),演示站 travel.laofeiwuleyuan.cn。
下面按"做了什么决定 → 代价是什么 → 我学到了什么"来写,不讲教程。
一、三个反常识的选型决定
决定 1:前端不做构建
6 个页面全是单文件 HTML,样式、逻辑、模板全内联,零外链 —— 不引 CDN、不装前端框架、没有 node_modules。
为什么:这个项目的使用场景是"自己/朋友部署一个给自己用"。这种场景下,"打开就能跑"的价值远高于"工程化":
- 没有构建步骤 → 改一行刷新生效,不存在"依赖装不上"
- 零外链 → CDN 挂了、被墙了、公司内网出不去,页面照样完整
- 没有版本漂移 → 三年后打开还是它本来的样子
代价:没有组件复用。改一个公共样式要改 6 处;一个 findPhotos 的匹配逻辑在多处重复。
判断:如果项目会活很久、且只有你一个人维护,"反构建"是资产不是债。如果会有多人协作,这决定会立刻变成负债 —— 别抄。
决定 2:地图数据完全离线内嵌
底图(975 KB 的 GeoJSON)、城市坐标表、ECharts / ECharts-GL 全部内嵌在 jar 里。
为什么:不需要申请地图 Key、不依赖任何外部服务、数据不出内网。合规上也更好处理 —— 边界数据含台湾省与南海诸岛十段线,自己控制比依赖第三方渲染可靠。
代价:包直接大了 1 MB。而且 1 MB 不能塞进每一次大屏数据请求里。
修法:把底图拆成独立端点 GET /api/geo,加 Cache-Control: max-age=86400, public,前端再做 promise 记忆化。切一次活动从"重传约 1 MB"变成 0。
顺带一个隐私细节:/api/geo 故意不返回 place2adcode 映射表。那张表是从真实行程反推出来的(比如"千岛湖 → 杭州"),属于个人轨迹信息,不该跟着公开接口一起发出去。
决定 3:数据库双轨,然后在保留字上摔了一跤
本地用 H2 文件库(零运维,java -jar 就能跑),生产用 MySQL,靠 profile 切换。
H2 和 MySQL 的保留字清单不完全一样,但交集里就藏着不少日常词:
| Java 属性名 | 原列名 | 撞上谁 | 改成 |
|---|---|---|---|
year |
year |
H2 + MySQL | trip_year |
avg |
avg |
H2 | trip_avg |
name |
name |
H2 + MySQL | item_name |
role |
role |
MySQL 8 | mem_role |
判断:只改列名,Java 属性名保持 year / avg / name 不动,getYear() 之类调用全不用改。"数据库层的命名适配"和"代码层的语义命名"分开处理,是这类跨库项目最省事的做法。
二、两个 AI 功能:同一个接口,两种性格
这是本文我最想讲的部分,因为真正的坑不在"怎么调模型",而在"怎么和它的不确定性共处"。
功能 A:一句话 → 一份行程
POST /api/ai/plan,走智谱 GLM 的文本模型(glm-4-air),temperature = 0.85。
system prompt 里立了四条规矩:
- 用 Markdown 分级(Day 1 / Day 2…),每天给出城市/景点/交通/住宿/餐饮与预算区间
- 标注人均花费与总预算
- 给出最佳出行季节与避坑提示
- 信息不足时,给出 2~3 个合理假设然后继续规划
第 4 条是关键的。早期版本遇到"我有 5 天,随便去哪"这种输入,模型会反过来追问,体验直接断掉。加上"允许它自己假设"之后,它才变成一个能对话完的流程。
结果可以直接存成草稿行程(/api/ai/save-draft),人再改。
功能 B:一张门票照片 → 填好金额和日期
POST /api/ai/photo,走多模态模型(glm-4.6v),temperature = 0.2。
图片转成 base64 data URL 传过去,prompt 强制它只返回 JSON:
{"type":"landmark|ticket|other","city":"中文城市名","name":"地标名或消费项目名",
"amount":1280,"date":"2026.9.10","note":"一句话说明"}
用途很实在:在后台记账时,拍一张门票/小票/地标照片,城市、项目名、金额、日期自动填好,人只做确认。
温度是人格参数,不是精度参数
这是我觉得最值得抄走的一点:
| 行程规划 | 照片识别 | |
|---|---|---|
| 模型 | glm-4-air(文本) |
glm-4.6v(多模态) |
| temperature | 0.85 | 0.2 |
| 我要的是 | 有想法、敢给方案 | 稳定、可解析、别发挥 |
同一个 base-url、同一个 API Key、同一个 HTTP 客户端,只是温度不同,就变成了两种性格的工具。
很多人调 AI 功能时把温度当"精度旋钮"调,其实它更像"愿不愿意冒险"的旋钮:要创意往上拧,要格式往下拧。
三、真正花时间的:AI 功能的工程化,90% 不在调用模型
先看一组数字,这是这个项目里我最想让人看到的地方:
AiPlanService.java 211 行 ← 两个 AI 功能的全部业务逻辑
AiRateLimiter.java 125 行 ← 保护它不被刷的代码
AiRateLimitException 13 行
AI 代码里,将近 40% 是在处理"别人可能滥用我的付费 Key"。
以下三条,都是上线前必须解决、而不是"以后优化"的问题。
1. 模型的输出不可信,必须"宽容解析"
说好了只返回 JSON。它会:
- 先说一句"好的,我来帮你识别这张图片"
- 然后用
```json把 JSON 包起来 amount有时给数字1280,有时给字符串"1,280"- 真判断不出来时,干脆不回 JSON
所以解析逻辑写成三层兜底:剥掉 ``` 围栏 → 截取首尾大括号之间的内容 → 解析失败就把原文一起返回给前端,而不是抛异常。
// 宽容解析:剥 ``` 围栏 → 截取首尾大括号之间的 JSON
String s = raw.trim();
if (s.startsWith("```")) {
s = s.replaceFirst("^```(json)?\\s*", "").replaceFirst("```\\s*$", "").trim();
}
int st = s.indexOf('{'), en = s.lastIndexOf('}');
if (st < 0 || en <= st) return Map.of("ok", false, "error", "AI 未返回有效 JSON", "raw", raw);
结论:不要相信模型会遵守格式,要能容忍它不遵守。 这句话省了我后面一大堆"偶发失败"的排查。
2. "AI 失败"是常态,不该当成系统异常
两个 AI 端点全部 catch Exception,统一返回 {ok:false, error:"..."} + HTTP 200,前端只看 ok 字段。
为什么不返 500?因为超时、被限流、模型抽风、返回格式不对 —— 这些都是业务层面的失败,不是系统故障。返 500 会:
- 污染接口监控,把"模型今天心情不好"记成"服务不可用"
- 触发一堆无意义的告警
- 前端还得分两套错误处理(HTTP 错误 + 业务错误)
未配 Key 时的降级也一并做了:直接返回"功能未启用 / 尚未配置 AI_API_KEY",页面不崩、按钮不失效,只是提示你还差一步。
3. 付费 Key + 公开 demo 账号 = 防刷是上线前必做项
这是最容易被忽略的一条。
我的演示环境里,demo 账号是公开的(写在 README 里,任何人可以登录体验)。而它后面挂的是真实的付费 API Key。这意味着:只要有人写三行脚本,就能拿我的 Key 刷。
所以有了 AiRateLimiter 的三层防护,顺序很讲究:
第一层:并发闸门(保命,不是保钱)
this.slots = new Semaphore(permits, true); // 公平模式:避免后来的请求饿死先到的
用的是同步 HttpClient,一次 AI 调用要几十秒。如果不设上限,20 个人同时点一下,20 个 Tomcat 线程就全被占住 —— 每个线程几十秒,很快整个网站对所有人不可用。
所以这里是 Semaphore(2) + 排队 3000ms 超时快速失败(返回"AI 服务繁忙,请稍后重试"),而不是让请求无限堆积。
顺序判断:服务先死,账单其次。 先让网站活着,再谈省钱。
第二层:分钟窗 —— 拦脚本连点
单用户 5 次/分钟。
第三层:天窗 —— 拦慢速刷
单用户/单 IP 100 次/天。
一个容易漏的细节:计数必须按 IP 和按用户双维度同时计。因为 demo 账号是公开的,攻击者只要换个浏览器重新登录就换了个用户身份 —— 只按用户计数等于没设。
诚实说明局限:这是进程内内存实现(ConcurrentHashMap + 每个 key 一个 ArrayDeque,同时服务分钟窗和天窗)。多实例部署时每个实例各自计数,实际总配额 = 实例数 × 配额。要严格全局配额得换 Redis,但接口不用改 —— 这是我当初把它单独抽成一个 Service 的原因。
另外加了个内存保护:key 数量硬上限 20000,超过就整体清空,防止异常流量把内存打爆。
四、一次被"演示数据"掩盖的缺陷
这部分跟 AI 无关,但它是这个项目里我最想记录的一件事。
产品的核心承诺是:"记一笔 → 那座城亮起来"。
而底图 geo.json 里,只有 69 个城市的多边形。
问题是:这个 bug 被演示数据完美掩盖了。 我自己的 45 条示例行程,恰好把这 69 个城市全覆盖 —— 于是校验"有没有城市找不到坐标"的那个字段恒为空。测试永远绿,用户永远不亮。
真实用户记广州、青岛、哈尔滨、拉萨、大连、长春、呼和浩特、黄山、张家界……得到的是:
| 现象 | 原因 |
|---|---|
| 城市不亮、无热力 | 底图里找不到对应 adcode,代码里 continue |
| 没有城市标签 | 无多边形即无锚点 |
| 3D 没有光柱 | 坐标表里查不到 |
| 没有任何报错 | 全部静默跳过,控制台干净 |
这是最麻烦的一种 bug:功能少了,但没有任何信号。
修完之后:
- 底图 69 → 369 个地级市 + 34 个省级 + 南海诸岛十段线,共 975 KB
- 抽稀用 Douglas-Peucker,
TOL=0.015,迭代实现(递归版本直接爆栈,因为单个省份的多边形点数太多) - 零回归硬指标:旧底图里的 93 个 adcode 必须全部保留、
place2adcode仍然 98 条不变 —— 先写校验,再改数据
顺带修了一个"城市名自然写法"的问题:用户输入"恩施"能不能命中"恩施土家族苗族自治州"。这里的坑很深:
- 必须用显式民族名数组,不能用正则。 我一开始写了
(?:[\u4e00-\u9fa5]{1,3}族)+$,结果它取最左匹配,把「海西蒙古族藏族」剥成了「海」。 - "族"字可有可无。 「海西蒙古族藏族自治州」带"族"能处理;但「博尔塔拉蒙古自治州」「伊犁哈萨克自治州」不带"族",只试
蒙古族剥不掉,标签会显示成「博尔塔拉蒙古」。
教训一句话:自测数据是你自己的习惯,不是用户的行为。 凡是"我的数据全过"的测试,都该追问一句:换个人来用会怎样? 后来我给这类校验加了硬指标(旧 adcode 必须全保留),就是不想再靠"我试了没问题"来判断。
五、从"能跑"到"能给别人用"
开源之后加了三件事,都不是业务功能,但决定了别人能不能真的用起来:
- Release 流水线:推
v*tag → 跑测试 → 打包 → 先起一次 jar 验活(轮询 curl 到 200)→ 发 Release 并附上 jar。加"验活"这一步是因为不能把跑不起来的包发出去。 - Docker 双架构镜像:
linux/amd64+linux/arm64。Apple Silicon 上原生跑,不用转译。 - 社区健康文件:CONTRIBUTING / CODE_OF_CONDUCT / SECURITY / Issue 模板 / PR 模板 → 健康度 42% → 100%。
其中一个坑值得单说:多架构构建时,构建阶段必须钉在原生平台。
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-21 AS build
不写这一行,arm64 那一路会在 QEMU 模拟下把整个 Maven 构建重跑一遍 —— 耗时从 1~2 分钟膨胀到十几分钟,而且是最容易随机失败、超时的环节。jar 是纯字节码、与架构无关,构建阶段固定跑在原生平台、再把产物 COPY 进运行阶段即可。
修完之后的实测:
| 场景 | 耗时 |
|---|---|
| 冷启动(缓存未命中) | 2 分 10 秒 |
| 热缓存 | 42 秒 |
六、三条可以直接抄走的判断
- 自托管的小工具,"零外链"往往比"工程化"划算。 没有构建步骤、没有 CDN、没有依赖装不上的那一堆破事 —— 前提是只有你一个人维护。
- AI 功能的工作量分配大约是 "调用模型 10%,处理它的不确定性 90%"。 温度决定人格、输出必须宽容解析、失败要当业务失败而不是系统异常。
- 只要挂了付费 Key,防刷就是上线前必做项,不是优化项。 而且第一层要防的是服务被自己的慢请求拖死,第三层才是防账单。顺序别搞反。
一条命令跑起来
不想在本机装 JDK,也不想等 Maven 编译:
docker run -d --name travel-screen -p 8388:8388 \
-v travel-screen-data:/app/data -v travel-screen-photos:/app/photos \
lookupstarts/travel-screen:latest
打开 http://localhost:8388/。演示账号 demo@travel.cn / Demo@2026(只会操作"示例·演示"团队,碰不到你的数据)。
仓库:github.com/Makezx/travel-screen · MIT
AI 部分要生效,需要设环境变量 AI_API_KEY(智谱开放平台)。
如果你也在给自己的小项目加 AI 功能,第三条经验大概能帮你省一晚上。