身份证号码缩小方案

前言#

最近拿到了比较心怡的 offer, 苏州某旅游集团的, 感觉对自己的成长较大, 还是相对满意的

当然了, 成长大, 其实也是挑战大, 所以以后要更加的努力了, 加油

本篇文章是水群里有人问怎么把身份证号(18位)压缩成更少的位数, 群里大佬真的牛批, 说能压到6位, 并大致简述了一下思路, 我觉得还是可行的. 于是自己抄袭思路实现了一下功能, 测试了一下, 可以压缩到6-7位, 应该有更好的压缩方法, 这里只是记录一下我自己的版本

局限#

这里我想提前说的是, 在真正的开发中, 极少数需要对身份证号码进行压缩的, 因为节省了空间, 带来的是很差的可读性, 而且对于一些数据筛选更是困难, 这里也仅仅是做参考和示例使用

思路#

号码结构#

对于身份证号码, 百度上有很多组成部分的解析, 这里摘抄一下百度百科公民身份证号码_百度百科 (baidu.com)

1.号码的结构

公民身份号码是特征组合码,由十七位数字本体码和一位校验码组成。排列顺序从左至右依次为:六位数字地址码,八位数字出生日期码,三位数字顺序码和一位数字校验码。

2.地址码

表示编码对象常住户口所在县(市、旗、区)的行政区划代码,按GB/T2260的规定执行。

3.出生日期码

表示编码对象出生的年、月、日,按GB/T7408的规定执行,年、月、日代码之间不用分隔符。

4.顺序码

表示在同一地址码所标识的区域范围内,对同年、同月、同日出生的人编定的顺序号,顺序码的奇数分配给男性,偶数分配给女性。

5.校验码

根据前面十七位数字码,按照ISO 7064:1983.MOD 11-2校验码计算出来的检验码。

以身份证号码130424200208110128 来讲, 其分为几部分:

  • 130424: 地址码
  • 20020811: 出生年月日
  • 012: 出生顺序和性别
  • 8: 校验码

地址码#

我们一步步来尝试压缩, 首先是地址码, 地址码在身份证号码中占据了6位, 地址码既然是标记了出生地, 那么就不可能是随机的, 其实中国很大, 我相信地址码也不会有999999个, 于是我查询了相关资料, 找到了民政部的公开资料www.mca.gov.cn/article/sj/xzqh/2020/20201201.html, 稍微查看了一下有不超过4000个, 因此, 如果我们自己设置一个映射关系, 比如将地址码654202在数据库中设置一个唯一 id, 因为总共不超过4000个, 因此 id 最长也4位, 这样就可以减少2位

出生日期码#

出生日期, 在身份证中占据了6位, 分别是YYYYMMDD, 这个怎么去优化呢? 有大佬认为可以将年的前两位分为2种19/20, 然后月的第一位只有两种0/1, 日的第一位只有0/1/2/3, 这几个可以按位存储

我认为, 这样比较复杂, 而且19/20范围太小了, 我的思路是, 将其按照天数存储, 例如, 19000102可以存储为0000001, 即存储从1900年0101开始到该用户出生日期的天数, 设计为最长7位, 我测试了一下, 支持到8888年也没有问题, 这样极大的提高了兼容性, 这里减少1位

顺序码#

顺序码, 记录了顺序和性别, 思考了一下, 感觉这里无从下手, 于是顺序码没有进行简化

校验码#

校验码本身占用一位, 但是校验码我查看了一下, 是通过前17位数字计算得来的, 计算规则为身份证校验码计算公式及实例计算-百度经验 (baidu.com):

第一步:将身份证号码的第1位数字与7相乘;将身份证号码的第2位数字与9相乘;将身份证号码的第3位数字与10相乘;将身份证号码的第4位数字与5相乘;将身份证号码的第5位数字与8相乘;将身份证号码的第6位数字与4相乘;将身份证号码的第7位数字与2相乘;将身份证号码的第8位数字与1相乘;将身份证号码的第9位数字与6相乘;将身份证号码的第10位数字与3相乘;将身份证号码的第11位数字与7相乘;将身份证号码的第12位数字与9相乘;将身份证号码的第13位数字与10相乘;将身份证号码的第14位数字与5相乘;将身份证号码的第15位数字与8相乘;将身份证号码的第16位数字与4相乘;将身份证号码的第17位数字与2相乘。

