spec.md 23 KB

营业时间校验接入顾客端

status: proposed created: 2026-09-03 complexity: 🟡中等 需求依据:forge-server/forge-business/forge-hotel/需求说明书.html v1.0 → 4.1 顾客端「营业时间提示」(P1) 缺口台账:forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md 5.1

1. 背景与目标

1.1 现状缺陷

后端营业时段能力已完备但顾客端完全未接入,且 H5 当前向顾客展示的是硬编码的假营业时间

环节 位置 现状
后端判断 HotelBusinessHoursServiceImpl.checkCurrentBusinessHours() L49-57 ✅ 已实现,按 dayOfWeek + HH:mm 字符串区间匹配
后端接口 GET /hotel/business-hours/check-current L62-66 ✅ 已存在,但需登录
PC 封装 forge-admin-ui/src/api/hotel.js L305-307 checkCurrentBusinessHours() ✅ 已封装
H5 封装 forge-h5-ui/src/api/index.js 无任何营业时段接口
H5 展示 room-confirm.vue L107 businessHoursText = ref('10:00 - 22:00') 🔴 硬编码,且声明后从未被赋值更新,L62 直接渲染给顾客
H5 拦截 order-confirm.vue submitOrder() L119-168 ❌ 无营业时间校验
后端拦截 HotelCustomerController.orderCreate() L120-131 ❌ 无营业时间校验,非营业时段可正常下单

后果:后台修改营业时段对顾客端零影响;顾客在任何时间都能下单,餐厅非营业时段收到订单无人处理。

1.2 鉴权约束(决定方案形态)

