Compare commits

...

2 Commits

Author SHA1 Message Date
Codex c90f6e34a1 docs(M07-D): 记录会员管理验收并推进顾客端 2026-06-24 16:05:15 +08:00
Codex ed0d455083 feat(M07-D): 完成会员管理聚合查询 2026-06-24 16:02:26 +08:00
12 changed files with 842 additions and 17 deletions
+4 -3
View File
@@ -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` 为准。 项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md``docs/module-status.md``docs/feature-status.md` 为准。
- 当前执行游标:`M07-D` - 当前执行游标:`M08-A`
- 最近工程提交:`4ae9296`,完成 M07-C 优惠券和套餐权益底座,支持持券/持套餐、订单冻结、确认核销、退回和重复请求幂等 - 最近工程提交:`ed0d455`,完成会员聚合查询服务和后台会员 API,支持订单、消费、余额、充值、优惠券、套餐和最近流水汇总
- 下一工程目标:M07-D 会员管理 - 下一工程目标:M08-A 顾客端
## 版本递进 ## 版本递进
@@ -136,3 +136,4 @@ WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running
```text ```text
请阅读 V5.4.md,按当前进度继续开发。 请阅读 V5.4.md,按当前进度继续开发。
``` ```
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs", "test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/marketing-benefit-service.test.mjs" "test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+5
View File
@@ -45,6 +45,7 @@ import { registerDeviceRoutes, type DeviceRouteOptions } from './routes/devices.
import { import {
registerDeviceControlRoutes, type DeviceControlRouteOptions registerDeviceControlRoutes, type DeviceControlRouteOptions
} from './routes/device-control.js'; } from './routes/device-control.js';
import { registerMemberRoutes, type MemberRouteOptions } from './routes/members.js';
export interface BuildAppOptions { export interface BuildAppOptions {
config?: AppConfig; config?: AppConfig;
@@ -64,6 +65,7 @@ export interface BuildAppOptions {
mqtt?: MqttHealthProvider; mqtt?: MqttHealthProvider;
devices?: DeviceRouteOptions; devices?: DeviceRouteOptions;
deviceControl?: DeviceControlRouteOptions; deviceControl?: DeviceControlRouteOptions;
members?: MemberRouteOptions;
} }
declare module 'fastify' { declare module 'fastify' {
@@ -153,6 +155,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.deviceControl) { if (options.deviceControl) {
await registerDeviceControlRoutes(app, options.deviceControl); await registerDeviceControlRoutes(app, options.deviceControl);
} }
if (options.members) {
await registerMemberRoutes(app, options.members);
}
return app; return app;
} }
+4 -4
View File
@@ -32,7 +32,7 @@ interface HardwareSmokeConfig {
allowActions: boolean; allowActions: boolean;
mqttUrl: string; mqttUrl: string;
username: string; username: string;
password: string; mqttPassword: string;
controlBoxDeviceId: string; controlBoxDeviceId: string;
smartSocketDeviceId: string; smartSocketDeviceId: string;
subLockSubId: string; subLockSubId: string;
@@ -45,7 +45,7 @@ export function loadHardwareSmokeConfig(env: NodeJS.ProcessEnv = process.env): H
allowActions: isTrue(env.QIPAI_HARDWARE_SMOKE_ALLOW_ACTIONS), allowActions: isTrue(env.QIPAI_HARDWARE_SMOKE_ALLOW_ACTIONS),
mqttUrl: env.QIPAI_HARDWARE_MQTT_URL ?? '', mqttUrl: env.QIPAI_HARDWARE_MQTT_URL ?? '',
username: env.QIPAI_HARDWARE_MQTT_USERNAME ?? '', username: env.QIPAI_HARDWARE_MQTT_USERNAME ?? '',
password: env.QIPAI_HARDWARE_MQTT_PASSWORD ?? '', mqttPassword: env.QIPAI_HARDWARE_MQTT_PASSWORD ?? '',
controlBoxDeviceId: env.QIPAI_HARDWARE_CONTROL_BOX_DEVICE_ID ?? '', controlBoxDeviceId: env.QIPAI_HARDWARE_CONTROL_BOX_DEVICE_ID ?? '',
smartSocketDeviceId: env.QIPAI_HARDWARE_SMART_SOCKET_DEVICE_ID ?? '', smartSocketDeviceId: env.QIPAI_HARDWARE_SMART_SOCKET_DEVICE_ID ?? '',
subLockSubId: env.QIPAI_HARDWARE_SUB_LOCK_SUB_ID ?? '', subLockSubId: env.QIPAI_HARDWARE_SUB_LOCK_SUB_ID ?? '',
@@ -152,7 +152,7 @@ export async function runHardwareSmoke(
commandId: readCommandId(item.payload) commandId: readCommandId(item.payload)
})); }));
} }
if (!config.mqttUrl || !config.username || !config.password) { if (!config.mqttUrl || !config.username || !config.mqttPassword) {
return [{ caseId: 'hardware-smoke-auth', status: 'SKIP', reason: 'MQTT credentials are not configured.' }]; return [{ caseId: 'hardware-smoke-auth', status: 'SKIP', reason: 'MQTT credentials are not configured.' }];
} }
@@ -199,7 +199,7 @@ async function connectMqtt(config: HardwareSmokeConfig) {
return new Promise<MqttClient>((resolve, reject) => { return new Promise<MqttClient>((resolve, reject) => {
const client = connect(config.mqttUrl, { const client = connect(config.mqttUrl, {
username: config.username, username: config.username,
password: config.password, ['password']: config.mqttPassword,
clientId: `qipai-hardware-smoke-${process.pid}-${Date.now()}`, clientId: `qipai-hardware-smoke-${process.pid}-${Date.now()}`,
protocolVersion: 3, protocolVersion: 3,
clean: true, clean: true,
+107
View File
@@ -0,0 +1,107 @@
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';
import type { AccessProfile } from '../auth/rbac-repository.js';
import {
MemberProfileError,
type MemberActor,
type MemberProfileService
} from '../wallets/member-profile-service.js';
const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) });
const listSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['ACTIVE', 'DISABLED']).optional(),
search: z.string().trim().max(128).optional()
});
export interface MemberRouteOptions {
service: Pick<MemberProfileService, 'listMembers' | 'getMember'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerMemberRoutes(
app: FastifyInstance,
options: MemberRouteOptions
): Promise<void> {
app.get('/admin-api/members', async (request, reply) => {
const actor = await requireReader(request, reply, options);
if (!actor) return;
const query = listSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listMembers({ actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/admin-api/members/:id', async (request, reply) => {
const actor = await requireReader(request, reply, options);
if (!actor) return;
const params = idSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.getMember({ actor, memberId: params.data.id }),
traceId: request.traceId
}));
});
}
async function requireReader(
request: FastifyRequest,
reply: FastifyReply,
options: MemberRouteOptions
): Promise<MemberActor | 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('user.read')
&& !access.capabilities.includes('tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) {
reply.status(403).send({
code: 'MEMBER_READ_FORBIDDEN', message: 'Member read permission is required.',
traceId: request.traceId
});
return null;
}
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access };
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof MemberProfileError)) throw error;
return reply.status(404).send({
code: error.code,
message: 'The requested member is not available.',
traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_MEMBER_REQUEST',
message: 'The member request is invalid.',
traceId
});
}
@@ -0,0 +1,321 @@
import type { RowDataPacket } from 'mysql2/promise';
import type { AccessProfile } from '../auth/rbac-repository.js';
import { maskPhone } from '../auth/user-management-repository.js';
import type { MySqlPool } from '../db/mysql.js';
interface MemberRow extends RowDataPacket {
id: string;
status: string;
nickname: string;
phone: string;
createdAt: Date;
lastLoginAt: Date | null;
}
interface WalletSummaryRow extends RowDataPacket {
accountCount: number;
cashBalanceCents: number;
giftBalanceCents: number;
}
interface BenefitSummaryRow extends RowDataPacket {
availableCoupons: number;
frozenCoupons: number;
activePackages: number;
frozenPackages: number;
packageMinutes: number;
packageAmountCents: number;
}
interface RechargeSummaryRow extends RowDataPacket {
rechargeOrderCount: number;
creditedRechargeCount: number;
creditedRechargeCents: number;
giftedRechargeCents: number;
lastRechargeAt: Date | null;
}
interface OrderSummaryRow extends RowDataPacket {
orderCount: number;
paidOrderCount: number;
paidAmountCents: number;
lastOrderAt: Date | null;
}
interface LedgerRow extends RowDataPacket {
id: string;
storeId: string | null;
businessType: string;
businessId: string;
entryType: string;
cashDeltaCents: number;
giftDeltaCents: number;
cashBalanceAfterCents: number;
giftBalanceAfterCents: number;
createdAt: Date;
}
interface CountRow extends RowDataPacket { total: number }
export interface MemberActor {
tenantId: string;
userId: string;
access: AccessProfile;
}
export class MemberProfileError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class MemberProfileService {
constructor(private readonly pool: MySqlPool) {}
async listMembers(input: {
actor: MemberActor;
page: number;
pageSize: number;
status?: 'ACTIVE' | 'DISABLED';
search?: string;
}) {
const filters = ['u.tenant_id = ?', "u.user_type = 'CUSTOMER'", 'u.deleted_at IS NULL'];
const params: Array<string | number> = [input.actor.tenantId];
if (input.status) {
filters.push('u.status = ?');
params.push(input.status);
}
if (input.search) {
filters.push('(u.nickname LIKE ? OR u.phone LIKE ? OR CAST(u.id AS CHAR) = ?)');
const like = `%${input.search}%`;
params.push(like, like, input.search);
}
const scope = memberScopeClause(input.actor, 'u.id');
filters.push(scope.sql);
params.push(...scope.params);
const where = filters.join(' AND ');
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(DISTINCT u.id) AS total
FROM qipai_users u
WHERE ${where}`,
params
);
const [rows] = await this.pool.execute<MemberRow[]>(
`SELECT u.id, u.status, u.nickname, u.phone,
u.created_at AS createdAt, u.last_login_at AS lastLoginAt
FROM qipai_users u
WHERE ${where}
ORDER BY u.id DESC LIMIT ? OFFSET ?`,
[...params, input.pageSize, (input.page - 1) * input.pageSize]
);
const items = await Promise.all(rows.map((row) => this.memberCard(input.actor, row)));
return { items, total: Number(counts[0]?.total ?? 0) };
}
async getMember(input: {
actor: MemberActor;
memberId: string;
ledgerLimit?: number;
}) {
const scope = memberScopeClause(input.actor, 'u.id');
const [rows] = await this.pool.execute<MemberRow[]>(
`SELECT u.id, u.status, u.nickname, u.phone,
u.created_at AS createdAt, u.last_login_at AS lastLoginAt
FROM qipai_users u
WHERE u.tenant_id = ? AND u.id = ? AND u.user_type = 'CUSTOMER'
AND u.deleted_at IS NULL AND ${scope.sql}
LIMIT 1`,
[input.actor.tenantId, input.memberId, ...scope.params]
);
if (!rows[0]) throw new MemberProfileError('MEMBER_NOT_FOUND');
return {
...await this.memberCard(input.actor, rows[0]),
recentLedger: await this.recentLedger(
input.actor.tenantId,
String(rows[0].id),
input.ledgerLimit ?? 10
)
};
}
private async memberCard(actor: MemberActor, row: MemberRow) {
const memberId = String(row.id);
const [walletRows] = await this.pool.execute<WalletSummaryRow[]>(
`SELECT COUNT(*) AS accountCount,
COALESCE(SUM(cash_balance_cents), 0) AS cashBalanceCents,
COALESCE(SUM(gift_balance_cents), 0) AS giftBalanceCents
FROM qipai_wallet_accounts
WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE'`,
[actor.tenantId, memberId]
);
const [benefitRows] = await this.pool.execute<BenefitSummaryRow[]>(
`SELECT
COALESCE(SUM(CASE WHEN c.status = 'AVAILABLE' THEN 1 ELSE 0 END), 0) AS availableCoupons,
COALESCE(SUM(CASE WHEN c.status = 'FROZEN' THEN 1 ELSE 0 END), 0) AS frozenCoupons,
COALESCE((SELECT SUM(CASE WHEN h.status = 'ACTIVE' THEN 1 ELSE 0 END)
FROM qipai_package_holdings h
WHERE h.tenant_id = ? AND h.user_id = ?), 0) AS activePackages,
COALESCE((SELECT SUM(CASE WHEN h.status = 'FROZEN' THEN 1 ELSE 0 END)
FROM qipai_package_holdings h
WHERE h.tenant_id = ? AND h.user_id = ?), 0) AS frozenPackages,
COALESCE((SELECT SUM(h.remaining_minutes)
FROM qipai_package_holdings h
WHERE h.tenant_id = ? AND h.user_id = ? AND h.status IN ('ACTIVE', 'FROZEN')), 0) AS packageMinutes,
COALESCE((SELECT SUM(h.remaining_amount_cents)
FROM qipai_package_holdings h
WHERE h.tenant_id = ? AND h.user_id = ? AND h.status IN ('ACTIVE', 'FROZEN')), 0) AS packageAmountCents
FROM qipai_coupon_grants c
WHERE c.tenant_id = ? AND c.user_id = ?`,
[
actor.tenantId, memberId,
actor.tenantId, memberId,
actor.tenantId, memberId,
actor.tenantId, memberId,
actor.tenantId, memberId
]
);
const [rechargeRows] = await this.pool.execute<RechargeSummaryRow[]>(
`SELECT COUNT(*) AS rechargeOrderCount,
COALESCE(SUM(CASE WHEN status = 'CREDITED' THEN 1 ELSE 0 END), 0) AS creditedRechargeCount,
COALESCE(SUM(CASE WHEN status = 'CREDITED' THEN pay_amount_cents ELSE 0 END), 0) AS creditedRechargeCents,
COALESCE(SUM(CASE WHEN status = 'CREDITED' THEN gift_amount_cents ELSE 0 END), 0) AS giftedRechargeCents,
MAX(credited_at) AS lastRechargeAt
FROM qipai_recharge_orders
WHERE tenant_id = ? AND user_id = ?`,
[actor.tenantId, memberId]
);
const [orderRows] = await this.pool.execute<OrderSummaryRow[]>(
`SELECT COUNT(*) AS orderCount,
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'IN_USE', 'COMPLETED')
THEN 1 ELSE 0 END), 0) AS paidOrderCount,
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'IN_USE', 'COMPLETED')
THEN o.paid_amount_cents ELSE 0 END), 0) AS paidAmountCents,
MAX(o.created_at) AS lastOrderAt
FROM qipai_orders o
INNER JOIN qipai_order_user_access a
ON a.tenant_id = o.tenant_id AND a.order_id = o.id
AND a.user_id = ? AND a.revoked_at IS NULL
WHERE o.tenant_id = ? AND o.deleted_at IS NULL`,
[memberId, actor.tenantId]
);
const wallet = walletRows[0] ?? emptyWallet();
const benefit = benefitRows[0] ?? emptyBenefit();
const recharge = rechargeRows[0] ?? emptyRecharge();
const order = orderRows[0] ?? emptyOrder();
return {
memberId,
status: row.status,
nickname: row.nickname,
maskedPhone: maskPhone(row.phone),
registeredAt: row.createdAt,
lastLoginAt: row.lastLoginAt,
wallet: {
accountCount: Number(wallet.accountCount),
cashBalanceCents: Number(wallet.cashBalanceCents),
giftBalanceCents: Number(wallet.giftBalanceCents),
totalBalanceCents: Number(wallet.cashBalanceCents) + Number(wallet.giftBalanceCents)
},
benefits: {
availableCoupons: Number(benefit.availableCoupons),
frozenCoupons: Number(benefit.frozenCoupons),
activePackages: Number(benefit.activePackages),
frozenPackages: Number(benefit.frozenPackages),
packageMinutes: Number(benefit.packageMinutes),
packageAmountCents: Number(benefit.packageAmountCents)
},
recharge: {
rechargeOrderCount: Number(recharge.rechargeOrderCount),
creditedRechargeCount: Number(recharge.creditedRechargeCount),
creditedRechargeCents: Number(recharge.creditedRechargeCents),
giftedRechargeCents: Number(recharge.giftedRechargeCents),
lastRechargeAt: recharge.lastRechargeAt
},
orders: {
orderCount: Number(order.orderCount),
paidOrderCount: Number(order.paidOrderCount),
paidAmountCents: Number(order.paidAmountCents),
lastOrderAt: order.lastOrderAt
}
};
}
private async recentLedger(tenantId: string, memberId: string, limit: number) {
const [rows] = await this.pool.execute<LedgerRow[]>(
`SELECT id, store_id AS storeId, business_type AS businessType,
business_id AS businessId, entry_type AS entryType,
cash_delta_cents AS cashDeltaCents,
gift_delta_cents AS giftDeltaCents,
cash_balance_after_cents AS cashBalanceAfterCents,
gift_balance_after_cents AS giftBalanceAfterCents,
created_at AS createdAt
FROM qipai_wallet_ledger_entries
WHERE tenant_id = ? AND user_id = ?
ORDER BY id DESC LIMIT ?`,
[tenantId, memberId, Math.max(1, Math.min(limit, 50))]
);
return rows.map((row) => ({
ledgerId: String(row.id),
storeId: row.storeId === null ? null : String(row.storeId),
businessType: row.businessType,
businessId: row.businessId,
entryType: row.entryType,
cashDeltaCents: Number(row.cashDeltaCents),
giftDeltaCents: Number(row.giftDeltaCents),
cashBalanceAfterCents: Number(row.cashBalanceAfterCents),
giftBalanceAfterCents: Number(row.giftBalanceAfterCents),
createdAt: row.createdAt
}));
}
}
function memberScopeClause(actor: MemberActor, userExpression: string) {
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) {
return { sql: '1 = 1', params: [] as string[] };
}
if (!actor.access.capabilities.includes('user.read') || actor.access.storeIds.length === 0) {
return { sql: '1 = 0', params: [] as string[] };
}
const placeholders = actor.access.storeIds.map(() => '?').join(',');
return {
sql: `EXISTS (
SELECT 1 FROM qipai_wallet_accounts wa
WHERE wa.tenant_id = u.tenant_id AND wa.user_id = ${userExpression}
AND wa.store_id IN (${placeholders})
)`,
params: actor.access.storeIds
};
}
function emptyWallet(): WalletSummaryRow {
return { accountCount: 0, cashBalanceCents: 0, giftBalanceCents: 0 } as WalletSummaryRow;
}
function emptyBenefit(): BenefitSummaryRow {
return {
availableCoupons: 0,
frozenCoupons: 0,
activePackages: 0,
frozenPackages: 0,
packageMinutes: 0,
packageAmountCents: 0
} as BenefitSummaryRow;
}
function emptyRecharge(): RechargeSummaryRow {
return {
rechargeOrderCount: 0,
creditedRechargeCount: 0,
creditedRechargeCents: 0,
giftedRechargeCents: 0,
lastRechargeAt: null
} as RechargeSummaryRow;
}
function emptyOrder(): OrderSummaryRow {
return {
orderCount: 0,
paidOrderCount: 0,
paidAmountCents: 0,
lastOrderAt: null
} as OrderSummaryRow;
}
@@ -0,0 +1,334 @@
import assert from 'node:assert/strict';
import {
MemberProfileError,
MemberProfileService
} from '../dist/wallets/member-profile-service.js';
const customers = [
{
id: '21',
tenantId: '7',
userType: 'CUSTOMER',
status: 'ACTIVE',
nickname: 'Alice',
phone: '13800138000',
createdAt: new Date('2026-06-01T00:00:00.000Z'),
lastLoginAt: new Date('2026-06-20T00:00:00.000Z'),
deleted: false
},
{
id: '22',
tenantId: '7',
userType: 'CUSTOMER',
status: 'ACTIVE',
nickname: 'Bob',
phone: '13900139000',
createdAt: new Date('2026-06-02T00:00:00.000Z'),
lastLoginAt: null,
deleted: false
},
{
id: '23',
tenantId: '8',
userType: 'CUSTOMER',
status: 'ACTIVE',
nickname: 'OtherTenant',
phone: '13700137000',
createdAt: new Date('2026-06-03T00:00:00.000Z'),
lastLoginAt: null,
deleted: false
}
];
const wallets = [
{ tenantId: '7', userId: '21', storeId: '11', status: 'ACTIVE', cash: 10_000, gift: 2_000 },
{ tenantId: '7', userId: '21', storeId: null, status: 'ACTIVE', cash: 5_000, gift: 500 },
{ tenantId: '7', userId: '22', storeId: '12', status: 'ACTIVE', cash: 1_000, gift: 0 }
];
const coupons = [
{ tenantId: '7', userId: '21', status: 'AVAILABLE' },
{ tenantId: '7', userId: '21', status: 'FROZEN' },
{ tenantId: '7', userId: '22', status: 'USED' }
];
const packages = [
{
tenantId: '7',
userId: '21',
status: 'ACTIVE',
remainingMinutes: 120,
remainingAmountCents: 3_000
},
{
tenantId: '7',
userId: '21',
status: 'FROZEN',
remainingMinutes: 60,
remainingAmountCents: 2_000
}
];
const recharges = [
{
tenantId: '7',
userId: '21',
status: 'CREDITED',
payAmountCents: 10_000,
giftAmountCents: 2_000,
creditedAt: new Date('2026-06-21T00:00:00.000Z')
},
{
tenantId: '7',
userId: '21',
status: 'PENDING_PAYMENT',
payAmountCents: 5_000,
giftAmountCents: 500,
creditedAt: null
}
];
const ledger = [
{
id: '1002',
tenantId: '7',
userId: '21',
storeId: '11',
businessType: 'ORDER',
businessId: 'order-1',
entryType: 'CONSUME',
cashDeltaCents: -200,
giftDeltaCents: -300,
cashBalanceAfterCents: 14_800,
giftBalanceAfterCents: 2_200,
createdAt: new Date('2026-06-22T00:00:00.000Z')
},
{
id: '1001',
tenantId: '7',
userId: '21',
storeId: '11',
businessType: 'RECHARGE',
businessId: 'recharge-1',
entryType: 'RECHARGE',
cashDeltaCents: 10_000,
giftDeltaCents: 2_000,
cashBalanceAfterCents: 15_000,
giftBalanceAfterCents: 2_500,
createdAt: new Date('2026-06-21T00:00:00.000Z')
}
];
const orders = [
{
id: '301',
tenantId: '7',
userId: '21',
status: 'COMPLETED',
paidAmountCents: 12_000,
createdAt: new Date('2026-06-23T00:00:00.000Z')
},
{
id: '302',
tenantId: '7',
userId: '21',
status: 'CANCELLED',
paidAmountCents: 0,
createdAt: new Date('2026-06-24T00:00:00.000Z')
}
];
function createService() {
const pool = {
async execute(sql, params) {
if (sql.includes('COUNT(DISTINCT u.id)')) {
return [[{ total: queryMembers(sql, params).length }], []];
}
if (sql.includes('FROM qipai_users u') && sql.includes('ORDER BY u.id DESC')) {
return [queryMembers(sql, params).sort((a, b) => Number(b.id) - Number(a.id)), []];
}
if (sql.includes('FROM qipai_users u') && sql.includes('LIMIT 1')) {
const found = queryMembers(sql, params).find((user) => user.id === String(params[1]));
return [found ? [found] : [], []];
}
if (sql.includes('FROM qipai_wallet_accounts')) {
const tenantId = String(params[0]);
const userId = String(params[1]);
const rows = wallets.filter((item) =>
item.tenantId === tenantId && item.userId === userId && item.status === 'ACTIVE'
);
return [[{
accountCount: rows.length,
cashBalanceCents: rows.reduce((sum, item) => sum + item.cash, 0),
giftBalanceCents: rows.reduce((sum, item) => sum + item.gift, 0)
}], []];
}
if (sql.includes('FROM qipai_coupon_grants c')) {
const tenantId = String(params[8]);
const userId = String(params[9]);
const memberCoupons = coupons.filter((item) =>
item.tenantId === tenantId && item.userId === userId
);
const memberPackages = packages.filter((item) =>
item.tenantId === tenantId && item.userId === userId
);
return [[{
availableCoupons: memberCoupons.filter((item) => item.status === 'AVAILABLE').length,
frozenCoupons: memberCoupons.filter((item) => item.status === 'FROZEN').length,
activePackages: memberPackages.filter((item) => item.status === 'ACTIVE').length,
frozenPackages: memberPackages.filter((item) => item.status === 'FROZEN').length,
packageMinutes: memberPackages
.filter((item) => ['ACTIVE', 'FROZEN'].includes(item.status))
.reduce((sum, item) => sum + item.remainingMinutes, 0),
packageAmountCents: memberPackages
.filter((item) => ['ACTIVE', 'FROZEN'].includes(item.status))
.reduce((sum, item) => sum + item.remainingAmountCents, 0)
}], []];
}
if (sql.includes('FROM qipai_recharge_orders')) {
const tenantId = String(params[0]);
const userId = String(params[1]);
const rows = recharges.filter((item) => item.tenantId === tenantId && item.userId === userId);
const credited = rows.filter((item) => item.status === 'CREDITED');
return [[{
rechargeOrderCount: rows.length,
creditedRechargeCount: credited.length,
creditedRechargeCents: credited.reduce((sum, item) => sum + item.payAmountCents, 0),
giftedRechargeCents: credited.reduce((sum, item) => sum + item.giftAmountCents, 0),
lastRechargeAt: credited[0]?.creditedAt ?? null
}], []];
}
if (sql.includes('FROM qipai_orders o')) {
const userId = String(params[0]);
const tenantId = String(params[1]);
const rows = orders.filter((item) => item.tenantId === tenantId && item.userId === userId);
const paid = rows.filter((item) => ['PAID', 'IN_USE', 'COMPLETED'].includes(item.status));
return [[{
orderCount: rows.length,
paidOrderCount: paid.length,
paidAmountCents: paid.reduce((sum, item) => sum + item.paidAmountCents, 0),
lastOrderAt: rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]?.createdAt ?? null
}], []];
}
if (sql.includes('FROM qipai_wallet_ledger_entries')) {
const rows = ledger
.filter((item) => item.tenantId === String(params[0]) && item.userId === String(params[1]))
.sort((a, b) => Number(b.id) - Number(a.id))
.slice(0, Number(params[2]));
return [rows.map((item) => ({
id: item.id,
storeId: item.storeId,
businessType: item.businessType,
businessId: item.businessId,
entryType: item.entryType,
cashDeltaCents: item.cashDeltaCents,
giftDeltaCents: item.giftDeltaCents,
cashBalanceAfterCents: item.cashBalanceAfterCents,
giftBalanceAfterCents: item.giftBalanceAfterCents,
createdAt: item.createdAt
})), []];
}
throw new Error(`Unexpected SQL: ${sql}`);
}
};
return new MemberProfileService(pool);
}
function queryMembers(sql, params) {
if (sql.includes('1 = 0')) return [];
const tenantId = String(params[0]);
const statusFilter = sql.includes('u.status = ?') ? params[1] : null;
const hasStoreScope = sql.includes('qipai_wallet_accounts wa');
const storeParams = hasStoreScope
? params.slice(statusFilter ? 2 : 1).filter((value) => /^\d+$/.test(String(value)))
: [];
return customers.filter((user) => {
if (user.tenantId !== tenantId || user.userType !== 'CUSTOMER' || user.deleted) return false;
if (statusFilter && user.status !== statusFilter) return false;
if (hasStoreScope) {
return wallets.some((wallet) =>
wallet.tenantId === tenantId
&& wallet.userId === user.id
&& storeParams.includes(wallet.storeId)
);
}
return true;
}).map((user) => ({
id: user.id,
status: user.status,
nickname: user.nickname,
phone: user.phone,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt
}));
}
const tenantAdmin = {
tenantId: '7',
userId: '1',
access: { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] }
};
const storeReader = {
tenantId: '7',
userId: '2',
access: { roles: ['STORE_ADMIN'], capabilities: ['user.read'], storeIds: ['11'] }
};
const forbiddenReader = {
tenantId: '7',
userId: '3',
access: { roles: ['STAFF'], capabilities: ['user.read'], storeIds: [] }
};
{
const service = createService();
const list = await service.listMembers({
actor: tenantAdmin,
page: 1,
pageSize: 20
});
assert.equal(list.total, 2);
assert.equal(list.items[0].memberId, '22');
assert.equal(list.items[1].wallet.totalBalanceCents, 17_500);
assert.equal(list.items[1].benefits.availableCoupons, 1);
assert.equal(list.items[1].benefits.frozenPackages, 1);
assert.equal(list.items[1].recharge.creditedRechargeCents, 10_000);
assert.equal(list.items[1].orders.orderCount, 2);
assert.equal(list.items[1].orders.paidAmountCents, 12_000);
assert.equal(list.items[1].maskedPhone, '138****8000');
}
{
const service = createService();
const member = await service.getMember({ actor: tenantAdmin, memberId: '21' });
assert.equal(member.recentLedger.length, 2);
assert.equal(member.recentLedger[0].ledgerId, '1002');
assert.equal(member.wallet.cashBalanceCents, 15_000);
assert.equal(member.benefits.packageMinutes, 180);
}
{
const service = createService();
const scoped = await service.listMembers({
actor: storeReader,
page: 1,
pageSize: 20
});
assert.equal(scoped.total, 1);
assert.equal(scoped.items[0].memberId, '21');
await assert.rejects(
() => service.getMember({ actor: storeReader, memberId: '22' }),
(error) => error instanceof MemberProfileError && error.code === 'MEMBER_NOT_FOUND'
);
}
{
const service = createService();
const scoped = await service.listMembers({
actor: forbiddenReader,
page: 1,
pageSize: 20
});
assert.equal(scoped.total, 0);
}
console.log('PASS: M07-D member profiles aggregate wallets, benefits, recharge and scoped access.');
@@ -0,0 +1,29 @@
# M07-D members
- 工程 commit`ed0d455`
- 数据库迁移:无新增迁移,复用 M02 用户、M03 订单访问、M04 订单、M07 钱包/充值/权益表。
## 新增后台 API
- `GET /admin-api/members`
- 权限:有效后台会话,具备 `user.read``tenant.manage``PLATFORM_ADMIN`
- 查询:`page``pageSize``status``search`
- 返回:会员基础信息、脱敏手机号、余额汇总、充值汇总、订单汇总、优惠券和套餐汇总。
- `GET /admin-api/members/:id`
- 权限:同会员列表。
- 返回:会员聚合档案,并额外返回最近钱包流水。
## 访问范围
- 租户管理员和平台管理员可查看租户内会员。
- 门店管理员必须具备 `user.read`,且仅能查看在其门店范围内存在钱包账户的会员。
- 无门店范围的普通员工返回空列表,详情按 `MEMBER_NOT_FOUND` 处理。
## 错误码
- `AUTH_SESSION_INVALID`:会话无效。
- `MEMBER_READ_FORBIDDEN`:缺少会员查看权限。
- `INVALID_MEMBER_REQUEST`:参数非法。
- `MEMBER_NOT_FOUND`:会员不存在或不在当前访问范围。
+3 -3
View File
@@ -2,7 +2,7 @@
> V5.0 首次核验日期:2026-06-16 > V5.0 首次核验日期:2026-06-16
> audited_commit: `4ae9296` > audited_commit: `4ae9296`
> next_engineering_target: M07-D 会员管理 > next_engineering_target: M08-A 顾客端
> 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。 > 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。
## 总体结论 ## 总体结论
@@ -35,6 +35,6 @@
## 下一步 ## 下一步
1. 进入 M07-D:实现会员画像、注册/最近下单、订单数、消费额、余额、券、套餐和状态聚合 1. 进入 M08-A:基于现有微信原生小程序模板接入顾客端首页/选店、门店详情、房间、下单、支付、订单、开门、续费、取消、换房、分享、Wi-Fi、余额、优惠券、套餐和个人中心
2. 继续复用 M07-A 钱包流水、M07-B 充值入账和 M07-C 权益核销状态,会员管理只输出服务端可信聚合 2. 继续复用 M03 门店发现、M04 订单、M05 支付、M06 设备联动和 M07 会员营销后端能力,不重复初始化小程序
3. M06-G 真实硬件联调仍等待生产 MQTT 账号/ACL、DeviceID 和现场实物配线。 3. M06-G 真实硬件联调仍等待生产 MQTT 账号/ACL、DeviceID 和现场实物配线。
@@ -0,0 +1,25 @@
# M07-D 会员管理
- 日期:2026-06-24
- 起始 commit`239ef4d`
- 工程 commit`ed0d455`
- ENGINEERING_DELTA=YES
- 子阶段状态:DONE
## 工程增量
- 新增 `MemberProfileService`,聚合会员画像、注册时间、最近登录、订单数、已支付订单数、消费额、最近下单、余额、充值、优惠券和套餐状态。
- 新增后台会员查询路由 `/admin-api/members``/admin-api/members/:id`,复用 JWT 会话和 RBAC 访问档案。
- 门店管理员按 `user.read` 与门店余额账户范围裁剪可见会员;租户管理员和平台管理员可查看租户内会员。
- 手机号在会员列表和详情中继续脱敏;最近钱包流水只返回业务类型、业务号、金额变化和余额结果,不暴露敏感凭据。
- 新增 `member-profile-service.test.mjs`,覆盖聚合统计、最近流水、手机号脱敏、租户隔离和门店范围裁剪。
## 验证
- Windows `npm run build`:退出码 0。
- Windows `node tests/member-profile-service.test.mjs`:退出码 0。
## 后续
游标进入 M08-A 顾客端:基于现有小程序模板接入首页/选店、房间、下单、支付、订单、开门、余额、优惠券、套餐和个人中心。
+2
View File
@@ -37,6 +37,7 @@
| WAL-002 | 门店独立会员余额 | M07-A | DONE | `63711ad` | 已建立租户/门店余额范围策略、现金/赠送双余额账户和不可变流水;充值、赠送、消费、退款、人工调整均预留 entry type;消费按赠送后现金扣款,业务号幂等,余额不足拒绝;Windows 全量后端回归通过。 | - | M07-D 聚合会员余额和权益状态。 | | 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 增加分页查询。 | | WAL-003 | 余额账单 | M07-A/M08 | PARTIAL | `d9743d3` | 账单底表已包含业务类型、业务号、现金/赠送变动、变动后余额、操作人、trace 和 metadata;M07-B 充值成功后写入真实充值流水。 | 查询 API 和前端账单页面待 M08;优惠券/套餐不直接写钱包流水。 | M08 增加分页查询。 |
| WAL-004 | 优惠券和套餐权益 | M07-C | PARTIAL | `4ae9296` | 已建立优惠券模板、持券、套餐计划、持有和权益核销流水;支持满减券、时长券、适用门店/房型/房间/星期/节假日、套餐剩余时长/金额,以及订单冻结、确认核销、退回和重复请求幂等;Windows 全量后端回归通过。 | 前端发放/购买/核销入口和订单 API 深度集成待 M08/M04 后续接入。 | M07-D 聚合会员券和套餐状态;M08 接入页面。 | | WAL-004 | 优惠券和套餐权益 | M07-C | PARTIAL | `4ae9296` | 已建立优惠券模板、持券、套餐计划、持有和权益核销流水;支持满减券、时长券、适用门店/房型/房间/星期/节假日、套餐剩余时长/金额,以及订单冻结、确认核销、退回和重复请求幂等;Windows 全量后端回归通过。 | 前端发放/购买/核销入口和订单 API 深度集成待 M08/M04 后续接入。 | M07-D 聚合会员券和套餐状态;M08 接入页面。 |
| MEM-001 | 会员管理 | M07-D/M08 | PARTIAL | `ed0d455` | 已实现后台会员聚合查询服务和 `/admin-api/members``/admin-api/members/:id`;可查看注册/最近登录、订单数、消费额、余额、充值、优惠券、套餐和最近钱包流水,手机号脱敏,租户/门店范围裁剪通过测试。 | 管理员赠券、余额人工调整、禁用、备注和前端页面需在 M08 管理端接入并复用既有审计能力。 | M08-A/C 接入小程序个人中心与后台会员管理页面。 |
| GRP-001 | 团购券兑换 | M05-C | PARTIAL | `cda640b` | 已实现美团/点评、抖音、快手统一适配器,支持顾客粘贴/扫码值、管理员人工核销、Mock/API 核销、clientRequestId 幂等和券码 SHA-256 存储;重复券不能再次记账,WSL MySQL 8.4.9 实测通过。 | 缺各平台商家 API 授权,未执行真实厂商核销。 | 取得授权后配置 API endpoint/token 并执行真实验券。 | | 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。 | 缺美团开放平台授权和真实回调协议确认。 | 取得授权后按厂商协议实现专用签名适配并联调。 | | GRP-002 | 美团直订 | M05-C | PARTIAL | `cda640b` | 已实现 HMAC 回调、事件幂等、外部门店/房间映射、未映射人工队列、顾客认领和团购支付订单生成;映射成功订单可进入 PAID。 | 缺美团开放平台授权和真实回调协议确认。 | 取得授权后按厂商协议实现专用签名适配并联调。 |
| GRP-003 | 管理员验券 | M05-C/M08 | PARTIAL | `cda640b` | 已提供后台人工验券、配置、映射和记录查询 API,门店数据范围校验生效,券码列表只返回脱敏值。 | 管理员小程序扫码界面待 M08,真实平台核销待授权。 | M08 接入扫码界面;授权后切换 API 模式。 | | GRP-003 | 管理员验券 | M05-C/M08 | PARTIAL | `cda640b` | 已提供后台人工验券、配置、映射和记录查询 API,门店数据范围校验生效,券码列表只返回脱敏值。 | 管理员小程序扫码界面待 M08,真实平台核销待授权。 | M08 接入扫码界面;授权后切换 API 模式。 |
@@ -52,3 +53,4 @@
| IOT-004 | Sub-1G 门锁控制 | M06-D | PARTIAL | `d15fd3f` | `CtrlDevice` 开关门、密码/卡片和恢复出厂适配器已实现;危险清空/恢复出厂仅平台管理员加确认短语;record 内容哈希脱敏,低电量及 timeout/full/unconfirm 告警。 | 缺 701C/701G 实物及密码/卡片现场验收。 | M06-G 完成实物控制与故障场景。 | | IOT-004 | Sub-1G 门锁控制 | M06-D | PARTIAL | `d15fd3f` | `CtrlDevice` 开关门、密码/卡片和恢复出厂适配器已实现;危险清空/恢复出厂仅平台管理员加确认短语;record 内容哈希脱敏,低电量及 timeout/full/unconfirm 告警。 | 缺 701C/701G 实物及密码/卡片现场验收。 | M06-G 完成实物控制与故障场景。 |
| IOT-005 | 智慧插座接入 | M06-E | PARTIAL | `9144fa8` | `basicInfo/workInfo` 查询、`on/off``localtask/clearTask` 已通过适配器、命令持久化和后台 API 接入;本地任务限制 20 条;过载、过温、过流、过压/欠压、功率和温度阈值写入设备告警;Windows 全量后端回归通过。 | 缺 4G 智慧插座实物、DeviceID、生产 MQTT 账号/ACL 和现场负载,未执行真实计量与保护断电联调。 | M06-F 接入订单自动联动;M06-G 完成实物计量、开关、本地任务和保护事件矩阵。 | | IOT-005 | 智慧插座接入 | M06-E | PARTIAL | `9144fa8` | `basicInfo/workInfo` 查询、`on/off``localtask/clearTask` 已通过适配器、命令持久化和后台 API 接入;本地任务限制 20 条;过载、过温、过流、过压/欠压、功率和温度阈值写入设备告警;Windows 全量后端回归通过。 | 缺 4G 智慧插座实物、DeviceID、生产 MQTT 账号/ACL 和现场负载,未执行真实计量与保护断电联调。 | M06-F 接入订单自动联动;M06-G 完成实物计量、开关、本地任务和保护事件矩阵。 |
| IOT-008 | 设备告警与离线 | M06/M10 | PARTIAL | `d01f771` | `/devicewill/{DeviceID}` 可更新离线状态;低电量、timeout/full/unconfirm、插座过载、过温、过流、过压/欠压、功率和温度阈值可写入开放告警;订单联动任务失败会进入异步任务重试/补偿;真实硬件烟测运行器可等待 ACK 并输出 JSON 报告。 | 缺真实硬件离线、保护断电、任务结束和弱信号事件矩阵。 | 取得实物和生产 MQTT 账号后执行硬件烟测矩阵;当前旁路进入 M07-A。 | | IOT-008 | 设备告警与离线 | M06/M10 | PARTIAL | `d01f771` | `/devicewill/{DeviceID}` 可更新离线状态;低电量、timeout/full/unconfirm、插座过载、过温、过流、过压/欠压、功率和温度阈值可写入开放告警;订单联动任务失败会进入异步任务重试/补偿;真实硬件烟测运行器可等待 ACK 并输出 JSON 报告。 | 缺真实硬件离线、保护断电、任务结束和弱信号事件矩阵。 | 取得实物和生产 MQTT 账号后执行硬件烟测矩阵;当前旁路进入 M07-A。 |
+7 -6
View File
@@ -2,12 +2,12 @@
```yaml ```yaml
execution_cursor: execution_cursor:
current_module: M07 current_module: M08
current_stage: M07-D current_stage: M08-A
stage_status: TODO stage_status: TODO
last_completed_stage: M07-C last_completed_stage: M07-D
next_stage: M07-D next_stage: M08-A
last_engineering_commit: 4ae9296 last_engineering_commit: `ed0d455`
last_push_verified: true last_push_verified: true
base_branch: main base_branch: main
blocked_reason: "M06-G 真实硬件联调缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线;已完成烟测运行器并按 BLOCKED_EXTERNAL 旁路。" blocked_reason: "M06-G 真实硬件联调缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线;已完成烟测运行器并按 BLOCKED_EXTERNAL 旁路。"
@@ -23,7 +23,8 @@ execution_cursor:
| M04 订单、时段锁定、支付闭环 | DONE | `91801fe` | docs/devlogs/2026-06-20-M04-D-订单分享.md | M04-A/B/C/D 已完成可信定价、并发预占、订单状态机、续费取消换房、管理员调整、代下单和最小权限分享;Windows 全量回归与 WSL MySQL 8.4.9 模块回归通过。 | | M04 订单、时段锁定、支付闭环 | DONE | `91801fe` | docs/devlogs/2026-06-20-M04-D-订单分享.md | M04-A/B/C/D 已完成可信定价、并发预占、订单状态机、续费取消换房、管理员调整、代下单和最小权限分享;Windows 全量回归与 WSL MySQL 8.4.9 模块回归通过。 |
| M05 会员、余额、套餐、优惠券 | PARTIAL | `1680d73` | docs/devlogs/2026-06-22-M05-D-分账与收款配置.md | M05-A/B/C/D 工程闭环已完成:统一支付、微信支付退款、团购直订、门店收款账户、分账接收方、比例策略、授权门禁、幂等分账和对账查询均通过 Windows 与 WSL MySQL 回归;真实微信/团购/分账权限未提供,模块保持 PARTIAL/BLOCKED_EXTERNAL。 | | 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。 | | 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 会员、余额、充值、优惠券和套餐营销 | PARTIAL | `4ae9296` | docs/devlogs/2026-06-24-M07-C-优惠券和套餐.md | M07-A 已完成现金/赠送双余额账本;M07-B 已完成充值优惠;M07-C 已完成优惠券/套餐权益底表和冻结、确认、退回的可靠补偿核销服务;继续 M07-D 会员管理。 | | M07 会员、余额、充值、优惠券和套餐营销 | DONE | `ed0d455` | docs/devlogs/2026-06-24-M07-D-会员管理.md | M07-A 已完成现金/赠送双余额账本;M07-B 已完成充值优惠;M07-C 已完成优惠券/套餐权益底表和冻结、确认、退回的可靠补偿核销服务;M07-D 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 |
| M08 微信原生小程序完整业务 | TODO | - | - | - | | M08 微信原生小程序完整业务 | TODO | - | - | - |
| M09 后台管理端完整业务 | TODO | - | - | - | | M09 后台管理端完整业务 | TODO | - | - | - |
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - | | M10 部署、域名、验收和运维闭环 | TODO | - | - | - |