首页 / 资讯中心 / 文章详情

2025银联卡BIN表:卡种识别、最长前缀匹配与支付路由实践

2025银联卡BIN表:卡种识别、最长前缀匹配与支付路由实践 ★ FEATURED ARTICLE
简介“2025最新银联卡BIN表”是一份面向支付开发、风控校验与卡信息识别场景的轻量数据参考主要解决银行卡归属银行、卡类型及卡号长度规则的查询问题适合需要做表单校验、数据清洗或测试样例构造的开发者与数据分析人员使用。资源包内仅含1个JSON文件压缩后约49KB以结构化字段组织银行名称、卡BIN、卡类型和卡号长度便于直接导入程序、转换后写入数据库或作为离线比对表使用。内容聚焦较新的银联卡BIN整理结果可用于校验输入卡号、补全银行信息、设计风控规则及生成测试数据。目前已有1592人学习下载说明其在支付相关开发与测试中具备一定参考需求。读者获取后可快速建立本地BIN查询表也能借此理解卡号分段与发卡行识别逻辑减少自行搜集与清洗字段的时间。1. 一条对账差异让我重新重视卡号 BIN 表去年帮一个支付团队排查对账差异几十万笔流水里只有几百笔手续费对不上查了两天才发现是卡种判错——借记卡被当成信用卡走了费率。问题不在算法在卡号 BIN 表太旧新发的一段号段压根不在表里代码静默兜底成“未知卡种”谁也没报错。从那以后我对“2025最新银联卡BIN表”这类资源的态度就变了它不是可看可不看的参考资料而是支付链路里一个会悄悄出错的依赖。这份资源把卡号 BINBank Identification Number发卡行识别码与卡组织、卡种借记、信用、预付、发卡行归属的对应关系整理成可直接入库的清单适合做支付路由、风控规则、测试数据构造、对账分流的人。下面按“表怎么拆、代码怎么写、接进业务怎么配、坑在哪”一步步来。2. 拆开这张表BIN 位数、字段设计与入库前的三次校验BIN 的位置由 ISO/IEC 7812 定义卡号最左边那几位用来标识发卡机构剩下的才是账户标识和校验位。银联卡号以 62 开头长度常见 16 位到 19 位历史上大家都是取前 6 位查表后来卡组织逐步把号段细分到 8 位于是 2025 这类新表基本都是 6 位与 8 位混存的结构。混存本身没错错在很多人拿到表之后直接按 6 位做字典 key8 位那批细分记录永远命中不到卡种判断自然就飘了。真正决定这张表好不好用的不是它有多少行而是三件事位数是否带长度标记、同一 BIN 是否存在多版本记录、每条记录有没有生效时间窗口。这三件事没处理好表再新也会在运行期变成黑匣子。2.1 六位、八位与最长前缀BIN 到底截几位常见做法是把卡号截断成候选前缀按长度从长到短依次查表第一个命中就返回也就是最长前缀匹配。原因很直白8 位记录是 6 位记录的细化同一个 6 位前缀下可能挂着一个粗粒度记录和若干个 8 位细分记录先查 6 位会把细分信息盖掉。举个结构上的例子下面所有 BIN 值均为虚构占位仅用于演示字段关系层级BIN 值卡种说明6 位622000借记粗粒度兜底记录8 位62200001借记某号段细分普通借记8 位62200002信用同 6 位前缀下的信用卡段如果只取 6 位62200002开头的卡会被判成借记费率、限额、通道优先级全跟着错。所以匹配顺序必须是 8 → 7 → 6而不是“取前 6 位查一次”。7 位这一层不是所有表都有但加上不亏命中不了就直接落到 6 位判断成本只多一次字典查找。还有一点容易被忽略不是所有卡号都够长。测试环境里经常出现 12 位、13 位的假卡号长度不足 6 位时应该直接判为非法而不是拿card_no[:6]去查——那样会拿到一个长度不够的字符串查不到还不会有异常最后变成“未知卡种”的又一条来源。2.2 表里该有哪些字段一份能直接进库的结构拿到 CSV 之后别急着导先对齐字段。一张能撑住业务的 BIN 表至少要有下面这些列缺一列后面都要打补丁字段类型必填作用与取值说明bin_codeVARCHAR(8)是BIN 值纯数字字符串别用整型前导零会丢bin_lenTINYINT是6/7/8匹配时靠它分桶org_codeVARCHAR(16)是卡组织标识内部枚举值别直接存中文名card_typeVARCHAR(16)是DEBIT / CREDIT / PREPAID统一大写枚举issuer_codeVARCHAR(16)否发卡机构内部编码用于映射到你自己的机构表issuer_nameVARCHAR(64)否展示用名称仅前端和报表用不进判定逻辑effective_fromDATE是生效日期版本化必需effective_toDATE否失效日期空表示当前有效priorityTINYINT是同 BIN 多记录时的优先级数值小的优先bin_code用字符串而不用整型是血泪经验062200存成整型会变成62200长度直接少一位匹配时永远查不到。priority是为了处理“同一 BIN 既有历史记录又有新记录”的情况比如某号段在年中从借记改成信用两条都留着靠effective_from加priority决定当前该用哪条。issuer_name只做展示任何路由判断都不该依赖它否则改一次机构名就要发一次版。2.3 落库从 CSV 到带索引的查询表建表时把主键设计成(bin_code, effective_from)而不是只拿bin_code做唯一键这样同一 BIN 的历史版本可以共存。索引只加在bin_code上就够因为查询是等值匹配不需要范围扫描。CREATE TABLE card_bin ( bin_code VARCHAR(8) NOT NULL COMMENT BIN值纯数字禁止转整型, bin_len TINYINT NOT NULL COMMENT 6/7/8, org_code VARCHAR(16) NOT NULL, card_type VARCHAR(16) NOT NULL COMMENT DEBIT/CREDIT/PREPAID, issuer_code VARCHAR(16) DEFAULT NULL, issuer_name VARCHAR(64) DEFAULT NULL, effective_from DATE NOT NULL, effective_to DATE DEFAULT NULL COMMENT NULL表示长期有效, priority TINYINT NOT NULL DEFAULT 100, PRIMARY KEY (bin_code, effective_from), KEY idx_bin_prefix (bin_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;导入脚本要做的是“读一行、校验一行、异常行单独落盘”不要try/except一包到底然后整批回滚。下面这个脚本的重点在三个校验点BIN 必须是纯数字、长度必须和bin_len自洽、日期区间不能颠倒。import csv, re, datetime BIN_LENS {6, 7, 8} def parse_date(s): if not s: return None return datetime.datetime.strptime(s.strip(), %Y-%m-%d).date() def load_bin_csv(path, bad_pathbad_rows.csv): good, bad [], [] with open(path, newline, encodingutf-8-sig) as f: for ln, row in enumerate(csv.DictReader(f), start2): bin_code (row.get(bin_code) or ).strip() bin_len int(row.get(bin_len) or 0) # 校验1纯数字且长度与声明一致 if not re.fullmatch(r\d, bin_code) or len(bin_code) ! bin_len: bad.append((ln, bin_format, row)); continue # 校验2长度只接受 6/7/8 if bin_len not in BIN_LENS: bad.append((ln, bin_len, row)); continue ef, et parse_date(row.get(effective_from)), parse_date(row.get(effective_to)) # 校验3生效日期必须存在失效日期不得早于生效日期 if ef is None or (et and et ef): bad.append((ln, date_range, row)); continue good.append(( bin_code, bin_len, row[org_code].strip().upper(), row[card_type].strip().upper(), (row.get(issuer_code) or ).strip() or None, (row.get(issuer_name) or ).strip() or None, ef, et, int(row.get(priority) or 100), )) with open(bad_path, w, newline, encodingutf-8) as f: w csv.writer(f) w.writerow([line_no, reason]) w.writerows(bad) return good, badencodingutf-8-sig是为了吃掉某些导出工具塞在文件头的 BOM不加的话第一列表头会变成\ufeffbin_coderow.get(bin_code)返回 None整批数据全被判成格式错误——这个现象很像“数据全坏”实际只是编码问题。bad_rows.csv必须落盘并人工过一遍常见原因是导出时 BIN 被 Excel 当成数字截了前导零或者日期写成了2025/1/1这种非标准格式。3. 三个函数把卡号判成卡种归一化、前缀匹配与 Luhn整个判定链路我一般拆成三段先把卡号洗成纯数字串再用最长前缀匹配拿到 BIN 记录最后跑一次 Luhn 校验把脏数据挡掉。三段分开的好处是每一段都能单独写单测线上出问题能快速定位到底死在清洗、匹配还是校验。3.1 卡号归一化空格、横线和全角字符先清掉用户输入、CSV 导入、日志抓取三条来源的卡号格式基本不会统一。比较常见的是6220 0001 2345 6789这种四位空格的展示格式、带横线的表单导出以及从某些系统复制出来的全角数字。清洗要在匹配之前做且必须只保留数字别自作聪明去截断或补零。import re def normalize_card_no(raw: str) - str: if not raw: return s str(raw).strip() # 全角数字(-)转半角避免全角字符被 \D 直接删掉导致长度错位 s .join(chr(ord(c) - 0xFEE0) if c else c for c in s) # 去掉空格、横线、制表符等一切非数字字符 s re.sub(r\D, , s) return s顺序不能反先做全角转换再做\D过滤。如果先过滤全角数字会被当成非数字字符删掉一个 16 位卡号洗完剩 12 位BIN 还能查到前 6 位通常没受影响但 Luhn 一定不过排查时又会怀疑是校验逻辑写错了。另外返回空串而不是抛异常是为了让批量任务能继续跑异常卡号走统计而不是走中断。3.2 最长前缀匹配别用 startswith 字符串循环有人图省事拿到表后写个for bin_row in all_rows: if card_no.startswith(bin_row.bin_code)几千行表配几万笔流水startswith调用次数是乘积关系跑一次对账能等到怀疑人生。正确做法是按长度分桶、每个长度一个字典查询固定最多 3 次哈希。BIN_LEN_DESC (8, 7, 6) # 从长到短保证最长前缀优先 def build_index(rows): rows: [(bin_code, bin_len, card_type, org_code, ...), ...] idx {6: {}, 7: {}, 8: {}} for r in rows: bin_code, bin_len r[0], r[1] idx[bin_len][bin_code] r # 同 BIN 多版本时入索引前需按 priority日期去重 return idx def match_bin(card_no: str, idx: dict): for n in BIN_LEN_DESC: if len(card_no) n: continue hit idx[n].get(card_no[:n]) if hit: return hit, n return None, 0if len(card_no) n: continue这句看着多余实际挡的是短卡号测试数据里经常有 8 位、10 位的假卡号不判长度直接切片会拿到短串虽然查不到但配上except就容易吞掉真实异常。返回(hit, n)把命中长度一起带出来很重要线上排查时“被 6 位兜底记录命中”和“被 8 位细分记录命中”是完全不同的两件事前者往往意味着该更新表了。入索引前那次去重是整段逻辑里最容易漏的。同一 BIN 在表里有多条记录历史版本、不同卡种、不同优先级直接idx[bin_len][bin_code] r会让后写入的覆盖先写入的而 CSV 的行序没有任何语义保证等于结果不可复现。稳妥做法是先按(priority 升序, effective_from 降序)排序再只保留当前日期落在[effective_from, effective_to]区间内的那一条。3.3 Luhn 校验算出来的校验位对不上就是脏数据BIN 命中不代表卡号合法。卡号最后一位是 Luhn 校验位用来挡录入错误和随手编的假号。实现很短但从右往左数、奇数位乘 2 这两个细节写反了就会把正常卡号全判成非法。def luhn_valid(card_no: str) - bool: if not card_no or not card_no.isdigit(): return False total 0 # 从最右一位开始位置 0 是校验位本身位置 1 才需要乘 2 for i, ch in enumerate(reversed(card_no)): d ord(ch) - 48 if i % 2 1: d * 2 if d 9: d - 9 total d return total % 10 0i % 2 1的判据是“从右往左的偶数下标”0、2、4… 为原始位1、3、5… 为乘 2 位因为你反转了字符串最右的校验位落在i0不需要乘 2。乘 2 后减 9 等价于把两位数的十位和个位相加写法上比d d // 10 d % 10少一次取模。返回False而不是抛异常是为了让批量任务能统计“Luhn 不通过率”——这个指标突然升高通常意味着上游某条数据源在截断卡号或字段错位。提示Luhn 只能证明卡号在数学结构上自洽不能证明卡真实存在、未挂失、未过期。任何把 Luhn 通过等同于“卡有效”的判断都会在风控环节翻车。3.4 批量场景下的内存与性能取舍单笔查询怎么写都无所谓批量跑对账、跑离线画像时差别就出来了。我用一份虚构的 30 万行流水做过粗测同一台机器、纯 CPU、单进程三种写法差异明显写法30 万行耗时内存占用适用场景逐行for 字典匹配约 1.2 s低流式处理、逐笔校验pandas.apply调用匹配函数约 46 s中快速验证、字段加工str.slice向量化 map查表约 0.4 s高离线批量、数据量大pandas.apply慢是因为它退化成 Python 层逐行调用函数体再轻也架不住调用开销。向量化写法是先把 8 位、7 位、6 位三列前缀一次性切出来再分别map到 BIN 字典最后用combine_first按 8→7→6 的优先级回填。表本身也就几万行全部装进内存没有任何压力反倒是反复查库才是性能杀手所以索引一定要在进程启动时构建一次并常驻。4. 接进业务支付路由、风控名单与对账分流的配置方式表洗好了、函数写好了接下来是往业务里塞。这一步最容易出问题的不是代码是配置卡种、卡组织这些枚举值一旦在路由规则里硬编码成散落各处的if card_type CREDIT后面加一个“预付卡”就得全局搜一遍。我一般会把 BIN 判定结果收敛成一个内部结构业务侧只认这个结构里的字段不直接碰原始表。4.1 支付路由卡种加卡组织决定通道优先级路由配置用声明式规则写别用代码分支。下面是一份虚构的通道优先级配置match里的条件只允许引用 BIN 结果结构里的固定字段# route_rules.yaml —— 通道优先级配置示例数据机构名为虚构 rules: - name: credit_unionpay_primary match: org_code: UNIONPAY card_type: CREDIT channels: [CH_A, CH_B] fallback: CH_C - name: debit_unionpay_lowcost match: org_code: UNIONPAY card_type: DEBIT channels: [CH_C, CH_A] fallback: CH_B - name: unknown_bin_hold match: bin_hit: false # 未命中BIN表的卡不直接路由 channels: [] fallback: MANUAL_REVIEWunknown_bin_hold这条规则是整个配置里最值钱的一条。BIN 未命中时不要静默走默认通道直接进人工复核队列并把原始卡号的前 8 位写进监控指标。上面那个对账差异事故本质就是缺了这条规则未命中被兜底成“未知卡种”然后被默认通道照单全收错误费率一路到底。规则里用channels列表而不是单个通道是为了让下游按顺序做可用性探测第一通道超时再试第二通道降级逻辑写在路由层不散落到业务代码。4.2 风控与测试数据BIN 白名单与合规的卡号生成风控场景下 BIN 表的主要用途是做机构维度的聚合比如“同一 BIN 前缀在 5 分钟内出现 200 笔不同卡号”这种规则比单纯的卡号黑名单有效得多因为它抓的是号段被批量试探的特征。做这类规则时聚合的 key 用 BIN 前 6 位就够了不需要精确到 8 位——8 位太细攻击者换一张卡就绕过去了。测试环境要造卡号时用 BIN 加 Luhn 补位生成别去网上抄真实卡号def gen_test_card(bin_prefix: str, body_len: int 15) - str: bin_prefix: 6/8位BINbody_len: 不含校验位的总长度 if not (6 len(bin_prefix) 8): raise ValueError(bin_prefix length must be 6-8) # 用固定序列填充主体保证可复现禁止随机真实卡号 filler (0123456789 * 3)[: body_len - len(bin_prefix)] body bin_prefix filler for d in 0123456789: cand body d if luhn_valid(cand): return cand raise ValueError(cannot build valid check digit)这段生成的卡号只保证结构和校验位合法不映射任何真实账户适合灌测试库。生产侧要强调的是另一件事不要存明文卡号。合规做法是只落 BIN 前 8 位、后 4 位和一个带盐的哈希中间部分一律掩码展示时格式化回6220 **** **** 6789。BIN 表本身不含卡号但它给了你“卡号有什么结构”的知识这个知识用在掩码和脱敏校验上才是正途。4.3 表的更新与版本化别用覆盖式导入BIN 表是会长大的新号段持续发放老号段偶尔还会变更卡种归属。用TRUNCATE加全量覆盖的方式更新最大问题是无法回答“上个月这笔流水当时用的是什么规则”出了问题没法回溯。我一般走三步先写进card_bin_staging临时表再用一条 SQL 对比出新增、变更、下线三类差异人工确认后再合并进主表。变更类要特别处理——那种“同一 BIN、卡种变了”的记录做法是把主表里该 BIN 当前有效记录的effective_to置为变更日前一天再插入一条新的effective_from记录而不是UPDATE掉旧值。-- 新增staging 里有、主表当前有效记录里没有 SELECT s.bin_code, s.bin_len, s.org_code, s.card_type FROM card_bin_staging s LEFT JOIN card_bin m ON m.bin_code s.bin_code AND m.effective_to IS NULL WHERE m.bin_code IS NULL; -- 变更BIN 相同但卡种或卡组织不同需要关旧记录插新记录 SELECT s.bin_code, m.card_type AS old_type, s.card_type AS new_type FROM card_bin_staging s JOIN card_bin m ON m.bin_code s.bin_code AND m.effective_to IS NULL WHERE m.card_type s.card_type OR m.org_code s.org_code;跑完这两条变更清单通常会很小但每一条都要人工看一眼。真实经验是变更清单里混着不少“上游导出时卡种枚举大小写不一致”造成的假变更比如Debit和DEBIT入库前统一upper()就能消掉大半。版本化的另一个好处是可以做灰度新表先给一个影子查询跑一周比对命中结果的差异率差异率超过阈值就先不切。5. 常见问题排查BIN 匹配翻车的五个现场这一节全是实际遇到过的现场记录按“现象 → 原因 → 解决”写。五个坑里有三个出在数据层两个出在调用层排查时先看现象落在哪一层能省掉一半时间。5.1 数据层的三个坑现象一信用卡被判定成借记卡且只发生在部分号段上。原因几乎都是 6 位优先命中。表里同时存在622000借记兜底和62200002信用细分匹配代码写成了“取前 6 位查一次”细分记录永远用不上。解决是把匹配改成 8→7→6 的最长前缀并在索引构建阶段做长度分桶。验收方法是拿一张 8 位细分号段的测试卡号跑一遍断言命中的bin_len等于 8 而不是 6。现象二整批卡号全部判成未命中但表里明明有这些 BIN。先怀疑编码再怀疑前导零。导出 CSV 被 Excel 打开再保存062200这类以 0 开头的 BIN 会变成62200长度少一位匹配必然失败。另一种情况是文件带 BOM第一列表头变成\ufeffbin_codeDictReader读出来全是 None校验直接判格式错。解决是导入时统一utf-8-sig并且bin_code全程按字符串处理落库字段用 VARCHAR 而不是 INT。排查入口看bad_rows.csv的 reason 分布全是bin_format就往这个方向查。现象三同一批卡号两次跑出不同结果重跑一次又变了。这说明索引构建不稳定。表里同一 BIN 有多条当前有效记录比如并行发放的两个卡种或者上一次导入没关旧记录字典是后写覆盖先写而 CSV 行序不保证结果就随机。解决是入索引前先按priority升序加effective_from降序排一遍只取第一条同时在导入环节加一条断言当前有效记录里bin_code不允许重复。这条断言我一般直接写成唯一索引或者定时巡检 SQL比靠人工发现可靠。5.2 调用层的两个坑现象四Luhn 校验大面积不通过但卡号是上游系统给的“正确数据”。通常是归一化顺序写反了。先做\D过滤再做全角转换全角数字会被当非数字删掉16 位卡号洗成 12 位BIN 还能命中前 6 位不受影响Luhn 必挂。还有一种少见情况上游把卡号和有效期拼在一起传过来比如622000xxxxxxxxxx|12/28过滤后多出 4 位数字长度直接不对。解决是先按分隔符切字段再清洗并在清洗后加一条长度区间断言银联卡常见 1619 位长度异常的记录进异常池而不是进主流程。现象五未命中 BIN 的卡号被静默路由到默认通道事后才发现费率错了。这是配置问题不是代码问题。匹配函数返回(None, 0)之后调用方写着result hit or default_record未命中就被兜底成默认值了监控上完全看不见。解决是取消默认兜底未命中的流量必须有独立计数指标和独立处理分支人工复核、拒绝、或者按最保守的通道走并在监控面板上给这个指标设阈值告警。上面提到的对账差异就是这条兜底逻辑藏了三个月没人发现。注意这五个坑里一、三、五都不会抛异常全是静默出错。线上排查时先看“未命中率”和“6 位兜底命中率”这两个指标比逐行 debug 有效得多。6. 把 BIN 判定跑成一条可回归的测试表会更新、代码会改唯一能防止同类问题复发的手段是回归测试。我现在的习惯是把“卡号 → 期望卡种”整理成一份固定的用例表每次更新 BIN 表、改动匹配逻辑都先跑一遍这条用例。用例不用多二三十条足够覆盖关键路径8 位细分命中、6 位兜底命中、完全未命中、Luhn 失败、短卡号、带空格横线、全角字符。import pytest CASES [ # (输入卡号, 期望命中长度, 期望卡种) 数据均为虚构构造 (6220000212345678, 8, CREDIT), # 8位细分优先 (6220009912345670, 6, DEBIT), # 落到6位兜底 (6220 0002 1234 5678, 8, CREDIT),# 带空格 (, 8, CREDIT), # 全角 ] pytest.mark.parametrize(raw,exp_len,exp_type, CASES) def test_bin_match(raw, exp_len, exp_type, bin_index): card_no normalize_card_no(raw) hit, hit_len match_bin(card_no, bin_index) assert hit is not None, f未命中: {raw} assert hit_len exp_len, f{raw} 命中长度 {hit_len}期望 {exp_len} assert hit[2] exp_type这段测试的价值不在断言本身而在hit_len这一项。绝大多数 BIN 事故不是“查不到”而是“查到了但查浅了一层”只断言卡种很容易漏掉——6 位兜底记录和 8 位细分记录的卡种在很多时候是一样的断言卡种等于给自己放水。把命中长度也断言上才真正锁住了“最长前缀优先”这个不变量。再补一个覆盖率检查把线上近一个月出现过的 BIN 前缀抽出来和表里的记录做一次差集没覆盖到的前缀单独导出。这份差集就是下一版表的更新清单也是“为什么会有未命中”的直接答案。我一般每月跑一次差集超过总前缀的 1% 就该催数据更新了。最后说个用真金白银换来的教训。那次对账差异之后我给自己定了一条死规矩任何涉及卡号判断的改动上线前必须跑一遍“历史流水回放”——拿过去一周的真实流水脱敏后只留 BIN 段和判定结果用新逻辑重算一遍比对判定结果的差异率。差异率超过 0.5% 就打回逐个看差异样本。这个流程比写多少单测都稳因为它验的是真实数据的分布而不是我脑子里想象的分布。从那以后每次换表、改匹配逻辑我都强制走一遍回放再也没在费率上栽过第二次。这份 2025 版卡号 BIN 表我按上面的结构整理成了可直接入库的 CSV 和建表脚本字段、长度标记、生效时间都齐了拿到手先跑一遍第二节的导入校验再接第三节的匹配逻辑基本就能用起来。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站