From 3bc15b10394a54bc8453e56031aa12bb2a0742fa Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 28 Apr 2026 19:33:27 +0800 Subject: [PATCH] feat(openapi): add unified /setting/* endpoints for OpenAPI - Introduced handlers for the new /setting/* endpoints in the OpenAPI module. - Updated import statements to include the new setting package for better organization. - Enhanced routing capabilities to support the new settings functionality. --- .gitignore | 1 + openapi/openapi.go | 4 + openapi/setting/promotions.yml | 42 +++++ openapi/setting/setting.go | 40 +++++ openapi/setting/system.go | 255 +++++++++++++++++++++++++++ openapi/setting/types.go | 55 ++++++ openapi/tests/setting/system_test.go | 108 ++++++++++++ 7 files changed, 505 insertions(+) create mode 100644 openapi/setting/promotions.yml create mode 100644 openapi/setting/setting.go create mode 100644 openapi/setting/system.go create mode 100644 openapi/setting/types.go create mode 100644 openapi/tests/setting/system_test.go diff --git a/.gitignore b/.gitignore index 300db619..1756c0d4 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,4 @@ agent/robot/ROBOT-CACHE-IMPROVEMENT.md sandbox/v2/PID-KILL-UPGRADE.md sandbox/v2/*.md POSTGRESQL_COMPAT.md +openapi/setting/*.md diff --git a/openapi/openapi.go b/openapi/openapi.go index 49f2c78f..c00cec4d 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -27,6 +27,7 @@ import ( "github.com/yaoapp/yao/openapi/otp" "github.com/yaoapp/yao/openapi/response" "github.com/yaoapp/yao/openapi/sandbox" + openAPISetting "github.com/yaoapp/yao/openapi/setting" openapiTai "github.com/yaoapp/yao/openapi/tai" "github.com/yaoapp/yao/openapi/team" openapiTrace "github.com/yaoapp/yao/openapi/trace" @@ -199,6 +200,9 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) { group.POST("/tai-nodes/heartbeat", taiapi.HandleHeartbeat) group.DELETE("/tai-nodes/register/:tai_id", taiapi.HandleUnregister) + // Setting handlers (unified /setting/* endpoints) + openAPISetting.Attach(group.Group("/setting"), openapi.OAuth) + // Custom handlers (Defined by developer) } diff --git a/openapi/setting/promotions.yml b/openapi/setting/promotions.yml new file mode 100644 index 00000000..a85feec9 --- /dev/null +++ b/openapi/setting/promotions.yml @@ -0,0 +1,42 @@ +# Promotions & localized labels for the System Info page. +# Embedded at compile time via go:embed. + +# --- Localized UI labels --- +labels: + deployment: + community: + zh: "社区版" + en: "Community" + starter: + zh: "入门版" + en: "Starter" + pro: + zh: "专业版" + en: "Pro" + enterprise: + zh: "企业版" + en: "Enterprise" + cloud: + zh: "Cloud" + en: "Cloud" + environment: + development: + zh: "测试环境" + en: "Development" + production: + zh: "正式环境" + en: "Production" + +# --- Promotions by deployment type --- +community: + - id: upgrade-enterprise + link: "https://yaoagents.com/enterprise?source=yao-setting" + i18n: + zh: + title: "升级到企业版" + desc: "专属支持、私有部署、完全可控,行业 Agents 方案" + label: "了解更多 →" + en: + title: "Upgrade to Enterprise" + desc: "Dedicated support, private deployment, full control, industry-specific Agents solutions" + label: "Learn more →" diff --git a/openapi/setting/setting.go b/openapi/setting/setting.go new file mode 100644 index 00000000..130f3ec2 --- /dev/null +++ b/openapi/setting/setting.go @@ -0,0 +1,40 @@ +package setting + +import ( + "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/openapi/oauth/authorized" + oauthTypes "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/openapi/response" + "github.com/yaoapp/yao/setting" +) + +// Attach registers all /setting/* routes under the given group. +// Currently only System Info routes are wired; other groups will be +// added incrementally. +func Attach(group *gin.RouterGroup, oauth oauthTypes.OAuth) { + group.Use(oauth.Guard) + + sys := group.Group("/system") + sys.GET("", handleSystemInfo) + sys.POST("/check-update", handleSystemCheckUpdate) +} + +// resolveOwner extracts the authenticated user/team from the Gin context +// and returns a setting.ScopeID suitable for registry operations. +func resolveOwner(c *gin.Context) setting.ScopeID { + info := authorized.GetInfo(c) + return setting.ScopeID{ + Scope: setting.ScopeUser, + TeamID: info.TeamID, + UserID: info.UserID, + } +} + +// respondError is a thin helper that writes a JSON error via the shared +// response package. +func respondError(c *gin.Context, status int, msg string) { + response.RespondWithError(c, status, &response.ErrorResponse{ + Code: "server_error", + ErrorDescription: msg, + }) +} diff --git a/openapi/setting/system.go b/openapi/setting/system.go new file mode 100644 index 00000000..1a46169a --- /dev/null +++ b/openapi/setting/system.go @@ -0,0 +1,255 @@ +package setting + +import ( + _ "embed" + "encoding/json" + "fmt" + "net/http" + "runtime" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/commercial" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/openapi/response" + "github.com/yaoapp/yao/share" + "gopkg.in/yaml.v3" +) + +const cdnBase = "https://get.yaoapps.com/yao" + +// update check cache (package-level, protected by mutex) +var ( + updateCache *CheckUpdateResult + updateCacheTime time.Time + updateMu sync.Mutex + cacheTTL = 10 * time.Minute +) + +// handleSystemInfo returns aggregated system information. +// GET /setting/system?locale=zh-cn +func handleSystemInfo(c *gin.Context) { + locale := strings.ToLower(c.DefaultQuery("locale", "en-us")) + + env := share.App.Option["env"] + environment, _ := env.(string) + if environment == "" { + environment = config.Conf.Mode + } + if environment == "" { + environment = "production" + } + + listen := fmt.Sprintf("%s:%d", config.Conf.Host, config.Conf.Port) + sessionStore := config.Conf.Session.Store + if sessionStore == "" { + sessionStore = "file" + } + + lang := langFromLocale(locale) + lic := commercial.License + deployment := lic.Edition + if deployment == "" { + deployment = "community" + } + + var licenseKey string + if lic.Valid && lic.SerialNumber != "" { + licenseKey = lic.SerialNumber + } + + data := SystemInfoData{ + App: AppInfo{ + Name: share.App.Name, + Short: share.App.Short, + Description: share.App.Description, + Logo: "/api/__yao/app/icons/app.png", + Version: share.App.Version, + }, + Deployment: deployment, + DeploymentLabel: resolveLabel(promFile.Labels.Deployment, deployment, lang, deployment), + LicenseKey: licenseKey, + Environment: environment, + EnvironmentLabel: resolveLabel(promFile.Labels.Environment, environment, lang, environment), + Server: VersionInfo{ + Version: share.VERSION, + BuildDate: share.PRVERSION, + CommitSHA: share.PRVERSION, + }, + Client: VersionInfo{ + Version: share.CUI, + BuildDate: share.PRCUI, + CommitSHA: share.PRCUI, + }, + Technical: TechnicalInfo{ + Listen: listen, + DBDriver: config.Conf.DB.Driver, + SessionStore: sessionStore, + }, + Promotions: buildPromotions(deployment, locale), + } + + response.RespondWithSuccess(c, http.StatusOK, data) +} + +//go:embed promotions.yml +var promotionsYML []byte + +type promotionEntry struct { + ID string `yaml:"id"` + Link string `yaml:"link"` + I18n map[string]promotionLocale `yaml:"i18n"` +} + +type promotionLocale struct { + Title string `yaml:"title"` + Desc string `yaml:"desc"` + Label string `yaml:"label"` +} + +type promotionsFile struct { + Labels struct { + Deployment map[string]map[string]string `yaml:"deployment"` + Environment map[string]map[string]string `yaml:"environment"` + } `yaml:"labels"` + Community []promotionEntry `yaml:"community"` + Enterprise []promotionEntry `yaml:"enterprise"` + Cloud []promotionEntry `yaml:"cloud"` +} + +var promFile promotionsFile + +func init() { + yaml.Unmarshal(promotionsYML, &promFile) +} + +func resolveLabel(m map[string]map[string]string, key, lang, fallback string) string { + if langs, ok := m[key]; ok { + if v, ok := langs[lang]; ok { + return v + } + if v, ok := langs["en"]; ok { + return v + } + } + return fallback +} + +func langFromLocale(locale string) string { + if strings.HasPrefix(locale, "zh") { + return "zh" + } + return "en" +} + +func buildPromotions(deployment, locale string) []Promotion { + lang := langFromLocale(locale) + + var entries []promotionEntry + switch deployment { + case "community": + entries = promFile.Community + case "enterprise": + entries = promFile.Enterprise + case "cloud": + entries = promFile.Cloud + } + if len(entries) == 0 { + return nil + } + + promos := make([]Promotion, 0, len(entries)) + for _, e := range entries { + loc, ok := e.I18n[lang] + if !ok { + loc = e.I18n["en"] + } + promos = append(promos, Promotion{ + ID: e.ID, + Title: loc.Title, + Desc: loc.Desc, + Link: e.Link, + Label: loc.Label, + }) + } + return promos +} + +// handleSystemCheckUpdate checks for a newer engine release. +// Uses the same CDN source as `yao upgrade` and yao-desktop: +// +// GET https://get.yaoapps.com/yao/latest.json +// +// POST /setting/system/check-update +func handleSystemCheckUpdate(c *gin.Context) { + updateMu.Lock() + if updateCache != nil && time.Since(updateCacheTime) < cacheTTL { + result := *updateCache + updateMu.Unlock() + response.RespondWithSuccess(c, http.StatusOK, result) + return + } + updateMu.Unlock() + + result := fetchLatestVersion() + + updateMu.Lock() + updateCache = &result + updateCacheTime = time.Now() + updateMu.Unlock() + + response.RespondWithSuccess(c, http.StatusOK, result) +} + +// cdnLatest mirrors the JSON structure of get.yaoapps.com/yao/latest.json +// (same format used by cmd/upgrade.go and yao-desktop updater.rs). +type cdnLatest struct { + Version string `json:"version"` + ReleasedAt string `json:"released_at"` + Assets map[string]string `json:"assets"` +} + +func fetchLatestVersion() CheckUpdateResult { + current := strings.TrimPrefix(share.VERSION, "v") + base := CheckUpdateResult{HasUpdate: false, CurrentVersion: current} + + url := cdnBase + "/latest.json" + client := &http.Client{Timeout: 15 * time.Second} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return base + } + req.Header.Set("User-Agent", fmt.Sprintf("yao/%s", share.VERSION)) + + resp, err := client.Do(req) + if err != nil { + return base + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return base + } + + var data cdnLatest + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return base + } + + latest := strings.TrimPrefix(data.Version, "v") + if latest == "" { + return base + } + + platformKey := fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH) + downloadURL := data.Assets[platformKey] + + return CheckUpdateResult{ + HasUpdate: latest != current, + CurrentVersion: current, + LatestVersion: latest, + DownloadURL: downloadURL, + } +} diff --git a/openapi/setting/types.go b/openapi/setting/types.go new file mode 100644 index 00000000..f8d3ec68 --- /dev/null +++ b/openapi/setting/types.go @@ -0,0 +1,55 @@ +package setting + +// SystemInfoData is the top-level response for GET /setting/system. +type SystemInfoData struct { + App AppInfo `json:"app"` + Deployment string `json:"deployment"` + DeploymentLabel string `json:"deployment_label"` + LicenseKey string `json:"license_key,omitempty"` + Server VersionInfo `json:"server"` + Client VersionInfo `json:"client"` + Environment string `json:"environment"` + EnvironmentLabel string `json:"environment_label"` + Technical TechnicalInfo `json:"technical"` + Promotions []Promotion `json:"promotions,omitempty"` +} + +// Promotion is a localized CTA banner returned by the API. +type Promotion struct { + ID string `json:"id"` + Title string `json:"title"` + Desc string `json:"desc"` + Link string `json:"link"` + Label string `json:"label"` +} + +// AppInfo describes the running application. +type AppInfo struct { + Name string `json:"name"` + Short string `json:"short"` + Description string `json:"description"` + Logo string `json:"logo"` + Version string `json:"version"` +} + +// VersionInfo carries build metadata for a component (engine / CUI). +type VersionInfo struct { + Version string `json:"version"` + BuildDate string `json:"build_date"` + CommitSHA string `json:"commit"` +} + +// TechnicalInfo contains runtime / infrastructure details. +type TechnicalInfo struct { + Listen string `json:"listen"` + DBDriver string `json:"db_driver"` + SessionStore string `json:"session_store"` +} + +// CheckUpdateResult is the response for POST /setting/system/check-update. +type CheckUpdateResult struct { + HasUpdate bool `json:"has_update"` + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version,omitempty"` + DownloadURL string `json:"download_url,omitempty"` +} diff --git a/openapi/tests/setting/system_test.go b/openapi/tests/setting/system_test.go new file mode 100644 index 00000000..7779e251 --- /dev/null +++ b/openapi/tests/setting/system_test.go @@ -0,0 +1,108 @@ +package setting_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/openapi" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +func baseURL() string { + if openapi.Server != nil && openapi.Server.Config != nil { + return openapi.Server.Config.BaseURL + } + return "" +} + +// TestSystemInfo verifies GET /setting/system returns the expected structure. +func TestSystemInfo(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + client := testutils.RegisterTestClient(t, "Setting System Test", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, client.ClientID) + token := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile") + + req, err := http.NewRequest("GET", serverURL+baseURL()+"/setting/system", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + + // Top-level keys + assert.Contains(t, body, "app") + assert.Contains(t, body, "deployment") + assert.Contains(t, body, "server") + assert.Contains(t, body, "client") + assert.Contains(t, body, "environment") + assert.Contains(t, body, "technical") + + // app sub-fields + app, ok := body["app"].(map[string]interface{}) + assert.True(t, ok) + assert.NotEmpty(t, app["name"]) + assert.NotEmpty(t, app["version"]) + + // server sub-fields + server, ok := body["server"].(map[string]interface{}) + assert.True(t, ok) + assert.NotEmpty(t, server["version"]) + + // technical sub-fields + tech, ok := body["technical"].(map[string]interface{}) + assert.True(t, ok) + assert.NotEmpty(t, tech["listen"]) + assert.NotEmpty(t, tech["db_driver"]) + assert.NotEmpty(t, tech["session_store"]) +} + +// TestSystemInfoUnauthenticated verifies 401 when no token is provided. +func TestSystemInfoUnauthenticated(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + req, err := http.NewRequest("GET", serverURL+baseURL()+"/setting/system", nil) + assert.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +// TestSystemCheckUpdate verifies POST /setting/system/check-update returns has_update. +func TestSystemCheckUpdate(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + client := testutils.RegisterTestClient(t, "Setting CheckUpdate Test", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, client.ClientID) + token := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile") + + req, err := http.NewRequest("POST", serverURL+baseURL()+"/setting/system/check-update", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + + _, exists := body["has_update"] + assert.True(t, exists, "response must contain has_update field") +}