package app import ( "context" "errors" "net" "sync" "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" luckygiftv1 "hyapp.local/api/proto/luckygift/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/grpchealth" "hyapp/pkg/healthhttp" "hyapp/pkg/logx" "hyapp/pkg/rocketmqx" serviceapp "hyapp/pkg/servicekit/app" servicegrpc "hyapp/pkg/servicekit/grpcserver" servicehealth "hyapp/pkg/servicekit/health" servicemq "hyapp/pkg/servicekit/mq" "hyapp/services/lucky-gift-service/internal/config" "hyapp/services/lucky-gift-service/internal/integration" luckygiftservice "hyapp/services/lucky-gift-service/internal/service/luckygift" mysqlstorage "hyapp/services/lucky-gift-service/internal/storage/mysql" grpcserver "hyapp/services/lucky-gift-service/internal/transport/grpc" ) // App 装配 lucky-gift-service 的 owner 进程。 // 该进程是幸运礼物规则、抽奖、外部 App 接入和 outbox 的唯一写入者;其他服务只持有 gRPC client。 type App struct { cfg config.Config server *grpc.Server listener net.Listener health *grpchealth.ServingChecker healthHTTP *healthhttp.Server repository *mysqlstorage.Repository walletConn *grpc.ClientConn eventProducer *rocketmqx.Producer service *luckygiftservice.Service workers *serviceapp.BackgroundGroup closeOnce sync.Once } func New(cfg config.Config) (*App, error) { startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() repository, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN) if err != nil { return nil, err } if cfg.MySQLAutoMigrate { if err := repository.Migrate(startupCtx); err != nil { _ = repository.Close() return nil, err } } walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { _ = repository.Close() return nil, err } var eventProducer *rocketmqx.Producer var eventPublisher luckygiftservice.EventPublisher if cfg.RocketMQ.LuckyGiftOutbox.Enabled { eventProducer, err = rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ)) if err != nil { _ = walletConn.Close() _ = repository.Close() return nil, err } eventPublisher = integration.NewRocketMQEventPublisher(eventProducer, cfg.RocketMQ.LuckyGiftOutbox.Topic) } listener, err := net.Listen("tcp", cfg.GRPCAddr) if err != nil { servicemq.ShutdownProducers([]*rocketmqx.Producer{eventProducer}) _ = walletConn.Close() _ = repository.Close() return nil, err } server := servicegrpc.New("lucky-gift-service") svc := luckygiftservice.New(repository, luckygiftservice.WithWallet(walletv1.NewWalletServiceClient(walletConn)), luckygiftservice.WithEventPublisher(eventPublisher), ) luckygiftv1.RegisterLuckyGiftServiceServer(server, grpcserver.NewLuckyGiftServer(svc)) luckygiftv1.RegisterAdminLuckyGiftServiceServer(server, grpcserver.NewAdminLuckyGiftServer(svc)) health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{ ServiceName: "lucky-gift-service", HTTPAddr: cfg.HealthHTTPAddr, NodeID: cfg.NodeID, Dependencies: []grpchealth.Dependency{{ Name: "mysql", Check: repository.Ping, }}, }) if err != nil { _ = listener.Close() servicemq.ShutdownProducers([]*rocketmqx.Producer{eventProducer}) _ = walletConn.Close() _ = repository.Close() return nil, err } return &App{ cfg: cfg, server: server, listener: listener, health: health, healthHTTP: healthHTTP, repository: repository, walletConn: walletConn, eventProducer: eventProducer, service: svc, workers: serviceapp.NewBackground(context.Background()), }, nil } func (a *App) Run() error { if a.healthHTTP != nil { go func() { if err := a.healthHTTP.Run(); err != nil { logx.Error(context.Background(), "lucky_gift_health_http_run_failed", err) } }() } if err := servicemq.StartProducers([]*rocketmqx.Producer{a.eventProducer}); err != nil { a.health.MarkStopped() return err } a.health.MarkServing() if a.cfg.LuckyGiftWorker.Enabled && a.service != nil { options := luckyGiftWorkerOptions(a.cfg.NodeID, a.cfg.LuckyGiftWorker) a.workers.Go(func(ctx context.Context) { a.service.RunWorker(ctx, options) }) } if a.cfg.LuckyGiftWorker.Retention.Enabled && a.service != nil { retention := a.cfg.LuckyGiftWorker.Retention a.workers.Go(func(ctx context.Context) { a.service.RunOutboxRetentionWorker(ctx, luckygiftservice.OutboxRetentionOptions{ ScanInterval: retention.ScanInterval, MaxAge: retention.MaxAge, BatchSize: retention.BatchSize, }) }) } err := a.server.Serve(a.listener) if a.workers != nil { a.workers.StopAndWait() } a.health.MarkStopped() servicemq.ShutdownProducers([]*rocketmqx.Producer{a.eventProducer}) if errors.Is(err, grpc.ErrServerStopped) { return nil } return err } func (a *App) Close() { a.closeOnce.Do(func() { if a.health != nil { a.health.MarkDraining() } if a.workers != nil { a.workers.StopAndWait() } servicegrpc.GracefulStop(a.server, 15*time.Second) if a.healthHTTP != nil { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _ = a.healthHTTP.Close(ctx) } if a.walletConn != nil { _ = a.walletConn.Close() } servicemq.ShutdownProducers([]*rocketmqx.Producer{a.eventProducer}) if a.repository != nil { _ = a.repository.Close() } }) } func rocketMQProducerConfig(cfg config.RocketMQConfig) rocketmqx.ProducerConfig { return rocketmqx.ProducerConfig{ EndpointConfig: rocketmqx.EndpointConfig{ NameServers: cfg.NameServers, NameServerDomain: cfg.NameServerDomain, AccessKey: cfg.AccessKey, SecretKey: cfg.SecretKey, SecurityToken: cfg.SecurityToken, Namespace: cfg.Namespace, VIPChannel: cfg.VIPChannel, }, GroupName: cfg.LuckyGiftOutbox.ProducerGroup, SendTimeout: cfg.LuckyGiftOutbox.SendTimeout, Retry: cfg.LuckyGiftOutbox.Retry, } } func luckyGiftWorkerOptions(nodeID string, cfg config.LuckyGiftWorkerConfig) luckygiftservice.WorkerOptions { return luckygiftservice.WorkerOptions{ WorkerID: nodeID, PollInterval: cfg.WorkerPollInterval, BatchSize: cfg.WorkerBatchSize, Concurrency: cfg.WorkerConcurrency, LockTTL: cfg.WorkerLockTTL, MaxRetry: cfg.WorkerMaxRetry, PublishTimeout: cfg.PublishTimeout, StatsInterval: cfg.StatsRefreshInterval, StatsBatchSize: cfg.StatsBatchSize, } }