feat: add booking agent session orchestration
This commit is contained in:
parent
77c6d6923b
commit
3c000654d8
@ -135,6 +135,12 @@ curl http://localhost:8080/api/store/list
|
||||
```
|
||||
脚本仅为仍为 `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
|
||||
|
||||
```bash
|
||||
@ -150,7 +156,7 @@ production profile 下:
|
||||
|
||||
### 生产只读预检
|
||||
|
||||
完成备份和八个迁移后,以最终生产环境变量运行:
|
||||
完成备份和九个版本化迁移后,以最终生产环境变量运行:
|
||||
|
||||
```bash
|
||||
chmod +x deploy/release-preflight.sh deploy/production-smoke.sh
|
||||
@ -219,6 +225,7 @@ API_ORIGIN=https://<api-domain> backend/deploy/production-smoke.sh
|
||||
- [ ] 已执行 `20260802_create_store_onboarding.sql`,历史开通状态与六项邀请/回执验证均为 0
|
||||
- [ ] 已执行 `20260802_create_store_customer_timeline.sql`,两项 StoreCustomer 别名不变量均为 0
|
||||
- [ ] 已执行 `20260802_create_follow_up_task.sql`,五项回访任务与事件验证计数均为 0
|
||||
- [ ] 部署包含智能预约实体前已执行 `20260802_create_booking_agent_session.sql`,四项会话状态与 TTL 验证计数均为 0,功能开关保持关闭
|
||||
- [ ] `CORS_ALLOWED_ORIGINS` 仅包含本次发布的显式 HTTPS Web 源
|
||||
- [ ] `deploy/release-preflight.sh` 已以最终环境变量通过并留存输出
|
||||
- [ ] readiness 与上线后只读冒烟全部通过
|
||||
|
||||
52
db/migrations/20260802_create_booking_agent_session.sql
Normal file
52
db/migrations/20260802_create_booking_agent_session.sql
Normal 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();
|
||||
@ -24,4 +24,6 @@
|
||||
|
||||
`20260802_create_follow_up_task.sql` 必须在客户时间线迁移之后执行:建立可领取、改期、关闭和真实再次预约归因的 `t_follow_up_task`。只为仍待处理的历史留资建立开放任务,不猜测其他历史结果;脚本末尾五个验证计数必须为 0。
|
||||
|
||||
`20260802_create_booking_agent_session.sql` 必须在回访任务迁移之后、部署包含智能预约 JPA 实体的 backend 之前执行:建立不含原文/音频/模型响应的短期结构化草稿会话,冻结六态、owner/store 范围、草稿版本和 TTL 索引。脚本末尾四个验证计数必须为 0;功能开关仍保持关闭,迁移本身不代表允许生产调用供应商。
|
||||
|
||||
正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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("非法智能预约会话状态"));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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 "这一天暂时没有可约时段,请换个日期。";
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ package com.petstore.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.petstore.bookingagent.domain.BookingAgentSession;
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.BusinessEvent;
|
||||
import com.petstore.entity.FollowUpTask;
|
||||
@ -44,6 +45,9 @@ public class BusinessEventService {
|
||||
public static final String FOLLOW_UP_CANCELED = "follow_up_canceled";
|
||||
public static final String REBOOK_CREATED = "rebook_created";
|
||||
public static final String STORE_REGISTERED = "store_registered";
|
||||
public static final String BOOKING_AGENT_STARTED = "booking_agent_started";
|
||||
public static final String BOOKING_AGENT_DRAFT_READY = "booking_agent_draft_ready";
|
||||
public static final String BOOKING_AGENT_FALLBACK = "booking_agent_fallback";
|
||||
|
||||
private static final Set<String> FORBIDDEN_METADATA_FRAGMENTS = Set.of(
|
||||
"phone", "token", "url", "openid", "unionid",
|
||||
@ -72,6 +76,58 @@ public class BusinessEventService {
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordBookingAgentStarted(BookingAgentSession session) {
|
||||
return record(new EventCommand(
|
||||
BOOKING_AGENT_STARTED,
|
||||
session.getStoreId(),
|
||||
resolveStoreCustomerId(session.getStoreId(), session.getCustomerUserId()),
|
||||
"booking_agent_session",
|
||||
session.getId(),
|
||||
session.getCustomerUserId(),
|
||||
"customer",
|
||||
"customer",
|
||||
session.getCreateTime(),
|
||||
"booking_agent_started:" + session.getId(),
|
||||
Map.of("entrySource", session.getEntrySource())
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordBookingAgentDraftReady(BookingAgentSession session) {
|
||||
return record(new EventCommand(
|
||||
BOOKING_AGENT_DRAFT_READY,
|
||||
session.getStoreId(),
|
||||
resolveStoreCustomerId(session.getStoreId(), session.getCustomerUserId()),
|
||||
"booking_agent_session",
|
||||
session.getId(),
|
||||
session.getCustomerUserId(),
|
||||
"customer",
|
||||
"customer",
|
||||
session.getUpdateTime(),
|
||||
"booking_agent_draft_ready:" + session.getId(),
|
||||
Map.of("inputModality", session.getInputModality())
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordBookingAgentFallback(BookingAgentSession session, String reason) {
|
||||
String safeReason = "llm_unavailable".equals(reason) ? "llm_unavailable" : "user";
|
||||
return record(new EventCommand(
|
||||
BOOKING_AGENT_FALLBACK,
|
||||
session.getStoreId(),
|
||||
resolveStoreCustomerId(session.getStoreId(), session.getCustomerUserId()),
|
||||
"booking_agent_session",
|
||||
session.getId(),
|
||||
session.getCustomerUserId(),
|
||||
"customer",
|
||||
"customer",
|
||||
session.getUpdateTime(),
|
||||
"booking_agent_fallback:" + session.getId(),
|
||||
Map.of("reason", safeReason)
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordAppointmentCreated(
|
||||
Appointment appointment,
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
)
|
||||
"""));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.petstore.bookingagent.domain.BookingAgentSession;
|
||||
import com.petstore.bookingagent.domain.BookingAgentStatus;
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.BusinessEvent;
|
||||
import com.petstore.entity.FollowUpTask;
|
||||
@ -189,4 +191,36 @@ class BusinessEventServiceTest {
|
||||
assertEquals("{\"bookingOrigin\":\"follow_up\"}", event.getMetadataJson());
|
||||
assertFalse(event.getMetadataJson().contains("phone"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bookingAgentFactsUseSessionAggregateAndWhitelistedMetadataOnly() {
|
||||
BookingAgentSession session = new BookingAgentSession();
|
||||
session.setId(500L);
|
||||
session.setStoreId(10L);
|
||||
session.setCustomerUserId(88L);
|
||||
session.setEntrySource("appointment_create");
|
||||
session.setInputModality("mixed");
|
||||
session.setStatus(BookingAgentStatus.CONFIRMABLE);
|
||||
session.setCreateTime(LocalDateTime.of(2026, 8, 2, 10, 0));
|
||||
session.setUpdateTime(LocalDateTime.of(2026, 8, 2, 10, 5));
|
||||
when(storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(10L, 88L))
|
||||
.thenReturn(Optional.empty());
|
||||
when(businessEventMapper.findFirstByIdempotencyKey(any())).thenReturn(Optional.empty());
|
||||
when(businessEventMapper.save(any(BusinessEvent.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
BusinessEvent started = service.recordBookingAgentStarted(session);
|
||||
BusinessEvent ready = service.recordBookingAgentDraftReady(session);
|
||||
BusinessEvent fallback = service.recordBookingAgentFallback(session, "llm_unavailable");
|
||||
|
||||
assertEquals("booking_agent_session", started.getAggregateType());
|
||||
assertEquals(500L, started.getAggregateId());
|
||||
assertEquals("{\"entrySource\":\"appointment_create\"}", started.getMetadataJson());
|
||||
assertEquals("{\"inputModality\":\"mixed\"}", ready.getMetadataJson());
|
||||
assertEquals("{\"reason\":\"llm_unavailable\"}", fallback.getMetadataJson());
|
||||
assertTrue(List.of(started, ready, fallback).stream().allMatch(event ->
|
||||
!event.getMetadataJson().contains("球球")
|
||||
&& !event.getMetadataJson().contains("remark")
|
||||
&& !event.getMetadataJson().contains("token")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user