摘要:本文介绍一个已经完成服务器部署的 Python 货运机器人项目。系统通过微信私聊和群聊收集运输条件,使用固定格式解析与 DeepSeek 语义提取生成结构化参数,再调用经过抓包验证的运满满业务接口完成校验、询价和用户确认后的发货。文章重点介绍接口适配、地址消歧、报价快照、订金字段、订单防重复提交、群聊会话隔离,以及 Conda + systemd 部署。

适用读者:有 Python 基础,希望将大模型接入实际业务流程的开发者。

文章基于项目 0.5.0 的实现与 2026 年 10 月的部署验收记录。示例中的账号、地点和消息编号均为占位或演示数据;不提供真实登录凭据和业务抓包文件。

**关键词:**Python、DeepSeek、微信机器人、WebSocket、SQLite、运费询价、订单状态机、Conda、systemd。

1. 业务目标与当前实现范围

货运询价涉及的条件比“从 A 到 B 运多少钱”复杂得多。除了装卸地址,还需要货名、数量、包装、车辆、时间、发票等信息。即使用户已经给出一段完整描述,也要进一步确认地址能否定位、车型是否存在、货物是否通过平台校验。

项目希望让用户在微信中完成下面这条操作链:(这里使用的是云端微信SaaS服务框架: www.wechatbot.online, 安全稳定不封控不封号)

发送运输条件
    ↓
补充必要信息、选择有歧义的地址或货物候选
    ↓
展示平台实际返回的各类运费
    ↓
用户确认具体服务与金额
    ↓
提交货源,返回货源编号

用户既可以发送自然语言:

我有一批五金螺母,大概11吨、10方,从苏州发往上海,
1辆9.6米高栏,今天装货,不开发票,帮我查一下运费。

也可以发送标准模板:

装货地址:江苏省苏州市姑苏区某路具体仓库
卸货地址:上海市徐汇区某路具体仓库
货物信息:配件五金,11吨、10方
包装方式:托盘
车辆数:1辆
是否高价值货物:否
车长车型:9.6米/高栏
装货时间:今天随时
卸货时间:不指定
发票类型:不开发票
备注:装货前请提前联系

模板中的地点仅用于说明格式,实际发送时必须替换为能够在地图中定位的地址。卸货时间和备注可以省略;重量和体积建议至少给出一项,减少平台再次要求补充的情况。

当前实现范围如下:

能力实现情况
微信私聊询价已实现
群聊 @机器人询价已实现
固定格式与自然语言参数提取已实现
地址候选确认、货物联想、车长车型校验已实现
装卸时间匹配、发票和备注处理已实现
展示本次接口返回的多种服务价格已实现
整车发货提交已适配抓包中的提交请求与回执
零担拼车提交已按服务卡指向的接口适配,尚缺独立的真实提交回执样本
满帮优车、快车自动提交当前抓包没有完整可执行操作链,暂不开放
专线物流自动提交当前样本指向参数缓存接口,不能直接认定为下单接口
无人确认自动发货未提供;必须由用户发送独立确认指令

价格展示与订单提交是两种能力。平台返回了某种服务价格,并不意味着程序已经掌握该服务完整的提交协议。

本文的运满满接口路径来自项目测试账号正常操作形成的抓包样本,属于当前版本的应用接口适配依据。接入方需要有对应账号、数据和操作授权,并在接口发生变化时重新验证。微信消息收发使用项目配置的接入服务协议,需要接入服务本身已经可用。

2. 技术栈与总体架构

项目使用 Python 3.11 及以上版本,实际服务器环境为 Python 3.13。主要依赖只有几类:

组件用途
requests调用微信发送接口、模型接口及货运平台接口
websockets接收微信消息回调、发送注册和心跳
asyncio管理 WebSocket、消息队列及重连
python-dotenv读取项目 .env
sqlite3持久化会话、消息处理记录和订单提交记录
pytest离线回归和协议适配验证
Conda + systemd隔离 Python 环境并管理常驻服务

当前版本直接以微信作为交互入口,没有运行 FastAPI 管理页,也不需要启动浏览器前端。

微信私聊或群内 @消息

WebSocket 接入

消息校验与去重

串行消息队列

对话状态机 Dialogue

固定格式解析与 DeepSeek 参数提取

参数校验与候选确认

FreightAPI 业务封装

运满满应用接口

报价快照与可提交服务

微信报价回复

用户独立确认指令

订单记录落盘

提交一次并保存结果

SQLite

原会话回复结果

这里最关键的分工是:DeepSeek 提取用户表达的参数;程序控制状态流转、金额校验与接口调用。模型不会直接获得发货工具,也不能自行决定向平台提交订单。

3. 代码目录如何划分

与当前业务直接相关的目录如下:

