代理批量关联租户接口、关于页运行时与资源指标
CI / test (push) Successful in 31s
Release / release (push) Successful in 57s

This commit is contained in:
2026-07-13 12:23:18 +08:00
parent c7cc5616ed
commit 81d4650f3d
13 changed files with 434 additions and 8 deletions
+2
View File
@@ -7,6 +7,8 @@
### Added
- 恢复 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 / -shmMySQL / PostgreSQL 外部库返回 -1 表示不可度量)
- README 重构部署、配置、鉴权、反向代理、AI 网关与升级发布说明,并新增 Responses、Chat Completions、Embeddings、Anthropic Messages 与标准接口的字段兼容矩阵,明确直通、转换、降级、忽略和拒绝边界
### Changed
+2
View File
@@ -147,6 +147,8 @@ func run() error {
console := service.NewConsoleService(ociConfigs)
proxies := service.NewProxyService(db, cipher)
defer proxies.Wait()
// 「关于」页存储指标需知数据库形态
api.SetAboutRuntime(cfg.DBDriver, cfg.DBPath)
router := api.NewRouter(auth, oauth, ociConfigs, tasks, console, settings, notifier, systemLogs, logEvents, proxies, aiGateway)
return serveHTTP(cfg.Addr, router)
}
+41 -1
View File
@@ -154,7 +154,7 @@ const docTemplate = `{
"tags": [
"设置"
],
"summary": "返回构建运行环境信息,「设置 · 关于」页展示",
"summary": "返回构建运行时长与资源占用信息,「设置 · 关于」页展示",
"responses": {
"200": {
"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": {
"get": {
"security": [
+41 -1
View File
@@ -147,7 +147,7 @@
"tags": [
"设置"
],
"summary": "返回构建运行环境信息,「设置 · 关于」页展示",
"summary": "返回构建运行时长与资源占用信息,「设置 · 关于」页展示",
"responses": {
"200": {
"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": {
"get": {
"security": [
+26 -1
View File
@@ -671,7 +671,7 @@ paths:
type: object
security:
- BearerAuth: []
summary: 返回构建运行环境信息,「设置 · 关于」页展示
summary: 返回构建运行时长与资源占用信息,「设置 · 关于」页展示
tags:
- 设置
/api/v1/ai-blacklist:
@@ -3668,6 +3668,31 @@ paths:
summary: 手动重测代理出口地区,同步返回最新视图
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:
post:
parameters:
+51 -1
View File
@@ -1,8 +1,11 @@
package api
import (
"math"
"net/http"
"os"
"runtime"
"time"
"github.com/gin-gonic/gin"
)
@@ -12,11 +15,20 @@ 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
@@ -27,5 +39,43 @@ func about(c *gin.Context) {
"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
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !unix
package api
import "time"
// processCPUTime 非 unix 平台无 getrusage,CPU 均值退化为 0。
func processCPUTime() time.Duration { return 0 }
+57
View File
@@ -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)
}
}
+18
View File
@@ -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())
}
+29
View File
@@ -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 {
+1
View File
@@ -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)
}
+57
View File
@@ -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 {
+97
View File
@@ -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)
}
}
}