feat: add booking agent provider adapters

This commit is contained in:
malei 2026-08-02 16:50:18 +08:00
parent d99b6d6e6d
commit 77c6d6923b
26 changed files with 1780 additions and 0 deletions

View File

@ -22,6 +22,9 @@
| `WECHAT_APPSECRET` | 生产必填 | 空 | 微信小程序 AppSecret | | `WECHAT_APPSECRET` | 生产必填 | 空 | 微信小程序 AppSecret |
| `PETSTORE_SESSION_SECRET` | **生产必填** | `dev-change-me` | HMAC session token 签名密钥;改密会使所有已签发 token 立即失效 | | `PETSTORE_SESSION_SECRET` | **生产必填** | `dev-change-me` | HMAC session token 签名密钥;改密会使所有已签发 token 立即失效 |
| `PETSTORE_SESSION_TTL_SECONDS` | 否 | `604800`7 天) | session token 有效期;生产限制为 3002592000 秒 | | `PETSTORE_SESSION_TTL_SECONDS` | 否 | `604800`7 天) | session token 有效期;生产限制为 3002592000 秒 |
| `PETSTORE_BOOKING_AGENT_ENABLED` | 否 | `false` | 智能预约 M0 总开关;默认关闭 |
| `PETSTORE_BOOKING_AGENT_LLM_*` | 开启时必填 | 固定模型/3s 超时 | `PROVIDER/BASE_URL/MODEL/API_KEY/TIMEOUT_MS`,意图提取逻辑配置 |
| `PETSTORE_BOOKING_AGENT_ASR_*` | 开启时必填 | 固定模型/5s 超时 | `PROVIDER/BASE_URL/MODEL/API_KEY/TIMEOUT_MS`,语音转写逻辑配置 |
| `APP_BASE_URL` | 生产必填 | `http://localhost:8080` | 后端对外可访问 base URL用于生成媒体绝对 URL | | `APP_BASE_URL` | 生产必填 | `http://localhost:8080` | 后端对外可访问 base URL用于生成媒体绝对 URL |
| `CORS_ALLOWED_ORIGINS` | **生产必填** | 本地开发源 | 逗号分隔的显式 HTTPS Web 源;禁止 `*`、localhost 和占位域名 | | `CORS_ALLOWED_ORIGINS` | **生产必填** | 本地开发源 | 逗号分隔的显式 HTTPS Web 源;禁止 `*`、localhost 和占位域名 |
| `UPLOAD_PATH` | 生产必填 | `/www/petstore/uploads` | 上传目录绝对路径;服务账号需可读写 | | `UPLOAD_PATH` | 生产必填 | `/www/petstore/uploads` | 上传目录绝对路径;服务账号需可读写 |
@ -40,6 +43,8 @@ CORS 只由全局 `CorsFilter` 和 `CORS_ALLOWED_ORIGINS` 控制Controller
Session token 的 `exp` 为必填 Unix 秒时间戳;缺失、非数字、非正或已到期均 fail-closed。非正 TTL 不再静默回退,生产 TTL 只能为 5 分钟至 30 天。 Session token 的 `exp` 为必填 Unix 秒时间戳;缺失、非数字、非正或已到期均 fail-closed。非正 TTL 不再静默回退,生产 TTL 只能为 5 分钟至 30 天。
智能预约 provider 仅允许 HTTPS base URL测试可使用 loopback HTTP不自动重试不记录输入原文、音频、外部响应或密钥。开关关闭或配置缺失时 provider fail closed不影响普通预约和应用启动。
> ⚠️ **密钥轮换**`application.yml` 历史版本曾提交过真实 DB 密码与微信 AppSecret。这些凭据已在仓库历史中暴露**必须按安全流程轮换**:改 DB 密码、重置微信 AppSecret、更换 `PETSTORE_SESSION_SECRET` > ⚠️ **密钥轮换**`application.yml` 历史版本曾提交过真实 DB 密码与微信 AppSecret。这些凭据已在仓库历史中暴露**必须按安全流程轮换**:改 DB 密码、重置微信 AppSecret、更换 `PETSTORE_SESSION_SECRET`
## 本地开发 ## 本地开发

View File

@ -13,6 +13,19 @@ PETSTORE_SESSION_SECRET=
PETSTORE_SESSION_TTL_SECONDS=604800 PETSTORE_SESSION_TTL_SECONDS=604800
SMS_UNIVERSAL_CODE= SMS_UNIVERSAL_CODE=
# 智能预约 M0 默认关闭。真实 Workspace URL/API Key 只写入服务器 0600 EnvironmentFile。
PETSTORE_BOOKING_AGENT_ENABLED=false
PETSTORE_BOOKING_AGENT_LLM_PROVIDER=aliyun
PETSTORE_BOOKING_AGENT_LLM_BASE_URL=
PETSTORE_BOOKING_AGENT_LLM_MODEL=qwen-plus-2025-12-01
PETSTORE_BOOKING_AGENT_LLM_API_KEY=
PETSTORE_BOOKING_AGENT_LLM_TIMEOUT_MS=3000
PETSTORE_BOOKING_AGENT_ASR_PROVIDER=aliyun
PETSTORE_BOOKING_AGENT_ASR_BASE_URL=
PETSTORE_BOOKING_AGENT_ASR_MODEL=qwen3-asr-flash-2026-02-10
PETSTORE_BOOKING_AGENT_ASR_API_KEY=
PETSTORE_BOOKING_AGENT_ASR_TIMEOUT_MS=5000
APP_BASE_URL=https://api.petstore.invalid APP_BASE_URL=https://api.petstore.invalid
CORS_ALLOWED_ORIGINS=https://admin.petstore.invalid,https://report.petstore.invalid CORS_ALLOWED_ORIGINS=https://admin.petstore.invalid,https://report.petstore.invalid
UPLOAD_PATH=/var/lib/petstore/uploads UPLOAD_PATH=/var/lib/petstore/uploads

View File

