You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

200 lines
7.4 KiB

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<IdVerifyService>();
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<void> ensureLoaded() async {
if (loaded.value) return;
await refresh();
}
/// 拉取实名状态。失败不抛,只是 loaded 保持 false,让闸门继续放行。
Future<void> 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<String?> 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];
}
}