2026-05-25 15:45:57 +08:00

292 lines
8.1 KiB
Go

package app
import (
"context"
"errors"
"log/slog"
"net"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
gamev1 "hyapp.local/api/proto/game/v1"
"hyapp/pkg/gamemq"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
"hyapp/services/game-service/internal/client"
"hyapp/services/game-service/internal/config"
gameservice "hyapp/services/game-service/internal/service/game"
mysqlstorage "hyapp/services/game-service/internal/storage/mysql"
grpcserver "hyapp/services/game-service/internal/transport/grpc"
)
// App 装配 game-service gRPC 入口和持久化依赖。
type App struct {
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
healthHTTP *healthhttp.Server
repo *mysqlstorage.Repository
walletConn *grpc.ClientConn
userConn *grpc.ClientConn
activityConn *grpc.ClientConn
outboxProducer *rocketmqx.Producer
outboxCtx context.Context
outboxCancel context.CancelFunc
outboxWG sync.WaitGroup
cfg config.Config
closeOnce sync.Once
}
func New(cfg config.Config) (*App, error) {
startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
repo, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN)
if err != nil {
return nil, err
}
walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = repo.Close()
return nil, err
}
userConn, err := grpc.Dial(cfg.UserServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = walletConn.Close()
_ = repo.Close()
return nil, err
}
activityConn, err := grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = userConn.Close()
_ = walletConn.Close()
_ = repo.Close()
return nil, err
}
listener, err := net.Listen("tcp", cfg.GRPCAddr)
if err != nil {
_ = activityConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
_ = repo.Close()
return nil, err
}
var outboxProducer *rocketmqx.Producer
if cfg.RocketMQ.GameOutbox.Enabled {
outboxProducer, err = rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ))
if err != nil {
_ = listener.Close()
_ = activityConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
_ = repo.Close()
return nil, err
}
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("game-service")))
svc := gameservice.New(gameservice.Config{LaunchSessionTTL: cfg.LaunchSessionTTL}, repo, client.NewWalletClient(walletConn), client.NewUserClient(userConn), client.NewActivityGrowthClient(activityConn))
transport := grpcserver.NewServer(svc)
gamev1.RegisterGameAppServiceServer(server, transport)
gamev1.RegisterGameCallbackServiceServer(server, transport)
gamev1.RegisterGameAdminServiceServer(server, transport)
gamev1.RegisterGameCronServiceServer(server, transport)
health := grpchealth.NewServingChecker("game-service", grpchealth.Dependency{
Name: "mysql",
Check: repo.Ping,
})
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
if err != nil {
_ = listener.Close()
_ = activityConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
_ = repo.Close()
return nil, err
}
outboxCtx, outboxCancel := context.WithCancel(context.Background())
return &App{server: server, listener: listener, health: health, healthHTTP: healthHTTP, repo: repo, walletConn: walletConn, userConn: userConn, activityConn: activityConn, outboxProducer: outboxProducer, outboxCtx: outboxCtx, outboxCancel: outboxCancel, cfg: cfg}, nil
}
func (a *App) Run() error {
a.runHealthHTTP()
if err := a.startMQ(); err != nil {
a.health.MarkStopped()
return err
}
a.runOutboxWorker()
a.health.MarkServing()
err := a.server.Serve(a.listener)
a.health.MarkStopped()
a.stopOutboxWorker()
a.shutdownMQ()
if errors.Is(err, grpc.ErrServerStopped) {
return nil
}
return err
}
func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
a.stopOutboxWorker()
grpcshutdown.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP()
if a.walletConn != nil {
_ = a.walletConn.Close()
}
if a.userConn != nil {
_ = a.userConn.Close()
}
if a.activityConn != nil {
_ = a.activityConn.Close()
}
if a.repo != nil {
_ = a.repo.Close()
}
a.shutdownMQ()
})
}
func (a *App) runOutboxWorker() {
if !a.cfg.OutboxWorker.Enabled || a.outboxProducer == nil || a.repo == nil {
return
}
workerID := "game-outbox-" + a.cfg.NodeID
a.outboxWG.Add(1)
go func() {
defer a.outboxWG.Done()
ticker := time.NewTicker(a.cfg.OutboxWorker.PollInterval)
defer ticker.Stop()
for {
processed, err := a.processOutboxBatch()
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(a.outboxCtx, "game_outbox_publish_failed", err, slog.String("worker_id", workerID))
}
if processed >= a.cfg.OutboxWorker.BatchSize {
continue
}
select {
case <-a.outboxCtx.Done():
return
case <-ticker.C:
}
}
}()
}
func (a *App) processOutboxBatch() (int, error) {
records, err := a.repo.ClaimPendingGameOutbox(a.outboxCtx, a.cfg.OutboxWorker.BatchSize)
if err != nil {
return 0, err
}
for _, record := range records {
publishCtx, cancel := context.WithTimeout(context.Background(), a.cfg.OutboxWorker.PublishTimeout)
err := a.publishGameOutboxRecord(publishCtx, record)
cancel()
if err != nil {
if markErr := a.repo.MarkGameOutboxPending(context.Background(), record.AppCode, record.EventID); markErr != nil {
return 0, markErr
}
return len(records), err
}
if err := a.repo.MarkGameOutboxDelivered(context.Background(), record.AppCode, record.EventID); err != nil {
return 0, err
}
}
return len(records), nil
}
func (a *App) publishGameOutboxRecord(ctx context.Context, record mysqlstorage.GameOutboxRecord) error {
body, err := gamemq.EncodeGameOutboxMessage(gamemq.GameOutboxMessage{
AppCode: record.AppCode,
EventID: record.EventID,
EventType: record.EventType,
OrderID: record.OrderID,
UserID: record.UserID,
PlatformCode: record.PlatformCode,
GameID: record.GameID,
OpType: record.OpType,
CoinAmount: record.CoinAmount,
PayloadJSON: record.PayloadJSON,
OccurredAtMS: record.CreatedAtMS,
})
if err != nil {
return err
}
return a.outboxProducer.SendSync(ctx, rocketmqx.Message{
Topic: a.cfg.RocketMQ.GameOutbox.Topic,
Tag: gamemq.TagGameOutboxEvent,
Keys: []string{record.EventID, record.OrderID, record.GameID},
Body: body,
})
}
func (a *App) startMQ() error {
if a.outboxProducer == nil {
return nil
}
return a.outboxProducer.Start()
}
func (a *App) shutdownMQ() {
if a.outboxProducer == nil {
return
}
if err := a.outboxProducer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error()))
}
}
func (a *App) stopOutboxWorker() {
if a.outboxCancel != nil {
a.outboxCancel()
}
a.outboxWG.Wait()
}
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.GameOutbox.ProducerGroup,
SendTimeout: cfg.SendTimeout,
Retry: cfg.Retry,
}
}
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)
}