From 3c000654d83f0350b0d44c8bca3dd3a2e5043453 Mon Sep 17 00:00:00 2001 From: malei <> Date: Sun, 2 Aug 2026 18:32:27 +0800 Subject: [PATCH] feat: add booking agent session orchestration --- README.md | 9 +- .../20260802_create_booking_agent_session.sql | 52 +++ db/migrations/README.md | 2 + .../api/BookingAgentController.java | 137 ++++++ .../bookingagent/api/BookingAgentDtos.java | 47 ++ .../api/BookingAgentRequestParser.java | 78 ++++ .../BookingAgentRuntimeConfiguration.java | 22 + .../domain/BookingAgentDraftState.java | 85 ++++ .../domain/BookingAgentSession.java | 77 ++++ .../domain/BookingAgentStatus.java | 36 ++ .../bookingagent/domain/BookingDraft.java | 49 ++ .../mapper/BookingAgentSessionMapper.java | 34 ++ .../service/BookingAgentAudioValidator.java | 330 +++++++++++++ .../service/BookingAgentContextResolver.java | 432 ++++++++++++++++++ .../service/BookingAgentException.java | 78 ++++ .../service/BookingAgentIntentMerger.java | 109 +++++ .../service/BookingAgentRateLimiter.java | 68 +++ .../service/BookingAgentReplyRenderer.java | 30 ++ .../service/BookingAgentResolution.java | 26 ++ .../service/BookingAgentService.java | 266 +++++++++++ .../service/BookingAgentSessionJanitor.java | 37 ++ .../BookingTimeConstraintResolver.java | 72 +++ .../service/BusinessEventService.java | 56 +++ .../api/BookingAgentControllerTest.java | 178 ++++++++ .../api/BookingAgentRequestParserTest.java | 54 +++ .../BookingAgentSessionMigrationTest.java | 66 +++ .../BookingAgentAudioValidatorTest.java | 65 +++ .../BookingAgentContextResolverTest.java | 187 ++++++++ .../service/BookingAgentIntentMergerTest.java | 70 +++ ...ngAgentNoAppointmentWriteContractTest.java | 33 ++ .../service/BookingAgentRateLimiterTest.java | 67 +++ .../service/BookingAgentServiceTest.java | 328 +++++++++++++ .../BookingAgentSessionJanitorTest.java | 30 ++ .../BookingTimeConstraintResolverTest.java | 32 ++ .../service/BusinessEventServiceTest.java | 34 ++ 35 files changed, 3275 insertions(+), 1 deletion(-) create mode 100644 db/migrations/20260802_create_booking_agent_session.sql create mode 100644 src/main/java/com/petstore/bookingagent/api/BookingAgentController.java create mode 100644 src/main/java/com/petstore/bookingagent/api/BookingAgentDtos.java create mode 100644 src/main/java/com/petstore/bookingagent/api/BookingAgentRequestParser.java create mode 100644 src/main/java/com/petstore/bookingagent/config/BookingAgentRuntimeConfiguration.java create mode 100644 src/main/java/com/petstore/bookingagent/domain/BookingAgentDraftState.java create mode 100644 src/main/java/com/petstore/bookingagent/domain/BookingAgentSession.java create mode 100644 src/main/java/com/petstore/bookingagent/domain/BookingAgentStatus.java create mode 100644 src/main/java/com/petstore/bookingagent/domain/BookingDraft.java create mode 100644 src/main/java/com/petstore/bookingagent/mapper/BookingAgentSessionMapper.java create mode 100644 src/main/java/com/petstore/bookingagent/service/BookingAgentAudioValidator.java create mode 100644 src/main/java/com/petstore/bookingagent/service/BookingAgentContextResolver.java create mode 100644 src/main/java/com/petstore/bookingagent/service/BookingAgentException.java create mode 100644 src/main/java/com/petstore/bookingagent/service/BookingAgentIntentMerger.java create mode 100644 src/main/java/com/petstore/bookingagent/service/BookingAgentRateLimiter.java create mode 100644 src/main/java/com/petstore/bookingagent/service/BookingAgentReplyRenderer.java create mode 100644 src/main/java/com/petstore/bookingagent/service/BookingAgentResolution.java create mode 100644 src/main/java/com/petstore/bookingagent/service/BookingAgentService.java create mode 100644 src/main/java/com/petstore/bookingagent/service/BookingAgentSessionJanitor.java create mode 100644 src/main/java/com/petstore/bookingagent/service/BookingTimeConstraintResolver.java create mode 100644 src/test/java/com/petstore/bookingagent/api/BookingAgentControllerTest.java create mode 100644 src/test/java/com/petstore/bookingagent/api/BookingAgentRequestParserTest.java create mode 100644 src/test/java/com/petstore/bookingagent/mapper/BookingAgentSessionMigrationTest.java create mode 100644 src/test/java/com/petstore/bookingagent/service/BookingAgentAudioValidatorTest.java create mode 100644 src/test/java/com/petstore/bookingagent/service/BookingAgentContextResolverTest.java create mode 100644 src/test/java/com/petstore/bookingagent/service/BookingAgentIntentMergerTest.java create mode 100644 src/test/java/com/petstore/bookingagent/service/BookingAgentNoAppointmentWriteContractTest.java create mode 100644 src/test/java/com/petstore/bookingagent/service/BookingAgentRateLimiterTest.java create mode 100644 src/test/java/com/petstore/bookingagent/service/BookingAgentServiceTest.java create mode 100644 src/test/java/com/petstore/bookingagent/service/BookingAgentSessionJanitorTest.java create mode 100644 src/test/java/com/petstore/bookingagent/service/BookingTimeConstraintResolverTest.java diff --git a/README.md b/README.md index 561c9ee..79bfdfe 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,12 @@ curl http://localhost:8080/api/store/list ``` 脚本仅为仍为 `pending` 的历史留资建立开放任务,并写入低敏 `follow_up_created` 事实;不会猜测已发送、已取消或已退订线索的处理结果。末尾五项客户归属、状态机、开放任务唯一性和事件完整性验证必须全部为 0。 +12. **智能预约 M0 短期会话**:回访任务迁移验证完成后、部署包含 `BookingAgentSession` 的 backend 前执行: + ```bash + mysql -h -u -p petstore < db/migrations/20260802_create_booking_agent_session.sql + ``` + 脚本只建立短期结构化草稿会话,不保存原文、音频或模型响应,也不关联 `Appointment`。末尾四项状态、唯一键和 TTL 验证必须全部为 0;迁移后仍保持 `PETSTORE_BOOKING_AGENT_ENABLED=false`,生产开启需另行授权。 + ### production profile ```bash @@ -150,7 +156,7 @@ production profile 下: ### 生产只读预检 -完成备份和八个迁移后,以最终生产环境变量运行: +完成备份和九个版本化迁移后,以最终生产环境变量运行: ```bash chmod +x deploy/release-preflight.sh deploy/production-smoke.sh @@ -219,6 +225,7 @@ API_ORIGIN=https:// backend/deploy/production-smoke.sh - [ ] 已执行 `20260802_create_store_onboarding.sql`,历史开通状态与六项邀请/回执验证均为 0 - [ ] 已执行 `20260802_create_store_customer_timeline.sql`,两项 StoreCustomer 别名不变量均为 0 - [ ] 已执行 `20260802_create_follow_up_task.sql`,五项回访任务与事件验证计数均为 0 +- [ ] 部署包含智能预约实体前已执行 `20260802_create_booking_agent_session.sql`,四项会话状态与 TTL 验证计数均为 0,功能开关保持关闭 - [ ] `CORS_ALLOWED_ORIGINS` 仅包含本次发布的显式 HTTPS Web 源 - [ ] `deploy/release-preflight.sh` 已以最终环境变量通过并留存输出 - [ ] readiness 与上线后只读冒烟全部通过 diff --git a/db/migrations/20260802_create_booking_agent_session.sql b/db/migrations/20260802_create_booking_agent_session.sql new file mode 100644 index 0000000..ea91e7b --- /dev/null +++ b/db/migrations/20260802_create_booking_agent_session.sql @@ -0,0 +1,52 @@ +-- Petstore 智能预约助手 M0:短期会话、草稿版本和固定 TTL +-- 前置:20260802_create_follow_up_task.sql 已执行。 +-- 本表不保存当轮原文、原始语音、提示词、模型响应或 Appointment 关联。 + +CREATE TABLE t_booking_agent_session ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(36) NOT NULL, + customer_user_id BIGINT NOT NULL, + store_id BIGINT NOT NULL, + status VARCHAR(24) NOT NULL COMMENT 'collecting | proposing | confirmable | fallback | expired | cancelled', + draft_json TEXT NOT NULL, + draft_version INT NOT NULL DEFAULT 0, + entry_source VARCHAR(32) NOT NULL, + input_modality VARCHAR(16) NULL COMMENT 'text | voice | mixed;首次成功消息后派生', + expires_at DATETIME NOT NULL, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + CONSTRAINT uk_booking_agent_session_public_id UNIQUE (session_id), + CONSTRAINT chk_booking_agent_session_status CHECK ( + status IN ('collecting', 'proposing', 'confirmable', 'fallback', 'expired', 'cancelled') + ), + CONSTRAINT chk_booking_agent_input_modality CHECK ( + input_modality IS NULL OR input_modality IN ('text', 'voice', 'mixed') + ), + INDEX idx_booking_agent_customer_status_expire (customer_user_id, status, expires_at), + INDEX idx_booking_agent_status_expire (status, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能预约 M0 短期结构化草稿会话'; + +-- 验证:以下四项在上线迁移完成且清理任务正常后必须全部为 0。 +SELECT COUNT(*) AS invalid_booking_agent_state_count +FROM t_booking_agent_session +WHERE status NOT IN ('collecting', 'proposing', 'confirmable', 'fallback', 'expired', 'cancelled') + OR draft_version < 0 + OR entry_source <> 'appointment_create' + OR (input_modality IS NOT NULL AND input_modality NOT IN ('text', 'voice', 'mixed')); + +SELECT COUNT(*) AS duplicate_booking_agent_public_id_count +FROM ( + SELECT session_id + FROM t_booking_agent_session + GROUP BY session_id + HAVING COUNT(*) > 1 +) duplicated; + +SELECT COUNT(*) AS invalid_booking_agent_ttl_count +FROM t_booking_agent_session +WHERE expires_at <= create_time; + +SELECT COUNT(*) AS stale_active_booking_agent_session_count +FROM t_booking_agent_session +WHERE status IN ('collecting', 'proposing', 'confirmable') + AND expires_at <= NOW(); diff --git a/db/migrations/README.md b/db/migrations/README.md index bf9cc88..ec39ca0 100644 --- a/db/migrations/README.md +++ b/db/migrations/README.md @@ -24,4 +24,6 @@ `20260802_create_follow_up_task.sql` 必须在客户时间线迁移之后执行:建立可领取、改期、关闭和真实再次预约归因的 `t_follow_up_task`。只为仍待处理的历史留资建立开放任务,不猜测其他历史结果;脚本末尾五个验证计数必须为 0。 +`20260802_create_booking_agent_session.sql` 必须在回访任务迁移之后、部署包含智能预约 JPA 实体的 backend 之前执行:建立不含原文/音频/模型响应的短期结构化草稿会话,冻结六态、owner/store 范围、草稿版本和 TTL 索引。脚本末尾四个验证计数必须为 0;功能开关仍保持关闭,迁移本身不代表允许生产调用供应商。 + 正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。 diff --git a/src/main/java/com/petstore/bookingagent/api/BookingAgentController.java b/src/main/java/com/petstore/bookingagent/api/BookingAgentController.java new file mode 100644 index 0000000..67e762d --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/api/BookingAgentController.java @@ -0,0 +1,137 @@ +package com.petstore.bookingagent.api; + +import com.fasterxml.jackson.databind.JsonNode; +import com.petstore.auth.CurrentUser; +import com.petstore.auth.CurrentUserContext; +import com.petstore.bookingagent.service.BookingAgentAudioValidator; +import com.petstore.bookingagent.service.BookingAgentException; +import com.petstore.bookingagent.service.BookingAgentService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartException; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.support.MissingServletRequestPartException; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/booking-agent") +@RequiredArgsConstructor +public class BookingAgentController { + private final BookingAgentService bookingAgentService; + private final BookingAgentRequestParser requestParser; + + @PostMapping("/sessions") + public Map createSession(@RequestBody JsonNode body) { + CurrentUser user = requireCustomer(); + Long storeId = requestParser.parseCreateSession(body); + return success(bookingAgentService.createSession(user.userId(), storeId)); + } + + @PostMapping("/sessions/{sessionId}/messages") + public Map submitMessage( + @PathVariable String sessionId, + @RequestBody JsonNode body) { + CurrentUser user = requireCustomer(); + BookingAgentRequestParser.SubmitMessageCommand command = requestParser.parseMessage(body); + return success(bookingAgentService.submitMessage( + user.userId(), + requestParser.parseSessionId(sessionId), + command.inputType(), + command.text(), + command.draftVersion() + )); + } + + @PostMapping("/sessions/{sessionId}/transcriptions") + public Map transcribe( + @PathVariable String sessionId, + @RequestParam("audio") MultipartFile audio) { + CurrentUser user = requireCustomer(); + if (audio == null || audio.isEmpty()) { + throw BookingAgentException.invalidAudio(); + } + if (audio.getSize() > BookingAgentAudioValidator.MAX_AUDIO_BYTES) { + throw BookingAgentException.audioTooLarge(); + } + try { + return success(bookingAgentService.transcribe( + user.userId(), + requestParser.parseSessionId(sessionId), + audio.getBytes(), + audio.getContentType() + )); + } catch (IOException exception) { + throw BookingAgentException.invalidAudio(); + } + } + + @PostMapping("/sessions/{sessionId}/fallback") + public Map handoff( + @PathVariable String sessionId, + @RequestBody JsonNode body) { + CurrentUser user = requireCustomer(); + return success(bookingAgentService.handoff( + user.userId(), + requestParser.parseSessionId(sessionId), + requestParser.parseDraftVersion(body) + )); + } + + @DeleteMapping("/sessions/{sessionId}") + public Map cancel(@PathVariable String sessionId) { + CurrentUser user = requireCustomer(); + return success(bookingAgentService.cancel( + user.userId(), + requestParser.parseSessionId(sessionId) + )); + } + + @ExceptionHandler(BookingAgentException.class) + public Map handleBookingAgentException(BookingAgentException exception) { + Map response = new LinkedHashMap<>(); + response.put("code", exception.getCode()); + response.put("message", exception.getUserMessage()); + response.put("bizCode", exception.getBizCode()); + return response; + } + + @ExceptionHandler({ + HttpMessageNotReadableException.class, + MissingServletRequestParameterException.class + }) + public Map handleInvalidRequest(Exception ignored) { + return handleBookingAgentException(BookingAgentException.invalidInput()); + } + + @ExceptionHandler({MultipartException.class, MissingServletRequestPartException.class}) + public Map handleInvalidMultipart(Exception ignored) { + return handleBookingAgentException(BookingAgentException.invalidAudio()); + } + + private CurrentUser requireCustomer() { + CurrentUser user = CurrentUserContext.require(); + if (!user.isCustomer()) { + throw BookingAgentException.forbidden(); + } + return user; + } + + private Map success(Object data) { + Map response = new LinkedHashMap<>(); + response.put("code", 200); + response.put("data", data); + return response; + } +} diff --git a/src/main/java/com/petstore/bookingagent/api/BookingAgentDtos.java b/src/main/java/com/petstore/bookingagent/api/BookingAgentDtos.java new file mode 100644 index 0000000..3a23322 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/api/BookingAgentDtos.java @@ -0,0 +1,47 @@ +package com.petstore.bookingagent.api; + +import com.petstore.bookingagent.domain.BookingAgentStatus; +import com.petstore.bookingagent.domain.BookingDraft; + +import java.time.LocalDateTime; +import java.util.List; + +public final class BookingAgentDtos { + private BookingAgentDtos() { + } + + public record SessionView( + String sessionId, + BookingAgentStatus status, + String assistantMessage, + BookingDraft draft, + List slotOptions, + List quickReplies, + boolean confirmable, + int draftVersion, + LocalDateTime expiresAt) { + + public SessionView { + slotOptions = slotOptions == null ? List.of() : List.copyOf(slotOptions); + quickReplies = quickReplies == null ? List.of() : List.copyOf(quickReplies); + } + } + + public record SlotOption(LocalDateTime startTime, LocalDateTime endTime, String label) { + } + + public record QuickReply(String type, String value, String label) { + } + + public record HandoffData(String source, BookingDraft draft) { + public HandoffData(BookingDraft draft) { + this("booking-agent-m0", draft); + } + } + + public record TranscriptionData(String text) { + } + + public record CancelData(String sessionId, BookingAgentStatus status) { + } +} diff --git a/src/main/java/com/petstore/bookingagent/api/BookingAgentRequestParser.java b/src/main/java/com/petstore/bookingagent/api/BookingAgentRequestParser.java new file mode 100644 index 0000000..061d468 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/api/BookingAgentRequestParser.java @@ -0,0 +1,78 @@ +package com.petstore.bookingagent.api; + +import com.fasterxml.jackson.databind.JsonNode; +import com.petstore.bookingagent.service.BookingAgentException; +import org.springframework.stereotype.Component; + +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; +import java.util.UUID; + +@Component +public class BookingAgentRequestParser { + + public Long parseCreateSession(JsonNode body) { + requireExactFields(body, Set.of("storeId")); + JsonNode storeId = body.get("storeId"); + if (storeId == null || !storeId.isIntegralNumber() || !storeId.canConvertToLong() + || storeId.longValue() < 1) { + throw BookingAgentException.invalidInput(); + } + return storeId.longValue(); + } + + public SubmitMessageCommand parseMessage(JsonNode body) { + requireExactFields(body, Set.of("inputType", "text", "draftVersion")); + JsonNode inputTypeNode = body.get("inputType"); + JsonNode textNode = body.get("text"); + String inputType = inputTypeNode != null && inputTypeNode.isTextual() + ? inputTypeNode.textValue() + : null; + String text = textNode != null && textNode.isTextual() ? textNode.textValue() : null; + int draftVersion = parseDraftVersionNode(body.get("draftVersion")); + if (!("text".equals(inputType) || "voice".equals(inputType)) + || text == null || text.isBlank() || text.strip().length() > 500) { + throw BookingAgentException.invalidInput(); + } + return new SubmitMessageCommand(inputType, text, draftVersion); + } + + public int parseDraftVersion(JsonNode body) { + requireExactFields(body, Set.of("draftVersion")); + return parseDraftVersionNode(body.get("draftVersion")); + } + + public String parseSessionId(String raw) { + if (raw == null || raw.isBlank()) { + throw BookingAgentException.sessionNotFound(); + } + try { + return UUID.fromString(raw.strip()).toString(); + } catch (IllegalArgumentException exception) { + throw BookingAgentException.sessionNotFound(); + } + } + + private int parseDraftVersionNode(JsonNode node) { + if (node == null || !node.isIntegralNumber() || !node.canConvertToInt() || node.intValue() < 0) { + throw BookingAgentException.invalidInput(); + } + return node.intValue(); + } + + private void requireExactFields(JsonNode body, Set expected) { + if (body == null || !body.isObject()) { + throw BookingAgentException.invalidInput(); + } + Set actual = new HashSet<>(); + Iterator fields = body.fieldNames(); + fields.forEachRemaining(actual::add); + if (!actual.equals(expected)) { + throw BookingAgentException.invalidInput(); + } + } + + public record SubmitMessageCommand(String inputType, String text, int draftVersion) { + } +} diff --git a/src/main/java/com/petstore/bookingagent/config/BookingAgentRuntimeConfiguration.java b/src/main/java/com/petstore/bookingagent/config/BookingAgentRuntimeConfiguration.java new file mode 100644 index 0000000..735ff87 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/config/BookingAgentRuntimeConfiguration.java @@ -0,0 +1,22 @@ +package com.petstore.bookingagent.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.time.Clock; +import java.time.ZoneId; + +@Configuration +@EnableScheduling +public class BookingAgentRuntimeConfiguration { + + public static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + + @Bean + @ConditionalOnMissingBean(Clock.class) + Clock bookingAgentClock() { + return Clock.system(BUSINESS_ZONE); + } +} diff --git a/src/main/java/com/petstore/bookingagent/domain/BookingAgentDraftState.java b/src/main/java/com/petstore/bookingagent/domain/BookingAgentDraftState.java new file mode 100644 index 0000000..5d3ec0c --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/domain/BookingAgentDraftState.java @@ -0,0 +1,85 @@ +package com.petstore.bookingagent.domain; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 会话内最小结构化草稿。只保存已清洗约束与后端解析事实,不保存当轮原文、提示词或模型响应。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BookingAgentDraftState { + private Long storeId; + private String storeName; + /** 仅当前请求内使用;解析为权威 Pet 后不进入 draft_json。 */ + @JsonIgnore + private String petQuery; + /** 仅当前请求内使用;解析为权威 ServiceType 后不进入 draft_json。 */ + @JsonIgnore + private String serviceQuery; + /** 仅当前请求内使用;解析为绝对日期后不进入 draft_json。 */ + @JsonIgnore + private String dateExpression; + private String requestedTimeStart; + private String requestedTimeEnd; + private Long petId; + private String petName; + private String petType; + private Long serviceTypeId; + private String serviceType; + private Integer durationMinutes; + private LocalDate dateConstraint; + private LocalDateTime appointmentTime; + private LocalDateTime appointmentEndTime; + private String remark; + + public static BookingAgentDraftState initial(Long storeId, String storeName) { + BookingAgentDraftState state = new BookingAgentDraftState(); + state.setStoreId(storeId); + state.setStoreName(storeName); + return state; + } + + public BookingDraft toPublicDraft() { + List missing = new ArrayList<>(); + if (petId == null) { + missing.add(BookingDraft.MissingField.PET); + } + if (serviceTypeId == null) { + missing.add(BookingDraft.MissingField.SERVICE); + } + if (dateConstraint == null) { + missing.add(BookingDraft.MissingField.DATE); + } + if (appointmentTime == null) { + missing.add(BookingDraft.MissingField.TIME); + } + BookingDraft.TimeWindow timeWindow = requestedTimeStart == null && requestedTimeEnd == null + ? null + : new BookingDraft.TimeWindow(requestedTimeStart, requestedTimeEnd); + return new BookingDraft( + storeId, + storeName, + petId, + petName, + petType, + serviceTypeId, + serviceType, + durationMinutes, + dateConstraint, + timeWindow, + appointmentTime, + appointmentEndTime, + remark, + missing + ); + } +} diff --git a/src/main/java/com/petstore/bookingagent/domain/BookingAgentSession.java b/src/main/java/com/petstore/bookingagent/domain/BookingAgentSession.java new file mode 100644 index 0000000..fe27d7f --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/domain/BookingAgentSession.java @@ -0,0 +1,77 @@ +package com.petstore.bookingagent.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Entity +@Table( + name = "t_booking_agent_session", + uniqueConstraints = @UniqueConstraint( + name = "uk_booking_agent_session_public_id", + columnNames = "session_id" + ), + indexes = { + @Index( + name = "idx_booking_agent_customer_status_expire", + columnList = "customer_user_id,status,expires_at" + ), + @Index(name = "idx_booking_agent_status_expire", columnList = "status,expires_at") + } +) +public class BookingAgentSession { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "session_id", nullable = false, length = 36) + private String sessionId; + + @Column(name = "customer_user_id", nullable = false) + private Long customerUserId; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(nullable = false, length = 24) + private String status; + + @Column(name = "draft_json", nullable = false, columnDefinition = "TEXT") + private String draftJson; + + @Column(name = "draft_version", nullable = false) + private Integer draftVersion; + + @Column(name = "entry_source", nullable = false, length = 32) + private String entrySource; + + @Column(name = "input_modality", length = 16) + private String inputModality; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "create_time", nullable = false) + private LocalDateTime createTime; + + @Column(name = "update_time", nullable = false) + private LocalDateTime updateTime; + + public BookingAgentStatus resolvedStatus() { + return BookingAgentStatus.fromWireValue(status); + } + + public void setStatus(BookingAgentStatus status) { + this.status = status.wireValue(); + } +} diff --git a/src/main/java/com/petstore/bookingagent/domain/BookingAgentStatus.java b/src/main/java/com/petstore/bookingagent/domain/BookingAgentStatus.java new file mode 100644 index 0000000..d17cc92 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/domain/BookingAgentStatus.java @@ -0,0 +1,36 @@ +package com.petstore.bookingagent.domain; + +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Arrays; + +public enum BookingAgentStatus { + COLLECTING("collecting"), + PROPOSING("proposing"), + CONFIRMABLE("confirmable"), + FALLBACK("fallback"), + EXPIRED("expired"), + CANCELLED("cancelled"); + + private final String wireValue; + + BookingAgentStatus(String wireValue) { + this.wireValue = wireValue; + } + + @JsonValue + public String wireValue() { + return wireValue; + } + + public boolean isTerminal() { + return this == FALLBACK || this == EXPIRED || this == CANCELLED; + } + + public static BookingAgentStatus fromWireValue(String value) { + return Arrays.stream(values()) + .filter(status -> status.wireValue.equals(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("非法智能预约会话状态")); + } +} diff --git a/src/main/java/com/petstore/bookingagent/domain/BookingDraft.java b/src/main/java/com/petstore/bookingagent/domain/BookingDraft.java new file mode 100644 index 0000000..1930bed --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/domain/BookingDraft.java @@ -0,0 +1,49 @@ +package com.petstore.bookingagent.domain; + +import com.fasterxml.jackson.annotation.JsonValue; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public record BookingDraft( + Long storeId, + String storeName, + Long petId, + String petName, + String petType, + Long serviceTypeId, + String serviceType, + Integer durationMinutes, + LocalDate dateConstraint, + TimeWindow timeWindow, + LocalDateTime appointmentTime, + LocalDateTime appointmentEndTime, + String remark, + List missingFields) { + + public BookingDraft { + missingFields = missingFields == null ? List.of() : List.copyOf(missingFields); + } + + public record TimeWindow(String start, String end) { + } + + public enum MissingField { + PET("pet"), + SERVICE("service"), + DATE("date"), + TIME("time"); + + private final String wireValue; + + MissingField(String wireValue) { + this.wireValue = wireValue; + } + + @JsonValue + public String wireValue() { + return wireValue; + } + } +} diff --git a/src/main/java/com/petstore/bookingagent/mapper/BookingAgentSessionMapper.java b/src/main/java/com/petstore/bookingagent/mapper/BookingAgentSessionMapper.java new file mode 100644 index 0000000..96517e4 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/mapper/BookingAgentSessionMapper.java @@ -0,0 +1,34 @@ +package com.petstore.bookingagent.mapper; + +import com.petstore.bookingagent.domain.BookingAgentSession; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface BookingAgentSessionMapper extends JpaRepository { + + Optional findBySessionIdAndCustomerUserId(String sessionId, Long customerUserId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM BookingAgentSession s WHERE s.sessionId = :sessionId " + + "AND s.customerUserId = :customerUserId") + Optional findOwnedForUpdate( + @Param("sessionId") String sessionId, + @Param("customerUserId") Long customerUserId + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE BookingAgentSession s SET s.status = 'expired', s.updateTime = :now " + + "WHERE s.status IN ('collecting', 'proposing', 'confirmable') AND s.expiresAt <= :now") + int expireActiveSessions(@Param("now") LocalDateTime now); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM BookingAgentSession s WHERE s.expiresAt < :cutoff") + int deleteExpiredBefore(@Param("cutoff") LocalDateTime cutoff); +} diff --git a/src/main/java/com/petstore/bookingagent/service/BookingAgentAudioValidator.java b/src/main/java/com/petstore/bookingagent/service/BookingAgentAudioValidator.java new file mode 100644 index 0000000..bf54321 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/service/BookingAgentAudioValidator.java @@ -0,0 +1,330 @@ +package com.petstore.bookingagent.service; + +import org.springframework.stereotype.Component; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.Set; + +@Component +public class BookingAgentAudioValidator { + public static final int MAX_AUDIO_BYTES = 3 * 1024 * 1024; + private static final double MAX_DURATION_SECONDS = 60.0; + 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 static final int[] MPEG1_LAYER3_BITRATES = { + 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0 + }; + private static final int[] MPEG2_LAYER3_BITRATES = { + 0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0 + }; + private static final int[] SAMPLE_RATES = {44100, 48000, 32000, 0}; + private static final int[] AAC_SAMPLE_RATES = { + 96000, 88200, 64000, 48000, 44100, 32000, 24000, + 22050, 16000, 12000, 11025, 8000, 7350 + }; + private static final int[] AMR_NB_FRAME_BYTES = {13, 14, 16, 18, 20, 21, 27, 32, 6}; + private static final int[] AMR_WB_FRAME_BYTES = {18, 24, 33, 37, 41, 47, 51, 59, 61, 6}; + + public ValidatedAudio validate(byte[] bytes, String declaredMimeType) { + if (bytes == null || bytes.length == 0) { + throw BookingAgentException.invalidAudio(); + } + if (bytes.length > MAX_AUDIO_BYTES) { + throw BookingAgentException.audioTooLarge(); + } + String mimeType = normalizeMimeType(declaredMimeType); + if (!ALLOWED_MIME_TYPES.contains(mimeType) || !matchesHeader(bytes, mimeType)) { + throw BookingAgentException.invalidAudio(); + } + double seconds = durationSeconds(bytes, mimeType); + if (!Double.isFinite(seconds) || seconds <= 0 || seconds > MAX_DURATION_SECONDS) { + throw BookingAgentException.invalidAudio(); + } + return new ValidatedAudio(bytes.clone(), mimeType, seconds); + } + + private String normalizeMimeType(String value) { + if (value == null) { + return ""; + } + String normalized = value.strip().toLowerCase(Locale.ROOT); + int separator = normalized.indexOf(';'); + if (separator >= 0) { + normalized = normalized.substring(0, separator).strip(); + } + return switch (normalized) { + case "audio/x-wav" -> "audio/wav"; + case "audio/mp3" -> "audio/mpeg"; + default -> normalized; + }; + } + + private boolean matchesHeader(byte[] bytes, String mimeType) { + return switch (mimeType) { + case "audio/wav" -> ascii(bytes, 0, "RIFF") && ascii(bytes, 8, "WAVE"); + case "audio/mpeg" -> ascii(bytes, 0, "ID3") || findMpegFrame(bytes, 0) >= 0; + case "audio/aac" -> bytes.length >= 7 && (bytes[0] & 0xff) == 0xff && (bytes[1] & 0xf6) == 0xf0; + case "audio/amr" -> ascii(bytes, 0, "#!AMR\n") || ascii(bytes, 0, "#!AMR-WB\n"); + case "audio/ogg", "audio/opus" -> ascii(bytes, 0, "OggS"); + case "audio/webm", "video/webm" -> bytes.length >= 4 + && (bytes[0] & 0xff) == 0x1a + && (bytes[1] & 0xff) == 0x45 + && (bytes[2] & 0xff) == 0xdf + && (bytes[3] & 0xff) == 0xa3; + default -> false; + }; + } + + private double durationSeconds(byte[] bytes, String mimeType) { + return switch (mimeType) { + case "audio/wav" -> wavDuration(bytes); + case "audio/mpeg" -> mp3Duration(bytes); + case "audio/aac" -> aacDuration(bytes); + case "audio/amr" -> amrDuration(bytes); + case "audio/ogg", "audio/opus" -> oggDuration(bytes); + case "audio/webm", "video/webm" -> webmDuration(bytes); + default -> Double.NaN; + }; + } + + private double wavDuration(byte[] bytes) { + int offset = 12; + long byteRate = -1; + long dataSize = -1; + while (offset + 8 <= bytes.length) { + String chunk = new String(bytes, offset, 4, StandardCharsets.US_ASCII); + long size = uint32le(bytes, offset + 4); + int body = offset + 8; + if (size < 0 || body + size > bytes.length) { + return Double.NaN; + } + if ("fmt ".equals(chunk) && size >= 16) { + byteRate = uint32le(bytes, body + 8); + } else if ("data".equals(chunk)) { + dataSize = size; + } + if (byteRate > 0 && dataSize >= 0) { + return dataSize / (double) byteRate; + } + offset = body + (int) size + ((size & 1) == 1 ? 1 : 0); + } + return Double.NaN; + } + + private double mp3Duration(byte[] bytes) { + int offset = ascii(bytes, 0, "ID3") && bytes.length >= 10 + ? 10 + synchsafeInt(bytes, 6) + : 0; + double duration = 0; + int frames = 0; + while (offset + 4 <= bytes.length) { + int frame = findMpegFrame(bytes, offset); + if (frame < 0 || frame + 4 > bytes.length) { + break; + } + int b1 = bytes[frame + 1] & 0xff; + int b2 = bytes[frame + 2] & 0xff; + int versionBits = (b1 >> 3) & 0x03; + int layerBits = (b1 >> 1) & 0x03; + if (versionBits == 1 || layerBits != 1) { + offset = frame + 1; + continue; + } + boolean mpeg1 = versionBits == 3; + int bitrateIndex = (b2 >> 4) & 0x0f; + int sampleIndex = (b2 >> 2) & 0x03; + int bitrate = (mpeg1 ? MPEG1_LAYER3_BITRATES : MPEG2_LAYER3_BITRATES)[bitrateIndex] * 1000; + int sampleRate = SAMPLE_RATES[sampleIndex]; + if (versionBits == 2) sampleRate /= 2; + if (versionBits == 0) sampleRate /= 4; + if (bitrate <= 0 || sampleRate <= 0) { + return Double.NaN; + } + int padding = (b2 >> 1) & 1; + int frameLength = (mpeg1 ? 144 : 72) * bitrate / sampleRate + padding; + if (frameLength <= 4 || frame + frameLength > bytes.length) { + break; + } + duration += (mpeg1 ? 1152.0 : 576.0) / sampleRate; + frames++; + offset = frame + frameLength; + } + return frames == 0 ? Double.NaN : duration; + } + + private double aacDuration(byte[] bytes) { + int offset = 0; + double duration = 0; + int frames = 0; + while (offset + 7 <= bytes.length) { + if ((bytes[offset] & 0xff) != 0xff || (bytes[offset + 1] & 0xf6) != 0xf0) { + return Double.NaN; + } + int sampleIndex = (bytes[offset + 2] >> 2) & 0x0f; + if (sampleIndex >= AAC_SAMPLE_RATES.length) { + return Double.NaN; + } + int frameLength = ((bytes[offset + 3] & 0x03) << 11) + | ((bytes[offset + 4] & 0xff) << 3) + | ((bytes[offset + 5] & 0xe0) >> 5); + if (frameLength < 7 || offset + frameLength > bytes.length) { + return Double.NaN; + } + duration += 1024.0 / AAC_SAMPLE_RATES[sampleIndex]; + frames++; + offset += frameLength; + } + return frames == 0 || offset != bytes.length ? Double.NaN : duration; + } + + private double amrDuration(byte[] bytes) { + boolean wideBand = ascii(bytes, 0, "#!AMR-WB\n"); + int offset = wideBand ? 9 : 6; + int frames = 0; + int[] frameSizes = wideBand ? AMR_WB_FRAME_BYTES : AMR_NB_FRAME_BYTES; + while (offset < bytes.length) { + int frameType = (bytes[offset] >> 3) & 0x0f; + if (frameType >= frameSizes.length) { + return Double.NaN; + } + int frameLength = frameSizes[frameType]; + if (offset + frameLength > bytes.length) { + return Double.NaN; + } + frames++; + offset += frameLength; + } + return frames == 0 ? Double.NaN : frames * 0.02; + } + + private double oggDuration(byte[] bytes) { + long lastGranule = -1; + int sampleRate = indexOf(bytes, "OpusHead".getBytes(StandardCharsets.US_ASCII), 0) >= 0 ? 48000 : -1; + int vorbis = indexOf(bytes, "vorbis".getBytes(StandardCharsets.US_ASCII), 0); + if (sampleRate < 0 && vorbis >= 1 && vorbis + 15 <= bytes.length) { + sampleRate = (int) uint32le(bytes, vorbis + 11); + } + int offset = 0; + while (offset + 27 <= bytes.length) { + int page = indexOf(bytes, "OggS".getBytes(StandardCharsets.US_ASCII), offset); + if (page < 0 || page + 27 > bytes.length) { + break; + } + lastGranule = uint64le(bytes, page + 6); + int segments = bytes[page + 26] & 0xff; + if (page + 27 + segments > bytes.length) { + return Double.NaN; + } + int payload = 0; + for (int i = 0; i < segments; i++) payload += bytes[page + 27 + i] & 0xff; + offset = page + 27 + segments + payload; + } + return sampleRate > 0 && lastGranule > 0 ? lastGranule / (double) sampleRate : Double.NaN; + } + + private double webmDuration(byte[] bytes) { + long timecodeScale = 1_000_000L; + Element scale = findElement(bytes, new byte[]{0x2a, (byte) 0xd7, (byte) 0xb1}); + if (scale != null && scale.size > 0 && scale.size <= 8) { + timecodeScale = unsignedBigEndian(bytes, scale.dataOffset, scale.size); + } + Element duration = findElement(bytes, new byte[]{0x44, (byte) 0x89}); + if (duration == null || (duration.size != 4 && duration.size != 8)) { + return Double.NaN; + } + ByteBuffer buffer = ByteBuffer.wrap(bytes, duration.dataOffset, duration.size).order(ByteOrder.BIG_ENDIAN); + double value = duration.size == 4 ? buffer.getFloat() : buffer.getDouble(); + return value * timecodeScale / 1_000_000_000.0; + } + + private Element findElement(byte[] bytes, byte[] id) { + int index = indexOf(bytes, id, 0); + if (index < 0) return null; + int sizeOffset = index + id.length; + if (sizeOffset >= bytes.length) return null; + int first = bytes[sizeOffset] & 0xff; + int length = 1; + int mask = 0x80; + while (length <= 8 && (first & mask) == 0) { + length++; + mask >>= 1; + } + if (length > 8 || sizeOffset + length > bytes.length) return null; + long value = first & (mask - 1); + for (int i = 1; i < length; i++) value = (value << 8) | (bytes[sizeOffset + i] & 0xff); + if (value < 0 || value > Integer.MAX_VALUE) return null; + int dataOffset = sizeOffset + length; + if (dataOffset + value > bytes.length) return null; + return new Element(dataOffset, (int) value); + } + + private int findMpegFrame(byte[] bytes, int from) { + for (int i = Math.max(from, 0); i + 1 < bytes.length; i++) { + if ((bytes[i] & 0xff) == 0xff && (bytes[i + 1] & 0xe0) == 0xe0) { + return i; + } + } + return -1; + } + + private int indexOf(byte[] bytes, byte[] needle, int from) { + outer: + for (int i = Math.max(from, 0); i + needle.length <= bytes.length; i++) { + for (int j = 0; j < needle.length; j++) { + if (bytes[i + j] != needle[j]) continue outer; + } + return i; + } + return -1; + } + + private boolean ascii(byte[] bytes, int offset, String expected) { + byte[] value = expected.getBytes(StandardCharsets.US_ASCII); + if (offset < 0 || offset + value.length > bytes.length) return false; + for (int i = 0; i < value.length; i++) if (bytes[offset + i] != value[i]) return false; + return true; + } + + private long uint32le(byte[] bytes, int offset) { + if (offset < 0 || offset + 4 > bytes.length) return -1; + return Integer.toUnsignedLong(ByteBuffer.wrap(bytes, offset, 4).order(ByteOrder.LITTLE_ENDIAN).getInt()); + } + + private long uint64le(byte[] bytes, int offset) { + if (offset < 0 || offset + 8 > bytes.length) return -1; + return ByteBuffer.wrap(bytes, offset, 8).order(ByteOrder.LITTLE_ENDIAN).getLong(); + } + + private long unsignedBigEndian(byte[] bytes, int offset, int size) { + long value = 0; + for (int i = 0; i < size; i++) value = (value << 8) | (bytes[offset + i] & 0xff); + return value; + } + + private int synchsafeInt(byte[] bytes, int offset) { + if (offset < 0 || offset + 4 > bytes.length) return 0; + return ((bytes[offset] & 0x7f) << 21) + | ((bytes[offset + 1] & 0x7f) << 14) + | ((bytes[offset + 2] & 0x7f) << 7) + | (bytes[offset + 3] & 0x7f); + } + + public record ValidatedAudio(byte[] bytes, String mimeType, double durationSeconds) { + public ValidatedAudio { + bytes = bytes.clone(); + } + + @Override + public byte[] bytes() { + return bytes.clone(); + } + } + + private record Element(int dataOffset, int size) { + } +} diff --git a/src/main/java/com/petstore/bookingagent/service/BookingAgentContextResolver.java b/src/main/java/com/petstore/bookingagent/service/BookingAgentContextResolver.java new file mode 100644 index 0000000..ab992d3 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/service/BookingAgentContextResolver.java @@ -0,0 +1,432 @@ +package com.petstore.bookingagent.service; + +import com.petstore.bookingagent.domain.BookingAgentDraftState; +import com.petstore.bookingagent.domain.BookingAgentStatus; +import com.petstore.bookingagent.provider.SensitiveTextGuard; +import com.petstore.entity.Appointment; +import com.petstore.entity.Pet; +import com.petstore.entity.ScheduleBlock; +import com.petstore.entity.ServiceType; +import com.petstore.entity.Store; +import com.petstore.mapper.AppointmentMapper; +import com.petstore.mapper.PetMapper; +import com.petstore.mapper.ScheduleBlockMapper; +import com.petstore.mapper.StoreMapper; +import com.petstore.service.AppointmentSlotSupport; +import com.petstore.service.BookingCapacityService; +import com.petstore.service.BookingDurationSupport; +import com.petstore.service.ServiceTypeService; +import com.petstore.service.StoreBookingWindow; +import com.petstore.service.StoreService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class BookingAgentContextResolver { + private static final DateTimeFormatter SLOT_LABEL = DateTimeFormatter.ofPattern("M月d日 HH:mm"); + + private final PetMapper petMapper; + private final ServiceTypeService serviceTypeService; + private final StoreMapper storeMapper; + private final AppointmentMapper appointmentMapper; + private final ScheduleBlockMapper scheduleBlockMapper; + private final BookingCapacityService bookingCapacityService; + private final BookingTimeConstraintResolver timeConstraintResolver; + private final Clock clock; + + public BookingAgentResolution resolve(BookingAgentDraftState state, Long customerUserId) { + Store store = storeMapper.findByIdAndDeletedFalse(state.getStoreId()).orElse(null); + if (store == null) { + throw BookingAgentException.storeNotFound(); + } + state.setStoreName(clipped(store.getName(), 128)); + + List pets = petMapper.findByOwnerUserIdAndDeletedFalseOrderByUpdateTimeDesc(customerUserId); + resolvePet(state, pets); + + List services = serviceTypeService.getByStoreId(state.getStoreId()); + resolveService(state, services); + + LocalDate today = LocalDate.now(clock); + if (state.getDateExpression() != null) { + state.setDateConstraint(timeConstraintResolver.resolve(state.getDateExpression(), today)); + } else if (state.getDateConstraint() != null && state.getDateConstraint().isBefore(today)) { + state.setDateConstraint(null); + } + state.setAppointmentTime(null); + state.setAppointmentEndTime(null); + + List quickReplies = firstMissingChoices( + state, pets, services, today + ); + if (state.getPetId() == null || state.getServiceTypeId() == null || state.getDateConstraint() == null) { + return new BookingAgentResolution( + state, + BookingAgentStatus.COLLECTING, + List.of(), + quickReplies, + false + ); + } + + List allSlots = availableSlots(state, store); + List preferred = filterByRequestedWindow(state, allSlots); + boolean exact = isExactTime(state); + if (exact && !preferred.isEmpty()) { + BookingAgentResolution.SlotCandidate selected = preferred.get(0); + state.setAppointmentTime(selected.startTime()); + state.setAppointmentEndTime(selected.endTime()); + return new BookingAgentResolution( + state, + BookingAgentStatus.CONFIRMABLE, + List.of(), + List.of(), + false + ); + } + + boolean requestedUnavailable = (state.getRequestedTimeStart() != null + || state.getRequestedTimeEnd() != null) && preferred.isEmpty(); + List offered = (preferred.isEmpty() ? allSlots : preferred) + .stream() + .limit(3) + .toList(); + List slotChoices = offered.stream() + .map(slot -> new BookingAgentResolution.QuickChoice( + "slot", + slot.startTime().toString(), + slot.label() + )) + .toList(); + return new BookingAgentResolution( + state, + BookingAgentStatus.PROPOSING, + offered, + slotChoices, + requestedUnavailable + ); + } + + public List speechContextTerms(Long customerUserId, Long storeId) { + Set candidates = new LinkedHashSet<>(); + petMapper.findByOwnerUserIdAndDeletedFalseOrderByUpdateTimeDesc(customerUserId).stream() + .map(Pet::getName) + .filter(this::safeContextTerm) + .forEach(candidates::add); + serviceTypeService.getByStoreId(storeId).stream() + .map(ServiceType::getName) + .filter(this::safeContextTerm) + .forEach(candidates::add); + List terms = new ArrayList<>(); + int totalLength = 0; + for (String candidate : candidates) { + int nextLength = totalLength + candidate.length() + (terms.isEmpty() ? 0 : 1); + if (terms.size() >= 30 || nextLength > 900) { + break; + } + terms.add(candidate); + totalLength = nextLength; + } + return List.copyOf(terms); + } + + private void resolvePet(BookingAgentDraftState state, List pets) { + if (state.getPetId() != null && pets.stream().noneMatch(p -> p.getId().equals(state.getPetId()))) { + clearPet(state); + } + if (state.getPetId() != null || state.getPetQuery() == null) { + if (state.getPetId() == null && state.getPetQuery() == null && pets.size() == 1) { + selectPet(state, pets.get(0)); + } + return; + } + String query = normalized(state.getPetQuery()); + List described = new ArrayList<>(); + List choiceLabels = petChoiceLabels(pets); + for (int i = 0; i < pets.size(); i++) { + if (normalized(choiceLabels.get(i)).equals(query)) { + described.add(pets.get(i)); + } + } + if (described.size() == 1) { + selectPet(state, described.get(0)); + return; + } + List exact = pets.stream() + .filter(p -> p.getName() != null && !p.getName().isBlank()) + .filter(p -> normalized(p.getName()).equals(query)) + .toList(); + if (exact.size() == 1) { + selectPet(state, exact.get(0)); + return; + } + List fuzzy = pets.stream() + .filter(p -> p.getName() != null && !p.getName().isBlank()) + .filter(p -> normalized(p.getName()).contains(query) || query.contains(normalized(p.getName()))) + .toList(); + if (fuzzy.size() == 1) { + selectPet(state, fuzzy.get(0)); + } else { + clearPet(state); + } + } + + private void resolveService(BookingAgentDraftState state, List services) { + if (state.getServiceTypeId() != null + && services.stream().noneMatch(s -> s.getId().equals(state.getServiceTypeId()))) { + clearService(state); + } + if (state.getServiceTypeId() != null || state.getServiceQuery() == null) { + if (state.getServiceTypeId() == null && state.getServiceQuery() == null && services.size() == 1) { + selectService(state, services.get(0)); + } + return; + } + String query = normalized(state.getServiceQuery()); + List exact = services.stream() + .filter(service -> service.getName() != null && !service.getName().isBlank()) + .filter(service -> normalized(service.getName()).equals(query)) + .toList(); + if (exact.size() == 1) { + selectService(state, exact.get(0)); + return; + } + String canonical = canonicalServiceName(query); + if (canonical != null) { + List aliased = services.stream() + .filter(service -> service.getName() != null && !service.getName().isBlank()) + .filter(service -> normalized(service.getName()).equals(normalized(canonical))) + .toList(); + if (aliased.size() == 1) { + selectService(state, aliased.get(0)); + return; + } + } + List fuzzy = services.stream() + .filter(service -> service.getName() != null && !service.getName().isBlank()) + .filter(service -> { + String name = normalized(service.getName()); + return name.contains(query) || query.contains(name); + }) + .toList(); + if (fuzzy.size() == 1) { + selectService(state, fuzzy.get(0)); + } else { + clearService(state); + } + } + + private List firstMissingChoices( + BookingAgentDraftState state, + List pets, + List services, + LocalDate today) { + if (state.getPetId() == null) { + List labels = petChoiceLabels(pets); + List choices = new ArrayList<>(); + for (int i = 0; i < pets.size() && choices.size() < 10; i++) { + String label = labels.get(i); + if (!label.isBlank()) { + choices.add(new BookingAgentResolution.QuickChoice("pet", label, label)); + } + } + return List.copyOf(choices); + } + if (state.getServiceTypeId() == null) { + return services.stream() + .filter(service -> service.getName() != null && !service.getName().isBlank()) + .sorted(Comparator.comparing(ServiceType::getName)) + .limit(10) + .map(service -> { + String name = clipped(service.getName(), 64); + return new BookingAgentResolution.QuickChoice("service", name, name); + }) + .toList(); + } + if (state.getDateConstraint() == null) { + return List.of(today, today.plusDays(1), today.plusDays(2)).stream() + .map(date -> new BookingAgentResolution.QuickChoice( + "date", date.toString(), date.getMonthValue() + "月" + date.getDayOfMonth() + "日" + )) + .toList(); + } + return List.of(); + } + + private List availableSlots( + BookingAgentDraftState state, + Store store) { + LocalDate date = state.getDateConstraint(); + LocalDateTime dayStart = date.atStartOfDay(); + LocalDateTime dayEnd = date.plusDays(1).atStartOfDay(); + List appointments = appointmentMapper.findActiveByStoreAndDateRange( + state.getStoreId(), dayStart, dayEnd + ); + List blocks = scheduleBlockMapper + .findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc( + state.getStoreId(), dayStart, dayEnd + ); + int duration = BookingDurationSupport.serviceMinutes(state.getDurationMinutes()); + int capacity = StoreService.normalizeCapacity(store.getBookingCapacity()); + StoreBookingWindow window = StoreBookingWindow.fromStore(store); + LocalDateTime now = LocalDateTime.now(clock); + List available = new ArrayList<>(); + for (LocalDateTime start : AppointmentSlotSupport.allSlotStartsOnDay(date, window)) { + if (!start.isAfter(now) + || !AppointmentSlotSupport.isWithinBookableWindow(start, duration, window)) { + continue; + } + BookingCapacityService.CapacityResult result = bookingCapacityService.evaluateAppointment( + start, duration, capacity, appointments, blocks + ); + if (result.available()) { + available.add(new BookingAgentResolution.SlotCandidate( + start, + start.plusMinutes(duration), + SLOT_LABEL.format(start) + )); + } + } + return available; + } + + private List filterByRequestedWindow( + BookingAgentDraftState state, + List slots) { + LocalTime start = parseTime(state.getRequestedTimeStart()); + LocalTime end = parseTime(state.getRequestedTimeEnd()); + if (start == null && end == null) { + return slots; + } + return slots.stream().filter(slot -> { + LocalTime time = slot.startTime().toLocalTime(); + return (start == null || !time.isBefore(start)) && (end == null || !time.isAfter(end)); + }).toList(); + } + + private boolean isExactTime(BookingAgentDraftState state) { + return state.getRequestedTimeStart() != null + && state.getRequestedTimeStart().equals(state.getRequestedTimeEnd()); + } + + private LocalTime parseTime(String value) { + if (value == null) { + return null; + } + try { + return LocalTime.parse(value); + } catch (DateTimeParseException exception) { + return null; + } + } + + private void selectPet(BookingAgentDraftState state, Pet pet) { + state.setPetId(pet.getId()); + state.setPetName(clipped(pet.getName(), 64)); + state.setPetType(clipped(pet.getPetType(), 32)); + } + + private void clearPet(BookingAgentDraftState state) { + state.setPetId(null); + state.setPetName(null); + state.setPetType(null); + } + + private void selectService(BookingAgentDraftState state, ServiceType service) { + state.setServiceTypeId(service.getId()); + state.setServiceType(clipped(service.getName(), 64)); + state.setDurationMinutes(BookingDurationSupport.serviceMinutes(service.getDurationMinutes())); + } + + private void clearService(BookingAgentDraftState state) { + state.setServiceTypeId(null); + state.setServiceType(null); + state.setDurationMinutes(null); + } + + private String canonicalServiceName(String query) { + if (query.contains("全套") || query.contains("洗美") + || (query.contains("洗") && query.contains("美容"))) { + return "洗澡+美容"; + } + if (query.contains("剪指甲") || "指甲".equals(query)) { + return "剪指甲"; + } + if (query.contains("驱虫")) { + return "驱虫"; + } + if (query.contains("美容") || query.contains("剪毛") + || query.contains("修毛") || query.contains("造型")) { + return "美容"; + } + if (query.contains("洗澡") || query.contains("洗香香") + || query.contains("精洗") || "洗护".equals(query)) { + return "洗澡"; + } + return null; + } + + private List petChoiceLabels(List pets) { + List labels = new ArrayList<>(pets.size()); + for (int i = 0; i < pets.size(); i++) { + Pet pet = pets.get(i); + String name = clipped(pet.getName(), 32); + if (name == null || name.isBlank()) { + labels.add(""); + continue; + } + long sameNameCount = pets.stream() + .filter(other -> normalized(other.getName()).equals(normalized(pet.getName()))) + .count(); + if (sameNameCount <= 1) { + labels.add(name); + continue; + } + String descriptor = pet.getPetType(); + if (descriptor == null || descriptor.isBlank()) { + descriptor = pet.getBreed(); + } + if (descriptor == null || descriptor.isBlank()) { + descriptor = "宠物"; + } + labels.add(clipped(name + "(" + descriptor + " · 第" + (i + 1) + "只)", 64)); + } + return labels; + } + + private String normalized(String value) { + if (value == null) { + return ""; + } + return value.strip().toLowerCase(Locale.ROOT).replaceAll("[\\s+\\-_/()()]", ""); + } + + private boolean safeContextTerm(String term) { + return term != null + && !term.isBlank() + && term.length() <= 64 + && !SensitiveTextGuard.containsForbiddenValue(term); + } + + private String clipped(String value, int maxLength) { + if (value == null) { + return null; + } + String normalized = value.strip(); + return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength); + } +} diff --git a/src/main/java/com/petstore/bookingagent/service/BookingAgentException.java b/src/main/java/com/petstore/bookingagent/service/BookingAgentException.java new file mode 100644 index 0000000..7ec1554 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/service/BookingAgentException.java @@ -0,0 +1,78 @@ +package com.petstore.bookingagent.service; + +public class BookingAgentException extends RuntimeException { + private final int code; + private final String bizCode; + private final String userMessage; + + private BookingAgentException(int code, String bizCode, String userMessage) { + super(bizCode); + this.code = code; + this.bizCode = bizCode; + this.userMessage = userMessage; + } + + public static BookingAgentException forbidden() { + return new BookingAgentException(403, "FORBIDDEN", "仅宠主可以使用智能预约"); + } + + public static BookingAgentException disabled() { + return new BookingAgentException(503, "AGENT_DISABLED", "智能预约暂未开启,请使用普通预约"); + } + + public static BookingAgentException storeNotFound() { + return new BookingAgentException(404, "STORE_NOT_FOUND", "门店不存在或已停用"); + } + + public static BookingAgentException sessionNotFound() { + return new BookingAgentException(404, "SESSION_NOT_FOUND", "智能预约会话不存在"); + } + + public static BookingAgentException sessionExpired() { + return new BookingAgentException(410, "SESSION_EXPIRED", "智能预约会话已过期,请重新开始"); + } + + public static BookingAgentException sessionTerminal() { + return new BookingAgentException(409, "SESSION_TERMINAL", "智能预约会话已结束"); + } + + public static BookingAgentException draftVersionConflict() { + return new BookingAgentException(409, "DRAFT_VERSION_CONFLICT", "草稿已更新,请刷新后重试"); + } + + public static BookingAgentException invalidInput() { + return new BookingAgentException(400, "INVALID_INPUT", "输入内容不符合要求"); + } + + public static BookingAgentException invalidAudio() { + return new BookingAgentException(400, "INVALID_AUDIO", "音频为空、格式不符或超过 60 秒"); + } + + public static BookingAgentException audioTooLarge() { + return new BookingAgentException(413, "AUDIO_TOO_LARGE", "单段音频不能超过 3 MB"); + } + + public static BookingAgentException rateLimited() { + return new BookingAgentException(429, "RATE_LIMITED", "操作过于频繁,请稍后再试"); + } + + public static BookingAgentException agentUnavailable() { + return new BookingAgentException(503, "AGENT_UNAVAILABLE", "暂时无法理解这段话,请使用普通预约"); + } + + public static BookingAgentException asrUnavailable() { + return new BookingAgentException(503, "ASR_UNAVAILABLE", "语音转写暂不可用,请改用文字输入"); + } + + public int getCode() { + return code; + } + + public String getBizCode() { + return bizCode; + } + + public String getUserMessage() { + return userMessage; + } +} diff --git a/src/main/java/com/petstore/bookingagent/service/BookingAgentIntentMerger.java b/src/main/java/com/petstore/bookingagent/service/BookingAgentIntentMerger.java new file mode 100644 index 0000000..274af2a --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/service/BookingAgentIntentMerger.java @@ -0,0 +1,109 @@ +package com.petstore.bookingagent.service; + +import com.petstore.bookingagent.domain.BookingAgentDraftState; +import com.petstore.bookingagent.provider.BookingIntentPatch; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Objects; + +@Component +public class BookingAgentIntentMerger { + + public BookingIntentPatch currentIntent(BookingAgentDraftState state) { + BookingIntentPatch.TimeWindow timeWindow = state.getRequestedTimeStart() == null + && state.getRequestedTimeEnd() == null + ? null + : new BookingIntentPatch.TimeWindow( + state.getRequestedTimeStart(), state.getRequestedTimeEnd() + ); + return new BookingIntentPatch( + BookingIntentPatch.SCHEMA_VERSION, + BookingIntentPatch.Intent.BOOK, + state.getPetQuery() != null ? state.getPetQuery() : state.getPetName(), + state.getServiceQuery() != null ? state.getServiceQuery() : state.getServiceType(), + state.getDateExpression() != null + ? state.getDateExpression() + : (state.getDateConstraint() == null ? null : state.getDateConstraint().toString()), + timeWindow, + state.getRemark(), + List.of(), + List.of(), + BookingIntentPatch.NextAction.RESOLVE_CONTEXT + ); + } + + public BookingAgentDraftState merge(BookingAgentDraftState state, BookingIntentPatch patch) { + if (state == null || patch == null) { + throw BookingAgentException.agentUnavailable(); + } + for (BookingIntentPatch.ClearField clearField : patch.clearFields()) { + clear(state, clearField); + } + if (patch.petQuery() != null && !Objects.equals(state.getPetQuery(), patch.petQuery())) { + state.setPetQuery(patch.petQuery()); + clearResolvedPet(state); + } + if (patch.serviceQuery() != null && !Objects.equals(state.getServiceQuery(), patch.serviceQuery())) { + state.setServiceQuery(patch.serviceQuery()); + clearResolvedService(state); + } + if (patch.dateExpression() != null && !Objects.equals(state.getDateExpression(), patch.dateExpression())) { + state.setDateExpression(patch.dateExpression()); + state.setDateConstraint(null); + clearResolvedTime(state); + } + if (patch.timeWindow() != null) { + state.setRequestedTimeStart(patch.timeWindow().start()); + state.setRequestedTimeEnd(patch.timeWindow().end()); + clearResolvedTime(state); + } + if (patch.remark() != null) { + state.setRemark(patch.remark()); + } + return state; + } + + private void clear(BookingAgentDraftState state, BookingIntentPatch.ClearField field) { + switch (field) { + case PET_QUERY -> { + state.setPetQuery(null); + clearResolvedPet(state); + } + case SERVICE_QUERY -> { + state.setServiceQuery(null); + clearResolvedService(state); + } + case DATE_EXPRESSION -> { + state.setDateExpression(null); + state.setDateConstraint(null); + clearResolvedTime(state); + } + case TIME_WINDOW -> { + state.setRequestedTimeStart(null); + state.setRequestedTimeEnd(null); + clearResolvedTime(state); + } + case REMARK -> state.setRemark(null); + } + } + + private void clearResolvedPet(BookingAgentDraftState state) { + state.setPetId(null); + state.setPetName(null); + state.setPetType(null); + clearResolvedTime(state); + } + + private void clearResolvedService(BookingAgentDraftState state) { + state.setServiceTypeId(null); + state.setServiceType(null); + state.setDurationMinutes(null); + clearResolvedTime(state); + } + + private void clearResolvedTime(BookingAgentDraftState state) { + state.setAppointmentTime(null); + state.setAppointmentEndTime(null); + } +} diff --git a/src/main/java/com/petstore/bookingagent/service/BookingAgentRateLimiter.java b/src/main/java/com/petstore/bookingagent/service/BookingAgentRateLimiter.java new file mode 100644 index 0000000..a3e1975 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/service/BookingAgentRateLimiter.java @@ -0,0 +1,68 @@ +package com.petstore.bookingagent.service; + +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Instant; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.concurrent.ConcurrentHashMap; + +/** 单实例 M0 限流;进入多实例前必须替换为共享状态。 */ +@Component +public class BookingAgentRateLimiter { + private static final Duration WINDOW = Duration.ofMinutes(10); + private static final int MAX_BUCKETS = 10_000; + + private final Clock clock; + private final ConcurrentHashMap> buckets = new ConcurrentHashMap<>(); + + public BookingAgentRateLimiter(Clock clock) { + this.clock = clock; + } + + public void acquire(Long customerUserId, Operation operation) { + if (customerUserId == null || operation == null) { + throw BookingAgentException.invalidInput(); + } + Instant now = clock.instant(); + Deque bucket = buckets.computeIfAbsent( + new BucketKey(customerUserId, operation), + ignored -> new ArrayDeque<>() + ); + synchronized (bucket) { + Instant cutoff = now.minus(WINDOW); + while (!bucket.isEmpty() && !bucket.peekFirst().isAfter(cutoff)) { + bucket.removeFirst(); + } + if (bucket.size() >= operation.limit) { + throw BookingAgentException.rateLimited(); + } + bucket.addLast(now); + } + if (buckets.size() > MAX_BUCKETS) { + buckets.entrySet().removeIf(entry -> { + Deque value = entry.getValue(); + synchronized (value) { + return value.isEmpty() || !value.peekLast().isAfter(now.minus(WINDOW)); + } + }); + } + } + + public enum Operation { + CREATE_SESSION(5), + SUBMIT_MESSAGE(30), + TRANSCRIBE_AUDIO(10); + + private final int limit; + + Operation(int limit) { + this.limit = limit; + } + } + + private record BucketKey(Long customerUserId, Operation operation) { + } +} diff --git a/src/main/java/com/petstore/bookingagent/service/BookingAgentReplyRenderer.java b/src/main/java/com/petstore/bookingagent/service/BookingAgentReplyRenderer.java new file mode 100644 index 0000000..2ac2a2d --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/service/BookingAgentReplyRenderer.java @@ -0,0 +1,30 @@ +package com.petstore.bookingagent.service; + +import com.petstore.bookingagent.domain.BookingDraft; +import org.springframework.stereotype.Component; + +@Component +public class BookingAgentReplyRenderer { + + public String render(BookingAgentResolution resolution) { + if (resolution.status().wireValue().equals("confirmable")) { + return "预约草稿已准备好,请确认后带入普通预约。"; + } + BookingDraft draft = resolution.state().toPublicDraft(); + if (draft.missingFields().contains(BookingDraft.MissingField.PET)) { + return "请选择要预约的宠物。"; + } + if (draft.missingFields().contains(BookingDraft.MissingField.SERVICE)) { + return "请选择需要的服务项目。"; + } + if (draft.missingFields().contains(BookingDraft.MissingField.DATE)) { + return "请选择想预约的日期。"; + } + if (!resolution.slotOptions().isEmpty()) { + return resolution.requestedSlotUnavailable() + ? "你选的时间暂不可约,可以看看这些时段。" + : "找到以下可约时间,请选择一个。"; + } + return "这一天暂时没有可约时段,请换个日期。"; + } +} diff --git a/src/main/java/com/petstore/bookingagent/service/BookingAgentResolution.java b/src/main/java/com/petstore/bookingagent/service/BookingAgentResolution.java new file mode 100644 index 0000000..27be570 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/service/BookingAgentResolution.java @@ -0,0 +1,26 @@ +package com.petstore.bookingagent.service; + +import com.petstore.bookingagent.domain.BookingAgentDraftState; +import com.petstore.bookingagent.domain.BookingAgentStatus; + +import java.time.LocalDateTime; +import java.util.List; + +public record BookingAgentResolution( + BookingAgentDraftState state, + BookingAgentStatus status, + List slotOptions, + List quickReplies, + boolean requestedSlotUnavailable) { + + public BookingAgentResolution { + slotOptions = slotOptions == null ? List.of() : List.copyOf(slotOptions); + quickReplies = quickReplies == null ? List.of() : List.copyOf(quickReplies); + } + + public record SlotCandidate(LocalDateTime startTime, LocalDateTime endTime, String label) { + } + + public record QuickChoice(String type, String value, String label) { + } +} diff --git a/src/main/java/com/petstore/bookingagent/service/BookingAgentService.java b/src/main/java/com/petstore/bookingagent/service/BookingAgentService.java new file mode 100644 index 0000000..2806607 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/service/BookingAgentService.java @@ -0,0 +1,266 @@ +package com.petstore.bookingagent.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.petstore.bookingagent.api.BookingAgentDtos; +import com.petstore.bookingagent.config.BookingAgentProperties; +import com.petstore.bookingagent.domain.BookingAgentDraftState; +import com.petstore.bookingagent.domain.BookingAgentSession; +import com.petstore.bookingagent.domain.BookingAgentStatus; +import com.petstore.bookingagent.mapper.BookingAgentSessionMapper; +import com.petstore.bookingagent.provider.BookingIntentExtractor; +import com.petstore.bookingagent.provider.BookingIntentPatch; +import com.petstore.bookingagent.provider.BookingIntentRequest; +import com.petstore.bookingagent.provider.ProviderException; +import com.petstore.bookingagent.provider.SpeechTranscriber; +import com.petstore.bookingagent.provider.SpeechTranscription; +import com.petstore.bookingagent.provider.SpeechTranscriptionRequest; +import com.petstore.entity.Store; +import com.petstore.mapper.StoreMapper; +import com.petstore.service.BusinessEventService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class BookingAgentService { + private static final String ENTRY_SOURCE = "appointment_create"; + private static final int SESSION_TTL_MINUTES = 30; + + private final BookingAgentProperties properties; + private final BookingAgentSessionMapper sessionMapper; + private final StoreMapper storeMapper; + private final BookingIntentExtractor intentExtractor; + private final SpeechTranscriber speechTranscriber; + private final BookingAgentContextResolver contextResolver; + private final BookingAgentIntentMerger intentMerger; + private final BookingAgentReplyRenderer replyRenderer; + private final BookingAgentAudioValidator audioValidator; + private final BookingAgentRateLimiter rateLimiter; + private final BusinessEventService businessEventService; + private final ObjectMapper objectMapper; + private final Clock clock; + + @Transactional(noRollbackFor = BookingAgentException.class) + public BookingAgentDtos.SessionView createSession(Long customerUserId, Long storeId) { + requireEnabled(); + rateLimiter.acquire(customerUserId, BookingAgentRateLimiter.Operation.CREATE_SESSION); + Store store = storeId == null + ? null + : storeMapper.findByIdAndDeletedFalse(storeId).orElse(null); + if (store == null) { + throw BookingAgentException.storeNotFound(); + } + LocalDateTime now = now(); + BookingAgentDraftState state = BookingAgentDraftState.initial(store.getId(), store.getName()); + BookingAgentResolution resolution = contextResolver.resolve(state, customerUserId); + + BookingAgentSession session = new BookingAgentSession(); + session.setSessionId(UUID.randomUUID().toString()); + session.setCustomerUserId(customerUserId); + session.setStoreId(store.getId()); + session.setStatus(resolution.status()); + session.setDraftJson(writeDraft(resolution.state())); + session.setDraftVersion(0); + session.setEntrySource(ENTRY_SOURCE); + session.setInputModality(null); + session.setExpiresAt(now.plusMinutes(SESSION_TTL_MINUTES)); + session.setCreateTime(now); + session.setUpdateTime(now); + BookingAgentSession saved = sessionMapper.save(session); + businessEventService.recordBookingAgentStarted(saved); + return toView(saved, resolution); + } + + @Transactional(noRollbackFor = BookingAgentException.class) + public BookingAgentDtos.SessionView submitMessage( + Long customerUserId, + String sessionId, + String inputType, + String text, + int draftVersion) { + requireEnabled(); + rateLimiter.acquire(customerUserId, BookingAgentRateLimiter.Operation.SUBMIT_MESSAGE); + BookingAgentSession session = loadActiveForUpdate(sessionId, customerUserId); + if (!session.getDraftVersion().equals(draftVersion)) { + throw BookingAgentException.draftVersionConflict(); + } + BookingAgentDraftState state = readDraft(session.getDraftJson()); + BookingIntentPatch patch; + try { + patch = intentExtractor.extract(new BookingIntentRequest( + text, + intentMerger.currentIntent(state) + )); + } catch (ProviderException exception) { + if (exception.getReason() == ProviderException.Reason.INVALID_INPUT) { + throw BookingAgentException.invalidInput(); + } + markProviderFallback(session); + throw BookingAgentException.agentUnavailable(); + } + + BookingAgentDraftState merged = intentMerger.merge(state, patch); + BookingAgentResolution resolution = contextResolver.resolve(merged, customerUserId); + BookingAgentStatus previous = session.resolvedStatus(); + LocalDateTime now = now(); + session.setStatus(resolution.status()); + session.setDraftJson(writeDraft(resolution.state())); + session.setDraftVersion(session.getDraftVersion() + 1); + session.setInputModality(mergeInputModality(session.getInputModality(), inputType)); + session.setUpdateTime(now); + BookingAgentSession saved = sessionMapper.save(session); + if (previous != BookingAgentStatus.CONFIRMABLE + && resolution.status() == BookingAgentStatus.CONFIRMABLE) { + businessEventService.recordBookingAgentDraftReady(saved); + } + return toView(saved, resolution); + } + + @Transactional(noRollbackFor = BookingAgentException.class) + public BookingAgentDtos.TranscriptionData transcribe( + Long customerUserId, + String sessionId, + byte[] audio, + String mimeType) { + requireEnabled(); + rateLimiter.acquire(customerUserId, BookingAgentRateLimiter.Operation.TRANSCRIBE_AUDIO); + BookingAgentSession session = loadActiveForUpdate(sessionId, customerUserId); + BookingAgentAudioValidator.ValidatedAudio validated = audioValidator.validate(audio, mimeType); + List terms = contextResolver.speechContextTerms(customerUserId, session.getStoreId()); + try { + SpeechTranscription transcription = speechTranscriber.transcribe(new SpeechTranscriptionRequest( + validated.bytes(), validated.mimeType(), terms + )); + return new BookingAgentDtos.TranscriptionData(transcription.text()); + } catch (ProviderException exception) { + if (exception.getReason() == ProviderException.Reason.INVALID_INPUT) { + throw BookingAgentException.invalidAudio(); + } + throw BookingAgentException.asrUnavailable(); + } + } + + @Transactional(noRollbackFor = BookingAgentException.class) + public BookingAgentDtos.HandoffData handoff( + Long customerUserId, + String sessionId, + int draftVersion) { + requireEnabled(); + BookingAgentSession session = loadActiveForUpdate(sessionId, customerUserId); + if (!session.getDraftVersion().equals(draftVersion)) { + throw BookingAgentException.draftVersionConflict(); + } + BookingAgentDraftState state = readDraft(session.getDraftJson()); + session.setStatus(BookingAgentStatus.FALLBACK); + session.setUpdateTime(now()); + BookingAgentSession saved = sessionMapper.save(session); + businessEventService.recordBookingAgentFallback(saved, "user"); + return new BookingAgentDtos.HandoffData(state.toPublicDraft()); + } + + @Transactional(noRollbackFor = BookingAgentException.class) + public BookingAgentDtos.CancelData cancel(Long customerUserId, String sessionId) { + BookingAgentSession session = loadActiveForUpdate(sessionId, customerUserId); + session.setStatus(BookingAgentStatus.CANCELLED); + session.setUpdateTime(now()); + BookingAgentSession saved = sessionMapper.save(session); + return new BookingAgentDtos.CancelData(saved.getSessionId(), BookingAgentStatus.CANCELLED); + } + + private BookingAgentSession loadActiveForUpdate(String sessionId, Long customerUserId) { + BookingAgentSession session = sessionMapper.findOwnedForUpdate(sessionId, customerUserId) + .orElseThrow(BookingAgentException::sessionNotFound); + BookingAgentStatus status = session.resolvedStatus(); + if (status == BookingAgentStatus.EXPIRED) { + throw BookingAgentException.sessionExpired(); + } + if (status.isTerminal()) { + throw BookingAgentException.sessionTerminal(); + } + LocalDateTime now = now(); + if (!session.getExpiresAt().isAfter(now)) { + session.setStatus(BookingAgentStatus.EXPIRED); + session.setUpdateTime(now); + sessionMapper.save(session); + throw BookingAgentException.sessionExpired(); + } + return session; + } + + private void markProviderFallback(BookingAgentSession session) { + session.setStatus(BookingAgentStatus.FALLBACK); + session.setUpdateTime(now()); + BookingAgentSession saved = sessionMapper.save(session); + businessEventService.recordBookingAgentFallback(saved, "llm_unavailable"); + } + + private BookingAgentDtos.SessionView toView( + BookingAgentSession session, + BookingAgentResolution resolution) { + List slots = resolution.slotOptions().stream() + .map(slot -> new BookingAgentDtos.SlotOption( + slot.startTime(), slot.endTime(), slot.label() + )) + .toList(); + List quickReplies = resolution.quickReplies().stream() + .map(choice -> new BookingAgentDtos.QuickReply( + choice.type(), choice.value(), choice.label() + )) + .toList(); + return new BookingAgentDtos.SessionView( + session.getSessionId(), + session.resolvedStatus(), + replyRenderer.render(resolution), + resolution.state().toPublicDraft(), + slots, + quickReplies, + resolution.status() == BookingAgentStatus.CONFIRMABLE, + session.getDraftVersion(), + session.getExpiresAt() + ); + } + + private BookingAgentDraftState readDraft(String json) { + try { + return objectMapper.readValue(json, BookingAgentDraftState.class); + } catch (JsonProcessingException exception) { + throw BookingAgentException.agentUnavailable(); + } + } + + private String writeDraft(BookingAgentDraftState state) { + try { + return objectMapper.writeValueAsString(state); + } catch (JsonProcessingException exception) { + throw BookingAgentException.agentUnavailable(); + } + } + + private String mergeInputModality(String current, String incoming) { + if (!("text".equals(incoming) || "voice".equals(incoming))) { + throw BookingAgentException.invalidInput(); + } + if (current == null || current.isBlank()) { + return incoming; + } + return current.equals(incoming) ? current : "mixed"; + } + + private void requireEnabled() { + if (!properties.isEnabled()) { + throw BookingAgentException.disabled(); + } + } + + private LocalDateTime now() { + return LocalDateTime.now(clock).truncatedTo(ChronoUnit.SECONDS); + } +} diff --git a/src/main/java/com/petstore/bookingagent/service/BookingAgentSessionJanitor.java b/src/main/java/com/petstore/bookingagent/service/BookingAgentSessionJanitor.java new file mode 100644 index 0000000..ad07031 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/service/BookingAgentSessionJanitor.java @@ -0,0 +1,37 @@ +package com.petstore.bookingagent.service; + +import com.petstore.bookingagent.mapper.BookingAgentSessionMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +@Component +@RequiredArgsConstructor +@Slf4j +public class BookingAgentSessionJanitor { + private static final int RETENTION_HOURS_AFTER_EXPIRY = 24; + + private final BookingAgentSessionMapper sessionMapper; + private final Clock clock; + + @Scheduled(cron = "0 0 * * * *", zone = "Asia/Shanghai") + @Transactional + public void maintainSessions() { + LocalDateTime now = LocalDateTime.now(clock).truncatedTo(ChronoUnit.SECONDS); + int expiredCount = sessionMapper.expireActiveSessions(now); + int deletedCount = sessionMapper.deleteExpiredBefore(now.minusHours(RETENTION_HOURS_AFTER_EXPIRY)); + if (expiredCount > 0 || deletedCount > 0) { + log.info( + "booking-agent session maintenance expiredCount={} deletedCount={}", + expiredCount, + deletedCount + ); + } + } +} diff --git a/src/main/java/com/petstore/bookingagent/service/BookingTimeConstraintResolver.java b/src/main/java/com/petstore/bookingagent/service/BookingTimeConstraintResolver.java new file mode 100644 index 0000000..812b9e4 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/service/BookingTimeConstraintResolver.java @@ -0,0 +1,72 @@ +package com.petstore.bookingagent.service; + +import org.springframework.stereotype.Component; + +import java.time.DateTimeException; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.MonthDay; +import java.time.temporal.TemporalAdjusters; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class BookingTimeConstraintResolver { + private static final Pattern MONTH_DAY = Pattern.compile("^(\\d{1,2})月(\\d{1,2})日$"); + private static final Pattern WEEKDAY = Pattern.compile("^(本周|这周|下周)?([一二三四五六日天])$"); + private static final Map DAY_OF_WEEK = Map.of( + "一", DayOfWeek.MONDAY, + "二", DayOfWeek.TUESDAY, + "三", DayOfWeek.WEDNESDAY, + "四", DayOfWeek.THURSDAY, + "五", DayOfWeek.FRIDAY, + "六", DayOfWeek.SATURDAY, + "日", DayOfWeek.SUNDAY, + "天", DayOfWeek.SUNDAY + ); + + public LocalDate resolve(String expression, LocalDate today) { + if (expression == null || expression.isBlank() || today == null) { + return null; + } + String value = expression.strip().replace("星期", "周"); + LocalDate resolved; + try { + resolved = switch (value) { + case "今天" -> today; + case "明天" -> today.plusDays(1); + case "后天" -> today.plusDays(2); + default -> resolveStructured(value, today); + }; + } catch (DateTimeException exception) { + return null; + } + return resolved != null && !resolved.isBefore(today) ? resolved : null; + } + + private LocalDate resolveStructured(String value, LocalDate today) { + try { + return LocalDate.parse(value); + } catch (DateTimeException ignored) { + // Continue with supported Chinese forms. + } + Matcher monthDay = MONTH_DAY.matcher(value); + if (monthDay.matches()) { + MonthDay md = MonthDay.of( + Integer.parseInt(monthDay.group(1)), + Integer.parseInt(monthDay.group(2)) + ); + return md.atYear(today.getYear()); + } + String normalizedWeek = value.startsWith("周") ? "本周" + value.substring(1) : value; + Matcher weekday = WEEKDAY.matcher(normalizedWeek); + if (!weekday.matches()) { + return null; + } + LocalDate monday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + int weekOffset = "下周".equals(weekday.group(1)) ? 1 : 0; + DayOfWeek target = DAY_OF_WEEK.get(weekday.group(2)); + return monday.plusWeeks(weekOffset).plusDays(target.getValue() - 1L); + } +} diff --git a/src/main/java/com/petstore/service/BusinessEventService.java b/src/main/java/com/petstore/service/BusinessEventService.java index cad5377..d68267f 100644 --- a/src/main/java/com/petstore/service/BusinessEventService.java +++ b/src/main/java/com/petstore/service/BusinessEventService.java @@ -2,6 +2,7 @@ package com.petstore.service; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.petstore.bookingagent.domain.BookingAgentSession; import com.petstore.entity.Appointment; import com.petstore.entity.BusinessEvent; import com.petstore.entity.FollowUpTask; @@ -44,6 +45,9 @@ public class BusinessEventService { public static final String FOLLOW_UP_CANCELED = "follow_up_canceled"; public static final String REBOOK_CREATED = "rebook_created"; public static final String STORE_REGISTERED = "store_registered"; + public static final String BOOKING_AGENT_STARTED = "booking_agent_started"; + public static final String BOOKING_AGENT_DRAFT_READY = "booking_agent_draft_ready"; + public static final String BOOKING_AGENT_FALLBACK = "booking_agent_fallback"; private static final Set FORBIDDEN_METADATA_FRAGMENTS = Set.of( "phone", "token", "url", "openid", "unionid", @@ -72,6 +76,58 @@ public class BusinessEventService { )); } + @Transactional + public BusinessEvent recordBookingAgentStarted(BookingAgentSession session) { + return record(new EventCommand( + BOOKING_AGENT_STARTED, + session.getStoreId(), + resolveStoreCustomerId(session.getStoreId(), session.getCustomerUserId()), + "booking_agent_session", + session.getId(), + session.getCustomerUserId(), + "customer", + "customer", + session.getCreateTime(), + "booking_agent_started:" + session.getId(), + Map.of("entrySource", session.getEntrySource()) + )); + } + + @Transactional + public BusinessEvent recordBookingAgentDraftReady(BookingAgentSession session) { + return record(new EventCommand( + BOOKING_AGENT_DRAFT_READY, + session.getStoreId(), + resolveStoreCustomerId(session.getStoreId(), session.getCustomerUserId()), + "booking_agent_session", + session.getId(), + session.getCustomerUserId(), + "customer", + "customer", + session.getUpdateTime(), + "booking_agent_draft_ready:" + session.getId(), + Map.of("inputModality", session.getInputModality()) + )); + } + + @Transactional + public BusinessEvent recordBookingAgentFallback(BookingAgentSession session, String reason) { + String safeReason = "llm_unavailable".equals(reason) ? "llm_unavailable" : "user"; + return record(new EventCommand( + BOOKING_AGENT_FALLBACK, + session.getStoreId(), + resolveStoreCustomerId(session.getStoreId(), session.getCustomerUserId()), + "booking_agent_session", + session.getId(), + session.getCustomerUserId(), + "customer", + "customer", + session.getUpdateTime(), + "booking_agent_fallback:" + session.getId(), + Map.of("reason", safeReason) + )); + } + @Transactional public BusinessEvent recordAppointmentCreated( Appointment appointment, diff --git a/src/test/java/com/petstore/bookingagent/api/BookingAgentControllerTest.java b/src/test/java/com/petstore/bookingagent/api/BookingAgentControllerTest.java new file mode 100644 index 0000000..33f7e92 --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/api/BookingAgentControllerTest.java @@ -0,0 +1,178 @@ +package com.petstore.bookingagent.api; + +import com.petstore.auth.CurrentUser; +import com.petstore.auth.CurrentUserContext; +import com.petstore.bookingagent.domain.BookingAgentStatus; +import com.petstore.bookingagent.domain.BookingDraft; +import com.petstore.bookingagent.service.BookingAgentAudioValidator; +import com.petstore.bookingagent.service.BookingAgentService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class BookingAgentControllerTest { + private static final String SESSION_ID = "f5d7b3e2-9c25-41d3-87b0-6e4895b24d50"; + + @Mock private BookingAgentService bookingAgentService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + BookingAgentController controller = new BookingAgentController( + bookingAgentService, + new BookingAgentRequestParser() + ); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + CurrentUserContext.set(new CurrentUser(99L, null, "customer")); + } + + @AfterEach + void tearDown() { + CurrentUserContext.clear(); + } + + @Test + void createSessionUsesCurrentCustomerAndFrozenBodyShape() throws Exception { + when(bookingAgentService.createSession(99L, 10L)).thenReturn(sessionView()); + + mockMvc.perform(post("/api/booking-agent/sessions") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"storeId\":10}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.sessionId").value(SESSION_ID)) + .andExpect(jsonPath("$.data.status").value("collecting")) + .andExpect(jsonPath("$.data.draft.storeId").value(10)); + + verify(bookingAgentService).createSession(99L, 10L); + } + + @Test + void nonCustomerGetsBodyForbiddenWithoutCallingService() throws Exception { + CurrentUserContext.set(new CurrentUser(7L, 10L, "staff")); + + mockMvc.perform(post("/api/booking-agent/sessions") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"storeId\":10}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(403)) + .andExpect(jsonPath("$.bizCode").value("FORBIDDEN")); + + verify(bookingAgentService, never()).createSession(any(), any()); + } + + @Test + void unknownFieldAndMalformedJsonReturnFrozenInvalidInputBody() throws Exception { + mockMvc.perform(post("/api/booking-agent/sessions") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"storeId\":10,\"userId\":99}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.bizCode").value("INVALID_INPUT")); + + mockMvc.perform(post("/api/booking-agent/sessions") + .contentType(MediaType.APPLICATION_JSON) + .content("{not-json")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.bizCode").value("INVALID_INPUT")); + } + + @Test + void messageHandoffAndCancelPassOnlyCurrentOwnerAndVersion() throws Exception { + when(bookingAgentService.submitMessage(99L, SESSION_ID, "text", "明天下午", 0)) + .thenReturn(sessionView()); + when(bookingAgentService.handoff(99L, SESSION_ID, 0)) + .thenReturn(new BookingAgentDtos.HandoffData(sessionView().draft())); + when(bookingAgentService.cancel(99L, SESSION_ID)) + .thenReturn(new BookingAgentDtos.CancelData(SESSION_ID, BookingAgentStatus.CANCELLED)); + + mockMvc.perform(post("/api/booking-agent/sessions/{sessionId}/messages", SESSION_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"inputType\":\"text\",\"text\":\"明天下午\",\"draftVersion\":0}")) + .andExpect(jsonPath("$.code").value(200)); + mockMvc.perform(post("/api/booking-agent/sessions/{sessionId}/fallback", SESSION_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"draftVersion\":0}")) + .andExpect(jsonPath("$.data.source").value("booking-agent-m0")); + mockMvc.perform(delete("/api/booking-agent/sessions/{sessionId}", SESSION_ID)) + .andExpect(jsonPath("$.data.status").value("cancelled")); + + verify(bookingAgentService).submitMessage(99L, SESSION_ID, "text", "明天下午", 0); + verify(bookingAgentService).handoff(99L, SESSION_ID, 0); + verify(bookingAgentService).cancel(99L, SESSION_ID); + } + + @Test + void missingAndOversizedAudioFailBeforeProvider() throws Exception { + mockMvc.perform(multipart("/api/booking-agent/sessions/{sessionId}/transcriptions", SESSION_ID)) + .andExpect(jsonPath("$.bizCode").value("INVALID_AUDIO")); + + MockMultipartFile oversized = new MockMultipartFile( + "audio", + "voice.wav", + "audio/wav", + new byte[BookingAgentAudioValidator.MAX_AUDIO_BYTES + 1] + ); + mockMvc.perform(multipart("/api/booking-agent/sessions/{sessionId}/transcriptions", SESSION_ID) + .file(oversized)) + .andExpect(jsonPath("$.code").value(413)) + .andExpect(jsonPath("$.bizCode").value("AUDIO_TOO_LARGE")); + + verify(bookingAgentService, never()).transcribe(any(), any(), any(), any()); + } + + private BookingAgentDtos.SessionView sessionView() { + BookingDraft draft = new BookingDraft( + 10L, + "宠小它测试店", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + List.of( + BookingDraft.MissingField.PET, + BookingDraft.MissingField.SERVICE, + BookingDraft.MissingField.DATE, + BookingDraft.MissingField.TIME + ) + ); + return new BookingAgentDtos.SessionView( + SESSION_ID, + BookingAgentStatus.COLLECTING, + "请选择要预约的宠物。", + draft, + List.of(), + List.of(), + false, + 0, + LocalDateTime.of(2026, 8, 2, 10, 30) + ); + } +} diff --git a/src/test/java/com/petstore/bookingagent/api/BookingAgentRequestParserTest.java b/src/test/java/com/petstore/bookingagent/api/BookingAgentRequestParserTest.java new file mode 100644 index 0000000..1a3041e --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/api/BookingAgentRequestParserTest.java @@ -0,0 +1,54 @@ +package com.petstore.bookingagent.api; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.petstore.bookingagent.service.BookingAgentException; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BookingAgentRequestParserTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + private final BookingAgentRequestParser parser = new BookingAgentRequestParser(); + + @Test + void parsesFrozenRequestShapes() throws Exception { + assertEquals(10L, parser.parseCreateSession(objectMapper.readTree("{\"storeId\":10}"))); + BookingAgentRequestParser.SubmitMessageCommand message = parser.parseMessage(objectMapper.readTree( + "{\"inputType\":\"voice\",\"text\":\"明天下午\",\"draftVersion\":2}" + )); + assertEquals("voice", message.inputType()); + assertEquals(2, message.draftVersion()); + assertEquals(3, parser.parseDraftVersion(objectMapper.readTree("{\"draftVersion\":3}"))); + } + + @Test + void rejectsUnknownMissingAndInvalidFields() throws Exception { + assertInvalid("{\"storeId\":10,\"userId\":99}", parser::parseCreateSession); + assertInvalid("{\"storeId\":0}", parser::parseCreateSession); + assertInvalid("{\"inputType\":\"audio\",\"text\":\"x\",\"draftVersion\":0}", parser::parseMessage); + assertInvalid("{\"inputType\":\"text\",\"text\":\"\",\"draftVersion\":0}", parser::parseMessage); + assertInvalid("{\"draftVersion\":-1}", parser::parseDraftVersion); + } + + @Test + void malformedSessionIdIsHiddenAsNotFound() { + BookingAgentException exception = assertThrows( + BookingAgentException.class, + () -> parser.parseSessionId("not-a-session") + ); + assertEquals("SESSION_NOT_FOUND", exception.getBizCode()); + } + + private void assertInvalid(String json, ParserCall call) throws Exception { + BookingAgentException exception = assertThrows( + BookingAgentException.class, + () -> call.parse(objectMapper.readTree(json)) + ); + assertEquals("INVALID_INPUT", exception.getBizCode()); + } + + private interface ParserCall { + Object parse(com.fasterxml.jackson.databind.JsonNode node); + } +} diff --git a/src/test/java/com/petstore/bookingagent/mapper/BookingAgentSessionMigrationTest.java b/src/test/java/com/petstore/bookingagent/mapper/BookingAgentSessionMigrationTest.java new file mode 100644 index 0000000..4edf72e --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/mapper/BookingAgentSessionMigrationTest.java @@ -0,0 +1,66 @@ +package com.petstore.bookingagent.mapper; + +import org.h2.tools.RunScript; +import org.junit.jupiter.api.Test; + +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BookingAgentSessionMigrationTest { + + @Test + void migrationCreatesScopedVersionedSessionWithStateChecks() throws Exception { + try (Connection connection = DriverManager.getConnection( + "jdbc:h2:mem:booking-agent-session-migration;MODE=MySQL;DATABASE_TO_LOWER=TRUE", + "sa", + "" + )) { + try (Reader reader = Files.newBufferedReader( + Path.of("db/migrations/20260802_create_booking_agent_session.sql") + )) { + RunScript.execute(connection, reader); + } + + try (Statement statement = connection.createStatement()) { + statement.execute(""" + INSERT INTO t_booking_agent_session ( + session_id, customer_user_id, store_id, status, draft_json, + draft_version, entry_source, input_modality, + expires_at, create_time, update_time + ) VALUES ( + 'f5d7b3e2-9c25-41d3-87b0-6e4895b24d50', 99, 10, 'collecting', '{}', + 0, 'appointment_create', NULL, + TIMESTAMP '2026-08-02 10:30:00', TIMESTAMP '2026-08-02 10:00:00', + TIMESTAMP '2026-08-02 10:00:00' + ) + """); + try (ResultSet rows = statement.executeQuery( + "SELECT COUNT(*) FROM t_booking_agent_session WHERE customer_user_id = 99 AND store_id = 10" + )) { + rows.next(); + assertEquals(1L, rows.getLong(1)); + } + assertThrows(SQLException.class, () -> statement.execute(""" + INSERT INTO t_booking_agent_session ( + session_id, customer_user_id, store_id, status, draft_json, + draft_version, entry_source, expires_at, create_time, update_time + ) VALUES ( + '1ed6e952-ecb1-4b4c-9c0f-569df291cf65', 99, 10, 'booked', '{}', + 0, 'appointment_create', + TIMESTAMP '2026-08-02 11:00:00', TIMESTAMP '2026-08-02 10:30:00', + TIMESTAMP '2026-08-02 10:30:00' + ) + """)); + } + } + } +} diff --git a/src/test/java/com/petstore/bookingagent/service/BookingAgentAudioValidatorTest.java b/src/test/java/com/petstore/bookingagent/service/BookingAgentAudioValidatorTest.java new file mode 100644 index 0000000..d40216f --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/service/BookingAgentAudioValidatorTest.java @@ -0,0 +1,65 @@ +package com.petstore.bookingagent.service; + +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BookingAgentAudioValidatorTest { + private final BookingAgentAudioValidator validator = new BookingAgentAudioValidator(); + + @Test + void acceptsShortWavAndNormalizesMimeType() { + BookingAgentAudioValidator.ValidatedAudio audio = validator.validate(wavSeconds(1), "audio/x-wav"); + + assertEquals("audio/wav", audio.mimeType()); + assertEquals(1.0, audio.durationSeconds(), 0.001); + } + + @Test + void rejectsMimeHeaderMismatchInvalidHeaderAndLongAudio() { + assertBizCode("INVALID_AUDIO", () -> validator.validate(wavSeconds(1), "audio/mpeg")); + assertBizCode("INVALID_AUDIO", () -> validator.validate(new byte[]{1, 2, 3}, "audio/wav")); + assertBizCode("INVALID_AUDIO", () -> validator.validate(wavSeconds(61), "audio/wav")); + } + + @Test + void rejectsPayloadOverThreeMegabytesBeforeParsing() { + assertBizCode("AUDIO_TOO_LARGE", () -> validator.validate( + new byte[BookingAgentAudioValidator.MAX_AUDIO_BYTES + 1], + "audio/wav" + )); + } + + private void assertBizCode(String expected, Runnable action) { + BookingAgentException exception = assertThrows(BookingAgentException.class, action::run); + assertEquals(expected, exception.getBizCode()); + } + + private byte[] wavSeconds(int seconds) { + int sampleRate = 16_000; + int channels = 1; + int bitsPerSample = 16; + int byteRate = sampleRate * channels * bitsPerSample / 8; + int dataSize = byteRate * seconds; + ByteBuffer buffer = ByteBuffer.allocate(44 + dataSize).order(ByteOrder.LITTLE_ENDIAN); + buffer.put("RIFF".getBytes(StandardCharsets.US_ASCII)); + buffer.putInt(36 + dataSize); + buffer.put("WAVE".getBytes(StandardCharsets.US_ASCII)); + buffer.put("fmt ".getBytes(StandardCharsets.US_ASCII)); + buffer.putInt(16); + buffer.putShort((short) 1); + buffer.putShort((short) channels); + buffer.putInt(sampleRate); + buffer.putInt(byteRate); + buffer.putShort((short) (channels * bitsPerSample / 8)); + buffer.putShort((short) bitsPerSample); + buffer.put("data".getBytes(StandardCharsets.US_ASCII)); + buffer.putInt(dataSize); + return buffer.array(); + } +} diff --git a/src/test/java/com/petstore/bookingagent/service/BookingAgentContextResolverTest.java b/src/test/java/com/petstore/bookingagent/service/BookingAgentContextResolverTest.java new file mode 100644 index 0000000..d6c23f3 --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/service/BookingAgentContextResolverTest.java @@ -0,0 +1,187 @@ +package com.petstore.bookingagent.service; + +import com.petstore.bookingagent.config.BookingAgentRuntimeConfiguration; +import com.petstore.bookingagent.domain.BookingAgentDraftState; +import com.petstore.bookingagent.domain.BookingAgentStatus; +import com.petstore.entity.Pet; +import com.petstore.entity.ServiceType; +import com.petstore.entity.Store; +import com.petstore.mapper.AppointmentMapper; +import com.petstore.mapper.PetMapper; +import com.petstore.mapper.ScheduleBlockMapper; +import com.petstore.mapper.StoreMapper; +import com.petstore.service.BookingCapacityService; +import com.petstore.service.ServiceTypeService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BookingAgentContextResolverTest { + @Mock private PetMapper petMapper; + @Mock private ServiceTypeService serviceTypeService; + @Mock private StoreMapper storeMapper; + @Mock private AppointmentMapper appointmentMapper; + @Mock private ScheduleBlockMapper scheduleBlockMapper; + @Mock private BookingCapacityService bookingCapacityService; + + private BookingAgentContextResolver resolver; + private Store store; + private Pet pet; + private ServiceType serviceType; + + @BeforeEach + void setUp() { + Clock clock = Clock.fixed( + Instant.parse("2026-08-02T02:00:00Z"), + BookingAgentRuntimeConfiguration.BUSINESS_ZONE + ); + resolver = new BookingAgentContextResolver( + petMapper, + serviceTypeService, + storeMapper, + appointmentMapper, + scheduleBlockMapper, + bookingCapacityService, + new BookingTimeConstraintResolver(), + clock + ); + store = new Store(); + store.setId(10L); + store.setName("宠小它测试店"); + store.setBookingCapacity(1); + pet = new Pet(); + pet.setId(20L); + pet.setName("球球"); + pet.setPetType("狗"); + serviceType = new ServiceType(); + serviceType.setId(30L); + serviceType.setName("洗澡"); + serviceType.setDurationMinutes(60); + + when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.of(store)); + when(petMapper.findByOwnerUserIdAndDeletedFalseOrderByUpdateTimeDesc(99L)).thenReturn(List.of(pet)); + when(serviceTypeService.getByStoreId(10L)).thenReturn(List.of(serviceType)); + } + + @Test + void resolvesOnlyOwnedPetStoreServiceAndRealAvailableExactSlot() { + when(appointmentMapper.findActiveByStoreAndDateRange(eq(10L), any(), any())).thenReturn(List.of()); + when(scheduleBlockMapper + .findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc( + eq(10L), any(), any() + )).thenReturn(List.of()); + when(bookingCapacityService.evaluateAppointment(any(), anyInt(), eq(1), eq(List.of()), eq(List.of()))) + .thenReturn(new BookingCapacityService.CapacityResult(true, null, 0)); + + BookingAgentDraftState state = draft("球球", "洗澡", "明天", "14:00", "14:00"); + BookingAgentResolution result = resolver.resolve(state, 99L); + + assertEquals(BookingAgentStatus.CONFIRMABLE, result.status()); + assertEquals(20L, state.getPetId()); + assertEquals(30L, state.getServiceTypeId()); + assertEquals(LocalDateTime.of(2026, 8, 3, 14, 0), state.getAppointmentTime()); + assertEquals(LocalDateTime.of(2026, 8, 3, 15, 0), state.getAppointmentEndTime()); + verify(petMapper).findByOwnerUserIdAndDeletedFalseOrderByUpdateTimeDesc(99L); + } + + @Test + void unavailableRequestedSlotOffersOtherRealSlotsWithoutGuessingSelection() { + when(appointmentMapper.findActiveByStoreAndDateRange(eq(10L), any(), any())).thenReturn(List.of()); + when(scheduleBlockMapper + .findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc( + eq(10L), any(), any() + )).thenReturn(List.of()); + when(bookingCapacityService.evaluateAppointment(any(), anyInt(), eq(1), eq(List.of()), eq(List.of()))) + .thenAnswer(invocation -> { + LocalDateTime start = invocation.getArgument(0); + return start.toLocalTime().equals(java.time.LocalTime.of(14, 0)) + ? new BookingCapacityService.CapacityResult(false, "接待容量已满", 1) + : new BookingCapacityService.CapacityResult(true, null, 0); + }); + + BookingAgentDraftState state = draft("球球", "洗澡", "明天", "14:00", "14:00"); + BookingAgentResolution result = resolver.resolve(state, 99L); + + assertEquals(BookingAgentStatus.PROPOSING, result.status()); + assertTrue(result.requestedSlotUnavailable()); + assertEquals(3, result.slotOptions().size()); + assertNull(state.getAppointmentTime()); + assertTrue(result.slotOptions().stream().noneMatch( + slot -> slot.startTime().toLocalTime().equals(java.time.LocalTime.of(14, 0)) + )); + } + + @Test + void unresolvedPetStopsBeforeStoreCapacityLookup() { + Pet other = new Pet(); + other.setId(21L); + other.setName("小白"); + other.setPetType("猫"); + when(petMapper.findByOwnerUserIdAndDeletedFalseOrderByUpdateTimeDesc(99L)).thenReturn(List.of(other)); + + BookingAgentDraftState state = draft("球球", "洗澡", "明天", "14:00", "14:00"); + BookingAgentResolution result = resolver.resolve(state, 99L); + + assertEquals(BookingAgentStatus.COLLECTING, result.status()); + assertNull(state.getPetId()); + assertEquals("pet", result.quickReplies().get(0).type()); + verify(appointmentMapper, never()).findActiveByStoreAndDateRange(any(), any(), any()); + } + + @Test + void duplicatePetNamesReturnDistinctNaturalLanguageChoicesWithoutIds() { + Pet cat = new Pet(); + cat.setId(21L); + cat.setName("豆豆"); + cat.setPetType("猫"); + Pet dog = new Pet(); + dog.setId(22L); + dog.setName("豆豆"); + dog.setPetType("狗"); + when(petMapper.findByOwnerUserIdAndDeletedFalseOrderByUpdateTimeDesc(99L)) + .thenReturn(List.of(cat, dog)); + + BookingAgentDraftState state = draft("豆豆", "洗澡", "明天", "14:00", "14:00"); + BookingAgentResolution result = resolver.resolve(state, 99L); + + assertNull(state.getPetId()); + assertEquals(2, result.quickReplies().size()); + assertTrue(result.quickReplies().stream().map(BookingAgentResolution.QuickChoice::label) + .allMatch(label -> label.contains("豆豆") && !label.matches(".*\\d{2,}.*"))); + assertEquals(2, result.quickReplies().stream().map(BookingAgentResolution.QuickChoice::label).distinct().count()); + } + + private BookingAgentDraftState draft( + String petQuery, + String serviceQuery, + String dateExpression, + String start, + String end) { + BookingAgentDraftState state = BookingAgentDraftState.initial(10L, store.getName()); + state.setPetQuery(petQuery); + state.setServiceQuery(serviceQuery); + state.setDateExpression(dateExpression); + state.setRequestedTimeStart(start); + state.setRequestedTimeEnd(end); + return state; + } +} diff --git a/src/test/java/com/petstore/bookingagent/service/BookingAgentIntentMergerTest.java b/src/test/java/com/petstore/bookingagent/service/BookingAgentIntentMergerTest.java new file mode 100644 index 0000000..f0ef996 --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/service/BookingAgentIntentMergerTest.java @@ -0,0 +1,70 @@ +package com.petstore.bookingagent.service; + +import com.petstore.bookingagent.domain.BookingAgentDraftState; +import com.petstore.bookingagent.provider.BookingIntentPatch; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class BookingAgentIntentMergerTest { + private final BookingAgentIntentMerger merger = new BookingAgentIntentMerger(); + + @Test + void newConstraintInvalidatesOnlyDependentBackendFacts() { + BookingAgentDraftState state = BookingAgentDraftState.initial(10L, "测试门店"); + state.setPetQuery("球球"); + state.setPetId(1L); + state.setPetName("球球"); + state.setServiceQuery("洗澡"); + state.setServiceTypeId(2L); + state.setServiceType("洗澡"); + state.setAppointmentTime(LocalDateTime.of(2026, 8, 3, 14, 0)); + + BookingIntentPatch patch = new BookingIntentPatch( + BookingIntentPatch.SCHEMA_VERSION, + BookingIntentPatch.Intent.MODIFY, + "小白", + null, + "明天", + new BookingIntentPatch.TimeWindow("16:00", null), + null, + List.of(), + List.of(), + BookingIntentPatch.NextAction.RESOLVE_CONTEXT + ); + + merger.merge(state, patch); + + assertEquals("小白", state.getPetQuery()); + assertNull(state.getPetId()); + assertEquals(2L, state.getServiceTypeId()); + assertEquals("明天", state.getDateExpression()); + assertEquals("16:00", state.getRequestedTimeStart()); + assertNull(state.getAppointmentTime()); + } + + @Test + void clearFieldsRemovePersistedConstraintAndResolvedFact() { + BookingAgentDraftState state = BookingAgentDraftState.initial(10L, "测试门店"); + state.setRemark("怕吹风机"); + state.setServiceQuery("洗澡"); + state.setServiceTypeId(2L); + + merger.merge(state, new BookingIntentPatch( + BookingIntentPatch.SCHEMA_VERSION, + BookingIntentPatch.Intent.MODIFY, + null, null, null, null, null, + List.of(BookingIntentPatch.ClearField.REMARK, BookingIntentPatch.ClearField.SERVICE_QUERY), + List.of(), + BookingIntentPatch.NextAction.ASK + )); + + assertNull(state.getRemark()); + assertNull(state.getServiceQuery()); + assertNull(state.getServiceTypeId()); + } +} diff --git a/src/test/java/com/petstore/bookingagent/service/BookingAgentNoAppointmentWriteContractTest.java b/src/test/java/com/petstore/bookingagent/service/BookingAgentNoAppointmentWriteContractTest.java new file mode 100644 index 0000000..4c02c1b --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/service/BookingAgentNoAppointmentWriteContractTest.java @@ -0,0 +1,33 @@ +package com.petstore.bookingagent.service; + +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 BookingAgentNoAppointmentWriteContractTest { + + @Test + void bookingAgentDomainContainsNoAppointmentWritePath() throws Exception { + List files; + try (var paths = Files.walk(Path.of("src/main/java/com/petstore/bookingagent"))) { + files = paths.filter(path -> path.toString().endsWith(".java")).toList(); + } + String source = new StringBuilder() + .append(String.join("\n", files.stream().map(path -> { + try { + return Files.readString(path); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + }).toList())) + .toString(); + + assertFalse(source.contains("AppointmentService")); + assertFalse(source.contains("appointmentMapper.save(")); + assertFalse(source.contains("/confirm")); + } +} diff --git a/src/test/java/com/petstore/bookingagent/service/BookingAgentRateLimiterTest.java b/src/test/java/com/petstore/bookingagent/service/BookingAgentRateLimiterTest.java new file mode 100644 index 0000000..64abafa --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/service/BookingAgentRateLimiterTest.java @@ -0,0 +1,67 @@ +package com.petstore.bookingagent.service; + +import com.petstore.bookingagent.config.BookingAgentRuntimeConfiguration; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BookingAgentRateLimiterTest { + + @Test + void enforcesPerCustomerPerOperationTenMinuteBuckets() { + MutableClock clock = new MutableClock( + Instant.parse("2026-08-02T02:00:00Z"), + BookingAgentRuntimeConfiguration.BUSINESS_ZONE + ); + BookingAgentRateLimiter limiter = new BookingAgentRateLimiter(clock); + for (int i = 0; i < 5; i++) { + limiter.acquire(99L, BookingAgentRateLimiter.Operation.CREATE_SESSION); + } + + BookingAgentException exception = assertThrows( + BookingAgentException.class, + () -> limiter.acquire(99L, BookingAgentRateLimiter.Operation.CREATE_SESSION) + ); + assertEquals("RATE_LIMITED", exception.getBizCode()); + + limiter.acquire(100L, BookingAgentRateLimiter.Operation.CREATE_SESSION); + limiter.acquire(99L, BookingAgentRateLimiter.Operation.SUBMIT_MESSAGE); + clock.advance(Duration.ofMinutes(10).plusSeconds(1)); + limiter.acquire(99L, BookingAgentRateLimiter.Operation.CREATE_SESSION); + } + + private static final class MutableClock extends Clock { + private Instant instant; + private final ZoneId zone; + + private MutableClock(Instant instant, ZoneId zone) { + this.instant = instant; + this.zone = zone; + } + + void advance(Duration duration) { + instant = instant.plus(duration); + } + + @Override + public ZoneId getZone() { + return zone; + } + + @Override + public Clock withZone(ZoneId zone) { + return new MutableClock(instant, zone); + } + + @Override + public Instant instant() { + return instant; + } + } +} diff --git a/src/test/java/com/petstore/bookingagent/service/BookingAgentServiceTest.java b/src/test/java/com/petstore/bookingagent/service/BookingAgentServiceTest.java new file mode 100644 index 0000000..8c48f54 --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/service/BookingAgentServiceTest.java @@ -0,0 +1,328 @@ +package com.petstore.bookingagent.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.petstore.bookingagent.api.BookingAgentDtos; +import com.petstore.bookingagent.config.BookingAgentProperties; +import com.petstore.bookingagent.config.BookingAgentRuntimeConfiguration; +import com.petstore.bookingagent.domain.BookingAgentDraftState; +import com.petstore.bookingagent.domain.BookingAgentSession; +import com.petstore.bookingagent.domain.BookingAgentStatus; +import com.petstore.bookingagent.mapper.BookingAgentSessionMapper; +import com.petstore.bookingagent.provider.BookingIntentExtractor; +import com.petstore.bookingagent.provider.BookingIntentPatch; +import com.petstore.bookingagent.provider.ProviderException; +import com.petstore.bookingagent.provider.SpeechTranscriber; +import com.petstore.bookingagent.provider.SpeechTranscription; +import com.petstore.entity.Store; +import com.petstore.mapper.StoreMapper; +import com.petstore.service.BusinessEventService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +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; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BookingAgentServiceTest { + private static final Long CUSTOMER_ID = 99L; + private static final Long STORE_ID = 10L; + private static final String SESSION_ID = "f5d7b3e2-9c25-41d3-87b0-6e4895b24d50"; + private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 2, 10, 0); + + @Mock private BookingAgentSessionMapper sessionMapper; + @Mock private StoreMapper storeMapper; + @Mock private BookingIntentExtractor intentExtractor; + @Mock private SpeechTranscriber speechTranscriber; + @Mock private BookingAgentContextResolver contextResolver; + @Mock private BookingAgentAudioValidator audioValidator; + @Mock private BookingAgentRateLimiter rateLimiter; + @Mock private BusinessEventService businessEventService; + + private BookingAgentProperties properties; + private ObjectMapper objectMapper; + private BookingAgentService service; + + @BeforeEach + void setUp() { + properties = new BookingAgentProperties(); + properties.setEnabled(true); + objectMapper = new ObjectMapper().findAndRegisterModules(); + Clock clock = Clock.fixed( + Instant.parse("2026-08-02T02:00:00Z"), + BookingAgentRuntimeConfiguration.BUSINESS_ZONE + ); + service = new BookingAgentService( + properties, + sessionMapper, + storeMapper, + intentExtractor, + speechTranscriber, + contextResolver, + new BookingAgentIntentMerger(), + new BookingAgentReplyRenderer(), + audioValidator, + rateLimiter, + businessEventService, + objectMapper, + clock + ); + lenient().when(sessionMapper.save(any(BookingAgentSession.class))).thenAnswer(invocation -> { + BookingAgentSession saved = invocation.getArgument(0); + if (saved.getId() == null) saved.setId(500L); + return saved; + }); + } + + @Test + void disabledFeatureFailsBeforeTouchingStoreOrProvider() { + properties.setEnabled(false); + + assertBizCode("AGENT_DISABLED", () -> service.createSession(CUSTOMER_ID, STORE_ID)); + + verify(storeMapper, never()).findByIdAndDeletedFalse(any()); + verify(intentExtractor, never()).extract(any()); + } + + @Test + void createsThirtyMinuteOwnedSessionAndStartedEvent() { + Store store = store(); + BookingAgentDraftState state = BookingAgentDraftState.initial(STORE_ID, store.getName()); + BookingAgentResolution resolution = collecting(state); + when(storeMapper.findByIdAndDeletedFalse(STORE_ID)).thenReturn(Optional.of(store)); + when(contextResolver.resolve(state, CUSTOMER_ID)).thenReturn(resolution); + + BookingAgentDtos.SessionView view = service.createSession(CUSTOMER_ID, STORE_ID); + + assertEquals(0, view.draftVersion()); + assertEquals(NOW.plusMinutes(30), view.expiresAt()); + assertEquals(BookingAgentStatus.COLLECTING, view.status()); + verify(rateLimiter).acquire(CUSTOMER_ID, BookingAgentRateLimiter.Operation.CREATE_SESSION); + verify(businessEventService).recordBookingAgentStarted(any(BookingAgentSession.class)); + } + + @Test + void successfulMessageChecksVersionIncrementsAndEmitsFirstReadyEvent() throws Exception { + BookingAgentSession session = activeSession(BookingAgentStatus.COLLECTING, 0); + BookingAgentDraftState state = objectMapper.readValue(session.getDraftJson(), BookingAgentDraftState.class); + BookingIntentPatch patch = patch("球球", "洗澡", "明天", "14:00", "14:00"); + when(sessionMapper.findOwnedForUpdate(SESSION_ID, CUSTOMER_ID)).thenReturn(Optional.of(session)); + when(intentExtractor.extract(any())).thenReturn(patch); + when(contextResolver.resolve(any(BookingAgentDraftState.class), eq(CUSTOMER_ID))) + .thenAnswer(invocation -> confirmable(invocation.getArgument(0))); + + BookingAgentDtos.SessionView view = service.submitMessage( + CUSTOMER_ID, SESSION_ID, "voice", "明天下午两点", 0 + ); + + assertEquals(1, view.draftVersion()); + assertEquals(BookingAgentStatus.CONFIRMABLE, view.status()); + assertTrue(view.confirmable()); + assertEquals("voice", session.getInputModality()); + verify(businessEventService).recordBookingAgentDraftReady(session); + assertFalse(session.getDraftJson().contains("明天下午两点")); + assertFalse(session.getDraftJson().contains("petQuery")); + assertFalse(session.getDraftJson().contains("serviceQuery")); + assertFalse(session.getDraftJson().contains("dateExpression")); + } + + @Test + void staleVersionDoesNotCallProviderOrMutateDraft() { + BookingAgentSession session = activeSession(BookingAgentStatus.COLLECTING, 2); + when(sessionMapper.findOwnedForUpdate(SESSION_ID, CUSTOMER_ID)).thenReturn(Optional.of(session)); + + assertBizCode("DRAFT_VERSION_CONFLICT", () -> service.submitMessage( + CUSTOMER_ID, SESSION_ID, "text", "明天", 1 + )); + + verify(intentExtractor, never()).extract(any()); + assertEquals(2, session.getDraftVersion()); + } + + @Test + void missingAndOtherCustomerSessionsUseSameHiddenNotFound() { + when(sessionMapper.findOwnedForUpdate(SESSION_ID, CUSTOMER_ID)).thenReturn(Optional.empty()); + + assertBizCode("SESSION_NOT_FOUND", () -> service.cancel(CUSTOMER_ID, SESSION_ID)); + } + + @Test + void endpointDetectionMarksExpiredSessionWithoutSlidingTtl() { + BookingAgentSession session = activeSession(BookingAgentStatus.COLLECTING, 0); + session.setExpiresAt(NOW); + when(sessionMapper.findOwnedForUpdate(SESSION_ID, CUSTOMER_ID)).thenReturn(Optional.of(session)); + + assertBizCode("SESSION_EXPIRED", () -> service.submitMessage( + CUSTOMER_ID, SESSION_ID, "text", "明天", 0 + )); + + assertEquals(BookingAgentStatus.EXPIRED, session.resolvedStatus()); + assertEquals(NOW, session.getExpiresAt()); + verify(sessionMapper).save(session); + verify(intentExtractor, never()).extract(any()); + } + + @Test + void providerFailurePreservesDraftAndVersionThenRecordsLowSensitivityFallback() { + BookingAgentSession session = activeSession(BookingAgentStatus.COLLECTING, 3); + String oldDraft = session.getDraftJson(); + when(sessionMapper.findOwnedForUpdate(SESSION_ID, CUSTOMER_ID)).thenReturn(Optional.of(session)); + when(intentExtractor.extract(any())).thenThrow(ProviderException.timeout()); + + assertBizCode("AGENT_UNAVAILABLE", () -> service.submitMessage( + CUSTOMER_ID, SESSION_ID, "text", "明天", 3 + )); + + assertEquals(BookingAgentStatus.FALLBACK, session.resolvedStatus()); + assertEquals(3, session.getDraftVersion()); + assertEquals(oldDraft, session.getDraftJson()); + verify(businessEventService).recordBookingAgentFallback(session, "llm_unavailable"); + } + + @Test + void handoffReturnsVerifiedDraftAndMakesSessionTerminal() { + BookingAgentSession session = activeSession(BookingAgentStatus.CONFIRMABLE, 4); + when(sessionMapper.findOwnedForUpdate(SESSION_ID, CUSTOMER_ID)).thenReturn(Optional.of(session)); + + BookingAgentDtos.HandoffData result = service.handoff(CUSTOMER_ID, SESSION_ID, 4); + + assertEquals("booking-agent-m0", result.source()); + assertEquals(STORE_ID, result.draft().storeId()); + assertEquals(BookingAgentStatus.FALLBACK, session.resolvedStatus()); + verify(businessEventService).recordBookingAgentFallback(session, "user"); + } + + @Test + void transcriptionUsesSessionDerivedContextButDoesNotPersistText() { + BookingAgentSession session = activeSession(BookingAgentStatus.COLLECTING, 0); + byte[] bytes = new byte[]{1, 2, 3}; + when(sessionMapper.findOwnedForUpdate(SESSION_ID, CUSTOMER_ID)).thenReturn(Optional.of(session)); + when(audioValidator.validate(bytes, "audio/wav")) + .thenReturn(new BookingAgentAudioValidator.ValidatedAudio(bytes, "audio/wav", 1)); + when(contextResolver.speechContextTerms(CUSTOMER_ID, STORE_ID)) + .thenReturn(List.of("球球", "洗澡")); + when(speechTranscriber.transcribe(any())).thenReturn(new SpeechTranscription("明天下午两点")); + + BookingAgentDtos.TranscriptionData result = service.transcribe( + CUSTOMER_ID, SESSION_ID, bytes, "audio/wav" + ); + + assertEquals("明天下午两点", result.text()); + assertFalse(session.getDraftJson().contains(result.text())); + verify(sessionMapper, never()).save(session); + } + + @Test + void cancelDoesNotDependOnFeatureFlagAndCreatesNoFallbackEvent() { + properties.setEnabled(false); + BookingAgentSession session = activeSession(BookingAgentStatus.PROPOSING, 1); + when(sessionMapper.findOwnedForUpdate(SESSION_ID, CUSTOMER_ID)).thenReturn(Optional.of(session)); + + BookingAgentDtos.CancelData result = service.cancel(CUSTOMER_ID, SESSION_ID); + + assertEquals(BookingAgentStatus.CANCELLED, result.status()); + verify(businessEventService, never()).recordBookingAgentFallback(any(), any()); + } + + private BookingAgentSession activeSession(BookingAgentStatus status, int version) { + BookingAgentDraftState state = BookingAgentDraftState.initial(STORE_ID, "宠小它测试店"); + BookingAgentSession session = new BookingAgentSession(); + session.setId(500L); + session.setSessionId(SESSION_ID); + session.setCustomerUserId(CUSTOMER_ID); + session.setStoreId(STORE_ID); + session.setStatus(status); + session.setDraftJson(write(state)); + session.setDraftVersion(version); + session.setEntrySource("appointment_create"); + session.setExpiresAt(NOW.plusMinutes(30)); + session.setCreateTime(NOW); + session.setUpdateTime(NOW); + return session; + } + + private BookingAgentResolution collecting(BookingAgentDraftState state) { + return new BookingAgentResolution( + state, + BookingAgentStatus.COLLECTING, + List.of(), + List.of(), + false + ); + } + + private BookingAgentResolution confirmable(BookingAgentDraftState state) { + state.setPetId(20L); + state.setPetName("球球"); + state.setPetType("狗"); + state.setServiceTypeId(30L); + state.setServiceType("洗澡"); + state.setDurationMinutes(60); + state.setDateConstraint(java.time.LocalDate.of(2026, 8, 3)); + state.setAppointmentTime(LocalDateTime.of(2026, 8, 3, 14, 0)); + state.setAppointmentEndTime(LocalDateTime.of(2026, 8, 3, 15, 0)); + return new BookingAgentResolution( + state, + BookingAgentStatus.CONFIRMABLE, + List.of(), + List.of(), + false + ); + } + + private BookingIntentPatch patch( + String pet, + String serviceName, + String date, + String start, + String end) { + return new BookingIntentPatch( + BookingIntentPatch.SCHEMA_VERSION, + BookingIntentPatch.Intent.BOOK, + pet, + serviceName, + date, + new BookingIntentPatch.TimeWindow(start, end), + null, + List.of(), + List.of(), + BookingIntentPatch.NextAction.RESOLVE_CONTEXT + ); + } + + private Store store() { + Store store = new Store(); + store.setId(STORE_ID); + store.setName("宠小它测试店"); + return store; + } + + private String write(BookingAgentDraftState state) { + try { + return objectMapper.writeValueAsString(state); + } catch (Exception exception) { + throw new AssertionError(exception); + } + } + + private void assertBizCode(String expected, Runnable action) { + BookingAgentException exception = assertThrows(BookingAgentException.class, action::run); + assertEquals(expected, exception.getBizCode()); + } +} diff --git a/src/test/java/com/petstore/bookingagent/service/BookingAgentSessionJanitorTest.java b/src/test/java/com/petstore/bookingagent/service/BookingAgentSessionJanitorTest.java new file mode 100644 index 0000000..fe94e2b --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/service/BookingAgentSessionJanitorTest.java @@ -0,0 +1,30 @@ +package com.petstore.bookingagent.service; + +import com.petstore.bookingagent.config.BookingAgentRuntimeConfiguration; +import com.petstore.bookingagent.mapper.BookingAgentSessionMapper; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class BookingAgentSessionJanitorTest { + + @Test + void expiresActiveRowsAndPurgesTwentyFourHoursAfterExpiry() { + BookingAgentSessionMapper mapper = mock(BookingAgentSessionMapper.class); + Clock clock = Clock.fixed( + Instant.parse("2026-08-02T02:00:00Z"), + BookingAgentRuntimeConfiguration.BUSINESS_ZONE + ); + + new BookingAgentSessionJanitor(mapper, clock).maintainSessions(); + + LocalDateTime now = LocalDateTime.of(2026, 8, 2, 10, 0); + verify(mapper).expireActiveSessions(now); + verify(mapper).deleteExpiredBefore(now.minusHours(24)); + } +} diff --git a/src/test/java/com/petstore/bookingagent/service/BookingTimeConstraintResolverTest.java b/src/test/java/com/petstore/bookingagent/service/BookingTimeConstraintResolverTest.java new file mode 100644 index 0000000..932011d --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/service/BookingTimeConstraintResolverTest.java @@ -0,0 +1,32 @@ +package com.petstore.bookingagent.service; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class BookingTimeConstraintResolverTest { + private final BookingTimeConstraintResolver resolver = new BookingTimeConstraintResolver(); + private final LocalDate today = LocalDate.of(2026, 8, 3); + + @Test + void resolvesFrozenRelativeAndAbsoluteForms() { + assertEquals(today, resolver.resolve("今天", today)); + assertEquals(LocalDate.of(2026, 8, 4), resolver.resolve("明天", today)); + assertEquals(LocalDate.of(2026, 8, 5), resolver.resolve("后天", today)); + assertEquals(LocalDate.of(2026, 8, 8), resolver.resolve("本周六", today)); + assertEquals(LocalDate.of(2026, 8, 11), resolver.resolve("下周二", today)); + assertEquals(LocalDate.of(2026, 8, 12), resolver.resolve("8月12日", today)); + assertEquals(LocalDate.of(2026, 8, 12), resolver.resolve("2026-08-12", today)); + } + + @Test + void refusesAmbiguousInvalidAndPastDates() { + assertNull(resolver.resolve("过几天", today)); + assertNull(resolver.resolve("周末都行", today)); + assertNull(resolver.resolve("2026-07-31", today)); + assertNull(resolver.resolve("2月30日", today)); + } +} diff --git a/src/test/java/com/petstore/service/BusinessEventServiceTest.java b/src/test/java/com/petstore/service/BusinessEventServiceTest.java index f1c880c..005cbff 100644 --- a/src/test/java/com/petstore/service/BusinessEventServiceTest.java +++ b/src/test/java/com/petstore/service/BusinessEventServiceTest.java @@ -1,6 +1,8 @@ package com.petstore.service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.petstore.bookingagent.domain.BookingAgentSession; +import com.petstore.bookingagent.domain.BookingAgentStatus; import com.petstore.entity.Appointment; import com.petstore.entity.BusinessEvent; import com.petstore.entity.FollowUpTask; @@ -189,4 +191,36 @@ class BusinessEventServiceTest { assertEquals("{\"bookingOrigin\":\"follow_up\"}", event.getMetadataJson()); assertFalse(event.getMetadataJson().contains("phone")); } + + @Test + void bookingAgentFactsUseSessionAggregateAndWhitelistedMetadataOnly() { + BookingAgentSession session = new BookingAgentSession(); + session.setId(500L); + session.setStoreId(10L); + session.setCustomerUserId(88L); + session.setEntrySource("appointment_create"); + session.setInputModality("mixed"); + session.setStatus(BookingAgentStatus.CONFIRMABLE); + session.setCreateTime(LocalDateTime.of(2026, 8, 2, 10, 0)); + session.setUpdateTime(LocalDateTime.of(2026, 8, 2, 10, 5)); + when(storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(10L, 88L)) + .thenReturn(Optional.empty()); + when(businessEventMapper.findFirstByIdempotencyKey(any())).thenReturn(Optional.empty()); + when(businessEventMapper.save(any(BusinessEvent.class))).thenAnswer(inv -> inv.getArgument(0)); + + BusinessEvent started = service.recordBookingAgentStarted(session); + BusinessEvent ready = service.recordBookingAgentDraftReady(session); + BusinessEvent fallback = service.recordBookingAgentFallback(session, "llm_unavailable"); + + assertEquals("booking_agent_session", started.getAggregateType()); + assertEquals(500L, started.getAggregateId()); + assertEquals("{\"entrySource\":\"appointment_create\"}", started.getMetadataJson()); + assertEquals("{\"inputModality\":\"mixed\"}", ready.getMetadataJson()); + assertEquals("{\"reason\":\"llm_unavailable\"}", fallback.getMetadataJson()); + assertTrue(List.of(started, ready, fallback).stream().allMatch(event -> + !event.getMetadataJson().contains("球球") + && !event.getMetadataJson().contains("remark") + && !event.getMetadataJson().contains("token") + )); + } }