2026-06-30 10:47:21 +08:00

420 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}