Skip to content

Latest commit

 

History

History
1700 lines (1296 loc) · 44.6 KB

API.md

File metadata and controls

1700 lines (1296 loc) · 44.6 KB

FlatRadar Backend API Reference

本文档整理 FlatRadar 移动端和第三方客户端使用的后端 API。当前稳定接口集中在 /api/v1/*,Web 后台的 HTML 页面和 /api/* 旧接口不作为移动端契约。

机器可读契约版本:openapi.json 1.26.0。接口行为以当前服务实现和契约测试为准。

机器可读契约:

目录

基础约定 · 端点总览 · Auth · Public Stats · Listings · Map · Calendar · Notifications

Current User · Filter Options · Devices and Push · Feedback · Diagnostics · Legal · Admin · Webhooks · 移动端实现建议 · 兼容性说明

基础约定

Base URL

生产环境:

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 做逻辑

条件缓存(ETag / 304)

所有 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 重启监控进程

Auth

POST /auth/login

登录并签发 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 天。监控进程定期清理到期记录及其关联设备。

POST /auth/register

自助注册普通用户,并立即签发 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": {}
    }
  }
}

POST /auth/logout

撤销当前 Bearer token。

鉴权:admin / user

返回:

{
  "ok": true,
  "data": {
    "revoked": true
  }
}

GET /auth/me

获取当前身份。

鉴权: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。

POST /auth/password

修改当前 user 密码。

鉴权:user

Body:

{
  "current_password": "old",
  "new_password": "new-secret"
}

返回:

{
  "ok": true,
  "data": {
    "revoked_other_sessions": 2
  }
}

说明:

  • admin 密码不通过此接口修改。
  • 修改成功后保留当前 token,撤销同一 user 的其他 token。

POST /auth/verify

校验当前用户的密码,不签发新 token。

鉴权:user

给只握着 Bearer token、却需要再确认一次密码的场景用——典型是在设置页开启生物识别 登录:平台要求先证明「人还在」,而此时再走一次 /auth/login 会多签一枚 token, 等于每开一次 Face ID 就往会话表里堆一条。

请求:

{ "password": "current-password" }

响应:

{ "ok": true, "data": { "ok": true } }

密码不对返回 401;admin 调用返回 403(该接口只对 user 开放)。

Public Stats

GET /stats/public/summary

公开统计概览。guest 可访问。

返回:

{
  "ok": true,
  "data": {
    "total": 199,
    "new_24h": 0,
    "new_7d": 24,
    "changes_24h": 1,
    "last_scrape": "2026-05-20T11:09:58"
  }
}

GET /stats/public/charts

返回可用图表 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"
    ]
  }
}

GET /stats/public/charts/<key>

查询图表数据。

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_* 按日期窗口统计。

Listings

GET /listings

分页房源列表。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_is_inferred

客户端必须展示该字段。 status 的取值不区分来源——平台上报的 Occupied 与系统推断的 Occupied 是同一个字符串,仅此布尔值可将两者区分开。

各平台均不提供「房源已下架」的显式信号,只是停止返回该房源。因此系统依据「消失 时长」推断状态:消失 30 分钟推定为 Reserved,消失 2 小时判定为 Occupied (2 小时对齐 Holland2Stay 官方的付款限时)。据此推断的行,其 status_is_inferred 为 true。实际运行中绝大多数终态均为推断结果;Web 面板的 处理方式是在状态徽标旁附加一枚「推测」标记,客户端可照此实现。

房源在 feed 中重新出现时,status 与该标记会一并复位。推测产生的状态变更 不会触发通知,因此客户端不会因其收到推送。

详见架构总览:房源状态与完整性。

GET /listings/<listing_id>

单条房源详情。

权限行为:

  • admin / guest:房源存在则返回。
  • user:如果该房源不通过当前用户的 listing_filter,返回 404。

返回字段同 /listings 的 item。

Map

GET /map

返回已缓存坐标的地图房源。不会触发外部 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> 查询。

GET /map/locate

按 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 是两件事:前者说请求成功,后者说找到没找到。

Calendar

GET /calendar

返回有 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"
      }
    ]
  }
}

Notifications

GET /notifications

分页通知列表。

鉴权:可选。带 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(没有可标记的已读状态)。

POST /notifications/read

标记通知已读。

鉴权:admin / user

Body:

{
  "ids": [1001, 1002]
}

或传 {} / 不传 ids 表示全部已读。

返回:

{
  "ok": true,
  "data": {
    "marked": true
  }
}

GET /notifications/stream

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 秒。

POST /notifications/stream/ticket

用长期 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 被撤销后,尚未过期的票据也会立即失效。

Current User

GET /me/summary

当前用户统计。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
  }
}

GET /me/filter

读取当前过滤条件。

鉴权: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
  }
}

PUT /me/filter

覆盖当前 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。

注意:这是完整覆盖式更新,缺省字段会回到默认值,不会保留旧值。

DELETE /me

注销当前用户账号。

鉴权:user

效果:

  • 删除 SQLite 用户配置。
  • 撤销该用户所有 App token。

返回:

{
  "ok": true,
  "data": {
    "deleted": true,
    "user_id": "usr_xxx"
  }
}

GET /me/export

导出当前用户数据。

鉴权:user

返回结构:

  • account:id、name、role、created_at、enabled、notifications_enabled
  • filter: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_seen
  • active_tokens[]:id、device_name、created_at、last_used_at、expires_at
  • exported_at

Filter Options

GET /filter/options

返回过滤器候选值。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 生效」。它只列出已登记的维度;缺失的键应视为「未知」,不要当作「对所有 平台生效」。

Devices and Push

当前状态

推送链路已支持 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)。 ✅

POST /devices/register

注册或刷新当前 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 客户端不发,缺失即「未上报」

model 与 os_version 是一对

单看任何一个都没有意义——App Store Connect 的 Analytics 已经免费给了机型分布和 系统版本分布。自己收一份的唯一理由是交叉表:model × os_version(限 disabled = false),也就是「硬件够格 且 系统够新」的设备占多少。ASC 给的 是两张互相独立的边缘分布,拼不出这个数。

正因为如此,model 存的必须是硬件标识符(iPhone16,2)而不是市场名称 (iPhone 17 Pro)——后者对不上任何硬件能力表。

os_version 的两条约束

  • 不做格式校验。 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(不适用)。

GET /devices

列出当前 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": ""
      }
    ]
  }
}

DELETE /devices/<device_id>

删除当前 session 下的设备。只能删除当前 Bearer token 绑定的设备;越权返回 404。

返回:

{
  "ok": true,
  "data": {
    "deleted": true
  }
}

POST /devices/test

测试通知链路。

鉴权: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)。

Feedback

POST /feedback

提交用户反馈。

鉴权: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
  }
}

Diagnostics

POST /diagnostics/crash

上传崩溃或性能诊断。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

两个理由,各自都足够:

  1. device_model 在这里只有 iPhone / iPad,和 /devices/register 上报的 iPhone16,2 不是一回事——拼不出机型 × 系统版本的交叉表。
  2. 即使只取版本号也不行:这批样本的触发条件是「崩过 且 同意上传」,而崩溃率 本身与系统版本相关,老系统会被系统性高估。回填等于往干净数据里掺一批有偏的。

字段名叫 ios_version 而不是 os_version,是因为这个端点目前只有 iOS 在打; /devices/register 那条是跨平台的,所以叫 os_version。两处不一致是有意的。

kind 可选:

  • crash
  • hang
  • diskwrite
  • cpuexception
  • launch
  • other

返回状态码: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。

Legal

GET /legal

服务条款与隐私政策正文。公开接口,不需要鉴权。

查询参数:lang(可选,语言代码)

响应:

{
  "ok": true,
  "data": {
    "terms": "…",
    "privacy": "…",
    "updated_at": "2026-05-26"
  }
}

客户端应以 updated_at 判断是否需要重新征求同意,而不是比对正文。

Admin

GET /admin/users

列出用户摘要。

鉴权: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
  }
}

POST /admin/users/<user_id>/toggle

启用或停用用户。

鉴权:admin

返回:

{
  "ok": true,
  "data": {
    "id": "usr_xxx",
    "enabled": false
  }
}

DELETE /admin/users/<user_id>

删除用户并撤销其 App token。

鉴权:admin

返回:

{
  "ok": true,
  "data": {
    "deleted": true,
    "name": "Alice",
    "revoked_sessions": 2
  }
}

GET /admin/monitor/status

返回监控进程状态。

鉴权: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 正在维护(首次探测到的时间)。

POST /admin/monitor/start

启动 monitor。

鉴权:admin

返回:

{
  "ok": true,
  "data": {
    "started": true,
    "method": "supervisor"
  }
}

如果已经运行,返回 400 validation。

POST /admin/monitor/stop

停止 monitor。

鉴权:admin

返回:

{
  "ok": true,
  "data": {
    "stopped": true,
    "pid": 12345,
    "method": "supervisor"
  }
}

如果未运行,返回 400 validation。

POST /admin/monitor/reload

热重载 monitor 配置。

鉴权:admin

返回:

{
  "ok": true,
  "data": {
    "reload": true,
    "method": "signal"
  }
}

method 为 signal 或 file;SIGHUP 失败后退回文件通知时会额外带 "fallback": true。

POST /admin/monitor/restart

重启 monitor 进程(先 stop 再 start)。

鉴权:admin

返回:

{
  "ok": true,
  "data": {
    "restarted": true,
    "was_running": true,
    "method": "supervisor"
  }
}

was_running 表示重启前 monitor 是否在运行;method 只在 supervisor 路径出现(未接管时省略)。

Webhooks

FlatRadar 通过 webhook 接收外部服务回调。Webhook 端点不走 Bearer token 鉴权,而是通过 Svix HMAC-SHA256 签名验证请求来源。

POST /api/inbound/email

接收 Resend email.received webhook 回调。当 notify@flatradar.app 收到入站邮件时,Resend 向该端点推送事件元数据。

鉴权:Svix HMAC-SHA256 签名(无 session)

请求头:

头 说明
svix-id 事件唯一 ID
svix-timestamp Unix 秒时间戳
svix-signature v1,<base64> 形式,可空格分隔多个轮换签名

签名验证:

  1. 用 RESEND_WEBHOOK_SECRET(whsec_ 前缀的 base64 串)解码 HMAC 密钥。
  2. 验证时间戳在 ±5 分钟容忍窗口内(防重放攻击)。
  3. 计算 HMAC-SHA256(secret, "svix-id.svix-timestamp.raw_body"),与 svix-signature 中的任一 v1,xxx 比对。
  4. 所有比较使用 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"
      }
    ]
  }
}

处理流程:

  1. 验证 Svix 签名,失败返回 401。
  2. 忽略非 email.received 事件类型。
  3. 用 email_id 反查 Resend API 拉取完整邮件正文(text/html)。
  4. DMARC 报告自动识别为 inbound_dmarc 类型,其余为 inbound_email。
  5. 写入 admin 通知面板(web_notifications 表)。
  6. 非 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 会自动重试失败的投递。

移动端实现建议

Token 存储

  • iOS:Keychain。
  • Android:EncryptedSharedPreferences 或 Jetpack Security。
  • 不要把 Bearer token 写入普通日志、crash payload 或 analytics。

Guest 模式

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 接口接入情况

Android 客户端已接入全部接口,FCM 推送已端到端拉通:

核心接口(已接入):

  1. /auth/login ✅
  2. /auth/register ✅
  3. /auth/me ✅
  4. /stats/public/summary ✅
  5. /stats/public/charts/<key> ✅
  6. /listings ✅
  7. /listings/<listing_id> ✅
  8. /me/summary ✅
  9. /me/filter ✅
  10. /filter/options ✅
  11. /notifications ✅
  12. /notifications/read ✅

推送与设备(已接入):

  1. /devices/register ✅
  2. /devices ✅
  3. /devices/<device_id> (DELETE) ✅
  4. /devices/test ✅
  5. /devices/test

Android 后端待办

在 Android 客户端正式启用 FCM 前,需要完成以下后端改动:

  • 新增 notifier_channels/fcm.py。
  • 修改 push dispatcher,按 device_tokens.platform 分发:
    • ios → APNs
    • android → 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

兼容性说明

当前 API 的人读说明以本文档为准;Android/iOS 共用的机器可读契约以 openapi.json 为准。修改规则:

  • 新字段只增不删。
  • 删除字段前至少保留一个 minor 版本。
  • 错误码 code 保持稳定。
  • 客户端不要依赖 message 做控制流。
  • Android/iOS 客户端应忽略未知字段。
  • 新增、删除、重命名 /api/v1/* 端点时,必须同步更新 docs/openapi.json 和 tests/test_openapi_contract.py。