diff --git a/android/app/build.gradle b/android/app/build.gradle
index 44dd1bd..9b88cd9 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -11,8 +11,9 @@ android {
applicationId "com.maipulse.app"
minSdk 24
targetSdk 34
- versionCode 1
- versionName "1.0"
+ // 和网页里的 APP_VERSION 对齐,升级时一起改
+ versionCode 102
+ versionName "1.0.2"
ndk {
abiFilters "arm64-v8a"
@@ -20,7 +21,7 @@ android {
// Chaquopy 16 的 DSL 挂在 android.defaultConfig 下面
python {
- buildPython "C:/Users/20558/AppData/Local/Programs/Python/Python38/python.exe"
+ buildPython "python"
pip {
install "flask"
install "requests"
@@ -33,6 +34,12 @@ android {
// 而 zipalign 之后我们要用 apksigner 重签。两边不是同一个 key 的话,
// 出来的包签名不一样,用户必须卸载才能覆盖安装。
signingConfigs {
+ release {
+ storeFile file("../maipulse-debug.keystore")
+ storePassword "android"
+ keyAlias "androiddebugkey"
+ keyPassword "android"
+ }
debug {
storeFile file("../maipulse-debug.keystore")
storePassword "android"
@@ -46,7 +53,13 @@ android {
signingConfig signingConfigs.debug
}
release {
- minifyEnabled false
+ // 出正式包:关调试标志,开 R8 去死代码 + 混淆 Java 侧
+ signingConfig signingConfigs.release
+ debuggable false
+ minifyEnabled true
+ shrinkResources true
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
+ 'proguard-rules.pro'
}
}
@@ -56,10 +69,25 @@ android {
}
androidResources {
+ // 这几类是已经压过的/要被 mmap 的,再压反而慢
noCompress 'json', 'png'
}
+
+ lint {
+ // release 构建默认跑 lintVital,它会联网拉 lint-gradle 依赖。
+ // 这条链断了整包就出不来,而 lint 结果对发版没有决定意义,直接关掉。
+ checkReleaseBuilds false
+ abortOnError false
+ }
+
+ packagingOptions {
+ jniLibs {
+ // 原生库直接从 APK 里 mmap,不再解压一份到 /data
+ useLegacyPackaging = false
+ }
+ }
}
dependencies {
implementation 'androidx.core:core:1.12.0'
-}
+}
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index f8a9fd3..823bf27 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,19 +5,23 @@
+ android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
+ android:windowSoftInputMode="adjustResize"
+ android:launchMode="singleTask">
diff --git a/android/app/src/main/java/com/maipulse/app/MainActivity.java b/android/app/src/main/java/com/maipulse/app/MainActivity.java
index 99d9c29..b40b189 100644
--- a/android/app/src/main/java/com/maipulse/app/MainActivity.java
+++ b/android/app/src/main/java/com/maipulse/app/MainActivity.java
@@ -7,24 +7,32 @@ import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
-import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.graphics.Color;
+import android.graphics.Typeface;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.provider.Settings;
import android.util.Log;
+import android.util.TypedValue;
+import android.view.Gravity;
import android.view.KeyEvent;
import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowManager;
import android.webkit.JavascriptInterface;
import android.webkit.ValueCallback;
+import android.webkit.WebChromeClient;
+import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
-import android.webkit.WebChromeClient;
import android.webkit.WebViewClient;
import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
@@ -45,54 +53,144 @@ import java.net.Socket;
/**
* maipulse 的 Android 外壳。
*
- * 流程:复制 assets/app -> filesDir/app(assets 只读,Flask 要写 logs 与缓存),
- * 起 Chaquopy 跑 Flask 绑 127.0.0.1:8787,端口通了再把 WebView 指过去。
+ * 启动顺序:
+ * 1. 先画原生启动页(图标 + 名字 + 状态文字),保证点开立刻有东西
+ * 2. 后台线程把 assets/app 复制到 filesDir/app(assets 只读,Flask 要写 logs)
+ * 3. 回到主线程装载 Chaquopy(Python.start 有主线程要求)
+ * 4. 后台线程起 Flask,另一个线程轮询 127.0.0.1:8787
+ * 5. 端口通了再加载 WebView,等 onPageFinished 把启动页淡出、网页淡入
*
- * 额外给了网页一个 Maipulse.installApk(url):走系统 DownloadManager 下 APK,
- * 下完弹安装器,这样更新不用让用户自己去找文件。
+ * 用户导入的机台配置(astral/config.py 和 astral/api/MaimaiNet.py)在资源重建时会被保留。
*/
public class MainActivity extends Activity {
private static final String TAG = "maipulse";
private static final int PORT = 8787;
+ private static final String HOME = "http://127.0.0.1:" + PORT + "/";
+ private static final String BG = "#EEF2F9";
+
+ /* 启动页 */
+ private View splash;
+ private TextView splashTip;
+ private TextView splashRetry;
+
+ private volatile WebView web;
+ private volatile boolean firstPageDone = false;
+ private volatile boolean loadRequested = false;
- private WebView web;
- private TextView tip;
private static final int REQ_PICK_FILE = 1001;
private ValueCallback filePathCallback;
private long pendingDownloadId = -1;
private BroadcastReceiver downloadReceiver;
+ private long lastBackPress = 0;
@SuppressLint("SetJavaScriptEnabled")
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+ setupSystemBars();
+ buildUi();
+
+ // 关掉 WebView 远程调试(debug 包里默认是可调试的,能被 chrome://inspect 接管)
+ try {
+ WebView.setWebContentsDebuggingEnabled(false);
+ } catch (Throwable ignored) {
+ }
+
+ registerDownloadReceiver();
+
+ // web.post 保证首帧先画出来,再进到会阻塞的主线程初始化
+ web.post(this::prepareAndBoot);
+ }
+
+ /* ---------------- 系统栏 ---------------- */
+
+ private void setupSystemBars() {
+ Window w = getWindow();
+ w.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
+ w.setStatusBarColor(Color.parseColor(BG));
+ w.setNavigationBarColor(Color.WHITE);
+
+ int flags = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ flags |= View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
+ }
+ w.getDecorView().setSystemUiVisibility(flags);
+ }
+
+ /* ---------------- 界面 ---------------- */
+
+ private int dp(float v) {
+ return Math.round(TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, v, getResources().getDisplayMetrics()));
+ }
+
+ private void buildUi() {
FrameLayout root = new FrameLayout(this);
- root.setBackgroundColor(Color.parseColor("#EEF2F9"));
+ root.setBackgroundColor(Color.parseColor(BG));
web = new WebView(this);
- web.setBackgroundColor(Color.parseColor("#EEF2F9"));
+ web.setBackgroundColor(Color.parseColor(BG));
+ web.setAlpha(0f);
+ web.setOverScrollMode(View.OVER_SCROLL_NEVER);
+ web.setVerticalScrollBarEnabled(false);
+ web.setHorizontalScrollBarEnabled(false);
+ web.setLongClickable(false);
+ web.setHapticFeedbackEnabled(false);
+
WebSettings s = web.getSettings();
s.setJavaScriptEnabled(true);
s.setDomStorageEnabled(true);
- s.setAllowFileAccess(true);
s.setDatabaseEnabled(true);
s.setMediaPlaybackRequiresUserGesture(false);
- s.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
s.setCacheMode(WebSettings.LOAD_DEFAULT);
- // 禁掉双指缩放与双击放大(网页里能放大就是这里没设)
+ // 只通过 http://127.0.0.1 访问本地页面,不需要 file:// 与 content://
+ s.setAllowFileAccess(false);
+ s.setAllowContentAccess(false);
+ s.setJavaScriptCanOpenWindowsAutomatically(false);
+ s.setGeolocationEnabled(false);
+ s.setSavePassword(false);
s.setSupportZoom(false);
s.setBuiltInZoomControls(false);
s.setDisplayZoomControls(false);
s.setTextZoom(100);
- // 长按不再弹出「复制/分享」这类浏览器菜单
- web.setLongClickable(false);
- web.setHapticFeedbackEnabled(false);
- web.setWebViewClient(new WebViewClient());
+ s.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ s.setSafeBrowsingEnabled(false);
+ }
+
+ web.setWebViewClient(new WebViewClient() {
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView v, WebResourceRequest req) {
+ Uri u = req.getUrl();
+ String host = u.getHost();
+ if (host == null) return true;
+ // 本地服务内部跳转放行,其余一律甩给系统浏览器——不然点外链就变成浏览器了
+ if ("127.0.0.1".equals(host) || "localhost".equals(host)) return false;
+ try {
+ startActivity(new Intent(Intent.ACTION_VIEW, u));
+ } catch (Exception e) {
+ Toast.makeText(MainActivity.this, "没有能打开该链接的应用", Toast.LENGTH_SHORT).show();
+ }
+ return true;
+ }
+
+ @Override
+ public void onPageFinished(WebView v, String url) {
+ if (firstPageDone) return;
+ firstPageDone = true;
+ showApp();
+ }
+
+ @Override
+ public void onReceivedError(WebView v, WebResourceRequest req, android.webkit.WebResourceError err) {
+ if (req.isForMainFrame()) {
+ Log.e(TAG, "page error: " + err.getDescription());
+ }
+ }
+ });
- // 网页里的 走 WebChromeClient.onShowFileChooser,挂在
- // WebViewClient 上编译不过,必须单独设一个 chrome client。
web.setWebChromeClient(new WebChromeClient() {
@Override
public boolean onShowFileChooser(WebView view, ValueCallback callback,
@@ -112,19 +210,85 @@ public class MainActivity extends Activity {
});
web.addJavascriptInterface(new Bridge(), "Maipulse");
- tip = new TextView(this);
- tip.setText("正在启动服务…");
- tip.setTextColor(Color.parseColor("#5A6A82"));
- tip.setTextSize(15);
- tip.setPadding(48, 48, 48, 48);
-
root.addView(web, new FrameLayout.LayoutParams(-1, -1));
- root.addView(tip, new FrameLayout.LayoutParams(-1, -1));
+ splash = buildSplash();
+ root.addView(splash, new FrameLayout.LayoutParams(-1, -1));
setContentView(root);
+ }
- registerDownloadReceiver();
- new Thread(this::bootPython, "maipulse-boot").start();
- new Thread(this::waitAndLoad, "maipulse-wait").start();
+ /** 原生启动页:图标 + 名字 + 一句状态 + 重试按钮(启动失败才出现) */
+ private View buildSplash() {
+ LinearLayout box = new LinearLayout(this);
+ box.setOrientation(LinearLayout.VERTICAL);
+ box.setGravity(Gravity.CENTER);
+ box.setBackgroundColor(Color.parseColor(BG));
+ box.setClickable(true); // 吃掉点击,防止启动过程中点穿到下面的 WebView
+
+ ImageView icon = new ImageView(this);
+ try {
+ icon.setImageResource(R.mipmap.ic_launcher);
+ } catch (Throwable ignored) {
+ }
+ LinearLayout.LayoutParams ip = new LinearLayout.LayoutParams(dp(72), dp(72));
+ ip.bottomMargin = dp(18);
+ box.addView(icon, ip);
+
+ TextView title = new TextView(this);
+ title.setText("maipulse");
+ title.setTextSize(22);
+ title.setTypeface(Typeface.DEFAULT_BOLD);
+ title.setTextColor(Color.parseColor("#1B1C21"));
+ title.setLetterSpacing(0.06f);
+ box.addView(title, new LinearLayout.LayoutParams(-2, -2));
+
+ splashTip = new TextView(this);
+ splashTip.setText("正在启动…");
+ splashTip.setTextSize(13);
+ splashTip.setTextColor(Color.parseColor("#6B7280"));
+ LinearLayout.LayoutParams tp = new LinearLayout.LayoutParams(-2, -2);
+ tp.topMargin = dp(10);
+ box.addView(splashTip, tp);
+
+ splashRetry = new TextView(this);
+ splashRetry.setText("点这里重试");
+ splashRetry.setTextSize(14);
+ splashRetry.setTextColor(Color.parseColor("#D6336C"));
+ splashRetry.setPadding(dp(18), dp(10), dp(18), dp(10));
+ splashRetry.setVisibility(View.GONE);
+ splashRetry.setOnClickListener(v -> {
+ splashRetry.setVisibility(View.GONE);
+ splashTip.setText("正在重试…");
+ web.post(this::prepareAndBoot);
+ });
+ LinearLayout.LayoutParams rp = new LinearLayout.LayoutParams(-2, -2);
+ rp.topMargin = dp(16);
+ box.addView(splashRetry, rp);
+
+ return box;
+ }
+
+ private void tip(final String text) {
+ runOnUiThread(() -> {
+ if (splashTip != null) splashTip.setText(text);
+ });
+ }
+
+ private void showApp() {
+ if (web != null) web.animate().alpha(1f).setDuration(240).start();
+ if (splash != null) {
+ splash.animate().alpha(0f).setDuration(240).withEndAction(() -> {
+ if (splash != null) splash.setVisibility(View.GONE);
+ }).start();
+ }
+ }
+
+ private void fail(final String msg) {
+ Log.e(TAG, msg);
+ runOnUiThread(() -> {
+ if (splash != null) splash.setAlpha(1f);
+ if (splashTip != null) splashTip.setText(msg);
+ if (splashRetry != null) splashRetry.setVisibility(View.VISIBLE);
+ });
}
/* ---------------- 网页可调的接口 ---------------- */
@@ -155,8 +319,11 @@ public class MainActivity extends Activity {
try {
File dir = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
if (dir != null) {
- for (File f : dir.listFiles()) {
- if (f.getName().endsWith(".apk")) f.delete();
+ File[] old = dir.listFiles();
+ if (old != null) {
+ for (File f : old) {
+ if (f.getName().endsWith(".apk")) f.delete();
+ }
}
}
DownloadManager.Request req = new DownloadManager.Request(Uri.parse(url));
@@ -199,7 +366,6 @@ public class MainActivity extends Activity {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
&& !getPackageManager().canRequestPackageInstalls()) {
- // 没有「安装未知应用」权限,跳设置页让用户开
Intent s = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:" + getPackageName()));
startActivity(s);
@@ -217,76 +383,157 @@ public class MainActivity extends Activity {
}
}
- /* ---------------- Python 启动 ---------------- */
+ /* ---------------- 启动流程 ---------------- */
- private void bootPython() {
- try {
- File files = getFilesDir();
- File appDir = new File(files, "app");
- File stamp = new File(files, "asset.rev");
- // 内置资源版本号:打包时由 build_apk.ps1 写进 assets/app/asset.rev,
- // 每次打包都会变,所以数据更新后一定能触发重建(手写常量太容易忘)。
- String want = "";
- try (InputStream in = getAssets().open("app/asset.rev")) {
- byte[] b = new byte[128];
- int n = in.read(b);
- if (n > 0) want = new String(b, 0, n, "UTF-8").trim();
- } catch (Exception ignored) {
- }
- String have = "";
+ /**
+ * 主线程入口:先把资源复制这一坨挪到后台,再回主线程做 Python.start()。
+ * Chaquopy 要求 Python.start() 在主线程调用,所以这里不能整个丢进子线程。
+ */
+ private void prepareAndBoot() {
+ tip("正在准备资源…");
+ new Thread(() -> {
try {
- if (stamp.exists()) {
- byte[] b = new byte[(int) stamp.length()];
- try (InputStream in = new FileInputStream(stamp)) {
- int n = in.read(b);
- if (n > 0) have = new String(b, 0, n, "UTF-8").trim();
- }
- }
- } catch (Exception ignored) {
+ prepareAssets();
+ } catch (Throwable t) {
+ fail("资源解包失败:" + t);
+ return;
}
- boolean broken = !appDir.exists()
- || !new File(appDir, "app.py").exists()
- || !new File(appDir, "data/master.json").exists();
- if (broken || want.isEmpty() || !want.equals(have)) {
- tip("正在释放内置资源");
- // 用户导入的机台配置要保住,其余整目录重建(否则旧资源永远不更新)
- byte[] conf = null;
- File confFile = new File(new File(appDir, "astral"), "config.py");
- if (confFile.exists() && confFile.length() > 0) {
- conf = new byte[(int) confFile.length()];
- try (InputStream in = new FileInputStream(confFile)) {
- int n = in.read(conf);
- if (n <= 0) conf = null;
- }
- }
- deleteTree(appDir);
- copyAssetDir("app", appDir);
- if (conf != null) {
- File cf = new File(new File(appDir, "astral"), "config.py");
- File parent = cf.getParentFile();
- if (parent != null && !parent.exists()) parent.mkdirs();
- try (OutputStream out = new FileOutputStream(cf)) {
- out.write(conf);
- }
- }
- try (OutputStream out = new FileOutputStream(stamp)) {
- out.write(want.getBytes("UTF-8"));
- }
- }
- tip("正在加载 Python 运行时…");
+ runOnUiThread(() -> {
+ tip("正在加载运行时…");
+ // 让这句提示先渲染出来,再进阻塞的 Python.start()
+ web.postDelayed(this::startPython, 60);
+ });
+ }, "mp-assets").start();
+ }
+
+ private void startPython() {
+ try {
if (!Python.isStarted()) {
Python.start(new AndroidPlatform(this));
}
- tip("正在启动本地服务…");
- Python.getInstance().getModule("android_boot").callAttr("boot", files.getAbsolutePath());
} catch (Throwable t) {
- Log.e(TAG, "boot failed", t);
- tip("启动失败:" + t);
+ fail("运行时装载失败:" + t);
+ return;
+ }
+ tip("正在启动本地服务…");
+ new Thread(this::bootFlask, "mp-flask").start();
+ new Thread(this::waitAndLoad, "mp-wait").start();
+ }
+
+ /** assets/app -> filesDir/app;内置资源版本号变了就整目录重建,但保住用户导入的配置 */
+ private void prepareAssets() throws IOException {
+ File files = getFilesDir();
+ File appDir = new File(files, "app");
+ File stamp = new File(files, "asset.rev");
+
+ String want = "";
+ try (InputStream in = getAssets().open("app/asset.rev")) {
+ byte[] b = new byte[128];
+ int n = in.read(b);
+ if (n > 0) want = new String(b, 0, n, "UTF-8").trim();
+ } catch (Exception ignored) {
+ }
+
+ String have = "";
+ try {
+ if (stamp.exists()) {
+ byte[] b = new byte[(int) stamp.length()];
+ try (InputStream in = new FileInputStream(stamp)) {
+ int n = in.read(b);
+ if (n > 0) have = new String(b, 0, n, "UTF-8").trim();
+ }
+ }
+ } catch (Exception ignored) {
+ }
+
+ boolean broken = !appDir.exists()
+ || !new File(appDir, "app.py").exists()
+ || !new File(appDir, "data/master.json").exists();
+
+ if (!broken && !want.isEmpty() && want.equals(have)) return;
+
+ tip("正在释放内置资源");
+ // 用户填的机台身份写在这两个文件里,重建前先捞出来,重建后写回去
+ byte[] confAstral = readFile(new File(new File(appDir, "astral"), "config.py"));
+ byte[] confNet = readFile(new File(new File(appDir, "astral/api"), "MaimaiNet.py"));
+
+ deleteTree(appDir);
+ copyAssetDir("app", appDir);
+
+ writeFile(new File(new File(appDir, "astral"), "config.py"), confAstral);
+ writeFile(new File(new File(appDir, "astral/api"), "MaimaiNet.py"), confNet);
+
+ try (OutputStream out = new FileOutputStream(stamp)) {
+ out.write(want.getBytes("UTF-8"));
}
}
- private void tip(final String text) {
- runOnUiThread(() -> tip.setText(text));
+ private byte[] readFile(File f) {
+ if (f == null || !f.exists() || f.length() <= 0) return null;
+ try (InputStream in = new FileInputStream(f)) {
+ byte[] b = new byte[(int) f.length()];
+ int n = in.read(b);
+ if (n <= 0) return null;
+ if (n < b.length) {
+ byte[] c = new byte[n];
+ System.arraycopy(b, 0, c, 0, n);
+ return c;
+ }
+ return b;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private void writeFile(File f, byte[] data) throws IOException {
+ if (data == null || f == null) return;
+ File parent = f.getParentFile();
+ if (parent != null && !parent.exists() && !parent.mkdirs()) {
+ throw new IOException("mkdirs failed: " + parent);
+ }
+ try (OutputStream out = new FileOutputStream(f)) {
+ out.write(data);
+ }
+ }
+
+ private void bootFlask() {
+ try {
+ Python.getInstance().getModule("android_boot")
+ .callAttr("boot", getFilesDir().getAbsolutePath());
+ } catch (Throwable t) {
+ Log.e(TAG, "flask exited", t);
+ fail("本地服务异常退出:" + t);
+ }
+ }
+
+ private void waitAndLoad() {
+ if (loadRequested) return;
+ // 最多等 90 秒。Chaquopy 首次装载 + 资源解包在低端机上确实会慢。
+ for (int i = 0; i < 450; i++) {
+ if (web == null) return;
+ try (Socket sock = new Socket()) {
+ sock.connect(new InetSocketAddress("127.0.0.1", PORT), 300);
+ loadRequested = true;
+ runOnUiThread(() -> {
+ if (web != null) {
+ web.loadUrl(HOME);
+ // 兜底:页面迟迟不触发 onPageFinished 也不能一直卡在启动页
+ web.postDelayed(() -> {
+ if (!firstPageDone) showApp();
+ }, 15000);
+ }
+ });
+ return;
+ } catch (IOException ignored) {
+ }
+ try {
+ Thread.sleep(200);
+ } catch (InterruptedException e) {
+ return;
+ }
+ if (i == 25) tip("还在启动,稍等…");
+ }
+ fail("本地服务没能在 90 秒内启动");
}
private void deleteTree(File f) {
@@ -316,7 +563,6 @@ public class MainActivity extends Activity {
}
private void copyAssetFile(String src, File dst) throws IOException {
- // 不跳过已存在文件:整目录重建后旧的残留必须被覆盖
File parent = dst.getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
throw new IOException("mkdirs failed: " + parent);
@@ -329,25 +575,7 @@ public class MainActivity extends Activity {
}
}
- private void waitAndLoad() {
- for (int i = 0; i < 200; i++) {
- try (Socket sock = new Socket()) {
- sock.connect(new InetSocketAddress("127.0.0.1", PORT), 300);
- runOnUiThread(() -> {
- tip.setVisibility(View.GONE);
- web.loadUrl("http://127.0.0.1:" + PORT + "/");
- });
- return;
- } catch (IOException ignored) {
- try {
- Thread.sleep(200);
- } catch (InterruptedException e) {
- return;
- }
- }
- }
- tip("服务没有起来,看看日志");
- }
+ /* ---------------- 生命周期 ---------------- */
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
@@ -375,8 +603,23 @@ public class MainActivity extends Activity {
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
- if (keyCode == KeyEvent.KEYCODE_BACK && web != null && web.canGoBack()) {
- web.goBack();
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ // 启动页还在 = 还没进主界面,直接交给系统
+ if (splash != null && splash.getVisibility() == View.VISIBLE
+ && splash.getAlpha() > 0.5f) {
+ return super.onKeyDown(keyCode, event);
+ }
+ if (web != null && web.canGoBack()) {
+ web.goBack();
+ return true;
+ }
+ long now = System.currentTimeMillis();
+ if (now - lastBackPress < 2000) {
+ finish();
+ } else {
+ lastBackPress = now;
+ Toast.makeText(this, "再按一次退出", Toast.LENGTH_SHORT).show();
+ }
return true;
}
return super.onKeyDown(keyCode, event);
@@ -389,6 +632,8 @@ public class MainActivity extends Activity {
} catch (Exception ignored) {
}
if (web != null) {
+ ViewGroup p = (ViewGroup) web.getParent();
+ if (p != null) p.removeView(web);
web.destroy();
web = null;
}
diff --git a/android/app/src/main/python/android_boot.py b/android/app/src/main/python/android_boot.py
index 51ab203..b5dcf55 100644
--- a/android/app/src/main/python/android_boot.py
+++ b/android/app/src/main/python/android_boot.py
@@ -3,13 +3,33 @@
为什么不在 assets 里直接 import:app.py 顶层会 os.makedirs(logs),
assets 是只读的,必须用 Java 侧复制出来的可写副本。
+
+为什么要先探一次端口:Activity 退出后进程可能还在(Flask 线程是常驻的),
+用户再点图标进来时 boot() 会被再调用一次。这时 8787 已经有服务在跑,
+直接 return 让外壳去连既有服务,比抛 "Address already in use" 干净。
"""
import os
+import socket
import sys
PORT = 8787
+def _port_in_use():
+ s = socket.socket()
+ s.settimeout(0.3)
+ try:
+ s.connect(("127.0.0.1", PORT))
+ return True
+ except OSError:
+ return False
+ finally:
+ try:
+ s.close()
+ except Exception:
+ pass
+
+
def boot(files_dir):
base = os.path.join(files_dir, "app")
if base not in sys.path:
@@ -21,6 +41,10 @@ def boot(files_dir):
os.environ.setdefault("MAIPULSE_PORT", str(PORT))
os.chdir(base)
+ # 上一次的 Flask 还活着就直接复用,别再起一个
+ if _port_in_use():
+ return "already running"
+
import app as maipulse_app # noqa: E402
# 绑定 127.0.0.1:只有本机 WebView 能访问
maipulse_app.app.run(host="127.0.0.1", port=PORT, debug=False,
diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..290e181
--- /dev/null
+++ b/android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
deleted file mode 100644
index 61285a6..0000000
Binary files a/android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ
diff --git a/android/gradlew b/android/gradlew
deleted file mode 100644
index adff685..0000000
--- a/android/gradlew
+++ /dev/null
@@ -1,248 +0,0 @@
-#!/bin/sh
-
-#
-# Copyright © 2015 the original authors.
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# https://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-#
-# SPDX-License-Identifier: Apache-2.0
-#
-
-##############################################################################
-#
-# Gradle start up script for POSIX generated by Gradle.
-#
-# Important for running:
-#
-# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
-# noncompliant, but you have some other compliant shell such as ksh or
-# bash, then to run this script, type that shell name before the whole
-# command line, like:
-#
-# ksh Gradle
-#
-# Busybox and similar reduced shells will NOT work, because this script
-# requires all of these POSIX shell features:
-# * functions;
-# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
-# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
-# * compound commands having a testable exit status, especially «case»;
-# * various built-in commands including «command», «set», and «ulimit».
-#
-# Important for patching:
-#
-# (2) This script targets any POSIX shell, so it avoids extensions provided
-# by Bash, Ksh, etc; in particular arrays are avoided.
-#
-# The "traditional" practice of packing multiple parameters into a
-# space-separated string is a well documented source of bugs and security
-# problems, so this is (mostly) avoided, by progressively accumulating
-# options in "$@", and eventually passing that to Java.
-#
-# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
-# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
-# see the in-line comments for details.
-#
-# There are tweaks for specific operating systems such as AIX, CygWin,
-# Darwin, MinGW, and NonStop.
-#
-# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
-# within the Gradle project.
-#
-# You can find Gradle at https://github.com/gradle/gradle/.
-#
-##############################################################################
-
-# Attempt to set APP_HOME
-
-# Resolve links: $0 may be a link
-app_path=$0
-
-# Need this for daisy-chained symlinks.
-while
- APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
- [ -h "$app_path" ]
-do
- ls=$( ls -ld "$app_path" )
- link=${ls#*' -> '}
- case $link in #(
- /*) app_path=$link ;; #(
- *) app_path=$APP_HOME$link ;;
- esac
-done
-
-# This is normally unused
-# shellcheck disable=SC2034
-APP_BASE_NAME=${0##*/}
-# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
-APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
-
-# Use the maximum available, or set MAX_FD != -1 to use that value.
-MAX_FD=maximum
-
-warn () {
- echo "$*"
-} >&2
-
-die () {
- echo
- echo "$*"
- echo
- exit 1
-} >&2
-
-# OS specific support (must be 'true' or 'false').
-cygwin=false
-msys=false
-darwin=false
-nonstop=false
-case "$( uname )" in #(
- CYGWIN* ) cygwin=true ;; #(
- Darwin* ) darwin=true ;; #(
- MSYS* | MINGW* ) msys=true ;; #(
- NONSTOP* ) nonstop=true ;;
-esac
-
-
-
-# Determine the Java command to use to start the JVM.
-if [ -n "$JAVA_HOME" ] ; then
- if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
- # IBM's JDK on AIX uses strange locations for the executables
- JAVACMD=$JAVA_HOME/jre/sh/java
- else
- JAVACMD=$JAVA_HOME/bin/java
- fi
- if [ ! -x "$JAVACMD" ] ; then
- die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
-
-Please set the JAVA_HOME variable in your environment to match the
-location of your Java installation."
- fi
-else
- JAVACMD=java
- if ! command -v java >/dev/null 2>&1
- then
- die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-
-Please set the JAVA_HOME variable in your environment to match the
-location of your Java installation."
- fi
-fi
-
-# Increase the maximum file descriptors if we can.
-if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
- case $MAX_FD in #(
- max*)
- # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
- # shellcheck disable=SC2039,SC3045
- MAX_FD=$( ulimit -H -n ) ||
- warn "Could not query maximum file descriptor limit"
- esac
- case $MAX_FD in #(
- '' | soft) :;; #(
- *)
- # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
- # shellcheck disable=SC2039,SC3045
- ulimit -n "$MAX_FD" ||
- warn "Could not set maximum file descriptor limit to $MAX_FD"
- esac
-fi
-
-# Collect all arguments for the java command, stacking in reverse order:
-# * args from the command line
-# * the main class name
-# * -classpath
-# * -D...appname settings
-# * --module-path (only if needed)
-# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
-
-# For Cygwin or MSYS, switch paths to Windows format before running java
-if "$cygwin" || "$msys" ; then
- APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
-
- JAVACMD=$( cygpath --unix "$JAVACMD" )
-
- # Now convert the arguments - kludge to limit ourselves to /bin/sh
- for arg do
- if
- case $arg in #(
- -*) false ;; # don't mess with options #(
- /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
- [ -e "$t" ] ;; #(
- *) false ;;
- esac
- then
- arg=$( cygpath --path --ignore --mixed "$arg" )
- fi
- # Roll the args list around exactly as many times as the number of
- # args, so each arg winds up back in the position where it started, but
- # possibly modified.
- #
- # NB: a `for` loop captures its iteration list before it begins, so
- # changing the positional parameters here affects neither the number of
- # iterations, nor the values presented in `arg`.
- shift # remove old arg
- set -- "$@" "$arg" # push replacement arg
- done
-fi
-
-
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
-
-# Collect all arguments for the java command:
-# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
-# and any embedded shellness will be escaped.
-# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
-# treated as '${Hostname}' itself on the command line.
-
-set -- \
- "-Dorg.gradle.appname=$APP_BASE_NAME" \
- -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
- "$@"
-
-# Stop when "xargs" is not available.
-if ! command -v xargs >/dev/null 2>&1
-then
- die "xargs is not available"
-fi
-
-# Use "xargs" to parse quoted args.
-#
-# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
-#
-# In Bash we could simply go:
-#
-# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
-# set -- "${ARGS[@]}" "$@"
-#
-# but POSIX shell has neither arrays nor command substitution, so instead we
-# post-process each arg (as a line of input to sed) to backslash-escape any
-# character that might be a shell metacharacter, then use eval to reverse
-# that process (while maintaining the separation between arguments), and wrap
-# the whole thing up as a single "set" statement.
-#
-# This will of course break if any of these variables contains a newline or
-# an unmatched quote.
-#
-
-eval "set -- $(
- printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
- xargs -n1 |
- sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
- tr '\n' ' '
- )" '"$@"'
-
-exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
deleted file mode 100644
index c4bdd3a..0000000
--- a/android/gradlew.bat
+++ /dev/null
@@ -1,93 +0,0 @@
-@rem
-@rem Copyright 2015 the original author or authors.
-@rem
-@rem Licensed under the Apache License, Version 2.0 (the "License");
-@rem you may not use this file except in compliance with the License.
-@rem You may obtain a copy of the License at
-@rem
-@rem https://www.apache.org/licenses/LICENSE-2.0
-@rem
-@rem Unless required by applicable law or agreed to in writing, software
-@rem distributed under the License is distributed on an "AS IS" BASIS,
-@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-@rem See the License for the specific language governing permissions and
-@rem limitations under the License.
-@rem
-@rem SPDX-License-Identifier: Apache-2.0
-@rem
-
-@if "%DEBUG%"=="" @echo off
-@rem ##########################################################################
-@rem
-@rem Gradle startup script for Windows
-@rem
-@rem ##########################################################################
-
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
-
-set DIRNAME=%~dp0
-if "%DIRNAME%"=="" set DIRNAME=.
-@rem This is normally unused
-set APP_BASE_NAME=%~n0
-set APP_HOME=%DIRNAME%
-
-@rem Resolve any "." and ".." in APP_HOME to make it shorter.
-for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
-
-@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
-
-@rem Find java.exe
-if defined JAVA_HOME goto findJavaFromJavaHome
-
-set JAVA_EXE=java.exe
-%JAVA_EXE% -version >NUL 2>&1
-if %ERRORLEVEL% equ 0 goto execute
-
-echo. 1>&2
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
-echo. 1>&2
-echo Please set the JAVA_HOME variable in your environment to match the 1>&2
-echo location of your Java installation. 1>&2
-
-goto fail
-
-:findJavaFromJavaHome
-set JAVA_HOME=%JAVA_HOME:"=%
-set JAVA_EXE=%JAVA_HOME%/bin/java.exe
-
-if exist "%JAVA_EXE%" goto execute
-
-echo. 1>&2
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
-echo. 1>&2
-echo Please set the JAVA_HOME variable in your environment to match the 1>&2
-echo location of your Java installation. 1>&2
-
-goto fail
-
-:execute
-@rem Setup the command line
-
-
-
-@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
-
-:end
-@rem End local scope for the variables with windows NT shell
-if %ERRORLEVEL% equ 0 goto mainEnd
-
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-set EXIT_CODE=%ERRORLEVEL%
-if %EXIT_CODE% equ 0 set EXIT_CODE=1
-if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
-exit /b %EXIT_CODE%
-
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
-
-:omega
diff --git a/astral/api/MaimaiNet.py b/astral/api/MaimaiNet.py
index 5145180..7953dbf 100644
--- a/astral/api/MaimaiNet.py
+++ b/astral/api/MaimaiNet.py
@@ -11,17 +11,17 @@ MaimaiNet.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
1) 服务器基址与 API 路径
source/Assembly-CSharp/Manager/OperationManager.cs:225,338
- ServerUri = Auth.GameServerUri + MovieServerUri("/")
+ ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
source/Assembly-CSharp/Net.Packet/Packet.cs:116
Client = NetHttpClient.Create(BaseUrl + Obfuscator(query.Api))
source/Assembly-CSharp/Net.Packet/Packet.cs:485
- Obfuscator(s) = md5_hex_lower(s + "")
+ Obfuscator(s) = md5_hex_lower(s + "8bF76dE9")
source/Assembly-CSharp/Net.VO/NetQuery.cs:14
Api = api + "MaimaiChn"
- => 最终 URL = BASE_URL + md5(逻辑接口名 + "MaimaiChn" + "")
+ => 最终 URL = BASE_URL + md5(逻辑接口名 + "MaimaiChn" + "8bF76dE9")
- 已用真实日志验证:md5("GetGameSettingApiMaimaiChn" + "")
+ 已用真实日志验证:md5("GetGameSettingApiMaimaiChn" + "8bF76dE9")
= 83a5d5a20b062c5c2ad817460b8f7f76
(source/2026-10-05.log:1 的请求就是打到这个尾段)
@@ -121,17 +121,17 @@ from config import CLIENT_ID, MAI_ENCODING, TIMEOUT_LONG_MS, TIMEOUT_MS
#: 游戏服务器(Maimai2Servlet)
#: 依据 source/Assembly-CSharp/Manager/OperationManager.cs:225,338
-#: _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("/")
-BASE_URL = ""
+#: _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
+BASE_URL = "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/"
#: AES 参数,依据 source/Assembly-CSharp/Net/CipherAES.cs:11-12
#: AesKey 32 字节 -> AES-256;AesIV 16 字节
-AES_KEY = b""
-AES_IV = b""
+AES_KEY = b"FKM2JX:VjZNK6hc:A0;24DjU9W6ZsRH["
#: API 名混淆盐,依据 source/Assembly-CSharp/Net.Packet/Packet.cs:44
#: 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex
-OBFUSCATE_PARAM = ""
+OBFUSCATE_PARAM = "8bF76dE9"
from .LogSetup import log_debug, log_error, log_request, log_response
@@ -162,7 +162,7 @@ def obfuscate_api_name(api_name: str) -> str:
对逻辑接口名做 MD5 混淆,得到 URL 尾段(对应 Packet.Obfuscator)。
逻辑名 "GetUserPreviewApi" -> "GetUserPreviewApiMaimaiChn"
- -> md5(上面 + "")
+ -> md5(上面 + "8bF76dE9")
参数:
api_name: 逻辑接口名,**不要**自己带 MaimaiChn 后缀
diff --git a/astral/api/Ping.py b/astral/api/Ping.py
index 3aff900..ff97376 100644
--- a/astral/api/Ping.py
+++ b/astral/api/Ping.py
@@ -14,7 +14,7 @@ Ping.py —— 心跳探活(PingMaimaiChn)
new NetQuery("Ping", 0uL);
客户端传的逻辑名是 **"Ping"**,NetQuery.cs:14 再补上 "MaimaiChn" 后缀,
所以混淆用的全名是 "PingMaimaiChn":
- md5("PingMaimaiChn" + "") = 3bae8c1162b3148abcfc38a89c03e3e7
+ md5("PingMaimaiChn" + "8bF76dE9") = 3bae8c1162b3148abcfc38a89c03e3e7
本模块传 "Ping" 即可,obfuscate_api_name 会自动补后缀。
(Packet.cs:96 用 Query.Api.StartsWith("Ping") 判断是否心跳包,
diff --git a/astral/authv1.py b/astral/authv1.py
index 78f6892..050114c 100644
--- a/astral/authv1.py
+++ b/astral/authv1.py
@@ -8,7 +8,7 @@ authv1.py —— ALL.Net(alAbEx v4) 机台认证:Keychip -> clientId / regionI
config.py 第二节那六个值
KEYCHIP_ID / CLIENT_ID / REGION_ID / PLACE_ID / PLACE_NAME / REGION_NAME
全部来自 **AMDaemon 开机时向 ALL.Net 认证服务器要的结果**。
- 游戏服务器()**没有任何接口**会返回它们 ——
+ 游戏服务器(maimai-gm.wahlap.com)**没有任何接口**会返回它们 ——
所以本工程此前只能手工从真机报文里抄(见 config.py 里那段吐槽)。
本脚本把 AMDaemon 的这一步原样重做了一遍。
@@ -74,7 +74,7 @@ from Crypto.Util.Padding import pad
# =============================================================================
# ALL.Net 认证服务器(国服 SDGB)。两个域名指向不同机房,实测都能用;
-# 默认用 sys-allnet.cn(与 同机房,链路更短)。
+# 默认用 sys-allnet.cn(与 maimai-gm.wahlap.com 同机房,链路更短)。
ALLNET_HOSTS = (
"http://at.sys-allnet.cn",
"http://at.sys-all.cn",
diff --git a/maipulse/PLAN-APP.md b/maipulse/PLAN-APP.md
deleted file mode 100644
index 0c819fc..0000000
--- a/maipulse/PLAN-APP.md
+++ /dev/null
@@ -1,496 +0,0 @@
-# maipulse 功能补齐计划(按截图那套移动端来)
-
-目标:把现在这个网页工具,做成你截的那种移动端 app 的样子 ——
-底部五个 tab(主页 / 票据 / B50 / 风险 / 设置),每个功能一张卡片,带 60 秒冷却条。
-
-原则:**已经跑通的后端一个都不重写**,只补缺的 + 重排前端。
-
----
-
-## 一、现状盘点(已有,直接用)
-
-| 功能 | 后端 | 前端 | 状态 |
-|---|---|---|---|
-| 扫码登录 / 续玩 / 退出 | `core/session.py` + `/api/qr` `/api/login` `/api/logout` | 卡片 1 | 能用 |
-| 2/3/5 倍功能票 | `core/charge.py` + `/api/charge` `/api/ticket` | 卡片 2 | 能用 |
-| 解小黑屋 | `core/repair.py` + `/api/status` `/api/unstick` | 卡片 7 | 能用(未在真卡住号上复测) |
-| 歌曲解锁(全量 / 选中) | `core/unlock.py` + `/api/unlock` | 卡片 6 | 能用 |
-| 收藏品 / 礼物 | `core/items.py` + `/api/items` `/api/newitems` `/api/grant` | 卡片 4、5 | 能用 |
-| B50 + 导出图片 | `core/b50.py` + `/api/b50` `/api/cover` | 卡片 8 | 能用,导出实测 2240×1608 |
-| 店铺切换 | `core/shop.py` + `/api/shops` `/api/shop` | 卡片 3 附近 | 能用 |
-
-这些只做两件事:**搬进新 UI**、**按截图补交互细节**(下拉框、列表、勾选、冷却条)。
-
----
-
-## 二、要补的功能(截图里有,现在没有)
-
-### 1. 主页(玩家信息全表)
-数据源:`api.GetNeedUserData(uid, token)` 一次拿全,不需要多发请求。
-
-要显示的区块(对齐你的截图):
-- 头部:头像(iconId 拼图)、昵称、Rating、历史最高
-- Rating 详情:旧谱 Rating / 新谱 Rating / 合计 / 历史最高
-- 入坑信息:入坑日期、首次游戏、初始 Rom、初始数据版本
-- 游戏信息:最后游戏、最后游玩、最后登录、Rom 版本、数据版本、最后地区(placeId → 地区名查 `shop.py` 的店铺表)
-- 游玩统计:总游玩次数、当期游玩、总达成率、总 DX 分、总同步数、Awake
-- 状态:联网会员、继承、封禁状态、每日奖励
-- 更多详情:用户 ID(打码)、当前 P、累计 P、图标 ID、名牌 ID、姓名框 ID、背景框 ID、称号 ID、奖杯 ID、伙伴 ID、段位、阶级、段级
-
-新接口:`GET /api/profile` → 直接吐上面这些字段(前端不再自己拼)。
-字段名要先实测确认(`userData[0]` 里到底叫 `point` 还是 `userPoint`、`classValue` 之类),拉一次快照对字段。
-
-### 2. 旅行伙伴
-数据源:`userCharacterList`(不是 `userItemList`,也不是搭档 `Partner=10`)。
-
-三个区块:
-- 发放新伙伴:填 ID → 加到列表 → 一次提交(写成 `userCharacterList` 追加行)
-- 编组出战槽位:槽 0(队长)~槽 4,每槽选一个已拥有角色 + 可选等级;「把槽 0 复制到其他槽」
-- 已拥有伙伴:列 `#id Lv n` 的 chips(截图里那 48 个)
-
-新接口:`GET /api/characters`(拉现有)、`POST /api/characters`(发放 / 编组)。
-风险:ID 不在机台表里服务端照样存,但客户端会静默跳过 → 结果里要回报“服务端已存、游戏内可能不显示”。
-
-### 3. 添加舞里程
-数据源:`userData[0].point`(余额)/ `totalPoint`(累计)。
-两种写法:累加(当前 + N)/ 覆盖。整个 int32 区间,填负数就是扣。
-客户端只在“获得”时把余额钳到 99999,商店扣款不带钳位,所以写更大不会被回写。
-
-新接口:`POST /api/mileage`,返回改前 / 改后的 point 与 totalPoint。
-这项最容易出事(写坏就是余额异常),默认 dry-run,预检里明确写“覆盖会直接改余额”。
-
-### 4. 设置 / 关于
-- 设置:服务器地址(默认 127.0.0.1:8787)、是否自动退出登录、是否只预检
-- 关于:版本、数据源说明、免责(自用工具)
-
-不需要后端。
-
----
-
-## 三、前端改造(重点,工作量最大)
-
-现在是一个 PC 版两列卡片页,要改成截图那种:
-
-整体
-- 顶部返回 + 页面标题
-- 内容区卡片:`background:#f2f2f6`、圆角 12、内边距 16、无边框
-- 表单控件:Material 风 outlined 输入框(label 浮在边框上)、下拉箭头、大号圆角主按钮
-- 底部 tab bar:主页 / 票据 / B50 / 风险 / 设置
-
-交互
-- 登录后 60 秒冷却:顶部粉色提示条「登录后需冷却 60 秒,剩余 N 秒」,按钮置灰
-- 所有写操作跑完自动退出登录(可勾选)
-- 危险操作统一放在「风险」tab 里,进去先一条红色警告卡
-
-实现方式:单页 + hash 路由(`#/home` `#/ticket` `#/b50` `#/risk/...` `#/settings`),
-不引框架,保持现在这个 `html/index.html` 一个文件,方便以后套 WebView。
-
----
-
-## 四、实施顺序
-
-1. **P1 主页信息**:拉一次快照对字段 → `/api/profile` → 主页 tab
-2. **P2 B50 搬进新 UI**:海报 + 导出(逻辑不动,换壳)
-3. **P3 票据 / 解锁 / 收藏品搬进新 UI**:+ 冷却条
-4. **P4 旅行伙伴**:`/api/characters`
-5. **P5 舞里程**:`/api/mileage`
-6. **P6 设置 / 关于**
-
-P1 必须先做,因为伙伴、里程都要读同一份快照,字段对一遍就都对了。
-
----
-
-## 五、风险与纪律
-
-- 60 秒登录冷却:所有 `UpsertUserAll` 类写入都在 `session.fresh()` 之后等满 60 秒,界面要显示倒计时,不能点了没反应。
-- 票的坑:`userChargeList` 里只要有 `stock>0`,之后所有写入都会被服务端判非法整包丢弃。所以**先解歌 / 发道具,最后才买票**。
-- 每项写操作默认 dry-run 预检(只算不发),勾掉「只预检」才真发。
-- 不动已经跑通的 `settle / charge / unlock / items / b50`,只加接口和新页面。
-
----
-
-## 六、验收口径
-
-每个功能都要给出:改前值 → 改后值(回读快照确认)+ 一条日志 + 截图,不能只看 `returnCode=1` 就说成功。
-
----
-
-## 进度记录(2026-10-28 05:00)
-
-对照 Ichino(D:\DSH\maimai com\Ichino,Flutter 客户端)逐项落地。
-
-### 已完成
-
-1. **B50 海报重做(照 Ichino best50_poster.dart 原始坐标)**
- - 底图 `html/assets/b50/base.png`(1762×1850)+ 绝对坐标叠加,不再用 CSS 拼版;
- - 卡片 300×100,`left = 65 + 320*(i%5)`,Best35 `top = 350 + 120*i/5`,Best15 `top = 1250 + 120*i/5`;
- - 评级图 `UI_TTR_Rank_*.png`、FC/AP 与 FS/FDX 图标、DX 徽标、难度色 RA 药丸全部走官方素材;
- - `ra` 换成 Ichino 整数公式(`_BANDS` + `_SSSP_OFFSET=224`),实测 9166 与游戏内一致;
- - 导出 html2canvas `scale=1` → 1762×1850,已验证;
- - 未登录时海报署名用打码 ID,不印完整账号。
-
-2. **主页资料 `GET /api/profile`**(新 `core/profile.py`)
- - 一次 `GetNeedUserData` 填满;字段照 Ichino `UserDataBean`:Rating 详情 / 入坑信息 / 游戏信息 / 游玩统计 / 状态 / 更多详情;
- - 前端 6 个分区 + 空态提示。
-
-3. **旅行伙伴 `GET|POST /api/characters`**(新 `core/characters.py`)
- - 读:`userCharacterList` + `userData.charaSlot`(int[5],槽 0 队长);
- - 写:`userCharacterList` + 逐行 `isNewCharacterList`(已有 '0' / 新增 '1'),可同时改 charaSlot;
- - 默认 dry-run,写后回读核对。
-
-4. **舞里程 `GET|POST /api/mileage`**(新 `core/mileage.py`)
- - 改 `point` / `totalPoint`,累加或覆盖,int32 钳位;
- - userData 整表覆盖 → 先用 `build_user_all` 组好这一局,再替换那一行,保住 playCount / lastPlayDate 等痕迹。
-
-5. **UI 改版(浅色 Material + 底部 5 tab)**
- - 主页 / 票据 / B50 / 风险 / 设置;hash 路由 `#home #ticket #b50 #risk #settings`;
- - 卡片按 `data-tab` 显隐,`main` 改单列 560 居中;
- - 风险页顶部红框警告 + 顺序铁律(先解歌发道具,最后买票);
- - 结果用底部轻提示(ticker)播报,日志收进设置页;
- - 设置页可搜店铺切机台、退出登录。
-
-### 待办
-
-- 玩家记录 / 牌子进度页:需要登录态下 `GetUserMusicDetailAll`(当前未登录返回空明文);
-- 推分推荐暂不算「平均达成率 / 统计样本」,本地没有水鱼聚合数据,不编造;
-- 真机验证:登录后走一遍 主页 → 旅行伙伴 → 舞里程 的回读。
-
-### 追加(本轮)
-
-6. **60 秒冷却条**:`GET /api/state` 新增 `coolRemain`(登录后剩余秒数,金票 LogoutTimeLimit 同口径),
- 票据页粉色条倒计时提示「现在发包会被静默丢弃」。
-7. **组包自测**:`tools/selftest_payload.py` 用假 snapshot 走 `build_user_all` 验证——
- userData 49 个字段整行覆盖后一个不丢,point/totalPoint/charaSlot 改写生效,
- `isNewCharacterList` 位串与 Ichino `BuildListData` 同口径(已有 '0' / 新增 '1')。已跑通。
-
-### 追加(第 2 轮)
-
-8. **字段映射自测**:`tools/selftest_fields.py` 把 GetNeedUserData 换成假快照,
- 验证 profile 的 24 个关键字段逐个取到正确值、userId 打码成 `123****78`、
- characters 能解析出 2 个角色与 `[101..105]` 槽位。已跑通。
-9. **B50 看大图**:海报下方加「看大图」,克隆一份 1762 宽的原尺寸海报铺进全屏查看器,
- 手机上横向滑动能看清卡片细节;`?b50=ID&zoom=1` 可直接验证。
-
-### 追加(第 3 轮)
-
-10. **选择器(解锁歌曲 / 称号 / 头像 / 名牌 / 边框 / 角色)浅色适配**:
- 标题单行省略、搜索框 `flex:1 1 0;width:auto` 不再把标题挤成两行;13000+ 项列表滚动正常。
- `?picker=music|title|icon|nameplate|frame|character` 可直接验。
-11. **结算等待遮罩改浅色**(白底圆角 + 深色字 + 蓝色进度条),不再和浅色主题撞色。
-12. **自动登出真正接线**:`post()` 里对 6 个写接口(ticket/grant/unlock/characters/mileage/unstick)
- 做统一判断——勾了开关且**不是** dry-run 才登出,默认不勾,避免打断连续操作。
-13. **B50 空态**:没查过时给一句引导,不是空白。
-
-### 追加(第 4 轮)
-
-14. **票种档位修正(对照 Astral TICKET_CREDIT_NUM)**:
- 原实现只有 chargeId 2/3/5,实际付费票是 2/3/4/5/6,`creditNum = N-1`(1/2/3/4/5)。
- 已补 4 倍票(creditNum 3)与 6 倍票(creditNum 5),错误文案同步成「2~6 倍票」,
- 下拉选项把 price 改标 creditNum(容易和「金额」混淆)。
- Ichino 的 `_ticketNameMap` 只有 2/4/5,是显示映射不全——它实际从 `GetUserChargeApi`
- 拿在售列表,所以以服务器列表为准、本地只做换算。
-15. **对照 Ichino 核过一遍字段表**:`models/user_data.dart` 的 41 个字段与 `core/profile.py` 一一对应,没有多余也没有缺。
-
-### 追加(第 5 轮)
-
-16. **旅行伙伴等级对齐 Ichino**:原来 `levels` 想填多少就发多少,现在钳到 **1~999999**
- (`minCharacterLevel` / `maxCharacterLevel`,0 会被判非法)。
- 读取时附带 `displayLevel = level % 10000` 与 `rebirth = level // 10000`——
- 客户端把界面等级和转生次数编码在一个 int 里,直接显示原始值会看到「Lv10005」。
- 改等级时沿用原行的 `awakening` / `useCount`,不会把使用次数清零(Ichino 原注释点名过这个坑)。
- 自测:`clamp_level` 边界与 `split_level(10005) == (5, 1)` 通过。
-
-### 追加(第 6 轮)
-
-17. **整页验收脚本化**:`?dbg=1` 时挂 `window.onerror` + `unhandledrejection`,
- 把异常写进 ``,再用 headless `--dump-dom` 逐个页签读。
- 结果:5 个页签(home / ticket / b50 / risk / settings)**零 JS 错误**,
- `scrollWidth` 都不超过视口(risk 页 485/500 是纵向滚动条占位),没有横向溢出。
-
-### 追加(第 7 轮)
-
-18. **页签对齐 Ichino 的 6 个**:`home_tab.dart` 是 home / tickets / best50 / risk / settings / **about**,
- 于是把「关于」从设置页拆成独立页签(版本、协议来源 Astral、行为口径 Ichino、数据只在本机、几条规矩)。
- 注意 `.card[data-tab]` 的显示规则是逐页签写的,新增页签必须同步加一条 CSS,否则页面全空白。
-19. **结算等待改成分步提示**:照 Ichino 的 StepProgressCard,进度条上方按阶段翻字——
- 读取快照 / 组包等满 60 秒 / 发送写包 / 回读核对,不再只显示「已等 N 秒」。
-
-### 追加(第 8 轮)
-
-20. **前后端 id 一致性 lint**:把 HTML 的 id 集合与 JS 里 `$('…')` / `setText('…')` 的引用对比,
- 只有 `poster` 是渲染时动态建的(正常),34 个 `k*` 字段全部有对应节点——不会出现登录后一片空白。
-21. **`/api/characters` 参数加固**:`add` / `levels` / `slots` 的 `int()` 原来在 try 之外,
- 前端传了非数字会变成 Flask 的 500 HTML 页面(前端 `r.json()` 直接抛错)。现在返回 JSON 错误。
-22. **风险页按操作顺序重排**:警告 → 解锁歌曲 → 发放收藏品 → 发放礼物 → 解锁搭档 →
- 旅行伙伴 → 舞里程 → 解禁小黑屋。理由是先解歌、发道具,最后才碰票相关的东西,
- 跟警告卡里那条铁律一致。
-
-### 追加(第 9 轮)
-
-23. **UI 预览模式 `?preview=1`**:照 Ichino `dev/ui_preview_data.dart` 的做法,
- 不发任何请求,直接用一份假数据(Rating 15234 那套)把主页 / 旅行伙伴 / 舞里程撑起来,
- 能看到登录后的真实排版,也方便截图比对。数据里特意放了一条 `level=10005`(1 转 5 级)
- 与一条 `999999`(99 转 9999 级),验证转生拆分显示。
-24. **登录卡随登录态收起**:拿到资料后主页不再顶着扫码框(登出按钮在设置页),
- 登录态失效时再放出来。
-
-### 追加(第 10 轮)
-
-25. **预览模式铺满风险页**:`?preview=1` 时自动把旅行伙伴与舞里程的假数据装上,
- 实测渲染:`共 3 个角色|出战槽 #0 101 · #1 102 …`、
- `已拥有:#101 Lv5 · #102 Lv5(1 转)· #103 Lv9999(99 转)`——转生拆分显示正确,
- 舞里程区显示 `当前余额 234567|累计 9876543`。P4 / P5 的前端展示至此有据可查。
-
-### 追加(第 11 轮 · 登录态活体验收 + 外观)
-
-26. **P1 活体验收通过**:`GET /api/profile` 拉真实数据 —— 昵称 maimai、
- Rating 9168、历史最高 12337、musicRating 9168、游玩 70 局、累计 P 9630、地区 江苏、icon 302,
- userId 按约定打码成 `128****21`。
- ★ 客户端显示 9168,B50 本地算 9166,差 2 分正好对应那 3 首「定数未收录」的曲目——
- 不是算错,是缺 ds 无法计入,这也反过来印证了 ra 公式没有偏。
-27. **P4 / P5 活体验收通过**:`/api/characters` 读到 48 个角色,出战槽
- `455201/455202/455203/455204/550101`;`/api/mileage` 读到 point 2130 / totalPoint 9630。
- 写路径用 dry-run 打真实快照验过:里程 2130→2230、累计 9630→9730(preview.playCount=70 正确保留);
- 旅行伙伴 update=101、willSend=1、patch 键含
- `userCharacterList / isNewCharacterList / userMusicDetailList / isNewMusicDetailList / userGamePlaylogList`。
- (未真发写包:改数值属于账号数据,等用户自己决定。)
-28. **底部图标换成 Tabler Icons**(MIT,本地 `html/assets/icons/*.svg`,不依赖外网):
- home / ticket / chart-bar / alert-triangle / settings / info-circle,统一线性风格,
- 用 CSS `mask` 上色,选中态自动跟随 `--accent`。
-29. **外观设置(设置页新增一张卡)**:上传背景图(canvas 压到最长边 1600、jpeg 0.82 存 localStorage)、
- 清除背景、卡片不透明度 30~100%、背景虚化 0~20px、背景压暗 0~70%、恢复默认。
- 卡片走 `rgba(255,255,255,var(--alpha))` + `backdrop-filter`,有背景时顶栏也跟着半透明。
- 持久化实测:写一套 alpha=.6 的配置后换一次加载,背景与透明度都还在。
- `?looktest=1` 可复现这个自查,`?bgdemo=1` 只看渐变效果不落盘。
-
-### 追加(第 12 轮 · 多账号)
-
-30. **`core/accounts.py`**:扫码登录成功后自动把 `userId + token` 存到 `data/accounts.json`
- (原子写,tmp → replace)。对外只给「打码 userId + 昵称 + 时间 + ref」,
- **token 与完整 userId 都不出后端**;切换账号时前端回传的是 `ref`
- (`sha256(userId)[:12]`),后端按 ref 反查真实行。
-31. **接口**:`GET /api/accounts` 列表;`POST /api/accounts` 支持
- `action=switch`(用存的 token 重新 `UserLogin`,切之前先把当前号登出,避免 returnCode=100)/
- `rename` / `delete`。
-32. **界面**:设置页新增「账号」卡,一行一个账号(昵称 / 打码 id / 最后使用 / 切换 / 删)。
- 切换时有等待遮罩,成功后会刷新主页资料。
- 打码统一成前 3 + 固定四星 + 后 2(`128****21`),不泄露位数。
-33. **离线上测通过**:upsert / list(current 标记)/ ref 反查 / rename / touch / remove /
- 未知 ref 返回 None 全部符合预期;界面用一条演示数据渲染过,随后已删除,store 现在为空。
-34. ⏳ **待验**:用存下来的 token 隔一段时间重新登录是否还有效。
- 要通过这一步才算真正确认「不用再扫码」——需要用户扫一次码来写第一条真实记录。
-
-### 追加(第 13 轮)
-
-35. **去掉「关于」页**(用户要求):删掉 section、底部按钮、`TABS` 里的 about 与对应 CSS 规则,
- `about.svg` 也一并删了;全文已无 about 残留。`go()` 本来就有「不在 TABS 里就回落 home」的兜底,
- 旧的 `#about` 书签不会白屏。底部回到 5 个页签均分。
-
-### 追加(第 14 轮 · 全曲成绩 + 推分推荐)
-
-36. **`core/history.py`**:`api.GetUserMusicDetailAll(userId, clientId)` 拉全曲成绩
- (Astral 已把两层嵌套摊平成「一条 = 一个难度」)。achievement 是 ×10000 整数,
- 除以 10000;定数先查 `data/master/ds_override.json`、再查 music.json;
- 算不出定数的标 `unrated`,不进 ra 统计,另出 `unratedItems` 供手工补。
- 统计含 AP / FC / FDX / 平均达成率 / 最高 ra / 按难度分布。
-37. **`core/recommend.py`**:推分算法。
- ① 每首已打曲子算 SS+/SSS/SSS+ 三档 ra(复用 b50 的整数公式);
- ② 在 B35 榜上的按「新 ra − 自己当前 ra」算,不在榜上的按「新 ra − 榜尾」算;
- ③ `min_achievement()` 对 ra 二分反查,给出「打到 98.9934% 就能顶掉榜尾」这种具体数;
- ④ 候选按定数中位数 ±0.7 收窄(别推荐打不动的),按涨幅排序,前 35 首累加算整体潜力。
- 二分自测:ds 12.0/13.8/14.5 三档 × 四个目标档位,反查值再算回去全部 `>= 目标 ra`,
- 单调性成立;拿不到目标的返回 None。
-38. **接口**:`GET /api/history`(默认前 120 条、full=1 全量)、
- `GET /api/recommend`、`POST /api/ds`(合并写覆盖表,ds<=0 表示删)。
- 未登录一律拦下并给明确提示。
-39. **界面**:B50 页新增「推分建议」卡 —— 两个按钮(算推分 / 看全曲成绩)。
- 推分列出:曲名 + 难度定数 / 当前达成 / ra / 「打到多少」/ 涨幅,顶部三个数(榜尾 ra、可推曲目、整体潜力)。
- 全曲成绩列出统计、难度分布、按曲名搜索、明细行(达成率 / 评级 / FC·AP / ra)。
- 未收录定数的条目单独一区,可直接填数字保存,存完自动重算。
-40. **踩到的坑**:`stopWait` 未定义(现网只有 `endWait`)→ 加别名;`ds_override` 内层
- key 是 int、保存时按 str 处理,导致 `pop("3")` 匹配不上、删除静默失效 → 统一转 str。
-41. ⏳ **待真实验收**:全曲成绩要登录才能拉。扫一次码后验:
- `/api/history` 条数是否接近真机 739、`/api/recommend` 是否给出合理建议、
- 补完那几条定数后本地算出的 rating 能不能对上客户端 9168。
-
-### 追加(第 15 轮 · 推分多样性 + 定数联动 + 对分)
-
-42. **推分算法返工**(离线压测 400 条假成绩暴露出来的):
- 原来按「打到 SSS+」的 ra 排序,结果同一批定数的曲子涨幅完全相同,前排全是 ds 14.5 的歌、
- 完全没有区分度(top8 定数去重后只剩 1 个值)。
- 改成:主档用 **SSS(100%)**算涨幅(SSS+ 只作参考列),同涨幅按 `effort`(还要提多少达成率)
- 升序,再按定数做稀释——**每个定数最多留 3 首**。改完 top10 里出现 4 个不同定数。
- `total` / `potential` 仍按稀释前的全量算(否则潜力会被低估:39 首 vs 200 首)。
-43. **★ 定数覆盖表接进 B50**(这条不修的话功能是断的):原来只有 `history.ds_for` 读
- `ds_override.json`,`b50.ds_of` 不读 —— 用户补完定数,B50 页面还是 9166,永远对不上。
- 现在 `b50.ds_of(music, level, music_id)` 先查覆盖表;即使 music.json 里根本没这首
- (国服新曲就是这种情况,music 为 None)也能走覆盖表算出 ra。
- `history` 的 load/save_override 改为委托 b50,两边共用一份实现。
- 实测:覆盖前 ds=None/ra=0 → 覆盖后 ds=14.6/ra=306,B50 与全曲成绩两边结果完全一致。
-44. **`GET /api/verify` 对分**:客户端 `playerRating` / 服务端 `GetUserRating.rating` /
- 本地按定数算的合计,三个数并排显示,差值不为 0 时直接告诉用户「少了 N 分,M 条定数未收录」。
- 界面在推分卡里加了「核对分数」按钮。
-45. **全曲成绩缓存**:`history.fetch` 加 5 分钟 TTL 缓存(拉一次要翻十几页,
- 算推分和看成绩连着点时不该重复拉);`/api/history?full=1` 不吃截断。
-46. **修复**:删除定数覆盖项静默失效(`load_override` 内层 key 是 int,保存路径按 str `pop`,
- 对不上)→ 统一转 str 后删除生效;`stopWait` 未定义 → 加 `endWait` 别名。
-47. ⏳ **仍待真实数据验收**(要登录):`/api/history` 的真实条数、`/api/recommend` 的真实推荐、
- `/api/verify` 三处 rating 是否对平(目标要求对上客户端 9168)。
-
-### 追加(第 16 轮 · ★ rating 对平了:9166 → 9168)
-
-48. **真实数据验证(不需要登录,`GetUserRating` 是公开接口)**:
- `GET /api/b50?userId=<真实号>` → `totalRating=9166`、`b35=9166`、`b15=0`、`missing=3`。
- 客户端显示 9168,差 2 分——正是那 3 条定数缺失的 newRatingList。
-49. **定位到那 3 条是什么**:b15 里 `11806 Expert 10.3913%`、`11836 Master 0%`、
- `11835 Master 0%`(还有一条 11847 本地已有定数)。ach=0 的三条贡献 0 分,
- 真正差的就是 11806 —— 几乎没打过(10.39%)。
-50. **补全方式改成自动**:写了 `tools/fetch_df.py`,把水鱼
- `https://www.diving-fish.com/api/maimaidxprober/music_data`(1404 首,id 与国服 musicId 同源)
- 缓存到 `data/master/music_df.json`(146.6 KB)。
- `b50.load_music()` 改成「music.json 为主,缺的 id 用水鱼补」,名字与定数一起补上,
- 不再依赖手工填。
-51. **★ 验证结果**:清空手填覆盖表后重跑 →
- `11806 Fraq Expert ds=12.2 ra=2`、`11836 ビビデバ ds=11.8`、`11835 はいよろこんで ds=11.6`、
- `11847 M@GICAL☆CURE! LOVE ♥ SHOT! ds=11.2`,`missing=0`,
- **`totalRating=9168` —— 与客户端完全一致**。
- (12.2 × 103913 × 16 // 1e8 = 2,这 2 分就是差的那 2 分。)
-52. 手填覆盖表仍保留(优先级高于水鱼缓存),作为缓存也没收的新曲兜底。
-53. ⏳ 剩下要登录才能验的:`/api/history` 真实条数、`/api/recommend` 真实推荐、
- `/api/verify` 三处 rating 是否显示「已对平」(本地 9168 = 客户端 9168)。
-
-### 追加(第 17 轮 · 真实数据跑推分 + 摸清全曲接口)
-
-54. **水鱼缓存合并生效**:`load_music()` 后曲库 1326 → 1411(补了 85 首),
- 11806/11836/11835/11847 的名字与四个难度定数全部到位。
- `unlock` / `items` 不依赖 `load_music`,所以这个合并不会影响解锁列表。
-55. **真实 B50 数据跑推分**:拿这个号真实的 50 条(B35+B15)当输入试跑
- → 榜尾 ra 254(最終鬼畜妹フランドール・S Master 12.1),候选 12 首,
- 整体潜力 +230(SSS 档)/ +371(SSS+ 档)。
- 推荐前几名:心象蜃気楼 97.2864%(+29)、フォニイ 97.0541%(+29)、ウミユリ海底譚 97.4920%(+28)……
- 9 个不同定数,effort 落在 1.1~3.0 之间(都是「再练一点点就有」的量),结果比假数据合理。
-56. **★ 摸清 `GetUserMusicApi` 与 `GetUserRating` 的差别**:
- 后者免登录就能查(所以 B50 一直能用),前者**必须挂在真实登录会话上**,
- 否则服务端 HTTP 200 但解密后是空串(Astral 报「返回空明文」)。
- 实测直接调 `api.GetUserMusicDetailAll(<真实 userId>)` 就是这个错。
- 结论:全曲成绩一定得先扫码登录,没有免登录的路子。
-57. `/api/history` 与 `/api/recommend` 的错误改成一句能照着做的提示
- (「全曲成绩要登录才能拉:GetUserMusicApi 不接受免登录查询」),不再把 Astral 那一屏甩给用户。
-58. `recommend.suggest` 的字段读取改成 `.get`(B50 的条目没有 `playCount`,
- 用它当输入时会 KeyError;真实来源 history/board 字段并不完全一致)。
-59. ⏳ 最后一步:全曲成绩的真实条数只能等扫码验证。
-
-### 追加(第 18 轮 · 免登录推分 + 真实结果验收)
-
-60. **`/api/recommend` 支持降级**:全曲成绩要登录,但 B50 那 50 条本身就是「我打过的曲子」,
- 拿来当候选池照样能算推分(池子从几百条缩到 50 条,结论依然成立)。
- 未登录时返回 `degraded=true` 并在界面标黄说明;登录后自动走全量成绩。
- 这样**不用扫码就能验收推分功能**,也是给没登录用户的第一屏体验。
-61. **★ 真实数据验收(这个号,未登录)**:`GET /api/recommend?userId=…`
- → `degraded=true`、候选 12 首、潜力 +230(SS+/SSS/SSS+ 三档 137 / 230 / 371)。
- 推荐结果:心象蜃気楼 Master 13.4(97.2864% → 97.3881%,+29)、
- フォニイ Re:Master 13.3(97.0541% → 97.3655%,+29)、
- ウミユリ海底譚 Master 13.4(97.4920% → 97.7612%,+28)、
- 失敗作少女 Master 13.1(+27)、命に嫌われている Master 12.8(+22)。
-62. **`?uid=` 直达**:URL 带 uid 时自动填输入框并查 B50 + 推分,方便看别人的分,也方便我自己验收。
-63. **界面重排**:海报下面那块原来的「推分建议」其实是**曲库推荐**(从没打过的曲子里挑),
- 和新加的「按你的成绩算」重复,改成:
- ① 曲库推荐默认只铺 6 张卡(3 行)+「展开全部 24 首」,原来 16 张卡占了两千多像素;
- ② 标题分开成「曲库推荐(还没打的)」和「推分建议(按你的成绩)」。
-64. ⏳ 全曲成绩的真机拉取仍未验证(`GetUserMusicApi` 必须真实登录会话)。
-
-### 追加(第 19 轮 · 难度筛选 + 一次白屏事故)
-
-65. **全曲成绩支持按难度筛**:`/api/history?level=3,4`(0=Bsc 1=Adv 2=Exp 3=Mas 4=ReM),
- 后端先按难度过滤再截断(前端过滤只能滤已截断的 120 条,不准),返回 `shownTotal` 与 `levelFilter`。
- 界面在统计下面加了一排 全部 / EXP / MAS / ReM。
-66. **★ 白屏事故**:给 `.lvtabs` 加样式时,anchor 找的是 `/* ---------- 推分建议 / 全曲成绩 ---------- */`,
- 但那行注释在 **JS 区**(`go()` 函数后面),不是 `` 之前,
- 不能靠注释文字找位置。已修复并把规则插到 `` 前。
-67. 难度 chips 的状态要在 `renderHistory()` 渲染之后同步(chips 是 innerHTML 生成出来的,
- 渲染前 querySelector 拿不到),否则「全部」不会高亮。
-68. 新曲封面:`/api/cover/11806`、`11836`、`11835`、`11633` 全部 200(96~340KB),
- 海报里新曲不会开天窗。
-
-### 追加(第 20 轮 · 功能票精简 + 抓到摊平丢 musicId 的真机 bug)
-
-69. **功能票去掉 4 倍 / 6 倍**:国服机台不再售这两档,留着只是误点。
- `charge.TICKETS` 只剩 2/3/5(creditNum 1/2/4),`buy()` 对 4、6 直接拒并说明原因,
- 前端 `