feat(M10-A): 完成统一通知中心与投递闭环
This commit is contained in:
@@ -16,6 +16,13 @@ QIPAI_TEST_PAYMENT_ENABLED=false
|
||||
QIPAI_WECHAT_PAY_CREDENTIALS={}
|
||||
QIPAI_PROFIT_SHARE_MOCK_ENABLED=false
|
||||
QIPAI_THIRD_PARTY_CREDENTIALS={}
|
||||
QIPAI_NOTIFICATION_HTTP_TIMEOUT_MS=10000
|
||||
QIPAI_NOTIFICATION_WECHAT_GATEWAY_URL=
|
||||
QIPAI_NOTIFICATION_WECHAT_GATEWAY_TOKEN=
|
||||
QIPAI_NOTIFICATION_WECOM_GATEWAY_URL=
|
||||
QIPAI_NOTIFICATION_WECOM_GATEWAY_TOKEN=
|
||||
QIPAI_NOTIFICATION_CLOUD_SPEAKER_GATEWAY_URL=
|
||||
QIPAI_NOTIFICATION_CLOUD_SPEAKER_GATEWAY_TOKEN=
|
||||
QIPAI_MQTT_URL=mqtt://101.42.38.246:1883
|
||||
QIPAI_MQTT_CLIENT_ID=qipai-backend
|
||||
QIPAI_MQTT_USERNAME=
|
||||
|
||||
@@ -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",
|
||||
"pretest": "npm run build && node tests/product-storage-service.test.mjs && node tests/product-storage-route.test.mjs",
|
||||
"pretest": "npm run build && node tests/product-storage-service.test.mjs && node tests/product-storage-route.test.mjs && node tests/notification-adapter.test.mjs && node tests/notification-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/admin-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/franchise.test.mjs && node tests/system-operations.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-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs && node tests/product-order-service.test.mjs && node tests/product-order-route.test.mjs && node tests/product-reconciliation.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
registerProductStorageRoutes,
|
||||
type ProductStorageRouteOptions
|
||||
} from './routes/product-storages.js';
|
||||
import { registerNotificationRoutes, type NotificationRouteOptions } from './routes/notifications.js';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
@@ -104,6 +105,7 @@ export interface BuildAppOptions {
|
||||
inventory?: InventoryRouteOptions;
|
||||
productOrders?: ProductOrderRouteOptions;
|
||||
productStorages?: ProductStorageRouteOptions;
|
||||
notifications?: NotificationRouteOptions;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -232,6 +234,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
if (options.productStorages) {
|
||||
await registerProductStorageRoutes(app, options.productStorages);
|
||||
}
|
||||
if (options.notifications) {
|
||||
await registerNotificationRoutes(app, options.notifications);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -152,14 +152,16 @@ export class AuthRepository {
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'goods.storage.read', 'goods.storage.manage'))
|
||||
'goods.storage.read', 'goods.storage.manage',
|
||||
'notification.read', 'notification.manage'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'goods.storage.read', 'goods.storage.manage'))
|
||||
'goods.storage.read', 'goods.storage.manage',
|
||||
'notification.read', 'notification.manage'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
[input.context.tenantId, input.context.tenantId]
|
||||
);
|
||||
|
||||
@@ -52,6 +52,7 @@ export class RbacRepository {
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'goods.storage.read', 'goods.storage.manage',
|
||||
'notification.read', 'notification.manage',
|
||||
'cleaning.task.read', 'cleaning.task.write',
|
||||
'cleaning.statistics.read'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
@@ -61,6 +62,7 @@ export class RbacRepository {
|
||||
'inventory.read', 'inventory.adjust',
|
||||
'goods.order.read', 'goods.order.manage',
|
||||
'goods.storage.read', 'goods.storage.manage',
|
||||
'notification.read', 'notification.manage',
|
||||
'cleaning.task.read', 'cleaning.task.write',
|
||||
'cleaning.statistics.read'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
|
||||
@@ -2057,7 +2057,7 @@ export class CleaningTaskRepository {
|
||||
action: string,
|
||||
note: string
|
||||
) {
|
||||
await connection.execute(
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_cleaning_task_events
|
||||
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT('actorType', ?))`,
|
||||
@@ -2065,6 +2065,17 @@ export class CleaningTaskRepository {
|
||||
`${input.traceId}-${taskId}`.slice(0, 128), note.slice(0, 512),
|
||||
input.actorType ?? 'USER']
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT IGNORE INTO qipai_outbox_events
|
||||
(tenant_id, aggregate_type, aggregate_id, event_type, idempotency_key, payload)
|
||||
SELECT t.tenant_id, 'CLEANING_TASK', CAST(t.id AS CHAR), ?, ?,
|
||||
JSON_OBJECT('storeId', t.store_id, 'roomId', t.room_id,
|
||||
'orderId', t.order_id, 'taskId', t.id, 'cleanerUserId', t.cleaner_user_id,
|
||||
'fromStatus', ?, 'status', ?, 'action', ?)
|
||||
FROM qipai_cleaning_tasks t WHERE t.tenant_id = ? AND t.id = ?`,
|
||||
[`CLEANING_TASK_${action}`, `cleaning:${taskId}:event:${result.insertId}:notification`,
|
||||
from, to, action, input.tenantId, taskId]
|
||||
);
|
||||
}
|
||||
|
||||
private async recordSettlementEventWithConnection(
|
||||
|
||||
@@ -73,7 +73,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql',
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql',
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql',
|
||||
'database/migrations/2026081109_m09d3_product_storage.up.sql'
|
||||
'database/migrations/2026081109_m09d3_product_storage.up.sql',
|
||||
'database/migrations/2026081110_m10a_notification_center.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -113,9 +114,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql',
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql',
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql',
|
||||
'database/migrations/2026081109_m09d3_product_storage.verify.sql'
|
||||
'database/migrations/2026081109_m09d3_product_storage.verify.sql',
|
||||
'database/migrations/2026081110_m10a_notification_center.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026081110_m10a_notification_center.down.sql',
|
||||
'database/migrations/2026081109_m09d3_product_storage.down.sql',
|
||||
'database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql',
|
||||
'database/migrations/2026081107_m09d1_product_inventory_foundation.down.sql',
|
||||
|
||||
@@ -234,6 +234,9 @@ export class IotMessageService {
|
||||
}
|
||||
|
||||
private async applyAlerts(device: DeviceRow, message: NormalizedVendorMessage) {
|
||||
if (message.eventType === 'will') {
|
||||
await this.upsertAlert(device, 'DEVICE_OFFLINE', 'HIGH', 'Device reported an offline will event.');
|
||||
}
|
||||
if (message.result === 'UNKNOWN_VENDOR_RESULT') {
|
||||
await this.upsertAlert(device, 'UNKNOWN_VENDOR_RESULT', 'MEDIUM',
|
||||
`Unsupported vendor result for ${message.eventType}.`);
|
||||
@@ -277,7 +280,7 @@ export class IotMessageService {
|
||||
private async upsertAlert(
|
||||
device: DeviceRow, alertType: string, severity: string, summary: string
|
||||
) {
|
||||
await this.pool.execute(
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_device_alerts
|
||||
(tenant_id, device_id, store_id, room_id, alert_type, severity, summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -286,6 +289,17 @@ export class IotMessageService {
|
||||
[device.tenantId, device.id, device.storeId, device.roomId,
|
||||
alertType, severity, summary]
|
||||
);
|
||||
if (result.affectedRows !== 1 || !result.insertId) return;
|
||||
await this.pool.execute(
|
||||
`INSERT IGNORE INTO qipai_outbox_events
|
||||
(tenant_id, aggregate_type, aggregate_id, event_type, idempotency_key, payload)
|
||||
VALUES (?, 'DEVICE_ALERT', ?, ?, ?, JSON_OBJECT(
|
||||
'storeId', ?, 'roomId', ?, 'deviceId', ?, 'alertId', ?,
|
||||
'alertType', ?, 'severity', ?, 'summary', ?))`,
|
||||
[device.tenantId, String(result.insertId), `DEVICE_ALERT_${alertType}`,
|
||||
`device-alert:${result.insertId}:notification`, device.storeId, device.roomId,
|
||||
device.id, String(result.insertId), alertType, severity, summary]
|
||||
);
|
||||
}
|
||||
|
||||
private async updateDevice(
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
import type { AsyncTask } from '../tasks/task-repository.js';
|
||||
import { TaskRepository } from '../tasks/task-repository.js';
|
||||
|
||||
export type NotificationChannel =
|
||||
| 'IN_APP' | 'WECHAT_SUBSCRIBE' | 'WE_COM' | 'WEBHOOK' | 'CLOUD_SPEAKER';
|
||||
|
||||
export interface NotificationSendInput {
|
||||
deliveryId: string; tenantId: string; channel: NotificationChannel;
|
||||
recipientId: string; title: string; body: string; payload: Record<string, unknown>;
|
||||
externalTemplateId: string;
|
||||
}
|
||||
|
||||
export interface NotificationAdapter {
|
||||
send(input: NotificationSendInput): Promise<{ providerMessageId: string }>;
|
||||
}
|
||||
|
||||
export class InAppNotificationAdapter implements NotificationAdapter {
|
||||
async send(input: NotificationSendInput) {
|
||||
return { providerMessageId: `in-app:${input.deliveryId}` };
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpNotificationAdapter implements NotificationAdapter {
|
||||
constructor(private readonly options: {
|
||||
endpoint?: string; endpointFromRecipient?: boolean; bearerToken?: string;
|
||||
timeoutMs?: number; fetcher?: typeof fetch;
|
||||
}) {}
|
||||
|
||||
async send(input: NotificationSendInput) {
|
||||
const endpoint = this.options.endpointFromRecipient ? input.recipientId : this.options.endpoint;
|
||||
let url: URL;
|
||||
try { url = new URL(endpoint ?? ''); } catch { throw new NotificationError('NOTIFICATION_ENDPOINT_INVALID'); }
|
||||
if (url.protocol !== 'https:' || url.username || url.password) {
|
||||
throw new NotificationError('NOTIFICATION_ENDPOINT_INVALID');
|
||||
}
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
'x-qipai-delivery-id': input.deliveryId,
|
||||
'idempotency-key': `notification:${input.deliveryId}`
|
||||
};
|
||||
if (this.options.bearerToken) headers.authorization = `Bearer ${this.options.bearerToken}`;
|
||||
const response = await (this.options.fetcher ?? fetch)(url, {
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({ deliveryId: input.deliveryId, channel: input.channel,
|
||||
recipientId: input.recipientId, externalTemplateId: input.externalTemplateId,
|
||||
title: input.title, body: input.body, payload: input.payload }),
|
||||
signal: AbortSignal.timeout(this.options.timeoutMs ?? 10_000)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new NotificationError('NOTIFICATION_PROVIDER_HTTP_ERROR', `HTTP ${response.status}`);
|
||||
}
|
||||
let responseBody: unknown;
|
||||
try { responseBody = await response.json(); } catch { responseBody = null; }
|
||||
const record = safeObject(responseBody);
|
||||
const providerId = String(record.providerMessageId ?? record.messageId
|
||||
?? response.headers.get('x-request-id') ?? `${input.channel.toLowerCase()}:${input.deliveryId}`);
|
||||
return { providerMessageId: providerId.slice(0, 191) };
|
||||
}
|
||||
}
|
||||
|
||||
export function createNotificationAdaptersFromEnvironment(
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
fetcher?: typeof fetch
|
||||
): ReadonlyMap<NotificationChannel, NotificationAdapter> {
|
||||
const configuredTimeout = Number(environment.QIPAI_NOTIFICATION_HTTP_TIMEOUT_MS ?? 10_000);
|
||||
const timeout = Number.isFinite(configuredTimeout)
|
||||
? Math.min(60_000, Math.max(100, Math.trunc(configuredTimeout))) : 10_000;
|
||||
const adapters = new Map<NotificationChannel, NotificationAdapter>([
|
||||
['IN_APP', new InAppNotificationAdapter()],
|
||||
['WEBHOOK', new HttpNotificationAdapter({ endpointFromRecipient: true, timeoutMs: timeout, fetcher })]
|
||||
]);
|
||||
const configured: Array<[NotificationChannel, string | undefined, string | undefined]> = [
|
||||
['WECHAT_SUBSCRIBE', environment.QIPAI_NOTIFICATION_WECHAT_GATEWAY_URL,
|
||||
environment.QIPAI_NOTIFICATION_WECHAT_GATEWAY_TOKEN],
|
||||
['WE_COM', environment.QIPAI_NOTIFICATION_WECOM_GATEWAY_URL,
|
||||
environment.QIPAI_NOTIFICATION_WECOM_GATEWAY_TOKEN],
|
||||
['CLOUD_SPEAKER', environment.QIPAI_NOTIFICATION_CLOUD_SPEAKER_GATEWAY_URL,
|
||||
environment.QIPAI_NOTIFICATION_CLOUD_SPEAKER_GATEWAY_TOKEN]
|
||||
];
|
||||
for (const [channel, endpoint, bearerToken] of configured) {
|
||||
if (endpoint?.trim()) adapters.set(channel, new HttpNotificationAdapter({
|
||||
endpoint: endpoint.trim(), bearerToken: bearerToken?.trim(), timeoutMs: timeout, fetcher
|
||||
}));
|
||||
}
|
||||
return adapters;
|
||||
}
|
||||
|
||||
export class NotificationError extends Error {
|
||||
constructor(public readonly code: string, message = code) { super(message); }
|
||||
}
|
||||
|
||||
interface OutboxRow extends RowDataPacket {
|
||||
id: string; tenantId: string; aggregateType: string; aggregateId: string;
|
||||
eventType: string; payload: unknown; status: string;
|
||||
}
|
||||
|
||||
interface RouteRow extends RowDataPacket {
|
||||
id: string; tenantId: string; storeId: string | null; eventType: string;
|
||||
templateId: string; recipientType: 'CUSTOMER' | 'USER' | 'ROLE' | 'STORE_WEBHOOK';
|
||||
recipientValue: string; quietStart: string | null; quietEnd: string | null;
|
||||
channel: NotificationChannel; templateCode: string; titleTemplate: string;
|
||||
bodyTemplate: string; externalTemplateId: string;
|
||||
}
|
||||
|
||||
interface DeliveryRow extends RowDataPacket {
|
||||
id: string; tenantId: string; channel: NotificationChannel; recipientId: string;
|
||||
titleSnapshot: string; bodySnapshot: string; payloadSnapshot: unknown;
|
||||
externalTemplateId: string; status: string; attempts: number; maxAttempts: number;
|
||||
}
|
||||
|
||||
const idPattern = /^[1-9]\d{0,19}$/;
|
||||
const codePattern = /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/;
|
||||
const sensitiveKey = /(password|credential|secret|token|openid|phone|voucher|card|key)/i;
|
||||
|
||||
export class NotificationService {
|
||||
private readonly tasks: TaskRepository;
|
||||
|
||||
constructor(
|
||||
private readonly pool: MySqlPool,
|
||||
private readonly adapters: ReadonlyMap<NotificationChannel, NotificationAdapter>,
|
||||
tasks?: TaskRepository,
|
||||
private readonly now = () => new Date()
|
||||
) { this.tasks = tasks ?? new TaskRepository(pool); }
|
||||
|
||||
async listTemplates(actor: ManagementActor, input: { storeId?: string; eventType?: string }) {
|
||||
assertCapability(actor, 'notification.read');
|
||||
const filters = ['tenant_id = ?'];
|
||||
const params: Array<string> = [actor.tenantId];
|
||||
if (input.storeId) {
|
||||
assertStore(actor, input.storeId);
|
||||
filters.push('(store_id IS NULL OR store_id = ?)'); params.push(input.storeId);
|
||||
} else {
|
||||
appendStoreScope(actor, 'store_id', true, filters, params);
|
||||
}
|
||||
if (input.eventType) { assertCode(input.eventType); filters.push('event_type = ?'); params.push(input.eventType); }
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id, store_id AS storeId, template_code AS templateCode,
|
||||
event_type AS eventType, channel, title_template AS titleTemplate,
|
||||
body_template AS bodyTemplate, external_template_id AS externalTemplateId,
|
||||
status, version, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM qipai_notification_templates WHERE ${filters.join(' AND ')}
|
||||
ORDER BY event_type, channel, id`, params
|
||||
);
|
||||
return rows.map(normalizeRow);
|
||||
}
|
||||
|
||||
async saveTemplate(actor: ManagementActor, input: {
|
||||
id?: string; storeId?: string | null; templateCode: string; eventType: string;
|
||||
channel: NotificationChannel; titleTemplate: string; bodyTemplate: string;
|
||||
externalTemplateId?: string; status?: 'ACTIVE' | 'DISABLED'; expectedVersion?: number;
|
||||
}) {
|
||||
assertCapability(actor, 'notification.manage');
|
||||
assertWritableScope(actor, input.storeId ?? null);
|
||||
assertCode(input.templateCode); assertCode(input.eventType); assertChannel(input.channel);
|
||||
const title = boundedText(input.titleTemplate, 256, 'NOTIFICATION_TITLE_INVALID');
|
||||
const body = boundedText(input.bodyTemplate, 2000, 'NOTIFICATION_BODY_INVALID');
|
||||
if (!input.id) {
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_notification_templates
|
||||
(tenant_id, store_id, template_code, event_type, channel,
|
||||
title_template, body_template, external_template_id, status, created_by, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, input.storeId ?? null, input.templateCode, input.eventType,
|
||||
input.channel, title, body, input.externalTemplateId?.trim() ?? '',
|
||||
input.status ?? 'ACTIVE', actor.userId, actor.userId]
|
||||
);
|
||||
return { id: String(result.insertId), version: 1 };
|
||||
}
|
||||
assertId(input.id); const version = positiveVersion(input.expectedVersion);
|
||||
const [currentRows] = await this.pool.execute<Array<RowDataPacket & { storeId: string | null }>>(
|
||||
`SELECT store_id AS storeId FROM qipai_notification_templates
|
||||
WHERE tenant_id = ? AND id = ?`, [actor.tenantId, input.id]
|
||||
);
|
||||
if (!currentRows[0]) throw new NotificationError('NOTIFICATION_TEMPLATE_NOT_FOUND');
|
||||
assertWritableScope(actor, currentRows[0].storeId === null ? null : String(currentRows[0].storeId));
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_notification_templates SET store_id = ?, template_code = ?,
|
||||
event_type = ?, channel = ?, title_template = ?, body_template = ?,
|
||||
external_template_id = ?, status = ?, version = version + 1, updated_by = ?
|
||||
WHERE tenant_id = ? AND id = ? AND version = ?`,
|
||||
[input.storeId ?? null, input.templateCode, input.eventType, input.channel, title, body,
|
||||
input.externalTemplateId?.trim() ?? '', input.status ?? 'ACTIVE', actor.userId,
|
||||
actor.tenantId, input.id, version]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new NotificationError('NOTIFICATION_VERSION_CONFLICT');
|
||||
return { id: input.id, version: version + 1 };
|
||||
}
|
||||
|
||||
async listRoutes(actor: ManagementActor, input: { storeId?: string; eventType?: string }) {
|
||||
assertCapability(actor, 'notification.read');
|
||||
const filters = ['r.tenant_id = ?']; const params: string[] = [actor.tenantId];
|
||||
if (input.storeId) { assertStore(actor, input.storeId); filters.push('(r.store_id IS NULL OR r.store_id = ?)'); params.push(input.storeId); }
|
||||
else appendStoreScope(actor, 'r.store_id', true, filters, params);
|
||||
if (input.eventType) { assertCode(input.eventType); filters.push('r.event_type = ?'); params.push(input.eventType); }
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT r.id, r.store_id AS storeId, r.event_type AS eventType,
|
||||
r.template_id AS templateId, t.template_code AS templateCode, t.channel,
|
||||
r.recipient_type AS recipientType, r.recipient_value AS recipientValue,
|
||||
r.quiet_start AS quietStart, r.quiet_end AS quietEnd, r.status, r.version
|
||||
FROM qipai_notification_routes r
|
||||
INNER JOIN qipai_notification_templates t
|
||||
ON t.tenant_id = r.tenant_id AND t.id = r.template_id
|
||||
WHERE ${filters.join(' AND ')} ORDER BY r.event_type, r.id`, params
|
||||
);
|
||||
return rows.map(normalizeRow);
|
||||
}
|
||||
|
||||
async createRoute(actor: ManagementActor, input: {
|
||||
storeId?: string | null; eventType: string; templateId: string;
|
||||
recipientType: 'CUSTOMER' | 'USER' | 'ROLE' | 'STORE_WEBHOOK'; recipientValue?: string;
|
||||
quietStart?: string | null; quietEnd?: string | null;
|
||||
}) {
|
||||
assertCapability(actor, 'notification.manage');
|
||||
const routeStoreId = input.storeId ?? null;
|
||||
assertWritableScope(actor, routeStoreId);
|
||||
assertCode(input.eventType); assertId(input.templateId);
|
||||
const value = validateRecipient(input.recipientType, input.recipientValue ?? '');
|
||||
const quietStart = validateTime(input.quietStart); const quietEnd = validateTime(input.quietEnd);
|
||||
if ((quietStart === null) !== (quietEnd === null)) throw new NotificationError('NOTIFICATION_QUIET_HOURS_INVALID');
|
||||
const [template] = await this.pool.execute<Array<RowDataPacket & { storeId: string | null }>>(
|
||||
`SELECT id, store_id AS storeId FROM qipai_notification_templates
|
||||
WHERE tenant_id = ? AND id = ? AND event_type = ? AND status = 'ACTIVE'`,
|
||||
[actor.tenantId, input.templateId, input.eventType]
|
||||
);
|
||||
if (!template[0]) throw new NotificationError('NOTIFICATION_TEMPLATE_NOT_FOUND');
|
||||
const templateStoreId = template[0].storeId === null ? null : String(template[0].storeId);
|
||||
if (templateStoreId !== null && templateStoreId !== routeStoreId) {
|
||||
throw new NotificationError('NOTIFICATION_TEMPLATE_SCOPE_MISMATCH');
|
||||
}
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_notification_routes
|
||||
(tenant_id, store_id, event_type, template_id, recipient_type,
|
||||
recipient_value, quiet_start, quiet_end, created_by, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, routeStoreId, input.eventType, input.templateId,
|
||||
input.recipientType, value, quietStart, quietEnd, actor.userId, actor.userId]
|
||||
);
|
||||
return { id: String(result.insertId), version: 1 };
|
||||
}
|
||||
|
||||
async listDeliveries(actor: ManagementActor, input: {
|
||||
storeId?: string; status?: string; channel?: string; page?: number; pageSize?: number;
|
||||
}) {
|
||||
assertCapability(actor, 'notification.read');
|
||||
const page = Math.max(1, Math.trunc(input.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.trunc(input.pageSize ?? 20)));
|
||||
const filters = ['d.tenant_id = ?']; const params: string[] = [actor.tenantId];
|
||||
if (input.storeId) { assertStore(actor, input.storeId); filters.push('d.store_id = ?'); params.push(input.storeId); }
|
||||
else appendStoreScope(actor, 'd.store_id', false, filters, params);
|
||||
if (input.status) { filters.push('d.status = ?'); params.push(input.status); }
|
||||
if (input.channel) { assertChannel(input.channel as NotificationChannel); filters.push('d.channel = ?'); params.push(input.channel); }
|
||||
const where = filters.join(' AND ');
|
||||
const [counts] = await this.pool.execute<Array<RowDataPacket & { total: number }>>(
|
||||
`SELECT COUNT(*) AS total FROM qipai_notification_deliveries d WHERE ${where}`, params
|
||||
);
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT d.id, d.store_id AS storeId, d.outbox_event_id AS outboxEventId,
|
||||
d.event_type AS eventType, d.channel, d.recipient_type AS recipientType,
|
||||
d.recipient_masked AS recipientMasked, d.title_snapshot AS title,
|
||||
d.body_snapshot AS body, d.status, d.attempts, d.max_attempts AS maxAttempts,
|
||||
d.last_error_code AS lastErrorCode, d.last_error_message AS lastErrorMessage,
|
||||
d.provider_message_id AS providerMessageId, d.manual_retry_count AS manualRetryCount,
|
||||
d.sent_at AS sentAt, d.read_at AS readAt, d.created_at AS createdAt
|
||||
FROM qipai_notification_deliveries d WHERE ${where}
|
||||
ORDER BY d.created_at DESC, d.id DESC LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`,
|
||||
params
|
||||
);
|
||||
return { items: rows.map(normalizeRow), total: Number(counts[0]?.total ?? 0), page, pageSize };
|
||||
}
|
||||
|
||||
async materializeOutboxEvent(tenantId: string, eventId: string) {
|
||||
assertId(tenantId); assertId(eventId);
|
||||
const [events] = await this.pool.execute<OutboxRow[]>(
|
||||
`SELECT id, tenant_id AS tenantId, aggregate_type AS aggregateType,
|
||||
aggregate_id AS aggregateId, event_type AS eventType, payload, status
|
||||
FROM qipai_outbox_events WHERE tenant_id = ? AND id = ?`, [tenantId, eventId]
|
||||
);
|
||||
const event = events[0]; if (!event) throw new NotificationError('NOTIFICATION_EVENT_NOT_FOUND');
|
||||
const payload = safeObject(event.payload); const storeId = scalarId(payload.storeId);
|
||||
const [routes] = await this.pool.execute<RouteRow[]>(
|
||||
`SELECT r.id, r.tenant_id AS tenantId, r.store_id AS storeId,
|
||||
r.event_type AS eventType, r.template_id AS templateId,
|
||||
r.recipient_type AS recipientType, r.recipient_value AS recipientValue,
|
||||
CAST(r.quiet_start AS CHAR) AS quietStart, CAST(r.quiet_end AS CHAR) AS quietEnd,
|
||||
t.channel, t.template_code AS templateCode,
|
||||
t.title_template AS titleTemplate, t.body_template AS bodyTemplate,
|
||||
t.external_template_id AS externalTemplateId
|
||||
FROM qipai_notification_routes r
|
||||
INNER JOIN qipai_notification_templates t
|
||||
ON t.tenant_id = r.tenant_id AND t.id = r.template_id AND t.status = 'ACTIVE'
|
||||
WHERE r.tenant_id = ? AND r.event_type = ? AND r.status = 'ACTIVE'
|
||||
AND (r.store_id IS NULL OR r.store_id = ?)`, [tenantId, event.eventType, storeId ?? '0']
|
||||
);
|
||||
let created = 0; let suppressed = 0;
|
||||
const timezoneByStore = new Map<string, string>();
|
||||
for (const route of routes) {
|
||||
const recipients = await this.resolveRecipients(route, payload, storeId);
|
||||
const deliveryStoreId = route.storeId ?? storeId;
|
||||
const timezoneKey = deliveryStoreId ?? 'tenant';
|
||||
let timezone = timezoneByStore.get(timezoneKey);
|
||||
if (!timezone) {
|
||||
timezone = await this.resolveTimezone(tenantId, deliveryStoreId);
|
||||
timezoneByStore.set(timezoneKey, timezone);
|
||||
}
|
||||
for (const recipient of recipients) {
|
||||
const subscriptionAllowed = route.channel !== 'WECHAT_SUBSCRIBE'
|
||||
|| await this.hasSubscription(tenantId, recipient.id, route.templateCode);
|
||||
const isSuppressed = !subscriptionAllowed
|
||||
|| inQuietHours(this.now(), route.quietStart, route.quietEnd, timezone);
|
||||
const status = isSuppressed ? 'SUPPRESSED' : 'PENDING';
|
||||
const snapshot = redact(payload);
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`INSERT IGNORE INTO qipai_notification_deliveries
|
||||
(tenant_id, store_id, outbox_event_id, route_id, template_id, event_type,
|
||||
channel, recipient_type, recipient_id, recipient_masked, title_snapshot,
|
||||
body_snapshot, payload_snapshot, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?)`,
|
||||
[tenantId, deliveryStoreId, event.id, route.id, route.templateId,
|
||||
event.eventType, route.channel, route.recipientType, recipient.id, recipient.masked,
|
||||
render(route.titleTemplate, snapshot), render(route.bodyTemplate, snapshot),
|
||||
JSON.stringify(snapshot), status]
|
||||
);
|
||||
if (!result.insertId) continue;
|
||||
created += 1;
|
||||
if (isSuppressed) { suppressed += 1; continue; }
|
||||
await this.tasks.enqueue({ tenantId, taskType: 'notification.dispatch',
|
||||
idempotencyKey: `notification:${result.insertId}:auto:1`,
|
||||
payload: { deliveryId: String(result.insertId) }, maxAttempts: 8 });
|
||||
}
|
||||
}
|
||||
return { eventId, matchedRoutes: routes.length, created, suppressed };
|
||||
}
|
||||
|
||||
async enqueuePendingOutbox(limit = 100) {
|
||||
const safeLimit = Math.min(500, Math.max(1, Math.trunc(limit)));
|
||||
const [rows] = await this.pool.execute<Array<RowDataPacket & { id: string; tenantId: string }>>(
|
||||
`SELECT id, tenant_id AS tenantId FROM qipai_outbox_events
|
||||
WHERE status = 'PENDING' AND available_at <= UTC_TIMESTAMP(3)
|
||||
ORDER BY available_at, id LIMIT ${safeLimit}`
|
||||
);
|
||||
let created = 0;
|
||||
for (const row of rows) {
|
||||
const result = await this.tasks.enqueue({ tenantId: String(row.tenantId),
|
||||
taskType: 'outbox.publish', idempotencyKey: `outbox:${row.id}:notification`,
|
||||
payload: { eventId: String(row.id) }, maxAttempts: 8 });
|
||||
if (result.created) created += 1;
|
||||
}
|
||||
return { scanned: rows.length, created };
|
||||
}
|
||||
|
||||
async handleTask(task: AsyncTask) {
|
||||
const payload = safeObject(task.payload); const deliveryId = scalarId(payload.deliveryId);
|
||||
if (!deliveryId) throw new NotificationError('NOTIFICATION_TASK_INVALID');
|
||||
const trigger = payload.trigger === 'MANUAL' ? 'MANUAL' : 'AUTO';
|
||||
const operatorId = trigger === 'MANUAL' ? scalarId(payload.operatorId) : null;
|
||||
if (trigger === 'MANUAL' && !operatorId) throw new NotificationError('NOTIFICATION_TASK_INVALID');
|
||||
await this.sendDelivery(task.tenantId, deliveryId, trigger, operatorId);
|
||||
}
|
||||
|
||||
async manualRetry(actor: ManagementActor, deliveryId: string) {
|
||||
assertCapability(actor, 'notification.manage'); assertId(deliveryId);
|
||||
const [deliveries] = await this.pool.execute<Array<RowDataPacket & { storeId: string | null; status: string }>>(
|
||||
`SELECT store_id AS storeId, status FROM qipai_notification_deliveries
|
||||
WHERE tenant_id = ? AND id = ?`, [actor.tenantId, deliveryId]
|
||||
);
|
||||
const delivery = deliveries[0];
|
||||
if (!delivery || delivery.status !== 'FAILED') {
|
||||
throw new NotificationError('NOTIFICATION_RETRY_NOT_ALLOWED');
|
||||
}
|
||||
assertWritableScope(actor, delivery.storeId === null ? null : String(delivery.storeId));
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_notification_deliveries
|
||||
SET status = 'PENDING', available_at = UTC_TIMESTAMP(3),
|
||||
max_attempts = LEAST(20, max_attempts + 1),
|
||||
manual_retry_count = manual_retry_count + 1,
|
||||
last_error_code = '', last_error_message = ''
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'FAILED' AND attempts < 20`,
|
||||
[actor.tenantId, deliveryId]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new NotificationError('NOTIFICATION_RETRY_NOT_ALLOWED');
|
||||
const [rows] = await this.pool.execute<Array<RowDataPacket & { retry: number }>>(
|
||||
`SELECT manual_retry_count AS retry FROM qipai_notification_deliveries
|
||||
WHERE tenant_id = ? AND id = ?`, [actor.tenantId, deliveryId]
|
||||
);
|
||||
await this.tasks.enqueue({ tenantId: actor.tenantId, taskType: 'notification.dispatch',
|
||||
idempotencyKey: `notification:${deliveryId}:manual:${rows[0].retry}`,
|
||||
payload: { deliveryId, trigger: 'MANUAL', operatorId: actor.userId }, maxAttempts: 1 });
|
||||
return { id: deliveryId, queued: true };
|
||||
}
|
||||
|
||||
async setSubscription(tenantId: string, userId: string, input: {
|
||||
channel: 'WECHAT_SUBSCRIBE'; templateCode: string; status: 'AUTHORIZED' | 'REVOKED';
|
||||
}) {
|
||||
assertId(tenantId); assertId(userId); assertCode(input.templateCode);
|
||||
if (input.channel !== 'WECHAT_SUBSCRIBE') throw new NotificationError('NOTIFICATION_CHANNEL_INVALID');
|
||||
await this.pool.execute(
|
||||
`INSERT INTO qipai_notification_subscriptions
|
||||
(tenant_id, user_id, channel, template_code, status, authorized_at, revoked_at)
|
||||
VALUES (?, ?, 'WECHAT_SUBSCRIBE', ?, ?, UTC_TIMESTAMP(3),
|
||||
IF(? = 'REVOKED', UTC_TIMESTAMP(3), NULL))
|
||||
ON DUPLICATE KEY UPDATE status = VALUES(status),
|
||||
authorized_at = IF(VALUES(status) = 'AUTHORIZED', UTC_TIMESTAMP(3), authorized_at),
|
||||
revoked_at = IF(VALUES(status) = 'REVOKED', UTC_TIMESTAMP(3), NULL)`,
|
||||
[tenantId, userId, input.templateCode, input.status, input.status]
|
||||
);
|
||||
return { channel: input.channel, templateCode: input.templateCode, status: input.status };
|
||||
}
|
||||
|
||||
async listInbox(tenantId: string, userId: string, input: { page?: number; pageSize?: number }) {
|
||||
assertId(tenantId); assertId(userId);
|
||||
const page = Math.max(1, Math.trunc(input.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.trunc(input.pageSize ?? 20)));
|
||||
const params = [tenantId, userId];
|
||||
const [counts] = await this.pool.execute<Array<RowDataPacket & { total: number }>>(
|
||||
`SELECT COUNT(*) AS total FROM qipai_notification_deliveries
|
||||
WHERE tenant_id = ? AND recipient_id = ? AND channel = 'IN_APP' AND status = 'SENT'`, params
|
||||
);
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id, event_type AS eventType, title_snapshot AS title, body_snapshot AS body,
|
||||
read_at AS readAt, sent_at AS sentAt, created_at AS createdAt
|
||||
FROM qipai_notification_deliveries
|
||||
WHERE tenant_id = ? AND recipient_id = ? AND channel = 'IN_APP' AND status = 'SENT'
|
||||
ORDER BY created_at DESC, id DESC LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`, params
|
||||
);
|
||||
return { items: rows.map(normalizeRow), total: Number(counts[0]?.total ?? 0), page, pageSize };
|
||||
}
|
||||
|
||||
async markInboxRead(tenantId: string, userId: string, deliveryId: string) {
|
||||
assertId(tenantId); assertId(userId); assertId(deliveryId);
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_notification_deliveries SET read_at = COALESCE(read_at, UTC_TIMESTAMP(3))
|
||||
WHERE tenant_id = ? AND id = ? AND recipient_id = ?
|
||||
AND channel = 'IN_APP' AND status = 'SENT'`, [tenantId, deliveryId, userId]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new NotificationError('NOTIFICATION_INBOX_NOT_FOUND');
|
||||
return { id: deliveryId, read: true };
|
||||
}
|
||||
|
||||
private async sendDelivery(tenantId: string, deliveryId: string, trigger: 'AUTO' | 'MANUAL', operatorId: string | null) {
|
||||
const connection = await this.pool.getConnection();
|
||||
let delivery: DeliveryRow;
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [rows] = await connection.execute<DeliveryRow[]>(
|
||||
`SELECT d.id, d.tenant_id AS tenantId, d.channel, d.recipient_id AS recipientId,
|
||||
d.title_snapshot AS titleSnapshot, d.body_snapshot AS bodySnapshot,
|
||||
d.payload_snapshot AS payloadSnapshot, t.external_template_id AS externalTemplateId,
|
||||
d.status, d.attempts, d.max_attempts AS maxAttempts
|
||||
FROM qipai_notification_deliveries d
|
||||
INNER JOIN qipai_notification_templates t
|
||||
ON t.tenant_id = d.tenant_id AND t.id = d.template_id
|
||||
WHERE d.tenant_id = ? AND d.id = ? FOR UPDATE`, [tenantId, deliveryId]
|
||||
);
|
||||
delivery = rows[0];
|
||||
if (!delivery || !['PENDING', 'RETRY'].includes(delivery.status)) {
|
||||
throw new NotificationError('NOTIFICATION_DELIVERY_NOT_DISPATCHABLE');
|
||||
}
|
||||
await connection.execute(
|
||||
`UPDATE qipai_notification_deliveries SET status = 'PROCESSING', attempts = attempts + 1
|
||||
WHERE tenant_id = ? AND id = ?`, [tenantId, deliveryId]
|
||||
);
|
||||
await connection.commit();
|
||||
} catch (error) { await connection.rollback(); throw error; } finally { connection.release(); }
|
||||
const attemptNo = Number(delivery.attempts) + 1;
|
||||
try {
|
||||
const adapter = this.adapters.get(delivery.channel);
|
||||
if (!adapter) throw new NotificationError('NOTIFICATION_CHANNEL_NOT_CONFIGURED');
|
||||
const sent = await adapter.send({ deliveryId, tenantId, channel: delivery.channel,
|
||||
recipientId: delivery.recipientId, title: delivery.titleSnapshot,
|
||||
body: delivery.bodySnapshot, payload: safeObject(delivery.payloadSnapshot),
|
||||
externalTemplateId: delivery.externalTemplateId });
|
||||
await this.recordAttempt(tenantId, deliveryId, attemptNo, trigger, 'SENT', '', '', sent.providerMessageId, operatorId);
|
||||
await this.pool.execute(
|
||||
`UPDATE qipai_notification_deliveries SET status = 'SENT', sent_at = UTC_TIMESTAMP(3),
|
||||
provider_message_id = ?, last_error_code = '', last_error_message = ''
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'PROCESSING'`,
|
||||
[sent.providerMessageId.slice(0, 191), tenantId, deliveryId]
|
||||
);
|
||||
} catch (error) {
|
||||
const code = error instanceof NotificationError ? error.code : 'NOTIFICATION_PROVIDER_ERROR';
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const terminal = attemptNo >= Number(delivery.maxAttempts);
|
||||
await this.recordAttempt(tenantId, deliveryId, attemptNo, trigger, 'FAILED', code, message, '', operatorId);
|
||||
await this.pool.execute(
|
||||
`UPDATE qipai_notification_deliveries
|
||||
SET status = ?, available_at = DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? SECOND),
|
||||
last_error_code = ?, last_error_message = ?
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'PROCESSING'`,
|
||||
[terminal ? 'FAILED' : 'RETRY', Math.min(3600, 2 ** Math.min(attemptNo, 10)),
|
||||
code, message.slice(0, 512), tenantId, deliveryId]
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async recordAttempt(tenantId: string, deliveryId: string, attemptNo: number,
|
||||
trigger: string, status: string, code: string, message: string,
|
||||
providerId: string, operatorId: string | null) {
|
||||
await this.pool.execute(
|
||||
`INSERT INTO qipai_notification_attempts
|
||||
(tenant_id, delivery_id, attempt_no, trigger_type, status, error_code,
|
||||
error_message, provider_message_id, operator_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[tenantId, deliveryId, attemptNo, trigger, status, code, message.slice(0, 512),
|
||||
providerId.slice(0, 191), operatorId]
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveRecipients(route: RouteRow, payload: Record<string, unknown>, storeId: string | null) {
|
||||
if (route.recipientType === 'CUSTOMER') {
|
||||
const id = scalarId(payload.memberId ?? payload.customerId ?? payload.userId);
|
||||
return id ? [{ id, masked: `用户 #${id}` }] : [];
|
||||
}
|
||||
if (route.recipientType === 'USER') return [{ id: route.recipientValue, masked: `用户 #${route.recipientValue}` }];
|
||||
if (route.recipientType === 'STORE_WEBHOOK') {
|
||||
if (!storeId) return [];
|
||||
const [rows] = await this.pool.execute<Array<RowDataPacket & { url: string }>>(
|
||||
`SELECT notification_url AS url FROM qipai_stores
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`, [route.tenantId, storeId]
|
||||
);
|
||||
const url = rows[0]?.url?.trim();
|
||||
return url && /^https:\/\//i.test(url) ? [{ id: url, masked: maskUrl(url) }] : [];
|
||||
}
|
||||
const params: string[] = [route.tenantId, route.recipientValue];
|
||||
let scope = '';
|
||||
if (storeId) { scope = `AND (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN') OR EXISTS (
|
||||
SELECT 1 FROM qipai_user_store_scopes us
|
||||
WHERE us.tenant_id = u.tenant_id AND us.user_id = u.id AND us.store_id = ?))`;
|
||||
params.push(storeId);
|
||||
}
|
||||
const [rows] = await this.pool.execute<Array<RowDataPacket & { id: string }>>(
|
||||
`SELECT DISTINCT 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 = ? AND u.status = 'ACTIVE'
|
||||
AND u.deleted_at IS NULL AND r.status = 'ACTIVE' AND r.deleted_at IS NULL ${scope}`,
|
||||
params
|
||||
);
|
||||
return rows.map((row) => ({ id: String(row.id), masked: `用户 #${row.id}` }));
|
||||
}
|
||||
|
||||
private async hasSubscription(tenantId: string, userId: string, templateCode: string) {
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id FROM qipai_notification_subscriptions
|
||||
WHERE tenant_id = ? AND user_id = ? AND channel = 'WECHAT_SUBSCRIBE'
|
||||
AND template_code = ? AND status = 'AUTHORIZED'`, [tenantId, userId, templateCode]
|
||||
);
|
||||
return Boolean(rows[0]);
|
||||
}
|
||||
|
||||
private async resolveTimezone(tenantId: string, storeId: string | null) {
|
||||
const [rows] = storeId
|
||||
? await this.pool.execute<Array<RowDataPacket & { timezone: string }>>(
|
||||
`SELECT timezone FROM qipai_stores
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`, [tenantId, storeId]
|
||||
)
|
||||
: await this.pool.execute<Array<RowDataPacket & { timezone: string }>>(
|
||||
`SELECT timezone FROM qipai_tenants WHERE id = ? AND deleted_at IS NULL`, [tenantId]
|
||||
);
|
||||
return rows[0]?.timezone || 'Asia/Shanghai';
|
||||
}
|
||||
}
|
||||
|
||||
function assertCapability(actor: ManagementActor, capability: string) {
|
||||
if (actor.access.roles.includes('PLATFORM_ADMIN')) return;
|
||||
if (actor.access.capabilities.some((item) => [capability, 'tenant.manage', 'platform.manage'].includes(item))) return;
|
||||
throw new NotificationError('NOTIFICATION_FORBIDDEN');
|
||||
}
|
||||
function assertStore(actor: ManagementActor, storeId: string) {
|
||||
assertId(storeId);
|
||||
if (actor.access.roles.includes('PLATFORM_ADMIN') || actor.access.capabilities.some((item) => ['tenant.manage', 'platform.manage'].includes(item))) return;
|
||||
if (!actor.access.storeIds.includes(storeId)) throw new NotificationError('NOTIFICATION_STORE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
function isGlobalManager(actor: ManagementActor) {
|
||||
return actor.access.roles.includes('PLATFORM_ADMIN')
|
||||
|| actor.access.capabilities.some((item) => ['tenant.manage', 'platform.manage'].includes(item));
|
||||
}
|
||||
function assertWritableScope(actor: ManagementActor, storeId: string | null) {
|
||||
if (storeId === null) {
|
||||
if (!isGlobalManager(actor)) throw new NotificationError('NOTIFICATION_STORE_SCOPE_FORBIDDEN');
|
||||
return;
|
||||
}
|
||||
assertStore(actor, storeId);
|
||||
}
|
||||
function appendStoreScope(actor: ManagementActor, column: string, includeGlobal: boolean,
|
||||
filters: string[], params: string[]) {
|
||||
if (isGlobalManager(actor)) return;
|
||||
if (!actor.access.storeIds.length) {
|
||||
filters.push(includeGlobal ? `${column} IS NULL` : '1 = 0');
|
||||
return;
|
||||
}
|
||||
const placeholders = actor.access.storeIds.map(() => '?').join(', ');
|
||||
filters.push(includeGlobal
|
||||
? `(${column} IS NULL OR ${column} IN (${placeholders}))`
|
||||
: `${column} IN (${placeholders})`);
|
||||
params.push(...actor.access.storeIds);
|
||||
}
|
||||
function assertId(value: string) { if (!idPattern.test(value)) throw new NotificationError('NOTIFICATION_ID_INVALID'); }
|
||||
function assertCode(value: string) { if (!codePattern.test(value)) throw new NotificationError('NOTIFICATION_CODE_INVALID'); }
|
||||
function assertChannel(value: NotificationChannel) { if (!['IN_APP', 'WECHAT_SUBSCRIBE', 'WE_COM', 'WEBHOOK', 'CLOUD_SPEAKER'].includes(value)) throw new NotificationError('NOTIFICATION_CHANNEL_INVALID'); }
|
||||
function positiveVersion(value: number | undefined) { const result = Math.trunc(value ?? 0); if (result < 1) throw new NotificationError('NOTIFICATION_VERSION_INVALID'); return result; }
|
||||
function boundedText(value: string, max: number, code: string) { const result = value.trim(); if (!result || result.length > max) throw new NotificationError(code); return result; }
|
||||
function validateTime(value: string | null | undefined) { if (!value) return null; if (!/^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/.test(value)) throw new NotificationError('NOTIFICATION_QUIET_HOURS_INVALID'); return value.length === 5 ? `${value}:00` : value; }
|
||||
function validateRecipient(type: string, value: string) { const result = value.trim(); if (type === 'CUSTOMER' || type === 'STORE_WEBHOOK') return result || '*'; if (type === 'USER') assertId(result); else assertCode(result); return result; }
|
||||
function safeObject(value: unknown): Record<string, unknown> { if (typeof value === 'string') { try { value = JSON.parse(value); } catch { return {}; } } return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
||||
function scalarId(value: unknown) { const text = value === null || value === undefined ? '' : String(value); return idPattern.test(text) ? text : null; }
|
||||
function redact(value: unknown): any { if (Array.isArray(value)) return value.map(redact); if (!value || typeof value !== 'object') return value; return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, sensitiveKey.test(key) ? '[REDACTED]' : redact(item)])); }
|
||||
function render(template: string, payload: Record<string, unknown>) { return template.replace(/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, (_, key: string) => { const value = key.split('.').reduce<any>((current, part) => current && typeof current === 'object' ? current[part] : undefined, payload); return value === undefined || value === null || typeof value === 'object' ? '' : String(value); }); }
|
||||
function inQuietHours(now: Date, start: string | null, end: string | null, timezone: string) {
|
||||
if (!start || !end) return false;
|
||||
let current: string;
|
||||
try {
|
||||
current = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: timezone, hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23'
|
||||
}).format(now);
|
||||
} catch {
|
||||
current = now.toISOString().slice(11, 19);
|
||||
}
|
||||
return start <= end ? current >= start && current < end : current >= start || current < end;
|
||||
}
|
||||
function maskUrl(value: string) { try { const url = new URL(value); return `${url.protocol}//${url.host}/***`; } catch { return 'https://***/'; } }
|
||||
function normalizeRow(row: RowDataPacket) { return Object.fromEntries(Object.entries(row).map(([key, value]) => [key, typeof value === 'bigint' ? value.toString() : value])); }
|
||||
@@ -126,6 +126,18 @@ export class OrderStateRepository {
|
||||
actor.actorType, actor.userId, actor.source, reason.slice(0, 512),
|
||||
actor.traceId, nextVersion]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT IGNORE INTO qipai_outbox_events
|
||||
(tenant_id, aggregate_type, aggregate_id, event_type, idempotency_key, payload)
|
||||
VALUES (?, 'ORDER', ?, ?, ?, JSON_OBJECT(
|
||||
'storeId', ?, 'orderId', ?, 'customerId', (
|
||||
SELECT user_id FROM qipai_order_user_access
|
||||
WHERE tenant_id = ? AND order_id = ? ORDER BY user_id LIMIT 1
|
||||
), 'fromStatus', ?, 'status', ?, 'action', ?))`,
|
||||
[actor.tenantId, orderId, `ORDER_${action}`,
|
||||
`order:${orderId}:history:${result.insertId}:notification`, order.storeId,
|
||||
orderId, actor.tenantId, orderId, order.status, targetStatus, action]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
@@ -244,7 +256,7 @@ export class OrderStateRepository {
|
||||
if (this.benefits) {
|
||||
const [users] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT user_id AS userId FROM qipai_order_user_access
|
||||
WHERE tenant_id = ? AND order_id = ? ORDER BY id LIMIT 1`,
|
||||
WHERE tenant_id = ? AND order_id = ? ORDER BY user_id LIMIT 1`,
|
||||
[tenantId, orderId]
|
||||
);
|
||||
if (users[0]?.userId) {
|
||||
|
||||
@@ -173,6 +173,16 @@ export class PricingRepository {
|
||||
input.actor?.source === 'ADMIN' ? 'Order created on behalf' : 'Room hold created',
|
||||
input.actor?.traceId ?? `order-created-${orderId}`, input.userId]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT IGNORE INTO qipai_outbox_events
|
||||
(tenant_id, aggregate_type, aggregate_id, event_type, idempotency_key, payload)
|
||||
VALUES (?, 'ORDER', ?, 'ORDER_CREATED', ?, JSON_OBJECT(
|
||||
'storeId', ?, 'roomId', ?, 'orderId', ?, 'orderNo', ?,
|
||||
'customerId', ?, 'status', 'PENDING_PAYMENT', 'source', ?))`,
|
||||
[input.tenantId, orderId, `order:${orderId}:created:notification`,
|
||||
String(room.storeId), input.roomId, orderId, orderNo, input.userId,
|
||||
input.actor?.source ?? 'APP']
|
||||
);
|
||||
if (input.actor?.source === 'ADMIN') {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
@@ -241,6 +251,20 @@ export class PricingRepository {
|
||||
WHERE ${filters.join(' AND ')}`,
|
||||
params
|
||||
);
|
||||
await connection.execute<ResultSetHeader>(
|
||||
`INSERT IGNORE INTO qipai_outbox_events
|
||||
(tenant_id, aggregate_type, aggregate_id, event_type, idempotency_key, payload)
|
||||
SELECT o.tenant_id, 'ORDER', CAST(o.id AS CHAR), 'ORDER_EXPIRED',
|
||||
CONCAT('order:', o.id, ':expired:notification'),
|
||||
JSON_OBJECT('storeId', o.store_id, 'roomId', o.room_id, 'orderId', o.id,
|
||||
'customerId', (SELECT a.user_id FROM qipai_order_user_access a
|
||||
WHERE a.tenant_id = o.tenant_id AND a.order_id = o.id ORDER BY a.user_id LIMIT 1),
|
||||
'status', 'CLOSED')
|
||||
FROM qipai_room_reservations r
|
||||
INNER JOIN qipai_orders o ON o.id = r.order_id AND o.tenant_id = r.tenant_id
|
||||
WHERE ${filters.join(' AND ')}`,
|
||||
params
|
||||
);
|
||||
await connection.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_room_reservations r
|
||||
INNER JOIN qipai_orders o
|
||||
|
||||
@@ -353,6 +353,18 @@ export class PaymentRepository {
|
||||
NULL, 'PAYMENT', ?, ?, JSON_OBJECT('statusVersion', ?, 'paymentId', ?))`,
|
||||
[input.tenantId, input.orderId, input.reason, input.traceId, nextVersion, input.paymentId]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT IGNORE INTO qipai_outbox_events
|
||||
(tenant_id, aggregate_type, aggregate_id, event_type, idempotency_key, payload)
|
||||
VALUES (?, 'ORDER', ?, 'ORDER_CONFIRM_PAYMENT', ?, JSON_OBJECT(
|
||||
'storeId', ?, 'orderId', ?, 'customerId', (
|
||||
SELECT user_id FROM qipai_order_user_access
|
||||
WHERE tenant_id = ? AND order_id = ? ORDER BY user_id LIMIT 1
|
||||
), 'paymentId', ?, 'amountCents', ?, 'status', 'PAID'))`,
|
||||
[input.tenantId, input.orderId, `payment:${input.paymentId}:succeeded:notification`,
|
||||
order.storeId, input.orderId, input.tenantId, input.orderId,
|
||||
input.paymentId, input.amountCents]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -443,7 +443,7 @@ export class WechatPaymentService {
|
||||
const [rows] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT user_id AS userId FROM qipai_order_user_access
|
||||
WHERE tenant_id = ? AND order_id = ? AND revoked_at IS NULL
|
||||
ORDER BY id LIMIT 1`,
|
||||
ORDER BY user_id LIMIT 1`,
|
||||
[tenantId, orderId]
|
||||
);
|
||||
return rows[0]?.userId ? String(rows[0].userId) : undefined;
|
||||
@@ -523,6 +523,18 @@ export class WechatPaymentService {
|
||||
[tenantId, refund.orderId, payment.orderStatus, traceId,
|
||||
refund.id, refund.amountCents]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT IGNORE INTO qipai_outbox_events
|
||||
(tenant_id, aggregate_type, aggregate_id, event_type, idempotency_key, payload)
|
||||
VALUES (?, 'ORDER', ?, 'ORDER_COMPLETE_REFUND', ?, JSON_OBJECT(
|
||||
'storeId', ?, 'orderId', ?, 'customerId', (
|
||||
SELECT user_id FROM qipai_order_user_access
|
||||
WHERE tenant_id = ? AND order_id = ? ORDER BY user_id LIMIT 1
|
||||
), 'refundId', ?, 'amountCents', ?, 'status', 'REFUNDED'))`,
|
||||
[tenantId, refund.orderId, `refund:${refund.id}:succeeded:notification`,
|
||||
payment.storeId, refund.orderId, tenantId, refund.orderId,
|
||||
refund.id, refund.amountCents]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1276,6 +1276,17 @@ export class ProductOrderService {
|
||||
event.versionAfter, event.reason.slice(0, 512), actor.traceId.slice(0, 128),
|
||||
JSON.stringify(event.metadata)]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT IGNORE INTO qipai_outbox_events
|
||||
(tenant_id, aggregate_type, aggregate_id, event_type,
|
||||
idempotency_key, payload, available_at)
|
||||
VALUES (?, 'PRODUCT_ORDER', ?, ?, ?, CAST(? AS JSON), UTC_TIMESTAMP(3))`,
|
||||
[actor.tenantId, event.orderId, `PRODUCT_ORDER_${event.action}`,
|
||||
`product-order:${event.orderId}:v${event.versionAfter}:${event.action}`,
|
||||
JSON.stringify({ storeId: event.storeId, orderId: event.orderId,
|
||||
fromStatus: event.fromStatus, toStatus: event.toStatus,
|
||||
action: event.action, version: event.versionAfter, ...event.metadata })]
|
||||
);
|
||||
}
|
||||
|
||||
private async insertRefundEvent(
|
||||
|
||||
@@ -119,7 +119,9 @@ function adminAccess(access: AccessProfile) {
|
||||
].includes(capability)) ? ['products'] : []),
|
||||
...(tenant ? ['platformApps', 'content', 'franchise', 'system', 'payments', 'people'] : []),
|
||||
...(tenant || access.capabilities.includes('device.read') ? ['devices'] : []),
|
||||
...(tenant || access.capabilities.includes('cleaning.task.read') ? ['cleaning'] : [])
|
||||
...(tenant || access.capabilities.includes('cleaning.task.read') ? ['cleaning'] : []),
|
||||
...(tenant || access.capabilities.some((capability) =>
|
||||
['notification.read', 'notification.manage'].includes(capability)) ? ['notifications'] : [])
|
||||
];
|
||||
return { ...access, menus: [...new Set(menus)] };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
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 type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import { NotificationError, type NotificationService } from '../notifications/notification-service.js';
|
||||
|
||||
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||
const code = z.string().trim().regex(/^[A-Za-z][A-Za-z0-9._:-]{0,127}$/);
|
||||
const channel = z.enum(['IN_APP', 'WECHAT_SUBSCRIBE', 'WE_COM', 'WEBHOOK', 'CLOUD_SPEAKER']);
|
||||
const listQuery = z.object({ storeId: id.optional(), eventType: code.optional() }).strict();
|
||||
const deliveryQuery = z.object({ storeId: id.optional(), status: z.string().trim().max(32).optional(),
|
||||
channel: channel.optional(), page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20) }).strict();
|
||||
const templateBody = z.object({ id: id.optional(), storeId: id.nullable().optional(),
|
||||
templateCode: code, eventType: code, channel,
|
||||
titleTemplate: z.string().trim().min(1).max(256),
|
||||
bodyTemplate: z.string().trim().min(1).max(2000),
|
||||
externalTemplateId: z.string().trim().max(128).optional(),
|
||||
status: z.enum(['ACTIVE', 'DISABLED']).optional(),
|
||||
expectedVersion: z.number().int().min(1).optional() }).strict()
|
||||
.refine((value) => !value.id || value.expectedVersion !== undefined);
|
||||
const routeBody = z.object({ storeId: id.nullable().optional(), eventType: code,
|
||||
templateId: id, recipientType: z.enum(['CUSTOMER', 'USER', 'ROLE', 'STORE_WEBHOOK']),
|
||||
recipientValue: z.string().trim().max(191).optional(),
|
||||
quietStart: z.string().trim().nullable().optional(),
|
||||
quietEnd: z.string().trim().nullable().optional() }).strict();
|
||||
const subscriptionBody = z.object({ channel: z.literal('WECHAT_SUBSCRIBE'), templateCode: code,
|
||||
status: z.enum(['AUTHORIZED', 'REVOKED']) }).strict();
|
||||
const inboxQuery = z.object({ page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20) }).strict();
|
||||
|
||||
export interface NotificationRouteOptions {
|
||||
service: Pick<NotificationService, 'listTemplates' | 'saveTemplate' | 'listRoutes'
|
||||
| 'createRoute' | 'listDeliveries' | 'manualRetry' | 'setSubscription'
|
||||
| 'listInbox' | 'markInboxRead'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerNotificationRoutes(app: FastifyInstance, options: NotificationRouteOptions) {
|
||||
for (const prefix of ['/admin-api', '/app-api/management']) {
|
||||
app.get(`${prefix}/notifications/templates`, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, false);
|
||||
const query = listQuery.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, () => options.service.listTemplates(actor, query.data));
|
||||
});
|
||||
app.put(`${prefix}/notifications/templates`, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, true);
|
||||
const body = templateBody.safeParse(request.body);
|
||||
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, () => options.service.saveTemplate(actor, body.data));
|
||||
});
|
||||
app.get(`${prefix}/notifications/routes`, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, false);
|
||||
const query = listQuery.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, () => options.service.listRoutes(actor, query.data));
|
||||
});
|
||||
app.post(`${prefix}/notifications/routes`, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, true);
|
||||
const body = routeBody.safeParse(request.body);
|
||||
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, () => options.service.createRoute(actor, body.data), 201);
|
||||
});
|
||||
app.get(`${prefix}/notifications/deliveries`, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, false);
|
||||
const query = deliveryQuery.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, () => options.service.listDeliveries(actor, query.data));
|
||||
});
|
||||
app.post(`${prefix}/notifications/deliveries/:deliveryId/retry`, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, true);
|
||||
const params = z.object({ deliveryId: id }).strict().safeParse(request.params);
|
||||
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, () => options.service.manualRetry(actor, params.data.deliveryId));
|
||||
});
|
||||
}
|
||||
|
||||
app.put('/app-api/notifications/subscriptions', async (request, reply) => {
|
||||
const auth = await authenticateAccessToken(request.headers.authorization,
|
||||
options.authRepository, options.jwtSecret);
|
||||
const body = subscriptionBody.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, () => options.service.setSubscription(
|
||||
auth.session.tenantId, auth.session.user.id, body.data
|
||||
));
|
||||
});
|
||||
app.get('/app-api/notifications/inbox', async (request, reply) => {
|
||||
const auth = await authenticateAccessToken(request.headers.authorization,
|
||||
options.authRepository, options.jwtSecret);
|
||||
const query = inboxQuery.safeParse(request.query);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, () => options.service.listInbox(
|
||||
auth.session.tenantId, auth.session.user.id, query.data
|
||||
));
|
||||
});
|
||||
app.post('/app-api/notifications/inbox/:deliveryId/read', async (request, reply) => {
|
||||
const auth = await authenticateAccessToken(request.headers.authorization,
|
||||
options.authRepository, options.jwtSecret);
|
||||
const params = z.object({ deliveryId: id }).strict().safeParse(request.params);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, () => options.service.markInboxRead(
|
||||
auth.session.tenantId, auth.session.user.id, params.data.deliveryId
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
async function requireActor(request: FastifyRequest, reply: FastifyReply,
|
||||
options: NotificationRouteOptions, write: boolean): Promise<ManagementActor | 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 manager = access.roles.includes('PLATFORM_ADMIN')
|
||||
|| access.capabilities.some((item) => ['tenant.manage', 'platform.manage'].includes(item));
|
||||
const allowed = manager || (write ? access.capabilities.includes('notification.manage')
|
||||
: access.capabilities.some((item) => ['notification.read', 'notification.manage'].includes(item)));
|
||||
if (!allowed) { reply.status(403).send({ code: 'NOTIFICATION_FORBIDDEN', message: 'Notification permission is required.', traceId: request.traceId }); return null; }
|
||||
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
|
||||
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '' };
|
||||
}
|
||||
|
||||
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>, status = 200) {
|
||||
try { return reply.status(status).send({ code: 0, data: await work(), traceId }); }
|
||||
catch (error) {
|
||||
if (!(error instanceof NotificationError)) throw error;
|
||||
const http = error.code.endsWith('_FORBIDDEN') ? 403 : error.code.endsWith('_NOT_FOUND') ? 404
|
||||
: error.code.includes('CONFLICT') || error.code.endsWith('_NOT_ALLOWED') ? 409 : 400;
|
||||
return reply.status(http).send({ code: error.code, message: 'The notification operation is not allowed.', traceId });
|
||||
}
|
||||
}
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({ code: 'INVALID_NOTIFICATION_REQUEST', message: 'The notification request is invalid.', traceId });
|
||||
}
|
||||
function unauthorized(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(401).send({ code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId });
|
||||
}
|
||||
@@ -48,6 +48,9 @@ import { ProductCatalogRepository } from './products/product-catalog-repository.
|
||||
import { InventoryService } from './inventory/inventory-service.js';
|
||||
import { ProductOrderService } from './products/product-order-service.js';
|
||||
import { ProductStorageService } from './products/product-storage-service.js';
|
||||
import {
|
||||
createNotificationAdaptersFromEnvironment, NotificationService
|
||||
} from './notifications/notification-service.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createMySqlPool(config);
|
||||
@@ -62,6 +65,7 @@ const productCatalogRepository = new ProductCatalogRepository(pool);
|
||||
const inventoryService = new InventoryService(pool);
|
||||
const productOrderService = new ProductOrderService(pool, inventoryService);
|
||||
const productStorageService = new ProductStorageService(pool);
|
||||
const notificationService = new NotificationService(pool, createNotificationAdaptersFromEnvironment());
|
||||
const paymentRepository = new PaymentRepository(pool, walletLedgerService, marketingBenefits);
|
||||
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
|
||||
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
|
||||
@@ -274,6 +278,12 @@ const app = await buildApp({
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
notifications: {
|
||||
service: notificationService,
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
}
|
||||
});
|
||||
app.addHook('onClose', async () => {
|
||||
|
||||
@@ -11,6 +11,10 @@ import { DeviceCommandService } from '../devices/device-command-service.js';
|
||||
import { DeviceControlService } from '../devices/device-control-service.js';
|
||||
import { IotMessageService } from '../devices/iot-message-service.js';
|
||||
import { OrderDeviceAutomationService } from '../devices/order-device-automation-service.js';
|
||||
import {
|
||||
createNotificationAdaptersFromEnvironment, NotificationService
|
||||
} from '../notifications/notification-service.js';
|
||||
import { OutboxRepository } from './outbox-repository.js';
|
||||
|
||||
type TaskHandler = (task: AsyncTask) => Promise<void>;
|
||||
|
||||
@@ -19,6 +23,7 @@ export interface WorkerOptions {
|
||||
handlers: ReadonlyMap<TaskType, TaskHandler>;
|
||||
workerId: string;
|
||||
leaseMs?: number;
|
||||
onIdle?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class TaskWorker {
|
||||
@@ -53,7 +58,10 @@ export class TaskWorker {
|
||||
async run(pollIntervalMs = 1000): Promise<void> {
|
||||
while (!this.stopping) {
|
||||
const processed = await this.runOnce();
|
||||
if (!processed) await sleep(pollIntervalMs);
|
||||
if (!processed) {
|
||||
await this.options.onIdle?.();
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,12 +82,23 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1
|
||||
new DeviceCommandService(iotMessages, mqtt)
|
||||
);
|
||||
const orderDevices = new OrderDeviceAutomationService(pool, deviceControl);
|
||||
const notifications = new NotificationService(pool, createNotificationAdaptersFromEnvironment());
|
||||
const outbox = new OutboxRepository(pool);
|
||||
const worker = new TaskWorker({
|
||||
repository: new TaskRepository(pool),
|
||||
handlers: new Map([
|
||||
['device.command', async (task) => { await orderDevices.handleTask(task); }]
|
||||
['device.command', async (task) => { await orderDevices.handleTask(task); }],
|
||||
['notification.dispatch', async (task) => { await notifications.handleTask(task); }],
|
||||
['outbox.publish', async (task) => {
|
||||
const payload = task.payload as { eventId?: unknown };
|
||||
const eventId = typeof payload?.eventId === 'string' ? payload.eventId : '';
|
||||
if (!/^[1-9]\d{0,19}$/.test(eventId)) throw new Error('Invalid outbox event task payload.');
|
||||
await notifications.materializeOutboxEvent(task.tenantId, eventId);
|
||||
if (!(await outbox.markPublished(eventId))) throw new Error('Outbox event was not publishable.');
|
||||
}]
|
||||
]),
|
||||
workerId: `${hostname()}:${process.pid}:${randomUUID()}`
|
||||
workerId: `${hostname()}:${process.pid}:${randomUUID()}`,
|
||||
onIdle: async () => { await notifications.enqueuePendingOutbox(); }
|
||||
});
|
||||
const shutdown = () => worker.stop();
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
@@ -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