Files
Astral/reincarnate.py
T
Txiaochen 8e3bd6fcaa 重构: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 项全绿。
2026-10-06 23:32:09 +08:00

411 lines
17 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.
#!/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())