From f9e011b9cb79d967d23bada02684c0babe3ab639 Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 12 Jun 2026 20:37:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cmd/backfill-last7/main.go | 4 ++-- .../internal/service/auth/service_test.go | 22 +++++++++++++++++++ .../storage/mysql/auth/third_party.go | 4 ++++ .../internal/storage/mysql/user/repository.go | 5 +++-- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/services/statistics-service/cmd/backfill-last7/main.go b/services/statistics-service/cmd/backfill-last7/main.go index b5c79014..e9ecbb24 100644 --- a/services/statistics-service/cmd/backfill-last7/main.go +++ b/services/statistics-service/cmd/backfill-last7/main.go @@ -333,7 +333,7 @@ func loadUserDims(ctx context.Context, db *sql.DB, state *rebuildState, app stri func rebuildRegistrations(ctx context.Context, db *sql.DB, state *rebuildState, app string, startMS, endMS int64, loc *time.Location) error { // 注册国家以 users.country 映射出的真实 country_id 为准;region_id 只作为独立维度写入,不能再兜底成国家。 - // source=game_robot 是后台全站机器人池账号,不能进入新增用户、注册 cohort 或留存口径。 + // source=game_robot 是后台全站机器人池账号,source=quick_account 是后台快捷建号;两者都不是 App 自然注册,不能进入新增用户、注册 cohort 或留存口径。 rows, err := db.QueryContext(ctx, ` SELECT u.user_id, COALESCE(c.country_id, 0), COALESCE(u.region_id, 0), u.created_at_ms FROM users u @@ -341,7 +341,7 @@ func rebuildRegistrations(ctx context.Context, db *sql.DB, state *rebuildState, WHERE u.app_code = ? AND u.created_at_ms >= ? AND u.created_at_ms < ? - AND LOWER(COALESCE(u.source, '')) <> 'game_robot'`, + AND LOWER(COALESCE(u.source, '')) NOT IN ('game_robot', 'quick_account')`, app, startMS, endMS) if err != nil { return err diff --git a/services/user-service/internal/service/auth/service_test.go b/services/user-service/internal/service/auth/service_test.go index b9a77529..b0242b9b 100644 --- a/services/user-service/internal/service/auth/service_test.go +++ b/services/user-service/internal/service/auth/service_test.go @@ -127,6 +127,25 @@ func newAuthService(repository *mysqltest.Repository, now *time.Time, ids []int6 }, options...) } +func assertUserRegisteredOutboxCount(t *testing.T, repository *mysqltest.Repository, userID int64, want int) { + t.Helper() + var count int + var status string + if err := repository.RawDB().QueryRowContext(context.Background(), ` + SELECT COUNT(*), COALESCE(MAX(status), '') + FROM user_outbox + WHERE app_code = 'lalu' AND event_type = 'UserRegistered' AND aggregate_id = ? + `, userID).Scan(&count, &status); err != nil { + t.Fatalf("query UserRegistered outbox failed: %v", err) + } + if count != want { + t.Fatalf("UserRegistered outbox count for user %d = %d, want %d", userID, count, want) + } + if want > 0 && status != "pending" { + t.Fatalf("UserRegistered outbox status for user %d = %s, want pending", userID, status) + } +} + func thirdPartyRegistration(platform string) authdomain.ThirdPartyRegistration { // 部分测试需要覆盖注册快照字段,因此默认提供完整资料;完成态仍必须由 onboarding 流程推进。 return authdomain.ThirdPartyRegistration{ @@ -197,6 +216,7 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) { if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) { t.Fatalf("new third-party user must require onboarding: %+v", thirdToken) } + assertUserRegisteredOutboxCount(t, repository, thirdToken.UserID, 1) if _, err := svc.LoginPassword(ctx, "163000", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) { // 未设置密码前不能通过短号密码登录,且错误必须统一 AUTH_FAILED。 @@ -285,6 +305,7 @@ func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) { if imImporter.userID != 900011 || imImporter.nickname != "Lingxian Test" || imImporter.faceURL != "https://cdn.example/avatar.png" { t.Fatalf("quick account must import IM account: %+v", imImporter) } + assertUserRegisteredOutboxCount(t, repository, token.UserID, 0) loginToken, err := svc.LoginPassword(ctx, token.DisplayUserID, "secret-pass", "quick-dev", authservice.Meta{RequestID: "req-quick-login", AppCode: "lalu"}) if err != nil { @@ -343,6 +364,7 @@ func TestQuickCreateGameRobotCannotLoginOrCreateSession(t *testing.T) { if sessionCount != 0 { t.Fatalf("robot account must not create auth session, got %d", sessionCount) } + assertUserRegisteredOutboxCount(t, repository, token.UserID, 0) if _, err := svc.LoginPassword(ctx, token.DisplayUserID, "secret-pass", "robot-dev", authservice.Meta{RequestID: "req-robot-login", AppCode: "lalu"}); !xerr.IsCode(err, xerr.UserDisabled) { t.Fatalf("robot password login must be blocked, got %v", err) } diff --git a/services/user-service/internal/storage/mysql/auth/third_party.go b/services/user-service/internal/storage/mysql/auth/third_party.go index d681cdea..222dbd6d 100644 --- a/services/user-service/internal/storage/mysql/auth/third_party.go +++ b/services/user-service/internal/storage/mysql/auth/third_party.go @@ -84,6 +84,10 @@ func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.U // session 插入失败会回滚用户和三方绑定。 return err } + if err := userstorage.InsertUserRegisteredOutbox(ctx, tx, user); err != nil { + // UserRegistered 是统计新增用户、留存 cohort 和注册后异步读模型的唯一事实;它必须跟首次三方注册同事务提交,避免用户已经创建但统计链路永远收不到事件。 + return err + } return tx.Commit() } diff --git a/services/user-service/internal/storage/mysql/user/repository.go b/services/user-service/internal/storage/mysql/user/repository.go index 9ab24e4e..fd10951f 100644 --- a/services/user-service/internal/storage/mysql/user/repository.go +++ b/services/user-service/internal/storage/mysql/user/repository.go @@ -146,14 +146,15 @@ func (r *Repository) CreateUserWithIdentity(ctx context.Context, user userdomain if _, err := invitestorage.EnsurePrimaryCodeForUser(ctx, tx, user.AppCode, user.UserID, user.CreatedAtMs); err != nil { return err } - if err := insertUserRegisteredOutbox(ctx, tx, user); err != nil { + if err := InsertUserRegisteredOutbox(ctx, tx, user); err != nil { return err } return tx.Commit() } -func insertUserRegisteredOutbox(ctx context.Context, tx *sql.Tx, user userdomain.User) error { +// InsertUserRegisteredOutbox writes the durable UserRegistered fact in the same transaction that creates the user. +func InsertUserRegisteredOutbox(ctx context.Context, tx *sql.Tx, user userdomain.User) error { if userdomain.IsGameRobotRegisterSource(user.RegisterSource) { // 全站机器人只复用用户资料、头像和短号参与游戏展示,不是真实自然注册用户; // 这里不写 UserRegistered 事实,避免实时统计、留存 cohort 和注册奖励把批量机器人当新增用户。