From a921aadcf8cf4ca0d3af73ff8d9b19447348dfc8 Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 29 Jun 2026 14:26:06 +0800 Subject: [PATCH] =?UTF-8?q?id=E6=90=9C=E7=B4=A2=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/modules/appuser/ban_list.go | 37 ++++++++++++++-- .../admin/internal/modules/appuser/handler.go | 17 ++++--- .../internal/modules/appuser/login_logs.go | 10 ++++- .../admin/internal/modules/appuser/request.go | 19 +++++--- .../admin/internal/modules/appuser/service.go | 9 +++- .../internal/modules/coinledger/handler.go | 12 +++-- .../internal/modules/coinledger/request.go | 4 ++ .../internal/modules/coinledger/service.go | 44 +++++++++++++++---- .../cumulativerechargereward/handler.go | 13 ++++-- .../modules/firstrechargereward/handler.go | 13 ++++-- .../admin/internal/modules/hostorg/handler.go | 16 ++++--- .../admin/internal/modules/hostorg/reader.go | 44 ++++++++++++++++--- .../admin/internal/modules/hostorg/request.go | 6 +++ .../admin/internal/modules/hostorg/service.go | 12 +++++ .../modules/hostsalarysettlement/dto.go | 4 ++ .../modules/hostsalarysettlement/handler.go | 2 + .../modules/hostsalarysettlement/service.go | 29 ++++++++++++ .../modules/inviteactivityreward/handler.go | 18 +++++--- .../admin/internal/modules/payment/handler.go | 30 +++++++++---- .../internal/modules/payment/handler_test.go | 18 ++++++-- .../modules/registrationreward/handler.go | 13 ++++-- .../internal/modules/resource/handler.go | 26 +++++++++++ .../internal/modules/roomadmin/handler.go | 1 + .../internal/modules/roomadmin/request.go | 3 ++ .../internal/modules/roomadmin/service.go | 9 +++- .../modules/roomturnoverreward/handler.go | 13 ++++-- .../modules/sevendaycheckin/handler.go | 13 ++++-- .../modules/shared/user_identity_query.go | 39 ++++++++++++++++ .../modules/shared/user_identity_resolver.go | 24 ++++++++++ .../modules/shared/user_identity_sql.go | 35 +++++++++++++++ .../modules/teamsalarysettlement/dto.go | 4 ++ .../modules/teamsalarysettlement/handler.go | 2 + .../modules/teamsalarysettlement/service.go | 31 +++++++++++++ .../cmd/backfill-last7/main.go | 9 ++++ 34 files changed, 501 insertions(+), 78 deletions(-) create mode 100644 server/admin/internal/modules/shared/user_identity_query.go diff --git a/server/admin/internal/modules/appuser/ban_list.go b/server/admin/internal/modules/appuser/ban_list.go index 7c4909cc..8dc5a9ee 100644 --- a/server/admin/internal/modules/appuser/ban_list.go +++ b/server/admin/internal/modules/appuser/ban_list.go @@ -49,7 +49,7 @@ func (s *Service) ListBannedUsers(ctx context.Context, query banListQuery) ([]Ap return nil, 0, fmt.Errorf("user mysql is not configured") } query = normalizeBanListQuery(query) - adminIDs, err := s.lookupAdminOperatorIDs(ctx, query.OperatorKeyword) + adminIDs, err := s.lookupAdminOperatorIDs(ctx, query.operatorAdminLookupKeyword()) if err != nil { return nil, 0, err } @@ -119,12 +119,20 @@ func banListWhereSQL(ctx context.Context, query banListQuery, adminIDs []int64) WHERE l.app_code = ? AND target.status IN ('banned', 'disabled')` args := []any{appCode, appCode} nowMs := nowMillis() - if query.TargetKeyword != "" { + if !query.TargetFilter.IsEmpty() { + matchSQL, matchArgs := shared.UserIdentityFieldsSQL("target", "target.user_id", query.TargetFilter, nowMs) + whereSQL += " AND " + matchSQL + args = append(args, matchArgs...) + } else if query.TargetKeyword != "" { matchSQL, matchArgs := shared.UserIdentityKeywordSQL("target", "target.user_id", query.TargetKeyword, nowMs) whereSQL += " AND " + matchSQL args = append(args, matchArgs...) } - if query.OperatorKeyword != "" { + if !query.OperatorFilter.IsEmpty() { + matchSQL, matchArgs := shared.UserIdentityFieldsSQL("operator_user", "operator_user.user_id", query.OperatorFilter, nowMs) + whereSQL += " AND (l.operator_type = 'app_user' AND " + matchSQL + ")" + args = append(args, matchArgs...) + } else if query.OperatorKeyword != "" { 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...) @@ -135,6 +143,15 @@ func banListWhereSQL(ctx context.Context, query banListQuery, adminIDs []int64) } } whereSQL += " AND (" + strings.Join(operatorClauses, " OR ") + ")" + } else if query.OperatorAdminKeyword != "" { + if len(adminIDs) == 0 { + whereSQL += " AND 1 = 0" + } else { + whereSQL += " AND (l.operator_type = 'admin' AND l.operator_user_id IN (" + placeholders(len(adminIDs)) + "))" + for _, id := range adminIDs { + args = append(args, id) + } + } } if query.StartMs > 0 { whereSQL += " AND l.created_at_ms >= ?" @@ -276,9 +293,23 @@ func normalizeBanListQuery(query banListQuery) banListQuery { } query.TargetKeyword = strings.TrimSpace(query.TargetKeyword) query.OperatorKeyword = strings.TrimSpace(query.OperatorKeyword) + query.OperatorAdminKeyword = strings.TrimSpace(query.OperatorAdminKeyword) + query.TargetFilter.UserID = strings.TrimSpace(query.TargetFilter.UserID) + query.TargetFilter.DisplayUserID = strings.TrimSpace(query.TargetFilter.DisplayUserID) + query.TargetFilter.Username = strings.TrimSpace(query.TargetFilter.Username) + query.OperatorFilter.UserID = strings.TrimSpace(query.OperatorFilter.UserID) + query.OperatorFilter.DisplayUserID = strings.TrimSpace(query.OperatorFilter.DisplayUserID) + query.OperatorFilter.Username = strings.TrimSpace(query.OperatorFilter.Username) return query } +func (query banListQuery) operatorAdminLookupKeyword() string { + if keyword := strings.TrimSpace(query.OperatorAdminKeyword); keyword != "" { + return keyword + } + return strings.TrimSpace(query.OperatorKeyword) +} + func uniquePositiveInt64s(values []int64) []int64 { seen := make(map[int64]struct{}, len(values)) out := make([]int64, 0, len(values)) diff --git a/server/admin/internal/modules/appuser/handler.go b/server/admin/internal/modules/appuser/handler.go index 708e4746..ec25a596 100644 --- a/server/admin/internal/modules/appuser/handler.go +++ b/server/admin/internal/modules/appuser/handler.go @@ -160,6 +160,7 @@ func parseListQuery(c *gin.Context) listQuery { PageSize: options.PageSize, Country: firstQuery(c, "country", "country_code", "countryCode"), Keyword: options.Keyword, + UserFilter: shared.UserIdentityFilterFromQuery(c, ""), RegionID: regionID, RegionIDSet: regionIDSet, Status: options.Status, @@ -177,6 +178,7 @@ func parseLoginLogQuery(c *gin.Context) loginLogQuery { Page: options.Page, PageSize: options.PageSize, Keyword: options.Keyword, + UserFilter: shared.UserIdentityFilterFromQuery(c, ""), UserID: queryInt64(c, "user_id", "userId"), DisplayUserID: firstQuery(c, "display_user_id", "displayUserId"), IP: firstQuery(c, "ip"), @@ -195,12 +197,15 @@ func parseLoginLogQuery(c *gin.Context) loginLogQuery { func parseBanListQuery(c *gin.Context) banListQuery { options := shared.ListOptions(c) return normalizeBanListQuery(banListQuery{ - Page: options.Page, - PageSize: options.PageSize, - TargetKeyword: firstQuery(c, "target_keyword", "targetKeyword", "keyword"), - OperatorKeyword: firstQuery(c, "operator_keyword", "operatorKeyword"), - StartMs: queryInt64(c, "start_ms", "startMs"), - EndMs: queryInt64(c, "end_ms", "endMs"), + Page: options.Page, + PageSize: options.PageSize, + TargetKeyword: firstQuery(c, "target_keyword", "targetKeyword", "keyword"), + TargetFilter: shared.UserIdentityFilterFromQuery(c, "target"), + OperatorKeyword: firstQuery(c, "operator_keyword", "operatorKeyword"), + OperatorFilter: shared.UserIdentityFilterFromQuery(c, "operator"), + OperatorAdminKeyword: firstQuery(c, "operator_admin_keyword", "operatorAdminKeyword"), + StartMs: queryInt64(c, "start_ms", "startMs"), + EndMs: queryInt64(c, "end_ms", "endMs"), }) } diff --git a/server/admin/internal/modules/appuser/login_logs.go b/server/admin/internal/modules/appuser/login_logs.go index 81648311..398f1dd4 100644 --- a/server/admin/internal/modules/appuser/login_logs.go +++ b/server/admin/internal/modules/appuser/login_logs.go @@ -108,6 +108,11 @@ func loginLogWhereSQL(ctx context.Context, query loginLogQuery) (string, []any) whereSQL += " AND " + matchSQL args = append(args, matchArgs...) } + if !query.UserFilter.IsEmpty() { + matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "la.user_id", query.UserFilter, nowMs) + whereSQL += " AND " + matchSQL + args = append(args, matchArgs...) + } if query.IP != "" { whereSQL += " AND la.client_ip = ?" args = append(args, query.IP) @@ -146,7 +151,7 @@ func loginLogWhereSQL(ctx context.Context, query loginLogQuery) (string, []any) whereSQL += " AND la.created_at_ms <= ?" args = append(args, query.EndMs) } - if query.Keyword != "" { + if query.Keyword != "" && query.UserFilter.IsEmpty() { like := "%" + query.Keyword + "%" matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "la.user_id", query.Keyword, nowMs) whereSQL += " AND (" + matchSQL + " OR la.client_ip LIKE ?)" @@ -214,6 +219,9 @@ func normalizeLoginLogQuery(query loginLogQuery) loginLogQuery { } query.Keyword = strings.TrimSpace(query.Keyword) query.DisplayUserID = strings.TrimSpace(query.DisplayUserID) + query.UserFilter.UserID = strings.TrimSpace(query.UserFilter.UserID) + query.UserFilter.DisplayUserID = strings.TrimSpace(query.UserFilter.DisplayUserID) + query.UserFilter.Username = strings.TrimSpace(query.UserFilter.Username) query.IP = strings.TrimSpace(query.IP) query.IPCountryCode = normalizeCountryCode(query.IPCountryCode) query.Channel = strings.ToLower(strings.TrimSpace(query.Channel)) diff --git a/server/admin/internal/modules/appuser/request.go b/server/admin/internal/modules/appuser/request.go index cf75fa14..be687d8d 100644 --- a/server/admin/internal/modules/appuser/request.go +++ b/server/admin/internal/modules/appuser/request.go @@ -1,10 +1,13 @@ package appuser +import "hyapp-admin-server/internal/modules/shared" + type listQuery struct { Page int PageSize int Country string Keyword string + UserFilter shared.UserIdentityFilter RegionID int64 RegionIDSet bool Status string @@ -18,6 +21,7 @@ type loginLogQuery struct { Page int PageSize int Keyword string + UserFilter shared.UserIdentityFilter UserID int64 DisplayUserID string IP string @@ -33,12 +37,15 @@ type loginLogQuery struct { } type banListQuery struct { - Page int - PageSize int - TargetKeyword string - OperatorKeyword string - StartMs int64 - EndMs int64 + Page int + PageSize int + TargetKeyword string + TargetFilter shared.UserIdentityFilter + OperatorKeyword string + OperatorFilter shared.UserIdentityFilter + OperatorAdminKeyword string + StartMs int64 + EndMs int64 } type updateUserRequest struct { diff --git a/server/admin/internal/modules/appuser/service.go b/server/admin/internal/modules/appuser/service.go index 21216b9d..ad82defb 100644 --- a/server/admin/internal/modules/appuser/service.go +++ b/server/admin/internal/modules/appuser/service.go @@ -771,6 +771,9 @@ func normalizeListQuery(query listQuery) listQuery { } query.Country = normalizeCountryCode(query.Country) query.Keyword = strings.TrimSpace(query.Keyword) + query.UserFilter.UserID = strings.TrimSpace(query.UserFilter.UserID) + query.UserFilter.DisplayUserID = strings.TrimSpace(query.UserFilter.DisplayUserID) + query.UserFilter.Username = strings.TrimSpace(query.UserFilter.Username) if query.RegionID < 0 { query.RegionID = 0 query.RegionIDSet = false @@ -798,7 +801,11 @@ func appUserListWhereSQLAt(appCode string, query listQuery, nowMs int64) (string whereSQL += " AND u.status = ?" args = append(args, query.Status) } - if query.Keyword != "" { + if !query.UserFilter.IsEmpty() { + matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "u.user_id", query.UserFilter, nowMs) + whereSQL += " AND " + matchSQL + args = append(args, matchArgs...) + } else if query.Keyword != "" { matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "u.user_id", query.Keyword, nowMs) whereSQL += " AND " + matchSQL args = append(args, matchArgs...) diff --git a/server/admin/internal/modules/coinledger/handler.go b/server/admin/internal/modules/coinledger/handler.go index e5be9db3..bd087449 100644 --- a/server/admin/internal/modules/coinledger/handler.go +++ b/server/admin/internal/modules/coinledger/handler.go @@ -149,6 +149,7 @@ func parseListQuery(c *gin.Context) (listQuery, bool) { Page: options.Page, PageSize: options.PageSize, UserKeyword: strings.TrimSpace(firstQuery(c, "user_keyword", "userKeyword", "user_id", "userId", "keyword")), + UserFilter: shared.UserIdentityFilterFromQuery(c, ""), BizType: firstQuery(c, "biz_type", "bizType"), StartAtMS: startAtMS, EndAtMS: endAtMS, @@ -186,9 +187,14 @@ func parseCoinSellerLedgerQuery(c *gin.Context) (coinSellerLedgerQuery, bool) { PageSize: options.PageSize, SellerUserID: sellerUserID, SellerKeyword: firstQuery(c, "seller_keyword", "sellerKeyword", "keyword"), - LedgerType: firstQuery(c, "ledger_type", "ledgerType"), - StartAtMS: startAtMS, - EndAtMS: endAtMS, + SellerFilter: shared.UserIdentityFilter{ + DisplayUserID: strings.TrimSpace(firstQuery(c, "seller_display_user_id", "sellerDisplayUserId")), + UserID: strings.TrimSpace(firstQuery(c, "seller_filter_user_id", "sellerFilterUserId")), + Username: strings.TrimSpace(firstQuery(c, "seller_username", "sellerUsername")), + }, + LedgerType: firstQuery(c, "ledger_type", "ledgerType"), + StartAtMS: startAtMS, + EndAtMS: endAtMS, }) if _, err := coinSellerLedgerBizTypes(query.LedgerType); err != nil { response.BadRequest(c, "流水类型不正确") diff --git a/server/admin/internal/modules/coinledger/request.go b/server/admin/internal/modules/coinledger/request.go index 5059352f..e2a365e8 100644 --- a/server/admin/internal/modules/coinledger/request.go +++ b/server/admin/internal/modules/coinledger/request.go @@ -1,9 +1,12 @@ package coinledger +import "hyapp-admin-server/internal/modules/shared" + type listQuery struct { Page int PageSize int UserKeyword string + UserFilter shared.UserIdentityFilter BizType string StartAtMS int64 EndAtMS int64 @@ -14,6 +17,7 @@ type coinSellerLedgerQuery struct { PageSize int SellerUserID int64 SellerKeyword string + SellerFilter shared.UserIdentityFilter LedgerType string StartAtMS int64 EndAtMS int64 diff --git a/server/admin/internal/modules/coinledger/service.go b/server/admin/internal/modules/coinledger/service.go index 6e628e83..23ee9fed 100644 --- a/server/admin/internal/modules/coinledger/service.go +++ b/server/admin/internal/modules/coinledger/service.go @@ -63,7 +63,7 @@ func (s *Service) ListCoinLedger(ctx context.Context, appCode string, query list return nil, 0, fmt.Errorf("wallet mysql is not configured") } - userIDs, userFiltered, err := s.resolveUserFilter(ctx, appCode, query.UserKeyword) + userIDs, userFiltered, err := s.resolveUserFilter(ctx, appCode, query.UserKeyword, query.UserFilter) if err != nil { return nil, 0, err } @@ -342,7 +342,7 @@ func (s *Service) ListCoinAdjustments(ctx context.Context, appCode string, query return nil, 0, fmt.Errorf("wallet mysql is not configured") } - userIDs, userFiltered, err := s.resolveUserFilter(ctx, appCode, query.UserKeyword) + userIDs, userFiltered, err := s.resolveUserFilter(ctx, appCode, query.UserKeyword, query.UserFilter) if err != nil { return nil, 0, err } @@ -539,13 +539,17 @@ func (s *Service) CreateCoinAdjustment(ctx context.Context, appCode string, acto }, nil } -func (s *Service) resolveUserFilter(ctx context.Context, appCode string, keyword string) ([]int64, bool, error) { +func (s *Service) resolveUserFilter(ctx context.Context, appCode string, keyword string, filter shared.UserIdentityFilter) ([]int64, bool, error) { keyword = strings.TrimSpace(keyword) - if keyword == "" { + if filter.IsEmpty() && keyword == "" { return nil, false, nil } directUserIDs := make([]int64, 0, 1) - if numeric, err := strconv.ParseInt(keyword, 10, 64); err == nil && numeric > 0 { + numericKeyword := keyword + if strings.TrimSpace(filter.UserID) != "" { + numericKeyword = filter.UserID + } + if numeric, err := strconv.ParseInt(numericKeyword, 10, 64); err == nil && numeric > 0 { directUserIDs = append(directUserIDs, numeric) } if s == nil || s.userDB == nil { @@ -555,8 +559,15 @@ func (s *Service) resolveUserFilter(ctx context.Context, appCode string, keyword return nil, true, fmt.Errorf("user mysql is not configured") } - // 用户列筛选同时支持长 ID、默认短号、当前短号、active 靓号和昵称;数字关键字直接加入 user_id,避免用户资料缺失时查不到账务事实。 - matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, time.Now().UnixMilli()) + var matchSQL string + var matchArgs []any + if !filter.IsEmpty() { + // 拆分字段来自表头明确输入:短 ID、长 ID 和昵称分别构造精确约束,避免数字短号被昵称或长 ID LIKE 扩大。 + matchSQL, matchArgs = shared.UserIdentityFieldsSQL("u", "u.user_id", filter, time.Now().UnixMilli()) + } else { + // 旧 keyword 入口保留兼容;数字关键字直接加入 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, ` @@ -589,6 +600,13 @@ func (s *Service) resolveCoinSellerFilter(ctx context.Context, appCode string, q // 即使用户资料暂时查不到,也允许账本事实被查出来,避免抽屉入口被资料缺失阻断。 return []int64{query.SellerUserID}, true, nil } + if !query.SellerFilter.IsEmpty() { + if s == nil || s.userDB == nil { + return nil, true, fmt.Errorf("user mysql is not configured") + } + matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "csp.user_id", query.SellerFilter, time.Now().UnixMilli()) + return s.resolveCoinSellerIDs(ctx, appCode, matchSQL, matchArgs) + } keyword := strings.TrimSpace(query.SellerKeyword) if keyword == "" { // 未传币商筛选时返回 userFiltered=false,调用方会查询所有币商流水,但仍被 biz_type、asset_type 和分页限制住。 @@ -601,8 +619,12 @@ func (s *Service) resolveCoinSellerFilter(ctx context.Context, appCode string, q // 币商关键字只命中 coin_seller_profiles 里的币商本人,LEFT JOIN users 只用于短 ID、active 靓号和昵称补充; // 这里故意不搜索 receiver/counterparty,避免“收款用户命中”把其他币商的流水带进当前币商筛选结果。 matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "csp.user_id", keyword, time.Now().UnixMilli()) - args := append([]any{appCode}, matchArgs...) + return s.resolveCoinSellerIDs(ctx, appCode, matchSQL, matchArgs) +} + +func (s *Service) resolveCoinSellerIDs(ctx context.Context, appCode string, matchSQL string, matchArgs []any) ([]int64, bool, error) { + args := append([]any{appCode}, matchArgs...) rows, err := s.userDB.QueryContext(ctx, ` SELECT csp.user_id FROM coin_seller_profiles csp @@ -765,6 +787,9 @@ func normalizeListQuery(query listQuery) listQuery { query.PageSize = 100 } query.UserKeyword = strings.TrimSpace(query.UserKeyword) + query.UserFilter.UserID = strings.TrimSpace(query.UserFilter.UserID) + query.UserFilter.DisplayUserID = strings.TrimSpace(query.UserFilter.DisplayUserID) + query.UserFilter.Username = strings.TrimSpace(query.UserFilter.Username) query.BizType = strings.TrimSpace(query.BizType) return query } @@ -780,6 +805,9 @@ func normalizeCoinSellerLedgerQuery(query coinSellerLedgerQuery) coinSellerLedge query.PageSize = 100 } query.SellerKeyword = strings.TrimSpace(query.SellerKeyword) + query.SellerFilter.UserID = strings.TrimSpace(query.SellerFilter.UserID) + query.SellerFilter.DisplayUserID = strings.TrimSpace(query.SellerFilter.DisplayUserID) + query.SellerFilter.Username = strings.TrimSpace(query.SellerFilter.Username) query.LedgerType = strings.TrimSpace(query.LedgerType) return query } diff --git a/server/admin/internal/modules/cumulativerechargereward/handler.go b/server/admin/internal/modules/cumulativerechargereward/handler.go index 7d9e1b01..9f74b6b9 100644 --- a/server/admin/internal/modules/cumulativerechargereward/handler.go +++ b/server/admin/internal/modules/cumulativerechargereward/handler.go @@ -131,12 +131,13 @@ func (h *Handler) UpdateConfig(c *gin.Context) { func (h *Handler) ListGrants(c *gin.Context) { options := shared.ListOptions(c) // 发放记录主表只存 user_id;后台 keyword 先在 users 表解析成精确用户,再下推给 activity-service 查询。 - userID, matched, ok := h.resolveGrantUserID(c.Request.Context(), strings.TrimSpace(options.Keyword)) + userFilter := shared.UserIdentityFilterFromQuery(c, "") + userID, matched, ok := h.resolveGrantUserID(c.Request.Context(), strings.TrimSpace(options.Keyword), userFilter) if !ok { response.ServerError(c, "查询用户信息失败") return } - if options.Keyword != "" && !matched { + if (options.Keyword != "" || !userFilter.IsEmpty()) && !matched { response.OK(c, response.Page{Items: []grantDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0}) return } @@ -172,10 +173,14 @@ func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta { } } -func (h *Handler) resolveGrantUserID(ctx context.Context, keyword string) (int64, bool, bool) { - if keyword == "" { +func (h *Handler) resolveGrantUserID(ctx context.Context, keyword string, filter shared.UserIdentityFilter) (int64, bool, bool) { + if filter.IsEmpty() && keyword == "" { return 0, false, true } + if !filter.IsEmpty() { + userID, matched, err := shared.ResolveUserIdentityFilter(ctx, h.userDB, appctx.FromContext(ctx), filter, time.Now().UnixMilli()) + return userID, matched, err == nil + } // 累计充值奖励按 activity-service 的内部 user_id 过滤;keyword 的长 ID、短号、靓号、昵称优先级由 shared 统一维护。 userID, matched, err := shared.ResolveExactUserID(ctx, h.userDB, appctx.FromContext(ctx), keyword, time.Now().UnixMilli()) if err != nil { diff --git a/server/admin/internal/modules/firstrechargereward/handler.go b/server/admin/internal/modules/firstrechargereward/handler.go index 41e1b7fe..ff20deb1 100644 --- a/server/admin/internal/modules/firstrechargereward/handler.go +++ b/server/admin/internal/modules/firstrechargereward/handler.go @@ -137,12 +137,13 @@ func (h *Handler) UpdateConfig(c *gin.Context) { func (h *Handler) ListClaims(c *gin.Context) { options := shared.ListOptions(c) - userID, matched, ok := h.resolveClaimUserID(c.Request.Context(), strings.TrimSpace(options.Keyword)) + userFilter := shared.UserIdentityFilterFromQuery(c, "") + userID, matched, ok := h.resolveClaimUserID(c.Request.Context(), strings.TrimSpace(options.Keyword), userFilter) if !ok { response.ServerError(c, "查询用户信息失败") return } - if options.Keyword != "" && !matched { + if (options.Keyword != "" || !userFilter.IsEmpty()) && !matched { response.OK(c, response.Page{Items: []claimDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0}) return } @@ -177,10 +178,14 @@ func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta { } } -func (h *Handler) resolveClaimUserID(ctx context.Context, keyword string) (int64, bool, bool) { - if keyword == "" { +func (h *Handler) resolveClaimUserID(ctx context.Context, keyword string, filter shared.UserIdentityFilter) (int64, bool, bool) { + if filter.IsEmpty() && keyword == "" { return 0, false, true } + if !filter.IsEmpty() { + userID, matched, err := shared.ResolveUserIdentityFilter(ctx, h.userDB, appctx.FromContext(ctx), filter, time.Now().UnixMilli()) + return userID, matched, err == nil + } // 奖励领取记录由 activity-service 按内部 user_id 过滤;后台关键词先统一解析,避免各活动模块复制短号/靓号 SQL。 userID, matched, err := shared.ResolveExactUserID(ctx, h.userDB, appctx.FromContext(ctx), keyword, time.Now().UnixMilli()) if err != nil { diff --git a/server/admin/internal/modules/hostorg/handler.go b/server/admin/internal/modules/hostorg/handler.go index 3486444b..9a565e97 100644 --- a/server/admin/internal/modules/hostorg/handler.go +++ b/server/admin/internal/modules/hostorg/handler.go @@ -471,12 +471,16 @@ func parseInt64ID(c *gin.Context, name string) (int64, bool) { func parseListQuery(c *gin.Context) (listQuery, bool) { options := shared.ListOptions(c) query := listQuery{ - Page: options.Page, - PageSize: options.PageSize, - Keyword: options.Keyword, - Status: options.Status, - SortBy: firstNonBlank(c.Query("sortBy"), c.Query("sort_by")), - SortDirection: firstNonBlank(c.Query("sortDirection"), c.Query("sort_direction"), c.Query("order")), + Page: options.Page, + PageSize: options.PageSize, + Keyword: options.Keyword, + UserFilter: shared.UserIdentityFilterFromQuery(c, ""), + OwnerFilter: shared.UserIdentityFilterFromQuery(c, "owner"), + ParentLeaderFilter: shared.UserIdentityFilterFromQuery(c, "parent_leader"), + ParentBDFilter: shared.UserIdentityFilterFromQuery(c, "parent_bd"), + Status: options.Status, + SortBy: firstNonBlank(c.Query("sortBy"), c.Query("sort_by")), + SortDirection: firstNonBlank(c.Query("sortDirection"), c.Query("sort_direction"), c.Query("order")), } var ok bool if query.RegionID, ok = parseOptionalInt64Query(c, "regionId", "region_id"); !ok { diff --git a/server/admin/internal/modules/hostorg/reader.go b/server/admin/internal/modules/hostorg/reader.go index cb991bcb..8e3209a8 100644 --- a/server/admin/internal/modules/hostorg/reader.go +++ b/server/admin/internal/modules/hostorg/reader.go @@ -134,8 +134,16 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin if query.ParentLeaderUserID > 0 { whereSQL += " AND bp.parent_leader_user_id = ?" args = append(args, query.ParentLeaderUserID) + } else if !query.ParentLeaderFilter.IsEmpty() { + parentSQL, parentArgs := shared.UserIdentityFieldsSQL("parent_leader", "bp.parent_leader_user_id", query.ParentLeaderFilter, time.Now().UnixMilli()) + whereSQL += " AND " + parentSQL + args = append(args, parentArgs...) } - if keyword := strings.TrimSpace(query.Keyword); keyword != "" { + if !query.UserFilter.IsEmpty() { + matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "bp.user_id", query.UserFilter, time.Now().UnixMilli()) + whereSQL += " AND " + matchSQL + args = append(args, matchArgs...) + } else if keyword := strings.TrimSpace(query.Keyword); keyword != "" { like := "%" + keyword + "%" matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "bp.user_id", keyword, time.Now().UnixMilli()) whereSQL += " AND (" + matchSQL + " OR user_region.name LIKE ?)" @@ -249,7 +257,11 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]* whereSQL += " AND user_region.region_id = ?" args = append(args, query.RegionID) } - if keyword := strings.TrimSpace(query.Keyword); keyword != "" { + if !query.UserFilter.IsEmpty() { + matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "bp.user_id", query.UserFilter, time.Now().UnixMilli()) + whereSQL += " AND " + matchSQL + args = append(args, matchArgs...) + } else if keyword := strings.TrimSpace(query.Keyword); keyword != "" { like := "%" + keyword + "%" matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "bp.user_id", keyword, time.Now().UnixMilli()) whereSQL += " AND (" + matchSQL + " OR user_region.name LIKE ?)" @@ -704,7 +716,11 @@ func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerL whereSQL += " AND manager_region.region_id = ?" args = append(args, query.RegionID) } - if keyword := strings.TrimSpace(query.Keyword); keyword != "" { + if !query.UserFilter.IsEmpty() { + managerSQL, managerArgs := shared.UserIdentityFieldsSQL("manager", "mp.user_id", query.UserFilter, time.Now().UnixMilli()) + whereSQL += " AND " + managerSQL + args = append(args, managerArgs...) + } else if keyword := strings.TrimSpace(query.Keyword); keyword != "" { like := "%" + keyword + "%" managerSQL, managerArgs := shared.UserIdentityKeywordSQL("manager", "mp.user_id", keyword, time.Now().UnixMilli()) whereSQL += " AND (" + managerSQL + " OR manager_region.name LIKE ?)" @@ -798,8 +814,16 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie if query.ParentBDUserID > 0 { whereSQL += " AND a.parent_bd_user_id = ?" args = append(args, query.ParentBDUserID) + } else if !query.ParentBDFilter.IsEmpty() { + parentSQL, parentArgs := shared.UserIdentityFieldsSQL("parent_bd", "a.parent_bd_user_id", query.ParentBDFilter, time.Now().UnixMilli()) + whereSQL += " AND " + parentSQL + args = append(args, parentArgs...) } - if keyword := strings.TrimSpace(query.Keyword); keyword != "" { + if !query.OwnerFilter.IsEmpty() { + ownerSQL, ownerArgs := shared.UserIdentityFieldsSQL("owner", "a.owner_user_id", query.OwnerFilter, time.Now().UnixMilli()) + whereSQL += " AND " + ownerSQL + args = append(args, ownerArgs...) + } else if keyword := strings.TrimSpace(query.Keyword); keyword != "" { like := "%" + keyword + "%" nowMs := time.Now().UnixMilli() ownerSQL, ownerArgs := shared.UserIdentityKeywordSQL("owner", "a.owner_user_id", keyword, nowMs) @@ -905,7 +929,11 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user whereSQL += " AND hp.current_agency_id = ?" args = append(args, query.AgencyID) } - if keyword := strings.TrimSpace(query.Keyword); keyword != "" { + if !query.UserFilter.IsEmpty() { + hostSQL, hostArgs := shared.UserIdentityFieldsSQL("u", "hp.user_id", query.UserFilter, time.Now().UnixMilli()) + whereSQL += " AND " + hostSQL + args = append(args, hostArgs...) + } else if keyword := strings.TrimSpace(query.Keyword); keyword != "" { like := "%" + keyword + "%" nowMs := time.Now().UnixMilli() hostSQL, hostArgs := shared.UserIdentityKeywordSQL("u", "hp.user_id", keyword, nowMs) @@ -1021,7 +1049,11 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS whereSQL += " AND u.region_id = ?" args = append(args, query.RegionID) } - if keyword := strings.TrimSpace(query.Keyword); keyword != "" { + if !query.UserFilter.IsEmpty() { + matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "csp.user_id", query.UserFilter, time.Now().UnixMilli()) + whereSQL += " AND " + matchSQL + args = append(args, matchArgs...) + } else if keyword := strings.TrimSpace(query.Keyword); keyword != "" { like := "%" + keyword + "%" matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "csp.user_id", keyword, time.Now().UnixMilli()) whereSQL += " AND (" + matchSQL + " OR csp.contact_info LIKE ?)" diff --git a/server/admin/internal/modules/hostorg/request.go b/server/admin/internal/modules/hostorg/request.go index bbf125a5..fd283fb0 100644 --- a/server/admin/internal/modules/hostorg/request.go +++ b/server/admin/internal/modules/hostorg/request.go @@ -5,6 +5,8 @@ import ( "fmt" "strconv" "strings" + + "hyapp-admin-server/internal/modules/shared" ) // flexibleInt64 只用于 admin HTTP 请求入参:前端新合约会按字符串提交用户长 ID,旧页面或脚本可能仍提交 JSON number。 @@ -44,6 +46,10 @@ type listQuery struct { Page int PageSize int Keyword string + UserFilter shared.UserIdentityFilter + OwnerFilter shared.UserIdentityFilter + ParentLeaderFilter shared.UserIdentityFilter + ParentBDFilter shared.UserIdentityFilter Status string RegionID int64 AgencyID int64 diff --git a/server/admin/internal/modules/hostorg/service.go b/server/admin/internal/modules/hostorg/service.go index a9fe8364..c370ed26 100644 --- a/server/admin/internal/modules/hostorg/service.go +++ b/server/admin/internal/modules/hostorg/service.go @@ -614,6 +614,18 @@ func normalizeListQuery(query listQuery) listQuery { query.PageSize = 100 } query.Keyword = strings.TrimSpace(query.Keyword) + query.UserFilter.UserID = strings.TrimSpace(query.UserFilter.UserID) + query.UserFilter.DisplayUserID = strings.TrimSpace(query.UserFilter.DisplayUserID) + query.UserFilter.Username = strings.TrimSpace(query.UserFilter.Username) + query.OwnerFilter.UserID = strings.TrimSpace(query.OwnerFilter.UserID) + query.OwnerFilter.DisplayUserID = strings.TrimSpace(query.OwnerFilter.DisplayUserID) + query.OwnerFilter.Username = strings.TrimSpace(query.OwnerFilter.Username) + query.ParentLeaderFilter.UserID = strings.TrimSpace(query.ParentLeaderFilter.UserID) + query.ParentLeaderFilter.DisplayUserID = strings.TrimSpace(query.ParentLeaderFilter.DisplayUserID) + query.ParentLeaderFilter.Username = strings.TrimSpace(query.ParentLeaderFilter.Username) + query.ParentBDFilter.UserID = strings.TrimSpace(query.ParentBDFilter.UserID) + query.ParentBDFilter.DisplayUserID = strings.TrimSpace(query.ParentBDFilter.DisplayUserID) + query.ParentBDFilter.Username = strings.TrimSpace(query.ParentBDFilter.Username) query.Status = strings.ToLower(strings.TrimSpace(query.Status)) query.SortBy = strings.ToLower(strings.TrimSpace(query.SortBy)) switch query.SortBy { diff --git a/server/admin/internal/modules/hostsalarysettlement/dto.go b/server/admin/internal/modules/hostsalarysettlement/dto.go index ef45ce3d..8b198ddf 100644 --- a/server/admin/internal/modules/hostsalarysettlement/dto.go +++ b/server/admin/internal/modules/hostsalarysettlement/dto.go @@ -5,6 +5,8 @@ import ( "fmt" "strconv" "strings" + + "hyapp-admin-server/internal/modules/shared" ) type settlementUserID int64 @@ -126,7 +128,9 @@ type query struct { SettlementType string Status string UserID int64 + UserFilter shared.UserIdentityFilter AgencyOwnerUserID int64 + AgencyOwnerFilter shared.UserIdentityFilter PolicyID uint64 StartAtMS int64 EndAtMS int64 diff --git a/server/admin/internal/modules/hostsalarysettlement/handler.go b/server/admin/internal/modules/hostsalarysettlement/handler.go index b07f8d0c..ef66a825 100644 --- a/server/admin/internal/modules/hostsalarysettlement/handler.go +++ b/server/admin/internal/modules/hostsalarysettlement/handler.go @@ -120,7 +120,9 @@ func parseQuery(c *gin.Context) (query, bool) { SettlementType: normalizeSettlementType(firstQuery(c, "settlement_type", "settlementType")), Status: normalizeStatus(firstQuery(c, "status")), UserID: userID, + UserFilter: shared.UserIdentityFilterFromQuery(c, ""), AgencyOwnerUserID: agencyOwnerUserID, + AgencyOwnerFilter: shared.UserIdentityFilterFromQuery(c, "agency_owner"), PolicyID: policyID, StartAtMS: startAtMS, EndAtMS: endAtMS, diff --git a/server/admin/internal/modules/hostsalarysettlement/service.go b/server/admin/internal/modules/hostsalarysettlement/service.go index 7d18484b..5a2ef038 100644 --- a/server/admin/internal/modules/hostsalarysettlement/service.go +++ b/server/admin/internal/modules/hostsalarysettlement/service.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "hyapp-admin-server/internal/modules/shared" walletv1 "hyapp.local/api/proto/wallet/v1" "google.golang.org/grpc" @@ -362,6 +363,15 @@ func (s *Service) List(ctx context.Context, appCode string, req query) ([]settle return nil, 0, err } req = normalizeQuery(req) + var matched bool + var err error + req, matched, err = s.resolveListUserFilters(ctx, appCode, req) + if err != nil { + return nil, 0, err + } + if !matched { + return []settlementDTO{}, 0, nil + } whereSQL, args := settlementWhere(appCode, req) var total int64 @@ -447,6 +457,25 @@ func (s *Service) List(ctx context.Context, appCode string, req query) ([]settle return items, total, nil } +func (s *Service) resolveListUserFilters(ctx context.Context, appCode string, req query) (query, bool, error) { + nowMS := time.Now().UnixMilli() + if req.UserID == 0 && !req.UserFilter.IsEmpty() { + userID, matched, err := shared.ResolveUserIdentityFilter(ctx, s.userDB, strings.TrimSpace(appCode), req.UserFilter, nowMS) + if err != nil || !matched { + return req, matched, err + } + req.UserID = userID + } + if req.AgencyOwnerUserID == 0 && !req.AgencyOwnerFilter.IsEmpty() { + userID, matched, err := shared.ResolveUserIdentityFilter(ctx, s.userDB, strings.TrimSpace(appCode), req.AgencyOwnerFilter, nowMS) + if err != nil || !matched { + return req, matched, err + } + req.AgencyOwnerUserID = userID + } + return req, true, nil +} + func settlementWhere(appCode string, req query) (string, []any) { conditions := []string{"app_code = ?"} args := []any{strings.TrimSpace(appCode)} diff --git a/server/admin/internal/modules/inviteactivityreward/handler.go b/server/admin/internal/modules/inviteactivityreward/handler.go index 0e31a89a..1b4e4930 100644 --- a/server/admin/internal/modules/inviteactivityreward/handler.go +++ b/server/admin/internal/modules/inviteactivityreward/handler.go @@ -158,12 +158,13 @@ func (h *Handler) UpdateConfig(c *gin.Context) { func (h *Handler) ListClaims(c *gin.Context) { options := shared.ListOptions(c) - userID, matched, ok := h.resolveUserID(c.Request.Context(), strings.TrimSpace(options.Keyword)) + userFilter := shared.UserIdentityFilterFromQuery(c, "") + userID, matched, ok := h.resolveUserID(c.Request.Context(), strings.TrimSpace(options.Keyword), userFilter) if !ok { response.ServerError(c, "查询用户信息失败") return } - if options.Keyword != "" && !matched { + if (options.Keyword != "" || !userFilter.IsEmpty()) && !matched { response.OK(c, response.Page{Items: []claimDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0}) return } @@ -193,12 +194,13 @@ func (h *Handler) ListClaims(c *gin.Context) { func (h *Handler) ListSummaries(c *gin.Context) { options := shared.ListOptions(c) - userID, matched, ok := h.resolveUserID(c.Request.Context(), strings.TrimSpace(options.Keyword)) + userFilter := shared.UserIdentityFilterFromQuery(c, "") + userID, matched, ok := h.resolveUserID(c.Request.Context(), strings.TrimSpace(options.Keyword), userFilter) if !ok { response.ServerError(c, "查询用户信息失败") return } - if options.Keyword != "" && !matched { + if (options.Keyword != "" || !userFilter.IsEmpty()) && !matched { response.OK(c, response.Page{Items: []summaryDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0}) return } @@ -233,10 +235,14 @@ func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta { } } -func (h *Handler) resolveUserID(ctx context.Context, keyword string) (int64, bool, bool) { - if keyword == "" { +func (h *Handler) resolveUserID(ctx context.Context, keyword string, filter shared.UserIdentityFilter) (int64, bool, bool) { + if filter.IsEmpty() && keyword == "" { return 0, false, true } + if !filter.IsEmpty() { + userID, matched, err := shared.ResolveUserIdentityFilter(ctx, h.userDB, appctx.FromContext(ctx), filter, time.Now().UnixMilli()) + return userID, matched, err == nil + } // 邀请奖励汇总页只把内部 user_id 交给 activity-service;展示 ID 的兼容规则集中在 shared 解析器里。 userID, matched, err := shared.ResolveExactUserID(ctx, h.userDB, appctx.FromContext(ctx), keyword, time.Now().UnixMilli()) if err != nil { diff --git a/server/admin/internal/modules/payment/handler.go b/server/admin/internal/modules/payment/handler.go index 61ddbc1e..7f43e4db 100644 --- a/server/admin/internal/modules/payment/handler.go +++ b/server/admin/internal/modules/payment/handler.go @@ -41,14 +41,20 @@ func New(wallet walletclient.Client, userDB *sql.DB, walletDB *sql.DB, store *re func (h *Handler) ListRechargeBills(c *gin.Context) { options := shared.ListOptions(c) appCode := appctx.FromContext(c.Request.Context()) - userID, ok := h.resolveRechargeBillUserFilter(c, appCode, "user_id 参数不正确", "user_id", "userId") + userFilter := shared.UserIdentityFilterFromQuery(c, "") + userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId") if !ok { return } - sellerUserID, ok := h.resolveRechargeBillUserFilter(c, appCode, "seller_user_id 参数不正确", "seller_user_id", "sellerUserId") + sellerFilter := shared.UserIdentityFilterFromQuery(c, "seller") + sellerUserID, sellerMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, sellerFilter, "seller_user_id 参数不正确", "seller_user_id", "sellerUserId") if !ok { return } + if (!userFilter.IsEmpty() && !userMatched) || (!sellerFilter.IsEmpty() && !sellerMatched) { + response.OK(c, response.Page{Items: []rechargeBillDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0}) + return + } resp, err := h.wallet.ListRechargeBills(c.Request.Context(), &walletv1.ListRechargeBillsRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appCode, @@ -80,22 +86,30 @@ func (h *Handler) ListRechargeBills(c *gin.Context) { response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()}) } -func (h *Handler) resolveRechargeBillUserFilter(c *gin.Context, appCode string, invalidMessage string, keys ...string) (int64, bool) { +func (h *Handler) resolveRechargeBillUserFilter(c *gin.Context, appCode string, filter shared.UserIdentityFilter, invalidMessage string, keys ...string) (int64, bool, bool) { keyword := strings.TrimSpace(firstQuery(c, keys...)) - if keyword == "" { - return 0, true + if filter.IsEmpty() && keyword == "" { + return 0, false, true + } + if !filter.IsEmpty() { + userID, matched, err := shared.ResolveUserIdentityFilter(c.Request.Context(), h.userDB, appCode, filter, time.Now().UnixMilli()) + if err != nil { + response.ServerError(c, "查询账单用户信息失败") + return 0, false, false + } + return userID, matched, true } // 充值账单事实在 wallet-service,筛选字段只能传内部 user_id;后台先把长 ID、默认短号、当前展示号和 active 靓号解析成同一个稳定 ID。 userID, _, err := shared.ResolveExactUserIDOrNumericFallback(c.Request.Context(), h.userDB, appCode, keyword, time.Now().UnixMilli()) if err != nil { if errors.Is(err, shared.ErrInvalidUserIdentityFilter) { response.BadRequest(c, invalidMessage) - return 0, false + return 0, false, false } response.ServerError(c, "查询账单用户信息失败") - return 0, false + return 0, false, false } - return userID, true + return userID, true, true } // fillRechargeBillUsers 将用户资料写回当前页账单 DTO;缺失资料会在 DTO 层保留长 ID,避免影响账单事实展示。 diff --git a/server/admin/internal/modules/payment/handler_test.go b/server/admin/internal/modules/payment/handler_test.go index 3262dca2..f3977b1c 100644 --- a/server/admin/internal/modules/payment/handler_test.go +++ b/server/admin/internal/modules/payment/handler_test.go @@ -31,10 +31,10 @@ func TestListRechargeBillsResolvesDisplayIDsBeforeWalletFilter(t *testing.T) { router := newPaymentHandlerTestRouter(New(wallet, db, nil, nil, nil)) userID := int64(327702592329093120) sellerUserID := int64(328453424662192128) - expectUserIdentityLookup(sqlMock, "111", userID) - expectUserIdentityLookup(sqlMock, "167864", sellerUserID) + expectUserDisplayIDLookup(sqlMock, "111", userID) + expectUserDisplayIDLookup(sqlMock, "167864", sellerUserID) - request := httptest.NewRequest(http.MethodGet, "/admin/payment/recharge-bills?user_id=111&seller_user_id=167864", nil) + request := httptest.NewRequest(http.MethodGet, "/admin/payment/recharge-bills?display_user_id=111&seller_display_user_id=167864", nil) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) @@ -600,6 +600,18 @@ func expectUserIdentityLookup(sqlMock sqlmock.Sqlmock, keyword string, userID in WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(userID)) } +func expectUserDisplayIDLookup(sqlMock sqlmock.Sqlmock, displayUserID string, userID int64) { + sqlMock.ExpectQuery(`(?s)SELECT u\.user_id.*FROM users u.*LIMIT 1`). + WithArgs( + "lalu", + displayUserID, + displayUserID, + sqlmock.AnyArg(), + displayUserID, + ). + WillReturnRows(sqlmock.NewRows([]string{"user_id"}).AddRow(userID)) +} + type moneyScopeFixture struct { appCode string regionID int64 diff --git a/server/admin/internal/modules/registrationreward/handler.go b/server/admin/internal/modules/registrationreward/handler.go index e7f89620..3a9926fd 100644 --- a/server/admin/internal/modules/registrationreward/handler.go +++ b/server/admin/internal/modules/registrationreward/handler.go @@ -118,12 +118,13 @@ func (h *Handler) ListRegistrationRewardClaims(c *gin.Context) { options := shared.ListOptions(c) claimedStartMS := queryInt64(c, "claimed_start_ms", "claimedStartMs", "start_ms", "startMs") claimedEndMS := queryInt64(c, "claimed_end_ms", "claimedEndMs", "end_ms", "endMs") - userID, matched, ok := h.resolveClaimUserID(c.Request.Context(), strings.TrimSpace(options.Keyword)) + userFilter := shared.UserIdentityFilterFromQuery(c, "") + userID, matched, ok := h.resolveClaimUserID(c.Request.Context(), strings.TrimSpace(options.Keyword), userFilter) if !ok { response.ServerError(c, "查询用户信息失败") return } - if options.Keyword != "" && !matched { + if (options.Keyword != "" || !userFilter.IsEmpty()) && !matched { todayClaimedCount, ok := h.loadTodayClaimedCount(c) if !ok { return @@ -183,10 +184,14 @@ func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta { } } -func (h *Handler) resolveClaimUserID(ctx context.Context, keyword string) (int64, bool, bool) { - if keyword == "" { +func (h *Handler) resolveClaimUserID(ctx context.Context, keyword string, filter shared.UserIdentityFilter) (int64, bool, bool) { + if filter.IsEmpty() && keyword == "" { return 0, false, true } + if !filter.IsEmpty() { + userID, matched, err := shared.ResolveUserIdentityFilter(ctx, h.userDB, appctx.FromContext(ctx), filter, time.Now().UnixMilli()) + return userID, matched, err == nil + } // 注册奖励领取记录的 owner 是 activity-service;列表关键词先解析成内部 user_id,再保持下游分页和 total 同源。 userID, matched, err := shared.ResolveExactUserID(ctx, h.userDB, appctx.FromContext(ctx), keyword, time.Now().UnixMilli()) if err != nil { diff --git a/server/admin/internal/modules/resource/handler.go b/server/admin/internal/modules/resource/handler.go index 93c6313c..0526fb6c 100644 --- a/server/admin/internal/modules/resource/handler.go +++ b/server/admin/internal/modules/resource/handler.go @@ -674,6 +674,7 @@ func (h *Handler) LookupResourceGrantTarget(c *gin.Context) { func (h *Handler) ListResourceGrants(c *gin.Context) { options := shared.ListOptions(c) appCode := appctx.FromContext(c.Request.Context()) + targetFilter := shared.UserIdentityFilterFromQuery(c, "target") targetUserID, _, err := shared.ResolveExactUserIDOrNumericFallback( c.Request.Context(), h.userDB, @@ -689,6 +690,18 @@ func (h *Handler) ListResourceGrants(c *gin.Context) { response.ServerError(c, "查询赠送用户失败") return } + if targetUserID == 0 && !targetFilter.IsEmpty() { + var matched bool + targetUserID, matched, err = shared.ResolveUserIdentityFilter(c.Request.Context(), h.userDB, appCode, targetFilter, time.Now().UnixMilli()) + if err != nil { + response.ServerError(c, "查询赠送用户失败") + return + } + if !matched { + response.OK(c, response.Page{Items: []grantDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0}) + return + } + } resp, err := h.wallet.ListResourceGrants(c.Request.Context(), &walletv1.ListResourceGrantsRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appCode, @@ -745,7 +758,20 @@ func (h *Handler) ListResourceShopPurchaseOrders(c *gin.Context) { if !ok { return } + userFilter := shared.UserIdentityFilterFromQuery(c, "") userKeyword := strings.TrimSpace(firstQuery(c, "user_keyword", "userKeyword", "user")) + if userID == 0 && !userFilter.IsEmpty() { + resolvedUserID, matched, err := shared.ResolveUserIdentityFilter(c.Request.Context(), h.userDB, appCode, userFilter, time.Now().UnixMilli()) + if err != nil { + response.ServerError(c, "查询购买用户失败") + return + } + if !matched { + response.OK(c, response.Page{Items: []resourceShopPurchaseOrderDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0}) + return + } + userID = resolvedUserID + } if userID == 0 && userKeyword != "" { resolvedUserID, matched, err := h.resolveResourceShopPurchaseUserID(c.Request.Context(), appCode, userKeyword) if err != nil { diff --git a/server/admin/internal/modules/roomadmin/handler.go b/server/admin/internal/modules/roomadmin/handler.go index 0be1c814..d3c85795 100644 --- a/server/admin/internal/modules/roomadmin/handler.go +++ b/server/admin/internal/modules/roomadmin/handler.go @@ -252,6 +252,7 @@ func parseListQuery(c *gin.Context) (listQuery, bool) { PageSize: options.PageSize, Keyword: options.Keyword, OwnerUserKeyword: firstQueryValue(c, "ownerUserKeyword", "owner_user_keyword"), + OwnerFilter: shared.UserIdentityFilterFromQuery(c, "owner"), Status: options.Status, SortBy: firstQueryValue(c, "sortBy", "sort_by"), SortDirection: firstQueryValue(c, "sortDirection", "sort_direction", "order"), diff --git a/server/admin/internal/modules/roomadmin/request.go b/server/admin/internal/modules/roomadmin/request.go index df2242ff..a6e67a36 100644 --- a/server/admin/internal/modules/roomadmin/request.go +++ b/server/admin/internal/modules/roomadmin/request.go @@ -5,6 +5,8 @@ import ( "fmt" "strconv" "strings" + + "hyapp-admin-server/internal/modules/shared" ) type listQuery struct { @@ -13,6 +15,7 @@ type listQuery struct { Keyword string OwnerUserID int64 OwnerUserKeyword string + OwnerFilter shared.UserIdentityFilter Status string RegionID int64 SortBy string diff --git a/server/admin/internal/modules/roomadmin/service.go b/server/admin/internal/modules/roomadmin/service.go index 2b9e7445..308ac91b 100644 --- a/server/admin/internal/modules/roomadmin/service.go +++ b/server/admin/internal/modules/roomadmin/service.go @@ -77,7 +77,7 @@ func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64 if err != nil { return nil, 0, err } - if strings.TrimSpace(query.OwnerUserKeyword) != "" && !ownerMatched { + if (!query.OwnerFilter.IsEmpty() || strings.TrimSpace(query.OwnerUserKeyword) != "") && !ownerMatched { return []Room{}, 0, nil } roomKeyword := query.Keyword @@ -115,6 +115,9 @@ func (s *Service) resolveListOwnerUserID(ctx context.Context, query listQuery) ( if query.OwnerUserID > 0 { return query.OwnerUserID, true, nil } + if !query.OwnerFilter.IsEmpty() { + return shared.ResolveUserIdentityFilter(ctx, s.userDB, appctx.FromContext(ctx), query.OwnerFilter, time.Now().UnixMilli()) + } return s.resolveOwnerUserID(ctx, query.OwnerUserKeyword) } @@ -380,6 +383,10 @@ func normalizeListQuery(query listQuery) listQuery { query.PageSize = 100 } query.Keyword = strings.TrimSpace(query.Keyword) + query.OwnerUserKeyword = strings.TrimSpace(query.OwnerUserKeyword) + query.OwnerFilter.UserID = strings.TrimSpace(query.OwnerFilter.UserID) + query.OwnerFilter.DisplayUserID = strings.TrimSpace(query.OwnerFilter.DisplayUserID) + query.OwnerFilter.Username = strings.TrimSpace(query.OwnerFilter.Username) query.Status = strings.ToLower(strings.TrimSpace(query.Status)) if status, ok := normalizeRoomStatus(query.Status); ok { query.Status = status diff --git a/server/admin/internal/modules/roomturnoverreward/handler.go b/server/admin/internal/modules/roomturnoverreward/handler.go index 43a1e042..91d3e0a2 100644 --- a/server/admin/internal/modules/roomturnoverreward/handler.go +++ b/server/admin/internal/modules/roomturnoverreward/handler.go @@ -130,7 +130,8 @@ func (h *Handler) ListSettlements(c *gin.Context) { // 列表筛选沿用通用 ListOptions:status 过滤结算状态,keyword 用作 room_id 精确查询,避免后台接口引入模糊扫表。 options := shared.ListOptions(c) ownerKeyword := strings.TrimSpace(firstQuery(c, "owner_user_keyword", "ownerUserKeyword")) - ownerUserID, ownerMatched, ownerOK := h.resolveOwnerUserID(c.Request.Context(), ownerKeyword) + ownerFilter := shared.UserIdentityFilterFromQuery(c, "owner") + ownerUserID, ownerMatched, ownerOK := h.resolveOwnerUserID(c.Request.Context(), ownerKeyword, ownerFilter) if !ownerOK { response.ServerError(c, "查询房主信息失败") return @@ -138,7 +139,7 @@ func (h *Handler) ListSettlements(c *gin.Context) { if !ownerMatched { ownerUserID = queryInt64(c, "owner_user_id", "ownerUserId") } - if !ownerMatched && ownerKeyword != "" { + if !ownerMatched && (ownerKeyword != "" || !ownerFilter.IsEmpty()) { response.OK(c, response.Page{Items: []settlementDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0}) return } @@ -197,10 +198,14 @@ func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta { } } -func (h *Handler) resolveOwnerUserID(ctx context.Context, keyword string) (int64, bool, bool) { - if keyword == "" { +func (h *Handler) resolveOwnerUserID(ctx context.Context, keyword string, filter shared.UserIdentityFilter) (int64, bool, bool) { + if filter.IsEmpty() && keyword == "" { return 0, false, true } + if !filter.IsEmpty() { + userID, matched, err := shared.ResolveUserIdentityFilter(ctx, h.userDB, appctx.FromContext(ctx), filter, time.Now().UnixMilli()) + return userID, matched, err == nil + } if h.userDB == nil { // userDB 缺失时只允许正整数长 ID 精确筛选,避免把短号或昵称误当成 settlement.owner_user_id 查询。 userID, matched, err := shared.ResolveExactUserIDOrNumericFallback(ctx, nil, appctx.FromContext(ctx), keyword, time.Now().UnixMilli()) diff --git a/server/admin/internal/modules/sevendaycheckin/handler.go b/server/admin/internal/modules/sevendaycheckin/handler.go index 95ac6b75..b4817131 100644 --- a/server/admin/internal/modules/sevendaycheckin/handler.go +++ b/server/admin/internal/modules/sevendaycheckin/handler.go @@ -112,12 +112,13 @@ func (h *Handler) UpdateSevenDayCheckInConfig(c *gin.Context) { func (h *Handler) ListSevenDayCheckInClaims(c *gin.Context) { options := shared.ListOptions(c) - userID, matched, ok := h.resolveClaimUserID(c.Request.Context(), strings.TrimSpace(options.Keyword)) + userFilter := shared.UserIdentityFilterFromQuery(c, "") + userID, matched, ok := h.resolveClaimUserID(c.Request.Context(), strings.TrimSpace(options.Keyword), userFilter) if !ok { response.ServerError(c, "查询用户信息失败") return } - if options.Keyword != "" && !matched { + if (options.Keyword != "" || !userFilter.IsEmpty()) && !matched { response.OK(c, response.Page{Items: []claimDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0}) return } @@ -159,10 +160,14 @@ func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta { } } -func (h *Handler) resolveClaimUserID(ctx context.Context, keyword string) (int64, bool, bool) { - if keyword == "" { +func (h *Handler) resolveClaimUserID(ctx context.Context, keyword string, filter shared.UserIdentityFilter) (int64, bool, bool) { + if filter.IsEmpty() && keyword == "" { return 0, false, true } + if !filter.IsEmpty() { + userID, matched, err := shared.ResolveUserIdentityFilter(ctx, h.userDB, appctx.FromContext(ctx), filter, time.Now().UnixMilli()) + return userID, matched, err == nil + } // 签到领取记录按 activity-service 的内部 user_id 查;短号、靓号和昵称先在 admin 侧解析,避免下游误用展示 ID。 userID, matched, err := shared.ResolveExactUserID(ctx, h.userDB, appctx.FromContext(ctx), keyword, time.Now().UnixMilli()) if err != nil { diff --git a/server/admin/internal/modules/shared/user_identity_query.go b/server/admin/internal/modules/shared/user_identity_query.go new file mode 100644 index 00000000..bcaf6c50 --- /dev/null +++ b/server/admin/internal/modules/shared/user_identity_query.go @@ -0,0 +1,39 @@ +package shared + +import ( + "strings" + + "github.com/gin-gonic/gin" +) + +func UserIdentityFilterFromQuery(c *gin.Context, prefix string) UserIdentityFilter { + prefix = strings.TrimSpace(prefix) + return UserIdentityFilter{ + UserID: firstQuery(c, queryNames(prefix, "user_id", "userId")...), + DisplayUserID: firstQuery(c, queryNames(prefix, "display_user_id", "displayUserId")...), + Username: firstQuery(c, queryNames(prefix, "username", "username")...), + } +} + +func firstQuery(c *gin.Context, names ...string) string { + for _, name := range names { + if value := strings.TrimSpace(c.Query(name)); value != "" { + return value + } + } + return "" +} + +func queryNames(prefix string, snake string, camel string) []string { + if prefix == "" { + return []string{snake, camel} + } + return []string{prefix + "_" + snake, prefix + upperFirst(camel)} +} + +func upperFirst(value string) string { + if value == "" { + return "" + } + return strings.ToUpper(value[:1]) + value[1:] +} diff --git a/server/admin/internal/modules/shared/user_identity_resolver.go b/server/admin/internal/modules/shared/user_identity_resolver.go index 66a6a78d..4a6397bb 100644 --- a/server/admin/internal/modules/shared/user_identity_resolver.go +++ b/server/admin/internal/modules/shared/user_identity_resolver.go @@ -42,6 +42,30 @@ func ResolveExactUserID(ctx context.Context, db *sql.DB, appCode string, keyword return userID, true, nil } +func ResolveUserIdentityFilter(ctx context.Context, db *sql.DB, appCode string, filter UserIdentityFilter, nowMs int64) (int64, bool, error) { + if filter.IsEmpty() || db == nil { + return 0, false, nil + } + matchSQL, matchArgs := UserIdentityFieldsSQL("u", "u.user_id", filter, nowMs) + args := append([]any{appCode}, matchArgs...) + row := db.QueryRowContext(ctx, ` + 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 1`, + args..., + ) + var userID int64 + if err := row.Scan(&userID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, false, nil + } + return 0, false, err + } + return userID, true, nil +} + // ResolveExactUserIDOrNumericFallback 用于列表 owner 在 wallet/activity 等下游服务的场景。 // 下游只认内部 user_id,后台先按短号/靓号解析;解析不到时只接受正整数长 ID,兼容用户资料缺失的历史事实记录。 func ResolveExactUserIDOrNumericFallback(ctx context.Context, db *sql.DB, appCode string, keyword string, nowMs int64) (int64, bool, error) { diff --git a/server/admin/internal/modules/shared/user_identity_sql.go b/server/admin/internal/modules/shared/user_identity_sql.go index d47abe1b..f24d479a 100644 --- a/server/admin/internal/modules/shared/user_identity_sql.go +++ b/server/admin/internal/modules/shared/user_identity_sql.go @@ -5,6 +5,18 @@ import ( "strings" ) +type UserIdentityFilter struct { + UserID string + DisplayUserID string + Username string +} + +func (filter UserIdentityFilter) IsEmpty() bool { + return strings.TrimSpace(filter.UserID) == "" && + strings.TrimSpace(filter.DisplayUserID) == "" && + strings.TrimSpace(filter.Username) == "" +} + func UserIdentityKeywordSQL(userAlias string, userIDExpr string, keyword string, nowMs int64) (string, []any) { keyword = strings.TrimSpace(keyword) if keyword == "" { @@ -27,6 +39,29 @@ func UserIdentityKeywordSQL(userAlias string, userIDExpr string, keyword string, } } +func UserIdentityFieldsSQL(userAlias string, userIDExpr string, filter UserIdentityFilter, nowMs int64) (string, []any) { + clauses := make([]string, 0, 3) + args := make([]any, 0, 8) + if userID := strings.TrimSpace(filter.UserID); userID != "" { + clauses = append(clauses, fmt.Sprintf("CAST(%s AS CHAR) = ?", userIDExpr)) + args = append(args, userID) + } + if displayUserID := strings.TrimSpace(filter.DisplayUserID); displayUserID != "" { + displaySQL, displayArgs := UserDisplayIDSQL(userAlias, displayUserID, nowMs) + clauses = append(clauses, displaySQL) + args = append(args, displayArgs...) + } + if username := strings.TrimSpace(filter.Username); username != "" { + clauses = append(clauses, userAlias+".username LIKE ?") + args = append(args, "%"+username+"%") + } + if len(clauses) == 0 { + return "1 = 1", nil + } + // 字段拆分后每个输入都是一个明确约束;同时输入多个字段时取交集,防止短 ID 和昵称互相扩大结果集。 + return "(" + strings.Join(clauses, " AND ") + ")", args +} + func UserIdentityExactSQL(userAlias string, userIDExpr string, keyword string, nowMs int64) (string, []any) { keyword = strings.TrimSpace(keyword) if keyword == "" { diff --git a/server/admin/internal/modules/teamsalarysettlement/dto.go b/server/admin/internal/modules/teamsalarysettlement/dto.go index f6c7a0de..9038d447 100644 --- a/server/admin/internal/modules/teamsalarysettlement/dto.go +++ b/server/admin/internal/modules/teamsalarysettlement/dto.go @@ -5,6 +5,8 @@ import ( "fmt" "strconv" "strings" + + "hyapp-admin-server/internal/modules/shared" ) // settlementUserID 只用于批量结算 HTTP 入参,兼容前端新合约的字符串 ID 和旧页面残留的数字 ID。 @@ -106,6 +108,7 @@ type pendingQuery struct { CountryID int64 CountryCode string UserID int64 + UserFilter shared.UserIdentityFilter } type recordQuery struct { @@ -119,6 +122,7 @@ type recordQuery struct { CountryID int64 CountryCode string UserID int64 + UserFilter shared.UserIdentityFilter PolicyID uint64 StartAtMS int64 EndAtMS int64 diff --git a/server/admin/internal/modules/teamsalarysettlement/handler.go b/server/admin/internal/modules/teamsalarysettlement/handler.go index 1f1adaad..7569edd8 100644 --- a/server/admin/internal/modules/teamsalarysettlement/handler.go +++ b/server/admin/internal/modules/teamsalarysettlement/handler.go @@ -97,6 +97,7 @@ func parsePendingQuery(c *gin.Context) (pendingQuery, bool) { TriggerMode: normalizeTriggerMode(firstQuery(c, "trigger_mode", "triggerMode")), CycleKey: strings.TrimSpace(firstQuery(c, "cycle_key", "cycleKey")), CountryCode: strings.ToUpper(strings.TrimSpace(firstQuery(c, "country_code", "countryCode"))), + UserFilter: shared.UserIdentityFilterFromQuery(c, ""), } var ok bool if req.RegionID, ok = optionalInt64(c, "region_id", "regionId"); !ok { @@ -133,6 +134,7 @@ func parseRecordQuery(c *gin.Context) (recordQuery, bool) { CycleKey: strings.TrimSpace(firstQuery(c, "cycle_key", "cycleKey")), Status: normalizeStatus(firstQuery(c, "status")), CountryCode: strings.ToUpper(strings.TrimSpace(firstQuery(c, "country_code", "countryCode"))), + UserFilter: shared.UserIdentityFilterFromQuery(c, ""), } var ok bool if req.RegionID, ok = optionalInt64(c, "region_id", "regionId"); !ok { diff --git a/server/admin/internal/modules/teamsalarysettlement/service.go b/server/admin/internal/modules/teamsalarysettlement/service.go index 48f2ada1..6de61e6e 100644 --- a/server/admin/internal/modules/teamsalarysettlement/service.go +++ b/server/admin/internal/modules/teamsalarysettlement/service.go @@ -14,6 +14,8 @@ import ( "strconv" "strings" "time" + + "hyapp-admin-server/internal/modules/shared" ) const ( @@ -124,6 +126,15 @@ func (s *Service) ListPending(ctx context.Context, appCode string, req pendingQu return nil, 0, err } req = normalizePendingQuery(req) + var matched bool + var err error + req.UserID, matched, err = s.resolveFilterUserID(ctx, appCode, req.UserID, req.UserFilter) + if err != nil { + return nil, 0, err + } + if !matched { + return []pendingDTO{}, 0, nil + } // 待结算列表复用结算候选计算,保证运营看到的金额与真正点击结算时的金额口径一致。 candidates, err := s.collectCandidates(ctx, strings.TrimSpace(appCode), req.PolicyType, req.TriggerMode, req.CycleKey, req.RegionID, req.CountryID, req.CountryCode, req.UserID, time.Now().UTC().UnixMilli()) if err != nil { @@ -157,6 +168,15 @@ func (s *Service) ListRecords(ctx context.Context, appCode string, req recordQue return nil, 0, err } req = normalizeRecordQuery(req) + var matched bool + var err error + req.UserID, matched, err = s.resolveFilterUserID(ctx, appCode, req.UserID, req.UserFilter) + if err != nil { + return nil, 0, err + } + if !matched { + return []recordDTO{}, 0, nil + } whereSQL, args, err := s.recordWhere(ctx, strings.TrimSpace(appCode), req) if err != nil { return nil, 0, err @@ -210,6 +230,17 @@ func (s *Service) ListRecords(ctx context.Context, appCode string, req recordQue return items, total, nil } +func (s *Service) resolveFilterUserID(ctx context.Context, appCode string, currentUserID int64, filter shared.UserIdentityFilter) (int64, bool, error) { + if currentUserID > 0 || filter.IsEmpty() { + return currentUserID, true, nil + } + userID, matched, err := shared.ResolveUserIdentityFilter(ctx, s.userDB, strings.TrimSpace(appCode), filter, time.Now().UnixMilli()) + if err != nil || !matched { + return 0, matched, err + } + return userID, true, nil +} + // Settle 执行手动或自动结算;它只关心“触发方式”和“用户集合”,差值与幂等统一落在 settleCandidate。 func (s *Service) Settle(ctx context.Context, appCode string, operatorAdminID int64, req settleRequest) (settleResultDTO, error) { if err := s.ensureReady(ctx); err != nil { diff --git a/services/statistics-service/cmd/backfill-last7/main.go b/services/statistics-service/cmd/backfill-last7/main.go index dbd91bb4..aa5f3933 100644 --- a/services/statistics-service/cmd/backfill-last7/main.go +++ b/services/statistics-service/cmd/backfill-last7/main.go @@ -903,6 +903,15 @@ func stageAndMaybeApply(ctx context.Context, db *sql.DB, state *rebuildState, ap return err } } + // 注册投影的主键是 app_code+stat_tz+user_id,不包含 registered_day。历史投影如果曾用错误时区写入, + // 仅按日期窗口删除会漏掉同一 user_id 的旧行;本次重算的用户集合必须先清掉,保证补数可重复执行。 + if _, err := tx.ExecContext(ctx, ` + DELETE FROM stat_user_registration + WHERE app_code = ? AND stat_tz = ? + AND user_id IN (SELECT user_id FROM tmp_stat_backfill_registration)`, + app, statTZ); err != nil { + return err + } for userID, dim := range state.userDims { if _, err := tx.ExecContext(ctx, ` INSERT INTO stat_user_dimension (app_code, user_id, country_id, region_id, updated_at_ms)