Files
junhong_cmp_fiber/scripts/migration/lib/legacy_query.py
break 31232ea899
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 53s
优化迁移脚本速度
2026-07-10 13:10:00 +09:00

965 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""奇成 MySQL 只读查询封装。
接入 kyhl 库,仅 SELECT,强制 READ ONLY 事务防止误写。
所有查询按 ICCID/agent_id 批量分块,避免单条 SQL 过大。
"""
from __future__ import annotations
import contextlib
import time
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Iterable, Iterator, Optional
# 重构后分阶段索引查询已无大 JOIN,1000 为安全默认值
BATCH_SIZE = 1000
class _StageTimer:
"""阶段耗时计时器,记录迁移各阶段用时。"""
def __init__(self, func_name: str) -> None:
self._func = func_name
self._t = time.perf_counter()
def log(self, stage: str, **kw: Any) -> None:
elapsed = time.perf_counter() - self._t
parts = " ".join(f"{k}={v}" for k, v in kw.items())
suffix = f" {parts}" if parts else ""
print(f"[legacy] {self._func}.{stage}:{suffix} elapsed={elapsed:.1f}s")
self._t = time.perf_counter()
def _normalize_iccid_pairs(iccid_pairs: Iterable[tuple]) -> list[tuple[str, str, bool]]:
"""标准化 ICCID 查询参数。
tuple 第三项表示是否允许用 iccid_19 查询奇成 tbl_card 主表:
- 只填 iccid_20 的固定 20 位卡:false,避免 19 位前缀误撞其他卡
- 显式填了 iccid_19:true,允许作为主表查询键或 20 位缺失时兜底
"""
out: list[tuple[str, str, bool]] = []
for item in iccid_pairs:
i19 = str(item[0] or "").strip() if len(item) > 0 else ""
i20 = str(item[1] or "").strip() if len(item) > 1 else ""
allow_i19_lookup = bool(item[2]) if len(item) > 2 else True
if i19:
out.append((i19, i20, allow_i19_lookup))
return out
def _import_pymysql():
"""延迟导入 pymysql,这样脚本1(纯离线)不需要安装 pymysql 也能运行。"""
try:
import pymysql
from pymysql.cursors import DictCursor
return pymysql, DictCursor
except ImportError as exc:
raise ImportError("缺少 pymysql,请先 pip install -r requirements.txt") from exc
@dataclass(frozen=True)
class LegacyPackage:
"""奇成当前生效正式套餐快照(取自 tbl_card_life)。"""
iccid: str
meal_id: str
meal_name: str
meal_type: str
start_date: Optional[str]
expire_date: Optional[str]
flow_size_mb: int # 套餐总量,单位 MB
status: int
@dataclass(frozen=True)
class LegacyPackageLifecycle:
"""奇成正式套餐生命周期记录。"""
iccid: str
source_table: str
life_id: str
meal_id: str
meal_name: str
meal_type: str
status: int
start_date: Optional[str]
expire_date: Optional[str]
flow_size_mb: int
stable_sort_key: str
migration_status: str
skip_reason: str = ""
@dataclass(frozen=True)
class LegacyCardUsage:
"""奇成卡累计流量快照。
奇成 ``tbl_card.total_bytes_cnt`` 存的是经过当前套餐 ``flow_add_discount``
加成后的"虚用量",我们这里同时拉取当前生效正式套餐(``tbl_card_life`` status=1,type!=1
且 ``expire_date`` 最大)的 ``flow_add_discount``,反算回新系统期望的"真用量"
精度说明:跨多个套餐周期累计时,``total_bytes_cnt`` 是按各时段对应套餐
虚比加权累加的,这里只用"当前生效套餐"的虚比反算,**有误差但可接受**——
奇成没有按套餐分段的用量明细,无更好做法。
"""
iccid: str # 业务方视角的完整 ICCID
virtual_used_mb: Decimal # 奇成 total_bytes_cnt 原始值(虚 MB)
flow_add_discount_pct: Decimal # 当前生效套餐虚比百分比(0~100)
has_active_package: bool # 是否在 tbl_card_life 中找到当前生效正式套餐
@property
def real_used_mb(self) -> Decimal:
"""反算真用量(Decimal MB,保留小数)。
虚 = 真 × (1 + pct/100) → 真 = 虚 / (1 + pct/100)
"""
if self.virtual_used_mb <= 0:
return Decimal("0")
ratio = Decimal(1) + self.flow_add_discount_pct / Decimal(100)
return self.virtual_used_mb / ratio
@property
def real_used_mb_floor(self) -> int:
"""适配新系统 ``data_usage_mb``(BIGINT)的整数 MB。
取整规则:0 → 0,(0, 1) → 1,≥1 → 向下取整。
sub-MB 上取整为 1,避免"明明用过的卡显示 0"
"""
real = self.real_used_mb
if real <= 0:
return 0
if real < 1:
return 1
return int(real)
@dataclass(frozen=True)
class LegacyCommissionAccount:
"""奇成代理佣金账户快照。"""
agent_id: str
agent_name: str
can_draw_amount_fen: int # 当前可提现佣金(分)
applying_draw_amount_fen: int # 申请提现中佣金(分)
total_commission_amount_fen: int # 累计总佣金(分,做参考)
total_draw_amount_fen: int # 累计已提现(分,做参考)
@dataclass(frozen=True)
class LegacyAgentBalance:
"""奇成代理主钱包余额快照(取自 tbl_agent.balance_money)。"""
agent_id: str
balance_fen: int
@dataclass(frozen=True)
class LegacyCardMeta:
"""奇成卡基础元数据(由 scan_legacy / migrate_assets 共享)。
业务方提供的 iccid_19 + iccid_20 才是新系统的权威值,奇成的 iccid_mark 长度不可信。
legacy_raw_iccid 仅作调试参考。
"""
legacy_raw_iccid: str # 奇成实际存的 iccid_mark(可能 19 或 20 位)
account_id: str # tbl_card.account_id(开卡公司/运营商账号 ID)
account_name: str # tbl_card.account_name(开卡公司/运营商账号名称)
category: int # tbl_card.category(运营商分类编号)
category_name: str # tbl_vendor_category.category_name
agent_id: str
agent_name: str
msisdn: str # tbl_card.phone
virtual_no: str # tbl_virtual_number.vcode(如有)
current_meal_id: str # 当前生效正式套餐 ID(可空,取自 tbl_card_life status=1,type!=1,expire_date 最大)
current_meal_name: str
current_meal_type: str # tbl_card_life.type,1=叠加包/加油包,2=月卡,3=季卡,4=半年卡,5=年卡
current_expire_date: Optional[str] # ISO 字符串
current_series_id: str # 当前生效正式套餐所属奇成套餐系列 ID
current_series_name: str
@dataclass(frozen=True)
class LegacyMealMeta:
"""奇成套餐 + 系列元数据(由 scan_legacy 用)。"""
meal_id: str
meal_name: str
series_id: str
series_name: str
# ---------------- 连接管理 ----------------
@contextlib.contextmanager
def connect_readonly(dsn: dict) -> Iterator[Any]:
"""打开只读连接。
用法:
with connect_readonly(dsn) as conn:
rows = fetch_current_packages(conn, iccids)
"""
pymysql, DictCursor = _import_pymysql()
conn = pymysql.connect(
host=dsn["host"],
port=int(dsn.get("port", 3306)),
user=dsn["user"],
password=dsn["password"],
database=dsn["database"],
charset=dsn.get("charset", "utf8mb4"),
connect_timeout=int(dsn.get("connect_timeout", 10)),
cursorclass=DictCursor,
autocommit=False,
)
try:
with conn.cursor() as cur:
cur.execute("SET SESSION TRANSACTION READ ONLY")
cur.execute("SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ")
conn.begin()
yield conn
conn.rollback() # 只读事务,回滚即结束
finally:
conn.close()
def _chunks(items: list[str], size: int = BATCH_SIZE) -> Iterator[list[str]]:
for start in range(0, len(items), size):
yield items[start : start + size]
def _to_fen(value) -> int:
"""元 → 分。奇成的 decimal 是元单位。"""
if value is None:
return 0
try:
d = Decimal(str(value))
except Exception:
return 0
return int((d * Decimal("100")).quantize(Decimal("1")))
def _to_mb_decimal(value) -> Decimal:
"""字符串/数字 → Decimal MB。奇成 total_bytes_cnt 是小数 MB 读数。"""
if value is None or value == "":
return Decimal("0")
try:
d = Decimal(str(value))
if d < 0:
return Decimal("0")
return d
except Exception:
return Decimal("0")
def _build_card_lookup(
pairs: list[tuple[str, str, bool]],
) -> dict[str, tuple[str, int]]:
"""构建 tbl_card 查询键 → (完整 ICCID, rank) 映射。
rank=0 表示完整精确命中,rank=1 表示 19 位兜底命中。
"""
by_full: dict[str, tuple[str, int]] = {}
for i19, i20, allow_i19_lookup in pairs:
card_key = i20 or i19
by_full[card_key] = (card_key, 0)
if i20 and allow_i19_lookup:
by_full[i19] = (card_key, 1)
return by_full
# ---------------- 查询函数 ----------------
def fetch_current_packages(
conn,
iccids: Iterable[str],
batch_size: int = BATCH_SIZE,
) -> dict[str, LegacyPackage]:
"""查每张卡的当前生效主套餐:status=1、type!=1 且 expire_date 最大。
返回: iccid → LegacyPackage(找不到的 iccid 不在结果中)
"""
timer = _StageTimer("fetch_current_packages")
iccid_list = sorted({i for i in iccids if i})
out: dict[str, LegacyPackage] = {}
if not iccid_list:
return out
sql = """
SELECT
iccid_mark,
meal_id,
meal_name,
COALESCE(CAST(type AS CHAR), '') AS meal_type,
start_date,
expire_date,
COALESCE(flow_size, 0) AS flow_size,
status
FROM tbl_card_life
WHERE iccid_mark IN %s
AND status = 1
AND COALESCE(CAST(type AS CHAR), '') <> '1'
ORDER BY iccid_mark ASC, expire_date DESC
"""
seen: set[str] = set()
rows = 0
batches = 0
with conn.cursor() as cur:
for batch in _chunks(iccid_list, batch_size):
batches += 1
cur.execute(sql, (tuple(batch),))
for row in cur.fetchall():
iccid = row["iccid_mark"]
rows += 1
if not iccid or iccid in seen:
continue
seen.add(iccid)
out[iccid] = LegacyPackage(
iccid=iccid,
meal_id=row.get("meal_id") or "",
meal_name=row.get("meal_name") or "",
meal_type=str(row.get("meal_type") or ""),
start_date=row["start_date"].strftime("%Y-%m-%d %H:%M:%S") if row.get("start_date") else None,
expire_date=row["expire_date"].strftime("%Y-%m-%d %H:%M:%S") if row.get("expire_date") else None,
flow_size_mb=int(row.get("flow_size") or 0),
status=int(row.get("status") or 0),
)
timer.log("tbl_card_life", keys=len(iccid_list), batches=batches, rows=rows, found=len(out))
return out
def fetch_package_lifecycles(
conn,
iccid_pairs: Iterable[tuple],
batch_size: int = BATCH_SIZE,
) -> dict[str, list[LegacyPackageLifecycle]]:
"""查当前生效和未生效待生效正式套餐生命周期。
只返回可迁移范围(active/pending)和需要在审核文件中说明的跳过记录。
加油包(type=1)和已过期记录不生成套餐 SQL,但保留 skip_reason 供审核产物输出。
"""
timer = _StageTimer("fetch_package_lifecycles")
pairs = _normalize_iccid_pairs(iccid_pairs)
out: dict[str, list[LegacyPackageLifecycle]] = {}
if not pairs:
return out
query_to_full: dict[str, str] = {}
for i19, i20, allow_i19_lookup in pairs:
card_key = i20 or i19
query_to_full[card_key] = card_key
if i20 and allow_i19_lookup:
query_to_full[i19] = card_key
if not i20:
query_to_full[i19] = card_key
iccid_list = sorted(query_to_full.keys())
sql = """
SELECT
'tbl_card_life' AS source_table,
COALESCE(CAST(id AS CHAR), '') AS life_id,
iccid_mark,
COALESCE(meal_id, '') AS meal_id,
COALESCE(meal_name, '') AS meal_name,
COALESCE(CAST(type AS CHAR), '') AS meal_type,
status,
start_date,
expire_date,
COALESCE(flow_size, 0) AS flow_size
FROM tbl_card_life
WHERE iccid_mark IN %s
ORDER BY
iccid_mark ASC,
COALESCE(start_date, '1970-01-01') ASC,
COALESCE(expire_date, '9999-12-31') ASC,
id ASC
"""
rows = 0
batches = 0
with conn.cursor() as cur:
for batch in _chunks(iccid_list, batch_size):
batches += 1
cur.execute(sql, (tuple(batch),))
for row in cur.fetchall():
legacy_iccid = row.get("iccid_mark") or ""
if not legacy_iccid:
continue
iccid = query_to_full.get(legacy_iccid)
if not iccid:
continue
rows += 1
start = row.get("start_date")
expire = row.get("expire_date")
start_str = start.strftime("%Y-%m-%d %H:%M:%S") if start else None
expire_str = expire.strftime("%Y-%m-%d %H:%M:%S") if expire else None
meal_type = str(row.get("meal_type") or "")
raw_status = int(row.get("status") or 0)
migration_status, skip_reason = _classify_package_lifecycle(meal_type, raw_status, start, expire)
life_id = str(row.get("life_id") or "")
stable_sort_key = "|".join([start_str or "", expire_str or "", life_id])
item = LegacyPackageLifecycle(
iccid=iccid,
source_table=row.get("source_table") or "tbl_card_life",
life_id=life_id,
meal_id=row.get("meal_id") or "",
meal_name=row.get("meal_name") or "",
meal_type=meal_type,
status=raw_status,
start_date=start_str,
expire_date=expire_str,
flow_size_mb=int(row.get("flow_size") or 0),
stable_sort_key=stable_sort_key,
migration_status=migration_status,
skip_reason=skip_reason,
)
out.setdefault(iccid, []).append(item)
timer.log("tbl_card_life", keys=len(iccid_list), batches=batches, rows=rows)
next_sql = """
SELECT
'tbl_next_month_card_life' AS source_table,
COALESCE(CAST(id AS CHAR), '') AS life_id,
iccid_mark,
COALESCE(meal_id, '') AS meal_id,
COALESCE(meal_name, '') AS meal_name,
COALESCE(CAST(order_type AS CHAR), '') AS meal_type,
status,
effect_begin_time AS start_date,
effect_complete_time AS expire_date,
COALESCE(flow_size, 0) AS flow_size
FROM tbl_next_month_card_life
WHERE iccid_mark IN %s
ORDER BY
iccid_mark ASC,
COALESCE(effect_begin_time, '1970-01-01') ASC,
COALESCE(effect_complete_time, '9999-12-31') ASC,
id ASC
"""
next_rows = 0
next_batches = 0
with conn.cursor() as cur:
for batch in _chunks(iccid_list, batch_size):
next_batches += 1
cur.execute(next_sql, (tuple(batch),))
for row in cur.fetchall():
legacy_iccid = row.get("iccid_mark") or ""
if not legacy_iccid:
continue
iccid = query_to_full.get(legacy_iccid)
if not iccid:
continue
next_rows += 1
start = row.get("start_date")
expire = row.get("expire_date")
start_str = start.strftime("%Y-%m-%d %H:%M:%S") if start else None
expire_str = expire.strftime("%Y-%m-%d %H:%M:%S") if expire else None
raw_status = int(row.get("status") or 0)
migration_status, skip_reason = _classify_next_month_lifecycle(raw_status)
life_id = str(row.get("life_id") or "")
stable_sort_key = "|".join([start_str or "", expire_str or "", f"next:{life_id}"])
out.setdefault(iccid, []).append(LegacyPackageLifecycle(
iccid=iccid,
source_table=row.get("source_table") or "tbl_next_month_card_life",
life_id=life_id,
meal_id=row.get("meal_id") or "",
meal_name=row.get("meal_name") or "",
meal_type=str(row.get("meal_type") or ""),
status=raw_status,
start_date=start_str,
expire_date=expire_str,
flow_size_mb=int(row.get("flow_size") or 0),
stable_sort_key=stable_sort_key,
migration_status=migration_status,
skip_reason=skip_reason,
))
timer.log("tbl_next_month_card_life", keys=len(iccid_list), batches=next_batches, rows=next_rows)
return out
def _classify_package_lifecycle(meal_type: str, status: int, start_date, expire_date) -> tuple[str, str]:
"""把奇成生命周期记录分类为 active/pending/skipped。"""
if meal_type == "1":
return "skipped", "加油包不迁移"
from datetime import datetime
now = datetime.now()
if expire_date and expire_date <= now:
return "skipped", "已过期套餐不迁移"
if status == 1:
if start_date and start_date > now:
return "pending", ""
return "active", ""
if start_date and start_date > now:
return "pending", ""
return "skipped", f"奇成状态 {status} 不在迁移范围"
def _classify_next_month_lifecycle(status: int) -> tuple[str, str]:
"""把奇成次月待生效套餐记录分类为 pending/skipped。"""
if status == 1:
return "pending", ""
if status == 2:
return "skipped", "次月待生效记录已插入 tbl_card_life,避免重复迁移"
return "skipped", f"奇成次月待生效状态 {status} 不在迁移范围"
def fetch_card_usage(
conn,
iccid_pairs: Iterable[tuple],
batch_size: int = BATCH_SIZE,
) -> dict[str, LegacyCardUsage]:
"""查每张卡的累计已用流量,分阶段索引查询替代大 JOIN。
阶段:
1. tbl_card — iccid_mark + total_bytes_cnt
2. tbl_card_life — 当前生效正式套餐 meal_id(Python 选 expire_date 最大)
3. tbl_set_meal — flow_add_discount(按 meal_id 精确查)
4. Python 组装 LegacyCardUsage
Args:
iccid_pairs: (iccid_19, iccid_20, allow_iccid_19_lookup) 列表。
Returns:
{完整 ICCID: LegacyCardUsage}。
"""
timer = _StageTimer("fetch_card_usage")
pairs = _normalize_iccid_pairs(iccid_pairs)
out: dict[str, LegacyCardUsage] = {}
if not pairs:
return out
by_full = _build_card_lookup(pairs)
full_set = sorted(by_full.keys())
# ── 阶段1: tbl_card ───────────────────────────────────────────────────────
card_sql = """
SELECT
iccid_mark,
COALESCE(total_bytes_cnt, 0) AS total_bytes_cnt
FROM tbl_card
WHERE iccid_mark IN %s
"""
hits: dict[str, list[tuple[int, dict]]] = {}
card_rows = 0
card_batches = 0
with conn.cursor() as cur:
for batch in _chunks(full_set, batch_size):
card_batches += 1
cur.execute(card_sql, (tuple(batch),))
for row in cur.fetchall():
legacy_iccid = row["iccid_mark"]
if not legacy_iccid:
continue
matched = by_full.get(legacy_iccid)
if matched is None:
continue
card_key, rank = matched
hits.setdefault(card_key, []).append((rank, row))
card_rows += 1
timer.log("tbl_card", keys=len(full_set), batches=card_batches, rows=card_rows)
# 取最优命中行,构建 legacy_iccid 列表
card_data: dict[str, dict] = {} # card_key → best tbl_card row
for card_key, rows in hits.items():
best_rank = min(r for r, _ in rows)
card_data[card_key] = next(row for r, row in rows if r == best_rank)
if not card_data:
return out
legacy_iccids = sorted({row["iccid_mark"] for row in card_data.values()})
# ── 阶段2: tbl_card_life 当前生效正式套餐(精确匹配,Python 选最新) ─────────
life_sql = """
SELECT
iccid_mark,
COALESCE(meal_id, '') AS meal_id
FROM tbl_card_life
WHERE iccid_mark IN %s
AND status = 1
AND COALESCE(CAST(type AS CHAR), '') <> '1'
ORDER BY iccid_mark ASC, expire_date DESC, id DESC
"""
current_meal_by_iccid: dict[str, str] = {} # legacy_iccid → meal_id
life_rows = 0
life_batches = 0
with conn.cursor() as cur:
for batch in _chunks(legacy_iccids, batch_size):
life_batches += 1
cur.execute(life_sql, (tuple(batch),))
for row in cur.fetchall():
iccid = row["iccid_mark"]
life_rows += 1
if iccid not in current_meal_by_iccid:
current_meal_by_iccid[iccid] = row.get("meal_id") or ""
timer.log("tbl_card_life", keys=len(legacy_iccids), batches=life_batches, rows=life_rows)
# ── 阶段3: tbl_set_meal flow_add_discount(按 distinct meal_id 查) ─────────
meal_ids = sorted({mid for mid in current_meal_by_iccid.values() if mid})
discount_by_meal: dict[str, Decimal] = {}
if meal_ids:
meal_sql = "SELECT id, COALESCE(flow_add_discount, 0) AS flow_add_discount FROM tbl_set_meal WHERE id IN %s"
meal_batches = 0
with conn.cursor() as cur:
for batch in _chunks(meal_ids, batch_size):
meal_batches += 1
cur.execute(meal_sql, (tuple(batch),))
for row in cur.fetchall():
mid = str(row["id"] or "")
if mid:
discount_by_meal[mid] = _to_mb_decimal(row.get("flow_add_discount"))
timer.log("tbl_set_meal", meal_ids=len(meal_ids), batches=meal_batches)
else:
timer.log("tbl_set_meal", meal_ids=0, skipped=True)
# ── 阶段4: Python 组装 ────────────────────────────────────────────────────
for card_key, base_row in card_data.items():
legacy_iccid = base_row["iccid_mark"]
meal_id = current_meal_by_iccid.get(legacy_iccid, "")
has_active = bool(meal_id or legacy_iccid in current_meal_by_iccid)
discount = discount_by_meal.get(str(meal_id), Decimal("0")) if meal_id else Decimal("0")
out[card_key] = LegacyCardUsage(
iccid=card_key,
virtual_used_mb=_to_mb_decimal(base_row.get("total_bytes_cnt")),
flow_add_discount_pct=discount,
has_active_package=has_active,
)
timer.log("assemble", cards=len(out))
return out
def fetch_commission_accounts(
conn,
agent_ids: Iterable[str],
batch_size: int = BATCH_SIZE,
) -> dict[str, LegacyCommissionAccount]:
"""查代理佣金账户。"""
timer = _StageTimer("fetch_commission_accounts")
agent_list = sorted({a for a in agent_ids if a})
out: dict[str, LegacyCommissionAccount] = {}
if not agent_list:
return out
sql = """
SELECT
agent_id, agent_name,
can_draw_amount, applying_draw_amount,
total_commision_amount, total_draw_amount
FROM tbl_agent_commission_account
WHERE agent_id IN %s
"""
rows = 0
batches = 0
with conn.cursor() as cur:
for batch in _chunks(agent_list, batch_size):
batches += 1
cur.execute(sql, (tuple(batch),))
for row in cur.fetchall():
aid = row["agent_id"]
rows += 1
if not aid:
continue
out[aid] = LegacyCommissionAccount(
agent_id=aid,
agent_name=row.get("agent_name") or "",
can_draw_amount_fen=_to_fen(row.get("can_draw_amount")),
applying_draw_amount_fen=_to_fen(row.get("applying_draw_amount")),
total_commission_amount_fen=_to_fen(row.get("total_commision_amount")),
total_draw_amount_fen=_to_fen(row.get("total_draw_amount")),
)
timer.log("tbl_agent_commission_account", agents=len(agent_list), batches=batches, rows=rows)
return out
def fetch_card_meta(
conn,
iccid_pairs: Iterable[tuple],
batch_size: int = BATCH_SIZE,
) -> tuple[dict[str, LegacyCardMeta], set[str]]:
"""查每张卡的元数据,分阶段索引查询替代大 JOIN。
阶段:
1. tbl_card — 卡基础字段 + 去重检测
2. tbl_vendor_category — 运营商分类名称(按 distinct category 查)
3. tbl_card_life — 当前生效正式套餐(Python 选 expire_date 最大,精确匹配)
4. tbl_set_meal + tbl_set_meal_series — 套餐系列(按 distinct meal_id 查)
5. tbl_virtual_number — 虚拟号(精确匹配,不做 LEFT() 前缀扫描)
6. Python 组装 LegacyCardMeta
匹配策略:
- tbl_card 主表同时查优先键和显式允许的兜底键;20 位精确命中优先
- 从表(card_life / virtual_number)用 tbl_card 实际返回的 iccid_mark 精确查询
- 不再使用 LEFT(vn.iccid_mark, 19) 前缀匹配,避免全表扫描
Args:
iccid_pairs: (iccid_19, iccid_20, allow_iccid_19_lookup) 列表。
Returns:
(metas, duplicate_iccids):
- metas: {完整 ICCID: LegacyCardMeta}
- duplicate_iccids: 一份业务方 ICCID 命中多条 tbl_card 记录的 set
"""
timer = _StageTimer("fetch_card_meta")
pairs = _normalize_iccid_pairs(iccid_pairs)
if not pairs:
return {}, set()
by_full = _build_card_lookup(pairs)
full_set = sorted(by_full.keys())
# ── 阶段1: tbl_card ───────────────────────────────────────────────────────
card_sql = """
SELECT
id AS card_id,
iccid_mark,
COALESCE(account_id, '') AS account_id,
COALESCE(account_name, '') AS account_name,
COALESCE(category, 0) AS category,
COALESCE(agent_id, '') AS agent_id,
COALESCE(agent_name, '') AS agent_name,
COALESCE(phone, '') AS phone
FROM tbl_card
WHERE iccid_mark IN %s
"""
hits: dict[str, list[tuple[int, dict]]] = {}
card_rows = 0
card_batches = 0
with conn.cursor() as cur:
for batch in _chunks(full_set, batch_size):
card_batches += 1
cur.execute(card_sql, (tuple(batch),))
for row in cur.fetchall():
legacy_iccid = row["iccid_mark"]
if not legacy_iccid:
continue
matched = by_full.get(legacy_iccid)
if matched is None:
continue
card_key, rank = matched
hits.setdefault(card_key, []).append((rank, row))
card_rows += 1
timer.log("tbl_card", keys=len(full_set), batches=card_batches, rows=card_rows)
# 去重:同一完整 ICCID 命中多条不同 card_id → duplicates
metas_base: dict[str, dict] = {} # card_key → best tbl_card row
duplicates: set[str] = set()
categories: set[int] = set()
for card_key, rows in hits.items():
best_rank = min(r for r, _ in rows)
best_rows = [row for r, row in rows if r == best_rank]
rows_by_card_id: dict[str, dict] = {}
for row in best_rows:
rows_by_card_id[str(row.get("card_id") or row.get("iccid_mark") or "")] = row
if len(rows_by_card_id) > 1:
duplicates.add(card_key)
continue
row = next(iter(rows_by_card_id.values()))
metas_base[card_key] = row
categories.add(int(row.get("category") or 0))
if not metas_base:
return {}, duplicates
# legacy_iccids: tbl_card 实际存储的 iccid_mark 值(用于从表精确查询)
legacy_iccids = sorted({row["iccid_mark"] for row in metas_base.values()})
# ── 阶段2: tbl_vendor_category(distinct category,通常极少几十个) ─────────
category_names: dict[int, str] = {}
cat_batches = 0
if categories:
cat_sql = """
SELECT category, COALESCE(category_name, '') AS category_name
FROM tbl_vendor_category
WHERE category IN %s
"""
cat_list = sorted(categories)
with conn.cursor() as cur:
for batch in _chunks(cat_list, batch_size):
cat_batches += 1
cur.execute(cat_sql, (tuple(batch),))
for row in cur.fetchall():
category_names[int(row["category"])] = row.get("category_name") or ""
timer.log("tbl_vendor_category", categories=len(categories), batches=cat_batches)
# ── 阶段3: tbl_card_life 当前生效正式套餐(精确匹配,Python 选最新) ─────────
# 精确用 legacy_iccid 查询;不再做 LEFT(iccid_mark, 19) 前缀扫描
life_sql = """
SELECT
iccid_mark,
COALESCE(meal_id, '') AS meal_id,
COALESCE(meal_name, '') AS meal_name,
COALESCE(CAST(type AS CHAR), '') AS meal_type,
expire_date
FROM tbl_card_life
WHERE iccid_mark IN %s
AND status = 1
AND COALESCE(CAST(type AS CHAR), '') <> '1'
ORDER BY iccid_mark ASC, expire_date DESC, id DESC
"""
current_meals: dict[str, dict] = {} # legacy_iccid → 当前生效套餐行
meal_ids: set[str] = set()
life_rows = 0
life_batches = 0
with conn.cursor() as cur:
for batch in _chunks(legacy_iccids, batch_size):
life_batches += 1
cur.execute(life_sql, (tuple(batch),))
for row in cur.fetchall():
iccid = row["iccid_mark"]
life_rows += 1
if iccid not in current_meals:
current_meals[iccid] = row
if row.get("meal_id"):
meal_ids.add(row["meal_id"])
timer.log("tbl_card_life", keys=len(legacy_iccids), batches=life_batches, rows=life_rows, found=len(current_meals))
# ── 阶段4: tbl_set_meal + tbl_set_meal_series(按 distinct meal_id) ───────
meal_series: dict[str, tuple[str, str]] = {} # meal_id → (series_id, series_name)
meal_batches = 0
if meal_ids:
meal_sql = """
SELECT
sm.id AS meal_id,
COALESCE(sm.meal_series_id, '') AS series_id,
COALESCE(sms.name, '') AS series_name
FROM tbl_set_meal sm
LEFT JOIN tbl_set_meal_series sms ON sms.id = sm.meal_series_id
WHERE sm.id IN %s
"""
meal_id_list = sorted(meal_ids)
with conn.cursor() as cur:
for batch in _chunks(meal_id_list, batch_size):
meal_batches += 1
cur.execute(meal_sql, (tuple(batch),))
for row in cur.fetchall():
mid = str(row["meal_id"] or "")
if mid:
meal_series[mid] = (row.get("series_id") or "", row.get("series_name") or "")
timer.log("tbl_set_meal", meal_ids=len(meal_ids), batches=meal_batches)
# ── 阶段5: tbl_virtual_number(精确匹配,不做前缀扫描) ─────────────────────
virtual_nos: dict[str, str] = {} # legacy_iccid → vcode
vn_rows = 0
vn_batches = 0
with conn.cursor() as cur:
for batch in _chunks(legacy_iccids, batch_size):
vn_batches += 1
cur.execute(
"SELECT iccid_mark, COALESCE(vcode, '') AS vcode FROM tbl_virtual_number WHERE iccid_mark IN %s",
(tuple(batch),),
)
for row in cur.fetchall():
iccid = row["iccid_mark"]
vn_rows += 1
if iccid:
virtual_nos[iccid] = row.get("vcode") or ""
timer.log("tbl_virtual_number", keys=len(legacy_iccids), batches=vn_batches, rows=vn_rows)
# ── 阶段6: Python 组装 ────────────────────────────────────────────────────
metas: dict[str, LegacyCardMeta] = {}
for card_key, base_row in metas_base.items():
legacy_iccid = base_row["iccid_mark"]
meal_row = current_meals.get(legacy_iccid, {})
expire = meal_row.get("expire_date")
expire_str = expire.strftime("%Y-%m-%d %H:%M:%S") if expire else None
meal_id = meal_row.get("meal_id") or ""
series_id, series_name = meal_series.get(str(meal_id), ("", ""))
metas[card_key] = LegacyCardMeta(
legacy_raw_iccid=legacy_iccid,
account_id=base_row.get("account_id") or "",
account_name=base_row.get("account_name") or "",
category=int(base_row.get("category") or 0),
category_name=category_names.get(int(base_row.get("category") or 0), ""),
agent_id=base_row.get("agent_id") or "",
agent_name=base_row.get("agent_name") or "",
msisdn=base_row.get("phone") or "",
virtual_no=virtual_nos.get(legacy_iccid, ""),
current_meal_id=meal_id,
current_meal_name=meal_row.get("meal_name") or "",
current_meal_type=str(meal_row.get("meal_type") or ""),
current_expire_date=expire_str,
current_series_id=series_id,
current_series_name=series_name,
)
timer.log("assemble", cards=len(metas), duplicates=len(duplicates))
return metas, duplicates
def fetch_meal_meta(
conn,
meal_ids: Iterable[str],
batch_size: int = BATCH_SIZE,
) -> dict[str, LegacyMealMeta]:
"""查套餐 + 系列元数据(供 scan_legacy 收集映射 legacy_* 字段)。"""
timer = _StageTimer("fetch_meal_meta")
id_list = sorted({m for m in meal_ids if m})
out: dict[str, LegacyMealMeta] = {}
if not id_list:
return out
sql = """
SELECT
sm.id AS meal_id,
COALESCE(sm.name, '') AS meal_name,
COALESCE(sm.meal_series_id, '') AS series_id,
COALESCE(sms.name, '') AS series_name
FROM tbl_set_meal sm
LEFT JOIN tbl_set_meal_series sms ON sms.id = sm.meal_series_id
WHERE sm.id IN %s
"""
rows = 0
batches = 0
with conn.cursor() as cur:
for batch in _chunks(id_list, batch_size):
batches += 1
cur.execute(sql, (tuple(batch),))
for row in cur.fetchall():
mid = row["meal_id"]
rows += 1
if not mid:
continue
out[mid] = LegacyMealMeta(
meal_id=mid,
meal_name=row.get("meal_name") or "",
series_id=row.get("series_id") or "",
series_name=row.get("series_name") or "",
)
timer.log("tbl_set_meal", meal_ids=len(id_list), batches=batches, rows=rows)
return out
def fetch_agent_balances(
conn,
agent_ids: Iterable[str],
batch_size: int = BATCH_SIZE,
) -> dict[str, LegacyAgentBalance]:
"""查代理主钱包余额(tbl_agent.balance_money,元单位)。"""
timer = _StageTimer("fetch_agent_balances")
agent_list = sorted({a for a in agent_ids if a})
out: dict[str, LegacyAgentBalance] = {}
if not agent_list:
return out
sql = """
SELECT id, balance_money
FROM tbl_agent
WHERE id IN %s
"""
rows = 0
batches = 0
with conn.cursor() as cur:
for batch in _chunks(agent_list, batch_size):
batches += 1
cur.execute(sql, (tuple(batch),))
for row in cur.fetchall():
aid = row["id"]
rows += 1
if not aid:
continue
out[aid] = LegacyAgentBalance(agent_id=aid, balance_fen=_to_fen(row.get("balance_money")))
timer.log("tbl_agent", agents=len(agent_list), batches=batches, rows=rows)
return out