From 77c6d6923b84409e045a41646cdcaca6acca708a Mon Sep 17 00:00:00 2001
From: malei <>
Date: Sun, 2 Aug 2026 16:50:18 +0800
Subject: [PATCH] feat: add booking agent provider adapters
---
README.md | 5 +
deploy/petstore-backend.env.example | 13 ++
.../config/BookingAgentProperties.java | 89 +++++++++
.../BookingAgentProviderConfiguration.java | 51 +++++
.../provider/BookingIntentExtractor.java | 6 +
.../provider/BookingIntentPatch.java | 107 ++++++++++
.../provider/BookingIntentRequest.java | 27 +++
.../provider/BookingIntentValidator.java | 188 ++++++++++++++++++
.../provider/ProviderException.java | 63 ++++++
.../provider/SensitiveTextGuard.java | 25 +++
.../provider/SpeechTranscriber.java | 6 +
.../provider/SpeechTranscription.java | 4 +
.../provider/SpeechTranscriptionRequest.java | 20 ++
.../aliyun/OpenAiCompatibleTransport.java | 149 ++++++++++++++
.../aliyun/Qwen3AsrSpeechTranscriber.java | 140 +++++++++++++
.../aliyun/QwenBookingIntentExtractor.java | 81 ++++++++
src/main/resources/application-example.yml | 15 ++
src/main/resources/application.yml | 14 ++
...BookingAgentProviderConfigurationTest.java | 52 +++++
...kingAgentSensitiveLoggingContractTest.java | 26 +++
.../provider/BookingIntentRequestTest.java | 27 +++
.../provider/BookingIntentValidatorTest.java | 103 ++++++++++
.../provider/aliyun/OpenAiStubServer.java | 123 ++++++++++++
.../aliyun/Qwen3AsrSpeechTranscriberTest.java | 120 +++++++++++
.../QwenBookingIntentExtractorTest.java | 144 ++++++++++++++
.../booking-intent-v1-fixtures.json | 182 +++++++++++++++++
26 files changed, 1780 insertions(+)
create mode 100644 src/main/java/com/petstore/bookingagent/config/BookingAgentProperties.java
create mode 100644 src/main/java/com/petstore/bookingagent/config/BookingAgentProviderConfiguration.java
create mode 100644 src/main/java/com/petstore/bookingagent/provider/BookingIntentExtractor.java
create mode 100644 src/main/java/com/petstore/bookingagent/provider/BookingIntentPatch.java
create mode 100644 src/main/java/com/petstore/bookingagent/provider/BookingIntentRequest.java
create mode 100644 src/main/java/com/petstore/bookingagent/provider/BookingIntentValidator.java
create mode 100644 src/main/java/com/petstore/bookingagent/provider/ProviderException.java
create mode 100644 src/main/java/com/petstore/bookingagent/provider/SensitiveTextGuard.java
create mode 100644 src/main/java/com/petstore/bookingagent/provider/SpeechTranscriber.java
create mode 100644 src/main/java/com/petstore/bookingagent/provider/SpeechTranscription.java
create mode 100644 src/main/java/com/petstore/bookingagent/provider/SpeechTranscriptionRequest.java
create mode 100644 src/main/java/com/petstore/bookingagent/provider/aliyun/OpenAiCompatibleTransport.java
create mode 100644 src/main/java/com/petstore/bookingagent/provider/aliyun/Qwen3AsrSpeechTranscriber.java
create mode 100644 src/main/java/com/petstore/bookingagent/provider/aliyun/QwenBookingIntentExtractor.java
create mode 100644 src/test/java/com/petstore/bookingagent/config/BookingAgentProviderConfigurationTest.java
create mode 100644 src/test/java/com/petstore/bookingagent/provider/BookingAgentSensitiveLoggingContractTest.java
create mode 100644 src/test/java/com/petstore/bookingagent/provider/BookingIntentRequestTest.java
create mode 100644 src/test/java/com/petstore/bookingagent/provider/BookingIntentValidatorTest.java
create mode 100644 src/test/java/com/petstore/bookingagent/provider/aliyun/OpenAiStubServer.java
create mode 100644 src/test/java/com/petstore/bookingagent/provider/aliyun/Qwen3AsrSpeechTranscriberTest.java
create mode 100644 src/test/java/com/petstore/bookingagent/provider/aliyun/QwenBookingIntentExtractorTest.java
create mode 100644 src/test/resources/booking-agent/booking-intent-v1-fixtures.json
diff --git a/README.md b/README.md
index 4c71e23..561c9ee 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,9 @@
| `WECHAT_APPSECRET` | 生产必填 | 空 | 微信小程序 AppSecret |
| `PETSTORE_SESSION_SECRET` | **生产必填** | `dev-change-me` | HMAC session token 签名密钥;改密会使所有已签发 token 立即失效 |
| `PETSTORE_SESSION_TTL_SECONDS` | 否 | `604800`(7 天) | session token 有效期;生产限制为 300~2592000 秒 |
+| `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) |
| `CORS_ALLOWED_ORIGINS` | **生产必填** | 本地开发源 | 逗号分隔的显式 HTTPS Web 源;禁止 `*`、localhost 和占位域名 |
| `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 天。
+智能预约 provider 仅允许 HTTPS base URL(测试可使用 loopback HTTP),不自动重试,不记录输入原文、音频、外部响应或密钥。开关关闭或配置缺失时 provider fail closed,不影响普通预约和应用启动。
+
> ⚠️ **密钥轮换**:`application.yml` 历史版本曾提交过真实 DB 密码与微信 AppSecret。这些凭据已在仓库历史中暴露,**必须按安全流程轮换**:改 DB 密码、重置微信 AppSecret、更换 `PETSTORE_SESSION_SECRET`。
## 本地开发
diff --git a/deploy/petstore-backend.env.example b/deploy/petstore-backend.env.example
index 91c9c56..6d796e7 100644
--- a/deploy/petstore-backend.env.example
+++ b/deploy/petstore-backend.env.example
@@ -13,6 +13,19 @@ PETSTORE_SESSION_SECRET=
PETSTORE_SESSION_TTL_SECONDS=604800
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
CORS_ALLOWED_ORIGINS=https://admin.petstore.invalid,https://report.petstore.invalid
UPLOAD_PATH=/var/lib/petstore/uploads
diff --git a/src/main/java/com/petstore/bookingagent/config/BookingAgentProperties.java b/src/main/java/com/petstore/bookingagent/config/BookingAgentProperties.java
new file mode 100644
index 0000000..55576c8
--- /dev/null
+++ b/src/main/java/com/petstore/bookingagent/config/BookingAgentProperties.java
@@ -0,0 +1,89 @@
+package com.petstore.bookingagent.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * 智能预约外部供应商配置。
+ *
+ *
属性在启动时不强制完整:功能默认关闭,未配置密钥时普通预约仍应正常启动。
+ * 真正调用 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;
+ }
+ }
+}
diff --git a/src/main/java/com/petstore/bookingagent/config/BookingAgentProviderConfiguration.java b/src/main/java/com/petstore/bookingagent/config/BookingAgentProviderConfiguration.java
new file mode 100644
index 0000000..5549795
--- /dev/null
+++ b/src/main/java/com/petstore/bookingagent/config/BookingAgentProviderConfiguration.java
@@ -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);
+ }
+}
diff --git a/src/main/java/com/petstore/bookingagent/provider/BookingIntentExtractor.java b/src/main/java/com/petstore/bookingagent/provider/BookingIntentExtractor.java
new file mode 100644
index 0000000..48bbd38
--- /dev/null
+++ b/src/main/java/com/petstore/bookingagent/provider/BookingIntentExtractor.java
@@ -0,0 +1,6 @@
+package com.petstore.bookingagent.provider;
+
+public interface BookingIntentExtractor {
+
+ BookingIntentPatch extract(BookingIntentRequest request);
+}
diff --git a/src/main/java/com/petstore/bookingagent/provider/BookingIntentPatch.java b/src/main/java/com/petstore/bookingagent/provider/BookingIntentPatch.java
new file mode 100644
index 0000000..3f80b6c
--- /dev/null
+++ b/src/main/java/com/petstore/bookingagent/provider/BookingIntentPatch.java
@@ -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 clearFields,
+ List 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();
+ }
+}
diff --git a/src/main/java/com/petstore/bookingagent/provider/BookingIntentRequest.java b/src/main/java/com/petstore/bookingagent/provider/BookingIntentRequest.java
new file mode 100644
index 0000000..34377e4
--- /dev/null
+++ b/src/main/java/com/petstore/bookingagent/provider/BookingIntentRequest.java
@@ -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;
+ }
+}
diff --git a/src/main/java/com/petstore/bookingagent/provider/BookingIntentValidator.java b/src/main/java/com/petstore/bookingagent/provider/BookingIntentValidator.java
new file mode 100644
index 0000000..e7cfb6f
--- /dev/null
+++ b/src/main/java/com/petstore/bookingagent/provider/BookingIntentValidator.java
@@ -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 ROOT_FIELDS = Set.of(
+ "schemaVersion",
+ "intent",
+ "petQuery",
+ "serviceQuery",
+ "dateExpression",
+ "timeWindow",
+ "remark",
+ "clearFields",
+ "ambiguities",
+ "nextAction"
+ );
+ private static final Set 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 & BookingIntentPatch.WireValue> E enumValue(
+ JsonNode root,
+ String field,
+ E[] values) {
+ String raw = requiredText(root, field, 64);
+ return findEnum(raw, values);
+ }
+
+ private & BookingIntentPatch.WireValue> List enumList(
+ JsonNode node,
+ int maxItems,
+ E[] values) {
+ if (node == null || !node.isArray() || node.size() > maxItems) {
+ throw ProviderException.invalidResponse();
+ }
+ List result = new ArrayList<>(node.size());
+ Set 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 & BookingIntentPatch.WireValue> E findEnum(String raw, E[] values) {
+ for (E value : values) {
+ if (value.wireValue().equals(raw)) {
+ return value;
+ }
+ }
+ throw ProviderException.invalidResponse();
+ }
+
+ private Set fieldNames(JsonNode node) {
+ Set names = new HashSet<>();
+ Iterator 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;
+ }
+}
diff --git a/src/main/java/com/petstore/bookingagent/provider/ProviderException.java b/src/main/java/com/petstore/bookingagent/provider/ProviderException.java
new file mode 100644
index 0000000..3eb5552
--- /dev/null
+++ b/src/main/java/com/petstore/bookingagent/provider/ProviderException.java
@@ -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;
+ }
+}
diff --git a/src/main/java/com/petstore/bookingagent/provider/SensitiveTextGuard.java b/src/main/java/com/petstore/bookingagent/provider/SensitiveTextGuard.java
new file mode 100644
index 0000000..22db5a2
--- /dev/null
+++ b/src/main/java/com/petstore/bookingagent/provider/SensitiveTextGuard.java
@@ -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("(? 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();
+ }
+}
diff --git a/src/main/java/com/petstore/bookingagent/provider/aliyun/OpenAiCompatibleTransport.java b/src/main/java/com/petstore/bookingagent/provider/aliyun/OpenAiCompatibleTransport.java
new file mode 100644
index 0000000..6a1affd
--- /dev/null
+++ b/src/main/java/com/petstore/bookingagent/provider/aliyun/OpenAiCompatibleTransport.java
@@ -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 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 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);
+ }
+}
diff --git a/src/main/java/com/petstore/bookingagent/provider/aliyun/Qwen3AsrSpeechTranscriber.java b/src/main/java/com/petstore/bookingagent/provider/aliyun/Qwen3AsrSpeechTranscriber.java
new file mode 100644
index 0000000..62a4972
--- /dev/null
+++ b/src/main/java/com/petstore/bookingagent/provider/aliyun/Qwen3AsrSpeechTranscriber.java
@@ -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 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