Gateway实现文本敏感词检测

429 阅读9分钟

1. 定义规范

1.1. GreenTextResult

package com.test.green;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;

import java.util.List;
import java.util.Map;

/**
 * 敏感词检测结果
 *
 * @author NightDW 2022/8/3 15:17
 */
@Data
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class GreenTextResult {

    /**
     * 是否检测通过
     */
    private final boolean pass;

    /**
     * 检测通过或不通过的原因
     */
    private final String message;

    /**
     * 用于存储检测到的敏感词
     */
    private final Map<String, List<String>> detail;

    public static GreenTextResult pass(String message) {
        return new GreenTextResult(true, message, null);
    }

    public static GreenTextResult notPass(String message) {
        return new GreenTextResult(false, message, null);
    }

    public static GreenTextResult notPass(Map<String, List<String>> detail) {
        return new GreenTextResult(false, "检测到敏感词", detail);
    }
}

1.2. GreenTextService

package com.test.green;

import org.springframework.data.util.Pair;
import reactor.core.publisher.Mono;

/**
 * 敏感词检测服务
 *
 * @author NightDW 2022/8/3 15:16
 */
public interface GreenTextService {

    /**
     * 检测文本text中是否含有敏感词
     *
     * @throws Exception 调用第三方接口时可能出现的错误,如连接问题等
     */
    GreenTextResult check(String text) throws Exception;

    /**
     * 支持响应式编程;这里将被检测文本与检测结果封装到一起,方便后续获取被检测文本
     */
    default Mono<Pair<String, GreenTextResult>> checkReactively(String text) {
        return Mono.fromSupplier(() -> {
            try {
                return Pair.of(text, check(text));
            } catch (Exception e) {
                return Pair.of(text, GreenTextResult.notPass(e.getMessage()));
            }
        });
    }
}

1.3. GreenTextExtractor

package com.test.green;

import org.springframework.cloud.gateway.route.Route;
import org.springframework.http.server.reactive.ServerHttpRequest;
import reactor.core.publisher.Mono;

import javax.annotation.Nullable;

/**
 * 该组件负责从请求中提取出待检测文本
 *
 * @author NightDW 2022/8/3 15:20
 */
public interface GreenTextExtractor {

    /**
     * 从请求中提取出待检测的文本;如果返回null,则不需要进行校验
     */
    @Nullable
    String getTextToCheck(Route route, ServerHttpRequest request, @Nullable Object requestBody);

    /**
     * 支持响应式编程;注意,如果getTextToCheck()方法返回null,则这里相当于返回Mono.empty()
     */
    default Mono<String> getTextToCheckReactively(Route route, ServerHttpRequest request, @Nullable Object requestBody) {
        return Mono.fromSupplier(() -> getTextToCheck(route, request, requestBody));
    }
}

2. 使用阿里云作为敏感词检测服务

2.1. 引入依赖

<dependencies>
    <dependency>
        <groupId>com.aliyun</groupId>
        <artifactId>aliyun-java-sdk-core</artifactId>
        <version>4.1.1</version>
    </dependency>
    <dependency>
        <groupId>com.aliyun</groupId>
        <artifactId>aliyun-java-sdk-green</artifactId>
        <version>3.6.2</version>
    </dependency>
    <dependency>
        <groupId>com.aliyun.oss</groupId>
        <artifactId>aliyun-sdk-oss</artifactId>
        <version>2.8.3</version>
    </dependency>
</dependencies>

2.2. AliGreenTextConfig

package com.test.green.ali;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.Collections;
import java.util.List;

/**
 * 调用阿里云接口所需要的参数
 *
 * @author NightDW 2022/8/3 15:17
 */
@Data
@Component
@ConfigurationProperties(prefix = "green.text.ali")
public class AliGreenTextConfig {
    private String accessKeyId;
    private String secret;
    private String bizType = "default";
    private List<String> scenes = Collections.singletonList("antispam");
    private Integer connectTimeout = 3000;
    private Integer readTimeout = 6000;
    private String regionId = "cn-shanghai";
    private String product = "Green";
    private String endpoint = "green.cn-shanghai.aliyuncs.com";
}

2.3. AliGreenTextServiceImpl

package com.test.green.ali;

