package app import ( "context" "fmt" "net/http" "strconv" "strings" "sync" "sub2api-cn-relay-manager/internal/routing" "sub2api-cn-relay-manager/internal/store/sqlite" ) type AppendRouteDecisionLogRequest struct { RequestID string `json:"request_id"` LogicalGroupID string `json:"logical_group_id"` PublicModel string `json:"public_model"` UserKey string `json:"user_key,omitempty"` ConversationKey string `json:"conversation_key,omitempty"` StickyKey string `json:"sticky_key,omitempty"` StickyKeyType string `json:"sticky_key_type,omitempty"` StickyHit bool `json:"sticky_hit,omitempty"` SelectedRouteID string `json:"selected_route_id"` SelectedShadowGroupID string `json:"selected_shadow_group_id"` FallbackUsed bool `json:"fallback_used,omitempty"` ErrorClass string `json:"error_class,omitempty"` UpstreamStatus int `json:"upstream_status,omitempty"` LatencyMS int `json:"latency_ms,omitempty"` Sync bool `json:"sync,omitempty"` } type ListRouteDecisionLogsRequest struct { RequestID string LogicalGroupID string PublicModel string SelectedRouteID string StickyKey string Limit int } type RouteDecisionLogInfo struct { ID int64 `json:"id"` RequestID string `json:"request_id"` LogicalGroupID string `json:"logical_group_id"` PublicModel string `json:"public_model"` UserKey string `json:"user_key,omitempty"` ConversationKey string `json:"conversation_key,omitempty"` StickyKey string `json:"sticky_key,omitempty"` StickyKeyType string `json:"sticky_key_type,omitempty"` StickyHit bool `json:"sticky_hit"` SelectedRouteID string `json:"selected_route_id"` SelectedShadowGroupID string `json:"selected_shadow_group_id"` FallbackUsed bool `json:"fallback_used"` ErrorClass string `json:"error_class,omitempty"` UpstreamStatus int `json:"upstream_status,omitempty"` LatencyMS int `json:"latency_ms,omitempty"` CreatedAt string `json:"created_at,omitempty"` } type AppendRouteFailoverEventRequest struct { RequestID string `json:"request_id"` LogicalGroupID string `json:"logical_group_id"` PublicModel string `json:"public_model"` FromRouteID string `json:"from_route_id"` ToRouteID string `json:"to_route_id"` Reason string `json:"reason"` FailureCount int `json:"failure_count,omitempty"` Sync bool `json:"sync,omitempty"` } type ListRouteFailoverEventsRequest struct { RequestID string LogicalGroupID string PublicModel string FromRouteID string ToRouteID string Limit int } type RouteFailoverEventInfo struct { ID int64 `json:"id"` RequestID string `json:"request_id"` LogicalGroupID string `json:"logical_group_id"` PublicModel string `json:"public_model"` FromRouteID string `json:"from_route_id"` ToRouteID string `json:"to_route_id"` Reason string `json:"reason"` FailureCount int `json:"failure_count"` CreatedAt string `json:"created_at,omitempty"` } type AppendRouteStickyAuditRequest struct { StickyKey string `json:"sticky_key"` StickyKeyType string `json:"sticky_key_type"` LogicalGroupID string `json:"logical_group_id"` PublicModel string `json:"public_model"` RouteID string `json:"route_id"` Action string `json:"action"` ExpiresAt string `json:"expires_at,omitempty"` Sync bool `json:"sync,omitempty"` } type ListRouteStickyAuditRequest struct { StickyKey string StickyKeyType string LogicalGroupID string PublicModel string RouteID string Action string Limit int } type RouteStickyAuditInfo struct { ID int64 `json:"id"` StickyKey string `json:"sticky_key"` StickyKeyType string `json:"sticky_key_type"` LogicalGroupID string `json:"logical_group_id"` PublicModel string `json:"public_model"` RouteID string `json:"route_id"` Action string `json:"action"` ExpiresAt string `json:"expires_at,omitempty"` CreatedAt string `json:"created_at,omitempty"` } func handleAppendRouteDecisionLog(w http.ResponseWriter, r *http.Request, fn func(context.Context, AppendRouteDecisionLogRequest) (RouteDecisionLogInfo, error)) { if fn == nil { writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "append-route-decision-log action is not configured"}) return } var req AppendRouteDecisionLogRequest if err := decodeJSON(r, &req); err != nil { writeHTTPError(w, err) return } item, err := fn(r.Context(), req) if err != nil { writeHTTPError(w, classifyError(err)) return } writeJSON(w, http.StatusCreated, map[string]any{"decision_log": item}) } func handleListRouteDecisionLogs(w http.ResponseWriter, r *http.Request, fn func(context.Context, ListRouteDecisionLogsRequest) ([]RouteDecisionLogInfo, error)) { if fn == nil { writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-route-decision-logs action is not configured"}) return } req, err := decodeRouteDecisionLogFilter(r) if err != nil { writeHTTPError(w, err) return } items, actionErr := fn(r.Context(), req) if actionErr != nil { writeHTTPError(w, classifyError(actionErr)) return } writeJSON(w, http.StatusOK, map[string]any{"decision_logs": items}) } func handleAppendRouteFailoverEvent(w http.ResponseWriter, r *http.Request, fn func(context.Context, AppendRouteFailoverEventRequest) (RouteFailoverEventInfo, error)) { if fn == nil { writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "append-route-failover-event action is not configured"}) return } var req AppendRouteFailoverEventRequest if err := decodeJSON(r, &req); err != nil { writeHTTPError(w, err) return } item, err := fn(r.Context(), req) if err != nil { writeHTTPError(w, classifyError(err)) return } writeJSON(w, http.StatusCreated, map[string]any{"failover_event": item}) } func handleListRouteFailoverEvents(w http.ResponseWriter, r *http.Request, fn func(context.Context, ListRouteFailoverEventsRequest) ([]RouteFailoverEventInfo, error)) { if fn == nil { writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-route-failover-events action is not configured"}) return } req, err := decodeRouteFailoverEventFilter(r) if err != nil { writeHTTPError(w, err) return } items, actionErr := fn(r.Context(), req) if actionErr != nil { writeHTTPError(w, classifyError(actionErr)) return } writeJSON(w, http.StatusOK, map[string]any{"failover_events": items}) } func handleAppendRouteStickyAudit(w http.ResponseWriter, r *http.Request, fn func(context.Context, AppendRouteStickyAuditRequest) (RouteStickyAuditInfo, error)) { if fn == nil { writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "append-route-sticky-audit action is not configured"}) return } var req AppendRouteStickyAuditRequest if err := decodeJSON(r, &req); err != nil { writeHTTPError(w, err) return } item, err := fn(r.Context(), req) if err != nil { writeHTTPError(w, classifyError(err)) return } writeJSON(w, http.StatusCreated, map[string]any{"sticky_audit": item}) } func handleListRouteStickyAudit(w http.ResponseWriter, r *http.Request, fn func(context.Context, ListRouteStickyAuditRequest) ([]RouteStickyAuditInfo, error)) { if fn == nil { writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-route-sticky-audit action is not configured"}) return } req, err := decodeRouteStickyAuditFilter(r) if err != nil { writeHTTPError(w, err) return } items, actionErr := fn(r.Context(), req) if actionErr != nil { writeHTTPError(w, classifyError(actionErr)) return } writeJSON(w, http.StatusOK, map[string]any{"sticky_audits": items}) } type lazyRouteLogWriter struct { sqliteDSN string mu sync.Mutex writer *routing.AsyncLogWriter } func newLazyRouteLogWriter(sqliteDSN string) *lazyRouteLogWriter { return &lazyRouteLogWriter{sqliteDSN: sqliteDSN} } func (l *lazyRouteLogWriter) get(ctx context.Context) (*routing.AsyncLogWriter, error) { l.mu.Lock() defer l.mu.Unlock() if l.writer != nil { return l.writer, nil } writer, err := routing.NewSQLiteLogWriter(ctx, l.sqliteDSN, routing.AsyncLogWriterOptions{}) if err != nil { return nil, err } l.writer = writer return l.writer, nil } func buildAppendRouteDecisionLogAction(writerSource *lazyRouteLogWriter, sqliteDSN string) func(context.Context, AppendRouteDecisionLogRequest) (RouteDecisionLogInfo, error) { return func(ctx context.Context, req AppendRouteDecisionLogRequest) (RouteDecisionLogInfo, error) { writer, err := writerSource.get(ctx) if err != nil { return RouteDecisionLogInfo{}, err } event := routing.RouteDecisionEvent{ RequestID: strings.TrimSpace(req.RequestID), LogicalGroupID: strings.TrimSpace(req.LogicalGroupID), PublicModel: strings.TrimSpace(req.PublicModel), UserKey: strings.TrimSpace(req.UserKey), ConversationKey: strings.TrimSpace(req.ConversationKey), StickyKey: strings.TrimSpace(req.StickyKey), StickyKeyType: strings.TrimSpace(req.StickyKeyType), StickyHit: req.StickyHit, SelectedRouteID: strings.TrimSpace(req.SelectedRouteID), SelectedShadowGroupID: strings.TrimSpace(req.SelectedShadowGroupID), FallbackUsed: req.FallbackUsed, ErrorClass: strings.TrimSpace(req.ErrorClass), UpstreamStatus: req.UpstreamStatus, LatencyMS: req.LatencyMS, } if err := writer.AppendDecision(ctx, event); err != nil { return RouteDecisionLogInfo{}, err } if !req.Sync { return routeDecisionLogRowToInfo(sqlite.RouteDecisionLog{ RequestID: event.RequestID, LogicalGroupID: event.LogicalGroupID, PublicModel: event.PublicModel, UserKey: event.UserKey, ConversationKey: event.ConversationKey, StickyKey: event.StickyKey, StickyKeyType: event.StickyKeyType, StickyHit: event.StickyHit, SelectedRouteID: event.SelectedRouteID, SelectedShadowGroupID: event.SelectedShadowGroupID, FallbackUsed: event.FallbackUsed, ErrorClass: event.ErrorClass, UpstreamStatus: event.UpstreamStatus, LatencyMS: event.LatencyMS, }), nil } if err := writer.Flush(ctx); err != nil { return RouteDecisionLogInfo{}, err } store, err := sqlite.Open(ctx, sqliteDSN) if err != nil { return RouteDecisionLogInfo{}, err } defer store.Close() items, err := store.RouteDecisionLogs().ListRecent(ctx, sqlite.RouteDecisionLogFilter{ RequestID: event.RequestID, Limit: 1, }) if err != nil { return RouteDecisionLogInfo{}, err } if len(items) == 0 { return RouteDecisionLogInfo{}, fmt.Errorf("route decision log %q not found after append", event.RequestID) } return routeDecisionLogRowToInfo(items[0]), nil } } func buildListRouteDecisionLogsAction(sqliteDSN string) func(context.Context, ListRouteDecisionLogsRequest) ([]RouteDecisionLogInfo, error) { return func(ctx context.Context, req ListRouteDecisionLogsRequest) ([]RouteDecisionLogInfo, error) { store, err := sqlite.Open(ctx, sqliteDSN) if err != nil { return nil, err } defer store.Close() rows, err := store.RouteDecisionLogs().ListRecent(ctx, sqlite.RouteDecisionLogFilter{ RequestID: strings.TrimSpace(req.RequestID), LogicalGroupID: strings.TrimSpace(req.LogicalGroupID), PublicModel: strings.TrimSpace(req.PublicModel), SelectedRouteID: strings.TrimSpace(req.SelectedRouteID), StickyKey: strings.TrimSpace(req.StickyKey), Limit: req.Limit, }) if err != nil { return nil, err } return routeDecisionLogRowsToInfo(rows), nil } } func buildAppendRouteFailoverEventAction(writerSource *lazyRouteLogWriter, sqliteDSN string) func(context.Context, AppendRouteFailoverEventRequest) (RouteFailoverEventInfo, error) { return func(ctx context.Context, req AppendRouteFailoverEventRequest) (RouteFailoverEventInfo, error) { writer, err := writerSource.get(ctx) if err != nil { return RouteFailoverEventInfo{}, err } event := routing.RouteFailoverEvent{ RequestID: strings.TrimSpace(req.RequestID), LogicalGroupID: strings.TrimSpace(req.LogicalGroupID), PublicModel: strings.TrimSpace(req.PublicModel), FromRouteID: strings.TrimSpace(req.FromRouteID), ToRouteID: strings.TrimSpace(req.ToRouteID), Reason: strings.TrimSpace(req.Reason), FailureCount: req.FailureCount, } if err := writer.AppendFailover(ctx, event); err != nil { return RouteFailoverEventInfo{}, err } if !req.Sync { return routeFailoverEventRowToInfo(sqlite.RouteFailoverEvent{ RequestID: event.RequestID, LogicalGroupID: event.LogicalGroupID, PublicModel: event.PublicModel, FromRouteID: event.FromRouteID, ToRouteID: event.ToRouteID, Reason: event.Reason, FailureCount: event.FailureCount, }), nil } if err := writer.Flush(ctx); err != nil { return RouteFailoverEventInfo{}, err } store, err := sqlite.Open(ctx, sqliteDSN) if err != nil { return RouteFailoverEventInfo{}, err } defer store.Close() items, err := store.RouteFailoverEvents().ListRecent(ctx, sqlite.RouteFailoverEventFilter{ RequestID: event.RequestID, Limit: 1, }) if err != nil { return RouteFailoverEventInfo{}, err } if len(items) == 0 { return RouteFailoverEventInfo{}, fmt.Errorf("route failover event %q not found after append", event.RequestID) } return routeFailoverEventRowToInfo(items[0]), nil } } func buildListRouteFailoverEventsAction(sqliteDSN string) func(context.Context, ListRouteFailoverEventsRequest) ([]RouteFailoverEventInfo, error) { return func(ctx context.Context, req ListRouteFailoverEventsRequest) ([]RouteFailoverEventInfo, error) { store, err := sqlite.Open(ctx, sqliteDSN) if err != nil { return nil, err } defer store.Close() rows, err := store.RouteFailoverEvents().ListRecent(ctx, sqlite.RouteFailoverEventFilter{ RequestID: strings.TrimSpace(req.RequestID), LogicalGroupID: strings.TrimSpace(req.LogicalGroupID), PublicModel: strings.TrimSpace(req.PublicModel), FromRouteID: strings.TrimSpace(req.FromRouteID), ToRouteID: strings.TrimSpace(req.ToRouteID), Limit: req.Limit, }) if err != nil { return nil, err } return routeFailoverEventRowsToInfo(rows), nil } } func buildAppendRouteStickyAuditAction(writerSource *lazyRouteLogWriter, sqliteDSN string) func(context.Context, AppendRouteStickyAuditRequest) (RouteStickyAuditInfo, error) { return func(ctx context.Context, req AppendRouteStickyAuditRequest) (RouteStickyAuditInfo, error) { writer, err := writerSource.get(ctx) if err != nil { return RouteStickyAuditInfo{}, err } event := routing.RouteStickyAuditEvent{ StickyKey: strings.TrimSpace(req.StickyKey), StickyKeyType: strings.TrimSpace(req.StickyKeyType), LogicalGroupID: strings.TrimSpace(req.LogicalGroupID), PublicModel: strings.TrimSpace(req.PublicModel), RouteID: strings.TrimSpace(req.RouteID), Action: strings.TrimSpace(req.Action), ExpiresAt: strings.TrimSpace(req.ExpiresAt), } if err := writer.AppendStickyAudit(ctx, event); err != nil { return RouteStickyAuditInfo{}, err } if !req.Sync { return routeStickyAuditRowToInfo(sqlite.RouteStickyAudit{ StickyKey: event.StickyKey, StickyKeyType: event.StickyKeyType, LogicalGroupID: event.LogicalGroupID, PublicModel: event.PublicModel, RouteID: event.RouteID, Action: event.Action, ExpiresAt: event.ExpiresAt, }), nil } if err := writer.Flush(ctx); err != nil { return RouteStickyAuditInfo{}, err } store, err := sqlite.Open(ctx, sqliteDSN) if err != nil { return RouteStickyAuditInfo{}, err } defer store.Close() items, err := store.RouteStickyAudit().ListRecent(ctx, sqlite.RouteStickyAuditFilter{ StickyKey: event.StickyKey, Action: event.Action, Limit: 1, }) if err != nil { return RouteStickyAuditInfo{}, err } if len(items) == 0 { return RouteStickyAuditInfo{}, fmt.Errorf("route sticky audit %q not found after append", event.StickyKey) } return routeStickyAuditRowToInfo(items[0]), nil } } func buildListRouteStickyAuditAction(sqliteDSN string) func(context.Context, ListRouteStickyAuditRequest) ([]RouteStickyAuditInfo, error) { return func(ctx context.Context, req ListRouteStickyAuditRequest) ([]RouteStickyAuditInfo, error) { store, err := sqlite.Open(ctx, sqliteDSN) if err != nil { return nil, err } defer store.Close() rows, err := store.RouteStickyAudit().ListRecent(ctx, sqlite.RouteStickyAuditFilter{ StickyKey: strings.TrimSpace(req.StickyKey), StickyKeyType: strings.TrimSpace(req.StickyKeyType), LogicalGroupID: strings.TrimSpace(req.LogicalGroupID), PublicModel: strings.TrimSpace(req.PublicModel), RouteID: strings.TrimSpace(req.RouteID), Action: strings.TrimSpace(req.Action), Limit: req.Limit, }) if err != nil { return nil, err } return routeStickyAuditRowsToInfo(rows), nil } } func decodeRouteDecisionLogFilter(r *http.Request) (ListRouteDecisionLogsRequest, *httpError) { limit, err := parseOptionalLimit(r.URL.Query().Get("limit")) if err != nil { return ListRouteDecisionLogsRequest{}, err } return ListRouteDecisionLogsRequest{ RequestID: strings.TrimSpace(r.URL.Query().Get("request_id")), LogicalGroupID: strings.TrimSpace(r.URL.Query().Get("logical_group_id")), PublicModel: strings.TrimSpace(r.URL.Query().Get("public_model")), SelectedRouteID: strings.TrimSpace(r.URL.Query().Get("selected_route_id")), StickyKey: strings.TrimSpace(r.URL.Query().Get("sticky_key")), Limit: limit, }, nil } func decodeRouteFailoverEventFilter(r *http.Request) (ListRouteFailoverEventsRequest, *httpError) { limit, err := parseOptionalLimit(r.URL.Query().Get("limit")) if err != nil { return ListRouteFailoverEventsRequest{}, err } return ListRouteFailoverEventsRequest{ RequestID: strings.TrimSpace(r.URL.Query().Get("request_id")), LogicalGroupID: strings.TrimSpace(r.URL.Query().Get("logical_group_id")), PublicModel: strings.TrimSpace(r.URL.Query().Get("public_model")), FromRouteID: strings.TrimSpace(r.URL.Query().Get("from_route_id")), ToRouteID: strings.TrimSpace(r.URL.Query().Get("to_route_id")), Limit: limit, }, nil } func decodeRouteStickyAuditFilter(r *http.Request) (ListRouteStickyAuditRequest, *httpError) { limit, err := parseOptionalLimit(r.URL.Query().Get("limit")) if err != nil { return ListRouteStickyAuditRequest{}, err } return ListRouteStickyAuditRequest{ StickyKey: strings.TrimSpace(r.URL.Query().Get("sticky_key")), StickyKeyType: strings.TrimSpace(r.URL.Query().Get("sticky_key_type")), LogicalGroupID: strings.TrimSpace(r.URL.Query().Get("logical_group_id")), PublicModel: strings.TrimSpace(r.URL.Query().Get("public_model")), RouteID: strings.TrimSpace(r.URL.Query().Get("route_id")), Action: strings.TrimSpace(r.URL.Query().Get("action")), Limit: limit, }, nil } func parseOptionalLimit(raw string) (int, *httpError) { raw = strings.TrimSpace(raw) if raw == "" { return 0, nil } limit, err := strconv.Atoi(raw) if err != nil || limit <= 0 { return 0, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "limit must be a positive integer"} } return limit, nil } func routeDecisionLogRowToInfo(row sqlite.RouteDecisionLog) RouteDecisionLogInfo { return RouteDecisionLogInfo{ ID: row.ID, RequestID: row.RequestID, LogicalGroupID: row.LogicalGroupID, PublicModel: row.PublicModel, UserKey: row.UserKey, ConversationKey: row.ConversationKey, StickyKey: row.StickyKey, StickyKeyType: row.StickyKeyType, StickyHit: row.StickyHit, SelectedRouteID: row.SelectedRouteID, SelectedShadowGroupID: row.SelectedShadowGroupID, FallbackUsed: row.FallbackUsed, ErrorClass: row.ErrorClass, UpstreamStatus: row.UpstreamStatus, LatencyMS: row.LatencyMS, CreatedAt: row.CreatedAt, } } func routeDecisionLogRowsToInfo(rows []sqlite.RouteDecisionLog) []RouteDecisionLogInfo { result := make([]RouteDecisionLogInfo, 0, len(rows)) for _, row := range rows { result = append(result, routeDecisionLogRowToInfo(row)) } return result } func routeFailoverEventRowToInfo(row sqlite.RouteFailoverEvent) RouteFailoverEventInfo { return RouteFailoverEventInfo{ ID: row.ID, RequestID: row.RequestID, LogicalGroupID: row.LogicalGroupID, PublicModel: row.PublicModel, FromRouteID: row.FromRouteID, ToRouteID: row.ToRouteID, Reason: row.Reason, FailureCount: row.FailureCount, CreatedAt: row.CreatedAt, } } func routeFailoverEventRowsToInfo(rows []sqlite.RouteFailoverEvent) []RouteFailoverEventInfo { result := make([]RouteFailoverEventInfo, 0, len(rows)) for _, row := range rows { result = append(result, routeFailoverEventRowToInfo(row)) } return result } func routeStickyAuditRowToInfo(row sqlite.RouteStickyAudit) RouteStickyAuditInfo { return RouteStickyAuditInfo{ ID: row.ID, StickyKey: row.StickyKey, StickyKeyType: row.StickyKeyType, LogicalGroupID: row.LogicalGroupID, PublicModel: row.PublicModel, RouteID: row.RouteID, Action: row.Action, ExpiresAt: row.ExpiresAt, CreatedAt: row.CreatedAt, } } func routeStickyAuditRowsToInfo(rows []sqlite.RouteStickyAudit) []RouteStickyAuditInfo { result := make([]RouteStickyAuditInfo, 0, len(rows)) for _, row := range rows { result = append(result, routeStickyAuditRowToInfo(row)) } return result }