// 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/outboxpartition"
	"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/pkg/xerr"
	"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"
	roomtimedomain "hyapp/services/user-service/internal/domain/roomtime"
	"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"
	roomtimeservice "hyapp/services/user-service/internal/service/roomtime"
	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
	// userOutboxPartitions 缓存真实 App 列表并提供跨 worker 的公平轮转游标。
	userOutboxPartitions *outboxpartition.Partitions
	// 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
	// roomTimeSvc 消费 room presence outbox，维护用户维度在房时长 read model。
	roomTimeSvc *roomtimeservice.Service
	// cpSvc 消费 room gift outbox，维护 CP/兄弟/姐妹申请和亲密值。
	cpSvc *cpservice.Service
	// loginRiskRedisClose 关闭登录风险/session denylist Redis；denylist 是启动硬依赖。
	loginRiskRedisClose func() error
	// sessionDenylistEpoch 记录完成全量重水合时的 Redis run_id，变化时必须再次重放。
	sessionDenylistEpoch string
	// 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
	}
	if cfg.MySQLAutoMigrate {
		// 在线迁移必须在创建 gRPC listener 前完成；否则实例可能先通过端口探测，再因 login_audit
		// 缺列导致“登录成功但审计丢失”。迁移失败时直接关闭连接池并阻止实例接流量。
		if err := mysqlRepo.Migrate(startupCtx); err != nil {
			_ = mysqlRepo.Close()
			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()
	roomTimeRepo := mysqlRepo.RoomTimeRepository()
	cpRepo := mysqlRepo.CPRepository()
	var ipDecisionCache authservice.IPDecisionCache
	var registerRiskConfigCache authservice.RegisterRiskConfigCache
	var loginRiskRedisClose func() error
	if cfg.LoginRisk.RedisAddr == "" {
		_ = listener.Close()
		_ = mysqlRepo.Close()
		return nil, xerr.New(xerr.Unavailable, "session denylist Redis address is required")
	}
	// IP 风控可关闭，但 session denylist 已是 refresh/logout 的认证安全依赖，必须独立建连并 fail-fast。
	// 启动 Ping 失败时不对外 healthy；否则 Redis 恢复后本进程也没有 client/worker，只会让全量 refresh 永久 UNAVAILABLE。
	redisClient, redisErr := authservice.NewLoginRiskRedisClient(startupCtx, cfg.LoginRisk.RedisAddr, cfg.LoginRisk.RedisPassword, cfg.LoginRisk.RedisDB)
	if redisErr != nil {
		_ = listener.Close()
		_ = mysqlRepo.Close()
		return nil, redisErr
	}
	riskCache := authservice.NewRedisIPDecisionCache(redisClient)
	ipDecisionCache = riskCache
	registerRiskConfigCache = riskCache
	loginRiskRedisClose = redisClient.Close
	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
	var imHistoryClient hostservice.PrivateMessageHistoryClient
	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}
		imHistoryClient = 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
	var privacyBenefitChecker userservice.PrivacyBenefitChecker
	var vipBenefitDecisionChecker userservice.VIPBenefitDecisionChecker
	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
		}
		// 同一个轻量 gRPC adapter 分别以最小接口注入 CP 头像框读模型和 user 隐私门禁，
		// 两个领域都不会依赖完整 wallet protobuf client 或复制钱包规则。
		walletClient := walletclient.NewGRPC(walletConn)
		avatarFrameReader = walletClient
		privacyBenefitChecker = walletClient
		vipBenefitDecisionChecker = walletClient
	}

	// auth service 负责登录、session 和 token；用户主数据和短号事务仍通过各自领域存储完成。
	authSvc := authservice.New(authservice.Config{
		Issuer:                  cfg.JWT.Issuer,
		AccessTokenTTLSec:       cfg.JWT.AccessTokenTTLSec,
		RefreshTokenTTLSec:      cfg.JWT.RefreshTokenTTLSec,
		RefreshRotationGraceSec: cfg.JWT.RefreshRotationGraceSec,
		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),
	)
	hostSvc := hostservice.New(hostRepo,
		hostservice.WithIDGenerator(idgen.NewInt64Generator(cfg.IDGenerator.NodeID)),
		// 私聊人数来自腾讯每小时离线归档；该依赖只暴露给 cron RPC，不进入 IM 发消息链路。
		hostservice.WithPrivateMessageHistorySource(cfg.TencentIM.SDKAppID, imHistoryClient),
	)
	// 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.WithRoleScopePolicyResolver(hostSvc),
		userservice.WithPrivacyBenefitChecker(privacyBenefitChecker),
		userservice.WithVIPBenefitDecisionChecker(vipBenefitDecisionChecker),
		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),
	)
	inviteSvc := inviteservice.New(inviteRepo)
	micTimeSvc := mictimeservice.New(micTimeRepo)
	roomTimeSvc := roomtimeservice.New(roomTimeRepo)
	cpSvc := cpservice.New(cpRepo,
		// CP service 同时承担送礼事件消费和榜单读模型刷新；Redis/wallet 依赖只在榜单相关方法里被强校验。
		cpservice.WithIntimacyLeaderboardStore(cpLeaderboardStore),
		cpservice.WithAvatarFrameReader(avatarFrameReader),
	)
	mqConsumers, err := newMQConsumers(cfg, inviteSvc, micTimeSvc, roomTimeSvc, 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.SetRoomTimeService(roomTimeSvc)
	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,
		userOutboxPartitions:    outboxpartition.New(cfg.OutboxWorker.AppCodes, outboxpartition.DefaultDiscoveryRefreshInterval),
		roomConn:                roomConn,
		activityConn:            activityConn,
		walletConn:              walletConn,
		authSvc:                 authSvc,
		userSvc:                 userSvc,
		inviteSvc:               inviteSvc,
		micTimeSvc:              micTimeSvc,
		roomTimeSvc:             roomTimeSvc,
		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
	}
	defer func() {
		a.workers.StopAndWait()
		shutdownMQConsumers(a.mqConsumers)
		a.shutdownUserOutboxProducer()
	}()
	if err := a.rehydrateSessionDenylist(context.Background()); err != nil {
		// denylist 是 access JWT 撤销的安全事实；Redis/重水合失败时不能先对外 healthy 再放行旧 token。
		a.health.MarkStopped()
		return err
	}
	a.runUserOutboxWorker()
	a.runSessionDenylistWorker()
	// 只有 listener 进入 Serve 后才标记 serving，避免健康检查提前放行。
	a.health.MarkServing()
	err := a.server.Serve(a.listener)
	a.health.MarkStopped()
	if errors.Is(err, grpc.ErrServerStopped) {
		// GracefulStop 触发的退出不是故障。
		return nil
	}

	return err
}

