Blog

  • 超市收银与数字价签一体化系统 PART 1: 利用条码秤通讯协议搭建本地商品库存

    开发手记 · 2026 年 10 月 · 公开脱敏版

    本开发手记实际为后续整理补充,主要代码由AI实现,本人提供想法。
    本文记录乐悠客收银系统、大华标签秤相关资料与电子价签管理工具的开发过程。保留技术思路、问题定位和代码示例;现场地址、原客户端专名、账号及身份信息已隐去。配图均为原创示意,商品为虚构数据。

    从收银机到电子价签:项目封面

    阅读说明|主要工作内容与AI免责声明

    主要工作内容

    这个项目起源于一个很实际的需求:收银系统里已经修改了商品价格,能不能让电子价签也跟着更新,减少重复录入?

    我选择从收银系统已有的标签秤接口入手。通过 Wireshark 观察收银系统与大华标签秤软件之间的通信,结合设备资料,对照不同商品以及名称、价格、单位、PLU 变化前后的数据,逐步理解通信方式、记录格式和字段含义,再比较单条商品更新与连续同步时的行为差异。

    在此基础上,我们实现了一个“虚拟大华标签秤接收端”。形象地说,就是造出了一台没有实体的“幽灵秤”:收银系统仍按原来的方式发送商品数据,接收它们的却是我们自己的程序。程序负责接收和拼接数据、解析商品记录,再根据 PLU 等信息建立本地商品列表。

    接下来,需要把这些商品交给电子价签平台。我们先使用 UPX 解压原管理软件的可执行文件,再结合 EXE、DLL 的针对性静态分析与实际请求记录,理解其中与登录认证、商品查询和数据提交有关的处理流程。这里没有恢复整套软件的源码,而是围绕项目需要的功能寻找线索,并通过实际操作验证。

    最终,我们用 C# 和 Windows Forms 重新实现了一套商品管理工具,把收银端的数据接收、本地商品管理和平台提交串联起来。其中还处理了公斤与市斤的价格换算、原始价格保留、删除状态、手动刷新和托盘交互等日常使用细节。

    目前,这套工具主要利用标签秤接口传来的 PLU、名称、价格和单位等信息组织商品,再通过既有平台及原有下发链路更新电子价签。价签底层无线通信和设备管理仍由原系统承担。

    这条路线让我们先接通了商品更新的完整链路。不过,目前通过该接口取得的信息仍有局限,连续接收是否代表完整同步,也只能采用启发式判断。后续希望寻找信息更完整、同步边界更明确的商品接入方式,在保留现有可用能力的基础上,继续完善这套工具。

    示意图:

    文章配图
    最后服务器架设部分还没进行

    AI免责声明

    以下是一段AI的免责声明,主要历程请跳转到下一段

    代码来源:下文代码是依据已有开发记录做的教学重写,并非从原项目逐行摘录。类名、变量名、内存结构和演示数据均为文章组织所用;需要接入现场的部分保留抽象接口。片段用于讲清一个问题,不是复制后即可连接设备的完整程序,也没有宣称已在目标 Windows 环境重新编译验证。

    文中“已实现”指已有版本与现场反馈支持的行为;“示例”指本次为解释该行为写的代码;“后续可改进”则仍属于未来工作。这样可以公开足够多的工程内容,同时不把示意当成原始证据。

    最后开发程序叫《电子价签管理系统》。它是一套 Windows 桌面工具,接收收银系统的商品更新,处理价格和单位,把商品提交到现有平台,再继续使用原有小程序及既有下发链路更新价签。

    整个过程跨过了旧硬件调查、设备资料阅读、通信观察、静态分析、数据建模和桌面软件交付。我负责现场操作、抓包、业务判断与真机反馈,AI 协助整理证据、分析程序和实现迭代。它最有价值的地方,是把这些分散环节接成了一条能验证、能修改、能实际使用的工作链路。[14] [15] [16]

    01|研发背景:小超市管理难题

    本人父母经营着一个经济效益非常不错的综合超市。但是去年,自我从美国被遣返后,超市恰好走到了一个十分微妙的位置。小型超市经营一般有两大高峰,其一是早高峰,其二是晚高峰。其中,压力最大的是早高峰的开始。早高峰开始之前,店内员工必须要进行商品入库——出库上架——更新商品价格——开售。

    所以,对于任何综合超市,都存在两种现象。其一是中午的时候超市员工和商品达成完美配合,但是早高峰的时候一定会乱糟糟的。其二是早高峰的时候员工和商品达成完美配合,但是中午的时候有大量散步工作人员。就我目前观察,即使部分超市可以实现中午和早上都完美配合。其本质也只是疯狂的压力员工,让这些员工在早上承担恐怖的压力。

    而我父母的超市正好就走到了这个位置,早高峰他们忙不过来,我必须要充当员工,但是到了中午,我又整体清闲下来了。所以我就在思考如何通过优化流程提升管理效率,让我这十个“早上忙,中午闲”的员工彻底消失。

    这之中,就引出来了一个非常费时间的过程:改价。就我观察而已,改价过程约占整个理货过程的一半,除非有专门的改价员对接收银系统,不然改价过程非常折磨。员工或是要根据自己负责栏位的种类去查询收银系统更新后的价格。或者主动询问“老板”价格。然后就要重复进行商品价格标签的摘取,改价,悬挂流程。而且非常容易出错,以我家的四种不同价格的梨为例:南国梨,精品南国梨,花盖梨,北京一号梨。除非栏位负责人很有经验,不然这四种里同时销售的周期里,他们弄混这四种梨中的两种的概率为百分之百。

    文章配图
    这种手写牌子耗时又耗力

    上图左边展示的就是传统手写牌子,这个东西耗时又耗力。于是我考虑寻找另一种方式,我在某东看到了一种数字价签,它本质就是一块显示屏+一个背板。背板可以连接互联网,通过与服务器连接,进而将服务器的商品展示在屏幕上。如下图。

    文章配图
    本图源自京东

    当时我还是没有想自己开发的想法的。于是我在某个做这个的公司里购买了几十块。结果到手后,我就蒙圈了。先不说,整个板子的溢价非常严重,我估计利润约为70%-50%,其后续修改内容繁琐又笨拙,各种基础功能缺失,就连修改模板都难度巨大。一气之下我就动了完全自己开发一个这种系统的想法,就目前而言,通过ai,我开发另一个可以和收银机通过大华tma读取plu商品的软件。而将整个数据下载到屏幕上简直易如反掌,考虑到目前本人要去外地。所以这部分没有开发,我目前这软件是可以完美的在自己的收银系统上允许的。

    同时这也是整个中小型超市收银/水墨价签/数字价签一体化系统的一部分。稍微说一下这个项目的开始,他最早从二手电子价签开始。我看过汉朔相关硬件的改造思路,也准备过开发板、调试器和测试夹,考虑能否建立自己的通信与管理方案。ATC_TLSR_Paper、stellar-L3N-etag 以及相关适配文章,给了我一些早期方向。它们属于价签固件和设备改造参考,不能据此认定我的价签型号已经完成适配。[1] [2] [3] [4]

    与此同时,现有的小程序已经能够更新价签电子。我于是把近期目标收窄:先接通商品管理这一层,保留已有的价签下发能力。 硬件研究没有被证明不可行,只是暂时不再是本轮交付的前提。[16]

    最终业务链路:收银系统、自建工具、现有平台、小程序与价签

    图 1:最终业务边界。图中是逻辑流向,不表示手机直接实现了价签的底层无线通信。

    后续内容都为AI开发过程的博客,如果你也想做一个这种系统,就按照主要工作内容上进行,如果你也不是很懂代码、编程和逆向。也行你也需要借助AI助手

    02|先弄清楚,谁在什么时候更新了谁

    我在收银机里新增了一个测试商品,桌面软件和小程序里都出现了它;执行商品同步时,同码商品也会被更新。可是在价签端删除商品,收银机里的商品仍然存在。

    这些现象很有帮助,却不足以直接写出一个完整架构。尤其“两个地方都更新了”,并不能说明它们一定相互推送,也不能说明收银机数据库与平台商品是同一份数据。后面的测试又补出了一个重要条件:收银机是否带有相应 PLU 记录,会影响自动传输是否触发。[15] [16]

    我们逐步把问题拆开:收银机如何产生商品记录?原桌面软件如何接收?平台如何保存商品?小程序何时读取并下发?每个环节都要有对应观察,不能仅凭最后一块屏幕变了,就推断中间所有机制。

    两份设备资料提供了背景:一份介绍收银系统与标签秤的连接和商品同步操作,另一份说明某类 PLU 记录的含义。但资料里的样例和版本,并不自动等于现场实际发送的格式。因此它们用于理解概念,具体实现仍以实测记录为准。[12] [13]

    这也是本文保留资料目录、却不转载原始协议内容的原因:说明推理依据,与公开一套可以照着实现的对接规格,是两种交付。

    2.1|先分清三种角色,再讨论怎么接

    项目里允许公开的设备与系统名称包括乐悠客和大华。这里需要把它们放回具体位置:乐悠客是收银系统一侧;《大华标签称连接乐悠客收银系统》提供的是标签秤连接和同步操作背景;自建工具接收商品变化,再把处理后的商品交给既有价签平台。标签秤资料与电子价签的无线协议不是同一层内容。

    对象 需要理解的事实 不能直接推导的结论
    乐悠客收银系统 商品与 PLU 记录、实际触发的发送动作 平台中有商品,不代表收银机里一定有对应 PLU
    大华标签秤相关资料 收银系统如何设置连接、商品如何同步 资料中的每个示例字节都等于现场版本
    原桌面客户端 收到数据之后怎样处理、何时请求平台 界面出现新商品就一定存在后台推送
    电子价签平台及小程序 保存平台商品,继续使用既有下发链路 平台删除会自动清除收银系统里的商品

    因此,我们不是把一个“商品编号”从左边抄到右边就完成了对接。至少要区分收银机 PLU、商品代码与平台商品标识,再根据已经核对的规则关联它们。公开文章可以展示软件怎样保留这些概念;没有证据的编码映射则不应补写。

    03|本地工具先跑起来,才有讨论平台的基础

    最初几版的目标很朴素:收到收银机传来的记录,保存下来,正确显示名称、PLU、单位和价格。

    数据处理也不能跳过。TCP 提供的是有序字节流,应用层的一条记录,不能想当然地等同于一次读取或一个网络包。接收端需要维护缓冲,按照已经验证的消息边界处理,并考虑不完整输入。RFC 的字节流定义和 Wireshark 的流跟踪、重组说明,为这部分提供了通用原理;本项目的具体边界及响应内容不在公开稿中展开。[5] [6] [7]

    到 V1.3,本地商品管理和单位处理已经形成可用基础。我的要求是:公斤商品明确显示公斤单位;启用市斤转换时,单位和价格必须同时变化,计件商品不能跟着减半。比如一件虚构的称重商品,原价为每公斤 20 元,转换后就是每 500 克 10 元。这只是业务换算示例,不是通信报文。[16]

    我们还把原始价格与用于展示、提交的价格分开处理,避免刷新或重复处理时再除一次。早期关于“疑似删除”的标记也在这一阶段逐渐出现:没有收到记录,可以是值得检查的线索,但不能直接等同于平台已经确认删除。

    3.1|先存原始业务值,再生成显示值

    价格一旦经过换算,就不能再当作原始价格回写。最容易犯的错误是:刷新时把 20 元/公斤变成 10 元/市斤,第二次刷新又拿 10 除以 2。界面看起来只切了一个开关,底层却悄悄改变了数据。

    教学模型把“来源里的价格”和“这一刻要显示的价格”分开。金额使用 decimal;计价方式用枚举表达,避免到处比较中文字符串。PLU 使用可空值,是为了允许平台独有商品没有收银机 PLU;这不是在恢复原平台的字段定义。

    C# · 教学重写 · 不含现场接入参数

    enum SaleKind { ByWeight, ByPiece }
    
    sealed class Product
    {
        public string LocalKey;
        public int? CashierPlu;
        public string PlatformKey;
        public string Name;
        public SaleKind Kind;
        public decimal SourcePrice;  // 保留来源业务值
    }
    
    sealed class PriceView
    {
        public decimal Price;
        public string Unit;
    }
    
    static PriceView BuildPriceView(Product item, bool useJin)
    {
        bool convert = item.Kind == SaleKind.ByWeight && useJin;
        return new PriceView {
            Price = convert ? item.SourcePrice / 2m : item.SourcePrice,
            Unit = item.Kind == SaleKind.ByPiece
                ? "个" : (convert ? "500g" : "kg")
        };
    }

    每次都从 SourcePrice 重新计算。一个虚构称重商品的公斤价为 20,公斤模式得到 20,市斤模式得到 10,再切回公斤仍得到 20。计件商品原价为 6,两种模式都显示 6 元/个。

    显示和提交应使用同一次换算结果,并共同带上对应单位。小数位数与舍入方式要按业务要求明确约定;上面的片段故意不预设舍入规则,以免把文章中的选择误写成平台要求。

    3.2|一次 Read,不一定是一条商品记录

    接收端最初要解决的不是把字节马上变成字符串,而是先找到完整记录。一次 Read 可能只拿到一半,也可能同时拿到几条。中文多字节字符也可能被拆在两次读取之间,所以不能对每一小段网络数据独立解码,再靠字符串拼接碰运气。

    下面把实际消息边界留给已经验证的解码器。解码器必须遵守约定:不完整时保留缓冲、返回 false;完整时只取走一条,并至少消费一个字节;非法输入抛出明确错误。具体边界如何识别,需由现场样本确定,这里不虚构帧头或字段偏移。

    C# · 教学重写 · 不含现场接入参数

    interface IRecordDecoder
    {
        bool TryTake(List<byte> pending, out byte[] record);
    }
    
    static void ReceiveRecords(NetworkStream stream,
        IRecordDecoder decoder, Action<byte[]> accept)
    {
        byte[] incoming = new byte[4096]; // 仅为示例缓冲大小
        List<byte> pending = new List<byte>();
        int count;
        while ((count = stream.Read(incoming, 0, incoming.Length)) > 0)
        {
            for (int i = 0; i < count; i++) pending.Add(incoming[i]);
            byte[] record;
            while (decoder.TryTake(pending, out record))
                accept(record);
    
            // 示例保护阈值,并非设备协议规定的最大报文长度
            if (pending.Count > 65536)
                throw new InvalidDataException("未找到完整记录,停止处理");
        }
        if (pending.Count != 0)
            throw new InvalidDataException("连接结束时仍有不完整记录");
    }

    缓冲大小和保护阈值只是教学参数。完整程序还要处理取消、断线、连接生命周期和异常提示;不能为了保留几行代码,把所有输入都当作正常记录。NetworkStream.Read 的返回值表示本次实际读取字节数,正常关闭时会返回 0,这与应用层记录完成是两回事。[17]

    在已知记录完整之后,才进入名称解码、字段校验和商品对象构造。接收失败与名称解码失败也应分别记录,不然最终只能看到一个笼统的“同步失败”,无法判断问题发生在哪一层。

    04|抓包和静态分析,解决的是不同的问题

    接下来,要让自建工具真正替代原桌面软件的商品接入部分。

    抓包先给出了动态证据:在什么操作之后,出现了什么请求,服务端有没有成功响应。原程序则提供了另一类线索:组件名称、字符串引用、回调关系和代码路径。Wireshark 的流跟踪能力帮助我们按连接查看应用层交互,而不是只盯着单个包。[6] [15]

    分析原程序时,先遇到了可执行文件压缩。经过 UPX 解压和相应完整性检查,文件从约 17 MB 还原到约 60 MB,并识别出它是一套 Delphi 编译的 32 位 Windows 程序。UPX 官方文档说明了其解压及完整性测试能力;本次文件规模和分析结果,则来自项目阶段记录。[8] [14]

    这一步并没有“恢复完整源码”。得到的主要仍是机器码、资源和可供追踪的信息。我们没有逐行解释整套程序,而是沿着与商品接收、认证和提交有关的路径缩小范围。

    一个关键发现是:商品接收与解析并非全部写在主界面代码里,其中一段工作由独立组件承担。沿主程序的回调路径追到数据处理,再与实际上传样本对照,才能确认某个字段最终去了哪里。看到一个可疑字符串,只能说明它值得调查;出现了一个分支,也不代表当前现场正在走那个分支。[14]

    这轮分析还纠正了一个容易犯的错误:PLU、收银机商品代码和平台商品编码,不能凭“都是数字”就混成一个编号。 内部保留各自含义,按证据建立转换关系;公开稿则不展示具体字段名、补零规则和映射公式。[13] [14]

    4.1|让一次操作对应一组可核对的证据

    抓包最有价值的部分,是把动作与结果对应起来。我们会先确定一个测试商品,记录操作前的状态,再只做一个动作。比如“在手机里改价”和“在电脑里点排序”必须分开,否则后来看到一次查询,也不知道到底是谁触发的。

    观察步骤 要记录什么 下一步判断
    先保持电脑端不操作 是否出现商品查询,列表是否自行变化 为“自动刷新”提供观察窗口
    只在手机端修改一个虚构测试商品 变化发生时间、平台是否已保存 避免把手机未保存当作电脑不同步
    再操作电脑端排序 查询是否出现、列表何时更新 区分本地排序与重新读取数据
    最后才重启或重新登录 变化能否持久保留 区分内存变化与平台持久状态

    静态分析则沿调用关系回答另一类问题:某个按钮走到哪个函数,认证结果怎样被保留,接收到的商品怎样进入提交路径。它能缩小调查范围,但“代码里存在”不等于“这次操作执行了”。这也是为什么我们会把反汇编线索与现场请求重新对照。

    公开手记不需要留下原软件专名、安装路径或完整窗口标题。写清“32 位 Delphi 程序、先解压再追踪商品处理回调”,已经能解释技术选择;把名称换成“原桌面客户端”,不会损失这条推理链。

    05|先让一个测试商品走完全程

    V1.4 的目标不是一次上传整店商品,而是验证最小闭环:先登录,查询测试商品,再核对提交前后的结果。只有平台更新和小程序读取都确认之后,才继续向批量和自动化推进。[16]

    这一轮还暴露了一个产品问题。早期界面把门店、分类等内部编号交给用户填写,我并不知道这些值。之后才进一步核对原程序的处理方式:有的值属于原程序内部处理,有的值需要读取平台分类。界面应该显示可理解的分类名称,而不是要求操作人员记住一串内部编号。

    V1.4.1 因此把登录收敛为账号和密码,并按名称提供分类选择。随后,查询、单商品更新,以及通过原小程序刷新实体价签,都得到了我的现场反馈确认。这里的关键证据是实际链路的验证,不是某一张“请求成功”的截图。[16]

    从本地接收到最终桌面交付的版本演进

    图 2:版本演进。图示压缩了小版本,正文保留影响最终方案的关键变化。

    5.1|把协议适配留在边界,业务逻辑才容易单独验证

    如果界面按钮直接拼请求、直接拆响应、同时修改本地商品,任何一个字段变化都会牵动所有层。更清楚的组织方式,是先把经过校验的商品变成业务对象,再由平台适配层完成真实接口的转换。

    下面的接口只是为文章定义的一层边界,不代表已经还原的服务器 API;它甚至不暴露请求路径。这样就能用一个模拟实现验证“更新成功怎样标记、删除失败怎样保留状态”,而不必每次都用真实门店数据试错。

    C# · 教学重写 · 不含现场接入参数

    interface IProductGateway
    {
        Task<GatewayResult> UpsertAsync(Product item, PriceView price);
        Task<GatewayResult> DeleteAsync(string platformKey);
    }
    
    sealed class GatewayResult
    {
        public bool Confirmed;   // 适配层确认业务操作成功
        public string SafeMessage; // 脱敏后的用户提示
    }

    Confirmed 不能简单写成“HTTP 请求有返回”。网络层成功、业务返回成功、平台商品更新、小程序读取和价签显示,是不同层次的确认。早期先用单个测试商品走完全程,正是为了避免在链路尚未验证时就批量写入。

    分类选择也属于这一层:用户需要看到分类名称,而适配层才负责保存和提交所需的标识。让用户自己填内部编号,会把对接工作转嫁成日常操作负担。

    06|一次“自动刷新”的误判,改变了同步设计

    V1.5 加入了平台变化同步和登录保持。为了让小程序改动出现在电脑里,测试版采用过固定间隔的全量查询。

    我接着问:这个频率是不是原程序的行为?会不会与服务器预期不一致?当时并没有足够证据可以保证。于是我们重新安排实验:让原程序闲置,再在手机端修改商品,最后才在电脑端操作。

    结果很有意思。我为了检查列表是否变化,点了两次按 PLU 排序,商品这才出现。对应抓包显示,观察时段里的商品查询出现在最后的排序操作附近。随后我又单独测试:不点排序,新增商品并没有自动显示。[15] [16]

    区分闲置、手机修改和电脑排序的验证过程

    图 3:实验逻辑重绘,不是真实抓包截图,不按实际时间比例绘制。

    这使我们收回了“原程序持续自动刷新”的早期判断。更准确的结论是:在这次环境与操作记录中,排序触发了重新查询;此前的观察不足以证明存在自动商品推送。 它也不能被扩大为“平台在任何版本、任何情况下都没有通知机制”。

    最后,我决定删除定时查询功能:进入软件时读取一次,之后点击刷新或排序再更新;收银机发来商品时,按事件处理并提交。这里删掉的是平台轮询,不是本地用于判断一批接收是否结束的计时。

    还有一个没有被掩盖的差异:我们的刷新会读取完整分页,而当次观察到的原程序查询的是当前页。因此最终实现没有宣称“请求量与原程序完全相同”,更没有用“已经登录成功”来推导“服务器一定不会限制”。[16]

    6.1|为什么“点击排序”可能是一次网络操作

    一个按钮叫排序,不代表它只在内存中交换几行。它可能带着排序条件重新读取平台当前页。因此,观察到“排序以后新增商品出现”,更稳妥的解释是“该动作关联了重新查询”,还不能倒推此前一直存在自动推送。

    最后保留三类入口:登录或恢复会话后首次读取;用户手动刷新或排序;收银机发来商品时按事件处理。删除的,是固定周期的平台全量查询;接收批次结束的本地计时仍有另一项职责。

    启动 / 恢复会话 → 加载商品列表
    手动刷新 / 排序 → 按当前条件读取平台数据
    收银机商品事件  → 校验 → 更新本地 → 提交对应商品
    接收暂时安静    → 结束当前接收批次 → 更新 PLU 判断
    

    这几条路径不要共用一个含义不明的“同步”按钮或计时器。否则排查时很难知道,是平台分页还没读完、刚收到一条本地记录,还是批次判断尚未结束。

    完整分页读取还带来一个细节:不要读完第一页就清空旧列表,等第二页失败后留下一个看似完整的商品表。教学上可以先收集临时结果,所有页面完成后再替换展示快照;这是一种说明问题的组织建议,不是新增的现场验证结论。若平台没有跨页快照能力,即便每页都成功,也不能保证翻页期间数据完全没有变化。

    07|删除、登录和兼容性,把工具推进到日常使用

    删除首先是一项业务动作。 只删本地记录,小程序里的商品不会自动消失。为核实原程序的做法,我专门删除了一个测试商品,并确认小程序中的对应商品确实消失。再对照请求与返回,才把联动删除接入自建工具。[15] [16]

    最终保留的规则是:疑似删除不参与上传;明确执行删除后先进入待删除状态;平台确认后完成本地清理。失败时保留待删除,防止后续旧记录又被自动提交。平台商品删除,也不意味着收银机中的商品或 PLU 已经消失。

    商品更新和删除状态的示意

    图 4:状态设计。图中描述商品状态与确认边界,正文给出相应的教学代码。

    登录则需要成为整个软件的入口。 我不希望先打开一个管理器,再进入另一个“平台登录”窗口。经过几轮说明,流程才最终统一:账号密码进入软件,后面的刷新、排序和商品处理共享这次会话;需要更换账号或更新密码时,有明确入口可用。

    记住账号、记住密码和开机启动是不同选项。保存凭据使用 Windows 当前用户的数据保护能力,而不是把密码写进普通文本文件。Microsoft 的 ProtectedData 文档解释了 DPAPI 的作用;它解决的是本地保存的保护问题,不意味着凭据在任何环境下都不可能泄露。[9] [16]

    兼容性也确实出过问题。 有一版在开发环境编译通过,但在 Windows 上刷新列表时,出现字符串拆分方法找不到的错误。最后改用目标旧运行时支持的重载,并检查编译后的调用。Microsoft 的 API 文档有助于核对签名,现场错误和修复结果仍来自本项目验证。[10] [16]

    这个教训很直接:选定旧运行环境之后,不能只看“我这里能编译”。界面、运行时 API、图标格式和启动方式,都要对应目标机器检查。

    7.1|删除要保留“还没做完”的状态

    如果先从本地列表删除,再请求平台,网络失败时就丢失了待办信息。下次收银机重发旧商品,程序可能又把它上传,用户看到的就是“明明删了,怎么又回来了”。待删除状态的意义,是在平台确认之前持续记住这个操作,并阻止普通更新重新排队。

    下面只演示状态转移。真实程序应让状态变化与本地保存配合,并对同一商品的接收、提交、删除协调顺序。失败后不做无限自动重试,是否重试应由明确的操作策略决定。

    C# · 教学重写 · 不含现场接入参数

    enum SyncState { PendingUpload, Synced, PendingDelete }
    
    sealed class LocalEntry
    {
        public Product Product;
        public SyncState State;
    }
    
    static bool CanUpload(LocalEntry entry)
    {
        return entry.State != SyncState.PendingDelete;
    }
    
    static async Task<bool> DeleteOneAsync(LocalEntry entry,
        IProductGateway gateway, Action persistPending,
        Action removeLocal)
    {
        entry.State = SyncState.PendingDelete;
        persistPending(); // 先把意图留在本地;持久化失败则不继续
    
        GatewayResult result = await gateway.DeleteAsync(
            entry.Product.PlatformKey);
        if (!result.Confirmed) return false; // 保留待删除,可再次处理
    
        removeLocal(); // 平台已确认后才清理本地
        return true;
    }

    这个片段不包含异常捕获、并发锁或平台身份验证;这些是完整工程外围必须补齐的部分。它展示的核心只有两点:先保存意图,再做远端操作;收到明确成功后,再清理本地。若平台已成功而本地清理失败,后续还要能识别“远端已经完成”,不能一概当作第一次删除。

    另外,“疑似删除”不等于这里的 PendingDelete。前者只是缺失观察,需要人工确认;后者是已经发起、尚未完成的明确操作。把两者混在一起,会让不完整的接收批次直接触发危险的批量删除。

    7.2|记住密码,不等于保存一份可读的配置文件

    “记住账号”和“记住密码”应分开。账号可以作为普通偏好,密码则需要本地保护。Windows DPAPI 的当前用户作用域,可以让保存的数据与当前 Windows 用户环境关联。[9]

    C# · 教学重写 · 不含现场接入参数

    static byte[] ProtectLocalSecret(string secret)
    {
        byte[] plain = Encoding.UTF8.GetBytes(secret);
        try
        {
            return ProtectedData.Protect(plain, null,
                DataProtectionScope.CurrentUser);
        }
        finally { Array.Clear(plain, 0, plain.Length); }
    }

    这里没有演示任何真实凭据。清空临时字节数组只是减少一份明文副本;传入的 .NET 字符串、已登录进程及当前用户权限仍有各自边界。不能因为调用了一个加密 API,就把它写成“密码绝不会泄露”。同样,不应在异常日志里输出完整登录请求、认证头或响应正文。

    08|登录变快,先从少等几件事开始

    后期我觉得登录明显慢于原程序。检查流程发现,入口曾经要串行等待额外的网络查询和分类读取;恢复登录时,还可能等完整商品分页处理完,才把主界面交给用户。

    V2.0 调整了顺序:已有会话先恢复并进入主界面,商品列表在后台加载;分类在实际需要时读取。认证所需的步骤仍然执行,没有为了缩短等待而绕过登录。[16]

    这类优化的效果,可以拆成两个指标:多快能开始使用界面,以及后台数据何时加载完成。 前者改善,不代表所有网络请求都已结束。项目没有在同一网络和设备上完成严谨的速度基准,所以文章也不把它写成“稳定一秒登录”。

    8.1|把“能开始用”和“数据全到齐”拆成两个时刻

    登录慢,有时不是认证本身慢,而是入口绑了太多等待:先认证、再额外查询、再取分类、再读全部商品,最后才显示窗口。用户会把整段时间都理解成“登录耗时”。

    后期实际调整的是顺序。已有可恢复会话时先恢复并进入主界面,商品列表随后加载;分类在需要的时候读取。认证仍然按要求执行,失效会话仍要重新登录。界面可以尽早出现,但必须清楚标记商品“正在加载”,不能把空列表显示成“没有商品”。

    时刻 界面应表达什么 不能混淆什么
    会话恢复或登录完成 允许进入主界面,展示加载状态 认证通过不代表商品已加载完
    商品分页全部返回 展示当前读取结果 第一页返回不等于全量完成
    分类选择被打开 按需获取或使用有效缓存 不必为未使用的功能阻塞每次启动

    这部分没有同机、同网条件下的严格基准数据,所以更适合写“减少了进入主界面前的串行等待”,而不是编造快了多少倍。主观体验和可重复测量都可以记录,但两者不能互相代替。

    09|从两张表,到一张真正顺手的商品表

    中间版本把“收银机记录”和“平台商品”分成两页,用于解释两份数据的区别。开发和排查时,这很直观;每天操作时,却多了一层理解成本。

    我的最终选择是合并为一张商品表。收银机发来商品后,直接更新相应商品并自动提交。平台独有的商品也可以保留,没有收银机 PLU 就留空,不必为了两边完全一致而删掉它。界面不再展示双页核对流程,底层仍保留商品来源、原始价格和待同步状态,以支持重试与恢复。[16]

    一次性手动查询和单商品上传窗口完成了验证使命,随后从界面移除。留下的是多选、全选、联动删除、市斤转换、刷新,以及按门店习惯设置的账号与启动功能。视觉上,程序统一为天蓝色,并正式命名为《电子价签管理系统》。

    新程序选择了 C# 与 Windows Forms,最终交付为可直接运行的 Windows 可执行文件,不再要求店里额外安装 Python。接收处理、平台请求和界面更新分工执行,本地保留商品缓存与未完成操作,登录凭据则单独加密保存。这是围绕门店现有 Windows 环境做出的工程选择,并不是对原程序源码的直接翻译。[9] [16]

    最终单页界面功能示意

    图 5:按最终功能重绘的示意界面。表内名称、编号及价格均为虚构,不构成实际数据样本。

    这里还有一件容易忽视的事:升级时,旧本地记录不能不加区分地覆盖平台新数据;小程序已经删除的商品,也不应该仅因为电脑重启、重新读到旧收银机记录,就又自动出现。这些边界进入了迁移和缓存检查,而不仅是把两张表在视觉上拼起来。[16]

    9.1|一张表可以简单,底层数据不能混成一份

    合并界面以后,同一个列表里可能出现三种行:收银机已有 PLU 且平台也有商品;只收到收银机商品、仍待提交;仅存在于平台、没有本地 PLU。看起来是一张表,来源和状态仍然要能分辨。

    因此刷新平台列表时,不能简单把平台商品编码写成收银机 PLU;收到收银机记录时,也不能用旧缓存无条件盖掉平台新结果。最小可用 PLU 只看收银机占用,上传状态只描述平台同步,两者回答的是不同问题。

    Windows Forms 还有一个很具体的实现细节:网络接收通常不发生在界面线程上,后台线程不能直接操作表格控件。示例把数据结果交回窗体线程;真正的程序还要保证窗口已创建、关闭过程协调好后台任务。[18]

    C# · 教学重写 · 不含现场接入参数

    // 示例:在主窗体已显示、且未开始退出时投递一次 UI 更新。
    void PostRowsToUi(List<Product> rows)
    {
        if (IsDisposed || Disposing || !IsHandleCreated) return;
        BeginInvoke((MethodInvoker)delegate
        {
            if (IsDisposed || Disposing) return;
            productGrid.DataSource = rows;
        });
    }

    rows 应是交给 UI 的独立快照,避免后台继续改同一个集合。上面只展示线程切换;窗口销毁与投递之间仍可能发生竞争,完整工程应在退出流程取消并收束后台工作,再处理相应异常。批量接收时还可以合并界面更新,避免每条商品都重绑整张表;这里属于后续性能改进方向,不冒充已测得的优化成果。

    10|最后一轮修改,都是很小、却每天会碰到的事

    软件能用之后,我提出了新的小要求:名字固定下来,图标显眼一点,能创建桌面快捷方式,关闭后留在右下角,点一下就打开。

    这里也有过一次做复杂的过程。程序曾经增加了通知区域设置入口,也尝试在关闭后保留任务栏入口。我实际用过后,决定把它们简化:关闭窗口隐藏到托盘,单击左键恢复,右键提供菜单。 Windows 决定托盘图标是否收入折叠区域,程序不把“保证常驻展开”当成已经实现的能力。NotifyIcon 的鼠标点击事件是实现交互的基础,具体体验则由现场使用决定。[11] [16]

    搜索商品也在最后被移除,我更需要直接看到最小可用 PLU。这个编号必须依据收银机,不能根据小程序的商品编码来判断。为此,PLU 占用与平台商品状态保持独立:平台删除不会直接释放收银机里的编号。

    最初做过手动全量校准,操作上又多了一步。最终 V2.2 按我的要求改为自动判断:同一连续接收批次出现至少两个不同 PLU,就视为一次同步;接收结束后,按本批记录更新占用表并计算最小可用 PLU。 同一个 PLU 重复收到不算两个不同 PLU;普通单条更新只补充占用。[16]

    这条规则需要讲清楚:它是本门店选择的启发式判断,并没有从协议层证明“全量同步已完整结束”。如果一次操作只发送了少量不同商品,仍可能触发重建,进而把实际仍被使用、但本批没有传来的 PLU 误判为空闲。它符合本次选择的简化流程,不能被写成适用于所有收银系统的通用完整性保证。

    10.1|最小可用 PLU:计算很简单,输入边界更重要

    PLU 的业务范围是 1—9999。计算最小空号本身不难:只要有一份可信的收银机占用集合,从 1 开始找第一个未使用编号即可。难点是这份集合代表一次完整同步,还是若干零散更新。

    C# · 教学重写 · 不含现场接入参数

    static int FindFirstFreePlu(IEnumerable<int> cashierPlus)
    {
        HashSet<int> occupied = new HashSet<int>();
        foreach (int plu in cashierPlus)
        {
            if (plu < 1 || plu > 9999)
                throw new ArgumentOutOfRangeException("cashierPlus");
            occupied.Add(plu);
        }
        for (int plu = 1; plu <= 9999; plu++)
            if (!occupied.Contains(plu)) return plu;
        return -1; // 范围已满,界面必须显示“无可用 PLU”
    }

    例如虚构占用集合为 {1, 2, 4},返回 3;同一个 PLU 重复收到多次,集合仍只占一个位置。平台删掉一个商品,不会自动从这份集合移除其收银机 PLU。

    10.2|V2.2 的自动批次判断,为什么只能叫启发式

    最终选择是:连续接收的一批数据里出现至少两个不同 PLU,就按一次同步处理;普通单条更新只补充占用。下面的片段专门把这条规则写出来,让它的取舍能被读者看见。

    C# · 教学重写 · 不含现场接入参数

    static HashSet<int> ApplyObservedBatch(
        HashSet<int> previous, IEnumerable<int> received)
    {
        HashSet<int> batch = new HashSet<int>(received);
        foreach (int plu in batch)
            if (plu < 1 || plu > 9999)
                throw new ArgumentOutOfRangeException("received");
    
        // 项目采用的判断:多个不同 PLU 视作一次同步。
        // 这不是设备协议给出的“完整批次”证明。
        if (batch.Count >= 2) return batch;
    
        HashSet<int> merged = new HashSet<int>(previous);
        merged.UnionWith(batch);
        return merged;
    }

    看一个会出问题的例子:先前占用 {1, 2, 3, 4},这次只收到 {1, 4},按上述规则就会重建为 {1, 4},随后把 2 判成空闲。如果 2 和 3 实际还在收银机里,这个建议就是错的。把反例写出来,读者才能真正理解“操作简化”换来了什么。

    如果将来协议里找到可靠的同步开始、结束或总数标记,可以据此改进完整性判断;如果没有,至少应考虑把结果标成估计值、保留上次占用或提供人工复核。这些是改进方向,当前文章不把它们写成 V2.2 已具备的功能。

    11|怎样确认它能用,又怎样知道还没证明什么

    验证是分层进行的。

    验证层 本项目做了什么 能说明什么
    离线数据检查 用原始样本对照商品解析、单位换算与编码处理 已知样本及对应规则是否处理一致
    模拟通信检查 本地接收、响应、构造请求、模拟成功与失败 接收至提交的代码路径是否连通
    界面与状态检查 多选、托盘、登录恢复、删除状态、重启缓存 这些交互与状态在测试环境中的行为
    门店实测 登录、商品更新、小程序变化及既有价签刷新 本门店当前账号、环境与操作下的实际结果

    最终一轮交付记录列出了 149 项离线及图形检查,并包含真实报文样本到模拟平台提交的本地链路检查。各版本检查集会调整,不能把不同轮次的数字简单累加,更不能把用例数量当作测试覆盖率或商业认证。此后我反馈“都成功了”,这是实际使用结果的一部分;它不等于已经完成长期负载测试或所有异常组合的验证。[16]

    收尾时,我还问过代码是否值得继续优化。结论是保留 V2.2 作为稳定基线,后续根据真实问题再处理性能、日志和备份等事项。它们是后续可以改进的方向,不应该在博客里被写成已经全部具备的能力。

    11.1|给算法写反例,比只演示一条成功路径更有价值

    下面这些是新增示例应当对应的检查点,用来解释“为什么这样写”。它们不属于原交付记录里的 149 项检查,也不代表本次已经在 Windows 机器上重新运行了原项目测试。

    示例输入 / 操作 期望结果 要防住的问题
    称重价 20,公斤→市斤→公斤 20→10→20;来源值始终为 20 重复除以 2
    计件价 6,切换市斤开关 仍为 6 元/个 全局开关误伤计件商品
    一条记录拆为多次读取 凑齐后才产生一条记录 把半条数据当完整商品
    一次读取含多条记录 逐条取出并保留末尾半条 只处理第一条,丢失剩余字节
    平台删除失败,收到旧商品 保留待删除;不当作普通商品上传 删除后又出现
    占用 {1,2,4},另有重复 2 最小可用 PLU 为 3 用计数代替集合,或混用平台编号
    占用覆盖 1—9999 返回无空号状态 错误生成 10000
    旧集合 {1,2,3,4},批次仅 {1,4} 揭示重建会误判 2 空闲 把启发式说成完整性保证

    这些检查也帮助划分故障位置:收到的记录错,先查接收与解析;单位和价格不匹配,查派生逻辑;平台已经变化但界面没变,查刷新和 UI 更新;删除后重新出现,查待办状态和旧缓存回灌。这样排查,比看到问题就重写整套程序更有针对性。

    12|这次协作,留下了哪些经验

    回头看,这项工作的难点分散在很多地方。一个细节可能属于设备通信,另一个属于商品语义,再下一个就成了 Windows 托盘的单击体验。

    我最看重的是三件事。

    让操作和证据一一对应。 每次只改一个测试商品,记录先后顺序,分清手动动作与自动行为。排序触发刷新那次纠错,比一开始写出一个漂亮架构图更有价值。

    让实现跟着证据收敛。 抓包、静态分析和设备文档各自回答一部分问题。它们相互校验,但谁也不能单独替代整条链路的实测。已经验证完使命的临时窗口,也可以删除。

    让每轮协作都有具体产物。 “下一步继续分析”与“此刻正在执行分析”需要说清楚;一次回复结束,也不代表有任务仍在后台运行。可核对的发现、程序版本和明确测试步骤,才能让远程协作持续前进。[16]

    最终的软件没有把每个设想都塞进去。它接住了收银机的商品更新,保留了原有价签下发能力,也逐渐贴近了门店每天的操作习惯。对这个项目来说,能在现场稳定地完成这条工作链路,就是这一阶段最重要的成果。


    参考资料与引用说明

    编号对应正文中的〔数字〕。 公开资料解释通用原理;本项目的行为结论以内部操作记录、分析记录及实测反馈为依据。以下是本次开发复盘能够核对到、并在正文实际使用的资料,不声称穷尽所有浏览历史。网页核查日期:2026-10-28。

    公开参考:早期硬件路线

    1. ATC_TLSR_Paper,项目作者仓库。 https://github.com/atc1441/ATC_TLSR_Paper
      引用位置:第 01 节。用途:早期了解价签固件改造路线;不是本软件平台对接代码的来源。
    2. stellar-L3N-etag,reece15 项目仓库。 https://github.com/reece15/stellar-L3N-etag
      引用位置:第 01 节。用途:了解设备适配与已有改造实践,未据此宣称本项目硬件已完成适配。
    3. reece:《〖墨水屏〗2.9寸墨水屏固件适配、二次开发,主控芯片:TLSR8359》。 https://javabin.cn/2022/epaper.html
      引用位置:第 01 节。用途:早期固件适配背景;本次已读取文章。
    4. 立创开源硬件平台:用户早期提供的汉朔墨水屏研究文章线索。 https://oshwhub.com/article/research-of-hanshow-epaper
      引用位置:第 01 节。状态:本次访问返回 403,题名与正文未能复核,保留为历史线索,不作为具体实现结论的依据。

    公开参考:通信、分析与 Windows 实现

    1. IETF / RFC Editor:RFC 9293,Transmission Control Protocol (TCP)。 https://www.rfc-editor.org/rfc/rfc9293.html
      引用位置:第 03 节。用途:TCP 有序字节流及应用层消息边界的通用背景。
    2. Wireshark User’s Guide,Following Protocol Streams。 https://www.wireshark.org/docs/wsug_html_chunked/ChAdvFollowStreamSection.html
      引用位置:第 03、04 节。用途:按协议流组织观察,不公开本项目捕获内容。
    3. Wireshark User’s Guide,Packet Reassembly。 https://www.wireshark.org/docs/wsug_html_chunked/ChAdvReassemblySection
      引用位置:第 03 节。用途:流重组的原理与观察边界。
    4. UPX 官方项目及文档。 https://upx.github.io/ ;https://github.com/upx/upx/blob/devel/doc/upx-doc.html
      引用位置:第 04 节。用途:可执行文件解压与完整性测试;本次具体文件分析结果另见资料 14。
    5. Microsoft Learn:ProtectedData;Microsoft 官方 DataProtectionScope 文档源。 https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.protecteddata ;https://github.com/dotnet/dotnet-api-docs/blob/main/xml/System.Security.Cryptography/DataProtectionScope.xml
      引用位置:第 07 节。用途:Windows DPAPI、本地保护作用域与边界。
    6. Microsoft Learn:String.Split Method。 https://learn.microsoft.com/en-us/dotnet/api/system.string.split
      引用位置:第 07 节。用途:核对字符串拆分 API 的重载;不能代替目标运行时验证。
    7. Microsoft Learn:NotifyIcon.MouseClick Event。 https://learn.microsoft.com/en-us/dotNet/API/system.windows.forms.notifyicon.mouseclick?view=netframework-4.8.1
      引用位置:第 10 节。用途:托盘鼠标交互;未据此保证 Windows 将图标固定在展开区域。

    新增代码参考

    1. Microsoft Learn:NetworkStream.Read Method。
      NetworkStream.Read 官方文档。用途:本次读取字节数、连接关闭和应用记录边界的区别。
    2. Microsoft Learn:Control.BeginInvoke Method。
      Control.BeginInvoke 官方文档。用途:将界面操作投递至控件所属线程。代码为本文教学重写,官方资料不证明本项目已完成的具体测试。

    内部依据:列明来源,不随公开稿分发

    1. 《大华标签称连接乐悠客收银系统.pdf》,用户提供,16 页,作者与版本未标明。
      引用位置:第 02 节。核对范围:连接与同步操作背景。仅列书目信息,不附原文、截图或软件下载地址。
    2. 《TMA07-PLU说明.doc》,用户提供,3 页,作者与版本未标明。
      引用位置:第 02、04 节。核对范围:PLU 与商品字段的概念。资料示例不直接视为现场传输规格,不转载报文或格式表。
    3. 本项目《原桌面客户端 静态分析:第一阶段》《第二阶段与 V1.4》。
      引用位置:导言及第 04 节。核对范围:文件解压结果、程序结构、组件调用与样本交叉验证。内部材料不公开下载。
    4. 本项目现场抓包、原始样本及对应操作记录。
      引用位置:导言及第 02、04、06、07 节。核对范围:测试商品变化、排序触发查询、平台删除结果。原文件不随本文提供。
    5. 本项目版本交付说明、相应源码与测试记录、开发对话及门店实测反馈。
      引用位置:全文各版本演进、功能取舍与验证边界。覆盖本地早期版本至 V2.2;原项目源码、账号信息和现场接口配置不随本文提供;正文示例为独立教学重写。

    配图来源: 封面及图 1—5 均为根据本项目事实原创绘制。图 5 为功能重绘,其他图为概念性结构示意。未使用真实抓包截图、账号页面或第三方商品图片。

    公开范围: 本文及配图可用于叙述开发历程;本文包含为讲解设计而重写的代码片段,不附原项目完整源码、原始抓包、现场配置或平台接入参数。

辽ICP备2026026537号-1