|
@@ -0,0 +1,154 @@
|
|
|
|
|
+package com.zsjz.ai.module.agent.state;
|
|
|
|
|
+
|
|
|
|
|
+import io.agentscope.extensions.redis.state.RedisClientAdapter;
|
|
|
|
|
+import org.springframework.data.redis.connection.RedisConnection;
|
|
|
|
|
+import org.springframework.data.redis.connection.ReturnType;
|
|
|
|
|
+import org.springframework.data.redis.core.Cursor;
|
|
|
|
|
+import org.springframework.data.redis.core.ScanOptions;
|
|
|
|
|
+import org.springframework.data.redis.core.StringRedisTemplate;
|
|
|
|
|
+
|
|
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.HashSet;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Objects;
|
|
|
|
|
+import java.util.Set;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 基于 {@link StringRedisTemplate} 的 Redis 适配器,供
|
|
|
|
|
+ * {@code io.agentscope.extensions.redis.state.RedisAgentStateStore} 存储会话状态。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>为什么不直接用官方的 {@code LettuceClientAdapter}</b>:
|
|
|
|
|
+ * <ol>
|
|
|
|
|
+ * <li>官方的 {@code of(RedisClient)} 会立刻 {@code connect()},Redis 不可用时整个应用启动失败;
|
|
|
|
|
+ * 本适配器复用 Spring 连接工厂,连接按需建立(对齐 {@code CaseDataCache} 的降级约定);</li>
|
|
|
|
|
+ * <li>官方适配器 {@code close()} 会 {@code client.shutdown()},把传进去的 client 关掉——
|
|
|
|
|
+ * 若传的是 Spring 管理的共享 client,等于把 sa-token 登录态依赖的连接一起关死;</li>
|
|
|
|
|
+ * <li>复用容器里的同一个连接工厂与序列化配置,不额外造第二个连接池。</li>
|
|
|
|
|
+ * </ol>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>key/value 一律走 {@code StringRedisTemplate} 的 String 序列化:{@code RedisAgentStateStore}
|
|
|
|
|
+ * 存的是 JSON 文本,键是它自己拼的字符串,不需要对象序列化。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @author cc
|
|
|
|
|
+ * @since 2026/10/29
|
|
|
|
|
+ */
|
|
|
|
|
+public class StringRedisTemplateClientAdapter implements RedisClientAdapter {
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 单次 SCAN 的建议条数(只影响分批大小,不是硬上限)
|
|
|
|
|
+ */
|
|
|
|
|
+ private static final long SCAN_BATCH = 1000;
|
|
|
|
|
+
|
|
|
|
|
+ private final StringRedisTemplate redis;
|
|
|
|
|
+
|
|
|
|
|
+ public StringRedisTemplateClientAdapter(StringRedisTemplate redis) {
|
|
|
|
|
+ this.redis = Objects.requireNonNull(redis, "StringRedisTemplate 不能为空");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public void set(String key, String value) {
|
|
|
|
|
+ redis.opsForValue().set(key, value);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public String get(String key) {
|
|
|
|
|
+ return redis.opsForValue().get(key);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public void rightPushList(String key, String value) {
|
|
|
|
|
+ redis.opsForList().rightPush(key, value);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public List<String> rangeList(String key, long start, long end) {
|
|
|
|
|
+ // Lettuce 对不存在的 key 返回空集合,这里对齐:调用方按"列表为空"分支处理,不能给 null
|
|
|
|
|
+ List<String> range = redis.opsForList().range(key, start, end);
|
|
|
|
|
+ return range == null ? List.of() : range;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public long getListLength(String key) {
|
|
|
|
|
+ return nullToZero(redis.opsForList().size(key));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public void deleteKeys(String... keys) {
|
|
|
|
|
+ if (keys == null || keys.length == 0) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ redis.delete(List.of(keys));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public void addToSet(String key, String member) {
|
|
|
|
|
+ redis.opsForSet().add(key, member);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public Set<String> getSetMembers(String key) {
|
|
|
|
|
+ Set<String> members = redis.opsForSet().members(key);
|
|
|
|
|
+ return members == null ? Set.of() : new HashSet<>(members);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public long getSetSize(String key) {
|
|
|
|
|
+ return nullToZero(redis.opsForSet().size(key));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public boolean keyExists(String key) {
|
|
|
|
|
+ return Boolean.TRUE.equals(redis.hasKey(key));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public Set<String> findKeysByPattern(String pattern) {
|
|
|
|
|
+ Set<String> keys = new HashSet<>();
|
|
|
|
|
+ ScanOptions options = ScanOptions.scanOptions().match(pattern).count(SCAN_BATCH).build();
|
|
|
|
|
+ // SCAN 游标必须关闭,否则连接不归还
|
|
|
|
|
+ try (Cursor<String> cursor = redis.scan(options)) {
|
|
|
|
|
+ cursor.forEachRemaining(keys::add);
|
|
|
|
|
+ }
|
|
|
|
|
+ return keys;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 执行 Lua 脚本。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>走底层 {@link RedisConnection#scriptingCommands()}({@link ReturnType#INTEGER})而不是
|
|
|
|
|
+ * {@code template.execute(RedisScript, ...)}:后者会拿 template 的 value 序列化器去反序列化
|
|
|
|
|
+ * 整数回复,String 序列化器下容易踩到类型转换,这里直接用原生的整数返回类型。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public long evalScript(String script, List<String> keys, List<String> args) {
|
|
|
|
|
+ byte[] scriptBytes = script.getBytes(StandardCharsets.UTF_8);
|
|
|
|
|
+ List<byte[]> keysAndArgs = new ArrayList<>(keys.size() + args.size());
|
|
|
|
|
+ for (String key : keys) {
|
|
|
|
|
+ keysAndArgs.add(key.getBytes(StandardCharsets.UTF_8));
|
|
|
|
|
+ }
|
|
|
|
|
+ for (String arg : args) {
|
|
|
|
|
+ keysAndArgs.add(arg.getBytes(StandardCharsets.UTF_8));
|
|
|
|
|
+ }
|
|
|
|
|
+ Long result = redis.execute((RedisConnection connection) ->
|
|
|
|
|
+ connection.scriptingCommands().eval(scriptBytes, ReturnType.INTEGER, keys.size(),
|
|
|
|
|
+ keysAndArgs.toArray(new byte[0][])));
|
|
|
|
|
+ if (result == null) {
|
|
|
|
|
+ throw new IllegalStateException("Lua 脚本未返回整数结果");
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 不做任何事:连接工厂与连接池归 Spring 管理,随容器关闭;
|
|
|
|
|
+ * 这里主动关会把整个应用共享的连接池关掉。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public void close() {
|
|
|
|
|
+ // no-op
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static long nullToZero(Long value) {
|
|
|
|
|
+ return value == null ? 0L : value;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|