
一个叫 Atlas 的 Show HN 项目把地理猜图玩成了一个很轻的形态它和 GeoGuessr 一样核心都是“看一张图判断在哪”但 Atlas 把开放式的“在地图上点选位置”改成了“每天一题、多个选项、免注册直接答”。限制变多体验反而轻了很多。对开发者来说这种产品非常适合用来练习“小但完整”的 Web 工程需要考虑固定每日题目、服务端不能泄漏答案、前端用本地存储记住答题状态、图片素材的版权和加载、上线后如何用一个边缘函数把成本压到最低。下面从产品设计、数据建模、种子化出题、前端交互、运行验证、部署排错这条链路完整拆解一个 Atlas 类项目可以怎么落地。1. 先理解 Atlas 这类游戏的产品逻辑再决定技术方案很多人看到 Atlas 的第一反应是“这不就是一个多选版 GeoGuessr 吗”直接开始写页面。但真正影响开发量的不是“多选”这个交互而是多选、每日、免登录三个约束放在一起后整个系统的设计目标发生了变化。1.1 从 GeoGuessr 到 Atlas开放式定位改多选代价和收益在哪GeoGuessr 的典型流程是给出一张全景图玩家通过缩放和拖拽地图把标记放到自己认为正确的位置系统按实际距离给出 0 到 5000 分的连续评分。这种机制对老玩家很有吸引力因为距离越近分数越高探索过程本身就是乐趣。Atlas 这类多选模式完全不一样。它把答题变成了一个离散判断给定一张图片从四个地点里选一个。这样做最直接的影响是用户不需要熟悉地图交互点击按钮即可完成答题。游戏结果从“距离误差”变成了“对或错”反馈更明确。单局时间被压缩到几十秒符合“每日一题”的消费场景。服务端不需要做复杂的连续距离计算只需要比较选项 ID。从工程角度看最值得关注的是“多选”让答案变成了服务端可校验的数据。GeoGuessr 使用地图点选可以由前端算出坐标与答案的距离而多选模式里如果服务端随意把正确选项放进接口响应任何一个玩家打开浏览器开发者工具都能看到正确答案。因此Atlas 类项目不能做成纯静态页至少需要一个非常薄的答案校验接口。可以用一张表对比两种游戏模式的技术差别对比维度GeoGuessr 常见形式Atlas 这类多选形式对后端的影响作答方式地图拖动、缩放点选点击候选按钮多层交互复杂度降低不需要地图引擎参与答题得分计算按距离连续给分布尔正确性只需要比对选项单局时长可长可短容易沉浸短平快每天一次页面不需要保存复杂会话内容依赖大量街景覆盖要求位置连续地理图片素材库加候选人素材运营比素材加工更关键题目状态通常随机或用户选择全球同一天同一题需要确定性随机算法和缓存1.2 “每日一题”不是简单随机而是要生成一个可复现的全局状态“每天一题”在技术上比“随机一题”更讲究。原因在于每日题目必须具备三个特点当天所有用户看到的题相同否则用户之间无法讨论和分享。不同日期的题目不同否则第二天内容没新鲜感。题目的主键是日期而不是自增 ID。要实现“当天所有用户相同”不能依赖每次请求时取用户的时间、再调用一次随机函数因为服务器时区和用户时区可能冲突不同实例之间也可能产生不同结果。最常见做法是使用一个固定的日界线例如统一使用 UTC 日期或者产品上约定使用东八区日期然后在服务端根据配置生成YYYY-MM-DD字符串。有了日期字符串还需要生成稳定的随机序列。普通Math.random()每次调用结果都不同服务重启后题就会变。这里必须引入“种子化随机”用日期字符串作为随机种子再通过一个确定性随机数生成器例如 mulberry32为当天生成唯一且固定的抽取顺序。这样即使用户刷新页面、服务重启只要日期相同题目顺序和正确答案就一致。1.3 “免登录”不是完全没有状态只是把状态放到了前端Atlas 项目最吸引人的文案是 no sign-up。从产品角度免登录意味着用户从点击链接到开始答题之间没有注册表单、没有邮箱验证、没有密码找回转化路径短。从后端角度这意味着项目不需要维护 users 表、access token、refresh token 和密码加密逻辑。但免登录并不等于“完全没有状态”。如果一个用户答完题刷新页面又出现同一道题体验会非常差。对于这种轻量产品常见做法是服务端保持无状态答题 API不记录答题人身份。前端用 localStorage 保存“哪一天已经答过、选了哪个选项、是否正确”。每次打开页面时先请求GET /api/daily拿到今天的日期和题目再检查本地是否存在相同日期的答题记录。这套方案的优点是不需要用户注册部署成本低缺点也很明显清除浏览器本地数据后可以再次答题。对这个体量的每日挑战产品来说基本可以接受。如果后续要上排行榜或更严格的防作弊再引入匿名会话或设备标识也不迟。2. 从数据模型开始一道每日题由哪些信息组成写代码前先把题目数据长什么样确定下来。一个问题看似简单实际上包含三类数据对外展示的题目、服务端用到的答案与坐标、素材库本身的元数据。三者混在一起最容易造成答案泄漏。2.1 对外接口里真正需要返回的字段只有图片和选项先说客户端能看到什么。Atlas 类游戏页面上需要的信息非常少{ date: 2025-05-13, image: { url: https://cdn.example.com/challenges/2025-05-13.jpg, alt: 远处有塔和湖泊的城市画面, credit: CC BY 4.0作者name }, options: [ { id: op-1, text: 杭州, region: 浙江 }, { id: op-2, text: 苏州, region: 江苏 }, { id: op-3, text: 无锡, region: 江苏 }, { id: op-4, text: 嘉兴, region: 浙江 } ] }这个对象决定了前端能渲染出什么但它不能包含正确答案索引和真实坐标。如果只写演示项目有些人会把正确答案也放到对象里结果在浏览器网络面板里一目了然。这是 Atlas 类项目最容易犯的错误。服务端真正持有的题目对象应该额外包含答案和揭晓信息单独存一份{ date: 2025-05-13, imageId: img-1001, options: [ { id: op-1, text: 杭州, region: 浙江 }, { id: op-2, text: 苏州, region: 江苏 }, { id: op-3, text: 无锡, region: 江苏 }, { id: op-4, text: 嘉兴, region: 浙江 } ], correctOptionId: op-1, location: { lat: 30.246, lng: 120.132, placeName: 杭州西湖 }, difficulty: 2 }对外 JSON 与对内 JSON 分离是保证“答案不泄漏”的第一步。实际项目中接口返回前建议只选择允许的字段避免直接把内层对象序列化给客户端。2.2 素材库不能只存图片 URL还要有足够的地理与版权元数据做一个 Atlas 类游戏真正的长期成本不是写代码而是积累图片素材。如果素材只有 URL根本无法自动生成每日题目。因为服务器要选“与你看到图片相似但实际不同的干扰项”必须知道每张图的地点类型、所属行政区域、经纬度和版权来源。一个最小可用的素材表结构大概如下CREATE TABLE location_assets ( id BIGSERIAL PRIMARY KEY, image_url TEXT NOT NULL, thumb_url TEXT, country_code CHAR(2) NOT NULL, region TEXT NOT NULL, city TEXT, place_name TEXT, place_type VARCHAR(32) NOT NULL, lat NUMERIC(9, 6) NOT NULL, lng NUMERIC(9, 6) NOT NULL, difficulty SMALLINT NOT NULL DEFAULT 3, source TEXT, license TEXT NOT NULL, author TEXT, status VARCHAR(16) NOT NULL DEFAULT draft, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );字段含义需要结合业务说明一下。place_type这张图是城市地标、自然景观、街道、交通枢纽还是人文建筑。干扰项抽选时要和正确答案保持同样的类型否则很容易被用户排除。difficulty难度等级建议用 1 到 5。1 代表线索非常明显例如地标建筑、路牌文字清楚5 代表图片信息少普通用户很难判断。country_code、region、city决定选项的粒度。如果答案是杭州干扰项可以同样来自浙江或者来自中国或者来自亚洲具体使用哪一层范围由今日题目的难度决定。license、author、source上线前必须确认图片授权。很多公开图片源要求署名或者只允许非商业使用。注意这里的lat和lng不只是给答题者抽题看的更多是答完题后展示“正确地点在地图上的位置”使用。如果素材来源为公开地理图片入库前人工核对经纬度仍然非常有必要否则会出现图片拍的是杭州、坐标却标到无锡的问题。2.3 正确答案为什么要留在服务端而不是做成一整份静态 JSON做一个每日一题的 Atlas 项目最省钱的想法是把全年题目都预先算好生成一份 JSON 放到 CDN 上前端直接读 JSON。这个想法在学习环境中可行但发布到公网之后只要 JSON 里包含correctOptionId或坐标任何人都能直接通过浏览器访问到所有答案。即使把答案字段命名成a1、b2这类无意义字符串也只是一个简单的混淆不能作为安全方案。解析 JavaScript 代码、查看网络请求都能在几分钟内还原答案。正确的做法是保留一个非常轻量的服务端GET /api/daily根据当天日期生成或读取题目只返回图片和选项。POST /api/answer接收日期和用户选择的选项 ID服务端比对后返回对错以及揭晓地点。因为这个服务不需要用户体系所以它可以用云函数、边缘函数或一个最小的 Node.js HTTP 服务实现。题目的种子化生成放在服务端客户端永远只拿题目不拿答案。3. 用“日期种子”生成每日题目并让所有玩家看到同一道题这是 Atlas 类项目技术上最核心的一环。每日题目的生成需要同时满足三个条件当天一致、隔天不同、结果可以被缓存。3.1 先把本地日期换算成稳定的日期字符串很多新手会直接写new Date().getDate()当种子。这个写法有两个问题第一不同用户处在不同时区午夜边界不同不能保证全球一致第二getDate()返回的是当月几号跨年之后同一数字会重复。建议在服务端固定一个时区并把日期统一成YYYY-MM-DD这样的格式。下面的函数可以运行在 Node.js 服务中function getDateString(date: Date, timeZone UTC): string { const parts new Intl.DateTimeFormat(zh-CN, { timeZone, year: numeric, month: 2-digit, day: 2-digit, }).formatToParts(date); const map: Recordstring, string {}; for (const part of parts) { map[part.type] part.value; } return ${map.year}-${map.month}-${map.day}; }这里要理解为什么固定时区很重要。如果服务部署在多台机器上其中一台系统时区为 UTC另一台为东八区那么在北京时间 0 点到 8 点之间两台机器计算出的“今天”会不一样。固定timeZone参数后无论服务器在哪都按同一个日历日切换题目。产品上选择哪个时区取决于目标用户。如果主要面向中国大陆用户可以统一使用Asia/Shanghai如果面向全球用户统一使用UTC最省心。题目date字段由服务端返回前端不要自己用本地时间计算“今天”否则用户换个时区或系统时间不准时会看到题目不对。3.2 用哈希和 mulberry32 生成可复现的随机序列拿到日期字符串后下一步是把字符串变成随机数生成器的种子。要求很简单同样的日期一定生成同样的序列不同日期基本不同。可以用 Node.js 内置的 crypto 模块计算一个 32 位种子import { createHash } from node:crypto; function seedFromDate(dateStr: string, salt atlas-v1): number { const hash createHash(sha256) .update(${dateStr}:${salt}) .digest(); return hash.readUInt32BE(0); }readUInt32BE(0)只取前 4 个字节可能碰撞概率很低对每日一题足够。salt 的作用是以后升级出题逻辑时通过换一个 salt 强制让同一天生成不同题目避免用户觉得每天的题总是那几个。得到种子后使用一个确定性伪随机数生成器。推荐 mulberry32它实现简单、速度足够适合这种轻量场景function mulberry32(seed: number) { let a seed 0; return function () { a (a 0x6d2b79f5) | 0; let t Math.imul(a ^ (a 15), 1 | a); t (t Math.imul(t ^ (t 7), 61 | t)) ^ t; return ((t ^ (t 14)) 0) / 4294967296; }; }调用时先生成日期种子再创建随机函数const dateStr getDateString(new Date(), UTC); const rand mulberry32(seedFromDate(dateStr, v1));之后所有“随机抽取”都调用rand()而不是Math.random()。这样即使重启服务当天的题目顺序也不会变化。3.3 从素材库抽正确地点和干扰项不能只做纯随机如果每天都从全部素材库里随机选一个正确答案很可能会出现选项之间差异过大的情况正确答案是杭州干扰项却给到纽约、悉尼、开罗。用户即使完全不了解图片里的线索也大概率能猜中题目失去趣味。设计干扰项时至少要保证以下两个条件选项的展示粒度一致。如果正确答案显示为“杭州”其他选项也应显示为城市名而不是混合国家和城市。干扰项与正确答案属于同一难度带。例如简单题都选“同一省份内的城市”中档题选“同一国家内的城市”难题选“同一大洲内的地点”。抽题逻辑可以写成下面这样function buildChallenge(assets: Asset[], dateStr: string) { const rand mulberry32(seedFromDate(dateStr, v1)); const publishedAssets assets.filter((item) item.status published); const correct publishedAssets[ Math.floor(rand() * publishedAssets.length) ]; const candidatePool publishedAssets.filter( (item) item.id ! correct.id item.place_type correct.place_type item.difficulty correct.difficulty - 1 item.difficulty correct.difficulty 1 ); const selected new Setstring(); selected.add(correct.id); while (selected.size 4 candidatePool.length 0) { const candidate candidatePool[Math.floor(rand() * candidatePool.length)]; if (!selected.has(candidate.id)) { selected.add(candidate.id); } if (selected.size candidatePool.length) { break; } } const options shuffle([...selected], rand).map((assetId, index) { const asset assets.find((item) item.id assetId)!; return { id: op-${index 1}, assetId: asset.id, text: asset.city || asset.place_name, region: asset.region, }; }); const serverChallenge { date: dateStr, options, correctOptionId: options.find((option) option.assetId correct.id)!.id, location: { lat: correct.lat, lng: correct.lng, placeName: correct.place_name, }, }; return serverChallenge; }上面代码里要注意while循环的退出条件。如果一个素材太少候选集可能不足 4 个这时不能无限循环要直接跳出。实际项目中候选池还需要排除“正确答案与当前图片看起来明显一致”的素材例如同一个城市的相似街景过多时要配置重复度上限。3.4 把当天题目缓存到午夜避免每次请求都重复计算种子化生成已经很轻量但如果素材库有几万条数据每次请求都要筛选会造成不必要的数据库压力。每天一题很适合缓存。缓存键用日期字符串过期时间设置为当天结束。async function getDailyChallenge(dateStr: string) { const cacheKey atlas:daily:${dateStr}; const cached await cache.get(cacheKey); if (cached) { return cached; } const assets await loadPublishedAssets(); const challenge buildChallenge(assets, dateStr); const ttlSeconds untilNextDay(dateStr); await cache.set(cacheKey, challenge, { ex: ttlSeconds }); return challenge; }这里的untilNextDay必须根据前一天相同的时区计算。例如题目按Asia/Shanghai切换那么 TTL 就计算到下一个上海 0 点。如果用 UTC 题目却用本地时区算 TTL会产生一小时到八小时的误差。使用云函数时如果函数存在多个实例最好使用 Redis、边缘 KV 等共享缓存而不是用函数实例内的内存缓存。内存缓存只适合单实例本地测试。4. 后端 API 与免登录前端怎么实现不注册也能记住状态后端与前端的分工可以概括成三句话后端负责安全地暴露题目和校验答案前端负责展示图片、收集选项、保存本地答题记录两者之间通过日期字段对齐“今天”。4.1 API 请求和响应字段要边界清晰一个最小 API 设计如下GET /api/daily{ date: 2025-05-13, image: { url: https://cdn.example.com/challenges/2025-05-13.jpg, alt: 湖面与远处城市轮廓, credit: CC BY 4.0 }, options: [ { id: op-1, text: 杭州, region: 浙江 }, { id: op-2, text: 苏州, region: 江苏 }, { id: op-3, text: 无锡, region: 江苏 }, { id: op-4, text: 嘉兴, region: 浙江 } ] }POST /api/answer请求体{ date: 2025-05-13, optionId: op-2 }响应体{ date: 2025-05-13, correct: false, selectedOptionId: op-2, correctOptionId: op-1, location: { name: 杭州, lat: 30.246, lng: 120.132 } }注意GET /api/daily里没有任何正确答案相关的字段。用户只有提交答案后才会从POST /api/answer中拿到正确选项和地点信息。如果做更严格的每日一题可以在GET /api/daily时返回一个短期attemptToken并在POST /api/answer时必须携带。这样可以避免有人直接拿题库中的日期和选项批量调用接口猜测答案。但这个方案仍然无法应对熟练用户多次提交因为免登录产品天然缺乏身份锚点。实际上对这种每日四选一游戏更合理的安全目标是“不让普通用户顺手看到答案”而不是“完全防住恶意脚本”。如果要在无注册前提下做严格防作弊需要引入设备指纹、IP 限流、答题耗时分析等更多手段这会显著增加复杂度。4.2 前端只要维护一个简单的状态机Atlas 类页面交互并不复杂核心状态只有三种加载中、答题中、已揭晓。如果已经答过题打开页面要直接进入结果态如果没有答过显示图片和按钮。一个 React 示例结构如下type Phase loading | ready | result; const [phase, setPhase] useStatePhase(loading); const [question, setQuestion] useStateDailyQuestion | null(null); const [selectedOptionId, setSelectedOptionId] useState(); async function loadDaily() { const res await fetch(/api/daily); const data await res.json(); setQuestion(data); if (hasPlayedToday(data.date)) { setPlanPhase(result); } else { setPlanPhase(ready); } } async function submitOption(optionId: string) { setSelectedOptionId(optionId); const res await fetch(/api/answer, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ date: question?.date, optionId }), }); const result await res.json(); savePlayedResult(question!.date, optionId, result.correct); setPlanPhase(result); }页面加载后在useEffect中调用loadDaily。按钮点击后先禁用所有选项避免用户连续提交然后等待接口返回。接口返回correct后再展示正确或错误的样式以及答题后的地图位置。也可以用 Vue、Svelte 或原生 JavaScript 实现核心逻辑是一样的。关键是不要在前端任何逻辑里硬编码某一天的正确选项。4.3 localStorage 保存答题状态时要绑定日期而不是只存一个“已经答过”标记免登录的答题记录通常放在 localStorage 中。最简单的错误写法是localStorage.setItem(atlas_played, true);这样写的问题很明显第二天打开页面标记仍然是true用户会永远看不到新题目。推荐保存一个以日期为键的对象const STORAGE_KEY atlas:play-records:v1; function readPlayRecords(): Recordstring, { optionId: string; correct: boolean } { try { const raw localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : {}; } catch { return {}; } } function hasPlayedToday(date: string): boolean { return Boolean(readPlayRecords()[date]); } function savePlayedResult( date: string, optionId: string, correct: boolean ) { const records readPlayRecords(); records[date] { optionId, correct }; localStorage.setItem(STORAGE_KEY, JSON.stringify(records)); }要注意的是if localStorage 不可用例如某些隐私模式需要使用try/catch兜底。没有本地存储时至少不能让页面直接崩溃可以提示用户“本次答题不会被记录”或者退化为允许当天重复答题。5. 运行验证与上线前测试不能只看页面能打开这类项目最容易出现的问题是“本地开发一切正常第二天题目不变”或“两个用户看到的题目不一样”。原因是验证不够系统。上线前至少要把种子生成、接口返回、前端状态三个环节分别验证。5.1 用脚本验证“当天一致、隔天不同”不要只打开页面肉眼观察可以写一个简单脚本验证种子系统。以下脚本用于确认同一日期生成的题目一致不同日期选项完全不同或至少正确选项不同// scripts/check-seed.ts import { buildChallenge, loadAssets } from ../src/server/daily; const date1 2025-05-13; const date2 2025-05-14; const assets await loadAssets(); const challenge1 buildChallenge(assets, date1); const challenge2 buildChallenge(assets, date2); console.log(date1, challenge1.correctOptionId, challenge1.options); console.log(date2, challenge2.correctOptionId, challenge2.options);运行后会看到类似输出2025-05-13 op-3 [杭州苏州无锡嘉兴] 2025-05-14 op-2 [桂林南宁北海柳州]验证时还要重复运行两次同一日期的脚本确认输出完全一致。如果同一日期两次运行出现不同结果说明代码里不小心用了Math.random()或遍历对象顺序不稳定需要重新检查随机函数。5.2 接口测试用例覆盖三类关键行为接口测试至少覆盖以下场景。第一次请求今日题目curl -s https://example.com/api/daily | jq .date, .options[].text提交错误答案curl -s -X POST https://example.com/api/answer \ -H Content-Type: application/json \ -d {date:2025-05-13,optionId:op-2}提交后应返回correct: false并且揭示正确答案的correctOptionId。提交正确答案curl -s -X POST https://example.com/api/answer \ -H Content-Type: application/json \ -d {date:2025-05-13,optionId:op-3}提交后应返回correct: true。测试时还要确认无论答案对错GET /api/daily的响应里都不能出现correct相关字段。此外还可以设计几个异常用例日期与今日不符时接口返回 409 或 400而不是默认取当天题目。选项 ID 不存在时返回 404 或可读的错误消息。用户重复提交同一个日期与选项时至少不应该发生 500 错误。最方便的做法是让接口幂等相同请求返回相同结果。5.3 前端验证清单页面层建议