project/
├── .env                         # 项目运行配置
├── .env.example                 # 不含真实凭据的配置示例
├── requirements.txt
├── pyproject.toml
├── captures/
│   └── 2222.har                  # 私有抓包,禁止随文章发布
├── ymm_quote/
│   ├── har.py                   # HAR 读取、检查、通用导入
│   ├── shipment.py              # 业务参数类型与约束
│   ├── ymm.py                   # 平台请求模板、字段适配、响应核对
│   ├── official.py              # FreightAPI 业务操作封装
│   ├── ymm_prices.py            # 页面组件与价格解析
│   ├── errors.py                # 业务异常与提交结果分类
│   └── wechat/
│       ├── __main__.py          # 启动、配置检查、单实例锁
│       ├── config.py            # Settings 与 .env 加载
│       ├── transport.py         # 私聊/群聊回调解析、回复发送
│       ├── bot.py               # 消息队列、WebSocket、重连
│       ├── dialogue.py          # 补充条件、询价、确认下单
│       ├── extraction.py        # 固定格式及模型提取
│       ├── regions.py           # 行政区域识别、地址范围判断
│       ├── store.py             # SQLite 会话与订单存储
│       ├── orders.py            # 本地订单查看和人工核实工具
│       └── data/                # 区划等本地数据
├── tests/
│   ├── test_official.py
│   ├── test_order_workflow.py
│   ├── test_wechat.py
│   ├── test_wechat_groups.py
│   └── ...
├── runtime/                     # 运行数据库与锁文件
├── logs/                        # 轮转日志
└── deploy/
    └── yunmanmanapp.service      # systemd 服务定义

FreightAPI 是可复用的 Python 业务接口。微信层负责对话,业务封装负责平台协议,存储层负责持久化。以后增加其他入口时,可以继续复用报价封装,但新的入口仍需实现确认机制和订单防重。

4. 从 HAR 中还原实际业务链

4.1 为什么要保留完整操作过程

只抓到一个“查询价格”的请求,通常不足以完成发货。平台表单可能依赖前面多个请求产生的结果,例如地址坐标、货物分类编码、车长编码、装货时间节点、订金设置和选中服务卡。

因此,项目分析的是一段完整的操作记录:

  1. 输入并选择装货、卸货地址。
  2. 输入货名,查看联想并完成货物校验。
  3. 选择包装、数量、车辆、时间和发票。
  4. 填写备注,查看多类服务报价。
  5. 在明确需要的测试场景中,记录提交请求及回执。

HAR 必须包含请求体和响应正文。程序也处理响应正文采用 Base64 编码的情况,并对文件结构、文件大小及响应 JSON 格式进行检查。

4.2 主要接口的职责

下表是项目样本中实际适配的主要路径,不表示平台对外承诺这些路径长期稳定:

业务请求路径
地点搜索/ld-cargo-management-app/normal/address/search
地点校验/ld-cargo-management-app/normal/address/page/validate
路线距离/ld-cargo-management-app/normal/address/getnavigationdistance
货物联想/ld-cargo-management-app/normal/goods/search
货物页面/ld-cargo-management-app/goods/page/render
货物校验/ld-cargo-management-app/goods/page/validate
车长车型选项/ld-cargo-management-app/vehicle/page/render
装卸时间选项/ld-cargo-management-app/normal/time/render
备注校验/ld-cargo-management-app/normal/service/remark/check
备注保存/ld-cargo-management-app/normal/requirement/save
表单渲染与报价/ld-cargo-management-app/cargo/publish/applet-simple/render
已适配的提交入口/ld-cargo-management-app/cargo/publish/applet-simple/commit

样本中的这些操作均使用 POST。HTTP 方法本身无法区分“查询”和“提交订单”,程序必须按业务路径和调用时机控制副作用。

4.3 请求模板不能直接重放

抓包中的请求同时包含结构信息和历史订单信息。适配时需要保留结构,重新填充本次业务数据。

例如,新订单应清空历史 cargoId、旧联系人、旧服务选择、图片和无关附加属性;地址、货物、车型、时间和价格则来自当前用户与当前接口响应。

还要注意 frontFormData、preData 这类辅助字段:即使主字段已经替换,旧数据仍可能藏在“修改前数据”里。项目会清理不属于当前用户的历史值,避免它们被一同提交。

HTTP 层去除 Content-Length 等不应原样复用的头,由客户端根据新请求体生成。模型和平台请求都设置超时,并关闭自动重定向。订单请求没有自动重试逻辑。

5. DeepSeek 负责提取参数,程序负责做决定

5.1 让模型返回受约束的 JSON

自然语言输入适合交给模型识别,但模型输出必须进入明确的数据结构。例如:

{
  "action": "quote",
  "fields": {
    "pickup_text": "苏州市某路仓库",
    "dropoff_text": "上海市某路仓库",
    "goods_name": "五金螺母",
    "weight_tons": 11,
    "volume_m3": 10,
    "vehicle_count": 1,
    "vehicle_lengths_m": [9.6],
    "vehicle_types": ["高栏"],
    "invoice_type": "不开发票"
  },
  "clarification": "",
  "clarification_field": ""
}

这个例子中没有坐标、行政区划编码、货物分类 ID 或价格,因为这些数据需要由程序和平台接口提供。

