zjk
2026-07-07 2d2bb71c706fa9f37f11807725df1a2da10518f1
feat: 实现访客终端离线优先架构支持

启用manifest中的SQLite原生模块权限
实现App全局初始化:初始化数据库、网络状态监听、自动同步队列与定时清理任务
新增SQLite数据库工具类处理离线业务数据持久化
实现离线同步引擎,支持网络恢复时自动上传待同步数据并清理本地文件
新增本地文件工具类管理抓拍图片的持久化与删除
添加设备初始化配置页面,支持缓存设备连接信息
预留mock API层用于后续对接真实后端接口
编写离线架构设计文档说明整体设计
5个文件已添加
3个文件已修改
514 ■■■■■ 已修改文件
docs/offline_architecture.md 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/App.vue 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mock.js 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/manifest.json 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/device-init/index.vue 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/db.js 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/fs.js 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/syncEngine.js 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/offline_architecture.md
New file
@@ -0,0 +1,93 @@
# 访客机终端 - 离线优先 (Offline-First) 架构设计说明
## 1. 业务背景与需求
访客机终端设备部署在园区或大堂,网络环境可能存在波动或完全断网。为了保证访客登记和签离流程的顺畅体验,系统必须支持**离线优先**的工作模式:
1. **无感登记**:即使断网,也能正常完成“登记”和“签离”流程。
2. **异步同步**:当网络恢复时,后台自动将离线期间积累的数据(包括身份证照片、人脸抓拍等)静默上传至云端。
3. **极限内存控制**:安卓设备存储空间极小(约 4G),系统必须控制本地文件的占用,并在同步成功后立即销毁本地缓存文件。
---
## 2. 核心技术选型
由于 UniApp 提供的 `uni.setStorageSync` 不适合存储大批量包含 base64 的复杂数据,本项目采用原生能力实现:
- **结构化数据持久化**:使用 App 原生 `plus.sqlite`。
- **多媒体文件持久化**:使用 App 原生 `plus.io`,将图片保存在沙盒 `_doc/images/` 目录下。
- **状态监听**:使用 `uni.onNetworkStatusChange` 监听网络恢复,结合定时器触发后台同步引擎。
---
## 3. 目录结构与模块说明
```text
src/
├── api/
│   └── mock.js          # API 预留层,包含获取数据、上传图片、提交表单的空接口,待对接真实后端
├── utils/
│   ├── db.js            # SQLite 数据库工具类,负责建表、存入数据、查询待同步数据、清理超期数据
│   ├── fs.js            # 文件系统工具类,负责将临时图片移动至持久化沙盒目录、以及同步成功后的文件销毁
│   └── syncEngine.js    # 离线同步引擎,串联 DB 和 FS,处理后台静默上传的核心逻辑
├── App.vue              # 全局入口,负责初始化 DB、挂载网络监听器、启动定时清理和轮询同步
└── manifest.json        # 已开启 "SQLite" 原生模块权限
```
---
## 4. 业务开发接入指南 (必读)
当你开发具体的业务页面(如 `appointment-scan` 中的人脸比对或信息提交)时,**完全不需要关心当前是否有网络**。请遵循以下“先存本地”的标准流程:
### 步骤 1: 处理图片文件
当设备获取到摄像头的临时路径(如 `tempFilePath`)时,必须调用 `saveImageToLocal` 持久化,否则临时文件可能会被系统回收。
```javascript
import { saveImageToLocal } from '@/utils/fs';
// 假设 captureRes.tempImagePath 是拍照返回的临时路径
const localPersistentPath = await saveImageToLocal(captureRes.tempImagePath);
```
### 步骤 2: 将数据写入 SQLite 离线表
构造一条包含本地图片路径的记录,并存入数据库,同步状态默认会标记为 `0`(待同步)。
```javascript
import { insertOfflineRecord } from '@/utils/db';
const record = {
  id: `record_${Date.now()}`,
  type: 'appointment', // 类型,例如 appointment / online / signout
  localImgPath: localPersistentPath, // 步骤1中生成的持久化路径
  payload: {
    name: '张三',
    idCard: '123456789012345678',
    // ... 其他业务表单字段
  }
};
await insertOfflineRecord(record);
// 走到这一步,即可直接跳转成功页面,给用户展示“登记成功”
```
---
## 5. 后台同步引擎工作原理 (底层逻辑)
`syncEngine.js` 挂载在 `App.vue` 中,在网络恢复或每 5 分钟轮询时触发,其工作流如下:
1. **查询数据**:从 SQLite `offline_records` 表中提取出 `sync_status = 0` 且最早生成的 20 条记录。
2. **上传图片**:若记录包含 `local_img_path`,调用 API 接口上传文件,获取真实的云端 URL。
3. **提交数据**:将业务 `payload`(合并云端图片 URL)通过 API 提交到后端服务器。
4. **清理释放**:
   - 数据库:将记录状态 `sync_status` 更新为 `1`(已同步)。
   - 文件系统:调用 `deleteLocalImage` **彻底删除沙盒中的本地图片**,释放设备的物理存储。
