本文档整理 FlatRadar 移动端和第三方客户端使用的后端 API。当前稳定接口集中在 /api/v1/*,Web 后台的 HTML 页面和 /api/* 旧接口不作为移动端契约。
机器可读契约版本:openapi.json 1.26.0。接口行为以当前服务实现和契约测试为准。
机器可读契约:
- OpenAPI 3.1 JSON:openapi.json
基础约定 · 端点总览 · Auth · Public Stats · Listings · Map · Calendar · Notifications
Current User · Filter Options · Devices and Push · Feedback · Diagnostics · Legal · Admin · Webhooks · 移动端实现建议 · 兼容性说明
生产环境:
https://flatradar.app/api/v1
本地开发:
http://127.0.0.1:8088/api/v1
所有 /api/v1/* JSON 接口使用统一响应壳。
成功:
{
"ok": true,
"data": {}
}失败:
{
"ok": false,
"error": {
"code": "validation",
"message": "参数无效"
}
}稳定错误码:
| HTTP | code | 含义 |
|---|---|---|
| 400 | validation |
请求参数或 JSON body 无效 |
| 401 | unauthorized |
未登录、token 无效、token 过期、用户已停用 |
| 403 | forbidden |
当前角色无权限 |
| 404 | not_found |
资源不存在,或 user 视角不可见 |
| 409 | conflict |
资源冲突,例如用户名已存在 |
| 429 | rate_limited |
请求过于频繁 |
| 500 | server_error |
服务端错误,客户端不应依赖 message 做逻辑 |
所有 GET 200 JSON 响应自动附带 ETag 和 Cache-Control 头,客户端可用 If-None-Match 做条件请求:
| 响应头 | 值 | 说明 |
|---|---|---|
ETag |
"<md5>" |
响应体的 MD5 哈希,内容不变则 ETag 不变 |
Cache-Control |
private, max-age=10, must-revalidate |
10s 新鲜窗口,超窗后自动带 If-None-Match 复验(默认) |
Cache-Control(/notifications) |
private, no-cache |
每次必须复验(通知已读状态高频变化),但仍支持 304 |
行为:
- 客户端发
GET /api/v1/listings带If-None-Match: "<上次的etag>":- 内容未变 → 304 Not Modified,无 body,节省带宽
- 内容已变 → 200 + 新 body + 新 ETag
- 10s 内重复请求无需网络(
max-age=10),对移动端 Tab 切换(列表 ↔ 地图 ↔ 日历)特别有效 - iOS 端用
URLCache(2MB 内存 + 20MB 磁盘)自动处理;Android 端用 OkHttp 默认缓存
移动端使用 Bearer token:
Authorization: Bearer <token>角色:
| role | 说明 |
|---|---|
admin |
管理员,可查看全量数据和执行管理操作 |
user |
普通用户,房源、通知等接口会按该用户的 listing_filter 隔离 |
| guest | 不登录,只能访问公开统计、公开房源、地图、日历、过滤选项等 optional bearer 接口 |
SSE 特例:GET /notifications/stream 支持 Authorization header;不能设置自定义 header 的 EventSource 客户端先用 POST /notifications/stream/ticket 换一枚 60 秒有效、只能用于 SSE 的票据,再用 ?ticket= 连接。不接受 ?token=——长期 token 放进 URL 会落进访问日志。
接口中的时间字符串主要来自服务端 SQLite,通常是 ISO-like 字符串。移动端展示时应按以下规则:
- 服务端业务时间默认按 Amsterdam 时间理解。
- 如果设备时区不是 Amsterdam,展示时应补充时区标识。
- 客户端不应把空字符串时间解析为当前时间。
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| POST | /auth/login |
无 | 登录并签发 Bearer token |
| POST | /auth/register |
无 | 自助注册,注册即登录 |
| POST | /auth/logout |
admin/user | 撤销当前 token |
| GET | /auth/me |
admin/user | 当前身份摘要 |
| POST | /auth/password |
user | 修改当前用户密码 |
| POST | /auth/verify |
user | 校验当前密码,不签发 token |
| GET | /stats/public/summary |
可选 | 公开统计概览 |
| GET | /stats/public/charts |
可选 | 图表 key 列表 |
| GET | /stats/public/charts/<key> |
可选 | 图表数据 |
| GET | /listings |
可选 | 房源列表(支持 sort) |
| GET | /listings/<listing_id> |
可选 | 房源详情 |
| GET | /map |
可选 | 已缓存坐标的地图房源 |
| GET | /map/locate |
可选 | 按 id 定位单条房源,区分三种「看不到」 |
| GET | /calendar |
可选 | 有入住日期的日历房源 |
| GET | /notifications |
可选 | 通知列表(guest 仅 status_change / new_listing) |
| POST | /notifications/read |
admin/user | 标记通知已读 |
| GET | /notifications/stream |
admin/user | SSE 通知流 |
| POST | /notifications/stream/ticket |
admin/user | 换一枚 60 秒 SSE 票据 |
| GET | /me/summary |
admin/user | 当前用户统计 |
| GET | /me/filter |
admin/user | 当前用户过滤条件 |
| PUT | /me/filter |
user | 更新当前用户过滤条件 |
| DELETE | /me |
user | 注销当前账号 |
| GET | /me/export |
user | 导出当前用户数据 |
| GET | /filter/options |
可选 | 过滤器候选值 |
| POST | /devices/register |
admin/user | 注册/刷新设备推送 token |
| GET | /devices |
admin/user | 当前 session 下设备列表 |
| DELETE | /devices/<device_id> |
admin/user | 删除当前 session 下设备 |
| POST | /devices/test |
admin/user | 测试推送 |
| POST | /feedback |
admin/user | 提交反馈 |
| POST | /diagnostics/crash |
可选 | 上传崩溃/性能诊断 |
| GET | /legal |
无 | 服务条款与隐私政策正文 |
| GET | /admin/users |
admin | 用户列表 |
| POST | /admin/users/<user_id>/toggle |
admin | 启用/停用用户 |
| DELETE | /admin/users/<user_id> |
admin | 删除用户 |
| GET | /admin/monitor/status |
admin | 监控进程状态 |
| POST | /admin/monitor/start |
admin | 启动监控 |
| POST | /admin/monitor/stop |
admin | 停止监控 |
| POST | /admin/monitor/reload |
admin | 热重载监控配置 |
| POST | /admin/monitor/restart |
admin | 重启监控进程 |
登录并签发 Bearer token。
Body:
{
"username": "Alice",
"password": "secret",
"device_name": "Pixel 9",
"ttl_days": 90
}字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
username |
string | 是 | 普通用户名;管理员固定为 __admin__ |
password |
string | 是 | 账号密码 |
device_name |
string | 否 | 登录设备名,最长 64 字符 |
ttl_days |
int | 否 | 初始有效期,1–90 天;默认 90。持续使用时会按服务端滑动续期策略延长。 |
成功:
{
"ok": true,
"data": {
"token": "plain-token",
"token_id": 123,
"role": "user",
"user_id": "usr_xxx",
"device_name": "Pixel 9",
"ttl_days": 90
}
}注意:
__admin__使用 Web 后台密码。- 普通 user 默认使用本地 app password。
- 只认本地 App 密码;不支持用 Holland2Stay 网站的账号密码登录。
- 登录失败计入 IP 限流。
- 已撤销的会话从撤销时间起保留 30 天;已过期但未撤销的会话从过期时间起保留 30 天。监控进程定期清理到期记录及其关联设备。
自助注册普通用户,并立即签发 token。
Body:
{
"username": "Alice",
"password": "secret",
"device_name": "Pixel 9",
"ttl_days": 90
}校验:
username至少 2 字符,最长 64 字符。password至少 4 字符。username不能是__admin__。- 同 IP 注册限流:每小时最多 3 次。
成功状态码:201
返回:
{
"ok": true,
"data": {
"token": "plain-token",
"token_id": 123,
"role": "user",
"user_id": "usr_xxx",
"device_name": "Pixel 9",
"ttl_days": 90,
"user": {
"id": "usr_xxx",
"name": "Alice",
"enabled": true,
"notifications_enabled": false,
"listing_filter": {}
}
}
}撤销当前 Bearer token。
鉴权:admin / user
返回:
{
"ok": true,
"data": {
"revoked": true
}
}获取当前身份。
鉴权:admin / user
返回:
{
"ok": true,
"data": {
"role": "user",
"user_id": "usr_xxx",
"user": {
"id": "usr_xxx",
"name": "Alice",
"enabled": true,
"notifications_enabled": true,
"listing_filter": {}
}
}
}admin 的 user 为 null。
修改当前 user 密码。
鉴权:user
Body:
{
"current_password": "old",
"new_password": "new-secret"
}返回:
{
"ok": true,
"data": {
"revoked_other_sessions": 2
}
}说明:
- admin 密码不通过此接口修改。
- 修改成功后保留当前 token,撤销同一 user 的其他 token。
校验当前用户的密码,不签发新 token。
鉴权:user
给只握着 Bearer token、却需要再确认一次密码的场景用——典型是在设置页开启生物识别
登录:平台要求先证明「人还在」,而此时再走一次 /auth/login 会多签一枚 token,
等于每开一次 Face ID 就往会话表里堆一条。
请求:
{ "password": "current-password" }响应:
{ "ok": true, "data": { "ok": true } }密码不对返回 401;admin 调用返回 403(该接口只对 user 开放)。
公开统计概览。guest 可访问。
返回:
{
"ok": true,
"data": {
"total": 199,
"new_24h": 0,
"new_7d": 24,
"changes_24h": 1,
"last_scrape": "2026-05-20T11:09:58"
}
}返回可用图表 key。
{
"ok": true,
"data": {
"charts": [
"area_dist",
"city_dist",
"contract_dist",
"daily_changes",
"daily_new",
"energy_dist",
"floor_dist",
"hourly_dist",
"price_dist",
"source_dist",
"status_dist",
"tenant_dist",
"type_dist"
]
}
}查询图表数据。
Query:
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
days |
int | 7 | 1-365,超范围会 clamp;实际生效的值在 data.days 里回显 |
返回:
{
"ok": true,
"data": {
"key": "type_dist",
"days": 7,
"data": []
}
}data[] 的元素结构按 key 固定:
| key | 元素字段 | 说明 |
|---|---|---|
daily_new / daily_changes |
{date, count} |
按天;返回 days + 1 个点(含今天),无数据为 0 |
city_dist |
{city, count} |
按数量降序 |
source_dist |
{source, count} |
按数量降序 |
status_dist |
{status, count} |
按数量降序 |
price_dist |
{range, count} |
固定 9 个价格档,零值也返回 |
hourly_dist |
{hour, count} |
固定 0–23 共 24 项 |
tenant_dist / contract_dist |
{label, count} |
label 已做同义归一;按数量降序 |
type_dist |
{label, count} |
排序:Studio → Loft → 数字房型 → 其他 |
energy_dist |
{label, count} |
按能耗等级从高到低排列 |
area_dist |
{label, count} |
固定 5 个面积档,零值也返回 |
floor_dist |
{label, count} |
固定 4 个楼层档,零值也返回 |
分布类图表只统计 days 窗口内 first_seen 的房源;daily_* 按日期窗口统计。
分页房源列表。admin 返回全量;user 自动应用自己的 listing_filter;guest 返回公开全量。
Query:
| 参数 | 类型 | 说明 |
|---|---|---|
status |
string | 精确匹配状态,例如 Available to book |
source |
string | 单平台过滤,旧兼容参数,例如 holland2stay |
sources |
string | 多平台逗号分隔,例如 holland2stay,ourdomain |
city |
string | 单城市过滤,旧兼容参数 |
cities |
string | 多城市逗号分隔,例如 Eindhoven,Amsterdam |
q |
string | 名称搜索 |
types |
string | 房型逗号分隔,例如 Studio,Apartment |
occupancies |
string | 入住人数逗号分隔,例如 Single,Two |
contract |
string | 合同类型子串匹配 |
energy |
string | 最低能耗等级,例如 B |
limit |
int | 1-500,默认 100 |
offset |
int | 默认 0 |
sort |
string | 排序键,前导 - 为降序。默认 -first_seen |
source / city 是 sources / cities 的单值旧写法;两者同时给出时以复数形式
为准。
sort 取单个键,前导 - 表示降序:
| 值 | 依据 |
|---|---|
price |
从 price_raw 解析出的数值(不是文本) |
area |
面积(㎡),派生列 area_value |
energy |
能耗等级排名,派生列 energy_rank,升序 = 最好的在前(A+++ → F) |
first_seen |
首次发现时间 |
last_seen |
最近一次见到 |
available_from |
可入住日期 |
city |
城市名,不区分大小写 |
status |
业务序,不是字典序,见下 |
source |
平台 |
三件需要客户端知道的事:
一、结果总是带一个隐含的 id 兜底。 排序键相同的行之间顺序是确定的,所以
offset 翻页不会重复或漏项。不传 sort 时也一样——默认顺序 -first_seen
现在是契约的一部分,不再是实现细节。
二、status 按业务序排,字典序在这里没有意义(按字母排是
Available in lottery < Available to book < Occupied < Reserved,
把"能抢的"排在"抽签的"后面):
Available to book → 0
Available in lottery → 1
Reserved → 2
Occupied / Rented / Not available → 3
其它 → 9
归一化和客户端 Listing.statusKind 一致,available_to_book 这类下划线写法同样
认得。
三、取不到值的行一律排最后,升降序都是。价格解析不出来的、available_from
为空或是 2050 哨兵的,都算取不到。「从便宜到贵」的开头戳着一堆价格未知的房,
不是任何人想要的;哨兵尤其糟,因为它看起来像个真日期。
不认识的 sort 值返回 400,不会静默回退到默认顺序:
{
"ok": false,
"error": { "code": "validation",
"message": "未知的 sort 值 'pirce';可用:price / -price、…" }
}这跟 limit 超范围就 clamp 不是一类——clamp 一个数字仍然尊重了意图,而悄悄换掉
一个拼错的排序键,返回的是一份错的顺序,客户端无从察觉。
四、area 和 energy 走派生列。 它们在 features 里存的是文本
("87.28 m²" / "B"),按文本排是字典序——"9 m²" 会排到 "87.28 m²" 后面,
"A+++" 会排到 "A" 前面。所以库里另存了两列:
| 列 | 含义 | 方向 |
|---|---|---|
area_value |
平方米 | 越大越大 |
energy_rank |
A+++ A++ A+ A B C D E F 的下标 |
越小越好,A+++ = 0 |
这两列不出现在响应里——客户端已经有 features / feature_map,再给一份等于
同一个事实两个副本。它们只用于服务端排序。
抓不到面积、能耗标签不在白名单里(上游冒出没见过的写法)时写 NULL,不写 0:
0 是一个合法的面积和等级,用它表示"不知道"会让那些房子挤在最优的一端,比排在
最后更糟,因为它看起来像真值。
逗号形式(sort=city,-price)语法上预留给将来的多键排序,v1 会拒绝——多键在 UI
上还没有入口,与其悄悄只用第一个键,不如明确报错。
返回:
{
"ok": true,
"data": {
"items": [
{
"id": "listing-id",
"name": "Victoriapark 875",
"status": "Reserved",
"status_is_inferred": true,
"source": "holland2stay",
"price_raw": "€2,088",
"price_value": 2088.0,
"available_from": "2026-06-22",
"city": "Eindhoven",
"url": "https://...",
"features": ["Type: Apartment"],
"feature_map": {"Type": "Apartment"},
"first_seen": "2026-05-14T10:00:00",
"last_seen": "2026-05-20T11:09:58"
}
],
"total": 1,
"limit": 100,
"offset": 0,
"filtered": true
}
}客户端必须展示该字段。 status 的取值不区分来源——平台上报的 Occupied
与系统推断的 Occupied 是同一个字符串,仅此布尔值可将两者区分开。
各平台均不提供「房源已下架」的显式信号,只是停止返回该房源。因此系统依据「消失
时长」推断状态:消失 30 分钟推定为 Reserved,消失 2 小时判定为 Occupied
(2 小时对齐 Holland2Stay 官方的付款限时)。据此推断的行,其
status_is_inferred 为 true。实际运行中绝大多数终态均为推断结果;Web 面板的
处理方式是在状态徽标旁附加一枚「推测」标记,客户端可照此实现。
房源在 feed 中重新出现时,status 与该标记会一并复位。推测产生的状态变更
不会触发通知,因此客户端不会因其收到推送。
单条房源详情。
权限行为:
- admin / guest:房源存在则返回。
- user:如果该房源不通过当前用户的
listing_filter,返回 404。
返回字段同 /listings 的 item。
返回已缓存坐标的地图房源。不会触发外部 geocode 请求。
鉴权:可选
返回:
{
"ok": true,
"data": {
"listings": [
{
"id": "listing-id",
"name": "Victoriapark 875",
"status": "Reserved",
"source": "holland2stay",
"price_raw": "€2,088",
"available_from": "2026-06-22",
"city": "Eindhoven",
"address": "Victoriapark 875, Eindhoven",
"url": "https://www.holland2stay.com/…",
"neighborhood": "Victoriapark",
"building": "",
"area": "52.5 m²",
"lat": 51.4416,
"lng": 5.4697,
"display_lat": 51.4416,
"display_lng": 5.4697,
"stack_n": 1
}
],
"uncached": 3
}
}说明:
- 绘制地图请用
display_lat/display_lng,不要直接用lat/lng。 同一栋楼的所有单元共用街道地址,地理编码后坐标完全重合;服务端把重合点摆成小圈,display_*是摆开后的近似坐标,lat/lng是真实坐标。stack_n是该点上的房源数,> 1时界面应说明图钉位置是近似的。 url、neighborhood、building、area可能为空字符串。uncached表示有多少房源没有坐标缓存。- user 会按
listing_filter过滤。 - guest 可访问,但只读取缓存,不产生外部请求或写入。
- 本端点不返回
status_is_inferred,/calendar同样不返回,该字段仅由/listings提供。需区分平台上报与系统推断时,请以id向/listings或/listings/<listing_id>查询。
按 id 定位单条房源,绕过新鲜度窗口和用户自己的筛选。
鉴权:可选
查询参数:id(必填)
三种「地图上看不到」分开报,不合并成一句「没找到」:
reason |
含义 | 用户能做什么 |
|---|---|---|
not_found |
库里没有这条 | 链接作废了 |
no_coords |
有这条,但还没解析出坐标 | 等地址解析 |
(无,ok: true) |
有坐标,只是被当前筛选挡住了 | 改一下筛选 |
合并的话,这三种在界面上长得一模一样,而用户能做的事完全不同。
响应:
{
"ok": true,
"data": {
"ok": true,
"listing": { "id": "…", "lat": 51.44, "lng": 5.47, "…": "…" }
}
}定位不到时 data.ok 为 false 并带 reason:
not_found:不带listing。no_coords:带listing(房源存在,只是没有坐标)。
成功时 listing 同样返回 display_lat / display_lng,且 stack_n 显式为 1。
注意外层信封的 ok 和 data.ok 是两件事:前者说请求成功,后者说找到没找到。
返回有 available_from 的房源,用于日历视图。
鉴权:可选
返回:
{
"ok": true,
"data": {
"listings": [
{
"id": "listing-id",
"name": "Victoriapark 875",
"status": "Reserved",
"source": "holland2stay",
"price_raw": "€2,088",
"available_from": "2026-06-22",
"url": "https://...",
"city": "Eindhoven",
"building": "Onyx"
}
]
}
}分页通知列表。
鉴权:可选。带 token 时按角色过滤;不带 token(guest)时只返回
status_change / new_listing 两类,unread 恒为 0。
Query:
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
limit |
int | 50 | 1-200 |
offset |
int | 0 | 分页偏移 |
返回:
{
"ok": true,
"data": {
"items": [
{
"id": 1001,
"created_at": "2026-05-20T11:09:58Z",
"type": "new_listing",
"title": "New listing",
"body": "Victoriapark 875",
"url": "",
"listing_id": "listing-id",
"read": 0,
"user_id": "usr_xxx"
}
],
"total": 1,
"unread": 1,
"limit": 50,
"offset": 0
}
}user 视角:
- 只返回自己可见的通知。
- 只保留
new_listing、status_change、booking、announcement类型。 - 带
listing_id的通知会再次按listing_filter过滤。
guest 视角:
- 只返回
status_change、new_listing两类。 unread恒为 0(没有可标记的已读状态)。
标记通知已读。
鉴权:admin / user
Body:
{
"ids": [1001, 1002]
}或传 {} / 不传 ids 表示全部已读。
返回:
{
"ok": true,
"data": {
"marked": true
}
}SSE 增量通知流。
鉴权:Authorization: Bearer <token>,或先换票再用 ?ticket=<ticket>
(见下一节)。长期 token 不允许出现在 query 里——URL 会落进访问日志。
Query:
| 参数 | 类型 | 说明 |
|---|---|---|
ticket |
string | 可选;无法设置 Authorization header 时使用(POST /notifications/stream/ticket 获取) |
last_id |
int | 从该通知 id 之后开始增量推送 |
事件格式:
retry: 2000
data: [{"id":1002,"created_at":"2026-05-20T11:09:58Z","type":"new_listing","title":"…","body":"…","url":"","listing_id":"listing-id","read":0,"user_id":""}]
: keepalive
data: 是一个 JSON 数组,元素为通知的完整行,字段与 GET /notifications 的
items[] 相同:id、created_at、type、title、body、url、listing_id、
read、user_id。一次轮询可以返回多条;没有新通知时只发 : keepalive 注释行。
客户端建议:
- 连接最长约 5 分钟,服务端会断开;客户端必须自动重连。
- 收到
data后用最后一条通知的id更新last_id。 - 断线重连建议指数退避,最大 30 秒。
用长期 Bearer token 换一枚短期 SSE 票据。
鉴权:admin / user(Bearer header)
POST /notifications/stream/ticket
Authorization: Bearer <token>{"ok": true, "data": {"ticket": "…", "expires_in": 60}}票据是长期 token 的短时代替品:60 秒内有效、只能用于 SSE;每次重新连接前 应重新换票。token 被撤销后,尚未过期的票据也会立即失效。
当前用户统计。admin 返回全库口径;user 返回匹配当前 listing_filter 的口径。
鉴权:admin / user
返回:
{
"ok": true,
"data": {
"role": "user",
"total_in_db": 199,
"new_24h_total": 0,
"matched_total": 54,
"matched_available": 12,
"last_scrape": "2026-05-20T11:09:58",
"filter_active": true
}
}读取当前过滤条件。
鉴权:admin / user
返回:
{
"ok": true,
"data": {
"role": "user",
"filter": {
"max_rent": 1200,
"min_area": 25,
"min_floor": 0,
"allowed_cities": ["Eindhoven"],
"allowed_types": ["Studio"],
"allowed_energy": "B"
},
"is_empty": false
}
}覆盖当前 user 的过滤条件。
鉴权:user
Body 支持字段:
| 字段 | 类型 | 说明 |
|---|---|---|
max_rent |
number | 最大租金,正数 |
min_area |
number | 最小面积,正数 |
min_floor |
int | 最低楼层,0-200 |
allowed_cities |
string[] | 城市 |
allowed_types |
string[] | 房型 |
allowed_neighborhoods |
string[] | 区域 |
allowed_sources |
string[] | 来源平台(holland2stay / ourdomain / …) |
allowed_contract |
string[] | 合同类型 |
allowed_tenant |
string[] | 租客类型 |
allowed_offer |
string[] | offer 类型 |
allowed_finishing |
string[] | 装修类型 |
allowed_occupancy |
string[] | 入住人数 |
allowed_energy |
string | 能耗等级,必须在服务端白名单内 |
示例:
{
"max_rent": 1200,
"min_area": 25,
"allowed_cities": ["Eindhoven"],
"allowed_energy": "B"
}返回同 GET /me/filter。
注意:这是完整覆盖式更新,缺省字段会回到默认值,不会保留旧值。
注销当前用户账号。
鉴权:user
效果:
- 删除 SQLite 用户配置。
- 撤销该用户所有 App token。
返回:
{
"ok": true,
"data": {
"deleted": true,
"user_id": "usr_xxx"
}
}导出当前用户数据。
鉴权:user
返回结构:
account:id、name、role、created_at、enabled、notifications_enabledfilter:max_rent、min_area、min_floor及全部allowed_*字段 (allowed_occupancy、allowed_types、allowed_neighborhoods、allowed_buildings、allowed_cities、allowed_sources、allowed_contract、allowed_tenant、allowed_offer、allowed_finishing、allowed_energy)notification_history[]:id、created_at、type、title、body、url、listing_id、read,最多 500 条notification_count:notification_history的条数listing_reads[]:listing_id、read_at,仅包含当前用户的房源已读记录active_devices[]:id、device_token、env、platform、model、os_version、bundle_id、created_at、last_seenactive_tokens[]:id、device_name、created_at、last_used_at、expires_atexported_at
返回过滤器候选值。guest 可访问。
{
"ok": true,
"data": {
"cities": ["Eindhoven"],
"sources": ["holland2stay", "xior"],
"occupancy": ["One", "Two (only couples)"],
"types": ["Studio"],
"neighborhoods": ["Vlootkwartier"],
"buildings": ["Amsterdam Diemen"],
"contract": ["Regular"],
"tenant": ["Student"],
"offer": ["Lottery"],
"finishing": ["Furnished"],
"energy": ["A+++", "A++", "A+", "A", "B", "C", "D", "E", "F"],
"dim_sources": {
"source": ["holland2stay", "ourdomain", "ourcampus", "xior", "magis", "studentexperience", "plaza"],
"contract": ["holland2stay"],
"neighborhood": ["holland2stay"],
"offer": ["holland2stay"]
}
}
}dim_sources 表示每个过滤维度实际对哪些平台生效,客户端据此在筛选表单里提示
「仅对 X 生效」。它只列出已登记的维度;缺失的键应视为「未知」,不要当作「对所有
平台生效」。
推送链路已支持 iOS 和 Android 双平台:
- iOS:APNs 推送,生产可用。
- macOS:与 iOS 同走 APNs(同一个
APNS_TOPIC),2026-09-15 起接受注册。 - Android:FCM 推送(v1.7.8 起端到端拉通),OAuth2 服务账号认证 + FCM HTTP v1 API,生产可用。
已完成的平台分流工作:
/devices/register对platform做白名单:ios/android/macos,不认识的值返回 400。 ✅ (此前这一条标着 ✅ 但路由、service、storage 三层都没校验,传什么字符串就存什么——2026-09-15 补上。)- 分流规则只在
app.services.device_service.push_channel一处:ios/macos→ APNs,android→ FCM,其余不推。mcore/push.py、/devices/test、管理员测试推送都调它。 ✅ /devices/test支持 Android FCM 测试(data-only payload)。 ✅
注册或刷新当前 session 的设备 token。
鉴权:admin / user
Body:
{
"device_token": "push-token",
"env": "production",
"platform": "ios",
"model": "iPhone16,2",
"bundle_id": "com.j.kong.FlatRadar",
"language": "zh",
"os_version": "26.0.1"
}字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
device_token |
string | 是 | APNs token;Android 为 FCM token |
env |
string | 否 | production / sandbox,默认 production |
platform |
string | 否 | ios / android / macos,默认 ios;其他值返回 400 |
model |
string | 否 | 硬件标识符,不是市场名称。iOS 端取 utsname().machine,形如 iPhone16,2。最长 64 |
bundle_id |
string | 否 | App bundle/package id,最长 128 |
language |
string | 否 | 推送语言(en / zh),默认 en。iOS 从很早就在发,但直到 v1.20.0 才登记进 spec |
os_version |
string | 否 | 客户端系统版本,原样存储不做格式校验(18.5 / 26.0.1)。iOS 2.1.0 起上报;更早的客户端与当前的 Android 客户端不发,缺失即「未上报」 |
单看任何一个都没有意义——App Store Connect 的 Analytics 已经免费给了机型分布和
系统版本分布。自己收一份的唯一理由是交叉表:model × os_version(限
disabled = false),也就是「硬件够格 且 系统够新」的设备占多少。ASC 给的
是两张互相独立的边缘分布,拼不出这个数。
正因为如此,model 存的必须是硬件标识符(iPhone16,2)而不是市场名称
(iPhone 17 Pro)——后者对不上任何硬件能力表。
- 不做格式校验。 Apple 给的是
18.5/26.0,偶尔18.5.1;Android 将来 又是另一套。卡正则的话,被丢掉的恰好是还没见过的那些版本——而那正是想了解的。 - 缺失存 NULL,不存空串。 「没上报」和「上报了但值奇怪」必须能分开,否则
统计时分母是错的。同理,这一列不会用崩溃诊断里的
ios_version回填:那批 样本的触发条件是「崩过 + 同意上传」,而崩溃率本身与系统版本相关,回填等于 往干净数据里掺一批有偏的。
字段名是 os_version 不是 ios_version:这个端点的 platform 枚举含 android。
POST /diagnostics/crash 那条路径仍叫 ios_version(同时也接受 os_version),
因为那个端点目前只有 Apple 客户端在打(iOS,以及 macOS 的 MetricKit 诊断)——两处不一致是有意的。
返回:
{
"ok": true,
"data": {
"device_id": 1,
"env": "production",
"platform": "ios",
"notifications_enabled": true
}
}notifications_enabled 表示该用户当前是否已有可用的通知渠道:普通用户为布尔值;admin token 注册时为 null(不适用)。
列出当前 token/session 下的设备。
鉴权:admin / user
返回:
{
"ok": true,
"data": {
"items": [
{
"id": 1,
"device_token_hint": "abcdef123456…7890",
"env": "production",
"platform": "ios",
"model": "iPhone16,2",
"os_version": "26.0.1",
"created_at": "2026-05-17T10:00:00Z",
"last_seen": "2026-05-21T10:00:00Z",
"disabled": false,
"disabled_reason": ""
}
]
}
}删除当前 session 下的设备。只能删除当前 Bearer token 绑定的设备;越权返回 404。
返回:
{
"ok": true,
"data": {
"deleted": true
}
}测试通知链路。
鉴权:admin / user
Body:
{
"title": "测试推送",
"body": "如果你看到这条,推送链路工作正常",
"apns_only": false,
"notification_only": false
}返回:
{
"ok": true,
"data": {
"sent": 1,
"total": 1,
"results": [
{
"device_token_hint": "abcdef123456…7890",
"platform": "ios",
"env": "production",
"status": 200,
"reason": "",
"ok": true
}
],
"notification_id": 1001
}
}当前实现说明:
- 写入
web_notifications可验证 SSE / App alerts。 - APNs 测试适用于 iOS / macOS。
- Android FCM 已支持,
POST /devices/test按 platform 自动分流(iOS / macOS → APNs,Android → FCM data-only payload)。
提交用户反馈。
鉴权:admin / user
Body:
{
"kind": "bug",
"message": "Map page freezes on first open.",
"user_name": "Alice",
"app_version": "1.6.0"
}字段:
| 字段 | 类型 | 说明 |
|---|---|---|
kind |
string | bug / suggestion / other |
message |
string | 5-2000 字符 |
user_name |
string | 可选 |
app_version |
string | 可选 |
返回:
{
"ok": true,
"data": {
"submitted": true
}
}上传崩溃或性能诊断。guest 可上传。
鉴权:可选
限制:
- 单 IP 每小时最多 20 条。
- body 最大 2 MB。
Body:
{
"kind": "crash",
"app_version": "1.6.0",
"platform": "ios",
"ios_version": "26.0",
"device_model": "iPhone",
"payload": {}
}字段:
| 字段 | 类型 | 说明 |
|---|---|---|
kind |
string | 见下 |
app_version |
string | 最长 32 |
platform |
string | ios / android / macos,默认 ios |
ios_version |
string | 系统版本。也接受 os_version(优先),ios_version 为兼容旧客户端保留 |
device_model |
string | UIDevice.current.model,族名(iPhone / iPad),不是硬件标识符 |
payload |
object | 诊断正文 |
device_tokens.os_version
两个理由,各自都足够:
device_model在这里只有iPhone/iPad,和/devices/register上报的iPhone16,2不是一回事——拼不出机型 × 系统版本的交叉表。- 即使只取版本号也不行:这批样本的触发条件是「崩过 且 同意上传」,而崩溃率 本身与系统版本相关,老系统会被系统性高估。回填等于往干净数据里掺一批有偏的。
字段名叫 ios_version 而不是 os_version,是因为这个端点目前只有 iOS 在打;
/devices/register 那条是跨平台的,所以叫 os_version。两处不一致是有意的。
kind 可选:
crashhangdiskwritecpuexceptionlaunchother
返回状态码:202
{
"ok": true,
"data": {
"received": true,
"id": "20260521T100000Z-crash-abcd1234.json"
}
}Android 客户端可复用此端点,但建议字段改为:
{
"kind": "crash",
"app_version": "1.0.0",
"device_model": "Pixel 9",
"payload": {}
}当前后端字段名仍有 ios_version 历史命名,后续可扩展 os_version。
服务条款与隐私政策正文。公开接口,不需要鉴权。
查询参数:lang(可选,语言代码)
响应:
{
"ok": true,
"data": {
"terms": "…",
"privacy": "…",
"updated_at": "2026-05-26"
}
}客户端应以 updated_at 判断是否需要重新征求同意,而不是比对正文。
列出用户摘要。
鉴权:admin
返回:
{
"ok": true,
"data": {
"items": [
{
"id": "usr_xxx",
"name": "Alice",
"enabled": true,
"notifications_enabled": true,
"channel_count": 1,
"channels": ["email"],
"app_login_enabled": true,
"has_app_password": true,
"active_devices": 2,
"auto_book_enabled": false,
"filter_summary": {
"max_rent": 1200,
"min_area": 25,
"min_floor": 0,
"cities": ["Eindhoven"],
"energy": "B",
"filter_active": true
}
}
],
"total": 1
}
}启用或停用用户。
鉴权:admin
返回:
{
"ok": true,
"data": {
"id": "usr_xxx",
"enabled": false
}
}删除用户并撤销其 App token。
鉴权:admin
返回:
{
"ok": true,
"data": {
"deleted": true,
"name": "Alice",
"revoked_sessions": 2
}
}返回监控进程状态。
鉴权:admin
{
"ok": true,
"data": {
"running": true,
"pid": 12345,
"last_scrape": "2026-05-20T11:09:58",
"last_count": "11",
"upstream_maintenance_since": ""
}
}upstream_maintenance_since 非空表示 Holland2Stay 正在维护(首次探测到的时间)。
启动 monitor。
鉴权:admin
返回:
{
"ok": true,
"data": {
"started": true,
"method": "supervisor"
}
}如果已经运行,返回 400 validation。
停止 monitor。
鉴权:admin
返回:
{
"ok": true,
"data": {
"stopped": true,
"pid": 12345,
"method": "supervisor"
}
}如果未运行,返回 400 validation。
热重载 monitor 配置。
鉴权:admin
返回:
{
"ok": true,
"data": {
"reload": true,
"method": "signal"
}
}method 为 signal 或 file;SIGHUP 失败后退回文件通知时会额外带 "fallback": true。
重启 monitor 进程(先 stop 再 start)。
鉴权:admin
返回:
{
"ok": true,
"data": {
"restarted": true,
"was_running": true,
"method": "supervisor"
}
}was_running 表示重启前 monitor 是否在运行;method 只在 supervisor 路径出现(未接管时省略)。
FlatRadar 通过 webhook 接收外部服务回调。Webhook 端点不走 Bearer token 鉴权,而是通过 Svix HMAC-SHA256 签名验证请求来源。
接收 Resend email.received webhook 回调。当 notify@flatradar.app 收到入站邮件时,Resend 向该端点推送事件元数据。
鉴权:Svix HMAC-SHA256 签名(无 session)
请求头:
| 头 | 说明 |
|---|---|
svix-id |
事件唯一 ID |
svix-timestamp |
Unix 秒时间戳 |
svix-signature |
v1,<base64> 形式,可空格分隔多个轮换签名 |
签名验证:
- 用
RESEND_WEBHOOK_SECRET(whsec_前缀的 base64 串)解码 HMAC 密钥。 - 验证时间戳在 ±5 分钟容忍窗口内(防重放攻击)。
- 计算
HMAC-SHA256(secret, "svix-id.svix-timestamp.raw_body"),与svix-signature中的任一v1,xxx比对。 - 所有比较使用
hmac.compare_digest防时序攻击。
Body:
{
"type": "email.received",
"created_at": "2026-02-22T23:41:12.126Z",
"data": {
"email_id": "56761188-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"from": "user@example.com",
"to": ["notify@flatradar.app"],
"subject": "关于某房源的咨询",
"attachments": [
{
"id": "att_xxx",
"filename": "photo.png",
"content_type": "image/png"
}
]
}
}处理流程:
- 验证 Svix 签名,失败返回
401。 - 忽略非
email.received事件类型。 - 用
email_id反查 Resend API 拉取完整邮件正文(text/html)。 - DMARC 报告自动识别为
inbound_dmarc类型,其余为inbound_email。 - 写入 admin 通知面板(
web_notifications表)。 - 非 DMARC 邮件转发到
INBOUND_FORWARD_TO配置的管理员邮箱。
成功响应:
{
"ok": true,
"email_id": "56761188-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}错误响应:
| HTTP | 说明 |
|---|---|
| 401 | 签名验证失败(secret 未配置 / 时间戳过期 / HMAC 不匹配) |
| 503 | 服务端未配置 RESEND_WEBHOOK_SECRET |
环境变量:
| 变量 | 必填 | 说明 |
|---|---|---|
RESEND_WEBHOOK_SECRET |
是 | Svix webhook 签名密钥,形如 whsec_<base64> |
RESEND_API_KEY |
是 | 反查完整邮件和转发邮件使用 |
RESEND_FROM |
否 | 转发邮件时的发件地址 |
INBOUND_FORWARD_TO |
否 | 管理员个人邮箱,用于转发入站邮件 |
Resend 配置建议:
在 Resend Dashboard → Webhooks 中添加 endpoint https://<domain>/api/inbound/email,订阅 email.received 事件。Resend 会自动重试失败的投递。
- iOS:Keychain。
- Android:EncryptedSharedPreferences 或 Jetpack Security。
- 不要把 Bearer token 写入普通日志、crash payload 或 analytics。
guest 不调用 /auth/login。客户端本地标记 guest,然后访问 optional bearer 接口:
/stats/public/summary/stats/public/charts/stats/public/charts/<key>/listings/listings/<listing_id>/map/calendar/filter/options/notifications(仅status_change/new_listing)
guest 不能访问:
/devices/*/me/*/admin/*/feedback
Android 客户端已接入全部接口,FCM 推送已端到端拉通:
核心接口(已接入):
/auth/login✅/auth/register✅/auth/me✅/stats/public/summary✅/stats/public/charts/<key>✅/listings✅/listings/<listing_id>✅/me/summary✅/me/filter✅/filter/options✅/notifications✅/notifications/read✅
推送与设备(已接入):
/devices/register✅/devices✅/devices/<device_id>(DELETE) ✅/devices/test✅/devices/test
在 Android 客户端正式启用 FCM 前,需要完成以下后端改动:
- 新增
notifier_channels/fcm.py。 - 修改 push dispatcher,按
device_tokens.platform分发:ios→ APNsandroid→ FCM
/devices/register增加 platform 白名单。/devices/test根据 platform 调用 APNs 或 FCM。- tests:
- device register 支持
platform=android - APNs sender 不处理 Android token
- FCM sender 不处理 iOS token
- test push 返回 platform-specific results
- device register 支持
当前 API 的人读说明以本文档为准;Android/iOS 共用的机器可读契约以 openapi.json 为准。修改规则:
- 新字段只增不删。
- 删除字段前至少保留一个 minor 版本。
- 错误码
code保持稳定。 - 客户端不要依赖
message做控制流。 - Android/iOS 客户端应忽略未知字段。
- 新增、删除、重命名
/api/v1/*端点时,必须同步更新docs/openapi.json和tests/test_openapi_contract.py。