ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

金橙子打标卡二次开发防重码:唯一约束与状态机实践

金橙子打标卡二次开发防重码:唯一约束与状态机实践

简介:面向从事激光打标软件二次开发与维护的.NET开发者,这份金橙子防重码打标单机版工程围绕防止重复打标、MDB记录查询与设备端功能定制展开,适合已具备C#/VB.NET基础、希望研究插件式打标软件架构的读者。压缩包共374个文件、约7.95MB,包含157个jsf界面/资源定义、31个bmp图标、31个cs源码,以及dll、plg插件、ini/config配置、resx/resources、exe程序和mdb数据库等,可据此梳理解决方案结构、界面资源组织与设备配置链路。已有3003人学习下载。读者可结合源码分析打标流程、查询去重逻辑和插件扩展方式,并参考现有工程进行报告生成、数据导入导出、界面定制、性能优化与错误排查,快速建立可运行的二次开发与测试环境。

1. 防重码不是加个 if:金橙子打标二次开发卡在哪

金橙子打标卡的二次开发,真正的门槛从来不是把图形打到工件上,而是保证同一个码只出现一次。做过追溯的人都知道,激光一出光,物理世界就变了,数据库里的事务回滚不掉那一笔灰。某产线曾经在换班时把一批已经打过的码池重新推了一遍,结果几十件产品带着重复二维码流到了客户手里,最后靠人工逐件比对序列号才把货追回来。这类事故的根因往往不在打标卡,而在上位机:把「防重码」简化成一次查表判断,既没有唯一约束兜底,也没有把「打标中」这个中间状态落盘。这份二次开发资源解决的正是这一段——在打标卡 SDK 之上补一层状态机、幂等占位和断电恢复逻辑,让打标动作具备可审计、可补打、可对账的能力。适合已经上手打标卡基础调用、准备把单机打标软件改造成产线工位程序的从业者。

2. 打标链路怎么切:从模板到卡控的四层结构

2.1 打标卡 SDK 只是执行器,防重码得自己兜

很多人第一次接打标卡,会下意识觉得「卡应该知道这个码打没打过」。事实相反,随卡提供的接口基本是无状态的:初始化、加载模板文件、按对象名改文本、下发出光指令、查询状态,就这几类。它不知道业务上的批次、工单、补打审批,更不知道这个字符串在半小时前是不是已经被另一台工位打过了。所以防重码的判定必须发生在调用打标接口之前,而且判定结果要以持久化记录的形式存在磁盘上,而不是存在上位机的内存集合里。

这里有个反直觉的结论:防重码的难点不在「判断」,而在「不可回滚」。查表判断谁都会写,问题在于从「库里标记为打标中」到「激光真正打完」之间存在几秒钟的时间窗口,这几秒里可能断电、可能急停、可能操作员直接关掉软件。窗口期内的状态如果没有被准确记录,重启后系统就不知道那件产品到底打了还是没打。后面几章的状态机和恢复流程,本质上都是在处理这个窗口。

选型上还有一个容易被忽略的点:不要把判定写进界面的事件回调里。工位程序往往一机多枪、一人多机,判定逻辑散落在按钮点击里,三个月后连写代码的人都不敢改。把它抽成独立的一层,哪怕只是一个类库,后面加补打、加审批、加对账都会轻松很多。

2.2 四层链路:界面、规则、模板、卡控

把整条链路切成四层,是我在类似项目里最常用的一种做法,切完之后每个人负责的边界很清楚,出问题也容易定位到层。

层级职责不该做的事典型故障表现
工位层扫码输入、操作员确认、状态显示、报警提示不做任何唯一性判断界面卡死、重复点击触发两次打标
规则层码规范化、幂等占位、补打审批、状态流转不直接调用打标接口码被打两次、状态卡在打标中
模板层加载 ezd 模板、按对象名改文本、回读校验不在运行时修改模板结构打出来还是上一件的码
卡控层初始化句柄、下发打标、等待完成信号、异常复位不做业务判断卡被占用、打标超时、需要重启软件

