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" ) const ( maxRegionBlockKeywords = 200 maxRegionBlockKeywordLength = 128 maxIPWhitelistItems = 500 maxUserWhitelistItems = 500 ) 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"` 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 = normalizeUserIDWhitelist(*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 normalizeUserIDWhitelist(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 := strconv.ParseInt(value, 10, 64) if err != nil || userID <= 0 { return nil, errInvalidPayload } if _, ok := seen[userID]; ok { continue } seen[userID] = struct{}{} result = append(result, userID) } return result, nil } 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 whitelist_id, user_id, enabled, created_at_ms, updated_at_ms FROM login_risk_user_whitelist WHERE app_code = ? AND enabled = 1 ORDER BY user_id ASC, 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.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 }