feat(admin): add audited Refine staging console

This commit is contained in:
Jesse
2026-07-27 19:55:38 +08:00
parent 43581ac0f7
commit 02ec5db361
35 changed files with 4545 additions and 272 deletions
+2
View File
@@ -1,3 +1,5 @@
# BLOCKED
- 真实收信端到端验收:执行环境没有可识别的 staging 测试邮箱/收件箱变量,仓库只记录发信配置而未提供受控测试邮箱。按任务硬规则不使用他人邮箱;代码、测试和部署继续,部署后的注册、验证码登录与忘记密码真实收信步骤待具备受控邮箱后补验。
- PostgreSQL 事务反向测试:当前执行环境没有 `docker``postgres``initdb``psql`、Podman/Colima/Lima。`frontend/tests/admin-database.test.ts` 已实现审计触发器故意失败并断言兑换码行数仍为 0 的红灯证据,但本地执行在启动 fixture 前以 `spawnSync docker ENOENT` 阻塞;交由 exact-SHA staging quality gate 的 Docker 环境运行。全量 `npm test` 因同一缺失 Docker 共阻塞 11 项数据库/部署测试,另有 1 项既有真实 DOM 测试因缺 Playwright headless Chromium 阻塞;其余 1031 项通过,skipped/todo=0。
- staging 两角色冒烟:仓库/环境未提供受控 admin 与 viewer 测试账号或其登录验证码收件箱;不得使用他人账号。部署后可完成匿名 401 和公开 healthadmin/viewer 浏览器冒烟需受控账号。
@@ -0,0 +1,6 @@
-- Admin-host sessions may be created for read-only viewers. API authorization
-- remains server-side and is resolved from this persisted role on every request.
-- Existing identity migrations already grant admin_runtime these reads; repeat the
-- least-privilege user grant so drifted staging databases fail closed at login.
grant select on table identity.users to admin_runtime;
+2358 -1
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -17,12 +17,17 @@
"worker:rectification-v4": "tsx scripts/rectification-v4-worker.mts"
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
"@base-ui/react": "^1.6.0",
"@gsap/react": "^2.1.2",
"@mastra/core": "^1.50.1",
"@refinedev/antd": "^6.0.3",
"@refinedev/core": "^5.0.12",
"@refinedev/nextjs-router": "^7.0.5",
"@supabase/ssr": "^0.12.3",
"@supabase/supabase-js": "^2.110.5",
"@tailwindcss/postcss": "^4.3.2",
"antd": "^5.29.3",
"better-auth": "1.6.23",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -0,0 +1,40 @@
"use client";
import { Descriptions, Tag, type TableColumnsType } from "antd";
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
type AuditRecord = {
id: string;
actorEmail: string;
actorRole: string;
action: string;
targetId: string;
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
requestId: string;
createdAt: string;
};
const columns: TableColumnsType<AuditRecord> = [
{ title: "操作者", dataIndex: "actorEmail", sorter: true },
{ title: "角色", dataIndex: "actorRole", render: (value) => <Tag>{value}</Tag> },
{ title: "动作", dataIndex: "action", sorter: true },
{ title: "目标 ID", dataIndex: "targetId" },
{ title: "Request ID", dataIndex: "requestId" },
{ title: "时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
];
export default function AuditLogsPage() {
return <ResourceTable<AuditRecord>
resource="audit-logs"
title="审计日志(只读)"
columns={columns}
statusOptions={[
{ label: "生成兑换码", value: "redemption_code.create" },
{ label: "修改兑换码", value: "redemption_code.update" },
{ label: "撤销兑换码", value: "redemption_code.revoke" },
]}
extra={<Descriptions size="small" items={[{ key: "policy", label: "策略", children: "仅保存脱敏前后值;日志只追加" }]} />}
/>;
}
+140 -173
View File
@@ -1,200 +1,167 @@
"use client";
import Link from "next/link";
import { FormEvent, useEffect, useRef, useState } from "react";
import { useCreate, useDelete, useGetIdentity, usePermissions, useUpdate } from "@refinedev/core";
import { Button, DatePicker, Form, Input, InputNumber, Modal, Space, Tag, Typography, type TableColumnsType } from "antd";
import dayjs from "dayjs";
import { useState } from "react";
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
import type { AdminIdentity } from "@/lib/admin/providers";
type CodeRecord = {
id: string;
code?: string;
mask: string;
credits: number;
expiresAt: string | null;
redeemedBy: string | null;
redeemedEmail: string | null;
redeemedAt: string | null;
note: string | null;
createdAt: string;
redeemedEmail: string | null;
redeemedAt: string | null;
revokedAt: string | null;
status: "available" | "expired" | "redeemed" | "revoked";
};
type GeneratedCode = { code: string; credits: number; expiresAt: string | null };
const previewCodes: CodeRecord[] = [
{ id: "preview-1", mask: "JYOT-••••-7Q9K", credits: 12, expiresAt: "2026-12-31T15:59:00.000Z", redeemedBy: null, redeemedEmail: null, redeemedAt: null, note: "秋季体验", createdAt: "2026-07-16T02:20:00.000Z" },
{ id: "preview-2", mask: "JYOT-••••-2M8A", credits: 6, expiresAt: null, redeemedBy: "preview-user", redeemedEmail: "linyao@example.com", redeemedAt: "2026-07-15T08:30:00.000Z", note: "访谈用户", createdAt: "2026-07-14T03:10:00.000Z" },
{ id: "preview-3", mask: "JYOT-••••-4D1R", credits: 20, expiresAt: "2026-07-01T15:59:00.000Z", redeemedBy: null, redeemedEmail: null, redeemedAt: null, note: null, createdAt: "2026-06-10T06:45:00.000Z" },
];
type CreateValues = {
credits: number;
count: number;
expiresAt?: ReturnType<typeof dayjs>;
note?: string;
};
type EditValues = { note?: string; expiresAt?: ReturnType<typeof dayjs> | null };
const dateFormatter = new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Asia/Taipei",
});
const statusColors: Record<CodeRecord["status"], string> = {
available: "green",
expired: "orange",
redeemed: "blue",
revoked: "red",
};
function apiMessage(payload: unknown, fallback: string) {
if (!payload || typeof payload !== "object") return fallback;
const data = payload as Record<string, unknown>;
return [data.message, data.error].find((value) => typeof value === "string") as string || fallback;
}
export default function CodesPage() {
const { data: role } = usePermissions<"admin" | "viewer">({});
const { data: identity } = useGetIdentity<AdminIdentity>();
const { mutate: createCodes, mutation: createMutation } = useCreate<{ id: string; generated: CodeRecord[] }>();
const { mutate: updateCode, mutation: updateMutation } = useUpdate<CodeRecord>();
const { mutate: revokeCode, mutation: revokeMutation } = useDelete<CodeRecord>();
const [createOpen, setCreateOpen] = useState(false);
const [editRecord, setEditRecord] = useState<CodeRecord | null>(null);
const [generated, setGenerated] = useState<CodeRecord[]>([]);
const [createForm] = Form.useForm<CreateValues>();
const [editForm] = Form.useForm<EditValues>();
const writable = role === "admin";
function redirectForAuth(response: Response) {
if (response.status === 401) window.location.assign("/login");
if (response.status === 403) window.location.assign("/");
}
function codeStatus(code: CodeRecord) {
if (code.redeemedAt) return "已兑换";
if (code.expiresAt && new Date(code.expiresAt).getTime() <= Date.now()) return "已过期";
return "可用";
}
function formatDate(value: string | null) {
return value ? dateFormatter.format(new Date(value)) : "—";
}
export default function AdminCodesPage() {
const [codes, setCodes] = useState<CodeRecord[]>([]);
const [generated, setGenerated] = useState<GeneratedCode[]>([]);
const [credits, setCredits] = useState(10);
const [count, setCount] = useState(1);
const [expiresAt, setExpiresAt] = useState("");
const [note, setNote] = useState("");
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const previewMode = useRef(false);
const [error, setError] = useState("");
const [copyNotice, setCopyNotice] = useState("");
useEffect(() => {
if (process.env.NODE_ENV === "development" && new URLSearchParams(window.location.search).get("preview") === "admin") {
const previewFrame = window.requestAnimationFrame(() => {
previewMode.current = true;
setCodes(previewCodes);
setLoading(false);
});
return () => window.cancelAnimationFrame(previewFrame);
}
const controller = new AbortController();
void fetch("/api/admin/codes", { signal: controller.signal, cache: "no-store" })
.then(async (response) => {
redirectForAuth(response);
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(apiMessage(payload, "暂时无法读取兑换码"));
setCodes((payload as { codes: CodeRecord[] }).codes);
})
.catch((caught) => {
if ((caught as Error).name !== "AbortError") setError(caught instanceof Error ? caught.message : "暂时无法读取兑换码");
})
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
async function reloadCodes() {
const response = await fetch("/api/admin/codes", { cache: "no-store" });
redirectForAuth(response);
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(apiMessage(payload, "暂时无法刷新兑换码"));
setCodes((payload as { codes: CodeRecord[] }).codes);
function submitCreate(values: CreateValues) {
createCodes({
resource: "codes",
values: {
credits: values.credits,
count: values.count,
expiresAt: values.expiresAt?.toISOString() ?? null,
note: values.note?.trim() || null,
},
successNotification: false,
}, {
onSuccess(result) {
setGenerated(result.data.generated);
setCreateOpen(false);
createForm.resetFields();
},
});
}
async function createCodes(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (creating) return;
setCreating(true);
setError("");
setGenerated([]);
setCopyNotice("");
if (process.env.NODE_ENV === "development" && previewMode.current) {
setGenerated(Array.from({ length: count }, (_, index) => ({
code: `PREVIEW-${String(index + 1).padStart(2, "0")}-JYOTISH`,
credits,
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
})));
setCreating(false);
return;
}
try {
const response = await fetch("/api/admin/codes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
credits,
count,
...(expiresAt ? { expiresAt: new Date(expiresAt).toISOString() } : {}),
...(note.trim() ? { note: note.trim() } : {}),
}),
});
redirectForAuth(response);
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(apiMessage(payload, "生成兑换码失败"));
setGenerated((payload as { codes: GeneratedCode[] }).codes);
await reloadCodes();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "生成兑换码失败");
} finally {
setCreating(false);
}
function submitEdit(values: EditValues) {
if (!editRecord) return;
updateCode({
resource: "codes",
id: editRecord.id,
values: {
note: values.note?.trim() || null,
expiresAt: values.expiresAt?.toISOString() ?? null,
},
}, { onSuccess: () => setEditRecord(null) });
}
async function copy(text: string) {
try {
await navigator.clipboard.writeText(text);
setCopyNotice("已复制到剪贴板");
} catch {
setCopyNotice("无法自动复制,请手动选择兑换码");
}
function confirmRevoke(record: CodeRecord) {
Modal.confirm({
title: "撤销此兑换码?",
content: `${record.mask} 撤销后不可兑换,且不能恢复。`,
okText: "确认撤销",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: () => new Promise<void>((resolve, reject) => {
revokeCode({ resource: "codes", id: record.id }, {
onSuccess: () => resolve(),
onError: () => reject(new Error("撤销失败")),
});
}),
});
}
const columns: TableColumnsType<CodeRecord> = [
{ title: "兑换码", dataIndex: "mask" },
{ title: "点数", dataIndex: "credits", sorter: true },
{ title: "状态", dataIndex: "status", sorter: true, render: (value) => <Tag color={statusColors[value as CodeRecord["status"]]}>{value}</Tag> },
{ title: "到期时间", dataIndex: "expiresAt", sorter: true, render: formatAdminDate },
{ title: "备注", dataIndex: "note", render: (value) => value || "—" },
{ title: "兑换账户", dataIndex: "redeemedEmail", render: (value) => value || "—" },
{ title: "兑换时间", dataIndex: "redeemedAt", render: formatAdminDate },
{ title: "撤销时间", dataIndex: "revokedAt", render: formatAdminDate },
{ title: "创建时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
{
title: "操作",
fixed: "right",
render: (_, record) => writable && record.status !== "redeemed" && record.status !== "revoked" ? (
<Space>
<Button size="small" onClick={() => {
setEditRecord(record);
editForm.setFieldsValue({
note: record.note ?? undefined,
expiresAt: record.expiresAt ? dayjs(record.expiresAt) : null,
});
}}></Button>
<Button danger size="small" loading={revokeMutation.isPending} onClick={() => confirmRevoke(record)}></Button>
</Space>
) : "—",
},
];
return (
<main className="standalone-page admin-page">
<header className="admin-header">
<h1></h1>
<Link className="button-secondary" href="/"></Link>
</header>
<>
<ResourceTable<CodeRecord>
resource="codes"
title={`兑换码${identity ? ` · ${identity.email} (${identity.role})` : ""}`}
columns={columns}
statusOptions={[
{ label: "可用", value: "available" },
{ label: "已过期", value: "expired" },
{ label: "已兑换", value: "redeemed" },
{ label: "已撤销", value: "revoked" },
]}
extra={writable ? <Button type="primary" onClick={() => setCreateOpen(true)}></Button> : <Tag>viewer </Tag>}
/>
<div className="admin-scroll">
<section className="admin-section" aria-labelledby="create-codes-title">
<div className="section-title"><div><h2 id="create-codes-title"></h2><p><span className="phrase-nowrap"></span></p></div></div>
<form className="code-form" onSubmit={createCodes}>
<label><span></span><input type="number" min={1} required value={credits} onChange={(event) => setCredits(Number(event.target.value))} /></label>
<label><span></span><input type="number" min={1} max={100} required value={count} onChange={(event) => setCount(Number(event.target.value))} /></label>
<label><span> <em></em></span><input type="datetime-local" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)} /></label>
<label className="note-field"><span> <em></em></span><input maxLength={200} value={note} onChange={(event) => setNote(event.target.value)} placeholder="例如:7 月活动" /></label>
<button className="button-primary" type="submit" disabled={creating || credits < 1 || count < 1 || count > 100}>{creating ? "生成中" : "生成"}</button>
</form>
{error && <p className="form-error" role="alert">{error}</p>}
</section>
<Modal title="批量生成兑换码" open={createOpen} onCancel={() => setCreateOpen(false)} footer={null} destroyOnHidden>
<Form form={createForm} layout="vertical" initialValues={{ credits: 10, count: 1 }} onFinish={submitCreate}>
<Form.Item name="credits" label="每个点数" rules={[{ required: true }]}><InputNumber min={1} max={1_000_000} style={{ width: "100%" }} /></Form.Item>
<Form.Item name="count" label="数量" rules={[{ required: true }]}><InputNumber min={1} max={100} style={{ width: "100%" }} /></Form.Item>
<Form.Item name="expiresAt" label="到期时间"><DatePicker showTime style={{ width: "100%" }} /></Form.Item>
<Form.Item name="note" label="备注"><Input maxLength={500} /></Form.Item>
<Button block type="primary" htmlType="submit" loading={createMutation.isPending}></Button>
</Form>
</Modal>
{generated.length > 0 && (
<section className="admin-section generated-section" aria-labelledby="generated-title">
<div className="section-title">
<div><h2 id="generated-title"></h2><p></p></div>
<button className="button-secondary" type="button" onClick={() => void copy(generated.map((item) => item.code).join("\n"))}></button>
</div>
<div className="generated-list">
{generated.map((item) => (
<div key={item.code}><code>{item.code}</code><span>{item.credits} </span><button type="button" onClick={() => void copy(item.code)}></button></div>
))}
</div>
{copyNotice && <p className="form-success" role="status">{copyNotice}</p>}
</section>
)}
<Modal title="完整兑换码(仅显示本次)" open={generated.length > 0} onCancel={() => setGenerated([])} footer={<Button onClick={() => setGenerated([])}></Button>}>
<Typography.Paragraph type="warning"></Typography.Paragraph>
{generated.map((record) => <Typography.Paragraph copyable key={record.code}><Typography.Text code>{record.code}</Typography.Text></Typography.Paragraph>)}
</Modal>
<section className="admin-section" aria-labelledby="codes-list-title">
<div className="section-title"><div><h2 id="codes-list-title"></h2><p>{loading ? "正在读取…" : `${codes.length} 条记录`}</p></div></div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{codes.map((code) => (
<tr key={code.id}>
<td><code>{code.mask}</code></td><td>{code.credits}</td><td><span className={`code-status status-${codeStatus(code)}`}>{codeStatus(code)}</span></td><td>{formatDate(code.expiresAt)}</td><td>{code.redeemedEmail || code.redeemedBy || "—"}</td><td>{formatDate(code.redeemedAt)}</td><td>{code.note || "—"}</td><td>{formatDate(code.createdAt)}</td>
</tr>
))}
{!loading && codes.length === 0 && <tr><td colSpan={8} className="empty-cell"></td></tr>}
</tbody>
</table>
</div>
</section>
</div>
</main>
<Modal title="编辑未兑换码" open={Boolean(editRecord)} onCancel={() => setEditRecord(null)} footer={null} destroyOnHidden>
<Form form={editForm} layout="vertical" onFinish={submitEdit}>
<Form.Item name="expiresAt" label="到期时间"><DatePicker showTime style={{ width: "100%" }} /></Form.Item>
<Form.Item name="note" label="备注"><Input maxLength={500} /></Form.Item>
<Button block type="primary" htmlType="submit" loading={updateMutation.isPending}></Button>
</Form>
</Modal>
</>
);
}
@@ -0,0 +1,40 @@
"use client";
import { Tag, type TableColumnsType } from "antd";
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
type ConsultationRecord = {
id: string;
email: string | null;
requestId: string;
status: string;
createdAt: string;
updatedAt: string;
};
const colors: Record<string, string> = {
reserved: "gold",
completed: "green",
cancelled: "default",
};
const columns: TableColumnsType<ConsultationRecord> = [
{ title: "用户", dataIndex: "email", render: (value) => value || "—" },
{ title: "请求 ID", dataIndex: "requestId" },
{ title: "状态", dataIndex: "status", sorter: true, render: (value) => <Tag color={colors[value]}>{value}</Tag> },
{ title: "创建时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
{ title: "更新时间", dataIndex: "updatedAt", sorter: true, render: formatAdminDate },
];
export default function ConsultationsPage() {
return <ResourceTable<ConsultationRecord>
resource="consultations"
title="咨询请求(只读)"
columns={columns}
statusOptions={[
{ label: "已预扣", value: "reserved" },
{ label: "已完成", value: "completed" },
{ label: "已取消", value: "cancelled" },
]}
/>;
}
@@ -0,0 +1,42 @@
"use client";
import { Tag, type TableColumnsType } from "antd";
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
type TransactionRecord = {
id: string;
email: string | null;
type: string;
amount: number;
balanceAfter: number;
requestId: string;
model: string | null;
inputTokens: number | null;
outputTokens: number | null;
createdAt: string;
};
const columns: TableColumnsType<TransactionRecord> = [
{ title: "用户", dataIndex: "email", render: (value) => value || "—" },
{ title: "类型", dataIndex: "type", sorter: true, render: (value) => <Tag>{value}</Tag> },
{ title: "变动", dataIndex: "amount", sorter: true, render: (value) => value > 0 ? `+${value}` : value },
{ title: "余额", dataIndex: "balanceAfter", sorter: true },
{ title: "请求 ID", dataIndex: "requestId" },
{ title: "模型", dataIndex: "model", render: (value) => value || "—" },
{ title: "输入/输出 token", render: (_, row) => `${row.inputTokens ?? "—"} / ${row.outputTokens ?? "—"}` },
{ title: "时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
];
export default function CreditTransactionsPage() {
return <ResourceTable<TransactionRecord>
resource="credit-transactions"
title="积分流水(只读)"
columns={columns}
statusOptions={[
{ label: "兑换", value: "redeem" },
{ label: "预扣", value: "reserve" },
{ label: "退款", value: "refund" },
]}
/>;
}
+7 -14
View File
@@ -1,18 +1,11 @@
import { ReactNode } from "react";
import { redirect } from "next/navigation";
import { isAdminEmail } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import "@refinedev/antd/dist/reset.css";
import "antd/dist/reset.css";
import type { ReactNode } from "react";
import { AdminApp } from "@/components/admin/admin-app";
export const dynamic = "force-dynamic";
export default async function AdminLayout({ children }: { children: ReactNode }) {
if (process.env.NODE_ENV === "development" && process.env.ENABLE_ADMIN_PREVIEW === "1") return children;
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
if (!isAdminEmail(user.email)) redirect("/");
return children;
export default function AdminLayout({ children }: { children: ReactNode }) {
return <AdminApp>{children}</AdminApp>;
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminPage() {
redirect("/admin/codes");
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { Tag, type TableColumnsType } from "antd";
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
type UserRecord = {
id: string;
email: string;
name: string | null;
role: string;
emailVerified: boolean;
banned: boolean;
createdAt: string;
credits: number;
birthDate: string | null;
birthTimeStatus: string | null;
birthPlace: string | null;
};
const columns: TableColumnsType<UserRecord> = [
{ title: "邮箱", dataIndex: "email", sorter: true },
{ title: "姓名", dataIndex: "name", sorter: true, render: (value) => value || "—" },
{ title: "角色", dataIndex: "role", render: (value) => <Tag>{value}</Tag> },
{ title: "积分", dataIndex: "credits", sorter: true },
{ title: "出生日期", dataIndex: "birthDate", render: (value) => value || "—" },
{ title: "出生时间状态", dataIndex: "birthTimeStatus", render: (value) => value || "—" },
{ title: "出生地", dataIndex: "birthPlace", render: (value) => value || "—" },
{ 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 },
];
export default function UsersPage() {
return <ResourceTable<UserRecord> resource="users" title="用户资料(只读)" columns={columns} />;
}
@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { requireAdminSession } 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 AuditRow = {
id: string;
actor_user_id: string;
actor_email: string;
actor_role: string;
action: string;
target_type: string;
target_id: string;
before_value: Record<string, unknown> | null;
after_value: Record<string, unknown> | null;
request_id: string;
created_at: Date;
total_count: string;
};
const sortColumns = new Map([
["createdAt", "a.created_at"],
["action", "a.action"],
["actorEmail", "a.actor_email"],
]);
export const POST = readonlyAdminMutation;
export const PUT = readonlyAdminMutation;
export const PATCH = readonlyAdminMutation;
export const DELETE = readonlyAdminMutation;
export async function GET(request: Request) {
try {
await requireAdminSession();
const parsed = parseListQuery(request);
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
const { page, pageSize, sort, order, q, status } = parsed.data;
const values: unknown[] = [];
const conditions: string[] = [];
if (q) {
values.push(`%${q}%`);
conditions.push(`(a.actor_email ilike $${values.length} or a.request_id ilike $${values.length})`);
}
if (status) {
values.push(status);
conditions.push(`a.action = $${values.length}`);
}
values.push(pageSize, pageOffset(page, pageSize));
const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "a.created_at";
const rows = await queryAdminRows<AuditRow>(`
select a.*, count(*) over()::text as total_count
from audit.admin_audit_logs a
${conditions.length ? `where ${conditions.join(" and ")}` : ""}
order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, a.id asc
limit $${values.length - 1} offset $${values.length}
`, values);
return NextResponse.json({
data: rows.map((row) => ({
id: row.id,
actorUserId: row.actor_user_id,
actorEmail: row.actor_email,
actorRole: row.actor_role,
action: row.action,
targetType: row.target_type,
targetId: row.target_id,
before: row.before_value,
after: row.after_value,
requestId: row.request_id,
createdAt: row.created_at.toISOString(),
})),
total: Number(rows[0]?.total_count ?? 0),
});
} catch (error) {
return adminErrorResponse(error);
}
}
@@ -0,0 +1,70 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/admin/auth";
import { runCodeRpc } from "@/lib/admin/codes";
import {
adminErrorResponse,
invalidQueryResponse,
requestId,
} from "@/lib/admin/http";
export const runtime = "nodejs";
const paramsSchema = z.object({ id: z.string().uuid() });
const updateCodeSchema = z.object({
note: z.string().trim().max(500).nullable().optional(),
expiresAt: z.string().datetime({ offset: true }).nullable().optional(),
}).refine((value) => "note" in value || "expiresAt" in value, {
message: "至少提供一个可修改字段",
});
export async function PATCH(
request: Request,
context: { params: Promise<{ id: string }> },
) {
try {
const session = await requireAdminSession("write");
const parsedParams = paramsSchema.safeParse(await context.params);
const parsedBody = updateCodeSchema.safeParse(await request.json().catch(() => null));
if (!parsedParams.success || !parsedBody.success) {
return invalidQueryResponse();
}
const body = parsedBody.data;
const rows = await runCodeRpc(
"admin_update_redemption_code",
session,
requestId(request),
{
p_code_id: parsedParams.data.id,
p_set_note: "note" in body,
p_note: body.note ?? null,
p_set_expires_at: "expiresAt" in body,
p_expires_at: body.expiresAt ?? null,
},
);
return NextResponse.json({ data: rows[0] });
} catch (error) {
return adminErrorResponse(error);
}
}
export async function DELETE(
request: Request,
context: { params: Promise<{ id: string }> },
) {
try {
const session = await requireAdminSession("write");
const parsed = paramsSchema.safeParse(await context.params);
if (!parsed.success) return invalidQueryResponse();
const rows = await runCodeRpc(
"admin_revoke_redemption_code",
session,
requestId(request),
{ p_code_id: parsed.data.id },
);
return NextResponse.json({ data: rows[0] });
} catch (error) {
return adminErrorResponse(error);
}
}
+108 -78
View File
@@ -1,113 +1,143 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/admin/auth";
import { mapCode, runCodeRpc, type RedemptionCodeRecord } from "@/lib/admin/codes";
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
import {
createAdminSupabaseClient,
isAdminEmail,
} from "@/lib/supabase/admin";
adminErrorResponse,
invalidQueryResponse,
parseListQuery,
requestId,
} from "@/lib/admin/http";
import {
generateRedeemCode,
hashRedeemCode,
maskRedeemCode,
} from "@/lib/supabase/codes";
import {
isSupabaseConfigurationError,
SupabaseConfigurationError,
} from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
const createCodesSchema = z.object({
credits: z.number().int().positive().max(1_000_000),
count: z.number().int().min(1).max(100),
expiresAt: z.string().datetime({ offset: true }).optional(),
note: z.string().trim().max(500).optional(),
expiresAt: z.string().datetime({ offset: true }).nullable().optional(),
note: z.string().trim().max(500).nullable().optional(),
});
async function requireAdmin() {
if (!process.env.ADMIN_EMAILS?.trim()) {
throw new SupabaseConfigurationError(["ADMIN_EMAILS"]);
}
type CodeRow = {
id: string;
code_mask: string;
credits: number;
expires_at: Date | null;
note: string | null;
created_at: Date;
redeemed_by: string | null;
redeemed_email: string | null;
redeemed_at: Date | null;
revoked_by: string | null;
revoked_at: Date | null;
total_count: string;
};
const supabase = await createServerSupabaseClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) return { response: NextResponse.json({ error: "请先登录" }, { status: 401 }) };
if (!isAdminEmail(user.email)) {
return { response: NextResponse.json({ error: "无管理员权限" }, { status: 403 }) };
}
return { user };
const sortColumns = new Map([
["createdAt", "c.created_at"],
["expiresAt", "c.expires_at"],
["credits", "c.credits"],
["status", "status"],
]);
function serializedCodeRow(row: CodeRow) {
return mapCode({
...row,
expires_at: row.expires_at?.toISOString() ?? null,
created_at: row.created_at.toISOString(),
redeemed_at: row.redeemed_at?.toISOString() ?? null,
revoked_at: row.revoked_at?.toISOString() ?? null,
});
}
export async function GET() {
export async function GET(request: Request) {
try {
const auth = await requireAdmin();
if ("response" in auth) return auth.response;
const admin = createAdminSupabaseClient();
const { data, error } = await admin
.from("redemption_codes")
.select("id,code_mask,credits,expires_at,note,created_at,redeemed_by,redeemed_email,redeemed_at")
.order("created_at", { ascending: false })
.limit(100);
if (error) {
return NextResponse.json({ error: "暂时无法读取兑换码列表" }, { status: 500 });
await requireAdminSession();
const parsed = parseListQuery(request);
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
const { page, pageSize, sort, order, q, status } = parsed.data;
const values: unknown[] = [];
const conditions: string[] = [];
if (q) {
values.push(`%${q}%`);
conditions.push(`(c.code_mask ilike $${values.length} or c.note ilike $${values.length})`);
}
if (status && ["available", "expired", "redeemed", "revoked"].includes(status)) {
const clauses = {
available: "c.redeemed_at is null and c.revoked_at is null and (c.expires_at is null or c.expires_at > now())",
expired: "c.redeemed_at is null and c.revoked_at is null and c.expires_at <= now()",
redeemed: "c.redeemed_at is not null",
revoked: "c.revoked_at is not null",
};
conditions.push(clauses[status as keyof typeof clauses]);
}
values.push(pageSize, pageOffset(page, pageSize));
const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "c.created_at";
const rows = await queryAdminRows<CodeRow>(`
select c.id, c.code_mask, c.credits, c.expires_at, c.note,
c.created_at, c.redeemed_by, c.redeemed_email, c.redeemed_at,
c.revoked_by, c.revoked_at,
case
when c.redeemed_at is not null then 'redeemed'
when c.revoked_at is not null then 'revoked'
when c.expires_at is not null and c.expires_at <= now() then 'expired'
else 'available'
end as status,
count(*) over()::text as total_count
from public.redemption_codes c
${conditions.length ? `where ${conditions.join(" and ")}` : ""}
order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, c.id asc
limit $${values.length - 1} offset $${values.length}
`, values);
return NextResponse.json({
codes: data.map((code) => ({
id: code.id,
mask: code.code_mask,
credits: code.credits,
expiresAt: code.expires_at,
note: code.note,
createdAt: code.created_at,
redeemedBy: code.redeemed_by,
redeemedEmail: code.redeemed_email,
redeemedAt: code.redeemed_at,
})),
data: rows.map(serializedCodeRow),
total: Number(rows[0]?.total_count ?? 0),
});
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 或管理员白名单尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: "兑换码管理服务暂时不可用" }, { status: 500 });
return adminErrorResponse(error);
}
}
export async function POST(request: Request) {
try {
const auth = await requireAdmin();
if ("response" in auth) return auth.response;
const session = await requireAdminSession("write");
const parsed = createCodesSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "兑换码参数不正确" }, { status: 400 });
}
const { credits, count, expiresAt, note } = parsed.data;
const codes = Array.from({ length: count }, generateRedeemCode);
const admin = createAdminSupabaseClient();
const { error } = await admin.from("redemption_codes").insert(codes.map((code) => ({
code_hash: hashRedeemCode(code),
code_mask: maskRedeemCode(code),
credits,
expires_at: expiresAt ?? null,
note: note || null,
created_by: auth.user.id,
})));
if (error) {
return NextResponse.json({ error: "生成兑换码失败,请重试" }, { status: 500 });
}
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
const plainCodes = Array.from({ length: parsed.data.count }, generateRedeemCode);
const records = plainCodes.map((code) => ({
codeHash: hashRedeemCode(code),
codeMask: maskRedeemCode(code),
credits: parsed.data.credits,
expiresAt: parsed.data.expiresAt ?? null,
note: parsed.data.note || null,
}));
const operationRequestId = requestId(request);
const stored = await runCodeRpc(
"admin_create_redemption_codes",
session,
operationRequestId,
{ p_codes: records },
);
const byMask = new Map<string, RedemptionCodeRecord>(
stored.map((record) => [record.mask, record]),
);
return NextResponse.json({
codes: codes.map((code) => ({ code, credits, expiresAt: expiresAt ?? null, note: note || null })),
data: {
id: operationRequestId,
generated: plainCodes.map((code) => ({
...(byMask.get(maskRedeemCode(code)) ?? {}),
code,
})),
},
}, { status: 201 });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 或管理员白名单尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: "兑换码管理服务暂时不可用" }, { status: 500 });
return adminErrorResponse(error);
}
}
@@ -0,0 +1,79 @@
import { NextResponse } from "next/server";
import { requireAdminSession } 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 ConsultationRow = {
id: string;
user_id: string;
email: string | null;
request_id: string;
status: string;
created_at: Date;
updated_at: Date;
total_count: string;
};
const sortColumns = new Map([
["createdAt", "c.created_at"],
["updatedAt", "c.updated_at"],
["status", "c.status"],
]);
export const POST = readonlyAdminMutation;
export const PUT = readonlyAdminMutation;
export const PATCH = readonlyAdminMutation;
export const DELETE = readonlyAdminMutation;
export async function GET(request: Request) {
try {
await requireAdminSession();
const parsed = parseListQuery(request);
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
const { page, pageSize, sort, order, q, status } = parsed.data;
const values: unknown[] = [];
const conditions: string[] = [];
if (q) {
values.push(`%${q}%`);
conditions.push(`(p.email ilike $${values.length} or c.request_id ilike $${values.length})`);
}
if (status && ["reserved", "completed", "cancelled"].includes(status)) {
values.push(status);
conditions.push(`c.status = $${values.length}`);
}
values.push(pageSize, pageOffset(page, pageSize));
const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "c.created_at";
const rows = await queryAdminRows<ConsultationRow>(`
select c.user_id || ':' || c.request_id as id, c.user_id, p.email,
c.request_id, c.status, c.created_at, c.updated_at,
count(*) over()::text as total_count
from public.consultation_requests c
left join public.profiles p on p.id = c.user_id
${conditions.length ? `where ${conditions.join(" and ")}` : ""}
order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, c.request_id asc
limit $${values.length - 1} offset $${values.length}
`, values);
return NextResponse.json({
data: rows.map((row) => ({
id: row.id,
userId: row.user_id,
email: row.email,
requestId: row.request_id,
status: row.status,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
})),
total: Number(rows[0]?.total_count ?? 0),
});
} catch (error) {
return adminErrorResponse(error);
}
}
@@ -0,0 +1,88 @@
import { NextResponse } from "next/server";
import { requireAdminSession } 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 TransactionRow = {
id: string;
user_id: string;
email: string | null;
transaction_type: string;
amount: number;
balance_after: number;
request_id: string;
model: string | null;
input_tokens: number | null;
output_tokens: number | null;
created_at: Date;
total_count: string;
};
const sortColumns = new Map([
["createdAt", "t.created_at"],
["amount", "t.amount"],
["balanceAfter", "t.balance_after"],
["type", "t.transaction_type"],
]);
export const POST = readonlyAdminMutation;
export const PUT = readonlyAdminMutation;
export const PATCH = readonlyAdminMutation;
export const DELETE = readonlyAdminMutation;
export async function GET(request: Request) {
try {
await requireAdminSession();
const parsed = parseListQuery(request);
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
const { page, pageSize, sort, order, q, status } = parsed.data;
const values: unknown[] = [];
const conditions: string[] = [];
if (q) {
values.push(`%${q}%`);
conditions.push(`(p.email ilike $${values.length} or t.request_id ilike $${values.length})`);
}
if (status && ["redeem", "reserve", "refund"].includes(status)) {
values.push(status);
conditions.push(`t.transaction_type = $${values.length}`);
}
values.push(pageSize, pageOffset(page, pageSize));
const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "t.created_at";
const rows = await queryAdminRows<TransactionRow>(`
select t.id, t.user_id, p.email, t.transaction_type, t.amount,
t.balance_after, t.request_id, t.model, t.input_tokens,
t.output_tokens, t.created_at, count(*) over()::text as total_count
from public.credit_transactions t
left join public.profiles p on p.id = t.user_id
${conditions.length ? `where ${conditions.join(" and ")}` : ""}
order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, t.id asc
limit $${values.length - 1} offset $${values.length}
`, values);
return NextResponse.json({
data: rows.map((row) => ({
id: row.id,
userId: row.user_id,
email: row.email,
type: row.transaction_type,
amount: row.amount,
balanceAfter: row.balance_after,
requestId: row.request_id,
model: row.model,
inputTokens: row.input_tokens,
outputTokens: row.output_tokens,
createdAt: row.created_at.toISOString(),
})),
total: Number(rows[0]?.total_count ?? 0),
});
} catch (error) {
return adminErrorResponse(error);
}
}
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { requireAdminSession } from "@/lib/admin/auth";
import { adminErrorResponse } from "@/lib/admin/http";
export const runtime = "nodejs";
export async function GET() {
try {
const { user, role } = await requireAdminSession();
return NextResponse.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role,
},
});
} catch (error) {
return adminErrorResponse(error);
}
}
+85
View File
@@ -0,0 +1,85 @@
import { NextResponse } from "next/server";
import { requireAdminSession } 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 UserRow = {
id: string;
email: string;
name: string | null;
role: string;
email_verified: boolean;
banned: boolean;
created_at: Date;
credits: number;
birth_date: string | null;
birth_time_status: string | null;
birth_place_label: string | null;
total_count: string;
};
const sortColumns = new Map([
["createdAt", "u.created_at"],
["email", "u.email"],
["credits", "p.credits"],
["name", "u.name"],
]);
export const POST = readonlyAdminMutation;
export const PUT = readonlyAdminMutation;
export const PATCH = readonlyAdminMutation;
export const DELETE = readonlyAdminMutation;
export async function GET(request: Request) {
try {
await requireAdminSession();
const parsed = parseListQuery(request);
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
const { page, pageSize, sort, order, q } = parsed.data;
const values: unknown[] = [];
const conditions: string[] = [];
if (q) {
values.push(`%${q}%`);
conditions.push(`(u.email ilike $${values.length} or u.name ilike $${values.length})`);
}
values.push(pageSize, pageOffset(page, pageSize));
const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "u.created_at";
const rows = await queryAdminRows<UserRow>(`
select
u.id, u.email, u.name, u.role, u.email_verified, u.banned,
u.created_at, p.credits, p.birth_date, p.birth_time_status,
p.birth_place_label, count(*) over()::text as total_count
from identity.users u
join public.profiles p on p.id = u.id
${conditions.length ? `where ${conditions.join(" and ")}` : ""}
order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, u.id asc
limit $${values.length - 1} offset $${values.length}
`, values);
return NextResponse.json({
data: rows.map((row) => ({
id: row.id,
email: row.email,
name: row.name,
role: row.role,
emailVerified: row.email_verified,
banned: row.banned,
createdAt: row.created_at.toISOString(),
credits: row.credits,
birthDate: row.birth_date,
birthTimeStatus: row.birth_time_status,
birthPlace: row.birth_place_label,
})),
total: Number(rows[0]?.total_count ?? 0),
});
} catch (error) {
return adminErrorResponse(error);
}
}
+1
View File
@@ -12,6 +12,7 @@ const redeemErrors: Record<string, { status: number; message: string }> = {
unauthorized: { status: 401, message: "请先登录" },
invalid_code: { status: 404, message: "兑换码不存在" },
expired_code: { status: 410, message: "兑换码已过期" },
revoked_code: { status: 410, message: "兑换码已撤销" },
already_redeemed: { status: 409, message: "兑换码已被使用" },
profile_missing: { status: 500, message: "账户资料不存在,请稍后重试" },
};
@@ -0,0 +1,58 @@
"use client";
import {
AuditOutlined,
GiftOutlined,
MessageOutlined,
TeamOutlined,
TransactionOutlined,
} from "@ant-design/icons";
import { Authenticated, Refine } from "@refinedev/core";
import { ErrorComponent, ThemedLayout, useNotificationProvider } from "@refinedev/antd";
import routerProvider from "@refinedev/nextjs-router";
import { App as AntdApp, ConfigProvider, Spin, theme } from "antd";
import type { ReactNode } from "react";
import {
adminAccessControlProvider,
adminAuthProvider,
adminDataProvider,
} from "@/lib/admin/providers";
export function AdminApp({ children }: { children: ReactNode }) {
const notificationProvider = useNotificationProvider();
return (
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm, token: { colorPrimary: "#c8a96b" } }}>
<AntdApp>
<Refine
routerProvider={routerProvider}
dataProvider={adminDataProvider}
authProvider={adminAuthProvider}
accessControlProvider={adminAccessControlProvider}
notificationProvider={notificationProvider}
resources={[
{ name: "codes", list: "/admin/codes", meta: { label: "兑换码", icon: <GiftOutlined /> } },
{ name: "users", list: "/admin/users", meta: { label: "用户资料", icon: <TeamOutlined /> } },
{ name: "credit-transactions", list: "/admin/credit-transactions", meta: { label: "积分流水", icon: <TransactionOutlined /> } },
{ name: "consultations", list: "/admin/consultations", meta: { label: "咨询请求", icon: <MessageOutlined /> } },
{ name: "audit-logs", list: "/admin/audit-logs", meta: { label: "审计日志", icon: <AuditOutlined /> } },
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
title: { text: "Jyotisha 后台" },
}}
>
<Authenticated
key="admin-authenticated"
loading={<div className="admin-loading"><Spin size="large" /><span></span></div>}
>
<ThemedLayout>{children}</ThemedLayout>
</Authenticated>
</Refine>
</AntdApp>
</ConfigProvider>
);
}
export { ErrorComponent as AdminErrorComponent };
@@ -0,0 +1,65 @@
"use client";
import type { BaseRecord } from "@refinedev/core";
import { List, useTable } from "@refinedev/antd";
import { Alert, Empty, Form, Input, Select, Space, Table, type TableColumnsType } from "antd";
import type { ReactNode } from "react";
export type ResourceFilterOption = { label: string; value: string };
export function ResourceTable<T extends BaseRecord>({
resource,
title,
columns,
statusOptions,
extra,
}: {
resource: string;
title: string;
columns: TableColumnsType<T>;
statusOptions?: ResourceFilterOption[];
extra?: ReactNode;
}) {
const { tableProps, searchFormProps, tableQuery } = useTable<T, { message: string; statusCode: number }, { q?: string; status?: string }>({
resource,
syncWithLocation: true,
pagination: { pageSize: 20 },
onSearch(values) {
return [
{ field: "q", operator: "contains", value: values.q },
{ field: "status", operator: "eq", value: values.status },
];
},
});
const error = tableQuery.error;
return (
<List title={title} headerButtons={extra}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Form {...searchFormProps} layout="inline">
<Form.Item name="q"><Input.Search allowClear placeholder="搜索" /></Form.Item>
{statusOptions && (
<Form.Item name="status">
<Select allowClear placeholder="状态" options={statusOptions} style={{ minWidth: 160 }} />
</Form.Item>
)}
</Form>
{error && <Alert type="error" showIcon message="读取失败" description={error.message} />}
<Table<T>
{...tableProps}
columns={columns}
rowKey="id"
locale={{ emptyText: <Empty description="暂无数据" /> }}
scroll={{ x: "max-content" }}
/>
</Space>
</List>
);
}
export function formatAdminDate(value: string | null | undefined) {
return value ? new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value)) : "—";
}
+3 -3
View File
@@ -132,7 +132,7 @@ export function EmailOtpLogin({
});
if (otpError) throw otpError;
}
window.location.assign("/");
window.location.assign(window.location.hostname.startsWith("admin.") && window.location.hostname.includes("staging") ? "/admin" : "/");
} catch (caught) {
if (!(caught instanceof Error)) throw caught;
setError(authMessage(caught));
@@ -149,7 +149,7 @@ export function EmailOtpLogin({
setNotice("");
try {
await selfHostedAuthActions.signInWithPassword(email, password);
window.location.assign("/");
window.location.assign(window.location.hostname.startsWith("admin.") && window.location.hostname.includes("staging") ? "/admin" : "/");
} catch (caught) {
if (!(caught instanceof Error)) throw caught;
setError(authMessage(caught));
@@ -169,7 +169,7 @@ export function EmailOtpLogin({
setError("");
try {
await selfHostedAuthActions.setPassword(password);
window.location.assign("/");
window.location.assign(window.location.hostname.startsWith("admin.") && window.location.hostname.includes("staging") ? "/admin" : "/");
} catch (caught) {
if (!(caught instanceof Error)) throw caught;
const message = authMessage(caught);
+23
View File
@@ -0,0 +1,23 @@
import type { IdentityUser } from "@/modules/identity/contracts";
export type AdminRole = "admin" | "viewer";
export type AdminAccessResult =
| { allowed: true; role: AdminRole }
| { allowed: false; status: 401 | 403 };
export function authorizeAdminAccess(
user: IdentityUser | null,
access: "read" | "write",
): AdminAccessResult {
if (!user) return { allowed: false, status: 401 };
const role: AdminRole | null = user.role.includes("admin")
? "admin"
: user.role.includes("viewer")
? "viewer"
: null;
if (!role || (access === "write" && role !== "admin")) {
return { allowed: false, status: 403 };
}
return { allowed: true, role };
}
+55
View File
@@ -0,0 +1,55 @@
import "server-only";
import { headers } from "next/headers";
import { getIdentityAuthServices } from "@/modules/identity/auth";
import {
IdentityAuthorizationError,
requireIdentityUser,
} from "@/modules/identity/session";
import type { IdentityUser } from "@/modules/identity/contracts";
import { authorizeAdminAccess, type AdminRole } from "./auth-policy";
export type { AdminRole } from "./auth-policy";
export class AdminAuthorizationError extends Error {
constructor(
message: string,
readonly status: 401 | 403,
) {
super(message);
this.name = "AdminAuthorizationError";
}
}
export async function requireAdminSession(
access: "read" | "write" = "read",
): Promise<{ user: IdentityUser; role: AdminRole }> {
if (
process.env.AUTH_PROVIDER?.trim() !== "self-hosted"
|| process.env.APP_ENV?.trim() === "production"
) {
throw new AdminAuthorizationError("后台身份服务未启用", 403);
}
try {
const user = await requireIdentityUser(
getIdentityAuthServices().admin.api,
new Headers(await headers()),
);
const authorization = authorizeAdminAccess(user, access);
if (!authorization.allowed) {
throw new AdminAuthorizationError("无权执行此操作", authorization.status);
}
return { user, role: authorization.role };
} catch (error) {
if (error instanceof AdminAuthorizationError) throw error;
if (error instanceof IdentityAuthorizationError) {
throw new AdminAuthorizationError(
error.status === 401 ? "请先登录" : "无权访问后台",
error.status,
);
}
throw error;
}
}
+79
View File
@@ -0,0 +1,79 @@
import "server-only";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import type { IdentityUser } from "@/modules/identity/contracts";
import type { AdminRole } from "./auth";
export type RedemptionCodeRecord = {
id: string;
mask: string;
credits: number;
expiresAt: string | null;
note: string | null;
createdAt: string;
redeemedBy: string | null;
redeemedEmail: string | null;
redeemedAt: string | null;
revokedBy: string | null;
revokedAt: string | null;
status: "available" | "expired" | "redeemed" | "revoked";
};
type RpcCodeRow = {
id: string;
code_mask: string;
credits: number;
expires_at: string | null;
note: string | null;
created_at: string;
redeemed_by: string | null;
redeemed_email: string | null;
redeemed_at: string | null;
revoked_by: string | null;
revoked_at: string | null;
};
export function codeStatus(row: RpcCodeRow): RedemptionCodeRecord["status"] {
if (row.redeemed_at) return "redeemed";
if (row.revoked_at) return "revoked";
if (row.expires_at && Date.parse(row.expires_at) <= Date.now()) return "expired";
return "available";
}
export function mapCode(row: RpcCodeRow): RedemptionCodeRecord {
return {
id: row.id,
mask: row.code_mask,
credits: row.credits,
expiresAt: row.expires_at,
note: row.note,
createdAt: row.created_at,
redeemedBy: row.redeemed_by,
redeemedEmail: row.redeemed_email,
redeemedAt: row.redeemed_at,
revokedBy: row.revoked_by,
revokedAt: row.revoked_at,
status: codeStatus(row),
};
}
export async function runCodeRpc(
functionName:
| "admin_create_redemption_codes"
| "admin_update_redemption_code"
| "admin_revoke_redemption_code",
session: { user: IdentityUser; role: AdminRole },
id: string,
args: Record<string, unknown>,
): Promise<RedemptionCodeRecord[]> {
const admin = createAdminSupabaseClient();
const { data, error } = await admin.rpc(functionName, {
p_actor_user_id: session.user.id,
p_actor_email: session.user.email,
p_actor_role: session.role,
p_request_id: id,
...args,
});
if (error) throw new Error(error.message);
return ((data ?? []) as RpcCodeRow[]).map(mapCode);
}
+41
View File
@@ -0,0 +1,41 @@
import "server-only";
import { Pool, type QueryResultRow } from "pg";
import { readDatabaseUrl } from "@/lib/db/config";
const poolGlobal = globalThis as typeof globalThis & {
jyotishaAdminReadPool?: Pool;
};
export function adminReadPool(): Pool {
if (
process.env.AUTH_PROVIDER?.trim() !== "self-hosted"
|| process.env.APP_ENV?.trim() === "production"
) {
throw new Error("admin reads require the staging self-hosted identity service");
}
poolGlobal.jyotishaAdminReadPool ??= new Pool({
connectionString: readDatabaseUrl(process.env, "ADMIN_DATABASE_URL"),
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
allowExitOnIdle: true,
application_name: "jyotisha-admin-read",
});
return poolGlobal.jyotishaAdminReadPool;
}
export async function queryAdminRows<T extends QueryResultRow>(
sql: string,
values: readonly unknown[] = [],
): Promise<T[]> {
const result = await adminReadPool().query<T>(sql, [...values]);
return result.rows;
}
export type PageResult<T> = { data: T[]; total: number };
export function pageOffset(page: number, pageSize: number) {
return (page - 1) * pageSize;
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { AdminAuthorizationError } from "./auth";
export const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
sort: z.string().trim().max(64).optional(),
order: z.enum(["asc", "desc"]).default("desc"),
q: z.string().trim().max(200).optional(),
status: z.string().trim().max(50).optional(),
});
export type ListQuery = z.infer<typeof listQuerySchema>;
export function parseListQuery(request: Request) {
return listQuerySchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams.entries()),
);
}
export function requestId(request: Request): string {
const supplied = request.headers.get("x-request-id")?.trim();
return supplied && supplied.length <= 200 ? supplied : crypto.randomUUID();
}
export function adminErrorResponse(error: unknown) {
if (error instanceof AdminAuthorizationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
return NextResponse.json(
{ error: "后台服务暂时不可用" },
{ status: 500 },
);
}
export function invalidQueryResponse(details?: unknown) {
return NextResponse.json(
{ error: "查询参数不正确", ...(details ? { details } : {}) },
{ status: 400 },
);
}
export async function readonlyAdminMutation() {
try {
const { requireAdminSession } = await import("./auth");
await requireAdminSession();
return NextResponse.json({ error: "此资源只读" }, { status: 405 });
} catch (error) {
return adminErrorResponse(error);
}
}
+173
View File
@@ -0,0 +1,173 @@
"use client";
import type {
AccessControlProvider,
AuthProvider,
BaseRecord,
CrudFilter,
DataProvider,
HttpError,
CreateParams,
DeleteOneParams,
GetListParams,
GetOneParams,
UpdateParams,
} from "@refinedev/core";
export type AdminIdentity = {
id: string;
email: string;
name: string;
role: "admin" | "viewer";
};
const apiBase = "/api/admin";
let identityCache: AdminIdentity | null = null;
function logicalFilters(filters: CrudFilter[] | undefined) {
return (filters ?? []).filter(
(filter): filter is Extract<CrudFilter, { field: string }> => "field" in filter,
);
}
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
cache: "no-store",
credentials: "same-origin",
...init,
headers: {
...(init?.body ? { "content-type": "application/json" } : {}),
...init?.headers,
},
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload && typeof payload.error === "string"
? payload.error
: "后台请求失败";
throw { message, statusCode: response.status } satisfies HttpError;
}
return payload as T;
}
function listParams(
pagination: { currentPage?: number; pageSize?: number } | undefined,
sorters: { field: string; order: "asc" | "desc" }[] | undefined,
filters: CrudFilter[] | undefined,
) {
const search = new URLSearchParams({
page: String(pagination?.currentPage ?? 1),
pageSize: String(pagination?.pageSize ?? 20),
});
const sorter = sorters?.[0];
if (sorter) {
search.set("sort", sorter.field);
search.set("order", sorter.order);
}
for (const filter of logicalFilters(filters)) {
if (filter.value === undefined || filter.value === null || filter.value === "") continue;
if (filter.field === "q" || filter.field === "status") {
search.set(filter.field, String(filter.value));
}
}
return search;
}
export const adminDataProvider: DataProvider = {
async getList<TData extends BaseRecord>({ resource, pagination, sorters, filters }: GetListParams) {
const search = listParams(pagination, sorters, filters);
return requestJson<{ data: TData[]; total: number }>(
`${apiBase}/${resource}?${search}`,
);
},
async getOne<TData extends BaseRecord>({ resource, id }: GetOneParams) {
return requestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`);
},
async create<TData extends BaseRecord, TVariables>({ resource, variables }: CreateParams<TVariables>) {
return requestJson<{ data: TData }>(`${apiBase}/${resource}`, {
method: "POST",
body: JSON.stringify(variables),
});
},
async update<TData extends BaseRecord, TVariables>({ resource, id, variables }: UpdateParams<TVariables>) {
return requestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`, {
method: "PATCH",
body: JSON.stringify(variables),
});
},
async deleteOne<TData extends BaseRecord, TVariables>({ resource, id }: DeleteOneParams<TVariables>) {
return requestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`, {
method: "DELETE",
});
},
getApiUrl: () => apiBase,
};
async function loadIdentity(): Promise<AdminIdentity> {
if (identityCache) return identityCache;
const payload = await requestJson<{ user: AdminIdentity }>(`${apiBase}/session`);
identityCache = payload.user;
return identityCache;
}
export const adminAuthProvider: AuthProvider = {
async login() {
return { success: false, redirectTo: "/login" };
},
async logout() {
identityCache = null;
await fetch("/api/auth/sign-out", {
method: "POST",
credentials: "same-origin",
});
return { success: true, redirectTo: "/login" };
},
async check() {
try {
await loadIdentity();
return { authenticated: true };
} catch (error) {
const status = (error as HttpError).statusCode;
return {
authenticated: false,
redirectTo: status === 401 ? "/login" : "/",
error: error as HttpError,
};
}
},
async onError(error) {
const status = (error as HttpError)?.statusCode;
if (status === 401) return { redirectTo: "/login", logout: true };
if (status === 403) return { error: error as HttpError };
return { error: error as HttpError };
},
async getPermissions() {
return (await loadIdentity()).role;
},
async getIdentity() {
return loadIdentity();
},
};
const readOnlyResources = new Set([
"users",
"credit-transactions",
"consultations",
"audit-logs",
]);
export const adminAccessControlProvider: AccessControlProvider = {
async can({ resource, action }) {
const role = (await loadIdentity()).role;
if (action === "list" || action === "show") return { can: true };
if (readOnlyResources.has(resource ?? "")) {
return { can: false, reason: "此资源只读" };
}
return role === "admin"
? { can: true }
: { can: false, reason: "viewer 仅可查看" };
},
options: {
buttons: { enableAccessControl: true, hideIfUnauthorized: true },
},
};
+18 -3
View File
@@ -16,6 +16,8 @@ interface AdminRoleRow {
ban_expires: Date | null;
}
export type IdentityAdminSurfaceRole = "admin" | "viewer";
export function createIdentityPool(databaseUrl: string): Pool {
return new Pool({
connectionString: databaseUrl,
@@ -27,8 +29,9 @@ export function createIdentityPool(databaseUrl: string): Pool {
});
}
export function createDatabaseAdminAuthorizer(
function createDatabaseRoleAuthorizer(
pool: Pool,
allowedRoles: ReadonlySet<string>,
): AdminUserAuthorizer {
return async (userId) => {
const result = await pool.query<AdminRoleRow>(
@@ -52,10 +55,22 @@ export function createDatabaseAdminAuthorizer(
return user.role
.split(",")
.map((role) => role.trim())
.includes("admin");
.some((role) => allowedRoles.has(role));
};
}
export function createDatabaseAdminAuthorizer(
pool: Pool,
): AdminUserAuthorizer {
return createDatabaseRoleAuthorizer(pool, new Set(["admin"]));
}
export function createDatabaseAdminSurfaceAuthorizer(
pool: Pool,
): AdminUserAuthorizer {
return createDatabaseRoleAuthorizer(pool, new Set(["admin", "viewer"]));
}
export interface IdentityAuthServices {
pool: Pool;
user: ReturnType<typeof betterAuth>;
@@ -80,7 +95,7 @@ export function createIdentityAuthServices(
from: config.resendFrom,
});
const authorizeAdminUser =
dependencies.authorizeAdminUser ?? createDatabaseAdminAuthorizer(pool);
dependencies.authorizeAdminUser ?? createDatabaseAdminSurfaceAuthorizer(pool);
return {
pool,
@@ -0,0 +1,475 @@
alter table public.redemption_codes
add column if not exists revoked_at timestamptz,
add column if not exists revoked_by uuid;
alter table public.redemption_codes
drop constraint if exists redemption_codes_redeemed_or_revoked_check;
alter table public.redemption_codes
add constraint redemption_codes_redeemed_or_revoked_check check (
not (redeemed_at is not null and revoked_at is not null)
and ((revoked_by is null and revoked_at is null)
or (revoked_by is not null and revoked_at is not null))
);
create index if not exists redemption_codes_revoked_at_idx
on public.redemption_codes (revoked_at)
where revoked_at is not null;
create schema if not exists audit;
revoke all on schema audit from public, anon, authenticated;
grant usage on schema audit to service_role;
create table if not exists audit.admin_audit_logs (
id uuid primary key default gen_random_uuid(),
actor_user_id uuid not null,
actor_email text not null check (actor_email = lower(btrim(actor_email)) and char_length(actor_email) between 3 and 320),
actor_role text not null check (actor_role in ('admin', 'viewer')),
action text not null check (action in ('redemption_code.create', 'redemption_code.update', 'redemption_code.revoke')),
target_type text not null check (target_type = 'redemption_code'),
target_id uuid not null,
before_value jsonb,
after_value jsonb,
request_id text not null check (char_length(request_id) between 1 and 200),
created_at timestamptz not null default clock_timestamp(),
check (before_value is null or not (before_value ?| array['code', 'code_hash', 'token', 'secret', 'key'])),
check (after_value is null or not (after_value ?| array['code', 'code_hash', 'token', 'secret', 'key'])),
unique (actor_user_id, request_id, action, target_id)
);
create index if not exists admin_audit_logs_created_at_idx
on audit.admin_audit_logs (created_at desc);
create index if not exists admin_audit_logs_actor_idx
on audit.admin_audit_logs (actor_user_id, created_at desc);
alter table audit.admin_audit_logs enable row level security;
revoke all on table audit.admin_audit_logs from public, anon, authenticated, service_role;
grant select on table audit.admin_audit_logs to service_role;
do $$
begin
if exists (select 1 from pg_roles where rolname = 'admin_runtime') then
grant usage on schema audit to admin_runtime;
grant select on table audit.admin_audit_logs to admin_runtime;
grant select (id, email, credits, name, birth_date, birth_time_status,
birth_place_label) on table public.profiles to admin_runtime;
grant select (id, code_mask, credits, expires_at, note, created_at,
redeemed_by, redeemed_email, redeemed_at, revoked_by, revoked_at)
on table public.redemption_codes to admin_runtime;
grant select (id, user_id, transaction_type, amount, balance_after,
request_id, model, input_tokens, output_tokens, created_at)
on table public.credit_transactions to admin_runtime;
grant select (user_id, request_id, status, created_at, updated_at)
on table public.consultation_requests to admin_runtime;
drop policy if exists profiles_admin_read on public.profiles;
create policy profiles_admin_read on public.profiles
for select to admin_runtime using (true);
drop policy if exists redemption_codes_admin_read on public.redemption_codes;
create policy redemption_codes_admin_read on public.redemption_codes
for select to admin_runtime using (true);
drop policy if exists credit_transactions_admin_read on public.credit_transactions;
create policy credit_transactions_admin_read on public.credit_transactions
for select to admin_runtime using (true);
drop policy if exists consultation_requests_admin_read on public.consultation_requests;
create policy consultation_requests_admin_read on public.consultation_requests
for select to admin_runtime using (true);
drop policy if exists admin_audit_logs_admin_read on audit.admin_audit_logs;
create policy admin_audit_logs_admin_read on audit.admin_audit_logs
for select to admin_runtime using (true);
end if;
end;
$$;
create or replace function public.reject_admin_audit_mutation()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
raise exception 'admin audit logs are append-only' using errcode = '55000';
end;
$$;
revoke all on function public.reject_admin_audit_mutation() from public, anon, authenticated, service_role;
drop trigger if exists admin_audit_logs_append_only on audit.admin_audit_logs;
create trigger admin_audit_logs_append_only
before update or delete on audit.admin_audit_logs
for each row execute function public.reject_admin_audit_mutation();
create or replace function public.admin_redemption_code_snapshot(p_code public.redemption_codes)
returns jsonb
language sql
stable
set search_path = ''
as $$
select jsonb_build_object(
'id', p_code.id,
'mask', p_code.code_mask,
'credits', p_code.credits,
'expiresAt', p_code.expires_at,
'note', p_code.note,
'status', case
when p_code.redeemed_at is not null then 'redeemed'
when p_code.revoked_at is not null then 'revoked'
when p_code.expires_at is not null and p_code.expires_at <= now() then 'expired'
else 'available'
end,
'redeemedAt', p_code.redeemed_at,
'revokedAt', p_code.revoked_at
)
$$;
revoke all on function public.admin_redemption_code_snapshot(public.redemption_codes)
from public, anon, authenticated, service_role;
create or replace function public.admin_verified_actor_email(
p_actor_user_id uuid,
p_actor_email text,
p_actor_role text
)
returns text
language plpgsql
security definer
set search_path = ''
as $$
declare
v_email text;
v_role text;
v_banned boolean;
v_ban_expires timestamptz;
begin
select lower(btrim(u.email)), u.role, u.banned, u.ban_expires
into v_email, v_role, v_banned, v_ban_expires
from identity.users u
where u.id = p_actor_user_id;
if not found or p_actor_role <> 'admin'
or v_role is null
or not ('admin' = any(string_to_array(replace(v_role, ' ', ''), ',')))
or v_email is distinct from lower(btrim(p_actor_email))
or (v_banned and (v_ban_expires is null or v_ban_expires > now())) then
raise exception 'administrator access required' using errcode = '42501';
end if;
return v_email;
end;
$$;
revoke all on function public.admin_verified_actor_email(uuid, text, text)
from public, anon, authenticated, service_role;
create or replace function public.admin_create_redemption_codes(
p_actor_user_id uuid,
p_actor_email text,
p_actor_role text,
p_request_id text,
p_codes jsonb
)
returns table (
id uuid,
code_mask text,
credits integer,
expires_at timestamptz,
note text,
created_at timestamptz,
redeemed_by uuid,
redeemed_email text,
redeemed_at timestamptz,
revoked_by uuid,
revoked_at timestamptz
)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_item jsonb;
v_code public.redemption_codes%rowtype;
v_email text := lower(btrim(p_actor_email));
v_request_id text := btrim(p_request_id);
begin
v_email := public.admin_verified_actor_email(
p_actor_user_id, p_actor_email, p_actor_role
);
if v_request_id is null or char_length(v_request_id) not between 1 and 200 then
raise exception 'invalid request id' using errcode = '22023';
end if;
if p_codes is null or jsonb_typeof(p_codes) is distinct from 'array'
or jsonb_array_length(p_codes) not between 1 and 100 then
raise exception 'codes must contain between 1 and 100 items' using errcode = '22023';
end if;
for v_item in select value from jsonb_array_elements(p_codes)
loop
insert into public.redemption_codes (
code_hash, code_mask, credits, expires_at, note, created_by
) values (
v_item ->> 'codeHash',
v_item ->> 'codeMask',
(v_item ->> 'credits')::integer,
nullif(v_item ->> 'expiresAt', '')::timestamptz,
nullif(btrim(v_item ->> 'note'), ''),
p_actor_user_id
) returning * into v_code;
insert into audit.admin_audit_logs (
actor_user_id, actor_email, actor_role, action, target_type,
target_id, before_value, after_value, request_id
) values (
p_actor_user_id, v_email, p_actor_role, 'redemption_code.create',
'redemption_code', v_code.id, null,
public.admin_redemption_code_snapshot(v_code), v_request_id
);
id := v_code.id;
code_mask := v_code.code_mask;
credits := v_code.credits;
expires_at := v_code.expires_at;
note := v_code.note;
created_at := v_code.created_at;
redeemed_by := v_code.redeemed_by;
redeemed_email := v_code.redeemed_email;
redeemed_at := v_code.redeemed_at;
revoked_by := v_code.revoked_by;
revoked_at := v_code.revoked_at;
return next;
end loop;
end;
$$;
create or replace function public.admin_update_redemption_code(
p_actor_user_id uuid,
p_actor_email text,
p_actor_role text,
p_request_id text,
p_code_id uuid,
p_set_note boolean,
p_note text,
p_set_expires_at boolean,
p_expires_at timestamptz
)
returns table (
id uuid,
code_mask text,
credits integer,
expires_at timestamptz,
note text,
created_at timestamptz,
redeemed_by uuid,
redeemed_email text,
redeemed_at timestamptz,
revoked_by uuid,
revoked_at timestamptz
)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_before public.redemption_codes%rowtype;
v_after public.redemption_codes%rowtype;
v_email text := lower(btrim(p_actor_email));
begin
v_email := public.admin_verified_actor_email(
p_actor_user_id, p_actor_email, p_actor_role
);
if p_request_id is null or char_length(btrim(p_request_id)) not between 1 and 200 then
raise exception 'invalid request id' using errcode = '22023';
end if;
if not coalesce(p_set_note, false) and not coalesce(p_set_expires_at, false) then
raise exception 'no editable field supplied' using errcode = '22023';
end if;
if p_set_note and p_note is not null and char_length(p_note) > 500 then
raise exception 'note is too long' using errcode = '22023';
end if;
select rc.* into v_before
from public.redemption_codes rc
where rc.id = p_code_id
for update;
if not found then
raise exception 'redemption code not found' using errcode = 'P0002';
end if;
if v_before.redeemed_at is not null then
raise exception 'redeemed codes are immutable' using errcode = '55000';
end if;
if v_before.revoked_at is not null then
raise exception 'revoked codes are immutable' using errcode = '55000';
end if;
update public.redemption_codes rc
set note = case when p_set_note then nullif(btrim(p_note), '') else rc.note end,
expires_at = case when p_set_expires_at then p_expires_at else rc.expires_at end
where rc.id = p_code_id
returning * into v_after;
insert into audit.admin_audit_logs (
actor_user_id, actor_email, actor_role, action, target_type,
target_id, before_value, after_value, request_id
) values (
p_actor_user_id, v_email, p_actor_role, 'redemption_code.update',
'redemption_code', v_after.id,
public.admin_redemption_code_snapshot(v_before),
public.admin_redemption_code_snapshot(v_after), btrim(p_request_id)
);
return query select
v_after.id, v_after.code_mask, v_after.credits, v_after.expires_at,
v_after.note, v_after.created_at, v_after.redeemed_by,
v_after.redeemed_email, v_after.redeemed_at, v_after.revoked_by,
v_after.revoked_at;
end;
$$;
create or replace function public.admin_revoke_redemption_code(
p_actor_user_id uuid,
p_actor_email text,
p_actor_role text,
p_request_id text,
p_code_id uuid
)
returns table (
id uuid,
code_mask text,
credits integer,
expires_at timestamptz,
note text,
created_at timestamptz,
redeemed_by uuid,
redeemed_email text,
redeemed_at timestamptz,
revoked_by uuid,
revoked_at timestamptz
)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_before public.redemption_codes%rowtype;
v_after public.redemption_codes%rowtype;
v_email text := lower(btrim(p_actor_email));
begin
v_email := public.admin_verified_actor_email(
p_actor_user_id, p_actor_email, p_actor_role
);
if p_request_id is null or char_length(btrim(p_request_id)) not between 1 and 200 then
raise exception 'invalid request id' using errcode = '22023';
end if;
select rc.* into v_before
from public.redemption_codes rc
where rc.id = p_code_id
for update;
if not found then
raise exception 'redemption code not found' using errcode = 'P0002';
end if;
if v_before.redeemed_at is not null then
raise exception 'redeemed codes cannot be revoked' using errcode = '55000';
end if;
if v_before.revoked_at is not null then
raise exception 'redemption code is already revoked' using errcode = '55000';
end if;
update public.redemption_codes rc
set revoked_at = clock_timestamp(), revoked_by = p_actor_user_id
where rc.id = p_code_id
returning * into v_after;
insert into audit.admin_audit_logs (
actor_user_id, actor_email, actor_role, action, target_type,
target_id, before_value, after_value, request_id
) values (
p_actor_user_id, v_email, p_actor_role, 'redemption_code.revoke',
'redemption_code', v_after.id,
public.admin_redemption_code_snapshot(v_before),
public.admin_redemption_code_snapshot(v_after), btrim(p_request_id)
);
return query select
v_after.id, v_after.code_mask, v_after.credits, v_after.expires_at,
v_after.note, v_after.created_at, v_after.redeemed_by,
v_after.redeemed_email, v_after.redeemed_at, v_after.revoked_by,
v_after.revoked_at;
end;
$$;
create or replace function public.redeem_code(p_code_hash text)
returns table (success boolean, credits integer, error_code text)
language plpgsql
security definer
set search_path = public, pg_temp
as $$
declare
v_user_id uuid := auth.uid();
v_email text := auth.jwt() ->> 'email';
v_code public.redemption_codes%rowtype;
v_balance integer;
begin
if v_user_id is null then
return query select false, null::integer, 'unauthorized'::text;
return;
end if;
if p_code_hash is null or p_code_hash !~ '^[0-9a-f]{64}$' then
return query select false, null::integer, 'invalid_code'::text;
return;
end if;
select rc.* into v_code
from public.redemption_codes rc
where rc.code_hash = p_code_hash
for update;
if not found then
return query select false, null::integer, 'invalid_code'::text;
return;
end if;
if v_code.redeemed_by is not null then
return query select false, null::integer, 'already_redeemed'::text;
return;
end if;
if v_code.revoked_at is not null then
return query select false, null::integer, 'revoked_code'::text;
return;
end if;
if v_code.expires_at is not null and v_code.expires_at <= now() then
return query select false, null::integer, 'expired_code'::text;
return;
end if;
select p.credits into v_balance
from public.profiles p
where p.id = v_user_id
for update;
if not found then
return query select false, null::integer, 'profile_missing'::text;
return;
end if;
update public.redemption_codes rc
set redeemed_by = v_user_id, redeemed_email = v_email, redeemed_at = now()
where rc.id = v_code.id;
update public.profiles p
set credits = p.credits + v_code.credits, updated_at = now()
where p.id = v_user_id
returning p.credits into v_balance;
insert into public.credit_transactions (
user_id, transaction_type, amount, balance_after, request_id, redemption_code_id
) values (
v_user_id, 'redeem', v_code.credits, v_balance, v_code.id::text, v_code.id
);
return query select true, v_balance, null::text;
end;
$$;
revoke all on function public.admin_create_redemption_codes(uuid, text, text, text, jsonb)
from public, anon, authenticated;
revoke all on function public.admin_update_redemption_code(uuid, text, text, text, uuid, boolean, text, boolean, timestamptz)
from public, anon, authenticated;
revoke all on function public.admin_revoke_redemption_code(uuid, text, text, text, uuid)
from public, anon, authenticated;
grant execute on function public.admin_create_redemption_codes(uuid, text, text, text, jsonb) to service_role;
grant execute on function public.admin_update_redemption_code(uuid, text, text, text, uuid, boolean, text, boolean, timestamptz) to service_role;
grant execute on function public.admin_revoke_redemption_code(uuid, text, text, text, uuid) to service_role;
revoke all on function public.redeem_code(text) from public, anon;
grant execute on function public.redeem_code(text) to authenticated;
+49
View File
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import { authorizeAdminAccess } from "../src/lib/admin/auth-policy.ts";
import type { IdentityUser } from "../src/modules/identity/contracts.ts";
function user(role: string[]): IdentityUser {
return {
id: "11111111-1111-4111-8111-111111111111",
email: "admin@example.com",
emailVerified: true,
name: "Admin",
image: null,
role,
};
}
test("anonymous admin access is 401", () => {
assert.deepEqual(authorizeAdminAccess(null, "read"), {
allowed: false,
status: 401,
});
});
test("viewer may read but may not write", () => {
assert.deepEqual(authorizeAdminAccess(user(["user", "viewer"]), "read"), {
allowed: true,
role: "viewer",
});
assert.deepEqual(authorizeAdminAccess(user(["viewer"]), "write"), {
allowed: false,
status: 403,
});
});
test("admin may read and write while unprivileged users are 403", () => {
assert.deepEqual(authorizeAdminAccess(user(["admin"]), "read"), {
allowed: true,
role: "admin",
});
assert.deepEqual(authorizeAdminAccess(user(["admin"]), "write"), {
allowed: true,
role: "admin",
});
assert.deepEqual(authorizeAdminAccess(user(["user"]), "read"), {
allowed: false,
status: 403,
});
});
+76
View File
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const migration = readFileSync(
new URL("../supabase/migrations/20260727010000_refine_admin_redemption_audit.sql", import.meta.url),
"utf8",
);
const auth = readFileSync(new URL("../src/lib/admin/auth.ts", import.meta.url), "utf8");
const authPolicy = readFileSync(new URL("../src/lib/admin/auth-policy.ts", import.meta.url), "utf8");
const codesRoute = readFileSync(new URL("../src/app/api/admin/codes/route.ts", import.meta.url), "utf8");
const codeRoute = readFileSync(new URL("../src/app/api/admin/codes/[id]/route.ts", import.meta.url), "utf8");
const providers = readFileSync(new URL("../src/lib/admin/providers.ts", import.meta.url), "utf8");
const readonlyRoutes = ["users", "credit-transactions", "consultations", "audit-logs"].map((resource) =>
readFileSync(new URL(`../src/app/api/admin/${resource}/route.ts`, import.meta.url), "utf8"),
);
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
test("admin APIs use persisted Better Auth roles with admin and viewer boundaries", () => {
assert.match(auth, /requireIdentityUser/);
assert.match(authPolicy, /user\.role\.includes\("admin"\)/);
assert.match(authPolicy, /user\.role\.includes\("viewer"\)/);
assert.match(authPolicy, /access === "write" && role !== "admin"/);
assert.doesNotMatch(auth, /ADMIN_EMAILS|isAdminEmail/);
assert.match(auth, /APP_ENV\?\.trim\(\) === "production"/);
assert.match(codesRoute, /requireAdminSession\("write"\)/);
assert.match(codeRoute, /requireAdminSession\("write"\)/g);
});
test("readonly resources cannot be mutated through Refine access control", () => {
for (const resource of ["users", "credit-transactions", "consultations", "audit-logs"]) {
assert.match(providers, new RegExp(`"${resource}"`));
}
assert.match(providers, /readOnlyResources\.has/);
assert.match(providers, /此资源只读/);
for (const route of readonlyRoutes) {
assert.match(route, /export const POST = readonlyAdminMutation/);
assert.match(route, /export const PATCH = readonlyAdminMutation/);
assert.match(route, /export const DELETE = readonlyAdminMutation/);
}
});
test("redemption code writes are atomic with append-only redacted audit", () => {
assert.match(migration, /create table if not exists audit\.admin_audit_logs/);
assert.match(migration, /admin_audit_logs_append_only/);
assert.match(migration, /redemption_code\.create/);
assert.match(migration, /redemption_code\.update/);
assert.match(migration, /redemption_code\.revoke/);
assert.match(migration, /before_value is null or not \(before_value \?\| array\['code', 'code_hash', 'token', 'secret', 'key'\]\)/);
assert.match(migration, /insert into audit\.admin_audit_logs/);
assert.match(migration, /redeemed codes are immutable/);
assert.match(migration, /revoked codes are immutable/);
assert.match(migration, /v_code\.revoked_at is not null/);
assert.match(migration, /'revoked_code'/);
assert.match(migration, /set local role service_role|profiles_admin_read/);
assert.match(migration, /p_codes is null or jsonb_typeof\(p_codes\) is distinct from 'array'/);
assert.match(migration, /admin_verified_actor_email/);
});
test("plaintext code is returned only by create and never enters audit snapshots", () => {
assert.match(codesRoute, /plainCodes\.map/);
assert.match(codesRoute, /code,/);
assert.doesNotMatch(codeRoute, /codeHash|code_hash|plainCodes/);
const snapshot = migration.match(/create or replace function public\.admin_redemption_code_snapshot[\s\S]*?revoke all on function/);
assert.ok(snapshot);
assert.doesNotMatch(snapshot[0], /code_hash|'code'/);
assert.match(snapshot[0], /'mask'/);
});
test("Refine dependencies and same-origin admin data provider are present", () => {
for (const dependency of ["@refinedev/core", "@refinedev/antd", "@refinedev/nextjs-router", "antd"]) {
assert.ok(packageJson.dependencies[dependency], `${dependency} missing`);
}
assert.match(providers, /const apiBase = "\/api\/admin"/);
assert.doesNotMatch(providers, /https?:\/\//);
});
+121
View File
@@ -0,0 +1,121 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
const actorId = "11111111-1111-4111-8111-111111111111";
const codeId = "22222222-2222-4222-8222-222222222222";
function rpcArgs(requestId: string) {
return {
p_actor_user_id: actorId,
p_actor_email: "admin@example.com",
p_actor_role: "admin",
p_request_id: requestId,
};
}
test("admin code functions reject immutable codes, revoked redemption, and roll back on audit failure", async () => {
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.stderr);
assert.match(migration.stdout, /20260727010000_refine_admin_redemption_audit\.sql/);
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
insert into identity.users (id, name, email, email_verified, email_verified_at, role)
values ('${actorId}', 'Admin', 'admin@example.com', true, now(), 'admin')
`);
const userId = fixture.psql(`select id from identity.users where email = 'admin@example.com'`);
const admin = createLocalPostgresDataClient(
fixture.connectionUrl("admin_runtime", "admin-runtime-test-password"),
null,
"service_role",
);
const created = await admin.rpc("admin_create_redemption_codes", {
...rpcArgs("create-1"),
p_codes: [{
codeHash: "a".repeat(64),
codeMask: "JYOTISH-****-AUD1",
credits: 5,
expiresAt: null,
note: "initial",
}],
});
assert.equal(created.error, null);
assert.equal((created.data as Array<{ code_mask: string }>)[0]?.code_mask, "JYOTISH-****-AUD1");
assert.equal(fixture.psql("select count(*) from audit.admin_audit_logs"), "1");
assert.doesNotMatch(fixture.psql("select after_value::text from audit.admin_audit_logs"), /[a-f0-9]{64}/);
const createdId = fixture.psql("select id from public.redemption_codes where code_mask = 'JYOTISH-****-AUD1'");
const revoked = await admin.rpc("admin_revoke_redemption_code", {
...rpcArgs("revoke-1"),
p_code_id: createdId,
});
assert.equal(revoked.error, null);
const app = createLocalPostgresDataClient(
fixture.connectionUrl("app_runtime", "app-runtime-test-password"),
{ id: userId, email: "admin@example.com" },
);
const redeemRevoked = await app.rpc("redeem_code", { p_code_hash: "a".repeat(64) });
assert.deepEqual(redeemRevoked.data, [{ success: false, credits: null, error_code: "revoked_code" }]);
fixture.psql(`
insert into public.redemption_codes (id, code_hash, code_mask, credits, redeemed_by, redeemed_email, redeemed_at)
values ('${codeId}', '${"b".repeat(64)}', 'JYOTISH-****-USED', 3, '${userId}', 'admin@example.com', now())
`);
const immutable = await admin.rpc("admin_update_redemption_code", {
...rpcArgs("update-used"),
p_code_id: codeId,
p_set_note: true,
p_note: "changed",
p_set_expires_at: false,
p_expires_at: null,
});
assert.ok(immutable.error);
assert.equal(fixture.psql(`select note is null from public.redemption_codes where id = '${codeId}'`), "t");
fixture.psql(`
create or replace function audit.test_fail_admin_audit()
returns trigger language plpgsql as $$
begin
raise exception 'forced audit failure';
end;
$$;
create trigger test_fail_admin_audit
before insert on audit.admin_audit_logs
for each row execute function audit.test_fail_admin_audit()
`);
const auditFailure = await admin.rpc("admin_create_redemption_codes", {
...rpcArgs("create-audit-failure"),
p_codes: [{
codeHash: "c".repeat(64),
codeMask: "JYOTISH-****-FAIL",
credits: 7,
expiresAt: null,
note: "must rollback",
}],
});
assert.ok(auditFailure.error);
assert.equal(
fixture.psql("select count(*) from public.redemption_codes where code_mask = 'JYOTISH-****-FAIL'"),
"0",
"audit failure must leave the redemption code unchanged",
);
fixture.psql("drop trigger test_fail_admin_audit on audit.admin_audit_logs");
} finally {
fixture.stop();
}
});
@@ -228,3 +228,23 @@ test("database admin authorizer requires a current persisted admin role", async
assert.match(queries[0].sql, /from identity\.users/);
assert.deepEqual(queries[0].values, ["admin"]);
});
test("admin surface authorizer allows persisted admin and viewer roles", async () => {
const { createDatabaseAdminSurfaceAuthorizer } = await import("../src/modules/identity/auth.ts");
const rowsByUser = new Map<string, Record<string, unknown>>([
["admin", { role: "admin", banned: false, ban_expires: null }],
["viewer", { role: "user,viewer", banned: false, ban_expires: null }],
["user", { role: "user", banned: false, ban_expires: null }],
]);
const pool = {
async query(_sql: string, values: unknown[]) {
const row = rowsByUser.get(String(values[0]));
return { rows: row ? [row] : [] };
},
} as unknown as Pool;
const authorize = createDatabaseAdminSurfaceAuthorizer(pool);
assert.equal(await authorize("admin"), true);
assert.equal(await authorize("viewer"), true);
assert.equal(await authorize("user"), false);
});
+18
View File
@@ -997,3 +997,21 @@
- onboarding PostgreSQL Date 回归测试已完成红→绿:修复前 8/9,新增用例报 `TypeError: dateString.match is not a function`;边界正规化后 9/9、skipped/todo=0。实现复用 `formatBirthDate`,同时用于完整性校验和 onboarding 缓存身份,不改共享 pg parser。
- 修复验证完成:单元/路由 45/45、PostgreSQL 认证集成 1/1、staging 契约 20/20ESLint、`npm run build``git diff --check` 全绿,skipped/todo=0。onboarding 测试另在 UTC、Asia/Taipei、America/Los_Angeles 三时区各 9/9,避免 date-only 时区偏移。
- 变更边界复核仅有 `frontend/src/lib/onboarding-post.ts``frontend/tests/onboarding-route.test.ts``PROGRESS.md`package/lockfile/migration/deploy/workflow 均无 diff。
## 2026-07-27 - Refine staging 后台
- 目标:仅 staging 将 `/admin` 替换为 Refine;兑换码可审计管理,用户资料/积分流水/咨询/审计只读。
- 起点:指定 worktree 已不存在,按任务 0 回退规则新建 `/private/tmp/jyotisha-refine-admin``codex/refine-admin-staging`
- 基线:`HEAD=origin/staging=ab2944f7fad7e449ab69259323aadc5f4097773a`worktree 干净,未改 main。
- 顺序:权限/session 接缝 → 原子数据库迁移 → 5 资源 API → Refine UI → 红绿/全量验证 → staging 交付。
- 真源:Better Auth `identity.users.role`;staging 质量门/迁移/部署分别为既有三条 GitHub workflow。
- 最大风险:viewer 当前被 admin session hook 拒绝、审计与业务原子性、双数据库客户端兼容、staging 外部凭据与两角色账号。
- 建议替换说明:无;Refine data provider 将仅访问同源 `/api/admin/*`
- 完成 identity 接缝:后台 session 允许持久化 `admin`/`viewer`API 每次从 Better Auth session role 鉴权;后台范围已无 `ADMIN_EMAILS`
- 完成迁移:撤销字段、撤销兑换拒绝、append-only 脱敏审计及 create/update/revoke 原子 RPC;审计插入失败会回滚同一事务。
- 完成 5 个服务端资源:codes 可写且其余资源显式 405 只读;全部带服务端鉴权、Zod 查询校验、分页/排序/筛选。
- 完成 Refine Core + Ant Design 单 provider UI5 资源导航、表格、筛选、分页、loading/error/empty、admin 写控件与 viewer 隐藏。
- 聚焦验证:admin/identity 16/16 通过,skipped=todo=0Next build 已通过。PostgreSQL 反向测试因本机无 docker/postgres runtime 阻塞,已保留可执行测试并记 BLOCKED。
- 独立安全复核后修复:为 admin_runtime 增加列级 grants + 明确 RLS 只读 policies,避免后台列表静默为空;移除迁移内层 BEGIN/COMMIT,保持 runner+ledger 原子性;RPC 由 identity.users 校验操作者;补 revoked 成对约束与 NULL JSON 防护。
- 本地全量 `npm test`1043 tests1031 pass12 failskipped/todo=011 项因 docker ENOENT(包含本功能反向测试),1 项因缺 Playwright headless Chromium,均已写入 BLOCKED。
- 最终静态验证:ESLint 0 error3 个既有 warning)、Next build 成功、`git diff --check` 通过。