这样分层之后,规则层可以单独写单元测试,模板层可以用空打模式验证,卡控层可以脱离业务用一个固定的测试码反复跑。整个系统的可验证性一下子就上来了,这在产线现场比代码写得多漂亮重要得多。

规则层放数据库还是放内存,也是个要提前定的事。单工位、单进程、允许停机几分钟的场景,本地 SQLite 完全够用;一旦出现两个工位共用一个码池,就必须换成带唯一约束的服务端库或者把码池集中到一台机器上。用内存 Set 做去重的方案我见过不止一次,重启即失效,两台机器之间还不共享,属于典型的血泪经验。

2.3 一次合格打标的七步时序

顺序不能乱。尤其是第三步和第五步,前者保证「没占到位就不许出光」,后者保证「写进去的就是要打的」。我一般会把下面这个时序直接贴在工位机旁边,让操作员也知道软件在干什么。

  1. 扫码枪读入原始字符串,触发工位层事件。
  2. 规范化:去首尾空格、统一大写、全角转半角、剔除不可见字符,同时做长度和字符集校验。
  3. 幂等占位:带着批次号去码池做条件更新,影响行数为零直接拒绝并给出可读原因。
  4. 加载模板:命中缓存则复用已加载的 ezd,未命中则从只读目录加载,加载后校验对象是否存在。
  5. 改文本并回读:写完目标对象后立刻把值读回来比对,不一致就中止。
  6. 下发打标并等待完成信号,超过设定超时则进入待确认状态,而不是直接判失败。
  7. 回写结果:成功写 OK,失败写 FAIL 并保留错误码,可选地再走一次相机或扫码枪回读。

第七步里的「可选」其实是分水岭。只做软件层防重码,成本低、上线快,适合内部追溯;一旦客户要求出货扫码可查,就必须加回读环节,因为激光打出来的条码质量受材料、焦距、速度影响,软件认为成功不代表扫码枪能读出来。

2.4 金橙子模板里的对象命名:把变量名写进 ezd

模板层最容易翻车的地方是对象名。金橙子的模板文件在编辑器里默认生成的对象名往往是「文本1」「文本2」这种,现场一旦有人重新排版,对象顺序变了,代码里按下标取对象的写法立刻失效,而且失效得很安静——不报错,只是打出来的内容不对。

我的做法是在模板里给所有需要动态替换的对象起固定名字,代码只按名字操作,不按下标。

对象名类型用途约束
Txt_Code文本主追溯码必须存在,缺失即拒绝启动
Txt_SN文本产品序列号可选,缺失时降级为只打主码
Txt_Date文本生产日期或班次格式在规则层拼好再写入
Bar_Code条码一维码或二维码码制与尺寸改模板,不改代码

配套还有几条硬规矩:模板统一放在只读目录,按「产品型号_版本号.ezd」命名;模板里的字体必须内嵌,不要依赖工位机的系统字体,否则换台机器排版就漂;速度、功率、频率这些笔号参数在模板里调好,代码里只在必要时覆盖,不要在代码里硬编码一整套参数。模板一旦要改,走版本号,旧版本保留,现场出问题能立刻切回去,这就是后悔药。

3. 防重码的核心实现:唯一约束加状态机

3.1 三张表定生死:码池、流水、任务

数据模型不需要复杂,三张表足够撑起绝大多数产线场景。码池负责唯一性,流水负责审计,任务负责批次隔离。关键在于职责别混:流水表长得像日志,就让它老老实实当日志,不要让它承担唯一性职责。

-- 码池:唯一性由主键保证,状态由业务流转 CREATE TABLE t_code_pool ( code VARCHAR(64) NOT NULL, -- 规范化之后的码 batch_no VARCHAR(32) NOT NULL, -- 批次/工单号 status VARCHAR(16) NOT NULL DEFAULT 'PENDING', station VARCHAR(16) NULL, -- 占用它的工位 retry_count INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL, mark_time DATETIME NULL, PRIMARY KEY (code), KEY idx_batch_status (batch_no, status) ); -- 流水:只做审计,允许同一码多条记录(首次打标、补打、失败重试) CREATE TABLE t_mark_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(64) NOT NULL, station VARCHAR(16) NOT NULL, action VARCHAR(16) NOT NULL, -- MARK / REPRINT result VARCHAR(16) NOT NULL, -- START / OK / FAIL / UNKNOWN err_msg VARCHAR(255) NULL, ts DATETIME NOT NULL, KEY idx_code_ts (code, ts) );

