v0.12.0 PG 16→18.6 + 备份系统(pg_basebackup 全量/增量+S3 归档+保留链)+ RBAC 用户分层(content/commerce/system)

This commit is contained in:
bigcode
2026-11-04 08:29:05 +08:00
parent b16c33c08b
commit 4fba80a216
14 changed files with 794 additions and 52 deletions
+246
View File
@@ -0,0 +1,246 @@
/**
* # 路由 /admin/backups 备份管理(仅管理员)
* PG 18 物理备份:全量 pg_basebackup / 增量 --incremental(引用最近 manifest)。
* S3 归档配置(settings key=backup)、立即备份、备份列表(本地+S3)、删除、恢复说明。
*/
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { App, Alert, Button, Card, Form, Input, InputNumber, Space, Switch, Table, Tag, Typography } from "antd";
import { CloudUpload, DatabaseBackup, Delete, RefreshCw, Zap } from "lucide-react";
import { useEffect, useState } from "react";
import { adminDeleteBackup, adminListBackups, adminRunBackup, adminUpdateSetting } from "~/server/admin";
import { Forbidden, PageHeader, guardAdmin, fmtDateTime, type Json } from "~/components/admin/common";
export const Route = createFileRoute("/admin/backups")({
loader: () => guardAdmin(() => adminListBackups()),
component: BackupsPage,
});
/** # 类型 loader 数据 **/
type BackupsData = {
local: { name: string; type: "full" | "incr"; sizeBytes: number; createdAt: string; artifact?: string }[];
s3: { key: string; size: number; lastModified: string }[];
s3Configured: boolean;
cfg: Json;
};
/** # 类型 settings.backup 配置形状(UI 表单回显用) **/
type BackupCfgShape = {
s3?: { endpoint?: string; region?: string; bucket?: string; accessKeyId?: string; secretAccessKey?: string; prefix?: string };
keepFull?: number;
schedule?: { enabled?: boolean; intervalHours?: number };
};
/** # 函数 字节格式化 */
function fmtSize(n: number): string {
if (n >= 1024 * 1024 * 1024) return `${(n / 1024 ** 3).toFixed(2)} GB`;
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${Math.max(1, Math.round(n / 1024))} KB`;
}
/** # 函数 备份管理页面组件 */
function BackupsPage() {
const data = Route.useLoaderData() as BackupsData | null;
const router = useRouter();
const { message, modal } = App.useApp();
const [busy, setBusy] = useState(false);
/** # 成员变量 S3 配置表单初值(从 settings.backup 展开) **/
const [form] = Form.useForm();
useEffect(() => {
if (!data) return;
const cfg = (data.cfg ?? {}) as BackupCfgShape;
const s3 = cfg.s3 ?? {};
form.setFieldsValue({
endpoint: s3.endpoint,
region: s3.region ?? "us-east-1",
bucket: s3.bucket,
accessKeyId: s3.accessKeyId,
secretAccessKey: s3.secretAccessKey,
prefix: s3.prefix ?? "dusuniot",
keepFull: cfg.keepFull ?? 3,
scheduleEnabled: cfg.schedule?.enabled ?? false,
intervalHours: cfg.schedule?.intervalHours ?? 24,
});
}, [data, form]);
if (!data) return <Forbidden />;
// 收窄后的非空引用(function 声明被提升、不保留 narrowing)
const d = data;
/** # 函数 执行备份(full/incr),期间禁用按钮 */
async function run(kind: "full" | "incr") {
setBusy(true);
try {
const entry = await adminRunBackup({ data: { kind } });
message.success(`备份完成:${entry.name}(${fmtSize(entry.sizeBytes)}${entry.s3Key ? ",已上传 S3" : ""})`);
await router.invalidate();
} catch (e) {
message.error(e instanceof Error ? e.message : "备份失败");
} finally {
setBusy(false);
}
}
/** # 函数 删除备份(全量删除会破坏其后的增量链,弹窗确认) */
function confirmDelete(name: string, type: string) {
modal.confirm({
title: `删除备份 ${name}?`,
content:
type === "full"
? "该全量之后的所有增量将无法合并恢复,请确认不再需要这条备份链。"
: "仅删除该增量备份,不影响全量与其他增量。",
okText: "删除",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: async () => {
await adminDeleteBackup({ data: { name } });
message.success("已删除");
await router.invalidate();
},
});
}
/** # 函数 保存 S3 / 保留 / 定时配置(settings key=backup) */
async function saveConfig(values: { endpoint?: string; region?: string; bucket?: string; accessKeyId?: string; secretAccessKey?: string; prefix?: string; keepFull: number; scheduleEnabled: boolean; intervalHours: number }) {
// 保留已存在的非 S3 字段(避免覆盖 backup 键里其他内容)
const merged = { ...((d.cfg ?? {}) as object) } as Record<string, unknown>;
merged.s3 = values.endpoint
? {
endpoint: values.endpoint,
region: values.region || "us-east-1",
bucket: values.bucket,
accessKeyId: values.accessKeyId,
secretAccessKey: values.secretAccessKey,
prefix: values.prefix || "dusuniot",
}
: undefined;
merged.keepFull = values.keepFull;
merged.schedule = { enabled: values.scheduleEnabled, intervalHours: values.intervalHours };
if (!values.endpoint) delete merged.s3;
try {
await adminUpdateSetting({ data: { key: "backup", value: merged as Json } });
message.success("备份配置已保存");
await router.invalidate();
} catch (e) {
message.error(e instanceof Error ? e.message : "保存失败");
}
}
return (
<div>
<Card size="small">
<PageHeader
title="备份管理(PostgreSQL 18 物理备份)"
extra={
<Space>
<Button icon={<RefreshCw size={14} aria-hidden />} onClick={() => void router.invalidate()}>
刷新
</Button>
<Button loading={busy} icon={<Zap size={14} aria-hidden />} onClick={() => void run("incr")} disabled={!d.local.length}>
增量备份
</Button>
<Button loading={busy} type="primary" icon={<DatabaseBackup size={14} aria-hidden />} onClick={() => void run("full")}>
全量备份
</Button>
</Space>
}
/>
<Alert
type="info"
showIcon
message="增量备份基于 PG 17+ summarize_wal(当前 18.6 已开启):只落全量以来的变更块;恢复用 pg_combinebackup 合并「全量 + 其后全部增量」"
/>
<Table
style={{ marginTop: 12 }}
rowKey="name"
size="small"
dataSource={data.local}
pagination={false}
locale={{ emptyText: "暂无备份(建议先做一次全量)" }}
columns={[
{ title: "备份名", dataIndex: "name", width: 220, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
{ title: "类型", dataIndex: "type", width: 90, render: (v: string) => <Tag color={v === "full" ? "blue" : "cyan"}>{v === "full" ? "全量" : "增量"}</Tag> },
{ title: "数据目录", dataIndex: "sizeBytes", width: 110, render: (v: number) => fmtSize(v) },
{ title: "归档", dataIndex: "artifact", width: 90, render: (v?: string) => (v ? <Tag color="green">已打包</Tag> : <Tag>未打包</Tag>) },
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
{
title: "操作",
key: "act",
width: 90,
render: (_, r) => (
<Button size="small" danger type="text" icon={<Delete size={14} aria-label="删除" />} onClick={() => confirmDelete(r.name, r.type)} />
),
},
]}
/>
<Typography.Paragraph type="secondary" style={{ marginTop: 12, fontSize: 12, marginBottom: 0 }}>
恢复步骤(示例):`docker exec -u postgres dusuniot-pg pg_combinebackup -o /tmp/restore /backups/&lt;全量&gt; /backups/&lt;增量1&gt; /backups/&lt;增量2&gt;`
→ 停库后把合并目录作为 PGDATA 启动。S3 归档为 tar.gz,解包后同法操作。
</Typography.Paragraph>
</Card>
<Card size="small" style={{ marginTop: 16 }} title={
<Space><CloudUpload size={16} aria-hidden /> S3 归档与保留策略 {data.s3Configured ? <Tag color="green">S3 已配置</Tag> : <Tag>未配置(仅本地)</Tag>}</Space>
}>
<Form form={form} layout="vertical" style={{ maxWidth: 860 }} onFinish={(v) => void saveConfig(v as Parameters<typeof saveConfig>[0])}>
<Space size={16} style={{ display: "flex" }} align="start">
<Form.Item name="endpoint" label="S3 Endpoint(S3 兼容,如 https://s3.endpoint)" style={{ minWidth: 300, flex: 1 }}>
<Input placeholder="https://s3.us-east-1.amazonaws.com 或自建端点" />
</Form.Item>
<Form.Item name="bucket" label="Bucket" style={{ width: 200 }}>
<Input placeholder="dusuniot-backup" />
</Form.Item>
<Form.Item name="region" label="Region" style={{ width: 140 }}>
<Input placeholder="us-east-1" />
</Form.Item>
</Space>
<Space size={16} style={{ display: "flex" }} align="start">
<Form.Item name="accessKeyId" label="AccessKeyId" style={{ width: 260 }}>
<Input autoComplete="off" />
</Form.Item>
<Form.Item name="secretAccessKey" label="SecretAccessKey" style={{ width: 260 }}>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item name="prefix" label="对象前缀" style={{ width: 160 }}>
<Input placeholder="dusuniot" />
</Form.Item>
</Space>
<Space size={24} style={{ display: "flex" }} align="start">
<Form.Item name="keepFull" label="保留全量链数" initialValue={3}>
<InputNumber min={1} max={30} />
</Form.Item>
<Form.Item name="scheduleEnabled" label="定时备份" valuePropName="checked" initialValue={false}>
<Switch />
</Form.Item>
<Form.Item name="intervalHours" label="间隔(小时)" initialValue={24}>
<InputNumber min={1} max={168} />
</Form.Item>
<Form.Item label=" ">
<Button type="primary" htmlType="submit" icon={<CloudUpload size={14} aria-hidden />}>
保存配置
</Button>
</Form.Item>
</Space>
</Form>
{data.s3.length ? (
<Table
rowKey="key"
size="small"
style={{ marginTop: 8 }}
dataSource={data.s3}
pagination={false}
locale={{ emptyText: "S3 暂无归档对象" }}
columns={[
{ title: "S3 对象", dataIndex: "key", ellipsis: true, render: (v: string) => <code style={{ fontSize: 12 }}>{v}</code> },
{ title: "大小", dataIndex: "size", width: 110, render: (v: number) => fmtSize(v) },
{ title: "上传时间", dataIndex: "lastModified", width: 170, render: (v: string) => fmtDateTime(v) },
]}
/>
) : null}
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8 }}>
定时备份由 jobs worker(`bun scripts/jobs-worker.ts`)消费 `backup.run` 任务执行:自动选择增量(无备份时全量),完成后按间隔自排下一轮。
</Typography.Paragraph>
</Card>
</div>
);
}
+2
View File
@@ -7,6 +7,7 @@ import { createFileRoute, Outlet, useNavigate, useRouter, useRouterState } from
import { App, Avatar, Button, Card, ConfigProvider, Form, Input, Layout, Menu, Space, Tag } from "antd";
import { LockKeyhole } from "lucide-react";
import {
DatabaseBackup,
Files,
Inbox,
Image as ImageIcon,
@@ -56,6 +57,7 @@ const MENU_GROUPS: { label: string; children: { key: string; icon: ReactNode; la
{ key: "/admin/seo/", icon: <RouteIcon size={16} aria-hidden />, label: "SEO / 重定向" },
{ key: "/admin/users/", icon: <UserRound size={16} aria-hidden />, label: "用户" },
{ key: "/admin/settings/", icon: <Settings size={16} aria-hidden />, label: "系统设置" },
{ key: "/admin/backups/", icon: <DatabaseBackup size={16} aria-hidden />, label: "备份" },
{ key: "/admin/audit/", icon: <ScrollText size={16} aria-hidden />, label: "审计日志" },
],
},
+17 -2
View File
@@ -4,7 +4,7 @@
* 服务端有防锁死保护:最后一个 admin 不可降级。
*/
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { App, Button, Card, Form, Input, Modal, Select, Space, Table, Tag } from "antd";
import { App, Button, Card, Form, Input, Modal, Select, Space, Table, Tabs, Tag } from "antd";
import { KeyRound, Search, UserPlus } from "lucide-react";
import { getCurrentUser } from "~/server/auth";
import { useState } from "react";
@@ -17,12 +17,17 @@ export const Route = createFileRoute("/admin/users")({
const page = Math.floor(Number(search.page)) || 0;
if (page > 1) out.page = page;
if (typeof search.q === "string" && search.q) out.q = search.q;
// 用户类型 Tab:backend 后台用户 / customer 普通用户
if (search.kind === "backend" || search.kind === "customer") out.kind = search.kind;
return out;
},
loaderDeps: ({ search }) => ({ search }),
loader: ({ deps }) =>
guardAdmin(async () => {
const [user, me] = await Promise.all([adminListUsers({ data: { page: deps.search.page, q: deps.search.q || undefined } }), getCurrentUser()]);
const [user, me] = await Promise.all([
adminListUsers({ data: { page: deps.search.page, q: deps.search.q || undefined, kind: deps.search.kind } }),
getCurrentUser(),
]);
return { users: user, me };
}),
component: UsersPage,
@@ -114,6 +119,16 @@ function UsersPage() {
</Space>
}
/>
{/* 用户类型分层:后台用户(admin/editor/manager)可登录后台;普通用户(customer)仅商城账号 */}
<Tabs
activeKey={search.kind ?? ""}
onChange={(key) => void navigate({ search: (prev) => ({ ...prev, kind: key || undefined, page: 1 }) })}
items={[
{ key: "", label: "全部" },
{ key: "backend", label: "后台用户" },
{ key: "customer", label: "普通用户" },
]}
/>
<Table
rowKey="id"
size="small"