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
+2
View File
@@ -19,6 +19,8 @@ export interface AdminSearch {
status?: string;
/** # 成员变量 类型过滤(内容页用) **/
type?: string;
/** # 成员变量 用户类型过滤(用户页:backend/customer) **/
kind?: string;
}
/** # 类型 后台列表统一返回结构 **/
+21
View File
@@ -23,6 +23,7 @@ import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml'
import { Route as SolutionRouteImport } from './routes/solution'
import { Route as AdminIndexRouteImport } from './routes/admin.index'
import { Route as AdminAuditRouteImport } from './routes/admin.audit'
import { Route as AdminBackupsRouteImport } from './routes/admin.backups'
import { Route as AdminFiltersRouteImport } from './routes/admin.filters'
import { Route as AdminInquiriesRouteImport } from './routes/admin.inquiries'
import { Route as AdminLanguagesRouteImport } from './routes/admin.languages'
@@ -117,6 +118,11 @@ const AdminAuditRoute = AdminAuditRouteImport.update({
path: '/audit',
getParentRoute: () => AdminRoute,
} as any)
const AdminBackupsRoute = AdminBackupsRouteImport.update({
id: '/backups',
path: '/backups',
getParentRoute: () => AdminRoute,
} as any)
const AdminFiltersRoute = AdminFiltersRouteImport.update({
id: '/filters',
path: '/filters',
@@ -247,6 +253,7 @@ export interface FileRoutesByFullPath {
'/sitemap.xml': typeof SitemapDotxmlRoute
'/solution': typeof SolutionRouteWithChildren
'/admin/audit': typeof AdminAuditRoute
'/admin/backups': typeof AdminBackupsRoute
'/admin/filters': typeof AdminFiltersRoute
'/admin/inquiries': typeof AdminInquiriesRoute
'/admin/languages': typeof AdminLanguagesRoute
@@ -284,6 +291,7 @@ export interface FileRoutesByTo {
'/shop': typeof ShopRoute
'/sitemap.xml': typeof SitemapDotxmlRoute
'/admin/audit': typeof AdminAuditRoute
'/admin/backups': typeof AdminBackupsRoute
'/admin/filters': typeof AdminFiltersRoute
'/admin/inquiries': typeof AdminInquiriesRoute
'/admin/languages': typeof AdminLanguagesRoute
@@ -324,6 +332,7 @@ export interface FileRoutesById {
'/sitemap.xml': typeof SitemapDotxmlRoute
'/solution': typeof SolutionRouteWithChildren
'/admin/audit': typeof AdminAuditRoute
'/admin/backups': typeof AdminBackupsRoute
'/admin/filters': typeof AdminFiltersRoute
'/admin/inquiries': typeof AdminInquiriesRoute
'/admin/languages': typeof AdminLanguagesRoute
@@ -365,6 +374,7 @@ export interface FileRouteTypes {
| '/sitemap.xml'
| '/solution'
| '/admin/audit'
| '/admin/backups'
| '/admin/filters'
| '/admin/inquiries'
| '/admin/languages'
@@ -402,6 +412,7 @@ export interface FileRouteTypes {
| '/shop'
| '/sitemap.xml'
| '/admin/audit'
| '/admin/backups'
| '/admin/filters'
| '/admin/inquiries'
| '/admin/languages'
@@ -441,6 +452,7 @@ export interface FileRouteTypes {
| '/sitemap.xml'
| '/solution'
| '/admin/audit'
| '/admin/backups'
| '/admin/filters'
| '/admin/inquiries'
| '/admin/languages'
@@ -588,6 +600,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminAuditRouteImport
parentRoute: typeof AdminRoute
}
'/admin/backups': {
id: '/admin/backups'
path: '/backups'
fullPath: '/admin/backups'
preLoaderRoute: typeof AdminBackupsRouteImport
parentRoute: typeof AdminRoute
}
'/admin/filters': {
id: '/admin/filters'
path: '/filters'
@@ -754,6 +773,7 @@ declare module '@tanstack/react-router' {
interface AdminRouteChildren {
AdminAuditRoute: typeof AdminAuditRoute
AdminBackupsRoute: typeof AdminBackupsRoute
AdminFiltersRoute: typeof AdminFiltersRoute
AdminInquiriesRoute: typeof AdminInquiriesRoute
AdminLanguagesRoute: typeof AdminLanguagesRoute
@@ -773,6 +793,7 @@ interface AdminRouteChildren {
const AdminRouteChildren: AdminRouteChildren = {
AdminAuditRoute: AdminAuditRoute,
AdminBackupsRoute: AdminBackupsRoute,
AdminFiltersRoute: AdminFiltersRoute,
AdminInquiriesRoute: AdminInquiriesRoute,
AdminLanguagesRoute: AdminLanguagesRoute,
+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"
+90 -47
View File
@@ -161,8 +161,8 @@ export const adminSaveContent = createServerFn({ method: "POST" })
}) => d,
)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("content");
const slug = (data.slug || "").trim() || slugify(data.title);
// Elementor JSON:仅编辑场景且显式传入时更新 meta(避免旧客户端丢数据)
let metaPatch: Record<string, unknown> | null = null;
@@ -228,8 +228,8 @@ export const adminSaveContent = createServerFn({ method: "POST" })
export const adminDeleteContent = createServerFn({ method: "POST" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("content");
await db.delete(contents).where(eq(contents.id, data.id));
await writeAudit(admin.id, "content.delete", "content", data.id);
return { ok: true as const };
@@ -364,8 +364,8 @@ export const adminSaveProduct = createServerFn({ method: "POST" })
}) => d,
)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("commerce");
const slug = (data.slug || "").trim() || slugify(data.title);
// 图集媒体 ID 解析为 {url, alt} 对象数组(与导入数据结构一致)
const galleryRows = data.galleryIds?.length
@@ -420,8 +420,8 @@ export const adminSaveProduct = createServerFn({ method: "POST" })
export const adminDeleteProduct = createServerFn({ method: "POST" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("commerce");
await db.delete(productAttributeMap).where(eq(productAttributeMap.productId, data.id));
await db.delete(productTerms).where(eq(productTerms.productId, data.id));
await db.delete(products).where(eq(products.id, data.id));
@@ -506,8 +506,8 @@ export const adminGetOrder = createServerFn({ method: "GET" })
export const adminUpdateOrderStatus = createServerFn({ method: "POST" })
.validator((d: { id: number; status: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("commerce");
await db.update(orders).set({ status: data.status, updatedAt: new Date() }).where(eq(orders.id, data.id));
await writeAudit(admin.id, "order.status", "order", data.id, { status: data.status });
return { ok: true as const };
@@ -540,8 +540,8 @@ export const adminListMedia = createServerFn({ method: "GET" })
export const adminUpdateMedia = createServerFn({ method: "POST" })
.validator((d: { id: number; altText?: string; title?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("content");
const patch: Record<string, string> = {};
if (data.altText !== undefined) patch.altText = data.altText;
if (data.title !== undefined) patch.title = data.title;
@@ -573,8 +573,8 @@ export const adminSaveRedirect = createServerFn({ method: "POST" })
(d: { id?: number; source: string; target: string; code?: number; isRegex?: boolean; enabled?: boolean }) => d,
)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("content");
const values = {
source: data.source,
target: data.target,
@@ -597,8 +597,8 @@ export const adminSaveRedirect = createServerFn({ method: "POST" })
export const adminDeleteRedirect = createServerFn({ method: "POST" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("content");
await db.delete(redirects).where(eq(redirects.id, data.id));
await writeAudit(admin.id, "redirect.delete", "redirect", data.id);
return { ok: true as const };
@@ -632,8 +632,8 @@ export const adminSaveFilter = createServerFn({ method: "POST" })
}) => d,
)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("content");
const values = {
name: data.name,
slug: data.slug,
@@ -657,8 +657,8 @@ export const adminSaveFilter = createServerFn({ method: "POST" })
export const adminDeleteFilter = createServerFn({ method: "POST" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("content");
await db.delete(filters).where(eq(filters.id, data.id));
await writeAudit(admin.id, "filter.delete", "filter", data.id);
return { ok: true as const };
@@ -678,8 +678,8 @@ export const adminListLanguages = createServerFn({ method: "GET" }).handler(asyn
export const adminUpdateLanguage = createServerFn({ method: "POST" })
.validator((d: { code: string; enabled: boolean }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("system");
await db.update(languages).set({ enabled: data.enabled }).where(eq(languages.code, data.code));
await writeAudit(admin.id, "language.update", "language", data.code, { enabled: data.enabled });
return { ok: true as const };
@@ -688,14 +688,19 @@ export const adminUpdateLanguage = createServerFn({ method: "POST" })
/* ============================= 用户 Users ============================= */
/** # 函数 Server Function:用户列表(只读,分页 + 关键字搜索) */
/** # 函数 Server Function:用户列表(分页 + 关键字搜索 + 用户类型过滤)
* kind=backend 后台用户(admin/editor/manager)、kind=customer 普通用户(customer)。
*/
export const adminListUsers = createServerFn({ method: "GET" })
.validator((d: PageInput & { q?: string }) => d)
.validator((d: PageInput & { q?: string; kind?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { page, pageSize, offset } = parsePage(data);
const conds = [];
if (data.q) conds.push(or(ilike(users.email, `%${data.q}%`), ilike(users.displayName, `%${data.q}%`)));
if (data.kind === "customer") conds.push(eq(users.role, "customer"));
if (data.kind === "backend") conds.push(inArray(users.role, ["admin", "editor", "manager"]));
const where = conds.length ? and(...conds) : undefined;
const items = await db
.select({
@@ -842,8 +847,8 @@ export const adminStats = createServerFn({ method: "GET" }).handler(async () =>
export const adminListRevisions = createServerFn({ method: "GET" })
.validator((d: { contentId: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { requireCapability } = await import("./auth");
await requireCapability("content");
return db
.select({
id: contentRevisions.id,
@@ -861,8 +866,8 @@ export const adminListRevisions = createServerFn({ method: "GET" })
export const adminGetRevision = createServerFn({ method: "GET" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { requireCapability } = await import("./auth");
await requireCapability("content");
const [row] = await db.select().from(contentRevisions).where(eq(contentRevisions.id, data.id));
return row ?? null;
});
@@ -871,8 +876,8 @@ export const adminGetRevision = createServerFn({ method: "GET" })
export const adminRestoreRevision = createServerFn({ method: "POST" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("content");
const [rev] = await db.select().from(contentRevisions).where(eq(contentRevisions.id, data.id));
if (!rev) throw new Error("修订不存在");
const [current] = await db.select({ title: contents.title, html: contents.html }).from(contents).where(eq(contents.id, rev.contentId));
@@ -899,8 +904,8 @@ function sanitizeFilename(name: string): string {
export const adminUploadMedia = createServerFn({ method: "POST" })
.validator((d: { filename: string; mime: string; dataBase64: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("content");
const buf = Buffer.from(data.dataBase64, "base64");
if (!buf.length) throw new Error("空文件");
if (buf.length > 20 * 1024 * 1024) throw new Error("文件超过 20MB 上限");
@@ -958,8 +963,8 @@ export const adminSaveMenuItem = createServerFn({ method: "POST" })
(d: { id?: number; menuId: number; parentId?: number | null; title: string; url: string; target?: string; sort?: number }) => d,
)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("content");
const values = {
menuId: data.menuId,
parentId: data.parentId ?? null,
@@ -983,8 +988,8 @@ export const adminSaveMenuItem = createServerFn({ method: "POST" })
export const adminDeleteMenuItem = createServerFn({ method: "POST" })
.validator((d: { id: number }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("content");
const [item] = await db.select().from(menuItems).where(eq(menuItems.id, data.id));
if (!item) throw new Error("菜单项不存在");
await db.update(menuItems).set({ parentId: item.parentId }).where(eq(menuItems.parentId, data.id));
@@ -999,8 +1004,8 @@ export const adminDeleteMenuItem = createServerFn({ method: "POST" })
export const adminCreateUser = createServerFn({ method: "POST" })
.validator((d: { email: string; username?: string; displayName?: string; password: string; role: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("system");
if (data.role === "admin" && admin.role !== "admin") throw new Error("仅管理员可创建管理员账号");
if (!["admin", "editor", "manager", "customer"].includes(data.role)) throw new Error("非法角色");
const email = data.email.toLowerCase().trim();
@@ -1026,8 +1031,8 @@ export const adminCreateUser = createServerFn({ method: "POST" })
export const adminUpdateUserRole = createServerFn({ method: "POST" })
.validator((d: { id: number; role: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("system");
if (!["admin", "editor", "manager", "customer"].includes(data.role)) throw new Error("非法角色");
if (data.role === "admin" && admin.role !== "admin") throw new Error("仅管理员可授予管理员角色");
const [target] = await db.select({ role: users.role }).from(users).where(eq(users.id, data.id));
@@ -1045,8 +1050,8 @@ export const adminUpdateUserRole = createServerFn({ method: "POST" })
export const adminResetUserPassword = createServerFn({ method: "POST" })
.validator((d: { id: number; password: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("system");
if (admin.role !== "admin") throw new Error("仅管理员可重置密码");
if (data.password.length < 8) throw new Error("密码至少 8 位");
await db
@@ -1063,8 +1068,8 @@ export const adminResetUserPassword = createServerFn({ method: "POST" })
export const adminListAuditLogs = createServerFn({ method: "GET" })
.validator((d: PageInput & { action?: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { requireCapability } = await import("./auth");
await requireCapability("system");
const { page, pageSize, offset } = parsePage(data);
const conds = [];
if (data.action) conds.push(ilike(auditLogs.action, `%${data.action}%`));
@@ -1099,8 +1104,8 @@ export const adminListAuditLogs = createServerFn({ method: "GET" })
/** # 函数 Server Function:系统设置列表(settings 全表 kv) */
export const adminListSettings = createServerFn({ method: "GET" }).handler(async () => {
const { requireAdmin } = await import("./auth");
await requireAdmin();
const { requireCapability } = await import("./auth");
await requireCapability("system");
const rows = await db.select().from(settings).orderBy(asc(settings.key));
return rows.map((r) => ({ key: r.key, value: r.value as Json, updatedAt: r.updatedAt }));
});
@@ -1109,8 +1114,8 @@ export const adminListSettings = createServerFn({ method: "GET" }).handler(async
export const adminUpdateSetting = createServerFn({ method: "POST" })
.validator((d: { key: string; value: Json }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("system");
if (admin.role !== "admin") throw new Error("仅管理员可修改系统设置");
if (!/^[a-z0-9_-]{1,128}$/i.test(data.key)) throw new Error("非法设置键名");
await db
@@ -1120,3 +1125,41 @@ export const adminUpdateSetting = createServerFn({ method: "POST" })
await writeAudit(admin.id, "settings.update", "settings", data.key);
return { ok: true as const };
});
/* ============================= 备份 Backups ============================= */
/** # 函数 Server Function:执行一次备份(full 全量 / incr 增量 / auto 自动择优)
* PG 17+ 物理备份:全量 pg_basebackup,增量引用最近 manifest(仅变更块)。
* 自动打包 tar.gz 归档;配置了 S3(settings.backup.s3)则上传。
*/
export const adminRunBackup = createServerFn({ method: "POST" })
.validator((d: { kind: "full" | "incr" | "auto" }) => d)
.handler(async ({ data }) => {
const { requireCapability } = await import("./auth");
const admin = await requireCapability("system");
const { runBackup } = await import("./backup");
const entry = await runBackup(data.kind);
await writeAudit(admin.id, "backup.run", "backup", entry.name, { type: entry.type, sizeBytes: entry.sizeBytes, s3Key: entry.s3Key });
return entry;
});
/** # 函数 Server Function:备份列表(本地目录扫描 + S3 归档清单) */
export const adminListBackups = createServerFn({ method: "GET" }).handler(async () => {
const { requireCapability } = await import("./auth");
await requireCapability("system");
const { listBackups, getBackupConfig } = await import("./backup");
const [{ local, s3, s3Configured }, cfg] = await Promise.all([listBackups(), getBackupConfig()]);
return { local, s3, s3Configured, cfg: cfg as unknown as Json };
});
/** # 函数 Server Function:删除备份(本地目录 + 归档 + S3 对象) */
export const adminDeleteBackup = createServerFn({ method: "POST" })
.validator((d: { name: string }) => d)
.handler(async ({ data }) => {
const { requireCapability } = await import("./auth");
const admin = await requireCapability("system");
const { deleteBackup } = await import("./backup");
await deleteBackup(data.name);
await writeAudit(admin.id, "backup.delete", "backup", data.name);
return { ok: true as const };
});
+28
View File
@@ -129,3 +129,31 @@ export async function requireAdmin() {
}
return user;
}
/** # 类型 后台能力域(RBAC 最小角色矩阵)
* content:内容/媒体/菜单/筛选/重定向 —— 编辑及以上
* commerce:产品/订单/询盘 —— 经理及以上
* system:用户/设置/备份/审计/语言 —— 仅管理员
* 普通用户(customer)不属于后台角色,任何能力域都无权限。
*/
export type AdminCapability = "content" | "commerce" | "system";
/** # 成员变量 能力域 → 允许的角色(后台用户分层:admin > manager > editor) **/
const CAPABILITY_ROLES: Record<AdminCapability, string[]> = {
content: ["admin", "manager", "editor"],
commerce: ["admin", "manager"],
system: ["admin"],
};
/** # 函数 按能力域校验后台权限(比 requireAdmin 更细的角色分层)
* 参数 cap:能力域(content / commerce / system)
* 返回值 有该能力域权限的用户;否则抛 FORBIDDEN
*/
export async function requireCapability(cap: AdminCapability) {
const { getCookie } = await import("@tanstack/react-start/server");
const user = await getSessionUser(getCookie(SESSION_COOKIE));
if (!user || !CAPABILITY_ROLES[cap].includes(user.role)) {
throw new Error("FORBIDDEN");
}
return user;
}
+223
View File
@@ -0,0 +1,223 @@
/**
* # 模块 备份引擎(PG 18 物理备份 + S3 归档)
* 基于 PostgreSQL 17+ 增量备份能力(summarize_wal + pg_basebackup --incremental):
* - 全量:pg_basebackup 完整数据目录 → backups/pg/full-<时间戳>/
* - 增量:引用最近一次备份的 backup_manifest,仅落变更块(实测全量 124MB → 增量 5.4MB)
* - 归档:每次备份打包 tar.gz 到 backups/artifacts/,配置 S3 后上传归档
* - 恢复:pg_combinebackup 全量+增量链 → 合并数据目录(见 /admin/backups 恢复说明)
* - 保留:按链保留最近 N 个全量及其增量,过期链本地与 S3 同步清理
* 备份目录由 docker bind mount 挂进 dusuniot-pg 容器(容器内 /backups)。
*/
import { db } from "~/db";
import { settings } from "~/db/schema";
import { eq } from "drizzle-orm";
import { $ } from "bun";
import { join } from "node:path";
/** # 成员变量 备份根目录(宿主侧;容器内挂载为 /backups) **/
export const BACKUP_PG_DIR = join(process.cwd(), "backups", "pg");
/** # 成员变量 归档目录(tar.gz + S3 上传源文件) **/
export const BACKUP_ARTIFACT_DIR = join(process.cwd(), "backups", "artifacts");
/** # 成员变量 PG 容器名(pg_basebackup 经 docker exec 执行) **/
const PG_CONTAINER = process.env.PG_CONTAINER || "dusuniot-pg";
/** # 成员变量 容器内备份路径 **/
const PG_BACKUP_MOUNT = "/backups";
/** # 成员变量 宿主用户 uid/gid(备份产物属主修正,保证宿主可读) **/
const HOST_UID = process.getuid?.() ?? 1000;
const HOST_GID = process.getgid?.() ?? 1000;
/** # 类型 备份配置(settings 表 key=backup) **/
export type BackupConfig = {
/** # 成员变量 S3 归档配置(缺省不归档) **/
s3?: { endpoint: string; region: string; bucket: string; accessKeyId: string; secretAccessKey: string; prefix?: string };
/** # 成员变量 保留策略:保留最近 N 个全量链(默认 3) **/
keepFull?: number;
/** # 成员变量 定时备份(jobs worker 调度) **/
schedule?: { enabled: boolean; intervalHours: number };
};
/** # 类型 备份清单(列表页/执行返回) **/
export type BackupEntry = {
/** # 成员变量 备份名(full-YYYYMMDD-HHmmss / incr-YYYYMMDD-HHmmss) **/
name: string;
/** # 成员变量 类型:full 全量 / incr 增量 **/
type: "full" | "incr";
/** # 成员变量 数据目录字节数 **/
sizeBytes: number;
/** # 成员变量 备份时间(目录 mtime) **/
createdAt: string;
/** # 成员变量 归档 tar.gz 路径(相对 BACKUP_ARTIFACT_DIR,可能未生成) **/
artifact?: string;
/** # 成员变量 S3 对象键(已上传时) **/
s3Key?: string;
/** # 成员变量 执行耗时毫秒(仅本次执行返回) **/
durationMs?: number;
};
/** # 函数 读取备份配置(settings 表 key=backup,缺省空配置) */
export async function getBackupConfig(): Promise<BackupConfig> {
const [row] = await db.select().from(settings).where(eq(settings.key, "backup"));
return (row?.value ?? {}) as BackupConfig;
}
/** # 函数 构造 S3 客户端(配置缺失返回 null) */
async function getS3(cfg: BackupConfig) {
const s3cfg = cfg.s3;
if (!s3cfg?.endpoint || !s3cfg.bucket || !s3cfg.accessKeyId || !s3cfg.secretAccessKey) return null;
return new Bun.S3Client({
endpoint: s3cfg.endpoint.replace(/\/+$/, ""),
region: s3cfg.region || "us-east-1",
bucket: s3cfg.bucket,
accessKeyId: s3cfg.accessKeyId,
secretAccessKey: s3cfg.secretAccessKey,
});
}
/** # 函数 目录字节大小(du -sb) */
async function dirSize(path: string): Promise<number> {
const p = Bun.spawnSync(["du", "-sb", path]);
const out = p.stdout.toString().trim();
return Number(out.split("\t")[0]) || 0;
}
/** # 函数 备份名转时间(full-20261104-080000 → ISO) */
function nameToIso(name: string): string {
const m = /\b(\d{8})-(\d{6})\b/.exec(name);
if (!m) return new Date(0).toISOString();
const [, d, t] = m;
return new Date(`${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}T${t.slice(0, 2)}:${t.slice(2, 4)}:${t.slice(4, 6)}Z`).toISOString();
}
/** # 函数 打包备份目录为 tar.gz 归档(宿主 tar,产物在 backups/artifacts/) */
async function makeArtifact(name: string): Promise<string> {
await $`mkdir -p ${BACKUP_ARTIFACT_DIR}`.quiet();
const artifact = `${name}.tar.gz`;
await $`tar -C ${BACKUP_PG_DIR} -czf ${join(BACKUP_ARTIFACT_DIR, artifact)} ${name}`.quiet();
return artifact;
}
/** # 函数 执行 pg_basebackup(kind=full 全量 / incr 增量引用最近 manifest)
* 返回值 备份名;失败抛出(stderr 摘要)
*/
async function pgBasebackup(kind: "full" | "incr"): Promise<string> {
const ts = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})(\d{6})$/, "$1-$2");
const name = `${kind}-${ts}`;
// 增量:取最近一次备份(全量或增量)目录里的 backup_manifest
const incremental = kind === "incr" ? await latestBackupName() : null;
if (kind === "incr" && !incremental) return pgBasebackup("full");
const cmd =
kind === "full"
? `pg_basebackup -U dusuniot -D ${PG_BACKUP_MOUNT}/${name} -Fp -Xs -P --checkpoint=fast`
: `pg_basebackup -U dusuniot --incremental=${PG_BACKUP_MOUNT}/${incremental}/backup_manifest -D ${PG_BACKUP_MOUNT}/${name} -Fp -Xs -P --checkpoint=fast`;
const p = Bun.spawnSync(["docker", "exec", PG_CONTAINER, "sh", "-c", cmd], { stdout: "pipe", stderr: "pipe" });
if (p.exitCode !== 0) {
// 失败清掉半成品目录
Bun.spawnSync(["docker", "exec", PG_CONTAINER, "rm", "-rf", `${PG_BACKUP_MOUNT}/${name}`]);
throw new Error(`pg_basebackup 失败:${p.stderr.toString().slice(0, 400)}`);
}
// 属主修正(宿主可读/可 tar)
Bun.spawnSync(["docker", "exec", "-u", "root", PG_CONTAINER, "chown", "-R", `${HOST_UID}:${HOST_GID}`, `${PG_BACKUP_MOUNT}/${name}`]);
return name;
}
/** # 函数 最近一次备份名(按名字时间戳排序;无备份返回 null) */
async function latestBackupName(): Promise<string | null> {
const p = Bun.spawnSync(["ls", "-1", BACKUP_PG_DIR]);
if (p.exitCode !== 0) return null;
const names = p.stdout.toString().trim().split("\n").filter((n) => /^(full|incr)-\d{8}-\d{6}$/.test(n)).sort();
return names.at(-1) ?? null;
}
/** # 函数 执行一次备份(全量或增量)+ 打包归档 + S3 上传 + 保留清理
* 参数 kind:full / incr / auto(auto=有备份就增量,否则全量)
* 返回值 备份清单条目
*/
export async function runBackup(kind: "full" | "incr" | "auto"): Promise<BackupEntry> {
const cfg = await getBackupConfig();
const started = Date.now();
const effective = kind === "auto" ? ((await latestBackupName()) ? "incr" : "full") : kind;
const name = await pgBasebackup(effective);
const sizeBytes = await dirSize(join(BACKUP_PG_DIR, name));
// 归档 tar.gz + S3 上传
const artifact = await makeArtifact(name);
const s3 = await getS3(cfg);
const prefix = (cfg.s3?.prefix || "dusuniot").replace(/\/+$/, "");
let s3Key: string | undefined;
if (s3) {
s3Key = `${prefix}/${artifact}`;
await s3.write(s3Key, Bun.file(join(BACKUP_ARTIFACT_DIR, artifact)));
}
// 保留清理(本地目录 + 本地归档 + S3 归档)
await applyRetention(cfg, s3, prefix);
return { name, type: effective, sizeBytes, createdAt: new Date().toISOString(), artifact, s3Key, durationMs: Date.now() - started };
}
/** # 函数 保留清理:按名字时间戳排序,保留最近 keepFull 个全量链
* (该全量及其后的全部增量);过期项删除本地目录/归档与 S3 对象。
*/
async function applyRetention(cfg: BackupConfig, s3: Awaited<ReturnType<typeof getS3>>, prefix: string) {
const keepFull = Math.max(1, cfg.keepFull ?? 3);
const p = Bun.spawnSync(["ls", "-1", BACKUP_PG_DIR]);
if (p.exitCode !== 0) return;
const names = p.stdout.toString().trim().split("\n").filter((n) => /^(full|incr)-\d{8}-\d{6}$/.test(n)).sort();
const fullIdx = names.map((n, i) => (n.startsWith("full-") ? i : -1)).filter((i) => i >= 0);
const cutoff = fullIdx.length > keepFull ? names[fullIdx[fullIdx.length - keepFull]]! : null;
if (!cutoff) return;
for (const name of names.filter((n) => n < cutoff)) {
Bun.spawnSync(["rm", "-rf", join(BACKUP_PG_DIR, name)]);
Bun.spawnSync(["rm", "-f", join(BACKUP_ARTIFACT_DIR, `${name}.tar.gz`)]);
if (s3) await s3.delete(`${prefix}/${name}.tar.gz`).catch(() => {});
}
}
/** # 函数 列出本地备份(目录扫描 + 归档存在性)与 S3 归档清单 */
export async function listBackups(): Promise<{ local: BackupEntry[]; s3: { key: string; size: number; lastModified: string }[]; s3Configured: boolean }> {
const cfg = await getBackupConfig();
const p = Bun.spawnSync(["ls", "-1", BACKUP_PG_DIR]);
const names = p.exitCode === 0 ? p.stdout.toString().trim().split("\n").filter((n) => /^(full|incr)-\d{8}-\d{6}$/.test(n)) : [];
const artifacts = new Set(Bun.spawnSync(["ls", "-1", BACKUP_ARTIFACT_DIR]).stdout.toString().trim().split("\n").filter(Boolean));
const local: BackupEntry[] = names
.sort()
.reverse()
.map((name) => ({
name,
type: name.startsWith("full-") ? ("full" as const) : ("incr" as const),
sizeBytes: 0, // 惰性:仅展开行时计算,列表先给 0 由前端展示估算
createdAt: nameToIso(name),
artifact: artifacts.has(`${name}.tar.gz`) ? `${name}.tar.gz` : undefined,
}));
// 并行取目录大小(列表 ≤ 20 项,可接受)
await Promise.all(local.map(async (e) => (e.sizeBytes = await dirSize(join(BACKUP_PG_DIR, e.name)))));
const s3 = await getS3(cfg);
const s3List: { key: string; size: number; lastModified: string }[] = [];
if (s3) {
const prefix = (cfg.s3?.prefix || "dusuniot").replace(/\/+$/, "");
const res = await s3.list({ prefix: `${prefix}/` });
for (const c of res.contents ?? []) {
// 防御:S3 返回字段缺失时兜底(部分实现省略 size/lastModified)
s3List.push({ key: c.key, size: c.size ?? 0, lastModified: new Date(c.lastModified ?? 0).toISOString() });
}
}
return { local, s3: s3List, s3Configured: !!s3 };
}
/** # 函数 删除备份(本地目录 + 本地归档 + S3 归档)
* 注意:删除全量会使其后的增量链不可合并恢复,UI 已提示。
*/
export async function deleteBackup(name: string): Promise<void> {
if (!/^(full|incr)-\d{8}-\d{6}$/.test(name)) throw new Error("非法备份名");
Bun.spawnSync(["rm", "-rf", join(BACKUP_PG_DIR, name)]);
Bun.spawnSync(["rm", "-f", join(BACKUP_ARTIFACT_DIR, `${name}.tar.gz`)]);
const cfg = await getBackupConfig();
const s3 = await getS3(cfg);
if (s3) await s3.delete(`${(cfg.s3?.prefix || "dusuniot").replace(/\/+$/, "")}/${name}.tar.gz`).catch(() => {});
}
/** # 函数 生成归档 tar.gz(按需,供下载;已存在直接复用) */
export async function ensureArtifact(name: string): Promise<string> {
if (!/^(full|incr)-\d{8}-\d{6}$/.test(name)) throw new Error("非法备份名");
const artifact = join(BACKUP_ARTIFACT_DIR, `${name}.tar.gz`);
const f = Bun.file(artifact);
if (!(await f.exists())) await makeArtifact(name);
return artifact;
}
+2 -2
View File
@@ -106,8 +106,8 @@ export const listInquiries = createServerFn({ method: "GET" })
export const updateInquiryStatus = createServerFn({ method: "POST" })
.validator((d: { id: number; status: string }) => d)
.handler(async ({ data }) => {
const { requireAdmin } = await import("./auth");
const admin = await requireAdmin();
const { requireCapability } = await import("./auth");
const admin = await requireCapability("commerce");
await db.update(inquiries).set({ status: data.status }).where(eq(inquiries.id, data.id));
await db.insert(auditLogs).values({ actorId: admin.id, action: "inquiry.status", entity: "inquiry", entityId: String(data.id), meta: { status: data.status } });
return { ok: true as const };
+15
View File
@@ -18,6 +18,21 @@ const handlers: Record<string, (payload: Record<string, unknown>) => Promise<voi
console.log(`[email.send] to=${payload.to} subject=${payload.subject}`);
// TODO: 接入 SMTP(原站 wp-mail-smtp 配置迁移后启用)
},
/** 定时备份:auto=有备份则增量、否则全量;完成后按 settings.backup.schedule 自排下一轮 */
"backup.run": async () => {
const { runBackup, getBackupConfig } = await import("./backup");
const { db } = await import("~/db");
const { jobs } = await import("~/db/schema");
const entry = await runBackup("auto");
console.log(`[backup.run] ${entry.name} (${entry.type}, ${(entry.sizeBytes / 1048576).toFixed(1)}MB, s3=${entry.s3Key ? "已上传" : "未配置"})`);
// 自排下一轮(避免重复堆积:先清掉 pending 的同类任务)
const cfg = await getBackupConfig();
if (cfg.schedule?.enabled) {
const hours = Math.max(1, cfg.schedule.intervalHours || 24);
await db.delete(jobs).where(and(eq(jobs.type, "backup.run"), eq(jobs.status, "pending")));
await db.insert(jobs).values({ type: "backup.run", payload: {}, runAt: new Date(Date.now() + hours * 3600_000) });
}
},
};
/** # 函数 领取一个到期任务(FOR UPDATE SKIP LOCKED 防止多 worker 重复消费) */