夜雨聆风学习资料网

ARTICLE · 1112019

老树开花,0 AI,MFC+lua,RPA跨机任务编排上百台机器全自动干活

老树开花,0 AI,MFC+lua,RPA跨机任务编排上百台机器全自动干活
TECH · 架构复盘2026.10

自动化一定要上 AI 吗?

不靠大模型也能全自动

确定性才是硬道理

MFC 自绘 · RPA 编排 · 多机分布式

桌面自动化技术复盘

OWNER-DRAWRPA

📦 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,

他也没耐心测试,全部回退了,搞出了很多生产问题。

我知道他也没精力了,但就是坚持不肯给我源代码,也不肯卖。

那一两个星期,我就做成了这一件事。

在此基础上,我终于把整个系统打通成为一个完整的整体。

1

把 Windows 原生控件整块重画一遍(MFC 自绘),做出一个不落后于时代的管理界面;MFC/C++终于老树开花了。

2

把哪台机器、什么时候、做什么编排成一套可靠的分布式任务系统,让自动执行变成一条可观测、可灰度、可回滚的流水线。

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 一个窗口,有六个地方需要自绘

你以为自绘就是把按钮画好看点,其实没那么简单。

实际上,要做出一个现代界面,你得分别在六个不同的层次接管绘制。

有点类似于安卓的自定义组件。

下图是我们项目实际用到的自绘点:

自绘位置
技术手段
背景与卡片
OnEraseBkgnd + OnPrintClient + PaintPage
标题栏
DWM 属性染色
按钮
BS_OWNERDRAW + OnDrawItem
列表
NM_CUSTOMDRAW 整行自绘
表头
WM_PAINT 覆写
编辑框与下拉框
WndProc 链式替换边框
复选框
只画方块边
右键菜单
MF_OWNERDRAW

① 背景与卡片:先有层次,才谈得上好看

白是最简洁大气,很多主流UI配色都是这么干的,

但是在MFC这里行不通,

你会得到一整面水泥白墙,粉刷上去的感觉,非常low。

所以纯白一片是最容易翻车的。

我们的做法是:

页面一层极浅暖底,内容用圆角白卡片框起来,卡片带 1px 投影。实现要点:

...cpp

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 里全权绘制:

...cpp

// 应用主题时,把按钮改成自绘

SetWindowLongW(hWnd, GWL_STYLE, style | BS_OWNERDRAW);

SetWindowPos(..., SWP_FRAMECHANGED); // 让风格变更立即生效

void CConsoleDlg::OnDrawItem(int nIDCtl, LPDRAWITEMSTRUCT dis) {

  // 圆角矩形 + 主色/悬停色 + 文本 + 小图标(开始 / 停止)

  // 图标和文本作为整体居中,视觉上才稳

}

hover 效果靠一个极薄的子类:只跟踪鼠标进出,然后重绘,其余消息原样转发。

...cpp

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 接管整行:自绘行底色、列文本,以及最出彩的状态胶囊。

...cpp

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;

  }

}

状态胶囊(在线绿、登录琥珀、关闭红)和行高,是这样处理的:

cpp

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:

cpp

class CThemeHeader : public CHeaderCtrl {

  // 只覆写 WM_PAINT / WM_ERASEBKGND,画纯白扁平表头;列宽拖动仍走默认过程

};

m_header.SubclassWindow(m_Info_list.GetHeaderCtrl()->GetSafeHwnd());

⑤ 编辑框与下拉框:把原生边框替换掉

给编辑框加个描边,最直觉的做法是在外面再画一圈——结果就是双线。正确做法是链式替换窗口过程:经典边框在非客户区(WM_NCPAINT),主题控件的边框在客户区(WM_PAINT 之后覆盖一层)。两种都接管,才能做到替换而不是叠加。

...cpp

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);

}

判断走哪条路,用窗口矩形是否大于客户区即可:

cpp

bool ControlHasNcBorder(HWND h) {

  RECT w, c; GetWindowRect(h, &w); GetClientRect(h, &c);

  return (w.right - w.left) > (c.right - c.left);

}

