2026-06-23 11:53:00 +08:00

333 lines
10 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"
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)
}