jim800121chen c2f0b1549e feat: OIDC 登入修復(email fallback / prompt=login / logout 連動)+ 真轉檔鏈路 e2e
接 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>
2026-06-27 03:18:59 +08:00

186 lines
8.2 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
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 C1VISIONA_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", ""),
// LogoutURLMC Web :7880 logout 入口GET /account/logout。留空logout 只清本地、
// 不連動 MC向下相容見 OIDCConfig.LogoutURL 註解)。
LogoutURL: getEnvString("VISIONA_OIDC_LOGOUT_URL", ""),
// prompt=logintrue 時每次登入都強制 IdP 重新認證(忽略既有 SSO session
PromptLogin: getEnvBool("VISIONA_OIDC_PROMPT_LOGIN", false),
// A1client_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 clientADR-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 接入塊 0PostgreSQL 連線(持久業務資料)。
// 對齊 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 不 wireRedis 連線(僅 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
}