一键开通华为云码道 CodeArts 代码智能体:https://developer.huaweicloud.com/codeartsco.html?source=dmzntgwatomgit1&sourcead=dmzntgwatomgithd
摘要:我用 CodeArts 代码智能体开发一个微信小程序「巡哨」,把运维巡检数据变成手机上可离线检索、可自定义告警规则的随身分析器——全部逻辑在端内完成,零服务端、零网络请求。本文重点记录端外采集工具链的完整落地:报告格式设计、默认脱敏机制、docker compose ps 输出解析器的三个坑,以及一次真实踩到的演示数据缺陷。
技术栈版本:码道 CodeArts 代码智能体(2026-09 挑战赛版本)| uni-app(Vue 3 + Vite)| TypeScript 5 | Node.js 22 | Vitest | 实测时间: 2026-09

一、项目背景:七服务的"人不在电脑前"困境
我维护着一套 docker-compose 生产环境:七个服务(nginx / backend / redis / mysql / api-gateway / order-service / worker),配了发布脚本与巡检流程。
这条链路在本机终端里很顺。但它有三个只在离开电脑时才暴露的缺口。
缺口一:发完版人不在电脑前,不知道服务到底起来了没有。 每次发布完只能等回到电脑前跑一遍 docker compose ps 才知道。中间这段空白只能靠运气。
缺口二:拿不到"本次发布让哪些指标变差了"的结论。 docker compose ps 只能给出当下这一刻的是与否,而真正有用的是差异——磁盘涨了几个点、某个容器内存比发布前高了多少、哪些探针延迟恶化了。人脑没法在两次扫屏输出之间做差分。
缺口三:告警阈值散落在记忆里,而且是复合语义。 "磁盘 85%、内存 1024MB、延迟 500ms"这些阈值每个服务都不一样。更麻烦的是运维真正想要的表达是复合的:CPU 超过 80% 且内存超过 1024MB、延迟超过 500ms 且连续三次采样都超。表单下拉框填数字这套交互,表达不了后者。
所以这次参赛,我决定做一个能把这三件事收拢到手机上的东西。
1.1 作品功能一览
| 巡检采集 | 把巡检输出归一化为标准报告文件,默认脱敏 | 已实现 |
| 报告导入 | 四道安全校验 + 解析落盘 | 已实现 |
| 健康看板 | 七服务健康状态卡片,异常优先 | 已实现 |
| 告警规则引擎 | 自研 DSL,支持比较/逻辑/括号/for N | 已实现 |
| 指标趋势 | Canvas 时序曲线 | 已实现 |
| 差分诊断 | 对比两次巡检,输出变差/恢复清单 | 已实现 |
技术栈:uni-app(Vue 3 + Vite + TypeScript)编译到微信小程序端,Vitest 做单测,全程零服务端。
成品实拍按功能两两并排(左右各一屏):

