495 lines
18 KiB
Go
495 lines
18 KiB
Go
package mysql
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
mysqlerr "github.com/go-sql-driver/mysql"
|
|
"hyapp/pkg/appcode"
|
|
"hyapp/pkg/xerr"
|
|
)
|
|
|
|
const (
|
|
SourceUser = "user"
|
|
SourceWallet = "wallet"
|
|
SourceRoom = "room"
|
|
SourceGame = "game"
|
|
)
|
|
|
|
// Repository owns the statistics read model. Online queries must use these
|
|
// aggregate tables, never owner-service fact tables.
|
|
type Repository struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func Open(ctx context.Context, dsn string) (*Repository, error) {
|
|
if strings.TrimSpace(dsn) == "" {
|
|
return nil, xerr.New(xerr.InvalidArgument, "mysql_dsn is required")
|
|
}
|
|
db, err := sql.Open("mysql", dsn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.PingContext(ctx); err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
repo := &Repository{db: db}
|
|
if err := repo.Migrate(ctx); err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
return repo, nil
|
|
}
|
|
|
|
func (r *Repository) Close() error {
|
|
if r == nil || r.db == nil {
|
|
return nil
|
|
}
|
|
return r.db.Close()
|
|
}
|
|
|
|
func (r *Repository) Ping(ctx context.Context) error {
|
|
if r == nil || r.db == nil {
|
|
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
|
}
|
|
return r.db.PingContext(ctx)
|
|
}
|
|
|
|
func (r *Repository) Migrate(ctx context.Context) error {
|
|
statements := []string{
|
|
`CREATE TABLE IF NOT EXISTS statistics_event_consumption (
|
|
app_code VARCHAR(32) NOT NULL, source VARCHAR(32) NOT NULL, event_id VARCHAR(160) NOT NULL,
|
|
event_type VARCHAR(64) NOT NULL, status VARCHAR(32) NOT NULL, consumed_at_ms BIGINT NOT NULL,
|
|
created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL,
|
|
PRIMARY KEY (app_code, source, event_id),
|
|
KEY idx_statistics_event_type (app_code, source, event_type, consumed_at_ms)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
`CREATE TABLE IF NOT EXISTS stat_app_day_country (
|
|
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
|
|
new_users BIGINT NOT NULL DEFAULT 0, active_users BIGINT NOT NULL DEFAULT 0, paid_users BIGINT NOT NULL DEFAULT 0,
|
|
recharge_usd_minor BIGINT NOT NULL DEFAULT 0, new_user_recharge_usd_minor BIGINT NOT NULL DEFAULT 0,
|
|
coin_seller_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, mifapay_recharge_usd_minor BIGINT NOT NULL DEFAULT 0,
|
|
google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, gift_coin_spent BIGINT NOT NULL DEFAULT 0,
|
|
lucky_gift_turnover BIGINT NOT NULL DEFAULT 0, lucky_gift_payers BIGINT NOT NULL DEFAULT 0,
|
|
game_turnover BIGINT NOT NULL DEFAULT 0, game_players BIGINT NOT NULL DEFAULT 0,
|
|
game_payout BIGINT NOT NULL DEFAULT 0, game_refund BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL,
|
|
PRIMARY KEY (app_code, stat_day, country_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
`CREATE TABLE IF NOT EXISTS stat_user_day_activity (
|
|
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
|
|
user_id BIGINT NOT NULL, first_active_at_ms BIGINT NOT NULL,
|
|
PRIMARY KEY (app_code, stat_day, user_id),
|
|
KEY idx_stat_user_day_country (app_code, stat_day, country_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
`CREATE TABLE IF NOT EXISTS stat_user_registration (
|
|
app_code VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, registered_day DATE NOT NULL,
|
|
country_id BIGINT NOT NULL DEFAULT 0, registered_at_ms BIGINT NOT NULL,
|
|
PRIMARY KEY (app_code, user_id), KEY idx_stat_user_registration_day (app_code, registered_day, country_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
`CREATE TABLE IF NOT EXISTS stat_recharge_day_payers (
|
|
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
|
|
user_id BIGINT NOT NULL, first_paid_at_ms BIGINT NOT NULL,
|
|
PRIMARY KEY (app_code, stat_day, user_id), KEY idx_stat_recharge_payer_country (app_code, stat_day, country_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
`CREATE TABLE IF NOT EXISTS stat_lucky_gift_day_payers (
|
|
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
|
|
user_id BIGINT NOT NULL, first_paid_at_ms BIGINT NOT NULL,
|
|
PRIMARY KEY (app_code, stat_day, user_id),
|
|
KEY idx_stat_lucky_payer_country (app_code, stat_day, country_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
`CREATE TABLE IF NOT EXISTS stat_game_day_country (
|
|
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
|
|
platform_code VARCHAR(64) NOT NULL DEFAULT '', game_id VARCHAR(96) NOT NULL,
|
|
turnover_coin BIGINT NOT NULL DEFAULT 0, payout_coin BIGINT NOT NULL DEFAULT 0, refund_coin BIGINT NOT NULL DEFAULT 0,
|
|
player_count BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL,
|
|
PRIMARY KEY (app_code, stat_day, country_id, platform_code, game_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
`CREATE TABLE IF NOT EXISTS stat_game_day_players (
|
|
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
|
|
platform_code VARCHAR(64) NOT NULL DEFAULT '', game_id VARCHAR(96) NOT NULL,
|
|
user_id BIGINT NOT NULL, first_played_at_ms BIGINT NOT NULL,
|
|
PRIMARY KEY (app_code, stat_day, country_id, platform_code, game_id, user_id)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
}
|
|
for _, statement := range statements {
|
|
if _, err := r.db.ExecContext(ctx, statement); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
alterStatements := []string{
|
|
`ALTER TABLE stat_app_day_country ADD COLUMN paid_users BIGINT NOT NULL DEFAULT 0 AFTER active_users`,
|
|
`ALTER TABLE stat_app_day_country ADD COLUMN game_refund BIGINT NOT NULL DEFAULT 0 AFTER game_payout`,
|
|
`ALTER TABLE stat_game_day_country ADD COLUMN refund_coin BIGINT NOT NULL DEFAULT 0 AFTER payout_coin`,
|
|
}
|
|
for _, statement := range alterStatements {
|
|
if _, err := r.db.ExecContext(ctx, statement); err != nil && !isDuplicateColumnError(err) {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type UserRegisteredEvent struct {
|
|
AppCode string
|
|
EventID string
|
|
UserID int64
|
|
RegionID int64
|
|
OccurredAtMS int64
|
|
}
|
|
|
|
type RechargeEvent struct {
|
|
AppCode string
|
|
EventID string
|
|
EventType string
|
|
UserID int64
|
|
USDMinor int64
|
|
RechargeSequence int64
|
|
TargetRegionID int64
|
|
RechargeType string
|
|
OccurredAtMS int64
|
|
}
|
|
|
|
type RoomGiftEvent struct {
|
|
AppCode string
|
|
EventID string
|
|
SenderUserID int64
|
|
VisibleRegionID int64
|
|
GiftValue int64
|
|
PoolID string
|
|
OccurredAtMS int64
|
|
}
|
|
|
|
type UserActiveEvent struct {
|
|
AppCode string
|
|
EventID string
|
|
EventType string
|
|
UserID int64
|
|
CountryID int64
|
|
OccurredAtMS int64
|
|
}
|
|
|
|
type GameOrderEvent struct {
|
|
AppCode string
|
|
EventID string
|
|
UserID int64
|
|
CountryID int64
|
|
PlatformCode string
|
|
GameID string
|
|
OpType string
|
|
CoinAmount int64
|
|
OccurredAtMS int64
|
|
}
|
|
|
|
func (r *Repository) ConsumeUserRegistered(ctx context.Context, event UserRegisteredEvent) error {
|
|
return r.withEvent(ctx, SourceUser, event.EventID, "UserRegistered", func(tx *sql.Tx, nowMS int64) error {
|
|
day := statDay(event.OccurredAtMS)
|
|
countryID := normalizeID(event.RegionID)
|
|
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, nowMS); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT IGNORE INTO stat_user_registration (app_code, user_id, registered_day, country_id, registered_at_ms)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, appcode.Normalize(event.AppCode), event.UserID, day, countryID, event.OccurredAtMS); err != nil {
|
|
return err
|
|
}
|
|
_, err := tx.ExecContext(ctx, `
|
|
UPDATE stat_app_day_country
|
|
SET new_users = new_users + 1, updated_at_ms = ?
|
|
WHERE app_code = ? AND stat_day = ? AND country_id = ?
|
|
`, nowMS, appcode.Normalize(event.AppCode), day, countryID)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (r *Repository) ConsumeRecharge(ctx context.Context, event RechargeEvent) error {
|
|
return r.withEvent(ctx, SourceWallet, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error {
|
|
day := statDay(event.OccurredAtMS)
|
|
countryID := normalizeID(event.TargetRegionID)
|
|
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, nowMS); err != nil {
|
|
return err
|
|
}
|
|
paidUsers, err := insertUnique(ctx, tx, `
|
|
INSERT IGNORE INTO stat_recharge_day_payers (app_code, stat_day, country_id, user_id, first_paid_at_ms)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, appcode.Normalize(event.AppCode), day, countryID, event.UserID, event.OccurredAtMS)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newUserUSD := int64(0)
|
|
if event.RechargeSequence == 1 {
|
|
newUserUSD = event.USDMinor
|
|
}
|
|
coinSellerUSD, mifapayUSD, googleUSD := int64(0), int64(0), int64(0)
|
|
switch strings.ToLower(strings.TrimSpace(event.RechargeType)) {
|
|
case "google":
|
|
googleUSD = event.USDMinor
|
|
case "mifapay":
|
|
mifapayUSD = event.USDMinor
|
|
default:
|
|
coinSellerUSD = event.USDMinor
|
|
}
|
|
_, err = tx.ExecContext(ctx, `
|
|
UPDATE stat_app_day_country
|
|
SET recharge_usd_minor = recharge_usd_minor + ?,
|
|
new_user_recharge_usd_minor = new_user_recharge_usd_minor + ?,
|
|
coin_seller_recharge_usd_minor = coin_seller_recharge_usd_minor + ?,
|
|
mifapay_recharge_usd_minor = mifapay_recharge_usd_minor + ?,
|
|
google_recharge_usd_minor = google_recharge_usd_minor + ?,
|
|
paid_users = paid_users + ?,
|
|
updated_at_ms = ?
|
|
WHERE app_code = ? AND stat_day = ? AND country_id = ?
|
|
`, event.USDMinor, newUserUSD, coinSellerUSD, mifapayUSD, googleUSD, paidUsers, nowMS, appcode.Normalize(event.AppCode), day, countryID)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (r *Repository) ConsumeRoomGift(ctx context.Context, event RoomGiftEvent) error {
|
|
return r.withEvent(ctx, SourceRoom, event.EventID, "RoomGiftSent", func(tx *sql.Tx, nowMS int64) error {
|
|
day := statDay(event.OccurredAtMS)
|
|
countryID := normalizeID(event.VisibleRegionID)
|
|
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, nowMS); err != nil {
|
|
return err
|
|
}
|
|
luckyTurnover, luckyPayers := int64(0), int64(0)
|
|
if strings.TrimSpace(event.PoolID) != "" {
|
|
luckyTurnover = event.GiftValue
|
|
affected, err := insertUnique(ctx, tx, `
|
|
INSERT IGNORE INTO stat_lucky_gift_day_payers (app_code, stat_day, country_id, user_id, first_paid_at_ms)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, appcode.Normalize(event.AppCode), day, countryID, event.SenderUserID, event.OccurredAtMS)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
luckyPayers = affected
|
|
}
|
|
_, err := tx.ExecContext(ctx, `
|
|
UPDATE stat_app_day_country
|
|
SET gift_coin_spent = gift_coin_spent + ?, lucky_gift_turnover = lucky_gift_turnover + ?,
|
|
lucky_gift_payers = lucky_gift_payers + ?, updated_at_ms = ?
|
|
WHERE app_code = ? AND stat_day = ? AND country_id = ?
|
|
`, event.GiftValue, luckyTurnover, luckyPayers, nowMS, appcode.Normalize(event.AppCode), day, countryID)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (r *Repository) ConsumeUserActive(ctx context.Context, event UserActiveEvent) error {
|
|
return r.withEvent(ctx, SourceRoom, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error {
|
|
return applyActive(ctx, tx, event.AppCode, statDay(event.OccurredAtMS), normalizeID(event.CountryID), event.UserID, event.OccurredAtMS, nowMS)
|
|
})
|
|
}
|
|
|
|
func (r *Repository) ConsumeGameOrder(ctx context.Context, event GameOrderEvent) error {
|
|
return r.withEvent(ctx, SourceGame, event.EventID, "GameOrderSettled", func(tx *sql.Tx, nowMS int64) error {
|
|
day := statDay(event.OccurredAtMS)
|
|
countryID := normalizeID(event.CountryID)
|
|
if err := applyActive(ctx, tx, event.AppCode, day, countryID, event.UserID, event.OccurredAtMS, nowMS); err != nil {
|
|
return err
|
|
}
|
|
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, nowMS); err != nil {
|
|
return err
|
|
}
|
|
turnover, payout, refund := int64(0), int64(0), int64(0)
|
|
switch strings.ToLower(strings.TrimSpace(event.OpType)) {
|
|
case "debit", "bet":
|
|
turnover = event.CoinAmount
|
|
case "credit", "payout":
|
|
payout = event.CoinAmount
|
|
case "refund", "reverse":
|
|
refund = event.CoinAmount
|
|
}
|
|
playerDelta := int64(0)
|
|
if turnover > 0 {
|
|
affected, err := insertUnique(ctx, tx, `
|
|
INSERT IGNORE INTO stat_game_day_players (app_code, stat_day, country_id, platform_code, game_id, user_id, first_played_at_ms)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, appcode.Normalize(event.AppCode), day, countryID, event.PlatformCode, event.GameID, event.UserID, event.OccurredAtMS)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
playerDelta = affected
|
|
}
|
|
if err := ensureGameDay(ctx, tx, event.AppCode, day, countryID, event.PlatformCode, event.GameID, nowMS); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE stat_game_day_country
|
|
SET turnover_coin = turnover_coin + ?, payout_coin = payout_coin + ?, refund_coin = refund_coin + ?,
|
|
player_count = player_count + ?, updated_at_ms = ?
|
|
WHERE app_code = ? AND stat_day = ? AND country_id = ? AND platform_code = ? AND game_id = ?
|
|
`, turnover, payout, refund, playerDelta, nowMS, appcode.Normalize(event.AppCode), day, countryID, event.PlatformCode, event.GameID); err != nil {
|
|
return err
|
|
}
|
|
_, err := tx.ExecContext(ctx, `
|
|
UPDATE stat_app_day_country
|
|
SET game_turnover = game_turnover + ?, game_payout = game_payout + ?, game_refund = game_refund + ?,
|
|
game_players = game_players + ?, updated_at_ms = ?
|
|
WHERE app_code = ? AND stat_day = ? AND country_id = ?
|
|
`, turnover, payout, refund, playerDelta, nowMS, appcode.Normalize(event.AppCode), day, countryID)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (r *Repository) withEvent(ctx context.Context, source string, eventID string, eventType string, apply func(tx *sql.Tx, nowMS int64) error) error {
|
|
var lastErr error
|
|
for attempt := 0; attempt < 5; attempt++ {
|
|
err := r.withEventOnce(ctx, source, eventID, eventType, apply)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if !isRetryableMySQLError(err) {
|
|
return err
|
|
}
|
|
lastErr = err
|
|
timer := time.NewTimer(time.Duration(attempt+1) * 50 * time.Millisecond)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
return lastErr
|
|
}
|
|
|
|
func (r *Repository) withEventOnce(ctx context.Context, source string, eventID string, eventType string, apply func(tx *sql.Tx, nowMS int64) error) error {
|
|
if r == nil || r.db == nil {
|
|
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
|
}
|
|
app := appcode.FromContext(ctx)
|
|
nowMS := time.Now().UTC().UnixMilli()
|
|
tx, err := r.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
result, err := tx.ExecContext(ctx, `
|
|
INSERT IGNORE INTO statistics_event_consumption (app_code, source, event_id, event_type, status, consumed_at_ms, created_at_ms, updated_at_ms)
|
|
VALUES (?, ?, ?, ?, 'processing', ?, ?, ?)
|
|
`, app, source, eventID, eventType, nowMS, nowMS, nowMS)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, _ := result.RowsAffected()
|
|
if affected == 0 {
|
|
return tx.Commit()
|
|
}
|
|
if err := apply(tx, nowMS); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE statistics_event_consumption
|
|
SET status = 'done', consumed_at_ms = ?, updated_at_ms = ?
|
|
WHERE app_code = ? AND source = ? AND event_id = ?
|
|
`, nowMS, nowMS, app, source, eventID); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func isRetryableMySQLError(err error) bool {
|
|
var mysqlErr *mysqlerr.MySQLError
|
|
if !errors.As(err, &mysqlErr) {
|
|
return false
|
|
}
|
|
return mysqlErr.Number == 1213 || mysqlErr.Number == 1205
|
|
}
|
|
|
|
func isDuplicateColumnError(err error) bool {
|
|
var mysqlErr *mysqlerr.MySQLError
|
|
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1060
|
|
}
|
|
|
|
func applyActive(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, userID int64, occurredAtMS int64, nowMS int64) error {
|
|
if err := ensureAppDay(ctx, tx, app, day, countryID, nowMS); err != nil {
|
|
return err
|
|
}
|
|
affected, err := insertUnique(ctx, tx, `
|
|
INSERT IGNORE INTO stat_user_day_activity (app_code, stat_day, country_id, user_id, first_active_at_ms)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, appcode.Normalize(app), day, countryID, userID, occurredAtMS)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected == 0 {
|
|
return nil
|
|
}
|
|
_, err = tx.ExecContext(ctx, `
|
|
UPDATE stat_app_day_country
|
|
SET active_users = active_users + 1, updated_at_ms = ?
|
|
WHERE app_code = ? AND stat_day = ? AND country_id = ?
|
|
`, nowMS, appcode.Normalize(app), day, countryID)
|
|
return err
|
|
}
|
|
|
|
func ensureAppDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, nowMS int64) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT IGNORE INTO stat_app_day_country (app_code, stat_day, country_id, updated_at_ms)
|
|
VALUES (?, ?, ?, ?)
|
|
`, appcode.Normalize(app), day, countryID, nowMS)
|
|
return err
|
|
}
|
|
|
|
func ensureGameDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, platformCode string, gameID string, nowMS int64) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT IGNORE INTO stat_game_day_country (app_code, stat_day, country_id, platform_code, game_id, updated_at_ms)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`, appcode.Normalize(app), day, countryID, platformCode, gameID, nowMS)
|
|
return err
|
|
}
|
|
|
|
func insertUnique(ctx context.Context, tx *sql.Tx, query string, args ...any) (int64, error) {
|
|
result, err := tx.ExecContext(ctx, query, args...)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return affected, nil
|
|
}
|
|
|
|
func statDay(ms int64) string {
|
|
return time.UnixMilli(ms).UTC().Format("2006-01-02")
|
|
}
|
|
|
|
func normalizeID(id int64) int64 {
|
|
if id < 0 {
|
|
return 0
|
|
}
|
|
return id
|
|
}
|
|
|
|
func DecodeJSON(payload string) map[string]any {
|
|
decoded := map[string]any{}
|
|
_ = json.Unmarshal([]byte(payload), &decoded)
|
|
return decoded
|
|
}
|
|
|
|
func Int64(payload map[string]any, key string) int64 {
|
|
switch value := payload[key].(type) {
|
|
case float64:
|
|
return int64(value)
|
|
case int64:
|
|
return value
|
|
case json.Number:
|
|
v, _ := value.Int64()
|
|
return v
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func String(payload map[string]any, key string) string {
|
|
if value, ok := payload[key].(string); ok {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
return ""
|
|
}
|