Compare commits

...

2 Commits

Author SHA1 Message Date
Codex f7e0c8d063 docs(M08-B): 记录保洁端基础流程进度 2026-06-25 23:22:03 +08:00
Codex 2a4dcd0fd8 feat(M08-B): 接入保洁任务端基础流程 2026-06-25 23:19:04 +08:00
26 changed files with 1186 additions and 23 deletions
+3 -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` 为准。
- 当前执行游标:`M08-A`PARTIAL - 当前执行游标:`M08-B`PARTIAL
- 最近工程提交:`4701674`,完成顾客端权益可视化选择器和时长券折价 - 最近工程提交:`2a4dcd0`,完成保洁任务端基础流程
- 下一工程目标:继续 M08-A,补真机合法域名、真实微信支付和实物开门验证 - 下一工程目标:继续 M08-B,补照片上传、驳回补做、订单结束自动建任务和结算前置能力
## 版本递进 ## 版本递进
+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-query.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/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.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-query.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/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-route.test.mjs"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+5
View File
@@ -50,6 +50,7 @@ import {
} from './routes/device-control.js'; } from './routes/device-control.js';
import { registerMemberRoutes, type MemberRouteOptions } from './routes/members.js'; import { registerMemberRoutes, type MemberRouteOptions } from './routes/members.js';
import { registerRechargeRoutes, type RechargeRouteOptions } from './routes/recharge.js'; import { registerRechargeRoutes, type RechargeRouteOptions } from './routes/recharge.js';
import { registerCleaningRoutes, type CleaningRouteOptions } from './routes/cleaning.js';
export interface BuildAppOptions { export interface BuildAppOptions {
config?: AppConfig; config?: AppConfig;
@@ -72,6 +73,7 @@ export interface BuildAppOptions {
deviceControl?: DeviceControlRouteOptions; deviceControl?: DeviceControlRouteOptions;
members?: MemberRouteOptions; members?: MemberRouteOptions;
recharge?: RechargeRouteOptions; recharge?: RechargeRouteOptions;
cleaning?: CleaningRouteOptions;
} }
declare module 'fastify' { declare module 'fastify' {
@@ -170,6 +172,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.recharge) { if (options.recharge) {
await registerRechargeRoutes(app, options.recharge); await registerRechargeRoutes(app, options.recharge);
} }
if (options.cleaning) {
await registerCleaningRoutes(app, options.cleaning);
}
return app; return app;
} }
+9 -2
View File
@@ -36,13 +36,20 @@ export class RbacRepository {
SELECT ?, r.id, p.id FROM qipai_roles r SELECT ?, r.id, p.id FROM qipai_roles r
INNER JOIN qipai_permissions p ON INNER JOIN qipai_permissions p ON
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read')) (r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
OR (r.code = 'CLEANER'
AND p.code IN ('profile.read', 'cleaning.task.read',
'cleaning.task.write', 'cleaning.statistics.read'))
OR (r.code = 'STORE_ADMIN' OR (r.code = 'STORE_ADMIN'
AND p.code IN ('user.read', 'staff.manage', 'session.reset', AND p.code IN ('user.read', 'staff.manage', 'session.reset',
'store.operation.read', 'store.operation.write', 'store.operation.read', 'store.operation.write',
'device.read', 'device.write')) 'device.read', 'device.write',
'cleaning.task.read', 'cleaning.task.write',
'cleaning.statistics.read'))
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN') OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage', AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
'device.read', 'device.write')) 'device.read', 'device.write',
'cleaning.task.read', 'cleaning.task.write',
'cleaning.statistics.read'))
WHERE r.tenant_id = ?`, WHERE r.tenant_id = ?`,
[tenantId, tenantId] [tenantId, tenantId]
); );
@@ -0,0 +1,286 @@
import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { MySqlPool } from '../db/mysql.js';
export const cleaningTaskStatuses = [
'WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED',
'REJECTED', 'EXEMPT', 'SETTLED', 'CANCELLED'
] as const;
export type CleaningTaskStatus = typeof cleaningTaskStatuses[number];
export class CleaningTaskError extends Error {
constructor(public readonly code: string) { super(code); }
}
export interface CleaningActor {
tenantId: string;
userId: string;
access: AccessProfile;
traceId: string;
}
interface CleaningTaskRow extends RowDataPacket {
id: string;
taskNo: string;
storeId: string;
storeName: string;
roomId: string;
roomName: string;
roomNo: string;
orderId: string | null;
orderNo: string | null;
status: CleaningTaskStatus;
cleanerUserId: string | null;
priority: number;
rewardCents: number;
requirement: string;
photoUrlsJson: string | string[] | null;
rejectReason: string;
claimedAt: Date | null;
startedAt: Date | null;
submittedAt: Date | null;
completedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
interface CountRow extends RowDataPacket { total: number }
interface StatusCountRow extends RowDataPacket { status: CleaningTaskStatus; total: number }
interface AmountRow extends RowDataPacket { amount: number | null }
interface CurrentStatusRow extends RowDataPacket { status: CleaningTaskStatus; cleanerUserId: string | null }
export class CleaningTaskRepository {
constructor(private readonly pool: MySqlPool) {}
async listHall(input: CleaningActor & { page: number; pageSize: number; status?: CleaningTaskStatus }) {
this.assertCleaner(input.access, 'read');
const status = input.status ?? 'WAITING';
const where = [
't.tenant_id = ?',
't.deleted_at IS NULL',
't.status = ?',
storeScopeSql(input.access, 't.store_id')
];
const params: Array<string | number> = [input.tenantId, status];
return this.listByWhere(where, params, input.page, input.pageSize, 't.priority ASC, t.created_at ASC');
}
async listMine(input: CleaningActor & { page: number; pageSize: number; status?: CleaningTaskStatus }) {
this.assertCleaner(input.access, 'read');
const where = [
't.tenant_id = ?',
't.deleted_at IS NULL',
't.cleaner_user_id = ?'
];
const params: Array<string | number> = [input.tenantId, input.userId];
if (input.status) {
where.push('t.status = ?');
params.push(input.status);
}
return this.listByWhere(where, params, input.page, input.pageSize, 't.updated_at DESC, t.id DESC');
}
async claim(input: CleaningActor & { taskId: string }) {
this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId);
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_tasks
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ? AND status = 'WAITING' AND cleaner_user_id IS NULL
AND deleted_at IS NULL`,
[input.userId, input.tenantId, input.taskId]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_NOT_CLAIMABLE');
await this.recordEvent(input, input.taskId, 'WAITING', 'CLAIMED', 'CLAIM', '');
return this.getMineTask(input, input.taskId);
}
async start(input: CleaningActor & { taskId: string }) {
return this.moveMine(input, 'CLAIMED', 'STARTED', 'START', 'started_at', '');
}
async submit(input: CleaningActor & { taskId: string; photoUrls: string[]; note?: string }) {
const task = await this.moveMine(
input,
'STARTED',
'SUBMITTED',
'SUBMIT',
'submitted_at',
input.note ?? '',
{ photo_urls_json: JSON.stringify(input.photoUrls.slice(0, 9)) }
);
return task;
}
async stats(input: CleaningActor) {
this.assertCleaner(input.access, 'read');
const [counts] = await this.pool.execute<StatusCountRow[]>(
`SELECT status, COUNT(*) AS total
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND cleaner_user_id = ? AND deleted_at IS NULL
GROUP BY status`,
[input.tenantId, input.userId]
);
const [amountRows] = await this.pool.execute<AmountRow[]>(
`SELECT COALESCE(SUM(reward_cents), 0) AS amount
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND cleaner_user_id = ? AND status IN ('SUBMITTED', 'COMPLETED')
AND settled_at IS NULL AND deleted_at IS NULL`,
[input.tenantId, input.userId]
);
return {
byStatus: Object.fromEntries(counts.map((row) => [row.status, Number(row.total)])),
pendingSettlementCents: Number(amountRows[0]?.amount ?? 0)
};
}
private async listByWhere(
where: string[],
params: Array<string | number>,
page: number,
pageSize: number,
orderBy: string
) {
const whereSql = where.join(' AND ');
const offset = (page - 1) * pageSize;
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_cleaning_tasks t WHERE ${whereSql}`,
params
);
const [rows] = await this.pool.execute<CleaningTaskRow[]>(
`SELECT t.id, t.task_no AS taskNo, t.store_id AS storeId, s.name AS storeName,
t.room_id AS roomId, r.name AS roomName, r.room_no AS roomNo,
t.order_id AS orderId, o.order_no AS orderNo, t.status,
t.cleaner_user_id AS cleanerUserId, t.priority, t.reward_cents AS rewardCents,
t.requirement, t.photo_urls_json AS photoUrlsJson, t.reject_reason AS rejectReason,
t.claimed_at AS claimedAt, t.started_at AS startedAt,
t.submitted_at AS submittedAt, t.completed_at AS completedAt,
t.created_at AS createdAt, t.updated_at AS updatedAt
FROM qipai_cleaning_tasks t
INNER JOIN qipai_stores s ON s.tenant_id = t.tenant_id AND s.id = t.store_id
INNER JOIN qipai_rooms r ON r.tenant_id = t.tenant_id AND r.id = t.room_id
LEFT JOIN qipai_orders o ON o.tenant_id = t.tenant_id AND o.id = t.order_id
WHERE ${whereSql}
ORDER BY ${orderBy}
LIMIT ? OFFSET ?`,
[...params, pageSize, offset]
);
return { items: rows.map(publicTask), total: Number(counts[0]?.total ?? 0), page, pageSize };
}
private async getMineTask(input: CleaningActor, taskId: string) {
const result = await this.listMine({ ...input, page: 1, pageSize: 50 });
const task = result.items.find((item) => item.id === taskId);
if (!task) throw new CleaningTaskError('CLEANING_TASK_NOT_FOUND');
return task;
}
private async moveMine(
input: CleaningActor & { taskId: string },
from: CleaningTaskStatus,
to: CleaningTaskStatus,
action: string,
timestampColumn: 'started_at' | 'submitted_at',
note: string,
extra?: { photo_urls_json: string }
) {
this.assertCleaner(input.access, 'write');
const setExtra = extra ? ', photo_urls_json = ?' : '';
const params: Array<string | number> = extra
? [extra.photo_urls_json, input.tenantId, input.taskId, input.userId, from]
: [input.tenantId, input.taskId, input.userId, from];
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_tasks
SET status = ?, ${timestampColumn} = UTC_TIMESTAMP(3)${setExtra}
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status = ?
AND deleted_at IS NULL`,
[to, ...params]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await this.recordEvent(input, input.taskId, from, to, action, note);
return this.getMineTask(input, input.taskId);
}
private async assertStoreVisible(input: CleaningActor, taskId: string) {
if (input.access.capabilities.includes('tenant.manage') || input.access.roles.includes('PLATFORM_ADMIN')) return;
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT store_id AS storeId FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[input.tenantId, taskId]
);
const storeId = rows[0]?.storeId;
if (!storeId || !input.access.storeIds.includes(String(storeId))) {
throw new CleaningTaskError('CLEANING_TASK_FORBIDDEN');
}
}
private async recordEvent(
input: CleaningActor,
taskId: string,
from: CleaningTaskStatus,
to: CleaningTaskStatus,
action: string,
note: string
) {
await this.pool.execute(
`INSERT IGNORE INTO qipai_cleaning_task_events
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
[input.tenantId, taskId, from, to, action, input.userId, input.traceId, note.slice(0, 512)]
);
}
private assertCleaner(access: AccessProfile, mode: 'read' | 'write') {
const permission = mode === 'read' ? 'cleaning.task.read' : 'cleaning.task.write';
if (!access.capabilities.includes(permission)
&& !access.capabilities.includes('tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) {
throw new CleaningTaskError('CLEANING_TASK_FORBIDDEN');
}
}
}
function storeScopeSql(access: AccessProfile, storeExpression: string) {
if (access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN')) {
return '1 = 1';
}
if (access.storeIds.length === 0) return '1 = 0';
return `${storeExpression} IN (${access.storeIds.map((id) => Number(id)).join(',')})`;
}
function publicTask(row: CleaningTaskRow) {
return {
id: String(row.id),
taskNo: row.taskNo,
storeId: String(row.storeId),
storeName: row.storeName,
roomId: String(row.roomId),
roomName: row.roomName,
roomNo: row.roomNo,
orderId: row.orderId === null ? null : String(row.orderId),
orderNo: row.orderNo,
status: row.status,
cleanerUserId: row.cleanerUserId === null ? null : String(row.cleanerUserId),
priority: Number(row.priority),
rewardCents: Number(row.rewardCents),
requirement: row.requirement,
photoUrls: parseJsonArray(row.photoUrlsJson),
rejectReason: row.rejectReason,
claimedAt: row.claimedAt,
startedAt: row.startedAt,
submittedAt: row.submittedAt,
completedAt: row.completedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt
};
}
function parseJsonArray(value: string | string[] | null): string[] {
if (Array.isArray(value)) return value.map(String);
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map(String) : [];
} catch {
return [];
}
}
+7 -3
View File
@@ -44,7 +44,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062421_m07a_wallet_ledger.up.sql', 'database/migrations/2026062421_m07a_wallet_ledger.up.sql',
'database/migrations/2026062422_m07b_recharge_plans.up.sql', 'database/migrations/2026062422_m07b_recharge_plans.up.sql',
'database/migrations/2026062423_m07c_benefits.up.sql', 'database/migrations/2026062423_m07c_benefits.up.sql',
'database/migrations/2026062524_m08a_recharge_wechat.up.sql' 'database/migrations/2026062524_m08a_recharge_wechat.up.sql',
'database/migrations/2026062525_m08b_cleaner_tasks.up.sql'
], ],
verify: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', 'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -70,9 +71,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062421_m07a_wallet_ledger.verify.sql', 'database/migrations/2026062421_m07a_wallet_ledger.verify.sql',
'database/migrations/2026062422_m07b_recharge_plans.verify.sql', 'database/migrations/2026062422_m07b_recharge_plans.verify.sql',
'database/migrations/2026062423_m07c_benefits.verify.sql', 'database/migrations/2026062423_m07c_benefits.verify.sql',
'database/migrations/2026062524_m08a_recharge_wechat.verify.sql' 'database/migrations/2026062524_m08a_recharge_wechat.verify.sql',
'database/migrations/2026062525_m08b_cleaner_tasks.verify.sql'
], ],
down: [ down: [
'database/migrations/2026062525_m08b_cleaner_tasks.down.sql',
'database/migrations/2026062524_m08a_recharge_wechat.down.sql', 'database/migrations/2026062524_m08a_recharge_wechat.down.sql',
'database/migrations/2026062423_m07c_benefits.down.sql', 'database/migrations/2026062423_m07c_benefits.down.sql',
'database/migrations/2026062422_m07b_recharge_plans.down.sql', 'database/migrations/2026062422_m07b_recharge_plans.down.sql',
@@ -235,7 +238,8 @@ export async function executeMigrationPlan(
3, 3, 9, 1, 3, 3, 9, 1,
1, 1, 1, 4, 7, 1, 1, 1, 1, 4, 7, 1,
1, 1, 7, 7, 1, 1, 1, 7, 7, 1,
5, 10, 7, 3, 1 5, 10, 7, 3, 1,
2, 8, 4, 3, 1
][index] ?? 1; ][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) { if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error( throw new Error(
+169
View File
@@ -0,0 +1,169 @@
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 {
CleaningTaskError,
cleaningTaskStatuses,
type CleaningActor,
type CleaningTaskRepository
} from '../cleaning/cleaning-task-repository.js';
const listSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(50).default(20),
status: z.enum(cleaningTaskStatuses).optional()
});
const paramsSchema = z.object({ taskId: z.string().regex(/^[1-9]\d{0,19}$/) });
const submitSchema = z.object({
photoUrls: z.array(z.string().url()).max(9).default([]),
note: z.string().trim().max(512).optional()
});
export interface CleaningRouteOptions {
repository: Pick<CleaningTaskRepository, 'listHall' | 'listMine' | 'claim' | 'start' | 'submit' | 'stats'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerCleaningRoutes(
app: FastifyInstance,
options: CleaningRouteOptions
): Promise<void> {
app.get('/app-api/cleaning/tasks/hall', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
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.repository.listHall({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/app-api/cleaning/tasks/mine', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
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.repository.listMine({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/app-api/cleaning/stats', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.stats(actor),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/claim', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.claim({ ...actor, taskId: params.data.taskId }),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/start', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.start({ ...actor, taskId: params.data.taskId }),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/submit', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = submitSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.submit({
...actor,
taskId: params.data.taskId,
photoUrls: body.data.photoUrls,
note: body.data.note
}),
traceId: request.traceId
}));
});
}
async function requireActor(
request: FastifyRequest,
reply: FastifyReply,
options: CleaningRouteOptions,
mode: 'read' | 'write'
): Promise<CleaningActor | 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);
const permission = mode === 'read' ? 'cleaning.task.read' : 'cleaning.task.write';
if (!access.capabilities.includes(permission)
&& !access.capabilities.includes('tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) {
reply.status(403).send({
code: 'CLEANING_TASK_FORBIDDEN',
message: 'Cleaning task permission is required.',
traceId: request.traceId
});
return null;
}
return {
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
access,
traceId: request.traceId
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof CleaningTaskError)) throw error;
const statusCode = error.code === 'CLEANING_TASK_FORBIDDEN' ? 403 : 409;
return reply.status(statusCode).send({
code: error.code,
message: 'The cleaning task request cannot be completed.',
traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_CLEANING_TASK_REQUEST',
message: 'The cleaning task request is invalid.',
traceId
});
}
+7
View File
@@ -37,6 +37,7 @@ import { MemberProfileService } from './wallets/member-profile-service.js';
import { RechargeService } from './wallets/recharge-service.js'; import { RechargeService } from './wallets/recharge-service.js';
import { WalletLedgerService } from './wallets/wallet-ledger-service.js'; import { WalletLedgerService } from './wallets/wallet-ledger-service.js';
import { MarketingBenefitService } from './wallets/marketing-benefit-service.js'; import { MarketingBenefitService } from './wallets/marketing-benefit-service.js';
import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js';
const config = loadConfig(); const config = loadConfig();
const pool = createMySqlPool(config); const pool = createMySqlPool(config);
@@ -184,6 +185,12 @@ const app = await buildApp({
authRepository, authRepository,
accessControl, accessControl,
jwtSecret: config.auth.jwtSecret jwtSecret: config.auth.jwtSecret
},
cleaning: {
repository: new CleaningTaskRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
} }
}); });
app.addHook('onClose', async () => { app.addHook('onClose', async () => {
+160
View File
@@ -0,0 +1,160 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
const secret = 'test-only-cleaning-route-secret';
const token = signAccessToken({
sub: '31', sid: '9c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
const forbiddenToken = signAccessToken({
sub: '32', sid: '8c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
const calls = [];
const app = await buildApp({
cleaning: {
jwtSecret: secret,
authRepository: {
async validateSession(sessionId) {
return {
id: sessionId,
tenantId: '7',
platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: {
id: sessionId.startsWith('8') ? '32' : '31',
tenantId: '7',
userType: 'CUSTOMER',
status: 'ACTIVE',
roleVersion: 1,
nickname: '',
avatarUrl: '',
phone: ''
}
};
}
},
accessControl: {
async getAccessProfile(tenantId, userId) {
return userId === '31'
? {
roles: ['CLEANER'],
capabilities: ['cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read'],
storeIds: ['11']
}
: { roles: ['CUSTOMER'], capabilities: ['profile.read'], storeIds: [] };
}
},
repository: {
async listHall(input) {
calls.push(['listHall', input]);
return { items: [task('WAITING')], total: 1, page: input.page, pageSize: input.pageSize };
},
async listMine(input) {
calls.push(['listMine', input]);
return { items: [task('CLAIMED')], total: 1, page: input.page, pageSize: input.pageSize };
},
async claim(input) {
calls.push(['claim', input]);
return task('CLAIMED');
},
async start(input) {
calls.push(['start', input]);
return task('STARTED');
},
async submit(input) {
calls.push(['submit', input]);
return { ...task('SUBMITTED'), photoUrls: input.photoUrls };
},
async stats(input) {
calls.push(['stats', input]);
return { byStatus: { SUBMITTED: 2 }, pendingSettlementCents: 1200 };
}
}
}
});
const hall = await app.inject({
method: 'GET',
url: '/app-api/cleaning/tasks/hall?page=1&pageSize=10',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(hall.statusCode, 200);
assert.equal(hall.json().data.items[0].status, 'WAITING');
assert.equal(calls.at(-1)[1].tenantId, '7');
assert.equal(calls.at(-1)[1].userId, '31');
const mine = await app.inject({
method: 'GET',
url: '/app-api/cleaning/tasks/mine?status=CLAIMED',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(mine.statusCode, 200);
assert.equal(calls.at(-1)[0], 'listMine');
assert.equal(calls.at(-1)[1].status, 'CLAIMED');
const claim = await app.inject({
method: 'POST',
url: '/app-api/cleaning/tasks/101/claim',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(claim.statusCode, 200);
assert.equal(calls.at(-1)[0], 'claim');
assert.equal(calls.at(-1)[1].taskId, '101');
const start = await app.inject({
method: 'POST',
url: '/app-api/cleaning/tasks/101/start',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(start.statusCode, 200);
assert.equal(calls.at(-1)[0], 'start');
const submit = await app.inject({
method: 'POST',
url: '/app-api/cleaning/tasks/101/submit',
headers: { authorization: `Bearer ${token}` },
payload: { photoUrls: ['https://api.txyundm.cn/uploads/cleaning/101.jpg'], note: 'ok' }
});
assert.equal(submit.statusCode, 200);
assert.deepEqual(calls.at(-1)[1].photoUrls, ['https://api.txyundm.cn/uploads/cleaning/101.jpg']);
const stats = await app.inject({
method: 'GET',
url: '/app-api/cleaning/stats',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(stats.statusCode, 200);
assert.equal(stats.json().data.pendingSettlementCents, 1200);
const unauthorized = await app.inject({ method: 'GET', url: '/app-api/cleaning/tasks/hall' });
assert.equal(unauthorized.statusCode, 401);
const forbidden = await app.inject({
method: 'GET',
url: '/app-api/cleaning/tasks/hall',
headers: { authorization: `Bearer ${forbiddenToken}` }
});
assert.equal(forbidden.statusCode, 403);
await app.close();
console.log('PASS: M08-B cleaning routes authenticate and forward cleaner task workflows.');
function task(status) {
return {
id: '101',
taskNo: 'CLN-20260625-0001',
storeId: '11',
storeName: 'Test Store',
roomId: '21',
roomName: 'A Room',
roomNo: 'A01',
orderId: '301',
orderNo: 'O301',
status,
rewardCents: 600,
photoUrls: []
};
}
+21 -1
View File
@@ -81,6 +81,9 @@ const benefitVerifySql = read('database/migrations/2026062423_m07c_benefits.veri
const rechargeWechatUpSql = read('database/migrations/2026062524_m08a_recharge_wechat.up.sql'); const rechargeWechatUpSql = read('database/migrations/2026062524_m08a_recharge_wechat.up.sql');
const rechargeWechatDownSql = read('database/migrations/2026062524_m08a_recharge_wechat.down.sql'); const rechargeWechatDownSql = read('database/migrations/2026062524_m08a_recharge_wechat.down.sql');
const rechargeWechatVerifySql = read('database/migrations/2026062524_m08a_recharge_wechat.verify.sql'); const rechargeWechatVerifySql = read('database/migrations/2026062524_m08a_recharge_wechat.verify.sql');
const cleaningUpSql = read('database/migrations/2026062525_m08b_cleaner_tasks.up.sql');
const cleaningDownSql = read('database/migrations/2026062525_m08b_cleaner_tasks.down.sql');
const cleaningVerifySql = read('database/migrations/2026062525_m08b_cleaner_tasks.verify.sql');
const coreTables = [ const coreTables = [
'qipai_schema_migrations', 'qipai_schema_migrations',
@@ -378,4 +381,21 @@ assert.match(rechargeWechatDownSql, /DROP COLUMN provider_payment_id/);
assert.match(rechargeWechatVerifySql, /'provider_payment_id'/); assert.match(rechargeWechatVerifySql, /'provider_payment_id'/);
assert.match(rechargeWechatVerifySql, /'uq_qipai_recharge_provider_callback'/); assert.match(rechargeWechatVerifySql, /'uq_qipai_recharge_provider_callback'/);
console.log('PASS: M01-B through M08-A migration contracts are present.'); for (const table of ['qipai_cleaning_tasks', 'qipai_cleaning_task_events']) {
assert.match(cleaningUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(cleaningDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(cleaningVerifySql, new RegExp(`'${table}'`));
}
for (const status of [
'WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED',
'REJECTED', 'EXEMPT', 'SETTLED', 'CANCELLED'
]) {
assert.match(cleaningUpSql, new RegExp(status));
}
assert.match(cleaningUpSql, /cleaner_user_id BIGINT UNSIGNED NULL/);
assert.match(cleaningUpSql, /photo_urls_json JSON NOT NULL/);
assert.match(cleaningUpSql, /uq_qipai_cleaning_task_order/);
assert.match(cleaningUpSql, /cleaning\.task\.write/);
assert.match(cleaningUpSql, /cleaning\.statistics\.read/);
console.log('PASS: M01-B through M08-B migration contracts are present.');
+2 -1
View File
@@ -34,7 +34,8 @@ assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql/);
assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/); assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/);
assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/); assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/);
assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/); assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/);
assert.match(plan.file, /2026062524_m08a_recharge_wechat\.up\.sql$/); assert.match(plan.file, /2026062524_m08a_recharge_wechat\.up\.sql/);
assert.match(plan.file, /2026062525_m08b_cleaner_tasks\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/); assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11); assert.ok(plan.statements.length >= 11);
@@ -0,0 +1,12 @@
DELETE rp FROM qipai_role_permissions rp
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
WHERE p.code IN ('cleaning.task.write', 'cleaning.statistics.read');
DELETE FROM qipai_permissions
WHERE code IN ('cleaning.task.write', 'cleaning.statistics.read');
DROP TABLE IF EXISTS qipai_cleaning_task_events;
DROP TABLE IF EXISTS qipai_cleaning_tasks;
DELETE FROM qipai_schema_migrations
WHERE version = '2026062525';
@@ -0,0 +1,76 @@
CREATE TABLE IF NOT EXISTS qipai_cleaning_tasks (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
room_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NULL,
task_no VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'WAITING',
cleaner_user_id BIGINT UNSIGNED NULL,
priority TINYINT UNSIGNED NOT NULL DEFAULT 5,
reward_cents INT UNSIGNED NOT NULL DEFAULT 0,
requirement VARCHAR(512) NOT NULL DEFAULT '',
photo_urls_json JSON NOT NULL,
reject_reason VARCHAR(512) NOT NULL DEFAULT '',
claimed_at DATETIME(3) NULL,
started_at DATETIME(3) NULL,
submitted_at DATETIME(3) NULL,
completed_at DATETIME(3) NULL,
settled_at DATETIME(3) NULL,
cancelled_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted_at DATETIME(3) NULL,
CONSTRAINT fk_qipai_cleaning_tasks_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_cleaning_tasks_store FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
CONSTRAINT fk_qipai_cleaning_tasks_room FOREIGN KEY (room_id) REFERENCES qipai_rooms(id),
CONSTRAINT fk_qipai_cleaning_tasks_order FOREIGN KEY (order_id) REFERENCES qipai_orders(id),
CONSTRAINT fk_qipai_cleaning_tasks_cleaner FOREIGN KEY (cleaner_user_id) REFERENCES qipai_users(id),
CONSTRAINT chk_qipai_cleaning_task_status CHECK (
status IN (
'WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED',
'REJECTED', 'EXEMPT', 'SETTLED', 'CANCELLED'
)
),
UNIQUE KEY uq_qipai_cleaning_task_no (tenant_id, task_no),
UNIQUE KEY uq_qipai_cleaning_task_order (tenant_id, order_id),
KEY idx_qipai_cleaning_hall (tenant_id, store_id, status, priority, created_at),
KEY idx_qipai_cleaning_cleaner (tenant_id, cleaner_user_id, status, updated_at),
KEY idx_qipai_cleaning_room (tenant_id, room_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_cleaning_task_events (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
task_id BIGINT UNSIGNED NOT NULL,
from_status VARCHAR(32) NULL,
to_status VARCHAR(32) NOT NULL,
action VARCHAR(32) NOT NULL,
actor_id BIGINT UNSIGNED NOT NULL,
trace_id VARCHAR(128) NOT NULL,
note VARCHAR(512) NOT NULL DEFAULT '',
metadata JSON NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_cleaning_events_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_cleaning_events_task FOREIGN KEY (task_id) REFERENCES qipai_cleaning_tasks(id),
UNIQUE KEY uq_qipai_cleaning_event_trace (tenant_id, task_id, trace_id),
KEY idx_qipai_cleaning_events_task (tenant_id, task_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO qipai_permissions (code, name, category) VALUES
('cleaning.task.write', '处理保洁任务', 'cleaning'),
('cleaning.statistics.read', '查看保洁统计', 'cleaning');
INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
SELECT r.tenant_id, r.id, p.id
FROM qipai_roles r
INNER JOIN qipai_permissions p
ON (r.code = 'CLEANER' AND p.code IN (
'cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read'
))
OR (r.code IN ('STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read'))
WHERE r.status = 'ACTIVE' AND r.deleted_at IS NULL;
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026062525', 'm08b_cleaner_tasks');
@@ -0,0 +1,30 @@
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN ('qipai_cleaning_tasks', 'qipai_cleaning_task_events');
SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_cleaning_tasks'
AND column_name IN (
'task_no', 'status', 'cleaner_user_id', 'reward_cents',
'photo_urls_json', 'reject_reason', 'started_at', 'submitted_at'
);
SELECT index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'qipai_cleaning_tasks'
AND index_name IN (
'uq_qipai_cleaning_task_no', 'uq_qipai_cleaning_task_order',
'idx_qipai_cleaning_hall', 'idx_qipai_cleaning_cleaner'
);
SELECT code
FROM qipai_permissions
WHERE code IN ('cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read');
SELECT version
FROM qipai_schema_migrations
WHERE version = '2026062525';
@@ -0,0 +1,36 @@
# 2026-06-25 M08-B 保洁端 API
## App API
- `GET /app-api/cleaning/tasks/hall`
- 登录:必需。
- 权限:`cleaning.task.read``tenant.manage``PLATFORM_ADMIN`
- 查询:`page``pageSize``status`;默认查询 `WAITING`
- 说明:按保洁员门店范围返回任务大厅。
- `GET /app-api/cleaning/tasks/mine`
- 登录:必需。
- 权限:`cleaning.task.read``tenant.manage``PLATFORM_ADMIN`
- 查询:`page``pageSize``status`
- 说明:返回当前保洁员已领取任务。
- `POST /app-api/cleaning/tasks/:taskId/claim`
- 登录:必需。
- 权限:`cleaning.task.write``tenant.manage``PLATFORM_ADMIN`
- 说明:仅 `WAITING` 且未被领取的任务可接单。
- `POST /app-api/cleaning/tasks/:taskId/start`
- 登录:必需。
- 权限:`cleaning.task.write``tenant.manage``PLATFORM_ADMIN`
- 说明:当前保洁员已领取的 `CLAIMED` 任务可开始。
- `POST /app-api/cleaning/tasks/:taskId/submit`
- 登录:必需。
- 权限:`cleaning.task.write``tenant.manage``PLATFORM_ADMIN`
- Body`photoUrls: string[]``note?: string`
- 说明:当前保洁员进行中的 `STARTED` 任务可提交验收。
- `GET /app-api/cleaning/stats`
- 登录:必需。
- 权限:`cleaning.task.read``tenant.manage``PLATFORM_ADMIN`
- 说明:返回当前保洁员任务状态计数和待结算金额。
+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: 继续 M08-A 真机合法域名、真实微信支付和实物开门验证 > next_engineering_target: 继续 M08-B 照片上传、驳回补做、订单结束自动建任务和结算前置能力
> 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。 > 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。
## 总体结论 ## 总体结论
@@ -13,7 +13,7 @@
| Git 远端 | `origin=ssh://git@git.txyundm.cn:2222/panda/qipai.git`,分支 `main` | `git rev-list main...origin/main``0 0` | 本地与远端同步 | | Git 远端 | `origin=ssh://git@git.txyundm.cn:2222/panda/qipai.git`,分支 `main` | `git rev-list main...origin/main``0 0` | 本地与远端同步 |
| 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、微信身份/会话,以及六角色 RBAC 和门店范围 | `backend/src/auth/**``database/migrations/2026061805_m02c_rbac.*.sql``backend/tests/**` | M01、M02-A/B/C DONEMySQL 已验证 capability 和跨租户门店授权拒绝;下一阶段 M02-D | | 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、微信身份/会话,以及六角色 RBAC 和门店范围 | `backend/src/auth/**``database/migrations/2026061805_m02c_rbac.*.sql``backend/tests/**` | M01、M02-A/B/C DONEMySQL 已验证 capability 和跨租户门店授权拒绝;下一阶段 M02-D |
| 后台管理端 | 仅有 `admin/.gitkeep` | 当前文件扫描 | M09 未开始,不能标记 DONE | | 后台管理端 | 仅有 `admin/.gitkeep` | 当前文件扫描 | M09 未开始,不能标记 DONE |
| 微信小程序 | 已纳管微信原生模板,并完成顾客端订单、充值、权益明细下单抵扣主体链路 | `miniapp/pages/index``miniapp/pages/store/detail``miniapp/pages/room/detail``miniapp/pages/orders/*``miniapp/pages/recharge/*``miniapp/pages/profile/*``miniapp/utils/api.js``scripts/check-miniapp-m08-a.mjs` | M08-A PARTIAL已支持选店、门店/房间浏览、报价、预占、测试支付、余额支付、优惠券/套餐下单抵扣和可视化选择器、订单微信预支付调起、充值微信支付调起、受控 Wi-Fi、订单列表/详情、状态历史、取消、订单权限开门、续费、换房、分享、个人中心和权益明细,仍不能标记 DONE | | 微信小程序 | 已纳管微信原生模板,并完成顾客端订单、充值、权益明细下单抵扣主体链路和保洁任务基础端 | `miniapp/pages/index``miniapp/pages/store/detail``miniapp/pages/room/detail``miniapp/pages/orders/*``miniapp/pages/recharge/*``miniapp/pages/profile/*``miniapp/pages/cleaner/*``miniapp/utils/api.js``scripts/check-miniapp-m08-a.mjs` | M08 PARTIALM08-A 顾客端工程闭环按外部阻塞旁路,M08-B 已支持保洁任务大厅、我的任务、接单、开始、提交验收和统计,仍不能标记 DONE |
| 数据库迁移 | 已新增 M01-B 核心 schema up/down/verify SQL、最小脱敏 seed、旧库结构 fixture、迁移计划/执行 CLI、验证结果数量门禁和 MySQL 8 集成测试 | `database/migrations/2026061601_m01b_core_schema.*.sql``database/seeds/2026061601_m01b_minimal_seed.sql``database/fixtures/2026061801_m01b_legacy_schema.sql``backend/src/db/migration-runner.ts``backend/tests/mysql-migration-roundtrip.test.mjs` | M01-B DONEWSL MySQL 8.4.9 已验证 8 条旧库 fixture、11 条 up、3 条 verify、10 条 down,并验证租户隔离和旧金额整数分转换;不导入真实旧数据 | | 数据库迁移 | 已新增 M01-B 核心 schema up/down/verify SQL、最小脱敏 seed、旧库结构 fixture、迁移计划/执行 CLI、验证结果数量门禁和 MySQL 8 集成测试 | `database/migrations/2026061601_m01b_core_schema.*.sql``database/seeds/2026061601_m01b_minimal_seed.sql``database/fixtures/2026061801_m01b_legacy_schema.sql``backend/src/db/migration-runner.ts``backend/tests/mysql-migration-roundtrip.test.mjs` | M01-B DONEWSL MySQL 8.4.9 已验证 8 条旧库 fixture、11 条 up、3 条 verify、10 条 down,并验证租户隔离和旧金额整数分转换;不导入真实旧数据 |
| M00 部署脚本 | 已有菜单、状态、HTTPS、Certbot、EMQX、备份检查模板 | `setup.sh``scripts/setup/``deploy/` | PARTIAL,生产未执行 | | M00 部署脚本 | 已有菜单、状态、HTTPS、Certbot、EMQX、备份检查模板 | `setup.sh``scripts/setup/``deploy/` | PARTIAL,生产未执行 |
| 发布清单 dry-run | 可基于当前 HEAD 输出 `deployed=false` 的 release manifest,记录后端/后台/迁移尚未生成而跳过 | `scripts/setup/deploy-business.sh --dry-run .``scripts/dev/windows/check-release-manifest.ps1` | 可在 M00 验证结构;真实构建与生产发布待 M01/M09 后接入 | | 发布清单 dry-run | 可基于当前 HEAD 输出 `deployed=false` 的 release manifest,记录后端/后台/迁移尚未生成而跳过 | `scripts/setup/deploy-business.sh --dry-run .``scripts/dev/windows/check-release-manifest.ps1` | 可在 M00 验证结构;真实构建与生产发布待 M01/M09 后接入 |
@@ -35,6 +35,6 @@
## 下一步 ## 下一步
1. 继续 M08-A:在已完成选店、门店/房间浏览、报价、预占、测试支付、余额支付、优惠券/套餐下单抵扣和可视化选择器、订单微信预支付调起、充值微信支付调起、受控 Wi-Fi、订单列表/详情、取消、订单权限开门、续费、换房、分享、个人中心、充值入口和权益明细基础上,补真机合法域名、真实微信支付和实物开门验证 1. 继续 M08-B:在已完成保洁任务底表、权限、任务大厅、我的任务、接单、开始、提交验收和统计基础上,补照片真实上传、驳回补做、订单结束自动建任务和结算前置能力
2. 继续复用 M03 门店发现、M04 订单、M05 支付、M06 设备联动和 M07 会员营销后端能力,不重复初始化小程序。 2. 继续复用 M03 门店发现、M04 订单、M05 支付、M06 设备联动和 M07 会员营销后端能力,不重复初始化小程序。
3. M06-G 真实硬件联调仍等待生产 MQTT 账号/ACL、DeviceID 和现场实物配线。 3. M06-G 真实硬件联调仍等待生产 MQTT 账号/ACL、DeviceID 和现场实物配线。
@@ -0,0 +1,25 @@
# 2026-06-25 M08-B 保洁端数据库变更
## 新增迁移
- `database/migrations/2026062525_m08b_cleaner_tasks.up.sql`
- `database/migrations/2026062525_m08b_cleaner_tasks.down.sql`
- `database/migrations/2026062525_m08b_cleaner_tasks.verify.sql`
## 新增表
- `qipai_cleaning_tasks`
- 保洁任务主表,包含租户、门店、房间、订单、任务号、状态、保洁员、奖励金额、要求、照片 URL、驳回原因和关键时间戳。
- 状态约束:`WAITING``CLAIMED``STARTED``SUBMITTED``COMPLETED``REJECTED``EXEMPT``SETTLED``CANCELLED`
- 唯一约束:租户内任务号唯一;订单关联唯一。
- 索引:任务大厅、保洁员任务、房间任务。
- `qipai_cleaning_task_events`
- 保洁任务状态事件表,记录 from/to 状态、动作、操作人、trace、备注和 metadata。
- 租户、任务、trace 唯一,便于幂等追踪。
## 权限
- 新增 `cleaning.task.write`
- 新增 `cleaning.statistics.read`
-`cleaning.task.read``cleaning.task.write``cleaning.statistics.read` 授予现有 `CLEANER``STORE_ADMIN``TENANT_ADMIN``PLATFORM_ADMIN` 角色。
@@ -0,0 +1,28 @@
# M08-B 保洁端
## 本次工程增量
- 新增 `2026062525_m08b_cleaner_tasks` 迁移,建立 `qipai_cleaning_tasks``qipai_cleaning_task_events`,覆盖 WAITING、CLAIMED、STARTED、SUBMITTED、COMPLETED、REJECTED、EXEMPT、SETTLED、CANCELLED 状态和事件追踪。
- 权限补齐 `cleaning.task.write``cleaning.statistics.read``CLEANER`、门店管理员、租户管理员和平台管理员可获得保洁任务读写与统计能力。
- 新增 `CleaningTaskRepository``/app-api/cleaning/*` 路由:任务大厅、我的任务、接单、开始、提交验收和个人统计均要求登录态与保洁权限。
- 生产 `server.ts` 注入真实保洁仓储,迁移 runner 纳入 M08-B 数据库迁移。
- 小程序新增 `pages/cleaner/tasks`,调用真实 app-api 展示任务大厅、我的任务、待结算统计,并支持接单、开始和提交照片 URL 验收。
- `scripts/check-miniapp-m08-a.mjs` 扩展为同时检查 M08-A 顾客端与 M08-B 保洁端页面注册和真实接口调用。
## 验收结果
- `npm run build``backend/`):PASS。
- `node scripts/check-miniapp-m08-a.mjs`PASS。
- `node tests/cleaning-route.test.mjs`PASS。
- `node tests/migration-contract.test.mjs`PASS。
- `node tests/migration-runner.test.mjs`PASS。
- `npm test``backend/`):PASS。
## 状态
M08-B 进入 `PARTIAL`。本次完成保洁端任务大厅、我的任务、接单、开始、提交验收和统计的基础工程闭环;照片真实上传、驳回补做、订单结束自动建任务、管理员指派、超时回收和结算单仍待继续。M08-A 的真机合法域名、真实微信支付和实物开门验证按外部条件阻塞保留,不阻断 M08-B 工程推进。
## 提交
- 工程提交:`2a4dcd0`
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`
+4 -1
View File
@@ -17,11 +17,14 @@
| 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-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 执行;真实备份配置未启用。 | 生产执行后补部署记录、真实备份和恢复演练记录。 | | 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`。 | | 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 | `4701674` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起、余额支付、优惠券/套餐下单抵扣参数与可视化选择器、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口、订单权限开门入口、续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口优惠券/套餐明细;`scripts/check-miniapp-m08-a.mjs`、相关会员/权益/充值测试通过。 | 多角色菜单和真机联调尚未完成。 | 继续 M08-A,补真机合法域名、真实微信支付和实物开门验证。 | | SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `2a4dcd0` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起、余额支付、优惠券/套餐下单抵扣参数与可视化选择器、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口、订单权限开门入口、续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口优惠券/套餐明细,以及保洁端任务大厅、我的任务、接单、开始、提交验收和统计`scripts/check-miniapp-m08-a.mjs`、相关会员/权益/充值/保洁测试通过。 | M08-A 真机联调缺微信后台/商户/硬件条件;M08-B 照片真实上传、驳回补做和结算前置待继续。 | 继续 M08-B,补照片上传、驳回补做、订单结束自动建任务和结算前置能力。 |
| CFG-001 | 小程序品牌自定义 | M02-A/M03/M08 | PARTIAL | `8b8fb28` | 已建立 AppID/tenant 品牌配置表和 `/app-api/bootstrap`;同一 AppID 两租户隔离读取在 MySQL 8.4.9 实测通过。 | Logo/分享图片上传与小程序实际展示属于后续阶段。 | M03/M08 接入配置管理和小程序展示。 | | 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 增加管理页面。 | | 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 完成小程序角色界面与真机登录。 | | AUTH-001 | 多端统一小程序身份 | M02-B/M02-C/M08 | PARTIAL | `5a300a8` | 已实现微信 code 交换适配器、租户/AppID 隔离身份、JWT、服务端会话、当前用户和注销;禁用、角色/门店范围变化及管理员重置均使旧会话立即失效。 | 缺真实 AppSecret 真机联调和小程序动态角色界面。 | M08 完成小程序角色界面与真机登录。 |
| AUTH-002 | 多级权限 | M02-B/M02-C/M08 | PARTIAL | `5a300a8` | 已建立六类角色、权限目录、用户角色和门店范围;后台员工 API 同时执行 capability、租户和门店范围校验,越权分配被拒绝并记录审计。 | 后续业务资源接口和前端按钮/菜单仍需逐模块接入。 | M03 起所有管理接口复用 capability 与数据范围;M08 完成前端菜单按钮控制。 | | AUTH-002 | 多级权限 | M02-B/M02-C/M08 | PARTIAL | `5a300a8` | 已建立六类角色、权限目录、用户角色和门店范围;后台员工 API 同时执行 capability、租户和门店范围校验,越权分配被拒绝并记录审计。 | 后续业务资源接口和前端按钮/菜单仍需逐模块接入。 | M03 起所有管理接口复用 capability 与数据范围;M08 完成前端菜单按钮控制。 |
| CLN-001 | 保洁人员管理 | M08-B/M09 | PARTIAL | `2a4dcd0` | `CLEANER` 角色已接入 `cleaning.task.read/write``cleaning.statistics.read`,保洁接口按登录态、capability 和门店范围裁剪任务大厅。 | 员工授权管理页面和保洁员资料管理仍待管理员端/后台接入。 | 继续 M08-B/M09,补管理员指派和保洁员管理界面。 |
| CLN-002 | 保洁任务大厅 | M08-B/M09 | PARTIAL | `2a4dcd0` | 新增 `qipai_cleaning_tasks``qipai_cleaning_task_events``GET /app-api/cleaning/tasks/hall``GET /app-api/cleaning/tasks/mine`、接单、开始和提交验收接口;小程序 `pages/cleaner/tasks` 已调用真实 app-api。 | 订单结束自动创建、管理员指派、超时回收、多保洁员模式和照片上传仍待继续。 | 继续 M08-B,补照片上传和驳回补做;M09-A 补订单结束自动建任务与事务锁。 |
| CLN-003 | 保洁统计 | M08-B/M09 | PARTIAL | `2a4dcd0` | 新增 `GET /app-api/cleaning/stats`,返回当前保洁员状态计数和待结算金额,小程序展示已抢、进行、待验、完成和待结算金额。 | 统计明细、周期筛选、驳回数和结算单反查待继续。 | M09-C 补统计明细和结算口径校验。 |
| STAFF-001 | 员工账号 | M02-D/M08 | PARTIAL | `5a300a8` | 已实现创建员工、分配角色/门店、禁用、备注、重置会话和审计;WSL MySQL 实测权限变化撤销活动会话。 | 后台与管理员小程序页面尚未实现。 | M08 接入管理界面。 | | STAFF-001 | 员工账号 | M02-D/M08 | PARTIAL | `5a300a8` | 已实现创建员工、分配角色/门店、禁用、备注、重置会话和审计;WSL MySQL 实测权限变化撤销活动会话。 | 后台与管理员小程序页面尚未实现。 | M08 接入管理界面。 |
| BKG-002 | 后台用户管理 | M02-D/M08 | PARTIAL | `5a300a8` | 已实现分页筛选 API、注册/登录时间、状态、角色、门店范围以及手机号/IP 脱敏。 | Vue3 后台页面尚未实现。 | M08 实现响应式用户管理页面。 | | BKG-002 | 后台用户管理 | M02-D/M08 | PARTIAL | `5a300a8` | 已实现分页筛选 API、注册/登录时间、状态、角色、门店范围以及手机号/IP 脱敏。 | Vue3 后台页面尚未实现。 | M08 实现响应式用户管理页面。 |
| TEN-001 | 多门店支持 | M02/M03 | PARTIAL | `15139b3` | 门店 CRUD 与门店管理员数据范围已实现;跨门店写操作在 Windows 和 WSL MySQL 实测被拒绝。 | 地图选店、前端切换与完整订单数据范围待后续阶段。 | M03-C 接入地图选店,M04 起业务资源继承门店范围。 | | TEN-001 | 多门店支持 | M02/M03 | PARTIAL | `15139b3` | 门店 CRUD 与门店管理员数据范围已实现;跨门店写操作在 Windows 和 WSL MySQL 实测被拒绝。 | 地图选店、前端切换与完整订单数据范围待后续阶段。 | M03-C 接入地图选店,M04 起业务资源继承门店范围。 |
+6 -6
View File
@@ -3,15 +3,15 @@
```yaml ```yaml
execution_cursor: execution_cursor:
current_module: M08 current_module: M08
current_stage: M08-A current_stage: M08-B
stage_status: PARTIAL stage_status: PARTIAL
last_completed_stage: M07-D last_completed_stage: M07-D
next_stage: M08-A next_stage: M08-B
last_engineering_commit: `4701674` last_engineering_commit: `2a4dcd0`
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: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;已完成顾客端工程闭环并按 BLOCKED_EXTERNAL 旁路进入 M08-B。M06-G 真实硬件联调缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。"
updated_at: 2026-06-25T23:59:00+08:00 updated_at: 2026-06-26T00:41: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。 | | 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 会员、余额、充值、优惠券和套餐营销 | DONE | `ed0d455` | docs/devlogs/2026-06-24-M07-D-会员管理.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 微信原生小程序完整业务 | PARTIAL | `4701674` | docs/devlogs/2026-06-24-M08-A-顾客端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起与通知入账、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细、优惠券/套餐/余额下单抵扣工程闭环和权益可视化选择器;真机合法域名、真实微信支付和实物开门验证继续留在 M08-A。 | | M08 微信原生小程序完整业务 | PARTIAL | `2a4dcd0` | docs/devlogs/2026-06-25-M08-B-保洁端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起与通知入账、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细、优惠券/套餐/余额下单抵扣工程闭环和权益可视化选择器;真机合法域名、真实微信支付和实物开门验证按 BLOCKED_EXTERNAL 旁路。M08-B 已完成保洁任务底表、权限、任务大厅、我的任务、接单、开始、提交验收和统计基础流程;照片真实上传、驳回补做、结算单和订单结束自动建任务待继续。 |
| M09 后台管理端完整业务 | TODO | - | - | - | | M09 后台管理端完整业务 | TODO | - | - | - |
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - | | M10 部署、域名、验收和运维闭环 | TODO | - | - | - |
+1
View File
@@ -8,6 +8,7 @@
"pages/profile/index", "pages/profile/index",
"pages/benefits/index", "pages/benefits/index",
"pages/recharge/index", "pages/recharge/index",
"pages/cleaner/tasks",
"pages/logs/logs" "pages/logs/logs"
], ],
"window": { "window": {
+108
View File
@@ -0,0 +1,108 @@
const { request, ensureLogin, cents } = require('../../utils/api.js')
Page({
data: {
loading: false,
errorMessage: '',
activeTab: 'hall',
hallTasks: [],
myTasks: [],
stats: { byStatus: {}, pendingSettlementText: cents(0) },
submitPhotoText: '',
},
onShow() {
this.refreshAll()
},
async refreshAll() {
this.setData({ loading: true, errorMessage: '' })
try {
await ensureLogin()
const [hall, mine, stats] = await Promise.all([
request('/cleaning/tasks/hall?page=1&pageSize=20'),
request('/cleaning/tasks/mine?page=1&pageSize=20'),
request('/cleaning/stats'),
])
this.setData({
hallTasks: (hall.data.items || []).map(formatTask),
myTasks: (mine.data.items || []).map(formatTask),
stats: formatStats(stats.data),
})
} catch (error) {
this.setData({ errorMessage: error.message || '保洁任务加载失败' })
} finally {
this.setData({ loading: false })
}
},
switchTab(event) {
const tab = event.currentTarget.dataset.tab
if (!tab || tab === this.data.activeTab) return
this.setData({ activeTab: tab })
},
async claimTask(event) {
await this.mutateTask(event.currentTarget.dataset.taskId, 'claim')
},
async startTask(event) {
await this.mutateTask(event.currentTarget.dataset.taskId, 'start')
},
onPhotoTextInput(event) {
this.setData({ submitPhotoText: event.detail.value })
},
async submitTask(event) {
const photoUrls = this.data.submitPhotoText
.split('\n')
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 9)
await this.mutateTask(event.currentTarget.dataset.taskId, 'submit', { photoUrls })
this.setData({ submitPhotoText: '' })
},
async mutateTask(taskId, action, data = {}) {
if (!taskId) return
this.setData({ loading: true, errorMessage: '' })
try {
await ensureLogin()
await request(`/cleaning/tasks/${encodeURIComponent(taskId)}/${action}`, {
method: 'POST',
data,
})
await this.refreshAll()
} catch (error) {
this.setData({ errorMessage: error.message || '保洁任务处理失败' })
} finally {
this.setData({ loading: false })
}
},
})
function formatTask(task) {
return {
...task,
rewardText: cents(task.rewardCents),
timeText: formatDate(task.updatedAt || task.createdAt),
canClaim: task.status === 'WAITING',
canStart: task.status === 'CLAIMED',
canSubmit: task.status === 'STARTED',
}
}
function formatStats(stats) {
return {
byStatus: stats.byStatus || {},
pendingSettlementText: cents(stats.pendingSettlementCents),
}
}
function formatDate(value) {
if (!value) return ''
const date = new Date(value)
const pad = (input) => String(input).padStart(2, '0')
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
+3
View File
@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "保洁任务"
}
+57
View File
@@ -0,0 +1,57 @@
<scroll-view class="scrollarea" scroll-y type="list">
<view class="page">
<view class="header">
<view>
<view class="title">保洁任务</view>
<view class="muted">待结算 {{stats.pendingSettlementText}}</view>
</view>
<button size="mini" loading="{{loading}}" bindtap="refreshAll">刷新</button>
</view>
<view class="tabs">
<button class="{{activeTab === 'hall' ? 'tab active' : 'tab'}}" data-tab="hall" bindtap="switchTab">大厅</button>
<button class="{{activeTab === 'mine' ? 'tab active' : 'tab'}}" data-tab="mine" bindtap="switchTab">我的</button>
</view>
<view class="stats">
<text>已抢 {{stats.byStatus.CLAIMED || 0}}</text>
<text>进行 {{stats.byStatus.STARTED || 0}}</text>
<text>待验 {{stats.byStatus.SUBMITTED || 0}}</text>
<text>完成 {{stats.byStatus.COMPLETED || 0}}</text>
</view>
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
<block wx:if="{{activeTab === 'hall'}}">
<view wx:if="{{!loading && hallTasks.length === 0}}" class="empty">暂无可接任务</view>
<view wx:for="{{hallTasks}}" wx:key="id" class="task-card">
<view class="task-title">{{item.storeName}} · {{item.roomName}}</view>
<view class="muted">任务号 {{item.taskNo}}</view>
<view class="muted">{{item.requirement || '常规保洁'}}</view>
<view class="row">
<text class="status">{{item.status}}</text>
<text class="amount">{{item.rewardText}}</text>
</view>
<button wx:if="{{item.canClaim}}" data-task-id="{{item.id}}" bindtap="claimTask">接单</button>
</view>
</block>
<block wx:if="{{activeTab === 'mine'}}">
<view wx:if="{{!loading && myTasks.length === 0}}" class="empty">暂无我的任务</view>
<view wx:for="{{myTasks}}" wx:key="id" class="task-card">
<view class="task-title">{{item.storeName}} · {{item.roomName}}</view>
<view class="muted">任务号 {{item.taskNo}}</view>
<view class="muted">{{item.timeText}}</view>
<view class="row">
<text class="status">{{item.status}}</text>
<text class="amount">{{item.rewardText}}</text>
</view>
<button wx:if="{{item.canStart}}" data-task-id="{{item.id}}" bindtap="startTask">开始</button>
<view wx:if="{{item.canSubmit}}" class="submit-box">
<textarea value="{{submitPhotoText}}" bindinput="onPhotoTextInput" placeholder="每行一个照片 URL" />
<button data-task-id="{{item.id}}" bindtap="submitTask">提交验收</button>
</view>
</view>
</block>
</view>
</scroll-view>
+106
View File
@@ -0,0 +1,106 @@
.scrollarea {
height: 100vh;
background: #f5f6f8;
}
.page {
padding: 32rpx;
}
.header,
.row,
.stats {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 40rpx;
font-weight: 600;
}
.muted {
margin-top: 8rpx;
color: #667085;
font-size: 26rpx;
}
.tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
margin: 28rpx 0 20rpx;
}
.tab {
border: 1rpx solid #d0d5dd;
background: #ffffff;
color: #344054;
}
.active {
border-color: #1f6feb;
background: #eaf2ff;
color: #1f6feb;
}
.stats {
padding: 20rpx 24rpx;
border-radius: 16rpx;
background: #ffffff;
color: #344054;
font-size: 24rpx;
}
.task-card {
margin-top: 20rpx;
padding: 28rpx;
border-radius: 16rpx;
background: #ffffff;
}
.task-title {
font-size: 32rpx;
font-weight: 600;
}
.row {
margin: 18rpx 0;
}
.status {
color: #1f6feb;
font-weight: 600;
}
.amount {
color: #b42318;
font-weight: 700;
}
.submit-box {
margin-top: 16rpx;
}
textarea {
box-sizing: border-box;
width: 100%;
min-height: 144rpx;
margin-bottom: 16rpx;
padding: 18rpx;
border: 1rpx solid #d0d5dd;
border-radius: 12rpx;
background: #f9fafb;
}
.error {
margin: 20rpx 0;
color: #c73535;
}
.empty {
padding: 80rpx 0;
color: #888888;
text-align: center;
}
+21 -2
View File
@@ -14,7 +14,8 @@ for (const page of [
'pages/orders/detail', 'pages/orders/detail',
'pages/profile/index', 'pages/profile/index',
'pages/benefits/index', 'pages/benefits/index',
'pages/recharge/index' 'pages/recharge/index',
'pages/cleaner/tasks'
]) { ]) {
assert.ok(appJson.pages.includes(page), `${page} must be registered`); assert.ok(appJson.pages.includes(page), `${page} must be registered`);
} }
@@ -116,4 +117,22 @@ for (const pattern of [
assert.match(recharge, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); assert.match(recharge, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
} }
console.log('PASS: M08-A miniapp customer pages use fixed domain and real app-api calls.'); const cleaner = read('miniapp/pages/cleaner/tasks.js')
+ read('miniapp/pages/cleaner/tasks.wxml')
+ read('miniapp/pages/cleaner/tasks.json');
for (const pattern of [
'/cleaning/tasks/hall',
'/cleaning/tasks/mine',
'/cleaning/stats',
'/cleaning/tasks/${encodeURIComponent(taskId)}/${action}',
'claimTask',
'startTask',
'submitTask',
'photoUrls',
'pendingSettlementText',
'保洁任务'
]) {
assert.match(cleaner, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}
console.log('PASS: M08-A/M08-B miniapp customer and cleaner pages use fixed domain and real app-api calls.');