import com.alibaba.fastjson.JSONObject;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.green.model.v20180509.TextScanRequest;
import com.aliyuncs.http.FormatType;
import com.aliyuncs.http.HttpResponse;
import com.aliyuncs.profile.DefaultProfile;
import com.test.green.GreenTextResult;
import com.test.green.GreenTextService;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.stream.Collectors;

/**
 * 文档:https://help.aliyun.com/document_detail/70439.html
 *
 * @author NightDW 2022/8/3 15:16
 */
@Slf4j
@Component
public class AliGreenTextServiceImpl implements GreenTextService {
    private static final int MAX_TEXT_LENGTH = 10000;
    private static final String UTF_8 = "UTF-8";
    private static final GreenTextResult PASS = GreenTextResult.pass("阿里云检测通过");
    private static final GreenTextResult EMPTY_TEXT = GreenTextResult.pass("检测文本内容为空");
    private static final GreenTextResult TEXT_TOO_LONG = GreenTextResult.notPass("文本长度超出限制");
    private static final GreenTextResult ALI_PROCESS_FAIL = GreenTextResult.notPass("阿里云处理请求失败");

    @Autowired
    private AliGreenTextConfig config;

    private IAcsClient client;

    @PostConstruct
    private void init() {
        DefaultProfile dp = DefaultProfile.getProfile(config.getRegionId(), config.getAccessKeyId(), config.getSecret());
        DefaultProfile.addEndpoint(config.getRegionId(), config.getProduct(), config.getEndpoint());
        this.client = new DefaultAcsClient(dp);
    }

    @Override
    public GreenTextResult check(String text) throws ClientException {
        log.debug("待检测文本:{}", text);

        if (text.isEmpty()) {
            return EMPTY_TEXT;
        }
        if (text.length() > MAX_TEXT_LENGTH) {
            return TEXT_TOO_LONG;
        }

        // 调用阿里云接口,调用失败则直接抛异常
        HttpResponse httpResponse;
        try {
            httpResponse = callAliInterface(Collections.singletonList(text));
        } catch (ClientException e) {
            log.error("阿里云服务调用失败!", e);
            throw e;
        }

        // 解析阿里云接口的响应,获取到各个任务(这里只有一个任务)的检测结果
        List<TaskResult> taskResults = getTaskResults(httpResponse);
        if (taskResults.isEmpty()) {
            log.debug("阿里云处理请求失败!");
            return ALI_PROCESS_FAIL;
        }

        // 解析第一个任务的检测结果,并返回相应的GreenTextResult
        return parseTaskResult(taskResults.get(0));
    }

    /**
     * 调用阿里云接口,texts中的每个文本都会单独作为一个任务来检测
     */
    private HttpResponse callAliInterface(List<String> texts) throws ClientException {
        List<Map<String, Object>> tasks = new ArrayList<>(texts.size());
        for (String text : texts) {
            Map<String, Object> task = new HashMap<>(2);
            task.put("dataId", UUID.randomUUID().toString());
            task.put("content", text);
            tasks.add(task);
        }

        JSONObject body = new JSONObject();
        body.put("bizType", config.getBizType());
        body.put("scenes", config.getScenes());
        body.put("tasks", tasks);

        TextScanRequest textScanRequest = new TextScanRequest();
        textScanRequest.setAcceptFormat(FormatType.JSON);
        textScanRequest.setMethod(com.aliyuncs.http.MethodType.POST);
        textScanRequest.setEncoding(UTF_8);
        textScanRequest.setRegionId(config.getRegionId());
        textScanRequest.setConnectTimeout(config.getConnectTimeout());
        textScanRequest.setReadTimeout(config.getReadTimeout());
        textScanRequest.setHttpContent(body.toJSONString().getBytes(StandardCharsets.UTF_8), UTF_8, FormatType.JSON);
        return client.doAction(textScanRequest);
    }

    /**
     * 解析Http响应,获取所有任务的检测结果;如果请求失败,则返回空集合
     */
    private List<TaskResult> getTaskResults(HttpResponse httpResponse) {
        return Optional.of(httpResponse)
                .filter(HttpResponse::isSuccess)
                .map(response -> {
                    String responseBodyStr = new String(response.getHttpContent(), StandardCharsets.UTF_8);
                    log.debug("阿里云检测结果:{}", responseBodyStr);
                    return JsonUtil.parseJson(responseBodyStr, AliResponseBody.class);
                })
                .filter(responseBody -> responseBody.code == 200)
                .map(responseBody -> responseBody.data)
                .orElse(Collections.emptyList());
    }

