fix(voice-gateway): S2S idle timeout + upstream send lock + iOS AudioContext suspended + port 3012→3013

- P0: S2S DialogAudioIdleTimeoutError now notifies client instead of force-closing, sets upstreamReady=false and cancels keepalive
- P0: Reduce audioKeepaliveIntervalMs from 20s to 8s to prevent S2S idle timeout
- P1: Add upstreamSendLock to prevent concurrent IllegalStateException: Send pending
- P1: iOS AudioContext suspended handling - buffer audio chunks and try resume after user interaction
- P1: disconnect() clears pendingAudioChunks and _resuming to prevent memory leak
- Fix: Frontend hardcoded port 3012→3013 in videoApi.js and vite.config.js
- Add complete Java backend source code to git tracking
This commit is contained in:
User
2026-04-16 19:16:11 +08:00
parent fe25229de7
commit ff6a63147b
93 changed files with 10557 additions and 23 deletions

View File

@@ -0,0 +1,122 @@
import axios from 'axios';
function resolveApiBaseURL(configured, path) {
if (configured) {
return configured;
}
if (typeof window === 'undefined') {
return path;
}
const hostname = window.location.hostname;
const port = window.location.port;
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
if (window.location.protocol === 'file:') {
return `http://127.0.0.1:3013${path}`;
}
if (!port || port === '80' || port === '443' || port === '3013') {
return path;
}
return `${protocol}//${hostname || '127.0.0.1'}:3013${path}`;
}
const chatApiBaseURL = resolveApiBaseURL(import.meta.env.VITE_CHAT_API_BASE_URL, '/api/chat');
const api = axios.create({
baseURL: chatApiBaseURL,
timeout: 30000,
});
export async function startChatSession(sessionId, voiceSubtitles = [], systemPrompt = '') {
const { data } = await api.post('/start', { sessionId, voiceSubtitles, systemPrompt });
return data;
}
export async function sendMessage(sessionId, message) {
const { data } = await api.post('/send', { sessionId, message });
return data;
}
/**
* SSE 流式发送消息,逐块回调
* @param {string} sessionId
* @param {string} message
* @param {object} callbacks - { onChunk, onToolCall, onDone, onError }
* @returns {function} abort - 调用可取消请求
*/
export function sendMessageStream(sessionId, message, { onChunk, onToolCall, onStreamReset, onDone, onError }) {
const controller = new AbortController();
(async () => {
try {
const response = await fetch(`${chatApiBaseURL}/send-stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, message }),
signal: controller.signal,
});
if (!response.ok) {
const err = await response.json().catch(() => ({ error: response.statusText }));
onError?.(err.error || 'Request failed');
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data: ')) continue;
try {
const data = JSON.parse(trimmed.slice(6));
switch (data.type) {
case 'chunk':
onChunk?.(data.content);
break;
case 'tool_call':
onToolCall?.(data.tools);
break;
case 'stream_reset':
onStreamReset?.(data.reason);
break;
case 'done':
onDone?.(data.content);
break;
case 'error':
onError?.(data.error);
break;
}
} catch (e) {
// skip malformed SSE
}
}
}
} catch (err) {
if (err.name !== 'AbortError') {
onError?.(err.message || 'Stream failed');
}
}
})();
return () => controller.abort();
}
export async function getChatHistory(sessionId) {
const { data } = await api.get(`/history/${sessionId}`);
return data.data;
}
export async function deleteChatSession(sessionId) {
const { data } = await api.delete(`/${sessionId}`);
return data;
}

View File

@@ -0,0 +1,426 @@
class NativeVoiceService {
constructor() {
this.ws = null;
this.mediaStream = null;
this.captureContext = null;
this.captureSource = null;
this.captureProcessor = null;
this.captureSilenceGain = null;
this.playbackContext = null;
this.playbackTime = 0;
this.activeSources = new Set();
this.pendingSamples = [];
this.pendingAudioChunks = [];
this._resuming = false;
this.readyResolver = null;
this.readyRejector = null;
this.callbacks = {
onSubtitle: null,
onConnectionStateChange: null,
onError: null,
onAssistantPending: null,
onDiagnostic: null,
onIdleTimeout: null,
onProductLink: null,
};
}
resolveWebSocketUrl(sessionId, userId) {
const query = new URLSearchParams({
sessionId,
userId: userId || '',
});
const configuredBase = import.meta.env.VITE_VOICE_WS_BASE_URL || import.meta.env.VITE_VOICE_API_BASE_URL || '';
if (configuredBase && !configuredBase.startsWith('/')) {
let base = configuredBase.replace(/\/$/, '');
if (base.startsWith('https://')) {
base = `wss://${base.slice('https://'.length)}`;
} else if (base.startsWith('http://')) {
base = `ws://${base.slice('http://'.length)}`;
}
if (base.endsWith('/api/voice')) {
base = base.slice(0, -'/api/voice'.length);
} else if (base.endsWith('/api')) {
base = base.slice(0, -'/api'.length);
}
return `${base}/ws/realtime-dialog?${query.toString()}`;
}
const hostname = window.location.hostname;
const port = window.location.port;
const isLocalHost = hostname === 'localhost' || hostname === '127.0.0.1';
if ((window.location.protocol === 'file:' || isLocalHost) && port !== '3013') {
return `ws://${hostname || '127.0.0.1'}:3013/ws/realtime-dialog?${query.toString()}`;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/ws/realtime-dialog?${query.toString()}`;
}
emitConnectionState(state) {
this.callbacks.onConnectionStateChange?.(state);
}
emitDiagnostic(type, payload) {
this.callbacks.onDiagnostic?.({ type, payload, timestamp: Date.now() });
}
resetPlaybackQueue() {
this.activeSources.forEach((source) => {
try {
source.stop();
} catch (_) {}
try {
source.disconnect();
} catch (_) {}
});
this.activeSources.clear();
if (this.playbackContext) {
this.playbackTime = this.playbackContext.currentTime + 0.02;
} else {
this.playbackTime = 0;
}
}
async connect({ sessionId, userId, botName, systemRole, speakingStyle, modelVersion, speaker, greetingText }) {
await this.disconnect();
const wsUrl = this.resolveWebSocketUrl(sessionId, userId);
this.emitConnectionState('connecting');
this.playbackContext = new (window.AudioContext || window.webkitAudioContext)();
if (this.playbackContext.state === 'suspended') {
await this.playbackContext.resume().catch(() => {});
}
this.playbackTime = this.playbackContext.currentTime;
// 并行: 同时预获取麦克风和建立WS连接节省500ms+
const micPromise = navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
noiseSuppression: true,
echoCancellation: true,
autoGainControl: true,
},
video: false,
}).catch((err) => {
console.warn('[NativeVoice] Pre-fetch getUserMedia failed:', err.message);
return null;
});
const CONNECTION_TIMEOUT_MS = 12000;
await new Promise((resolve, reject) => {
this.readyResolver = resolve;
this.readyRejector = reject;
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
this.ws = ws;
// 超时兜底:避免无限等待
const timeoutId = setTimeout(() => {
if (this.readyResolver) {
console.warn(`[NativeVoice] Connection timeout (${CONNECTION_TIMEOUT_MS}ms), forcing ready`);
this.readyResolver();
this.readyResolver = null;
this.readyRejector = null;
}
}, CONNECTION_TIMEOUT_MS);
const clearTimeoutOnSettle = () => clearTimeout(timeoutId);
ws.onopen = () => {
this.emitConnectionState('connected');
ws.send(JSON.stringify({
type: 'start',
sessionId,
userId,
botName,
systemRole,
speakingStyle,
modelVersion,
speaker,
greetingText,
}));
};
ws.onerror = () => {
clearTimeoutOnSettle();
const error = new Error('WebSocket connection failed');
this.callbacks.onError?.(error);
this.readyRejector?.(error);
this.readyResolver = null;
this.readyRejector = null;
reject(error);
};
ws.onclose = () => {
clearTimeoutOnSettle();
this.emitConnectionState('disconnected');
if (this.readyRejector) {
this.readyRejector(new Error('WebSocket closed before ready'));
this.readyResolver = null;
this.readyRejector = null;
}
};
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
const peek = event.data;
if (peek.includes('"ready"')) {
clearTimeoutOnSettle();
}
this.handleJsonMessage(peek);
return;
}
this.handleAudioMessage(event.data);
};
});
// 使用预获取的mediaStream已并行获取避免重复申请
const preFetchedStream = await micPromise;
await this.startCapture(preFetchedStream);
}
handleJsonMessage(raw) {
try {
const msg = JSON.parse(raw);
if (msg.type === 'ready') {
this.readyResolver?.();
this.readyResolver = null;
this.readyRejector = null;
return;
}
if (msg.type === 'subtitle') {
this.callbacks.onSubtitle?.({
text: msg.text,
role: msg.role,
isFinal: !!msg.isFinal,
sequence: msg.sequence,
});
return;
}
if (msg.type === 'tts_reset') {
this.resetPlaybackQueue();
this.emitDiagnostic('tts_reset', msg);
return;
}
if (msg.type === 'assistant_pending') {
this.callbacks.onAssistantPending?.(!!msg.active);
return;
}
if (msg.type === 'idle_timeout') {
this.callbacks.onIdleTimeout?.(msg.timeout || 300000);
return;
}
if (msg.type === 'product_link') {
this.callbacks.onProductLink?.({
product: msg.product,
link: msg.link,
description: msg.description,
});
return;
}
if (msg.type === 'upstream_closed') {
this.callbacks.onError?.(new Error('语音服务已断开,请重新开始通话'));
return;
}
if (msg.type === 'error') {
this.callbacks.onError?.(new Error(msg.error || 'native voice error'));
return;
}
this.emitDiagnostic('ws_message', msg);
} catch (error) {
this.emitDiagnostic('ws_raw_text', raw);
}
}
handleAudioMessage(arrayBuffer) {
if (!this.playbackContext) {
return;
}
if (this.playbackContext.state === 'suspended') {
this.pendingAudioChunks.push(arrayBuffer);
this._tryResumePlayback();
return;
}
this._playPcm(arrayBuffer);
}
_playPcm(arrayBuffer) {
try {
const pcm16 = new Int16Array(arrayBuffer);
if (!pcm16.length) {
return;
}
const audioBuffer = this.playbackContext.createBuffer(1, pcm16.length, 24000);
const channel = audioBuffer.getChannelData(0);
for (let i = 0; i < pcm16.length; i += 1) {
channel[i] = pcm16[i] / 32768;
}
const source = this.playbackContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(this.playbackContext.destination);
this.activeSources.add(source);
source.onended = () => {
this.activeSources.delete(source);
try {
source.disconnect();
} catch (_) {}
};
const now = this.playbackContext.currentTime;
if (this.playbackTime < now) {
this.playbackTime = now + 0.02;
}
source.start(this.playbackTime);
this.playbackTime += audioBuffer.duration;
this.emitDiagnostic('audio_chunk', { samples: pcm16.length, duration: audioBuffer.duration });
} catch (err) {
console.warn('[NativeVoice] playPcm failed:', err.message);
}
}
async _tryResumePlayback() {
if (this._resuming) return;
this._resuming = true;
try {
await this.playbackContext.resume();
while (this.pendingAudioChunks.length > 0) {
this._playPcm(this.pendingAudioChunks.shift());
}
} catch (e) {
console.warn('[NativeVoice] resume failed:', e.message);
} finally {
this._resuming = false;
}
}
async startCapture(preFetchedStream) {
this.mediaStream = preFetchedStream || await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
noiseSuppression: true,
echoCancellation: true,
autoGainControl: true,
},
video: false,
});
this.captureContext = new (window.AudioContext || window.webkitAudioContext)();
this.captureSource = this.captureContext.createMediaStreamSource(this.mediaStream);
this.captureProcessor = this.captureContext.createScriptProcessor(4096, 1, 1);
this.captureSilenceGain = this.captureContext.createGain();
this.captureSilenceGain.gain.value = 0;
this.captureProcessor.onaudioprocess = (event) => {
const input = event.inputBuffer.getChannelData(0);
const downsampled = this.downsampleBuffer(input, this.captureContext.sampleRate, 16000);
for (let i = 0; i < downsampled.length; i += 1) {
this.pendingSamples.push(downsampled[i]);
}
while (this.pendingSamples.length >= 320) {
const chunk = this.pendingSamples.splice(0, 320);
const pcm = new Int16Array(chunk.length);
for (let i = 0; i < chunk.length; i += 1) {
const sample = Math.max(-1, Math.min(1, chunk[i]));
pcm[i] = sample < 0 ? sample * 32768 : sample * 32767;
}
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(pcm.buffer);
}
}
};
this.captureSource.connect(this.captureProcessor);
this.captureProcessor.connect(this.captureSilenceGain);
this.captureSilenceGain.connect(this.captureContext.destination);
}
downsampleBuffer(buffer, inputRate, outputRate) {
if (outputRate >= inputRate) {
return Array.from(buffer);
}
const sampleRateRatio = inputRate / outputRate;
const newLength = Math.round(buffer.length / sampleRateRatio);
const result = new Array(newLength);
let offsetResult = 0;
let offsetBuffer = 0;
while (offsetResult < result.length) {
const nextOffsetBuffer = Math.round((offsetResult + 1) * sampleRateRatio);
let accum = 0;
let count = 0;
for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i += 1) {
accum += buffer[i];
count += 1;
}
result[offsetResult] = count > 0 ? accum / count : 0;
offsetResult += 1;
offsetBuffer = nextOffsetBuffer;
}
return result;
}
async setMuted(muted) {
this.mediaStream?.getAudioTracks().forEach((track) => {
track.enabled = !muted;
});
}
requestGreetingReplay() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'replay_greeting' }));
this.emitDiagnostic('replay_greeting', { sent: true });
}
}
async disconnect() {
if (this.captureProcessor) {
this.captureProcessor.disconnect();
this.captureProcessor.onaudioprocess = null;
this.captureProcessor = null;
}
if (this.captureSource) {
this.captureSource.disconnect();
this.captureSource = null;
}
if (this.captureSilenceGain) {
this.captureSilenceGain.disconnect();
this.captureSilenceGain = null;
}
if (this.captureContext) {
await this.captureContext.close().catch(() => {});
this.captureContext = null;
}
if (this.mediaStream) {
this.mediaStream.getTracks().forEach((track) => track.stop());
this.mediaStream = null;
}
if (this.ws) {
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'stop' }));
this.ws.close();
}
} catch (_) {}
this.ws = null;
}
if (this.playbackContext) {
this.resetPlaybackQueue();
await this.playbackContext.close().catch(() => {});
this.playbackContext = null;
}
this.playbackTime = 0;
this.pendingSamples = [];
this.pendingAudioChunks = [];
this._resuming = false;
this.emitConnectionState('disconnected');
}
on(event, callback) {
if (event in this.callbacks) {
this.callbacks[event] = callback;
}
}
off(event) {
if (event in this.callbacks) {
this.callbacks[event] = null;
}
}
}
const nativeVoiceService = new NativeVoiceService();
export default nativeVoiceService;

View File

@@ -0,0 +1,80 @@
import axios from 'axios';
function resolveApiBaseURL(configured, path) {
if (configured) {
return configured;
}
if (typeof window === 'undefined') {
return path;
}
const hostname = window.location.hostname;
const port = window.location.port;
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
if (window.location.protocol === 'file:') {
return `http://127.0.0.1:3013${path}`;
}
if (!port || port === '80' || port === '443' || port === '3013') {
return path;
}
return `${protocol}//${hostname || '127.0.0.1'}:3013${path}`;
}
const videoApiBaseURL = resolveApiBaseURL(import.meta.env.VITE_VIDEO_API_BASE_URL, '/api/video');
const api = axios.create({
baseURL: videoApiBaseURL,
timeout: 60000,
});
/**
* 提交视频生成任务multipart/form-data
*/
export async function generateVideo({ prompt, product, username, template, size, seconds, image }) {
const formData = new FormData();
if (prompt) formData.append('prompt', prompt);
if (product) formData.append('product', product);
if (username) formData.append('username', username);
if (template) formData.append('template', template);
if (size) formData.append('size', size);
if (seconds) formData.append('seconds', String(seconds));
if (image) formData.append('image', image);
const { data } = await api.post('/generate', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data;
}
/**
* 查询任务状态
*/
export async function getTaskStatus(taskId) {
const { data } = await api.get(`/task/${taskId}`);
return data;
}
/**
* 获取视频历史
*/
export async function getVideoHistory({ username, limit = 20, offset = 0 } = {}) {
const params = { limit, offset };
if (username) params.username = username;
const { data } = await api.get('/history', { params });
return data;
}
/**
* 获取管理配置
*/
export async function getAdminConfig() {
const { data } = await api.get('/admin/config');
return data;
}
/**
* 更新模型配置
*/
export async function updateAdminConfig(model) {
const { data } = await api.post('/admin/config', { model });
return data;
}

View File

@@ -0,0 +1,61 @@
import axios from 'axios';
function resolveApiBaseURL(configured, path) {
if (configured) {
return configured;
}
if (typeof window === 'undefined') {
return path;
}
const hostname = window.location.hostname;
const port = window.location.port;
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
if (window.location.protocol === 'file:') {
return `http://127.0.0.1:3013${path}`;
}
if (!port || port === '80' || port === '443' || port === '3013') {
return path;
}
return `${protocol}//${hostname || '127.0.0.1'}:3013${path}`;
}
const voiceApiBaseURL = resolveApiBaseURL(import.meta.env.VITE_VOICE_API_BASE_URL, '/api/voice');
const sessionApiBaseURL = resolveApiBaseURL(import.meta.env.VITE_SESSION_API_BASE_URL, '/api/session');
const api = axios.create({
baseURL: voiceApiBaseURL,
timeout: 10000,
});
export async function getVoiceConfig() {
const { data } = await api.get('/config');
return data.data;
}
// ========== 会话历史 API ==========
const sessionApi = axios.create({
baseURL: sessionApiBaseURL,
timeout: 10000,
});
export async function getSessionHistory(sessionId, limit = 20) {
const { data } = await sessionApi.get(`/${sessionId}/history`, { params: { limit } });
return data.data;
}
export async function switchSessionMode(sessionId, targetMode) {
const { data } = await sessionApi.post(`/${sessionId}/switch`, { targetMode });
return data.data;
}
export async function getSessionList(userId, limit = 50) {
const params = { limit };
if (userId) params.userId = userId;
const { data } = await sessionApi.get('/list', { params });
return data.data;
}
export async function deleteSessionById(sessionId) {
const { data } = await sessionApi.delete(`/${sessionId}`);
return data;
}

View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
const backendTarget = 'http://localhost:3013';
export default defineConfig({
plugins: [react(), tailwindcss()],
build: {
outDir: 'dist',
sourcemap: false,
},
server: {
port: 5174,
proxy: {
'/api': {
target: backendTarget,
changeOrigin: true,
},
'/ws': {
target: backendTarget,
changeOrigin: true,
ws: true,
},
},
},
});

View File

@@ -0,0 +1,23 @@
package com.bigwo.javaserver;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication(exclude = {
DataSourceAutoConfiguration.class,
RedisAutoConfiguration.class,
RedisRepositoriesAutoConfiguration.class
})
@ConfigurationPropertiesScan
@EnableScheduling
public class BigwoApplication {
public static void main(String[] args) {
SpringApplication.run(BigwoApplication.class, args);
}
}

View File

@@ -0,0 +1,27 @@
package com.bigwo.javaserver.api;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiEnvelope<T>(Integer code, String message, Boolean success, T data, String error) {
public static <T> ApiEnvelope<T> ok(T data) {
return new ApiEnvelope<>(null, null, true, data, null);
}
public static ApiEnvelope<Void> okEmpty() {
return new ApiEnvelope<>(null, null, true, null, null);
}
public static <T> ApiEnvelope<T> assistantOk(T data) {
return new ApiEnvelope<>(0, "success", true, data, null);
}
public static <T> ApiEnvelope<T> failure(String error) {
return new ApiEnvelope<>(null, null, false, null, error);
}
public static <T> ApiEnvelope<T> assistantFailure(int code, String error) {
return new ApiEnvelope<>(code, error, false, null, error);
}
}

View File

@@ -0,0 +1,71 @@
package com.bigwo.javaserver.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.StringUtils;
@ConfigurationProperties(prefix = "bigwo.assistant-profile")
public class AssistantProfileProperties {
private String apiUrl = "";
private String apiMethod = "GET";
private String apiToken = "";
private String apiHeaders = "";
private long timeoutMs = 5000;
private long cacheTtlMs = 60000;
public String getApiUrl() {
return apiUrl;
}
public void setApiUrl(String apiUrl) {
this.apiUrl = apiUrl;
}
public String getApiMethod() {
return apiMethod;
}
public void setApiMethod(String apiMethod) {
this.apiMethod = apiMethod;
}
public String getApiToken() {
return apiToken;
}
public void setApiToken(String apiToken) {
this.apiToken = apiToken;
}
public String getApiHeaders() {
return apiHeaders;
}
public void setApiHeaders(String apiHeaders) {
this.apiHeaders = apiHeaders;
}
public long getTimeoutMs() {
return timeoutMs;
}
public void setTimeoutMs(long timeoutMs) {
this.timeoutMs = timeoutMs;
}
public long getCacheTtlMs() {
return cacheTtlMs;
}
public void setCacheTtlMs(long cacheTtlMs) {
this.cacheTtlMs = cacheTtlMs;
}
public boolean isConfigured() {
return StringUtils.hasText(apiUrl) && !apiUrl.startsWith("your_");
}
public String normalizedMethod() {
return "POST".equalsIgnoreCase(apiMethod) ? "POST" : "GET";
}
}

View File

@@ -0,0 +1,24 @@
package com.bigwo.javaserver.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}

View File

@@ -0,0 +1,49 @@
package com.bigwo.javaserver.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.StringUtils;
@ConfigurationProperties(prefix = "bigwo.coze")
public class CozeProperties {
private String baseUrl = "https://api.coze.cn";
private String apiToken = "";
private String botId = "";
public String getBaseUrl() {
return baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public String getApiToken() {
return apiToken;
}
public void setApiToken(String apiToken) {
this.apiToken = apiToken;
}
public String getBotId() {
return botId;
}
public void setBotId(String botId) {
this.botId = botId;
}
public boolean isConfigured() {
return isEffectiveValue(apiToken) && isEffectiveValue(botId);
}
public String normalizedBaseUrl() {
String value = StringUtils.hasText(baseUrl) ? baseUrl.trim() : "https://api.coze.cn";
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
}
private boolean isEffectiveValue(String value) {
return StringUtils.hasText(value) && !value.trim().startsWith("your_");
}
}

View File

@@ -0,0 +1,240 @@
package com.bigwo.javaserver.config;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
import javax.sql.DataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@Component
public class DatabaseContext {
private static final Logger log = LoggerFactory.getLogger(DatabaseContext.class);
private static final Pattern IDENTIFIER_PATTERN = Pattern.compile("[A-Za-z0-9_]+");
private final MysqlProperties properties;
private final InfrastructureAvailabilityState infrastructureAvailabilityState;
private volatile HikariDataSource dataSource;
private volatile JdbcTemplate jdbcTemplate;
public DatabaseContext(MysqlProperties properties, InfrastructureAvailabilityState infrastructureAvailabilityState) {
this.properties = properties;
this.infrastructureAvailabilityState = infrastructureAvailabilityState;
}
@PostConstruct
public void initialize() {
if (!properties.isEnabled()) {
log.info("[DB] MySQL disabled by configuration");
return;
}
try {
createDatabaseIfNeeded();
HikariDataSource hikariDataSource = createDataSource();
JdbcTemplate template = new JdbcTemplate(hikariDataSource);
initializeSchema(template);
this.dataSource = hikariDataSource;
this.jdbcTemplate = template;
infrastructureAvailabilityState.setDatabaseReady(true);
log.info("[DB] MySQL connected: {}, tables ready", properties.getDatabase());
} catch (Exception exception) {
infrastructureAvailabilityState.setDatabaseReady(false);
closeDataSource();
log.warn("[DB] MySQL initialization failed: {}", exception.getMessage());
log.warn("[DB] Continuing without database");
}
}
public boolean isAvailable() {
return jdbcTemplate != null && dataSource != null && infrastructureAvailabilityState.isDatabaseReady();
}
public JdbcTemplate requiredJdbcTemplate() {
JdbcTemplate template = jdbcTemplate;
if (template == null) {
throw new IllegalStateException("Database unavailable");
}
return template;
}
public DataSource requiredDataSource() {
DataSource source = dataSource;
if (source == null) {
throw new IllegalStateException("Database unavailable");
}
return source;
}
@PreDestroy
public void close() {
closeDataSource();
}
private void createDatabaseIfNeeded() throws SQLException {
String databaseName = sanitizeIdentifier(properties.getDatabase());
try (Connection connection = DriverManager.getConnection(
properties.getServerJdbcUrl(),
properties.getUsername(),
properties.getPassword());
Statement statement = connection.createStatement()) {
statement.execute("CREATE DATABASE IF NOT EXISTS `" + databaseName + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
}
}
private HikariDataSource createDataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(properties.getJdbcUrl());
config.setUsername(properties.getUsername());
config.setPassword(properties.getPassword());
config.setMaximumPoolSize(properties.getMaxPoolSize());
config.setConnectionTimeout(properties.getConnectionTimeoutMs());
config.setPoolName("bigwo-hikari");
config.setInitializationFailTimeout(0);
config.setConnectionTestQuery("SELECT 1");
config.setConnectionInitSql("SET NAMES utf8mb4");
return new HikariDataSource(config);
}
private void initializeSchema(JdbcTemplate jdbc) {
jdbc.execute("""
CREATE TABLE IF NOT EXISTS sessions (
id VARCHAR(128) PRIMARY KEY,
user_id VARCHAR(128),
username VARCHAR(64) DEFAULT '',
mode ENUM('voice', 'chat') DEFAULT 'chat',
created_at BIGINT,
updated_at BIGINT,
INDEX idx_user (user_id),
INDEX idx_updated (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""");
jdbc.execute("""
CREATE TABLE IF NOT EXISTS messages (
id INT AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(128) NOT NULL,
role ENUM('user', 'assistant', 'tool', 'system') NOT NULL,
content TEXT NOT NULL,
source ENUM('voice_asr', 'voice_bot', 'voice_tool', 'chat_user', 'chat_bot', 'search_knowledge') NOT NULL,
tool_name VARCHAR(64),
meta_json JSON,
created_at BIGINT,
INDEX idx_session (session_id),
INDEX idx_session_time (session_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""");
jdbc.execute("""
CREATE TABLE IF NOT EXISTS conversation_summaries (
id INT AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(128) NOT NULL,
user_id VARCHAR(128),
summary TEXT NOT NULL,
turn_count INT DEFAULT 0,
topics JSON,
created_at BIGINT,
updated_at BIGINT,
UNIQUE INDEX idx_session (session_id),
INDEX idx_user_time (user_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""");
jdbc.execute("""
CREATE TABLE IF NOT EXISTS video_tasks (
id VARCHAR(128) PRIMARY KEY,
username VARCHAR(64) DEFAULT '',
original_prompt TEXT,
optimized_prompt TEXT,
product_name VARCHAR(128) DEFAULT '',
template_type VARCHAR(16) DEFAULT 'product',
video_size VARCHAR(16) DEFAULT '720x1280',
video_seconds INT DEFAULT 5,
status VARCHAR(32) DEFAULT 'processing',
progress INT DEFAULT 0,
video_url TEXT,
error_msg TEXT,
created_at BIGINT NOT NULL,
completed_at BIGINT,
INDEX idx_username (username),
INDEX idx_created (created_at),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""");
migrateSchema(jdbc);
}
private void migrateSchema(JdbcTemplate jdbc) {
if (!columnMatchesType(jdbc, "sessions", "mode", "'chat'")) {
jdbc.execute("ALTER TABLE `sessions` MODIFY COLUMN `mode` ENUM('voice', 'chat') DEFAULT 'chat'");
}
if (!columnMatchesType(jdbc, "messages", "role", "'system'")) {
jdbc.execute("ALTER TABLE `messages` MODIFY COLUMN `role` ENUM('user', 'assistant', 'tool', 'system') NOT NULL");
}
if (!columnMatchesType(jdbc, "messages", "source", "'search_knowledge'")) {
jdbc.execute("ALTER TABLE `messages` MODIFY COLUMN `source` ENUM('voice_asr', 'voice_bot', 'voice_tool', 'chat_user', 'chat_bot', 'search_knowledge') NOT NULL");
}
ensureColumnExists(jdbc, "messages", "tool_name", "`tool_name` VARCHAR(64) NULL AFTER `source`");
ensureColumnExists(jdbc, "messages", "meta_json", "`meta_json` JSON NULL AFTER `tool_name`");
ensureColumnExists(jdbc, "messages", "created_at", "`created_at` BIGINT NULL AFTER `meta_json`");
ensureColumnExists(jdbc, "sessions", "updated_at", "`updated_at` BIGINT NULL AFTER `created_at`");
ensureColumnExists(jdbc, "sessions", "username", "`username` VARCHAR(64) DEFAULT '' AFTER `user_id`");
}
private void ensureColumnExists(JdbcTemplate jdbc, String tableName, String columnName, String definitionSql) {
Integer count = jdbc.queryForObject(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?",
Integer.class,
sanitizeIdentifier(properties.getDatabase()),
tableName,
columnName
);
if (count != null && count == 0) {
jdbc.execute("ALTER TABLE `" + tableName + "` ADD COLUMN " + definitionSql);
}
}
private boolean columnMatchesType(JdbcTemplate jdbc, String tableName, String columnName, String expectedTypeFragment) {
List<String> columnTypes = jdbc.query(
"SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?",
(ResultSet resultSet, int rowNum) -> resultSet.getString(1),
sanitizeIdentifier(properties.getDatabase()),
tableName,
columnName
);
if (columnTypes.isEmpty()) {
return false;
}
return columnTypes.getFirst().toLowerCase(Locale.ROOT).contains(expectedTypeFragment.toLowerCase(Locale.ROOT));
}
private String sanitizeIdentifier(String value) {
String trimmed = value == null ? "" : value.trim();
if (!IDENTIFIER_PATTERN.matcher(trimmed).matches()) {
throw new IllegalArgumentException("Invalid database identifier");
}
return trimmed;
}
private void closeDataSource() {
HikariDataSource source = this.dataSource;
this.jdbcTemplate = null;
this.dataSource = null;
if (source != null) {
source.close();
}
}
}

View File

@@ -0,0 +1,19 @@
package com.bigwo.javaserver.config;
import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.stereotype.Component;
@Component
public class InfrastructureAvailabilityState {
private final AtomicBoolean databaseReady = new AtomicBoolean(false);
public boolean isDatabaseReady() {
return databaseReady.get();
}
public void setDatabaseReady(boolean ready) {
databaseReady.set(ready);
}
}

View File

@@ -0,0 +1,127 @@
package com.bigwo.javaserver.config;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.StringUtils;
@ConfigurationProperties(prefix = "bigwo.knowledge")
public class KnowledgeBaseProperties {
private String accessKeyId = "";
private String secretAccessKey = "";
private String endpointId = "";
private String model = "";
private List<String> datasetIds = new ArrayList<>();
private int retrievalTopK = 25;
private double threshold = 0.1D;
private String rerankerModel = "doubao-seed-rerank";
private int rerankerTopN = 3;
private boolean enableReranker = true;
private boolean enableRedisContext = true;
private String collectionMapJson = "";
public String getAccessKeyId() {
return accessKeyId;
}
public void setAccessKeyId(String accessKeyId) {
this.accessKeyId = accessKeyId;
}
public String getSecretAccessKey() {
return secretAccessKey;
}
public void setSecretAccessKey(String secretAccessKey) {
this.secretAccessKey = secretAccessKey;
}
public String getEndpointId() {
return endpointId;
}
public void setEndpointId(String endpointId) {
this.endpointId = endpointId;
}
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
public List<String> getDatasetIds() {
return datasetIds;
}
public void setDatasetIds(List<String> datasetIds) {
this.datasetIds = datasetIds;
}
public int getRetrievalTopK() {
return retrievalTopK;
}
public void setRetrievalTopK(int retrievalTopK) {
this.retrievalTopK = retrievalTopK;
}
public double getThreshold() {
return threshold;
}
public void setThreshold(double threshold) {
this.threshold = threshold;
}
public String getRerankerModel() {
return rerankerModel;
}
public void setRerankerModel(String rerankerModel) {
this.rerankerModel = rerankerModel;
}
public int getRerankerTopN() {
return rerankerTopN;
}
public void setRerankerTopN(int rerankerTopN) {
this.rerankerTopN = rerankerTopN;
}
public boolean isEnableReranker() {
return enableReranker;
}
public void setEnableReranker(boolean enableReranker) {
this.enableReranker = enableReranker;
}
public boolean isEnableRedisContext() {
return enableRedisContext;
}
public void setEnableRedisContext(boolean enableRedisContext) {
this.enableRedisContext = enableRedisContext;
}
public String getCollectionMapJson() {
return collectionMapJson;
}
public void setCollectionMapJson(String collectionMapJson) {
this.collectionMapJson = collectionMapJson;
}
public boolean hasAccessKeys() {
return StringUtils.hasText(accessKeyId) && StringUtils.hasText(secretAccessKey);
}
public List<String> normalizedDatasetIds() {
return datasetIds == null ? List.of() : datasetIds.stream().map(value -> value == null ? "" : value.trim()).filter(StringUtils::hasText).toList();
}
}

View File

@@ -0,0 +1,97 @@
package com.bigwo.javaserver.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "bigwo.mysql")
public class MysqlProperties {
private boolean enabled = true;
private String host = "localhost";
private int port = 3306;
private String database = "bigwo_chat";
private String username = "root";
private String password = "";
private int maxPoolSize = 10;
private long connectionTimeoutMs = 5000;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getHost() {
return host;
}
public void setHost(String host) {
this.host = host;
}
public int getPort() {
return port;
}
public void setPort(int port) {
this.port = port;
}
public String getDatabase() {
return database;
}
public void setDatabase(String database) {
this.database = database;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public int getMaxPoolSize() {
return maxPoolSize;
}
public void setMaxPoolSize(int maxPoolSize) {
this.maxPoolSize = maxPoolSize;
}
public long getConnectionTimeoutMs() {
return connectionTimeoutMs;
}
public void setConnectionTimeoutMs(long connectionTimeoutMs) {
this.connectionTimeoutMs = connectionTimeoutMs;
}
public String getJdbcUrl() {
return String.format(
"jdbc:mysql://%s:%d/%s?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false",
host,
port,
database
);
}
public String getServerJdbcUrl() {
return String.format(
"jdbc:mysql://%s:%d/?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false",
host,
port
);
}
}

View File

@@ -0,0 +1,86 @@
package com.bigwo.javaserver.config;
import java.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisURI;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
@Component
public class RedisClientManager {
private static final Logger log = LoggerFactory.getLogger(RedisClientManager.class);
private final RedisProperties properties;
private volatile RedisClient redisClient;
private volatile StatefulRedisConnection<String, String> connection;
private volatile boolean available;
public RedisClientManager(RedisProperties properties) {
this.properties = properties;
}
@PostConstruct
public void initialize() {
if (!properties.isEnabled()) {
log.info("[Redis] Redis disabled by configuration");
return;
}
try {
RedisURI redisUri = RedisURI.create(properties.getUrl());
redisUri.setDatabase(properties.getDatabase());
redisUri.setTimeout(Duration.ofMillis(properties.getTimeoutMs()));
if (StringUtils.hasText(properties.getPassword())) {
redisUri.setPassword(properties.getPassword().toCharArray());
}
RedisClient client = RedisClient.create(redisUri);
StatefulRedisConnection<String, String> redisConnection = client.connect();
redisConnection.sync().ping();
this.redisClient = client;
this.connection = redisConnection;
this.available = true;
log.info("[Redis] Connected: {}", properties.getUrl());
} catch (Exception exception) {
this.available = false;
close();
log.warn("[Redis] Initialization failed: {}", exception.getMessage());
log.warn("[Redis] Continuing without Redis");
}
}
public boolean isAvailable() {
return available && connection != null;
}
public RedisCommands<String, String> syncCommands() {
StatefulRedisConnection<String, String> currentConnection = this.connection;
if (!available || currentConnection == null) {
throw new IllegalStateException("Redis unavailable");
}
return currentConnection.sync();
}
@PreDestroy
public void close() {
StatefulRedisConnection<String, String> currentConnection = this.connection;
RedisClient currentClient = this.redisClient;
this.connection = null;
this.redisClient = null;
this.available = false;
if (currentConnection != null) {
currentConnection.close();
}
if (currentClient != null) {
currentClient.shutdown();
}
}
}

View File

@@ -0,0 +1,62 @@
package com.bigwo.javaserver.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "bigwo.redis")
public class RedisProperties {
private boolean enabled = true;
private String url = "redis://127.0.0.1:6379";
private String password = "";
private int database = 0;
private long timeoutMs = 5000;
private String keyPrefix = "bigwo:";
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public int getDatabase() {
return database;
}
public void setDatabase(int database) {
this.database = database;
}
public long getTimeoutMs() {
return timeoutMs;
}
public void setTimeoutMs(long timeoutMs) {
this.timeoutMs = timeoutMs;
}
public String getKeyPrefix() {
return keyPrefix;
}
public void setKeyPrefix(String keyPrefix) {
this.keyPrefix = keyPrefix;
}
}

View File

@@ -0,0 +1,143 @@
package com.bigwo.javaserver.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "bigwo.video")
public class VideoGenerationProperties {
private String seedanceApiKey = "";
private String seedanceApiBase = "https://n.lconai.com";
private String seedanceModel = "seedance-2.0";
private String geminiModel = "";
private String geminiApiKey = "";
private String geminiApiBase = "";
private long pollIntervalMs = 5000L;
private long pollTimeoutMs = 900000L;
private long requestTimeoutMs = 120000L;
private String workDir = "";
private int maxConcurrentTasks = 4;
private String logoPath = "";
private String videosDir = "";
private int ffmpegThreads = 1;
private long postProcessTimeoutMs = 180000L;
public String getSeedanceApiKey() {
return seedanceApiKey;
}
public void setSeedanceApiKey(String seedanceApiKey) {
this.seedanceApiKey = seedanceApiKey;
}
public String getSeedanceApiBase() {
return seedanceApiBase;
}
public void setSeedanceApiBase(String seedanceApiBase) {
this.seedanceApiBase = seedanceApiBase;
}
public String getSeedanceModel() {
return seedanceModel;
}
public void setSeedanceModel(String seedanceModel) {
this.seedanceModel = seedanceModel;
}
public String getGeminiModel() {
return geminiModel;
}
public void setGeminiModel(String geminiModel) {
this.geminiModel = geminiModel;
}
public String getGeminiApiKey() {
return geminiApiKey;
}
public void setGeminiApiKey(String geminiApiKey) {
this.geminiApiKey = geminiApiKey;
}
public String getGeminiApiBase() {
return geminiApiBase;
}
public void setGeminiApiBase(String geminiApiBase) {
this.geminiApiBase = geminiApiBase;
}
public long getPollIntervalMs() {
return pollIntervalMs;
}
public void setPollIntervalMs(long pollIntervalMs) {
this.pollIntervalMs = pollIntervalMs;
}
public long getPollTimeoutMs() {
return pollTimeoutMs;
}
public void setPollTimeoutMs(long pollTimeoutMs) {
this.pollTimeoutMs = pollTimeoutMs;
}
public long getRequestTimeoutMs() {
return requestTimeoutMs;
}
public void setRequestTimeoutMs(long requestTimeoutMs) {
this.requestTimeoutMs = requestTimeoutMs;
}
public String getWorkDir() {
return workDir;
}
public void setWorkDir(String workDir) {
this.workDir = workDir;
}
public int getMaxConcurrentTasks() {
return maxConcurrentTasks;
}
public void setMaxConcurrentTasks(int maxConcurrentTasks) {
this.maxConcurrentTasks = maxConcurrentTasks;
}
public String getLogoPath() {
return logoPath;
}
public void setLogoPath(String logoPath) {
this.logoPath = logoPath;
}
public String getVideosDir() {
return videosDir;
}
public void setVideosDir(String videosDir) {
this.videosDir = videosDir;
}
public int getFfmpegThreads() {
return ffmpegThreads;
}
public void setFfmpegThreads(int ffmpegThreads) {
this.ffmpegThreads = ffmpegThreads;
}
public long getPostProcessTimeoutMs() {
return postProcessTimeoutMs;
}
public void setPostProcessTimeoutMs(long postProcessTimeoutMs) {
this.postProcessTimeoutMs = postProcessTimeoutMs;
}
}

View File

@@ -0,0 +1,103 @@
package com.bigwo.javaserver.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.StringUtils;
@ConfigurationProperties(prefix = "bigwo.voice")
public class VoiceGatewayProperties {
private boolean enabled = true;
private String upstreamUrl = "wss://openspeech.bytedance.com/api/v3/realtime/dialogue";
private String resourceId = "volc.speech.dialog";
private String appId = "";
private String token = "";
private String appKey = "PlgvMymc7f3tQnJ6";
private String defaultSpeaker = "zh_female_vv_jupiter_bigtts";
private long idleTimeoutMs = 300000L;
private long audioKeepaliveIntervalMs = 8000L;
private boolean sendReadyEarly = true;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getUpstreamUrl() {
return upstreamUrl;
}
public void setUpstreamUrl(String upstreamUrl) {
this.upstreamUrl = upstreamUrl;
}
public String getResourceId() {
return resourceId;
}
public void setResourceId(String resourceId) {
this.resourceId = resourceId;
}
public String getAppId() {
return appId;
}
public void setAppId(String appId) {
this.appId = appId;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public String getAppKey() {
return appKey;
}
public void setAppKey(String appKey) {
this.appKey = appKey;
}
public String getDefaultSpeaker() {
return defaultSpeaker;
}
public void setDefaultSpeaker(String defaultSpeaker) {
this.defaultSpeaker = defaultSpeaker;
}
public long getIdleTimeoutMs() {
return idleTimeoutMs;
}
public void setIdleTimeoutMs(long idleTimeoutMs) {
this.idleTimeoutMs = idleTimeoutMs;
}
public long getAudioKeepaliveIntervalMs() {
return audioKeepaliveIntervalMs;
}
public void setAudioKeepaliveIntervalMs(long audioKeepaliveIntervalMs) {
this.audioKeepaliveIntervalMs = audioKeepaliveIntervalMs;
}
public boolean isSendReadyEarly() {
return sendReadyEarly;
}
public void setSendReadyEarly(boolean sendReadyEarly) {
this.sendReadyEarly = sendReadyEarly;
}
public boolean isConfigured() {
return StringUtils.hasText(appId) && StringUtils.hasText(token) && StringUtils.hasText(upstreamUrl);
}
}

View File

@@ -0,0 +1,77 @@
package com.bigwo.javaserver.controller;
import com.bigwo.javaserver.api.ApiEnvelope;
import com.bigwo.javaserver.model.AssistantProfileResult;
import com.bigwo.javaserver.service.AssistantProfileService;
import com.bigwo.javaserver.web.request.AssistantProfileRefreshRequest;
import com.bigwo.javaserver.web.response.AssistantProfileResponseData;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.util.StringUtils;
@RestController
@RequestMapping("/api/assistant-profile")
public class AssistantProfileController {
private static final Logger log = LoggerFactory.getLogger(AssistantProfileController.class);
private final AssistantProfileService assistantProfileService;
public AssistantProfileController(AssistantProfileService assistantProfileService) {
this.assistantProfileService = assistantProfileService;
}
@GetMapping
public ResponseEntity<ApiEnvelope<AssistantProfileResponseData>> getAssistantProfile(
@RequestParam(name = "userId", required = false) String userId,
@RequestParam(name = "forceRefresh", required = false) String forceRefresh) {
try {
String normalizedUserId = normalizeNullable(userId);
AssistantProfileResult result = assistantProfileService.getAssistantProfile(normalizedUserId, "true".equals(normalizeNullable(forceRefresh)));
return ResponseEntity.ok(ApiEnvelope.assistantOk(toResponseData(normalizedUserId, result)));
} catch (Exception exception) {
log.error("[AssistantProfile] query failed: {}", exception.getMessage(), exception);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiEnvelope.assistantFailure(500, exception.getMessage()));
}
}
@PostMapping("/refresh")
public ResponseEntity<ApiEnvelope<AssistantProfileResponseData>> refreshAssistantProfile(
@RequestBody(required = false) AssistantProfileRefreshRequest request) {
try {
String normalizedUserId = normalizeNullable(request == null ? null : request.userId());
assistantProfileService.clearAssistantProfileCache(normalizedUserId);
AssistantProfileResult result = assistantProfileService.getAssistantProfile(normalizedUserId, true);
return ResponseEntity.ok(ApiEnvelope.assistantOk(toResponseData(normalizedUserId, result)));
} catch (Exception exception) {
log.error("[AssistantProfile] refresh failed: {}", exception.getMessage(), exception);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiEnvelope.assistantFailure(500, exception.getMessage()));
}
}
private AssistantProfileResponseData toResponseData(String userId, AssistantProfileResult result) {
return new AssistantProfileResponseData(
userId,
result.profile(),
result.source(),
result.cached(),
result.fetchedAt(),
result.configured(),
result.error()
);
}
private String normalizeNullable(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
}

View File

@@ -0,0 +1,75 @@
package com.bigwo.javaserver.controller;
import java.io.IOException;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.bigwo.javaserver.api.ApiEnvelope;
import com.bigwo.javaserver.model.ChatSendResponse;
import com.bigwo.javaserver.model.ChatStartResponse;
import com.bigwo.javaserver.model.ChatStreamEvent;
import com.bigwo.javaserver.service.ChatService;
import com.bigwo.javaserver.web.request.ChatSendRequest;
import com.bigwo.javaserver.web.request.ChatStartRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final ChatService chatService;
private final ObjectMapper objectMapper;
public ChatController(ChatService chatService, ObjectMapper objectMapper) {
this.chatService = chatService;
this.objectMapper = objectMapper;
}
@PostMapping("/start")
public ApiEnvelope<ChatStartResponse> start(@RequestBody(required = false) ChatStartRequest request) {
return ApiEnvelope.ok(chatService.startSession(request));
}
@PostMapping("/send")
public ApiEnvelope<ChatSendResponse> send(@RequestBody(required = false) ChatSendRequest request) {
return ApiEnvelope.ok(chatService.sendMessage(request));
}
@GetMapping("/history/{sessionId}")
public ApiEnvelope<?> history(@PathVariable("sessionId") String sessionId) {
return ApiEnvelope.ok(chatService.getHistory(sessionId));
}
@PostMapping("/send-stream")
public void sendStream(@RequestBody(required = false) ChatSendRequest request, HttpServletResponse response) throws IOException {
response.setContentType("text/event-stream;charset=UTF-8");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
response.setHeader("X-Accel-Buffering", "no");
response.flushBuffer();
chatService.streamMessage(request, event -> writeEvent(response, event));
}
@DeleteMapping("/{sessionId}")
public ApiEnvelope<Void> delete(@PathVariable("sessionId") String sessionId) {
chatService.deleteSession(sessionId);
return ApiEnvelope.okEmpty();
}
private void writeEvent(HttpServletResponse response, ChatStreamEvent event) {
try {
response.getWriter().write("data: " + objectMapper.writeValueAsString(event) + "\n\n");
response.getWriter().flush();
} catch (IOException exception) {
throw new IllegalStateException(exception.getMessage(), exception);
}
}
}

