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
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];
|
|
}
|
|
}
|
|
|