diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f4ba8e..89e7809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 / -shm;MySQL / PostgreSQL 外部库返回 -1 表示不可度量) - README 重构部署、配置、鉴权、反向代理、AI 网关与升级发布说明,并新增 Responses、Chat Completions、Embeddings、Anthropic Messages 与标准接口的字段兼容矩阵,明确直通、转换、降级、忽略和拒绝边界 ### Changed diff --git a/cmd/server/main.go b/cmd/server/main.go index cb8aa22..d1f62c2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) } diff --git a/docs/docs.go b/docs/docs.go index cca3630..8ee4b86 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": [ diff --git a/docs/swagger.json b/docs/swagger.json index 7f7361a..d1a391c 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c08f00c..71407ef 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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: diff --git a/internal/api/about.go b/internal/api/about.go index 1ef087d..4941924 100644 --- a/internal/api/about.go +++ b/internal/api/about.go @@ -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 +} diff --git a/internal/api/about_other.go b/internal/api/about_other.go new file mode 100644 index 0000000..191ecb0 --- /dev/null +++ b/internal/api/about_other.go @@ -0,0 +1,8 @@ +//go:build !unix + +package api + +import "time" + +// processCPUTime 非 unix 平台无 getrusage,CPU 均值退化为 0。 +func processCPUTime() time.Duration { return 0 } diff --git a/internal/api/about_test.go b/internal/api/about_test.go new file mode 100644 index 0000000..372cbbc --- /dev/null +++ b/internal/api/about_test.go @@ -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) + } +} diff --git a/internal/api/about_unix.go b/internal/api/about_unix.go new file mode 100644 index 0000000..90038da --- /dev/null +++ b/internal/api/about_unix.go @@ -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()) +} diff --git a/internal/api/proxy.go b/internal/api/proxy.go index 16d113d..be8b64b 100644 --- a/internal/api/proxy.go +++ b/internal/api/proxy.go @@ -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 { diff --git a/internal/api/routes_settings.go b/internal/api/routes_settings.go index 69b2550..70ed177 100644 --- a/internal/api/routes_settings.go +++ b/internal/api/routes_settings.go @@ -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) } diff --git a/internal/service/proxy.go b/internal/service/proxy.go index ff2e91f..ef8ac76 100644 --- a/internal/service/proxy.go +++ b/internal/service/proxy.go @@ -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 { diff --git a/internal/service/proxy_test.go b/internal/service/proxy_test.go index ab9df7d..9eb763b 100644 --- a/internal/service/proxy_test.go +++ b/internal/service/proxy_test.go @@ -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) + } + } +}