318 lines
9.3 KiB
Go
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
|
|
}
|