import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import '../models/user_Info.dart'; import '../../core/utils/logger.dart'; import 'network/api.dart'; /// 实名认证(身份证二要素)状态与调用。 /// /// 服务端接口:`user_idverify` 提交核验、`user_getidverify` 查状态。 /// 身份证号只在提交那一刻用于核验,**本地与服务端都不保存全文**—— /// 服务端只落掩码 + 加盐指纹,客户端这边也只留服务端回给我们的掩码。 class IdVerifyService extends GetxService { static IdVerifyService get to => Get.find(); static const _tag = 'IdVerifyService'; /// 是否已实名 final verified = false.obs; /// 姓名(未实名为空) final realname = ''.obs; /// 身份证号掩码,如 4303**********8812(未实名为空) final idcardMask = ''.obs; /// 通过时间(秒级时间戳,0=未通过) final verifyTime = 0.obs; /// 按身份证解析出的真实性别(1=男 2=女,0=未知)。 /// 只有本次提交核验成功时服务端才会回,用于把「我的」页里用户自选的性别改过来。 final verifiedGender = 0.obs; /// 是否已从服务端拉过一次状态。没拉过时闸门不该拦人—— /// 否则冷启动、断网等情况下会把已实名的用户也挡在功能外面。 final loaded = false.obs; /// 是否有一次拉取正在飞行中,避免并发重复请求。 bool _fetching = false; @override void onInit() { super.onInit(); // 注意:本服务在 InitialBinding 里注册,那时**还没登录**(GetMaterialApp 的 // initialBinding 在登录之前就跑了)。所以这里只能尝试性地拉一次, // 真正保证拿到状态的是登录之后的 ensureLoaded()。 ensureLoaded(); } /// 按需拉取:还没拉到过就拉一次。可重复调用,登录后各处入口都该调它。 /// /// 这是修掉「重新登录后显示未认证」的关键:状态只在 App 启动时拉一次是不够的, /// 那时没有登录态,服务端只会回 NoLogin。 Future ensureLoaded() async { if (loaded.value) return; await refresh(); } /// 拉取实名状态。失败不抛,只是 loaded 保持 false,让闸门继续放行。 Future refresh() async { // 未登录时没有可查的实名状态,别浪费一次请求(也拿不到)。 // 游客不再排除:游客登录已改走服务端,有真实账号也有实名状态可查。 if (!User.isLoggedIn()) return; if (_fetching) return; _fetching = true; try { final data = await Api.userGetIdVerify({}); _apply(data); loaded.value = true; } catch (e) { Logger.w(_tag, '拉取实名状态失败: $e'); } finally { _fetching = false; } } /// 清空状态。**登出/切账号时必须调**——否则下一个登录的账号会看到上一个账号的 /// 「已认证 + 姓名」,既是错的也是信息泄露。 void clear() { verified.value = false; realname.value = ''; idcardMask.value = ''; verifyTime.value = 0; verifiedGender.value = 0; loaded.value = false; _fetching = false; } /// 提交核验。成功返回 null;失败返回可直接展示的错误文案。 /// /// 已实名时再次提交即「修改认证」——服务端支持换人改绑, /// 提交的还是同一个人时它会直接返回、不重复计费。 Future submit(String name, String idcard) async { final n = name.trim(); final id = idcard.trim().toUpperCase(); if (n.isEmpty) return 'idVerifyNameEmpty'.tr; if (!isValidIdCard(id)) return 'idVerifyIdInvalid'.tr; try { final data = await Api.userIdVerify({'realname': n, 'idcardno': id}); _apply(data); loaded.value = true; // 核验通过后服务端会按身份证第 17 位回写真实性别,同步到本地用户资料, // 免得「我的」页还显示着用户当初随手选的那个。 final g = _asInt(data is Map ? data['gender'] : null); verifiedGender.value = g; _applyGender(g); return verified.value ? null : 'idVerifyFailed'.tr; } catch (e) { Logger.w(_tag, '实名核验失败: $e'); return _friendlyError(e); } } /// 把核验回来的真实性别写进全局用户对象,并让在场的 UI 立刻跟着变。 /// /// 三件事缺一不可,早先只做了第一件,所以「我的」页要退出去再进来才更新: /// 1. 改 [User.instance](ProfileController 的 `userInfo` 包的就是这个对象); /// 2. 回写本地 `user_info`,否则重启后又变回用户自选的那个值 /// (服务端 markUserVerified 已经存了,但本地缓存会盖回来); /// 3. `refreshTick++` 驱动重建 —— 直接改字段不会触发任何 Rx。 void _applyGender(int gender) { if (gender <= 0) return; if (!User.isLoggedIn()) return; // 实名要求已登录,理论上到不了这里 final u = User.instance; if (u.gender == gender) return; u.gender = gender; try { GetStorage().write('user_info', u.toJson()); } catch (e) { Logger.w(_tag, '性别写入本地缓存失败: $e'); } User.refreshTick.value++; } void _apply(dynamic data) { if (data is! Map) return; verified.value = data['verified'] == true; realname.value = (data['realname'] as String?) ?? ''; idcardMask.value = (data['idcardmask'] as String?) ?? ''; verifyTime.value = _asInt(data['verifytime']); } static int _asInt(dynamic v) { if (v is int) return v; if (v is num) return v.toInt(); if (v is String) return int.tryParse(v) ?? 0; return 0; } /// 把后端错误码翻成用户看得懂的话。 /// 错误码见 errorcode.proto 的 5001-5005。 String _friendlyError(Object e) { final code = _codeOf(e); switch (code) { case 5001: return 'idVerifyNotConfigured'.tr; case 5002: return 'idVerifyIdInvalid'.tr; case 5003: return 'idVerifyMismatch'.tr; case 5004: return 'idVerifyProviderError'.tr; case 5005: return 'idVerifyTooFrequent'.tr; default: return 'idVerifyFailed'.tr; } } int _codeOf(Object e) { // DioManager 失败时抛的是带 code 的结构;兼容几种形态,取不到就 0。 try { final dyn = e as dynamic; final c = dyn.code; if (c is int) return c; if (c is String) return int.tryParse(c) ?? 0; } catch (_) {} final m = RegExp(r'code[^0-9]{0,3}(\d{4})').firstMatch(e.toString()); return m == null ? 0 : (int.tryParse(m.group(1)!) ?? 0); } // ── 身份证号本地校验(与服务端 sys/idverify 同一套算法)── // 服务商按次计费,把明显不合法的号码挡在提交之前既省钱也快。 static const _weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; static const _checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']; /// 校验 18 位二代身份证号(GB 11643 / ISO 7064 MOD 11-2 校验位)。 static bool isValidIdCard(String no) { final s = no.trim().toUpperCase(); if (s.length != 18) return false; var sum = 0; for (var i = 0; i < 17; i++) { final c = s.codeUnitAt(i); if (c < 0x30 || c > 0x39) return false; sum += (c - 0x30) * _weights[i]; } return s[17] == _checkCodes[sum % 11]; } }