Test Plan · v1

TempoSoul 首页「IP 定位 → 内容生成」测试方案

项目 TempoSoul 命律 | 目标页 /sky 探索星空首屏 | 生成时间 2026-09-14 17:0x
基线 SKY-BASELINE-CT1(构图模板 CT-1 已冻结) | 代码版本 thread/t3-i18n-accuracy · v8 本地就绪 | 状态 方案待审批,未执行任何写操作
◤ 一句话结论

现在的 IP 首页生成链路是「上半段通、下半段断」: /api/locate 能在 0.65s 内拿到当前 IP 的城市坐标, 但真实城市线稿接口 /api/geo 的唯一消费入口 loadCityToPayload 零调用点, 非台北坐标一律落到「伪随机天际线」分支。

所以此刻直接拿 IP 跑一次首页,产出的是与你的实际位置无关的抽象轮廓 —— 这正是本次测试要暴露的第 1 号问题,也是方案必须先修的地方。

01测试对象:把「访客 IP」变成「他头顶那片天空 + 脚下那条街」

测试的不是一个接口,而是一条四层链路。任一层断裂,用户看到的就不是「他的城市」。因此判定也必须分层做,不能只看「首屏有没有画面」。

L1 · IP → 坐标
/api/locate
✅ 已上线可用
CF 边缘解析 request.cf
▶
L2 · 坐标 → 城市线稿
/api/geo
⚠ 端点可用 / 拒稿率高
KV 命中 0.87s|未命中挂死
▶
L3 · 线稿 → 场景
SkyScene.loadGeoCity
❌ 未接线
只认台北,其余走伪随机
▶
L4 · 场景 → 首屏
CT-1 构图 + HUD
✅ 标准已冻结
待绑定真实数据后复测
层输入 → 输出实现位置当前实态
L1 客户端 IP → {lat,lon,city} functions/api/locate.ts (18 行)
读 request.cf,source: ip/fallback
已部署,生产实测 200 / 0.65s
L2 lat/lon → OSM 建筑/道路/水系线稿 functions/api/geo.ts (96 行)
主路径纯 KV 读;miss 回源 Overpass ×3 镜像
KV 命中极快;未命中路径 12–30s 挂死(今日实测)
L3 线稿 → Three.js 场景 SkyScene.ts:478 loadGeoCity()
只调 loadTilePayload(台北101) / buildLandmarkPayload
/api/geo 消费方 loadCityToPayload 零调用点
非台北 → buildAbstractSkylinePayload(伪随机)
L4 场景 → 首屏(构图 + HUD) SkyPage.tsx:74 三层定位管线
URL 参数 → IP → GPS
前端逻辑完整;线上 /sky 仍是 09-13 v2,无 IP 逻辑
为什么必须先讲 L3 断线,再谈测试
如果跳过这一层直接跑「IP 访问首页」,得到的结果无论对错都无法归因: 画面出得来(因为伪随机天际线永远画得出来),但画面内容与定位没有任何因果关系。 此时「测试通过」是假通过 —— 这正是本次测试设计要首先堵掉的漏洞。

02实测证据:今日对生产环境的 8 项只读探测

全部为 2026-09-14 17:0x 实时执行,非推断。作为方案的判断依据。

