// 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, } }