【python】我用AI辅助开发了LanChat 局域网即时通讯的小软件

LanChat 局域网即时通讯软件 --- 技术文档

起初做这个项目是我工作中有两台电脑工作,另一台又是外网,不方便下载聊天工具,那么我就突然奇想用AI辅助开发一个局域网聊天工具,方便复制和发送文件,而且关闭聊天框后会自动清楚聊天记录,方便上班发悄悄话!!!

github 源代码链接在此

项目概述

LanChat 是一款基于 Python 的局域网即时通讯软件,支持文本聊天、文件传输、在线用户自动发现,采用 TCP + UDP 混合协议实现 P2P 通信。


点击发送就可以发送任何文件

右击气泡即可复制内容,和保存文件

目录结构

复制代码
lan_message/
├── main.py              # 程序入口
├── network.py           # 网络层(UDP 发现 + TCP 通信)
├── ui.py                # Tkinter 图形界面
├── LanChat.spec         # PyInstaller 打包配置
├── dist/                # 构建产物
│   ├── main.exe
│   └── main.rar
└── docs/
    └── 技术文档.md       # 本文

架构设计

整体架构

通信架构

网络层 --- network.py

核心数据类

字段 说明
Peer name, ip, last_seen, online 对等节点信息
ChatMessage sender, content, timestamp, is_file, file_name, file_size, file_path, is_self 聊天消息

常量

常量 说明
DISCOVERY_PORT 9876 UDP 广播发现端口
TCP_PORT 9877 TCP 消息传输端口
BROADCAST_ADDR 255.255.255.255 子网广播地址
DISCOVERY_INTERVAL 3s 心跳广播间隔
PEER_TIMEOUT 12s 对等节点超时阈值
BUFFER_SIZE 65536 网络缓冲区大小
CHUNK_SIZE 32768 文件传输分块大小

NetworkManager 类

线程模型
复制代码
start()
├── _udp_listener()      [daemon]   ← 共享 UDP socket,持续监听 "hello" 广播
├── _udp_broadcaster()   [daemon]   ← 共享 UDP socket,每 3s 发送 "hello" 心跳
├── _tcp_server_thread() [daemon]   ← 监听 TCP 端口,处理消息/文件接收
└── _queue_processor()   [daemon]   ← 消费消息队列,回调 UI 层
启动流程
复制代码
NetworkManager.__init__()
  └─ 注册回调 (on_message, on_file_progress, on_peers_changed)

NetworkManager.start()
  ├─ 创建单例 UDP socket (SO_BROADCAST | SO_REUSEADDR)
  ├─ bind 0.0.0.0:9876
  │   ├─ 成功 → 启动 _udp_listener
  │   └─ 失败 → close socket, self.udp_sock = None (仅发送广播)
  ├─ 启动 _udp_broadcaster
  ├─ 启动 _tcp_server_thread
  └─ 启动 _queue_processor
对等发现机制
心跳与超时检测
  • 每个节点每 3s 广播一次 {"type": "hello", "name": hostname}

  • 接收方记录 peer.last_seen = time.time(), 设置 peer.online = True

  • 广播线程每轮循环检查 now - peer.last_seen > 12s,超时则标记 online = False

  • 离线节点仍然保留在列表中(红色标识),可手动刷新清除

    时间线:
    t=0 t=3 t=6 t=9 t=12 t=15
    │ │ │ │ │ │
    hello──► hello──► hello──► hello──► (hello 停止)
    ↑ ↑ ↑ ↑
    last_seen=3 last_seen=6 last_seen=9 last_seen=9
    now - 9 = 6 < 12 → 仍在线
    t=21

    now - 9 = 12 ≥ 12 → offline

