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,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 /> } },