码道(CodeArts)实战体验:基于FMOV4.0协议的业余无线电网络通讯手机终端

工具:华为云码道(CodeArts)代码智能体
项目:FMO(FM Over Internet)业余无线电数字通联系统
任务:从零开发 FMO 手持对讲 Android APP + 用户注册监管系统 + 总系统管理平台
体验关键词:自然语言驱动、团队协作、底层 Bug 定位、前后端分离、双端口安全架构、跨平台部署
开发规模:Android APP(Java,50+ 文件)、服务器端(Python,零依赖)、正式版 v3.0.0.1
开发周期:从需求描述到正式版 APK + NAS 部署完成,全程自然语言驱动


目录


一、背景:为什么会用码道

FMO 项目简介

在业余无线电圈中,FMO(FM Over Internet) 是一项极具前瞻性的创新项目:将传统模拟调频对讲机的通信方式,通过互联网重构为低延迟、高安全、全球互联的数字语音链路。

但长期以来,缺乏一个真正可用的移动端客户端——尤其是能原生支持 MQTT v5 + Ed25519 零信任认证 + Opus 8kHz 实时编码 的 Android 应用。

我的挑战

  • 要实现一套完整的 FMO v4.0 协议栈(含加密、认证、音视频流)
  • 构建前后端分离的用户注册监管系统
  • 搭建双端口安全架构(公网接入 / 内网管理)
  • 支持多房间切换、聊天记录回放、隐私合规弹窗
  • 最终部署到家庭 NAS(Synology DS923+)

传统开发模式下,这至少需要 2~3 个工程师协作 3 个月以上。

于是我决定尝试:用华为云码道(CodeArts)代码智能体,以自然语言驱动整个开发流程。

需求很明确:

  • 协议层:用 Java 开发,实现 FMO 协议(MQTT v5、SAS 认证、Opus 编解码、CBOR 编码、Ed25519 签名)
  • 音频层:8kHz 采样率录音/播放,Opus 压缩,ring buffer + jitter buffer 抗抖动
  • UI 层:PTT 按住说话,松开收听,支持多房间切换,聊天记录可回放
  • 发现机制:通过 APRS-IS 自动发现全球 FMO 服务器
  • 证书认证:Ed25519 签名 + CBOR 编码 + 证书指纹,零信任架构
  • 用户系统:注册/登录/心跳,服务器地址可配置,不硬编码
  • 监管系统:管理后台监控在线状态,分系统/总系统架构,双端口安全

这是一个涉及协议、音频、加密、UI、后端、部署的全栈项目。我决定用华为云码道来开发,看看它能不能胜任这种复杂度的任务。以下是完整的使用体验。


二、项目成果速览

在展开详细体验之前,先看看最终交付了什么:

交付物清单

交付物说明状态
FMO 对讲 APPAndroid 正式版 APK,17.8 MB,已签名✅ v3.0.0.1
分系统后端Python API 服务器(注册/登录/心跳/上报/管理后台)✅ 已部署 NAS
总系统后端Python 双端口服务器(公网接收+内网管理)✅ 已部署 NAS
证书申请系统Python 证书生成服务(Ed25519 证书链)✅ 完成
FMO 服务器Node.js SAS 认证 + MQTT broker✅ 完成

APP 功能矩阵

模块功能点实现状态
FMO 协议层MQTT v5 收发✅
SAS 认证(Ed25519 + CBOR)✅
FMO 帧解析/构造✅
Opus 编解码(8kHz)✅
证书管理(4 件套上传)✅
音频引擎AudioRecord 录音✅
AudioTrack 播放✅
ring buffer + jitter buffer✅
PTT 按住说话控制✅
声音放大 5 倍 + clamp 防爆音✅
UI三页面(对讲/房间/设置)✅
深色科技风主题✅
隐私弹窗 + 免责协议✅
聊天记录 + 语音回放✅
用户系统登录(服务器地址可配置)✅
注册(含证书上传)✅
心跳上报(5 秒间隔)✅
服务器发现APRS-IS 自动发现✅
手动直连✅
收藏缓存✅

服务器端架构

                    ┌─────────────────────────────────────────┐
                    │              公网互联网                   │
                    └──────┬──────────────────┬────────────────┘
                           │                  │
              APP 用户     │            分系统上报
          (注册/登录/心跳) │          (POST /api/subsystem/report)
                           ▼                  ▼
  ┌──────────────┐              ┌──────────────────────┐
  │  分系统       │              │  总系统               │
  │  (内网A)      │              │  (内网B)              │
  │              │   出站POST    │                      │
  │  API:35928   │─────────────▶│  公网端口:35929       │
  │  注册/登录   │              │  只接收上报(403拒绝)  │
  │  心跳/管理   │              │                      │
  │              │              │  内网端口:35930       │
  │  后台线程    │              │  管理界面+查询API     │
  │  30秒上报    │              │  (不暴露公网)         │
  └──────────────┘              └──────────────────────┘
                                       ▲
                                管理员(内网访问)

三、技术栈全景

APP 端

层技术说明
语言Java 11非 Kotlin,保持与参考实现一致
构建Gradle 8.x + Kotlin DSLbuild.gradle.kts
协议Paho MQTT v5MqttClient / MqttConnectionOptions
音频Android AudioRecord/AudioTrack8kHz 采样率
编解码Opus + IMA ADPCMOpus 主力,ADPCM 备选
加密Ed25519 + CBOR + SHA-256零信任证书链
UIAndroid View + Fragment深色科技风
存储SharedPreferences登录态、服务器地址、收藏
包名com.example.myapplication—

服务器端

组件技术说明
语言Python 3.x标准库 only,零第三方依赖
HTTPhttp.server + socketserverThreadingTCPServer 多线程
数据库SQLite3sqlite3 标准库
配置JSONconfig.json(utf-8-sig 编码)
部署systemd + shellSynology NAS DSM
认证SHA-256 密码哈希无盐(简化版)

FMO 服务器

组件技术说明
MQTT BrokerNode.js + aedes需用 Aedes.createBroker()
认证SAS (Rust)Ed25519 证书验证
证书Python 生成复现 Rust cbor_tbs 逻辑

