找回密码
立即注册
搜索
发回帖 发新帖

6313

积分

0

好友

770

主题
发表于 昨天 22:30 | 查看: 9| 回复: 0

一、背景

风控后台风险提示记录界面

1. 为什么要做风控?

这不得拜产品大佬所赐。目前我们业务里用到了不少 AI 能力,比如 OCR 识别、语音测评等等,这些能力要么费钱、要么费资源。所以产品层面也希望给用户的能力使用次数做一定限制,风控自然就成了刚需。

2. 为什么要自己写风控?

那么多开源的风控组件,为什么还要自己写呢?是不是想重复发明轮子呀。

要回答这个问题,得先弄清楚我们业务需要的风控(简称业务风控)和常见开源风控(简称普通风控)之间的区别:

业务风控与普通风控对比表

因此,直接套用开源的普通风控,一般情况下是满足不了需求的。

3. 其它要求

支持实时调整限制。不少限制值在第一次设置时基本都是拍脑袋定的,后续调整的可能性很大,所以“可调整 + 实时生效”是必须的。

二、思路

要实现一个简单的业务风控组件,需要做哪些事情?

1. 风控规则的实现

a. 需要实现的规则:

  • 自然日计数
  • 自然小时计数
  • 自然日 + 自然小时计数

自然日 + 自然小时计数不能简单地串联两个判断。因为如果自然日判定通过了,自然小时判定没通过,需要回退——自然日和自然小时都不能计入本次调用。

b. 计数方式的选择:

目前能想到的方案主要有:

  • mysql + db 事务:持久化、记录可溯源,但实现麻烦,整体偏“重”
  • redis+lua:实现简单,Redis 可执行 Lua 脚本的特性也能满足对“事务”的要求
  • mysql/redis + 分布式事务:需要上锁,实现复杂,但能做到比较精确的计数,也就是真正等代码块执行成功之后再操作计数

目前没有很精确的计数要求,代价太大,也没有持久化需求,所以选用 redis+lua 即可。

2. 调用方式的实现

a. 常见做法

先定义一个通用入口:

// 简化版代码

@Component
class DetectManager {

    fun matchExceptionally(eventId: String, content: String) {
        // 调用规则匹配
        val rt = ruleService.match(eventId, content)
        if (!rt) {
            throw BaseException(ErrorCode.OPERATION_TOO_FREQUENT)
        }
    }
}

在 Service 中调用该方法:

// 简化版代码

@Service
class OcrServiceImpl : OcrService {

    @Autowired
    private lateinit var detectManager: DetectManager

    /**
     * 提交 OCR 任务
     * 需要根据用户 id 来做次数限制
     */
    override fun submitOcrTask(userId: String, imageUrl: String): String {
        detectManager.matchExceptionally("ocr", userId)
        // do ocr
    }
}

有没有更优雅一点的方法?用注解可能会更好一些(这个也比较有争议,这边先支持实现)。

因为传入的 content 跟业务关联,所以需要通过 SpEL 将参数组装成对应的 content。

三、具体实现

1. 风控计数规则实现

a. 自然日 / 自然小时

自然日和自然小时可以共用一套 Lua 脚本,因为它们只是 key 不同。脚本如下:

-- 自然日 / 自然小时计数脚本
local currentValue = redis.call('get', KEYS[1]);
if currentValue ~= false then
    if tonumber(currentValue) < tonumber(ARGV[1]) then
        return redis.call('INCR', KEYS[1]);
    else
        return tonumber(currentValue) + 1;
    end;
else
    redis.call('set', KEYS[1], 1, 'px', ARGV[2]);
    return 1;
end;

其中 KEYS[1] 是日/小时关联的 key,ARGV[1] 是上限值,ARGV[2] 是过期时间,返回值是当前计数值 +1 后的结果(如果已经达到上限,实际上不会计数)。

b. 自然日 + 自然小时

如前文提到的,两者结合并不是单纯拼凑,还要处理回退逻辑。

-- 自然日 + 自然小时组合计数脚本(含回退逻辑)
local dayValue = 0;
local hourValue = 0;
local dayPass = true;
local hourPass = true;

-- 判断自然日
local dayCurrentValue = redis.call('get', KEYS[1]);
if dayCurrentValue ~= false then
    if tonumber(dayCurrentValue) < tonumber(ARGV[1]) then
        dayValue = redis.call('INCR', KEYS[1]);
    else
        dayPass = false;
        dayValue = tonumber(dayCurrentValue) + 1;
    end;
