package main import ( "context" "encoding/json" "errors" "fmt" "os" "os/signal" "strconv" "strings" "syscall" "time" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/feichibridge" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/health" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability" ) type authSecret struct { Username string `json:"username"` Password string `json:"password"` } type targetSecret struct { PlatformID string `json:"platformId"` Username string `json:"username"` Password string `json:"password"` } type config struct { BaseURL string AuthSecretFile string TargetSecret string TargetAddress string StateFile string HealthAddress string HTTPTimeout time.Duration TargetTimeout time.Duration OCRImage string OCRTimeout time.Duration LoginAttempts int LoginRetryDelay time.Duration Service feichibridge.ServiceConfig } func main() { logger := observability.NewLogger("feichi-bridge") cfg, err := loadConfig() if err != nil { logger.Error("load configuration failed", "error", err) os.Exit(1) } auth, err := readJSONSecret[authSecret](cfg.AuthSecretFile) if err != nil { logger.Error("read Feichi API secret failed", "error", err) os.Exit(1) } credentials, err := readJSONSecret[targetSecret](cfg.TargetSecret) if err != nil { logger.Error("read GB/T 32960 target secret failed", "error", err) os.Exit(1) } source, err := feichibridge.NewAuthenticatedAPIClient( cfg.BaseURL, feichibridge.LoginCredentials{ Username: auth.Username, Password: auth.Password, MaxAttempts: cfg.LoginAttempts, RetryDelay: cfg.LoginRetryDelay, }, feichibridge.DockerCaptchaSolver{Image: cfg.OCRImage, Timeout: cfg.OCRTimeout}, cfg.HTTPTimeout, ) if err != nil { logger.Error("build Feichi API client failed", "error", err) os.Exit(1) } state, err := feichibridge.OpenStateStore(cfg.StateFile) if err != nil { logger.Error("open bridge state failed", "error", err) os.Exit(1) } target, err := feichibridge.NewTarget(feichibridge.TargetConfig{ Address: cfg.TargetAddress, PlatformID: credentials.PlatformID, Username: credentials.Username, Password: credentials.Password, Timeout: cfg.TargetTimeout, }, state) if err != nil { logger.Error("build GB/T 32960 target failed", "error", err) os.Exit(1) } registry := metrics.NewRegistry() service, err := feichibridge.NewService(cfg.Service, source, target, state, logger, registry) if err != nil { logger.Error("build bridge service failed", "error", err) os.Exit(1) } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() health.Start(ctx, logger, health.NewServer(cfg.HealthAddress, "feichi-bridge", []health.Check{ {Name: "bridge", Check: service.Ready}, }, registry)) logger.Info("Feichi bridge starting", "base_url", cfg.BaseURL, "target_address", cfg.TargetAddress, "poll_interval", cfg.Service.PollInterval, "backfill_enabled", cfg.Service.BackfillEnabled, ) if strings.HasPrefix(strings.ToLower(cfg.BaseURL), "http://") { logger.Warn("Feichi source uses clear-text HTTP; deploy only through a controlled egress path") } if err := service.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { logger.Error("Feichi bridge stopped unexpectedly", "error", err) os.Exit(1) } logger.Info("Feichi bridge stopped") } func loadConfig() (config, error) { cfg := config{ BaseURL: strings.TrimSpace(os.Getenv("FEICHI_BASE_URL")), AuthSecretFile: env("FEICHI_AUTH_SECRET_FILE", strings.TrimSpace(os.Getenv("FEICHI_AUTH_HEADERS_FILE"))), TargetSecret: strings.TrimSpace(os.Getenv("FEICHI_TARGET_SECRET_FILE")), TargetAddress: env("FEICHI_TARGET_ADDR", "127.0.0.1:32960"), StateFile: env("FEICHI_STATE_FILE", "/var/lib/lingniu-feichi-bridge/state.json"), HealthAddress: env("HEALTH_ADDR", "127.0.0.1:20219"), HTTPTimeout: seconds("FEICHI_HTTP_TIMEOUT_SECONDS", 15), TargetTimeout: seconds("FEICHI_TARGET_TIMEOUT_SECONDS", 10), OCRImage: env("FEICHI_OCR_IMAGE", "lingniu/feichi-captcha-ocr:1.0.0"), OCRTimeout: seconds("FEICHI_OCR_TIMEOUT_SECONDS", 20), LoginAttempts: envInt("FEICHI_LOGIN_MAX_ATTEMPTS", 20), LoginRetryDelay: seconds("FEICHI_LOGIN_RETRY_SECONDS", 1), Service: feichibridge.ServiceConfig{ PollInterval: seconds("FEICHI_POLL_INTERVAL_SECONDS", 10), DiscoveryInterval: seconds("FEICHI_DISCOVERY_INTERVAL_SECONDS", 300), BackfillInterval: seconds("FEICHI_BACKFILL_INTERVAL_SECONDS", 3600), BackfillLookback: seconds("FEICHI_BACKFILL_LOOKBACK_SECONDS", 3600), BackfillWindow: seconds("FEICHI_BACKFILL_WINDOW_SECONDS", 1200), BackfillSafetyLag: seconds("FEICHI_BACKFILL_SAFETY_SECONDS", 30), SourceStaleAfter: seconds("FEICHI_SOURCE_STALE_SECONDS", 120), FetchConcurrency: envInt("FEICHI_FETCH_CONCURRENCY", 4), BackfillEnabled: envBool("FEICHI_BACKFILL_ENABLED", true), StaleReissueEnabled: envBool("FEICHI_STALE_REISSUE_ENABLED", true), }, } var missing []string if cfg.BaseURL == "" { missing = append(missing, "FEICHI_BASE_URL") } if cfg.AuthSecretFile == "" { missing = append(missing, "FEICHI_AUTH_SECRET_FILE") } if cfg.TargetSecret == "" { missing = append(missing, "FEICHI_TARGET_SECRET_FILE") } if len(missing) > 0 { return config{}, fmt.Errorf("required configuration missing: %s", strings.Join(missing, ", ")) } if cfg.Service.BackfillWindow > 24*time.Hour { return config{}, errors.New("FEICHI_BACKFILL_WINDOW_SECONDS must not exceed 86400") } return cfg, nil } func readJSONSecret[T any](path string) (T, error) { var value T encoded, err := os.ReadFile(path) if err != nil { return value, err } if err := json.Unmarshal(encoded, &value); err != nil { return value, fmt.Errorf("decode %s: %w", path, err) } return value, nil } func env(name, fallback string) string { if value := strings.TrimSpace(os.Getenv(name)); value != "" { return value } return fallback } func envInt(name string, fallback int) int { value := strings.TrimSpace(os.Getenv(name)) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil || parsed <= 0 { return fallback } return parsed } func envBool(name string, fallback bool) bool { value := strings.TrimSpace(os.Getenv(name)) if value == "" { return fallback } parsed, err := strconv.ParseBool(value) if err != nil { return fallback } return parsed } func seconds(name string, fallback int) time.Duration { return time.Duration(envInt(name, fallback)) * time.Second }