Files
sub2api-cn-relay-manager/internal/batch/status_projection.go
2026-05-22 15:31:33 +08:00

255 lines
10 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 batch
import (
"encoding/json"
"fmt"
"strings"
"sub2api-cn-relay-manager/internal/probe"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
type ProjectionBadge struct {
Kind string `json:"kind"`
Tone string `json:"tone"`
Label string `json:"label"`
}
type RunSummaryProjection struct {
RunID string `json:"run_id"`
State string `json:"state"`
StateBadge ProjectionBadge `json:"state_badge"`
Mode string `json:"mode"`
AccessMode string `json:"access_mode"`
TotalItems int `json:"total_items"`
CompletedItems int `json:"completed_items"`
ActiveItems int `json:"active_items"`
DegradedItems int `json:"degraded_items"`
BrokenItems int `json:"broken_items"`
WarningItems int `json:"warning_items"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
RecentWarnings []string `json:"recent_warnings"`
}
type ItemSummaryProjection struct {
ItemID string `json:"item_id"`
BaseURL string `json:"base_url"`
ProviderID string `json:"provider_id"`
APIKeyFingerprint string `json:"api_key_fingerprint"`
RequestedModels []string `json:"requested_models"`
CanonicalModelFamilies []string `json:"canonical_model_families"`
ResolvedSmokeModel string `json:"resolved_smoke_model"`
CurrentStage string `json:"current_stage"`
ConfirmationStatus string `json:"confirmation_status"`
AccessStatus string `json:"access_status"`
MatchedAccountState string `json:"matched_account_state"`
AccountResolution string `json:"account_resolution"`
ProvisionReused bool `json:"provision_reused"`
RetryCount int `json:"retry_count"`
LastRetryAt string `json:"last_retry_at"`
AdvisoryMessages []string `json:"advisory_messages"`
LastErrorStage string `json:"last_error_stage"`
LastError string `json:"last_error"`
Badges []ProjectionBadge `json:"badges"`
}
type EventProjection struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
Stage string `json:"stage"`
Attempt int `json:"attempt"`
Message string `json:"message"`
PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"`
}
type ItemDetailProjection struct {
ItemSummaryProjection
RawModels []string `json:"raw_models"`
NormalizedModels []string `json:"normalized_models"`
RecommendedModels []string `json:"recommended_models"`
ReusedFromProviderID string `json:"reused_from_provider_id"`
ReusedFromAccountID *int64 `json:"reused_from_account_id"`
ChannelID *int64 `json:"channel_id"`
AccountID *int64 `json:"account_id"`
CapabilityProfile probe.CapabilityProfile `json:"capability_profile"`
Events []EventProjection `json:"events"`
}
func ProjectRunSummary(run sqlite.ImportRun) RunSummaryProjection {
view := RunSummaryProjection{
RunID: run.RunID,
State: run.State,
StateBadge: runStateBadge(run.State),
Mode: run.Mode,
AccessMode: run.AccessMode,
TotalItems: run.TotalItems,
CompletedItems: run.CompletedItems,
ActiveItems: run.ActiveItems,
DegradedItems: run.DegradedItems,
BrokenItems: run.BrokenItems,
WarningItems: run.WarningItems,
StartedAt: run.StartedAt,
FinishedAt: run.FinishedAt,
RecentWarnings: []string{},
}
if run.WarningItems > 0 {
view.RecentWarnings = append(view.RecentWarnings, fmt.Sprintf("该批次包含 %d 条 advisory item建议检查 capability profile 与 retry 轨迹", run.WarningItems))
}
return view
}
func ProjectItemSummary(item sqlite.ImportRunItem) ItemSummaryProjection {
view := ItemSummaryProjection{
ItemID: item.ItemID,
BaseURL: item.BaseURL,
ProviderID: item.ProviderID,
APIKeyFingerprint: item.APIKeyFingerprint,
RequestedModels: parseJSONStringArray(item.RequestedModelsJSON),
CanonicalModelFamilies: parseJSONStringArray(item.CanonicalFamiliesJSON),
ResolvedSmokeModel: item.ResolvedSmokeModel,
CurrentStage: item.CurrentStage,
ConfirmationStatus: item.ConfirmationStatus,
AccessStatus: item.AccessStatus,
MatchedAccountState: item.MatchedAccountState,
AccountResolution: item.AccountResolution,
ProvisionReused: item.ProvisionReused,
RetryCount: item.RetryCount,
LastRetryAt: item.LastRetryAt,
AdvisoryMessages: mapAdvisoryMessages(parseJSONStringArray(item.AdvisoryMessagesJSON)),
LastErrorStage: item.LastErrorStage,
LastError: item.LastError,
Badges: []ProjectionBadge{},
}
if item.ProvisionReused {
view.Badges = append(view.Badges, ProjectionBadge{Kind: "reused", Tone: "cyan", Label: "reused"})
}
if badge, ok := matchedAccountStateBadge(item.MatchedAccountState); ok {
view.Badges = append(view.Badges, badge)
}
if badge, ok := accountResolutionBadge(item.AccountResolution); ok {
view.Badges = append(view.Badges, badge)
}
return view
}
func ProjectItemDetail(item sqlite.ImportRunItem, events []sqlite.ImportRunItemEvent) (ItemDetailProjection, error) {
view := ItemDetailProjection{
ItemSummaryProjection: ProjectItemSummary(item),
RawModels: parseJSONStringArray(item.RawModelsJSON),
NormalizedModels: parseJSONStringArray(item.NormalizedModelsJSON),
RecommendedModels: parseJSONStringArray(item.RecommendedModelsJSON),
ReusedFromProviderID: item.ReusedFromProviderID,
ReusedFromAccountID: item.ReusedFromAccountID,
ChannelID: item.ChannelID,
AccountID: item.AccountID,
CapabilityProfile: probe.CapabilityProfile{},
Events: make([]EventProjection, 0, len(events)),
}
if payload := strings.TrimSpace(item.CapabilityProfileJSON); payload != "" {
if err := json.Unmarshal([]byte(payload), &view.CapabilityProfile); err != nil {
return ItemDetailProjection{}, fmt.Errorf("decode capability profile: %w", err)
}
}
for _, event := range events {
view.Events = append(view.Events, EventProjection{
EventID: event.EventID,
EventType: event.EventType,
Stage: event.Stage,
Attempt: event.Attempt,
Message: event.Message,
PayloadJSON: event.PayloadJSON,
CreatedAt: event.CreatedAt,
})
}
return view, nil
}
func parseJSONStringArray(raw string) []string {
values := []string{}
if err := json.Unmarshal([]byte(defaultJSONString(raw, "[]")), &values); err != nil {
return []string{}
}
return values
}
func mapAdvisoryMessages(messages []string) []string {
mapped := make([]string, 0, len(messages))
for _, message := range messages {
switch strings.TrimSpace(message) {
case "responses_unsupported_but_chat_ok":
mapped = append(mapped, "该上游不支持 /v1/responses系统已自动回退到 /v1/chat/completions")
case "initial_probe_race_expected":
mapped = append(mapped, "账号创建后宿主异步探测尚未稳定,首次 /test 已按 advisory 处理")
case "gateway_warmup_retry_succeeded":
mapped = append(mapped, "初次调度出现 no available accounts短暂重试后已恢复")
case "provision_reused":
mapped = append(mapped, "已检测到同 URL + 同模型家族 + 健康账号,系统直接复用已有 provider")
case "patch_only_new_aliases":
mapped = append(mapped, "模型属于已覆盖家族,仅补充别名映射与定价,不重复创建资源")
case "duplicate_active_account":
mapped = append(mapped, "该账号已存在且处于启用状态,本次未重复创建,直接复用")
case "deprecated_account_reactivated":
mapped = append(mapped, "该账号此前处于弃用/停用状态,本次已快速启用并重新确认")
default:
if trimmed := strings.TrimSpace(message); trimmed != "" {
mapped = append(mapped, trimmed)
}
}
}
return mapped
}
func runStateBadge(state string) ProjectionBadge {
switch strings.TrimSpace(state) {
case string(RunStateRunning):
return ProjectionBadge{Kind: "state", Tone: "blue", Label: "running"}
case string(RunStateCompleted):
return ProjectionBadge{Kind: "state", Tone: "green", Label: "completed"}
case string(RunStateCompletedWithWarnings):
return ProjectionBadge{Kind: "state", Tone: "yellow", Label: "warning"}
case string(RunStateFailed):
return ProjectionBadge{Kind: "state", Tone: "red", Label: "failed"}
case string(RunStateCancelled):
return ProjectionBadge{Kind: "state", Tone: "gray", Label: "cancelled"}
default:
return ProjectionBadge{Kind: "state", Tone: "gray", Label: strings.TrimSpace(state)}
}
}
func matchedAccountStateBadge(state string) (ProjectionBadge, bool) {
switch strings.TrimSpace(state) {
case string(MatchedAccountStateActive):
return ProjectionBadge{Kind: "matched_account_state", Tone: "green", Label: "已启用"}, true
case string(MatchedAccountStateDisabled):
return ProjectionBadge{Kind: "matched_account_state", Tone: "gray", Label: "已停用"}, true
case string(MatchedAccountStateDeprecated):
return ProjectionBadge{Kind: "matched_account_state", Tone: "yellow", Label: "已弃用"}, true
case string(MatchedAccountStateBroken):
return ProjectionBadge{Kind: "matched_account_state", Tone: "red", Label: "已损坏"}, true
default:
return ProjectionBadge{}, false
}
}
func accountResolutionBadge(resolution string) (ProjectionBadge, bool) {
switch strings.TrimSpace(resolution) {
case string(AccountResolutionCreated):
return ProjectionBadge{Kind: "account_resolution", Tone: "blue", Label: "新建"}, true
case string(AccountResolutionReused):
return ProjectionBadge{Kind: "account_resolution", Tone: "cyan", Label: "复用"}, true
case string(AccountResolutionReactivated):
return ProjectionBadge{Kind: "account_resolution", Tone: "green", Label: "已快速启用"}, true
case string(AccountResolutionReplaced):
return ProjectionBadge{Kind: "account_resolution", Tone: "red", Label: "已替换"}, true
default:
return ProjectionBadge{}, false
}
}