From c6c69aa04b7f48cd6bd302a543c5c8ca8ecc1bec Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 27 Oct 2026 00:03:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=96=E5=8A=A8=E6=8E=92=E5=BA=8F=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E5=86=99=E5=AE=8C=EF=BC=8C=E6=96=B0=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E4=BB=8D=E6=8E=92=E5=9C=A8=E6=9C=80=E4=B8=8A=E9=9D=A2=EF=BC=88?= =?UTF-8?q?BUG-1449=EF=BC=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Grok (agent loop) --- docs/BUG_HISTORY.md | 16 ++ ...PROGRESS-ui-kit-onboarding-fix-20261026.md | 11 ++ docs/tasks/README.md | 2 +- .../src/app/api/chart-profiles/order/route.ts | 33 ++++ frontend/src/app/api/sessions/order/route.ts | 42 +++++ .../src/components/people/people-page.tsx | 21 +-- frontend/src/hooks/use-session-management.ts | 31 ++-- frontend/src/lib/order-batch.ts | 84 +++++++++ frontend/src/lib/session-groups.ts | 12 +- ...1026120000_sidebar_archive_order_batch.sql | 107 +++++++++++ .../tests/database-local-business.test.ts | 21 +++ frontend/tests/database-order-batch.test.ts | 166 ++++++++++++++++++ frontend/tests/order-batch.test.ts | 106 +++++++++++ frontend/tests/session-groups.test.ts | 22 +++ 14 files changed, 637 insertions(+), 37 deletions(-) create mode 100644 frontend/src/app/api/chart-profiles/order/route.ts create mode 100644 frontend/src/app/api/sessions/order/route.ts create mode 100644 frontend/src/lib/order-batch.ts create mode 100644 frontend/supabase/migrations/20261026120000_sidebar_archive_order_batch.sql create mode 100644 frontend/tests/database-order-batch.test.ts create mode 100644 frontend/tests/order-batch.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 5f1d085c..aa3295e3 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19706,3 +19706,19 @@ - 复发自:无 - 修复版本:分支 `codex/ui-kit-onboarding-fix-20261026`。 +## BUG-1449 | 拖动排序一次发很多请求,新对话掉到组底 + +- 状态:resolved +- 首次发现:2026-10-26 +- 最近更新:2026-10-26 +- 影响面:侧栏会话排序、星盘档案排序。 +- 用户现象:拖一次排序会按每一条各存一次。一组会话超过 40 条时,一部分存上、屏幕又退回去,刷新后的顺序不是刚拖的。档案也一样。拖过一次之后,新开的对话和刚活跃、还没拖过的对话排到组的底部。 +- 触发条件:在侧栏一组里拖动会话,或在星盘档案里拖动其他人。会话写入每分钟最多 40 次。 +- 根因:变了的每一行各发一个请求,没有放在同一次事务里。有序号的会话排在没序号的前面。 +- 修复:会话和档案各改成一个请求。数据库在一次事务里写完整组;任何一行不属于当前用户,整组都不改。档案仍只排其他人。没拖过的对话按最近活跃排在上面,拖过的按用户顺序排在下面。 +- 验证:`frontend/tests/order-batch.test.ts` 5 通过,0 失败。60 条会话一次请求。`frontend/tests/session-groups.test.ts` 与 `frontend/tests/people-archive-order.test.ts` 通过。`frontend/tests/database-order-batch.test.ts` 1 通过,0 失败:写入后重读一致;请求里夹一条别人的记录时,自己的和别人的都保持原顺序。 +- 防复发:上面的一次请求测试,以及真库的写入重读和整组回滚测试。 +- 相关记录:issue #111 验收 U6、U7。#105。#108。 +- 复发自:无 +- 修复版本:分支 `codex/ui-kit-onboarding-fix-20261026`。 + diff --git a/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md b/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md index e967c08d..59e641d1 100644 --- a/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md +++ b/docs/tasks/PROGRESS-ui-kit-onboarding-fix-20261026.md @@ -47,3 +47,14 @@ | 检查 | 命令 | 结果 | | --- | --- | --- | | 拉取与打勾 | `node node_modules/tsx/dist/cli.mjs --test tests/onboarding-facts-refresh.test.tsx` | 4 通过,0 失败,退出码 0 | + +## U6 / U7 · 拖动排序一次写完,新对话仍在上面(BUG-1449) + +会话和档案的排序各改成一个请求。数据库在一次事务里写完整组,任何一行不属于当前用户就整组不动。没拖过的对话按最近活跃排在上面,拖过的排在下面。 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 一次请求与新对话位置 | `node node_modules/tsx/dist/cli.mjs --test tests/order-batch.test.ts`;同命令再跑 `tests/session-groups.test.ts tests/people-archive-order.test.ts` | `order-batch` 5 通过,0 失败,退出码 0。排序与档案 16 项通过,0 失败 | +| 真库写入后重读 | `node node_modules/tsx/dist/cli.mjs --test tests/database-order-batch.test.ts` | 1 通过,0 失败,退出码 0 | + +断言三栏(`database-local-business.test.ts` 排序段):原值只核对两列存在,以及 authenticated 能更新 `archive_order`。新值多核对整组顺序的迁移已应用、不在 `frontend/db/migrations`,以及两个函数只给 authenticated 执行。原因是 U6 要一次事务写完整组,列的语义不改。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 8c604a81..00d530fc 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -40,7 +40,7 @@ | `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` | | `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` | | `TASK-ui-kit-migration-20261023.md` | `PROGRESS-ui-kit-0-base-20261023.md`;`PROGRESS-ui-kit-1-shell-20261023.md`;`PROGRESS-ui-kit-2-chat-20261023.md`;`PROGRESS-ui-kit-3-chart-20261023.md`;`PROGRESS-ui-kit-4-people-20261023.md`;`PROGRESS-ui-kit-5-pages-20261023.md`;`PROGRESS-ui-kit-6-rectification-20261023.md`;`PROGRESS-ui-kit-7-cleanup-20261023.md` | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | K0–K7 已在测试环境,待验收 | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG | -| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U5 已提交) | `codex/ui-kit-onboarding-fix-20261026` | +| `TASK-ui-kit-onboarding-fix-20261026.md` | `PROGRESS-ui-kit-onboarding-fix-20261026.md` | **组件库 K0~K7 与启程(二)~(四)事后验收**:桌面首屏后整页重挂、中英切换看不出选中、提示层每次 DOM 变化全页扫描、启程动效对所有人生效且会藏内容、清单数据只拉一次、拖动排序 N 个请求与部分失败、缺真库重读测试、首屏超 2%、依赖卫生、补回削弱的测试、启程(二)三处未做 | 执行中(#111,U1–U6 已提交) | `codex/ui-kit-onboarding-fix-20261026` | | —(门禁修复,无新任务书) | `PROGRESS-birth-journey-contract-20261025.md` | **测试分支过不了门禁,网站停在完整数据版(二)**:BUG-1439 出生时间合同;BUG-1440 测试画布;BUG-1441 侧栏流式检查等第一次补画结束再数帧。页面不改 | 已合入 staging,等门禁 | `codex/gate-birth-journey-contract-20261025` | | `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md`;`PROGRESS-onboarding-2-intake-20261022.md`;`PROGRESS-onboarding-4-motion-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022`。(二)T3+T4 记 BUG-1431,在 `codex/onboarding-2-intake-20261022`。(四)T7 在 `codex/onboarding-4-motion-20261022` | (一)(二)(三)已在 staging;(四)本分支待验收 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U | | `TASK-site-motion-20261022.md` | — | **全站动效**:换页按侧栏方向滑入、选中块跟随、页签、盘型切换星体移宫、换人、翻日期、列表补位、弹窗从触发处长出、抽屉、顶部提示、点数滚动、中英交叉淡入;重写 DESIGN §6(推翻 180 ms 上限与 Spatial Instant),不引动画库,先验证 View Transitions。产品 2026-10-22 拍板;三个 issue,排在启程(三)之后、启程(四)之前;**2026-10-23 被 ui-kit-migration 取代** | 已取代 | 原型 https://claude.ai/artifact/7u9goivnmTNSGTNWNeVMQd | diff --git a/frontend/src/app/api/chart-profiles/order/route.ts b/frontend/src/app/api/chart-profiles/order/route.ts new file mode 100644 index 00000000..d6ba545d --- /dev/null +++ b/frontend/src/app/api/chart-profiles/order/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; + +import { orderBatchErrorStatus, parseOrderBatch } from "@/lib/order-batch"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +function rpcMessage(error: { message?: string } | null): string { + return error?.message ?? ""; +} + +export async function POST(request: Request) { + try { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const parsed = parseOrderBatch(await request.json().catch(() => null)); + if (!parsed) return NextResponse.json({ error: "排序无效" }, { status: 400 }); + const { error } = await supabase.rpc("set_chart_profile_archive_order", { + p_ids: parsed.ids, + p_orders: parsed.orders, + }); + if (error) { + const status = orderBatchErrorStatus(rpcMessage(error)); + return NextResponse.json({ error: status === 400 ? "排序无效" : "排序没有保存" }, { status }); + } + return NextResponse.json({ ok: true }); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 }); + } + return NextResponse.json({ error: "排序没有保存" }, { status: 500 }); + } +} diff --git a/frontend/src/app/api/sessions/order/route.ts b/frontend/src/app/api/sessions/order/route.ts new file mode 100644 index 00000000..f6c7e9cc --- /dev/null +++ b/frontend/src/app/api/sessions/order/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; + +import { orderBatchErrorStatus, parseOrderBatch } from "@/lib/order-batch"; +import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +function rpcMessage(error: { message?: string } | null): string { + return error?.message ?? ""; +} + +export async function POST(request: Request) { + try { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const limited = consumeUserRequestRateLimit("sessionWrite", user.id); + if (!limited.ok) { + return NextResponse.json({ + error: "请求过于频繁", + code: "rate_limited", + retryAfterSeconds: limited.retryAfterSeconds, + }, { status: 429, headers: { "Retry-After": String(limited.retryAfterSeconds) } }); + } + const parsed = parseOrderBatch(await request.json().catch(() => null)); + if (!parsed) return NextResponse.json({ error: "排序无效" }, { status: 400 }); + const { error } = await supabase.rpc("set_chat_session_sidebar_order", { + p_ids: parsed.ids, + p_orders: parsed.orders, + }); + if (error) { + const status = orderBatchErrorStatus(rpcMessage(error)); + return NextResponse.json({ error: status === 400 ? "排序无效" : "排序没有保存" }, { status }); + } + return NextResponse.json({ ok: true }); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 }); + } + return NextResponse.json({ error: "排序没有保存" }, { status: 500 }); + } +} diff --git a/frontend/src/components/people/people-page.tsx b/frontend/src/components/people/people-page.tsx index 34c497ba..d7d55159 100644 --- a/frontend/src/components/people/people-page.tsx +++ b/frontend/src/components/people/people-page.tsx @@ -32,7 +32,8 @@ import { peopleRowMeta, peopleTimeRectified, } from "@/lib/people-archive-view"; -import { archiveOrdersForIds, sortByArchiveOrder } from "@/lib/people-archive-order"; +import { archiveOrderBatch, postOrderBatch } from "@/lib/order-batch"; +import { sortByArchiveOrder } from "@/lib/people-archive-order"; import { usePeopleArchiveSortable } from "@/components/people/people-archive-sortable-loader"; import { MAX_OTHER_SUBJECTS, MAX_SAVED_SUBJECTS } from "@/lib/subject-birth"; import { profileGenderLabel } from "@/lib/profile-gender"; @@ -350,29 +351,21 @@ export function PeoplePage() { } async function persistArchiveOrder(orderedIds: readonly string[]) { - const ids = orderedIds.filter((id) => id !== "self"); + const batch = archiveOrderBatch(orderedIds, people); + if (!batch) return; const previous = people; - const orders = archiveOrdersForIds(ids); + const orders = new Map(batch.ids.map((id, index) => [id, batch.orders[index] ?? null])); setPeople((current) => { const selfRows = current.filter((person) => person.role === "self"); const byId = new Map(current.filter((person) => person.role === "other").map((person) => [person.id, person])); - const others = ids.flatMap((id) => { + const others = batch.ids.flatMap((id) => { const person = byId.get(id); return person ? [{ ...person, archiveOrder: orders.get(id) ?? null }] : []; }); return [...selfRows, ...others]; }); - const changed = ids.filter((id) => (previous.find((person) => person.id === id)?.archiveOrder ?? null) !== (orders.get(id) ?? null)); try { - const saved = await Promise.all(changed.map(async (id) => { - const response = await fetch(`/api/chart-profiles/${id}`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ archive_order: orders.get(id) ?? 0 }), - }); - return response.ok; - })); - if (saved.some((ok) => !ok)) throw new Error("order"); + await postOrderBatch("/api/chart-profiles/order", batch); } catch { setPeople(previous); notifyError("排序没有保存"); diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index 573a8873..342445d7 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -17,6 +17,7 @@ import { mergeSubjectSessionRows } from "@/lib/subject-session-merge"; import { consultationReportMarkdown } from "@/lib/consultation-report-export"; import { fallbackSessionId } from "@/lib/rectification-session-composer-guard"; import { takeSynastryChatDraft } from "@/lib/synastry-report-chat-client"; +import { postOrderBatch, sidebarOrderBatch } from "@/lib/order-batch"; import { sidebarOrdersForIds, sortSessions } from "@/lib/session-groups"; import { clearBirthTimeConsultationConsent, @@ -310,30 +311,24 @@ export function useSessionManagement(params: SessionManagementParams) { function reorderSessions(orderedIds: readonly string[]) { if (uiPreview.current || orderedIds.length === 0) return; + const sessions = sessionsRef.current; const orders = sidebarOrdersForIds(orderedIds); - const previous = new Map(); - const changed: string[] = []; - for (const session of sessionsRef.current) { - const next = orders.get(session.id); - if (next === undefined || (session.sidebarOrder ?? null) === next) continue; - previous.set(session.id, session.sidebarOrder ?? null); - if (isUnsavedEmptyConsultation(session) || !cloudCreatedIds.current.has(session.id)) continue; - changed.push(session.id); - } + const batch = sidebarOrderBatch(orderedIds, sessions, (session) => ( + !isUnsavedEmptyConsultation(session) && cloudCreatedIds.current.has(session.id) + )); + const previous = new Map(sessions.map((session) => [session.id, session.sidebarOrder ?? null])); setSessions((list) => list.map((session) => { const next = orders.get(session.id); if (next === undefined || (session.sidebarOrder ?? null) === next) return session; return { ...session, sidebarOrder: next }; })); - if (changed.length === 0) return; - void Promise.allSettled(changed.map((id) => writeChatSession(id, { sidebar_order: orders.get(id) ?? 0 }, "update"))).then((results) => { - const failed = changed.filter((_, index) => results[index]?.status === "rejected"); - if (failed.length === 0) return; - setSessions((list) => list.map((session) => ( - failed.includes(session.id) - ? { ...session, sidebarOrder: previous.get(session.id) ?? null } - : session - ))); + if (!batch) return; + void postOrderBatch("/api/sessions/order", batch).catch(() => { + setSessions((list) => list.map((session) => { + const prior = previous.get(session.id); + if (prior === undefined || (session.sidebarOrder ?? null) === prior) return session; + return { ...session, sidebarOrder: prior }; + })); setComposerNotice("排序没有保存"); }); } diff --git a/frontend/src/lib/order-batch.ts b/frontend/src/lib/order-batch.ts new file mode 100644 index 00000000..3d568bbc --- /dev/null +++ b/frontend/src/lib/order-batch.ts @@ -0,0 +1,84 @@ +import { archiveOrdersForIds } from "@/lib/people-archive-order"; +import { sidebarOrdersForIds } from "@/lib/session-groups"; + +const ORDER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export const ORDER_BATCH_LIMIT = 1000; + +export type OrderBatch = { + readonly ids: string[]; + readonly orders: number[]; +}; + +export function parseOrderBatch(body: unknown): OrderBatch | null { + if (!body || typeof body !== "object" || Array.isArray(body)) return null; + const record = body as { ids?: unknown; orders?: unknown }; + if (!Array.isArray(record.ids) || !Array.isArray(record.orders)) return null; + if (record.ids.length === 0 || record.ids.length > ORDER_BATCH_LIMIT) return null; + if (record.ids.length !== record.orders.length) return null; + const ids: string[] = []; + const orders: number[] = []; + const seen = new Set(); + for (let index = 0; index < record.ids.length; index += 1) { + const id = record.ids[index]; + const order = record.orders[index]; + if (typeof id !== "string" || !ORDER_ID.test(id) || seen.has(id)) return null; + if (typeof order !== "number" || !Number.isInteger(order) || order < 0 || order > 1_000_000) return null; + seen.add(id); + ids.push(id); + orders.push(order); + } + return { ids, orders }; +} + +export function orderBatchErrorStatus(message: string): 400 | 404 | 500 { + if (message.includes("order_invalid") || message.includes("order_unauthenticated")) return 400; + if (message.includes("order_row_missing")) return 404; + return 500; +} + +/** Cloud rows in one dragged group. Null when every saved order already matches. */ +export function sidebarOrderBatch( + orderedIds: readonly string[], + sessions: readonly T[], + isPersistent: (session: T) => boolean, +): OrderBatch | null { + const assigned = sidebarOrdersForIds(orderedIds); + const byId = new Map(sessions.map((session) => [session.id, session])); + const ids = orderedIds.filter((id) => { + const session = byId.get(id); + return session ? isPersistent(session) : false; + }); + if (ids.length === 0) return null; + const changed = ids.some((id) => (byId.get(id)?.sidebarOrder ?? null) !== (assigned.get(id) ?? null)); + if (!changed) return null; + return { ids, orders: ids.map((id) => assigned.get(id) ?? 0) }; +} + +/** Other people in one dragged archive. Self is never included. */ +export function archiveOrderBatch( + orderedIds: readonly string[], + people: readonly { id: string; archiveOrder?: number | null }[], +): OrderBatch | null { + const ids = orderedIds.filter((id) => id !== "self"); + const assigned = archiveOrdersForIds(ids); + if (ids.length === 0) return null; + const byId = new Map(people.map((person) => [person.id, person])); + const changed = ids.some((id) => (byId.get(id)?.archiveOrder ?? null) !== (assigned.get(id) ?? null)); + if (!changed) return null; + return { ids, orders: ids.map((id) => assigned.get(id) ?? 0) }; +} + +export async function postOrderBatch( + url: "/api/sessions/order" | "/api/chart-profiles/order", + batch: OrderBatch, + fetcher: typeof fetch = fetch, +): Promise { + const response = await fetcher(url, { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ ids: batch.ids, orders: batch.orders }), + }); + if (!response.ok) throw new Error("排序没有保存"); +} diff --git a/frontend/src/lib/session-groups.ts b/frontend/src/lib/session-groups.ts index b85685ab..80c1a84e 100644 --- a/frontend/src/lib/session-groups.ts +++ b/frontend/src/lib/session-groups.ts @@ -32,11 +32,15 @@ export function sortSessions 1000 + or coalesce(array_length(p_orders, 1), 0) <> coalesce(array_length(p_ids, 1), 0) + or exists ( + select 1 + from unnest(p_orders) as item(ord) + where item.ord is null or item.ord < 0 or item.ord > 1000000 + ) + or ( + select count(distinct item.id) + from unnest(p_ids) as item(id) + ) <> array_length(p_ids, 1) + then + raise exception 'order_invalid'; + end if; + + update public.chat_sessions as session + set sidebar_order = incoming.ord + from unnest(p_ids, p_orders) as incoming(id, ord) + where session.id = incoming.id + and session.user_id = owner; + + get diagnostics written = row_count; + if written <> array_length(p_ids, 1) then + raise exception 'order_row_missing'; + end if; +end; +$function$; + +create or replace function public.set_chart_profile_archive_order( + p_ids uuid[], + p_orders integer[] +) +returns void +language plpgsql +security invoker +set search_path = '' +as $function$ +declare + owner uuid := (select auth.uid()); + written integer; +begin + if owner is null then + raise exception 'order_unauthenticated'; + end if; + if p_ids is null + or p_orders is null + or coalesce(array_length(p_ids, 1), 0) < 1 + or coalesce(array_length(p_ids, 1), 0) > 1000 + or coalesce(array_length(p_orders, 1), 0) <> coalesce(array_length(p_ids, 1), 0) + or exists ( + select 1 + from unnest(p_orders) as item(ord) + where item.ord is null or item.ord < 0 or item.ord > 1000000 + ) + or ( + select count(distinct item.id) + from unnest(p_ids) as item(id) + ) <> array_length(p_ids, 1) + then + raise exception 'order_invalid'; + end if; + + update public.chart_profiles as profile + set archive_order = incoming.ord + from unnest(p_ids, p_orders) as incoming(id, ord) + where profile.id = incoming.id + and profile.user_id = owner + and profile.role = 'other'; + + get diagnostics written = row_count; + if written <> array_length(p_ids, 1) then + raise exception 'order_row_missing'; + end if; +end; +$function$; + +revoke all on function public.set_chat_session_sidebar_order(uuid[], integer[]) from public, anon; +revoke all on function public.set_chart_profile_archive_order(uuid[], integer[]) from public, anon; +grant execute on function public.set_chat_session_sidebar_order(uuid[], integer[]) to authenticated; +grant execute on function public.set_chart_profile_archive_order(uuid[], integer[]) to authenticated; + +commit; diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index f4145b66..9e1c635d 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -172,6 +172,27 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic fixture.psql("select has_column_privilege('authenticated', 'public.chart_profiles', 'archive_order', 'UPDATE')"), "t", ); + // 原值:只核对排序列存在,以及 authenticated 能更新 archive_order + // 新值:整组顺序由两个 security invoker 函数一次写完,函数不复制进 frontend/db/migrations + // 原因:U6 一次事务写完整组。列本身的语义不改 + assert.match(migration.stdout, /applied 20261026120000_sidebar_archive_order_batch\.sql/); + assert.equal( + existsSync(fileURLToPath(new URL("../db/migrations/20261026120000_sidebar_archive_order_batch.sql", import.meta.url))), + false, + "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", + ); + assert.equal( + fixture.psql("select has_function_privilege('authenticated', 'public.set_chat_session_sidebar_order(uuid[], integer[])', 'execute')"), + "t", + ); + assert.equal( + fixture.psql("select has_function_privilege('anon', 'public.set_chat_session_sidebar_order(uuid[], integer[])', 'execute')"), + "f", + ); + assert.equal( + fixture.psql("select has_function_privilege('authenticated', 'public.set_chart_profile_archive_order(uuid[], integer[])', 'execute')"), + "t", + ); assert.equal( fixture.psql(` diff --git a/frontend/tests/database-order-batch.test.ts b/frontend/tests/database-order-batch.test.ts new file mode 100644 index 00000000..5c0688fb --- /dev/null +++ b/frontend/tests/database-order-batch.test.ts @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + closeLocalPostgresDataPools, + createLocalPostgresDataClient, +} from "../src/lib/db/local-postgres-client-core.ts"; +import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); +const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0; +const schemaPassword = "schema-owner-test-password"; + +const ownerSessionA = "11111111-1111-4111-8111-1111111114a1"; +const ownerSessionB = "11111111-1111-4111-8111-1111111114a2"; +const ownerSessionC = "11111111-1111-4111-8111-1111111114a3"; +const otherSession = "22222222-2222-4222-8222-2222222224a4"; +const ownerProfileP = "33333333-3333-4333-8333-3333333334a5"; +const ownerProfileQ = "33333333-3333-4333-8333-3333333334a6"; +const otherProfile = "44444444-4444-4444-8444-4444444444a7"; + +function windowsMigrationDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), "jyotisha-order-migrations-")); + const seen = new Map(); + for (const relative of ["../db/migrations", "../supabase/migrations"]) { + const source = fileURLToPath(new URL(relative, import.meta.url)); + for (const name of readdirSync(source)) { + if (!/^\d{14}_[a-z0-9_]+\.sql$/.test(name)) continue; + const bytes = readFileSync(join(source, name)); + if (/^\.\.\/.+\.sql$/.test(bytes.toString("utf8").trim())) continue; + const previous = seen.get(name); + if (previous) { + assert.equal(bytes.equals(previous), true, `${name} mirrors must match`); + continue; + } + seen.set(name, bytes); + writeFileSync(join(directory, name), bytes); + } + } + assert.ok(seen.has("20261026120000_sidebar_archive_order_batch.sql")); + return directory; +} + +function applyMigrations(fixture: PostgresFixture): void { + const env = { + ...process.env, + SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", schemaPassword), + }; + const first = spawnSync(process.execPath, [runner], { encoding: "utf8", env }); + if (first.status === 0) return; + const detail = `${first.stderr ?? ""}\n${first.stdout ?? ""}`; + assert.match(detail, /duplicate migration filename/, detail); + const directory = windowsMigrationDirectory(); + try { + const second = spawnSync(process.execPath, [runner], { + encoding: "utf8", + env: { ...env, MIGRATIONS_DIRECTORY: directory }, + }); + assert.equal(second.status, 0, second.stderr || second.stdout); + } finally { + rmSync(directory, { force: true, recursive: true }); + } +} + +function rpcError(error: unknown): string { + if (!error || typeof error !== "object") return ""; + const message = (error as { message?: unknown }).message; + return typeof message === "string" ? message : ""; +} + +test("order batch rereads the caller's rows and leaves the group unchanged when one id is foreign", { + skip: docker ? false : "docker unavailable", +}, async () => { + const fixture = startPostgresFixture(); + try { + applyMigrations(fixture); + fixture.psql(` + insert into identity.users (name, email, email_verified, email_verified_at) + values + ('Fictional Order', 'order-owner@example.invalid', true, now()), + ('Fictional Other', 'order-other@example.invalid', true, now()); + `); + const owner = fixture.psql("select id from identity.users where email = 'order-owner@example.invalid'"); + const other = fixture.psql("select id from identity.users where email = 'order-other@example.invalid'"); + fixture.psql(` + insert into public.chat_sessions (id, user_id, title, theme, model_id, messages, session_type, updated_at) + values + ('${ownerSessionA}', '${owner}', 'A', 'general', 'test-model', '[]', 'consultation', now()), + ('${ownerSessionB}', '${owner}', 'B', 'general', 'test-model', '[]', 'consultation', now()), + ('${ownerSessionC}', '${owner}', 'C', 'general', 'test-model', '[]', 'consultation', now()), + ('${otherSession}', '${other}', 'D', 'general', 'test-model', '[]', 'consultation', now()); + update public.chat_sessions set sidebar_order = 7 where id = '${otherSession}'; + insert into public.chart_profiles (id, user_id, role, profile) + values + ('${ownerProfileP}', '${owner}', 'other', '{"name":"虚构甲","chartRelationship":"friend"}'::jsonb), + ('${ownerProfileQ}', '${owner}', 'other', '{"name":"虚构乙","chartRelationship":"friend"}'::jsonb), + ('${otherProfile}', '${other}', 'other', '{"name":"虚构丙","chartRelationship":"friend"}'::jsonb); + update public.chart_profiles set archive_order = 7 where id = '${otherProfile}'; + `); + + const sessionOrder = (id: string) => fixture.psql( + `select coalesce(sidebar_order::text, 'null') || '|' || updated_at::text from public.chat_sessions where id = '${id}'`, + ); + const profileOrder = (id: string) => fixture.psql( + `select coalesce(archive_order::text, 'null') || '|' || updated_at::text from public.chart_profiles where id = '${id}'`, + ); + const beforeA = sessionOrder(ownerSessionA); + const beforeC = sessionOrder(ownerSessionC); + const beforeOtherSession = sessionOrder(otherSession); + const beforeP = profileOrder(ownerProfileP); + const beforeOtherProfile = profileOrder(otherProfile); + + const client = createLocalPostgresDataClient( + fixture.connectionUrl("app_runtime", "app-runtime-test-password"), + { id: owner, email: "order-owner@example.invalid" }, + "authenticated", + ); + const saved = await client.rpc("set_chat_session_sidebar_order", { + p_ids: [ownerSessionB, ownerSessionA], + p_orders: [10, 20], + }); + assert.equal(saved.error, null, rpcError(saved.error)); + assert.equal(sessionOrder(ownerSessionB).startsWith("10|"), true); + assert.equal(sessionOrder(ownerSessionA).startsWith("20|"), true); + assert.equal(sessionOrder(ownerSessionA).slice(sessionOrder(ownerSessionA).indexOf("|")), beforeA.slice(beforeA.indexOf("|"))); + assert.equal(sessionOrder(ownerSessionC), beforeC); + assert.equal(sessionOrder(otherSession), beforeOtherSession); + + const rejected = await client.rpc("set_chat_session_sidebar_order", { + p_ids: [ownerSessionA, ownerSessionB, otherSession], + p_orders: [30, 40, 50], + }); + assert.match(rpcError(rejected.error), /order_row_missing/); + assert.equal(sessionOrder(ownerSessionA).startsWith("20|"), true); + assert.equal(sessionOrder(ownerSessionB).startsWith("10|"), true); + assert.equal(sessionOrder(ownerSessionC), beforeC); + assert.equal(sessionOrder(otherSession), beforeOtherSession); + + const savedProfiles = await client.rpc("set_chart_profile_archive_order", { + p_ids: [ownerProfileQ, ownerProfileP], + p_orders: [10, 20], + }); + assert.equal(savedProfiles.error, null, rpcError(savedProfiles.error)); + assert.equal(profileOrder(ownerProfileQ).startsWith("10|"), true); + assert.equal(profileOrder(ownerProfileP).startsWith("20|"), true); + assert.equal(profileOrder(ownerProfileP).slice(profileOrder(ownerProfileP).indexOf("|")), beforeP.slice(beforeP.indexOf("|"))); + assert.equal(profileOrder(otherProfile), beforeOtherProfile); + + const rejectedProfiles = await client.rpc("set_chart_profile_archive_order", { + p_ids: [ownerProfileP, otherProfile], + p_orders: [30, 40], + }); + assert.match(rpcError(rejectedProfiles.error), /order_row_missing/); + assert.equal(profileOrder(ownerProfileP).startsWith("20|"), true); + assert.equal(profileOrder(ownerProfileQ).startsWith("10|"), true); + assert.equal(profileOrder(otherProfile), beforeOtherProfile); + } finally { + await closeLocalPostgresDataPools(); + fixture.stop(); + } +}); diff --git a/frontend/tests/order-batch.test.ts b/frontend/tests/order-batch.test.ts new file mode 100644 index 00000000..86802dbe --- /dev/null +++ b/frontend/tests/order-batch.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import test from "node:test"; + +import { + archiveOrderBatch, + parseOrderBatch, + postOrderBatch, + sidebarOrderBatch, +} from "../src/lib/order-batch.ts"; + +test("dragging 60 chats sends one order request for the whole group", async () => { + const sessions = Array.from({ length: 60 }, () => ({ + id: randomUUID(), + sidebarOrder: null as number | null, + })); + const ordered = sessions.map((session) => session.id).reverse(); + const batch = sidebarOrderBatch(ordered, sessions, () => true); + assert.ok(batch); + assert.equal(batch.ids.length, 60); + assert.deepEqual(batch.orders, ordered.map((_, index) => (index + 1) * 10)); + const calls: Array<{ url: string; method: string; body: string }> = []; + await postOrderBatch("/api/sessions/order", batch, async (url, init) => { + calls.push({ + url: String(url), + method: String(init?.method), + body: String(init?.body), + }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.url, "/api/sessions/order"); + assert.equal(calls[0]?.method, "POST"); + const body = JSON.parse(calls[0]?.body ?? "{}") as { ids: string[] }; + assert.equal(body.ids.length, 60); +}); + +test("an unsaved chat stays out of the order request, and a failed request throws", async () => { + const local = { id: randomUUID(), sidebarOrder: null as number | null }; + const cloud = { id: randomUUID(), sidebarOrder: null as number | null }; + const batch = sidebarOrderBatch( + [local.id, cloud.id], + [local, cloud], + (session) => session.id === cloud.id, + ); + assert.deepEqual(batch, { ids: [cloud.id], orders: [20] }); + await assert.rejects( + () => postOrderBatch("/api/sessions/order", batch!, async () => new Response("{}", { status: 500 })), + /排序没有保存/, + ); +}); + +test("archive order is one batch and never includes self", () => { + const first = randomUUID(); + const second = randomUUID(); + assert.equal(archiveOrderBatch(["self", first], [{ id: first, archiveOrder: 10 }]), null); + assert.deepEqual( + archiveOrderBatch(["self", second, first], [ + { id: first, archiveOrder: 10 }, + { id: second, archiveOrder: 20 }, + ]), + { ids: [second, first], orders: [10, 20] }, + ); +}); + +test("order batch rejects a short, duplicate, or non-integer body", () => { + const id = "11111111-1111-4111-8111-111111111449"; + assert.equal(parseOrderBatch(null), null); + assert.equal(parseOrderBatch({ ids: [], orders: [] }), null); + assert.equal(parseOrderBatch({ ids: [id], orders: [1, 2] }), null); + assert.equal(parseOrderBatch({ ids: [id, id], orders: [10, 20] }), null); + assert.equal(parseOrderBatch({ ids: [id], orders: [1.5] }), null); + assert.equal(parseOrderBatch({ ids: ["self"], orders: [10] }), null); + assert.deepEqual(parseOrderBatch({ ids: [id], orders: [10] }), { ids: [id], orders: [10] }); +}); + +test("reorder and archive each post one batch, and the migration stays out of db/migrations", () => { + const hook = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8"); + const reorder = hook.slice(hook.indexOf("function reorderSessions"), hook.indexOf("function togglePinnedSession")); + assert.match(reorder, /postOrderBatch\(\s*"\/api\/sessions\/order"/); + assert.doesNotMatch(reorder, /Promise\.allSettled|writeChatSession\(/); + assert.match(reorder, /排序没有保存/); + const page = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8"); + const archive = page.slice(page.indexOf("async function persistArchiveOrder"), page.indexOf("function openPerson")); + assert.match(archive, /postOrderBatch\(\s*"\/api\/chart-profiles\/order"/); + assert.doesNotMatch(archive, /\/api\/chart-profiles\/\$\{/); + const sessionRoute = readFileSync(new URL("../src/app/api/sessions/order/route.ts", import.meta.url), "utf8"); + assert.equal(sessionRoute.match(/consumeUserRequestRateLimit\(/g)?.length, 1); + assert.match(sessionRoute, /set_chat_session_sidebar_order/); + const archiveRoute = readFileSync(new URL("../src/app/api/chart-profiles/order/route.ts", import.meta.url), "utf8"); + assert.match(archiveRoute, /set_chart_profile_archive_order/); + assert.doesNotMatch(archiveRoute, /consumeUserRequestRateLimit/); + const sql = readFileSync( + new URL("../supabase/migrations/20261026120000_sidebar_archive_order_batch.sql", import.meta.url), + "utf8", + ); + assert.match(sql, /security invoker/); + assert.doesNotMatch(sql, /security definer/i); + assert.match(sql, /profile\.role = 'other'/); + assert.match(sql, /raise exception 'order_row_missing'/); + assert.equal( + existsSync(new URL("../db/migrations/20261026120000_sidebar_archive_order_batch.sql", import.meta.url)), + false, + ); +}); diff --git a/frontend/tests/session-groups.test.ts b/frontend/tests/session-groups.test.ts index 90517ba5..6a2c8bb3 100644 --- a/frontend/tests/session-groups.test.ts +++ b/frontend/tests/session-groups.test.ts @@ -34,6 +34,28 @@ test("sortSessions keeps the original order when pin and time are equal", () => assert.deepEqual(sortSessions([first, second]).map((item) => item.id), ["a", "b"]); }); +test("sortSessions keeps chats the user has not ordered above dragged rows", () => { + const dragged = { id: "dragged", pinned: false, updatedAt: 100, sidebarOrder: 10 }; + const olderOpen = { id: "older", pinned: false, updatedAt: 200, sidebarOrder: null }; + const newest = { id: "newest", pinned: false, updatedAt: 900 }; + assert.deepEqual( + sortSessions([dragged, olderOpen, newest]).map((item) => item.id), + ["newest", "older", "dragged"], + ); +}); + +test("sortSessions still pins a dragged row above a newer open chat", () => { + const pinnedDragged = { id: "pin", pinned: true, updatedAt: 100, sidebarOrder: 10 }; + const newest = { id: "newest", pinned: false, updatedAt: 900 }; + assert.deepEqual(sortSessions([newest, pinnedDragged]).map((item) => item.id), ["pin", "newest"]); +}); + +test("sortSessions keeps a new pinned chat above pinned chats the user ordered", () => { + const ordered = { id: "ordered", pinned: true, updatedAt: 500, sidebarOrder: 10 }; + const fresh = { id: "fresh", pinned: true, updatedAt: 100 }; + assert.deepEqual(sortSessions([ordered, fresh]).map((item) => item.id), ["fresh", "ordered"]); +}); + test("sortSessions does not drop archived rows", () => { const archived = { ...session("arc", 50), archivedAt: "2026-09-01T00:00:00.000Z" }; const live = session("live", 80);