GraphQL与TypeScript构建实时Andon报警系统:从建模到部署
2026/9/15 0:08:33 网站建设 项目流程

简介:一份可扩展Andon系统的部署解决方案,面向制造业现场管理、工业物联网与质量改善团队,也适合需要用TypeScript构建车间看板与异常报警后台的开发者。该方案整合前端界面、后端逻辑与数据订阅链路,使异常事件从触发、响应到关闭形成闭环,同时为向预测性维护过渡奠定数据基础。压缩包内含158个文件,整体大小约710KB,以41个TypeScript源文件、29个TSX组件、32个VTL模板为主体,配合JSON配置、JavaScript脚本、Markdown文档及GraphQL模式定义,形成前后端一体的项目骨架。其中还带有架构图、测试配置与Shell/Python辅助工具,便于从环境搭建、接口调试到自动化验证全流程跟进。目前已有112人学习/下载,对需要快速落地异常管理看板、研究实时报警与预测性维护衔接的人来说,这套结构清晰的TypeScript实现能提供直接的模块划分参考与二次开发起点。

1. 产线拉绳按灯背后,先解决事件模型的问题

一条组装线上最怕的不是设备报警,而是报警事件被淹没在聊天群里,等班组长看到消息,停机已经持续了十几分钟。Andon 系统的本质不是“发一个通知”,而是把现场的设备状态变化抽象成一条可追溯的、可升级的、能自动流转的事件链路:工人拉绳触发报警,系统记录事件,通知对应角色,处理完成再复位。这套 TypeScript 工程要解决的正是这条链路的建模与部署问题。项目里schema.graphql承担事件模型定义,queries.jsmutations.jssubscriptions.js覆盖查询、变更与实时推送三个入口,jest.config.js负责把行为锁进测试。对想做制造业物联网看板、设备状态监测和可扩展告警系统的开发者来说,它是一个能直接拆开看骨架的参考实现,也对从“事后告警”走向“预测性维护”给出了数据基础。

2. GraphQL 建模与 TypeScript 工程化:从 schema.graphql 到 queries、mutations

2.1 为什么 Andon 系统更合适用 GraphQL,而不是 REST

现场 Andon 的使用方通常不止一种:车间大屏需要全量工位状态、班组长手机上只想看报警中的工位、质量系统要拉某个工位的历史事件。若用 REST,就得为这些展示场景各写一个接口,或者做一个字段越来越多的大而全端点。GraphQL 把取数权交给客户端,一种资源模型按需取字段,同一条事件记录既能喂大屏也能喂移动端。

另一个关键点是类型。GraphQL schema 本身就是强约束的接口契约,TypeScript 项目里再通过 codegen 从 schema 生成类型定义,前端组件与后端 resolver 用的是同一份类型。这个项目把.graphql文件单独拆出来,正是为了让事件模型先立住,前后端都围绕它展开。

2.2 schema.graphql 里的领域模型:状态机 + 事件溯源

打开schema.graphql,核心不是那张AndonStation表,而是AndonEvent。Andon 系统里工位(Station)只保存当前状态,真正的业务价值全在事件流里。一个工位从正常到报警再到复位,是一个有限状态机。先看基础的模型定义:

scalar DateTime enum AndonStatus { RUNNING IDLE ALERT DOWNTIME } enum AndonEventType { TRIPPED RESET ESCALATED ACKNOWLEDGED } type AndonStation { id: ID! line: String! stationCode: String! status: AndonStatus! lastEvent: AndonEvent } type AndonEvent { id: ID! stationId: ID! station: AndonStation! type: AndonEventType! message: String occurredAt: DateTime! acknowledgedBy: String } type Query { stations(line: String): [AndonStation!]! events(stationId: ID!, limit: Int = 50): [AndonEvent!]! } type Mutation { trip(stationId: ID!, message: String): AndonEvent! reset(stationId: ID!): AndonEvent! acknowledge(eventId: ID!, user: String!): AndonEvent! } type Subscription { onAndonEvent(types: [AndonEventType!]): AndonEvent! }

schema.graphql里最值得注意的设计是AndonEvent只追加、不更新。Trip生成一条TRIPPED事件,复位生成RESET事件,事件与事件之间通过stationId关联,回溯问题时只需要按时间查事件列表。

AndonEventType四种取值可以这样理解:

事件类型触发方语义
TRIPPED拉绳/按钮/设备 IO工位进入报警,产线需要关注
ACKNOWLEDGED班组长/系统报警已被确认,有人接手处理
RESET作业员/维修完成问题已解除,工位恢复可运行
ESCALATED定时器/规则超时未处理,事件升级给更高级别