消息发送 (TCP)
复制代码
send_message(peer_ip, content)
├─ 创建 TCP 连接 → peer_ip:9877
├─ 4字节小端长度前缀 + JSON 消息体
│   {"type":"msg", "sender", "content", "time"}
├─ sendall
└─ close
文件发送 (TCP)
复制代码
send_file(peer_ip, file_path)
├─ TCP 连接 → peer_ip:9877
├─ 发送文件头: {"type":"file_offer", "name","size","sender","time"}
├─ 等待接收方 ACK (1字节 0x01)
├─ 分块发送 (CHUNK_SIZE = 32KB)
│   └─ 每块发送后推送进度到队列
├─ close
└─ 创建本地 ChatMessage (is_self=True)
文件接收
复制代码
_handle_tcp_client(conn, peer_ip)
├─ 4字节长度前缀 → JSON 解析
├─ msg_type == "file_offer"
│   ├─ 创建接收目录: ~/Downloads/LanChat/
│   ├─ 处理文件名冲突 (添加 (1), (2)...)
│   ├─ 发送 ACK
│   ├─ 分块接收写入文件
│   │   └─ 每块推送进度到消息队列
│   └─ 创建 ChatMessage (is_file=True)
└─ msg_type == "msg"
    └─ 创建 ChatMessage → 入队
线程安全设计
  • peers 字典由 peers_lock (threading.Lock) 保护
  • 所有 UI 更新通过 root.after(0, callback) 委托到主线程
  • 网络线程通过 queue.Queue 异步传递消息到 UI 层
  • _queue_processor 单线程消费消息队列,避免竞态

UI 层 --- ui.py

窗口层级

复制代码
MainWindow (DnDTk)
├── PanedWindow
│   ├── 左侧面板 (Frame, 220px)
│   │   ├── Label "在线用户"
│   │   ├── Button "🔄 刷新"
│   │   └── Listbox (peer_listbox)
│   │
│   └── 右侧容器 (Frame, weight=1)
│       ├── welcome_frame (初始欢迎页)
│       └── ChatView (聊天视图)
│           ├── 顶部导航 (← 返回 + 标题)
│           ├── Canvas + Scrollbar (消息区域)
│           │   └── msg_frame
│           │       └── ChatBubbleFrame ×N
│           ├── 拖拽提示条
│           └── 底部输入区
│               ├── Text (多行输入)
│               ├── Button "发送"
│               ├── Button "📎 发送文件"
│               └── Label (进度提示)

ChatBubbleFrame --- 聊天气泡

渲染流程
复制代码
__init__(parent, msg)
├─ 创建 Canvas
├─ 创建 content Frame (Canvas 的子控件)
├─ 组装内容 (时间 + 文本/文件控件)
├─ 临时 pack + create_window 测量尺寸
│   ├─ content.winfo_width()
│   └─ content.winfo_height()
├─ 删除临时窗口
├─ 配置 Canvas 最终尺寸 (宽度 + RADIUS, 高度 + 4)
├─ pack 到父容器 (anchor=E/W + 边距)
├─ 绘制圆角矩形背景 (polygon + smooth)
└─ create_window 居中嵌入 content
圆角矩形算法
复制代码
_round_rect(c, x1, y1, x2, y2, r=10)
  输入: Canvas 对象, 矩形左上角(x1,y1), 右下角(x2,y2), 圆角半径r
  输出: Canvas polygon ID

  控制点:
  (x1+r,y1) ──────────── (x2-r,y1)
  │                        │
  │   ┌────────────────┐   │
  │   │    content     │   │
  │   └────────────────┘   │
  │                        │
  (x1,y1+r)              (x2,y1+r)
     │                      │
     │                      │
  (x1,y2-r)              (x2,y2-r)
     │                      │
  (x1+r,y2) ──────────── (x2-r,y2)

  → 使用 create_polygon(points, smooth=True)
     smooth 自动在角点生成贝塞尔曲线
气泡布局
条件 对齐 padx_left padx_right 背景色
is_self=True tk.E (右) 40 0 #dcf8c6
is_self=False tk.W (左) 0 40 #ffffff

定时刷新机制

机制 触发方式 间隔
UDP 事件驱动 on_peers_changed 回调 实时 (收到 hello 时)
定时器兜底 _start_peer_refresh_timer 2s
手动刷新 用户点击 "🔄 刷新" ---

拖拽文件发送

  • 依赖 tkinterdnd2 库(基于 TkDnD 原生扩展)
  • MainWindow 使用 DnDTk() 替代 tk.Tk() 作为根窗口,使所有子控件继承拖放能力
  • ChatView._setup_drag_drop() 注册 DND_FILES 类型
  • 拖放时解析 event.data,提取文件路径,调用 network.send_file()

右键菜单

消息类型 菜单项
文本消息 复制文本、复制消息内容
文件消息 打开文件、另存文件...、复制消息内容

数据流

文本消息发送

