從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:
- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
(tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
- internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
- internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
防 session fixation, OWASP ASVS V3.2.1)
- 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
- 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
- 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
- OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
(AuthStyleInParams 強制 token endpoint 不送 client_secret)
- 預留 ServiceClient* 欄位給未來 client_credentials grant
- 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
(Audit C1:multi-tenant 隔離破口)
- Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
- 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)
驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
125 lines
3.8 KiB
Go
125 lines
3.8 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// HealthzHandler 是 K8s liveness / readiness 用的最小健康檢查。
|
||
//
|
||
// 不檢查任何依賴(remote-proxy、DB),只代表 process 還活著。
|
||
// readiness 想檢查依賴的話應該用 /api/system/health。
|
||
func HealthzHandler() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||
}
|
||
}
|
||
|
||
// registerSystemRoutes 註冊 /api/system/* 的 routes。
|
||
//
|
||
// MVP 範圍(B4 + B5):
|
||
// - GET /api/system/health → 回 api-server 自己 + tunnel 連線狀態
|
||
// - GET /api/system/info → 回版本資訊(雛形 hard-coded)
|
||
// - GET /api/system/deps → 走 tunnel proxy 查 local agent 的依賴狀態(B5)
|
||
func registerSystemRoutes(g *gin.RouterGroup, deps Deps) {
|
||
g.GET("/system/health", systemHealthHandler(deps))
|
||
g.GET("/system/info", systemInfoHandler())
|
||
// /api/system/deps 透過 tunnel proxy 到 local agent 的同路徑。
|
||
g.GET("/system/deps", newProxyHandler(deps, proxyOptions{}))
|
||
}
|
||
|
||
// SystemHealthResponse 是 GET /api/system/health 的 data payload。
|
||
//
|
||
// 對齊 api-spec.md §7:
|
||
//
|
||
// {
|
||
// "api_server": "ok",
|
||
// "tunnel_connected": true,
|
||
// "agent_last_seen_at": "..."
|
||
// }
|
||
type SystemHealthResponse struct {
|
||
APIServer string `json:"api_server"`
|
||
TunnelConnected bool `json:"tunnel_connected"`
|
||
AgentLastSeenAt *time.Time `json:"agent_last_seen_at,omitempty"`
|
||
AgentSessionCount int `json:"agent_session_count"`
|
||
}
|
||
|
||
// systemHealthHandler 回報 api-server + tunnel 狀態。
|
||
//
|
||
// 「tunnel_connected」的判定方式:呼叫 SessionStore.List。若有任一 session
|
||
// 在線就視為 connected。雛形是單一 user 場景,所以這個語義足以呈現「我這邊
|
||
// 有沒有 agent 連著」;多 user / 多 device 階段會改成 per-user 查詢(B5)。
|
||
func systemHealthHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
resp := SystemHealthResponse{
|
||
APIServer: "ok",
|
||
}
|
||
|
||
if deps.SessionStore != nil {
|
||
// 給一個短 timeout — health 檢查不該卡住整個 request
|
||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||
defer cancel()
|
||
|
||
summaries, err := deps.SessionStore.List(ctx)
|
||
if err != nil {
|
||
// SessionStore 失敗不致命;只回 tunnel_connected=false 加 warning
|
||
logOrDefault(deps.Logger).Warn("system/health: list sessions failed",
|
||
"error", err,
|
||
"request_id", RequestIDFrom(c))
|
||
} else {
|
||
resp.AgentSessionCount = len(summaries)
|
||
if len(summaries) > 0 {
|
||
resp.TunnelConnected = true
|
||
// 取最新一個 LastHeartbeat 作為 agent_last_seen_at
|
||
var latest time.Time
|
||
for _, s := range summaries {
|
||
if s.LastHeartbeat.After(latest) {
|
||
latest = s.LastHeartbeat
|
||
}
|
||
}
|
||
if !latest.IsZero() {
|
||
resp.AgentLastSeenAt = &latest
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
WriteSuccess(c, http.StatusOK, resp)
|
||
}
|
||
}
|
||
|
||
// SystemInfoResponse 是 GET /api/system/info 的 data payload。
|
||
type SystemInfoResponse struct {
|
||
Service string `json:"service"`
|
||
Version string `json:"version"`
|
||
Phase string `json:"phase"`
|
||
}
|
||
|
||
// logOrDefault 是 nil-safe slog 取用 helper;給 handler 共用。
|
||
//
|
||
// Deps.validate 已會把 nil logger fallback 到 slog.Default,但測試直接呼叫
|
||
// register*Routes 時可能跳過 validate;這個 helper 讓 handler 不必每處都 nil 檢查。
|
||
func logOrDefault(l *slog.Logger) *slog.Logger {
|
||
if l == nil {
|
||
return slog.Default()
|
||
}
|
||
return l
|
||
}
|
||
|
||
// systemInfoHandler 回報版本與環境階段。
|
||
//
|
||
// 雛形版本字串 hard-coded;B6(CI/CD)會改用 build flag 注入 git commit hash。
|
||
func systemInfoHandler() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
WriteSuccess(c, http.StatusOK, SystemInfoResponse{
|
||
Service: "visiona-api-server",
|
||
Version: "0.0.0-phase0",
|
||
Phase: "phase-0-prototype",
|
||
})
|
||
}
|
||
}
|