From 4ac2c960e4c66ae69d3e12d18bd7c0c9330404ad Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 24 Jun 2026 21:26:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(M08-A):=20=E6=8E=A5=E5=85=A5=E9=A1=BE?= =?UTF-8?q?=E5=AE=A2=E7=AB=AF=E9=80=89=E5=BA=97=E4=B8=8B=E5=8D=95=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- backend/src/routes/store-discovery.ts | 89 ++++++++++++- .../src/stores/store-discovery-repository.ts | 100 ++++++++++++++ backend/tests/store-discovery.test.mjs | 49 ++++++- docs/current-baseline.md | 6 +- docs/devlogs/2026-06-24-M08-A-顾客端.md | 27 ++++ docs/feature-status.md | 10 +- docs/module-status.md | 8 +- miniapp/app.js | 9 -- miniapp/pages/index/index.js | 6 + miniapp/pages/index/index.wxml | 3 +- miniapp/pages/index/index.wxss | 10 ++ miniapp/pages/room/detail.js | 125 ++++++++++++++++++ miniapp/pages/room/detail.wxml | 38 +++++- miniapp/pages/room/detail.wxss | 48 +++++++ miniapp/pages/store/detail.js | 52 ++++++++ miniapp/pages/store/detail.wxml | 30 ++++- miniapp/pages/store/detail.wxss | 55 ++++++++ miniapp/utils/api.js | 51 ++++++- scripts/check-miniapp-m08-a.mjs | 44 ++++++ 20 files changed, 734 insertions(+), 32 deletions(-) create mode 100644 docs/devlogs/2026-06-24-M08-A-顾客端.md create mode 100644 scripts/check-miniapp-m08-a.mjs diff --git a/README.md b/README.md index 6c4e9f0..ab57123 100644 --- a/README.md +++ b/README.md @@ -120,9 +120,9 @@ WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running 项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md`、`docs/module-status.md`、`docs/feature-status.md` 为准。 -- 当前执行游标:`M08-A` -- 最近工程提交:`ed0d455`,完成会员聚合查询服务和后台会员 API,支持订单、消费、余额、充值、优惠券、套餐和最近流水汇总。 -- 下一工程目标:M08-A 顾客端。 +- 当前执行游标:`M08-A`(PARTIAL) +- 最近工程提交:`M08A_COMMIT`,完成顾客端第一段:公开门店详情/房间列表 API、小程序登录态、选店到房间浏览、报价、预占、测试支付和受控 Wi-Fi 入口。 +- 下一工程目标:继续 M08-A,补顾客端订单列表、开门、续费、取消、换房、分享、余额、优惠券、套餐和个人中心。 ## 版本递进 diff --git a/backend/src/routes/store-discovery.ts b/backend/src/routes/store-discovery.ts index e8c5eb4..d386f80 100644 --- a/backend/src/routes/store-discovery.ts +++ b/backend/src/routes/store-discovery.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import type { AuthRepository } from '../auth/auth-repository.js'; +import type { AuthRepository, LoginContext } from '../auth/auth-repository.js'; import type { StoreDiscoveryRepository } from '../stores/store-discovery-repository.js'; const headersSchema = z.object({ @@ -21,7 +21,7 @@ const querySchema = z.object({ }); export interface StoreDiscoveryRouteOptions { - repository: Pick; + repository: Pick; tenancy: Pick; } @@ -64,4 +64,89 @@ export async function registerStoreDiscoveryRoutes( throw error; } }); + + app.get('/app-api/stores/:storeId', async (request, reply) => { + const context = await resolveContext(request, reply, options); + const params = storeIdSchema.safeParse(request.params); + if (!context) return; + if (!params.success) return invalid(reply, request.traceId); + const store = await options.repository.getStore({ + tenantId: context.tenantId, + storeId: params.data.storeId + }); + if (!store) { + return reply.status(404).send({ + code: 'STORE_NOT_FOUND', + message: 'Store was not found.', + traceId: request.traceId + }); + } + return { code: 0, data: store, traceId: request.traceId }; + }); + + app.get('/app-api/stores/:storeId/rooms', async (request, reply) => { + const context = await resolveContext(request, reply, options); + const params = storeIdSchema.safeParse(request.params); + if (!context) return; + if (!params.success) return invalid(reply, request.traceId); + return { + code: 0, + data: await options.repository.listRooms({ + tenantId: context.tenantId, + storeId: params.data.storeId + }), + traceId: request.traceId + }; + }); +} + +const storeIdSchema = z.object({ + storeId: z.string().regex(/^[1-9]\d{0,19}$/) +}); + +async function resolveContext( + request: { headers: unknown; traceId: string }, + reply: { status(code: number): { send(payload: unknown): unknown } }, + options: StoreDiscoveryRouteOptions +): Promise { + const headers = headersSchema.safeParse(request.headers); + if (!headers.success) { + invalid(reply, request.traceId); + return null; + } + try { + const context = await options.tenancy.resolveLoginContext( + headers.data['x-wechat-appid'], headers.data['tenant-id'] + ); + if (!context) { + reply.status(404).send({ + code: 'APP_TENANT_NOT_FOUND', + message: 'Application tenant binding was not found.', + traceId: request.traceId + }); + return null; + } + return context; + } catch (error) { + if (error instanceof Error && error.message === 'TENANT_SELECTION_REQUIRED') { + reply.status(409).send({ + code: 'TENANT_SELECTION_REQUIRED', + message: 'tenant-id is required for an application bound to multiple tenants.', + traceId: request.traceId + }); + return null; + } + throw error; + } +} + +function invalid( + reply: { status(code: number): { send(payload: unknown): unknown } }, + traceId: string +) { + return reply.status(400).send({ + code: 'INVALID_STORE_DISCOVERY_REQUEST', + message: 'AppID and valid store filters are required.', + traceId + }); } diff --git a/backend/src/stores/store-discovery-repository.ts b/backend/src/stores/store-discovery-repository.ts index 7db6f03..e8daf83 100644 --- a/backend/src/stores/store-discovery-repository.ts +++ b/backend/src/stores/store-discovery-repository.ts @@ -30,6 +30,30 @@ interface DiscoveryRow extends RowDataPacket { sortOrder: number; } +interface RoomRow extends RowDataPacket { + id: string; + storeId: string; + categoryName: string; + name: string; + roomNo: string; + capacity: number; + basePriceCents: number; + weekdayPriceCents: number; + holidayPriceCents: number; + overnightPriceCents: number; + fullDayPriceCents: number; + minimumSpendCents: number; + depositCents: number; + minimumMinutes: number; + maxAdvanceStartMinutes: number; + maxAdvanceDays: number; + configurationStatus: string; + operationalStatus: string; + tags: string | string[] | null; + images: string | string[] | null; + sortOrder: number; +} + export interface DiscoveredStore { id: string; name: string; @@ -46,6 +70,30 @@ export interface DiscoveredStore { sortOrder: number; } +export interface PublicRoom { + id: string; + storeId: string; + categoryName: string; + name: string; + roomNo: string; + capacity: number; + basePriceCents: number; + weekdayPriceCents: number; + holidayPriceCents: number; + overnightPriceCents: number; + fullDayPriceCents: number; + minimumSpendCents: number; + depositCents: number; + minimumMinutes: number; + maxAdvanceStartMinutes: number; + maxAdvanceDays: number; + configurationStatus: string; + operationalStatus: string; + tags: string[]; + images: string[]; + sortOrder: number; +} + export class StoreDiscoveryRepository { constructor(private readonly pool: MySqlPool) {} @@ -125,6 +173,47 @@ export class StoreDiscoveryRepository { return left.sortOrder - right.sortOrder || Number(left.id) - Number(right.id); }); } + + async getStore(query: { tenantId: string; storeId: string; now?: Date }): + Promise { + const stores = await this.findStores({ tenantId: query.tenantId, now: query.now }); + return stores.find((store) => store.id === query.storeId) ?? null; + } + + async listRooms(query: { tenantId: string; storeId: string }): Promise { + const [rows] = await this.pool.execute( + `SELECT r.id, r.store_id AS storeId, COALESCE(c.name, '') AS categoryName, + r.name, r.room_no AS roomNo, r.capacity, + r.base_price_cents AS basePriceCents, r.weekday_price_cents AS weekdayPriceCents, + r.holiday_price_cents AS holidayPriceCents, + r.overnight_price_cents AS overnightPriceCents, + r.full_day_price_cents AS fullDayPriceCents, + r.minimum_spend_cents AS minimumSpendCents, + r.deposit_cents AS depositCents, + r.minimum_minutes AS minimumMinutes, + r.max_advance_start_minutes AS maxAdvanceStartMinutes, + r.max_advance_days AS maxAdvanceDays, + r.configuration_status AS configurationStatus, + r.operational_status AS operationalStatus, r.tags, r.images, + r.sort_order AS sortOrder + FROM qipai_rooms r + INNER JOIN qipai_stores s + ON s.tenant_id = r.tenant_id AND s.id = r.store_id AND s.deleted_at IS NULL + LEFT JOIN qipai_room_categories c + ON c.id = r.category_id AND c.tenant_id = r.tenant_id AND c.deleted_at IS NULL + WHERE r.tenant_id = ? AND r.store_id = ? AND r.deleted_at IS NULL + AND r.configuration_status = 'ENABLED' + ORDER BY r.sort_order, r.id`, + [query.tenantId, query.storeId] + ); + return rows.map((row) => ({ + ...row, + id: String(row.id), + storeId: String(row.storeId), + tags: parseJsonArray(row.tags), + images: parseJsonArray(row.images) + })); + } } export function haversineMeters( @@ -162,3 +251,14 @@ function isOpenAt(hours: DiscoveryRow[], timezone: string, now: Date): boolean { } return currentMinute >= today.openMinute || currentMinute < today.closeMinute; } + +function parseJsonArray(value: string | string[] | null): string[] { + if (Array.isArray(value)) return value; + if (!value) return []; + try { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) && parsed.every((item) => typeof item === 'string') ? parsed : []; + } catch { + return []; + } +} diff --git a/backend/tests/store-discovery.test.mjs b/backend/tests/store-discovery.test.mjs index 851996a..4dc677f 100644 --- a/backend/tests/store-discovery.test.mjs +++ b/backend/tests/store-discovery.test.mjs @@ -50,6 +50,37 @@ const app = await buildApp({ async findStores(query) { assert.equal(query.city, '上海市'); return stores; + }, + async getStore(query) { + assert.equal(query.tenantId, '7'); + assert.equal(query.storeId, '11'); + return stores[0]; + }, + async listRooms(query) { + assert.deepEqual(query, { tenantId: '7', storeId: '11' }); + return [{ + id: '101', + storeId: '11', + categoryName: '棋牌包间', + name: '青竹房', + roomNo: 'A101', + capacity: 6, + basePriceCents: 3600, + weekdayPriceCents: 3200, + holidayPriceCents: 4200, + overnightPriceCents: 12800, + fullDayPriceCents: 22800, + minimumSpendCents: 0, + depositCents: 5000, + minimumMinutes: 60, + maxAdvanceStartMinutes: 30, + maxAdvanceDays: 7, + configurationStatus: 'ENABLED', + operationalStatus: 'AVAILABLE', + tags: ['自动麻将机'], + images: [], + sortOrder: 1 + }]; } } } @@ -67,6 +98,22 @@ const invalid = await app.inject({ headers: { 'x-wechat-appid': 'wx-test-app', 'tenant-id': '7' } }); assert.equal(invalid.statusCode, 400); + +const detail = await app.inject({ + method: 'GET', + url: '/app-api/stores/11', + headers: { 'x-wechat-appid': 'wx-test-app', 'tenant-id': '7' } +}); +assert.equal(detail.statusCode, 200); +assert.equal(detail.json().data.name, '近店'); + +const rooms = await app.inject({ + method: 'GET', + url: '/app-api/stores/11/rooms', + headers: { 'x-wechat-appid': 'wx-test-app', 'tenant-id': '7' } +}); +assert.equal(rooms.statusCode, 200); +assert.equal(rooms.json().data[0].id, '101'); await app.close(); -console.log('PASS: M03-C trusted distance sorting, city fallback and public store discovery are present.'); +console.log('PASS: M08-A public store detail and room discovery support customer miniapp browsing.'); diff --git a/docs/current-baseline.md b/docs/current-baseline.md index 1e77ed8..fd31b35 100644 --- a/docs/current-baseline.md +++ b/docs/current-baseline.md @@ -2,7 +2,7 @@ > V5.0 首次核验日期:2026-06-16 > audited_commit: `4ae9296` -> next_engineering_target: M08-A 顾客端 +> next_engineering_target: 继续 M08-A 顾客端订单与会员权益页面 > 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。 ## 总体结论 @@ -13,7 +13,7 @@ | Git 远端 | `origin=ssh://git@git.txyundm.cn:2222/panda/qipai.git`,分支 `main` | `git rev-list main...origin/main` 为 `0 0` | 本地与远端同步 | | 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、微信身份/会话,以及六角色 RBAC 和门店范围 | `backend/src/auth/**`、`database/migrations/2026061805_m02c_rbac.*.sql`、`backend/tests/**` | M01、M02-A/B/C DONE;MySQL 已验证 capability 和跨租户门店授权拒绝;下一阶段 M02-D | | 后台管理端 | 仅有 `admin/.gitkeep` | 当前文件扫描 | M09 未开始,不能标记 DONE | -| 微信小程序 | Git 中仅有 `miniapp/.gitkeep`;工作区另有未跟踪模板,按 ISSUE-007 隔离 | Git 状态与当前文件扫描 | M08 未开始,模板未审查、未纳管,不能标记 DONE | +| 微信小程序 | 已纳管微信原生模板,并完成顾客端第一段工程闭环 | `miniapp/pages/index`、`miniapp/pages/store/detail`、`miniapp/pages/room/detail`、`miniapp/utils/api.js`、`scripts/check-miniapp-m08-a.mjs` | M08-A PARTIAL;已支持选店、门店/房间浏览、报价、预占、测试支付和受控 Wi-Fi 入口,仍不能标记 DONE | | 数据库迁移 | 已新增 M01-B 核心 schema up/down/verify SQL、最小脱敏 seed、旧库结构 fixture、迁移计划/执行 CLI、验证结果数量门禁和 MySQL 8 集成测试 | `database/migrations/2026061601_m01b_core_schema.*.sql`、`database/seeds/2026061601_m01b_minimal_seed.sql`、`database/fixtures/2026061801_m01b_legacy_schema.sql`、`backend/src/db/migration-runner.ts`、`backend/tests/mysql-migration-roundtrip.test.mjs` | M01-B DONE;WSL MySQL 8.4.9 已验证 8 条旧库 fixture、11 条 up、3 条 verify、10 条 down,并验证租户隔离和旧金额整数分转换;不导入真实旧数据 | | M00 部署脚本 | 已有菜单、状态、HTTPS、Certbot、EMQX、备份检查模板 | `setup.sh`、`scripts/setup/`、`deploy/` | PARTIAL,生产未执行 | | 发布清单 dry-run | 可基于当前 HEAD 输出 `deployed=false` 的 release manifest,记录后端/后台/迁移尚未生成而跳过 | `scripts/setup/deploy-business.sh --dry-run .`、`scripts/dev/windows/check-release-manifest.ps1` | 可在 M00 验证结构;真实构建与生产发布待 M01/M09 后接入 | @@ -35,6 +35,6 @@ ## 下一步 -1. 进入 M08-A:基于现有微信原生小程序模板接入顾客端首页/选店、门店详情、房间、下单、支付、订单、开门、续费、取消、换房、分享、Wi-Fi、余额、优惠券、套餐和个人中心。 +1. 继续 M08-A:在已完成选店、门店/房间浏览、报价、预占、测试支付和受控 Wi-Fi 入口基础上,补订单列表、开门、续费、取消、换房、分享、余额、优惠券、套餐和个人中心。 2. 继续复用 M03 门店发现、M04 订单、M05 支付、M06 设备联动和 M07 会员营销后端能力,不重复初始化小程序。 3. M06-G 真实硬件联调仍等待生产 MQTT 账号/ACL、DeviceID 和现场实物配线。 diff --git a/docs/devlogs/2026-06-24-M08-A-顾客端.md b/docs/devlogs/2026-06-24-M08-A-顾客端.md new file mode 100644 index 0000000..7b832ce --- /dev/null +++ b/docs/devlogs/2026-06-24-M08-A-顾客端.md @@ -0,0 +1,27 @@ +# M08-A 顾客端 + +## 本次工程增量 + +- 后端新增顾客端公开门店详情和房间列表接口:`GET /app-api/stores/:storeId`、`GET /app-api/stores/:storeId/rooms`,继续通过 `x-wechat-appid` 与可选 `tenant-id` 解析租户,房间只返回未删除且启用的公开数据。 +- 小程序新增登录态封装,所有需登录接口自动携带 `Authorization: Bearer `;首页门店卡片可进入门店详情。 +- 门店详情页加载门店资料、公开房间列表和受控 Wi-Fi;Wi-Fi 只在登录后请求 `/app-api/stores/:storeId/wifi`。 +- 房间详情页接入报价、预占、测试支付创建和测试支付回调完成,形成顾客端下单链路的本地可验证入口。 +- 新增 `scripts/check-miniapp-m08-a.mjs`,检查小程序页面注册、固定生产 API 域名、登录和 M08-A 关键接口调用。 + +## 验收结果 + +- `node scripts/check-miniapp-m08-a.mjs`:PASS。 +- `npm run build`(`backend/`):PASS。 +- `node tests/store-discovery.test.mjs`:PASS。 +- `node tests/pricing.test.mjs`:PASS。 +- `node tests/payment.test.mjs`:PASS。 +- `npm test`(`backend/`):PASS。 + +## 状态 + +M08-A 仍为 `PARTIAL`。本次完成顾客端第一段工程闭环,但订单列表、开门、续费、取消、换房、分享、余额、优惠券、套餐、个人中心、生产微信支付调起和真机合法域名验证仍未完成,游标继续停留在 M08-A。 + +## 提交 + +- 工程提交:`M08A_COMMIT` +- 远端校验:提交后更新。 diff --git a/docs/feature-status.md b/docs/feature-status.md index 3960e15..a6bd302 100644 --- a/docs/feature-status.md +++ b/docs/feature-status.md @@ -11,13 +11,13 @@ | API-001 | 固定 HTTPS API 域名 | M00-E/M01/M08/M10 | PARTIAL | `1192dcb` | 已新增 Windows 检查脚本、Ubuntu 菜单检查和固定域名 Nginx 模板;M01-A 健康接口、编译和本地 HTTP 检查通过;M01-B 的连接池、迁移 CLI、旧表兼容读取、整数分转换已通过契约测试,并在 WSL MySQL 8.4.9 的空库和脱敏旧库副本完成 `up/verify/down/up/verify`,异常退出清理后临时库与账号均为 0。 | DNS/HTTPS 生产验证、Ubuntu 24.04 生产运行时、业务接口和鉴权未验收。 | 进入 M02 身份与权限;后续在 M10 接入生产域名、证书和 Nginx 真实验收。 | | ASYNC-001 | MySQL outbox 与轻量任务 worker | M01-C | DONE | `2b90d1f` | outbox/任务迁移、幂等键、`FOR UPDATE SKIP LOCKED` 领取、租约恢复、指数退避、人工补偿状态和 PM2 worker 已实现;Windows 契约测试与 WSL MySQL 8.4.9 往返迁移、重复入队、重启后领取验证通过。 | 具体通知、订单、设备、退款和统计 handler 在对应业务模块接入。 | M02 起按业务事务写入 outbox/任务并注册真实 handler。 | | TLS-001 | Nginx 与证书自动化 | M00-E/M10 | PARTIAL | `4cb3ab6` | 已生成 Nginx 模板、Certbot 命令说明和菜单第 4 项检查,可检查模板、站点启用、TLS、健康端点、证书文件、续期配置和 `certbot.timer`。 | 证书申请/续期 dry-run、80/443 生产验证未执行。 | 在生产 Ubuntu 执行证书签发、续期 dry-run 和 Nginx 安装记录。 | -| WXNET-001 | 微信合法域名与真机验证 | M00-E/M08/M10 | TODO | - | 已补 API 域名报告入口,但未做微信后台或真机验证。 | 微信后台/真机未验证。 | 后续导入小程序后执行合法域名和真机验证。 | +| WXNET-001 | 微信合法域名与真机验证 | M00-E/M08/M10 | PARTIAL | `M08A_COMMIT` | 小程序配置继续固定 `https://api.txyundm.cn/app-api`,`scripts/check-miniapp-m08-a.mjs` 已检查不存在 localhost、127.0.0.1 和 HTTP API。 | 微信后台合法域名和真机体验版未验证。 | M08-A 后续补真机登录、上传/下载、下单和设备接口冒烟;M10 做生产验收。 | | OPS-001 | 固定 `/opt/apps` 目录 | M00-D/M10 | PARTIAL | `292ab7f` | `scripts/setup/init-layout.sh` 已生成目录布局、uploads 目录和 manifest;WSL 检查脚本通过,未在生产 Ubuntu 执行。 | 生产操作未执行。 | 由管理员在 Ubuntu 菜单执行并记录结果。 | | OPS-002 | 单仓库 Gitea 推送与拉取部署 | M00-B/M10 | PARTIAL | 远端 HEAD | Windows 到 Gitea `origin/main` 首次 push 与远端校验通过。 | 生产服务器只读拉取部署尚未验证。 | M00-D/M00-E 继续完善生产拉取、菜单部署和状态检查。 | | OPS-003 | 整仓发布清单 | M00-E/M10 | PARTIAL | `59d92c0` | `deploy-business.sh --dry-run` 可在 Windows/WSL 验证当前 HEAD 的 release manifest 结构;`check-release-manifest.ps1` 已接入 `test-all.ps1`;正式构建待项目生成。 | 后端/后台尚未生成,生产菜单未执行。 | M01/M09 后接入真实构建结果,并在生产菜单 2 生成真实 release manifest。 | | OPS-004 | 菜单式更新与环境监测 | M00-E/M10 | PARTIAL | `292ab7f` | `setup.sh` 已接入初始化、更新、MQTT、HTTPS、状态、备份、恢复、回滚和诊断菜单;`--backup-status` 已在 WSL 验证,可检查备份工具、目录和模板。 | 未在生产 Ubuntu 执行;真实备份配置未启用。 | 生产执行后补部署记录、真实备份和恢复演练记录。 | | IOT-001 | MQTT Broker 生产部署 | M00/M06 | PARTIAL | `d01f771` | 菜单 3 已支持 Ubuntu 24.04 amd64 原生安装/更新与复检;正式 Topic ACL 已固定;后端实现 MQTT.js 3.1、QoS 1、持久会话、自动重连、订阅恢复、健康检查、消息上限和真实硬件烟测运行器;Windows 全量测试通过,WSL EMQX 5.8.9 服务级检查通过。 | 缺生产 EMQX 安装、正式账号、ACL/端口/备份验收;WSL 本地账号未配置,真实硬件烟测按预期 SKIP。 | 由管理员在生产菜单 3 执行并登记结果;提供生产账号、DeviceID 和实物后执行 `node dist/devices/hardware-smoke-runner.js`。 | -| SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `c658b09` | 微信原生基础模板、固定生产 API 配置和开发者工具项目配置已纳管;JSON 与 JavaScript 静态语法检查通过。 | 当前仅为基础模板,登录、门店、订单和多角色业务页面尚未接入。 | 保持 M03-C 当前游标;到 M08 基于现有模板实现完整业务,不重复初始化。 | +| SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `M08A_COMMIT` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付和受控 Wi-Fi 入口;`scripts/check-miniapp-m08-a.mjs` 与后端全量测试通过。 | 订单列表、开门、续费、取消、换房、分享、余额、优惠券、套餐、个人中心、多角色菜单和真机联调尚未完成。 | 继续 M08-A,补顾客端订单和会员权益页面,再进入 M08-B。 | | CFG-001 | 小程序品牌自定义 | M02-A/M03/M08 | PARTIAL | `8b8fb28` | 已建立 AppID/tenant 品牌配置表和 `/app-api/bootstrap`;同一 AppID 两租户隔离读取在 MySQL 8.4.9 实测通过。 | Logo/分享图片上传与小程序实际展示属于后续阶段。 | M03/M08 接入配置管理和小程序展示。 | | BKG-010 | 后台多小程序管理 | M02-A/M08/M10 | PARTIAL | `8b8fb28` | 已建立逻辑应用、租户绑定和租户配置模型;多租户 AppID 未指定租户会被拒绝。 | 尚无后台管理页面,多 AppID 真机配置未验证。 | M02-B/C 完成身份权限,M08 增加管理页面。 | | AUTH-001 | 多端统一小程序身份 | M02-B/M02-C/M08 | PARTIAL | `5a300a8` | 已实现微信 code 交换适配器、租户/AppID 隔离身份、JWT、服务端会话、当前用户和注销;禁用、角色/门店范围变化及管理员重置均使旧会话立即失效。 | 缺真实 AppSecret 真机联调和小程序动态角色界面。 | M08 完成小程序角色界面与真机登录。 | @@ -29,10 +29,10 @@ | ROOM-001 | 房间配置 | M03-A/M04 | PARTIAL | `15139b3` | 已实现房型、整数分价格、工作日/节假日/通宵价、押金、最低时长、提前规则、禁用时段、标签、图片和软删除。 | 价格计算、节假日日历及下单时段冲突属于 M04。 | M04 接入定价快照和预约校验。 | | ADV-001 | 广告管理 | M03-B/M08 | PARTIAL | `f746242` | 已实现平台、租户、门店三级广告,素材租户归属、投放时间、跳转、排序和范围权限均由后端校验。 | 后台编辑页面和小程序展示待 M08。 | M08 接入广告管理和前端投放。 | | UI-001 | 门店装修和多套模板 | M03-B/M08 | PARTIAL | `f746242` | 已实现装修草稿版本、Schema 版本、组件白名单、发布和旧版本归档。 | 可视化编辑器及小程序模板渲染待 M08。 | M08 实现编辑器与渲染组件。 | -| MAP-001 | 地图选店和最近门店 | M03-C/M08 | PARTIAL | `df373fa` | 已实现 AppID/tenant 隔离门店发现、城市/营业筛选、按门店时区判断营业中、后端 Haversine 距离排序;小程序支持定位和手工城市降级。 | 地图 SDK 可视化和真机定位授权尚未验收。 | M08 接入地图界面并完成真机验证。 | +| MAP-001 | 地图选店和最近门店 | M03-C/M08 | PARTIAL | `M08A_COMMIT` | 已实现 AppID/tenant 隔离门店发现、城市/营业筛选、按门店时区判断营业中、后端 Haversine 距离排序;小程序支持定位和手工城市降级,并可从门店卡片进入公开门店详情与房间列表。 | 地图 SDK 可视化和真机定位授权尚未验收。 | M08-A 继续补真机定位、地图展示和门店装修渲染。 | | QR-001 | 门店/房间场景码与 NFC | M03-D/M08 | PARTIAL | `d563078` | 已实现场景码生成、重建失效、撤销、扫码统计和二维码/NFC 统一页面解析;返回权限固定为空,旧码失效已在 MySQL 8.4.9 实测。 | 真实微信小程序码图片生成依赖可用 AppSecret 与真机环境。 | M08 接入微信小程序码接口并完成真机扫描验收。 | -| WIFI-001 | 受控 Wi-Fi 凭据 | M03-D/M04 | PARTIAL | `91801fe` | 正式订单与分享令牌均执行最小权限;分享开门权限独立于查看和续费,撤销、过期或订单终态即时失效;MySQL 8.4.9 实测通过。 | 真实开门与设备权限仍需 M06 厂商适配器和硬件联调。 | M06 接入真实设备权限。 | -| ORD-001 | 预约下单与多方式支付 | M04/M05 | PARTIAL | `cda640b` | 已完成订单全生命周期、微信支付 v3 工程闭环和团购支付:团购券核销与直订认领均生成统一支付记录并事务化推进订单;Windows 全量测试及 WSL MySQL 8.4.9 往返迁移通过。 | 缺真实微信和团购平台授权联调;余额/套餐支付属于 M07。 | M05-D 完成分账与收款配置;取得平台凭据后补真实联调。 | +| WIFI-001 | 受控 Wi-Fi 凭据 | M03-D/M04/M08 | PARTIAL | `M08A_COMMIT` | 正式订单与分享令牌均执行最小权限;M08-A 门店详情新增登录后受控 Wi-Fi 请求入口,不在未鉴权页面暴露密码。 | 真实到店权限、真机 Wi-Fi 连接和设备权限仍需现场联调。 | M08-A 补有效订单下的 Wi-Fi 真机连接验证。 | +| ORD-001 | 预约下单与多方式支付 | M04/M05/M08 | PARTIAL | `M08A_COMMIT` | 已完成订单全生命周期、微信支付 v3 工程闭环和团购支付;M08-A 小程序房间页已接入报价、预占、测试支付创建与测试回调完成入口,后端全量测试通过。 | 缺真实微信和团购平台授权联调;小程序订单列表、生产微信支付调起和余额/套餐支付入口待补。 | M08-A 继续补顾客端订单、取消/续费/换房和生产支付调起。 | | WAL-001 | 充值优惠 | M07-B | PARTIAL | `d9743d3` | 已建立充值规则和充值订单表,支持有效期、适用门店、限购、启停、clientRequestId 幂等;支付成功后调用钱包入账,重复支付回调不重复充值;Windows 全量后端回归通过。 | 真实微信充值支付回调仍需 M05 生产凭据和 M08 前端入口联调。 | M08 增加充值页面和账单查询。 | | WAL-002 | 门店独立会员余额 | M07-A | DONE | `63711ad` | 已建立租户/门店余额范围策略、现金/赠送双余额账户和不可变流水;充值、赠送、消费、退款、人工调整均预留 entry type;消费按赠送后现金扣款,业务号幂等,余额不足拒绝;Windows 全量后端回归通过。 | - | M07-D 聚合会员余额和权益状态。 | | WAL-003 | 余额账单 | M07-A/M08 | PARTIAL | `d9743d3` | 账单底表已包含业务类型、业务号、现金/赠送变动、变动后余额、操作人、trace 和 metadata;M07-B 充值成功后写入真实充值流水。 | 查询 API 和前端账单页面待 M08;优惠券/套餐不直接写钱包流水。 | M08 增加分页查询。 | diff --git a/docs/module-status.md b/docs/module-status.md index f3e2941..ebd05f5 100644 --- a/docs/module-status.md +++ b/docs/module-status.md @@ -4,11 +4,11 @@ execution_cursor: current_module: M08 current_stage: M08-A - stage_status: TODO + stage_status: PARTIAL last_completed_stage: M07-D next_stage: M08-A - last_engineering_commit: `ed0d455` - last_push_verified: true + last_engineering_commit: `M08A_COMMIT` + last_push_verified: false base_branch: main blocked_reason: "M06-G 真实硬件联调缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线;已完成烟测运行器并按 BLOCKED_EXTERNAL 旁路。" updated_at: 2026-06-24T00:00:00+08:00 @@ -24,7 +24,7 @@ execution_cursor: | M05 会员、余额、套餐、优惠券 | PARTIAL | `1680d73` | docs/devlogs/2026-06-22-M05-D-分账与收款配置.md | M05-A/B/C/D 工程闭环已完成:统一支付、微信支付退款、团购直订、门店收款账户、分账接收方、比例策略、授权门禁、幂等分账和对账查询均通过 Windows 与 WSL MySQL 回归;真实微信/团购/分账权限未提供,模块保持 PARTIAL/BLOCKED_EXTERNAL。 | | M06 设备、MQTT 与真实硬件联动 | PARTIAL | `d01f771` | docs/devlogs/2026-06-24-M06-G-真实硬件联调.md | M06-A 至 F 已完成 MQTT、拓扑、协议幂等、控制箱、Sub-1G 门锁、智慧插座和订单自动联动任务;M06-G 已补真实硬件烟测运行器,缺生产 MQTT 账号/ACL、DeviceID 和实物配线,保持 BLOCKED_EXTERNAL。 | | M07 会员、余额、充值、优惠券和套餐营销 | DONE | `ed0d455` | docs/devlogs/2026-06-24-M07-D-会员管理.md | M07-A 已完成现金/赠送双余额账本;M07-B 已完成充值优惠;M07-C 已完成优惠券/套餐权益底表和冻结、确认、退回的可靠补偿核销服务;M07-D 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 | -| M08 微信原生小程序完整业务 | TODO | - | - | - | +| M08 微信原生小程序完整业务 | PARTIAL | `M08A_COMMIT` | docs/devlogs/2026-06-24-M08-A-顾客端.md | M08-A 已完成顾客端第一段工程闭环:公开门店详情/房间列表 API、小程序登录态、选店到房间浏览、报价、预占、测试支付和受控 Wi-Fi 入口;订单列表、开门、续费、取消、换房、分享、余额、优惠券、套餐和个人中心继续留在 M08-A。 | | M09 后台管理端完整业务 | TODO | - | - | - | | M10 部署、域名、验收和运维闭环 | TODO | - | - | - | diff --git a/miniapp/app.js b/miniapp/app.js index 32b936b..7aa3436 100644 --- a/miniapp/app.js +++ b/miniapp/app.js @@ -1,17 +1,8 @@ App({ onLaunch() { - // 记录本地启动时间,便于开发调试。 const logs = wx.getStorageSync('logs') || [] logs.unshift(Date.now()) wx.setStorageSync('logs', logs) - - // 获取临时登录凭证;正式换取会话在 M08 接入。 - wx.login({ - success: res => { - // 后续将 res.code 发送到 /app-api/auth/wechat-login。 - void res - } - }) }, globalData: { userInfo: null diff --git a/miniapp/pages/index/index.js b/miniapp/pages/index/index.js index 43997d8..68ab190 100644 --- a/miniapp/pages/index/index.js +++ b/miniapp/pages/index/index.js @@ -59,6 +59,12 @@ Page({ } }, + openStore(event) { + const storeId = event.currentTarget.dataset.storeId + if (!storeId) return + wx.navigateTo({ url: `/pages/store/detail?storeId=${encodeURIComponent(storeId)}` }) + }, + async resolveScene(code, sourceType) { this.setData({ loading: true, errorMessage: '' }) try { diff --git a/miniapp/pages/index/index.wxml b/miniapp/pages/index/index.wxml index 8e2f7bd..5008df8 100644 --- a/miniapp/pages/index/index.wxml +++ b/miniapp/pages/index/index.wxml @@ -8,13 +8,14 @@ {{errorMessage}} 暂无门店 - + {{item.name}} {{item.city}}{{item.district}} {{item.address}} 距离 {{item.distanceMeters}} 米 {{item.openNow ? '营业中' : '休息中'}} + 查看房间 diff --git a/miniapp/pages/index/index.wxss b/miniapp/pages/index/index.wxss index 009cb4a..8e7e0ce 100644 --- a/miniapp/pages/index/index.wxss +++ b/miniapp/pages/index/index.wxss @@ -36,6 +36,10 @@ line-height: 1.7; } +.store-card:active { + background: #eef4ff; +} + .store-name { font-size: 34rpx; font-weight: 600; @@ -55,3 +59,9 @@ color: #888888; text-align: center; } + +.card-action { + margin-top: 16rpx; + color: #1f6feb; + font-weight: 600; +} diff --git a/miniapp/pages/room/detail.js b/miniapp/pages/room/detail.js index 696d562..0df964b 100644 --- a/miniapp/pages/room/detail.js +++ b/miniapp/pages/room/detail.js @@ -1,7 +1,32 @@ +const { + request, + ensureLogin, + cents, + clientRequestId, +} = require('../../utils/api.js') + +function defaultStartAt() { + const date = new Date(Date.now() + 30 * 60 * 1000) + date.setSeconds(0, 0) + return date +} + +function isoMinutesAfter(date, minutes) { + return new Date(date.getTime() + minutes * 60 * 1000).toISOString() +} + Page({ data: { storeId: '', roomId: '', + loading: false, + errorMessage: '', + successMessage: '', + durationMinutes: 120, + pricingMode: 'HOURLY', + quote: null, + order: null, + payment: null, }, onLoad(options) { this.setData({ @@ -9,4 +34,104 @@ Page({ roomId: options.roomId || '', }) }, + + setDuration(event) { + this.setData({ + durationMinutes: Number(event.currentTarget.dataset.minutes), + quote: null, + order: null, + payment: null, + errorMessage: '', + successMessage: '', + }) + }, + + async quote() { + await this.withRequest(async () => { + const payload = this.pricingPayload() + const response = await request('/pricing/quote', { method: 'POST', data: payload }) + this.setData({ + quote: { + ...response.data, + totalText: cents(response.data.totalCents), + depositText: cents(response.data.depositCents), + }, + successMessage: '报价已生成', + }) + }) + }, + + async reserve() { + await this.withRequest(async () => { + const payload = this.pricingPayload() + const response = await request('/orders/reserve', { method: 'POST', data: payload }) + this.setData({ + order: response.data, + successMessage: '时段已预占,请继续支付', + }) + }) + }, + + async createTestPayment() { + if (!this.data.order || !this.data.order.orderId) { + this.setData({ errorMessage: '请先预占订单' }) + return + } + await this.withRequest(async () => { + const response = await request('/payments', { + method: 'POST', + data: { + orderId: this.data.order.orderId, + provider: 'TEST', + clientRequestId: clientRequestId('miniapp-pay'), + }, + }) + this.setData({ + payment: { + ...response.data, + amountText: cents(response.data.amountCents), + }, + successMessage: '测试支付单已创建', + }) + }) + }, + + async completeTestPayment() { + if (!this.data.payment || !this.data.payment.paymentId) { + this.setData({ errorMessage: '请先创建测试支付单' }) + return + } + await this.withRequest(async () => { + await request(`/payments/${encodeURIComponent(this.data.payment.paymentId)}/test-complete`, { + method: 'POST', + data: { + callbackId: clientRequestId('miniapp-callback'), + amountCents: this.data.payment.amountCents, + }, + }) + this.setData({ successMessage: '测试支付已完成,生产环境将切换微信支付' }) + }) + }, + + pricingPayload() { + const start = defaultStartAt() + return { + roomId: this.data.roomId, + startAt: start.toISOString(), + endAt: isoMinutesAfter(start, this.data.durationMinutes), + pricingMode: this.data.pricingMode, + } + }, + + async withRequest(work) { + this.setData({ loading: true, errorMessage: '', successMessage: '' }) + try { + await ensureLogin() + await work() + } catch (error) { + this.setData({ errorMessage: error.message || '操作失败' }) + } finally { + this.setData({ loading: false }) + } + }, }) diff --git a/miniapp/pages/room/detail.wxml b/miniapp/pages/room/detail.wxml index 1c0241b..69a1360 100644 --- a/miniapp/pages/room/detail.wxml +++ b/miniapp/pages/room/detail.wxml @@ -2,5 +2,41 @@ 房间详情 门店编号:{{storeId}} 房间编号:{{roomId}} - 扫码或 NFC 只打开此页面,开门权限仍由有效订单单独校验。 + 选择时长 + + + + + + + + + + + + + + {{errorMessage}} + {{successMessage}} + + + 报价 + 总价:{{quote.totalText}} + 押金:{{quote.depositText}} + + + + 订单 + 订单编号:{{order.orderId}} + 预占编号:{{order.reservationId}} + + + + 支付 + 支付编号:{{payment.paymentId}} + 金额:{{payment.amountText}} + 状态:{{payment.status}} + + + 扫码或 NFC 只打开此页面,开门权限仍由有效订单单独校验;生产支付以微信预支付为准。 diff --git a/miniapp/pages/room/detail.wxss b/miniapp/pages/room/detail.wxss index 2d5d9a5..dcc7b24 100644 --- a/miniapp/pages/room/detail.wxss +++ b/miniapp/pages/room/detail.wxss @@ -1,5 +1,7 @@ .page { padding: 32rpx; + min-height: 100vh; + background: #f5f6f8; } .title { @@ -12,3 +14,49 @@ margin-top: 32rpx; color: #777777; } + +.section-title { + margin: 32rpx 0 16rpx; + font-size: 32rpx; + font-weight: 600; +} + +.duration-row, +.action-grid { + display: flex; + gap: 16rpx; + flex-wrap: wrap; + margin-bottom: 24rpx; +} + +.action-grid button { + flex: 1 1 280rpx; +} + +.selected { + color: #ffffff; + background: #1f6feb; +} + +.result-card { + margin-top: 20rpx; + padding: 28rpx; + border-radius: 16rpx; + background: #ffffff; + line-height: 1.7; +} + +.result-title { + font-size: 32rpx; + font-weight: 600; +} + +.success { + margin: 16rpx 0; + color: #17823b; +} + +.error { + margin: 16rpx 0; + color: #c73535; +} diff --git a/miniapp/pages/store/detail.js b/miniapp/pages/store/detail.js index bd0e8ef..0a25f8d 100644 --- a/miniapp/pages/store/detail.js +++ b/miniapp/pages/store/detail.js @@ -1,8 +1,60 @@ +const { request, ensureLogin, cents } = require('../../utils/api.js') + Page({ data: { storeId: '', + loading: false, + errorMessage: '', + store: null, + rooms: [], + wifi: null, }, onLoad(options) { this.setData({ storeId: options.storeId || '' }) + if (options.storeId) this.loadStore(options.storeId) + }, + + async loadStore(storeId) { + this.setData({ loading: true, errorMessage: '' }) + try { + const [storeResponse, roomsResponse] = await Promise.all([ + request(`/stores/${encodeURIComponent(storeId)}`), + request(`/stores/${encodeURIComponent(storeId)}/rooms`), + ]) + this.setData({ + store: storeResponse.data, + rooms: (roomsResponse.data || []).map((room) => ({ + ...room, + basePriceText: cents(room.basePriceCents), + depositText: cents(room.depositCents), + })), + }) + } catch (error) { + this.setData({ errorMessage: error.message || '门店加载失败' }) + } finally { + this.setData({ loading: false }) + } + }, + + openRoom(event) { + const roomId = event.currentTarget.dataset.roomId + if (!roomId || !this.data.storeId) return + wx.navigateTo({ + url: `/pages/room/detail?storeId=${encodeURIComponent(this.data.storeId)}&roomId=${encodeURIComponent(roomId)}`, + }) + }, + + async loadWifi() { + if (!this.data.storeId) return + this.setData({ loading: true, errorMessage: '' }) + try { + await ensureLogin() + const response = await request(`/stores/${encodeURIComponent(this.data.storeId)}/wifi`) + this.setData({ wifi: response.data }) + } catch (error) { + this.setData({ errorMessage: error.message || '请登录并确认到店权限后查看 Wi-Fi' }) + } finally { + this.setData({ loading: false }) + } }, }) diff --git a/miniapp/pages/store/detail.wxml b/miniapp/pages/store/detail.wxml index bac4bfa..a44b5d5 100644 --- a/miniapp/pages/store/detail.wxml +++ b/miniapp/pages/store/detail.wxml @@ -1,5 +1,31 @@ - 门店详情 - 门店编号:{{storeId}} + + {{store.name}} + {{store.city}}{{store.district}} {{store.address}} + {{store.openNow ? '营业中' : '休息中'}} + 电话:{{store.contactPhone}} + + 门店详情 + + {{errorMessage}} + + 房间 + 暂无可预订房间 + + {{item.name}} + {{item.categoryName}} · {{item.capacity}}人 · {{item.roomNo}} + {{item.basePriceText}}/小时 + 押金 {{item.depositText}} · 最少 {{item.minimumMinutes}} 分钟 + + {{item.operationalStatus === 'AVAILABLE' ? '可预订' : '暂不可订'}} + + + + + + SSID:{{wifi.ssid}} + 密码:{{wifi.password}} + + 场景码仅用于页面导航,不授予开门或设备控制权限。 diff --git a/miniapp/pages/store/detail.wxss b/miniapp/pages/store/detail.wxss index 2d5d9a5..f6d9d40 100644 --- a/miniapp/pages/store/detail.wxss +++ b/miniapp/pages/store/detail.wxss @@ -1,5 +1,7 @@ .page { padding: 32rpx; + min-height: 100vh; + background: #f5f6f8; } .title { @@ -12,3 +14,56 @@ margin-top: 32rpx; color: #777777; } + +.store-head, +.room-card, +.wifi-box { + margin-bottom: 24rpx; + padding: 28rpx; + border-radius: 16rpx; + background: #ffffff; +} + +.room-card:active { + background: #eef4ff; +} + +.section-title { + margin: 32rpx 0 16rpx; + font-size: 32rpx; + font-weight: 600; +} + +.room-name { + font-size: 34rpx; + font-weight: 600; +} + +.muted { + margin-top: 8rpx; + color: #667085; +} + +.price { + margin-top: 12rpx; + color: #b42318; + font-size: 34rpx; + font-weight: 700; +} + +.open { + margin-top: 8rpx; + color: #17823b; +} + +.closed, +.error { + margin-top: 8rpx; + color: #c73535; +} + +.empty { + padding: 64rpx 0; + color: #888888; + text-align: center; +} diff --git a/miniapp/utils/api.js b/miniapp/utils/api.js index b993897..ee1185f 100644 --- a/miniapp/utils/api.js +++ b/miniapp/utils/api.js @@ -1,13 +1,17 @@ const { APP_API_BASE_URL, WECHAT_APP_ID } = require('../config/env.js') +const TOKEN_KEY = 'qipai_access_token' + function request(path, options = {}) { return new Promise((resolve, reject) => { + const token = wx.getStorageSync(TOKEN_KEY) wx.request({ url: `${APP_API_BASE_URL}${path}`, method: options.method || 'GET', data: options.data, header: { 'x-wechat-appid': WECHAT_APP_ID, + ...(token ? { authorization: `Bearer ${token}` } : {}), ...(options.headers || {}), }, success(response) { @@ -22,4 +26,49 @@ function request(path, options = {}) { }) } -module.exports = { request } +function login() { + return new Promise((resolve, reject) => { + wx.login({ + success: async ({ code }) => { + try { + const response = await request('/auth/wechat-login', { + method: 'POST', + data: { code }, + }) + wx.setStorageSync(TOKEN_KEY, response.data.accessToken) + resolve(response.data) + } catch (error) { + reject(error) + } + }, + fail: reject, + }) + }) +} + +function ensureLogin() { + const token = wx.getStorageSync(TOKEN_KEY) + if (token) return Promise.resolve({ accessToken: token }) + return login() +} + +function clearSession() { + wx.removeStorageSync(TOKEN_KEY) +} + +function cents(value) { + return `¥${((Number(value || 0)) / 100).toFixed(2)}` +} + +function clientRequestId(prefix) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +module.exports = { + request, + login, + ensureLogin, + clearSession, + cents, + clientRequestId, +} diff --git a/scripts/check-miniapp-m08-a.mjs b/scripts/check-miniapp-m08-a.mjs new file mode 100644 index 0000000..12c4125 --- /dev/null +++ b/scripts/check-miniapp-m08-a.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const root = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'); +const read = (path) => readFileSync(join(root, path), 'utf8'); + +const appJson = JSON.parse(read('miniapp/app.json')); +for (const page of [ + 'pages/index/index', + 'pages/store/detail', + 'pages/room/detail' +]) { + assert.ok(appJson.pages.includes(page), `${page} must be registered`); +} + +const env = read('miniapp/config/env.js'); +assert.match(env, /https:\/\/api\.txyundm\.cn/); +assert.doesNotMatch(env, /localhost|127\.0\.0\.1|http:\/\//); + +const api = read('miniapp/utils/api.js'); +assert.match(api, /\/auth\/wechat-login/); +assert.match(api, /authorization: `Bearer \$\{token\}`/); + +const index = read('miniapp/pages/index/index.js'); +assert.match(index, /\/stores\?\$\{query\}/); +assert.match(index, /openStore/); + +const store = read('miniapp/pages/store/detail.js'); +assert.match(store, /\/stores\/\$\{encodeURIComponent\(storeId\)\}/); +assert.match(store, /\/stores\/\$\{encodeURIComponent\(storeId\)\}\/rooms/); +assert.match(store, /\/stores\/\$\{encodeURIComponent\(this\.data\.storeId\)\}\/wifi/); + +const room = read('miniapp/pages/room/detail.js'); +for (const route of [ + '/pricing/quote', + '/orders/reserve', + '/payments', + '/test-complete' +]) { + assert.match(room, new RegExp(route.replace('/', '\\/'))); +} + +console.log('PASS: M08-A miniapp customer pages use fixed domain and real app-api calls.');