[功能] 添加获取配置版本详情的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
import (
"github.com/gin-gonic/gin"
"net/http"
"openflare/service"
"strconv"
"github.com/gin-gonic/gin"
)
// 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
// @Summary Get active config version
// @Tags ConfigVersions
@@ -137,7 +171,7 @@ func ActivateConfigVersion(c *gin.Context) {
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
"message": "invalid id",
})
return
}
+14 -2
View File
@@ -2,6 +2,15 @@ package model
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 {
ID uint `json:"id" gorm:"primaryKey"`
Version string `json:"version" gorm:"uniqueIndex;size:32;not null"`
@@ -15,8 +24,11 @@ type ConfigVersion struct {
CreatedAt time.Time `json:"created_at"`
}
func ListConfigVersions() (versions []*ConfigVersion, err error) {
err = DB.Order("id desc").Find(&versions).Error
func ListConfigVersionSummaries() (versions []*ConfigVersionSummary, err error) {
err = DB.Model(&ConfigVersion{}).
Select("id", "version", "checksum", "is_active", "created_by", "created_at").
Order("id desc").
Find(&versions).Error
return versions, err
}
+1
View File
@@ -108,6 +108,7 @@ func SetApiRouter(router *gin.Engine) {
configVersionRoute.GET("/active", controller.GetActiveConfigVersion)
configVersionRoute.GET("/preview", controller.PreviewConfigVersion)
configVersionRoute.GET("/diff", controller.DiffConfigVersion)
configVersionRoute.GET("/:id", controller.GetConfigVersion)
configVersionRoute.POST("/publish", controller.PublishConfigVersion)
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)
var versions []model.ConfigVersion
var versions []map[string]any
decodeResponseData(t, resp, &versions)
if len(versions) != 2 {
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)
var activeVersion model.ConfigVersion
+11 -3
View File
@@ -37,6 +37,10 @@ type ConfigPreviewResult struct {
RouteCount int `json:"route_count"`
}
type ConfigVersionSummary = model.ConfigVersionSummary
type ConfigVersionDetail = model.ConfigVersion
type ConfigDiffResult struct {
ActiveVersion string `json:"active_version,omitempty"`
AddedDomains []string `json:"added_domains"`
@@ -158,11 +162,15 @@ var requiredMainConfigTemplatePlaceholders = []string{
"{{OpenRestyRouteConfigInclude}}",
}
func ListConfigVersions() ([]*model.ConfigVersion, error) {
return model.ListConfigVersions()
func ListConfigVersions() ([]*ConfigVersionSummary, error) {
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()
}
@@ -1,14 +1,23 @@
import { apiRequest } from '@/lib/api/client';
import type {
ConfigDiffResult,
ConfigPreviewResult,
ConfigVersionItem,
} from '@/features/config-versions/types';
export function getConfigVersions() {
return apiRequest<ConfigVersionItem[]>('/config-versions/');
}
import { apiRequest } from '@/lib/api/client';
import type {
ConfigDiffResult,
ConfigPreviewResult,
ConfigVersionDetail,
ConfigVersionSummary,
} from '@/features/config-versions/types';
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() {
return apiRequest<ConfigPreviewResult>('/config-versions/preview');
@@ -18,14 +27,14 @@ export function getConfigVersionDiff() {
return apiRequest<ConfigDiffResult>('/config-versions/diff');
}
export function publishConfigVersion() {
return apiRequest<ConfigVersionItem>('/config-versions/publish', {
method: 'POST',
});
}
export function activateConfigVersion(id: number) {
return apiRequest<ConfigVersionItem>(`/config-versions/${id}/activate`, {
export function publishConfigVersion() {
return apiRequest<ConfigVersionDetail>('/config-versions/publish', {
method: 'POST',
});
}
export function activateConfigVersion(id: number) {
return apiRequest<ConfigVersionDetail>(`/config-versions/${id}/activate`, {
method: 'POST',
});
}
@@ -1,20 +1,41 @@
'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 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 {
CodeBlock,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function ConfigVersionSnapshotModal({
version,
onClose,
}: {
version: ConfigVersionItem | null;
version: ConfigVersionSummary | null;
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 (
<AppModal
isOpen={Boolean(version)}
@@ -30,7 +51,14 @@ export function ConfigVersionSnapshotModal({
</div>
}
>
{version ? (
{!version ? null : versionDetailQuery.isLoading && !versionDetail ? (
<LoadingState />
) : versionDetailQuery.isError ? (
<ErrorState
title="配置版本详情加载失败"
description={getErrorMessage(versionDetailQuery.error)}
/>
) : versionDetail ? (
<div className="space-y-5">
<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">
@@ -38,7 +66,7 @@ export function ConfigVersionSnapshotModal({
Checksum
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{version.checksum}
{versionDetail.checksum}
</p>
</div>
<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 className="mt-2 text-sm text-[var(--foreground-primary)]">
{version.created_by || '系统'}
{versionDetail.created_by || '系统'}
</p>
</div>
<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 className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatDateTime(version.created_at)}
{formatDateTime(versionDetail.created_at)}
</p>
</div>
</div>
@@ -64,23 +92,25 @@ export function ConfigVersionSnapshotModal({
快照 JSON
</p>
<CodeBlock className="max-h-96 whitespace-pre-wrap">
{version.snapshot_json}
{versionDetail.snapshot_json}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
主配置
</p>
<CodeBlock className="max-h-96 whitespace-pre-wrap">
{version.main_config}
{versionDetail.main_config}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
路由配置
</p>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{version.rendered_config}
{versionDetail.rendered_config}
</CodeBlock>
</div>
</div>
@@ -12,6 +12,7 @@ import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
activateConfigVersion,
getActiveConfigVersion,
getConfigVersionDiff,
getConfigVersionPreview,
getConfigVersions,
@@ -22,7 +23,8 @@ import type {
ConfigOptionDiffItem,
ConfigDiffResult,
ConfigPreviewResult,
ConfigVersionItem,
ConfigVersionDetail,
ConfigVersionSummary,
SupportFile,
} from '@/features/config-versions/types';
import {
@@ -168,14 +170,20 @@ function OptionDiffTable({ items }: { items: ConfigOptionDiffItem[] }) {
function PublishPreviewCard({
preview,
diff,
activeVersion,
activeVersionMeta,
activeVersionDetail,
isActiveVersionDetailLoading,
activeVersionDetailError,
isPublishing,
onConfirm,
onCancel,
}: {
preview: ConfigPreviewResult;
diff: ConfigDiffResult;
activeVersion: ConfigVersionItem | null;
activeVersionMeta: ConfigVersionSummary | null;
activeVersionDetail: ConfigVersionDetail | null;
isActiveVersionDetailLoading: boolean;
activeVersionDetailError: string | null;
isPublishing: boolean;
onConfirm: () => void;
onCancel: () => void;
@@ -263,18 +271,29 @@ function PublishPreviewCard({
<OptionDiffTable items={diff.changed_option_details} />
</div>
{diff.main_config_changed && activeVersion ? (
{diff.main_config_changed && activeVersionMeta ? (
<div className="grid gap-5 xl:grid-cols-2">
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
Current Active Main Config
</p>
<StatusBadge label={activeVersion.version} variant="info" />
<StatusBadge label={activeVersionMeta.version} variant="info" />
</div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{activeVersion.main_config}
</CodeBlock>
{isActiveVersionDetailLoading ? (
<LoadingState />
) : 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 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,
[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({
mutationFn: publishConfigVersion,
@@ -455,7 +482,7 @@ export function ConfigVersionsPage() {
}
};
const handleActivate = (version: ConfigVersionItem) => {
const handleActivate = (version: ConfigVersionSummary) => {
if (version.is_active) {
return;
}
@@ -493,7 +520,14 @@ export function ConfigVersionsPage() {
<PublishPreviewCard
preview={publishPreview.preview}
diff={publishPreview.diff}
activeVersion={activeVersion}
activeVersionMeta={activeVersion}
activeVersionDetail={activeVersionDetailQuery.data ?? null}
isActiveVersionDetailLoading={activeVersionDetailQuery.isLoading}
activeVersionDetailError={
activeVersionDetailQuery.isError
? getErrorMessage(activeVersionDetailQuery.error)
: null
}
isPublishing={publishMutation.isPending}
onConfirm={() => publishMutation.mutate()}
onCancel={() => setPublishPreview(null)}
@@ -1,16 +1,19 @@
export interface ConfigVersionItem {
export interface ConfigVersionSummary {
id: number;
version: string;
snapshot_json: string;
main_config: string;
rendered_config: string;
support_files_json: string;
checksum: string;
is_active: boolean;
created_by: 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 {
path: string;
content: string;
@@ -17,7 +17,7 @@ import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { getConfigVersions } from '@/features/config-versions/api/config-versions';
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 {
deleteNode,
@@ -416,7 +416,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
}
};
const activeConfigVersion = useMemo<ConfigVersionItem | null>(() => {
const activeConfigVersion = useMemo<ConfigVersionSummary | null>(() => {
return (
(configVersionsQuery.data ?? []).find((item) => item.is_active) ?? null
);