From ab7d1c937a8c12cf1434401503bda014a6359657 Mon Sep 17 00:00:00 2001 From: feiezha Date: Tue, 29 Sep 2026 11:52:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E8=AE=A2=E9=98=85=E8=8A=82=E7=82=B9=E5=B9=B6=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E7=8E=B0=E6=9C=89=E6=95=B0=E6=8D=AE=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 18 + app/admin/dashboard/page.tsx | 18 +- app/admin/nodes/actions.ts | 66 ++- app/admin/nodes/custom-node-form.tsx | 89 ++++ app/admin/nodes/page.tsx | 396 ++++++++------- app/admin/subscriptions/page.tsx | 11 +- app/api/admin/nodes/[slug]/config/route.ts | 21 +- app/api/subscription/[token]/clash/route.ts | 12 +- app/globals.css | 7 + bun.lock | 62 +++ components/action-form.tsx | 10 +- lib/custom-proxy.ts | 67 +++ lib/db-migrations.ts | 194 +++++++ lib/db.ts | 177 +------ lib/hy2.ts | 4 + lib/store.ts | 221 ++++++-- lib/subscription.ts | 85 +++- package-lock.json | 535 ++++++++++++++++++++ package.json | 5 +- playwright.config.ts | 5 +- tests/custom-nodes.spec.ts | 133 +++++ tests/e2e-fixtures.ts | 18 + tests/fixtures/remote-v4-schema.sql | 124 +++++ tests/ui.spec.ts | 20 +- tests/unit/custom-proxy.test.ts | 121 +++++ tests/unit/migrations.test.ts | 130 +++++ tests/unit/store-custom.test.ts | 103 ++++ 27 files changed, 2203 insertions(+), 449 deletions(-) create mode 100644 app/admin/nodes/custom-node-form.tsx create mode 100644 lib/custom-proxy.ts create mode 100644 lib/db-migrations.ts create mode 100644 tests/custom-nodes.spec.ts create mode 100644 tests/e2e-fixtures.ts create mode 100644 tests/fixtures/remote-v4-schema.sql create mode 100644 tests/unit/custom-proxy.test.ts create mode 100644 tests/unit/migrations.test.ts create mode 100644 tests/unit/store-custom.test.ts diff --git a/README.md b/README.md index 8081d02..a4fb6fc 100644 --- a/README.md +++ b/README.md @@ -159,3 +159,21 @@ npm run test:e2e ``` 测试自动启动 3106 端口的开发服务,使用 `/tmp` 中独立的临时数据库并关闭节点轮询,不访问现有数据库。覆盖排序持久化、鼠标/键盘/模拟触屏、列表筛选、用户编辑、复制链接、删除确认、失败保留输入和移动端溢出检查。截图输出在 `test-results/`。运行前请停止同一工作目录的其他 Next 开发服务。 + +## 自定义订阅节点与数据库兼容 + +在「节点管理 → 新增自定义订阅节点」填写显示名、Slug 和单个 Clash/Mihomo proxy 的 YAML。支持单对象、单节点列表,以及 `proxies:` 包装。协议参数(例如 AnyTLS 的 password、sni、alpn)由 YAML 提供;订阅名使用 YAML `name`,面板显示名独立。原始 YAML 和注释按原样保存,输出订阅时才解析、序列化。格式错误会在表单内提示并保留输入。 + +自定义节点与 HY2 节点共用列表、启停、删除、主组拖拽排序及自定义分组。自定义节点仅分发配置,不参与 HY2 HTTP 鉴权、轮询或面板流量计量,也不生成 HY2 服务端配置。代理的访问控制和流量限制由该代理服务自身负责。 + +数据库升级沿用服务器现有的 **SQLite `user_version = 4`**,保留 `hy2_nodes.node_type`(默认 `hy2`)和 `hy2_nodes.custom_config_yaml`(默认空字符串)的原名称、类型和默认值: + +- 全新数据库、版本 1–3 的数据库在启动时自动迁移。 +- 服务器已有的完整版本 4 不再重复添加列,也不重写现有记录。 +- 对于版本 3/4 中已部分添加的自定义节点字段,只补充缺失列。 +- 迁移在一个 SQLite 写事务中执行,失败回滚;更高版本的数据库会拒绝启动,避免错误降级。 +- 节点 ID、用户 ID、订阅 token、加密凭据、额度、会话、历史流量和代理分组 JSON 都不因迁移而重建。 + +升级时保留原 `.env`、`DATABASE_PATH`、`SESSION_SECRET` 和管理员配置。尤其不能更换 `SESSION_SECRET`,现有 HY2 用户凭据依赖它解密。发布前应停服务并通过 SQLite backup API 备份数据库(包含 WAL 中已提交的数据),不要仅复制正在写入的 `.db` 文件。随后更新代码、安装锁定依赖、构建并启动;无需导出导入业务数据,也无需手工执行 `ALTER TABLE`。 + +`tests/fixtures/remote-v4-schema.sql` 是从服务器只读获取的实际表结构,不含生产数据。`npm test` 使用该结构和模拟业务数据验证兼容性、重复迁移、失败回滚、原始 YAML 保留、订阅生成,以及自定义节点不参与鉴权和轮询。`npm run test:e2e` 验证新版 UI 的新增、校验、编辑、分组、禁用和删除流程。 diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx index 2d66a23..c53d817 100644 --- a/app/admin/dashboard/page.tsx +++ b/app/admin/dashboard/page.tsx @@ -7,12 +7,16 @@ import { DashboardCharts } from "./dashboard-charts"; function getNodePill(node: { enabled: number; + node_type: "hy2" | "custom"; last_error_message: string | null; }) { if (!node.enabled) { return { label: "已禁用", className: "status-pill status-pill--off" }; } + if (node.node_type === "custom") + return { label: "自定义订阅", className: "status-pill status-pill--ink" }; + if (node.last_error_message) { return { label: "同步异常", className: "status-pill status-pill--danger" }; } @@ -66,7 +70,7 @@ export default async function DashboardPage() { node.node_type === "hy2")} trafficTrend={data.trafficTrend} trafficBuckets={data.trafficBuckets} trafficUserOptions={data.trafficUserOptions} @@ -103,11 +107,15 @@ export default async function DashboardPage() {

- 在线 {node.last_online_users} / 流 {node.last_stream_count} -

-

- 最近成功 {formatLocalDateTime(node.last_sync_ok_at)} + {node.node_type === "custom" + ? "仅订阅 · 不参与轮询" + : `在线 ${node.last_online_users} / 流 ${node.last_stream_count}`}

+ {node.node_type === "hy2" && ( +

+ 最近成功 {formatLocalDateTime(node.last_sync_ok_at)} +

+ )} {node.last_error_message ? (

{node.last_error_message}

) : null} diff --git a/app/admin/nodes/actions.ts b/app/admin/nodes/actions.ts index 11dc9fa..d32f380 100644 --- a/app/admin/nodes/actions.ts +++ b/app/admin/nodes/actions.ts @@ -1,69 +1,110 @@ "use server"; +import { + CustomProxyValidationError, + parseCustomProxyYaml, +} from "@/lib/custom-proxy"; import { revalidatePath } from "next/cache"; import { requireAdminAccess } from "@/lib/session"; -import { createNode, deleteNode, updateNode } from "@/lib/store"; +import { + createNode, + deleteNode, + normalizeNodeType, + updateNode, +} from "@/lib/store"; function boolFromForm(value: FormDataEntryValue | null) { return value === "on"; } +function validateCustomYaml(nodeType: string, formData: FormData) { + if (nodeType !== "custom") return; + try { + parseCustomProxyYaml(String(formData.get("custom_config_yaml") ?? "")); + } catch (error) { + if (error instanceof CustomProxyValidationError) + return { error: error.message }; + throw error; + } +} + export async function createNodeAction(formData: FormData) { await requireAdminAccess(); + const nodeType = normalizeNodeType( + String(formData.get("node_type") ?? "hy2"), + ); + const validation = validateCustomYaml(nodeType, formData); + if (validation) return validation; + const name = String(formData.get("name") ?? "").trim(); + const clientName = String(formData.get("client_name") ?? "").trim(); + await createNode({ + nodeType, slug: String(formData.get("slug") ?? "").trim(), - name: String(formData.get("name") ?? "").trim(), + name, authToken: String(formData.get("auth_token") ?? "").trim() || null, hy2Listen: String(formData.get("hy2_listen") ?? "").trim() || "", serverHost: String(formData.get("server_host") ?? "").trim(), serverPort: Number(formData.get("server_port") ?? 443), - clientName: - String(formData.get("client_name") ?? "").trim() || - String(formData.get("name") ?? "").trim(), + clientName: clientName || (nodeType === "hy2" ? name : ""), bandwidthUpMbps: Number(formData.get("bandwidth_up_mbps") ?? 30), bandwidthDownMbps: Number(formData.get("bandwidth_down_mbps") ?? 30), skipCertVerify: boolFromForm(formData.get("skip_cert_verify")), sni: String(formData.get("sni") ?? "").trim() || null, + customConfigYaml: String(formData.get("custom_config_yaml") ?? ""), trafficStatsUrl: String(formData.get("traffic_stats_url") ?? "").trim(), trafficStatsListen: String(formData.get("traffic_stats_listen") ?? "").trim() || ":9999", - trafficStatsSecret: - String(formData.get("traffic_stats_secret") ?? "").trim(), + trafficStatsSecret: String( + formData.get("traffic_stats_secret") ?? "", + ).trim(), enabled: boolFromForm(formData.get("enabled")), pollIntervalSeconds: Number(formData.get("poll_interval_seconds") ?? 15), }); revalidatePath("/admin/nodes"); + revalidatePath("/admin/subscriptions"); revalidatePath("/admin/dashboard"); } export async function updateNodeAction(formData: FormData) { await requireAdminAccess(); + const nodeType = normalizeNodeType( + String(formData.get("node_type") ?? "hy2"), + ); + const validation = validateCustomYaml(nodeType, formData); + if (validation) return validation; + const name = String(formData.get("name") ?? "").trim(); + const clientName = String(formData.get("client_name") ?? "").trim(); + await updateNode({ id: Number(formData.get("id")), + nodeType, slug: String(formData.get("slug") ?? "").trim(), - name: String(formData.get("name") ?? "").trim(), + name, authToken: String(formData.get("auth_token") ?? "").trim(), hy2Listen: String(formData.get("hy2_listen") ?? "").trim() || "", serverHost: String(formData.get("server_host") ?? "").trim(), serverPort: Number(formData.get("server_port") ?? 443), - clientName: - String(formData.get("client_name") ?? "").trim() || - String(formData.get("name") ?? "").trim(), + clientName: clientName || (nodeType === "hy2" ? name : ""), bandwidthUpMbps: Number(formData.get("bandwidth_up_mbps") ?? 30), bandwidthDownMbps: Number(formData.get("bandwidth_down_mbps") ?? 30), skipCertVerify: boolFromForm(formData.get("skip_cert_verify")), sni: String(formData.get("sni") ?? "").trim() || null, + customConfigYaml: String(formData.get("custom_config_yaml") ?? ""), trafficStatsUrl: String(formData.get("traffic_stats_url") ?? "").trim(), trafficStatsListen: String(formData.get("traffic_stats_listen") ?? "").trim() || ":9999", - trafficStatsSecret: String(formData.get("traffic_stats_secret") ?? "").trim(), + trafficStatsSecret: String( + formData.get("traffic_stats_secret") ?? "", + ).trim(), enabled: boolFromForm(formData.get("enabled")), pollIntervalSeconds: Number(formData.get("poll_interval_seconds") ?? 15), }); revalidatePath("/admin/nodes"); + revalidatePath("/admin/subscriptions"); revalidatePath("/admin/dashboard"); } @@ -71,5 +112,6 @@ export async function deleteNodeAction(formData: FormData) { await requireAdminAccess(); await deleteNode(Number(formData.get("id"))); revalidatePath("/admin/nodes"); + revalidatePath("/admin/subscriptions"); revalidatePath("/admin/dashboard"); } diff --git a/app/admin/nodes/custom-node-form.tsx b/app/admin/nodes/custom-node-form.tsx new file mode 100644 index 0000000..883dd09 --- /dev/null +++ b/app/admin/nodes/custom-node-form.tsx @@ -0,0 +1,89 @@ +import { ActionForm } from "@/components/action-form"; +import { CopyButton } from "@/components/copy-button"; +import { Input } from "@/components/ui/input"; +import { SubmitButton } from "@/components/submit-button"; +import type { Hy2NodeRow } from "@/lib/store"; +import { createNodeAction, updateNodeAction } from "./actions"; + +const example = `- name: "HK AnyTLS" + type: anytls + server: hk.example.com + port: 443 + password: "your-password" + sni: hk.example.com + udp: true`; + +export function CustomNodeForm({ node }: { node?: Hy2NodeRow }) { + return ( + + {node && } + + + + +

+ 仅写入订阅,不使用 HY2 鉴权和流量统计。订阅中的节点名称以 YAML 的 name + 为准。 +

+