主键选code而不是自增 ID,是为了让「重复插入必然失败」这件事由数据库来保证,而不是靠应用层先查再插。先查再插在并发下有一个经典的空窗:两个线程同时查,都说不存在,然后都插进去了。

status的取值我固定成五个:PENDING、MARKING、MARKED、FAILED、VOID。别小看这个值域约束,很多时候问题就出在有人偷偷加了个DONE或者2进去,对账脚本一跑全是异常。可以在数据库层加一个 CHECK 约束,或者至少在建表注释里写死。

retry_count是给自己留的证据。同一码重试次数超过阈值就该报警,因为正常流程下它不应该被反复重试——要么卡有问题,要么码池数据有问题,两种情况都需要人介入。

3.2 幂等占位:先占坑,再出光

占位这一步是整个防重码的心脏。它必须满足两个条件:原子性,以及能明确告诉调用方「为什么不行」。用条件更新拿影响行数,是我最推荐的方式,它同时解决了并发和原因判定。

// 幂等占位:把“这个码能不能打”压缩成一次带条件的 UPDATE public MarkPermission TryOccupy(string code, string station, string batchNo) { using (var conn = new MySqlConnection(_connStr)) { conn.Open(); using (var tx = conn.BeginTransaction(IsolationLevel.ReadCommitted)) { // 只有 PENDING 才允许被占;影响行数 0 说明已被别人拿走或状态不对 const string sql = @"UPDATE t_code_pool SET status = 'MARKING', station = @station, mark_time = NOW() WHERE code = @code AND batch_no = @batch AND status = 'PENDING'"; using (var cmd = new MySqlCommand(sql, conn, tx)) { cmd.Parameters.AddWithValue("@code", code); cmd.Parameters.AddWithValue("@station", station); cmd.Parameters.AddWithValue("@batch", batchNo); if (cmd.ExecuteNonQuery() == 0) { tx.Rollback(); return MarkPermission.Deny(QueryDenyReason(conn, code, batchNo)); } } // 流水必须在出光之前落库,否则断电后无法判断打过没打过 InsertLog(conn, tx, code, station, "MARK", "START", null); tx.Commit(); return MarkPermission.Allow(code); } } }

@code传进来的必须是规范化之后的值,大写、去空格、去全角,这一步在工位层做完。如果规范化放在占位之后,就会出现「同一个码大小写不同被当成两个」的漏洞,而且这种漏洞在测试阶段几乎发现不了,因为测试数据通常都是规整的。

station记录是谁占的,多工位场景下非常有用。某工位卡死没释放,运维一看就知道该去重启哪台机器,不用挨个问。

InsertLog放在事务提交之前,是因为它的语义是「我打算打了」。这条 START 记录如果存在但找不到对应的 OK,重启后的恢复逻辑就有了判断依据。

QueryDenyReason返回的原因要具体到人能看懂:码不存在、已被本工位打过、已被其他工位打过、属于其他批次、已作废。笼统的「操作失败」在现场会被骂,因为操作员不知道该找谁。

3.3 补打通道要单独开一条路

补打是防重码方案里最容易破防的地方。为了省事,很多实现直接在原流程上加个「强制打标」勾选框,勾上就跳过所有校验。结果就是所有重复打标事故都能追溯到那个勾选框。

正确做法是把补打做成一条独立链路,它只做一件事:在原记录基础上追加一条 REPRINT 流水,并且要求三个输入——原码、工单号、当班负责人确认。三个都齐了才允许打,缺一个就拒绝。原码的码池状态不回退到 PENDING,只把retry_count加一,这样即便补打失败,也不会出现「状态回退后被另一个人正常占用」的混乱。

参数上我一般设三条阈值:同一码补打次数上限 1 次、补打时效 30 分钟内、超出时效必须走纸质审批再手工录单。前两条写进代码,第三条写进作业规范。参数不是越严越好,太严了现场会想办法绕过你,那比不设还危险。

