195 lines
5.4 KiB
Go
195 lines
5.4 KiB
Go
package regionblock
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"hyapp-admin-server/internal/appctx"
|
||
)
|
||
|
||
const (
|
||
maxRegionBlockKeywords = 200
|
||
maxRegionBlockKeywordLength = 128
|
||
)
|
||
|
||
var (
|
||
countryCodePattern = regexp.MustCompile(`^[A-Za-z]{2,3}$`)
|
||
errInvalidPayload = errors.New("region block payload invalid")
|
||
)
|
||
|
||
type Service struct {
|
||
userDB *sql.DB
|
||
}
|
||
|
||
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 normalizedKeyword struct {
|
||
Keyword string
|
||
CountryCode string
|
||
}
|
||
|
||
type countryCodeQuerier interface {
|
||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||
}
|
||
|
||
func NewService(userDB *sql.DB) *Service {
|
||
return &Service{userDB: userDB}
|
||
}
|
||
|
||
// List 返回当前 App 已启用的登录地区屏蔽词。
|
||
// 表属于 user-service 登录风控事实,admin-server 只作为后台维护入口直接写 user DB。
|
||
func (s *Service) List(ctx context.Context) ([]RegionBlock, error) {
|
||
if s.userDB == nil {
|
||
return nil, errors.New("user db is not configured")
|
||
}
|
||
return queryEnabledBlocks(ctx, s.userDB, appctx.FromContext(ctx))
|
||
}
|
||
|
||
// Replace 用页面提交的词表整体替换当前 App 的生效配置。
|
||
// 整体替换比逐条增删更适合配置页保存语义,也能避免客户端和服务端出现半旧半新的屏蔽词集合。
|
||
func (s *Service) Replace(ctx context.Context, actorID int64, keywords []string) ([]RegionBlock, error) {
|
||
if s.userDB == nil {
|
||
return nil, errors.New("user db is not configured")
|
||
}
|
||
normalized, err := normalizeKeywords(ctx, s.userDB, appctx.FromContext(ctx), keywords)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
tx, err := s.userDB.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return nil, 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 nil, 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 nil, err
|
||
}
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return nil, 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 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 nullableActor(actorID int64) any {
|
||
if actorID <= 0 {
|
||
return nil
|
||
}
|
||
return actorID
|
||
}
|