feat(admin): add safe account reset
Staging Backend Quality Gate / validate (push) Successful in 12m13s
Staging Backend Quality Gate / publish (push) Successful in 7m50s

This commit is contained in:
Jesse_Chen
2026-08-11 17:57:40 +08:00
parent 6f7b2b1089
commit c0e9a1c9f1
5 changed files with 554 additions and 1 deletions
@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { isPostgresError, queryAdminRows } from "@/lib/admin/database";
import {
adminErrorResponse,
invalidQueryResponse,
requestId,
requireHighRiskAdminMutation,
} from "@/lib/admin/http";
export const runtime = "nodejs";
const resetSchema = z.object({
userId: z.string().uuid(),
confirmation: z.literal("RESET"),
reason: z.string().trim().min(1).max(500),
});
type ResetRow = {
user_id: string;
email: string;
credits: number;
chat_sessions_deleted: number;
chart_profiles_deleted: number;
synastry_reports_deleted: number;
};
export async function POST(request: Request) {
try {
const session = await requireHighRiskAdminMutation(
request,
"admin.users.manage_roles",
);
const parsed = resetSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
const rows = await queryAdminRows<ResetRow>(
"select * from public.admin_reset_customer_account($1, $2, $3, $4)",
[
session.user.id,
parsed.data.userId,
parsed.data.reason,
requestId(request),
],
);
const row = rows[0];
if (!row) return NextResponse.json({ error: "用户不存在" }, { status: 404 });
return NextResponse.json({
data: {
userId: row.user_id,
email: row.email,
credits: row.credits,
deleted: {
chatSessions: row.chat_sessions_deleted,
chartProfiles: row.chart_profiles_deleted,
synastryReports: row.synastry_reports_deleted,
},
},
});
} catch (error) {
if (
isPostgresError(error)
&& error.code === "P0002"
&& error instanceof Error
&& error.message.includes("admin_customer_not_found_or_identity_bridge_mismatch")
) {
return NextResponse.json({ error: "用户不存在或账号数据不完整" }, { status: 404 });
}
return adminErrorResponse(error);
}
}
@@ -4,6 +4,8 @@ import { useGetIdentity } from "@refinedev/core";
import { App, Button, Space, Tag, Typography, type TableColumnsType } from "antd";
import { useState } from "react";
import { ReasonActionModal } from "./reason-action-modal";
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
@@ -36,6 +38,9 @@ export default function UsersPage() {
const { data: identity } = useGetIdentity<AdminIdentity>();
const [revealed, setRevealed] = useState<Record<string, RevealedBirthData>>({});
const [revealingId, setRevealingId] = useState<string | null>(null);
const [resetTarget, setResetTarget] = useState<UserRecord | null>(null);
const [resetting, setResetting] = useState(false);
const canReset = Boolean(identity?.permissions.includes("admin.users.manage_roles"));
const canReveal = Boolean(identity?.permissions.includes("admin.customers.birth_data.read"));
async function revealBirthData(userId: string) {
@@ -53,6 +58,36 @@ export default function UsersPage() {
}
}
async function resetAccount(reason: string) {
if (!resetTarget) return;
setResetting(true);
try {
await adminRequestJson<{ data: { credits: number } }>(
"/api/admin/customers/reset",
{
method: "POST",
body: JSON.stringify({
userId: resetTarget.id,
confirmation: "RESET",
reason,
}),
},
);
message.success(`已重置 ${resetTarget.email},登录身份、管理员角色和积分保持不变`);
setRevealed((current) => {
const next = { ...current };
delete next[resetTarget.id];
return next;
});
setResetTarget(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "重置账号失败");
throw error;
} finally {
setResetting(false);
}
}
const maskedValue = (value: string | null | undefined) => value || "未填写";
const columns: TableColumnsType<UserRecord> = [
{ title: "邮箱", dataIndex: "email", sorter: true },
@@ -82,7 +117,25 @@ export default function UsersPage() {
{ title: "邮箱验证", dataIndex: "emailVerified", render: (value) => value ? "已验证" : "未验证" },
{ title: "状态", dataIndex: "banned", render: (value) => value ? <Tag color="red"></Tag> : <Tag color="green"></Tag> },
{ title: "注册时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
...(canReset ? [{
title: "操作",
render: (_: unknown, item: UserRecord) => <Button danger size="small" onClick={() => setResetTarget(item)}>
</Button>,
}] : []),
];
return <ResourceTable<UserRecord> resource="customers" title="用户资料(列表始终脱敏)" columns={columns} />;
return <>
<ResourceTable<UserRecord> resource="customers" title="用户资料(列表始终脱敏)" columns={columns} />
<ReasonActionModal
open={Boolean(resetTarget)}
title={`确认重置 ${resetTarget?.email ?? "该账号"} 的资料与会话?登录身份、管理员角色、积分及账务审计记录会保留。`}
okText="确认重置"
danger
confirmLoading={resetting}
reauthPermission="admin.users.manage_roles"
onCancel={() => setResetTarget(null)}
onSubmit={resetAccount}
/>
</>;
}
@@ -0,0 +1,227 @@
begin;
create or replace function public.admin_reset_customer_account(
p_actor_user_id uuid,
p_target_user_id uuid,
p_reason text,
p_request_id text
)
returns table (
user_id uuid,
email text,
credits integer,
chat_sessions_deleted integer,
chart_profiles_deleted integer,
synastry_reports_deleted integer
)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_actor_email text;
v_target_email text;
v_profile_email text;
v_credits integer;
v_identity_accounts bigint;
v_identity_sessions bigint;
v_admin_user_roles bigint;
v_credit_transactions bigint;
v_credit_cancellations bigint;
v_consultation_requests bigint;
v_rectification_billing bigint;
v_action_receipts bigint;
v_redeemed_codes bigint;
v_admin_audit_logs bigint;
v_chat_sessions_deleted integer;
v_chart_profiles_deleted integer;
v_synastry_reports_deleted integer;
begin
if not public.admin_has_permission(p_actor_user_id, 'admin.users.manage_roles') then
raise exception 'admin_permission_denied' using errcode = '42501';
end if;
if p_target_user_id is null then
raise exception 'admin_customer_scope_invalid' using errcode = '22023';
end if;
if char_length(btrim(coalesce(p_reason, ''))) not between 1 and 500 then
raise exception 'admin_reason_required' using errcode = '22023';
end if;
if char_length(btrim(coalesce(p_request_id, ''))) not between 1 and 200 then
raise exception 'admin_request_id_invalid' using errcode = '22023';
end if;
select lower(btrim(value.email))
into v_actor_email
from identity.users value
where value.id = p_actor_user_id;
if v_actor_email is null then
raise exception 'admin_user_not_found' using errcode = '22023';
end if;
select
identity_user.email,
profile.email,
profile.credits,
(select count(*) from identity.accounts value where value.user_id = identity_user.id),
(select count(*) from identity.sessions value where value.user_id = identity_user.id),
(select count(*) from public.admin_user_roles value where value.admin_user_id = identity_user.id),
(select count(*) from public.credit_transactions value where value.user_id = identity_user.id),
(select count(*) from public.credit_request_cancellations value where value.user_id = identity_user.id),
(select count(*) from public.consultation_requests value where value.user_id = identity_user.id),
(select count(*) from public.birth_time_rectification_billing value where value.user_id = identity_user.id),
(select count(*) from public.birth_time_rectification_action_receipts value where value.user_id = identity_user.id),
(select count(*) from public.redemption_codes value where value.redeemed_by = identity_user.id),
(select count(*) from audit.admin_audit_logs value where value.actor_user_id = identity_user.id)
into
v_target_email,
v_profile_email,
v_credits,
v_identity_accounts,
v_identity_sessions,
v_admin_user_roles,
v_credit_transactions,
v_credit_cancellations,
v_consultation_requests,
v_rectification_billing,
v_action_receipts,
v_redeemed_codes,
v_admin_audit_logs
from identity.users identity_user
join auth.users auth_user on auth_user.id = identity_user.id
join public.profiles profile on profile.id = identity_user.id
where identity_user.id = p_target_user_id
and lower(btrim(auth_user.email)) = lower(btrim(identity_user.email))
for update of identity_user, auth_user, profile;
if not found then
raise exception 'admin_customer_not_found_or_identity_bridge_mismatch' using errcode = 'P0002';
end if;
update public.profiles
set name = null,
birth_date = null,
birth_time = null,
country_code = null,
province_code = null,
city_code = null,
district_code = null,
onboarding_payload = null,
onboarding_version = null,
onboarding_generated_at = null,
latitude = null,
longitude = null,
timezone_offset = null,
reported_birth_time = null,
active_birth_time = null,
birth_time_source = null,
birth_time_period = null,
birth_time_clue = null,
uncertainty_before_minutes = null,
uncertainty_after_minutes = null,
birth_time_status = null,
rectification_confidence = null,
rectification_case_id = null,
birth_place_label = null,
birth_place_type = null,
birth_place_provider = null,
birth_place_provider_id = null,
timezone_id = null,
timezone_source = null,
updated_at = pg_catalog.now()
where id = p_target_user_id;
delete from public.chat_sessions value where value.user_id = p_target_user_id;
get diagnostics v_chat_sessions_deleted = row_count;
delete from public.chart_profiles value where value.user_id = p_target_user_id;
get diagnostics v_chart_profiles_deleted = row_count;
delete from public.synastry_reports value where value.user_id = p_target_user_id;
get diagnostics v_synastry_reports_deleted = row_count;
if exists (
select 1
from identity.users identity_user
join auth.users auth_user on auth_user.id = identity_user.id
join public.profiles profile on profile.id = identity_user.id
where identity_user.id = p_target_user_id
and (
identity_user.email is distinct from v_target_email
or auth_user.email is distinct from v_target_email
or profile.email is distinct from v_profile_email
or profile.credits is distinct from v_credits
or (select count(*) from identity.accounts value where value.user_id = identity_user.id) <> v_identity_accounts
or (select count(*) from identity.sessions value where value.user_id = identity_user.id) <> v_identity_sessions
or (select count(*) from public.admin_user_roles value where value.admin_user_id = identity_user.id) <> v_admin_user_roles
or (select count(*) from public.credit_transactions value where value.user_id = identity_user.id) <> v_credit_transactions
or (select count(*) from public.credit_request_cancellations value where value.user_id = identity_user.id) <> v_credit_cancellations
or (select count(*) from public.consultation_requests value where value.user_id = identity_user.id) <> v_consultation_requests
or (select count(*) from public.birth_time_rectification_billing value where value.user_id = identity_user.id) <> v_rectification_billing
or (select count(*) from public.birth_time_rectification_action_receipts value where value.user_id = identity_user.id) <> v_action_receipts
or (select count(*) from public.redemption_codes value where value.redeemed_by = identity_user.id) <> v_redeemed_codes
or (select count(*) from audit.admin_audit_logs value where value.actor_user_id = identity_user.id) <> v_admin_audit_logs
)
) then
raise exception 'admin_customer_preserved_state_changed' using errcode = 'P0001';
end if;
if exists (select 1 from public.chat_sessions value where value.user_id = p_target_user_id)
or exists (select 1 from public.chart_profiles value where value.user_id = p_target_user_id)
or exists (select 1 from public.synastry_reports value where value.user_id = p_target_user_id)
or exists (
select 1
from public.profiles profile
where profile.id = p_target_user_id
and (
profile.name is not null or profile.birth_date is not null or profile.birth_time is not null
or profile.country_code is not null or profile.province_code is not null or profile.city_code is not null or profile.district_code is not null
or profile.onboarding_payload is not null or profile.onboarding_version is not null or profile.onboarding_generated_at is not null
or profile.latitude is not null or profile.longitude is not null or profile.timezone_offset is not null
or profile.reported_birth_time is not null or profile.active_birth_time is not null or profile.birth_time_source is not null
or profile.birth_time_period is not null or profile.birth_time_clue is not null
or profile.uncertainty_before_minutes is not null or profile.uncertainty_after_minutes is not null
or profile.birth_time_status is not null or profile.rectification_confidence is not null or profile.rectification_case_id is not null
or profile.birth_place_label is not null or profile.birth_place_type is not null or profile.birth_place_provider is not null
or profile.birth_place_provider_id is not null or profile.timezone_id is not null or profile.timezone_source is not null
)
) then
raise exception 'admin_customer_reset_state_not_empty' using errcode = 'P0001';
end if;
insert into audit.admin_audit_logs (
actor_user_id, actor_email, actor_role, action, target_type, target_id,
after_value, request_id, permission_used, reason
) values (
p_actor_user_id, v_actor_email, 'admin', 'admin.customer.account.reset',
'customer', p_target_user_id,
jsonb_build_object(
'email', lower(btrim(v_target_email)),
'creditsPreserved', v_credits,
'chatSessionsDeleted', v_chat_sessions_deleted,
'chartProfilesDeleted', v_chart_profiles_deleted,
'synastryReportsDeleted', v_synastry_reports_deleted
),
btrim(p_request_id), 'admin.users.manage_roles', btrim(p_reason)
) on conflict do nothing;
return query select
p_target_user_id,
lower(btrim(v_target_email)),
v_credits,
v_chat_sessions_deleted,
v_chart_profiles_deleted,
v_synastry_reports_deleted;
end;
$$;
revoke all on function public.admin_reset_customer_account(uuid, uuid, text, text)
from public, anon, authenticated, service_role;
do $$
begin
if exists (select 1 from pg_roles where rolname = 'admin_runtime') then
grant execute on function public.admin_reset_customer_account(uuid, uuid, text, text)
to admin_runtime;
end if;
end;
$$;
commit;
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const root = new URL("../", import.meta.url);
const route = readFileSync(new URL("src/app/api/admin/customers/reset/route.ts", root), "utf8");
const users = readFileSync(new URL("src/components/admin/users-resource.tsx", root), "utf8");
const migration = readFileSync(new URL("supabase/migrations/20260811020000_admin_customer_account_reset.sql", root), "utf8");
test("admin account reset stays owner-only, explicit, and high risk", () => {
assert.match(route, /requireHighRiskAdminMutation\(\s*request,\s*"admin\.users\.manage_roles",?\s*\)/);
assert.match(route, /confirmation:\s*z\.literal\("RESET"\)/);
assert.match(route, /admin_reset_customer_account\(\$1, \$2, \$3, \$4\)/);
assert.match(users, /permissions\.includes\("admin\.users\.manage_roles"\)/);
assert.match(users, /reauthPermission="admin\.users\.manage_roles"/);
assert.match(users, /登录身份、管理员角色、积分及账务审计记录会保留/);
});
test("database reset mirrors the existing staging reset boundary and audits it", () => {
assert.match(migration, /update public\.profiles[\s\S]*name = null[\s\S]*timezone_source = null/);
assert.match(migration, /delete from public\.chat_sessions/);
assert.match(migration, /delete from public\.chart_profiles/);
assert.match(migration, /delete from public\.synastry_reports/);
assert.match(migration, /profile\.credits is distinct from v_credits/);
assert.match(migration, /identity\.accounts/);
assert.match(migration, /identity\.sessions/);
assert.match(migration, /public\.admin_user_roles/);
assert.match(migration, /public\.credit_transactions/);
assert.match(migration, /public\.consultation_requests/);
assert.match(migration, /audit\.admin_audit_logs/);
assert.match(migration, /'admin\.customer\.account\.reset'/);
assert.doesNotMatch(migration, /delete from (identity|auth)\.users/);
});
@@ -0,0 +1,167 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { Client } from "pg";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
const ownerId = "81000000-0000-4000-8000-000000000001";
const targetId = "81000000-0000-4000-8000-000000000002";
function postgresCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String((error as { code?: unknown }).code)
: undefined;
}
test("admin customer reset clears only rebuildable application state", async () => {
const fixture = startPostgresFixture();
const adminRuntime = new Client({
connectionString: fixture.connectionUrl("admin_runtime", "admin-runtime-test-password"),
});
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}`);
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
insert into identity.users (id, name, email, email_verified, email_verified_at, role) values
('${ownerId}', 'Owner', 'reset-owner@example.com', true, now(), 'admin'),
('${targetId}', 'Target Admin', 'reset-target@example.com', true, now(), 'admin');
insert into identity.accounts (id, account_id, provider_id, user_id, password)
values ('82000000-0000-4000-8000-000000000001', 'reset-target@example.com', 'credential', '${targetId}', 'password-hash');
insert into identity.sessions (id, token, user_id, expires_at)
values ('82000000-0000-4000-8000-000000000002', 'reset-session-token', '${targetId}', now() + interval '1 day');
`);
fixture.psql(`
insert into public.admin_users (user_id, created_by) values
('${ownerId}', '${ownerId}'), ('${targetId}', '${ownerId}');
insert into public.admin_user_roles (admin_user_id, role_id, assigned_by)
select values.user_id, roles.id, '${ownerId}'::uuid
from (values
('${ownerId}'::uuid, 'owner'),
('${targetId}'::uuid, 'support')
) values(user_id, role_code)
join public.admin_roles roles on roles.code = values.role_code;
update public.profiles set
credits = 73,
name = 'Reset Me',
birth_date = '1990-01-02',
birth_time = '03:04',
country_code = 'CN',
province_code = '11',
city_code = '1101',
district_code = '110101',
onboarding_payload = '{"ready":true}'::jsonb,
onboarding_version = 'test-v1',
onboarding_generated_at = now(),
latitude = 39.9,
longitude = 116.4,
timezone_offset = 8,
reported_birth_time = '03:04',
active_birth_time = '03:04',
birth_time_source = 'legacy_import',
birth_time_status = 'confirmed',
birth_place_label = 'Test Place',
birth_place_type = 'city',
birth_place_provider = 'geonames',
birth_place_provider_id = 'test-place',
timezone_id = 'Asia/Shanghai',
timezone_source = 'iana_historical'
where id = '${targetId}';
insert into public.chat_sessions (user_id, title, theme, messages)
values ('${targetId}', 'Reset Chat', 'general', '[]'::jsonb);
insert into public.chart_profiles (user_id, role, profile)
values ('${targetId}', 'self', '{}'::jsonb);
insert into public.synastry_reports (user_id, partner_name, report)
values ('${targetId}', 'Partner', '{}'::jsonb);
insert into public.credit_transactions (user_id, transaction_type, amount, balance_after, request_id)
values ('${targetId}', 'redeem', 73, 73, 'reset-preserved-credit');
insert into audit.admin_audit_logs (
actor_user_id, actor_email, actor_role, action, target_type, target_id,
after_value, request_id, permission_used, reason
) values (
'${targetId}', 'reset-target@example.com', 'admin', 'admin.customer.birth_data.read',
'customer', '${targetId}', '{}'::jsonb, 'reset-preserved-audit',
'admin.customers.birth_data.read', 'existing audit history'
);
`);
await adminRuntime.connect();
await assert.rejects(
adminRuntime.query(
"select * from public.admin_reset_customer_account($1, $2, $3, $4)",
[targetId, targetId, "unauthorized self reset", "reset-denied"],
),
(error) => postgresCode(error) === "42501",
);
const reset = await adminRuntime.query<{
user_id: string;
email: string;
credits: number;
chat_sessions_deleted: number;
chart_profiles_deleted: number;
synastry_reports_deleted: number;
}>(
"select * from public.admin_reset_customer_account($1, $2, $3, $4)",
[ownerId, targetId, "prepare account for a fresh onboarding test", "reset-success"],
);
assert.deepEqual(reset.rows, [{
user_id: targetId,
email: "reset-target@example.com",
credits: 73,
chat_sessions_deleted: 1,
chart_profiles_deleted: 1,
synastry_reports_deleted: 1,
}]);
const state = JSON.parse(fixture.psql(`
select jsonb_build_object(
'identityUsers', (select count(*) from identity.users where id = '${targetId}'),
'authUsers', (select count(*) from auth.users where id = '${targetId}'),
'identityAccounts', (select count(*) from identity.accounts where user_id = '${targetId}'),
'identitySessions', (select count(*) from identity.sessions where user_id = '${targetId}'),
'credits', (select credits from public.profiles where id = '${targetId}'),
'profileReset', (select name is null and birth_date is null and birth_time is null and onboarding_payload is null and reported_birth_time is null and active_birth_time is null and birth_place_label is null from public.profiles where id = '${targetId}'),
'chatSessions', (select count(*) from public.chat_sessions where user_id = '${targetId}'),
'chartProfiles', (select count(*) from public.chart_profiles where user_id = '${targetId}'),
'synastryReports', (select count(*) from public.synastry_reports where user_id = '${targetId}'),
'creditTransactions', (select count(*) from public.credit_transactions where user_id = '${targetId}'),
'adminRoles', (select count(*) from public.admin_user_roles where admin_user_id = '${targetId}'),
'existingAudit', (select count(*) from audit.admin_audit_logs where actor_user_id = '${targetId}' and request_id = 'reset-preserved-audit'),
'resetAudit', (select count(*) from audit.admin_audit_logs where actor_user_id = '${ownerId}' and request_id = 'reset-success' and action = 'admin.customer.account.reset')
)
`)) as Record<string, number | boolean>;
assert.deepEqual(state, {
identityUsers: 1,
authUsers: 1,
identityAccounts: 1,
identitySessions: 1,
credits: 73,
profileReset: true,
chatSessions: 0,
chartProfiles: 0,
synastryReports: 0,
creditTransactions: 1,
adminRoles: 1,
existingAudit: 1,
resetAudit: 1,
});
} finally {
await adminRuntime.end().catch(() => undefined);
fixture.stop();
}
});