# 主叫号码实时分析设计核对与整改方案 日期:2026-08-31 审查版本:V1.0 被审文档:[主叫号码接通率与应答率实时分析设计方案 V1.1](CALLER_REALTIME_ANALYTICS_DESIGN.md) 初始源码基线:`116e316eca5b564f33ec3558dbd00201da173ed8`;收尾补核对提交 `d78616561ead24e54734c8dfc9b34178bfd24ab1` 及当时可见的并行未提交差异。 结论:业务指标定义基本成立,可保留;实现已有基础,但证据可信度、乱序校正、采集完整性、运维隔离和验收闭环仍需整改,暂不具备认定“完整实时分析已验收”的依据。 ## 1. 核查范围与结论边界 本次核对设计内部一致性,以及设计与本地 OpenSIPS 采集脚本、分析 Worker、状态计算、MySQL 聚合、API、React 页面、迁移、发布脚本和测试脚本的对应关系。补充查阅 SIP、OpenSIPS、Redis 官方资料,并运行现有专项单元测试和不连接外部服务的内存复现。 本次没有连接 B/T 或生产服务,没有查询线上数据库,没有执行迁移、发布、服务重启、真实呼叫或计费操作;不把历史实施记录当成本轮现场验证。原文第12节记录的 B 现场事实仍属历史记录。本地 `infra/server-a/s28/opensips.cfg` 是采集脚本依赖的路由布局参照,不等于当前 B 生效配置。 核查开始时已有以下其他任务文件,本次不改动、不纳入交付:`docs/CODE_QUALITY_REVIEW_20260831.md`、`tests/reports/code-quality-20260831-dependency-audit.json`、`tests/reports/code-quality-20260831-repro.mjs`。 **并行更新说明:**核查期间另一个任务提交了 `d786165`,并继续修改存储/真实测试及新增 `scripts/rebuild-caller-analytics.mjs`。本文已补核对:gap写入游标文件、部分回滚恢复、日志匹配与趋势时间序列化已有修复代码;分钟桶JSON键修复、SQL对账与重复消费测试为收尾时可见的候选变化。下文注明相应进展,不将其全部视为未开发,也不代替另一任务宣称已部署/验收。本轮不覆盖或提交这些变化。 证据分级: | 标记 | 含义 | 本文如何使用 | | --- | --- | --- | | 已复现 | 本轮直接执行本地纯计算,得到输出 | 确认状态计算缺陷,不等于线上事故 | | 源码确认 | 当前文件可直接确认行为或缺少必要路径 | 能确认实现差距,发生概率、线上影响仍需联调 | | 待现场验证 | 涉及生效配置、SIP 状态、资源与部署结果 | 作为实施前置门槛,不写成已发生事实 | 整改分为13组,其中10组P1、3组P2。P1指完整功能验收或正式启用前应关闭的问题;P2指应补齐的契约、展示和容量工程项。未凭本地审查认定已有P0线上事故。 ## 2. 应保留的设计,以及需要澄清的约束 以下内容不应在整改时改回旧口径: 1. 接通 C 以匹配的初始 INVITE 下游180/183、直接有效2xx或可信正时长证据判定;183不代表真实振铃,C不等于真人接听。 2. 应答 A 以可信业务通话时长严格大于0判定;200本身、answeredAt、计费取整秒数均不能替代。不能为整改方便把A重新定义成2xx数量,也不额外要求真人检测或RTP分析。 3. 原始主叫按业务呼叫去重,落地主叫按实际出局尝试计数;不同客户同号不能合并归属。 4. 保留 `N=F+P`、`T=C+F+P+U`、`0≤A≤C≤T`,以及三个率 `C/T`、`A/T`、`A/C`;分母0返回null。 5. 后续进展回写发起时间桶;最终失败不撤销已有接通证据;历史缺少过程时不能用最终486/487倒推F。 6. 统计不改变计费、余额、路由,不自动停号换路。保留独立消费者、数据库事务去重及提交后ACK。 原文关于100/180/183及200与请求方法相关的说明与 [RFC 3261 §21.1–21.2](https://www.rfc-editor.org/rfc/rfc3261.html#section-21.1) 一致;“180/183算业务接通”仍是本平台产品定义,不是协议规定的接听率。 需新增两个独立概念: - **呼叫分类未知 U**:已知存在的呼叫中,不能确认是否达到C的数量;继续参与上述守恒式。 - **数据覆盖缺口 / 应答证据不完整**:可能整个呼叫都未采到,或C已知但A未知。不能硬塞进U,也不能因U=0就认为所有指标完整。前者连T的真实值都可能未知,必须单独标注窗口覆盖状态。 原文“对账后P=U=0”只能作为完整采集且可修复的验收目标;不可修复的历史缺口不能为了闭环强行归零。 ## 3. 分级问题与整改要求 ### R01|P1|“成功响应至结束的时间差”尚未充分证明实际通话时长 **对应原文:**第3.4、6、12节。**证据:源码确认,纯计算复现;协议边界待真实验证。** - `packages/database/src/caller-analytics-model.ts:79–84` 在没有显式talkMs时,用结束时间减acceptedAt生成最终时长。 - `apps/worker-cdr/src/analytics-main.ts:60–71` 只按Call-ID匹配MI,状态3或4均可生成暂定正时长。 - `scripts/instrument-caller-analytics.mjs:31–38` 将挂断、超时和事务失败统一标为 `verified-dialog-final`,该字符串本身并不能证明会话时长已核验。 - `tests/api/caller-analytics-sip.py` 的INVITE/响应均为 `Content-Length: 0`,未提供SDP offer/answer。这些场景能验证SIP响应和对话流程,但不足以独立证明实际业务会话的计时口径。 OpenSIPS 3.4官方文档区分状态3(已收到最终响应,尚未收到ACK)与4(已收到ACK);对话lifetime由对话确认起计算。不能只凭字段名或状态范围声明为“实际通话时长”。参见 [OpenSIPS Dialog状态与lifetime](https://docs.opensips.org/manual/3-4/modules/dialog/#exported-pseudo-variables)。实际部署版本仍需现场核对。 **整改:**建立经过验证的时长来源适配层,分别保存 `inviteAcceptedAt`、`talkStartedAt`、`talkEndedAt`、`talkDurationMs`、`durationEvidenceId`、`durationStatus` 和证据版本。ACK、协商及对话状态用于核验来源,不改动最终“可信正时长>0”的业务条件。信令观察差值在核验完成前只能作为观察值,不能自动升级为权威最终时长。无ACK、协商失败、取消竞态、对话超时须逐类定义时长有效性;不确定时保留C、标记A证据待核实。使用一致时钟域;秒/微秒读取应为同一次时间采样,并检测时钟跳变,而非仅用Math.max掩盖负时长。 **关闭标准:**CRA-005/006/007/013/014及新增“200无ACK”“协商失败”“早期媒体”“零时长终态”真实验证;证明正常500ms计A、可信零时长不计A。必须保留会话开始/结束证据及来源解释。 ### R02|P1|结束事件错误地清除未知,采集丢失可能被统计成失败 **对应原文:**第3.3、4、7节。**证据:已复现。** `caller-analytics-model.ts:68–78` 初始可把缺过程的leg设为unknown,但遇到 `verified-dialog-final` 或 `platform-final` 的END就清零。当前END记录不携带完整的历史阶段快照,不能证明之前没有180/183。 本轮输入只有 `END(487, verified-dialog-final)`,得到 `T=1,C=0,F=1,U=0`;输入 `ATTEMPT→UNKNOWN→END` 也得到F=1。对缺少过程证据的下游呼叫,预期应保留U,除非补回过程或有可信完整终态快照。 另外,`legCounts()` 的unknown受 `!connected` 限制:一旦C成立,A时长丢失不会通过U反映;这不是修改守恒式的理由,而是需要独立的时长完整性字段。 **整改:**分离 `ended`、`connectionEvidenceComplete`、`durationEvidenceComplete`、`captureGapIds`。END只证明结束,不能默认证明全过程完整。平台本地明确拒绝可通过独立的“未出局终态证据”判F;下游失败只有在阶段覆盖完整时判F。终态快照应包含已观测180/183/2xx标志、证据ID及序列覆盖,不伪造时间。 **关闭标准:**CRA-019/020/023/024;有缺口的487保留U,补回180后只转C;已接通但时长证据缺失时,A相关比率按不完整口径展示且不触发业务告警。 ### R03|P1|最终校正与乱序合并不满足到达顺序无关 **对应原文:**第3.4、4、6节。**证据:已复现。** `caller-analytics-model.ts:86–89` 同时以“最终/暂定”和event.at决定更新。MI先取得在线快照、结束事件晚到时,可能已产生晚于真实结束时刻的暂定观察。最终零时长事件若event.at较早,会被忽略。 同一组事实的本轮输出: | 输入到达顺序(t为统一起点) | 当前结果 | 应整改为 | | --- | --- | --- | | ACCEPTED(t+1s) → DURATION(t+4s,3000ms) → RECONCILE(t+3s,0ms) | A=1,talkMs=3000 | A=0,talkMs=0 | | ACCEPTED(t+1s) → RECONCILE(t+3s,0ms) → DURATION(t+4s,3000ms) | A=0,talkMs=0 | 与上一行相同 | | ATTEMPT → UNKNOWN → END(487) | F=1,U=0 | 缺口尚未修复时U=1 | | ATTEMPT → END(487) → UNKNOWN | F=0,U=1 | 与上一行相同 | **整改:**将业务事件时间、接收时间、证据权威等级、校正版本分开。已核验最终时长优先于暂定时长;最终之间按来源修订版本选择,不能用消息到达顺序决定。未知缺口只能被对应补偿证据关闭。保留纯函数状态归约,但使同一事实集合的排列、重复、重领结果一致;更高版本纠错通过现有同事务贡献差值回写。 **关闭标准:**新增排列测试、显式零时长后迟到END/ACCEPTED测试;真实MySQL重放后events/state/buckets一致,A回退不产生负数,C按其独立有效证据保留。 **聚合层补充:**初始基线 `caller-analytics-store.ts:46` 将JSON路径 `$.totalCalls` 等作为JSON_OBJECT的键传入,而读取使用的是普通键totalCalls等。并行候选改动已把写入键修正为普通键,并新增从states重建分钟桶的维护脚本。这属于“修复代码已出现、历史聚合修复与真实验证待确认”,不能只发布新写入逻辑而留下旧桶。须核对受影响桶、states与events是否可信,备份后按第5节重建/差异核对;本轮没有执行该维护脚本。 ### R04|P1|运行心跳被当作数据完整性,lagMs=0缺少测量依据 **对应原文:**第4、5、6、10节。**证据:源码确认。** - 初始基线的gap仅在内存;并行提交 `d786165` 已将gap随游标保存并在启动恢复,原“重启必丢标志”问题已有修复代码。仍只有全局布尔值,没有受影响节点/时间范围与修复状态;保存失败、无完整新日志行、旧游标缺字段等边界仍需测试。 - `analytics-main.ts:57` 将最后处理的event.at作为dataThrough,乱序可倒退,且不代表此前无缺口。 - `analytics-main.ts:89–95` 只看消费组pending/lag,正常时直接返回 `lagMs: 0`;未计入未读日志、rsyslog队列或生产端停采。 - `caller-analytics.service.ts:88–90` 以Worker心跳新鲜且未degraded标LIVE。即使OpenSIPS日志钩子失效,只要日志可读、MI可用且队列空,仍可能显示LIVE。 **整改:**增加生产端序列/心跳、日志未读字节、Redis最旧未处理年龄、消费速率、DB提交水位及持久化gap表。按节点维护“连续处理到哪个生产序列”,再合成查询窗口覆盖状态。没有可靠延迟证据时lagMs返回null;空闲与停采必须可区分。恢复进程只改变运行状态,不能自动修复历史缺口。 **关闭标准:**停采但不停Worker、积压日志但Stream暂空、轮转后重启、迟到旧事件四类场景均不误标完整;质量提示可按时间窗口查询,缺口修复有审计记录。 ### R05|P1|留存、持久化与资源隔离没有形成闭环 **对应原文:**第6、9节。**证据:源码确认;线上参数待核验。** - `analytics-main.ts:45–49` 在XADD返回后推进本机游标,但这不等于Redis数据已经落盘。本地Redis基线 `infra/server-b/s04/redis/redis-lisglosips.conf:8–18` 为AOF everysec、1536MB、noeviction,实际生效值本轮未核实。everysec存在故障丢失最近约一秒写入的边界,见 [Redis持久化文档](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/)。 - 分析代码中未见Stream裁剪、专属日志轮转方案、事件/状态/桶/死信清理任务;rsyslog的256MB队列上限不是日志文件、Stream或MySQL表的保留期。 - `infra/server-b/caller-analytics/deploy.sh:64` 从cdr-worker环境复制REDIS_URL。独立Stream/消费组不能隔离同一Redis实例内存耗尽的影响。 - `analytics-main.ts:52–55,84` 消费时直接JSON.parse并断言类型;缺少消费端schema校验和坏事件重试上限,也未处理XAUTOCLAIM可能返回的已删除pending ID列表。 Redis XACK移除的是消费组待确认记录,不能代替Stream数据删除。参见 [Redis XACK](https://redis.io/docs/latest/commands/xack/) 和 [Streams恢复说明](https://redis.io/docs/latest/develop/use-cases/streaming/)。 **整改:**建立已持久化消费水位与日志可重放区间,Redis恢复后识别回退并从保留日志补送。轮转按inode读取完旧文件后切换,游标含文件身份、序列、校验与持久化状态。为日志、Stream、events、states、buckets、deadletter分别定义容量、保留期、清理安全线和溢出行为;清理不得早于允许迟到/重放/去重窗口。超界必须登记缺口,不能静默丢弃。消费端校验输入,坏事件隔离并告警,正常事件继续推进。 资源方面先测共享Redis和MySQL余量;若无法证明统计流在故障与峰值下不影响呼叫/计费,则使用独立Redis实例或等效强隔离。仅换Redis DB编号不构成内存隔离。日志、死信含号码与Call-ID,须同步限定文件权限、访问范围、导出审计和脱敏保留规则。 **关闭标准:**断电/进程崩溃、XADD后游标提交、日志轮转、毒事件、满盘/满队列、重领/清理竞态均有真实恢复记录;数据可恢复或缺口可见,原呼叫与计费服务不受统计积压拖垮。 ### R06|P1|呼叫/尝试生命周期及真实出局边界不完整 **对应原文:**第3.1、4、6、12节。**证据:源码确认,重试间隙已纯计算复现;真实重试尚未实施。** - 当前ATTEMPT在 `t_relay()` 前发出(`instrument-caller-analytics.mjs:21`、路由基线 `opensips.cfg:307–315`)。若本地转发失败且未实际发包,仍可能计入“实际发出的线路尝试”。 - `projectAnalytics():109–115` 把“当前所有leg结束”视为原始呼叫结束,没有业务呼叫关闭/不再重试事实。第一条失败、第二条尚未生成的间隙,原始视角提前判F。 - 路由暂为单attempt=1,这是第12节已经承认的范围限制;现有多attempt单测证明能聚合两条记录,不能证明整个重试生命周期已支持。 - `observeActive()` 对MI中消失的呼叫没有UNKNOWN或对账处理;只有START或丢失END的记录可长期停在active/P。 **整改:**补充 `CALL_CLOSED/retryExhausted`、`ATTEMPT_DISPATCHED`、本地发送失败及终态对账契约。线路分母用可核验的实际派发事实;准备路由和尝试派发分开。原始呼叫只有被权威关闭且全部尝试判定完成时才判F。MI消失只表示需核对,不直接判业务超时;找不到终态依据转数据未知,并保留差异清单。 **关闭标准:**CRA-015/017/020;本地relay失败原始T=1、落地T按是否真实发送判断;重试间隙不提前判F;丢失END后不永久伪装处理中。真实重试启用前必须完成对应采集验收,不扩大当前路由功能范围。 ### R07|P2|身份、版本、号码标准化与对账字段缺少可执行契约 **对应原文:**第3.1、6、7节。**证据:源码确认。** 事件接口没有显式nodeId、bootId、事务方法/CSeq/branch、receivedAt、schemaVersion;日志解码时固定version=1、sequence=0。callUid由Call-ID、From-tag及OpenSIPS启动时间组合后哈希,并不是只用Call-ID,但仍缺少显式节点与客户隔离及事务重传/鉴权续发规则。不能因使用哈希就认定全球唯一。 状态和桶没有metricVersion/analysisRevision列,桶hash也不包含版本。号码直接使用caller/landingCaller,没有原值、规范值和规则版本的明确分离。`RECONCILE`虽在类型中存在,当前采集/Worker未见实际最终话单对账生产者;详情让用户按Call-ID查找,尚无稳定话单关联键。 **整改:**定义事件JSON Schema与日志映射,增加生产身份/事务/阶段/时间字段及规范化规则。保存原号码、用于分组的规范号码和规则版本;空维度采用文档化非空哨兵。增加事实修订与聚合版本,定义callUid/attemptId到原始话单的显式关联;来源无法核实时保留“未关联”,禁止Call-ID单字段猜配。旧固定6秒CDR继续禁止用于时长校准。 **关闭标准:**同号不同客户、跨节点相同Call-ID、重传、鉴权续发、主叫改写、升级前后重放均不串数;版本升级能并行核对和回退。 ### R08|P1|历史无覆盖与空窗口未区分,详情也未保持同一快照 **对应原文:**第3.5、4、5、7节及第12节补充。**证据:源码确认。** - `overview()` 仅返回通用historyNotice;不按captureSince和缺口检查查询窗口。新采集前的窗口可返回T=0和LIVE,不能据此区分“没有呼叫”和“没有采集覆盖”。 - overview使用一次数据库事务读取列表和趋势,这是已有正确基础;但asOf在查询后生成,详情另开查询,其rows与breakdown也不在同一快照。使用相同from/to只能保证发起窗口相同,不能冻结后续状态变更。 - `detail()` 的分布直接 `LIMIT 500`,无ORDER BY、total或truncated字段,超过500组合会静默截断。 - 列表后的客户、号码排序没有完整稳定的唯一兜底键,同号跨客户且指标相同时,分页稳定性需要补足。 **整改:**增加按查询窗口计算的coverage及各指标可用性;无覆盖返回null/NO_COVERAGE,不把未知历史展示成0。建议同一响应内生成总览与选中号码详情,或使用服务端短期snapshotId缓存聚合结果;明确“发起时间窗口”和“状态截至快照”的区别。详情查询也使用一致事务。分布改为稳定分页,或返回total/truncated并禁止将截断数据解释为完整总量。 第12节关于“卡片/趋势使用业务筛选,样本/比例/仅告警只筛号码列表”的调整合理,页面已有提示,可保留;但须把baseFilters与numberFilters写进正式API契约,避免与第3.5节混淆。 **关闭标准:**CRA-017/018/019/024;采集生效前、跨覆盖边界、非整分钟窗口、跨日、并发状态更新及>500组合均能正确解释并对账。 ### R09|P1|告警规则混合持续计数,且告警范围与查询范围不一致 **对应原文:**第8、12节。**证据:源码确认。** - `analytics-alerts.ts:26–31` 每个“客户+号码+视角”只有一个streak;“低接通率→低总体应答率→低接通率”会累计成三次,即使没有同一规则连续满足。 - 告警仅按号码全线路、最近15分钟计算;API按客户+号码附加ACTIVE告警,没有检查所选历史窗口、落地网关、地区等范围。筛选某条正常线路仍可能显示另一条线路造成的号码级告警。 - Worker在degraded时连评估与过期处理一起跳过,旧ACTIVE可持续保留;API未按更新时间或查询窗口过滤。健康恢复前不能将旧快照继续解释为当前业务异常。 - 当前实现有连续三次触发/恢复,但没有独立规则状态、明确冷却期、恢复阈值迟滞;同一窗口可每15秒重复评估,是否算独立持续证据未定义。 **整改:**按ruleId、客户、号码、视角、规则作用域和规则版本维护状态,分别记录触发计数、恢复计数、冷却、触发/恢复阈值与评估快照。采集异常时转SUSPENDED/STALE,保留历史原因但不继续当有效业务告警。若第一期只支持全线路15分钟号码级告警,应明确标注该作用域;历史或线路筛选下不冒充当前筛选结果的异常。 **关闭标准:**交替命中规则不误累计;同规则连续达到条件才触发;健康异常时暂停业务判断;查询历史/单线路时告警作用域清楚;仍不发送外部通知或自动处置号码。 ### R10|P2|页面与API有确定的契约差异,详情功能不完整 **对应原文:**第5、6节。**证据:源码确认;本轮未做浏览器渲染验收。** - 页面用countColumns生成排序项,其中包含 `notConnectedCalls`(`CallerAnalyticsPage.jsx:13,65`),API白名单没有该字段(`caller-analytics.service.ts:26`),选择该排序会被400拒绝。 - 页面未展示API已有的metricVersion、lagMs、dataThrough、captureSince;仅展示通用质量状态和asOf。 - 供应商筛选在API有vendorId,页面没有;详情缺少选中号码的独立分钟趋势、180/183/直接成功统计分布、未接通原因聚合、接通未应答最终原因聚合和稳定CDR详情链接。 - 详情用单一 `finalCode` 显示“最终结果”,采集挂断钩子填200、超时填408,可能把对话结束原因与初始INVITE最终响应混为一谈。 - 页面详情遇到“仅可信正时长RECONCILE补C、无180/183/2xx时间”时仍显示“未达接通”,与C/A计数不符。 - 自动刷新为请求完成后再等4000ms,与原文“每5秒刷新”不同;失败保留旧数据、筛选变化取消旧请求的逻辑可保留。 **整改:**共享字段/排序契约,支持N排序或去掉无效选项;补齐版本、覆盖时间、延迟未知说明。详情分离 `initialInviteFinalCode`、`terminationReason`、`localFailureCode`,显示真实connectedEvidence。补齐承诺功能;若分期延后,必须列出未交付项,不能写“页面已完成”概括替代。今日边界建议由服务端计算,统一Asia/Shanghai展示;自定义输入标明时区。保留宽表、业务数字直显、筛选和详情刷新语义。 **关闭标准:**逐一操作所有排序、筛选、分页、详情与失败重试;采集缺口和CDR补计显示一致;网络面板、真实API/DB与截图留证,静态页面不算验收。 ### R11|P2|实时SLO、查询成本和容量限制需要实测拆解 **对应原文:**第6、9、10、12节。**证据:源码确认,容量待实测。** `analytics-main.ts` 将日志采集、串行消费、全量MI查询、活跃呼叫观察、告警循环放在同一循环;`observeActive()` 固定LIMIT 2000且无遍历游标,超过范围可能长期漏观察尾部呼叫。时长大于0后不再更新,进行中的talkMs只是首次正时长观察值,不是持续实时增长时长。 API overview每次读取最多10001号码后在应用内过滤排序,summary/numbers/trends均复用完整overview;10000上限限制结果数量,不等于限制数据库扫描成本。7天分钟趋势可达到10080点,需验证前端渲染成本。告警每号码还执行多次查询。状态更新会对已有多个投影反复撤销/添加桶贡献,吞吐不能仅按事件数量估计。 **整改:**先记录峰值CPS、事件/呼叫数、号码与维度基数、平均通话时长、迟到分布、保留期和资源余量,再确定索引与任务频率。将采集、消费、MI观察和告警按职责解耦或设独立预算;活跃观察游标遍历并暴露遗漏量。API用实际SQL执行计划核验,再决定数据库分页/汇总、短缓存和趋势降采样,缓存键必须含授权范围、筛选及快照/版本。 容量估算统一使用:`事件写入率≈CPS×每呼叫事件数`;留存空间使用实测平均事件字节、索引及复制/备份开销。消费恢复能力必须大于持续新增速率,不能只测空队列瞬时吞吐。 **关闭标准:**保留原目标“约定负载下证据可用至页面可见P95≤5秒”,分解采集、消费、查询、轮询等待与渲染延迟;本轮不给机器容量承诺。若轮询无法满足整体目标,调整轮询或推送,不能把SLO起点偷换为API返回时刻。记录原呼叫建立、计费延迟与资源回归。 ### R12|P1|发布脚本的完整回滚与再次发布行为需要补齐 **对应原文:**第9、12节。**证据:源码确认,未执行部署。** `infra/server-b/caller-analytics/deploy.sh` 已有备份、候选配置校验、空闲通话检查、健康检查和加法迁移保留,这些应保留。但仍存在: 1. 已含CRA1标记时直接沿用旧采集配置(41–46行附近),后续采集器修复不会自动进入生效配置;须有明确instrumentationVersion和差异审核流程。 2. 初始rollback未恢复rsyslog配置/env及旧分析进程;并行提交 `d786165` 已补恢复配置、重启rsyslog及按原active状态拉起分析服务。仍需恢复原enable/disable状态、验证每个失败点,不能仅凭代码补齐就认定已演练通过。 3. 部分配置修改发生在changed=1之前,之后验证失败可能不进入完整回滚;只能证明主指针回退,不能证明所有步骤原子恢复。 4. 开始时一次“当前无呼叫”检查与实际重启之间存在新呼叫进入窗口;这不是持续排空保证。 5. 脚本末尾只检查服务active/API ready/commit,未验证分析权限、心跳、采集覆盖、端到端数据和持久化。不能把这些检查当完整功能验收。 **整改:**使用可重复执行的版本化采集配置生成;逐步登记变更与回滚动作,包括原配置存在性、服务enable/active状态、rsyslog重载、入口开关和DB版本。发布前后分别检查,并采用维护窗口/受控排空防止中途新呼叫。回滚默认保留分析事实与新表;恢复应用/配置/消费者组合,不回滚或删除计费账务。增加分析端到端冒烟门槛并验证服务自动启动关系。 **关闭标准:**在测试环境分别注入迁移后、rsyslog校验后、OpenSIPS安装后、API切换后、分析服务启动后的失败;每个步骤均能恢复原服务组合,保留证据且不影响既有账务。 ### R13|P1|“已实施”与“已验收”记录没有闭合 **对应原文:**文首、第2、10、11、12节;主设计与实施状态索引。**证据:源码/本地报告目录确认。** 原文用实施更新和第12节解释前文历史状态,但文首仍写全部待实施,第11节仍写仅文档交付;主设计V2第1.1节仍写实现待开始。`IMPLEMENTATION_STATUS.md` 顶部写测试发布验证中,测试计划仍注明CRA-001至024全部待执行。本轮在 `tests/reports` 未检索到专项真实验收报告;这只能说明本地未归档,不能断言远端从未执行。 初始 `caller-analytics-real.mjs` 主要断言六个本机SIP场景的聚合值、详情、401和非法参数400,“real DB states”主要来自API详情。收尾时并行候选代码已增加独立states SQL求和与API汇总核对、事件重复消费保持不变的断言;本轮只检查代码,未执行这些新增步骤,也未看到其真实报告。普通view客户越权、采集丢失、计费不重复、P95、真实最终CDR校正、故障恢复与发布回滚仍无完整证据;SIP脚本未带SDP。因此不可把“六场景通过”写成24项全通过或媒体/计费全链路验收。 **整改:**原文修订为设计文档V1.2,保留V1.1业务口径,增加明确“设计/本地实现/测试部署/真实验收/正式启用”状态表;原第11节移到历史记录。建立需求—实现—CRA用例—报告路径—提交/环境对应台账;每项标记通过、失败、未执行、范围外,不以测试文件存在代替执行。若真实证据在远端,核验后脱敏归档。 **关闭标准:**24项原CRA逐条有对应结果;新增边界有证据;发布报告包含commit、配置hash、迁移版本、环境、执行时间、回滚点及未通过项。没有证据的项继续标未验收。 ## 4. 推荐目标契约与实施顺序 ### 4.1 最小充分整改范围 保留现有“OpenSIPS观察 → 本机留存 → 独立Stream → 分析Worker → MySQL事实/状态/分钟桶 → API/页面”架构。先修事实与状态,不以重写整个平台、引入新消息中间件或修改计费作为前提。 最小需要触及:采集脚本与版本、分析Worker/模型/存储、加法数据库迁移、API/页面契约、专项测试、部署/回滚脚本与文档。是否增加独立Redis实例由R05容量及故障隔离验证决定,作为需提前报告的范围扩展。 固定6秒旧CDR属于独立计费/话单问题。本轮只规定禁止其作为统计权威时长;不自动重算历史费用、补扣费或变更账务。后续若整改该问题,须另列范围、影响评估与验收。 ### 4.2 建议新增/明确的数据字段 下表为整改目标,不表示当前已存在。物理表拆分由实施时评审,优先加法兼容。 | 对象 | 最小补充内容 | 作用 | | --- | --- | --- | | 事件事实 | schemaVersion、nodeId、bootId、producerSequence、occurredAt、receivedAt、初始事务身份、dispatch结果、source/evidenceId | 稳定关联、去重、追踪顺序与缺口 | | 业务呼叫 | callUid、客户快照、businessStartedAt、closedAt、retryExhausted、callCloseEvidence | 原始视角的分母与结束条件 | | 线路尝试 | attemptId、attemptStartedAt、dispatchedAt、first180At/183At、initialInviteFinalCode、terminationReason | 区分准备、发送、进展、最终响应与对话结束 | | 时长证据 | talkStartedAt、talkEndedAt、talkDurationMs、durationStatus、sourceRevision、correctedAt | 暂定/最终/无效/未知及乱序校正 | | 完整性 | 节点、起止序列/时间、gapId、影响范围、恢复/未恢复状态、处理水位 | 不以Worker活跃冒充数据完整 | | 状态/分钟桶 | metricVersion、analysisRevision、numberNormalizationVersion、coverage信息 | 版本隔离、可回算与可回退 | | 关联/告警 | 稳定CDR关联键;ruleId、scopeHash、ruleVersion、snapshotId、冷却/恢复状态 | 可追溯且不会混用范围 | 保留 `metricVersion=caller-analytics-v1.1` 表示业务公式未改变;增加analysisRevision表示算法/证据修复批次。若未来真正改变指标定义,才升级metricVersion。文档版本与业务口径版本不能混为一谈。 API建议在现有字段之外明确:`snapshotId`、`coverageStatus`、`captureFrom`、`gapIntervals`、`metricAvailability`、`provisionalAnsweredCalls`、`durationUnknownCalls`、`baseFilters`、`numberFilters`及分页完整性。`asOf`标数据库状态快照时间,`dataThrough`标连续覆盖水位,`lagMs`为测量值或null。 ### 4.3 工作包与依赖 | 工作包 | 优先级/涉及项 | 产物 | 前置依赖与完成门槛 | | --- | --- | --- | --- | | W0 基线冻结 | P1,R13 | V1.2文档、需求/用例矩阵、实际部署只读核对表 | 确定生效配置、版本、授权测试范围,不实施业务变更 | | W1 证据与状态 | P1,R01/R02/R03/R06;带R07字段 | 采集契约、确定性归约、真实会话时长验证 | 无法核验时长来源时,A不能宣称完整;单元边界先通过 | | W2 完整性与可靠性 | P1,R04/R05 | gap、水位、补送/死信/留存、资源隔离 | 必须完成真实Redis/MySQL恢复与计费隔离验证 | | W3 API/页面/告警 | P1/P2,R08/R09/R10/R11 | 快照、覆盖、告警状态机、契约对齐、性能报告 | 依赖W1/W2可信事实;页面不能用模拟数据验收 | | W4 迁移/发布/验收 | P1,R12/R13 | 灰度发布、故障回滚记录、CRA证据和关闭台账 | P1全部关闭,P2未完成项须明确限制,不笼统全量验收 | W0/W1是首批最小开工范围;W2前不得扩大长期采集留存或据此承诺“计费隔离”。数据库索引、批量策略、轮询/推送选择在实测后确定。本轮未获得当前规模、可用测试时段及现场依赖,不给出缺乏依据的人日或上线日期承诺。 ## 5. 数据修复、迁移与回滚方案 1. **只读盘点与备份:**核对当前release、OpenSIPS/Redis/MySQL版本及生效参数、分析表行数/体积、Stream长度/PEL、日志及游标、采集生效时间、账务基线;备份配置与分析证据。已有发布状态先记录再变更。 2. **兼容加法迁移:**增加证据/覆盖/版本列或新表,不覆盖原始events,不删除既有raw/rated CDR;保留旧读路径。迁移前核对现有数据、唯一键及索引成本。 3. **影子重放:**用新的analysisRevision写入独立状态/桶命名空间。按客户、号码、视角、发起分钟对比旧新T/C/A/F/P/U,输出每项差异对应的事件证据与修复原因。 4. **不能恢复的历史数据:**旧事件缺少事务/时长/覆盖证据时,不因新代码部署而变成可信数据;登记未知/无覆盖,明确新证据生效边界。旧固定6秒不参与补算A。 5. **灰度读取:**先内部测试客户及受控SIP入口,再扩大授权客户范围;先观察数据质量,业务告警默认抑制,满足完整性门槛后启用。测试呼叫限定已授权环境和目标,不发外部通知。 6. **验收切换:**完成原CRA及新增用例、数据重放一致性、既有呼叫/计费回归、延迟和资源验证,再切换读取版本,归档数据覆盖起点和发布记录。 7. **回滚:**关闭业务告警和新读入口,切回上一个匹配的应用/配置/数据读版本,恢复原服务组合。存在采集性能风险时再关闭采集钩子,登记停采缺口;保留事实和新表以便修复,不全库恢复覆盖期间产生的新话单与账务。 回算、清理和去重必须共用保留期契约:允许重放的时间范围不能超过去重身份的保留范围;若超出,只允许写入新的重建版本,不能重灌在线聚合造成重复累计。 ## 6. 验收矩阵与证据要求 原CRA-001至CRA-024继续有效,本次不修改其业务预期。已有单测通过仅说明部分纯计算行为正确。 | 验收组 | 关联原用例 / 新增建议编号 | 必须提供的证据 | | --- | --- | --- | | 接通/应答基础语义 | CRA-001~009、011~014、018、021~023 | 真实SIP报文与事务匹配、会话时长来源、API快照、状态及桶SQL | | 去重与多视角 | CRA-010、015~017 | 同一业务呼叫多尝试、重传/鉴权续发、同号不同客户与重领;DB及账务不重复 | | 历史/故障/未知 | CRA-019/020/024 | 缺过程终态、缺整个呼叫、失联/轮转/重启、补送溢出及覆盖提示 | | 时长来源边界 | CRA-025(建议新增) | 200无ACK、协商失败、早期媒体与真实500ms/0ms,区分可信/暂定/未知 | | 最终校正乱序 | CRA-026(建议新增) | 本文R03两种顺序、重复、迟到END/ACCEPTED,终态和聚合完全一致 | | 采集完整性 | CRA-027(建议新增) | Worker存活而生产端停采、日志积压、gap跨重启、水位乱序不误报LIVE | | 留存与毒事件 | CRA-028(建议新增) | XADD后故障、游标恢复、轮转旧文件、坏schema、PEL被裁剪;不静默丢失 | | 原始呼叫关闭 | CRA-029(建议新增) | 未真实出局、重试间隙、丢END后MI消失,不误算落地T和原始F | | 快照与覆盖 | CRA-030(建议新增) | 生效前窗口、跨覆盖、非整分钟、跨日、>500组合及同步详情 | | 告警状态机 | CRA-031(建议新增) | 交替规则、冷却、迟滞、健康暂停、历史/线路筛选作用域 | | 权限与页面 | CRA-032(建议新增) | view但无scope、单客户scope、view_all、401/403;overview/detail/options均校验;全部排序及详情证据显示 | | 容量与隔离 | CRA-033(建议新增) | 按约定CPS/基数/保留期记录端到端P95和原有呼叫/计费指标;活跃观察超2000不漏 | | 发布与回滚 | CRA-034(建议新增) | 发布每个失败点、二次采集脚本升级、服务自动启动、配置与应用版本恢复 | 真实验收每个用例至少记录:runId、时间/环境、提交与配置hash、脱敏callUid/attemptId/Call-ID、预期计数、SIP与会话证据、API请求/返回、DB事件/状态/分钟桶查询、CDR关联或不能关联的原因、结果与报告路径。涉及计费隔离的用例,还必须独立核对原始话单、计费话单、余额变更和消费去重,而非只看分析API。 建议统一归档到 `tests/reports/caller-analytics//`;真实数据按最小必要原则脱敏。没有覆盖的用例必须显式未执行,不用“正常”或“已支持”代替结果。 ## 7. 原设计文档的逐节修订清单 此处给出待实施修订,不在本次直接重写原文,避免把建议误记为已落实。 | 原位置 | 建议修订 | | --- | --- | | 文首、第11/12节 | 当前状态集中成表;第11节移为历史交付记录,第12节事实附环境/时间/证据路径;设计版本V1.2、业务口径仍V1.1 | | 第2节 | 改为“已有实现与未关闭差距”,准确列出分析表/Worker/API/页面,不再统称待新增 | | 第3.1节 | 明确派发边界、业务呼叫关闭、单attempt现状与未来重试前置门槛 | | 第3.3/3.5节 | 区分U、覆盖缺口、A证据不完整,明确null/暂定规则与baseFilters/numberFilters | | 第3.4节 | 写明经过验证的时长来源、权威等级和校正版本,去掉以标签或差值自动认定“实际”的歧义 | | 第4节 | 增加确定性归约、业务关闭、水位和时钟域;明确快照不等于窗口结束时间 | | 第5节 | 增加页面已交付/待补列表;补供应商、详情、证据、延迟与覆盖展示 | | 第6节 | 把建议事件名与实际START/ATTEMPT等做映射;补字段Schema、版本唯一键、补送/死信/保留期与对账生产者 | | 第7节 | 规定新覆盖生效时间、不可修复历史规则、算法修复批次及影子回算方法 | | 第8节 | 逐规则持续计数、范围、评估周期、冷却/迟滞、健康暂停及恢复条件 | | 第9节 | 引用逐步发布/回滚清单和资源隔离门槛,不以服务active等同功能正常 | | 第10节 | 保留24项原用例,增补CRA-025~034并链接真实报告;P95仍为待测目标 | | 相关索引 | 同步主设计V2第1.1节、IMPLEMENTATION_STATUS和TEST_PLAN_AND_CASES,历史状态不覆盖当前状态 | ## 8. 本轮验证记录与复现方法 ### 8.1 已执行的专项测试 执行本地现有依赖,不安装/升级依赖: ```text node node_modules/vitest/vitest.mjs run packages/database/src/caller-analytics.spec.ts apps/api/src/modules/caller-analytics/caller-analytics.service.spec.ts ``` 本轮2026-08-31 17:32(Asia/Shanghai)结果:2个文件通过,16项测试通过;其中模型13项、查询参数3项。未在本轮重跑全量114项、构建或现场测试,因此不能引用旧全量结果作本轮验收。 ### 8.2 纯计算复现 本轮用本地TypeScript转译源码后以内存模块加载 `foldAnalytics/projectAnalytics`,不实例化PrismaClient、不连接Redis、不读环境密钥、不调用SIP。以下等价复现可在项目支持TypeScript导入的Node/tsx环境执行;事件均为构造输入,用于证明算法边界,不能代替真实业务验收。 ```javascript import { foldAnalytics, projectAnalytics } from './packages/database/src/caller-analytics-model.ts'; const t = Date.UTC(2026, 7, 31); const event = (kind, extra = {}) => ({ eventId: kind, callUid: 'review-u1', callId: 'review-c1', attemptId: '1', kind, at: t + 1000, startedAt: t, customerId: 'review-c', customerGatewayId: 'cg', vendorId: 'v', vendorGatewayId: 'vg', caller: '123', landingCaller: '456', callee: '789', city: 'x', carrier: 'UNKNOWN', code: 0, talkMs: null, source: 'opensips', sequence: 0, ...extra }); const run = events => projectAnalytics(events.reduce((c, e) => foldAnalytics(c, e), null))[0]; const attempt = event('ATTEMPT'); const accepted = event('ACCEPTED', { code: 200 }); const provisional = event('DURATION', { at: t + 4000, talkMs: 3000 }); const finalZero = event('RECONCILE', { at: t + 3000, talkMs: 0, source: 'verified-dialog-final' }); const end = event('END', { code: 487, source: 'verified-dialog-final' }); console.log(run([end])); // 当前F=1,U=0 console.log(run([attempt, event('UNKNOWN'), end])); // 当前F=1,U=0 console.log(run([attempt, end, event('UNKNOWN')])); // 当前F=0,U=1 console.log(run([attempt, accepted, provisional, finalZero])); // 当前A=1,talkMs=3000 console.log(run([attempt, accepted, finalZero, provisional])); // 当前A=0,talkMs=0 ``` 另两组本轮纯计算观察:第一尝试失败且尚无业务关闭事实时原始F=1;只有ACCEPTED及32秒后的对话END、无独立会话确认事实时A=1、talkMs=32000。前者证明多attempt生命周期仍不足;后者证明当前模型依赖信令时间差,不能单独证明实际通话成立。 ### 8.3 本轮没有完成的验证 未读取线上活动配置、未执行真实MySQL/Redis查询与事务重放、未运行SIP脚本、未验收UI渲染、未做容量/故障/回滚演练。本文件是审查与整改方案,所有整改工作包尚待实施,不应据此标记缺陷已修复或功能已上线。 ## 9. 主要依据索引 行号对应本次源码基线,后续修改后以函数/路由名及提交为准。 | 本地文件 | 核查用途 | | --- | --- | | [原设计](CALLER_REALTIME_ANALYTICS_DESIGN.md) | 业务语义、实现补充、24项CRA与交付边界 | | [状态模型](../packages/database/src/caller-analytics-model.ts) | 60–129行,事实合并、时长校正、投影、比率 | | [存储事务](../packages/database/src/caller-analytics-store.ts) | consume/bucket/state,事务去重和贡献差值 | | [分析Worker](../apps/worker-cdr/src/analytics-main.ts) | 18–98行,日志/Stream/MI/健康与恢复 | | [告警](../apps/worker-cdr/src/analytics-alerts.ts) | 3–35行,窗口、规则状态及过期 | | [采集脚本](../scripts/instrument-caller-analytics.mjs) | 11–38行,身份、挂钩、阶段和结束事件 | | [API](../apps/api/src/modules/caller-analytics/caller-analytics.service.ts) | parseAnalyticsQuery/overview/detail/options,筛选、权限、快照、分布及覆盖 | | [页面](../apps/web/src/pages/CallerAnalyticsPage.jsx) | 排序、刷新、展示与详情契约 | | [迁移](../prisma/migrations/20260831090000_caller_analytics/migration.sql) | 表、索引、版本字段与权限 | | [发布脚本](../infra/server-b/caller-analytics/deploy.sh) | 备份、迁移、配置切换、回滚与发布门槛 | | [Redis基线](../infra/server-b/s04/redis/redis-lisglosips.conf) | 本地持久化/内存配置参照,不代表现场 | | [真实测试入口](../tests/api/caller-analytics-real.mjs) / [SIP脚本](../tests/api/caller-analytics-sip.py) | 六场景覆盖范围及缺失证据 | | [实施状态](../IMPLEMENTATION_STATUS.md) / [主设计V2](../SOFTSWITCH_PLATFORM_DESIGN_V2.md) / [测试计划](TEST_PLAN_AND_CASES.md) | 状态一致性与验收索引 | 外部依据使用本轮查阅的RFC及OpenSIPS/Redis官方文档;不据此猜测现场软件版本或配置。所有方案中的生产变更、受控呼叫、数据回算和发布,都应在后续实施任务的实际授权范围内执行。