diff --git a/BLOCKED.md b/BLOCKED.md index de48cc48..e81b10f8 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -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`。 diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e19d89..e711ffe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)。 diff --git a/docs/tasks/PROGRESS-account-deletion-20260930.md b/docs/tasks/PROGRESS-account-deletion-20260930.md new file mode 100644 index 00000000..0cec6c26 --- /dev/null +++ b/docs/tasks/PROGRESS-account-deletion-20260930.md @@ -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+@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 ;` | `return <>;` | 注销入口放通用设置底部,头像菜单不加入口 | + +## 未验证 / 待办 + +- DB 测试(请求→会话清空→扣点被拒→撤销→到期清除→内容删、流水留、身份墓碑→重复执行无副作用)需 Docker 跑 `npm run test:db`。 +- 迁移只在 staging 部署时首次真实应用;清除任务真实执行需等 7 天或在 staging 库手工把 `scheduled_for` 调早。 +- 无受控 staging 账号,端到端(注销→退出→重登见门→撤销)未走。 +- 与 fork C(反馈表)若新增带 `user_id` 的表,会被自动归入删除;如需保留投诉记录,需加入保留名单。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 0a1f8a03..ff75d57e 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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` 数不变。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 2c57f8b6..d8b4a651 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -327,6 +327,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 「解锁完整咨询」兑换弹窗只在第一次因点数不足发不出消息时弹(2026-09-28 起,不再进首页就弹)。说明句写「问题还在输入框里。填写兑换码,点数到账就能发送,生时校正与完整解读也能用。」;到账后写「点数已到账,回到输入框点发送就行。」。不要再写「入门问题已经准备好」,弹出时刚被拦下的是用户自己的问题。 +注销账号(2026-09-30):按钮写「注销账号…」,确认按钮写「确认注销」。说明必须三件事都说全:「7 天内重新登录可以撤销」、会被永久删除的内容清单、「剩余点数和会员权益会一并作废,不退款。订单和点数流水按法规保留,但不再与你的身份关联。」联系邮箱只从 `lib/legal-entity.ts` 取。冷静期内重新登录的全屏提示标题「账号注销中」,正文「这个账号将在 {日期} 永久删除。在那之前,你可以撤销注销,恢复全部内容。」按钮只有「退出登录」「撤销注销」。不要写「我们很遗憾」「确定要离开吗」这类挽留话。 + ## 首屏没加载完 脚本没跑起来、关键文件没下到,或浏览器版本过旧时,转圈停在「正在载入账户」不会自己结束。超时后只留这一屏,不自动刷新: diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index 2697d7ac..288ba033 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -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 }) { + ); } diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 6a3a0ece..d9d4f5b8 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -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 ; + return <>; }, renderLogout() { return ( diff --git a/frontend/src/app/admin/account-deletions/page.tsx b/frontend/src/app/admin/account-deletions/page.tsx new file mode 100644 index 00000000..72a6a2ad --- /dev/null +++ b/frontend/src/app/admin/account-deletions/page.tsx @@ -0,0 +1,2 @@ +import AccountDeletionsResource from "@/components/admin/account-deletions-resource"; +export default function Page() { return ; } diff --git a/frontend/src/app/api/account/deletion/route.ts b/frontend/src/app/api/account/deletion/route.ts new file mode 100644 index 00000000..5fba4ad0 --- /dev/null +++ b/frontend/src/app/api/account/deletion/route.ts @@ -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 { + 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" }); +} diff --git a/frontend/src/app/api/admin/account-deletions/route.ts b/frontend/src/app/api/admin/account-deletions/route.ts new file mode 100644 index 00000000..ccd15619 --- /dev/null +++ b/frontend/src/app/api/admin/account-deletions/route.ts @@ -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(` + 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); + } +} diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 2d579e53..d0fae84e 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -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), diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 4770f731..4d28ade3 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -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; diff --git a/frontend/src/app/api/rectification/cases/open/route.ts b/frontend/src/app/api/rectification/cases/open/route.ts index b3e2ecd3..d6f8add2 100644 --- a/frontend/src/app/api/rectification/cases/open/route.ts +++ b/frontend/src/app/api/rectification/cases/open/route.ts @@ -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( diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index 66850efd..7c8e8acb 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -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; diff --git a/frontend/src/app/api/synastry/route.ts b/frontend/src/app/api/synastry/route.ts index 24501e0a..af742338 100644 --- a/frontend/src/app/api/synastry/route.ts +++ b/frontend/src/app/api/synastry/route.ts @@ -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" }, diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 89a4cb7d..4553780f 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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); } diff --git a/frontend/src/components/account-deletion-gate.tsx b/frontend/src/components/account-deletion-gate.tsx new file mode 100644 index 00000000..e57154ba --- /dev/null +++ b/frontend/src/components/account-deletion-gate.tsx @@ -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({ 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 ( +
+
+