@ -0,0 +1,89 @@
package com.petstore.bookingagent.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 智能预约外部供应商配置
*
* <p>属性在启动时不强制完整功能默认关闭未配置密钥时普通预约仍应正常启动
* 真正调用 provider 前由适配器 fail closed
*/
@ConfigurationProperties(prefix = "app.booking-agent")
public class BookingAgentProperties {
private boolean enabled;
private final Provider llm = new Provider("qwen-plus-2025-12-01", 3000);
private final Provider asr = new Provider("qwen3-asr-flash-2026-02-10", 5000);
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public Provider getLlm() {
return llm;
}
public Provider getAsr() {
return asr;
}
public static class Provider {
private String provider = "aliyun";
private String baseUrl = "";
private String model;
private String apiKey = "";
private int timeoutMs;
public Provider() {
}
Provider(String model, int timeoutMs) {
this.model = model;
this.timeoutMs = timeoutMs;
}
public String getProvider() {
return provider;
}
public void setProvider(String provider) {
this.provider = provider;
}
public String getBaseUrl() {
return baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public int getTimeoutMs() {
return timeoutMs;
}
public void setTimeoutMs(int timeoutMs) {
this.timeoutMs = timeoutMs;
}
}
}

View File

@ -0,0 +1,51 @@
package com.petstore.bookingagent.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.petstore.bookingagent.provider.BookingIntentExtractor;
import com.petstore.bookingagent.provider.BookingIntentValidator;
import com.petstore.bookingagent.provider.SpeechTranscriber;
import com.petstore.bookingagent.provider.aliyun.Qwen3AsrSpeechTranscriber;
import com.petstore.bookingagent.provider.aliyun.QwenBookingIntentExtractor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.net.http.HttpClient;
import java.time.Duration;
@Configuration
@EnableConfigurationProperties(BookingAgentProperties.class)
public class BookingAgentProviderConfiguration {
@Bean
BookingIntentValidator bookingIntentValidator(ObjectMapper objectMapper) {
return new BookingIntentValidator(objectMapper);
}
@Bean("bookingAgentHttpClient")
HttpClient bookingAgentHttpClient() {
return HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.followRedirects(HttpClient.Redirect.NEVER)
.version(HttpClient.Version.HTTP_1_1)
.build();
}
@Bean
BookingIntentExtractor bookingIntentExtractor(
BookingAgentProperties properties,
ObjectMapper objectMapper,
BookingIntentValidator validator,
@Qualifier("bookingAgentHttpClient") HttpClient httpClient) {
return new QwenBookingIntentExtractor(properties, objectMapper, validator, httpClient);
}
@Bean
SpeechTranscriber speechTranscriber(
BookingAgentProperties properties,
ObjectMapper objectMapper,
@Qualifier("bookingAgentHttpClient") HttpClient httpClient) {
return new Qwen3AsrSpeechTranscriber(properties, objectMapper, httpClient);
}
}

View File

@ -0,0 +1,6 @@
package com.petstore.bookingagent.provider;
public interface BookingIntentExtractor {
BookingIntentPatch extract(BookingIntentRequest request);
}

View File

@ -0,0 +1,107 @@
package com.petstore.bookingagent.provider;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.List;
public record BookingIntentPatch(
String schemaVersion,
Intent intent,
String petQuery,
String serviceQuery,
String dateExpression,
TimeWindow timeWindow,
String remark,
List<ClearField> clearFields,
List<Ambiguity> ambiguities,
NextAction nextAction) {
public static final String SCHEMA_VERSION = "booking-intent-v1";
public record TimeWindow(String start, String end) {
}
public enum Intent implements WireValue {
BOOK("book"),
MODIFY("modify"),
END("end"),
FALLBACK("fallback");
private final String wireValue;
Intent(String wireValue) {
this.wireValue = wireValue;
}
@Override
@JsonValue
public String wireValue() {
return wireValue;
}
}
public enum ClearField implements WireValue {
PET_QUERY("petQuery"),
SERVICE_QUERY("serviceQuery"),
DATE_EXPRESSION("dateExpression"),
TIME_WINDOW("timeWindow"),
REMARK("remark");
private final String wireValue;
ClearField(String wireValue) {
this.wireValue = wireValue;
}
@Override
@JsonValue
public String wireValue() {
return wireValue;
}
}
public enum Ambiguity implements WireValue {
PET("pet"),
SERVICE("service"),
DATE("date"),
TIME("time"),
REMARK("remark");
private final String wireValue;
Ambiguity(String wireValue) {
this.wireValue = wireValue;
}
@Override
@JsonValue
public String wireValue() {
return wireValue;
}
}
public enum NextAction implements WireValue {
ASK("ask"),
RESOLVE_CONTEXT("resolve_context"),
SEARCH_SLOTS("search_slots"),
SHOW_DRAFT("show_draft"),
FALLBACK("fallback"),
END("end");
private final String wireValue;
NextAction(String wireValue) {
this.wireValue = wireValue;
}
@Override
@JsonValue
public String wireValue() {
return wireValue;
}
}
interface WireValue {
String wireValue();
}
}

View File

@ -0,0 +1,27 @@
package com.petstore.bookingagent.provider;
public record BookingIntentRequest(String text, BookingIntentPatch currentIntent) {
private static final int MAX_TEXT_LENGTH = 500;
public BookingIntentRequest {
text = normalize(text);
if (text == null
|| text.length() > MAX_TEXT_LENGTH
|| SensitiveTextGuard.containsForbiddenValue(text)) {
throw ProviderException.invalidInput();
}
}
private static String normalize(String value) {
if (value == null) {
return null;
}
StringBuilder normalized = new StringBuilder(value.length());
value.strip().codePoints()
.filter(codePoint -> !Character.isISOControl(codePoint))
.forEach(normalized::appendCodePoint);
String result = normalized.toString().strip();
return result.isEmpty() ? null : result;
}
}

View File

@ -0,0 +1,188 @@
package com.petstore.bookingagent.provider;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.petstore.bookingagent.provider.BookingIntentPatch.Ambiguity;
import com.petstore.bookingagent.provider.BookingIntentPatch.ClearField;
import com.petstore.bookingagent.provider.BookingIntentPatch.Intent;
import com.petstore.bookingagent.provider.BookingIntentPatch.NextAction;
import com.petstore.bookingagent.provider.BookingIntentPatch.TimeWindow;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
/** 与 docs/contracts/booking-intent-v1.schema.json 对齐的服务端强校验。 */
public class BookingIntentValidator {
private static final int MAX_RESPONSE_LENGTH = 16 * 1024;
private static final Pattern HH_MM = Pattern.compile("^(?:[01]\\d|2[0-3]):[0-5]\\d$");
private static final Set<String> ROOT_FIELDS = Set.of(
"schemaVersion",
"intent",
"petQuery",
"serviceQuery",
"dateExpression",
"timeWindow",
"remark",
"clearFields",
"ambiguities",
"nextAction"
);
private static final Set<String> TIME_WINDOW_FIELDS = Set.of("start", "end");
private final ObjectMapper objectMapper;
public BookingIntentValidator(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public BookingIntentPatch parseAndValidate(String content) {
if (content == null || content.isBlank() || content.length() > MAX_RESPONSE_LENGTH) {
throw ProviderException.invalidResponse();
}
JsonNode root;
try {
root = objectMapper.reader()
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.readTree(content);
} catch (JsonProcessingException exception) {
throw ProviderException.invalidResponse();
}
if (root == null || !root.isObject() || !fieldNames(root).equals(ROOT_FIELDS)) {
throw ProviderException.invalidResponse();
}
String schemaVersion = requiredText(root, "schemaVersion", 64);
if (!BookingIntentPatch.SCHEMA_VERSION.equals(schemaVersion)) {
throw ProviderException.invalidResponse();
}
return new BookingIntentPatch(
schemaVersion,
enumValue(root, "intent", Intent.values()),
nullableText(root, "petQuery", 64),
nullableText(root, "serviceQuery", 64),
nullableText(root, "dateExpression", 32),
timeWindow(root.get("timeWindow")),
nullableText(root, "remark", 200),
enumList(root.get("clearFields"), 5, ClearField.values()),
enumList(root.get("ambiguities"), 5, Ambiguity.values()),
enumValue(root, "nextAction", NextAction.values())
);
}
private TimeWindow timeWindow(JsonNode node) {
if (node == null || node.isNull()) {
return null;
}
if (!node.isObject() || !fieldNames(node).equals(TIME_WINDOW_FIELDS)) {
throw ProviderException.invalidResponse();
}
String start = nullableTime(node.get("start"));
String end = nullableTime(node.get("end"));
return new TimeWindow(start, end);
}
private String nullableTime(JsonNode node) {
if (node == null || node.isNull()) {
return null;
}
if (!node.isTextual()) {
throw ProviderException.invalidResponse();
}
String value = normalize(node.textValue());
if (value == null || !HH_MM.matcher(value).matches()) {
throw ProviderException.invalidResponse();
}
return value;
}
private String requiredText(JsonNode root, String field, int maxLength) {
String value = nullableText(root, field, maxLength);
if (value == null) {
throw ProviderException.invalidResponse();
}
return value;
}
private String nullableText(JsonNode root, String field, int maxLength) {
JsonNode node = root.get(field);
if (node == null || node.isNull()) {
return null;
}
if (!node.isTextual()) {
throw ProviderException.invalidResponse();
}
String value = normalize(node.textValue());
if (value == null
|| value.length() > maxLength
|| SensitiveTextGuard.containsForbiddenValue(value)) {
throw ProviderException.invalidResponse();
}
return value;
}
private <E extends Enum<E> & BookingIntentPatch.WireValue> E enumValue(
JsonNode root,
String field,
E[] values) {
String raw = requiredText(root, field, 64);
return findEnum(raw, values);
}
private <E extends Enum<E> & BookingIntentPatch.WireValue> List<E> enumList(
JsonNode node,
int maxItems,
E[] values) {
if (node == null || !node.isArray() || node.size() > maxItems) {
throw ProviderException.invalidResponse();
}
List<E> result = new ArrayList<>(node.size());
Set<E> unique = new HashSet<>();
for (JsonNode item : node) {
if (!item.isTextual()) {
throw ProviderException.invalidResponse();
}
E parsed = findEnum(item.textValue(), values);
if (!unique.add(parsed)) {
throw ProviderException.invalidResponse();
}
result.add(parsed);
}
return List.copyOf(result);
}
private <E extends Enum<E> & BookingIntentPatch.WireValue> E findEnum(String raw, E[] values) {
for (E value : values) {
if (value.wireValue().equals(raw)) {
return value;
}
}
throw ProviderException.invalidResponse();
}
private Set<String> fieldNames(JsonNode node) {
Set<String> names = new HashSet<>();
Iterator<String> iterator = node.fieldNames();
iterator.forEachRemaining(names::add);
return names;
}
private String normalize(String value) {
if (value == null) {
return null;
}
StringBuilder normalized = new StringBuilder(value.length());
value.strip().codePoints()
.filter(codePoint -> !Character.isISOControl(codePoint))
.forEach(normalized::appendCodePoint);
String result = normalized.toString().strip();
return result.isEmpty() ? null : result;
}
}

View File

@ -0,0 +1,63 @@
package com.petstore.bookingagent.provider;
/** 供应商层只暴露可映射的失败类型,不携带外部响应原文或请求载荷。 */
public class ProviderException extends RuntimeException {
public enum Reason {
DISABLED,
CONFIGURATION,
INVALID_INPUT,
TIMEOUT,
UPSTREAM,
INVALID_RESPONSE
}
private final Reason reason;
private final Integer httpStatus;
private ProviderException(Reason reason, Integer httpStatus) {
super(reason.name());
this.reason = reason;
this.httpStatus = httpStatus;
}
public static ProviderException disabled() {
return new ProviderException(Reason.DISABLED, null);
}
public static ProviderException configuration() {
return new ProviderException(Reason.CONFIGURATION, null);
}
public static ProviderException invalidInput() {
return new ProviderException(Reason.INVALID_INPUT, null);
}
public static ProviderException timeout() {
return new ProviderException(Reason.TIMEOUT, null);
}
public static ProviderException upstream(int httpStatus) {
return new ProviderException(Reason.UPSTREAM, httpStatus);
}
public static ProviderException upstream() {
return new ProviderException(Reason.UPSTREAM, null);
}
public static ProviderException invalidResponse() {
return new ProviderException(Reason.INVALID_RESPONSE, null);
}
public Reason getReason() {
return reason;
}
public Integer getHttpStatus() {
return httpStatus;
}
public boolean isRetryable() {
return reason == Reason.TIMEOUT || reason == Reason.UPSTREAM;
}
}

View File

@ -0,0 +1,25 @@
package com.petstore.bookingagent.provider;
import java.util.regex.Pattern;
public final class SensitiveTextGuard {
private static final Pattern MAINLAND_MOBILE = Pattern.compile("(?<!\\d)1[3-9]\\d{9}(?!\\d)");
private static final Pattern URL_OR_CREDENTIAL = Pattern.compile(
"(?i)(?:https?://|bearer\\s+[a-z0-9._~-]+|(?:report|access|session)[_-]?token\\s*[:=]|api[_ -]?key\\s*[:=])"
);
private static final Pattern JWT = Pattern.compile(
"(?i)(?<![a-z0-9_-])eyJ[a-z0-9_-]{8,}\\.[a-z0-9_-]{8,}\\.[a-z0-9_-]{8,}(?![a-z0-9_-])"
);
private SensitiveTextGuard() {
}
public static boolean containsForbiddenValue(String value) {
return value != null && (
MAINLAND_MOBILE.matcher(value).find()
|| URL_OR_CREDENTIAL.matcher(value).find()
|| JWT.matcher(value).find()
);
}
}

View File

@ -0,0 +1,6 @@
package com.petstore.bookingagent.provider;
public interface SpeechTranscriber {
SpeechTranscription transcribe(SpeechTranscriptionRequest request);
}

View File

@ -0,0 +1,4 @@
package com.petstore.bookingagent.provider;
public record SpeechTranscription(String text) {
}

View File

@ -0,0 +1,20 @@
package com.petstore.bookingagent.provider;
import java.util.List;
public record SpeechTranscriptionRequest(byte[] audio, String mimeType, List<String> contextTerms) {
public SpeechTranscriptionRequest {
audio = audio == null ? null : audio.clone();
try {
contextTerms = contextTerms == null ? List.of() : List.copyOf(contextTerms);
} catch (NullPointerException exception) {
throw ProviderException.invalidInput();
}
}
@Override
public byte[] audio() {
return audio == null ? null : audio.clone();
}
}

View File

@ -0,0 +1,149 @@
package com.petstore.bookingagent.provider.aliyun;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.petstore.bookingagent.config.BookingAgentProperties;
import com.petstore.bookingagent.provider.ProviderException;
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.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Locale;
import java.util.Set;
final class OpenAiCompatibleTransport {
private static final int MAX_RESPONSE_BYTES = 64 * 1024;
private static final Set<String> LOOPBACK_HOSTS = Set.of("localhost", "127.0.0.1", "::1", "[::1]");
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
OpenAiCompatibleTransport(ObjectMapper objectMapper, HttpClient httpClient) {
this.objectMapper = objectMapper;
this.httpClient = httpClient;
}
String postChatCompletion(BookingAgentProperties.Provider provider, Object requestBody) {
URI endpoint = endpoint(provider);
byte[] requestBytes;
try {
requestBytes = objectMapper.writeValueAsBytes(requestBody);
} catch (JsonProcessingException exception) {
throw ProviderException.invalidInput();
}
HttpRequest request = HttpRequest.newBuilder()
.uri(endpoint)
.timeout(Duration.ofMillis(provider.getTimeoutMs()))
.header("Authorization", "Bearer " + provider.getApiKey().strip())
.header("Content-Type", "application/json; charset=UTF-8")
.header("Accept", "application/json")
.header("User-Agent", "PetstoreBookingAgent/0.1")
.POST(HttpRequest.BodyPublishers.ofByteArray(requestBytes))
.build();
HttpResponse<byte[]> response;
try {
response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
} catch (HttpTimeoutException exception) {
throw ProviderException.timeout();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw ProviderException.timeout();
} catch (IOException exception) {
throw ProviderException.upstream();
}
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw ProviderException.upstream(response.statusCode());
}
byte[] body = response.body();
if (body == null || body.length == 0 || body.length > MAX_RESPONSE_BYTES) {
throw ProviderException.invalidResponse();
}
JsonNode root;
try {
root = objectMapper.reader()
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.readTree(new String(body, StandardCharsets.UTF_8));
} catch (JsonProcessingException exception) {
throw ProviderException.invalidResponse();
}
JsonNode choice = root.path("choices").path(0);
if (!"stop".equals(choice.path("finish_reason").asText())) {
throw ProviderException.invalidResponse();
}
JsonNode content = choice.path("message").path("content");
if (!content.isTextual() || content.textValue().isBlank()) {
throw ProviderException.invalidResponse();
}
return content.textValue();
}
static void requireAvailable(boolean enabled, BookingAgentProperties.Provider provider) {
if (!enabled) {
throw ProviderException.disabled();
}
if (provider == null
|| !"aliyun".equalsIgnoreCase(normalize(provider.getProvider()))
|| isBlank(provider.getBaseUrl())
|| isBlank(provider.getModel())
|| isPlaceholder(provider.getApiKey())
|| provider.getTimeoutMs() < 50
|| provider.getTimeoutMs() > 30_000) {
throw ProviderException.configuration();
}
}
private static URI endpoint(BookingAgentProperties.Provider provider) {
String baseUrl = provider.getBaseUrl().strip();
String value = baseUrl.endsWith("/chat/completions")
? baseUrl
: baseUrl.replaceAll("/+$", "") + "/chat/completions";
URI uri;
try {
uri = URI.create(value);
} catch (IllegalArgumentException exception) {
throw ProviderException.configuration();
}
String scheme = normalize(uri.getScheme());
String host = normalize(uri.getHost());
boolean localHttp = "http".equals(scheme) && LOOPBACK_HOSTS.contains(host);
if (!("https".equals(scheme) || localHttp)
|| host == null
|| uri.getUserInfo() != null
|| uri.getQuery() != null
|| uri.getFragment() != null) {
throw ProviderException.configuration();
}
return uri;
}
private static boolean isPlaceholder(String value) {
if (isBlank(value)) {
return true;
}
String normalized = value.strip().toLowerCase(Locale.ROOT);
return normalized.startsWith("<")
|| normalized.contains("from-secret-store")
|| normalized.contains("change-me")
|| normalized.contains("your_api_key");
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
private static String normalize(String value) {
return value == null ? null : value.strip().toLowerCase(Locale.ROOT);
}
}

View File

@ -0,0 +1,140 @@
package com.petstore.bookingagent.provider.aliyun;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.petstore.bookingagent.config.BookingAgentProperties;
import com.petstore.bookingagent.provider.ProviderException;
import com.petstore.bookingagent.provider.SensitiveTextGuard;
import com.petstore.bookingagent.provider.SpeechTranscriber;
import com.petstore.bookingagent.provider.SpeechTranscription;
import com.petstore.bookingagent.provider.SpeechTranscriptionRequest;
import java.net.http.HttpClient;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
public class Qwen3AsrSpeechTranscriber implements SpeechTranscriber {
static final int MAX_AUDIO_BYTES = 3 * 1024 * 1024;
private static final int MAX_TRANSCRIPT_LENGTH = 500;
private static final int MAX_CONTEXT_TERMS = 30;
private static final int MAX_CONTEXT_TERM_LENGTH = 64;
private static final int MAX_CONTEXT_LENGTH = 1000;
private static final Set<String> ALLOWED_MIME_TYPES = Set.of(
"audio/aac",
"audio/amr",
"audio/mpeg",
"audio/ogg",
"audio/opus",
"audio/wav",
"audio/webm",
"video/webm"
);
private final BookingAgentProperties properties;
private final OpenAiCompatibleTransport transport;
public Qwen3AsrSpeechTranscriber(
BookingAgentProperties properties,
ObjectMapper objectMapper,
HttpClient httpClient) {
this.properties = properties;
this.transport = new OpenAiCompatibleTransport(objectMapper, httpClient);
}
@Override
public SpeechTranscription transcribe(SpeechTranscriptionRequest request) {
if (request == null) {
throw ProviderException.invalidInput();
}
BookingAgentProperties.Provider provider = properties.getAsr();
OpenAiCompatibleTransport.requireAvailable(properties.isEnabled(), provider);
byte[] audio = request.audio();
String mimeType = normalizeMimeType(request.mimeType());
if (audio == null
|| audio.length == 0
|| audio.length > MAX_AUDIO_BYTES
|| !ALLOWED_MIME_TYPES.contains(mimeType)) {
throw ProviderException.invalidInput();
}
List<Map<String, Object>> messages = new ArrayList<>();
String context = contextMessage(request.contextTerms());
if (!context.isEmpty()) {
messages.add(Map.of("role", "system", "content", context));
}
String dataUrl = "data:" + mimeType + ";base64," + Base64.getEncoder().encodeToString(audio);
messages.add(Map.of(
"role", "user",
"content", List.of(Map.of(
"type", "input_audio",
"input_audio", Map.of("data", dataUrl)
))
));
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", provider.getModel().strip());
body.put("messages", messages);
body.put("stream", false);
body.put("asr_options", Map.of("enable_itn", true));
String content = normalizeText(transport.postChatCompletion(provider, body));
if (content == null || content.length() > MAX_TRANSCRIPT_LENGTH) {
throw ProviderException.invalidResponse();
}
return new SpeechTranscription(content);
}
private String contextMessage(List<String> terms) {
if (terms == null || terms.isEmpty()) {
return "";
}
if (terms.size() > MAX_CONTEXT_TERMS) {
throw ProviderException.invalidInput();
}
List<String> sanitized = new ArrayList<>(terms.size());
for (String term : terms) {
String normalized = normalizeText(term);
if (normalized == null
|| normalized.length() > MAX_CONTEXT_TERM_LENGTH
|| SensitiveTextGuard.containsForbiddenValue(normalized)) {
throw ProviderException.invalidInput();
}
sanitized.add(normalized);
}
String context = "背景词仅用于语音识别,可能包含宠物名和当前门店服务名:" + String.join("", sanitized);
if (context.length() > MAX_CONTEXT_LENGTH) {
throw ProviderException.invalidInput();
}
return context;
}
private String normalizeMimeType(String mimeType) {
if (mimeType == null) {
return "";
}
String normalized = mimeType.strip().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "audio/x-wav" -> "audio/wav";
case "audio/mp3" -> "audio/mpeg";
default -> normalized;
};
}
private String normalizeText(String value) {
if (value == null) {
return null;
}
StringBuilder normalized = new StringBuilder(value.length());
value.strip().codePoints()
.filter(codePoint -> !Character.isISOControl(codePoint))
.forEach(normalized::appendCodePoint);
String result = normalized.toString().strip();
return result.isEmpty() ? null : result;
}
}

View File

@ -0,0 +1,81 @@
package com.petstore.bookingagent.provider.aliyun;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.petstore.bookingagent.config.BookingAgentProperties;
import com.petstore.bookingagent.provider.BookingIntentExtractor;
import com.petstore.bookingagent.provider.BookingIntentPatch;
import com.petstore.bookingagent.provider.BookingIntentRequest;
import com.petstore.bookingagent.provider.BookingIntentValidator;
import com.petstore.bookingagent.provider.ProviderException;
import java.net.http.HttpClient;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class QwenBookingIntentExtractor implements BookingIntentExtractor {
private static final String SYSTEM_PROMPT = """
你是宠物门店预约意图提取器只输出一个 JSON object不要 Markdown解释或额外文字
必须恰好包含字段schemaVersion,intent,petQuery,serviceQuery,dateExpression,timeWindow,remark,clearFields,ambiguities,nextAction
schemaVersion 固定 booking-intent-v1
intent 只能为 book,modify,end,fallback
nextAction 只能为 ask,resolve_context,search_slots,show_draft,fallback,end
clearFields 只能包含 petQuery,serviceQuery,dateExpression,timeWindow,remark
ambiguities 只能包含 pet,service,date,time,remark
timeWindow null 或对象例如 {"start":"15:00","end":null}start/end 各自只能是 HH:mm 字符串或 null
不得输出业务 ID价格号源预约状态门店或宠物归属事实无法确定时用 null ambiguities不要猜测
""";
private final BookingAgentProperties properties;
private final ObjectMapper objectMapper;
private final BookingIntentValidator validator;
private final OpenAiCompatibleTransport transport;
public QwenBookingIntentExtractor(
BookingAgentProperties properties,
ObjectMapper objectMapper,
BookingIntentValidator validator,
HttpClient httpClient) {
this.properties = properties;
this.objectMapper = objectMapper;
this.validator = validator;
this.transport = new OpenAiCompatibleTransport(objectMapper, httpClient);
}
@Override
public BookingIntentPatch extract(BookingIntentRequest request) {
if (request == null) {
throw ProviderException.invalidInput();
}
BookingAgentProperties.Provider provider = properties.getLlm();
OpenAiCompatibleTransport.requireAvailable(properties.isEnabled(), provider);
Map<String, Object> userPayload = new LinkedHashMap<>();
userPayload.put("currentIntent", request.currentIntent());
userPayload.put("text", request.text());
String userContent;
try {
userContent = objectMapper.writeValueAsString(userPayload);
} catch (JsonProcessingException exception) {
throw ProviderException.invalidInput();
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", provider.getModel().strip());
body.put("messages", List.of(
Map.of("role", "system", "content", SYSTEM_PROMPT),
Map.of("role", "user", "content", userContent)
));
body.put("response_format", Map.of("type", "json_object"));
body.put("stream", false);
body.put("enable_thinking", false);
body.put("temperature", 0.1);
body.put("max_completion_tokens", 800);
String content = transport.postChatCompletion(provider, body);
return validator.parseAndValidate(content);
}
}

View File

@ -52,6 +52,21 @@ app:
preflight-only: false preflight-only: false
demo: demo:
sms-universal-code: ${SMS_UNIVERSAL_CODE:123456} sms-universal-code: ${SMS_UNIVERSAL_CODE:123456}
booking-agent:
# M0 默认关闭;未配置真实密钥时应 fail closed不影响普通预约启动。
enabled: ${PETSTORE_BOOKING_AGENT_ENABLED:false}
llm:
provider: ${PETSTORE_BOOKING_AGENT_LLM_PROVIDER:aliyun}
base-url: ${PETSTORE_BOOKING_AGENT_LLM_BASE_URL:}
model: ${PETSTORE_BOOKING_AGENT_LLM_MODEL:qwen-plus-2025-12-01}
api-key: ${PETSTORE_BOOKING_AGENT_LLM_API_KEY:}
timeout-ms: ${PETSTORE_BOOKING_AGENT_LLM_TIMEOUT_MS:3000}
asr:
provider: ${PETSTORE_BOOKING_AGENT_ASR_PROVIDER:aliyun}
base-url: ${PETSTORE_BOOKING_AGENT_ASR_BASE_URL:}
model: ${PETSTORE_BOOKING_AGENT_ASR_MODEL:qwen3-asr-flash-2026-02-10}
api-key: ${PETSTORE_BOOKING_AGENT_ASR_API_KEY:}
timeout-ms: ${PETSTORE_BOOKING_AGENT_ASR_TIMEOUT_MS:5000}
highlight-video: highlight-video:
ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg} ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg}
ffprobe-binary: ${HIGHLIGHT_FFPROBE:ffprobe} ffprobe-binary: ${HIGHLIGHT_FFPROBE:ffprobe}