模型请求使用 response_format={"type": "json_object"},提示词明确要求 JSON,并提供输出示例。DeepSeek 官方文档也指出,JSON Output 需要配合提示词和合适的输出长度,且仍可能出现空输出,因此不能省略本地解析和异常处理。DeepSeek JSON Output 文档

项目中关键请求参数的组织方式如下,代码节选中省略了 HTTP 错误处理:

body = {
    "model": settings.deepseek_model,
    "messages": [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": json.dumps(context, ensure_ascii=False)},
    ],
    "response_format": {"type": "json_object"},
    "max_tokens": 1600,
    "temperature": 0,
    "stream": False,
}

模型名称从 .env 读取,部署时填写当前账号实际可用、支持所需调用方式的模型。

5.2 模型上下文只传必要信息

传给模型的上下文包括当前北京时间、用户已经提供的字段、上一轮待补问题、可选目录以及本条消息。

HAR 认证头、微信 TOKEN、订单提交凭据不进入模型上下文。地址和货物描述属于语义理解需要的数据,会按业务需要发送给配置的模型服务。

模型返回后,程序通过字段白名单过滤结果:未知字段直接忽略;重量、体积检查数值和正数约束;车辆数必须为整数;高价值标记必须为布尔值;数组、文字长度和时间格式分别校验。

即使模型输出了 price 或一个自创的工具指令,也不会成为业务调用的依据。

5.3 固定格式解析优先覆盖模型结果

固定格式的价值是减少歧义。例如“车长车型:依维柯/平板”可以确定前者是命名车长,后者是车型。

当前实现会提取固定字段,并用这些明确字段覆盖模型解析出的同名值。这样用户写明的内容不会因为模型理解偏差而被替换。

模型暂时不可用时,能够识别的固定格式可以进入规则解析分支。不过这不意味着整条业务链完全不需要模型:货物分类仍可能出现歧义,此时需要用户从平台候选中选择。

6. 用对话状态保存进度,减少重复输入

单轮模型调用无法管理一笔完整询价。项目为每个会话保存一份状态,下面是简化示意:

{
  "fields": {
    "pickup_text": "用户原始装货地址",
    "goods_name": "配件五金",
    "weight_tons": 11
  },
  "resolved": {},
  "pending": {
    "kind": "field",
    "field": "supplement",
    "question": "请补充包装方式、车辆数和是否高价值货物"
  },
  "workflow_version": 2
}

fields 保存用户条件,resolved 保存已经选中的地点对象,pending 表示当前正在等待哪一类补充。

需要包装、车辆数和高价值标记时,程序尽量合并为一次追问。用户回复后只更新相关字段,不清空已解析的路线和货物。

条件发生变化时,失效范围也要明确:

用户修改需要失效的数据
装货地址装货地点候选和已经选中的装货对象
卸货地址卸货地点候选和已经选中的卸货对象
货物名称旧货物联想、分类匹配结果
装货时间需要重新匹配的时间节点、旧报价
车辆或数量旧报价与报价条件指纹

修改任何有效询价条件后,旧报价不再允许用于确认下单。

另一个需要单独处理的情况是“平台没有自动报价”。如果页面显示“自己出价”或“请填写价格”,不应把它解释成用户缺少车长或重量。项目用 NoQuoteError 记录这类结果,保留现有条件,并让用户明确选择“重试报价”或修改条件。

7. 装卸地点、货物、车辆和时间如何校验

7.1 地址:保留原文,再做定位

用户已经提供“城市+镇村、道路、园区或仓库名”时,不必仅因为缺少区县文字就反复追问。程序先用本地区划数据确定搜索范围,再把原始地址交给平台搜索。

平台返回的候选需要包含行政区划和坐标。只有唯一的精确匹配才直接采用;其余情况给用户候选列表,由用户回复编号确认。当前代码即便只返回一个非精确候选,也可能要求确认,以免把相近地点当成实际装卸地点。

选中后的地址对象供距离计算和平台地址校验复用。DeepSeek 不生成经纬度,也不自行决定一个不明确的园区具体位于哪里。

7.2 货物:名称与数量必须拆开

项目曾遇到过一个看起来很普通的报错:

平台校验未通过:货物名称最多能输入12个字。

实际原因是把下面整行都保存成了 goods_name:

电子产品 0.5吨 10方

正确结果应该是:

{
  "goods_name": "电子产品",
  "weight_tons": 0.5,
  "volume_m3": 10
}

修复时增加了 split_goods_info():只分离货名后、由分隔符引出的数量片段;保留真正属于产品名称的型号和规格,例如“M3螺母”或“0.5吨叉车”。多个同类数量可能表示区间或多种货物,程序不会随意选一个。

真正超过 12 个字的货名仍需用户提供准确的简短名称,不能直接截取前 12 个字。旧会话中误存的货名也会在重新处理时规范化,同时保留后来单独修改过的重量和体积。

货物分类来自本次平台联想候选。模型可以帮助匹配候选名称,但最终分类编码来自候选数据;明确的禁运判断以平台校验失败为依据。尚未确认含义的 badCaseType 不会被简单解释为“非零即违禁”。