账号注销中

+

+ 这个账号将在 {formatAccountDeletionDate(status.scheduledFor)} 永久删除。在那之前,你可以撤销注销,恢复全部内容。 +

+ {error ?

{error}

: null} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/account-deletion-section.tsx b/frontend/src/components/account-deletion-section.tsx new file mode 100644 index 00000000..126cf542 --- /dev/null +++ b/frontend/src/components/account-deletion-section.tsx @@ -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 ( +
+
+ 注销账号 + {ACCOUNT_DELETION_GRACE_DAYS} 天内可以撤销,之后永久删除 +
+ {!open ? ( + + ) : ( +
+

提交后你会立刻退出登录,账号暂停使用。{ACCOUNT_DELETION_GRACE_DAYS} 天内重新登录可以撤销;{ACCOUNT_DELETION_GRACE_DAYS} 天后以下内容会被永久删除,无法恢复:

+
    + {ACCOUNT_DELETION_DELETED_ITEMS.map((item) =>
  • {item}
  • )} +
+

剩余点数和会员权益会一并作废,不退款。订单和点数流水按法规保留,但不再与你的身份关联。

+

有疑问可以先联系我们:{LEGAL_ENTITY.contactEmail}

+ + {error ? : null} +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/admin/account-deletions-resource.tsx b/frontend/src/components/admin/account-deletions-resource.tsx new file mode 100644 index 00000000..fdc38fe0 --- /dev/null +++ b/frontend/src/components/admin/account-deletions-resource.tsx @@ -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 = { pending: "冷静期中", cancelled: "已撤销", completed: "已删除" }; + +const columns: TableColumnsType = [ + { 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 + resource="account-deletions" + title="注销申请(只读)" + columns={columns} + statusOptions={[ + { label: "冷静期中", value: "pending" }, + { label: "已撤销", value: "cancelled" }, + { label: "已删除", value: "completed" }, + ]} + extra={} + />; +} diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx index cf9d86f9..9b977d77 100644 --- a/frontend/src/components/admin/admin-app.tsx +++ b/frontend/src/components/admin/admin-app.tsx @@ -104,6 +104,7 @@ export function AdminApp({ children }: { children: ReactNode }) { { name: "administrators", list: "/admin/administrators", meta: { label: "管理员", icon: } }, { name: "roles", list: "/admin/roles", meta: { label: "角色权限", icon: } }, { name: "customers", list: "/admin/customers", meta: { label: "用户资料", icon: } }, + { name: "account-deletions", list: "/admin/account-deletions", meta: { label: "注销申请", icon: } }, { name: "products", list: "/admin/products", meta: { label: "商品权益", icon: } }, { name: "subscriptions", list: "/admin/subscriptions", meta: { label: "订阅", icon: } }, { name: "orders", list: "/admin/orders", meta: { label: "订单", icon: } }, diff --git a/frontend/src/instrumentation.ts b/frontend/src/instrumentation.ts index e17d2d12..e3b5a7da 100644 --- a/frontend/src/instrumentation.ts +++ b/frontend/src/instrumentation.ts @@ -14,4 +14,8 @@ export async function register(): Promise { 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(); } diff --git a/frontend/src/lib/account-deletion-server.ts b/frontend/src/lib/account-deletion-server.ts new file mode 100644 index 00000000..1f07dba7 --- /dev/null +++ b/frontend/src/lib/account-deletion-server.ts @@ -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 { + 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 { + const scheduledFor = await readPendingAccountDeletion(userId); + return scheduledFor ? accountDeletionPendingResponse(scheduledFor) : null; +} diff --git a/frontend/src/lib/account-deletion-worker-core.ts b/frontend/src/lib/account-deletion-worker-core.ts new file mode 100644 index 00000000..6f1c771e --- /dev/null +++ b/frontend/src/lib/account-deletion-worker-core.ts @@ -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; + purge: (requestId: string) => Promise<{ ok: true; status: string } | { ok: false; code: string }>; + markFailed: (requestId: string, code: string) => Promise; + 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 { + 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 }; +} diff --git a/frontend/src/lib/account-deletion-worker.ts b/frontend/src/lib/account-deletion-worker.ts new file mode 100644 index 00000000..f4889231 --- /dev/null +++ b/frontend/src/lib/account-deletion-worker.ts @@ -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 { + 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); } }; +} diff --git a/frontend/src/lib/account-deletion.ts b/frontend/src/lib/account-deletion.ts new file mode 100644 index 00000000..01094279 --- /dev/null +++ b/frontend/src/lib/account-deletion.ts @@ -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 { + 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>): 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 { + 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; + } +} diff --git a/frontend/src/lib/admin/providers.ts b/frontend/src/lib/admin/providers.ts index aea4acdb..73341cd2 100644 --- a/frontend/src/lib/admin/providers.ts +++ b/frontend/src/lib/admin/providers.ts @@ -187,6 +187,7 @@ const resourcePermissions: Record = { }, 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" }, diff --git a/frontend/supabase/migrations/20260930020000_account_deletion_requests.sql b/frontend/supabase/migrations/20260930020000_account_deletion_requests.sql new file mode 100644 index 00000000..05e6b6c2 --- /dev/null +++ b/frontend/supabase/migrations/20260930020000_account_deletion_requests.sql @@ -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; diff --git a/frontend/tests/account-deletion.test.tsx b/frontend/tests/account-deletion.test.tsx new file mode 100644 index 00000000..143ab462 --- /dev/null +++ b/frontend/tests/account-deletion.test.tsx @@ -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(); + 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(); + 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(); + } 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(); + await signedOut.idle(); + assert.equal(calls, 0, "no request before sign-in"); + assert.equal(signedOut.container.text, ""); + } finally { + globalThis.fetch = originalFetch; + await signedOut.close(); + } +}); diff --git a/frontend/tests/database-account-deletion.test.ts b/frontend/tests/database-account-deletion.test.ts new file mode 100644 index 00000000..0a91963f --- /dev/null +++ b/frontend/tests/database-account-deletion.test.ts @@ -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?.(); + } +}); diff --git a/frontend/tests/settings-mvp-contract.test.ts b/frontend/tests/settings-mvp-contract.test.ts index 0ca634d3..ca6cdb8a 100644 --- a/frontend/tests/settings-mvp-contract.test.ts +++ b/frontend/tests/settings-mvp-contract.test.ts @@ -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 ;/); + // 原值: /renderGeneral\(\) \{\s*return ;/ + // 新值: 主题面板仍是第一项,其后挂注销账号区 + // 原因: 2026-09-30 自助注销入口放在通用设置底部,头像菜单不加入口 + assert.match(page, /renderGeneral\(\) \{\s*return <><\/>;/); });