View File

@@ -0,0 +1,77 @@
package com.bigwo.javaserver.controller;
import org.springframework.core.env.Environment;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.bigwo.javaserver.config.RedisClientManager;
import com.bigwo.javaserver.config.VoiceGatewayProperties;
import com.bigwo.javaserver.web.response.HealthFeaturesResponse;
import com.bigwo.javaserver.web.response.HealthResponse;
@RestController
@RequestMapping("/api/health")
public class HealthController {
private final Environment environment;
private final RedisClientManager redisClientManager;
private final VoiceGatewayProperties voiceGatewayProperties;
public HealthController(Environment environment, RedisClientManager redisClientManager, VoiceGatewayProperties voiceGatewayProperties) {
this.environment = environment;
this.redisClientManager = redisClientManager;
this.voiceGatewayProperties = voiceGatewayProperties;
}
@GetMapping
public HealthResponse health() {
boolean envReady = !isPlaceholderValue("VOLC_S2S_APP_ID");
Object reranker = "true".equals(environment.getProperty("ENABLE_RERANKER"))
? configuredOrDefault("VOLC_ARK_RERANKER_MODEL", "doubao-seed-rerank")
: Boolean.FALSE;
return new HealthResponse(
"ok",
"s2s-hybrid",
"2024-12-01",
envReady,
new HealthFeaturesResponse(
true,
hasConfiguredValue("COZE_API_TOKEN") && hasConfiguredValue("COZE_BOT_ID"),
"coze",
hasConfiguredValue("VOLC_WEBSEARCH_API_KEY"),
hasConfiguredValue("VOLC_S2S_SPEAKER_ID"),
hasConfiguredValue("VOLC_ARK_KNOWLEDGE_BASE_IDS"),
redisClientManager.isAvailable(),
reranker,
"raw",
voiceGatewayProperties.isEnabled() && voiceGatewayProperties.isConfigured()
)
);
}
private boolean isPlaceholderValue(String key) {
String value = environment.getProperty(key, "");
if (!StringUtils.hasText(value)) {
return false;
}
return value.startsWith("your_");
}
private boolean hasConfiguredValue(String key) {
String value = environment.getProperty(key, "");
if (!StringUtils.hasText(value)) {
return false;
}
return !value.startsWith("your_");
}
private String configuredOrDefault(String key, String fallback) {
String value = environment.getProperty(key, "");
if (!StringUtils.hasText(value)) {
return fallback;
}
return value;
}
}

View File

@@ -0,0 +1,88 @@
package com.bigwo.javaserver.controller;
import com.bigwo.javaserver.api.ApiEnvelope;
import com.bigwo.javaserver.model.LlmMessage;
import com.bigwo.javaserver.model.SessionFullMessage;
import com.bigwo.javaserver.model.SessionHistoryResult;
import com.bigwo.javaserver.model.SessionListItem;
import com.bigwo.javaserver.model.SessionSwitchResult;
import com.bigwo.javaserver.service.SessionService;
import com.bigwo.javaserver.web.request.SessionSwitchRequest;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.util.StringUtils;
@RestController
@RequestMapping("/api/session")
public class SessionController {
private static final Logger log = LoggerFactory.getLogger(SessionController.class);
private final SessionService sessionService;
public SessionController(SessionService sessionService) {
this.sessionService = sessionService;
}
@GetMapping("/list")
public ApiEnvelope<List<SessionListItem>> listSessions(
@RequestParam(name = "userId", required = false) String userId,
@RequestParam(name = "limit", required = false) String limit) {
List<SessionListItem> sessions = sessionService.listSessions(normalizeNullable(userId), parseInteger(limit));
return ApiEnvelope.ok(sessions);
}
@DeleteMapping("/{id}")
public ApiEnvelope<Void> deleteSession(@PathVariable("id") String sessionId) {
sessionService.deleteSession(sessionId);
return ApiEnvelope.okEmpty();
}
@GetMapping("/{id}/history")
public ApiEnvelope<?> getHistory(
@PathVariable("id") String sessionId,
@RequestParam(name = "limit", required = false) String limit,
@RequestParam(name = "format", required = false) String format) {
Integer parsedLimit = parseInteger(limit);
String responseFormat = normalizeNullable(format);
if ("full".equals(responseFormat)) {
SessionHistoryResult<SessionFullMessage> result = sessionService.getFullHistory(sessionId, parsedLimit);
return ApiEnvelope.ok(result);
}
SessionHistoryResult<LlmMessage> result = sessionService.getLlmHistory(sessionId, parsedLimit);
return ApiEnvelope.ok(result);
}
@PostMapping("/{id}/switch")
public ApiEnvelope<SessionSwitchResult> switchMode(
@PathVariable("id") String sessionId,
@RequestBody(required = false) SessionSwitchRequest request) {
SessionSwitchResult result = sessionService.switchMode(sessionId, normalizeNullable(request == null ? null : request.targetMode()));
log.debug("[Session] Switch result generated for {}", sessionId);
return ApiEnvelope.ok(result);
}
private String normalizeNullable(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private Integer parseInteger(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException exception) {
return null;
}
}
}

View File

@@ -0,0 +1,156 @@
package com.bigwo.javaserver.controller;
import com.bigwo.javaserver.exception.BadRequestException;
import com.bigwo.javaserver.model.VideoAdminConfigResponse;
import com.bigwo.javaserver.model.VideoGenerateResponse;
import com.bigwo.javaserver.model.VideoHistoryResponse;
import com.bigwo.javaserver.model.VideoTaskSnapshot;
import com.bigwo.javaserver.service.VideoGenerationService;
import com.bigwo.javaserver.web.request.VideoAdminConfigRequest;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.MultipartException;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/video")
public class VideoController {
private static final Logger log = LoggerFactory.getLogger(VideoController.class);
private final VideoGenerationService videoGenerationService;
public VideoController(VideoGenerationService videoGenerationService) {
this.videoGenerationService = videoGenerationService;
}
@PostMapping(value = "/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<?> generate(
@RequestParam(name = "prompt", required = false) String prompt,
@RequestParam(name = "product", required = false) String product,
@RequestParam(name = "username", required = false) String username,
@RequestParam(name = "template", required = false) String template,
@RequestParam(name = "size", required = false) String size,
@RequestParam(name = "seconds", required = false) String seconds,
@RequestParam(name = "image", required = false) MultipartFile image
) {
try {
VideoGenerateResponse response = videoGenerationService.generate(prompt, product, username, template, size, seconds, image);
return ResponseEntity.ok(response);
} catch (BadRequestException exception) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorBody(exception.getMessage()));
} catch (Exception exception) {
log.error("[Video] generate failed: {}", exception.getMessage(), exception);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorBody(extractMessage(exception, "生成失败")));
}
}
@GetMapping("/task/{id}")
public ResponseEntity<?> getTask(@PathVariable("id") String taskId) {
VideoTaskSnapshot snapshot = videoGenerationService.getTask(taskId);
if (snapshot == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorBody("任务不存在"));
}
return ResponseEntity.ok(snapshot);
}
@GetMapping("/history")
public VideoHistoryResponse getHistory(
@RequestParam(name = "username", required = false) String username,
@RequestParam(name = "limit", required = false) String limit,
@RequestParam(name = "offset", required = false) String offset
) {
return videoGenerationService.getHistory(normalizeNullable(username), parseInteger(limit), parseInteger(offset));
}
@GetMapping("/file/{filename}")
public ResponseEntity<?> getVideoFile(@PathVariable("filename") String filename) {
Path file = videoGenerationService.getVideoFile(filename);
if (file == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorBody("文件不存在"));
}
try {
Resource resource = new FileSystemResource(file);
long fileSize = Files.size(file);
return ResponseEntity.ok()
.header("Content-Type", "video/mp4")
.header("Content-Length", String.valueOf(fileSize))
.header("Content-Disposition", "inline; filename=\"" + file.getFileName() + "\"")
.header("Cache-Control", "public, max-age=86400")
.body(resource);
} catch (Exception ex) {
log.error("[Video] 文件读取失败: {}", ex.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorBody("文件读取失败"));
}
}
@GetMapping("/admin/config")
public VideoAdminConfigResponse getAdminConfig() {
return videoGenerationService.getAdminConfig();
}
@PostMapping("/admin/config")
public ResponseEntity<?> updateAdminConfig(@RequestBody(required = false) VideoAdminConfigRequest request) {
try {
return ResponseEntity.ok(videoGenerationService.updateAdminConfig(request == null ? null : request.model()));
} catch (BadRequestException exception) {
Map<String, Object> body = errorBody(exception.getMessage());
body.put("validModels", videoGenerationService.validModels());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<Map<String, Object>> handleMaxUpload(MaxUploadSizeExceededException exception) {
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(errorBody("图片过大(上限 50MB请压缩后重试"));
}
@ExceptionHandler(MultipartException.class)
public ResponseEntity<Map<String, Object>> handleMultipart(MultipartException exception) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorBody(extractMessage(exception, "上传失败")));
}
private Map<String, Object> errorBody(String error) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", StringUtils.hasText(error) ? error : "请求失败");
return body;
}
private String extractMessage(Exception exception, String fallback) {
return exception.getMessage() == null || exception.getMessage().isBlank() ? fallback : exception.getMessage();
}
private String normalizeNullable(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private Integer parseInteger(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException exception) {
return null;
}
}
}

View File

@@ -0,0 +1,61 @@
package com.bigwo.javaserver.controller;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.bigwo.javaserver.api.ApiEnvelope;
import com.bigwo.javaserver.config.VoiceGatewayProperties;
@RestController
@RequestMapping("/api/voice")
public class VoiceController {
private static final List<VoiceOption> MODELS = List.of(
new VoiceOption("1.2.1.0", "O2.0(推荐,精品音质)", null),
new VoiceOption("O", "O基础版", null),
new VoiceOption("2.2.0.0", "SC2.0(推荐,声音复刻)", null),
new VoiceOption("SC", "SC基础版", null)
);
private static final List<VoiceOption> SPEAKERS = List.of(
new VoiceOption("zh_female_vv_jupiter_bigtts", "VV活泼女声", "O"),
new VoiceOption("zh_female_xiaohe_jupiter_bigtts", "小禾(甜美女声·台湾口音)", "O"),
new VoiceOption("zh_male_yunzhou_jupiter_bigtts", "云舟(沉稳男声)", "O"),
new VoiceOption("zh_male_xiaotian_jupiter_bigtts", "小天(磁性男声)", "O"),
new VoiceOption("saturn_common_female_1", "Saturn 女声1", "SC2.0"),
new VoiceOption("saturn_common_male_1", "Saturn 男声1", "SC2.0"),
new VoiceOption("ICL_common_female_1", "ICL 女声1", "SC"),
new VoiceOption("ICL_common_male_1", "ICL 男声1", "SC")
);
private static final List<VoiceTool> TOOLS = List.of(
new VoiceTool("search_knowledge", "【强制调用】优先查询知识库,基于企业官方信息回答产品、公司、制度、招商与常见问题。"),
new VoiceTool("query_weather", "查询指定城市的天气信息"),
new VoiceTool("query_order", "根据订单号查询订单状态"),
new VoiceTool("get_current_time", "获取当前日期和时间"),
new VoiceTool("calculate", "计算数学表达式,支持加减乘除")
);
private final VoiceGatewayProperties voiceGatewayProperties;
public VoiceController(VoiceGatewayProperties voiceGatewayProperties) {
this.voiceGatewayProperties = voiceGatewayProperties;
}
@GetMapping("/config")
public ApiEnvelope<VoiceConfigData> config() {
return ApiEnvelope.ok(new VoiceConfigData(MODELS, SPEAKERS, TOOLS, voiceGatewayProperties.isEnabled()));
}
private record VoiceConfigData(List<VoiceOption> models, List<VoiceOption> speakers, List<VoiceTool> tools, boolean enabled) {
}
private record VoiceOption(String value, String label, String series) {
}
private record VoiceTool(String name, String description) {
}
}

View File

@@ -0,0 +1,8 @@
package com.bigwo.javaserver.exception;
public class BadRequestException extends RuntimeException {
public BadRequestException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,53 @@
package com.bigwo.javaserver.exception;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException;
import com.bigwo.javaserver.api.ApiEnvelope;
import jakarta.servlet.http.HttpServletRequest;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(BadRequestException.class)
public ResponseEntity<ApiEnvelope<Void>> handleBadRequest(BadRequestException exception) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiEnvelope.failure(exception.getMessage()));
}
@ExceptionHandler({MethodArgumentNotValidException.class, BindException.class, HttpMessageNotReadableException.class})
public ResponseEntity<ApiEnvelope<Void>> handleValidationException(Exception exception) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiEnvelope.failure("Invalid request body"));
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiEnvelope<Void>> handleMethodNotAllowed(HttpRequestMethodNotSupportedException exception) {
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(ApiEnvelope.failure(exception.getMessage()));
}
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<ApiEnvelope<Void>> handleNotFound(NoHandlerFoundException exception, HttpServletRequest request) {
String error = "Route not found: " + request.getMethod() + " " + request.getRequestURI();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiEnvelope.failure(error));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiEnvelope<Void>> handleGenericException(Exception exception, HttpServletRequest request) {
log.error("Unhandled error on {} {}", request.getMethod(), request.getRequestURI(), exception);
String message = exception.getMessage() == null || exception.getMessage().isBlank()
? "Internal Server Error"
: exception.getMessage();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiEnvelope.failure(message));
}
}

View File

