feat(admin): add audited Refine staging console
This commit is contained in:
@@ -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 和公开 health,admin/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;
|
||||
Generated
+2358
-1
File diff suppressed because it is too large
Load Diff
@@ -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: "仅保存脱敏前后值;日志只追加" }]} />}
|
||||
/>;
|
||||
}
|
||||
@@ -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" },
|
||||
]}
|
||||
/>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect("/admin/codes");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)) : "—";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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?:\/\//);
|
||||
});
|
||||
@@ -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
@@ -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/20;ESLint、`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 UI:5 资源导航、表格、筛选、分页、loading/error/empty、admin 写控件与 viewer 隐藏。
|
||||
- 聚焦验证:admin/identity 16/16 通过,skipped=todo=0;Next build 已通过。PostgreSQL 反向测试因本机无 docker/postgres runtime 阻塞,已保留可执行测试并记 BLOCKED。
|
||||
- 独立安全复核后修复:为 admin_runtime 增加列级 grants + 明确 RLS 只读 policies,避免后台列表静默为空;移除迁移内层 BEGIN/COMMIT,保持 runner+ledger 原子性;RPC 由 identity.users 校验操作者;补 revoked 成对约束与 NULL JSON 防护。
|
||||
- 本地全量 `npm test`:1043 tests,1031 pass,12 fail,skipped/todo=0;11 项因 docker ENOENT(包含本功能反向测试),1 项因缺 Playwright headless Chromium,均已写入 BLOCKED。
|
||||
- 最终静态验证:ESLint 0 error(3 个既有 warning)、Next build 成功、`git diff --check` 通过。
|
||||
|
||||
Reference in New Issue
Block a user