release: v1.0.1
全曲解锁改为一包发完(per 默认 0),不再每包 4 首反复登出重登;失败时自动降级到 200 首 / 50 首重试
This commit is contained in:
1 parent
791d11a5c2
commit
5220eb1708
7 files changed
+173
-58
No files matched your search
@@ -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
@@ -148,7 +148,7 @@ MaiTechUI 金票包 UpsertUserAllApi.poop() 原样:
|
||||
它从不在登录后 60 秒内提交结算。服务端对"登录不到一分钟就交成绩"的包一律回
|
||||
returnCode=1 然后把 userItemList / userMusicDetailList 整块丢掉。
|
||||
|
||||
已验证(账号 <REDACTED>):每次发包前补足 60 秒后,四类道具全部真落地。
|
||||
已验证(账号 12042860):每次发包前补足 60 秒后,四类道具全部真落地。
|
||||
|
||||
| kind | 名称 | 发送 id | 库存变化 | waited |
|
||||
| --- | --- | --- | --- | --- |
|
||||
|
||||
+2
-2
@@ -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
@@ -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 |
@@ -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}`,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user