2026-06-30 10:52:52 +08:00

497 lines
15 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"
"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 || 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
}