React Native一次人脸注册引发的 MMKV 数据污染排查复盘

一次人脸注册引发的 MMKV 数据污染排查复盘

日期:2026-08-11

场景:React Native Android 工位机、人脸注册、react-native-mmkv、FaceAI SDK

摘要

项目原本可以正常登录和调用业务接口,但多次执行人脸注册后,陆续出现以下问题:

  • KEY_USER_INFO 无法执行 JSON.parse;
  • access token 从正常 JWT 变成短字符串;
  • refresh token 消失;
  • 人脸注册先返回 403,随后变成 401;
  • 401 又触发应用自动登出并删除 token。

最终确认:应用与 FaceAI SDK 同时使用了 MMKV 默认实例 mmkv.default。应用通过 react-native-mmkv 写入登录信息,FaceAI SDK 通过腾讯原生 MMKV 写入人脸特征。两边使用不同版本、不同原生入口操作同一个 mmap 文件,导致应用数据被覆盖或污染。

最终方案是将应用鉴权和设置数据迁移到独立 MMKV 命名空间 wbst-app-storage-v1,彻底与 FaceAI SDK 的默认存储隔离。

一、故障现象

最初日志表现为:

text 复制代码
[FaceAI] Calling arc face registration API...
[FaceAI] Registration failed: Error: 人脸信息注册失败

HeaderBar: Failed to parse KEY_USER_INFO:
JSON Parse error: Unexpected character: R

[FaceAI] Registration failed:
Request failed with status code 401

表面上看,问题可能来自以下任意一层:

  1. Face SDK 没有正确采集图片;
  2. Base64 格式不符合后端要求;
  3. multipart 请求格式错误;
  4. token 过期或登录时保存错误;
  5. MMKV 文件或某些键发生异常。

仅凭这些错误无法判断先后因果,因此需要在关键节点记录相同格式的存储快照。

二、建立可比较的诊断日志

新增统一的鉴权存储摘要日志,记录:

  • 当前服务端 host;
  • token 是否存在、长度、JWT 段数、校验值;
  • refresh token 是否存在;
  • userInfo 是否为合法 JSON;
  • userInfo 长度、校验值和首字符;
  • userId。

为避免泄露凭据,日志不打印完整 token,只记录长度、掩码尾号和非加密校验值。

关键埋点包括:

text 复制代码
password-login:after-token-write
password-login:after-user-info-write

face-registration:before-sdk-camera
face-registration:after-sdk-camera
arc-face:before-request

401-interceptor:before-logout-event
app-logout:before-delete
app-logout:after-delete

这种设计的重点不是"多打日志",而是保证每个阶段使用同一种快照结构,从而直接比较数据在哪一步变化。

三、关键证据

1. 登录写入完全正常

登录接口返回的 access token 是标准三段式 JWT:

text 复制代码
token type: Bearer
JWT segments: 3
stored length: 1434
contains undefined/null: false

登录后重新获取用户信息,userInfo 也恢复为合法 JSON:

text 复制代码
jsonValid: true
valueType: object
isAuthenticated: true

因此,登录响应和登录写入不是本次 token 异常的直接原因。

2. 数据变化发生在原生人脸页面返回期间

人脸注册前:

text 复制代码
face-registration:before-sdk-camera
token.length = 1434
token.jwtSegments = 3
refreshToken.exists = true
userInfo.jsonValid = true

第一次从 FaceAI 原生相机页面返回后,token 暂时保持不变,但 userInfo 已消失:

text 复制代码
face-registration:after-sdk-camera
token.length = 1434
userInfo.exists = false

继续重复注册后,某一次相机返回前后发生了决定性变化:

text 复制代码
before-sdk-camera:
  token.length = 1434
  token.jwtSegments = 3
  refreshToken.exists = true

after-sdk-camera:
  token.length = 50
  token.jwtSegments = 1
  refreshToken.exists = false
  userInfo.jsonValid = false

变化发生在调用后端注册接口之前,因此可以排除:

  • Axios 响应拦截器;
  • 401 自动登出;
  • 后端注册接口;
  • React 页面主动清理 token。

3. 401 是结果,不是起因

损坏后的 50 字符字符串被当作 Authorization token 发送:

text 复制代码
Authorization: <非 Bearer、非 JWT 的短字符串>

服务端返回 401 后,项目中的统一拦截器广播登出事件:

text 复制代码
HTTP 401
  -> NETWORK_ERROR_401
  -> setIsSignedIn(false)
  -> 删除 TOKEN 和 REFRESH_TOKEN

因此,"人脸注册后 token 丢了"包含两个连续阶段:

  1. FaceAI 原生流程先污染 token;
  2. 随后的 401 再触发应用主动删除 token。

MMKV 并不是在收到 401 后随机丢失数据。

四、根因定位

应用侧

应用原来使用默认 MMKV 实例:

ts 复制代码
const mmkv = new MMKV();

react-native-mmkv 默认实例 ID 为:

text 复制代码
mmkv.default

项目中的 token、refresh token、用户信息、语言和设备设置都保存在该实例中。

FaceAI SDK 侧

FaceAI React Native 模块源码中直接使用:

kotlin 复制代码
MMKV.defaultMMKV().encode(faceID, faceFeature)
MMKV.defaultMMKV().decodeString(faceID)
MMKV.defaultMMKV().removeValueForKey(faceID)

其 Android 依赖为:

gradle 复制代码
implementation 'com.tencent:mmkv:1.3.14'

当前 react-native-mmkv 包内嵌的 MMKV Core 为 1.3.3。

这意味着应用的 JSI MMKV 与 FaceAI SDK 的 Java MMKV 使用不同原生入口操作同一个默认 mmap 文件。写入人脸特征时,默认存储中的应用键出现丢失、错位或错误解码。

由于 FaceAI SDK 内部 Activity 和依赖也可能直接使用 MMKV.defaultMMKV(),只修改 React Native Bridge 中的几个调用并不能完整隔离风险。

五、最终修复

1. 应用使用独立 MMKV ID

新增统一存储模块:

ts 复制代码
export const APP_STORAGE_ID = 'wbst-app-storage-v1';
export const appStorage = new MMKV({ id: APP_STORAGE_ID });

应用鉴权、用户信息和业务设置全部通过该实例读写。FaceAI SDK 继续使用 mmkv.default,两者对应不同文件,互不覆盖。

相关实现:

  • src/storage/appStorage.ts
  • src/App.tsx
  • src/i18n/MMKVBackend.ts
  • src/utils/AuthStorageDebug.ts

2. 一次性安全迁移

升级已有安装时,需要保留旧环境地址、语言等设置,因此增加一次性迁移:

text 复制代码
旧 mmkv.default
  -> 校验已知应用键
  -> 写入 wbst-app-storage-v1
  -> 写入迁移完成标记

迁移过程中增加额外保护:

  • 非三段式 Bearer JWT 不迁移;
  • 非法 JSON 的 userInfo 不迁移;
  • token 已损坏时不迁移 refresh token;
  • 新存储已有值时不覆盖。

因此,已损坏设备升级后通常需要重新登录一次,但不会把错误鉴权信息带进新存储。

3. 登录写入保护

登录流程不再打印完整密码和 token,仅保留脱敏摘要。

同时,用户信息保存前检查 currentUser.id,避免接口占位实现返回 undefined 后继续读取 res.id,产生未处理的 Promise 异常。

六、人脸注册请求格式的排查结论

Swagger 生成的请求使用:

bash 复制代码
curl -X POST '.../registerFaceFeature' \
  -H 'Authorization: Bearer ***' \
  -H 'Content-Type: multipart/form-data' \
  -F 'UserId=<uuid>' \
  -F 'ImageBase64=data:image/jpg;base64,...'

因此接口要求:

  • multipart/form-data;
  • UserId 为文本字段;
  • ImageBase64 为文本字段;
  • Base64 保留完整 data:image/jpg;base64, 前缀;
  • 不上传 Image 二进制文件时,只提交上述两个字段。

客户端最终采用显式 FormData,避免额外封装造成歧义:

ts 复制代码
const formData = new FormData();
formData.append('UserId', String(userId));
formData.append('ImageBase64', String(imageBase64));

403、500、401 的含义不同

状态 本次场景中的含义
403 服务端业务校验失败,例如找不到对应用户
500 请求已进入服务端,但图片处理、特征提取或保存阶段发生未处理异常
401 Authorization 中的 token 无效;本次事故中由 MMKV 污染后的短字符串触发

抓包工具已经能够把 App 请求解析为两个 FORM-DATA 字段,说明 multipart boundary 和字段结构已被服务端接收。500 不等于请求没有进入接口;如果 URL 或媒体类型错误,更常见的是 404 或 415。

