2025-12-12 09:11:30 +08:00
|
|
|
|
import 'dart:core';
|
|
|
|
|
|
|
|
|
|
|
|
class IDCardInfo {
|
|
|
|
|
|
final String raw;
|
|
|
|
|
|
final bool isValid;
|
|
|
|
|
|
final String? error;
|
2026-04-13 08:59:45 +08:00
|
|
|
|
final String? id18;
|
|
|
|
|
|
final String? addressCode;
|
2025-12-12 09:11:30 +08:00
|
|
|
|
final String? provinceCode;
|
|
|
|
|
|
final String? province;
|
|
|
|
|
|
final DateTime? birthDate;
|
2026-04-13 08:59:45 +08:00
|
|
|
|
final String? birth;
|
2025-12-12 09:11:30 +08:00
|
|
|
|
final int? age;
|
2026-04-13 08:59:45 +08:00
|
|
|
|
final String? gender;
|
2025-12-12 09:11:30 +08:00
|
|
|
|
final bool checksumValid;
|
2026-04-13 08:59:45 +08:00
|
|
|
|
final String? constellation;
|
|
|
|
|
|
final String? zodiac;
|
2025-12-12 09:11:30 +08:00
|
|
|
|
|
|
|
|
|
|
IDCardInfo({
|
|
|
|
|
|
required this.raw,
|
|
|
|
|
|
required this.isValid,
|
|
|
|
|
|
this.error,
|
|
|
|
|
|
this.id18,
|
2026-04-13 08:59:45 +08:00
|
|
|
|
this.addressCode,
|
2025-12-12 09:11:30 +08:00
|
|
|
|
this.provinceCode,
|
|
|
|
|
|
this.province,
|
|
|
|
|
|
this.birthDate,
|
|
|
|
|
|
this.birth,
|
|
|
|
|
|
this.age,
|
|
|
|
|
|
this.gender,
|
|
|
|
|
|
required this.checksumValid,
|
|
|
|
|
|
this.constellation,
|
|
|
|
|
|
this.zodiac,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
|
|
|
|
'raw': raw,
|
|
|
|
|
|
'isValid': isValid,
|
|
|
|
|
|
'error': error,
|
|
|
|
|
|
'id18': id18,
|
2026-04-13 08:59:45 +08:00
|
|
|
|
'addressCode': addressCode,
|
2025-12-12 09:11:30 +08:00
|
|
|
|
'provinceCode': provinceCode,
|
|
|
|
|
|
'province': province,
|
|
|
|
|
|
'birth': birth,
|
|
|
|
|
|
'age': age,
|
|
|
|
|
|
'gender': gender,
|
|
|
|
|
|
'checksumValid': checksumValid,
|
|
|
|
|
|
'constellation': constellation,
|
|
|
|
|
|
'zodiac': zodiac,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
String toString() => toJson().toString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 08:59:45 +08:00
|
|
|
|
/// 只校验 18 位身份证号
|
|
|
|
|
|
IDCardInfo parseChineseIDCard(String? id) {
|
|
|
|
|
|
final raw = _normalizeId(id);
|
|
|
|
|
|
|
2025-12-12 09:11:30 +08:00
|
|
|
|
if (raw.isEmpty) {
|
2026-04-13 08:59:45 +08:00
|
|
|
|
return IDCardInfo(
|
|
|
|
|
|
raw: raw,
|
|
|
|
|
|
isValid: false,
|
|
|
|
|
|
error: '身份证号不能为空',
|
|
|
|
|
|
checksumValid: false,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (raw.length != 18) {
|
|
|
|
|
|
return IDCardInfo(
|
|
|
|
|
|
raw: raw,
|
|
|
|
|
|
isValid: false,
|
|
|
|
|
|
error: '身份证号长度不正确,应为18位',
|
|
|
|
|
|
checksumValid: false,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!RegExp(r'^\d{17}[\dX]$').hasMatch(raw)) {
|
|
|
|
|
|
return IDCardInfo(
|
|
|
|
|
|
raw: raw,
|
|
|
|
|
|
isValid: false,
|
|
|
|
|
|
error: '身份证号格式不正确:前17位必须是数字,最后一位必须是数字或X',
|
|
|
|
|
|
checksumValid: false,
|
|
|
|
|
|
);
|
2025-12-12 09:11:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const provinceMap = <String, String>{
|
|
|
|
|
|
'11': '北京市',
|
|
|
|
|
|
'12': '天津市',
|
|
|
|
|
|
'13': '河北省',
|
|
|
|
|
|
'14': '山西省',
|
|
|
|
|
|
'15': '内蒙古自治区',
|
|
|
|
|
|
'21': '辽宁省',
|
|
|
|
|
|
'22': '吉林省',
|
|
|
|
|
|
'23': '黑龙江省',
|
|
|
|
|
|
'31': '上海市',
|
|
|
|
|
|
'32': '江苏省',
|
|
|
|
|
|
'33': '浙江省',
|
|
|
|
|
|
'34': '安徽省',
|
|
|
|
|
|
'35': '福建省',
|
|
|
|
|
|
'36': '江西省',
|
|
|
|
|
|
'37': '山东省',
|
|
|
|
|
|
'41': '河南省',
|
|
|
|
|
|
'42': '湖北省',
|
|
|
|
|
|
'43': '湖南省',
|
|
|
|
|
|
'44': '广东省',
|
|
|
|
|
|
'45': '广西壮族自治区',
|
|
|
|
|
|
'46': '海南省',
|
|
|
|
|
|
'50': '重庆市',
|
|
|
|
|
|
'51': '四川省',
|
|
|
|
|
|
'52': '贵州省',
|
|
|
|
|
|
'53': '云南省',
|
|
|
|
|
|
'54': '西藏自治区',
|
|
|
|
|
|
'61': '陕西省',
|
|
|
|
|
|
'62': '甘肃省',
|
|
|
|
|
|
'63': '青海省',
|
|
|
|
|
|
'64': '宁夏回族自治区',
|
|
|
|
|
|
'65': '新疆维吾尔自治区',
|
|
|
|
|
|
'71': '台湾省',
|
|
|
|
|
|
'81': '香港特别行政区',
|
|
|
|
|
|
'82': '澳门特别行政区',
|
2026-04-13 08:59:45 +08:00
|
|
|
|
'91': '国外',
|
2025-12-12 09:11:30 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-13 08:59:45 +08:00
|
|
|
|
final addressCode = raw.substring(0, 6);
|
|
|
|
|
|
final provinceCode = raw.substring(0, 2);
|
|
|
|
|
|
final province = provinceMap[provinceCode];
|
|
|
|
|
|
|
|
|
|
|
|
if (province == null) {
|
2025-12-12 09:11:30 +08:00
|
|
|
|
return IDCardInfo(
|
|
|
|
|
|
raw: raw,
|
|
|
|
|
|
isValid: false,
|
2026-04-13 08:59:45 +08:00
|
|
|
|
error: '地址码不合法:省份码错误',
|
|
|
|
|
|
id18: raw,
|
|
|
|
|
|
addressCode: addressCode,
|
|
|
|
|
|
provinceCode: provinceCode,
|
2025-12-12 09:11:30 +08:00
|
|
|
|
checksumValid: false,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 08:59:45 +08:00
|
|
|
|
final yearStr = raw.substring(6, 10);
|
|
|
|
|
|
final monthStr = raw.substring(10, 12);
|
|
|
|
|
|
final dayStr = raw.substring(12, 14);
|
2025-12-12 09:11:30 +08:00
|
|
|
|
|
2026-04-13 08:59:45 +08:00
|
|
|
|
if (!RegExp(r'^(18|19|20)\d{2}$').hasMatch(yearStr)) {
|
2025-12-12 09:11:30 +08:00
|
|
|
|
return IDCardInfo(
|
|
|
|
|
|
raw: raw,
|
|
|
|
|
|
isValid: false,
|
2026-04-13 08:59:45 +08:00
|
|
|
|
error: '年份不合法:必须以18、19或20开头',
|
|
|
|
|
|
id18: raw,
|
|
|
|
|
|
addressCode: addressCode,
|
|
|
|
|
|
provinceCode: provinceCode,
|
|
|
|
|
|
province: province,
|
|
|
|
|
|
checksumValid: false,
|
2025-12-12 09:11:30 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 08:59:45 +08:00
|
|
|
|
if (!RegExp(r'^(0[1-9]|1[0-2])$').hasMatch(monthStr)) {
|
|
|
|
|
|
return IDCardInfo(
|
|
|
|
|
|
raw: raw,
|
|
|
|
|
|
isValid: false,
|
|
|
|
|
|
error: '月份不合法:必须在01到12之间',
|
|
|
|
|
|
id18: raw,
|
|
|
|
|
|
addressCode: addressCode,
|
|
|
|
|
|
provinceCode: provinceCode,
|
|
|
|
|
|
province: province,
|
|
|
|
|
|
checksumValid: false,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!RegExp(r'^(0[1-9]|[12]\d|3[01])$').hasMatch(dayStr)) {
|
|
|
|
|
|
return IDCardInfo(
|
|
|
|
|
|
raw: raw,
|
|
|
|
|
|
isValid: false,
|
|
|
|
|
|
error: '日期不合法:必须在01到31之间',
|
|
|
|
|
|
id18: raw,
|
|
|
|
|
|
addressCode: addressCode,
|
|
|
|
|
|
provinceCode: provinceCode,
|
|
|
|
|
|
province: province,
|
|
|
|
|
|
checksumValid: false,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final year = int.parse(yearStr);
|
|
|
|
|
|
final month = int.parse(monthStr);
|
|
|
|
|
|
final day = int.parse(dayStr);
|
|
|
|
|
|
|
2025-12-12 09:11:30 +08:00
|
|
|
|
DateTime? birthDate;
|
|
|
|
|
|
try {
|
2026-04-13 08:59:45 +08:00
|
|
|
|
final dt = DateTime(year, month, day);
|
|
|
|
|
|
if (dt.year == year && dt.month == month && dt.day == day) {
|
|
|
|
|
|
birthDate = dt;
|
2025-12-12 09:11:30 +08:00
|
|
|
|
}
|
2026-04-13 08:59:45 +08:00
|
|
|
|
} catch (_) {
|
2025-12-12 09:11:30 +08:00
|
|
|
|
birthDate = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (birthDate == null) {
|
|
|
|
|
|
return IDCardInfo(
|
|
|
|
|
|
raw: raw,
|
|
|
|
|
|
isValid: false,
|
2026-04-13 08:59:45 +08:00
|
|
|
|
error: '出生日期无效:请检查年月日是否真实存在',
|
|
|
|
|
|
id18: raw,
|
|
|
|
|
|
addressCode: addressCode,
|
|
|
|
|
|
provinceCode: provinceCode,
|
|
|
|
|
|
province: province,
|
|
|
|
|
|
checksumValid: false,
|
2025-12-12 09:11:30 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 08:59:45 +08:00
|
|
|
|
final seqCode = raw.substring(14, 17);
|
|
|
|
|
|
if (!RegExp(r'^\d{3}$').hasMatch(seqCode)) {
|
|
|
|
|
|
return IDCardInfo(
|
|
|
|
|
|
raw: raw,
|
|
|
|
|
|
isValid: false,
|
|
|
|
|
|
error: '顺序码不合法:第15到17位必须全部为数字',
|
|
|
|
|
|
id18: raw,
|
|
|
|
|
|
addressCode: addressCode,
|
|
|
|
|
|
provinceCode: provinceCode,
|
|
|
|
|
|
province: province,
|
|
|
|
|
|
birthDate: birthDate,
|
|
|
|
|
|
birth: _formatDate(birthDate),
|
|
|
|
|
|
checksumValid: false,
|
|
|
|
|
|
);
|
2025-12-12 09:11:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 08:59:45 +08:00
|
|
|
|
final seqNum = int.parse(seqCode);
|
|
|
|
|
|
if (seqNum == 0) {
|
|
|
|
|
|
return IDCardInfo(
|
|
|
|
|
|
raw: raw,
|
|
|
|
|
|
isValid: false,
|
|
|
|
|
|
error: '顺序码不合法:不能为000',
|
|
|
|
|
|
id18: raw,
|
|
|
|
|
|
addressCode: addressCode,
|
|
|
|
|
|
provinceCode: provinceCode,
|
|
|
|
|
|
province: province,
|
|
|
|
|
|
birthDate: birthDate,
|
|
|
|
|
|
birth: _formatDate(birthDate),
|
|
|
|
|
|
checksumValid: false,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-12-12 09:11:30 +08:00
|
|
|
|
|
2026-04-13 08:59:45 +08:00
|
|
|
|
final gender = (seqNum % 2 == 1) ? '男' : '女';
|
2025-12-12 09:11:30 +08:00
|
|
|
|
|
2026-04-13 08:59:45 +08:00
|
|
|
|
final checksumValid = _verifyCheck(raw);
|
|
|
|
|
|
if (!checksumValid) {
|
|
|
|
|
|
return IDCardInfo(
|
|
|
|
|
|
raw: raw,
|
|
|
|
|
|
isValid: false,
|
|
|
|
|
|
error: '校验位不正确',
|
|
|
|
|
|
id18: raw,
|
|
|
|
|
|
addressCode: addressCode,
|
|
|
|
|
|
provinceCode: provinceCode,
|
|
|
|
|
|
province: province,
|
|
|
|
|
|
birthDate: birthDate,
|
|
|
|
|
|
birth: _formatDate(birthDate),
|
|
|
|
|
|
age: _calcAge(birthDate),
|
|
|
|
|
|
gender: gender,
|
|
|
|
|
|
checksumValid: false,
|
|
|
|
|
|
constellation: _calcConstellation(birthDate.month, birthDate.day),
|
|
|
|
|
|
zodiac: _calcChineseZodiac(birthDate.year),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-12-12 09:11:30 +08:00
|
|
|
|
|
|
|
|
|
|
return IDCardInfo(
|
|
|
|
|
|
raw: raw,
|
|
|
|
|
|
isValid: true,
|
2026-04-13 08:59:45 +08:00
|
|
|
|
id18: raw,
|
|
|
|
|
|
addressCode: addressCode,
|
2025-12-12 09:11:30 +08:00
|
|
|
|
provinceCode: provinceCode,
|
|
|
|
|
|
province: province,
|
|
|
|
|
|
birthDate: birthDate,
|
2026-04-13 08:59:45 +08:00
|
|
|
|
birth: _formatDate(birthDate),
|
|
|
|
|
|
age: _calcAge(birthDate),
|
2025-12-12 09:11:30 +08:00
|
|
|
|
gender: gender,
|
2026-04-13 08:59:45 +08:00
|
|
|
|
checksumValid: true,
|
|
|
|
|
|
constellation: _calcConstellation(birthDate.month, birthDate.day),
|
|
|
|
|
|
zodiac: _calcChineseZodiac(birthDate.year),
|
2025-12-12 09:11:30 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 08:59:45 +08:00
|
|
|
|
String _normalizeId(String? id) {
|
|
|
|
|
|
return (id ?? '')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.replaceAll(' ', '')
|
|
|
|
|
|
.replaceAll(' ', '')
|
|
|
|
|
|
.replaceAll('X', 'X')
|
|
|
|
|
|
.toUpperCase();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
String _formatDate(DateTime d) {
|
|
|
|
|
|
return '${d.year.toString().padLeft(4, '0')}-'
|
|
|
|
|
|
'${d.month.toString().padLeft(2, '0')}-'
|
|
|
|
|
|
'${d.day.toString().padLeft(2, '0')}';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
int _calcAge(DateTime birthDate) {
|
|
|
|
|
|
final now = DateTime.now();
|
|
|
|
|
|
var age = now.year - birthDate.year;
|
|
|
|
|
|
if (now.month < birthDate.month ||
|
|
|
|
|
|
(now.month == birthDate.month && now.day < birthDate.day)) {
|
|
|
|
|
|
age -= 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
return age;
|
|
|
|
|
|
}
|
2025-12-12 09:11:30 +08:00
|
|
|
|
|
|
|
|
|
|
String _calcCheckChar(String id17) {
|
|
|
|
|
|
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
|
|
|
|
|
|
const checkMap = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
|
|
|
|
|
|
|
|
|
|
|
|
int sum = 0;
|
|
|
|
|
|
for (var i = 0; i < 17; i++) {
|
2026-04-13 08:59:45 +08:00
|
|
|
|
final n = int.parse(id17[i]);
|
2025-12-12 09:11:30 +08:00
|
|
|
|
sum += n * weights[i];
|
|
|
|
|
|
}
|
2026-04-13 08:59:45 +08:00
|
|
|
|
return checkMap[sum % 11];
|
2025-12-12 09:11:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool _verifyCheck(String id18) {
|
|
|
|
|
|
if (id18.length != 18) return false;
|
|
|
|
|
|
final id17 = id18.substring(0, 17);
|
|
|
|
|
|
final expected = _calcCheckChar(id17);
|
|
|
|
|
|
final actual = id18[17].toUpperCase();
|
|
|
|
|
|
return expected == actual;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
String _calcConstellation(int month, int day) {
|
|
|
|
|
|
const names = [
|
2026-04-13 08:59:45 +08:00
|
|
|
|
'摩羯座',
|
|
|
|
|
|
'水瓶座',
|
|
|
|
|
|
'双鱼座',
|
|
|
|
|
|
'白羊座',
|
|
|
|
|
|
'金牛座',
|
|
|
|
|
|
'双子座',
|
|
|
|
|
|
'巨蟹座',
|
|
|
|
|
|
'狮子座',
|
|
|
|
|
|
'处女座',
|
|
|
|
|
|
'天秤座',
|
|
|
|
|
|
'天蝎座',
|
|
|
|
|
|
'射手座'
|
2025-12-12 09:11:30 +08:00
|
|
|
|
];
|
|
|
|
|
|
const startDays = [20, 19, 21, 21, 21, 22, 23, 23, 23, 23, 22, 22];
|
2026-04-13 08:59:45 +08:00
|
|
|
|
final idx = month - 1;
|
2025-12-12 09:11:30 +08:00
|
|
|
|
if (day < startDays[idx]) {
|
|
|
|
|
|
return names[(idx + 11) % 12];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return names[idx];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
String _calcChineseZodiac(int year) {
|
2026-04-13 08:59:45 +08:00
|
|
|
|
const zodiacs = ['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪'];
|
2025-12-12 09:11:30 +08:00
|
|
|
|
final idx = (year - 1900) % 12;
|
|
|
|
|
|
final i = idx < 0 ? (idx + 12) % 12 : idx;
|
|
|
|
|
|
return zodiacs[i];
|
2026-04-13 08:59:45 +08:00
|
|
|
|
}
|