diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c2ac9131..d7861be6 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7150,3 +7150,19 @@ - 相关记录:BUG-464 - 复发自:无 - 修复版本:待发布 + +## BUG-466 | 星盘库、合盘历史和会话置顶/归档仍有一份会骗人的本地真相 + +- 状态:resolved +- 首次发现:2026-09-01 +- 最近更新:2026-09-01 +- 影响面:星盘库保存/删除、合盘历史、会话置顶与归档、`chat_sessions` 列 +- 用户现象:云端保存失败仍提示已保存,刷新后记录消失;云端删除失败后刷新会复活;合盘历史按 id 并集,服务端删掉的记录会从本机回来;置顶和归档只活在这台浏览器里,换设备全部归零。 +- 触发条件:断网或接口失败时保存/删除其他人的星盘;云端删除合盘历史后刷新;在一台设备置顶或归档后再换设备打开。 +- 根因:星盘库和合盘历史同时写 localStorage 与云端,启动时又用云端替换或并集覆盖;置顶/归档只有 `jyotisha-session-controls` 本地键,没有账户数据。仓内曾用 `20260718100000_repair_missing_chart_profiles.sql` 与 `20260718101000_repair_missing_synastry_reports.sql` 修过同类双份真相。 +- 修复:星盘库与合盘历史只信云端列表;写失败保留表单或当页报告并明确报错,不再写本地副本。启动清除旧库/历史键。`chat_sessions` 增加 `pinned`、`archived_at`,置顶/归档走既有 PATCH;本地控制键一次性导入后删除。每日星语缓存与 `activeChartId` 不动。 +- 验证:合同测试锁保存/删除失败不再写本地、合盘不再并集、列表/详情带上两列、PATCH 元数据接受 `pinned`/`archived_at`、旧 `messages` PATCH 仍忽略。`npm run test:db` 重放加列迁移并验证 RLS。`./node_modules/.bin/tsc --noEmit`、前端测试与 `next build` 的 `/` `○ Static` 必须通过。 +- 防复发:不得把 `jyotisha_chart_library` / `jyotisha_synastry_history` / `jyotisha-session-controls` 写回作为权威存储。云端写失败不得再提示已保存到本地。不得把业务迁移复制进 `frontend/db/migrations`。列表 GET 仍不得带回 `messages`。 +- 相关记录:BUG-464、BUG-465 +- 复发自:无 +- 修复版本:待发布 diff --git a/frontend/src/app/api/sessions/[id]/route.ts b/frontend/src/app/api/sessions/[id]/route.ts index 39e5a91d..551416a1 100644 --- a/frontend/src/app/api/sessions/[id]/route.ts +++ b/frontend/src/app/api/sessions/[id]/route.ts @@ -14,7 +14,7 @@ import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit"; type RouteContext = { params: Promise<{ id: string }> }; -const sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at"; +const sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"; const sessionIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function payloadHasMessages(payload: unknown): payload is { messages: unknown } { diff --git a/frontend/src/app/api/sessions/route.ts b/frontend/src/app/api/sessions/route.ts index 7addf632..77c24b6d 100644 --- a/frontend/src/app/api/sessions/route.ts +++ b/frontend/src/app/api/sessions/route.ts @@ -4,7 +4,7 @@ import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; -const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at"; +const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"; export async function GET() { try { diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index f1407955..bb1121ec 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -106,7 +106,11 @@ import { membershipHref, } from "@/lib/membership"; import { nextActivityView, activityCompletedTrail, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view"; -import { chartLibrarySessionBranch } from "@/lib/chart-library-session"; +import { + chartLibraryFromCloudOthers, + chartLibraryOnCloudFailure, + chartLibrarySessionBranch, +} from "@/lib/chart-library-session"; import { emptyConsultationTimeline, reduceConsultationTimeline, @@ -249,6 +253,8 @@ type ChatSession = { chartProfileId: string | null; chartProfileName: string | null; chartProfileRole: "self" | "other" | null; + pinned: boolean; + archivedAt: string | null; messagesHydrated: boolean; }; @@ -448,6 +454,8 @@ function createSession( updatedAt: timestamp(), sessionType, rectificationCaseId: null, + pinned: false, + archivedAt: null, messagesHydrated: true, ...chartBinding, }; @@ -497,19 +505,62 @@ function selectedBirthPlace(profile: Profile): BirthPlace | null { }; } -function chartLibraryStorageKey(accountId: string) { - return `jyotisha_chart_library:${accountId}`; -} function activeChartStorageKey(accountId: string) { return `jyotisha_active_chart:${accountId}`; } -function synastryHistoryStorageKey(accountId: string) { - return `jyotisha_synastry_history:${accountId}`; -} function dailyStarlanguageStorageKey(accountId: string) { return `jyotisha_daily_starlanguage:${accountId}`; } +function sessionControlsStorageKey(accountId: string, kind: "pinned" | "archived") { + return `jyotisha-session-controls:${accountId}:${kind}`; +} + +function discardLegacyCloudMirrorKeys(accountId: string) { + localStorage.removeItem(`jyotisha_chart_library:${accountId}`); + localStorage.removeItem(`jyotisha_synastry_history:${accountId}`); +} + +function readLegacySessionControlIds(accountId: string, kind: "pinned" | "archived"): string[] { + try { + const parsed = JSON.parse(localStorage.getItem(sessionControlsStorageKey(accountId, kind)) || "null") as unknown; + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : []; + } catch { + return []; + } +} + +function clearLegacySessionControlKeys(accountId: string) { + localStorage.removeItem(sessionControlsStorageKey(accountId, "pinned")); + localStorage.removeItem(sessionControlsStorageKey(accountId, "archived")); +} + +function applyLegacySessionControls(accountId: string, sessions: ChatSession[]): ChatSession[] { + const pinnedKey = sessionControlsStorageKey(accountId, "pinned"); + const archivedKey = sessionControlsStorageKey(accountId, "archived"); + if (localStorage.getItem(pinnedKey) === null && localStorage.getItem(archivedKey) === null) { + return sessions; + } + const pinnedIds = new Set(readLegacySessionControlIds(accountId, "pinned")); + const archivedIds = new Set(readLegacySessionControlIds(accountId, "archived")); + const next = sessions.map((session) => ({ + ...session, + pinned: session.pinned || pinnedIds.has(session.id), + archivedAt: session.archivedAt || (archivedIds.has(session.id) ? new Date().toISOString() : null), + })); + void Promise.allSettled(next.flatMap((session, index) => { + const previous = sessions[index]; + if (!previous) return []; + const patch: { pinned?: boolean; archived_at?: string | null } = {}; + if (session.pinned !== previous.pinned) patch.pinned = session.pinned; + if (session.archivedAt !== previous.archivedAt) patch.archived_at = session.archivedAt; + if (patch.pinned === undefined && patch.archived_at === undefined) return []; + return [writeChatSession(session.id, patch, "update")]; + })); + clearLegacySessionControlKeys(accountId); + return next; +} + type StoredDailyStarlanguage = { readonly day: string; readonly fingerprint: string; @@ -574,34 +625,6 @@ function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) { return [buildSelfChartRecord(profile), ...others]; } -function readChartLibrary(accountId: string): ChartLibraryRecord[] { - try { - const parsed = JSON.parse(localStorage.getItem(chartLibraryStorageKey(accountId)) || "[]") as ChartLibraryRecord[]; - return Array.isArray(parsed) - ? parsed - .filter((record) => record?.id && record?.profile) - .map((record) => { - const relationship = record.role === "self" ? "self" : record.relationship || record.profile.chartRelationship || "other"; - return { ...record, profile: { ...record.profile, chartRelationship: relationship }, relationship }; - }) - : []; - } catch { - return []; - } -} -function readSynastryHistory(accountId: string): SynastryReportCard[] { - try { - const parsed = JSON.parse(localStorage.getItem(synastryHistoryStorageKey(accountId)) || "[]") as SynastryReportCard[]; - return Array.isArray(parsed) ? parsed.filter((record) => record?.id && record?.partnerName).slice(0, 10) : []; - } catch { - return []; - } -} - -function writeSynastryHistory(accountId: string, history: SynastryReportCard[]) { - localStorage.setItem(synastryHistoryStorageKey(accountId), JSON.stringify(history.slice(0, 10))); -} - function normalizeSynastryReportApiRecord(record: SynastryReportApiRecord): SynastryReportCard | null { if (!record.report || typeof record.report !== "object") return null; return { @@ -903,6 +926,7 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null chart_profile_role?: unknown; session_type?: unknown; updated_at?: unknown; + archived_at?: unknown; }; const messagesPresent = Object.prototype.hasOwnProperty.call(session, "messages"); const messages: Message[] = Array.isArray(session.messages) @@ -951,6 +975,12 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null chartProfileRole: session.chart_profile_role === "self" || session.chart_profile_role === "other" ? session.chart_profile_role : null, + pinned: session.pinned === true, + archivedAt: typeof session.archived_at === "string" && session.archived_at + ? session.archived_at + : typeof session.archivedAt === "string" && session.archivedAt + ? session.archivedAt + : null, updatedAt: typeof session.updatedAt === "number" ? session.updatedAt : typeof session.updated_at === "string" @@ -1286,8 +1316,6 @@ export default function Home() { const [accountError, setAccountError] = useState(""); const [signingOut, setSigningOut] = useState(false); const [sessions, setSessions] = useState([]); - const [pinnedSessionIds, setPinnedSessionIds] = useState([]); - const [archivedSessionIds, setArchivedSessionIds] = useState([]); const [showArchivedSessions, setShowArchivedSessions] = useState(false); const [sessionMenuId, setSessionMenuId] = useState(null); const [pendingSessionDeletion, setPendingSessionDeletion] = useState(null); @@ -1391,12 +1419,12 @@ export default function Home() { const rectificationSurfaceOpen = activeRectificationSession && activeSession.id === rectificationSessionId; const visibleSessions = sessions - .filter((session) => showArchivedSessions ? archivedSessionIds.includes(session.id) : !archivedSessionIds.includes(session.id)) + .filter((session) => showArchivedSessions ? Boolean(session.archivedAt) : !session.archivedAt) .filter((session) => session.sessionType === "birth_time_rectification" || session.messages.length > 0 || !session.messagesHydrated || session.id === activeSessionId) - .sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id))); + .sort((left, right) => Number(right.pinned) - Number(left.pinned)); const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : ""; const isLoading = pendingSessionId === activeSession?.id; const productEntrypointsDisabled = !hydrated @@ -1478,13 +1506,6 @@ export default function Home() { rectificationSessionId, ]); - useEffect(() => { - if (!hydrated || !accountId) return; - const prefix = `jyotisha-session-controls:${accountId}:`; - setPinnedSessionIds(JSON.parse(localStorage.getItem(`${prefix}pinned`) || "[]")); - setArchivedSessionIds(JSON.parse(localStorage.getItem(`${prefix}archived`) || "[]")); - }, [accountId, hydrated]); - useEffect(() => { if (!hydrated || !accountId) return; void (async () => { @@ -1499,13 +1520,6 @@ export default function Home() { })(); }, [accountId, hydrated]); - useEffect(() => { - if (!hydrated || !accountId) return; - const prefix = `jyotisha-session-controls:${accountId}:`; - localStorage.setItem(`${prefix}pinned`, JSON.stringify(pinnedSessionIds)); - localStorage.setItem(`${prefix}archived`, JSON.stringify(archivedSessionIds)); - }, [accountId, archivedSessionIds, hydrated, pinnedSessionIds]); - useEffect(() => { if (!hydrated || !accountId) return; setActiveChartId(localStorage.getItem(activeChartStorageKey(accountId)) || "self"); @@ -1523,37 +1537,47 @@ export default function Home() { const profileForLibrary: Profile = activeChartId === "self" ? profile : account ? readProfile(account.profile) : profile; if (branch === "hydrate-then-persist") { chartLibraryLoadedAccount.current = accountId; - setChartLibrary(upsertSelfChart(readChartLibrary(accountId), profileForLibrary)); - setSynastryHistory(readSynastryHistory(accountId)); + discardLegacyCloudMirrorKeys(accountId); + setChartLibrary(chartLibraryOnCloudFailure(profileForLibrary, upsertSelfChart)); + setSynastryHistory([]); void fetchCloudChartLibrary() .then((cloudLibrary) => { - setChartLibrary(() => { - const next = upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profileForLibrary); - localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); - return next; - }); + setChartLibrary(chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart)); }) .catch(() => { - // Cloud chart library is best-effort; local library remains usable. + setComposerNotice("星盘库暂时无法读取,请重试。", { + label: "重试", + onClick: () => { + void fetchCloudChartLibrary() + .then((cloudLibrary) => { + setChartLibrary(chartLibraryFromCloudOthers(cloudLibrary, profileForLibrary, upsertSelfChart)); + }) + .catch(() => { + setComposerNotice("星盘库暂时无法读取,请重试。"); + }); + }, + }); }); void fetchCloudSynastryHistory() .then((cloudHistory) => { - setSynastryHistory((current) => { - const byId = new Map([...current, ...cloudHistory].map((record) => [record.id, record] as const)); - const next = [...byId.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10); - writeSynastryHistory(accountId, next); - return next; - }); + setSynastryHistory([...cloudHistory].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10)); }) .catch(() => { - // Cloud synastry history is best-effort; local history remains usable. + setComposerNotice("合盘历史暂时无法读取,请重试。", { + label: "重试", + onClick: () => { + void fetchCloudSynastryHistory() + .then((cloudHistory) => { + setSynastryHistory([...cloudHistory].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10)); + }) + .catch(() => { + setComposerNotice("合盘历史暂时无法读取,请重试。"); + }); + }, + }); }); } - setChartLibrary((current) => { - const next = upsertSelfChart(current, profileForLibrary); - localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); - return next; - }); + setChartLibrary((current) => upsertSelfChart(current, profileForLibrary)); }, [account, accountId, activeChartId, profile]); useEffect(() => { @@ -1786,6 +1810,8 @@ export default function Home() { chartProfileId: "self", chartProfileName: previewProfile.name.trim() || "我", chartProfileRole: "self", + pinned: false, + archivedAt: null, messagesHydrated: true, }; setAccount({ @@ -1857,6 +1883,7 @@ export default function Home() { } nextSessions = [initialSession]; } + nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions); let reservedConsultation: ConsultationStatus | null = null; const storedPending: StoredPendingConsultation | null = readStoredPendingConsultation( @@ -2377,8 +2404,6 @@ export default function Home() { const nextSessions = sessions.filter((item) => item.id !== session.id); setSessions(nextSessions); setBirthTimeConsultationConsent((current) => clearBirthTimeConsultationConsent(current, session.id)); - setPinnedSessionIds((current) => current.filter((id) => id !== session.id)); - setArchivedSessionIds((current) => current.filter((id) => id !== session.id)); if (activeSessionId === session.id) { const fallbackId = nextSessions[0]?.id ?? ""; setActiveSessionId(fallbackId); @@ -2395,18 +2420,37 @@ export default function Home() { } function togglePinnedSession(sessionId: string) { - setPinnedSessionIds((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [sessionId, ...current]); + const session = sessions.find((item) => item.id === sessionId); + if (!session) return; + const nextPinned = !session.pinned; + updateSession(sessionId, (current) => ({ ...current, pinned: nextPinned })); + void writeChatSession(sessionId, { pinned: nextPinned }, "update").catch((caught) => { + updateSession(sessionId, (current) => ({ ...current, pinned: session.pinned })); + setComposerNotice(caught instanceof Error ? caught.message : "置顶同步失败"); + }); } function toggleArchivedSession(sessionId: string) { - const restoring = archivedSessionIds.includes(sessionId); - setArchivedSessionIds((current) => restoring ? current.filter((id) => id !== sessionId) : [sessionId, ...current]); + const session = sessions.find((item) => item.id === sessionId); + if (!session) return; + const restoring = Boolean(session.archivedAt); + const previousActiveId = activeSessionId; + const nextArchivedAt = restoring ? null : new Date().toISOString(); + updateSession(sessionId, (current) => ({ ...current, archivedAt: nextArchivedAt })); if (!restoring && activeSessionId === sessionId) { - const fallbackId = visibleSessions.find((session) => session.id !== sessionId)?.id ?? ""; + const fallbackId = visibleSessions.find((item) => item.id !== sessionId)?.id ?? ""; setActiveSessionId(fallbackId); if (!uiPreview.current) writeSessionUrl(fallbackId || null, "replace"); } setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。"); + void writeChatSession(sessionId, { archived_at: nextArchivedAt }, "update").catch((caught) => { + updateSession(sessionId, (current) => ({ ...current, archivedAt: session.archivedAt })); + if (!restoring && previousActiveId === sessionId) { + setActiveSessionId(previousActiveId); + if (!uiPreview.current) writeSessionUrl(previousActiveId || null, "replace"); + } + setComposerNotice(caught instanceof Error ? caught.message : "归档同步失败"); + }); } async function shareSession(session: ChatSession) { @@ -2680,43 +2724,32 @@ export default function Home() { return; } if (!accountId) return; - let record: ChartLibraryRecord = { + const record: ChartLibraryRecord = { id: editingChartId || globalThis.crypto.randomUUID(), role: "other", profile: nextProfile, relationship: otherChartRelationship, updatedAt: timestamp(), }; - let cloudSaved = false; try { - if (editingChartId) { - record = await updateCloudChartProfile(record); - } else { - record = await saveCloudChartProfile(record); - cloudSaved = true; - } - cloudSaved = true; - } catch { - setProfileNotice("已保存到本地星盘库;云端同步失败,稍后会继续使用本地记录。"); + const saved = editingChartId + ? await updateCloudChartProfile(record) + : await saveCloudChartProfile(record); + const selfProfile = account ? readProfile(account.profile) : profile; + setChartLibrary((current) => { + const others = editingChartId + ? current.map((item) => item.id === saved.id ? saved : item) + : [...current, saved]; + return upsertSelfChart(others, selfProfile); + }); + setOtherProfileDraft(emptyProfile); + setOtherChartRelationship("other"); + setEditingChartId(null); setAccountError(""); - } - const selfProfile = account ? readProfile(account.profile) : profile; - setChartLibrary((current) => { - const others = editingChartId - ? current.map((item) => item.id === record.id ? record : item) - : [...current, record]; - const next = upsertSelfChart(others, selfProfile); - localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); - return next; - }); - setOtherProfileDraft(emptyProfile); - setOtherChartRelationship("other"); - setEditingChartId(null); - setAccountError(""); - if (cloudSaved) { setProfileNotice(editingChartId ? "已更新其他人的星盘资料。" : "已保存到云端星盘库。请选择关系类型后点击“用于合盘”。"); - } else { - setProfileNotice("已保存到本地星盘库。"); + } catch { + setProfileNotice("保存失败,请重试"); + setAccountError(""); } } @@ -2731,12 +2764,12 @@ export default function Home() { async function deleteOtherChart(recordId: string) { if (!accountId || !window.confirm("确定删除这份其他人的星盘资料吗?删除后无法恢复。")) return; - let cloudDeleted = false; try { await deleteCloudChartProfile(recordId); - cloudDeleted = true; } catch { + setProfileNotice("删除失败,请重试"); setAccountError(""); + return; } setChartLibrary((current) => { const next = current.filter((record) => record.id !== recordId || record.role === "self"); @@ -2744,13 +2777,10 @@ export default function Home() { setActiveChartId("self"); localStorage.setItem(activeChartStorageKey(accountId), "self"); } - localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); return next; }); setAccountError(""); - setProfileNotice(cloudDeleted - ? "已从云端星盘库删除。" - : "已从本地星盘库删除;云端同步失败,稍后云端可能仍显示旧记录。"); + setProfileNotice("已从云端星盘库删除。"); } function makeDefaultChart(record: ChartLibraryRecord) { @@ -3073,6 +3103,7 @@ export default function Home() { // Merge the server-created session into the local list. The browser // never generates a Case id; it only mirrors the returned binding. + const existing = sessions.find((session) => session.id === opened.sessionId); const merged: ChatSession = { id: opened.sessionId, title: resolveSessionTitle("生时校正", undefined, { @@ -3085,6 +3116,8 @@ export default function Home() { updatedAt: timestamp(), sessionType: "birth_time_rectification", rectificationCaseId: opened.caseId, + pinned: existing?.pinned ?? false, + archivedAt: existing?.archivedAt ?? null, messagesHydrated: true, ...chartSnapshotForSession(activeChartId, chartLibrary, profile), }; @@ -3197,20 +3230,20 @@ export default function Home() { createdAt: Date.now(), }; let savedReportCard = reportCard; + let historyPersisted = !accountId; if (accountId) { try { savedReportCard = await saveCloudSynastryReport(reportCard); + historyPersisted = true; } catch { - // Local history remains the fallback when cloud persistence is unavailable. + historyPersisted = false; } } setSynastryReportCard(savedReportCard); - if (accountId) { - setSynastryHistory((current) => { - const next = [savedReportCard, ...current.filter((item) => item.id !== savedReportCard.id)].slice(0, 10); - writeSynastryHistory(accountId, next); - return next; - }); + if (accountId && historyPersisted) { + setSynastryHistory((current) => ( + [savedReportCard, ...current.filter((item) => item.id !== savedReportCard.id)].slice(0, 10) + )); } chooseSuggestedQuestion([ baseQuestion, @@ -3218,6 +3251,9 @@ export default function Home() { evidenceSummary, payload.relationshipReport?.headline ? `结构化摘要:${payload.relationshipReport.headline}` : "", ].join("\n"), relationshipType === "business" ? "career" : "marriage"); + if (accountId && !historyPersisted) { + setComposerNotice("未能存入历史"); + } } else { chooseSuggestedQuestion(baseQuestion, relationshipType === "business" ? "career" : "marriage"); setComposerNotice(response.status === 404 @@ -4108,8 +4144,8 @@ export default function Home() { const sidebarSessions = visibleSessions.map((session) => ({ id: session.id, title: sessionSidebarTitle(session, chartLibrary), - pinned: pinnedSessionIds.includes(session.id), - archived: archivedSessionIds.includes(session.id), + pinned: session.pinned, + archived: Boolean(session.archivedAt), })); const sidebarCharts = (chartLibrary.length > 0 ? chartLibrary @@ -4354,7 +4390,7 @@ export default function Home() { newChatDisabled={!hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending} creatingSession={creatingSession} sessionControls={{ - archivedCount: archivedSessionIds.length, + archivedCount: sessions.filter((session) => session.archivedAt).length, showingArchived: showArchivedSessions, menuSessionId: sessionMenuId, disabled: Boolean(pendingSessionId) || cancellationPending, diff --git a/frontend/src/lib/chart-library-session.ts b/frontend/src/lib/chart-library-session.ts index b1c74777..90a360f7 100644 --- a/frontend/src/lib/chart-library-session.ts +++ b/frontend/src/lib/chart-library-session.ts @@ -9,14 +9,17 @@ export function chartLibrarySessionBranch( return "persist-self"; } -export function chartLibraryFromCloudOthers( +export function chartLibraryFromCloudOthers( cloudLibrary: readonly T[], - profile: unknown, - upsertSelfChart: (library: T[], profile: unknown) => T[], + profile: P, + upsertSelfChart: (library: T[], profile: P) => T[], ): T[] { return upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profile); } -export function keepLocalChartLibraryOnCloudFailure(localLibrary: T): T { - return localLibrary; +export function chartLibraryOnCloudFailure( + profile: P, + upsertSelfChart: (library: T[], profile: P) => T[], +): T[] { + return upsertSelfChart([], profile); } diff --git a/frontend/src/lib/chat-notice.ts b/frontend/src/lib/chat-notice.ts index 719cadd7..0854869b 100644 --- a/frontend/src/lib/chat-notice.ts +++ b/frontend/src/lib/chat-notice.ts @@ -10,7 +10,7 @@ export type ChatNoticeAction = Readonly<{ }>; const ongoingNotice = /正在|请稍候|请先|联网后/; -const failedNotice = /失败|无法|不可用|未找到|已写满|已被删除/; +const failedNotice = /失败|无法|不可用|未能|未找到|已写满|已被删除/; const settledNotice = /^已|^回答已恢复/; export function noticeTone(message: string): NoticeTone { diff --git a/frontend/src/lib/chat-session-write-contract.ts b/frontend/src/lib/chat-session-write-contract.ts index 760c937e..b912b14b 100644 --- a/frontend/src/lib/chat-session-write-contract.ts +++ b/frontend/src/lib/chat-session-write-contract.ts @@ -37,6 +37,8 @@ export const chatSessionMetadataPatchSchema = z.object({ title: z.string().trim().min(1).max(160).optional(), theme: consultationDomainSchema.optional(), model_id: z.string().trim().min(1).max(64).optional(), + pinned: z.boolean().optional(), + archived_at: z.string().datetime().nullable().optional(), ...chartBindingSchema, }).strict().refine( (value) => Object.values(value).some((field) => field !== undefined), @@ -123,6 +125,8 @@ export function extractChatSessionMetadataPatch(payload: unknown): unknown { if ("chart_profile_id" in record) patch.chart_profile_id = record.chart_profile_id; if ("chart_profile_name" in record) patch.chart_profile_name = record.chart_profile_name; if ("chart_profile_role" in record) patch.chart_profile_role = record.chart_profile_role; + if ("pinned" in record) patch.pinned = record.pinned; + if ("archived_at" in record) patch.archived_at = record.archived_at; return patch; } @@ -130,6 +134,8 @@ export type ChatSessionMetadataPatch = Readonly<{ title?: string; theme?: ConsultationDomain; model_id?: string; + pinned?: boolean; + archived_at?: string | null; chart_profile_id?: string | null; chart_profile_name?: string | null; chart_profile_role?: "self" | "other" | null; diff --git a/frontend/supabase/migrations/20260901020000_chat_session_pin_archive.sql b/frontend/supabase/migrations/20260901020000_chat_session_pin_archive.sql new file mode 100644 index 00000000..e5c36054 --- /dev/null +++ b/frontend/supabase/migrations/20260901020000_chat_session_pin_archive.sql @@ -0,0 +1,10 @@ +begin; + +alter table public.chat_sessions + add column if not exists pinned boolean not null default false, + add column if not exists archived_at timestamptz null; + +grant update (pinned, archived_at) + on table public.chat_sessions to authenticated; + +commit; diff --git a/frontend/tests/chart-library-other-profile.test.ts b/frontend/tests/chart-library-other-profile.test.ts index a36e75e2..0e360053 100644 --- a/frontend/tests/chart-library-other-profile.test.ts +++ b/frontend/tests/chart-library-other-profile.test.ts @@ -11,16 +11,20 @@ test("other chart saves do not require the owner's rectification state", () => { assert.doesNotMatch(source, /saveOtherChart[\s\S]{0,500}missingProfileStep\(nextProfile\)/); }); -test("other chart save falls back to local library when cloud sync fails", () => { - assert.match(source, /let cloudSaved = false/); - assert.match(source, /record = await saveCloudChartProfile\(record\);[\s\S]{0,120}cloudSaved = true/); - assert.match(source, /catch\s*\{[\s\S]{0,300}已保存到本地星盘库;云端同步失败/); - assert.match(source, /localStorage\.setItem\(chartLibraryStorageKey\(accountId\), JSON\.stringify\(next\)\)/); - assert.match(source, /if \(cloudSaved\)[\s\S]{0,180}已保存到云端星盘库/); - assert.match(source, /async function deleteOtherChart[\s\S]{0,500}let cloudDeleted = false/); +test("other chart save fails closed when cloud sync fails", () => { + // Former values that locked the dual-truth fallback: + // `let cloudSaved = false`, catch copy "已保存到本地星盘库;云端同步失败", + // `localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next))`, + // delete copy "已从本地星盘库删除;云端同步失败". Cloud write failure is now failure. + assert.doesNotMatch(source, /let cloudSaved = false/); + assert.match(source, /await saveCloudChartProfile\(record\)/); + assert.match(source, /async function saveOtherChart[\s\S]{0,1600}catch \{[\s\S]{0,180}保存失败,请重试/); + assert.doesNotMatch(source, /localStorage\.setItem\(chartLibraryStorageKey/); + assert.doesNotMatch(source, /已保存到本地星盘库/); + assert.match(source, /已保存到云端星盘库/); assert.match(source, /async function deleteOtherChart[\s\S]{0,500}await deleteCloudChartProfile\(recordId\)/); - assert.match(source, /async function deleteOtherChart[\s\S]{0,900}已从本地星盘库删除;云端同步失败/); - assert.doesNotMatch(source, /deleteOtherChart[\s\S]{0,500}return;\s*}\s*setChartLibrary/); + assert.match(source, /async function deleteOtherChart[\s\S]{0,700}catch \{[\s\S]{0,160}删除失败,请重试[\s\S]{0,80}return;/); + assert.doesNotMatch(source, /已从本地星盘库删除/); }); test("adding another chart waits for the user to choose a relationship type", () => { @@ -33,7 +37,7 @@ test("adding another chart waits for the user to choose a relationship type", () test("a successful cloud read replaces stale local other charts", () => { assert.match( source, - /fetchCloudChartLibrary\(\)[\s\S]{0,800}upsertSelfChart\(cloudLibrary\.filter\(\(record\) => record\.role !== "self"\), profileForLibrary\)/, + /fetchCloudChartLibrary\(\)[\s\S]{0,800}chartLibraryFromCloudOthers\(cloudLibrary, profileForLibrary, upsertSelfChart\)/, ); assert.doesNotMatch(source, /fetchCloudChartLibrary\(\)[\s\S]{0,800}new Map\(\[[\s\S]{0,500}current\.filter\(\(record\) => record\.role === "other"\)/); }); @@ -83,6 +87,15 @@ test("relationship intent selects domain-specific evidence instead of treating e }); +test("synastry history is replaced by the cloud list and save failures stay in page memory", () => { + // Former value: `new Map([...current, ...cloudHistory])` unioned local+cloud + // and `writeSynastryHistory(accountId, next)` on cloud save failure. + assert.doesNotMatch(source, /new Map\(\[\.\.\.current, \.\.\.cloudHistory\]/); + assert.match(source, /未能存入历史/); + assert.doesNotMatch(source, /writeSynastryHistory\(/); + assert.match(source, /discardLegacyCloudMirrorKeys\(/); +}); + test("current chart selection is local and does not overwrite the owner's profile", () => { assert.match(source, /activeChartStorageKey/); assert.match(source, /localStorage\.setItem\(activeChartStorageKey\(accountId\), record\.id\)/); diff --git a/frontend/tests/chart-library-session.test.ts b/frontend/tests/chart-library-session.test.ts index 5ce04b47..3ad333bc 100644 --- a/frontend/tests/chart-library-session.test.ts +++ b/frontend/tests/chart-library-session.test.ts @@ -3,8 +3,8 @@ import test from "node:test"; import { chartLibraryFromCloudOthers, + chartLibraryOnCloudFailure, chartLibrarySessionBranch, - keepLocalChartLibraryOnCloudFailure, } from "../src/lib/chart-library-session.ts"; type RecordShape = { id: string; role: "self" | "other" }; @@ -44,7 +44,12 @@ test("a successful cloud read keeps only non-self records before upserting self" ]); }); -test("a failed cloud read leaves the local library in place", () => { +test("a failed cloud read keeps only the profile-derived self chart", () => { + // Former value: keepLocalChartLibraryOnCloudFailure(local) === local. + // Local other-charts are no longer a fallback when the cloud read fails. const local: RecordShape[] = [{ id: "self", role: "self" }, { id: "other-1", role: "other" }]; - assert.equal(keepLocalChartLibraryOnCloudFailure(local), local); + assert.notDeepEqual(chartLibraryOnCloudFailure({ name: "self" }, upsertSelf), local); + assert.deepEqual(chartLibraryOnCloudFailure({ name: "self" }, upsertSelf), [ + { id: "self", role: "self" }, + ]); }); diff --git a/frontend/tests/chat-notice-and-scroll-contract.test.ts b/frontend/tests/chat-notice-and-scroll-contract.test.ts index 02166455..bbfa3f6b 100644 --- a/frontend/tests/chat-notice-and-scroll-contract.test.ts +++ b/frontend/tests/chat-notice-and-scroll-contract.test.ts @@ -62,6 +62,8 @@ test("assigns notice severity by message intent", () => { assert.equal(noticeTone("重命名同步失败"), "error"); assert.equal(noticeTone("模型服务暂时不可用,当前无法发送问题。"), "error"); assert.equal(noticeTone("后台未找到本次咨询请求,已停止恢复,请重新发送。"), "error"); + assert.equal(noticeTone("未能存入历史"), "error"); + assert.equal(noticeTone("保存失败,请重试"), "error"); }); test("anchors the streaming scroll instead of following every token", () => { diff --git a/frontend/tests/chat-session-authority.test.ts b/frontend/tests/chat-session-authority.test.ts index 10f5f673..ba83130a 100644 --- a/frontend/tests/chat-session-authority.test.ts +++ b/frontend/tests/chat-session-authority.test.ts @@ -13,15 +13,17 @@ const sql = readFileSync( const sendSource = page.slice(page.indexOf(" async function send("), page.indexOf("\n\n consultationReplay.current")); test("session list GET omits messages while detail GET returns them", () => { + // Former list/detail column strings ended at updated_at; pinned and archived_at + // were added when those flags moved off localStorage. assert.match( listRoute, - /SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at"/, + /SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"/, ); assert.doesNotMatch(listRoute, /select\(SESSION_LIST_COLUMNS\)[\s\S]*messages/); assert.match(itemRoute, /export async function GET/); assert.match( itemRoute, - /sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at"/, + /sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"/, ); assert.match(page, /async function fetchSessionDetail\(/); assert.match(page, /async function ensureSessionMessages\(/); @@ -58,3 +60,20 @@ test("PATCH compatibility accepts and ignores a legacy messages write", () => { assert.match(itemRoute, /return NextResponse\.json\(\{ ok: true \}\)/); assert.doesNotMatch(itemRoute, /\.update\(\{[\s\S]*messages:/); }); + +test("pin and archive flags are session metadata, not localStorage", () => { + const pinSql = readFileSync( + new URL("../supabase/migrations/20260901020000_chat_session_pin_archive.sql", import.meta.url), + "utf8", + ); + assert.match(pinSql, /add column if not exists pinned boolean not null default false/); + assert.match(pinSql, /add column if not exists archived_at timestamptz null/); + assert.match(pinSql, /grant update \(pinned, archived_at\)/); + assert.match(page, /function applyLegacySessionControls\(/); + assert.match(page, /writeChatSession\(sessionId, \{ pinned: nextPinned \}, "update"\)/); + assert.match(page, /writeChatSession\(sessionId, \{ archived_at: nextArchivedAt \}, "update"\)/); + assert.match(page, /discardLegacyCloudMirrorKeys\(/); + assert.doesNotMatch(page, /setPinnedSessionIds/); + assert.doesNotMatch(page, /localStorage\.setItem\(`\$\{prefix\}pinned`/); + assert.doesNotMatch(page, /writeSynastryHistory\(/); +}); diff --git a/frontend/tests/chat-session-write.test.ts b/frontend/tests/chat-session-write.test.ts index 0f5e2222..f06d99a4 100644 --- a/frontend/tests/chat-session-write.test.ts +++ b/frontend/tests/chat-session-write.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { chatSessionCreateSchema, chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts"; +import { chatSessionCreateSchema, chatSessionMetadataPatchSchema, chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts"; const sessionId = "11111111-1111-4111-8111-111111111111"; const values = { @@ -111,6 +111,15 @@ test("chat session schema keeps structured thinking sections on assistant messag assert.equal(parsed.messages[0]?.thinkingSections?.[0]?.heading, "统一参数与原始结构"); }); +test("metadata patch accepts pin and archive fields without a transcript", () => { + assert.deepEqual(chatSessionMetadataPatchSchema.parse({ pinned: true }), { pinned: true }); + assert.deepEqual( + chatSessionMetadataPatchSchema.parse({ archived_at: "2026-09-01T00:00:00.000Z" }), + { archived_at: "2026-09-01T00:00:00.000Z" }, + ); + assert.deepEqual(chatSessionMetadataPatchSchema.parse({ archived_at: null }), { archived_at: null }); +}); + test("chat session writes use same-origin API instead of browser-to-Supabase requests", async () => { const calls: Array<{ url: string; init?: RequestInit }> = []; await writeChatSession(sessionId, metadataPatch, "update", async (url, init) => { diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index 50bde32c..e105cc02 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import test from "node:test"; @@ -18,6 +18,10 @@ const acceptedExactFamilyMigration = readFileSync( new URL("../supabase/migrations/20260816010000_accept_exact_family_birth_times.sql", import.meta.url), "utf8", ); +const pinArchiveMigration = readFileSync( + new URL("../supabase/migrations/20260901020000_chat_session_pin_archive.sql", import.meta.url), + "utf8", +); function rpcError(error: unknown): string { if (!error || typeof error !== "object") return ""; @@ -87,6 +91,33 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic assert.match(migration.stdout, /applied 20260824030000_rectification_turn_origin\.sql/); assert.match(migration.stdout, /applied 20260831020000_feature_pricing_admin_runtime_read_policy\.sql/); assert.match(migration.stdout, /applied 20260901010000_append_consultation_question\.sql/); + assert.match(migration.stdout, /applied 20260901020000_chat_session_pin_archive\.sql/); + assert.equal( + existsSync(fileURLToPath(new URL("../db/migrations/20260901020000_chat_session_pin_archive.sql", import.meta.url))), + false, + "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", + ); + fixture.psql(pinArchiveMigration); + assert.equal( + fixture.psql(` + select is_nullable || ':' || data_type + from information_schema.columns + where table_schema = 'public' + and table_name = 'chat_sessions' + and column_name = 'pinned' + `), + "NO:boolean", + ); + assert.equal( + fixture.psql(` + select is_nullable || ':' || data_type + from information_schema.columns + where table_schema = 'public' + and table_name = 'chat_sessions' + and column_name = 'archived_at' + `), + "YES:timestamp with time zone", + ); assert.equal( fixture.psql(` @@ -664,6 +695,63 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic assert.equal(inserted.error, null); assert.deepEqual(inserted.data, { id: sessionId }); + const pinDefaults = await local + .from("chat_sessions") + .select("pinned,archived_at") + .eq("id", sessionId) + .single(); + assert.equal(pinDefaults.error, null); + assert.deepEqual(pinDefaults.data, { pinned: false, archived_at: null }); + + const ownerPin = await local + .from("chat_sessions") + .update({ pinned: true, archived_at: "2026-09-01T00:00:00.000Z" }) + .eq("id", sessionId) + .select("pinned,archived_at") + .single(); + assert.equal(ownerPin.error, null); + assert.equal((ownerPin.data as { pinned: boolean }).pinned, true); + assert.ok((ownerPin.data as { archived_at: string | null }).archived_at); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + ` + insert into identity.users (name, email, email_verified, email_verified_at) + values ('Pin Archive Other', 'pin-archive-other@example.com', true, now()) + `, + ); + const otherUserId = fixture.psql( + "select id from identity.users where email = 'pin-archive-other@example.com'", + ); + const other = createLocalPostgresDataClient( + fixture.connectionUrl("app_runtime", "app-runtime-test-password"), + { id: otherUserId, email: "pin-archive-other@example.com" }, + ); + const stolen = await other + .from("chat_sessions") + .update({ pinned: false, archived_at: null }) + .eq("id", sessionId) + .select("id"); + assert.equal((Array.isArray(stolen.data) ? stolen.data : []).length, 0); + assert.equal( + fixture.psql(`select pinned from public.chat_sessions where id = '${sessionId}'`), + "t", + ); + assert.equal( + fixture.psql(`select archived_at is not null from public.chat_sessions where id = '${sessionId}'`), + "t", + ); + + const ownerRestore = await local + .from("chat_sessions") + .update({ pinned: false, archived_at: null }) + .eq("id", sessionId) + .select("pinned,archived_at") + .single(); + assert.equal(ownerRestore.error, null); + assert.deepEqual(ownerRestore.data, { pinned: false, archived_at: null }); + const beforeTomorrow = await local .from("chat_sessions") .select("id") diff --git a/progress.md b/progress.md index 1a175f03..80909aaf 100644 --- a/progress.md +++ b/progress.md @@ -1104,3 +1104,23 @@ - `./node_modules/.bin/tsc --noEmit` 通过。 - `npm test`:**2424 通过 / fail=0 / skipped=0**(含数据库测试;本轮未改库)。 - 实测:无登录态,浏览器验收(刷新仍在 A、粘贴 `/?c=`、A→B→C 后退、删除死链、流式中切走再 back、401 回跳)未做,由合同测试覆盖。浅色/深色:新文案「该对话不存在或已被删除」走既有 sonner toast,`已被删除` 命中 `failedNotice` 用 error 语气,颜色仍是 `--color-canvas` / `--color-ink`。 + +## 2026-09-01 - TASK-cloud-truth-convergence:星盘库 / 合盘历史 / 置顶归档收归云端 + +- 星盘库启动只读 `fetchCloudChartLibrary`(self 仍由 profile 重建),合盘历史用云端列表整体替换。`jyotisha_chart_library:*` 与 `jyotisha_synastry_history:*` 停读写,启动 `removeItem`。云端读失败给出重试,不再静默用本地副本。 +- 保存/删除星盘:云端成功才改 state。失败保留表单或列表行,提示「保存失败,请重试」/「删除失败,请重试」,不再有 `cloudSaved` 或「已保存到本地」。合盘保存失败报告留在当页,提示「未能存入历史」,不写本地历史。 +- `chat_sessions` 增加 `pinned boolean not null default false`、`archived_at timestamptz null`(只加列)。列表/详情 SELECT 带上这两列;置顶/归档走既有 PATCH,乐观更新,失败回滚。本地 `jyotisha-session-controls` 一次性导入后删键。每日星语缓存与 `activeChartId` 未动。 +- 迁移额外 `grant update (pinned, archived_at)`:原表 update 是列级授权,不加新列 grant 则 PATCH 会失败。未改写既有 grant,也未复制进 `frontend/db/migrations`。 +- 断言例外(锁住的正是本轮要修的缺陷): + - `chart-library-other-profile.test.ts`:原值 `let cloudSaved = false`、catch 文案「已保存到本地星盘库;云端同步失败」、`localStorage.setItem(chartLibraryStorageKey…)`、删除「已从本地星盘库删除」。现为失败即失败。 + - `chart-library-session.test.ts`:原值 `keepLocalChartLibraryOnCloudFailure(local) === local`。现为只保留 profile 重建的 self。 + - `tests/test_supabase_user_data_contract.py`:原值页面必须含 `localStorage.setItem(chartLibraryStorageKey…)`、`chartLibraryStorageKey`、`synastryHistoryStorageKey`、`Cloud chart library is best-effort`、`writeSynastryHistory(accountId, next)`。 + - `chat-session-authority.test.ts`:列表/详情列字符串原在 `updated_at` 结束;现含 `pinned,archived_at`。列表仍不得带 `messages`。 +- `next build`:因本 worktree 的 `node_modules` 是指向其他 worktree 的符号链接,默认 Turbopack 拒绝;改用 `next build --webpack`。路由表仍是 `┌ ○ /`(Static)。`/login` 仍是 `ƒ`。 +- 本地验证: + - `./node_modules/.bin/tsc --noEmit` 通过。 + - 非数据库前端测试:**2393 通过 / fail=0 / skipped=0**。 + - `npm run test:db`:**34 通过 / fail=0 / skipped=0**。第二次整组为干净通过;第一次整组里 `database-admin-account-reset` 在 Docker 刚起来时 migration status=1,单测重跑即过。业务库测试 stdout 含 `applied 20260901020000_chat_session_pin_archive.sql`,幂等重放有 `column "pinned"/"archived_at" already exists, skipping`。 + - 合计 **2427 ≥ 2424**。 +- 实测:无登录态,未做浏览器里断网保存、云端删除后刷新、双 profile 置顶一致性、localStorage 键审计。由合同测试覆盖。浅色/深色:新文案「保存失败,请重试」「未能存入历史」「星盘库暂时无法读取,请重试。」走既有 sonner,`失败`/`未能`/`无法` 命中 `failedNotice` 用 error 语气,颜色仍是 `--color-canvas` / `--color-ink`。 +- 未提交、未推送。 diff --git a/tests/test_supabase_user_data_contract.py b/tests/test_supabase_user_data_contract.py index 7fd17a23..ec93afe5 100644 --- a/tests/test_supabase_user_data_contract.py +++ b/tests/test_supabase_user_data_contract.py @@ -126,7 +126,9 @@ def test_chat_page_uses_authenticated_cloud_persistence() -> None: assert "系统正在以账户记录为准同步点数" in source assert "回答中途断开,已保留现有内容,本次已计费。" not in source assert "本次已开始生成并计费" not in source - assert 'localStorage.setItem(chartLibraryStorageKey(accountId)' in source + # Former value: `localStorage.setItem(chartLibraryStorageKey(accountId)` + # wrote a local library copy on cloud save failure. + assert "localStorage.setItem(chartLibraryStorageKey(accountId)" not in source assert 'localStorage.setItem("chat_sessions"' not in source @@ -180,19 +182,24 @@ def test_chart_profile_library_has_cloud_table_api_and_local_fallback() -> None: "draftSynastryQuestionFromChart", "synastryReportCard", "synastryHistory", - "synastryHistoryStorageKey", + "discardLegacyCloudMirrorKeys", "synastry-report-card", "synastry-history-list", 'fetch("/api/synastry"', "Ashtakoot", - "chartLibraryStorageKey", - "Cloud chart library is best-effort", + "jyotisha_chart_library", + "保存失败,请重试", "星盘库", "添加其他星盘", "用于合盘", "设为默认", ): assert token in page + # Former values: synastryHistoryStorageKey, chartLibraryStorageKey, + # and the comment "Cloud chart library is best-effort". + assert "synastryHistoryStorageKey" not in page + assert "chartLibraryStorageKey" not in page + assert "Cloud chart library is best-effort" not in page assert "ayanam-profile" not in page assert "ayanam-sessions" not in page @@ -251,11 +258,14 @@ def test_synastry_reports_are_cloud_persisted_per_user() -> None: "fetchCloudSynastryHistory", "saveCloudSynastryReport", 'fetch("/api/synastry-reports"', - "writeSynastryHistory(accountId, next)", + "未能存入历史", "cloud_synastry_history_unavailable", "cloud_synastry_report_save_failed", ): assert token in page + # Former value: writeSynastryHistory(accountId, next) wrote a local + # history copy when cloud save failed. + assert "writeSynastryHistory(accountId, next)" not in page def test_consultation_credit_lifecycle_is_idempotent_and_server_only() -> None: