333 lines
10 KiB
Go
333 lines
10 KiB
Go
package app
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"log/slog"
|
||
"net"
|
||
"sync"
|
||
"time"
|
||
|
||
"google.golang.org/grpc"
|
||
"google.golang.org/grpc/credentials/insecure"
|
||
gamev1 "hyapp.local/api/proto/game/v1"
|
||
"hyapp/pkg/gamemq"
|
||
"hyapp/pkg/grpchealth"
|
||
"hyapp/pkg/healthhttp"
|
||
"hyapp/pkg/logx"
|
||
"hyapp/pkg/rocketmqx"
|
||
servicegrpc "hyapp/pkg/servicekit/grpcserver"
|
||
servicehealth "hyapp/pkg/servicekit/health"
|
||
servicemq "hyapp/pkg/servicekit/mq"
|
||
"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"
|
||
roomrpsservice "hyapp/services/game-service/internal/service/roomrps"
|
||
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
|
||
robotConn *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
|
||
}
|
||
robotConn, err := grpc.Dial(cfg.RobotServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||
if err != nil {
|
||
_ = activityConn.Close()
|
||
_ = userConn.Close()
|
||
_ = walletConn.Close()
|
||
_ = repo.Close()
|
||
return nil, err
|
||
}
|
||
listener, err := net.Listen("tcp", cfg.GRPCAddr)
|
||
if err != nil {
|
||
_ = robotConn.Close()
|
||
_ = 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()
|
||
_ = robotConn.Close()
|
||
_ = activityConn.Close()
|
||
_ = userConn.Close()
|
||
_ = walletConn.Close()
|
||
_ = repo.Close()
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
server := servicegrpc.New("game-service")
|
||
walletClient := client.NewWalletClient(walletConn)
|
||
userClient := client.NewUserClient(userConn)
|
||
svc := gameservice.New(gameservice.Config{LaunchSessionTTL: cfg.LaunchSessionTTL}, repo, walletClient, userClient, client.NewActivityGrowthClient(activityConn))
|
||
// 骰子服务仍持有局事实仓储和钱包依赖,但机器人池已经拆到 robot-service;旧后台机器人 RPC 会通过这个客户端转发,避免后台接口一次性改约。
|
||
diceSvc := diceservice.New(diceservice.Config{}, repo, walletClient, diceservice.WithRobotRegistry(client.NewGameRobotClient(robotConn)))
|
||
var roomRPSPublisher roomrpsservice.Publisher
|
||
if cfg.TencentIM.Enabled {
|
||
// 房内 RPS 的“发起 PK”需要同步投递腾讯 IM 房间群消息;启动阶段先创建 REST client,避免运行中才发现配置缺失。
|
||
roomRPSPublisher, err = roomrpsservice.NewTencentIMPublisher(cfg.TencentIM.RESTConfig())
|
||
if err != nil {
|
||
_ = listener.Close()
|
||
_ = robotConn.Close()
|
||
_ = activityConn.Close()
|
||
_ = userConn.Close()
|
||
_ = walletConn.Close()
|
||
_ = repo.Close()
|
||
return nil, err
|
||
}
|
||
} else {
|
||
// 显式关闭时保留 HTTP/RPC 功能,便于纯本地测试;测试服和线上必须在 YAML 中打开,否则用户看不到房内 PK。
|
||
logx.Warn(context.Background(), "room_rps_im_disabled", slog.String("reason", "tencent_im.enabled=false"))
|
||
}
|
||
// 房内猜拳独立于骰子和独立猜拳:配置、挑战状态机和 IM 事件全部由 room-rps service 承接,transport 只做 RPC 适配。
|
||
roomRPSSvc := roomrpsservice.New(roomrpsservice.Config{}, userClient, roomRPSPublisher, walletClient, repo)
|
||
transport := grpcserver.NewServer(svc, diceSvc, roomRPSSvc)
|
||
gamev1.RegisterGameAppServiceServer(server, transport)
|
||
gamev1.RegisterGameCallbackServiceServer(server, transport)
|
||
gamev1.RegisterGameAdminServiceServer(server, transport)
|
||
gamev1.RegisterGameCronServiceServer(server, transport)
|
||
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
|
||
ServiceName: "game-service",
|
||
HTTPAddr: cfg.HealthHTTPAddr,
|
||
NodeID: cfg.NodeID,
|
||
Dependencies: []grpchealth.Dependency{{
|
||
Name: "mysql",
|
||
Check: repo.Ping,
|
||
}},
|
||
})
|
||
if err != nil {
|
||
_ = listener.Close()
|
||
_ = robotConn.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, robotConn: robotConn, 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()
|
||
servicegrpc.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.robotConn != nil {
|
||
_ = a.robotConn.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 servicemq.StartProducers([]*rocketmqx.Producer{a.outboxProducer})
|
||
}
|
||
|
||
func (a *App) shutdownMQ() {
|
||
servicemq.ShutdownProducers([]*rocketmqx.Producer{a.outboxProducer})
|
||
}
|
||
|
||
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)
|
||
}
|