View File

@ -52,6 +52,20 @@ app:
preflight-only: false preflight-only: false
demo: demo:
sms-universal-code: ${SMS_UNIVERSAL_CODE:123456} sms-universal-code: ${SMS_UNIVERSAL_CODE:123456}
booking-agent:
enabled: ${PETSTORE_BOOKING_AGENT_ENABLED:false}
llm:
provider: ${PETSTORE_BOOKING_AGENT_LLM_PROVIDER:aliyun}
base-url: ${PETSTORE_BOOKING_AGENT_LLM_BASE_URL:}
model: ${PETSTORE_BOOKING_AGENT_LLM_MODEL:qwen-plus-2025-12-01}
api-key: ${PETSTORE_BOOKING_AGENT_LLM_API_KEY:}
timeout-ms: ${PETSTORE_BOOKING_AGENT_LLM_TIMEOUT_MS:3000}
asr:
provider: ${PETSTORE_BOOKING_AGENT_ASR_PROVIDER:aliyun}
base-url: ${PETSTORE_BOOKING_AGENT_ASR_BASE_URL:}
model: ${PETSTORE_BOOKING_AGENT_ASR_MODEL:qwen3-asr-flash-2026-02-10}
api-key: ${PETSTORE_BOOKING_AGENT_ASR_API_KEY:}
timeout-ms: ${PETSTORE_BOOKING_AGENT_ASR_TIMEOUT_MS:5000}
highlight-video: highlight-video:
ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg} ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg}
ffprobe-binary: ${HIGHLIGHT_FFPROBE:ffprobe} ffprobe-binary: ${HIGHLIGHT_FFPROBE:ffprobe}

