jim800121chen 4d0b870480 feat(visionA-backend): DB 接入 — 6 store 接 PostgreSQL/Redis 持久化(塊 0-5)
把 visionA-backend 6 個 in-memory store 接到資料庫持久化,範圍=完整
(PG 全接 + session 接 Redis + 交易韌性)。interface / handler 不動,
只加 DB 實作 + 換 wiring,config 未設 DB 時保留 in-memory fallback。

- 塊 0 基礎建設:pgx/v5 連線池 + DatabaseConfig/RedisConfig + golang-migrate
  runner(embed)+ cmd/migrate + testcontainers 測試基礎建設
- 塊 1 model → Postgres:array 映射、upsert 保留 CreatedAt、faa_object_key、
  三維 filter(owner/chip/source)、soft-delete partial index
- 塊 2 device → Postgres:partial unique(已刪 serial 可重註冊)、雙狀態欄位
- 塊 3 token → Postgres:pairing_tokens + session_tokens 分表、token_hash 當 PK
- 塊 4 userSession → Redis:idle + absolute 雙 TTL 取代 cleanup goroutine
  (tunnel session 維持 in-memory,yamux handle 不可序列化)
- 塊 5 交易/韌性:WithTx helper + 刪 device cascade 撤銷 token(同 tx 原子)
  + /healthz ping PG/Redis(fail-fast 503)+ pgx error 統一映射(不洩漏 raw error)

降級策略(fail-fast):PG 掉 → 持久資料 API 回 503;Redis 掉 → session 失敗
不自動 fallback in-memory(避免多機 session 不同步)。

DB:PostgreSQL 14.23(gen_random_uuid 內建、無 citext → email 用 lower() unique
index)。每塊經 Reviewer 審查 + 真 PG/Redis testcontainers 全量 dbtest 綠燈,
in-memory fallback 未受影響。

docs: 同步更新 database.md(schema/config/migration 清單)+ api-spec.md
(409/503 錯誤碼、/healthz 新行為、device unpair cascade)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:28:04 +08:00

