package regionblock import ( "context" "database/sql" "errors" "net/netip" "regexp" "strconv" "strings" "time" "unicode/utf8" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/userclient" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) const ( maxRegionBlockKeywords = 200 maxRegionBlockKeywordLength = 128 maxIPWhitelistItems = 500 maxUserWhitelistItems = 500 maxUserWhitelistLookupLength = 64 ) var ( countryCodePattern = regexp.MustCompile(`^[A-Za-z]{2,3}$`) errInvalidPayload = errors.New("region block payload invalid") errWhitelistCacheRefresh = errors.New("login risk whitelist cache refresh failed") ) type Service struct { userDB *sql.DB user userclient.Client } type RegionBlock struct { BlockID int64 `json:"block_id"` Keyword string `json:"keyword"` CountryCode string `json:"country_code"` Enabled bool `json:"enabled"` CreatedAtMS int64 `json:"created_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` } type IPWhitelistItem struct { WhitelistID int64 `json:"whitelist_id"` IPAddress string `json:"ip_address"` Enabled bool `json:"enabled"` CreatedAtMS int64 `json:"created_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` } type UserWhitelistItem struct { WhitelistID int64 `json:"whitelist_id"` UserID int64 `json:"user_id,string"` DisplayUserID string `json:"display_user_id"` Enabled bool `json:"enabled"` CreatedAtMS int64 `json:"created_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` } type Config struct { Items []RegionBlock `json:"items"` Total int `json:"total"` WhitelistItems []IPWhitelistItem `json:"whitelist_items"` WhitelistTotal int `json:"whitelist_total"` WhitelistUserItems []UserWhitelistItem `json:"whitelist_user_items"` WhitelistUserTotal int `json:"whitelist_user_total"` } type normalizedKeyword struct { Keyword string CountryCode string } type countryCodeQuerier interface { QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row } func NewService(userDB *sql.DB, user userclient.Client) *Service { return &Service{userDB: userDB, user: user} } // List 返回当前 App 已启用的登录地区屏蔽词、IP 白名单和用户白名单。 // 表属于 user-service 登录风控事实,admin-server 只作为后台维护入口直接写 user DB。 func (s *Service) List(ctx context.Context) (Config, error) { if s.userDB == nil { return Config{}, errors.New("user db is not configured") } appCode := appctx.FromContext(ctx) blocks, err := queryEnabledBlocks(ctx, s.userDB, appCode) if err != nil { return Config{}, err } whitelist, err := queryEnabledIPWhitelist(ctx, s.userDB, appCode) if err != nil { return Config{}, err } userWhitelist, err := queryEnabledUserWhitelist(ctx, s.userDB, appCode) if err != nil { return Config{}, err } return Config{Items: blocks, Total: len(blocks), WhitelistItems: whitelist, WhitelistTotal: len(whitelist), WhitelistUserItems: userWhitelist, WhitelistUserTotal: len(userWhitelist)}, nil } // Replace 用页面提交的词表整体替换当前 App 的生效配置。 // 整体替换比逐条增删更适合配置页保存语义,也能避免客户端和服务端出现半旧半新的屏蔽词集合。 func (s *Service) Replace(ctx context.Context, requestID string, actorID int64, keywords []string, whitelistIPs *[]string, whitelistUserIDs *[]string) (Config, error) { if s.userDB == nil { return Config{}, errors.New("user db is not configured") } normalized, err := normalizeKeywords(ctx, s.userDB, appctx.FromContext(ctx), keywords) if err != nil { return Config{}, err } var normalizedIPs []string if whitelistIPs != nil { normalizedIPs, err = normalizeIPWhitelist(*whitelistIPs) if err != nil { return Config{}, err } } var normalizedUserIDs []int64 if whitelistUserIDs != nil { normalizedUserIDs, err = s.normalizeUserIDWhitelist(ctx, requestID, *whitelistUserIDs) if err != nil { return Config{}, err } } tx, err := s.userDB.BeginTx(ctx, nil) if err != nil { return Config{}, err } defer tx.Rollback() nowMS := time.Now().UTC().UnixMilli() appCode := appctx.FromContext(ctx) if _, err := tx.ExecContext(ctx, ` UPDATE login_risk_country_blocks SET enabled = 0, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND enabled = 1 `, nullableActor(actorID), nowMS, appCode); err != nil { return Config{}, err } for _, keyword := range normalized { if _, err := tx.ExecContext(ctx, ` INSERT INTO login_risk_country_blocks ( app_code, keyword, country_code, enabled, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, 1, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE country_code = VALUES(country_code), enabled = 1, updated_by_user_id = VALUES(updated_by_user_id), updated_at_ms = VALUES(updated_at_ms) `, appCode, keyword.Keyword, keyword.CountryCode, nullableActor(actorID), nullableActor(actorID), nowMS, nowMS); err != nil { return Config{}, err } } if whitelistIPs != nil { if _, err := tx.ExecContext(ctx, ` UPDATE login_risk_ip_whitelist SET enabled = 0, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND enabled = 1 `, nullableActor(actorID), nowMS, appCode); err != nil { return Config{}, err } for _, ip := range normalizedIPs { if _, err := tx.ExecContext(ctx, ` INSERT INTO login_risk_ip_whitelist ( app_code, ip_address, enabled, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, 1, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE enabled = 1, updated_by_user_id = VALUES(updated_by_user_id), updated_at_ms = VALUES(updated_at_ms) `, appCode, ip, nullableActor(actorID), nullableActor(actorID), nowMS, nowMS); err != nil { return Config{}, err } } } if whitelistUserIDs != nil { if _, err := tx.ExecContext(ctx, ` UPDATE login_risk_user_whitelist SET enabled = 0, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND enabled = 1 `, nullableActor(actorID), nowMS, appCode); err != nil { return Config{}, err } for _, userID := range normalizedUserIDs { if _, err := tx.ExecContext(ctx, ` INSERT INTO login_risk_user_whitelist ( app_code, user_id, enabled, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, 1, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE enabled = 1, updated_by_user_id = VALUES(updated_by_user_id), updated_at_ms = VALUES(updated_at_ms) `, appCode, userID, nullableActor(actorID), nullableActor(actorID), nowMS, nowMS); err != nil { return Config{}, err } } } if err := tx.Commit(); err != nil { return Config{}, err } if whitelistIPs != nil || whitelistUserIDs != nil { if err := s.refreshWhitelistCache(ctx, requestID); err != nil { return Config{}, err } } return s.List(ctx) } func normalizeKeywords(ctx context.Context, querier countryCodeQuerier, appCode string, raw []string) ([]normalizedKeyword, error) { if len(raw) > maxRegionBlockKeywords { return nil, errInvalidPayload } result := make([]normalizedKeyword, 0, len(raw)) seen := map[string]struct{}{} for _, item := range raw { keyword := strings.TrimSpace(item) if keyword == "" || utf8.RuneCountInString(keyword) > maxRegionBlockKeywordLength { return nil, errInvalidPayload } if countryCodePattern.MatchString(keyword) { keyword = strings.ToUpper(keyword) } key := strings.ToLower(keyword) if _, ok := seen[key]; ok { continue } countryCode, err := resolveCountryCode(ctx, querier, appCode, keyword) if err != nil { return nil, err } seen[key] = struct{}{} result = append(result, normalizedKeyword{Keyword: keyword, CountryCode: countryCode}) } return result, nil } func normalizeIPWhitelist(raw []string) ([]string, error) { if len(raw) > maxIPWhitelistItems { return nil, errInvalidPayload } result := make([]string, 0, len(raw)) seen := map[string]struct{}{} for _, item := range raw { addr, err := netip.ParseAddr(strings.TrimSpace(item)) if err != nil { return nil, errInvalidPayload } ip := addr.String() if _, ok := seen[ip]; ok { continue } seen[ip] = struct{}{} result = append(result, ip) } return result, nil } func (s *Service) normalizeUserIDWhitelist(ctx context.Context, requestID string, raw []string) ([]int64, error) { if len(raw) > maxUserWhitelistItems { return nil, errInvalidPayload } result := make([]int64, 0, len(raw)) seen := map[int64]struct{}{} for _, item := range raw { value := strings.TrimSpace(item) if value == "" { return nil, errInvalidPayload } userID, err := s.resolveWhitelistUserID(ctx, requestID, value) if err != nil { return nil, err } if userID <= 0 { return nil, errInvalidPayload } if _, ok := seen[userID]; ok { continue } seen[userID] = struct{}{} result = append(result, userID) } return result, nil } func (s *Service) resolveWhitelistUserID(ctx context.Context, requestID string, raw string) (int64, error) { value := strings.TrimSpace(raw) if value == "" || utf8.RuneCountInString(value) > maxUserWhitelistLookupLength { return 0, errInvalidPayload } if s.user == nil { // 本地隔离测试没有 user-service client 时只接受真实 user_id,避免把短号误写成未知用户。 numericID, numericOK := parsePositiveInt64(value) if !numericOK { return 0, errInvalidPayload } return numericID, nil } // 后台输入优先按当前 active 展示号解析,默认短号和靓号都由 user-service 统一处理; // 保存时只落真实 user_id,后续用户改短号或靓号过期也不会影响登录风控白名单。 if identity, err := s.user.ResolveDisplayUserID(ctx, userclient.ResolveDisplayUserIDRequest{ RequestID: requestID, Caller: "admin-server", DisplayUserID: value, }); err != nil { if !isGRPCNotFound(err) && !isGRPCInvalidArgument(err) { return 0, err } } else if identity != nil && identity.UserID > 0 { return identity.UserID, nil } // 展示号没有命中时,纯数字输入再兜底按内部 user_id 校验;非数字靓号未命中就直接视为无效配置。 numericID, numericOK := parsePositiveInt64(value) if !numericOK { return 0, errInvalidPayload } if found, err := s.userExists(ctx, requestID, numericID); err != nil { return 0, err } else if found { return numericID, nil } return 0, errInvalidPayload } func (s *Service) userExists(ctx context.Context, requestID string, userID int64) (bool, error) { user, err := s.user.GetUser(ctx, userclient.GetUserRequest{ RequestID: requestID, Caller: "admin-server", UserID: userID, }) if err != nil { if isGRPCNotFound(err) { return false, nil } return false, err } return user != nil && user.UserID > 0, nil } func parsePositiveInt64(text string) (int64, bool) { parsed, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64) return parsed, err == nil && parsed > 0 } func isGRPCNotFound(err error) bool { return status.Code(err) == codes.NotFound } func isGRPCInvalidArgument(err error) bool { return status.Code(err) == codes.InvalidArgument } func (s *Service) refreshWhitelistCache(ctx context.Context, requestID string) error { if s.user == nil { return nil } _, err := s.user.RefreshLoginRiskWhitelistCache(ctx, userclient.RefreshLoginRiskWhitelistCacheRequest{ RequestID: requestID, Caller: "admin-server", }) if err != nil { return errWhitelistCacheRefresh } return nil } func resolveCountryCode(ctx context.Context, querier countryCodeQuerier, appCode string, keyword string) (string, error) { if countryCodePattern.MatchString(keyword) { return strings.ToUpper(keyword), nil } upperKeyword := strings.ToUpper(keyword) var countryCode string err := querier.QueryRowContext(ctx, ` SELECT country_code FROM countries WHERE app_code = ? AND ( country_name = ? OR country_display_name = ? OR UPPER(country_code) = ? OR UPPER(COALESCE(iso_alpha3, '')) = ? ) ORDER BY enabled DESC, sort_order ASC, country_id ASC LIMIT 1 `, appCode, keyword, keyword, upperKeyword, upperKeyword).Scan(&countryCode) if errors.Is(err, sql.ErrNoRows) { return "", nil } if err != nil { return "", err } return strings.ToUpper(strings.TrimSpace(countryCode)), nil } func queryEnabledBlocks(ctx context.Context, db *sql.DB, appCode string) ([]RegionBlock, error) { rows, err := db.QueryContext(ctx, ` SELECT block_id, keyword, country_code, enabled, created_at_ms, updated_at_ms FROM login_risk_country_blocks WHERE app_code = ? AND enabled = 1 ORDER BY country_code ASC, keyword ASC, block_id ASC `, appCode) if err != nil { return nil, err } defer rows.Close() items := make([]RegionBlock, 0) for rows.Next() { var item RegionBlock if err := rows.Scan(&item.BlockID, &item.Keyword, &item.CountryCode, &item.Enabled, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { return nil, err } items = append(items, item) } if err := rows.Err(); err != nil { return nil, err } return items, nil } func queryEnabledIPWhitelist(ctx context.Context, db *sql.DB, appCode string) ([]IPWhitelistItem, error) { rows, err := db.QueryContext(ctx, ` SELECT whitelist_id, ip_address, enabled, created_at_ms, updated_at_ms FROM login_risk_ip_whitelist WHERE app_code = ? AND enabled = 1 ORDER BY ip_address ASC, whitelist_id ASC `, appCode) if err != nil { return nil, err } defer rows.Close() items := make([]IPWhitelistItem, 0) for rows.Next() { var item IPWhitelistItem if err := rows.Scan(&item.WhitelistID, &item.IPAddress, &item.Enabled, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { return nil, err } items = append(items, item) } if err := rows.Err(); err != nil { return nil, err } return items, nil } func queryEnabledUserWhitelist(ctx context.Context, db *sql.DB, appCode string) ([]UserWhitelistItem, error) { rows, err := db.QueryContext(ctx, ` SELECT w.whitelist_id, w.user_id, COALESCE(u.current_display_user_id, ''), w.enabled, w.created_at_ms, w.updated_at_ms FROM login_risk_user_whitelist w LEFT JOIN users u ON u.app_code = w.app_code AND u.user_id = w.user_id WHERE w.app_code = ? AND w.enabled = 1 ORDER BY w.user_id ASC, w.whitelist_id ASC `, appCode) if err != nil { return nil, err } defer rows.Close() items := make([]UserWhitelistItem, 0) for rows.Next() { var item UserWhitelistItem if err := rows.Scan(&item.WhitelistID, &item.UserID, &item.DisplayUserID, &item.Enabled, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { return nil, err } if item.UserID > 0 { items = append(items, item) } } if err := rows.Err(); err != nil { return nil, err } return items, nil } func nullableActor(actorID int64) any { if actorID <= 0 { return nil } return actorID }