View File

@ -0,0 +1,52 @@
package com.petstore.bookingagent.config;
import com.petstore.bookingagent.provider.BookingIntentExtractor;
import com.petstore.bookingagent.provider.BookingIntentRequest;
import com.petstore.bookingagent.provider.ProviderException;
import com.petstore.bookingagent.provider.SpeechTranscriber;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class BookingAgentProviderConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class))
.withUserConfiguration(BookingAgentProviderConfiguration.class);
@Test
void applicationSliceStartsWithoutKeysAndProvidersFailClosed() {
contextRunner.run(context -> {
assertFalse(context.getBean(BookingAgentProperties.class).isEnabled());
assertNotNull(context.getBean(BookingIntentExtractor.class));
assertNotNull(context.getBean(SpeechTranscriber.class));
ProviderException exception = assertThrows(
ProviderException.class,
() -> context.getBean(BookingIntentExtractor.class)
.extract(new BookingIntentRequest("给球球洗澡", null))
);
assertEquals(ProviderException.Reason.DISABLED, exception.getReason());
});
}
@Test
void environmentStylePropertiesBindWithoutCallingNetwork() {
contextRunner.withPropertyValues(
"app.booking-agent.enabled=true",
"app.booking-agent.llm.base-url=http://127.0.0.1:9/compatible-mode/v1",
"app.booking-agent.llm.api-key=local-test-key",
"app.booking-agent.llm.timeout-ms=1234"
).run(context -> {
BookingAgentProperties properties = context.getBean(BookingAgentProperties.class);
assertEquals("http://127.0.0.1:9/compatible-mode/v1", properties.getLlm().getBaseUrl());
assertEquals(1234, properties.getLlm().getTimeoutMs());
});
}
}

