For agentic workers: inline execution in the current workspace is required; preserve unrelated user changes and do not commit or push automatically.
Goal: 让顾客端展示真实营业时间,并在非营业时段入口即整页拦截(不给浏览菜单),叠加提交前二次校验与后端异常,共三道防线。
Architecture: 后端新增一个免登录开放接口 GET /hotel/open/customer/businessHours,由 HotelBusinessHoursServiceImpl.currentStatus() 基于既有 selectEnabled 一次查询在 Java 层推导「当天时段 + 下次营业时段」,返回结构化 VO;下单校验落在 HotelCustomerController.orderCreate()(非 Service 层)。H5 侧以 Pinia store 为单一数据源(不持久化):room-confirm 入口整页拦截、menu 独立校验不渲染菜品(防 TabBar 绕过)、order-confirm 提交前刷新并阻断,后端异常兜底。
Tech Stack: Java 17(forge-hotel 模块强制 JDK 7 语法风格)、Spring Boot 3.2、MyBatis-Plus、Sa-Token;Vue 3 <script setup>、uni-app、Pinia。
关键前置约束(执行前必读)
SaTokenConfig 不需要修改——/hotel/open/** 已在 L79/L104 排除登录与 API 权限校验,新接口挂在该前缀下自动免登录。->、.stream()、::、switch 表达式、@PathVariable、LambdaQueryWrapper;排序用 Collections.sort + 匿名 Comparator,遍历用传统 for。@Autowired 字段注入,字段不加 final。hotel_meal_type、hotel_business_hours_status 已在 V1.0.103 建好)。HH:mm 字符串字典序、左闭右开,与 selectCurrentBusinessHours 一致;不支持跨零点时段,勿"顺手修复"。configured=false、open=true),严禁拦截。configured=false → fail-open(全天营业,放行);接口调用失败(断网 / 5xx)→ UI fail-closed(拦住 + 重试)。两者不得混写。rc-start-btn 文案改成「查看菜单」。Files:
Create: forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/BusinessHoursStatusVO.java
[ ] 类加 @Data,实现 Serializable 并声明 @Serial private static final long serialVersionUID = 1L;,风格对齐同目录 RoomCheckOutVO。
[ ] 字段:Boolean configured(该租户是否配置了启用时段)、Boolean open(当前是否在营业时段内)、String currentMealName、String currentEndTime、String todayText(当天时段汇总文案,无配置时为「全天营业」)、List<Period> todayPeriods、String nextMealName、String nextStartTime、Integer nextDayOffset(0=今天)、String nextDateText(今天/明天/周X)、String nextText(组装好的完整文案,前端直接渲染)。
[ ] 内部静态类 Period 加 @Data,字段 String mealName、String startTime、String endTime(沿用 HotelOrderVO.OrderItemVO 的内部类先例)。
[ ] 每个字段补中文 Javadoc 注释,密度对齐同目录既有 VO。
Files:
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelBusinessHoursService.javaModify: forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelBusinessHoursServiceImpl.java
[ ] 接口新增 BusinessHoursStatusVO currentStatus();,补 Javadoc 说明 fail-open 语义与「不支持跨零点时段」限制。
[ ] 实现类新增 private static final String[] WEEK_NAMES = { "周日", "周一", "周二", "周三", "周四", "周五", "周六" };(下标即 day_of_week 值)。
[ ] currentStatus() 实现步骤:
List<HotelBusinessHours> enabled = businessHoursMapper.selectEnabled(resolveTenantId());enabled 为空 → configured=false、open=true、todayText="全天营业"、todayPeriods 为空 ArrayList、nextText="",直接返回。configured=true;取 int today = java.time.LocalDate.now().getDayOfWeek().getValue() % 7; 与 String currentTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm"));(与 checkCurrentBusinessHours() L53-55 完全一致)。enabled,筛出 dayOfWeek == null || dayOfWeek == today 的项构建 todayPeriods,用 Collections.sort + 匿名 Comparator<Period> 按 startTime 升序;拼 todayText(mealName + " " + startTime + "-" + endTime,多段以 · 连接)。checkCurrentBusinessHours()(勿重写 SQL 判定),非 null 则 open=true 并回填 currentMealName / currentEndTime;null 则 open=false。open=true 时 nextText=""、nextDayOffset=null,不做下次推导。open=false 时推导下次营业:先在 todayPeriods 中找 startTime.compareTo(currentTime) > 0 的最早一段(dayOffset=0);未命中则 for (int offset = 1; offset <= 7; offset++) 计算 int dow = (today + offset) % 7;,在 enabled 中找 dayOfWeek == null || dayOfWeek == dow 且 startTime 最小的一段,命中即 break。nextDateText:offset==0 → "今天";offset==1 → "明天";否则 WEEK_NAMES[dow]。nextText 组装为 nextDateText + " " + nextStartTime + " 开始(" + nextMealName + ")"。nextText="",前端拦截页与菜单占位需容忍空文案,只显示「当前不在营业时间」(room-confirm 兜底「请稍后再试」,menu 直接不渲染该行)。[ ] 不引入任何 lambda / stream / 方法引用;不新增 Mapper 方法与 XML。
[ ] 抽取 private 辅助方法(如 buildTodayPeriods、resolveNextPeriod)保持 currentStatus() 可读,方法数不超过 3 个。
Files:
Modify: forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelCustomerController.java
[ ] 新增 @Autowired private HotelBusinessHoursService businessHoursService;(字段注入,不加 final),import 对应 Service 与 VO。
[ ] 新增「营业时段」分区注释,风格对齐文件内既有 // ==================== 菜品 ====================。
[ ] 新增端点:
@GetMapping("/businessHours")
public RespInfo<BusinessHoursStatusVO> businessHours(@RequestParam Long tenantId) {
BusinessHoursStatusVO result = TenantContextHolder.executeWithTenant(tenantId,
new java.util.function.Supplier<BusinessHoursStatusVO>() {
@Override
public BusinessHoursStatusVO get() {
return businessHoursService.currentStatus();
}
});
return RespInfo.success(result);
}
[ ] 不加 @OperationLog(与该 Controller 其它免登录端点一致,避免顾客高频访问污染操作日志)。
[ ] 不修改 SaTokenConfig;补 Javadoc 说明路径已在 /hotel/open/** 白名单内。
Files:
Modify: forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelCustomerController.java
[ ] 在 orderCreate()(L120-131)中,于 TenantContextHolder.executeWithTenant 的匿名 Supplier.get() 内、调用 orderService.orderCreate(dto) 之前插入校验:
BusinessHoursStatusVO hours = businessHoursService.currentStatus();
if (hours != null && Boolean.TRUE.equals(hours.getConfigured())
&& !Boolean.TRUE.equals(hours.getOpen())) {
String tip = hours.getNextText();
throw new BusinessException(StringUtils.hasText(tip)
? "当前不在营业时间," + tip + ",暂不可下单"
: "当前不在营业时间,暂不可下单");
}
return orderService.orderCreate(dto);
[ ] import com.mdframe.forge.starter.core.exception.BusinessException 与 org.springframework.util.StringUtils。
[ ] 校验必须在 executeWithTenant 内部执行,否则 resolveTenantId() 会回退到 1L 取错租户配置。
[ ] 不修改 HotelOrderServiceImpl;不影响 POST /hotel/order(管理端代客下单不受营业时间限制)。
[ ] 更新 orderCreate() 的 Javadoc,补一行营业时间校验说明。
cd forge; mvn clean install -DskipTests 通过。->、.stream()、::、case + ->、@PathVariable、LambdaQueryWrapper,除日志字符串外应无命中。forge-admin-server,用免登录请求验证三种配置场景:
configured=false, open=true, todayText="全天营业"open=true,todayPeriods 与 PC「营业时段」页配置一致open=false,nextText 正确(含跨天到次日的场景)POST /hotel/open/customer/orderCreate,确认返回业务异常且 hotel_order 无新记录。Files:
forge-h5-ui/src/api/index.jsModify: forge-h5-ui/src/store/modules/hotel-order.js
[ ] api/index.js 在「酒店顾客端(扫码点餐)」区块 hotelAbandonOrder 之后新增:
/** 顾客端 - 当前营业状态(免登录) */
hotelBusinessHours: tenantId => request({
url: '/hotel/open/customer/businessHours',
method: 'get',
params: { tenantId },
skipAuthRefresh: true,
}),
[ ] hotel-order.js state 新增 businessHours: null(注释说明结构来自后端 BusinessHoursStatusVO)。
[ ] getters 新增:
isOpenNow(state):if (!state.businessHours) return true(未拉取不拦,由各页自己的拉取结果决定,不等于 fail-open);if (state.businessHours.configured === false) return true(这才是 fail-open);return state.businessHours.open !== falsebusinessHoursText(state):返回 state.businessHours?.todayText || '营业时间以门店公告为准'businessHoursTip(state):返回 state.businessHours?.nextText || ''[ ] actions 新增 setBusinessHours(v) { this.businessHours = v }。
[ ] 不修改 persist.pick(L146)—— 营业状态是时效数据,持久化会让下次打开用旧值误拦 / 误放;内存态跨 redirectTo / reLaunch 已足够存活,且各页 onMounted 都会重新拉取。
Files:
Modify: forge-h5-ui/src/pages/hotel/customer/room-confirm.vue
[ ] 删除 L107 const businessHoursText = ref('10:00 - 22:00'),改为 const businessHoursText = computed(() => orderStore.businessHoursText);L98 import 由 { onMounted, ref } 改为 { computed, onMounted, ref }。
[ ] 新增 const closedInfo = ref(null),承载非营业拦截数据 { nextText }。不新增 isOpenNow / 提示条 —— 入口页走整页拦截。
[ ] 新增 async function loadBusinessHours(tenantId),返回 'OPEN' / 'CLOSED' / 'ERROR' 三态字符串(不用布尔,避免调用方把「拉取失败」误当「营业中」):
try:调 api.hotelBusinessHours(tenantId) → const bh = res?.data || res → orderStore.setBusinessHours(bh)!bh 或 bh.configured === false(fail-open)或 bh.open !== false → return 'OPEN'closedInfo.value = { nextText: bh.nextText || '' } → return 'CLOSED'catch → console.log 后 return 'ERROR'(不静默失败,入口页必须 fail-closed)[ ] loadRoomInfo() 中在 L171-172(setTenantId + setRoomInfo)之后、L174 联系人回填之前插入 const hoursState = await loadBusinessHours(data.tenantId):
'CLOSED' → loading.value = false + return(跳过 L182-188 的 setHotelConfig 与 L193 的 autoFetchUserName(),不能点餐就不必拉支付宝授权)'ERROR' → errorMsg.value = '营业状态获取失败,请点击重试'、loading.value = false、return'OPEN' → 继续原流程(L174 以下不动)setTenantId 之后:menu 页依赖 orderStore.tenantId 自行校验,客人经 TabBar 绕过时仍能拿到租户[ ] 模板在 v-else-if="errorMsg"(L10-38)与 v-else(L41)之间插入 v-else-if="closedInfo" 分支,完整复用 rc-error-page / rc-brand / rc-error-card 既有样式类(L328-397,不新写一套):
rc-error-icon-inner(L23)内容由 ! 改为 🕐(营业语义,非错误语义)rc-error-title(L25)文案「当前不在营业时间」rc-error-room 区块(L26-30)保留,客人需确认扫的是哪个房间(roomInfo 已在 L161 赋值)rc-error-msg(L31)渲染 closedInfo.nextText,为空时渲染「请稍后再试」rc-error-btn(L32)文案由「重新扫码」改为「重新检查」,@click="retry" 不变rc-error-footer(L35-37)—— 其文案与 handleWrongRoom 弹窗(L208-214)是「房间信息有误」语义,复用会误导客人(spec 7.2)[ ] retry()(L200-202)改为先重置再加载:closedInfo.value = null 然后 loadRoomInfo()。closedInfo 必须重置,否则拦截态残留、重试无效(errorMsg 已由 L137 重置,无需重复)。
[ ] L60-63 营业时间行不改结构,仅因 businessHoursText 改为 computed 而自动生效;保留 🕐 图标与 rc-info-row 样式类。
[ ] rc-start-btn(L73)文案与 goToMenu(L204-206)保持原样 —— 能渲染到该按钮即代表营业中,无需三元表达式。
[ ] 预期无新增样式类;若 rc-error-icon-inner 展示 emoji 时字号偏大,可加一个修饰类,色值仍取 --h-pri / --h-acc-lt 等既有变量,不引入新色值。
[ ] deliveryTime / deliveryFee 的硬编码(L108-109、L182-188)不在本变更范围(属缺口清单 5.3 hotelconfig 模块),保持原样勿动。
Files:
forge-h5-ui/src/pages/hotel/customer/menu.vue必须独立校验:
HotelTabBar(L111)用uni.reLaunch导航,客人可从orders/order-status直达本页,绕过room-confirm的入口拦截。
const hoursLoading = ref(false)、const hoursError = ref(false)、const isOpenNow = computed(() => orderStore.isOpenNow)、const businessTip = computed(() => orderStore.businessHoursTip)(computed / ref 已在 L116 import,无需补)。async function loadBusinessHours():
orderStore.tenantId 为空 → 直接 return(不置 hoursError;无租户上下文属异常入口,与既有 loadCategories / loadDishes 行为一致)hoursLoading.value = true 必须在第一个 await 之前同步执行,否则首帧会用 store 里的旧值闪现错误状态try:调 api.hotelBusinessHours(orderStore.tenantId) → orderStore.setBusinessHours(res?.data || res)、hoursError.value = falsecatch → hoursError.value = true(fail-closed)+ console.error,对齐既有 loadDishes 风格finally → hoursLoading.value = falseonMounted(L152-155)追加 loadBusinessHours(),与 loadCategories() / loadDishes() 并列(reLaunch 每次重建页面,onMounted 已足够,不引入 onShow)。scroll-view(L47-95)内改造分支顺序,在 dishLoading(L48)之后、原 m-empty(L52)之前插入三个分支:
v-else-if="hoursLoading" → 复用 m-loading + m-spinner,文案「正在确认营业状态...」v-else-if="hoursError" → 复用 m-empty 样式,m-empty-icon 改 ⚠️、m-empty-text 改「营业状态获取失败」,下方新增重试按钮(新类 m-retry-btn)绑定 loadBusinessHoursv-else-if="!isOpenNow" → 复用 m-empty 样式,m-empty-icon 改 🕐、m-empty-text 改「当前不在营业时间」,下方追加一行渲染 businessTip(v-if="businessTip",为空时不渲染)v-else-if="filteredDishes.length === 0"(L52-55)与 v-else(L56-92)内部不动,仅因新分支插入而自然后移HotelTabBar(L111)、菜品卡片与快速加减(L57-91)、goToDetail。非营业时菜品列表不渲染,加购与详情入口自然不可达,无需逐处加禁用态。cart.vue / dish-detail.vue(spec 2.4、7.2)。m-retry-btn 一个,复用既有主题变量(--h-pri 等),不引入新色值。Files:
Modify: forge-h5-ui/src/pages/hotel/customer/order-confirm.vue
[ ] submitOrder()(L119-168)中,在手机号校验(L124-127)之后、if (submitting.value) return(L128)之前插入营业状态刷新与阻断:
try { const res = await api.hotelBusinessHours(orderStore.tenantId); const bh = res?.data || res; if (bh) orderStore.setBusinessHours(bh) } catch (e) { console.log('营业状态刷新失败,交由后端校验:', e?.message || e) } —— 刷新失败不 return,继续走下单由后端兜底。if (bh && bh.configured !== false && bh.open === false) → uni.showModal({ title: '暂不可下单', content: bh.nextText ? '当前不在营业时间,' + bh.nextText : '当前不在营业时间,请稍后再试', showCancel: false }) 并 return(此时尚未置 submitting=true,无需复位)。[ ] 后端 BusinessException 消息已由 L163-164 的 catch + uni.showToast(err?.message) 展示,无需额外错误处理分支。
[ ] orderData 结构(L132-151)不改动——规格/加料 ID 缺失属缺口清单 3.1 资金缺陷,另行提案,本变更不得顺手修改。
cd forge-h5-ui; pnpm build 构建通过。10:00 - 22:00,确认 room-confirm.vue 内硬编码零残留。orders / order-status 点 TabBar 直达菜单 → 菜品区显示非营业占位,无任何菜品卡片,无法进详情、无法加购;送达信息栏 / 分类栏 / TabBar 仍正常order-confirm 点提交 → 弹窗阻断、无下单请求发出07:00-10:00 + 午餐 11:00-14:00,在 10:30 扫码 → 被拦截且提示「今天 11:00 开始(午餐)」Files:
code-copilot/changes/hotel-customer-business-hours-check/spec.mdcode-copilot/changes/hotel-customer-business-hours-check/tasks.mdforge-server/forge-business/forge-hotel/酒店模块需求缺口清单.mdModify: forge-server/forge-business/forge-hotel/酒店模块迁移跟踪.md
[ ] 按 AGENTS.md 2.1 先读取 code-copilot/rules/automated-testing-standard.md,再据本轮差异生成 test-spec.md 与 execution-log.md(本变更目录当前无历史测试产物,属首轮)。
[ ] 执行 git diff --check,确认无空白错误。
[ ] 酒店模块需求缺口清单.md:5.1 状态 ❌ → ✅ 并补变更目录链接;4.1「营业时间提示」行同步为 ✅;第九节第 2 批第 7 项标注已关闭。
[ ] 酒店模块迁移跟踪.md:营业时段小节补记 GET /hotel/open/customer/businessHours 与 BusinessHoursStatusVO;模块状态总览表 business-hours 行「前端 H5」列由 — 改为 ✅;代码结构树 vo/ 与 controller/open/ 补新文件;文末更新时间脚注追加本轮内容。
[ ] spec.md 第 6 章验收项逐条勾选,第 8 章回填执行结论(含实测证据与跳过项)。