21 KiB
TriloopTem App 商业化审计报告
审计日期:2026-06-20 审计范围:代码质量、UX 健壮性、安全性、性能、数据完整性 总计问题:36 项(致命 4 / 高 13 / 中 14 / 低 5)
一、代码质量与架构
1.1 [致命] 生产代码中有大量调试日志
文件及行号:
src/services/TcpService.ts— 第 38, 45, 51, 65, 68, 70, 75, 83, 125, 129 行(10 处)src/services/DeviceService.ts— 第 30, 111-114, 149, 153, 159 行(5 处)src/protocol/parser.ts— 第 58-61, 64, 71, 77 行(4 处)
问题描述:
总计约 20 处 console.log / console.warn 调用。其中 TcpService 第 51 和 68 行在每个 TCP data 事件上做 Array.from(...).map(b => '0x' + b.toString(16)) 十六进制格式化,DeviceService 第 111-114 行在每帧数据到达时循环打印各通道的原始 ADC 值。在 250kHz 六通道连续采集时,这些日志每秒触发数百次,产生大量 GC 压力。
修复方案:
删除所有 console.log / console.warn,或用 if (__DEV__) 包裹。建议封装一个 logger 模块,在 release 构建中为 no-op。
1.2 [高] IP 地址和端口号缺少格式校验
文件: src/components/modals/ConnectModal.tsx 第 42-43 行
问题描述:
连接弹窗仅检查 !host.trim() 和 isNaN(port),接受诸如 999.999.999.999 等无效 IP,端口号也没有范围限制(1-65535)。
修复方案:
在连接前用正则或 IP 解析函数校验地址格式,端口校验 port >= 1 && port <= 65535。
1.3 [高] 心跳写入失败被静默吞掉
文件: src/services/TcpService.ts 第 97-99 行
问题描述:
心跳定时器每 8 秒写 '0' 到 socket,外层 try/catch 捕获所有异常但不做任何处理。如果 socket 处于半断开状态,心跳持续失败但 this.connected 仍为 true,用户无法感知连接已断。
修复方案:
在 catch 中将 this.connected 设为 false,调用 this.callbacks?.onError(),触发重连流程。
1.4 [高] 数据库查询使用 any 类型
文件: src/services/StorageService.ts 第 247 行
问题描述:
getAllAsync<any> 绕过了 TypeScript 类型检查。如果后续数据库 schema 变更(列名改动),运行时会产生 undefined 值但编译阶段无法发现。
修复方案:
为每个查询定义明确的行类型接口,替换 any。
1.5 [中] TcpService 单例无防重复连接保护
文件: src/services/TcpService.ts 第 23-30 行
问题描述:
在已连接状态下调用 connect() 不会先断开旧连接。createSocket() 虽然销毁旧 socket,但旧的 reconnect 定时器可能仍在运行,回调引用也未清理。
修复方案:
在 connect() 开头调用 this.disconnect() 确保干净状态。
1.6 [中] 分帧传输状态使用模块级变量,断线后不重置
文件: src/services/DeviceService.ts 第 24-26 行
问题描述:
splitChunks、splitActive、splitFrameNo 是模块级 let 变量。如果分帧传输中途断线重连,这些状态不会重置,下次分帧传输会拼接到旧的脏数据上。
修复方案:
在 onClose 回调中重置分帧状态,或在 deviceStartSplitFrame 开始时清理。
1.7 [中] clearTimeout 对可能为 null 的值使用 ! 断言
文件: src/services/TcpService.ts 第 43, 113, 137 行
问题描述:
clearTimeout(this.reconnectTimer!) 虽然在大多数 JS 引擎中对 null 不会崩溃,但 ! 非空断言不正确,严格模式下可能隐藏问题。
修复方案:
改为 if (this.reconnectTimer) clearTimeout(this.reconnectTimer)。
1.8 [低] 导出函数缺少返回类型注解
文件: src/services/DeviceService.ts 第 28, 172-186 行
问题描述:
handleIncomingFrame、deviceStartSplitFrame 等公共 API 函数没有显式返回类型。
修复方案:
为所有 export 函数添加返回类型注解。
1.9 [低] filePrefix 默认值在模块加载时计算,跨天不更新
文件: src/stores/deviceStore.ts 第 29 行
问题描述:
new Date().toISOString().slice(0,10).replace(/-/g,'') 仅在模块首次加载时执行一次。如果 App 过了午夜仍在运行,文件前缀还是昨天的日期。
修复方案: 在创建新采集会话时动态生成日期前缀,或在每天零点时刷新。
二、UX 与健壮性
2.1 [致命] ACK 竞态条件——并发请求覆盖
文件: src/services/DeviceService.ts 第 147-170 行
问题描述:
pendingAcks 以 func code 为 key 存储 Promise resolver。如果用户快速连续点击(如双击"下发配置"按钮),第二次 pendingAcks.set() 会静默覆盖第一次的 resolver,第一个 Promise 永远不会 resolve,直到 5 秒超时才报错。
修复方案:
在覆盖前先 reject 已有的 pending promise,或使用队列/序列号机制。UI 层也应在 busy 状态下禁用按钮(已部分实现但需确认所有路径)。
2.2 [高] TCP 初始连接无超时
文件: src/services/TcpService.ts 第 32-91 行
问题描述:
TcpSocket.createConnection 没有连接超时设置。如果设备不可达,可能挂起 75+ 秒等待操作系统级 TCP 超时。期间 UI 显示"连接中"但无法取消。
修复方案:
添加 10 秒连接超时:如果在超时内未收到 onConnect 回调,销毁 socket 并调用 onError。可以在 createSocket() 中启动一个定时器,onConnect 时清除。
2.3 [高] clearHistory 只清内存不清数据库
文件: src/stores/dataStore.ts 第 62 行
问题描述:
clearHistory 仅清空内存中的 history 数组和 currentFrame,但 SQLite 中的帧数据和磁盘上的 bin 文件不受影响。用户以为"清除"了数据,实际上数据仍然存在。
修复方案: 要么连同数据库和文件一起删除,要么将按钮文案改为"清除显示"并向用户说明数据仍已保存。
2.4 [高] 分享功能不可用时无用户反馈
文件: src/utils/export.ts 第 94-99 行
问题描述:
shareFile 调用 Sharing.isAvailableAsync() 检测分享是否可用,如果不可用则静默返回。用户点击"导出"后看到 CSV 文件生成但什么也没发生。
修复方案: 在分享不可用时弹出 Alert 提示,或提供复制文件路径的选项。
2.5 [中] 操作前不检查连接状态
文件: src/hooks/useDevice.ts 第 35-78 行
问题描述:
setup、startContinuous、startSingle 在发送命令前不检查是否已连接。当未连接时,sendAndWaitAck 会立即 reject "TCP not connected",但用户看到的提示是"配置超时"而非"未连接"。
修复方案:
在每个操作开始时检查 tcpService.isConnected(),未连接时显示明确的"请先连接设备"提示。
2.6 [中] wave.tsx 项目名加载无错误处理
文件: app/(tabs)/wave.tsx 第 396-400 行
问题描述:
动态 import + 异步链没有 .catch() 处理。如果 listProjects() 失败,Promise rejection 未被捕获。
修复方案:
添加 .catch(() => setProjectName(null))。
2.7 [中] CSV 导出只导出内存中的帧
文件: app/(tabs)/records.tsx 第 113 行
问题描述:
exportCsv(history, sessionId) 只导出当前内存中的帧数据(最多 MAX_HISTORY = 500 帧)。如果用户采集了 1000 帧,CSV 中只有最近 500 帧,且用户不知情。
修复方案: 从数据库导出全部帧,或在导出前提示用户当前仅包含内存中的 N 帧。
2.8 [低] 大项目导出无进度指示
文件: src/services/StorageService.ts 第 336-393 行
问题描述:
exportProject 对大项目可能耗时较长(读取所有 bin 文件),虽然提供了 onProgress 回调,但调用方需确保显示进度 UI。
修复方案: 在项目导出时显示进度条或加载动画。
三、安全性
3.1 [高] 原始数据被打印到日志
文件:
src/services/TcpService.ts第 51, 68, 125 行src/services/DeviceService.ts第 111-114 行
问题描述:
原始 TCP 载荷的十六进制内容和各通道 ADC 数值被打印到 console。在 Android 上,console.log 输出可通过 adb logcat 被任何持有 READ_LOGS 权限的应用读取。对于地球物理勘探数据,这可能涉及商业敏感信息。
修复方案:
同 1.1,移除或用 __DEV__ 门控。
3.2 [中] 项目名/文件名未做文件系统安全过滤
文件: src/services/StorageService.ts 第 104-112, 390 行
问题描述:
createProject 使用 name.trim() 但不过滤文件系统特殊字符。exportProject 第 390 行直接将项目名拼入文件路径 TEM_${proj.name}_${Date.now()}.tem。包含 /、\、.. 或空字节的项目名可能导致路径穿越或文件创建失败。
修复方案: 对项目名做白名单过滤(只允许字母、数字、中文、下划线、短横线),或在文件名中使用项目 ID 代替名称。
3.3 [中] GPS 坐标明文存储
文件: src/services/StorageService.ts 第 58-69 行
问题描述: GPS 经纬度、海拔以明文存储在 SQLite 中,zustand 持久化文件也是明文 JSON。
修复方案:
评估是否需要静态加密。如果需要,可使用 expo-crypto 或 SQLCipher。对于大多数行业仪器 App 这不是硬性要求,但需在隐私政策中声明。
3.4 [低] filePrefix 输入未做字符过滤
文件: src/components/ParamForm.tsx 第 183 行
问题描述:
filePrefix 只限制了长度(15 字符)但未过滤特殊字符。该字段仅用于协议包构建(不进 SQL),风险较低。
修复方案: 添加正则校验,只允许字母、数字、下划线。
四、性能
4.1 [致命] 热路径上的重量级调试计算
文件:
src/services/TcpService.ts第 51, 68 行src/services/DeviceService.ts第 111-114 行
问题描述:
每个 TCP data 事件都做 Array.from(arr.slice(0, 20)).map(...) 创建中间数组和字符串。每帧测量数据到达时循环各通道做 Array.from() + .toFixed()。连续采集时每秒触发数百次。
修复方案: 同 1.1,直接删除。
4.2 [高] Parser 缓冲区溢出时静默丢弃数据
文件: src/protocol/parser.ts 第 27-29 行
问题描述:
当 this.len + incoming.length > capacity(2MB)时,直接 this.len = 0 重置缓冲区,丢弃所有已缓存数据。无错误回调,无日志,用户无感知。在高吞吐量连续采集时可能导致数据丢失。
修复方案: 至少触发一个错误回调通知上层。考虑动态扩容或增大默认容量。记录溢出事件以便排查。
4.3 [高] 内存中历史帧可能导致 OOM
文件: src/stores/dataStore.ts 第 47 行
问题描述:
每个 MeasurementFrame 包含 adcRaw: Int32Array[] 和 adcUV: Float64Array[]。以 6 通道 2000 采样深度计算,每帧约 6 × 2000 × (4 + 8) = 144 KB。MAX_HISTORY = 500 时内存占用可达 72 MB。中低端手机可能 OOM 崩溃。
修复方案:
- 方案 A:将
MAX_HISTORY降至 50 - 方案 B:内存中只存元数据和峰值摘要,完整波形按需从 bin 文件加载
- 方案 C:历史帧只保留
adcUV,不保留adcRaw(节省 1/3 内存)
4.4 [中] 缩放/拖拽时每帧重建 Skia Path
文件: src/components/WaveformChart.tsx 第 204-222 行
问题描述:
channelPaths 的 useMemo 依赖 toY、xMinMs、xMaxMs,这些在每次手势更新时都会变化。每个手势帧都触发全量重新计算所有通道的 Skia Path(最多 512 点/通道 × 6 通道)。
修复方案: 在固定坐标系中构建 Path,用 Skia 矩阵变换实现平移缩放,避免逐帧重建 Path。
4.5 [中] 降采样使用简单抽取,可能遗漏尖峰
文件: src/hooks/useWaveform.ts 第 15-22 行
问题描述: 当前降采样每隔 N 个取一个点。对于测量仪器来说,这可能完全遗漏瞬态尖峰信号,导致显示的峰值与实际不符。
修复方案: 使用 min-max 降采样(每组取最大值和最小值各一个点)或 LTTB 算法,确保极值被保留。
4.6 [中] PeakRow 每次渲染都重新计算峰值
文件: app/(tabs)/wave.tsx 第 294-297 行
问题描述:
ch.reduce((m, v) => Math.max(m, Math.abs(v)), 0) 在每次组件渲染时对每个可见通道运行一次。对于大数组(2000+ 点)这是不必要的开销。
修复方案:
在帧接收时计算峰值并缓存到 MeasurementFrame,或用 useMemo 包裹。
4.7 [低] Base64 编码产生大量中间字符串
文件: src/services/BinLoader.ts 第 68-75 行
问题描述:
u8ToBase64 将二进制数据拆成 8192 字节块,逐块 String.fromCharCode 再 btoa。6 通道 2000 点的帧约 48KB 二进制 → 64KB base64 字符串。批量导出时内存抖动明显。
修复方案:
考虑使用 expo-file-system 的直接二进制写入能力,或流式处理。
五、数据完整性
5.1 [致命] importProject 中 gain 列写入了错误值
文件: src/services/StorageService.ts 第 480-484 行
问题描述:
SQL INSERT 的 gain 参数位置(第 10 个参数)使用了 meta.ampRatio(增益 code,如 3 代表 1×),而不是实际增益倍数(AMP_GAIN[meta.ampRatio],即 1)。导致导入的数据在重新加载时,电压换算使用错误的增益值,波形幅度错误。
修复方案:
第 483 行改为 AMP_GAIN[meta.ampRatio] ?? 1。
5.2 [高] 数据库迁移无版本管理
文件: src/services/StorageService.ts 第 73-78 行
问题描述:
当前通过 try { ALTER TABLE } catch {} 添加 project_id 列。这种方式只能处理单次迁移,随着 App 迭代新增更多 schema 变更时无法可靠管理。没有版本号追踪,无法知道数据库处于什么状态。
修复方案:
实现基于 PRAGMA user_version 的迁移系统:
const version = await db.getFirstAsync<{user_version:number}>('PRAGMA user_version');
if (version.user_version < 1) {
await db.runAsync('ALTER TABLE sessions ADD COLUMN project_id TEXT');
await db.runAsync('PRAGMA user_version = 1');
}
if (version.user_version < 2) {
// 下一次迁移...
await db.runAsync('PRAGMA user_version = 2');
}
5.3 [高] 删除项目不级联删除关联数据
文件: src/services/StorageService.ts 第 139-142 行
问题描述:
deleteProject 只删除 projects 表中的记录。关联的 sessions(project_id 被 SET NULL)、frames 记录和磁盘上的 bin 文件都成为孤儿数据,永远不会被清理。
修复方案: 删除项目时,先查出关联的 sessions,再删除对应的 frames 和 bin 文件,最后删除 sessions 和 project 记录。
5.4 [高] persistFrame 使用 INSERT OR REPLACE 可能静默覆盖数据
文件: src/services/StorageService.ts 第 224 行
问题描述:
如果由于 bug 或竞态条件,两帧具有相同的 (frameId, sessionId) 主键,第二帧会静默覆盖第一帧的数据,无任何警告。
修复方案:
改用 INSERT OR IGNORE 并记录警告日志,或在插入前检查唯一性。
5.5 [中] Session/Project ID 可能冲突
文件:
src/stores/dataStore.ts第 113-119 行src/services/StorageService.ts第 95-101 行
问题描述:
generateSessionId 和 generateProjectId 使用秒级时间戳生成 ID。如果在同一秒内创建两个会话或项目,ID 会重复。ensureSession 使用 INSERT OR IGNORE,第二个会话会静默复用第一个。
修复方案: 添加随机后缀(如 4 位随机字母数字),或使用 UUID,或至少使用毫秒精度。
5.6 [中] CSV 导出未转义特殊字符
文件:
src/utils/export.ts第 28-47 行src/services/StorageService.ts第 318-327 行
问题描述: CSV 值直接用逗号拼接,未做引号包裹或转义。虽然数值字段一般不含逗号,但如果 UTC 字符串在某些 locale 下包含逗号,CSV 格式会错乱。
修复方案: 对所有字段做标准 CSV 转义:包含逗号、引号或换行的值用双引号包裹,值内的双引号用两个双引号转义。
5.7 [中] 帧计数器内存与数据库可能不同步
文件: src/stores/dataStore.ts 第 38-41 行
问题描述:
nextFrameId 在 zustand 内存中递增,persistFrame 通过 void 异步调用(fire-and-forget)。如果 persistFrame 失败(如磁盘满),内存中计数器继续递增但数据库缺少对应帧,间隙永远不会被检测到。
修复方案:
await persistFrame 或至少处理其 rejection(记录错误,通知用户,重试)。
5.8 [中] 导入项目时临时文件清理为 fire-and-forget
文件: src/services/StorageService.ts 第 430 行
问题描述:
FileSystem.deleteAsync(tmpPath, ...).catch(() => {}) 静默忽略清理失败。多次失败的导入会在设备上累积临时文件。
修复方案: 跟踪临时文件并定期清理,或 await 删除操作并在失败时记录。
5.9 [低] parseAck 读取 reason 字符串时未检查 payload 长度
文件: src/protocol/parser.ts 第 189-195 行
问题描述:
reason 字符串从 payload offset 22 开始读取,但没有检查 payload.length >= 22。如果设备返回一个短 ACK 包,循环会读取到 undefined,String.fromCharCode(undefined) 得到乱码。
修复方案:
在读取前检查 if (payload.length < reasonOffset) return { result, reason: '' }。
修复状态追踪
✅ 已修复
| # | 问题 | 修复日期 |
|---|---|---|
| 1.1 | 调试日志加 __DEV__ 门控 |
2026-06-20 |
| 1.2 | IP/端口格式校验 | 2026-06-20 |
| 1.3 | 心跳失败触发重连 | 2026-06-20 |
| 1.5 | connect 防重复连接 | 2026-06-20 |
| 1.6 | 分帧状态断线重置 | 2026-06-20 |
| 1.9 | filePrefix 动态日期 | 2026-06-20 |
| 2.1 | ACK 竞态覆盖保护 | 2026-06-20 |
| 2.2 | TCP 连接 10s 超时 | 2026-06-20 |
| 2.3 | clearHistory 文案明确 | 2026-06-20 |
| 2.4 | 分享不可用提示 | 2026-06-20 |
| 2.5 | 操作前检查连接 | 2026-06-20 |
| 2.6 | 项目名加载加 catch | 2026-06-20 |
| 3.1 | 日志泄露(同 1.1) | 2026-06-20 |
| 3.2 | 项目名文件安全过滤 | 2026-06-20 |
| 4.1 | 热路径调试计算(同 1.1) | 2026-06-20 |
| 4.2 | Parser 溢出加 onOverflow 回调 | 2026-06-20 |
| 4.3 | MAX_HISTORY 降至 50 | 2026-06-20 |
| 4.5 | 降采样改 min-max 保留极值 | 2026-06-20 |
| 4.6 | PeakRow 已删除 | 2026-06-20 |
| 5.1 | importProject gain 用 AMP_GAIN | 2026-06-20 |
| 5.2 | 数据库迁移 PRAGMA user_version | 2026-06-20 |
| 5.3 | 删项目级联清理文件 | 2026-06-20 |
| 5.4 | INSERT OR REPLACE → IGNORE | 2026-06-20 |
| 5.5 | ID 加毫秒+随机后缀 | 2026-06-20 |
| 5.9 | parseAck 长度检查 | 2026-06-20 |
⏳ 暂不修(低风险或需大重构)
| # | 问题 | 原因 |
|---|---|---|
| 1.4 | 数据库查询 any 类型 |
纯代码规范 |
| 1.7 | clearTimeout ! 断言 |
运行无影响 |
| 1.8 | 缺返回类型注解 | 纯代码规范 |
| 2.7 | CSV 只导内存帧 | 需重构导出 |
| 2.8 | 大项目导出无进度 | UX 优化级 |
| 3.3 | GPS 明文存储 | 行业仪器通常不要求 |
| 3.4 | filePrefix 字符过滤 | 仅用于协议包 |
| 4.4 | 缩放重建 Path | 性能优化级 |
| 4.7 | Base64 中间字符串 | 优化级 |
| 5.6 | CSV 未转义 | 纯数值低风险 |
| 5.7 | 帧计数不同步 | 需重构存储层 |
| 5.8 | 临时文件清理 | 低风险 |
原修复优先级建议(已完成)
### 第一批(发布前必须修复)
移除所有调试日志(1.1 + 3.1 + 4.1)修复 importProject gain 字段(5.1)降低内存历史上限或改为按需加载(4.3)修复 ACK 竞态条件(2.1)
### 第二批(发布前应该修复)
5. 添加 TCP 连接超时(2.2)
6. 心跳失败处理(1.3)
7. 操作前检查连接状态(2.5)
8. 数据库迁移版本管理(5.2)
9. 删除项目级联清理(5.3)
10. IP/端口校验(1.2)
11. 项目名文件安全过滤(3.2)
12. 分享不可用提示(2.4)
13. Parser 缓冲区溢出通知(4.2)
第三批(后续迭代优化)
- Skia 矩阵变换优化波形缩放(4.4)
- Min-max 降采样算法(4.5)
- Session ID 防冲突(5.5)
- CSV 转义(5.6)
- 其余低优先级项