文章目录

  • 前言
  • 序言
  • 一、身份证号码介绍
  • 1.身份证号码组成
  • 2.省份证号码中的名词解释
  • 1.区域代码(地址码)
  • 2.生日
  • 3.顺序码
  • 4.校验码
  • 总结
  • 二、校验码计算
  • 1.公式拆解
  • 2.运算
  • 三、实现思路
  • 1.伪代码
  • 2.代码实现
  • 参考资料


前言

最近在工作中需要对用户的身份证号码进行强校验(严格校验),然后用于实名认证。看到这个需求时,我心想这还不简单,一个正则表达式就可以搞定了。但是想法很美好,现实很残酷,狠狠的抽了我一个大嘴巴子…

因此萌生了写下一篇博客,防止后面的童鞋和我一样被现实抽了个大大的嘴巴…

虽然不知道这篇博客会被多少人看到,总归能帮一个是一个🤪,犊子扯完了开始正文内容了;

序言

本文使用的JDK版本为1.8.0_291,IntelliJ IDEA版本为IntelliJ IDEA 2021.1.3 (Ultimate Edition) 内部版本号 #IU-211.7628.21,June 29, 2021 构建。

本文包含大量数学公式和Java代码,手机端浏览体验较差,建议在 PC 端或 PAD 端浏览。另外,本文参考了一些法律条文、中华人民共和国国家标注(GB、GB/T)以及 ISO/IEC 标准,感兴趣的童鞋可以从文末参考资料里面直达.

一、身份证号码介绍

根据最新修订的《中华人民共和国居民身份证法》第二十三条规定,依照《中华人民共和国居民身份证条例》领取的居民身份证,自2013年1月1日起停止使用。即一代身份证已于2013年1月1日起停止使用,但仍有部分人在使用一代证件,咱作为一个技术人员当然不能抛下这一小部分人啦。

1.身份证号码组成

在介绍身份证号码组成之前,我先放张图在下面,方便等下介绍时对照查看。

java 身份证OCR Tesseract OCR_名词解释


图 1-1 身份证号码组成

从图 1-1 可以看出不管是 15 位身份证号码还是 18 位身份证号码,它们都有区域代码也叫地址码和顺序码。而这两种证件号码不同的在于生日校验码

2.省份证号码中的名词解释

1.区域代码(地址码)

区域代码表示登记户口时(或出生时)所在地的行政区划代码,不随户籍所在地变动而改变,依照《中华人民共和国行政区划代码》国家标准(GB/T2260)的规定执行。

区域代码是身份证号码的 1-6 位,前两位表示省份(直辖市,自治区,特别行政区);第 3,4 位表示市(地区、自治州、盟),第 5,6 位表示县(自治县、县级市、旗、自治旗、市辖区、林区、特区)。例如北京市市辖区东城区的区域代码为:110101;
啰嗦一句,关于地址码笔者已经上传到了资源中可以点击区域简码或区域全码下载使用,文件格式为 json

2.生日

生日(出生日期码)表示编码对象(公民)出生的年、月、日,按国标GB/T 7408的规定执行,年、月、日代码之间不用分隔符。一代证件号码中表示生日时采用的是 yyMMdd 的格式,而在二代证件中则采用 yyyyMMdd 的格式,这里在做校验的时候要注意。

3.顺序码

顺序码表示在同一地址码所标识的区域范围内,对同年、同月、同日出生的人编定的顺序号,顺序码的奇数分配给男性,偶数分配给女性。细心的同学看到这里可能会想顺序码的最大容量是 1000,而区分男女后最大容量为 500,哪第 501 个人咋办?其实这个完全没必要担心(杠精同学请出门左转)在制定规则的时候已经根据我国的人口现状,各区县在以往历年的出生人口统计分析中确认,所有区县的一天同性别出生人口远远达不到500个之多。

4.校验码

校验码采用ISO 7064:1983,MOD 11-2校验算法对前面的地址码、出生日期码和顺序码进行校验,由于身份证号码的本体码(区域代码+生日+顺序码)太长(一代证为 15 位,二代证为 17 位),为了防止人工录入是将身份证号码输入错误引入了校验码。
校验码的计算公式如下:
java 身份证OCR Tesseract OCR_参考资料_02
具体计算逻辑下一章来聊聊。

总结

  1. 一代证和二代证都有地址码和顺序码。
  2. 一代证的生日是 yyMMdd 格式而二代证则是 yyyyMMdd 格式。
  3. 一代证没有校验码,二代证有校验码。

二、校验码计算

1.公式拆解

