Files
gochat/channels/shangwutong/internal/command/root.go
T

318 lines
9.3 KiB
Go

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().RecoverOutboundOperationResults(ctx); err != nil {
return fmt.Errorf("recover outbound operation result 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
}