ARTICLE · 1112019
老树开花,0 AI,MFC+lua,RPA跨机任务编排上百台机器全自动干活
自动化一定要上 AI 吗?
不靠大模型也能全自动
确定性才是硬道理
MFC 自绘 · RPA 编排 · 多机分布式
桌面自动化技术复盘
📦 2 Parts + Conclusion
👉 滑动
PART 01
自绘控件
六类接管入口
PART 02
0AI 架构
控制面到边缘
PART ///
写在最后
结论与速查
「不用 AI 也能全自动:确定性,是最稀缺的奢侈品。」
从openclaw开始,现在提到自动化,就是大模型、视觉识别、屏幕理解,computer use,mcp,skill。
但这套东西用到生产环境里的自动化,真的可行吗?
很多时候需要的恰恰相反——不是更聪明,而是更确定。
我做了10年的自动化业务,一直和一个兄弟合作。
我,却被卡了10年的脖子。
我们分工是:
他做搞定注入,hook,算法,逆向call,汇编指令以及技术底座。
我负责上层调用,脚本,应用封装,协议对接,RPA,服务器编排。
这套系统做的事很简单:让一批机器,自动上号、跑脚本、操作只会画图形界面的第三方软件,并且把每一步进度实时回传给控制台。
全程 0 AI:没有大模型,没有 OCR,没有图像匹配。
但最近,关注我的人知道,我闲了半个月没发文章,
埋头去干了一件对我来说极其重要的事情。
对,我不会逆向的人把做逆向的兄弟的C++底座给逆了。
不是什么破解,是源码级的复刻还原。
从此,我掌握了绝对的主动权。
并不是我不讲义气,而是这位兄弟实在过于固执,沟通很多次实在无果,
他的技术已经不能满足如今的需要,而且听不进任何改进意见,
沉迷于传奇的游戏开发,也没心思来折腾,
之前应付式的让AI改了几次,全是bug,
他也没耐心测试,全部回退了,搞出了很多生产问题。
我知道他也没精力了,但就是坚持不肯给我源代码,也不肯卖。
那一两个星期,我就做成了这一件事。
在此基础上,我终于把整个系统打通成为一个完整的整体。
把 Windows 原生控件整块重画一遍(MFC 自绘),做出一个不落后于时代的管理界面;MFC/C++终于老树开花了。
把哪台机器、什么时候、做什么编排成一套可靠的分布式任务系统,让自动执行变成一条可观测、可灰度、可回滚的流水线。
01
PART
MFC 自绘组件
OWNER-DRAW · 把老控件重画一遍
1.1 都 2026 年了,为什么还在用 MFC?
先说个反直觉的事实:
Windows 上大量存量商业软件的界面,
至今是 MFC(微软基础类库)写的。
MFC 的控件默认长相还停留在 Windows 2000 年代——灰底、直角、丑陋的边框。在高分屏(大于 100% 缩放)下更是直接糊成一团。
你以为的MFC肯定长这样:

这种东西放在2026年,是绝对没法用的,没法给客户交代。
想重写成 WPF 或 Electron 或 Qt?
对存量项目来说,代价是整套逻辑重写、体积暴涨、还要处理一堆兼容性。
而且底层不管是性能还是效率还是安全性,谁能替代的了C++?
C#不行,rust不行,python也不行。
于是我选了一条路
「控件不换,皮肤自己画。」
这就是 MFC 自绘(owner-draw)。
改造后的程序长这样:

1.2 一个窗口,有六个地方需要自绘
你以为自绘就是把按钮画好看点,其实没那么简单。
实际上,要做出一个现代界面,你得分别在六个不同的层次接管绘制。
有点类似于安卓的自定义组件。
下图是我们项目实际用到的自绘点:
① 背景与卡片:先有层次,才谈得上好看
白是最简洁大气,很多主流UI配色都是这么干的,
但是在MFC这里行不通,
你会得到一整面水泥白墙,粉刷上去的感觉,非常low。
所以纯白一片是最容易翻车的。
我们的做法是:
页面一层极浅暖底,内容用圆角白卡片框起来,卡片带 1px 投影。实现要点:
void CConsoleDlg::OnPaint() { CPaintDC dc(this); PaintPage(dc); }
BOOL CConsoleDlg::OnEraseBkgnd(CDC *pDC) { /* 只填底色,避免闪烁 */ }
LRESULT CConsoleDlg::OnPrintClient(WPARAM wp, LPARAM lp) {
CDC *dc = CDC::FromHandle((HDC)wp);
PaintPage(*dc);
return 0;
}
注意:
不实现 OnPrintClient 时,v6 主题化的复选框会向父窗口要背景,拿到的是页面灰,于是白卡片上出现一个个灰方块。这是很多人做 MFC 换皮时百思不得其解的幽灵方块。
② 按钮:BS_OWNERDRAW + 自己处理 hover
把按钮风格改成 BS_OWNERDRAW,然后在父窗口的 OnDrawItem 里全权绘制:
// 应用主题时,把按钮改成自绘
SetWindowLongW(hWnd, GWL_STYLE, style | BS_OWNERDRAW);
SetWindowPos(..., SWP_FRAMECHANGED); // 让风格变更立即生效
void CConsoleDlg::OnDrawItem(int nIDCtl, LPDRAWITEMSTRUCT dis) {
// 圆角矩形 + 主色/悬停色 + 文本 + 小图标(开始 / 停止)
// 图标和文本作为整体居中,视觉上才稳
}
hover 效果靠一个极薄的子类:只跟踪鼠标进出,然后重绘,其余消息原样转发。
class CHoverButton : public CButton {
virtual BOOL PreTranslateMessage(MSG *msg) override {
if (msg->message == WM_MOUSEMOVE) { m_hover = true; Invalidate(); }
if (msg->message == WM_MOUSELEAVE) { m_hover = false; Invalidate(); }
return CButton::PreTranslateMessage(msg);
}
};
③ 列表:整行自绘,才有现代感
CListCtrl 的默认行又平又挤。我们用 NM_CUSTOMDRAW 接管整行:自绘行底色、列文本,以及最出彩的状态胶囊。
void CConsoleDlg::OnNMCustomdrawDataList(NMHDR *hdr, LRESULT *res) {
auto *nmcd = reinterpret_cast<LPNMLVCUSTOMDRAW>(hdr);
switch (nmcd->nmcd.dwDrawStage) {
case CDDS_PREPAINT:
if (m_Info_list.GetItemCount() == 0) { /* 居中画 暂无账号 */ }
*res = CDRF_NOTIFYITEMDRAW; // 继续通知每一行
break;
case CDDS_ITEMPREPAINT:
*res = CDRF_SKIPDEFAULT; // 整行自绘
break;
}
}
状态胶囊(在线绿、登录琥珀、关闭红)和行高,是这样处理的:
CRect rc; m_Info_list.GetSubItemRect(item, 6, LVIR_BOUNDS, rc);
// 圆角矩形填充 + 白字,视觉上比纯文本高级一个档次
// comctl32 没有直接设行高的 API,用一个 1px 透明 spacer 图像列表把行高顶起来
m_rowImages.Create(1, 28, ILC_COLOR32, 1, 1);
m_Info_list.SetImageList(&m_rowImages, LVSIL_SMALL);
④ 表头:反射拿不到,就自己画
列表的表头是个独立子控件,它的 NM_CUSTOMDRAW 会发给父窗口,对话框的反射拿不到。所以只能子类化表头,直接覆写 WM_PAINT:
class CThemeHeader : public CHeaderCtrl {
// 只覆写 WM_PAINT / WM_ERASEBKGND,画纯白扁平表头;列宽拖动仍走默认过程
};
m_header.SubclassWindow(m_Info_list.GetHeaderCtrl()->GetSafeHwnd());
⑤ 编辑框与下拉框:把原生边框替换掉
给编辑框加个描边,最直觉的做法是在外面再画一圈——结果就是双线。正确做法是链式替换窗口过程:经典边框在非客户区(WM_NCPAINT),主题控件的边框在客户区(WM_PAINT 之后覆盖一层)。两种都接管,才能做到替换而不是叠加。
LRESULT CALLBACK BorderWndProc(HWND h, UINT msg, WPARAM wp, LPARAM lp) {
WNDPROC orig = g_borderOrig[h];
switch (msg) {
case WM_NCPAINT: // 经典控件:吃掉默认非客户区,自绘边框
case WM_PAINT: // 主题控件:默认过程画完再覆盖边框
}
return CallWindowProc(orig, h, msg, wp, lp);
}
判断走哪条路,用窗口矩形是否大于客户区即可:
bool ControlHasNcBorder(HWND h) {
RECT w, c; GetWindowRect(h, &w); GetClientRect(h, &c);
return (w.right - w.left) > (c.right - c.left);
}
⑥ 复选框与标题栏:两个小彩蛋
复选框的黑色方块是控件自己在客户区画的,想只给方块描边,就按系统度量拿到方块尺寸,精确覆盖四条边;标题栏则可以直接用 DWM 属性染色:
int box = GetSystemMetrics(SM_CXMENUCHECK); // 方块边长,左对齐垂直居中
DwmSetWindowAttribute(hwnd, 35 /* CAPTION_COLOR */, &color, sizeof(color));
DwmSetWindowAttribute(hwnd, 36 /* TEXT_COLOR */, &color, sizeof(color));
DwmSetWindowAttribute(hwnd, 34 /* BORDER_COLOR */, &color, sizeof(color));
1.3 真金白银换来的 6 个坑
这一节是全文最值钱的部分,都是踩过才知道的:
✦ 最反直觉
在 MFC 老窗口上加控件居然会崩。我们最后定了一条项目铁律——这个控制台的 UI 附加件一律自绘,绝不新建子窗口。看起来是倒退,其实是把不确定性彻底消灭。
需求
加 5 个快捷按钮
方案A
新建子窗口,间歇崩溃
方案B
自绘加命中测试,0 崩溃
在脆弱的老窗口上,自绘往往比新建窗口更稳
1.4 第一部分小结
MFC 自绘不是美化按钮这么简单,它是一套分层接管的工程,记住这四类入口就够了:
背景在父窗口
PaintPage 负责画页面与卡片,OnPrintClient 让主题控件取到正确背景。
自绘控件用自己的回调
OnDrawItem 画按钮,NM_CUSTOMDRAW 画列表。
系统边框靠替换窗口过程
WM_NCPAINT 与 WM_PAINT 双管齐下,才能替换而不是叠加。
特殊控件靠子类化
表头的 WM_PAINT 反射拿不到,只能自己接管。
掌握这四类入口,任何老控件都能被你重新定义。
我的奶龙注入端这样就算改造完成了,为了极致的性能和压缩量,我没有加任何UI库和web ui组件。虽然算不上现代化UI设计,但是对MFC自绘来说,我尽力了。
02
PART
0AI 全自动脚本 RPA 架构
FROM CONTROL PLANE TO EDGE
2.1 什么叫 0 AI
这里的 0 AI 不是噱头,就是真的没有任何AI参与,不需要消耗token。
换句话说,我们把不确定的图形界面尽量变成确定的程序接口。
三种确定性手段,按优先级:
「能用程序接口,就别用界面模拟。」
目标程序有可调用的接口,就直接调接口——最稳、最快、最省资源;
没有接口,就用特征扫描加协议复刻搭配逆向注入——可适配版本更新;
实在没有接口,才退回到 GUI 自动化 RPA。