View File

@ -0,0 +1,26 @@
package com.petstore.bookingagent.provider;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
class BookingAgentSensitiveLoggingContractTest {
@Test
void providerLayerCannotLogPromptsAudioKeysOrExternalPayloads() throws Exception {
for (String path : List.of(
"src/main/java/com/petstore/bookingagent/provider/aliyun/OpenAiCompatibleTransport.java",
"src/main/java/com/petstore/bookingagent/provider/aliyun/QwenBookingIntentExtractor.java",
"src/main/java/com/petstore/bookingagent/provider/aliyun/Qwen3AsrSpeechTranscriber.java"
)) {
String source = Files.readString(Path.of(path));
assertFalse(source.contains("log."), path + " 不得记录外部请求/响应");
assertFalse(source.contains("System.out"), path + " 不得输出调试载荷");
assertFalse(source.contains(".getMessage()"), path + " 不得暴露可能含 URI/载荷的异常消息");
}
}
}

View File

@ -0,0 +1,27 @@
package com.petstore.bookingagent.provider;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class BookingIntentRequestTest {
@Test
void normalizesTextButRejectsSensitiveValuesBeforeProviderCall() {
assertEquals("周六给球球洗澡", new BookingIntentRequest(" 周六给球球洗澡 ", null).text());
assertInvalid("联系电话13800138000周六来");
assertInvalid("请读这个 https://example.test/report?token=abc");
assertInvalid("session_token=secret-value");
assertInvalid("Bearer abc.def.ghi");
}
private void assertInvalid(String text) {
ProviderException exception = assertThrows(
ProviderException.class,
() -> new BookingIntentRequest(text, null)
);
assertEquals(ProviderException.Reason.INVALID_INPUT, exception.getReason());
}
}

View File

