现在的 IP 首页生成链路是「上半段通、下半段断」:
/api/locate 能在 0.65s 内拿到当前 IP 的城市坐标,
但真实城市线稿接口 /api/geo 的唯一消费入口 loadCityToPayload 零调用点,
非台北坐标一律落到「伪随机天际线」分支。
所以此刻直接拿 IP 跑一次首页,产出的是与你的实际位置无关的抽象轮廓 ——
这正是本次测试要暴露的第 1 号问题,也是方案必须先修的地方。
测试的不是一个接口,而是一条四层链路。任一层断裂,用户看到的就不是「他的城市」。因此判定也必须分层做,不能只看「首屏有没有画面」。
| 层 | 输入 → 输出 | 实现位置 | 当前实态 |
|---|---|---|---|
| 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 逻辑 |
全部为 2026-09-14 17:0x 实时执行,非推断。作为方案的判断依据。
| # | 探测项 | 实测结果 | 判定 |
|---|---|---|---|
| E1 | 本机真实出口 IP 归属 | 111.34.196.29 · 中国移动 · 山东济南 (36.6683, 117.021) | 基准 |
| E2 | Cloudflare 边缘视角 | ip=111.34.196.29 loc=CN colo=LAX warp=off · 流量绕行洛杉矶节点 | 异常 |
| E3 | 生产 /api/locate | HTTP 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 首屏 DOM | HTTP 200 / 941ms / len=4471 含 locate:否 | 含 定位:否 | 未部署 |
探针脚本 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.41 | 200 | 300ms | hit | 800 | ✅ OK(raw 1008) |
| A | 上海·城市库 | 31.23,121.47 | 200 | 273ms | hit | 800 | ✅ OK(raw 1164) |
| A | 济南·城市库 | 36.65,117.12 | 200 | 290ms | hit | 776 | ✅ OK |
| B | 上海·IP 坐标 | 31.22222,121.45806 | 200 | 371ms | fail-cooldown | 0 | ◇ 降级 fallback |
| B | 济南·IP 坐标 | 36.6683,117.021 | 200 | 1034ms | fail-cooldown | 0 | ◇ 降级 fallback |
| C | 天津·曾预热失败 | 39.13,117.2 | 200 | 288ms | hit | 360 | ✅ 已自愈 |
| C | 太原·曾预热失败 | 37.87,112.55 | — | 12007ms | — | — | ❌ 挂死 TIMEOUT |
| D | 阿勒泰·假设未预热 | 47.85,88.14 | 200 | 1032ms | hit | 563 | ✅ 已预热(推翻假设) |
| E | 东京·海外 | 35.68,139.77 | — | 12010ms | — | — | ❌ 挂死 TIMEOUT |
| E | 纽约·海外 | 40.71,-74.01 | — | 12012ms | — | — | ❌ 挂死 TIMEOUT |
| F | 悉尼·南半球 | -33.87,151.21 | 200 | 5794ms | miss | 800 | ✅ OK(真实回源成功) |
| F | 深海·极端 | -45.12,-170.57 | 200 | 444ms | fail-cooldown | 0 | ◇ 降级 fallback |
| # | 新发现 | 影响 |
|---|---|---|
| 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 更大 —— 这对修复方案是利好。 |
按修复必要性排序。B1/B2 是十行内的代码级修复,B3 是接线,B4 是流程。
geo.ts:47 用 ${lat.toFixed(2)}:${lon.toFixed(2)} 做 key,是严格字符串相等。
但坐标有两个来源:cities.json(城市库)与 CF request.cf(IP 地理库),二者不是同一份数据:
| 上海 | 坐标 | KV key |
|---|---|---|
| 城市库 | 31.23, 121.47 | 31.23:121.47 ✅ hit |
| IP 定位 | 31.22222, 121.45806 | 31.22:121.46 ❌ miss |
→ 差 0.01° 就换 key,预热 346 城对 IP 用户等于 0 城。
客户端 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。
/api/geo 的前端消费方从未被调用
cityAdapter.ts:100 loadCityToPayload() 是唯一调用 /api/geo 的函数 ——
全仓 grep 零调用点。
SkyScene.ts:478 loadGeoCity() 实际只做两件事:
① loadTilePayload() → 台北 101 专用,范围外返回 null
② buildLandmarkPayload() → 台北 14 地标,范围外落 buildAbstractSkylinePayload()
后者是按经纬度做哈希的"确定性伪随机天际线",与真实城市无关。
生产 /sky DOM 实测不含 locate / 定位 字样(E8)。
本地 v8(7f12004 + 561545e)在 thread/t3-i18n-accuracy 分支,未 push、未部署。
→ 想在生产环境用真实 IP 做端到端测试,必须先部署。 而部署前应先修完 B1–B3,否则上线即带病。
www.temposoul.com 的流量经过了代理/分流。测试结论必须以 CF 视角的坐标为准,不能拿本机 IP 归属去对账。colo=LAX,边缘节点在洛杉矶。跨太平洋往返会让所有 CF Function 延迟虚高,测 L1/L2 响应时间时要标注这一点。三档递进,成本与真实性递增。T0/T1 不依赖任何改动,今天即可执行;T2 需要先闭环 B1–B4。
手段:/sky?lat=&lon=&name= URL 参数直注坐标(SkyPage.tsx:88-92 已实现,优先级最高,跳过 L1/L2)。
目的:确认「给定坐标 → 首屏构图」这一段本身是好的,把 L3/L4 的问题与 L1/L2 的问题解耦。
前置:pnpm dev(无需 functions,因为不测 /api)。
产出:多坐标截图 × CT-1 对拍报告。
耗时 ≈ 15 min
手段:脚本批量打生产 /api/locate + /api/geo,覆盖多类坐标,记录 status / 耗时 / x-geo-cache / raw_count。
目的:量化测得 KV 命中率矩阵 与 回源挂死边界 —— 也就是本次实测 E1–E7 的规模化版本。
前置:无。生产已在跑,只读请求。
产出:CSV + 判定表(可对比 warmup-geo-result.csv)。
耗时 ≈ 10 min(未命中项每条约 12–25s)
手段:先修 B1–B4 → 部署 v8 → 用真实网络访问 www.temposoul.com/sky → Playwright 双视口截图 → 与 CT-1 基线对拍。
目的:唯一能证明「用户看到的是他自己的城市」的档位。
前置:B1–B4 全部闭环 + 预热补跑 + 部署审批。
产出:验收报告 + 真实 IP 首屏截图 + HUD 定位徽标证据。
耗时 ≈ 2 h(含构建/部署/门禁)
矩阵设计原则 —— 每条用例必须能证伪某一个具体假设,否则就是浪费一次 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, 零未捕获异常 |
已落盘脚本: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%。
# 终端 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。
# ① 本地全链路(含 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 真的驱动了首页」的唯一可视证据。
| 层 | 判定项 | 修复前实测 | 通过阈值 |
|---|---|---|---|
| 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.pngdiffRatio ≤ 3%(promote-gate 判据) |
| E2E | 端到端:IP 真的驱动了首页 | 不可能(线上无 IP 逻辑) | HUD 定位徽标 = ip;城市名 = CF 定位城市; 街景轮廓随城市切换而变 |
36.65,117.12(SkyPage.tsx:153),与定位成功无法区分。→ 测试必须用非济南坐标,或读 HUD 的 locSrc 徽标。/api/geo 「永远 200 + fallback:true」是设计纪律(geo.ts:4),200 不代表拿到数据。| 序 | 修复项 | 改法 | 收益 / 成本 |
|---|---|---|---|
| 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 同批 |
geo.ts:94 在回源全失败后会写 geo:fail:{ck},TTL 600s。同一坐标 10 分钟内重复探测会直接返回 fail-cooldown,导致测得的「超时」其实是缓存标记(本轮 B 类与 F 类深海均命中该状态)。/api/geo 回源的 simplifyOsm() 是 CPU 密集段 —— 这解释了历史 503。矩阵不要并发打未命中路径。functions/api/geo.ts 属「已上线接口」→ 须老板批示 + 先备份 geo.ts.bak_YYYYMMDD_HHMM。155745ce,保留 wrangler pages deploy 的历史版本可回退。impact.py 查关联面(fusion_gateway / kb_service 等),改后跑 run_health.py。| 项 | 决策点 | 我的建议 |
|---|---|---|
| P5 | 是否立即执行 T1 只读探针 | 建议执行 —— 零风险、10 分钟、产出决定性证据 |
| P6 | B1 修复走哪条路 | F1(前端吸附)为主,F2(服务端容差)作为后续兜底 |
| P7 | 是否批准 F3 接线 + 部署 v8 | 建议排在 T1/T0 结论出来之后再定 |
| P8 | 测试用的目标城市 | 建议用非济南城市(如北京/上海),否则无法与默认兜底值区分 |
functions/api/locate.ts、functions/api/geo.ts、SkyScene.ts、cityAdapter.ts、geoEngine.ts、SkyPage.tsx、city/config.ts/api/locate · /api/geo × 12 坐标 · /sky DOM · CF 边缘视角docs/sky/IP-HOMEPAGE-TEST-PLAN-2026-09-14.htmlscripts/probe-ip-geo.mjs(零依赖 · 可复跑)docs/sky/ip-probe-2026-09-14.csvsrc/ 零改动 | functions/ 零改动 | 线上零写入 | 无部署 | 无 KV 写入