M4:触发历史的翻页游标写成 Z 结尾,照原样拼进查询串也能翻页

next_before 原来是「2026-10-06T14:37:03.376320+00:00」,照接口说明「原样传回」拼进地址时「+」被查询串解成空格,
回 422「翻页位置需要是接口返回的 next_before」。界面用 URLSearchParams 编码不受影响,照说明写脚本、做集成的会撞上。
现在游标是 UTC 的 Z 结尾、精确到微秒(和运行列表的游标一个写法,放进地址不用编码);解析时 Z、+00:00、被解成空格
的「 00:00」(老的游标照原样拼进地址时就是这样)都认,不是时间的照旧回 422。check-schedules 的假接口照服务端用记录
时间作游标,另加一节:老服务端给 +00:00 的游标时,页面编码成 %2B 传回、第二页接得上、不重复。
This commit is contained in:
ThearchyHelios
2026-10-07 00:02:15 +08:00
parent e410d2a6a7
commit df6b0dd3ae
3 changed files with 131 additions and 5 deletions
+28 -3
View File
@@ -7,6 +7,7 @@
"""
from __future__ import annotations
import re
from datetime import datetime, timedelta
from typing import Any
@@ -42,6 +43,29 @@ def _iso(moment: datetime | None) -> str | None:
return service.as_utc(moment).isoformat() if moment is not None else None
#: 查询串把「+」解成空格之后,时间末尾的时区偏移:「…03.376320 00:00」
_SPACED_OFFSET = re.compile(r"(.*\d) (\d{2}:?\d{2})")
def _cursor(moment: datetime) -> str:
"""触发历史的翻页游标:UTC 写成 Z 结尾、精确到微秒。写成 +00:00 时照接口说明原样拼进地址,「+」在查询串里会被
解成空格,回 422(2026-10 验收);Z 结尾的放进地址不用编码,和运行列表的游标一个写法。"""
return service.as_utc(moment).isoformat().replace("+00:00", "Z")
def _cursor_time(before: str) -> datetime:
"""before → 时间:Z、+00:00 都认,也认「+」被查询串解成空格的「 00:00」(老的游标照原样拼进地址时就是这样)。
不是时间的照样抛 ValueError。"""
text = before.strip()
try:
return datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
spaced = _SPACED_OFFSET.fullmatch(text)
if spaced is None:
raise
return datetime.fromisoformat(f"{spaced[1]}+{spaced[2]}")
async def _body(request: Request) -> dict[str, Any]:
try:
data = await request.json()
@@ -579,7 +603,8 @@ async def run_now(schedule_id: str, session: AsyncSession = Depends(get_session)
@router.get("/{schedule_id}/fires")
async def list_fires(schedule_id: str, limit: str | None = None, before: str | None = None,
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""触发历史,新的在前:{items: [Fire], next_before}。翻页把 next_before 原样作为 before 传回来;没有更多时为空。"""
"""触发历史,新的在前:{items: [Fire], next_before}。翻页把 next_before 原样作为 before 传回来(Z 结尾,拼进地址
不用编码);没有更多时为空。"""
await _get(session, schedule_id)
size = 20
if limit is not None:
@@ -589,7 +614,7 @@ async def list_fires(schedule_id: str, limit: str | None = None, before: str | N
stmt = select(ScheduleFire).where(ScheduleFire.schedule_id == schedule_id)
if before is not None:
try:
cursor = datetime.fromisoformat(before.strip().replace("Z", "+00:00"))
cursor = _cursor_time(before)
except ValueError as e:
raise _invalid(f"翻页位置需要是接口返回的 next_before,当前为「{before}」", "before") from e
stmt = stmt.where(ScheduleFire.created_at < service.as_utc(cursor))
@@ -597,7 +622,7 @@ async def list_fires(schedule_id: str, limit: str | None = None, before: str | N
stmt.order_by(ScheduleFire.created_at.desc(), ScheduleFire.id.desc()).limit(size + 1))).scalars())
items = rows[:size]
return {"items": await _fire_outs(session, items),
"next_before": _iso(items[-1].created_at) if len(rows) > size else None}
"next_before": _cursor(items[-1].created_at) if len(rows) > size else None}
@router.post("/{schedule_id}/webhook/test")
@@ -0,0 +1,85 @@
"""触发历史的翻页游标(GET /api/schedules/{id}/fires 的 next_before)照原样拼进查询串也能翻页。
2026-10 验收:next_before 是「2026-10-06T14:37:03.376320+00:00」这样带 +00:00 的时间,照接口说明「原样传回」拼进
地址(不做百分号编码),「+」在查询串里被解成空格,回 422「翻页位置需要是接口返回的 next_before」。界面用
URLSearchParams 编码,不受影响;写脚本、做集成的照说明写就会撞上。现在 next_before 写成 Z 结尾(放进地址不用编码,
和运行列表的游标一个写法),解析时 Z、+00:00、被解成空格的「 00:00」都认:老的游标、编码过的照样能用。
定时任务、工作流、触发记录都是虚构的。
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.base import SessionLocal
from app.db.models import Schedule, ScheduleFire, Workflow
from app.main import app
UTC = timezone.utc
#: 五次触发,记录时间各差一天、带微秒(游标得精确到微秒,翻页才不重不漏)
CREATED = [datetime(2031, 3, 1 + i, 1, 0, 1, 250_000 + i, tzinfo=UTC) for i in range(5)]
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
async def schedule_id() -> str:
async with SessionLocal() as session:
wf = Workflow(name=f"灯塔周报-{uuid.uuid4().hex[:6]}", graph={"nodes": [], "edges": []})
session.add(wf)
await session.flush()
row = Schedule(name="灯塔周报 · 每周", workflow_id=wf.id, workflow_name=wf.name, cron="0 9 * * 1",
timezone="Asia/Shanghai", run_class="exploratory", input_template={}, enabled=False)
session.add(row)
await session.flush()
for i, at in enumerate(CREATED):
session.add(ScheduleFire(schedule_id=row.id, fire_at=at.replace(second=0, microsecond=0), kind="scheduled",
status="skipped_overlap", reason=f"第 {i} 次", created_at=at))
await session.commit()
return row.id
def _reasons(body: dict) -> list[str]:
return [f["reason"] for f in body["items"]]
async def test_next_before_needs_no_encoding(client, schedule_id):
first = (await client.get(f"/api/schedules/{schedule_id}/fires?limit=2")).json()
assert _reasons(first) == ["第 4 次", "第 3 次"]
cursor = first["next_before"]
# UTC 写成 Z 结尾、精确到微秒:没有「+」,放进地址不用编码
assert cursor == "2031-03-04T01:00:01.250003Z"
# 照原样拼进查询串(不做百分号编码)
second = await client.get(f"/api/schedules/{schedule_id}/fires?limit=2&before={cursor}")
assert second.status_code == 200, second.text
assert _reasons(second.json()) == ["第 2 次", "第 1 次"]
last = await client.get(f"/api/schedules/{schedule_id}/fires?limit=2&before={second.json()['next_before']}")
assert last.status_code == 200 and _reasons(last.json()) == ["第 0 次"] and last.json()["next_before"] is None
@pytest.mark.parametrize("written", [
"2031-03-04T01:00:01.250003+00:00", # 老的游标照原样拼进地址:「+」被解成空格
"2031-03-04T01:00:01.250003%2B00:00", # 老的游标编码过
"2031-03-04T01:00:01.250003Z",
"2031-03-04T09:00:01.250003%2B08:00", # 别的时区的同一时刻
"2031-03-04T09:00:01.250003+08:00", # ……照原样拼进地址
])
async def test_every_way_the_cursor_arrives_reads_the_same_page(client, schedule_id, written):
got = await client.get(f"/api/schedules/{schedule_id}/fires?limit=2&before={written}")
assert got.status_code == 200, got.text
assert _reasons(got.json()) == ["第 2 次", "第 1 次"]
@pytest.mark.parametrize("written", ["昨天", "2031-03-04T01:00:01 昨天", "2031-03-04T01:00:01 00:00:00"])
async def test_a_cursor_that_is_not_a_time_is_still_refused_in_chinese(client, schedule_id, written):
got = await client.get(f"/api/schedules/{schedule_id}/fires", params={"before": written})
assert got.status_code == 422 and got.json()["field"] == "before"
assert got.json()["detail"].startswith("翻页位置需要是接口返回的 next_before")
+18 -2
View File
@@ -578,12 +578,15 @@ async function reply(state, method, path, url, body) {
return { json: { fire: f, run_id: 'run-new-1' } }
}
if (method === 'GET' && rest === '/fires') {
// 游标照服务端的写法:最后一条的记录时间,UTC 写成 Z 结尾;legacyCursor 时写成老服务端的 +00:00(「+」不编码
// 放进查询串会被解成空格,页面得照样翻得过去)
const cursor = (f) => (state.legacyCursor ? f.created_at.replace(/Z$/, '+00:00') : f.created_at)
const limit = Number(url.searchParams.get('limit') ?? 20)
const before = url.searchParams.get('before')
const start = before ? state.fires.findIndex((f) => f.fire_at === before) + 1 : 0
const start = before ? state.fires.findIndex((f) => cursor(f) === before) + 1 : 0
const items = state.fires.slice(start, start + limit)
const more = start + limit < state.fires.length
return { json: { items, next_before: more ? items.at(-1).fire_at : null } }
return { json: { items, next_before: more ? cursor(items.at(-1)) : null } }
}
if (method === 'POST' && rest === '/webhook/test') return { json: state.webhookTest ?? WEBHOOK_TEST_OK }
}
@@ -1439,6 +1442,19 @@ await section('详情:触发历史分页', async () => {
.some((x) => x.key === 'POST /schedules/preview'))
check('没有页面报错', !errors.length, errors.join(' | '))
// 老服务端给的游标带 +00:00:页面照样编码成 %2B 传回去,第二页接得上、不重复
const legacy = await open('/schedules/s-weekly?tab=history', { state: freshState({ legacyCursor: true }) })
await legacy.page.locator('[data-fire-id]').first().waitFor()
await legacy.page.locator('[data-fire-more]').click()
await until(async () => (await legacy.page.locator('[data-fire-id]').count()) === 25)
const old = legacy.sent.filter((s) => s.key === 'GET /schedules/s-weekly/fires').at(-1)
const want = makeFires()[19].created_at.replace(/Z$/, '+00:00')
check('老服务端的 +00:00 游标:编码后原样传回(「+」写成 %2B)', (old?.search ?? '').includes('%2B')
&& new URLSearchParams(old?.search ?? '').get('before') === want, old?.search ?? '')
check('……接在后面,共 25 条、没有重复的行', new Set(await legacy.page.locator('[data-fire-id]')
.evaluateAll((els) => els.map((e) => e.getAttribute('data-fire-id')))).size === 25)
check('……没有页面报错', !legacy.errors.length, legacy.errors.join(' | '))
const none = freshState({ fires: [] })
const e = await open('/schedules/s-stock?tab=history', { state: none })
await e.page.locator('[data-fire-empty]').waitFor()