fix: reconcile caller aggregates and strengthen evidence handling
This commit is contained in:
@@ -23,7 +23,7 @@ export function parseAnalyticsQuery(raw: Query, now = new Date()): AnalyticsQuer
|
||||
const from = raw.from ? new Date(str('from', 40)) : new Date(to.getTime() - number('minutes', 15, 1, 10080) * 60000);
|
||||
if (!Number.isFinite(+from) || !Number.isFinite(+to) || from >= to || +to - +from > 7 * 86400000 || +to > +now + 5000) throw new BadRequestException('Time range must be within 7 days and not in future');
|
||||
const sort = str('sort', 32) || 'totalCalls';
|
||||
if (!['caller', ...COUNT_KEYS, 'connectionRate', 'overallAnswerRate', 'connectedAnswerRate'].includes(sort)) throw new BadRequestException('Invalid sort');
|
||||
if (!['caller', ...COUNT_KEYS, 'notConnectedCalls', 'connectionRate', 'overallAnswerRate', 'connectedAnswerRate'].includes(sort)) throw new BadRequestException('Invalid sort');
|
||||
const order = str('order', 4) || 'desc'; if (!['asc', 'desc'].includes(order)) throw new BadRequestException('Invalid order');
|
||||
const result: AnalyticsQuery = {
|
||||
view: view as AnalyticsQuery['view'], from, to, customerId: str('customerId', 32), caller: str('caller', 64),
|
||||
|
||||
@@ -27,6 +27,7 @@ export function CallerAnalyticsPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const q = { ...filters, onlyAlerts: String(filters.onlyAlerts) };
|
||||
if (q.minutes !== 'custom') { delete q.from; delete q.to; }
|
||||
if (q.minutes === 'today') { const now = new Date(); q.from = new Date(now.toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' }) + 'T00:00:00+08:00').toISOString(); delete q.minutes; }
|
||||
if (q.minutes === 'custom') { q.from = new Date(q.from).toISOString(); q.to = new Date(q.to).toISOString(); delete q.minutes; }
|
||||
const result = await api.callerAnalytics(q, { signal: controller.signal });
|
||||
@@ -43,7 +44,7 @@ export function CallerAnalyticsPage() {
|
||||
.then(r => { setDetail(r); setDetailError(''); }).catch(e => { if (!c.signal.aborted) setDetailError(explainApiError(e)); });
|
||||
return () => c.abort();
|
||||
}, [target, data, filters, detailSkip]);
|
||||
const change = (key, value) => setDraft(d => ({ ...d, [key]: value, ...(key === 'view' && value === 'original' ? { vendorGatewayId: '' } : {}) }));
|
||||
const change = (key, value) => setDraft(d => ({ ...d, [key]: value, ...(key === 'view' && value === 'original' ? { vendorGatewayId: '' } : {}), ...(key === 'customerId' ? {customerGatewayId:''} : {}) }));
|
||||
const names = Object.fromEntries(options.customers.map(c => [c.id, c.name]));
|
||||
const gatewayNames = Object.fromEntries(options.vendorGateways.map(c => [c.id, c.name]));
|
||||
const select = (label, key, choices) => <Field label={label}><Select value={draft[key]} onChange={e => change(key, e.target.value)}><option value="">全部</option>{choices.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}</Select></Field>;
|
||||
@@ -75,7 +76,7 @@ export function CallerAnalyticsPage() {
|
||||
</form></Panel>
|
||||
<section className="metric-grid cra-metrics">{[...countColumns, ...rateColumns].map(c => <div key={c.key} className="metric-card"><span>{c.label}</span><strong>{metrics ? c.render ? c.render(metrics) : metrics[c.key] : '—'}</strong></div>)}</section>
|
||||
<p className="muted-text" role="status">{data ? `${formatDateTime(data.from)} — ${formatDateTime(data.to)} · ${data.qualityStatus === 'LIVE' ? '采集运行中' : '数据滞后或不完整'} · 待接通 ${metrics.pendingCalls} · 已结束未接通 ${metrics.failedCalls} · 未知 ${metrics.unknownCalls} · 更新于 ${formatDateTime(data.asOf)}` : '正在读取真实统计数据…'}</p>
|
||||
{data ? <Alert title="口径与数据范围" tone={data.qualityStatus === 'LIVE' ? 'info' : 'warning'}>{data.historyNotice} {data.summaryScope}。183 不代表实际振铃;未接通数含待接通,进行中指标为暂定值。</Alert> : null}
|
||||
{data ? <Alert title="口径与数据范围" tone={data.qualityStatus === 'LIVE' ? 'info' : 'warning'}>{data.historyNotice} {data.summaryScope}。183 不代表实际振铃;未接通数含待接通,进行中指标为暂定值。采集运行状态不代表窗口数据完整性已核验,端到端延迟尚未测定。</Alert> : null}
|
||||
<Panel title="分钟趋势" aside={<span className="muted-text">灰:呼叫 / 蓝:接通 / 绿:应答</span>}>
|
||||
<div className="cra-chart" aria-label="分钟呼叫接通应答趋势">{timeline.map((r,i) => <div key={r.time ?? i} className="cra-chart-column" title={`${formatDateTime(Number(r.time))} 呼叫${r.totalCalls} 接通${r.connectedCalls} 应答${r.answeredCalls}`}><div className="cra-bars"><span style={{height:`${r.totalCalls/max*100}%`}} /><span style={{height:`${r.connectedCalls/max*100}%`}} /><span style={{height:`${r.answeredCalls/max*100}%`}} /></div><small>{new Date(Number(r.time)).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}</small></div>)}</div>
|
||||
{!timeline.length ? <p className="muted-text">当前窗口暂无已采集呼叫</p> : <details><summary>查看趋势数值与三个率</summary><SimpleTable rows={timeline.map((r,i) => ({ ...r, id:i, minute:formatDateTime(Number(r.time)) }))} columns={[{key:'minute',label:'分钟'},...countColumns,...rateColumns]} /></details>}
|
||||
|
||||
@@ -54,7 +54,7 @@ async function entries(entries: Array<[string, string[]]>) {
|
||||
const event = JSON.parse(fields[1]) as AnalyticsEvent;
|
||||
await store.consume(event);
|
||||
await redis.xack(ANALYTICS_STREAM, ANALYTICS_GROUP, id);
|
||||
dataThrough = new Date(event.at).toISOString();
|
||||
if (!dataThrough || event.at > Date.parse(dataThrough)) dataThrough = new Date(event.at).toISOString();
|
||||
}
|
||||
}
|
||||
async function observeActive() {
|
||||
@@ -92,7 +92,7 @@ async function main() {
|
||||
if (Date.now() - lastAlerts > 15000 && !degraded) { await evaluateCallerAlerts(db); lastAlerts = Date.now(); }
|
||||
} catch (e) { degraded = true; reason = e instanceof Error ? e.message.slice(0, 200) : 'Worker error'; logger.error({ reason }, 'Analytics cycle failed'); }
|
||||
try {
|
||||
const payload = JSON.stringify({ degraded, reason, dataThrough, lagMs: degraded ? null : 0, captureSince: process.env.ANALYTICS_CAPTURE_SINCE ?? null, spoolOffset: cursor.offset, gap, updatedAt: new Date().toISOString() });
|
||||
const payload = JSON.stringify({ degraded, reason, dataThrough, lagMs: null, coverageStatus: 'OBSERVATIONS_ONLY', captureSince: process.env.ANALYTICS_CAPTURE_SINCE ?? null, spoolOffset: cursor.offset, gap, updatedAt: new Date().toISOString() });
|
||||
await db.$executeRaw(Prisma.sql`INSERT INTO caller_analysis_health(id,payload,updated_at) VALUES ('worker',${payload},NOW(3)) ON DUPLICATE KEY UPDATE payload=VALUES(payload),updated_at=VALUES(updated_at)`);
|
||||
} catch { logger.error('Analytics heartbeat write failed'); }
|
||||
await new Promise(resolve => setTimeout(resolve, 700));
|
||||
|
||||
Reference in New Issue
Block a user