第10章 - STAC API 检索与扩展
STAC API 的检索能力分两层:核心参数(bbox、intersects、datetime、collections、ids、limit)由 Item Search 规范定义,任何实现都必须支持;扩展参数(sort、fields、filter、query)由扩展定义,实现可以选择。用户抱怨“搜不了云量小于 10%”时,问题往往不在核心参数,而在 filter 扩展没开或没写对。
这一章按“核心参数 → 分页 → 排序与字段裁剪 → CQL2 过滤 → 扩展组合”的顺序讲,每个参数给出规范约束与 pgstac 的实际行为。所有示例都可以对着第 9 章起的服务直接跑。
先记一个版本事实:STAC API 规范是 1.0.0(2023-04-24),不是 1.1.0。数据规范升到 1.1.0 后,API 的 conformance URI 仍然是 https://api.stacspec.org/v1.0.0/core。写兼容性判断时不要拿 STAC 版本去比对 API 版本。
10.1 核心参数
10.1.1 参数表
| 参数 | 类型 | 默认 | 约束 |
|---|---|---|---|
| limit | integer | 10 | 上限 10000;超过按上限处理,不报错 |
| bbox | [number] | 无 | 4 或 6 个数;与 intersects 互斥 |
| intersects | GeoJSON Geometry | 无 | 与 bbox 互斥 |
| datetime | string | 无 | RFC 3339 单值或区间,开区间用 .. |
| collections | [string] | 无 | 逗号分隔(GET)或数组(POST) |
| ids | [string] | 无 | 逗号分隔或数组 |
| token | string | 无 | 分页令牌,不透明 |
三条硬规则:
- bbox 与 intersects 只能给一个。规范写的是“如果两个都给,必须返回 400 Bad Request”。
- limit 超过上限不报错。规范原文是“服务器必须按上限处理,而不是返回错误”。stac-fastapi 用 AfterValidator 把值裁到 10000。
- bbox 是 2n 个数,二维 4 个、三维 6 个。给 5 个数会 400,报 “BBox ‘…’ must have 4 or 6 values.”。
10.1.2 GET 与 POST 的差异
同一个查询,两种写法:
# GET
curl -s "http://localhost:8082/search?collections=demo-landsat\
&bbox=100,20,110,30\
&datetime=2023-01-01T00:00:00Z/2023-12-31T23:59:59Z\
&limit=5"
{
"collections": ["demo-landsat"],
"bbox": [100, 20, 110, 30],
"datetime": "2023-01-01T00:00:00Z/2023-12-31T23:59:59Z",
"limit": 5
}
curl -s -X POST http://localhost:8082/search \
-H "Content-Type: application/json" \
-d @search.json
GET 的限制是 URL 长度。带 intersects 的 GeoJSON 可能几 KB,URL 编码后超过 8 KB 会被某些代理拒绝。规范的建议是:用 intersects 或复杂条件时优先 POST。
10.1.3 datetime 的写法
| 写法 | 含义 |
|---|---|
| 2023-05-01T00:00:00Z | 精确到该时刻(实际按“包含该时刻”解释) |
| 2023-05-01T00:00:00Z/2023-12-31T23:59:59Z | 闭区间 |
| 2023-05-01T00:00:00Z/.. | 从该时刻起,无上界 |
| ../2023-12-31T23:59:59Z | 无下界,到该时刻止 |
| 2023-05-01 | 只给日期,按当天 00:00:00Z 解释 |
pgstac 的解析在 str_to_interval 里,遇到越界值会返回稳定的错误串:
Invalid RFC3339 datetime.
注意区间是闭区间:start 与 end 两个端点都包含在内。这与 STAC 1.1.0 对 start_datetime / end_datetime 的定义一致。
10.1.4 bbox 与几何的语义差
规范要求“API 的空间过滤必须用 Item 的 geometry 求交,不能用 bbox”。pgstac 的实现是:
ST_Intersects(i.geometry, ST_MakeEnvelope(100,20,110,30,4326))
用 bbox 做索引粗筛、再用 geometry 精确判断。如果实现图省事只做 bbox 相交,斜向条带的影像会被误命中——搜出来的 Item 实际不覆盖查询范围。
10.2 分页
10.2.1 token 语义
响应里的 next 链接长这样:
{
"type": "FeatureCollection",
"features": [ ... ],
"links": [
{"rel": "root", "href": "http://localhost:8082/", "type": "application/json"},
{"rel": "self", "href": "http://localhost:8082/search", "type": "application/json"},
{"rel": "next", "href": "http://localhost:8082/search?token=next:eyJ...",
"type": "application/geo+json", "method": "GET"}
],
"numberReturned": 10,
"numberMatched": 137
}
客户端只要跟着 next 走,不要自己拼 token。没有 next 链接就是最后一页——这是规范定义的终止条件。
10.2.2 POST 的分页链接
POST 请求不能靠 URL 传参,所以规范给 Link 对象加了四个字段:method、headers、body、merge。
{
"rel": "next",
"href": "http://localhost:8082/search",
"method": "POST",
"body": {"token": "next:eyJ...", "collections": ["demo-landsat"], "limit": 10},
"merge": true
}
merge: true 表示“把 body 合并进原始请求体”,而不是替换。服务端不必把整个原始查询回显,只需要给出增量。
10.2.3 翻页的性能陷阱
用 OFFSET 实现的分页,翻到第 1000 页会扫过 100 万行。pgstac 用 keyset,翻页成本恒定。自己实现后端时这条必须注意:
# 反例:越翻越慢
rows = all_items[offset : offset + limit]
# 正例:从上一页最后一个排序键继续
rows = [r for r in all_items if (r.datetime, r.id) < last_key][:limit]
10.3 排序与字段裁剪
10.3.1 sortby
sort 扩展给请求模型加一个 sortby 数组:
{"sortby": [{"field": "properties.eo:cloud_cover", "direction": "asc"}]}
字段名要带 properties. 前缀(datetime 例外,直接写 datetime)。direction 只能是 asc 或 desc。
pgstac 对 datetime 排序做了专门优化,其他字段排序在超大表上会退化成全表排序。stac-fastapi-pgstac 的文档明确提醒:百万级以上建议限制 sort 的使用,或者给数据库设 statement_timeout。
# 只允许按时间排序的最简单做法:不开 sort 扩展
export ENABLED_EXTENSIONS="query,fields,filter,pagination"
10.3.2 fields
fields 扩展裁剪返回的字段,减少传输量:
{
"collections": ["demo-landsat"],
"fields": {
"include": ["id", "properties.datetime", "assets.visual.href"],
"exclude": ["properties.eo:cloud_cover"]
}
}
规则:
- include 与 exclude 同时给时,先 include 再 exclude;
- include 里的 “id”、”type”、”geometry” 之类顶层字段直接写名字;
- 嵌套字段用点号;
- 空的 include 数组表示“什么都不返回”,不是“返回全部”。
pgstac 的实现会据此生成列投影,把不需要的重列(geometry、assets、links)替换成 NULL,保持结果 schema 稳定:
CASE
WHEN a.attname = ANY (ARRAY['geometry','bbox','assets','links','link_hrefs',
'extra','properties','stac_version','stac_extensions'])
AND NOT field_included(...)
THEN format('NULL::%s AS %I', format_type(a.atttypid, a.atttypmod), a.attname)
ELSE format('i.%I', a.attname)
END
这个设计的好处是:客户端可以准备一次查询、逐页绑定参数,列数永远一致。
10.4 CQL2 过滤
10.4.1 为什么要 filter
核心参数只能表达“空间 + 时间 + 集合 + id”。想搜“云量小于 10% 且平台是 landsat-8”,核心参数做不到——这两个字段在 properties 里,属于任意字段过滤。
filter 扩展用 CQL2 补上这块。规范要求实现必须支持:
- Filter(queryables 机制 + filter / filter-lang / filter-crs 参数)
- Basic CQL2(AND / OR / NOT、= / <> / < / <= / > / >=、IS NULL)
- Item Search Filter(把上面两个绑定到 /search)
- CQL2 Text 或 CQL2 JSON 至少一种
10.4.2 两种语法
cql2-text 是类 SQL 的字符串:
eo:cloud_cover < 10 AND platform = 'landsat-8'
datetime >= TIMESTAMP('2023-01-01T00:00:00Z')
S_INTERSECTS(geometry, POLYGON((100 20, 110 20, 110 30, 100 30, 100 20)))
cql2-json 是 JSON 结构:
{
"op": "and",
"args": [
{"op": "<", "args": [{"property": "eo:cloud_cover"}, 10]},
{"op": "=", "args": [{"property": "platform"}, "landsat-8"]}
]
}
filter-lang 的默认值按请求方式区分:GET 默认 cql2-text,POST 默认 cql2-json。这条容易记混,写错会得到 400 或者“过滤器被忽略”的诡异结果。
10.4.3 完整示例
# GET + cql2-text,注意 URL 编码
curl -s -G "http://localhost:8082/search" \
--data-urlencode "collections=demo-landsat" \
--data-urlencode "filter-lang=cql2-text" \
--data-urlencode "filter=eo:cloud_cover < 10 AND platform = 'landsat-8'" \
--data-urlencode "limit=5"
# POST + cql2-json
curl -s -X POST "http://localhost:8082/search" \
-H "Content-Type: application/json" \
-d '{
"collections": ["demo-landsat"],
"filter-lang": "cql2-json",
"filter": {
"op": "and",
"args": [
{"op": "<", "args": [{"property": "eo:cloud_cover"}, 10]},
{"op": "=", "args": [{"property": "platform"}, "landsat-8"]}
]
},
"limit": 5
}'
用 -G 加 –data-urlencode 而不是手拼查询串,能避免中文、单引号、空格的编码问题。手拼时最常见的错误是把 < 直接写进 URL,被当成 HTML 实体或截断。
10.4.4 常用算子
| 类别 | 算子 |
|---|---|
| 逻辑 | and、or、not |
| 比较 | =、<>、<、<=、>、>= |
| 空值 | isNull |
| 范围 | between、in(Advanced Comparison) |
| 字符串 | like(Advanced Comparison)、lower/upper(Case-insensitive) |
| 空间 | s_intersects、s_within、s_contains、s_disjoint 等(Spatial Functions) |
| 时间 | t_before、t_after、t_intersects、t_during(Temporal Functions) |
| 数组 | a_equals、a_contains(Array Functions) |
空间算子要额外声明 conformance class。Basic Spatial Functions 只接受 BBOX 与 Point 作为空间字面量;要传 Polygon 必须实现 basic-spatial-functions-plus。
10.4.5 queryables
queryables 端点告诉客户端“哪些字段能过滤”:
curl -s "http://localhost:8082/queryables" | python -m json.tool | head -30
# {
# "$schema": "https://json-schema.org/draft/2020-12/schema",
# "$id": "https://localhost:8082/queryables",
# "type": "object",
# "title": "Queryables for this STAC API",
# "properties": {
# "id": {"$ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/properties/id"},
# "datetime": {...},
# "eo:cloud_cover": {"title": "Cloud Cover", "type": "number"}
# }
# }
pgstac 的 queryables 是动态的:item_field_registry 记录了每个 Collection 出现过哪些 JSON 路径,注册过的字段才能出现在 queryables 里。新加一个字段后要重新生成 queryables,否则 filter 会报“字段不存在”。
10.5 扩展组合
10.5.1 stac-fastapi 提供的扩展
| 扩展 | 作用 | conformance |
|---|---|---|
| transaction | 增删改 | ogcapi-features/extensions/transaction |
| bulk_transactions | 批量写入 | 同上,额外端点 |
| filter | CQL2 过滤 + queryables | filter + basic-cql2 + cql2-text/json |
| sort | 排序 | item-search#sort |
| fields | 字段裁剪 | item-search#fields |
| query | 属性等值查询(旧机制) | item-search#query |
| pagination | token 或 offset 分页 | 无独立 conformance |
| collection_search | 集合级检索 | collection-search |
| aggregation | 聚合统计 | aggregation |
| free_text | 全文检索 | free-text |
query 扩展是 CQL2 之前的老机制,只支持等值与 in。新项目直接用 filter,query 只用于兼容老客户端。
10.5.2 组合时的冲突
几个真实的冲突点:
filter 与 query 同时开,同一个字段两种写法都能表达,pgstac 会把它们 AND 起来。这不是错误,但会让调试变复杂——建议只开一个。
sort 与 pagination 耦合。token 里编码了排序键,改了 sortby 之后旧 token 失效。客户端换排序必须从头翻。
fields 与 aggregation 冲突。聚合需要读被裁剪掉的字段,开了 fields 又开 aggregation 会得到空结果。
# 只开需要的扩展,避免隐式行为
export ENABLED_EXTENSIONS="filter,sort,fields,pagination"
10.5.3 一致性检查
开了扩展之后,落地页的 conformsTo 应该包含对应的 URI。用这个脚本核对:
#!/usr/bin/env bash
set -euo pipefail
API="${1:-http://localhost:8082}"
curl -s "$API/" | python - "$API" <<'PY'
import json, sys
api = sys.argv[1]
d = json.load(sys.stdin)
need = {
"core": "https://api.stacspec.org/v1.0.0/core",
"item-search": "https://api.stacspec.org/v1.0.0/item-search",
"filter": "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter",
"cql2-json": "http://www.opengis.net/spec/cql2/1.0/conf/cql2-json",
"cql2-text": "http://www.opengis.net/spec/cql2/1.0/conf/cql2-text",
}
have = set(d.get("conformsTo", []))
for name, uri in need.items():
print(f"{name:12s} {'OK' if uri in have else 'MISSING'}")
PY
core OK
item-search OK
filter OK
cql2-json OK
cql2-text OK
注意 filter 扩展的 SEARCH 类在 stac-fastapi 里写的是 https://api.stacspec.org/v1.0.0-rc.2/item-search#filter,而规范文本里是 v1.0.0 版本。这个 rc.2 的残留是源码事实,客户端如果用严格字符串匹配会判成“不支持 filter”。判断时建议只匹配前缀或只认 CQL2 那几个 URI。
10.6 排错
| 现象 | 原因 | 修法 |
|---|---|---|
| 400 BBox ‘…’ must have 4 or 6 values | bbox 个数不对 | 检查是否漏了逗号或多了空格 |
| 400 bbox 与 intersects 同时给 | 规范禁止 | 二选一 |
| filter 不生效,返回全部 | filter-lang 与实际语法不匹配 | GET 用 cql2-text,POST 用 cql2-json |
| filter 报字段不存在 | queryables 没注册该字段 | 重新生成 queryables |
| sortby 报字段不合法 | 忘了 properties. 前缀 | 改成 properties.eo:cloud_cover |
| 翻页拿到重复数据 | 排序键不唯一(只按 datetime 排) | 加 id 作为次级排序键 |
| numberMatched 是估算值 | context=auto | 需要精确值设 context=on |
| token 失效 | 换了 sortby 或 collections | 重新从第一页开始 |
第六条在自研后端里很常见:只按 datetime 排序时,同一秒的多条记录顺序不稳定,翻页会重复或漏掉。keyset 分页必须用唯一且稳定的排序键组合。
10.7 生产建议
默认关闭不用的扩展。 每开一个扩展就多一组 conformance 声明和请求模型字段,客户端会据此假设能力。ENABLED_EXTENSIONS 显式列出比全开更可控。
给 /search 设 limit 上限。 10000 是规范上限,但对内存不友好——10000 个 Item 的 FeatureCollection 可能几十 MB。可以在反向代理层限制,或者在业务层再裁一次。
filter 的字段要有索引。 pgstac 的提升列(eo_cloud_cover、gsd)自带索引;自定义字段要靠 queryables 注册后建的表达式索引。没索引的字段过滤会全表扫。
不要在前端拼 CQL2 字符串。 用户输入直接拼进 cql2-text 会有注入风险(虽然 CQL2 不是 SQL,但语法注入一样能让查询报错或返回意外结果)。用 cql2-json 结构化构造。
缓存 queryables 响应。 它变化不频繁,但每次请求都要查注册表。给 5–10 分钟的缓存。
10.8 本章小结
| 要点 | 结论 |
|---|---|
| 版本 | STAC API 是 1.0.0,与数据规范 1.1.0 不同步 |
| 核心参数 | bbox 与 intersects 互斥;limit 超限静默裁剪到 10000 |
| 分页 | 跟着 next 链接走,token 不透明;keyset 优于 OFFSET |
| 排序 | 字段带 properties. 前缀;大表限制 sort 使用 |
| 过滤 | GET 默认 cql2-text,POST 默认 cql2-json |
| 扩展 | 按需开启,注意 filter/query 重复、sort 与 token 耦合 |
下一章讲浏览层:STAC Browser 的部署模式、配置项、与静态目录/API 的对接,以及部署到 GitHub Pages 时的限制。