Compare commits
2 Commits
a4e13d911e
...
c7c869c18b
| Author | SHA1 | Date | |
|---|---|---|---|
| c7c869c18b | |||
| 874b9b8e29 |
@@ -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)
|
||||
- 最近工程提交:`eaa8654`,完成顾客端订单权限开门入口,按本人有效订单校验后调用门控服务。
|
||||
- 下一工程目标:继续 M08-A,补顾客端续费、换房、分享、余额、优惠券、套餐和个人中心。
|
||||
- 最近工程提交:`874b9b8`,完成顾客端续费、同门店换房和分享口令生成入口。
|
||||
- 下一工程目标:继续 M08-A,补顾客端余额、优惠券、套餐和个人中心。
|
||||
|
||||
## 版本递进
|
||||
|
||||
|
||||
@@ -83,6 +83,14 @@ export class OrderManagementRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async customerRenew(actor: OrderActor, orderId: string, input: {
|
||||
endAt: Date; pricingPolicy: PricingPolicy; reason: string;
|
||||
}) {
|
||||
return this.renewForActor(actor, orderId, input, async (connection, order) => {
|
||||
await this.assertOwner(connection, actor.tenantId, order.id, actor.userId);
|
||||
});
|
||||
}
|
||||
|
||||
async changeRoom(actor: OrderActor, orderId: string, input: {
|
||||
roomId: string; reason: string;
|
||||
}) {
|
||||
@@ -124,6 +132,17 @@ export class OrderManagementRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async customerChangeRoom(actor: OrderActor, orderId: string, input: {
|
||||
roomId: string; reason: string;
|
||||
}) {
|
||||
return this.changeRoomForActor(actor, orderId, input, async (connection, order, target) => {
|
||||
await this.assertOwner(connection, actor.tenantId, order.id, actor.userId);
|
||||
if (target.storeId !== order.storeId) {
|
||||
throw new OrderManagementError('ORDER_ROOM_CHANGE_STORE_INVALID');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async adjustTime(actor: OrderActor, orderId: string, input: {
|
||||
startAt?: Date; endAt?: Date; reason: string;
|
||||
}) {
|
||||
@@ -220,6 +239,93 @@ export class OrderManagementRepository {
|
||||
};
|
||||
}
|
||||
|
||||
private async renewForActor(
|
||||
actor: OrderActor, orderId: string, input: {
|
||||
endAt: Date; pricingPolicy: PricingPolicy; reason: string;
|
||||
},
|
||||
authorize: (connection: PoolConnection, order: OrderRow) => Promise<void>
|
||||
) {
|
||||
return this.transaction(async (connection) => {
|
||||
const order = await this.loadOrder(connection, actor.tenantId, orderId);
|
||||
await authorize(connection, order);
|
||||
const duplicate = await this.duplicate(connection, actor, orderId);
|
||||
if (duplicate) return this.duplicateResult(orderId, duplicate);
|
||||
if (!['PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) {
|
||||
throw new OrderManagementError('ORDER_RENEW_STATUS_INVALID');
|
||||
}
|
||||
if (input.endAt <= order.endAt) throw new OrderManagementError('ORDER_RENEW_END_INVALID');
|
||||
await this.lockRooms(connection, actor.tenantId, [order.roomId]);
|
||||
await this.assertAvailable(
|
||||
connection, actor.tenantId, order.roomId, order.endAt, input.endAt, orderId
|
||||
);
|
||||
const unitPrice = await this.resolveUnitPrice(
|
||||
connection, actor.tenantId, order, input.pricingPolicy
|
||||
);
|
||||
const amountDeltaCents = Math.ceil(
|
||||
(input.endAt.getTime() - order.endAt.getTime()) / 3600000
|
||||
) * unitPrice;
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders
|
||||
SET end_at = ?, total_amount_cents = total_amount_cents + ?,
|
||||
adjustment_amount_cents = adjustment_amount_cents + ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.endAt, amountDeltaCents, amountDeltaCents, actor.tenantId, orderId]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_room_reservations
|
||||
SET ends_at = ?, expires_at = GREATEST(expires_at, ?)
|
||||
WHERE tenant_id = ? AND order_id = ?`,
|
||||
[input.endAt, input.endAt, actor.tenantId, orderId]
|
||||
);
|
||||
return this.record(connection, actor, order, 'RENEW', input.reason, {
|
||||
endAt: order.endAt
|
||||
}, { endAt: input.endAt, pricingPolicy: input.pricingPolicy }, amountDeltaCents);
|
||||
});
|
||||
}
|
||||
|
||||
private async changeRoomForActor(
|
||||
actor: OrderActor, orderId: string, input: { roomId: string; reason: string },
|
||||
authorize: (
|
||||
connection: PoolConnection, order: OrderRow, target: RoomRow
|
||||
) => Promise<void>
|
||||
) {
|
||||
return this.transaction(async (connection) => {
|
||||
const order = await this.loadOrder(connection, actor.tenantId, orderId);
|
||||
const duplicate = await this.duplicate(connection, actor, orderId);
|
||||
if (duplicate) return this.duplicateResult(orderId, duplicate);
|
||||
if (!['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) {
|
||||
throw new OrderManagementError('ORDER_ROOM_CHANGE_STATUS_INVALID');
|
||||
}
|
||||
if (input.roomId === order.roomId) throw new OrderManagementError('ORDER_ROOM_UNCHANGED');
|
||||
await this.lockRooms(connection, actor.tenantId, [order.roomId, input.roomId]);
|
||||
const target = await this.loadRoom(connection, actor.tenantId, input.roomId);
|
||||
await authorize(connection, order, target);
|
||||
await this.assertAvailable(
|
||||
connection, actor.tenantId, target.id, order.startAt, order.endAt, orderId
|
||||
);
|
||||
const oldRoom = await this.loadRoom(connection, actor.tenantId, order.roomId);
|
||||
const hours = Math.ceil((order.endAt.getTime() - order.startAt.getTime()) / 3600000);
|
||||
const amountDeltaCents = hours * (target.basePriceCents - oldRoom.basePriceCents);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_orders
|
||||
SET store_id = ?, room_id = ?,
|
||||
total_amount_cents = GREATEST(0, total_amount_cents + ?),
|
||||
adjustment_amount_cents = adjustment_amount_cents + ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[target.storeId, target.id, amountDeltaCents, amountDeltaCents,
|
||||
actor.tenantId, orderId]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_room_reservations SET room_id = ?
|
||||
WHERE tenant_id = ? AND order_id = ?`,
|
||||
[target.id, actor.tenantId, orderId]
|
||||
);
|
||||
return this.record(connection, actor, order, 'CHANGE_ROOM', input.reason, {
|
||||
storeId: order.storeId, roomId: order.roomId
|
||||
}, { storeId: target.storeId, roomId: target.id }, amountDeltaCents);
|
||||
});
|
||||
}
|
||||
|
||||
private async loadOrder(connection: PoolConnection, tenantId: string, orderId: string) {
|
||||
const [rows] = await connection.execute<OrderRow[]>(
|
||||
`SELECT o.id, o.store_id AS storeId, o.room_id AS roomId, o.status,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
@@ -27,7 +27,8 @@ const noteSchema = z.object({ note: z.string().min(1).max(512) }).strict();
|
||||
|
||||
export interface OrderManagementRouteOptions {
|
||||
repository: Pick<OrderManagementRepository,
|
||||
'renew' | 'changeRoom' | 'adjustTime' | 'note' | 'cancellationQuote'>;
|
||||
'renew' | 'changeRoom' | 'adjustTime' | 'note' | 'cancellationQuote'
|
||||
| 'customerRenew' | 'customerChangeRoom'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
@@ -36,6 +37,34 @@ export interface OrderManagementRouteOptions {
|
||||
export async function registerOrderManagementRoutes(
|
||||
app: FastifyInstance, options: OrderManagementRouteOptions
|
||||
) {
|
||||
app.post('/app-api/orders/:orderId/renew', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = renewSchema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
const actor = customerActor(auth, request);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.customerRenew(actor, params.data.orderId, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/app-api/orders/:orderId/change-room', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = roomSchema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
const actor = customerActor(auth, request);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.customerChangeRoom(actor, params.data.orderId, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/app-api/orders/:orderId/cancellation-quote', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
@@ -82,6 +111,17 @@ export async function registerOrderManagementRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
function customerActor(
|
||||
auth: { tenantId: string; userId: string; access: AccessProfile },
|
||||
request: FastifyRequest
|
||||
): OrderActor {
|
||||
return {
|
||||
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER',
|
||||
source: 'APP', traceId: request.traceId, ip: request.ip,
|
||||
userAgent: String(request.headers['user-agent'] ?? ''), access: auth.access
|
||||
};
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
authorization: string | undefined, options: OrderManagementRouteOptions
|
||||
) {
|
||||
|
||||
@@ -8,6 +8,7 @@ const token = signAccessToken({
|
||||
tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
let called;
|
||||
let customerCalled;
|
||||
const authRepository = {
|
||||
async validateSession() {
|
||||
return {
|
||||
@@ -33,7 +34,15 @@ const app = await buildApp({
|
||||
called = { actor, orderId, body };
|
||||
return { orderId, adjustmentType: 'RENEW', amountDeltaCents: 1200 };
|
||||
},
|
||||
async customerRenew(actor, orderId, body) {
|
||||
customerCalled = { action: 'renew', actor, orderId, body };
|
||||
return { orderId, adjustmentType: 'RENEW', amountDeltaCents: 1800 };
|
||||
},
|
||||
async changeRoom() { throw new Error('not called'); },
|
||||
async customerChangeRoom(actor, orderId, body) {
|
||||
customerCalled = { action: 'changeRoom', actor, orderId, body };
|
||||
return { orderId, adjustmentType: 'CHANGE_ROOM', amountDeltaCents: -500 };
|
||||
},
|
||||
async adjustTime() { throw new Error('not called'); },
|
||||
async note() { throw new Error('not called'); },
|
||||
async cancellationQuote() {
|
||||
@@ -71,6 +80,37 @@ assert.equal(called.orderId, '31');
|
||||
assert.equal(called.actor.traceId, 'm04c-renew-route');
|
||||
assert.equal(called.body.pricingPolicy, 'LOCKED');
|
||||
|
||||
const customerRenewed = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/orders/31/renew',
|
||||
headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm08a-customer-renew' },
|
||||
payload: {
|
||||
endAt: new Date(Date.now() + 10800000).toISOString(),
|
||||
pricingPolicy: 'CURRENT',
|
||||
reason: 'miniapp renew'
|
||||
}
|
||||
});
|
||||
assert.equal(customerRenewed.statusCode, 200);
|
||||
assert.equal(customerCalled.action, 'renew');
|
||||
assert.equal(customerCalled.actor.source, 'APP');
|
||||
assert.equal(customerCalled.actor.traceId, 'm08a-customer-renew');
|
||||
assert.equal(customerCalled.body.pricingPolicy, 'CURRENT');
|
||||
|
||||
const customerChanged = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/orders/31/change-room',
|
||||
headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm08a-customer-change-room' },
|
||||
payload: {
|
||||
roomId: '32',
|
||||
reason: 'miniapp change room'
|
||||
}
|
||||
});
|
||||
assert.equal(customerChanged.statusCode, 200);
|
||||
assert.equal(customerCalled.action, 'changeRoom');
|
||||
assert.equal(customerCalled.actor.source, 'APP');
|
||||
assert.equal(customerCalled.orderId, '31');
|
||||
assert.equal(customerCalled.body.roomId, '32');
|
||||
|
||||
const quote = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/orders/31/cancellation-quote',
|
||||
|
||||
@@ -57,3 +57,26 @@ M08-A 仍为 `PARTIAL`。本次完成顾客端第一段工程闭环,但订单
|
||||
状态:
|
||||
|
||||
- M08-A 仍为 `PARTIAL`;续费、换房、分享、余额、优惠券、套餐、个人中心、真实微信支付调起和真机合法域名/实物开门验证待继续。
|
||||
|
||||
## 2026-06-25 续接:顾客端续费、换房与分享
|
||||
|
||||
- 后端新增顾客专用订单调整入口:`POST /app-api/orders/:orderId/renew` 与 `POST /app-api/orders/:orderId/change-room`。
|
||||
- 顾客续费只允许本人未撤销订单访问,继续复用服务端时段冲突、状态和价格计算;顾客换房额外限制目标房间必须属于原订单门店,避免跨门店改单。
|
||||
- 小程序订单详情页新增续费分钟、换房目标房间 ID、分享有效期输入;续费/换房调用真实 app-api 并显示差价,分享调用 `POST /app-api/orders/:orderId/shares` 生成临时口令。
|
||||
- `scripts/check-miniapp-m08-a.mjs` 扩展检查 `/renew`、`/change-room` 和 `/shares` 调用。
|
||||
|
||||
验收:
|
||||
|
||||
- `npm run build`(`backend/`):PASS。
|
||||
- `node scripts/check-miniapp-m08-a.mjs`:PASS。
|
||||
- `node tests/order-management.test.mjs`:PASS。
|
||||
- `npm test`(`backend/`):PASS。
|
||||
|
||||
状态:
|
||||
|
||||
- M08-A 仍为 `PARTIAL`;余额、优惠券、套餐、个人中心、生产微信支付调起、真机合法域名和实物开门验证待继续。
|
||||
|
||||
提交:
|
||||
|
||||
- 工程提交:`874b9b8`
|
||||
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`。
|
||||
|
||||
@@ -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 | `eaa8654` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口和订单权限开门入口;`scripts/check-miniapp-m08-a.mjs` 与后端全量测试通过。 | 续费、换房、分享、余额、优惠券、套餐、个人中心、多角色菜单和真机联调尚未完成。 | 继续 M08-A,补顾客端续费/换房和会员权益页面,再进入 M08-B。 |
|
||||
| SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `874b9b8` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、受控 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,7 +32,7 @@
|
||||
| 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 | `eea8eea` | 已完成订单全生命周期、微信支付 v3 工程闭环和团购支付;M08-A 小程序已接入报价、预占、测试支付、订单列表、订单详情、状态历史、取消测算和取消订单入口,后端全量测试通过。 | 缺真实微信和团购平台授权联调;小程序续费、换房、生产微信支付调起和余额/套餐支付入口待补。 | M08-A 继续补顾客端续费/换房、生产支付调起和权益支付。 |
|
||||
| ORD-001 | 预约下单与多方式支付 | M04/M05/M08 | PARTIAL | `874b9b8` | 已完成订单全生命周期、微信支付 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 增加分页查询。 |
|
||||
@@ -47,7 +47,7 @@
|
||||
| BKG-004 | 后台设备管理 | M06-B/M08 | PARTIAL | `9144fa8` | 已提供设备入库、列表、拓扑、通道绑定、Sub-1G 绑定、状态、维护和插座控制 API;同一房间控制目标唯一,控制箱与智慧插座重复负载被数据库拒绝。 | Vue 后台页面和真实设备扫码入库待 M08/M06-G。 | M06-F 接入订单自动联动;M08 实现管理页面。 |
|
||||
| IOT-006 | MQTT 消息幂等 | M01/M06-C | DONE | `e795cdb` | 上行按租户、设备、Topic 和 payload SHA-256 唯一;QoS 1 重复消息只增加 `receive_count`,不重复更新命令或设备副作用;WSL MySQL 8.4.9 实测两次 ACK 仅一条事件。 | - | M06-D/E 继续复用同一消费链路。 |
|
||||
| IOT-007 | 命令状态机 | M01/M06-C | DONE | `e795cdb` | 已实现 `PENDING/PUBLISHED/ACKED/FAILED/TIMEOUT/UNKNOWN/CANCELLED` 数据模型,发布失败、ACK 结果、超时和 13 位命令 ID 均有测试;发布成功不等于 ACK。 | - | M06-D/F 接入业务权限、补偿与过期取消。 |
|
||||
| DEV-001 | 一键开门与设备动作 | M06-D/M06-F/M08 | PARTIAL | `eaa8654` | 已提供控制箱控电、磁力锁、TTS、LED、订单任务、Sub-1G 门锁和智慧插座控制服务;M08-A 新增顾客端订单权限开门入口,按本人有效订单和时间窗口校验后下发普通开门命令,命令返回 PUBLISHED 而非假成功。 | 真实硬件属于 M06-G;小程序真机开门、续费/换房后的设备动作仍待联调。 | M08-A 继续补续费/换房入口;取得硬件后执行真实开门矩阵。 |
|
||||
| DEV-001 | 一键开门与设备动作 | M06-D/M06-F/M08 | PARTIAL | `874b9b8` | 已提供控制箱控电、磁力锁、TTS、LED、订单任务、Sub-1G 门锁和智慧插座控制服务;M08-A 新增顾客端订单权限开门入口,按本人有效订单和时间窗口校验后下发普通开门命令,命令返回 PUBLISHED 而非假成功;顾客端续费/同门店换房入口已接入订单调整 API。 | 真实硬件属于 M06-G;小程序真机开门、续费/换房后的设备动作仍待现场联调。 | 取得硬件后执行真实开门、续费和换房设备联动矩阵。 |
|
||||
| IOT-002 | 控制箱接入 | M06-D | PARTIAL | `d15fd3f` | `ConctolPower/Crldoor/PlayTTS/stopTTS/CrlLED/task/addtask/canceltask` 已通过适配器、命令持久化和 API 接入。 | 缺控制箱实物逐路负载、门磁、TTS 和任务回包联调。 | M06-G 执行实物矩阵。 |
|
||||
| IOT-003 | Sub-1G 门锁绑定 | M06-D | PARTIAL | `d15fd3f` | `AddDevice` 下发、超时窗口、回包 subID/subtype、701C/701G 型号映射和父子拓扑自动固化已实现。 | 缺现场 `*789#` 与实物配对。 | M06-G 现场配对、超时和换绑验收。 |
|
||||
| IOT-004 | Sub-1G 门锁控制 | M06-D | PARTIAL | `d15fd3f` | `CtrlDevice` 开关门、密码/卡片和恢复出厂适配器已实现;危险清空/恢复出厂仅平台管理员加确认短语;record 内容哈希脱敏,低电量及 timeout/full/unconfirm 告警。 | 缺 701C/701G 实物及密码/卡片现场验收。 | M06-G 完成实物控制与故障场景。 |
|
||||
|
||||
@@ -7,11 +7,11 @@ execution_cursor:
|
||||
stage_status: PARTIAL
|
||||
last_completed_stage: M07-D
|
||||
next_stage: M08-A
|
||||
last_engineering_commit: `eaa8654`
|
||||
last_engineering_commit: `874b9b8`
|
||||
last_push_verified: true
|
||||
base_branch: main
|
||||
blocked_reason: "M06-G 真实硬件联调缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线;已完成烟测运行器并按 BLOCKED_EXTERNAL 旁路。"
|
||||
updated_at: 2026-06-24T00:00:00+08:00
|
||||
updated_at: 2026-06-25T00: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 微信原生小程序完整业务 | PARTIAL | `eaa8654` | docs/devlogs/2026-06-24-M08-A-顾客端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单和订单权限开门入口;续费、换房、分享、余额、优惠券、套餐和个人中心继续留在 M08-A。 |
|
||||
| M08 微信原生小程序完整业务 | PARTIAL | `874b9b8` | docs/devlogs/2026-06-24-M08-A-顾客端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房和分享口令生成;余额、优惠券、套餐和个人中心继续留在 M08-A。 |
|
||||
| M09 后台管理端完整业务 | TODO | - | - | - |
|
||||
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - |
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ Page({
|
||||
order: null,
|
||||
history: [],
|
||||
cancellation: null,
|
||||
renewMinutes: 60,
|
||||
changeRoomId: '',
|
||||
shareTtlMinutes: 30,
|
||||
shareToken: '',
|
||||
sharePermissions: ['VIEW_ROOM', 'OPEN_DOOR'],
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
@@ -79,6 +84,81 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
onRenewMinutesInput(event) {
|
||||
this.setData({ renewMinutes: Number(event.detail.value || 0) })
|
||||
},
|
||||
|
||||
onChangeRoomInput(event) {
|
||||
this.setData({ changeRoomId: String(event.detail.value || '').trim() })
|
||||
},
|
||||
|
||||
onShareTtlInput(event) {
|
||||
this.setData({ shareTtlMinutes: Number(event.detail.value || 0) })
|
||||
},
|
||||
|
||||
async renewOrder() {
|
||||
if (!this.data.orderId || !this.data.order) return
|
||||
const minutes = Number(this.data.renewMinutes || 0)
|
||||
if (!Number.isFinite(minutes) || minutes < 15) {
|
||||
this.setData({ errorMessage: '续费时长至少 15 分钟' })
|
||||
return
|
||||
}
|
||||
const currentEnd = new Date(this.data.order.endAt)
|
||||
const nextEnd = new Date(currentEnd.getTime() + minutes * 60000)
|
||||
await this.withOrderRequest(async () => {
|
||||
const response = await request(`/orders/${encodeURIComponent(this.data.orderId)}/renew`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
endAt: nextEnd.toISOString(),
|
||||
pricingPolicy: 'CURRENT',
|
||||
reason: `顾客小程序续费 ${minutes} 分钟`,
|
||||
},
|
||||
})
|
||||
await this.loadOrder(this.data.orderId)
|
||||
this.setData({
|
||||
successMessage: `续费已提交,补差 ${cents(response.data.amountDeltaCents)}`,
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async changeRoom() {
|
||||
if (!this.data.orderId || !this.data.changeRoomId) {
|
||||
this.setData({ errorMessage: '请输入目标房间 ID' })
|
||||
return
|
||||
}
|
||||
await this.withOrderRequest(async () => {
|
||||
const response = await request(`/orders/${encodeURIComponent(this.data.orderId)}/change-room`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
roomId: this.data.changeRoomId,
|
||||
reason: '顾客小程序换房',
|
||||
},
|
||||
})
|
||||
await this.loadOrder(this.data.orderId)
|
||||
this.setData({
|
||||
successMessage: `换房已提交,差价 ${cents(response.data.amountDeltaCents)}`,
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async createShare() {
|
||||
if (!this.data.orderId) return
|
||||
const ttlMinutes = Math.min(Math.max(Number(this.data.shareTtlMinutes || 30), 5), 1440)
|
||||
await this.withOrderRequest(async () => {
|
||||
const response = await request(`/orders/${encodeURIComponent(this.data.orderId)}/shares`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
permissions: this.data.sharePermissions,
|
||||
ttlMinutes,
|
||||
},
|
||||
})
|
||||
this.setData({
|
||||
shareToken: response.data.token,
|
||||
successMessage: `分享口令已生成,${response.data.expiresInMinutes} 分钟内有效`,
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async withOrderRequest(work) {
|
||||
this.setData({ loading: true, errorMessage: '', successMessage: '' })
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<view wx:if="{{successMessage}}" class="success">{{successMessage}}</view>
|
||||
|
||||
<view wx:if="{{order}}" class="card">
|
||||
<view class="order-title">{{order.storeName}} · {{order.roomName}}</view>
|
||||
<view class="order-title">{{order.storeName}} / {{order.roomName}}</view>
|
||||
<view class="muted">订单号:{{order.orderNo}}</view>
|
||||
<view class="muted">房间:{{order.roomNo}}</view>
|
||||
<view class="muted">开始:{{order.startText}}</view>
|
||||
@@ -26,6 +26,34 @@
|
||||
<button loading="{{loading}}" bindtap="cancelOrder">取消订单</button>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="section-title">续费</view>
|
||||
<view class="field">
|
||||
<text>分钟</text>
|
||||
<input type="number" value="{{renewMinutes}}" bindinput="onRenewMinutesInput" />
|
||||
</view>
|
||||
<button loading="{{loading}}" bindtap="renewOrder">提交续费</button>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="section-title">换房</view>
|
||||
<view class="field">
|
||||
<text>目标房间 ID</text>
|
||||
<input type="number" value="{{changeRoomId}}" bindinput="onChangeRoomInput" />
|
||||
</view>
|
||||
<button loading="{{loading}}" bindtap="changeRoom">提交换房</button>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="section-title">分享</view>
|
||||
<view class="field">
|
||||
<text>有效分钟</text>
|
||||
<input type="number" value="{{shareTtlMinutes}}" bindinput="onShareTtlInput" />
|
||||
</view>
|
||||
<button loading="{{loading}}" bindtap="createShare">生成分享口令</button>
|
||||
<view wx:if="{{shareToken}}" class="token">{{shareToken}}</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{cancellation}}" class="card">
|
||||
<view class="section-title">取消测算</view>
|
||||
<view class="muted">是否允许:{{cancellation.allowed ? '允许' : '不允许'}}</view>
|
||||
@@ -34,7 +62,7 @@
|
||||
<view class="muted">免费取消分钟线:{{cancellation.cutoffMinutes}}</view>
|
||||
</view>
|
||||
|
||||
<view class="section-title">状态历史</view>
|
||||
<view class="section-title history-title">状态历史</view>
|
||||
<view wx:for="{{history}}" wx:key="id" class="history-item">
|
||||
<view>{{item.action}}:{{item.fromStatus || '-'}} → {{item.toStatus}}</view>
|
||||
<view class="muted">{{item.createdText}}</view>
|
||||
|
||||
@@ -59,6 +59,41 @@
|
||||
flex: 1 1 220rpx;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin: 20rpx 0;
|
||||
}
|
||||
|
||||
.field text {
|
||||
width: 180rpx;
|
||||
color: #475467;
|
||||
}
|
||||
|
||||
.field input {
|
||||
flex: 1;
|
||||
min-height: 72rpx;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid #d0d5dd;
|
||||
border-radius: 8rpx;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.token {
|
||||
margin-top: 16rpx;
|
||||
padding: 16rpx;
|
||||
word-break: break-all;
|
||||
border-radius: 8rpx;
|
||||
background: #f2f4f7;
|
||||
color: #344054;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.success {
|
||||
margin: 16rpx 0;
|
||||
color: #17823b;
|
||||
|
||||
@@ -51,7 +51,10 @@ for (const route of [
|
||||
'/history',
|
||||
'/cancellation-quote',
|
||||
'/cancel',
|
||||
'/open-door'
|
||||
'/open-door',
|
||||
'/renew',
|
||||
'/change-room',
|
||||
'/shares'
|
||||
]) {
|
||||
assert.match(orders, new RegExp(route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user