feat: 实现访客终端离线优先架构支持
启用manifest中的SQLite原生模块权限
实现App全局初始化:初始化数据库、网络状态监听、自动同步队列与定时清理任务
新增SQLite数据库工具类处理离线业务数据持久化
实现离线同步引擎,支持网络恢复时自动上传待同步数据并清理本地文件
新增本地文件工具类管理抓拍图片的持久化与删除
添加设备初始化配置页面,支持缓存设备连接信息
预留mock API层用于后续对接真实后端接口
编写离线架构设计文档说明整体设计
| New file |
| | |
| | | # 访客机终端 - 离线优先 (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小时** 的历史数据。这保证了即使设备一直处于断网的“孤岛”状态,最多也只占用最近两天的数据空间,防止设备写满死机。 |
| | |
| | | <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') |
| New file |
| | |
| | | /** |
| | | * 离线优先架构的 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); |
| | | }); |
| | | }; |
| | |
| | | "autoclose": true, |
| | | "delay": 0 |
| | | }, |
| | | "modules": {}, |
| | | "modules": { |
| | | "SQLite": {} |
| | | }, |
| | | "distribute": { |
| | | "android": { |
| | | "permissions": [ |
| | |
| | | <script setup> |
| | | import { reactive } from 'vue' |
| | | import { onLoad } from '@dcloudio/uni-app' |
| | | |
| | | const formRows = reactive([ |
| | | { label: '设备ID:', placeholder: '自动回填', value: '', readonly: true }, |
| | |
| | | { 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', |
| | | }) |
| | |
| | | </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"> |
| New file |
| | |
| | | /** |
| | | * 离线优先架构的 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]); |
| | | }; |
| New file |
| | |
| | | /** |
| | | * 离线优先架构的本地文件系统工具类 |
| | | * 用于将临时抓拍图片持久化到设备存储中,以及同步后的清理 |
| | | */ |
| | | |
| | | // 将临时文件保存到本地 _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 |
| | | }); |
| | | }; |
| New file |
| | |
| | | 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); |
| | | }); |
| | | }; |