245 lines
6.6 KiB
Go
245 lines
6.6 KiB
Go
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, "`", "``") + "`"
|
|
}
|