fix(chat): make cloud the only truth for charts, synastry, and pin/archive
Local fallbacks were creating fake saves and resurrecting deleted rows. Pin and archive now live on chat_sessions so they follow the account. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+159
-123
@@ -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<ChatSession[]>([]);
|
||||
const [pinnedSessionIds, setPinnedSessionIds] = useState<string[]>([]);
|
||||
const [archivedSessionIds, setArchivedSessionIds] = useState<string[]>([]);
|
||||
const [showArchivedSessions, setShowArchivedSessions] = useState(false);
|
||||
const [sessionMenuId, setSessionMenuId] = useState<string | null>(null);
|
||||
const [pendingSessionDeletion, setPendingSessionDeletion] = useState<ChatSession | null>(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,
|
||||
|
||||
@@ -9,14 +9,17 @@ export function chartLibrarySessionBranch(
|
||||
return "persist-self";
|
||||
}
|
||||
|
||||
export function chartLibraryFromCloudOthers<T extends { role: "self" | "other" }>(
|
||||
export function chartLibraryFromCloudOthers<T extends { role: "self" | "other" }, P>(
|
||||
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<T>(localLibrary: T): T {
|
||||
return localLibrary;
|
||||
export function chartLibraryOnCloudFailure<T, P>(
|
||||
profile: P,
|
||||
upsertSelfChart: (library: T[], profile: P) => T[],
|
||||
): T[] {
|
||||
return upsertSelfChart([], profile);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export type ChatNoticeAction = Readonly<{
|
||||
}>;
|
||||
|
||||
const ongoingNotice = /正在|请稍候|请先|联网后/;
|
||||
const failedNotice = /失败|无法|不可用|未找到|已写满|已被删除/;
|
||||
const failedNotice = /失败|无法|不可用|未能|未找到|已写满|已被删除/;
|
||||
const settledNotice = /^已|^回答已恢复/;
|
||||
|
||||
export function noticeTone(message: string): NoticeTone {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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\)/);
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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\(/);
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
+20
@@ -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`。
|
||||
- 未提交、未推送。
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user