简介面向前端开发者的银行卡BIN码对照数据集以JSON键值对组织可直接嵌入表单校验或支付页面解决用户输入卡号时无法即时识别发卡行、卡类型与币种的问题。压缩包内共1个json文件体积约33KB以六位BIN码为键对应值记录银行名称、卡类型、货币等字段结构扁平、解析成本低便于按需裁剪或导入本地缓存查询。已有1753人学习下载。读者可据此实现输入六位数字即回显发卡行信息、按卡类型分流校验逻辑以及查不到匹配项时的兜底提示同时获得一份可直接对照的银行清单用于测试用例构造与数据更新维护。数据仅含发卡机构标识不涉及敏感信息适合在HTTPS通道下加载使用。1. 一份 BIN 码 JSON 能做什么从卡号前 8 位读出四件事风控同学丢过来一个 CSV十万行卡号要求半小时内标出每行属于哪家发卡行、是借记卡还是信用卡、卡号有没有录错。没有外网接口没有生产库权限手上唯一能给的就是一份 JSON 格式的银行卡 BIN 码表。这种场景下BIN 表的价值就体现出来了它本质上是一张前缀字典把卡号开头 6 到 8 位数字映射到发卡行、卡组织、卡类型、国家与币种本地查表、毫秒级返回不依赖任何外部服务。它解决的是「拿到一串数字先判断它像不像一张正常的卡」这件事。支付路由要知道走哪条通道对账系统要按发卡行分组注册表单要在提交前拦住明显乱填的卡号测试同学要批量造合规的测试数据——这些活儿都能用同一份表。写这篇东西的起因是身边不少同行把 BIN 表当成一个黑匣子slice(0, 6)一把梭结果 19 位卡号识别不出来、同一张卡今天能过明天不能过。第 2 章先把判定边界讲清楚后面几章落到代码、参数和踩过的坑。2. 判定边界6 位还是 8 位模 10 校验到底能证明什么2.1 从 ISO/IEC 7812 看 BIN 在卡号里的真实位置卡号PAN不是一串随机数字它的结构由 ISO/IEC 7812 系列标准定义从高位到低位大致分四段。第一段是主行业标识MII占 1 位用来区分航空、银行金融、商业金融、医疗等大类第二段是发卡行识别码也就是大家嘴里的 BIN历史上固定 6 位后来扩展到最多 8 位标准里的正式叫法是 IIN第三段是个人账户标识长度由发卡行自己定最后一位是校验位参与模 10 校验。段位长度含义常见取值MII1 位主行业标识4/5/6 多为银行金融类IINBIN68 位发卡行识别码含 MII 在内的前 68 位个人账户标识变长由发卡行分配决定总长度校验位1 位模 10 校验结果09这张表解释了一个常见困惑为什么同一份 JSON 里bin字段有的 6 位、有的 8 位。BIN 从 6 位向 8 位迁移是一个持续多年的过程不同数据源、不同采集时间的记录混在一起很正常。你手上的表如果是近几年整理的大概率 6 位和 8 位并存绝对不能假设长度统一。再补一个容易忽略的点PAN 总长度标准允许 8 到 19 位市面上常见的是 13、15、16、19 位。有些老系统硬编码len 16遇到 19 位卡直接判非法这是后面第 5 章要展开的一个坑。2.2 6 位与 8 位混用错在哪一步假设某家发卡行历史上拿到的是 6 位前缀620000后来把它拆成两个 8 位子段62000001和62000002分别对应借记和信用两个产品线。如果你的查表逻辑永远只取前 6 位这两条记录在哈希表里会互相覆盖谁最后写入谁生效结果就是同一批卡里借记卡被标成信用卡。反过来也出错。假如数据源只提供 6 位记录而你的代码按 8 位精确匹配table.get(pan[:8])会全部落空十万行数据跑出来全是「未知 BIN」排查半天才发现是宽度不匹配。正确的做法是降级匹配先按 8 位查查不到退到 7 位再退到 6 位并且把命中的宽度一块返回。宽度本身携带信息量——8 位命中说明数据源覆盖到了细粒度号段6 位命中只能说明大方向对置信度要打折。我在风控侧一般会给 8 位命中打满分、7 位次之、6 位再降一档具体权重看业务容忍度。提示降级顺序必须是长到短。反过来先查 6 位一旦命中就返回就等于主动放弃了 8 位记录能提供的区分度。2.3 模 10 校验的证明力它只证明「不像手滑打错的」Luhn 模 10 校验是所有卡号校验里最容易被高估的一环。算法本身不复杂从最右位往左偶数位从右数第 2、4、6… 位乘 2乘积大于 9 就减 9全部相加总和能被 10 整除即通过。它挡住的是单字符录入错误和相邻位颠倒大约能过滤掉七成的随机手滑。但它挡不住伪造——随便凑一串满足校验位的数字在算法层面完全合法。所以校验通过只能说明「这串数字不像打错的」不能说明这张卡真实存在、没有挂失、额度够用。反过来校验不通过也不一定是假卡某些内部测试号段、部分非标准发卡体系就不遵循这套规则。工程上的结论很直接把模 10 当成格式校验的一环和长度检查、BIN 匹配并列但不要把它当成真实性验证。字段设计里我一般会带一个luhn布尔值因为少数号段在数据源里明确标注不走校验硬套算法会误杀。2.4 一份能落库的 BIN 记录该长什么样原始 JSON 的字段命名各家不同card_type/type、bank/issuer、brand/network混着来。加载时做一次字段归一比在业务代码里到处做兼容要省事得多。字段类型必填说明binstring是68 位纯数字保留前导零brandstring否卡组织标识统一大写typestring否credit / debit / prepaid / charge / unknownissuerstring否发卡行名称仅作展示不参与判定countrystring否两位地区代码占位用pan_lengthstuple是该 BIN 允许的卡号长度集合luhnbool是是否参与模 10 校验bin_start / bin_endstring否区间式数据源才有用于二分查找pan_lengths这个字段值得单说。它让长度校验从「全局允许 8 到 19 位」收紧成「这个 BIN 只允许 16 位或 19 位」误判率会明显下降。如果数据源没给退化成默认集合(16,)也比不校验强只是要接受一定的漏判。3. 把 JSON 装进内存字段清洗、前缀索引与降级匹配3.1 读取先兼容外层结构再谈性能同一份「JSON 格式 BIN 大全」在不同来源里可能有三种外层结构裸数组、{data: [...]}、以 BIN 为键的对象。直接json.load然后按下标取遇到结构不符就是一场空。第一步做结构探测和字段归一。import json from typing import Any, Dict, Optional BIN_MIN, BIN_MAX 6, 8 def _norm_bin(value: Any) - Optional[str]: 把各种写法的 BIN 收敛成 6~8 位纯数字字符串 if value is None: return None s str(value).strip().replace( , ).replace(-, ) if not s.isdigit() or not (BIN_MIN len(s) BIN_MAX): return None return s # 注意不要转 int前导零会丢 def load_bin_table(path: str) - Dict[str, dict]: with open(path, r, encodingutf-8) as f: raw json.load(f) # 兼容三种常见外层裸数组 / {data: [...]} / {bins: {...}} if isinstance(raw, dict) and isinstance(raw.get(data), list): records raw[data] elif isinstance(raw, dict) and isinstance(raw.get(bins), dict): records [{bin: k, **v} for k, v in raw[bins].items()] else: records raw table: Dict[str, dict] {} dropped 0 for rec in records: code _norm_bin(rec.get(bin)) if code is None: dropped 1 continue table[code] { bin: code, brand: str(rec.get(brand) or rec.get(network) or ).upper(), type: str(rec.get(type) or rec.get(card_type) or unknown).lower(), issuer: rec.get(issuer) or rec.get(bank) or , country: rec.get(country) or , pan_lengths: tuple(rec.get(pan_lengths) or (16,)), luhn: bool(rec.get(luhn, True)), } if dropped: print(f[warn] 丢弃 {dropped} 条非法 BIN 记录) return table几个参数要交代清楚。_norm_bin里坚持返回字符串而不是整数是因为 BIN 可以以 0 开头MII 为 0 属于标准分配段转成int会丢掉前导零后续pan.startswith(bin)永远失败。pan_lengths统一转成 tuple 而不是 listtuple 可哈希方便后面做缓存 key。dropped计数不是摆设它是数据质量的第一道观测口——如果一份表加载完丢弃率超过 1%大概率是字段名对不上或者混进了区间格式的记录。性能方面十万条以内的表用内置json.load完全够单次加载几百毫秒量级。真正需要注意的是解析后的内存膨胀JSON 文本 20MB转成 Python 字典嵌套结构后占用往往是文本体积的 5 到 10 倍。内存紧张的环境要么裁剪字段issuer这种只用于展示的可以先不入表要么换成按需加载。3.2 建索引哈希表是默认解区间数据才需要二分绝大多数 BIN 表是「精确前缀」形式一条记录对应一个 6/7/8 位数字串。这种数据用dict就是最优解单次查找微秒级实现成本最低没必要上什么前缀树。我见过有人为了「显得专业」手写 Trie结果内存翻了三倍查询速度没提升——因为哈希表的比较是 O(1)Trie 的深度遍历反而有常数开销。真正需要特殊处理的是区间式数据源记录形如bin_start400000, bin_end400099。这种数据用哈希表查不了得先按起始值排序再二分。import bisect from typing import Optional, Tuple, List def build_range_index(records: List[dict]) - Tuple[List[int], List[tuple]]: rows sorted( ((int(r[bin_start]), int(r[bin_end]), r) for r in records), keylambda x: x[0], ) return [r[0] for r in rows], rows def lookup_range(pan_digits: str, index) - Optional[dict]: starts, rows index n len(pan_digits) for width in (8, 7, 6): # 与精确表保持同样的降级顺序 if n width: continue key int(pan_digits[:width]) i bisect.bisect_right(starts, key) - 1 if i 0: s, e, rec rows[i] if s key e: return {**rec, matched_width: width} return None这段代码有一个隐含前提区间之间不重叠。bisect_right - 1只能定位到「起始值小于等于 key 的最后一个区间」如果两个区间互相覆盖它会漏掉前面那个。真实数据里跨行共用号段、历史号段未清理的情况并不少见所以建索引前应该先做一次区间合并或者老老实实退化成扫描。我在生产里吃过这个亏某批数据里有两个区间的起始值相同排序后顺序不稳定跑出来的结果时对时错排查了大半天。3.3 降级匹配8→7→6 的顺序和提前返回精确表的查询逻辑短但细节不少。核心是把「宽度」和「命中记录」一起返回让上层能根据命中宽度决定置信度。from typing import Dict, Optional def match_bin(pan_digits: str, table: Dict[str, dict]) - Optional[dict]: pan_digits 必须是已归一化的纯数字串 n len(pan_digits) for width in (8, 7, 6): if n width: continue rec table.get(pan_digits[:width]) if rec is not None: return {**rec, matched_width: width} return Noneif n width: continue这行不能省。有些短卡号只有 8 位前 8 位就是全号此时 7 位、6 位的前缀依然有意义但如果输入只有 6 位去取前 8 位会得到 6 位字符串虽然dict.get不会报错但语义上已经错了容易掩盖上游的脏数据。{**rec, ...}的写法会复制一份字典好处是调用方改返回值不会污染原表坏处是高频调用时多一次分配——十万次批量跑下来差异肉眼可见。对性能敏感的场景可以改成返回(rec, width)元组让调用方自己决定要不要复制。3.4 返回值约定把「不确定」也表达出来接口设计上最容易犯的错是只返回「命中/未命中」两态。实际业务里「BIN 没查到但格式合法」和「BIN 查到但长度不对」是两回事前者可能是新号段后者大概率是脏数据处理策略完全不同。我的做法是让匹配层只负责查表返回{bin, brand, type, issuer, pan_lengths, luhn, matched_width}或None状态判定交给上层的校验函数职责分开测试也好写。线程安全方面加载完成后这张表就是只读的多线程并发查dict在 CPython 下是安全的GIL 保护读操作不需要加锁。真要更新数据整体替换引用而不是原地修改避免读到一半表被改了。4. 接入业务链路清洗、分级、批量校验的完整实现4.1 归一化空格、连字符、全角数字和掩码卡号真实输入从来不是干净的数字串。用户从各种地方复制粘贴可能带空格、带连字符、带全角数字日志导出后还可能带****掩码。归一化要做的第一件事是字符层面的收敛。import re import unicodedata MASK_CHARS set(*xX#•·) def normalize_pan(raw) - str: 只做字符归一不做长度与合法性判断 if raw is None: return s unicodedata.normalize(NFKC, str(raw)) # 全角数字、全角符号转半角 s re.sub(r[\s\-_.·], , s) # 常见分隔符 return s def is_masked(s: str) - bool: return any(ch in MASK_CHARS for ch in s)unicodedata.normalize(NFKC, ...)这一行解决的是最隐蔽的问题从某些网页或文档里复制出来的数字是全角字符.isdigit()返回True但ord() - 48得到的不是 1直接丢进模 10 校验会算出莫名其妙的结果。NFKC 归一化会把全角转半角、把各种兼容字符统一属于零成本的必要步骤。掩码卡号要单独处理。is_masked判断出来之后不能直接送进校验也不能简单丢弃——风控场景下掩码卡号本身有价值可以只取可见前缀做 BIN 匹配。我在批处理里对掩码数据的策略是「只匹配、不校验」返回状态里显式标出来。4.2 三道闸长度、前缀、校验位顺序很重要校验逻辑的顺序会影响错误信息的可读性和计算成本。我的习惯是先做最便宜的检查非数字 → 长度越界 → BIN 匹配 → 长度集合匹配 → 模 10。BIN 匹配放在模 10 前面因为查表比逐位计算便宜而且能给出更有意义的错误原因。def luhn_ok(digits: str) - bool: total 0 for i, ch in enumerate(reversed(digits)): d ord(ch) - 48 if i 1: # 从右数第 2、4、6… 位加倍 d 1 if d 9: d - 9 total d return total % 10 0 def validate_pan(raw, table: Dict[str, dict]) - dict: digits normalize_pan(raw) if not digits or not digits.isdigit(): return {status: INVALID, reason: non_digit} if not (8 len(digits) 19): return {status: INVALID, reason: length_out_of_range} hit match_bin(digits, table) if hit is None: return {status: UNKNOWN_BIN, pan_len: len(digits)} if len(digits) not in hit[pan_lengths]: return {status: SUSPECT, reason: length_mismatch, bin: hit} if hit[luhn] and not luhn_ok(digits): return {status: SUSPECT, reason: luhn_failed, bin: hit} return {status: HIT, bin: hit}i 1比i % 2 1快一点点含义是从右往左数第二位开始加倍这个位置关系一定要对——写反了会导致所有卡号都校验失败或者反过来让大量假号通过属于那种上线才发现的错。hit[luhn]为False时跳过校验是给特殊号段留的后门。4.3 结果分级四种状态和它们的下游动作状态设计决定了这份数据在下游怎么用。四态划分在我经手的几个系统里都够用再细就是过度设计。状态触发条件建议下游动作HITBIN 命中且长度、校验位全过正常放行可直接用 brand/type 做路由SUSPECTBIN 命中但长度或校验位不过降级处理转人工或二次确认不要直接拒UNKNOWN_BIN格式合法但 BIN 未收录记录并上报很可能是新增号段INVALID非数字或长度不在 819 位直接拒绝属于明确的脏数据SUSPECT这一档最容易被砍掉很多人图省事把长度不符和校验失败都归到INVALID。但这两类的业务含义差得远长度不符可能是老系统截断了卡号校验失败可能是用户手输错了一位都不是「恶意伪造」直接拒绝会误伤正常用户。UNKNOWN_BIN更不能当非法处理新号段上线总有一段时间数据源没跟上。4.4 批量任务十万行 CSV 的吞吐与内存批量场景和单条校验的差别在 IO 和内存不在算法。核心原则是流式读写别把整个文件读进列表。import csv from collections import Counter def batch_validate(in_path: str, out_path: str, table: Dict[str, dict]) - Counter: stat Counter() with open(in_path, newline, encodingutf-8) as fin, \ open(out_path, w, newline, encodingutf-8) as fout: reader csv.DictReader(fin) writer csv.writer(fout) writer.writerow([pan_masked, status, bin, brand, type, issuer]) for row in reader: res validate_pan(row.get(pan, ), table) stat[res[status]] 1 pan normalize_pan(row.get(pan, )) # 只保留前 6 后 4中间打码避免完整卡号落盘 masked pan[:6] * * max(len(pan) - 10, 0) pan[-4:] if len(pan) 10 else pan b res.get(bin) or {} writer.writerow([masked, res[status], b.get(bin, ), b.get(brand, ), b.get(type, ), b.get(issuer, )]) return statDictReader迭代时逐行解析内存占用和文件行数无关。stat用Counter统计各状态数量跑完打一行就够了比在循环里打日志强得多。掩码那段逻辑要注意边界长度不超过 10 位的卡号打码后会丢失全部信息此时原样输出反而更合理因为短卡号本身就不是真卡。如果数据量上到千万行单进程就不够了。多进程方案里有个坑每个子进程都复制一份 BIN 表十万条记录乘以进程数内存直接翻倍。Linux 下fork启动的子进程能共享父进程内存页写时复制前提是表在fork之前加载好且之后只读Windows 下没有这个机制得考虑multiprocessing.shared_memory或者干脆换成一个查表服务。这一点在第 5 章还会提到。5. 避坑排查五条踩过的坑与现象、原因、解决5.1 现象同一张卡昨天识别正常今天变成未知 BIN原因数据源做了一次全量更新把 6 位记录替换成了 8 位细分记录而查表逻辑当时只做了 6 位匹配新表里根本没有 6 位键。解决把匹配逻辑固定成 8→7→6 的降级顺序并且在数据更新后跑一次覆盖率回归——用固定的样本集统计UNKNOWN_BIN占比波动超过 1 个百分点就拦下来人工看。后来我把这条规则写进了更新脚本数据源再换格式也不会静默失效。5.2 现象19 位卡号全部校验失败16 位正常原因校验函数里硬编码了if len(pan) ! 16: return False是从某个只处理标准卡的老模块抄过来的。19 位卡在模 10 计算时被提前截断最后一位校验位压根没参与。解决长度判断从「等于 16」改成「在 8 到 19 之间」并且额外用pan_lengths做 BIN 级约束。改完之后还要补一条回归用例专门覆盖 19 位输入否则同样的错很容易在别的地方复现。5.3 现象明明是借记卡被标成了信用卡原因数据里同一个 6 位前缀对应两条记录一条借记一条信用分别带 8 位细分。加载时用 6 位做键后写入的把先写入的覆盖了而 JSON 数组的顺序恰好是信用在后。解决加载时如果发现键冲突不要静默覆盖打一条警告并把两条记录都留下做人工核对。更彻底的做法是优先保留更长的 BIN短 BIN 只在长 BIN 查不到时兜底。这个改动之后同类问题从每月几次降到零。5.4 现象内存 2G 的机器加载 BIN 表直接 OOM原因除了 BIN 表本身代码里还保留了一份原始 JSON 的解析结果用于「查不到时打印上下文」。两份数据同时在内存里Python 对象开销又大实际占用是文件体积的十几倍。解决解析完立刻释放原始结构只保留归一化后的精简字段issuer这类纯展示字段改成按需回查。另外把加载时机从「每次请求」挪到「进程启动」配合只读共享内存曲线立刻平了。5.5 现象日志里出现了完整卡号原因校验失败时打了logger.warning(finvalid pan: {pan})本意是方便排查结果日志平台按明文存储一下子捅了大篓子。解决所有日志输出统一过一遍掩码函数只保留前 6 后 4SUSPECT类的错误日志改成记录 BIN 和失败原因不记完整卡号。这件事之后我养成了一个习惯凡是涉及卡号、证件号的模块日志函数入口先做一层脱敏宁可少点排查信息。6. 数据保鲜与回归样本让这份 BIN 表三个月后还能用BIN 表不是一次性资产。发卡行新增号段、产品线拆分、卡组织从 6 位迁移到 8 位任何一次变动都会让旧表的覆盖率下降。我的做法是每次拿到新版本先做集合差分看清楚到底变在哪再决定要不要全量替换。import json def load_bins(path: str) - dict: with open(path, encodingutf-8) as f: raw json.load(f) records raw[data] if isinstance(raw, dict) and data in raw else raw return {str(r[bin]): r for r in records} old load_bins(bin_prev.json) new load_bins(bin_curr.json) added new.keys() - old.keys() # 新增号段 removed old.keys() - new.keys() # 消失号段重点看是否误删 changed {k for k in new.keys() old.keys() if new[k].get(issuer) ! old[k].get(issuer) or new[k].get(type) ! old[k].get(type)} print(f新增 {len(added)}移除 {len(removed)}属性变更 {len(changed)})三个数字的解读方式不一样。新增多说明数据源在补录正常移除多要警惕可能是数据源换了格式导致大面积解析失败也可能是真的下线了号段得抽样核对属性变更最值得逐条看尤其是type从 debit 变 credit 这种直接影响下游路由。我一般会把changed里的前 50 条打印出来人工扫一遍剩下的抽样。光有差分还不够得有能自动跑的回归样本。我在每个项目里都会维护一张固定用例表覆盖那些「一改就坏」的边界每次更新数据或改校验逻辑都跑一遍。用例构造输入期望结果最短合法卡号8 位纯数字BIN 命中HIT 或 SUSPECT看 pan_lengths最长合法卡号19 位模 10 通过HIT全角数字半角数字替换为全角字符归一化后结果与半角一致带分隔符每 4 位插入空格或连字符归一化后结果不变掩码卡号前6位 **** 后4位不做模 10 校验只匹配 BIN前导零 BINBIN 以 0 开头不丢前导零能正常命中未收录 BIN合法格式但表中不存在UNKNOWN_BIN长度越界7 位或 20 位数字INVALID校验位错误合法 BIN末位改一位SUSPECT原因 luhn_failed区间边界等于 bin_start 与 bin_end 的卡号都能命中区间外一位不命中这张表的价值在最后两行。区间边界是最容易写错的地方——bisect_right用错成bisect_left边界值就会漏掉一个。用例表里明确写出「等于起始值」「等于结束值」「结束值 1」三个输入改代码时想偷懒都难。还有一个小技巧把matched_width的分布也纳入监控。如果某个时间点之后 6 位命中的比例突然从 20% 涨到 80%说明新数据的 8 位记录大面积缺失虽然服务没报错但识别精度已经掉了。这种「不报错的退化」比直接崩溃更难发现靠的就是这类分布指标。血泪经验是BIN 表这类基础数据出问题从来不是查表代码写错而是数据换了、假设没跟着换。从那以后我每次更新数据源都强制走一遍「差分 → 抽样核对 → 回归用例 → 覆盖率对比」这四步一步都不跳。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?