diff --git a/scripts/migration/remove_cards.py b/scripts/migration/remove_cards.py new file mode 100644 index 0000000..92646ed --- /dev/null +++ b/scripts/migration/remove_cards.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""卡删除脚本:读取 ICCID 列表,生成数据库删除 SQL 和 Redis 轮询清理命令。 + +用法: + python3 remove_cards.py --input [--output-dir output] + +CSV 格式: 纯文本,每行一个 ICCID,首行可有 "iccid" 表头(自动跳过) + +输出: + output/remove_cards_db.sql -- 数据库软删 + 标识符硬删 SQL(事务封装) + output/remove_cards_redis_gen.sql -- 在数据库执行后把输出通过 redis-cli 清理轮询队列 +""" +from __future__ import annotations + +import argparse +import sys +from datetime import datetime +from pathlib import Path + + +def load_iccids(csv_path: Path) -> list[str]: + """读取 CSV 文件中的 ICCID 列表,自动跳过空行和表头。""" + iccids: list[str] = [] + with csv_path.open(encoding="utf-8") as f: + for lineno, raw in enumerate(f, 1): + line = raw.strip() + if not line: + continue + # 跳过纯字母表头(如 "iccid") + if lineno == 1 and not line.isdigit() and not line.startswith("8"): + print(f"[remove] 跳过表头: {line!r}", file=sys.stderr) + continue + iccids.append(line) + return iccids + + +def sql_in_list(iccids: list[str]) -> str: + """生成 SQL IN 列表字符串,如 '898...', '898...'""" + return ", ".join(f"'{i}'" for i in iccids) + + +def generate_db_sql(iccids: list[str], generated_at: str) -> str: + in_list = sql_in_list(iccids) + count = len(iccids) + return f"""\ +-- ============================================================ +-- 迁移卡删除脚本 (数据库部分) +-- 生成时间: {generated_at} +-- 卡数量 : {count} +-- 执行前请先用 remove_cards_redis_gen.sql 生成 Redis 清理命令并执行 +-- 建议执行: psql ... -1 -f <此文件> +-- ============================================================ + +BEGIN; + +-- 临时表:本次要删除的卡 +CREATE TEMP TABLE tmp_remove_cards AS +SELECT id, iccid +FROM tb_iot_card +WHERE iccid IN ({in_list}); + +DO $$ BEGIN + RAISE NOTICE '本次将删除 % 张卡(共输入 {count} 个 ICCID)', (SELECT COUNT(*) FROM tmp_remove_cards); +END $$; + +-- 1. 删除套餐使用记录 +DELETE FROM tb_package_usage +WHERE iot_card_id IN (SELECT id FROM tmp_remove_cards); + +-- 2. 删除订单 +DELETE FROM tb_order +WHERE iot_card_id IN (SELECT id FROM tmp_remove_cards); + +-- 3. 删除分配记录 +DELETE FROM tb_asset_allocation_record +WHERE asset_type = 'iot_card' + AND asset_id IN (SELECT id FROM tmp_remove_cards); + +-- 4. 删除资产钱包 +DELETE FROM tb_asset_wallet +WHERE resource_type = 'iot_card' + AND resource_id IN (SELECT id FROM tmp_remove_cards); + +-- 5. 删除设备绑卡关系 +DELETE FROM tb_device_sim_binding +WHERE iot_card_id IN (SELECT id FROM tmp_remove_cards); + +-- 6. 删除资产标识符 +DELETE FROM tb_asset_identifier +WHERE asset_type = 'iot_card' + AND asset_id IN (SELECT id FROM tmp_remove_cards); + +-- 7. 删除卡本体(最后执行) +DELETE FROM tb_iot_card +WHERE id IN (SELECT id FROM tmp_remove_cards); + +COMMIT; +""" + + +def generate_redis_gen_sql(iccids: list[str], generated_at: str) -> str: + in_list = sql_in_list(iccids) + count = len(iccids) + return f"""\ +-- ============================================================ +-- 轮询队列清理命令生成 SQL +-- 生成时间: {generated_at} +-- 卡数量 : {count} +-- 执行方式: +-- psql ... -t -A -c "$(cat remove_cards_redis_gen.sql)" | redis-cli --pipe +-- 或: +-- psql ... -t -A -f remove_cards_redis_gen.sql | redis-cli +-- ============================================================ + +WITH cards AS ( + SELECT id FROM tb_iot_card + WHERE iccid IN ({in_list}) +), +task_types(task_type) AS ( + VALUES + ('polling:realname'), + ('polling:carddata'), + ('polling:package'), + ('polling:protect'), + ('polling:card_status') +) +SELECT format('ZREM polling:shard:%s:queue:%s %s', id % 16, task_type, id) +FROM cards CROSS JOIN task_types +UNION ALL +SELECT format('DEL polling:card:%s', id) +FROM cards +UNION ALL +SELECT format('LREM polling:manual:%s 0 %s', task_type, id) +FROM cards CROSS JOIN task_types +UNION ALL +SELECT format('SREM polling:manual:dedupe:%s %s', task_type, id) +FROM cards CROSS JOIN task_types; +""" + + +def main() -> int: + parser = argparse.ArgumentParser(description="迁移卡删除脚本:生成删除 SQL 和 Redis 清理命令") + parser.add_argument("--input", required=True, help="ICCID 列表 CSV 文件路径") + parser.add_argument("--output-dir", default="output", help="输出目录(默认 output)") + args = parser.parse_args() + + base = Path(__file__).resolve().parent + input_path = Path(args.input) + if not input_path.is_absolute(): + input_path = base / input_path + output_dir = (base / args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + if not input_path.exists(): + print(f"[remove] 错误: 找不到输入文件 {input_path}", file=sys.stderr) + return 1 + + iccids = load_iccids(input_path) + if not iccids: + print("[remove] 错误: CSV 中没有找到有效的 ICCID", file=sys.stderr) + return 1 + + print(f"[remove] 读取到 {len(iccids)} 个 ICCID,开始生成 SQL...") + + generated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + db_sql_path = output_dir / "remove_cards_db.sql" + redis_sql_path = output_dir / "remove_cards_redis_gen.sql" + + db_sql_path.write_text(generate_db_sql(iccids, generated_at), encoding="utf-8") + redis_sql_path.write_text(generate_redis_gen_sql(iccids, generated_at), encoding="utf-8") + + print(f"[remove] 生成完成:") + print(f" 数据库删除 SQL : {db_sql_path}") + print(f" Redis 清理 SQL : {redis_sql_path}") + print() + print("执行步骤:") + print(" 1. 先执行 Redis 清理(在删除卡数据之前,以便 SQL 还能查到卡 ID):") + print(f" psql <连接参数> -t -A -f {redis_sql_path} | redis-cli") + print(" 2. 再执行数据库删除:") + print(f" psql <连接参数> -1 -f {db_sql_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())