255 lines
10 KiB
Go
255 lines
10 KiB
Go
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
|
||
}
|
||
}
|