不要把 multipart boundary 当成 UserId

multipart 原始内容类似:

text 复制代码
--<随机 boundary>
Content-Disposition: form-data; name="UserId"

<真正的 UserId>
--<随机 boundary>

第一行的随机 UUID 样式字符串只是分隔符,不是字段值。严格比较 App 与 Postman 请求时,应使用 Content-Disposition 下方空行后的值。

七、验证方案

修复后建议连续执行至少 20 次人脸注册,并比较以下日志:

text 复制代码
face-registration:before-sdk-camera
face-registration:after-sdk-camera
arc-face:before-request

通过标准:

  • token checksum 始终一致;
  • token 长度和 JWT 段数不变;
  • refresh token 始终存在;
  • userInfo.jsonValid 始终为 true;
  • App 重启后仍能读取同一组数据;
  • Face SDK 可以继续保存和读取本地人脸特征。

请求层还应检查:

text 复制代码
imagePrefix = data:image/jpg;base64,
payloadLengthMod4 = 0
containsWhitespace = false
looksLikeJpeg = true

如果存储快照稳定,但接口仍返回 500,应使用抓包中的完整请求在 Postman 原样重放,并让后端根据同一次请求的追踪信息检查真实异常。此时不应继续修改 MMKV 或 token 逻辑。

八、经验总结

  1. 第三方 SDK 使用默认存储时,必须检查是否与应用共享文件或 key 空间。
  2. 同一个 MMKV mmap 文件不应由两套独立原生封装并行管理。
  3. 鉴权信息应使用独立、明确命名的存储实例,而不是默认实例。
  4. 遇到"数据偶发丢失",应在跨原生页面前后记录同格式快照,而不是只在错误发生后读取。
  5. 401 自动登出会放大上游问题;必须区分"token 先损坏"和"应用随后删除 token"。
  6. 403、500 和 401 分别代表不同执行阶段,不能仅凭提示文字判断请求格式。
  7. 调试日志禁止打印完整密码、access token、refresh token 和完整 Base64 图片。

结论

本次故障不是登录接口随机返回错误 token,也不是 React Native MMKV 无故丢数据。真正原因是 FaceAI SDK 与应用共享 mmkv.default,导致原生人脸注册流程写入特征时污染应用鉴权数据。

将应用数据迁移到独立 MMKV 命名空间后,存储污染链路被切断。后续人脸注册接口出现的 403 或 500,应作为独立的服务端业务问题继续排查,不再与 token 丢失混为一谈。

相关推荐
梦想的颜色16 小时前
【编程实战】AI 时代 APP 开发全栈硬核指南:技术选型 + AI 架构 + 模型落地全维度决策
python·flutter·react native·react.js·ai·桌面应用·milvus
song5011 天前
React Native for OpenHarmony 实战:三方库 react-native-css-transformer 的鸿蒙化适配指南
css·人工智能·深度学习·react native·transformer
enjoywindstorm4 天前
移动端原生框架对比评测
react native
传奇开心果编程4 天前
【声明式UI开发实用技术学与练】第8课 状态提升与下放
学习·flutter·react native·ui·swiftui·composer
传奇开心果编程5 天前
【现代声明式UI学与练】第4课 列表渲染与 key——如何高效渲染列表、key 的作用、列表重排时的状态保持
学习·flutter·react native·ui·swiftui·android jetpack
传奇开心果编程5 天前
【现代声明式UI学与练】第7课 组件通信——父传子、子传父、跨层级通信、状态提升与 Context
学习·flutter·react native·ui·swiftui·android jetpack
500846 天前
React Native for OpenHarmony 实战:三方库 react-native-url-polyfill 的鸿蒙化适配指南
javascript·react native·react.js·性能优化·electron·harmonyos
传奇开心果编程6 天前
【现代声明式UI学与练】第9课 性能优化——渲染优化、列表优化、内存优化、启动优化
学习·flutter·react native·ui·性能优化·swiftui·android jetpack
500846 天前
React Native for OpenHarmony 实战:三方库 react-native-volume-control 的鸿蒙化适配指南
javascript·react native·react.js·electron·harmonyos
5008411 天前
React Native for OpenHarmony 实战:三方库 react-native-crypto-js 的鸿蒙化适配指南
javascript·react native·性能优化·electron·harmonyos