一次人脸注册引发的 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
表面上看,问题可能来自以下任意一层:
- Face SDK 没有正确采集图片;
- Base64 格式不符合后端要求;
- multipart 请求格式错误;
- token 过期或登录时保存错误;
- 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 丢了"包含两个连续阶段:
- FaceAI 原生流程先污染 token;
- 随后的 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.tssrc/App.tsxsrc/i18n/MMKVBackend.tssrc/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 逻辑。
八、经验总结
- 第三方 SDK 使用默认存储时,必须检查是否与应用共享文件或 key 空间。
- 同一个 MMKV mmap 文件不应由两套独立原生封装并行管理。
- 鉴权信息应使用独立、明确命名的存储实例,而不是默认实例。
- 遇到"数据偶发丢失",应在跨原生页面前后记录同格式快照,而不是只在错误发生后读取。
- 401 自动登出会放大上游问题;必须区分"token 先损坏"和"应用随后删除 token"。
- 403、500 和 401 分别代表不同执行阶段,不能仅凭提示文字判断请求格式。
- 调试日志禁止打印完整密码、access token、refresh token 和完整 Base64 图片。
结论
本次故障不是登录接口随机返回错误 token,也不是 React Native MMKV 无故丢数据。真正原因是 FaceAI SDK 与应用共享 mmkv.default,导致原生人脸注册流程写入特征时污染应用鉴权数据。
将应用数据迁移到独立 MMKV 命名空间后,存储污染链路被切断。后续人脸注册接口出现的 403 或 500,应作为独立的服务端业务问题继续排查,不再与 token 丢失混为一谈。