    /**
     * 将阿里云的任务检测结果(即文本的检测结果)封装成自定义的检测结果
     */
    private GreenTextResult parseTaskResult(TaskResult taskResult) {
        if (taskResult.code != 200) {
            return ALI_PROCESS_FAIL;
        }

        // 拿到所有场景下的检测结果,并筛选出未通过的场景检测结果
        List<SceneResult> invalidSceneResults = taskResult.getResults().stream()
                .filter(sceneResult -> !"pass".equals(sceneResult.suggestion))
                .collect(Collectors.toList());

        // 所有场景都检测通过,返回PASS
        if (invalidSceneResults.isEmpty()) {
            return PASS;
        }

        // 否则检测不通过,此时提取出所有的敏感词及其对应的问题类型,并将其放入到GreenTextResult中
        Map<String, List<String>> invalidWordsMap = new HashMap<>();
        invalidSceneResults.stream()
                .flatMap(invalidSceneResult -> invalidSceneResult.details.stream())
                .forEach(labelResult -> {
                    String label = labelResult.label;
                    List<String> invalidWords = invalidWordsMap.computeIfAbsent(label, k -> new ArrayList<>());
                    if (labelResult.contexts != null) {
                        labelResult.contexts.forEach(context -> invalidWords.add(context.context));
                    }
                });
        return GreenTextResult.notPass(invalidWordsMap);
    }

    /**
     * 阿里云检测接口的响应体
     */
    @Data
    private static class AliResponseBody {
        private int code;                  // 状态码
        private String msg;                // 状态码描述
        private String requestId;          // 请求的id
        private List<TaskResult> data;     // 此次请求的所有任务的检测结果
    }

    /**
     * 任务的检测结果;一个任务对应一个文本
     */
    @Data
    private static class TaskResult {
        private int code;                  // 状态码
        private String msg;                // 状态码描述
        private String dataId;             // 数据id
        private String taskId;             // 任务id
        private String content;            // 被检测的文本
        private String filteredContent;    // 经过过滤后的文本,敏感词会被替换成星号
        private List<SceneResult> results; // 该文本在各个场景下的检测结果
    }

    /**
     * 场景检测结果
     */
    @Data
    private static class SceneResult {
        private String scene;              // 场景名称
        private String label;              // 问题的类型;比如,porn代表色情
        private double rate;               // 结果的准确率(可信度)
        private String suggestion;         // pass:通过 review:需进一步审核 block:违规
        private List<LabelResult> details; // 该文本在各个问题类型下的检测结果
    }

    /**
     * 问题类型检测结果
     */
    @Data
    private static class LabelResult {
        private String label;              // 问题的类型;比如,porn代表色情
        private List<Context> contexts;    // 检测到的该类型的所有敏感词
    }

    /**
     * 检测到的敏感词
     */
    @Data
    private static class Context {
        private String context;            // 敏感词
        private String libCode;            // 敏感词所在的词库(自定义的敏感词才有该字段)
        private String libName;            // 敏感词所在的词库(自定义的敏感词才有该字段)
        private List<Position> positions;  // 敏感词出现的位置
    }

    /**
     * 下标范围
     */
    @Data
    private static class Position {
        private int startPos;              // 起始下标
        private int endPos;                // 终止下标
    }
}

3. 实现其它接口

3.1. UrlEncodedBodyUtil

package com.test.util;

import org.springframework.util.MultiValueMap;
import org.springframework.util.MultiValueMapAdapter;

import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.HashMap;

/**
 * 解析被URL编码的请求体
 *
 * @author NightDW 2023/2/14 15:30
 */
public class UrlEncodedBodyUtil {
    private static final String UTF_8 = "UTF-8";
    private static final String PARAM_SPLIT = "&";
    private static final char KV_SEPARATOR = '=';
    private UrlEncodedBodyUtil() { }

