// Package app 装配 user-service 的 gRPC 入口、领域服务、repository 和健康检查。 package app import ( "context" "database/sql" "errors" "net" "sync" "time" userv1 "hyapp.local/api/proto/user/v1" "hyapp/pkg/grpchealth" "hyapp/pkg/idgen" "hyapp/pkg/logx" "hyapp/services/user-service/internal/config" authservice "hyapp/services/user-service/internal/service/auth" 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" invitestorage "hyapp/services/user-service/internal/storage/mysql/invite" mictimestorage "hyapp/services/user-service/internal/storage/mysql/mictime" grpcserver "hyapp/services/user-service/internal/transport/grpc" "google.golang.org/grpc" healthgrpc "google.golang.org/grpc/health/grpc_health_v1" ) // 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 // mysqlRepo 持有用户主数据、认证身份、session 和短号事务的唯一运行时存储。 mysqlRepo *mysqlstorage.Repository // walletDB 只用于读取 wallet_outbox 充值事实,实际账务状态仍由 wallet-service owner。 walletDB *sql.DB // roomDB 只用于读取 room_outbox 麦位事实,Room Cell 状态仍由 room-service owner。 roomDB *sql.DB // 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 // loginRiskRedisClose 关闭登录风险 Redis cache;cache 不作为启动硬依赖。 loginRiskRedisClose func() error // cfg 保存 worker 运行参数,避免 Run 阶段重新读配置。 cfg config.Config // workerCtx 统一控制后台 worker 生命周期。 workerCtx context.Context // workerCancel 停止 invite recharge 和 room mic outbox worker。 workerCancel context.CancelFunc // workerWG 等待后台 worker 当前批次安全退出。 workerWG sync.WaitGroup // 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 := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("user-service"))) thirdPartyVerifier := authservice.NewRoutedThirdPartyVerifier( authservice.NewFirebaseThirdPartyVerifier(authservice.FirebaseVerifierConfig{ ProjectID: cfg.ThirdParty.Firebase.ProjectID, AllowedSignInProviders: cfg.ThirdParty.Firebase.AllowedSignInProviders, CertsURL: cfg.ThirdParty.Firebase.CertsURL, }), 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() var ipDecisionCache authservice.IPDecisionCache 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 { ipDecisionCache = authservice.NewRedisIPDecisionCache(redisClient) loginRiskRedisClose = redisClient.Close } else { // IP 风控 Redis 缓存按文档 fail-open;启动继续,worker 仍会写 MySQL 审计。 logx.Warn(startupCtx, "login_risk_redis_unavailable") } } // 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.WithIPGeoProviders(authservice.BuildIPGeoProviders(cfg.LoginRisk.Providers)), ) // user service 负责用户主状态和 display_user_id/靓号用例,不进入房间高频流程。 userSvc := userservice.New(userRepo, userservice.WithAppRegistryRepository(appRepo), userservice.WithIdentityRepository(identityRepo), userservice.WithCountryRegionRepository(regionRepo), userservice.WithCountryAdminRepository(regionRepo), userservice.WithRegionAdminRepository(regionRepo), userservice.WithRegionRebuildRepository(regionRepo), userservice.WithDeviceRepository(deviceRepo), 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)), ) var walletDB *sql.DB var inviteSvc *inviteservice.Service if cfg.InviteRechargeWorker.Enabled { walletDB, err = sql.Open("mysql", cfg.InviteRechargeWorker.WalletMySQLDSN) if err != nil { if loginRiskRedisClose != nil { _ = loginRiskRedisClose() } _ = listener.Close() _ = mysqlRepo.Close() return nil, err } if err := walletDB.PingContext(startupCtx); err != nil { _ = walletDB.Close() if loginRiskRedisClose != nil { _ = loginRiskRedisClose() } _ = listener.Close() _ = mysqlRepo.Close() return nil, err } inviteSvc = inviteservice.New(inviteRepo, invitestorage.NewWalletOutboxReader(walletDB)) } var roomDB *sql.DB micTimeSvc := mictimeservice.New(micTimeRepo, nil) if cfg.MicTimeWorker.Enabled { roomDB, err = sql.Open("mysql", cfg.MicTimeWorker.RoomMySQLDSN) if err != nil { if walletDB != nil { _ = walletDB.Close() } if loginRiskRedisClose != nil { _ = loginRiskRedisClose() } _ = listener.Close() _ = mysqlRepo.Close() return nil, err } if err := roomDB.PingContext(startupCtx); err != nil { _ = roomDB.Close() if walletDB != nil { _ = walletDB.Close() } if loginRiskRedisClose != nil { _ = loginRiskRedisClose() } _ = listener.Close() _ = mysqlRepo.Close() return nil, err } micTimeSvc = mictimeservice.New(micTimeRepo, mictimestorage.NewRoomOutboxReader(roomDB)) } userServer := grpcserver.NewServer(authSvc, userSvc, hostSvc) userServer.SetMicTimeService(micTimeSvc) // 多个 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.RegisterCountryAdminServiceServer(server, userServer) userv1.RegisterCountryQueryServiceServer(server, userServer) userv1.RegisterRegionAdminServiceServer(server, userServer) userv1.RegisterUserHostServiceServer(server, userServer) userv1.RegisterUserHostAdminServiceServer(server, userServer) userv1.RegisterUserCronServiceServer(server, userServer) health := grpchealth.NewServingChecker("user-service") // user-service 目前使用简单 serving checker,存储探测由后续专用 health 可扩展。 healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health)) workerCtx, workerCancel := context.WithCancel(context.Background()) return &App{ server: server, listener: listener, health: health, mysqlRepo: mysqlRepo, walletDB: walletDB, roomDB: roomDB, authSvc: authSvc, userSvc: userSvc, inviteSvc: inviteSvc, micTimeSvc: micTimeSvc, loginRiskRedisClose: loginRiskRedisClose, cfg: cfg, workerCtx: workerCtx, workerCancel: workerCancel, }, nil } // Run 启动 gRPC 服务。 func (a *App) Run() error { // 只有 listener 进入 Serve 后才标记 serving,避免健康检查提前放行。 a.health.MarkServing() defer func() { if a.workerCancel != nil { a.workerCancel() } a.workerWG.Wait() }() if a.cfg.InviteRechargeWorker.Enabled && a.inviteSvc != nil { a.workerWG.Go(func() { a.inviteSvc.RunWalletRechargeWorker(a.workerCtx, inviteservice.WorkerOptions{ WorkerID: a.cfg.NodeID, PollInterval: time.Duration(a.cfg.InviteRechargeWorker.PollIntervalSec) * time.Second, BatchSize: a.cfg.InviteRechargeWorker.BatchSize, }) }) } if a.cfg.MicTimeWorker.Enabled && a.micTimeSvc != nil { a.workerWG.Go(func() { a.micTimeSvc.RunRoomMicWorker(a.workerCtx, mictimeservice.WorkerOptions{ WorkerID: a.cfg.NodeID, PollInterval: time.Duration(a.cfg.MicTimeWorker.PollIntervalSec) * time.Second, BatchSize: a.cfg.MicTimeWorker.BatchSize, }) }) } 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() if a.workerCancel != nil { a.workerCancel() } a.workerWG.Wait() // GracefulStop 等待已进入的 RPC 结束,避免 token/session 写入被硬切。 a.server.GracefulStop() if a.mysqlRepo != nil { // MySQL 连接池最后关闭,保证 GracefulStop 期间 repository 仍可用。 _ = a.mysqlRepo.Close() } if a.walletDB != nil { _ = a.walletDB.Close() } if a.roomDB != nil { _ = a.roomDB.Close() } if a.loginRiskRedisClose != nil { _ = a.loginRiskRedisClose() } }) }