接 DB 後真人 OIDC 登入暴露 MC OIDC provider 實作不完整,visionA 端逐項繞過, 讓登入/換帳號可用;另補真轉檔服務的整合 e2e。 OIDC 登入修復(MC 端根因另有交接檔,visionA 先繞過): - email fallback:MC id_token 不發 email claim(ASP.NET Identity 預設 factory 只發 sub/name)→ A7 email 必填擋住登入。callback email 空時用 <sub>@noemail.visiona.local placeholder,不污染 schema,MC 修好發真 email 後 ON CONFLICT 自動覆寫 - prompt=login:authorize 帶 prompt=login(config VISIONA_OIDC_PROMPT_LOGIN,預設關) - logout 連動 MC:logout 回 idp_logout(MC Web :7880 /account/logout,GET),前端用 隱藏 iframe 觸發清 MC session(Web/Api 共享 DataProtection)→ 能換帳號。 config VISIONA_OIDC_LOGOUT_URL、向下相容(未設則只清本地) 真轉檔鏈路 e2e(//go:build realconv,按需對 stage 跑、不污染主測試集): - real_converter_e2e:give 真轉檔服務 contract(init→poll→completed/promote/result) - real_chain_e2e:真轉檔→PromoteToModels→model 進 PG→冪等 全鏈路(對 stage 跑 PASS) 交接檔(給對應團隊根治): - mc-email-claim-handoff:MC 加 email claim(自訂 UserClaimsPrincipalFactory) - converter-promote-oauth-handoff:轉檔服務 OAuth 用 form body 非 Basic Auth 全程 Reviewer 審查 + 對 stage 真環境驗證。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
186 lines
8.2 KiB
Go
186 lines
8.2 KiB
Go
package config
|
||
|
||
import (
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Load 從環境變數讀取並組出一個 Config。
|
||
//
|
||
// 所有欄位皆有預設值(雛形便利),因此 Load 不會回傳 error;
|
||
// 未來加入必填欄位時(例如 Phase 1 的 DB URL),應改為回傳 error。
|
||
func Load() *Config {
|
||
return &Config{
|
||
Server: ServerConfig{
|
||
Host: getEnvString("VISIONA_HOST", "0.0.0.0"),
|
||
Port: getEnvInt("VISIONA_API_PORT", 3721),
|
||
TunnelPort: getEnvInt("VISIONA_TUNNEL_PORT", 3800),
|
||
InternalPort: getEnvInt("VISIONA_PROXY_INTERNAL_PORT", 3801),
|
||
RelayPublicURL: getEnvString("VISIONA_RELAY_PUBLIC_URL", ""),
|
||
SeedDemoData: getEnvBool("VISIONA_SEED_DEMO_DATA", false),
|
||
},
|
||
Session: SessionConfig{
|
||
Backend: getEnvString("VISIONA_SESSION_BACKEND", "inmemory"),
|
||
ProxyInternalURL: getEnvString("VISIONA_PROXY_INTERNAL_URL", "http://localhost:3801"),
|
||
},
|
||
Auth: AuthConfig{
|
||
// Phase 0.7 security fix C1:VISIONA_STATIC_USER_ID 僅供 dev seed / unit test 用,
|
||
// stage/prod 留空無影響;不再注入 api.Deps(見 internal/api/api.go Deps 註解)。
|
||
StaticUserID: getEnvString("VISIONA_STATIC_USER_ID", "demo-user"),
|
||
PairingToken: getEnvString("VISIONA_PAIRING_TOKEN", ""),
|
||
SigningSecret: getEnvString("VISIONA_STORAGE_SIGNING_SECRET", "dev-signing-secret-do-not-use-in-prod"),
|
||
},
|
||
OIDC: OIDCConfig{
|
||
IssuerURL: getEnvString("VISIONA_OIDC_ISSUER_URL", ""),
|
||
ClientID: getEnvString("VISIONA_OIDC_CLIENT_ID", ""),
|
||
ClientSecret: getEnvString("VISIONA_OIDC_CLIENT_SECRET", ""),
|
||
RedirectURL: getEnvString("VISIONA_OIDC_REDIRECT_URL", ""),
|
||
PostLoginURL: getEnvString("VISIONA_FRONTEND_URL", ""),
|
||
// LogoutURL:MC Web :7880 logout 入口(GET /account/logout)。留空=logout 只清本地、
|
||
// 不連動 MC(向下相容,見 OIDCConfig.LogoutURL 註解)。
|
||
LogoutURL: getEnvString("VISIONA_OIDC_LOGOUT_URL", ""),
|
||
// prompt=login:true 時每次登入都強制 IdP 重新認證(忽略既有 SSO session)。
|
||
PromptLogin: getEnvBool("VISIONA_OIDC_PROMPT_LOGIN", false),
|
||
// A1:client_credentials grant 預留欄位,留空表「不啟用 service client」。
|
||
ServiceClientID: getEnvString("VISIONA_OIDC_SERVICE_CLIENT_ID", ""),
|
||
ServiceClientSecret: getEnvString("VISIONA_OIDC_SERVICE_CLIENT_SECRET", ""),
|
||
},
|
||
UserSession: UserSessionConfig{
|
||
Secret: getEnvString("VISIONA_SESSION_SECRET", ""),
|
||
CookieName: getEnvString("VISIONA_SESSION_COOKIE_NAME", "visiona_session"),
|
||
CookieDomain: getEnvString("VISIONA_SESSION_COOKIE_DOMAIN", ""),
|
||
CookieSecure: getEnvBool("VISIONA_SESSION_COOKIE_SECURE", false),
|
||
AbsoluteTTL: getEnvDuration("VISIONA_SESSION_ABSOLUTE_TTL", 168*time.Hour),
|
||
IdleTTL: getEnvDuration("VISIONA_SESSION_IDLE_TTL", 24*time.Hour),
|
||
},
|
||
Storage: StorageConfig{
|
||
Backend: getEnvString("VISIONA_STORAGE_BACKEND", "localfs"),
|
||
RootDir: getEnvString("VISIONA_STORAGE_LOCALFS_ROOT", "./data/storage"),
|
||
BaseURL: getEnvString("VISIONA_STORAGE_LOCALFS_BASE_URL", "http://localhost:3721/storage"),
|
||
},
|
||
Model: ModelConfig{
|
||
MaxSizeMB: getEnvInt("VISIONA_MODEL_MAX_SIZE_MB", 100),
|
||
},
|
||
Tunnel: TunnelConfig{
|
||
HeartbeatInterval: getEnvDuration("VISIONA_TUNNEL_HEARTBEAT_INTERVAL", 10*time.Second),
|
||
IdleTimeout: getEnvDuration("VISIONA_TUNNEL_IDLE_TIMEOUT", 30*time.Second),
|
||
},
|
||
Logger: LoggerConfig{
|
||
Level: getEnvString("VISIONA_LOG_LEVEL", "info"),
|
||
},
|
||
CORS: CORSConfig{
|
||
AllowedOrigins: getEnvStringSlice("VISIONA_CORS_ALLOWED_ORIGINS", nil),
|
||
},
|
||
// Phase 0.8 / 0.8b conversion (見 docs/autoflow/04-architecture/conversion.md §3、
|
||
// ADR-015、ADR-016)
|
||
// Phase 0.8b T5:原暫留欄位 TenantID / DelegatedTTLSeconds 與對應 env
|
||
// (VISIONA_OIDC_TENANT_ID / VISIONA_FAA_DELEGATED_TTL_SECONDS)已移除 —
|
||
// MC 認證鏈與 delegated download token 機制不存在了。
|
||
// Phase 0.8b v0.6 T4:原 FAA 相關欄位 FAABaseURL / FAAAPIKey 與對應 env
|
||
// (VISIONA_FAA_BASE_URL / VISIONA_FAA_API_KEY)已移除 — ADR-016 撤回 v0.5
|
||
// 設計缺口,visionA 端不再直接呼叫 FAA、download/promote 改走 converter.GetResult。
|
||
Conversion: ConversionConfig{
|
||
ConverterBaseURL: getEnvString("VISIONA_CONVERTER_BASE_URL", ""),
|
||
ConverterAPIKey: getEnvString("VISIONA_CONVERTER_API_KEY", ""),
|
||
MaxModelSizeMB: getEnvInt("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", 500),
|
||
},
|
||
// Phase 0.9 模型庫 FAA 直連下載(ADR-017 (a),見 adr-017 §10 stage e2e 藍本)。
|
||
// ⚠️ 技術債:ServiceClientID/Secret 第一階段 PoC 共用 FAA 的 service client;
|
||
// 正式上線前須換 visionA 專屬 usage=file_api client(ADR-017 §7 R1 / Q10)。
|
||
FileAccess: FileAccessConfig{
|
||
MCBaseURL: getEnvString("VISIONA_FILE_ACCESS_MC_BASE_URL", ""),
|
||
ServiceClientID: getEnvString("VISIONA_FILE_ACCESS_SERVICE_CLIENT_ID", ""),
|
||
ServiceClientSecret: getEnvString("VISIONA_FILE_ACCESS_SERVICE_CLIENT_SECRET", ""),
|
||
TenantID: getEnvString("VISIONA_FILE_ACCESS_TENANT_ID", ""),
|
||
FAABaseURL: getEnvString("VISIONA_FILE_ACCESS_FAA_BASE_URL", ""),
|
||
DownloadTokenTTLSeconds: getEnvInt("VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS", 120),
|
||
},
|
||
// DB 接入塊 0:PostgreSQL 連線(持久業務資料)。
|
||
// 對齊 docs/autoflow/04-architecture/database.md §5.5.1。
|
||
// 留空(Host/User/DBName 任一缺)→ Enabled()=false → main.go 不建池、維持 in-memory。
|
||
Database: DatabaseConfig{
|
||
Host: getEnvString("VISIONA_DB_HOST", ""),
|
||
Port: getEnvInt("VISIONA_DB_PORT", 5432),
|
||
User: getEnvString("VISIONA_DB_USER", ""),
|
||
Password: getEnvString("VISIONA_DB_PASSWORD", ""),
|
||
DBName: getEnvString("VISIONA_DB_NAME", ""),
|
||
SSLMode: getEnvString("VISIONA_DB_SSLMODE", "require"),
|
||
MaxConns: getEnvInt("VISIONA_DB_MAX_CONNS", 10),
|
||
MinConns: getEnvInt("VISIONA_DB_MIN_CONNS", 2),
|
||
MaxConnLifetime: getEnvDuration("VISIONA_DB_MAX_CONN_LIFETIME", time.Hour),
|
||
ConnTimeout: getEnvDuration("VISIONA_DB_CONN_TIMEOUT", 5*time.Second),
|
||
AutoMigrate: getEnvBool("VISIONA_DB_AUTO_MIGRATE", true),
|
||
},
|
||
// DB 接入塊 4(鉤子先留,塊 0 不 wire):Redis 連線(僅 userSession)。
|
||
// 對齊 docs/autoflow/04-architecture/database.md §5.5.2。
|
||
Redis: RedisConfig{
|
||
Host: getEnvString("VISIONA_REDIS_HOST", ""),
|
||
Port: getEnvInt("VISIONA_REDIS_PORT", 6379),
|
||
Password: getEnvString("VISIONA_REDIS_PASSWORD", ""),
|
||
DB: getEnvInt("VISIONA_REDIS_DB", 0),
|
||
ConnTimeout: getEnvDuration("VISIONA_REDIS_CONN_TIMEOUT", 5*time.Second),
|
||
},
|
||
}
|
||
}
|
||
|
||
// getEnvStringSlice 從環境變數取逗號分隔字串,拆成 slice。
|
||
// 每段都會 TrimSpace;空段會被過濾。若環境變數未設定或為空,回傳 fallback。
|
||
func getEnvStringSlice(key string, fallback []string) []string {
|
||
v, ok := os.LookupEnv(key)
|
||
if !ok || v == "" {
|
||
return fallback
|
||
}
|
||
parts := strings.Split(v, ",")
|
||
result := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
if trimmed := strings.TrimSpace(p); trimmed != "" {
|
||
result = append(result, trimmed)
|
||
}
|
||
}
|
||
if len(result) == 0 {
|
||
return fallback
|
||
}
|
||
return result
|
||
}
|
||
|
||
// getEnvString 從環境變數取字串,不存在或為空則回傳預設值。
|
||
func getEnvString(key, fallback string) string {
|
||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||
return v
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
// getEnvInt 從環境變數取整數,若無法解析則回傳預設值。
|
||
func getEnvInt(key string, fallback int) int {
|
||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||
if n, err := strconv.Atoi(v); err == nil {
|
||
return n
|
||
}
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
// getEnvDuration 從環境變數取 time.Duration(支援 "10s"、"1m" 等格式)。
|
||
func getEnvDuration(key string, fallback time.Duration) time.Duration {
|
||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||
if d, err := time.ParseDuration(v); err == nil {
|
||
return d
|
||
}
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
// getEnvBool 從環境變數取布林值(接受 "true"/"false"/"1"/"0",大小寫不敏感)。
|
||
// 解析失敗或未設定回傳 fallback。
|
||
func getEnvBool(key string, fallback bool) bool {
|
||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||
if b, err := strconv.ParseBool(v); err == nil {
|
||
return b
|
||
}
|
||
}
|
||
return fallback
|
||
}
|