feat: add booking agent session orchestration

This commit is contained in:
malei 2026-08-02 18:32:27 +08:00
parent 77c6d6923b
commit 3c000654d8
35 changed files with 3275 additions and 1 deletions

View File

@ -135,6 +135,12 @@ curl http://localhost:8080/api/store/list
``` ```
脚本仅为仍为 `pending` 的历史留资建立开放任务,并写入低敏 `follow_up_created` 事实;不会猜测已发送、已取消或已退订线索的处理结果。末尾五项客户归属、状态机、开放任务唯一性和事件完整性验证必须全部为 0。 脚本仅为仍为 `pending` 的历史留资建立开放任务,并写入低敏 `follow_up_created` 事实;不会猜测已发送、已取消或已退订线索的处理结果。末尾五项客户归属、状态机、开放任务唯一性和事件完整性验证必须全部为 0。
12. **智能预约 M0 短期会话**:回访任务迁移验证完成后、部署包含 `BookingAgentSession` 的 backend 前执行:
```bash
mysql -h <host> -u <user> -p petstore < db/migrations/20260802_create_booking_agent_session.sql
```
脚本只建立短期结构化草稿会话,不保存原文、音频或模型响应,也不关联 `Appointment`。末尾四项状态、唯一键和 TTL 验证必须全部为 0迁移后仍保持 `PETSTORE_BOOKING_AGENT_ENABLED=false`,生产开启需另行授权。
### production profile ### production profile
```bash ```bash
@ -150,7 +156,7 @@ production profile 下:
### 生产只读预检 ### 生产只读预检
完成备份和八个迁移后,以最终生产环境变量运行: 完成备份和九个版本化迁移后,以最终生产环境变量运行:
```bash ```bash
chmod +x deploy/release-preflight.sh deploy/production-smoke.sh chmod +x deploy/release-preflight.sh deploy/production-smoke.sh
@ -219,6 +225,7 @@ API_ORIGIN=https://<api-domain> backend/deploy/production-smoke.sh
- [ ] 已执行 `20260802_create_store_onboarding.sql`,历史开通状态与六项邀请/回执验证均为 0 - [ ] 已执行 `20260802_create_store_onboarding.sql`,历史开通状态与六项邀请/回执验证均为 0
- [ ] 已执行 `20260802_create_store_customer_timeline.sql`,两项 StoreCustomer 别名不变量均为 0 - [ ] 已执行 `20260802_create_store_customer_timeline.sql`,两项 StoreCustomer 别名不变量均为 0
- [ ] 已执行 `20260802_create_follow_up_task.sql`,五项回访任务与事件验证计数均为 0 - [ ] 已执行 `20260802_create_follow_up_task.sql`,五项回访任务与事件验证计数均为 0
- [ ] 部署包含智能预约实体前已执行 `20260802_create_booking_agent_session.sql`,四项会话状态与 TTL 验证计数均为 0功能开关保持关闭
- [ ] `CORS_ALLOWED_ORIGINS` 仅包含本次发布的显式 HTTPS Web 源 - [ ] `CORS_ALLOWED_ORIGINS` 仅包含本次发布的显式 HTTPS Web 源
- [ ] `deploy/release-preflight.sh` 已以最终环境变量通过并留存输出 - [ ] `deploy/release-preflight.sh` 已以最终环境变量通过并留存输出
- [ ] readiness 与上线后只读冒烟全部通过 - [ ] readiness 与上线后只读冒烟全部通过

View File

@ -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();

View File