    /**
     * 将被URL编码的请求体解析成MultiValueMap
     */
    public static MultiValueMap<String, String> parse(String urlEncodedBody) {
        String[] parameters = urlEncodedBody.split(PARAM_SPLIT);

        MultiValueMap<String, String> map = new MultiValueMapAdapter<>(new HashMap<>(parameters.length));
        for (String keyAndValue : parameters) {
            int kvSepIdx = keyAndValue.indexOf(KV_SEPARATOR);
            if (kvSepIdx == -1) {
                map.add(keyAndValue, null);
            } else {
                String key = keyAndValue.substring(0, kvSepIdx);
                String value = urlDecode(keyAndValue.substring(kvSepIdx + 1));
                map.add(key, value);
            }
        }
        return map;
    }

    private static String urlDecode(String text) {
        try {
            return URLDecoder.decode(text, UTF_8);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }
}

3.2. Obj2StrUtil

package com.test.util;

import lombok.extern.slf4j.Slf4j;

import javax.annotation.Nullable;
import java.util.Collection;
import java.util.Map;

/**
 * 将对象转成字符串
 *
 * @author NightDW 2023/3/8 10:09
 */
@Slf4j
public class Obj2StrUtil {
    private static final String MULTI_VALUE_SEPARATOR = " && ";
    private static final String MULTI_PARAM_SEPARATOR = " -> ";

    /**
     * 将对象添加到StringBuilder中;对象只能是字面量、集合或Map类型
     */
    public static boolean appendObject(StringBuilder sb, @Nullable Object obj) {

        // 如果对象为null,则返回false,代表没往StringBuilder中添加数据
        if (obj == null) {
            return false;
        }

        // 如果是字面量,则直接转成字符串
        if (isLiteral(obj)) {
            String str = obj.toString();
            if (str.isEmpty()) {
                return false;
            }
            sb.append(str);
            return true;
        }

        // 如果不是字面量,则尝试将对象转成集合类型
        Collection<?> collection;
        if (obj instanceof Collection) {
            collection = (Collection<?>) obj;
        } else if (obj instanceof Map) {
            collection = ((Map<?, ?>) obj).values();
        } else {
            log.error("无法将对象转成字符串!对象:[{}],类型:[{}]", obj, obj.getClass());
            throw new RuntimeException("不支持的类型");
        }

        // 遍历集合,将集合中的元素逐个添加到StringBuilder中
        int oldLength = sb.length();
        for (Object item : collection) {
            if (appendObject(sb, item)) {
                sb.append(MULTI_VALUE_SEPARATOR);
            }
        }

        // 判断是否真的往StringBuilder中添加数据了;如果是,则删除掉多余的分隔符
        int newLength = sb.length();
        if (newLength == oldLength) {
            return false;
        }
        sb.delete(newLength - MULTI_VALUE_SEPARATOR.length(), newLength);
        return true;
    }

    /**
     * 将Map中某些键对应的值转成字符串并拼接起来
     */
    public static String toString(Map<String, ?> map, @Nullable Collection<String> keys) {

        // 如果没指定key,则默认取Map中的所有key
        if (keys == null) {
            keys = map.keySet();
        }

        StringBuilder sb = new StringBuilder();
        for (String key : keys) {
            if (appendObject(sb, map.get(key))) {
                sb.append(MULTI_PARAM_SEPARATOR);
            }
        }
        return sb.length() == 0 ? "" : sb.substring(0, sb.length() - MULTI_PARAM_SEPARATOR.length());
    }

    /**
     * 判断某个值是否为字面量
     */
    private static boolean isLiteral(Object value) {
        return value instanceof String || value instanceof Number || value instanceof Boolean;
    }
}

3.3. ConfigBasedGreenTextExtractorImpl

package com.test.green.impl;

import com.fasterxml.jackson.core.type.TypeReference;
import com.test.green.GreenTextExtractor;
import com.test.util.Obj2StrUtil;
import com.test.util.UrlEncodedBodyUtil;
import lombok.Data;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.MultiValueMap;

import javax.annotation.Nullable;
import java.util.*;

/**
 * 基于配置的文本提取器
 *
 * @author NightDW 2022/8/3 15:30
 */
@Slf4j
@Component
@ConfigurationProperties(prefix = "green.text")
public class ConfigBasedGreenTextExtractorImpl implements GreenTextExtractor {

    /**
     * 针对某些Http接口的配置项
     */
    @Data
    private static class PathRule {

