From df6b0dd3aec6a6d4a93cc44c42567561858b805a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 7 Oct 2026 00:02:15 +0800 Subject: [PATCH] =?UTF-8?q?M4=EF=BC=9A=E8=A7=A6=E5=8F=91=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E7=9A=84=E7=BF=BB=E9=A1=B5=E6=B8=B8=E6=A0=87=E5=86=99=E6=88=90?= =?UTF-8?q?=20Z=20=E7=BB=93=E5=B0=BE=EF=BC=8C=E7=85=A7=E5=8E=9F=E6=A0=B7?= =?UTF-8?q?=E6=8B=BC=E8=BF=9B=E6=9F=A5=E8=AF=A2=E4=B8=B2=E4=B9=9F=E8=83=BD?= =?UTF-8?q?=E7=BF=BB=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 传回、第二页接得上、不重复。 --- backend/app/api/schedules.py | 31 +++++++- backend/tests/test_schedule_fire_cursor.py | 85 ++++++++++++++++++++++ scripts/check-schedules.mjs | 20 ++++- 3 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_schedule_fire_cursor.py diff --git a/backend/app/api/schedules.py b/backend/app/api/schedules.py index 5ca0a2d1..9b160db5 100644 --- a/backend/app/api/schedules.py +++ b/backend/app/api/schedules.py @@ -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") diff --git a/backend/tests/test_schedule_fire_cursor.py b/backend/tests/test_schedule_fire_cursor.py new file mode 100644 index 00000000..4eff5191 --- /dev/null +++ b/backend/tests/test_schedule_fire_cursor.py @@ -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") diff --git a/scripts/check-schedules.mjs b/scripts/check-schedules.mjs index ec7a7e48..bc67671b 100644 --- a/scripts/check-schedules.mjs +++ b/scripts/check-schedules.mjs @@ -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()