7.3 车辆:数字车长与命名车长分别处理

9.6米 可以保存为数字车长,而“依维柯”需要保存为命名车长,不能强行转换成一个数字。

每次询价刷新平台车长车型页面,检查选项存在性、禁用状态和组合规则。部分特殊车辆除 id 外还包含 specialCode,提交时需要同步兼容字段。

包装和发票编码从已捕获或读取到的平台目录解析,当前并非所有目录都有独立的实时查询方法。发票在项目中限制为“专票”和“不开发票”;附加车辆要求若没有完成编码映射,会提示到小程序处理,避免静默漏掉用户要求。

7.4 时间:匹配平台时间树

“今天随时”在内部可以表示为:

anytime:YYYY-MM-DD

具体提交节点仍需从平台时间接口中选取。“立即装货”使用 immediately,预约时间使用带 +08:00 时区的 ISO 8601 字符串。

卸货区间必须同时保留开始和结束。例如“10月30日 00:00—06:00”需要匹配对应区间,不能只取 06:00 而误选一个单点时间。

卸货时间未提供时写空。对已经过去、早于装货或无法对应平台选项的时间,程序返回具体原因并保留其他条件。

8. 报价解析:展示价格与下单金额分开保存

平台的报价响应是页面组件结构,可能包含 body、header、footer、items 等层次。项目通过组件遍历找到各服务卡,再按服务结构读取价格。

一个服务卡可能同时包含参考价、起价、优惠信息、一口价和电议选项。因此项目将字段拆开:

字段含义
price解析后的页面展示金额
display_text包含“起”“约”等说明的展示文本
options一口价、电议等计价选项
order_price当前选中方案实际准备提交的金额
order_option当前选中的计价选项
can_order该方案是否具备当前提交条件
order_unavailable_reason暂时不能提交的原因

举一个项目中观察到的结构差异:某个零担卡片页面展示 1500 元,而选中的一口价方案是 1540 元。这两者必须同时说明,不能展示一个价格却提交另一个价格。

金额使用 Decimal 校验,明确区分元和分。抓包中的 expectFreight 以分为单位,组装订单时必须与已经展示的 order_price 一致。

成功询价后会保存一份快照:

quote_id
queried_at
quotes
snapshot.body
snapshot.cards
snapshot.deposit_rules
snapshot.capture

其中 capture 保存 HAR 文件指纹。确认前如果登录抓包已经更新,程序要求重新询价,因为新会话与旧报价的组合未经验证。报价默认有效期为 300 秒,可在 .env 中修改。

9. 订金报错:省略字段与传空对象的区别

接入提交接口时,还出现过一次明确的平台拒绝:

请选择订金是否退还。

检查会话快照发现,程序提交的是:

{
  "standardDeposit": {
    "data": {
      "refundId": null,
      "deposit": null
    }
  },
  "deposit": {
    "data": {
      "selectLabelId": null,
      "deposit": null
    }
  }
}

这些空值来自清理历史订单参数的逻辑。程序原本想避免复用旧订金,但空对象被平台当成了用户清空的设置。

对照小程序新建货源的请求发现,首次渲染会省略这两组字段,再由响应返回当前设置。修复后,程序采用相同流程,并将响应里的两组数据一起保存。

下面是脱敏后的结构示例,金额单位为分:

{
  "standardDeposit": {
    "type": "StandardDeposit",
    "data": {
      "source": "recommendation",
      "refundId": 1,
      "refundSubId": null,
      "deposit": 2000
    }
  },
  "deposit": {
    "type": "Deposit",
    "data": {
      "selectLabelId": 1,
      "deposit": 2000
    }
  }
}

当前观察到的选项中,refundId=1 对应收货后退司机,refundId=0 对应装货后转给货主的选项。是否可选还要检查本次响应里的禁用标记,不能仅凭这个编码就允许提交。

提交前会核对两组金额一致、退还方式一致、选项可用、金额在本次规则范围内。微信报价同时展示金额和退还规则。20 元只是一次响应的结果,代码没有将它硬编码成所有订单的固定订金。

修复后曾通过实际查询接口验证:省略空订金组件后,平台能够正常返回当前订金设置。该验证没有调用订单提交接口。

10. 用户确认如何触发真正的提交

10.1 只接受整条消息匹配的确认命令

项目支持下面这些确认文本的形式:

整车发货立即下单
零担拼车立即发货
满帮优车立即下单
快车立即下单

识别出命令之后,还需要检查该服务是否出现在本次报价中,以及 can_order 是否允许提交。命令被识别不代表当前已经支持该服务的下单协议。

核心匹配规则为:

re.fullmatch(
    r"(?:快车|满帮优车|整车发货|零担拼车)(?:立即下单|立即发货)[!!。]?",
    text.strip(),
)

因此,“备注:整车发货立即下单”不会直接触发发货。模型提取流程也不能替用户生成这个确认动作。

10.2 提交前检查的内容