#探测项实测结果判定
E1本机真实出口 IP 归属111.34.196.29 · 中国移动 · 山东济南 (36.6683, 117.021)基准
E2Cloudflare 边缘视角ip=111.34.196.29 loc=CN colo=LAX warp=off · 流量绕行洛杉矶节点异常
E3生产 /api/locateHTTP 200 / 652ms / cache=DYNAMIC
→ Shanghai 31.22222, 121.45806 · source=ip
位置不符
E4/api/geo · 北京城市库坐标HTTP 200 / 1037ms / cache=hit
raw_count=1008 · bld=800 · water=25
通
E5/api/geo · 上海城市库坐标31.23,121.47 → HTTP 200 / 871ms / cache=hit · bld=800通
E6/api/geo · 上海IP 定位坐标31.22222,121.45806 → 超时 12.4s,无响应断
E7/api/geo · 未预热随机点-45.12,-170.57 → 超时 25.3s断
E8生产 /sky 首屏 DOMHTTP 200 / 941ms / len=4471
含 locate:否 | 含 定位:否
未部署
0.87s
KV 命中响应(上海)
12.4s+
同一城市用 IP 坐标 → 超时
0%
IP 坐标的 KV 命中率
21.4%
KV 预热失败率(74/346)
15s < 24s
客户端超时 < 服务端最坏回源
🔴 E5 vs E6 = 本次测试的核心铁证
同一座上海、同一个接口、相差 12 秒之内:用城市库坐标请求 → 0.87s 命中 800 栋建筑; 用 IP 定位给的坐标请求 → 12.4s 无响应。 这不是网络问题,是 KV key 的精确字符串匹配(toFixed(2) 网格)与 IP 定位库的坐标漂移不兼容。 上海明明已预热成功,IP 用户依然拿不到 —— 换句话说,现在就算把 v8 部署上线,IP 首页依然是空的。

02bT1 全量矩阵实测结果 已执行

探针脚本 scripts/probe-ip-geo.mjs 已落盘并实际跑完一轮(12 用例 · 串行 · 只读): CSV 见 docs/sky/ip-probe-2026-09-14.csv。以下为真实输出,非预期值。

类样本lat,lon HTTP耗时x-geo-cache 建筑数判定
A北京·城市库39.9,116.41200300mshit800✅ OK(raw 1008)
A上海·城市库31.23,121.47200273mshit800✅ OK(raw 1164)
A济南·城市库36.65,117.12200290mshit776✅ OK
B上海·IP 坐标31.22222,121.45806200371msfail-cooldown0◇ 降级 fallback
B济南·IP 坐标36.6683,117.0212001034msfail-cooldown0◇ 降级 fallback
C天津·曾预热失败39.13,117.2200288mshit360✅ 已自愈
C太原·曾预热失败37.87,112.55—12007ms——❌ 挂死 TIMEOUT
D阿勒泰·假设未预热47.85,88.142001032mshit563✅ 已预热(推翻假设)
E东京·海外35.68,139.77—12010ms——❌ 挂死 TIMEOUT
E纽约·海外40.71,-74.01—12012ms——❌ 挂死 TIMEOUT
F悉尼·南半球-33.87,151.212005794msmiss800✅ OK(真实回源成功)
F深海·极端-45.12,-170.57200444msfail-cooldown0◇ 降级 fallback
3/3
A 类 城市库坐标命中
0/2
B 类 IP 坐标命中
100%→0%
坐标漂移的命中率落差
273ms
KV 命中实测延迟(初测 871ms)
5.8s
回源成功案例(悉尼)

本轮比初测多出来的 5 条事实(均已修正进 §7 / §9)

#新发现影响
N1 KV 主路径实测 273–300ms(初测 871ms) 边缘缓存生效,命中比预想更快。「数据层本身是健康的」这一结论被强化 —— 问题集中在连接点而非数据源。
N2 IP 坐标不只 miss,还会被「拉黑」:B 类返回 fail-cooldown 而非 miss 因为 15 分钟前的初测触发过失败回源,geo.ts:94 写入了 TTL 600s 的 fail 标记。
→ 任何一次回源失败,会让该坐标在 10 分钟内对所有用户直接返回 fallback,且不再尝试回源。 这是一条此前未记录的生产级隐患:冷门城市一旦被首个访问者打失败,接下来 10 分钟所有人都是空城。
N3 回源不是「永不成功」,是概率性慢:悉尼 5.79s 成功回源 800 栋 修正 B2 的表述:不是「永远等不到」,而是回源耗时在 5.8s – 25s+ 之间剧烈波动, 而客户端只等 15s → 变成赌运气。悉尼这次赌赢了,太原/东京/纽约赌输了。
N4 预热缺口会自愈,但代价由用户承担:天津预热日志记为 fallback,现实测已命中 360 栋 说明后续某次真实用户回源成功并写回了 KV。自愈过程 = 第一个访问者等 12s+ 然后可能仍是空城(取决于当次回源成败)。
N5 KV 覆盖面大于 346 城清单:阿勒泰(新疆)实测命中 563 栋 原假设「346 城之外必然 miss」被推翻。实际 KV 覆盖比 warmup 日志记录更广, 意味着 F1 最近邻吸附的可用城市范围可能比 346 更大 —— 这对修复方案是利好。
🔴 核心对照结论(A 类 vs B 类)
同一座上海,相隔 15 分钟、同一接口:城市库坐标 273ms 命中 800 栋; IP 定位坐标 0 栋 + 被 fail-cooldown 拉黑。
B1 断链已在生产环境被完整复现并量化:A 类 3/3 vs B 类 0/2。

