diff --git a/.gitignore b/.gitignore index b0680b42a..a00566938 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ visual-audit/ # 原站镜像与原站媒体(取证/运行资产,体积 7G+,不同步 git;部署时另行同步 /public/uploads/) /legacy/ /public/uploads/ + +# 备份数据(物理备份/归档/迁移转储)不入库 +backups/ diff --git a/docs/admin-crm.md b/docs/admin-crm.md index 570531596..e9553359b 100644 --- a/docs/admin-crm.md +++ b/docs/admin-crm.md @@ -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-/` +- 增量:`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) | 模块 | 路由 | 能力 | diff --git a/package.json b/package.json index 7a36edcd7..e6f76d516 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dusuniot-rebuild", - "version": "0.11.0", + "version": "0.12.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/admin-browser-test.ts b/scripts/admin-browser-test.ts new file mode 100644 index 000000000..c15e7b8dc --- /dev/null +++ b/scripts/admin-browser-test.ts @@ -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("

bt content

"); + 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); diff --git a/src/components/admin/common.tsx b/src/components/admin/common.tsx index fd86a6b75..d481dc200 100644 --- a/src/components/admin/common.tsx +++ b/src/components/admin/common.tsx @@ -19,6 +19,8 @@ export interface AdminSearch { status?: string; /** # 成员变量 类型过滤(内容页用) **/ type?: string; + /** # 成员变量 用户类型过滤(用户页:backend/customer) **/ + kind?: string; } /** # 类型 后台列表统一返回结构 **/ diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index a23a467f2..84ffb3f22 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, diff --git a/src/routes/admin.backups.tsx b/src/routes/admin.backups.tsx new file mode 100644 index 000000000..af44d61ce --- /dev/null +++ b/src/routes/admin.backups.tsx @@ -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 ; + // 收窄后的非空引用(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; + 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 ( +
+ + + + + + + } + /> + + {v} }, + { title: "类型", dataIndex: "type", width: 90, render: (v: string) => {v === "full" ? "全量" : "增量"} }, + { title: "数据目录", dataIndex: "sizeBytes", width: 110, render: (v: number) => fmtSize(v) }, + { title: "归档", dataIndex: "artifact", width: 90, render: (v?: string) => (v ? 已打包 : 未打包) }, + { title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) }, + { + title: "操作", + key: "act", + width: 90, + render: (_, r) => ( + + + + + {data.s3.length ? ( +
{v} }, + { title: "大小", dataIndex: "size", width: 110, render: (v: number) => fmtSize(v) }, + { title: "上传时间", dataIndex: "lastModified", width: 170, render: (v: string) => fmtDateTime(v) }, + ]} + /> + ) : null} + + 定时备份由 jobs worker(`bun scripts/jobs-worker.ts`)消费 `backup.run` 任务执行:自动选择增量(无备份时全量),完成后按间隔自排下一轮。 + + + + ); +} diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 8309b78fb..fa60592b2 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -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: , label: "SEO / 重定向" }, { key: "/admin/users/", icon: , label: "用户" }, { key: "/admin/settings/", icon: , label: "系统设置" }, + { key: "/admin/backups/", icon: , label: "备份" }, { key: "/admin/audit/", icon: , label: "审计日志" }, ], }, diff --git a/src/routes/admin.users.tsx b/src/routes/admin.users.tsx index afa512597..578812aea 100644 --- a/src/routes/admin.users.tsx +++ b/src/routes/admin.users.tsx @@ -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() { } /> + {/* 用户类型分层:后台用户(admin/editor/manager)可登录后台;普通用户(customer)仅商城账号 */} + void navigate({ search: (prev) => ({ ...prev, kind: key || undefined, page: 1 }) })} + items={[ + { key: "", label: "全部" }, + { key: "backend", label: "后台用户" }, + { key: "customer", label: "普通用户" }, + ]} + />
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 | 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 = {}; 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 }; + }); diff --git a/src/server/auth.ts b/src/server/auth.ts index 74d7c69d6..5ae6973af 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -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 = { + 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; +} diff --git a/src/server/backup.ts b/src/server/backup.ts new file mode 100644 index 000000000..0d51c2eca --- /dev/null +++ b/src/server/backup.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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>, 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 { + 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 { + 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; +} diff --git a/src/server/inquiries.ts b/src/server/inquiries.ts index 366c5fd06..0653b515f 100644 --- a/src/server/inquiries.ts +++ b/src/server/inquiries.ts @@ -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 }; diff --git a/src/server/jobs.ts b/src/server/jobs.ts index e348bf997..fca3cf60b 100644 --- a/src/server/jobs.ts +++ b/src/server/jobs.ts @@ -18,6 +18,21 @@ const handlers: Record) => Promise { + 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 重复消费) */