img


四、体验全过程

4.1 第一印象:它真的在"理解"项目

我第一句话描述了需求,码道没有直接开始写代码,而是先探索了整个项目结构。它读取了 Rust 参考实现的源码(fmo-server-ref/src/),理解了:

  • FMO 协议的 MQTT 话题:FMO/RAW 语音帧、FMO/TELE 遥测
  • 语音帧格式:64 字节帧头,呼号在 [10:16],[0]=0x01 标识语音帧
  • APRS-IS 广播格式:FMO-V4 STATION,提取 host/port/online/freq
  • 认证流程:CBOR 编码 TBS → Ed25519 签名 → 证书指纹 → SAS 认证
  • Opus 音频参数:8kHz 采样率,每帧 6 个 Opus 子包,每个 40ms
  • IMA ADPCM:备选编解码,4-bit 压缩

然后它用一句话总结了我需要什么,并提出了分模块的实现方案。这种"先理解再动手"的方式让我很放心——它不是在盲目生成代码,而是基于对项目的理解在做决策。如果是我自己来做,光理解这些协议细节就要花好几天,而码道几分钟就完成了项目分析。

让我印象深刻的细节:它甚至注意到了 FMO 帧头中 [0]=0x01 这个标识字节的含义——这是语音帧的标识,区别于遥测帧。这种从二进制协议细节中提取语义的能力,说明它不是在"搜索"代码,而是在"阅读和理解"代码。

4.2 团队协作:7 个子代理并行开发

码道做了一个让我意外的操作:它创建了一个团队,分配了 7 个不同角色的子代理并行工作:

子代理任务产出文件耗时
框架工程师Gradle 依赖、AndroidManifest、导航骨架build.gradle.kts, AndroidManifest.xml~15min
FMO 协议工程师MQTT v5、SAS 认证、FMO 帧解析、证书管理fmo/ 目录(7 个文件)~20min
音频工程师AudioRecord 录音、AudioTrack 播放、Opus 编解码audio/ 目录(4 个文件)~20min
FMO 界面工程师对讲页、PTT 按钮、语音收发、聊天记录TalkFragment + 布局~22min
房间界面工程师APRS 服务器发现、列表、连接/断开、收藏RoomFragment + 布局~22min
设置界面工程师证书上传(4 件套)、音频配置、直连SettingsFragment + 布局~22min
集成工程师编译修复、界面间数据流转、权限请求MainActivity + strings~35min

img

每个子代理独立工作,完成后向团队领导汇报。我能看到任务状态从 pending → in_progress → completed 实时更新。这种团队协作模式让 7 个子任务并行推进,整个 APP 骨架在约 35 分钟内就完成了——如果串行做,至少要 2-3 小时。

更让我印象深刻的是子代理之间的依赖管理:界面工程师依赖框架工程师和协议工程师的产出,码道自动处理了这些依赖关系,确保先完成基础模块再做上层 UI。

团队协作的内部机制:码道维护了一个全局任务列表(publish-task),每个任务有 blocked_by 字段标记前置依赖。只有前置任务完成后,后续任务才会被分配给子代理。这确保了:

  • 框架工程师先完成 Gradle 配置 → 协议工程师和音频工程师才能开始
  • 协议工程师完成 FMO 帧解析 → 界面工程师才能调用
  • 所有界面完成后 → 集成工程师做编译修复和数据流转
框架工程师 ──┬──▶ FMO 协议工程师 ──┐
             ├──▶ 音频工程师 ──────┤
             │                     ├──▶ FMO 界面工程师 ──┐
             │                     ├──▶ 房间界面工程师 ──┤
             │                     ├──▶ 设置界面工程师 ──┤
             └─────────────────────┘                     │
                                                       ▼
                                              集成工程师

4.3 MQTT v3 到 v5 的迁移:从被拒绝到连接成功

APP 最初用的是 Paho MQTT v3 客户端,但 FMO 服务器要求 MQTT v5。连接一直失败,码道的排查过程像一部侦探小说:

第一步:怀疑认证失败

  • 检查 broker 日志 → 发现 SAS 返回 "deny"
  • 直接 POST 到 SAS → 发现 SAS 返回 "allow"
  • 结论:认证本身没问题,问题在 MQTT 连接层

第二步:怀疑 SAS 参数

  • 发现 SAS 进程没带启动参数(--cert 等缺失)
  • 重启 SAS 带正确参数 → 认证通过了
  • 但 MQTT 仍然超时

第三步:用原始 TCP 测试

  • 写了原始 TCP 脚本,手工构造 MQTT CONNECT 包直接发送
  • broker 收到了连接,但完全不回 CONNACK
  • 结论:不是认证问题,是 broker 库本身不回响应
# 原始 TCP 测试脚本(手工构造 MQTT CONNECT 包)
import socket, struct

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('localhost', 1883))

# MQTT CONNECT 包: Fixed header + Variable header + Payload
# Fixed header: 0x10 (CONNECT), Remaining length
# Variable header: Protocol name "MQTT", Level 5, Connect flags
# Payload: Client ID
packet = bytes([
    0x10, 0x10,       # Fixed header: CONNECT, len=16
    0x00, 0x04,       # Protocol name length: 4
    0x4D, 0x51, 0x54, 0x54,  # "MQTT"
    0x05,             # Protocol level: 5
    0x02,             # Connect flags: Clean Start
    0x00, 0x3C,       # Keep alive: 60s
    0x00, 0x00,       # Client ID length: 0 (empty)
])
sock.send(packet)
# 等待 CONNACK... 超时!broker 不回响应

第四步:定位 aedes 库的 Bug

  • 写了最小化测试:new Aedes() + net.createServer → 同样超时
  • 查 aedes 示例代码 → 发现用的是 await Aedes.createBroker(),不是 new Aedes()
  • 改用 createBroker() → 连接成功!

根因:aedes 1.1.1 在 Node.js v24 上,new Aedes({...}) 构造的 broker 不发送 CONNACK 包,导致所有 MQTT 客户端超时。改用异步工厂方法 Aedes.createBroker() 后恢复正常。