func (a *App) rehydrateSessionDenylist(ctx context.Context) error {
	if a.authSvc == nil || a.loginRiskRedisClose == nil {
		return xerr.New(xerr.Unavailable, "session denylist Redis is not configured")
	}
	workerID := "auth-session-denylist-hydrate-" + a.cfg.NodeID
	for epochAttempt := 0; epochAttempt < 3; epochAttempt++ {
		epochBefore, err := a.authSvc.SessionDenylistEpoch(ctx)
		if err != nil {
			return err
		}
		// 每批只读 1000 行且 cursor 循环到固定 max job_id；hydration 不认领/锁 job，随后 drain 新增 pending 后健康检查才可放行。
		if _, err := a.authSvc.RehydrateAllSessionDenylistJobs(ctx, 1000); err != nil {
			return err
		}
		for {
			processed, err := a.authSvc.ProcessSessionDenylistBatch(ctx, workerID, 100)
			if err != nil {
				return err
			}
			if processed < 100 {
				break
			}
		}
		epochAfter, err := a.authSvc.SessionDenylistEpoch(ctx)
		if err != nil {
			return err
		}
		if epochBefore == epochAfter {
			a.sessionDenylistEpoch = epochAfter
			return nil
		}
	}
	return xerr.New(xerr.Unavailable, "session denylist Redis changed during hydration")
}