第二步:将第一步身份证号码1~17位相乘的结果求和,全部加起来。

第三步:用第二步计算出来的结果除以11,这样就会出现余数为0,余数为1,余数为2,余数为3,余数为4,余数为5,余数为6,余数为7,余数为8,余数为9,余数为10共11种可能性。

第四步:如果余数为0,那对应的最后一位身份证的号码为1;如果余数为1,那对应的最后一位身份证的号码为0;如果余数为2,那对应的最后一位身份证的号码为X;如果余数为3,那对应的最后一位身份证的号码为9;如果余数为4,那对应的最后一位身份证的号码为8;如果余数为5,那对应的最后一位身份证的号码为7;如果余数为6,那对应的最后一位身份证的号码为6;如果余数为7,那对应的最后一位身份证的号码为5;如果余数为8,那对应的最后一位身份证的号码为4;如果余数为9,那对应的最后一位身份证的号码为3;如果余数为10,那对应的最后一位身份证的号码为2。

既然是计算的, 那么这里的校验位存储时可以舍去, 等还原时再进行计算和填充, 这里减少1位

进制转换#

走到这里, 我们发现, 只减少了4位, 剩下14位, 还要怎么压缩呢? 这里使用大杀器, 进制转换

众所周知, 从低进制向高进制进行转换, 可以减少位数, 因为高进制每一位可以表示更多种数据可能, 这里不考虑数据库等等的兼容性, 直接使用 ascii 的所有可见字符, 94个, 也就是说将身份证号码转换为94进制, 达到压缩的目的

代码#

当然是写完代码才有的这一个博客输出的喽,

地址码#

地址码这里, 虽然翻看了 github 上有别人抓好的数据, 但是看更新日志有些年头了, 于是我自己写了一个 py 脚本, 进行数据抓取并保存在本地的 sqlite 文件中, py 文件如下(脚本依赖 python3)

依赖如下:

beautifulsoup4==4.10.0
requests==2.27.0

脚本代码如下:

import code
import logging
import sqlite3

import requests
from bs4 import BeautifulSoup

# 中华人民共和国民政部官网->民政数据->行政区划代码
mca_url = "http://www.mca.gov.cn/article/sj/xzqh/2020/20201201.html"

logger = logging.getLogger()
logger.setLevel(logging.DEBUG)

def get_response():
    # 获取行政区划网页数据
    response = requests.get(url=mca_url)
    if response.status_code != 200:
        logger.warning("获取数据时出现问题, 网页返回状态码: %s" % response.status_code)
        return
    return response.content

def parse_response(content: str):
    # 解析response
    soup = BeautifulSoup(content, "html.parser")
    tab = soup.find(name="table")
    if not tab:
        logger.warning("解析失败, 没有找到 table 元素")
        return
    trs = tab.find_all(name="tr")
    if len(trs) == 0:
        logger.warning("解析失败, 没有找到 tr 元素")
        return
    codes = []
    for i in range(len(trs)):
        if i <= 2:
            # 前3行是标题
            continue
        tds = trs[i].find_all(name="td")
        if len(tds) < 2:
            continue
        code = tds[1].get_text().strip()  # 真实的区域码 
        if not code:
            continue
        code = int(code)
        name = tds[2].get_text().strip()  # 区域名
        id = i-2  # id
        codes.append((id, code, name))
    return codes


def insert_db(codes: list):
    con = sqlite3.connect("area.sqlite")
    cur = con.cursor()
    sql = """
        CREATE TABLE "area" (
        "id" INTEGER NOT NULL,
        "code" integer,
        "name" TEXT,
        PRIMARY KEY ("id")
        );
    """
    cur.execute(sql)
    sql = """
        CREATE UNIQUE INDEX "code"
        ON "area" (
        "code"
        );
    """
    cur.execute(sql)
    cur.executemany('INSERT INTO area VALUES (?,?,?)', codes)
    con.commit()
    # 关闭游标
    cur.close()
    # 断开数据库连接
    con.close()


def main():
    content = get_response()
    codes = parse_response(content)
    insert_db(codes)


if __name__ == "__main__":
    main()

简单的爬虫脚本, 但是网页不规范(ZF 网站通病), 导致做了很多的兼容处理

执行完成后, 会在本地生成area.sqlite文件

