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:
co-authored by
Claude Opus 5.5
parent
3c8123d912
commit
96adbce3a9
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { AccountDeletionGate } from "@/components/account-deletion-gate";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { LegalConsentGate } from "@/components/legal/legal-consent-gate";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
@@ -35,6 +36,7 @@ function AppShell({ children }: { children: ReactNode }) {
|
||||
</SidebarInset>
|
||||
<LegalConsentGate signedIn={Boolean(account) && !list.signedOut} />
|
||||
</main>
|
||||
<AccountDeletionGate signedIn={Boolean(account)} />
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent } from "react";
|
||||
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
|
||||
import { ProfilePanel } from "@/components/profile-panel";
|
||||
import { AccountDeletionSection } from "@/components/account-deletion-section";
|
||||
import { ThemePreferencePanel } from "@/components/theme-preference-menu";
|
||||
import type { BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay";
|
||||
import { AppLoadingIndicator } from "@/components/app-loading-indicator";
|
||||
@@ -1138,7 +1139,7 @@ export default function Home() {
|
||||
),
|
||||
|
||||
renderGeneral() {
|
||||
return <ThemePreferencePanel />;
|
||||
return <><ThemePreferencePanel /><AccountDeletionSection /></>;
|
||||
},
|
||||
renderLogout() {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import AccountDeletionsResource from "@/components/admin/account-deletions-resource";
|
||||
export default function Page() { return <AccountDeletionsResource />; }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -1904,6 +1904,23 @@ button:disabled:where(:not([data-slot="button"])) { cursor: default; opacity: .4
|
||||
.account-info-list dd { min-width: 0; margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); }
|
||||
.account-info-list dd small { display: block; margin-top: var(--space-1); color: var(--color-ink-tertiary); font-size: var(--type-caption); }
|
||||
.theme-preference-panel { display: grid; gap: var(--space-4); margin-top: var(--space-2); }
|
||||
/* 注销账号 (2026-09-30): the last section of 通用设置, set apart by a rule. The
|
||||
confirmation opens in place, not as a second dialog over the settings one. */
|
||||
.account-deletion-section { display: grid; gap: var(--space-3); margin-top: var(--space-8); padding-top: var(--space-6); border-top: 1px solid var(--color-border); }
|
||||
.account-deletion-open { justify-self: start; min-height: 44px; padding: 0 var(--space-3); border: 1px solid color-mix(in srgb, var(--color-danger) 45%, var(--color-border)); border-radius: var(--radius-md); background: transparent; color: var(--color-danger); font: inherit; font-size: var(--type-body-sm); cursor: pointer; }
|
||||
.account-deletion-open:hover { background: var(--color-danger-muted); }
|
||||
.account-deletion-confirm { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border)); border-radius: var(--radius-md); background: var(--color-danger-muted); color: var(--color-ink); font-size: var(--type-body-sm); line-height: 1.6; }
|
||||
.account-deletion-confirm p, .account-deletion-confirm ul { margin: 0; }
|
||||
.account-deletion-confirm ul { padding-left: 1.2em; }
|
||||
.account-deletion-contact { color: var(--color-ink-secondary); }
|
||||
.account-deletion-input { display: grid; gap: var(--space-1); }
|
||||
.account-deletion-input input { min-height: 44px; padding: 0 var(--space-3); border: 1px solid var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); font: inherit; }
|
||||
/* 账号注销中: covers the whole app for an account with a pending deletion. */
|
||||
.account-deletion-gate { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: var(--space-6); background: var(--color-canvas-soft); }
|
||||
.account-deletion-gate section { width: min(440px, 100%); display: grid; gap: var(--space-4); padding: var(--space-8) var(--space-6); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); text-align: center; }
|
||||
.account-deletion-gate h2 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 500; }
|
||||
.account-deletion-gate p { margin: 0; color: var(--color-ink-secondary); line-height: 1.6; }
|
||||
.account-deletion-gate .dialog-actions { justify-content: center; }
|
||||
.theme-preference-options { display: grid; gap: var(--space-2); }
|
||||
.theme-preference-option { min-height: 44px; display: grid; grid-template-columns: 20px minmax(0, 1fr) 18px; align-items: center; gap: var(--space-3); padding: 0 var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; text-align: left; }
|
||||
.theme-preference-option:hover, .theme-preference-option[aria-pressed="true"] { border-color: var(--color-action); background: var(--color-action-soft); }
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
formatAccountDeletionDate,
|
||||
parseAccountDeletionStatus,
|
||||
type AccountDeletionStatus,
|
||||
} from "@/lib/account-deletion";
|
||||
import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
|
||||
import { selfHostedOtpActions } from "@/modules/identity/client";
|
||||
|
||||
/**
|
||||
* 「账号注销中」 (2026-09-30). An account with a pending deletion that signs in
|
||||
* again sees only this: the date it becomes permanent, 撤销注销 (restores the
|
||||
* account) or 退出登录. Paid routes refuse the account server-side meanwhile,
|
||||
* so the screen is the way back, not the lock.
|
||||
*/
|
||||
export function AccountDeletionGate({ signedIn }: Readonly<{ signedIn: boolean }>) {
|
||||
const [status, setStatus] = useState<AccountDeletionStatus>({ status: "none" });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!signedIn) return;
|
||||
let cancelled = false;
|
||||
fetch("/api/account/deletion", { credentials: "same-origin", headers: { Accept: "application/json" } })
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((json) => { if (!cancelled) setStatus(parseAccountDeletionStatus(json)); })
|
||||
.catch(() => { /* No status is no screen: never lock anyone out on a read error. */ });
|
||||
return () => { cancelled = true; };
|
||||
}, [signedIn]);
|
||||
|
||||
if (status.status !== "pending") return null;
|
||||
|
||||
async function restore() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/account/deletion", { method: "DELETE", credentials: "same-origin", headers: { Accept: "application/json" } });
|
||||
if (!response.ok) {
|
||||
const json = await response.json().catch(() => null) as { error?: unknown } | null;
|
||||
setError(typeof json?.error === "string" ? json.error : "撤销暂时没能完成,请稍后再试。");
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
clearHomeWarmSnapshot();
|
||||
window.location.reload();
|
||||
} catch {
|
||||
setError("网络异常,请检查连接后重试。");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function leave() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
clearHomeWarmSnapshot();
|
||||
try {
|
||||
await selfHostedOtpActions.signOut();
|
||||
} finally {
|
||||
window.location.assign("/login");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="account-deletion-gate" role="alertdialog" aria-modal="true" aria-labelledby="account-deletion-gate-title" aria-describedby="account-deletion-gate-body">
|
||||
<section>
|
||||
<h2 id="account-deletion-gate-title">账号注销中</h2>
|
||||
<p id="account-deletion-gate-body">
|
||||
这个账号将在 {formatAccountDeletionDate(status.scheduledFor)} 永久删除。在那之前,你可以撤销注销,恢复全部内容。
|
||||
</p>
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
<div className="dialog-actions">
|
||||
<button className="button-secondary" type="button" onClick={() => void leave()} disabled={busy}>退出登录</button>
|
||||
<button className="button-primary" type="button" onClick={() => void restore()} disabled={busy}>{busy ? "正在处理" : "撤销注销"}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
ACCOUNT_DELETION_CONFIRM_WORD,
|
||||
ACCOUNT_DELETION_DELETED_ITEMS,
|
||||
ACCOUNT_DELETION_GRACE_DAYS,
|
||||
isAccountDeletionConfirmation,
|
||||
} from "@/lib/account-deletion";
|
||||
import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
|
||||
import { LEGAL_ENTITY } from "@/lib/legal-entity";
|
||||
|
||||
/**
|
||||
* 「注销账号」 at the bottom of 通用设置 (2026-09-30). The confirmation opens in
|
||||
* place rather than as a second dialog on top of the settings dialog: what is
|
||||
* deleted, what is kept, that credits are forfeited, the 7-day window, and a
|
||||
* typed 「注销」. On success every session has already been ended server-side,
|
||||
* so the page goes to the login screen.
|
||||
*/
|
||||
export function AccountDeletionSection() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function submit() {
|
||||
if (submitting || !isAccountDeletionConfirmation(confirm)) return;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/account/deletion", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ confirm: confirm.trim() }),
|
||||
});
|
||||
const json = await response.json().catch(() => null) as { error?: unknown } | null;
|
||||
if (!response.ok) {
|
||||
setError(typeof json?.error === "string" ? json.error : "注销申请暂时没能提交,请稍后再试。");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
clearHomeWarmSnapshot();
|
||||
window.location.assign("/login");
|
||||
} catch {
|
||||
setError("网络异常,请检查连接后重试。");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="account-deletion-section" aria-labelledby="account-deletion-title">
|
||||
<div className="section-heading">
|
||||
<b id="account-deletion-title">注销账号</b>
|
||||
<small>{ACCOUNT_DELETION_GRACE_DAYS} 天内可以撤销,之后永久删除</small>
|
||||
</div>
|
||||
{!open ? (
|
||||
<button type="button" className="account-deletion-open" onClick={() => setOpen(true)}>
|
||||
注销账号…
|
||||
</button>
|
||||
) : (
|
||||
<div className="account-deletion-confirm">
|
||||
<p>提交后你会立刻退出登录,账号暂停使用。{ACCOUNT_DELETION_GRACE_DAYS} 天内重新登录可以撤销;{ACCOUNT_DELETION_GRACE_DAYS} 天后以下内容会被永久删除,无法恢复:</p>
|
||||
<ul>
|
||||
{ACCOUNT_DELETION_DELETED_ITEMS.map((item) => <li key={item}>{item}</li>)}
|
||||
</ul>
|
||||
<p>剩余点数和会员权益会一并作废,不退款。订单和点数流水按法规保留,但不再与你的身份关联。</p>
|
||||
<p className="account-deletion-contact">有疑问可以先联系我们:{LEGAL_ENTITY.contactEmail}</p>
|
||||
<label className="account-deletion-input">
|
||||
<span>输入「{ACCOUNT_DELETION_CONFIRM_WORD}」确认</span>
|
||||
<input
|
||||
value={confirm}
|
||||
onChange={(event) => setConfirm(event.target.value)}
|
||||
autoComplete="off"
|
||||
aria-describedby={error ? "account-deletion-error" : undefined}
|
||||
/>
|
||||
</label>
|
||||
{error ? <p id="account-deletion-error" className="form-error" role="alert">{error}</p> : null}
|
||||
<div className="dialog-actions">
|
||||
<button className="button-secondary" type="button" onClick={() => { setOpen(false); setConfirm(""); setError(""); }} disabled={submitting}>取消</button>
|
||||
<button
|
||||
className="button-primary danger-primary"
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={submitting || !isAccountDeletionConfirmation(confirm)}
|
||||
>
|
||||
{submitting ? "正在提交" : "确认注销"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Descriptions, type TableColumnsType } from "antd";
|
||||
|
||||
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
|
||||
|
||||
type DeletionRecord = {
|
||||
id: string;
|
||||
userId: string;
|
||||
status: string;
|
||||
requestedAt: string;
|
||||
scheduledFor: string;
|
||||
cancelledAt: string | null;
|
||||
completedAt: string | null;
|
||||
attemptCount: number;
|
||||
errorCode: string | null;
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = { pending: "冷静期中", cancelled: "已撤销", completed: "已删除" };
|
||||
|
||||
const columns: TableColumnsType<DeletionRecord> = [
|
||||
{ title: "状态", dataIndex: "status", sorter: true, render: (value: string) => STATUS_LABELS[value] ?? value },
|
||||
{ title: "用户 ID", dataIndex: "userId" },
|
||||
{ title: "申请时间", dataIndex: "requestedAt", sorter: true, render: formatAdminDate },
|
||||
{ title: "计划删除", dataIndex: "scheduledFor", sorter: true, render: formatAdminDate },
|
||||
{ title: "撤销时间", dataIndex: "cancelledAt", render: (value: string | null) => (value ? formatAdminDate(value) : "—") },
|
||||
{ title: "完成时间", dataIndex: "completedAt", render: (value: string | null) => (value ? formatAdminDate(value) : "—") },
|
||||
{ title: "失败次数", dataIndex: "attemptCount" },
|
||||
{ title: "最近错误", dataIndex: "errorCode", render: (value: string | null) => value ?? "—" },
|
||||
];
|
||||
|
||||
export default function AccountDeletionsPage() {
|
||||
return <ResourceTable<DeletionRecord>
|
||||
resource="account-deletions"
|
||||
title="注销申请(只读)"
|
||||
columns={columns}
|
||||
statusOptions={[
|
||||
{ label: "冷静期中", value: "pending" },
|
||||
{ label: "已撤销", value: "cancelled" },
|
||||
{ label: "已删除", value: "completed" },
|
||||
]}
|
||||
extra={<Descriptions size="small" items={[{ key: "policy", label: "说明", children: "7 天冷静期后自动删除个人内容;订单与点数流水保留但不再关联身份。本表不存邮箱与昵称。" }]} />}
|
||||
/>;
|
||||
}
|
||||
@@ -104,6 +104,7 @@ export function AdminApp({ children }: { children: ReactNode }) {
|
||||
{ name: "administrators", list: "/admin/administrators", meta: { label: "管理员", icon: <SafetyCertificateOutlined /> } },
|
||||
{ name: "roles", list: "/admin/roles", meta: { label: "角色权限", icon: <TeamOutlined /> } },
|
||||
{ name: "customers", list: "/admin/customers", meta: { label: "用户资料", icon: <UserOutlined /> } },
|
||||
{ name: "account-deletions", list: "/admin/account-deletions", meta: { label: "注销申请", icon: <UserOutlined /> } },
|
||||
{ name: "products", list: "/admin/products", meta: { label: "商品权益", icon: <ShoppingOutlined /> } },
|
||||
{ name: "subscriptions", list: "/admin/subscriptions", meta: { label: "订阅", icon: <CreditCardOutlined /> } },
|
||||
{ name: "orders", list: "/admin/orders", meta: { label: "订单", icon: <DatabaseOutlined /> } },
|
||||
|
||||
@@ -14,4 +14,8 @@ export async function register(): Promise<void> {
|
||||
|
||||
const { startPersonalReportWorker } = await import("./lib/personal-report-worker");
|
||||
startPersonalReportWorker();
|
||||
|
||||
// Account deletion: permanent purge after the 7-day cooling-off period.
|
||||
const { startAccountDeletionWorker } = await import("./lib/account-deletion-worker");
|
||||
startAccountDeletionWorker();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import "server-only";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
ACCOUNT_DELETION_PENDING_CODE,
|
||||
readPendingAccountDeletionWith,
|
||||
type AccountDeletionRpcClient,
|
||||
} from "@/lib/account-deletion";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
|
||||
/** The pending deletion date for this user, read with the service client; null when none or unreadable. */
|
||||
export async function readPendingAccountDeletion(userId: string): Promise<string | null> {
|
||||
try {
|
||||
return await readPendingAccountDeletionWith(createAdminSupabaseClient() as unknown as AccountDeletionRpcClient, userId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 423 with the stable code; the app shows the 「账号注销中」 screen for it. */
|
||||
export function accountDeletionPendingResponse(scheduledFor: string): NextResponse {
|
||||
return NextResponse.json(
|
||||
{ error: "账号注销中,撤销注销后才能继续使用。", code: ACCOUNT_DELETION_PENDING_CODE, scheduledFor },
|
||||
{ status: 423 },
|
||||
);
|
||||
}
|
||||
|
||||
/** For paid routes: a 423 response when the account is frozen, otherwise null. */
|
||||
export async function refuseWhenAccountDeletionPending(userId: string): Promise<NextResponse | null> {
|
||||
const scheduledFor = await readPendingAccountDeletion(userId);
|
||||
return scheduledFor ? accountDeletionPendingResponse(scheduledFor) : null;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The permanent step of account deletion (2026-09-30). One tick finds pending
|
||||
* requests whose 7 days are over and calls `purge_deleted_account` for each.
|
||||
* The database function is all-or-nothing, so a failed purge leaves the
|
||||
* account exactly as it was; the attempt is counted and the next tick retries
|
||||
* until MAX_ATTEMPTS. Logs carry counts and codes only, never ids or emails.
|
||||
*/
|
||||
|
||||
export const ACCOUNT_DELETION_MAX_ATTEMPTS = 5;
|
||||
export const ACCOUNT_DELETION_BATCH = 20;
|
||||
export const ACCOUNT_DELETION_TICK_MS = 30 * 60 * 1000;
|
||||
export const ACCOUNT_DELETION_FIRST_TICK_MS = 60 * 1000;
|
||||
|
||||
export type AccountDeletionWorkerDeps = Readonly<{
|
||||
now: () => Date;
|
||||
listDue: (nowIso: string, limit: number, maxAttempts: number) => Promise<readonly string[]>;
|
||||
purge: (requestId: string) => Promise<{ ok: true; status: string } | { ok: false; code: string }>;
|
||||
markFailed: (requestId: string, code: string) => Promise<void>;
|
||||
log: (line: string) => void;
|
||||
}>;
|
||||
|
||||
export type AccountDeletionTickResult = Readonly<{ due: number; completed: number; skipped: number; failed: number }>;
|
||||
|
||||
export function purgeErrorCode(message: string | undefined): string {
|
||||
if (message?.includes("account_deletion_purge_incomplete")) return "purge_incomplete";
|
||||
if (message?.includes("account_deletion_request_not_found")) return "request_not_found";
|
||||
return "purge_failed";
|
||||
}
|
||||
|
||||
export async function runAccountDeletionTick(deps: AccountDeletionWorkerDeps): Promise<AccountDeletionTickResult> {
|
||||
const due = await deps.listDue(deps.now().toISOString(), ACCOUNT_DELETION_BATCH, ACCOUNT_DELETION_MAX_ATTEMPTS);
|
||||
let completed = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
for (const requestId of due) {
|
||||
const result = await deps.purge(requestId);
|
||||
if (result.ok) {
|
||||
if (result.status === "completed") completed += 1; else skipped += 1;
|
||||
continue;
|
||||
}
|
||||
failed += 1;
|
||||
await deps.markFailed(requestId, result.code);
|
||||
}
|
||||
if (due.length > 0) {
|
||||
deps.log(`[account-deletion-worker] due=${due.length} completed=${completed} skipped=${skipped} failed=${failed}`);
|
||||
}
|
||||
return { due: due.length, completed, skipped, failed };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import "server-only";
|
||||
|
||||
import {
|
||||
ACCOUNT_DELETION_FIRST_TICK_MS,
|
||||
ACCOUNT_DELETION_TICK_MS,
|
||||
purgeErrorCode,
|
||||
runAccountDeletionTick,
|
||||
} from "@/lib/account-deletion-worker-core";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
|
||||
type WorkerGlobal = typeof globalThis & { jyotishaAccountDeletionWorker?: { stop: () => void } };
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
const admin = createAdminSupabaseClient();
|
||||
await runAccountDeletionTick({
|
||||
now: () => new Date(),
|
||||
listDue: async (nowIso, limit, maxAttempts) => {
|
||||
const { data, error } = await admin
|
||||
.from("account_deletion_requests")
|
||||
.select("id,attempt_count")
|
||||
.eq("status", "pending")
|
||||
.lte("scheduled_for", nowIso)
|
||||
.order("scheduled_for", { ascending: true })
|
||||
.limit(limit * 2);
|
||||
if (error || !Array.isArray(data)) return [];
|
||||
// Attempts are filtered here: the self-hosted query builder has no lt()
|
||||
// (an unimplemented filter once broke a list for two weeks, BUG-990).
|
||||
return (data as { id: unknown; attempt_count: unknown }[])
|
||||
.filter((row) => typeof row.attempt_count !== "number" || row.attempt_count < maxAttempts)
|
||||
.slice(0, limit)
|
||||
.map((row) => String(row.id));
|
||||
},
|
||||
purge: async (requestId) => {
|
||||
const { data, error } = await admin.rpc("purge_deleted_account", { p_request_id: requestId });
|
||||
if (error) return { ok: false, code: purgeErrorCode(error.message) };
|
||||
const status = (data as { status?: unknown } | null)?.status;
|
||||
return { ok: true, status: typeof status === "string" ? status : "unknown" };
|
||||
},
|
||||
markFailed: async (requestId, code) => {
|
||||
await admin.rpc("mark_account_deletion_attempt_failed", { p_request_id: requestId, p_error_code: code });
|
||||
},
|
||||
log: (line) => console.info(line),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One unref'ed timer per server process: first run a minute after start, then
|
||||
* every 30 minutes. Several instances are safe — the purge locks the request
|
||||
* row and a completed request is skipped.
|
||||
*/
|
||||
export function startAccountDeletionWorker(): void {
|
||||
const state = globalThis as WorkerGlobal;
|
||||
if (state.jyotishaAccountDeletionWorker) return;
|
||||
const run = () => {
|
||||
tick().catch((error: unknown) => {
|
||||
console.error(`[account-deletion-worker] tick failed reason=${error instanceof Error ? error.name : "UnknownError"}`);
|
||||
});
|
||||
};
|
||||
const first = setTimeout(run, ACCOUNT_DELETION_FIRST_TICK_MS);
|
||||
const every = setInterval(run, ACCOUNT_DELETION_TICK_MS);
|
||||
first.unref?.();
|
||||
every.unref?.();
|
||||
state.jyotishaAccountDeletionWorker = { stop: () => { clearTimeout(first); clearInterval(every); } };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Self-service account deletion (2026-09-30). Shared by the settings entry,
|
||||
* the frozen-account screen, the API route and the purge worker. Pure: no
|
||||
* server imports, so client components can use it.
|
||||
*
|
||||
* Rules (product): request → signed out everywhere, account frozen; within 7
|
||||
* days signing in again offers 撤销注销; after 7 days personal content is
|
||||
* deleted and the identity becomes an anonymous tombstone. Orders, the credit
|
||||
* ledger, usage and billing rows are kept for bookkeeping, pointing at that
|
||||
* tombstone. Remaining credits are forfeited.
|
||||
*/
|
||||
|
||||
export const ACCOUNT_DELETION_GRACE_DAYS = 7;
|
||||
/** Typed by the user to confirm, the same way the staging reset asks for a phrase. */
|
||||
export const ACCOUNT_DELETION_CONFIRM_WORD = "注销";
|
||||
/** Stable code every paid route returns while a deletion is pending. */
|
||||
export const ACCOUNT_DELETION_PENDING_CODE = "account_deletion_pending";
|
||||
|
||||
/** Mirrors `public.account_deletion_kept_tables()`; everything else the user owns is deleted. */
|
||||
export const ACCOUNT_DELETION_KEPT_TABLES = [
|
||||
"account_deletion_requests",
|
||||
"admin_session_revocations",
|
||||
"admin_user_roles",
|
||||
"admin_users",
|
||||
"birth_time_rectification_billing",
|
||||
"consultation_requests",
|
||||
"credit_request_cancellations",
|
||||
"credit_transactions",
|
||||
"payment_orders",
|
||||
"pricing_experiment_events",
|
||||
"redemption_attempts",
|
||||
"redemption_codes",
|
||||
"usage_ledger",
|
||||
"usage_reservations",
|
||||
"user_product_redemptions",
|
||||
"user_subscriptions",
|
||||
] as const;
|
||||
|
||||
/** What the confirmation dialog tells the user will be deleted. */
|
||||
export const ACCOUNT_DELETION_DELETED_ITEMS = [
|
||||
"全部对话记录",
|
||||
"你和星盘档案里其他人的出生资料与星盘",
|
||||
"个人报告",
|
||||
"生时校正记录",
|
||||
"合盘记录",
|
||||
"账号本身(邮箱、昵称、登录方式)",
|
||||
] as const;
|
||||
|
||||
export type AccountDeletionStatus =
|
||||
| Readonly<{ status: "none" }>
|
||||
| Readonly<{ status: "pending"; requestedAt: string | null; scheduledFor: string }>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isoOrNull(value: unknown): string | null {
|
||||
if (typeof value !== "string" && !(value instanceof Date)) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
/** Reads the status the API returns (and the RPC result); anything unusable is "none". */
|
||||
export function parseAccountDeletionStatus(value: unknown): AccountDeletionStatus {
|
||||
if (!isRecord(value) || value.status !== "pending") return { status: "none" };
|
||||
const scheduledFor = isoOrNull(value.scheduledFor);
|
||||
if (!scheduledFor) return { status: "none" };
|
||||
return { status: "pending", requestedAt: isoOrNull(value.requestedAt), scheduledFor };
|
||||
}
|
||||
|
||||
/** 「10 月 7 日」 in Beijing time — the day the deletion becomes permanent. */
|
||||
export function formatAccountDeletionDate(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "七天后";
|
||||
const parts = new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", month: "numeric", day: "numeric" }).formatToParts(date);
|
||||
const month = parts.find((part) => part.type === "month")?.value;
|
||||
const day = parts.find((part) => part.type === "day")?.value;
|
||||
return month && day ? `${month} 月 ${day} 日` : "七天后";
|
||||
}
|
||||
|
||||
export function isAccountDeletionConfirmation(input: unknown): boolean {
|
||||
return typeof input === "string" && input.trim() === ACCOUNT_DELETION_CONFIRM_WORD;
|
||||
}
|
||||
|
||||
/** Maps a database error from the request RPC to a stable route code. */
|
||||
export function accountDeletionRequestErrorCode(message: string | undefined): "admin_account" | "not_found" | "unavailable" {
|
||||
if (message?.includes("account_deletion_admin_account")) return "admin_account";
|
||||
if (message?.includes("account_deletion_user_not_found")) return "not_found";
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
export type AccountDeletionRpcClient = {
|
||||
rpc(name: string, args: Readonly<Record<string, unknown>>): PromiseLike<{ data: unknown; error: { message?: string } | null }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The permanent-deletion date when the account has a pending request, else
|
||||
* null. A read failure (for example before the migration has run) reads as
|
||||
* "not pending": this guard must never lock people out by accident.
|
||||
*/
|
||||
export async function readPendingAccountDeletionWith(client: AccountDeletionRpcClient, userId: string): Promise<string | null> {
|
||||
try {
|
||||
const { data, error } = await client.rpc("account_deletion_scheduled_for", { p_user_id: userId });
|
||||
if (error || data === null || data === undefined) return null;
|
||||
return isoOrNull(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,7 @@ const resourcePermissions: Record<string, { read: string; write?: string }> = {
|
||||
},
|
||||
roles: { read: "admin.users.read" },
|
||||
customers: { read: "admin.customers.read" },
|
||||
"account-deletions": { read: "admin.customers.read" },
|
||||
codes: { read: "admin.access", write: "admin.access" },
|
||||
"credit-transactions": { read: "billing.orders.read" },
|
||||
consultations: { read: "billing.orders.read" },
|
||||
|
||||
Reference in New Issue
Block a user