feat: 新增日志中心、功能优化与代码重构
- 新增 log_datasource.go / log_proxy.go / user_api_token.go 后端控制器 - 新增 log_datasource.go model - 新增 monitor/log-center.ts API 模块 - 新增 LogCenter 前端组件及页面 - 重构 deployment 相关组件 (HPDA/UpdateImageModal/bundle-yaml 等) - 新增 ResetPasswordDialog / code-snippet-block / copy-button 等 UI 组件 - 重构多个 k8s 相关组件 (TopologyView/ResourceVisualEditor/PodFileBrowser 等) - 更新多个 API 和工具函数 (auth.ts/monitor/index.ts/request.ts 等) - 更新静态资源 (index.html + 新 JS/CSS bundle) - 删除废弃静态资源文件
This commit is contained in:
1 parent
5ca0e6d83a
commit
8d46e8f352
197 files changed
+5014
-815
No files matched your search
@@ -224,10 +224,11 @@ func (h *KubernetesHandler) GetClusterDetail(c *gin.Context) {
|
||||
"description": cluster.Description,
|
||||
"has_token": cluster.Token != "",
|
||||
"has_kube_config": cluster.KubeConfig != "",
|
||||
"node_count": cluster.NodeCount,
|
||||
"pod_count": cluster.PodCount,
|
||||
"service_count": cluster.ServiceCount,
|
||||
"node_count": cluster.NodeCount,
|
||||
"pod_count": cluster.PodCount,
|
||||
"service_count": cluster.ServiceCount,
|
||||
"deployment_count": cluster.DeploymentCount,
|
||||
"ingress_count": cluster.IngressCount,
|
||||
"cpu_usage": cluster.CPUUsage,
|
||||
"memory_usage": cluster.MemoryUsage,
|
||||
"labels": cluster.Labels,
|
||||
@@ -287,7 +288,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
client, err := k8sclient.NewClient(cluster.KubeConfig)
|
||||
client, err := buildK8sClient(&cluster)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "连接K8s失败: " + err.Error()})
|
||||
return
|
||||
@@ -366,6 +367,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
|
||||
}
|
||||
} else {
|
||||
log.Printf("[K8s] sync nodes error: %v", err)
|
||||
result.Errors = append(result.Errors, "nodes: "+err.Error())
|
||||
}
|
||||
|
||||
// Sync pods
|
||||
@@ -396,6 +398,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
|
||||
}
|
||||
} else {
|
||||
log.Printf("[K8s] sync pods error: %v", err)
|
||||
result.Errors = append(result.Errors, "pods: "+err.Error())
|
||||
}
|
||||
|
||||
// Sync deployments
|
||||
@@ -421,6 +424,7 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
|
||||
}
|
||||
} else {
|
||||
log.Printf("[K8s] sync deps error: %v", err)
|
||||
result.Errors = append(result.Errors, "deployments: "+err.Error())
|
||||
}
|
||||
|
||||
// Sync services
|
||||
@@ -442,6 +446,9 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
|
||||
})
|
||||
result.Services++
|
||||
}
|
||||
} else {
|
||||
log.Printf("[K8s] sync services error: %v", err)
|
||||
result.Errors = append(result.Errors, "services: "+err.Error())
|
||||
}
|
||||
|
||||
// Sync ingresses
|
||||
@@ -473,6 +480,9 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
|
||||
})
|
||||
result.Ingresses++
|
||||
}
|
||||
} else {
|
||||
log.Printf("[K8s] sync ingresses error: %v", err)
|
||||
result.Errors = append(result.Errors, "ingresses: "+err.Error())
|
||||
}
|
||||
|
||||
// Update cluster stats
|
||||
@@ -482,13 +492,18 @@ func (h *KubernetesHandler) SyncCluster(c *gin.Context) {
|
||||
"pod_count": result.Pods,
|
||||
"deployment_count": result.Deployments,
|
||||
"service_count": result.Services,
|
||||
"ingress_count": result.Ingresses,
|
||||
"last_sync_at": &now,
|
||||
})
|
||||
|
||||
msg := fmt.Sprintf("同步完成: %d nodes, %d pods, %d deployments, %d services, %d ingresses",
|
||||
result.Nodes, result.Pods, result.Deployments, result.Services, result.Ingresses)
|
||||
if len(result.Errors) > 0 {
|
||||
msg += " (部分资源同步失败,详见 errors 字段)"
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Message: fmt.Sprintf("同步完成: %d nodes, %d pods, %d deployments, %d services, %d ingresses",
|
||||
result.Nodes, result.Pods, result.Deployments, result.Services, result.Ingresses),
|
||||
Message: msg,
|
||||
Data: result,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,9 +42,12 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) {
|
||||
OS string `json:"os"`
|
||||
Kubelet string `json:"kubelet_version"`
|
||||
CPU int `json:"cpu"`
|
||||
Memory int64 `json:"memory_mb"`
|
||||
Memory int64 `json:"memory"` // MiB
|
||||
MaxPods int `json:"max_pods"`
|
||||
// Metrics (optional, omitted if empty)
|
||||
// Metrics percent (for list usage bars)
|
||||
CPUUsage float64 `json:"cpu_usage,omitempty"`
|
||||
MemoryUsage float64 `json:"memory_usage,omitempty"`
|
||||
// Metrics absolute (for detail pages)
|
||||
CPUUsageM string `json:"cpu_usage_m,omitempty"` // e.g. "122m"
|
||||
MemUsageMi string `json:"mem_usage_mi,omitempty"` // e.g. "2105Mi"
|
||||
}
|
||||
@@ -71,6 +74,8 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) {
|
||||
break
|
||||
}
|
||||
}
|
||||
cpuCapacity := parseIntResource(n.Status.Capacity["cpu"])
|
||||
memCapacity := parseMemResource(n.Status.Capacity["memory"])
|
||||
s := NodeSummary{
|
||||
Name: n.Metadata.Name,
|
||||
IP: ip,
|
||||
@@ -78,14 +83,23 @@ func (h *KubernetesHandler) ListNodes(c *gin.Context) {
|
||||
Status: nodeStatus,
|
||||
OS: n.Status.NodeInfo.OSImage,
|
||||
Kubelet: n.Status.NodeInfo.KubeletVersion,
|
||||
CPU: parseIntResource(n.Status.Capacity["cpu"]),
|
||||
Memory: parseMemResource(n.Status.Capacity["memory"]),
|
||||
CPU: cpuCapacity,
|
||||
Memory: memCapacity,
|
||||
MaxPods: parseIntResource(n.Status.Capacity["pods"]),
|
||||
}
|
||||
if m, ok := metrics[n.Metadata.Name]; ok {
|
||||
// K8s metrics API returns CPU in nanocores ("80410173n"), memory in Ki ("2154292Ki")
|
||||
s.CPUUsageM = formatCpuFromNano(m.Usage["cpu"])
|
||||
s.MemUsageMi = formatMemFromKi(m.Usage["memory"])
|
||||
// 计算百分比用于列表页使用率条 // Logan.Li
|
||||
if cpuCapacity > 0 {
|
||||
cpuMillicores := parseCpuNanoToMilli(m.Usage["cpu"])
|
||||
s.CPUUsage = float64(cpuMillicores) / float64(cpuCapacity*1000) * 100
|
||||
}
|
||||
if memCapacity > 0 {
|
||||
memMi := parseMemKiToMi(m.Usage["memory"])
|
||||
s.MemoryUsage = float64(memMi) / float64(memCapacity) * 100
|
||||
}
|
||||
}
|
||||
result = append(result, s)
|
||||
}
|
||||
|
||||
@@ -366,6 +366,7 @@ func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) {
|
||||
Image string `json:"image"`
|
||||
CPUUsageM string `json:"cpu_usage_m,omitempty"`
|
||||
MemUsageMi string `json:"mem_usage_mi,omitempty"`
|
||||
Conditions []k8sclient.PodCondition `json:"conditions,omitempty"`
|
||||
}
|
||||
result := []PodSummary{}
|
||||
for _, p := range pods.Items {
|
||||
@@ -380,6 +381,7 @@ func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) {
|
||||
IP: p.Status.PodIP,
|
||||
RestartCount: restarts,
|
||||
Image: image,
|
||||
Conditions: p.Status.Conditions,
|
||||
}
|
||||
if m, ok := podMetrics[p.Metadata.Namespace+"/"+p.Metadata.Name]; ok {
|
||||
ps.CPUUsageM = m.CPU
|
||||
@@ -523,17 +525,29 @@ func parseMemResource(val string) int64 {
|
||||
if val == "" {
|
||||
return 0
|
||||
}
|
||||
// 清理常见后缀,大小写不敏感,支持 GiB / G / GB / bytes 等 // Logan.Li
|
||||
val = strings.TrimSpace(val)
|
||||
val = strings.ToUpper(val)
|
||||
val = strings.TrimSuffix(val, "IB")
|
||||
val = strings.TrimSuffix(val, "B")
|
||||
var num float64
|
||||
var unit string
|
||||
fmt.Sscanf(val, "%f%s", &num, &unit)
|
||||
unit = strings.TrimSpace(unit)
|
||||
switch unit {
|
||||
case "Ki":
|
||||
case "KI":
|
||||
return int64(num / 1024)
|
||||
case "Mi":
|
||||
case "MI":
|
||||
return int64(num)
|
||||
case "Gi":
|
||||
case "GI":
|
||||
return int64(num * 1024)
|
||||
case "TI":
|
||||
return int64(num * 1024 * 1024)
|
||||
default:
|
||||
// 纯数字通常是 bytes
|
||||
if unit == "" && num > 0 {
|
||||
return int64(num / 1024 / 1024)
|
||||
}
|
||||
return int64(num / 1024 / 1024)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +330,39 @@ func (h *KubernetesHandler) listIngressesWithNS(c *gin.Context, ns string) {
|
||||
}
|
||||
c.JSON(200, model.Response{Code: 200, Data: result})
|
||||
}
|
||||
|
||||
// ListIngressClasses 列出集群所有 IngressClass(供可视化编辑 Ingress 时下拉选择) // Logan.Li
|
||||
func (h *KubernetesHandler) ListIngressClasses(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 {
|
||||
c.JSON(500, model.Response{Code: 500, Message: "cluster not found"})
|
||||
return
|
||||
}
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
icList, err := client.ListIngressClasses()
|
||||
if err != nil {
|
||||
c.JSON(500, model.Response{Code: 500, Message: "K8s API error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
type ICItem struct {
|
||||
Name string `json:"name"`
|
||||
Controller string `json:"controller"`
|
||||
}
|
||||
result := []ICItem{}
|
||||
for _, ic := range icList.Items {
|
||||
result = append(result, ICItem{
|
||||
Name: ic.Metadata.Name,
|
||||
Controller: ic.Spec.Controller,
|
||||
})
|
||||
}
|
||||
c.JSON(200, model.Response{Code: 200, Data: result})
|
||||
}
|
||||
|
||||
func (h *KubernetesHandler) ListPVCsNS(c *gin.Context) {
|
||||
clusterID := h.resolveClusterCode(c.Param("clusterCode"))
|
||||
if clusterID == 0 { c.JSON(500, model.Response{Code: 500, Message: "cluster not found"}); return }
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ezops/model"
|
||||
|
||||
@@ -302,6 +303,7 @@ func (h *KubernetesHandler) updateDeploymentImageWithNS(c *gin.Context, ns strin
|
||||
if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) }
|
||||
var req struct { Image string `json:"image"`; ContainerName string `json:"container_name"` }
|
||||
if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, model.Response{Code: 400, Message: "参数错误"}); return }
|
||||
if strings.TrimSpace(req.Image) == "" { c.JSON(400, model.Response{Code: 400, Message: "image 不能为空"}); return }
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil { c.JSON(500, model.Response{Code: 500, Message: err.Error()}); return }
|
||||
if err := client.UpdateDeploymentImage(ns, c.Param("name"), req.ContainerName, req.Image); err != nil { c.JSON(500, model.Response{Code: 500, Message: "更新镜像失败: " + err.Error()}); return }
|
||||
|
||||
@@ -89,16 +89,24 @@ func (h *KubernetesHandler) getK8sClient(clusterID int) (*k8sclient.Client, *mod
|
||||
if err := h.db.First(&cluster, clusterID).Error; err != nil {
|
||||
return nil, nil, fmt.Errorf("cluster not found")
|
||||
}
|
||||
if cluster.KubeConfig == "" {
|
||||
return nil, nil, fmt.Errorf("no kubeconfig")
|
||||
}
|
||||
client, err := k8sclient.NewClient(cluster.KubeConfig)
|
||||
client, err := buildK8sClient(&cluster)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return client, &cluster, nil
|
||||
}
|
||||
|
||||
// buildK8sClient 根据集群配置构造客户端,支持 kubeconfig 或 token+api_server // Logan.Li
|
||||
func buildK8sClient(cluster *model.KubernetesCluster) (*k8sclient.Client, error) {
|
||||
if cluster.KubeConfig != "" {
|
||||
return k8sclient.NewClient(cluster.KubeConfig)
|
||||
}
|
||||
if cluster.Token != "" && cluster.APIServer != "" {
|
||||
return k8sclient.NewClientFromToken(cluster.APIServer, cluster.Token)
|
||||
}
|
||||
return nil, fmt.Errorf("no kubeconfig or token+api_server configured")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PVC (存储) handlers
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LogDSHandler 日志数据源管理 // Logan.Li
|
||||
type LogDSHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewLogDSHandler(db *gorm.DB) *LogDSHandler {
|
||||
return &LogDSHandler{db: db}
|
||||
}
|
||||
|
||||
// List GET /api/v1/log-datasources?type=loki
|
||||
func (h *LogDSHandler) List(c *gin.Context) {
|
||||
q := h.db.Order("is_default DESC, id ASC")
|
||||
if t := c.Query("type"); t != "" {
|
||||
q = q.Where("type = ?", t)
|
||||
}
|
||||
var sources []model.LogDataSource
|
||||
if err := q.Find(&sources).Error; err != nil {
|
||||
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if sources == nil {
|
||||
sources = []model.LogDataSource{}
|
||||
}
|
||||
c.JSON(200, model.Response{Code: 200, Data: sources})
|
||||
}
|
||||
|
||||
// Get GET /api/v1/log-datasources/:id
|
||||
func (h *LogDSHandler) Get(c *gin.Context) {
|
||||
var ds model.LogDataSource
|
||||
if err := h.db.First(&ds, c.Param("id")).Error; err != nil {
|
||||
c.JSON(404, model.Response{Code: 404, Message: "数据源不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, model.Response{Code: 200, Data: ds})
|
||||
}
|
||||
|
||||
// Create POST /api/v1/log-datasources
|
||||
func (h *LogDSHandler) Create(c *gin.Context) {
|
||||
var req model.CreateLogDSReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
req.Type = strings.ToLower(req.Type)
|
||||
if req.Type != "loki" && req.Type != "elasticsearch" {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "type 必须为 loki 或 elasticsearch"})
|
||||
return
|
||||
}
|
||||
if req.IsDefault {
|
||||
h.db.Model(&model.LogDataSource{}).Where("type = ? AND is_default = ?", req.Type, true).Update("is_default", false)
|
||||
}
|
||||
ds := model.LogDataSource{
|
||||
Name: req.Name, Type: req.Type, URL: strings.TrimRight(req.URL, "/"),
|
||||
BasicAuth: req.BasicAuth, BasicAuthUser: req.BasicAuthUser, BasicAuthPass: req.BasicAuthPass,
|
||||
SkipTLSVerify: req.SkipTLSVerify, DefaultIndex: req.DefaultIndex,
|
||||
IsDefault: req.IsDefault, Status: 1,
|
||||
}
|
||||
if err := h.db.Create(&ds).Error; err != nil {
|
||||
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, model.Response{Code: 200, Message: "创建成功", Data: ds})
|
||||
}
|
||||
|
||||
// Update PUT /api/v1/log-datasources/:id
|
||||
func (h *LogDSHandler) Update(c *gin.Context) {
|
||||
var ds model.LogDataSource
|
||||
if err := h.db.First(&ds, c.Param("id")).Error; err != nil {
|
||||
c.JSON(404, model.Response{Code: 404, Message: "数据源不存在"})
|
||||
return
|
||||
}
|
||||
var req model.UpdateLogDSReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
updates["name"] = *req.Name
|
||||
}
|
||||
if req.Type != nil {
|
||||
updates["type"] = strings.ToLower(*req.Type)
|
||||
}
|
||||
if req.URL != nil {
|
||||
updates["url"] = strings.TrimRight(*req.URL, "/")
|
||||
}
|
||||
if req.BasicAuth != nil {
|
||||
updates["basic_auth"] = *req.BasicAuth
|
||||
}
|
||||
if req.BasicAuthUser != nil {
|
||||
updates["basic_auth_user"] = *req.BasicAuthUser
|
||||
}
|
||||
if req.BasicAuthPass != nil && *req.BasicAuthPass != "" {
|
||||
updates["basic_auth_pass"] = *req.BasicAuthPass
|
||||
}
|
||||
if req.SkipTLSVerify != nil {
|
||||
updates["skip_tls_verify"] = *req.SkipTLSVerify
|
||||
}
|
||||
if req.DefaultIndex != nil {
|
||||
updates["default_index"] = *req.DefaultIndex
|
||||
}
|
||||
if req.Status != nil {
|
||||
updates["status"] = *req.Status
|
||||
}
|
||||
if req.IsDefault != nil && *req.IsDefault {
|
||||
h.db.Model(&model.LogDataSource{}).Where("type = ? AND is_default = ?", ds.Type, true).Update("is_default", false)
|
||||
updates["is_default"] = true
|
||||
} else if req.IsDefault != nil {
|
||||
updates["is_default"] = false
|
||||
}
|
||||
if err := h.db.Model(&ds).Updates(updates).Error; err != nil {
|
||||
c.JSON(500, model.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
h.db.First(&ds, ds.ID)
|
||||
c.JSON(200, model.Response{Code: 200, Message: "更新成功", Data: ds})
|
||||
}
|
||||
|
||||
// Delete DELETE /api/v1/log-datasources/:id
|
||||
func (h *LogDSHandler) Delete(c *gin.Context) {
|
||||
if err := h.db.Delete(&model.LogDataSource{}, c.Param("id")).Error; err != nil {
|
||||
c.JSON(500, model.Response{Code: 500, Message: "删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, model.Response{Code: 200, Message: "已删除"})
|
||||
}
|
||||
|
||||
// SetDefault POST /api/v1/log-datasources/:id/default
|
||||
func (h *LogDSHandler) SetDefault(c *gin.Context) {
|
||||
var ds model.LogDataSource
|
||||
if err := h.db.First(&ds, c.Param("id")).Error; err != nil {
|
||||
c.JSON(404, model.Response{Code: 404, Message: "数据源不存在"})
|
||||
return
|
||||
}
|
||||
h.db.Model(&model.LogDataSource{}).Where("type = ? AND is_default = ?", ds.Type, true).Update("is_default", false)
|
||||
h.db.Model(&ds).Update("is_default", true)
|
||||
c.JSON(200, model.Response{Code: 200, Message: "已设为默认"})
|
||||
}
|
||||
|
||||
// TestConnection POST /api/v1/log-datasources/test
|
||||
func (h *LogDSHandler) TestConnection(c *gin.Context) {
|
||||
var req model.TestLogDSReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
ok, msg := testLogDSConnection(req)
|
||||
if !ok {
|
||||
c.JSON(502, model.Response{Code: 502, Message: msg})
|
||||
return
|
||||
}
|
||||
c.JSON(200, model.Response{Code: 200, Message: "连接成功", Data: map[string]string{"status": "ok"}})
|
||||
}
|
||||
|
||||
func testLogDSConnection(req model.TestLogDSReq) (bool, string) {
|
||||
client := newLogHTTPClient(req.SkipTLSVerify, 10*time.Second)
|
||||
url := strings.TrimRight(req.URL, "/")
|
||||
var testURL string
|
||||
switch strings.ToLower(req.Type) {
|
||||
case "loki":
|
||||
testURL = url + "/loki/api/v1/labels"
|
||||
case "elasticsearch":
|
||||
testURL = url + "/_cluster/health"
|
||||
default:
|
||||
return false, "未知数据源类型"
|
||||
}
|
||||
httpReq, err := http.NewRequest(http.MethodGet, testURL, nil)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
applyLogDSAuth(httpReq, req.BasicAuth, req.BasicAuthUser, req.BasicAuthPass)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return false, "连接失败: " + err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return false, "HTTP " + resp.Status + ": " + string(body)
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func loadLogDS(h *LogDSHandler, id uint) (*model.LogDataSource, error) {
|
||||
var ds model.LogDataSource
|
||||
if err := h.db.First(&ds, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ds, nil
|
||||
}
|
||||
|
||||
func newLogHTTPClient(skipTLS bool, timeout time.Duration) *http.Client {
|
||||
tr := &http.Transport{}
|
||||
if skipTLS {
|
||||
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec
|
||||
}
|
||||
return &http.Client{Timeout: timeout, Transport: tr}
|
||||
}
|
||||
|
||||
func applyLogDSAuth(req *http.Request, basic bool, user, pass string) {
|
||||
if basic && user != "" {
|
||||
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(user+":"+pass)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// LogProxyHandler Loki / ES 无状态查询代理
|
||||
// 实时转发请求并解析展示字段,不将日志写入 AIOps 数据库 // Logan.Li
|
||||
type LogProxyHandler struct {
|
||||
ds *LogDSHandler
|
||||
}
|
||||
|
||||
const maxLogQueryLimit = 500 // 单次查询上限,防止平台承载过大数据 // Logan.Li
|
||||
|
||||
func NewLogProxyHandler(ds *LogDSHandler) *LogProxyHandler {
|
||||
return &LogProxyHandler{ds: ds}
|
||||
}
|
||||
|
||||
// QueryLoki POST /api/v1/logs/loki/query
|
||||
func (h *LogProxyHandler) QueryLoki(c *gin.Context) {
|
||||
var req struct {
|
||||
DatasourceID uint `json:"datasource_id" binding:"required"`
|
||||
Query string `json:"query" binding:"required"`
|
||||
Limit int `json:"limit"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Direction string `json:"direction"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
ds, err := loadLogDS(h.ds, req.DatasourceID)
|
||||
if err != nil || ds.Type != "loki" {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "Loki 数据源无效"})
|
||||
return
|
||||
}
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 200
|
||||
}
|
||||
if req.Limit > maxLogQueryLimit {
|
||||
req.Limit = maxLogQueryLimit
|
||||
}
|
||||
if req.Direction == "" {
|
||||
req.Direction = "backward"
|
||||
}
|
||||
end := time.Now()
|
||||
start := end.Add(-1 * time.Hour)
|
||||
if req.End != "" {
|
||||
if t, e := parseLogTime(req.End); e == nil {
|
||||
end = t
|
||||
}
|
||||
}
|
||||
if req.Start != "" {
|
||||
if t, e := parseLogTime(req.Start); e == nil {
|
||||
start = t
|
||||
}
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("query", req.Query)
|
||||
params.Set("limit", strconv.Itoa(req.Limit))
|
||||
params.Set("direction", req.Direction)
|
||||
params.Set("start", fmt.Sprintf("%d", start.UnixNano()))
|
||||
params.Set("end", fmt.Sprintf("%d", end.UnixNano()))
|
||||
|
||||
client := newLogHTTPClient(ds.SkipTLSVerify, time.Duration(60)*time.Second)
|
||||
target := ds.URL + "/loki/api/v1/query_range?" + params.Encode()
|
||||
httpReq, _ := http.NewRequest(http.MethodGet, target, nil)
|
||||
applyLogDSAuth(httpReq, ds.BasicAuth, ds.BasicAuthUser, ds.BasicAuthPass)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
c.JSON(502, model.Response{Code: 502, Message: "查询失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
c.JSON(502, model.Response{Code: 502, Message: "Loki 错误: " + string(body)})
|
||||
return
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
_ = json.Unmarshal(body, &raw)
|
||||
entries := flattenLokiStreams(raw)
|
||||
c.JSON(200, model.Response{Code: 200, Data: gin.H{
|
||||
"entries": entries, "total": len(entries),
|
||||
}})
|
||||
}
|
||||
|
||||
// QueryES POST /api/v1/logs/elasticsearch/query
|
||||
func (h *LogProxyHandler) QueryES(c *gin.Context) {
|
||||
var req struct {
|
||||
DatasourceID uint `json:"datasource_id" binding:"required"`
|
||||
Index string `json:"index"`
|
||||
Query string `json:"query"`
|
||||
QueryBody map[string]interface{} `json:"query_body"`
|
||||
Size int `json:"size"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
ds, err := loadLogDS(h.ds, req.DatasourceID)
|
||||
if err != nil || ds.Type != "elasticsearch" {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "Elasticsearch 数据源无效"})
|
||||
return
|
||||
}
|
||||
index := req.Index
|
||||
if index == "" {
|
||||
index = ds.DefaultIndex
|
||||
}
|
||||
if index == "" {
|
||||
index = "*"
|
||||
}
|
||||
if req.Size <= 0 {
|
||||
req.Size = 200
|
||||
}
|
||||
if req.Size > maxLogQueryLimit {
|
||||
req.Size = maxLogQueryLimit
|
||||
}
|
||||
|
||||
searchBody := req.QueryBody
|
||||
if searchBody == nil {
|
||||
searchBody = map[string]interface{}{
|
||||
"size": req.Size,
|
||||
"sort": []map[string]interface{}{{"@timestamp": map[string]string{"order": "desc"}}},
|
||||
"query": map[string]interface{}{
|
||||
"bool": map[string]interface{}{
|
||||
"must": []interface{}{
|
||||
map[string]interface{}{"query_string": map[string]interface{}{"query": defaultESQuery(req.Query)}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if req.Start != "" || req.End != "" {
|
||||
rangeQ := map[string]interface{}{"range": map[string]interface{}{"@timestamp": map[string]string{}}}
|
||||
r := rangeQ["range"].(map[string]interface{})["@timestamp"].(map[string]string)
|
||||
if req.Start != "" {
|
||||
r["gte"] = req.Start
|
||||
}
|
||||
if req.End != "" {
|
||||
r["lte"] = req.End
|
||||
}
|
||||
searchBody["query"] = map[string]interface{}{
|
||||
"bool": map[string]interface{}{
|
||||
"must": []interface{}{
|
||||
searchBody["query"],
|
||||
rangeQ,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(searchBody)
|
||||
client := newLogHTTPClient(ds.SkipTLSVerify, 60*time.Second)
|
||||
target := fmt.Sprintf("%s/%s/_search", ds.URL, strings.Trim(index, "/"))
|
||||
httpReq, _ := http.NewRequest(http.MethodPost, target, bytes.NewReader(payload))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
applyLogDSAuth(httpReq, ds.BasicAuth, ds.BasicAuthUser, ds.BasicAuthPass)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
c.JSON(502, model.Response{Code: 502, Message: "查询失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
c.JSON(502, model.Response{Code: 502, Message: "ES 错误: " + string(body)})
|
||||
return
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
_ = json.Unmarshal(body, &raw)
|
||||
entries := flattenESEntries(raw)
|
||||
c.JSON(200, model.Response{Code: 200, Data: gin.H{
|
||||
"entries": entries, "total": len(entries),
|
||||
}})
|
||||
}
|
||||
|
||||
func parseLogTime(s string) (time.Time, error) {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
if n > 1e15 {
|
||||
return time.Unix(0, n), nil
|
||||
}
|
||||
return time.Unix(n, 0), nil
|
||||
}
|
||||
return time.Parse(time.RFC3339, s)
|
||||
}
|
||||
|
||||
func defaultESQuery(q string) string {
|
||||
if strings.TrimSpace(q) == "" {
|
||||
return "*"
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
type logEntryRow struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Raw string `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
func flattenLokiStreams(raw map[string]interface{}) []logEntryRow {
|
||||
out := []logEntryRow{}
|
||||
data, _ := raw["data"].(map[string]interface{})
|
||||
result, _ := data["result"].([]interface{})
|
||||
for _, item := range result {
|
||||
stream, _ := item.(map[string]interface{})
|
||||
labels, _ := stream["stream"].(map[string]interface{})
|
||||
labelMap := map[string]string{}
|
||||
for k, v := range labels {
|
||||
labelMap[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
values, _ := stream["values"].([]interface{})
|
||||
for _, val := range values {
|
||||
pair, _ := val.([]interface{})
|
||||
if len(pair) < 2 {
|
||||
continue
|
||||
}
|
||||
ts := fmt.Sprintf("%v", pair[0])
|
||||
msg := fmt.Sprintf("%v", pair[1])
|
||||
out = append(out, logEntryRow{
|
||||
Timestamp: ts,
|
||||
Level: labelMap["level"],
|
||||
Message: msg,
|
||||
Labels: labelMap,
|
||||
Raw: msg,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenESEntries(raw map[string]interface{}) []logEntryRow {
|
||||
out := []logEntryRow{}
|
||||
hitsWrap, _ := raw["hits"].(map[string]interface{})
|
||||
hits, _ := hitsWrap["hits"].([]interface{})
|
||||
for _, h := range hits {
|
||||
doc, _ := h.(map[string]interface{})
|
||||
src, _ := doc["_source"].(map[string]interface{})
|
||||
if src == nil {
|
||||
continue
|
||||
}
|
||||
msg := pickESMessage(src)
|
||||
level, _ := src["level"].(string)
|
||||
ts, _ := src["@timestamp"].(string)
|
||||
if ts == "" {
|
||||
ts = fmt.Sprintf("%v", src["timestamp"])
|
||||
}
|
||||
rawBytes, _ := json.Marshal(src)
|
||||
out = append(out, logEntryRow{
|
||||
Timestamp: ts,
|
||||
Level: level,
|
||||
Message: msg,
|
||||
Raw: string(rawBytes),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pickESMessage(src map[string]interface{}) string {
|
||||
for _, key := range []string{"message", "msg", "log", "content"} {
|
||||
if v, ok := src[key]; ok {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(src)
|
||||
if len(b) > 500 {
|
||||
return string(b[:500]) + "..."
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -358,3 +358,35 @@ func (h *UserHandler) ImportTemplate(c *gin.Context) {
|
||||
c.Header("Content-Disposition", "attachment; filename=users_import_template.csv")
|
||||
c.String(http.StatusOK, csv)
|
||||
}
|
||||
|
||||
// ResetUserPassword 管理员重置用户密码(仅本地账号)// Logan.Li
|
||||
// POST /api/v1/admin/users/:id/reset-password
|
||||
func (h *UserHandler) ResetUserPassword(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var req struct {
|
||||
NewPassword string `json:"new_password" binding:"required,min=6,max=32"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
var user model.User
|
||||
if err := h.db.First(&user, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "用户不存在"})
|
||||
return
|
||||
}
|
||||
if user.AuthSource == "ldap" {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "LDAP 用户请在 LDAP 目录中重置密码"})
|
||||
return
|
||||
}
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "密码加密失败"})
|
||||
return
|
||||
}
|
||||
if err := h.db.Model(&user).Update("password", string(hashed)).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "重置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "密码已重置"})
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"ezops/middleware"
|
||||
"ezops/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const apiKeyChars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
const (
|
||||
defaultAPIKeyMonths = 12
|
||||
maxAPIKeyMonths = 36
|
||||
)
|
||||
|
||||
// randomAPIKeyPart 生成指定长度的随机密钥片段 // Logan.Li
|
||||
func randomAPIKeyPart(n int) (string, error) {
|
||||
out := make([]byte, n)
|
||||
max := big.NewInt(int64(len(apiKeyChars)))
|
||||
for i := range out {
|
||||
idx, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out[i] = apiKeyChars[idx.Int64()]
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func normalizeAPIKeyMonths(months int) int {
|
||||
if months <= 0 {
|
||||
return defaultAPIKeyMonths
|
||||
}
|
||||
if months > maxAPIKeyMonths {
|
||||
return maxAPIKeyMonths
|
||||
}
|
||||
return months
|
||||
}
|
||||
|
||||
func apiKeyExpiresAfter(months int) time.Time {
|
||||
return time.Now().AddDate(0, normalizeAPIKeyMonths(months), 0)
|
||||
}
|
||||
|
||||
func parseExpiresAt(dateStr string) (*time.Time, error) {
|
||||
if dateStr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, time.Local)
|
||||
return &end, nil
|
||||
}
|
||||
|
||||
func apiKeyStatus(expiresAt *time.Time) string {
|
||||
if expiresAt != nil && expiresAt.Before(time.Now()) {
|
||||
return "expired"
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
func (h *UserHandler) loadAPIKeyUser(userID uint) (model.User, error) {
|
||||
var user model.User
|
||||
err := h.db.Select(
|
||||
"id", "api_access_key_prefix", "api_access_key_id", "api_access_key_description",
|
||||
"api_access_key_duration_months", "api_access_key_at", "api_access_key_expires_at",
|
||||
"api_access_key_last_used_at",
|
||||
).First(&user, userID).Error
|
||||
return user, err
|
||||
}
|
||||
|
||||
// apiKeyResponse 单条密钥元信息(不含明文)// Logan.Li
|
||||
func apiKeyResponse(user model.User) gin.H {
|
||||
hasToken := user.ApiAccessKeyPrefix != ""
|
||||
expired := false
|
||||
var daysLeft *int
|
||||
if user.ApiAccessKeyExpiresAt != nil {
|
||||
now := time.Now()
|
||||
if user.ApiAccessKeyExpiresAt.Before(now) {
|
||||
expired = true
|
||||
d := 0
|
||||
daysLeft = &d
|
||||
} else {
|
||||
d := int(user.ApiAccessKeyExpiresAt.Sub(now).Hours()/24) + 1
|
||||
daysLeft = &d
|
||||
}
|
||||
}
|
||||
mask := ""
|
||||
if user.ApiAccessKeyPrefix != "" {
|
||||
mask = user.ApiAccessKeyPrefix + ".•••••••••••••••••••••••••••••••••"
|
||||
}
|
||||
return gin.H{
|
||||
"has_token": hasToken,
|
||||
"key_id": user.ApiAccessKeyID,
|
||||
"prefix": user.ApiAccessKeyPrefix,
|
||||
"access_key_mask": mask,
|
||||
"description": user.ApiAccessKeyDescription,
|
||||
"duration_months": user.ApiAccessKeyDurationMonths,
|
||||
"created_at": user.ApiAccessKeyAt,
|
||||
"expires_at": user.ApiAccessKeyExpiresAt,
|
||||
"last_used_at": user.ApiAccessKeyLastUsedAt,
|
||||
"status": apiKeyStatus(user.ApiAccessKeyExpiresAt),
|
||||
"expired": expired,
|
||||
"days_left": daysLeft,
|
||||
"default_months": defaultAPIKeyMonths,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *UserHandler) ensureAPIKeyMeta(user *model.User) {
|
||||
if user.ApiAccessKeyPrefix == "" {
|
||||
return
|
||||
}
|
||||
patches := map[string]interface{}{}
|
||||
if user.ApiAccessKeyID == "" {
|
||||
if id, err := randomAPIKeyPart(24); err == nil {
|
||||
patches["api_access_key_id"] = id
|
||||
user.ApiAccessKeyID = id
|
||||
}
|
||||
}
|
||||
if user.ApiAccessKeyExpiresAt == nil {
|
||||
exp := apiKeyExpiresAfter(defaultAPIKeyMonths)
|
||||
patches["api_access_key_expires_at"] = exp
|
||||
user.ApiAccessKeyExpiresAt = &exp
|
||||
}
|
||||
if user.ApiAccessKeyDurationMonths == 0 {
|
||||
patches["api_access_key_duration_months"] = defaultAPIKeyMonths
|
||||
user.ApiAccessKeyDurationMonths = defaultAPIKeyMonths
|
||||
}
|
||||
if len(patches) > 0 {
|
||||
_ = h.db.Model(&model.User{}).Where("id = ?", user.ID).Updates(patches).Error
|
||||
}
|
||||
}
|
||||
|
||||
// GetApiAccessKey 获取当前用户 API 密钥元信息 // Logan.Li
|
||||
func (h *UserHandler) GetApiAccessKey(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
user, err := h.loadAPIKeyUser(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "用户不存在"})
|
||||
return
|
||||
}
|
||||
h.ensureAPIKeyMeta(&user)
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "success", Data: apiKeyResponse(user)})
|
||||
}
|
||||
|
||||
type apiKeyCreateRequest struct {
|
||||
Description string `json:"description"`
|
||||
DurationMonths int `json:"duration_months"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
type apiKeyUpdateRequest struct {
|
||||
Description string `json:"description"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
// RegenerateApiAccessKey 创建或重新生成唯一 API 密钥 // Logan.Li
|
||||
func (h *UserHandler) RegenerateApiAccessKey(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
var req apiKeyCreateRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
months := normalizeAPIKeyMonths(req.DurationMonths)
|
||||
|
||||
var expires *time.Time
|
||||
if req.ExpiresAt != "" {
|
||||
parsed, err := parseExpiresAt(req.ExpiresAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "截止时间格式无效,请使用 YYYY-MM-DD"})
|
||||
return
|
||||
}
|
||||
expires = parsed
|
||||
} else {
|
||||
exp := apiKeyExpiresAfter(months)
|
||||
expires = &exp
|
||||
}
|
||||
|
||||
keyID, err := randomAPIKeyPart(24)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "生成密钥失败"})
|
||||
return
|
||||
}
|
||||
prefix, err := randomAPIKeyPart(12)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "生成密钥失败"})
|
||||
return
|
||||
}
|
||||
secret, err := randomAPIKeyPart(32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "生成密钥失败"})
|
||||
return
|
||||
}
|
||||
full := fmt.Sprintf("%s.%s", prefix, secret)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(full), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "密钥加密失败"})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"api_access_key_hash": string(hash),
|
||||
"api_access_key_prefix": prefix,
|
||||
"api_access_key_id": keyID,
|
||||
"api_access_key_description": req.Description,
|
||||
"api_access_key_duration_months": months,
|
||||
"api_access_key_at": now,
|
||||
"api_access_key_expires_at": expires,
|
||||
"api_access_key_last_used_at": nil,
|
||||
}
|
||||
if err := h.db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存密钥失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200, Message: "访问密钥已生成,请立即保存(仅显示一次)",
|
||||
Data: gin.H{
|
||||
"token": full,
|
||||
"prefix": prefix,
|
||||
"key_id": keyID,
|
||||
"description": req.Description,
|
||||
"created_at": now,
|
||||
"expires_at": expires,
|
||||
"duration_months": months,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateApiAccessKey 编辑描述或截止时间(不更换密钥明文)// Logan.Li
|
||||
func (h *UserHandler) UpdateApiAccessKey(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
var req apiKeyUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
user, err := h.loadAPIKeyUser(userID)
|
||||
if err != nil || user.ApiAccessKeyPrefix == "" {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "尚未创建 API 密钥"})
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
if req.Description != "" || c.Request.ContentLength > 0 {
|
||||
updates["api_access_key_description"] = req.Description
|
||||
}
|
||||
if req.ExpiresAt != "" {
|
||||
parsed, err := parseExpiresAt(req.ExpiresAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "截止时间格式无效"})
|
||||
return
|
||||
}
|
||||
updates["api_access_key_expires_at"] = parsed
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "没有可更新的字段"})
|
||||
return
|
||||
}
|
||||
if err := h.db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "更新失败"})
|
||||
return
|
||||
}
|
||||
user, _ = h.loadAPIKeyUser(userID)
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已更新", Data: apiKeyResponse(user)})
|
||||
}
|
||||
|
||||
// ExtendApiAccessKey 延长有效期(兼容旧接口)// Logan.Li
|
||||
func (h *UserHandler) ExtendApiAccessKey(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
var req struct{ DurationMonths int `json:"duration_months"` }
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误"})
|
||||
return
|
||||
}
|
||||
user, err := h.loadAPIKeyUser(userID)
|
||||
if err != nil || user.ApiAccessKeyPrefix == "" {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "尚未创建 API 密钥"})
|
||||
return
|
||||
}
|
||||
months := normalizeAPIKeyMonths(req.DurationMonths)
|
||||
start := time.Now()
|
||||
if user.ApiAccessKeyExpiresAt != nil && user.ApiAccessKeyExpiresAt.After(start) {
|
||||
start = *user.ApiAccessKeyExpiresAt
|
||||
}
|
||||
expires := start.AddDate(0, months, 0)
|
||||
if err := h.db.Model(&model.User{}).Where("id = ?", userID).Update("api_access_key_expires_at", expires).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "续期失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200, Message: "密钥有效期已延长",
|
||||
Data: gin.H{"expires_at": expires, "duration_months": months},
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeApiAccessKey 删除 API 访问密钥 // Logan.Li
|
||||
func (h *UserHandler) RevokeApiAccessKey(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
if err := h.db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"api_access_key_hash": "", "api_access_key_prefix": "", "api_access_key_id": "",
|
||||
"api_access_key_description": "", "api_access_key_duration_months": 0,
|
||||
"api_access_key_at": nil, "api_access_key_expires_at": nil, "api_access_key_last_used_at": nil,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "访问密钥已删除"})
|
||||
}
|
||||
Reference in new issue
Block a user