@@ -0,0 +1,56 @@
package com.bigwo.javaserver.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public record AssistantProfile(
String documents,
String email,
String agentName,
String nickname,
String wxl,
String mobile,
@JsonProperty("wx_code") String wxCode,
String intro,
String sign,
String story
) {
public static AssistantProfile resolve(AssistantProfile profile) {
AssistantProfile source = profile == null ? new AssistantProfile(null, null, null, null, null, null, null, null, null, null) : profile;
String agentName = firstNonBlank(source.agentName, source.nickname, "大沃");
String nickname = firstNonBlank(source.nickname, agentName);
return new AssistantProfile(
normalize(source.documents),
normalize(source.email),
agentName,
nickname,
normalize(source.wxl),
normalize(source.mobile),
normalize(source.wxCode),
normalize(source.intro),
normalize(source.sign),
normalize(source.story)
);
}
public static AssistantProfile defaults() {
return resolve(null);
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
private static String firstNonBlank(String... values) {
if (values == null) {
return "";
}
for (String value : values) {
String normalized = normalize(value);
if (!normalized.isEmpty()) {
return normalized;
}
}
return "";
}
}

View File

@@ -0,0 +1,11 @@
package com.bigwo.javaserver.model;
public record AssistantProfileResult(
AssistantProfile profile,
String source,
boolean cached,
Long fetchedAt,
boolean configured,
String error
) {
}

View File

@@ -0,0 +1,4 @@
package com.bigwo.javaserver.model;
public record ChatHistoryResponse(String conversationId, boolean fromVoice) {
}

View File

@@ -0,0 +1,4 @@
package com.bigwo.javaserver.model;
public record ChatSendResponse(String content) {
}

View File

@@ -0,0 +1,124 @@
package com.bigwo.javaserver.model;
import java.util.List;
public class ChatSessionState {
private final String sessionId;
private volatile String userId;
private volatile String profileUserId;
private volatile String conversationId;
private volatile String lastKbTopic;
private volatile long lastKbHitAt;
private final List<LlmMessage> voiceMessages;
private final String handoffSummary;
private volatile boolean handoffSummaryUsed;
private final long createdAt;
private volatile long lastActiveAt;
private final boolean fromVoice;
public ChatSessionState(
String sessionId,
String userId,
String profileUserId,
String conversationId,
String lastKbTopic,
long lastKbHitAt,
List<LlmMessage> voiceMessages,
String handoffSummary,
boolean handoffSummaryUsed,
long createdAt,
long lastActiveAt,
boolean fromVoice) {
this.sessionId = sessionId;
this.userId = userId;
this.profileUserId = profileUserId;
this.conversationId = conversationId;
this.lastKbTopic = lastKbTopic;
this.lastKbHitAt = lastKbHitAt;
this.voiceMessages = List.copyOf(voiceMessages);
this.handoffSummary = handoffSummary;
this.handoffSummaryUsed = handoffSummaryUsed;
this.createdAt = createdAt;
this.lastActiveAt = lastActiveAt;
this.fromVoice = fromVoice;
}
public String getSessionId() {
return sessionId;
}
public String getUserId() {
return userId;
}
public synchronized void setUserId(String userId) {
this.userId = userId;
}
public String getProfileUserId() {
return profileUserId;
}
public synchronized void setProfileUserId(String profileUserId) {
this.profileUserId = profileUserId;
}
public String getConversationId() {
return conversationId;
}
public synchronized void setConversationId(String conversationId) {
this.conversationId = conversationId;
}
public String getLastKbTopic() {
return lastKbTopic;
}
public long getLastKbHitAt() {
return lastKbHitAt;
}
public synchronized void setLastKbContext(String lastKbTopic, long lastKbHitAt) {
this.lastKbTopic = lastKbTopic;
this.lastKbHitAt = lastKbHitAt;
}
public synchronized void clearLastKbContext() {
this.lastKbTopic = "";
this.lastKbHitAt = 0L;
}
public List<LlmMessage> getVoiceMessages() {
return voiceMessages;
}
public String getHandoffSummary() {
return handoffSummary;
}
public boolean isHandoffSummaryUsed() {
return handoffSummaryUsed;
}
public synchronized void setHandoffSummaryUsed(boolean handoffSummaryUsed) {
this.handoffSummaryUsed = handoffSummaryUsed;
}
public long getCreatedAt() {
return createdAt;
}
public long getLastActiveAt() {
return lastActiveAt;
}
public synchronized void touch() {
this.lastActiveAt = System.currentTimeMillis();
}
public boolean isFromVoice() {
return fromVoice;
}
}

View File

@@ -0,0 +1,4 @@
package com.bigwo.javaserver.model;
public record ChatStartResponse(String sessionId, int messageCount, boolean fromVoice) {
}

View File

@@ -0,0 +1,23 @@
package com.bigwo.javaserver.model;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ChatStreamEvent(String type, String content, String error, String reason) {
public static ChatStreamEvent chunk(String content) {
return new ChatStreamEvent("chunk", content, null, null);
}
public static ChatStreamEvent done(String content) {
return new ChatStreamEvent("done", content, null, null);
}
public static ChatStreamEvent error(String error) {
return new ChatStreamEvent("error", null, error, null);
}
public static ChatStreamEvent reset(String reason) {
return new ChatStreamEvent("stream_reset", null, null, reason);
}
}

View File

@@ -0,0 +1,4 @@
package com.bigwo.javaserver.model;
public record ChatSubtitle(String role, String text) {
}

View File

@@ -0,0 +1,4 @@
package com.bigwo.javaserver.model;
public record CozeChatResult(String content, String conversationId) {
}

View File

@@ -0,0 +1,15 @@
package com.bigwo.javaserver.model;
import java.util.Map;
public record KnowledgeChunk(
String id,
String content,
double score,
String docName,
String chunkTitle,
Map<String, Object> metadata,
String collection,
boolean reranked
) {
}

View File

@@ -0,0 +1,14 @@
package com.bigwo.javaserver.model;
import java.util.List;
public record KnowledgeQueryInfo(
String rawText,
String normalizedText,
String rewrittenText,
List<String> entities,
String primaryEntity,
boolean hasExplicitEntity,
boolean hasKnowledgeSignal
) {
}

View File

@@ -0,0 +1,22 @@
package com.bigwo.javaserver.model;
import java.util.List;
import java.util.Map;
public record KnowledgeSearchResult(
String query,
String originalQuery,
boolean hit,
String reason,
List<KnowledgeChunk> chunks,
List<KnowledgeChunk> rerankedChunks,
List<Map<String, Object>> ragPayload,
Map<String, Object> evidencePack,
double topScore,
long latencyMs,
Long retrievalLatencyMs,
String source,
boolean hasReferences,
Map<String, Object> usage
) {
}

View File

@@ -0,0 +1,4 @@
package com.bigwo.javaserver.model;
public record LlmMessage(String role, String content) {
}

View File

@@ -0,0 +1,4 @@
package com.bigwo.javaserver.model;
public record RedisContextMessage(String role, String content, String source, long ts) {
}

View File

@@ -0,0 +1,9 @@
package com.bigwo.javaserver.model;
public record RenderedVideoPayload(
String prompt,
String negative,
String voiceScript,
VideoPromptDetail promptDetail
) {
}

View File

@@ -0,0 +1,13 @@
package com.bigwo.javaserver.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public record SessionFullMessage(
String role,
String content,
String source,
@JsonProperty("tool_name") String toolName,
@JsonProperty("meta_json") String metaJson,
@JsonProperty("created_at") Long createdAt
) {
}

View File

@@ -0,0 +1,6 @@
package com.bigwo.javaserver.model;
import java.util.List;
public record SessionHistoryResult<T>(String sessionId, String mode, List<T> messages, int count) {
}

View File

@@ -0,0 +1,12 @@
package com.bigwo.javaserver.model;
public record SessionListItem(
String id,
String userId,
String mode,
Long createdAt,
Long updatedAt,
String lastMessage,
int messageCount
) {
}

View File

@@ -0,0 +1,6 @@
package com.bigwo.javaserver.model;
import java.util.List;
public record SessionSwitchResult(String sessionId, String mode, List<LlmMessage> history, int count) {
}

View File

@@ -0,0 +1,16 @@
package com.bigwo.javaserver.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record VideoAdminConfigResponse(
Boolean success,
String previousModel,
String currentModel,
String source,
List<String> validModels,
String envModel,
String runtimeModel
) {
}

View File

@@ -0,0 +1,4 @@
package com.bigwo.javaserver.model;
public record VideoGenerateResponse(String taskId, String originalPrompt, String username, String status) {
}

View File

@@ -0,0 +1,6 @@
package com.bigwo.javaserver.model;
import java.util.List;
public record VideoHistoryResponse(int total, List<VideoHistoryRowResponse> rows) {
}

View File

@@ -0,0 +1,20 @@
package com.bigwo.javaserver.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public record VideoHistoryRowResponse(
String id,
String username,
@JsonProperty("original_prompt") String originalPrompt,
@JsonProperty("optimized_prompt") String optimizedPrompt,
@JsonProperty("product_name") String productName,
@JsonProperty("template_type") String templateType,
@JsonProperty("video_size") String videoSize,
@JsonProperty("video_seconds") Integer videoSeconds,
String status,
Integer progress,
@JsonProperty("video_url") String videoUrl,
@JsonProperty("created_at") Long createdAt,
@JsonProperty("completed_at") Long completedAt
) {
}

View File

@@ -0,0 +1,16 @@
package com.bigwo.javaserver.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
import java.util.Map;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record VideoPromptDetail(
String videoPrompt,
String voiceScript,
String negative,
String note,
String theme,
List<Map<String, Object>> shotList
) {
}

View File

@@ -0,0 +1,24 @@
package com.bigwo.javaserver.model;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record VideoTaskSnapshot(
String id,
String username,
String originalPrompt,
String optimizedPrompt,
String productName,
String templateType,
String videoSize,
Integer videoSeconds,
String status,
String statusText,
Integer progress,
String videoUrl,
String error,
Long createdAt,
Long completedAt,
VideoPromptDetail promptDetail
) {
}

View File

@@ -0,0 +1,111 @@
package com.bigwo.javaserver.repository;
import com.bigwo.javaserver.config.DatabaseContext;
import com.bigwo.javaserver.model.LlmMessage;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.sql.ResultSet;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
@Repository
public class ChatRepository {
private static final Logger log = LoggerFactory.getLogger(ChatRepository.class);
private final DatabaseContext databaseContext;
private final ObjectMapper objectMapper;
public ChatRepository(DatabaseContext databaseContext, ObjectMapper objectMapper) {
this.databaseContext = databaseContext;
this.objectMapper = objectMapper;
}
public List<LlmMessage> getHistoryForLlm(String sessionId, int limit) {
if (!databaseContext.isAvailable()) {
return List.of();
}
try {
JdbcTemplate jdbc = databaseContext.requiredJdbcTemplate();
int safeLimit = Math.max(1, Math.min(limit, 100));
List<LlmMessage> messages = jdbc.query(
"""
SELECT role, content
FROM messages
WHERE session_id = ?
ORDER BY created_at DESC
LIMIT ?
""",
(ResultSet resultSet, int rowNum) -> new LlmMessage(resultSet.getString("role"), resultSet.getString("content")),
sessionId,
safeLimit
);
return messages.reversed().stream()
.filter(message -> "user".equals(message.role()) || "assistant".equals(message.role()))
.toList();
} catch (Exception exception) {
log.warn("[DB] getHistoryForLLM failed: {}", exception.getMessage());
return List.of();
}
}
public void createSession(String sessionId, String userId, String mode) {
if (!databaseContext.isAvailable()) {
return;
}
try {
long now = System.currentTimeMillis();
JdbcTemplate jdbc = databaseContext.requiredJdbcTemplate();
jdbc.update(
"""
INSERT INTO sessions (id, user_id, username, mode, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
user_id = IF(VALUES(user_id) = '' OR VALUES(user_id) IS NULL, user_id, VALUES(user_id)),
mode = VALUES(mode),
username = IF(VALUES(username) = '', username, VALUES(username)),
updated_at = VALUES(updated_at)
""",
sessionId,
userId,
"",
mode,
now,
now
);
} catch (Exception exception) {
log.warn("[DB] createSession failed: {}", exception.getMessage());
}
}
public void addMessage(String sessionId, String role, String content, String source) {
addMessage(sessionId, role, content, source, null, null);
}
public void addMessage(String sessionId, String role, String content, String source, String toolName, Object meta) {
if (!databaseContext.isAvailable() || !StringUtils.hasText(content)) {
return;
}
try {
long now = System.currentTimeMillis();
String metaJson = meta == null ? null : objectMapper.writeValueAsString(meta);
JdbcTemplate jdbc = databaseContext.requiredJdbcTemplate();
jdbc.update(
"INSERT INTO messages (session_id, role, content, source, tool_name, meta_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
sessionId,
role,
content,
source,
toolName,
metaJson,
now
);
jdbc.update("UPDATE sessions SET updated_at = ? WHERE id = ?", now, sessionId);
} catch (Exception exception) {
log.warn("[DB] addMessage failed: {}", exception.getMessage());
}
}
}

View File

@@ -0,0 +1,144 @@
package com.bigwo.javaserver.repository;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collections;
import java.util.List;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import com.bigwo.javaserver.config.DatabaseContext;
import com.bigwo.javaserver.model.LlmMessage;
import com.bigwo.javaserver.model.SessionFullMessage;
import com.bigwo.javaserver.model.SessionListItem;
@Repository
public class SessionRepository {
private final DatabaseContext databaseContext;
public SessionRepository(DatabaseContext databaseContext) {
this.databaseContext = databaseContext;
}
public List<SessionListItem> getSessionList(String userId, Integer limit) {
JdbcTemplate jdbc = databaseContext.requiredJdbcTemplate();
int safeLimit = clamp(limit, 50, 1, 200);
String normalizedUserId = normalizeNullable(userId);
if (normalizedUserId != null) {
return jdbc.query(
"""
SELECT s.id, s.user_id, s.mode, s.created_at, s.updated_at,
(SELECT content FROM messages WHERE session_id = s.id ORDER BY created_at DESC LIMIT 1) AS last_message,
(SELECT COUNT(*) FROM messages WHERE session_id = s.id) AS message_count
FROM sessions s
WHERE s.user_id = ?
ORDER BY s.updated_at DESC
LIMIT ?
""",
(resultSet, rowNum) -> mapSessionListItem(resultSet),
normalizedUserId,
safeLimit
);
}
return jdbc.query(
"""
SELECT s.id, s.user_id, s.mode, s.created_at, s.updated_at,
(SELECT content FROM messages WHERE session_id = s.id ORDER BY created_at DESC LIMIT 1) AS last_message,
(SELECT COUNT(*) FROM messages WHERE session_id = s.id) AS message_count
FROM sessions s
ORDER BY s.updated_at DESC
LIMIT ?
""",
(resultSet, rowNum) -> mapSessionListItem(resultSet),
safeLimit
);
}
public void deleteSession(String sessionId) {
JdbcTemplate jdbc = databaseContext.requiredJdbcTemplate();
jdbc.update("DELETE FROM messages WHERE session_id = ?", sessionId);
jdbc.update("DELETE FROM conversation_summaries WHERE session_id = ?", sessionId);
jdbc.update("DELETE FROM sessions WHERE id = ?", sessionId);
}
public List<SessionFullMessage> getRecentMessages(String sessionId, Integer limit) {
JdbcTemplate jdbc = databaseContext.requiredJdbcTemplate();
int safeLimit = clamp(limit, 20, 1, 100);
List<SessionFullMessage> messages = jdbc.query(
"""
SELECT role, content, source, tool_name, meta_json, created_at
FROM messages
WHERE session_id = ?
ORDER BY created_at DESC
LIMIT ?
""",
(resultSet, rowNum) -> mapSessionFullMessage(resultSet),
sessionId,
safeLimit
);
Collections.reverse(messages);
return messages;
}
public List<LlmMessage> getHistoryForLlm(String sessionId, Integer limit) {
return getRecentMessages(sessionId, limit).stream()
.filter(message -> "user".equals(message.role()) || "assistant".equals(message.role()))
.map(message -> new LlmMessage(message.role(), message.content()))
.toList();
}
public String getSessionMode(String sessionId) {
JdbcTemplate jdbc = databaseContext.requiredJdbcTemplate();
List<String> modes = jdbc.query(
"SELECT mode FROM sessions WHERE id = ? LIMIT 1",
(resultSet, rowNum) -> resultSet.getString("mode"),
sessionId
);
return modes.isEmpty() ? null : modes.get(0);
}
public void updateSessionMode(String sessionId, String mode) {
JdbcTemplate jdbc = databaseContext.requiredJdbcTemplate();
jdbc.update("UPDATE sessions SET mode = ?, updated_at = ? WHERE id = ?", mode, System.currentTimeMillis(), sessionId);
}
private SessionListItem mapSessionListItem(ResultSet resultSet) throws SQLException {
return new SessionListItem(
resultSet.getString("id"),
resultSet.getString("user_id"),
resultSet.getString("mode"),
getNullableLong(resultSet, "created_at"),
getNullableLong(resultSet, "updated_at"),
resultSet.getString("last_message"),
resultSet.getInt("message_count")
);
}
private SessionFullMessage mapSessionFullMessage(ResultSet resultSet) throws SQLException {
return new SessionFullMessage(
resultSet.getString("role"),
resultSet.getString("content"),
resultSet.getString("source"),
resultSet.getString("tool_name"),
resultSet.getString("meta_json"),
getNullableLong(resultSet, "created_at")
);
}
private Long getNullableLong(ResultSet resultSet, String columnName) throws SQLException {
long value = resultSet.getLong(columnName);
return resultSet.wasNull() ? null : value;
}
private String normalizeNullable(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private int clamp(Integer value, int fallback, int min, int max) {
int resolved = value == null ? fallback : value;
return Math.max(min, Math.min(resolved, max));
}
}

View File

@@ -0,0 +1,224 @@
package com.bigwo.javaserver.repository;
import java.sql.ResultSet;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import com.bigwo.javaserver.config.DatabaseContext;
import com.bigwo.javaserver.model.VideoTaskSnapshot;
@Repository
public class VideoTaskRepository {
private static final Logger log = LoggerFactory.getLogger(VideoTaskRepository.class);
private final DatabaseContext databaseContext;
public VideoTaskRepository(DatabaseContext databaseContext) {
this.databaseContext = databaseContext;
}
public boolean isAvailable() {
return databaseContext.isAvailable();
}
public int cleanupStaleTasks() {
if (!databaseContext.isAvailable()) {
return 0;
}
try {
JdbcTemplate jdbc = databaseContext.requiredJdbcTemplate();
long now = System.currentTimeMillis();
return jdbc.update(
"UPDATE video_tasks SET status = 'failed', error_msg = '服务重启,任务已中断', completed_at = ? WHERE status IN ('queued', 'optimizing', 'processing', 'subtitling')",
now
);
} catch (Exception exception) {
log.warn("[Video] cleanup stale tasks failed: {}", exception.getMessage());
return 0;
}
}
public void insertVideoTask(
String id,
String username,
String originalPrompt,
String optimizedPrompt,
String productName,
String templateType,
String videoSize,
int videoSeconds,
String status
) {
if (!databaseContext.isAvailable()) {
return;
}
try {
long now = System.currentTimeMillis();
JdbcTemplate jdbc = databaseContext.requiredJdbcTemplate();
jdbc.update(
"INSERT INTO video_tasks (id, username, original_prompt, optimized_prompt, product_name, template_type, video_size, video_seconds, status, progress, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)",
id,
defaultString(username),
defaultString(originalPrompt),
StringUtils.hasText(optimizedPrompt) ? optimizedPrompt : null,
defaultString(productName),
StringUtils.hasText(templateType) ? templateType : "product",
StringUtils.hasText(videoSize) ? videoSize : "720x1280",
videoSeconds,
StringUtils.hasText(status) ? status : "processing",
now
);
} catch (Exception exception) {
log.warn("[Video] insert task failed: {}", exception.getMessage());
}
}
public void updateVideoProgress(String id, String status, int progress) {
if (!databaseContext.isAvailable() || !StringUtils.hasText(id)) {
return;
}
try {
JdbcTemplate jdbc = databaseContext.requiredJdbcTemplate();
jdbc.update(
"UPDATE video_tasks SET status = ?, progress = ? WHERE id = ?",
StringUtils.hasText(status) ? status : "processing",
Math.max(0, progress),
id.trim()
);
} catch (Exception exception) {
log.warn("[Video] update progress failed: {}", exception.getMessage());
}
}
public void updateVideoOptimizedPrompt(String id, String optimizedPrompt) {
if (!databaseContext.isAvailable() || !StringUtils.hasText(id)) {
return;
}
try {
JdbcTemplate jdbc = databaseContext.requiredJdbcTemplate();
jdbc.update(
"UPDATE video_tasks SET optimized_prompt = ? WHERE id = ?",
StringUtils.hasText(optimizedPrompt) ? optimizedPrompt : null,
id.trim()
);
} catch (Exception exception) {
log.warn("[Video] update optimized prompt failed: {}", exception.getMessage());
}
}
public void completeVideoTask(String id, String status, String videoUrl, String errorMsg) {
if (!databaseContext.isAvailable() || !StringUtils.hasText(id)) {
return;
}
try {
long now = System.currentTimeMillis();
JdbcTemplate jdbc = databaseContext.requiredJdbcTemplate();
boolean completed = "completed".equalsIgnoreCase(status);
jdbc.update(
"UPDATE video_tasks SET status = ?, progress = ?, video_url = ?, error_msg = ?, completed_at = ? WHERE id = ?",
StringUtils.hasText(status) ? status : "completed",
completed ? 100 : 0,
StringUtils.hasText(videoUrl) ? videoUrl : null,
StringUtils.hasText(errorMsg) ? errorMsg : null,
now,
id.trim()
);
} catch (Exception exception) {
log.warn("[Video] complete task failed: {}", exception.getMessage());
}
}
public VideoTaskSnapshot getVideoTask(String id) {
if (!databaseContext.isAvailable() || !StringUtils.hasText(id)) {
return null;
}
try {
JdbcTemplate jdbc = databaseContext.requiredJdbcTemplate();
List<VideoTaskSnapshot> rows = jdbc.query(
"SELECT id, username, original_prompt, optimized_prompt, product_name, template_type, video_size, video_seconds, status, progress, video_url, error_msg, created_at, completed_at FROM video_tasks WHERE id = ? LIMIT 1",
this::mapSnapshot,
id.trim()
);
return rows.isEmpty() ? null : rows.getFirst();
} catch (Exception exception) {
log.warn("[Video] get task failed: {}", exception.getMessage());
return null;
}
}
public List<VideoTaskSnapshot> getVideoHistory(String username, int limit, int offset) {
if (!databaseContext.isAvailable()) {
return List.of();
}
int safeLimit = Math.max(1, Math.min(limit, 100));
int safeOffset = Math.max(0, offset);
try {
JdbcTemplate jdbc = databaseContext.requiredJdbcTemplate();
if (StringUtils.hasText(username)) {
return jdbc.query(
"SELECT id, username, original_prompt, optimized_prompt, product_name, template_type, video_size, video_seconds, status, progress, video_url, error_msg, created_at, completed_at FROM video_tasks WHERE username = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
this::mapSnapshot,
username.trim(),
safeLimit,
safeOffset
);
}
return jdbc.query(
"SELECT id, username, original_prompt, optimized_prompt, product_name, template_type, video_size, video_seconds, status, progress, video_url, error_msg, created_at, completed_at FROM video_tasks ORDER BY created_at DESC LIMIT ? OFFSET ?",
this::mapSnapshot,
safeLimit,
safeOffset
);
} catch (Exception exception) {
log.warn("[Video] get history failed: {}", exception.getMessage());
return List.of();
}
}
public int getVideoHistoryCount(String username) {
if (!databaseContext.isAvailable()) {
return 0;
}
try {
JdbcTemplate jdbc = databaseContext.requiredJdbcTemplate();
Integer total = StringUtils.hasText(username)
? jdbc.queryForObject("SELECT COUNT(*) FROM video_tasks WHERE username = ?", Integer.class, username.trim())
: jdbc.queryForObject("SELECT COUNT(*) FROM video_tasks", Integer.class);
return total == null ? 0 : total;
} catch (Exception exception) {
log.warn("[Video] get history count failed: {}", exception.getMessage());
return 0;
}
}
private VideoTaskSnapshot mapSnapshot(ResultSet resultSet, int rowNum) throws java.sql.SQLException {
return new VideoTaskSnapshot(
resultSet.getString("id"),
resultSet.getString("username"),
resultSet.getString("original_prompt"),
resultSet.getString("optimized_prompt"),
resultSet.getString("product_name"),
resultSet.getString("template_type"),
resultSet.getString("video_size"),
resultSet.getInt("video_seconds"),
resultSet.getString("status"),
null,
resultSet.getInt("progress"),
resultSet.getString("video_url"),
resultSet.getString("error_msg"),
resultSet.getLong("created_at"),
resultSet.getObject("completed_at") == null ? null : resultSet.getLong("completed_at"),
null
);
}
private String defaultString(String value) {
return StringUtils.hasText(value) ? value.trim() : "";
}
}

View File

@@ -0,0 +1,220 @@
package com.bigwo.javaserver.service;
import com.bigwo.javaserver.config.AssistantProfileProperties;
import com.bigwo.javaserver.model.AssistantProfile;
import com.bigwo.javaserver.model.AssistantProfileResult;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
@Service
public class AssistantProfileService {
private static final Logger log = LoggerFactory.getLogger(AssistantProfileService.class);
private final AssistantProfileProperties properties;
private final ObjectMapper objectMapper;
private final ConcurrentMap<String, CachedAssistantProfile> cache = new ConcurrentHashMap<>();
public AssistantProfileService(AssistantProfileProperties properties, ObjectMapper objectMapper) {
this.properties = properties;
this.objectMapper = objectMapper;
}
public AssistantProfileResult getAssistantProfile(String userId, boolean forceRefresh) {
String normalizedUserId = normalizeNullable(userId);
String cacheKey = getCacheKey(normalizedUserId);
CachedAssistantProfile cached = cache.get(cacheKey);
long ttlMs = Math.max(0L, properties.getCacheTtlMs());
if (!forceRefresh && cached != null && (System.currentTimeMillis() - cached.fetchedAt()) <= ttlMs) {
return new AssistantProfileResult(cached.profile(), cached.source(), true, cached.fetchedAt(), cached.configured(), null);
}
try {
CachedAssistantProfile fetched = fetchRemoteAssistantProfile(normalizedUserId);
cache.put(cacheKey, fetched);
return new AssistantProfileResult(fetched.profile(), fetched.source(), false, fetched.fetchedAt(), fetched.configured(), null);
} catch (Exception exception) {
AssistantProfile fallbackProfile = cached != null ? cached.profile() : AssistantProfile.defaults();
return new AssistantProfileResult(
fallbackProfile,
cached != null ? "cache_fallback" : "default_fallback",
cached != null,
cached != null ? cached.fetchedAt() : null,
properties.isConfigured(),
exception.getMessage()
);
}
}
public void clearAssistantProfileCache(String userId) {
String normalizedUserId = normalizeNullable(userId);
if (normalizedUserId == null) {
cache.clear();
return;
}
cache.remove(getCacheKey(normalizedUserId));
}
private CachedAssistantProfile fetchRemoteAssistantProfile(String userId) {
long fetchedAt = System.currentTimeMillis();
if (!properties.isConfigured()) {
return new CachedAssistantProfile(AssistantProfile.defaults(), "default", fetchedAt, false);
}
HttpHeaders headers = new HttpHeaders();
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
parseAssistantProfileHeaders().forEach(headers::set);
if (StringUtils.hasText(properties.getApiToken())) {
headers.setBearerAuth(properties.getApiToken().trim());
}
RestTemplate restTemplate = createRestTemplate();
ResponseEntity<JsonNode> response;
if ("POST".equals(properties.normalizedMethod())) {
Map<String, Object> body = userId == null ? Map.of() : Map.of("userId", userId);
response = restTemplate.exchange(
properties.getApiUrl().trim(),
HttpMethod.POST,
new HttpEntity<>(body, headers),
JsonNode.class
);
} else {
String url = UriComponentsBuilder.fromHttpUrl(properties.getApiUrl().trim())
.queryParamIfPresent("userId", java.util.Optional.ofNullable(userId))
.build(true)
.toUriString();
response = restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), JsonNode.class);
}
AssistantProfile profile = sanitizeAssistantProfilePayload(response.getBody());
return new CachedAssistantProfile(profile, "remote_api", fetchedAt, true);
}
private RestTemplate createRestTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
int timeoutMs = (int) Duration.ofMillis(Math.max(properties.getTimeoutMs(), 1L)).toMillis();
factory.setConnectTimeout(timeoutMs);
factory.setReadTimeout(timeoutMs);
return new RestTemplate(factory);
}
private Map<String, String> parseAssistantProfileHeaders() {
if (!StringUtils.hasText(properties.getApiHeaders())) {
return Map.of();
}
try {
JsonNode root = objectMapper.readTree(properties.getApiHeaders());
if (!root.isObject()) {
return Map.of();
}
Map<String, String> headers = new HashMap<>();
Iterator<Map.Entry<String, JsonNode>> fields = root.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
String key = field.getKey() == null ? "" : field.getKey().trim();
String value = field.getValue() == null ? "" : field.getValue().asText("").trim();
if (!key.isEmpty() && !value.isEmpty()) {
headers.put(key, value);
}
}
return headers;
} catch (Exception exception) {
log.warn("[AssistantProfile] parse headers failed: {}", exception.getMessage());
return Map.of();
}
}
private AssistantProfile sanitizeAssistantProfilePayload(JsonNode payload) {
JsonNode source = pickAssistantProfilePayload(payload);
AssistantProfile profile = new AssistantProfile(
textValue(source, "documents"),
textValue(source, "email"),
firstNonBlank(
textValue(source, "agentName"),
textValue(source, "agent_name"),
textValue(source, "assistantName"),
textValue(source, "assistant_name"),
textValue(source, "nickname")
),
textValue(source, "nickname"),
textValue(source, "wxl"),
textValue(source, "mobile"),
textValue(source, "wx_code"),
textValue(source, "intro"),
textValue(source, "sign"),
textValue(source, "story")
);
return AssistantProfile.resolve(profile);
}
private JsonNode pickAssistantProfilePayload(JsonNode payload) {
if (payload == null || payload.isNull()) {
return objectMapper.createObjectNode();
}
JsonNode[] candidates = new JsonNode[] {
payload.path("assistantProfile"),
payload.path("profile"),
payload.path("data").path("assistantProfile"),
payload.path("data").path("profile"),
payload.path("data"),
payload
};
for (JsonNode candidate : candidates) {
if (candidate != null && candidate.isObject()) {
return candidate;
}
}
return objectMapper.createObjectNode();
}
private String textValue(JsonNode node, String fieldName) {
if (node == null || node.isNull()) {
return "";
}
JsonNode value = node.path(fieldName);
return value.isMissingNode() || value.isNull() ? "" : value.asText("").trim();
}
private String firstNonBlank(String... values) {
if (values == null) {
return "";
}
for (String value : values) {
if (StringUtils.hasText(value)) {
return value.trim();
}
}
return "";
}
private String getCacheKey(String userId) {
return userId == null ? "global" : userId;
}
private String normalizeNullable(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private record CachedAssistantProfile(AssistantProfile profile, String source, long fetchedAt, boolean configured) {
}
}

View File

@@ -0,0 +1,133 @@
package com.bigwo.javaserver.service;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
@Service
public class ChatContentSafetyService {
private static final String BRAND_NAMES = "德国PM|PM-International|PM公司|PM-FitLine|FitLine|一成系统|一成团队|大沃|PM营养素|PM健康|PM事业|PM直销|PM产品|PM";
private static final List<String> GENERIC_HARMFUL_WORDS = List.of(
"传销", "直销骗局", "非法直销", "变相传销", "网络传销", "精神传销",
"传销组织", "传销模式", "传销公司", "传销骗局", "传销陷阱", "传销套路",
"骗局", "骗子公司", "骗子", "骗人的", "诈骗", "行骗", "欺诈",
"虚假宣传", "夸大宣传", "虚假广告", "消费欺诈", "商业欺诈",
"非法集资", "非法经营", "非法营销", "非法组织", "非法敛财",
"涉嫌违法", "涉嫌传销", "疑似传销", "涉嫌欺诈", "涉嫌诈骗",
"违法经营", "违规经营", "违规操作",
"不正规", "不合法", "不合规", "不靠谱", "不正当",
"庞氏骗局", "老鼠会", "拉人头", "割韭菜", "资金盘", "层级分销",
"金字塔骗局", "金字塔模式", "发展下线", "上线下线",
"会员费骗局", "入门费骗局", "人头费骗局",
"智商税", "缴智商税", "交智商税", "收割", "被收割", "被割",
"洗脑", "被洗脑", "洗脑术", "洗脑营销", "精神控制",
"坑人", "坑钱", "坑货", "害人", "黑心", "黑幕",
"暴利", "暴利产品", "天价产品", "高价低质",
"被查处", "被取缔", "被罚款", "被处罚", "被举报",
"工商处罚", "市场监管处罚", "行政处罚",
"依法处理", "依法查处", "依法取缔",
"没有合法直销资质", "没有直销资质", "不具备直销资质",
"没有合法资质", "没有经营资质", "无合法资质",
"没有取得批准文号", "未取得批准文号", "没取得批准文号",
"没有取得资质", "未取得资质", "没取得资质",
"没有取得直销资质", "未取得直销资质",
"没有取得牌照", "未取得牌照",
"没有保健食品批准", "未取得保健食品",
"法律风险", "资金损失", "经济损失", "血本无归",
"不符合相关法律", "不符合法律法规", "违反法律法规",
"受害者", "受骗者", "上当受骗", "上当了", "被骗了",
"维权", "退款难", "投诉无门",
"臭名昭著", "声名狼藉", "劣迹斑斑", "口碑极差",
"过街老鼠", "千夫所指"
);
private static final List<String> BRAND_NEGATIVE_SUFFIXES = List.of(
"是传销", "属于传销", "涉嫌传销", "疑似传销", "就是传销",
"是骗局", "是骗子", "是骗人的", "在骗人", "骗钱",
"是非法的", "不合法", "不正规", "不靠谱", "不可信",
"有问题", "有争议", "有风险", "有隐患",
"已被查", "已被处罚", "被取缔", "被举报",
"在割韭菜", "在收割", "在洗脑", "在坑人", "在骗人",
"没有资质", "没有牌照", "没有直销牌照", "没有合法直销资质", "没有直销资质",
"没取得", "没有取得", "未取得",
"不符合法律", "不符合法规", "不符合相关法律",
"存在法律风险", "带来法律风险", "面临法律风险",
"存在资金损失", "带来资金损失", "带来经济损失",
"产品不行", "产品是假的", "产品没用", "产品有害",
"害了很多人", "坑了很多人", "骗了很多人",
"不值得信任", "不值得加入", "不建议加入", "不要加入",
"千万别信", "千万别买", "千万不要", "别上当",
"是假的", "没用", "没效果", "没什么用", "不管用", "是垃圾", "垃圾产品"
);
private static final List<String> NEGATIVE_BRAND_PREFIXES = List.of(
"传销组织", "传销公司", "传销骗局", "传销陷阱",
"骗局", "骗子公司", "骗人的",
"非法的", "不合法的", "不正规的", "不靠谱的",
"有争议的", "有问题的", "有风险的",
"坑人的", "害人的", "割韭菜的", "洗脑的",
"千万别信", "千万不要买", "千万不要加入",
"远离", "警惕", "小心", "当心", "注意"
);
private static final List<String> POSITIVE_PHRASES = List.of(
"不是传销", "并非传销", "并不是传销", "不属于传销",
"合法正规的直销企业", "合法正规直销企业", "合法直销公司", "合法直销企业",
"正规直销企业", "正规直销公司", "正规持牌直销公司", "正规持牌直销企业",
"拥有直销牌照", "持有直销牌照", "获得直销牌照",
"邓白氏AAA\\+", "邓白氏AAA", "AAA\\+认证", "AAA\\+信用",
"合法合规", "正规合法", "正规经营",
"业务覆盖全球", "覆盖.*国家",
"1993年成立", "成立于德国"
);
private static final Pattern BRAND_HARMFUL_PATTERN = Pattern.compile(
quoteJoin(GENERIC_HARMFUL_WORDS)
+ "|(?:" + BRAND_NAMES + ").*?(?:" + quoteJoin(BRAND_NEGATIVE_SUFFIXES) + ")"
+ "|(?:" + quoteJoin(NEGATIVE_BRAND_PREFIXES) + ").*?(?:" + BRAND_NAMES + ")",
Pattern.CASE_INSENSITIVE
);
private static final Pattern BRAND_POSITIVE_LEGALITY_PATTERN = Pattern.compile(
"(?:" + BRAND_NAMES + ").*?(?:" + String.join("|", POSITIVE_PHRASES) + ")"
+ "|(?:" + String.join("|", POSITIVE_PHRASES) + ").*?(?:" + BRAND_NAMES + ")",
Pattern.CASE_INSENSITIVE
);
private static final String TEXT_SAFE_REPLY = "德国PM是一家1993年成立于德国的合法直销公司获得邓白氏AAA+认证业务覆盖100多个国家和地区。如果你想了解更多可以问我关于PM公司的详细介绍哦。";
public boolean isBrandHarmful(String text) {
if (text == null || text.isBlank()) {
return false;
}
String normalized = text.replaceAll("\\s+", " ");
if (BRAND_POSITIVE_LEGALITY_PATTERN.matcher(normalized).find()) {
return false;
}
return BRAND_HARMFUL_PATTERN.matcher(normalized).find();
}
public String guardAssistantText(String text) {
String result = normalizeAssistantText(text);
if (isBrandHarmful(result)) {
return getTextSafeReply();
}
return result;
}
public String getTextSafeReply() {
return TEXT_SAFE_REPLY;
}
private String normalizeAssistantText(String text) {
return String.valueOf(text == null ? "" : text)
.replace("\r", " ")
.replaceAll("\n{2,}", "")
.replace("\n", " ")
.replaceAll("。{2,}", "")
.replaceAll("([])\\1+", "$1")
.replaceAll("([。!?;,])\\s*([。!?;,])", "$2")
.replaceAll("\\s+", " ")
.trim();
}
private static String quoteJoin(List<String> values) {
return values.stream().map(Pattern::quote).collect(Collectors.joining("|"));
}
}

View File

