package storage import ( "context" "database/sql" "fmt" "log/slog" "strings" "time" "github.com/shopspring/decimal" "dashboard-cdc-worker/internal/dashboard" ) const ( reconcileOfficialSource = "likei.order_purchase_history" reconcileMifapaySource = "likei.order_user_purchase_pay" ) type officialRechargeRow struct { id string userID int64 sysOrigin string payPlatform string unitPrice decimal.Decimal coinQuantity decimal.Decimal eventTime time.Time user dashboard.UserCountry } type mifapayRechargeRow struct { id string userID int64 sysOrigin string amount decimal.Decimal eventTime time.Time user dashboard.UserCountry } func (r *MySQLRepository) BuildRechargeReconcileEvents(ctx context.Context, since time.Time, limit int) ([]dashboard.Event, error) { if limit <= 0 { limit = 5000 } officialRows, err := r.listOfficialRechargeRows(ctx, since, limit) if err != nil { return nil, err } remaining := limit - len(officialRows) if remaining <= 0 { remaining = 1 } mifapayRows, err := r.listMifapayRechargeRows(ctx, since, remaining) if err != nil { return nil, err } events := make([]dashboard.Event, 0, len(officialRows)+len(mifapayRows)) for _, row := range officialRows { if !r.allowedOrigin(row.sysOrigin) || row.user.IsDeleted || row.user.SysOrigin != row.sysOrigin { continue } contribution := dashboard.Contribution{ SourceSchema: "likei", SourceTable: "order_purchase_history", SourcePK: row.id, SourceAction: "insert", DeltaSign: 1, SysOrigin: row.sysOrigin, UserID: row.userID, Country: row.user.Country, EventTime: row.eventTime, UserCreatedAt: row.user.CreatedAt, NewUserRechargeCandidate: row.unitPrice, RechargeDetail: officialRechargeDetail(row), } if row.payPlatform != "MIFA_PAY" { contribution.OfficialRecharge = row.unitPrice } if row.payPlatform == "GOOGLE" { contribution.GoogleRecharge = row.unitPrice } events = append(events, dashboard.Event{ EventKey: rechargeReconcileEventKey(reconcileOfficialSource, row.id), Source: reconcileOfficialSource, Contributions: []dashboard.Contribution{contribution}, }) } for _, row := range mifapayRows { if !r.allowedOrigin(row.sysOrigin) || row.user.IsDeleted || row.user.SysOrigin != row.sysOrigin { continue } contribution := dashboard.Contribution{ SourceSchema: "likei", SourceTable: "order_user_purchase_pay", SourcePK: row.id, SourceAction: "insert", DeltaSign: 1, SysOrigin: row.sysOrigin, UserID: row.userID, Country: row.user.Country, EventTime: row.eventTime, UserCreatedAt: row.user.CreatedAt, NewUserRechargeCandidate: row.amount, MifapayRecharge: row.amount, RechargeDetail: mifapayRechargeDetail(row), } events = append(events, dashboard.Event{ EventKey: rechargeReconcileEventKey(reconcileMifapaySource, row.id), Source: reconcileMifapaySource, Contributions: []dashboard.Contribution{contribution}, }) } return events, nil } func (r *MySQLRepository) StartRechargeReconciler(ctx context.Context, logger *slog.Logger, projector interface { ApplyEvents(context.Context, []dashboard.Event) error }) { if !r.cfg.Recharge.Enabled || r.cfg.Recharge.Interval <= 0 { return } run := func() { since := currentStatDayStart(r.cfg.Dashboard.StatTimezones, r.cfg.Recharge.CurrentDayLag) events, err := r.BuildRechargeReconcileEvents(ctx, since, r.cfg.Recharge.BatchLimit) if err != nil { if r.metrics != nil { r.metrics.RecordError(err) } logger.Error("dashboard recharge reconcile query failed", "error", err) return } if len(events) == 0 { return } if err := projector.ApplyEvents(ctx, events); err != nil { if r.metrics != nil { r.metrics.RecordError(err) } logger.Error("dashboard recharge reconcile apply failed", "events", len(events), "error", err) return } logger.Info("dashboard recharge reconcile applied", "events", len(events), "since", since) } go func() { run() ticker := time.NewTicker(r.cfg.Recharge.Interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: run() } } }() } func (r *MySQLRepository) listOfficialRechargeRows(ctx context.Context, since time.Time, limit int) ([]officialRechargeRow, error) { rows, err := r.db.QueryContext(ctx, ` SELECT CAST(o.id AS CHAR), o.user_id, o.sys_origin, o.pay_platform, o.unit_price, 0 AS coin_quantity, o.create_time, 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 order_purchase_history o JOIN user_base_info u ON u.id = o.user_id WHERE o.create_time >= ? AND o.evn = 'PROD' AND o.status = 'COMPLETE' AND IFNULL(o.is_trial_period, 0) = 0 ORDER BY o.create_time ASC LIMIT ?`, r.cfg.Dashboard.UnknownCountryCode, r.cfg.Dashboard.UnknownCountryCode, since, limit) if err != nil { return nil, err } defer rows.Close() var result []officialRechargeRow for rows.Next() { var item officialRechargeRow if err := scanRechargeUser(rows, &item.id, &item.userID, &item.sysOrigin, &item.payPlatform, &item.unitPrice, &item.coinQuantity, &item.eventTime, &item.user); err != nil { return nil, err } result = append(result, item) } return result, rows.Err() } func (r *MySQLRepository) listMifapayRechargeRows(ctx context.Context, since time.Time, limit int) ([]mifapayRechargeRow, error) { rows, err := r.db.QueryContext(ctx, ` SELECT CAST(o.id AS CHAR), o.user_id, o.sys_origin, o.compute_usd_amount, o.create_time, 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 order_user_purchase_pay o JOIN user_base_info u ON u.id = o.user_id WHERE o.create_time >= ? AND o.evn = 'PROD' AND o.factory_code = 'MIFA_PAY' AND o.pay_status = 'SUCCESSFUL' AND o.receipt_type = 'PAYMENT' AND o.refund_status = 'NONE' ORDER BY o.create_time ASC LIMIT ?`, r.cfg.Dashboard.UnknownCountryCode, r.cfg.Dashboard.UnknownCountryCode, since, limit) if err != nil { return nil, err } defer rows.Close() var result []mifapayRechargeRow for rows.Next() { var item mifapayRechargeRow var countryCode, countryName string var isDeleted bool var userCreatedAt, userUpdatedAt sql.NullTime if err := rows.Scan(&item.id, &item.userID, &item.sysOrigin, &item.amount, &item.eventTime, &item.user.UserID, &item.user.SysOrigin, &countryCode, &countryName, &isDeleted, &userCreatedAt, &userUpdatedAt); err != nil { return nil, err } 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 } result = append(result, item) } return result, rows.Err() } func scanRechargeUser(rows *sql.Rows, id *string, userID *int64, sysOrigin *string, payPlatform *string, unitPrice *decimal.Decimal, coinQuantity *decimal.Decimal, eventTime *time.Time, user *dashboard.UserCountry) error { var countryCode, countryName string var isDeleted bool var userCreatedAt, userUpdatedAt sql.NullTime if err := rows.Scan(id, userID, sysOrigin, payPlatform, unitPrice, coinQuantity, eventTime, &user.UserID, &user.SysOrigin, &countryCode, &countryName, &isDeleted, &userCreatedAt, &userUpdatedAt); err != nil { return err } user.Country = dashboard.Country{Code: countryCode, Name: countryName} user.IsDeleted = isDeleted if userCreatedAt.Valid { user.CreatedAt = userCreatedAt.Time } if userUpdatedAt.Valid { user.UpdatedAt = userUpdatedAt.Time } return nil } func officialRechargeDetail(row officialRechargeRow) *dashboard.RechargeDetail { return &dashboard.RechargeDetail{ SourceType: "OFFICIAL", RecordID: row.id, SourceChannel: row.payPlatform, SysOrigin: row.user.SysOrigin, UserID: row.userID, Country: row.user.Country, Amount: row.unitPrice, CoinQuantity: row.coinQuantity, EventTime: row.eventTime, } } func mifapayRechargeDetail(row mifapayRechargeRow) *dashboard.RechargeDetail { return &dashboard.RechargeDetail{ SourceType: "MIFAPAY", RecordID: row.id, SourceChannel: "MIFA_PAY", SysOrigin: row.user.SysOrigin, UserID: row.userID, Country: row.user.Country, Amount: row.amount, EventTime: row.eventTime, } } func rechargeReconcileEventKey(source string, id string) string { return fmt.Sprintf("reconcile:%s:%s:complete", source, id) } func currentStatDayStart(statTimezones []string, lag time.Duration) time.Time { timezone := "Asia/Riyadh" if len(statTimezones) > 0 && strings.TrimSpace(statTimezones[0]) != "" { timezone = strings.TrimSpace(statTimezones[0]) } location, err := time.LoadLocation(timezone) if err != nil { location = time.UTC } now := time.Now().In(location).Add(-lag) return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location) } func (r *MySQLRepository) allowedOrigin(sysOrigin string) bool { if len(r.cfg.Dashboard.SysOrigins) == 0 { return true } for _, origin := range r.cfg.Dashboard.SysOrigins { if strings.TrimSpace(origin) == sysOrigin { return true } } return false }