-- 补打前的最后一道检查:确认该码确实有成功记录,且补打次数未超限 SELECT p.code, p.status, p.retry_count, (SELECT COUNT(1) FROM t_mark_log l WHERE l.code = p.code AND l.action = 'REPRINT' AND l.result = 'OK') AS reprint_ok FROM t_code_pool p WHERE p.code = ? AND p.retry_count < 1;

这条查询返回空行就拒绝补打,返回非空行再由程序判断reprint_ok是否为零。两次判断看起来冗余,实际上是在防两种不同的错误:前者防超次数,后者防「补打成功但流水没写进去」的不一致。

3.4 码池导入与清洗

码池数据从哪来,决定了后面一半的坑。常见来源是客户下发的 Excel、上游系统导出的 CSV,或者现场用号段规则生成。三种来源的清洗策略不一样,但有一条是共同的:导入必须在一个事务里完成,任何一行出错就整体回滚,不要留半截数据在库里。

导入时最容易漏的是重复检测。Excel 里出现两行一样的码,导入脚本如果只用INSERT,主键冲突会让整个事务失败,这时候要能定位到具体是第几行冲突,而不是抛一个笼统的异常。我一般会在导入前先跑一遍「组内重复 + 库内重复」的预检,把冲突行单独输出成一份文件,让人工确认之后再去重导入。

号段生成的情况则要防另一件事:多个工位同时申请号段。号段必须由中心节点统一分配,每次分配一块(比如一千个),用完再申请。千万不要让每个工位自己按时间戳生成,机器时间不同步的时候,两个工位会生成出完全一样的号。

4. 与打标卡对接:状态机、超时与中断恢复

4.1 初始化句柄只做一次,全程复用

打标卡的初始化和关闭都是有代价的操作,反复 Initial/Close 会让卡处于不稳定状态,现场表现为「打几十件之后就不出光了,重启软件又好了」。正确的做法是在程序启动时初始化一次,句柄用单例持有,退出时统一关闭。如果初始化失败,先看两件事:卡是不是被编辑器软件占着,以及驱动是否正常加载。打标卡在同一时刻通常只允许一个进程控制,编辑器开着,工位程序就初始化不了。

加载模板也一样。同一个模板反复加载既慢又容易出问题,我会做一个模板缓存,键是模板文件路径加文件修改时间,文件没变就直接复用已加载的句柄,文件变了才重新加载并刷新缓存。这样现场改模板之后不用重启软件,但也避免了每次打标都读一遍磁盘。

4.2 打标线程只能有一条

不管界面上有多少个按钮,真正调用打标接口的线程必须有且只有一条。做法是让所有打标请求进一个串行队列,工作线程从队列里取任务逐个执行。这一步用BlockingCollection或者Channel都行,关键是别用lock去保护打标调用——lock只保证互斥,不保证顺序,而且一旦打标卡死,锁会一直不释放,整个界面跟着假死。

超时设置要留出余量。一个常规的小幅面二维码,从下发到打完通常在几百毫秒量级,但材料不同、功率不同、打标范围不同,差别会很大。我一般按实测节拍的两到三倍设超时,宁可多等一会儿,也不要因为超时误判失败。超时之后不要立刻调用关闭或复位接口,先查一次卡状态,确认是真的卡住了再走复位流程。

4.3 返回值不算数,要闭环判定

打标接口返回成功,只代表指令下发成功,不代表工件上真的有码。这句话我每次做培训都要强调一遍。判定要三层:接口返回码、打标完成信号、物理回读。

