feat(staging): switch to local postgres

This commit is contained in:
Jesse_Chen
2026-07-22 11:04:36 +08:00
parent f5c57efeba
commit a1b8eaec36
30 changed files with 1011 additions and 141 deletions
+2
View File
@@ -3,6 +3,8 @@ import { redirect } from "next/navigation";
import { isAdminEmail } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export default async function AdminLayout({ children }: { children: ReactNode }) {
if (process.env.NODE_ENV === "development" && process.env.ENABLE_ADMIN_PREVIEW === "1") return children;
+3 -1
View File
@@ -57,7 +57,7 @@ export async function GET() {
// an older case. A concurrently created case simply appears on refresh.
const { data: profile, error } = await supabase
.from("profiles")
.select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset")
.select("credits,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_time,active_birth_time,birth_time_status,rectification_case_id")
.eq("id", userId)
.single();
@@ -72,6 +72,8 @@ export async function GET() {
return NextResponse.json({
user: { id: user.id, email: user.email ?? null },
credits: profile.credits,
profile,
authProvider: process.env.AUTH_PROVIDER?.trim() === "self-hosted" ? "self-hosted" : "supabase",
isAdmin: isAdminEmail(user.email),
rectificationPriceCredits,
hasConfirmedBirthTime: profile.birth_time_status === "confirmed"
+11 -2
View File
@@ -64,10 +64,19 @@ export async function GET() {
process.env.RECTIFICATION_V3_MIGRATIONS_READY?.trim().toLowerCase() === "true";
const creationPolicy = conversationalRectificationCreationPolicyFromEnvironment();
const truthSourceIdentity = getTruthSourceRuntimeIdentity();
const selfHosted = process.env.AUTH_PROVIDER?.trim() === "self-hosted";
const databaseChecks: Record<string, Check> = selfHosted
? {
localBusinessDatabase: envCheck(["APP_DATABASE_URL", "ADMIN_DATABASE_URL"]),
localIdentityDatabase: envCheck(["IDENTITY_DATABASE_URL"]),
}
: {
supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]),
supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]),
};
const checks = {
web: { status: "ok" } satisfies Check,
supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]),
supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]),
...databaseChecks,
modelProvider: anyEnvCheck(["LLM_MODELS_JSON", "OPENAI_API_KEY", "LLM_API_KEY", "DEEPSEEK_API_KEY"]),
jyotishApi: await jyotishApiCheck(),
researchTruthSource: {
+16 -4
View File
@@ -1,21 +1,33 @@
import { NextResponse } from "next/server";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { chatSessionWriteSchema } from "@/lib/chat-session-write-contract";
import {
chatSessionModelPatchSchema,
chatSessionWriteSchema,
} from "@/lib/chat-session-write-contract";
type RouteContext = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, context: RouteContext) {
try {
const { id } = await context.params;
const parsed = chatSessionWriteSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
const payload = await request.json().catch(() => null);
const fullWrite = chatSessionWriteSchema.safeParse(payload);
const modelPatch = chatSessionModelPatchSchema.safeParse(payload);
let values: Record<string, unknown>;
if (fullWrite.success) {
values = fullWrite.data;
} else if (modelPatch.success) {
values = modelPatch.data;
} else {
return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
}
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { data, error } = await supabase
.from("chat_sessions")
.update(parsed.data)
.update(values)
.eq("id", id)
.eq("user_id", user.id)
.select("id")
+20
View File
@@ -3,6 +3,26 @@ import { chatSessionCreateSchema } from "@/lib/chat-session-write-contract";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { data, error } = await supabase
.from("chat_sessions")
.select("id,title,theme,model_id,messages,session_type,rectification_case_id,updated_at")
.eq("user_id", user.id)
.order("updated_at", { ascending: false });
if (error) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
return NextResponse.json({ sessions: data ?? [] });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const supabase = await createServerSupabaseClient();
+51 -55
View File
@@ -89,6 +89,7 @@ import {
resolveSessionModelId,
type PublicLanguageModelCatalog,
} from "@/lib/public-models";
import { selfHostedOtpActions } from "@/modules/identity/client";
import { createBrowserSupabaseClient } from "@/lib/supabase/client";
const BirthTimeRectification = dynamic(
@@ -158,6 +159,8 @@ type BirthPlace = { label: string; lat: number; lon: number; tz: number };
type Account = {
user: { id: string; email: string | null };
credits: number;
profile: unknown;
authProvider: "supabase" | "self-hosted";
isAdmin: boolean;
rectificationPriceCredits: number;
hasConfirmedBirthTime: boolean;
@@ -738,6 +741,32 @@ async function fetchModelCatalog(signal?: AbortSignal) {
return parsePublicModelCatalog(payload);
}
async function fetchSessions(signal?: AbortSignal) {
const response = await fetch("/api/sessions", { signal, cache: "no-store" });
if (response.status === 401) {
window.location.assign("/login");
throw new Error("请先登录");
}
const payload = await response.json().catch(() => null) as { sessions?: unknown; error?: string } | null;
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录"));
return payload?.sessions ?? [];
}
async function patchSessionModel(sessionId: string, modelId: string, signal?: AbortSignal) {
const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
credentials: "same-origin",
signal,
body: JSON.stringify({ model_id: modelId }),
});
const payload = await response.json().catch(() => null);
return {
found: response.ok,
error: response.ok ? null : payloadMessage(payload, "模型选择暂时无法同步"),
};
}
export default function Home() {
const [profile, setProfile] = useState<Profile>(emptyProfile);
const [profileDraft, setProfileDraft] = useState<Profile>(emptyProfile);
@@ -1075,6 +1104,8 @@ export default function Home() {
setAccount({
user: { id: "preview-user", email: "preview@local.test" },
credits: 8,
profile: previewProfile,
authProvider: "self-hosted",
isAdmin: false,
rectificationPriceCredits: 1,
hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed",
@@ -1101,16 +1132,7 @@ export default function Home() {
return;
}
const supabase = createBrowserSupabaseClient();
const { data: authData, error: authError } = await supabase.auth.getSession();
if (authError) throw authError;
if (controller.signal.aborted) return;
if (!authData.session) {
window.location.assign("/login");
return;
}
const [nextAccount, modelCatalogResult] = await Promise.all([
const [nextAccount, modelCatalogResult, sessionsPayload] = await Promise.all([
fetchAccount(controller.signal),
fetchModelCatalog(controller.signal)
.then((catalog) => ({ catalog, unavailable: false }))
@@ -1118,50 +1140,30 @@ export default function Home() {
if (caught instanceof Error && caught.name === "AbortError") throw caught;
return { catalog: null, unavailable: true };
}),
fetchSessions(controller.signal),
]);
const nextModelCatalog = modelCatalogResult.catalog;
const [profileResult, sessionsResult] = await Promise.all([
supabase
.from("profiles")
.select("name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code")
.eq("id", nextAccount.user.id)
.abortSignal(controller.signal)
.maybeSingle(),
supabase
.from("chat_sessions")
.select("id,title,theme,model_id,messages,session_type,rectification_case_id,updated_at")
.abortSignal(controller.signal)
.order("updated_at", { ascending: false }),
]);
if (profileResult.error) throw profileResult.error;
if (sessionsResult.error) throw sessionsResult.error;
const parsedSessions = readSessions(sessionsResult.data, nextModelCatalog);
const parsedSessions = readSessions(sessionsPayload, nextModelCatalog);
let nextSessions = parsedSessions.sessions;
if (nextSessions.length === 0) {
if (controller.signal.aborted) return;
const initialSession = createSession(nextModelCatalog?.defaultModelId ?? "");
const { error } = await supabase
.from("chat_sessions")
.insert({
id: initialSession.id,
user_id: nextAccount.user.id,
if (initialSession.modelId) {
await writeChatSession(initialSession.id, {
title: initialSession.title,
theme: initialSession.theme,
model_id: initialSession.modelId || null,
model_id: initialSession.modelId,
messages: initialSession.messages,
session_type: initialSession.sessionType,
rectification_case_id: initialSession.rectificationCaseId,
updated_at: new Date(initialSession.updatedAt).toISOString(),
})
.abortSignal(controller.signal);
if (error) throw error;
}, "create");
}
nextSessions = [initialSession];
}
if (controller.signal.aborted) return;
const nextProfile = readProfile(profileResult.data);
const nextProfile = readProfile(nextAccount.profile);
setAccount(nextAccount);
setModelCatalog(nextModelCatalog);
setProfile(nextProfile);
@@ -1178,13 +1180,9 @@ export default function Home() {
setAccountError("");
if (nextModelCatalog && parsedSessions.fallbackSessionIds.length > 0) {
const { error } = await supabase
.from("chat_sessions")
.update({ model_id: nextModelCatalog.defaultModelId })
.eq("user_id", nextAccount.user.id)
.in("id", parsedSessions.fallbackSessionIds)
.abortSignal(controller.signal);
if (error && !controller.signal.aborted) {
const results = await Promise.all(parsedSessions.fallbackSessionIds.map((sessionId) =>
patchSessionModel(sessionId, nextModelCatalog.defaultModelId, controller.signal)));
if (results.some((result) => result.error) && !controller.signal.aborted) {
setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。");
}
}
@@ -1491,14 +1489,8 @@ export default function Home() {
if (process.env.NODE_ENV === "development" && uiPreview.current) {
return { found: true, error: null };
}
const { data, error } = await createBrowserSupabaseClient()
.from("chat_sessions")
.update(values)
.eq("id", sessionId)
.eq("user_id", ownerId)
.select("id")
.maybeSingle();
return { found: Boolean(data), error: error?.message ?? null };
void ownerId;
return patchSessionModel(sessionId, values.model_id);
},
userId,
nextSession.id,
@@ -1834,8 +1826,12 @@ export default function Home() {
setSigningOut(true);
setAccountError("");
try {
const { error } = await createBrowserSupabaseClient().auth.signOut();
if (error) throw error;
if (account?.authProvider === "self-hosted") {
await selfHostedOtpActions.signOut();
} else {
const { error } = await createBrowserSupabaseClient().auth.signOut();
if (error) throw error;
}
window.location.assign("/login");
} catch (caught) {
const message = caught instanceof Error ? caught.message : "退出失败";