2026-06-11 01:02:16 +08:00

298 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
diceservice "hyapp/services/game-service/internal/service/dice"
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")))
walletClient := client.NewWalletClient(walletConn)
svc := gameservice.New(gameservice.Config{LaunchSessionTTL: cfg.LaunchSessionTTL}, repo, walletClient, client.NewUserClient(userConn), client.NewActivityGrowthClient(activityConn))
// 骰子服务复用同一个 MySQL repository 和 wallet client局事实走 dice 表,金币事实仍走 game_orders + wallet-service。
diceSvc := diceservice.New(diceservice.Config{}, repo, walletClient)
transport := grpcserver.NewServer(svc, diceSvc)
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) {
workerID := "game-outbox-" + a.cfg.NodeID
records, err := a.repo.ClaimPendingGameOutbox(a.outboxCtx, workerID, 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 {
nextRetryAtMS := time.Now().UTC().Add(a.cfg.OutboxWorker.PollInterval).UnixMilli()
if markErr := a.repo.MarkGameOutboxRetryable(context.Background(), record.AppCode, record.EventID, err.Error(), nextRetryAtMS); 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)
}