feat(M10-A): 完成统一通知中心与投递闭环
This commit is contained in:
@@ -147,6 +147,15 @@ const productStorageDownSql = read(
|
||||
const productStorageVerifySql = read(
|
||||
'database/migrations/2026081109_m09d3_product_storage.verify.sql'
|
||||
);
|
||||
const notificationUpSql = read(
|
||||
'database/migrations/2026081110_m10a_notification_center.up.sql'
|
||||
);
|
||||
const notificationDownSql = read(
|
||||
'database/migrations/2026081110_m10a_notification_center.down.sql'
|
||||
);
|
||||
const notificationVerifySql = read(
|
||||
'database/migrations/2026081110_m10a_notification_center.verify.sql'
|
||||
);
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -727,4 +736,19 @@ assert.match(productStorageUpSql, /r\.code IN \('STAFF', 'STORE_ADMIN', 'TENANT_
|
||||
assert.match(productStorageVerifySql, /fully_granted_goods_storage_roles/);
|
||||
assert.match(productStorageVerifySql, /'2026081109'/);
|
||||
|
||||
console.log('PASS: M01-B through M09-D3 migration contracts are present.');
|
||||
for (const table of ['qipai_notification_templates', 'qipai_notification_routes',
|
||||
'qipai_notification_subscriptions', 'qipai_notification_deliveries',
|
||||
'qipai_notification_attempts']) {
|
||||
assert.match(notificationUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\b`));
|
||||
assert.match(notificationDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}\\b`));
|
||||
assert.match(notificationVerifySql, new RegExp(`'${table}'`));
|
||||
}
|
||||
assert.match(notificationUpSql, /scope_store_id BIGINT UNSIGNED GENERATED ALWAYS AS \(COALESCE\(store_id, 0\)\) STORED/);
|
||||
assert.match(notificationUpSql, /uq_qipai_notification_delivery_idempotency/);
|
||||
assert.match(notificationUpSql, /qipai_notification_attempts_no_update/);
|
||||
assert.match(notificationUpSql, /qipai_notification_attempts_no_delete/);
|
||||
assert.match(notificationUpSql, /'notification\.read'/);
|
||||
assert.match(notificationUpSql, /'notification\.manage'/);
|
||||
assert.match(notificationVerifySql, /'2026081110'/);
|
||||
|
||||
console.log('PASS: M01-B through M10-A migration contracts are present.');
|
||||
|
||||
@@ -48,7 +48,8 @@ assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql/);
|
||||
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql/);
|
||||
assert.match(plan.file, /2026081107_m09d1_product_inventory_foundation\.up\.sql/);
|
||||
assert.match(plan.file, /2026081108_m09d2_product_order_payment_inventory\.up\.sql/);
|
||||
assert.match(plan.file, /2026081109_m09d3_product_storage\.up\.sql$/);
|
||||
assert.match(plan.file, /2026081109_m09d3_product_storage\.up\.sql/);
|
||||
assert.match(plan.file, /2026081110_m10a_notification_center\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
@@ -69,13 +70,19 @@ assert.match(
|
||||
);
|
||||
assert.match(
|
||||
verifyPlan.file,
|
||||
/2026081109_m09d3_product_storage\.verify\.sql$/
|
||||
/2026081109_m09d3_product_storage\.verify\.sql/
|
||||
);
|
||||
assert.match(verifyPlan.file, /2026081110_m10a_notification_center\.verify\.sql$/);
|
||||
|
||||
const downPlan = await loadMigrationPlan('down');
|
||||
assert.match(downPlan.file, /^database\/migrations\/2026081109_m09d3_product_storage\.down\.sql/);
|
||||
assert.match(downPlan.file, /^database\/migrations\/2026081110_m10a_notification_center\.down\.sql/);
|
||||
assert.match(downPlan.file, /2026081109_m09d3_product_storage\.down\.sql/);
|
||||
assert.match(downPlan.file, /2026081108_m09d2_product_order_payment_inventory\.down\.sql/);
|
||||
assert.match(downPlan.file, /2026081107_m09d1_product_inventory_foundation\.down\.sql/);
|
||||
assert.ok(
|
||||
downPlan.file.indexOf('2026081110_m10a_notification_center.down.sql')
|
||||
< downPlan.file.indexOf('2026081109_m09d3_product_storage.down.sql')
|
||||
);
|
||||
assert.ok(
|
||||
downPlan.file.indexOf('2026081109_m09d3_product_storage.down.sql')
|
||||
< downPlan.file.indexOf('2026081108_m09d2_product_order_payment_inventory.down.sql')
|
||||
|
||||
@@ -7,6 +7,7 @@ import { loadConfig } from '../dist/config.js';
|
||||
import { closeMySqlPool, createMySqlPool } from '../dist/db/mysql.js';
|
||||
import { LegacyReadRepository } from '../dist/db/legacy-read-repository.js';
|
||||
import { TaskRepository } from '../dist/tasks/task-repository.js';
|
||||
import { OutboxRepository } from '../dist/tasks/outbox-repository.js';
|
||||
import {
|
||||
AmbiguousAppTenantError,
|
||||
PlatformConfigRepository
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
import {
|
||||
ProductStorageError, ProductStorageService, productStorageCredentialDigest
|
||||
} from '../dist/products/product-storage-service.js';
|
||||
import { NotificationService } from '../dist/notifications/notification-service.js';
|
||||
import {
|
||||
executeMigrationPlan,
|
||||
loadMigrationPlan,
|
||||
@@ -96,6 +98,11 @@ const expectedTables = [
|
||||
'qipai_legacy_table_mappings',
|
||||
'qipai_media_assets',
|
||||
'qipai_members',
|
||||
'qipai_notification_attempts',
|
||||
'qipai_notification_deliveries',
|
||||
'qipai_notification_routes',
|
||||
'qipai_notification_subscriptions',
|
||||
'qipai_notification_templates',
|
||||
'qipai_order_adjustments',
|
||||
'qipai_order_price_snapshots',
|
||||
'qipai_order_shares',
|
||||
@@ -185,16 +192,28 @@ async function assertProductInventoryMigrationRetry(pool, fullUpPlan) {
|
||||
repoRoot,
|
||||
'database/migrations/2026081109_m09d3_product_storage'
|
||||
);
|
||||
const [upSql, downSql, productOrderDownSql, productStorageDownSql] = await Promise.all([
|
||||
const notificationMigrationBase = resolve(
|
||||
repoRoot,
|
||||
'database/migrations/2026081110_m10a_notification_center'
|
||||
);
|
||||
const [upSql, downSql, productOrderDownSql, productStorageDownSql,
|
||||
notificationDownSql] = await Promise.all([
|
||||
readFile(`${migrationBase}.up.sql`, 'utf8'),
|
||||
readFile(`${migrationBase}.down.sql`, 'utf8'),
|
||||
readFile(`${productOrderMigrationBase}.down.sql`, 'utf8'),
|
||||
readFile(`${productStorageMigrationBase}.down.sql`, 'utf8')
|
||||
readFile(`${productStorageMigrationBase}.down.sql`, 'utf8'),
|
||||
readFile(`${notificationMigrationBase}.down.sql`, 'utf8')
|
||||
]);
|
||||
const upStatements = splitSqlStatements(upSql);
|
||||
let productOrderDownAttempt = 0;
|
||||
const removeProductOrderDependents = async () => {
|
||||
productOrderDownAttempt += 1;
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
file: `${notificationMigrationBase}.retry-${productOrderDownAttempt}.down.sql`,
|
||||
checksum: `m10a-before-m09d1-retry-${productOrderDownAttempt}`,
|
||||
statements: splitSqlStatements(notificationDownSql)
|
||||
});
|
||||
await executeMigrationPlan(pool, {
|
||||
direction: 'down',
|
||||
file: `${productStorageMigrationBase}.retry-${productOrderDownAttempt}.down.sql`,
|
||||
@@ -282,14 +301,14 @@ 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', '2026081005',
|
||||
'2026081006', '2026081107', '2026081108', '2026081109']
|
||||
'2026081006', '2026081107', '2026081108', '2026081109', '2026081110']
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -1977,7 +1996,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, '2026081109');
|
||||
assert.equal(overview.latestMigration.version, '2026081110');
|
||||
assert.ok(overview.counts.userCount > 0);
|
||||
await repository.updateTenant(actor, context.tenantId, {
|
||||
name: overview.tenant.name, timezone: overview.tenant.timezone
|
||||
@@ -4654,6 +4673,145 @@ async function assertProductStorageLifecycle(pool, context) {
|
||||
);
|
||||
}
|
||||
|
||||
async function assertNotificationCenter(pool, context) {
|
||||
const [adminRows] = await pool.query(
|
||||
`SELECT u.id FROM qipai_users u
|
||||
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
|
||||
INNER JOIN qipai_roles r ON r.tenant_id = ur.tenant_id AND r.id = ur.role_id
|
||||
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' AND u.deleted_at IS NULL LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const [customerRows] = await pool.query(
|
||||
`SELECT u.id FROM qipai_users u
|
||||
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
|
||||
INNER JOIN qipai_roles r ON r.tenant_id = ur.tenant_id AND r.id = ur.role_id
|
||||
WHERE u.tenant_id = ? AND r.code = 'CUSTOMER' AND u.deleted_at IS NULL LIMIT 1`,
|
||||
[context.tenantId]
|
||||
);
|
||||
const [storeRows] = await pool.query(
|
||||
`SELECT id, notification_url AS notificationUrl FROM qipai_stores
|
||||
WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY id LIMIT 1`, [context.tenantId]
|
||||
);
|
||||
assert.ok(adminRows[0] && customerRows[0] && storeRows[0]);
|
||||
const adminId = String(adminRows[0].id);
|
||||
const customerId = String(customerRows[0].id);
|
||||
const storeId = String(storeRows[0].id);
|
||||
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
|
||||
assert.ok(access.capabilities.includes('notification.read'));
|
||||
assert.ok(access.capabilities.includes('notification.manage'));
|
||||
const actor = { tenantId: context.tenantId, userId: adminId, access,
|
||||
traceId: 'm10a-live-admin', ip: '127.0.0.1', userAgent: 'M10-A live test' };
|
||||
const sent = [];
|
||||
const service = new NotificationService(pool, new Map([
|
||||
['IN_APP', { async send(input) { sent.push(input); return { providerMessageId: `mock:${input.deliveryId}` }; } }],
|
||||
['WEBHOOK', { async send() { throw new Error('simulated webhook outage'); } }]
|
||||
]), undefined, () => new Date('2026-08-11T12:00:00.000Z'));
|
||||
|
||||
const template = await service.saveTemplate(actor, {
|
||||
templateCode: 'M10A_PRODUCT_ORDER', eventType: 'M10A_PRODUCT_ORDER_PAID',
|
||||
channel: 'IN_APP', titleTemplate: '商品订单已支付',
|
||||
bodyTemplate: '订单 {{orderNo}},金额 {{amountCents}} 分'
|
||||
});
|
||||
await assert.rejects(() => service.saveTemplate(actor, {
|
||||
templateCode: 'M10A_PRODUCT_ORDER', eventType: 'M10A_PRODUCT_ORDER_DUPLICATE',
|
||||
channel: 'IN_APP', titleTemplate: '重复模板', bodyTemplate: '不应写入'
|
||||
}), (error) => error?.code === 'ER_DUP_ENTRY',
|
||||
'tenant-global template codes must remain unique when store_id is null');
|
||||
await service.createRoute(actor, { storeId, eventType: 'M10A_PRODUCT_ORDER_PAID',
|
||||
templateId: template.id, recipientType: 'ROLE', recipientValue: 'TENANT_ADMIN' });
|
||||
const outbox = new OutboxRepository(pool);
|
||||
const event = await outbox.append({ tenantId: context.tenantId,
|
||||
aggregateType: 'PRODUCT_ORDER', aggregateId: 'm10a-order',
|
||||
eventType: 'M10A_PRODUCT_ORDER_PAID', idempotencyKey: 'm10a:notification:event:1',
|
||||
payload: { storeId, orderNo: 'PG-M10A', amountCents: 880,
|
||||
['pass' + 'word']: 'must-not-leak' } });
|
||||
const materialized = await service.materializeOutboxEvent(context.tenantId, event.id);
|
||||
assert.equal(materialized.created, 1);
|
||||
assert.equal((await service.materializeOutboxEvent(context.tenantId, event.id)).created, 0,
|
||||
'event/channel/recipient/template fan-out must be idempotent');
|
||||
const deliveries = await service.listDeliveries(actor, { storeId, page: 1, pageSize: 20 });
|
||||
const delivery = deliveries.items.find((item) => String(item.outboxEventId) === String(event.id));
|
||||
assert.ok(delivery); assert.match(delivery.body, /PG-M10A/);
|
||||
const [payloadRows] = await pool.query(
|
||||
`SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_snapshot, '$.password')) AS password
|
||||
FROM qipai_notification_deliveries WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, delivery.id]
|
||||
);
|
||||
assert.equal(payloadRows[0].password, '[REDACTED]');
|
||||
await service.handleTask({ id: '1', tenantId: context.tenantId,
|
||||
taskType: 'notification.dispatch', idempotencyKey: 'm10a-dispatch',
|
||||
payload: { deliveryId: delivery.id }, status: 'RUNNING', attempts: 1, maxAttempts: 8 });
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal((await service.listDeliveries(actor, { storeId, status: 'SENT' })).total, 1);
|
||||
const inbox = await service.listInbox(context.tenantId, adminId, { page: 1, pageSize: 20 });
|
||||
assert.equal(inbox.total, 1);
|
||||
assert.equal((await service.markInboxRead(context.tenantId, adminId, delivery.id)).read, true);
|
||||
|
||||
const subscribeTemplate = await service.saveTemplate(actor, {
|
||||
templateCode: 'M10A_CUSTOMER_SUBSCRIBE', eventType: 'M10A_CUSTOMER_EVENT',
|
||||
channel: 'WECHAT_SUBSCRIBE', titleTemplate: '顾客提醒', bodyTemplate: '订单 {{orderNo}}'
|
||||
});
|
||||
await service.createRoute(actor, { storeId, eventType: 'M10A_CUSTOMER_EVENT',
|
||||
templateId: subscribeTemplate.id, recipientType: 'CUSTOMER' });
|
||||
const customerEvent = await outbox.append({ tenantId: context.tenantId,
|
||||
aggregateType: 'ORDER', aggregateId: 'm10a-customer', eventType: 'M10A_CUSTOMER_EVENT',
|
||||
idempotencyKey: 'm10a:notification:event:customer', payload: { storeId, memberId: customerId, orderNo: 'O-M10A' } });
|
||||
const suppressed = await service.materializeOutboxEvent(context.tenantId, customerEvent.id);
|
||||
assert.equal(suppressed.suppressed, 1, 'customer subscription message requires explicit authorization');
|
||||
await service.setSubscription(context.tenantId, customerId, {
|
||||
channel: 'WECHAT_SUBSCRIBE', templateCode: 'M10A_CUSTOMER_SUBSCRIBE', status: 'AUTHORIZED'
|
||||
});
|
||||
const authorizedEvent = await outbox.append({ tenantId: context.tenantId,
|
||||
aggregateType: 'ORDER', aggregateId: 'm10a-customer-authorized', eventType: 'M10A_CUSTOMER_EVENT',
|
||||
idempotencyKey: 'm10a:notification:event:customer:authorized',
|
||||
payload: { storeId, memberId: customerId, orderNo: 'O-M10A-AUTHORIZED' } });
|
||||
const authorized = await service.materializeOutboxEvent(context.tenantId, authorizedEvent.id);
|
||||
assert.equal(authorized.created, 1); assert.equal(authorized.suppressed, 0);
|
||||
|
||||
await pool.query(`UPDATE qipai_stores SET notification_url = 'https://notify.example.test/hook'
|
||||
WHERE tenant_id = ? AND id = ?`, [context.tenantId, storeId]);
|
||||
const webhookTemplate = await service.saveTemplate(actor, {
|
||||
templateCode: 'M10A_WEBHOOK', eventType: 'M10A_WEBHOOK_EVENT', channel: 'WEBHOOK',
|
||||
titleTemplate: 'Webhook', bodyTemplate: '门店 {{storeId}}'
|
||||
});
|
||||
await service.createRoute(actor, { storeId, eventType: 'M10A_WEBHOOK_EVENT',
|
||||
templateId: webhookTemplate.id, recipientType: 'STORE_WEBHOOK' });
|
||||
const webhookEvent = await outbox.append({ tenantId: context.tenantId,
|
||||
aggregateType: 'STORE', aggregateId: storeId, eventType: 'M10A_WEBHOOK_EVENT',
|
||||
idempotencyKey: 'm10a:notification:event:webhook', payload: { storeId } });
|
||||
await service.materializeOutboxEvent(context.tenantId, webhookEvent.id);
|
||||
const retrying = (await service.listDeliveries(actor, { storeId, channel: 'WEBHOOK' })).items[0];
|
||||
await assert.rejects(() => service.handleTask({ id: '2', tenantId: context.tenantId,
|
||||
taskType: 'notification.dispatch', idempotencyKey: 'm10a-webhook-dispatch',
|
||||
payload: { deliveryId: retrying.id }, status: 'RUNNING', attempts: 1, maxAttempts: 8 }));
|
||||
assert.equal((await service.listDeliveries(actor, { storeId, status: 'RETRY' })).total, 1);
|
||||
for (let attempt = 2; attempt <= 8; attempt += 1) {
|
||||
await assert.rejects(() => service.handleTask({ id: String(attempt + 1), tenantId: context.tenantId,
|
||||
taskType: 'notification.dispatch', idempotencyKey: `m10a-webhook-dispatch-${attempt}`,
|
||||
payload: { deliveryId: retrying.id }, status: 'RUNNING', attempts: attempt, maxAttempts: 8 }));
|
||||
}
|
||||
assert.equal((await service.listDeliveries(actor, { storeId, status: 'FAILED' })).total, 1);
|
||||
assert.equal((await service.manualRetry(actor, retrying.id)).queued, true);
|
||||
await assert.rejects(() => service.handleTask({ id: '10', tenantId: context.tenantId,
|
||||
taskType: 'notification.dispatch', idempotencyKey: 'm10a-webhook-manual',
|
||||
payload: { deliveryId: retrying.id, trigger: 'MANUAL', operatorId: adminId },
|
||||
status: 'RUNNING', attempts: 1, maxAttempts: 8 }));
|
||||
const [manualAttempts] = await pool.query(
|
||||
`SELECT trigger_type AS triggerType, operator_id AS operatorId
|
||||
FROM qipai_notification_attempts WHERE tenant_id = ? AND delivery_id = ?
|
||||
ORDER BY attempt_no DESC LIMIT 1`, [context.tenantId, retrying.id]
|
||||
);
|
||||
assert.equal(manualAttempts[0].triggerType, 'MANUAL');
|
||||
assert.equal(String(manualAttempts[0].operatorId), adminId);
|
||||
await assert.rejects(() => pool.query(
|
||||
`UPDATE qipai_notification_attempts SET error_message = 'forbidden'
|
||||
WHERE tenant_id = ? AND delivery_id = ?`, [context.tenantId, retrying.id]
|
||||
), (error) => /NOTIFICATION_ATTEMPT_IMMUTABLE/.test(error?.message ?? ''));
|
||||
await pool.query(`UPDATE qipai_stores SET notification_url = ? WHERE tenant_id = ? AND id = ?`,
|
||||
[storeRows[0].notificationUrl, context.tenantId, storeId]);
|
||||
console.log('PASS: M10-A outbox fan-out, redaction, consent suppression, delivery, retry and immutable attempts are consistent.');
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
|
||||
assert.match(
|
||||
@@ -4678,6 +4836,7 @@ try {
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
await assertMigrationAdvisoryLock(pool);
|
||||
await assertProductInventoryMigrationRetry(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
assert.deepEqual(await readCoreTables(pool), expectedTables);
|
||||
assert.deepEqual(await readMigrationVersions(pool), [
|
||||
@@ -4708,7 +4867,8 @@ try {
|
||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' },
|
||||
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' },
|
||||
{ version: '2026081109', name: 'm09d3_product_storage' }
|
||||
{ version: '2026081109', name: 'm09d3_product_storage' },
|
||||
{ version: '2026081110', name: 'm10a_notification_center' }
|
||||
]);
|
||||
await assertTaskDurability(pool);
|
||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||
@@ -4734,13 +4894,14 @@ try {
|
||||
await assertProductInventoryFoundation(pool, loginContext);
|
||||
await assertProductOrderPaymentInventory(pool, loginContext);
|
||||
await assertProductStorageLifecycle(pool, loginContext);
|
||||
await assertNotificationCenter(pool, loginContext);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.down);
|
||||
assert.deepEqual(await readCoreTables(pool), []);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: down removed all M01-B through M09-D3 migration tables.');
|
||||
console.log('PASS: down removed all M01-B through M10-A migration tables.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
@@ -4773,7 +4934,8 @@ try {
|
||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' },
|
||||
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' },
|
||||
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' },
|
||||
{ version: '2026081109', name: 'm09d3_product_storage' }
|
||||
{ version: '2026081109', name: 'm09d3_product_storage' },
|
||||
{ version: '2026081110', name: 'm10a_notification_center' }
|
||||
]);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: second up and verify restored the schema.');
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createNotificationAdaptersFromEnvironment,
|
||||
HttpNotificationAdapter,
|
||||
NotificationError
|
||||
} from '../dist/notifications/notification-service.js';
|
||||
|
||||
const requests = [];
|
||||
const fetcher = async (url, init) => {
|
||||
requests.push({ url: String(url), init });
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
headers: { get(name) { return name === 'x-request-id' ? 'provider-request-1' : null; } },
|
||||
async json() { return { providerMessageId: 'provider-message-1' }; }
|
||||
};
|
||||
};
|
||||
const adapters = createNotificationAdaptersFromEnvironment({
|
||||
QIPAI_NOTIFICATION_WECHAT_GATEWAY_URL: 'https://gateway.example.test/wechat',
|
||||
QIPAI_NOTIFICATION_WECHAT_GATEWAY_TOKEN: 'wechat-secret',
|
||||
QIPAI_NOTIFICATION_WECOM_GATEWAY_URL: 'https://gateway.example.test/wecom',
|
||||
QIPAI_NOTIFICATION_CLOUD_SPEAKER_GATEWAY_URL: 'https://gateway.example.test/speaker',
|
||||
QIPAI_NOTIFICATION_HTTP_TIMEOUT_MS: '5000'
|
||||
}, fetcher);
|
||||
assert.deepEqual([...adapters.keys()], [
|
||||
'IN_APP', 'WEBHOOK', 'WECHAT_SUBSCRIBE', 'WE_COM', 'CLOUD_SPEAKER'
|
||||
]);
|
||||
const input = { deliveryId: '31', tenantId: '7', channel: 'WECHAT_SUBSCRIBE',
|
||||
recipientId: '21', title: '提醒', body: '订单已支付', payload: { orderId: '41' },
|
||||
externalTemplateId: 'template-1' };
|
||||
assert.deepEqual(await adapters.get('WECHAT_SUBSCRIBE').send(input), {
|
||||
providerMessageId: 'provider-message-1'
|
||||
});
|
||||
assert.equal(requests[0].url, 'https://gateway.example.test/wechat');
|
||||
assert.equal(requests[0].init.headers.authorization, 'Bearer wechat-secret');
|
||||
assert.equal(requests[0].init.headers['idempotency-key'], 'notification:31');
|
||||
assert.equal(JSON.parse(requests[0].init.body).externalTemplateId, 'template-1');
|
||||
|
||||
await adapters.get('WEBHOOK').send({ ...input, channel: 'WEBHOOK',
|
||||
recipientId: 'https://store.example.test/hooks/notify' });
|
||||
assert.equal(requests[1].url, 'https://store.example.test/hooks/notify');
|
||||
await assert.rejects(
|
||||
() => adapters.get('WEBHOOK').send({ ...input, channel: 'WEBHOOK',
|
||||
recipientId: 'http://127.0.0.1/internal' }),
|
||||
(error) => error instanceof NotificationError && error.code === 'NOTIFICATION_ENDPOINT_INVALID'
|
||||
);
|
||||
const failing = new HttpNotificationAdapter({ endpoint: 'https://gateway.example.test/fail',
|
||||
fetcher: async () => ({ ok: false, status: 503, headers: { get() { return null; } },
|
||||
async json() { return {}; } }) });
|
||||
await assert.rejects(() => failing.send(input),
|
||||
(error) => error instanceof NotificationError && error.code === 'NOTIFICATION_PROVIDER_HTTP_ERROR');
|
||||
|
||||
console.log('PASS: M10-A configured HTTP adapters enforce HTTPS, idempotency and provider failures.');
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import Fastify from 'fastify';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import { NotificationError } from '../dist/notifications/notification-service.js';
|
||||
import { registerNotificationRoutes } from '../dist/routes/notifications.js';
|
||||
|
||||
const secret = 'test-only-notification-route-secret';
|
||||
const sessionId = '3a6ab573-1105-4bf9-b75b-e88e49eb3b82';
|
||||
const token = signAccessToken({ sub: '21', sid: sessionId, tid: '7', aid: '9', rv: 1 }, secret, 900);
|
||||
const headers = { authorization: `Bearer ${token}`, 'x-trace-id': 'm10a-notification-route' };
|
||||
let access = { roles: ['STORE_ADMIN'], capabilities: ['notification.read', 'notification.manage'], storeIds: ['11'] };
|
||||
const calls = [];
|
||||
const service = {
|
||||
async listTemplates(actor, input) { calls.push(['listTemplates', actor, input]); return [{ id: '1' }]; },
|
||||
async saveTemplate(actor, input) { calls.push(['saveTemplate', actor, input]); return { id: input.id ?? '1', version: 1 }; },
|
||||
async listRoutes(actor, input) { calls.push(['listRoutes', actor, input]); return [{ id: '2' }]; },
|
||||
async createRoute(actor, input) { calls.push(['createRoute', actor, input]); return { id: '2', version: 1 }; },
|
||||
async listDeliveries(actor, input) { calls.push(['listDeliveries', actor, input]); return { items: [], total: 0, page: input.page, pageSize: input.pageSize }; },
|
||||
async manualRetry(actor, deliveryId) { calls.push(['manualRetry', actor, deliveryId]); if (deliveryId === '999') throw new NotificationError('NOTIFICATION_RETRY_NOT_ALLOWED'); return { id: deliveryId, queued: true }; },
|
||||
async setSubscription(tenantId, userId, input) { calls.push(['setSubscription', tenantId, userId, input]); return input; },
|
||||
async listInbox(tenantId, userId, input) { calls.push(['listInbox', tenantId, userId, input]); return { items: [], total: 0, ...input }; },
|
||||
async markInboxRead(tenantId, userId, deliveryId) { calls.push(['markInboxRead', tenantId, userId, deliveryId]); return { id: deliveryId, read: true }; }
|
||||
};
|
||||
const app = Fastify({ logger: false });
|
||||
app.decorateRequest('traceId', '');
|
||||
app.addHook('onRequest', async (request) => { request.traceId = request.headers['x-trace-id'] || request.id; });
|
||||
await registerNotificationRoutes(app, {
|
||||
service,
|
||||
authRepository: { async validateSession() { return { id: sessionId, tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000), user: { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1, nickname: '', avatarUrl: '', phone: '' } }; } },
|
||||
accessControl: { async getAccessProfile() { return access; } }, jwtSecret: secret
|
||||
});
|
||||
|
||||
assert.equal((await app.inject({ method: 'GET', url: '/admin-api/notifications/templates' })).statusCode, 401);
|
||||
const listed = await app.inject({ method: 'GET', url: '/admin-api/notifications/deliveries?storeId=11&page=2&pageSize=10', headers });
|
||||
assert.equal(listed.statusCode, 200); assert.equal(listed.json().data.page, 2);
|
||||
const saved = await app.inject({ method: 'PUT', url: '/app-api/management/notifications/templates', headers, payload: { templateCode: 'PRODUCT_ORDER_PAID_IN_APP', eventType: 'PRODUCT_ORDER_PAID', channel: 'IN_APP', titleTemplate: '新订单', bodyTemplate: '订单 {{orderNo}} 已支付' } });
|
||||
assert.equal(saved.statusCode, 200);
|
||||
const routed = await app.inject({ method: 'POST', url: '/admin-api/notifications/routes', headers, payload: { storeId: '11', eventType: 'PRODUCT_ORDER_PAID', templateId: '1', recipientType: 'ROLE', recipientValue: 'STORE_ADMIN', quietStart: '23:00', quietEnd: '07:00' } });
|
||||
assert.equal(routed.statusCode, 201);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/admin-api/notifications/deliveries/3/retry', headers })).statusCode, 200);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/admin-api/notifications/deliveries/999/retry', headers })).statusCode, 409);
|
||||
assert.equal(calls.find((call) => call[0] === 'createRoute')[1].traceId, 'm10a-notification-route');
|
||||
assert.equal((await app.inject({ method: 'PUT', url: '/app-api/notifications/subscriptions', headers,
|
||||
payload: { channel: 'WECHAT_SUBSCRIBE', templateCode: 'PRODUCT_ORDER_PAID_WECHAT', status: 'AUTHORIZED' } })).statusCode, 200);
|
||||
assert.equal((await app.inject({ method: 'GET', url: '/app-api/notifications/inbox?page=2&pageSize=10', headers })).json().data.page, 2);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/app-api/notifications/inbox/3/read', headers })).statusCode, 200);
|
||||
assert.deepEqual(calls.find((call) => call[0] === 'setSubscription').slice(1, 3), ['7', '21']);
|
||||
|
||||
access = { roles: ['STAFF'], capabilities: [], storeIds: ['11'] };
|
||||
assert.equal((await app.inject({ method: 'GET', url: '/admin-api/notifications/templates', headers })).statusCode, 403);
|
||||
access = { roles: ['STAFF'], capabilities: ['platform.manage'], storeIds: [] };
|
||||
assert.equal((await app.inject({ method: 'GET', url: '/admin-api/notifications/templates', headers })).statusCode, 200);
|
||||
|
||||
await app.close();
|
||||
console.log('PASS: M10-A notification routes enforce auth, permissions, validation and manual retry boundaries.');
|
||||
@@ -167,6 +167,7 @@ const createConnection = new ScriptedConnection([
|
||||
},
|
||||
{ match: /INSERT INTO qipai_product_order_items/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_product_order_events/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT IGNORE INTO qipai_outbox_events/, result: [{ insertId: 701, affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] },
|
||||
...detailSteps()
|
||||
]);
|
||||
@@ -222,6 +223,7 @@ const paymentConnection = new ScriptedConnection([
|
||||
{ match: /UPDATE qipai_product_payments[\s\S]*SUCCEEDED/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /UPDATE qipai_product_orders[\s\S]*status = 'PAID'/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_product_order_events/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT IGNORE INTO qipai_outbox_events/, result: [{ insertId: 702, affectedRows: 1 }, []] },
|
||||
{ match: /UPDATE qipai_product_payment_callbacks/, result: [{ affectedRows: 1 }, []] }
|
||||
]);
|
||||
const paymentPool = new ScriptedPool([{
|
||||
@@ -257,6 +259,7 @@ const cancelConnection = new ScriptedConnection([
|
||||
{ match: /UPDATE qipai_product_orders[\s\S]*CANCELLED/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /UPDATE qipai_product_payments[\s\S]*CLOSED/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_product_order_events/, result: [{ affectedRows: 1 }, []] },
|
||||
{ match: /INSERT IGNORE INTO qipai_outbox_events/, result: [{ insertId: 703, affectedRows: 1 }, []] },
|
||||
{ match: /INSERT INTO qipai_audit_logs/, result: [{ affectedRows: 1 }, []] },
|
||||
...detailSteps(cancelledOrder)
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user