feat(account): self-service deletion with a 7-day cooling-off period
Request signs out everywhere and freezes paid routes (423 + DB triggers); signing in within 7 days shows the pending gate with 撤销注销. A periodic idempotent worker purges personal content afterwards and keeps finance rows against a tombstoned identity. Read-only admin list. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3c8123d912
commit
96adbce3a9
@@ -0,0 +1,33 @@
|
||||
import "server-only";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
ACCOUNT_DELETION_PENDING_CODE,
|
||||
readPendingAccountDeletionWith,
|
||||
type AccountDeletionRpcClient,
|
||||
} from "@/lib/account-deletion";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
|
||||
/** The pending deletion date for this user, read with the service client; null when none or unreadable. */
|
||||
export async function readPendingAccountDeletion(userId: string): Promise<string | null> {
|
||||
try {
|
||||
return await readPendingAccountDeletionWith(createAdminSupabaseClient() as unknown as AccountDeletionRpcClient, userId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 423 with the stable code; the app shows the 「账号注销中」 screen for it. */
|
||||
export function accountDeletionPendingResponse(scheduledFor: string): NextResponse {
|
||||
return NextResponse.json(
|
||||
{ error: "账号注销中,撤销注销后才能继续使用。", code: ACCOUNT_DELETION_PENDING_CODE, scheduledFor },
|
||||
{ status: 423 },
|
||||
);
|
||||
}
|
||||
|
||||
/** For paid routes: a 423 response when the account is frozen, otherwise null. */
|
||||
export async function refuseWhenAccountDeletionPending(userId: string): Promise<NextResponse | null> {
|
||||
const scheduledFor = await readPendingAccountDeletion(userId);
|
||||
return scheduledFor ? accountDeletionPendingResponse(scheduledFor) : null;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The permanent step of account deletion (2026-09-30). One tick finds pending
|
||||
* requests whose 7 days are over and calls `purge_deleted_account` for each.
|
||||
* The database function is all-or-nothing, so a failed purge leaves the
|
||||
* account exactly as it was; the attempt is counted and the next tick retries
|
||||
* until MAX_ATTEMPTS. Logs carry counts and codes only, never ids or emails.
|
||||
*/
|
||||
|
||||
export const ACCOUNT_DELETION_MAX_ATTEMPTS = 5;
|
||||
export const ACCOUNT_DELETION_BATCH = 20;
|
||||
export const ACCOUNT_DELETION_TICK_MS = 30 * 60 * 1000;
|
||||
export const ACCOUNT_DELETION_FIRST_TICK_MS = 60 * 1000;
|
||||
|
||||
export type AccountDeletionWorkerDeps = Readonly<{
|
||||
now: () => Date;
|
||||
listDue: (nowIso: string, limit: number, maxAttempts: number) => Promise<readonly string[]>;
|
||||
purge: (requestId: string) => Promise<{ ok: true; status: string } | { ok: false; code: string }>;
|
||||
markFailed: (requestId: string, code: string) => Promise<void>;
|
||||
log: (line: string) => void;
|
||||
}>;
|
||||
|
||||
export type AccountDeletionTickResult = Readonly<{ due: number; completed: number; skipped: number; failed: number }>;
|
||||
|
||||
export function purgeErrorCode(message: string | undefined): string {
|
||||
if (message?.includes("account_deletion_purge_incomplete")) return "purge_incomplete";
|
||||
if (message?.includes("account_deletion_request_not_found")) return "request_not_found";
|
||||
return "purge_failed";
|
||||
}
|
||||
|
||||
export async function runAccountDeletionTick(deps: AccountDeletionWorkerDeps): Promise<AccountDeletionTickResult> {
|
||||
const due = await deps.listDue(deps.now().toISOString(), ACCOUNT_DELETION_BATCH, ACCOUNT_DELETION_MAX_ATTEMPTS);
|
||||
let completed = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
for (const requestId of due) {
|
||||
const result = await deps.purge(requestId);
|
||||
if (result.ok) {
|
||||
if (result.status === "completed") completed += 1; else skipped += 1;
|
||||
continue;
|
||||
}
|
||||
failed += 1;
|
||||
await deps.markFailed(requestId, result.code);
|
||||
}
|
||||
if (due.length > 0) {
|
||||
deps.log(`[account-deletion-worker] due=${due.length} completed=${completed} skipped=${skipped} failed=${failed}`);
|
||||
}
|
||||
return { due: due.length, completed, skipped, failed };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import "server-only";
|
||||
|
||||
import {
|
||||
ACCOUNT_DELETION_FIRST_TICK_MS,
|
||||
ACCOUNT_DELETION_TICK_MS,
|
||||
purgeErrorCode,
|
||||
runAccountDeletionTick,
|
||||
} from "@/lib/account-deletion-worker-core";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
|
||||
type WorkerGlobal = typeof globalThis & { jyotishaAccountDeletionWorker?: { stop: () => void } };
|
||||
|
||||
async function tick(): Promise<void> {
|
||||
const admin = createAdminSupabaseClient();
|
||||
await runAccountDeletionTick({
|
||||
now: () => new Date(),
|
||||
listDue: async (nowIso, limit, maxAttempts) => {
|
||||
const { data, error } = await admin
|
||||
.from("account_deletion_requests")
|
||||
.select("id,attempt_count")
|
||||
.eq("status", "pending")
|
||||
.lte("scheduled_for", nowIso)
|
||||
.order("scheduled_for", { ascending: true })
|
||||
.limit(limit * 2);
|
||||
if (error || !Array.isArray(data)) return [];
|
||||
// Attempts are filtered here: the self-hosted query builder has no lt()
|
||||
// (an unimplemented filter once broke a list for two weeks, BUG-990).
|
||||
return (data as { id: unknown; attempt_count: unknown }[])
|
||||
.filter((row) => typeof row.attempt_count !== "number" || row.attempt_count < maxAttempts)
|
||||
.slice(0, limit)
|
||||
.map((row) => String(row.id));
|
||||
},
|
||||
purge: async (requestId) => {
|
||||
const { data, error } = await admin.rpc("purge_deleted_account", { p_request_id: requestId });
|
||||
if (error) return { ok: false, code: purgeErrorCode(error.message) };
|
||||
const status = (data as { status?: unknown } | null)?.status;
|
||||
return { ok: true, status: typeof status === "string" ? status : "unknown" };
|
||||
},
|
||||
markFailed: async (requestId, code) => {
|
||||
await admin.rpc("mark_account_deletion_attempt_failed", { p_request_id: requestId, p_error_code: code });
|
||||
},
|
||||
log: (line) => console.info(line),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One unref'ed timer per server process: first run a minute after start, then
|
||||
* every 30 minutes. Several instances are safe — the purge locks the request
|
||||
* row and a completed request is skipped.
|
||||
*/
|
||||
export function startAccountDeletionWorker(): void {
|
||||
const state = globalThis as WorkerGlobal;
|
||||
if (state.jyotishaAccountDeletionWorker) return;
|
||||
const run = () => {
|
||||
tick().catch((error: unknown) => {
|
||||
console.error(`[account-deletion-worker] tick failed reason=${error instanceof Error ? error.name : "UnknownError"}`);
|
||||
});
|
||||
};
|
||||
const first = setTimeout(run, ACCOUNT_DELETION_FIRST_TICK_MS);
|
||||
const every = setInterval(run, ACCOUNT_DELETION_TICK_MS);
|
||||
first.unref?.();
|
||||
every.unref?.();
|
||||
state.jyotishaAccountDeletionWorker = { stop: () => { clearTimeout(first); clearInterval(every); } };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Self-service account deletion (2026-09-30). Shared by the settings entry,
|
||||
* the frozen-account screen, the API route and the purge worker. Pure: no
|
||||
* server imports, so client components can use it.
|
||||
*
|
||||
* Rules (product): request → signed out everywhere, account frozen; within 7
|
||||
* days signing in again offers 撤销注销; after 7 days personal content is
|
||||
* deleted and the identity becomes an anonymous tombstone. Orders, the credit
|
||||
* ledger, usage and billing rows are kept for bookkeeping, pointing at that
|
||||
* tombstone. Remaining credits are forfeited.
|
||||
*/
|
||||
|
||||
export const ACCOUNT_DELETION_GRACE_DAYS = 7;
|
||||
/** Typed by the user to confirm, the same way the staging reset asks for a phrase. */
|
||||
export const ACCOUNT_DELETION_CONFIRM_WORD = "注销";
|
||||
/** Stable code every paid route returns while a deletion is pending. */
|
||||
export const ACCOUNT_DELETION_PENDING_CODE = "account_deletion_pending";
|
||||
|
||||
/** Mirrors `public.account_deletion_kept_tables()`; everything else the user owns is deleted. */
|
||||
export const ACCOUNT_DELETION_KEPT_TABLES = [
|
||||
"account_deletion_requests",
|
||||
"admin_session_revocations",
|
||||
"admin_user_roles",
|
||||
"admin_users",
|
||||
"birth_time_rectification_billing",
|
||||
"consultation_requests",
|
||||
"credit_request_cancellations",
|
||||
"credit_transactions",
|
||||
"payment_orders",
|
||||
"pricing_experiment_events",
|
||||
"redemption_attempts",
|
||||
"redemption_codes",
|
||||
"usage_ledger",
|
||||
"usage_reservations",
|
||||
"user_product_redemptions",
|
||||
"user_subscriptions",
|
||||
] as const;
|
||||
|
||||
/** What the confirmation dialog tells the user will be deleted. */
|
||||
export const ACCOUNT_DELETION_DELETED_ITEMS = [
|
||||
"全部对话记录",
|
||||
"你和星盘档案里其他人的出生资料与星盘",
|
||||
"个人报告",
|
||||
"生时校正记录",
|
||||
"合盘记录",
|
||||
"账号本身(邮箱、昵称、登录方式)",
|
||||
] as const;
|
||||
|
||||
export type AccountDeletionStatus =
|
||||
| Readonly<{ status: "none" }>
|
||||
| Readonly<{ status: "pending"; requestedAt: string | null; scheduledFor: string }>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isoOrNull(value: unknown): string | null {
|
||||
if (typeof value !== "string" && !(value instanceof Date)) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
/** Reads the status the API returns (and the RPC result); anything unusable is "none". */
|
||||
export function parseAccountDeletionStatus(value: unknown): AccountDeletionStatus {
|
||||
if (!isRecord(value) || value.status !== "pending") return { status: "none" };
|
||||
const scheduledFor = isoOrNull(value.scheduledFor);
|
||||
if (!scheduledFor) return { status: "none" };
|
||||
return { status: "pending", requestedAt: isoOrNull(value.requestedAt), scheduledFor };
|
||||
}
|
||||
|
||||
/** 「10 月 7 日」 in Beijing time — the day the deletion becomes permanent. */
|
||||
export function formatAccountDeletionDate(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "七天后";
|
||||
const parts = new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", month: "numeric", day: "numeric" }).formatToParts(date);
|
||||
const month = parts.find((part) => part.type === "month")?.value;
|
||||
const day = parts.find((part) => part.type === "day")?.value;
|
||||
return month && day ? `${month} 月 ${day} 日` : "七天后";
|
||||
}
|
||||
|
||||
export function isAccountDeletionConfirmation(input: unknown): boolean {
|
||||
return typeof input === "string" && input.trim() === ACCOUNT_DELETION_CONFIRM_WORD;
|
||||
}
|
||||
|
||||
/** Maps a database error from the request RPC to a stable route code. */
|
||||
export function accountDeletionRequestErrorCode(message: string | undefined): "admin_account" | "not_found" | "unavailable" {
|
||||
if (message?.includes("account_deletion_admin_account")) return "admin_account";
|
||||
if (message?.includes("account_deletion_user_not_found")) return "not_found";
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
export type AccountDeletionRpcClient = {
|
||||
rpc(name: string, args: Readonly<Record<string, unknown>>): PromiseLike<{ data: unknown; error: { message?: string } | null }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The permanent-deletion date when the account has a pending request, else
|
||||
* null. A read failure (for example before the migration has run) reads as
|
||||
* "not pending": this guard must never lock people out by accident.
|
||||
*/
|
||||
export async function readPendingAccountDeletionWith(client: AccountDeletionRpcClient, userId: string): Promise<string | null> {
|
||||
try {
|
||||
const { data, error } = await client.rpc("account_deletion_scheduled_for", { p_user_id: userId });
|
||||
if (error || data === null || data === undefined) return null;
|
||||
return isoOrNull(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,7 @@ const resourcePermissions: Record<string, { read: string; write?: string }> = {
|
||||
},
|
||||
roles: { read: "admin.users.read" },
|
||||
customers: { read: "admin.customers.read" },
|
||||
"account-deletions": { read: "admin.customers.read" },
|
||||
codes: { read: "admin.access", write: "admin.access" },
|
||||
"credit-transactions": { read: "billing.orders.read" },
|
||||
consultations: { read: "billing.orders.read" },
|
||||
|
||||
Reference in New Issue
Block a user