status字段是状态机在当前时刻的投影,而lastEvent保留最近一次变更的引用。这样 reslover 里做状态迁移时,只需要校验“当前状态 + 新事件”是否合法,再落一条事件,顺带更新工位状态。

2.3 queries.js / mutations.js 的实际调用姿势

客户端使用 GraphQL operation 时,queries.js里通常维护查询语句,mutations.js维护变更语句。典型的一个查询是按产线拉出所有工位状态:

import { gql } from "@apollo/client"; export const GET_LINE_OVERVIEW = gql` query GetLineOverview($line: String) { stations(line: $line) { id stationCode status lastEvent { type occurredAt message } } } `;

这里把line作为可选参数,车间大屏只传当前车间名,后端按产线过滤;如果传空,则返回全厂工位。lastEvent用嵌套对象取最近一次事件的类型和时间,前端可以直接根据lastEvent.type判断是否需要闪烁告警图标。

触发报警的 mutation 长这样:

export const TRIP_STATION = gql` mutation TripStation($stationId: ID!, $message: String) { trip(stationId: $stationId, message: $message) { id type occurredAt station { id status } } } `;

注意 mutation 的返回结构里既带了事件信息,也带上了station.status。这是 GraphQL 的典型技巧:一次变更之后直接把受影响的状态投影一起返回,客户端更新本地缓存时不需要再发一次查询。

2.4 用 TypeScript 约束 mutation 入参和事件载荷

项目对外提供.js文件,但内部工程化仍然依赖 TypeScript 编译器。常见的做法是在 schema 旁维护一份类型定义,例如把事件载荷写成显式类型别名:

type AndonEventPayload = { id: string; stationId: string; type: "TRIPPED" | "RESET" | "ESCALATED" | "ACKNOWLEDGED"; occurredAt: string; acknowledgedBy?: string; }; type TripInput = { stationId: string; message?: string; };

type AndonEventPayload这种写法在 TypeScript 里和AndonEventPayload = [{}]这种空对象数组标注是完全不同的语义:前者描述一条事件,后者描述一个元素为对象的数组,排错时常遇到把这两种混用的报错。在 resolver 里,trip的入参直接标注为TripInput,编辑器提示和编译期检查就都齐了。

3. 用 subscription 把报警推到看板:GraphQL over WebSocket 的实时链路

3.1 为什么实时链路不能靠前端轮询

Andon 报警的时效性要求是秒级的,而轮询的问题不只在延迟,还会放大数据库压力:50 个工位每 2 秒查一次,一个车间光轮询 QPS 就很高。GraphQL subscription 将传输通道从“请求-响应”变成“订阅-推送”,服务端在事件发生时主动下发,网络开销和数据库压力都小一个量级。解法上建议直接用graphql-ws协议,老的subscriptions-transport-ws已经停止维护,新项目不要再用。

3.2 服务端如何把事件变成推送流

在 NestJS + TypeScript 的工程里,subscription 的 resolver 层通常依赖 PubSub 把业务事件转发给 GraphQL 执行引擎。以下是一个订阅方法的最小实现:

import { Resolver, Subscription, Mutation, Args } from "@nestjs/graphql"; import { PubSub } from "graphql-subscriptions"; const pubSub = new PubSub(); @Resolver("AndonEvent") export class AndonEventResolver { @Mutation("trip") async trip(@Args("stationId") stationId: string, @Args("message") message: string = "") { const event = { id: `${Date.now()}`, stationId, type: "TRIPPED", message, occurredAt: new Date().toISOString(), }; await pubSub.publish("andonEvent", { onAndonEvent: event }); return event; } @Subscription("onAndonEvent", { filter: (payload, variables) => { if (!variables.types || variables.types.length === 0) return true; return variables.types.includes(payload.onAndonEvent.type); }, }) onAndonEvent() { return pubSub.asyncIterator("andonEvent"); } }

发布逻辑里publish("andonEvent", { onAndonEvent: event })的第二个参数是触发Subscription顶层字段的载荷对象,类型必须是某个字段名,并且 payload 结构要和 schema 中AndonEvent的字段匹配。filter那个函数是这里的关键:客户端可以传["TRIPPED", "ESCALATED"],服务端在推给这个连接之前先过滤,确保班长不接收RESET这类非告警事件。

