21 · 常见问题与坑
相关:frontend/NOTES.md(前端坑 §1…§46)· docs/前后端RPC对齐审计-给后端.md(已知缺口)
这一章是什么
踩过的坑集中放这里。 按"你看到什么现象"来查。
每条的格式是:症状 → 原因 → 怎么修。
前端/CSS 相关的坑在
frontend/NOTES.md,不在这里。这一章只收后端与程序集相关的。
一、⭐ 五个最容易踩的坑
坑 1 · 改了东西但没生效,而且返回 ok: true
症状:调了 add_tag / set_element_props,返回 {ok: true},但打开工程一看什么都没变。
原因:RPC 参数的命名风格搞错了,反序列化时静默产出空对象。
顶层参数 → camelCase 如 channelId、elementName
嵌套的工程模型 → PascalCase 如 device、config、props 里的 Name、Width
怎么修:记住这条边界。
// ✅ 对
{ "channelId": 0, "device": { "Name": "1号炉", "StationNo": 1 } }
// ❌ 错:channelId 写成 ChannelId,device 里写成 name
{ "ChannelId": 0, "device": { "name": "1号炉" } }
⚠ 最坑的地方是它不报错。空对象也是合法的
DeviceDef—— 只是字段全是默认值。
坑 2 · Save() 报"点 Id 必须从 0 连续无重复"
症状:批量加点后保存失败,说 Id 有断裂或重复。
原因:实时库用 Id 当数组下标,所以 Id 必须 0、1、2… 连续。 你自己指定了 Id(比如从 100 开始编),或者删点后没重排。
怎么修:
- 不要自己指定 Id ——
AddTag会自动取当前点数 - 不要长期保存 Id ——
RemoveTag会重排剩余点的 Id - 引用点一律用点名 —— 绑定、配方、报警配置全是按名引用的,不受重排影响
⚠ 全局变量和采集点共用同一个 Id 序列。所以加内部变量也算进连续性里。
坑 3 · set_element_props 说"该元素无此可写属性"
症状:
失败:FontWeight(该元素无此可写属性);TextAlignment(…)
原因:属性名写错了。set_element_props 是按名反射写入的,
名字不对就静默跳过 —— 或者回一句"没有这个属性"。
真名是什么:
| 你以为的 | 真名 |
|---|---|
FontWeight |
Bold |
TextAlignment |
TextAlign |
BackgroundColor |
Background |
ForegroundColor |
Foreground |
怎么修:改之前先调 describe_element_props(type, elementName? 拿真名。
⚠ 大小写差异是合法的(比较时忽略大小写),所以
Fontsize也能用。 但"词不一样"(FontWeight ≠ Bold)就是错的。真源在前端
frontend/src/editor/propSchema.js。
坑 4 · check_script / build_project 返回"编译器未接入"
症状:脚本校验用不了,一直说编译器没接。
原因:打开工程后忘了调 AttachCompiler。
怎么修:
var auto = ProjectAutomation.Open(dir);
auto.AttachCompiler(new RoslynScriptChecker(auto.Loaded)); // ← 别忘
⚠ 换工程后要重挂。
LoadProject/CreateProject成功会把内部编译器置空 (旧编译器指向旧工程)。所以:
auto.ProjectSwitched += dir =>
auto.AttachCompiler(new RoslynScriptChecker(auto.Loaded));
坑 5 · 调用返回了失败,但我没看
症状:程序跑完了,什么都没发生。
原因:AutomationResult / AuthResult 都是失败不抛异常的设计 ——
失败信息在 Message 里,Ok 是 false。不看就等于没调。
怎么修:每个调用都查。
var r = auto.AddTag(tag);
if (!r.Ok) { Console.WriteLine($"加点失败:{r.Message}"); return; }
为什么要这么设计:批量操作时抛异常会中断整批。返回失败让你能逐条处理、继续往下走。 但代价就是"必须自己查"。
二、脚本相关的坑
坑 6 · 脚本写入被拦,抛 InvalidOperationException
症状:脚本里 Tag.Write("某个采集点", 1) 报错。
原因:RuntimeWriteGate —— 脚本只放行内部软件变量,
采集点 / PLC 点的写入一律被拦下。
怎么修:这是故意的安全设计。要改 PLC 值,走运行态的写值通道(有权限校验 + 审计), 不要在脚本里写。
判断依据:点是
IsInternal还是采集点。
坑 7 · 脚本抛 ScriptApiUnavailableException
症状:脚本里调 Ui.OpenScreen(...) 或 Db.Query(...),抛这个异常。
原因:宿主没接上这个能力。
怎么修:这不是你的脚本写错了 —— 是装配问题。
| 库 | 需要什么 |
|---|---|
Ui |
运行态界面服务 |
Db |
数据库子系统 |
Alarm / History |
报警 / 历史服务 |
用 RuntimeDataServices.Compose(...) 装配时把对应服务带上。
坑 8 · 元素事件名写了但不生效
症状:给元素配了一个事件脚本,名字写了但没跑。
原因:元素事件是 12 个固定字面量,写别的会被拒或静默无效。
Click DoubleClick MouseDown MouseUp
RightClick RightDoubleClick RightMouseDown RightMouseUp
MouseEnter MouseHover MouseMove MouseLeave
怎么修:照上面 12 个写。
⚠ 前四个是"左键",名字是历史遗留的(没有
Left前缀)。别改成LeftClick。真源:
frontend/src/ide/assemblyWorkspace.js的ELEMENT_EVENTS; 后端ElementEvents.cs;契约 06 §3.3。三处要一致。
坑 9 · 窗口变量的名字对不上
症状:AddScreenVariable("主画面", "选中项") 之后,读 "选中项" 读不到。
原因:窗口变量落库时自动加了窗口前缀,真名是 主画面.选中项。
怎么修:读的时候写全名,或者用 ScreenVariables("主画面") 拿真名。
为什么要加前缀:实时库的点名必须全局唯一。两张画面各有"选中项"时不加前缀就撞了。
三、数据与配置的坑
坑 10 · 报警/历史"配了但没生效"
症状:SetAlarm 成功,但运行时不报警。
排查顺序:
| 查什么 | 说明 |
|---|---|
| 模拟量配了级别吗 | 配了才检测。只配 Hi 没配 HiHi,超 HiHi 也只按 Hi 报 |
开关量配了 Digital 吗 |
开关量要单独配 |
| 延时是不是太长 | DelaySeconds 是"持续超限多少秒才算"。设了 30 就要等 30 秒。⚠ 是秒不是毫秒 |
| 回差是不是太大 | Deadband 太大时,超了之后很快又落回,可能不触发 |
历史是 Timed 模式吗 |
Timed 模式下周期(TimedIntervalMs)> 0 才记 |
顺带对一遍字段名(AlarmConfigModel 上最容易写错的三个):
| 写错 | 正确 |
|---|---|
HiHi = 120 |
HiHi = new AnalogLimitModel { Limit = 120 } —— 是对象不是数字 |
DelayMs = 3000 |
DelaySeconds = 3 —— 秒,且没有 DelayMs 这个字段 |
Level = "Warning" |
Level = "Warn" —— 枚举里没有 Warning |
坑 11 · 历史库数据不全 / 少了点
症状:查历史发现数据比预期少。
可能原因:
| 原因 | 说明 |
|---|---|
OnChange 模式 + 死区太大 |
变化小于死区不记 |
| 攒批还没落盘 | SqliteHistorian 攒 1000 条或 1 秒才写(BatchSize / FlushIntervalMs)。刚发生的数据可能还在内存队列里 |
| 保留天数到了被清理 | RetentionSweeper 会删过期数据 |
| 抽稀了 | 查历史时传了 maxPoints,会被抽稀。看 Truncated 字段 |
⭐
Truncated = true意思是"数据被抽过了" —— 别以为查到的就是全部。
坑 12 · 界面上的实时值会跳变
症状:界面上数值偶尔跳过几个值。
原因:订阅推送的队列满了会 DropOldest(丢最老的)。
怎么修:这是故意的 —— 实时值保最新才有意义。
但要注意:别靠订阅攒趋势数据。要完整的曲线数据,查历史库。
坑 13 · 订阅收不到值
症状:sub 发过去了,但没有 tag-update 事件。
排查:
| 查什么 | 说明 |
|---|---|
| 点名对不对 | 用全名(窗口变量带前缀) |
| 时间戳格式 | ts 是毫秒 epoch。别当秒或 tick 用 |
value 的类型 |
模拟量是 double,离散量是 long。别假设一定是小数 |
| 消息发早了 | WebView2 还没就绪时消息会被静默丢弃。等 EnsureCoreWebView2Async 完成 |
四、扩展能力时的坑
坑 14 · ⭐ 加了能力但"某个出口"没生效
症状:set_element_props 在前端能用,但 AI 通过 MCP 调就失败。
原因:同一个能力有多个出口,漏改了一个。
以 describe_element_props 为例,要改的地方:
| # | 位置 |
|---|---|
| 1 | Hmi.Automation —— 接口 + 实现 |
| 2 | Hmi.HostBridge —— AutomationRpcDispatcher 的 RPC 名 |
| 3 | Hmi.Mcp.Host —— ProjectMcpTools 的工具方法 |
| 4 | 前端 —— propSchema.js + mock 的 ELEMENT_TYPES |
| 5 | 文档 —— 本手册 + docs/ 相关文档 |
⭐ 漏改不会报错,只会静默不生效。这是这个项目最常见的坑。
坑 15 · 加了新协议但前端下拉里没有
症状:后端加了协议,前端选不到。
原因:协议清单有两份手抄:
| 位置 | 内容 |
|---|---|
| 后端 | BuiltInRegistry |
| 前端 mock | MOCK_PROTOCOLS |
有测试 test/channels.mjs 跨语言钉死。
怎么修:两边一起改,改完跑 test/channels.mjs。
坑 16 · 加了新图元但显示成灰色不可用
症状:图元库里某个控件是灰的(以为"后端还没支持")。
原因:图元库的级别标记必须与后端实况一致。
core = 后端已注册 [JsonDerivedType]。
⚠ 别用
'ext'标"待后端" —— 曾经把 31 个能正常用的控件显示成灰名。
怎么修:标记要和后端实况对。守卫测试 SampleElementRoundTripTests 会双向断言。
⭐ 改完能力记得回头改文案(标记 / tooltip 最容易漂)。
坑 17 · MCP 工具改了但文档没改 → 测试红
症状:加了 MCP 工具,测试报"文档计数对不上"。
原因:docs/MCP服务器-外部AI连接说明.md 里那句"共 N 个工具"被测试直读核对。
怎么修:改工具必须同步改文档里的那句话。
⚠ 别删那句话 —— 删了计数钉就失效了,测试会报"找不到「共 N 个工具」"。
坑 18 · 加 MCP 工具忘了写 [Description] → 测试红
症状:测试报"这些 MCP 工具没有 [Description]"。
原因:MCP 的提示词全靠 [Description] ——
工具级 + 每个参数级,覆盖率要求 100%。
为什么这么严:缺一个,外部 AI 就不知道那个参数填什么,只能瞎猜。
坑 19 · 改权限位数值 → 所有角色的权限悄悄变了
症状:改了一个权限枚举值,现场账号权限全乱。
原因:权限值存进数据库了。改旧值 = 把所有已有角色的权限改掉。
怎么修:新增权限往后加一个 2 的幂,绝不改旧值。
⚠ 注意
Permission.All是"所有已定义位的并" —— 新增权限位会被All自动包含。
坑 20 · 驱动插件加载失败
症状:自己写的协议插件放进去,加载不了。
排查:
| 查什么 | 说明 |
|---|---|
plugin.json 字段齐吗 |
id / displayName / version / abi / entryAssembly |
abi 版本对吗 |
ProtocolAbi.Version = 1。不匹配会拒载 |
| 依赖引对了吗 | 只该引 Hmi.Core + Drivers.Abstractions |
entryAssembly 名字对吗 |
要和 dll 文件名一致 |
每个插件包在独立的
AssemblyLoadContext里加载,所以依赖不会互相污染 —— 但也意味着别指望宿主里的类型能直接共享。
五、宿主与进程的坑
坑 21 · 运行内核起不来,一直等
症状:父进程等就绪信号,一直等不到。
原因:就绪判定是"stdout 打恰好一行 RUNTIMECORE READY"。
如果前面多打了别的东西(某个库的欢迎语、调试输出),父进程可能认不出来。
怎么修:检查有没有多余的 stdout 输出。日志走 stderr。
坑 22 · MCP 服务器参数写了但没生效
症状:传了 --read-only,但写工具还是能用。
原因:两个可能:
--read-only只在 http 模式生效 —— stdio 模式全量暴露- 参数写错了不报错 —— 解析很宽松,不认识的 flag 静默忽略
还有:
| 你以为的 | 实际 |
|---|---|
有 --help |
没有 |
--stdio 能显式选 stdio |
不是真参数,被忽略。默认就是 stdio |
| 端口填错了会报错 | 宽松回落默认值 8765 |
坑 23 · 子进程崩了留下一堆孤儿
症状:主程序退出后,还有子进程在跑。
原因:只杀了父进程。
怎么修:用 Kill(entireProcessTree: true) —— ProcessSupervisor 内部就是这么做的。
另外
RuntimeCore.Host有个自保机制:stdin 读到 EOF 就自己退。 父进程一关,stdin 断了,子进程跟着走。
坑 24 · 运行态日志没显示在输出面板
症状:运行内核的日志看不到。
原因:日志是通过 stdout + 哨兵前缀 传回来的:
##RTLOG##{"level":"warn","text":"…","source":"…","ts":…}
父进程要按行读、判前缀、拆 JSON、推事件。少一步就看不到。
怎么修:检查 RuntimeLogWire.Sentinel 有没有被改;
父进程的解析逻辑是不是还在。
⚠ 哨兵常量只该有一处定义,别在两处各写一遍字面量。
六、安全相关的坑
坑 25 · 把 UserAccount 整个发给了前端
症状:前端收到的账号列表里带 PasswordHash / PasswordSalt。
原因:直接序列化了 UserAccount。
怎么修:挑白名单字段,或者用 LoginCandidate(只有 3 个字段,结构上不可能泄漏)。
⚠ 契约 07 明令禁止把哈希发出去。
坑 26 · 运行态忘了关 configMode
症状:操作工登录后能改一切。
原因:AuthService 构造时传了 configMode: true。
怎么修:
| 进程 | configMode |
|---|---|
| 编辑器 | true(开发者本来就有完全访问权) |
运行态壳(--runtime) |
false ⚠ |
⚠ 这个搞反了是严重的安全问题。好在两者是不同进程,运行态壳自己构造
AuthService, 不会串 —— 但别在编辑器代码里把true写成默认。
坑 27 · 改别人的口令没验旧口令就成功了
症状:管理员能直接改他人口令,不用知道旧的。
原因:这是设计如此。
| 改谁的 | 要验旧口令吗 |
|---|---|
| 自己 | ✅ 要 |
| 别人(管理员) | ❌ 不要,但要 ManageUsers + 目标等级 < 自己 |
管理员重置口令是正常运维需求。但改自己必须验旧口令 —— 防止有人趁你离开电脑改密码。
七、构建与运行环境
坑 28 · dotnet build 报目标框架不匹配
症状:引用 Hmi.Shell 时报错。
原因:Hmi.Shell 是 net8.0-windows(唯一一个,因为要 WPF),
其它全是 net8.0。
怎么修:引它的项目也要是 net8.0-windows。
坑 29 · 改了代码但跑起来还是老行为
症状:明明改了,行为没变。
排查:
| 查什么 | 说明 |
|---|---|
| 编译了吗 | 跑 打包发布.cmd 或 run-shell.cmd(后者每次都增量编译) |
| 前端缓存 | WebView2 有缓存,必要时清一下 |
| 改的是不是真源 | ⚠ 有些能力有多个出口,你改的那个可能不是生效的那个(见坑 14) |
八、⭐ 已知缺口(核查于 2026-09-26)
~~缺口 1 · MCP 状态面板的工具数显示偏小~~ ✅ 2026-09-26 已修
| 位置 | src/Hmi.HostBridge/McpControlRpcDispatcher.cs |
| 原现象 | ToolCatalogSize = 79,但 ProjectMcpTools 实际有 84 个工具。没配 ExposeTools 时,组态器「MCP 服务器」面板会显示 79 |
| 修法 | 常量改成 84;并在 tests/Hmi.Mcp.Host.Tests/McpToolShapeTests.cs 加了一条守卫(反射读常量 vs 反射数工具方法),回退验证过会红 |
| 为什么会漂 | 工具数散落在多处(文档里、常量里)。文档那份有测试钉住,常量这份没有 —— 加工具时文档跟着改了,常量没跟着改。现在两份都钉住了 |
教训:同一个数字被抄到两处,就一定要有两处钉子。只钉一处,另一处必然漂。
缺口 2 · 运行态写值类 RPC 尚未开通
| 方法名 | 状态 |
|---|---|
runtime_write_tag |
明确回"阶段六未开通" |
download_recipe / capture_recipe |
同上 |
report_get / report_record_click |
同上 |
这是有意留的边界 —— 写 PLC 有物理后果,要单独走带确认的通道。
缺口 3 · 脚本类 RPC 路由
前端记录里有"脚本类 RPC 后端尚未路由(11 个方法)"的条目。
核对了一下:现在 AutomationRpcDispatcher 已经覆盖了脚本相关的方法
(check_script / build_project / get_project_scripts / set_screen_scripts / list_modules …)。
所以这条应该已经补上了 —— 但前端 NOTES.md 的措辞可能还没更新。
九、遇到问题时的排查顺序
① 返回 Ok 了吗?
└─ 否 → 看 Message,那里面写了原因
└─ 是但没生效 → 坑 1(命名风格)/ 坑 2(Id 连续性)
② 是"调不通"还是"调通了但结果不对"?
└─ 调不通 → 检查方法名拼写、参数形状、有没有 AttachCompiler
└─ 结果不对 → 检查是不是漏改了某个出口(坑 14)
③ 跑测试
└─ 前端:node frontend/test/run.mjs
└─ MCP 自检:node tools/mcp-probe.mjs
└─ 后端:dotnet test
④ 还是找不到 → 翻 frontend/NOTES.md 和 docs/ 下的相关文档
十、加新能力时的自检清单
加完一个能力,逐条对一遍:
- [ ]
IProjectAutomation接口 + 实现都加了吗 - [ ]
AutomationRpcDispatcher里加了 RPC 名吗 - [ ]
ProjectMcpTools里加了工具方法吗 - [ ] 工具级
[Description]写了吗 - [ ] 每个参数的
[Description]都写了吗 - [ ] MCP 文档里的工具计数改了吗
- [ ] 前端
propSchema.js/ mock 桥跟上了吗 - [ ] 有测试能证明它工作吗(而且回退会红)
- [ ] 本手册相关章节改了吗
- [ ]
docs/更新日志.md记了吗
⭐ 最后一条尤其容易漏:这个项目有"同一能力多出口"的传统, 漏一个不报错、只静默不生效。
发现新的坑就往这里加。每条都写清"症状 → 原因 → 怎么修" —— 只写"注意 XXX"没有用,下次还是会踩。