@ -0,0 +1,103 @@
package com.petstore.bookingagent.provider;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import java.io.InputStream;
import java.util.Map;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class BookingIntentValidatorTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final BookingIntentValidator validator = new BookingIntentValidator(MAPPER);
@Test
void thirtySyntheticFixturesMatchFrozenContract() throws Exception {
JsonNode fixtures = fixtures();
assertEquals(30, fixtures.size());
Map<String, Integer> expectedCategories = Map.of(
"standard", 8,
"relative_time", 5,
"ambiguity", 4,
"service_synonym", 4,
"unavailable_or_invalid_time", 3,
"modify", 3,
"boundary", 3
);
expectedCategories.forEach((category, expected) -> {
int count = 0;
for (JsonNode fixture : fixtures) {
if (category.equals(fixture.path("category").asText())) {
count++;
}
}
assertEquals(expected, count, category);
});
for (JsonNode fixture : fixtures) {
assertTrue(fixture.path("input").isTextual(), fixture.path("name").asText());
BookingIntentPatch patch = validator.parseAndValidate(fixture.path("output").toString());
assertEquals(BookingIntentPatch.SCHEMA_VERSION, patch.schemaVersion(), fixture.path("name").asText());
}
}
@ParameterizedTest
@MethodSource("invalidOutputs")
void invalidOrOutOfSchemaOutputFailsClosed(String output) {
ProviderException exception = assertThrows(
ProviderException.class,
() -> validator.parseAndValidate(output)
);
assertEquals(ProviderException.Reason.INVALID_RESPONSE, exception.getReason());
}
@Test
void trimsTextAndRemovesControlCharacters() {
String output = validOutput().replace("球球", "\\u0001球 ");
BookingIntentPatch patch = validator.parseAndValidate(output);
assertEquals("球球", patch.petQuery());
}
private static Stream<String> invalidOutputs() {
return Stream.of(
"not-json",
"```json\n" + validOutput() + "\n```",
validOutput() + " trailing",
validOutput().replace("\"nextAction\":\"resolve_context\"", "\"nextAction\":\"unknown\""),
validOutput().replace("\"schemaVersion\":\"booking-intent-v1\"", "\"schemaVersion\":\"booking-intent-v2\""),
validOutput().replace("\"petQuery\":\"球球\",", ""),
validOutput().replace("\"nextAction\":\"resolve_context\"", "\"nextAction\":\"resolve_context\",\"petId\":99"),
validOutput().replace("\"start\":\"15:00\"", "\"start\":\"25:00\""),
validOutput().replace("\"end\":null", "\"end\":null,\"timezone\":\"UTC\""),
validOutput().replace("\"clearFields\":[]", "\"clearFields\":[\"remark\",\"remark\"]"),
validOutput().replace("\"ambiguities\":[]", "\"ambiguities\":[\"price\"]"),
validOutput().replace("\"remark\":null", "\"remark\":\"" + "x".repeat(201) + "\""),
validOutput().replace("\"remark\":null", "\"remark\":\"联系电话13800138000\"")
);
}
static JsonNode fixtures() throws Exception {
try (InputStream input = BookingIntentValidatorTest.class.getResourceAsStream(
"/booking-agent/booking-intent-v1-fixtures.json")) {
if (input == null) {
throw new IllegalStateException("fixture missing");
}
return MAPPER.readTree(input);
}
}
static String validOutput() {
return """
{"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"球球","serviceQuery":"洗澡","dateExpression":"本周六","timeWindow":{"start":"15:00","end":null},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
""".strip();
}
}

View File

@ -0,0 +1,123 @@
package com.petstore.bookingagent.provider.aliyun;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
final class OpenAiStubServer implements AutoCloseable {
record CapturedRequest(String method, String path, String authorization, String body) {
}
private record StubResponse(int status, String body, long delayMs) {
}
private final ObjectMapper objectMapper = new ObjectMapper();
private final LinkedBlockingQueue<StubResponse> responses = new LinkedBlockingQueue<>();
private final List<CapturedRequest> requests = new ArrayList<>();
private final HttpServer server;
private final ExecutorService executor;
OpenAiStubServer() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
executor = Executors.newCachedThreadPool();
server.setExecutor(executor);
server.createContext("/compatible-mode/v1/chat/completions", this::handle);
server.start();
}
String baseUrl() {
return "http://127.0.0.1:" + server.getAddress().getPort() + "/compatible-mode/v1";
}
void enqueueChatCompletion(String content) {
responses.add(new StubResponse(200, chatCompletion(content), 0));
}
void enqueue(int status, String body) {
responses.add(new StubResponse(status, body, 0));
}
void enqueueDelayedChatCompletion(String content, long delayMs) {
responses.add(new StubResponse(200, chatCompletion(content), delayMs));
}
CapturedRequest singleRequest() {
synchronized (requests) {
if (requests.size() != 1) {
throw new AssertionError("Expected one request but got " + requests.size());
}
return requests.get(0);
}
}
private void handle(HttpExchange exchange) throws IOException {
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
synchronized (requests) {
requests.add(new CapturedRequest(
exchange.getRequestMethod(),
exchange.getRequestURI().getPath(),
exchange.getRequestHeaders().getFirst("Authorization"),
body
));
}
StubResponse response;
try {
response = responses.poll(1, TimeUnit.SECONDS);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
response = null;
}
if (response == null) {
response = new StubResponse(500, "{}", 0);
}
if (response.delayMs() > 0) {
try {
Thread.sleep(response.delayMs());
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
}
}
byte[] bytes = response.body().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8");
exchange.sendResponseHeaders(response.status(), bytes.length);
try {
exchange.getResponseBody().write(bytes);
} finally {
exchange.close();
}
}
private String chatCompletion(String content) {
try {
return objectMapper.writeValueAsString(Map.of(
"id", "chatcmpl-local-stub",
"choices", List.of(Map.of(
"index", 0,
"finish_reason", "stop",
"message", Map.of("role", "assistant", "content", content)
))
));
} catch (JsonProcessingException exception) {
throw new IllegalStateException(exception);
}
}
@Override
public void close() {
server.stop(0);
executor.shutdownNow();
}
}

View File

