This commit is contained in:
zhx 2026-06-12 20:37:23 +08:00
parent 75b867ca16
commit f9e011b9cb
4 changed files with 31 additions and 4 deletions

View File

@ -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

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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 和注册奖励把批量机器人当新增用户。