46 KiB
agent约束.md
内容范围: 仅存放架构约定、代码规范、框架级安全原则等非业务内容。不写入具体设备、工序、场景等业务细节(业务内容归
agent业务.md管理)。无新增架构/规范层面的发现,不强行写入。
本文档禁止agent更改任何内容,禁止人工更改。同架构新项目复刻开发的固定规则。AI 不猜测「能不能做」,只执行「固定约定 + 可修改范围」。人工提前确认所有基础基准文件后交付 AI,AI 仅读取使用、禁止私自修改基准文件内容。
一、新项目启动前,人工必须确认的基础基准文件
| # | 文件 | 说明 | 新项目行为 |
|---|---|---|---|
| 1 | doc/signals.sql |
PLC 全部点位、寄存器地址、数据类型、读写方向 | 人工替换为新设备信号表 |
| 2 | schema/*.go |
Ent ORM 表结构定义(运行 go generate 生成 ent/ 代码) |
人工修改表结构后运行生成 |
| 3 | doc/data.sql |
数据库种子数据(设备类型、设备实例、槽位、配方、工序步骤) | 人工替换为新产线设备/配方 |
| 4 | agent业务.md |
业务规则文档(工艺步骤、调度规则、闭环) | 人工填写工艺步骤和业务规则 |
以上 4 项是 AI 的「只读数据源」,AI 不修改、不新增、不删除其中的任何内容。 Mock 模式是配置项,每个新项目都保留模拟能力。
agent业务.md: 1. 标记的说明,agent 要注释到相关代码里;2. 重要业务节点标记清楚:触发点、流向、结果;3. 业务要闭环:如何从 0 启动、多种收尾情况如何处理 数据库表注释很重要,开发人员会完整维护,agent 也要遵守。
二、业务文档编写约束(agent业务.md 编写规范)
文档定位
agent业务.md 是业务人员视角的文档,描述「产线要做什么」,不描述「代码怎么实现」。
可以写: 产线设备及工序、触发/完成条件、异常处理、业务规则、正常/收尾流程。
禁止写(SQL/schema 已有,不许重复): 设备槽位数、IP、信号地址、设备类型码、exchange/batch/dualGrip 字段值、Go 常量名/包名/函数名、工件/槽位状态机。
编写规则
- OP 是执行单元,非判断单元:描述单设备上的完整操作,不描述条件分支逻辑。
- 条件分支独立标记:路由判断作为步骤间规则描述,由
recipe_step.next_step_default承载。 - 自然语言:不使用代码中的常量名、表名、字段名、信号名。
- 不重复数据:基准文件中已有的信息不重复声明。
三、项目目录结构与包职责(固定,不可变)
spherical/
├── apis/ # API 定义 (.api),go-zero 代码生成源
├── cmd/ # 程序入口(debug、mockrun、engine_mockrun)
├── common/ # 通用工具(errorx、httpx、logx、token)
├── constants/ # 全局常量(槽位状态、工件状态、工序类型)
├── doc/ # 基准数据文件
├── etc/ # 配置文件
├── internal/
│ ├── action/ # 动作定义(RobotAction + Validate)
│ ├── alarm/ # 告警服务
│ ├── config/ # 配置结构体
│ ├── db/ # 数据库连接(Ent + 原生 SQL)
│ ├── engine/ # 全配置化流程引擎(ACQUIRE→EXECUTE→WAIT→COMPLETE,纯执行器)
│ ├── eventbus/ # 事件总线
│ ├── eventlog/ # 事件日志异步批量落库
│ ├── handler/ # HTTP Handler(go-zero 生成)
│ ├── logic/ # HTTP 业务逻辑
│ ├── middleware/ # HTTP 中间件
│ ├── mock/ # Mock PLC
│ ├── plc/ # PLC 连接管理(S7 连接/读写/重连,不含业务逻辑)
│ ├── plcactions/ # PLC 动作执行器(Req→等Done→复位 闭环)
│ ├── preload/ # 预加载(信号地址映射、产品类型)
│ ├── processor/ # 产线核心逻辑
│ │ ├── actor/ # 设备 Actor(锁保护槽位状态机,不写 DB)
│ │ ├── eventloop/ # 事件循环(唯一生产状态写路径)
│ │ ├── scheduler/ # 调度器(三层管道:生成→过滤→排序,只读 DB)
│ │ ├── steplog/ # 步骤日志
│ │ └── tool/ # 工具(扫码枪、激光打标)
│ ├── robot/ # 机器人控制器
│ ├── sse/ # SSE 推送
│ ├── svc/ # ServiceContext 根容器
│ └── upload/ # 文件上传
├── schema/ # Ent 表结构定义(Go 源文件)
├── ent/ # Ent 自动生成(禁止手动修改)
├── spherical.go # Windows 主入口
└── spherical_default.go # macOS/Linux 主入口
四、启动顺序(svc.NewServiceContext 内部,固定)
1. 解析配置 conf.MustLoad(yaml) → config.Config
2. 数据库连接 db.MustNewDB
3. 预加载数据 preload.Init → 信号地址表、产品类型到内存
4. 事件总线 eventbus.NewLocalBus()
5. 告警服务 alarm.NewService
6. SSE 处理器 sse.NewHandler()
7. PLC 连接 plc.Build 或 Mock
8. 机器人管理器 robot.MustNewManager
9. 设备 Actor BuildActors(ctx, entClient) → map[machineID]MachineActor
10. 工单处理器 NewJobProcessor(...)
11. PLC Worker NewPlcWorker(machineActors, plcExecutor)
12. 事件循环 NewProductionEventLoop(...) ← 产线大脑
13. SignalRouter BuildProductionLine(...) → 注入 EventLoop 为 sink
14. 信号轮询 SignalRouter.Run(ctx, 1s) → goroutine
15. 恢复协调器 NewRecoveryCoordinator(...)
16. 总线启动 bus.Start(ctx)
17. 日志写入器 eventLogWriter.Start(ctx)
18. SSE 桥接 sse.NewEventBridge(bus, sseHandler)
19. 事件订阅 bus.Subscribe(SCHEDULE_TICK, ...) → EventLoop.SendScheduleTick()
20. 断电恢复 DBState.RecoverOnStartup(ctx) → 恢复未完成工单
21. Actor 同步 InitActorsFromDB(ctx) → 内存状态对齐数据库
22. 事件循环启动 go eventLoop.Run(ctx) → goroutine
五、运行时信号流与数据流
5.1 PLC 信号 → 设备状态变更(完整链路)
PLC 硬件信号
→ SignalRouter(1s 轮询,上升沿检测)
→ EventLoop.msgCh(串行处理,单一 goroutine)
├── handleMachineSignal → Actor.MarkSlotDone → 投递 MACHINE_DONE
├── handleMachineDone → 推进步骤 / 设置 WAITING_UNLOAD
├── trySchedule → RuntimeSnapshot → Scheduler.ScheduleAll(生成→过滤→排序)
└── dispatchWorker → PlcWorker.Execute(RobotAction)
├── Actor 分配/查找槽位
├── PlcExecutor(写Req→等Done→复位Req→等Done清零)
└── WorkerResult → EventLoop
├── Actor.LoadComplete / UnloadComplete(更新内存)
├── DBState.AdvanceStep(推进工序,持久化)
└── trySchedule(闭环触发下一轮)
5.2 API 请求流程
前端 HTTP → handler 参数校验 → logic 业务逻辑
├── 读操作:直接查询 entClient
└── 写操作:通过 EventLoop 消息 → command_handlers → DBState.xxx()
六、数据库严格定义
6.1 核心约束
- 表定义源:
schema/*.go,运行go generate生成ent/。禁止手动修改ent/ equipment_slot是设备槽位唯一数据源,写操作必须通过 EventLoop → DBState- MachineActor 不直接写 DB,只操作内存状态
- 数据库字段定义清晰,不可随意改动语义或复用。 每个字段有明确的业务含义(如
done_signal_name仅用于 SignalRouter 轮询监听,不可随意写入其他信号名),禁止为临时需求借用已有字段,禁止随意扩大字段用途
6.2 数据库操作分层
| 层级 | 负责 | 位置 |
|---|---|---|
| 只读查询 | 直接使用 entClient |
handler/logic 层 |
| 生产状态写入 | 必须通过 EventLoop → DBState | eventloop/dbstate.go |
| 复杂统计查询 | 原生 SQL + 注释说明用途 | logic 层 |
6.3 设备建模规则(框架级通用,数据驱动)
不区分工站/搬运设备的显式分类字段,由数据驱动,判别优先级从高到低:
| 优先级 | 条件 | 含义 | 行为 |
|---|---|---|---|
| 1 | equipmentType.code = ROBOT |
搬运设备 | 串行互斥,slotCount 为车载暂存容量,不参与 MachineActor |
| 2 | 非 ROBOT + slotCount >= 1 |
工站 | 持有工件,进入 MachineActor 状态机 |
| 3 | 非 ROBOT + slotCount = 0 |
未就绪 | 视为退化/未配置 |
机器人
slotCount > 0但优先级 1 命中,仍为 handler,槽位语义为装载→搬运→卸载(瞬时),由robot.Controller管理。
信号驱动子类型区分(不存储为字段): 有 doneSignalName → 加工/检测工站;无 → 缓冲工站。
硬件能力字段(必须显式存储):
| 字段 | 适用 | 说明 |
|---|---|---|
exchange |
工站 | 支持换料协议:单次到达同时取成品放毛坯 |
batch |
工站 | 批量完成模式:所有槽位同时报完工 |
dualGrip |
搬运设备 | 双夹持能力:配合 exchange 单次往返换料 |
6.4 PLC 信号握手协议(框架级标准)
由 plcactions.PlcExecutor 实现,3 步时序:
1. 写参数字节(如位置号、托盘号),非必须
2. 置位 Req = true(触发 PLC 动作)
3. 返回(不等待 Done,由 SignalRouter 异步监控)
- 命名规范:
{设备名}{动作}Req/{设备名}{动作}Done成对 - 参数命名:
{设备名}{动作}{参数名} - 并发约束:同一设备 Req 不能并发下发,由 EventLoop 串行化保证
6.4.1 PLC 信号硬约束
| # | 规则 | 原因 | 违反后果 |
|---|---|---|---|
| 1 | Go 只写 Req=true,绝不写 Req=false | PLC 程序自行管理信号复位,Go 写 false 会与 PLC 自身逻辑冲突,干扰 PLC 正常运行 | 信号错乱、PLC 状态异常 |
| 2 | 任意两次 PLC 写操作之间必须间隔 ≥ 1 秒 | PLC 扫描周期有限,连续写入可能导致后一次覆盖前一次,或 PLC 漏读上升沿 | 信号丢失 |
| 3 | 所有 done 信号由 SignalRouter 统一异步监控,不在 executor 中同步阻塞等待 | 同步等待超时后无人收信号,PLC 手动发也收不到;统一监控确保所有信号可追溯 | 信号丢失、排障无日志 |
| 4 | 每次 PLC 读写必须打印日志,含信号名、地址、值、成功/失败,无论正常还是异常 | 排障需要完整链路,漏日志等于盲飞 | 排障无依据 |
| 5 | 禁止在 Go 代码中复位任何 PLC 信号(包括 Req 和 Done) | PLC 是信号的主人,Go 只能触发,不能复位 | 与 PLC 程序冲突 |
6.5 调度器(框架级通用)
只读组件,三层管道:
活跃Job → TaskGenerator.Generate() → 候选动作
→ ConstraintFilter.Filter() → 过滤后候选
→ PolicyEngine.Rank() → 排序后候选
→ 取第一个 → dispatchWorker
- 触发时机:定时(60s)、Worker 完成后、信号处理完成后;机器人忙时跳过
- 一次调度只派遣一个动作,派遣前动态分配目标设备
- 并发控制:EventLoop 单 goroutine 串行 + robot.Controller sync.Mutex
6.5.1 约束过滤器设计原则
| # | 规则 | 反模式 |
|---|---|---|
| 1 | 状态输入来自真实系统:buildSystemState 每个字段必须有填充逻辑,禁止仅 make() 后空置 |
ActiveExchanges 空初始化导致 hasExchangePair 永远 false |
| 2 | 降级必须加设备占用守卫:降级(如 Exchange→Load)必须检查目标设备空闲,空闲才降级,否则 continue |
无条件降级导致设备重复塞工件 |
| 3 | 覆盖三种设备状态:A.空闲允许降级 B.有料+可配对走原逻辑 C.有料+无配对丢弃 | 提交修改时需在注释中说明三种场景 |
| 4 | 写入/读取路径格式对齐:多动作类型走同一写入路径时,注释标出格式对齐点 | PositionRefId 格式不一致 |
| 5 | 过滤假设无状态:不依赖「前一次已过滤了竞争候选」,MachineHasJob 从 DB 实时读 |
6.6 数据初始化流程
1. schema/tools/generate.go → 读取 schema/*.go → 生成 ent/
2. schema/tools/migrate.go → 创建/更新数据库表结构
3. schema/tools/data.go signals → 导入 doc/signals.sql
4. schema/tools/data.go seed → 导入 doc/data.sql
| 工具 | 命令 | 输入 | 输出 |
|---|---|---|---|
| generate | go run generate.go |
schema/*.go |
ent/ |
| migrate | go run migrate.go |
ent/ + PG |
数据库表结构 |
| data signals | go run data.go signals |
doc/signals.sql |
signal 表 |
| data seed | go run data.go seed |
doc/data.sql |
equipment/recipe 等表 |
变更 schema 需执行步骤 1→2→3→4;变更 signals.sql 仅需步骤 3;变更 data.sql 仅需步骤 4。
七、架构总纲(框架不可变)
7.1 五大架构原则
| # | 原则 | 含义 | 违反后果 |
|---|---|---|---|
| 1 | 单一写路径 | 所有生产状态变更必须经过 EventLoop → DBState → DB | 数据不一致、并发冲突 |
| 2 | 只读直通 | Scheduler/Logic 直接查询 entClient,读无需经 EventLoop | — |
| 3 | 信号驱动 | 不预排任务,下一步由 PLC 信号或 Worker 结果事件触发实时决策 | 状态脱节 |
| 4 | 串行化并发 | EventLoop 单 goroutine 串行,Robot 互斥锁 | 竞态条件 |
| 5 | 防御式编程 | 外部输入边界校验;函数 error 不许 _ 忽略 |
隐性失败、panic |
7.2 编码架构原则
| # | 原则 | 含义 |
|---|---|---|
| 1 | 类型最小化 | 一个概念一个类型,禁止冗余翻译层 |
| 2 | 校验前置 | 外部输入边界处校验(零值、nil、空字符串) |
| 3 | 错误零容忍 | error 必须日志告警或向上抛出 |
| 4 | 直连不绕路 | 跨包引用直接 import 目标包,不通过中间别名 |
| 5 | map 安全访问 | 加锁或单 goroutine,访问前检查 key 存在性 |
| 6 | 显式结构体优先 | 消息/事件/命令用显式结构体,禁止 map[string]any |
| 7 | 编译期接口验证 | 接口实现必须用 var _ Interface = (*Impl)(nil) 做编译期检查,确保编译期即发现接口不匹配 |
| 8 | 接口路径统一 | 已有接口抽象必须充分使用,禁止并行 switch 分发绕过接口;一个概念只走一条路径 |
7.3 实体关系
| 关系 | 基数 | 约束 |
|---|---|---|
| WorkOrder → Job | 1:N | Job 是工单实例化 |
| Job → Recipe | N:1 | 配方由产品类型决定,运行时不可变 |
| Recipe → RecipeStep | 1:N(有序) | next_step_default 决定流转 |
| Equipment → EquipmentSlot | 1:N | 1 槽位 = 1 原子资源,同持 1 个 Job |
| Equipment → MachineActor | 1:1 | 统一实现,Type 适配 |
| Equipment → PlcExecutor | 1:1 | 设备特有信号握手封装 |
7.4 写入路径约束
| 操作类型 | 路径 | 位置 |
|---|---|---|
| 生产状态写入 | EventLoop → DBState → ent → PG | eventloop/dbstate.go |
| 设备槽位写入 | EventLoop → DBState.SetEquipmentSlot | eventloop/dbstate.go |
| 只读查询 | Logic/Scheduler 直接使用 entClient | logic/、scheduler/ |
| 事件审计 | EventBus → EventLogWriter → ent(异步批量) | eventlog/writer.go |
Actor 不写 DB,Scheduler 不写 DB。只有 EventLoop 能修改生产状态。
八、代码编写强制规范
8.1 依赖注入
- 所有组件通过
New构造函数注入。New只能做字段赋值和必要参数处理,禁止调用实例方法(如m.Init()) - 禁止
SetXxx()后期绑定;结构体依赖字段一律小写私有 - 不允许单独的
Init()方法:初始化逻辑内联在New内 - 依赖注入参数必须用接口类型,不用具体类型:
NewXxx(worker HardwareWorker)✅,NewXxx(worker *PlcWorker)❌ - 依赖图必须为纯树形单向,禁止循环依赖、双向依赖、网状依赖:组件间只能
svcCtx → A → B,不允许A ↔ B互相引用
8.2 context.Context
- ctx 仅存放超时、取消信号、TraceID。禁止 ctx.WithValue 存入常驻对象
8.3 命名规范
- 接收者:单字母;禁止匈牙利命名法(
alarmService不写alarmSvc) - 导出字段:大写开头,完整单词;布尔变量用完整单词(
exists/ok/found,不用exist/isOk) - PLC 信号:
XxxReq/XxxDone成对
8.4 事件订阅
- 事件订阅、handler 映射、路由注册必须在构造函数内完成,禁止跨文件
init()零散注册 - 跨切面通知(告警、SSE 推送)必须通过 EventBus 解耦,禁止组件间直接调用:
bus.Publish(event)✅,alarmService.Notify()❌
8.5 事务
- 多表状态修改必须开启事务(
entClient.Tx()或 DBState 原子方法) AdvanceStep所有 DB 写在同一ent.Tx内完成;FinishJob提取finishJobTx方法接受*ent.Tx,避免嵌套事务
8.6 超时
- 所有阻塞等待逻辑必须配置 Timeout,禁止无限等待
8.7 注释规范
- 常量必须有行内注释;关键业务函数有文档注释(业务场景、执行流程、参数、返回)
- 导出结构体字段有行内注释;注释描述影响,不只写「做什么」
- 禁止无意义注释(函数名已自解释)、禁止过时注释
8.8 日志规范
- 消息格式:中文业务描述 + 英文 key(如
"工件上料失败 jobId:%d") - 关键字段必含:
jobId、orderId、machineId、equipmentName、action、slotNo、stepIndex、signal - 同一工件生命周期通过
jobId串联所有日志;错误日志必须含所有上下文字段
8.8.1 外部通信日志强制规范
所有与外部系统的通信,无论正常还是异常,必须打印日志。 禁止只打错误日志、不打正常日志。
| 外部系统 | 日志必须包含 |
|---|---|
| PLC | 每次读写:信号名、地址、数据类型、写入值/读取值、成功/失败。写 Req 必须打「开始」日志;收到 Done 必须打「结束」日志 |
| MOM | 每次请求/响应:接口名、请求参数、响应内容、成功/失败 |
| AGV | 每次进线/离场:AGV 编号、接驳台编号、时间、成功/失败 |
| 立库/WMS | 每次交互:操作类型、参数、结果、成功/失败 |
日志级别规则:
- 正常操作:
Info - 读取失败/网络错误:
Warn(可重试,非致命) - 业务逻辑错误/数据异常:
Error
8.8.2 日志文件存储规范
- 按自然天切割:每天一个日志文件,命名格式
{基础名}-YYYY-MM-DD.log(如spherical-2026-08-07.log) - 日志文件放独立目录:日志文件必须放
log/子目录,禁止放在 exe 同级目录 - 每天内仍按大小(MaxSize)滚动,保留 MaxBackups 个备份
- 历史日志保留 MaxAge 天,超期自动删除
- 禁止单文件无限增长
8.9 Git 规范(AI 辅助开发)
- AI 只能执行读操作(
status/diff/log/branch/show) - 禁止执行写操作(
add/commit/push/rebase/reset/stash/checkout/restore/clean) - 代码修改只通过 Edit/Write 工具,不通过 Git 回滚
8.10 值对象规范
所有 ID 类型用值对象封装(internal/processor/types/id_types.go),禁止裸 int/string:
| 值对象 | 底层 | 校验 | 方法 |
|---|---|---|---|
JobID |
int |
> 0 |
Valid(), Int(), String(), ParseJobID() |
OrderID |
int |
> 0 |
同上 |
MachineID |
int |
> 0 |
同上 |
SlotNo |
int |
> 0 |
同上 |
StepID |
string |
非空 | Valid(), String(), ParseStepID() |
ProductID |
int |
> 0 |
同上 |
WorkpieceType |
string |
非空 | 同上 |
Valid() 校验时机:外部输入边界、DB 写入前、公共方法入口。
禁止反模式:裸 int 参数、map key 用 int、日志直接输出值对象、跨层传递 int。
8.11 Validate 校验函数规范
- 结构体校验逻辑使用独立函数
ValidateXxx()或ValidateRobotAction(),禁止在结构体上定义Validate()方法 - 原因:
Validate()方法与接口方法(如Validate() error)容易混淆,独立函数名明确校验对象 - 校验必须在派单前、DB 写入前等边界处调用
九、新项目可修改的范围
9.1 基准文件(不可修改,AI 只读)
doc/signals.sql、schema/*.go、doc/data.sql、doc/agent业务.md
9.2 业务逻辑(完全重写)
actor/、eventloop/command_handlers.go、eventloop/candidate_handlers.go、scheduler/filter.go、scheduler/policy.go、scheduler/generator.go、plc_worker.go、plcactions/executor.go、recipe_loader.go、robot/、alarm/、handler/+logic/、sse/
9.3 可微调参数
轮询周期、消息队列长度、日志批量条数、Mock 清理范围、预加载数据类型
十、代码安全要求
- 阻塞调用必须带超时+ctx取消
- error 不许
_忽略,必须日志告警或向上抛出 - 切片/map 取值前判空/长度,下标访问校验索引
- 多协程共享内存加锁或单队列串行
- 文件/连接/句柄/通道defer 关闭
- 外部参数合法性校验(零值/null/空字符串)
- 循环逻辑必须有退出条件或最大重试
- 第三方调用/JSON 解析异常捕获
- 定时任务/异步 goroutine panic recover
- 批量 DB 操作加事务或分批
mustGetAddr等查找函数返回错误必须日志告警+处理,不能 panic 导致程序退出;空地址返回前必须校验
十一、设备动作安全架构(防撞机 / 防误操作硬性约束)
11.1 核心原则
调度层约束过滤器是软保护(依赖 DB,有假阳性风险)。Actor 带锁槽位检查是硬守卫——所有 PLC 使能方法(Load/Exchange/Unload/AirBlow)在调用 Station 前必须过 Actor 守卫。
11.2 场景覆盖矩阵
| 场景 | 危险指令 | Actor 硬守卫 |
|---|---|---|
| 装料到有料设备 | load_to_machine |
AllocateSlot 无空槽即报错 |
| 空磨床换料 | exchange_grinder |
HasEmptySlot()→拒绝 |
| 缓存台没空位放 | load_full_check |
AllocateSlot 遍历全槽 |
| 接驳台没货取 | load_from_dock |
FindSlotByJobID + 槽位一致性 |
| 接驳台有货放成品 | unload_to_dock |
Snapshot 检查目标槽位为空 |
| 磨床空误下换料 | exchange_grinder |
HasEmptySlot()→拒绝 |
11.3 Actor 守卫实现契约
- 装料类:
a.AllocateSlot(act.JobID)带锁遍历空槽,无空槽不下发 PLC - 取料类:
a.FindSlotByJobID(act.JobID)确认工件存在 + 槽位一致(双校验),否则拒绝 - 换料类:
a.HasEmptySlot()==true→ 拒绝(空设备无工件可换) - 放料类:
a.Snapshot()遍历目标槽位,Status != Empty→ 拒绝(防撞料) - 槽位状态比较必须用常量
string(constants.SlotStatus_Empty),禁止""或字面量
11.4 新增动作安全检查清单
| 检查项 |
|---|
□ 是否有 Actor 守卫(w.actors[id] 访问)? |
□ 守卫失败是否 return error 阻断后续? |
□ 装料=调用 AllocateSlot / 取料=调用 FindSlotByJobID / 换料=调用 HasEmptySlot / 放料=检查目标为空? |
□ 槽位比较用 SlotStatus_Empty 常量? |
□ 覆盖 Actor 不存在的情况(if a, ok := w.actors[id]; ok)? |
| □ 错误信息含设备ID+动作类型? |
11.5 三层防御
调度层(软保护)→ Actor 层(硬守卫,不可绕过)→ PLC 层(信号握手)
十二、框架级可排查性原则
| # | 原则 | 含义 |
|---|---|---|
| 1 | 显式结构体取代 map | 跨组件数据用显式结构体(WorkerResultPayload等),禁止 map[string]any |
| 2 | 带校验的解析类型 | 字符串格式封装为带 Valid 的结构体(如 PositionRef),禁止零值表示失败 |
| 3 | 函数单一职责 | 单函数超 80 行必须按动作类型拆分 |
| 4 | 错误日志含原始输入 | slog.Error 必须含触发错误的原始数据 |
| 5 | 防御性 nil 检查 | map 取值检查 nil;外部输入校验有效性 |
十三、框架级安全防护原则
13.1 宕机风险防护
| # | 原则 | 违反后果 |
|---|---|---|
| 1 | 运行时 goroutine 必须 defer recover()(EventLoop.Run、dispatchWorker、EventLogWriter.Run、SignalRouter.Run) |
产线崩溃 |
| 2 | 启动路径 fail-fast 正确(New/RecoverOnStartup/InitActorsFromDB 不要求 recover) |
— |
| 3 | workerBusy 标志必须配套超时自动重置(如 5 分钟) |
调度永久阻塞 |
| 4 | DB 操作必须 context.WithTimeout |
EventLoop 阻塞 |
| 5 | Send() 必须 select + default 非阻塞,满时记告警 |
信号/API 阻塞 |
| 6 | DB 写 error 必须处理,禁止 _ 忽略 |
状态不一致 |
13.2 并发安全
| # | 原则 |
|---|---|
| 1 | goroutine 启动前提取所有共享数据为局部变量 |
| 2 | map 取值必用 v, ok := m[key] |
| 3 | 类型断言必带 ok 检查 |
| 4 | JSON 反序列化 intFromPayload 兼容 int/int64/float64 |
13.3 业务安全
| # | 原则 | 违反后果 |
|---|---|---|
| 1 | handleWorkerResult 必须幂等(记录已处理 taskID) |
重复推进步骤 |
| 2 | Exchange LoadJob 失败必须同步处理(挂起或标记重配对) | LoadJob 卡住 |
| 3 | 槽位分配失败 fail-fast(挂起+报警,禁 fallback 到槽位 1) | 撞料 |
| 4 | Execute 返回 error 禁止调用 LoadComplete/UnloadComplete | Actor 与硬件不一致 |
| 5 | Exchange 状态推进在同一事务内完成 | 断电后 LoadJob 卡住 |
| 6 | 全检台 RequestInspect 用原子标志去重,卸料后重置 | 重复信号 |
| 7 | 全检台容量从 Actor.SlotCount() 或配置获取,不硬编码 | 配置失效 |
| 8 | 槽位状态用 SlotStatus_Empty 常量比较,禁用 "" |
空槽误判为非空 |
十四、业务流程完整性约束
14.1 三阶段核心问题
| 阶段 | 核心问题 | 解决 |
|---|---|---|
| 开始 | 创建工单 DB 更新但 Actor 未同步 | 创建后触发调度;Actor 守卫降级为辅助检查 |
| 中间 | 设备状态变更后 Actor 与 DB 不一致 | EventLoop 串行处理,Worker 结果同步 Actor |
| 结尾 | 工单完成后资源未释放 | 取消时强制释放所有资源 |
14.2 工单开始阶段
创建流程:验证→检查→开事务(创建工单+工件+更新槽位)→提交→发布 SCHEDULE_TICK → SSE 通知。
关键约束:
| # | 约束 | 违反后果 |
|---|---|---|
| 1 | PositionRefId 格式必须为 equipmentId:slotNo |
解析失败 |
| 2 | 创建工单后必须发布 SCHEDULE_TICK 触发调度 |
工件不启动 |
| 3 | Actor 守卫支持降级:!found 但 SrcSlot > 0 时允许执行 + WARN 日志 |
新工单无法启动 |
| 4 | 接驳台槽位号范围校验 | 越界信号错误 |
14.3 工单中间阶段
| 动作 | 执行前检查 | 执行后同步 | 特殊处理 |
|---|---|---|---|
load_from_dock |
FindSlotByJobID | LoadComplete | 新工单 Actor 降级 |
load_to_machine |
AllocateSlot | LoadComplete | 失败 fail-fast |
unload_from_machine |
FindSlotByJobID | UnloadComplete | fail-fast |
exchange_grinder |
HasEmptySlot() | 同步卸料+上料 | 空磨床拒绝 |
unload_to_dock |
检查目标空 | UnloadComplete | 非空拒绝 |
磨床换料约束:空→仅 load_to_machine;有工件→exchange_grinder;最后一件→unload_from_machine。
14.4 工单结尾阶段
- 完成:所有工件 Completed → 释放接驳台+缓存台 → 更新工单状态 → SSE 通知
- 取消:标记 Scrapped → 释放所有设备槽位+缓存台 → 更新状态 → 发布报废事件
资源释放约束:
- 取消必须释放所有设备槽位(磨床/转台/全检台)+ 缓存台占用
- 接驳台卸料完成后
Status=Empty - 工单完成后清理
jobRuntimes
14.5 接驳台槽位索引规范(防 bug)
buildSystemState 查询 equipment_slot 构建 DockSlots map,key 必须用 d.SlotNo,禁止用 d.ID(ID 是自增主键,多设备混合自增会导致 key 错乱)。
十五、数据库字段默认值规范(防坑指南)
15.0 核心口诀
- Nillable 字段(
*int):判 nil → 判 > 0 → 才使用。三步缺一不可。 - 非 Nillable 字段:创建时显式
SetXxx(),不依赖 schema 默认值。 - 枚举字段:永远用
constants.Xxx常量比较,不用""或字面量。
三层默认值各管各的:
Go 代码: 零值 (int=0, string="", *int=nil)
schema: Default() / Optional().Nillable()
PG: INSERT 结果
创建时:不调 SetXxx → schema 默认值生效;调了 → 你设的值生效
读取时:Nillable → *int (nil=NULL);非 Nillable → int (0=0)
头号杀手:*int nil 解引用 → 0 → "0" → 解析失败
二号杀手:忘 SetXxx → 依赖 schema 默认值 → 换库行为变
三号杀手:枚举 "" 当有效值 → 状态判断全错
15.1 关键字段三层对照
| 字段 | Go 类型 | schema 默认值 | Go 零值 | 易错点 |
|---|---|---|---|---|
status (Job) |
JobStatus(string) |
"CREATED" |
"" |
"" 非有效状态 |
positionRefId |
string |
"" |
"" |
禁止写 "0" |
tempSlotNo |
*int |
NULL | nil |
nil 解引用 panic |
dockNo |
*int |
NULL | nil |
同上 |
priority |
int |
2 |
0 |
0=最高优先级,意外抢占 |
currentJobId (slot) |
*int |
NULL | nil |
nil 当 0 → 查询工件ID=0 |
stepType (recipe_step) |
string |
无默认值 | "" |
创建时必设 |
recipeId (product) |
*int |
NULL | nil |
nil 解引用 |
15.2 强制规则
| # | 规则 | 违反后果 |
|---|---|---|
| 1 | *int 字段使用前必须判 nil + 判 > 0 |
panic 或写入 "0" |
| 2 | 禁止将 Nillable 的 Go 零值(0)当业务值 | 解析失败 |
| 3 | 创建时非零值含义字段必须显式 SetXxx() |
换库行为变 |
| 4 | 枚举字段用 constants 常量比较,禁用 "" |
逻辑错误 |
| 5 | 新增字段确认三层默认值一致 | 行为不可预测 |
15.3 安全代码模板
// 模板1:安全读取 Nillable
func safeInt(val *int) int {
if val == nil || *val <= 0 { return 0 }
return *val
}
// 模板2:创建时显式 Set 所有字段
jobCreate := entClient.Job.Create().
SetStatus(constants.JobStatus_Created). // 显式
SetPositionType(constants.PositionType_OnDock).
SetPriority(2).
SetPositionRefId(validOrEmpty).
SetVersion(0)
if dockNo > 0 { jobCreate.SetDockNo(dockNo) } // Nillable 有值才设
// 模板3:枚举比较用常量
if job.Status == constants.JobStatus_Created { ... } // ✅
// if job.Status == "" { ... } // ❌
// 模板4:安全 positionRefId
func buildPosRef(machineID, slotNo int) string {
if machineID <= 0 || slotNo <= 0 { return "" }
return fmt.Sprintf("%d:%d", machineID, slotNo)
}
15.4 创建必设字段清单
Job: WorkOrderId, ProductTypeId, RecipeID, CurrentStepId, Status, PositionType, PositionRefId, Priority, Version(Nillable: DockNo, DockSlotNo, TempSlotNo 有值才设)
WorkOrder: ProductTypeId, Quantity, Status, Source, WorkOrderNo
EquipmentSlot: EquipmentId, SlotNo, Status(Nillable: DockNo 接驳台才设)
15.5 值对象定义一致性原则
值对象注释只在 id_types.go 中唯一确定,业务结构体字段注释只能引用,不重新解释。特殊值(如 0)必须在值对象注释中说明业务含义。
十六、测试约束
16.1 mockrun 测试框架
cmd/mockrun/ 不依赖真实 PLC,使用 Mock PLC 自动回复信号。
# 终端1:go run . -f etc/spherical-api.yaml
# 终端2:go run cmd/mockrun/main.go -debug -scenario round -jobs 12
16.2 场景覆盖
| 场景 | 参数 | 覆盖边界 |
|---|---|---|
round |
-jobs 12 |
圆形件完整流程 |
single_job |
默认 | 单工件端到端 |
square |
-jobs 6 |
方形件含转台换向 |
exchange |
-jobs 4 |
磨床换料 |
cache_reflow |
-jobs 4 |
全检台满→缓存→回流 |
no_material |
默认 | 空接驳台→暂停 |
no_space |
-jobs 4 |
填满→暂停 |
last_piece |
默认 | 单工件收尾:只卸不换 |
覆盖验证:正常流程、换料、收尾、缓存回流、无料/无空间暂停、资源释放、并发安全。
16.3 验证项(-verify=true)
工单状态 COMPLETED、工件完成数、设备槽位 EMPTY、接驳台成品数量。
16.4 测试数据要求
从 data.sql 初始化,Mock 自动清空旧数据,每种产品类型至少 1 工单,每工单 ≥ 3 工件,缓存回流 ≥ 8,无空间 ≥ 13。
16.5 通过标准
✅ 工单: COMPLETED ✅ 工件: N完成 0报废 ✅ 设备槽位 EMPTY ✅ 接驳台成品到位
十七、positionRefId 字段决策
- 状态:
job表String字段,存储如"1:42";业务已用独立字段解耦,不再依赖解析 - 决策:保留,降级为展示/辅助字段(删除成本高、有调试价值、无危害)
- 规则:
- 它是只写展示字段,禁止业务逻辑解析它
- 需要位置信息时用
positionType+dockNo/dockSlotNo/tempSlotNo - 写入格式
"设备ID:槽位号"或空字符串,禁止写入"0"
十八、业务并发与设备互斥占用机制
18.1 双层防护模型
内部并发(机器人动作、调度、信号处理)
└── EventLoop 单 goroutine 串行化处理
├── workerBusy 原子标志 → 防止并发派发 Worker 动作
├── Actor sync.Mutex → 保护单个设备槽位状态机
└── goroutine 启动前快照提取 → 防止共享内存并发读写
外部并发(HTTP API、AGV 回调)
└── equipment.occupied 字段 → 外部入口即时校验
├── 被占用 → 立即拒绝,不投递消息到 EventLoop
└── 空闲 → 先标记占用 → 再投递消息
核心原则:EventLoop 串行化处理内部并发;equipment.occupied 字段处理外部并发。两者互补,不可互相替代。
18.2 equipment 表占用字段
ALTER TABLE equipment ADD COLUMN occupied BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE equipment ADD COLUMN occupied_by VARCHAR(20); -- ROBOT / AGV / MANUAL
ALTER TABLE equipment ADD COLUMN occupied_reason VARCHAR(100);
ALTER TABLE equipment ADD COLUMN occupied_at TIMESTAMPTZ;
ALTER TABLE equipment ADD COLUMN occupied_by_operator VARCHAR(100);
仅接驳台需要互斥占用。 缓存台、磨床、转向台等只有一个机器人操作,EventLoop 串行已保证安全。
18.3 互斥规则
| 当前占用 | 机器人取放 | AGV 进线 | AGV 离场 | 人工操作 |
|---|---|---|---|---|
| IDLE | 允许 | 允许 | 允许 | 允许 |
| ROBOT | — | 拒绝 | 允许 | 拒绝 |
| AGV | 拒绝 | 拒绝 | 允许 | 拒绝 |
| MANUAL | 拒绝 | 拒绝 | 允许 | 允许(解锁) |
AGV 离场不受任何占用影响。
18.4 读写约束
| 操作 | 路径 | 说明 |
|---|---|---|
| 读取(调度器过滤) | buildSystemState → EquipmentOccupied map |
从 DB 读,仅对接驳台 |
| 读取(外部入口校验) | HTTP handler 直接查 entClient.Equipment.Get() |
投递消息前校验 |
| 写入(外部入口) | HTTP handler 先写 DB 再投递消息 | 防止投递排队期间的竞态 |
| 写入(内部) | EventLoop 消息处理中写 DB | 串行化保证安全 |
| 清除 | EventLoop 消息处理中写 DB | 统一清理 |
18.5 占用常量
const (
OccupiedBy_Robot = "ROBOT" // 机器人取放(仅接驳台相关动作)
OccupiedBy_AGV = "AGV" // AGV 进线
OccupiedBy_Manual = "MANUAL" // 人工占用(故障排查/维护,仅解锁操作)
)
18.6 与现有并发机制的配合
| 现有机制 | 配合方式 |
|---|---|
| EventLoop 串行处理 | 占用字段作为消息处理输入之一 |
workerBusy 原子标志 |
忙时不调度 + 占用字段额外过滤 |
Actor sync.Mutex |
保护槽位状态机,与占用字段互不干扰 |
dispatchWorker goroutine |
快照提取机制不变 |
十九、全配置化流程引擎(engine 包)
19.1 引擎定位
internal/engine/ 是纯执行器,不包含任何设备类型或工序知识。所有业务逻辑通过数据库配置驱动。
与 EventLoop 的关系:EventLoop 保留为消息路由器,负责接收外部消息(HTTP/PLC/Worker)→ 更新 DB 事实 → 调用 Engine.TryAdvanceJob 推进 job 步骤。Engine 不直接处理 HTTP 消息或 PLC 信号,只通过 EventLoop 调用或 SignalRouter 回调触发。
19.2 引擎核心循环(4 阶段)
ACQUIRE(获取资源)→ EXECUTE(发送信号)→ WAIT(等待完成)→ COMPLETE(后置操作)
| 阶段 | 读取配置表 | 操作 |
|---|---|---|
| ACQUIRE | recipe_step_resource |
SELECT FOR UPDATE 事务原子获取资源(robot/temp_slot 锁、设备槽位;{dock_slot} 解析 job 绑定槽位);决策步骤直接进入 DECISION 处理 |
| EXECUTE | recipe_step_signal |
按 sort_order 发送 param/req 信号到 PLC;信号间按 wait_after_ms 等待(毫秒,0=不等待) |
| WAIT | recipe_step_signal(done 角色) |
SignalRouter 异步监控 Done 信号,引擎不主动轮询 |
| COMPLETE | recipe_step_action |
释放资源、设置设备槽位状态、发送后置信号、步骤跳转;步骤切换时死循环防护计数(见 19.4-4) |
19.3 引擎文件职责
| 文件 | 职责 |
|---|---|
engine.go |
核心引擎,TryAdvanceJob 主循环,AlarmService 接口 |
acquirer.go |
资源获取,SELECT FOR UPDATE 事务,死循环检测+报警 |
executor.go |
信号发送,按 sort_order 写入 PLC,失败报警 |
completer.go |
后置操作执行,资源释放/状态设置/跳转,Done 信号匹配 |
decision.go |
决策步骤处理,按 priority 匹配条件分支 |
db_ops.go |
数据库操作封装,加载配置、读写资源 |
adapter.go |
I/O 层适配器,桥接引擎与 SignalRouter/PLC |
types.go |
类型定义,Job/JobStep/StepSignal/StepAction 等 |
19.4 引擎约束
| # | 约束 | 说明 |
|---|---|---|
| 1 | 引擎不包含业务知识 | 不知道磨床、接驳台、全检台的区别,只关心资源获取 |
| 2 | 资源获取原子性 | SELECT FOR UPDATE 事务,全部成功或全部回滚 |
| 3 | 快照隔离 | job_step 从 recipe 复制,recipe 变更不影响运行中 job |
| 4 | 死循环防护 | total_step_count 在步骤切换时(advanceToNextStep/jumpToStep 事务内)+1,超限(>= max_step_count,默认 200)→ job.status = ERROR + 报警。资源等待/决策等待不计数(如缓存台回流、无料暂停属正常阻塞),防止正常等待被误判为死循环 |
| 5 | 事件驱动触发 | Done 信号到达、资源释放时触发 tryAdvance,非轮询 |
| 6 | held_by_job_id | 支持同一 job 跨步骤持有资源(如机器人持料过清洗) |
| 7 | 日志规范 | 所有步骤日志包含 jobId/stepId/stepName/equipmentType,使用 slog 包 |
| 8 | 报警规范 | 所有不可恢复错误(死循环、PLC 写入失败、DB 写入失败、Done 标记失败)必须创建 alarm 记录 |
19.5 引擎报警编码
| alarmCode | 触发条件 | 级别 |
|---|---|---|
STEP_LOOP_DETECTED |
步骤切换计数超限(total_step_count >= max_step_count) | ERROR |
PLC_WRITE_FAILED |
写入 param/req 信号到 PLC 失败 | ERROR |
DB_WRITE_FAILED |
数据库写入失败(更新 job_step 状态、事务提交) | ERROR |
DONE_SIGNAL_MARK_FAILED |
标记 Done 信号失败 | ERROR |
19.6 引擎测试
go run cmd/engine_mockrun/main.go -scenario single # 单工件完整流程
go run cmd/engine_mockrun/main.go -scenario concurrent -jobs 4 # 并发资源竞争
go run cmd/engine_mockrun/main.go -scenario decision -jobs 8 # 决策分支
go run cmd/engine_mockrun/main.go -scenario square -jobs 2 # 配油盘流程
19.7 数据库迁移
新引擎表结构通过 doc/engine_schema.sql 管理,data.exe reset-all 自动执行。配方种子数据在 doc/new_recipe.sql。
19.8 与 EventLoop 的调用关系
EventLoop(消息路由器) Engine(纯执行器)
│ │
├── 新 Job 创建 ──────────────→ TryAdvanceJob(jobID)
├── PLC Done 信号 ────────────→ OnDoneSignal(signalName)
│ └── TryAdvanceJob(affectedJobIDs)
├── 资源释放事件 ─────────────→ OnResourceReleased(type, key)
│ └── 唤醒等待资源的 pending job
└── 调度 Tick ────────────────→ TryAdvanceJob(pendingJobIDs)
EventLoop 不再包含业务逻辑(if 磨床/接驳台/全检台),只负责消息路由和 DB 事实更新。
19.9 洋葱结构(架构分层,依赖单向不可循环)
整个系统按"洋葱"分层,每一层只允许依赖内层,禁止反向依赖或跨层调用。这一结构是"不能死循环"的架构级保证:依赖单向 → 无循环依赖;写路径唯一 → 无并发写冲突。
┌─────────────────────────────────────────────────────┐
│ 第 0 层:输入源(HTTP API / MOM 轮询 / PLC 信号) │ ← 外部世界
├─────────────────────────────────────────────────────┤
│ 第 1 层:装配层 svc.NewServiceContext │ ← 依赖注入,只做组装,无业务逻辑
├─────────────────────────────────────────────────────┤
│ 第 2 层:业务处理(handler → logic) │ ← 无状态,校验输入 → 投递命令
├─────────────────────────────────────────────────────┤
│ 第 3 层:EventLoop(消息路由器 + 唯一写路径) │ ← 串行处理所有消息,更新 DB 事实
│ · 单 goroutine 消费消息通道(丢满即丢弃+告警) │
│ · 所有 DB 状态变更必须经过这里 │
│ · 引擎触发事件(Done/资源释放)回流到这里 │
├─────────────────────────────────────────────────────┤
│ 第 4 层:Engine(纯执行器,零业务知识) │ ← ACQUIRE→EXECUTE→WAIT→COMPLETE
│ · 事务原子性(SELECT FOR UPDATE) │
│ · 快照隔离(job_step 从 recipe 复制) │
│ · 死循环防护(步骤切换计数) │
├─────────────────────────────────────────────────────┤
│ 第 5 层:DB(唯一事实源) + PLC I/O(PlcExecutor) │ ← 最内层,被所有上层依赖
└─────────────────────────────────────────────────────┘
分层规则(不可违反):
- 第 3 层 EventLoop 是唯一生产状态写路径:HTTP/PLC/Worker 消息全部汇入消息通道,串行处理,禁止在引擎之外直接写 job/job_step/equipment_slot 状态。
- 第 4 层 Engine 不持有任何设备/工序知识,只读配置子表执行四阶段循环;引擎不直接持有 goplc.Client,I/O 经
EngineSignalAdapter → PlcExecutor安全链路。 - 依赖方向严格单向:Handler→EventLoop→Engine→DB/PLC。禁止 Engine 回调 Handler、禁止 EventLoop 外的模块直接调用引擎内部方法。
- 引擎触发外部调度只能通过
EventTrigger接口(事件总线发布 → EventLoop 订阅 → trySchedule),形成闭环但不产生依赖环。
"保证不能死循环"的三道防线:
- 步骤切换计数:
total_step_count在步骤推进/跳转的事务内 +1,超限 → job=ERROR +STEP_LOOP_DETECTED报警(防配置错误导致的 A→B→A→B 循环)。 - 等待不计数:资源获取失败、决策分支无匹配均属正常阻塞等待,不累计步骤数(防全检台满→缓存回流的正常等待被误杀)。
- 写路径唯一:EventLoop 单 goroutine 串行,任何时刻一个 job 只有一个推进上下文,不可能出现两个 goroutine 同时推进同一 job 形成的活锁/重复加工。
二十、本地开发环境约束(AI 工具轻量级运行)
开发机为 macOS。执行任何任务必须优先轻量级运行,防止全量构建/全量测试卡死开发机。
- 禁止全量
go build:本地验证不需要编译整个项目时,不得执行全量go build(本项目依赖重,全量构建会卡死开发机)。 - 禁止无差别全量测试:不得执行
go test ./...全量跑测试;只跑改动相关包的测试(如go test ./internal/engine/)。 - 优先轻量验证:验证改动用
go run <具体命令>(只编译该命令的依赖闭包,如go run cmd/engine_mockrun/main.go)或go test <指定包>。 - 本地跑不需要构建产物就不构建:本地开发、回归验证一律
go run直跑;只有打包发布(build.sh/build.bat)才需要真正构建。