// ❌ 错误写法:不发送 CONNACK
const aedes = new Aedes({ id: 'broker' });
const server = net.createServer(aedes.handle);
server.listen(1883);

// ✅ 正确写法:异步工厂方法
const aedes = await Aedes.createBroker({ id: 'broker' });
const server = net.createServer(aedes.handle);
server.listen(1883);

第五步:迁移到 MQTT v5

  • 把 Paho v3 客户端全部重写为 v5 API(MqttClient/MqttConnectionOptions/MqttCallback v5 版本)
  • client ID 改用用户真实 UID 而非 0
  • 添加 MqttSubscription.setNoLocal(true) 防止收到自己发布的消息
// MQTT v5 连接配置
MqttConnectionOptions options = new MqttConnectionOptions();
options.setCleanStart(true);
options.setKeepAliveInterval(60);
options.setUserName(uid);  // 用真实 UID 而非 0
options.setPassword(password.toCharArray());

// 订阅时设置 NoLocal,防止收到自己的语音
MqttSubscription sub = new MqttSubscription("FMO/RAW", 0);
sub.setNoLocal(true);  // v5 特性:不接收自己发布的消息
client.subscribe(new MqttSubscription[]{sub});

这个排查过程展示了码道的分层排查能力:从应用层(认证)→ 协议层(MQTT)→ 传输层(TCP)→ 库 Bug。每一层都写了测试验证,不是瞎猜。这种排查能力已经接近一个有经验的网络工程师了。

排查方法论总结:

应用层:SAS 认证是否通过?
  ├── 是 → 问题不在认证层,继续往下
  └── 否 → 修复认证参数
       │
协议层:MQTT CONNECT 是否收到 CONNACK?
  ├── 是 → 问题不在连接层,检查订阅/发布
  └── 否 → 继续往下
       │
传输层:原始 TCP 能否收到响应?
  ├── 是 → 问题在 MQTT 客户端库
  └── 否 → 继续往下
       │
库层:aedes 最小化测试是否正常?
  ├── 是 → 问题在集成方式
  └── 否 → 定位到库 Bug,找替代 API

4.4 证书生成工具:Python 复现 Rust 逻辑

FMO 认证需要 Ed25519 证书链(Root CA → Intermediate CA → User Cert → Device Key),输出 4 个 JSON 文件。Rust 参考实现用了 cbor_tbs 和 Ed25519 签名,码道需要用 Python 完整复现这个逻辑。

它实现了:

  • CBOR 编码:匹配 Rust 的 cbor_tbs 逻辑,TBS 格式 Root CA 15 元素、Int CA 20 元素
  • Ed25519 签名:用 cryptography 库生成密钥对和签名
  • base64url 编解码:URL 安全的 base64 变体
  • CBOR bool 编码:true=0xF5,false=0xF4(CBOR 规范)
  • 证书指纹计算:SHA-256 哈希后取前 8 字节
# CBOR 编码 TBS(To Be Signed)数据
def cbor_tbs(elements):
    """匹配 Rust 的 cbor_tbs 逻辑"""
    # CBOR array header: 0x80 + n (for n < 24)
    result = bytes([0x80 + len(elements)])
    for elem in elements:
        result += encode_cbor(elem)
    return result

# Ed25519 签名
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
private_key = Ed25519PrivateKey.generate()
signature = private_key.sign(tbs_bytes)  # 签名 TBS 数据

# 证书指纹:SHA-256 前 8 字节
import hashlib
fingerprint = hashlib.sha256(cert_bytes).digest()[:8]

它还写了验证脚本,对比 Python 输出和 Rust 参考实现的二进制是否一致。这个过程让我印象深刻——它不是"大概写一下然后祈祷能跑",而是每一步都做了验证。它甚至发现了 Buffer.from(s, 'base64url') 在 Node.js 16+ 才可用这种细节。

证书链结构:

Root CA (自签名)
  │
  ├── 签名 → Intermediate CA
  │              │
  │              ├── 签名 → User Cert
  │              │              │
  │              │              ├── 绑定 → Device Key
  │              │              │
  │              │              └── 输出: user.json
  │              │
  │              └── 输出: intermediate.json
  │
  └── 输出: root.json

输出 4 个 JSON 文件:
  ├── root.json          # Root CA 公钥 + 证书
  ├── intermediate.json  # Intermediate CA 公钥 + 证书
  ├── user.json          # User 证书(含呼号、UID)
  └── device.json        # Device 私钥 + 公钥

4.5 隐私弹窗与界面美化

隐私弹窗

我说"需要免责协议+隐私协议弹窗,勾选后才能进入程序"。码道创建了:

  • dialog_privacy.xml:底部圆形勾选框,深色主题
  • PrivacyDialog.java:弹窗逻辑,未勾选时禁止进入
  • ic_check_circle.xml:圆形勾选 drawable 样式
  • bg_privacy_dialog.xml:弹窗背景圆角样式
// PrivacyDialog 核心逻辑
public class PrivacyDialog extends Dialog {
    private CheckBox cbAgree;
    private Button btnEnter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.dialog_privacy);

        cbAgree = findViewById(R.id.cb_agree);
        btnEnter = findViewById(R.id.btn_enter);

        btnEnter.setOnClickListener(v -> {
            if (cbAgree.isChecked()) {
                // 勾选了,保存同意状态,进入主界面
                SharedPreferences sp = getContext()
                    .getSharedPreferences("privacy", Context.MODE_PRIVATE);
                sp.edit().putBoolean("agreed", true).apply();
                dismiss();
                startMainActivity();
            } else {
                // 未勾选,提示
                Toast.makeText(getContext(), "请先勾选同意", Toast.LENGTH_SHORT).show();
            }
        });
    }
}

界面美化

我说"科技感深色主题"。码道修改了所有布局文件和 drawable 资源:

