diff --git a/.gitignore b/.gitignore index cd11b9e..b264bbe 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,9 @@ /logs /target /temp -CLAUDE.md +/.claude +/CLAUDE.md +/AGENTS.md multilingualField/ !.mvn/wrapper/maven-wrapper.jar diff --git a/src/main/java/com/imyeyu/api/config/RedisConfig.java b/src/main/java/com/imyeyu/api/config/RedisConfig.java index 50c751b..ec69cbf 100644 --- a/src/main/java/com/imyeyu/api/config/RedisConfig.java +++ b/src/main/java/com/imyeyu/api/config/RedisConfig.java @@ -1,13 +1,13 @@ package com.imyeyu.api.config; -import lombok.Data; -import lombok.EqualsAndHashCode; import com.imyeyu.api.modules.blog.entity.ArticleRanking; import com.imyeyu.api.modules.common.entity.Multilingual; import com.imyeyu.spring.bean.RedisConfigParams; import com.imyeyu.spring.config.AbstractRedisConfig; import com.imyeyu.spring.util.Redis; import com.imyeyu.spring.util.RedisSerializers; +import lombok.Data; +import lombok.EqualsAndHashCode; import org.apache.commons.pool2.impl.GenericObjectPoolConfig; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -134,6 +134,9 @@ public class RedisConfig extends AbstractRedisConfig { /** Minecraft 登录 */ private int fmcPlayerToken; + + /** 共享剪切板 */ + private int clipboard; } @Override @@ -265,4 +268,10 @@ public class RedisConfig extends AbstractRedisConfig { public Redis getMCPlayerLoginRedisTemplate() { return getRedis(database.fmcPlayerToken, RedisSerializers.STRING, RedisSerializers.LONG); } + + /** @return 共享剪切板,会话 ID: 内容 */ + @Bean("redisClipboard") + public Redis getClipboardRedisTemplate() { + return getRedis(database.clipboard, RedisSerializers.STRING, RedisSerializers.STRING); + } } diff --git a/src/main/java/com/imyeyu/api/modules/common/service/ClipboardService.java b/src/main/java/com/imyeyu/api/modules/common/service/ClipboardService.java new file mode 100644 index 0000000..9ad0911 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/ClipboardService.java @@ -0,0 +1,36 @@ +package com.imyeyu.api.modules.common.service; + +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * 共享剪切板服务 + * + * @author 夜雨 + * @since 2026-01-15 16:40 + */ +public interface ClipboardService { + + /** + * 获取共享剪切板内容 + * + * @param id 会话 ID + * @return 剪切板内容 + */ + String getContent(String id); + + /** + * 设置共享剪切板内容 + * + * @param id 会话 ID + * @param content 剪切板内容 + */ + void setContent(String id, String content); + + /** + * 订阅共享剪切板实时更新 + * + * @param id 会话 ID + * @return SSE 发射器 + */ + SseEmitter subscribe(String id); +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/ClipboardServiceImplement.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/ClipboardServiceImplement.java new file mode 100644 index 0000000..9c11d74 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/ClipboardServiceImplement.java @@ -0,0 +1,140 @@ +package com.imyeyu.api.modules.common.service.implement; + +import com.imyeyu.api.modules.common.service.ClipboardService; +import com.imyeyu.java.TimiJava; +import com.imyeyu.java.bean.timi.TimiCode; +import com.imyeyu.java.bean.timi.TimiException; +import com.imyeyu.spring.util.Redis; +import com.imyeyu.utils.Time; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * 共享剪切板服务实现 + * + * @author 夜雨 + * @since 2026-01-15 16:40 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ClipboardServiceImplement implements ClipboardService { + + /** Redis key 前缀 */ + private static final String KEY_PREFIX = "clipboard:"; + + /** 剪切板内容最大长度 */ + private static final int MAX_CONTENT_SIZE = 10 * 1024 * 1024; + + /** 剪切板缓存 */ + private final Redis redisClipboard; + + /** SSE 订阅者 */ + private final Map> emitterMap = new ConcurrentHashMap<>(); + + @Override + public String getContent(String id) { + validateId(id); + return redisClipboard.get(getKey(id)); + } + + @Override + public void setContent(String id, String content) { + validateId(id); + if (content == null) { + throw new TimiException(TimiCode.ARG_MISS).msgKey("剪切板内容不能为空"); + } + byte[] contentBytes = content.getBytes(StandardCharsets.UTF_8); + if (MAX_CONTENT_SIZE < contentBytes.length) { + throw new TimiException(TimiCode.ARG_BAD).msgKey("剪切板内容不能超过 10MB"); + } + redisClipboard.set(getKey(id), content, Time.D * 7); + notifySubscribers(id, content); + } + + @Override + public SseEmitter subscribe(String id) { + validateId(id); + SseEmitter emitter = new SseEmitter(0L); + emitter.onCompletion(() -> removeEmitter(id, emitter)); + emitter.onTimeout(() -> removeEmitter(id, emitter)); + emitter.onError(e -> removeEmitter(id, emitter)); + emitterMap.computeIfAbsent(id, key -> new CopyOnWriteArrayList<>()).add(emitter); + String content = getContent(id); + if (content != null) { + sendEvent(emitter, content); + } + return emitter; + } + + private void validateId(String id) { + if (TimiJava.isEmpty(id)) { + throw new TimiException(TimiCode.ARG_MISS).msgKey("id 不能为空"); + } + } + + private String getKey(String id) { + return "%s%s".formatted(KEY_PREFIX, id); + } + + private void notifySubscribers(String id, String content) { + List emitterList = emitterMap.get(id); + if (emitterList == null || emitterList.isEmpty()) { + return; + } + for (SseEmitter emitter : emitterList) { + boolean success; + try { + success = sendEvent(emitter, content); + } catch (Exception e) { + log.debug("发送剪切板事件异常", e); + success = false; + } + if (!success) { + completeEmitter(id, emitter); + } + } + } + + private boolean sendEvent(SseEmitter emitter, String content) { + try { + emitter.send(SseEmitter.event().name("clipboard").data(content)); + return true; + } catch (IOException e) { + log.debug("剪切板连接已断开", e); + return false; + } catch (IllegalStateException e) { + log.debug("剪切板 SSE 状态异常", e); + return false; + } + } + + private void completeEmitter(String id, SseEmitter emitter) { + removeEmitter(id, emitter); + try { + emitter.complete(); + } catch (Exception e) { + log.debug("关闭剪切板 SSE 连接失败", e); + } + } + + private void removeEmitter(String id, SseEmitter emitter) { + List emitterList = emitterMap.get(id); + if (emitterList == null) { + return; + } + emitterList.remove(emitter); + if (emitterList.isEmpty()) { + emitterMap.remove(id); + } + } +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/TempFileServiceImplement.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/TempFileServiceImplement.java index 8f74d1a..ffccf8f 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/implement/TempFileServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/TempFileServiceImplement.java @@ -16,7 +16,6 @@ import com.imyeyu.spring.bean.Page; import com.imyeyu.utils.Time; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -92,21 +91,4 @@ public class TempFileServiceImplement implements TempFileService { throw new TimiException(TimiCode.ERROR, "store temp file error", e); } } - - @Scheduled(fixedRate = 3600000) - public void cleanup() { -// List expiredIds = metadataMap.values() -// .stream() -// .filter(metadata -> metadata.getLastAccessAt() + metadata.getTtl() < Time.now()) -// .map(TempFileMetaData::getId) -// .toList(); -// for (int i = 0; i < expiredIds.size(); i++) { -// TempFileMetaData removed = metadataMap.remove(expiredIds.get(i)); -// if (TimiJava.isNotEmpty(removed)) { -// File file = removed.getPath().toFile(); -// IO.destroy(file); -// usageMap.computeIfPresent(TimiSpring.getRequestIP(), (ip, usage) -> usage - file.length()); -// } -// } - } } diff --git a/src/main/java/com/imyeyu/api/modules/common/vo/ClipboardRequest.java b/src/main/java/com/imyeyu/api/modules/common/vo/ClipboardRequest.java new file mode 100644 index 0000000..6176372 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/vo/ClipboardRequest.java @@ -0,0 +1,18 @@ +package com.imyeyu.api.modules.common.vo; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 共享剪切板请求 + * + * @author 夜雨 + * @since 2026-01-15 16:40 + */ +@Data +public class ClipboardRequest { + + /** 剪切板内容 */ + @NotNull + private String content; +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9b03eb6..a47f817 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -61,6 +61,7 @@ spring: qps-limit: 9 # APP.IP.method: COUNT_IN_LIFE_CYCLE 接口访问频率控制(多个系统公用,需要 App 标记) setting: 10 # Setting: SettingValue 系统配置 fmc-player-token: 11 # TOKEN: USER_ID|PLAYER_ID MC 登录缓存 + clipboard: 12 # ID: CONTENT 共享剪切板 lettuce: # 连接池配置 pool: max-wait: -1