feat(M09-B): 完成清洁规则与照片验收闭环

This commit is contained in:
Codex
2026-08-10 21:49:07 +08:00
parent b507e87999
commit bc7cb8fab9
24 changed files with 1650 additions and 81 deletions
+150 -11
View File
@@ -60,6 +60,9 @@ const expectedTables = [
'qipai_async_tasks',
'qipai_audit_logs',
'qipai_auth_sessions',
'qipai_cleaning_task_photos',
'qipai_cleaning_task_submissions',
'qipai_cleaning_templates',
'qipai_collection_accounts',
'qipai_device_alerts',
'qipai_device_channels',
@@ -139,13 +142,13 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220', '2026081002', '2026081003', '2026081004']
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005']
);
return rows;
}
@@ -1833,7 +1836,7 @@ async function assertSystemOperations(pool, context) {
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
const overview = await repository.getSystemOverview(context.tenantId);
assert.equal(overview.tenant.id, context.tenantId);
assert.equal(overview.latestMigration.version, '2026081004');
assert.equal(overview.latestMigration.version, '2026081005');
assert.ok(overview.counts.userCount > 0);
await repository.updateTenant(actor, context.tenantId, {
name: overview.tenant.name, timezone: overview.tenant.timezone
@@ -2528,6 +2531,19 @@ async function assertCleaningTaskTransactions(pool, context) {
[context.tenantId, taskId, userId, memberRole, rewardCents, removedAt, settledAt]
);
};
const recordPhoto = async (taskId, userId, suffix) => repository.recordPhotoUpload({
...cleanerActor(userId, `m09b-photo-${suffix}`),
taskId,
image: {
storagePath: `tenants/${context.tenantId}/stores/${storeId}/${suffix}.webp`,
publicUrl: `https://api.txyundm.cn/uploads/tenants/${context.tenantId}/stores/${storeId}/${suffix}.webp`,
mimeType: 'image/webp',
byteSize: 1024,
width: 1280,
height: 720,
checksumSha256: suffix.padEnd(64, '0').slice(0, 64)
}
});
const rollbackTaskId = await insertTask();
const rollbackTrace = `m09a-event-failure-${rollbackTaskId}`;
@@ -2650,9 +2666,10 @@ async function assertCleaningTaskTransactions(pool, context) {
await repository.start({
...cleanerActor(firstCleanerId, 'm09a-start'), taskId: lifecycleTaskId
});
const firstPhoto = await recordPhoto(lifecycleTaskId, firstCleanerId, 'm09b-first');
await repository.submit({
...cleanerActor(firstCleanerId, 'm09a-submit-first'), taskId: lifecycleTaskId,
photoUrls: ['https://api.txyundm.cn/uploads/m09a-first.webp'], note: 'first submit'
photoUrls: [firstPhoto.publicUrl], note: 'first submit'
});
await repository.reject({
...managerActor('m09a-reject'), taskId: lifecycleTaskId, reason: 'needs rework'
@@ -2666,9 +2683,10 @@ async function assertCleaningTaskTransactions(pool, context) {
await repository.rework({
...cleanerActor(firstCleanerId, 'm09a-rework'), taskId: lifecycleTaskId
});
const secondPhoto = await recordPhoto(lifecycleTaskId, firstCleanerId, 'm09b-second');
await repository.submit({
...cleanerActor(firstCleanerId, 'm09a-submit-second'), taskId: lifecycleTaskId,
photoUrls: ['https://api.txyundm.cn/uploads/m09a-second.webp'], note: 'second submit'
photoUrls: [secondPhoto.publicUrl], note: 'second submit'
});
const completeInput = {
...managerActor('m09a-complete'), taskId: lifecycleTaskId, note: 'accepted'
@@ -2688,6 +2706,74 @@ async function assertCleaningTaskTransactions(pool, context) {
status: lifecycleRows[0].status,
completedSet: Number(lifecycleRows[0].completedSet)
}, { status: 'COMPLETED', completedSet: 1 });
const [submissionRows] = await pool.query(
`SELECT revision, status, JSON_UNQUOTE(JSON_EXTRACT(photo_urls_json, '$[0]')) AS photoUrl,
reject_reason AS rejectReason
FROM qipai_cleaning_task_submissions
WHERE tenant_id = ? AND task_id = ? ORDER BY revision`,
[context.tenantId, lifecycleTaskId]
);
assert.deepEqual(submissionRows.map((row) => ({
revision: Number(row.revision), status: row.status,
photoUrl: row.photoUrl, rejectReason: row.rejectReason
})), [
{ revision: 2, status: 'REJECTED', photoUrl: firstPhoto.publicUrl, rejectReason: 'needs rework' },
{ revision: 3, status: 'ACCEPTED', photoUrl: secondPhoto.publicUrl, rejectReason: '' }
]);
const [photoRows] = await pool.query(
`SELECT public_url AS publicUrl, status, attached_revision AS attachedRevision
FROM qipai_cleaning_task_photos
WHERE tenant_id = ? AND task_id = ? ORDER BY attached_revision`,
[context.tenantId, lifecycleTaskId]
);
assert.deepEqual(photoRows.map((row) => ({
publicUrl: row.publicUrl, status: row.status, revision: Number(row.attachedRevision)
})), [
{ publicUrl: firstPhoto.publicUrl, status: 'ATTACHED', revision: 2 },
{ publicUrl: secondPhoto.publicUrl, status: 'ATTACHED', revision: 3 }
]);
const submissionHistory = await repository.listSubmissions({
...managerActor('m09b-submission-history'), taskId: lifecycleTaskId
});
assert.deepEqual(submissionHistory.map((submission) => ({
revision: submission.revision,
status: submission.status,
photoUrl: submission.photoUrls[0],
rejectReason: submission.rejectReason
})), [
{ revision: 3, status: 'ACCEPTED', photoUrl: secondPhoto.publicUrl, rejectReason: '' },
{ revision: 2, status: 'REJECTED', photoUrl: firstPhoto.publicUrl, rejectReason: 'needs rework' }
]);
const cleanupTaskId = await insertTask({
status: 'STARTED', cleanerUserId: firstCleanerId,
claimedAt: new Date(), startedAt: new Date()
});
const expiredPhoto = await recordPhoto(cleanupTaskId, firstCleanerId, 'm09b-expired');
await pool.query(
`UPDATE qipai_cleaning_task_photos SET retention_until = TIMESTAMPADD(DAY, -1, UTC_TIMESTAMP(3))
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, expiredPhoto.id]
);
const expiredClaims = await repository.claimExpiredPhotoUploads({
...managerActor('m09b-photo-cleanup-claim'), limit: 10
});
assert.deepEqual(expiredClaims, [{ id: expiredPhoto.id, storagePath: expiredPhoto.storagePath }]);
assert.deepEqual(
await repository.markPhotoUploadsDeleted({
...managerActor('m09b-photo-cleanup-delete'), photoIds: [expiredPhoto.id]
}),
{ deleted: 1 }
);
const [deletedPhotoRows] = await pool.query(
`SELECT status, deleted_at IS NOT NULL AS deleted
FROM qipai_cleaning_task_photos WHERE tenant_id = ? AND id = ?`,
[context.tenantId, expiredPhoto.id]
);
assert.deepEqual(
{ status: deletedPhotoRows[0].status, deleted: Number(deletedPhotoRows[0].deleted) },
{ status: 'ORPHANED', deleted: 1 }
);
const [lifecycleEventRows] = await pool.query(
`SELECT action FROM qipai_cleaning_task_events
WHERE tenant_id = ? AND task_id = ? ORDER BY id`,
@@ -2782,6 +2868,26 @@ async function assertCleaningTaskTransactions(pool, context) {
assert.equal(settledMemberRows[0].removedAt, null);
assert.equal(Number(settledMemberRows[0].settled), 1);
const deniedExemptTaskId = await insertTask();
await pool.query(
`UPDATE qipai_cleaning_tasks SET exempt_policy = 'DISABLED'
WHERE tenant_id = ? AND id = ?`,
[context.tenantId, deniedExemptTaskId]
);
await assert.rejects(
() => repository.exempt({
...managerActor('m09b-exempt-denied'), taskId: deniedExemptTaskId, note: 'not allowed'
}),
(error) => error instanceof CleaningTaskError && error.code === 'CLEANING_EXEMPT_POLICY_DENIED'
);
const template = await repository.upsertTemplate({
...managerActor('m09b-template-v1'), scopeType: 'TENANT', name: 'M09-B tenant default',
requirement: '两张照片并在开始前决定免清洁', photoRequired: true,
minPhotoCount: 2, maxPhotoCount: 3, exemptPolicy: 'BEFORE_START', status: 'ACTIVE'
});
assert.equal(template.version, 1);
const [orderResult] = await pool.query(
`INSERT INTO qipai_orders
(tenant_id, store_id, room_id, order_no, status, start_at, end_at,
@@ -2812,7 +2918,10 @@ async function assertCleaningTaskTransactions(pool, context) {
orderConnection.release();
}
const [generatedRows] = await pool.query(
`SELECT t.status,
`SELECT t.status, t.cleaning_template_id AS cleaningTemplateId,
t.cleaning_template_version AS cleaningTemplateVersion,
t.photo_required AS photoRequired, t.min_photo_count AS minPhotoCount,
t.max_photo_count AS maxPhotoCount, t.exempt_policy AS exemptPolicy,
(SELECT COUNT(*) FROM qipai_cleaning_task_events e
WHERE e.tenant_id = t.tenant_id AND e.task_id = t.id
AND e.action = 'AUTO_CREATE') AS eventCount
@@ -2822,10 +2931,34 @@ async function assertCleaningTaskTransactions(pool, context) {
);
assert.equal(generatedRows.length, 1);
assert.equal(generatedRows[0].status, 'WAITING');
assert.equal(String(generatedRows[0].cleaningTemplateId), template.id);
assert.equal(Number(generatedRows[0].cleaningTemplateVersion), 1);
assert.equal(Number(generatedRows[0].photoRequired), 1);
assert.equal(Number(generatedRows[0].minPhotoCount), 2);
assert.equal(Number(generatedRows[0].maxPhotoCount), 3);
assert.equal(generatedRows[0].exemptPolicy, 'BEFORE_START');
assert.equal(Number(generatedRows[0].eventCount), 1);
const updatedTemplate = await repository.upsertTemplate({
...managerActor('m09b-template-v2'), scopeType: 'TENANT', name: 'M09-B tenant default',
requirement: '新任务无需照片', photoRequired: false,
minPhotoCount: 0, maxPhotoCount: 2, exemptPolicy: 'DISABLED', status: 'ACTIVE'
});
assert.equal(updatedTemplate.version, 2);
const [snapshotRows] = await pool.query(
`SELECT cleaning_template_version AS version, min_photo_count AS minPhotoCount,
exempt_policy AS exemptPolicy
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND order_id = ?`,
[context.tenantId, finishedOrderId]
);
assert.deepEqual({
version: Number(snapshotRows[0].version),
minPhotoCount: Number(snapshotRows[0].minPhotoCount),
exemptPolicy: snapshotRows[0].exemptPolicy
}, { version: 1, minPhotoCount: 2, exemptPolicy: 'BEFORE_START' });
console.log(
'PASS: M09-A cleaning claims, lifecycle events, rollback, reassignment, timeout reclaim and order generation are transactional.'
'PASS: M09-A/M09-B cleaning transactions, template snapshots, photo revisions and exemption rules are consistent.'
);
}
@@ -2875,7 +3008,8 @@ try {
{ version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' }
{ version: '2026081004', name: 'm08d_admin_password_auth' },
{ version: '2026081005', name: 'm09b_cleaning_rules' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -2904,7 +3038,7 @@ try {
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B through M06-C migration tables.');
console.log('PASS: down removed all M01-B through M09-B migration tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -2932,7 +3066,8 @@ try {
{ version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' }
{ version: '2026081004', name: 'm08d_admin_password_auth' },
{ version: '2026081005', name: 'm09b_cleaning_rules' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -3069,7 +3204,11 @@ try {
'idempotent cleaning completion trace',
'SKIP LOCKED cleaning timeout reclaim batch',
'settled cleaning member preservation',
'finished order creates one cleaning task'
'finished order creates one cleaning task',
'cleaning template snapshot remains stable after config version change',
'rejected and accepted cleaning photo revisions remain distinguishable',
'expired unattached cleaning photo retention cleanup',
'task-level cleaning exemption policy denial'
]
}, null, 2));
} finally {