feat(M10-A): 完成统一通知中心与投递闭环

This commit is contained in:
Codex
2026-08-11 09:17:18 +08:00
parent 41b9cf7349
commit 5d70114bab
46 changed files with 1724 additions and 74 deletions
+2 -2
View File
@@ -24,11 +24,11 @@ if (!distOnly) {
assert.doesNotMatch(app, /import\s+(?!AdminLoginPanel\b)\w+Panel\s+from/);
const router = read('admin/src/router.ts');
for (const path of ['/overview', '/stores', '/orders', '/products', '/payments', '/third-party',
for (const path of ['/overview', '/stores', '/orders', '/products', '/notifications', '/payments', '/third-party',
'/people', '/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']) {
assert.match(read('admin/src/navigation.mjs'), new RegExp(`path: '${path.replace('/', '\\/')}'`));
}
assert.equal((router.match(/\(\) => import\('/g) || []).length, 13);
assert.equal((router.match(/\(\) => import\('/g) || []).length, 14);
assert.match(router, /router\.beforeEach/);
assert.match(router, /isNavigationAllowed/);
+2 -2
View File
@@ -24,11 +24,11 @@ for (const pattern of [
assert.doesNotMatch(app, /import\s+(?!AdminLoginPanel\b)\w+Panel\s+from/);
const router = read('admin/src/router.ts');
assert.equal((router.match(/\(\) => import\('/g) || []).length, 13);
assert.equal((router.match(/\(\) => import\('/g) || []).length, 14);
assert.match(router, /router\.beforeEach/);
const navigation = read('admin/src/navigation.mjs');
for (const path of ['/overview', '/stores', '/orders', '/products', '/payments', '/third-party', '/people',
'/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']) assert.ok(navigation.includes(`path: '${path}'`));
'/cleaning', '/notifications', '/devices', '/apps', '/content', '/franchise', '/system']) assert.ok(navigation.includes(`path: '${path}'`));
const cleaningWorkspace = read('admin/src/components/CleaningWorkspace.vue');
for (const pattern of ['CleaningTasksPanel', 'CleaningSettlementsPanel', 'CleaningStatisticsPanel',
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const root = process.cwd(); const read = (path) => readFileSync(join(root, path), 'utf8');
for (const suffix of ['up', 'down', 'verify']) assert.ok(existsSync(join(root, `database/migrations/2026081110_m10a_notification_center.${suffix}.sql`)));
const migration = read('database/migrations/2026081110_m10a_notification_center.up.sql');
for (const pattern of ['qipai_notification_templates', 'qipai_notification_routes',
'qipai_notification_subscriptions', 'qipai_notification_deliveries',
'qipai_notification_attempts', 'uq_qipai_notification_delivery_idempotency',
'scope_store_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(store_id, 0)) STORED',
'NOTIFICATION_ATTEMPT_IMMUTABLE', 'notification.read', 'notification.manage'])
assert.ok(migration.includes(pattern), `notification migration is missing ${pattern}`);
const service = read('backend/src/notifications/notification-service.ts');
for (const pattern of ['materializeOutboxEvent', 'enqueuePendingOutbox', 'handleTask',
'manualRetry', 'WECHAT_SUBSCRIBE', 'hasSubscription', 'inQuietHours',
'setSubscription', 'listInbox', 'markInboxRead', "trigger === 'MANUAL'",
'HttpNotificationAdapter', 'createNotificationAdaptersFromEnvironment',
'sensitiveKey', 'notification.dispatch', 'outbox.publish'])
assert.ok(service.includes(pattern), `notification service is missing ${pattern}`);
assert.match(read('backend/src/tasks/worker.ts'), /notification\.dispatch[\s\S]*outbox\.publish/);
assert.match(read('backend/src/products/product-order-service.ts'), /INSERT IGNORE INTO qipai_outbox_events/);
for (const source of ['backend/src/orders/pricing-repository.ts',
'backend/src/orders/order-state-repository.ts', 'backend/src/payments/payment-repository.ts',
'backend/src/payments/wechat-payment-service.ts',
'backend/src/cleaning/cleaning-task-repository.ts',
'backend/src/devices/iot-message-service.ts']) {
assert.match(read(source), /qipai_outbox_events/, `${source} must publish notification events`);
}
const routes = read('backend/src/routes/notifications.ts');
for (const pattern of ['/admin-api', '/app-api/management', '/notifications/templates',
'/notifications/routes', '/notifications/deliveries', '/retry',
'/app-api/notifications/subscriptions', '/app-api/notifications/inbox']) assert.ok(routes.includes(pattern));
const panel = read('admin/src/components/NotificationCenterPanel.vue');
for (const pattern of ['事件路由、模板与投递追踪', '人工补发', '新增通知模板',
'新增通知路由', '静默开始/结束']) assert.ok(panel.includes(pattern));
assert.match(read('admin/src/navigation.mjs'), /menuKey: 'notifications'/);
assert.match(read('admin/src/router.ts'), /NotificationCenterPanel\.vue/);
const mysql = read('backend/tests/mysql-migration-roundtrip.test.mjs');
for (const pattern of ['assertNotificationCenter', 'must-not-leak', 'explicit authorization',
'simulated webhook outage', "triggerType, operator_id AS operatorId",
'NOTIFICATION_ATTEMPT_IMMUTABLE']) assert.ok(mysql.includes(pattern));
console.log('PASS: M10-A notification templates, routing, consent, outbox fan-out, retry and operations gates are present.');
+2
View File
@@ -48,6 +48,8 @@ node scripts/check-m09-d4-product-experience.mjs
Assert-NativeSuccess "check-m09-d4-product-experience"
node scripts/check-m09-regression.mjs
Assert-NativeSuccess "check-m09-regression"
node scripts/check-m10-a-notifications.mjs
Assert-NativeSuccess "check-m10-a-notifications"
if (Test-Path "admin/package.json") {
Push-Location admin
+2 -2
View File
@@ -107,6 +107,6 @@ export QIPAI_MYSQL_USER="${username}"
export QIPAI_MYSQL_PASSWORD="${password}"
export QIPAI_MYSQL_CONNECTION_LIMIT=2
echo "INFO: MySQL ${mysql_version}; running M01-B through M09-D3 integration roundtrip in a temporary database."
echo "INFO: MySQL ${mysql_version}; running M01-B through M10-A integration roundtrip in a temporary database."
npm --prefix backend run test:mysql:migration
echo "PASS: M01-B through M09-D3 live MySQL integration roundtrip completed."
echo "PASS: M01-B through M10-A live MySQL integration roundtrip completed."