公共逻辑抽离

This commit is contained in:
zhx 2026-06-12 22:40:39 +08:00
parent f9e011b9cb
commit 3b491ffb26
22 changed files with 471 additions and 177 deletions

View File

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

View File

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

View File

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

View File

@ -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 精确命中;前端传展示名时不会被猜测转换,避免误筛到错误国家。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 展示号有过期语义,默认短号永不过期。

View File

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

View File

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

View File

@ -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 == "" {