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

This commit is contained in:
Codex
2026-08-11 09:17:18 +08:00
parent 41b9cf7349
commit 5d70114bab
46 changed files with 1724 additions and 74 deletions
+4 -4
View File
@@ -136,9 +136,9 @@ sh scripts/setup-git-hooks.sh
项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md``docs/module-status.md``docs/feature-status.md` 为准。
- 当前执行游标:`M10-A`TODO);M09 商品、库存、订单、支付退款与寄存已完成模块级回归,真实团购商家授权、微信和硬件现场验收保持 `BLOCKED_EXTERNAL`
- 最近工程结果:已推送 `e91b979` 完成 M09-D4 商品选购与运营工作台;当前 M09-REGRESSION 随本次中文工程提交固化逐单金额/支付退款/库存/寄存对账、运营明细、`platform.manage` 权限一致性及 MySQL 8.4 库存分页兼容修复
- 下一工程目标:执行 `M10-A`建立复用 outbox 的通知中心、模板、幂等投递、失败重试与人工补发闭环
- 当前执行游标:`M10-B`TODO);M10-A 统一通知中心已完成内部工程,真实微信/企微/云喇叭网关凭据及现场验收保持 `BLOCKED_EXTERNAL`
- 最近工程结果:已推送 `41b9cf7` 完成 M09 商品模块回归;当前 M10-A 随本次中文工程提交固化通知模板、事件路由、订阅授权、站内信、HTTPS 渠道、幂等投递、有限重试、人工补发、静默时段、脱敏和后台追踪
- 下一工程目标:执行 `M10-B`补齐可由明细复算的收入、退款、房间使用时长、利用率、商品收入、保洁成本和支付渠道报表
## 版本递进
@@ -147,7 +147,7 @@ sh scripts/setup-git-hooks.sh
- **V5.3**:增加固定模块队列、执行游标、会话内自动推进、可中断恢复和跨模块回归检查点。
- **V5.4**:固化 WSL/生产 MySQL 账号密码、配置位置、真实登录检测和特殊字符处理规则。
- **V5.6**:接管最新源码与在制 M09-A,固化协议返工、后台架构返工、M09/M10 收口队列和 `CODE_COMPLETE` 门禁。
- **V5.7**:增加中文 Git 提交门禁,持续收口 M09-D1 至 D4 商品库存、订单、寄存取出与端到端运营体验,游标推进至 M09 模块回归前夕
- **V5.7**:增加中文 Git 提交门禁,完成 M09 商品域及 M10-A 统一通知中心内部闭环,游标推进至 M10-B
## Codex 入口
+27 -17
View File
@@ -7072,14 +7072,14 @@ TARGET=CODE_COMPLETE_THEN_RELEASE_CANDIDATE_THEN_PRODUCTION_ACCEPTED
execution_cursor_v57:
document_version: V5.7
audited_snapshot_sha256: cc802fecbea399698e0a9c1b38127bc12ff260d8f4d6a084828108307f557d6f
evidence_head: e91b97912840a358900a51814660c96fb4f168ea
evidence_origin_main: e91b97912840a358900a51814660c96fb4f168ea
evidence_head: 41b9cf7349f1bcf6eb91fbe9cbf1fb972158622f
evidence_origin_main: 41b9cf7349f1bcf6eb91fbe9cbf1fb972158622f
base_branch: main
remote: ssh://git@git.txyundm.cn:2222/panda/qipai.git
current_stage: M10-A
current_stage_status: TODO_AFTER_M09_REGRESSION_CURRENT_COMMIT
last_completed_functional_stage: M09-REGRESSION
last_engineering_commit: e91b97912840a358900a51814660c96fb4f168ea
current_stage: M10-B
current_stage_status: TODO_AFTER_M10_A_CURRENT_COMMIT
last_completed_functional_stage: M10-A
last_engineering_commit: 41b9cf7349f1bcf6eb91fbe9cbf1fb972158622f
last_tooling_commit: 2de796143ff418d681fdb972040b8555a8e058d1
m09_d1_business_commit: 93af128002c27ea548e9ed7d0c9af7b8f264002a
m09_d1_static_gate: PASS
@@ -7106,14 +7106,24 @@ execution_cursor_v57:
m09_d4_admin_build_and_test: PASS
m09_d4_mysql_84_roundtrip: PASS_217_UP_159_VERIFY_199_DOWN
m09_d4_one_time_credential_not_persisted: PASS
m09_regression_business_commit: PENDING_CURRENT_CHINESE_ENGINEERING_COMMIT
m09_regression_business_commit: 41b9cf7349f1bcf6eb91fbe9cbf1fb972158622f
m09_regression_static_gate: PASS
m09_regression_backend_build_and_full_test: PASS
m09_regression_admin_build_and_test: PASS
m09_regression_mysql_84_roundtrip: PASS_217_UP_159_VERIFY_199_DOWN
m09_regression_platform_manage_scope: PASS
m09_regression_order_payment_refund_inventory_storage_reconciliation: PASS
default_next_stage: M10-A
m10_a_business_commit: PENDING_CURRENT_CHINESE_ENGINEERING_COMMIT
m10_a_static_gate: PASS
m10_a_windows_full_test: PASS_76_7_SECONDS
m10_a_backend_build_and_full_test: PASS
m10_a_admin_build_and_test: PASS
m10_a_mysql_84_roundtrip: PASS_229_UP_165_VERIFY_209_DOWN
m10_a_mysql_cleanup_probe: PASS
m10_a_outbox_consent_redaction_idempotency: PASS
m10_a_retry_manual_attribution_immutable_attempts: PASS
m10_a_configured_http_adapters: PASS
default_next_stage: M10-B
actual_head_policy: REAL_REPOSITORY_EVIDENCE_WINS
worktree_protected: true
unresolved_deletions_policy: EXCLUDE_FROM_STAGE_COMMIT_UNTIL_USER_CONFIRMS
@@ -7127,13 +7137,12 @@ execution_cursor_v57:
auto_continue_until_code_complete: true
```
真实 `D:\qipai` 若有 `e91b979` 之后的提交,Codex 必须以 `git merge-base`、diff、迁移、代码、测试、提交和远端 SHA 判断进度,自动跳到第一个未完成阶段。当前 M09-REGRESSION 的推进依据是逐单对账、权限修复、静态门禁、Windows 全量与真实 MySQL 共同证据,不是 docs-only 状态变更;伴随业务提交完成后进入 M10-A
真实 `D:\qipai` 若有 `41b9cf7` 之后的提交,Codex 必须以 `git merge-base`、diff、迁移、代码、测试、提交和远端 SHA 判断进度,自动跳到第一个未完成阶段。当前 M10-A 的推进依据是通知领域迁移、统一 outbox、渠道适配、权限与客户授权、后台工作台、静态门禁、Windows 全量与真实 MySQL 共同证据,不是 docs-only 状态变更;伴随业务提交完成后进入 M10-B
## 30.2 最终唯一队列
```text
M09-REGRESSION
→ M10-A
M10-A
→ M10-B
→ M10-C
→ M10-D
@@ -7143,24 +7152,25 @@ M09-REGRESSION
→ PRODUCTION_ACCEPTED
```
Git 中文门禁M09-D1/D2/D3/D4 已由真实工程提交闭环;M09-REGRESSION 当前伴随业务提交必须完成中文 commit、push 和远端校验后继续 M10-A。不能依据文档中的 `DONE` 单独跳过其他阶段;已完成阶段若出现回归,只做针对性修复,不得重启整个模块。
Git 中文门禁M09-D1/D2/D3/D4 与 M09-REGRESSION 已由真实工程提交闭环;M10-A 当前伴随业务提交必须完成中文 commit、push 和远端校验后继续 M10-B。不能依据文档中的 `DONE` 单独跳过其他阶段;已完成阶段若出现回归,只做针对性修复,不得重启整个模块。
M09-D4 的唯一允许推进条件:
M10-A 的唯一允许推进条件:
```text
M09_D4_WORKTREE_PROTECTED=PASS
M09_D4_STATIC_GATE=PASS
M10_A_WORKTREE_PROTECTED=PASS
M10_A_STATIC_GATE=PASS
BACKEND_BUILD=PASS
BACKEND_FULL_TEST=PASS
MYSQL_84_ROUNDTRIP=PASS
MYSQL_CLEANUP_PROBE=PASS
ADMIN_BUILD_AND_TEST=PASS
REGRESSION_GATES=PASS
NOTIFICATION_DELIVERY_GATES=PASS
COMMIT_SUBJECT_CN=PASS
PUSH_VERIFIED=PASS
REMOTE_COMMIT_SUBJECT_CN=PASS
```
任一项未通过时保持 `CURRENT_STAGE=M09-D4`,修复后重跑;不能先更新为 DONE 或进入 M09-REGRESSION
任一项未通过时保持 `CURRENT_STAGE=M10-A`,修复后重跑;不能先更新为 DONE 或进入 M10-B
## 30.3 每次提交和推送的最终语言门禁
+2
View File
@@ -102,6 +102,7 @@ import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue';
import { ElMessage } from 'element-plus';
import {
ClipboardList,
BellRing,
Handshake,
Images,
LayoutDashboard,
@@ -150,6 +151,7 @@ const visibleNavigation = computed(() => ADMIN_NAVIGATION.filter(
(item) => isNavigationAllowed(adminSessionState.access, item)
));
const navigationIcons: Record<string, Component> = {
BellRing,
ClipboardList,
Handshake,
Images,
+53
View File
@@ -41,6 +41,10 @@ import type {
ProductOrderReconciliation,
ProductStorage,
InventoryStock,
NotificationChannel,
NotificationDelivery,
NotificationRoute,
NotificationTemplate,
PlatformApplication,
ProfitSharingSnapshot,
PayoutStateFilter,
@@ -349,6 +353,55 @@ export function getBusinessStatistics(
return request<BusinessStatistics>(session, `/statistics?${params}`);
}
export function listNotificationTemplates(session: ApiSession, input: { storeId?: string; eventType?: string } = {}) {
const params = new URLSearchParams();
if (input.storeId) params.set('storeId', input.storeId);
if (input.eventType) params.set('eventType', input.eventType);
return request<NotificationTemplate[]>(session, `/notifications/templates?${params}`);
}
export function saveNotificationTemplate(session: ApiSession, input: {
id?: string; storeId?: string | null; templateCode: string; eventType: string;
channel: NotificationChannel; titleTemplate: string; bodyTemplate: string;
externalTemplateId?: string; status?: 'ACTIVE' | 'DISABLED'; expectedVersion?: number;
}) {
return request<{ id: string; version: number }>(session, '/notifications/templates', {
method: 'PUT', body: JSON.stringify(input)
});
}
export function listNotificationRoutes(session: ApiSession, input: { storeId?: string; eventType?: string } = {}) {
const params = new URLSearchParams();
if (input.storeId) params.set('storeId', input.storeId);
if (input.eventType) params.set('eventType', input.eventType);
return request<NotificationRoute[]>(session, `/notifications/routes?${params}`);
}
export function createNotificationRoute(session: ApiSession, input: {
storeId?: string | null; eventType: string; templateId: string;
recipientType: 'CUSTOMER' | 'USER' | 'ROLE' | 'STORE_WEBHOOK'; recipientValue?: string;
quietStart?: string | null; quietEnd?: string | null;
}) {
return request<{ id: string; version: number }>(session, '/notifications/routes', {
method: 'POST', body: JSON.stringify(input)
});
}
export function listNotificationDeliveries(session: ApiSession, input: {
storeId?: string; status?: string; channel?: NotificationChannel; page?: number; pageSize?: number;
} = {}) {
const params = new URLSearchParams({ page: String(input.page || 1), pageSize: String(input.pageSize || 50) });
if (input.storeId) params.set('storeId', input.storeId);
if (input.status) params.set('status', input.status);
if (input.channel) params.set('channel', input.channel);
return request<PageResult<NotificationDelivery>>(session, `/notifications/deliveries?${params}`);
}
export function retryNotificationDelivery(session: ApiSession, deliveryId: string) {
return request<{ id: string; queued: boolean }>(session,
`/notifications/deliveries/${encodeURIComponent(deliveryId)}/retry`, { method: 'POST' });
}
export function listProductListings(session: ApiSession, storeId: string) {
return request<ProductListing[]>(session,
`/stores/${encodeURIComponent(storeId)}/product-listings?includeInactive=true`);
@@ -0,0 +1,89 @@
<template>
<section class="notification-page">
<el-alert v-if="error" :title="error" type="error" show-icon closable @close="error = ''" />
<section class="notification-metrics">
<span><small>通知模板</small><strong>{{ templates.length }}</strong></span>
<span><small>路由规则</small><strong>{{ routes.length }}</strong></span>
<span><small>待重试</small><strong>{{ retryCount }}</strong></span>
<span><small>投递失败</small><strong>{{ failedCount }}</strong></span>
</section>
<section class="panel">
<header class="notification-toolbar">
<div><p class="section-kicker">M10-A · 通知中心</p><h3>事件路由模板与投递追踪</h3></div>
<div class="toolbar-actions"><el-select v-model="storeId" clearable filterable placeholder="全部授权门店" @change="loadAll"><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select><el-button :loading="loading" @click="loadAll">刷新</el-button></div>
</header>
<el-tabs v-model="activeTab">
<el-tab-pane :label="`投递记录 (${deliveries.total})`" name="deliveries">
<div class="tab-tools"><el-select v-model="deliveryStatus" clearable placeholder="全部状态" @change="loadDeliveries"><el-option v-for="status in statuses" :key="status" :label="status" :value="status" /></el-select><el-select v-model="deliveryChannel" clearable placeholder="全部渠道" @change="loadDeliveries"><el-option v-for="item in channels" :key="item" :label="channelLabel(item)" :value="item" /></el-select></div>
<el-table v-loading="loading" :data="deliveries.items" row-key="id">
<el-table-column label="事件 / 内容" min-width="260"><template #default="{ row }"><div class="stack"><strong>{{ row.title }}</strong><span>{{ row.eventType }} · {{ row.body }}</span></div></template></el-table-column>
<el-table-column label="渠道 / 接收人" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ channelLabel(row.channel) }}</strong><span>{{ row.recipientMasked }}</span></div></template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column label="尝试" width="100"><template #default="{ row }">{{ row.attempts }} / {{ row.maxAttempts }}</template></el-table-column>
<el-table-column label="异常 / 时间" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.lastErrorCode || '-' }}</strong><span>{{ row.lastErrorMessage || dateText(row.sentAt || row.createdAt) }}</span></div></template></el-table-column>
<el-table-column v-if="canManage" label="操作" width="110" fixed="right"><template #default="{ row }"><el-button v-if="row.status === 'FAILED'" size="small" type="primary" plain @click="retry(row)">人工补发</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`模板 (${templates.length})`" name="templates">
<div class="tab-tools"><el-button v-if="canManage" type="primary" @click="openTemplate">新增模板</el-button></div>
<el-table :data="templates" row-key="id"><el-table-column label="模板" min-width="220"><template #default="{ row }"><div class="stack"><strong>{{ row.templateCode }}</strong><span>{{ row.titleTemplate }}</span></div></template></el-table-column><el-table-column prop="eventType" label="事件" min-width="210" /><el-table-column label="渠道" width="140"><template #default="{ row }">{{ channelLabel(row.channel) }}</template></el-table-column><el-table-column prop="bodyTemplate" label="正文" min-width="260" /><el-table-column prop="status" label="状态" width="100" /></el-table>
</el-tab-pane>
<el-tab-pane :label="`路由 (${routes.length})`" name="routes">
<div class="tab-tools"><el-button v-if="canManage" type="primary" :disabled="!templates.length" @click="openRoute">新增路由</el-button></div>
<el-table :data="routes" row-key="id"><el-table-column prop="eventType" label="事件" min-width="210" /><el-table-column label="模板 / 渠道" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.templateCode }}</strong><span>{{ channelLabel(row.channel) }}</span></div></template></el-table-column><el-table-column label="接收人" min-width="190"><template #default="{ row }">{{ row.recipientType }} · {{ row.recipientValue }}</template></el-table-column><el-table-column label="静默时段" width="160"><template #default="{ row }">{{ row.quietStart ? `${row.quietStart} - ${row.quietEnd}` : '未设置' }}</template></el-table-column><el-table-column prop="status" label="状态" width="100" /></el-table>
</el-tab-pane>
</el-tabs>
</section>
<el-dialog v-model="templateDialog" title="新增通知模板" width="min(620px, 94vw)"><el-form label-position="top"><el-form-item label="模板编码"><el-input v-model="templateForm.templateCode" /></el-form-item><el-form-item label="事件类型"><el-input v-model="templateForm.eventType" /></el-form-item><el-form-item label="渠道"><el-select v-model="templateForm.channel"><el-option v-for="item in channels" :key="item" :label="channelLabel(item)" :value="item" /></el-select></el-form-item><el-form-item label="标题模板"><el-input v-model="templateForm.titleTemplate" /></el-form-item><el-form-item label="正文模板"><el-input v-model="templateForm.bodyTemplate" type="textarea" :rows="4" placeholder="支持 {{field}} 占位符" /></el-form-item><el-form-item label="外部模板 ID"><el-input v-model="templateForm.externalTemplateId" /></el-form-item></el-form><template #footer><el-button @click="templateDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveTemplate">保存</el-button></template></el-dialog>
<el-dialog v-model="routeDialog" title="新增通知路由" width="min(620px, 94vw)"><el-form label-position="top"><el-form-item label="模板"><el-select v-model="routeForm.templateId" filterable @change="syncRouteEvent"><el-option v-for="item in templates" :key="item.id" :label="`${item.templateCode} · ${item.eventType}`" :value="item.id" /></el-select></el-form-item><el-form-item label="事件类型"><el-input v-model="routeForm.eventType" disabled /></el-form-item><el-form-item label="接收人类型"><el-select v-model="routeForm.recipientType"><el-option label="事件顾客" value="CUSTOMER" /><el-option label="指定用户" value="USER" /><el-option label="角色" value="ROLE" /><el-option label="门店 Webhook" value="STORE_WEBHOOK" /></el-select></el-form-item><el-form-item label="接收人值"><el-input v-model="routeForm.recipientValue" :disabled="['CUSTOMER', 'STORE_WEBHOOK'].includes(routeForm.recipientType)" placeholder="角色编码或用户 ID" /></el-form-item><el-form-item label="静默开始/结束"><div class="quiet-row"><el-time-picker v-model="routeForm.quietStart" value-format="HH:mm:ss" placeholder="开始" /><el-time-picker v-model="routeForm.quietEnd" value-format="HH:mm:ss" placeholder="结束" /></div></el-form-item></el-form><template #footer><el-button @click="routeDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveRoute">保存</el-button></template></el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { ApiError, createNotificationRoute, listManagedStores, listNotificationDeliveries, listNotificationRoutes, listNotificationTemplates, retryNotificationDelivery, saveNotificationTemplate, type ApiSession } from '../api';
import { adminSessionState } from '../session';
import type { ManagedStore, NotificationChannel, NotificationDelivery, NotificationRoute, NotificationTemplate, PageResult } from '../types';
const props = defineProps<{ session: ApiSession }>();
const stores = ref<ManagedStore[]>([]); const storeId = ref(''); const activeTab = ref('deliveries');
const templates = ref<NotificationTemplate[]>([]); const routes = ref<NotificationRoute[]>([]);
const deliveries = reactive<PageResult<NotificationDelivery>>({ items: [], total: 0, page: 1, pageSize: 50 });
const channels: NotificationChannel[] = ['IN_APP', 'WECHAT_SUBSCRIBE', 'WE_COM', 'WEBHOOK', 'CLOUD_SPEAKER'];
const statuses = ['PENDING', 'PROCESSING', 'SENT', 'RETRY', 'FAILED', 'SUPPRESSED'];
const deliveryStatus = ref(''); const deliveryChannel = ref<NotificationChannel | ''>('');
const loading = ref(false); const saving = ref(false); const error = ref('');
const templateDialog = ref(false); const routeDialog = ref(false);
const templateForm = reactive({ templateCode: '', eventType: '', channel: 'IN_APP' as NotificationChannel, titleTemplate: '', bodyTemplate: '', externalTemplateId: '' });
const routeForm = reactive({ templateId: '', eventType: '', recipientType: 'ROLE' as 'CUSTOMER' | 'USER' | 'ROLE' | 'STORE_WEBHOOK', recipientValue: 'STORE_ADMIN', quietStart: null as string | null, quietEnd: null as string | null });
const canManage = computed(() => adminSessionState.access.roles.includes('PLATFORM_ADMIN') || adminSessionState.access.capabilities.some((item) => ['notification.manage', 'tenant.manage', 'platform.manage'].includes(item)));
const retryCount = computed(() => deliveries.items.filter((item) => item.status === 'RETRY').length);
const failedCount = computed(() => deliveries.items.filter((item) => item.status === 'FAILED').length);
async function capture<T>(work: () => Promise<T>) { error.value = ''; try { return await work(); } catch (reason) { error.value = reason instanceof ApiError ? reason.code : reason instanceof Error ? reason.message : '操作失败'; throw reason; } }
async function loadAll() { if (!props.session.token) return; loading.value = true; try { const [nextStores, nextTemplates, nextRoutes, nextDeliveries] = await Promise.all([stores.value.length ? Promise.resolve(stores.value) : capture(() => listManagedStores(props.session)), capture(() => listNotificationTemplates(props.session, { storeId: storeId.value || undefined })), capture(() => listNotificationRoutes(props.session, { storeId: storeId.value || undefined })), capture(() => listNotificationDeliveries(props.session, { storeId: storeId.value || undefined, status: deliveryStatus.value || undefined, channel: deliveryChannel.value || undefined, pageSize: 50 }))]); stores.value = nextStores; templates.value = nextTemplates; routes.value = nextRoutes; Object.assign(deliveries, nextDeliveries); } catch { /* displayed */ } finally { loading.value = false; } }
async function loadDeliveries() { loading.value = true; try { Object.assign(deliveries, await capture(() => listNotificationDeliveries(props.session, { storeId: storeId.value || undefined, status: deliveryStatus.value || undefined, channel: deliveryChannel.value || undefined, pageSize: 50 }))); } catch { /* displayed */ } finally { loading.value = false; } }
function openTemplate() { Object.assign(templateForm, { templateCode: '', eventType: '', channel: 'IN_APP', titleTemplate: '', bodyTemplate: '', externalTemplateId: '' }); templateDialog.value = true; }
async function saveTemplate() { saving.value = true; try { await capture(() => saveNotificationTemplate(props.session, { ...templateForm, storeId: storeId.value || null })); templateDialog.value = false; ElMessage.success('通知模板已保存'); await loadAll(); } catch { /* displayed */ } finally { saving.value = false; } }
function openRoute() { const first = templates.value[0]; Object.assign(routeForm, { templateId: first?.id || '', eventType: first?.eventType || '', recipientType: 'ROLE', recipientValue: 'STORE_ADMIN', quietStart: null, quietEnd: null }); routeDialog.value = true; }
function syncRouteEvent() { routeForm.eventType = templates.value.find((item) => item.id === routeForm.templateId)?.eventType || ''; }
async function saveRoute() { saving.value = true; try { await capture(() => createNotificationRoute(props.session, { ...routeForm, storeId: storeId.value || null, recipientValue: ['CUSTOMER', 'STORE_WEBHOOK'].includes(routeForm.recipientType) ? undefined : routeForm.recipientValue })); routeDialog.value = false; ElMessage.success('通知路由已保存'); await loadAll(); } catch { /* displayed */ } finally { saving.value = false; } }
async function retry(row: NotificationDelivery) { try { await capture(() => retryNotificationDelivery(props.session, row.id)); ElMessage.success('补发任务已入队'); await loadDeliveries(); } catch { /* displayed */ } }
function channelLabel(value: string) { return ({ IN_APP: '后台站内', WECHAT_SUBSCRIBE: '微信订阅', WE_COM: '企业微信', WEBHOOK: 'Webhook', CLOUD_SPEAKER: '云喇叭' } as Record<string, string>)[value] || value; }
function statusType(value: string) { return value === 'SENT' ? 'success' : value === 'FAILED' ? 'danger' : ['RETRY', 'SUPPRESSED'].includes(value) ? 'warning' : 'info'; }
function dateText(value: string | null) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'; }
watch(() => props.session.token, (token) => { if (token) void loadAll(); }, { immediate: true });
</script>
<style scoped>
.notification-page { display: grid; gap: 18px; }
.notification-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
.notification-metrics span { display: grid; gap: 5px; padding: 18px; border: 1px solid var(--line); border-radius: 16px; background: #fff; }
.notification-metrics small, .stack span { color: var(--muted); }
.notification-metrics strong { font-size: 28px; }
.panel { padding: 20px; border: 1px solid var(--line); border-radius: 18px; background: #fff; }
.notification-toolbar, .toolbar-actions, .tab-tools, .quiet-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.notification-toolbar { justify-content: space-between; margin-bottom: 12px; }
.notification-toolbar h3 { margin: 2px 0 0; }.section-kicker { margin: 0; color: #2563eb; font-size: 12px; font-weight: 700; }
.tab-tools { justify-content: flex-end; margin-bottom: 14px; }.stack { display: grid; gap: 4px; }.stack span { font-size: 12px; }
@media (max-width: 760px) { .notification-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }.panel { padding: 12px; }.toolbar-actions, .toolbar-actions .el-select { width: 100%; } }
</style>
+1
View File
@@ -3,6 +3,7 @@ export type AdminMenuKey =
| 'stores'
| 'orders'
| 'products'
| 'notifications'
| 'payments'
| 'thirdParty'
| 'people'
+1
View File
@@ -3,6 +3,7 @@ export const ADMIN_NAVIGATION = Object.freeze([
{ menuKey: 'stores', path: '/stores', stage: 'M08-D', title: '门店与房间管理', label: '门店房间', icon: 'Store', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'orders', path: '/orders', stage: 'M08-D', title: '订单筛选与运营处置', label: '订单运营', icon: 'ClipboardList', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'products', path: '/products', stage: 'M09-D4', title: '商品、库存、订单与寄存', label: '商品运营', icon: 'ShoppingBasket', capabilities: ['product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust', 'goods.order.read', 'goods.order.manage', 'goods.storage.read', 'goods.storage.manage', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'notifications', path: '/notifications', stage: 'M10-A', title: '通知模板、路由与投递追踪', label: '通知中心', icon: 'BellRing', capabilities: ['notification.read', 'notification.manage', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'payments', path: '/payments', stage: 'M08-D', title: '支付、退款与分账', label: '支付分账', icon: 'WalletCards', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'thirdParty', path: '/third-party', stage: 'M08-D', title: '团购与第三方平台运营', label: '团购平台', icon: 'TicketCheck', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'people', path: '/people', stage: 'M08-D', title: '会员与员工用户管理', label: '会员员工', icon: 'UsersRound', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
+1
View File
@@ -12,6 +12,7 @@ const routeComponents = {
stores: () => import('./components/StoresRoomsPanel.vue'),
orders: () => import('./components/OrdersPanel.vue'),
products: () => import('./components/ProductOperationsPanel.vue'),
notifications: () => import('./components/NotificationCenterPanel.vue'),
payments: () => import('./components/PaymentsPanel.vue'),
thirdParty: () => import('./components/ThirdPartyPanel.vue'),
people: () => import('./components/MembersStaffPanel.vue'),
+22
View File
@@ -113,6 +113,28 @@ export interface ProductStorageItem {
totalQuantity: number; remainingQuantity: number;
}
export type NotificationChannel = 'IN_APP' | 'WECHAT_SUBSCRIBE' | 'WE_COM' | 'WEBHOOK' | 'CLOUD_SPEAKER';
export interface NotificationTemplate {
id: string; storeId: string | null; templateCode: string; eventType: string;
channel: NotificationChannel; titleTemplate: string; bodyTemplate: string;
externalTemplateId: string; status: 'ACTIVE' | 'DISABLED'; version: number;
createdAt: string; updatedAt: string;
}
export interface NotificationRoute {
id: string; storeId: string | null; eventType: string; templateId: string;
templateCode: string; channel: NotificationChannel;
recipientType: 'CUSTOMER' | 'USER' | 'ROLE' | 'STORE_WEBHOOK';
recipientValue: string; quietStart: string | null; quietEnd: string | null;
status: 'ACTIVE' | 'DISABLED'; version: number;
}
export interface NotificationDelivery {
id: string; storeId: string | null; outboxEventId: string; eventType: string;
channel: NotificationChannel; recipientType: string; recipientMasked: string;
title: string; body: string; status: string; attempts: number; maxAttempts: number;
lastErrorCode: string; lastErrorMessage: string; providerMessageId: string;
manualRetryCount: number; sentAt: string | null; readAt: string | null; createdAt: string;
}
export interface ManagedStore {
id: string;
name: string;
+7 -1
View File
@@ -9,12 +9,13 @@ import {
assert.deepEqual(
ADMIN_NAVIGATION.map(({ path }) => path),
['/overview', '/stores', '/orders', '/products', '/payments', '/third-party', '/people',
['/overview', '/stores', '/orders', '/products', '/notifications', '/payments', '/third-party', '/people',
'/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']
);
assert.equal(pathForMenu('platformApps'), '/apps');
assert.equal(pathForMenu('thirdParty'), '/third-party');
assert.equal(pathForMenu('products'), '/products');
assert.equal(pathForMenu('notifications'), '/notifications');
const storeOperator = {
roles: ['STORE_ADMIN'],
@@ -33,6 +34,11 @@ const goodsOperator = {
assert.equal(resolveAuthorizedPath(goodsOperator, 'products'), '/products');
assert.equal(firstAuthorizedPath(goodsOperator), '/products');
const notificationOperator = {
roles: ['STORE_ADMIN'], capabilities: ['notification.read'], menus: ['notifications']
};
assert.equal(resolveAuthorizedPath(notificationOperator, 'notifications'), '/notifications');
const forgedSystemMenu = {
roles: ['STORE_ADMIN'],
capabilities: ['store.operation.read'],
+7
View File
@@ -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=
+1 -1
View File
@@ -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": {
+5
View File
@@ -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;
}
+4 -2
View File
@@ -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]
);
+2
View File
@@ -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(
+5 -2
View File
@@ -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',
+15 -1
View File
@@ -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])); }
+13 -1
View File
@@ -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) {
+24
View File
@@ -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]
);
}
}
+13 -1
View File
@@ -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(
+3 -1
View File
@@ -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)] };
}
+144
View File
@@ -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 });
}
+10
View File
@@ -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 () => {
+22 -3
View File
@@ -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);
+25 -1
View File
@@ -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.');
+10 -3
View File
@@ -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.');
+55
View File
@@ -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)
]);
@@ -0,0 +1,11 @@
DROP TRIGGER IF EXISTS qipai_notification_attempts_no_delete;
DROP TRIGGER IF EXISTS qipai_notification_attempts_no_update;
DELETE rp FROM qipai_role_permissions rp INNER JOIN qipai_permissions p ON p.id = rp.permission_id
WHERE p.code IN ('notification.read', 'notification.manage');
DELETE FROM qipai_permissions WHERE code IN ('notification.read', 'notification.manage');
DROP TABLE IF EXISTS qipai_notification_attempts;
DROP TABLE IF EXISTS qipai_notification_deliveries;
DROP TABLE IF EXISTS qipai_notification_subscriptions;
DROP TABLE IF EXISTS qipai_notification_routes;
DROP TABLE IF EXISTS qipai_notification_templates;
DELETE FROM qipai_schema_migrations WHERE version = '2026081110';
@@ -0,0 +1,154 @@
CREATE TABLE IF NOT EXISTS qipai_notification_templates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NULL,
scope_store_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(store_id, 0)) STORED,
template_code VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
event_type VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
channel VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
title_template VARCHAR(256) NOT NULL,
body_template VARCHAR(2000) NOT NULL,
external_template_id VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
version INT UNSIGNED NOT NULL DEFAULT 1,
created_by BIGINT UNSIGNED NULL,
updated_by BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_notification_template_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_notification_template_store FOREIGN KEY (tenant_id, store_id) REFERENCES qipai_stores(tenant_id, id),
CONSTRAINT fk_qipai_notification_template_creator FOREIGN KEY (tenant_id, created_by) REFERENCES qipai_users(tenant_id, id),
CONSTRAINT fk_qipai_notification_template_updater FOREIGN KEY (tenant_id, updated_by) REFERENCES qipai_users(tenant_id, id),
UNIQUE KEY uq_qipai_notification_template_scope (tenant_id, scope_store_id, template_code),
UNIQUE KEY uq_qipai_notification_template_scope_id (tenant_id, id),
KEY idx_qipai_notification_template_event (tenant_id, event_type, channel, status),
CONSTRAINT chk_qipai_notification_template_channel CHECK (channel IN ('IN_APP', 'WECHAT_SUBSCRIBE', 'WE_COM', 'WEBHOOK', 'CLOUD_SPEAKER')),
CONSTRAINT chk_qipai_notification_template_status CHECK (status IN ('ACTIVE', 'DISABLED')),
CONSTRAINT chk_qipai_notification_template_version CHECK (version >= 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_notification_routes (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NULL,
scope_store_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(store_id, 0)) STORED,
event_type VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
template_id BIGINT UNSIGNED NOT NULL,
recipient_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
recipient_value VARCHAR(191) NOT NULL,
quiet_start TIME NULL,
quiet_end TIME NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
version INT UNSIGNED NOT NULL DEFAULT 1,
created_by BIGINT UNSIGNED NULL,
updated_by BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_notification_route_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_notification_route_store FOREIGN KEY (tenant_id, store_id) REFERENCES qipai_stores(tenant_id, id),
CONSTRAINT fk_qipai_notification_route_template FOREIGN KEY (tenant_id, template_id) REFERENCES qipai_notification_templates(tenant_id, id),
CONSTRAINT fk_qipai_notification_route_creator FOREIGN KEY (tenant_id, created_by) REFERENCES qipai_users(tenant_id, id),
CONSTRAINT fk_qipai_notification_route_updater FOREIGN KEY (tenant_id, updated_by) REFERENCES qipai_users(tenant_id, id),
UNIQUE KEY uq_qipai_notification_route_scope_id (tenant_id, id),
UNIQUE KEY uq_qipai_notification_route (tenant_id, scope_store_id, event_type, template_id, recipient_type, recipient_value),
KEY idx_qipai_notification_route_match (tenant_id, event_type, store_id, status),
CONSTRAINT chk_qipai_notification_route_recipient CHECK (recipient_type IN ('CUSTOMER', 'USER', 'ROLE', 'STORE_WEBHOOK')),
CONSTRAINT chk_qipai_notification_route_status CHECK (status IN ('ACTIVE', 'DISABLED')),
CONSTRAINT chk_qipai_notification_route_quiet CHECK ((quiet_start IS NULL AND quiet_end IS NULL) OR (quiet_start IS NOT NULL AND quiet_end IS NOT NULL)),
CONSTRAINT chk_qipai_notification_route_version CHECK (version >= 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_notification_subscriptions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
channel VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
template_code VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status VARCHAR(16) NOT NULL,
authorized_at DATETIME(3) NOT NULL,
revoked_at DATETIME(3) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_notification_subscription_user FOREIGN KEY (tenant_id, user_id) REFERENCES qipai_users(tenant_id, id),
UNIQUE KEY uq_qipai_notification_subscription (tenant_id, user_id, channel, template_code),
CONSTRAINT chk_qipai_notification_subscription_channel CHECK (channel IN ('WECHAT_SUBSCRIBE')),
CONSTRAINT chk_qipai_notification_subscription_status CHECK (status IN ('AUTHORIZED', 'REVOKED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_notification_deliveries (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NULL,
outbox_event_id BIGINT UNSIGNED NOT NULL,
route_id BIGINT UNSIGNED NOT NULL,
template_id BIGINT UNSIGNED NOT NULL,
event_type VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
channel VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
recipient_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
recipient_id VARCHAR(191) NOT NULL,
recipient_masked VARCHAR(191) NOT NULL,
title_snapshot VARCHAR(256) NOT NULL,
body_snapshot VARCHAR(2000) NOT NULL,
payload_snapshot JSON NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
attempts INT UNSIGNED NOT NULL DEFAULT 0,
max_attempts INT UNSIGNED NOT NULL DEFAULT 8,
available_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
sent_at DATETIME(3) NULL,
read_at DATETIME(3) NULL,
last_error_code VARCHAR(64) NOT NULL DEFAULT '',
last_error_message VARCHAR(512) NOT NULL DEFAULT '',
provider_message_id VARCHAR(191) NOT NULL DEFAULT '',
manual_retry_count INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_notification_delivery_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_notification_delivery_store FOREIGN KEY (tenant_id, store_id) REFERENCES qipai_stores(tenant_id, id),
CONSTRAINT fk_qipai_notification_delivery_outbox FOREIGN KEY (outbox_event_id) REFERENCES qipai_outbox_events(id),
CONSTRAINT fk_qipai_notification_delivery_route FOREIGN KEY (tenant_id, route_id) REFERENCES qipai_notification_routes(tenant_id, id),
CONSTRAINT fk_qipai_notification_delivery_template FOREIGN KEY (tenant_id, template_id) REFERENCES qipai_notification_templates(tenant_id, id),
UNIQUE KEY uq_qipai_notification_delivery_scope_id (tenant_id, id),
UNIQUE KEY uq_qipai_notification_delivery_idempotency (tenant_id, outbox_event_id, channel, recipient_type, recipient_id, template_id),
KEY idx_qipai_notification_delivery_dispatch (status, available_at, id),
KEY idx_qipai_notification_delivery_management (tenant_id, store_id, status, created_at, id),
KEY idx_qipai_notification_delivery_inbox (tenant_id, recipient_type, recipient_id, channel, read_at, created_at),
CONSTRAINT chk_qipai_notification_delivery_status CHECK (status IN ('PENDING', 'PROCESSING', 'SENT', 'RETRY', 'FAILED', 'SUPPRESSED')),
CONSTRAINT chk_qipai_notification_delivery_attempts CHECK (attempts <= max_attempts AND max_attempts BETWEEN 1 AND 20)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_notification_attempts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
delivery_id BIGINT UNSIGNED NOT NULL,
attempt_no INT UNSIGNED NOT NULL,
trigger_type VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status VARCHAR(16) NOT NULL,
error_code VARCHAR(64) NOT NULL DEFAULT '',
error_message VARCHAR(512) NOT NULL DEFAULT '',
provider_message_id VARCHAR(191) NOT NULL DEFAULT '',
operator_id BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_notification_attempt_delivery FOREIGN KEY (tenant_id, delivery_id) REFERENCES qipai_notification_deliveries(tenant_id, id),
CONSTRAINT fk_qipai_notification_attempt_operator FOREIGN KEY (tenant_id, operator_id) REFERENCES qipai_users(tenant_id, id),
UNIQUE KEY uq_qipai_notification_attempt (tenant_id, delivery_id, attempt_no),
CONSTRAINT chk_qipai_notification_attempt_trigger CHECK (trigger_type IN ('AUTO', 'MANUAL')),
CONSTRAINT chk_qipai_notification_attempt_status CHECK (status IN ('SENT', 'FAILED', 'SUPPRESSED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
DROP TRIGGER IF EXISTS qipai_notification_attempts_no_update;
CREATE TRIGGER qipai_notification_attempts_no_update BEFORE UPDATE ON qipai_notification_attempts
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'NOTIFICATION_ATTEMPT_IMMUTABLE';
DROP TRIGGER IF EXISTS qipai_notification_attempts_no_delete;
CREATE TRIGGER qipai_notification_attempts_no_delete BEFORE DELETE ON qipai_notification_attempts
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'NOTIFICATION_ATTEMPT_IMMUTABLE';
INSERT IGNORE INTO qipai_permissions (code, name, category) VALUES
('notification.read', '查看通知中心', 'notification'),
('notification.manage', '管理通知与补发', 'notification');
INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
SELECT r.tenant_id, r.id, p.id FROM qipai_roles r
INNER JOIN qipai_permissions p ON p.code IN ('notification.read', 'notification.manage')
WHERE r.code IN ('STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
AND r.status = 'ACTIVE' AND r.deleted_at IS NULL;
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026081110', 'm10a_notification_center');
@@ -0,0 +1,26 @@
SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name IN (
'qipai_notification_templates', 'qipai_notification_routes',
'qipai_notification_subscriptions', 'qipai_notification_deliveries',
'qipai_notification_attempts'
) ORDER BY table_name;
SELECT table_name, index_name FROM information_schema.statistics
WHERE table_schema = DATABASE() AND index_name IN (
'uq_qipai_notification_template_scope', 'idx_qipai_notification_template_event',
'uq_qipai_notification_route', 'idx_qipai_notification_route_match',
'uq_qipai_notification_subscription', 'uq_qipai_notification_delivery_idempotency',
'idx_qipai_notification_delivery_dispatch', 'idx_qipai_notification_delivery_management',
'uq_qipai_notification_attempt'
) GROUP BY table_name, index_name ORDER BY table_name, index_name;
SELECT trigger_name FROM information_schema.triggers
WHERE trigger_schema = DATABASE() AND trigger_name IN (
'qipai_notification_attempts_no_update', 'qipai_notification_attempts_no_delete'
) ORDER BY trigger_name;
SELECT code FROM qipai_permissions
WHERE code IN ('notification.read', 'notification.manage') ORDER BY code;
SELECT COUNT(*) AS assigned_role_permission_count FROM qipai_role_permissions rp
INNER JOIN qipai_roles r ON r.tenant_id = rp.tenant_id AND r.id = rp.role_id
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
WHERE r.code IN ('STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('notification.read', 'notification.manage');
SELECT version, name FROM qipai_schema_migrations WHERE version = '2026081110';
+9
View File
@@ -98,6 +98,14 @@ MYSQL_DATABASE=<以项目实际数据库名为准>
- 直订回调当前统一使用 `X-Third-Party-Signature` HMAC-SHA256 入口;取得真实平台授权后由专用适配器按厂商协议替换,不得降低验签要求。
- 券码只保存 SHA-256 和脱敏显示值,不得写入日志、回调摘要或后台列表。
## 通知渠道网关
- 站内信无需外部配置;门店 Webhook 使用后台登记的 `notification_url`,只接受不含用户名和密码的 HTTPS 地址。
- 微信订阅消息、企业微信和云喇叭分别使用 `QIPAI_NOTIFICATION_WECHAT_GATEWAY_URL``QIPAI_NOTIFICATION_WECOM_GATEWAY_URL``QIPAI_NOTIFICATION_CLOUD_SPEAKER_GATEWAY_URL`;仅配置非空 HTTPS 网关的渠道才启用。
- 对应 Bearer 凭据使用同名前缀的 `*_GATEWAY_TOKEN`,真实值只写 `/etc/qipai/qipai.secrets` 或 Git 忽略的本地配置,不进入数据库、日志和前端包。
- `QIPAI_NOTIFICATION_HTTP_TIMEOUT_MS` 默认 10000,服务端限制为 100–60000 毫秒;每次请求携带投递 ID 和幂等键,非 2xx 响应进入有限指数退避,达到上限转为失败并等待人工补发。
- 微信订阅消息必须先由登录顾客写入明确授权;撤销授权或处于门店本地静默时段时只记录 `SUPPRESSED`,人工补发不能绕过授权或静默拦截。
## 配置加载优先级
1. `/etc/qipai/qipai.secrets`
@@ -121,4 +129,5 @@ MYSQL_DATABASE=<以项目实际数据库名为准>
- 2026-06-22M05-D 增加门店收款账户、`profitShareReceivers` 环境映射、分账授权状态和非生产 Mock 门禁。
- 2026-08-10:总纲升级为 V5.6;同步最新源码接管、M09-A-RECOVERY 完成证据和 M06-C-R1 固定执行游标,既有凭据与配置位置不变。
- 2026-08-11:总纲升级为 V5.7;增加中文 Git 提交门禁并将当前游标推进至 M09-D2,既有凭据与配置位置不变。
- 2026-08-11M10-A 增加通知渠道 HTTPS 网关、Bearer 凭据、超时、订阅授权、静默时段与有限重试配置;未登记任何真实渠道凭据。
- 2026-08-10M06-C-R1 增加门锁烟测 subtype 变量,确保实物 `CtrlDevice` 始终携带 14/15;未增加任何仓库内明文凭据。
+11 -11
View File
@@ -1,10 +1,10 @@
# 当前开发成果基线
> V5.0 首次核验日期:2026-06-16
> audited_commit: `60264cb`
> audited_worktree: M09-D4 已完成工程(随本次中文工程提交固化)
> audited_at: `2026-08-11T15:10:00+08:00`
> next_engineering_target: 按 V5.7 固定队列执行 M09-REGRESSION,完成商品域全链路对账与模块验收
> audited_commit: `41b9cf7`
> audited_worktree: M10-A 已完成工程(随本次中文工程提交固化)
> audited_at: `2026-08-11T09:08:00+08:00`
> next_engineering_target: 按 V5.7 固定队列执行 M10-B,完成可由明细复算的统计报表
> 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。
## 总体结论
@@ -13,10 +13,10 @@
|---|---|---|---|
| 总纲版本 | V5.7 已成为当前权威总纲,V5.6 及更早版本仅作历史基线 | 根目录 `V5.7.md` 的增量控制层与第 30 章固定队列 | 必须按 V5.7 游标开发,不得回退到历史队列 |
| Git 远端 | `origin=ssh://git@git.txyundm.cn:2222/panda/qipai.git`,分支 `main` | `git rev-list main...origin/main``0 0` | 本地与远端同步 |
| 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、身份/RBAC/门店范围及 M03-M08 业务 APIM09-A/B/C 已完成保洁收口,M09-D1/D2/D3 已完成商品目录、库存、订单支付和寄存取出安全生命周期;M09-D4 新增登录态顾客门店目录接口 | `backend/src/products/**``backend/src/inventory/**``backend/src/routes/{products,inventory,product-orders,product-storages}.ts``database/migrations/202608110{7,8,9}_m09d*``backend/tests/product-*.test.mjs``backend/tests/inventory-*.test.mjs` | M09-D4 Windows 全量 71.6 秒通过;MySQL 8.4.10 完成 217 up、159 verify、199 down 往返清理探针,进入 M09-REGRESSION |
| 后台管理端 | 已建立 Vue3 + Vite + TypeScript + Element Plus 权限路由工程;13 个业务入口按路由懒加载;新增商品运营工作台统一目录、库存、商品订单与寄存,并按各域 read/manage capability 独立加载和控制写操作 | `admin/src/router.ts``admin/src/components/ProductOperationsPanel.vue``admin/src/api.ts``backend/src/routes/admin-auth.ts` | M09-D4 生产构建导航测试通过;桌面/平板/手机响应式样式和一次性领取码提示已纳入静态门禁 |
| 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、身份/RBAC/门店范围及 M03-M09 业务 APIM10-A 新增统一通知模板、路由、接收人解析、顾客订阅授权/站内信、HTTPS 渠道、幂等投递、有限重试、失败人工补发、门店时区静默与递归脱敏,并将房间订单支付退款、保洁、设备、商品订单和寄存事件接入 outbox | `backend/src/notifications/notification-service.ts``backend/src/routes/notifications.ts``backend/src/tasks/worker.ts``database/migrations/2026081110_m10a_notification_center.*.sql` | Windows 全量 76.7 秒通过;MySQL 8.4.10 完成 229 up、165 verify、209 down 往返清理探针,进入 M10-B |
| 后台管理端 | 已建立 Vue3 + Vite + TypeScript + Element Plus 权限路由工程;14 个业务入口按路由懒加载;通知中心支持按门店查看模板、事件路由和投递状态,筛选渠道/失败原因并对终态失败记录人工补发 | `admin/src/router.ts``admin/src/components/NotificationCenterPanel.vue``admin/src/api.ts``backend/src/routes/admin-auth.ts` | 生产构建导航测试通过;通知中心分块约 6.4 KiB gzip桌面/平板/手机响应式样式纳入静态门禁 |
| 微信小程序 | 已纳管微信原生模板,并完成顾客端房间订单、充值、权益、保洁等主体链路;M09-D4 新增门店商品选购、购物车、商品订单取消、整单寄存、部分取出、换码与取消寄存页面 | `miniapp/pages/goods/*``miniapp/pages/store/detail``miniapp/pages/profile/*``miniapp/utils/api.js``scripts/check-m09-d4-product-experience.mjs` | 商品领取码只保留页面内存并可复制后隐藏,不调用本地持久化;生产商品支付入口仍沿用服务端后续真实微信支付接线边界 |
| 数据库迁移 | 已形成 M01-B 至 M09-D3 连续 up/down/verify 计划;M09-D3 新增 4 张寄存记录、明细、事件与事件项流水表及 4 个不可变触发器;M09-D1 崩溃重试会先按依赖撤销 D3/D2,迁移 runner 继续按 marker/checksum 增量执行并由数据库命名锁串行化 | `database/migrations/2026081109_m09d3_product_storage.{up,down,verify}.sql``backend/src/db/migration-runner.ts``backend/tests/mysql-migration-roundtrip.test.mjs` | WSL MySQL 8.4.10 已完成 217 up、159 verify、199 down 的 up/verify/down/up/verify一次性凭证、部分取出、并发全量取出单赢家、取消/过期、隔离和不可变流水通过 |
| 数据库迁移 | 已形成 M01-B 至 M10-A 连续 up/down/verify 计划;M10-A 新增模板、路由、订阅、投递、不可变尝试 5 张表,使用生成作用域键保证 `NULL` 门店下的全局配置唯一,并在 M09-D1 崩溃重试按依赖撤销 | `database/migrations/2026081110_m10a_notification_center.{up,down,verify}.sql``backend/src/db/migration-runner.ts``backend/tests/mysql-migration-roundtrip.test.mjs` | WSL MySQL 8.4.10 已完成 229 up、165 verify、209 down 的 up/verify/down/up/verify订阅拦截、脱敏、幂等扇出、失败重试、人工审计和不可变尝试通过 |
| M00 部署脚本 | 已有菜单、状态、HTTPS、Certbot、EMQX、备份检查模板 | `setup.sh``scripts/setup/``deploy/` | PARTIAL,生产未执行 |
| 发布清单 dry-run | 可基于当前 HEAD 输出 `deployed=false` 的 release manifest,记录后端/后台/迁移尚未生成而跳过 | `scripts/setup/deploy-business.sh --dry-run .``scripts/dev/windows/check-release-manifest.ps1` | 可在 M00 验证结构;真实构建与生产发布待 M01/M09 后接入 |
| 参考资料 | 已有清单、脱敏日志、页面/接口/表结构摘要 | `docs/reference-*``docs/db-schema-inventory.md` | PARTIAL,仍需按模块迁移正式实现 |
@@ -26,7 +26,7 @@
## 状态校准
- `docs/module-status.md` 中 M00 仍为 `PARTIAL` 是正确的:生产 Ubuntu 初始化、部署与运维验收尚未执行;后端、后台和小程序源码已在后续模块建立。
- M01-M04 与 M07 已具备模块工程验收;M05、M06、M08 因真实厂商/硬件/生产条件保持 `PARTIAL`M09-A/B/C/D1/D2/D3/D4、M06-C/E-R1、M06-R-REGRESSION 与 M08-D-R1 已完成,当前进入 M09-REGRESSIONM10 尚未进入
- M01-M04、M07、M09 已具备模块工程验收;M05、M06、M08 因真实厂商/硬件/生产条件保持 `PARTIAL`M10-A 内部工程已完成,真实渠道凭据保持外部阻塞,当前进入 M10-B
- 任何“目录存在”只能证明骨架存在,不能证明模块完成。
## Git 历史敏感信息核验
@@ -37,6 +37,6 @@
## 下一步
1. 完成 M09-D4 中文提交、push远端标题校验。
2. 执行 M09-REGRESSION,对商品目录、库存、订单支付、退款、寄存数量、权限和界面逐单对账,补模块级缺陷与验收证据
3. M06-G 真实硬件、真实微信与团购联调继续等待外部条件,不阻断内部主队列。
1. 完成 M10-A 中文提交、push远端标题与 SHA 校验。
2. 执行 M10-B,统一报表口径、门店时区边界、明细复算、筛选和导出
3. 真实通知网关、M06-G 硬件、微信与团购联调继续等待外部条件,不阻断内部主队列。
+5 -2
View File
@@ -58,6 +58,8 @@
> 2026-08-11 M09-REGRESSION:当前伴随工程新增商品订单逐单对账,只读聚合订单明细、成功支付/退款、库存不可变流水及订单来源寄存余额,九项账实检查可直接定位金额、数量、占用、销售和寄存漂移;运营端同步展示支付、退款与不可变事件。回归修正 `platform.manage` 在库存/商品订单路由和服务层的空门店范围放行,并将已校验分页整数安全内联,解决 MySQL 8.4 预处理 `LIMIT/OFFSET` 参数兼容问题。Windows 全量与 MySQL 8.4.10 的 217 up、159 verify、199 down 往返通过,M09 内部工程完成,游标进入 M10-A。
> 2026-08-11 M10-A:当前伴随工程完成统一通知中心。5 张新表覆盖模板、事件路由、微信订阅授权、投递快照和不可变尝试;房间订单/支付退款、保洁任务、设备告警、商品订单和寄存事件统一写 outbox。worker 执行幂等扇出、门店时区静默、递归脱敏、有限指数退避和终态失败人工补发,人工尝试保留操作人;顾客可管理订阅授权、读取/已读站内信。站内信、门店 HTTPS Webhook 与按环境启用的微信订阅/企微/云喇叭网关适配器已接线,未配置渠道明确失败。Vue 后台第 14 个懒加载入口可维护模板/路由并追踪投递。Windows 全量 76.7 秒与 MySQL 8.4.10 的 229 up、165 verify、209 down 往返及清理探针通过,游标进入 M10-B。
| ID | 功能 | 模块子阶段 | 状态 | 最近提交 | 测试/验收证据 | 阻塞原因 | 下一步 |
|---|---|---|---|---|---|---|---|
| ENV-001 | 固定 Windows 工作区 | M00-A/M00-B | DONE | 远端 HEAD | 已确认 `D:\qipai`Git 根、main 分支、固定 origin 和 push 校验通过。 | - | 后续每模块继续复检。 |
@@ -66,6 +68,7 @@
| WSL-001 | WSL 隔离辅助验证 | M00-C | PARTIAL | `a690e85` | WSL 基础脚本已执行通过;本地 MQTT 服务级核验通过;认证/ACL 冒烟脚本入口、配置自检、可选 TLS/遗嘱/重复消息探测入口已生成;V5.0 点名的 WSL EMQX 检查/启动/停止入口已补齐。 | 正式后端/后台尚未生成,无法执行完整 Linux 构建;MQTT 本地账号未配置,认证/ACL/TLS/遗嘱/幂等未验收;生产 EMQX 不由 WSL 脚本管理。 | M01/M09 生成项目后在 WSL 原生临时副本执行完整构建;配置本地 MQTT 最小权限账号后执行冒烟和可选探测。 |
| API-001 | 固定 HTTPS API 域名 | M00-E/M01/M08/M10 | PARTIAL | `1192dcb` | 已新增 Windows 检查脚本、Ubuntu 菜单检查和固定域名 Nginx 模板;M01-A 健康接口、编译和本地 HTTP 检查通过;M01-B 的连接池、迁移 CLI、旧表兼容读取、整数分转换已通过契约测试,并在 WSL MySQL 8.4.9 的空库和脱敏旧库副本完成 `up/verify/down/up/verify`,异常退出清理后临时库与账号均为 0。 | DNS/HTTPS 生产验证、Ubuntu 24.04 生产运行时、业务接口和鉴权未验收。 | 进入 M02 身份与权限;后续在 M10 接入生产域名、证书和 Nginx 真实验收。 |
| ASYNC-001 | MySQL outbox 与轻量任务 worker | M01-C | DONE | `2b90d1f` | outbox/任务迁移、幂等键、`FOR UPDATE SKIP LOCKED` 领取、租约恢复、指数退避、人工补偿状态和 PM2 worker 已实现;Windows 契约测试与 WSL MySQL 8.4.9 往返迁移、重复入队、重启后领取验证通过。 | 具体通知、订单、设备、退款和统计 handler 在对应业务模块接入。 | M02 起按业务事务写入 outbox/任务并注册真实 handler。 |
| NTF-001 | 全场景通知提醒 | M10-A | PARTIAL | M10-A 当前工程 | 模板/路由、角色/用户/顾客/Webhook 接收人、订阅授权、站内信、HTTPS 渠道、投递快照、幂等、有限重试、失败人工补发、门店时区静默、递归脱敏和不可变尝试已完成;订单、支付退款、保洁、设备、商品与寄存事件接入统一 outbox,后台可维护和追踪。 | 真实微信订阅、企微和云喇叭网关凭据及生产回执为 `BLOCKED_EXTERNAL`。 | 取得渠道条件后执行小流量真实投递与回执对账;内部队列继续 M10-B。 |
| TLS-001 | Nginx 与证书自动化 | M00-E/M10 | PARTIAL | `4cb3ab6` | 已生成 Nginx 模板、Certbot 命令说明和菜单第 4 项检查,可检查模板、站点启用、TLS、健康端点、证书文件、续期配置和 `certbot.timer`。 | 证书申请/续期 dry-run、80/443 生产验证未执行。 | 在生产 Ubuntu 执行证书签发、续期 dry-run 和 Nginx 安装记录。 |
| WXNET-001 | 微信合法域名与真机验证 | M00-E/M08/M10 | PARTIAL | `4ac2c96` | 小程序配置继续固定 `https://api.txyundm.cn/app-api``scripts/check-miniapp-m08-a.mjs` 已检查不存在 localhost、127.0.0.1 和 HTTP API。 | 微信后台合法域名和真机体验版未验证。 | M08-A 后续补真机登录、上传/下载、下单和设备接口冒烟;M10 做生产验收。 |
| OPS-001 | 固定 `/opt/apps` 目录 | M00-D/M10 | PARTIAL | `292ab7f` | `scripts/setup/init-layout.sh` 已生成目录布局、uploads 目录和 manifestWSL 检查脚本通过,未在生产 Ubuntu 执行。 | 生产操作未执行。 | 由管理员在 Ubuntu 菜单执行并记录结果。 |
@@ -85,8 +88,8 @@
| CLN-005 | 保洁驳回/免清洁 | M08-B/M09 | PARTIAL | `eb838f5` | 已支持管理侧驳回、批量驳回和保洁员驳回补做;已新增 `POST /admin-api/cleaning/tasks/:taskId/exempt`,后台任务列表提供免清洁按钮、批量免清洁和 `EXEMPT` 筛选,仓储将可处理任务置为 `EXEMPT`、奖励归零并移除未结算协作者;待结算候选继续只取 `COMPLETED` 且未结算任务,免清洁任务不进入结算;管理统计 summary、门店明细、每日趋势和统计导出均新增免清洁数量,便于核对不结算口径。 | 真实商户现场执行仍待继续。 | 继续 M08-B/M09,补现场验收记录和更完整运营管理界面。 |
| PRD-001 | 商品目录与门店售卖 | M09-D1/M09-D4 | DONE | `93af128``e91b979` | 分类、商品、SKU、门店上架、履约方式、销售开关/人工暂停及最多 8 段每周营业时段已实现;M09-D4 新增登录态顾客门店目录,小程序与运营后台复用同一商品主数据。 | - | M10 按通知和统计需求复用商品主数据。 |
| INV-001 | 库存策略与不可变流水 | M09-D1/M09-D2 | DONE | `93af128``1a3ea7b` | TRACKED/UNLIMITED 策略、入库、调整、盘点、损耗、批量锁定/释放/扣减已实现;M09-D2 已把订单创建、支付、取消、超时和退款接入稳定业务号与同事务库存变更。请求指纹、版本 CAS、数量边界和不可变流水保证并发回调只扣一次、重复释放/返库幂等且不出现负库存。 | - | M09-D3 寄存数量已使用独立账本,不回写已完成订单项快照。 |
| GOODS-102 | 商品订单与支付库存闭环 | M09-D2/M09-D4 | DONE | `1a3ea7b``e91b979` + M09-REGRESSION 当前工程 | 商品订单与支付单分离,订单项与状态事件不可变;小程序与 Vue 运营端已接入完整状态处置,逐单对账覆盖金额、成功支付/退款、库存占用/销售余额并显示原始流水。 | 真实微信商品支付入口仍缺生产商户条件。 | M10-A 将商品订单与退款事件接入统一通知中心;真实支付纳入 BLOCKED_EXTERNAL。 |
| GOODS-104 | 商品寄存与安全取出 | M09-D3/M09-D4 | DONE | `60264cb``e91b979` + M09-REGRESSION 当前工程 | 寄存状态、商品快照、一次性哈希领取码、部分取出自动轮换、并发防超领、审计与 outbox 已完成;逐单对账验证订单来源寄存总量、剩余量与明细聚合一致。 | - | M10-A 消费既有寄存 outbox 并接入通知重试。 |
| GOODS-102 | 商品订单与支付库存闭环 | M09-D2/M09-D4 | DONE | `1a3ea7b``e91b979``41b9cf7` + M10-A 当前工程 | 商品订单与支付单分离,订单项与状态事件不可变;小程序与 Vue 运营端已接入完整状态处置,逐单对账覆盖金额、成功支付/退款、库存占用/销售余额并显示原始流水;状态、支付和退款事件已进入统一通知 outbox。 | 真实微信商品支付入口仍缺生产商户条件。 | M10-B 复用明细完成商品收入与退款报表;真实支付纳入 BLOCKED_EXTERNAL。 |
| GOODS-104 | 商品寄存与安全取出 | M09-D3/M09-D4 | DONE | `60264cb``e91b979``41b9cf7` + M10-A 当前工程 | 寄存状态、商品快照、一次性哈希领取码、部分取出自动轮换、并发防超领、审计与 outbox 已完成;逐单对账验证订单来源寄存总量、剩余量与明细聚合一致M10-A worker 已消费寄存事件并按路由投递。 | - | M10-B 保持寄存数量与商品订单收入分离。 |
| STAFF-001 | 员工账号 | M02-D/M08 | PARTIAL | `93f06d2` | 已实现创建员工、分配角色/门店、禁用、备注、重置会话和审计;管理员小程序已展示员工概况,Vue 后台可按角色/状态搜索并完成完整生命周期管理,权限变化即时撤销活动会话。 | 真实员工微信绑定和生产账号生命周期验收待补。 | M10 以真实员工账号验证绑定、门店切换、禁用和重新登录。 |
| BKG-002 | 后台用户管理 | M02-D/M08 | PARTIAL | `0511008` | 已实现分页筛选、注册/登录时间、状态、角色、门店范围、手机号/IP 脱敏及会员/STAFF 分栏;租户管理员可为同租户有效 STAFF 设置唯一登录名和强密码,更新凭据会撤销其活动会话且不返回密码哈希。 | 尚缺平台级跨租户用户视图和真实生产数据验收。 | M10 以生产平台/租户账号复验跨租户用户视图、凭据重置和审计。 |
| TEN-001 | 多门店支持 | M02/M03 | PARTIAL | `15139b3` | 门店 CRUD 与门店管理员数据范围已实现;跨门店写操作在 Windows 和 WSL MySQL 实测被拒绝。 | 地图选店、前端切换与完整订单数据范围待后续阶段。 | M03-C 接入地图选店,M04 起业务资源继承门店范围。 |
+8 -8
View File
@@ -3,16 +3,16 @@
```yaml
execution_cursor:
current_module: M10
current_stage: M10-A
current_stage: M10-B
stage_status: TODO
last_completed_stage: M09-REGRESSION
next_stage: M10-B
last_engineering_commit: `e91b979`
pending_engineering_commit: "M09-REGRESSION 已完成工程(随本次中文工程提交固化)"
last_completed_stage: M10-A
next_stage: M10-C
last_engineering_commit: `41b9cf7`
pending_engineering_commit: "M10-A 已完成工程(随本次中文工程提交固化)"
last_push_verified: true
base_branch: main
blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 可独立工程内容已完成,真实商户凭据、微信后台配置、生产小额转账和实物硬件结果保持 BLOCKED_EXTERNALM08-C 可独立工程内容已完成,真实美团/抖音/快手商家授权与真机验券保持 BLOCKED_EXTERNAL,已按规则旁路进入 M08-D。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。"
updated_at: 2026-08-11T08:30:00+08:00
updated_at: 2026-08-11T09:08:00+08:00
```
| 模块 | 状态 | 最近提交 | 最近开发日志 | 备注 |
@@ -26,6 +26,6 @@ execution_cursor:
| M06 设备、MQTT 与真实硬件联动 | PARTIAL | `da5751f` | docs/devlogs/2026-06-24-M06-E-智慧插座.md | M06-C/E-R1 已按原件校正控制箱、Sub-1G 与智慧插座 Wire/事件/脱敏;M06-R 已通过 Windows 后端全量、MQTT fixture、四项静态检查的中文/空格路径回归及 WSL MySQL 8.4.10 完整往返。M06-G 仍缺生产 MQTT 账号/ACL、DeviceID 和实物配线,保持 BLOCKED_EXTERNAL。 |
| M07 会员、余额、充值、优惠券和套餐营销 | DONE | `ed0d455` | docs/devlogs/2026-06-24-M07-D-会员管理.md | M07-A 已完成现金/赠送双余额账本;M07-B 已完成充值优惠;M07-C 已完成优惠券/套餐权益底表和冻结、确认、退回的可靠补偿核销服务;M07-D 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 |
| M08 微信原生小程序完整业务 | PARTIAL | `3faf868` | docs/devlogs/2026-08-10-M08-D-Vue平台后台.md | M08-A/B/C/D 功能切片与 M08-D-R1 架构返工已完成;12 个权限路由按领域懒加载,最大 JS 分块 217,195 B,三档响应式门禁通过。真实微信、团购和硬件现场验收按 BLOCKED_EXTERNAL 保持 PARTIAL。 |
| M09 保洁任务、结算、商品和库存 | DONE | `e91b979` + M09-REGRESSION 已完成工程 | docs/devlogs/2026-08-11-M09-REGRESSION-商品全链路对账.md | M09-A/B/C 与 M09-D1/D2/D3/D4 均已完成并推送。模块回归新增商品订单金额、支付退款、库存不可变流水和订单来源寄存的逐单对账,修正库存分页 MySQL 8.4 预处理兼容及 `platform.manage` 权限一致性;Windows 全量和 MySQL 8.4 往返通过,游标进入 M10-A。 |
| M10 部署、域名、验收和运维闭环 | TODO | M09-REGRESSION 当前工程 | docs/devlogs/2026-08-11-M09-REGRESSION-商品全链路对账.md | 下一步实现通知中心。 |
| M09 保洁任务、结算、商品和库存 | DONE | `41b9cf7` | docs/devlogs/2026-08-11-M09-REGRESSION-商品全链路对账.md | M09-A/B/C 与 M09-D1/D2/D3/D4 及模块回归均已完成并推送;逐单金额、支付退款、库存、寄存和权限闭环通过。 |
| M10 部署、域名、验收和运维闭环 | PARTIAL | M10-A 当前工程 | V5.7.md | M10-A 已完成统一通知事件、模板/路由、接收人解析、订阅授权、站内信、HTTPS 渠道适配器、幂等投递、有限重试、失败人工补发、静默时段、脱敏和后台追踪;Windows 全量 76.7 秒与 MySQL 8.4.10 的 229 up、165 verify、209 down 往返通过。下一步执行 M10-B 统计报表。 |
+2 -2
View File
@@ -24,11 +24,11 @@ if (!distOnly) {
assert.doesNotMatch(app, /import\s+(?!AdminLoginPanel\b)\w+Panel\s+from/);
const router = read('admin/src/router.ts');
for (const path of ['/overview', '/stores', '/orders', '/products', '/payments', '/third-party',
for (const path of ['/overview', '/stores', '/orders', '/products', '/notifications', '/payments', '/third-party',
'/people', '/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']) {
assert.match(read('admin/src/navigation.mjs'), new RegExp(`path: '${path.replace('/', '\\/')}'`));
}
assert.equal((router.match(/\(\) => import\('/g) || []).length, 13);
assert.equal((router.match(/\(\) => import\('/g) || []).length, 14);
assert.match(router, /router\.beforeEach/);
assert.match(router, /isNavigationAllowed/);
+2 -2
View File
@@ -24,11 +24,11 @@ for (const pattern of [
assert.doesNotMatch(app, /import\s+(?!AdminLoginPanel\b)\w+Panel\s+from/);
const router = read('admin/src/router.ts');
assert.equal((router.match(/\(\) => import\('/g) || []).length, 13);
assert.equal((router.match(/\(\) => import\('/g) || []).length, 14);
assert.match(router, /router\.beforeEach/);
const navigation = read('admin/src/navigation.mjs');
for (const path of ['/overview', '/stores', '/orders', '/products', '/payments', '/third-party', '/people',
'/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']) assert.ok(navigation.includes(`path: '${path}'`));
'/cleaning', '/notifications', '/devices', '/apps', '/content', '/franchise', '/system']) assert.ok(navigation.includes(`path: '${path}'`));
const cleaningWorkspace = read('admin/src/components/CleaningWorkspace.vue');
for (const pattern of ['CleaningTasksPanel', 'CleaningSettlementsPanel', 'CleaningStatisticsPanel',
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const root = process.cwd(); const read = (path) => readFileSync(join(root, path), 'utf8');
for (const suffix of ['up', 'down', 'verify']) assert.ok(existsSync(join(root, `database/migrations/2026081110_m10a_notification_center.${suffix}.sql`)));
const migration = read('database/migrations/2026081110_m10a_notification_center.up.sql');
for (const pattern of ['qipai_notification_templates', 'qipai_notification_routes',
'qipai_notification_subscriptions', 'qipai_notification_deliveries',
'qipai_notification_attempts', 'uq_qipai_notification_delivery_idempotency',
'scope_store_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(store_id, 0)) STORED',
'NOTIFICATION_ATTEMPT_IMMUTABLE', 'notification.read', 'notification.manage'])
assert.ok(migration.includes(pattern), `notification migration is missing ${pattern}`);
const service = read('backend/src/notifications/notification-service.ts');
for (const pattern of ['materializeOutboxEvent', 'enqueuePendingOutbox', 'handleTask',
'manualRetry', 'WECHAT_SUBSCRIBE', 'hasSubscription', 'inQuietHours',
'setSubscription', 'listInbox', 'markInboxRead', "trigger === 'MANUAL'",
'HttpNotificationAdapter', 'createNotificationAdaptersFromEnvironment',
'sensitiveKey', 'notification.dispatch', 'outbox.publish'])
assert.ok(service.includes(pattern), `notification service is missing ${pattern}`);
assert.match(read('backend/src/tasks/worker.ts'), /notification\.dispatch[\s\S]*outbox\.publish/);
assert.match(read('backend/src/products/product-order-service.ts'), /INSERT IGNORE INTO qipai_outbox_events/);
for (const source of ['backend/src/orders/pricing-repository.ts',
'backend/src/orders/order-state-repository.ts', 'backend/src/payments/payment-repository.ts',
'backend/src/payments/wechat-payment-service.ts',
'backend/src/cleaning/cleaning-task-repository.ts',
'backend/src/devices/iot-message-service.ts']) {
assert.match(read(source), /qipai_outbox_events/, `${source} must publish notification events`);
}
const routes = read('backend/src/routes/notifications.ts');
for (const pattern of ['/admin-api', '/app-api/management', '/notifications/templates',
'/notifications/routes', '/notifications/deliveries', '/retry',
'/app-api/notifications/subscriptions', '/app-api/notifications/inbox']) assert.ok(routes.includes(pattern));
const panel = read('admin/src/components/NotificationCenterPanel.vue');
for (const pattern of ['事件路由、模板与投递追踪', '人工补发', '新增通知模板',
'新增通知路由', '静默开始/结束']) assert.ok(panel.includes(pattern));
assert.match(read('admin/src/navigation.mjs'), /menuKey: 'notifications'/);
assert.match(read('admin/src/router.ts'), /NotificationCenterPanel\.vue/);
const mysql = read('backend/tests/mysql-migration-roundtrip.test.mjs');
for (const pattern of ['assertNotificationCenter', 'must-not-leak', 'explicit authorization',
'simulated webhook outage', "triggerType, operator_id AS operatorId",
'NOTIFICATION_ATTEMPT_IMMUTABLE']) assert.ok(mysql.includes(pattern));
console.log('PASS: M10-A notification templates, routing, consent, outbox fan-out, retry and operations gates are present.');
+2
View File
@@ -48,6 +48,8 @@ node scripts/check-m09-d4-product-experience.mjs
Assert-NativeSuccess "check-m09-d4-product-experience"
node scripts/check-m09-regression.mjs
Assert-NativeSuccess "check-m09-regression"
node scripts/check-m10-a-notifications.mjs
Assert-NativeSuccess "check-m10-a-notifications"
if (Test-Path "admin/package.json") {
Push-Location admin
+2 -2
View File
@@ -107,6 +107,6 @@ export QIPAI_MYSQL_USER="${username}"
export QIPAI_MYSQL_PASSWORD="${password}"
export QIPAI_MYSQL_CONNECTION_LIMIT=2
echo "INFO: MySQL ${mysql_version}; running M01-B through M09-D3 integration roundtrip in a temporary database."
echo "INFO: MySQL ${mysql_version}; running M01-B through M10-A integration roundtrip in a temporary database."
npm --prefix backend run test:mysql:migration
echo "PASS: M01-B through M09-D3 live MySQL integration roundtrip completed."
echo "PASS: M01-B through M10-A live MySQL integration roundtrip completed."