程序依次核对是否存在待核实提交、是否已经取得当前报价、条件指纹是否一致、报价是否过期、服务是否唯一匹配,以及价格、订金和抓包指纹是否有效。

通过检查后,调用 prepare_order() 组装参数。这个步骤不发送订单请求;只有持久化提交记录成功后,才调用 submit_order()。

平台 FreightAPI SQLite Dialogue 用户 平台 FreightAPI SQLite Dialogue 用户 独立确认指令 检查历史提交记录 prepare_order,核对报价快照 提交参数 reserve_order,记录 submitting 成功占用本次报价 submit_order 单次提交请求 成功回执、明确拒绝或不确定结果 标准化结果 保存提交状态 回复货源编号或具体处理提示

取得合法的 cargoId 才视为平台已返回成功回执。HTTP 200、success=true 或返回了某个跳转地址,单独出现时都不足以替代货源编号。

11. SQLite 订单状态机与防重复提交

11.1 三类数据分别保存

项目使用三张主要表:

表主要字段职责
sessionsuser、state、updated保存对话字段、候选、报价快照
messagesid、status、updated标记已经接收处理的消息
ordersuser、quote_id、status、result、updated保存实际提交过程与结果

orders 以 (user, quote_id) 为联合主键。会话过期或消息记录清理不会自动删除订单提交记录。

11.2 网络请求前先落盘

提交前先开启 BEGIN IMMEDIATE,检查该用户是否有 submitting 或 unknown 记录、该报价是否已有提交记录,然后插入新的 submitting。

这可以将“检查”和“占用”放在同一个写事务中,避免两个数据库连接同时通过检查。SQLite 同时只允许一个写事务,BEGIN IMMEDIATE 会立即尝试取得写事务,遇到竞争仍可能产生忙等待或 SQLITE_BUSY,并不是没有并发约束。SQLite 事务文档

程序使用 WAL 模式改善读取与写入的并发,但 WAL 同样没有取消单写者限制。SQLite WAL 文档

11.3 必须区分明确失败与结果未知

状态含义后续处理
submitting请求发送前已经落盘阻止再次提交
submitted平台返回货源编号同一报价不再提交
rejected平台明确拒绝本次操作修正条件,重新询价并确认
not_sent请求尚未发出,如参数编码失败处理原因后重新询价
unknown超时、异常响应或缺少可确认回执先在小程序核实结果
verified_not_submitted已人工核实没有生成货源获取新报价后重新确认

网络超时后,服务端可能已经创建货源,只是客户端没有收到回执。如果此时自动再提交一次,就可能生成重复订单。

反过来,平台明确返回“请选择订金是否退还”这样的拒绝,也不能永远保存为结果未知。项目引入 OrderSubmissionError,保存结果分类、可读原因、异常类型以及可用的 HTTP 状态或平台错误码。

对包含额外业务数据、后续动作或无法确定含义的失败响应,程序仍采取 unknown,保留核实流程。原始响应和认证头不直接作为提交错误日志输出。

11.4 这套机制的保证范围

它提供的是本地持久化防重和不确定结果阻断。平台接口没有提供经过验证的远端幂等协议时,不能宣称已经实现分布式“恰好一次”提交。

例如,进程在记录 submitting 后、发请求前退出,也可能留下需要核实的记录;数据库写入成功回执失败时,系统还要保留已得到的货源编号,避免再把它误写成一个没有编号的未知结果。

当前实现优先保留可追溯状态,接受少量人工核实成本,以减少重复发货。

12. 微信群接入:识别成员、识别 @、隔离报价

12.1 群 ID 不能代替实际发送人

项目接入服务的典型群文本回调如下,字段值均为演示数据:

{
  "robotid": "wxid_robot_demo",
  "Data": {
    "FromUserName": {"string": "123456@chatroom"},
    "ToUserName": {"string": "wxid_robot_demo"},
    "MsgType": 1,
    "Content": {
      "string": "wxid_user_demo:\n@运费助手\u2005整车发货立即下单"
    },
    "MsgSource": "<msgsource><atuserlist><![CDATA[wxid_robot_demo]]></atuserlist></msgsource>",
    "NewMsgId": "demo-message-1001"
  }
}

FromUserName 表示群,Content 的成员前缀才表示实际发送人。若把整群当成一个用户,多个人补充条件时就会互相覆盖,甚至误确认别人的报价。

解析成员前缀时也不能使用 split(':')[-1],因为地址、模板标签和时间里都有冒号。项目只剥离符合协议的 成员ID:\n 前缀,保留后续正文。

12.2 用元数据确认真正的 @

默认情况下,每一条群消息都必须 @当前机器人。判断依据是 MsgSource 中的 atuserlist,不是正文是否出现“@运费助手”。

这样可以区分真正 @机器人、仅手打昵称、@其他人和 @所有人。缺少群成员前缀或有效 @元数据的回调会被忽略,而不是猜测发送者。

微信的 @昵称后通常带有特殊空格 U+2005。程序在明确只提及当前机器人时剥离开头的 @文本,使剩余内容仍能满足独立确认命令的匹配规则。复杂的多目标 @不会被无条件全部删除,以免把原本不独立的文字变成下单命令。

