package storage import ( "context" "database/sql" "fmt" "log/slog" "strings" "time" "github.com/shopspring/decimal" "dashboard-cdc-worker/internal/dashboard" ) type walletGiftRow struct { table string id string idNumber int64 userID int64 sysOrigin string eventType string recordType int64 amount decimal.Decimal eventTime time.Time user dashboard.UserCountry } func (r *MySQLRepository) BuildGiftReconcileEvents(ctx context.Context, since time.Time, limit int) ([]dashboard.Event, error) { if limit <= 0 { limit = 50000 } rows, err := r.listWalletGiftRows(ctx, since, limit) if err != nil { return nil, err } events := make([]dashboard.Event, 0, len(rows)) for _, row := range rows { if !r.allowedOrigin(row.sysOrigin) || row.user.IsDeleted || row.user.SysOrigin != row.sysOrigin { continue } contribution := dashboard.Contribution{ SourceSchema: r.cfg.MySQL.WalletSchema, SourceTable: row.table, SourcePK: row.id, SourceAction: "insert", EventKey: dashboard.WalletMetricEventKey(r.cfg.MySQL.WalletSchema, row.table, row.id), DeltaSign: 1, SysOrigin: row.sysOrigin, UserID: row.userID, Country: row.user.Country, EventTime: row.eventTime, UserCreatedAt: row.user.CreatedAt, } switch { case isGiftConsumeEvent(row.eventType) && row.recordType == 1: contribution.GiftConsume = row.amount default: continue } events = append(events, dashboard.Event{ EventKey: contribution.EventKey, Source: r.cfg.MySQL.WalletSchema + "." + row.table, Contributions: []dashboard.Contribution{contribution}, }) } return events, nil } func (r *MySQLRepository) StartGiftReconciler(ctx context.Context, logger *slog.Logger, projector interface { ApplyEvents(context.Context, []dashboard.Event) error }) { if !r.cfg.Gift.Enabled || r.cfg.Gift.Interval <= 0 { return } run := func() { since := currentStatDayStart(r.cfg.Dashboard.StatTimezones, r.cfg.Gift.CurrentDayLag) events, err := r.BuildGiftReconcileEvents(ctx, since, r.cfg.Gift.BatchLimit) if err != nil { if r.metrics != nil { r.metrics.RecordError(err) } logger.Error("dashboard gift reconcile query failed", "error", err) return } if len(events) == 0 { return } if err := projector.ApplyEvents(ctx, events); err != nil { r.resetGiftHighWater() if r.metrics != nil { r.metrics.RecordError(err) } logger.Error("dashboard gift reconcile apply failed", "events", len(events), "error", err) return } logger.Info("dashboard gift reconcile applied", "events", len(events), "since", since) } go func() { run() ticker := time.NewTicker(r.cfg.Gift.Interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: run() } } }() } func (r *MySQLRepository) resetGiftHighWater() { r.giftMu.Lock() defer r.giftMu.Unlock() r.giftHighWater = make(map[string]int64) } func (r *MySQLRepository) listWalletGiftRows(ctx context.Context, since time.Time, limit int) ([]walletGiftRow, error) { tables, err := r.walletRecordTables(ctx) if err != nil { return nil, err } remaining := limit var result []walletGiftRow for _, table := range tables { if remaining <= 0 { break } rows, err := r.listWalletGiftRowsFromTable(ctx, table, since, remaining) if err != nil { return nil, err } result = append(result, rows...) remaining -= len(rows) } return result, nil } func (r *MySQLRepository) walletRecordTables(ctx context.Context) ([]string, error) { rows, err := r.db.QueryContext(ctx, ` SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_name LIKE 'wallet_gold_asset_record\_%' ORDER BY table_name`, r.cfg.MySQL.WalletSchema) if err != nil { return nil, err } defer rows.Close() var tables []string for rows.Next() { var table string if err := rows.Scan(&table); err != nil { return nil, err } tables = append(tables, table) } return tables, rows.Err() } func (r *MySQLRepository) listWalletGiftRowsFromTable(ctx context.Context, table string, since time.Time, limit int) ([]walletGiftRow, error) { highWaterKey := r.cfg.MySQL.WalletSchema + "." + table r.giftMu.Lock() highWater := r.giftHighWater[highWaterKey] r.giftMu.Unlock() sinceMillis := since.UnixMilli() query := fmt.Sprintf(` SELECT CAST(w.id AS CHAR), w.id, w.user_id, w.sys_origin, w.event_type, w.type, w.penny_amount, FROM_UNIXTIME(w.create_time / 1000), u.id, u.origin_sys, IFNULL(NULLIF(u.country_code, ''), ?), IFNULL(NULLIF(u.country_name, ''), IFNULL(NULLIF(u.country_code, ''), ?)), IFNULL(u.is_del, 0), u.create_time, u.update_time FROM %s.%s w JOIN %s.user_base_info u ON u.id = w.user_id WHERE w.id > ? AND w.create_time >= ? AND w.event_type LIKE 'GIVE_GIFT%%' AND w.event_type NOT LIKE '%%LUCKY_GIFT%%' AND w.type = 1 ORDER BY w.id ASC LIMIT ?`, quoteIdent(r.cfg.MySQL.WalletSchema), quoteIdent(table), quoteIdent(r.cfg.MySQL.Schema)) rows, err := r.db.QueryContext(ctx, query, r.cfg.Dashboard.UnknownCountryCode, r.cfg.Dashboard.UnknownCountryCode, highWater, sinceMillis, limit) if err != nil { return nil, err } defer rows.Close() var result []walletGiftRow maxID := highWater for rows.Next() { var item walletGiftRow item.table = table var penny int64 var countryCode, countryName string var isDeleted bool var userCreatedAt, userUpdatedAt sql.NullTime if err := rows.Scan(&item.id, &item.idNumber, &item.userID, &item.sysOrigin, &item.eventType, &item.recordType, &penny, &item.eventTime, &item.user.UserID, &item.user.SysOrigin, &countryCode, &countryName, &isDeleted, &userCreatedAt, &userUpdatedAt); err != nil { return nil, err } item.amount = decimal.NewFromInt(penny).Div(decimal.NewFromInt(100)) item.user.Country = dashboard.Country{Code: countryCode, Name: countryName} item.user.IsDeleted = isDeleted if userCreatedAt.Valid { item.user.CreatedAt = userCreatedAt.Time } if userUpdatedAt.Valid { item.user.UpdatedAt = userUpdatedAt.Time } if item.idNumber > maxID { maxID = item.idNumber } result = append(result, item) } if err := rows.Err(); err != nil { return nil, err } if maxID > highWater { r.giftMu.Lock() if maxID > r.giftHighWater[highWaterKey] { r.giftHighWater[highWaterKey] = maxID } r.giftMu.Unlock() } return result, nil } func isGiftConsumeEvent(eventType string) bool { return strings.HasPrefix(eventType, "GIVE_GIFT") && !strings.Contains(eventType, "LUCKY_GIFT") } func quoteIdent(value string) string { return "`" + strings.ReplaceAll(value, "`", "``") + "`" }