Files
lingniu-vehicle-ingest/go/vehicle-gateway/cmd/feichi-bridge/main.go

212 lines
6.6 KiB
Go

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
}