        /**
         * 接口的路径,可以有通配符
         */
        private List<String> paths;

        /**
         * 接口的请求方式;为null时,代表不限制
         */
        private Set<HttpMethod> methods;

        /**
         * 接口中待检测的请求参数;为null时,提取所有参数的值
         */
        private List<String> paramsToCheck;
    }

    /**
     * 是否启用;如果为false,则getTextToCheck()方法固定返回null
     */
    @Setter
    private boolean enable = true;

    /**
     * 映射规则;key代表路由id,value代表该路由对应的项目的所有路径规则
     */
    @Setter
    private Map<String, List<PathRule>> mapping = Collections.emptyMap();

    /**
     * 路径匹配器
     */
    private final AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    @Nullable
    public String getTextToCheck(Route route, ServerHttpRequest request, @Nullable Object requestBody) {
        if (!enable) {
            log.debug("未开启文本检测");
            return null;
        }

        HttpMethod method = request.getMethod();
        String path = request.getURI().getPath();

        // 获取到当前请求对应的路径规则
        PathRule pathRule = deducePathRule(route, path);

        if (pathRule == null || !isMatch(pathRule.getMethods(), method)) {
            log.debug("当前接口没有路径规则,或者当前请求方式与规则中的不匹配");
            return null;
        }

        // 如果是GET请求,则提取请求参数中的数据
        if (method == HttpMethod.GET) {
            MultiValueMap<String, String> queryParams = request.getQueryParams();
            return Obj2StrUtil.toString(queryParams, pathRule.getParamsToCheck());
        }

        if (requestBody == null || request.getHeaders().getContentType() == null) {
            log.debug("请求体为空,或者未指定请求体类型");
            return null;
        }
        if (!(requestBody instanceof String)) {
            log.debug("不支持的请求体数据类型:{}", requestBody.getClass());
            return null;
        }

        String bodyStr = (String) requestBody;
        String contentTypeStr = request.getHeaders().getContentType().toString();

        // 如果请求体是JSON类型,则将JSON解析成Map类型,然后提取出相应的数据
        if (contentTypeStr.equals(MediaType.APPLICATION_JSON_VALUE)) {
            Map<String, Object> bodyMap = JsonUtil.parseJson(bodyStr, new TypeReference<Map<String, Object>>() {});
            return Obj2StrUtil.toString(bodyMap, pathRule.getParamsToCheck());
        }

        // 如果是x-www-form-urlencoded类型,则将请求体转成MultiValueMap格式,并提取出相应的数据
        if (contentTypeStr.equals(MediaType.APPLICATION_FORM_URLENCODED_VALUE)) {
            MultiValueMap<String, String> paramMap = UrlEncodedBodyUtil.parse(bodyStr);
            return Obj2StrUtil.toString(paramMap, pathRule.getParamsToCheck());
        }

        log.debug("不支持的请求体类型:{}", contentTypeStr);
        return null;
    }

    /**
     * 获取到当前请求对应的路径规则
     */
    private PathRule deducePathRule(Route route, String path) {
        for (PathRule rule : mapping.getOrDefault(route.getId(), Collections.emptyList())) {
            for (String pattern : rule.getPaths()) {
                if (antPathMatcher.match(pattern, path)) {
                    return rule;
                }
            }
        }
        return null;
    }

