遥望星星
发布于 2026-09-11 / 26 阅读
0

记一笔,地图就亮:一个离线旅行大屏的实现与取舍

用 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 里立了四条规矩:

  1. 用 Markdown 分级(Day 1 / Day 2…),每天给出城市/景点/交通/住宿/餐饮与预算区间
  2. 标注人均花费与总预算
  3. 给出最佳出行季节与避坑提示
  4. 信息不足时,给出 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 必须全保留),就是不想再靠"我试了没问题"来判断。


五、从"能跑"到"能给别人用"

开源之后加了三件事,都不是业务功能,但决定了别人能不能真的用起来:

  1. Release 流水线:推 v* tag → 跑测试 → 打包 → 先起一次 jar 验活(轮询 curl 到 200)→ 发 Release 并附上 jar。加"验活"这一步是因为不能把跑不起来的包发出去。
  2. Docker 双架构镜像:linux/amd64 + linux/arm64。Apple Silicon 上原生跑,不用转译。
  3. 社区健康文件: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 秒

六、三条可以直接抄走的判断

  1. 自托管的小工具,"零外链"往往比"工程化"划算。 没有构建步骤、没有 CDN、没有依赖装不上的那一堆破事 —— 前提是只有你一个人维护。
  2. AI 功能的工作量分配大约是 "调用模型 10%,处理它的不确定性 90%"。 温度决定人格、输出必须宽容解析、失败要当业务失败而不是系统异常。
  3. 只要挂了付费 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 功能,第三条经验大概能帮你省一晚上。