03四个阻塞项:不修这些,「用 IP 测一次」得不到有效结论

按修复必要性排序。B1/B2 是十行内的代码级修复,B3 是接线,B4 是流程。

B1 · P0 命中率 0%
KV key 精确匹配,但 IP 坐标与城市库坐标系统性不等

geo.ts:47 用 ${lat.toFixed(2)}:${lon.toFixed(2)} 做 key,是严格字符串相等。
但坐标有两个来源:cities.json(城市库)与 CF request.cf(IP 地理库),二者不是同一份数据:

上海坐标KV key
城市库31.23, 121.4731.23:121.47 ✅ hit
IP 定位31.22222, 121.4580631.22:121.46 ❌ miss

→ 差 0.01° 就换 key,预热 346 城对 IP 用户等于 0 城。

B2 · P0 预算倒挂
客户端 15s 就放弃,服务端最坏要 24s 才回源完

客户端 CITY_CONFIG.GEO_API_TIMEOUT_MS = 15000(config.ts:25)
服务端 miss 路径 PER_MIRROR_TIMEOUT = 8000 × 3 个 Overpass 镜像 = 最坏 24s(geo.ts:20)

→ 回源即使最终成功,结果也已经被客户端丢弃,用户大概率落到占位城。
T1 实测修正:回源并非「永不成功」—— 悉尼 5.79s 成功返回 800 栋,而太原 / 东京 / 纽约 >12s 挂死。 真实语义是「赌运气」:耗时在 5.8s – 25s 之间剧烈波动,客户端只等 15s。

B3 · P0 架构断线
/api/geo 的前端消费方从未被调用

cityAdapter.ts:100 loadCityToPayload() 是唯一调用 /api/geo 的函数 —— 全仓 grep 零调用点。

SkyScene.ts:478 loadGeoCity() 实际只做两件事:
① loadTilePayload() → 台北 101 专用,范围外返回 null
② buildLandmarkPayload() → 台北 14 地标,范围外落 buildAbstractSkylinePayload()
后者是按经纬度做哈希的"确定性伪随机天际线",与真实城市无关。

B4 · P0 未上线
线上 /sky 仍是 09-13 v2,不含 IP 定位逻辑

生产 /sky DOM 实测不含 locate / 定位 字样(E8)。 本地 v8(7f12004 + 561545e)在 thread/t3-i18n-accuracy 分支,未 push、未部署。

→ 想在生产环境用真实 IP 做端到端测试,必须先部署。 而部署前应先修完 B1–B3,否则上线即带病。

⚠ 附带发现 3 条(不影响主判定,但会被用户感知)

04三档测试方案:从「今天就能跑」到「上线真机」

三档递进,成本与真实性递增。T0/T1 不依赖任何改动,今天即可执行;T2 需要先闭环 B1–B4。

T0 · 本地桩测
不碰网络定位,只验渲染链路

手段:/sky?lat=&lon=&name= URL 参数直注坐标(SkyPage.tsx:88-92 已实现,优先级最高,跳过 L1/L2)。

目的:确认「给定坐标 → 首屏构图」这一段本身是好的,把 L3/L4 的问题与 L1/L2 的问题解耦。

前置:pnpm dev(无需 functions,因为不测 /api)。

产出:多坐标截图 × CT-1 对拍报告。

耗时 ≈ 15 min

T1 · 生产只读探针
只测数据层,零写入、零部署

手段:脚本批量打生产 /api/locate + /api/geo,覆盖多类坐标,记录 status / 耗时 / x-geo-cache / raw_count。