先拆解一下校验码计算公式:
java 身份证OCR Tesseract OCR_名词解释_03

  • i表示号码字符从右至左包括校验码字符在内的位置序号;
  • ai 表示第i位置上的号码字符值,如a1 是身份证号码第18位校验码;
  • Wi 表示i位置上的加权因子,加权因子计算公式:Wi = 2i-1 (mod 11);

公式的含义:加入校验码后,身份号码所有位置(包括校验码)上的数字乘以该位置上的加权因子再求和,再对11求模,其结果要等于1。

俗话说光说不练假把式,下面我们就用图 1-1 中的省份证号码来计算一下(PS:图里面的身份证号码是韦小宝的🤓)

2.运算

我这里为了使表格尽量好看些,序号 i 我使用两位表示,计算中不需要这么做!!!

  1. 根据加权因子计算公式计算加权因子

i

18

17

16

15

14

13

12

11

10

09

08

07

06

05

04

03

02

01

Wi

7

9

10

5

8

4

2

1

6

3

7

9

10

5

8

4

2

1

  1. 变换公式

已知 Wi = 2i-1 (mod 11),那么将 a1 * W1 从公式中提取出来,那么校验公式就变为:java 身份证OCR Tesseract OCR_校验码_04然后计算 W1 = 21-1 (mod 11) = 1。进而公式可以变为:java 身份证OCR Tesseract OCR_java_05

  1. 根据公式 2计算出 2-18 位的值并换算出校验码(a1)的值,就可以得到如下表的换算关系表:

i

0

1

2

3

4

5

6

7

8

9

10

a1

1

0

X

9

8

7

6

5

4

3

2

看到这个表肯定有不少童鞋要问,上面这个换算表是怎么来的?首先国标规定了校验码的值为 0-10(当 a1 =10时用罗马字符 X 表示); 然后以最右边的 10 举例说明一下:java 身份证OCR Tesseract OCR_名词解释_06
取值范围在 0-10 之间的只有 2,所以 10 换算后为 2;简而言之就是取值范围在 0-10 之间 a1 + 公式 2 的值 = 11*n+1.
4. 有了上面的准备工作下面可以正式尝试一下计算了

  • 列出对应数据表:

i

18

17

16

15

14

13

12

11

10

09

08

07

06

05

04

03

02

01

ai

1

1

2

0

4

4

1

6

5

4

1

2

2

0

2

4

3

a1

Wi

7

9

10

5

8

4

2

1

6

3

7

9

10

5

8

4

2

1

ai * Wi

7

9

20

0

32

16

2

6

30

12

7

18

20

0

16

16

6

a1

  • 对ai * Wi求和:
    java 身份证OCR Tesseract OCR_名词解释_07
  • 对11取模:
    java 身份证OCR Tesseract OCR_参考资料_08
  • 求出校验码字符值:
    java 身份证OCR Tesseract OCR_名词解释_09
    当 n=1 时,a1 = 2,满足取值范围在 [0,10];
    当 n=2 时,a1 = 12,不满足取值范围在 [0,10];
    因此 a1 = 2, 查表检验码为 X

三、实现思路

1.伪代码

通过上面对身份证号码的介绍可以发现,要对身份证号码进行校验要校验以下几点(伪代码先搞搞):

  1. 15 位身份证号码转化为 18 位(此处不考虑 1900 年及之前出生的人)
  2. 区域码与中华人民共和国国家标准 GB/T 2260-2007 中华人民共和国行政区划代码是否相匹配;
Map<String,String> map = getAreaCode();// 获取区域码与行政区域的关系集合
String areaCode = idNumber.substring(0,6);
if(map.get(areaCode) != null){
	return true;
}else{
	return false;
}
  1. 生日要符合规范
String year = idNumber.substring(6,10);
String mouth = idNumber.substring(10,12);
String day = idNumber.substring(12,14);
if(year+"-"+mouth+"-"+day>new Date()){
//这个人还没出生呢
	return false;
} else{
	if(年份差>150){
		// 哪里来老寿星  我没见过
		return false;
	}
	if(mouth>12||mouth<=0){
		// 什么夭寿数据 或许在地球外出生的吧
		return false;
	}
	if(day>31 || day<=0){
		//啊这... 大佬 你是哪个星球的?
		return false;
	}
	// 2 月份是个坑
	if(是闰年 && mouth==2 && day>29){
		// 又是个外星人
		return false;
	}else if(不是闰年 && mouth==2 && day>28){
		// 求放过  在接触外星人怕是要被拉去做实验了
		return false;
	}
}
  1. 通过本体码计算出的校验码要与身份证号码中的校验码相匹配