1.2 与第 1 期作品的关系
第 1 期我做的是 ArticlePilot——一个带 Java 后端和 MySQL 的浏览器端内容分发管理系统。本期和它在三个层面做了切割:
| 运行环境 | 浏览器 + 服务端 + 数据库 | 小程序,零服务端、零数据库、零网络请求 |
| 业务域 | 内容分发(写作链路) | 运维巡检(发布链路) |
| 核心技术 | 适配器模式 + 状态机 + RBAC | 数据格式 + 自研 DSL + 差分存储 |
需要说明的是,两期前端都用 Vue 3 语法(本期通过 uni-app)。这在语法层面是重叠的,但代码零复用、功能零交叉——共享的只是我个人的工程习惯,以及"本地优先、端外工具链、导入安全校验"这套方法论。
二、设计前提:四条平台红线与一条业务红线
动手之前,我先把约束摆清楚。小程序架构的难点不在分层设计,而在平台硬约束下的取舍。
2.1 小程序四条平台红线
2026 年现行标准(微信官方文档):
| 主包体积 | ≤ 2MB | 禁止引入第三方规则引擎库、图表库;DSL 解析器必须自研 |
| 单个分包 | ≤ 2MB | 数据文件绝不能进代码包 |
| 总包体积 | ≤ 20MB | 同上 |
| wx/storage | 单 key 1MB / 总量 10MB | 只放规则集与偏好,历史巡检序列放不下 |
| 本地用户文件 | ≤ 200MB | 巡检序列的唯一落点,必须做配额管理 |
这组数字直接决定了第三章的三条设计决策。
2.2 一条业务红线:运维数据不能上云
比平台约束更硬的是这条——巡检报告里含主机标识、服务名、端口。这类数据一旦传到公网接口或第三方 SaaS,就等于把内网拓扑画给了外人。
它直接否决了下面三个听起来更"现代"的方案:
#mermaid-svg-wLFzrcWFbvPcNIUB{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-wLFzrcWFbvPcNIUB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wLFzrcWFbvPcNIUB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wLFzrcWFbvPcNIUB .error-icon{fill:#552222;}#mermaid-svg-wLFzrcWFbvPcNIUB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wLFzrcWFbvPcNIUB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wLFzrcWFbvPcNIUB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wLFzrcWFbvPcNIUB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wLFzrcWFbvPcNIUB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wLFzrcWFbvPcNIUB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wLFzrcWFbvPcNIUB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wLFzrcWFbvPcNIUB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wLFzrcWFbvPcNIUB .marker.cross{stroke:#333333;}#mermaid-svg-wLFzrcWFbvPcNIUB svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wLFzrcWFbvPcNIUB p{margin:0;}#mermaid-svg-wLFzrcWFbvPcNIUB .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-wLFzrcWFbvPcNIUB .cluster-label text{fill:#333;}#mermaid-svg-wLFzrcWFbvPcNIUB .cluster-label span{color:#333;}#mermaid-svg-wLFzrcWFbvPcNIUB .cluster-label span p{background-color:transparent;}#mermaid-svg-wLFzrcWFbvPcNIUB .label text,#mermaid-svg-wLFzrcWFbvPcNIUB span{fill:#333;color:#333;}#mermaid-svg-wLFzrcWFbvPcNIUB .node rect,#mermaid-svg-wLFzrcWFbvPcNIUB .node circle,#mermaid-svg-wLFzrcWFbvPcNIUB .node ellipse,#mermaid-svg-wLFzrcWFbvPcNIUB .node polygon,#mermaid-svg-wLFzrcWFbvPcNIUB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wLFzrcWFbvPcNIUB .rough-node .label text,#mermaid-svg-wLFzrcWFbvPcNIUB .node .label text,#mermaid-svg-wLFzrcWFbvPcNIUB .image-shape .label,#mermaid-svg-wLFzrcWFbvPcNIUB .icon-shape .label{text-anchor:middle;}#mermaid-svg-wLFzrcWFbvPcNIUB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-wLFzrcWFbvPcNIUB .rough-node .label,#mermaid-svg-wLFzrcWFbvPcNIUB .node .label,#mermaid-svg-wLFzrcWFbvPcNIUB .image-shape .label,#mermaid-svg-wLFzrcWFbvPcNIUB .icon-shape .label{text-align:center;}#mermaid-svg-wLFzrcWFbvPcNIUB .node.clickable{cursor:pointer;}#mermaid-svg-wLFzrcWFbvPcNIUB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-wLFzrcWFbvPcNIUB .arrowheadPath{fill:#333333;}#mermaid-svg-wLFzrcWFbvPcNIUB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-wLFzrcWFbvPcNIUB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-wLFzrcWFbvPcNIUB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wLFzrcWFbvPcNIUB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-wLFzrcWFbvPcNIUB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wLFzrcWFbvPcNIUB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-wLFzrcWFbvPcNIUB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-wLFzrcWFbvPcNIUB .cluster text{fill:#333;}#mermaid-svg-wLFzrcWFbvPcNIUB .cluster span{color:#333;}#mermaid-svg-wLFzrcWFbvPcNIUB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-wLFzrcWFbvPcNIUB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-wLFzrcWFbvPcNIUB rect.text{fill:none;stroke-width:0;}#mermaid-svg-wLFzrcWFbvPcNIUB .icon-shape,#mermaid-svg-wLFzrcWFbvPcNIUB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wLFzrcWFbvPcNIUB .icon-shape p,#mermaid-svg-wLFzrcWFbvPcNIUB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-wLFzrcWFbvPcNIUB .icon-shape .label rect,#mermaid-svg-wLFzrcWFbvPcNIUB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wLFzrcWFbvPcNIUB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-wLFzrcWFbvPcNIUB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-wLFzrcWFbvPcNIUB :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
方案D:端外采集到端内解析-采用
零服务端
采用
数据全程不出设备
方案C:接入监控 SaaS
依赖外部账号
否决
数据完全出网
方案B:云函数中转 + 云数据库
需常驻云资源
否决
运维数据出网到第三方平台
方案A:小程序直连服务器 API
需要公网接口 + 域名备案 + HTTPS 证书
否决
服务器暴露面扩大,还要下发 token
最终选的是第四条路:端外把巡检结果采集成文件,端内只做解析与分析。数据不落地到任何服务端。
三、架构设计:三条主线
#mermaid-svg-1Jrw0n7fKi09WnVq{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1Jrw0n7fKi09WnVq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1Jrw0n7fKi09WnVq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1Jrw0n7fKi09WnVq .error-icon{fill:#552222;}#mermaid-svg-1Jrw0n7fKi09WnVq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1Jrw0n7fKi09WnVq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1Jrw0n7fKi09WnVq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1Jrw0n7fKi09WnVq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1Jrw0n7fKi09WnVq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1Jrw0n7fKi09WnVq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1Jrw0n7fKi09WnVq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1Jrw0n7fKi09WnVq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1Jrw0n7fKi09WnVq .marker.cross{stroke:#333333;}#mermaid-svg-1Jrw0n7fKi09WnVq svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1Jrw0n7fKi09WnVq p{margin:0;}#mermaid-svg-1Jrw0n7fKi09WnVq .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-1Jrw0n7fKi09WnVq .cluster-label text{fill:#333;}#mermaid-svg-1Jrw0n7fKi09WnVq .cluster-label span{color:#333;}#mermaid-svg-1Jrw0n7fKi09WnVq .cluster-label span p{background-color:transparent;}#mermaid-svg-1Jrw0n7fKi09WnVq .label text,#mermaid-svg-1Jrw0n7fKi09WnVq span{fill:#333;color:#333;}#mermaid-svg-1Jrw0n7fKi09WnVq .node rect,#mermaid-svg-1Jrw0n7fKi09WnVq .node circle,#mermaid-svg-1Jrw0n7fKi09WnVq .node ellipse,#mermaid-svg-1Jrw0n7fKi09WnVq .node polygon,#mermaid-svg-1Jrw0n7fKi09WnVq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1Jrw0n7fKi09WnVq .rough-node .label text,#mermaid-svg-1Jrw0n7fKi09WnVq .node .label text,#mermaid-svg-1Jrw0n7fKi09WnVq .image-shape .label,#mermaid-svg-1Jrw0n7fKi09WnVq .icon-shape .label{text-anchor:middle;}#mermaid-svg-1Jrw0n7fKi09WnVq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1Jrw0n7fKi09WnVq .rough-node .label,#mermaid-svg-1Jrw0n7fKi09WnVq .node .label,#mermaid-svg-1Jrw0n7fKi09WnVq .image-shape .label,#mermaid-svg-1Jrw0n7fKi09WnVq .icon-shape .label{text-align:center;}#mermaid-svg-1Jrw0n7fKi09WnVq .node.clickable{cursor:pointer;}#mermaid-svg-1Jrw0n7fKi09WnVq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1Jrw0n7fKi09WnVq .arrowheadPath{fill:#333333;}#mermaid-svg-1Jrw0n7fKi09WnVq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1Jrw0n7fKi09WnVq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1Jrw0n7fKi09WnVq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1Jrw0n7fKi09WnVq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1Jrw0n7fKi09WnVq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1Jrw0n7fKi09WnVq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1Jrw0n7fKi09WnVq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1Jrw0n7fKi09WnVq .cluster text{fill:#333;}#mermaid-svg-1Jrw0n7fKi09WnVq .cluster span{color:#333;}#mermaid-svg-1Jrw0n7fKi09WnVq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1Jrw0n7fKi09WnVq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1Jrw0n7fKi09WnVq rect.text{fill:none;stroke-width:0;}#mermaid-svg-1Jrw0n7fKi09WnVq .icon-shape,#mermaid-svg-1Jrw0n7fKi09WnVq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1Jrw0n7fKi09WnVq .icon-shape p,#mermaid-svg-1Jrw0n7fKi09WnVq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1Jrw0n7fKi09WnVq .icon-shape .label rect,#mermaid-svg-1Jrw0n7fKi09WnVq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1Jrw0n7fKi09WnVq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1Jrw0n7fKi09WnVq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1Jrw0n7fKi09WnVq :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
端内:小程序
端外:tools/ops-collect
微信会话传输
docker compose ps 输出
解析器
按 schema 归一化
脱敏:主机转哈希别名
gzip → .opsreport
chooseMessageFile 导入
四道安全校验
报告解析 + 指标扁平化
core 纯逻辑层
DSL 规则引擎
差分诊断引擎
健康看板
诊断清单
USER_DATA_PATH 落盘
三条主线分别是数据格式、规则引擎、差分存储。数据格式的实现见第四章,后两条的实现思路在本章各占一节。
3.1 主线一:巡检报告格式与端外采集(已实现)
核心思路是把最重的计算移出小程序运行时:端外一次预处理,端内只做装载。详细实现见第四章。
3.2 主线二:自研告警规则 DSL
要表达 probe_nginx_latency_ms > 500 for 3 这类语义,有四条路:
| 表单式固定阈值 | 弱:"连续 N 次"表达不了 | 极小 | 一般 | 否决 |
| 手写 if-else 模板 | 中 | 小 | 差,加规则要改核心代码 | 否决 |
| 第三方规则引擎库 | 强 | +数百 KB,挤压 2MB 主包 | 好 | 否决 |
| 自研轻量 DSL | 够用:比较/逻辑/括号/for N | 约 15KB | 极好,纯函数 | 采用 |
它不是炫技的选择——2MB 主包预算里确实塞不下第三方引擎。
#mermaid-svg-F7OZJhQlYHnhxjHG{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-F7OZJhQlYHnhxjHG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-F7OZJhQlYHnhxjHG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-F7OZJhQlYHnhxjHG .error-icon{fill:#552222;}#mermaid-svg-F7OZJhQlYHnhxjHG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-F7OZJhQlYHnhxjHG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-F7OZJhQlYHnhxjHG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-F7OZJhQlYHnhxjHG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-F7OZJhQlYHnhxjHG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-F7OZJhQlYHnhxjHG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-F7OZJhQlYHnhxjHG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-F7OZJhQlYHnhxjHG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-F7OZJhQlYHnhxjHG .marker.cross{stroke:#333333;}#mermaid-svg-F7OZJhQlYHnhxjHG svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-F7OZJhQlYHnhxjHG p{margin:0;}#mermaid-svg-F7OZJhQlYHnhxjHG .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-F7OZJhQlYHnhxjHG .cluster-label text{fill:#333;}#mermaid-svg-F7OZJhQlYHnhxjHG .cluster-label span{color:#333;}#mermaid-svg-F7OZJhQlYHnhxjHG .cluster-label span p{background-color:transparent;}#mermaid-svg-F7OZJhQlYHnhxjHG .label text,#mermaid-svg-F7OZJhQlYHnhxjHG span{fill:#333;color:#333;}#mermaid-svg-F7OZJhQlYHnhxjHG .node rect,#mermaid-svg-F7OZJhQlYHnhxjHG .node circle,#mermaid-svg-F7OZJhQlYHnhxjHG .node ellipse,#mermaid-svg-F7OZJhQlYHnhxjHG .node polygon,#mermaid-svg-F7OZJhQlYHnhxjHG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-F7OZJhQlYHnhxjHG .rough-node .label text,#mermaid-svg-F7OZJhQlYHnhxjHG .node .label text,#mermaid-svg-F7OZJhQlYHnhxjHG .image-shape .label,#mermaid-svg-F7OZJhQlYHnhxjHG .icon-shape .label{text-anchor:middle;}#mermaid-svg-F7OZJhQlYHnhxjHG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-F7OZJhQlYHnhxjHG .rough-node .label,#mermaid-svg-F7OZJhQlYHnhxjHG .node .label,#mermaid-svg-F7OZJhQlYHnhxjHG .image-shape .label,#mermaid-svg-F7OZJhQlYHnhxjHG .icon-shape .label{text-align:center;}#mermaid-svg-F7OZJhQlYHnhxjHG .node.clickable{cursor:pointer;}#mermaid-svg-F7OZJhQlYHnhxjHG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-F7OZJhQlYHnhxjHG .arrowheadPath{fill:#333333;}#mermaid-svg-F7OZJhQlYHnhxjHG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-F7OZJhQlYHnhxjHG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-F7OZJhQlYHnhxjHG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-F7OZJhQlYHnhxjHG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-F7OZJhQlYHnhxjHG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-F7OZJhQlYHnhxjHG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-F7OZJhQlYHnhxjHG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-F7OZJhQlYHnhxjHG .cluster text{fill:#333;}#mermaid-svg-F7OZJhQlYHnhxjHG .cluster span{color:#333;}#mermaid-svg-F7OZJhQlYHnhxjHG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-F7OZJhQlYHnhxjHG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-F7OZJhQlYHnhxjHG rect.text{fill:none;stroke-width:0;}#mermaid-svg-F7OZJhQlYHnhxjHG .icon-shape,#mermaid-svg-F7OZJhQlYHnhxjHG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-F7OZJhQlYHnhxjHG .icon-shape p,#mermaid-svg-F7OZJhQlYHnhxjHG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-F7OZJhQlYHnhxjHG .icon-shape .label rect,#mermaid-svg-F7OZJhQlYHnhxjHG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-F7OZJhQlYHnhxjHG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-F7OZJhQlYHnhxjHG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-F7OZJhQlYHnhxjHG :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
未注册标识符
规则文本probe_nginx_latency_ms > 500 for 3
tokenizeToken[]
parse 递归下降AstNode
resolve白名单校验指标
evaluateRuleOutcome
true / false / unknown
unknown 而非 false
这里有个容易被忽视的安全点:允许用户自定义规则,等于把半个求值器暴露给了输入。所以求值必须走沙箱——标识符只能取自预注册白名单、禁止函数调用与属性访问、禁止 eval 类动态求值。否则规则编辑器就是个现成的注入入口。
3.3 主线三:差分增量存储
真正有价值的信息不在单次快照,而在差异。设计上第二次巡检起只存差分字段,超过 150MB 按"最旧优先"淘汰。
四、落地实录:采集工具链
工具在 tools/ops-collect/,零第三方依赖,只用 Node 内置模块;小程序端所有页面的数据源质量,都由它决定。
4.1 报告格式设计
{
"schemaVersion": "1.0",
"hostAlias": "h-c5f8b961",
"collectedAt": "2026-09-21T09:00:00+08:00",
"system": { "diskUsedPercent": 63.2, "load1": 0.45, "uptimeHours": 1420 },
"services": [
{ "name": "mysql", "status": "healthy", "cpuPercent": 12.4, "memMb": 512, "restartCount": 0 }
],
"probes": [{ "name": "http_health", "latencyMs": 128, "statusCode": 200 }]
}
三条格式纪律值得单独说:
数值缺失就是缺失,不用哨兵值。 不允许 -1、9999 这类表达"没采到"的写法。因为差分诊断会拿它当真实数据算百分比,一个 -1 能让整个诊断清单失真。
状态枚举收窄到四个。 healthy | unhealthy | exited | unknown。终端输出里常见的 "N/A"、"—-" 这类占位字符串一律拒绝——它们看着像"无数据",实际上是字符串类型,会让规则引擎的比较运算直接判错。
每个校验错误都带 JSON path。 services[2].cpuPercent 这样的定位,才能让用户知道到底哪一条数据坏了:
// src/core/report/validate.ts(节选)
const err = (code: string, message: string, path: string): ParseResult<never> => ({
ok: false,
error: { code, message, path },
})
// 有限数校验:拒绝 NaN / Infinity / null / 非数字类型
function isFiniteNumber(v: unknown): v is number {
return typeof v === 'number' && Number.isFinite(v)
}
if (!isFiniteNumber(s[key])) {
return err('INVALID_NUMBER', `${path}.${key} 必须是有限数值(拒绝 NaN/Infinity/null)`, `${path}.${key}`)
}
4.2 三种工作模式
# 1. 生成演示序列(零配置,给评审看)
node index.js –demo –out demo.opsreport –series-out demo-series.json
# 2. 解析 docker compose ps 的输出(状态映射:Up→healthy,Exit→exited)
docker compose ps | node index.js –from-file /dev/stdin –mode docker –out prod.opsreport
# 3. 校验并规范化已有 JSON
node index.js –from-file raw.json –mode json –out prod.opsreport
4.3 解析器踩的三个坑
采集端要对接的是 docker compose ps 这类给人看的终端输出,格式天然不适合机器解析。
坑一:docker compose ps 的列对齐空格数不固定。 它会按最长列宽对齐,列名之间的空格数随数据变化,按单个空格切分必然切碎:
// tools/ops-collect/index.js — readDockerMode(节选)
const cols = line.trim().split(/\\s{2,}|\\t+/).filter((c) => c.trim() !== '')
if (cols.length < 2) continue
let name = cols[0]
let statusText = cols[cols.length – 1]
if (cols.length >= 3) {
name = cols[1]
statusText = cols.slice(2).join(' ')
}
坑二:容器名与服务名是两列,取错列整份报告就张冠李戴。 NAME SERVICE STATUS 三列布局下,容器名(infra-nginx-1)和服务名(nginx)都在前两列,报告里要的是服务名:
// 服务名列通常是第 2 列(NAME SERVICE STATUS);兼容只有 NAME + STATUS 两列的情况
if (cols.length >= 3) {
name = cols[1]
statusText = cols.slice(2).join(' ')
}
坑三:Up (unhealthy) 的括号标记才是权威状态。 状态列长得都像 “Up”,但 Up 20 minutes (unhealthy) 里藏着健康检查结论,必须让带标记的规则先于裸 Up 匹配——顺序错了全部误判为健康:
// tools/ops-collect/index.js(节选):unhealthy 必须先于 Up 判定
const DOCKER_STATUS_MAP = [
{ test: /unhealthy/i, status: 'unhealthy' },
{ test: /^\\s*Exit/i, status: 'exited' },
{ test: /^\\s*Restarting/i, status: 'unhealthy' },
{ test: /^\\s*Up/i, status: 'healthy' },
{ test: /^\\s*Created|^\\s*Paused/i, status: 'unknown' },
]
4.4 单位换算:MiB 不是 MB
docker stats 的 412MiB 是二进制单位(1048576 字节),直接当 MB 用会低估 4.86%。本期采集端尚未接入 docker stats,docker 模式下的 cpu/mem 字段以 0 占位(readDockerMode 里显式写死),但 schema 已经把纪律立好:所有数值字段必须是有限数,字符串 "412MiB" 这类值在校验层直接拒绝(INVALID_NUMBER)。
4.5 脱敏:默认就是安全的
这是我最看重的一处设计。运维数据一旦漏进公开仓库就无法撤回,与其指望"记得脱敏",不如让工具默认产出就是安全的:
prod-web-01.example.com → h-c5f8b961
用的是 sha256 前 8 位(aliasFor),同一原值永远得到同一别名。反查表落在 .hostmap(已被 gitignore 忽略),作者本人能查回原值,不影响真实使用。
需要保留原文时必须显式放行,并且会打印警告:
$ node index.js –from-file raw.json –mode json –host prod-web-01.example.com –keep-host –out prod.opsreport
[警告] 输出包含未脱敏的主机标识,请勿提交到公开仓库
4.6 演示序列:不是随机毛刺,是有剧本的数据
评审不会去配服务器、跑采集脚本。所以要有一份零配置可用的演示数据,而且必须"有故事"。
生成器是确定性公式、不用随机数(buildDemoSeries 里全是 base + slope * i)——同一份代码在任何机器上产出完全一致的序列,保证文档里写的数字和你跑出来的数字是同一份。内置四个场景:
| 内存泄漏(发布后) | backend 自 880MB 起每点 +27MB,第 7 点越过 1024MB | svc_backend_mem_mb > 1024 |
| 磁盘水位上涨 | 79.0% 起每点 +0.45,第 15 点突破 85% | disk_used_percent > 85 |
| 探针延迟恶化 | 360ms 起每点 +10ms,第 16 点超 500ms,末三点连续命中 | probe_nginx_latency_ms > 500 for 3 |
| 瞬时抖动后自愈 | redis 第 17 点 unhealthy,其余采样点 healthy | svc_redis_status != healthy |
实测运行结果(npm run collect:demo,即 node index.js –demo –out src/static/demo/demo-series.json –no-compress –series-out tools/ops-collect/demo-series.json):
[脱敏] 主机标识已哈希为 h-6a3cb155,对照表写入 src/static/demo/.hostmap(已被 .gitignore 忽略)
[输出] 演示序列 tools/ops-collect/demo-series.json(20 个采样点)
[输出] src/static/demo/demo-series.json
报告数 20 / 服务数 7
原始 JSON 17.4 KB → 未压缩 17.4 KB
hostAlias = h-6a3cb155
17.4KB 意味着演示数据可以直接随代码包分发,评审零配置就能看到完整功能,不用自己跑采集。
4.7 一次真实踩到的缺陷:for 3 命中不了
写完生成器后我加了个校验脚本 verify.js,专门回答一个问题:交付给评审的演示数据是不是真的能触发规则。
第一版生成器把 Nginx 延迟的增长斜率设成了 8ms/点,第一次运行 verify.js 就挂了:
svc_backend_mem_mb > 1024 | [HIT] | #7 | 14/20 (70.0%)
disk_used_percent > 85 | [HIT] | #15 | 6/20 (30.0%)
probe_nginx_latency_ms > 500 for 3 | [MISS] | – | 0/20 (0.0%)
svc_redis_status != healthy | [HIT] | #17 | 1/20 (5.0%)
[FAIL] 未命中 1/4: probe_nginx_latency_ms > 500 for 3
原因是斜率不够:第 17 个采样点是 360 + 17×8 = 496ms,差 4ms 没过线,末三个点不是连续命中。
把斜率改成 10ms/点后:
// tools/ops-collect/index.js — buildDemoSeries(节选)
// 斜率必须保证最后三个采样点都超过 500ms,否则 for 3 语义命中不了
// 取 10ms/点:第 16 点 360+16*10=520 > 500,第 17/18 点更高 → 连续三次命中
const nginxLatency = Math.round(360 + 10 * i)
修复后 npm run collect:verify 四条规则全部命中:
svc_backend_mem_mb > 1024 | [HIT] | #7 | 14/20 (70.0%)
disk_used_percent > 85 | [HIT] | #15 | 6/20 (30.0%)
probe_nginx_latency_ms > 500 for 3 | [HIT] | #18 | 3/20 (15.0%)
svc_redis_status != healthy | [HIT] | #17 | 1/20 (5.0%)
[PASS] 4/4 条规则全部命中
这个坑的价值在于:如果没写这个校验脚本,"内置演示数据可体验"就只是一句宣传语,等评审打开一看什么都没触发,前面所有设计都白做。