GET /hotel/business-hours/check-current 不在 Sa-Token 白名单内,顾客端免登录调不通:

  • SaTokenConfig L79 .notMatch("/hotel/open/**")、L104 .excludePathPatterns("/hotel/open/**")
  • 白名单只有 /hotel/open/**/hotel/notification/subscribe/hotel/notification/remote/**
  • HotelBusinessHoursController 全部端点带 @OperationLog@SaIgnore

因此必须新增 /hotel/open/customer/businessHours 开放接口,而不是给 check-current@SaIgnore——后者会让管理端的营业时段查询接口一并失去鉴权与操作日志。

1.3 目标

  • 顾客端展示的营业时间来自数据库真实配置,硬编码清零
  • 非营业时段顾客端完全不可点餐:房号确认页整页拦截、菜单页不展示菜品,顾客无法进入点餐流程
  • 明确告知下次营业时段,并提供「重新检查」入口,避免客人到点后被迫重新扫码
  • 营业中进入、停留至打烊才提交的场景:提交时二次校验 + 后端异常双重阻断
  • 未配置任何启用时段的租户:fail-open,显示「全天营业」且不拦截,避免配置缺失把点餐全关死
  • 不新增数据库表/字段/字典,不修改 Sa-Token 配置

2. 研究结论

2.1 可复用的既有能力(不新增 SQL)

HotelBusinessHoursMapper.xml 已有 selectEnabled(L29-37,返回该租户全部 status='ENABLED'del_flag=0 的时段,含所有星期)。计算「当天时段」与「下次营业时段」所需数据全部可由这一次查询在 Java 层推导,无需新增 Mapper 方法与 XML,符合 AGENTS.md 5.1「SQL 写在 Mapper XML」且避免 SQL 面扩散。

2.2 时段匹配语义(新代码必须与之一致)

selectCurrentBusinessHours(L40-52)的判定规则:

AND status = 'ENABLED' AND del_flag = 0
AND (day_of_week = #{dayOfWeek} OR day_of_week IS NULL)
AND #{currentTime} >= start_time AND #{currentTime} < end_time
ORDER BY sort_order ASC LIMIT 1
  • day_of_week0=周日, 1=周一, …, 6=周六NULL 表示每天(Java 侧取值 LocalDate.now().getDayOfWeek().getValue() % 7
  • 时间比较是 HH:mm 字符串字典序比较,仅对零填充的 24 小时制成立;左闭右开>= start< end
  • ⚠️ 已知限制:跨零点时段(如 22:00-02:00)用字符串比较永不命中。当前 PC 编辑表单的开始/结束时间限定为 09:00~23:30 每 30 分钟一档的下拉,无法配置跨天时段,故该限制当前不可达。本变更不修复此限制,但 currentStatus() 的下次营业推导必须沿用同一语义(同样不支持跨零点),避免前后端判断打架。若后续放开跨天配置,须同时改 SQL 与本推导逻辑。
  • 时区:沿用现有 LocalDate.now() / LocalTime.now() 的 JVM 默认时区,本变更不引入时区配置。

2.3 校验落点选择:开放接口 Controller

营业时间校验放在 HotelCustomerController.orderCreate()不放进 HotelOrderServiceImpl.orderCreate()。实地核对后的依据:

事实 证据
PC 管理端无代客下单入口 views/hotel/order.vue L11 :hide-add="true"api-config(L5-6)只有 list,无 add
POST /hotel/order无调用方的遗留端点 HotelOrderController.orderCreate() L66-73 注释写「客户端下单」,但客户端实际走 /hotel/open/customer/orderCreate,PC 又 hide-add,全仓无调用方
开放接口是唯一真实下单入口 在此校验即可覆盖全部实际业务流量

结论:在 Service 层加校验当前零收益(没有任何其它路径创建订单),却会引入 HotelOrderServiceImplHotelBusinessHoursService 的 Service 间依赖。放在开放接口 Controller 既符合 AGENTS.md 5.6「跨 Service 协调逻辑上提到 Controller 层」,又保持零依赖。

若未来新增「前台代客下单」入口,需先明确该入口是否受营业时间约束:受约束则把校验下沉到 Service;不受约束(电话订餐等运营例外)则维持现状。这是一次可逆决策,不是单向门。

2.4 拦截形态:入口整页拦截(fail fast)

已与业务方确认:非营业时段顾客不能浏览菜单,入口即拦。

页面 拦截形态 复用的既有 UI 模式
room-confirm.vue 整页拦截,不渲染房间卡片与「开始点餐」按钮,展示非营业原因 + 下次营业时段 + 「重新检查」 现有 rc-error-page(L9-38,已用于「二维码未绑定」「房间未入住」)
menu.vue 保留送达信息栏与分类栏,菜品列表区替换为非营业占位,加购入口随之消失 现有 m-empty 空态(L52-55)
order-confirm.vue 提交前二次校验,uni.showModal 阻断

选择整页拦截而非「提示条 + 允许浏览」的理由:

  • fail fast:避免客人挑完 5 分钟菜、加满购物车,才在提交时被告知不能下单
  • 视觉一致:与「房间未入住」同为「当前无法点餐」语义,共用一套错误页 UI,客人认知成本为零
  • 实现更省:整页拦截只需一个 v-else-if 分支;「允许浏览但禁用加购」需在菜品卡片、快速加减、详情页、购物车四处分别处理禁用态

menu.vue 必须独立校验,不能只靠 room-confirm 拦截:HotelTabBaruni.reLaunch 导航,客人可从 orders / order-status 页经 TabBar 直达菜单页,绕过房号确认。

cart.vue 不在本变更范围:非营业时购物车浮层仍可跳转查看已加购商品(营业中加的),结算进入 order-confirm 后由 L3 拦截。在 cart 页再加一层拦截收益低且扩大改动面。

营业状态接口调用失败(网络异常/5xx)时,room-confirm 走整页错误 + 「重新检查」,不放行menu 显示占位。UI 层 fail-closed 成本低(客人点一次重试即可),后端仍是最终边界。

注意区分两种「不营业」:接口调用失败 → UI fail-closed(拦住 + 重试);接口成功但 configured=false(无启用时段配置) → fail-open(全天营业,放行)。

2.5 字典合规判断

本变更不新增字典,理由(供 review 核对 AGENTS.md 5.7):

  • hotel_meal_typehotel_business_hours_status 已在 V1.0.103 建好,餐段名 meal_name 直接取库中值展示,不做前端映射
  • open 是布尔判定结果,非业务枚举
  • nextDateText(今天/明天/周三)属日期本地化文案,由后端统一生成,不是可配置业务枚举,不入 sys_dict_data

2.6 多时段空档期语义(已确认)

配置早餐 07:00-10:00、午餐 11:00-14:00 时,10:30空档期,视为非营业,提示「午餐 11:00 开始」。

  • selectCurrentBusinessHours 的 SQL 判定完全一致(区间左闭右开、逐段匹配),Java 层不另写一套「当天最早~最晚」的宽松判定,避免两端打架
  • 多时段配置的粒度因此有意义;若酒店实际空档期也接单,应在后台补配一个覆盖空档的时段,而不是放宽系统判定

2.7 遗留端点权限缺口(本变更不修)

核对中发现 HotelOrderController 整个类无任何 @SaCheckPermission,仅有 @OperationLog。任何登录用户均可调用接单/拒单/备餐/出餐/配送/完成/退单/审核/手动退款共 15 个端点。属独立权限缺口,已记入 酒店模块需求缺口清单.md不得夹带进本变更

3. 功能要求

3.1 后端

  • 新增 BusinessHoursStatusVOhotel/vo/),承载营业状态与下次营业信息,内部静态类 Period 表达单个时段(沿用 HotelOrderVO.OrderItemVO 的内部类先例)
  • HotelBusinessHoursService 新增 BusinessHoursStatusVO currentStatus(),实现类基于 selectEnabled 一次查询 + Java 层推导,复用 checkCurrentBusinessHours() 判断当前是否营业
  • currentStatus()无启用时段时返回 configured=falseopen=truetodayText="全天营业"(fail-open)
  • 下次营业推导:先找当天 startTime > currentTime 的最早时段;找不到则按 dayOffset 1..7 逐日找最早匹配时段
  • HotelCustomerController 新增 GET /hotel/open/customer/businessHours?tenantId=xxx,用 TenantContextHolder.executeWithTenant 包裹,不加 @OperationLog(与该 Controller 其它免登录端点一致)
  • HotelCustomerController.orderCreate() 在调用 orderService.orderCreate(dto) 之前校验:configured && !open 时抛 BusinessException,消息含下次营业文案
  • 不修改 SaTokenConfig/hotel/open/** 已在白名单)
  • 不新增 Flyway 脚本(无表结构与字典变更)

3.2 H5 顾客端

API 与 Store

  • api/index.js 新增 hotelBusinessHours(tenantId),置于「酒店顾客端」区块 hotelAbandonOrder 之后,带 skipAuthRefresh: true
  • store/modules/hotel-order.js 新增 businessHours: null state(存后端 VO 原样)+ setBusinessHours(v) action;不加入 persist.pick(L146)—— 营业状态是时效数据,持久化会让下次打开时用旧值误拦或误放;内存态已足够跨 redirectTo / reLaunch 存活,且每页 onMounted 都会重新拉取
  • getter isOpenNowbusinessHoursnull(未拉取)→ true(不拦,由各页自己的拉取结果决定);configured === falsetrue(全天营业);否则 open !== false
  • getter businessHoursText(当天时段文案,兜底「营业时间以门店公告为准」)、businessHoursTipnextText,兜底 ''

room-confirm.vue(L1 入口拦截)

  • 删除 L107 硬编码 const businessHoursText = ref('10:00 - 22:00'),改为 computed(() => orderStore.businessHoursText)importcomputed
  • 新增 const closedInfo = ref(null),承载非营业整页拦截数据({ nextText }
  • loadRoomInfo() 中在 orderStore.setTenantId(data.tenantId)(L171)与 setRoomInfo(L172)之后、联系人回填(L174)之前 await 拉取营业状态(不再静默失败):
    • 成功且 configured !== false && open === falseclosedInfo.value = { nextText }loading=falsereturn(不渲染房间卡片)
    • 成功且营业中 → 写入 store,正常渲染,L60-63 营业时间行显示 businessHoursText
    • 调用失败(网络/5xx)→ errorMsg.value = '营业状态获取失败,请点击重试'loading=falsereturn(fail-closed)
  • 模板在 v-else-if="errorMsg"(L10)与 v-else(L41)之间插入 v-else-if="closedInfo" 分支,复用 rc-error-page 全套样式类:标题「当前不在营业时间」,正文展示 closedInfo.nextText(为空时只显示「请稍后再试」),按钮文案「重新检查」绑定 retry()
  • retry()(L200-202)需先重置 closedInfo.value = null 再调 loadRoomInfo()
  • deliveryTime / deliveryFee 硬编码(L108-109、L182-188)不动,属缺口清单 5.3 hotelconfig 模块

menu.vue(L2 防绕过)

  • 新增 const hoursLoading = ref(false)const hoursError = ref(false)const isOpenNow = computed(() => orderStore.isOpenNow)const businessTip = computed(() => orderStore.businessHoursTip)
  • 新增 loadBusinessHours()orderStore.tenantId 为空则直接 return;hoursLoading=true → 调接口 → 成功写 store、hoursError=false;失败 hoursError=truefinally hoursLoading=false
  • onMounted(L152-155)追加 loadBusinessHours(),与 loadCategories() / loadDishes() 并列(reLaunch 每次重建页面,onMounted 已足够,不引入 onShow
  • 菜品列表 scroll-view(L47-95)内新增分支:hoursLoading → 复用 m-loading!isOpenNow || hoursError非营业占位(复用 m-empty 样式,图标改 🕐,文案「当前不在营业时间」+ businessTiphoursError 时改为「营业状态获取失败」+ 重试按钮);否则渲染原菜品列表
  • 送达信息栏(L4-8)、分类栏、购物车浮层、HotelTabBar 保持渲染,不改动
  • 菜品卡片、快速加减、goToDetailcart.vue 不改动(非营业时列表不渲染,加购入口自然消失)

order-confirm.vue(L3 提交阻断)

  • submitOrder()(L119-168)在手机号校验(L124-127)之后、if (submitting.value) return(L128)之前插入:刷新营业状态(try/catch,失败仅 console.log 不 return)→ 若 configured !== false && open === falseuni.showModal({ title: '暂不可下单', content: nextText ? '当前不在营业时间,' + nextText : '当前不在营业时间,请稍后再试', showCancel: false })return(此时尚未置 submitting=true,无需复位)
  • 后端 BusinessException 消息已由 L163-164 的 catch + uni.showToast(err?.message) 展示,无需额外分支
  • orderData 结构(L132-151)不改动——规格/加料 ID 缺失属缺口清单 3.1 资金缺陷,另行提案

3.3 编码风格约束(forge-hotel 模块专属)

  • 新增 Java 代码为 JDK 7 风格: lambda -> .stream() :: 方法引用、 switch 表达式 case X -> @PathVariable LambdaQueryWrapper;排序用 Collections.sort + 匿名 Comparator
  • 依赖注入用 @Autowired 字段注入,字段不加 final,不使用 @RequiredArgsConstructor
  • 开放接口的 tenantId@RequestParam 单独接收(属既有安全上下文参数例外,非查询筛选条件,不违反「查询参数用实体类接收」规范)
  • H5 拦截页与非营业占位优先复用既有样式类rc-error-page / rc-error-card / m-empty / m-loading),确需新增时只取 --h-pri / --h-acc-lt 等既有主题变量,不引入新色值

4. 影响范围

后端(forge-hotel)

  • Create: vo/BusinessHoursStatusVO.java
  • Modify: service/HotelBusinessHoursService.javaservice/impl/HotelBusinessHoursServiceImpl.java
  • Modify: controller/open/HotelCustomerController.java

前端(forge-h5-ui)

  • Modify: src/api/index.jssrc/store/modules/hotel-order.js
  • Modify: src/pages/hotel/customer/room-confirm.vuemenu.vueorder-confirm.vue

不受影响

  • 数据库结构与字典:无变更,无 Flyway 脚本
  • SaTokenConfig:无变更
  • PC 管理端 businessHours.vue / api/hotel.js / order.vue:无变更
  • HotelOrderServiceImplHotelOrderController:无变更(校验不落 Service 层;后者无 @SaCheckPermission 的权限缺口另案处理,见 2.7)
  • cart.vue / dish-detail.vue:无变更(非营业时菜品列表不渲染,进不了详情页;购物车仅可查看,结算由 L3 拦截)
  • 管理端下单入口 POST /hotel/order不施加营业时间限制(无调用方,见 2.3)

5. 技术决策

决策 说明
新增开放接口而非给 check-current@SaIgnore @SaIgnore 会让管理端营业时段查询一并失去鉴权与操作日志;新接口挂在已白名单的 /hotel/open/** 下,零配置改动
入口整页拦截(fail fast) 已与业务方确认「只有营业时间内才可以点餐」且不给浏览菜单。避免客人挑完菜才在提交时被打断;与「房间未入住」共用 rc-error-page UI,认知成本为零
menu 页必须独立校验 HotelTabBaruni.reLaunch,客人可从 orders / order-status 直达菜单页,绕过 room-confirm
空档期视为非营业 已确认。与 SQL 逐段匹配语义一致,不在 Java 层另写宽松判定;多时段配置粒度因此有意义
校验落开放接口 Controller 见 2.3;PC hide-addPOST /hotel/order 无调用方,开放接口是唯一真实下单入口;Service 层校验零收益却引入 Service 间依赖
无配置时后端 fail-open 未配置启用时段视为「全天营业」,不拦截。宁可少拦不可错拦——错拦会导致全店无法点餐
接口调用失败时 UI fail-closed 与上一条不矛盾:configured=false 是业务上的「不限制」,而调用失败是状态未知。入口页拦住 + 「重新检查」成本低,后端仍是最终边界
order-confirm 刷新失败不阻断 该页客人已通过入口校验,网络抖动不应让其无法下单,以后端 BusinessException 为最终边界
营业状态不持久化 persist.pick(L146)只放需要跨会话恢复的数据(购物车 / 房间 / 联系人)。营业状态逐分钟变化,持久化等于用旧值做拦截判断,属反向优化
下次营业文案后端生成 dayOfWeek 数字与 NULL=每天 的映射是服务端知识,前端只渲染 nextText,避免两端各写一份星期逻辑
复用 selectEnabled,不新增 SQL 一次查询即可推导当天时段与未来 7 天最近时段,避免 Mapper 面扩散
沿用字符串时间比较语义 与既有 selectCurrentBusinessHours 保持一致;跨零点限制显式记录为已知问题(见 2.2),不在本变更修复

6. 验收标准

6.1 功能

  • 租户未配置任何启用时段:H5 房号确认页显示「全天营业」,无拦截,按钮为「开始点餐」,可正常下单
  • 租户已配置且当前在时段内:显示当天真实时段文案(如「早餐 07:00-10:00 · 午餐 11:00-14:00」),无拦截,可正常下单与加购
  • 租户已配置且当前不在时段内room-confirm 整页拦截 —— 不渲染房间卡片、不渲染「开始点餐」按钮,展示「当前不在营业时间」+ 下次营业文案 + 「重新检查」按钮
  • 非营业时经 TabBar 从 orders / order-status 直达 menu:菜品列表区显示非营业占位(🕐 + 下次营业文案),不渲染任何菜品卡片,加购与详情入口不可达;送达信息栏 / 分类栏 / TabBar 仍正常渲染
  • 营业状态接口调用失败(断网 / 5xx):room-confirm 整页错误 + 「重新检查」,menu 显示「营业状态获取失败」+ 重试按钮,两端均不放行
  • 营业中进入 menu 并加购,停留至打烊后order-confirm 点提交:弹出阻断弹窗并展示下次营业时段,不发起下单请求
  • 点击「重新检查」:closedInfo 先被重置,跨入营业时段后页面恢复为正常房间卡片,无需重新扫码
  • 绕过前端直连 POST /hotel/open/customer/orderCreate:返回 BusinessException,消息含下次营业时段,订单未落库
  • 后台修改营业时段后,顾客端重新扫码即生效,无需重启服务
  • dayOfWeek IS NULL(每天)的时段参与当天判定与下次推导
  • 跨天场景:当天剩余时段为空时,下次营业正确落到次日/后续星期的最早时段,nextDateText 为「明天」或对应周名

6.2 代码质量

  • room-confirm.vue'10:00 - 22:00' 硬编码零残留(全仓检索无命中)
  • 新增 Java 代码通过 JDK 7 风格核查:无 ->(日志字符串除外)、无 .stream()、无 ::、无 case X ->、无 @PathVariable、无 LambdaQueryWrapper
  • 新增依赖注入均为 @Autowired 字段注入
  • cd forge && mvn clean install -DskipTests 编译通过
  • cd forge-h5-ui && pnpm build 构建通过
  • git diff --check 无空白错误

6.3 文档回填

  • 酒店模块需求缺口清单.md 5.1 状态由 ❌ 改为 ✅,4.1「营业时间提示」行同步,并在第九节补齐顺序中标注已关闭
  • 酒店模块迁移跟踪.md 3.x 营业时段小节补记新增的开放接口与 VO;模块状态总览表 business-hours 行 H5 列由 改为

7. 业务确认结论与遗留待确认项

7.1 已确认(2026-09-03,业务方确认)

# 事项 结论
1 非营业时段是否允许顾客浏览菜单 不允许。入口整页拦截、菜单页不渲染菜品,详见 2.4
2 多时段之间的空档期是否算营业 不算营业,视为非营业并提示下一段开始时间,详见 2.6
3 是否需要同步实现缺口 5.4「临时暂停接单」 本变更不做。5.4 需新增开关字段与 Flyway,且语义独立(人为暂停 vs 时段外),合并会让 fail-open 判定复杂化;后续单独提案时可复用本变更的拦截页与阻断链路

7.2 待确认(不阻塞开发,按默认方案执行)

# 事项 默认方案
1 非营业拦截页是否需要「联系前台」入口 不加rc-error-footer 的既有文案与弹窗(L36、L209-213)是「房间信息有误」语义,直接复用会误导客人;若业务方要求,需另配一套文案而非复用 handleWrongRoom
2 拦截是否需要覆盖 cart.vue 不覆盖。非营业时菜品列表不渲染,cart 仅能查看营业中已加购的商品,结算由 L3 阻断;见 2.4
3 「重新检查」是否需要自动轮询(到点自动放行) 不做。客人手动点一次即可,自动轮询会在非营业时段产生持续请求;后端仍是最终边界

8. 执行结论

/apply 后回填。