feat: 实现客户端实例上报功能

- 新增 ClientInstance 模型,记录客户端 IP 与配置项的关联
- 配置拉取接口(fetchAll/fetchChanged)自动记录客户端信息
- 配置列表页面展示每个配置的在线客户端数量
- 点击可查看具体 IP 地址、拉取方式、最后拉取时间
- 超过 5 分钟未拉取的记录自动清理
- 注释掉 /api/ping 接口
This commit is contained in:
2026-04-27 21:07:37 +08:00
parent b4b38fa0c7
commit ed94f53306
9 changed files with 167 additions and 5 deletions
+5 -1
View File
@@ -42,6 +42,7 @@ func main() {
userHandler := handler.NewUserHandler(permService) userHandler := handler.NewUserHandler(permService)
groupHandler := handler.NewGroupHandler() groupHandler := handler.NewGroupHandler()
profileHandler := handler.NewProfileHandler() profileHandler := handler.NewProfileHandler()
clientHandler := handler.NewClientHandler()
// init gin // init gin
r := gin.Default() r := gin.Default()
@@ -101,6 +102,9 @@ func main() {
admin.POST("/profile/delete/:id", profileHandler.Delete) admin.POST("/profile/delete/:id", profileHandler.Delete)
admin.POST("/profile/switch/:id", profileHandler.Switch) admin.POST("/profile/switch/:id", profileHandler.Switch)
admin.GET("/profile/options", profileHandler.Options) admin.GET("/profile/options", profileHandler.Options)
// client instance management
admin.GET("/client/listByConfig", clientHandler.ListByConfigId)
} }
// user info routes (JWT auth required) // user info routes (JWT auth required)
@@ -116,7 +120,7 @@ func main() {
{ {
api.POST("/fetchAllConfigs", configApiHandler.FetchAllConfigs) api.POST("/fetchAllConfigs", configApiHandler.FetchAllConfigs)
api.POST("/fetchChangedConfigs", configApiHandler.FetchChangedConfigs) api.POST("/fetchChangedConfigs", configApiHandler.FetchChangedConfigs)
api.GET("/ping", configApiHandler.Ping) // api.GET("/ping", configApiHandler.Ping)
} }
// start server // start server
+1
View File
@@ -33,6 +33,7 @@ func Init() error {
new(model.AppConfigHistory), new(model.AppConfigHistory),
new(model.User), new(model.User),
new(model.UserPermission), new(model.UserPermission),
new(model.ClientInstance),
); err != nil { ); err != nil {
return err return err
} }
+29
View File
@@ -0,0 +1,29 @@
package handler
import (
"kenaito-conf/server/internal/dao"
"kenaito-conf/server/internal/model"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type ClientHandler struct{}
func NewClientHandler() *ClientHandler {
return &ClientHandler{}
}
func (h *ClientHandler) ListByConfigId(c *gin.Context) {
configId, _ := strconv.Atoi(c.Query("configId"))
if configId == 0 {
c.JSON(http.StatusBadRequest, model.WrapperResponse{Code: 400, Msg: "参数错误"})
return
}
var instances []model.ClientInstance
dao.Engine.Where("app_config_id = ?", configId).OrderBy("last_fetch_at DESC").Find(&instances)
c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: instances})
}
+23 -1
View File
@@ -80,9 +80,31 @@ func (h *ConfigHandler) List(c *gin.Context) {
total, _ := dao.Engine.Where(whereClause).Count(&model.AppConfig{}) total, _ := dao.Engine.Where(whereClause).Count(&model.AppConfig{})
dao.Engine.Where(whereClause).Limit(req.PageSize, (req.PageNo-1)*req.PageSize).Find(&configs) dao.Engine.Where(whereClause).Limit(req.PageSize, (req.PageNo-1)*req.PageSize).Find(&configs)
// build app names // build app names and client counts
configIds := make([]int, 0, len(configs))
for i := range configs { for i := range configs {
configs[i].AppNames = h.buildAppNames(&configs[i]) configs[i].AppNames = h.buildAppNames(&configs[i])
configIds = append(configIds, configs[i].Id)
}
// batch query client counts
if len(configIds) > 0 {
type countResult struct {
AppConfigId int `xorm:"app_config_id"`
Cnt int `xorm:"cnt"`
}
var results []countResult
dao.Engine.Table("client_instance").
Select("app_config_id, COUNT(DISTINCT ip) as cnt").
In("app_config_id", configIds).
GroupBy("app_config_id").
Find(&results)
countMap := make(map[int]int, len(results))
for _, r := range results {
countMap[r.AppConfigId] = r.Cnt
}
for i := range configs {
configs[i].ClientCount = countMap[configs[i].Id]
}
} }
c.JSON(http.StatusOK, model.PageResult{PageNo: req.PageNo, PageSize: req.PageSize, Total: total, Data: configs}) c.JSON(http.StatusOK, model.PageResult{PageNo: req.PageNo, PageSize: req.PageSize, Total: total, Data: configs})
+48 -2
View File
@@ -70,6 +70,7 @@ func (h *ConfigApiHandler) FetchAllConfigs(c *gin.Context) {
} }
result := make(map[string]interface{}) result := make(map[string]interface{})
var configIds []int
// load global configs // load global configs
if !req.IgnoreGlobal && app.GroupId != nil { if !req.IgnoreGlobal && app.GroupId != nil {
@@ -77,6 +78,7 @@ func (h *ConfigApiHandler) FetchAllConfigs(c *gin.Context) {
dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND group_id = ? AND (app_id is null OR app_id = 0)", dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND group_id = ? AND (app_id is null OR app_id = 0)",
req.Env, version, *app.GroupId).Find(&globalConfigs) req.Env, version, *app.GroupId).Find(&globalConfigs)
for _, cfg := range globalConfigs { for _, cfg := range globalConfigs {
configIds = append(configIds, cfg.Id)
kvMap := h.parseService.ParseConfigToKVMap(&cfg) kvMap := h.parseService.ParseConfigToKVMap(&cfg)
for k, v := range kvMap { for k, v := range kvMap {
result[k] = v result[k] = v
@@ -90,6 +92,7 @@ func (h *ConfigApiHandler) FetchAllConfigs(c *gin.Context) {
dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ?", dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ?",
req.Env, version, *app.ParentId).Find(&parentConfigs) req.Env, version, *app.ParentId).Find(&parentConfigs)
for _, cfg := range parentConfigs { for _, cfg := range parentConfigs {
configIds = append(configIds, cfg.Id)
kvMap := h.parseService.ParseConfigToKVMap(&cfg) kvMap := h.parseService.ParseConfigToKVMap(&cfg)
for k, v := range kvMap { for k, v := range kvMap {
result[k] = v result[k] = v
@@ -102,6 +105,7 @@ func (h *ConfigApiHandler) FetchAllConfigs(c *gin.Context) {
dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ?", dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ?",
req.Env, version, app.Id).Find(&appConfigs) req.Env, version, app.Id).Find(&appConfigs)
for _, cfg := range appConfigs { for _, cfg := range appConfigs {
configIds = append(configIds, cfg.Id)
kvMap := h.parseService.ParseConfigToKVMap(&cfg) kvMap := h.parseService.ParseConfigToKVMap(&cfg)
for k, v := range kvMap { for k, v := range kvMap {
result[k] = v result[k] = v
@@ -115,6 +119,9 @@ func (h *ConfigApiHandler) FetchAllConfigs(c *gin.Context) {
result["latest.fetch.timestamp"] = strconv.FormatInt(time.Now().UnixMilli(), 10) result["latest.fetch.timestamp"] = strconv.FormatInt(time.Now().UnixMilli(), 10)
// record client instances asynchronously
go h.recordClientInstances(c.ClientIP(), configIds, req.AppName, req.Env, "fetchAll")
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
} }
@@ -156,6 +163,7 @@ func (h *ConfigApiHandler) FetchChangedConfigs(c *gin.Context) {
lastUpdateTime := time.UnixMilli(req.LastTime) lastUpdateTime := time.UnixMilli(req.LastTime)
result := make(map[string]string) result := make(map[string]string)
var configIds []int
// load global changed configs // load global changed configs
if !req.IgnoreGlobal && app.GroupId != nil { if !req.IgnoreGlobal && app.GroupId != nil {
@@ -163,6 +171,7 @@ func (h *ConfigApiHandler) FetchChangedConfigs(c *gin.Context) {
dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND group_id = ? AND (app_id is null OR app_id = 0) AND updated_at > ?", 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) req.Env, version, *app.GroupId, lastUpdateTime).Find(&globalConfigs)
for _, cfg := range globalConfigs { for _, cfg := range globalConfigs {
configIds = append(configIds, cfg.Id)
h.addChangedConfigs(result, &cfg, req.FetchAll) h.addChangedConfigs(result, &cfg, req.FetchAll)
} }
} }
@@ -173,6 +182,7 @@ func (h *ConfigApiHandler) FetchChangedConfigs(c *gin.Context) {
dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ? AND updated_at > ?", dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ? AND updated_at > ?",
req.Env, version, *app.ParentId, lastUpdateTime).Find(&parentConfigs) req.Env, version, *app.ParentId, lastUpdateTime).Find(&parentConfigs)
for _, cfg := range parentConfigs { for _, cfg := range parentConfigs {
configIds = append(configIds, cfg.Id)
h.addChangedConfigs(result, &cfg, req.FetchAll) h.addChangedConfigs(result, &cfg, req.FetchAll)
} }
} }
@@ -182,11 +192,15 @@ func (h *ConfigApiHandler) FetchChangedConfigs(c *gin.Context) {
dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ? AND updated_at > ?", dao.Engine.Where("env = ? AND version = ? AND enabled = 1 AND app_id = ? AND updated_at > ?",
req.Env, version, app.Id, lastUpdateTime).Find(&appConfigs) req.Env, version, app.Id, lastUpdateTime).Find(&appConfigs)
for _, cfg := range appConfigs { for _, cfg := range appConfigs {
configIds = append(configIds, cfg.Id)
h.addChangedConfigs(result, &cfg, req.FetchAll) h.addChangedConfigs(result, &cfg, req.FetchAll)
} }
result["latest.fetch.timestamp"] = strconv.FormatInt(time.Now().UnixMilli(), 10) result["latest.fetch.timestamp"] = strconv.FormatInt(time.Now().UnixMilli(), 10)
// record client instances asynchronously
go h.recordClientInstances(c.ClientIP(), configIds, req.AppName, req.Env, "fetchChanged")
c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: result}) c.JSON(http.StatusOK, model.WrapperResponse{Code: 0, Data: result})
} }
@@ -197,6 +211,38 @@ func (h *ConfigApiHandler) addChangedConfigs(result map[string]string, config *m
} }
} }
func (h *ConfigApiHandler) Ping(c *gin.Context) { func (h *ConfigApiHandler) recordClientInstances(ip string, configIds []int, appName, env, fetchType string) {
c.String(http.StatusOK, "200") if len(configIds) == 0 || ip == "" {
return
}
now := time.Now()
for _, configId := range configIds {
var existing model.ClientInstance
has, _ := dao.Engine.Where("app_config_id = ? AND ip = ?", configId, ip).Get(&existing)
if has {
existing.LastFetchAt = &now
existing.LastFetchType = fetchType
existing.UpdatedAt = &now
dao.Engine.ID(existing.Id).Cols("last_fetch_at", "last_fetch_type", "updated_at").Update(&existing)
} else {
instance := model.ClientInstance{
AppConfigId: configId,
AppName: appName,
Env: env,
IP: ip,
LastFetchAt: &now,
LastFetchType: fetchType,
CreatedAt: &now,
UpdatedAt: &now,
}
dao.Engine.Insert(&instance)
}
}
// cleanup records older than 5 minutes
fiveMinutesAgo := now.Add(-5 * time.Minute)
dao.Engine.Where("last_fetch_at < ?", fiveMinutesAgo).Delete(&model.ClientInstance{})
} }
// func (h *ConfigApiHandler) Ping(c *gin.Context) {
// c.String(http.StatusOK, "200")
// }
+1
View File
@@ -19,6 +19,7 @@ type AppConfig struct {
UpdatedBy string `xorm:"varchar(32)" json:"updatedBy"` UpdatedBy string `xorm:"varchar(32)" json:"updatedBy"`
Global bool `xorm:"-" json:"global"` Global bool `xorm:"-" json:"global"`
AppNames string `xorm:"-" json:"appNames,omitempty"` AppNames string `xorm:"-" json:"appNames,omitempty"`
ClientCount int `xorm:"-" json:"clientCount,omitempty"`
} }
type AppConfigHistory struct { type AppConfigHistory struct {
+15
View File
@@ -0,0 +1,15 @@
package model
import "time"
type ClientInstance struct {
Id int `xorm:"int autoincr pk" json:"id"`
AppConfigId int `xorm:"int index" json:"appConfigId"`
AppName string `xorm:"varchar(64) index" json:"appName"`
Env string `xorm:"varchar(32)" json:"env"`
IP string `xorm:"varchar(64)" json:"ip"`
LastFetchAt *time.Time `xorm:"datetime" json:"lastFetchAt"`
LastFetchType string `xorm:"varchar(16)" json:"lastFetchType"`
CreatedAt *time.Time `xorm:"datetime" json:"createdAt"`
UpdatedAt *time.Time `xorm:"datetime" json:"updatedAt"`
}
+9
View File
@@ -0,0 +1,9 @@
import request from './request'
export function getClientListByConfig(configId) {
return request({
url: '/admin/client/listByConfig',
method: 'get',
params: { configId }
})
}
+36 -1
View File
@@ -25,6 +25,14 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="version" label="版本" width="100"></el-table-column> <el-table-column prop="version" label="版本" width="100"></el-table-column>
<el-table-column prop="clientCount" label="在线客户端" width="120">
<template slot-scope="scope">
<el-button v-if="scope.row.clientCount > 0" type="text" @click="showClients(scope.row)">
{{ scope.row.clientCount }} 个
</el-button>
<span v-else style="color: #909399">无</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200"> <el-table-column label="操作" width="200">
<template slot-scope="scope"> <template slot-scope="scope">
<el-button type="text" @click="showHistory(scope.row)">变更历史</el-button> <el-button type="text" @click="showHistory(scope.row)">变更历史</el-button>
@@ -92,11 +100,29 @@
</div> </div>
</div> </div>
</el-dialog> </el-dialog>
<el-dialog title="在线客户端" :visible.sync="clientVisible" width="700px">
<el-table :data="clientList" border stripe>
<el-table-column prop="ip" label="IP 地址" width="160"></el-table-column>
<el-table-column prop="lastFetchType" label="拉取方式" width="120">
<template slot-scope="scope">
<span v-if="scope.row.lastFetchType === 'fetchAll'">全量拉取</span>
<span v-else>增量拉取</span>
</template>
</el-table-column>
<el-table-column label="最后拉取时间">
<template slot-scope="scope">
{{ formatTime(scope.row.lastFetchAt) }}
</template>
</el-table-column>
</el-table>
</el-dialog>
</el-card> </el-card>
</template> </template>
<script> <script>
import { getConfigList, deleteConfig, getConfigHistory, rollbackConfig, getConfig } from '../../api/config' import { getConfigList, deleteConfig, getConfigHistory, rollbackConfig, getConfig } from '../../api/config'
import { getClientListByConfig } from '../../api/client'
import request from '../../api/request' import request from '../../api/request'
function computeDiff(oldText, newText) { function computeDiff(oldText, newText) {
@@ -204,7 +230,9 @@ export default {
contentVisible: false, contentVisible: false,
currentHistory: {}, currentHistory: {},
leftLines: [], leftLines: [],
rightLines: [] rightLines: [],
clientVisible: false,
clientList: []
} }
}, },
created() { created() {
@@ -305,6 +333,13 @@ export default {
this.historyVisible = false this.historyVisible = false
this.loadData() this.loadData()
} catch (e) {} } catch (e) {}
},
async showClients(row) {
try {
const res = await getClientListByConfig(row.id)
this.clientList = res.data || []
this.clientVisible = true
} catch (e) {}
} }
} }
} }