而我们使用 go 进行 sqlite 文件的读取, 纯纯的读取而不是修改和插入, 对于读取, 需要有两个方法, 当压缩时, 我们需要通过地址码来获取对应的 id, 在解压时, 需要通过 id 获取对应的地址码, 下面是area.go:

package main

import (
	"database/sql"
	"fmt"

	_ "github.com/mattn/go-sqlite3"
)

// sqlite
type SqliteConn struct {
	file string
	conn *sql.DB
}

// 初始化
func InitSqlite(file string) (*SqliteConn, error) {
	return &SqliteConn{
		file: file,
	}, nil
}

// 连接
func (c *SqliteConn) Conn() error {
	db, err := sql.Open("sqlite3", c.file)
	if err != nil {
		return err
	}
	c.conn = db
	return nil
}

// 关闭连接
func (c *SqliteConn) Close() {
	c.conn.Close()
}

// 获取 code
func (c *SqliteConn) GetCode(id int) (int, error) {
	code := 0
	sql := "SELECT code FROM area WHERE id=?"
	row := c.conn.QueryRow(sql, id)
	if row.Err() != nil {
		return code, fmt.Errorf("未查询到数据")
	}
	if err := row.Scan(&code); err != nil {
		return code, fmt.Errorf("数据解析错误")
	}
	return code, nil
}

// 获取 ID
func (c *SqliteConn) GetID(code int) (int, error) {
	id := 0
	sql := "SELECT id FROM area WHERE code=?"
	row := c.conn.QueryRow(sql, code)
	if row.Err() != nil {
		return id, fmt.Errorf("未查询到数据")
	}
	if err := row.Scan(&id); err != nil {
		return id, fmt.Errorf("数据解析错误")
	}
	return id, nil
}

出生日期码#

出生日期码, 也是两个方法, 一个是将日期转成从19700101到指定日期的天数, 一个是根据天数算出日期

这里需要注意的是, 使用Sub方法, 不能进行很长的时间间隔计算, 其文档所述Sub returns the duration t-u. If the result exceeds the maximum (or minimum) value that can be stored in a Duration, the maximum (or minimum) duration will be returned. To compute t-d for a duration d, use t.Add(-d)., 所以这里按照250年进行切分, 解决日期差距过长导致的计算错误

time2.go文件如下:

package main

import (
	"fmt"
	"strconv"
	"time"
)

func time2Day(startTime, endTime time.Time) int {
	// 因为官方的 time.Add/Sub 最多只能29X 年, 所以自己实现一个, 250年一个循环
	maxYear := 250
	day := 0
	for {
		middleTime := startTime.AddDate(maxYear, 0, 0)
		if endTime.Sub(middleTime).Hours() > 0 {
			// 还没有到
			day += int(middleTime.Sub(startTime).Hours()) / 24
			startTime = middleTime
		} else {
			// 到了
			day += int(endTime.Sub(startTime).Hours()) / 24
			break
		}
	}
	return day
}

func Time2Day(Y, M, D string) (int, error) {
	// 转换时间
	// 将时间转换成从1900年1月1日之后的天数
	eTm, err := time.Parse("2006-01-02", fmt.Sprintf("%v-%v-%v", Y, M, D))
	if err != nil {
		return 0, err
	}
	sTm, err := time.Parse("2006-01-02", "1900-01-01")
	if err != nil {
		return 0, err
	}
	subD := time2Day(sTm, eTm)
	if subD > 9999999 { // 防止特别大做的天数限制
		return 0, fmt.Errorf("year exceeding the limit")
	}
	return subD, nil
}

func Day2Time(d string) (string, error) {
	// 转换时间
	// 从1900年1月1日之后的天数转换成时间
	sTm, err := time.Parse("2006-01-02", "1900-01-01")
	if err != nil {
		return "", err
	}
	subD, _ := strconv.Atoi(d)
	nTm := sTm.AddDate(0, 0, subD)
	snTm := fmt.Sprintf("%v%v%v", nTm.Year(), func(ntm time.Time) string {
		if int(nTm.Month()) < 10 {  // 对1位月份兼容
			return fmt.Sprintf("0%v", int(ntm.Month()))
		}
		return fmt.Sprintf("%v", int(ntm.Month()))
	}(nTm), func(ntm time.Time) string {
		if nTm.Day() < 10 {  // 对1位日期做兼容
			return fmt.Sprintf("0%v", ntm.Day())
		}
		return fmt.Sprintf("%v", ntm.Day())
	}(nTm))
	return snTm, nil
}

