From 92c09df1030544faafabd611fc69be9968e3bb0e Mon Sep 17 00:00:00 2001 From: Odboy Date: Sun, 6 Sep 2026 21:04:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E5=A2=9E=E5=BC=BA=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=8E=86=E5=8F=B2=E7=AE=A1=E7=90=86=E5=B9=B6=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=95=B0=E6=8D=AE=E5=BA=93=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 dao.Now() 函数获取数据库毫秒精度时间,避免多实例时钟偏差 - 增量同步查询增加上界(updated_at <= upperBound)防止竞态条件 - 配置创建、更新、删除、回滚统一使用数据库时间替代 time.Now() - 配置历史新增 operate 字段记录操作类型(update/delete/rollback) - 删除配置和回滚操作前增加历史快照记录 - 新增 DeleteHistory 接口支持手动删除单条历史记录 - 移除 GetHistory 的 Limit(5) 限制,返回完整历史列表 - 删除应用时同步更新关联配置的 updated_at 以便增量同步识别 - version 字段改为必填,前后端同步增加校验规则 - 前端历史列表增加操作类型列、删除按钮及表格最大高度限制 - 数据库表结构 datetime 升级为 datetime(3) 支持毫秒精度 --- server/cmd/main.go | 1 + server/internal/dao/engine.go | 23 ++++++++ server/internal/handler/app.go | 14 ++++- server/internal/handler/config.go | 80 +++++++++++++++++++++++--- server/internal/handler/config_api.go | 37 ++++++++---- server/internal/model/app_config.go | 61 ++++++++++---------- sql/kenaito_conf.sql | 5 +- web/src/api/config.js | 7 +++ web/src/views/config/AllConfigList.vue | 24 +++++++- web/src/views/config/ConfigAdd.vue | 3 +- web/src/views/config/ConfigEdit.vue | 8 ++- web/src/views/config/ConfigList.vue | 24 +++++++- 12 files changed, 224 insertions(+), 63 deletions(-) diff --git a/server/cmd/main.go b/server/cmd/main.go index 67cfed3..ef11340 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -67,6 +67,7 @@ func main() { admin.POST("/config/update", configHandler.Update) admin.POST("/config/delete/:id", configHandler.Delete) admin.GET("/config/configHistories/:id", configHandler.GetHistory) + admin.POST("/config/history/delete/:id", configHandler.DeleteHistory) admin.POST("/config/rollback/:id", configHandler.Rollback) // app management diff --git a/server/internal/dao/engine.go b/server/internal/dao/engine.go index 6c1660d..ef77ae7 100644 --- a/server/internal/dao/engine.go +++ b/server/internal/dao/engine.go @@ -1,6 +1,7 @@ package dao import ( + "errors" "kenaito-conf/server/internal/config" "kenaito-conf/server/internal/model" "kenaito-conf/server/internal/util" @@ -98,3 +99,25 @@ func initSeedData() error { return nil } + +// Now 返回数据库当前时间(毫秒精度),用于增量同步游标和 updated_at 写入。 +// 统一使用数据库时间可以避免多实例应用服务器之间的时钟偏差。 +func Now() (time.Time, error) { + rows, err := Engine.DB().Query("SELECT NOW(3)") + if err != nil { + return time.Time{}, err + } + defer rows.Close() + + var now time.Time + if !rows.Next() { + if err := rows.Err(); err != nil { + return time.Time{}, err + } + return time.Time{}, errors.New("no database time result") + } + if err := rows.Scan(&now); err != nil { + return time.Time{}, err + } + return now, nil +} diff --git a/server/internal/handler/app.go b/server/internal/handler/app.go index 77a5cd5..c05e55e 100644 --- a/server/internal/handler/app.go +++ b/server/internal/handler/app.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "encoding/hex" "kenaito-conf/server/internal/dao" + "kenaito-conf/server/internal/logger" "kenaito-conf/server/internal/middleware" "kenaito-conf/server/internal/model" "net/http" @@ -258,14 +259,21 @@ func (h *AppHandler) Delete(c *gin.Context) { return } + now, err := dao.Now() + if err != nil { + logger.SugaredLogger.Errorf("[AppHandler.Delete] get database time error: %v", err) + c.JSON(http.StatusInternalServerError, model.WrapperResponse{Code: 500, Msg: "删除失败"}) + return + } + app.Enabled = false app.UpdatedBy = userInfo.Name - now := time.Now() app.UpdatedAt = &now dao.Engine.ID(app.Id).Cols("enabled", "updated_by", "updated_at").Update(&app) - // soft delete related configs - dao.Engine.Where("app_id = ?", id).Cols("enabled").Update(&model.AppConfig{Enabled: false}) + // soft delete related configs,同时更新 updated_at 让增量同步识别到删除 + dao.Engine.Where("app_id = ?", id).Cols("enabled", "updated_at"). + Update(&model.AppConfig{Enabled: false, UpdatedAt: &now}) c.JSON(http.StatusOK, model.WrapperResponse{Code: 0}) } diff --git a/server/internal/handler/config.go b/server/internal/handler/config.go index 8445c28..bb2209d 100644 --- a/server/internal/handler/config.go +++ b/server/internal/handler/config.go @@ -2,6 +2,7 @@ package handler import ( "kenaito-conf/server/internal/dao" + "kenaito-conf/server/internal/logger" "kenaito-conf/server/internal/middleware" "kenaito-conf/server/internal/model" "kenaito-conf/server/internal/service" @@ -162,7 +163,7 @@ type AddConfigRequest struct { GroupId *int `json:"groupId"` AppId *int `json:"appId"` Env string `json:"env" binding:"required"` - Version string `json:"version"` + Version string `json:"version" binding:"required"` Name string `json:"name"` Type int `json:"type" binding:"required"` Contents string `json:"contents"` @@ -201,6 +202,13 @@ func (h *ConfigHandler) Add(c *gin.Context) { } } + now, err := dao.Now() + if err != nil { + logger.SugaredLogger.Errorf("[ConfigHandler.Add] get database time error: %v", err) + c.JSON(http.StatusInternalServerError, model.WrapperResponse{Code: 500, Msg: "保存失败"}) + return + } + config := model.AppConfig{ GroupId: req.GroupId, AppId: req.AppId, @@ -210,9 +218,9 @@ func (h *ConfigHandler) Add(c *gin.Context) { Type: req.Type, Contents: req.Contents, Enabled: true, - CreatedAt: func() *time.Time { t := time.Now(); return &t }(), + CreatedAt: &now, CreatedBy: userInfo.Name, - UpdatedAt: func() *time.Time { t := time.Now(); return &t }(), + UpdatedAt: &now, UpdatedBy: userInfo.Name, } @@ -257,13 +265,18 @@ func (h *ConfigHandler) Update(c *gin.Context) { } // save history - h.saveHistory(&config, userInfo.Name) + h.saveHistory(&config, userInfo.Name, "update") originalContents := config.Contents config.Contents = req.Contents config.Version = req.Version config.UpdatedBy = userInfo.Name - now := time.Now() + now, err := dao.Now() + if err != nil { + logger.SugaredLogger.Errorf("[ConfigHandler.Update] get database time error: %v", err) + c.JSON(http.StatusInternalServerError, model.WrapperResponse{Code: 500, Msg: "保存失败"}) + return + } config.UpdatedAt = &now dao.Engine.ID(config.Id).Cols("contents", "version", "updated_by", "updated_at").Update(&config) @@ -289,9 +302,17 @@ func (h *ConfigHandler) Delete(c *gin.Context) { return } + // 记录删除前的配置内容 + h.saveHistory(&config, userInfo.Name, "delete") + config.Enabled = false config.UpdatedBy = userInfo.Name - now := time.Now() + now, err := dao.Now() + if err != nil { + logger.SugaredLogger.Errorf("[ConfigHandler.Delete] get database time error: %v", err) + c.JSON(http.StatusInternalServerError, model.WrapperResponse{Code: 500, Msg: "删除失败"}) + return + } config.UpdatedAt = &now dao.Engine.ID(config.Id).Cols("enabled", "updated_by", "updated_at").Update(&config) @@ -301,7 +322,7 @@ func (h *ConfigHandler) Delete(c *gin.Context) { func (h *ConfigHandler) GetHistory(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var histories []model.AppConfigHistory - dao.Engine.Where("origin_id = ?", id).OrderBy("id desc").Limit(5).Find(&histories) + dao.Engine.Where("origin_id = ?", id).OrderBy("id desc").Find(&histories) if len(histories) > 0 { var currentConfig model.AppConfig @@ -315,6 +336,38 @@ func (h *ConfigHandler) GetHistory(c *gin.Context) { c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: histories}) } +// DeleteHistory 手动删除单条配置历史 +func (h *ConfigHandler) DeleteHistory(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + var history model.AppConfigHistory + has, _ := dao.Engine.ID(id).Get(&history) + if !has { + c.JSON(http.StatusOK, model.WrapperResponse{Code: 404, Msg: "历史记录不存在"}) + return + } + + userInfo := middleware.GetUserInfo(c) + var config model.AppConfig + if has, _ := dao.Engine.ID(history.OriginId).Get(&config); has { + if err := h.permService.RequirePermission(userInfo, config.GroupId, config.AppId, "RW"); err != nil { + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: err.Error()}) + return + } + } else if !userInfo.IsSuperAdmin() { + // 原配置已不存在时,只有超级管理员可以清理历史 + c.JSON(http.StatusForbidden, model.WrapperResponse{Code: 403, Msg: "只有超级管理员才能删除该历史记录"}) + return + } + + if _, err := dao.Engine.ID(id).Delete(&model.AppConfigHistory{}); err != nil { + logger.SugaredLogger.Errorf("[ConfigHandler.DeleteHistory] delete history error: %v", err) + c.JSON(http.StatusInternalServerError, model.WrapperResponse{Code: 500, Msg: "删除失败"}) + return + } + + c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: true}) +} + func (h *ConfigHandler) Rollback(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var history model.AppConfigHistory @@ -332,9 +385,17 @@ func (h *ConfigHandler) Rollback(c *gin.Context) { return } + // 记录回滚前的配置内容 + h.saveHistory(&config, userInfo.Name, "rollback") + config.Contents = history.Contents config.UpdatedBy = userInfo.Name - now := time.Now() + now, err := dao.Now() + if err != nil { + logger.SugaredLogger.Errorf("[ConfigHandler.Rollback] get database time error: %v", err) + c.JSON(http.StatusInternalServerError, model.WrapperResponse{Code: 500, Msg: "回滚失败"}) + return + } config.UpdatedAt = &now dao.Engine.ID(config.Id).Cols("contents", "updated_by", "updated_at").Update(&config) } @@ -353,7 +414,7 @@ func (h *ConfigHandler) buildAppNames(config *model.AppConfig) string { return "" } -func (h *ConfigHandler) saveHistory(config *model.AppConfig, operator string) { +func (h *ConfigHandler) saveHistory(config *model.AppConfig, operator, operate string) { history := model.AppConfigHistory{ OriginId: config.Id, AppId: config.AppId, @@ -362,6 +423,7 @@ func (h *ConfigHandler) saveHistory(config *model.AppConfig, operator string) { Version: config.Version, Name: config.Name, Type: config.Type, + Operate: operate, Contents: config.Contents, CreatedAt: func() *time.Time { t := time.Now(); return &t }(), CreatedBy: operator, diff --git a/server/internal/handler/config_api.go b/server/internal/handler/config_api.go index 0bf7849..ec53d4b 100644 --- a/server/internal/handler/config_api.go +++ b/server/internal/handler/config_api.go @@ -91,6 +91,14 @@ func (h *ConfigApiHandler) FetchAllConfigs(c *gin.Context) { return } + // 游标上界取查询前的数据库时间,保证查询期间发生的更新会在下次增量中返回 + upperBound, err := dao.Now() + if err != nil { + logger.SugaredLogger.Errorf("[FetchAllConfigs] get database time error: %v", err) + c.JSON(http.StatusInternalServerError, model.WrapperResponse{Code: 500, Msg: "服务暂时不可用"}) + return + } + result := make(map[string]interface{}) var configIds []int @@ -125,7 +133,7 @@ func (h *ConfigApiHandler) FetchAllConfigs(c *gin.Context) { return } - result["latest.fetch.timestamp"] = strconv.FormatInt(time.Now().UnixMilli(), 10) + result["latest.fetch.timestamp"] = strconv.FormatInt(upperBound.UnixMilli(), 10) // record client instances asynchronously go h.recordClientInstances(getClientIP(c), configIds, req.AppName, req.Env, "fetchAll") @@ -170,6 +178,15 @@ func (h *ConfigApiHandler) FetchChangedConfigs(c *gin.Context) { lastUpdateTime := time.UnixMilli(req.LastTime) + // 以数据库时间为本次查询的固定上界,避免查询与游标生成之间的竞态, + // 同时避免多实例应用服务器的时钟偏差。 + upperBound, err := dao.Now() + if err != nil { + logger.SugaredLogger.Errorf("[FetchChangedConfigs] get database time error: %v", err) + c.JSON(http.StatusInternalServerError, model.WrapperResponse{Code: 500, Msg: "服务暂时不可用"}) + return + } + result := make(map[string]string) var configIds []int @@ -177,8 +194,8 @@ func (h *ConfigApiHandler) FetchChangedConfigs(c *gin.Context) { var deletedKeys []string if !req.IgnoreGlobal && app.GroupId != nil { var deletedGlobalConfigs []model.AppConfig - dao.Engine.Where("env = ? AND version = ? AND enabled = 0 AND group_id = ? AND (app_id is null OR app_id = 0) AND updated_at > ?", - req.Env, version, *app.GroupId, lastUpdateTime).Find(&deletedGlobalConfigs) + dao.Engine.Where("env = ? AND version = ? AND enabled = 0 AND group_id = ? AND (app_id is null OR app_id = 0) AND updated_at > ? AND updated_at <= ?", + req.Env, version, *app.GroupId, lastUpdateTime, upperBound).Find(&deletedGlobalConfigs) for _, cfg := range deletedGlobalConfigs { kvMap := h.parseService.ParseConfigToKVMap(&cfg) for k := range kvMap { @@ -187,8 +204,8 @@ func (h *ConfigApiHandler) FetchChangedConfigs(c *gin.Context) { } } var deletedAppConfigs []model.AppConfig - dao.Engine.Where("env = ? AND version = ? AND enabled = 0 AND app_id = ? AND updated_at > ?", - req.Env, version, app.Id, lastUpdateTime).Find(&deletedAppConfigs) + dao.Engine.Where("env = ? AND version = ? AND enabled = 0 AND app_id = ? AND updated_at > ? AND updated_at <= ?", + req.Env, version, app.Id, lastUpdateTime, upperBound).Find(&deletedAppConfigs) for _, cfg := range deletedAppConfigs { kvMap := h.parseService.ParseConfigToKVMap(&cfg) for k := range kvMap { @@ -202,8 +219,8 @@ func (h *ConfigApiHandler) FetchChangedConfigs(c *gin.Context) { // load global changed configs if !req.IgnoreGlobal && app.GroupId != nil { var globalConfigs []model.AppConfig - dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND group_id = ? AND (app_id is null OR app_id = 0) AND updated_at > ?", - req.Env, version, *app.GroupId, lastUpdateTime).Find(&globalConfigs) + dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND group_id = ? AND (app_id is null OR app_id = 0) AND updated_at > ? AND updated_at <= ?", + req.Env, version, *app.GroupId, lastUpdateTime, upperBound).Find(&globalConfigs) for _, cfg := range globalConfigs { configIds = append(configIds, cfg.Id) h.addChangedConfigs(result, &cfg, req.FetchAll) @@ -212,14 +229,14 @@ func (h *ConfigApiHandler) FetchChangedConfigs(c *gin.Context) { // load app changed configs var appConfigs1 []model.AppConfig - dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ? AND updated_at > ?", - req.Env, version, app.Id, lastUpdateTime).Find(&appConfigs1) + dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ? AND updated_at > ? AND updated_at <= ?", + req.Env, version, app.Id, lastUpdateTime, upperBound).Find(&appConfigs1) for _, cfg := range appConfigs1 { configIds = append(configIds, cfg.Id) h.addChangedConfigs(result, &cfg, req.FetchAll) } - result["latest.fetch.timestamp"] = strconv.FormatInt(time.Now().UnixMilli(), 10) + result["latest.fetch.timestamp"] = strconv.FormatInt(upperBound.UnixMilli(), 10) // query all config ids for this app to record client instances var allConfigIds []int diff --git a/server/internal/model/app_config.go b/server/internal/model/app_config.go index 87861f6..2f707b7 100644 --- a/server/internal/model/app_config.go +++ b/server/internal/model/app_config.go @@ -3,23 +3,23 @@ package model import "time" type AppConfig struct { - Id int `xorm:"int autoincr pk" json:"id"` - GroupId *int `xorm:"int" json:"groupId"` - AppId *int `xorm:"int" json:"appId"` - AppCode string `xorm:"varchar(32)" json:"appCode"` - Env string `xorm:"varchar(16)" json:"env"` - Version string `xorm:"varchar(32)" json:"version"` - Name string `xorm:"varchar(32)" json:"name"` - Type int `xorm:"smallint(1)" json:"type"` // 1:file, 2:kv, 3:json - Contents string `xorm:"text" json:"contents"` - Enabled bool `xorm:"tinyint(1) default 1" json:"enabled"` - CreatedAt *time.Time `xorm:"datetime" json:"createdAt"` - CreatedBy string `xorm:"varchar(32)" json:"createdBy"` - UpdatedAt *time.Time `xorm:"datetime" json:"updatedAt"` - UpdatedBy string `xorm:"varchar(32)" json:"updatedBy"` - Global bool `xorm:"-" json:"global"` - AppNames string `xorm:"-" json:"appNames,omitempty"` - ClientCount int `xorm:"-" json:"clientCount,omitempty"` + Id int `xorm:"int autoincr pk" json:"id"` + GroupId *int `xorm:"int" json:"groupId"` + AppId *int `xorm:"int" json:"appId"` + AppCode string `xorm:"varchar(32)" json:"appCode"` + Env string `xorm:"varchar(16)" json:"env"` + Version string `xorm:"varchar(32)" json:"version"` + Name string `xorm:"varchar(32)" json:"name"` + Type int `xorm:"smallint(1)" json:"type"` // 1:file, 2:kv, 3:json + Contents string `xorm:"text" json:"contents"` + Enabled bool `xorm:"tinyint(1) default 1" json:"enabled"` + CreatedAt *time.Time `xorm:"datetime(3)" json:"createdAt"` + CreatedBy string `xorm:"varchar(32)" json:"createdBy"` + UpdatedAt *time.Time `xorm:"datetime(3)" json:"updatedAt"` + UpdatedBy string `xorm:"varchar(32)" json:"updatedBy"` + Global bool `xorm:"-" json:"global"` + AppNames string `xorm:"-" json:"appNames,omitempty"` + ClientCount int `xorm:"-" json:"clientCount,omitempty"` } func (AppConfig) TableName() string { @@ -27,19 +27,20 @@ func (AppConfig) TableName() string { } type AppConfigHistory struct { - Id int `xorm:"int autoincr pk" json:"id"` - OriginId int `xorm:"int" json:"originId"` - AppId *int `xorm:"int" json:"appId"` - AppName string `xorm:"varchar(32)" json:"appName"` - AppNames string `xorm:"varchar(64)" json:"appNames"` - Env string `xorm:"varchar(16)" json:"env"` - Version string `xorm:"varchar(32)" json:"version"` - Name string `xorm:"varchar(32)" json:"name"` - Type int `xorm:"smallint(1)" json:"type"` - Contents string `xorm:"text" json:"contents"` - ActiveContents string `xorm:"-" json:"activeContents,omitempty"` - CreatedAt *time.Time `xorm:"datetime" json:"createdAt"` - CreatedBy string `xorm:"varchar(32)" json:"createdBy"` + Id int `xorm:"int autoincr pk" json:"id"` + OriginId int `xorm:"int" json:"originId"` + AppId *int `xorm:"int" json:"appId"` + AppName string `xorm:"varchar(32)" json:"appName"` + AppNames string `xorm:"varchar(64)" json:"appNames"` + Env string `xorm:"varchar(16)" json:"env"` + Version string `xorm:"varchar(32)" json:"version"` + Name string `xorm:"varchar(32)" json:"name"` + Type int `xorm:"smallint(1)" json:"type"` + Operate string `xorm:"varchar(16)" json:"operate"` // update, delete, rollback + Contents string `xorm:"text" json:"contents"` + ActiveContents string `xorm:"-" json:"activeContents,omitempty"` + CreatedAt *time.Time `xorm:"datetime" json:"createdAt"` + CreatedBy string `xorm:"varchar(32)" json:"createdBy"` } func (AppConfigHistory) TableName() string { diff --git a/sql/kenaito_conf.sql b/sql/kenaito_conf.sql index 28dcb98..c2e8672 100644 --- a/sql/kenaito_conf.sql +++ b/sql/kenaito_conf.sql @@ -99,9 +99,9 @@ CREATE TABLE `app_config` ( `type` smallint DEFAULT NULL, `contents` text, `enabled` tinyint(1) DEFAULT '1', - `created_at` datetime DEFAULT NULL, + `created_at` datetime(3) DEFAULT NULL, `created_by` varchar(32) DEFAULT NULL, - `updated_at` datetime DEFAULT NULL, + `updated_at` datetime(3) DEFAULT NULL, `updated_by` varchar(32) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8mb3; @@ -150,6 +150,7 @@ CREATE TABLE `app_config_history` ( `version` varchar(32) DEFAULT NULL, `name` varchar(32) DEFAULT NULL, `type` smallint DEFAULT NULL, + `operate` varchar(16) DEFAULT NULL, `contents` text, `created_at` datetime DEFAULT NULL, `created_by` varchar(32) DEFAULT NULL, diff --git a/web/src/api/config.js b/web/src/api/config.js index 7ae7a03..f39405c 100644 --- a/web/src/api/config.js +++ b/web/src/api/config.js @@ -51,3 +51,10 @@ export function rollbackConfig(id) { method: 'post' }) } + +export function deleteConfigHistory(id) { + return request({ + url: `/admin/config/history/delete/${id}`, + method: 'post' + }) +} diff --git a/web/src/views/config/AllConfigList.vue b/web/src/views/config/AllConfigList.vue index c44fd7a..9a04936 100644 --- a/web/src/views/config/AllConfigList.vue +++ b/web/src/views/config/AllConfigList.vue @@ -62,9 +62,17 @@ > - + + + +