PubSub在单实例部署时够用,但它默认基于内存EventEmitter,多实例部署时一个实例发布的事件其他实例收不到。生产环境要换成带 Redis 适配器的实现,比如graphql-redis-subscriptions,这是从 demo 走向可扩展的第一个硬门槛。

3.3 前端如何建立订阅连接

浏览器端使用graphql-ws创建 WebSocket 连接,核心代码:

import { createClient } from "graphql-ws"; const client = createClient({ url: "wss://andon.example.com/graphql", connectionParams: { authToken: localStorage.getItem("token") }, retryAttempts: 5, shouldRetry: () => true, }); client.subscribe( { query: ` subscription OnAndonEvent($types: [AndonEventType!]) { onAndonEvent(types: $types) { id type stationId message occurredAt } } `, variables: { types: ["TRIPPED", "ESCALATED"] }, }, { next: (payload) => { pushToBoard(payload.data.onAndonEvent); }, error: (err) => console.error("subscription error", err), complete: () => console.warn("subscription completed"), } );

connectionParams.authToken会随 WebSocket 升级请求发给服务端,服务端从连接上下文里校验身份,不能在 URL 上明文带 token。shouldRetry: () => true表示遇到网络抖动就自动重建连接,但注意配合心跳机制,默认的 keep-alive 间隔由服务端graphql-wskeepAlive参数控制。

3.4 工厂内网部署 WebSocket 的注意点

厂区网络环境往往要经过 Nginx 反向代理,WebSocket 的代理配置需要显式升级协议。Nginx 的 server 块里常见这样一段配置:

location /graphql { proxy_pass http://127.0.0.1:3000/graphql; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 3600s; }

proxy_read_timeout 3600s很关键,默认 60 秒的读超时会让不活跃的 WebSocket 连接被 Nginx 掐断。虽然前端会自动重连,但每次重连都会有短暂的推送空窗。把这个超时调到和客户端心跳一致,能显著减少看板上的断线闪断。生产环境还应在 GraphQL 网关层对 subscription 做鉴权,不能用next函数里简单判断完就忽略。

4. 用 Jest 和 ts-jest 锁住 Andon 状态流转的正确性

4.1 mutations 业务逻辑里的状态校验

mutations.js里最值得测试的不是“存了一条数据”,而是状态流转的合法性。比如trip调用时,工位已经在ALERT又触发TRIPPED,真实现场表现为同一工位反复拉绳,业务上应该返回或忽略,而不是再追加一条重复报警。一个合格的 resolver 会有这样一段逻辑:

async function trip({ stationId, message }, context) { const station = await context.db.stations.findById(stationId); if (!station) throw new Error(`station ${stationId} not found`); if (station.status === "ALERT" || station.status === "DOWNTIME") { return station.lastEvent; // 幂等:已报警不重复触发 } const event = { stationId, type: "TRIPPED", message, occurredAt: new Date().toISOString(), }; await context.db.events.insert(event); await context.db.stations.setStatus(stationId, "ALERT", event.id); return event; }

这段逻辑隐藏了两个业务决策:一是重复报警被当作幂等处理,二是在事件落库之后才更新工位状态,保证事件表和状态表在同一时刻一致。真实项目里这两个操作会包在事务里,避免中途崩溃造成数据不一致。

4.2 jest.config.js 的配置要点

要让 Jest 同时处理.ts测试文件和.js源码,jest.config.js一般这样配置:

module.exports = { preset: "ts-jest", testEnvironment: "node", roots: ["<rootDir>/src", "<rootDir>/test"], testMatch: ["**/*.spec.ts", "**/*.test.ts"], moduleFileExtensions: ["ts", "js", "json", "graphql"], collectCoverageFrom: ["src/**/*.{ts,js}", "!src/**/*.d.ts"], coverageThreshold: { global: { statements: 80, branches: 75, functions: 80, lines: 80 }, }, };

preset: "ts-jest"让 Jest 用 TypeScript 编译器转译测试文件,不需要额外 babel 配置。roots限定查找范围,testMatch明确测试文件后缀。coverageThreshold是我比较看重的一项:覆盖率不达标时 CI 直接失败,避免团队里测试越写越少。对mutations.js里的分支逻辑(比如重复报警那个if),覆盖率阈值能逼着人补充场景。

4.3 状态机的行为测试样例

状态流转逻辑最忌讳只测“happy path”,把acknowledgereset的非法迁移也要覆盖掉。以下是一个集成测试样例:

import { trip, acknowledge, reset } from "../src/mutations"; import { createInMemoryDb } from "../src/db"; describe("Andon station state machine", () => { const db = createInMemoryDb(); beforeEach(async () => { db.reset(); await db.stations.insert({ id: "S1", line: "L1", stationCode: "A-01", status: "RUNNING" }); }); it("should move RUNNING -> ALERT on trip", async () => { const event = await trip({ stationId: "S1", message: "物料卡住" }, { db }); expect(event.type).toBe("TRIPPED"); expect(db.stations.findById("S1").status).toBe("ALERT"); }); it("should not create duplicate trip when already ALERT", async () => { await trip({ stationId: "S1", message: "第一次" }, { db }); const second = await trip({ stationId: "S1", message: "第二次" }, { db }); expect(second.type).toBe("TRIPPED"); expect(db.events.findAll().length).toBe(1); }); it("should reject acknowledge when not in ALERT", async () => { await expect(acknowledge({ eventId: "E999", user: "zhang" }, { db })).rejects.toThrow(/not found/); }); it("should return RESET event and restore RUNNING status", async () => { await trip({ stationId: "S1" }, { db }); const resetEvent = await reset({ stationId: "S1" }, { db }); expect(resetEvent.type).toBe("RESET"); expect(db.stations.findById("S1").status).toBe("RUNNING"); }); });

createInMemoryDb()是常见的测试替身模式,用内存数组代替真实数据库,避免单测依赖 MySQL 或 PostgreSQL。第一和第三个用例分别验证合法迁移和非法迁移:RUNNING -> ALERT合法,ALERT状态下直接acknowledge且事件不存在时会抛错。把状态机测试写好后,后续改成 Redis PubSub 或换数据库时,业务流转部分仍然由这些用例兜底。

5. 部署、探活与把 Andon 事件喂给预测性维护

5.1 Node 服务部署与 GraphQL 探活

部署这套服务时,进程管理要比裸node server.js更稳。常见用 PM2 的 cluster 模式拉起多个实例:

pm2 start dist/main.js --name andon -i max --max-memory-restart 1G

-i max按 CPU 核数创建实例,--max-memory-restart 1G在内存超过 1G 时自动重启。健康检查探针不要用 HTTP 的/health空接口,直接请求 GraphQL 端点并执行一个最小查询更真实:

curl -X POST http://127.0.0.1:3000/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ __typename }"}'

返回{"data":{"__typename":"Query"}}即表示 resolver 能正常响应。这个探活比 ping TCP 端口更严格,数据库连接池出问题时往往就挂在这里了。

5.2 TypeScript 版本升级时的编译器配置排错

从旧版本 TypeScript 升级时,tsconfig 里常出现这个警告:“选项 baseUrl 已弃用,并将停止在 TypeScript 7.0 中运行,请指定 compilerOption”。旧工程用baseUrl指定模块解析根路径,新版本推荐直接用相对路径或把paths拆出来:

{ "compilerOptions": { "target": "ES2022", "module": "commonjs", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "dist" } }

不要为了消除警告把baseUrl硬塞进去继续用。ts-jest 的配置也尽量单独指向一个tsconfig.jest.json,这样构建配置和测试配置各自独立,升级 TS 大版本时测试环境能先暴露问题。如果遇到Type '{}' is missing the following properties的报错,多半是事件对象的类型标注成了空对象,改成AndonEventPayload类型别名即可。

5.3 从 Andon 事件流到预测性维护的过渡

Andon 报警数据本身是离散事件,但把同工位的TRIPPED事件按时间聚合,就能得到“报警间隔序列”。当设备轴承开始劣化时,报警频率会逐渐加快,即相邻TRIPPED事件的时间间隔变短。用一个指数移动平均(EMA)来平滑抖动并提前识别趋势:

function ema(values: number[], alpha = 0.2): number[] { let prev = values[0]; const result: number[] = []; for (let i = 0; i < values.length; i++) { if (i === 0) { result.push(prev); } else { prev = alpha * values[i] + (1 - alpha) * prev; result.push(prev); } } return result; }

这段代码把报警间隔序列变成平滑曲线,alpha取 0.2 表示新值对均值的贡献只有 20%,不容易被单次波动带偏。实践里把每个工位最近 30 次报警间隔算一个 EMA,再和这个工位的历史基线比较,当 EMA 低于基线的 60% 时,就可以提前生成一条预测性维护工单,而不是等设备彻底停机触发TRIPPED。这样 Andon 系统的价值就从“及时响应”提升到了“提前干预”。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询