复制代码
用户输入 → Enter / 点击发送
  → ChatView._send_message()
    → 创建 ChatMessage (is_self=True)
    → _add_message() 本地显示
    → 线程: network.send_message(peer_ip, text)
      → TCP 连接 → JSON 编码 → sendall
        → 接收方 _handle_tcp_client()
          → ChatMessage 入队
            → _queue_processor()
              → on_message() callback
                → MainWindow._on_message()
                  → root.after(0, ...)
                    → ChatView.receive_message()
                      → _add_message() 显示

文件发送

复制代码
用户选择文件 / 拖拽
  → network.send_file(peer_ip, path)
    → TCP 连接 → 发送文件头
    → 接收方 ACK
    → 分块发送 (每块32KB)
    → 每块发完后推送进度到队列
      → on_file_progress()
        → ChatView.update_progress()
    → 发送完成 → 创建 ChatMessage (is_self=True)
      → on_message() → 显示文件气泡

接收方:
  TCP 接收 → 文件头解析
  → 创建 ~/Downloads/LanChat/
  → 分块写入文件
  → 每块推送进度 → UI 进度条
  → 完整接收 → ChatMessage (is_file=True) → 显示文件气泡

配置与打包

PyInstaller 打包

打包配置见 LanChat.spec:

python 复制代码
# 关键配置
datas = ['tkinterdnd2/tkdnd']  # 需要打包 TkDnD 原生扩展库
hiddenimports = ['tkinterdnd2']
console = False                # 无控制台窗口

依赖

依赖 用途 安装
Python ≥ 3.10 运行时 ---
tkinter GUI 框架 Python 内置
tkinterdnd2 文件拖拽 pip install tkinterdnd2
PyInstaller 打包 exe pip install pyinstaller

构建命令

bash 复制代码
pyinstaller LanChat.spec

端口占用

端口 协议 用途
9876 UDP 对等发现 (广播 + 监听)
9877 TCP 消息传输、文件传输

关键设计决策

为什么使用单例 UDP socket?

最初 _udp_listener_udp_broadcaster 各自创建独立的 UDP socket,但 Windows 上多 socket 绑定同一端口行为不可靠(SO_REUSEADDR 无法保证所有平台都能接收广播)。改为在 start() 中创建单个 socket (同时设置 SO_BROADCASTSO_REUSEADDR),两个线程共享此 socket 分别执行 sendtorecvfrom,避免了端口冲突问题。

为什么用 Canvas 绘制气泡背景?

Tkinter 原生控件不支持圆角。采用 Canvas.create_polygon(smooth=True) 绘制圆角矩形,通过 12 个控制点生成平滑贝塞尔曲线,替代了原始的 Frame 背景方案,实现聊天气泡的圆角效果。

为什么使用 create_window 要求子控件关系?

Tkinter 的 canvas create window 要求嵌入的控件必须是 Canvas 的直接子控件(或同一窗口树中的后代)。将 content Frame 的父控件设置为 self.canvas(而非 self)才能正确通过 create_window 嵌入并显示。

线程模型设计考量

线程 职责 为什么独立
_udp_listener 持续接收广播 recvfrom 是阻塞调用
_udp_broadcaster 定时发送心跳 + 超时检测 需要精确计时循环
_tcp_server_thread 接受 TCP 连接 accept 是阻塞调用
_queue_processor 消费消息队列 串行化网络 → UI 消息
TCP handler ×N 处理单个连接 recv 可能长时间阻塞(尤其文件传输)

所有网络线程使用 daemon=True,主窗口关闭时自动退出。UI 更新通过 root.after(0, callback) 线程安全地调度到主线程。


错误处理策略

场景 处理方式
UDP 端口被占用 关闭 socket,设置 udp_sock = None,仅发送广播不接收
TCP 端口被占用 tcp_server = None,跳过接收(不能发送消息和文件)
发送消息超时/失败 send_message 返回 False,UI 弹出警告
文件接收目录创建失败 静默处理,文件可能无法保存
网络线程异常 通用 except: continue 保持线程运行
tkinterdnd2 未安装 拖拽功能降级为文字提示,文件发送走按钮

版本构建

当前版本通过 PyInstaller 打包为单个 main.exe,位于 dist/ 目录。TkDnD 原生扩展库 (tkdnd/) 需随程序分发,已在 .spec 中通过 datas 配置。