From 2a4dcd0fd8544301d1f316a3a86d4e04d37547fa Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 25 Jun 2026 23:19:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(M08-B):=20=E6=8E=A5=E5=85=A5=E4=BF=9D?= =?UTF-8?q?=E6=B4=81=E4=BB=BB=E5=8A=A1=E7=AB=AF=E5=9F=BA=E7=A1=80=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- backend/src/app.ts | 5 + backend/src/auth/rbac-repository.ts | 11 +- .../src/cleaning/cleaning-task-repository.ts | 286 ++++++++++++++++++ backend/src/db/migration-runner.ts | 10 +- backend/src/routes/cleaning.ts | 169 +++++++++++ backend/src/server.ts | 7 + backend/tests/cleaning-route.test.mjs | 160 ++++++++++ backend/tests/migration-contract.test.mjs | 22 +- backend/tests/migration-runner.test.mjs | 3 +- .../2026062525_m08b_cleaner_tasks.down.sql | 12 + .../2026062525_m08b_cleaner_tasks.up.sql | 76 +++++ .../2026062525_m08b_cleaner_tasks.verify.sql | 30 ++ miniapp/app.json | 1 + miniapp/pages/cleaner/tasks.js | 108 +++++++ miniapp/pages/cleaner/tasks.json | 3 + miniapp/pages/cleaner/tasks.wxml | 57 ++++ miniapp/pages/cleaner/tasks.wxss | 106 +++++++ scripts/check-miniapp-m08-a.mjs | 23 +- 19 files changed, 1081 insertions(+), 10 deletions(-) create mode 100644 backend/src/cleaning/cleaning-task-repository.ts create mode 100644 backend/src/routes/cleaning.ts create mode 100644 backend/tests/cleaning-route.test.mjs create mode 100644 database/migrations/2026062525_m08b_cleaner_tasks.down.sql create mode 100644 database/migrations/2026062525_m08b_cleaner_tasks.up.sql create mode 100644 database/migrations/2026062525_m08b_cleaner_tasks.verify.sql create mode 100644 miniapp/pages/cleaner/tasks.js create mode 100644 miniapp/pages/cleaner/tasks.json create mode 100644 miniapp/pages/cleaner/tasks.wxml create mode 100644 miniapp/pages/cleaner/tasks.wxss diff --git a/backend/package.json b/backend/package.json index fe01c08..f1d7e02 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,7 @@ "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", "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": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index cae8f77..e4c8ad1 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -50,6 +50,7 @@ import { } from './routes/device-control.js'; import { registerMemberRoutes, type MemberRouteOptions } from './routes/members.js'; import { registerRechargeRoutes, type RechargeRouteOptions } from './routes/recharge.js'; +import { registerCleaningRoutes, type CleaningRouteOptions } from './routes/cleaning.js'; export interface BuildAppOptions { config?: AppConfig; @@ -72,6 +73,7 @@ export interface BuildAppOptions { deviceControl?: DeviceControlRouteOptions; members?: MemberRouteOptions; recharge?: RechargeRouteOptions; + cleaning?: CleaningRouteOptions; } declare module 'fastify' { @@ -170,6 +172,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise = [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 = [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( + `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( + `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( + `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, + page: number, + pageSize: number, + orderBy: string + ) { + const whereSql = where.join(' AND '); + const offset = (page - 1) * pageSize; + const [counts] = await this.pool.execute( + `SELECT COUNT(*) AS total FROM qipai_cleaning_tasks t WHERE ${whereSql}`, + params + ); + const [rows] = await this.pool.execute( + `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 = 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( + `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( + `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 []; + } +} diff --git a/backend/src/db/migration-runner.ts b/backend/src/db/migration-runner.ts index 0a34840..0325bfc 100644 --- a/backend/src/db/migration-runner.ts +++ b/backend/src/db/migration-runner.ts @@ -44,7 +44,8 @@ const migrationFiles: Record = { 'database/migrations/2026062421_m07a_wallet_ledger.up.sql', 'database/migrations/2026062422_m07b_recharge_plans.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: [ 'database/migrations/2026061601_m01b_core_schema.verify.sql', @@ -70,9 +71,11 @@ const migrationFiles: Record = { 'database/migrations/2026062421_m07a_wallet_ledger.verify.sql', 'database/migrations/2026062422_m07b_recharge_plans.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: [ + 'database/migrations/2026062525_m08b_cleaner_tasks.down.sql', 'database/migrations/2026062524_m08a_recharge_wechat.down.sql', 'database/migrations/2026062423_m07c_benefits.down.sql', 'database/migrations/2026062422_m07b_recharge_plans.down.sql', @@ -235,7 +238,8 @@ export async function executeMigrationPlan( 3, 3, 9, 1, 1, 1, 1, 4, 7, 1, 1, 1, 7, 7, 1, - 5, 10, 7, 3, 1 + 5, 10, 7, 3, 1, + 2, 8, 4, 3, 1 ][index] ?? 1; if (!Array.isArray(result) || result.length < minimumRows) { throw new Error( diff --git a/backend/src/routes/cleaning.ts b/backend/src/routes/cleaning.ts new file mode 100644 index 0000000..5173a3a --- /dev/null +++ b/backend/src/routes/cleaning.ts @@ -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; + authRepository: Pick; + accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; + jwtSecret: string; +} + +export async function registerCleaningRoutes( + app: FastifyInstance, + options: CleaningRouteOptions +): Promise { + 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 { + 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) { + 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 + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 0be7ff9..a35dabb 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -37,6 +37,7 @@ import { MemberProfileService } from './wallets/member-profile-service.js'; import { RechargeService } from './wallets/recharge-service.js'; import { WalletLedgerService } from './wallets/wallet-ledger-service.js'; import { MarketingBenefitService } from './wallets/marketing-benefit-service.js'; +import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js'; const config = loadConfig(); const pool = createMySqlPool(config); @@ -184,6 +185,12 @@ const app = await buildApp({ authRepository, accessControl, jwtSecret: config.auth.jwtSecret + }, + cleaning: { + repository: new CleaningTaskRepository(pool), + authRepository, + accessControl, + jwtSecret: config.auth.jwtSecret } }); app.addHook('onClose', async () => { diff --git a/backend/tests/cleaning-route.test.mjs b/backend/tests/cleaning-route.test.mjs new file mode 100644 index 0000000..6e597e0 --- /dev/null +++ b/backend/tests/cleaning-route.test.mjs @@ -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: [] + }; +} diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index 2ca6340..54cf153 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -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 rechargeWechatDownSql = read('database/migrations/2026062524_m08a_recharge_wechat.down.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 = [ 'qipai_schema_migrations', @@ -378,4 +381,21 @@ assert.match(rechargeWechatDownSql, /DROP COLUMN provider_payment_id/); assert.match(rechargeWechatVerifySql, /'provider_payment_id'/); 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.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index 426fc75..b5c7d48 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -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, /2026062421_m07a_wallet_ledger\.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.ok(plan.statements.length >= 11); diff --git a/database/migrations/2026062525_m08b_cleaner_tasks.down.sql b/database/migrations/2026062525_m08b_cleaner_tasks.down.sql new file mode 100644 index 0000000..1e9a377 --- /dev/null +++ b/database/migrations/2026062525_m08b_cleaner_tasks.down.sql @@ -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'; diff --git a/database/migrations/2026062525_m08b_cleaner_tasks.up.sql b/database/migrations/2026062525_m08b_cleaner_tasks.up.sql new file mode 100644 index 0000000..d989c2a --- /dev/null +++ b/database/migrations/2026062525_m08b_cleaner_tasks.up.sql @@ -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'); diff --git a/database/migrations/2026062525_m08b_cleaner_tasks.verify.sql b/database/migrations/2026062525_m08b_cleaner_tasks.verify.sql new file mode 100644 index 0000000..aca274e --- /dev/null +++ b/database/migrations/2026062525_m08b_cleaner_tasks.verify.sql @@ -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'; diff --git a/miniapp/app.json b/miniapp/app.json index aa8dc6d..8da7314 100644 --- a/miniapp/app.json +++ b/miniapp/app.json @@ -8,6 +8,7 @@ "pages/profile/index", "pages/benefits/index", "pages/recharge/index", + "pages/cleaner/tasks", "pages/logs/logs" ], "window": { diff --git a/miniapp/pages/cleaner/tasks.js b/miniapp/pages/cleaner/tasks.js new file mode 100644 index 0000000..10f27e0 --- /dev/null +++ b/miniapp/pages/cleaner/tasks.js @@ -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())}` +} diff --git a/miniapp/pages/cleaner/tasks.json b/miniapp/pages/cleaner/tasks.json new file mode 100644 index 0000000..2d8ade2 --- /dev/null +++ b/miniapp/pages/cleaner/tasks.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "保洁任务" +} diff --git a/miniapp/pages/cleaner/tasks.wxml b/miniapp/pages/cleaner/tasks.wxml new file mode 100644 index 0000000..9e97668 --- /dev/null +++ b/miniapp/pages/cleaner/tasks.wxml @@ -0,0 +1,57 @@ + + + + + 保洁任务 + 待结算 {{stats.pendingSettlementText}} + + + + + + + + + + + 已抢 {{stats.byStatus.CLAIMED || 0}} + 进行 {{stats.byStatus.STARTED || 0}} + 待验 {{stats.byStatus.SUBMITTED || 0}} + 完成 {{stats.byStatus.COMPLETED || 0}} + + + {{errorMessage}} + + + 暂无可接任务 + + {{item.storeName}} · {{item.roomName}} + 任务号 {{item.taskNo}} + {{item.requirement || '常规保洁'}} + + {{item.status}} + {{item.rewardText}} + + + + + + + 暂无我的任务 + + {{item.storeName}} · {{item.roomName}} + 任务号 {{item.taskNo}} + {{item.timeText}} + + {{item.status}} + {{item.rewardText}} + + + +