    /**
     * 判断某个元素是否在Set集合中;如果Set集合是null,则返回true
     */
    private static <T> boolean isMatch(@Nullable Set<T> set, T item) {
        return set == null || set.contains(item);
    }
}

4. 自定义全局过滤器

4.1. RequestBodyCacheFilter

package com.test.filter;

import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.core.Ordered;
import org.springframework.http.MediaType;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.List;

/**
 * 缓存请求内容的过滤器
 *
 * @author NightDW 2022/8/3 15:17
 */
@Component
public class RequestBodyCacheFilter implements GlobalFilter, Ordered {
    private static final List<HttpMessageReader<?>> MESSAGE_READERS = HandlerStrategies.withDefaults().messageReaders();
    public static final String REQUEST_BODY_CACHE_ATTR = "requestBodyCache";

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        if (MediaType.MULTIPART_FORM_DATA.isCompatibleWith(exchange.getRequest().getHeaders().getContentType())) {
            return chain.filter(exchange);
        }
        return ServerWebExchangeUtils.cacheRequestBodyAndRequest(exchange, (serverHttpRequest) ->
                ServerRequest.create(exchange.mutate().request(serverHttpRequest).build(), MESSAGE_READERS)
                        .bodyToMono(String.class)
                        .doOnNext(body -> exchange.getAttributes().put(REQUEST_BODY_CACHE_ATTR, body))
        ).then(chain.filter(exchange));
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

4.2. GreenTextFilter

package com.test.filter;

import com.test.green.GreenTextExtractor;
import com.test.green.GreenTextResult;
import com.test.green.GreenTextService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

/**
 * 进行敏感词校验的全局过滤器
 *
 * @author NightDW 2022/8/3 15:17
 */
@Slf4j
@Component
@ConditionalOnProperty(prefix = "green.text", name = "enable", havingValue = "true")
public class GreenTextFilter implements GlobalFilter, Ordered {
    private static final String PATH_COUNT_REDIS_KEY_PATTERN = "green:text:counter:%s:%s";

    @Autowired
    private GreenTextService greenTextService;

    @Autowired
    private GreenTextExtractor greenTextExtractor;

    @Autowired
    private RedisTemplate<String, ?> redisTemplate;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        Route route = (Route) exchange.getAttributes().get(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
        ServerHttpRequest request = exchange.getRequest();
        String path = request.getURI().getPath();
        Object requestBody = exchange.getAttributes().get(RequestBodyCacheFilter.REQUEST_BODY_CACHE_ATTR);

        // 首先,提取出待检测文本,并获取其检测结果:
        // 1. 如果有待检测文本,则对其进行检测,然后将当前路径的访问计数加一,并向RabbitMQ发送一条消息,然后返回检测结果
        // 2. 如果无需检测,则默认返回一个检测通过的结果
        // 最后,判断检测是否通过,如果是,则放行;否则响应错误信息
        return this.greenTextExtractor.getTextToCheckReactively(route, request, requestBody)
                .flatMap(this.greenTextService::checkReactively)
                .flatMap(resultPair -> {
                    String text = resultPair.getFirst();
                    GreenTextResult result = resultPair.getSecond();
                    log.debug("检测结果:{}", result);
                    return increasePathCount(route.getId(), path)
                            .zipWith(sendRabbitLogMessage(request, text, result))
                            .then(Mono.just(result));
                })
                .switchIfEmpty(Mono.fromSupplier(() -> GreenTextResult.pass("无需检测")))
                .flatMap(result -> doPassOrReject(exchange, chain, result));
    }

    private Mono<Long> increasePathCount(String routeId, String path) {
        return Mono.fromSupplier(() -> {
            String redisKey = String.format(PATH_COUNT_REDIS_KEY_PATTERN, routeId, path);
            return redisTemplate.opsForValue().increment(redisKey);
        });
    }

    private Mono<Void> sendRabbitLogMessage(ServerHttpRequest request, String text, GreenTextResult result) {
        return Mono.fromRunnable(() -> {
            // TODO 发送RabbitMQ消息
        });
    }

    private Mono<Void> doPassOrReject(ServerWebExchange exchange, GatewayFilterChain chain, GreenTextResult result) {
        if (result.isPass()) {
            return chain.filter(exchange);
        }

        Map<String, Object> jsonData = new HashMap<>();
        // TODO 往jsonData中添加自定义的响应体内容

        ServerHttpResponse response = exchange.getResponse();
        DataBuffer body = response.bufferFactory().wrap(JsonUtil.toJson(jsonData).getBytes(StandardCharsets.UTF_8));
        return Mono.defer(() -> {
            response.setStatusCode(HttpStatus.OK);
            response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
            return response.writeWith(Mono.just(body));
        });
    }

    @Override
    public int getOrder() {
        return 10; // 至少应该排在StripPrefixGatewayFilter之后
    }
}

5. yml配置示例

green:
  text:
    enable: true
    mapping:
      my-user-module:
        - paths: /user/add
          methods: POST
          paramsToCheck: username,introduction
        - paths: /commons1,/commons2
    ali:
      accessKeyId: xxx
      secret: xxx
logging:
  level:
    com.test: debug