fix: align reporting filters and carrier quality

This commit is contained in:
hectorzhao
2026-07-28 20:55:50 +08:00
parent d8e5319647
commit 95c052e9ea
10 changed files with 172 additions and 52 deletions
+33 -1
View File
@@ -570,6 +570,24 @@ describe('OperationsService', () => {
averageArrivalMs: 1800,
},
]);
prisma.$queryRaw.mockResolvedValueOnce([
{
signatureId: 'signature-1',
carrier: 'mobile',
businessMessageCount: 3,
finalSuccessCount: 2,
finalSuccessRate: 66.7,
averageArrivalMs: 1100,
},
{
signatureId: 'signature-1',
carrier: 'telecom',
businessMessageCount: 2,
finalSuccessCount: 1,
finalSuccessRate: 50,
averageArrivalMs: 1800,
},
]);
const service = new OperationsService(prisma as never);
await expect(service.signatureQuality({
@@ -584,6 +602,20 @@ describe('OperationsService', () => {
signatureName: '【测试签名】',
total: 5,
channelSubmitTotal: 6,
carrierOverview: [
expect.objectContaining({
carrier: 'mobile',
businessMessageCount: 3,
finalSuccessCount: 2,
finalSuccessRate: 66.7,
}),
expect.objectContaining({
carrier: 'telecom',
businessMessageCount: 2,
finalSuccessCount: 1,
finalSuccessRate: 50,
}),
],
breakdowns: [
expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', total: 4 }),
expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', total: 2 }),
@@ -593,7 +625,7 @@ describe('OperationsService', () => {
page: 2,
pageSize: 5,
});
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
expect(prisma.$queryRaw).toHaveBeenCalledTimes(3);
});
it('does not query channel details when the selected date has no registered signatures', async () => {
+44
View File
@@ -890,11 +890,55 @@ export class OperationsService {
GROUP BY signature_id, channel_id, carrier
ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier
`);
const carrierOverview = signatureIds.length === 0
? []
: await this.prisma.$queryRaw<Array<{
signatureId: string;
carrier: string;
businessMessageCount: number;
finalSuccessCount: number;
finalSuccessRate: number;
averageArrivalMs: number | null;
}>>(Prisma.sql`
SELECT
message."signatureId" AS "signatureId",
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
COUNT(*)::integer AS "businessMessageCount",
COUNT(*) FILTER (
WHERE message.status = 'delivered'
OR message."receiptStatus" = 'delivered'
)::integer AS "finalSuccessCount",
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND(
COUNT(*) FILTER (
WHERE message.status = 'delivered'
OR message."receiptStatus" = 'delivered'
) * 100.0 / COUNT(*),
1
)::double precision
END AS "finalSuccessRate",
ROUND(AVG(
CASE
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
AND message."submittedAt" IS NOT NULL
AND message."deliveredAt" >= message."submittedAt"
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000
END
))::integer AS "averageArrivalMs"
FROM "SmsMessageRecord" message
WHERE message."signatureId" IN (${Prisma.join(signatureIds)})
AND message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
GROUP BY message."signatureId", COALESCE(NULLIF(message.carrier, ''), 'unknown')
ORDER BY message."signatureId", COUNT(*) DESC, carrier
`);
const items = summaries.map(({ rowCount: _rowCount, ...summary }) => {
const signatureBreakdowns = breakdowns.filter((item) => item.signatureId === summary.signatureId);
return {
...summary,
channelSubmitTotal: signatureBreakdowns.reduce((sum, item) => sum + item.total, 0),
carrierOverview: carrierOverview.filter((item) => item.signatureId === summary.signatureId),
breakdowns: signatureBreakdowns,
};
});
@@ -1510,8 +1510,9 @@
8. 导入审核必须同时支持新增和修改:页面明确展示每行操作类型、原对象、目标资料、错误原因和审核结果。驳回原因可选,不得因未填写原因阻止常用批量操作;审核人、审核时间和实际处理结果必须落 PostgreSQL。
9. “待生成报备批次”页面由“待生成资料”和“已生成批次”两个页签组成。两个页签均使用后端分页,并可按关键字和时间范围查询;待生成资料可继续按签名/引流类型筛选。
10. 已生成批次展示报备总数、成功数和成功率。报备总数以批次导出文件中的通道报备明细数为准,成功数以对应通道报备任务当前为通过的明细数为准;不展示“已导入回执”“等待回执”等当前无明确业务需求的字段。
11. 报备明细是一条签名或引流信息在一个具体通道上的当前报备状态。常用操作为逐条人工修改状态,弹窗只要求选择目标状态,修改原因可选;详情展示所属企业/应用、来源批次、导出文件行号和时间顺序的状态轨迹
12. 本阶段不提供新的回执导入入口,不实现批量回执文件规范、解析或自动状态覆盖。已有历史数据和后端兼容代码保留以便追溯,后续只有在回执文件格式、匹配键、批量结果语义和异常处理规则明确后再立项
11. 两个页签的搜索区使用统一查询操作按钮尺寸。待生成资料应缩短资料类型及“企业/应用/签名/站点”关键字输入宽度并增加“资料变更时间”宽度;已生成批次的“批次生成时间”保持紧凑,不占满剩余空间。查询和重置按钮统一使用全局查询操作区样式
12. 报备明细是一条签名或引流信息在一个具体通道上的当前报备状态。常用操作为逐条人工修改状态,弹窗只要求选择目标状态,修改原因可选;详情展示所属企业/应用、来源批次、导出文件行号和时间顺序的状态轨迹
13. 本阶段不提供新的回执导入入口,不实现批量回执文件规范、解析或自动状态覆盖。已有历史数据和后端兼容代码保留以便追溯,后续只有在回执文件格式、匹配键、批量结果语义和异常处理规则明确后再立项。
## HTTP 客户接口第一版
### 管理端企业应用配置
@@ -1795,6 +1796,7 @@
- 签名总览按业务短信记录统计发送量、送达成功、送达失败、提交失败、成功率和平均到达时间;同一业务短信在总览中只计算一次。
- 签名通道明细按真实`SmsSubmitRecord`提交尝试统计。短信发生切换通道补发时,各次提交分别归入实际通道,因此通道提交次数允许大于业务短信数。
- 运营商取短信发送时识别并持久化的真实号码运营商;通道与运营商是实际发送组合,不假设一个通道只支持一个运营商,也不把通道永久挂在某一个运营商下。
- 查看签名明细时,“运营商概览”的数量按真实`SmsMessageRecord`业务短信去重统计,同一短信发生通道切换或补发仍只计一条;成功率为最终送达成功业务短信数除以该运营商业务短信总数,展示名称统一为“最终成功率”。
- 查看签名明细时使用“通道行 × 运营商列”矩阵,每个有数据的单元格展示提交次数、成功率、平均到达时间及提交失败数量;所选日期无真实提交显示`—`,不据此推断通道不支持该运营商。
- 平均到达时间只统计成功送达的提交尝试,从该通道提交受理时间开始,到该通道成功回执完成为止;长短信以全部成功分片完成时间为准。
- 签名列表提供签名、企业或应用关键字查询和后端分页;查看详情不应依赖前端模拟数据或浏览器本地存储。
+2
View File
@@ -3607,6 +3607,7 @@ npm run verify:phase8
| TC-REPORT-BATCH-004 | 同一签名修改资料后再次选择生成批次。 | 材料版本递增;复用同一签名/通道任务并重置到新一轮状态,批次项目保留当次版本和快照,历史导出文件仍可追溯。 |
| TC-REPORT-BATCH-005 | 打开“待生成报备批次”,分别切换“待生成资料”和“已生成批次”,按时间范围和关键字查询并翻页。 | 两个页签均使用后端分页与时间查询;切换、重置筛选后查询条件正确,不读取上一页签的旧条件。 |
| TC-REPORT-BATCH-006 | 生成包含3条通道报备明细的批次,将其中2条任务人工改为通过后刷新已生成批次。 | 批次显示报备总数3、成功数2、成功率66.67%;不显示已导入回执或等待回执。 |
| TC-REPORT-BATCH-007 | 在桌面宽度分别查看待生成资料和已生成批次搜索区,再缩窄至平板宽度。 | 待生成资料关键字框宽度适中、资料变更时间有足够空间;已生成批次时间框保持紧凑;两个页签查询/重置按钮等宽,平板宽度自动换为两列且不横向溢出。 |
| TC-REPORT-TASK-STATUS-001 | 在报备明细页逐条修改签名或引流信息的通道状态,分别填写和不填写修改原因。 | 两种操作均成功;状态和时间轨迹写真实任务/记录,原因空时不阻断提交;页面无生成同范围任务和导入回执入口。 |
### 17.13 Gateway 提交异常与通道级 TPS 限速
@@ -3985,3 +3986,4 @@ npm run verify:phase8
| TC-ANALYTICS-SIGNATURE-010 | 关键字查询签名、企业或应用 | 后端返回匹配签名并保持总数和分页正确 |
| TC-ANALYTICS-SIGNATURE-011 | 点击“查看明细” | 打开右侧详情抽屉,展示运营商概览及通道×运营商矩阵;Esc、关闭按钮和遮罩均可关闭 |
| TC-ANALYTICS-SIGNATURE-012 | 所选日期没有已登记签名发送 | 返回真实空状态,不显示演示或历史日期数据 |
| TC-ANALYTICS-SIGNATURE-013 | 同一运营商的一条业务短信首通道失败后切换通道并最终送达 | 运营商概览计1条业务短信、最终成功率为100%;通道矩阵仍分别记录两次真实提交 |
+16
View File
@@ -2646,3 +2646,19 @@ git diff --check
- 部署后`cmpp-api``cmpp-gateway`、Nginx、PostgreSQL和MinIO均activeRedis`PONG``12026/17890/8090/3000/6379/5432/9000`均监听,API/Gateway health、公网首页、运营端、客户端和API health均HTTP 200,公网CMPP 17890 TCP可连接。Redis Stream消费者1、`pending=0``lag=0`,发布后API/Gateway无error级journal。
- 4条active供应商通道中3条恢复`connected 1/1``会员营销-富泷`保持`failed 0/1`,数据库明确记录`authentication / connect response status: auth failed`并进入既有自动重连窗口,该状态属于当前上游鉴权配置/外部连接事实,本次未擅自修改通道账号或状态。120秒内活跃下游客户连接为0。
- 本次未发送、重投或补发真实短信,未执行本地演示数据脚本,未修改通道配置或客户连接状态。发布运行代码为`99c8c7c68b5eb7cd0f63f0c70d376c257cc18c61`
## 2026-07-28 签名发送质量运营商概览业务短信口径(本地未提交)
- 数据统计“签名通道发送质量”的查看明细中,运营商概览不再累加通道提交尝试,改为按真实`SmsMessageRecord`业务短信统计;同一短信发生通道切换或补发仍只计一条。卡片数量文案由“XX次”改为“XX条业务短信”。
- 运营商概览的“成功率”改名为“最终成功率”,按该运营商最终送达成功的业务短信数除以业务短信总数计算;平均到达时间同步取最终成功业务短信的`deliveredAt - submittedAt`。下方“通道 × 运营商矩阵”继续保留真实`SmsSubmitRecord`提交尝试口径,不与业务短信口径混算。
- 后端`GET /admin/operations/signature-quality`新增独立运营商业务短信汇总,数据直接来自PostgreSQL,不使用前端去重、mock、静态数据或localStorage。需求文档和系统功能测试用例已同步补充补发去重及最终成功率规则。
- Node.js v24.14.0下Operations定向1 suite / 24 tests、API TypeScript正式构建、前端TypeScript检查和Vite生产构建通过;Vite仅保留既有大chunk提示。预发布PostgreSQL只读执行等价聚合SQL成功并返回真实运营商分组,验证业务短信数、最终成功数、最终成功率和平均到达时间字段可执行;本地5432未运行,因此未虚报本地数据库验证。
- 本轮按用户要求保持未提交、未推送、未部署;既有`api/tsconfig.build.tsbuildinfo``tsconfig.tsbuildinfo``outputs/`及空文件`=`继续保留且不纳入代码提交。
## 2026-07-28 待生成报备批次搜索区宽度统一(发布前门禁)
- “待生成资料”搜索区将资料类型收窄为160px,将“企业/应用/签名/站点”关键字控制在200~260px,并把剩余主要空间分配给“资料变更时间”;“已生成批次”将批次号控制在220~260px、批次生成时间固定为300px,避免日期条件无意义拉满。
- 新增全局`.ui-query-actions`查询操作区样式,查询和重置按钮统一为88px,不再由当前Grid隐式拉伸重置按钮。980px及以下两个页签统一切换为两列搜索布局,操作按钮独占下一行,避免横向溢出。
- 需求文档新增搜索区尺寸规则,系统功能测试新增`TC-REPORT-BATCH-007`覆盖桌面和平板布局;筛选查询、重置及真实后端分页逻辑未改动。
- 本次发布门禁覆盖此前未提交的“签名发送质量运营商概览业务短信口径”和本次搜索区调整:Node.js v24.14.0下API全量26 suites / 366 tests通过,Prisma format/validate/generate、API TypeScript正式构建、前端TypeScript/Vite生产构建、Gateway`go test ./...``go vet ./...`、依赖安全门禁和`git diff --check`通过;Vite仅保留既有大chunk提示,Jest保留既有`--forceExit`异步句柄提示。
- 应用内浏览器接管检查确认当前没有已登录运营会话,目标路由被真实鉴权守卫跳转至图形验证码登录页;未绕过验证码,因此发布前未把登录页冒充为两个页签的视觉验收。发布后仍需在有效运营会话下复核两页签桌面和平板宽度。
+10
View File
@@ -414,6 +414,15 @@ export type SignatureChannelCarrierQualityStat = {
averageArrivalMs?: number | null;
};
export type SignatureCarrierBusinessQualityStat = {
signatureId: string;
carrier: string;
businessMessageCount: number;
finalSuccessCount: number;
finalSuccessRate: number;
averageArrivalMs?: number | null;
};
export type SignatureChannelQualityItem = {
signatureId: string;
signatureName: string;
@@ -429,6 +438,7 @@ export type SignatureChannelQualityItem = {
successRate: number;
averageArrivalMs?: number | null;
channelSubmitTotal: number;
carrierOverview: SignatureCarrierBusinessQualityStat[];
breakdowns: SignatureChannelCarrierQualityStat[];
};
+11 -26
View File
@@ -290,7 +290,14 @@ function SignatureQualityDrawer({
item: SignatureChannelQualityItem;
onClose: () => void;
}) {
const carriers = aggregateByCarrier(item.breakdowns);
const carriers = item.carrierOverview.map((carrier) => ({
...carrier,
channelCount: new Set(
item.breakdowns
.filter((entry) => normalizeCarrier(entry.carrier) === normalizeCarrier(carrier.carrier))
.map((entry) => entry.channelId),
).size,
}));
const channels = [...new Map(item.breakdowns.map((entry) => [entry.channelId, entry.channelName])).entries()]
.map(([channelId, channelName]) => ({ channelId, channelName }));
const visibleCarriers = carrierOrder.filter((carrier) => item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier));
@@ -321,7 +328,7 @@ function SignatureQualityDrawer({
<div className="signature-quality-section__heading">
<div>
<h3></h3>
<p></p>
<p></p>
</div>
</div>
<div className="signature-carrier-grid">
@@ -329,10 +336,10 @@ function SignatureQualityDrawer({
<article className={`signature-carrier-card signature-carrier-card--${normalizeCarrier(carrier.carrier)}`} key={carrier.carrier}>
<div>
<Tag tone={carrierTagTone(carrier.carrier)}>{carrierLabel(carrier.carrier)}</Tag>
<strong>{carrier.total.toLocaleString('zh-CN')} </strong>
<strong>{carrier.businessMessageCount.toLocaleString('zh-CN')} </strong>
</div>
<dl>
<div><dt></dt><dd>{carrier.successRate.toFixed(1)}%</dd></div>
<div><dt></dt><dd>{carrier.finalSuccessRate.toFixed(1)}%</dd></div>
<div><dt></dt><dd>{formatDuration(carrier.averageArrivalMs)}</dd></div>
<div><dt></dt><dd>{carrier.channelCount} </dd></div>
</dl>
@@ -417,28 +424,6 @@ function QualityRate({ value }: { value: number }) {
);
}
function aggregateByCarrier(items: SignatureChannelCarrierQualityStat[]) {
return carrierOrder
.map((carrier) => {
const entries = items.filter((item) => normalizeCarrier(item.carrier) === carrier);
if (entries.length === 0) return null;
const acceptedCount = entries.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = entries.reduce((sum, item) => sum + item.successCount, 0);
const arrivalWeight = entries.reduce((sum, item) => (
sum + (item.averageArrivalMs == null ? 0 : item.averageArrivalMs * item.successCount)
), 0);
const arrivalCount = entries.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0);
return {
carrier,
total: entries.reduce((sum, item) => sum + item.total, 0),
successRate: acceptedCount === 0 ? 0 : successCount * 100 / acceptedCount,
averageArrivalMs: arrivalCount === 0 ? null : Math.round(arrivalWeight / arrivalCount),
channelCount: new Set(entries.map((item) => item.channelId)).size,
};
})
.filter((item): item is NonNullable<typeof item> => item !== null);
}
function normalizeCarrier(value: string) {
const normalized = value.toLowerCase();
if (['mobile', 'cmcc', '移动'].includes(normalized)) return 'mobile';
+24 -22
View File
@@ -204,31 +204,33 @@ export function AdminReportMaterialsPage() {
{ key: 'files', title: '报备文件', align: 'right', render: (batch) => <div className="table-actions">{batch.exportFiles.map((file) => file.fileObjectId ? <a href={fileDownloadUrl(file.fileObjectId)} key={file.id}><Download size={15} />{file.fileName}{file.rowCount}</a> : null)}</div> },
], []);
const filter = <div className="surface report-material-filter">
const filter = <div className={`surface report-material-filter report-material-filter--${activeTab}`}>
{activeTab === 'pending' ? <Select label="资料类型" onChange={(event) => { setReportType(event.target.value); setPendingPage(1); }} options={[{ label: '全部资料', value: 'all' }, { label: '签名资料', value: 'signature' }, { label: '引流信息', value: 'drainage' }]} value={reportType} /> : null}
<Input label={activeTab === 'pending' ? '企业/应用/签名/站点' : '报备批次号'} onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'pending' ? '搜索待生成资料' : '搜索报备批次号'} value={keyword} />
<DateRangeInput label={activeTab === 'pending' ? '资料变更时间' : '批次生成时间'} onChange={setDateRange} value={dateRange} />
<Button icon={<Search size={16} />} onClick={() => {
if (activeTab === 'pending') {
setPendingPage(1);
void loadPending(1);
} else {
setBatchPage(1);
void loadBatches(1);
}
}}></Button>
<Button onClick={() => {
setKeyword('');
setDateRange({});
if (activeTab === 'pending') {
setReportType('all');
setPendingPage(1);
void loadPending(1, { keyword: '', dateRange: {}, reportType: 'all' });
} else {
setBatchPage(1);
void loadBatches(1, { keyword: '', dateRange: {} });
}
}} variant="ghost"></Button>
<div className="ui-query-actions">
<Button icon={<Search size={16} />} onClick={() => {
if (activeTab === 'pending') {
setPendingPage(1);
void loadPending(1);
} else {
setBatchPage(1);
void loadBatches(1);
}
}}></Button>
<Button onClick={() => {
setKeyword('');
setDateRange({});
if (activeTab === 'pending') {
setReportType('all');
setPendingPage(1);
void loadPending(1, { keyword: '', dateRange: {}, reportType: 'all' });
} else {
setBatchPage(1);
void loadBatches(1, { keyword: '', dateRange: {} });
}
}} variant="ghost"></Button>
</div>
</div>;
return <section className="page-stack report-material-page">
+11
View File
@@ -522,6 +522,17 @@
color: var(--color-selected);
}
.ui-query-actions {
align-items: end;
display: flex;
gap: var(--space-3);
}
.ui-query-actions .ui-button {
flex: 0 0 88px;
width: 88px;
}
.ui-pagination {
align-items: center;
display: flex;
+17 -1
View File
@@ -7489,7 +7489,20 @@ h3 {
.report-import-profile .field { min-width: 320px; }
.report-import-preview pre { max-height: 240px; overflow: auto; padding: 12px; background: #111827; color: #d1fae5; border-radius: 8px; font-size: 11px; }
.report-material-filter { display: grid; grid-template-columns: 220px minmax(280px, 1fr) auto; gap: 14px; align-items: end; }
.report-material-filter {
align-items: end;
display: grid;
gap: 12px;
}
.report-material-filter--pending {
grid-template-columns: 160px minmax(200px, 260px) minmax(320px, 1fr) auto;
}
.report-material-filter--batches {
grid-template-columns: minmax(220px, 260px) 300px auto;
justify-content: start;
}
.report-material-pool { overflow: hidden; padding: 0; }
.report-material-table-head,
.report-material-row { display: grid; grid-template-columns: 28px minmax(260px, 1.5fr) minmax(180px, 1fr) 90px 160px; gap: 12px; align-items: center; padding: 12px 16px; }
@@ -7518,6 +7531,9 @@ h3 {
@media (max-width: 980px) {
.channel-field-mapping-grid, .report-import-basic-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.report-material-filter--pending,
.report-material-filter--batches { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.report-material-filter .ui-query-actions { grid-column: 1 / -1; }
.report-material-table-head, .report-material-row { grid-template-columns: 28px minmax(220px, 1.5fr) minmax(160px, 1fr); }
.report-material-table-head > :nth-child(n+4), .report-material-row > :nth-child(n+4) { display: none; }
}