2026-07-16 12:47:03 +08:00

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 ""
}