func (a *App) runSessionDenylistWorker() {
	if a.authSvc == nil || a.loginRiskRedisClose == nil {
		// 生产 New 已强制 Redis 连通并在启动 hydration 失败时退出；这里只防御不完整的测试/工具装配。
		return
	}
	workerID := "auth-session-denylist-" + a.cfg.NodeID
	a.workers.Go(func(ctx context.Context) {
		ticker := time.NewTicker(time.Second)
		defer ticker.Stop()
		for {
			processed, err := a.authSvc.ProcessSessionDenylistBatch(ctx, workerID, 100)
			if err != nil && !errors.Is(err, context.Canceled) {
				logx.Error(ctx, "auth_session_denylist_retry_failed", err, slog.String("worker_id", workerID))
			}
			if processed >= 100 {
				continue
			}
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
			}
		}
	})
	// Redis AOF/volume 覆盖正常重启；run_id 变化检测再覆盖独立重启/主从切换，且只有 epoch 变化才全量重水合，
	// 不用每小时无条件重写全部 sid。INFO 临时失败只告警并在下一轮重试。
	a.workers.Go(func(ctx context.Context) {
		lastEpoch := a.sessionDenylistEpoch
		ticker := time.NewTicker(30 * time.Second)
		defer ticker.Stop()
		for {
			epoch, err := a.authSvc.SessionDenylistEpoch(ctx)
			if err != nil {
				if !errors.Is(err, context.Canceled) {
					logx.Error(ctx, "auth_session_denylist_epoch_read_failed", err, slog.String("worker_id", workerID))
				}
			} else if lastEpoch == "" {
				lastEpoch = epoch
			} else if epoch != lastEpoch {
				if err := a.rehydrateSessionDenylist(ctx); err != nil {
					if !errors.Is(err, context.Canceled) {
						logx.Error(ctx, "auth_session_denylist_epoch_rehydrate_failed", err, slog.String("worker_id", workerID))
					}
				} else {
					// rehydrate 内部会在 Redis run_id 变化时重试到稳定 epoch；必须采用它最终确认的值，
					// 不能回写本轮调用前捕获的旧 epoch，否则下一轮会重复全量重水合。
					lastEpoch = a.sessionDenylistEpoch
				}
			}
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
			}
		}
	})
	// 已越过 deny_until 的补偿事实按小时小批清理；多实例各跑一次只会造成可接受的幂等索引扫描，
	// 不把清理塞进每秒轮询，避免空队列时持续制造无意义 MySQL DELETE QPS。
	a.workers.Go(func(ctx context.Context) {
		ticker := time.NewTicker(time.Hour)
		defer ticker.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				// 单轮最多 10x1000，批满继续追赶日增量但限制 IO 峰值；连续打满会留下可观测 backlog 告警。
				if saturated, err := a.cleanupAuthSessionPersistence(ctx, 1000, 10); err != nil && !errors.Is(err, context.Canceled) {
					logx.Error(ctx, "auth_session_persistence_cleanup_failed", err, slog.String("worker_id", workerID))
				} else if saturated {
					logx.Warn(ctx, "auth_session_persistence_cleanup_backlog", slog.String("worker_id", workerID))
				}
			}
		}
	})
}

func (a *App) cleanupAuthSessionPersistence(ctx context.Context, batchSize int, maxBatches int) (bool, error) {
	for batch := 0; batch < maxBatches; batch++ {
		deletedJobs, err := a.authSvc.CleanupExpiredSessionDenylistJobs(ctx, batchSize)
		if err != nil {
			return false, err
		}
		deletedOutcomes, err := a.authSvc.CleanupExpiredRefreshOutcomes(ctx, batchSize)
		if err != nil {
			return false, err
		}
		if deletedJobs < batchSize && deletedOutcomes < batchSize {
			return false, nil
		}
	}
	return true, nil
}