12.3 会话隔离与订单防重使用不同范围

数据隔离键
私聊会话机器人ID:发送人ID
群聊会话机器人ID:group:群ID:发送人ID
订单提交记录机器人ID:实际发送人ID,再结合 quote_id

群聊中的询价条件和报价按“群+成员”隔离。因此,甲在 A 群取得的报价,乙不能直接确认;甲切到 B 群或私聊,也不会自动继承 A 群的报价。

订单未知结果则按实际发送人跨会话阻断。如果甲在 A 群提交超时,换到 B 群重新询价,也不能绕开上一笔待核实记录。

12.4 回复发回原群,并指定原发送人

项目接入服务使用 /sendTextMessage,群回复的参数示例如下:

{
  "toWxid": "123456@chatroom",
  "ats": "wxid_user_demo",
  "content": "@wxid_user_demo\u2005\n请补充包装方式和车辆数。"
}

回复的 toWxid 是群 ID,ats 是原消息发送人。群消息内容对群成员可见,不会额外发送一份私聊消息。

用户的实际操作流程仍然很简单:@机器人发送模板,@机器人补充条件或候选编号,收到报价后再 @机器人发送独立确认指令。

13. asyncio、串行队列和消息去重

WebSocket 接收协程持续接收数据,将有效消息放入有界 asyncio.Queue。单个消费协程按顺序处理消息,避免同一用户连续两次补充条件发生状态覆盖。

对话和 HTTP 请求是同步逻辑,因此通过 asyncio.to_thread() 放入线程执行,防止阻塞 WebSocket 心跳和收包。Python 文档将这个方法主要用于把阻塞 I/O 从事件循环移出;它并不意味着纯 Python 的 CPU 密集工作自动获得并行加速。Python asyncio.to_thread 文档

当前实现中的典型调用为:

reply, updated = await asyncio.to_thread(
    self.dialogue.handle,
    message.text,
    state,
)

SQLite 连接使用 check_same_thread=False,并用 RLock 保护连接操作。关闭线程检查并不等于自动线程安全,互斥和事务仍然必要。

消息去重键包含机器人、会话范围、实际发送人和平台消息 ID。同一回调重复到达时,不会再次询价或提交。

发送回复前先保存会话、标记消息为 sending,发送成功再标记为 sent。发送超时不会自动重发,因为客户端不能判断微信服务是否已经接受了第一条消息。

这也形成了当前系统的边界:失败消息和队列满时被丢弃的消息没有完整的自动可靠补偿机制,需要日志排查或用户重新发消息。串行处理适合当前业务规模;若后续吞吐量增长,可以按会话分区消费,但同一会话仍需保持顺序。

14. 配置集中在项目 .env

下面是用于文章展示的配置模板,所有值都需要替换为自己的接入信息:

ROBOT_ID=YOUR_ROBOT_WXID
TOKEN=YOUR_WECHAT_TOKEN

# 微信消息接入服务地址,不一定是部署机器人的服务器地址。
SERVER_IP=YOUR_WECHAT_GATEWAY_HOST
SERVER_PORT=5555
# 留空时生成 ws://SERVER_IP:SERVER_PORT/ws。
WECHAT_WS_URL=
WECHAT_API_BASE_URL=https://YOUR_WECHAT_HTTP_HOST

DEEPSEEK_ENABLED=true
DEEPSEEK_API_KEY=YOUR_DEEPSEEK_API_KEY
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
DEEPSEEK_MODEL=YOUR_AVAILABLE_MODEL
DEEPSEEK_TIMEOUT_SECONDS=30

YMM_HAR_PATH=captures/2222.har
BOT_DB_PATH=runtime/wechat.sqlite3
BOT_LOG_PATH=logs/wechat-bot.log
BOT_QUOTE_TTL_SECONDS=300

BOT_GROUP_ENABLED=true
BOT_GROUP_REQUIRE_AT=true
BOT_ALLOWED_GROUPS=
BOT_ALLOWED_SENDERS=

SERVER_IP 指向微信消息接入服务,不能仅因为程序迁移到另一台服务器,就把它改成部署服务器的 IP。WECHAT_WS_URL 非空时优先使用它。

BOT_ALLOWED_GROUPS 是群白名单,BOT_ALLOWED_SENDERS 是实际发送人白名单,后者同时用于私聊和群成员。留空表示不额外限制对应范围。

应用配置由 .env 管理,运满满认证仍从本人的 HAR 会话读取;因此 .env 和 HAR 都需要作为私有运行文件管理。当前加载规则允许同名进程环境变量覆盖 .env,排查配置不生效时也需要检查服务进程的环境。

本地启动和检查命令如下:

python -m pip install -r requirements.txt

# 检查配置、HAR 和微信在线状态,不发送消息。
python -m ymm_quote.wechat --check

# 正常启动。
python -m ymm_quote.wechat

# 终端模拟,会访问配置的模型和查询接口,但不发送微信或提交订单。
python -m ymm_quote.wechat --console

