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.
This commit is contained in:
Max 2026-04-28 19:33:27 +08:00
parent 934424f9ea
commit 3bc15b1039
7 changed files with 505 additions and 0 deletions

1
.gitignore vendored
View file

@ -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

View file

@ -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)
}

View file

@ -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 →"

View file

@ -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,
})
}

255
openapi/setting/system.go Normal file
View file

@ -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,
}
}

55
openapi/setting/types.go Normal file
View file

@ -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"`
}

View file

@ -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")
}