一份可靠的双因素认证方案,是很多业务系统最后一道安全防线。HOTP(HMAC-Based One-Time Password,RFC 4226)作为其中一种经典实现,和TOTP(基于时间的一次性密码)相比,它基于事件计数器工作,不会因为设备时间偏差导致验证失败,在离线场景、硬件令牌、低频次登录校验里都有很稳定的表现。
我在把现有Flutter业务迁移到OpenHarmony平台时,遇到了一个很实际的问题:项目里已经在用的hotp这个三方库,底层依赖了dart的crypto包来算HMAC-SHA1,而当前OpenHarmony的Flutter运行环境对原生加密库的支撑并不完整,直接编译就报了一堆错。这篇文章就是我完整走通"Flutter hotp库鸿蒙适配"的记录,从RFC 4226算法本身的拆解,到纯Dart侧的重实现、验证向量回归、MethodChannel原生桥接,再到密钥存储和防重放设计,把整个流程磨了一遍。如果你正准备在OpenHarmony上做动态令牌、双因素登录,或者只是想把一个看似简单的Flutter库迁移到鸿蒙,这篇文章应该能帮你少踩不少坑。
1. 适配任务全景拆解
1.1 双因素认证为什么值得认真做
先花点时间把概念理清楚。很多人把HOTP和TOTP混着说,但它们的核心差异决定了选型方向。TOTP依赖时间戳,每30秒或60秒变一次,手机和服务器之间只要时间偏差超过一个窗口就验证失败;而HOTP依赖一个单调递增的计数器,每次成功验证后计数器和服务器同步递增,只要计数器一致,算法算出来的就是同一个验证码。
我在真正开始适配之前,先把RFC 4226原文和hotp库的源码都翻了一遍。hotp库本身非常轻量,核心逻辑就是"HMAC-SHA1(密钥, 计数器) → 动态截断 → 6到8位数字",它依赖dart的crypto包提供HMAC能力。问题在于,OpenHarmony的Flutter SDK当前对dart:crypto底层依赖的原生算力支持不全,跑起来之后要么是编译找不到符号,要么是运行调到某个平台方法直接走不通。这种情况下,光在依赖配置里换换版本是解决不了的。
1.2 RFC 4226算法的数学模型
把RFC 4226的算法流程完整写出来,是这样的:
- 将计数器counter编码为8字节的大端序(Big-Endian)二进制数。
- 用HMAC-SHA1算法,以密钥secret对上述8字节数据进行计算,得到20字节的摘要。
- 动态截断(Dynamic Truncation):取摘要最后一个字节的低4位,得到一个0到15的偏移量offset。
- 从摘要的第offset个字节开始,连续取4个字节,把这4字节拼成一个32位整数,屏蔽掉最高位(符号位),得到一个31位非负整数。
- 这个整数对10的d次方取模(d默认是6),不足d位前面用0补齐,得到最终的动态验证码。
这里有个经常被忽略的点:为什么不是直接把HMAC摘要转成大整数再取模?因为HMAC-SHA1的20字节摘要做模运算,高位字节的微小差异会影响最终结果,而动态截断的设计是为了让验证码片段尽量与密钥的所有字节都相关,同时保证输出空间的均匀分布。RFC 4226里的测试向量为什么能被广泛验证,正是因为动态截断规则非常明确,没有歧义。
1.3 Flutter三方库的适配难点在哪里
在普通Flutter环境里,导入hotp依赖后直接调用HOTP.generate或者类似API,底层crypto包会通过Flutter引擎的原生通道去跑系统加密库,一切都很透明。但在OpenHarmony上,SDK对dart:crypto的支撑情况是"部分可用",尤其HMAC这类需要平台实现的算法,容易出现Unsupported operation或者No implementation found。
适配的难点其实可以拆成三层:
- 编译层:pubspec.yaml里声明hotp后,依赖图会带入crypto包,OpenHarmony构建工具链能否正确拉取并编译这些包。
- 运行层:即使编译通过,crypto包内部调用平台能力时,鸿蒙运行环境是否注册了对应的平台插件。
- 安全层:就算算法跑通了,密钥存在哪里,FSSO(文件安全存储)、轻量级偏好或者普通文件,安全性完全不同,不能图省事直接塞进SharedPreferences。
我在适配时选择了一条比较务实的技术路线:不纠结于让原有crypto包在鸿蒙上跑通(这是依赖鸿蒙SDK持续进化的,我等不起),而是直接用纯Dart重新实现RFC 4226的核心计算逻辑,再用可选通道的方式拥抱鸿蒙的原生加密能力。这样既有确定性,又能确保算法本身在所有Flutter平台上行为一致。
2. 环境准备与工程搭建
2.1 开发环境清单
在动手改代码之前,先把环境问题铺平。以下环境组合是我实际使用的,如果版本不对,后续编译错误会非常干扰排查:
- OpenHarmony SDK版本:建议不低于5.0.0,同时打开Native C++能力
- Flutter鸿蒙SDK:使用基于OpenHarmony的分支版本,建议确认官方构建产物中dart:ui的API Level兼容性
- Dart SDK:跟随Flutter鸿蒙SDK自带的版本,不要单独安装更高版本的Dart
- 鸿蒙官方IDE:用于创建鸿蒙工程、签名、真机调试
- 一台OpenHarmony真机或模拟器(我用的真机是因为模拟器对加密库的支持稍有差异)
另外,给项目开一个单独的git分支做适配,别在主分支上直接改。因为你可能会尝试几条路线,比如先试crypto包能否用,再切换到纯Dart实现,分支管理能让你随时回退对比。
2.2 初始化Flutter鸿蒙工程
工程初始化有一个细节:必须用鸿蒙Flutter SDK提供的flutter命令来创建项目,不能用原生Flutter SDK创建的工程直接拿过来跑。命令通常是:
flutter create --org org.example hotp_ohos_demo创建完后,工程目录里会有原生的OpenHarmony模块和Flutter模块。如果你把之前Android/iOS的Flutter项目直接搬过来,需要手动把lib、pubspec.yaml等目录拷贝过来,同时重新生成entry模块的Abc文件签名配置。
接着,在pubspec.yaml里把hotp依赖先删掉,因为我们准备用自己重写的实现来做第一版验证:
dependencies: flutter: sdk: flutter # hotp: ^x.y.z // 先注释掉,避免编译阶段干扰等你把其余依赖的版本都调整到鸿蒙兼容的版本后,再把hotp加回来。我踩过一个坑:hotp库虽然小,但它依赖的crypto版本如果被另一个包锁定,会直接导致pub get报版本冲突,这在鸿蒙构建体系里会多耗一两个小时排查。
2.3 第一次编译的报错与排查
环境配好后的第一次编译,我把原有代码原封不动地编译了一遍,目的不是让它通过,而是看它到底会报什么错。这里有一个经验:很多开发者喜欢一开始就猜问题、改代码,而不是先把完整错误树跑出来。我实际收集到的错误大致分三类:
- 编译型错误:涉及dart:io、dart:ffi等库在鸿蒙Flutter SDK中部分API不可用,报的是undefined symbol。
- 插件注册错误:原项目里的某些插件在OpenHarmony上没有原生实现,运行时报Unable to find plugin。
- 加密能力错误:即使代码编译过了,只要一调用HMAC相关方法,就报Failed to load crypto backend。
第一轮的结论很清晰:hotp库直接拿来用,在目前的鸿蒙Flutter环境里走不通。但这不代表hotp库本身有问题,只能说明鸿蒙对dart:crypto的原生支撑还不够完善,所以我的适配策略锁定为:用纯Dart重写RFC 4226的算法根基,同时保留对原生加密能力的可选桥接。
3. 核心算法实现与验证
3.1 手写RFC 4226核心逻辑
整个适配工作里,最核心、最不能出错的就是这一步。好在我重写逻辑并不复杂,RFC 4226的算法是公开标准,测试向量也都是公开的,只要严格按照规范实现,理论上结果就必须一致。
在纯Dart实现里,最敏感的部分就是HMAC-SHA1。SHA1本身用纯Dart写可行,但性能不是最优,但HOTP每次只在登录验证时算一次,性能完全够用。我先把整个HOTP计算的Dart代码贴出来:
import 'dart:convert'; import 'dart:math'; /// 纯Dart实现的HOTP(RFC 4226)核心算法 class HotpGenerator { /// 生成一次性密码 /// [secret] 密钥,可以是ASCII字符串或Base32编码后的字节序列 /// [counter] 事件计数器,需要保持单调递增 /// [codeLength] 验证码位数,默认6 static String generate(String secret, int counter, {int codeLength = 6}) { // 1. 将计数器转换为8字节大端序表示 final counterBytes = _convertCounterTo8Bytes(counter); // 2. 计算HMAC-SHA1 final hmacResult = _hmacSha1(utf8.encode(secret), counterBytes); // 3. 动态截断(Dynamic Truncation) final offset = hmacResult[hmacResult.length - 1] & 0x0f; final binaryCode = ((hmacResult[offset] & 0x7f) << 24) | ((hmacResult[offset + 1] & 0xff) << 16) | ((hmacResult[offset + 2] & 0xff) << 8) | (hmacResult[offset + 3] & 0xff); // 4. 取模得到指定位数 final code = (binaryCode % pow(10, codeLength)).toInt(); return code.toString().padLeft(codeLength, '0'); } /// 把计数器的非负整数值转为8字节大端序 static List<int> _convertCounterTo8Bytes(int counter) { final bytes = List<int>.filled(8, 0); for (var i = 7; i >= 0; i--) { bytes[i] = counter & 0xff; counter >>= 8; } return bytes; } /// 一个最小化的HMAC-SHA1实现 /// 实际项目中可以直接复用dart的crypto包,如果可用的话 static List<int> _hmacSha1(List<int> key, List<int> message) { const blockSize = 64; if (key.length > blockSize) { // 超长密钥需要先做SHA1哈希压缩,这里省略,实际使用中密钥远小于64字节 } // 补齐密钥到64字节 final paddedKey = List<int>.from(key); while (paddedKey.length < blockSize) { paddedKey.add(0); } final iPad = List<int>.generate(blockSize, (i) => 0x36); final oPad = List<int>.generate(blockSize, (i) => 0x5c); final iKeyPad = List<int>.generate(blockSize, (i) => paddedKey[i] ^ iPad[i]); final oKeyPad = List<int>.generate(blockSize, (i) => paddedKey[i] ^ oPad[i]); final innerHash = _sha1([...iKeyPad, ...message]); return _sha1([...oKeyPad, ...innerHash]); } static List<int> _sha1(List<int> data) { // 实际项目中最好引用现成的纯Dart SHA1实现 // 这里只是为了展示算法流程,省略了完整SHA1的填充、轮函数等细节 return List<int>.generate(20, (i) => 0); } }代码里我把_sha1省略了,真实项目里可以直接引用pub上的纯Dart crypto实现,或者在鸿蒙桥接到原生加密库。这样做的原因是:SHA1的完整纯Dart实现代码量大,而且容易在边界条件上出错,既然有成熟的纯Dart包可以用,没必要重新造轮子。
3.2 用官方测试向量做回归验证
无论采用哪种密码学实现,都必须先用RFC 4226附录里的官方测试向量做回归验证,否则你可能写出一个"能跑但不对"的算法。
RFC 4226给出的标准测试向量如下,使用ASCII字符串"12345678901234567890"作为密钥,验证码位数6位:
| Counter | HOTP值 |
|---|---|
| 0 | 755224 |
| 1 | 287082 |
| 2 | 359152 |
| 3 | 969429 |
| 4 | 338314 |
| 5 | 254676 |
| 6 | 287922 |
| 7 | 162583 |
| 8 | 399871 |
| 9 | 520489 |
我把上面这段表格写进单元测试里,逐一对齐。第一次跑测试时,我的实现得到了0号计数器对应755224,但2号计数器开始出现偏差,排查后发现是计数器字节序转换时的右移逻辑写错了,因为Dart的int默认是64位有符号,右移会保留符号位,而计数器必须被视为无符号64位。这个问题在RFC 4226的参考实现里不会出现,因为它们用的是无符号长整型;在Dart里如果不小心,计数器超过2的31次方之后就会出问题。
除了6位验证码,我还测了7位和8位的情况,因为有些业务对安全性要求高,会要求8位动态码。测试时明确指定codeLength参数,并验证输出长度是否正确。
3.3 代码边界情况处理
重写核心算法时,有四个边界情况必须单独处理:
- 计数器为0:不能因为counter为0就直接返回错误,HOTP的设计里初始计数器就是0,很多新手在换成long后默认值是0,会误判为非法值。
- 计数器超过32位:上面说了,右移要保持逻辑移位,Dart中可以用>>>运算符。
- 密钥为空:必须抛出自定义异常,规格上没有明确的行为,但实际业务里空密钥意味着配置错误,早暴露比晚暴露好。
- Base32编码的密钥:很多业务把密钥以Base32字符串形式发放给用户,这个格式在TOTP(Google Authenticator风格)里很常见。HOTP同样可以采用。真实的HOTP密钥本质是字节序列,拿到Base32字符串后要先解码再参与HMAC计算。
把这些边界情况用单元测试锁死后,核心算法部分就稳了。这里多说一句:这种标准算法实现,宁可慢一点,也要对着标准文档把每一个字节的偏移、截断逻辑确认清楚,因为后面如果还有验证失败的问题,你很难再回头怀疑算法本身。
4. 鸿蒙侧的桥接与密钥存储
4.1 MethodChannel桥接设计
纯Dart实现可以保证核心算法在各平台行为一致,但鸿蒙原生侧还有一些能力需要打通,最典型的就是密钥安全存储和本地HMAC加速。
在Flutter鸿蒙环境中,MethodChannel的用法和Android侧基本一致。我设计了一个专门用于HOTP服务的通道:
import 'package:flutter/services.dart'; class HotpPlatformChannel { static const MethodChannel _channel = MethodChannel('com.example.hotp/channel'); /// 从鸿蒙安全存储读取密钥 /// 如果鸿蒙原生未注册,则回退到Dart侧配置 static Future<String?> getSecretFromSecureStorage() async { try { final result = await _channel.invokeMethod<String>('readSecret'); return result; } on MissingPluginException { return null; } } /// 调用鸿蒙原生HMAC-SHA1(可选加速) static Future<List<int>?> hmacSha1(List<int> key, List<int> message) async { try { final result = await _channel.invokeListMethod<int>( 'hmacSha1', {'key': key, 'message': message}, ); return result; } on MissingPluginException { return null; } } }为什么设计成"可回退"结构?因为鸿蒙原生侧的实现可能存在平台版本差异,有些设备没有注册这个通道,为了不让整个HOTP功能瘫痪,Dart侧必须保留一套纯Dart的算法实现作为兜底。这就是适配工作里的一个关键思想:桥接是锦上添花,纯Dart实现才是保底方案。
4.2 鸿蒙原生HMAC-SHA1实现
如果你希望在鸿蒙原生侧用系统加密库里已实现的HMAC-SHA1,而不是依赖纯Dart,可以在鸿蒙的工程模块里注册同一个MethodChannel。
鸿蒙的加密API和安卓不太一样,但核心概念类似。伪代码思路如下:
// 鸿蒙原生侧 import cryptoFramework from '@ohos.security.cryptoFramework'; function hmacSha1(key: Uint8Array, message: Uint8Array): Uint8Array { const mac = cryptoFramework.createMac('HMAC|SHA1'); const symKey = cryptoFramework.createSymKeyGenerator('HMAC') .convertKey(key); mac.init(symKey); mac.update({ data: message }); return mac.doFinal(); } export function registerHotpChannel() { // 注册 MethodChannel 的回调,等价逻辑: // 当Flutter侧调用hmacSha1时,执行上面方法并返回结果 }不过要提醒的是,我这里没有把完整API调用细节展开,因为鸿蒙SDK的加密接口在不同版本间变化较快,直接照搬API代码可能过一个月就要改。你更好的做法是:先确认当前SDK版本里createMac的入参格式,然后写好适配层。桥接的好处是,如果纯Dart的SHA1实现存在性能隐患,原生HMAC可以提供更快的计算;但在HOTP这种低频率调用场景下,性能提升其实感知并不明显,安全存储才是原生桥接更大的价值点。
4.3 密钥存储的工程取舍
HOTP的安全性,很大程度取决于种子密钥secret的存储方式。如果密钥明文存在普通文件或偏好设置里,那整个双因素认证就形同虚设。我在实际适配时做了三个层级的取舍:
- 最低安全级别:把密钥写在Dart侧配置里(仅用于开发和联调环境)
- 中等级别:用鸿蒙的轻量级偏好存储,但密钥必须进行编码混淆,至少不是明文
- 高安全级别:通过MethodChannel调用鸿蒙安全存储能力,把密钥写入加密文件/安全区域
我项目里最终选择了高安全级别方案,原生侧暴露readSecret方法,密钥由鸿蒙系统负责加密保存。这里有一个关键经验:安全存储能力不可能跨平台完全一致,不管是Android的Keystore还是鸿蒙的安全存储,平台差异都很大,所以适配层一定要把"读密钥"和"算HOTP"两个职责拆开。你在Dart侧不用关心密钥来自哪个平台,只需要知道有一个标准的读取入口,拿到的字节序列用于算法计算即可。
5. 验证流程与防重放设计
5.1 验证窗口与计数器同步
HOTP和TOTP的核心差异就是计数器同步机制。后端验证HOTP时,不可能只拿客户端提交的counter来做一次精确匹配,因为用户可能连续按了几次生成按钮,或者网络请求重试导致计数偏移。RFC 4226建议使用"验证窗口"(lookahead window)概念,比如后端允许客户端计数器比服务器当前计数器超前最多n位:
for (i = 0; i < windowSize; i++) { expected = generateHOTP(secret, serverCounter + i) if (providedCode == expected) { serverCounter += i + 1 return success } }在代码实现上,这里的serverCounter存储在服务端,客户端每次生成令牌后,本地counter递增;验证成功后,服务端也把对应值同步上去。问题最容易出在并发场景:两个客户端同时验证相同counter的同一个码,结果一个成功一个失败。解决办法是加锁,或者在计数器同步时做原子更新。
在鸿蒙端的Flutter代码里,counter的本地存储也需要认真设计。不能每次启动都从0开始算,否则你的动态码永远是同一串。本地存储用SharedPreferences或者鸿蒙偏好都可以,但要注意读写时机,建议在成功生成一次验证码后立即持久化,而不是等验证结果返回后。因为用户可能在弱网环境下生成了一次码但没提交成功,再次打开页面时计数器已经不一致,这时需要给用户一个重新同步的机制。
5.2 后端验证逻辑要点
后端验证HOTP的接口设计,我建议把客户端计数器(或一次性的随机数)一并上传,而不是只传一个验证码。否则服务端无法快速定位验证窗口。
请求参数示例:
{ "username": "test_user", "hotp_code": "755224", "client_counter": 0 }后端拿到clientCounter后,先从数据库中读取serverCounter,然后计算两者差值。如果clientCounter小于serverCounter,说明客户端落后了,这种情况下通常直接拒绝,因为可能存在重放攻击。如果clientCounter比serverCounter大,但差值在窗口范围内(比如10),则依次计算serverCounter到clientCounter之间所有可能值,其中一个匹配则通过验证,并把serverCounter更新为clientCounter+1。
我自己实现时遇到一个坑:测试时总把clientCounter和服务器的counter用同一个变量初始化,导致线下验证永远通过,线上真实部署时却因为两端起始不一致全部失败。后来加了日志打印两个counter值,问题一眼就看明白了。所以在适配阶段,日志里把两个counter都打出来是排查验证失败的最快手段。
5.3 防重放攻击的细节
HOTP本身对重放攻击是敏感的,因为动态码的有效期本质上取决于计数器是否已经消耗掉。一旦某个counter对应的动态码被成功验证,服务端就应该永久拒绝同一个码再次使用。
在鸿蒙客户端侧,我能做的主要是两点:一是确保每次生成的动态码都有独立的会话上下文,不至于同一个码被复制后在多个地方都能用;二是在本地UI上增加一次性使用提示,动态码展示后30秒或60秒自动隐藏,降低被偷窥的风险。
服务端侧的防重放还要考虑暴力枚举。虽然验证码只有6位,总共100万个组合,攻击者如果无限次提交,总会被枚举出来。所以接口必须加限流,同一个账号每秒钟最多尝试1到3次,超过直接锁定一段时间。如果你做的是高安全场景,建议把验证码扩展到8位,100万个组合变1亿个,暴力尝试代价大幅提升。
还有一个高级技巧:绑定一次性票据。客户端调用后端接口获取动态码时,可以同时下发一个随机的challenge值;HOTP计算时把challenge和counter混合进入HMAC消息。这样即使验证码被截获,在没有对应challenge的情况下也无法重放。这个方案超出了RFC 4226标准范围,但工程落地效果很好,我在分支里实现了一个试验版本,代码不多,安全性提升一个档次。
6. 常见问题与排查实录
6.1 适配阶段的高频报错速查表
| 问题现象 | 根本原因 | 解决办法 |
|---|---|---|
| pub get时hotp依赖与crypto版本冲突 | 鸿蒙分支的Flutter SDK对旧版crypto不兼容 | 升级crypto到支持OpenHarmony的分支版本,或者去掉hotp依赖改用纯Dart实现 |
| MethodChannel调用返回MissingPluginException | 鸿蒙原生侧没有注册对应通道 | 检查原生工程是否正确引入并调用registerHotpChannel,同时确保Dart侧有兜底逻辑 |
| 生成的验证码与RFC 4226测试向量不一致 | 计数器字节序错误或HMAC实现有误 | 用官方测试向量逐段定位,先验证HMAC结果再用固定输入验证截断逻辑 |
| 同一个验证码反复验证失败 | 客户端与服务端counter不同步 | 打印两端counter值,检查本地存储持久化时机,确认服务端窗口参数 |
| 密钥存进偏好存储后重启读出来是空 | 鸿蒙安全分区未正确初始化 | 优先使用鸿蒙安全存储API;若用轻量级偏好,检查沙箱路径是否随系统清理被重置 |
| 编译期报dart:io相关符号找不到 | 鸿蒙Flutter SDK对dart:io部分API尚未支持 | 移除代码中对dart:io的直接依赖,改用通道方式加载原生能力 |
6.2 排查实录:一次诡异的验证码漂移
适配过程中最折磨我的一个问题,是同一段算法代码在一台旧款OpenHarmony设备上生成的验证码每隔几个计数器就会跳一个数字。用测试向量验证是完全通过的,真机上的表现却时好时坏。我最初怀疑是HMAC实现的问题,但换了好几个候选实现都没解决。
后来把设备日志打出来才发现,是某个后台进程会周期性触发一次内存压缩,导致dart isolate的堆内存布局发生变化,而我当时用了未初始化的局部变量参与哈希计算——在最开始的_sha1骨架里,我留了一处缓存数组没有显式清零。Dart的List用法在多数平台零初始化,但在那次异常堆布局下,残留数据参与了计算,验证码自然漂移。定位后把所有中间计算结果都用填充方式显式初始化,再跑一整轮测试,彻底稳定了。
这件事给我的教训是:标准算法的实现,测试向量不是核验一遍就完事,应当在真机上连续跑几十个计数器、跨设备跑、甚至用随机密钥跑多轮,才能确认没有隐藏的初始化隐患。
6.3 关于平台适配的几条硬经验
有几点经验想集中说一下,都是员工文档里不会写的内容:
- 不要被"纯Dart实现一定慢"这个观念吓住。HOTP计算本身就几次哈希,微秒到毫秒级别,哪怕纯Dart实现稍慢,用户完全感知不到。真正卡脖子的往往是密钥存储的安全性,而不是算法性能。
- 桥接原生通道时,通道方法名一定要带上业务前缀,比如hotp_channel下的readSecret、hmacSha1,避免和其他插件撞名。鸿蒙生态的插件越来越丰富,命名空间冲突是迟早的事。
- 版本锁定比频繁升级更安全。鸿蒙SDK还在快速演进,如果你的适配层刚好卡在一个中间版本,就锁死该版本并做好隔离,别被新的SDK迫着升级,否则今天能跑明天又崩。
7. 写在最后
从Flutter普通平台的hotp库到OpenHarmony上可用、可信、可扩展的动态令牌能力,这个适配过程真正验证了一个道理:三方库的迁移重点不是把依赖换成鸿蒙版,而是要理解这个库在干什么,然后让它在目标平台上用最稳的方式重新落地。
对我个人而言,最受用的还是那套纯Dart的RFC 4226实现,它让整个HOTP能力不再受制于底层平台是否支持某个加密库,也算给项目留下了一个跨平台能力最强的底座。如果你也正在做鸿蒙适配,我建议你一定保留一个纯Dart实现的兜底通道,不要把所有希望都押在原生桥接上。最后再分享一个小技巧:在鸿蒙工程里联调HOTP时,先在开发阶段把计数器随机打乱几次,强制触发服务端的窗口重同步逻辑,这样才能在正式上线前发现同步问题,别等真用户被锁住了才后悔。