// 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) {
	ctx := a.workers.Context()
	appCodes, discoveryErr := a.userOutboxPartitions.Resolve(ctx, a.mysqlRepo.ListUserOutboxAppCodes)
	if discoveryErr != nil {
		if len(appCodes) == 0 {
			return 0, discoveryErr
		}
		// 发现刷新失败时沿用旧列表；缓存会在五分钟后再试，不阻断既有 App 的事实发布。
		logx.Warn(ctx, "user_outbox_app_discovery_stale", slog.String("worker_id", workerID), slog.String("error", discoveryErr.Error()))
	}
	records, err := outboxpartition.ClaimFair(ctx, a.userOutboxPartitions.Order(appCodes), a.cfg.OutboxWorker.BatchSize, func(claimCtx context.Context, appCode string, limit int) ([]mysqlstorage.UserOutboxRecord, error) {
		return a.mysqlRepo.ClaimPendingUserOutbox(appcode.WithContext(claimCtx, appCode), workerID, limit)
	})
	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, roomTimeSvc *roomtimeservice.Service, cpSvc *cpservice.Service) ([]*rocketmqx.Consumer, error) {
	consumers := make([]*rocketmqx.Consumer, 0, 4)
	if cfg.InviteRechargeWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
		// 邀请关系只消费充值成功事实。兼容期仍订阅 legacy Tag 以排空 producer
		// 切换前的存量消息，typed 消息则在 Broker 侧只投递充值事件。
		walletTagExpression, err := walletmq.LegacyCompatibleTagExpression(walletmq.EventTypeWalletRechargeRecorded)
		if err != nil {
			return nil, err
		}
		consumer, err := rocketmqx.NewConsumer(walletOutboxConsumerConfig(cfg.RocketMQ))
		if err != nil {
			return nil, err
		}
		if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletTagExpression, 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 := subscribeRoomOutboxEvents(consumer, cfg.RocketMQ.RoomOutbox.Topic, []string{roommq.EventTypeRoomMicChanged}, 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.RoomTimeWorker.Enabled && cfg.RocketMQ.RoomOutbox.Enabled {
		// 在房时长消费使用独立 consumer group，保证和麦位时长/CP 读模型各自拥有 MQ 位点和重试节奏。
		consumer, err := rocketmqx.NewConsumer(roomTimeOutboxConsumerConfig(cfg))
		if err != nil {
			shutdownMQConsumers(consumers)
			return nil, err
		}
		if err := subscribeRoomOutboxEvents(consumer, cfg.RocketMQ.RoomOutbox.Topic, []string{
			roommq.EventTypeRoomUserJoined,
			roommq.EventTypeRoomUserLeft,
			roommq.EventTypeRoomUserKicked,
		}, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
			event, ok, err := roomPresenceEventFromRoomMessage(message.Body)
			if err != nil || !ok {
				return err
			}
			_, err = roomTimeSvc.ConsumeRoomPresenceEvent(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 := subscribeRoomOutboxEvents(consumer, cfg.RocketMQ.RoomOutbox.Topic, []string{roommq.EventTypeRoomGiftSent}, 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
}

// subscribeRoomOutboxEvents 是 user-service 的 room typed-tag 迁移边界。所有
// consumer group 在 Phase A 都保留 legacy 分支以排空旧消息，Phase B 的 typed
// 消息才由 Broker 精确路由到麦时长、在房时长或 CP 关系各自的业务位点。
func subscribeRoomOutboxEvents(consumer *rocketmqx.Consumer, topic string, eventTypes []string, handler rocketmqx.Handler) error {
	tagExpression, err := roommq.LegacyCompatibleTagExpression(eventTypes...)
	if err != nil {
		return err
	}
	return consumer.Subscribe(topic, tagExpression, handler)
}

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 roomPresenceEventFromRoomMessage(body []byte) (roomtimedomain.RoomPresenceEvent, bool, error) {
	envelope, _, err := roommq.DecodeRoomOutboxMessage(body)
	if err != nil {
		return roomtimedomain.RoomPresenceEvent{}, false, err
	}
	event := roomtimedomain.RoomPresenceEvent{
		AppCode:      appcode.Normalize(envelope.GetAppCode()),
		EventID:      envelope.GetEventId(),
		EventType:    envelope.GetEventType(),
		RoomID:       envelope.GetRoomId(),
		RoomVersion:  envelope.GetRoomVersion(),
		OccurredAtMs: envelope.GetOccurredAtMs(),
	}
	switch envelope.GetEventType() {
	case roomtimedomain.RoomUserJoinedEventType:
		var joined roomeventsv1.RoomUserJoined
		if err := proto.Unmarshal(envelope.GetBody(), &joined); err != nil {
			return roomtimedomain.RoomPresenceEvent{}, false, err
		}
		event.UserID = joined.GetUserId()
		event.IsRobot = joined.GetIsRobot()
	case roomtimedomain.RoomUserLeftEventType:
		var left roomeventsv1.RoomUserLeft
		if err := proto.Unmarshal(envelope.GetBody(), &left); err != nil {
			return roomtimedomain.RoomPresenceEvent{}, false, err
		}
		event.UserID = left.GetUserId()
	case roomtimedomain.RoomUserKickedEventType:
		// 踢人/系统驱逐/关房清场只发 RoomUserKicked，不再补 RoomUserLeft；presence 主体是 target_user_id。
		var kicked roomeventsv1.RoomUserKicked
		if err := proto.Unmarshal(envelope.GetBody(), &kicked); err != nil {
			return roomtimedomain.RoomPresenceEvent{}, false, err
		}
		event.UserID = kicked.GetTargetUserId()
	default:
		return roomtimedomain.RoomPresenceEvent{}, false, nil
	}
	return event, 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 关系时 GiftValue 继续增加亲密值，而 CoinSpent 独立保留 wallet 结算出的礼物金币价值，二者不能互相回填。
	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(),
		CoinSpent:        sent.GetCoinSpent(),
		BillingReceiptID: sent.GetBillingReceiptId(),
		VisibleRegionID:  sent.GetVisibleRegionId(),
		RoomLocked:       sent.GetRoomLocked(),
		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 roomTimeOutboxConsumerConfig(cfg config.Config) rocketmqx.ConsumerConfig {
	return rocketmqConsumerConfig(cfg.RocketMQ, cfg.RoomTimeWorker.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,
	}
}
