release: v1.0.1

全曲解锁改为一包发完(per 默认 0),不再每包 4 首反复登出重登;失败时自动降级到 200 首 / 50 首重试
This commit is contained in:
shouko committed 2026-10-29 11:52:51 +08:00
1 parent 791d11a5c2
commit 5220eb1708
7 files changed
+173 -58

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@ EasyMai 的路子是绕的:自建后端 `getUserMusic` + 水鱼 `update_record
| 鉴权 | jwt-token + import-token | 只要 userId + 机台 clientId,**不用登录** |
| 写操作 | 往水鱼导入成绩 | 无,纯只读 |
#### 已验证的接口形状(2026-10-28 实测,账号 <REDACTED>)
#### 已验证的接口形状(2026-10-28 实测,账号 12800621)
GetUserRating -> userRating:
rating : 0(服务端不填,别指望它)
+1 -1
View File
@@ -148,7 +148,7 @@ MaiTechUI 金票包 UpsertUserAllApi.poop() 原样:
它从不在登录后 60 秒内提交结算。服务端对"登录不到一分钟就交成绩"的包一律回
returnCode=1 然后把 userItemList / userMusicDetailList 整块丢掉。
已验证(账号 <REDACTED>):每次发包前补足 60 秒后,四类道具全部真落地。
已验证(账号 12042860):每次发包前补足 60 秒后,四类道具全部真落地。
| kind | 名称 | 发送 id | 库存变化 | waited |
| --- | --- | --- | --- | --- |
+2 -2
View File
@@ -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")
+74 -47
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+7 -5
View File
@@ -500,7 +500,7 @@
<label><input id="mRemaster" type="checkbox" checked> Re:MASTER</label>
</div>
<div class="row">
<div><label>每包曲目数</label><input id="songPer" type="number" value="4" min="1" max="8"></div>
<div><label>每包曲目数(0=一次发完)</label><input id="songPer" type="number" value="0" min="0" max="2000"></div>
<div><label>全量试跑首数(0=全部)</label><input id="songLimit" type="number" value="0" min="0"></div>
</div>
<div class="check">
@@ -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}`,
+88 -2
View File
@@ -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", "[email protected]",
"commit", "-m", "release: v%s\n\n%s" % (new_ver, notes or "-")], cwd=REPO_DIR, check=False)