357 lines
12 KiB
Go
357 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"hyapp/pkg/appcode"
|
|
statisticsconfig "hyapp/services/statistics-service/internal/config"
|
|
|
|
_ "github.com/go-sql-driver/mysql"
|
|
)
|
|
|
|
const (
|
|
legacyDayResolutionSource = "legacy_day_unique"
|
|
legacyWindowResolutionSource = "legacy_window_unique"
|
|
legacyAmbiguousResolutionSource = "legacy_ambiguous"
|
|
)
|
|
|
|
type replayWindow struct {
|
|
start time.Time
|
|
end time.Time
|
|
loc *time.Location
|
|
}
|
|
|
|
type deviceResolution struct {
|
|
userID int64
|
|
ambiguous bool
|
|
}
|
|
|
|
type dayResolution struct {
|
|
deviceID string
|
|
userID int64
|
|
state string
|
|
source string
|
|
}
|
|
|
|
type daySummary struct {
|
|
day string
|
|
anonymous int
|
|
resolved int
|
|
ambiguous int
|
|
unresolved int
|
|
}
|
|
|
|
func main() {
|
|
var (
|
|
appsValue = flag.String("apps", envDefault("APP_CODES", "lalu,huwaa,fami"), "comma-separated app_code list")
|
|
statTZ = flag.String("timezone", envDefault("STAT_TZ", "Asia/Shanghai"), "UTC or Asia/Shanghai")
|
|
startDay = flag.String("start-day", "", "inclusive day, yyyy-mm-dd")
|
|
endDay = flag.String("end-day", "", "inclusive day, yyyy-mm-dd")
|
|
apply = flag.Bool("apply", false, "write identity resolutions; default is dry-run")
|
|
configPath = flag.String("config", envDefault("STATISTICS_CONFIG", "/app/config.yaml"), "statistics-service YAML used when no DSN flag/env is set")
|
|
dsn = flag.String("statistics-dsn", firstNonEmptyEnv("STATISTICS_MYSQL_DSN", "MYSQL_DSN"), "statistics MySQL DSN")
|
|
pause = flag.Duration("pause", 250*time.Millisecond, "pause between bounded day queries")
|
|
slicePause = flag.Duration("slice-pause", 50*time.Millisecond, "pause after each one-hour raw scan")
|
|
queryLimit = flag.Duration("query-timeout", 30*time.Second, "timeout for each bounded query or day transaction")
|
|
maxOpenConn = flag.Int("max-open-conns", 1, "small connection cap for production-safe backfill")
|
|
receivedCutoff = flag.Int64("received-before-ms", time.Now().UTC().UnixMilli(), "only use raw events received at or before this fixed cutoff")
|
|
allowWindowFallback = flag.Bool("allow-window-fallback", false, "allow cross-day unique device inference; unsafe by default for legacy shared devices")
|
|
)
|
|
flag.Parse()
|
|
|
|
apps := normalizeApps(*appsValue)
|
|
dsnValue := strings.TrimSpace(*dsn)
|
|
if dsnValue == "" {
|
|
cfg, err := statisticsconfig.Load(strings.TrimSpace(*configPath))
|
|
if err != nil {
|
|
log.Fatalf("load statistics config: %v", err)
|
|
}
|
|
dsnValue = strings.TrimSpace(cfg.MySQLDSN)
|
|
}
|
|
if len(apps) == 0 || dsnValue == "" {
|
|
log.Fatal("apps and statistics MySQL DSN are required")
|
|
}
|
|
window, err := resolveWindow(*startDay, *endDay, *statTZ)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
db, err := sql.Open("mysql", dsnValue)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
if *maxOpenConn < 1 {
|
|
*maxOpenConn = 1
|
|
}
|
|
db.SetMaxOpenConns(*maxOpenConn)
|
|
db.SetMaxIdleConns(1)
|
|
db.SetConnMaxLifetime(5 * time.Minute)
|
|
ctx, cancel := context.WithTimeout(context.Background(), *queryLimit)
|
|
if err := db.PingContext(ctx); err != nil {
|
|
cancel()
|
|
log.Fatal(err)
|
|
}
|
|
cancel()
|
|
fmt.Printf("identity backfill cutoff received_before_ms=%d (%s)\n", *receivedCutoff, time.UnixMilli(*receivedCutoff).UTC().Format(time.RFC3339))
|
|
|
|
for _, app := range apps {
|
|
if err := backfillApp(db, app, *statTZ, window, *receivedCutoff, *allowWindowFallback, *apply, *pause, *slicePause, *queryLimit); err != nil {
|
|
log.Fatalf("app=%s: %v", app, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func backfillApp(db *sql.DB, app, statTZ string, window replayWindow, receivedCutoff int64, allowWindowFallback, apply bool, pause, slicePause, queryLimit time.Duration) error {
|
|
windowDeviceUsers := map[string]deviceResolution{}
|
|
dayDeviceUsers := map[string]map[string]deviceResolution{}
|
|
for day := window.start; !day.After(window.end); day = day.AddDate(0, 0, 1) {
|
|
dayText := day.Format("2006-01-02")
|
|
dayDeviceUsers[dayText] = map[string]deviceResolution{}
|
|
// One-hour slices cap every index range even on the peak day. All
|
|
// authenticated event names participate: an account that only emitted
|
|
// page_open must still disqualify a shared device from "unique" mapping.
|
|
for hour := day; hour.Before(day.AddDate(0, 0, 1)); hour = hour.Add(time.Hour) {
|
|
if err := loadLoggedIdentitySlice(db, app, hour.UnixMilli(), hour.Add(time.Hour).UnixMilli(), receivedCutoff,
|
|
dayDeviceUsers[dayText], windowDeviceUsers, queryLimit); err != nil {
|
|
return fmt.Errorf("load logged identities day=%s hour=%s: %w", dayText, hour.Format("15:04"), err)
|
|
}
|
|
pauseFor(slicePause)
|
|
}
|
|
pauseFor(pause)
|
|
}
|
|
|
|
for day := window.start; !day.After(window.end); day = day.AddDate(0, 0, 1) {
|
|
dayText := day.Format("2006-01-02")
|
|
resolutions, summary, err := planDay(db, app, statTZ, dayText, dayDeviceUsers[dayText], windowDeviceUsers, allowWindowFallback, queryLimit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mode := "dry-run"
|
|
if apply {
|
|
mode = "applied"
|
|
if err := applyDay(db, app, statTZ, dayText, resolutions, queryLimit); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
fmt.Printf("%s app=%s stat_tz=%s day=%s anonymous=%d resolved=%d ambiguous=%d unresolved=%d\n",
|
|
mode, app, statTZ, summary.day, summary.anonymous, summary.resolved, summary.ambiguous, summary.unresolved)
|
|
pauseFor(pause)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadLoggedIdentitySlice(db *sql.DB, app string, startMS, endMS, receivedCutoff int64, dayUsers, windowUsers map[string]deviceResolution, queryLimit time.Duration) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), queryLimit)
|
|
defer cancel()
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT device_id, user_id
|
|
FROM app_tracking_events FORCE INDEX (idx_app_tracking_time_window)
|
|
WHERE app_code = ? AND occurred_at_ms >= ? AND occurred_at_ms < ? AND received_at_ms <= ?
|
|
AND device_id <> '' AND user_id > 0
|
|
`, app, startMS, endMS, receivedCutoff)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var deviceID string
|
|
var userID int64
|
|
if err := rows.Scan(&deviceID, &userID); err != nil {
|
|
return err
|
|
}
|
|
deviceID = strings.TrimSpace(deviceID)
|
|
if deviceID == "" || userID <= 0 {
|
|
continue
|
|
}
|
|
addDeviceUser(dayUsers, deviceID, userID)
|
|
addDeviceUser(windowUsers, deviceID, userID)
|
|
}
|
|
return rows.Err()
|
|
}
|
|
|
|
func addDeviceUser(target map[string]deviceResolution, deviceID string, userID int64) {
|
|
current, exists := target[deviceID]
|
|
if !exists {
|
|
target[deviceID] = deviceResolution{userID: userID}
|
|
return
|
|
}
|
|
if current.ambiguous || current.userID == userID {
|
|
return
|
|
}
|
|
current.ambiguous = true
|
|
current.userID = 0
|
|
target[deviceID] = current
|
|
}
|
|
|
|
func planDay(db *sql.DB, app, statTZ, day string, dayUsers, windowUsers map[string]deviceResolution, allowWindowFallback bool, queryLimit time.Duration) ([]dayResolution, daySummary, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), queryLimit)
|
|
defer cancel()
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT device_id
|
|
FROM stat_social_user_day
|
|
WHERE app_code = ? AND stat_tz = ? AND stat_day = ?
|
|
AND user_id = 0 AND device_id <> ''
|
|
ORDER BY device_id
|
|
`, app, statTZ, day)
|
|
if err != nil {
|
|
return nil, daySummary{}, fmt.Errorf("load anonymous devices day=%s: %w", day, err)
|
|
}
|
|
defer rows.Close()
|
|
summary := daySummary{day: day}
|
|
resolutions := []dayResolution{}
|
|
for rows.Next() {
|
|
var deviceID string
|
|
if err := rows.Scan(&deviceID); err != nil {
|
|
return nil, daySummary{}, err
|
|
}
|
|
summary.anonymous++
|
|
identity, ok := dayUsers[deviceID]
|
|
switch {
|
|
case ok && (identity.ambiguous || identity.userID <= 0):
|
|
summary.ambiguous++
|
|
resolutions = append(resolutions, dayResolution{deviceID: deviceID, state: "ambiguous", source: legacyAmbiguousResolutionSource})
|
|
case ok:
|
|
summary.resolved++
|
|
resolutions = append(resolutions, dayResolution{deviceID: deviceID, userID: identity.userID, state: "resolved", source: legacyDayResolutionSource})
|
|
default:
|
|
if !allowWindowFallback {
|
|
summary.unresolved++
|
|
continue
|
|
}
|
|
identity, ok = windowUsers[deviceID]
|
|
if !ok {
|
|
summary.unresolved++
|
|
continue
|
|
}
|
|
if identity.ambiguous || identity.userID <= 0 {
|
|
summary.ambiguous++
|
|
resolutions = append(resolutions, dayResolution{deviceID: deviceID, state: "ambiguous", source: legacyAmbiguousResolutionSource})
|
|
continue
|
|
}
|
|
// Cross-day unique fallback repairs the exact retention gap where day 1
|
|
// was anonymous and the same device first authenticated on day 2. It is
|
|
// never used if the device showed more than one account in the window.
|
|
summary.resolved++
|
|
resolutions = append(resolutions, dayResolution{deviceID: deviceID, userID: identity.userID, state: "resolved", source: legacyWindowResolutionSource})
|
|
}
|
|
}
|
|
return resolutions, summary, rows.Err()
|
|
}
|
|
|
|
func applyDay(db *sql.DB, app, statTZ, day string, resolutions []dayResolution, queryLimit time.Duration) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), queryLimit)
|
|
defer cancel()
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
// Only rows produced by this legacy job are replaced. Realtime client-session
|
|
// evidence has higher authority and must survive an idempotent historical rerun.
|
|
if _, err := tx.ExecContext(ctx, `
|
|
DELETE FROM stat_social_identity_day
|
|
WHERE app_code = ? AND stat_tz = ? AND stat_day = ?
|
|
AND resolution_source IN (?, ?, ?)
|
|
`, app, statTZ, day, legacyDayResolutionSource, legacyWindowResolutionSource, legacyAmbiguousResolutionSource); err != nil {
|
|
return err
|
|
}
|
|
stmt, err := tx.PrepareContext(ctx, `
|
|
INSERT INTO stat_social_identity_day (
|
|
app_code, stat_tz, stat_day, device_id, resolved_user_id,
|
|
resolution_state, resolution_source, updated_at_ms
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
resolved_user_id = IF(resolution_source = 'client_session', resolved_user_id, VALUES(resolved_user_id)),
|
|
resolution_state = IF(resolution_source = 'client_session', resolution_state, VALUES(resolution_state)),
|
|
resolution_source = IF(resolution_source = 'client_session', resolution_source, VALUES(resolution_source)),
|
|
updated_at_ms = IF(resolution_source = 'client_session', updated_at_ms, VALUES(updated_at_ms))
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer stmt.Close()
|
|
nowMS := time.Now().UTC().UnixMilli()
|
|
for _, resolution := range resolutions {
|
|
if _, err := stmt.ExecContext(ctx, app, statTZ, day, resolution.deviceID, resolution.userID,
|
|
resolution.state, resolution.source, nowMS); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func resolveWindow(startDay, endDay, statTZ string) (replayWindow, error) {
|
|
var loc *time.Location
|
|
switch strings.TrimSpace(statTZ) {
|
|
case "UTC":
|
|
loc = time.UTC
|
|
case "Asia/Shanghai":
|
|
loc = time.FixedZone("Asia/Shanghai", 8*60*60)
|
|
default:
|
|
return replayWindow{}, fmt.Errorf("unsupported timezone %q", statTZ)
|
|
}
|
|
start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(startDay), loc)
|
|
if err != nil {
|
|
return replayWindow{}, fmt.Errorf("parse start-day: %w", err)
|
|
}
|
|
end, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(endDay), loc)
|
|
if err != nil {
|
|
return replayWindow{}, fmt.Errorf("parse end-day: %w", err)
|
|
}
|
|
if end.Before(start) {
|
|
return replayWindow{}, fmt.Errorf("end-day is before start-day")
|
|
}
|
|
if days := int(end.Sub(start).Hours()/24) + 1; days > 31 {
|
|
return replayWindow{}, fmt.Errorf("window has %d days; maximum is 31", days)
|
|
}
|
|
return replayWindow{start: start, end: end, loc: loc}, nil
|
|
}
|
|
|
|
func normalizeApps(value string) []string {
|
|
seen := map[string]struct{}{}
|
|
apps := []string{}
|
|
for _, item := range strings.Split(value, ",") {
|
|
app := appcode.Normalize(item)
|
|
if app == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[app]; ok {
|
|
continue
|
|
}
|
|
seen[app] = struct{}{}
|
|
apps = append(apps, app)
|
|
}
|
|
sort.Strings(apps)
|
|
return apps
|
|
}
|
|
|
|
func pauseFor(pause time.Duration) {
|
|
if pause > 0 {
|
|
time.Sleep(pause)
|
|
}
|
|
}
|
|
|
|
func envDefault(key, fallback string) string {
|
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func firstNonEmptyEnv(keys ...string) string {
|
|
for _, key := range keys {
|
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|