package command import ( "context" "errors" "fmt" "net" "net/http" "os" "os/signal" "strings" "syscall" "time" "github.com/gochat/gochat/channels/shangwutong/internal/account" "github.com/gochat/gochat/channels/shangwutong/internal/config" "github.com/gochat/gochat/channels/shangwutong/internal/delivery" "github.com/gochat/gochat/channels/shangwutong/internal/gochat" "github.com/gochat/gochat/channels/shangwutong/internal/httpapi" "github.com/gochat/gochat/channels/shangwutong/internal/observability" "github.com/gochat/gochat/channels/shangwutong/internal/store" "github.com/gochat/gochat/channels/shangwutong/internal/swt" "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) func NewRootCommand() *cobra.Command { root := &cobra.Command{ Use: "shangwutong", Short: "GoChat 商务通 Connector", SilenceUsage: true, SilenceErrors: true, } root.AddCommand(newServeCommand(), newMigrateCommand(), newReconcileCommand(), newBackupCommand(), newDoctorCommand()) return root } func newServeCommand() *cobra.Command { return &cobra.Command{ Use: "serve", Short: "启动 Connector", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return serve(cmd.Context()) }, } } func serve(ctx context.Context) error { cfg, err := config.Load() if err != nil { return err } logger := observability.NewLogger() entry := logrus.NewEntry(logger) metrics := observability.NewMetrics() database, err := store.Open(ctx, cfg.DBPath) if err != nil { return err } database.SetWriteObserver(metrics.SQLiteWrite) defer database.Close() if _, err := database.Writer().RecoverInboundDeliveries(ctx); err != nil { return fmt.Errorf("recover inbound queue: %w", err) } if _, err := database.Writer().RecoverOutboundPartDeliveriesAsUncertain(ctx); err != nil { return fmt.Errorf("recover outbound part queue: %w", err) } if _, err := database.Writer().RecoverOutboundDeliveriesAsUncertain(ctx); err != nil { return fmt.Errorf("recover outbound queue: %w", err) } if _, err := database.Writer().RecoverOutboundOperationsAsUncertain(ctx); err != nil { return fmt.Errorf("recover outbound operation queue: %w", err) } if _, err := database.Writer().RecoverOutboundStatusSyncs(ctx); err != nil { return fmt.Errorf("recover outbound status sync queue: %w", err) } transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = 1024 transport.MaxIdleConnsPerHost = 128 sharedHTTPClient := &http.Client{Transport: transport, Timeout: 30 * time.Second} gochatClient, err := gochat.NewClient(cfg.GoChatBaseURL, cfg.GoChatServiceToken, sharedHTTPClient) if err != nil { return err } protocolClient := swt.NewClient(sharedHTTPClient) manager, err := account.NewManager(database, protocolClient, gochatClient, entry, cfg.MaxInflightHeartbeats) if err != nil { return err } manager.SetMetrics(metrics) reconciler := account.NewReconciler(gochatClient, database, manager.WakeInbox) server, err := httpapi.NewServer(database, reconciler, manager, entry, metrics) if err != nil { return err } if err := server.RefreshMetrics(ctx); err != nil { return fmt.Errorf("load initial metric snapshot: %w", err) } outbound, err := delivery.NewOutbound(database, manager, protocolClient, gochatClient, entry, cfg.OutboundWorkers, cfg.GoChatBaseURL) if err != nil { return err } outbound.SetMetrics(metrics) inbound, err := delivery.NewInbound(database, gochatClient, entry, cfg.InboundWorkers) if err != nil { return err } inbound.SetMetrics(metrics) signalCtx, stopSignals := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stopSignals() serviceCtx, cancelService := context.WithCancel(context.Background()) defer cancelService() if err := manager.Start(serviceCtx); err != nil { return err } server.StartMetrics(serviceCtx) inbound.Start(serviceCtx) outbound.Start(serviceCtx) go reconcileUntilSuccessful(serviceCtx, reconciler.ReconcileAll, entry, time.Second) listenErr := make(chan error, 1) go func() { listenErr <- server.Listen(cfg.Listen) }() select { case err = <-listenErr: case <-signalCtx.Done(): } server.SetReady(false) shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) defer cancel() shutdownErr := server.Shutdown(shutdownCtx) cancelService() manager.Stop() inbound.Wait() outbound.Wait() manager.Wait() checkpointErr := database.Checkpoint(shutdownCtx) return errors.Join(err, shutdownErr, checkpointErr) } func reconcileUntilSuccessful(ctx context.Context, reconcile func(context.Context) error, entry *logrus.Entry, delay time.Duration) { if delay <= 0 { delay = time.Second } for { err := reconcile(ctx) if err == nil || ctx.Err() != nil { return } entry.WithFields(logrus.Fields{ "component": "config_reconcile", "operation": "startup", "result": "failed", }).WithError(err).Warn("startup configuration reconcile failed; local accounts continue running") waitDelay := delay var apiErr *gochat.APIError if errors.As(err, &apiErr) && apiErr.RetryAfter > waitDelay { waitDelay = apiErr.RetryAfter } timer := time.NewTimer(waitDelay) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: } if delay < 5*time.Minute { delay *= 2 if delay > 5*time.Minute { delay = 5 * time.Minute } } } } func newMigrateCommand() *cobra.Command { return &cobra.Command{ Use: "migrate up|status", Short: "执行或查看 SQLite migration", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { path, err := config.LoadDBPath() if err != nil { return err } switch args[0] { case "up": database, err := store.Open(cmd.Context(), path) if err != nil { return err } defer database.Close() version, err := database.MigrationVersion(cmd.Context()) if err == nil { _, _ = fmt.Fprintf(cmd.OutOrStdout(), "migration version: %d\n", version) } return err case "status": version, err := store.InspectDatabase(cmd.Context(), path) if err != nil { return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "migration version: %d\n", version) return nil default: return fmt.Errorf("unsupported migration action %q", args[0]) } }, } } func newReconcileCommand() *cobra.Command { return &cobra.Command{ Use: "reconcile", Short: "触发运行中 Connector 全量同步配置", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { target, err := localAdminURL(os.Getenv("SWT_CONNECTOR_LISTEN")) if err != nil { return err } // #nosec G704 -- target is derived from the operator-controlled connector listen address. request, err := http.NewRequestWithContext(cmd.Context(), http.MethodPost, target+"/internal/reconcile", nil) if err != nil { return err } // #nosec G704 -- the CLI intentionally calls its configured connector endpoint. response, err := (&http.Client{Timeout: 5 * time.Minute}).Do(request) if err != nil { return err } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { return fmt.Errorf("reconcile endpoint returned %s", response.Status) } _, _ = fmt.Fprintln(cmd.OutOrStdout(), "reconcile accepted") return nil }, } } func newBackupCommand() *cobra.Command { var output string command := &cobra.Command{ Use: "backup", Short: "在线备份 SQLite 数据库", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if strings.TrimSpace(output) == "" { return errors.New("--output is required") } path, err := config.LoadDBPath() if err != nil { return err } database, err := store.Open(cmd.Context(), path) if err != nil { return err } defer database.Close() if err := database.Backup(cmd.Context(), output); err != nil { return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "backup written: %s\n", output) return nil }, } command.Flags().StringVar(&output, "output", "", "备份输出路径") return command } func newDoctorCommand() *cobra.Command { return &cobra.Command{ Use: "doctor", Short: "只读检查配置、SQLite 和 GoChat API", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { cfg, err := config.Load() if err != nil { return err } version, err := store.InspectDatabase(cmd.Context(), cfg.DBPath) if err != nil { return fmt.Errorf("inspect SQLite: %w", err) } client, err := gochat.NewClient(cfg.GoChatBaseURL, cfg.GoChatServiceToken, nil) if err != nil { return err } configs, err := client.ListInboxConfigs(cmd.Context()) if err != nil { return fmt.Errorf("check GoChat connector API: %w", err) } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "ok: migration=%d inboxes=%d\n", version, len(configs)) return nil }, } } func localAdminURL(listen string) (string, error) { listen = strings.TrimSpace(listen) if listen == "" { listen = ":9100" } host, port, err := net.SplitHostPort(listen) if err != nil { return "", fmt.Errorf("parse SWT_CONNECTOR_LISTEN: %w", err) } if host == "" || host == "0.0.0.0" || host == "::" { host = "127.0.0.1" } return "http://" + net.JoinHostPort(host, port), nil }