目的:量化测得 KV 命中率矩阵 与 回源挂死边界 —— 也就是本次实测 E1–E7 的规模化版本。

前置:无。生产已在跑,只读请求。

产出:CSV + 判定表(可对比 warmup-geo-result.csv)。

耗时 ≈ 10 min(未命中项每条约 12–25s)

T2 · 端到端真机
真实 IP → 真实首页 → 截图对拍

手段:先修 B1–B4 → 部署 v8 → 用真实网络访问 www.temposoul.com/sky → Playwright 双视口截图 → 与 CT-1 基线对拍。

目的:唯一能证明「用户看到的是他自己的城市」的档位。

前置:B1–B4 全部闭环 + 预热补跑 + 部署审批。

产出:验收报告 + 真实 IP 首屏截图 + HUD 定位徽标证据。

耗时 ≈ 2 h(含构建/部署/门禁)

建议执行顺序:T1(先拿数据)→ T0(并行验证渲染)→ 修 B1–B4 → T2(最终验收)
理由:T1 是只读的、成本最低、且能立刻产出决定性的量化证据(当前 KV 命中率矩阵)。它把「要不要动手修」这个问题从主观判断变成数据判断。 T0 可在 T1 跑的同时并行开一个本地 dev 窗口。T2 一定要放在修复之后,否则重复一次今天的失败。

05测试矩阵:6 类坐标,每类只回答一个问题

矩阵设计原则 —— 每条用例必须能证伪某一个具体假设,否则就是浪费一次 25 秒超时。

类样本(可直接复制) 要回答的问题预期 · 修复前通过阈值 · 修复后
A 北京 39.90,116.41
上海 31.23,121.47
济南 36.65,117.12
KV 主路径是否健康? hit · <1.5s · bld=800 P95 ≤ 1.5s,cache=hit 100%
B 同 A,但用 IP 定位坐标
上海 31.22222,121.45806
B1 是否已修好?
与 A 类同城对照
miss → 超时 12s+ 与 A 类耗时差 ≤ 2×,
不做回源
C 天津 39.13,117.20
太原 37.87,112.55
保定 38.87,115.46
预热缺口是否补上? fallback(已记录) 补跑预热后 ≥ HYBRID(20 bld)
D 随机三线县城(cities.json 外) 回源路径的降级表现 超时 25s → fallback ≤3s 内明确返回 fallback,
且 不阻塞首屏
E 东京 35.68,139.77
纽约 40.71,-74.01
跨境可用性
CF 免费版 CPU 限制最易在此暴露
未知 · 未预热 不 5xx;降级路径正常
F 南半球 -33.87,151.21
深海 -45.12,-170.57
极地 89.9,0
鲁棒性 / 无地标时是否崩 超时挂死 必返 200 + fallback,
零未捕获异常
B 类是整个矩阵的核心 —— 它是唯一能证明 B1 修复有效性的用例
其它类都在验证「系统能不能用」,只有 B 类在验证 「IP 定位这条路径能不能用」。 做法很简单:同一座城市跑两次,一次喂城市库坐标(A 类),一次喂 IP 定位坐标(B 类)。 修复前两者结果一个天一个地(0.87s vs 12.4s),修复后应当趋于一致。

06执行步骤:可复制的命令

T1 · 生产只读探针(建议先跑这个)

已落盘脚本:scripts/probe-ip-geo.mjs(T1 档探针,纯 node 内置 fetch,零新依赖、零写入)。

# 在项目根目录执行
cd "E:/KnowledgeOS/项目库/TempoSoul 命律 网站建设系统"

# 全量矩阵(A-F 六类,含未命中项会等待 12-25s)
node scripts/probe-ip-geo.mjs --base https://www.temposoul.com

# 只跑快路径(仅 A 类,秒级返回,用于冒烟)
node scripts/probe-ip-geo.mjs --base https://www.temposoul.com --fast

# 结果落盘 CSV,便于与 warmup-geo-result.csv 对照
node scripts/probe-ip-geo.mjs --base https://www.temposoul.com --csv docs/sky/ip-probe-2026-09-14.csv

