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
+8
View File
@@ -24,6 +24,14 @@
- 替代证据:`frontend/tests/feedback-complaints-20260930.test.tsx`(校验、错误映射、弹窗提交与限流提示、评价按文本哈希恢复、SQL 与 RBAC 合同);部署到 staging 时迁移会被真实执行一次。
- 解除条件:staging 迁移成功后,用受控账号提交一条反馈、连续提交第 6 条看到限流提示、在后台把它改为「已解决」并在审计日志看到记录;点赞后刷新对话仍在。
## 自助注销账号(2026-09-30,PROGRESS-account-deletion-20260930):DB 测试、真实清除与端到端未跑
- 本机无 Docker:`frontend/tests/database-account-deletion.test.ts`(清除函数真库验证)skipped,需 `npm run test:db`。全量 `npm test` 的 24 条 DB / 部署套件失败与基线逐条同名。
- 迁移 `20260930020000_account_deletion_requests.sql` 只在 staging 部署时首次应用;清除任务的真实执行要等 7 天,或在 staging 库把测试账号的 `scheduled_for` 调早后观察。
- 无受控 staging 账号:注销 → 各端退出 → 重登见「账号注销中」→ 撤销注销,这条链路未在浏览器走过。
- 运营主体与联系邮箱仍是 `lib/legal-entity.ts` 占位值。
- 未推送、未部署。
## TASK-chart-surface-polish:受控登录、实体手机与基线全量失败(2026-09-29)
- 本轮 Chrome、Edge、Docker 均可用,不套用历史“无 Chrome / 无 Docker”。真实 Chrome + golden 的本地组件验收及骨架高度修复后复验已完成(70+8 项通过);没有受控线上登录账号、实体手机和读屏实测;不能代替完整账户/人物/请求链路。清单见 `docs/testing/chart-surface-polish-20260929.md`。
+9
View File
@@ -15,6 +15,15 @@
- 管理后台新增「反馈与投诉」列表:按状态或类型筛选,新的内容举报排在最前面并标红;可以改处理状态、写内部备注,每次处理都记入审计日志。
- 新增数据库表 `user_feedback`、`reply_ratings`(只加不改)。Skill 版本不 bump。
## 2026-09-30 — 自助注销账号,7 天冷静期(待验收)
- 设置 → 通用设置最底下新增「注销账号」。点开后说明会删除什么、保留什么,并显示联系邮箱;要输入「注销」才能提交。
- 提交后所有设备立即退出登录,账号冻结:咨询、校正、报告、合盘都不能再扣点(接口返回 423 `account_deletion_pending`,数据库也拒绝扣点)。
- 7 天内重新登录只看到「账号注销中,将在 X 月 X 日永久删除」,可以「撤销注销」恢复全部内容,也可以「退出登录」。
- 7 天后后台任务永久删除对话、星盘档案、报告、合盘、校正等个人内容(按 user_id 自动发现的所有非财务表);订单、支付、点数流水按法规保留,但登录邮箱与姓名被抹掉,不再与身份关联。剩余点数和会员权益作废,不退款。
- 后台新增只读的「注销申请」列表(不显示邮箱)。
- 新增迁移 `20260930020000_account_deletion_requests.sql`(只加表、函数、触发器)。Skill 版本不 bump。
## 2026-09-30 — 首页去掉校正提示、星盘类型文字完整显示、加载改为轨道环动画、「那一刻的天空」换小星座图标(待验收)
- 首页不再显示「上次那次校正还没完成,可以在历史对话里接着做。」(BUG-1111)。
@@ -0,0 +1,50 @@
# PROGRESS — 自助注销账号 + 7 天冷静期(2026-09-30)
分支 `codex/account-deletion-20260930`,基线 `codex/compliance-base-20260930`(2cd37720,含 `lib/legal-entity.ts` 占位主体)。产品决定:注销 = 7 天冷静期后删除;联系方式先用占位。
## 做了什么
| 部分 | 位置 |
| --- | --- |
| 迁移(只加) | `frontend/supabase/migrations/20260930020000_account_deletion_requests.sql`:表 `account_deletion_requests`、函数 `request_account_deletion` / `cancel_account_deletion` / `account_deletion_scheduled_for` / `purge_deleted_account` / `mark_account_deletion_attempt_failed`、扣点拦截触发器 |
| API | `GET/POST/DELETE /api/account/deletion`(POST 同源校验 + 必须 `confirm:"注销"`;管理员账号 403) |
| 冻结 | 咨询、报告、校正 agent、打开校正、合盘五条付费路由认证后先查,注销中返回 423 `account_deletion_pending`;数据库层 `usage_reservations`、`birth_time_rectification_billing`、`credit_transactions`(amount < 0)BEFORE INSERT 触发器同码拒绝 |
| 前端 | 通用设置底部「注销账号」区;`(app)` 布局的「账号注销中」全屏门(撤销注销 / 退出登录) |
| 后台 | `/admin/account-deletions` 只读列表(`admin.customers.read`,不显示邮箱) |
| 清除任务 | `lib/account-deletion-worker.ts`,由 `instrumentation.ts` 启动 |
## 删除 vs 去标识
- **删除**:`public` 下所有以 `user_id` 指向 `auth.users` 的表(外键自动发现 + 带 uuid `user_id` 列的表),以及 `profiles`(按 `id`)。包括对话、星盘档案、个人报告及其任务/分节、校正 case、合盘、记忆等。`created_by` / `updated_by` 这类运营配置列不算用户内容,不删。
- **保留并去标识**(`account_deletion_kept_tables()`,前后端同一份名单,测试比对):`payment_orders`、`credit_transactions`、`usage_ledger`、`usage_reservations`、`user_subscriptions`、`user_product_redemptions`、`redemption_attempts`、`redemption_codes`、`credit_request_cancellations`、`birth_time_rectification_billing`、`consultation_requests`、`pricing_experiment_events`、`account_deletion_requests`、`admin_*` 三张。
- **身份墓碑**:`identity.users` / `auth.users` 行保留(财务表外键是 cascade,删身份会连带删账),邮箱改为 `deleted+<id>@deleted.invalid`,姓名「已注销用户」,封禁;`sessions`、`accounts`、`two_factors`、`verifications` 删除。
- 全部在一个事务里:删不干净(多轮重试外键顺序后仍有剩余)就整体回滚,记 `purge_incomplete`,下次再试。
## 清除任务怎么跑
进程启动 60 秒后第一次,之后每 30 分钟一次(`unref` 定时器,globalThis 防重)。每次取最多 20 条到期、`attempt_count < 5` 的 pending 申请,逐条调 `purge_deleted_account`;函数对未到期 / 已撤销 / 已完成返回 skipped,可重复执行。失败记错误码与次数,满 5 次停止重试,后台列表可见。日志只写计数,不写 id 或邮箱。
## 验证
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error / 126 warning(与基线同) |
| 新单测 `tests/account-deletion.test.tsx` | 8/8 |
| 新 DB 测 `tests/database-account-deletion.test.ts` | 本机无 Docker,skipped |
| 全量 `npm test` | 4427 项,fail 25 = 基线 24 条环境失败(同名)+ 1 条合同测试;改后该条通过 |
| `next build` | 通过,`/` ○ Static |
| 首屏 gzip | 648,688 B(基线约 646,480,+0.34%) |
改动的既有断言(`frontend/tests/settings-mvp-contract.test.ts`):
| 原值 | 新值 | 原因 |
| --- | --- | --- |
| `renderGeneral() { return <ThemePreferencePanel />;` | `return <><ThemePreferencePanel /><AccountDeletionSection /></>;` | 注销入口放通用设置底部,头像菜单不加入口 |
## 未验证 / 待办
- DB 测试(请求→会话清空→扣点被拒→撤销→到期清除→内容删、流水留、身份墓碑→重复执行无副作用)需 Docker 跑 `npm run test:db`。
- 迁移只在 staging 部署时首次真实应用;清除任务真实执行需等 7 天或在 staging 库手工把 `scheduled_for` 调早。
- 无受控 staging 账号,端到端(注销→退出→重登见门→撤销)未走。
- 与 fork C(反馈表)若新增带 `user_id` 的表,会被自动归入删除;如需保留投诉记录,需加入保留名单。
+7
View File
@@ -1226,3 +1226,10 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
- **结束后没有提示:** 过场正常结束或被点掉后直接露出下一步,不弹任何提示(2026-09-29 产品去掉原来的「这片天空在星盘页可以保存。」)。
- **平时的首页加载动画不改。** 这是用户操作之后的一次性结果展示,不是首页加载等待,不违反 §9「首页只揭幕一次」;轨道环揭幕逻辑不动。
- 代码:时序与插值是 `lib/birth-sky/converge.ts` 的纯函数;过场组件 `components/birth-sky/birth-sky-converge.tsx`(懒加载 chunk,含 `draw.ts` 与样式);状态在 `hooks/use-birth-sky-reveal.ts`,`page.tsx` 接线未变(`Home()` 的 `useState` / `useRef` 数不变)。星盘页的「那一刻的天空」弹窗删掉了只给揭幕用的 `is-reveal` 样式与「继续」按钮。
## 18. 注销账号(2026-09-30,自助注销 + 7 天冷静期)
- **入口:** 设置弹窗 → 通用设置,主题偏好之下,一条 hairline 分隔后的「注销账号」区(`.account-deletion-section`)。头像菜单不加入口。触发按钮是描边危险色、透明底、44px 高(`.account-deletion-open`)。
- **确认块:** 原地展开,不再叠一层弹窗。浅危险色底(`--color-danger-muted`)、危险色 35% 描边;说明、删除清单、保留说明、联系邮箱(次级墨色)、「输入「注销」确认」输入框,底部「取消」+ 实心危险「确认注销」(`.danger-primary`)。没输对字之前确认按钮禁用。
- **冷静期门:** 登录后若有待执行的注销,`(app)` 布局渲染全屏 `role="alertdialog"`(`.account-deletion-gate`,z-index 90,`--color-canvas-soft` 底),中间 440px 卡片:宋体标题「账号注销中」、日期句、「退出登录」「撤销注销」两个按钮居中。不做入场动画,不出现 spinner;撤销后整页刷新。
- 代码:`components/account-deletion-section.tsx`、`components/account-deletion-gate.tsx`;`page.tsx` 只在 `renderGeneral()` 里多挂一个组件,`Home()` 的 `useState` / `useRef` 数不变。
+2
View File
@@ -327,6 +327,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
「解锁完整咨询」兑换弹窗只在第一次因点数不足发不出消息时弹(2026-09-28 起,不再进首页就弹)。说明句写「问题还在输入框里。填写兑换码,点数到账就能发送,生时校正与完整解读也能用。」;到账后写「点数已到账,回到输入框点发送就行。」。不要再写「入门问题已经准备好」,弹出时刚被拦下的是用户自己的问题。
注销账号(2026-09-30):按钮写「注销账号…」,确认按钮写「确认注销」。说明必须三件事都说全:「7 天内重新登录可以撤销」、会被永久删除的内容清单、「剩余点数和会员权益会一并作废,不退款。订单和点数流水按法规保留,但不再与你的身份关联。」联系邮箱只从 `lib/legal-entity.ts` 取。冷静期内重新登录的全屏提示标题「账号注销中」,正文「这个账号将在 {日期} 永久删除。在那之前,你可以撤销注销,恢复全部内容。」按钮只有「退出登录」「撤销注销」。不要写「我们很遗憾」「确定要离开吗」这类挽留话。
## 首屏没加载完
脚本没跑起来、关键文件没下到,或浏览器版本过旧时,转圈停在「正在载入账户」不会自己结束。超时后只留这一屏,不自动刷新:
+2
View File
@@ -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>
);
}
+2 -1
View File
@@ -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);
}
}
+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" },
+17
View File
@@ -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 /> } },
+4
View File
@@ -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); } };
}
+109
View File
@@ -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;
}
}
+1
View File
@@ -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" },
@@ -0,0 +1,428 @@
-- Self-service account deletion with a 7-day cooling-off period (2026-09-30).
--
-- request_account_deletion: records a pending request, signs the user out
-- everywhere (identity sessions deleted). Charges are refused while pending.
-- cancel_account_deletion: within the 7 days, the user signs in again and
-- restores the account.
-- purge_deleted_account: after the 7 days, in ONE transaction, deletes every
-- personal-content row the user owns and anonymises the identity. Financial
-- records (orders, credit ledger, usage, subscriptions, billing) are kept
-- for bookkeeping; their user_id then points at an anonymous tombstone
-- identity with no email, name or login. A failure rolls everything back;
-- the worker retries.
--
-- Add-only: new table, new functions, new triggers. No existing column,
-- constraint or function changes.
begin;
do $migration$
begin
if current_user <> 'schema_owner' then
raise exception 'account_deletion_requests_requires_schema_owner'
using errcode = '42501';
end if;
end
$migration$;
create table if not exists public.account_deletion_requests (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
status text not null default 'pending'
check (status in ('pending', 'cancelled', 'completed')),
requested_at timestamptz not null default now(),
scheduled_for timestamptz not null,
cancelled_at timestamptz,
completed_at timestamptz,
attempt_count integer not null default 0 check (attempt_count >= 0),
error_code text
check (error_code is null or error_code ~ '^[a-z][a-z0-9_]{0,63}$'),
-- Counts per table only; never names, emails or content.
outcome jsonb,
updated_at timestamptz not null default now(),
constraint account_deletion_requests_schedule_check
check (scheduled_for > requested_at),
constraint account_deletion_requests_cancelled_check
check ((status = 'cancelled') = (cancelled_at is not null)),
constraint account_deletion_requests_completed_check
check ((status = 'completed') = (completed_at is not null))
);
create unique index if not exists account_deletion_requests_one_pending_idx
on public.account_deletion_requests (user_id)
where status = 'pending';
create index if not exists account_deletion_requests_due_idx
on public.account_deletion_requests (scheduled_for)
where status = 'pending';
alter table public.account_deletion_requests enable row level security;
revoke all on table public.account_deletion_requests
from public, anon, authenticated, service_role;
revoke all on table public.account_deletion_requests
from app_runtime, admin_runtime, migration_runner, backup_reader;
drop policy if exists account_deletion_requests_select_own
on public.account_deletion_requests;
create policy account_deletion_requests_select_own
on public.account_deletion_requests
for select
to authenticated
using (auth.uid() = user_id);
grant select on table public.account_deletion_requests to authenticated;
grant select, insert, update on table public.account_deletion_requests to service_role;
-- Operators read the list (status and dates only; the table holds no email).
do $$
begin
if exists (select 1 from pg_roles where rolname = 'admin_runtime') then
grant select on table public.account_deletion_requests to admin_runtime;
drop policy if exists account_deletion_requests_admin_runtime_read on public.account_deletion_requests;
create policy account_deletion_requests_admin_runtime_read
on public.account_deletion_requests
for select to admin_runtime using (true);
end if;
end;
$$;
-- Tables whose rows are KEPT (de-identified through the tombstone identity).
-- Everything else in public that belongs to the user is deleted.
create or replace function public.account_deletion_kept_tables()
returns text[]
language sql
immutable
set search_path = pg_catalog
as $$
select array[
'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'
]::text[];
$$;
create or replace function public.account_deletion_scheduled_for(p_user_id uuid)
returns timestamptz
language sql
stable
security definer
set search_path = pg_catalog, public
as $$
select scheduled_for
from public.account_deletion_requests
where user_id = p_user_id and status = 'pending'
limit 1;
$$;
create or replace function public.request_account_deletion(p_user_id uuid)
returns jsonb
language plpgsql
security definer
set search_path = pg_catalog, public
as $$
declare
v_row public.account_deletion_requests%rowtype;
begin
if p_user_id is null then
raise exception 'account_deletion_user_required' using errcode = '22023';
end if;
if not exists (select 1 from auth.users where id = p_user_id) then
raise exception 'account_deletion_user_not_found' using errcode = '22023';
end if;
-- Operators are removed through the admin role flow, not self-service.
if exists (
select 1 from public.admin_users
where user_id = p_user_id and revoked_at is null
) then
raise exception 'account_deletion_admin_account' using errcode = '42501';
end if;
select * into v_row
from public.account_deletion_requests
where user_id = p_user_id and status = 'pending'
for update;
if not found then
insert into public.account_deletion_requests (user_id, status, requested_at, scheduled_for)
values (p_user_id, 'pending', now(), now() + interval '7 days')
returning * into v_row;
end if;
-- Signed out everywhere, at once.
if to_regclass('identity.sessions') is not null then
execute 'delete from identity.sessions where user_id = $1' using p_user_id;
end if;
return jsonb_build_object(
'status', v_row.status,
'requestedAt', v_row.requested_at,
'scheduledFor', v_row.scheduled_for
);
end;
$$;
create or replace function public.cancel_account_deletion(p_user_id uuid)
returns boolean
language plpgsql
security definer
set search_path = pg_catalog, public
as $$
declare
v_count integer;
begin
update public.account_deletion_requests
set status = 'cancelled',
cancelled_at = now(),
updated_at = now()
where user_id = p_user_id
and status = 'pending'
and scheduled_for > now();
get diagnostics v_count = row_count;
return v_count > 0;
end;
$$;
-- Charges are refused while a deletion is pending: a second line behind the
-- routes' own check, so no code path can bill a frozen account.
create or replace function public.refuse_charge_while_deletion_pending()
returns trigger
language plpgsql
security definer
set search_path = pg_catalog, public
as $$
begin
if tg_table_name = 'credit_transactions' and coalesce(new.amount, 0) >= 0 then
return new;
end if;
if exists (
select 1 from public.account_deletion_requests
where user_id = new.user_id and status = 'pending'
) then
raise exception 'account_deletion_pending' using errcode = '55000';
end if;
return new;
end;
$$;
drop trigger if exists usage_reservations_refuse_while_deletion_pending on public.usage_reservations;
create trigger usage_reservations_refuse_while_deletion_pending
before insert on public.usage_reservations
for each row execute function public.refuse_charge_while_deletion_pending();
drop trigger if exists rectification_billing_refuse_while_deletion_pending on public.birth_time_rectification_billing;
create trigger rectification_billing_refuse_while_deletion_pending
before insert on public.birth_time_rectification_billing
for each row execute function public.refuse_charge_while_deletion_pending();
drop trigger if exists credit_transactions_refuse_debit_while_deletion_pending on public.credit_transactions;
create trigger credit_transactions_refuse_debit_while_deletion_pending
before insert on public.credit_transactions
for each row execute function public.refuse_charge_while_deletion_pending();
-- The permanent step. Idempotent and all-or-nothing: rows are deleted in
-- repeated passes (so a child table blocked by a restrict foreign key is
-- retried after its parent is gone); if anything the user owns is still
-- there at the end, the whole transaction is rolled back.
create or replace function public.purge_deleted_account(p_request_id uuid)
returns jsonb
language plpgsql
security definer
set search_path = pg_catalog, public
as $$
declare
v_request public.account_deletion_requests%rowtype;
v_user uuid;
v_kept text[] := public.account_deletion_kept_tables();
v_target record;
v_pass integer;
v_progress boolean;
v_deleted jsonb := '{}'::jsonb;
v_count bigint;
v_remaining bigint;
v_left text[] := array[]::text[];
begin
select * into v_request
from public.account_deletion_requests
where id = p_request_id
for update;
if not found then
raise exception 'account_deletion_request_not_found' using errcode = '22023';
end if;
if v_request.status <> 'pending' then
return jsonb_build_object('status', 'skipped', 'reason', v_request.status);
end if;
if v_request.scheduled_for > now() then
return jsonb_build_object('status', 'skipped', 'reason', 'not_due');
end if;
v_user := v_request.user_id;
-- Every public base table that holds the user's rows: by a user_id foreign
-- key to auth.users (and profiles.id), or by a uuid user_id column without one.
create temporary table if not exists account_purge_targets (
table_name text not null,
column_name text not null,
primary key (table_name, column_name)
) on commit drop;
truncate account_purge_targets;
insert into account_purge_targets (table_name, column_name)
select distinct cls.relname, att.attname
from pg_constraint con
join pg_class cls on cls.oid = con.conrelid
join pg_namespace nsp on nsp.oid = cls.relnamespace
join pg_attribute att on att.attrelid = con.conrelid and att.attnum = con.conkey[1]
where con.contype = 'f'
and con.confrelid = 'auth.users'::regclass
and array_length(con.conkey, 1) = 1
and nsp.nspname = 'public'
and cls.relkind = 'r'
-- Ownership columns only. created_by / updated_by / assigned_by point at
-- operators who authored configuration; those rows are not the user's.
and (att.attname = 'user_id' or (cls.relname = 'profiles' and att.attname = 'id'))
and not (cls.relname = any (v_kept))
on conflict do nothing;
insert into account_purge_targets (table_name, column_name)
select col.table_name, col.column_name
from information_schema.columns col
join information_schema.tables tab
on tab.table_schema = col.table_schema and tab.table_name = col.table_name
where col.table_schema = 'public'
and col.column_name = 'user_id'
and col.data_type = 'uuid'
and tab.table_type = 'BASE TABLE'
and not (col.table_name = any (v_kept))
on conflict do nothing;
for v_pass in 1..6 loop
v_progress := false;
for v_target in select table_name, column_name from account_purge_targets order by table_name loop
begin
execute format('delete from public.%I where %I = $1', v_target.table_name, v_target.column_name)
using v_user;
get diagnostics v_count = row_count;
if v_count > 0 then
v_progress := true;
v_deleted := jsonb_set(
v_deleted,
array[v_target.table_name],
to_jsonb(coalesce((v_deleted ->> v_target.table_name)::bigint, 0) + v_count)
);
end if;
exception when foreign_key_violation then
-- A kept or not-yet-deleted row still points here; try again next pass.
null;
end;
end loop;
exit when not v_progress;
end loop;
for v_target in select table_name, column_name from account_purge_targets loop
execute format('select count(*) from public.%I where %I = $1', v_target.table_name, v_target.column_name)
into v_remaining using v_user;
if v_remaining > 0 then
v_left := v_left || v_target.table_name;
end if;
end loop;
if array_length(v_left, 1) > 0 then
raise exception 'account_deletion_purge_incomplete: %', array_to_string(v_left, ',')
using errcode = '55000';
end if;
-- The identity becomes an anonymous tombstone: no email, name, image,
-- login method, session or pending verification. Kept financial rows point
-- at it and at nothing else.
if to_regclass('identity.users') is not null then
execute 'delete from identity.sessions where user_id = $1' using v_user;
execute 'delete from identity.accounts where user_id = $1' using v_user;
if to_regclass('identity.two_factors') is not null then
execute 'delete from identity.two_factors where user_id = $1' using v_user;
end if;
execute 'delete from identity.verifications where identifier in (
select email from identity.users where id = $1
union all select ''sign-in-otp-'' || email from identity.users where id = $1
union all select ''email-verification-otp-'' || email from identity.users where id = $1
union all select ''forget-password-otp-'' || email from identity.users where id = $1)'
using v_user;
execute 'update identity.users
set name = ''已注销用户'',
email = ''deleted+'' || id::text || ''@deleted.invalid'',
email_verified = false,
email_verified_at = null,
image = null,
banned = true,
ban_reason = ''account_deleted'',
updated_at = now()
where id = $1'
using v_user;
end if;
-- The identity trigger mirrors the email; the metadata is cleared here too,
-- and directly when there is no identity schema.
update auth.users
set email = 'deleted+' || id::text || '@deleted.invalid',
raw_user_meta_data = '{}'::jsonb,
email_confirmed_at = null,
updated_at = now()
where id = v_user;
update public.account_deletion_requests
set status = 'completed',
completed_at = now(),
error_code = null,
outcome = jsonb_build_object('deleted', v_deleted, 'kept', to_jsonb(v_kept)),
updated_at = now()
where id = p_request_id;
return jsonb_build_object('status', 'completed', 'deleted', v_deleted);
end;
$$;
-- The worker records a failed attempt outside the rolled-back purge.
create or replace function public.mark_account_deletion_attempt_failed(p_request_id uuid, p_error_code text)
returns void
language plpgsql
security definer
set search_path = pg_catalog, public
as $$
begin
update public.account_deletion_requests
set attempt_count = attempt_count + 1,
error_code = case
when p_error_code ~ '^[a-z][a-z0-9_]{0,63}$' then p_error_code
else 'purge_failed'
end,
updated_at = now()
where id = p_request_id and status = 'pending';
end;
$$;
revoke all on function public.account_deletion_kept_tables() from public, anon, authenticated;
revoke all on function public.account_deletion_scheduled_for(uuid) from public, anon, authenticated;
revoke all on function public.request_account_deletion(uuid) from public, anon, authenticated;
revoke all on function public.cancel_account_deletion(uuid) from public, anon, authenticated;
revoke all on function public.refuse_charge_while_deletion_pending() from public, anon, authenticated;
revoke all on function public.purge_deleted_account(uuid) from public, anon, authenticated;
revoke all on function public.mark_account_deletion_attempt_failed(uuid, text) from public, anon, authenticated;
grant execute on function public.account_deletion_kept_tables() to service_role;
grant execute on function public.account_deletion_scheduled_for(uuid) to service_role;
grant execute on function public.request_account_deletion(uuid) to service_role;
grant execute on function public.cancel_account_deletion(uuid) to service_role;
grant execute on function public.purge_deleted_account(uuid) to service_role;
grant execute on function public.mark_account_deletion_attempt_failed(uuid, text) to service_role;
commit;
+173
View File
@@ -0,0 +1,173 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { AccountDeletionGate } from "../src/components/account-deletion-gate";
import { AccountDeletionSection } from "../src/components/account-deletion-section";
import {
ACCOUNT_DELETION_KEPT_TABLES,
accountDeletionRequestErrorCode,
formatAccountDeletionDate,
isAccountDeletionConfirmation,
parseAccountDeletionStatus,
readPendingAccountDeletionWith,
} from "../src/lib/account-deletion";
import {
ACCOUNT_DELETION_MAX_ATTEMPTS,
purgeErrorCode,
runAccountDeletionTick,
} from "../src/lib/account-deletion-worker-core";
import { LEGAL_ENTITY } from "../src/lib/legal-entity";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
// Self-service account deletion with a 7-day cooling-off period (2026-09-30).
Object.assign(globalThis, { React });
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
const migration = read("../supabase/migrations/20260930020000_account_deletion_requests.sql");
test("status parsing, confirmation word, date and error codes", () => {
assert.deepEqual(parseAccountDeletionStatus(null), { status: "none" });
assert.deepEqual(parseAccountDeletionStatus({ status: "none" }), { status: "none" });
assert.deepEqual(parseAccountDeletionStatus({ status: "pending", scheduledFor: "not a date" }), { status: "none" });
const pending = parseAccountDeletionStatus({ status: "pending", requestedAt: "2026-09-30T02:00:00Z", scheduledFor: "2026-10-07T02:00:00Z" });
assert.deepEqual(pending, { status: "pending", requestedAt: "2026-09-30T02:00:00.000Z", scheduledFor: "2026-10-07T02:00:00.000Z" });
assert.equal(formatAccountDeletionDate("2026-10-07T02:00:00Z"), "10 月 7 日");
assert.equal(formatAccountDeletionDate("2026-10-06T17:00:00Z"), "10 月 7 日", "Beijing date, not UTC");
assert.equal(isAccountDeletionConfirmation(" 注销 "), true);
assert.equal(isAccountDeletionConfirmation("注 销"), false);
assert.equal(isAccountDeletionConfirmation(undefined), false);
assert.equal(accountDeletionRequestErrorCode("ERROR: account_deletion_admin_account"), "admin_account");
assert.equal(accountDeletionRequestErrorCode("connection refused"), "unavailable");
});
test("the pending-deletion read never locks anyone out on an error", async () => {
const ok = { rpc: async () => ({ data: "2026-10-07T02:00:00Z", error: null }) };
assert.equal(await readPendingAccountDeletionWith(ok, "u"), "2026-10-07T02:00:00.000Z");
assert.equal(await readPendingAccountDeletionWith({ rpc: async () => ({ data: null, error: null }) }, "u"), null);
assert.equal(await readPendingAccountDeletionWith({ rpc: async () => ({ data: null, error: { message: "function does not exist" } }) }, "u"), null);
assert.equal(await readPendingAccountDeletionWith({ rpc: async () => { throw new Error("down"); } }, "u"), null);
});
test("the kept tables in code and in SQL are the same list, and finance is kept", () => {
const sqlList = migration.slice(migration.indexOf("select array["), migration.indexOf("]::text[]"));
const sqlTables = [...sqlList.matchAll(/'([a-z_]+)'/g)].map((match) => match[1]);
assert.deepEqual(sqlTables, [...ACCOUNT_DELETION_KEPT_TABLES]);
for (const table of ["payment_orders", "credit_transactions", "usage_ledger", "usage_reservations", "user_subscriptions", "birth_time_rectification_billing"]) {
assert.ok((ACCOUNT_DELETION_KEPT_TABLES as readonly string[]).includes(table), table);
}
for (const table of ["chat_sessions", "chart_profiles", "personal_reports", "synastry_reports", "profiles"]) {
assert.ok(!(ACCOUNT_DELETION_KEPT_TABLES as readonly string[]).includes(table), `${table} is deleted`);
}
});
test("the migration is add-only, all-or-nothing, and only touches ownership columns", () => {
assert.doesNotMatch(migration, /\bdrop\s+(table|column)\b|alter\s+table\s+public\.(?!account_deletion_requests)\w+\s+(drop|alter|rename)/i);
assert.match(migration, /create table if not exists public\.account_deletion_requests/);
assert.match(migration, /now\(\) \+ interval '7 days'/);
// Signed out everywhere on request.
assert.match(migration, /delete from identity\.sessions where user_id = \$1/);
// Deletes keep retrying across passes; leftovers roll the whole thing back.
assert.match(migration, /exception when foreign_key_violation/);
assert.match(migration, /raise exception 'account_deletion_purge_incomplete/);
// created_by / updated_by rows are operators' configuration, never the user's.
assert.match(migration, /att\.attname = 'user_id' or \(cls\.relname = 'profiles' and att\.attname = 'id'\)/);
// Tombstone identity: no email, name, login.
assert.match(migration, /email = ''deleted\+'' \|\| id::text \|\| ''@deleted\.invalid''/);
assert.match(migration, /delete from identity\.accounts where user_id = \$1/);
// No charge while pending, enforced in the database too.
for (const table of ["usage_reservations", "birth_time_rectification_billing", "credit_transactions"]) {
assert.match(migration, new RegExp(`before insert on public\\.${table}`), table);
}
assert.match(migration, /raise exception 'account_deletion_admin_account'/);
});
test("the purge worker completes, skips, counts failures and logs no identifiers", async () => {
const logs: string[] = [];
const failed: [string, string][] = [];
const seen: { limit: number; attempts: number }[] = [];
const result = await runAccountDeletionTick({
now: () => new Date("2026-10-08T00:00:00Z"),
listDue: async (_now, limit, attempts) => { seen.push({ limit, attempts }); return ["r1", "r2", "r3"]; },
purge: async (id) => (id === "r1" ? { ok: true, status: "completed" } : id === "r2" ? { ok: true, status: "skipped" } : { ok: false, code: "purge_incomplete" }),
markFailed: async (id, code) => { failed.push([id, code]); },
log: (line) => logs.push(line),
});
assert.deepEqual(result, { due: 3, completed: 1, skipped: 1, failed: 1 });
assert.deepEqual(failed, [["r3", "purge_incomplete"]]);
assert.equal(seen[0]?.attempts, ACCOUNT_DELETION_MAX_ATTEMPTS);
assert.equal(logs.length, 1);
assert.doesNotMatch(logs[0]!, /r1|r2|r3|@/);
assert.equal(purgeErrorCode("account_deletion_purge_incomplete: chat_sessions"), "purge_incomplete");
assert.equal(purgeErrorCode("boom"), "purge_failed");
const quiet: string[] = [];
await runAccountDeletionTick({ now: () => new Date(), listDue: async () => [], purge: async () => ({ ok: true, status: "completed" }), markFailed: async () => {}, log: (line) => quiet.push(line) });
assert.deepEqual(quiet, [], "an idle tick says nothing");
});
test("every paid route refuses a frozen account before doing work", () => {
for (const path of [
"../src/app/api/consult/route.ts",
"../src/app/api/reports/route.ts",
"../src/app/api/rectification/agent/route.ts",
"../src/app/api/rectification/cases/open/route.ts",
"../src/app/api/synastry/route.ts",
]) {
const source = read(path);
assert.match(source, /refuseWhenAccountDeletionPending\(/, path);
assert.match(source, /if \(frozen\) return frozen;/, path);
}
const server = read("../src/lib/account-deletion-server.ts");
assert.match(server, /status: 423/);
assert.match(read("../src/instrumentation.ts"), /startAccountDeletionWorker\(\);/);
});
test("the settings section explains the rule and only submits after 「注销」 is typed", async () => {
const h = createClientLifecycleHarness();
try {
await h.render(<AccountDeletionSection />);
const open = h.elements().find((node) => node.tagName === "BUTTON" && node.text.startsWith("注销账号"))!;
await h.event(open);
const text = h.container.text;
assert.match(text, /7 天内重新登录可以撤销/);
assert.match(text, /剩余点数和会员权益会一并作废/);
assert.match(text, /订单和点数流水按法规保留/);
assert.ok(text.includes(LEGAL_ENTITY.contactEmail));
const confirm = () => h.elements().find((node) => node.tagName === "BUTTON" && /确认注销|正在提交/.test(node.text))!;
assert.equal(confirm().disabled, true);
const input = h.elements().find((node) => node.tagName === "INPUT")!;
await h.event(input, "onChange", { target: { value: "注销" } });
assert.equal(confirm().disabled, false);
assert.deepEqual(h.errors, []);
} finally { await h.close(); }
});
test("a pending account sees 「账号注销中」 with the date and a way back", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response(JSON.stringify({ status: "pending", scheduledFor: "2026-10-07T02:00:00Z" }), { status: 200 })) as typeof fetch;
const h = createClientLifecycleHarness();
try {
await h.render(<AccountDeletionGate signedIn />);
await h.idle();
assert.match(h.container.text, /账号注销中/);
assert.match(h.container.text, /10 月 7 日 永久删除/);
assert.ok(h.elements().some((node) => node.tagName === "BUTTON" && node.text === "撤销注销"));
assert.ok(h.elements().some((node) => node.tagName === "BUTTON" && node.text === "退出登录"));
await h.render(<AccountDeletionGate signedIn={false} />);
} finally {
globalThis.fetch = originalFetch;
await h.close();
}
const signedOut = createClientLifecycleHarness();
let calls = 0;
globalThis.fetch = (async () => { calls += 1; return new Response("{}"); }) as typeof fetch;
try {
await signedOut.render(<AccountDeletionGate signedIn={false} />);
await signedOut.idle();
assert.equal(calls, 0, "no request before sign-in");
assert.equal(signedOut.container.text, "");
} finally {
globalThis.fetch = originalFetch;
await signedOut.close();
}
});
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
// Self-service account deletion (2026-09-30): request signs out and freezes,
// cancel restores, purge deletes personal content and keeps de-identified
// finance rows. Needs Docker (test:db).
const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
const userId = "91000000-0000-4000-8000-000000000001";
const otherId = "91000000-0000-4000-8000-000000000002";
function dockerAvailable(): boolean {
return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { encoding: "utf8", stdio: "ignore" }).status === 0;
}
test("account deletion: request, frozen charges, cancel, purge with kept finance rows", { skip: dockerAvailable() ? false : "docker unavailable on this host" }, () => {
const fixture = startPostgresFixture();
try {
const migration = spawnSync(process.execPath, [runnerPath], {
encoding: "utf8",
env: { ...process.env, SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password") },
});
assert.equal(migration.status, 0, `${migration.stdout}${migration.stderr}`);
assert.match(migration.stdout, /applied 20260930020000_account_deletion_requests\.sql/);
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
insert into identity.users (id, name, email, email_verified, email_verified_at) values
('${userId}', 'Deletion User', 'deletion-user@example.com', true, now()),
('${otherId}', 'Other User', 'deletion-other@example.com', true, now());
insert into identity.accounts (id, account_id, provider_id, user_id, password)
values ('92000000-0000-4000-8000-000000000001', 'deletion-user@example.com', 'credential', '${userId}', 'password-hash');
insert into identity.sessions (id, token, user_id, expires_at)
values ('92000000-0000-4000-8000-000000000002', 'deletion-session-token', '${userId}', now() + interval '1 day');
`);
fixture.psql(`
update public.profiles set credits = 12, name = 'Deletion User', birth_date = '1990-01-02' where id = '${userId}';
insert into public.chat_sessions (user_id, title, theme, messages) values
('${userId}', 'Mine', 'general', '[]'::jsonb), ('${otherId}', 'Theirs', 'general', '[]'::jsonb);
insert into public.chart_profiles (user_id, role, profile) values ('${userId}', 'other', '{}'::jsonb);
insert into public.synastry_reports (user_id, partner_name, report) values ('${userId}', 'Partner', '{}'::jsonb);
insert into public.credit_transactions (user_id, transaction_type, amount, balance_after, request_id)
values ('${userId}', 'redeem', 12, 12, 'deletion-kept-credit');
`);
// Request: pending, signed out everywhere.
const requested = JSON.parse(fixture.psql(`select public.request_account_deletion('${userId}')::text;`).trim().split("\n").pop()!);
assert.equal(requested.status, "pending");
assert.equal(fixture.psql(`select count(*) from identity.sessions where user_id = '${userId}';`).trim(), "0");
// Frozen: a debit is refused, a credit (refund) is not.
assert.throws(() => fixture.psql(`
insert into public.credit_transactions (user_id, transaction_type, amount, balance_after, request_id)
values ('${userId}', 'consume', -1, 11, 'deletion-refused-debit');
`), /account_deletion_pending/);
// Cancel within the window restores; request again for the purge.
assert.match(fixture.psql(`select public.cancel_account_deletion('${userId}');`), /\bt\b/);
fixture.psql(`select public.request_account_deletion('${userId}');`);
const requestId = fixture.psql(`select id from public.account_deletion_requests where user_id = '${userId}' and status = 'pending';`).trim();
// Not due yet: skipped, nothing touched.
assert.match(fixture.psql(`select public.purge_deleted_account('${requestId}')::text;`), /not_due/);
fixture.psql(`update public.account_deletion_requests set requested_at = now() - interval '8 days', scheduled_for = now() - interval '1 day' where id = '${requestId}';`);
const purged = fixture.psql(`select public.purge_deleted_account('${requestId}')::text;`);
assert.match(purged, /"status": ?"completed"/);
const state = JSON.parse(fixture.psql(`
select jsonb_build_object(
'chatMine', (select count(*) from public.chat_sessions where user_id = '${userId}'),
'chatOther', (select count(*) from public.chat_sessions where user_id = '${otherId}'),
'chartProfiles', (select count(*) from public.chart_profiles where user_id = '${userId}'),
'synastry', (select count(*) from public.synastry_reports where user_id = '${userId}'),
'profiles', (select count(*) from public.profiles where id = '${userId}'),
'keptCredit', (select count(*) from public.credit_transactions where user_id = '${userId}'),
'identityEmail', (select email from identity.users where id = '${userId}'),
'identityName', (select name from identity.users where id = '${userId}'),
'authEmail', (select email from auth.users where id = '${userId}'),
'accounts', (select count(*) from identity.accounts where user_id = '${userId}'),
'status', (select status from public.account_deletion_requests where id = '${requestId}')
)::text;
`).trim());
assert.deepEqual(state, {
chatMine: 0,
chatOther: 1,
chartProfiles: 0,
synastry: 0,
profiles: 0,
keptCredit: 1,
identityEmail: `deleted+${userId}@deleted.invalid`,
identityName: "已注销用户",
authEmail: `deleted+${userId}@deleted.invalid`,
accounts: 0,
status: "completed",
});
// Idempotent: a second purge of the same request is a no-op.
assert.match(fixture.psql(`select public.purge_deleted_account('${requestId}')::text;`), /skipped/);
} finally {
fixture.stop?.();
}
});
+4 -1
View File
@@ -45,5 +45,8 @@ test("the general dialog renders the shared theme preference panel", () => {
assert.match(overlay, /dialog: "profile" \| "chart-library" \| "billing" \| "general"/);
assert.match(overlay, /renderGeneral: \(\) => ReactNode/);
assert.match(overlay, /return model\.renderGeneral\(\)/);
assert.match(page, /renderGeneral\(\) \{\s*return <ThemePreferencePanel \/>;/);
// 原值: /renderGeneral\(\) \{\s*return <ThemePreferencePanel \/>;/
// 新值: 主题面板仍是第一项,其后挂注销账号区
// 原因: 2026-09-30 自助注销入口放在通用设置底部,头像菜单不加入口
assert.match(page, /renderGeneral\(\) \{\s*return <><ThemePreferencePanel \/><AccountDeletionSection \/><\/>;/);
});