代理批量关联租户接口、关于页运行时与资源指标
This commit is contained in:
+55
-5
@@ -1,8 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -12,20 +15,67 @@ import (
|
||||
var (
|
||||
buildVersion = "dev"
|
||||
buildTime = ""
|
||||
// startedAt 为进程启动时刻,运行时长与 CPU 均值的计算基准
|
||||
startedAt = time.Now()
|
||||
// aboutDB 由 main 启动时注入,存储指标据此定位数据库文件
|
||||
aboutDB struct{ driver, path string }
|
||||
)
|
||||
|
||||
// SetAboutRuntime 注入数据库形态,「关于」页存储指标展示用;启动时调用一次。
|
||||
func SetAboutRuntime(dbDriver, dbPath string) {
|
||||
aboutDB.driver, aboutDB.path = dbDriver, dbPath
|
||||
}
|
||||
|
||||
// about 返回构建与运行环境信息,「设置 · 关于」页展示。
|
||||
//
|
||||
// @Summary 返回构建与运行环境信息,「设置 · 关于」页展示
|
||||
// @Summary 返回构建、运行时长与资源占用信息,「设置 · 关于」页展示
|
||||
// @Tags 设置
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/about [get]
|
||||
func about(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"version": buildVersion,
|
||||
"buildTime": buildTime,
|
||||
"goVersion": runtime.Version(),
|
||||
"platform": runtime.GOOS + "/" + runtime.GOARCH,
|
||||
"version": buildVersion,
|
||||
"buildTime": buildTime,
|
||||
"goVersion": runtime.Version(),
|
||||
"platform": runtime.GOOS + "/" + runtime.GOARCH,
|
||||
"startedAt": startedAt.UTC().Format(time.RFC3339),
|
||||
"uptimeSeconds": int64(time.Since(startedAt).Seconds()),
|
||||
"resources": resourcesSnapshot(),
|
||||
})
|
||||
}
|
||||
|
||||
// resourcesSnapshot 采集进程资源占用:CPU 自启动均值、内存、goroutine 与存储。
|
||||
func resourcesSnapshot() gin.H {
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
pct := 0.0
|
||||
if up := time.Since(startedAt).Seconds(); up > 0 {
|
||||
// 多核并行时均值可超 100%,口径为「占单核百分比」
|
||||
pct = processCPUTime().Seconds() / up * 100
|
||||
}
|
||||
return gin.H{
|
||||
"cpuAvgPercent": math.Round(pct*100) / 100,
|
||||
"numCpu": runtime.NumCPU(),
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"memHeapBytes": ms.HeapAlloc,
|
||||
"memSysBytes": ms.Sys,
|
||||
"dbEngine": aboutDB.driver,
|
||||
"dbBytes": dbFileBytes(aboutDB.driver, aboutDB.path),
|
||||
}
|
||||
}
|
||||
|
||||
// dbFileBytes 统计数据库磁盘占用:sqlite 累加主文件与 -wal / -shm,
|
||||
// 外部数据库(mysql / postgres)不可从本机度量,返回 -1 表示不可用。
|
||||
func dbFileBytes(driver, path string) int64 {
|
||||
if driver != "sqlite" || path == "" {
|
||||
return -1
|
||||
}
|
||||
var total int64
|
||||
for _, p := range []string{path, path + "-wal", path + "-shm"} {
|
||||
if fi, err := os.Stat(p); err == nil {
|
||||
total += fi.Size()
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !unix
|
||||
|
||||
package api
|
||||
|
||||
import "time"
|
||||
|
||||
// processCPUTime 非 unix 平台无 getrusage,CPU 均值退化为 0。
|
||||
func processCPUTime() time.Duration { return 0 }
|
||||
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDBFileBytes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "app.db")
|
||||
mustWrite := func(p string, n int) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(p, make([]byte, n), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
mustWrite(dbPath, 100)
|
||||
mustWrite(dbPath+"-wal", 30)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
driver string
|
||||
path string
|
||||
want int64
|
||||
}{
|
||||
{name: "sqlite 主文件加 wal", driver: "sqlite", path: dbPath, want: 130},
|
||||
{name: "sqlite 文件不存在计 0", driver: "sqlite", path: filepath.Join(dir, "none.db"), want: 0},
|
||||
{name: "外部数据库不可度量", driver: "postgres", path: "", want: -1},
|
||||
{name: "路径为空不可度量", driver: "sqlite", path: "", want: -1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := dbFileBytes(tc.driver, tc.path); got != tc.want {
|
||||
t.Fatalf("dbFileBytes(%q, %q) = %d, want %d", tc.driver, tc.path, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcesSnapshot(t *testing.T) {
|
||||
SetAboutRuntime("sqlite", filepath.Join(t.TempDir(), "absent.db"))
|
||||
got := resourcesSnapshot()
|
||||
|
||||
if got["numCpu"].(int) < 1 || got["goroutines"].(int) < 1 {
|
||||
t.Fatalf("numCpu / goroutines 应为正数: %+v", got)
|
||||
}
|
||||
if got["memHeapBytes"].(uint64) == 0 || got["memSysBytes"].(uint64) == 0 {
|
||||
t.Fatalf("内存指标应为正数: %+v", got)
|
||||
}
|
||||
if got["cpuAvgPercent"].(float64) < 0 {
|
||||
t.Fatalf("cpuAvgPercent 不应为负: %+v", got)
|
||||
}
|
||||
if got["dbEngine"].(string) != "sqlite" || got["dbBytes"].(int64) != 0 {
|
||||
t.Fatalf("存储指标不符: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build unix
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// processCPUTime 返回进程自启动累计的 CPU 时间(用户态 + 内核态);
|
||||
// 取不到时返回 0,「关于」页 CPU 均值显示为 0 而非报错。
|
||||
func processCPUTime() time.Duration {
|
||||
var ru syscall.Rusage
|
||||
if err := syscall.Getrusage(syscall.RUSAGE_SELF, &ru); err != nil {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(ru.Utime.Nano() + ru.Stime.Nano())
|
||||
}
|
||||
@@ -144,6 +144,35 @@ func (h *proxyHandler) probe(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
// setTenants 设置代理关联的租户全集(代理侧批量关联 / 解除)。
|
||||
//
|
||||
// @Summary 设置代理关联的租户全集
|
||||
// @Tags 设置
|
||||
// @Param id path int true "代理 ID"
|
||||
// @Param body body object true "请求体 {\"ociConfigIds\": [1,2]}"
|
||||
// @Success 200 {object} map[string]any "usedBy"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/proxies/{id}/tenants [put]
|
||||
func (h *proxyHandler) setTenants(c *gin.Context) {
|
||||
id, ok := pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
OciConfigIDs []uint `json:"ociConfigIds"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
used, err := h.svc.SetTenants(c.Request.Context(), id, req.OciConfigIDs)
|
||||
if err != nil {
|
||||
h.respond(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"usedBy": used})
|
||||
}
|
||||
|
||||
// respond 把代理业务错误映射到语义状态码。
|
||||
func (h *proxyHandler) respond(c *gin.Context, err error) {
|
||||
switch {
|
||||
|
||||
@@ -35,4 +35,5 @@ func registerSettings(secured *gin.RouterGroup, settings *service.SettingService
|
||||
secured.PUT("/proxies/:id", px.update)
|
||||
secured.DELETE("/proxies/:id", px.remove)
|
||||
secured.POST("/proxies/:id/probe", px.probe)
|
||||
secured.PUT("/proxies/:id/tenants", px.setTenants)
|
||||
}
|
||||
|
||||
@@ -229,6 +229,63 @@ func (s *ProxyService) Delete(ctx context.Context, id uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTenants 设置关联此代理的租户全集:列表内的租户改挂到本代理(含从
|
||||
// 其他代理改挂),列表外已关联的解除;整体事务生效,返回新的关联计数。
|
||||
func (s *ProxyService) SetTenants(ctx context.Context, id uint, cfgIDs []uint) (int64, error) {
|
||||
if err := s.db.WithContext(ctx).First(&model.Proxy{}, id).Error; err != nil {
|
||||
return 0, fmt.Errorf("find proxy %d: %w", id, err)
|
||||
}
|
||||
ids, err := s.validTenantIDs(ctx, cfgIDs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
unbind := tx.Model(&model.OciConfig{}).Where("proxy_id = ?", id)
|
||||
if len(ids) > 0 {
|
||||
unbind = unbind.Where("id NOT IN ?", ids)
|
||||
}
|
||||
if err := unbind.Update("proxy_id", nil).Error; err != nil {
|
||||
return fmt.Errorf("unbind tenants: %w", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
err := tx.Model(&model.OciConfig{}).Where("id IN ?", ids).Update("proxy_id", id).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("bind tenants: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(len(ids)), nil
|
||||
}
|
||||
|
||||
// validTenantIDs 去重并校验租户 ID 均存在;含无效 ID 时报 ErrProxyInvalid。
|
||||
func (s *ProxyService) validTenantIDs(ctx context.Context, cfgIDs []uint) ([]uint, error) {
|
||||
seen := map[uint]struct{}{}
|
||||
ids := make([]uint, 0, len(cfgIDs))
|
||||
for _, v := range cfgIDs {
|
||||
if _, ok := seen[v]; !ok {
|
||||
seen[v] = struct{}{}
|
||||
ids = append(ids, v)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return ids, nil
|
||||
}
|
||||
var n int64
|
||||
err := s.db.WithContext(ctx).Model(&model.OciConfig{}).Where("id IN ?", ids).Count(&n).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count tenants: %w", err)
|
||||
}
|
||||
if n != int64(len(ids)) {
|
||||
return nil, fmt.Errorf("存在无效的租户 ID: %w", ErrProxyInvalid)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// SpecOf 解密并组装 SDK 用的代理参数;id 为 nil 返回 nil(直连)。
|
||||
func (s *ProxyService) SpecOf(ctx context.Context, id *uint) (*oci.ProxySpec, error) {
|
||||
if id == nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -227,3 +228,99 @@ func TestProxyGeoProbe(t *testing.T) {
|
||||
t.Fatalf("probe view = %+v, want 空地区带探测时间", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxySetTenants(t *testing.T) {
|
||||
svc, db := newProxyEnv(t)
|
||||
ctx := context.Background()
|
||||
|
||||
pa, _ := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "10.0.0.1", Port: 1080})
|
||||
pb, _ := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "10.0.0.2", Port: 1080})
|
||||
// t1 已挂 A,t2 挂 B,t3 直连
|
||||
t1 := model.OciConfig{Alias: "t1", ProxyID: &pa.ID}
|
||||
t2 := model.OciConfig{Alias: "t2", ProxyID: &pb.ID}
|
||||
t3 := model.OciConfig{Alias: "t3"}
|
||||
for _, cfg := range []*model.OciConfig{&t1, &t2, &t3} {
|
||||
if err := db.Create(cfg).Error; err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
proxyOf := func(id uint) *uint {
|
||||
var row model.OciConfig
|
||||
if err := db.First(&row, id).Error; err != nil {
|
||||
t.Fatalf("load config %d: %v", id, err)
|
||||
}
|
||||
return row.ProxyID
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
proxyID uint
|
||||
cfgIDs []uint
|
||||
wantN int64
|
||||
wantErr error
|
||||
}{
|
||||
{name: "改挂并新增", proxyID: pa.ID, cfgIDs: []uint{t2.ID, t3.ID, t3.ID}, wantN: 2},
|
||||
{name: "清空解除全部", proxyID: pa.ID, cfgIDs: nil, wantN: 0},
|
||||
{name: "无效租户拒绝", proxyID: pa.ID, cfgIDs: []uint{9999}, wantErr: ErrProxyInvalid},
|
||||
{name: "代理不存在", proxyID: 9999, cfgIDs: nil, wantErr: gorm.ErrRecordNotFound},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
n, err := svc.SetTenants(ctx, tc.proxyID, tc.cfgIDs)
|
||||
if tc.wantErr != nil {
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("err = %v, want %v", err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || n != tc.wantN {
|
||||
t.Fatalf("SetTenants = %d, %v; want %d, nil", n, err, tc.wantN)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 终态:清空后三租户全部直连(t1 在第一步已被解除,t2/t3 在第二步解除)
|
||||
for _, id := range []uint{t1.ID, t2.ID, t3.ID} {
|
||||
if got := proxyOf(id); got != nil {
|
||||
t.Fatalf("config %d proxyID = %v, want nil", id, *got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxySetTenantsRebind(t *testing.T) {
|
||||
svc, db := newProxyEnv(t)
|
||||
ctx := context.Background()
|
||||
|
||||
pa, _ := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "10.0.1.1", Port: 1080})
|
||||
pb, _ := svc.Create(ctx, ProxyInput{Type: "socks5", Host: "10.0.1.2", Port: 1080})
|
||||
cfg := model.OciConfig{Alias: "steal", ProxyID: &pa.ID}
|
||||
if err := db.Create(&cfg).Error; err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.SetTenants(ctx, pb.ID, []uint{cfg.ID}); err != nil {
|
||||
t.Fatalf("SetTenants: %v", err)
|
||||
}
|
||||
var row model.OciConfig
|
||||
if err := db.First(&row, cfg.ID).Error; err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
if row.ProxyID == nil || *row.ProxyID != pb.ID {
|
||||
t.Fatalf("proxyID = %v, want %d(改挂到 B)", row.ProxyID, pb.ID)
|
||||
}
|
||||
// A 侧计数应归零,B 侧为 1
|
||||
views, err := svc.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
for _, v := range views {
|
||||
want := int64(0)
|
||||
if v.ID == pb.ID {
|
||||
want = 1
|
||||
}
|
||||
if v.UsedBy != want {
|
||||
t.Fatalf("proxy %d UsedBy = %d, want %d", v.ID, v.UsedBy, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user