package storage import ( "context" "database/sql" "fmt" "log/slog" "time" "dashboard-cdc-worker/internal/dashboard" ) const userReconcileSource = "likei.user_base_info" type userReconcileRow struct { user dashboard.UserCountry } func (r *MySQLRepository) StartUserReconciler(ctx context.Context, logger *slog.Logger, projector *dashboard.Projector) { if !r.cfg.User.Enabled || r.cfg.User.Interval <= 0 { return } run := func() { location, err := time.LoadLocation(r.cfg.Dashboard.StorageTimezone) if err != nil { location = time.UTC } now := time.Now().In(location).Add(-r.cfg.User.CurrentDayLag) end := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second(), 0, location) start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location). AddDate(0, 0, -r.cfg.User.LookbackDays+1) events, err := r.BuildUserReconcileEvents(ctx, start, end, r.cfg.User.BatchLimit) if err != nil { if r.metrics != nil { r.metrics.RecordError(err) } logger.Error("dashboard user reconcile failed", "error", err) return } if len(events) == 0 { logger.Info("dashboard user reconcile skipped, no events", "since", start, "until", end) return } if err := projector.ApplyEvents(ctx, events); err != nil { if r.metrics != nil { r.metrics.RecordError(err) } logger.Error("dashboard user reconcile apply failed", "events", len(events), "error", err) return } logger.Info("dashboard user reconcile applied", "events", len(events), "since", start, "until", end) } go func() { run() ticker := time.NewTicker(r.cfg.User.Interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: run() } } }() } func (r *MySQLRepository) BuildUserReconcileEvents(ctx context.Context, since, until time.Time, limit int) ([]dashboard.Event, error) { rows, err := r.loadUserReconcileRows(ctx, since, until, limit) if err != nil { return nil, err } events := make([]dashboard.Event, 0, len(rows)) for _, row := range rows { user := row.user displayCreateTime := user.CreatedAt.Add(r.cfg.User.CreateTimeDisplayOffset) contribution := dashboard.Contribution{ SourceSchema: r.cfg.MySQL.Schema, SourceTable: "user_base_info", SourcePK: fmt.Sprint(user.UserID), SourceAction: "insert", DeltaSign: 1, SysOrigin: user.SysOrigin, UserID: user.UserID, Country: user.Country, EventTime: displayCreateTime, UserCreatedAt: user.CreatedAt, CountryNewUser: 1, } events = append(events, dashboard.Event{ EventKey: userReconcileEventKey(user.UserID), Source: userReconcileSource, User: &user, Contributions: []dashboard.Contribution{contribution}, }) } return events, nil } func (r *MySQLRepository) loadUserReconcileRows(ctx context.Context, since, until time.Time, limit int) ([]userReconcileRow, error) { args := []interface{}{ r.cfg.Dashboard.UnknownCountryCode, r.cfg.Dashboard.UnknownCountryCode, since, until, } originSQL := "" if len(r.cfg.Dashboard.SysOrigins) > 0 { originSQL = " AND origin_sys IN " + inPlaceholders(len(r.cfg.Dashboard.SysOrigins)) for _, origin := range r.cfg.Dashboard.SysOrigins { args = append(args, origin) } } limitSQL := "" if limit > 0 { limitSQL = " LIMIT ?" args = append(args, limit) } rows, err := r.db.QueryContext(ctx, ` SELECT id, origin_sys, IFNULL(NULLIF(country_code, ''), ?), IFNULL(NULLIF(country_name, ''), IFNULL(NULLIF(country_code, ''), ?)), IFNULL(is_del, 0), create_time, update_time FROM user_base_info WHERE create_time >= ? AND create_time < ? AND IFNULL(is_del, 0) = 0`+originSQL+` ORDER BY create_time ASC`+limitSQL, args...) if err != nil { return nil, err } defer rows.Close() result := make([]userReconcileRow, 0) for rows.Next() { var user dashboard.UserCountry var createdAt, updatedAt sql.NullTime if err := rows.Scan(&user.UserID, &user.SysOrigin, &user.Country.Code, &user.Country.Name, &user.IsDeleted, &createdAt, &updatedAt); err != nil { return nil, err } if createdAt.Valid { user.CreatedAt = createdAt.Time } if updatedAt.Valid { user.UpdatedAt = updatedAt.Time } if user.UserID == 0 || user.SysOrigin == "" || user.CreatedAt.IsZero() { continue } result = append(result, userReconcileRow{user: user}) } return result, rows.Err() } func userReconcileEventKey(userID int64) string { return fmt.Sprintf("reconcile:%s:%d:created:v2-display-time", userReconcileSource, userID) }