diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index de3f506..cfd628b 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -355,11 +355,28 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.ErrDefault("请求失败")) return } - id := idFromBody(r, w) - if id <= 0 { + + var req struct { + ID int64 `json:"id"` + Channel string `json:"channel"` + } + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) return } - secret, err := h.repo.GetNodeSecret(id) + if req.ID <= 0 { + response.WriteJSON(w, response.ErrDefault("参数错误")) + return + } + + channel := normalizeReleaseChannel(req.Channel) + version, err := resolveLatestReleaseByChannel(channel) + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err))) + return + } + + secret, err := h.repo.GetNodeSecret(req.ID) if err != nil { response.WriteJSON(w, response.ErrDefault("节点不存在")) return @@ -373,7 +390,7 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.Err(-2, err.Error())) return } - cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret) + cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/download/%s/install.sh -o ./install.sh && chmod +x ./install.sh && VERSION=%s ./install.sh -a %s -s %s", version, version, processServerAddress(panelAddr), secret) response.WriteJSON(w, response.OK(cmd)) } diff --git a/go-backend/internal/http/handler/upgrade.go b/go-backend/internal/http/handler/upgrade.go index 99e0cbb..9fa933e 100644 --- a/go-backend/internal/http/handler/upgrade.go +++ b/go-backend/internal/http/handler/upgrade.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "regexp" "strings" "sync" "time" @@ -19,8 +20,104 @@ const ( githubHTMLBase = "https://github.com" upgradeTimeout = 5 * time.Minute batchWorkers = 5 + + releaseChannelStable = "stable" + releaseChannelDev = "dev" ) +var ( + stableVersionPattern = regexp.MustCompile(`^\d+(?:\.\d+)+$`) + testKeywordPattern = regexp.MustCompile(`(?i)(alpha|beta|rc)`) +) + +type githubRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + PublishedAt string `json:"published_at"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` +} + +func normalizeReleaseChannel(channel string) string { + switch strings.ToLower(strings.TrimSpace(channel)) { + case releaseChannelDev: + return releaseChannelDev + default: + return releaseChannelStable + } +} + +func releaseChannelFromTag(tag string) string { + normalized := strings.ToLower(strings.TrimSpace(tag)) + if normalized == "" { + return releaseChannelDev + } + if testKeywordPattern.MatchString(normalized) { + return releaseChannelDev + } + if stableVersionPattern.MatchString(normalized) { + return releaseChannelStable + } + + return releaseChannelDev +} + +func releaseChannelLabel(channel string) string { + if normalizeReleaseChannel(channel) == releaseChannelDev { + return "测试版" + } + + return "正式版" +} + +func fetchGitHubReleases(perPage int) ([]githubRelease, error) { + if perPage <= 0 { + perPage = 20 + } + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Get(fmt.Sprintf("%s/repos/%s/releases?per_page=%d", githubAPIBase, githubRepo, perPage)) + if err != nil { + return nil, fmt.Errorf("请求GitHub API失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body)) + } + + var releases []githubRelease + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return nil, fmt.Errorf("解析GitHub API响应失败: %v", err) + } + + return releases, nil +} + +func resolveLatestReleaseByChannel(channel string) (string, error) { + normalizedChannel := normalizeReleaseChannel(channel) + releases, err := fetchGitHubReleases(50) + if err != nil { + return "", err + } + + for _, r := range releases { + if r.Draft { + continue + } + tag := strings.TrimSpace(r.TagName) + if tag == "" { + continue + } + if releaseChannelFromTag(tag) == normalizedChannel { + return tag, nil + } + } + + return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel)) +} + func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { response.WriteJSON(w, response.ErrDefault("请求失败")) @@ -30,6 +127,7 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) { var req struct { ID int64 `json:"id"` Version string `json:"version"` + Channel string `json:"channel"` } if err := decodeJSON(r.Body, &req); err != nil { response.WriteJSON(w, response.ErrDefault("请求参数错误")) @@ -40,12 +138,13 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) { return } + channel := normalizeReleaseChannel(req.Channel) version := strings.TrimSpace(req.Version) if version == "" { var err error - version, err = resolveLatestRelease() + version, err = resolveLatestReleaseByChannel(channel) if err != nil { - response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err))) + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err))) return } } @@ -75,61 +174,11 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) { } func resolveLatestRelease() (string, error) { - client := &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - Timeout: 10 * time.Second, - } - - resp, err := client.Get(githubProxy + "/" + githubHTMLBase + "/" + githubRepo + "/releases/latest") - if err != nil { - return "", fmt.Errorf("请求GitHub失败: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently { - return resolveLatestReleaseAPI() - } - - location := resp.Header.Get("Location") - if location == "" { - return resolveLatestReleaseAPI() - } - - parts := strings.Split(location, "/") - tag := parts[len(parts)-1] - if tag == "" || tag == "latest" { - return resolveLatestReleaseAPI() - } - - return tag, nil + return resolveLatestReleaseByChannel(releaseChannelStable) } func resolveLatestReleaseAPI() (string, error) { - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases/latest") - if err != nil { - return "", fmt.Errorf("请求GitHub API失败: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return "", fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body)) - } - - var release struct { - TagName string `json:"tag_name"` - } - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return "", fmt.Errorf("解析GitHub API响应失败: %v", err) - } - if strings.TrimSpace(release.TagName) == "" { - return "", fmt.Errorf("无法从GitHub获取最新版本号") - } - - return release.TagName, nil + return resolveLatestReleaseByChannel(releaseChannelStable) } func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) { @@ -141,6 +190,7 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) { var req struct { IDs []int64 `json:"ids"` Version string `json:"version"` + Channel string `json:"channel"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.WriteJSON(w, response.ErrDefault("请求参数错误")) @@ -151,12 +201,13 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) { return } + channel := normalizeReleaseChannel(req.Channel) version := strings.TrimSpace(req.Version) if version == "" { var err error - version, err = resolveLatestRelease() + version, err = resolveLatestReleaseByChannel(channel) if err != nil { - response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err))) + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err))) return } } @@ -212,37 +263,28 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) { return } - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases?per_page=20") + var req struct { + Channel string `json:"channel"` + } + if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + + channel := normalizeReleaseChannel(req.Channel) + + releases, err := fetchGitHubReleases(50) if err != nil { response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err))) return } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: GitHub API返回 %d: %s", resp.StatusCode, string(body)))) - return - } - - var releases []struct { - TagName string `json:"tag_name"` - Name string `json:"name"` - PublishedAt string `json:"published_at"` - Prerelease bool `json:"prerelease"` - Draft bool `json:"draft"` - } - if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { - response.WriteJSON(w, response.Err(-2, fmt.Sprintf("解析版本列表失败: %v", err))) - return - } type releaseItem struct { Version string `json:"version"` Name string `json:"name"` PublishedAt string `json:"publishedAt"` Prerelease bool `json:"prerelease"` + Channel string `json:"channel"` } items := make([]releaseItem, 0, len(releases)) @@ -250,11 +292,20 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) { if r.Draft { continue } + tag := strings.TrimSpace(r.TagName) + if tag == "" { + continue + } + itemChannel := releaseChannelFromTag(tag) + if itemChannel != channel { + continue + } items = append(items, releaseItem{ - Version: r.TagName, + Version: tag, Name: r.Name, PublishedAt: r.PublishedAt, - Prerelease: r.Prerelease, + Prerelease: itemChannel == releaseChannelDev, + Channel: itemChannel, }) } diff --git a/go-backend/internal/http/handler/upgrade_release_channel_test.go b/go-backend/internal/http/handler/upgrade_release_channel_test.go new file mode 100644 index 0000000..6c9ef2e --- /dev/null +++ b/go-backend/internal/http/handler/upgrade_release_channel_test.go @@ -0,0 +1,46 @@ +package handler + +import "testing" + +func TestReleaseChannelFromTag(t *testing.T) { + tests := []struct { + name string + tag string + expects string + }{ + {name: "stable semantic version", tag: "2.1.4", expects: releaseChannelStable}, + {name: "v prefix should be dev", tag: "v2.1.4", expects: releaseChannelDev}, + {name: "rc release", tag: "2.1.4-rc2", expects: releaseChannelDev}, + {name: "beta release", tag: "2.1.4-beta.1", expects: releaseChannelDev}, + {name: "alpha release", tag: "2.1.4-alpha", expects: releaseChannelDev}, + {name: "non numeric tag", tag: "nightly", expects: releaseChannelDev}, + {name: "empty tag", tag: "", expects: releaseChannelDev}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := releaseChannelFromTag(tc.tag); got != tc.expects { + t.Fatalf("releaseChannelFromTag(%q) = %q, want %q", tc.tag, got, tc.expects) + } + }) + } +} + +func TestNormalizeReleaseChannel(t *testing.T) { + tests := []struct { + input string + expects string + }{ + {input: "", expects: releaseChannelStable}, + {input: "stable", expects: releaseChannelStable}, + {input: "dev", expects: releaseChannelDev}, + {input: "DEV", expects: releaseChannelDev}, + {input: "preview", expects: releaseChannelStable}, + } + + for _, tc := range tests { + if got := normalizeReleaseChannel(tc.input); got != tc.expects { + t.Fatalf("normalizeReleaseChannel(%q) = %q, want %q", tc.input, got, tc.expects) + } + } +} diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index bea1164..4df128b 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -31,6 +31,8 @@ import axios from "axios"; import Network from "./network"; +export type ReleaseChannel = "stable" | "dev"; + // 登陆相关接口 export interface LoginData { username: string; @@ -66,8 +68,10 @@ export const getNodeList = () => Network.post("/node/list"); export const updateNode = (data: NodeMutationPayload) => Network.post("/node/update", data); export const deleteNode = (id: number) => Network.post("/node/delete", { id }); -export const getNodeInstallCommand = (id: number) => - Network.post("/node/install", { id }); +export const getNodeInstallCommand = ( + id: number, + channel: ReleaseChannel = "stable", +) => Network.post("/node/install", { id, channel }); export const updateNodeOrder = (data: { nodes: Array<{ id: number; inx: number }>; }) => Network.post("/node/update-order", data); @@ -77,20 +81,28 @@ export const checkNodeStatus = (nodeId?: number) => { return Network.post("/node/check-status", params); }; -export const upgradeNode = (id: number, version?: string) => +export const upgradeNode = ( + id: number, + version?: string, + channel: ReleaseChannel = "stable", +) => Network.post( "/node/upgrade", - { id, version: version || "" }, + { id, version: version || "", channel }, { timeout: 5 * 60 * 1000 }, ); -export const batchUpgradeNodes = (ids: number[], version?: string) => +export const batchUpgradeNodes = ( + ids: number[], + version?: string, + channel: ReleaseChannel = "stable", +) => Network.post( "/node/batch-upgrade", - { ids, version: version || "" }, + { ids, version: version || "", channel }, { timeout: 15 * 60 * 1000 }, ); -export const getNodeReleases = () => - Network.post("/node/releases"); +export const getNodeReleases = (channel: ReleaseChannel = "stable") => + Network.post("/node/releases", { channel }); export const rollbackNode = (id: number) => Network.post("/node/rollback", { id }); diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index b4ae2e0..f1a7454 100644 --- a/vite-frontend/src/api/types.ts +++ b/vite-frontend/src/api/types.ts @@ -172,6 +172,7 @@ export interface NodeReleaseApiItem { name: string; publishedAt: string; prerelease: boolean; + channel: "stable" | "dev"; } export interface UserPackageInfoApiData { diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 66cb8e1..9b46bff 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -49,6 +49,7 @@ import { batchUpgradeNodes, getNodeReleases, rollbackNode, + type ReleaseChannel, } from "@/api"; import { PageEmptyState, PageLoadingState } from "@/components/page-state"; import { @@ -183,6 +184,10 @@ export default function NodePage() { const [installCommandModal, setInstallCommandModal] = useState(false); const [installCommand, setInstallCommand] = useState(""); const [currentNodeName, setCurrentNodeName] = useState(""); + const [installSelectorOpen, setInstallSelectorOpen] = useState(false); + const [installTargetNode, setInstallTargetNode] = useState(null); + const [installChannel, setInstallChannel] = + useState("stable"); // 升级相关状态 const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); @@ -198,9 +203,12 @@ export default function NodePage() { name: string; publishedAt: string; prerelease: boolean; + channel: ReleaseChannel; }> >([]); const [releasesLoading, setReleasesLoading] = useState(false); + const [releaseChannel, setReleaseChannel] = + useState("stable"); const [selectedVersion, setSelectedVersion] = useState(""); const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false); const [upgradeProgress, setUpgradeProgress] = useState< @@ -609,20 +617,31 @@ export default function NodePage() { } }; + const openInstallSelector = (node: Node) => { + setInstallTargetNode(node); + setInstallChannel("stable"); + setInstallSelectorOpen(true); + }; + // 复制安装命令 - const handleCopyInstallCommand = async (node: Node) => { + const handleCopyInstallCommand = async ( + node: Node, + channel: ReleaseChannel, + ) => { setNodeList((prev) => prev.map((n) => (n.id === node.id ? { ...n, copyLoading: true } : n)), ); try { - const res = await getNodeInstallCommand(node.id); + const res = await getNodeInstallCommand(node.id, channel); if (res.code === 0 && res.data) { const copied = await tryCopyInstallCommand(res.data); if (copied) { - toast.success("安装命令已复制到剪贴板"); + toast.success( + `${channel === "stable" ? "正式版" : "测试版"}安装命令已复制到剪贴板`, + ); } else { setInstallCommand(res.data); setCurrentNodeName(node.name); @@ -640,6 +659,13 @@ export default function NodePage() { } }; + const handleConfirmInstallCommand = async () => { + if (!installTargetNode) return; + + setInstallSelectorOpen(false); + await handleCopyInstallCommand(installTargetNode, installChannel); + }; + // 手动复制安装命令 const handleManualCopy = async () => { try { @@ -651,18 +677,10 @@ export default function NodePage() { } }; - // 打开版本选择弹窗 - const openUpgradeModal = async ( - target: "single" | "batch", - nodeId?: number, - ) => { - setUpgradeTarget(target); - setUpgradeTargetNodeId(nodeId || null); - setSelectedVersion(""); - setUpgradeModalOpen(true); + const loadReleasesByChannel = useCallback(async (channel: ReleaseChannel) => { setReleasesLoading(true); try { - const res = await getNodeReleases(); + const res = await getNodeReleases(channel); if (res.code === 0 && Array.isArray(res.data)) { setReleases(res.data); @@ -674,6 +692,21 @@ export default function NodePage() { } finally { setReleasesLoading(false); } + }, []); + + // 打开版本选择弹窗 + const openUpgradeModal = async ( + target: "single" | "batch", + nodeId?: number, + ) => { + const defaultChannel: ReleaseChannel = "stable"; + + setUpgradeTarget(target); + setUpgradeTargetNodeId(nodeId || null); + setReleaseChannel(defaultChannel); + setSelectedVersion(""); + setUpgradeModalOpen(true); + await loadReleasesByChannel(defaultChannel); }; // 确认升级(从版本弹窗) @@ -692,7 +725,11 @@ export default function NodePage() { ), ); try { - const res = await upgradeNode(upgradeTargetNodeId, version); + const res = await upgradeNode( + upgradeTargetNodeId, + version, + releaseChannel, + ); if (res.code === 0) { toast.success(`节点升级命令已发送,节点将自动重启`); @@ -712,7 +749,11 @@ export default function NodePage() { setBatchUpgradeLoading(true); setUpgradeModalOpen(false); try { - const res = await batchUpgradeNodes(Array.from(selectedIds), version); + const res = await batchUpgradeNodes( + Array.from(selectedIds), + version, + releaseChannel, + ); if (res.code === 0) { toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`); @@ -1367,7 +1408,7 @@ export default function NodePage() { isLoading={node.copyLoading} size="sm" variant="flat" - onPress={() => handleCopyInstallCommand(node)} + onPress={() => openInstallSelector(node)} > 安装 @@ -1811,6 +1852,55 @@ export default function NodePage() { + + + {(onClose) => ( + <> + +

+ 选择安装通道 + {installTargetNode ? ` - ${installTargetNode.name}` : ""} +

+
+ +
+ +
+
+ + + + + + )} +
+
+ {/* 安装命令模态框 */} ) : (
+