五、用 CodeArts 开发:实测感受
到目前为止,我给 CodeArts 投喂的都是中小型、边界清晰的任务——采集工具链逐个模块投喂,每个块都带验收标准。
有个做法已经验证有效:让 AI 先输出实现计划,我确认后再动手。工具链这种"输入输出都很明确"的任务,AI 一次通过率很高;真正需要我介入修正的,是话术没覆盖到的语义细节(比如 Up (unhealthy) 的括号标记才是权威状态这条,是我验收时才发现的)。
下面两处投喂现场比较有代表性。工程初始化属于"输入输出都明确"的任务,一次通过:一段话术交代「克隆仓库 → 套 uni-preset-vue(vite-ts)模板 → 装依赖 → 构建验证」,Agent 自主调用 AtomGit CLI 走完全链路,5 分 11 秒后交付的是 npm run build:mp-weixin 构建通过的工程:

而工程骨架这类带架构取舍的任务,走的是"先出计划、确认后动手":Agent 先给出目录结构(src/core 纯逻辑层禁止 import uni.* / vue)、分包配置与主题系统三段方案,我逐条确认后才放行实现:

六、开发节奏与质量纪律
开发按「端外工具链 → 工程骨架 → 导入链路 → 规则引擎 → 差分诊断 → 质量收口」的顺序推进,每一步都先让 Agent 出实现计划、确认后再动手。
其中一条贯穿全程的硬纪律:src/core/** 必须是零平台依赖的纯 TypeScript(不 import 任何 uni.* / vue 模块)。
这条纪律同时解决两个问题——控住 2MB 主包红线,以及让核心逻辑能脱离小程序宿主环境做高覆盖率单测。
七、开源地址与运行方式
项目仓库:https://atomgit.com/dickeryang/patrolops-mp.git
本地运行:
# 端外工具链(仅需 Node >= 18)
npm run collect:demo # 生成内置演示序列(src/static/demo/demo-series.json)
npm run collect:verify # 校验演示数据能触发全部 4 条规则
# 小程序端
npm install
npm run dev:mp-weixin
# 微信开发者工具 → 导入 dist/dev/mp-weixin(勾选「保存时自动编译小程序」)
视频演示地址
视频演示地址
八、小结
回到开头那三个缺口,本篇完成的是第一步:把给人看的终端输出,变成结构化、安全、可传播的数据文件。
三件我认为做对了的事:
觉得有用就点个赞,有什么想法欢迎评论区交流。
网硕互联帮助中心






评论前必须登录!
注册