mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
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:
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user