@ -0,0 +1,120 @@
package com.petstore.bookingagent.provider.aliyun;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.petstore.bookingagent.config.BookingAgentProperties;
import com.petstore.bookingagent.provider.ProviderException;
import com.petstore.bookingagent.provider.SpeechTranscription;
import com.petstore.bookingagent.provider.SpeechTranscriptionRequest;
import org.junit.jupiter.api.Test;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class Qwen3AsrSpeechTranscriberTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Test
void sendsBase64AudioAndMinimalGlossaryToLocalStub() throws Exception {
try (OpenAiStubServer stub = new OpenAiStubServer()) {
stub.enqueueChatCompletion("周六下午给球球洗澡");
BookingAgentProperties properties = properties(stub.baseUrl());
Qwen3AsrSpeechTranscriber transcriber = transcriber(properties);
byte[] audio = "RIFF-local-WAVE".getBytes(StandardCharsets.US_ASCII);
SpeechTranscription result = transcriber.transcribe(new SpeechTranscriptionRequest(
audio,
"audio/x-wav",
List.of("球球", "精洗护理")
));
assertEquals("周六下午给球球洗澡", result.text());
JsonNode body = MAPPER.readTree(stub.singleRequest().body());
assertEquals("qwen3-asr-flash-2026-02-10", body.path("model").asText());
assertFalse(body.path("stream").asBoolean(true));
assertTrue(body.path("asr_options").path("enable_itn").asBoolean());
assertTrue(body.path("messages").path(0).path("content").asText().contains("球球"));
String data = body.path("messages").path(1).path("content").path(0)
.path("input_audio").path("data").asText();
assertTrue(data.startsWith("data:audio/wav;base64,"));
assertFalse(data.contains("球球"));
}
}
@Test
void rejectsInvalidAudioBeforeNetworkCall() throws Exception {
try (OpenAiStubServer stub = new OpenAiStubServer()) {
Qwen3AsrSpeechTranscriber transcriber = transcriber(properties(stub.baseUrl()));
assertInvalid(() -> transcriber.transcribe(new SpeechTranscriptionRequest(
new byte[0], "audio/wav", List.of()
)));
assertInvalid(() -> transcriber.transcribe(new SpeechTranscriptionRequest(
new byte[]{1, 2, 3}, "application/octet-stream", List.of()
)));
assertInvalid(() -> transcriber.transcribe(new SpeechTranscriptionRequest(
new byte[Qwen3AsrSpeechTranscriber.MAX_AUDIO_BYTES + 1], "audio/wav", List.of()
)));
assertInvalid(() -> transcriber.transcribe(new SpeechTranscriptionRequest(
new byte[]{1}, "audio/wav", java.util.Collections.nCopies(31, "球球")
)));
assertInvalid(() -> transcriber.transcribe(new SpeechTranscriptionRequest(
new byte[]{1}, "audio/wav", List.of("13800138000")
)));
}
}
@Test
void transcriptIsTrimmedAndBounded() throws Exception {
try (OpenAiStubServer stub = new OpenAiStubServer()) {
stub.enqueueChatCompletion(" 球球" + (char) 1 + " ");
SpeechTranscription result = transcriber(properties(stub.baseUrl())).transcribe(
new SpeechTranscriptionRequest(new byte[]{1}, "audio/wav", List.of())
);
assertEquals("球球", result.text());
}
}
@Test
void missingConfigurationFailsClosed() {
BookingAgentProperties properties = new BookingAgentProperties();
properties.setEnabled(true);
ProviderException exception = assertThrows(
ProviderException.class,
() -> transcriber(properties).transcribe(
new SpeechTranscriptionRequest(new byte[]{1}, "audio/wav", List.of())
)
);
assertEquals(ProviderException.Reason.CONFIGURATION, exception.getReason());
}
private void assertInvalid(Runnable invocation) {
ProviderException exception = assertThrows(ProviderException.class, invocation::run);
assertEquals(ProviderException.Reason.INVALID_INPUT, exception.getReason());
}
private Qwen3AsrSpeechTranscriber transcriber(BookingAgentProperties properties) {
return new Qwen3AsrSpeechTranscriber(
properties,
MAPPER,
HttpClient.newBuilder().connectTimeout(Duration.ofMillis(200)).build()
);
}
private BookingAgentProperties properties(String baseUrl) {
BookingAgentProperties properties = new BookingAgentProperties();
properties.setEnabled(true);
properties.getAsr().setBaseUrl(baseUrl);
properties.getAsr().setApiKey("local-test-key");
properties.getAsr().setTimeoutMs(1000);
return properties;
}
}

View File

@ -0,0 +1,144 @@
package com.petstore.bookingagent.provider.aliyun;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.petstore.bookingagent.config.BookingAgentProperties;
import com.petstore.bookingagent.provider.BookingIntentPatch;
import com.petstore.bookingagent.provider.BookingIntentRequest;
import com.petstore.bookingagent.provider.BookingIntentValidator;
import com.petstore.bookingagent.provider.ProviderException;
import org.junit.jupiter.api.Test;
import java.net.http.HttpClient;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class QwenBookingIntentExtractorTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String VALID_OUTPUT = """
{"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"球球","serviceQuery":"洗澡","dateExpression":"本周六","timeWindow":{"start":"15:00","end":null},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
""".strip();
@Test
void sendsNonThinkingJsonRequestToLocalStubAndValidatesResponse() throws Exception {
try (OpenAiStubServer stub = new OpenAiStubServer()) {
stub.enqueueChatCompletion(VALID_OUTPUT);
BookingAgentProperties properties = properties(stub.baseUrl(), 1000);
QwenBookingIntentExtractor extractor = extractor(properties);
BookingIntentPatch patch = extractor.extract(new BookingIntentRequest(
"周六下午给球球洗澡",
null
));
assertEquals("球球", patch.petQuery());
OpenAiStubServer.CapturedRequest captured = stub.singleRequest();
assertEquals("POST", captured.method());
assertEquals("/compatible-mode/v1/chat/completions", captured.path());
assertEquals("Bearer local-test-key", captured.authorization());
JsonNode body = MAPPER.readTree(captured.body());
assertEquals("qwen-plus-2025-12-01", body.path("model").asText());
assertFalse(body.path("stream").asBoolean(true));
assertFalse(body.path("enable_thinking").asBoolean(true));
assertEquals("json_object", body.path("response_format").path("type").asText());
assertTrue(body.path("messages").path(0).path("content").asText().contains("JSON"));
JsonNode userPayload = MAPPER.readTree(body.path("messages").path(1).path("content").asText());
assertEquals("周六下午给球球洗澡", userPayload.path("text").asText());
assertTrue(userPayload.path("currentIntent").isNull());
}
}
@Test
void upstreamErrorDoesNotExposeResponseBody() throws Exception {
try (OpenAiStubServer stub = new OpenAiStubServer()) {
stub.enqueue(429, "{\"error\":{\"message\":\"sensitive upstream payload\"}}");
ProviderException exception = assertThrows(
ProviderException.class,
() -> extractor(properties(stub.baseUrl(), 1000))
.extract(new BookingIntentRequest("给球球洗澡", null))
);
assertEquals(ProviderException.Reason.UPSTREAM, exception.getReason());
assertEquals(429, exception.getHttpStatus());
assertEquals("UPSTREAM", exception.getMessage());
}
}
@Test
void invalidModelContentFailsClosed() throws Exception {
try (OpenAiStubServer stub = new OpenAiStubServer()) {
stub.enqueueChatCompletion("{\"petId\":99}");
ProviderException exception = assertThrows(
ProviderException.class,
() -> extractor(properties(stub.baseUrl(), 1000))
.extract(new BookingIntentRequest("给球球洗澡", null))
);
assertEquals(ProviderException.Reason.INVALID_RESPONSE, exception.getReason());
}
}
@Test
void timeoutIsMappedWithoutAutomaticRetry() throws Exception {
try (OpenAiStubServer stub = new OpenAiStubServer()) {
stub.enqueueDelayedChatCompletion(VALID_OUTPUT, 300);
ProviderException exception = assertThrows(
ProviderException.class,
() -> extractor(properties(stub.baseUrl(), 50))
.extract(new BookingIntentRequest("给球球洗澡", null))
);
assertEquals(ProviderException.Reason.TIMEOUT, exception.getReason());
stub.singleRequest();
}
}
@Test
void disabledOrUnconfiguredProviderFailsBeforeNetworkCall() {
BookingAgentProperties disabled = new BookingAgentProperties();
ProviderException disabledFailure = assertThrows(
ProviderException.class,
() -> extractor(disabled).extract(new BookingIntentRequest("给球球洗澡", null))
);
assertEquals(ProviderException.Reason.DISABLED, disabledFailure.getReason());
BookingAgentProperties unconfigured = new BookingAgentProperties();
unconfigured.setEnabled(true);
ProviderException configFailure = assertThrows(
ProviderException.class,
() -> extractor(unconfigured).extract(new BookingIntentRequest("给球球洗澡", null))
);
assertEquals(ProviderException.Reason.CONFIGURATION, configFailure.getReason());
}
@Test
void nonTlsNonLoopbackEndpointIsRejected() {
BookingAgentProperties properties = properties("http://example.com/compatible-mode/v1", 1000);
ProviderException exception = assertThrows(
ProviderException.class,
() -> extractor(properties).extract(new BookingIntentRequest("给球球洗澡", null))
);
assertEquals(ProviderException.Reason.CONFIGURATION, exception.getReason());
}
private QwenBookingIntentExtractor extractor(BookingAgentProperties properties) {
return new QwenBookingIntentExtractor(
properties,
MAPPER,
new BookingIntentValidator(MAPPER),
HttpClient.newBuilder().connectTimeout(Duration.ofMillis(200)).build()
);
}
private BookingAgentProperties properties(String baseUrl, int timeoutMs) {
BookingAgentProperties properties = new BookingAgentProperties();
properties.setEnabled(true);
properties.getLlm().setBaseUrl(baseUrl);
properties.getLlm().setApiKey("local-test-key");
properties.getLlm().setTimeoutMs(timeoutMs);
return properties;
}
}