--check 不会遍历验证所有业务接口。实际部署验收还需要针对模型和平台做受控的查询检查。

15. 使用 Conda + systemd 部署到 Linux

15.1 代码与运行数据分开存放

项目部署使用以下目录结构:

/root/yunmanmanapp/
├── current -> releases/版本号
├── releases/
│   └── 版本号/
│       ├── ymm_quote/
│       ├── tests/
│       ├── examples/
│       ├── requirements.txt
│       ├── .env -> ../../shared/.env
│       ├── captures -> ../../shared/captures
│       ├── runtime -> ../../shared/runtime
│       └── logs -> ../../shared/logs
└── shared/
    ├── .env
    ├── captures/2222.har
    ├── runtime/wechat.sqlite3
    └── logs/wechat-bot.log

运行数据与代码版本分开后,切换代码不会丢失会话和订单记录。回退代码时也不应恢复一份旧数据库覆盖新的订单状态。

项目使用现有 Conda 环境:

/root/anaconda3/envs/yunmanmanapp

服务器命令直接指定该环境的 Python:

cd /root/yunmanmanapp/current

/root/anaconda3/envs/yunmanmanapp/bin/python -m pip install -r requirements.txt
/root/anaconda3/envs/yunmanmanapp/bin/python -m pip install 'pytest>=8,<10'
/root/anaconda3/envs/yunmanmanapp/bin/python -m pip check
/root/anaconda3/envs/yunmanmanapp/bin/python -m pytest -q
/root/anaconda3/envs/yunmanmanapp/bin/python -m ymm_quote.wechat --check

文章中的路径用于说明实际部署布局,不需要把源码放进 Conda 环境的 site-packages。服务在项目目录启动模块,解释器与依赖来自指定环境。

15.2 迁移 SQLite 时使用一致性备份

WAL 模式下,已提交数据可能仍在 -wal 文件中,不能假定只复制主数据库文件就得到完整快照。SQLite WAL 文件说明

迁移前先停止源机器人,再使用 SQLite 备份接口生成迁移文件。下面是简化示例:

from contextlib import closing
import sqlite3

with closing(sqlite3.connect("runtime/wechat.sqlite3")) as source:
    with closing(sqlite3.connect("runtime/wechat-migration.sqlite3")) as target:
        source.backup(target)

Connection.backup() 支持在其他客户端访问源数据库时创建备份;本项目仍选择在机器人停止时做迁移,以便同时协调消息接收和订单处理的切换。Python sqlite3 备份接口

上传后对文件做 SHA-256 校验,检查数据库完整性及订单状态数量,再启动目标服务器。不能让旧机器与新服务器同时接收同一个机器人的回调;本地文件锁只能约束同一数据目录,不能跨服务器防重。

15.3 配置 systemd 服务

服务文件放在 /etc/systemd/system/yunmanmanapp.service,核心配置如下:

[Unit]
Description=Yunmanman WeChat quote and shipment bot
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=120
StartLimitBurst=10

[Service]
Type=simple
User=root
WorkingDirectory=/root/yunmanmanapp/current
ExecStart=/root/anaconda3/envs/yunmanmanapp/bin/python -u -m ymm_quote.wechat --env /root/yunmanmanapp/shared/.env
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONDONTWRITEBYTECODE=1
Environment=PYTHONNOUSERSITE=1
Environment=TZ=Asia/Shanghai
Restart=on-failure
RestartSec=5
KillSignal=SIGINT
TimeoutStopSec=180
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/root/yunmanmanapp/shared

[Install]
WantedBy=multi-user.target

使用解释器绝对路径,可以避免依赖交互式 Shell 的 conda activate。Restart=on-failure 处理异常退出;人工执行 systemctl stop 不会因此立刻把服务拉起。具体重启条件以目标系统的 systemd 版本为准。systemd.service 官方手册

这份配置使用 root,是因为现有 Conda 环境和项目都位于 /root。迁移到专用服务用户时,需要连同目录位置和访问权限一起调整。服务运行期间主要写入 shared,代码目录保持只读。

使用 SIGINT 是为了配合当前 Python 入口的停止处理,让已接收消息有机会完成。超出 TimeoutStopSec 后仍可能被 systemd 强制终止,因此订单提前落盘的机制依然不可省略。

启动服务:

systemd-analyze verify /etc/systemd/system/yunmanmanapp.service
systemctl daemon-reload
systemctl enable --now yunmanmanapp
systemctl status yunmanmanapp --no-pager

项目没有公开 Web 管理端口。机器人作为客户端连接微信 WebSocket,并通过出站 HTTP 请求完成业务调用。

16. 测试策略与部署验收

部署版本在本机和 Linux 指定 Conda 环境中均执行了测试;服务器验收记录为 244 项通过。这个数字对应当时的代码版本,不是接口长期可用或所有实际发货场景均已验证的保证。

测试覆盖的重点如下:

