feat(legal): terms and privacy pages, login consent recorded per version, re-consent gate (draft text, placeholders)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-30 09:42:11 +08:00
co-authored by Claude Opus 5.5
parent 788a84a4c0
commit 8f58eccecf
25 changed files with 876 additions and 6 deletions
@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { CURRENT_LEGAL_VERSION, LEGAL_CONSENT_DOCUMENTS, LEGAL_RECONSENT_BLOCKING, consentOutdated, isCurrentConsentRequest, latestAccepted } from "@/lib/legal-consent";
import { consentIpHash } from "@/lib/legal-consent-server";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
type ConsentRow = { document: string; version: string; accepted_at: string };
/**
* GET: which versions this user accepted, and whether a (blocking) re-consent
* is required now. POST: record acceptance of the current version of both
* documents. Rows are append-only (see 20260930010000_user_consents.sql).
*/
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { data, error } = await supabase.from("user_consents").select("document,version,accepted_at").eq("user_id", user.id);
if (error) return NextResponse.json({ error: "暂时读不到同意记录" }, { status: 503 });
const accepted = latestAccepted((data ?? []) as ConsentRow[]);
return NextResponse.json({
current: CURRENT_LEGAL_VERSION,
accepted,
required: LEGAL_RECONSENT_BLOCKING && consentOutdated(accepted),
});
} catch (caught) {
if (isSupabaseConfigurationError(caught)) return NextResponse.json({ error: "服务未配置" }, { status: 503 });
throw caught;
}
}
export async function POST(request: Request) {
try {
if (!isCurrentConsentRequest(await request.json().catch(() => null))) {
return NextResponse.json({ error: "协议版本已更新,请刷新页面后重新确认" }, { status: 409 });
}
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { data: existing, error: readError } = await supabase
.from("user_consents").select("document,version,accepted_at")
.eq("user_id", user.id).eq("version", CURRENT_LEGAL_VERSION);
if (readError) return NextResponse.json({ error: "暂时无法记录" }, { status: 503 });
const have = new Set(((existing ?? []) as ConsentRow[]).map(row => row.document));
const missing = LEGAL_CONSENT_DOCUMENTS.filter(document => !have.has(document));
if (missing.length) {
const ipHash = consentIpHash(request.headers);
const userAgent = request.headers.get("user-agent")?.slice(0, 160) ?? null;
const { error } = await supabase.from("user_consents").insert(missing.map(document => ({
user_id: user.id, document, version: CURRENT_LEGAL_VERSION, ip_hash: ipHash, user_agent: userAgent,
})));
if (error) return NextResponse.json({ error: "暂时无法记录" }, { status: 503 });
}
return NextResponse.json({ recorded: missing, version: CURRENT_LEGAL_VERSION });
} catch (caught) {
if (isSupabaseConfigurationError(caught)) return NextResponse.json({ error: "服务未配置" }, { status: 503 });
throw caught;
}
}