From 3b491ffb2600c8f68c7f4abf16057083cb2ba946 Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 12 Jun 2026 22:40:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=AC=E5=85=B1=E9=80=BB=E8=BE=91=E6=8A=BD?= =?UTF-8?q?=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/modules/appuser/ban_list.go | 14 +-- .../internal/modules/appuser/ban_list_test.go | 21 +++- .../internal/modules/appuser/login_logs.go | 13 ++- .../admin/internal/modules/appuser/service.go | 20 ++-- .../internal/modules/appuser/service_test.go | 71 +++++++++++++- .../internal/modules/coinledger/service.go | 60 +++++------- .../cumulativerechargereward/handler.go | 26 +++-- .../modules/firstrechargereward/handler.go | 26 +++-- .../admin/internal/modules/hostorg/reader.go | 39 ++++++-- .../modules/inviteactivityreward/handler.go | 26 +++-- .../modules/registrationreward/handler.go | 26 +++-- .../admin/internal/modules/report/service.go | 8 +- .../internal/modules/report/service_test.go | 26 ++++- .../internal/modules/resource/handler.go | 22 ++--- .../internal/modules/roomadmin/service.go | 11 ++- .../modules/sevendaycheckin/handler.go | 26 +++-- .../modules/shared/user_identity_sql.go | 95 +++++++++++++++++++ .../cmd/backfill-last7/main.go | 4 +- .../user-service/internal/domain/user/user.go | 8 ++ .../internal/service/auth/service_test.go | 27 +++++- .../internal/storage/mysql/user/business.go | 40 +++++++- .../internal/storage/mysql/user/repository.go | 39 +++++++- 22 files changed, 471 insertions(+), 177 deletions(-) create mode 100644 server/admin/internal/modules/shared/user_identity_sql.go diff --git a/server/admin/internal/modules/appuser/ban_list.go b/server/admin/internal/modules/appuser/ban_list.go index 3d12a795..7c4909cc 100644 --- a/server/admin/internal/modules/appuser/ban_list.go +++ b/server/admin/internal/modules/appuser/ban_list.go @@ -8,6 +8,7 @@ import ( "strings" "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/modules/shared" ) type AppUserBanRecord struct { @@ -117,15 +118,16 @@ func banListWhereSQL(ctx context.Context, query banListQuery, adminIDs []int64) LEFT JOIN users operator_user ON l.operator_type = 'app_user' AND operator_user.app_code = l.app_code AND operator_user.user_id = l.operator_user_id WHERE l.app_code = ? AND target.status IN ('banned', 'disabled')` args := []any{appCode, appCode} + nowMs := nowMillis() if query.TargetKeyword != "" { - like := "%" + query.TargetKeyword + "%" - whereSQL += " AND (CAST(target.user_id AS CHAR) LIKE ? OR target.current_display_user_id LIKE ? OR target.username LIKE ?)" - args = append(args, like, like, like) + matchSQL, matchArgs := shared.UserIdentityKeywordSQL("target", "target.user_id", query.TargetKeyword, nowMs) + whereSQL += " AND " + matchSQL + args = append(args, matchArgs...) } if query.OperatorKeyword != "" { - like := "%" + query.OperatorKeyword + "%" - operatorClauses := []string{"(l.operator_type = 'app_user' AND (CAST(operator_user.user_id AS CHAR) LIKE ? OR operator_user.current_display_user_id LIKE ? OR operator_user.username LIKE ?))"} - args = append(args, like, like, like) + matchSQL, matchArgs := shared.UserIdentityKeywordSQL("operator_user", "operator_user.user_id", query.OperatorKeyword, nowMs) + operatorClauses := []string{"(l.operator_type = 'app_user' AND " + matchSQL + ")"} + args = append(args, matchArgs...) if len(adminIDs) > 0 { operatorClauses = append(operatorClauses, "(l.operator_type = 'admin' AND l.operator_user_id IN ("+placeholders(len(adminIDs))+"))") for _, id := range adminIDs { diff --git a/server/admin/internal/modules/appuser/ban_list_test.go b/server/admin/internal/modules/appuser/ban_list_test.go index 4ad946a7..00d80fbc 100644 --- a/server/admin/internal/modules/appuser/ban_list_test.go +++ b/server/admin/internal/modules/appuser/ban_list_test.go @@ -15,20 +15,25 @@ func TestBanListWhereSQLBuildsTargetKeywordFilter(t *testing.T) { "target.status IN ('banned', 'disabled')", "CAST(target.user_id AS CHAR) LIKE ?", "target.current_display_user_id LIKE ?", + "target.default_display_user_id LIKE ?", "target.username LIKE ?", + "pretty_display_user_id_leases lease", } { if !strings.Contains(whereSQL, fragment) { t.Fatalf("where sql missing %q: %s", fragment, whereSQL) } } - if len(args) != 5 { - t.Fatalf("args length = %d, want 5: %#v", len(args), args) + if len(args) != 8 { + t.Fatalf("args length = %d, want 8: %#v", len(args), args) } - for index := 2; index < len(args); index++ { + for _, index := range []int{2, 3, 4, 5, 7} { if args[index] != "%10086%" { t.Fatalf("target keyword arg[%d] = %#v, want %%10086%%", index, args[index]) } } + if _, ok := args[6].(int64); !ok { + t.Fatalf("pretty lease expiry arg type = %T, want int64: %+v", args[6], args) + } } func TestBanListWhereSQLBuildsOperatorKeywordFilter(t *testing.T) { @@ -41,6 +46,8 @@ func TestBanListWhereSQLBuildsOperatorKeywordFilter(t *testing.T) { for _, fragment := range []string{ "l.operator_type = 'app_user'", "operator_user.current_display_user_id LIKE ?", + "operator_user.default_display_user_id LIKE ?", + "pretty_display_user_id_leases lease", "(l.operator_type = 'admin' AND l.operator_user_id IN (?,?))", "l.created_at_ms >= ?", "l.created_at_ms < ?", @@ -49,11 +56,17 @@ func TestBanListWhereSQLBuildsOperatorKeywordFilter(t *testing.T) { t.Fatalf("where sql missing %q: %s", fragment, whereSQL) } } - wantArgs := []any{"lalu", "lalu", "%admin%", "%admin%", "%admin%", int64(7), int64(9), int64(1000), int64(2000)} + wantArgs := []any{"lalu", "lalu", "%admin%", "%admin%", "%admin%", "%admin%", int64(0), "%admin%", int64(7), int64(9), int64(1000), int64(2000)} if len(args) != len(wantArgs) { t.Fatalf("args length = %d, want %d: %#v", len(args), len(wantArgs), args) } for index := range wantArgs { + if index == 6 { + if _, ok := args[index].(int64); !ok { + t.Fatalf("arg[%d] type = %T, want int64: %#v", index, args[index], args) + } + continue + } if args[index] != wantArgs[index] { t.Fatalf("arg[%d] = %#v, want %#v", index, args[index], wantArgs[index]) } diff --git a/server/admin/internal/modules/appuser/login_logs.go b/server/admin/internal/modules/appuser/login_logs.go index dc2b5a61..81648311 100644 --- a/server/admin/internal/modules/appuser/login_logs.go +++ b/server/admin/internal/modules/appuser/login_logs.go @@ -8,6 +8,7 @@ import ( "strings" "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/modules/shared" ) type AppLoginLog struct { @@ -97,13 +98,15 @@ func loginLogWhereSQL(ctx context.Context, query loginLogQuery) (string, []any) LEFT JOIN users u ON u.app_code = la.app_code AND u.user_id = la.user_id WHERE la.app_code = ?` args := []any{appctx.FromContext(ctx)} + nowMs := nowMillis() if query.UserID > 0 { whereSQL += " AND la.user_id = ?" args = append(args, query.UserID) } if query.DisplayUserID != "" { - whereSQL += " AND u.current_display_user_id = ?" - args = append(args, query.DisplayUserID) + matchSQL, matchArgs := shared.UserDisplayIDSQL("u", query.DisplayUserID, nowMs) + whereSQL += " AND " + matchSQL + args = append(args, matchArgs...) } if query.IP != "" { whereSQL += " AND la.client_ip = ?" @@ -145,8 +148,10 @@ func loginLogWhereSQL(ctx context.Context, query loginLogQuery) (string, []any) } if query.Keyword != "" { like := "%" + query.Keyword + "%" - whereSQL += " AND (CAST(la.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR la.client_ip LIKE ?)" - args = append(args, like, like, like, like) + matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "la.user_id", query.Keyword, nowMs) + whereSQL += " AND (" + matchSQL + " OR la.client_ip LIKE ?)" + args = append(args, matchArgs...) + args = append(args, like) } return whereSQL, args } diff --git a/server/admin/internal/modules/appuser/service.go b/server/admin/internal/modules/appuser/service.go index 91b5895b..a5ce32d8 100644 --- a/server/admin/internal/modules/appuser/service.go +++ b/server/admin/internal/modules/appuser/service.go @@ -14,6 +14,7 @@ import ( "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/userclient" + "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/security" activityv1 "hyapp.local/api/proto/activity/v1" ) @@ -76,7 +77,8 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in } query = normalizeListQuery(query) appCode := appctx.FromContext(ctx) - whereSQL, args := appUserListWhereSQL(appCode, query) + nowMs := nowMillis() + whereSQL, args := appUserListWhereSQLAt(appCode, query, nowMs) total, err := countRows(ctx, s.userDB, whereSQL, args...) if err != nil { @@ -84,10 +86,9 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in } if query.SortBy == "coin" { // 金币余额来自 wallet 库,必须先补齐当前筛选结果的余额再排序分页,避免只按当前页局部重排。 - items, err := s.listUsersSortedByCoin(ctx, query, whereSQL, args) + items, err := s.listUsersSortedByCoin(ctx, query, whereSQL, args, nowMs) return items, total, err } - nowMs := nowMillis() rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(` SELECT u.user_id, u.current_display_user_id, @@ -148,8 +149,7 @@ func (s *Service) ListUsers(ctx context.Context, query listQuery) ([]AppUser, in return items, total, nil } -func (s *Service) listUsersSortedByCoin(ctx context.Context, query listQuery, whereSQL string, args []any) ([]AppUser, error) { - nowMs := nowMillis() +func (s *Service) listUsersSortedByCoin(ctx context.Context, query listQuery, whereSQL string, args []any, nowMs int64) ([]AppUser, error) { rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(` SELECT u.user_id, u.current_display_user_id, @@ -589,6 +589,10 @@ func normalizeListQuery(query listQuery) listQuery { } func appUserListWhereSQL(appCode string, query listQuery) (string, []any) { + return appUserListWhereSQLAt(appCode, query, nowMillis()) +} + +func appUserListWhereSQLAt(appCode string, query listQuery, nowMs int64) (string, []any) { whereSQL := "FROM users u WHERE u.app_code = ?" args := []any{appCode} if query.Status != "" { @@ -596,9 +600,9 @@ func appUserListWhereSQL(appCode string, query listQuery) (string, []any) { args = append(args, query.Status) } if query.Keyword != "" { - like := "%" + query.Keyword + "%" - whereSQL += " AND (CAST(u.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ?)" - args = append(args, like, like, like) + matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "u.user_id", query.Keyword, nowMs) + whereSQL += " AND " + matchSQL + args = append(args, matchArgs...) } if query.Country != "" { // 国家筛选用 users.country 的 ISO code 精确命中;前端传展示名时不会被猜测转换,避免误筛到错误国家。 diff --git a/server/admin/internal/modules/appuser/service_test.go b/server/admin/internal/modules/appuser/service_test.go index 070f222b..d7ec538f 100644 --- a/server/admin/internal/modules/appuser/service_test.go +++ b/server/admin/internal/modules/appuser/service_test.go @@ -3,6 +3,8 @@ package appuser import ( "strings" "testing" + + "hyapp-admin-server/internal/modules/shared" ) func TestNormalizeListQuerySort(t *testing.T) { @@ -36,6 +38,8 @@ func TestAppUserListWhereSQLFilters(t *testing.T) { "u.status = ?", "CAST(u.user_id AS CHAR) LIKE ?", "UPPER(u.country) = ?", + "u.default_display_user_id LIKE ?", + "pretty_display_user_id_leases lease", "u.region_id = ?", "u.created_at_ms >= ?", "u.created_at_ms < ?", @@ -44,9 +48,22 @@ func TestAppUserListWhereSQLFilters(t *testing.T) { t.Fatalf("where sql missing %q: %s", want, whereSQL) } } - if len(args) != 9 || args[0] != "lalu" || args[5] != "PH" || args[6] != int64(3) || args[7] != int64(1000) || args[8] != int64(2000) { + if len(args) != 12 || + args[0] != "lalu" || + args[2] != "%hunter%" || + args[3] != "%hunter%" || + args[4] != "%hunter%" || + args[5] != "%hunter%" || + args[7] != "%hunter%" || + args[8] != "PH" || + args[9] != int64(3) || + args[10] != int64(1000) || + args[11] != int64(2000) { t.Fatalf("where args mismatch: %+v", args) } + if _, ok := args[6].(int64); !ok { + t.Fatalf("pretty lease expiry arg type = %T, want int64: %+v", args[6], args) + } whereSQL, args = appUserListWhereSQL("lalu", normalizeListQuery(listQuery{RegionID: 0, RegionIDSet: true})) if !strings.Contains(whereSQL, "COALESCE(u.region_id, 0) = 0") || !strings.Contains(whereSQL, "NOT EXISTS") { @@ -57,6 +74,58 @@ func TestAppUserListWhereSQLFilters(t *testing.T) { } } +func TestAppUserDisplayIDSQLMatchesDefaultCurrentAndPrettyLease(t *testing.T) { + whereSQL, args := shared.UserDisplayIDSQL("u", "bd100", 12345) + for _, want := range []string{ + "u.current_display_user_id = ?", + "u.default_display_user_id = ?", + "pretty_display_user_id_leases lease", + "lease.display_user_id = ?", + "lease.expires_at_ms > ?", + } { + if !strings.Contains(whereSQL, want) { + t.Fatalf("where sql missing %q: %s", want, whereSQL) + } + } + wantArgs := []any{"bd100", "bd100", int64(12345), "bd100"} + if len(args) != len(wantArgs) { + t.Fatalf("args length = %d, want %d: %#v", len(args), len(wantArgs), args) + } + for index := range wantArgs { + if args[index] != wantArgs[index] { + t.Fatalf("arg[%d] = %#v, want %#v", index, args[index], wantArgs[index]) + } + } +} + +func TestLoginLogWhereSQLUsesSharedUserKeywordFilter(t *testing.T) { + whereSQL, args := loginLogWhereSQL(nil, loginLogQuery{ + Keyword: "bd100", + DisplayUserID: "vip888", + }) + for _, want := range []string{ + "u.current_display_user_id = ?", + "u.default_display_user_id = ?", + "CAST(la.user_id AS CHAR) LIKE ?", + "u.default_display_user_id LIKE ?", + "pretty_display_user_id_leases lease", + "la.client_ip LIKE ?", + } { + if !strings.Contains(whereSQL, want) { + t.Fatalf("where sql missing %q: %s", want, whereSQL) + } + } + if len(args) != 12 || args[1] != "vip888" || args[2] != "vip888" || args[4] != "vip888" || args[5] != "%bd100%" || args[11] != "%bd100%" { + t.Fatalf("args mismatch: %+v", args) + } + if _, ok := args[3].(int64); !ok { + t.Fatalf("display id pretty expiry arg type = %T, want int64: %+v", args[3], args) + } + if _, ok := args[9].(int64); !ok { + t.Fatalf("keyword pretty expiry arg type = %T, want int64: %+v", args[9], args) + } +} + func TestAppUserOrderSQL(t *testing.T) { asc := appUserOrderSQL(listQuery{SortBy: "created_at", SortDirection: "asc"}) if asc != "ORDER BY u.created_at_ms ASC, u.user_id ASC" { diff --git a/server/admin/internal/modules/coinledger/service.go b/server/admin/internal/modules/coinledger/service.go index 8e44247b..caf8e1b8 100644 --- a/server/admin/internal/modules/coinledger/service.go +++ b/server/admin/internal/modules/coinledger/service.go @@ -13,6 +13,7 @@ import ( "time" "hyapp-admin-server/internal/integration/walletclient" + "hyapp-admin-server/internal/modules/shared" walletv1 "hyapp.local/api/proto/wallet/v1" ) @@ -442,20 +443,20 @@ func (s *Service) LookupCoinAdjustmentTarget(ctx context.Context, appCode string if s == nil || s.userDB == nil { return coinLedgerUserDTO{}, fmt.Errorf("user mysql is not configured") } + nowMs := time.Now().UnixMilli() + matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs) + orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs) + args := append([]any{appCode}, matchArgs...) + args = append(args, orderArgs...) row := s.userDB.QueryRowContext(ctx, ` - SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '') - FROM users - WHERE app_code = ? - AND (CAST(user_id AS CHAR) = ? OR current_display_user_id = ?) + SELECT u.user_id, u.current_display_user_id, COALESCE(u.username, ''), COALESCE(u.avatar, '') + FROM users u + WHERE u.app_code = ? AND `+matchSQL+` ORDER BY - CASE - WHEN CAST(user_id AS CHAR) = ? THEN 0 - WHEN current_display_user_id = ? THEN 1 - ELSE 2 - END, - user_id DESC + `+orderSQL+`, + u.user_id DESC LIMIT 1`, - appCode, keyword, keyword, keyword, keyword, + args..., ) var profile userProfile if err := row.Scan(&profile.UserID, &profile.DisplayUserID, &profile.Username, &profile.Avatar); err != nil { @@ -545,22 +546,15 @@ func (s *Service) resolveUserFilter(ctx context.Context, appCode string, keyword return nil, true, fmt.Errorf("user mysql is not configured") } - // 用户列筛选同时支持长 ID、当前短 ID 和昵称;数字关键字直接加入 user_id,避免用户资料缺失时查不到账务事实。 - args := []any{appCode} - where := "WHERE app_code = ? AND (current_display_user_id = ?" - args = append(args, keyword) - if numeric, err := strconv.ParseInt(keyword, 10, 64); err == nil && numeric > 0 { - where += " OR user_id = ?" - args = append(args, numeric) - } - where += " OR username LIKE ? ESCAPE '\\\\')" - args = append(args, "%"+escapeLike(keyword)+"%") + // 用户列筛选同时支持长 ID、默认短号、当前短号、active 靓号和昵称;数字关键字直接加入 user_id,避免用户资料缺失时查不到账务事实。 + matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, time.Now().UnixMilli()) + args := append([]any{appCode}, matchArgs...) rows, err := s.userDB.QueryContext(ctx, ` - SELECT user_id - FROM users - `+where+` - ORDER BY updated_at_ms DESC, user_id DESC + SELECT u.user_id + FROM users u + WHERE u.app_code = ? AND `+matchSQL+` + ORDER BY u.updated_at_ms DESC, u.user_id DESC LIMIT 1000`, args..., ) @@ -595,24 +589,16 @@ func (s *Service) resolveCoinSellerFilter(ctx context.Context, appCode string, q return nil, true, fmt.Errorf("user mysql is not configured") } - // 币商关键字只命中 coin_seller_profiles 里的币商本人,LEFT JOIN users 只用于短 ID 和昵称补充; + // 币商关键字只命中 coin_seller_profiles 里的币商本人,LEFT JOIN users 只用于短 ID、active 靓号和昵称补充; // 这里故意不搜索 receiver/counterparty,避免“收款用户命中”把其他币商的流水带进当前币商筛选结果。 - args := []any{appCode} - where := "WHERE csp.app_code = ? AND (u.current_display_user_id = ?" - args = append(args, keyword) - if numeric, err := strconv.ParseInt(keyword, 10, 64); err == nil && numeric > 0 { - // 数字关键字按币商长 ID 精确匹配,不做 LIKE,防止短数字造成过宽的 user_id 扫描。 - where += " OR csp.user_id = ?" - args = append(args, numeric) - } - where += " OR u.username LIKE ? ESCAPE '\\\\')" - args = append(args, "%"+escapeLike(keyword)+"%") + matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "csp.user_id", keyword, time.Now().UnixMilli()) + args := append([]any{appCode}, matchArgs...) rows, err := s.userDB.QueryContext(ctx, ` SELECT csp.user_id FROM coin_seller_profiles csp LEFT JOIN users u ON u.app_code = csp.app_code AND u.user_id = csp.user_id - `+where+` + WHERE csp.app_code = ? AND `+matchSQL+` ORDER BY csp.updated_at_ms DESC, csp.user_id DESC LIMIT 1000`, args..., diff --git a/server/admin/internal/modules/cumulativerechargereward/handler.go b/server/admin/internal/modules/cumulativerechargereward/handler.go index 8b4aa4d4..1f468b34 100644 --- a/server/admin/internal/modules/cumulativerechargereward/handler.go +++ b/server/admin/internal/modules/cumulativerechargereward/handler.go @@ -181,24 +181,20 @@ func (h *Handler) resolveGrantUserID(ctx context.Context, keyword string) (int64 } // keyword 支持真实 user_id、短 ID 和昵称模糊匹配;排序优先精确 ID,避免昵称碰撞查到错误用户。 appCode := appctx.FromContext(ctx) + nowMs := time.Now().UnixMilli() + matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs) + orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs) + args := append([]any{appCode}, matchArgs...) + args = append(args, orderArgs...) rows, err := h.userDB.QueryContext(ctx, ` - SELECT user_id - FROM users - WHERE app_code = ? - AND ( - CAST(user_id AS CHAR) = ? - OR current_display_user_id = ? - OR username LIKE ? - ) + SELECT u.user_id + FROM users u + WHERE u.app_code = ? AND `+matchSQL+` ORDER BY - CASE - WHEN CAST(user_id AS CHAR) = ? THEN 0 - WHEN current_display_user_id = ? THEN 1 - ELSE 2 - END, - user_id DESC + `+orderSQL+`, + u.user_id DESC LIMIT 1`, - appCode, keyword, keyword, "%"+keyword+"%", keyword, keyword, + args..., ) if err != nil { return 0, false, false diff --git a/server/admin/internal/modules/firstrechargereward/handler.go b/server/admin/internal/modules/firstrechargereward/handler.go index 8416b1bf..8a80b111 100644 --- a/server/admin/internal/modules/firstrechargereward/handler.go +++ b/server/admin/internal/modules/firstrechargereward/handler.go @@ -185,24 +185,20 @@ func (h *Handler) resolveClaimUserID(ctx context.Context, keyword string) (int64 return 0, false, true } appCode := appctx.FromContext(ctx) + nowMs := time.Now().UnixMilli() + matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs) + orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs) + args := append([]any{appCode}, matchArgs...) + args = append(args, orderArgs...) rows, err := h.userDB.QueryContext(ctx, ` - SELECT user_id - FROM users - WHERE app_code = ? - AND ( - CAST(user_id AS CHAR) = ? - OR current_display_user_id = ? - OR username LIKE ? - ) + SELECT u.user_id + FROM users u + WHERE u.app_code = ? AND `+matchSQL+` ORDER BY - CASE - WHEN CAST(user_id AS CHAR) = ? THEN 0 - WHEN current_display_user_id = ? THEN 1 - ELSE 2 - END, - user_id DESC + `+orderSQL+`, + u.user_id DESC LIMIT 1`, - appCode, keyword, keyword, "%"+keyword+"%", keyword, keyword, + args..., ) if err != nil { return 0, false, false diff --git a/server/admin/internal/modules/hostorg/reader.go b/server/admin/internal/modules/hostorg/reader.go index 16a29c80..1f35efee 100644 --- a/server/admin/internal/modules/hostorg/reader.go +++ b/server/admin/internal/modules/hostorg/reader.go @@ -10,6 +10,7 @@ import ( "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/userclient" + "hyapp-admin-server/internal/modules/shared" ) type Reader struct { @@ -111,8 +112,10 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { like := "%" + keyword + "%" - whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR user_region.name LIKE ?)" - args = append(args, like, like, like, like) + matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "bp.user_id", keyword, time.Now().UnixMilli()) + whereSQL += " AND (" + matchSQL + " OR user_region.name LIKE ?)" + args = append(args, matchArgs...) + args = append(args, like) } total, err := countRows(ctx, r.db, whereSQL, args...) @@ -209,8 +212,10 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]* } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { like := "%" + keyword + "%" - whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR user_region.name LIKE ?)" - args = append(args, like, like, like, like) + matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "bp.user_id", keyword, time.Now().UnixMilli()) + whereSQL += " AND (" + matchSQL + " OR user_region.name LIKE ?)" + args = append(args, matchArgs...) + args = append(args, like) } total, err := countRows(ctx, r.db, whereSQL, args...) @@ -551,8 +556,14 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { like := "%" + keyword + "%" - whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR CAST(a.owner_user_id AS CHAR) LIKE ? OR owner.current_display_user_id LIKE ? OR owner.username LIKE ? OR CAST(a.parent_bd_user_id AS CHAR) LIKE ? OR parent_bd.current_display_user_id LIKE ? OR parent_bd.username LIKE ? OR owner_region.name LIKE ?)" - args = append(args, like, like, like, like, like, like, like, like, like) + nowMs := time.Now().UnixMilli() + ownerSQL, ownerArgs := shared.UserIdentityKeywordSQL("owner", "a.owner_user_id", keyword, nowMs) + parentSQL, parentArgs := shared.UserIdentityKeywordSQL("parent_bd", "a.parent_bd_user_id", keyword, nowMs) + whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR " + ownerSQL + " OR " + parentSQL + " OR owner_region.name LIKE ?)" + args = append(args, like, like) + args = append(args, ownerArgs...) + args = append(args, parentArgs...) + args = append(args, like) } total, err := countRows(ctx, r.db, whereSQL, args...) @@ -634,8 +645,14 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { like := "%" + keyword + "%" - whereSQL += " AND (CAST(hp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR a.name LIKE ? OR agency_owner.current_display_user_id LIKE ? OR agency_owner.username LIKE ? OR user_region.name LIKE ?)" - args = append(args, like, like, like, like, like, like, like) + nowMs := time.Now().UnixMilli() + hostSQL, hostArgs := shared.UserIdentityKeywordSQL("u", "hp.user_id", keyword, nowMs) + agencyOwnerSQL, agencyOwnerArgs := shared.UserIdentityKeywordSQL("agency_owner", "a.owner_user_id", keyword, nowMs) + whereSQL += " AND (" + hostSQL + " OR a.name LIKE ? OR " + agencyOwnerSQL + " OR user_region.name LIKE ?)" + args = append(args, hostArgs...) + args = append(args, like) + args = append(args, agencyOwnerArgs...) + args = append(args, like) } total, err := countRows(ctx, r.db, whereSQL, args...) @@ -736,8 +753,10 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { like := "%" + keyword + "%" - whereSQL += " AND (CAST(csp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR csp.contact_info LIKE ?)" - args = append(args, like, like, like, like) + matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "csp.user_id", keyword, time.Now().UnixMilli()) + whereSQL += " AND (" + matchSQL + " OR csp.contact_info LIKE ?)" + args = append(args, matchArgs...) + args = append(args, like) } total, err := countRows(ctx, r.db, whereSQL, args...) diff --git a/server/admin/internal/modules/inviteactivityreward/handler.go b/server/admin/internal/modules/inviteactivityreward/handler.go index f4109c68..925ad584 100644 --- a/server/admin/internal/modules/inviteactivityreward/handler.go +++ b/server/admin/internal/modules/inviteactivityreward/handler.go @@ -241,24 +241,20 @@ func (h *Handler) resolveUserID(ctx context.Context, keyword string) (int64, boo return 0, false, true } appCode := appctx.FromContext(ctx) + nowMs := time.Now().UnixMilli() + matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs) + orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs) + args := append([]any{appCode}, matchArgs...) + args = append(args, orderArgs...) rows, err := h.userDB.QueryContext(ctx, ` - SELECT user_id - FROM users - WHERE app_code = ? - AND ( - CAST(user_id AS CHAR) = ? - OR current_display_user_id = ? - OR username LIKE ? - ) + SELECT u.user_id + FROM users u + WHERE u.app_code = ? AND `+matchSQL+` ORDER BY - CASE - WHEN CAST(user_id AS CHAR) = ? THEN 0 - WHEN current_display_user_id = ? THEN 1 - ELSE 2 - END, - user_id DESC + `+orderSQL+`, + u.user_id DESC LIMIT 1`, - appCode, keyword, keyword, "%"+keyword+"%", keyword, keyword, + args..., ) if err != nil { return 0, false, false diff --git a/server/admin/internal/modules/registrationreward/handler.go b/server/admin/internal/modules/registrationreward/handler.go index d08a4b83..de3c391a 100644 --- a/server/admin/internal/modules/registrationreward/handler.go +++ b/server/admin/internal/modules/registrationreward/handler.go @@ -191,24 +191,20 @@ func (h *Handler) resolveClaimUserID(ctx context.Context, keyword string) (int64 return 0, false, true } appCode := appctx.FromContext(ctx) + nowMs := time.Now().UnixMilli() + matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs) + orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs) + args := append([]any{appCode}, matchArgs...) + args = append(args, orderArgs...) rows, err := h.userDB.QueryContext(ctx, ` - SELECT user_id - FROM users - WHERE app_code = ? - AND ( - CAST(user_id AS CHAR) = ? - OR current_display_user_id = ? - OR username LIKE ? - ) + SELECT u.user_id + FROM users u + WHERE u.app_code = ? AND `+matchSQL+` ORDER BY - CASE - WHEN CAST(user_id AS CHAR) = ? THEN 0 - WHEN current_display_user_id = ? THEN 1 - ELSE 2 - END, - user_id DESC + `+orderSQL+`, + u.user_id DESC LIMIT 1`, - appCode, keyword, keyword, "%"+keyword+"%", keyword, keyword, + args..., ) if err != nil { return 0, false, false diff --git a/server/admin/internal/modules/report/service.go b/server/admin/internal/modules/report/service.go index 46557fee..f709ea84 100644 --- a/server/admin/internal/modules/report/service.go +++ b/server/admin/internal/modules/report/service.go @@ -7,8 +7,10 @@ import ( "fmt" "strconv" "strings" + "time" "hyapp-admin-server/internal/integration/roomclient" + "hyapp-admin-server/internal/modules/shared" ) const ( @@ -177,8 +179,10 @@ func reportWhere(appCode string, query listQuery) (string, []any) { } if query.Keyword != "" { like := "%" + query.Keyword + "%" - conditions = append(conditions, `(r.report_id LIKE ? OR r.room_id LIKE ? OR CAST(r.target_user_id AS CHAR) LIKE ? OR CAST(r.reporter_user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.default_display_user_id LIKE ? OR u.username LIKE ?)`) - args = append(args, like, like, like, like, like, like, like) + targetSQL, targetArgs := shared.UserIdentityKeywordSQL("u", "r.target_user_id", query.Keyword, time.Now().UnixMilli()) + conditions = append(conditions, `(r.report_id LIKE ? OR r.room_id LIKE ? OR CAST(r.reporter_user_id AS CHAR) LIKE ? OR `+targetSQL+`)`) + args = append(args, like, like, like) + args = append(args, targetArgs...) } return "WHERE " + strings.Join(conditions, " AND "), args } diff --git a/server/admin/internal/modules/report/service_test.go b/server/admin/internal/modules/report/service_test.go index f357855f..171e84ae 100644 --- a/server/admin/internal/modules/report/service_test.go +++ b/server/admin/internal/modules/report/service_test.go @@ -1,6 +1,9 @@ package report -import "testing" +import ( + "strings" + "testing" +) func TestNormalizeListQueryCapsPageSizeAndFilters(t *testing.T) { query := normalizeListQuery(listQuery{Page: -1, PageSize: 1000, Keyword: " abc ", TargetType: "用户", ReportType: " Fraud "}) @@ -18,13 +21,26 @@ func TestReportWhereUsesStableFilters(t *testing.T) { EndAtMS: 200, } where, args := reportWhere("lalu", query) - want := "WHERE r.app_code = ? AND r.target_type = ? AND r.report_type = ? AND r.created_at_ms >= ? AND r.created_at_ms < ? AND (r.report_id LIKE ? OR r.room_id LIKE ? OR CAST(r.target_user_id AS CHAR) LIKE ? OR CAST(r.reporter_user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.default_display_user_id LIKE ? OR u.username LIKE ?)" - if where != want { - t.Fatalf("where mismatch:\nwant %s\n got %s", want, where) + for _, want := range []string{ + "WHERE r.app_code = ? AND r.target_type = ?", + "r.report_id LIKE ?", + "r.room_id LIKE ?", + "CAST(r.reporter_user_id AS CHAR) LIKE ?", + "CAST(r.target_user_id AS CHAR) LIKE ?", + "u.current_display_user_id LIKE ?", + "u.default_display_user_id LIKE ?", + "pretty_display_user_id_leases lease", + } { + if !strings.Contains(where, want) { + t.Fatalf("where missing %q: %s", want, where) + } } - if len(args) != 12 || args[0] != "lalu" || args[1] != targetTypeRoom || args[2] != "fraud" || args[3] != int64(100) || args[4] != int64(200) || args[5] != "%10001%" || args[11] != "%10001%" { + if len(args) != 14 || args[0] != "lalu" || args[1] != targetTypeRoom || args[2] != "fraud" || args[3] != int64(100) || args[4] != int64(200) || args[5] != "%10001%" || args[13] != "%10001%" { t.Fatalf("args mismatch: %#v", args) } + if _, ok := args[12].(int64); !ok { + t.Fatalf("pretty lease expiry arg type = %T, want int64: %#v", args[12], args) + } } func TestParseImageURLsNormalizesEmptyJSON(t *testing.T) { diff --git a/server/admin/internal/modules/resource/handler.go b/server/admin/internal/modules/resource/handler.go index 8f4cd306..b8e23965 100644 --- a/server/admin/internal/modules/resource/handler.go +++ b/server/admin/internal/modules/resource/handler.go @@ -613,20 +613,20 @@ func (h *Handler) LookupResourceGrantTarget(c *gin.Context) { } // 资源赠送页允许输入短号,但 wallet 只认内部 user_id;这里在 admin 侧先解析, // 避免前端把短号直接发给 wallet,也避免 JS number 对长 user_id 造成精度损失。 + nowMs := time.Now().UnixMilli() + matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs) + orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs) + args := append([]any{appctx.FromContext(c.Request.Context())}, matchArgs...) + args = append(args, orderArgs...) row := h.userDB.QueryRowContext(c.Request.Context(), ` - SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '') - FROM users - WHERE app_code = ? - AND (CAST(user_id AS CHAR) = ? OR current_display_user_id = ?) + SELECT u.user_id, u.current_display_user_id, COALESCE(u.username, ''), COALESCE(u.avatar, '') + FROM users u + WHERE u.app_code = ? AND `+matchSQL+` ORDER BY - CASE - WHEN CAST(user_id AS CHAR) = ? THEN 0 - WHEN current_display_user_id = ? THEN 1 - ELSE 2 - END, - user_id DESC + `+orderSQL+`, + u.user_id DESC LIMIT 1`, - appctx.FromContext(c.Request.Context()), keyword, keyword, keyword, keyword, + args..., ) user := grantUserDTO{} if err := row.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil { diff --git a/server/admin/internal/modules/roomadmin/service.go b/server/admin/internal/modules/roomadmin/service.go index 01dc950c..0d749e5c 100644 --- a/server/admin/internal/modules/roomadmin/service.go +++ b/server/admin/internal/modules/roomadmin/service.go @@ -7,6 +7,7 @@ import ( "fmt" "strconv" "strings" + "time" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -114,13 +115,15 @@ func (s *Service) queryOwnerUserIDByDisplayID(ctx context.Context, displayUserID if displayUserID == "" || s.userDB == nil { return 0, false, nil } + matchSQL, matchArgs := shared.UserDisplayIDSQL("u", displayUserID, time.Now().UnixMilli()) + args := append([]any{appctx.FromContext(ctx)}, matchArgs...) var userID int64 err := s.userDB.QueryRowContext(ctx, ` - SELECT user_id - FROM users - WHERE app_code = ? AND current_display_user_id = ? + SELECT u.user_id + FROM users u + WHERE u.app_code = ? AND `+matchSQL+` LIMIT 1 - `, appctx.FromContext(ctx), displayUserID).Scan(&userID) + `, args...).Scan(&userID) if errors.Is(err, sql.ErrNoRows) { return 0, false, nil } diff --git a/server/admin/internal/modules/sevendaycheckin/handler.go b/server/admin/internal/modules/sevendaycheckin/handler.go index f46d6b84..61f3a9df 100644 --- a/server/admin/internal/modules/sevendaycheckin/handler.go +++ b/server/admin/internal/modules/sevendaycheckin/handler.go @@ -167,24 +167,20 @@ func (h *Handler) resolveClaimUserID(ctx context.Context, keyword string) (int64 return 0, false, true } appCode := appctx.FromContext(ctx) + nowMs := time.Now().UnixMilli() + matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs) + orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs) + args := append([]any{appCode}, matchArgs...) + args = append(args, orderArgs...) rows, err := h.userDB.QueryContext(ctx, ` - SELECT user_id - FROM users - WHERE app_code = ? - AND ( - CAST(user_id AS CHAR) = ? - OR current_display_user_id = ? - OR username LIKE ? - ) + SELECT u.user_id + FROM users u + WHERE u.app_code = ? AND `+matchSQL+` ORDER BY - CASE - WHEN CAST(user_id AS CHAR) = ? THEN 0 - WHEN current_display_user_id = ? THEN 1 - ELSE 2 - END, - user_id DESC + `+orderSQL+`, + u.user_id DESC LIMIT 1`, - appCode, keyword, keyword, "%"+keyword+"%", keyword, keyword, + args..., ) if err != nil { return 0, false, false diff --git a/server/admin/internal/modules/shared/user_identity_sql.go b/server/admin/internal/modules/shared/user_identity_sql.go new file mode 100644 index 00000000..d47abe1b --- /dev/null +++ b/server/admin/internal/modules/shared/user_identity_sql.go @@ -0,0 +1,95 @@ +package shared + +import ( + "fmt" + "strings" +) + +func UserIdentityKeywordSQL(userAlias string, userIDExpr string, keyword string, nowMs int64) (string, []any) { + keyword = strings.TrimSpace(keyword) + if keyword == "" { + return "1 = 1", nil + } + like := "%" + keyword + "%" + // 后台表格里的用户搜索统一覆盖长 user_id、当前展示号、默认短号、昵称和 active 靓号租约。 + // 靓号不能只依赖 users.current_display_user_id;历史修复、本地导入或过期恢复可能让 users 快照和租约审计表短暂不一致。 + return fmt.Sprintf(`(CAST(%[2]s AS CHAR) LIKE ? + OR %[1]s.current_display_user_id LIKE ? + OR %[1]s.default_display_user_id LIKE ? + OR %[1]s.username LIKE ? + OR %[3]s)`, userAlias, userIDExpr, ActivePrettyDisplayIDSQL(userAlias, "LIKE ?")), []any{ + like, + like, + like, + like, + nowMs, + like, + } +} + +func UserIdentityExactSQL(userAlias string, userIDExpr string, keyword string, nowMs int64) (string, []any) { + keyword = strings.TrimSpace(keyword) + if keyword == "" { + return "1 = 1", nil + } + like := "%" + keyword + "%" + // 单用户解析先做精确 user_id/短号/靓号命中,再允许昵称模糊兜底,避免运营输入靓号时被当成普通昵称查询。 + return fmt.Sprintf(`(CAST(%[2]s AS CHAR) = ? + OR %[1]s.current_display_user_id = ? + OR %[1]s.default_display_user_id = ? + OR %[3]s + OR %[1]s.username LIKE ?)`, userAlias, userIDExpr, ActivePrettyDisplayIDSQL(userAlias, "= ?")), []any{ + keyword, + keyword, + keyword, + nowMs, + keyword, + like, + } +} + +func UserIdentityExactOrderSQL(userAlias string, userIDExpr string, keyword string, nowMs int64) (string, []any) { + keyword = strings.TrimSpace(keyword) + return fmt.Sprintf(`CASE + WHEN CAST(%[2]s AS CHAR) = ? THEN 0 + WHEN %[1]s.current_display_user_id = ? THEN 1 + WHEN %[1]s.default_display_user_id = ? THEN 2 + WHEN %[3]s THEN 3 + ELSE 4 + END`, userAlias, userIDExpr, ActivePrettyDisplayIDSQL(userAlias, "= ?")), []any{ + keyword, + keyword, + keyword, + nowMs, + keyword, + } +} + +func UserDisplayIDSQL(userAlias string, displayUserID string, nowMs int64) (string, []any) { + displayUserID = strings.TrimSpace(displayUserID) + if displayUserID == "" { + return "1 = 1", nil + } + // 精确短号筛选同样覆盖默认短号、当前展示号和 active 靓号,避免换靓号后只能按其中一种号码查到记录。 + return fmt.Sprintf(`(%[1]s.current_display_user_id = ? + OR %[1]s.default_display_user_id = ? + OR %[2]s)`, userAlias, ActivePrettyDisplayIDSQL(userAlias, "= ?")), []any{ + displayUserID, + displayUserID, + nowMs, + displayUserID, + } +} + +func ActivePrettyDisplayIDSQL(userAlias string, displayPredicate string) string { + return fmt.Sprintf(`EXISTS ( + SELECT 1 + FROM pretty_display_user_id_leases lease + WHERE lease.app_code = %[1]s.app_code + AND lease.user_id = %[1]s.user_id + AND lease.status = 'active' + AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?) + AND lease.display_user_id %[2]s + LIMIT 1 + )`, userAlias, displayPredicate) +} diff --git a/services/statistics-service/cmd/backfill-last7/main.go b/services/statistics-service/cmd/backfill-last7/main.go index e9ecbb24..851bdbe0 100644 --- a/services/statistics-service/cmd/backfill-last7/main.go +++ b/services/statistics-service/cmd/backfill-last7/main.go @@ -224,14 +224,14 @@ func formatDay(value time.Time) string { } func collectRegistrationUsers(ctx context.Context, db *sql.DB, state *rebuildState, app string, startMS, endMS int64) error { - // 全站机器人使用 users 资料参与游戏展示,但不是自然注册用户;补数入口必须和实时 UserRegistered 口径一致排除。 + // 全站机器人和后台快捷账号使用 users 资料参与运营、联调或游戏展示,但不是 App 自然注册用户;补数入口必须和实时 UserRegistered 口径一致排除。 rows, err := db.QueryContext(ctx, ` SELECT u.user_id FROM users u 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/domain/user/user.go b/services/user-service/internal/domain/user/user.go index a40db5a5..52e57828 100644 --- a/services/user-service/internal/domain/user/user.go +++ b/services/user-service/internal/domain/user/user.go @@ -25,6 +25,8 @@ const ( const ( // RegisterSourceGameRobot 标记后台为全站机器人池创建的资料号。 RegisterSourceGameRobot = "game_robot" + // RegisterSourceQuickAccount 标记后台快捷创建的联调或运营账号,不属于 App 自然新增。 + RegisterSourceQuickAccount = "quick_account" ) // OnboardingStatus 表达用户是否完成 App 最小注册资料。 @@ -808,6 +810,12 @@ func IsGameRobotRegisterSource(source string) bool { return strings.EqualFold(strings.TrimSpace(source), RegisterSourceGameRobot) } +// ExcludedFromRegistrationStats 判断账号来源是否应该排除在自然新增、注册 cohort 和留存统计之外。 +func ExcludedFromRegistrationStats(source string) bool { + normalized := strings.ToLower(strings.TrimSpace(source)) + return normalized == RegisterSourceGameRobot || normalized == RegisterSourceQuickAccount +} + // DisplayUserIDExpired 判断当前展示号快照是否已经因为靓号到期而失效。 func (u User) DisplayUserIDExpired(nowMs int64) bool { // 只有 pretty 展示号有过期语义,默认短号永不过期。 diff --git a/services/user-service/internal/service/auth/service_test.go b/services/user-service/internal/service/auth/service_test.go index b0242b9b..865b99ca 100644 --- a/services/user-service/internal/service/auth/service_test.go +++ b/services/user-service/internal/service/auth/service_test.go @@ -3,6 +3,7 @@ package auth_test import ( "context" + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -146,6 +147,28 @@ func assertUserRegisteredOutboxCount(t *testing.T, repository *mysqltest.Reposit } } +func assertUserRegisteredOutboxDimensions(t *testing.T, repository *mysqltest.Repository, userID int64, countryID int64, regionID int64) { + t.Helper() + var raw string + if err := repository.RawDB().QueryRowContext(context.Background(), ` + SELECT payload_json + FROM user_outbox + WHERE app_code = 'lalu' AND event_type = 'UserRegistered' AND aggregate_id = ? + `, userID).Scan(&raw); err != nil { + t.Fatalf("query UserRegistered payload failed: %v", err) + } + var payload struct { + CountryID int64 `json:"country_id"` + RegionID int64 `json:"region_id"` + } + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + t.Fatalf("decode UserRegistered payload failed: %v raw=%s", err, raw) + } + if payload.CountryID != countryID || payload.RegionID != regionID { + t.Fatalf("UserRegistered dimensions = country:%d region:%d, want country:%d region:%d", payload.CountryID, payload.RegionID, countryID, regionID) + } +} + func thirdPartyRegistration(platform string) authdomain.ThirdPartyRegistration { // 部分测试需要覆盖注册快照字段,因此默认提供完整资料;完成态仍必须由 onboarding 流程推进。 return authdomain.ThirdPartyRegistration{ @@ -202,7 +225,8 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) { // 覆盖三方注册、设置密码、密码登录、refresh 轮换和 logout 的完整认证生命周期。 ctx := context.Background() repository := mysqltest.NewRepository(t) - seedCountry(t, repository, "SG") + country := seedCountry(t, repository, "SG") + region := mustResolveRegionByCountry(t, repository, "SG") now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"}) @@ -217,6 +241,7 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) { t.Fatalf("new third-party user must require onboarding: %+v", thirdToken) } assertUserRegisteredOutboxCount(t, repository, thirdToken.UserID, 1) + assertUserRegisteredOutboxDimensions(t, repository, thirdToken.UserID, country.CountryID, region.RegionID) if _, err := svc.LoginPassword(ctx, "163000", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) { // 未设置密码前不能通过短号密码登录,且错误必须统一 AUTH_FAILED。 diff --git a/services/user-service/internal/storage/mysql/user/business.go b/services/user-service/internal/storage/mysql/user/business.go index 0376c73e..dd512eab 100644 --- a/services/user-service/internal/storage/mysql/user/business.go +++ b/services/user-service/internal/storage/mysql/user/business.go @@ -4,6 +4,7 @@ import ( "context" "strconv" "strings" + "time" "hyapp/pkg/appcode" "hyapp/pkg/xerr" @@ -30,10 +31,43 @@ func (r *Repository) BusinessUserLookup(ctx context.Context, keyword string, num if parsed, err := strconv.ParseInt(keyword, 10, 64); err == nil { userID = parsed } - query += ` AND (current_display_user_id = ? OR user_id = ?) - ORDER BY CASE WHEN current_display_user_id = ? THEN 0 WHEN user_id = ? THEN 1 ELSE 2 END, updated_at_ms DESC, user_id DESC + nowMs := time.Now().UnixMilli() + // 业务查人入口服务运营搜索,必须同时接受默认短号、当前快照号和 user_display_user_ids 的 active 事实号; + // 靓号发放或过期恢复过程中 users.current_display_user_id 可能短暂落后,事实表命中后列表仍返回当前用户投影。 + query += ` AND ( + current_display_user_id = ? + OR default_display_user_id = ? + OR user_id = ? + OR EXISTS ( + SELECT 1 + FROM user_display_user_ids du + WHERE du.app_code = users.app_code + AND du.user_id = users.user_id + AND du.display_user_id = ? + AND du.status = ? + AND (du.expires_at_ms IS NULL OR du.expires_at_ms = 0 OR du.expires_at_ms > ?) + LIMIT 1 + ) + ) + ORDER BY CASE + WHEN current_display_user_id = ? THEN 0 + WHEN default_display_user_id = ? THEN 1 + WHEN user_id = ? THEN 2 + ELSE 3 + END, updated_at_ms DESC, user_id DESC LIMIT ?` - args = append(args, keyword, userID, keyword, userID, limit) + args = append(args, + keyword, + keyword, + userID, + keyword, + string(userdomain.DisplayUserIDStatusActive), + nowMs, + keyword, + keyword, + userID, + limit, + ) } else { query += ` AND username LIKE ? ESCAPE '\\' ORDER BY updated_at_ms DESC, user_id DESC diff --git a/services/user-service/internal/storage/mysql/user/repository.go b/services/user-service/internal/storage/mysql/user/repository.go index fd10951f..b83dba26 100644 --- a/services/user-service/internal/storage/mysql/user/repository.go +++ b/services/user-service/internal/storage/mysql/user/repository.go @@ -155,16 +155,20 @@ func (r *Repository) CreateUserWithIdentity(ctx context.Context, user userdomain // 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 和注册奖励把批量机器人当新增用户。 + if userdomain.ExcludedFromRegistrationStats(user.RegisterSource) { + // 全站机器人和后台快捷账号只复用用户资料、头像和短号参与运营或联调,不是真实 App 自然注册; + // 这里不写 UserRegistered 事实,避免实时统计、留存 cohort 和注册奖励把后台造号当新增用户。 return nil } + countryID, err := userRegisteredCountryID(ctx, tx, user) + if err != nil { + return err + } payload := map[string]any{ "user_id": user.UserID, "default_display_user_id": user.DefaultDisplayUserID, "country": user.Country, - "country_id": user.CountryID, + "country_id": countryID, "country_by_ip": user.CountryByIP, "region_id": user.RegionID, "invite_code": user.InviteCode, @@ -185,6 +189,33 @@ func InsertUserRegisteredOutbox(ctx context.Context, tx *sql.Tx, user userdomain return err } +func userRegisteredCountryID(ctx context.Context, tx *sql.Tx, user userdomain.User) (int64, error) { + if user.CountryID > 0 { + // 查询路径已经带出国家主数据时直接复用,避免多一次 SQL。 + return user.CountryID, nil + } + country := strings.ToUpper(strings.TrimSpace(user.Country)) + if country == "" { + // 无国家资料的极简注册仍允许进入全局维度,后续补资料不会回写注册当天国家快照。 + return 0, nil + } + var countryID int64 + err := tx.QueryRowContext(ctx, ` + SELECT country_id + FROM countries + WHERE app_code = ? AND country_code = ? + LIMIT 1 + `, appcode.Normalize(user.AppCode), country).Scan(&countryID) + if err == sql.ErrNoRows { + // 国家码格式合法但历史主数据缺失时不阻断注册,统计进入全局国家维度,和查询投影的兜底语义一致。 + return 0, nil + } + if err != nil { + return 0, err + } + return countryID, nil +} + func insertUserRegionChangedOutbox(ctx context.Context, tx *sql.Tx, command userdomain.CountryChangeCommand, oldCountry string, oldRegionID int64) error { source := strings.TrimSpace(command.Source) if source == "" {