diff --git a/.env.example b/.env.example
index 4014febb..07ea46f6 100644
--- a/.env.example
+++ b/.env.example
@@ -40,7 +40,7 @@ POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=interview_guide
POSTGRES_USER=postgres
-POSTGRES_PASSWORD=password
+POSTGRES_PASSWORD=123456
# Redis(compose 映射 6379)
REDIS_HOST=localhost
diff --git a/app/src/main/java/interview/guide/common/ai/AgentUtilsConfiguration.java b/app/src/main/java/interview/guide/common/ai/AgentUtilsConfiguration.java
index 439a5d6f..e4fcd205 100644
--- a/app/src/main/java/interview/guide/common/ai/AgentUtilsConfiguration.java
+++ b/app/src/main/java/interview/guide/common/ai/AgentUtilsConfiguration.java
@@ -26,12 +26,17 @@ public AgentUtilsConfiguration(
this.agentUtilsProperties = agentUtilsProperties;
}
+ /**
+ * 创建 SkillsTool,并注册为 Spring Bean。
+ */
@Bean("interviewSkillsToolCallback")
public ToolCallback interviewSkillsToolCallback() {
String configuredSkillsRoot = agentUtilsProperties.getSkillsRoot();
+ // 规范化路径:处理 null/空白、\\ 分隔符、/SKILL.md 后缀、通配符、末尾斜杠等边界情况
String normalizedSkillsRoot = normalizeSkillsRoot(configuredSkillsRoot);
Resource skillsRootResource = resourceLoader.getResource(normalizedSkillsRoot);
+ // 技能目录不存在时直接拦停启动,避免 Agent 调用时静默报错难以排查
if (!skillsRootResource.exists()) {
throw new IllegalStateException("未找到 skills 根目录,请检查配置: " + normalizedSkillsRoot);
}
@@ -43,7 +48,13 @@ public ToolCallback interviewSkillsToolCallback() {
.build();
}
+ /**
+ * 规范化 skills 路径:兼容用户配置可能携带的各种后缀和格式。
+ * 用户可能配置 classpath:skills、classpath:skills/*、classpath:skills/SKILL.md 等变体,
+ * 统一归约到目录形式后再交给 ResourceLoader。
+ */
private String normalizeSkillsRoot(String raw) {
+ // 未配置时使用默认值,确保 SkillsTool 能找到内置技能
if (raw == null || raw.isBlank()) {
return "classpath:skills";
}
@@ -51,15 +62,19 @@ private String normalizeSkillsRoot(String raw) {
String normalized = raw.trim();
normalized = normalized.replace('\\', '/');
+ // 用户可能直接指向某个 SKILL.md 文件而非目录
if (normalized.endsWith("/SKILL.md")) {
+ // 去掉 SKILL.md
normalized = normalized.substring(0, normalized.length() - "/SKILL.md".length());
}
+ // 去掉 classpath:skills/* 中的通配符,SkillsTool 会扫描子目录
int wildcardIndex = normalized.indexOf('*');
if (wildcardIndex >= 0) {
normalized = normalized.substring(0, wildcardIndex);
}
+ // 去掉末尾的 /
while (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
diff --git a/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java b/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java
index 358d5a9d..234530dc 100644
--- a/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java
+++ b/app/src/main/java/interview/guide/common/ai/ApiPathResolver.java
@@ -10,11 +10,21 @@
import java.time.Duration;
import java.util.regex.Pattern;
+/**
+ * OpenAI API 客户端构造器。
+ * 不同 LLM Provider 的 baseUrl 结构不同:
+ * - OpenAI 系:{@code https://api.openai.com/v1} → API 路径为 /v1/chat/completions
+ * - Ollama 等:{@code http://localhost:11434} → 路径就是 /chat/completions(无版本前缀)
+ * 此类自动识别 baseUrl 中是否已包含版本段,决定是否需要补全 API 路径。
+ */
public final class ApiPathResolver {
- private static final int DEFAULT_CONNECT_TIMEOUT = 10000;
- private static final int DEFAULT_READ_TIMEOUT = 300000;
+ // 连接超时 10s:网络握手不应等待过久
+ private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10);
+ // 读取超时 5min:LLM 流式响应可能持续较长时间
+ private static final Duration DEFAULT_READ_TIMEOUT = Duration.ofMinutes(5);
+ // 匹配 baseUrl 末尾的 /v1、/v1beta 等版本段
private static final Pattern TRAILING_VERSION = Pattern.compile("/v\\d+[a-zA-Z0-9]*$");
private ApiPathResolver() {}
@@ -24,10 +34,10 @@ public static OpenAIClient buildOpenAiClient(String baseUrl, String apiKey) {
}
public static OpenAIClient buildOpenAiClient(String baseUrl, String apiKey,
- int connectTimeout, int readTimeout) {
+ Duration connectTimeout, Duration readTimeout) {
Timeout timeout = Timeout.builder()
- .connect(Duration.ofMillis(connectTimeout))
- .read(Duration.ofMillis(readTimeout))
+ .connect(connectTimeout)
+ .read(readTimeout)
.build();
ClientOptions options = ClientOptions.Companion.builder()
.apiKey(apiKey)
@@ -47,6 +57,7 @@ public static String resolveVersionedBaseUrl(String baseUrl) {
return stripped + "/v1";
}
+ /** 判断 baseUrl 末尾是否包含 /v1 之类的版本段。 */
public static boolean baseUrlContainsVersion(String baseUrl) {
if (baseUrl == null || baseUrl.isBlank()) {
return false;
@@ -55,6 +66,7 @@ public static boolean baseUrlContainsVersion(String baseUrl) {
return TRAILING_VERSION.matcher(stripped).find();
}
+ /** 去除字符串末尾的斜杠,便于路径拼接和正则匹配。 */
public static String stripTrailingSlashes(String value) {
if (value == null) {
return "";
diff --git a/app/src/main/java/interview/guide/common/ai/LlmProviderRegistry.java b/app/src/main/java/interview/guide/common/ai/LlmProviderRegistry.java
index 9125967e..daebe768 100644
--- a/app/src/main/java/interview/guide/common/ai/LlmProviderRegistry.java
+++ b/app/src/main/java/interview/guide/common/ai/LlmProviderRegistry.java
@@ -39,14 +39,24 @@
import java.util.concurrent.ConcurrentHashMap;
/**
- * Registry for managing and caching LLM providers.
- * Supports dynamic creation of ChatClient based on provider configurations.
+ *
+ * * Registry for managing and caching LLM providers.
+ * * Supports dynamic creation of ChatClient based on provider configurations.
+ *
+ * LLM Provider 注册中心,负责 ChatClient / EmbeddingModel 的创建、缓存和路由。
+ *
+ * 职责:
+ * - 按 providerId 创建并缓存 ChatClient,避免重复创建底层 ChatModel
+ * - 区分 通用/纯文本(plain)/语音(voice) 三种客户端场景,各自装配不同的 Advisor
+ * - Provider 配置支持 DB 和配置文件两种来源,DB 优先
+ * - 运行时支持 reload 重新加载(配置热更新)
*/
@Component
@Slf4j
public class LlmProviderRegistry {
private final LlmProviderProperties properties;
+ // 三层缓存,按粒度拆分:ChatModel 和 EmbeddingModel 可被不同 Client 共享复用
private final Map clientCache = new ConcurrentHashMap<>();
private final Map chatModelCache = new ConcurrentHashMap<>();
private final Map embeddingModelCache = new ConcurrentHashMap<>();
@@ -57,6 +67,7 @@ public class LlmProviderRegistry {
private final ToolCallingManager toolCallingManager;
private final ObservationRegistry observationRegistry;
private final ToolCallback interviewSkillsToolCallback;
+ // 各厂商推荐的 Embedding 模型名映射,用于用户误配聊天模型时给出具体建议
private static final Map RECOMMENDED_EMBEDDING_MODELS = Map.of(
"dashscope", "text-embedding-v3",
"glm", "embedding-3",
@@ -71,6 +82,8 @@ public LlmProviderRegistry(
LlmProviderRepository providerRepository,
LlmGlobalSettingRepository globalSettingRepository,
ApiKeyEncryptionService encryptionService,
+ // 以下三个入参依赖第三方 jar(spring-ai、spring-ai-agent-utils),
+ // 用 required=false 避免它们不在类路径时启动失败
@Autowired(required = false) ToolCallingManager toolCallingManager,
@Autowired(required = false) ObservationRegistry observationRegistry,
@Autowired(required = false) @Qualifier("interviewSkillsToolCallback") ToolCallback interviewSkillsToolCallback) {
@@ -83,6 +96,7 @@ public LlmProviderRegistry(
this.interviewSkillsToolCallback = interviewSkillsToolCallback;
}
+ // 无 DB 依赖的构造器重载,供单测或无需 DB 持久化的环境使用
public LlmProviderRegistry(
LlmProviderProperties properties,
ToolCallingManager toolCallingManager,
@@ -131,6 +145,7 @@ public ChatClient getChatClientOrDefault(String providerId) {
*/
public ChatClient getPlainChatClient(String providerId) {
String id = resolveProviderId(providerId);
+ // ":plain" 后缀与通用 ChatClient 隔离,避免缓存污染
return clientCache.computeIfAbsent(id + ":plain", key -> createPlainChatClient(id));
}
@@ -140,11 +155,13 @@ public ChatClient getPlainChatClient(String providerId) {
*/
public ChatClient getVoiceChatClient(String providerId) {
String id = resolveProviderId(providerId);
+ // ":voice" 后缀与通用/plain ChatClient 隔离
return clientCache.computeIfAbsent(id + ":voice", key -> createVoiceChatClient(id));
}
/**
* 清空缓存,重新加载所有 provider。
+ * 配置页修改后调用,下次请求重建 ChatClient。
*/
public void reload() {
int size = clientCache.size() + chatModelCache.size() + embeddingModelCache.size();
@@ -154,6 +171,9 @@ public void reload() {
log.info("[LlmProviderRegistry] Cache cleared ({} entries). Next access will re-create clients.", size);
}
+ /**
+ * 获取 EmbeddingModel(用于知识库向量化)。
+ */
public EmbeddingModel getEmbeddingModel(String providerId) {
return embeddingModelCache.computeIfAbsent(providerId, id -> {
log.info("[LlmProviderRegistry] Creating new embedding model for provider: {}", id);
@@ -161,14 +181,24 @@ public EmbeddingModel getEmbeddingModel(String providerId) {
});
}
+ /**
+ * 获取默认的 EmbeddingModel。
+ * 优先使用 app.ai.default-embedding-provider,未配置时退化为 default-provider。
+ */
public EmbeddingModel getDefaultEmbeddingModel() {
return getEmbeddingModel(resolveDefaultEmbeddingProviderId());
}
+ // ---- 三种 ChatClient 创建 ----
+
+ /**
+ * 创建通用 ChatClient:SkillsTool + 配置装配的 Advisor 列表。
+ */
private ChatClient createChatClient(String providerId) {
OpenAiChatModel chatModel = getChatModel(providerId);
ChatClient.Builder builder = ChatClient.builder(chatModel);
+ // SkillsTool 可选依赖,不存在时跳过(如测试环境)
if (interviewSkillsToolCallback != null) {
builder.defaultTools(interviewSkillsToolCallback);
}
@@ -181,6 +211,10 @@ private ChatClient createChatClient(String providerId) {
return builder.build();
}
+ /**
+ * 纯文本 ChatClient:仅保留敏感词护卫,不带工具调用和记忆。
+ * 用于出题、评分等结构化输出场景——工具调用消息会干扰模型生成纯净 JSON。
+ */
private ChatClient createPlainChatClient(String providerId) {
OpenAiChatModel chatModel = getChatModel(providerId);
ChatClient.Builder builder = ChatClient.builder(chatModel);
@@ -189,6 +223,10 @@ private ChatClient createPlainChatClient(String providerId) {
return builder.build();
}
+ /**
+ * 语音面试 ChatClient:SkillsTool + 流式 ToolCallAdvisor。
+ * 不加 Memory Advisor——语音面试按对话轮次管理历史,不走 Spring AI 的窗口记忆。
+ */
private ChatClient createVoiceChatClient(String providerId) {
OpenAiChatModel chatModel = getChatModel(providerId);
@@ -208,6 +246,11 @@ private ChatClient createVoiceChatClient(String providerId) {
return builder.build();
}
+ // ---- Model 创建与缓存 ----
+
+ /**
+ * 按 provider 缓存 ChatModel,避免相同 Provider 的 ChatClient 反复重建底层模型。
+ */
private OpenAiChatModel getChatModel(String providerId) {
return chatModelCache.computeIfAbsent(providerId, id -> {
log.info("[LlmProviderRegistry] Creating new ChatModel for provider: {}", id);
@@ -215,6 +258,10 @@ private OpenAiChatModel getChatModel(String providerId) {
});
}
+ /**
+ * 构造 OpenAiChatModel。
+ * temperature 默认 0.2,偏向确定性和可复现的面试评估——过高 temperature 的输出难以评分。
+ */
private OpenAiChatModel buildChatModel(String providerId) {
ProviderSnapshot config = loadProviderOrThrow(providerId);
log.info("[LlmProviderRegistry] Building ChatModel - Provider: {}, BaseUrl: {}, Model: {}",
@@ -235,6 +282,12 @@ private OpenAiChatModel buildChatModel(String providerId) {
.build();
}
+ /**
+ * 创建 EmbeddingModel(知识库向量化)。
+ * 创建前校验:
+ * - 未配置 Embedding 模型时抛异常
+ * - 用户可能误填聊天模型名,通过 looksLikeChatModel 预先拦截并给出推荐修正
+ */
private EmbeddingModel createEmbeddingModel(String providerId) {
ProviderSnapshot config = loadProviderOrThrow(providerId);
if (!config.supportsEmbedding() || isBlank(config.embeddingModel())) {
@@ -267,6 +320,12 @@ private EmbeddingModel createEmbeddingModel(String providerId) {
.build();
}
+ // ---- Advisor 装配 ----
+
+ /**
+ * 按配置逐项装配 Advisor。
+ * 工具调用 → 对话记忆 → 请求日志 → 敏感词护卫,全由 application.yml 控制开关。
+ */
private List buildDefaultAdvisors(String providerId) {
AdvisorConfig config = properties.getAdvisors();
if (config == null || !config.isEnabled()) {
@@ -284,6 +343,7 @@ private List buildDefaultAdvisors(String providerId) {
}
if (config.isMessageChatMemoryEnabled()) {
+ // 窗口记忆最少保留 20 条,避免记忆太短对话失去上下文
int maxMessages = Math.max(20, config.getMessageChatMemoryMaxMessages());
MessageChatMemoryAdvisor memoryAdvisor = MessageChatMemoryAdvisor.builder(
MessageWindowChatMemory.builder()
@@ -309,6 +369,10 @@ private ToolCallingAdvisor buildToolCallAdvisor(boolean conversationHistoryEnabl
.build();
}
+ /**
+ * 敏感词护卫:拦截求职者试图让 AI 做面试无关的事情(写代码、聊天等)。
+ * 提示词设计为"只能协助面试相关任务",保持边界安全。
+ */
private Optional buildSafeGuardAdvisor() {
AdvisorConfig config = properties.getAdvisors();
if (config == null || !config.isSafeguardEnabled()) {
@@ -322,11 +386,17 @@ private Optional buildSafeGuardAdvisor() {
return Optional.of(advisor);
}
+ // ---- Provider ID 解析(DB 优先,properties 兜底) ----
+
private String resolveProviderId(String providerId) {
return (providerId != null && !providerId.isBlank())
? providerId : resolveDefaultChatProviderId();
}
+ /**
+ * 从 DB 查询默认聊天 Provider,查询不到时用配置文件的默认值。
+ * providerRepository 为 null 说明是无 DB 环境(测试/配置驱动模式),直接返回配置默认值。
+ */
private String resolveDefaultChatProviderId() {
if (globalSettingRepository == null) {
return properties.getDefaultProvider();
@@ -337,6 +407,10 @@ private String resolveDefaultChatProviderId() {
.orElse(properties.getDefaultProvider());
}
+ /**
+ * 解析默认 Embedding Provider:优先查 DB,未配置时退化为 default-provider。
+ * 比聊天 Provider 多一层 fallback:先查 default-embedding-provider,再退化为 default-provider。
+ */
private String resolveDefaultEmbeddingProviderId() {
if (globalSettingRepository == null) {
return !isBlank(properties.getDefaultEmbeddingProvider())
@@ -351,6 +425,11 @@ private String resolveDefaultEmbeddingProviderId() {
: properties.getDefaultProvider());
}
+
+ /**
+ * 加载 Provider 配置:DB 有数据则走 DB(支持加密存储的 API Key),否则退化为配置文件。
+ * providerRepository 为 null 时直接走配置文件路径(测试/配置驱动模式)。
+ */
private ProviderSnapshot loadProviderOrThrow(String providerId) {
if (providerRepository == null) {
return loadProviderFromPropertiesOrThrow(providerId);
@@ -361,6 +440,7 @@ private ProviderSnapshot loadProviderOrThrow(String providerId) {
return new ProviderSnapshot(
entity.getId(),
entity.getBaseUrl(),
+ // API Key 在 DB 中以 nonce + ciphertext 分存,需解密后使用
encryptionService.decrypt(entity.getApiKeyNonce(), entity.getApiKeyCiphertext()),
entity.getModel(),
entity.getEmbeddingModel(),
@@ -370,6 +450,10 @@ private ProviderSnapshot loadProviderOrThrow(String providerId) {
);
}
+ /**
+ * 从配置文件加载 Provider 配置。
+ * supportsEmbedding 自动推导:显式开启或配置了 embeddingModel 即视为支持。
+ */
private ProviderSnapshot loadProviderFromPropertiesOrThrow(String providerId) {
ProviderConfig config = properties.getProviders().get(providerId);
if (config == null) {
@@ -390,10 +474,15 @@ private ProviderSnapshot loadProviderFromPropertiesOrThrow(String providerId) {
);
}
+ // ---- 工具方法 ----
+
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
+ /**
+ * 解析 Embedding 维度:配置值 > 0 则用配置值,否则用全局默认 dimensions。
+ */
private Integer resolveEmbeddingDimensions(Integer configuredDimensions) {
if (configuredDimensions != null && configuredDimensions > 0) {
return configuredDimensions;
@@ -401,6 +490,10 @@ private Integer resolveEmbeddingDimensions(Integer configuredDimensions) {
return properties.getEmbeddingDimensions();
}
+ /**
+ * 启发式判断用户是否误将聊天模型名填入了 Embedding 模型配置。
+ * 常见聊天模型名以 glm- / deepseek / qwen 等开头,Embedding 模型名不会有这些前缀。
+ */
private boolean looksLikeChatModel(String model) {
String lower = model.toLowerCase();
return lower.startsWith("glm-")
@@ -411,6 +504,8 @@ private boolean looksLikeChatModel(String model) {
|| lower.startsWith("ernie");
}
+ // ---- 内部数据载体 ----
+
private record ProviderSnapshot(
String id,
String baseUrl,
diff --git a/app/src/main/java/interview/guide/common/ai/StructuredOutputInvoker.java b/app/src/main/java/interview/guide/common/ai/StructuredOutputInvoker.java
index 4426e736..070fb9a8 100644
--- a/app/src/main/java/interview/guide/common/ai/StructuredOutputInvoker.java
+++ b/app/src/main/java/interview/guide/common/ai/StructuredOutputInvoker.java
@@ -14,11 +14,20 @@
import java.util.regex.Pattern;
/**
- * 统一封装结构化输出调用与重试策略。
+ * 结构化输出调用与重试的统一封装。
+ *
+ * LLM 生成 JSON 格式输出时,常见问题:
+ * - 输出 Markdown 代码块包裹的 JSON(```json ... ```)
+ * - JSON 字符串值内引号未转义
+ * - 附带解释文字
+ * 此类通过重试 + 本地修复 + 严格指令组合处理上述问题。
*/
@Component
public class StructuredOutputInvoker {
+ // === 常量区 ===
+
+ // 重试时追加的严格 JSON 指令——提醒模型不要输出 Markdown 包裹、额外文字或未转义引号
private static final String STRICT_JSON_INSTRUCTION = """
请仅返回可被 JSON 解析器直接解析的 JSON 对象,并严格满足字段结构要求:
1) 不要输出 Markdown 代码块(如 ```json)。
@@ -26,21 +35,26 @@ public class StructuredOutputInvoker {
3) 所有字符串内引号必须正确转义。
""";
+ // invocations = 一次 invoke() 整体成功/失败,attempts = 每次 LLM 请求(含重试),latency = 总耗时
private static final String METRIC_INVOCATIONS = "app.ai.structured_output.invocations";
private static final String METRIC_ATTEMPTS = "app.ai.structured_output.attempts";
private static final String METRIC_LATENCY = "app.ai.structured_output.latency";
private static final String STATUS_SUCCESS = "success";
private static final String STATUS_FAILURE = "failure";
+ // 以下用于 context tag 归一化,适配 Prometheus/Micrometer tag 命名规范
private static final int MAX_CONTEXT_TAG_LENGTH = 48;
private static final Pattern NON_ALNUM_PATTERN = Pattern.compile("[^a-z0-9_]+");
private static final Pattern MULTI_UNDERSCORE = Pattern.compile("_+");
+ // === 配置字段(从 StructuredOutputProperties 注入) ===
+
private final int maxAttempts;
private final boolean includeLastErrorInRetryPrompt;
private final boolean retryUseRepairPrompt;
private final boolean retryAppendStrictJsonInstruction;
private final int errorMessageMaxLength;
private final boolean metricsEnabled;
+ // MeterRegistry 可选——无 Micrometer 依赖时不报错
private final boolean schemaValidationEnabled;
private final MeterRegistry meterRegistry;
@@ -48,16 +62,26 @@ public StructuredOutputInvoker(
StructuredOutputProperties properties,
@Autowired(required = false) MeterRegistry meterRegistry
) {
+ // 最少重试 1 次(至少一次调用),避免配置意外设为 0 或负数
this.maxAttempts = Math.max(1, properties.getStructuredMaxAttempts());
this.includeLastErrorInRetryPrompt = properties.isStructuredIncludeLastError();
this.retryUseRepairPrompt = properties.isStructuredRetryUseRepairPrompt();
this.retryAppendStrictJsonInstruction = properties.isStructuredRetryAppendStrictJsonInstruction();
+ // 错误消息截断最少保留 20 字符,避免太短看不出问题
this.errorMessageMaxLength = Math.max(20, properties.getStructuredErrorMessageMaxLength());
this.metricsEnabled = properties.isStructuredMetricsEnabled();
this.schemaValidationEnabled = properties.isStructuredSchemaValidationEnabled();
this.meterRegistry = meterRegistry;
}
+ /**
+ * 调用 LLM 并解析结构化输出,失败时按配置重试。
+ *
+ * 重试策略三层递进:
+ * 1. 本地 repair 修复常见 JSON 格式问题(未转义引号)
+ * 2. 重试时追加严格 JSON 指令 + 上次失败原因
+ * 3. 达到 maxAttempts 后抛出 BusinessException
+ */
public T invoke(
ChatClient chatClient,
String systemPromptWithFormat,
@@ -70,10 +94,12 @@ public T invoke(
) {
long startNanos = System.nanoTime();
String contextTag = normalizeContextTag(logContext);
+ // 追加防注入指令:防止用户输入通过 system prompt 劫持 LLM 行为
String securedSystemPrompt = systemPromptWithFormat
+ PromptSecurityConstants.ANTI_INJECTION_INSTRUCTION;
Exception lastError = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
+ // 首次用原始 prompt,重试用增强 prompt(含上次错误信息和严格 JSON 指令)
String attemptSystemPrompt = attempt == 1
? securedSystemPrompt
: buildRetrySystemPrompt(securedSystemPrompt, lastError);
@@ -96,6 +122,7 @@ public T invoke(
}
}
+ // 所有重试耗尽,记录失败指标并抛出业务异常
recordInvocation(contextTag, STATUS_FAILURE, startNanos);
throw new BusinessException(
errorCode,
@@ -145,13 +172,18 @@ private T convertWithRepair(
}
}
+ /**
+ * 修复 JSON 字符串值内未转义的引号(双引号前面缺少反斜杠)。
+ * 这是 LLM 输出 JSON 最频繁的错误模式:如 {"name":他说"你好"} -> {"name":他说\"你好\"}
+ * 使用状态机逐字符扫描,仅在确信是字符串终结符时才保留原始 "。
+ */
private String repairUnescapedQuotesInJsonStrings(String content) {
if (content == null || content.isBlank()) {
return content;
}
StringBuilder repaired = new StringBuilder(content.length() + 16);
- boolean inString = false;
- boolean escaping = false;
+ boolean inString = false; // 当前是否在 JSON 字符串值内部
+ boolean escaping = false; // 前一个字符是否为反斜杠(转义模式)
for (int i = 0; i < content.length(); i++) {
char ch = content.charAt(i);
if (!inString) {
@@ -173,6 +205,7 @@ private String repairUnescapedQuotesInJsonStrings(String content) {
continue;
}
if (ch == '"') {
+ // 根据后续字符判断这是字符串终结符还是字符串内部的双引号
if (isLikelyJsonStringTerminator(content, i + 1)) {
inString = false;
repaired.append(ch);
@@ -186,6 +219,11 @@ private String repairUnescapedQuotesInJsonStrings(String content) {
return repaired.toString();
}
+ /**
+ * 启发式判断当前位置的双引号是否可能是字符串的终结符。
+ * 跳过空白后,如果后面的字符是 ,}]: 之一,则很可能是终结符;
+ * 如果后面紧跟其他字符(如中文),则很可能是字符串内部的引号。
+ */
private boolean isLikelyJsonStringTerminator(String content, int start) {
for (int i = start; i < content.length(); i++) {
char next = content.charAt(i);
@@ -197,6 +235,10 @@ private boolean isLikelyJsonStringTerminator(String content, int start) {
return true;
}
+ /**
+ * 构建重试 prompt:保留原 system prompt,追加严格 JSON 指令和(可选的)上次失败原因。
+ * 由配置控制是否启用:retryUseRepairPrompt、retryAppendStrictJsonInstruction、includeLastErrorInRetryPrompt。
+ */
private String buildRetrySystemPrompt(String systemPromptWithFormat, Exception lastError) {
if (!retryUseRepairPrompt) {
return systemPromptWithFormat;
@@ -217,6 +259,9 @@ private String buildRetrySystemPrompt(String systemPromptWithFormat, Exception l
return prompt.toString();
}
+ /**
+ * 清洗错误消息:去换行、截断,避免重试 prompt 中的错误消息太长稀释有效指令。
+ */
private String sanitizeErrorMessage(String message) {
String oneLine = message.replace('\n', ' ').replace('\r', ' ').trim();
if (oneLine.length() > errorMessageMaxLength) {
@@ -225,6 +270,8 @@ private String sanitizeErrorMessage(String message) {
return oneLine;
}
+ // === 指标记录 ===
+
private void recordAttempt(String contextTag, String status) {
if (!isMetricsAvailable()) {
return;
@@ -235,12 +282,17 @@ private void recordAttempt(String contextTag, String status) {
).increment();
}
+ /**
+ * 记录一次调用。
+ */
private void recordInvocation(String contextTag, String status, long startNanos) {
if (!isMetricsAvailable()) {
return;
}
Tags tags = Tags.of("context", contextTag, "status", status);
+ // 调用次数
meterRegistry.counter(METRIC_INVOCATIONS, tags).increment();
+ // 调用耗时
meterRegistry.timer(METRIC_LATENCY, tags)
.record(System.nanoTime() - startNanos, java.util.concurrent.TimeUnit.NANOSECONDS);
}
@@ -249,6 +301,10 @@ private boolean isMetricsAvailable() {
return metricsEnabled && meterRegistry != null;
}
+ /**
+ * 将上下文标签归一化为 Prometheus tag 可接受的格式:
+ * 小写 + 下划线 + 最多 48 字符。
+ */
private String normalizeContextTag(String raw) {
String source = (raw == null || raw.isBlank()) ? "unknown" : raw;
String normalized = source.toLowerCase(Locale.ROOT).trim().replace(' ', '_');
diff --git a/app/src/main/java/interview/guide/common/aspect/RateLimitAspect.java b/app/src/main/java/interview/guide/common/aspect/RateLimitAspect.java
index 3cf2abbd..cdaf8ac4 100644
--- a/app/src/main/java/interview/guide/common/aspect/RateLimitAspect.java
+++ b/app/src/main/java/interview/guide/common/aspect/RateLimitAspect.java
@@ -9,6 +9,7 @@
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
+import org.jspecify.annotations.Nullable;
import org.redisson.api.RScript;
import org.redisson.api.RedissonClient;
import org.redisson.client.codec.StringCodec;
@@ -100,31 +101,31 @@ private Long executeRateLimitScript(String key, long nowMs, String requestId, lo
};
try {
- Object resultObj = rScript.evalSha(
- RScript.Mode.READ_WRITE,
- luaScriptSha,
- RScript.ReturnType.VALUE,
- keysList,
- args
- );
- return convertToLong(resultObj);
+ return executeRateLimitScript(keysList, args);
} catch (org.redisson.client.RedisException e) {
// Redis 重启后脚本缓存丢失,重新加载并重试
if (e.getMessage() != null && e.getMessage().contains("NOSCRIPT")) {
loadScript();
- Object resultObj = rScript.evalSha(
- RScript.Mode.READ_WRITE,
- luaScriptSha,
- RScript.ReturnType.VALUE,
- keysList,
- args
- );
- return convertToLong(resultObj);
+ return executeRateLimitScript(keysList, args);
}
throw e;
}
}
+ /**
+ * 执行限流 Lua 脚本
+ */
+ private @Nullable Long executeRateLimitScript(List