资源效果
bg_gradient_dark.xml深色渐变背景(#0a0e1a → #1b263b)
bg_card_surface.xml圆角卡片(8dp 圆角,半透明背景)
bg_button_primary.xml霓虹蓝渐变按钮(#00b4ff → #00e5ff)
bg_bottom_nav.xml底部导航栏毛玻璃效果
colors.xml科技色板(主色 #00e5ff,强调 #00ff9d,危险 #ff4d6d)
<!-- bg_gradient_dark.xml: 深色科技渐变背景 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <gradient
        android:startColor="#0a0e1a"
        android:endColor="#1b263b"
        android:angle="270" />
</shape>

<!-- bg_button_primary.xml: 霓虹蓝渐变按钮 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <gradient
        android:startColor="#00b4ff"
        android:endColor="#00e5ff"
        android:angle="45" />
    <corners android:radius="8dp" />
</shape>

最终效果是一个深色科技风的对讲 APP,看起来像专业无线电设备的应用,而不是普通的聊天软件。PTT 按钮在按下时会有霓虹蓝发光效果,语音电平指示条用渐变色显示音量大小,整体视觉风格统一且专业。

4.6 UI 重构:从两协议到三页面

最初 APP 同时支持 FMO 和 NRL 两个协议,底部导航有 FMO/NRL/设置三个页。后来决定去掉 NRL,专注 FMO 协议。码道执行了一次大规模重构:

  1. 删除 NRL 相关所有代码:NrlFragment、NrlServerAdapter、NrlUdpClient、NrlPlatformApi、NrlProtocol、G711Alaw、fragment_nrl.xml 等十几个文件
  2. 创建共享会话管理器 FmoSession.java:单例模式,集中管理 FmoMqttClient、AudioEngine、CertManager、AprsClient、连接状态、服务器列表、收藏列表、发射会话参数
  3. 重构为三页面:
    • 对讲页(TalkFragment):PTT 按钮 + 聊天记录 + 电平指示 + 活跃说话人
    • 房间页(RoomFragment):APRS 服务器发现 + 搜索 + 收藏 + 连接/断开 + 直连
    • 设置页(SettingsFragment):证书上传 + 音频配置
  4. 添加聊天记录功能:接收语音自动记录(呼号 + 时间戳),PCM 保存为 WAV 文件,点击可回放录音
// FmoSession: 共享会话管理器(单例)
public class FmoSession {
    private static FmoSession instance;

    // 核心组件
    private FmoMqttClient mqttClient;
    private AudioEngine audioEngine;
    private CertManager certManager;
    private AprsClient aprsClient;

    // 状态
    private boolean connected;
    private List<FmoServer> serverList;
    private Set<String> favorites;
    private TransmitSession txSession;

    public static synchronized FmoSession getInstance() {
        if (instance == null) instance = new FmoSession();
        return instance;
    }
}

重构后代码更清晰,功能更聚焦。整个过程码道自动处理了所有编译依赖和引用关系——删除 NRL 后,所有引用 NRL 的地方都自动清理,没有留下悬空引用导致编译失败。

4.7 声音放大:一个简单但关键的需求

实际使用中发现语音音量太小,我说"声音可以大不能小,至少比原来大 5 倍"。码道直接在 AudioEngine.java 的 playPcm 方法中把 PCM sample × 5,并 clamp 到 short 范围防止爆音:

// 声音放大 5 倍,clamp 防止爆音
for (int i = 0; i < samples.length; i++) {
    int sample = samples[i] * 5;
    samples[i] = (short) Math.max(Short.MIN_VALUE, 
        Math.min(Short.MAX_VALUE, sample));
}

简单直接,不需要我解释具体怎么改。这种"说需求就改"的效率让我很满意。

技术细节:PCM(Pulse-Code Modulation)音频中每个 sample 是一个 16-bit 有符号整数(short,范围 -32768 到 32767)。直接乘以 5 后可能超出 short 范围,导致整数溢出产生杂音。Math.max(Short.MIN_VALUE, Math.min(Short.MAX_VALUE, sample)) 这个 clamp 操作确保放大后的值不会超出 short 范围——超过 32767 的截断为 32767,低于 -32768 的截断为 -32768。虽然截断会引入少量失真,但比整数溢出导致的爆音好得多。

4.8 音频修复:ring buffer + jitter buffer

语音播放有两个问题:卡顿和聊天记录重复显示 6 遍。码道对照 Rust 参考实现的 PlaybackState,做了两个修复:

修复 1:ring buffer + jitter buffer

网络语音包到达时间不均匀(jitter),直接写 AudioTrack 会导致:

  • 包到达快时:AudioTrack 缓冲区溢出,丢包
  • 包到达慢时:AudioTrack 缓冲区空,播放卡顿

解决方案是引入 ring buffer 作为缓冲层:

网络包到达 → ring buffer(200ms 深度)→ 专用播放线程 → AudioTrack
                    ↑
              jitter buffer 逻辑:
              - 包序号乱序时重排
              - 包延迟超过阈值时丢弃
              - 缓冲区水位低时等待
  • 200ms 目标深度的 ring buffer
  • 专用播放线程从 ring buffer 读取写入 AudioTrack
  • playPcm 改为入队到 ring buffer,不再直接写 AudioTrack
  • 效果:语音流畅,不再卡顿
// ring buffer 核心(简化版)
class RingBuffer {
    private final byte[] buffer;
    private int writePos = 0, readPos = 0;
    private final Object lock = new Object();

    void write(byte[] data) {
        synchronized (lock) {
            // 写入数据到环形缓冲区
            System.arraycopy(data, 0, buffer, writePos, data.length);
            writePos = (writePos + data.length) % buffer.length;
            lock.notify();  // 唤醒播放线程
        }
    }

    byte[] read(int len) {
        synchronized (lock) {
            while (available() < len) {
                lock.wait();  // 等待数据到达
            }
            // 从环形缓冲区读取数据
            ...
        }
    }
}

修复 2:聊天记录重复

一条 FMO/RAW 消息包含 6 个 Opus 子包(每个 40ms,共 240ms 语音):

  • 原来每个子包解码后都回调一次 onVoiceReceived,导致记录重复 6 遍
  • 修复:6 个子包解码后 PCM 拼接成一个 byte[],只回调一次
  • 效果:聊天记录每条语音只显示一次
修复前:                          修复后:
FMO/RAW 消息                     FMO/RAW 消息
  ├── Opus 子包 1 → onVoice()      ├── Opus 子包 1 → PCM 1
  ├── Opus 子包 2 → onVoice()      ├── Opus 子包 2 → PCM 2
  ├── Opus 子包 3 → onVoice()      ├── Opus 子包 3 → PCM 3
  ├── Opus 子包 4 → onVoice()      ├── Opus 子包 4 → PCM 4
  ├── Opus 子包 5 → onVoice()      ├── Opus 子包 5 → PCM 5
  └── Opus 子包 6 → onVoice()      └── Opus 子包 6 → PCM 6
                                       └── 拼接 PCM 1-6 → onVoice()  ← 只回调一次

4.9 登录注册系统:从设计到验证

需求描述

我说"登录/注册页面都要支持填入服务器地址,不硬编码"。码道创建了完整的用户系统:

组件功能
LoginActivity服务器地址 + 呼号 + 密码,登录后保存 token
RegisterActivity服务器地址 + 姓名 + 呼号 + 电话 + 密码 + 上传证书图片
UserManager单例管理登录状态、token、动态服务器地址(SharedPreferences)
HeartbeatService每 5 秒上报心跳,使用 UserManager 的动态服务器地址
MainActivity启动流程:未登录跳转 LoginActivity,登录后启动心跳
// UserManager: 动态服务器地址管理
public class UserManager {
    private static UserManager instance;
    private SharedPreferences sp;

    public String getServerUrl() {
        return sp.getString("server_url", "");  // 不硬编码,从配置读取
    }

    public void saveLogin(String token, String name, String callsign) {
        sp.edit()
          .putString("token", token)
          .putString("name", name)
          .putString("callsign", callsign)
          .putBoolean("logged_in", true)
          .apply();
    }

    public boolean isLoggedIn() {
        return sp.getBoolean("logged_in", false);
    }
}

// HeartbeatService: 每 5 秒心跳
public class HeartbeatService extends Service {
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        handler.postDelayed(heartbeatRunnable, 5000);  // 5 秒间隔
        return START_STICKY;
    }

    private final Runnable heartbeatRunnable = () -> {
        String serverUrl = UserManager.getInstance().getServerUrl();
        // 用动态服务器地址发心跳
        sendHeartbeat(serverUrl);
        handler.postDelayed(this, 5000);
    };
}

端到端验证

码道不是写完就走,而是做了完整的端到端测试:

  1. gradlew assembleDebug 构建 APK → BUILD SUCCESSFUL
  2. adb install 安装到模拟器 → Success
  3. 启动 APP → 验证未登录时跳转登录页
  4. 截图确认登录页布局(有服务器地址输入框 ✅)

登录页面

  1. 用 adb shell input tap 点击"去注册" → 验证跳转注册页

注册页面

  1. 用 uiautomator dump 获取输入框坐标,adb shell input text 填写表单
  2. 验证校验逻辑:未上传证书时点击注册 → 阻止提交(留在注册页)
  3. 直接 curl 调用服务器 API 验证注册/登录/心跳全链路
# 端到端验证脚本
# 1. 注册
curl -X POST http://10.0.2.2:35928/api/register \
  -H "Content-Type: application/json" \
  -d '{"name":"测试","callsign":"BG7XYZ","phone":"13800138000","password":"123456"}'
# → {"ok":true,"token":"abc123","callsign":"BG7XYZ"}

# 2. 登录
curl -X POST http://10.0.2.2:35928/api/login \
  -H "Content-Type: application/json" \
  -d '{"callsign":"BG7XYZ","password":"123456"}'
# → {"ok":true,"token":"abc123","name":"测试","callsign":"BG7XYZ"}

# 3. 心跳
curl -X POST http://10.0.2.2:35928/api/heartbeat \
  -H "Content-Type: application/json" \
  -d '{"callsign":"BG7XYZ","token":"abc123"}'
# → {"ok":true}

Lambda 变量问题

构建时遇到编译错误:variable used in lambda should be final or effectively final。码道立刻定位到 RegisterActivity.java 第 326 行,serverUrl 被重新赋值(加了 http:// 前缀)后在 lambda 中使用。修复方式是创建 final 副本:

// 规范化服务器地址
if (!serverUrl.startsWith("http://") && !serverUrl.startsWith("https://")) {
    serverUrl = "http://" + serverUrl;
}
// lambda 要求变量 effectively final,创建 final 副本
final String finalServerUrl = serverUrl;
new Thread(() -> {
    String result = doRegister(finalServerUrl, ...);
}).start();

这是 Java lambda 的经典陷阱:lambda 捕获的局部变量必须是 effectively final(声明后不再修改)。serverUrl 被重新赋值后就不是 effectively final 了,所以需要创建一个不再修改的副本 finalServerUrl。

4.10 正式版打包:版本 3.0.0.1

我说"打包正式版 3.0.0.1",码道自动完成了全流程:

  1. 修改 build.gradle.kts:versionCode = 30001、versionName = "3.0.0.1"
  2. 运行 assembleRelease 构建 → BUILD SUCCESSFUL
  3. 用 fmo-release.keystore 签名
  4. 安装到模拟器验证版本号 → versionName=3.0.0.1, versionCode=30001
  5. 启动 APP 验证正式版运行正常
  6. 复制为 fmo-对讲终端-v3.0.0.1-release.apk(17.8 MB)
// build.gradle.kts 版本配置
android {
    defaultConfig {
        versionCode = 30001      // 3.0.0.1 → 30001
        versionName = "3.0.0.1"
    }

    signingConfigs {
        create("release") {
            storeFile = file("fmo-release.keystore")
            storePassword = "..."
            keyAlias = "fmo"
            keyPassword = "..."
        }
    }

    buildTypes {
        release {
            signingConfig = signingConfigs.getByName("release")
            isMinifyEnabled = false
        }
    }
}

整个过程不需要我干预,自动完成构建、签名、验证、命名。

4.11 服务器端:前后端分离 + 双端口安全架构

架构演进

最初我描述的需求是"分系统往总系统发东西"。码道设计了分系统推送方案。但我接着说"总系统在内网,分系统在不同的内网",码道给出了方案A(总系统拉取)和方案B(总系统暴露接收端口)。

我进一步明确:"分系统在内网中开一个上报的 API 接口,从外向内无法访问,只能从内向外发送消息,总系统暴露一个端口在公网中只用于接收,不用于排出,最大程度保护信息。"

码道立刻理解了,设计了双端口安全架构:

  分系统(内网A)                              总系统(内网B)
  ┌──────────────┐                        ┌──────────────────────┐
  │ API:35928    │──公网映射──> APP用户    │ 公网端口:35929       │
  │ (注册/登录)  │                        │ 只接收上报(403拒绝读取)│
  │              │                        │                      │
  │ 上报功能     │──出站POST──>           │ 内网端口:35930       │
  │ (不暴露公网) │   (往公网端口35929)    │ 管理界面+查询API     │
  └──────────────┘                        │ (不暴露公网)         │
                                          └──────────────────────┘
                                                 ↑
                                          管理员(内网访问)

安全保证:

  • 公网端口:只接受 POST /api/subsystem/report,所有 GET 请求 → 403 Forbidden,所有 DELETE 请求 → 403 Forbidden
  • 内网端口:管理界面 + 查询 API,不暴露公网,只有内网管理员能访问
  • 分系统:只需出站访问公网,不需要开任何入站端口

为什么这样设计安全?

攻击者从公网尝试访问总系统:
  ├── 访问公网端口 35929
  │   ├── GET 请求 → 403 Forbidden(无法读取任何数据)
  │   ├── DELETE 请求 → 403 Forbidden(无法删除任何数据)
  │   └── POST /api/subsystem/report → 可以上报(但只能注入数据,无法获取数据)
  │
  └── 访问内网端口 35930
      └── 无法访问(内网端口不暴露公网,路由不可达)

攻击者从公网尝试访问分系统:
  └── 无法访问(分系统不暴露公网入站端口,只有出站连接)

代码实现

码道创建了一个 PublicHandler 类继承 MasterHandler,重写所有非上报方法返回 403:

class PublicHandler(MasterHandler):
    """公网端口:只允许 POST /api/subsystem/report"""
    def do_GET(self):
        self._send_error(403, "forbidden: public port only accepts reports")
    def do_DELETE(self):
        self._send_error(403, "forbidden: public port only accepts reports")
    def do_POST(self):
        if urlparse(self.path).path == "/api/subsystem/report":
            return super().do_POST()
        self._send_error(403, "forbidden: only report is allowed")

# 双端口启动
def main():
    # 公网端口:只接收上报
    public_server = ThreadingHTTPServer(('0.0.0.0', 35929), PublicHandler)
    # 内网端口:完整管理功能
    private_server = ThreadingHTTPServer(('0.0.0.0', 35930), MasterHandler)

    Thread(target=public_server.serve_forever, daemon=True).start()
    Thread(target=private_server.serve_forever, daemon=True).start()

端到端安全验证

码道写了完整的测试脚本验证安全策略,全部通过:

✅ 公网端口 GET /api/subsystems → 403 Forbidden
✅ 公网端口 GET / → 403 Forbidden
✅ 公网端口 POST /api/subsystem/report → 200 OK
✅ 内网端口管理界面 → 200 OK
✅ 分系统上报 → 总系统公网端口 → 数据存储 → 内网管理界面可查看

4.12 管理后台配置:APP 登录地址可配置

我说"日志中要写 APP 使用登录地址,不要显示总系统地址,管理界面里可以设置域名和是否带端口"。码道做了四项修改:

  1. 管理页面由后端提供:访问 /admin 路由返回 HTML,不再需要打开文件
  2. 日志不显示总系统地址:去掉所有日志中的 master_url 打印
  3. 日志显示 APP 登录地址:从 config.json 读取 app_domain 和 app_port
============================================================
  FMO 分系统后端 API 服务已启动
============================================================
  APP 登录地址: bh6bhg.cloud:35928
  API 端口: 35928
  管理后台: http://内网IP:35928/admin
============================================================
  1. 管理界面可配置:添加域名输入框、端口输入框、"地址中包含端口"勾选框,保存后持久化到 config.json
// config.json
{
  "master_url": "http://bh6bhg.synology.me:35929",
  "app_domain": "bh6bhg.cloud",
  "app_port": 35928,
  "app_port_in_url": true,
  "report_interval": 30
}

上报域名修复

部署后发现总系统显示的域名是 register.example.com(config.json 默认值),而不是管理后台设置的 bh6bhg.cloud。码道定位到上报代码用的是 CONFIG.get("domain") 而不是 CONFIG.get("app_domain"),修复后优先使用管理后台设置的值。

# 修复前:用了错误的配置键
domain = CONFIG.get("domain", "register.example.com")  # ❌ 默认值

# 修复后:优先用管理后台设置的值
domain = CONFIG.get("app_domain") or CONFIG.get("domain", "register.example.com")  # ✅

4.13 部署到 Synology NAS:一路踩坑一路修

部署到 Synology NAS(DSM 系统)时遇到了一系列问题,码道都是秒回解决方案:

问题原因码道的解决
--now 不支持Synology systemctl 版本旧"分两步:先 enable 再 start"
service 路径不存在写死 /opt/fmo-mastersed 替换为实际路径 /volume1/homes/...
no such table: users数据库未初始化手动 python3 -c "import api_server; api_server.init_db()"
用户列表加载不出来服务器单线程阻塞TCPServer → ThreadingTCPServer
网页停留在"加载中"JS 调用已删除的 setStatusTag()删除残留调用
登录显示"响应缺少 token"APP 没检查 ok 字段先检查 ok,再取 error 字段
config.json 读取失败文件有 BOM 头encoding='utf-8' → encoding='utf-8-sig'
上报域名不正确用了 domain 而非 app_domain优先用管理后台设置的值

单线程阻塞问题(最典型)

用户反馈管理后台用户列表加载不出来。curl 测试 API 秒回(0.016 秒),但浏览器一直"加载中"。

码道定位到服务器用的是 socketserver.TCPServer(单线程),一个请求阻塞全部排队。改成 ThreadingTCPServer(多线程)后秒回:

# 修复前:单线程,一个请求阻塞全部
class ReusableTCPServer(socketserver.TCPServer):
    allow_reuse_address = True

# 修复后:多线程,并发处理
class ReusableTCPServer(socketserver.ThreadingTCPServer):
    allow_reuse_address = True
    daemon_threads = True

这个 Bug 的特点是 curl 能通但浏览器不行——因为浏览器加载页面时会同时发起多个请求(HTML + JS + API),单线程服务器处理第一个请求时,后续请求全部排队等待。

单线程服务器:                    多线程服务器:
浏览器请求 HTML ──▶ 处理中       浏览器请求 HTML ──▶ 线程1处理
浏览器请求 JS   ──▶ 排队等待 ⏳   浏览器请求 JS   ──▶ 线程2处理
浏览器请求 API  ──▶ 排队等待 ⏳   浏览器请求 API  ──▶ 线程3处理
                                  ↓
页面一直"加载中" ❌              页面秒回 ✅

BOM 编码问题

config.json 在 Windows 上编辑后带了 BOM(Byte Order Mark)头,Python 的 json.load() 用 encoding='utf-8' 读取时失败,静默回退到默认配置。改用 encoding='utf-8-sig' 后自动跳过 BOM 头:

# 修复前:BOM 头导致 JSON 解析失败
with open('config.json', encoding='utf-8') as f:
    config = json.load(f)  # ❌ UnicodeDecodeError 或静默失败

# 修复后:utf-8-sig 自动跳过 BOM 头
with open('config.json', encoding='utf-8-sig') as f:
    config = json.load(f)  # ✅ 正常解析

BOM 是什么? UTF-8 的 BOM 是文件开头的 3 个字节 EF BB BF,用于标识这是一个 UTF-8 文件。Windows 的 Notepad 保存 UTF-8 文件时会自动加 BOM,Linux 不会。这个差异是跨平台开发的经典坑。

登录失败问题

用户反馈"有的登录成功有的登录失败,失败的显示响应缺少 token"。码道分析:

用户输入错误密码
    ↓
服务器返回: {"ok": false, "error": "密码错误"}  (HTTP 200)
    ↓
APP 没检查 ok 字段,直接找 token
    ↓
找不到 token → 显示 "响应缺少 token"(而不是真正的错误 "密码错误")

根因有两个:

  1. APP 没检查 ok 字段是否为 true
  2. 服务器用 error 字段返回错误,APP 找的是 message 字段
// 修复前:直接找 token,不检查 ok
String token = source.optString("token", "");
if (token.isEmpty()) {
    // 显示 "响应缺少 token" ← 错误信息不准确
}

// 修复后:先检查 ok,再取 error
boolean ok = source.optBoolean("ok", true);
if (!ok) {
    String msg = source.optString("error", 
        source.optString("message", "登录失败"));
    onLoginFail(msg);  // 显示 "密码错误" ← 准确的错误信息
    return;
}

修复后先检查 ok,再取 error 显示。重新安装 APK 后,失败的用户就能看到真正的错误原因了。

4.14 在线判定与密码验证

在线判定

用户问"只要登进 APP 就算在线对不对"。码道确认这是正常行为:

  • APP 登录后 HeartbeatService 每 5 秒上报心跳
  • 服务器判定:最后心跳在 15 秒内 = 在线
  • APP 退出/被杀 → 心跳停止 → 15 秒后变离线
APP 登录成功
    ↓
HeartbeatService 启动,每 5 秒 POST /api/heartbeat
    ↓
服务器更新 last_heartbeat 时间戳
    ↓
管理后台查询: now() - last_heartbeat < 15秒 → 在线 🟢
管理后台查询: now() - last_heartbeat ≥ 15秒 → 离线 ⚪

APP 被用户退出/被系统杀死
    ↓
HeartbeatService 停止
    ↓
15 秒后 last_heartbeat 超时 → 状态变离线 ⚪

密码不匹配

用户验证某个登录失败的用户(BA4QAO),发现存储的 SHA256 哈希和输入密码的哈希不匹配:

存储的哈希: 6b1cc8a187571ca55b46237bcfa02e42585fc768373489d69a756ad1f3311ca3
计算的哈希: 2846fe7c6ea14bc5f7b20a4d1185f49e66aea007e692ae1a8b62374c3c3bca80
匹配: ❌ 否

结论:用户注册时输入的密码和现在记的不一样。码道直接给出重置命令:

python3 -c "
import sqlite3, hashlib
c = sqlite3.connect('/volume1/homes/fmo-subsystem-deploy/users.db')
new_hash = hashlib.sha256('新密码'.encode('utf-8')).hexdigest()
c.execute('UPDATE users SET password_hash=? WHERE callsign=?', (new_hash, 'BA4QAO'))
c.commit()
"

五、开发时间线

整个开发过程的时间线如下:

阶段 1: APP 骨架开发
├── 需求描述 → 码道探索项目结构 → 理解 FMO 协议
├── 创建团队 → 7 个子代理并行开发
├── 框架 + 协议 + 音频 + UI 同步推进
└── 35 分钟完成 APP 骨架

阶段 2: 协议层调试
├── MQTT v3 → v5 迁移
├── SAS 认证参数修复
├── aedes CONNACK Bug 定位(分层排查)
└── MQTT 连接成功

阶段 3: 功能完善
├── 证书生成工具(Python 复现 Rust)
├── 隐私弹窗 + 界面美化(深色科技风)
├── UI 重构(去掉 NRL,专注 FMO)
├── 声音放大 5 倍
└── 音频修复(ring buffer + jitter buffer)

阶段 4: 用户系统
├── 登录/注册页面(服务器地址可配置)
├── 心跳服务
├── 端到端验证(构建→安装→截图→API 测试)
└── 正式版打包 v3.0.0.1

阶段 5: 服务器端
├── 分系统后端(注册/登录/心跳/管理后台)
├── 总系统后端(双端口安全架构)
├── 管理后台配置(APP 登录地址可配置)
└── 安全验证测试

阶段 6: 部署
├── Synology NAS 部署
├── systemd 服务配置
├── 踩坑修复(单线程/BOM/域名/登录解析)
└── 全链路验证通过

六、使用体验总结

6.1 好的地方

能力体验评价
项目理解自动读 Rust 源码理解 FMO 协议、认证流程、音频参数不需要我解释项目背景
团队协作自动分配 7 个子代理并行开发 APP,自动处理依赖多任务并行,35 分钟完成骨架
验证意识每步都做端到端测试(构建→安装→截图→API 验证)不是"写完就走",而是"写完验证"
Bug 定位分层排查 aedes CONNACK 问题,从应用层到库 Bug层层验证,不是瞎猜
架构设计双端口安全架构(公网只接收,内网管理)理解网络拓扑,提出安全方案
部署支持Synology NAS/systemd/路径/权限/编码问题秒解踩坑即修,不卡壳
用户反馈响应我说"加载不出来"它自己找原因并修复不需要我解释具体问题
跨平台同一份 Python 代码跑 Windows 开发 + Linux 部署零修改跨平台

6.2 交互方式

整个过程中,我的输入都很简短,像跟一个有经验的工程师聊天:

我的输入码道的理解和行动
"我要做一个 FMO 对讲 APP"探索项目 → 创建团队 → 7 个子代理并行开发
"声音可以大不能小"PCM sample × 5 + clamp 防爆音
"打包正式版 3.0.0.1"改版本号 → 构建 → 签名 → 验证 → 输出 APK
"总系统在内网,分系统在不同的内网"理解网络拓扑 → 设计双端口安全架构
"日志中写 APP 登录地址,不显示总系统地址"改日志 + 加管理界面配置功能
"用户信息加载不出来"排查 → 单线程阻塞 → 改多线程 → 秒回
"有的登录成功有的登录失败"分析响应格式 → 修 APP 解析逻辑

我不需要写详细的需求文档,也不需要解释技术背景,用自然语言描述问题就够了。

6.3 不足之处

  • 平台假设:部署打包时最初只写了 Windows bat 脚本,需要用户反馈"都上服务器了怎么可能是 Windows"后才补 Linux 版本
  • 代码清理遗漏:前端代码清理时遗漏了已删除函数的调用(setStatusTag),导致页面加载失败
  • 编码细节:config.json 写入时带了 BOM 头,导致 JSON 解析失败用了默认值
  • 服务重启提醒:有时候修改代码后需要重启服务才生效,码道不会主动提醒

img


七、经验与反思

7.1 码道擅长的

  1. 从现有代码中学习:它能阅读 Rust 参考实现,理解协议细节,然后用 Java 复现。这种跨语言的知识迁移能力非常有价值。

  2. 分层排查复杂问题:aedes CONNACK Bug 的排查过程是教科书级的——从应用层到传输层到库本身,每层都写测试验证。这不是搜索能搜到的,需要真正的工程推理能力。

  3. 并行开发:7 个子代理并行开发 APP 骨架,自动处理依赖关系。这种团队协作模式大幅缩短了开发时间。

  4. 端到端验证:不是写完代码就交付,而是构建→安装→截图→API 测试全流程验证。这种"写完就验证"的习惯让交付质量有保障。

  5. 理解隐含需求:我说"总系统在内网",它就理解了需要双端口架构;我说"声音大不能小",它就知道要放大并 clamp 防爆音。不需要我把每个细节都说明。

7.2 码道需要改进的

  1. 部署环境假设:默认 Windows、默认 /opt 路径——这些假设在 Linux 服务器场景下不成立。建议增加对部署目标的初始询问。

  2. 代码清理的彻底性:删除函数后遗漏了调用点,导致运行时错误。建议在删除任何 API 后做全项目引用检查。

  3. 编码细节:BOM 头问题在跨平台场景下很常见。建议在处理 JSON/配置文件时默认使用 utf-8-sig。

  4. 主动提醒:修改代码后需要重启服务才生效,但码道不会主动提醒。建议在修改运行中的服务代码后,主动提示"需要重启服务"。

7.3 对开发者的建议

如果你要用码道开发类似的全栈项目,我的建议是:

  1. 先让它理解项目:如果有参考实现,先让它读。它的"先理解再动手"模式效果很好。
  2. 用自然语言描述需求:不需要写详细的需求文档,但要说清楚关键约束(如"不硬编码"、"内网部署")。
  3. 及时给反馈:看到问题就说,不需要定位原因。码道会自己排查。
  4. 关注部署环境:提前说明部署目标(如"部署到 Synology NAS"),避免后期返工。
  5. 要求端到端验证:让它构建→安装→测试,不要只看代码。

八、总结

用码道开发 FMO APP 和监控系统的体验是:我只需要知道我想要什么,它来负责怎么实现。

整个过程中我没有写过一行代码,甚至没有打开过代码编辑器。我的角色更像是一个"产品经理"——提需求、看效果、给反馈。码道负责理解项目、设计方案、写代码、调试 Bug、处理部署问题。

最让我印象深刻的有三点:

  1. aedes Bug 的定位过程:这个 Bug 涉及 Node.js 内部库的行为差异,不是常规搜索能找到的。码道通过分层测试(认证 → MQTT → TCP → 库本身)逐步缩小范围,最终在 aedes 示例代码里找到了 createBroker() 的用法。这种排查能力已经接近一个有经验的工程师了。

  2. 双端口安全架构的设计:当我指出"总系统在内网,分系统在不同的内网"这个网络拓扑问题时,码道立刻理解了问题本质,提出了公网端口只接收上报、内网端口提供管理界面的方案,并写了完整的安全验证测试。这种对网络架构的理解能力已经接近一个有经验的系统架构师了。

  3. 全流程端到端验证:从 APP 构建安装到 API 调用验证,从安全策略测试到 NAS 部署,码道每一步都做了验证而不是假设。这种"写完就验证"的习惯,让最终交付的质量很有保障。

如果要说不足,主要是对部署环境的假设有时偏理想化(比如默认 Windows、默认 /opt 路径),需要用户反馈后才能修正。但一旦反馈,修正速度很快。总体来说,用自然语言驱动复杂全栈项目开发,码道交出了一份超出预期的答卷。

相关文件下载
fmo-server-deploy.zip
2.36 MB
下载
Logo

华为AI开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