测试方向代表场景
参数解析货名和重量体积分离、型号保留、固定字段覆盖模型误解
业务校验地址候选、车型组合、装卸时间区间、货名长度
报价解析多服务卡、一口价与电议、参考价与提交金额差异
订金处理首次请求省略旧字段、同步两组数据、禁用选项和金额范围
提交状态明确拒绝、请求未发出、超时未知、回执保存异常
防重复消息重复、相同报价再次确认、重启后的未知结果阻断
群聊真正 @校验、原群回复、不同成员及不同群隔离
跨会话群聊未知提交后切换私聊,仍不能再次发货
基础设施单实例锁、配置加载、WebSocket 注册和串行消费

订单测试使用假的 HTTP 会话和假网关。测试会记录传入请求体,并断言调用次数,从而验证“先落盘、再提交、只调用一次”的顺序,不会为了跑回归测试真正发布货源。

服务器还做了几项实际连通性检查:微信账号在线、WebSocket 注册成功、平台货物联想返回候选、DeepSeek 能将演示文本提取成预期数量字段。这些检查没有发送测试微信消息,也没有提交新订单。

离线测试与真实群聊验收各有作用。离线测试可以验证逻辑分支,真实接入仍需核对接入服务实际回调的结构、@元数据和群内回复表现。

17. 运维与常见故障排查

17.1 先确认服务,再确认业务状态

常用命令:

systemctl status yunmanmanapp --no-pager
journalctl -u yunmanmanapp -f
systemctl restart yunmanmanapp

服务处于 active 只表示进程正在运行。进一步检查日志中的 websocket_connected registration_sent 和 websocket_registration_ack,才能确认接入链路已经完成注册。

配置或抓包更新后重启服务,再进行新询价。日志包含业务错误提示,转发日志前仍需检查是否包含地点、货名等用户业务内容。

17.2 常见问题与对应检查

表现优先检查
群里没有回复是否使用微信实际 @、是否 @了正确机器人、群与成员白名单、回调成员前缀和 atuserlist
私聊也没有回复机器人在线状态、WebSocket 注册、TOKEN 和消息时间过滤
一直问装货地点是否已有可定位细节、是否需要候选确认、是否在另一会话补充
货名超过 12 字重量体积是否混进货名、真实货名是否确实过长
平台没有自动报价区分参数缺失与平台要求自行出价,查看本次服务卡提示
请选择订金是否退还旧报价快照是否为空、是否已经更新代码并重新询价
报价过期回复“重试报价”,确认新的金额与条件
登录会话不可用更新当前账号的有效 HAR,再重新询价
提交结果待核实查看小程序货源记录,不能直接自动重试

17.3 核实未知提交记录

查看提交状态:

cd /root/yunmanmanapp/current
/root/anaconda3/envs/yunmanmanapp/bin/python -m ymm_quote.wechat.orders

如果小程序已确认没有生成对应货源,先停止机器人,再执行核实操作。下面的编号是占位符,必须替换为工具输出的真实 quote_id:

systemctl stop yunmanmanapp

/root/anaconda3/envs/yunmanmanapp/bin/python -m ymm_quote.wechat.orders \
  --resolve QUOTE_ID_FROM_LIST \
  --not-published

systemctl start yunmanmanapp

若确认已经生成货源,则用实际货源编号登记成功,替换上述核实命令:

/root/anaconda3/envs/yunmanmanapp/bin/python -m ymm_quote.wechat.orders \
  --resolve QUOTE_ID_FROM_LIST \
  --cargo-id ACTUAL_CARGO_ID

该工具只更新本地核实记录,不请求发货接口。当前没有实现通过请求标识自动查询平台新货源的完整流程,因此仍需人工核对。不能靠删除 SQLite 文件、清空会话或重启来解除未知结果。

18. 当前边界与后续迭代方向

这个版本已经把文本收集、业务校验、报价展示、确认提交和服务器运行连接起来,但仍有几项明确的工程边界:

  1. 接口适配依赖样本。 平台字段、服务卡和调用链变化后,需要新的授权抓包和回归验证,不能一直复用旧模板。
  2. 服务提交覆盖有限。 满帮优车、快车和专线的完整提交链还需要进一步补充;零担仍需独立真实回执样本验证。
  3. 群回调格式有前提。 目前支持本文列出的成员前缀和 @元数据结构,其他微信接入服务可能需要增加协议适配。
  4. 消息处理采用单消费者。 当前实现优先保证顺序,尚未实现多实例共享消息队列和跨服务器的统一提交锁。
  5. 未知订单需要核实。 后续可在具备可靠平台查询接口后实现自动对账,不能单凭客户端超时推断失败。
  6. 部分输入边界需要继续扩展。 多装卸点、复杂装卸要求、特殊车辆附加选项等,需要对应的模型字段、业务映射和真实接口样本共同支持。

后续优先补充可观察性:把接入故障、模型提取失败、平台业务拒绝和订单未知结果分别统计,让维护者能够知道问题发生在哪一层。扩展业务范围时,为每条新增提交链保留脱敏请求样本、回执样本和离线回归用例,再向用户开放确认指令。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