feat(account): self-service deletion with a 7-day cooling-off period

Request signs out everywhere and freezes paid routes (423 + DB triggers);
signing in within 7 days shows the pending gate with 撤销注销. A periodic
idempotent worker purges personal content afterwards and keeps finance
rows against a tombstoned identity. Read-only admin list.

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 3c8123d912
commit 96adbce3a9
30 changed files with 1480 additions and 2 deletions
@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import {
accountDeletionRequestErrorCode,
isAccountDeletionConfirmation,
parseAccountDeletionStatus,
} from "@/lib/account-deletion";
import { readPendingAccountDeletion } from "@/lib/account-deletion-server";
import { checkSameOrigin, resolveAllowedReportOrigins } from "@/lib/personal-report-entitlement";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Self-service account deletion (2026-09-30).
* GET → { status: "none" } | { status: "pending", scheduledFor }
* POST { confirm: "注销" } → request; every session of the account is ended.
* DELETE → cancel while still within the 7 days.
*/
async function currentUserId(): Promise<string | null> {
const supabase = await createServerSupabaseClient();
const { data: { user }, error } = await supabase.auth.getUser();
return error || !user ? null : user.id;
}
function crossOrigin(request: Request): NextResponse | null {
const decision = checkSameOrigin(
request.url,
request.headers.get("origin"),
resolveAllowedReportOrigins(process.env),
request.headers,
);
return decision.ok ? null : NextResponse.json({ error: "跨域请求被拒绝", code: decision.code }, { status: 403 });
}
export async function GET() {
const userId = await currentUserId();
if (!userId) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const scheduledFor = await readPendingAccountDeletion(userId);
return NextResponse.json(scheduledFor ? { status: "pending", scheduledFor } : { status: "none" });
}
export async function POST(request: Request) {
const refused = crossOrigin(request);
if (refused) return refused;
const userId = await currentUserId();
if (!userId) return NextResponse.json({ error: "请先登录" }, { status: 401 });
let body: { confirm?: unknown } = {};
try {
body = (await request.json()) as { confirm?: unknown };
} catch {
body = {};
}
if (!isAccountDeletionConfirmation(body.confirm)) {
return NextResponse.json({ error: "请输入「注销」确认。", code: "confirmation_required" }, { status: 400 });
}
const admin = createAdminSupabaseClient();
const { data, error } = await admin.rpc("request_account_deletion", { p_user_id: userId });
if (error) {
const code = accountDeletionRequestErrorCode(error.message);
if (code === "admin_account") {
return NextResponse.json({ error: "管理员账号不能自助注销,请联系负责人移除权限。", code: "admin_account" }, { status: 403 });
}
return NextResponse.json({ error: "注销申请暂时没能提交,请稍后再试。", code: "deletion_unavailable" }, { status: 503 });
}
const status = parseAccountDeletionStatus(data);
return NextResponse.json(status.status === "pending" ? status : { status: "pending" });
}
export async function DELETE(request: Request) {
const refused = crossOrigin(request);
if (refused) return refused;
const userId = await currentUserId();
if (!userId) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const admin = createAdminSupabaseClient();
const { data, error } = await admin.rpc("cancel_account_deletion", { p_user_id: userId });
if (error) {
return NextResponse.json({ error: "撤销暂时没能完成,请稍后再试。", code: "deletion_unavailable" }, { status: 503 });
}
if (data !== true) {
return NextResponse.json({ error: "没有可以撤销的注销申请。", code: "no_pending_deletion" }, { status: 409 });
}
return NextResponse.json({ status: "none" });
}
@@ -0,0 +1,82 @@
import { NextResponse } from "next/server";
import { requirePermission } from "@/lib/admin/auth";
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
import {
adminErrorResponse,
invalidQueryResponse,
parseListQuery,
readonlyAdminMutation,
} from "@/lib/admin/http";
export const runtime = "nodejs";
type DeletionRow = {
id: string;
user_id: string;
status: string;
requested_at: Date;
scheduled_for: Date;
cancelled_at: Date | null;
completed_at: Date | null;
attempt_count: number;
error_code: string | null;
total_count: string;
};
const sortColumns = new Map([
["requestedAt", "requested_at"],
["scheduledFor", "scheduled_for"],
["status", "status"],
]);
export const POST = readonlyAdminMutation;
export const PUT = readonlyAdminMutation;
export const PATCH = readonlyAdminMutation;
export const DELETE = readonlyAdminMutation;
/**
* GET /api/admin/account-deletions — read-only (2026-09-30). Status and dates
* of self-service deletion requests; no email or name is stored or shown.
* After completion the user id belongs to an anonymous tombstone identity.
*/
export async function GET(request: Request) {
try {
await requirePermission("admin.customers.read");
const parsed = parseListQuery(request);
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
const { page, pageSize, sort, order, status } = parsed.data;
const values: unknown[] = [];
const conditions: string[] = [];
if (status) {
values.push(status);
conditions.push(`status = $${values.length}`);
}
values.push(pageSize, pageOffset(page, pageSize));
const sortColumn = sortColumns.get(sort ?? "requestedAt") ?? "requested_at";
const rows = await queryAdminRows<DeletionRow>(`
select id, user_id, status, requested_at, scheduled_for, cancelled_at, completed_at,
attempt_count, error_code, count(*) over()::text as total_count
from public.account_deletion_requests
${conditions.length ? `where ${conditions.join(" and ")}` : ""}
order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, id asc
limit $${values.length - 1} offset $${values.length}
`, values);
return NextResponse.json({
data: rows.map((row) => ({
id: row.id,
userId: row.user_id,
status: row.status,
requestedAt: row.requested_at.toISOString(),
scheduledFor: row.scheduled_for.toISOString(),
cancelledAt: row.cancelled_at?.toISOString() ?? null,
completedAt: row.completed_at?.toISOString() ?? null,
attemptCount: row.attempt_count,
errorCode: row.error_code,
})),
total: Number(rows[0]?.total_count ?? 0),
});
} catch (error) {
return adminErrorResponse(error);
}
}
+4
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { refuseWhenAccountDeletionPending } from "@/lib/account-deletion-server";
import {
consultationInputSchema,
consultationWorkflowReceipt,
@@ -304,6 +305,9 @@ export async function POST(request: Request) {
{ status: 401 },
);
}
// A pending account deletion freezes the account: nothing new is charged.
const frozen = await refuseWhenAccountDeletionPending(user.id);
if (frozen) return frozen;
const parsed = chatRequestSchema.safeParse(
await request.json().catch(() => null),
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { refuseWhenAccountDeletionPending } from "@/lib/account-deletion-server";
import { createAdoptNarrationWriter } from "@/lib/rectification-agentic/v9/adopt-narration-agent";
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
@@ -61,6 +62,9 @@ export async function POST(request: Request) {
isStructuredChoice,
chatSession,
} = context;
// A pending account deletion freezes the account: no rectification turn runs.
const frozen = await refuseWhenAccountDeletionPending(userId);
if (frozen) return frozen;
const declaredWindowReply = await replyToDeclaredBirthWindow(context);
if (declaredWindowReply) return declaredWindowReply;
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { refuseWhenAccountDeletionPending } from "@/lib/account-deletion-server";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { isProductEnabled } from "@/lib/product-access";
@@ -48,6 +49,8 @@ export async function POST(request: Request) {
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const frozen = await refuseWhenAccountDeletionPending(user.id);
if (frozen) return frozen;
if (!await isProductEnabled("rectification")) {
return NextResponse.json(
+4
View File
@@ -1,4 +1,5 @@
import { normalizeReportSubjectRequest } from "@/lib/report-subject-request";
import { refuseWhenAccountDeletionPending } from "@/lib/account-deletion-server";
import { NextResponse } from "next/server";
import { LONGFORM_SNAPSHOT_SECTION_ID } from "@/lib/personal-report-longform-snapshot";
import { resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone";
@@ -175,6 +176,9 @@ export async function POST(request: Request) {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
const userId = authError || !user ? null : user.id;
// A pending account deletion freezes the account: no new report is charged.
const frozen = userId ? await refuseWhenAccountDeletionPending(userId) : null;
if (frozen) return frozen;
const reportProductEnabled = userId
? await isProductEnabled("report_center")
: false;
+3
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { refuseWhenAccountDeletionPending } from "@/lib/account-deletion-server";
import { z } from "zod";
import { isProductEnabled } from "@/lib/product-access";
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
@@ -127,6 +128,8 @@ export async function POST(request: Request) {
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const frozen = await refuseWhenAccountDeletionPending(user.id);
if (frozen) return frozen;
if (!await isProductEnabled("compatibility")) {
return NextResponse.json(
{ error: "合盘服务暂未开放", code: "compatibility_product_disabled" },