status: proposed created: 2026-09-03 complexity: 🟡中等 需求依据:
forge-server/forge-business/forge-hotel/需求说明书.htmlv1.0 → 4.1 顾客端「营业时间提示」(P1) 缺口台账:forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md5.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 |
❌ 无营业时间校验,非营业时段可正常下单 |
后果:后台修改营业时段对顾客端零影响;顾客在任何时间都能下单,餐厅非营业时段收到订单无人处理。
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——后者会让管理端的营业时段查询接口一并失去鉴权与操作日志。
HotelBusinessHoursMapper.xml 已有 selectEnabled(L29-37,返回该租户全部 status='ENABLED' 且 del_flag=0 的时段,含所有星期)。计算「当天时段」与「下次营业时段」所需数据全部可由这一次查询在 Java 层推导,无需新增 Mapper 方法与 XML,符合 AGENTS.md 5.1「SQL 写在 Mapper XML」且避免 SQL 面扩散。
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_week:0=周日, 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 默认时区,本变更不引入时区配置。营业时间校验放在 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 层加校验当前零收益(没有任何其它路径创建订单),却会引入 HotelOrderServiceImpl → HotelBusinessHoursService 的 Service 间依赖。放在开放接口 Controller 既符合 AGENTS.md 5.6「跨 Service 协调逻辑上提到 Controller 层」,又保持零依赖。
若未来新增「前台代客下单」入口,需先明确该入口是否受营业时间约束:受约束则把校验下沉到 Service;不受约束(电话订餐等运营例外)则维持现状。这是一次可逆决策,不是单向门。
已与业务方确认:非营业时段顾客不能浏览菜单,入口即拦。
| 页面 | 拦截形态 | 复用的既有 UI 模式 |
|---|---|---|
room-confirm.vue |
整页拦截,不渲染房间卡片与「开始点餐」按钮,展示非营业原因 + 下次营业时段 + 「重新检查」 | 现有 rc-error-page(L9-38,已用于「二维码未绑定」「房间未入住」) |
menu.vue |
保留送达信息栏与分类栏,菜品列表区替换为非营业占位,加购入口随之消失 | 现有 m-empty 空态(L52-55) |
order-confirm.vue |
提交前二次校验,uni.showModal 阻断 |
— |
选择整页拦截而非「提示条 + 允许浏览」的理由:
v-else-if 分支;「允许浏览但禁用加购」需在菜品卡片、快速加减、详情页、购物车四处分别处理禁用态menu.vue 必须独立校验,不能只靠 room-confirm 拦截:HotelTabBar 用 uni.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(全天营业,放行)。
本变更不新增字典,理由(供 review 核对 AGENTS.md 5.7):
hotel_meal_type、hotel_business_hours_status 已在 V1.0.103 建好,餐段名 meal_name 直接取库中值展示,不做前端映射open 是布尔判定结果,非业务枚举nextDateText(今天/明天/周三)属日期本地化文案,由后端统一生成,不是可配置业务枚举,不入 sys_dict_data配置早餐 07:00-10:00、午餐 11:00-14:00 时,10:30 属空档期,视为非营业,提示「午餐 11:00 开始」。
selectCurrentBusinessHours 的 SQL 判定完全一致(区间左闭右开、逐段匹配),Java 层不另写一套「当天最早~最晚」的宽松判定,避免两端打架核对中发现 HotelOrderController 整个类无任何 @SaCheckPermission,仅有 @OperationLog。任何登录用户均可调用接单/拒单/备餐/出餐/配送/完成/退单/审核/手动退款共 15 个端点。属独立权限缺口,已记入 酒店模块需求缺口清单.md,不得夹带进本变更。
BusinessHoursStatusVO(hotel/vo/),承载营业状态与下次营业信息,内部静态类 Period 表达单个时段(沿用 HotelOrderVO.OrderItemVO 的内部类先例)HotelBusinessHoursService 新增 BusinessHoursStatusVO currentStatus(),实现类基于 selectEnabled 一次查询 + Java 层推导,复用 checkCurrentBusinessHours() 判断当前是否营业currentStatus() 在无启用时段时返回 configured=false、open=true、todayText="全天营业"(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/** 已在白名单)API 与 Store
api/index.js 新增 hotelBusinessHours(tenantId),置于「酒店顾客端」区块 hotelAbandonOrder 之后,带 skipAuthRefresh: truestore/modules/hotel-order.js 新增 businessHours: null state(存后端 VO 原样)+ setBusinessHours(v) action;不加入 persist.pick(L146)—— 营业状态是时效数据,持久化会让下次打开时用旧值误拦或误放;内存态已足够跨 redirectTo / reLaunch 存活,且每页 onMounted 都会重新拉取isOpenNow:businessHours 为 null(未拉取)→ true(不拦,由各页自己的拉取结果决定);configured === false → true(全天营业);否则 open !== falsebusinessHoursText(当天时段文案,兜底「营业时间以门店公告为准」)、businessHoursTip(nextText,兜底 '')room-confirm.vue(L1 入口拦截)
const businessHoursText = ref('10:00 - 22:00'),改为 computed(() => orderStore.businessHoursText);import 补 computedconst closedInfo = ref(null),承载非营业整页拦截数据({ nextText })loadRoomInfo() 中在 orderStore.setTenantId(data.tenantId)(L171)与 setRoomInfo(L172)之后、联系人回填(L174)之前 await 拉取营业状态(不再静默失败):
configured !== false && open === false → closedInfo.value = { nextText },loading=false,return(不渲染房间卡片)businessHoursTexterrorMsg.value = '营业状态获取失败,请点击重试',loading=false,return(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=true;finally hoursLoading=falseonMounted(L152-155)追加 loadBusinessHours(),与 loadCategories() / loadDishes() 并列(reLaunch 每次重建页面,onMounted 已足够,不引入 onShow)scroll-view(L47-95)内新增分支:hoursLoading → 复用 m-loading;!isOpenNow || hoursError → 非营业占位(复用 m-empty 样式,图标改 🕐,文案「当前不在营业时间」+ businessTip;hoursError 时改为「营业状态获取失败」+ 重试按钮);否则渲染原菜品列表HotelTabBar 保持渲染,不改动goToDetail、cart.vue 不改动(非营业时列表不渲染,加购入口自然消失)order-confirm.vue(L3 提交阻断)
submitOrder()(L119-168)在手机号校验(L124-127)之后、if (submitting.value) return(L128)之前插入:刷新营业状态(try/catch,失败仅 console.log 不 return)→ 若 configured !== false && open === false 则 uni.showModal({ title: '暂不可下单', content: nextText ? '当前不在营业时间,' + nextText : '当前不在营业时间,请稍后再试', showCancel: false }) 并 return(此时尚未置 submitting=true,无需复位)BusinessException 消息已由 L163-164 的 catch + uni.showToast(err?.message) 展示,无需额外分支orderData 结构(L132-151)不改动——规格/加料 ID 缺失属缺口清单 3.1 资金缺陷,另行提案->、无 .stream()、无 :: 方法引用、无 switch 表达式 case X ->、无 @PathVariable、无 LambdaQueryWrapper;排序用 Collections.sort + 匿名 Comparator@Autowired 字段注入,字段不加 final,不使用 @RequiredArgsConstructortenantId 用 @RequestParam 单独接收(属既有安全上下文参数例外,非查询筛选条件,不违反「查询参数用实体类接收」规范)rc-error-page / rc-error-card / m-empty / m-loading),确需新增时只取 --h-pri / --h-acc-lt 等既有主题变量,不引入新色值后端(forge-hotel)
vo/BusinessHoursStatusVO.javaservice/HotelBusinessHoursService.java、service/impl/HotelBusinessHoursServiceImpl.javacontroller/open/HotelCustomerController.java前端(forge-h5-ui)
src/api/index.js、src/store/modules/hotel-order.jssrc/pages/hotel/customer/room-confirm.vue、menu.vue、order-confirm.vue不受影响
SaTokenConfig:无变更businessHours.vue / api/hotel.js / order.vue:无变更HotelOrderServiceImpl 与 HotelOrderController:无变更(校验不落 Service 层;后者无 @SaCheckPermission 的权限缺口另案处理,见 2.7)cart.vue / dish-detail.vue:无变更(非营业时菜品列表不渲染,进不了详情页;购物车仅可查看,结算由 L3 拦截)POST /hotel/order:不施加营业时间限制(无调用方,见 2.3)| 决策 | 说明 |
|---|---|
新增开放接口而非给 check-current 加 @SaIgnore |
@SaIgnore 会让管理端营业时段查询一并失去鉴权与操作日志;新接口挂在已白名单的 /hotel/open/** 下,零配置改动 |
| 入口整页拦截(fail fast) | 已与业务方确认「只有营业时间内才可以点餐」且不给浏览菜单。避免客人挑完菜才在提交时被打断;与「房间未入住」共用 rc-error-page UI,认知成本为零 |
| menu 页必须独立校验 | HotelTabBar 用 uni.reLaunch,客人可从 orders / order-status 直达菜单页,绕过 room-confirm |
| 空档期视为非营业 | 已确认。与 SQL 逐段匹配语义一致,不在 Java 层另写宽松判定;多时段配置粒度因此有意义 |
| 校验落开放接口 Controller | 见 2.3;PC hide-add、POST /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),不在本变更修复 |
room-confirm 整页拦截 —— 不渲染房间卡片、不渲染「开始点餐」按钮,展示「当前不在营业时间」+ 下次营业文案 + 「重新检查」按钮orders / order-status 直达 menu:菜品列表区显示非营业占位(🕐 + 下次营业文案),不渲染任何菜品卡片,加购与详情入口不可达;送达信息栏 / 分类栏 / TabBar 仍正常渲染room-confirm 整页错误 + 「重新检查」,menu 显示「营业状态获取失败」+ 重试按钮,两端均不放行menu 并加购,停留至打烊后在 order-confirm 点提交:弹出阻断弹窗并展示下次营业时段,不发起下单请求closedInfo 先被重置,跨入营业时段后页面恢复为正常房间卡片,无需重新扫码POST /hotel/open/customer/orderCreate:返回 BusinessException,消息含下次营业时段,订单未落库dayOfWeek IS NULL(每天)的时段参与当天判定与下次推导nextDateText 为「明天」或对应周名room-confirm.vue 中 '10:00 - 22:00' 硬编码零残留(全仓检索无命中)->(日志字符串除外)、无 .stream()、无 ::、无 case X ->、无 @PathVariable、无 LambdaQueryWrapper@Autowired 字段注入cd forge && mvn clean install -DskipTests 编译通过cd forge-h5-ui && pnpm build 构建通过git diff --check 无空白错误酒店模块需求缺口清单.md 5.1 状态由 ❌ 改为 ✅,4.1「营业时间提示」行同步,并在第九节补齐顺序中标注已关闭酒店模块迁移跟踪.md 3.x 营业时段小节补记新增的开放接口与 VO;模块状态总览表 business-hours 行 H5 列由 — 改为 ✅| # | 事项 | 结论 |
|---|---|---|
| 1 | 非营业时段是否允许顾客浏览菜单 | 不允许。入口整页拦截、菜单页不渲染菜品,详见 2.4 |
| 2 | 多时段之间的空档期是否算营业 | 不算营业,视为非营业并提示下一段开始时间,详见 2.6 |
| 3 | 是否需要同步实现缺口 5.4「临时暂停接单」 | 本变更不做。5.4 需新增开关字段与 Flyway,且语义独立(人为暂停 vs 时段外),合并会让 fail-open 判定复杂化;后续单独提案时可复用本变更的拦截页与阻断链路 |
| # | 事项 | 默认方案 |
|---|---|---|
| 1 | 非营业拦截页是否需要「联系前台」入口 | 不加。rc-error-footer 的既有文案与弹窗(L36、L209-213)是「房间信息有误」语义,直接复用会误导客人;若业务方要求,需另配一套文案而非复用 handleWrongRoom |
| 2 | 拦截是否需要覆盖 cart.vue |
不覆盖。非营业时菜品列表不渲染,cart 仅能查看营业中已加购的商品,结算由 L3 阻断;见 2.4 |
| 3 | 「重新检查」是否需要自动轮询(到点自动放行) | 不做。客人手动点一次即可,自动轮询会在非营业时段产生持续请求;后端仍是最终边界 |
待 /apply 后回填。