@@ -0,0 +1,440 @@
package com.bigwo.javaserver.service;
import com.bigwo.javaserver.exception.BadRequestException;
import com.bigwo.javaserver.model.ChatHistoryResponse;
import com.bigwo.javaserver.model.ChatSendResponse;
import com.bigwo.javaserver.model.ChatSessionState;
import com.bigwo.javaserver.model.ChatStartResponse;
import com.bigwo.javaserver.model.ChatStreamEvent;
import com.bigwo.javaserver.model.ChatSubtitle;
import com.bigwo.javaserver.model.CozeChatResult;
import com.bigwo.javaserver.model.KnowledgeSearchResult;
import com.bigwo.javaserver.model.LlmMessage;
import com.bigwo.javaserver.repository.ChatRepository;
import com.bigwo.javaserver.web.request.ChatSendRequest;
import com.bigwo.javaserver.web.request.ChatStartRequest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class ChatService {
private static final Logger log = LoggerFactory.getLogger(ChatService.class);
private static final Pattern FAST_GREETING_PATTERN = Pattern.compile("^(喂|你好|您好|嗨|哈喽|hello|hi|在吗|在不在|早上好|中午好|下午好|晚上好|早安|晚安)[,!。??~\\s]*[啊呀吧呢哦嗯嘛哈的了]*[!。??~]*$", Pattern.CASE_INSENSITIVE);
private static final long SESSION_TTL_MS = 30L * 60L * 1000L;
private final ConcurrentMap<String, ChatSessionState> chatSessions = new ConcurrentHashMap<>();
private final ChatRepository chatRepository;
private final CozeChatClient cozeChatClient;
private final ChatContentSafetyService chatContentSafetyService;
private final ContextKeywordTracker contextKeywordTracker;
private final KnowledgeRouteDecider knowledgeRouteDecider;
private final KnowledgeBaseRetrieverService knowledgeBaseRetrieverService;
public ChatService(
ChatRepository chatRepository,
CozeChatClient cozeChatClient,
ChatContentSafetyService chatContentSafetyService,
ContextKeywordTracker contextKeywordTracker,
KnowledgeRouteDecider knowledgeRouteDecider,
KnowledgeBaseRetrieverService knowledgeBaseRetrieverService
) {
this.chatRepository = chatRepository;
this.cozeChatClient = cozeChatClient;
this.chatContentSafetyService = chatContentSafetyService;
this.contextKeywordTracker = contextKeywordTracker;
this.knowledgeRouteDecider = knowledgeRouteDecider;
this.knowledgeBaseRetrieverService = knowledgeBaseRetrieverService;
}
public ChatStartResponse startSession(ChatStartRequest request) {
if (request == null || !StringUtils.hasText(request.sessionId())) {
throw new BadRequestException("sessionId is required");
}
if (!cozeChatClient.isConfigured()) {
throw new IllegalStateException("Coze 智能体未配置,请设置 COZE_API_TOKEN 和 COZE_BOT_ID");
}
ChatSessionState sessionState = buildChatSessionState(request.sessionId().trim(), safeSubtitles(request.voiceSubtitles()), normalizeNullable(request.userId()));
chatRepository.createSession(sessionState.getSessionId(), sessionState.getUserId(), "chat");
chatSessions.put(sessionState.getSessionId(), sessionState);
log.info("[Chat] Session started: {}, fromVoice: {}, voiceMessages: {}, summary: {}",
sessionState.getSessionId(),
sessionState.isFromVoice(),
sessionState.getVoiceMessages().size(),
sessionState.getHandoffSummary().isBlank() ? "no" : "yes");
return new ChatStartResponse(sessionState.getSessionId(), sessionState.getVoiceMessages().size(), sessionState.isFromVoice());
}
public ChatSendResponse sendMessage(ChatSendRequest request) {
ValidatedChatRequest validated = validateChatSendRequest(request);
ChatSessionState session = resolveSession(validated.sessionId(), validated.userId());
contextKeywordTracker.updateSession(validated.sessionId(), validated.message());
log.info("[Chat] User({}): {}", validated.sessionId(), validated.message());
chatRepository.addMessage(validated.sessionId(), "user", validated.message(), "chat_user");
String fastGreetingReply = buildFastGreetingReply(validated.message());
if (!fastGreetingReply.isEmpty()) {
chatRepository.addMessage(validated.sessionId(), "assistant", fastGreetingReply, "chat_bot");
return new ChatSendResponse(fastGreetingReply);
}
KnowledgeEvidenceContext kbEvidence = tryKnowledgeEvidence(validated.sessionId(), session, validated.message());
List<Map<String, Object>> extraMessages = session.getConversationId() == null ? buildInitialContextMessages(session) : List.of();
String cozeMessage = kbEvidence == null ? validated.message() : buildKnowledgePrompt(validated.message(), kbEvidence.evidence());
CozeChatResult result = cozeChatClient.chat(session.getUserId(), cozeMessage, session.getConversationId(), extraMessages);
String normalizedContent = chatContentSafetyService.guardAssistantText(result.content());
session.setConversationId(result.conversationId());
session.setHandoffSummaryUsed(true);
if (kbEvidence != null) {
session.setLastKbContext(validated.message(), System.currentTimeMillis());
}
if (!normalizedContent.isBlank()) {
String source = kbEvidence == null ? "chat_bot" : "search_knowledge";
chatRepository.addMessage(validated.sessionId(), "assistant", normalizedContent, source, kbEvidence == null ? null : "search_knowledge", kbEvidence == null ? null : kbEvidence.meta());
}
log.info("[Chat] Assistant({}): {} [source={}]", validated.sessionId(), abbreviate(normalizedContent), kbEvidence == null ? "chat_bot" : "search_knowledge");
return new ChatSendResponse(normalizedContent);
}
public Object getHistory(String sessionId) {
ChatSessionState session = chatSessions.get(sessionId);
if (session == null) {
return List.of();
}
return new ChatHistoryResponse(session.getConversationId(), session.isFromVoice());
}
public void deleteSession(String sessionId) {
chatSessions.remove(sessionId);
}
public void streamMessage(ChatSendRequest request, Consumer<ChatStreamEvent> eventConsumer) {
ValidatedChatRequest validated = validateChatSendRequest(request);
ChatSessionState session = resolveSession(validated.sessionId(), validated.userId());
contextKeywordTracker.updateSession(validated.sessionId(), validated.message());
log.info("[Chat][SSE] User({}): {}", validated.sessionId(), validated.message());
chatRepository.addMessage(validated.sessionId(), "user", validated.message(), "chat_user");
String fastGreetingReply = buildFastGreetingReply(validated.message());
if (!fastGreetingReply.isEmpty()) {
chatRepository.addMessage(validated.sessionId(), "assistant", fastGreetingReply, "chat_bot");
eventConsumer.accept(ChatStreamEvent.done(fastGreetingReply));
return;
}
try {
KnowledgeEvidenceContext kbEvidence = tryKnowledgeEvidence(validated.sessionId(), session, validated.message());
List<Map<String, Object>> extraMessages = session.getConversationId() == null ? buildInitialContextMessages(session) : List.of();
String cozeMessage = kbEvidence == null ? validated.message() : buildKnowledgePrompt(validated.message(), kbEvidence.evidence());
StringBuilder streamBuffer = new StringBuilder();
AtomicBoolean harmfulDetected = new AtomicBoolean(false);
CozeChatResult result = cozeChatClient.chatStream(
session.getUserId(),
cozeMessage,
session.getConversationId(),
extraMessages,
chunk -> {
if (harmfulDetected.get()) {
return;
}
streamBuffer.append(chunk);
if (chatContentSafetyService.isBrandHarmful(streamBuffer.toString())) {
harmfulDetected.set(true);
eventConsumer.accept(ChatStreamEvent.reset("content_safety"));
return;
}
eventConsumer.accept(ChatStreamEvent.chunk(chunk));
}
);
String finalContent = harmfulDetected.get() ? chatContentSafetyService.getTextSafeReply() : chatContentSafetyService.guardAssistantText(result.content());
session.setConversationId(result.conversationId());
session.setHandoffSummaryUsed(true);
if (kbEvidence != null) {
session.setLastKbContext(validated.message(), System.currentTimeMillis());
}
if (!finalContent.isBlank()) {
String source = kbEvidence == null ? "chat_bot" : "search_knowledge";
chatRepository.addMessage(validated.sessionId(), "assistant", finalContent, source, kbEvidence == null ? null : "search_knowledge", kbEvidence == null ? null : kbEvidence.meta());
}
log.info("[Chat][SSE] Assistant({}): {}{} [source=chat_bot]",
validated.sessionId(),
abbreviate(finalContent),
harmfulDetected.get() ? " [SAFE_REPLACED]" : "");
eventConsumer.accept(ChatStreamEvent.done(finalContent));
} catch (Exception exception) {
log.error("[Chat][SSE] Stream failed: {}", exception.getMessage(), exception);
eventConsumer.accept(ChatStreamEvent.error(exception.getMessage()));
}
}
@Scheduled(fixedDelay = 300000)
public void cleanupExpiredSessions() {
long now = System.currentTimeMillis();
for (Map.Entry<String, ChatSessionState> entry : chatSessions.entrySet()) {
ChatSessionState session = entry.getValue();
long lastActive = session.getLastActiveAt() > 0 ? session.getLastActiveAt() : session.getCreatedAt();
if (now - lastActive > SESSION_TTL_MS && chatSessions.remove(entry.getKey(), session)) {
log.info("[Chat] Session expired and cleaned: {}", entry.getKey());
}
}
contextKeywordTracker.cleanup();
}
private ChatSessionState resolveSession(String sessionId, String userId) {
ChatSessionState session = chatSessions.computeIfAbsent(sessionId, id -> buildChatSessionState(id, List.of(), userId));
if (userId != null) {
session.setUserId(userId);
session.setProfileUserId(userId);
}
session.touch();
return session;
}
private ChatSessionState buildChatSessionState(String sessionId, List<ChatSubtitle> voiceSubtitles, String userId) {
List<LlmMessage> voiceMessages = loadHandoffMessages(sessionId, voiceSubtitles);
seedKnowledgeKeywords(sessionId, voiceMessages);
String resolvedUserId = userId == null ? generatedUserId(sessionId) : userId;
long now = System.currentTimeMillis();
return new ChatSessionState(
sessionId,
resolvedUserId,
userId,
null,
"",
0L,
voiceMessages,
buildDeterministicHandoffSummary(voiceMessages),
false,
now,
now,
!voiceSubtitles.isEmpty() || !voiceMessages.isEmpty()
);
}
private List<LlmMessage> loadHandoffMessages(String sessionId, List<ChatSubtitle> voiceSubtitles) {
List<LlmMessage> dbHistory = chatRepository.getHistoryForLlm(sessionId, 20);
if (!dbHistory.isEmpty()) {
log.info("[Chat] Loaded {} messages from DB for session {}", dbHistory.size(), sessionId);
return dbHistory;
}
List<LlmMessage> voiceMessages = new ArrayList<>();
if (!voiceSubtitles.isEmpty()) {
List<ChatSubtitle> recentSubtitles = voiceSubtitles.subList(Math.max(voiceSubtitles.size() - 10, 0), voiceSubtitles.size());
for (ChatSubtitle subtitle : recentSubtitles) {
if (subtitle == null || !StringUtils.hasText(subtitle.text())) {
continue;
}
String role = "user".equalsIgnoreCase(normalizeNullable(subtitle.role())) ? "user" : "assistant";
voiceMessages.add(new LlmMessage(role, subtitle.text().trim()));
}
}
return voiceMessages;
}
private String buildDeterministicHandoffSummary(List<LlmMessage> messages) {
List<LlmMessage> normalizedMessages = messages.stream()
.filter(item -> item != null && ("user".equals(item.role()) || "assistant".equals(item.role())) && StringUtils.hasText(item.content()))
.skip(Math.max(messages.size() - 8, 0))
.toList();
if (normalizedMessages.isEmpty()) {
return "";
}
List<LlmMessage> userMessages = normalizedMessages.stream().filter(item -> "user".equals(item.role())).toList();
String currentQuestion = userMessages.isEmpty() ? "" : userMessages.getLast().content().trim();
String previousQuestion = userMessages.size() > 1 ? userMessages.get(userMessages.size() - 2).content().trim() : "";
String assistantFacts = normalizedMessages.stream()
.filter(item -> "assistant".equals(item.role()))
.skip(Math.max(normalizedMessages.stream().filter(item -> "assistant".equals(item.role())).count() - 2, 0))
.map(LlmMessage::content)
.filter(StringUtils::hasText)
.map(String::trim)
.map(value -> value.length() > 60 ? value.substring(0, 60) : value)
.reduce((left, right) -> left + "" + right)
.orElse("");
List<String> parts = new ArrayList<>();
if (!currentQuestion.isBlank()) {
parts.add("当前问题:" + currentQuestion);
}
if (!previousQuestion.isBlank() && !previousQuestion.equals(currentQuestion)) {
parts.add("上一轮关注:" + previousQuestion);
}
if (!assistantFacts.isBlank()) {
parts.add("已给信息:" + assistantFacts);
}
return String.join("", parts);
}
private KnowledgeEvidenceContext tryKnowledgeEvidence(String sessionId, ChatSessionState session, String message) {
String text = message == null ? "" : message.trim();
if (text.isBlank()) {
return null;
}
List<LlmMessage> context = buildKnowledgeContextMessages(sessionId, session);
if (!knowledgeRouteDecider.shouldForceKnowledgeRoute(text, context)) {
return null;
}
KnowledgeSearchResult result = knowledgeBaseRetrieverService.searchKnowledge(
text,
contextKeywordTracker.suggestContextTerms(sessionId, text),
null,
sessionId,
resolveProfileScope(session)
);
if (!result.hit()) {
return null;
}
String evidence = extractEvidenceText(result);
if (evidence.isBlank()) {
return null;
}
log.info("[Chat] KB evidence found, len={}", evidence.length());
return new KnowledgeEvidenceContext(evidence, buildKnowledgeMeta(result, text));
}
private List<LlmMessage> buildKnowledgeContextMessages(String sessionId, ChatSessionState session) {
List<LlmMessage> dbHistory = chatRepository.getHistoryForLlm(sessionId, 20);
String summary = session == null || session.getHandoffSummary() == null ? "" : session.getHandoffSummary().trim();
if (summary.isBlank() || (session != null && session.isHandoffSummaryUsed())) {
return dbHistory;
}
List<LlmMessage> context = new ArrayList<>();
context.add(new LlmMessage("assistant", "会话交接摘要:" + summary));
context.addAll(dbHistory);
return List.copyOf(context);
}
private String extractEvidenceText(KnowledgeSearchResult result) {
if (result == null || result.ragPayload() == null || result.ragPayload().isEmpty()) {
return "";
}
return result.ragPayload().stream()
.filter(item -> item != null && !"instruction".equals(item.get("kind")) && !"context".equals(item.get("kind")))
.map(item -> String.valueOf(item.getOrDefault("content", "")).trim())
.filter(StringUtils::hasText)
.reduce((left, right) -> left + "\n" + right)
.orElse("");
}
private Map<String, Object> buildKnowledgeMeta(KnowledgeSearchResult result, String originalText) {
Map<String, Object> meta = new LinkedHashMap<>();
meta.put("route", "search_knowledge");
meta.put("original_text", originalText);
meta.put("tool_name", "search_knowledge");
meta.put("tool_args", Map.of("query", originalText));
meta.put("source", result.source());
meta.put("original_query", result.originalQuery());
meta.put("rewritten_query", result.query());
meta.put("hit", result.hit());
meta.put("reason", result.reason());
meta.put("latency_ms", result.latencyMs());
if (result.evidencePack() != null && result.evidencePack().get("retrieval") instanceof Map<?, ?> retrieval) {
meta.put("selected_dataset_ids", retrieval.get("selected_dataset_ids"));
meta.put("selected_kb_routes", retrieval.get("selected_kb_routes"));
}
return meta;
}
private String buildKnowledgePrompt(String message, String evidence) {
return message + "\n\n【知识库参考信息】\n" + evidence + "\n\n请基于以上参考信息用简洁口语化的方式回答我的问题控制在200字以内。";
}
private String resolveProfileScope(ChatSessionState session) {
if (session == null) {
return "global";
}
if (StringUtils.hasText(session.getProfileUserId())) {
return session.getProfileUserId().trim();
}
if (StringUtils.hasText(session.getUserId())) {
return session.getUserId().trim();
}
return "global";
}
private void seedKnowledgeKeywords(String sessionId, List<LlmMessage> messages) {
List<LlmMessage> userMessages = messages.stream()
.filter(item -> item != null && "user".equals(item.role()) && StringUtils.hasText(item.content()))
.toList();
int start = Math.max(userMessages.size() - 6, 0);
for (LlmMessage message : userMessages.subList(start, userMessages.size())) {
contextKeywordTracker.updateSession(sessionId, message.content());
}
}
private List<Map<String, Object>> buildInitialContextMessages(ChatSessionState session) {
List<Map<String, Object>> extraMessages = new ArrayList<>();
String summary = session.getHandoffSummary() == null ? "" : session.getHandoffSummary().trim();
if (!summary.isBlank() && !session.isHandoffSummaryUsed()) {
extraMessages.add(messagePayload("assistant", "会话交接摘要:" + summary));
}
List<LlmMessage> voiceMessages = session.getVoiceMessages();
int start = Math.max(voiceMessages.size() - 6, 0);
for (LlmMessage message : voiceMessages.subList(start, voiceMessages.size())) {
extraMessages.add(messagePayload(message.role(), message.content()));
}
return extraMessages;
}
private Map<String, Object> messagePayload(String role, String content) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("role", role);
payload.put("content", content);
payload.put("content_type", "text");
return payload;
}
private String buildFastGreetingReply(String message) {
String text = message == null ? "" : message.trim();
if (!FAST_GREETING_PATTERN.matcher(text).matches()) {
return "";
}
return "你好😊我是大沃智能助手。你可以直接问我一成系统、德国PM产品、招商合作、营养科普等问题我会尽量快速给你准确回复。";
}
private ValidatedChatRequest validateChatSendRequest(ChatSendRequest request) {
String sessionId = request == null ? null : normalizeNullable(request.sessionId());
String message = request == null ? null : normalizeNullable(request.message());
String userId = request == null ? null : normalizeNullable(request.userId());
if (sessionId == null || message == null) {
throw new BadRequestException("sessionId and message are required");
}
return new ValidatedChatRequest(sessionId, message, userId);
}
private String normalizeNullable(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private String generatedUserId(String sessionId) {
return "user_" + sessionId.substring(0, Math.min(sessionId.length(), 12));
}
private List<ChatSubtitle> safeSubtitles(List<ChatSubtitle> subtitles) {
return subtitles == null ? List.of() : subtitles;
}
private String abbreviate(String value) {
if (value == null || value.length() <= 100) {
return value;
}
return value.substring(0, 100);
}
private record KnowledgeEvidenceContext(String evidence, Map<String, Object> meta) {
}
private record ValidatedChatRequest(String sessionId, String message, String userId) {
}
}

View File

@@ -0,0 +1,118 @@
package com.bigwo.javaserver.service;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class ContextKeywordTracker {
private static final long TTL_MS = 30L * 60L * 1000L;
private static final int MAX_KEYWORDS = 8;
private static final Pattern FOLLOW_UP_SIGNAL = Pattern.compile("(它|它的|他|他的|这个|那个|这款|那款|该产品|上面|刚才|再说|再次|强调一下|详细|继续|怎么吃|怎么用|怎么样|功效|成分|作用|原理|核心|区别|哪个好|为什么|什么意思|适合谁|多少钱|价格|副作用|正规吗|一天几次|每天几次|每日几次|给我介绍|介绍一下|说一下|讲一下)", Pattern.CASE_INSENSITIVE);
private final KnowledgeKeywordCatalog keywordCatalog;
private final KnowledgeQueryResolver knowledgeQueryResolver;
private final ConcurrentMap<String, SessionKeywords> sessionKeywords = new ConcurrentHashMap<>();
public ContextKeywordTracker(KnowledgeKeywordCatalog keywordCatalog, KnowledgeQueryResolver knowledgeQueryResolver) {
this.keywordCatalog = keywordCatalog;
this.knowledgeQueryResolver = knowledgeQueryResolver;
}
public void updateSession(String sessionId, String text) {
if (!StringUtils.hasText(sessionId)) {
return;
}
String normalizedText = knowledgeQueryResolver.normalizeKnowledgeText(text);
if (!StringUtils.hasText(normalizedText)) {
return;
}
List<String> keywords = extractKeywords(normalizedText);
if (keywords.isEmpty()) {
return;
}
SessionKeywords existing = sessionKeywords.get(sessionId);
List<String> merged = mergeKeywords(existing == null ? List.of() : existing.keywords(), keywords);
sessionKeywords.put(sessionId, new SessionKeywords(merged, System.currentTimeMillis()));
}
public List<String> getSessionKeywords(String sessionId) {
SessionKeywords data = sessionKeywords.get(sessionId);
if (data == null) {
return List.of();
}
if (System.currentTimeMillis() - data.lastUpdate() > TTL_MS) {
sessionKeywords.remove(sessionId, data);
return List.of();
}
return data.keywords();
}
public List<String> suggestContextTerms(String sessionId, String query) {
String normalized = knowledgeQueryResolver.normalizeKnowledgeText(query);
if (!StringUtils.hasText(normalized)) {
return List.of();
}
if (knowledgeQueryResolver.hasExplicitKnowledgeEntity(normalized)) {
return List.of();
}
boolean isShortGeneric = normalized.length() <= 20;
if (!FOLLOW_UP_SIGNAL.matcher(normalized).find() && !isShortGeneric) {
return List.of();
}
List<String> keywords = getSessionKeywords(sessionId);
if (keywords.isEmpty()) {
return List.of();
}
return List.of(keywords.getLast());
}
public void cleanup() {
long now = System.currentTimeMillis();
for (var entry : sessionKeywords.entrySet()) {
if (now - entry.getValue().lastUpdate() > TTL_MS) {
sessionKeywords.remove(entry.getKey(), entry.getValue());
}
}
}
private List<String> extractKeywords(String text) {
List<String> keywords = new ArrayList<>();
for (String keyword : keywordCatalog.knowledgeEntityKeywords()) {
if (text.contains(keyword)) {
keywords.add(keyword);
}
}
Set<String> deduped = new LinkedHashSet<>();
for (String keyword : keywords) {
if (StringUtils.hasText(keyword)) {
deduped.add(keyword.trim());
}
}
return List.copyOf(deduped);
}
private List<String> mergeKeywords(List<String> existing, List<String> incoming) {
List<String> merged = new ArrayList<>(existing == null ? List.of() : existing);
for (String keyword : incoming == null ? List.<String>of() : incoming) {
String normalized = StringUtils.hasText(keyword) ? keyword.trim() : "";
if (!StringUtils.hasText(normalized)) {
continue;
}
merged.removeIf(item -> item.equalsIgnoreCase(normalized));
merged.add(normalized);
}
int start = Math.max(merged.size() - MAX_KEYWORDS, 0);
return List.copyOf(merged.subList(start, merged.size()));
}
private record SessionKeywords(List<String> keywords, long lastUpdate) {
}
}

View File

@@ -0,0 +1,230 @@
package com.bigwo.javaserver.service;
import com.bigwo.javaserver.config.CozeProperties;
import com.bigwo.javaserver.model.CozeChatResult;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
@Service
public class CozeChatClient {
private static final Logger log = LoggerFactory.getLogger(CozeChatClient.class);
private final CozeProperties properties;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
public CozeChatClient(CozeProperties properties, ObjectMapper objectMapper) {
this.properties = properties;
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
}
public boolean isConfigured() {
return properties.isConfigured();
}
public CozeChatResult chat(String userId, String message, String conversationId, List<Map<String, Object>> extraMessages) {
try {
Map<String, Object> body = buildChatBody(userId, message, conversationId, extraMessages, false);
log.info("[CozeChat] Sending non-stream chat, userId={}, convId={}", userId, conversationId == null ? "new" : conversationId);
ResponseEntity<JsonNode> chatResponse = createRestTemplate(15000).exchange(
properties.normalizedBaseUrl() + "/v3/chat",
HttpMethod.POST,
new HttpEntity<>(body, defaultHeaders()),
JsonNode.class
);
JsonNode chatData = chatResponse.getBody() == null ? null : chatResponse.getBody().path("data");
String chatId = text(chatData, "id");
String conversation = text(chatData, "conversation_id");
if (chatId.isBlank() || conversation.isBlank()) {
throw new IllegalStateException("Coze chat creation failed: " + String.valueOf(chatResponse.getBody()));
}
for (int attempt = 0; attempt < 30; attempt++) {
Thread.sleep(2000L);
String retrieveUrl = UriComponentsBuilder.fromHttpUrl(properties.normalizedBaseUrl() + "/v3/chat/retrieve")
.queryParam("chat_id", chatId)
.queryParam("conversation_id", conversation)
.build(true)
.toUriString();
ResponseEntity<JsonNode> retrieveResponse = createRestTemplate(10000).exchange(
retrieveUrl,
HttpMethod.GET,
new HttpEntity<>(defaultHeaders()),
JsonNode.class
);
String status = text(retrieveResponse.getBody() == null ? null : retrieveResponse.getBody().path("data"), "status");
if ("completed".equals(status)) {
break;
}
if ("failed".equals(status) || "requires_action".equals(status)) {
throw new IllegalStateException("Coze chat ended with status: " + status);
}
}
String messageListUrl = UriComponentsBuilder.fromHttpUrl(properties.normalizedBaseUrl() + "/v3/chat/message/list")
.queryParam("chat_id", chatId)
.queryParam("conversation_id", conversation)
.build(true)
.toUriString();
ResponseEntity<JsonNode> messageResponse = createRestTemplate(10000).exchange(
messageListUrl,
HttpMethod.GET,
new HttpEntity<>(defaultHeaders()),
JsonNode.class
);
String content = "";
JsonNode data = messageResponse.getBody() == null ? null : messageResponse.getBody().path("data");
if (data != null && data.isArray()) {
for (JsonNode item : data) {
if ("assistant".equals(text(item, "role")) && "answer".equals(text(item, "type"))) {
content = text(item, "content");
break;
}
}
}
return new CozeChatResult(content, conversation);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Coze chat interrupted", exception);
} catch (Exception exception) {
throw new IllegalStateException(exception.getMessage(), exception);
}
}
public CozeChatResult chatStream(String userId, String message, String conversationId, List<Map<String, Object>> extraMessages, Consumer<String> onChunk) {
try {
Map<String, Object> body = buildChatBody(userId, message, conversationId, extraMessages, true);
String requestBody = objectMapper.writeValueAsString(body);
log.info("[CozeChat] Sending stream chat, userId={}, convId={}", userId, conversationId == null ? "new" : conversationId);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(properties.normalizedBaseUrl() + "/v3/chat"))
.timeout(Duration.ofSeconds(60))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + properties.getApiToken().trim())
.POST(HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8))
.build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() >= 400) {
throw new IllegalStateException("Coze stream failed with status: " + response.statusCode());
}
String resultConversationId = conversationId;
StringBuilder fullContent = new StringBuilder();
String currentEvent = "";
try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
String trimmed = line.trim();
if (trimmed.isEmpty()) {
currentEvent = "";
continue;
}
if (trimmed.startsWith("event:")) {
currentEvent = trimmed.substring(6).trim();
continue;
}
if (!trimmed.startsWith("data:")) {
continue;
}
String data = trimmed.substring(5).trim();
if ("\"[DONE]\"".equals(data) || "[DONE]".equals(data)) {
continue;
}
JsonNode parsed = objectMapper.readTree(data);
if ("conversation.chat.created".equals(currentEvent)) {
String candidateConversationId = text(parsed, "conversation_id");
if (!candidateConversationId.isBlank()) {
resultConversationId = candidateConversationId;
}
}
if ("conversation.message.delta".equals(currentEvent)
&& "assistant".equals(text(parsed, "role"))
&& "answer".equals(text(parsed, "type"))) {
String content = text(parsed, "content");
if (!content.isBlank()) {
fullContent.append(content);
onChunk.accept(content);
}
}
}
}
return new CozeChatResult(fullContent.toString(), resultConversationId);
} catch (Exception exception) {
log.error("[CozeChat] Stream error: {}", exception.getMessage(), exception);
throw new IllegalStateException(exception.getMessage(), exception);
}
}
private Map<String, Object> buildChatBody(String userId, String message, String conversationId, List<Map<String, Object>> extraMessages, boolean stream) {
List<Map<String, Object>> additionalMessages = new ArrayList<>();
additionalMessages.addAll(extraMessages);
additionalMessages.add(messagePayload("user", message));
Map<String, Object> body = new LinkedHashMap<>();
body.put("bot_id", properties.getBotId().trim());
body.put("user_id", userId);
body.put("additional_messages", additionalMessages);
body.put("stream", stream);
body.put("auto_save_history", true);
if (conversationId != null && !conversationId.isBlank()) {
body.put("conversation_id", conversationId);
}
return body;
}
private Map<String, Object> messagePayload(String role, String content) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("role", role);
payload.put("content", content);
payload.put("content_type", "text");
return payload;
}
private HttpHeaders defaultHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(properties.getApiToken().trim());
return headers;
}
private RestTemplate createRestTemplate(int timeoutMs) {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(timeoutMs);
factory.setReadTimeout(timeoutMs);
return new RestTemplate(factory);
}
private String text(JsonNode node, String fieldName) {
if (node == null || node.isMissingNode() || node.isNull()) {
return "";
}
JsonNode child = node.path(fieldName);
return child.isMissingNode() || child.isNull() ? "" : child.asText("");
}
}

View File

@@ -0,0 +1,153 @@
package com.bigwo.javaserver.service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.springframework.stereotype.Component;
@Component
public class FastAsrCorrector {
private static final Map<String, String> PHRASE_MAP = Map.ofEntries(
Map.entry("一城系统", "一成系统"), Map.entry("逸城系统", "一成系统"), Map.entry("一程系统", "一成系统"),
Map.entry("易成系统", "一成系统"), Map.entry("一诚系统", "一成系统"), Map.entry("亦成系统", "一成系统"),
Map.entry("艺成系统", "一成系统"), Map.entry("溢成系统", "一成系统"), Map.entry("义成系统", "一成系统"),
Map.entry("毅成系统", "一成系统"), Map.entry("怡成系统", "一成系统"), Map.entry("以成系统", "一成系统"),
Map.entry("已成系统", "一成系统"), Map.entry("亿成系统", "一成系统"), Map.entry("忆成系统", "一成系统"),
Map.entry("益成系统", "一成系统"), Map.entry("一乘系统", "一成系统"), Map.entry("一承系统", "一成系统"),
Map.entry("一丞系统", "一成系统"), Map.entry("一呈系统", "一成系统"), Map.entry("一澄系统", "一成系统"),
Map.entry("一橙系统", "一成系统"), Map.entry("一层系统", "一成系统"), Map.entry("一趁系统", "一成系统"),
Map.entry("一陈系统", "一成系统"), Map.entry("依成系统", "一成系统"), Map.entry("伊成系统", "一成系统"),
Map.entry("益生系统", "一成系统"), Map.entry("易诚系统", "一成系统"), Map.entry("易乘系统", "一成系统"),
Map.entry("一声系统", "一成系统"), Map.entry("亿生系统", "一成系统"), Map.entry("义诚系统", "一成系统"),
Map.entry("忆诚系统", "一成系统"), Map.entry("以诚系统", "一成系统"), Map.entry("盛咖学院", "盛咖学愿"),
Map.entry("圣咖学愿", "盛咖学愿"), Map.entry("盛卡学愿", "盛咖学愿"), Map.entry("营养配送系统", "NTC营养保送系统"),
Map.entry("营养输送系统", "NTC营养保送系统"), Map.entry("营养传送系统", "NTC营养保送系统"), Map.entry("营养传输系统", "NTC营养保送系统"),
Map.entry("暖炉原理", "火炉原理"), Map.entry("整应反应", "好转反应"), Map.entry("整健反应", "好转反应"),
Map.entry("排毒反应", "好转反应"), Map.entry("5加1", "5+1"), Map.entry("五加一", "5+1"),
Map.entry("起步三观", "起步三关"), Map.entry("起步三官", "起步三关"), Map.entry("doublepm", "德国PM"),
Map.entry("double pm", "德国PM"), Map.entry("DoublePM", "德国PM"), Map.entry("Double PM", "德国PM"),
Map.entry("DOUBLEPM", "德国PM"), Map.entry("DOUBLE PM", "德国PM"), Map.entry("基础三合一", "PM细胞营养素 基础套装"),
Map.entry("三合一基础套", "PM细胞营养素 基础套装"), Map.entry("大白小红小白", "PM细胞营养素 基础套装")
);
private static final Map<String, String> WORD_MAP = Map.ofEntries(
Map.entry("一城", "一成"), Map.entry("逸城", "一成"), Map.entry("一程", "一成"), Map.entry("易成", "一成"),
Map.entry("一诚", "一成"), Map.entry("亦成", "一成"), Map.entry("艺成", "一成"), Map.entry("溢成", "一成"),
Map.entry("义成", "一成"), Map.entry("毅成", "一成"), Map.entry("怡成", "一成"), Map.entry("以成", "一成"),
Map.entry("已成", "一成"), Map.entry("亿成", "一成"), Map.entry("忆成", "一成"), Map.entry("益成", "一成"),
Map.entry("一乘", "一成"), Map.entry("一承", "一成"), Map.entry("一丞", "一成"), Map.entry("一呈", "一成"),
Map.entry("一澄", "一成"), Map.entry("一橙", "一成"), Map.entry("一层", "一成"), Map.entry("一陈", "一成"),
Map.entry("依成", "一成"), Map.entry("伊成", "一成"), Map.entry("益生", "一成"), Map.entry("易诚", "一成"),
Map.entry("义诚", "一成"), Map.entry("忆诚", "一成"), Map.entry("以诚", "一成"), Map.entry("一声", "一成"),
Map.entry("亿生", "一成"), Map.entry("易乘", "一成"), Map.entry("大窝", "大沃"), Map.entry("大握", "大沃"),
Map.entry("大我", "大沃"), Map.entry("大卧", "大沃"), Map.entry("爱众享", "Ai众享"), Map.entry("艾众享", "Ai众享"),
Map.entry("哎众享", "Ai众享"), Map.entry("小洪", "小红"), Map.entry("小宏", "小红"), Map.entry("小鸿", "小红"),
Map.entry("大百", "大白"), Map.entry("大柏", "大白"), Map.entry("小百", "小白"), Map.entry("小柏", "小白"),
Map.entry("维适多", "小白"), Map.entry("营养配送", "营养保送"), Map.entry("营养输送", "营养保送"),
Map.entry("阿玉吠陀", "阿育吠陀"), Map.entry("阿育费陀", "阿育吠陀")
);
private static final Map<String, String> PRODUCT_ALIAS_MAP = Map.ofEntries(
Map.entry("小红", "小红产品 Activize Oxyplus"), Map.entry("Activize", "小红产品 Activize Oxyplus"),
Map.entry("Activize Oxyplus", "小红产品 Activize Oxyplus"), Map.entry("大白", "大白产品 Basics"),
Map.entry("Basics", "大白产品 Basics"), Map.entry("小白", "小白产品 Restorate"), Map.entry("Restorate", "小白产品 Restorate"),
Map.entry("FitLine", "PM-FitLine"), Map.entry("PM FitLine", "PM-FitLine"), Map.entry("PM细胞营养", "PM细胞营养素"),
Map.entry("PM营养素", "PM细胞营养素"), Map.entry("德国PM营养素", "PM细胞营养素")
);
private static final Pattern SYSTEM_PATTERN = Pattern.compile("[一二三四五六七八九十壹贰叁肆伍陆柒捌玖拾百千万亿兆零两几单双半多少全数整这那某每各以已亦艺毅怡逸溢义忆益伊依乙翼奕弈邑佚颐译蚁屹役疫裔翊熠旖漪倚绮峄羿轶弋驿懿肄翌苡圯佾诒铱仡易]{1,2}(?:成|城|程|诚|乘|承|丞|呈|澄|橙|层|陈|趁|撑|称|秤|盛|剩|胜|生|声)系统");
private static final Pattern FILLER_PREFIX = Pattern.compile("^[啊哦嗯呢呀哎诶额,。!?、\\s]+|[啊哦嗯呢呀哎诶额,。!?、\\s]+$");
private static final List<PatternReplacement> PHONETIC_CORRECTIONS = List.of(
new PatternReplacement("[细希西系息][胞苞包宝][抗康][氧养仰样][素速]", "细胞抗氧素"),
new PatternReplacement("[胶交教焦角][原圆远元源][蛋旦但淡][白百柏拍]", "胶原蛋白"),
new PatternReplacement("[白百柏][藜梨黎离莉丽力利理礼里][芦炉路鹿鲁卢露陆][醇纯唇]", "白藜芦醇"),
new PatternReplacement("[好号浩耗][转赚砖专][反返犯翻范][应映影英]", "好转反应"),
new PatternReplacement("[阿啊][育玉域遇雨宇御][吠废费肺飞非][陀驼拖脱托]", "阿育吠陀"),
new PatternReplacement("[骨谷古鼓][骼格隔革各阁][健剑键建][康慷抗]", "骨骼健康"),
new PatternReplacement("[活火获霍货][力利立厉励历丽][健剑键建见件]", "活力健"),
new PatternReplacement("[倍被背贝备辈杯北][力利立厉励历丽][健剑键建见件]", "倍力健"),
new PatternReplacement("[氨安暗按胺][基机鸡积极几计][酸算]", "氨基酸"),
new PatternReplacement("[益意易亿以][生声胜升省圣][菌军均君]", "益生菌"),
new PatternReplacement("[辅付副附府腐][酵教叫觉较角][素速诉]", "辅酵素"),
new PatternReplacement("[葡铺浦蒲][萄逃淘桃陶][籽子紫]", "葡萄籽"),
new PatternReplacement("[排牌拍派][毒独度读督][饮引印隐]", "排毒饮"),
new PatternReplacement("[乳如入][酪烙络落][煲包保宝]", "乳酪煲"),
new PatternReplacement("[草操曹][本苯奔][茶查差]", "草本茶"),
new PatternReplacement("[异意易][黄皇荒慌][酮铜同桐]", "异黄酮"),
new PatternReplacement("[骨谷古鼓][骼格隔革各阁][健剑键建见]", "骨骼健"),
new PatternReplacement("[舒书叔输][采彩菜蔡][健剑键建见]", "舒采健"),
new PatternReplacement("[衡横恒亨][醇纯唇春][饮引印隐]", "衡醇饮"),
new PatternReplacement("[纤先鲜仙][萃翠脆粹催]", "纤萃")
);
private final PinyinProductMatcher pinyinProductMatcher;
public FastAsrCorrector(PinyinProductMatcher pinyinProductMatcher) {
this.pinyinProductMatcher = pinyinProductMatcher;
}
public String correctAsrText(String text) {
if (text == null || text.isBlank()) {
return text == null ? "" : text;
}
String result = text.trim();
result = replaceOrderedMappings(result, PHRASE_MAP);
result = replaceOrderedMappings(result, WORD_MAP);
result = phoneticCorrectProducts(result);
result = pinyinProductMatcher.matchProducts(result);
result = SYSTEM_PATTERN.matcher(result).replaceAll("一成系统");
for (Map.Entry<String, String> entry : orderedEntries(PRODUCT_ALIAS_MAP).entrySet()) {
if (shouldExpandProductAlias(result, entry.getKey())) {
result = result.replace(entry.getKey(), entry.getValue());
}
}
return FILLER_PREFIX.matcher(result).replaceAll("").trim();
}
public String phoneticCorrectProducts(String text) {
String result = text;
for (PatternReplacement replacement : PHONETIC_CORRECTIONS) {
result = replacement.pattern().matcher(result).replaceAll(replacement.replacement());
}
return result;
}
private Map<String, String> orderedEntries(Map<String, String> source) {
LinkedHashMap<String, String> ordered = new LinkedHashMap<>();
source.entrySet().stream()
.sorted((left, right) -> Integer.compare(right.getKey().length(), left.getKey().length()))
.forEach(entry -> ordered.put(entry.getKey(), entry.getValue()));
return ordered;
}
private String replaceOrderedMappings(String text, Map<String, String> mapping) {
String result = text;
for (Map.Entry<String, String> entry : orderedEntries(mapping).entrySet()) {
if (result.contains(entry.getKey())) {
result = result.replace(entry.getKey(), entry.getValue());
}
}
return result;
}
private boolean shouldExpandProductAlias(String text, String alias) {
String target = PRODUCT_ALIAS_MAP.get(alias);
if (target != null && text.contains(target)) {
return false;
}
if (text.equals(alias)) {
return true;
}
String escapedAlias = Pattern.quote(alias);
Pattern pattern = Pattern.compile(escapedAlias + "(?=\\s|的|是|有|和|跟|及|怎么|为什么|适合谁|什么意思|怎么吃|怎么用|功效|成分|多少钱|哪里买|价格|副作用|区别|哪个好|是什么|呢|吗|呀|啊|哦|吧|啦|了|$)", Pattern.CASE_INSENSITIVE);
return pattern.matcher(text).find();
}
private record PatternReplacement(Pattern pattern, String replacement) {
private PatternReplacement(String regex, String replacement) {
this(Pattern.compile(regex), replacement);
}
}
}

View File