View File

@ -0,0 +1,182 @@
[
{
"name": "standard-01",
"category": "standard",
"input": "周六下午给球球洗澡,三点以后都行",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"球球","serviceQuery":"洗澡","dateExpression":"本周六","timeWindow":{"start":"15:00","end":null},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
},
{
"name": "standard-02",
"category": "standard",
"input": "明天上午带多多来剪毛",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"多多","serviceQuery":"剪毛","dateExpression":"明天","timeWindow":{"start":"08:00","end":"12:00"},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
},
{
"name": "standard-03",
"category": "standard",
"input": "下周二晚上六点给咪咪做基础护理",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"咪咪","serviceQuery":"基础护理","dateExpression":"下周二","timeWindow":{"start":"18:00","end":"18:00"},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
},
{
"name": "standard-04",
"category": "standard",
"input": "8月12日给小白做精洗怕吹风机",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"小白","serviceQuery":"精洗","dateExpression":"8月12日","timeWindow":null,"remark":"怕吹风机","clearFields":[],"ambiguities":["time"],"nextAction":"ask"}
},
{
"name": "standard-05",
"category": "standard",
"input": "后天给可乐洗护,下午都可以",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"可乐","serviceQuery":"洗护","dateExpression":"后天","timeWindow":{"start":"12:00","end":"18:00"},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
},
{
"name": "standard-06",
"category": "standard",
"input": "本周五两点给豆豆洗澡",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"豆豆","serviceQuery":"洗澡","dateExpression":"本周五","timeWindow":{"start":"14:00","end":"14:00"},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
},
{
"name": "standard-07",
"category": "standard",
"input": "给布丁约个护理",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"布丁","serviceQuery":"护理","dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["date","time"],"nextAction":"ask"}
},
{
"name": "standard-08",
"category": "standard",
"input": "周日上午给小黑洗澡,注意别剪指甲",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"小黑","serviceQuery":"洗澡","dateExpression":"本周日","timeWindow":{"start":"08:00","end":"12:00"},"remark":"别剪指甲","clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
},
{
"name": "relative-01",
"category": "relative_time",
"input": "今天晚一点带旺财来洗澡",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"旺财","serviceQuery":"洗澡","dateExpression":"今天","timeWindow":{"start":"17:00","end":null},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
},
{
"name": "relative-02",
"category": "relative_time",
"input": "下周三中午之前给软软剪毛",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"软软","serviceQuery":"剪毛","dateExpression":"下周三","timeWindow":{"start":null,"end":"12:00"},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
},
{
"name": "relative-03",
"category": "relative_time",
"input": "后天三点到五点之间给来福护理",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"来福","serviceQuery":"护理","dateExpression":"后天","timeWindow":{"start":"15:00","end":"17:00"},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
},
{
"name": "relative-04",
"category": "relative_time",
"input": "这周一早上给咖啡做精洗",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"咖啡","serviceQuery":"精洗","dateExpression":"本周一","timeWindow":{"start":"08:00","end":"12:00"},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
},
{
"name": "relative-05",
"category": "relative_time",
"input": "过几天带元宝来洗澡",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"元宝","serviceQuery":"洗澡","dateExpression":"过几天","timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["date","time"],"nextAction":"ask"}
},
{
"name": "ambiguity-01",
"category": "ambiguity",
"input": "给豆豆约洗澡",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"豆豆","serviceQuery":"洗澡","dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["pet","date","time"],"nextAction":"resolve_context"}
},
{
"name": "ambiguity-02",
"category": "ambiguity",
"input": "给家里那只猫做护理",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"那只猫","serviceQuery":"护理","dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["pet","service","date","time"],"nextAction":"ask"}
},
{
"name": "ambiguity-03",
"category": "ambiguity",
"input": "周六给球球做那个套餐",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"球球","serviceQuery":"那个套餐","dateExpression":"本周六","timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["service","time"],"nextAction":"resolve_context"}
},
{
"name": "ambiguity-04",
"category": "ambiguity",
"input": "周末给旺财洗澡",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"旺财","serviceQuery":"洗澡","dateExpression":"周末","timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["date","time"],"nextAction":"ask"}
},
{
"name": "synonym-01",
"category": "service_synonym",
"input": "给球球洗香香",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"球球","serviceQuery":"洗香香","dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["date","time"],"nextAction":"resolve_context"}
},
{
"name": "synonym-02",
"category": "service_synonym",
"input": "给小白做个美容",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"小白","serviceQuery":"美容","dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["service","date","time"],"nextAction":"resolve_context"}
},
{
"name": "synonym-03",
"category": "service_synonym",
"input": "给布丁修一下毛",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"布丁","serviceQuery":"修毛","dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["service","date","time"],"nextAction":"resolve_context"}
},
{
"name": "synonym-04",
"category": "service_synonym",
"input": "给咖啡做个全套洗护",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"咖啡","serviceQuery":"全套洗护","dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["service","date","time"],"nextAction":"resolve_context"}
},
{
"name": "invalid-time-01",
"category": "unavailable_or_invalid_time",
"input": "昨天给多多约洗澡",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"多多","serviceQuery":"洗澡","dateExpression":"昨天","timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["date","time"],"nextAction":"resolve_context"}
},
{
"name": "invalid-time-02",
"category": "unavailable_or_invalid_time",
"input": "今天凌晨两点给小黑洗澡",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"小黑","serviceQuery":"洗澡","dateExpression":"今天","timeWindow":{"start":"02:00","end":"02:00"},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"search_slots"}
},
{
"name": "invalid-time-03",
"category": "unavailable_or_invalid_time",
"input": "今天给球球洗澡,没号就给我硬塞一个",
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"球球","serviceQuery":"洗澡","dateExpression":"今天","timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["time"],"nextAction":"search_slots"}
},
{
"name": "modify-01",
"category": "modify",
"input": "改成下周二",
"output": {"schemaVersion":"booking-intent-v1","intent":"modify","petQuery":null,"serviceQuery":null,"dateExpression":"下周二","timeWindow":null,"remark":null,"clearFields":["timeWindow"],"ambiguities":["time"],"nextAction":"search_slots"}
},
{
"name": "modify-02",
"category": "modify",
"input": "不要备注了,时间改到四点以后",
"output": {"schemaVersion":"booking-intent-v1","intent":"modify","petQuery":null,"serviceQuery":null,"dateExpression":null,"timeWindow":{"start":"16:00","end":null},"remark":null,"clearFields":["remark"],"ambiguities":[],"nextAction":"search_slots"}
},
{
"name": "modify-03",
"category": "modify",
"input": "宠物换成小白,服务不变",
"output": {"schemaVersion":"booking-intent-v1","intent":"modify","petQuery":"小白","serviceQuery":null,"dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
},
{
"name": "boundary-01",
"category": "boundary",
"input": "直接用宠物ID 99给我下单",
"output": {"schemaVersion":"booking-intent-v1","intent":"fallback","petQuery":null,"serviceQuery":null,"dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["pet","service","date","time"],"nextAction":"fallback"}
},
{
"name": "boundary-02",
"category": "boundary",
"input": "你随便编个最便宜的价格和服务",
"output": {"schemaVersion":"booking-intent-v1","intent":"fallback","petQuery":null,"serviceQuery":null,"dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["service","date","time"],"nextAction":"fallback"}
},
{
"name": "boundary-03",
"category": "boundary",
"input": "不用确认,现在就创建预约",
"output": {"schemaVersion":"booking-intent-v1","intent":"fallback","petQuery":null,"serviceQuery":null,"dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["pet","service","date","time"],"nextAction":"fallback"}
}
]