// 打标执行器:串行调用,三层判定,超时进待确认而不是直接判失败 public MarkResult Execute(string code, string templatePath, int timeoutMs = 3000) { if (!_card.Ready) _card.Initial(); // 句柄初始化一次,后续复用 _card.LoadTemplate(templatePath); // 命中缓存则内部直接返回 if (!_card.SetTextByName("Txt_Code", code)) // 按对象名写入,不按下标 return MarkResult.Fail("对象写入失败,检查 Txt_Code 是否存在"); if (_card.GetTextByName("Txt_Code") != code) // 写完必须回读 return MarkResult.Fail("回读不一致,模板对象名可能被改过"); var sw = Stopwatch.StartNew(); var rc = _card.Mark(); // 仅代表指令下发 if (rc != 0) return MarkResult.Fail($"Mark 返回 {rc}"); if (!_card.WaitDone(timeoutMs)) // 等完成信号,超时语义是“未知” return MarkResult.Unknown($"等待完成超时 {sw.ElapsedMilliseconds}ms,需人工确认"); return MarkResult.Ok(sw.ElapsedMilliseconds); }

timeoutMs按实测节拍的两到三倍取,别照抄默认值。MarkResult.Unknown这个状态很重要,它和Fail的语义完全不同:Fail表示确定没打上,可以自动重试;Unknown表示不确定,必须人工看一眼工件再决定。把Unknown当Fail处理,就是重复打标的源头之一。

GetTextByName这一步看着多余,实际能挡住一类很隐蔽的故障:模板被别人重新排版过,对象名被改了,SetTextByName返回失败但有人把返回值忽略了,于是打出来的还是模板里固化的旧值。加上回读,这种问题在第一次打标时就会暴露。

4.4 断电、急停、卡死三种现场怎么恢复

三种中断的处理原则一样:宁可让状态停在中间,也不要猜。软件启动时先做一次扫描,把所有status = 'MARKING'的记录挑出来,连同对应的流水一起进人工确认队列,界面上弹出提示,要求操作员确认工件上是否已有码。确认有码的,改判MARKED并补一条人工流水;确认无码的,退回PENDING允许重新打。

急停的处理稍微不同。急停发生时打标可能已经出光了一部分,这时候工件上是半个码。半个码的工件必须走作废流程,status置VOID,不允许退回重打,因为同一个码打在两个工件上,等于主动制造重码。这一点要写进作业规范,不能只靠软件拦。

卡死或者软件无响应,最常见的原因是打标卡被异常占用。处理步骤是:先停队列、再查状态、最后复位。复位失败就重启软件,重启还不行再断电重启控制箱。别在卡死状态下反复点打标按钮,那样只会让状态更难判断。恢复完成之后,务必跑一次对账脚本,把这段时间的流水和码池状态对一遍,确认没有漏网的中间态。

5. 避坑与排查:五条现场记录

5.1 现象:打出来的还是上一件的码

原因:模板里对象名被改过,或者用了按下标取对象的写法;更隐蔽的一种是SetTextByName返回了失败,但调用方没检查返回值,程序继续往下走,打出来的是模板里固化的上一次内容。

解决:所有写对象的调用都必须检查返回值,并在写完之后立刻回读比对,不一致就中止本次打标。模板上线前做一次对象名自检,把Txt_Code这类必需对象列成清单,启动时逐个检查存在性,缺一个就拒绝进入就绪状态。这一条看似麻烦,但它把一类随机性故障变成了启动时的确定性报错。

5.2 现象:补打之后出现双胞胎

原因:补打通道绕过了唯一约束,操作员看到「打标失败」就点补打,实际上第一次已经打上了,只是完成信号没等到。软件判失败,物理上却成功了。

解决:把「超时」和「失败」分成两个状态,超时进人工确认队列而不是自动允许重打。补打必须带原码和工单号,且要先查流水里有没有 OK 记录,有 OK 记录的补打一律走审批。另外给补打加次数上限,超过就锁死该码,强制人工介入。这几个动作加起来,能挡掉绝大部分双胞胎。

5.3 现象:重启后一堆「打标中」的僵尸记录

原因:占位成功、流水 START 落库之后,程序在等打标完成信号时被强杀或断电,状态停在MARKING上再没人管。时间一长,这些码既不能重打也不能作废,成了孤儿。

解决:软件启动时强制扫描MARKING状态,全部转人工确认队列,不允许静默转成 OK。同时对账脚本要能按时间窗口筛出停留超过 N 分钟的MARKING记录并告警。N 取多少看节拍,常规产线 10 分钟足够,慢节拍的工位可以放宽到 30 分钟。