@@ -0,0 +1,666 @@
package com.bigwo.javaserver.service;
import com.bigwo.javaserver.config.KnowledgeBaseProperties;
import com.bigwo.javaserver.model.KnowledgeChunk;
import com.bigwo.javaserver.model.KnowledgeQueryInfo;
import com.bigwo.javaserver.model.KnowledgeSearchResult;
import com.bigwo.javaserver.model.RedisContextMessage;
import com.bigwo.javaserver.util.VolcSignerV4;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class KnowledgeBaseRetrieverService {
private static final Logger log = LoggerFactory.getLogger(KnowledgeBaseRetrieverService.class);
private static final String KNOWLEDGE_HOST = "api-knowledgebase.mlp.cn-beijing.volces.com";
private static final String SEARCH_PATH = "/api/knowledge/collection/search_knowledge";
private static final String RERANK_PATH = "/api/knowledge/service/rerank";
private static final String SERVICE_NAME = "air";
private static final String REGION = "cn-north-1";
private static final String SOURCE = "ark_knowledge";
private static final long KB_CACHE_TTL_MS = 5L * 60L * 1000L;
private static final long KB_CACHE_NO_HIT_TTL_MS = 10_000L;
private static final int KB_CACHE_MAX_SIZE = 200;
private static final String KB_INSTRUCTION = "用口语化、简洁的方式回答像朋友聊天一样自然地说出来。严格遵守以下规则1只能使用证据中出现的产品名严禁自创或概括出新的产品名称2产品的用法、剂量、剂型粒/勺/包必须原文引用不可凭记忆改写3不同产品的信息不可混用回答时明确指出是哪个产品4如果证据不足以回答直接说不确定不要编造5推荐产品时必须从证据中提取该产品的具体推荐理由包括但不限于产品定位与核心功效、关键成分及其作用、适用场景与人群、认证背书如科隆名单、运动员使用、用户见证或体感反馈用提取到的具体事实作为推荐依据不要用笼统的推荐语。";
private static final Map<String, String> DEFAULT_COLLECTION_MAP = Map.of(
"kb-ad2e0ea30902421b", "product_details",
"kb-d45d3056a7b75ac5", "faq_qa",
"kb-d0ef0b7b8f36a839", "science_training",
"kb-6a170ab7b1bc024f", "system_training"
);
private final KnowledgeBaseProperties properties;
private final VolcSignerV4 volcSignerV4;
private final ObjectMapper objectMapper;
private final KnowledgeQueryResolver knowledgeQueryResolver;
private final RedisContextStore redisContextStore;
private final HttpClient httpClient;
private final ConcurrentMap<String, CachedKnowledgeResult> memoryCache = new ConcurrentHashMap<>();
public KnowledgeBaseRetrieverService(
KnowledgeBaseProperties properties,
VolcSignerV4 volcSignerV4,
ObjectMapper objectMapper,
KnowledgeQueryResolver knowledgeQueryResolver,
RedisContextStore redisContextStore
) {
this.properties = properties;
this.volcSignerV4 = volcSignerV4;
this.objectMapper = objectMapper;
this.knowledgeQueryResolver = knowledgeQueryResolver;
this.redisContextStore = redisContextStore;
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
}
public boolean isConfigured() {
return properties.hasAccessKeys();
}
public KnowledgeSearchResult searchKnowledge(String query, List<String> contextTerms, List<String> datasetIds, String sessionId, String profileScope) {
long startTime = System.currentTimeMillis();
String rawQuery = StringUtils.hasText(query) ? query.trim() : "";
String rewrittenQuery = knowledgeQueryResolver.rewriteKnowledgeQuery(rawQuery, contextTerms == null ? List.of() : contextTerms);
String effectiveQuery = StringUtils.hasText(rewrittenQuery) ? rewrittenQuery : (StringUtils.hasText(rawQuery) ? rawQuery : "请介绍你们的产品和服务");
List<String> effectiveDatasetIds = normalizeDatasetIds(datasetIds);
String normalizedProfileScope = StringUtils.hasText(profileScope) ? profileScope.trim() : "global";
String cacheKey = buildCacheKey(effectiveQuery, effectiveDatasetIds, normalizedProfileScope);
KnowledgeSearchResult redisCached = redisContextStore.getKbCache(cacheKey, KnowledgeSearchResult.class);
if (redisCached != null) {
return withLatency(redisCached, System.currentTimeMillis() - startTime);
}
KnowledgeSearchResult memoryCached = getMemoryCache(cacheKey);
if (memoryCached != null) {
return withLatency(memoryCached, System.currentTimeMillis() - startTime);
}
RetrievalResult retrievalResult = retrieveChunks(effectiveQuery, effectiveDatasetIds, properties.getRetrievalTopK(), 0.01D);
if (retrievalResult.error() != null) {
KnowledgeSearchResult errorResult = buildFailureResult(
effectiveQuery,
rawQuery,
retrievalResult.error(),
System.currentTimeMillis() - startTime,
retrievalResult.latencyMs()
);
setMemoryCache(cacheKey, errorResult);
return errorResult;
}
if (retrievalResult.chunks().isEmpty()) {
String reason = retrievalResult.kbHasContent() ? "chunks_parse_failed" : "no_relevant_content";
KnowledgeSearchResult emptyResult = buildFailureResult(
effectiveQuery,
rawQuery,
reason,
System.currentTimeMillis() - startTime,
retrievalResult.latencyMs()
);
setMemoryCache(cacheKey, emptyResult);
return emptyResult;
}
List<KnowledgeChunk> rerankedChunks = rerankChunks(rawQuery.isBlank() ? effectiveQuery : rawQuery, retrievalResult.chunks(), properties.getRerankerTopN());
double topScore = rerankedChunks.isEmpty() ? 0D : rerankedChunks.getFirst().score();
double hitThreshold = properties.isEnableReranker() && StringUtils.hasText(properties.getRerankerModel()) ? 0.1D : 0.3D;
boolean hit = !rerankedChunks.isEmpty() && topScore >= hitThreshold;
String reason = hit ? "reranked_hit" : "below_threshold";
List<RedisContextMessage> conversationHistory = loadConversationHistory(sessionId);
List<Map<String, Object>> ragPayload = buildRagPayload(rerankedChunks, conversationHistory);
Map<String, Object> evidencePack = buildEvidencePack(
rawQuery.isBlank() ? effectiveQuery : rawQuery,
rawQuery.isBlank() ? effectiveQuery : rawQuery,
effectiveQuery,
ragPayload,
rerankedChunks,
conversationHistory,
hit,
topScore,
reason,
effectiveDatasetIds,
List.of()
);
KnowledgeSearchResult result = new KnowledgeSearchResult(
effectiveQuery,
rawQuery.isBlank() ? effectiveQuery : rawQuery,
hit,
reason,
retrievalResult.chunks(),
rerankedChunks,
ragPayload,
evidencePack,
topScore,
System.currentTimeMillis() - startTime,
retrievalResult.latencyMs(),
SOURCE,
retrievalResult.hasReferences(),
retrievalResult.usage()
);
setMemoryCache(cacheKey, result);
redisContextStore.setKbCache(cacheKey, result, result.hit());
return result;
}
public List<Map<String, Object>> buildRagPayload(List<KnowledgeChunk> rerankedChunks, List<RedisContextMessage> conversationHistory) {
List<Map<String, Object>> ragItems = new ArrayList<>();
Map<String, Object> instruction = new LinkedHashMap<>();
instruction.put("kind", "instruction");
instruction.put("title", "回答要求");
instruction.put("content", KB_INSTRUCTION);
instruction.put("source", "system");
instruction.put("confidence", 1);
ragItems.add(instruction);
if (conversationHistory != null && !conversationHistory.isEmpty()) {
List<String> lines = conversationHistory.stream()
.filter(item -> item != null && StringUtils.hasText(item.content()))
.map(item -> ("user".equalsIgnoreCase(item.role()) ? "用户" : "助手") + ": " + item.content().trim())
.toList();
if (!lines.isEmpty()) {
Map<String, Object> contextItem = new LinkedHashMap<>();
contextItem.put("kind", "context");
contextItem.put("title", "对话上下文");
contextItem.put("content", String.join("\n", lines));
contextItem.put("source", "conversation_history");
contextItem.put("confidence", 1);
ragItems.add(contextItem);
}
}
for (int index = 0; index < rerankedChunks.size(); index++) {
Map<String, Object> evidenceItem = buildEvidencePackItem(rerankedChunks.get(index), index);
if (evidenceItem != null) {
ragItems.add(evidenceItem);
}
}
return List.copyOf(ragItems);
}
public Map<String, Object> buildEvidencePack(
String query,
String originalQuery,
String rewrittenQuery,
List<Map<String, Object>> ragItems,
List<KnowledgeChunk> rerankedChunks,
List<RedisContextMessage> conversationHistory,
boolean hit,
double topScore,
String reason,
List<String> datasetIds,
List<String> selectedRoutes
) {
KnowledgeQueryInfo semanticQuery = knowledgeQueryResolver.resolveKnowledgeQuery(rewrittenQuery);
List<Map<String, Object>> instructions = ragItems == null ? List.<Map<String, Object>>of() : ragItems.stream()
.filter(item -> item != null && ("instruction".equals(item.get("kind")) || "回答要求".equals(item.get("title"))))
.<Map<String, Object>>map(item -> new LinkedHashMap<String, Object>(item))
.toList();
List<Map<String, Object>> contexts = ragItems == null ? List.<Map<String, Object>>of() : ragItems.stream()
.filter(item -> item != null && ("context".equals(item.get("kind")) || "对话上下文".equals(item.get("title"))))
.<Map<String, Object>>map(item -> new LinkedHashMap<String, Object>(item))
.toList();
List<Map<String, Object>> facts = new ArrayList<>();
for (int index = 0; index < rerankedChunks.size(); index++) {
Map<String, Object> item = buildEvidencePackItem(rerankedChunks.get(index), index);
if (item != null) {
facts.add(item);
}
}
Map<String, Object> queryInfo = new LinkedHashMap<>();
queryInfo.put("raw", safeText(query));
queryInfo.put("original", safeText(originalQuery));
queryInfo.put("rewritten", safeText(rewrittenQuery));
queryInfo.put("normalized", semanticQuery.normalizedText());
queryInfo.put("canonical_entity", semanticQuery.primaryEntity());
queryInfo.put("entities", semanticQuery.entities());
queryInfo.put("has_explicit_entity", semanticQuery.hasExplicitEntity());
queryInfo.put("has_knowledge_signal", semanticQuery.hasKnowledgeSignal());
Map<String, Object> retrieval = new LinkedHashMap<>();
retrieval.put("source", SOURCE);
retrieval.put("retrieval_mode", "raw");
retrieval.put("hit", hit);
retrieval.put("top_score", topScore);
retrieval.put("reason", reason);
retrieval.put("selected_dataset_ids", datasetIds == null ? List.of() : datasetIds);
retrieval.put("selected_kb_routes", selectedRoutes == null ? List.of() : selectedRoutes);
Map<String, Object> evidencePack = new LinkedHashMap<>();
evidencePack.put("version", 1);
evidencePack.put("query", queryInfo);
evidencePack.put("retrieval", retrieval);
evidencePack.put("instructions", instructions);
evidencePack.put("context", contexts);
evidencePack.put("facts", facts);
evidencePack.put("items", ragItems == null ? List.of() : ragItems);
evidencePack.put("conversation_count", conversationHistory == null ? 0 : conversationHistory.size());
return evidencePack;
}
private RetrievalResult retrieveChunks(String query, List<String> datasetIds, int topK, double threshold) {
if (!properties.hasAccessKeys()) {
log.warn("[KBRetriever] retrieve skipped: AK/SK not configured");
return new RetrievalResult(List.of(), "aksk_not_configured", 0L, false, Map.of(), false);
}
long startTime = System.currentTimeMillis();
String effectiveQuery = StringUtils.hasText(query) ? query.trim() : "请介绍你们的产品和服务";
ResolvedCollections resolvedCollections = resolveCollections(datasetIds);
if (resolvedCollections.collectionNames().isEmpty()) {
log.warn("[KBRetriever] retrieve skipped: no collections to search");
return new RetrievalResult(List.of(), "no_collections", 0L, false, Map.of(), false);
}
int perCollectionLimit = Math.max(3, (int) Math.ceil((double) Math.max(topK, 1) / resolvedCollections.collectionNames().size()));
List<CompletableFuture<List<KnowledgeChunk>>> futures = resolvedCollections.collectionNames().stream()
.map(name -> CompletableFuture.supplyAsync(() -> {
try {
return searchVikingDb(name, effectiveQuery, perCollectionLimit);
} catch (Exception exception) {
log.warn("[KBRetriever] search {} failed: {}", name, exception.getMessage());
return List.<KnowledgeChunk>of();
}
}))
.toList();
List<KnowledgeChunk> allChunks = futures.stream()
.map(CompletableFuture::join)
.flatMap(List::stream)
.sorted(Comparator.comparingDouble(KnowledgeChunk::score).reversed())
.limit(topK)
.toList();
List<KnowledgeChunk> filteredChunks = threshold > 0D
? allChunks.stream().filter(chunk -> chunk.score() >= threshold).toList()
: allChunks;
long latencyMs = System.currentTimeMillis() - startTime;
return new RetrievalResult(filteredChunks, null, latencyMs, !filteredChunks.isEmpty(), Map.of(), true);
}
private List<KnowledgeChunk> searchVikingDb(String collectionName, String query, int limit) {
Map<String, Object> requestBody = new LinkedHashMap<>();
requestBody.put("project", "default");
requestBody.put("name", collectionName);
requestBody.put("query", query);
requestBody.put("limit", limit);
requestBody.put("pre_processing", Map.of("need_instruction", true, "rewrite", false));
requestBody.put("dense_weight", 0.5);
requestBody.put("post_processing", Map.of("rerank_switch", false, "chunk_group", false));
String body = writeJson(requestBody);
HttpResponse<String> response = postJsonWithRetry(SEARCH_PATH, body, Duration.ofSeconds(10), "search:" + collectionName);
JsonNode root = readJson(response.body());
if (root.path("code").asInt(-1) != 0) {
log.warn("[KBRetriever] VikingDB search {} error: {}", collectionName, root.path("message").asText("unknown"));
return List.of();
}
JsonNode resultList = root.path("data").path("result_list");
if (!resultList.isArray()) {
return List.of();
}
List<KnowledgeChunk> chunks = new ArrayList<>();
int index = 0;
for (JsonNode item : resultList) {
String content = item.path("content").asText("").replace("<KBTable>", "").trim();
if (!StringUtils.hasText(content)) {
index++;
continue;
}
String id = firstNonBlank(item.path("chunk_id").asText(""), item.path("id").asText(""), "vdb_" + collectionName + "_" + index);
String docName = firstNonBlank(item.path("doc_info").path("doc_name").asText(""), item.path("doc_info").path("title").asText(""));
String chunkTitle = item.path("chunk_title").asText("");
Map<String, Object> metadata = objectMapper.convertValue(item.path("doc_info"), new TypeReference<Map<String, Object>>() { });
chunks.add(new KnowledgeChunk(id, content, item.path("score").asDouble(0D), docName, chunkTitle, metadata, collectionName, false));
index++;
}
return List.copyOf(chunks);
}
private List<KnowledgeChunk> rerankChunks(String query, List<KnowledgeChunk> chunks, int topN) {
if (chunks == null || chunks.isEmpty()) {
return List.of();
}
if (chunks.size() <= topN) {
return chunks;
}
if (!properties.isEnableReranker()) {
return chunks.stream().limit(topN).toList();
}
try {
List<Map<String, Object>> datas = chunks.stream().map(chunk -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("query", query);
item.put("content", safeText(chunk.content()));
item.put("title", safeText(chunk.docName()));
return item;
}).toList();
Map<String, Object> requestBody = new LinkedHashMap<>();
requestBody.put("rerank_model", properties.getRerankerModel());
requestBody.put("datas", datas);
HttpResponse<String> response = postJsonWithRetry(RERANK_PATH, writeJson(requestBody), Duration.ofSeconds(5), "rerank");
JsonNode root = readJson(response.body());
List<Double> scores = extractScores(root.path("data"));
if (scores.size() == chunks.size()) {
List<KnowledgeChunk> reranked = new ArrayList<>();
for (int index = 0; index < chunks.size(); index++) {
KnowledgeChunk chunk = chunks.get(index);
reranked.add(new KnowledgeChunk(
chunk.id(),
chunk.content(),
scores.get(index),
chunk.docName(),
chunk.chunkTitle(),
chunk.metadata(),
chunk.collection(),
true
));
}
return reranked.stream().sorted(Comparator.comparingDouble(KnowledgeChunk::score).reversed()).limit(topN).toList();
}
log.warn("[KBRetriever] rerank returned {} scores, expect {}", scores.size(), chunks.size());
return chunks.stream().limit(topN).toList();
} catch (Exception exception) {
log.warn("[KBRetriever] rerank failed: {}", exception.getMessage());
return chunks.stream().limit(topN).toList();
}
}
private HttpResponse<String> postJsonWithRetry(String path, String body, Duration timeout, String label) {
int maxRetries = 2;
for (int attempt = 0; ; attempt++) {
try {
Map<String, String> headers = volcSignerV4.signRequest(
"POST",
KNOWLEDGE_HOST,
path,
body,
properties.getAccessKeyId(),
properties.getSecretAccessKey(),
SERVICE_NAME,
REGION
);
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create("https://" + KNOWLEDGE_HOST + path))
.timeout(timeout)
.POST(HttpRequest.BodyPublishers.ofString(body));
headers.forEach((k, v) -> {
if (!"host".equalsIgnoreCase(k)) {
requestBuilder.header(k, v);
}
});
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
int status = response.statusCode();
if ((status == 429 || status >= 500) && attempt < maxRetries) {
sleepBackoff(attempt, label, status);
continue;
}
return response;
} catch (IOException exception) {
if (attempt >= maxRetries) {
throw new IllegalStateException(exception.getMessage(), exception);
}
sleepBackoff(attempt, label, 0);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException(exception.getMessage(), exception);
}
}
}
private void sleepBackoff(int attempt, String label, int status) {
long delayMs = (long) (300D * Math.pow(2, attempt));
if (status > 0) {
log.warn("[KBRetriever] {} got {}, retry after {}ms", label, status, delayMs);
}
try {
Thread.sleep(delayMs);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException(exception.getMessage(), exception);
}
}
private List<Double> extractScores(JsonNode dataNode) {
JsonNode scoresNode = dataNode.path("scores");
JsonNode effectiveNode = scoresNode.isArray() ? scoresNode : dataNode;
if (!effectiveNode.isArray()) {
return List.of();
}
List<Double> scores = new ArrayList<>();
for (JsonNode score : effectiveNode) {
scores.add(score.asDouble(0D));
}
return List.copyOf(scores);
}
private List<RedisContextMessage> loadConversationHistory(String sessionId) {
if (!properties.isEnableRedisContext() || !StringUtils.hasText(sessionId)) {
return List.of();
}
List<RedisContextMessage> history = redisContextStore.getRecentHistory(sessionId.trim(), 5);
return history == null ? List.of() : history;
}
private Map<String, Object> buildEvidencePackItem(KnowledgeChunk chunk, int index) {
if (chunk == null || !StringUtils.hasText(chunk.content())) {
return null;
}
Map<String, Object> item = new LinkedHashMap<>();
item.put("kind", "evidence");
item.put("title", firstNonBlank(chunk.docName(), chunk.chunkTitle(), "知识库片段" + (index + 1)));
item.put("content", chunk.content());
item.put("source", "kb_chunk");
item.put("source_chunk_id", safeText(chunk.id()));
item.put("doc_name", safeText(chunk.docName()));
item.put("chunk_title", safeText(chunk.chunkTitle()));
item.put("collection", safeText(chunk.collection()));
item.put("score", chunk.score());
item.put("confidence", clampConfidence(chunk.score()));
item.put("metadata", chunk.metadata() == null ? Map.of() : chunk.metadata());
return item;
}
private double clampConfidence(double score) {
if (Double.isNaN(score) || Double.isInfinite(score)) {
return 0D;
}
return Math.max(0D, Math.min(1D, score));
}
private KnowledgeSearchResult buildFailureResult(String query, String originalQuery, String reason, long latencyMs, Long retrievalLatencyMs) {
List<Map<String, Object>> ragPayload = List.of();
Map<String, Object> evidencePack = buildEvidencePack(
StringUtils.hasText(query) ? query : originalQuery,
StringUtils.hasText(originalQuery) ? originalQuery : query,
StringUtils.hasText(query) ? query : originalQuery,
ragPayload,
List.of(),
List.of(),
false,
0D,
reason,
List.of(),
List.of()
);
return new KnowledgeSearchResult(
StringUtils.hasText(query) ? query : originalQuery,
StringUtils.hasText(originalQuery) ? originalQuery : query,
false,
reason,
List.of(),
List.of(),
ragPayload,
evidencePack,
0D,
latencyMs,
retrievalLatencyMs,
SOURCE,
false,
Map.of()
);
}
private KnowledgeSearchResult withLatency(KnowledgeSearchResult result, long latencyMs) {
return new KnowledgeSearchResult(
result.query(),
result.originalQuery(),
result.hit(),
result.reason(),
result.chunks(),
result.rerankedChunks(),
result.ragPayload(),
result.evidencePack(),
result.topScore(),
latencyMs,
result.retrievalLatencyMs(),
result.source(),
result.hasReferences(),
result.usage()
);
}
private KnowledgeSearchResult getMemoryCache(String cacheKey) {
CachedKnowledgeResult cached = memoryCache.get(cacheKey);
if (cached == null) {
return null;
}
long ttl = cached.result().hit() ? KB_CACHE_TTL_MS : KB_CACHE_NO_HIT_TTL_MS;
if (System.currentTimeMillis() - cached.timestamp() > ttl) {
memoryCache.remove(cacheKey, cached);
return null;
}
return cached.result();
}
private void setMemoryCache(String cacheKey, KnowledgeSearchResult result) {
if (memoryCache.size() >= KB_CACHE_MAX_SIZE) {
String oldest = memoryCache.entrySet().stream()
.min(Comparator.comparingLong(entry -> entry.getValue().timestamp()))
.map(Map.Entry::getKey)
.orElse(null);
if (oldest != null) {
memoryCache.remove(oldest);
}
}
memoryCache.put(cacheKey, new CachedKnowledgeResult(result, System.currentTimeMillis()));
}
private String buildCacheKey(String query, List<String> datasetIds, String profileScope) {
List<String> orderedIds = datasetIds == null ? List.of() : datasetIds.stream().sorted().toList();
return "vdb2|raw|" + profileScope + "|" + safeText(query) + "|" + String.join(",", orderedIds);
}
private List<String> normalizeDatasetIds(List<String> datasetIds) {
List<String> input = datasetIds == null || datasetIds.isEmpty() ? properties.normalizedDatasetIds() : datasetIds;
Set<String> normalized = new LinkedHashSet<>();
for (String datasetId : input) {
if (StringUtils.hasText(datasetId)) {
normalized.add(datasetId.trim());
}
}
return List.copyOf(normalized);
}
private ResolvedCollections resolveCollections(List<String> datasetIds) {
Map<String, String> collectionMap = resolveCollectionMap();
List<String> allCollections = collectionMap.values().stream().distinct().sorted().toList();
if (datasetIds == null || datasetIds.isEmpty()) {
return new ResolvedCollections(allCollections, properties.normalizedDatasetIds());
}
List<String> selected = datasetIds.stream()
.map(collectionMap::get)
.filter(StringUtils::hasText)
.distinct()
.toList();
return selected.isEmpty() ? new ResolvedCollections(allCollections, datasetIds) : new ResolvedCollections(selected, datasetIds);
}
private Map<String, String> resolveCollectionMap() {
if (!StringUtils.hasText(properties.getCollectionMapJson())) {
return DEFAULT_COLLECTION_MAP;
}
try {
Map<String, String> parsed = objectMapper.readValue(properties.getCollectionMapJson(), new TypeReference<Map<String, String>>() { });
if (parsed == null || parsed.isEmpty()) {
return DEFAULT_COLLECTION_MAP;
}
Map<String, String> merged = new LinkedHashMap<>(DEFAULT_COLLECTION_MAP);
parsed.forEach((key, value) -> {
if (StringUtils.hasText(key) && StringUtils.hasText(value)) {
merged.put(key.trim(), value.trim());
}
});
return Map.copyOf(merged);
} catch (Exception exception) {
log.warn("[KBRetriever] parse collection map failed: {}", exception.getMessage());
return DEFAULT_COLLECTION_MAP;
}
}
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (Exception exception) {
throw new IllegalStateException(exception.getMessage(), exception);
}
}
private JsonNode readJson(String payload) {
try {
return objectMapper.readTree(payload);
} catch (Exception exception) {
throw new IllegalStateException(exception.getMessage(), exception);
}
}
private String safeText(String value) {
return value == null ? "" : value.trim();
}
private String firstNonBlank(String... values) {
if (values == null) {
return "";
}
for (String value : values) {
if (StringUtils.hasText(value)) {
return value.trim();
}
}
return "";
}
private record RetrievalResult(
List<KnowledgeChunk> chunks,
String error,
Long latencyMs,
boolean kbHasContent,
Map<String, Object> usage,
boolean hasReferences
) {
}
private record CachedKnowledgeResult(KnowledgeSearchResult result, long timestamp) {
}
private record ResolvedCollections(List<String> collectionNames, List<String> datasetIds) {
}
}

View File

@@ -0,0 +1,170 @@
package com.bigwo.javaserver.service;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Stream;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@Component
public class KnowledgeKeywordCatalog {
private static final List<String> COMPANY_ENTITY_KEYWORDS = List.of(
"德国PM", "德国PM公司", "PM公司", "PM国际", "PM-International", "PM-International AG",
"Rolf Sorg", "邓白氏", "AAA+", "DSN", "BFH", "ELAB", "科隆名单", "GMP", "Halal",
"宣明会", "世界宣明会", "斯派尔", "Speyer", "卢森堡", "培安(烟台)日用品有限责任公司", "培安烟台", "烟台"
);
private static final List<String> SYSTEM_ENTITY_KEYWORDS = List.of(
"一成系统", "一成AI", "一成Ai", "Ai众享", "AI众享", "数字化工作室", "盛咖学愿",
"三大平台", "四大AI生态", "四大Ai生态", "四大生态", "盟主社区", "AI智能生产力", "AI生产力",
"智能生产力", "行动圈", "批发式晋级", "身未动,梦已成", "身未动梦已成", "零成本高效率",
"零成本高效率运行", "赋能团队", "团队赋能", "团队发展", "文化解析", "故事分享", "自我介绍",
"邀约话术", "线上拓客", "线上成交", "陌生客户", "陌生人沟通"
);
private static final List<String> PRODUCT_ENTITY_KEYWORDS = List.of(
"PM产品", "PM-FitLine", "FitLine", "PM细胞营养素", "细胞营养素", "基础套装", "基础三合一", "三合一",
"基础二合一", "二合一", "小红产品", "小红", "艾特维", "Activize Oxyplus", "Activize Serum", "Activize",
"小红精华液", "大白产品", "大白", "倍适", "Basics", "Basic Power", "PowerCocktail", "小白产品",
"小白", "维适多", "Restorate", "儿童倍适", "PowerCocktail Junior", "NTC营养保送系统", "NTC",
"Nutrient Transport Concept", "火炉原理", "暖炉原理", "阿育吠陀", "Ayurveda", "Med Dental+", "草本护理牙膏",
"Men Face", "全效男士护肤抗衰乳霜", "CC-Cell", "CC-Cell胶囊", "CC-Cell乳霜", "CC套装", "CC胶囊",
"D-Drink", "小绿排毒饮", "14天排毒D饮料Plus", "ProShape Amino", "ProShape® Amino", "氨基酸", "支链氨基酸",
"BCAA", "MEN+", "Men+", "倍力健 MEN+", "倍力健", "小黑", "Herbal Tea", "草本茶", "Hair+", "med Hair+",
"口服发宝", "外用发健", "发宝", "Fitness-Drink", "运动饮料健康饮品", "运动饮料", "健康饮品", "TopShape",
"孅萃TopShape纤萃减肥", "纤萃减肥", "乐活50+", "Generation 50+", "Apple Antioxy", "苹果细胞抗氧素", "Antioxy",
"Zellschutz", "细胞抗氧素", "胶原蛋白肽", "胶原蛋白", "Women+", "乐活奶昔", "乐活", "乳清蛋白", "蛋白粉",
"乳酪煲", "乳酪饮品", "乳酪", "IB5", "口腔免疫喷雾", "Q10", "辅酵素", "Q10辅酵素氧修护", "关节套装",
"关节舒缓", "男士乳霜", "男士护肤", "去角质", "面膜", "叶黄素", "葡萄籽", "白藜芦醇", "益生菌", "肽美",
"德丽", "德维", "宝丽", "美固健", "CitrusCare", "NutriSunny", "Omega", "Young Care", "骨骼健", "顾心",
"舒采健", "衡醇饮", "小粉C", "小粉", "异黄酮", "Isoflavon", "眼霜", "Eye Cream", "洁面", "洁面乳",
"爽肤水", "Tonic", "餐代餐", "代餐奶昔", "美白霜", "修护膜"
);
private static final List<String> BUSINESS_ENTITY_KEYWORDS = List.of(
"PM事业", "做PM", "加入PM", "招商合作", "招商与代理", "招商稿", "招商", "招募", "代理", "代理商",
"代理政策", "加盟", "招商加盟", "合作加盟", "事业合作", "事业机会", "创业", "培训新人起步三关", "起步三关",
"培训打造精品会议具体如下", "精品会议", "会议组织", "培训成长上总裁", "成长上总裁"
);
private static final List<String> CANONICAL_KNOWLEDGE_TERMS = List.of(
"一成系统", "德国PM", "PM-FitLine", "FitLine", "PM细胞营养素", "NTC营养保送系统",
"Activize Oxyplus", "Basics", "Restorate", "儿童倍适", "火炉原理", "阿育吠陀"
);
private static final List<String> KNOWLEDGE_ROUTE_KEYWORDS_BASE = List.of(
"公司介绍", "公司背景", "公司实力", "公司地址", "公司电话", "联系方式", "总部", "分公司", "公司成立", "公司历史",
"公司规模", "全球布局", "信用评级", "行业排名", "获奖", "慈善", "社会责任", "不上市", "汽车奖励", "退休金", "旅行",
"福利", "企业性质", "发展历程", "产品介绍", "产品说明", "产品推荐", "产品有哪些", "产品列表", "产品图片", "产品外观",
"你们公司", "你们的公司", "你们产品", "你们的产品", "你们那个", "咱们公司", "咱们产品", "你们卖的", "你们的东西", "这个东西",
"这东西", "那玩意", "说说", "讲讲", "介绍介绍", "查查", "帮我查", "帮我问", "帮我看看", "有啥用", "啥意思", "有啥",
"营养素", "营养品", "保健品", "保健食品", "营养补充", "直销", "直销公司", "直销事业", "排毒", "排毒产品", "减肥",
"减肥产品", "瘦身", "护肤", "护肤品", "护发", "脱发", "掉发", "头发", "牙膏", "喷雾", "关节痛", "关节", "眼睛", "视力",
"叶黄素", "抗氧化", "抗衰", "抗衰老", "胶原蛋白", "免疫力", "能量", "抗疲劳", "疲劳", "睡眠", "失眠", "消化", "肠胃",
"便秘", "皮肤", "美容", "美白", "痘痘", "补铁", "补血", "骨密度", "补钙", "孕妇", "哺乳期", "怀孕", "孕期", "儿童", "小孩",
"孩子", "老人", "老年人", "过敏", "过敏体质", "事业", "怎么赚钱", "能赚钱吗", "收入", "奖金", "奖金制度", "正规性", "合法性",
"传销", "骗局", "骗子", "是不是传销", "直销还是传销", "合不合法", "正不正规", "正规吗", "合法吗", "层级分销", "非法集资", "拉人头",
"发展下线", "报单", "人头费", "安全吗", "合规吗", "有许可证吗", "好转反应", "整健反应", "排毒反应", "副作用", "不良反应", "皮肤发痒",
"皮肤微痒", "促销活动", "活动分数", "5+1活动分数", "5+1", "怎么吃", "怎么用", "怎么服用", "服用方法", "吃法", "用法", "用量",
"搭配", "空腹吃", "饭前吃", "饭后吃", "温水冲", "冷水冲", "一天吃几次", "一次吃多少", "功效", "作用", "成分", "原料", "配方",
"多少钱", "价格", "适合谁", "适用人群", "区别", "哪个好", "多久见效", "多久能见效", "哪里买", "怎么买", "保质期", "储存",
"效果怎么样", "有效果吗", "有没有用", "好不好", "靠谱吗", "值得买吗", "值得做吗", "真的有用吗", "真的假的", "有科学依据吗", "怎么加入",
"如何加入", "怎么报名", "怎么参与", "怎么做", "如何开始", "科普", "误区", "认证", "检测", "检测报告", "安全认证", "培训", "新人",
"起步", "成长", "高血压", "糖尿病", "胆固醇", "心脏病", "肾病", "肝病", "痛风", "贫血", "肥胖", "能吃吗", "可以吃吗", "能喝吗",
"可以喝吗", "能用吗", "可以用吗", "一起吃", "同时吃", "混着吃", "搭配吃", "吃药", "药物", "粉末", "粉剂", "粉状", "冲剂", "冲泡",
"片剂", "药片", "胶囊", "软胶囊", "颗粒", "口服液", "膏状", "不是的", "搞错了", "说错了", "弄错了", "不对", "不准确", "有误",
"不一样", "不一致", "不信", "骗人", "忽悠", "太夸张", "离谱", "你确定吗", "确定吗", "真的吗", "再查一下", "再确认一下", "重新查",
"核实一下", "谁说的", "有什么根据", "有证据吗", "有依据吗", "怎么可能", "不可能", "不会吧", "胡说", "瞎说", "乱说", "到底是", "究竟是",
"应该是", "明明是", "其实是", "本来是", "怎么变成", "不应该是", "冲着喝", "泡着喝", "直接吞", "是喝的", "是吃的"
);
private final List<String> knowledgeEntityKeywords;
private final List<String> knowledgeRouteKeywords;
private final List<String> canonicalKnowledgeTerms;
public KnowledgeKeywordCatalog() {
this.knowledgeEntityKeywords = uniqueKeywords(Stream.of(
COMPANY_ENTITY_KEYWORDS,
SYSTEM_ENTITY_KEYWORDS,
PRODUCT_ENTITY_KEYWORDS,
BUSINESS_ENTITY_KEYWORDS
).flatMap(List::stream).toList());
this.knowledgeRouteKeywords = uniqueKeywords(Stream.concat(this.knowledgeEntityKeywords.stream(), KNOWLEDGE_ROUTE_KEYWORDS_BASE.stream()).toList());
this.canonicalKnowledgeTerms = uniqueKeywords(CANONICAL_KNOWLEDGE_TERMS);
}
public boolean hasKeywordFromList(String text, List<String> keywords) {
return containsAny(text, keywords == null ? List.of() : keywords);
}
public boolean hasCanonicalKnowledgeTerm(String text) {
return containsAny(text, canonicalKnowledgeTerms);
}
public boolean hasKnowledgeRouteKeyword(String text) {
return containsAny(text, knowledgeRouteKeywords);
}
public List<String> extractKnowledgeEntityMatches(String text) {
String normalized = normalize(text);
if (normalized.isBlank()) {
return List.of();
}
List<String> matches = new ArrayList<>();
Set<String> seen = new LinkedHashSet<>();
for (String keyword : knowledgeEntityKeywords) {
String lowered = keyword.toLowerCase(Locale.ROOT);
if (normalized.contains(lowered) && seen.add(lowered)) {
matches.add(keyword);
}
}
return List.copyOf(matches);
}
public List<String> knowledgeEntityKeywords() {
return knowledgeEntityKeywords;
}
private boolean containsAny(String text, List<String> keywords) {
String normalized = normalize(text);
if (normalized.isBlank()) {
return false;
}
for (String keyword : keywords) {
if (normalized.contains(keyword.toLowerCase(Locale.ROOT))) {
return true;
}
}
return false;
}
private String normalize(String text) {
return StringUtils.hasText(text) ? text.trim().toLowerCase(Locale.ROOT) : "";
}
private List<String> uniqueKeywords(List<String> keywords) {
Set<String> seen = new LinkedHashSet<>();
List<String> result = new ArrayList<>();
for (String keyword : keywords) {
String normalized = keyword == null ? "" : keyword.trim();
if (!StringUtils.hasText(normalized)) {
continue;
}
String lowered = normalized.toLowerCase(Locale.ROOT);
if (seen.add(lowered)) {
result.add(normalized);
}
}
result.sort((left, right) -> {
if (right.length() != left.length()) {
return Integer.compare(right.length(), left.length());
}
return left.compareToIgnoreCase(right);
});
return List.copyOf(result);
}
}

