更新日期: 2026-02-04
相关功能: 013-appointment-booking, 014-mobile-booking
适用客户端: Mobile App (Flutter) + Web App (Next.js)
| 端点 | GET /api/public/booking/slots |
|---|---|
| 位置 | backend/api/public/booking.js:303 |
| 限流 | publicSlotsLimiter |
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
store_id |
string | 是 | 店铺 ID |
service_id |
string | 是 | 服务 ID(用于获取服务时长) |
date |
string | 是 | 日期 (YYYY-MM-DD 格式) |
employee_id |
string | 否 | 员工 ID(null 表示"任意技师") |
excluded_slots |
string (JSON) | 否 | 购物车中已占用的时间段(JSON 数组) |
[
{
"employee_id": "emp-123", // 特定技师占用
"start_time": "09:00",
"duration": 60
},
{
"employee_id": null, // "任意技师"占用(消耗一个可用名额)
"start_time": "10:30",
"duration": 45
}
]
employee_id 现在支持 null 值,表示"任意技师"模式的购物车项目。
后端会将每个 employee_id: null 的项目计为"占用一个可用技师名额"。
Slots API 查询可用技师时,通过 employee_service_capabilities 表做第一层过滤。
只有 is_available = true 且关联了对应 service_id 的员工才会被纳入可用列表。
GET /api/employees/:id/service-capabilities |
PUT /api/employees/:id/service-capabilities
| 层级 | 过滤维度 | 数据来源 | 说明 |
|---|---|---|---|
| 1. 粗粒度 | 职位级别 | job_titles.can_provide_service |
标记该职位是否为服务提供者(如前台不提供美甲服务) |
| 2. 细粒度 | 员工-服务关联 | employee_service_capabilities |
精确的多对多关联,指定每个员工可以提供哪些具体服务 |
employee_service_capabilities 为空),
则该员工对所有服务均可用(向后兼容行为)。只有当存在至少一条记录时,才启用细粒度过滤。
位置: lib/features/shopping_cart/presentation/pages/select_time_page.dart
void _loadTimeSlots() {
final dateStr = DateFormat('yyyy-MM-dd').format(_selectedDate);
final employeeId = widget.employeeId == 'any' ? null : widget.employeeId;
// 仅当选择"任意技师"时才需要传递 excludedSlots
List<ExcludedSlot>? excludedSlots;
if (employeeId == null) {
final cartState = getIt<CartBloc>().state;
// 包含所有同一日期的购物车项目(包括"任意技师"项目)
// - 特定技师项目: 直接占用该技师的时间
// - 任意技师项目: 消耗一个可用技师名额
final cartItemsForDate = cartState.items.where((item) =>
item.selectedDate == dateStr &&
item.selectedTimeSlot != null
// 注意:不再过滤 employeeId != null,允许 null 值
).toList();
if (cartItemsForDate.isNotEmpty) {
excludedSlots = cartItemsForDate.map((item) => ExcludedSlot(
employeeId: item.employeeId, // null 表示"任意技师"
startTime: item.selectedTimeSlot!,
duration: item.serviceDuration,
)).toList();
}
}
getIt<BookingBloc>().add(
LoadTimeSlots(
storeId: widget.storeId,
serviceId: widget.serviceId,
date: dateStr,
employeeId: employeeId,
excludedSlots: excludedSlots,
),
);
}
位置: lib/features/booking/domain/repositories/booking_repository.dart
/// 购物车中已占用的时间段,用于排除冲突时间
///
/// [employeeId] 为 null 表示"任意技师"模式的购物车项目,
/// 后端会将其计为"占用一个可用时间槽"
class ExcludedSlot {
final String? employeeId; // null 表示"任意技师"
final String startTime; // HH:MM 格式
final int duration; // 分钟
ExcludedSlot({
this.employeeId, // nullable for "any technician"
required this.startTime,
required this.duration,
});
Map<String, dynamic> toJson() => {
'employee_id': employeeId, // null will be serialized as JSON null
'start_time': startTime,
'duration': duration,
};
}
// 解析购物车中已占用的时间段
let cartExcludedSlots = [];
if (excluded_slots) {
try {
cartExcludedSlots = JSON.parse(excluded_slots);
if (!Array.isArray(cartExcludedSlots)) {
cartExcludedSlots = [];
}
} catch (e) {
logger.warn('Invalid excluded_slots format', { excluded_slots });
cartExcludedSlots = [];
}
}
// 辅助函数:检查时间是否被购物车项目占用
const isTimeBlockedByCart = (startTime, employeeId) => {
for (const cartItem of cartExcludedSlots) {
// 只检查同一员工
if (cartItem.employee_id !== employeeId) continue;
const cartStart = cartItem.start_time; // "09:00"
const cartDuration = cartItem.duration || 60; // 分钟
// 计算购物车项目的时间范围
const [cartHour, cartMin] = cartStart.split(':').map(Number);
const cartStartMinutes = cartHour * 60 + cartMin;
const cartEndMinutes = cartStartMinutes + cartDuration;
// 计算当前槽位的时间范围
const [slotHour, slotMin] = startTime.split(':').map(Number);
const slotStartMinutes = slotHour * 60 + slotMin;
const slotEndMinutes = slotStartMinutes + durationMinutes;
// 检查重叠:如果两个时间段有交集,则被占用
if (slotStartMinutes < cartEndMinutes && slotEndMinutes > cartStartMinutes) {
return true;
}
}
return false;
};
if (employee_id) {
// 查询指定员工的可用槽位
const slotsResult = await pool.query(
`SELECT TO_CHAR(datetime AT TIME ZONE $3, 'HH24:MI') as start_time
FROM tenant_qqnails.employee_available_start_times
WHERE employee_id = $1
AND datetime::date = $2
AND is_available = true
AND max_duration_minutes >= $4
ORDER BY datetime`,
[employee_id, date, timezone, durationMinutes]
);
// 过滤掉被购物车占用的时间
availableSlots = slotsResult.rows.filter(
slot => !isTimeBlockedByCart(slot.start_time, employee_id)
);
}
// 辅助函数:检查时间段是否重叠
const isTimeOverlap = (slot1Start, slot1Duration, slot2Start, slot2Duration) => {
const [h1, m1] = slot1Start.split(':').map(Number);
const [h2, m2] = slot2Start.split(':').map(Number);
const start1 = h1 * 60 + m1;
const end1 = start1 + slot1Duration;
const start2 = h2 * 60 + m2;
const end2 = start2 + slot2Duration;
return start1 < end2 && end1 > start2;
};
// 检查时间是否被特定员工的购物车项目占用
const isTimeBlockedByCart = (startTime, employeeId) => {
for (const cartItem of cartExcludedSlots) {
// 只检查特定员工的占用(employee_id 不为 null)
if (cartItem.employee_id === null || cartItem.employee_id === undefined) continue;
if (cartItem.employee_id !== employeeId) continue;
const cartDuration = cartItem.duration || 60;
if (isTimeOverlap(startTime, durationMinutes, cartItem.start_time, cartDuration)) {
return true;
}
}
return false;
};
// 计算某时间点被"任意技师"购物车项目占用的数量
const countAnyTechnicianBlockedSlots = (startTime) => {
let count = 0;
for (const cartItem of cartExcludedSlots) {
// 只计算"任意技师"项目(employee_id 为 null)
if (cartItem.employee_id !== null && cartItem.employee_id !== undefined) continue;
const cartDuration = cartItem.duration || 60;
if (isTimeOverlap(startTime, durationMinutes, cartItem.start_time, cartDuration)) {
count++;
}
}
return count;
};
// 任意技师模式:计算每个时间点的剩余可用名额
const slotsResult = await pool.query(
`SELECT
eat.employee_id,
TO_CHAR(eat.datetime AT TIME ZONE $3, 'HH24:MI') as start_time
FROM tenant_qqnails.employee_available_start_times eat
JOIN tenant_qqnails.employees e ON e.id = eat.employee_id
WHERE e.center_id = $1
AND e.is_active = true
AND eat.datetime::date = $2
AND eat.is_available = true
AND eat.max_duration_minutes >= $4
ORDER BY start_time`,
[store_id, date, timezone, durationMinutes]
);
// 按时间分组
const timeToEmployees = new Map();
for (const row of slotsResult.rows) {
if (!timeToEmployees.has(row.start_time)) {
timeToEmployees.set(row.start_time, []);
}
timeToEmployees.get(row.start_time).push(row.employee_id);
}
// 计算每个时间点的剩余可用名额
const availableTimes = [];
for (const [startTime, employees] of timeToEmployees) {
// 过滤掉被特定购物车项目占用的员工
const unblockedEmployees = employees.filter(
empId => !isTimeBlockedByCart(startTime, empId)
);
// 统计"任意技师"购物车项目数量
const anyTechnicianCount = countAnyTechnicianBlockedSlots(startTime);
// 剩余可用名额 = 未被特定占用的员工数 - "任意技师"项目数
const remainingAvailable = unblockedEmployees.length - anyTechnicianCount;
if (remainingAvailable > 0) {
availableTimes.push(startTime);
}
}
availableSlots = availableTimes.map(t => ({ start_time: t }));
| 条件 | 值 |
|---|---|
| 店铺员工 | 只有 Test Admin 一人 |
| 购物车项目 | Test Admin 在 9:00-10:00 (60分钟) |
| 新服务选择 | "任意技师",时长 60 分钟 |
| 条件 | 值 |
|---|---|
| 店铺员工 | 只有 Test Admin 一人 |
| 购物车项目 | "任意技师" 在 9:00-10:00 (60分钟) |
| 新服务选择 | "任意技师",时长 60 分钟 |
| 条件 | 值 |
|---|---|
| 店铺员工 | Alice, Bob, Carol(3人) |
| 购物车项目 1 | Alice 在 9:00-10:00 (特定技师) |
| 购物车项目 2 | "任意技师" 在 9:00-10:00 |
| 新服务选择 | "任意技师",时长 60 分钟 |
excluded_slots = [
{ employee_id: "alice-id", start_time: "09:00", duration: 60 },
{ employee_id: null, start_time: "09:00", duration: 60 }
]
| 层级 | 文件路径 | 说明 |
|---|---|---|
| UI | guest_mobile_app/.../select_time_page.dart |
时间选择页面,构建 excludedSlots |
| Event | guest_mobile_app/.../booking_event.dart |
LoadTimeSlots 事件定义 |
| BLoC | guest_mobile_app/.../booking_bloc.dart |
_onLoadTimeSlots 处理器 |
| UseCase | guest_mobile_app/.../get_available_slots.dart |
GetAvailableSlots 用例 |
| Repository Interface | guest_mobile_app/.../booking_repository.dart |
ExcludedSlot 类定义 |
| Repository Impl | guest_mobile_app/.../booking_repository_impl.dart |
Repository 实现 |
| API Client | guest_mobile_app/.../api_client.dart |
getAvailableSlots 方法 |
| Backend | backend/api/public/booking.js:303 |
/slots 端点实现 |
Web 端预约系统使用相同的后端 API,前端实现位于 frontend/web_app/ 目录。
位置: src/hooks/api/useBookingApi.ts
// ExcludedSlot 类型定义
export interface ExcludedSlot {
employee_id: string;
start_time: string; // HH:MM 格式
duration: number; // 分钟
}
// useTimeSlots hook 支持 excludedSlots 参数
export function useTimeSlots(
storeId: string,
serviceId: string,
date: string,
options?: {
employeeId?: string | null;
excludedSlots?: ExcludedSlot[];
}
) {
const { employeeId, excludedSlots } = options || {};
const queryParams = new URLSearchParams({
store_id: storeId,
service_id: serviceId,
date: date,
});
if (employeeId) {
queryParams.append('employee_id', employeeId);
}
if (excludedSlots && excludedSlots.length > 0) {
queryParams.append('excluded_slots', JSON.stringify(excludedSlots));
}
return useQuery({
queryKey: ['slots', storeId, serviceId, date, employeeId, excludedSlots],
queryFn: () => fetchWithRetry(`${API_BASE_URL}/api/public/booking/slots?${queryParams}`),
staleTime: 2 * 60 * 1000,
enabled: !!storeId && !!serviceId && !!date,
});
}
// 基本用法(不带 excludedSlots)
const { data } = useTimeSlots(storeId, serviceId, date);
// 带 excludedSlots 参数(用于多服务预约场景)
const { data } = useTimeSlots(storeId, serviceId, date, {
employeeId: null, // "任意技师"模式
excludedSlots: [
{ employee_id: 'emp-123', start_time: '09:00', duration: 60 },
{ employee_id: 'emp-456', start_time: '10:30', duration: 45 },
],
});
| 文件路径 | 说明 |
|---|---|
src/hooks/api/useBookingApi.ts |
React Query hooks,包含 useTimeSlots |
src/stores/booking-store.ts |
Zustand store,管理预约流程状态 |
src/app/[locale]/[tenant]/booking/[store]/time/page.tsx |
时间选择页面 |
employee_id: null 的"任意技师"项目)可用名额 = 未被特定占用的员工数 - "任意技师"购物车项目数
如果 可用名额 > 0,该时间点可选。
| 场景 | 计算 | 结果 |
|---|---|---|
| 1 员工,0 购物车项目 | 1 - 0 = 1 | ✓ 可选 |
| 1 员工,1 "任意技师"项目 | 1 - 1 = 0 | ✗ 不可选 |
| 2 员工,1 特定 + 1 "任意技师" | (2 - 1) - 1 = 0 | ✗ 不可选 |
| 3 员工,1 特定 + 1 "任意技师" | (3 - 1) - 1 = 1 | ✓ 可选 |