校验码计算#

校验码计算按照上面的百度说的方式, 先进行相乘, 然后进行整除, 求余数进行对应

validate.go代码如下

package main

import "fmt"

var validate = [11]string{"1", "0", "X", "9", "8", "7", "6", "5", "4", "3", "2"}

var base = [17]int{7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2}

func CalculteValidateCode(IDCard string) (string, error) {
	validateCode := ""
	count := 0
	if len(IDCard) != len(base) {
		return validateCode, fmt.Errorf("not find on base")
	}
	for i, v := range IDCard {
		bv := base[i]
		count += bv * int(v-48)
	}
	return validate[count%11], nil
}

这里有趣的是, 利用遍历字符串, 每个元素是其ASCII的编号的特征, 使其-48来计算得出真实的数字

进制转换#

go 虽然本身自带了进制转换, 但是最高支持32位, 并不支持94位, 其实进制转换超过32位也不常见, 据我所知只有短网址生成需要64进制, 这里只能自己实现一个

package main

import (
	"fmt"
	"math"
	"strings"
)

// 拼接 ascii 字符串
func initAsciiString(start, end int) (string, error) {
	compare := ""
	if start > end {
		return compare, fmt.Errorf("error")
	}
	for i := start; i <= end; i++ {
		compare = compare + fmt.Sprintf("%v", string(rune(i)))
	}
	return compare, nil
}

type Conver struct {
	asciiString string
	len         int
}

func InitConver(asciiString string) (*Conver, error) {
	return &Conver{
		asciiString: asciiString,
		len:         len(asciiString),
	}, nil
}

// 十进制转任意进制
func (c *Conver) Num2BHex(num int) string {
	num_str := ""
	for num != 0 {
		yu := num % c.len
		num_str = string(c.asciiString[yu]) + num_str
		num = num / c.len
	}
	return num_str
}

// 任意进制转十进制
func (c *Conver) BHex2Num(str string) int {
	v := 0.0
	length := len(str)
	for i := 0; i < length; i++ {
		s := string(str[i])
		index := strings.Index(c.asciiString, s)
		v += float64(index) * math.Pow(float64(c.len), float64(length-1-i)) // 倒序
	}
	return int(v)
}

压缩#

为了一定程度的规范代码, 我会将身份证先进行分割, 保存到结构体中, 压缩的主要流程, 在complex2simple.go

在编写代码中, 遇到过两个问题:

地区对应编号可能不足4位

比如说编号110101, 对应的是北京市东城区, 他的 id 是2, 但是 id 最长有可能是4位, 不足的话在前面填充0又感觉麻烦(比如2变成0002), 于是查看了一下目前的数据为3209条, 将 id 存储时变为5000-id, 这样保证永远占用4位, 解码时记得处理即可

压缩日期这里, 有可能不足7位

对于比较小的日期, 算出来天数, 有可能不足7位, 如果在前加入0填充, 又稍微麻烦点, 设置最高值也可以, 但是有一种办法更省力, 那就是把日期挪到最后, 地区确定4位, 顺序码3位, 剩下的全部都是天数

package main

import (
	"fmt"
	"strconv"
)

type IDCardSplit struct {
	AreaCode string
	Year     string
	Month    string
	Day      string
	Order    string
	Validate string
}

// 切分身份证
func splitID(ID string) (IDCardSplit, error) {
	// 切分原始身份证, 剥离出地区码和校验码
	IDS := IDCardSplit{}
	for i, v := range ID {
		switch {
		case i <= 5:
			IDS.AreaCode += string(v)
		case i > 5 && i <= 9:
			IDS.Year += string(v)
		case i > 9 && i <= 11:
			IDS.Month += string(v)
		case i > 11 && i <= 13:
			IDS.Day += string(v)
		case i > 13 && i <= 16:
			IDS.Order += string(v)
		case i == 17:
			IDS.Validate = string(v)
		}
	}
	return IDS, nil
}

