2026-06-08 14:12:23 +08:00

372 lines
12 KiB
Go

// Package app wires notice-service modules, health endpoints and background workers.
package app
import (
"context"
"errors"
"log/slog"
"net"
"sync"
"time"
"google.golang.org/grpc"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
"hyapp/pkg/appcode"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
"hyapp/pkg/roommq"
"hyapp/pkg/tencentim"
"hyapp/pkg/usermq"
"hyapp/pkg/walletmq"
"hyapp/services/notice-service/internal/config"
"hyapp/services/notice-service/internal/modules/cpnotice"
"hyapp/services/notice-service/internal/modules/roomnotice"
"hyapp/services/notice-service/internal/modules/walletnotice"
mysqlplatform "hyapp/services/notice-service/internal/platform/mysql"
)
// App owns the notice-service process lifecycle.
type App struct {
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
healthHTTP *healthhttp.Server
store *mysqlplatform.Store
walletNoticeService *walletnotice.Service
walletWorkerOptions walletnotice.WalletNoticeWorkerOptions
walletWorkerEnabled bool
roomNoticeService *roomnotice.Service
roomWorkerOptions roomnotice.RoomNoticeWorkerOptions
roomWorkerEnabled bool
cpNoticeService *cpnotice.Service
cpWorkerOptions cpnotice.CPNoticeWorkerOptions
mqConsumers []*rocketmqx.Consumer
workerCtx context.Context
workerCancel context.CancelFunc
workerWG sync.WaitGroup
closeOnce sync.Once
}
// New initializes MySQL, module repositories and optional Tencent IM publisher.
func New(cfg config.Config) (*App, error) {
startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
store, err := mysqlplatform.Open(startupCtx, cfg.MySQLDSN)
if err != nil {
return nil, err
}
listener, err := net.Listen("tcp", cfg.GRPCAddr)
if err != nil {
_ = store.Close()
return nil, err
}
walletRepo, err := walletnotice.NewMySQLRepository(store.DB, cfg.WalletDatabase)
if err != nil {
_ = listener.Close()
_ = store.Close()
return nil, err
}
roomRepo, err := roomnotice.NewMySQLRepository(store.DB, cfg.RoomDatabase)
if err != nil {
_ = listener.Close()
_ = store.Close()
return nil, err
}
cpRepo := cpnotice.NewMySQLRepository(store.DB)
var publisher *tencentim.RESTClient
if cfg.TencentIM.Enabled {
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
if err != nil {
_ = listener.Close()
_ = store.Close()
return nil, err
}
publisher = tencentClient
}
if cfg.WalletNoticeWorker.Enabled && publisher == nil {
_ = listener.Close()
_ = store.Close()
return nil, errors.New("wallet notice worker requires tencent_im.enabled")
}
if cfg.RoomNoticeWorker.Enabled && publisher == nil {
_ = listener.Close()
_ = store.Close()
return nil, errors.New("room notice worker requires tencent_im.enabled")
}
if cfg.RocketMQ.WalletOutbox.Enabled && publisher == nil {
_ = listener.Close()
_ = store.Close()
return nil, errors.New("wallet outbox mq consumer requires tencent_im.enabled")
}
if cfg.RocketMQ.RoomOutbox.Enabled && publisher == nil {
_ = listener.Close()
_ = store.Close()
return nil, errors.New("room outbox mq consumer requires tencent_im.enabled")
}
if cfg.RocketMQ.UserOutbox.Enabled && publisher == nil {
_ = listener.Close()
_ = store.Close()
return nil, errors.New("user outbox mq consumer requires tencent_im.enabled")
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("notice-service")))
health := grpchealth.NewServingChecker("notice-service", grpchealth.Dependency{
Name: "mysql",
Check: store.Ping,
})
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
if err != nil {
_ = listener.Close()
_ = store.Close()
return nil, err
}
walletSvc := walletnotice.New(walletnotice.Config{NodeID: cfg.NodeID}, walletRepo, publisher)
roomSvc := roomnotice.New(roomnotice.Config{NodeID: cfg.NodeID}, roomRepo, publisher)
roomOptions := roomNoticeWorkerOptions(cfg.RoomNoticeWorker)
cpSvc := cpnotice.New(cpnotice.Config{NodeID: cfg.NodeID, GroupIDPrefix: cfg.TencentIM.GroupIDPrefix}, cpRepo, publisher)
cpOptions := cpNoticeWorkerOptions(cfg.CPNoticeWorker)
mqConsumers := make([]*rocketmqx.Consumer, 0, 3)
if cfg.RocketMQ.WalletOutbox.Enabled {
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.ConsumerGroup, cfg.RocketMQ.WalletOutbox.ConsumerMaxReconsumeTimes))
if err != nil {
_ = listener.Close()
_ = store.Close()
return nil, err
}
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body)
if err != nil {
return err
}
_, err = walletSvc.ProcessWalletOutboxMessage(appcode.WithContext(ctx, walletMessage.AppCode), walletMessage, walletNoticeWorkerOptions(cfg.WalletNoticeWorker))
return err
}); err != nil {
_ = consumer.Shutdown()
_ = listener.Close()
_ = store.Close()
return nil, err
}
mqConsumers = append(mqConsumers, consumer)
}
if cfg.RocketMQ.RoomOutbox.Enabled {
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.RoomOutbox.ConsumerGroup, cfg.RocketMQ.RoomOutbox.ConsumerMaxReconsumeTimes))
if err != nil {
shutdownConsumers(mqConsumers)
_ = listener.Close()
_ = store.Close()
return nil, err
}
if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body)
if err != nil {
return err
}
_, err = roomSvc.ProcessRoomOutboxEnvelope(appcode.WithContext(ctx, envelope.GetAppCode()), envelope, roomOptions)
return err
}); err != nil {
_ = listener.Close()
_ = store.Close()
shutdownConsumers(mqConsumers)
return nil, err
}
mqConsumers = append(mqConsumers, consumer)
}
if cfg.RocketMQ.UserOutbox.Enabled {
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.UserOutbox.ConsumerGroup, cfg.RocketMQ.UserOutbox.ConsumerMaxReconsumeTimes))
if err != nil {
shutdownConsumers(mqConsumers)
_ = listener.Close()
_ = store.Close()
return nil, err
}
if err := consumer.Subscribe(cfg.RocketMQ.UserOutbox.Topic, usermq.TagUserOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
userMessage, err := usermq.DecodeUserOutboxMessage(message.Body)
if err != nil {
return err
}
_, err = cpSvc.ProcessUserOutboxMessage(appcode.WithContext(ctx, userMessage.AppCode), userMessage, cpOptions)
return err
}); err != nil {
_ = listener.Close()
_ = store.Close()
shutdownConsumers(mqConsumers)
return nil, err
}
mqConsumers = append(mqConsumers, consumer)
}
workerCtx, workerCancel := context.WithCancel(context.Background())
return &App{
server: server,
listener: listener,
health: health,
healthHTTP: healthHTTP,
store: store,
walletNoticeService: walletSvc,
walletWorkerOptions: walletNoticeWorkerOptions(cfg.WalletNoticeWorker),
walletWorkerEnabled: cfg.WalletNoticeWorker.Enabled,
roomNoticeService: roomSvc,
roomWorkerOptions: roomOptions,
roomWorkerEnabled: cfg.RoomNoticeWorker.Enabled,
cpNoticeService: cpSvc,
cpWorkerOptions: cpOptions,
mqConsumers: mqConsumers,
workerCtx: workerCtx,
workerCancel: workerCancel,
}, nil
}
// Run starts health serving, optional workers and the gRPC health endpoint.
func (a *App) Run() error {
a.runHealthHTTP()
a.health.MarkServing()
if err := a.startMQ(); err != nil {
a.health.MarkStopped()
return err
}
defer func() {
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
a.shutdownMQ()
a.health.MarkStopped()
}()
if a.walletWorkerEnabled && a.walletNoticeService != nil {
a.workerWG.Go(func() {
a.walletNoticeService.RunWalletBalanceWorker(a.workerCtx, a.walletWorkerOptions)
})
}
if a.roomWorkerEnabled && a.roomNoticeService != nil {
a.workerWG.Go(func() {
a.roomNoticeService.RunRoomKickWorker(a.workerCtx, a.roomWorkerOptions)
})
}
err := a.server.Serve(a.listener)
if errors.Is(err, grpc.ErrServerStopped) {
return nil
}
return err
}
// Close drains workers before closing gRPC and MySQL resources.
func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
a.shutdownMQ()
grpcshutdown.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP()
if a.store != nil {
_ = a.store.Close()
}
})
}
func (a *App) runHealthHTTP() {
if a.healthHTTP == nil {
return
}
go func() {
if err := a.healthHTTP.Run(); err != nil {
logx.Error(context.Background(), "health_http_run_failed", err)
}
}()
}
func (a *App) closeHealthHTTP() {
if a.healthHTTP == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = a.healthHTTP.Close(ctx)
}
func walletNoticeWorkerOptions(cfg config.WalletNoticeWorkerConfig) walletnotice.WalletNoticeWorkerOptions {
return walletnotice.WalletNoticeWorkerOptions{
PollInterval: cfg.PollInterval,
BatchSize: cfg.BatchSize,
LockTTL: cfg.LockTTL,
PublishTimeout: cfg.PublishTimeout,
MaxRetryCount: cfg.MaxRetryCount,
InitialBackoff: cfg.InitialBackoff,
MaxBackoff: cfg.MaxBackoff,
}
}
func roomNoticeWorkerOptions(cfg config.WalletNoticeWorkerConfig) roomnotice.RoomNoticeWorkerOptions {
return roomnotice.RoomNoticeWorkerOptions{
PollInterval: cfg.PollInterval,
BatchSize: cfg.BatchSize,
LockTTL: cfg.LockTTL,
PublishTimeout: cfg.PublishTimeout,
MaxRetryCount: cfg.MaxRetryCount,
InitialBackoff: cfg.InitialBackoff,
MaxBackoff: cfg.MaxBackoff,
}
}
func cpNoticeWorkerOptions(cfg config.WalletNoticeWorkerConfig) cpnotice.CPNoticeWorkerOptions {
return cpnotice.CPNoticeWorkerOptions{
BatchSize: cfg.BatchSize,
LockTTL: cfg.LockTTL,
PublishTimeout: cfg.PublishTimeout,
MaxRetryCount: cfg.MaxRetryCount,
InitialBackoff: cfg.InitialBackoff,
MaxBackoff: cfg.MaxBackoff,
}
}
func (a *App) startMQ() error {
for _, consumer := range a.mqConsumers {
if err := consumer.Start(); err != nil {
a.shutdownMQ()
return err
}
}
return nil
}
func (a *App) shutdownMQ() {
for _, consumer := range a.mqConsumers {
if err := consumer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error()))
}
}
}
func shutdownConsumers(consumers []*rocketmqx.Consumer) {
for _, consumer := range consumers {
_ = consumer.Shutdown()
}
}
func rocketMQConsumerConfig(cfg config.RocketMQConfig, group string, maxReconsumeTimes int32) rocketmqx.ConsumerConfig {
return rocketmqx.ConsumerConfig{
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: group,
MaxReconsumeTimes: maxReconsumeTimes,
ConsumeRetryDelay: time.Second,
ConsumePullBatch: 32,
ConsumePullTimeout: 15 * time.Minute,
}
}