v0.12.0 PG 16→18.6 + 备份系统(pg_basebackup 全量/增量+S3 归档+保留链)+ RBAC 用户分层(content/commerce/system)
This commit is contained in:
@@ -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/<全量> /backups/<增量1> /backups/<增量2>`
|
||||
→ 停库后把合并目录作为 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>
|
||||
);
|
||||
}
|
||||
@@ -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: "审计日志" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user