func Complex2Simple(IDC string) (string, error) {
	sID, _ := splitID(IDC)
	simpleSID := ""

	// 初始化 sqlite 数据库
	con, err := InitSqlite("area.sqlite")
	if err != nil {
		fmt.Println(err)
		return simpleSID, err
	}
	// 连接数据库
	if err := con.Conn(); err != nil {
		fmt.Println(err)
		return simpleSID, err
	}
	defer con.Close()
	// 转换地区码为 int
	areaCode, err := strconv.Atoi(sID.AreaCode)
	if err != nil {
		fmt.Println(err)
		return simpleSID, err
	}
	// 获取 areaID
	areaID, err := con.GetID(areaCode)
	if err != nil {
		fmt.Println(err)
		return simpleSID, err
	}
	d, err := Time2Day(sID.Year, sID.Month, sID.Day)
	if err != nil {
		return simpleSID, err
	}
	simpleSID = fmt.Sprintf("%v%v%v", maxAreaID-areaID, sID.Order, d)
	simpleID, _ := strconv.Atoi(simpleSID)
	asciiString, err := initAsciiString(33, 126) // 所有可见 ascii, 94个
	if err != nil {
		fmt.Println(err)
		return simpleSID, err
	}
	conver, _ := InitConver(asciiString)
	s := conver.Num2BHex(simpleID)
	return s, nil
}

解压缩#

与压缩基本相反, 需要注意的是顺序发生了调整, 另外, 在通过 id 获取地区码时, 注意要先使用5000-id得出真正的 id

simple2complex.go

package main

import (
	"fmt"
	"strconv"
)

type IDCardSimpleSplit struct {
	AreaID string
	SubDay string
	Order  string
}

// 切分simple 字符串
func splitSimple(simple string) (IDCardSimpleSplit, error) {
	// 切分 simple
	IDS := IDCardSimpleSplit{}
	for i, v := range simple {
		switch {
		case i <= 3:
			IDS.AreaID += string(v)
		case i > 3 && i <= 6:
			IDS.Order += string(v)
		case i > 6:
			IDS.SubDay += string(v)
		}
	}
	return IDS, nil
}

func Simple2Complex(simpleID string) (string, error) {
	complexID := ""
	asciiString, err := initAsciiString(33, 126) // 所有可见 ascii, 94个
	if err != nil {
		fmt.Println(err)
		return complexID, err
	}
	conver, _ := InitConver(asciiString)
	s := conver.BHex2Num(simpleID)
	sID, err := splitSimple(fmt.Sprintf("%v", s))
	if err != nil {
		fmt.Println(err)
		return complexID, err
	}
	// 初始化 sqlite 数据库
	con, err := InitSqlite("area.sqlite")
	if err != nil {
		fmt.Println(err)
		return complexID, err
	}
	// 连接数据库
	if err := con.Conn(); err != nil {
		fmt.Println(err)
		return complexID, err
	}
	defer con.Close()
	// 转换地区码为 int
	areaSID, err := strconv.Atoi(sID.AreaID)
	if err != nil {
		fmt.Println(err)
		return complexID, err
	}
	// 获取 areaID
	areaID, err := con.GetCode(maxAreaID - areaSID)
	if err != nil {
		fmt.Println(err)
		return complexID, err
	}
	snTm, err := Day2Time(sID.SubDay)
	if err != nil {
		return complexID, err
	}
	complexID = fmt.Sprintf("%v%v%v", areaID, snTm, sID.Order)
	validate, _ := CalculteValidateCode(complexID)
	complexID = complexID + validate
	return complexID, nil
}

测试#

main.go如下

package main

import (
	"fmt"

	_ "github.com/mattn/go-sqlite3"
)

var maxAreaID = 5000

func main() {
	IDC := "411503211208110123" // 原始的身份证号码
	simple, err := Complex2Simple(IDC)
	fmt.Println(err)
	fmt.Println(simple)
	c, err := Simple2Complex(simple)
	fmt.Println(err)
	fmt.Println(c)
}

运行

➜  task_test go run .
<nil>
NtnBRM
<nil>
411503211208110123

写在最后#

虽然想法比较简单, 但是实现起来还是有些复杂的, 包括日期的转换, 进制计算这些, 也算是受益良多

下次实现一个64进制的短网址 DEMO 吧

作者:chnmig

出处:https://www.cnblogs.com/chnmig/p/16744193.html

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

posted @   ChnMig  阅读(266)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 零经验选手,Compose 一天开发一款小游戏!
· 一起来玩mcp_server_sqlite,让AI帮你做增删改查!!
点击右上角即可分享
微信分享提示
more_horiz
keyboard_arrow_up dark_mode palette
选择主题
menu