tasks.md 22 KB

营业时间校验接入顾客端 Implementation Plan

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。

关键前置约束(执行前必读)

  1. SaTokenConfig 不需要修改——/hotel/open/** 已在 L79/L104 排除登录与 API 权限校验,新接口挂在该前缀下自动免登录。
  2. forge-hotel 模块禁止 lambda ->.stream()::、switch 表达式、@PathVariableLambdaQueryWrapper;排序用 Collections.sort + 匿名 Comparator,遍历用传统 for。
  3. 依赖注入统一 @Autowired 字段注入,字段不加 final
  4. 无 Flyway 脚本、无字典新增hotel_meal_typehotel_business_hours_status 已在 V1.0.103 建好)。
  5. 时间比较沿用 HH:mm 字符串字典序、左闭右开,与 selectCurrentBusinessHours 一致;不支持跨零点时段,勿"顺手修复"。
  6. 无启用时段配置时 fail-openconfigured=falseopen=true),严禁拦截。
  7. 区分两种「不营业」:接口成功但 configured=false → fail-open(全天营业,放行);接口调用失败(断网 / 5xx)→ UI fail-closed(拦住 + 重试)。两者不得混写。
  8. 拦截形态是整页拦截,不是「提示条 + 允许浏览」。已与业务方确认:只有营业时间内才可以点餐,非营业不给看菜单(spec 2.4)。不得rc-start-btn 文案改成「查看菜单」。

Task 1:新增营业状态 VO

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 currentMealNameString currentEndTimeString todayText(当天时段汇总文案,无配置时为「全天营业」)、List<Period> todayPeriodsString nextMealNameString nextStartTimeInteger nextDayOffset(0=今天)、String nextDateText(今天/明天/周X)、String nextText(组装好的完整文案,前端直接渲染)。

  • [ ] 内部静态类 Period@Data,字段 String mealNameString startTimeString endTime(沿用 HotelOrderVO.OrderItemVO 的内部类先例)。

  • [ ] 每个字段补中文 Javadoc 注释,密度对齐同目录既有 VO。

Task 2:Service 层推导当前营业状态

Files:

  • Modify: forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelBusinessHoursService.java
  • Modify: 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=falseopen=truetodayText="全天营业"todayPeriods 为空 ArrayListnextText="",直接返回。
    • configured=true;取 int today = java.time.LocalDate.now().getDayOfWeek().getValue() % 7;String currentTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm"));(与 checkCurrentBusinessHours() L53-55 完全一致)。
    • 传统 for 遍历 enabled,筛出 dayOfWeek == null || dayOfWeek == today 的项构建 todayPeriods,用 Collections.sort + 匿名 Comparator<Period>startTime 升序;拼 todayTextmealName + " " + startTime + "-" + endTime,多段以 · 连接)。
    • 当前是否营业:复用 checkCurrentBusinessHours()(勿重写 SQL 判定),非 null 则 open=true 并回填 currentMealName / currentEndTime;null 则 open=false
    • open=truenextText=""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 == dowstartTime 最小的一段,命中即 break。
    • nextDateTextoffset==0"今天"offset==1"明天";否则 WEEK_NAMES[dow]
    • nextText 组装为 nextDateText + " " + nextStartTime + " 开始(" + nextMealName + ")"
    • 7 天内无匹配(配置了时段但星期覆盖不全)→ nextText="",前端拦截页与菜单占位需容忍空文案,只显示「当前不在营业时间」(room-confirm 兜底「请稍后再试」,menu 直接不渲染该行)。
  • [ ] 不引入任何 lambda / stream / 方法引用;不新增 Mapper 方法与 XML。

  • [ ] 抽取 private 辅助方法(如 buildTodayPeriodsresolveNextPeriod)保持 currentStatus() 可读,方法数不超过 3 个。

Task 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/** 白名单内。

Task 4:下单前服务端校验

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.BusinessExceptionorg.springframework.util.StringUtils

  • [ ] 校验必须在 executeWithTenant 内部执行,否则 resolveTenantId() 会回退到 1L 取错租户配置。

  • [ ] 不修改 HotelOrderServiceImpl不影响 POST /hotel/order(管理端代客下单不受营业时间限制)。

  • [ ] 更新 orderCreate() 的 Javadoc,补一行营业时间校验说明。

Task 5:编译验证(后端)

  • cd forge; mvn clean install -DskipTests 通过。
  • 核查新增 Java 代码零 Java 8+ 特性:检索新增/修改文件内 ->.stream()::case + ->@PathVariableLambdaQueryWrapper,除日志字符串外应无命中。
  • 启动 forge-admin-server,用免登录请求验证三种配置场景:
    • 无启用时段 → configured=false, open=true, todayText="全天营业"
    • 当前在时段内 → open=truetodayPeriods 与 PC「营业时段」页配置一致
    • 当前不在时段内 → open=falsenextText 正确(含跨天到次日的场景)
  • 非营业时段直连 POST /hotel/open/customer/orderCreate,确认返回业务异常且 hotel_order 无新记录。

Task 6:H5 API 与 Store

Files:

  • Modify: forge-h5-ui/src/api/index.js
  • Modify: 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 !== false
    • businessHoursText(state):返回 state.businessHours?.todayText || '营业时间以门店公告为准'
    • businessHoursTip(state):返回 state.businessHours?.nextText || ''
  • [ ] actions 新增 setBusinessHours(v) { this.businessHours = v }

  • [ ] 不修改 persist.pick(L146)—— 营业状态是时效数据,持久化会让下次打开用旧值误拦 / 误放;内存态跨 redirectTo / reLaunch 已足够存活,且各页 onMounted 都会重新拉取。

Task 7:房号确认页入口整页拦截(L1)

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 || resorderStore.setBusinessHours(bh)
    • !bhbh.configured === false(fail-open)或 bh.open !== falsereturn 'OPEN'
    • 否则 closedInfo.value = { nextText: bh.nextText || '' }return 'CLOSED'
    • catchconsole.logreturn '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 = falsereturn
    • '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 模块),保持原样勿动。

Task 8:菜单页独立校验与菜品区拦截(L2 防绕过)

Files:

  • Modify: 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 = false
    • catchhoursError.value = true(fail-closed)+ console.error,对齐既有 loadDishes 风格
    • finallyhoursLoading.value = false
  • onMounted(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)绑定 loadBusinessHours
    • v-else-if="!isOpenNow" → 复用 m-empty 样式,m-empty-icon🕐m-empty-text 改「当前不在营业时间」,下方追加一行渲染 businessTipv-if="businessTip",为空时不渲染)
    • v-else-if="filteredDishes.length === 0"(L52-55)与 v-else(L56-92)内部不动,仅因新分支插入而自然后移
  • 不改动:送达信息栏(L4-8)、Banner(L11-14)、搜索栏(L17-26)、分类栏(L29-44)、购物车浮层(L98-108)、HotelTabBar(L111)、菜品卡片与快速加减(L57-91)、goToDetail。非营业时菜品列表不渲染,加购与详情入口自然不可达,无需逐处加禁用态。
  • 不改 cart.vue / dish-detail.vue(spec 2.4、7.2)。
  • 新增样式类仅 m-retry-btn 一个,复用既有主题变量(--h-pri 等),不引入新色值。

Task 9:下单页提交阻断

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 资金缺陷,另行提案,本变更不得顺手修改。

Task 10:H5 验证

  • cd forge-h5-ui; pnpm build 构建通过。
  • 全仓检索 10:00 - 22:00,确认 room-confirm.vue 内硬编码零残留。
  • 全仓检索「查看菜单」,确认该文案残留(旧宽松版产物,本变更不应出现)。
  • 浏览器(H5 模式)实测以下路径,控制台无报错:
    • 营业中扫码 → 房号确认页显示真实时段文案、无拦截、按钮为「开始点餐」,可进菜单、加购、完整下单
    • 非营业扫码 → 房号确认页整页拦截:无房间卡片、无「开始点餐」按钮,显示🕐 + 「当前不在营业时间」+ 下次营业文案 + 「重新检查」
    • 绕过入口:非营业时从 orders / order-status 点 TabBar 直达菜单 → 菜品区显示非营业占位,无任何菜品卡片,无法进详情、无法加购;送达信息栏 / 分类栏 / TabBar 仍正常
    • 打烊边界:营业中进菜单并加购 → 后台把当前时段改为已过期 → 回 H5 进 order-confirm 点提交 → 弹窗阻断、无下单请求发出
    • fail-closed:断网或后端返回 5xx 时扫码 → 房号确认页显示「营业状态获取失败,请点击重试」,不放行;菜单页显示失败占位 + 重试按钮
    • fail-open:后台把该租户全部时段禁用(或删除)后扫码 → 房号确认页显示「全天营业」、无拦截,可正常下单
    • 重新检查:非营业拦截页点「重新检查」,后台同时改为营业中 → 页面恢复为正常房间卡片,无需重新扫码
    • 空档期:配早餐 07:00-10:00 + 午餐 11:00-14:00,在 10:30 扫码 → 被拦截且提示「今天 11:00 开始(午餐)」
  • 后台改营业时段后重新扫码,确认 H5 文案随之变化(验证未走缓存旧值);刷新页面后确认 store 未持久化旧营业状态。

Task 11:增量验证与文档回填

Files:

  • Modify: code-copilot/changes/hotel-customer-business-hours-check/spec.md
  • Modify: code-copilot/changes/hotel-customer-business-hours-check/tasks.md
  • Modify: forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md
  • Modify: forge-server/forge-business/forge-hotel/酒店模块迁移跟踪.md

  • [ ] 按 AGENTS.md 2.1 先读取 code-copilot/rules/automated-testing-standard.md,再据本轮差异生成 test-spec.mdexecution-log.md(本变更目录当前无历史测试产物,属首轮)。

  • [ ] 执行 git diff --check,确认无空白错误。

  • [ ] 酒店模块需求缺口清单.md:5.1 状态 ❌ → ✅ 并补变更目录链接;4.1「营业时间提示」行同步为 ✅;第九节第 2 批第 7 项标注已关闭。

  • [ ] 酒店模块迁移跟踪.md:营业时段小节补记 GET /hotel/open/customer/businessHoursBusinessHoursStatusVO;模块状态总览表 business-hours 行「前端 H5」列由 改为 ;代码结构树 vo/controller/open/ 补新文件;文末更新时间脚注追加本轮内容。

  • [ ] spec.md 第 6 章验收项逐条勾选,第 8 章回填执行结论(含实测证据与跳过项)。