2026-05-12 09:53:20 +08:00

316 lines
12 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 装配 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 cachecache 不作为启动硬依赖。
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()
}
})
}