diff --git a/maipulse/PLAN-EXT.md b/maipulse/PLAN-EXT.md index 449a858..6f24113 100644 --- a/maipulse/PLAN-EXT.md +++ b/maipulse/PLAN-EXT.md @@ -39,7 +39,7 @@ EasyMai 的路子是绕的:自建后端 `getUserMusic` + 水鱼 `update_record | 鉴权 | jwt-token + import-token | 只要 userId + 机台 clientId,**不用登录** | | 写操作 | 往水鱼导入成绩 | 无,纯只读 | -#### 已验证的接口形状(2026-10-28 实测,账号 ) +#### 已验证的接口形状(2026-10-28 实测,账号 12800621) GetUserRating -> userRating: rating : 0(服务端不填,别指望它) diff --git a/maipulse/PLAN.md b/maipulse/PLAN.md index 5672a7d..b19e512 100644 --- a/maipulse/PLAN.md +++ b/maipulse/PLAN.md @@ -148,7 +148,7 @@ MaiTechUI 金票包 UpsertUserAllApi.poop() 原样: 它从不在登录后 60 秒内提交结算。服务端对"登录不到一分钟就交成绩"的包一律回 returnCode=1 然后把 userItemList / userMusicDetailList 整块丢掉。 -已验证(账号 ):每次发包前补足 60 秒后,四类道具全部真落地。 +已验证(账号 12042860):每次发包前补足 60 秒后,四类道具全部真落地。 | kind | 名称 | 发送 id | 库存变化 | waited | | --- | --- | --- | --- | --- | diff --git a/maipulse/app.py b/maipulse/app.py index 878d9c6..709329c 100644 --- a/maipulse/app.py +++ b/maipulse/app.py @@ -790,8 +790,8 @@ def api_master(): # ---------------------------------------------------------------- 更新 -APP_VERSION = "1.0.0" -APP_BUILD = "2026-10-28" +APP_VERSION = "1.0.1" +APP_BUILD = "2026-10-29" @app.get("/api/version") diff --git a/maipulse/core/unlock.py b/maipulse/core/unlock.py index c7a3182..cd7a239 100644 --- a/maipulse/core/unlock.py +++ b/maipulse/core/unlock.py @@ -289,6 +289,60 @@ def _unlock_once(state: dict, ids: list, master: bool, remaster: bool, return one +def _run_batches(result: dict, state: dict, batches: list, have: set, + master: bool, remaster: bool, who: dict, + snapshot_ok: bool = True) -> bool: + """跑一批包,返回「是否全部成功」。失败不抛,写进 result。""" + user_id = int(state["user_id"]) + snapshot = api.GetNeedUserData(user_id, token=state["token"], save=False) + total_before = result.get("done", 0) + for index, chunk in enumerate(batches, 1): + rows = build_items(chunk, have, master, remaster) + if not rows: + result["done"] = result.get("done", 0) + len(chunk) + continue + try: + session.fresh(state) + play_data, playlog = settle.prepare(state, snapshot) + settle.ensure_play_window(state) + settle.upload(state, playlog) + patch = settle.patch(state, snapshot, play_data) + patch.update({"userItemList": rows, "isNewItemList": "1" * len(rows)}) + resp = api.UpsertUserAll( + user_id, state["login_id"], + login_date_time=state["login_date_time"], + auth_date_time=state["date_time"], + token=state["token"], + snapshot=snapshot, + play=play_data, + user_playlog_list=[playlog], + patch=patch, + verify_landing=False, + auto_characters=False, + place_id=who["place_id"], place_name=who["place_name"], + region_id=who["region_id"], region_name=who["region_name"], + client_id=who["client_id"], + cooldown_sec=0.0, + dry_run=False, + confirm=True, + ) + code = getattr(resp, "returnCode", None) + if code not in (1, "1", None): + result["failed"] = result.get("failed", 0) + len(chunk) + result["errors"].append("第 %d 包 returnCode=%s" % (index, code)) + continue + result["done"] = result.get("done", 0) + len(chunk) + result["sentItems"] = result.get("sentItems", 0) + len(rows) + for row in rows: + have.add((int(row["itemKind"]), int(row["itemId"]))) + except Exception as exc: # noqa: BLE001 + result["failed"] = result.get("failed", 0) + len(chunk) + result["errors"].append("第 %d 包异常:%s" % (index, exc)) + if len(result.get("errors", [])) >= 5: + break + time.sleep(0.3) + return result.get("failed", 0) == 0 + def unlock_all(state: dict, *, master: bool = True, remaster: bool = True, per: int = 0, limit: int = 0, dry_run: bool = False, progress=None) -> dict: @@ -338,53 +392,26 @@ def unlock_all(state: dict, *, master: bool = True, remaster: bool = True, result["sampleItems"] = len(sample) return result - for index, chunk in enumerate(batches, 1): - rows = build_items(chunk, have, master, remaster) - if not rows: - result["done"] += len(chunk) - continue - try: - session.fresh(state) - play_data, playlog = settle.prepare(state, snapshot) - settle.ensure_play_window(state) - settle.upload(state, playlog) - patch = settle.patch(state, snapshot, play_data) - patch.update({"userItemList": rows, "isNewItemList": "1" * len(rows)}) - resp = api.UpsertUserAll( - user_id, state["login_id"], - login_date_time=state["login_date_time"], - auth_date_time=state["date_time"], - token=state["token"], - snapshot=snapshot, - play=play_data, - user_playlog_list=[playlog], - patch=patch, - verify_landing=False, - auto_characters=False, - place_id=who["place_id"], place_name=who["place_name"], - region_id=who["region_id"], region_name=who["region_name"], - client_id=who["client_id"], - cooldown_sec=0.0, - dry_run=False, - confirm=True, - ) - code = getattr(resp, "returnCode", None) - if code not in (1, "1", None): - result["failed"] += len(chunk) - result["errors"].append("第 %d 包 returnCode=%s" % (index, code)) - else: - result["done"] += len(chunk) - result["sentItems"] += len(rows) - for row in rows: - have.add((int(row["itemKind"]), int(row["itemId"]))) - except Exception as exc: # noqa: BLE001 - result["failed"] += len(chunk) - result["errors"].append("第 %d 包异常:%s" % (index, exc)) - if len(result["errors"]) >= 5: - break - if progress: - progress(index, len(batches), result["done"], result["failed"]) - time.sleep(0.3) + # ★ 降级阶梯:一包发失败(异常 / returnCode 非 1 / 一行都没落库)就自动换更小的包 + # 重来。大包最省时间,但部分服务端会静默丢弃超大的 upsertUserAll。 + retry_sizes = [int(per) if int(per) > 0 else 0, 200, 50] + attempt = 0 + while True: + ok = _run_batches(result, state, batches, have, master, remaster, who, snapshot_ok=True) + if ok or attempt + 1 >= len(retry_sizes): + break + attempt += 1 + # 只把还没落库的重新切包,已落库的跳过 + remaining = [m for m in targets if (5, m) not in have] + size = retry_sizes[attempt] + if not remaining or size <= 0: + break + batches = chunked(remaining, size) + result["failed"] = 0 + result["errors"].append( + "第 %d 次降级:改用每包 %d 首重试(剩余 %d 首)" % (attempt, size, len(remaining))) + result["retries"] = attempt + result.pop("landed", None) have5 = owned(user_id, 5) result["ownedMusic"] = len(have5) diff --git a/maipulse/data/icons/302.png b/maipulse/data/icons/302.png new file mode 100644 index 0000000..ed5f5cd Binary files /dev/null and b/maipulse/data/icons/302.png differ diff --git a/maipulse/html/index.html b/maipulse/html/index.html index 5978d40..272d70b 100644 --- a/maipulse/html/index.html +++ b/maipulse/html/index.html @@ -500,7 +500,7 @@
-
+
@@ -1859,17 +1859,19 @@ async function doUnlock(){ const body = {master:$('mMaster').checked, remaster:$('mRemaster').checked, dryRun:dry}; if(all){ body.all = true; - body.per = parseInt($('songPer').value || '4', 10); + body.per = parseInt($('songPer').value || '0', 10); body.limit = parseInt($('songLimit').value || '0', 10); - log(`全量解锁(每包 ${body.per} 首,试跑 ${body.limit||'全部'})${dry?',预检':''}`); + log(`全量解锁(${body.per>0?'每包 '+body.per+' 首':'一次发完'},试跑 ${body.limit||'全部'})${dry?',预检':''}`); }else{ body.ids = ids; log(`选中解锁 ${ids.length} 首${dry?',预检':''}`); } const r = await postLong('/api/unlock', body, all ? '正在批量解锁歌曲' : '正在解锁歌曲', - all ? `每包 ${body.per} 首、每包都要等满 60 秒,包数多时请耐心等。` : WAIT_SUB, - all ? 60 : 60); + all ? (body.per>0 + ? `每包 ${body.per} 首,每包都要登出重登换一局,包数多会慢。` + : '一次把整个曲库的解锁道具发上去,只需一局,通常十几秒。') : WAIT_SUB, + all ? 300 : 60); if(!r.ok){ log('失败:'+r.error, 'err'); if(r.trace) log(r.trace,'dim'); return; } if(all){ log(`完成 ${r.done} · 失败 ${r.failed} · 共 ${r.targets} 首 / ${r.batches} 包 · 现有 ${r.ownedMusic}`, diff --git a/maipulse/tools/release.py b/maipulse/tools/release.py index d98feb1..be59e08 100644 --- a/maipulse/tools/release.py +++ b/maipulse/tools/release.py @@ -36,6 +36,93 @@ UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0 Safari/537.36") +# 要同步进仓库的源码面(相对 APP_DIR)。只列「改了必须让用户拿到」的, +# 探针脚本、dump 工具、快照数据一律不进库。 +SYNC_DIRS = ["core", "html", "tools", "data"] +SYNC_FILES = ["app.py", "PLAN.md", "PLAN-APP.md", "PLAN-EXT.md"] + +# 数据目录里只带运行必需的资产,其余(用户数据 / 快照 / 缓存)排除 +DATA_KEEP = ["master.json", "master", "icons"] +SKIP_NAMES = {"__pycache__", ".release-token", "accounts.json", "shops.json", + "shop_current.json", "config.filled.json", "covers", "_backup", + "logs", "data"} +SKIP_SUFFIX = (".pyc", ".pyo") + + +def _force_copy(src, dst): + """覆盖式复制:仓库里旧文件可能带只读属性(html/assets 下就有只读的 mp3/png), + shutil.copy2 会直接 PermissionError。先摘掉只读再写。""" + import shutil + import stat + if os.path.exists(dst): + try: + os.chmod(dst, stat.S_IWRITE | stat.S_IREAD) + except OSError: + pass + shutil.copy2(src, dst) + + +def sync_source(verbose=True): + """把源码面同步进 REPO_DIR/maipulse。返回同步的文件数。""" + import shutil + dst_root = os.path.join(REPO_DIR, "maipulse") + os.makedirs(dst_root, exist_ok=True) + count = 0 + + for name in SYNC_FILES: + src = os.path.join(APP_DIR, name) + if os.path.isfile(src): + _force_copy(src, os.path.join(dst_root, name)) + count += 1 + + for d in ("core", "html", "tools"): + src_dir = os.path.join(APP_DIR, d) + if not os.path.isdir(src_dir): + continue + dst_dir = os.path.join(dst_root, d) + os.makedirs(dst_dir, exist_ok=True) + for root, dirs, files in os.walk(src_dir): + dirs[:] = [x for x in dirs if x not in SKIP_NAMES] + rel = os.path.relpath(root, src_dir) + target = dst_dir if rel == "." else os.path.join(dst_dir, rel) + os.makedirs(target, exist_ok=True) + for f in files: + if f in SKIP_NAMES or f.endswith(SKIP_SUFFIX): + continue + # tools 里的探针/dump 脚本不进库,只带运行与发版必需的 + if d == "tools" and f not in { + "build_apk.ps1", "blank_secrets.py", "apply_update.py", + "fetch_df.py", "dump_master.py", "release.py", "restart.ps1"}: + continue + _force_copy(os.path.join(root, f), os.path.join(target, f)) + count += 1 + + # data:只带曲库 / 图标这些运行资产 + data_src = os.path.join(APP_DIR, "data") + data_dst = os.path.join(dst_root, "data") + for name in DATA_KEEP: + s_item = os.path.join(data_src, name) + if os.path.isfile(s_item): + os.makedirs(data_dst, exist_ok=True) + _force_copy(s_item, os.path.join(data_dst, name)) + count += 1 + elif os.path.isdir(s_item): + for root, dirs, files in os.walk(s_item): + dirs[:] = [x for x in dirs if x not in SKIP_NAMES] + rel = os.path.relpath(root, data_src) + target = os.path.join(data_dst, rel) + os.makedirs(target, exist_ok=True) + for f in files: + if f in SKIP_NAMES or f.endswith(SKIP_SUFFIX): + continue + _force_copy(os.path.join(root, f), os.path.join(target, f)) + count += 1 + + if verbose: + print(" 源码同步 %d 个文件 -> %s" % (count, dst_root)) + return count + + def token() -> str: t = (os.environ.get("GITEA_TOKEN") or "").strip() if t: @@ -148,8 +235,7 @@ def main(): # 3) 同步进仓库并推送 print("\n[3/5] 推仓库") - import shutil - shutil.copy2(APP_PY, os.path.join(REPO_DIR, "maipulse", "app.py")) + sync_source() run(["git", "add", "-A"], cwd=REPO_DIR) run(["git", "-c", "user.name=shouko", "-c", "user.email=qmy2055885931@gmail.com", "commit", "-m", "release: v%s\n\n%s" % (new_ver, notes or "-")], cwd=REPO_DIR, check=False)