else
    redis.call('set', KEYS[1], 1, 'px', ARGV[3]);
    dayValue = 1;
end;

-- 判断自然小时
local hourCurrentValue = redis.call('get', KEYS[2]);
if hourCurrentValue ~= false then
    if tonumber(hourCurrentValue) < tonumber(ARGV[2]) then
        hourValue = redis.call('INCR', KEYS[2]);
    else
        hourPass = false;
        hourValue = tonumber(hourCurrentValue) + 1;
    end;
else
    redis.call('set', KEYS[2], 1, 'px', ARGV[4]);
    hourValue = 1;
end;

-- 回退逻辑:一个条件不满足时,回退另一个已经自增的计数
if (not dayPass) and hourPass then
    hourValue = redis.call('DECR', KEYS[2]);
end;

if dayPass and (not hourPass) then
    dayValue = redis.call('DECR', KEYS[1]);
end;

local pair = {};
pair[1] = dayValue;
pair[2] = hourValue;
return pair;

其中 KEYS[1] 是天关联生成的 key,KEYS[2] 是小时关联生成的 key;ARGV[1] 是天的上限值,ARGV[2] 是小时的上限值;ARGV[3] 是天的过期时间,ARGV[4] 是小时的过期时间,返回值同上。

这里给的是比较粗糙的写法,主要想表达的是:进行两个条件判断时,只要其中一个不满足,另一个都需要回退。

2. 注解的实现

a. 定义一个 @Detect 注解

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
annotation class Detect(

    /**
     * 事件 id
     */
    val eventId: String = "",

    /**
     * content 的 SpEL 表达式
     */
    val contentSpel: String = ""
)

其中 content 是需要经过表达式解析出来的,所以接收的是 String。

b. 定义 @Detect 注解的处理类

@Aspect
@Component
class DetectHandler {

    private val logger = LoggerFactory.getLogger(javaClass)

    @Autowired
    private lateinit var detectManager: DetectManager

    @Resource(name = "detectSpelExpressionParser")
    private lateinit var spelExpressionParser: SpelExpressionParser

    @Bean(name = ["detectSpelExpressionParser"])
    fun detectSpelExpressionParser(): SpelExpressionParser {
        return SpelExpressionParser()
    }

    @Around(value = "@annotation(detect)")
    fun operatorAnnotation(joinPoint: ProceedingJoinPoint, detect: Detect): Any? {
        if (detect.eventId.isBlank() || detect.contentSpel.isBlank()) {
            throw illegalArgumentExp("@Detect config is not available!")
        }
        // 转换表达式
        val expression = spelExpressionParser.parseExpression(detect.contentSpel)
        val argMap = joinPoint.args.mapIndexed { index, any ->
            "arg${index + 1}" to any
        }.toMap()
        // 构建上下文
        val context = StandardEvaluationContext().apply {
            if (argMap.isNotEmpty()) this.setVariables(argMap)
        }
        // 拿到结果
        val content = expression.getValue(context)

        detectManager.matchExceptionally(detect.eventId, content)
        return joinPoint.proceed()
    }
}

需要把参数放进上下文中,并命名成 arg1、arg2 ...

四、测试一下

1. 写法

使用注解之后的写法简洁多了:

// 简化版代码

@Service
class OcrServiceImpl : OcrService {

    @Autowired
    private lateinit var detectManager: DetectManager

    /**
     * 提交 OCR 任务
     * 需要根据用户 id 来做次数限制
     */
    @Detect(eventId = "ocr", contentSpel = "#arg1")
    override fun submitOcrTask(userId: String, imageUrl: String): String {
        // do ocr
    }
}

2. Debug 看看

Kotlin 风控注解处理类断点调试截图

  • 注解值获取成功
  • 表达式解析成功

整个组件就是这么简单:计数交给 Redis + Lua,调用方式交给注解 + AOP,业务代码里几乎零侵入。如果大家还有别的风控实现思路,也欢迎到 云栈社区 一起交流。




上一篇:游戏服务器为何不搞微服务?王者荣耀实时对战架构拆解
下一篇:PHP 核心两枚 RCE 泄露:CVE-2026-109618/-109619 事件梳理与应急响应
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-22 07:27 , Processed in 0.071847 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表