5.4 现象:软件判成功,扫码枪读不出来

原因:打标参数和材料不匹配。速度太快导致码点连接不清,功率太低导致对比度不够,焦距没调好导致边缘发虚。软件层只判断了指令下发,没有判断光学效果。

解决:首件必须用实际使用的扫码枪在标准距离下读三次,全过才算通过。参数按材料打样确定,确定后写进模板版本,不要在现场随手改。上线后每隔一段时间抽检一次,抽检记录跟着批次走。有条件的话加一个固定位置的读码器做在线回读,读不出直接报警停线,这比事后追溯便宜太多。

5.5 现象:第二个工位抢不到打标卡

原因:打标卡同一时刻只能被一个进程控制。第一台机器的软件没退干净,或者编辑器还开着,第二台初始化必然失败。另一个常见原因是把两张卡装在同一台机器上,驱动层面的设备序号没区分开,程序总是打开第一张。

解决:初始化失败时给出明确提示,区分「被占用」和「设备不存在」两种情况。多卡场景按设备序号或序列号显式指定,不要依赖默认枚举顺序。工位程序退出时确保释放句柄,异常退出要有兜底清理。这一条属于运维范畴,但软件不给提示的话,现场只能靠猜。

6. 上线前的灰度验证与对账习惯

防重码这种东西,功能跑通只完成了一半,剩下的靠验证。我一般的节奏是:先在空打模式下跑通全流程,也就是加载模板、改对象、不下发出光,只走状态机。这个阶段跑五百件,重点看码池状态流转是否正确、流水是否成对出现、异常分支有没有漏。空打模式的价值在于它把「光学」这个变量摘掉了,剩下全是软件逻辑,出问题一定是代码问题。

接着做断电测试。跑到第两百件左右直接拔电,重启后看人工确认队列里是不是正好有那一件,数量对不对得上。这个测试至少要重复三次,因为断电时机不同,暴露的问题也不同。再往后是并发测试,两个工位同时抢同一批码池,看有没有出现同一个码被两个工位都占成功的情况。

最后是对账。下面这段脚本我每次上线都会挂到定时任务里,每天早上开班前跑一次,输出为空才允许生产。

# 每日对账:三类异常必须清零,否则不允许开班 import sqlite3 conn = sqlite3.connect("mark.db") cur = conn.cursor() # 1) 有 START 没有终态的码:说明打标过程被打断 cur.execute(""" SELECT p.code, p.status, p.mark_time FROM t_code_pool p WHERE p.status = 'MARKING' AND p.mark_time < datetime('now', '-30 minutes') """) stale = cur.fetchall() # 2) 同一码出现多条 OK 流水:典型的双胞胎 cur.execute(""" SELECT code, COUNT(1) AS ok_cnt FROM t_mark_log WHERE result = 'OK' AND action = 'MARK' GROUP BY code HAVING ok_cnt > 1 """) dup = cur.fetchall() # 3) 有 OK 流水但码池状态不是 MARKED:状态回写丢失 cur.execute(""" SELECT l.code FROM t_mark_log l JOIN t_code_pool p ON p.code = l.code WHERE l.result = 'OK' AND p.status <> 'MARKED' """) mismatch = cur.fetchall() print("僵尸记录:", stale) print("重复成功:", dup) print("状态不一致:", mismatch) conn.close()

三类异常对应三种不同的问题:第一类查的是中断恢复有没有做全,第二类查的是补打通道有没有被绕开,第三类查的是事务边界有没有踩线。脚本本身很简单,难的是坚持跑。我见过太多项目上线第一周天天看对账,一个月之后就没人管了,等到客户投诉重码才想起来翻日志。

顺便说一个验收清单的用法:把「首件扫码三次全过」「断电恢复数量一致」「并发抢占无重复」「对账三列为空」这四条列成表格,每一条都要有人签字。签字这个动作看着形式主义,但它把责任落到了具体的人身上,比在群里发一句「测过了」有用得多。

从那以后我每次接打标类项目,都会在写第一行业务代码之前先把码池表和状态机定下来,哪怕界面还没画。因为界面可以改,状态一旦乱了,追起来就是几天几夜。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进