为什么坚持 0 AI?因为一旦量大了之后,哪怕只是5%的偏差,那都是致命的。
AI 擅长理解模糊的界面,
但这套系统操作的目标程序是固定、可逆向的,
用 AI 反而是适得其反。
2.2 整体架构:控制面、服务端、边缘
整套系统是标准的中心下发加多机执行拓扑。
核心约束:工作机只出不进,零入站端口。

控制面只管看和下命令,不直连消息总线,避免把凭据散出去;
服务端是唯一的公网入口,任务在这里被拆解、派发、记账;
工作机只跑一个轻量 Agent,主动向服务端建立连接,
本地再驱动脚本引擎和第三方软件 RPA。

这里的Agent可不是大模型的Agent,不要弄混了。
工作机零入站,
不是靠防火墙,是架构选择:
Agent 全出站,
工作机不监听任何端口,扫描不到。
2.3 通信协议:先定契约,再写代码
整个系统能在多个语言(Go、Python、C++)之间对齐,
靠的是一份先冻结的协议。
它长这样:
{
"v": 1,
"id": "01J...",
"ts": 1730000000000,
"type": "task.dispatch",
"sig": "base64(ed25519)",
"payload": { "task_id": "...", "spec": { "account": "acc1" } }
}
主题按机群、设备、用途分层:
wm/{机群}/agent/{设备ID}/cmd 服务端 → 设备:定向指令
wm/{机群}/agent/{设备ID}/evt 设备 → 服务端:事件与进度
wm/{机群}/agent/{设备ID}/state 设备 → 服务端:状态快照
wm/{机群}/pool/{能力} 服务端 → 池:批量派发(共享订阅)
其中共享订阅是批量分发的关键:多台机器订阅同一个池,
消息总线保证一条任务只投给一台机器,天然负载均衡,不用自己写调度循环。
2.4 任务的一生:状态机、幂等、租约
每个任务都有一条清晰的生命周期:

