873 lines
32 KiB
Go
873 lines
32 KiB
Go
// Package app 装配 user-service 的 gRPC 入口、领域服务、repository 和健康检查。
|
||
package app
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"log/slog"
|
||
"net"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"google.golang.org/protobuf/proto"
|
||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||
userv1 "hyapp.local/api/proto/user/v1"
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/grpcclient"
|
||
"hyapp/pkg/grpchealth"
|
||
"hyapp/pkg/healthhttp"
|
||
"hyapp/pkg/idgen"
|
||
"hyapp/pkg/logx"
|
||
"hyapp/pkg/rocketmqx"
|
||
"hyapp/pkg/roommq"
|
||
serviceapp "hyapp/pkg/servicekit/app"
|
||
servicegrpc "hyapp/pkg/servicekit/grpcserver"
|
||
servicehealth "hyapp/pkg/servicekit/health"
|
||
servicemq "hyapp/pkg/servicekit/mq"
|
||
"hyapp/pkg/tencentim"
|
||
"hyapp/pkg/usermq"
|
||
"hyapp/pkg/walletmq"
|
||
"hyapp/services/user-service/internal/config"
|
||
cpdomain "hyapp/services/user-service/internal/domain/cp"
|
||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||
mictimedomain "hyapp/services/user-service/internal/domain/mictime"
|
||
"hyapp/services/user-service/internal/integration/activityclient"
|
||
"hyapp/services/user-service/internal/integration/roomclient"
|
||
"hyapp/services/user-service/internal/integration/walletclient"
|
||
authservice "hyapp/services/user-service/internal/service/auth"
|
||
cpservice "hyapp/services/user-service/internal/service/cp"
|
||
hostservice "hyapp/services/user-service/internal/service/host"
|
||
inviteservice "hyapp/services/user-service/internal/service/invite"
|
||
mictimeservice "hyapp/services/user-service/internal/service/mictime"
|
||
userservice "hyapp/services/user-service/internal/service/user"
|
||
mysqlstorage "hyapp/services/user-service/internal/storage/mysql"
|
||
grpcserver "hyapp/services/user-service/internal/transport/grpc"
|
||
|
||
"google.golang.org/grpc"
|
||
)
|
||
|
||
// App 装配 user-service gRPC 入口和底座依赖。
|
||
type App struct {
|
||
// server 承载 AuthService、UserService、UserIdentityService 和 gRPC health。
|
||
server *grpc.Server
|
||
// listener 是 user-service 的唯一网络入口。
|
||
listener net.Listener
|
||
// health 是标准 gRPC health 的 serving/draining 状态来源。
|
||
health *grpchealth.ServingChecker
|
||
// healthHTTP 给 CLB 和发布脚本提供 HTTP readiness,不承载业务请求。
|
||
healthHTTP *healthhttp.Server
|
||
// mysqlRepo 持有用户主数据、认证身份、session 和短号事务的唯一运行时存储。
|
||
mysqlRepo *mysqlstorage.Repository
|
||
// mqConsumers 按 consumer group 消费 wallet/room outbox 事实。
|
||
mqConsumers []*rocketmqx.Consumer
|
||
// userOutboxProducer 把 user_outbox 事实发布到 RocketMQ,供统计等读模型消费。
|
||
userOutboxProducer *rocketmqx.Producer
|
||
// roomConn 是低频平台治理调用 room-service 的 gRPC 连接。
|
||
roomConn *grpc.ClientConn
|
||
// activityConn 是注册完成后触发注册奖励的 gRPC 连接。
|
||
activityConn *grpc.ClientConn
|
||
// walletConn 是读取用户佩戴资源快照的 gRPC 连接。
|
||
walletConn *grpc.ClientConn
|
||
// userSvc 持有用户主数据用例,cron RPC 复用它消费区域重算任务。
|
||
userSvc *userservice.Service
|
||
// authSvc 持有认证、登录 session 和登录 IP 风控用例。
|
||
authSvc *authservice.Service
|
||
// inviteSvc 消费 wallet recharge outbox,维护有效邀请 read model。
|
||
inviteSvc *inviteservice.Service
|
||
// micTimeSvc 消费 room mic outbox,维护用户维度麦上时长 read model。
|
||
micTimeSvc *mictimeservice.Service
|
||
// cpSvc 消费 room gift outbox,维护 CP/兄弟/姐妹申请和亲密值。
|
||
cpSvc *cpservice.Service
|
||
// loginRiskRedisClose 关闭登录风险 Redis cache;cache 不作为启动硬依赖。
|
||
loginRiskRedisClose func() error
|
||
// cpLeaderboardRedisClose 关闭 CP 亲密榜 Redis 读模型连接。
|
||
cpLeaderboardRedisClose func() error
|
||
// cfg 保存 worker 运行参数,避免 Run 阶段重新读配置。
|
||
cfg config.Config
|
||
// workers 统一控制后台 worker 生命周期。
|
||
workers *serviceapp.BackgroundGroup
|
||
// closeOnce 防止信号退出和 Serve 异常同时触发重复关闭。
|
||
closeOnce sync.Once
|
||
}
|
||
|
||
// New 初始化 user-service 应用。
|
||
func New(cfg config.Config) (*App, error) {
|
||
// userRepository 只表达用户主数据用例,authRepository 只表达认证用例;MySQL 实现同时满足两者。
|
||
startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
// user-service 的登录、注册、短号和 session 都必须落 MySQL;空 DSN 直接启动失败。
|
||
mysqlRepo, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// listener 在 New 阶段创建,端口占用会作为启动失败返回。
|
||
listener, err := net.Listen("tcp", cfg.GRPCAddr)
|
||
if err != nil {
|
||
if mysqlRepo != nil {
|
||
_ = mysqlRepo.Close()
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
server := servicegrpc.New("user-service")
|
||
thirdPartyVerifier := authservice.NewRoutedThirdPartyVerifier(
|
||
authservice.NewAppFirebaseThirdPartyVerifier(authservice.AppFirebaseVerifierConfig{
|
||
Default: authservice.FirebaseVerifierConfig{
|
||
ProjectID: cfg.ThirdParty.Firebase.ProjectID,
|
||
AllowedSignInProviders: cfg.ThirdParty.Firebase.AllowedSignInProviders,
|
||
CertsURL: cfg.ThirdParty.Firebase.CertsURL,
|
||
},
|
||
Projects: firebaseVerifierProjects(cfg.ThirdParty.Firebase),
|
||
}),
|
||
authservice.NewStaticThirdPartyVerifier(cfg.ThirdParty.AllowedProviders),
|
||
)
|
||
authRepo := mysqlRepo.AuthRepository()
|
||
appRepo := mysqlRepo.AppRepository()
|
||
userRepo := mysqlRepo.UserRepository()
|
||
identityRepo := mysqlRepo.IdentityRepository()
|
||
regionRepo := mysqlRepo.RegionRepository()
|
||
deviceRepo := mysqlRepo.DeviceRepository()
|
||
hostRepo := mysqlRepo.HostRepository()
|
||
inviteRepo := mysqlRepo.InviteRepository()
|
||
micTimeRepo := mysqlRepo.MicTimeRepository()
|
||
cpRepo := mysqlRepo.CPRepository()
|
||
var ipDecisionCache authservice.IPDecisionCache
|
||
var registerRiskConfigCache authservice.RegisterRiskConfigCache
|
||
var loginRiskRedisClose func() error
|
||
if cfg.LoginRisk.Enabled && cfg.LoginRisk.RedisAddr != "" {
|
||
redisClient, redisErr := authservice.NewLoginRiskRedisClient(startupCtx, cfg.LoginRisk.RedisAddr, cfg.LoginRisk.RedisPassword, cfg.LoginRisk.RedisDB)
|
||
if redisErr == nil {
|
||
riskCache := authservice.NewRedisIPDecisionCache(redisClient)
|
||
ipDecisionCache = riskCache
|
||
registerRiskConfigCache = riskCache
|
||
loginRiskRedisClose = redisClient.Close
|
||
} else {
|
||
// IP 风控 Redis 缓存按文档 fail-open;启动继续,worker 仍会写 MySQL 审计。
|
||
logx.Warn(startupCtx, "login_risk_redis_unavailable")
|
||
}
|
||
}
|
||
var cpLeaderboardStore cpservice.IntimacyLeaderboardStore
|
||
var cpLeaderboardRedisClose func() error
|
||
if cfg.CPIntimacyLeaderboard.Enabled {
|
||
// CP 亲密榜是 App 查询的唯一读模型,启动时必须能连上 Redis;否则服务启动成功但榜单接口必然不可用。
|
||
redisClient, redisErr := cpservice.NewRedisIntimacyLeaderboardClient(startupCtx, cfg.CPIntimacyLeaderboard.RedisAddr, cfg.CPIntimacyLeaderboard.RedisPassword, cfg.CPIntimacyLeaderboard.RedisDB)
|
||
if redisErr != nil {
|
||
if loginRiskRedisClose != nil {
|
||
_ = loginRiskRedisClose()
|
||
}
|
||
_ = listener.Close()
|
||
_ = mysqlRepo.Close()
|
||
return nil, redisErr
|
||
}
|
||
// store 只持有 Redis client 和 key 前缀;具体 app_code/relation_type 在 cron 刷新和查询时动态决定。
|
||
cpLeaderboardStore = cpservice.NewRedisIntimacyLeaderboardStore(redisClient, cfg.CPIntimacyLeaderboard.KeyPrefix)
|
||
cpLeaderboardRedisClose = redisClient.Close
|
||
}
|
||
var imLoginKicker userservice.IMLoginKicker
|
||
var imAccountImporter authservice.IMAccountImporter
|
||
if cfg.TencentIM.Enabled {
|
||
imClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
||
if err != nil {
|
||
if loginRiskRedisClose != nil {
|
||
_ = loginRiskRedisClose()
|
||
}
|
||
if cpLeaderboardRedisClose != nil {
|
||
_ = cpLeaderboardRedisClose()
|
||
}
|
||
_ = listener.Close()
|
||
_ = mysqlRepo.Close()
|
||
return nil, err
|
||
}
|
||
imLoginKicker = imClient
|
||
imAccountImporter = tencentIMAccountImporter{client: imClient}
|
||
}
|
||
var roomConn *grpc.ClientConn
|
||
var roomEvictor userservice.RoomEvictor
|
||
if cfg.RoomService.Enabled {
|
||
roomConn, err = grpcclient.Dial(cfg.RoomService.Addr, grpcclient.Config{
|
||
DefaultTimeout: time.Duration(cfg.RoomService.RequestTimeoutMs) * time.Millisecond,
|
||
})
|
||
if err != nil {
|
||
if loginRiskRedisClose != nil {
|
||
_ = loginRiskRedisClose()
|
||
}
|
||
if cpLeaderboardRedisClose != nil {
|
||
_ = cpLeaderboardRedisClose()
|
||
}
|
||
_ = listener.Close()
|
||
_ = mysqlRepo.Close()
|
||
return nil, err
|
||
}
|
||
roomEvictor = roomclient.NewGRPC(roomConn)
|
||
}
|
||
var activityConn *grpc.ClientConn
|
||
var registrationRewardIssuer authservice.RegistrationRewardIssuer
|
||
var registrationLevelBadgeIssuer authservice.RegistrationLevelBadgeIssuer
|
||
var levelProfileReader userservice.LevelProfileReader
|
||
if cfg.ActivityService.Enabled {
|
||
activityConn, err = grpcclient.Dial(cfg.ActivityService.Addr, grpcclient.Config{
|
||
DefaultTimeout: time.Duration(cfg.ActivityService.RequestTimeoutMs) * time.Millisecond,
|
||
})
|
||
if err != nil {
|
||
if loginRiskRedisClose != nil {
|
||
_ = loginRiskRedisClose()
|
||
}
|
||
if cpLeaderboardRedisClose != nil {
|
||
_ = cpLeaderboardRedisClose()
|
||
}
|
||
if roomConn != nil {
|
||
_ = roomConn.Close()
|
||
}
|
||
_ = listener.Close()
|
||
_ = mysqlRepo.Close()
|
||
return nil, err
|
||
}
|
||
activityClient := activityclient.NewGRPC(activityConn)
|
||
registrationRewardIssuer = activityClient
|
||
registrationLevelBadgeIssuer = activityClient
|
||
levelProfileReader = activityClient
|
||
}
|
||
var walletConn *grpc.ClientConn
|
||
var avatarFrameReader cpservice.AvatarFrameReader
|
||
if cfg.WalletService.Enabled {
|
||
// 头像框佩戴事实归 wallet-service;user-service 刷新榜单前必须能读取该快照,否则榜单会缺少双方头像框。
|
||
walletConn, err = grpcclient.Dial(cfg.WalletService.Addr, grpcclient.Config{
|
||
DefaultTimeout: time.Duration(cfg.WalletService.RequestTimeoutMs) * time.Millisecond,
|
||
})
|
||
if err != nil {
|
||
if loginRiskRedisClose != nil {
|
||
_ = loginRiskRedisClose()
|
||
}
|
||
if cpLeaderboardRedisClose != nil {
|
||
_ = cpLeaderboardRedisClose()
|
||
}
|
||
if roomConn != nil {
|
||
_ = roomConn.Close()
|
||
}
|
||
if activityConn != nil {
|
||
_ = activityConn.Close()
|
||
}
|
||
_ = listener.Close()
|
||
_ = mysqlRepo.Close()
|
||
return nil, err
|
||
}
|
||
// 只把头像框读取能力注入 CP service,避免 CP 领域依赖完整的钱包客户端。
|
||
avatarFrameReader = walletclient.NewGRPC(walletConn)
|
||
}
|
||
|
||
// auth service 负责登录、session 和 token;用户主数据和短号事务仍通过各自领域存储完成。
|
||
authSvc := authservice.New(authservice.Config{
|
||
Issuer: cfg.JWT.Issuer,
|
||
AccessTokenTTLSec: cfg.JWT.AccessTokenTTLSec,
|
||
RefreshTokenTTLSec: cfg.JWT.RefreshTokenTTLSec,
|
||
SigningAlg: cfg.JWT.SigningAlg,
|
||
SigningSecret: cfg.JWT.SigningSecret,
|
||
},
|
||
authservice.WithAuthRepository(authRepo),
|
||
authservice.WithUserRepository(userRepo),
|
||
authservice.WithIdentityRepository(identityRepo),
|
||
authservice.WithCountryRegionRepository(regionRepo),
|
||
authservice.WithIDGenerator(idgen.NewInt64Generator(cfg.IDGenerator.NodeID)),
|
||
authservice.WithDisplayUserIDAllocateMaxAttempts(cfg.DisplayUserID.AllocateMaxAttempts),
|
||
authservice.WithThirdPartyVerifier(thirdPartyVerifier),
|
||
authservice.WithLoginRiskPolicy(authservice.LoginRiskPolicy{
|
||
Enabled: cfg.LoginRisk.Enabled,
|
||
UnsupportedCountries: cfg.LoginRisk.UnsupportedCountries,
|
||
BlockedTTL: time.Duration(cfg.LoginRisk.BlockedTTLSec) * time.Second,
|
||
AllowedTTL: time.Duration(cfg.LoginRisk.AllowedTTLSec) * time.Second,
|
||
UnknownTTL: time.Duration(cfg.LoginRisk.UnknownTTLSec) * time.Second,
|
||
ProviderTimeout: time.Duration(cfg.LoginRisk.ProviderTimeoutMs) * time.Millisecond,
|
||
ProviderNames: cfg.LoginRisk.Providers,
|
||
DenylistTTL: time.Duration(cfg.JWT.AccessTokenTTLSec+60) * time.Second,
|
||
}),
|
||
authservice.WithIPDecisionCache(ipDecisionCache),
|
||
authservice.WithRegisterRiskConfigCache(registerRiskConfigCache),
|
||
authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders(cfg.LoginRisk.Providers)),
|
||
authservice.WithRegistrationRewardIssuer(registrationRewardIssuer),
|
||
authservice.WithRegistrationLevelBadgeIssuer(registrationLevelBadgeIssuer),
|
||
authservice.WithIMAccountImporter(imAccountImporter),
|
||
)
|
||
// user service 负责用户主状态和 display_user_id/靓号用例,不进入房间高频流程。
|
||
userSvc := userservice.New(userRepo,
|
||
userservice.WithAppRegistryRepository(appRepo),
|
||
userservice.WithIdentityRepository(identityRepo),
|
||
userservice.WithLevelProfileReader(levelProfileReader),
|
||
userservice.WithCountryRegionRepository(regionRepo),
|
||
userservice.WithCountryAdminRepository(regionRepo),
|
||
userservice.WithRegionAdminRepository(regionRepo),
|
||
userservice.WithRegionRebuildRepository(regionRepo),
|
||
userservice.WithRoleSummaryRepository(hostRepo),
|
||
userservice.WithDeviceRepository(deviceRepo),
|
||
userservice.WithModerationRepository(userRepo),
|
||
userservice.WithSessionDenylist(ipDecisionCache, time.Duration(cfg.JWT.AccessTokenTTLSec+60)*time.Second),
|
||
userservice.WithIMLoginKicker(imLoginKicker),
|
||
userservice.WithRoomEvictor(roomEvictor),
|
||
userservice.WithIDGenerator(idgen.NewInt64Generator(cfg.IDGenerator.NodeID)),
|
||
userservice.WithDisplayUserIDPolicy(cfg.DisplayUserID.AllocateMaxAttempts, time.Duration(cfg.DisplayUserID.ChangeCooldownSec)*time.Second),
|
||
)
|
||
hostSvc := hostservice.New(hostRepo,
|
||
hostservice.WithIDGenerator(idgen.NewInt64Generator(cfg.IDGenerator.NodeID)),
|
||
)
|
||
inviteSvc := inviteservice.New(inviteRepo)
|
||
micTimeSvc := mictimeservice.New(micTimeRepo)
|
||
cpSvc := cpservice.New(cpRepo,
|
||
// CP service 同时承担送礼事件消费和榜单读模型刷新;Redis/wallet 依赖只在榜单相关方法里被强校验。
|
||
cpservice.WithIntimacyLeaderboardStore(cpLeaderboardStore),
|
||
cpservice.WithAvatarFrameReader(avatarFrameReader),
|
||
)
|
||
mqConsumers, err := newMQConsumers(cfg, inviteSvc, micTimeSvc, cpSvc)
|
||
if err != nil {
|
||
if loginRiskRedisClose != nil {
|
||
_ = loginRiskRedisClose()
|
||
}
|
||
if cpLeaderboardRedisClose != nil {
|
||
_ = cpLeaderboardRedisClose()
|
||
}
|
||
if roomConn != nil {
|
||
_ = roomConn.Close()
|
||
}
|
||
if activityConn != nil {
|
||
_ = activityConn.Close()
|
||
}
|
||
if walletConn != nil {
|
||
_ = walletConn.Close()
|
||
}
|
||
_ = listener.Close()
|
||
_ = mysqlRepo.Close()
|
||
return nil, err
|
||
}
|
||
var userOutboxProducer *rocketmqx.Producer
|
||
if cfg.RocketMQ.UserOutbox.Enabled {
|
||
userOutboxProducer, err = rocketmqx.NewProducer(userOutboxProducerConfig(cfg.RocketMQ))
|
||
if err != nil {
|
||
shutdownMQConsumers(mqConsumers)
|
||
if loginRiskRedisClose != nil {
|
||
_ = loginRiskRedisClose()
|
||
}
|
||
if cpLeaderboardRedisClose != nil {
|
||
_ = cpLeaderboardRedisClose()
|
||
}
|
||
if roomConn != nil {
|
||
_ = roomConn.Close()
|
||
}
|
||
if activityConn != nil {
|
||
_ = activityConn.Close()
|
||
}
|
||
if walletConn != nil {
|
||
_ = walletConn.Close()
|
||
}
|
||
_ = listener.Close()
|
||
_ = mysqlRepo.Close()
|
||
return nil, err
|
||
}
|
||
}
|
||
userServer := grpcserver.NewServer(authSvc, userSvc, hostSvc)
|
||
userServer.SetMicTimeService(micTimeSvc)
|
||
userServer.SetCPService(cpSvc)
|
||
// 多个 protobuf service 共用一个 Server 适配器,领域逻辑仍拆在 auth/user/host service。
|
||
userv1.RegisterAuthServiceServer(server, userServer)
|
||
userv1.RegisterUserServiceServer(server, userServer)
|
||
userv1.RegisterUserSocialServiceServer(server, userServer)
|
||
userv1.RegisterUserDeviceServiceServer(server, userServer)
|
||
userv1.RegisterAppRegistryServiceServer(server, userServer)
|
||
userv1.RegisterUserIdentityServiceServer(server, userServer)
|
||
userv1.RegisterUserPrettyDisplayIDAdminServiceServer(server, userServer)
|
||
userv1.RegisterCountryAdminServiceServer(server, userServer)
|
||
userv1.RegisterCountryQueryServiceServer(server, userServer)
|
||
userv1.RegisterRegionAdminServiceServer(server, userServer)
|
||
userv1.RegisterUserHostServiceServer(server, userServer)
|
||
userv1.RegisterUserHostAdminServiceServer(server, userServer)
|
||
userv1.RegisterUserCronServiceServer(server, userServer)
|
||
userv1.RegisterUserCPServiceServer(server, userServer)
|
||
userv1.RegisterUserCPInternalServiceServer(server, userServer)
|
||
|
||
healthDependencies := []grpchealth.Dependency{{
|
||
Name: "mysql",
|
||
Check: mysqlRepo.Ping,
|
||
}}
|
||
health := grpchealth.NewServingChecker("user-service", healthDependencies...)
|
||
// user-service 目前使用简单 serving checker,存储探测由 servicekit 统一注册。
|
||
healthHTTP, err := servicehealth.Register(server, health, cfg.HealthHTTPAddr, cfg.NodeID)
|
||
if err != nil {
|
||
shutdownMQConsumers(mqConsumers)
|
||
if loginRiskRedisClose != nil {
|
||
_ = loginRiskRedisClose()
|
||
}
|
||
if cpLeaderboardRedisClose != nil {
|
||
_ = cpLeaderboardRedisClose()
|
||
}
|
||
if roomConn != nil {
|
||
_ = roomConn.Close()
|
||
}
|
||
if activityConn != nil {
|
||
_ = activityConn.Close()
|
||
}
|
||
if walletConn != nil {
|
||
_ = walletConn.Close()
|
||
}
|
||
_ = listener.Close()
|
||
_ = mysqlRepo.Close()
|
||
return nil, err
|
||
}
|
||
return &App{
|
||
server: server,
|
||
listener: listener,
|
||
health: health,
|
||
healthHTTP: healthHTTP,
|
||
mysqlRepo: mysqlRepo,
|
||
mqConsumers: mqConsumers,
|
||
userOutboxProducer: userOutboxProducer,
|
||
roomConn: roomConn,
|
||
activityConn: activityConn,
|
||
walletConn: walletConn,
|
||
authSvc: authSvc,
|
||
userSvc: userSvc,
|
||
inviteSvc: inviteSvc,
|
||
micTimeSvc: micTimeSvc,
|
||
cpSvc: cpSvc,
|
||
loginRiskRedisClose: loginRiskRedisClose,
|
||
cpLeaderboardRedisClose: cpLeaderboardRedisClose,
|
||
cfg: cfg,
|
||
workers: serviceapp.NewBackground(context.Background()),
|
||
}, nil
|
||
}
|
||
|
||
// Run 启动 gRPC 服务。
|
||
func (a *App) Run() error {
|
||
a.runHealthHTTP()
|
||
if err := startMQConsumers(a.mqConsumers); err != nil {
|
||
a.health.MarkStopped()
|
||
return err
|
||
}
|
||
if err := a.startUserOutboxProducer(); err != nil {
|
||
a.health.MarkStopped()
|
||
return err
|
||
}
|
||
a.runUserOutboxWorker()
|
||
// 只有 listener 进入 Serve 后才标记 serving,避免健康检查提前放行。
|
||
a.health.MarkServing()
|
||
defer func() {
|
||
a.workers.StopAndWait()
|
||
shutdownMQConsumers(a.mqConsumers)
|
||
a.shutdownUserOutboxProducer()
|
||
}()
|
||
err := a.server.Serve(a.listener)
|
||
a.health.MarkStopped()
|
||
if errors.Is(err, grpc.ErrServerStopped) {
|
||
// GracefulStop 触发的退出不是故障。
|
||
return nil
|
||
}
|
||
|
||
return err
|
||
}
|
||
|
||
// Close 关闭 gRPC 服务和底层持久化连接。
|
||
func (a *App) Close() {
|
||
a.closeOnce.Do(func() {
|
||
// draining 会让 health 立即失败,避免下线进程继续接新 RPC。
|
||
a.health.MarkDraining()
|
||
a.workers.StopAndWait()
|
||
shutdownMQConsumers(a.mqConsumers)
|
||
a.shutdownUserOutboxProducer()
|
||
// GracefulStop 等待已进入的 RPC 结束,避免 token/session 写入被硬切。
|
||
servicegrpc.GracefulStop(a.server, 15*time.Second)
|
||
a.closeHealthHTTP()
|
||
if a.mysqlRepo != nil {
|
||
// MySQL 连接池最后关闭,保证 GracefulStop 期间 repository 仍可用。
|
||
_ = a.mysqlRepo.Close()
|
||
}
|
||
if a.roomConn != nil {
|
||
_ = a.roomConn.Close()
|
||
}
|
||
if a.activityConn != nil {
|
||
_ = a.activityConn.Close()
|
||
}
|
||
if a.walletConn != nil {
|
||
_ = a.walletConn.Close()
|
||
}
|
||
if a.loginRiskRedisClose != nil {
|
||
_ = a.loginRiskRedisClose()
|
||
}
|
||
if a.cpLeaderboardRedisClose != nil {
|
||
_ = a.cpLeaderboardRedisClose()
|
||
}
|
||
})
|
||
}
|
||
|
||
func (a *App) runUserOutboxWorker() {
|
||
if !a.cfg.OutboxWorker.Enabled || a.userOutboxProducer == nil || a.mysqlRepo == nil {
|
||
return
|
||
}
|
||
workerID := "user-outbox-" + a.cfg.NodeID
|
||
a.workers.Go(func(ctx context.Context) {
|
||
ticker := time.NewTicker(a.cfg.OutboxWorker.PollInterval)
|
||
defer ticker.Stop()
|
||
for {
|
||
processed, err := a.processUserOutboxBatch(workerID)
|
||
if err != nil && !errors.Is(err, context.Canceled) {
|
||
logx.Error(ctx, "user_outbox_publish_failed", err, slog.String("worker_id", workerID))
|
||
}
|
||
if processed >= a.cfg.OutboxWorker.BatchSize {
|
||
continue
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
func (a *App) processUserOutboxBatch(workerID string) (int, error) {
|
||
records, err := a.mysqlRepo.ClaimPendingUserOutbox(a.workers.Context(), workerID, a.cfg.OutboxWorker.BatchSize)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
for _, record := range records {
|
||
publishCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), a.cfg.OutboxWorker.PublishTimeout)
|
||
err := a.publishUserOutboxRecord(publishCtx, record)
|
||
cancel()
|
||
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), a.cfg.OutboxWorker.PublishTimeout)
|
||
if err != nil {
|
||
nextRetryAtMS := time.Now().UTC().Add(a.cfg.OutboxWorker.PollInterval).UnixMilli()
|
||
markErr := a.mysqlRepo.MarkUserOutboxRetryable(markCtx, record.EventID, err.Error(), nextRetryAtMS)
|
||
markCancel()
|
||
if markErr != nil {
|
||
return 0, markErr
|
||
}
|
||
return len(records), err
|
||
}
|
||
markErr := a.mysqlRepo.MarkUserOutboxDelivered(markCtx, record.EventID)
|
||
markCancel()
|
||
if markErr != nil {
|
||
return 0, markErr
|
||
}
|
||
}
|
||
return len(records), nil
|
||
}
|
||
|
||
func (a *App) publishUserOutboxRecord(ctx context.Context, record mysqlstorage.UserOutboxRecord) error {
|
||
body, err := usermq.EncodeUserOutboxMessage(usermq.UserOutboxMessage{
|
||
AppCode: record.AppCode,
|
||
EventID: record.EventID,
|
||
EventType: record.EventType,
|
||
AggregateType: record.AggregateType,
|
||
AggregateID: record.AggregateID,
|
||
PayloadJSON: record.PayloadJSON,
|
||
OccurredAtMS: record.CreatedAtMS,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return a.userOutboxProducer.SendSync(ctx, rocketmqx.Message{
|
||
Topic: a.cfg.RocketMQ.UserOutbox.Topic,
|
||
Tag: usermq.TagUserOutboxEvent,
|
||
Keys: []string{record.EventID, record.AggregateType},
|
||
Body: body,
|
||
})
|
||
}
|
||
|
||
func (a *App) startUserOutboxProducer() error {
|
||
if a.userOutboxProducer == nil {
|
||
return nil
|
||
}
|
||
return servicemq.StartProducers([]*rocketmqx.Producer{a.userOutboxProducer})
|
||
}
|
||
|
||
func (a *App) shutdownUserOutboxProducer() {
|
||
if a.userOutboxProducer == nil {
|
||
return
|
||
}
|
||
servicemq.ShutdownProducers([]*rocketmqx.Producer{a.userOutboxProducer})
|
||
}
|
||
|
||
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 firebaseVerifierProjects(cfg config.FirebaseConfig) map[string]authservice.FirebaseVerifierConfig {
|
||
projects := make(map[string]authservice.FirebaseVerifierConfig, len(cfg.Projects))
|
||
for appCode, project := range cfg.Projects {
|
||
// app_code 已在配置加载阶段归一化;这里仍保留 Normalize,避免测试或手动构造配置绕过 Load。
|
||
projects[appcode.Normalize(appCode)] = authservice.FirebaseVerifierConfig{
|
||
ProjectID: project.ProjectID,
|
||
AllowedSignInProviders: project.AllowedSignInProviders,
|
||
CertsURL: project.CertsURL,
|
||
}
|
||
}
|
||
return projects
|
||
}
|
||
|
||
type tencentIMAccountImporter struct {
|
||
client *tencentim.RESTClient
|
||
}
|
||
|
||
func (i tencentIMAccountImporter) ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error {
|
||
return i.client.ImportAccount(ctx, tencentim.AccountProfile{
|
||
UserID: userID,
|
||
Nick: nickname,
|
||
FaceURL: faceURL,
|
||
})
|
||
}
|
||
|
||
func newMQConsumers(cfg config.Config, inviteSvc *inviteservice.Service, micTimeSvc *mictimeservice.Service, cpSvc *cpservice.Service) ([]*rocketmqx.Consumer, error) {
|
||
consumers := make([]*rocketmqx.Consumer, 0, 3)
|
||
if cfg.InviteRechargeWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
|
||
consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||
event, ok, err := inviteRechargeEventFromWalletMessage(message.Body)
|
||
if err != nil || !ok {
|
||
return err
|
||
}
|
||
_, err = inviteSvc.ConsumeWalletRechargeEvent(appcode.WithContext(ctx, event.AppCode), event)
|
||
return err
|
||
}); err != nil {
|
||
_ = consumer.Shutdown()
|
||
return nil, err
|
||
}
|
||
consumers = append(consumers, consumer)
|
||
}
|
||
if cfg.MicTimeWorker.Enabled && cfg.RocketMQ.RoomOutbox.Enabled {
|
||
consumer, err := rocketmqx.NewConsumer(roomOutboxConsumerConfig(cfg.RocketMQ))
|
||
if err != nil {
|
||
shutdownMQConsumers(consumers)
|
||
return nil, err
|
||
}
|
||
if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||
event, ok, err := roomMicEventFromRoomMessage(message.Body)
|
||
if err != nil || !ok {
|
||
return err
|
||
}
|
||
_, err = micTimeSvc.ConsumeRoomMicEvent(appcode.WithContext(ctx, event.AppCode), event)
|
||
return err
|
||
}); err != nil {
|
||
_ = consumer.Shutdown()
|
||
shutdownMQConsumers(consumers)
|
||
return nil, err
|
||
}
|
||
consumers = append(consumers, consumer)
|
||
}
|
||
if cfg.CPRelationshipWorker.Enabled && cfg.RocketMQ.RoomOutbox.Enabled {
|
||
// CP 关系消费使用独立 consumer group,保证和麦位时长读模型各自拥有 MQ 位点和重试节奏。
|
||
consumer, err := rocketmqx.NewConsumer(cpRoomOutboxConsumerConfig(cfg))
|
||
if err != nil {
|
||
shutdownMQConsumers(consumers)
|
||
return nil, err
|
||
}
|
||
if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||
event, ok, err := roomGiftCPEventFromRoomMessage(message.Body)
|
||
if err != nil || !ok {
|
||
return err
|
||
}
|
||
_, err = cpSvc.ConsumeGiftEvent(appcode.WithContext(ctx, event.AppCode), event)
|
||
return err
|
||
}); err != nil {
|
||
_ = consumer.Shutdown()
|
||
shutdownMQConsumers(consumers)
|
||
return nil, err
|
||
}
|
||
consumers = append(consumers, consumer)
|
||
}
|
||
return consumers, nil
|
||
}
|
||
|
||
func inviteRechargeEventFromWalletMessage(body []byte) (invitedomain.RechargeEvent, bool, error) {
|
||
message, err := walletmq.DecodeWalletOutboxMessage(body)
|
||
if err != nil {
|
||
return invitedomain.RechargeEvent{}, false, err
|
||
}
|
||
if message.EventType != invitedomain.RechargeEventWalletRecorded {
|
||
return invitedomain.RechargeEvent{}, false, nil
|
||
}
|
||
return invitedomain.RechargeEvent{
|
||
AppCode: appcode.Normalize(message.AppCode),
|
||
EventID: message.EventID,
|
||
EventType: message.EventType,
|
||
TransactionID: message.TransactionID,
|
||
CommandID: message.CommandID,
|
||
InvitedUserID: message.UserID,
|
||
AssetType: message.AssetType,
|
||
RechargeCoinAmount: message.AvailableDelta,
|
||
RechargeType: rechargeTypeFromWalletPayload(message.PayloadJSON),
|
||
OccurredAtMs: message.OccurredAtMS,
|
||
PayloadJSON: message.PayloadJSON,
|
||
}, true, nil
|
||
}
|
||
|
||
func roomMicEventFromRoomMessage(body []byte) (mictimedomain.RoomMicEvent, bool, error) {
|
||
envelope, _, err := roommq.DecodeRoomOutboxMessage(body)
|
||
if err != nil {
|
||
return mictimedomain.RoomMicEvent{}, false, err
|
||
}
|
||
if envelope.GetEventType() != mictimedomain.RoomMicChangedEventType {
|
||
return mictimedomain.RoomMicEvent{}, false, nil
|
||
}
|
||
var changed roomeventsv1.RoomMicChanged
|
||
if err := proto.Unmarshal(envelope.GetBody(), &changed); err != nil {
|
||
return mictimedomain.RoomMicEvent{}, false, err
|
||
}
|
||
return mictimedomain.RoomMicEvent{
|
||
AppCode: appcode.Normalize(envelope.GetAppCode()),
|
||
EventID: envelope.GetEventId(),
|
||
EventType: envelope.GetEventType(),
|
||
RoomID: envelope.GetRoomId(),
|
||
RoomVersion: envelope.GetRoomVersion(),
|
||
OutboxCreatedAtMs: envelope.GetOccurredAtMs(),
|
||
OccurredAtMs: envelope.GetOccurredAtMs(),
|
||
ActorUserID: changed.GetActorUserId(),
|
||
TargetUserID: changed.GetTargetUserId(),
|
||
FromSeat: changed.GetFromSeat(),
|
||
ToSeat: changed.GetToSeat(),
|
||
Action: changed.GetAction(),
|
||
MicSessionID: changed.GetMicSessionId(),
|
||
PublishState: changed.GetPublishState(),
|
||
Reason: changed.GetReason(),
|
||
PublishDeadlineMs: changed.GetPublishDeadlineMs(),
|
||
PublishEventTimeMs: changed.GetPublishEventTimeMs(),
|
||
}, true, nil
|
||
}
|
||
|
||
func roomGiftCPEventFromRoomMessage(body []byte) (cpdomain.GiftEvent, bool, error) {
|
||
envelope, _, err := roommq.DecodeRoomOutboxMessage(body)
|
||
if err != nil {
|
||
return cpdomain.GiftEvent{}, false, err
|
||
}
|
||
if envelope.GetEventType() != "RoomGiftSent" {
|
||
return cpdomain.GiftEvent{}, false, nil
|
||
}
|
||
var sent roomeventsv1.RoomGiftSent
|
||
if err := proto.Unmarshal(envelope.GetBody(), &sent); err != nil {
|
||
return cpdomain.GiftEvent{}, false, err
|
||
}
|
||
// CP worker 必须接收所有送礼事实:没有 active 关系时仓储层只让 CP/兄弟/姐妹礼物生成申请;
|
||
// 已有 active 关系时,普通、幸运和超级幸运礼物都按 wallet 已结算的 GiftValue 增加亲密值。
|
||
return cpdomain.GiftEvent{
|
||
AppCode: appcode.Normalize(envelope.GetAppCode()),
|
||
EventID: envelope.GetEventId(),
|
||
RoomID: envelope.GetRoomId(),
|
||
RoomVersion: envelope.GetRoomVersion(),
|
||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||
SenderUserID: sent.GetSenderUserId(),
|
||
TargetUserID: sent.GetTargetUserId(),
|
||
GiftID: sent.GetGiftId(),
|
||
GiftCount: sent.GetGiftCount(),
|
||
GiftValue: sent.GetGiftValue(),
|
||
BillingReceiptID: sent.GetBillingReceiptId(),
|
||
VisibleRegionID: sent.GetVisibleRegionId(),
|
||
CommandID: sent.GetCommandId(),
|
||
GiftTypeCode: sent.GetGiftTypeCode(),
|
||
CPRelationType: sent.GetCpRelationType(),
|
||
GiftName: sent.GetGiftName(),
|
||
GiftIconURL: sent.GetGiftIconUrl(),
|
||
GiftAnimationURL: sent.GetGiftAnimationUrl(),
|
||
}, true, nil
|
||
}
|
||
|
||
func rechargeTypeFromWalletPayload(payload string) string {
|
||
var decoded map[string]any
|
||
if err := json.Unmarshal([]byte(payload), &decoded); err == nil {
|
||
for _, key := range []string{"recharge_type", "provider", "channel"} {
|
||
if value, ok := decoded[key].(string); ok && strings.TrimSpace(value) != "" {
|
||
return normalizeInviteRechargeType(value)
|
||
}
|
||
}
|
||
}
|
||
return invitedomain.RechargeTypeCoinSellerTransfer
|
||
}
|
||
|
||
func normalizeInviteRechargeType(value string) string {
|
||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||
case "coin_seller", "coin_seller_transfer":
|
||
return invitedomain.RechargeTypeCoinSellerTransfer
|
||
case "google", "google_play", "googleplay":
|
||
return invitedomain.RechargeTypeGooglePlay
|
||
case "mifapay", "mifa_pay":
|
||
return invitedomain.RechargeTypeMifaPay
|
||
case "v5pay", "v5_pay":
|
||
return invitedomain.RechargeTypeV5Pay
|
||
case "usdt", "usdt_trc20", "trc20":
|
||
return invitedomain.RechargeTypeUSDTTRC20
|
||
default:
|
||
return strings.ToLower(strings.TrimSpace(value))
|
||
}
|
||
}
|
||
|
||
func startMQConsumers(consumers []*rocketmqx.Consumer) error {
|
||
return servicemq.StartConsumers(consumers)
|
||
}
|
||
|
||
func shutdownMQConsumers(consumers []*rocketmqx.Consumer) {
|
||
servicemq.ShutdownConsumers(consumers)
|
||
}
|
||
|
||
func walletOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
|
||
return rocketmqConsumerConfig(cfg, cfg.WalletOutbox.ConsumerGroup, cfg.WalletOutbox.ConsumerMaxReconsumeTimes)
|
||
}
|
||
|
||
func roomOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
|
||
return rocketmqConsumerConfig(cfg, cfg.RoomOutbox.ConsumerGroup, cfg.RoomOutbox.ConsumerMaxReconsumeTimes)
|
||
}
|
||
|
||
func cpRoomOutboxConsumerConfig(cfg config.Config) rocketmqx.ConsumerConfig {
|
||
return rocketmqConsumerConfig(cfg.RocketMQ, cfg.CPRelationshipWorker.ConsumerGroup, cfg.RocketMQ.RoomOutbox.ConsumerMaxReconsumeTimes)
|
||
}
|
||
|
||
func userOutboxProducerConfig(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.UserOutbox.ProducerGroup,
|
||
SendTimeout: 3 * time.Second,
|
||
Retry: 2,
|
||
}
|
||
}
|
||
|
||
func rocketmqConsumerConfig(cfg config.RocketMQConfig, group string, maxReconsume 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: maxReconsume,
|
||
ConsumeRetryDelay: time.Second,
|
||
ConsumePullBatch: 32,
|
||
ConsumePullTimeout: 15 * time.Minute,
|
||
}
|
||
}
|