产出:终端判定表 + 可选 CSV。关键看两个数:A 类命中率、B 类命中率。修复前应为 100% / 0%。

T0 · 本地桩测(不依赖 CF,可并行开)

# 终端 1:启动本地 dev(vite,无需 functions)
pnpm dev    # → http://localhost:5173

# 终端 2:直注坐标截图(URL 参数优先级最高,跳过 L1/L2)
node scripts/capture-baseline.mjs --base "http://localhost:5173/?lat=39.90&lon=116.41&name=beijing"
node scripts/capture-baseline.mjs --base "http://localhost:5173/?lat=31.23&lon=121.47&name=shanghai"

# 与 CT-1 冻结基线做像素对拍(阈值 3%)
npx tsx scripts/diff-baseline.mjs

注意:pnpm dev 是纯 vite,不执行 functions/(vite.config.ts 只代理 /api/v1/ai/*)。 所以本地 /api/locate、/api/geo 都会 404 —— 这正是 T0 只用 URL 参数、不用 IP 的原因。 若要在本地测两个接口,必须改用 npx wrangler pages dev dist,见下方 T2。

T2 · 端到端真机(B1–B4 闭环后再做)

# ① 本地全链路(含 functions,可先用 --ip 伪造来源 IP 验证 L1)
pnpm build
npx wrangler pages dev dist --port 8788 --ip 111.34.196.29

# ② 部署(需审批;--branch main 才会进 Production)
npx wrangler pages deploy dist --project-name temposoul --branch main

# ③ 门禁 G1-G6(性能/错误/城市切换压测)
node scripts/promote-gate.mjs --base https://www.temposoul.com --version v8 --sw 8

# ④ 首屏截图 + CT-1 对拍
node scripts/capture-baseline.mjs --base https://www.temposoul.com
npx tsx scripts/diff-baseline.mjs

验收额外项:截图须同时包含左上角定位徽标(显示 ip / gps / manual 来源), 并与 /api/locate 返回的城市一致 —— 这是「IP 真的驱动了首页」的唯一可视证据。

07判定标准:分层验收,禁止「首屏有画面」当通过

层判定项修复前实测通过阈值
L1 /api/locate 响应 200 / 652ms / source=ip
但城市 = Shanghai(与真实归属不符)
HTTP 200 且 source=ip;
city 与 CF 视角出口 IP 归属一致(不以本机 IP 归属对账)
L2 /api/geo 命中率与延迟 A 类 3/3 命中 / B 类 0/2
未命中 12s+ 挂死
(悉尼 5.8s 侥幸成功)
A/B 类命中率均 ≥ 95%;
未命中 P95 ≤ 3s 且必返 200
L3 场景是否消费真实线稿 消费方零调用点
非台北走伪随机天际线
swapCity() 收到的建筑数与
接口 raw_count 一致
L4 首屏构图 CT-1 已冻结,待绑定真实数据复测 截图 vs SKY-BASELINE-CT1.png
diffRatio ≤ 3%(promote-gate 判据)
E2E 端到端:IP 真的驱动了首页 不可能(线上无 IP 逻辑) HUD 定位徽标 = ip;
城市名 = CF 定位城市;
街景轮廓随城市切换而变
🚫 明确不算通过的情形(防止假通过)

08修复建议:按 ROI 排序(最小改动 → 最大收益)

序修复项改法收益 / 成本
F1 IP 坐标 → 城市库最近邻吸附
治 B1 正本
SkyPage 拿到 IP 坐标后,先在 cities.json(346城) 做最近邻,取城市库坐标再 setTimeLocation;
复用已有 nearestCityName() 思路,改为返回坐标
命中率 0% → ~95%(346 城覆盖内)
改动 ≈ 10 行 · 纯前端 · 零接口风险
F2 服务端 KV key 容差
治 B1 兜底
geo.ts:47 命中失败后,追加 3×3 网格探测(±0.01°);
KV 读 ≈1ms,9 次读仍在 CPU 限额内
覆盖 F1 未覆盖的客户端
改动 ≈ 15 行 · 需备份 + 审批
F3 接线 loadCityToPayload
治 B3
SkyScene.ts:478 loadGeoCity() 增加非台北分支:
await loadCityToPayload(lat, lon, tier==='low');
注意返回含 placeholder: THREE.Group 需挂载
真实城市线稿首次上屏
改动 ≈ 25 行 · 涉及场景集成,需回归 G1–G6
F4 超时预算对齐
治 B2
客户端 15s → 8s;服务端 PER_MIRROR_TIMEOUT 8s → 2.5s;
或改 waitUntil 异步回源 + 立即返 fallback(体验最优)
消除 12–30s 挂死
改动 ≈ 3 行
F5 预热补全 重跑 scripts/warmup-geo.mjs 覆盖 74 个失败城;
按需扩预热集
补掉缺口(且会自愈,见 N4)
零代码 · 纯运维
F6 负缓存不再「拉黑」坐标
本轮新发现
geo.ts:94 的 FAIL_TTL = 600 改为「异步回源不停」:
命中 fail 标记时仍发起回源(waitUntil),只把负缓存用于兜底返回而非阻断尝试
消除「首个访问者打坏 → 10 分钟全城空城」
改动 ≈ 10 行 · 中风险 · 建议与 F2 同批
F1 是整份方案里性价比最高的一步
十行前端改动,把 IP 用户的 KV 命中率从 0% 拉到约 95%, 且不动任何已上线接口 —— 完全避开「改生产网关」的红线。 F2 作为兜底可以后置;F3 才是真正让「IP 首页」有意义的那一刀,但它涉及场景集成,建议单独走一轮。

09风险、回滚与红线

风险 · 测试行为本身
  • fail-cooldown 污染 实测已复现:geo.ts:94 在回源全失败后会写 geo:fail:{ck},TTL 600s。同一坐标 10 分钟内重复探测会直接返回 fail-cooldown,导致测得的「超时」其实是缓存标记(本轮 B 类与 F 类深海均命中该状态)。
    → 对策:同一坐标在矩阵中只测一次;重测前间隔 >10 分钟。
    → 这不只是测试干扰,更是生产隐患:冷门城市一旦被第一个访问者打成 fail,接下来 10 分钟内所有用户都会直接拿到空城,且系统不再尝试回源。建议单列为 F6。
  • CPU 限额:CF Free 单请求 10ms CPU。/api/geo 回源的 simplifyOsm() 是 CPU 密集段 —— 这解释了历史 503。矩阵不要并发打未命中路径。
  • outbound 流量:未命中会真实拉取 Overpass(外部免费服务),批量测试请串行、限速,避免被 Overpass 限流。
红线 · 执行前必须满足
  • 改 functions/api/geo.ts 属「已上线接口」→ 须老板批示 + 先备份 geo.ts.bak_YYYYMMDD_HHMM。
  • 部署 v8 会覆盖线上 09-13 v2 → 记录回滚点 155745ce,保留 wrangler pages deploy 的历史版本可回退。
  • 本方案 T0 / T1 全程只读,零写入、零部署。T2 才涉及写操作,须单独审批。
  • 任何改动前先跑 impact.py 查关联面(fusion_gateway / kb_service 等),改后跑 run_health.py。

待老板拍板

项决策点我的建议
P5是否立即执行 T1 只读探针建议执行 —— 零风险、10 分钟、产出决定性证据
P6B1 修复走哪条路F1(前端吸附)为主,F2(服务端容差)作为后续兜底
P7是否批准 F3 接线 + 部署 v8建议排在 T1/T0 结论出来之后再定
P8测试用的目标城市建议用非济南城市(如北京/上海),否则无法与默认兜底值区分

10本次勘察已完成(只读,未改动任何代码)

一句话总结这次勘察
你以为缺的是「用 IP 测一次」,实际缺的是 「IP 坐标和城市库坐标之间的一根接线」。 数据全都在、接口全都能用、定位也已经跑通 —— 唯独三个连接点(坐标对齐、超时预算、前端消费)没接上。 修这三处,IP 首页才第一次真正成立;在那之前做端到端测试,测的是伪随机数生成器。