v0.12.0 PG 16→18.6 + 备份系统(pg_basebackup 全量/增量+S3 归档+保留链)+ RBAC 用户分层(content/commerce/system)
This commit is contained in:
@@ -11,3 +11,6 @@ visual-audit/
|
||||
# 原站镜像与原站媒体(取证/运行资产,体积 7G+,不同步 git;部署时另行同步 /public/uploads/)
|
||||
/legacy/
|
||||
/public/uploads/
|
||||
|
||||
# 备份数据(物理备份/归档/迁移转储)不入库
|
||||
backups/
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
# 后台(Admin / CRM)与 MCP 说明
|
||||
|
||||
> v0.12.0 起:PG 16 → 18.6(增量备份能力)、备份系统(全量/增量/S3)、用户权限分层。
|
||||
> v0.11.0 起:后台按「功能接近原版 WordPress + 现代化 CRM 界面」重做一轮,并新增站点管理 MCP 服务器。
|
||||
|
||||
## 用户权限分层(RBAC)
|
||||
|
||||
两类用户:**普通用户**(customer,仅商城账号)与**后台用户**(admin > manager > editor)。能力矩阵(`src/server/auth.ts` requireCapability):
|
||||
|
||||
| 能力域 | 允许角色 | 覆盖操作 |
|
||||
| --- | --- | --- |
|
||||
| content | admin/manager/editor | 内容保存/删除、修订恢复、媒体上传/编辑、菜单项、筛选、重定向 |
|
||||
| commerce | admin/manager | 产品保存/删除、订单状态、询盘状态 |
|
||||
| system | admin | 用户管理、系统设置、语言、备份、审计日志查看 |
|
||||
|
||||
用户页 `/admin/users/` 提供「全部 / 后台用户 / 普通用户」Tab。
|
||||
|
||||
## 备份系统(PostgreSQL 18 物理备份)
|
||||
|
||||
- PG 已升级 **16.15 → 18.6**(容器 `dusuniot-pg`,`summarize_wal=on`,命名卷 `dusuniot-pg18-data`,`--restart unless-stopped`;旧 16 容器保留为 `dusuniot-pg16-old` 可回滚)
|
||||
- 全量:`pg_basebackup -Fp -Xs` → `backups/pg/full-<ts>/`
|
||||
- 增量:`pg_basebackup --incremental=<最近备份>/backup_manifest`(PG 17+ 能力;实测全量 124MB → 增量 5.4MB)
|
||||
- 归档:每次备份 tar.gz → `backups/artifacts/`;配置 S3 后上传(Bun.S3Client,支持 multipart 大文件)
|
||||
- 保留:保留最近 N 个全量链(该全量及其后全部增量),本地与 S3 同步清理
|
||||
- 恢复:`pg_combinebackup 全量 增量…` 合并 → 作为 PGDATA 启动(已在容器内实测恢复 699 内容完整)
|
||||
- 定时:settings.backup.schedule 开启后,jobs worker(`bun scripts/jobs-worker.ts`)按间隔执行 `backup.run`(自动增量、无备份时全量),完成后自排下一轮
|
||||
- UI:`/admin/backups`(仅 admin):立即全量/增量、本地+S3 列表、删除、S3/保留/定时配置
|
||||
|
||||
## 后台功能清单(/admin,React + Ant Design)
|
||||
|
||||
| 模块 | 路由 | 能力 |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dusuniot-rebuild",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* # 脚本 后台浏览器点击级实测(playwright + headless chromium)
|
||||
* 覆盖:登录态进入各页、媒体上传、内容保存(分类+Elementor)、修订弹窗、
|
||||
* 备份 UI(增量执行/配置保存)、用户 Tab。输出 PASS/FAIL 清单。
|
||||
*/
|
||||
import { chromium } from "playwright-core";
|
||||
import { readFileSync, writeFileSync, mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const CHROME = process.env.CHROME || `${process.env.HOME}/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome`;
|
||||
const BASE = "http://127.0.0.1:3312";
|
||||
const TOKEN = readFileSync("/tmp/bt-token.txt", "utf8").trim();
|
||||
|
||||
const results: { name: string; ok: boolean; detail?: string }[] = [];
|
||||
const check = (name: string, ok: boolean, detail?: string) => {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ executablePath: CHROME, headless: true, args: ["--no-sandbox"] });
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
await ctx.addCookies([{ name: "dusun_session", value: TOKEN, domain: "127.0.0.1", path: "/" }]);
|
||||
const page = await ctx.newPage();
|
||||
|
||||
try {
|
||||
// 1. Dashboard 渲染
|
||||
await page.goto(BASE + "/admin/", { waitUntil: "networkidle" });
|
||||
check("Dashboard 渲染", await page.getByText("询盘漏斗").count() > 0);
|
||||
|
||||
// 2. 媒体上传(AntD Upload input 直设文件)
|
||||
await page.goto(BASE + "/admin/media/", { waitUntil: "networkidle" });
|
||||
const png = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFklEQVR4nGP8//8/AzGAiShVowoJAgCCpgQDXDeUDQAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
const upDir = mkdtempSync(join(tmpdir(), "bt-"));
|
||||
const upFile = join(upDir, "bt-upload.png");
|
||||
writeFileSync(upFile, png);
|
||||
const fileChooserP = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: /上传媒体/ }).click();
|
||||
const fc = await fileChooserP;
|
||||
await fc.setFiles([upFile]);
|
||||
await page.waitForTimeout(2500);
|
||||
const uploaded = await page.getByText("bt-upload.png").count();
|
||||
check("媒体上传", uploaded > 0, uploaded ? "" : "列表未出现新文件");
|
||||
|
||||
// 3. 新建内容(页面类型 + 分类标签 + 保存)
|
||||
await page.goto(BASE + "/admin/contents/new/", { waitUntil: "networkidle" });
|
||||
await page.getByLabel("标题").first().fill("浏览器实测页面");
|
||||
await page.getByLabel("正文 HTML(无 Elementor JSON 时前台按此渲染)").first().fill("<p>bt content</p>");
|
||||
await page.getByRole("button", { name: /保 存|保存/ }).first().click();
|
||||
await page.waitForTimeout(2000);
|
||||
check("新建内容保存", await page.getByText("保存成功").count() > 0);
|
||||
|
||||
// 4. 编辑该内容:绑定分类 + 填 Elementor JSON + 再保存(产生修订)
|
||||
await page.goto(BASE + "/admin/contents/", { waitUntil: "networkidle" });
|
||||
await page.getByPlaceholder("搜索标题 / 别名").fill("浏览器实测页面");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForTimeout(1500);
|
||||
await page.getByRole("link", { name: "浏览器实测页面" }).first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
// TreeSelect(分类/标签)
|
||||
const ts = page.locator(".ant-select:not(.ant-select-customize-input)").filter({ hasText: "选择分类与标签" });
|
||||
if (await ts.count()) {
|
||||
await ts.first().click();
|
||||
await page.waitForTimeout(800);
|
||||
const opt = page.locator(".ant-select-tree-treenode").filter({ hasText: /.+/ }).first();
|
||||
await opt.click();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
await page.getByLabel(/Elementor JSON/).first().fill('{"elements":[]}');
|
||||
await page.getByRole("button", { name: /保 存|保存/ }).first().click();
|
||||
await page.waitForTimeout(2000);
|
||||
check("编辑内容+分类+Elementor 保存", await page.getByText("保存成功").count() > 0);
|
||||
// 保存后跳回列表:重新搜索并进入编辑页再测修订
|
||||
await page.waitForTimeout(1000);
|
||||
await page.getByPlaceholder("搜索标题 / 别名").first().fill("浏览器实测页面");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForTimeout(1500);
|
||||
await page.getByRole("link", { name: "浏览器实测页面" }).first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 5. 修订历史弹窗
|
||||
await page.getByRole("button", { name: /修订历史/ }).first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
const revRows = await page.locator(".ant-modal .ant-table-row").count();
|
||||
check("修订历史留档", revRows >= 1, `修订 ${revRows} 条(创建不快照,每次更新留档)`);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// 6. 备份页:列表 + 增量备份执行
|
||||
await page.goto(BASE + "/admin/backups/", { waitUntil: "networkidle" });
|
||||
check("备份页渲染", await page.getByText("备份管理(PostgreSQL 18 物理备份)").count() > 0);
|
||||
await page.getByRole("button", { name: /增量备份/ }).click();
|
||||
// 备份 = pg_basebackup + tar.gz + S3 上传,实测 ~10-20s
|
||||
await page.waitForSelector("text=/备份完成:incr-/", { timeout: 40000 }).catch(() => {});
|
||||
const incrDone = await page.getByText(/备份完成:incr-/).count() > 0;
|
||||
if (!incrDone) await page.waitForTimeout(3000);
|
||||
check("UI 增量备份执行", incrDone || (await page.locator(".ant-table-row").filter({ hasText: /incr-/ }).count()) > 2);
|
||||
|
||||
// 7. 备份配置保存(S3 留空 → 仅保留策略)
|
||||
await page.getByLabel(/保留全量链数/).fill("3");
|
||||
await page.getByRole("button", { name: /保存配置/ }).click();
|
||||
await page.waitForTimeout(1500);
|
||||
check("备份配置保存", await page.getByText("备份配置已保存").count() > 0);
|
||||
|
||||
// 8. 用户页 Tab
|
||||
await page.goto(BASE + "/admin/users/", { waitUntil: "networkidle" });
|
||||
await page.getByRole("tab", { name: "普通用户" }).click();
|
||||
await page.waitForTimeout(1500);
|
||||
const rows = await page.locator(".ant-table-row").count();
|
||||
check("用户页普通用户 Tab", rows > 0, `${rows} 行`);
|
||||
} catch (e) {
|
||||
check("脚本异常", false, e instanceof Error ? e.message.slice(0, 300) : String(e));
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
const fail = results.filter((r) => !r.ok);
|
||||
console.log(`\n==== ${results.length - fail.length}/${results.length} PASS ====`);
|
||||
process.exit(0);
|
||||
@@ -19,6 +19,8 @@ export interface AdminSearch {
|
||||
status?: string;
|
||||
/** # 成员变量 类型过滤(内容页用) **/
|
||||
type?: string;
|
||||
/** # 成员变量 用户类型过滤(用户页:backend/customer) **/
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
/** # 类型 后台列表统一返回结构 **/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
+90
-47
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 重复消费) */
|
||||
|
||||
Reference in New Issue
Block a user