2.代码实现

代码里面写了很多注释,这里就不再赘述了,有问题可以随时联系我。
1.校验逻辑工具类VerifyUtil.java(这里面我使用异常去处理校验失败的情况,那些异常可换成return false;

package club.mooye.service.utils;

import club.mooye.service.enumeration.ErrorCodeEnum;
import club.mooye.service.enumeration.RegularExpressionEnum;
import club.mooye.service.exception.CustomException;
import club.mooye.service.resource.SystemStaticResource;
import lombok.extern.slf4j.Slf4j;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * <h1>VerifyUtil<h1>
 * <p>Copyright (C), 星期三,07,7月,2021,</p>
 * <br/>
 * <hr>
 * <h3>File Info:</h3>
 * <p>FileName: VerifyUtil</p>
 * <p>Author:   mooye</p>
 * <p>E-mail: curtainldy@163.com</p>
 * <p>Date:     2021/7/7</p>
 * <p>Description: 处理一些校验逻辑</p>
 * <hr>
 * <h3>History:</h3>
 * <hr>
 * <table>
 *  <thead>
 *  <tr><td style='width:100px;' center>作者姓名</td><td style='width:200px;' center>修改时间</td><td style='width:100px;' center>版本号</td><td style='width:100px;' center>描述</td></tr>
 *  </thead>
 *  <tbody>
 *    <tr><td style='width:100px;' center>mooye</td><td style='width:200px;' center>16:44 2021/7/7</td><td style='width:100px;' center>v_0.0.1</td><td style='width:100px;' center>创建</td></tr>
 *  </tbody>
 * </table>
 * <hr>
 * <br/>
 *
 * @author mooye
 */

@Slf4j
public class VerifyUtil {
    private static final int LONG = 18;
    private static final int SHORT = 15;
    private static final Pattern IS_NUMBER = Pattern.compile("[0-9]*");
    private static final Integer MAX_MONTH = 12;
    private static final Integer ARG_2 = 2;
    private static final Integer ARG_4 = 4;
    private static final Integer ARG_6 = 6;
    private static final Integer ARG_100 = 100;
    private static final Integer ARG_150 = 150;
    private static final Integer ARG_400 = 400;
    
    /**
     * 身份证号码合法性校验
     * @param idCardNumber 身份证号码
     * @return TRUE
     * @throws CustomException 不合法时抛出
     * @throws ParseException 字符串转换异常时抛出
     */
    public static boolean verifyCard(String idCardNumber) throws CustomException, ParseException {
        // 判断身份证位数是否为 15 or 18 位
        if (idCardNumber.length() != LONG && idCardNumber.length() != SHORT) {
            log.error("身份证号码长度不是 15/18 位!");
            throw new CustomException("身份证号码长度应该为15位或18位", ErrorCodeEnum.PARAM_ERROR.getCode());
        }
        // 15 位身份证号码转成 18位进行校验
        if (idCardNumber.length() == SHORT){
            idCardNumber = idCardNumberConversion(idCardNumber);
        }
        String idNo = idCardNumber.substring(0,LONG-1);
        
        if (!isNum(idNo)){
            log.error("省份证号码格式错误!");
            throw new CustomException("身份证号码格式错误!",ErrorCodeEnum.PARAM_ERROR.getCode());
        }
        // 校验生日
        String year = idNo.substring(ARG_6, 10);
        String month = idNo.substring(10, 12);
        String day = idNo.substring(12, 14);
        if (!verifyDate(year,month,day)){
            log.error("生日格式错误!");
            throw new CustomException("身份证号码格式错误!",ErrorCodeEnum.PARAM_ERROR.getCode());
        }
        // 校验地区码
        if (SystemStaticResource.areaCode().get(idNo.substring(0, ARG_6)) == null){
            log.info("地区码错误!");
            throw new CustomException("身份证号码格式错误!",ErrorCodeEnum.PARAM_ERROR.getCode());
        }
        // 判断校验码
        int theLastCode = 0;
        for (int i = 0; i < LONG-1; i++) {
            theLastCode += Integer.parseInt(String.valueOf(idNo.charAt(i))) * Integer.parseInt(SystemStaticResource.CHECK_CODE[i]);
        }
        int modValue = theLastCode % 11;
        String verifyCode = SystemStaticResource.WF[modValue];
        idNo = idNo + verifyCode;
        if (!Objects.equals(idCardNumber,idNo)){
            log.error("校验码不匹配!");
            throw new CustomException("身份证无效,不是合法的身份证号码",ErrorCodeEnum.PARAM_ERROR.getCode());
        }
        return true;
    }
    
    
    // ================================ 私有静态方法区 ================================ //
    /**
     * 15 位身份证号转 18 位身份证号 (此处不考虑 1900 年出生的老寿星)
     * @param idCardNumber 身份证号
     * @return 转换后的身份证号
     */
    private static String idCardNumberConversion(String idCardNumber) {
        idCardNumber = idCardNumber.substring(0,6) + "19" + idCardNumber.substring(6,SHORT);
        int theLastCode = 0;
        for (int i = 0; i < idCardNumber.length(); i++) {
            theLastCode += Integer.parseInt(String.valueOf(idCardNumber.charAt(i))) * Integer.parseInt(SystemStaticResource.CHECK_CODE[i]);
        }
        int modValue = theLastCode % 11;
        String verifyCode = SystemStaticResource.WF[modValue];
        idCardNumber = idCardNumber + verifyCode;
        return idCardNumber;
    }
    
    /**
     * 判断字符串是否为纯数字
     * @param idNo 字符串
     * @return TRUE | FALSE
     */
    private static boolean isNum(String idNo) {
        Matcher matcher = IS_NUMBER.matcher(idNo);
        return matcher.matches();
    }
    
    /**
     * 判断字符串是否满足指定的日期格式
     * @param idNo 字符串
     * @return TRUE | FALSE
     */
    private static boolean isDate(String idNo) {
        String[] strings = idNo.split("-");
        boolean flag = true;
        for (String string : strings) {
            flag = flag && isNum(string);
        }
        return flag;
    }
    
    /**
     * 校验生日合法性
     * @param year 生日-年
     * @param month 生日-月
     * @param day 生日-日
     * @return TRUE
     * @throws CustomException FALSE 时抛出
     * @throws ParseException String To Date 出现异常时抛出
     */
    public static boolean verifyDate(String year, String month, String day) throws CustomException, ParseException {
        String date = year + "-" + month + "-" + day;
        log.info("生日:{}", date);
        if (!isDate(date)){
            log.error("身份证生日无效!");
            throw new CustomException("身份证生日无效",ErrorCodeEnum.PARAM_ERROR.getCode());
        }
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
        long idCardTime = simpleDateFormat.parse(date).getTime();
        long nowTime = simpleDateFormat.parse(simpleDateFormat.format(new Date())).getTime();
        Calendar calendar = new GregorianCalendar();
        if (idCardTime-nowTime>0){
            log.error("出生日期超过当前日期!");
            throw new CustomException("身份证中的日期不合法",ErrorCodeEnum.PARAM_ERROR.getCode());
        }
        // 判读年份合法性
        if (calendar.get(Calendar.YEAR) - Integer.parseInt(year) > ARG_150){
            log.error("年份数据不合法!");
            throw new CustomException("身份证中的日期不合法",ErrorCodeEnum.PARAM_ERROR.getCode());
        }
        // 判断月份合法性
        if (Integer.parseInt(month)> MAX_MONTH || Integer.parseInt(month) == 0){
            log.error("月份数据不合法!");
            throw new CustomException("身份证中的日期不合法",ErrorCodeEnum.PARAM_ERROR.getCode());
        }
        // 判断日数据,注意 2 月份
        boolean flag1 = Integer.parseInt(day) > 31 || Integer.parseInt(day) <=0;
        boolean flag2 = isLeapYear(Integer.parseInt(year)) && Integer.parseInt(month) ==2 && Integer.parseInt(day) > 29;
        boolean flag3 = !isLeapYear(Integer.parseInt(year)) && Integer.parseInt(month) ==2 && Integer.parseInt(day) > 28;
        if (flag1 || flag2 || flag3 ){
            log.error("日期数据不合法!");
            throw new CustomException("身份证中的日期不合法",ErrorCodeEnum.PARAM_ERROR.getCode());
        }
        return true;
    }
    
    /**
     * 判断是否为闰年
     * @param year 年份
     * @return TRUE | FALSE
     */
    private static boolean isLeapYear(int year){
        boolean flag = false;
        if (year % ARG_4 == 0 && year % ARG_100 != 0){
            flag = true;
        }else if (year % ARG_400 == 0 ){
            flag = true;
        }
        return flag;
    }
}

2.静态资源加载类SystemStaticResource.java

package club.mooye.service.resource;

import club.mooye.service.enumeration.ErrorCodeEnum;
import club.mooye.service.exception.CustomException;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import io.swagger.annotations.ApiModelProperty;

import java.io.*;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

/**
 * <h1>SystemStaticResource<h1>
 * <p>Copyright (C), 星期三,07,7月,2021,</p>
 * <br/>
 * <hr>
 * <h3>File Info:</h3>
 * <p>FileName: SystemStaticResource</p>
 * <p>Author:   mooye</p>
 * <p>E-mail: curtainldy@163.com</p>
 * <p>Date:     2021/7/7</p>
 * <p>Description: 系统静态资源</p>
 * <hr>
 * <h3>History:</h3>
 * <hr>
 * <table>
 *  <thead>
 *  <tr><td style='width:100px;' center>作者姓名</td><td style='width:200px;' center>修改时间</td><td style='width:100px;' center>版本号</td><td style='width:100px;' center>描述</td></tr>
 *  </thead>
 *  <tbody>
 *    <tr><td style='width:100px;' center>mooye</td><td style='width:200px;' center>16:34 2021/7/7</td><td style='width:100px;' center>v_0.0.1</td><td style='width:100px;' center>创建</td></tr>
 *  </tbody>
 * </table>
 * <hr>
 * <br/>
 *
 * @author mooye
 */

public class SystemStaticResource {
    /**
     * 校验码
     */
    @ApiModelProperty(value = "校验码",notes = "身份证校验")
    public static final String[] WF = { "1", "0", "X", "9", "8", "7", "6", "5", "4", "3", "2" };
    /**
     * 加权因子常数
     */
    @ApiModelProperty(value = "加权因子常数",notes = "身份证校验")
    public static final String[] CHECK_CODE = { "7", "9", "10", "5", "8", "4", "2", "1", "6", "3", "7", "9", "10", "5", "8", "4", "2" };
    /**
     * 加载区域代码和行政区域关系
     * @return string
     */
    private static String readerJsonFile() throws CustomException {
        String areaCode = null;
        String path =
                Optional.ofNullable(SystemStaticResource.class.getClassLoader().getResource("templates/json/areaCode.json")).orElseThrow(
                        ()->new CustomException(ErrorCodeEnum.FILE_NOT_FIND.getMessage(),ErrorCodeEnum.FILE_NOT_FIND.getCode())
                ).getPath();
        try (Reader reader = new InputStreamReader(new FileInputStream(path))) {
            int chore = 0;
            StringBuilder buffer = new StringBuilder();
            while ((chore = reader.read()) != -1) {
                buffer.append((char) chore);
            }
             areaCode = buffer.toString();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return areaCode;
    }
    
    /**
     * 单位年转化为单位月
     */
    @ApiModelProperty("单位年转化为单位月")
    public static final int YEAR_TO_MOUTH = 12;
    /**
     * 单位周转化为单位日
     */
    @ApiModelProperty("单位周转化为单位日")
    public static final int WEEK_TO_DAY = 7;
    /**
     * 单位日转化为单位时
     */
    @ApiModelProperty("单位日转化为单位时")
    public static final int DAY_TO_HOUR = 24;
    /**
     * 单位时转化为单位分钟
     */
    @ApiModelProperty("单位时转化为单位分钟")
    public static final int HOUR_TO_MINUTE = 60;
    /**
     * 单位分转化为单位秒
     */
    @ApiModelProperty("单位分转化为单位秒")
    public static final int MINUTE_TO_SECOND = 60;
    /**
     * 单位秒转化为单位毫秒
     */
    @ApiModelProperty("单位秒转化为单位毫秒")
    public static final int SECOND_TO_MILLI_SECOND = 1000;
    
    /**
     * 地区码
     * @return 地区码集合
     */
    public static Map<String,String> areaCode() throws CustomException {
        String areaCode = readerJsonFile();
        if (areaCode == null){
            throw new CustomException(ErrorCodeEnum.PARAM_NULL.getMessage(),ErrorCodeEnum.PARAM_NULL.getCode());
        }
        Map<String,String> map = new HashMap<>(16);
        JSONArray array = JSONObject.parseObject(areaCode).getJSONArray("areaCode");
        array.forEach(o -> {
            JSONObject json = JSONObject.parseObject(o.toString());
            map.put(json.getString("code"),json.getString("area"));
        });
        return map;
    }
}

参考资料

  1. 中华人民共和国居民身份证法
  2. 中华人民共和国国家标准 GB/T 2260-2007 中华人民共和国行政区划代码
  3. 中华人民共和国国家标准 GB 11643-1999 公民身份证号码
  4. 中华人民共和国国家标准 GB/T 17710-2008 信息技术 安全技术 校验字符系统
  5. ISO/IEC 7064:2003 Information technology - Security techniques - Check character systems