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

231 lines
7.4 KiB
Go

package mysql
import (
"context"
"database/sql"
"strings"
"hyapp/pkg/appcode"
)
const missingClientSessionID = "__missing_client_session__"
type socialIdentityDayKey struct {
StatTZ string
StatDay string
}
// applySocialTrackingIdentity records only identity evidence. It deliberately
// never moves or rewrites stat_social_user_day rows: that table contains additive
// money and duration fields which cannot be safely undone when events arrive out
// of order. Query-time canonicalization consumes the small day-resolution table
// and leaves the financial read model untouched.
func applySocialTrackingIdentity(ctx context.Context, tx *sql.Tx, event AppTrackingEvent, nowMS int64) error {
app := appcode.Normalize(event.AppCode)
deviceID := strings.TrimSpace(event.DeviceID)
clientSessionID := strings.TrimSpace(event.ClientSessionID)
if app == "" || deviceID == "" {
return nil
}
if event.UserID <= 0 {
// Only events that actually create a d: row may participate in the
// device-day resolution. page_open and failed actions otherwise introduce
// phantom unresolved sessions that permanently block a valid login merge.
contributesSocialRow := false
for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) {
if _, ok := socialDeltaForAppTrackingEvent(event, scope, nowMS); ok {
contributesSocialRow = true
break
}
}
if !contributesSocialRow {
return nil
}
} else if clientSessionID == "" {
// Old authenticated clients still produce a direct u: fact, but without
// the process session they cannot link any earlier anonymous row. Return
// before taking a device lock on this high-volume compatibility path.
return nil
}
if err := lockSocialIdentityDevice(ctx, tx, app, deviceID, nowMS); err != nil {
return err
}
if event.UserID <= 0 {
// Old clients do not send client_session_id. A single unresolved sentinel
// per device/day blocks a different resolved session from swallowing those
// anonymous events; conservative under-count correction is safer than a
// false account merge on shared devices.
if clientSessionID == "" {
clientSessionID = missingClientSessionID
}
for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) {
affected, err := insertUnique(ctx, tx, `
INSERT IGNORE INTO stat_social_identity_session_day (
app_code, stat_tz, stat_day, device_id, client_session_id, first_seen_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?)
`, app, scope.tz, scope.day, deviceID, clientSessionID, event.OccurredAtMS, nowMS)
if err != nil {
return err
}
if affected > 0 {
if err := recomputeSocialIdentityDay(ctx, tx, app, scope.tz, scope.day, deviceID, nowMS); err != nil {
return err
}
}
}
return nil
}
inserted, err := insertUnique(ctx, tx, `
INSERT IGNORE INTO stat_social_identity_session_user (
app_code, device_id, client_session_id, user_id, first_seen_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?)
`, app, deviceID, clientSessionID, normalizeID(event.UserID), event.OccurredAtMS, nowMS)
if err != nil || inserted == 0 {
return err
}
// A session can cross a Beijing/UTC midnight. When its first authenticated
// event arrives, recompute every earlier anonymous day observed for that same
// device+client session so D1/D3/D7 retention sees one canonical account.
rows, err := tx.QueryContext(ctx, `
SELECT stat_tz, DATE_FORMAT(stat_day, '%Y-%m-%d')
FROM stat_social_identity_session_day
WHERE app_code = ? AND device_id = ? AND client_session_id = ?
ORDER BY stat_tz, stat_day
FOR UPDATE
`, app, deviceID, clientSessionID)
if err != nil {
return err
}
days := make([]socialIdentityDayKey, 0, 2)
for rows.Next() {
var key socialIdentityDayKey
if err := rows.Scan(&key.StatTZ, &key.StatDay); err != nil {
_ = rows.Close()
return err
}
days = append(days, key)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
for _, day := range days {
if err := recomputeSocialIdentityDay(ctx, tx, app, day.StatTZ, day.StatDay, deviceID, nowMS); err != nil {
return err
}
}
return nil
}
func recomputeSocialIdentityDay(ctx context.Context, tx *sql.Tx, app, statTZ, statDay, deviceID string, nowMS int64) error {
rows, err := tx.QueryContext(ctx, `
SELECT d.client_session_id, u.user_id
FROM stat_social_identity_session_day d
LEFT JOIN stat_social_identity_session_user u
ON u.app_code = d.app_code
AND u.device_id = d.device_id
AND u.client_session_id = d.client_session_id
WHERE d.app_code = ? AND d.stat_tz = ? AND d.stat_day = ? AND d.device_id = ?
ORDER BY d.client_session_id, u.user_id
FOR UPDATE
`, app, normalizeStatTZ(statTZ), statDay, deviceID)
if err != nil {
return err
}
sessionUsers := map[string]map[int64]struct{}{}
for rows.Next() {
var sessionID string
var userID sql.NullInt64
if err := rows.Scan(&sessionID, &userID); err != nil {
_ = rows.Close()
return err
}
if sessionUsers[sessionID] == nil {
sessionUsers[sessionID] = map[int64]struct{}{}
}
if userID.Valid && userID.Int64 > 0 {
sessionUsers[sessionID][userID.Int64] = struct{}{}
}
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
state, resolvedUserID := "unresolved", int64(0)
resolutionSource := "client_session"
ambiguous := false
unresolved := false
for _, users := range sessionUsers {
if len(users) == 0 {
unresolved = true
continue
}
if len(users) > 1 {
ambiguous = true
continue
}
for userID := range users {
if resolvedUserID == 0 {
resolvedUserID = userID
} else if resolvedUserID != userID {
ambiguous = true
}
}
}
if ambiguous {
state, resolvedUserID = "ambiguous", 0
} else if !unresolved && len(sessionUsers) > 0 && resolvedUserID > 0 {
state = "resolved"
} else {
resolvedUserID = 0
}
if len(sessionUsers) == 1 {
if _, onlyMissingSession := sessionUsers[missingClientSessionID]; onlyMissingSession {
// A legacy client cannot establish an exact session bridge. Keep that
// evidence conservative, but let a later bounded historical replay
// replace it instead of giving it permanent client-session authority.
resolutionSource = "missing_client_session"
}
}
_, err = tx.ExecContext(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 = VALUES(resolved_user_id),
resolution_state = VALUES(resolution_state),
resolution_source = VALUES(resolution_source),
updated_at_ms = VALUES(updated_at_ms)
`, app, normalizeStatTZ(statTZ), statDay, deviceID, resolvedUserID, state, resolutionSource, nowMS)
return err
}
func lockSocialIdentityDevice(ctx context.Context, tx *sql.Tx, app, deviceID string, nowMS int64) error {
if _, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO stat_social_identity_device_lock (app_code, device_id, created_at_ms)
VALUES (?, ?, ?)
`, appcode.Normalize(app), strings.TrimSpace(deviceID), nowMS); err != nil {
return err
}
var lockedDeviceID string
return tx.QueryRowContext(ctx, `
SELECT device_id
FROM stat_social_identity_device_lock
WHERE app_code = ? AND device_id = ?
FOR UPDATE
`, appcode.Normalize(app), strings.TrimSpace(deviceID)).Scan(&lockedDeviceID)
}