[功能] 添加获取配置版本详情的API,优化配置版本管理逻辑

This commit is contained in:
ryan
2026-03-18 15:19:39 +08:00
parent 4433ab5af4
commit 9ced0eb6f0
10 changed files with 206 additions and 53 deletions
+36 -2
View File
@@ -1,10 +1,11 @@
package controller package controller
import ( import (
"github.com/gin-gonic/gin"
"net/http" "net/http"
"openflare/service" "openflare/service"
"strconv" "strconv"
"github.com/gin-gonic/gin"
) )
// GetConfigVersions godoc // GetConfigVersions godoc
@@ -30,6 +31,39 @@ func GetConfigVersions(c *gin.Context) {
}) })
} }
// GetConfigVersion godoc
// @Summary Get config version detail
// @Tags ConfigVersions
// @Produce json
// @Security BearerAuth
// @Param id path int true "Version ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/config-versions/{id} [get]
func GetConfigVersion(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "invalid id",
})
return
}
version, err := service.GetConfigVersionDetail(uint(id))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": version,
})
}
// GetActiveConfigVersion godoc // GetActiveConfigVersion godoc
// @Summary Get active config version // @Summary Get active config version
// @Tags ConfigVersions // @Tags ConfigVersions
@@ -137,7 +171,7 @@ func ActivateConfigVersion(c *gin.Context) {
if err != nil || id == 0 { if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"success": false, "success": false,
"message": "无效的参数", "message": "invalid id",
}) })
return return
} }
+14 -2
View File
@@ -2,6 +2,15 @@ package model
import "time" import "time"
type ConfigVersionSummary struct {
ID uint `json:"id"`
Version string `json:"version"`
Checksum string `json:"checksum"`
IsActive bool `json:"is_active"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
type ConfigVersion struct { type ConfigVersion struct {
ID uint `json:"id" gorm:"primaryKey"` ID uint `json:"id" gorm:"primaryKey"`
Version string `json:"version" gorm:"uniqueIndex;size:32;not null"` Version string `json:"version" gorm:"uniqueIndex;size:32;not null"`
@@ -15,8 +24,11 @@ type ConfigVersion struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
func ListConfigVersions() (versions []*ConfigVersion, err error) { func ListConfigVersionSummaries() (versions []*ConfigVersionSummary, err error) {
err = DB.Order("id desc").Find(&versions).Error err = DB.Model(&ConfigVersion{}).
Select("id", "version", "checksum", "is_active", "created_by", "created_at").
Order("id desc").
Find(&versions).Error
return versions, err return versions, err
} }
+1
View File
@@ -108,6 +108,7 @@ func SetApiRouter(router *gin.Engine) {
configVersionRoute.GET("/active", controller.GetActiveConfigVersion) configVersionRoute.GET("/active", controller.GetActiveConfigVersion)
configVersionRoute.GET("/preview", controller.PreviewConfigVersion) configVersionRoute.GET("/preview", controller.PreviewConfigVersion)
configVersionRoute.GET("/diff", controller.DiffConfigVersion) configVersionRoute.GET("/diff", controller.DiffConfigVersion)
configVersionRoute.GET("/:id", controller.GetConfigVersion)
configVersionRoute.POST("/publish", controller.PublishConfigVersion) configVersionRoute.POST("/publish", controller.PublishConfigVersion)
configVersionRoute.POST("/:id/activate", controller.ActivateConfigVersion) configVersionRoute.POST("/:id/activate", controller.ActivateConfigVersion)
} }
+23 -1
View File
@@ -127,11 +127,33 @@ func TestPhase1PublishLifecycle(t *testing.T) {
} }
resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/", nil) resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/", nil)
var versions []model.ConfigVersion var versions []map[string]any
decodeResponseData(t, resp, &versions) decodeResponseData(t, resp, &versions)
if len(versions) != 2 { if len(versions) != 2 {
t.Fatalf("expected 2 versions, got %d", len(versions)) t.Fatalf("expected 2 versions, got %d", len(versions))
} }
if _, ok := versions[0]["snapshot_json"]; ok {
t.Fatal("expected config version list to omit snapshot_json")
}
if _, ok := versions[0]["main_config"]; ok {
t.Fatal("expected config version list to omit main_config")
}
if _, ok := versions[0]["rendered_config"]; ok {
t.Fatal("expected config version list to omit rendered_config")
}
if _, ok := versions[0]["support_files_json"]; ok {
t.Fatal("expected config version list to omit support_files_json")
}
detailResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/"+toString(version2.ID), nil)
var versionDetail model.ConfigVersion
decodeResponseData(t, detailResp, &versionDetail)
if versionDetail.ID != version2.ID {
t.Fatalf("expected config version detail %d, got %d", version2.ID, versionDetail.ID)
}
if versionDetail.SnapshotJSON == "" || versionDetail.MainConfig == "" || versionDetail.RenderedConfig == "" {
t.Fatal("expected config version detail endpoint to include full payload")
}
activeResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/active", nil) activeResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/config-versions/active", nil)
var activeVersion model.ConfigVersion var activeVersion model.ConfigVersion
+11 -3
View File
@@ -37,6 +37,10 @@ type ConfigPreviewResult struct {
RouteCount int `json:"route_count"` RouteCount int `json:"route_count"`
} }
type ConfigVersionSummary = model.ConfigVersionSummary
type ConfigVersionDetail = model.ConfigVersion
type ConfigDiffResult struct { type ConfigDiffResult struct {
ActiveVersion string `json:"active_version,omitempty"` ActiveVersion string `json:"active_version,omitempty"`
AddedDomains []string `json:"added_domains"` AddedDomains []string `json:"added_domains"`
@@ -158,11 +162,15 @@ var requiredMainConfigTemplatePlaceholders = []string{
"{{OpenRestyRouteConfigInclude}}", "{{OpenRestyRouteConfigInclude}}",
} }
func ListConfigVersions() ([]*model.ConfigVersion, error) { func ListConfigVersions() ([]*ConfigVersionSummary, error) {
return model.ListConfigVersions() return model.ListConfigVersionSummaries()
} }
func GetActiveConfigVersion() (*model.ConfigVersion, error) { func GetConfigVersionDetail(id uint) (*ConfigVersionDetail, error) {
return model.GetConfigVersionByID(id)
}
func GetActiveConfigVersion() (*ConfigVersionDetail, error) {
return model.GetActiveConfigVersion() return model.GetActiveConfigVersion()
} }
@@ -1,14 +1,23 @@
import { apiRequest } from '@/lib/api/client'; import { apiRequest } from '@/lib/api/client';
import type { import type {
ConfigDiffResult, ConfigDiffResult,
ConfigPreviewResult, ConfigPreviewResult,
ConfigVersionItem, ConfigVersionDetail,
} from '@/features/config-versions/types'; ConfigVersionSummary,
} from '@/features/config-versions/types';
export function getConfigVersions() {
return apiRequest<ConfigVersionItem[]>('/config-versions/'); export function getConfigVersions() {
} return apiRequest<ConfigVersionSummary[]>('/config-versions/');
}
export function getConfigVersion(id: number) {
return apiRequest<ConfigVersionDetail>(`/config-versions/${id}`);
}
export function getActiveConfigVersion() {
return apiRequest<ConfigVersionDetail>('/config-versions/active');
}
export function getConfigVersionPreview() { export function getConfigVersionPreview() {
return apiRequest<ConfigPreviewResult>('/config-versions/preview'); return apiRequest<ConfigPreviewResult>('/config-versions/preview');
@@ -18,14 +27,14 @@ export function getConfigVersionDiff() {
return apiRequest<ConfigDiffResult>('/config-versions/diff'); return apiRequest<ConfigDiffResult>('/config-versions/diff');
} }
export function publishConfigVersion() { export function publishConfigVersion() {
return apiRequest<ConfigVersionItem>('/config-versions/publish', { return apiRequest<ConfigVersionDetail>('/config-versions/publish', {
method: 'POST', method: 'POST',
}); });
} }
export function activateConfigVersion(id: number) { export function activateConfigVersion(id: number) {
return apiRequest<ConfigVersionItem>(`/config-versions/${id}/activate`, { return apiRequest<ConfigVersionDetail>(`/config-versions/${id}/activate`, {
method: 'POST', method: 'POST',
}); });
} }
@@ -1,20 +1,41 @@
'use client'; 'use client';
import { useQuery } from '@tanstack/react-query';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppModal } from '@/components/ui/app-modal'; import { AppModal } from '@/components/ui/app-modal';
import type { ConfigVersionItem } from '@/features/config-versions/types'; import { getConfigVersion } from '@/features/config-versions/api/config-versions';
import type { ConfigVersionSummary } from '@/features/config-versions/types';
import { import {
CodeBlock, CodeBlock,
SecondaryButton, SecondaryButton,
} from '@/features/shared/components/resource-primitives'; } from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date'; import { formatDateTime } from '@/lib/utils/date';
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function ConfigVersionSnapshotModal({ export function ConfigVersionSnapshotModal({
version, version,
onClose, onClose,
}: { }: {
version: ConfigVersionItem | null; version: ConfigVersionSummary | null;
onClose: () => void; onClose: () => void;
}) { }) {
const versionDetailQuery = useQuery({
queryKey: ['config-versions', 'detail', version?.id ?? 0],
queryFn: () => {
if (!version) {
throw new Error('missing config version');
}
return getConfigVersion(version.id);
},
enabled: Boolean(version?.id),
});
const versionDetail = versionDetailQuery.data ?? null;
return ( return (
<AppModal <AppModal
isOpen={Boolean(version)} isOpen={Boolean(version)}
@@ -30,7 +51,14 @@ export function ConfigVersionSnapshotModal({
</div> </div>
} }
> >
{version ? ( {!version ? null : versionDetailQuery.isLoading && !versionDetail ? (
<LoadingState />
) : versionDetailQuery.isError ? (
<ErrorState
title="配置版本详情加载失败"
description={getErrorMessage(versionDetailQuery.error)}
/>
) : versionDetail ? (
<div className="space-y-5"> <div className="space-y-5">
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
@@ -38,7 +66,7 @@ export function ConfigVersionSnapshotModal({
Checksum Checksum
</p> </p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]"> <p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{version.checksum} {versionDetail.checksum}
</p> </p>
</div> </div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
@@ -46,7 +74,7 @@ export function ConfigVersionSnapshotModal({
创建人 创建人
</p> </p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]"> <p className="mt-2 text-sm text-[var(--foreground-primary)]">
{version.created_by || '系统'} {versionDetail.created_by || '系统'}
</p> </p>
</div> </div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
@@ -54,7 +82,7 @@ export function ConfigVersionSnapshotModal({
创建时间 创建时间
</p> </p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]"> <p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatDateTime(version.created_at)} {formatDateTime(versionDetail.created_at)}
</p> </p>
</div> </div>
</div> </div>
@@ -64,23 +92,25 @@ export function ConfigVersionSnapshotModal({
快照 JSON 快照 JSON
</p> </p>
<CodeBlock className="max-h-96 whitespace-pre-wrap"> <CodeBlock className="max-h-96 whitespace-pre-wrap">
{version.snapshot_json} {versionDetail.snapshot_json}
</CodeBlock> </CodeBlock>
</div> </div>
<div> <div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]"> <p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
主配置 主配置
</p> </p>
<CodeBlock className="max-h-96 whitespace-pre-wrap"> <CodeBlock className="max-h-96 whitespace-pre-wrap">
{version.main_config} {versionDetail.main_config}
</CodeBlock> </CodeBlock>
</div> </div>
<div> <div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]"> <p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
路由配置 路由配置
</p> </p>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap"> <CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{version.rendered_config} {versionDetail.rendered_config}
</CodeBlock> </CodeBlock>
</div> </div>
</div> </div>
@@ -12,6 +12,7 @@ import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge'; import { StatusBadge } from '@/components/ui/status-badge';
import { import {
activateConfigVersion, activateConfigVersion,
getActiveConfigVersion,
getConfigVersionDiff, getConfigVersionDiff,
getConfigVersionPreview, getConfigVersionPreview,
getConfigVersions, getConfigVersions,
@@ -22,7 +23,8 @@ import type {
ConfigOptionDiffItem, ConfigOptionDiffItem,
ConfigDiffResult, ConfigDiffResult,
ConfigPreviewResult, ConfigPreviewResult,
ConfigVersionItem, ConfigVersionDetail,
ConfigVersionSummary,
SupportFile, SupportFile,
} from '@/features/config-versions/types'; } from '@/features/config-versions/types';
import { import {
@@ -168,14 +170,20 @@ function OptionDiffTable({ items }: { items: ConfigOptionDiffItem[] }) {
function PublishPreviewCard({ function PublishPreviewCard({
preview, preview,
diff, diff,
activeVersion, activeVersionMeta,
activeVersionDetail,
isActiveVersionDetailLoading,
activeVersionDetailError,
isPublishing, isPublishing,
onConfirm, onConfirm,
onCancel, onCancel,
}: { }: {
preview: ConfigPreviewResult; preview: ConfigPreviewResult;
diff: ConfigDiffResult; diff: ConfigDiffResult;
activeVersion: ConfigVersionItem | null; activeVersionMeta: ConfigVersionSummary | null;
activeVersionDetail: ConfigVersionDetail | null;
isActiveVersionDetailLoading: boolean;
activeVersionDetailError: string | null;
isPublishing: boolean; isPublishing: boolean;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
@@ -263,18 +271,29 @@ function PublishPreviewCard({
<OptionDiffTable items={diff.changed_option_details} /> <OptionDiffTable items={diff.changed_option_details} />
</div> </div>
{diff.main_config_changed && activeVersion ? ( {diff.main_config_changed && activeVersionMeta ? (
<div className="grid gap-5 xl:grid-cols-2"> <div className="grid gap-5 xl:grid-cols-2">
<div> <div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3"> <div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]"> <p className="text-sm font-semibold text-[var(--foreground-primary)]">
Current Active Main Config Current Active Main Config
</p> </p>
<StatusBadge label={activeVersion.version} variant="info" /> <StatusBadge label={activeVersionMeta.version} variant="info" />
</div> </div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap"> {isActiveVersionDetailLoading ? (
{activeVersion.main_config} <LoadingState />
</CodeBlock> ) : activeVersionDetailError ? (
<InlineMessage tone="danger" message={activeVersionDetailError} />
) : activeVersionDetail ? (
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{activeVersionDetail.main_config}
</CodeBlock>
) : (
<InlineMessage
tone="info"
message="当前激活版本详情暂不可用。"
/>
)}
</div> </div>
<div> <div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3"> <div className="mb-2 flex flex-wrap items-center justify-between gap-3">
@@ -406,6 +425,14 @@ export function ConfigVersionsPage() {
() => versions.find((item) => item.id === selectedVersionId) ?? null, () => versions.find((item) => item.id === selectedVersionId) ?? null,
[selectedVersionId, versions], [selectedVersionId, versions],
); );
const shouldLoadActiveVersionDetail = Boolean(
activeVersion?.id && publishPreview?.diff.main_config_changed,
);
const activeVersionDetailQuery = useQuery({
queryKey: ['config-versions', 'active-detail', activeVersion?.id ?? 0],
queryFn: getActiveConfigVersion,
enabled: shouldLoadActiveVersionDetail,
});
const publishMutation = useMutation({ const publishMutation = useMutation({
mutationFn: publishConfigVersion, mutationFn: publishConfigVersion,
@@ -455,7 +482,7 @@ export function ConfigVersionsPage() {
} }
}; };
const handleActivate = (version: ConfigVersionItem) => { const handleActivate = (version: ConfigVersionSummary) => {
if (version.is_active) { if (version.is_active) {
return; return;
} }
@@ -493,7 +520,14 @@ export function ConfigVersionsPage() {
<PublishPreviewCard <PublishPreviewCard
preview={publishPreview.preview} preview={publishPreview.preview}
diff={publishPreview.diff} diff={publishPreview.diff}
activeVersion={activeVersion} activeVersionMeta={activeVersion}
activeVersionDetail={activeVersionDetailQuery.data ?? null}
isActiveVersionDetailLoading={activeVersionDetailQuery.isLoading}
activeVersionDetailError={
activeVersionDetailQuery.isError
? getErrorMessage(activeVersionDetailQuery.error)
: null
}
isPublishing={publishMutation.isPending} isPublishing={publishMutation.isPending}
onConfirm={() => publishMutation.mutate()} onConfirm={() => publishMutation.mutate()}
onCancel={() => setPublishPreview(null)} onCancel={() => setPublishPreview(null)}
@@ -1,16 +1,19 @@
export interface ConfigVersionItem { export interface ConfigVersionSummary {
id: number; id: number;
version: string; version: string;
snapshot_json: string;
main_config: string;
rendered_config: string;
support_files_json: string;
checksum: string; checksum: string;
is_active: boolean; is_active: boolean;
created_by: string; created_by: string;
created_at: string; created_at: string;
} }
export interface ConfigVersionDetail extends ConfigVersionSummary {
snapshot_json: string;
main_config: string;
rendered_config: string;
support_files_json: string;
}
export interface SupportFile { export interface SupportFile {
path: string; path: string;
content: string; content: string;
@@ -17,7 +17,7 @@ import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge'; import { StatusBadge } from '@/components/ui/status-badge';
import { getConfigVersions } from '@/features/config-versions/api/config-versions'; import { getConfigVersions } from '@/features/config-versions/api/config-versions';
import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal'; import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal';
import type { ConfigVersionItem } from '@/features/config-versions/types'; import type { ConfigVersionSummary } from '@/features/config-versions/types';
import { getApplyLogs } from '@/features/apply-logs/api/apply-logs'; import { getApplyLogs } from '@/features/apply-logs/api/apply-logs';
import { import {
deleteNode, deleteNode,
@@ -416,7 +416,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
} }
}; };
const activeConfigVersion = useMemo<ConfigVersionItem | null>(() => { const activeConfigVersion = useMemo<ConfigVersionSummary | null>(() => {
return ( return (
(configVersionsQuery.data ?? []).find((item) => item.is_active) ?? null (configVersionsQuery.data ?? []).find((item) => item.is_active) ?? null
); );