This is an automated email from the ASF dual-hosted git repository.

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git


The following commit(s) were added to refs/heads/master by this push:
     new 848ae95cd0 [type:feat] add the sensitive word plugin (#7153)
848ae95cd0 is described below

commit 848ae95cd0e9b26f023237ac9dd8e6019627a64d
Author: HY-love-sleep <[email protected]>
AuthorDate: Mon Sep 21 17:10:38 2026 +0800

    [type:feat] add the sensitive word plugin (#7153)
---
 shenyu-bootstrap/pom.xml                           |   8 +
 .../dto/convert/rule/SensitiveWordHandle.java      | 111 +++++++++++
 .../org/apache/shenyu/common/enums/PluginEnum.java |   5 +
 .../dto/convert/rule/SensitiveWordHandleTest.java  |  54 +++++
 shenyu-plugin/shenyu-plugin-ai/pom.xml             |   1 +
 .../shenyu-plugin-ai-sensitive-word/README.md      |  60 ++++++
 .../{ => shenyu-plugin-ai-sensitive-word}/pom.xml  |  26 +--
 .../ai/sensitive/word/SensitiveWordPlugin.java     | 176 +++++++++++++++++
 .../plugin/ai/sensitive/word/ac/AhoCorasick.java   | 163 +++++++++++++++
 .../handler/SensitiveWordPluginDataHandler.java    | 168 ++++++++++++++++
 .../ai/sensitive/word/SensitiveWordPluginTest.java | 218 +++++++++++++++++++++
 .../ai/sensitive/word/ac/AhoCorasickTest.java      | 125 ++++++++++++
 .../SensitiveWordPluginDataHandlerTest.java        | 166 ++++++++++++++++
 .../shenyu-spring-boot-starter-plugin/pom.xml      |   1 +
 .../pom.xml                                        |  22 +--
 .../word/SensitiveWordPluginConfiguration.java     |  57 ++++++
 .../src/main/resources/META-INF/spring.factories   |  19 ++
 .../src/main/resources/META-INF/spring.provides    |  18 ++
 ...rk.boot.autoconfigure.AutoConfiguration.imports |  18 ++
 .../word/SensitiveWordPluginConfigurationTest.java |  65 ++++++
 20 files changed, 1456 insertions(+), 25 deletions(-)

diff --git a/shenyu-bootstrap/pom.xml b/shenyu-bootstrap/pom.xml
index 4a0b181b6b..caabd7d17f 100644
--- a/shenyu-bootstrap/pom.xml
+++ b/shenyu-bootstrap/pom.xml
@@ -263,6 +263,14 @@
         </dependency>
         <!--Ai token limiter Plugin end-->
 
+        <!--Sensitive word Plugin Start-->
+        <dependency>
+            <groupId>org.apache.shenyu</groupId>
+            
<artifactId>shenyu-spring-boot-starter-plugin-ai-sensitive-word</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <!--Sensitive word Plugin end-->
+
         <!--Ai request transformer Plugin Start-->
         <dependency>
             <groupId>org.apache.shenyu</groupId>
diff --git 
a/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/SensitiveWordHandle.java
 
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/SensitiveWordHandle.java
new file mode 100644
index 0000000000..fd51e9eed3
--- /dev/null
+++ 
b/shenyu-common/src/main/java/org/apache/shenyu/common/dto/convert/rule/SensitiveWordHandle.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.common.dto.convert.rule;
+
+/**
+ * The sensitive word rule handle, it tells the plugin where the dictionary 
lives and how long a
+ * loaded dictionary may be reused.
+ */
+public class SensitiveWordHandle {
+
+    /**
+     * The default redis key holding the sensitive word set.
+     */
+    public static final String DEFAULT_REDIS_KEY = "shenyu:sensitive:words";
+
+    /**
+     * The redis key of the sensitive word set, every rule may point to its 
own dictionary.
+     */
+    private String redisKey = DEFAULT_REDIS_KEY;
+
+    /**
+     * How long, in seconds, a loaded dictionary is reused before it is read 
from redis again.
+     * Zero or a negative value reads the dictionary on every request.
+     */
+    private long refreshIntervalSeconds = 300L;
+
+    /**
+     * Whether the request must be rejected when the dictionary is 
unavailable. It defaults to
+     * false, which means such a request is passed through: a broken redis 
must not take the
+     * traffic down. Deployments with a hard compliance requirement can opt 
into blocking.
+     */
+    private boolean failClosed;
+
+    /**
+     * get redis key.
+     *
+     * @return redis key
+     */
+    public String getRedisKey() {
+        return redisKey;
+    }
+
+    /**
+     * set redis key.
+     *
+     * @param redisKey redis key
+     */
+    public void setRedisKey(final String redisKey) {
+        this.redisKey = redisKey;
+    }
+
+    /**
+     * get refresh interval seconds.
+     *
+     * @return refresh interval in seconds
+     */
+    public long getRefreshIntervalSeconds() {
+        return refreshIntervalSeconds;
+    }
+
+    /**
+     * set refresh interval seconds.
+     *
+     * @param refreshIntervalSeconds refresh interval in seconds
+     */
+    public void setRefreshIntervalSeconds(final long refreshIntervalSeconds) {
+        this.refreshIntervalSeconds = refreshIntervalSeconds;
+    }
+
+    /**
+     * whether the request must be rejected when the dictionary is unavailable.
+     *
+     * @return true when the request must be rejected
+     */
+    public boolean isFailClosed() {
+        return failClosed;
+    }
+
+    /**
+     * set whether the request must be rejected when the dictionary is 
unavailable.
+     *
+     * @param failClosed true to reject the request
+     */
+    public void setFailClosed(final boolean failClosed) {
+        this.failClosed = failClosed;
+    }
+
+    /**
+     * new default instance.
+     *
+     * @return the default handle
+     */
+    public static SensitiveWordHandle newDefaultInstance() {
+        return new SensitiveWordHandle();
+    }
+}
diff --git 
a/shenyu-common/src/main/java/org/apache/shenyu/common/enums/PluginEnum.java 
b/shenyu-common/src/main/java/org/apache/shenyu/common/enums/PluginEnum.java
index 8501238fa4..0ad0f12feb 100644
--- a/shenyu-common/src/main/java/org/apache/shenyu/common/enums/PluginEnum.java
+++ b/shenyu-common/src/main/java/org/apache/shenyu/common/enums/PluginEnum.java
@@ -287,6 +287,11 @@ public enum PluginEnum {
      */
     AI_TOKEN_LIMITER(171, 0, "aiTokenLimiter"),
 
+    /**
+     * Sensitive-word plugin enum.
+     */
+    SENSITIVE_WORD(197, 0, "sensitiveWord"),
+
     /**
      * Mcp-server plugin enum.
      */
diff --git 
a/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/SensitiveWordHandleTest.java
 
b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/SensitiveWordHandleTest.java
new file mode 100644
index 0000000000..ddc61e24db
--- /dev/null
+++ 
b/shenyu-common/src/test/java/org/apache/shenyu/common/dto/convert/rule/SensitiveWordHandleTest.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.common.dto.convert.rule;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test cases for {@link SensitiveWordHandle}.
+ */
+public final class SensitiveWordHandleTest {
+
+    @Test
+    public void testDefaultInstance() {
+        SensitiveWordHandle handle = SensitiveWordHandle.newDefaultInstance();
+        assertEquals(SensitiveWordHandle.DEFAULT_REDIS_KEY, 
handle.getRedisKey());
+        assertEquals(300L, handle.getRefreshIntervalSeconds());
+    }
+
+    @Test
+    public void testFailClosed() {
+        SensitiveWordHandle handle = new SensitiveWordHandle();
+        assertFalse(handle.isFailClosed());
+        handle.setFailClosed(true);
+        assertTrue(handle.isFailClosed());
+    }
+
+    @Test
+    public void testSetter() {
+        SensitiveWordHandle handle = new SensitiveWordHandle();
+        handle.setRedisKey("custom:sensitive:words");
+        handle.setRefreshIntervalSeconds(30L);
+        assertEquals("custom:sensitive:words", handle.getRedisKey());
+        assertEquals(30L, handle.getRefreshIntervalSeconds());
+    }
+}
diff --git a/shenyu-plugin/shenyu-plugin-ai/pom.xml 
b/shenyu-plugin/shenyu-plugin-ai/pom.xml
index 22f032a879..7f910e45a6 100644
--- a/shenyu-plugin/shenyu-plugin-ai/pom.xml
+++ b/shenyu-plugin/shenyu-plugin-ai/pom.xml
@@ -34,6 +34,7 @@
         <module>shenyu-plugin-ai-request-transformer</module>
         <module>shenyu-plugin-ai-proxy</module>
         <module>shenyu-plugin-ai-response-transformer</module>
+        <module>shenyu-plugin-ai-sensitive-word</module>
     </modules>
 
 </project>
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/README.md 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/README.md
new file mode 100644
index 0000000000..a5120053b0
--- /dev/null
+++ b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/README.md
@@ -0,0 +1,60 @@
+# shenyu-plugin-ai-sensitive-word
+
+The sensitive word plugin rejects a request whose body contains a word of the 
dictionary
+configured for the matched rule. The dictionary lives in a **redis set**, so 
it can be maintained
+by the operations team without redeploying shenyu.
+
+It is aimed at the content compliance of an AI gateway, where a prompt must 
not reach the model
+with forbidden content, but it only inspects the request body, so it also 
applies to plain HTTP
+routes.
+
+## How it works
+
+- The dictionary is read with `SMEMBERS <rule.redisKey>` and compiled into an
+  
[Aho-Corasick](../main/java/org/apache/shenyu/plugin/ai/sensitive/word/ac/AhoCorasick.java)
+  automaton, which reports **every** matching word, including nested and 
overlapping ones, for
+  example both `中国` and `中国银行` for the text `中国银行`.
+- The body is read with the shared 
`ServerWebExchangeUtils#rewriteRequestBody`, the whole path is
+  reactive and the automaton is compiled on a bounded elastic thread, so the 
gateway event loop is
+  never blocked.
+- A compiled dictionary is reused for `refreshIntervalSeconds` and then read 
from redis again, so
+  a dictionary update takes effect within that interval. Updating the rule in 
the admin console
+  drops the cached dictionary immediately.
+- If redis is unreachable the request is **passed through** (fail open, a 
warning is logged): a
+  broken dictionary must not take the traffic down.
+
+## Configuration
+
+Plugin level (`config` of the plugin, the redis client used to read the 
dictionaries):
+
+```json
+{
+  "url": "127.0.0.1:6379",
+  "password": "",
+  "database": 0,
+  "mode": "standalone",
+  "maxIdle": 8,
+  "minIdle": 0,
+  "maxActive": 8
+}
+```
+
+Rule level (`handle` of the rule):
+
+| field | type | default | description |
+| --- | --- | --- | --- |
+| `redisKey` | string | `shenyu:sensitive:words` | the redis set holding the 
dictionary of this rule |
+| `refreshIntervalSeconds` | long | `300` | how long a compiled dictionary is 
reused, `0` reads it on every request |
+
+## Dictionary format
+
+A redis set of words, one word per member, for example:
+
+```
+SADD shenyu:sensitive:words "bad word 1"
+SADD shenyu:sensitive:words "bad word 2"
+```
+
+The word list itself is **not** part of this repository: every deployment is 
expected to provide
+its own dictionary, because the content of such a list depends on the country, 
the business and
+the compliance rules that apply to it.
diff --git a/shenyu-plugin/shenyu-plugin-ai/pom.xml 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/pom.xml
similarity index 69%
copy from shenyu-plugin/shenyu-plugin-ai/pom.xml
copy to shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/pom.xml
index 22f032a879..782c0b24d2 100644
--- a/shenyu-plugin/shenyu-plugin-ai/pom.xml
+++ b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/pom.xml
@@ -17,23 +17,25 @@
   -->
 
 <project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
-
     <parent>
         <groupId>org.apache.shenyu</groupId>
-        <artifactId>shenyu-plugin</artifactId>
+        <artifactId>shenyu-plugin-ai</artifactId>
         <version>2.7.2-SNAPSHOT</version>
     </parent>
     <modelVersion>4.0.0</modelVersion>
-    <artifactId>shenyu-plugin-ai</artifactId>
-    <packaging>pom</packaging>
+    <artifactId>shenyu-plugin-ai-sensitive-word</artifactId>
 
-    <modules>
-        <module>shenyu-plugin-ai-common</module>
-        <module>shenyu-plugin-ai-prompt</module>
-        <module>shenyu-plugin-ai-token-limiter</module>
-        <module>shenyu-plugin-ai-request-transformer</module>
-        <module>shenyu-plugin-ai-proxy</module>
-        <module>shenyu-plugin-ai-response-transformer</module>
-    </modules>
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.shenyu</groupId>
+            <artifactId>shenyu-plugin-base</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.shenyu</groupId>
+            <artifactId>shenyu-infra-redis</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+    </dependencies>
 
 </project>
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/main/java/org/apache/shenyu/plugin/ai/sensitive/word/SensitiveWordPlugin.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/main/java/org/apache/shenyu/plugin/ai/sensitive/word/SensitiveWordPlugin.java
new file mode 100644
index 0000000000..6ce6f14335
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/main/java/org/apache/shenyu/plugin/ai/sensitive/word/SensitiveWordPlugin.java
@@ -0,0 +1,176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.ai.sensitive.word;
+
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.dto.convert.rule.SensitiveWordHandle;
+import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.plugin.api.ShenyuPluginChain;
+import org.apache.shenyu.plugin.api.exception.ResponsiveException;
+import org.apache.shenyu.plugin.api.utils.WebFluxResultUtils;
+import org.apache.shenyu.plugin.base.AbstractShenyuPlugin;
+import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
+import org.apache.shenyu.plugin.base.utils.ServerWebExchangeUtils;
+import org.apache.shenyu.plugin.ai.sensitive.word.ac.AhoCorasick;
+import 
org.apache.shenyu.plugin.ai.sensitive.word.handler.SensitiveWordPluginDataHandler;
+import 
org.apache.shenyu.plugin.ai.sensitive.word.handler.SensitiveWordPluginDataHandler.CachedDictionary;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.redis.core.ReactiveRedisTemplate;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.codec.HttpMessageReader;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * The sensitive word plugin, it rejects the request when its body contains a 
word of the dictionary
+ * configured for the matched rule.
+ *
+ * <p>The dictionary is read from a redis set, so it can be maintained outside 
of shenyu. A loaded
+ * dictionary is reused for the refresh interval of the rule handle, see 
{@link SensitiveWordHandle}.
+ */
+public class SensitiveWordPlugin extends AbstractShenyuPlugin {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(SensitiveWordPlugin.class);
+
+    /**
+     * The error code returned to a client whose request was rejected. It 
follows the waf plugin,
+     * which rejects with 403 as well.
+     */
+    private static final int SENSITIVE_WORD_CODE = 
HttpStatus.FORBIDDEN.value();
+
+    /**
+     * The message returned to a client whose request was rejected. It never 
contains the matched
+     * words: echoing them back would confirm the dictionary to the caller and 
would re-emit
+     * forbidden content into the caller side logs.
+     */
+    private static final String REJECT_MESSAGE = "Request rejected: sensitive 
content detected";
+
+    private final List<HttpMessageReader<?>> readers;
+
+    public SensitiveWordPlugin(final List<HttpMessageReader<?>> readers) {
+        this.readers = readers;
+    }
+
+    @Override
+    protected Mono<Void> doExecute(final ServerWebExchange exchange,
+                                   final ShenyuPluginChain chain,
+                                   final SelectorData selector,
+                                   final RuleData rule) {
+        SensitiveWordHandle handle = 
SensitiveWordPluginDataHandler.CACHED_HANDLE.get()
+                .obtainHandle(CacheKeyUtils.INST.getKey(rule));
+        if (Objects.isNull(handle)) {
+            return chain.execute(exchange);
+        }
+        ReactiveRedisTemplate<String, String> redisTemplate = 
SensitiveWordPluginDataHandler.REDIS_TEMPLATES.get()
+                .obtainHandle(SensitiveWordPluginDataHandler.PLUGIN_NAME);
+        if (Objects.isNull(redisTemplate)) {
+            LOG.warn("sensitive word plugin: the redis template is not 
initialized, skip the sensitive word check");
+            return chain.execute(exchange);
+        }
+        return ServerWebExchangeUtils.rewriteRequestBody(exchange, readers,
+                        body -> check(exchange, redisTemplate, handle, body))
+                .flatMap(chain::execute)
+                .onErrorResume(error -> {
+                    if (error instanceof ResponsiveException) {
+                        return 
WebFluxResultUtils.failedResult((ResponsiveException) error);
+                    }
+                    return Mono.error(error);
+                });
+    }
+
+    private Mono<String> check(final ServerWebExchange exchange,
+                               final ReactiveRedisTemplate<String, String> 
redisTemplate,
+                               final SensitiveWordHandle handle,
+                               final String body) {
+        return dictionary(exchange, redisTemplate, handle)
+                .map(automaton -> automaton.search(body))
+                .flatMap(matches -> {
+                    if (matches.isEmpty()) {
+                        return Mono.just(body);
+                    }
+                    // The matches are logged server side only: returning them 
to the caller would
+                    // turn the gateway into an oracle of the dictionary and 
would push content that
+                    // was just classified as forbidden into the caller side 
logs.
+                    LOG.warn("sensitive word plugin: the request was rejected, 
matched words: {}", matches);
+                    return Mono.error(new 
ResponsiveException(SENSITIVE_WORD_CODE, REJECT_MESSAGE, exchange));
+                });
+    }
+
+    /**
+     * Get the dictionary of the rule, it is read from redis when the cached 
one is missing or
+     * expired. The request is never blocked by the dictionary: a dictionary 
that cannot be read
+     * is treated as empty, so that a broken redis never stops the traffic.
+     *
+     * @param redisTemplate the redis template
+     * @param handle the rule handle
+     * @return the automaton of the dictionary
+     */
+    private Mono<AhoCorasick> dictionary(final ServerWebExchange exchange,
+                                         final ReactiveRedisTemplate<String, 
String> redisTemplate,
+                                         final SensitiveWordHandle handle) {
+        String redisKey = Objects.isNull(handle.getRedisKey())
+                ? SensitiveWordHandle.DEFAULT_REDIS_KEY : handle.getRedisKey();
+        CachedDictionary cached = 
SensitiveWordPluginDataHandler.DICTIONARIES.get().obtainHandle(redisKey);
+        Mono<AhoCorasick> automaton;
+        if (Objects.nonNull(cached) && 
!cached.isExpired(handle.getRefreshIntervalSeconds())) {
+            automaton = Mono.just(cached.getAutomaton());
+        } else {
+            automaton = redisTemplate.opsForSet()
+                    .members(redisKey)
+                    .collectList()
+                    // building the automaton is cpu bound, keep it away from 
the event loop
+                    .map(AhoCorasick::of)
+                    .subscribeOn(Schedulers.boundedElastic())
+                    .doOnNext(loaded -> 
SensitiveWordPluginDataHandler.DICTIONARIES.get()
+                            .cachedHandle(redisKey, new 
CachedDictionary(loaded)))
+                    .onErrorResume(error -> {
+                        LOG.error("sensitive word plugin: cannot read the 
dictionary from redis key {}", redisKey, error);
+                        if (Objects.nonNull(cached)) {
+                            return Mono.just(cached.getAutomaton());
+                        }
+                        if (handle.isFailClosed()) {
+                            LOG.warn("sensitive word plugin: no dictionary is 
available and failClosed is set,"
+                                    + " the request is rejected");
+                            return Mono.error(new 
ResponsiveException(SENSITIVE_WORD_CODE, REJECT_MESSAGE, exchange));
+                        }
+                        // fail open: a broken redis must not take the traffic 
down
+                        LOG.warn("sensitive word plugin: no dictionary is 
available, the request is passed through");
+                        return Mono.just(AhoCorasick.empty());
+                    });
+        }
+        // The cached path emits on the thread that subscribes to it, which is 
the netty event loop
+        // of the request being filtered: hop once here so that the scan below 
never runs on it.
+        return automaton.publishOn(Schedulers.boundedElastic());
+    }
+
+    @Override
+    public String named() {
+        return PluginEnum.SENSITIVE_WORD.getName();
+    }
+
+    @Override
+    public int getOrder() {
+        return PluginEnum.SENSITIVE_WORD.getCode();
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/main/java/org/apache/shenyu/plugin/ai/sensitive/word/ac/AhoCorasick.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/main/java/org/apache/shenyu/plugin/ai/sensitive/word/ac/AhoCorasick.java
new file mode 100644
index 0000000000..cc491250e9
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/main/java/org/apache/shenyu/plugin/ai/sensitive/word/ac/AhoCorasick.java
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.ai.sensitive.word.ac;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Queue;
+import java.util.Set;
+
+/**
+ * The Aho-Corasick multi pattern matching automaton.
+ *
+ * <p>An instance is built once for a dictionary and is immutable afterwards, 
so it can be shared
+ * by concurrent requests. The failure links are built with a breadth first 
traversal of the trie,
+ * which makes scanning a text linear in its length (times the length of the 
longest failure path).
+ *
+ * <p>Every word of the dictionary contained in the scanned text is reported, 
including nested and
+ * overlapping ones, for example both {@code 中国} and {@code 中国银行} are reported 
for the text
+ * {@code 中国银行}.
+ */
+public final class AhoCorasick {
+
+    private static final AhoCorasick EMPTY_DICTIONARY = new AhoCorasick();
+
+    private final TrieNode root = new TrieNode();
+
+    private AhoCorasick() {
+    }
+
+    /**
+     * Build an automaton for the given dictionary. Blank entries are ignored.
+     *
+     * @param words the dictionary words
+     * @return the automaton
+     */
+    public static AhoCorasick of(final Collection<String> words) {
+        if (Objects.isNull(words) || words.isEmpty()) {
+            return EMPTY_DICTIONARY;
+        }
+        AhoCorasick automaton = new AhoCorasick();
+        automaton.insertAll(words);
+        automaton.buildFailureLinks();
+        return automaton;
+    }
+
+    /**
+     * An automaton without any word, it never matches. Useful as a fail open 
fallback when the
+     * dictionary cannot be loaded.
+     *
+     * @return an empty automaton
+     */
+    public static AhoCorasick empty() {
+        return EMPTY_DICTIONARY;
+    }
+
+    /**
+     * Find every dictionary word contained in the given text.
+     *
+     * <p>The scan is linear in the length of the text: every node carries the 
words matched by
+     * itself and by its failure chain, so no failure link is walked here.
+     *
+     * @param text the text to scan
+     * @return the matched words, in the order they were found, each word is 
reported once
+     */
+    public Set<String> search(final String text) {
+        if (Objects.isNull(text) || text.isEmpty()) {
+            return Collections.emptySet();
+        }
+        Set<String> matches = new LinkedHashSet<>();
+        TrieNode current = root;
+        for (int i = 0; i < text.length(); i++) {
+            char c = text.charAt(i);
+            while (current != root && !current.children.containsKey(c)) {
+                current = current.fail;
+            }
+            TrieNode next = current.children.get(c);
+            current = Objects.isNull(next) ? root : next;
+            if (!current.outputs.isEmpty()) {
+                matches.addAll(current.outputs);
+            }
+        }
+        return matches;
+    }
+
+    private void insertAll(final Collection<String> words) {
+        for (String word : words) {
+            if (Objects.isNull(word) || word.trim().isEmpty()) {
+                continue;
+            }
+            insert(word.trim());
+        }
+    }
+
+    private void insert(final String word) {
+        TrieNode node = root;
+        for (char c : word.toCharArray()) {
+            node = node.children.computeIfAbsent(c, key -> new TrieNode());
+        }
+        node.word = word;
+    }
+
+    private void buildFailureLinks() {
+        Queue<TrieNode> queue = new LinkedList<>();
+        queue.add(root);
+        while (!queue.isEmpty()) {
+            TrieNode current = queue.poll();
+            for (Map.Entry<Character, TrieNode> entry : 
current.children.entrySet()) {
+                TrieNode child = entry.getValue();
+                TrieNode fail = current.fail;
+                while (Objects.nonNull(fail) && 
!fail.children.containsKey(entry.getKey())) {
+                    fail = fail.fail;
+                }
+                child.fail = Objects.nonNull(fail) ? 
fail.children.get(entry.getKey()) : root;
+                // Resolve the words matched at this node once, so that 
scanning a text never has
+                // to walk the failure chain: the words of the failure chain 
are the suffixes.
+                List<String> outputs = new ArrayList<>();
+                if (Objects.nonNull(child.word)) {
+                    outputs.add(child.word);
+                }
+                outputs.addAll(child.fail.outputs);
+                child.outputs = outputs.isEmpty() ? Collections.emptyList() : 
outputs;
+                queue.add(child);
+            }
+        }
+    }
+
+    /**
+     * A trie node: {@code word} is not null only on the node that ends a 
dictionary word, and
+     * {@code outputs} holds every word matched when the automaton is in this 
state.
+     */
+    private static final class TrieNode {
+
+        private final Map<Character, TrieNode> children = new HashMap<>();
+
+        private TrieNode fail;
+
+        private String word;
+
+        private List<String> outputs = Collections.emptyList();
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/main/java/org/apache/shenyu/plugin/ai/sensitive/word/handler/SensitiveWordPluginDataHandler.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/main/java/org/apache/shenyu/plugin/ai/sensitive/word/handler/SensitiveWordPluginDataHandler.java
new file mode 100644
index 0000000000..a718dbbb41
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/main/java/org/apache/shenyu/plugin/ai/sensitive/word/handler/SensitiveWordPluginDataHandler.java
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.ai.sensitive.word.handler;
+
+import org.apache.shenyu.common.dto.PluginData;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.convert.rule.SensitiveWordHandle;
+import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.infra.redis.RedisConfigProperties;
+import org.apache.shenyu.infra.redis.RedisConnectionFactory;
+import org.apache.shenyu.infra.redis.ShenyuReactiveRedisTemplate;
+import 
org.apache.shenyu.infra.redis.serializer.ShenyuRedisSerializationContext;
+import org.apache.shenyu.plugin.base.cache.CommonHandleCache;
+import org.apache.shenyu.plugin.base.handler.PluginDataHandler;
+import org.apache.shenyu.plugin.base.utils.BeanHolder;
+import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
+import org.apache.shenyu.plugin.ai.sensitive.word.ac.AhoCorasick;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.redis.core.ReactiveRedisTemplate;
+import org.springframework.util.StringUtils;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Supplier;
+
+/**
+ * The sensitive word plugin data handler.
+ */
+public class SensitiveWordPluginDataHandler implements PluginDataHandler {
+
+    /**
+     * The plugin name, it is also the cache key of the redis template.
+     */
+    public static final String PLUGIN_NAME = 
PluginEnum.SENSITIVE_WORD.getName();
+
+    /**
+     * Cache the reactive redis template, the key is the plugin name.
+     */
+    public static final Supplier<CommonHandleCache<String, 
ReactiveRedisTemplate<String, String>>> REDIS_TEMPLATES =
+            new BeanHolder<>(CommonHandleCache::new);
+
+    /**
+     * Cache the redis configuration the template was built from, the key is 
the plugin name.
+     */
+    public static final Supplier<CommonHandleCache<String, 
RedisConfigProperties>> REDIS_PROPERTIES =
+            new BeanHolder<>(CommonHandleCache::new);
+
+    /**
+     * Cache the rule handle, the key is the cache key of the rule.
+     */
+    public static final Supplier<CommonHandleCache<String, 
SensitiveWordHandle>> CACHED_HANDLE =
+            new BeanHolder<>(CommonHandleCache::new);
+
+    /**
+     * Cache the dictionary automaton, the key is the redis key of the 
dictionary, so that rules
+     * pointing to different dictionaries never share the same automaton.
+     */
+    public static final Supplier<CommonHandleCache<String, CachedDictionary>> 
DICTIONARIES =
+            new BeanHolder<>(CommonHandleCache::new);
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(SensitiveWordPluginDataHandler.class);
+
+    @Override
+    public void handlerPlugin(final PluginData pluginData) {
+        if (Objects.isNull(pluginData) || 
!Boolean.TRUE.equals(pluginData.getEnabled())) {
+            return;
+        }
+        RedisConfigProperties redisConfig = GsonUtils.getInstance()
+                .fromJson(pluginData.getConfig(), RedisConfigProperties.class);
+        if (Objects.isNull(redisConfig) || 
!StringUtils.hasText(redisConfig.getUrl())) {
+            LOG.warn("sensitive word plugin: the redis configuration is 
missing, skip the redis initialization");
+            return;
+        }
+        RedisConfigProperties cachedProperties = 
REDIS_PROPERTIES.get().obtainHandle(PLUGIN_NAME);
+        if (Objects.isNull(REDIS_TEMPLATES.get().obtainHandle(PLUGIN_NAME)) || 
!redisConfig.equals(cachedProperties)) {
+            RedisConnectionFactory connectionFactory = new 
RedisConnectionFactory(redisConfig);
+            ReactiveRedisTemplate<String, String> redisTemplate = new 
ShenyuReactiveRedisTemplate<>(
+                    connectionFactory.getLettuceConnectionFactory(),
+                    
ShenyuRedisSerializationContext.stringSerializationContext());
+            REDIS_TEMPLATES.get().cachedHandle(PLUGIN_NAME, redisTemplate);
+            REDIS_PROPERTIES.get().cachedHandle(PLUGIN_NAME, redisConfig);
+            LOG.info("sensitive word plugin: cached the reactive redis 
template");
+        }
+    }
+
+    @Override
+    public void removePlugin(final PluginData pluginData) {
+        REDIS_TEMPLATES.get().removeHandle(PLUGIN_NAME);
+        REDIS_PROPERTIES.get().removeHandle(PLUGIN_NAME);
+        LOG.info("sensitive word plugin: released the cached redis template");
+    }
+
+    @Override
+    public void handlerRule(final RuleData ruleData) {
+        Optional.ofNullable(ruleData.getHandle()).ifPresent(json -> {
+            SensitiveWordHandle handle = 
GsonUtils.getInstance().fromJson(json, SensitiveWordHandle.class);
+            if (Objects.isNull(handle)) {
+                handle = SensitiveWordHandle.newDefaultInstance();
+            }
+            
CACHED_HANDLE.get().cachedHandle(CacheKeyUtils.INST.getKey(ruleData), handle);
+            // The rule changed, drop the cached dictionary so that it is read 
from redis again.
+            DICTIONARIES.get().removeHandle(handle.getRedisKey());
+        });
+    }
+
+    @Override
+    public void removeRule(final RuleData ruleData) {
+        CACHED_HANDLE.get().removeHandle(CacheKeyUtils.INST.getKey(ruleData));
+    }
+
+    @Override
+    public String pluginNamed() {
+        return PLUGIN_NAME;
+    }
+
+    /**
+     * A dictionary automaton together with the time it was loaded, used to 
refresh a dictionary
+     * whose refresh interval elapsed.
+     */
+    public static final class CachedDictionary {
+
+        private final AhoCorasick automaton;
+
+        private final long loadTime;
+
+        public CachedDictionary(final AhoCorasick automaton) {
+            this.automaton = automaton;
+            this.loadTime = System.currentTimeMillis();
+        }
+
+        /**
+         * Whether this dictionary is older than the refresh interval of the 
rule.
+         *
+         * @param refreshIntervalSeconds the refresh interval in seconds
+         * @return true when the dictionary must be read from redis again
+         */
+        public boolean isExpired(final long refreshIntervalSeconds) {
+            return refreshIntervalSeconds <= 0L
+                    || System.currentTimeMillis() - loadTime >= 
refreshIntervalSeconds * 1000L;
+        }
+
+        /**
+         * get the automaton.
+         *
+         * @return the automaton
+         */
+        public AhoCorasick getAutomaton() {
+            return automaton;
+        }
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/test/java/org/apache/shenyu/plugin/ai/sensitive/word/SensitiveWordPluginTest.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/test/java/org/apache/shenyu/plugin/ai/sensitive/word/SensitiveWordPluginTest.java
new file mode 100644
index 0000000000..6b7fe62d4f
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/test/java/org/apache/shenyu/plugin/ai/sensitive/word/SensitiveWordPluginTest.java
@@ -0,0 +1,218 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.ai.sensitive.word;
+
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.convert.rule.SensitiveWordHandle;
+import org.apache.shenyu.common.enums.PluginEnum;
+import 
org.apache.shenyu.plugin.ai.sensitive.word.handler.SensitiveWordPluginDataHandler;
+import org.apache.shenyu.plugin.api.ShenyuPluginChain;
+import org.apache.shenyu.plugin.api.result.DefaultShenyuResult;
+import org.apache.shenyu.plugin.api.result.ShenyuResult;
+import org.apache.shenyu.plugin.api.utils.SpringBeanUtils;
+import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.data.redis.core.ReactiveRedisTemplate;
+import org.springframework.data.redis.core.ReactiveSetOperations;
+import org.springframework.http.MediaType;
+import org.springframework.http.codec.ServerCodecConfigurer;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Test cases for {@link SensitiveWordPlugin}.
+ */
+public final class SensitiveWordPluginTest {
+
+    private static final String REDIS_KEY = "test:sensitive:words";
+
+    private SensitiveWordPlugin plugin;
+
+    private ShenyuPluginChain chain;
+
+    private RuleData ruleData;
+
+    @BeforeEach
+    public void setUp() {
+        ConfigurableApplicationContext applicationContext = 
mock(ConfigurableApplicationContext.class);
+        when(applicationContext.getBean(ShenyuResult.class)).thenReturn(new 
DefaultShenyuResult());
+        
SpringBeanUtils.getInstance().setApplicationContext(applicationContext);
+
+        plugin = new 
SensitiveWordPlugin(ServerCodecConfigurer.create().getReaders());
+        chain = mock(ShenyuPluginChain.class);
+        
when(chain.execute(any(ServerWebExchange.class))).thenReturn(Mono.empty());
+        ruleData = new RuleData();
+        ruleData.setId("rule-1");
+        ruleData.setName("rule-1");
+        ruleData.setSelectorId("selector-1");
+        ruleData.setPluginName(PluginEnum.SENSITIVE_WORD.getName());
+        ruleData.setHandle("{\"redisKey\":\"" + REDIS_KEY + "\"}");
+        SensitiveWordHandle handle = SensitiveWordHandle.newDefaultInstance();
+        handle.setRedisKey(REDIS_KEY);
+        SensitiveWordPluginDataHandler.CACHED_HANDLE.get()
+                .cachedHandle(CacheKeyUtils.INST.getKey(ruleData), handle);
+    }
+
+    @AfterEach
+    public void tearDown() {
+        SpringBeanUtils.getInstance().setApplicationContext(null);
+        
SensitiveWordPluginDataHandler.CACHED_HANDLE.get().removeHandle(CacheKeyUtils.INST.getKey(ruleData));
+        
SensitiveWordPluginDataHandler.REDIS_TEMPLATES.get().removeHandle(SensitiveWordPluginDataHandler.PLUGIN_NAME);
+        
SensitiveWordPluginDataHandler.DICTIONARIES.get().removeHandle(REDIS_KEY);
+    }
+
+    @Test
+    public void testNamed() {
+        assertEquals(PluginEnum.SENSITIVE_WORD.getName(), plugin.named());
+    }
+
+    @Test
+    public void testGetOrder() {
+        assertEquals(PluginEnum.SENSITIVE_WORD.getCode(), plugin.getOrder());
+    }
+
+    @Test
+    public void testPassThroughWhenNoRuleHandle() {
+        
SensitiveWordPluginDataHandler.CACHED_HANDLE.get().removeHandle(CacheKeyUtils.INST.getKey(ruleData));
+        StepVerifier.create(plugin.doExecute(exchange("a clean request"), 
chain, null, ruleData)).verifyComplete();
+        verify(chain).execute(any(ServerWebExchange.class));
+    }
+
+    @Test
+    public void testPassThroughWhenRedisIsNotInitialized() {
+        StepVerifier.create(plugin.doExecute(exchange("a clean request"), 
chain, null, ruleData)).verifyComplete();
+        verify(chain).execute(any(ServerWebExchange.class));
+    }
+
+    @Test
+    public void testPassThroughWhenTheBodyIsClean() {
+        mockRedisDictionary("forbidden", "banned");
+        StepVerifier.create(plugin.doExecute(exchange("a clean request"), 
chain, null, ruleData)).verifyComplete();
+        verify(chain).execute(any(ServerWebExchange.class));
+    }
+
+    @Test
+    public void testRejectTheRequestContainingASensitiveWord() {
+        mockRedisDictionary("forbidden", "banned");
+        MockServerWebExchange exchange = exchange("this request is banned");
+        StepVerifier.create(plugin.doExecute(exchange, chain, null, 
ruleData)).verifyComplete();
+        verify(chain, never()).execute(any(ServerWebExchange.class));
+        // the client is told that the request was rejected, never which word 
matched
+        StepVerifier.create(exchange.getResponse().getBodyAsString())
+                .expectNextMatches(body -> body.contains("sensitive content 
detected") && !body.contains("banned"))
+                .verifyComplete();
+    }
+
+    @Test
+    public void 
testFailClosedRejectsTheRequestWhenTheDictionaryIsUnavailable() {
+        SensitiveWordPluginDataHandler.CACHED_HANDLE.get()
+                .cachedHandle(CacheKeyUtils.INST.getKey(ruleData), 
failClosedHandle());
+        ReactiveRedisTemplate<String, String> redisTemplate = 
mock(ReactiveRedisTemplate.class);
+        ReactiveSetOperations<String, String> setOperations = 
mock(ReactiveSetOperations.class);
+        when(redisTemplate.opsForSet()).thenReturn(setOperations);
+        when(setOperations.members(REDIS_KEY)).thenReturn(Flux.error(new 
IllegalStateException("redis is down")));
+        cacheRedisTemplate(redisTemplate);
+
+        MockServerWebExchange exchange = exchange("a clean request");
+        StepVerifier.create(plugin.doExecute(exchange, chain, null, 
ruleData)).verifyComplete();
+        verify(chain, never()).execute(any(ServerWebExchange.class));
+        StepVerifier.create(exchange.getResponse().getBodyAsString())
+                .expectNextMatches(body -> body.contains("sensitive content 
detected"))
+                .verifyComplete();
+    }
+
+    @Test
+    public void testTheStaleDictionaryIsUsedWhenRedisFails() {
+        mockRedisDictionary("forbidden", "banned");
+        MockServerWebExchange first = exchange("a clean request");
+        StepVerifier.create(plugin.doExecute(first, chain, null, 
ruleData)).verifyComplete();
+        // the first request legitimately went through, only what follows 
matters here
+        clearInvocations(chain);
+
+        // failClosed + refreshIntervalSeconds = 0: the dictionary is read 
again on every request
+        SensitiveWordPluginDataHandler.CACHED_HANDLE.get()
+                .cachedHandle(CacheKeyUtils.INST.getKey(ruleData), 
failClosedHandle());
+        ReactiveRedisTemplate<String, String> brokenTemplate = 
mock(ReactiveRedisTemplate.class);
+        ReactiveSetOperations<String, String> setOperations = 
mock(ReactiveSetOperations.class);
+        when(brokenTemplate.opsForSet()).thenReturn(setOperations);
+        when(setOperations.members(REDIS_KEY)).thenReturn(Flux.error(new 
IllegalStateException("redis is down")));
+        cacheRedisTemplate(brokenTemplate);
+
+        MockServerWebExchange exchange = exchange("this request is banned");
+        StepVerifier.create(plugin.doExecute(exchange, chain, null, 
ruleData)).verifyComplete();
+        // the stale dictionary is still enforced instead of letting the 
request through
+        verify(chain, never()).execute(any(ServerWebExchange.class));
+    }
+
+    @Test
+    public void testPassThroughWhenRedisFails() {
+        ReactiveRedisTemplate<String, String> redisTemplate = 
mock(ReactiveRedisTemplate.class);
+        ReactiveSetOperations<String, String> setOperations = 
mock(ReactiveSetOperations.class);
+        when(redisTemplate.opsForSet()).thenReturn(setOperations);
+        when(setOperations.members(REDIS_KEY)).thenReturn(Flux.error(new 
IllegalStateException("redis is down")));
+        cacheRedisTemplate(redisTemplate);
+
+        // fail open: a broken redis must not stop the traffic
+        StepVerifier.create(plugin.doExecute(exchange("this request is 
banned"), chain, null, ruleData)).verifyComplete();
+        verify(chain).execute(any(ServerWebExchange.class));
+    }
+
+    @SuppressWarnings("unchecked")
+    private void mockRedisDictionary(final String... words) {
+        ReactiveRedisTemplate<String, String> redisTemplate = 
mock(ReactiveRedisTemplate.class);
+        ReactiveSetOperations<String, String> setOperations = 
mock(ReactiveSetOperations.class);
+        when(redisTemplate.opsForSet()).thenReturn(setOperations);
+        
when(setOperations.members(REDIS_KEY)).thenReturn(Flux.fromArray(words));
+        cacheRedisTemplate(redisTemplate);
+    }
+
+    @SuppressWarnings("unchecked")
+    private void cacheRedisTemplate(final ReactiveRedisTemplate<String, 
String> redisTemplate) {
+        SensitiveWordPluginDataHandler.REDIS_TEMPLATES.get()
+                .cachedHandle(SensitiveWordPluginDataHandler.PLUGIN_NAME, 
redisTemplate);
+    }
+
+    private SensitiveWordHandle failClosedHandle() {
+        SensitiveWordHandle handle = SensitiveWordHandle.newDefaultInstance();
+        handle.setRedisKey(REDIS_KEY);
+        handle.setRefreshIntervalSeconds(0L);
+        handle.setFailClosed(true);
+        return handle;
+    }
+
+    private MockServerWebExchange exchange(final String body) {
+        return 
MockServerWebExchange.from(MockServerHttpRequest.post("/ai/chat")
+                .contentType(MediaType.TEXT_PLAIN)
+                .body(body));
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/test/java/org/apache/shenyu/plugin/ai/sensitive/word/ac/AhoCorasickTest.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/test/java/org/apache/shenyu/plugin/ai/sensitive/word/ac/AhoCorasickTest.java
new file mode 100644
index 0000000000..ad6e8bcd4a
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/test/java/org/apache/shenyu/plugin/ai/sensitive/word/ac/AhoCorasickTest.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.ai.sensitive.word.ac;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Test cases for {@link AhoCorasick}.
+ */
+public final class AhoCorasickTest {
+
+    @Test
+    public void testMatchSingleWord() {
+        AhoCorasick automaton = 
AhoCorasick.of(Collections.singletonList("sensitive"));
+        assertThat(automaton.search("this is a sensitive 
word")).containsExactly("sensitive");
+    }
+
+    @Test
+    public void testMatchEveryWordOfTheText() {
+        AhoCorasick automaton = AhoCorasick.of(Arrays.asList("sensitive", 
"word", "spam"));
+        assertThat(automaton.search("a sensitive word and a 
spam")).containsExactlyInAnyOrder("sensitive", "word", "spam");
+    }
+
+    @Test
+    public void testMatchNestedWords() {
+        AhoCorasick automaton = AhoCorasick.of(Arrays.asList("abc", "bc", 
"c"));
+        assertThat(automaton.search("abc")).containsExactlyInAnyOrder("abc", 
"bc", "c");
+    }
+
+    @Test
+    public void testMatchOverlappingWords() {
+        AhoCorasick automaton = AhoCorasick.of(Arrays.asList("中国", "中国银行"));
+        assertThat(automaton.search("中国银行")).containsExactlyInAnyOrder("中国", 
"中国银行");
+    }
+
+    @Test
+    public void testMatchSuffixWords() {
+        AhoCorasick automaton = AhoCorasick.of(Arrays.asList("敏感词", "词"));
+        
assertThat(automaton.search("这里有敏感词")).containsExactlyInAnyOrder("敏感词", "词");
+    }
+
+    @Test
+    public void testFailureLinksAreFollowed() {
+        AhoCorasick automaton = AhoCorasick.of(Arrays.asList("he", "she", 
"his", "hers"));
+        
assertThat(automaton.search("ushers")).containsExactlyInAnyOrder("she", "he", 
"hers");
+    }
+
+    @Test
+    public void testMatchAtTextBoundaries() {
+        AhoCorasick automaton = 
AhoCorasick.of(Collections.singletonList("ab"));
+        assertThat(automaton.search("abxxab")).containsExactly("ab");
+        assertThat(automaton.search("ab")).containsExactly("ab");
+        assertThat(automaton.search("a")).isEmpty();
+    }
+
+    @Test
+    public void testWordIsReportedOnce() {
+        AhoCorasick automaton = 
AhoCorasick.of(Collections.singletonList("spam"));
+        assertThat(automaton.search("spam spam spam")).containsExactly("spam");
+    }
+
+    @Test
+    public void testMatchCjkText() {
+        AhoCorasick automaton = AhoCorasick.of(Arrays.asList("违规", "敏感"));
+        
assertThat(automaton.search("这段内容违规而且敏感")).containsExactlyInAnyOrder("违规", 
"敏感");
+    }
+
+    @Test
+    public void testNoMatch() {
+        AhoCorasick automaton = AhoCorasick.of(Arrays.asList("sensitive", 
"spam"));
+        assertThat(automaton.search("a clean text")).isEmpty();
+    }
+
+    @Test
+    public void testEmptyDictionary() {
+        
assertThat(AhoCorasick.of(Collections.emptyList()).search("anything")).isEmpty();
+        assertThat(AhoCorasick.of(null).search("anything")).isEmpty();
+        assertThat(AhoCorasick.empty().search("anything")).isEmpty();
+    }
+
+    @Test
+    public void testBlankAndNullWordsAreIgnored() {
+        AhoCorasick automaton = AhoCorasick.of(Arrays.asList(" ", "", null, "  
hit  "));
+        assertThat(automaton.search("a hit")).containsExactly("hit");
+        assertThat(automaton.search("nothing")).isEmpty();
+    }
+
+    @Test
+    public void testNullAndEmptyText() {
+        AhoCorasick automaton = 
AhoCorasick.of(Collections.singletonList("sensitive"));
+        assertThat(automaton.search(null)).isEmpty();
+        assertThat(automaton.search("")).isEmpty();
+    }
+
+    @Test
+    public void testLongText() {
+        StringBuilder text = new StringBuilder();
+        for (int i = 0; i < 10000; i++) {
+            text.append("filler");
+        }
+        text.append("sensitive");
+        AhoCorasick automaton = AhoCorasick.of(Arrays.asList("sensitive", 
"filler"));
+        
assertThat(automaton.search(text.toString())).containsExactlyInAnyOrder("filler",
 "sensitive");
+    }
+}
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/test/java/org/apache/shenyu/plugin/ai/sensitive/word/handler/SensitiveWordPluginDataHandlerTest.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/test/java/org/apache/shenyu/plugin/ai/sensitive/word/handler/SensitiveWordPluginDataHandlerTest.java
new file mode 100644
index 0000000000..767da72c7d
--- /dev/null
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/test/java/org/apache/shenyu/plugin/ai/sensitive/word/handler/SensitiveWordPluginDataHandlerTest.java
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.plugin.ai.sensitive.word.handler;
+
+import org.apache.shenyu.common.dto.PluginData;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.convert.rule.SensitiveWordHandle;
+import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
+import org.apache.shenyu.plugin.ai.sensitive.word.ac.AhoCorasick;
+import 
org.apache.shenyu.plugin.ai.sensitive.word.handler.SensitiveWordPluginDataHandler.CachedDictionary;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test cases for {@link SensitiveWordPluginDataHandler}.
+ */
+public final class SensitiveWordPluginDataHandlerTest {
+
+    private SensitiveWordPluginDataHandler handler;
+
+    @BeforeEach
+    public void setUp() {
+        handler = new SensitiveWordPluginDataHandler();
+    }
+
+    @AfterEach
+    public void tearDown() {
+        SensitiveWordPluginDataHandler.CACHED_HANDLE.get()
+                .removeHandle(CacheKeyUtils.INST.getKey("selector-1", 
"rule-1"));
+        
SensitiveWordPluginDataHandler.DICTIONARIES.get().removeHandle("my:sensitive:words");
+        
SensitiveWordPluginDataHandler.REDIS_TEMPLATES.get().removeHandle(SensitiveWordPluginDataHandler.PLUGIN_NAME);
+        
SensitiveWordPluginDataHandler.REDIS_PROPERTIES.get().removeHandle(SensitiveWordPluginDataHandler.PLUGIN_NAME);
+    }
+
+    @Test
+    public void testPluginNamed() {
+        assertEquals(PluginEnum.SENSITIVE_WORD.getName(), 
handler.pluginNamed());
+    }
+
+    @Test
+    public void testHandlerRuleCachesTheHandle() {
+        RuleData ruleData = 
ruleData("{\"redisKey\":\"my:sensitive:words\",\"refreshIntervalSeconds\":10}");
+        handler.handlerRule(ruleData);
+        SensitiveWordHandle handle = 
SensitiveWordPluginDataHandler.CACHED_HANDLE.get()
+                .obtainHandle(CacheKeyUtils.INST.getKey(ruleData));
+        assertNotNull(handle);
+        assertEquals("my:sensitive:words", handle.getRedisKey());
+        assertEquals(10L, handle.getRefreshIntervalSeconds());
+    }
+
+    @Test
+    public void testHandlerRuleWithoutRedisKeyFallsBackToTheDefaultOne() {
+        RuleData ruleData = ruleData("{}");
+        handler.handlerRule(ruleData);
+        SensitiveWordHandle handle = 
SensitiveWordPluginDataHandler.CACHED_HANDLE.get()
+                .obtainHandle(CacheKeyUtils.INST.getKey(ruleData));
+        assertNotNull(handle);
+        assertEquals(SensitiveWordHandle.DEFAULT_REDIS_KEY, 
handle.getRedisKey());
+        assertEquals(300L, handle.getRefreshIntervalSeconds());
+    }
+
+    @Test
+    public void testHandlerRuleInvalidatesTheCachedDictionary() {
+        String redisKey = "my:sensitive:words";
+        SensitiveWordPluginDataHandler.DICTIONARIES.get()
+                .cachedHandle(redisKey, new 
CachedDictionary(AhoCorasick.empty()));
+        handler.handlerRule(ruleData("{\"redisKey\":\"my:sensitive:words\"}"));
+        
assertNull(SensitiveWordPluginDataHandler.DICTIONARIES.get().obtainHandle(redisKey));
+    }
+
+    @Test
+    public void testRemoveRuleRemovesTheHandle() {
+        RuleData ruleData = ruleData("{\"redisKey\":\"my:sensitive:words\"}");
+        handler.handlerRule(ruleData);
+        
assertNotNull(SensitiveWordPluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(ruleData)));
+        handler.removeRule(ruleData);
+        
assertNull(SensitiveWordPluginDataHandler.CACHED_HANDLE.get().obtainHandle(CacheKeyUtils.INST.getKey(ruleData)));
+    }
+
+    @Test
+    public void testHandlerPluginWithoutRedisConfiguration() {
+        PluginData pluginData = new PluginData();
+        pluginData.setEnabled(true);
+        pluginData.setConfig("{}");
+        handler.handlerPlugin(pluginData);
+        assertNull(SensitiveWordPluginDataHandler.REDIS_TEMPLATES.get()
+                .obtainHandle(SensitiveWordPluginDataHandler.PLUGIN_NAME));
+    }
+
+    @Test
+    public void testHandlerPluginDisabled() {
+        PluginData pluginData = new PluginData();
+        pluginData.setEnabled(false);
+        pluginData.setConfig("{\"url\":\"127.0.0.1:6379\"}");
+        handler.handlerPlugin(pluginData);
+        assertNull(SensitiveWordPluginDataHandler.REDIS_TEMPLATES.get()
+                .obtainHandle(SensitiveWordPluginDataHandler.PLUGIN_NAME));
+    }
+
+    @Test
+    public void testHandlerPluginCachesTheRedisTemplate() {
+        PluginData pluginData = new PluginData();
+        pluginData.setEnabled(true);
+        
pluginData.setConfig("{\"url\":\"127.0.0.1:6379\",\"database\":0,\"maxIdle\":8,\"minIdle\":0,\"maxActive\":8}");
+        handler.handlerPlugin(pluginData);
+        assertNotNull(SensitiveWordPluginDataHandler.REDIS_TEMPLATES.get()
+                .obtainHandle(SensitiveWordPluginDataHandler.PLUGIN_NAME));
+        assertNotNull(SensitiveWordPluginDataHandler.REDIS_PROPERTIES.get()
+                .obtainHandle(SensitiveWordPluginDataHandler.PLUGIN_NAME));
+    }
+
+    @Test
+    public void testRemovePluginReleasesTheRedisTemplate() {
+        PluginData pluginData = new PluginData();
+        pluginData.setEnabled(true);
+        pluginData.setConfig("{\"url\":\"127.0.0.1:6379\"}");
+        handler.handlerPlugin(pluginData);
+        assertNotNull(SensitiveWordPluginDataHandler.REDIS_TEMPLATES.get()
+                .obtainHandle(SensitiveWordPluginDataHandler.PLUGIN_NAME));
+        handler.removePlugin(pluginData);
+        assertNull(SensitiveWordPluginDataHandler.REDIS_TEMPLATES.get()
+                .obtainHandle(SensitiveWordPluginDataHandler.PLUGIN_NAME));
+        assertNull(SensitiveWordPluginDataHandler.REDIS_PROPERTIES.get()
+                .obtainHandle(SensitiveWordPluginDataHandler.PLUGIN_NAME));
+    }
+
+    @Test
+    public void testCachedDictionaryExpires() {
+        CachedDictionary dictionary = new 
CachedDictionary(AhoCorasick.empty());
+        assertTrue(dictionary.isExpired(0L));
+        assertTrue(dictionary.isExpired(-1L));
+        assertTrue(!dictionary.isExpired(300L));
+    }
+
+    private RuleData ruleData(final String handle) {
+        RuleData ruleData = new RuleData();
+        ruleData.setId("rule-1");
+        ruleData.setName("rule-1");
+        ruleData.setSelectorId("selector-1");
+        ruleData.setPluginName(PluginEnum.SENSITIVE_WORD.getName());
+        ruleData.setHandle(handle);
+        return ruleData;
+    }
+}
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/pom.xml 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/pom.xml
index e5192d9829..d096319ffb 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/pom.xml
+++ b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/pom.xml
@@ -78,5 +78,6 @@
         
<module>shenyu-spring-boot-starter-plugin-ai-request-transformer</module>
         
<module>shenyu-spring-boot-starter-plugin-ai-response-transformer</module>
         <module>shenyu-spring-boot-starter-plugin-mcp-server</module>
+        <module>shenyu-spring-boot-starter-plugin-ai-sensitive-word</module>
     </modules>
 </project>
diff --git a/shenyu-plugin/shenyu-plugin-ai/pom.xml 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/pom.xml
similarity index 71%
copy from shenyu-plugin/shenyu-plugin-ai/pom.xml
copy to 
shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/pom.xml
index 22f032a879..0da5b0acc6 100644
--- a/shenyu-plugin/shenyu-plugin-ai/pom.xml
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/pom.xml
@@ -17,23 +17,19 @@
   -->
 
 <project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
-
     <parent>
         <groupId>org.apache.shenyu</groupId>
-        <artifactId>shenyu-plugin</artifactId>
+        <artifactId>shenyu-spring-boot-starter-plugin</artifactId>
         <version>2.7.2-SNAPSHOT</version>
     </parent>
     <modelVersion>4.0.0</modelVersion>
-    <artifactId>shenyu-plugin-ai</artifactId>
-    <packaging>pom</packaging>
-
-    <modules>
-        <module>shenyu-plugin-ai-common</module>
-        <module>shenyu-plugin-ai-prompt</module>
-        <module>shenyu-plugin-ai-token-limiter</module>
-        <module>shenyu-plugin-ai-request-transformer</module>
-        <module>shenyu-plugin-ai-proxy</module>
-        <module>shenyu-plugin-ai-response-transformer</module>
-    </modules>
+    
<artifactId>shenyu-spring-boot-starter-plugin-ai-sensitive-word</artifactId>
 
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.shenyu</groupId>
+            <artifactId>shenyu-plugin-ai-sensitive-word</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+    </dependencies>
 </project>
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/src/main/java/org/apache/shenyu/springboot/starter/plugin/ai/sensitive/word/SensitiveWordPluginConfiguration.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/src/main/java/org/apache/shenyu/springboot/starter/plugin/ai/sensitive/word/SensitiveWordPluginConfiguration.java
new file mode 100644
index 0000000000..e9a4d06a37
--- /dev/null
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/src/main/java/org/apache/shenyu/springboot/starter/plugin/ai/sensitive/word/SensitiveWordPluginConfiguration.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.springboot.starter.plugin.ai.sensitive.word;
+
+import org.apache.shenyu.plugin.api.ShenyuPlugin;
+import org.apache.shenyu.plugin.base.handler.PluginDataHandler;
+import org.apache.shenyu.plugin.ai.sensitive.word.SensitiveWordPlugin;
+import 
org.apache.shenyu.plugin.ai.sensitive.word.handler.SensitiveWordPluginDataHandler;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.codec.ServerCodecConfigurer;
+
+/**
+ * The sensitive word plugin configuration.
+ */
+@Configuration
+@ConditionalOnProperty(value = "shenyu.plugins.sensitive-word.enabled", 
havingValue = "true", matchIfMissing = true)
+public class SensitiveWordPluginConfiguration {
+
+    /**
+     * the sensitive word plugin.
+     *
+     * @param configurer the server codec configurer
+     * @return the sensitive word plugin
+     */
+    @Bean
+    public ShenyuPlugin sensitiveWordPlugin(final ServerCodecConfigurer 
configurer) {
+        return new SensitiveWordPlugin(configurer.getReaders());
+    }
+
+    /**
+     * the sensitive word plugin data handler, it caches the redis client, the 
rule handles and the
+     * dictionaries.
+     *
+     * @return the sensitive word plugin data handler
+     */
+    @Bean
+    public PluginDataHandler sensitiveWordPluginDataHandler() {
+        return new SensitiveWordPluginDataHandler();
+    }
+}
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/src/main/resources/META-INF/spring.factories
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/src/main/resources/META-INF/spring.factories
new file mode 100644
index 0000000000..1349512313
--- /dev/null
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/src/main/resources/META-INF/spring.factories
@@ -0,0 +1,19 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
+org.apache.shenyu.springboot.starter.plugin.ai.sensitive.word.SensitiveWordPluginConfiguration
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/src/main/resources/META-INF/spring.provides
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/src/main/resources/META-INF/spring.provides
new file mode 100644
index 0000000000..19277d9808
--- /dev/null
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/src/main/resources/META-INF/spring.provides
@@ -0,0 +1,18 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+provides: shenyu-spring-boot-starter-plugin-ai-sensitive-word
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 0000000000..e802915ebf
--- /dev/null
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1,18 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+org.apache.shenyu.springboot.starter.plugin.ai.sensitive.word.SensitiveWordPluginConfiguration
diff --git 
a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/src/test/java/org/apache/shenyu/springboot/starter/plugin/ai/sensitive/word/SensitiveWordPluginConfigurationTest.java
 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/src/test/java/org/apache/shenyu/springboot/starter/plugin/ai/sensitive/word/SensitiveWordPluginConfigurationTest.java
new file mode 100644
index 0000000000..16b4302f22
--- /dev/null
+++ 
b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-plugin/shenyu-spring-boot-starter-plugin-ai-sensitive-word/src/test/java/org/apache/shenyu/springboot/starter/plugin/ai/sensitive/word/SensitiveWordPluginConfigurationTest.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.springboot.starter.plugin.ai.sensitive.word;
+
+import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.plugin.api.ShenyuPlugin;
+import org.apache.shenyu.plugin.base.handler.PluginDataHandler;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import 
org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.codec.support.DefaultServerCodecConfigurer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * Test case for {@link SensitiveWordPluginConfiguration}.
+ */
+@Configuration
+@EnableConfigurationProperties
+public class SensitiveWordPluginConfigurationTest {
+
+    @Test
+    public void testSensitiveWordPlugin() {
+        new ApplicationContextRunner()
+            
.withConfiguration(AutoConfigurations.of(SensitiveWordPluginConfiguration.class,
 DefaultServerCodecConfigurer.class))
+            .withBean(SensitiveWordPluginConfigurationTest.class)
+            .withPropertyValues("debug=true")
+            .run(context -> {
+                ShenyuPlugin plugin = context.getBean("sensitiveWordPlugin", 
ShenyuPlugin.class);
+                assertNotNull(plugin);
+                
assertThat(plugin.named()).isEqualTo(PluginEnum.SENSITIVE_WORD.getName());
+            });
+    }
+
+    @Test
+    public void testSensitiveWordPluginDataHandler() {
+        new ApplicationContextRunner()
+            
.withConfiguration(AutoConfigurations.of(SensitiveWordPluginConfiguration.class,
 DefaultServerCodecConfigurer.class))
+            .withBean(SensitiveWordPluginConfigurationTest.class)
+            .withPropertyValues("debug=true")
+            .run(context -> {
+                PluginDataHandler handler = 
context.getBean("sensitiveWordPluginDataHandler", PluginDataHandler.class);
+                assertNotNull(handler);
+                
assertThat(handler.pluginNamed()).isEqualTo(PluginEnum.SENSITIVE_WORD.getName());
+            });
+    }
+}

Reply via email to