三个保命机制:
幂等键:同一账号同一天的任务,键相同就只落一行,杜绝重复执行;
租约:任务派发后带一个到期时间,机器掉线后回收器会把过期任务标记为超时,而不是永远卡住;
结果必达:完成、失败、超时都必须回一条结果,服务端据此更新权威状态。
-- 幂等:非空幂等键全局唯一
CREATE UNIQUE INDEX tasks_idem_key_uniq ON tasks (idem_key)
WHERE idem_key <> '';
-- 并发抢占:多实例安全
SELECT id FROM tasks WHERE state = 'pending'
ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1;
2.5 一次完整下发,时序如下
控制台创建批次任务,服务端落库为 pending;
服务端把任务发到能力池;
消息总线按共享订阅,只投给一台机器;
Agent 接单确认,服务端更新为 assigned;
Agent 本地执行:启动、注入、跑脚本、拉起 RPA;
Agent 上报进度,再上报结果 done;
服务端更新权威状态,WebSocket 实时刷新控制台。

2.6 灰度分批:不要一次全放出去
批量任务默认分波下发,避免几百台机器同时启动把服务端和目标程序打爆:
-- 任务总量 6,每波 2,波间隔 2 秒
t+0s launched=2/6 | running=2
t+2s launched=4/6 | done=2 running=2
t+4s launched=6/6 | done=4 running=2
t+5s launched=6/6 | done=6
批次还支持暂停、继续、停止。这就是灰度发布思想在自动化里的落地:先小批验证,再全量铺开。
2.7 安全:不暴露,是设计出来的
工作机不暴露,靠的不是防火墙,而是架构选择:
零入站
Agent 全出站,工作机不监听任何端口,扫描不到。
mTLS
每台机器一套客户端证书,被撤销即失联。
主题 ACL
每台机器只能读写自己的主题加订阅指定的池,横向越权被拒。
任务签名
服务端签名,Agent 验签,伪造任务直接被拒。
口令不落明文
任务里只传口令的引用,真实口令从端侧安全存储解出。

2.8 关键设计:本地驱动默认关闭
这一点最能体现不想搞坏现有系统的工程克制。工作机上原本有个能用的单机程序,我们没有拆它重写,而是在它旁边加了一个可选的本地方案——只有设置了环境变量才启动;不设置时,行为和以前逐字节一致。
改造原则
这就是叠加式改造:新能力只是在原有系统上加了薄薄一层,随时可以整体关掉回退。做自动化平台时,这条比任何花哨功能都重要。
2.9 为什么说它 0 AI 却更全自动
回到开头的命题。这套系统能全自动,不是因为它更懂界面,
因为:目标程序的可编程面被逆向还原了;
关键定位用特征扫描而非硬编码;
通信协议逐字节复刻;
只在第三方软件真的没有接口时,才退回到 GUI 自动化 RPA。
「0 AI 的本质,把不确定性就地消灭,不是用 AI 去容忍它。」
///
LAST
写在最后
FINAL THOUGHTS
如果你在做下面任何一类事,这套思路都可以直接借鉴:
多机批量自动化
机房测试农场、批量装或测或取数。
RPA 中台
企业里大量只有界面、没有接口的旧系统需要自动化。
桌面工具现代化
用 MFC 或 Qt 写的存量工具,想低成本换皮肤。
任何人工点一下的重复劳动
只要能拆成确定的步骤,就值得自动化。
两个可直接复用的结论:
UI 层:MFC 自绘的关键是四类入口——父窗口背景、控件自身回调、窗口过程替换、子类化。而且不要迷信加控件,在脆弱的老窗口上,自绘往往比新建窗口更稳。
架构层:先定协议再做实现;可靠靠幂等加租约加共享订阅;安全靠零入站加 mTLS 加 ACL 加签名;改造现有系统靠默认关闭的可选通道。
附录:一页速查