449 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package config 定義 visionA-backend 的組態結構,對齊 TDD §2.10。
//
// 雛形遵循 12-Factor App所有可變設定皆透過環境變數注入不寫死在程式碼裡。
// `api-server` 與 `remote-proxy` 共享同一份 Config各自只消費自己需要的欄位。
package config
import "time"
// Config 是整個 visionA-backend 的環境設定。
//
// 所有欄位皆由 Load() 從環境變數讀取並套用預設值。
// 欄位命名對齊 TDD §2.10;新增欄位時請同步更新 `.env.example`(待 B6
type Config struct {
Server ServerConfig
Session SessionConfig
Auth AuthConfig
OIDC OIDCConfig
UserSession UserSessionConfig
Storage StorageConfig
Model ModelConfig
Tunnel TunnelConfig
Logger LoggerConfig
CORS CORSConfig
// Conversion 控制 Phase 0.8 轉檔功能整合converter / FAA / MC service token
// 對齊 .autoflow/04-architecture/conversion.md §5.3。
Conversion ConversionConfig
// FileAccess 控制 Phase 0.9 模型庫 model 直連 FAA 下載鏈ADR-017 (a))。
FileAccess FileAccessConfig
// Database 控制 PostgreSQL 連線DB 接入塊 0持久業務資料 model / device / token
// 對齊 docs/autoflow/04-architecture/database.md §5.5.1。
Database DatabaseConfig
// Redis 控制 Redis 連線DB 接入塊 4僅 userSession browser cookie session
// 對齊 docs/autoflow/04-architecture/database.md §5.5.2。塊 0 不 wire只先把 config 鉤子留好。
Redis RedisConfig
}
// ServerConfig 控制 HTTP listener 的位址與埠號。
//
// api-server 端使用 Port 提供 REST / WebSocket
// remote-proxy 端使用 TunnelPort面向 local agent與 InternalPort面向 api-server
//
// Port 預設為 3721 — 對齊 local-tool 的 base URL這樣 local-tool 前端切到雲端版時
// base URL 可以維持一致,降低前端的 dev 流程切換成本B4 決定)。
type ServerConfig struct {
Host string // VISIONA_HOST預設 "0.0.0.0"
Port int // VISIONA_API_PORT預設 3721對齊 local-tool
TunnelPort int // VISIONA_TUNNEL_PORT預設 3800
InternalPort int // VISIONA_PROXY_INTERNAL_PORT預設 3801
// RelayPublicURL 是 agent 連 tunnel 用的對外可達 URL通常是 wss://.../tunnel/connect
// 的 origin 部分wss://relay.visionA.cloud
// AB11 新增:/api/pairing/exchange 會把這個值回傳給 agent。
// 雛形預設為空 — handler 會 fallback 到 placeholder `wss://relay.visionA.cloud`。
RelayPublicURL string
// SeedDemoData 控制 api-server 啟動時是否塞入示範用 device + model + pairing token。
// 預設 false本機開發或 demo 時可設 VISIONA_SEED_DEMO_DATA=true 開啟,
// 方便前端不必跑完整 pairing 流程就能看到資料。
SeedDemoData bool
}
// SessionConfig 控制 SessionStore 的實作選擇與連線資訊。
//
// Backend:
// - "inmemory" — remote-proxy 端持有 yamux session 的唯一來源
// - "proxy-client" — api-server 端透過 internal HTTP 查詢 remote-proxy
type SessionConfig struct {
Backend string // VISIONA_SESSION_BACKEND預設 "inmemory"
ProxyInternalURL string // VISIONA_PROXY_INTERNAL_URL預設 "http://localhost:3801"
}
// AuthConfig 控制雛形專用的 user fallback 與 pairing token。
//
// OB52026-04-26起認證走 OIDCOIDCConfig
// Phase 0.72026-05-01security audit 移除了 api.Deps.StaticUserID handler fallback
// (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md C1
// 此處的 StaticUserID 欄位**僅供 dev seedVISIONA_SEED_DEMO_DATA=true與 unit test
// fixture 讀取使用**,不再注入 api.Deps、不影響 stage/prod 認證行為。
type AuthConfig struct {
// StaticUserID — Deprecated for routing/auth use. 僅供 dev seed / unit test。
// 見 internal/api/api.go 的 Deps 註解stage/prod 留空無影響。
StaticUserID string // VISIONA_STATIC_USER_ID預設 "demo-user"dev seed only
PairingToken string // VISIONA_PAIRING_TOKEN格式必須為 vAc_ + 32 hex
SigningSecret string // VISIONA_STORAGE_SIGNING_SECRETpresigned URL HMAC secret
}
// OIDCConfig 控制 OpenID Connect 登入流程BFF 模式)。
//
// 對齊 oidc-tdd.md §13.1 + ADR-010 + ADR-011 + ADR-013。
// OB5 起 OIDC 是唯一認證路徑A1 起支援 public PKCE-only client。
type OIDCConfig struct {
// IssuerURL 是 OIDC IdP 的 issuer不帶結尾斜線例如
// dev: http://localhost:5050
// prod: https://members.innovedus.com
// 對齊 VISIONA_OIDC_ISSUER_URL。
IssuerURL string
// ClientID 是 visionA 在 IdP 註冊的 OAuth client idconfidential 或 public 皆可)。
// 對齊 VISIONA_OIDC_CLIENT_ID。
ClientID string
// ClientSecret 為**選填**A1, 2026-05-01
// - 有值 → confidential client modeclient_secret + PKCE 雙保險)
// - 留空 → PKCE-only public client mode純依靠 PKCE 防 code interception
// 兩種 mode 由 IdP 決定visionA-backend 都支援(見 ADR-013
// **禁止 commit 進 repo**;對齊 VISIONA_OIDC_CLIENT_SECRET。
ClientSecret string
// RedirectURL 是 visionA-backend 的 callback URL必須與 IdP 註冊值完全一致。
// dev: http://localhost:3721/api/auth/callback
// prod: https://api.visiona.cloud/api/auth/callback
// 對齊 VISIONA_OIDC_REDIRECT_URL。
RedirectURL string
// PostLoginURL 是 callback 完成後 302 回 frontend 的 base URL。
// dev: http://localhost:3000
// prod: https://app.visiona.cloud
// 對齊 VISIONA_FRONTEND_URL沿用 oidc-tdd.md §13.1 命名)。
PostLoginURL string
// ServiceClientID 是「visionA-backend 以服務身份呼叫 MC API」用的 client id
// 預留給未來 client_credentials grant flow例如查詢使用者組織、推送通知等
//
// **A1 階段不啟用**Validate() 不檢查、main.go 不 wire只先把 config 鉤子留好,
// 之後接時不必再改 OIDCConfig schema。對齊 VISIONA_OIDC_SERVICE_CLIENT_ID。
ServiceClientID string
// ServiceClientSecret 是 service clientclient_credentials grant的 secret。
// 與 ServiceClientID 配對使用;同樣 A1 階段不啟用、Validate() 不檢查。
// **禁止 commit 進 repo**;對齊 VISIONA_OIDC_SERVICE_CLIENT_SECRET。
ServiceClientSecret string
}
// UserSessionConfig 控制 OIDC 登入後在 browser 端建立的 cookie session。
//
// 注意:與既有 SessionConfigtunnel session 用)刻意分開,避免命名混淆。
// 對齊 oidc-tdd.md §5、§13.1。
type UserSessionConfig struct {
// Secret 是 cookie HMAC-SHA256 簽章金鑰;應為至少 32 byte 隨機字串。
// 對齊 VISIONA_SESSION_SECRET。
Secret string
// CookieName 預設 "visiona_session"。
// 對齊 VISIONA_SESSION_COOKIE_NAME。
CookieName string
// CookieDomaindev 留空host-only cookieprod 設 ".visiona.cloud"。
// 對齊 VISIONA_SESSION_COOKIE_DOMAIN。
CookieDomain string
// CookieSecure 控制 Secure flag。dev=falsehttpprod=truehttps
// 對齊 VISIONA_SESSION_COOKIE_SECURE。
CookieSecure bool
// AbsoluteTTL 是 session 的最長存活時間(從 Create 起算)。預設 168h7 天)。
// 對齊 VISIONA_SESSION_ABSOLUTE_TTL。
AbsoluteTTL time.Duration
// IdleTTL 是 session 的閒置存活時間(從 LastSeenAt 起算)。預設 24h。
// 對齊 VISIONA_SESSION_IDLE_TTL。
IdleTTL time.Duration
}
// StorageConfig 控制儲存層實作LocalFS / S3與路徑。
type StorageConfig struct {
Backend string // VISIONA_STORAGE_BACKEND預設 "localfs"
RootDir string // VISIONA_STORAGE_LOCALFS_ROOT預設 "./data/storage"
BaseURL string // VISIONA_STORAGE_LOCALFS_BASE_URL預設 "http://localhost:3721/storage"(對齊 api-server port
}
// ModelConfig 針對模型資源的驗證限制(大小等)。
type ModelConfig struct {
// MaxSizeMB 是允許上傳的單一模型檔案大小上限MB
// PRD §8.4 規範 Phase 0 為 100 MB可由 VISIONA_MODEL_MAX_SIZE_MB 覆寫。
MaxSizeMB int
}
// TunnelConfig 控制 tunnel 心跳與掉線判定閾值,對齊 tunnel.md §4.2。
type TunnelConfig struct {
// HeartbeatInterval 為 yamux KeepAliveInterval 值。預設 10s。
HeartbeatInterval time.Duration
// IdleTimeout 為判定對端失聯的時間。預設 30s= 3 次心跳未回)。
IdleTimeout time.Duration
}
// LoggerConfig 控制結構化 logger 的輸出等級。
type LoggerConfig struct {
Level string // VISIONA_LOG_LEVELdebug / info / warn / error預設 "info"
}
// ConversionConfig 控制 Phase 0.8 / 0.8b 轉檔功能整合。
//
// 對齊 docs/autoflow/04-architecture/conversion.md §3、
// `adr/adr-015-server-to-server-api-key.md`、`adr/adr-016-download-via-converter.md`。
//
// 啟用判定(由 Enabled() 給 main.go 用Phase 0.8b v0.6 起2 個必要欄位
// ConverterBaseURL / ConverterAPIKey**全部非空**才視為啟用;任一缺即視為未啟用,
// 5 個 /api/conversion/* endpoint 不會 wiremain.go 在 wire 階段跳過、log warn
//
// **Phase 0.8b 變更**:服務間認證從 OAuth client_credentials 改為 pre-shared API keyADR-015
//
// **Phase 0.8b T5 完成**(見 conversion.md §3.2 / ADR-015 §5 §7原暫留欄位
// TenantID / DelegatedTTLSeconds 已移除 — MC 認證鏈與 delegated download token 機制
// 都不存在了,兩個欄位連同對應 envVISIONA_OIDC_TENANT_ID /
// VISIONA_FAA_DELEGATED_TTL_SECONDS一併清除。
//
// **Phase 0.8b v0.6 T4 完成**(見 ADR-016 §2 / conversion.md v0.6.1 §3.1):原 FAA 相關
// 欄位 FAABaseURL / FAAAPIKey 已移除 — visionA 端不再直接呼叫 FAAdownload / promote
// 流程改走 converter.GetResultADR-016 撤回 v0.5 設計缺口),兩個欄位連同對應 env
// VISIONA_FAA_BASE_URL / VISIONA_FAA_API_KEY一併清除`Enabled()` 也簡化為只判
// converter 兩欄位。
type ConversionConfig struct {
// ConverterBaseURL 是 kneron_model_converter task-scheduler 服務的 base URL。
// 例http://192.168.0.130:9501dev / stage / https://converter.visiona.cloudprod
// 對齊 VISIONA_CONVERTER_BASE_URL留空 = 不啟用 Phase 0.8 轉檔功能。
ConverterBaseURL string
// ConverterAPIKey 是 visionA → converter 服務間認證的 pre-shared API keyPhase 0.8b 新增)。
// 對齊 VISIONA_CONVERTER_API_KEY以 `Authorization: Bearer <key>` 形式帶上。
// 雙方獨立產生(`openssl rand -hex 32`visionA 端的值必須與 converter 端的
// `CONVERTER_API_KEY` env 對齊;不對齊 → 下游 401visionA 端不重試,回 502 converter_auth_failed
// 對應 ADR-015 §3。
//
// 安全log 永遠不印此值全文(可印 `api_key_set=true/false` 或前 8 字元 prefix
// 部署用 AWS Secrets Manager / Vault嚴格分環境dev / stage / prod 各自獨立 key
ConverterAPIKey string
// MaxModelSizeMB 是 visionA-backend 端對上傳模型檔的大小上限MB
// 與 converter 端 limit 對齊converter 預設 500 MB
// 對齊 VISIONA_CONVERTER_MAX_MODEL_SIZE_MB預設 500。
MaxModelSizeMB int
}
// FileAccessConfig 控制「模型庫 model 直連 FAA 下載」鏈路ADR-017 (a))。
//
// 對齊 adr/adr-017-model-library-access.md §10stage e2e 實測藍本):
//
// visionA 用 service client 打 MC `/oauth/token`scope files:download.delegate
// → 打 MC `POST /file-access/download-tokens`Issue簽 opaque `fdt_` token
// → 回給 Client「FAA 下載 URL + fdt token」Client 帶 `Authorization: Bearer fdt_...`
// 直接 GET `{FAA}/files/{object_key}`。
//
// 啟用判定(由 Enabled() 給 main.go 用4 個必要欄位
// MCBaseURL / ServiceClientID / ServiceClientSecret / TenantID全部非空才視為啟用
// 任一缺即不 wire download token issuermodel download endpoint 回 501。
// FAABaseURL 是「組對外 download_url」用留空時 endpoint 也回 501無從組 URL
//
// ⚠️ 技術債ADR-017 §7 R1 / Q10第一階段 PoC 短期**共用 FAA 的 service client**
// `4242ba63...`stage 實測可拿 files:download.delegate token。MC 規範明訂「OAuth client
// 禁止混用 usage、secret 不共用」,故正式上線前須請 MC 配發 visionA 專屬 usage=file_api
// client 換掉此共用 client把 secret 邊界收回 visionA。詳見 .env.example 對應註解。
type FileAccessConfig struct {
// MCBaseURL 是 Member Center API base URL不帶結尾斜線
// stagehttps://stage-9527.innovedus.com:7850
// 對齊 VISIONA_FILE_ACCESS_MC_BASE_URL。
MCBaseURL string
// ServiceClientID 是打 MC `/oauth/token`client_credentials的 client id。
// ⚠️ 技術債:第一階段 PoC 共用 FAA service client`4242ba63...`)。
// 對齊 VISIONA_FILE_ACCESS_SERVICE_CLIENT_ID。
ServiceClientID string
// ServiceClientSecret 是 service client 的 secret。
// **禁止 commit 進 repo**;對齊 VISIONA_FILE_ACCESS_SERVICE_CLIENT_SECRET。
// 安全log 永遠不印此值全文。
ServiceClientSecret string
// TenantID 是簽 download token 時帶給 MC 的 tenant_id須與 FAA validate 的 tenant 一致)。
// stage732270c0-449c-489c-bfad-321e9bf89b3d
// 對齊 VISIONA_FILE_ACCESS_TENANT_ID。
TenantID string
// FAABaseURL 是 File Access Agent 對外 base URL不帶結尾斜線用來組回給 Client 的
// download_url`{FAABaseURL}/files/{object_key}`)。
// stagehttps://stage-9527.innovedus.com:5081
// 對齊 VISIONA_FILE_ACCESS_FAA_BASE_URL。
FAABaseURL string
// DownloadTokenTTLSeconds 是簽 download token 時帶給 MC 的 expires_in_seconds。
// ADR-017 Q2 區間 60300s預設 120s。對齊 VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS。
DownloadTokenTTLSeconds int
}
// Enabled 回傳「模型庫 FAA 直連下載」是否啟用。
//
// 4 個必要欄位MCBaseURL / ServiceClientID / ServiceClientSecret / TenantID+ FAABaseURL
// 全部非空才視為啟用;任一缺 → main.go 不 wire download token issuer
// GET /api/models/:id/download 回 501。
func (c FileAccessConfig) Enabled() bool {
return c.MCBaseURL != "" &&
c.ServiceClientID != "" &&
c.ServiceClientSecret != "" &&
c.TenantID != "" &&
c.FAABaseURL != ""
}
// Enabled 回傳 Phase 0.8 / 0.8b conversion 是否啟用。
//
// **Phase 0.8b v0.6 T4 簡化**ADR-016 §2 / conversion.md v0.6.1 §3.1visionA 端撤回
// 對 FAA 的直接呼叫download / promote 改走 converter.GetResult只剩 ConverterBaseURL
// 與 ConverterAPIKey 兩個欄位需非空。任一缺 → 視為未啟用main.go 不會 wire
// conversion.Service5 個 endpoint 回 501 / 不註冊)。
func (c ConversionConfig) Enabled() bool {
return c.ConverterBaseURL != "" && c.ConverterAPIKey != ""
}
// DatabaseConfig 控制 PostgreSQL 連線持久業務資料model / device / token
//
// 對齊 docs/autoflow/04-architecture/database.md §5.5.1。
//
// 啟用判定Enabled()Host / User / DBName 三者全非空才視為啟用;
// 任一缺 → main.go 不建連線池、6 個 repository 仍用 in-memorylocal dev fallback
//
// 安全Password / DSN 永遠不印 log 全文(可印 host:port/dbname
// 部署走既有 secrets 機制AWS Secrets Manager / Vault禁止 commit 進 repo。
type DatabaseConfig struct {
Host string // VISIONA_DB_HOST
Port int // VISIONA_DB_PORT預設 5432
User string // VISIONA_DB_USER
Password string // VISIONA_DB_PASSWORD禁止 commit
DBName string // VISIONA_DB_NAME
SSLMode string // VISIONA_DB_SSLMODE預設 "require"stage/prod本機 testcontainers 用 "disable"
// 連線池pgxpool
MaxConns int // VISIONA_DB_MAX_CONNS預設 10
MinConns int // VISIONA_DB_MIN_CONNS預設 2
MaxConnLifetime time.Duration // VISIONA_DB_MAX_CONN_LIFETIME預設 1h
ConnTimeout time.Duration // VISIONA_DB_CONN_TIMEOUT預設 5s建池/ping 逾時)
// AutoMigrate 控制連線池建立後是否自動跑 migrate up。
// 預設 true可由 VISIONA_DB_AUTO_MIGRATE=false 關閉(例如改用獨立 cmd/migrate
AutoMigrate bool // VISIONA_DB_AUTO_MIGRATE預設 true
}
// Enabled 回傳 PostgreSQL 是否啟用。
//
// Host / User / DBName 三者全非空才視為啟用;任一缺 →
// main.go 不建連線池model / device / token repository 維持 in-memorylocal dev fallback
func (c DatabaseConfig) Enabled() bool {
return c.Host != "" && c.User != "" && c.DBName != ""
}
// RedisConfig 控制 Redis 連線(僅 userSessionbrowser cookie session
//
// 對齊 docs/autoflow/04-architecture/database.md §5.5.2。
//
// ⚠️ visionA 專用 Redis 實例:由使用者自行在 stage host(130) 另起、設密碼。
// visionA 端不 provision、只接上。
//
// 啟用判定Enabled()Host 非空才視為啟用;
// 未啟用 → userSession 仍用 in-memory雛形行為process 重啟掉 session
//
// 安全Password 永遠不印 log 全文。禁止 commit 進 repo。
//
// 塊 0 範圍:只先把 config 鉤子留好main.go 尚未 wire等塊 4 接 RedisUserSessionStore
type RedisConfig struct {
Host string // VISIONA_REDIS_HOST
Port int // VISIONA_REDIS_PORT預設 6379
Password string // VISIONA_REDIS_PASSWORDvisionA 專用實例必設密碼;禁止 commit
DB int // VISIONA_REDIS_DB預設 0db index
ConnTimeout time.Duration // VISIONA_REDIS_CONN_TIMEOUT預設 5s
}
// Enabled 回傳 Redis 是否啟用Host 非空即啟用)。
func (c RedisConfig) Enabled() bool {
return c.Host != ""
}
// CORSConfig 控制 api-server 對瀏覽器的 CORS 白名單。
//
// AllowedOrigins 為逗號分隔字串解析後的 slice
// 空時 api.Deps.validate() 會 fallback 到 http://localhost:3000前端 dev server
type CORSConfig struct {
AllowedOrigins []string // VISIONA_CORS_ALLOWED_ORIGINS逗號分隔
}
// Validate 在 Load() 之後檢查交叉依賴與必填欄位。
//
// OB5 起 OIDC 是唯一認證路徑,所有 OIDC 必填欄位永遠都要非空:
// - IssuerURL / ClientID / RedirectURL / PostLoginURL
// - UserSession.Secretcookie HMAC 簽章)
//
// ClientSecret 為**選填**A1, 2026-05-01
// - 有值 → confidential client mode標準 OAuth + PKCE 雙保險)
// - 留空 → PKCE-only public client mode依靠 PKCE 防 code interception
//
// 兩種 mode 由 IdP 決定visionA 都支援(見 ADR-013、oidc-tdd.md §13.1)。
//
// ServiceClientID / ServiceClientSecret 為 client_credentials grant 預留欄位,
// A1 階段不啟用、不檢查;之後若接服務間 API 呼叫再補 Validate。
//
// 缺任何**必填**項 → 回 *MissingEnvErrormain.go 啟動時 fatal log 退出。
// 維持單一 error 而非列表 — caller 只是 fail-fast 紀錄,不需要結構化處理。
func (c *Config) Validate() error {
missing := make([]string, 0, 5)
if c.OIDC.IssuerURL == "" {
missing = append(missing, "VISIONA_OIDC_ISSUER_URL")
}
if c.OIDC.ClientID == "" {
missing = append(missing, "VISIONA_OIDC_CLIENT_ID")
}
// ClientSecret 為選填public PKCE-only client 留空)— 不檢查。
if c.OIDC.RedirectURL == "" {
missing = append(missing, "VISIONA_OIDC_REDIRECT_URL")
}
if c.OIDC.PostLoginURL == "" {
missing = append(missing, "VISIONA_FRONTEND_URL")
}
if c.UserSession.Secret == "" {
missing = append(missing, "VISIONA_SESSION_SECRET")
}
if len(missing) > 0 {
return &MissingEnvError{Vars: missing}
}
return nil
}
// MissingEnvError 表示 OIDC 必填環境變數缺少OB5 起永遠檢查)。
type MissingEnvError struct {
Vars []string
}
func (e *MissingEnvError) Error() string {
return "config: OIDC enabled but required env vars are missing: " + joinStrings(e.Vars, ", ")
}
// joinStrings 是 strings.Join 的本地版本,避免單純為了 join 引入 strings package。
func joinStrings(parts []string, sep string) string {
switch len(parts) {
case 0:
return ""
case 1:
return parts[0]
}
n := len(sep) * (len(parts) - 1)
for _, p := range parts {
n += len(p)
}
out := make([]byte, 0, n)
out = append(out, parts[0]...)
for _, p := range parts[1:] {
out = append(out, sep...)
out = append(out, p...)
}
return string(out)
}