重构:Get* 命名统一 + 用户数据落盘 + use_ticket / reincarnate
1) 命名统一(服务端 API 名带 Get 的,脚本名与函数名一律带 get/Get)
- 26 个 api 脚本改名:userdata.py -> get_user_data.py 等
- 7 个 game_*.py -> get_game_*.py
- 38 个函数改名:UserData -> GetUserData 等
- upsert_snapshot.py -> get_need_user_data.py,
fetch_upsert_snapshot -> GetNeedUserData
- user_getter_common.fetch_all_pages -> get_all_pages
2) GetNeedUserData 抓到的数据自动落盘
- <工程根>/data/<userId>.json,新数据总是覆盖老数据
- 新增 save_user_data / load_user_data / user_data_path /
list_saved_user_ids / DATA_DIR / PROJECT_ROOT
- data/ 已加入 .gitignore
3) use_ticket.py(新)
- 读取账号当前持有的功能票,用 UpsertUserAll 消耗掉
- 依据:GetUserChargeApi 与 UpsertUserAllApi 的 userChargeList 格式相同;
真机实证 userChargeList.stock -1 + useTicketId + playCredit
- playCredit = 投币数 + 票的 creditNum(VOExtensions.cs:838-845)
4) reincarnate.py(新)
- 账号数据迁移(转生):A 的数据克隆到 B
- 每包最多 4 首(真机一局上限),包间用续关登录换新 loginId
续关依据:ContinueProcess.cs:403 -> PacketUserLogin(isContinue: true)
- playlog 从曲目记录反推合成(没有对应的 Get 接口)
自检 238 项全绿。
This commit is contained in:
+410
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
reincarnate.py —— 账号数据迁移(转生):把账号 A 的数据克隆到账号 B
|
||||
================================================================================
|
||||
|
||||
┌─ 源账号 A ─────────────────────────────────────────────┐
|
||||
│ 二维码 -> ParseQRCode -> UserLogin -> GetNeedUserData │
|
||||
│ 或:直接读 data/<userId>.json(离线复用) │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
↓ 节点数据
|
||||
┌─ 目标账号 B ───────────────────────────────────────────┐
|
||||
│ 二维码 -> ParseQRCode -> UserPreview -> UserLogin │
|
||||
│ 循环:UpsertUserAll(每包最多 4 首曲目) │
|
||||
│ 然后 UserLogin(isContinue=true) 续关,拿新 loginId│
|
||||
│ 收尾:UserLogout │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ 为什么一次最多传 4 首
|
||||
--------------------------------------------------------------------------------
|
||||
真机一局(一个 credit)最多打 4 首,所以一条 UpsertUserAll 报文里的
|
||||
userPlaylogList / userGamePlaylogList **最多 4 条**。
|
||||
|
||||
本脚本把源账号的 userMusicDetailList 按 4 条一组切分,一组 = 一局。
|
||||
★ 每局都必须带**非空**的 userPlaylogList —— 踩坑清单第 2 条实测:
|
||||
留空会被服务端当成无效包**静默丢弃**(returnCode=1 但不落库)。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ 为什么需要「续关登录」
|
||||
--------------------------------------------------------------------------------
|
||||
UpsertUserAll 的 playlogId 必须等于**本次登录**的 loginId
|
||||
(PacketUpsertUserAll.cs:31 / UserAllRequestVO)。
|
||||
一局结束后这个会话就被消费掉了,要继续上传必须重新登录 ——
|
||||
真机走的就是「续关」:
|
||||
|
||||
Process/ContinueProcess.cs:403
|
||||
new PacketUserLogin(userID, accessCode, isContinue: true, ...)
|
||||
|
||||
对应本工程:UserLogin(user_id, token, is_continue=True)
|
||||
它和普通登录的唯一区别就是 UserLoginRequestVO.isContinue = true,
|
||||
会返回**新的 loginId / loginDateTime**,供下一局 UpsertUserAll 使用。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ playlog 从哪来
|
||||
--------------------------------------------------------------------------------
|
||||
GetNeedUserData 抓不到 userPlaylogList(那是逐局记录,没有对应的 Get 接口),
|
||||
所以本脚本**从曲目记录反推合成**:
|
||||
|
||||
UserMusicDetail(9 字段) -> UserPlaylog(110 字段)
|
||||
───────────────────────────── ─────────────────────────
|
||||
musicId musicId
|
||||
level level
|
||||
achievement achievement
|
||||
deluxscoreMax deluxscore
|
||||
scoreRank scoreRank / isClear
|
||||
comboStatus comboStatus
|
||||
syncStatus syncStatus
|
||||
其余字段 取自 config.PLAY_*(合理默认值)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
用法
|
||||
--------------------------------------------------------------------------------
|
||||
# 源数据来自二维码(会联网抓 A 的数据,并落盘到 data/<A>.json)
|
||||
python reincarnate.py --dst-qr <B的二维码> --src-qr <A的二维码>
|
||||
|
||||
# 源数据来自已保存的 JSON(离线,推荐:不用反复消耗 A 的二维码)
|
||||
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json
|
||||
|
||||
# ★ 真实上传
|
||||
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --send
|
||||
|
||||
# 只看计划,不发包
|
||||
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --plan
|
||||
|
||||
# 常用开关
|
||||
--limit N 只迁前 N 首(试跑用)
|
||||
--per-packet N 每包曲目数(默认 4,★ 不要超过 4)
|
||||
--clone-identity 连 userName / accessCode 一起克隆(默认**保留 B 自己的**)
|
||||
--no-continue 不续关(只发一包,调试用)
|
||||
|
||||
★ 迁移会**真实写入**账号 B(不可撤销)。不传 --send 时只构造报文。
|
||||
★ 二维码**有效期 10 分钟**。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from api import (
|
||||
setup_logging,
|
||||
teardown_logging,
|
||||
log_error,
|
||||
log_info,
|
||||
log_warning,
|
||||
ParseQRCode,
|
||||
GetUserPreview,
|
||||
UserLogin,
|
||||
UserLogout,
|
||||
LogoutType,
|
||||
GetNeedUserData,
|
||||
normalize_snapshot,
|
||||
NODE_ORDER,
|
||||
NODE_TYPES,
|
||||
ARRAY_TO_BITS,
|
||||
DIFF_PRIMARY_KEYS,
|
||||
PlayData,
|
||||
build_user_playlog,
|
||||
build_user_game_playlog,
|
||||
UpsertUserAll,
|
||||
ConfirmRequiredError,
|
||||
Ping,
|
||||
load_user_data,
|
||||
list_saved_user_ids,
|
||||
DATA_DIR,
|
||||
)
|
||||
from config import PLACE_ID, REGION_ID
|
||||
|
||||
#: 真机一局最多 4 首
|
||||
MAX_TRACKS_PER_CREDIT = 4
|
||||
|
||||
#: 默认保留目标账号自己的身份字段(不克隆过去,避免把 B 改名)
|
||||
IDENTITY_KEYS = ("userName", "accessCode")
|
||||
|
||||
#: 迁移时**不**逐条传的节点(要么是位串、要么由 UpsertUserAll 自己算)
|
||||
SKIP_NODES = (
|
||||
"userMusicDetailList", # 单独分批
|
||||
"userGamePlaylogList", # 由本脚本按曲目合成
|
||||
"user2pPlaylog", # 2P 记录,迁移无意义
|
||||
"userGetPointList", # 里程/奖励,迁移会凭空发点
|
||||
"userTradeItemList", # 无 Get 来源
|
||||
"userFriendSeasonRankingList", # 无 Get 来源
|
||||
)
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
opts = {"dst_qr": "", "src_qr": "", "src_json": "", "send": False,
|
||||
"plan": False, "debug": False, "limit": 0,
|
||||
"per_packet": MAX_TRACKS_PER_CREDIT,
|
||||
"clone_identity": False, "no_continue": False}
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a == "--send":
|
||||
opts["send"] = True
|
||||
elif a == "--plan":
|
||||
opts["plan"] = True
|
||||
elif a == "--debug":
|
||||
opts["debug"] = True
|
||||
elif a == "--clone-identity":
|
||||
opts["clone_identity"] = True
|
||||
elif a == "--no-continue":
|
||||
opts["no_continue"] = True
|
||||
elif a in ("--dst-qr", "--src-qr", "--src-json", "--limit", "--per-packet") and i + 1 < len(argv):
|
||||
key = a[2:].replace("-", "_")
|
||||
opts[key] = int(argv[i + 1]) if key in ("limit", "per_packet") else argv[i + 1]
|
||||
i += 1
|
||||
i += 1
|
||||
return opts
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 工具
|
||||
# =============================================================================
|
||||
|
||||
def _bits_for(node, items, base_items):
|
||||
"""
|
||||
按目标账号 B 的基线算 isNew* 位串:
|
||||
B 里没有该条目 -> "1"(新增)
|
||||
B 里已有 -> "0"(更新)
|
||||
主键表见 upsert_user_all.DIFF_PRIMARY_KEYS。
|
||||
"""
|
||||
keys = DIFF_PRIMARY_KEYS.get(node)
|
||||
if not keys:
|
||||
return None
|
||||
have = set()
|
||||
for b in (base_items or []):
|
||||
if isinstance(b, dict):
|
||||
have.add(tuple(str(b.get(k)) for k in keys))
|
||||
return "".join(
|
||||
"0" if tuple(str(it.get(k)) for k in keys) in have else "1"
|
||||
for it in (items or []) if isinstance(it, dict)
|
||||
)
|
||||
|
||||
|
||||
def play_from_detail(detail, base_play=None):
|
||||
"""
|
||||
从一条 UserMusicDetail(9 字段)反推一份 PlayData,用来合成 userPlaylog。
|
||||
|
||||
对不上的字段保留 config.PLAY_* 的默认值(它们不在曲目记录里)。
|
||||
"""
|
||||
p = PlayData() if base_play is None else base_play
|
||||
p.music_id = int(detail.get("musicId", p.music_id) or 0)
|
||||
p.music_level = int(detail.get("level", p.music_level) or 0)
|
||||
p.achievement = int(detail.get("achievement", p.achievement) or 0)
|
||||
p.deluxscore = int(detail.get("deluxscoreMax", p.deluxscore) or 0)
|
||||
rank = int(detail.get("scoreRank", 0) or 0)
|
||||
p.score_rank = rank
|
||||
p.is_clear = rank > 0
|
||||
p.combo_status = int(detail.get("comboStatus", p.combo_status) or 0)
|
||||
p.sync_status = int(detail.get("syncStatus", p.sync_status) or 0)
|
||||
return p
|
||||
|
||||
|
||||
def collect_nodes(src_nodes, base_nodes, clone_identity):
|
||||
"""把源账号 A 的节点整理成 extra_data(曲目/2P/奖励类除外)。"""
|
||||
out = {}
|
||||
for node in NODE_ORDER:
|
||||
if node in SKIP_NODES:
|
||||
continue
|
||||
if node.startswith("isNew"):
|
||||
continue # 位串单独算
|
||||
kind = NODE_TYPES.get(node)
|
||||
if kind not in ("singleton", "array", "object"):
|
||||
continue
|
||||
val = src_nodes.get(node)
|
||||
if val in (None, [], {}):
|
||||
continue
|
||||
out[node] = json.loads(json.dumps(val)) # 深拷贝
|
||||
# 身份字段:默认保留 B 自己的
|
||||
if not clone_identity and out.get("userData"):
|
||||
for item in out["userData"]:
|
||||
if isinstance(item, dict):
|
||||
for k in IDENTITY_KEYS:
|
||||
item.pop(k, None)
|
||||
# 位串:按 B 的基线算
|
||||
for arr, bits in ARRAY_TO_BITS.items():
|
||||
if arr not in out:
|
||||
continue
|
||||
b = _bits_for(arr, out[arr], base_nodes.get(arr))
|
||||
if b:
|
||||
out[bits] = b
|
||||
return out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 主流程
|
||||
# =============================================================================
|
||||
|
||||
def fetch_source_by_qr(qr):
|
||||
"""用源账号 A 的二维码联网抓一份数据(并自动落盘到 data/<A>.json)。"""
|
||||
log_info("=== 源账号:联网抓取 ===")
|
||||
uid, token = ParseQRCode(qr)
|
||||
preview = GetUserPreview(uid, token)
|
||||
if preview.isLogin:
|
||||
raise RuntimeError("源账号处于已登录状态,请先在机台登出")
|
||||
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||
try:
|
||||
raw = GetNeedUserData(uid, token=token, place_id=PLACE_ID, region_id=REGION_ID)
|
||||
finally:
|
||||
try:
|
||||
UserLogout(uid, token, login.loginDateTime, logout_type=LogoutType.TestIn,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
except Exception as exc:
|
||||
log_warning("源账号登出失败:" + str(exc))
|
||||
return raw
|
||||
|
||||
|
||||
def main() -> int:
|
||||
opts = parse_args(sys.argv[1:])
|
||||
if not opts["dst_qr"] or not (opts["src_qr"] or opts["src_json"]):
|
||||
print(__doc__)
|
||||
return 1
|
||||
|
||||
if opts["debug"]:
|
||||
import config as _cfg
|
||||
_cfg.LOG_LEVEL = "debug"
|
||||
|
||||
if opts["per_packet"] > MAX_TRACKS_PER_CREDIT:
|
||||
print("--per-packet 不能超过 " + str(MAX_TRACKS_PER_CREDIT) + "(真机一局最多 4 首)")
|
||||
return 1
|
||||
|
||||
setup_logging("reincarnate")
|
||||
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
||||
try:
|
||||
# ---------------- 1. 源数据 ----------------
|
||||
if opts["src_json"]:
|
||||
src_raw = json.load(open(opts["src_json"], "r", encoding="utf-8"))
|
||||
log_info("=== 源账号:从文件读取 " + opts["src_json"] + " ===")
|
||||
else:
|
||||
src_raw = fetch_source_by_qr(opts["src_qr"])
|
||||
|
||||
src_nodes = normalize_snapshot(src_raw)
|
||||
musics = src_nodes.get("userMusicDetailList") or []
|
||||
if opts["limit"] > 0:
|
||||
musics = musics[:opts["limit"]]
|
||||
log_info("源数据:节点 " + str(len(src_nodes)) + " 个,曲目记录 " + str(len(musics)) + " 条")
|
||||
|
||||
if not musics:
|
||||
log_error("源数据里没有 userMusicDetailList,没什么可迁的。")
|
||||
return 4
|
||||
|
||||
# ---------------- 2. 目标账号 ----------------
|
||||
Ping()
|
||||
uid, token = ParseQRCode(opts["dst_qr"])
|
||||
session.update(user_id=uid, token=token)
|
||||
preview = GetUserPreview(uid, token)
|
||||
if preview.isLogin:
|
||||
log_error("目标账号处于已登录状态,请先在机台登出或等待会话超时")
|
||||
return 2
|
||||
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||
|
||||
base_raw = GetNeedUserData(uid, token=token, place_id=PLACE_ID, region_id=REGION_ID)
|
||||
base_nodes = normalize_snapshot(base_raw)
|
||||
log_info("目标账号基线已抓取")
|
||||
|
||||
# ---------------- 3. 分批 ----------------
|
||||
per = int(opts["per_packet"])
|
||||
batches = [musics[i:i + per] for i in range(0, len(musics), per)]
|
||||
log_info("计划:共 " + str(len(musics)) + " 首 -> " + str(len(batches))
|
||||
+ " 包(每包 " + str(per) + " 首)")
|
||||
|
||||
if opts["plan"]:
|
||||
for i, b in enumerate(batches):
|
||||
log_info(" 第 " + str(i + 1) + " 包:" + ", ".join(
|
||||
str(x.get("musicId")) + "/L" + str(x.get("level")) for x in b))
|
||||
log_info("--plan:只列计划,不发包")
|
||||
return 0
|
||||
|
||||
# 第一包才带非曲目节点(整账号克隆)
|
||||
shared_nodes = collect_nodes(src_nodes, base_nodes, opts["clone_identity"])
|
||||
log_info("非曲目节点 " + str(len([k for k in shared_nodes if not k.startswith('isNew')]))
|
||||
+ " 个将在第 1 包上传")
|
||||
|
||||
# ---------------- 4. 逐包上传 ----------------
|
||||
sent = 0
|
||||
for idx, batch in enumerate(batches):
|
||||
log_info("")
|
||||
log_info("========== 第 " + str(idx + 1) + "/" + str(len(batches))
|
||||
+ " 包(" + str(len(batch)) + " 首)==========")
|
||||
|
||||
nodes = {}
|
||||
if idx == 0:
|
||||
nodes.update(json.loads(json.dumps(shared_nodes)))
|
||||
nodes["userMusicDetailList"] = json.loads(json.dumps(batch))
|
||||
b = _bits_for("userMusicDetailList", batch,
|
||||
base_nodes.get("userMusicDetailList"))
|
||||
if b:
|
||||
nodes["isNewMusicDetailList"] = b
|
||||
log_info(" isNewMusicDetailList = " + b)
|
||||
|
||||
# 合成 playlog(每首一条)与 game playlog
|
||||
playlogs = []
|
||||
game_playlogs = []
|
||||
for d in batch:
|
||||
p = play_from_detail(d)
|
||||
playlogs.append(build_user_playlog(
|
||||
login.loginId, login.loginDateTime, p, place_id=PLACE_ID))
|
||||
game_playlogs.append(build_user_game_playlog(
|
||||
login.loginId, login.loginDateTime, p))
|
||||
nodes["userGamePlaylogList"] = game_playlogs
|
||||
|
||||
UpsertUserAll(
|
||||
uid, login.loginId,
|
||||
login_date_time=login.loginDateTime,
|
||||
auth_date_time=login.dateTime,
|
||||
token=token,
|
||||
snapshot=base_raw,
|
||||
auto_characters=False,
|
||||
play=play_from_detail(batch[0]),
|
||||
extra_data=nodes,
|
||||
user_playlog_list=playlogs,
|
||||
pre_new_item_list=False,
|
||||
verify_landing=False,
|
||||
dry_run=not opts["send"],
|
||||
confirm=opts["send"],
|
||||
place_id=PLACE_ID, region_id=REGION_ID,
|
||||
)
|
||||
sent += 1
|
||||
|
||||
# ---- ★ 续关登录:拿下一局的 loginId ----
|
||||
if idx < len(batches) - 1:
|
||||
if opts["no_continue"]:
|
||||
log_warning("--no-continue:不再续关,提前结束(只发了 1 包)")
|
||||
break
|
||||
log_info("--- 续关登录(isContinue=true)---")
|
||||
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID,
|
||||
is_continue=True)
|
||||
session.update(login_dt=login.loginDateTime)
|
||||
|
||||
if not opts["send"]:
|
||||
log_info("")
|
||||
log_info("[dry-run] 未发包。想真实迁移请加 --send(★ 会写入账号 B)")
|
||||
return 0
|
||||
|
||||
log_info("")
|
||||
log_info("★ 迁移完成:共上传 " + str(sent) + " 包 / "
|
||||
+ str(min(len(musics), sent * per)) + " 首")
|
||||
return 0
|
||||
|
||||
except ConfirmRequiredError:
|
||||
return 5
|
||||
except Exception as exc:
|
||||
log_error(type(exc).__name__ + ": " + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
if session["logged_in"]:
|
||||
try:
|
||||
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
||||
logout_type=LogoutType.TestIn,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
except Exception as exc:
|
||||
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
|
||||
teardown_logging()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user