代理批量关联租户接口、关于页运行时与资源指标
This commit is contained in:
@@ -7,6 +7,8 @@
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 恢复 OpenAI Chat Completions 兼容端点 `/ai/v1/chat/completions`:请求经 OCI Responses 接口转换转发,支持非流式与 SSE、文本与图片、function 工具调用、结构化输出、推理力度、模型白名单、缓存 token 用量和调用日志
|
- 恢复 OpenAI Chat Completions 兼容端点 `/ai/v1/chat/completions`:请求经 OCI Responses 接口转换转发,支持非流式与 SSE、文本与图片、function 工具调用、结构化输出、推理力度、模型白名单、缓存 token 用量和调用日志
|
||||||
|
- 代理侧批量关联租户接口 `PUT /api/v1/proxies/{id}/tenants`:一次调用设置关联该代理的租户全集——列表内租户改挂到本代理(含从其他代理改挂),列表外已关联的解除,事务生效并返回最新关联计数;含无效租户 ID 返回 400,代理不存在返回 404
|
||||||
|
- `/api/v1/about` 扩展运行时信息:进程启动时刻 `startedAt`、运行秒数 `uptimeSeconds` 与资源占用 `resources`——CPU 自启动均值(占单核百分比)、CPU 核数、goroutine 数、Go 堆内存与向系统申请内存、数据库引擎与磁盘占用(SQLite 统计主文件与 -wal / -shm;MySQL / PostgreSQL 外部库返回 -1 表示不可度量)
|
||||||
- README 重构部署、配置、鉴权、反向代理、AI 网关与升级发布说明,并新增 Responses、Chat Completions、Embeddings、Anthropic Messages 与标准接口的字段兼容矩阵,明确直通、转换、降级、忽略和拒绝边界
|
- README 重构部署、配置、鉴权、反向代理、AI 网关与升级发布说明,并新增 Responses、Chat Completions、Embeddings、Anthropic Messages 与标准接口的字段兼容矩阵,明确直通、转换、降级、忽略和拒绝边界
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ func run() error {
|
|||||||
console := service.NewConsoleService(ociConfigs)
|
console := service.NewConsoleService(ociConfigs)
|
||||||
proxies := service.NewProxyService(db, cipher)
|
proxies := service.NewProxyService(db, cipher)
|
||||||
defer proxies.Wait()
|
defer proxies.Wait()
|
||||||
|
// 「关于」页存储指标需知数据库形态
|
||||||
|
api.SetAboutRuntime(cfg.DBDriver, cfg.DBPath)
|
||||||
router := api.NewRouter(auth, oauth, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway)
|
router := api.NewRouter(auth, oauth, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway)
|
||||||
return serveHTTP(cfg.Addr, router)
|
return serveHTTP(cfg.Addr, router)
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-1
@@ -154,7 +154,7 @@ const docTemplate = `{
|
|||||||
"tags": [
|
"tags": [
|
||||||
"设置"
|
"设置"
|
||||||
],
|
],
|
||||||
"summary": "返回构建与运行环境信息,「设置 · 关于」页展示",
|
"summary": "返回构建、运行时长与资源占用信息,「设置 · 关于」页展示",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -5001,6 +5001,46 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/proxies/{id}/tenants": {
|
||||||
|
"put": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"设置"
|
||||||
|
],
|
||||||
|
"summary": "设置代理关联的租户全集",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "代理 ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "请求体 {\\",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "usedBy",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/regions": {
|
"/api/v1/regions": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
|
|||||||
+41
-1
@@ -147,7 +147,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"设置"
|
"设置"
|
||||||
],
|
],
|
||||||
"summary": "返回构建与运行环境信息,「设置 · 关于」页展示",
|
"summary": "返回构建、运行时长与资源占用信息,「设置 · 关于」页展示",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -4994,6 +4994,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/proxies/{id}/tenants": {
|
||||||
|
"put": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"设置"
|
||||||
|
],
|
||||||
|
"summary": "设置代理关联的租户全集",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "代理 ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "请求体 {\\",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "usedBy",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/regions": {
|
"/api/v1/regions": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
|
|||||||
+26
-1
@@ -671,7 +671,7 @@ paths:
|
|||||||
type: object
|
type: object
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
summary: 返回构建与运行环境信息,「设置 · 关于」页展示
|
summary: 返回构建、运行时长与资源占用信息,「设置 · 关于」页展示
|
||||||
tags:
|
tags:
|
||||||
- 设置
|
- 设置
|
||||||
/api/v1/ai-blacklist:
|
/api/v1/ai-blacklist:
|
||||||
@@ -3668,6 +3668,31 @@ paths:
|
|||||||
summary: 手动重测代理出口地区,同步返回最新视图
|
summary: 手动重测代理出口地区,同步返回最新视图
|
||||||
tags:
|
tags:
|
||||||
- 设置
|
- 设置
|
||||||
|
/api/v1/proxies/{id}/tenants:
|
||||||
|
put:
|
||||||
|
parameters:
|
||||||
|
- description: 代理 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
- description: 请求体 {\
|
||||||
|
in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: usedBy
|
||||||
|
schema:
|
||||||
|
additionalProperties: true
|
||||||
|
type: object
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 设置代理关联的租户全集
|
||||||
|
tags:
|
||||||
|
- 设置
|
||||||
/api/v1/proxies/import:
|
/api/v1/proxies/import:
|
||||||
post:
|
post:
|
||||||
parameters:
|
parameters:
|
||||||
|
|||||||
+51
-1
@@ -1,8 +1,11 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -12,11 +15,20 @@ import (
|
|||||||
var (
|
var (
|
||||||
buildVersion = "dev"
|
buildVersion = "dev"
|
||||||
buildTime = ""
|
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 返回构建与运行环境信息,「设置 · 关于」页展示。
|
// about 返回构建与运行环境信息,「设置 · 关于」页展示。
|
||||||
//
|
//
|
||||||
// @Summary 返回构建与运行环境信息,「设置 · 关于」页展示
|
// @Summary 返回构建、运行时长与资源占用信息,「设置 · 关于」页展示
|
||||||
// @Tags 设置
|
// @Tags 设置
|
||||||
// @Success 200 {object} map[string]any
|
// @Success 200 {object} map[string]any
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
@@ -27,5 +39,43 @@ func about(c *gin.Context) {
|
|||||||
"buildTime": buildTime,
|
"buildTime": buildTime,
|
||||||
"goVersion": runtime.Version(),
|
"goVersion": runtime.Version(),
|
||||||
"platform": runtime.GOOS + "/" + runtime.GOARCH,
|
"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)
|
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 把代理业务错误映射到语义状态码。
|
// respond 把代理业务错误映射到语义状态码。
|
||||||
func (h *proxyHandler) respond(c *gin.Context, err error) {
|
func (h *proxyHandler) respond(c *gin.Context, err error) {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -35,4 +35,5 @@ func registerSettings(secured *gin.RouterGroup, settings *service.SettingService
|
|||||||
secured.PUT("/proxies/:id", px.update)
|
secured.PUT("/proxies/:id", px.update)
|
||||||
secured.DELETE("/proxies/:id", px.remove)
|
secured.DELETE("/proxies/:id", px.remove)
|
||||||
secured.POST("/proxies/:id/probe", px.probe)
|
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
|
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(直连)。
|
// SpecOf 解密并组装 SDK 用的代理参数;id 为 nil 返回 nil(直连)。
|
||||||
func (s *ProxyService) SpecOf(ctx context.Context, id *uint) (*oci.ProxySpec, error) {
|
func (s *ProxyService) SpecOf(ctx context.Context, id *uint) (*oci.ProxySpec, error) {
|
||||||
if id == nil {
|
if id == nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -227,3 +228,99 @@ func TestProxyGeoProbe(t *testing.T) {
|
|||||||
t.Fatalf("probe view = %+v, want 空地区带探测时间", view)
|
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