5. **异常重试**:单条数据失败不阻断队列,下一次轮询会重新尝试。
---
## 6. 极限环境容灾机制
针对设备只有 4G 内存的极端情况,本架构设置了两层防线:
1. **即时销毁**:见同步引擎第 4 步,只要同步上云,图片立刻销毁,绝不驻留。
2. **超期强制抹除**:`App.vue` 启动时会调用 `runDailyCleanup()`,该方法会强制删除 SQLite 中超过 **48小时** 的历史数据。这保证了即使设备一直处于断网的“孤岛”状态,最多也只占用最近两天的数据空间,防止设备写满死机。
src/App.vue
@@ -1,7 +1,35 @@
<script>
import { initDB } from './utils/db';
import { startSyncQueue, runDailyCleanup } from './utils/syncEngine';
export default {
  onLaunch() {
    console.log('App Launch')
    // 1. 初始化本地 SQLite 数据库
    initDB().then(() => {
      console.log('数据库初始化完毕');
      // 初始化完尝试跑一次同步
      startSyncQueue();
    }).catch(err => {
      console.error('数据库初始化失败:', err);
    });
    // 2. 全局监听网络状态变化
    uni.onNetworkStatusChange((res) => {
      if (res.isConnected && res.networkType !== 'none') {
        console.log('[App] 监听到网络恢复,触发同步队列');
        startSyncQueue();
      }
    });
    // 3. 执行日常清理机制 (删除48小时前的脏数据,防止4G存储满)
    runDailyCleanup();
    // 开启定时同步轮询 (作为网络监听的补充,每 5 分钟尝试一次)
    setInterval(() => {
      startSyncQueue();
    }, 5 * 60 * 1000);
  },
  onShow() {
    console.log('App Show')
src/api/mock.js
New file
@@ -0,0 +1,51 @@
/**
 * 离线优先架构的 API 预留接口 (Mock)
 * 这里预留了所有与后端交互的接口,方便后续直接替换为真实的 request
 */
// 模拟预拉取当天的预约名单和已入园名单,供离线校验使用
export const fetchPreloadData = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        code: 200,
        data: {
          appointments: [
            // { idCard: '123456789012345678', name: '张三' }
          ],
          onsite: [
            // { idCard: '098765432109876543', name: '李四' }
          ]
        }
      });
    }, 500);
  });
};
// 模拟上传图片到云端,返回云端的图片 URL
export const uploadImage = (localFilePath) => {
  return new Promise((resolve, reject) => {
    console.log('[API Mock] 开始上传图片:', localFilePath);
    setTimeout(() => {
      // 模拟上传成功,返回网络 URL
      resolve({
        code: 200,
        url: `https://fake-cloud.com/images/${Date.now()}.jpg`
      });
    }, 800);
  });
};
// 模拟提交一条完整的登记/签离记录到云端
export const submitRecord = (recordData) => {
  return new Promise((resolve, reject) => {
    console.log('[API Mock] 开始提交业务数据:', recordData);
    setTimeout(() => {
      // 模拟提交成功
      resolve({
        code: 200,
        message: 'success'
      });
    }, 800);
  });
};
src/manifest.json
@@ -15,7 +15,9 @@
      "autoclose": true,
      "delay": 0
    },
    "modules": {},
    "modules": {
      "SQLite": {}
    },
    "distribute": {
      "android": {
        "permissions": [
src/pages/device-init/index.vue
@@ -1,5 +1,6 @@
<script setup>
import { reactive } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
const formRows = reactive([
  { label: '设备ID:', placeholder: '自动回填', value: '', readonly: true },
@@ -7,7 +8,49 @@
  { label: '端口号:', placeholder: '请输入', value: '', readonly: false },
])
// 页面加载时检查是否已经激活过,如果有缓存的 IP 和端口,直接跳转到首页
onLoad(() => {
  const ip = uni.getStorageSync('device_ip')
  const port = uni.getStorageSync('device_port')
  console.log(ip, port)
  if (ip && port) {
    uni.redirectTo({
      url: '/pages/home/index',
    })
  }
})
// 重置表单,清空 IP 和端口的输入值
const handleReset = () => {
  formRows[1].value = ''
  formRows[2].value = ''
}
// 激活并跳转到首页
const goHome = () => {
  const ip = formRows[1].value
  const port = formRows[2].value
  if (!ip) {
    uni.showToast({
      title: '请输入IP地址',
      icon: 'none'
    })
    return
  }
  if (!port) {
    uni.showToast({
      title: '请输入端口号',
      icon: 'none'
    })
    return
  }
  // 缓存设备信息
  uni.setStorageSync('device_ip', ip)
  uni.setStorageSync('device_port', port)
  uni.redirectTo({
    url: '/pages/home/index',
  })
@@ -41,7 +84,7 @@
      </view>
      <view class="button-row">
        <view class="btn btn-reset">
        <view class="btn btn-reset" @click="handleReset">
          <text class="btn-reset-text">重置</text>
        </view>
        <view class="btn btn-primary" @click="goHome">
src/utils/db.js
New file
@@ -0,0 +1,132 @@
/**
 * 离线优先架构的 SQLite 数据库工具类
 * 处理断网时业务数据的持久化存储,供网络恢复时使用
 */
const DB_NAME = 'visitor_offline_db';
const DB_PATH = '_doc/visitor.db';
// 内部执行 SQL 的封装
const executeSql = (sql) => {
  return new Promise((resolve, reject) => {
    // #ifdef APP-PLUS
    plus.sqlite.executeSql({
      name: DB_NAME,
      sql: sql,
      success: (e) => resolve(e),
      fail: (e) => reject(e)
    });
    // #endif
    // #ifndef APP-PLUS
    resolve();
    // #endif
  });
};
const selectSql = (sql) => {
  return new Promise((resolve, reject) => {
    // #ifdef APP-PLUS
    plus.sqlite.selectSql({
      name: DB_NAME,
      sql: sql,
      success: (data) => resolve(data),
      fail: (e) => reject(e)
    });
    // #endif
    // #ifndef APP-PLUS
    resolve([]);
    // #endif
  });
};
// 初始化数据库与表
export const initDB = () => {
  return new Promise((resolve, reject) => {
    // #ifdef APP-PLUS
    const isOpen = plus.sqlite.isOpenDatabase({
      name: DB_NAME,
      path: DB_PATH
    });
    const createTables = async () => {
      try {
        // 创建离线记录表:
        // id: 唯一标识
        // type: 记录类型(appointment/online/signout)
        // payload: 业务数据(JSON 字符串)
        // local_img_path: 抓拍图片的本地沙盒路径
        // sync_status: 同步状态(0-待同步, 1-已同步)
        // create_time: 创建时间戳
        await executeSql([
          `CREATE TABLE IF NOT EXISTS offline_records (
            id TEXT PRIMARY KEY,
            type TEXT NOT NULL,
            payload TEXT NOT NULL,
            local_img_path TEXT,
            sync_status INTEGER DEFAULT 0,
            create_time INTEGER NOT NULL
          )`
        ]);
        console.log('[DB] 初始化表结构成功');
        resolve();
      } catch (err) {
        console.error('[DB] 初始化表结构失败:', err);
        reject(err);
      }
    };
    if (isOpen) {
      createTables();
    } else {
      plus.sqlite.openDatabase({
        name: DB_NAME,
        path: DB_PATH,
        success: () => createTables(),
        fail: (e) => {
          console.error('[DB] 打开数据库失败:', e);
          reject(e);
        }
      });
    }
    // #endif
    // #ifndef APP-PLUS
    resolve();
    // #endif
  });
};
// 插入一条待同步的离线记录
export const insertOfflineRecord = async (record) => {
  const { id, type, payload, localImgPath } = record;
  const createTime = Date.now();
  const payloadStr = JSON.stringify(payload).replace(/'/g, "''"); // 处理单引号
  const sql = `INSERT INTO offline_records (id, type, payload, local_img_path, sync_status, create_time)
               VALUES ('${id}', '${type}', '${payloadStr}', '${localImgPath || ''}', 0, ${createTime})`;
  await executeSql([sql]);
};
// 获取所有待同步的记录(每次限制 20 条防溢出)
export const getPendingSyncRecords = async () => {
  const sql = `SELECT * FROM offline_records WHERE sync_status = 0 ORDER BY create_time ASC LIMIT 20`;
  const rows = await selectSql(sql);
  return rows.map(row => ({
    ...row,
    payload: JSON.parse(row.payload)
  }));
};
// 标记记录为已同步
export const markRecordSynced = async (id) => {
  const sql = `UPDATE offline_records SET sync_status = 1 WHERE id = '${id}'`;
  await executeSql([sql]);
};
// 删除超过 48 小时的记录,防止 4G 内存写满
export const cleanOldRecords = async () => {
  const twoDaysAgo = Date.now() - 48 * 60 * 60 * 1000;
  // 这里可以先查出记录,用来顺带清理没被清理掉的文件,不过为了简化,先直接删除数据行
  const sql = `DELETE FROM offline_records WHERE create_time < ${twoDaysAgo}`;
  await executeSql([sql]);
};
src/utils/fs.js
New file
@@ -0,0 +1,70 @@
/**
 * 离线优先架构的本地文件系统工具类
 * 用于将临时抓拍图片持久化到设备存储中,以及同步后的清理
 */
// 将临时文件保存到本地 _doc/images/ 目录下
export const saveImageToLocal = (tempFilePath) => {
  return new Promise((resolve, reject) => {
    // 只有 App-Plus 环境支持 plus.io
    // #ifdef APP-PLUS
    const fileName = `face_${Date.now()}_${Math.floor(Math.random() * 1000)}.jpg`;
    plus.io.resolveLocalFileSystemURL(tempFilePath, (entry) => {
      plus.io.resolveLocalFileSystemURL('_doc/', (docEntry) => {
        // 创建或打开 images 目录
        docEntry.getDirectory('images', { create: true }, (dirEntry) => {
          entry.moveTo(dirEntry, fileName, (newFile) => {
            resolve(newFile.fullPath);
          }, (err) => {
            console.error('[FS] 移动文件失败:', err);
            reject(err);
          });
        }, (err) => {
          console.error('[FS] 创建目录失败:', err);
          reject(err);
        });
      }, (err) => {
        console.error('[FS] 访问_doc目录失败:', err);
        reject(err);
      });
    }, (err) => {
      console.error('[FS] 访问临时文件失败:', err);
      reject(err);
    });
    // #endif
    // #ifndef APP-PLUS
    // 浏览器等其他环境直接返回临时路径用于模拟
    resolve(tempFilePath);
    // #endif
  });
};
// 删除指定的本地文件
export const deleteLocalImage = (localPath) => {
  return new Promise((resolve, reject) => {
    // #ifdef APP-PLUS
    if (!localPath || !localPath.includes('_doc/')) {
      resolve();
      return;
    }
    plus.io.resolveLocalFileSystemURL(localPath, (entry) => {
      entry.remove(() => {
        console.log('[FS] 成功删除本地文件:', localPath);
        resolve();
      }, (err) => {
        console.error('[FS] 删除文件失败:', err);
        reject(err);
      });
    }, (err) => {
      console.error('[FS] 未找到需删除的文件:', err);
      resolve(); // 找不到就算了,不阻塞流程
    });
    // #endif
    // #ifndef APP-PLUS
    resolve();
    // #endif
  });
};
src/utils/syncEngine.js
New file
@@ -0,0 +1,91 @@
import { getPendingSyncRecords, markRecordSynced, cleanOldRecords } from './db';
import { deleteLocalImage } from './fs';
import { uploadImage, submitRecord } from '../api/mock';
let isSyncing = false;
/**
 * 启动离线数据同步队列
 * 核心逻辑:
 * 1. 查询所有未同步的数据
 * 2. 如果有图片路径,先上传图片获取云端 URL
 * 3. 提交完整业务数据到后端
 * 4. 成功后:将数据库记录置为已同步,并删除本地沙盒图片释放内存
 */
export const startSyncQueue = async () => {
  if (isSyncing) return;
  // 简单判断当前网络是否可用
  const networkType = await new Promise(resolve => {
    uni.getNetworkType({
      success: (res) => resolve(res.networkType)
    });
  });
  if (networkType === 'none') {
    console.log('[Sync] 当前无网络,取消同步');
    return;
  }
  isSyncing = true;
  console.log('[Sync] 开始执行同步队列...');
  try {
    const pendingRecords = await getPendingSyncRecords();
    if (pendingRecords.length === 0) {
      console.log('[Sync] 无待同步记录');
      isSyncing = false;
      return;
    }
    for (let record of pendingRecords) {
      try {
        let payload = record.payload;
        // 1. 如果存在本地图片,先上传图片
        if (record.local_img_path) {
          const uploadRes = await uploadImage(record.local_img_path);
          if (uploadRes && uploadRes.code === 200) {
            // 将云端 URL 赋值给业务 payload
            payload.cloudImageUrl = uploadRes.url;
          } else {
            throw new Error('图片上传失败');
          }
        }
        // 2. 提交业务数据
        const submitRes = await submitRecord(payload);
        if (submitRes && submitRes.code === 200) {
          // 3. 提交成功后:标记数据库状态
          await markRecordSynced(record.id);
          // 4. 最重要:删除本地沙盒图片,释放 4G 设备的有限空间
          if (record.local_img_path) {
            await deleteLocalImage(record.local_img_path);
          }
          console.log(`[Sync] 记录 ${record.id} 同步成功并已清理本地文件`);
        } else {
          throw new Error('业务数据提交失败');
        }
      } catch (err) {
        console.error(`[Sync] 记录 ${record.id} 同步遇到错误,跳过等下次:`, err);
        // 单条失败不阻断循环,继续下一条
      }
    }
  } catch (err) {
    console.error('[Sync] 同步队列执行异常:', err);
  } finally {
    isSyncing = false;
    // 如果一次同步了 20 条,可能还有,可以再次触发
  }
};
/**
 * 每日凌晨或空闲时清理超期废弃数据
 */
export const runDailyCleanup = () => {
  console.log('[Sync] 执行超期数据清理...');
  cleanOldRecords().catch(err => {
    console.error('[Sync] 清理失败:', err);
  });
};