// Package app wires cron-service configuration, scheduler, storage and gRPC health. package app import ( "context" "errors" "net" "sync" "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" activityv1 "hyapp.local/api/proto/activity/v1" gamev1 "hyapp.local/api/proto/game/v1" userv1 "hyapp.local/api/proto/user/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/grpchealth" "hyapp/pkg/healthhttp" "hyapp/pkg/logx" serviceapp "hyapp/pkg/servicekit/app" servicegrpc "hyapp/pkg/servicekit/grpcserver" servicehealth "hyapp/pkg/servicekit/health" "hyapp/services/cron-service/internal/config" "hyapp/services/cron-service/internal/integration" "hyapp/services/cron-service/internal/scheduler" mysqlstorage "hyapp/services/cron-service/internal/storage/mysql" ) // App owns the cron-service process lifecycle. type App struct { server *grpc.Server listener net.Listener health *grpchealth.ServingChecker healthHTTP *healthhttp.Server repository *mysqlstorage.Repository scheduler *scheduler.Scheduler userConn *grpc.ClientConn activityConn *grpc.ClientConn gameConn *grpc.ClientConn walletConn *grpc.ClientConn workers *serviceapp.BackgroundGroup closeOnce sync.Once } // New initializes cron metadata storage, health service and scheduler. func New(cfg config.Config) (*App, error) { startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // cron-service owns only scheduler metadata. Business data stays in owner service databases. repository, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN) if err != nil { return nil, err } listener, err := net.Listen("tcp", cfg.GRPCAddr) if err != nil { _ = repository.Close() return nil, err } userConn, err := grpc.Dial(cfg.UserServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { _ = listener.Close() _ = repository.Close() return nil, err } activityConn, err := grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { _ = userConn.Close() _ = listener.Close() _ = repository.Close() return nil, err } gameConn, err := grpc.Dial(cfg.GameServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { _ = activityConn.Close() _ = userConn.Close() _ = listener.Close() _ = repository.Close() return nil, err } walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { _ = gameConn.Close() _ = activityConn.Close() _ = userConn.Close() _ = listener.Close() _ = repository.Close() return nil, err } server := servicegrpc.New("cron-service") health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{ ServiceName: "cron-service", HTTPAddr: cfg.HealthHTTPAddr, NodeID: cfg.NodeID, Dependencies: []grpchealth.Dependency{{ Name: "mysql", Check: repository.Ping, }}, }) if err != nil { _ = walletConn.Close() _ = gameConn.Close() _ = activityConn.Close() _ = userConn.Close() _ = listener.Close() _ = repository.Close() return nil, err } userCron := integration.NewUserCronClient(userv1.NewUserCronServiceClient(userConn)) activityCron := integration.NewActivityCronClient(activityv1.NewActivityCronServiceClient(activityConn)) gameCron := integration.NewGameCronClient(gamev1.NewGameCronServiceClient(gameConn)) walletCron := integration.NewWalletCronClient(walletv1.NewWalletCronServiceClient(walletConn)) taskScheduler := scheduler.New(cfg.NodeID, cfg.Tasks, repository, map[string]scheduler.Handler{ "login_ip_risk": userCron.ProcessLoginIPRiskBatch, "user_region_rebuild": userCron.ProcessRegionRebuildBatch, "mic_open_session_compensation": userCron.CompensateMicOpenSessions, "manager_user_block_expiry": userCron.ExpireManagerUserBlocks, // CP 榜单任务只占 cron 租约并调用 user-service;真实 MySQL 读取、wallet 头像框补齐和 Redis 替换都在 user-service 内完成。 "cp_intimacy_leaderboard": userCron.RefreshCPIntimacyLeaderboard, "message_fanout": activityCron.ProcessMessageFanoutBatch, "growth_level_reward": activityCron.ProcessLevelRewardBatch, "achievement_reward": activityCron.ProcessAchievementRewardBatch, "weekly_star_settlement": activityCron.ProcessWeeklyStarSettlementBatch, "agency_opening_settlement": activityCron.ProcessAgencyOpeningSettlementBatch, "cp_weekly_rank_settlement": activityCron.ProcessCPWeeklyRankSettlementBatch, "room_turnover_reward_settlement": activityCron.ProcessRoomTurnoverRewardSettlementBatch, "game_level_event_relay": gameCron.ProcessLevelEventOutboxBatch, "host_salary_daily_settlement": walletCron.ProcessHostSalaryDailySettlementBatch, "host_salary_half_month_settlement": walletCron.ProcessHostSalaryHalfMonthSettlementBatch, "host_salary_month_end": walletCron.ProcessHostSalaryMonthEndBatch, }) return &App{ server: server, listener: listener, health: health, healthHTTP: healthHTTP, repository: repository, scheduler: taskScheduler, userConn: userConn, activityConn: activityConn, gameConn: gameConn, walletConn: walletConn, workers: serviceapp.NewBackground(context.Background()), }, nil } // Run starts scheduler loops and gRPC health serving. func (a *App) Run() error { a.runHealthHTTP() a.health.MarkServing() defer func() { a.workers.StopAndWait() }() if a.scheduler != nil { a.workers.Go(func(ctx context.Context) { a.scheduler.Run(ctx) }) } err := a.server.Serve(a.listener) a.health.MarkStopped() if errors.Is(err, grpc.ErrServerStopped) { return nil } return err } // Close drains scheduler loops before closing gRPC and MySQL resources. func (a *App) Close() { a.closeOnce.Do(func() { a.health.MarkDraining() a.workers.StopAndWait() servicegrpc.GracefulStop(a.server, 15*time.Second) a.closeHealthHTTP() if a.repository != nil { _ = a.repository.Close() } if a.userConn != nil { _ = a.userConn.Close() } if a.activityConn != nil { _ = a.activityConn.Close() } if a.gameConn != nil { _ = a.gameConn.Close() } if a.walletConn != nil { _ = a.walletConn.Close() } }) } 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) }