@ -24,4 +24,6 @@
`20260802_create_follow_up_task.sql` 必须在客户时间线迁移之后执行:建立可领取、改期、关闭和真实再次预约归因的 `t_follow_up_task`。只为仍待处理的历史留资建立开放任务,不猜测其他历史结果;脚本末尾五个验证计数必须为 0。 `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 后续任务;接入前不得把本目录误当成自动执行目录。 正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。

View File

@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> cancel(@PathVariable String sessionId) {
CurrentUser user = requireCustomer();
return success(bookingAgentService.cancel(
user.userId(),
requestParser.parseSessionId(sessionId)
));
}
@ExceptionHandler(BookingAgentException.class)
public Map<String, Object> handleBookingAgentException(BookingAgentException exception) {
Map<String, Object> 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<String, Object> handleInvalidRequest(Exception ignored) {
return handleBookingAgentException(BookingAgentException.invalidInput());
}
@ExceptionHandler({MultipartException.class, MissingServletRequestPartException.class})
public Map<String, Object> handleInvalidMultipart(Exception ignored) {
return handleBookingAgentException(BookingAgentException.invalidAudio());
}
private CurrentUser requireCustomer() {
CurrentUser user = CurrentUserContext.require();
if (!user.isCustomer()) {
throw BookingAgentException.forbidden();
}
return user;
}
private Map<String, Object> success(Object data) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("code", 200);
response.put("data", data);
return response;
}
}

View File

@ -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<SlotOption> slotOptions,
List<QuickReply> 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) {
}
}

View File

@ -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<String> expected) {
if (body == null || !body.isObject()) {
throw BookingAgentException.invalidInput();
}
Set<String> actual = new HashSet<>();
Iterator<String> fields = body.fieldNames();
fields.forEachRemaining(actual::add);
if (!actual.equals(expected)) {
throw BookingAgentException.invalidInput();
}
}
public record SubmitMessageCommand(String inputType, String text, int draftVersion) {
}
}

View File

@ -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);
}
}

View File

@ -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<BookingDraft.MissingField> 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
);
}
}

View File

@ -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();
}
}

View File

@ -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("非法智能预约会话状态"));
}
}

View File

@ -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<MissingField> 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;
}
}
}

View File

@ -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<BookingAgentSession, Long> {
Optional<BookingAgentSession> findBySessionIdAndCustomerUserId(String sessionId, Long customerUserId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM BookingAgentSession s WHERE s.sessionId = :sessionId "
+ "AND s.customerUserId = :customerUserId")
Optional<BookingAgentSession> 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);
}

View File

@ -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<String> 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) {
}
}

View File

@ -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<Pet> pets = petMapper.findByOwnerUserIdAndDeletedFalseOrderByUpdateTimeDesc(customerUserId);
resolvePet(state, pets);
List<ServiceType> 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<BookingAgentResolution.QuickChoice> 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<BookingAgentResolution.SlotCandidate> allSlots = availableSlots(state, store);
List<BookingAgentResolution.SlotCandidate> 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<BookingAgentResolution.SlotCandidate> offered = (preferred.isEmpty() ? allSlots : preferred)
.stream()
.limit(3)
.toList();
List<BookingAgentResolution.QuickChoice> 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<String> speechContextTerms(Long customerUserId, Long storeId) {
Set<String> 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<String> 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<Pet> 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<Pet> described = new ArrayList<>();
List<String> 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<Pet> 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<Pet> 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<ServiceType> 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<ServiceType> 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<ServiceType> 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<ServiceType> 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<BookingAgentResolution.QuickChoice> firstMissingChoices(
BookingAgentDraftState state,
List<Pet> pets,
List<ServiceType> services,
LocalDate today) {
if (state.getPetId() == null) {
List<String> labels = petChoiceLabels(pets);
List<BookingAgentResolution.QuickChoice> 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<BookingAgentResolution.SlotCandidate> availableSlots(
BookingAgentDraftState state,
Store store) {
LocalDate date = state.getDateConstraint();
LocalDateTime dayStart = date.atStartOfDay();
LocalDateTime dayEnd = date.plusDays(1).atStartOfDay();
List<Appointment> appointments = appointmentMapper.findActiveByStoreAndDateRange(
state.getStoreId(), dayStart, dayEnd
);
List<ScheduleBlock> 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<BookingAgentResolution.SlotCandidate> 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<BookingAgentResolution.SlotCandidate> filterByRequestedWindow(
BookingAgentDraftState state,
List<BookingAgentResolution.SlotCandidate> 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<String> petChoiceLabels(List<Pet> pets) {
List<String> 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);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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<BucketKey, Deque<Instant>> 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<Instant> 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<Instant> 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) {
}
}

View File

@ -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 "这一天暂时没有可约时段,请换个日期。";
}
}

View File

@ -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<SlotCandidate> slotOptions,
List<QuickChoice> 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) {
}
}

View File

@ -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<String> 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<BookingAgentDtos.SlotOption> slots = resolution.slotOptions().stream()
.map(slot -> new BookingAgentDtos.SlotOption(
slot.startTime(), slot.endTime(), slot.label()
))
.toList();
List<BookingAgentDtos.QuickReply> 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);
}
}

View File

@ -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
);
}
}
}

View File

@ -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<String, DayOfWeek> 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);
}
}

View File

@ -2,6 +2,7 @@ package com.petstore.service;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.petstore.bookingagent.domain.BookingAgentSession;
import com.petstore.entity.Appointment; import com.petstore.entity.Appointment;
import com.petstore.entity.BusinessEvent; import com.petstore.entity.BusinessEvent;
import com.petstore.entity.FollowUpTask; 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 FOLLOW_UP_CANCELED = "follow_up_canceled";
public static final String REBOOK_CREATED = "rebook_created"; public static final String REBOOK_CREATED = "rebook_created";
public static final String STORE_REGISTERED = "store_registered"; 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<String> FORBIDDEN_METADATA_FRAGMENTS = Set.of( private static final Set<String> FORBIDDEN_METADATA_FRAGMENTS = Set.of(
"phone", "token", "url", "openid", "unionid", "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 @Transactional
public BusinessEvent recordAppointmentCreated( public BusinessEvent recordAppointmentCreated(
Appointment appointment, Appointment appointment,

View File

@ -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)
);
}
}

View File

@ -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);
}
}

View File

@ -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'
)
"""));
}
}
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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<Path> 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"));
}
}

View File

@ -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;
}
}
}

View File

@ -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());
}
}

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -1,6 +1,8 @@
package com.petstore.service; package com.petstore.service;
import com.fasterxml.jackson.databind.ObjectMapper; 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.Appointment;
import com.petstore.entity.BusinessEvent; import com.petstore.entity.BusinessEvent;
import com.petstore.entity.FollowUpTask; import com.petstore.entity.FollowUpTask;
@ -189,4 +191,36 @@ class BusinessEventServiceTest {
assertEquals("{\"bookingOrigin\":\"follow_up\"}", event.getMetadataJson()); assertEquals("{\"bookingOrigin\":\"follow_up\"}", event.getMetadataJson());
assertFalse(event.getMetadataJson().contains("phone")); 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")
));
}
} }