⑥ 复选框与标题栏:两个小彩蛋

复选框的黑色方块是控件自己在客户区画的,想只给方块描边,就按系统度量拿到方块尺寸,精确覆盖四条边;标题栏则可以直接用 DWM 属性染色:

cpp

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 个坑

这一节是全文最值钱的部分,都是踩过才知道的:

现象
根因
解法
新增子窗口按钮后,启动 2~4 秒内间歇崩溃
该对话框对新增或变动子 HWND 极敏感,存在潜在时序缺陷
快捷按钮改成纯自绘加命中测试,一个 HWND 都不新建
列表选中态判断错误,首次绘制所有行都像被选中
uItemState 在首次绘制时对每行都报 CDIS_SELECTED
改用 GetItemState 判断选中
自绘行底色不生效
clrTextBk 若等于文本背景色会被 comctl32 忽略
行底色用 254 而不是 255
截图验证时看不到自绘边框
PrintWindow 走 WM_PRINTCLIENT,不触发 WM_PAINT
必须用真实屏幕截图取证
高分屏下整体发虚
未声明 DPI 感知,系统对窗口做了位图拉伸
manifest 声明 dpiAware 并按比例缩放
中文字体换了但没生效
用 GetTextFaceA 比对字体名,中文系统返回本地化名
改用 EnumFontFamiliesEx 判断字体是否已安装

✦ 最反直觉

在 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。

换句话说,我们把不确定的图形界面尽量变成确定的程序接口。

三种确定性手段,按优先级:

「能用程序接口,就别用界面模拟。」

1

目标程序有可调用的接口,就直接调接口——最稳、最快、最省资源;

2

没有接口,就用特征扫描加协议复刻搭配逆向注入——可适配版本更新;

3

实在没有接口,才退回到 GUI 自动化 RPA。

为什么坚持 0 AI?因为一旦量大了之后,哪怕只是5%的偏差,那都是致命的。

维度
0 AI(确定性)
大模型 / CV 方案
可复现性
同样输入永远同样结果
有概率波动
延迟
毫秒到秒级
受模型推理影响
成本
几乎为零
每步都烧算力
可审计
每步都有明确依据
模型觉得应该点这里

AI 擅长理解模糊的界面,

但这套系统操作的目标程序是固定、可逆向的,

用 AI 反而是适得其反。

2.2 整体架构:控制面、服务端、边缘

整套系统是标准的中心下发加多机执行拓扑。

核心约束:工作机只出不进,零入站端口。

控制面只管看和下命令,不直连消息总线,避免把凭据散出去;

服务端是唯一的公网入口,任务在这里被拆解、派发、记账;

工作机只跑一个轻量 Agent,主动向服务端建立连接,

本地再驱动脚本引擎和第三方软件 RPA。

这里的Agent可不是大模型的Agent,不要弄混了。

工作机零入站,

不是靠防火墙,是架构选择:

Agent 全出站,

工作机不监听任何端口,扫描不到。

2.3 通信协议:先定契约,再写代码

整个系统能在多个语言(Go、Python、C++)之间对齐,

靠的是一份先冻结的协议。

它长这样:

...json

{

  "v": 1,

  "id": "01J...",

  "ts": 1730000000000,

  "type": "task.dispatch",

  "sig": "base64(ed25519)",

  "payload": { "task_id": "...", "spec": { "account": "acc1" } }

}

主题按机群、设备、用途分层:

topic

wm/{机群}/agent/{设备ID}/cmd 服务端 → 设备:定向指令

wm/{机群}/agent/{设备ID}/evt 设备 → 服务端:事件与进度

wm/{机群}/agent/{设备ID}/state 设备 → 服务端:状态快照

wm/{机群}/pool/{能力} 服务端 → 池:批量派发(共享订阅)

其中共享订阅是批量分发的关键:多台机器订阅同一个池,

消息总线保证一条任务只投给一台机器,天然负载均衡,不用自己写调度循环。

2.4 任务的一生:状态机、幂等、租约

每个任务都有一条清晰的生命周期:

状态流转
触发
pending → assigned
派发到某台机器
assigned → running
机器接单确认
running → done
成功
running → failed
失败,带回错误码
assigned → assigned
租约到期,重新派发
running → timeout
租约到期仍未完成
→ canceled
撤回

三个保命机制:

1

幂等键:同一账号同一天的任务,键相同就只落一行,杜绝重复执行;

2

租约:任务派发后带一个到期时间,机器掉线后回收器会把过期任务标记为超时,而不是永远卡住;

3

结果必达:完成、失败、超时都必须回一条结果,服务端据此更新权威状态。

...sql

-- 幂等:非空幂等键全局唯一

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 一次完整下发,时序如下

1

控制台创建批次任务,服务端落库为 pending;

2

服务端把任务发到能力池;

3

消息总线按共享订阅,只投给一台机器;

4

Agent 接单确认,服务端更新为 assigned;

5

Agent 本地执行:启动、注入、跑脚本、拉起 RPA;

6

Agent 上报进度,再上报结果 done;

7

服务端更新权威状态,WebSocket 实时刷新控制台。

2.6 灰度分批:不要一次全放出去

批量任务默认分波下发,避免几百台机器同时启动把服务端和目标程序打爆:

...rollout

-- 任务总量 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 关键设计:本地驱动默认关闭

这一点最能体现不想搞坏现有系统的工程克制。工作机上原本有个能用的单机程序,我们没有拆它重写,而是在它旁边加了一个可选的本地方案——只有设置了环境变量才启动;不设置时,行为和以前逐字节一致。

环境变量
行为
未设置
命名管道不存在,单机照常用
WM_BANANA_RPC=1
Agent 可经管道下发开始或停止脚本等指令

改造原则

这就是叠加式改造:新能力只是在原有系统上加了薄薄一层,随时可以整体关掉回退。做自动化平台时,这条比任何花哨功能都重要。

2.9 为什么说它 0 AI 却更全自动

回到开头的命题。这套系统能全自动,不是因为它更懂界面,

因为:目标程序的可编程面被逆向还原了;

关键定位用特征扫描而非硬编码;

通信协议逐字节复刻;

只在第三方软件真的没有接口时,才退回到 GUI 自动化 RPA。

「0 AI 的本质,把不确定性就地消灭,不是用 AI 去容忍它。」

///

LAST

写在最后

FINAL THOUGHTS

如果你在做下面任何一类事,这套思路都可以直接借鉴:

多机批量自动化

机房测试农场、批量装或测或取数。

RPA 中台

企业里大量只有界面、没有接口的旧系统需要自动化。

桌面工具现代化

用 MFC 或 Qt 写的存量工具,想低成本换皮肤。

任何人工点一下的重复劳动

只要能拆成确定的步骤,就值得自动化。

两个可直接复用的结论:

1

UI 层:MFC 自绘的关键是四类入口——父窗口背景、控件自身回调、窗口过程替换、子类化。而且不要迷信加控件,在脆弱的老窗口上,自绘往往比新建窗口更稳。

2

架构层:先定协议再做实现;可靠靠幂等加租约加共享订阅;安全靠零入站加 mTLS 加 ACL 加签名;改造现有系统靠默认关闭的可选通道。

附录:一页速查

主题
关键点
MFC 背景与卡片
OnPaint 调 PaintPage,必须处理 OnPrintClient
MFC 按钮
BS_OWNERDRAW 加 OnDrawItem,hover 用薄子类
MFC 列表
NM_CUSTOMDRAW 整行自绘,行高用 1px 透明图片列表
MFC 表头
反射拿不到,子类化后覆写 WM_PAINT
MFC 边框
链式替换 WndProc,WM_NCPAINT 加 WM_PAINT
MFC 高分屏
manifest 声明 dpiAware,按比例缩放
架构
控制面、服务端、边缘,工作机零入站
协议
信封加分层主题加共享订阅
可靠性
幂等键加租约回收加结果必达
安全
mTLS 加 Topic ACL 加签名
改造原则
新能力默认关闭,不影响原有单机使用

相关学习资料