diff --git a/README.md b/README.md index f4fdad5..4f0fe87 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,8 @@ 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`(PARTIAL) -- 最近工程提交:`dbdfe3d`,完成顾客端充值计划查询和充值单创建入口。 -- 下一工程目标:继续 M08-A,补顾客端优惠券/套餐明细和生产支付调起。 +- 最近工程提交:`6cb3d2d`,完成顾客端优惠券/套餐明细和订单微信预支付调起。 +- 下一工程目标:继续 M08-A,补充值微信支付调起和权益下单抵扣集成。 ## 版本递进 diff --git a/backend/src/routes/members.ts b/backend/src/routes/members.ts index 250029e..48d7108 100644 --- a/backend/src/routes/members.ts +++ b/backend/src/routes/members.ts @@ -18,7 +18,7 @@ const listSchema = z.object({ }); export interface MemberRouteOptions { - service: Pick; + service: Pick; authRepository: Pick; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; jwtSecret: string; @@ -29,31 +29,26 @@ export async function registerMemberRoutes( options: MemberRouteOptions ): Promise { app.get('/app-api/profile', async (request, reply) => { - const auth = await authenticateAccessToken( - request.headers.authorization, - options.authRepository, - options.jwtSecret - ); - if (!auth) { - return reply.status(401).send({ - code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId - }); - } - const access = await options.accessControl.getAccessProfile( - auth.session.tenantId, - auth.session.user.id - ); - if (!access.capabilities.includes('profile.read')) { - return reply.status(403).send({ - code: 'PROFILE_READ_FORBIDDEN', message: 'Profile read permission is required.', - traceId: request.traceId - }); - } + const auth = await requireProfileReader(request, reply, options); + if (!auth) return; return handle(reply, request.traceId, async () => ({ code: 0, data: await options.service.getMyProfile({ - tenantId: auth.session.tenantId, - userId: auth.session.user.id + tenantId: auth.tenantId, + userId: auth.userId + }), + traceId: request.traceId + })); + }); + + app.get('/app-api/profile/benefits', async (request, reply) => { + const auth = await requireProfileReader(request, reply, options); + if (!auth) return; + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.service.getMyBenefits({ + tenantId: auth.tenantId, + userId: auth.userId }), traceId: request.traceId })); @@ -84,6 +79,36 @@ export async function registerMemberRoutes( }); } +async function requireProfileReader( + request: FastifyRequest, + reply: FastifyReply, + options: MemberRouteOptions +): Promise<{ tenantId: string; userId: string } | null> { + const auth = await authenticateAccessToken( + request.headers.authorization, + options.authRepository, + options.jwtSecret + ); + if (!auth) { + reply.status(401).send({ + code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId + }); + return null; + } + const access = await options.accessControl.getAccessProfile( + auth.session.tenantId, + auth.session.user.id + ); + if (!access.capabilities.includes('profile.read')) { + reply.status(403).send({ + code: 'PROFILE_READ_FORBIDDEN', message: 'Profile read permission is required.', + traceId: request.traceId + }); + return null; + } + return { tenantId: auth.session.tenantId, userId: auth.session.user.id }; +} + async function requireReader( request: FastifyRequest, reply: FastifyReply, diff --git a/backend/src/wallets/member-profile-service.ts b/backend/src/wallets/member-profile-service.ts index 4fb247f..e321c1f 100644 --- a/backend/src/wallets/member-profile-service.ts +++ b/backend/src/wallets/member-profile-service.ts @@ -55,6 +55,45 @@ interface LedgerRow extends RowDataPacket { createdAt: Date; } +interface CouponDetailRow extends RowDataPacket { + id: string; + templateId: string; + name: string; + status: string; + couponType: string; + discountAmountCents: number; + timeMinutes: number; + minOrderAmountCents: number; + remainingUses: number; + validFrom: Date; + validTo: Date; + storeId: string | null; + roomCategoryId: string | null; + roomId: string | null; + holidayOnly: number; + frozenOrderId: string | null; + usedAt: Date | null; +} + +interface PackageDetailRow extends RowDataPacket { + id: string; + planId: string; + name: string; + status: string; + priceCents: number; + minutesTotal: number; + amountCentsTotal: number; + remainingMinutes: number; + remainingAmountCents: number; + validFrom: Date; + validTo: Date; + storeId: string | null; + roomCategoryId: string | null; + roomId: string | null; + holidayOnly: number; + frozenOrderId: string | null; +} + interface CountRow extends RowDataPacket { total: number } export interface MemberActor { @@ -167,6 +206,93 @@ export class MemberProfileService { }; } + async getMyBenefits(input: { + tenantId: string; + userId: string; + limit?: number; + }) { + const limit = Math.max(1, Math.min(input.limit ?? 50, 100)); + const [coupons] = await this.pool.execute( + `SELECT g.id, g.template_id AS templateId, t.name, g.status, + t.coupon_type AS couponType, + t.discount_amount_cents AS discountAmountCents, + t.time_minutes AS timeMinutes, + t.min_order_amount_cents AS minOrderAmountCents, + g.remaining_uses AS remainingUses, + g.valid_from AS validFrom, g.valid_to AS validTo, + t.store_id AS storeId, t.room_category_id AS roomCategoryId, + t.room_id AS roomId, t.holiday_only AS holidayOnly, + g.frozen_order_id AS frozenOrderId, g.used_at AS usedAt + FROM qipai_coupon_grants g + INNER JOIN qipai_coupon_templates t + ON t.tenant_id = g.tenant_id AND t.id = g.template_id + WHERE g.tenant_id = ? AND g.user_id = ? AND t.deleted_at IS NULL + ORDER BY FIELD(g.status, 'AVAILABLE', 'FROZEN', 'USED', 'EXPIRED'), + g.valid_to ASC, g.id DESC + LIMIT ?`, + [input.tenantId, input.userId, limit] + ); + const [packages] = await this.pool.execute( + `SELECT h.id, h.plan_id AS planId, p.name, h.status, + p.price_cents AS priceCents, + p.minutes_total AS minutesTotal, + p.amount_cents_total AS amountCentsTotal, + h.remaining_minutes AS remainingMinutes, + h.remaining_amount_cents AS remainingAmountCents, + h.valid_from AS validFrom, h.valid_to AS validTo, + p.store_id AS storeId, p.room_category_id AS roomCategoryId, + p.room_id AS roomId, p.holiday_only AS holidayOnly, + h.frozen_order_id AS frozenOrderId + FROM qipai_package_holdings h + INNER JOIN qipai_package_plans p + ON p.tenant_id = h.tenant_id AND p.id = h.plan_id + WHERE h.tenant_id = ? AND h.user_id = ? AND p.deleted_at IS NULL + ORDER BY FIELD(h.status, 'ACTIVE', 'FROZEN', 'EXHAUSTED', 'EXPIRED'), + h.valid_to ASC, h.id DESC + LIMIT ?`, + [input.tenantId, input.userId, limit] + ); + return { + coupons: coupons.map((row) => ({ + couponGrantId: String(row.id), + templateId: String(row.templateId), + name: row.name, + status: row.status, + couponType: row.couponType, + discountAmountCents: Number(row.discountAmountCents), + timeMinutes: Number(row.timeMinutes), + minOrderAmountCents: Number(row.minOrderAmountCents), + remainingUses: Number(row.remainingUses), + validFrom: row.validFrom, + validTo: row.validTo, + storeId: row.storeId === null ? null : String(row.storeId), + roomCategoryId: row.roomCategoryId === null ? null : String(row.roomCategoryId), + roomId: row.roomId === null ? null : String(row.roomId), + holidayOnly: Number(row.holidayOnly) === 1, + frozenOrderId: row.frozenOrderId === null ? null : String(row.frozenOrderId), + usedAt: row.usedAt + })), + packages: packages.map((row) => ({ + packageHoldingId: String(row.id), + planId: String(row.planId), + name: row.name, + status: row.status, + priceCents: Number(row.priceCents), + minutesTotal: Number(row.minutesTotal), + amountCentsTotal: Number(row.amountCentsTotal), + remainingMinutes: Number(row.remainingMinutes), + remainingAmountCents: Number(row.remainingAmountCents), + validFrom: row.validFrom, + validTo: row.validTo, + storeId: row.storeId === null ? null : String(row.storeId), + roomCategoryId: row.roomCategoryId === null ? null : String(row.roomCategoryId), + roomId: row.roomId === null ? null : String(row.roomId), + holidayOnly: Number(row.holidayOnly) === 1, + frozenOrderId: row.frozenOrderId === null ? null : String(row.frozenOrderId) + })) + }; + } + private async memberCard(actor: MemberActor, row: MemberRow) { const memberId = String(row.id); const [walletRows] = await this.pool.execute( diff --git a/backend/tests/member-profile-route.test.mjs b/backend/tests/member-profile-route.test.mjs index 0f028f5..f7314ed 100644 --- a/backend/tests/member-profile-route.test.mjs +++ b/backend/tests/member-profile-route.test.mjs @@ -13,6 +13,7 @@ const forbiddenToken = signAccessToken({ }, secret, 900); let profileInput; +let benefitsInput; const app = await buildApp({ members: { jwtSecret: secret, @@ -46,6 +47,23 @@ const app = await buildApp({ service: { async listMembers() { throw new Error('not called'); }, async getMember() { throw new Error('not called'); }, + async getMyBenefits(input) { + benefitsInput = input; + return { + coupons: [{ + couponGrantId: '401', + name: '30元抵扣券', + status: 'AVAILABLE', + discountAmountCents: 3000 + }], + packages: [{ + packageHoldingId: '501', + name: '三小时畅玩包', + status: 'ACTIVE', + remainingMinutes: 120 + }] + }; + }, async getMyProfile(input) { profileInput = input; return { @@ -71,6 +89,17 @@ assert.equal(profileInput.tenantId, '7'); assert.equal(profileInput.userId, '21'); assert.equal(profile.json().data.wallet.totalBalanceCents, 1200); +const benefits = await app.inject({ + method: 'GET', + url: '/app-api/profile/benefits', + headers: { authorization: `Bearer ${token}` } +}); +assert.equal(benefits.statusCode, 200); +assert.equal(benefitsInput.tenantId, '7'); +assert.equal(benefitsInput.userId, '21'); +assert.equal(benefits.json().data.coupons[0].couponGrantId, '401'); +assert.equal(benefits.json().data.packages[0].packageHoldingId, '501'); + const unauthorized = await app.inject({ method: 'GET', url: '/app-api/profile' diff --git a/backend/tests/member-profile-service.test.mjs b/backend/tests/member-profile-service.test.mjs index 14c41a5..b2adbf0 100644 --- a/backend/tests/member-profile-service.test.mjs +++ b/backend/tests/member-profile-service.test.mjs @@ -47,25 +47,111 @@ const wallets = [ ]; const coupons = [ - { tenantId: '7', userId: '21', status: 'AVAILABLE' }, - { tenantId: '7', userId: '21', status: 'FROZEN' }, - { tenantId: '7', userId: '22', status: 'USED' } + { + id: '401', + tenantId: '7', + userId: '21', + templateId: '301', + name: '30元抵扣券', + status: 'AVAILABLE', + couponType: 'FULL_REDUCTION', + discountAmountCents: 3000, + timeMinutes: 0, + minOrderAmountCents: 10000, + remainingUses: 1, + validFrom: new Date('2026-06-01T00:00:00.000Z'), + validTo: new Date('2026-07-01T00:00:00.000Z'), + storeId: '11', + roomCategoryId: null, + roomId: null, + holidayOnly: 0, + frozenOrderId: null, + usedAt: null + }, + { + id: '402', + tenantId: '7', + userId: '21', + templateId: '302', + name: '60分钟券', + status: 'FROZEN', + couponType: 'TIME', + discountAmountCents: 0, + timeMinutes: 60, + minOrderAmountCents: 0, + remainingUses: 1, + validFrom: new Date('2026-06-01T00:00:00.000Z'), + validTo: new Date('2026-07-02T00:00:00.000Z'), + storeId: null, + roomCategoryId: null, + roomId: null, + holidayOnly: 0, + frozenOrderId: '301', + usedAt: null + }, + { + id: '403', + tenantId: '7', + userId: '22', + templateId: '303', + name: '其他用户券', + status: 'USED', + couponType: 'FULL_REDUCTION', + discountAmountCents: 500, + timeMinutes: 0, + minOrderAmountCents: 0, + remainingUses: 0, + validFrom: new Date('2026-06-01T00:00:00.000Z'), + validTo: new Date('2026-07-01T00:00:00.000Z'), + storeId: null, + roomCategoryId: null, + roomId: null, + holidayOnly: 0, + frozenOrderId: null, + usedAt: new Date('2026-06-20T00:00:00.000Z') + } ]; const packages = [ { + id: '501', tenantId: '7', userId: '21', + planId: '601', + name: '三小时畅玩包', status: 'ACTIVE', + priceCents: 9000, + minutesTotal: 180, + amountCentsTotal: 3000, remainingMinutes: 120, - remainingAmountCents: 3_000 + remainingAmountCents: 3_000, + validFrom: new Date('2026-06-01T00:00:00.000Z'), + validTo: new Date('2026-08-01T00:00:00.000Z'), + storeId: '11', + roomCategoryId: null, + roomId: null, + holidayOnly: 0, + frozenOrderId: null }, { + id: '502', tenantId: '7', userId: '21', + planId: '602', + name: '锁定中的套餐', status: 'FROZEN', + priceCents: 5000, + minutesTotal: 60, + amountCentsTotal: 2000, remainingMinutes: 60, - remainingAmountCents: 2_000 + remainingAmountCents: 2_000, + validFrom: new Date('2026-06-01T00:00:00.000Z'), + validTo: new Date('2026-08-02T00:00:00.000Z'), + storeId: null, + roomCategoryId: null, + roomId: null, + holidayOnly: 0, + frozenOrderId: '301' } ]; @@ -185,6 +271,53 @@ function createService() { .reduce((sum, item) => sum + item.remainingAmountCents, 0) }], []]; } + if (sql.includes('FROM qipai_coupon_grants g')) { + const rows = coupons + .filter((item) => item.tenantId === String(params[0]) && item.userId === String(params[1])) + .slice(0, Number(params[2])); + return [rows.map((item) => ({ + id: item.id, + templateId: item.templateId, + name: item.name, + status: item.status, + couponType: item.couponType, + discountAmountCents: item.discountAmountCents, + timeMinutes: item.timeMinutes, + minOrderAmountCents: item.minOrderAmountCents, + remainingUses: item.remainingUses, + validFrom: item.validFrom, + validTo: item.validTo, + storeId: item.storeId, + roomCategoryId: item.roomCategoryId, + roomId: item.roomId, + holidayOnly: item.holidayOnly, + frozenOrderId: item.frozenOrderId, + usedAt: item.usedAt + })), []]; + } + if (sql.includes('FROM qipai_package_holdings h')) { + const rows = packages + .filter((item) => item.tenantId === String(params[0]) && item.userId === String(params[1])) + .slice(0, Number(params[2])); + return [rows.map((item) => ({ + id: item.id, + planId: item.planId, + name: item.name, + status: item.status, + priceCents: item.priceCents, + minutesTotal: item.minutesTotal, + amountCentsTotal: item.amountCentsTotal, + remainingMinutes: item.remainingMinutes, + remainingAmountCents: item.remainingAmountCents, + validFrom: item.validFrom, + validTo: item.validTo, + storeId: item.storeId, + roomCategoryId: item.roomCategoryId, + roomId: item.roomId, + holidayOnly: item.holidayOnly, + frozenOrderId: item.frozenOrderId + })), []]; + } if (sql.includes('FROM qipai_recharge_orders')) { const tenantId = String(params[0]); const userId = String(params[1]); @@ -306,6 +439,18 @@ const forbiddenReader = { assert.equal(member.benefits.packageMinutes, 180); } +{ + const service = createService(); + const benefits = await service.getMyBenefits({ tenantId: '7', userId: '21' }); + assert.equal(benefits.coupons.length, 2); + assert.equal(benefits.coupons[0].couponGrantId, '401'); + assert.equal(benefits.coupons[0].discountAmountCents, 3000); + assert.equal(benefits.coupons[1].frozenOrderId, '301'); + assert.equal(benefits.packages.length, 2); + assert.equal(benefits.packages[0].packageHoldingId, '501'); + assert.equal(benefits.packages[0].remainingAmountCents, 3000); +} + { const service = createService(); const scoped = await service.listMembers({ diff --git a/docs/current-baseline.md b/docs/current-baseline.md index 7838907..199da51 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 检查脚本。 ## 总体结论 @@ -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 index 244e488..f387fe5 100644 --- a/docs/devlogs/2026-06-24-M08-A-顾客端.md +++ b/docs/devlogs/2026-06-24-M08-A-顾客端.md @@ -129,3 +129,31 @@ M08-A 仍为 `PARTIAL`。本次完成顾客端第一段工程闭环,但订单 - 工程提交:`dbdfe3d` - 远端校验:待本轮 push 后执行 `HEAD == origin/main`。 + +## 2026-06-25 续接:顾客端权益明细与微信支付调起 + +- 后端 `MemberProfileService` 新增 `getMyBenefits`,按当前登录顾客读取本人优惠券和套餐持有明细,返回状态、金额、分钟、剩余次数、有效期、适用范围和冻结订单,不暴露其他用户权益。 +- 新增 `GET /app-api/profile/benefits`,复用登录态和 `profile.read` 权限校验,只允许读取当前用户本人权益。 +- `backend/tests/member-profile-service.test.mjs` 与 `backend/tests/member-profile-route.test.mjs` 扩展覆盖权益明细查询和本人鉴权。 +- 小程序新增 `pages/benefits/index` 权益明细页,个人中心新增入口,可展示优惠券和套餐的可用/锁定/已用尽状态、金额、分钟、有效期和适用范围。 +- 小程序 `utils/api.js` 新增 `requestWechatPayment`,房间下单页新增“微信支付”按钮:先创建 `WECHAT` 支付单,再调用 `/pay/wechat/prepay`,仅在后端返回真实预支付参数后调用 `wx.requestPayment`;测试支付路径保留。 +- `scripts/check-miniapp-m08-a.mjs` 扩展检查权益页注册、`/profile/benefits`、`/pay/wechat/prepay` 和 `wx.requestPayment`。 + +验收: + +- `npm run build`(`backend/`):PASS。 +- `node scripts/check-miniapp-m08-a.mjs`:PASS。 +- `node tests/member-profile-service.test.mjs`:PASS。 +- `node tests/member-profile-route.test.mjs`:PASS。 +- `node tests/marketing-benefit-service.test.mjs`:PASS。 +- `npm test`(`backend/`):PASS。 +- `powershell -ExecutionPolicy Bypass -File scripts\dev\windows\test-all.ps1`:PASS。 + +状态: + +- M08-A 仍为 `PARTIAL`;充值微信支付调起、余额/套餐/优惠券下单抵扣深度集成、真机合法域名、真实微信支付和实物开门验证待继续。 + +提交: + +- 工程提交:`6cb3d2d` +- 远端校验:待本轮 push 后执行 `HEAD == origin/main`。 diff --git a/docs/feature-status.md b/docs/feature-status.md index 976f0cc..806e3cd 100644 --- a/docs/feature-status.md +++ b/docs/feature-status.md @@ -17,7 +17,7 @@ | 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 | `dbdfe3d` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口、订单权限开门入口、续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总和充值计划/充值单入口;`scripts/check-miniapp-m08-a.mjs` 与后端全量测试通过。 | 优惠券/套餐明细、多角色菜单、生产支付调起和真机联调尚未完成。 | 继续 M08-A,补顾客端优惠券/套餐明细和生产支付调起。 | +| SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `6cb3d2d` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、订单生产微信预支付调起、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口、订单权限开门入口、续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口和优惠券/套餐明细;`scripts/check-miniapp-m08-a.mjs`、相关会员/权益测试通过。 | 多角色菜单、充值微信支付调起、权益下单抵扣深度集成和真机联调尚未完成。 | 继续 M08-A,补充值微信支付调起和权益下单抵扣集成。 | | 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 完成小程序角色界面与真机登录。 | @@ -32,11 +32,11 @@ | MAP-001 | 地图选店和最近门店 | M03-C/M08 | PARTIAL | `4ac2c96` | 已实现 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/M08 | PARTIAL | `4ac2c96` | 正式订单与分享令牌均执行最小权限;M08-A 门店详情新增登录后受控 Wi-Fi 请求入口,不在未鉴权页面暴露密码。 | 真实到店权限、真机 Wi-Fi 连接和设备权限仍需现场联调。 | M08-A 补有效订单下的 Wi-Fi 真机连接验证。 | -| ORD-001 | 预约下单与多方式支付 | M04/M05/M08 | PARTIAL | `874b9b8` | 已完成订单全生命周期、微信支付 v3 工程闭环和团购支付;M08-A 小程序已接入报价、预占、测试支付、订单列表、订单详情、状态历史、取消测算、取消订单、续费和同门店换房入口,后端全量测试通过。 | 缺真实微信和团购平台授权联调;生产微信支付调起和余额/套餐支付入口待补。 | M08-A 继续补生产支付调起和权益支付。 | +| ORD-001 | 预约下单与多方式支付 | M04/M05/M08 | PARTIAL | `6cb3d2d` | 已完成订单全生命周期、微信支付 v3 工程闭环和团购支付;M08-A 小程序已接入报价、预占、测试支付、订单生产微信预支付调起、订单列表、订单详情、状态历史、取消测算、取消订单、续费和同门店换房入口。 | 缺真实微信和团购平台授权联调;充值微信支付调起、余额/套餐支付和下单权益抵扣入口待补。 | M08-A 继续补充值微信支付调起和权益支付。 | | WAL-001 | 充值优惠 | M07-B/M08 | PARTIAL | `dbdfe3d` | 已建立充值规则和充值订单表,支持有效期、适用门店、限购、启停、clientRequestId 幂等;M08-A 新增顾客端充值计划查询、充值单创建 API 和小程序充值页;支付成功后调用钱包入账,重复支付回调不重复充值;Windows 全量后端回归通过。 | 真实微信充值支付调起和回调仍需 M05 生产凭据与后续联调。 | M08-A 继续接入充值微信支付调起。 | | WAL-002 | 门店独立会员余额 | M07-A | DONE | `63711ad` | 已建立租户/门店余额范围策略、现金/赠送双余额账户和不可变流水;充值、赠送、消费、退款、人工调整均预留 entry type;消费按赠送后现金扣款,业务号幂等,余额不足拒绝;Windows 全量后端回归通过。 | - | M07-D 聚合会员余额和权益状态。 | | WAL-003 | 余额账单 | M07-A/M08 | PARTIAL | `7446852` | 账单底表已包含业务类型、业务号、现金/赠送变动、变动后余额、操作人、trace 和 metadata;M07-B 充值成功后写入真实充值流水;M08-A 个人中心已展示最近钱包账单。 | 完整分页账单页面待继续;优惠券/套餐不直接写钱包流水。 | M08-A 增加分页账单或权益明细入口。 | -| WAL-004 | 优惠券和套餐权益 | M07-C/M08 | PARTIAL | `7446852` | 已建立优惠券模板、持券、套餐计划、持有和权益核销流水;支持满减券、时长券、适用门店/房型/房间/星期/节假日、套餐剩余时长/金额,以及订单冻结、确认核销、退回和重复请求幂等;M08-A 个人中心已展示可用/冻结券和套餐汇总。 | 前端优惠券/套餐明细、发放/购买/核销入口和订单 API 深度集成待 M08/M04 后续接入。 | M08-A 继续接入权益明细与下单使用入口。 | +| WAL-004 | 优惠券和套餐权益 | M07-C/M08 | PARTIAL | `6cb3d2d` | 已建立优惠券模板、持券、套餐计划、持有和权益核销流水;支持满减券、时长券、适用门店/房型/房间/星期/节假日、套餐剩余时长/金额,以及订单冻结、确认核销、退回和重复请求幂等;M08-A 个人中心已展示可用/冻结券和套餐汇总,并新增本人优惠券/套餐明细页。 | 发放/购买/核销入口和订单 API 深度集成待 M08/M04 后续接入。 | M08-A 继续接入下单使用权益入口。 | | MEM-001 | 会员管理 | M07-D/M08 | PARTIAL | `7446852` | 已实现后台会员聚合查询服务和 `/admin-api/members`、`/admin-api/members/:id`;新增顾客端 `/app-api/profile` 和小程序个人中心,可查看注册/最近登录、订单数、消费额、余额、充值、优惠券、套餐和最近钱包流水,手机号脱敏,租户/门店范围及本人读取通过测试。 | 管理员赠券、余额人工调整、禁用、备注和后台前端页面需在 M09/M08 管理端接入并复用既有审计能力。 | M08-A 继续补顾客端权益明细;M09 接入后台会员管理页面。 | | GRP-001 | 团购券兑换 | M05-C | PARTIAL | `cda640b` | 已实现美团/点评、抖音、快手统一适配器,支持顾客粘贴/扫码值、管理员人工核销、Mock/API 核销、clientRequestId 幂等和券码 SHA-256 存储;重复券不能再次记账,WSL MySQL 8.4.9 实测通过。 | 缺各平台商家 API 授权,未执行真实厂商核销。 | 取得授权后配置 API endpoint/token 并执行真实验券。 | | GRP-002 | 美团直订 | M05-C | PARTIAL | `cda640b` | 已实现 HMAC 回调、事件幂等、外部门店/房间映射、未映射人工队列、顾客认领和团购支付订单生成;映射成功订单可进入 PAID。 | 缺美团开放平台授权和真实回调协议确认。 | 取得授权后按厂商协议实现专用签名适配并联调。 | diff --git a/docs/module-status.md b/docs/module-status.md index 5ff2cd2..04af268 100644 --- a/docs/module-status.md +++ b/docs/module-status.md @@ -7,11 +7,11 @@ execution_cursor: stage_status: PARTIAL last_completed_stage: M07-D next_stage: M08-A - last_engineering_commit: `dbdfe3d` + last_engineering_commit: `6cb3d2d` last_push_verified: true base_branch: main blocked_reason: "M06-G 真实硬件联调缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线;已完成烟测运行器并按 BLOCKED_EXTERNAL 旁路。" - updated_at: 2026-06-25T00:00:00+08:00 + updated_at: 2026-06-25T12:30: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 微信原生小程序完整业务 | PARTIAL | `dbdfe3d` | docs/devlogs/2026-06-24-M08-A-顾客端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总和充值计划/充值单入口;优惠券/套餐明细和生产支付继续留在 M08-A。 | +| M08 微信原生小程序完整业务 | PARTIAL | `6cb3d2d` | docs/devlogs/2026-06-24-M08-A-顾客端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、订单生产微信预支付调起、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口和优惠券/套餐明细;充值微信支付调起、权益下单抵扣深度集成和真机联调继续留在 M08-A。 | | M09 后台管理端完整业务 | TODO | - | - | - | | M10 部署、域名、验收和运维闭环 | TODO | - | - | - | diff --git a/miniapp/app.json b/miniapp/app.json index 340bbb5..aa8dc6d 100644 --- a/miniapp/app.json +++ b/miniapp/app.json @@ -6,6 +6,7 @@ "pages/orders/list", "pages/orders/detail", "pages/profile/index", + "pages/benefits/index", "pages/recharge/index", "pages/logs/logs" ], diff --git a/miniapp/pages/benefits/index.js b/miniapp/pages/benefits/index.js new file mode 100644 index 0000000..17bb8b9 --- /dev/null +++ b/miniapp/pages/benefits/index.js @@ -0,0 +1,80 @@ +const { request, ensureLogin, cents } = require('../../utils/api.js') + +Page({ + data: { + loading: false, + errorMessage: '', + coupons: [], + packages: [], + }, + + onLoad() { + this.loadBenefits() + }, + + async loadBenefits() { + this.setData({ loading: true, errorMessage: '' }) + try { + await ensureLogin() + const response = await request('/profile/benefits') + this.setData({ + coupons: (response.data.coupons || []).map(formatCoupon), + packages: (response.data.packages || []).map(formatPackage), + }) + } catch (error) { + this.setData({ errorMessage: error.message || '权益明细加载失败' }) + } finally { + this.setData({ loading: false }) + } + }, +}) + +function formatCoupon(item) { + const isTime = item.couponType === 'TIME' + return { + ...item, + statusText: benefitStatus(item.status), + valueText: isTime ? `${item.timeMinutes || 0}分钟` : cents(item.discountAmountCents), + thresholdText: Number(item.minOrderAmountCents || 0) > 0 + ? `满${cents(item.minOrderAmountCents)}可用` + : '无门槛', + validText: `${formatDate(item.validFrom)} 至 ${formatDate(item.validTo)}`, + scopeText: scopeText(item), + } +} + +function formatPackage(item) { + return { + ...item, + statusText: benefitStatus(item.status), + priceText: cents(item.priceCents), + remainingAmountText: cents(item.remainingAmountCents), + totalAmountText: cents(item.amountCentsTotal), + validText: `${formatDate(item.validFrom)} 至 ${formatDate(item.validTo)}`, + scopeText: scopeText(item), + } +} + +function benefitStatus(status) { + return ({ + AVAILABLE: '可用', + ACTIVE: '可用', + FROZEN: '锁定中', + USED: '已使用', + EXHAUSTED: '已用尽', + EXPIRED: '已过期', + })[status] || status +} + +function scopeText(item) { + if (item.roomId) return `指定房间 ${item.roomId}` + if (item.roomCategoryId) return `指定房型 ${item.roomCategoryId}` + if (item.storeId) return `指定门店 ${item.storeId}` + return item.holidayOnly ? '节假日可用' : '全部门店可用' +} + +function formatDate(value) { + const date = new Date(value) + const pad = (input) => String(input).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} diff --git a/miniapp/pages/benefits/index.json b/miniapp/pages/benefits/index.json new file mode 100644 index 0000000..a16869e --- /dev/null +++ b/miniapp/pages/benefits/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "权益明细" +} diff --git a/miniapp/pages/benefits/index.wxml b/miniapp/pages/benefits/index.wxml new file mode 100644 index 0000000..c8cd8d9 --- /dev/null +++ b/miniapp/pages/benefits/index.wxml @@ -0,0 +1,43 @@ + + + 权益明细 + {{errorMessage}} + + 优惠券 + 暂无优惠券 + + + {{item.name}} + {{item.statusText}} + + {{item.valueText}} + {{item.thresholdText}} + {{item.scopeText}} + {{item.validText}} + 剩余次数:{{item.remainingUses}} + + + 套餐 + 暂无套餐 + + + {{item.name}} + {{item.statusText}} + + + 剩余分钟 + {{item.remainingMinutes}} / {{item.minutesTotal}} + + + 剩余金额 + {{item.remainingAmountText}} / {{item.totalAmountText}} + + + 购买价 + {{item.priceText}} + + {{item.scopeText}} + {{item.validText}} + + + diff --git a/miniapp/pages/benefits/index.wxss b/miniapp/pages/benefits/index.wxss new file mode 100644 index 0000000..95cc60e --- /dev/null +++ b/miniapp/pages/benefits/index.wxss @@ -0,0 +1,72 @@ +.scrollarea { + height: 100vh; + background: #f5f6f8; +} + +.page { + padding: 32rpx; +} + +.title { + margin-bottom: 24rpx; + font-size: 40rpx; + font-weight: 600; +} + +.section-title { + margin: 28rpx 0 16rpx; + font-size: 32rpx; + font-weight: 600; +} + +.benefit-card { + margin-top: 20rpx; + padding: 28rpx; + border-radius: 16rpx; + background: #ffffff; +} + +.card-head, +.row { + display: flex; + justify-content: space-between; + gap: 24rpx; +} + +.benefit-name { + font-size: 32rpx; + font-weight: 600; +} + +.status { + color: #1f6feb; + white-space: nowrap; +} + +.benefit-value { + margin-top: 16rpx; + color: #b42318; + font-size: 44rpx; + font-weight: 700; +} + +.row { + margin-top: 16rpx; + color: #475467; +} + +.muted { + margin-top: 8rpx; + color: #667085; +} + +.empty { + padding: 60rpx 0; + color: #888888; + text-align: center; +} + +.error { + margin: 16rpx 0; + color: #c73535; +} diff --git a/miniapp/pages/profile/index.js b/miniapp/pages/profile/index.js index 46c2ec2..8988d86 100644 --- a/miniapp/pages/profile/index.js +++ b/miniapp/pages/profile/index.js @@ -36,6 +36,10 @@ Page({ openRecharge() { wx.navigateTo({ url: '/pages/recharge/index' }) }, + + openBenefits() { + wx.navigateTo({ url: '/pages/benefits/index' }) + }, }) function formatProfile(profile) { diff --git a/miniapp/pages/profile/index.wxml b/miniapp/pages/profile/index.wxml index 1cbf857..94de561 100644 --- a/miniapp/pages/profile/index.wxml +++ b/miniapp/pages/profile/index.wxml @@ -54,6 +54,7 @@ 套餐余额 {{profile.benefits.packageAmountText}} + diff --git a/miniapp/pages/room/detail.js b/miniapp/pages/room/detail.js index f56c00e..e08edfa 100644 --- a/miniapp/pages/room/detail.js +++ b/miniapp/pages/room/detail.js @@ -3,6 +3,7 @@ const { ensureLogin, cents, clientRequestId, + requestWechatPayment, } = require('../../utils/api.js') function defaultStartAt() { @@ -96,6 +97,35 @@ Page({ }) }, + async createWechatPayment() { + if (!this.data.order || !this.data.order.orderId) { + this.setData({ errorMessage: '请先预占订单' }) + return + } + await this.withRequest(async () => { + const paymentResponse = await request('/payments', { + method: 'POST', + data: { + orderId: this.data.order.orderId, + provider: 'WECHAT', + clientRequestId: clientRequestId('miniapp-wxpay'), + }, + }) + const prepayResponse = await request('/pay/wechat/prepay', { + method: 'POST', + data: { paymentId: paymentResponse.data.paymentId }, + }) + await requestWechatPayment(prepayResponse.data) + this.setData({ + payment: { + ...paymentResponse.data, + amountText: cents(paymentResponse.data.amountCents), + }, + successMessage: '微信支付已提交,请稍后查看订单状态', + }) + }) + }, + async completeTestPayment() { if (!this.data.payment || !this.data.payment.paymentId) { this.setData({ errorMessage: '请先创建测试支付单' }) diff --git a/miniapp/pages/room/detail.wxml b/miniapp/pages/room/detail.wxml index c26935f..cddb54b 100644 --- a/miniapp/pages/room/detail.wxml +++ b/miniapp/pages/room/detail.wxml @@ -12,6 +12,7 @@ + diff --git a/miniapp/utils/api.js b/miniapp/utils/api.js index ee1185f..c2a4d05 100644 --- a/miniapp/utils/api.js +++ b/miniapp/utils/api.js @@ -64,6 +64,24 @@ function clientRequestId(prefix) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } +function requestWechatPayment(params) { + return new Promise((resolve, reject) => { + if (!params || !params.timeStamp || !params.nonceStr || !params.package || !params.paySign) { + reject(new Error('微信支付参数不完整')) + return + } + wx.requestPayment({ + timeStamp: String(params.timeStamp), + nonceStr: params.nonceStr, + package: params.package, + signType: params.signType || 'RSA', + paySign: params.paySign, + success: resolve, + fail: reject, + }) + }) +} + module.exports = { request, login, @@ -71,4 +89,5 @@ module.exports = { clearSession, cents, clientRequestId, + requestWechatPayment, } diff --git a/scripts/check-miniapp-m08-a.mjs b/scripts/check-miniapp-m08-a.mjs index 2bc17e3..556cd0b 100644 --- a/scripts/check-miniapp-m08-a.mjs +++ b/scripts/check-miniapp-m08-a.mjs @@ -13,6 +13,7 @@ for (const page of [ 'pages/orders/list', 'pages/orders/detail', 'pages/profile/index', + 'pages/benefits/index', 'pages/recharge/index' ]) { assert.ok(appJson.pages.includes(page), `${page} must be registered`); @@ -25,6 +26,7 @@ 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\}`/); +assert.match(api, /wx\.requestPayment/); const index = read('miniapp/pages/index/index.js'); assert.match(index, /\/stores\?\$\{query\}/); @@ -40,10 +42,12 @@ for (const route of [ '/pricing/quote', '/orders/reserve', '/payments', + '/pay/wechat/prepay', '/test-complete' ]) { assert.match(room, new RegExp(route.replace('/', '\\/'))); } +assert.match(room, /requestWechatPayment/); const orders = read('miniapp/pages/orders/list.js') + read('miniapp/pages/orders/detail.js'); @@ -73,6 +77,18 @@ for (const pattern of [ assert.match(profile, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); } +const benefits = read('miniapp/pages/benefits/index.js') + + read('miniapp/pages/benefits/index.wxml'); +for (const pattern of [ + '/profile/benefits', + 'coupons', + 'packages', + 'couponGrantId', + 'packageHoldingId' +]) { + assert.match(benefits, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +} + const recharge = read('miniapp/pages/recharge/index.js') + read('miniapp/pages/recharge/index.wxml'); for (const pattern of [