技术博客
代付与对账系统批量核对开户行:用银行卡归属地查询补全存量数据

代付与对账系统批量核对开户行:用银行卡归属地查询补全存量数据

作者: 万维易源
2026-09-15
银行卡归属地查询代付对账批量查询清结算限流
# 代付与对账系统批量核对开户行:用银行卡归属地查询补全存量数据 > 接口:银行卡归属地查询(apiCode=30)· 接入点:`30-7` · 计费:5 厘/次,查询失败不计费 · 并发上限:10 次/秒 · 适用人群:做代付、对账、清结算的后端与数据工程师 · 阅读时间:约 8 分钟 > 最后实测核对:2026-09-15 一句话结论:批量核对开户行的流程是「去重 → 查缓存 → 按接口上限限流回源 → 未收录记录进人工复核队列」;去重的粒度必须是完整卡号而不是 BIN 段——2026-09-15 实测同一 BIN 段的两个卡号归属地不同,按 BIN 去重会漏掉需要单独查询的卡号。 ## 批量场景要解决的问题 代付和对账系统里常见的三类脏数据: - 历史导入的卡号只有数字串,没有开户行字段,报表和凭证要手工填。 - 从多个渠道汇总过来的卡号,银行名写法不统一(有的写「中国农业银行」,有的写「农业银行」),同一家银行被拆成好几组。 - 有少量卡号在源头就录错了,需要区分出来走人工处理,而不是混在正常数据里。 银行卡归属地查询能一次补齐行名、卡种、开户地与客服电话,并给出卡号形态的判断依据。 ## 第一步:去重,粒度是完整卡号 先把任务量压下来。做法是同一张卡号只查一次: ```python import hashlib SALT = "a-long-random-salt-stored-in-env" def card_key(card_no: str) -> str: return hashlib.sha256((SALT + card_no.strip()).encode()).hexdigest() def dedupe(card_nos): """按完整卡号去重,返回 {哈希: 卡号}。""" seen, out = set(), {} for raw in card_nos: no = (raw or "").strip().replace(" ", "").replace("-", "") if not no: continue k = card_key(no) if k not in seen: seen.add(k) out[k] = no removed = len(card_nos) - len(out) print(f"原始 {len(card_nos)} 条,去重后 {len(out)} 条,减少 {removed} 次调用") return out ``` **不要按 BIN 段去重。** 2026-09-15 实测:`6228480402564890018` 返回 `江苏 - 苏州`,`6228480000000000000` 返回 `广东 - 江门`,两张卡的前 6 位完全相同。按 BIN 去重会把后者当成前者的重复项跳过,结果就是一批卡的归属地全部标错。 同一份实测里还看到,`card_digits` 也是卡号级数据:同一 `622588` 段,一个卡号返回 `16`、另一个返回 `19`。 ## 第二步:查缓存,再回源 对账任务通常周期性跑,卡号重复率很高。先查缓存能把回源量压到只有新增卡号那么多。 ```python import json import hashlib import requests import redis APPKEY = "YOUR_APPKEY" API_URL = "https://route.showapi.com/30-7" SALT = "a-long-random-salt-stored-in-env" TTL = 30 * 24 * 3600 # 接口数据每年不定期更新,30 天是可用的折中值 r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True) def cached_lookup(card_no: str) -> dict | None: raw = r.get("bca:" + hashlib.sha256((SALT + card_no).encode()).hexdigest()) return json.loads(raw) if raw else None def fetch_one(card_no: str) -> dict: resp = requests.get(API_URL, params={"appKey": APPKEY, "cardNum": card_no}, timeout=30) resp.raise_for_status() data = resp.json() if data.get("showapi_res_code") != 0: return {"ok": False, "state": "unavailable", "msg": data.get("showapi_res_error", "")} body = data.get("showapi_res_body") or {} if str(body.get("ret_code")) != "0": area = body.get("area") or "" state = "area_not_found" if area.startswith("该卡归属地信息暂未收录") else "card_not_found" # 未收录不写缓存:接口数据每年不定期更新,固定 30 天没有意义 return {"ok": False, "state": state, "msg": body.get("remark", ""), "raw": body} result = { "ok": True, "state": "ok", "bank": body.get("formatBankName") or body.get("bankName", ""), "bank_raw": body.get("bankName", ""), "card_type": body.get("cardType", ""), "area": body.get("area", ""), "tel": body.get("tel", ""), "url": body.get("url", ""), } r.setex("bca:" + hashlib.sha256((SALT + card_no).encode()).hexdigest(), TTL, json.dumps(result, ensure_ascii=False)) return result ``` 缓存里存 `formatBankName` 对应的 `bank` 字段做分组主键。理由是实测同一 BIN 段的两次调用里,`bankName` 一次返回 `中国农业银行`、一次返回 `农业银行`,而 `formatBankName` 两次都是 `农业银行`。用后者做分组,同一家银行不会被拆成两组。 ## 第三步:限流分批 接口并发上限是 10 次/秒。批量任务必须自己排队,建议压到 8 次/秒留余量。 ```python import time import threading from concurrent.futures import ThreadPoolExecutor class TokenBucket: def __init__(self, rate: float = 8.0, capacity: int = 8): self.rate, self.capacity = rate, capacity self.tokens, self.updated = capacity, time.monotonic() self.lock = threading.Lock() def acquire(self, timeout: float = 60.0) -> bool: deadline = time.monotonic() + timeout while True: with self.lock: now = time.monotonic() self.tokens = min(self.capacity, self.tokens + (now - self.updated) * self.rate) self.updated = now if self.tokens >= 1: self.tokens -= 1 return True if time.monotonic() >= deadline: return False time.sleep(0.02) bucket = TokenBucket() def run_batch(card_map: dict, workers: int = 4) -> dict: """card_map: {哈希: 卡号};返回统计与结果明细。""" stats = {"hit_cache": 0, "queried": 0, "ok": 0, "card_not_found": 0, "area_not_found": 0, "unavailable": 0} results = {} def work(item): key, card_no = item hit = cached_lookup(card_no) if hit: stats["hit_cache"] += 1 results[key] = hit return bucket.acquire() out = fetch_one(card_no) stats["queried"] += 1 stats[out["state"]] = stats.get(out["state"], 0) + 1 results[key] = out with ThreadPoolExecutor(max_workers=workers) as pool: list(pool.map(work, card_map.items())) return {"stats": stats, "results": results} ``` 任务时间窗要先算:回源量 U 条,速率 8 次/秒,最少需要 `U / 8` 秒。10 万条约 3.5 小时,别塞进凌晨的一个短窗口。 ## 第四步:未收录记录进人工复核队列 两类未收录要分开处理: | 状态 | 判定 | 处理方式 | |------|------|------| | `card_not_found` | `ret_code=-1` 且带 `remark`(典型值 `找不到此卡号信息`) | 卡号本身有问题,进人工复核队列,转人工核对 | | `area_not_found` | `ret_code=-1`,`area` 是占位串 `该卡归属地信息暂未收录 - `,银行字段正常 | 行名可用,先把行名回填,归属地留空并标记待补 | 第二类容易被整体当成失败丢掉。实际上它的 `bankName`、`tel`、`url` 都有正常值,补「开户行名称」这个字段是够用的,只是「开户地」要留空。 ## 第五步:对账差异定位 补齐行名之后,对账差异的定位会清晰很多。常见的三类差异可以这样分层: - **行名不同但卡号相同**:看是不是 `bankName` 与 `formatBankName` 混用了。同一家银行这两个字段的写法可能不同,比对时统一用 `formatBankName`。 - **归属地对不上**:`area` 是开户行所在地,不是交易发生地。跨行代付场景下这两者本来就不一致。 - **同一张卡两次跑出不同结果**:检查是不是命中了不同时间的缓存,或者第二次调用时该卡号的 BIN 刚好处于未收录状态。 ## FAQ **Q1:能按卡号前 6 位批量查吗?** 不能。2026-09-15 实测同一 BIN 段的两个卡号分别返回 `江苏 - 苏州` 和 `广东 - 江门`。归属地是卡号级数据,必须有完整卡号才能查。 **Q2:一个 10 万条的批次要跑多久?** 按接口 10 次/秒的上限,最快约 2.8 小时;实际把速率压到 8 次/秒留余量,大约 3.5 小时。命中缓存的记录不占这个时间。 **Q3:未收录的卡号重复跑会查到吗?** 接口数据每年不定期更新,没有固定的刷新时点。同一批卡号间隔一段时间重跑,可能有一部分会从「未收录」变成有结果。 **Q4:未收录的调用扣费吗?** 不扣。2026-09-15 实测卡号未收录与归属地未收录两种情况的 `showapi_fee_num` 都是 `0`。 **Q5:批次任务中途断了怎么续跑?** `fetch_one` 内部对成功结果做了缓存写入,续跑时命中的部分不会重复回源。把未收录的记录单独落表,续跑时只重试这一批。 **Q6:能用 MCP 或 OpenAPI 文档直接对接批处理平台吗?** 可以。本接口提供接口级 MCP 服务与 OpenAPI 3.0 文档,配置方式见[在 AI 客户端和 Postman 里接入银行卡归属地查询](https://www.showapi.com/guides/bank-card-attribution-mcp-openapi-30)。 ## 下一步阅读 - [银行卡归属地查询按次计费下怎么省调用](https://www.showapi.com/guides/bank-card-attribution-cache-cost-30)——缓存粒度与令牌桶限流的完整写法 - [银行卡归属地查询排错:三种失败形态怎么区分](https://www.showapi.com/guides/bank-card-attribution-error-codes-30)——`remark` 与 `area` 占位串的判定方式 - [支付收银台接入银行卡归属地查询](https://www.showapi.com/guides/bank-card-attribution-payment-risk-30)——实时绑卡场景的链路设计 - **本系列共 10 篇**:查看[银行卡归属地查询指南总目录](https://www.showapi.com/guides/bank-card-attribution-guides-30)