View File

@@ -0,0 +1,327 @@
package com.bigwo.javaserver.service;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import com.bigwo.javaserver.model.KnowledgeQueryInfo;
@Service
public class KnowledgeQueryResolver {
private static final List<String> SPECIAL_ENTITY_ALIASES = List.of("太美", "态美");
private static final Pattern SPECIAL_ENTITY_GUARD = Pattern.compile("(?:怎么|咋|如何|能|可以|介绍|是什么|功效|成分|配方|多少钱|价格|适合|区别|吃|喝|用|服用|使用|产品|产品功效|产品介绍|哪里买|怎么买|查|问|看|搜|找|了解)|^(?:请问|麻烦你|帮我|帮忙|我想问|我想|我要|想问|问一下|问下|介绍一下|介绍下|查一下|查下|查查|看一下|看看|看下|能不能|可以|可不可以|能否).*(?:太美|态美)$");
private static final Pattern MULTI_SPACE = Pattern.compile("\\s+");
private static final Pattern POLITE_PREFIX = Pattern.compile("^(你|你们|帮我|麻烦你|请你?|我想|我要|能不能|可以|可不可以|能否)[给帮]?(我)?(查一下|查查|查下|搜一下|搜搜|搜下|找一下|找找|找下|看一下|看看|看下|说一下|说说|说下|讲一下|讲讲|讲下|介绍一下|介绍下)?");
private static final Pattern TAILING_INFO = Pattern.compile("(的)?(相关|详细)?(内容|信息|资料|介绍|说明)[。??]*$");
private static final List<RegexReplacement> ALIAS_REPLACEMENTS = List.of(
new RegexReplacement("一成[,、。!?\\s]+系统", "一成系统", 0),
new RegexReplacement("X{2}系统", "一成系统", Pattern.CASE_INSENSITIVE),
new RegexReplacement("[\\u4e00-\\u9fff]{1,3}(?:成|城|程|诚|乘|声|生)[,、\\s]*系统", "一成系统", 0),
new RegexReplacement("(?:一城|逸城|一程|易成|一诚|亦成|艺成|溢成|义成|毅成|怡成|以成|已成|亿成|忆成|益成|益生|易诚|义诚|忆诚|以诚|一声|亿生|易乘)系统", "一成系统", 0),
new RegexReplacement("大窝|大握|大我|大卧", "大沃", 0),
new RegexReplacement("盛咖学院|圣咖学愿|圣咖学院|盛卡学愿", "盛咖学愿", 0),
new RegexReplacement("爱众享|艾众享|哎众享", "Ai众享", 0),
new RegexReplacement("PM[-\\s]*Fitline|Fitline", "PM-FitLine", Pattern.CASE_INSENSITIVE),
new RegexReplacement("PM细胞营养|PM营养素|德国PM营养素", "PM细胞营养素", 0),
new RegexReplacement("NTC科技", "NTC营养保送系统", 0),
new RegexReplacement("NTC营养保送系统|NTC营养配送系统|NTC营养输送系统|NTC营养传送系统|NTC营养传输系统", "NTC营养保送系统", 0),
new RegexReplacement("Nutrient Transport Concept", "NTC营养保送系统", Pattern.CASE_INSENSITIVE),
new RegexReplacement("Activize Oxyplus|Activize", "Activize Oxyplus", Pattern.CASE_INSENSITIVE),
new RegexReplacement("Restorate", "Restorate", Pattern.CASE_INSENSITIVE),
new RegexReplacement("Basics", "Basics", Pattern.CASE_INSENSITIVE),
new RegexReplacement("活力健|火力剑|火力健", "Basics 活力健", 0),
new RegexReplacement("基础三合一|三合一基础套|大白小红小白|基础套装?", "PM细胞营养素 基础套装", 0),
new RegexReplacement("儿童倍适|儿童产品", "儿童倍适", 0),
new RegexReplacement("小红精华液", "Activize Serum 小红精华液", 0),
new RegexReplacement("小红产品", "小红产品 Activize Oxyplus", 0),
new RegexReplacement("大白产品", "大白产品 Basics", 0),
new RegexReplacement("小白产品", "小白产品 Restorate", 0),
new RegexReplacement("(?<!小红产品 )(?<!大白产品 )(?<!小白产品 )小红(?!精华)", "小红产品 Activize Oxyplus", 0),
new RegexReplacement("(?<!小红产品 )(?<!大白产品 )(?<!小白产品 )大白", "大白产品 Basics", 0),
new RegexReplacement("(?<!小红产品 )(?<!大白产品 )(?<!小白产品 )(?<!儿童)小白", "小白产品 Restorate", 0),
new RegexReplacement("维适多", "小白产品 Restorate", 0),
new RegexReplacement("暖炉原理|火炉原理", "火炉原理 暖炉原理", 0),
new RegexReplacement("阿育吠陀|Ayurveda", "阿育吠陀", Pattern.CASE_INSENSITIVE),
new RegexReplacement("好转反应|整健反应|调整反应", "好转反应 整健反应", 0),
new RegexReplacement("舒采健|Women\\+", "Women+ 舒采健", Pattern.CASE_INSENSITIVE),
new RegexReplacement("骨骼健", "骨骼健 关节套装", 0),
new RegexReplacement("顾心", "顾心 心脏保护", 0),
new RegexReplacement("衡醇饮|小粉C|小粉(?!红)", "衡醇饮 小粉C", 0),
new RegexReplacement("异黄酮", "异黄酮素 Isoflavon", 0),
new RegexReplacement("(?<!儿童)倍适(?!多)", "PowerCocktail 倍适", 0),
new RegexReplacement("PowerCocktail\\s*Junior", "PowerCocktail Junior 儿童倍适", Pattern.CASE_INSENSITIVE),
new RegexReplacement("(?<!Junior )(?<!倍适 )PowerCocktail", "PowerCocktail 倍适", Pattern.CASE_INSENSITIVE),
new RegexReplacement("苹果细胞抗氧素|苹果抗氧素", "Apple Antioxy Zellschutz 苹果细胞抗氧素", 0),
new RegexReplacement("(?:全效)?眼霜", "Eye Cream 全效眼霜", 0),
new RegexReplacement("(?:洁面乳|洗面奶|洁面)", "Cleansing Lotion 洁面乳", 0),
new RegexReplacement("爽肤水", "Tonic 爽肤水", 0),
new RegexReplacement("蛋白粉|餐代餐|代餐奶昔", "ProShape 全效纤体营养餐代餐", 0),
new RegexReplacement("小绿", "D-Drink 小绿 排毒饮", 0),
new RegexReplacement("(?<!小绿 )排毒饮", "D-Drink 排毒饮", 0),
new RegexReplacement("(?<!草本护理)牙膏", "草本护理牙膏 Med Dental+", 0),
new RegexReplacement("(?:口腔免疫喷雾|口腔喷雾|免疫喷雾)", "IB5 口腔免疫喷雾", 0),
new RegexReplacement("(?<!免疫)喷雾", "IB5 口腔免疫喷雾", 0),
new RegexReplacement("(?<!Herbal Tea )草本茶", "Herbal Tea 草本茶", 0),
new RegexReplacement("发宝|发健", "Med Hair+ 发宝", 0),
new RegexReplacement("(?:男士乳霜|男士护肤|男士面霜)", "Men Face 男士护肤乳霜", 0),
new RegexReplacement("纤萃", "TopShape 纤萃", 0),
new RegexReplacement("运动饮料", "Fitness-Drink 运动饮料", 0),
new RegexReplacement("(?<!Generation 50\\+ )乐活", "Generation 50+ 乐活", 0),
new RegexReplacement("(?<!Zellschutz )细胞抗氧素", "Zellschutz 细胞抗氧素", 0),
new RegexReplacement("CC套装|CC胶囊|CC乳霜", "CC-Cell", 0),
new RegexReplacement("(?<!Q10 )辅酵素", "Q10 辅酵素", 0),
new RegexReplacement("氧修护", "Q10 氧修护", 0),
new RegexReplacement("小黑", "MEN+ 倍力健 小黑", 0),
new RegexReplacement("(?<!MEN\\+ )倍力健", "MEN+ 倍力健", 0),
new RegexReplacement("(?<!ProShape Amino )氨基酸", "ProShape Amino 氨基酸", 0),
new RegexReplacement("BCAA", "ProShape Amino BCAA", Pattern.CASE_INSENSITIVE),
new RegexReplacement("(?<!胶原蛋白)胶原蛋白(?!肽)", "胶原蛋白肽", 0),
new RegexReplacement("乳酪煲|乳酪饮品|乳酪", "乳酪煲 乳酪饮品", 0),
new RegexReplacement("(?<!关节套装 )关节舒缓", "关节套装 关节舒缓", 0)
);
private final FastAsrCorrector fastAsrCorrector;
private final KnowledgeKeywordCatalog keywordCatalog;
public KnowledgeQueryResolver(FastAsrCorrector fastAsrCorrector, KnowledgeKeywordCatalog keywordCatalog) {
this.fastAsrCorrector = fastAsrCorrector;
this.keywordCatalog = keywordCatalog;
}
public String normalizeKnowledgeText(String text) {
return normalizeKnowledgeText(text, false);
}
public String normalizeKnowledgeText(String text, boolean skipAsrCorrection) {
String normalized = StringUtils.hasText(text) ? text.trim() : "";
if (normalized.isEmpty()) {
return "";
}
if (!skipAsrCorrection) {
normalized = fastAsrCorrector.correctAsrText(normalized);
}
normalized = applySpecialEntityAliases(normalized);
return collapseWhitespace(normalized);
}
public List<String> extractKnowledgeEntities(String text) {
return keywordCatalog.extractKnowledgeEntityMatches(normalizeKnowledgeText(text));
}
public boolean hasExplicitKnowledgeEntity(String text) {
return !extractKnowledgeEntities(text).isEmpty();
}
public String pickPrimaryKnowledgeEntity(List<String> entities) {
if (entities == null || entities.isEmpty()) {
return "";
}
return entities.stream()
.filter(StringUtils::hasText)
.map(String::trim)
.distinct()
.sorted((left, right) -> {
if (right.length() != left.length()) {
return Integer.compare(right.length(), left.length());
}
return left.toLowerCase(Locale.ROOT).compareTo(right.toLowerCase(Locale.ROOT));
})
.findFirst()
.orElse("");
}
public KnowledgeQueryInfo resolveKnowledgeQuery(String text) {
String rawText = StringUtils.hasText(text) ? text.trim() : "";
String normalizedText = normalizeKnowledgeText(rawText);
String rewrittenText = rewriteKnowledgeQuery(rawText, List.of());
List<String> entities = keywordCatalog.extractKnowledgeEntityMatches(normalizedText);
String primaryEntity = pickPrimaryKnowledgeEntity(entities);
boolean hasExplicitEntity = !entities.isEmpty();
boolean hasKnowledgeSignal = hasExplicitEntity || keywordCatalog.hasKnowledgeRouteKeyword(normalizedText);
return new KnowledgeQueryInfo(rawText, normalizedText, rewrittenText, List.copyOf(entities), primaryEntity, hasExplicitEntity, hasKnowledgeSignal);
}
public String rewriteKnowledgeQuery(String query, List<String> contextTerms) {
String originalQuery = StringUtils.hasText(query) ? query.trim() : "";
if (originalQuery.isEmpty()) {
return "";
}
String aliasNormalized = normalizeKnowledgeQueryAlias(originalQuery);
String deterministicQuery = buildDeterministicKnowledgeQuery(aliasNormalized);
String rewritten = deterministicQuery != null ? deterministicQuery : applyKnowledgeQueryAnchor(aliasNormalized);
rewritten = enrichQueryWithContext(rewritten, contextTerms);
return sanitizeRewrittenQuery(rewritten);
}
public String normalizeKnowledgeQueryAlias(String query) {
String normalized = normalizeKnowledgeText(query)
.replaceAll("^[啊哦嗯呢呀哎诶额,。!?、\\s]+", "")
.replaceAll("[啊哦嗯呢呀哎诶额,。!?、\\s]+$", "");
normalized = POLITE_PREFIX.matcher(normalized).replaceFirst("");
normalized = TAILING_INFO.matcher(normalized).replaceFirst("");
for (RegexReplacement replacement : ALIAS_REPLACEMENTS) {
normalized = replacement.pattern().matcher(normalized).replaceAll(replacement.replacement());
}
return collapseWhitespace(normalized);
}
public String sanitizeRewrittenQuery(String query) {
String cleaned = StringUtils.hasText(query) ? query.trim() : "";
if (cleaned.isEmpty()) {
return "";
}
cleaned = cleaned.replaceAll("[啊哦嗯呢呀哎诶额嘛吧啦哇噢]+", " ");
cleaned = cleaned.replaceAll("[,。!?!?\\s]{2,}", " ");
cleaned = cleaned.replaceAll("(.{3,}?)[?!。,,\\s]+\\1", "$1");
String[] parts = cleaned.split("\\s+");
Set<String> seen = new LinkedHashSet<>();
List<String> deduped = new ArrayList<>();
for (String part : parts) {
String normalized = part == null ? "" : part.trim();
if (!StringUtils.hasText(normalized) || !seen.add(normalized)) {
continue;
}
deduped.add(normalized);
}
cleaned = String.join(" ", deduped).trim();
if (cleaned.length() > 80) {
cleaned = cleaned.substring(0, 80).trim();
int lastSpace = cleaned.lastIndexOf(' ');
if (lastSpace > 0) {
cleaned = cleaned.substring(0, lastSpace).trim();
}
}
return cleaned;
}
public String applyKnowledgeQueryAnchor(String query) {
String anchoredQuery = StringUtils.hasText(query) ? query.trim() : "";
if (anchoredQuery.contains("一成系统") && !Pattern.compile("(德国PM|PM事业|赋能工具|Ai众享|数字化工作室|盛咖学愿)", Pattern.CASE_INSENSITIVE).matcher(anchoredQuery).find()) {
anchoredQuery = anchoredQuery.replace("一成系统", "一成系统 德国PM事业赋能工具");
}
return anchoredQuery.trim();
}
public String buildDeterministicKnowledgeQuery(String query) {
String text = StringUtils.hasText(query) ? query.trim() : "";
if (text.isEmpty()) {
return null;
}
if (Pattern.compile("(一成系统|Ai众享|数字化工作室|盛咖学愿|三大平台|四大Ai生态|四大生态|智能生产力)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
if (Pattern.compile("(核心竞争力|竞争力|核心优势|优势)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "一成系统 核心竞争力 三大平台 四大Ai生态 零成本高效率";
}
if (Pattern.compile("(发展|怎么做|怎么用|如何用|如何做|关键点|关键|方法|步骤)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "一成系统 发展PM事业 三大平台 四大Ai生态 零成本高效率 全球市场";
}
if (Pattern.compile("(线上拓客|拓客|成交|成交率|陌生客户|陌生人沟通|邀约)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "一成系统 PM事业 线上拓客 成交 邀约 三大平台 四大Ai生态";
}
if (Pattern.compile("(ai智能生产力|ai生产力|智能生产力|团队效率|赋能团队|团队赋能)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "一成系统 AI智能生产力 赋能团队 三大平台 四大Ai生态";
}
if (Pattern.compile("(一部手机|0门槛|零门槛|0成本|零成本|足不出户|梦想横扫全球|一部手机做天下)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "一成系统 软件赋能 0成本高效率 一部手机做天下 足不出户梦想横扫全球";
}
if (Pattern.compile("(故事|自我介绍|分享)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "一成系统 PM事业 故事分享 自我介绍";
}
if (Pattern.compile("(邀约|话术)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "一成系统 邀约话术";
}
if (text.contains("文化")) {
return "一成系统 文化解析";
}
if (Pattern.compile("(赋能团队|团队发展|AI赋能|ai赋能)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "一成系统用AI赋能团队发展";
}
if (Pattern.compile("(三大平台|四大生态|Ai生态)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "一成系统 三大平台 四大Ai生态";
}
return "一成系统 德国PM事业发展的强大赋能工具 三大平台 四大Ai生态";
}
if (Pattern.compile("(一部手机做天下|一部手机即可运营全球市场|0门槛启动|零门槛启动|0成本高效率|零成本高效率|足不出户梦想横扫全球|身未动,?梦已成|批发式晋级)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "一成系统 软件赋能 德国PM事业 0成本高效率 一部手机做天下 身未动梦已成 批发式晋级";
}
if (Pattern.compile("(身未动,?梦已成|批发式晋级)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "一成系统 身未动梦已成 批发式晋级 三大平台 四大Ai生态";
}
if (text.contains("行动圈")) {
return "一成系统 行动圈 数字化工作室 团队管理 目标考核";
}
if (text.contains("盟主社区")) {
return "一成系统 盟主社区 AI众享 社区盟主 引流 转化";
}
if (Pattern.compile("(招商|代理|加盟|事业机会|招商稿|代理政策)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "一成系统 PM事业 招商与代理 软件赋能 0成本高效率";
}
if (Pattern.compile("(如何发展PM事业|怎么发展PM事业|PM事业发展逻辑|介绍PM事业|两分钟介绍PM事业)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "一成系统 PM事业 发展逻辑 商机 价值 软件赋能 三大平台 四大Ai生态 0成本高效率";
}
if (Pattern.compile("(为什么选择德国PM|为何选择德国PM|为什么选德国PM|为什么选PM|为何选PM)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "一成系统 德国PM 选择理由 公司实力 产品优势 软件赋能 0成本高效率";
}
if (Pattern.compile("(陌生客户|陌生人).*(沟通|开口|邀约|交流|切入).*(PM事业|德国PM|PM)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "一成系统 PM事业 陌生客户 沟通 邀约 话术 软件赋能";
}
if (Pattern.compile("(线上拓客|线上成交|线上开发客户|线上获客|线上成交率)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "一成系统 PM事业 线上拓客 成交 获客";
}
if (Pattern.compile("(一成AI|AI落地|ai落地|转观念|落地对比)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "2026一成Ai落地对比与转观念";
}
if (Pattern.compile("(传销|骗局|骗子|正规吗|合法吗|正不正规|合不合法|是不是传销|直销还是传销|层级分销|非法集资|拉人头|下线|发展下线|报单|人头费)", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "德国PM 1993年 创立 100多个国家 FitLine 公司介绍 邓白氏 99分 AAA+ 合法直销";
}
if (Pattern.compile("暖炉原理", Pattern.CASE_INSENSITIVE).matcher(text).find()) {
return "火炉原理";
}
return null;
}
private String applySpecialEntityAliases(String text) {
String compact = text.replaceAll("[\\s,。!??!~~、]", "");
if ("太美".equals(compact) || "态美".equals(compact) || SPECIAL_ENTITY_GUARD.matcher(text).find()) {
String result = text;
for (String alias : SPECIAL_ENTITY_ALIASES) {
result = result.replace(alias, "肽美");
}
return result;
}
return text;
}
private String enrichQueryWithContext(String query, List<String> contextTerms) {
if (contextTerms == null || contextTerms.isEmpty()) {
return query;
}
Set<String> terms = new LinkedHashSet<>();
for (String term : contextTerms) {
if (StringUtils.hasText(term) && !query.contains(term.trim())) {
terms.add(term.trim());
}
}
if (terms.isEmpty()) {
return query;
}
return (query + " " + String.join(" ", terms)).trim();
}
private String collapseWhitespace(String text) {
return MULTI_SPACE.matcher(StringUtils.hasText(text) ? text.trim() : "").replaceAll(" ").trim();
}
private record RegexReplacement(Pattern pattern, String replacement) {
private RegexReplacement(String regex, String replacement, int flags) {
this(Pattern.compile(regex, flags), replacement);
}
}
}

View File

@@ -0,0 +1,63 @@
package com.bigwo.javaserver.service;
import com.bigwo.javaserver.model.LlmMessage;
import java.util.List;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class KnowledgeRouteDecider {
private static final Pattern FOLLOW_UP_EXACT = Pattern.compile("^(详细|详细说说|详细查一下|展开说说|继续说|继续讲|介绍一下|给我介绍一下|详细介绍一下|继续介绍一下|怎么用|怎么操作|怎么配置|适合谁|有什么区别|费用多少|价格多少|怎么申请|怎么开通|是什么|什么意思|地址在哪|公司地址在哪|电话多少|公司电话多少|联系方式|公司联系方式|具体政策|具体内容|怎么吃|功效是什么|有什么功效|成分是什么|有什么成分|多少钱|哪里买|怎么买|配方|原理是什么|有什么好处|怎么服用|适合什么人|规格是什么|什么规格|包装是什么|什么包装|剂型是什么|什么剂型|形态是什么|什么形态|一天几次|每天几次|每日几次|一天吃几次|每天吃几次|一天服用几次|每日服用几次)$", Pattern.CASE_INSENSITIVE);
private static final Pattern FOLLOW_UP_CORRECTION = Pattern.compile("(不是的|不是啊|不是不是|才不是|没有啊|哪里有|搞错|说错|弄错|记错|讲错|答错|错了|搞混|搞反|记岔|说反|弄反|答非所问|不对|不准确|不正确|有误|有问题|不一样|不一致|我听说|我记得|前后矛盾|自相矛盾|不信|骗人|忽悠|吹牛|太夸张|离谱|再查|再看看|再确认|再核实|重新查|重新回答|核实一下|查清楚|搞清楚|好像不是|好像不太对|我觉得不|恐怕不是|谁说的|谁告诉你|你从哪|有什么根据|有什么依据|你确定|确定吗|真的吗|当真|真的假的|怎么可能|不可能|不会吧|开玩笑|胡说|瞎说|乱说|粉末|粉剂|粉状|冲剂|冲泡|片剂|药片|胶囊|软胶囊|颗粒|口服液|喷雾剂|乳霜|乳液|凝胶|膏体|膏状|冲着喝|泡着喝|直接吞|是喝的|是吃的|到底是|究竟是|应该是|明明是|本来是|其实是|怎么变成|不应该是)", Pattern.CASE_INSENSITIVE);
private static final Pattern SUBJECT_ACTION = Pattern.compile("^(这个|那个|它|它的|他|他的|该|这款|那款|该系统|这个系统|那个系统|这个功能|那个功能|这个产品|那个产品|这个公司|那家公司|这个政策|那个政策|这个培训|那个培训)(的)?(详细|详细说说|详细查一下|展开说说|继续说|继续讲|介绍一下|给我介绍一下|详细介绍一下|继续介绍一下|怎么用|怎么操作|怎么配置|适合谁|有什么区别|费用多少|价格多少|怎么申请|怎么开通|是什么|什么意思|地址在哪|公司地址在哪|电话多少|公司电话多少|联系方式|公司联系方式|具体政策|具体内容|怎么吃|功效是什么|有什么功效|成分是什么|有什么成分|多少钱|哪里买|怎么买|配方|原理是什么|有什么好处|怎么服用|适合什么人|规格是什么|什么规格|包装是什么|什么包装|剂型是什么|什么剂型|形态是什么|什么形态|一天几次|每天几次|每日几次|一天吃几次|每天吃几次|一天服用几次|每日服用几次)$", Pattern.CASE_INSENSITIVE);
private final KnowledgeKeywordCatalog keywordCatalog;
private final KnowledgeQueryResolver knowledgeQueryResolver;
public KnowledgeRouteDecider(KnowledgeKeywordCatalog keywordCatalog, KnowledgeQueryResolver knowledgeQueryResolver) {
this.keywordCatalog = keywordCatalog;
this.knowledgeQueryResolver = knowledgeQueryResolver;
}
public boolean shouldForceKnowledgeRoute(String userText, List<LlmMessage> context) {
String text = StringUtils.hasText(userText) ? userText.trim() : "";
if (text.isEmpty()) {
return false;
}
if (hasKnowledgeKeyword(text)) {
return true;
}
if (!isKnowledgeFollowUp(text)) {
return false;
}
String recentContextText = (context == null ? List.<LlmMessage>of() : context).stream()
.skip(Math.max((context == null ? 0 : context.size()) - 6, 0))
.map(item -> item == null ? "" : StringUtils.hasText(item.content()) ? item.content().trim() : "")
.filter(StringUtils::hasText)
.reduce((left, right) -> left + "\n" + right)
.orElse("");
return hasKnowledgeKeyword(recentContextText);
}
public boolean hasKnowledgeKeyword(String text) {
String normalized = knowledgeQueryResolver.normalizeKnowledgeText(text).replaceAll("\\s+", "");
return keywordCatalog.hasKnowledgeRouteKeyword(normalized) || knowledgeQueryResolver.hasExplicitKnowledgeEntity(normalized);
}
public boolean isKnowledgeFollowUp(String text) {
String normalized = StringUtils.hasText(text) ? text.trim() : "";
if (normalized.isEmpty()) {
return false;
}
normalized = normalized.replaceAll("[,。!??~\\s]+$", "").replaceAll("^(那你|那再|那(?!个|种|款|些)|你再|再来|再|麻烦你|帮我)[,、\\s]*", "");
if (FOLLOW_UP_EXACT.matcher(normalized).matches()) {
return true;
}
if (FOLLOW_UP_CORRECTION.matcher(normalized).find()) {
return true;
}
return SUBJECT_ACTION.matcher(normalized).matches();
}
}

View File

@@ -0,0 +1,181 @@
package com.bigwo.javaserver.service;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Component
public class PinyinProductMatcher {
private static final Logger log = LoggerFactory.getLogger(PinyinProductMatcher.class);
private static final Pattern CHINESE_WINDOW_PATTERN = Pattern.compile("^[\\u4e00-\\u9fff]+$");
private static final Map<String, String> PINYIN_MAP = Map.ofEntries(
Map.entry("", "a"), Map.entry("", "a"),
Map.entry("", "ai"), Map.entry("", "ai"), Map.entry("", "ai"),
Map.entry("", "an"), Map.entry("", "an"), Map.entry("", "an"), Map.entry("", "an"), Map.entry("", "an"),
Map.entry("", "ang"),
Map.entry("", "ba"), Map.entry("", "ba"),
Map.entry("", "bai"), Map.entry("", "bai"), Map.entry("", "bai"),
Map.entry("", "bao"), Map.entry("", "bao"), Map.entry("", "bao"), Map.entry("", "bao"), Map.entry("", "bao"), Map.entry("", "bao"), Map.entry("", "bao"),
Map.entry("", "bei"), Map.entry("", "bei"), Map.entry("", "bei"), Map.entry("", "bei"), Map.entry("", "bei"), Map.entry("", "bei"), Map.entry("", "bei"), Map.entry("", "bei"),
Map.entry("", "ben"), Map.entry("", "ben"), Map.entry("", "ben"), Map.entry("", "ben"),
Map.entry("", "cai"), Map.entry("", "cai"), Map.entry("", "cai"), Map.entry("", "cai"), Map.entry("", "cai"), Map.entry("", "cai"), Map.entry("", "cai"), Map.entry("", "cai"),
Map.entry("", "cao"), Map.entry("", "cao"), Map.entry("", "cao"), Map.entry("", "cao"),
Map.entry("", "cha"), Map.entry("", "cha"), Map.entry("", "cha"), Map.entry("", "cha"), Map.entry("", "cha"),
Map.entry("", "chun"), Map.entry("", "chun"), Map.entry("", "chun"), Map.entry("", "chun"), Map.entry("", "chun"),
Map.entry("", "cui"), Map.entry("", "cui"), Map.entry("", "cui"), Map.entry("", "cui"), Map.entry("", "cui"),
Map.entry("", "da"),
Map.entry("", "dan"), Map.entry("", "dan"), Map.entry("", "dan"), Map.entry("", "dan"), Map.entry("", "dan"), Map.entry("", "dan"),
Map.entry("", "du"), Map.entry("", "du"), Map.entry("", "du"), Map.entry("", "du"), Map.entry("", "du"),
Map.entry("", "fa"), Map.entry("", "fa"), Map.entry("", "fa"),
Map.entry("", "fan"), Map.entry("", "fan"), Map.entry("", "fan"), Map.entry("", "fan"), Map.entry("", "fan"), Map.entry("", "fan"),
Map.entry("", "fei"), Map.entry("", "fei"), Map.entry("", "fei"), Map.entry("", "fei"), Map.entry("", "fei"), Map.entry("", "fei"),
Map.entry("", "fu"), Map.entry("", "fu"), Map.entry("", "fu"), Map.entry("", "fu"), Map.entry("", "fu"), Map.entry("", "fu"), Map.entry("", "fu"), Map.entry("", "fu"), Map.entry("", "fu"), Map.entry("", "fu"), Map.entry("", "fu"), Map.entry("", "fu"), Map.entry("", "fu"),
Map.entry("", "ge"), Map.entry("", "ge"), Map.entry("", "ge"), Map.entry("", "ge"), Map.entry("", "ge"), Map.entry("", "ge"), Map.entry("", "ge"),
Map.entry("", "gu"), Map.entry("", "gu"), Map.entry("", "gu"), Map.entry("", "gu"), Map.entry("", "gu"), Map.entry("", "gu"), Map.entry("", "gu"),
Map.entry("", "guan"), Map.entry("", "guan"), Map.entry("", "guan"), Map.entry("", "guan"),
Map.entry("", "hao"), Map.entry("", "hao"), Map.entry("", "hao"), Map.entry("", "hao"), Map.entry("", "hao"),
Map.entry("", "heng"), Map.entry("", "heng"), Map.entry("", "heng"), Map.entry("", "heng"),
Map.entry("", "hong"), Map.entry("", "hong"), Map.entry("", "hong"), Map.entry("鸿", "hong"),
Map.entry("", "hei"), Map.entry("", "hei"),
Map.entry("", "huo"), Map.entry("", "huo"), Map.entry("", "huo"), Map.entry("", "huo"), Map.entry("", "huo"), Map.entry("", "huo"),
Map.entry("", "huang"), Map.entry("", "huang"), Map.entry("", "huang"), Map.entry("", "huang"), Map.entry("", "huang"), Map.entry("", "huang"),
Map.entry("", "ji"), Map.entry("", "ji"), Map.entry("", "ji"), Map.entry("", "ji"), Map.entry("", "ji"), Map.entry("", "ji"), Map.entry("", "ji"), Map.entry("", "ji"), Map.entry("", "ji"),
Map.entry("", "jian"), Map.entry("", "jian"), Map.entry("", "jian"), Map.entry("", "jian"), Map.entry("", "jian"), Map.entry("", "jian"), Map.entry("", "jian"), Map.entry("", "jian"), Map.entry("", "jian"), Map.entry("", "jian"), Map.entry("", "jian"), Map.entry("", "jian"), Map.entry("", "jian"),
Map.entry("", "jiao"), Map.entry("", "jiao"), Map.entry("", "jiao"), Map.entry("", "jiao"), Map.entry("", "jiao"), Map.entry("", "jiao"), Map.entry("", "jiao"), Map.entry("", "jiao"), Map.entry("", "jiao"),
Map.entry("", "jie"), Map.entry("", "jie"), Map.entry("", "jie"), Map.entry("", "jie"), Map.entry("", "jie"), Map.entry("", "jie"), Map.entry("", "jie"),
Map.entry("", "jun"), Map.entry("", "jun"), Map.entry("", "jun"), Map.entry("", "jun"), Map.entry("", "jun"),
Map.entry("", "kang"), Map.entry("", "kang"), Map.entry("", "kang"),
Map.entry("", "kou"),
Map.entry("", "le"), Map.entry("", "le"),
Map.entry("", "li"), Map.entry("", "li"), Map.entry("", "li"), Map.entry("", "li"), Map.entry("", "li"), Map.entry("", "li"), Map.entry("", "li"), Map.entry("", "li"), Map.entry("", "li"), Map.entry("", "li"), Map.entry("", "li"), Map.entry("", "li"), Map.entry("", "li"),
Map.entry("", "li"), Map.entry("", "li"), Map.entry("", "li"),
Map.entry("绿", "lv"),
Map.entry("", "lu"), Map.entry("", "lu"), Map.entry("", "lu"), Map.entry("鹿", "lu"), Map.entry("", "lu"), Map.entry("", "lu"), Map.entry("", "lu"), Map.entry("", "lu"), Map.entry("", "lu"),
Map.entry("", "lao"), Map.entry("", "lao"),
Map.entry("", "luo"), Map.entry("", "luo"),
Map.entry("", "mian"), Map.entry("", "mian"), Map.entry("", "mian"), Map.entry("", "mian"), Map.entry("", "mian"), Map.entry("", "mian"),
Map.entry("", "pai"), Map.entry("", "pai"), Map.entry("", "pai"), Map.entry("", "pai"),
Map.entry("", "pu"), Map.entry("", "pu"), Map.entry("", "pu"), Map.entry("", "pu"),
Map.entry("", "qiang"),
Map.entry("", "ru"), Map.entry("", "ru"), Map.entry("", "ru"), Map.entry("", "ru"),
Map.entry("", "shuang"), Map.entry("", "shuang"), Map.entry("", "shuang"),
Map.entry("", "shui"), Map.entry("", "shui"), Map.entry("", "shui"),
Map.entry("", "shu"), Map.entry("", "shu"), Map.entry("", "shu"), Map.entry("", "shu"), Map.entry("", "shu"), Map.entry("", "shu"),
Map.entry("", "sheng"), Map.entry("", "sheng"), Map.entry("", "sheng"), Map.entry("", "sheng"), Map.entry("", "sheng"), Map.entry("", "sheng"),
Map.entry("", "su"), Map.entry("", "su"), Map.entry("", "su"), Map.entry("", "su"), Map.entry("", "su"),
Map.entry("", "suan"), Map.entry("", "suan"), Map.entry("", "suan"),
Map.entry("", "tao"), Map.entry("", "tao"), Map.entry("", "tao"), Map.entry("", "tao"), Map.entry("", "tao"), Map.entry("", "tao"), Map.entry("", "tao"),
Map.entry("", "tuo"), Map.entry("", "tuo"), Map.entry("", "tuo"), Map.entry("", "tuo"), Map.entry("", "tuo"),
Map.entry("", "tong"), Map.entry("", "tong"), Map.entry("", "tong"), Map.entry("", "tong"), Map.entry("", "tong"), Map.entry("", "tong"), Map.entry("", "tong"), Map.entry("", "tong"),
Map.entry("", "xi"), Map.entry("", "xi"), Map.entry("西", "xi"), Map.entry("", "xi"), Map.entry("", "xi"), Map.entry("", "xi"), Map.entry("", "xi"), Map.entry("", "xi"),
Map.entry("", "xian"), Map.entry("", "xian"), Map.entry("", "xian"), Map.entry("", "xian"), Map.entry("", "xian"), Map.entry("", "xian"), Map.entry("线", "xian"), Map.entry("", "xian"), Map.entry("", "xian"), Map.entry("", "xian"), Map.entry("", "xian"),
Map.entry("", "xiao"),
Map.entry("", "yan"), Map.entry("", "yan"), Map.entry("", "yan"), Map.entry("", "yan"), Map.entry("", "yan"), Map.entry("", "yan"), Map.entry("", "yan"), Map.entry("", "yan"), Map.entry("", "yan"), Map.entry("", "yan"),
Map.entry("", "yang"), Map.entry("", "yang"), Map.entry("", "yang"), Map.entry("", "yang"), Map.entry("", "yang"), Map.entry("", "yang"), Map.entry("", "yang"),
Map.entry("", "yi"), Map.entry("", "yi"), Map.entry("", "yi"), Map.entry("亿", "yi"), Map.entry("", "yi"), Map.entry("", "yi"), Map.entry("", "yi"), Map.entry("", "yi"), Map.entry("", "yi"), Map.entry("", "yi"), Map.entry("", "yi"), Map.entry("", "yi"), Map.entry("", "yi"),
Map.entry("", "yin"), Map.entry("", "yin"), Map.entry("", "yin"), Map.entry("", "yin"), Map.entry("", "yin"), Map.entry("", "yin"),
Map.entry("", "ying"), Map.entry("", "ying"), Map.entry("", "ying"), Map.entry("", "ying"), Map.entry("", "ying"), Map.entry("", "ying"),
Map.entry("", "yu"), Map.entry("", "yu"), Map.entry("", "yu"), Map.entry("", "yu"), Map.entry("", "yu"), Map.entry("", "yu"), Map.entry("", "yu"), Map.entry("", "yu"), Map.entry("", "yu"),
Map.entry("", "yuan"), Map.entry("", "yuan"), Map.entry("", "yuan"), Map.entry("", "yuan"), Map.entry("", "yuan"), Map.entry("", "yuan"), Map.entry("", "yuan"),
Map.entry("", "zi"), Map.entry("", "zi"), Map.entry("", "zi"), Map.entry("", "zi"), Map.entry("", "zi"),
Map.entry("", "zhuan"), Map.entry("", "zhuan"), Map.entry("", "zhuan"), Map.entry("", "zhuan")
);
private static final List<String> PRODUCTS = List.of(
"细胞抗氧素",
"胶原蛋白", "白藜芦醇", "好转反应", "阿育吠陀",
"活力健", "倍力健", "氨基酸", "益生菌", "辅酵素",
"葡萄籽", "排毒饮", "乳酪煲", "草本茶", "异黄酮",
"骨骼健", "舒采健", "衡醇饮", "洁面乳", "爽肤水"
);
private static final Map<String, String> PINYIN_INDEX = buildPinyinIndex();
private static final List<Integer> PRODUCT_LENGTHS = PRODUCTS.stream().map(String::length).distinct().sorted(Comparator.reverseOrder()).toList();
public String matchProducts(String text) {
if (text == null || text.isBlank()) {
return text == null ? "" : text;
}
String result = text;
for (Integer length : PRODUCT_LENGTHS) {
if (result.length() < length) {
continue;
}
int index = 0;
while (index <= result.length() - length) {
String window = result.substring(index, index + length);
if (!CHINESE_WINDOW_PATTERN.matcher(window).matches()) {
index++;
continue;
}
String key = toPinyinKey(window);
if (key == null) {
index++;
continue;
}
String product = PINYIN_INDEX.get(key);
if (product != null && !product.equals(window)) {
log.debug("[PinyinMatcher] \"{}\" -> \"{}\" (pinyin: {})", window, product, key);
result = result.substring(0, index) + product + result.substring(index + length);
index += product.length();
continue;
}
index++;
}
}
return result;
}
public String toPinyinKey(String text) {
if (text == null || text.isBlank()) {
return null;
}
StringBuilder builder = new StringBuilder();
for (int index = 0; index < text.length(); index++) {
String current = String.valueOf(text.charAt(index));
String pinyin = PINYIN_MAP.get(current);
if (pinyin == null) {
return null;
}
if (builder.length() > 0) {
builder.append('-');
}
builder.append(pinyin);
}
return builder.toString();
}
private static Map<String, String> buildPinyinIndex() {
Map<String, String> index = new LinkedHashMap<>();
for (String name : PRODUCTS) {
String key = toStaticPinyinKey(name);
if (key != null) {
index.put(key, name);
}
}
return Map.copyOf(index);
}
private static String toStaticPinyinKey(String text) {
StringBuilder builder = new StringBuilder();
for (int index = 0; index < text.length(); index++) {
String current = String.valueOf(text.charAt(index));
String pinyin = PINYIN_MAP.get(current);
if (pinyin == null) {
return null;
}
if (builder.length() > 0) {
builder.append('-');
}
builder.append(pinyin);
}
return builder.toString();
}
}

View File

@@ -0,0 +1,95 @@
package com.bigwo.javaserver.service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.regex.Pattern;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@Component
public class ProductLinkTrigger {
private static final List<ProductEntry> PRODUCT_LINK_MAP = List.of(
new ProductEntry("基础三合一", List.of("基础三合一", "三合一", "基础套装", "powercocktail"), "https://example.com/products/power-cocktail", "FitLine 基础三合一套装(大白+小红+小白)"),
new ProductEntry("小红", List.of("小红", "小红产品", "艾特维", "activize", "activize oxyplus", "activize serum", "小红精华液"), "https://example.com/products/activize", "FitLine 小红 Activize Oxyplus 能量激活饮品"),
new ProductEntry("大白", List.of("大白", "大白产品", "倍适", "basics", "basic power"), "https://example.com/products/basics", "FitLine 大白 Basics 基础营养素"),
new ProductEntry("小白", List.of("小白", "小白产品", "维适多", "restorate"), "https://example.com/products/restorate", "FitLine 小白 Restorate 矿物质晚间饮品"),
new ProductEntry("儿童倍适", List.of("儿童倍适", "powercocktail junior", "儿童套装"), "https://example.com/products/junior", "FitLine 儿童倍适 PowerCocktail Junior"),
new ProductEntry("CC-Cell", List.of("cc-cell", "cc-cell胶囊", "cc-cell乳霜", "cc套装", "cc胶囊"), "https://example.com/products/cc-cell", "FitLine CC-Cell 细胞修护套装"),
new ProductEntry("D-Drink", List.of("d-drink", "小绿排毒饮", "小绿", "14天排毒d饮料plus", "排毒饮"), "https://example.com/products/d-drink", "FitLine D-Drink 14天排毒饮品"),
new ProductEntry("倍力健", List.of("倍力健", "men+", "倍力健 men+", "小黑"), "https://example.com/products/men-plus", "FitLine 倍力健 MEN+ 男士活力营养素"),
new ProductEntry("肽美", List.of("肽美", "太美", "态美"), "https://example.com/products/beauty", "FitLine 肽美 胶原蛋白肽美容饮品"),
new ProductEntry("活力健", List.of("活力健"), "https://example.com/products/fitness-drink", "FitLine 活力健运动饮料"),
new ProductEntry("ProShape Amino", List.of("proshape amino", "proshape", "氨基酸", "支链氨基酸", "bcaa"), "https://example.com/products/proshape-amino", "FitLine ProShape Amino 氨基酸蛋白粉"),
new ProductEntry("草本茶", List.of("草本茶", "herbal tea"), "https://example.com/products/herbal-tea", "FitLine 草本茶"),
new ProductEntry("Hair+", List.of("hair+", "med hair+", "口服发宝", "外用发健", "发宝"), "https://example.com/products/hair-plus", "FitLine med Hair+ 护发套装"),
new ProductEntry("Fitness-Drink", List.of("fitness-drink", "运动饮料", "健康饮品"), "https://example.com/products/fitness-drink", "FitLine Fitness-Drink 运动饮料"),
new ProductEntry("纤萃", List.of("topshape", "纤萃", "纤萃减肥"), "https://example.com/products/topshape", "FitLine TopShape 纤萃体重管理"),
new ProductEntry("乐活50+", List.of("乐活50+", "generation 50+", "乐活"), "https://example.com/products/generation-50", "FitLine Generation 50+ 乐活中老年营养素"),
new ProductEntry("苹果细胞抗氧素", List.of("apple antioxy", "苹果细胞抗氧素", "antioxy", "细胞抗氧素", "zellschutz"), "https://example.com/products/antioxy", "FitLine Apple Antioxy 苹果细胞抗氧素"),
new ProductEntry("草本护理牙膏", List.of("med dental+", "草本护理牙膏", "牙膏"), "https://example.com/products/dental", "FitLine Med Dental+ 草本护理牙膏"),
new ProductEntry("Q10", List.of("q10", "辅酵素", "q10辅酵素氧修护"), "https://example.com/products/q10", "FitLine Q10 辅酵素氧修护"),
new ProductEntry("关节套装", List.of("关节套装", "关节舒缓"), "https://example.com/products/joint", "FitLine 关节舒缓套装"),
new ProductEntry("IB5", List.of("ib5", "口腔免疫喷雾"), "https://example.com/products/ib5", "FitLine IB5 口腔免疫喷雾"),
new ProductEntry("男士乳霜", List.of("men face", "全效男士护肤抗衰乳霜", "男士乳霜", "男士护肤"), "https://example.com/products/men-face", "FitLine Men Face 全效男士护肤抗衰乳霜")
);
private static final List<Pattern> DETAIL_REQUEST_PATTERNS = List.of(
Pattern.compile("(?:查看|看看|看一下|看下|了解|了解一下|了解下).*(?:详细|详情|介绍|说明|资料|信息)"),
Pattern.compile("(?:详细|详情|详细介绍|产品介绍|产品详情|产品说明).*(?:看|查|发|给|了解)"),
Pattern.compile("(?:发|给|分享|推|发个|发一个|给我|给我发|发给我|分享一下).*(?:链接|网址|地址|网页|页面|资料|图文|详情)"),
Pattern.compile("(?:链接|网址|网页).*(?:发|给|看|有没有)"),
Pattern.compile("(?:有没有|有无|有.+吗).*(?:链接|网址|网页|资料|图文|详情)"),
Pattern.compile("(?:更多|更详细|更具体|深入).*(?:介绍|了解|信息|内容)"),
Pattern.compile("(?:图文|产品页|详情页|官网|产品图).*(?:看|有|发|给)"),
Pattern.compile("(?:看|有|发|给).*(?:图文|产品页|详情页|官网|产品图)")
);
private static final List<AliasCandidate> SORTED_CANDIDATES;
static {
List<AliasCandidate> candidates = new ArrayList<>();
for (ProductEntry product : PRODUCT_LINK_MAP) {
for (String alias : product.aliases()) {
candidates.add(new AliasCandidate(alias, product));
}
}
candidates.sort(Comparator.comparingInt((AliasCandidate c) -> c.alias().length()).reversed());
SORTED_CANDIDATES = List.copyOf(candidates);
}
public TriggerResult check(String text) {
String t = StringUtils.hasText(text) ? text.trim() : "";
if (t.isEmpty()) {
return TriggerResult.NOT_TRIGGERED;
}
String lower = t.toLowerCase();
ProductEntry product = null;
for (AliasCandidate candidate : SORTED_CANDIDATES) {
if (lower.contains(candidate.alias())) {
product = candidate.product();
break;
}
}
if (product == null) {
return TriggerResult.NOT_TRIGGERED;
}
boolean hasIntent = DETAIL_REQUEST_PATTERNS.stream().anyMatch(p -> p.matcher(t).find());
if (!hasIntent) {
return TriggerResult.NOT_TRIGGERED;
}
return new TriggerResult(true, product.name(), product.link(), product.description());
}
private record ProductEntry(String name, List<String> aliases, String link, String description) {
}
private record AliasCandidate(String alias, ProductEntry product) {
}
public record TriggerResult(boolean triggered, String productName, String link, String description) {
static final TriggerResult NOT_TRIGGERED = new TriggerResult(false, null, null, null);
}
}

View File

@@ -0,0 +1,159 @@
package com.bigwo.javaserver.service;
import com.bigwo.javaserver.config.RedisClientManager;
import com.bigwo.javaserver.config.RedisProperties;
import com.bigwo.javaserver.model.RedisContextMessage;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.lettuce.core.api.sync.RedisCommands;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class RedisContextStore {
private static final Logger log = LoggerFactory.getLogger(RedisContextStore.class);
private static final int HISTORY_MAX_LEN = 10;
private static final long HISTORY_TTL_SECONDS = 1800;
private static final long KB_CACHE_HIT_TTL_SECONDS = 300;
private static final long SUMMARY_TTL_SECONDS = 7200;
private final RedisClientManager redisClientManager;
private final RedisProperties redisProperties;
private final ObjectMapper objectMapper;
public RedisContextStore(RedisClientManager redisClientManager, RedisProperties redisProperties, ObjectMapper objectMapper) {
this.redisClientManager = redisClientManager;
this.redisProperties = redisProperties;
this.objectMapper = objectMapper;
}
public boolean pushMessage(String sessionId, RedisContextMessage message) {
if (!redisClientManager.isAvailable() || message == null || !StringUtils.hasText(sessionId)) {
return false;
}
try {
RedisCommands<String, String> commands = redisClientManager.syncCommands();
String key = historyKey(sessionId);
String payload = objectMapper.writeValueAsString(message);
commands.lpush(key, payload);
commands.ltrim(key, 0, HISTORY_MAX_LEN - 1);
commands.expire(key, HISTORY_TTL_SECONDS);
return true;
} catch (Exception exception) {
log.warn("[Redis] pushMessage failed: {}", exception.getMessage());
return false;
}
}
public List<RedisContextMessage> getRecentHistory(String sessionId, int maxRounds) {
if (!redisClientManager.isAvailable() || !StringUtils.hasText(sessionId)) {
return null;
}
try {
RedisCommands<String, String> commands = redisClientManager.syncCommands();
List<String> items = commands.lrange(historyKey(sessionId), 0, Math.max(0, maxRounds * 2 - 1));
if (items == null || items.isEmpty()) {
return List.of();
}
return items.reversed().stream()
.map(item -> deserialize(item, new TypeReference<RedisContextMessage>() { }))
.filter(java.util.Objects::nonNull)
.toList();
} catch (Exception exception) {
log.warn("[Redis] getRecentHistory failed: {}", exception.getMessage());
return null;
}
}
public boolean clearSession(String sessionId) {
if (!redisClientManager.isAvailable() || !StringUtils.hasText(sessionId)) {
return false;
}
try {
redisClientManager.syncCommands().del(historyKey(sessionId));
return true;
} catch (Exception exception) {
log.warn("[Redis] clearSession failed: {}", exception.getMessage());
return false;
}
}
public boolean setSummary(String sessionId, String summary) {
if (!redisClientManager.isAvailable() || !StringUtils.hasText(sessionId) || !StringUtils.hasText(summary)) {
return false;
}
try {
redisClientManager.syncCommands().setex(summaryKey(sessionId), SUMMARY_TTL_SECONDS, summary);
return true;
} catch (Exception exception) {
log.warn("[Redis] setSummary failed: {}", exception.getMessage());
return false;
}
}
public String getSummary(String sessionId) {
if (!redisClientManager.isAvailable() || !StringUtils.hasText(sessionId)) {
return null;
}
try {
return redisClientManager.syncCommands().get(summaryKey(sessionId));
} catch (Exception exception) {
log.warn("[Redis] getSummary failed: {}", exception.getMessage());
return null;
}
}
public boolean setKbCache(String cacheKey, Object result, boolean hit) {
if (!redisClientManager.isAvailable() || !StringUtils.hasText(cacheKey) || !hit || result == null) {
return false;
}
try {
String payload = objectMapper.writeValueAsString(result);
redisClientManager.syncCommands().setex(kbCacheKey(cacheKey), KB_CACHE_HIT_TTL_SECONDS, payload);
return true;
} catch (Exception exception) {
log.warn("[Redis] setKbCache failed: {}", exception.getMessage());
return false;
}
}
public <T> T getKbCache(String cacheKey, Class<T> valueType) {
if (!redisClientManager.isAvailable() || !StringUtils.hasText(cacheKey)) {
return null;
}
try {
String payload = redisClientManager.syncCommands().get(kbCacheKey(cacheKey));
if (!StringUtils.hasText(payload)) {
return null;
}
return objectMapper.readValue(payload, valueType);
} catch (Exception exception) {
log.warn("[Redis] getKbCache failed: {}", exception.getMessage());
return null;
}
}
private String historyKey(String sessionId) {
return redisProperties.getKeyPrefix() + "voice:history:" + sessionId;
}
private String summaryKey(String sessionId) {
return redisProperties.getKeyPrefix() + "voice:summary:" + sessionId;
}
private String kbCacheKey(String cacheKey) {
return redisProperties.getKeyPrefix() + "kb_cache:" + cacheKey;
}
private <T> T deserialize(String payload, TypeReference<T> typeReference) {
try {
return objectMapper.readValue(payload, typeReference);
} catch (Exception exception) {
return null;
}
}
}

View File

@@ -0,0 +1,70 @@
package com.bigwo.javaserver.service;
import com.bigwo.javaserver.exception.BadRequestException;
import com.bigwo.javaserver.model.LlmMessage;
import com.bigwo.javaserver.model.SessionFullMessage;
import com.bigwo.javaserver.model.SessionHistoryResult;
import com.bigwo.javaserver.model.SessionListItem;
import com.bigwo.javaserver.model.SessionSwitchResult;
import com.bigwo.javaserver.repository.SessionRepository;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class SessionService {
private static final Logger log = LoggerFactory.getLogger(SessionService.class);
private final SessionRepository sessionRepository;
public SessionService(SessionRepository sessionRepository) {
this.sessionRepository = sessionRepository;
}
public List<SessionListItem> listSessions(String userId, Integer limit) {
return sessionRepository.getSessionList(userId, limit).stream()
.map(item -> new SessionListItem(
item.id(),
item.userId(),
item.mode(),
item.createdAt(),
item.updatedAt(),
truncateMessage(item.lastMessage()),
item.messageCount()
))
.toList();
}
public void deleteSession(String sessionId) {
sessionRepository.deleteSession(sessionId);
}
public SessionHistoryResult<SessionFullMessage> getFullHistory(String sessionId, Integer limit) {
List<SessionFullMessage> messages = sessionRepository.getRecentMessages(sessionId, limit);
return new SessionHistoryResult<>(sessionId, sessionRepository.getSessionMode(sessionId), messages, messages.size());
}
public SessionHistoryResult<LlmMessage> getLlmHistory(String sessionId, Integer limit) {
List<LlmMessage> messages = sessionRepository.getHistoryForLlm(sessionId, limit);
return new SessionHistoryResult<>(sessionId, sessionRepository.getSessionMode(sessionId), messages, messages.size());
}
public SessionSwitchResult switchMode(String sessionId, String targetMode) {
if (!"voice".equals(targetMode) && !"chat".equals(targetMode)) {
throw new BadRequestException("targetMode must be \"voice\" or \"chat\"");
}
sessionRepository.updateSessionMode(sessionId, targetMode);
List<LlmMessage> history = sessionRepository.getHistoryForLlm(sessionId, 20);
log.info("[Session] Switched {} to {}, history: {} messages", sessionId, targetMode, history.size());
return new SessionSwitchResult(sessionId, targetMode, history, history.size());
}
private String truncateMessage(String value) {
if (value == null || value.length() <= 60) {
return value;
}
return value.substring(0, 60) + "...";
}
}

View File

@@ -0,0 +1,123 @@
package com.bigwo.javaserver.service;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import com.bigwo.javaserver.model.AssistantProfile;
@Component
public class VoiceAssistantProfileSupport {
public static final String DEFAULT_SPEAKING_STYLE = "整体语气亲切自然、轻快有温度,像熟悉行业的朋友在语音聊天。优先短句和口语化表达,先给结论,再补一句最有帮助的信息。不要播音腔,不要念稿,不要客服腔,不要过度热情,也不要输出任何思考过程。";
public AssistantProfile resolve(AssistantProfile profile) {
return AssistantProfile.resolve(profile);
}
public String getDisplayName(AssistantProfile profile) {
AssistantProfile resolved = resolve(profile);
if (StringUtils.hasText(resolved.agentName())) {
return resolved.agentName().trim();
}
if (StringUtils.hasText(resolved.nickname())) {
return resolved.nickname().trim();
}
return "大沃";
}
public String buildVoiceGreeting(AssistantProfile profile) {
return "你好!我是" + getDisplayName(profile) + "的AI智能体可以回答你关于PM事业、FitLine产品和一成系统优势等相关问题也可以陪你聊天。你想和我聊点什么呢";
}
public String buildVoiceSystemRole(AssistantProfile profile, String handoffSummary) {
AssistantProfile resolved = resolve(profile);
String displayName = getDisplayName(resolved);
String documentsClause = StringUtils.hasText(resolved.documents())
? ",拥有丰富的基础知识库信息" + resolved.documents().trim()
: ",拥有丰富的基础知识库信息";
String personalInfoBlock = buildPersonalInfoBlock(resolved);
String sensitiveTopicDirective = " 当涉及备孕、怀孕、孕期、哺乳、疾病治疗、用药搭配等敏感健康话题时,"
+ "你必须先根据知识库内容给出专业建议,然后引导用户联系大沃的专业健康管理顾问获取一对一指导。"
+ "绝对禁止说\"建议咨询医生\"\"咨询营养师\"\"咨询专业人士\"等笼统第三方建议,只能引导到大沃的健康管理顾问。";
String salesGuidance = buildSalesGuidanceDirective();
String summaryBlock = StringUtils.hasText(handoffSummary)
? "\n\n【对话历史摘要】以下是用户之前的对话记录请据此保持对话连贯性不要重复已回答的内容\n" + handoffSummary.trim()
: "";
return "你是名为" + displayName + "的AI智能体由一成系统自主研发专注于PM事业、FitLine产品和一成系统相关知识。"
+ "你不是字节跳动、抖音、火山引擎或任何第三方平台研发的助手;如果用户问到研发归属,只能回答\"我是一成系统自主研发的AI智能体\""
+ "你是专业、热情、有亲和力的事业伙伴,回答时先给结论,再补充关键依据;始终使用第一人称\"\",不要用第三人称描述自己。"
+ "回答要围绕产品、使用方法、搭配建议、常见问题和事业机会展开" + documentsClause + ""
+ "优先使用知识库信息自然转述不要照念需要查资料时直接调用search_knowledge工具绝不猜测或编造产品信息。"
+ "禁止输出思考过程或元描述。"
+ "禁止使用推脱式说法用户追问产品详情时必须调用search_knowledge工具查询不要凭自身知识回答具体产品信息。"
+ sensitiveTopicDirective
+ salesGuidance
+ personalInfoBlock
+ summaryBlock
+ "";
}
public String normalizeTextForSpeech(String text) {
return String.valueOf(text == null ? "" : text)
.replaceAll("^#{1,6}\\s*", "")
.replaceAll("\\*\\*([^*]*)\\*\\*", "$1")
.replaceAll("__([^_]*)__", "$1")
.replaceAll("\\*([^*]+)\\*", "$1")
.replaceAll("_([^_]+)_", "$1")
.replaceAll("~~([^~]*)~~", "$1")
.replaceAll("`{1,3}[^`]*`{1,3}", "")
.replace("\r", " ")
.replaceAll("\\n{2,}", "")
.replace("\n", " ")
.replaceAll("。{2,}", "")
.replaceAll("([])\\1+", "$1")
.replaceAll("([。!?;,])\\s*([。!?;,])", "$2")
.replaceAll("\\s+", " ")
.trim();
}
private String buildPersonalInfoBlock(AssistantProfile profile) {
List<String> lines = new ArrayList<>();
if (StringUtils.hasText(profile.email())) {
lines.add("邮箱:" + profile.email().trim());
}
if (StringUtils.hasText(profile.nickname())) {
lines.add("姓名:" + profile.nickname().trim());
}
if (StringUtils.hasText(profile.wxl())) {
lines.add("微信号:" + profile.wxl().trim());
}
if (StringUtils.hasText(profile.mobile())) {
lines.add("手机号:" + profile.mobile().trim());
}
if (StringUtils.hasText(profile.wxCode())) {
lines.add("微信二维码:" + profile.wxCode().trim());
}
if (StringUtils.hasText(profile.intro())) {
lines.add("个人介绍:" + profile.intro().trim());
}
if (StringUtils.hasText(profile.sign())) {
lines.add("签名:" + profile.sign().trim());
}
if (StringUtils.hasText(profile.story())) {
lines.add("我的故事:" + profile.story().trim());
}
if (lines.isEmpty()) {
return "";
}
return " 当用户询问我的联系方式、微信、手机号、个人介绍、签名或故事时,可优先使用以下资料:" + String.join("", lines) + "";
}
private String buildSalesGuidanceDirective() {
return " 【成交大师策略】你是深谙人性的成交大师,沟通风格不是强推硬卖,而是精准捕捉客户需求,以专业顾问姿态引导对话,让客户自己得出\"我需要\"的结论。你的双重使命:(A)根据客户健康需求推荐FitLine产品促成购买,(B)在合适时机引导客户了解PM事业机会和一成系统,促成事业合作。"
+ " 【产品成交五步法】(1)探需优先(40%)——不要一上来就介绍产品,先用开放式提问了解客户:身体有什么困扰?精力如何?睡眠怎样?家里谁最需要调理?在客户描述问题中捕捉成交切入点。(2)痛点共鸣(20%)——根据反馈将痛点适度放大,用专业知识解释根源,让客户意识到问题值得重视。如\"经常累不是小问题,是细胞长期营养不足,吃进去的没被有效吸收,这是底层问题\"。(3)精准匹配(20%)——聚焦最能解决当下痛点的1-2个产品:精力不足/亚健康→基础三合一(Optimal-Set);皮肤/美容→肽美;排毒/消化→D-Drink小绿;运动/体能→活力健+ProShape;男性→倍力健MEN+;儿童→儿童倍适;50+→乐活Generation50+;任何单品都搭配基础三合一打底。强调NTC营养传输技术(约10分钟直达细胞,吸收率远超普通营养品)的差异化优势。(4)消除顾虑(10%)——主动回应三大顾虑:安全性→科隆名单认证+2000多名运动员在用;有效性→NTC专利+100项专利+科研机构合作;价格→算单位有效营养的性价比而非绝对价格。(5)临门一脚(10%)——从众效应\"很多像您这样的客户都从Optimal-Set开始\";降低门槛\"先体验一个月感受变化\";二选一\"您是先试基础套组还是全家方案?\""
+ " 【产品推荐理由必须来自知识库】推荐产品时,推荐理由必须从search_knowledge返回的产品切片中提取,不能用笼统话术。提取优先级:(a)产品定位与核心功效——切片中的\"产品定位\"字段直接说明该产品解决什么问题;(b)关键成分及作用——如\"L-精氨酸支持一氧化氮生成\"\"胶原蛋白肽从内而外\";(c)适用场景——切片中的适用人群和使用场景;(d)认证与背书——科隆名单、运动员使用、GMP等;(e)用户体感——KB中的见证和反馈案例。通用弹药仅在KB切片无具体卖点时补充:NTC约10分钟营养直达细胞;100%德国制造;科隆名单认证;100多项专利。像朋友分享好东西一样推荐。"
+ " 【异议应对】太贵了→\"普通营养品吸收不到20%等于浪费,FitLine的NTC让每分钱都变成真正进入身体的营养,算性价比其实更划算\";已吃别的→\"如果觉得变化不大,可能是吸收没过关,FitLine最大的不同就是NTC解决吸收问题,试一个月身体告诉你答案\";再考虑→\"健康投资每早一天细胞就多得到一天营养支持\";是不是传销→\"PM在全球40多个国家合法运营33年,营销模式通过德国法院认证,是100%合法直销\""
+ " 【事业合作引导五步法】当用户表现出以下信号时切入:谈创业/副业/收入/想改变、连续关注多个产品、询问购买/代理/加入、宝妈/时间自由话题。(1)梦想激活(30%)——先聊生活和梦想,\"如果经济和时间都不是问题,您最想过什么样的生活?\";(2)机会呈现(25%)——将PM事业与客户梦想对接:想被动收入→管道收入;想时间自由→灵活工作;想全球发展→一个ID全球创业;(3)信心建立(25%)——用企业硬实力:33年零负增长全球唯一、DSN全球第六连续五年增长率冠军、邓白氏接近满分信用评级、营销模式法院认证;同时介绍一成系统(三大平台+四大AI生态+批发式晋级)消除\"不会做\"的恐惧;(4)门槛降低(10%)——一天一杯咖啡的钱、不囤货、一部手机就能做、一条线就能成功;(5)行动推动(10%)——\"您是先体验产品还是直接锁定合伙人位置?\""
+ " 【客户画像匹配】怕风险→强调33年零负增长+邓白氏评级+零囤货;上班族→碎片时间+管道收入+不用辞职;宝妈→在家做+全家受益+自我成长;被坑过→法院认证+邓白氏+DSN第六用硬数据打消;关注养老→安联保险养老金+可传承事业;中国市场敏感→即将全面启动+先机者红利。"
+ " 【底线】不过度推销,用户拒绝或闲聊时不强推;同轮不超2个产品;先解决问题再顺势引导;对事业感兴趣的用户引导留联系方式或加微信深入了解。";
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
package com.bigwo.javaserver.service;
import java.net.http.WebSocket;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledFuture;
import org.springframework.web.socket.WebSocketSession;
import com.bigwo.javaserver.model.AssistantProfile;
import com.bigwo.javaserver.model.KnowledgeSearchResult;
final class VoiceSessionState {
final String clientConnectionId;
final String sessionId;
final WebSocketSession clientSession;
final Object clientSendLock = new Object();
final Object upstreamSendLock = new Object();
final StringBuilder assistantStreamBuffer = new StringBuilder();
volatile String userId;
volatile AssistantProfile assistantProfile = AssistantProfile.defaults();
volatile WebSocket upstream;
volatile boolean upstreamReady;
volatile boolean readySent;
volatile boolean hasSentGreeting;
volatile boolean blockUpstreamAudio;
volatile boolean awaitingUpstreamReply;
volatile boolean pendingExternalRagReply;
volatile boolean discardNextAssistantResponse;
volatile boolean closed;
volatile String botName = "大沃";
volatile String systemRole = "";
volatile String speakingStyle = VoiceAssistantProfileSupport.DEFAULT_SPEAKING_STYLE;
volatile String speaker = "zh_female_vv_jupiter_bigtts";
volatile String modelVersion = "O";
volatile String greetingText = "";
volatile String handoffSummary = "";
volatile String latestUserText = "";
volatile long latestUserTurnSeq;
volatile String lastPersistedUserText = "";
volatile long lastPersistedUserAt;
volatile String lastPersistedAssistantText = "";
volatile long lastPersistedAssistantAt;
volatile String currentTtsType = "";
volatile String currentSpeechText = "";
volatile String assistantStreamReplyId = "";
volatile String pendingAssistantSource;
volatile String pendingAssistantToolName;
volatile Map<String, Object> pendingAssistantMeta;
volatile long pendingAssistantTurnSeq;
volatile long lastDeliveredAssistantTurnSeq;
volatile long lastActivityAt = System.currentTimeMillis();
volatile long greetingSentAt;
volatile long lastBargeInResetAt;
volatile String lastFinalNormalized = "";
volatile long lastFinalAt;
volatile ScheduledFuture<?> idleFuture;
volatile ScheduledFuture<?> keepaliveFuture;
// TTS timing & local speech delivery
volatile boolean isSendingChatTTSText;
volatile long chatTTSUntil;
volatile long directSpeakUntil;
volatile boolean fillerActive;
// Suppress upstream reply mechanism
volatile long suppressUpstreamUntil;
volatile ScheduledFuture<?> suppressReplyFuture;
// Greeting protection & timers
volatile long greetingProtectionUntil;
volatile boolean pendingGreetingAck;
volatile ScheduledFuture<?> greetingTimerFuture;
volatile ScheduledFuture<?> greetingAckTimerFuture;
volatile ScheduledFuture<?> chatTTSTimerFuture;
// processReply queue & concurrency
volatile boolean processingReply;
volatile String queuedUserText = "";
volatile long queuedUserTurnSeq;
volatile ScheduledFuture<?> queuedReplyTimerFuture;
// Reply timeout watchdog
volatile ScheduledFuture<?> replyTimeoutFuture;
volatile long replyTimeoutTurnSeq;
volatile long cancelledReplyTurnSeq;
// Echo detection
volatile boolean echoLogOnce;
volatile long lastPartialAt;
// Audio block logging
volatile boolean audioBlockLogOnce;
volatile long clientAudioFrameCount;
// Async chain for serialized upstream audio sends (non-blocking)
volatile CompletableFuture<?> lastUpstreamAudioSend = CompletableFuture.completedFuture(null);
volatile long lastAudioSendSuccessAt;
// Turn count
volatile long turnCount;
// KB prequery (GAP-11)
volatile CompletableFuture<KnowledgeSearchResult> pendingKbPrequery;
volatile String kbPrequeryText = "";
volatile long kbPrequeryStartedAt;
// KB protection window (GAP-19)
volatile String lastKbTopic = "";
volatile long lastKbHitAt;
// Reply plan: evidence text (raw KB content, never used for subtitle/persistence)
volatile String ragEvidenceText = "";
VoiceSessionState(String clientConnectionId, String sessionId, WebSocketSession clientSession, String userId) {
this.clientConnectionId = clientConnectionId;
this.sessionId = sessionId;
this.clientSession = clientSession;
this.userId = userId;
}
void touch() {
lastActivityAt = System.currentTimeMillis();
}
synchronized String appendAssistantChunk(String replyId, String chunkText) {
if (replyId != null && !replyId.isBlank() && !assistantStreamReplyId.isBlank() && !assistantStreamReplyId.equals(replyId)) {
assistantStreamBuffer.setLength(0);
}
if (replyId != null && !replyId.isBlank()) {
assistantStreamReplyId = replyId;
}
assistantStreamBuffer.append(chunkText == null ? "" : chunkText);
return assistantStreamBuffer.toString();
}
synchronized String consumeAssistantBuffer() {
String text = assistantStreamBuffer.toString().trim();
assistantStreamBuffer.setLength(0);
assistantStreamReplyId = "";
return text;
}
synchronized void clearAssistantBuffer() {
assistantStreamBuffer.setLength(0);
assistantStreamReplyId = "";
}
}

View File

@@ -0,0 +1,76 @@
package com.bigwo.javaserver.util;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.stereotype.Component;
@Component
public class VolcSignerV4 {
public Map<String, String> signRequest(String method, String host, String path, String body, String accessKeyId, String secretAccessKey, String service, String region) {
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
String dateStamp = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String amzDate = now.format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'"));
String credentialScope = dateStamp + "/" + region + "/" + service + "/request";
String payload = body == null ? "" : body;
String bodyHash = sha256Hex(payload);
Map<String, String> headers = new TreeMap<>();
headers.put("content-type", "application/json");
headers.put("host", host);
headers.put("x-content-sha256", bodyHash);
headers.put("x-date", amzDate);
String signedHeaders = String.join(";", headers.keySet());
String canonicalHeaders = headers.entrySet().stream()
.map(entry -> entry.getKey() + ":" + entry.getValue() + "\n")
.reduce("", String::concat);
String canonicalRequest = String.join("\n", method, path, "", canonicalHeaders, signedHeaders, bodyHash);
String stringToSign = String.join("\n", "HMAC-SHA256", amzDate, credentialScope, sha256Hex(canonicalRequest));
byte[] signingKey = hmacSha256(secretAccessKey.getBytes(StandardCharsets.UTF_8), dateStamp);
signingKey = hmacSha256(signingKey, region);
signingKey = hmacSha256(signingKey, service);
signingKey = hmacSha256(signingKey, "request");
String signature = bytesToHex(hmacSha256(signingKey, stringToSign));
Map<String, String> result = new LinkedHashMap<>(headers);
result.put("authorization", "HMAC-SHA256 Credential=" + accessKeyId + "/" + credentialScope + ", SignedHeaders=" + signedHeaders + ", Signature=" + signature);
return result;
}
private byte[] hmacSha256(byte[] key, String data) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
} catch (Exception exception) {
throw new IllegalStateException(exception.getMessage(), exception);
}
}
private String sha256Hex(String data) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return bytesToHex(digest.digest(data.getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException(exception.getMessage(), exception);
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder builder = new StringBuilder(bytes.length * 2);
for (byte value : bytes) {
builder.append(String.format("%02x", value));
}
return builder.toString();
}
}

View File

@@ -0,0 +1,31 @@
package com.bigwo.javaserver.web;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
public class ApiRequestLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(ApiRequestLoggingFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
long startTime = System.currentTimeMillis();
try {
filterChain.doFilter(request, response);
} finally {
if (request.getRequestURI().startsWith("/api/")) {
long elapsed = System.currentTimeMillis() - startTime;
log.info("[{}] {} -> {} ({}ms)", request.getMethod(), request.getRequestURI(), response.getStatus(), elapsed);
}
}
}
}

View File

@@ -0,0 +1,4 @@
package com.bigwo.javaserver.web.request;
public record AssistantProfileRefreshRequest(String userId) {
}

View File

@@ -0,0 +1,4 @@
package com.bigwo.javaserver.web.request;
public record ChatSendRequest(String sessionId, String message, String userId) {
}

View File

@@ -0,0 +1,7 @@
package com.bigwo.javaserver.web.request;
import com.bigwo.javaserver.model.ChatSubtitle;
import java.util.List;
public record ChatStartRequest(String sessionId, List<ChatSubtitle> voiceSubtitles, String userId) {
}

View File

@@ -0,0 +1,4 @@
package com.bigwo.javaserver.web.request;
public record SessionSwitchRequest(String targetMode) {
}

View File

@@ -0,0 +1,4 @@
package com.bigwo.javaserver.web.request;
public record VideoAdminConfigRequest(String model) {
}

View File

@@ -0,0 +1,14 @@
package com.bigwo.javaserver.web.response;
import com.bigwo.javaserver.model.AssistantProfile;
public record AssistantProfileResponseData(
String userId,
AssistantProfile profile,
String source,
boolean cached,
Long fetchedAt,
boolean configured,
String error
) {
}

View File

@@ -0,0 +1,15 @@
package com.bigwo.javaserver.web.response;
public record HealthFeaturesResponse(
boolean voiceChat,
boolean textChat,
String textChatProvider,
boolean webSearch,
boolean customSpeaker,
boolean arkKnowledgeBase,
boolean redis,
Object reranker,
String kbRetrievalMode,
boolean nativeVoiceGateway
) {
}

View File

@@ -0,0 +1,10 @@
package com.bigwo.javaserver.web.response;
public record HealthResponse(
String status,
String mode,
String apiVersion,
boolean configured,
HealthFeaturesResponse features
) {
}

View File

@@ -0,0 +1,22 @@
package com.bigwo.javaserver.websocket;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class VoiceWebSocketConfig implements WebSocketConfigurer {
private final VoiceWebSocketHandler voiceWebSocketHandler;
public VoiceWebSocketConfig(VoiceWebSocketHandler voiceWebSocketHandler) {
this.voiceWebSocketHandler = voiceWebSocketHandler;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(voiceWebSocketHandler, "/ws/realtime-dialog").setAllowedOriginPatterns("*");
}
}

View File

@@ -0,0 +1,47 @@
package com.bigwo.javaserver.websocket;
import com.bigwo.javaserver.service.VoiceGatewayService;
import java.net.URI;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.BinaryMessage;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.BinaryWebSocketHandler;
import org.springframework.web.util.UriComponentsBuilder;
@Component
public class VoiceWebSocketHandler extends BinaryWebSocketHandler {
private final VoiceGatewayService voiceGatewayService;
public VoiceWebSocketHandler(VoiceGatewayService voiceGatewayService) {
this.voiceGatewayService = voiceGatewayService;
}
@Override
public void afterConnectionEstablished(WebSocketSession session) {
URI uri = session.getUri();
var queryParams = UriComponentsBuilder.fromUri(uri == null ? URI.create("/") : uri).build(true).getQueryParams();
voiceGatewayService.afterConnectionEstablished(session, queryParams.getFirst("sessionId"), queryParams.getFirst("userId"));
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
voiceGatewayService.handleTextMessage(session, message == null ? "" : message.getPayload());
}
@Override
protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) {
voiceGatewayService.handleBinaryMessage(session, message == null ? new byte[0] : message.getPayload().array());
}
@Override
public void afterConnectionClosed(WebSocketSession session, org.springframework.web.socket.CloseStatus status) {
voiceGatewayService.afterConnectionClosed(session);
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) {
voiceGatewayService.handleTransportError(session, exception);
}
}

View File

@@ -0,0 +1,169 @@
package com.bigwo.javaserver.websocket;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
public final class VolcRealtimeProtocol {
private static final int HEADER_SIZE_4 = 0x1;
private static final int VERSION_1 = 0x10;
private static final int SERIALIZATION_JSON = 0x1 << 4;
private static final int SERIALIZATION_RAW = 0;
private static final int COMPRESSION_NONE = 0;
private static final int MSG_TYPE_FLAG_WITH_EVENT = 0b100;
public static final int TYPE_FULL_CLIENT = 1;
public static final int TYPE_AUDIO_ONLY_CLIENT = 2;
public static final int TYPE_FULL_SERVER = 9;
public static final int TYPE_AUDIO_ONLY_SERVER = 11;
public static final int TYPE_ERROR = 15;
private VolcRealtimeProtocol() {
}
public static byte[] createStartConnectionMessage() {
return marshal(TYPE_FULL_CLIENT, MSG_TYPE_FLAG_WITH_EVENT, 1, "", "{}".getBytes(StandardCharsets.UTF_8), false);
}
public static byte[] createStartSessionMessage(String sessionId, Map<String, Object> payload, ObjectMapper objectMapper) {
return marshal(TYPE_FULL_CLIENT, MSG_TYPE_FLAG_WITH_EVENT, 100, sessionId, writeJsonBytes(payload, objectMapper), false);
}
public static byte[] createAudioMessage(String sessionId, byte[] audioBuffer) {
return marshal(TYPE_AUDIO_ONLY_CLIENT, MSG_TYPE_FLAG_WITH_EVENT, 200, sessionId, audioBuffer == null ? new byte[0] : audioBuffer, true);
}
public static byte[] createSayHelloMessage(String sessionId, String content, ObjectMapper objectMapper) {
return marshal(TYPE_FULL_CLIENT, MSG_TYPE_FLAG_WITH_EVENT, 300, sessionId, writeJsonBytes(Map.of("content", content == null ? "" : content), objectMapper), false);
}
public static byte[] createChatTTSTextMessage(String sessionId, boolean start, boolean end, String content, ObjectMapper objectMapper) {
Map<String, Object> payload = new java.util.LinkedHashMap<>();
payload.put("session_id", sessionId);
payload.put("start", start);
payload.put("end", end);
payload.put("content", content == null ? "" : content);
return marshal(TYPE_FULL_CLIENT, MSG_TYPE_FLAG_WITH_EVENT, 500, sessionId, writeJsonBytes(payload, objectMapper), false);
}
public static byte[] createChatRagTextMessage(String sessionId, String externalRag, ObjectMapper objectMapper) {
return marshal(
TYPE_FULL_CLIENT,
MSG_TYPE_FLAG_WITH_EVENT,
502,
sessionId,
writeJsonBytes(Map.of("session_id", sessionId, "external_rag", externalRag == null ? "[]" : externalRag), objectMapper),
false
);
}
public static Frame unmarshal(byte[] data) {
byte[] buffer = data == null ? new byte[0] : data;
if (buffer.length < 4) {
throw new IllegalArgumentException("protocol message too short");
}
ByteBuffer byteBuffer = ByteBuffer.wrap(buffer);
byteBuffer.get();
int typeAndFlag = Byte.toUnsignedInt(byteBuffer.get());
byteBuffer.get();
byteBuffer.get();
int type = (typeAndFlag >> 4) & 0x0F;
int typeFlag = typeAndFlag & 0x0F;
if (type == TYPE_ERROR) {
byte[] payload = new byte[byteBuffer.remaining()];
byteBuffer.get(payload);
return new Frame(type, typeFlag, 0, "", payload);
}
int event = 0;
String sessionId = "";
if (containsEvent(typeFlag)) {
event = byteBuffer.getInt();
}
if (containsEvent(typeFlag) && shouldHandleSessionId(event)) {
sessionId = readStringWithLength(byteBuffer);
}
byte[] payload = readPayload(byteBuffer);
return new Frame(type, typeFlag, event, sessionId, payload);
}
private static byte[] marshal(int type, int typeFlag, int event, String sessionId, byte[] payload, boolean rawPayload) {
try {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
outputStream.write(new byte[] {
(byte) (VERSION_1 | HEADER_SIZE_4),
(byte) (((type & 0x0F) << 4) | (typeFlag & 0x0F)),
(byte) ((rawPayload ? SERIALIZATION_RAW : SERIALIZATION_JSON) | COMPRESSION_NONE),
0
});
if (containsEvent(typeFlag)) {
writeInt(outputStream, event);
}
if (containsEvent(typeFlag) && shouldHandleSessionId(event)) {
writeStringWithLength(outputStream, sessionId);
}
writePayload(outputStream, payload == null ? new byte[0] : payload);
return outputStream.toByteArray();
} catch (IOException exception) {
throw new IllegalStateException(exception.getMessage(), exception);
}
}
private static void writeInt(ByteArrayOutputStream outputStream, int value) throws IOException {
outputStream.write(ByteBuffer.allocate(4).putInt(value).array());
}
private static void writeStringWithLength(ByteArrayOutputStream outputStream, String value) throws IOException {
byte[] content = String.valueOf(value == null ? "" : value).getBytes(StandardCharsets.UTF_8);
writeInt(outputStream, content.length);
outputStream.write(content);
}
private static void writePayload(ByteArrayOutputStream outputStream, byte[] payload) throws IOException {
writeInt(outputStream, payload.length);
outputStream.write(payload);
}
private static String readStringWithLength(ByteBuffer byteBuffer) {
int size = byteBuffer.getInt();
if (size <= 0) {
return "";
}
byte[] value = new byte[size];
byteBuffer.get(value);
return new String(value, StandardCharsets.UTF_8);
}
private static byte[] readPayload(ByteBuffer byteBuffer) {
int size = byteBuffer.getInt();
if (size <= 0) {
return new byte[0];
}
byte[] payload = new byte[size];
byteBuffer.get(payload);
return payload;
}
private static boolean containsEvent(int typeFlag) {
return (typeFlag & MSG_TYPE_FLAG_WITH_EVENT) == MSG_TYPE_FLAG_WITH_EVENT;
}
private static boolean shouldHandleSessionId(int event) {
return event != 1 && event != 2 && event != 50 && event != 51 && event != 52;
}
private static byte[] writeJsonBytes(Object payload, ObjectMapper objectMapper) {
try {
return objectMapper.writeValueAsBytes(payload);
} catch (IOException exception) {
throw new IllegalStateException(exception.getMessage(), exception);
}
}
public record Frame(int type, int typeFlag, int event, String sessionId, byte[] payload) {
}
}

View File

@@ -0,0 +1,89 @@
server:
port: ${PORT:3012}
spring:
application:
name: bigwo-java-server
mvc:
throw-exception-if-no-handler-found: true
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB
web:
resources:
add-mappings: false
logging:
level:
root: INFO
com.bigwo.javaserver: INFO
bigwo:
mysql:
enabled: ${MYSQL_ENABLED:true}
host: ${MYSQL_HOST:localhost}
port: ${MYSQL_PORT:3306}
database: ${MYSQL_DATABASE:bigwo_chat}
username: ${MYSQL_USER:root}
password: ${MYSQL_PASSWORD:}
max-pool-size: ${MYSQL_MAX_POOL_SIZE:10}
connection-timeout-ms: ${MYSQL_CONNECTION_TIMEOUT_MS:5000}
redis:
enabled: ${ENABLE_REDIS_CONTEXT:true}
url: ${REDIS_URL:redis://127.0.0.1:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DB:0}
key-prefix: ${REDIS_KEY_PREFIX:bigwo:}
timeout-ms: ${REDIS_TIMEOUT_MS:5000}
voice:
enabled: ${ENABLE_NATIVE_VOICE_GATEWAY:true}
upstream-url: ${VOLC_S2S_UPSTREAM_URL:wss://openspeech.bytedance.com/api/v3/realtime/dialogue}
resource-id: ${VOLC_S2S_RESOURCE_ID:volc.speech.dialog}
app-id: ${VOLC_S2S_APP_ID:}
token: ${VOLC_S2S_TOKEN:}
app-key: ${VOLC_DIALOG_APP_KEY:PlgvMymc7f3tQnJ6}
default-speaker: ${VOLC_S2S_SPEAKER_ID:zh_female_vv_jupiter_bigtts}
idle-timeout-ms: ${VOICE_IDLE_TIMEOUT_MS:300000}
audio-keepalive-interval-ms: ${VOICE_AUDIO_KEEPALIVE_INTERVAL_MS:20000}
send-ready-early: ${VOICE_SEND_READY_EARLY:true}
assistant-profile:
api-url: ${ASSISTANT_PROFILE_API_URL:}
api-method: ${ASSISTANT_PROFILE_API_METHOD:GET}
api-token: ${ASSISTANT_PROFILE_API_TOKEN:}
api-headers: ${ASSISTANT_PROFILE_API_HEADERS:}
timeout-ms: ${ASSISTANT_PROFILE_API_TIMEOUT_MS:5000}
cache-ttl-ms: ${ASSISTANT_PROFILE_CACHE_TTL_MS:60000}
coze:
base-url: ${COZE_BASE_URL:https://api.coze.cn}
api-token: ${COZE_API_TOKEN:}
bot-id: ${COZE_BOT_ID:}
knowledge:
access-key-id: ${VOLC_ACCESS_KEY_ID:}
secret-access-key: ${VOLC_SECRET_ACCESS_KEY:}
endpoint-id: ${VOLC_ARK_KNOWLEDGE_ENDPOINT_ID:${VOLC_ARK_ENDPOINT_ID:}}
model: ${VOLC_ARK_KB_MODEL:${VOLC_ARK_KNOWLEDGE_ENDPOINT_ID:${VOLC_ARK_ENDPOINT_ID:}}}
dataset-ids: ${VOLC_ARK_KNOWLEDGE_BASE_IDS:}
retrieval-top-k: ${VOLC_ARK_KB_RETRIEVAL_TOP_K:25}
threshold: ${VOLC_ARK_KNOWLEDGE_THRESHOLD:0.1}
reranker-model: ${VOLC_ARK_RERANKER_MODEL:${VOLC_ARK_RERANKER_ENDPOINT_ID:doubao-seed-rerank}}
reranker-top-n: ${VOLC_ARK_RERANKER_TOP_N:3}
enable-reranker: ${ENABLE_RERANKER:true}
enable-redis-context: ${ENABLE_REDIS_CONTEXT:true}
collection-map-json: ${VIKINGDB_COLLECTION_MAP:}
video:
seedance-api-key: ${SEEDANCE_API_KEY:}
seedance-api-base: ${SEEDANCE_API_BASE:https://n.lconai.com}
seedance-model: ${SEEDANCE_MODEL:seedance-2.0}
gemini-model: ${GEMINI_MODEL:}
gemini-api-key: ${GEMINI_API_KEY:${SEEDANCE_API_KEY:}}
gemini-api-base: ${GEMINI_API_BASE:${SEEDANCE_API_BASE:https://n.lconai.com}}
poll-interval-ms: ${SEEDANCE_POLL_INTERVAL_MS:5000}
poll-timeout-ms: ${SEEDANCE_POLL_TIMEOUT_MS:900000}
request-timeout-ms: ${VIDEO_HTTP_TIMEOUT_MS:1800000}
work-dir: ${VIDEO_WORK_DIR:}
max-concurrent-tasks: ${VIDEO_MAX_CONCURRENT_TASKS:4}
logo-path: ${VIDEO_LOGO_PATH:}
videos-dir: ${VIDEO_VIDEOS_DIR:}
ffmpeg-threads: ${VIDEO_FFMPEG_THREADS:1}
post-process-timeout-ms: ${VIDEO_POST_PROCESS_TIMEOUT_MS:180000}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<property name="CONSOLE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n"/>
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
<logger name="com.bigwo.javaserver" level="INFO"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>

View File

@@ -0,0 +1,64 @@
package com.bigwo.javaserver;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(properties = {
"MYSQL_ENABLED=false",
"ENABLE_REDIS_CONTEXT=false"
})
@AutoConfigureMockMvc
class ApiContractSmokeTest {
@Autowired
private MockMvc mockMvc;
@Test
void healthEndpointShouldMatchExpectedContract() throws Exception {
mockMvc.perform(get("/api/health"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("ok"))
.andExpect(jsonPath("$.mode").value("s2s-hybrid"))
.andExpect(jsonPath("$.apiVersion").value("2024-12-01"))
.andExpect(jsonPath("$.configured").value(true))
.andExpect(jsonPath("$.features.voiceChat").value(true))
.andExpect(jsonPath("$.features.redis").value(false));
}
@Test
void assistantProfileEndpointShouldReturnDefaultProfileWhenRemoteApiDisabled() throws Exception {
mockMvc.perform(get("/api/assistant-profile"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.message").value("success"))
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.profile.agentName").value("大沃"))
.andExpect(jsonPath("$.data.configured").value(false));
}
@Test
void assistantProfileRefreshEndpointShouldReturnDefaultProfileWhenRemoteApiDisabled() throws Exception {
mockMvc.perform(post("/api/assistant-profile/refresh")
.contentType("application/json")
.content("{}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.profile.nickname").value("大沃"));
}
@Test
void sessionListShouldReturnServerErrorWhenDatabaseIsUnavailable() throws Exception {
mockMvc.perform(get("/api/session/list"))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error").value("Database unavailable"));
}
}

View File

@@ -0,0 +1,61 @@
package com.bigwo.javaserver;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(properties = {
"MYSQL_ENABLED=false",
"ENABLE_REDIS_CONTEXT=false"
})
@AutoConfigureMockMvc
class ChatApiContractSmokeTest {
@Autowired
private MockMvc mockMvc;
@Test
void chatStartShouldFailWhenCozeIsNotConfigured() throws Exception {
mockMvc.perform(post("/api/chat/start")
.contentType("application/json")
.content("{\"sessionId\":\"chat-test-1\"}"))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error").value("Coze 智能体未配置,请设置 COZE_API_TOKEN 和 COZE_BOT_ID"));
}
@Test
void chatSendShouldReturnFastGreetingWithoutCoze() throws Exception {
mockMvc.perform(post("/api/chat/send")
.contentType("application/json")
.content("{\"sessionId\":\"chat-test-2\",\"message\":\"你好\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.content").value("你好😊我是大沃智能助手。你可以直接问我一成系统、德国PM产品、招商合作、营养科普等问题我会尽量快速给你准确回复。"));
}
@Test
void chatHistoryShouldReturnEmptyArrayForUnknownSession() throws Exception {
mockMvc.perform(get("/api/chat/history/unknown-session"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data").isArray());
}
@Test
void chatDeleteShouldReturnSuccess() throws Exception {
mockMvc.perform(delete("/api/chat/chat-test-3"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(content().json("{\"success\":true}"));
}
}

View File

@@ -0,0 +1,77 @@
package com.bigwo.javaserver;
import com.bigwo.javaserver.model.KnowledgeQueryInfo;
import com.bigwo.javaserver.model.LlmMessage;
import com.bigwo.javaserver.service.ContextKeywordTracker;
import com.bigwo.javaserver.service.FastAsrCorrector;
import com.bigwo.javaserver.service.KnowledgeKeywordCatalog;
import com.bigwo.javaserver.service.KnowledgeQueryResolver;
import com.bigwo.javaserver.service.KnowledgeRouteDecider;
import com.bigwo.javaserver.service.PinyinProductMatcher;
import java.util.List;
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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class KnowledgeServicesTest {
private KnowledgeQueryResolver knowledgeQueryResolver;
private KnowledgeRouteDecider knowledgeRouteDecider;
private ContextKeywordTracker contextKeywordTracker;
@BeforeEach
void setUp() {
KnowledgeKeywordCatalog keywordCatalog = new KnowledgeKeywordCatalog();
PinyinProductMatcher pinyinProductMatcher = new PinyinProductMatcher();
FastAsrCorrector fastAsrCorrector = new FastAsrCorrector(pinyinProductMatcher);
knowledgeQueryResolver = new KnowledgeQueryResolver(fastAsrCorrector, keywordCatalog);
knowledgeRouteDecider = new KnowledgeRouteDecider(keywordCatalog, knowledgeQueryResolver);
contextKeywordTracker = new ContextKeywordTracker(keywordCatalog, knowledgeQueryResolver);
}
@Test
void rewriteKnowledgeQueryShouldNormalizeWarmFurnaceAlias() {
assertEquals("火炉原理", knowledgeQueryResolver.rewriteKnowledgeQuery("暖炉原理是什么", List.of()));
}
@Test
void resolveKnowledgeQueryShouldApplySpecialEntityAlias() {
KnowledgeQueryInfo info = knowledgeQueryResolver.resolveKnowledgeQuery("太美怎么吃");
assertTrue(info.normalizedText().contains("肽美"));
assertEquals("肽美", info.primaryEntity());
assertTrue(info.hasExplicitEntity());
}
@Test
void knowledgeRouteDeciderShouldUseContextForFollowUpQuestion() {
boolean forced = knowledgeRouteDecider.shouldForceKnowledgeRoute(
"怎么吃",
List.of(new LlmMessage("assistant", "小红产品 Activize Oxyplus 的功效和吃法"))
);
assertTrue(forced);
}
@Test
void contextKeywordTrackerShouldReturnLatestKeywordForShortFollowUp() {
contextKeywordTracker.updateSession("session-1", "我想了解肽美的功效");
List<String> contextTerms = contextKeywordTracker.suggestContextTerms("session-1", "怎么吃");
assertEquals(List.of("肽美"), contextTerms);
}
@Test
void contextKeywordTrackerShouldNotInjectContextWhenQueryHasExplicitEntity() {
contextKeywordTracker.updateSession("session-2", "我想了解肽美的功效");
List<String> contextTerms = contextKeywordTracker.suggestContextTerms("session-2", "Basics 怎么吃");
assertTrue(contextTerms.isEmpty());
assertFalse(knowledgeRouteDecider.shouldForceKnowledgeRoute("你好", List.of()));
}
}

View File

@@ -0,0 +1,79 @@
package com.bigwo.javaserver;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(properties = {
"MYSQL_ENABLED=false",
"ENABLE_REDIS_CONTEXT=false",
"SEEDANCE_API_KEY=",
"GEMINI_MODEL="
})
@AutoConfigureMockMvc
class VideoApiContractSmokeTest {
@Autowired
private MockMvc mockMvc;
@Test
void videoAdminConfigShouldExposeDefaultModelContract() throws Exception {
mockMvc.perform(get("/api/video/admin/config"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.currentModel").value("seedance-2.0"))
.andExpect(jsonPath("$.source").value("default"))
.andExpect(jsonPath("$.validModels").isArray())
.andExpect(jsonPath("$.validModels[0]").value("seedance-2.0"));
}
@Test
void videoGenerateShouldReturnTaskIdImmediately() throws Exception {
MockMultipartFile emptyFile = new MockMultipartFile("image", "", "application/octet-stream", new byte[0]);
mockMvc.perform(multipart("/api/video/generate")
.file(emptyFile)
.param("prompt", "展示这个产品的高级质感")
.param("product", "PM 产品")
.param("username", "tester")
.param("template", "product")
.param("size", "720x1280")
.param("seconds", "15"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.taskId").exists())
.andExpect(jsonPath("$.status").value("optimizing"))
.andExpect(jsonPath("$.username").value("tester"));
}
@Test
void videoTaskShouldReturnNotFoundForUnknownId() throws Exception {
mockMvc.perform(get("/api/video/task/not-found-task"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").value("任务不存在"));
}
@Test
void videoHistoryShouldReturnRowsArray() throws Exception {
mockMvc.perform(get("/api/video/history"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.total").isNumber())
.andExpect(jsonPath("$.rows").isArray());
}
@Test
void videoAdminConfigShouldRejectInvalidModel() throws Exception {
mockMvc.perform(post("/api/video/admin/config")
.contentType("application/json")
.content("{\"model\":\"invalid-model\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("无效的模型名: \"invalid-model\""))
.andExpect(jsonPath("$.validModels").isArray());
}
}

View File

@@ -0,0 +1,183 @@
package com.bigwo.javaserver;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.bigwo.javaserver.service.KnowledgeQueryResolver;
import com.bigwo.javaserver.service.KnowledgeRouteDecider;
import com.bigwo.javaserver.service.ProductLinkTrigger;
import com.bigwo.javaserver.service.VoiceAssistantProfileSupport;
@SpringBootTest
class VoiceGatewaySmokeTest {
@Autowired
private ProductLinkTrigger productLinkTrigger;
@Autowired
private VoiceAssistantProfileSupport voiceAssistantProfileSupport;
// ── ProductLinkTrigger ──
@Test
void productLinkTrigger_matchesKnownProduct() {
ProductLinkTrigger.TriggerResult result = productLinkTrigger.check("看看基础三合一的详细介绍");
assertThat(result.triggered()).isTrue();
assertThat(result.productName()).isEqualTo("基础三合一");
assertThat(result.link()).isNotBlank();
}
@Test
void productLinkTrigger_noIntentNoTrigger() {
ProductLinkTrigger.TriggerResult result = productLinkTrigger.check("基础三合一好不好");
assertThat(result.triggered()).isFalse();
}
@Test
void productLinkTrigger_unknownProductNoTrigger() {
ProductLinkTrigger.TriggerResult result = productLinkTrigger.check("帮我查一下苹果手机的详细介绍");
assertThat(result.triggered()).isFalse();
}
@Test
void productLinkTrigger_emptyInput() {
assertThat(productLinkTrigger.check("").triggered()).isFalse();
assertThat(productLinkTrigger.check(null).triggered()).isFalse();
}
@Test
void productLinkTrigger_aliasMatch() {
ProductLinkTrigger.TriggerResult result = productLinkTrigger.check("我想了解一下三合一的详细信息");
assertThat(result.triggered()).isTrue();
assertThat(result.productName()).isEqualTo("基础三合一");
}
@Test
void productLinkTrigger_multiplePatternsMatch() {
assertThat(productLinkTrigger.check("给我看看活力健的详情").triggered()).isTrue();
assertThat(productLinkTrigger.check("看一下肽美的详细介绍").triggered()).isTrue();
assertThat(productLinkTrigger.check("给我发一下小白的链接").triggered()).isTrue();
}
// ── normalizeTextForSpeech ──
@Test
void normalizeTextForSpeech_removesMarkdown() {
String result = voiceAssistantProfileSupport.normalizeTextForSpeech("## 标题\n**加粗文字**");
assertThat(result).doesNotContain("#").doesNotContain("**");
assertThat(result).contains("标题").contains("加粗文字");
}
@Test
void normalizeTextForSpeech_handlesNull() {
assertThat(voiceAssistantProfileSupport.normalizeTextForSpeech(null)).isEmpty();
}
// ── splitTextForSpeech (via reflection or indirect test) ──
// We test the normalizer which splitTextForSpeech depends on.
// splitTextForSpeech is private in VoiceGatewayService; direct test requires
// exposing it or using integration test. The compile+context start already
// validates the wiring.
@Test
void normalizeTextForSpeech_preservesChinesePunctuation() {
String input = "你好,世界!这是一段测试。";
String result = voiceAssistantProfileSupport.normalizeTextForSpeech(input);
assertThat(result).isEqualTo(input);
}
// ── GAP-21: normalizeKnowledgeAlias ──
@Autowired
private KnowledgeQueryResolver knowledgeQueryResolver;
@Test
void normalizeKnowledgeAlias_dawo() {
assertThat(knowledgeQueryResolver.normalizeKnowledgeQueryAlias("大窝怎么样")).contains("大沃");
assertThat(knowledgeQueryResolver.normalizeKnowledgeQueryAlias("大握是什么")).contains("大沃");
assertThat(knowledgeQueryResolver.normalizeKnowledgeQueryAlias("大卧好不好")).contains("大沃");
}
@Test
void normalizeKnowledgeAlias_shengka() {
assertThat(knowledgeQueryResolver.normalizeKnowledgeQueryAlias("盛咖学院是什么")).contains("盛咖学愿");
assertThat(knowledgeQueryResolver.normalizeKnowledgeQueryAlias("圣咖学院怎么样")).contains("盛咖学愿");
}
@Test
void normalizeKnowledgeAlias_aizhongxiang() {
assertThat(knowledgeQueryResolver.normalizeKnowledgeQueryAlias("爱众享是什么")).contains("Ai众享");
assertThat(knowledgeQueryResolver.normalizeKnowledgeQueryAlias("艾众享怎么用")).contains("Ai众享");
}
@Test
void normalizeKnowledgeAlias_yicheng() {
assertThat(knowledgeQueryResolver.normalizeKnowledgeQueryAlias("一城系统介绍")).contains("一成系统");
assertThat(knowledgeQueryResolver.normalizeKnowledgeQueryAlias("易成系统是什么")).contains("一成系统");
}
// ── GAP-19: isKnowledgeFollowUp ──
@Autowired
private KnowledgeRouteDecider knowledgeRouteDecider;
@Test
void isKnowledgeFollowUp_directQuestions() {
assertThat(knowledgeRouteDecider.isKnowledgeFollowUp("详细说说")).isTrue();
assertThat(knowledgeRouteDecider.isKnowledgeFollowUp("怎么吃")).isTrue();
assertThat(knowledgeRouteDecider.isKnowledgeFollowUp("多少钱")).isTrue();
assertThat(knowledgeRouteDecider.isKnowledgeFollowUp("功效是什么")).isTrue();
}
@Test
void isKnowledgeFollowUp_corrections() {
assertThat(knowledgeRouteDecider.isKnowledgeFollowUp("你搞错了吧")).isTrue();
assertThat(knowledgeRouteDecider.isKnowledgeFollowUp("不是的")).isTrue();
assertThat(knowledgeRouteDecider.isKnowledgeFollowUp("帮我再查一下")).isTrue();
}
@Test
void isKnowledgeFollowUp_notFollowUp() {
assertThat(knowledgeRouteDecider.isKnowledgeFollowUp("你好")).isFalse();
assertThat(knowledgeRouteDecider.isKnowledgeFollowUp("")).isFalse();
}
// ── GAP-20: Brand sensitive pattern ──
private static final Pattern BRAND_SENSITIVE = Pattern.compile("传销|骗局|骗子|正规吗|合法吗|正不正规|合不合法|是不是传销|直销还是传销|层级分销|非法集资|拉人头|下线|发展下线|报单|人头费");
@Test
void brandSensitivePattern_matches() {
assertThat(BRAND_SENSITIVE.matcher("PM是不是传销").find()).isTrue();
assertThat(BRAND_SENSITIVE.matcher("德国PM正规吗").find()).isTrue();
assertThat(BRAND_SENSITIVE.matcher("这是骗局吧").find()).isTrue();
}
@Test
void brandSensitivePattern_noMatch() {
assertThat(BRAND_SENSITIVE.matcher("PM产品怎么样").find()).isFalse();
assertThat(BRAND_SENSITIVE.matcher("基础三合一功效").find()).isFalse();
}
// ── GAP-15: Consultant referral pattern ──
private static final Pattern CONSULTANT_REFERRAL = Pattern.compile("咨询(?:专业|你的)?顾问|健康管理顾问|联系顾问|一对一指导|咨询专业|咨询医生|咨询营养师|咨询专业人士|建议.*咨询|问问医生|问问.*营养师");
@Test
void consultantReferralPattern_matches() {
assertThat(CONSULTANT_REFERRAL.matcher("建议你咨询专业顾问").find()).isTrue();
assertThat(CONSULTANT_REFERRAL.matcher("可以联系顾问了解").find()).isTrue();
assertThat(CONSULTANT_REFERRAL.matcher("建议咨询医生").find()).isTrue();
assertThat(CONSULTANT_REFERRAL.matcher("问问营养师").find()).isTrue();
}
@Test
void consultantReferralPattern_noMatch() {
assertThat(CONSULTANT_REFERRAL.matcher("产品功效很好").find()).isFalse();
assertThat(CONSULTANT_REFERRAL.matcher("你好").find()).isFalse();
}
}

View File

@@ -10,6 +10,8 @@ class NativeVoiceService {
this.playbackTime = 0;
this.activeSources = new Set();
this.pendingSamples = [];
this.pendingAudioChunks = [];
this._resuming = false;
this.readyResolver = null;
this.readyRejector = null;
this.callbacks = {
@@ -19,6 +21,7 @@ class NativeVoiceService {
onAssistantPending: null,
onDiagnostic: null,
onIdleTimeout: null,
onProductLink: null,
};
}
@@ -87,6 +90,15 @@ class NativeVoiceService {
}
this.playbackTime = this.playbackContext.currentTime;
// 安全上下文检查: getUserMedia 需要 HTTPS 或 localhost
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
const errMsg = window.isSecureContext === false
? '麦克风访问需要 HTTPS 连接,请使用 https:// 地址访问'
: '当前浏览器不支持麦克风访问';
this.emitConnectionState('error', errMsg);
throw new Error(errMsg);
}
// 并行: 同时预获取麦克风和建立WS连接节省500ms+
const micPromise = navigator.mediaDevices.getUserMedia({
audio: {
@@ -97,7 +109,12 @@ class NativeVoiceService {
},
video: false,
}).catch((err) => {
console.warn('[NativeVoice] Pre-fetch getUserMedia failed:', err.message);
console.warn('[NativeVoice] Pre-fetch getUserMedia failed:', err.name, err.message);
if (err.name === 'NotAllowedError' || err.message?.includes('Permission denied')) {
const msg = '麦克风权限被拒绝,请在浏览器设置中允许本站访问麦克风后重试';
this.emitConnectionState('error', msg);
throw new Error(msg);
}
return null;
});
@@ -206,6 +223,14 @@ class NativeVoiceService {
this.callbacks.onIdleTimeout?.(msg.timeout || 300000);
return;
}
if (msg.type === 'product_link') {
this.callbacks.onProductLink?.({
product: msg.product,
link: msg.link,
description: msg.description,
});
return;
}
if (msg.type === 'upstream_closed') {
this.callbacks.onError?.(new Error('语音服务已断开,请重新开始通话'));
return;
@@ -224,6 +249,16 @@ class NativeVoiceService {
if (!this.playbackContext) {
return;
}
if (this.playbackContext.state === 'suspended') {
this.pendingAudioChunks.push(arrayBuffer);
this._tryResumePlayback();
return;
}
this._playPcm(arrayBuffer);
}
_playPcm(arrayBuffer) {
try {
const pcm16 = new Int16Array(arrayBuffer);
if (!pcm16.length) {
return;
@@ -250,9 +285,30 @@ class NativeVoiceService {
source.start(this.playbackTime);
this.playbackTime += audioBuffer.duration;
this.emitDiagnostic('audio_chunk', { samples: pcm16.length, duration: audioBuffer.duration });
} catch (err) {
console.warn('[NativeVoice] playPcm failed:', err.message);
}
}
async _tryResumePlayback() {
if (this._resuming) return;
this._resuming = true;
try {
await this.playbackContext.resume();
while (this.pendingAudioChunks.length > 0) {
this._playPcm(this.pendingAudioChunks.shift());
}
} catch (e) {
console.warn('[NativeVoice] resume failed:', e.message);
} finally {
this._resuming = false;
}
}
async startCapture(preFetchedStream) {
if (!preFetchedStream && (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia)) {
throw new Error('麦克风不可用: 需要 HTTPS 安全连接');
}
this.mediaStream = preFetchedStream || await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
@@ -365,6 +421,8 @@ class NativeVoiceService {
}
this.playbackTime = 0;
this.pendingSamples = [];
this.pendingAudioChunks = [];
this._resuming = false;
this.emitConnectionState('disconnected');
}