316 lines
12 KiB
Go
316 lines
12 KiB
Go
// 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()
|
||
}
|
||
})
|
||
}
|