Compare commits

...
3 Commits
Author SHA1 Message Date
maipulse 874fe81039 chore: gitignore 排除打包生成的 android assets 2026-10-30 21:38:55 +08:00
maipulse 9b5038818f release: v1.0.2
加固:release 构建 + R8 混淆,清理无用资源,修启动链路
2026-10-30 21:36:23 +08:00
maipulse 967e93af98 release: v1.0.2
加固:release 构建 + R8 混淆,清理无用资源,修启动链路
2026-10-30 21:35:19 +08:00
30 changed files with 1686 additions and 1502 deletions

No files matched your search

+4
View File
@@ -32,3 +32,7 @@ Thumbs.db
# 发版 token
maipulse/tools/.release-token
tools/.release-token
# 打包时从 maipulse/ 复制进去的运行时副本,不入库
android/app/src/main/assets/
android/.gradle/
android/build/
+33 -5
View File
@@ -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'
}
}
+7 -3
View File
@@ -5,19 +5,23 @@
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:allowBackup="true"
android:allowBackup="false"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:label="maipulse"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true"
android:hardwareAccelerated="true"
android:largeHeap="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:windowSoftInputMode="adjustResize">
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -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<Uri[]> 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());
}
}
});
// 网页里的 <input type=file> 走 WebChromeClient.onShowFileChooser,挂在
// WebViewClient 上编译不过,必须单独设一个 chrome client。
web.setWebChromeClient(new WebChromeClient() {
@Override
public boolean onShowFileChooser(WebView view, ValueCallback<Uri[]> 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;
}
@@ -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,
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- allowBackup 已经是 false,这里再堵一层:任何域名都不参与云备份和换机迁移。
配置里有机台身份(Keychip / 店铺 / 密钥),不该跟着账号漂到别的设备上。 -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>
Binary file not shown.
-248
View File
@@ -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" "$@"
-93
View File
@@ -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
+10 -10
View File
@@ -11,17 +11,17 @@ MaimaiNet.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
1) 服务器基址与 API 路径
source/Assembly-CSharp/Manager/OperationManager.cs:225,338
ServerUri = Auth.GameServerUri + MovieServerUri("<OBFUSCATE_PARAM>/")
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 + "<OBFUSCATE_PARAM>")
Obfuscator(s) = md5_hex_lower(s + "8bF76dE9")
source/Assembly-CSharp/Net.VO/NetQuery.cs:14
Api = api + "MaimaiChn"
=> 最终 URL = BASE_URL + md5(逻辑接口名 + "MaimaiChn" + "<OBFUSCATE_PARAM>")
=> 最终 URL = BASE_URL + md5(逻辑接口名 + "MaimaiChn" + "8bF76dE9")
已用真实日志验证:md5("GetGameSettingApiMaimaiChn" + "<OBFUSCATE_PARAM>")
已用真实日志验证: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("<OBFUSCATE_PARAM>/")
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<JU:i5oR7LA]9W"
AES_IV = b"F>;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(上面 + "<OBFUSCATE_PARAM>")
-> md5(上面 + "8bF76dE9")
参数:
api_name: 逻辑接口名,**不要**自己带 MaimaiChn 后缀
+1 -1
View File
@@ -14,7 +14,7 @@ Ping.py —— 心跳探活(PingMaimaiChn)
new NetQuery<PingRequestVO, PingResponseVO>("Ping", 0uL);
客户端传的逻辑名是 **"Ping"**,NetQuery.cs:14 再补上 "MaimaiChn" 后缀,
所以混淆用的全名是 "PingMaimaiChn":
md5("PingMaimaiChn" + "<REDACTED>") = 3bae8c1162b3148abcfc38a89c03e3e7
md5("PingMaimaiChn" + "8bF76dE9") = 3bae8c1162b3148abcfc38a89c03e3e7
本模块传 "Ping" 即可,obfuscate_api_name 会自动补后缀。
(Packet.cs:96 用 Query.Api.StartsWith("Ping") 判断是否心跳包,
+2 -2
View File
@@ -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 认证服务器要的结果**。
游戏服务器(<REDACTED>)**没有任何接口**会返回它们 ——
游戏服务器(maimai-gm.wahlap.com)**没有任何接口**会返回它们 ——
所以本工程此前只能手工从真机报文里抄(见 config.py 里那段吐槽)。
本脚本把 AMDaemon 的这一步原样重做了一遍。
@@ -74,7 +74,7 @@ from Crypto.Util.Padding import pad
# =============================================================================
# ALL.Net 认证服务器(国服 SDGB)。两个域名指向不同机房,实测都能用;
# 默认用 sys-allnet.cn(与 <REDACTED> 同机房,链路更短)。
# 默认用 sys-allnet.cn(与 maimai-gm.wahlap.com 同机房,链路更短)。
ALLNET_HOSTS = (
"http://at.sys-allnet.cn",
"http://at.sys-all.cn",
-496
View File
@@ -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`,
把异常写进 `<title>`,再用 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()` 函数后面),不是 `<style>` 里 —— CSS 规则被塞进 JavaScript,
整页脚本解析失败,只省下 header 和 tabbar。
教训:这个文件样式表与脚本都在同一个 html 里,插 CSS 必须定位到 `</style>` 之前,
不能靠注释文字找位置。已修复并把规则插到 `</style>` 前。
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 直接拒并说明原因,
前端 `<select id="chargeId">` 同步只留三项。
70. **★ 静态审查抓到真机必崩的 bug**:`api.GetUserMusicDetailAll` 摊平时会丢 `musicId`。
服务端返回的是 `{"userMusicList": [{"musicId": N, "userMusicDetailList": [...]}]}` ——
musicId 在**组**这一层,detail 里没有;Astral 只做 `flat.extend(group["userMusicDetailList"])`,
没有把父层 musicId 带下来。照原样用,全曲成绩会拿到一堆没有曲号的条目 →
定数一律 None → 整页变成「未收录」、ra 全 0、推分也全废。
这是只有真机才暴露的问题,我这边没有登录拉不到真实数据,是靠读 Astral 源码发现的。
71. **改法**:`history._fetch_items()` 自己调 `get_all_pages` 并摊平,
每条 detail 补上父层 `musicId`(detail 自带则尊重它);拿不到 `get_all_pages`
时才退回官方封装。
72. **Mock 验证**:造了两组 3 条 detail(全部不带 musicId),摊平后
`musicId 834/834/11806` 都在,定数 14.9 / 15.0 / 12.2 全部算得出,
ra 312 / 291 / 2,`unrated=0`。没这个修复,这三条会是 `musicId=0`、名字空、定数空。
### 追加(第 21 轮 · ★ 更正上一轮的错误结论)
73. **更正第 70~72 条**:上一轮说「Astral 摊平时丢 musicId、真机必崩」——**判断错了**。
证据在 `Astral/selftest.py:548-555` 的 fixture:
```python
"userMusicDetailList": {"userMusicList": [
{"userMusicDetailList": [
{"musicId": 143, "level": 4, "playCount": 7, "achievement": 0,
"comboStatus": 0, "syncStatus": 0, "deluxscoreMax": 0, ...}]}]}
```
外层 group 里**没有** musicId,是**内层 detail 自带** `musicId`。
`_dispense.py:30` 的 `{(x["musicId"], x["level"]) for x in ...}` 也印证了这一点
(它取的就是摊平后的列表)。所以 `history.fetch` 原来的 `it.get("musicId")` 本来就是对的。
74. `_fetch_items()` 仍然保留,但定位改成**加固**而不是修 bug:对四种形状都成立 ——
detail 带 musicId(真实形状)原样保留;group 带而 detail 不带则补上;
两边都带以 group 为准;两边都没有则记 0。实测:
A(143,4)/(143,3)、B(834,3)、C(999,3)、D(0,3)。
75. **顺便核对了字段名**:`history._row` 用到的 `achievement / level / playCount /
comboStatus / syncStatus` 与 selftest fixture 里的 detail 字段**完全一致**,
都是 ×10000 的整数达成率,没有拼写或口径问题。
76. 结论:全曲成绩这条链路在代码层面没有已知缺陷,剩下的只有真机跑一次。
### 追加(第 22 轮 · ★★ 真机验收通过,目标达成)
**用户在机台扫码登录了**(`/api/state` → `logged=true`,账号 maimai / 128****21),
全曲成绩这条链路第一次在真号上跑通。
77. **全曲成绩(真机)**:`GET /api/history` → **207 条**(每曲每难度一条),涉及 **180 首曲子**
- 已算 ra **207 条,未收录 0 条**,总游玩次数 255
- AP 19 / FC 33 / FDX 0 / 平均达成率 86.2609% / 最高 ra 292
- 难度分布:Basic 26 · Advanced 8 · Expert 47 · Master 111 · Re:Master 12 · 宴 3
78. **★ 真机抓到两个只有上机才会暴露的问题**:
1. **宴/協/息谱算不出定数**:`111355 [協]ラグトレイン`、`100998 [宴]Oshama Scramble!`、
`111597 [息]ノンブレス・オブリージュ` 三条的 `level = 10`,
—— 服务端用越界值标记特殊谱面,定数存在 `notes[0]`,原来 `notes[level]` 直接越界返回 None。
修:`ds_of()` 里 `level >= 5 且越界` 时改用 `notes[0]`;
另加 `level_name()`,这几条的难度名显示「宴」而不是「?」。
修完 unrated 3 → 0,三条 ra 分别 292 / 175 / 155
(協ラグトレイン 175.3337% 是全场最高分,被 clamp 到 100.5%)。
2. **`syncStatus = 5` 被映射成 "sync"**:真机 207 条里 **152 条**是 5,
显示出来满屏「sync」纯属误导(EasyMai 表里有这个值,但国服没拿它当 FS/FDX 标记)。
修:`_SYNC` 去掉 5,只保留 0~4(''/fs/fsp/fdx/fdxp)。
79. **★ 自检路由 `/api/selftest`**(登录后一键跑,用户自己也能用):5/5 全过
- 全曲成绩条数:207 条 / 180 首
- 定数未收录:全部收录
- 达成率非零:平均 86.2609%,最高 ra 292
- **B35+B15 合计:本地 9168 / 客户端 9168** ✓
- 全曲最高分 vs B50 第一:OMAKENO Stroke(Master 13.8)ra 290 / B50 ra 290 ✓
- 这条检查第一版没过:宴谱 協ラグトレイン ra 292 比 B50 第一名还高。
不是 bug —— 宴谱不参与 B35 排名,比较时排除 level>=5 即可。
80. **★ 全曲模式的推分(真机)**:`/api/recommend` → `degraded=false`,候选池 207 条
- 可推 **58 首**,榜尾 ra 254(最終鬼畜妹フランドール・S Master 12.1)
- 三档潜力:SS+ **+830** / SSS **+1106** / SSS+ **+1526**
- 前几名:INTERNET OVERDOSE 94.2194% → +37、神々が恋した幻想郷 92.5207% → +37、
ドクハク 96.4595% → +35、ここからはじまるプロローグ。→ +35
- 全部 `onBoard=false`(要顶掉榜尾那类),和降级模式(全是榜内)形成对比
81. **登录态持久化验证**:重启服务会清空会话(`logged=false`),
但 `POST /api/accounts {action:"switch"}` 用保存的 token 能直接恢复 ——
实测恢复后 `isLogin=false / banState=0 / loginDateTime=1793198451`,
再拉全曲成绩照常成功。也就是说以后重启不用重新扫码。
82. **目标五项全部达成**:① 全曲成绩真机拉通 ✓ ② 定数缺漏(含水鱼补全 + 手填覆盖 + 宴谱兜底)✓
③ Ichino 整数 ra 公式 + 推分 ✓ ⑤ **9168 = 9168** ✓(④ 移动端按用户要求未做)
-182
View File
@@ -1,182 +0,0 @@
# maipulse 功能扩展计划(对齐 Ichino)
参考实现:`D:\DSH\maimai com\Ichino`(Flutter,eaquira 系列的 Dart 移植)
本文件只是计划,**不改动现有任何文件**;确认后再按阶段动手。
---
## 一、现状盘点
已做通(实机验证过):
| 功能 | 写入字段 | 状态 |
| --- | --- | --- |
| 发功能票 | UpsertUserChargelog | 通过(5 倍票 stock=1) |
| 收集品 / 礼物 | userItemList + isNewItemList | 通过(称号/伙伴/名牌/边框) |
| 解锁歌曲 | userItemList kind 5/6/7 | 通过(709 首一包全落地) |
| 解小黑屋 | UserLogout | 已改金票流程,待实测 |
Ichino 有、我们没有的:**B50 查分、修改 Rating、舞里程、一键跑图、万花筒**。
---
## 二、要加的五个功能
### 1. B50 查分(只读,零风险)—— 按 EasyMai 规划
参考:`https://github.com/Lista233/EasyMai`(Vue/Uni-app,202★)
关键文件:`pages/maib50/maib50.vue`、`utils/b50adapter.js`、`utils/ratingUtils.js`
#### 数据源对比(我们比它更直接)
EasyMai 的路子是绕的:自建后端 `getUserMusic` + 水鱼 `update_records` 导入,
再从水鱼拿回算好的 rating。我们不需要这一层:
| | EasyMai | 我们 |
| --- | --- | --- |
| 成绩 | 自建后端 + 水鱼 | `api.GetUserRating(user_id, client_id)` 直取 |
| 定数 | 本地 `musicData`(水鱼曲库) | 本地 `data/master/music.json` 的 `notes[]` |
| 鉴权 | jwt-token + import-token | 只要 userId + 机台 clientId,**不用登录** |
| 写操作 | 往水鱼导入成绩 | 无,纯只读 |
#### 已验证的接口形状(2026-10-28 实测,账号 12800621)
GetUserRating -> userRating:
rating : 0(服务端不填,别指望它)
ratingList : 35 条 {musicId, level, romVersion, achievement}
newRatingList : ≤15 条 同上
nextRatingList / nextNewRatingList / udemae
注意 **没有 ra 字段**,必须自己算。定数从
`data/master/music.json` 的 `notes[level]` 拼:`level + levelDecimal/10`
(例:11633 Master → level 13, levelDecimal 8 → 13.8)。
#### ra 公式(水鱼标准表,24 档)
achievement >= 100.5% -> 22.4 >= 95% -> 15.2 >= 82% -> 2.4
>= 100% -> 21.6 >= 94% -> 13.6 >= 80% -> 1.6
>= 99.5% -> 21.1 >= 93% -> 12.0 >= 78% -> 1.2
>= 99% -> 20.8 >= 92% -> 10.4 >= 76% -> 0.8
>= 98.5% -> 20.3 >= 91% -> 8.8 >= 74% -> 0.4
>= 98% -> 20.0 >= 90% -> 8.0 >= 72% -> 0.2
>= 97% -> 17.6 >= 86% -> 4.8 >= 70% -> 0.1
>= 96% -> 16.8 >= 84% -> 3.2 其余 -> 0
ra = floor(ds × coef)
#### 实测校验(同一账号)
B35 求和 = 9075,服务端总 rating = 9168,差 93
差的来源:newRatingList 4 条里 3 条在国服 music.json 查不到定数(未实装的新曲)
所以「定数缺失」必须当成正常情况处理,不能当错误。
#### 展示(照 EasyMai 的结构)
- 顶部:总 Rating = B35 + B15,配色按 `getCurrentRatingClass()`
(≥15000 rainbow / ≥14500 bright-gold / ≥14000 gold / ≥13000 blue / ≥12000 copper)
- 两块列表:B35(旧曲 35 条)、B15(新曲最多 15 条),每行
曲名 · 难度(Basic/Adv/Exp/Master/Re:Master)· 定数 · 达成率 · ra · FC/FS
- FC/FS 适配(`b50adapter.js`):
`comboStatus 0/1/2/3/4 -> ''/fc/fcp/ap/app`,
`syncStatus 0/1/2/3/4/5 -> ''/fs/fsp/fdx/fdxp/sync`
- 定数缺失的行标 `—`,不进合计,并在底部注明"n 首定数缺失"
#### 第一版不做
- 封面图(要拉 CDN 并转 base64)
- 海报导出、历史快照、分享(EasyMai 本地存 20 条 + 分享图)
#### 改动面
core/b50.py 取数 + 算 ra + 组装(约 120 行)
app.py + GET /api/b50?userId=
index.html + 一张「B50 查分」卡(总分 + 两个列表)
#### 验收
- 与游戏内 B50 前三名的 ra 逐个对得上
- 总分误差 = 定数缺失那几首的 ra(应能解释清楚,不是bug)
### 2. 修改 Rating(写,高风险)
- 字段:`userData[0].playerRating` + `userRatingList[0].rating`,**两处必须同时改且一致**
- 范围 0 ~ 99999(Ichino `clampValue`)
- 已知后果:下次真实游玩会被服务端按成绩重算覆盖;`ratingList` 里每首的 rating 不动
- 新增:`core/rating.py`
### 3. 舞里程(写,中风险)
- 字段:`userData[0].point`(int32 余额),可选带 `totalPoint`(累计)
- 两种模式:设为 X / 当前值 + X;负数即扣款
- 客户端的钳位只发生在「获得」路径(`UserDetail.AddMile` 钳到 99999),商店扣款不钳,
所以传大值不会被客户端回写覆盖
- 新增:`core/point.py`
### 4. 一键跑图(写,中风险)
- 字段:`userMapList` + `isNewMapList`,主键 mapId,每行
`{mapId, distance, isLock, isClear, isComplete, unlockFlag}`
- 关键:`isClear` / `isComplete` 在客户端是**从 distance 派生**的
(`MapMaster.CreateUserDataMapList`),单发 flag 下次进区域选择就被冲掉,
所以必须把 `distance` 直接推到 `UserMapData.MaxDistance = 999999999`
- `unlockFlag` 语义是反的:`IsFinishedOpening ? 0 : 1`,取 0 才是「已开启」
- 需要先 `api.GetUserMapAll()` 拿现有行合并(保留已有进度,别清零)
- 新增:`core/maprun.py`
### 5. 万花筒专区(写,高风险)
- 字段:`userKaleidxScopeList` + `isNewKaleidxScopeList`
- 每行是**整行替换**(15 字段),`isGateFound` / `isKeyFound` / `isClear` 同在一条
- 陷阱:不先回读原行合并成绩 / 日期 / playCount,会把已有记录清零
- 需先 `api.GetUserKaleidxScope()`
- 新增:`core/kaleidx.py`
---
## 三、所有写入项的统一前置(这次踩坑换来的)
1. **先查票**:`GetUserCharge` 里任何 `stock > 0` 的功能票都会让 UpsertUserAll 整包被判非法,
先拦下来提示「号上有票,先清票/等过期」;
2. **等满 60 秒**:登录后不足一分钟的结算包会被静默丢弃 → 复用 `settle.ensure_play_window`;
3. **一条占位 playlog + 一条占位 detail**(大包模式),跟发收藏品一致;
4. **一包一局**:每次 `session.fresh` 换局,一局只收一次 UpsertUserAll;
5. 每项写完都回读校验(`GetUserData` / `GetUserMap` / `GetUserRating`),不回读不算成功。
---
## 四、实施顺序
| 阶段 | 内容 | 风险 | 验收方式 |
| --- | --- | --- | --- |
| A | B50 查分 | 无(只读) | 页面显示总分与 50 首,和游戏内对得上 |
| B | 舞里程、一键跑图 | 中(可回退) | 改完回读数值;游戏内看里程/区域状态 |
| C | 修改 Rating、万花筒 | 高 | 改完回读;rating 以游戏内显示为准 |
---
## 五、改动面(确认后才动)
新增文件(互不影响):
core/b50.py core/rating.py core/point.py core/maprun.py core/kaleidx.py
追加式改动(只在文件末尾加,不动已有逻辑):
app.py +6 条路由(/api/b50, /api/rating, /api/point, /api/map, /api/kaleidx, /api/kaleidx/list)
html/index.html +3 张卡(B50 查分 / 数值修改 / 拓展功能)
复用不修改:
core/settle.py(60 秒窗口、占位 playlog)
core/session.py(登录/换局/登出)
core/items.py(GetUserItem 封装,用来回读)
core/charge.py(查票的闸门)
---
## 六、不做的事
- 不移植 Ichino 的 B50 PNG 海报(要引图表依赖,先上表格)
- 不动现有的六项功能与其界面
- 不引入 Flutter 那套 UI,继续用当前单页
-174
View File
@@ -1,174 +0,0 @@
# maipulse 发包工具 — 实施计划
> 目标:舞萌DX 国服(SDGB)本地发包工具,六项功能,GUI 操作,协议复用 Astral。
> 计划版本:v1(2026-10-21),发票口径已确认 = 2/3/5 倍功能票(charge 体系)。
---
## 一、目标与范围
| 功能 | 协议落点 | 状态 |
|---|---|---|
| 发票(2/3/5 倍功能票) | `UpsertUserChargelogApi`(chargeId 2/3/5) | 复用 Astral `buy_ticket.py` 链路 |
| 解禁小黑屋 | `UserLogoutApi`(type 5/4)+ `GetUserPreview.banState` 检测 | 待实现(会话解卡口径) |
| 解锁伙伴 | `userItemList` itemKind=10(Partner) | 待实现 |
| 发礼物 | `userItemList` itemKind=4(Present) | 待实现 |
| 发收藏品 | `userItemList` itemKind=1/2/3/9/11(Plate/Title/Icon/Character/Frame) | 待实现 |
| 解锁歌曲(一键 / 选中) | `userItemList` itemKind=5/6/7 | 内核已有,需改成真全量+分批 |
非目标:不做修改客户端、不做注入、不做成绩伪造以外的机台侧改动。
---
## 二、现状(已核实)
1. 协议层:`D:\DSH\maimai\Astral`(主分支 mai),`api/` 40+ 接口,`MaimaiNet.py` 负责 AES-256-CBC + zlib + MD5 URL 混淆 + Cookie 会话。
2. 环境:Python 3.13 + flask 3.1.0 / requests 2.32.5 / pycryptodome 3.23.0 / openpyxl 3.1.5,全部可用。
3. 机台身份:`config.py` 已配 CLIENT_ID `A63E01E3636`、PLACE_ID `2282`、REGION_ID `15` → KEYCHIP_ID `A63E-01E36362282`。
4. 反编译源码:`sdgb-unpack\full.cs\Assembly-CSharp.unpack.decompiled.cs`(9.0 MB),`ItemKind` 枚举已确认 15 种。
5. 数据表:`maipulse\data\master\` 下 ticket / partner / title / icon / plate / frame / chara / music 八张。
6. 店铺表:`SDGB_keychips_20260929.csv.xlsx`,3383 行,字段 client_id / place_id / name / region_id / region_name;**place_id 混十进制与十六进制**(`859` 与 `"082f"` 并存),必须统一解析。
7. 现有 GUI:`maipulse\app.py`(Flask,400 行)+ `html\index.html`(六面板 + 选择器),内核有下述 5 个必修缺陷。
---
## 三、六项功能的实现定义
### 1. 发票(确认口径:2/3/5 倍功能票)
- 接口:`UpsertUserChargelogApi`,走 Astral `api.UpsertUserChargelog()`。
- chargeId 与上报 price(= ticket.creditNum,不是金额):
- 2 → 2倍チケット,price 1
- 3 → 3倍チケット,price 2
- 5 → 5倍チケット,price 4
- 调用链:`ParseQRCode → GetUserPreview(顶号)→ UserLogin → GetUserCharge(查重)→ 等 15s → UpsertUserChargelog → 再查一次确认 → UserLogout(TestIn)`
- 内部已有闸门:chargeId 必须是付费票(ticketKind=1)、`stock > 0` 直接拒(重复购买有封号风险)、同账号同进程只允许一次。
- UI:票种选择 2/3/5、当前持有列表(chargeId / stock / validDate)、购买按钮、预检开关。
### 2. 解禁小黑屋
- 口径:账号卡在 `isLogin=True`(扫码提示已登录、机台进不去)。
- 做法:先 `GetUserPreview` 读状态;能拿到 preview 说明二维码有效,直接 `UserLogin` 后立刻 `UserLogout(type=5 Quit 或 4 TestIn)`,loginDateTime 用本次 login 返回的值。
- `banState != 0`(橙/红限制)属于服务端下发字段,客户端写不进 `userData`,只做**检测 + 明示**,不伪造解除。
- UI:一键解卡 + 状态展示(isLogin / banState / lastLoginDate)。
### 3. 解锁伙伴
- `userItemList` 条目 `{"itemKind":10,"itemId":<partnerId>,"stock":1,"isValid":true}`。
- 数据源:`data/master/partner.json`(id / name / lock / norm)。
- 可选增强:`userIntimateList`(主键 partnerId,字段 intimateLevel / intimateCountRewarded)——单独开关,默认不动。
### 4. 发礼物
- `userItemList` 条目 itemKind=4(Present),id 表待补:现有 `master.json` 无 present 分类,需要从 `sdgb.lemonno.xyz` 或反编译 `PresentData` 表导出。
### 5. 发收藏品
- 一个通用发放器按 kind 切表:
- 1 Plate(`plate.json`)
- 2 Title(`title.json`,724 KB)
- 3 Icon(`icon.json`)
- 9 Character(`chara.json`)
- 11 Frame(`frame.json`)
- UI:分类切换 + 搜索 + 多选 + 数量。
### 6. 解锁歌曲
- itemKind:5 本体 / 6 Master / 7 Re:Master(8 = Strong,前端标签需改)。
- 规则:musicId < 10000 的标准谱 Master/Re 天生解锁,不写 6/7;只写 5。
- 一键全量:`music.json` 全表 → 减掉 `GetUserItem(5)` 已有 → 分批(单包 ≤4 首,规避 2 首包被拒,按 4/3/3 切)→ 每包前 fresh session(登出 + 重登)→ 落库回查。
- 选中解锁:id 列表直接走 `UpsertUserAll(unlock_music_ids=[...])`。
---
## 四、内核必修(审计结论)
1. **位串长度错**:`_push_items` 发完整 `userItemList`,却只给 `"1"*新增数` 的 `isNewItemList`。`BuildListData` 要求一位一条 → 必须逐条 0(存量)/1(新增),否则 500 或重复插入。
2. **全量解锁是假的**:`unlock_music_ids=[-1]` 会被 `build_unlock_items` 直接丢弃(只认 >0 的 id)。必须换成真实全表 + 分批。
3. **假成绩风险**:固定塞 `music 11308 / level 3 / achievement 0` 的 playlog,服务端可能不收;改为最小合法 playlog,且不污染账号统计(playCount 增量可控)。
4. **每包新会话**:服务端把一次登录当一局,一局只收一次 Upsert → 批量流程强制 `_fresh_session`。
5. **kind8 命名**:Survival → Strong(`MusicUnlockStrongList`)。
6. **place_id 解析**:xlsx 里 `"082f"` 按十六进制 → 2095,`859` 按十进制;统一转 int 后再拼 KEYCHIP。
---
## 五、架构
```
maipulse/
app.py Flask 路由(/api/qr /login /logout /charge /grant /unlock /repair /shop /master)
core/session.py 会话:ParseQRCode、GetUserPreview、UserLogin、fresh_session、UserLogout
core/charge.py 功能票:GetUserCharge 查重 → 15s → UpsertUserChargelog → 回查
core/grant.py 通用道具:GetUserItem(kind) → 合并 → 位串 → UpsertUserAll patch
core/unlock.py 歌曲:全量分批 / 选中
core/repair.py 小黑屋:状态检测 + 解卡登出
core/shop.py xlsx 解析 + 店铺搜索 + config 回写 + KEYCHIP 校验
data/master/*.json
html/index.html 六面板
```
原则:协议层只 import Astral,不复制、不 fork;业务层只做编排与数据合并;前端只发 HTTP。
---
## 六、里程碑
| 阶段 | 内容 | 验收 |
|---|---|---|
| M0 冒烟 | 跑 `selftest.py`、起 maipulse、dry-run 空包 | 284 项绿 + 页面可开 |
| M1 内核 | 位串 / 全量 / playlog / fresh session / 回查 | dry-run 报文节点与位串长度一致 |
| M2 功能 | 票 → 伙伴 → 收藏品 → 礼物 → 歌曲 → 小黑屋 | 每项 dry-run + 单条实发 + Get 回查 |
| M3 店铺 | xlsx 解析(hex 修正)+ 店铺选择 + config 回写 | KEYCHIP 与表内一致 |
| M4 界面 | 六面板 + 状态卡 + 错误详情 + 日志 | 全流程浏览器操作 |
| M5 验收 | 逐功能 预检 → 实发 → 游戏内确认 | 验收记录 + 截图 |
---
## 七、风险与验证口径
- 一局一 Upsert、单包 ≤4 首、2 首包被拒 → 分批固定 4/3/3。
- 重复插入已有条目 → HTTP 500;所有发放前必须先 Get 差集。
- `banState` 只读,不伪造。
- 二维码 10 分钟过期,一过期就得回机台重扫。
- 所有写操作默认 dry-run,UI 上必须显式勾选「真实发包」。
- 每步落库回查(GetUserItem / GetUserCharge),以服务器回读为准,不以本地构造为准。
---
## 八、实测结论(2026-10-27)
### ★ 一局至少 60 秒 —— 之前所有"回码 1 但不落库"的真正原因
MaiTechUI 金票包 UpsertUserAllApi.poop() 原样:
long num = Configs.getNowUnixTime() - Configs.NowLoginTime;
Thread.Sleep(Configs.LogoutTimeLimit - (int)num * 1000); // LogoutTimeLimit = 60000
return base.poop();
它从不在登录后 60 秒内提交结算。服务端对"登录不到一分钟就交成绩"的包一律回
returnCode=1 然后把 userItemList / userMusicDetailList 整块丢掉。
已验证(账号 12042860):每次发包前补足 60 秒后,四类道具全部真落地。
| kind | 名称 | 发送 id | 库存变化 | waited |
| --- | --- | --- | --- | --- |
| 2 | 称号 | 32 | 147 → 148 | 57.7s |
| 10 | 伙伴 | 12 | 22 → 23 | 52.6s |
| 1 | 名牌 | 2 | 49 → 50 | 57.4s |
| 11 | 边框 | 2 | 41 → 42 | 56.8s |
实现:core/settle.py:ensure_play_window(),登录时在 state 里记 login_at,
items.grant / unlock.unlock_selected / unlock.unlock_all 在发 UpsertUserAll 前调用。
### 本局必须是 dummy 曲 11538 的自洽全 Miss
除了 playlog,还要把这一局的四处痕迹一起对上(见 settle.py 头注释):
userData.lastPlayDate/dateTime、userMusicDetailList + isNewMusicDetailList、
userGamePlaylogList(playTrack=1 / playlogId=loginId / playSpecial 随机置换)、
userPlaylogList(achievement=0 且有 miss)。
### 重启必须走 tools/restart.ps1
直接杀进程会把服务端会话留在 isLogin=True,账号进小黑屋(登录回 100),
且 loginId 只存在内存里,事后连 UserLogout 都发不对。
/api/shutdown 会先登出再退出。
+89 -5
View File
@@ -25,7 +25,7 @@ APP_DIR = os.path.dirname(os.path.abspath(__file__))
LOG_DIR = os.path.join(APP_DIR, "logs")
os.makedirs(LOG_DIR, exist_ok=True)
LOG_PATH = os.path.join(LOG_DIR, "app.log")
ASTRAL = os.environ.get("ASTRAL_DIR", r"D:\DSH\maimai\Astral")
ASTRAL = os.environ.get("ASTRAL_DIR", r"<astral>")
for path in (APP_DIR, ASTRAL):
if path not in sys.path:
sys.path.insert(0, path)
@@ -770,6 +770,82 @@ def assets(name):
# ---------------------------------------------------------------- 数据表
#: addVersion -> 界面显示名(原代号里补一个空格,maimaDX 缩成 DX)
_VER_LABEL = {
"maimai": "maimai", "maimaiPLUS": "maimai PLUS",
"GreeN": "GreeN", "GreeNPLUS": "GreeN PLUS",
"ORANGE": "ORANGE", "ORANGEPLUS": "ORANGE PLUS",
"PiNK": "PiNK", "PiNKPLUS": "PiNK PLUS",
"MURASAKi": "MURASAKi", "MURASAKiPLUS": "MURASAKi PLUS",
"MiLK": "MiLK", "MiLKPLUS": "MiLK PLUS",
"FiNALE": "FiNALE", "maimaDX": "DX",
"Splash": "Splash", "UNiVERSE": "UNiVERSE",
"FESTiVAL": "FESTiVAL", "PRiSM": "PRiSM",
"PRiSMPLUS": "PRiSM PLUS", "BUDDiES": "BUDDiES",
}
def _music_groups(items):
"""曲目分类:全曲 → 各版本 → 当前版本 → 当前版本各差分包 → 无归属。
版本组只统计 A000 基础包里的曲目;当前版本组是增量包里出现的那一版
(曲目全在 A011~A041 里,A000 里已经没有它的目录了),含该版本全部曲目;
差分包组是当前版本的细分。三者相加会重复 73 首,界面上标注清楚即可。
"""
by_id = {it.get("id"): it for it in items}
ver = {} # addVersion -> {"ids": [...], "order": 最早 version}
pack = {} # 包号 -> [id]
loose = [] # 哪个包都没有的(水鱼曲库补进来的)
all_ids = []
for it in items:
mid = it.get("id")
all_ids.append(mid)
pk = (it.get("pack") or "").strip()
if pk == "A000":
av = it.get("addVersion") or ""
node = ver.setdefault(av, {"ids": [], "order": it.get("version") or 0})
node["ids"].append(mid)
node["order"] = min(node["order"], it.get("version") or 0)
elif pk:
pack.setdefault(pk, []).append(mid)
else:
loose.append(mid)
groups = [{"key": "all", "label": "全曲解锁", "kind": "all",
"count": len(all_ids), "ids": all_ids}]
for av in sorted(ver, key=lambda k: (ver[k]["order"], k)):
ids = ver[av]["ids"]
groups.append({"key": "ver:" + (av or "-"),
"label": _VER_LABEL.get(av, av or "未标注"),
"kind": "version", "count": len(ids), "ids": ids})
# 当前版本:只出现在增量包里的那一版,把该版本全部曲目合成一组
cur = {}
for mids in pack.values():
for mid in mids:
av = (by_id.get(mid) or {}).get("addVersion") or ""
node = cur.setdefault(av, {"ids": [], "order": 0})
node["ids"].append(mid)
node["order"] = min(node["order"] or 10 ** 9,
(by_id.get(mid) or {}).get("version") or 0)
for av in sorted(cur, key=lambda k: (cur[k]["order"], k)):
if av in ver:
continue # A000 里还有这一版,走上面的版本组,不重复
ids = sorted(cur[av]["ids"])
groups.append({"key": "cur:" + (av or "-"),
"label": (_VER_LABEL.get(av, av or "未标注") + "(当前版本)"),
"kind": "current", "count": len(ids), "ids": ids})
for pk in sorted(pack, reverse=True):
ids = pack[pk]
groups.append({"key": "pack:" + pk, "label": pk + " 更新",
"kind": "pack", "count": len(ids), "ids": ids})
if loose:
groups.append({"key": "loose", "label": "无归属", "kind": "loose",
"count": len(loose), "ids": loose})
return groups
@app.get("/api/master")
def api_master():
path = os.path.join(APP_DIR, "data", "master.json")
@@ -781,8 +857,16 @@ def api_master():
if cat:
if cat not in data:
return fail("没有这个分类:" + cat)
return ok(cat=cat, kind=data[cat]["kind"], count=data[cat]["count"],
items=data[cat]["items"])
node = data[cat]
extra = {}
if cat == "music":
extra["groups"] = _music_groups(node.get("items") or [])
if request.args.get("brief"):
# 只要分类清单(面板上的版本下拉用),不背 1343 条曲目过来
return ok(cat=cat, kind=node["kind"], count=node["count"],
groups=extra["groups"])
return ok(cat=cat, kind=node["kind"], count=node["count"],
items=node["items"], **extra)
return ok(cats={k: {"kind": v["kind"], "count": v["count"]} for k, v in data.items()})
@@ -790,8 +874,8 @@ def api_master():
# ---------------------------------------------------------------- 更新
APP_VERSION = "1.0.1"
APP_BUILD = "2026-10-29"
APP_VERSION = "1.0.2"
APP_BUILD = "2026-10-30"
@app.get("/api/version")
+1 -1
View File
@@ -9,6 +9,6 @@ import os
import sys
# ★ 让 core.* 无论从哪个入口 import 都能找到 api 包(Flask、自检脚本、CLI 都一样)
_ASTRAL = os.environ.get("ASTRAL_DIR", r"D:\DSH\maimai\Astral")
_ASTRAL = os.environ.get("ASTRAL_DIR", r"<astral>")
if os.path.isdir(_ASTRAL) and _ASTRAL not in sys.path:
sys.path.insert(0, _ASTRAL)
+1 -1
View File
@@ -22,7 +22,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join(BASE_DIR, "data")
SHOP_CACHE = os.path.join(DATA_DIR, "shops.json")
CURRENT_FILE = os.path.join(DATA_DIR, "shop_current.json")
XLSX_PATH = os.environ.get("SDGB_SHOPS_XLSX", r"D:\DSH\maimai\SDGB_keychips_20260929.csv.xlsx")
XLSX_PATH = os.environ.get("SDGB_SHOPS_XLSX", r"<项目根>\SDGB_keychips_20260929.csv.xlsx")
def to_int(value) -> int:
+1 -1
View File
@@ -1 +1 @@
{"ticket":{"kind":"ticket","count":12,"items":[{"id":2,"name":"2倍チケット","detail":"区域前进2倍票","areaPercent":200,"charaMagnification":200,"isMoney":true,"creditNum":1,"maxCount":0,"kind":"Paid","event":"無期限常時解放"},{"id":3,"name":"3倍チケット","detail":"区域前进3倍票","areaPercent":300,"charaMagnification":300,"isMoney":true,"creditNum":2,"maxCount":0,"kind":"Paid","event":"無期限常時解放"},{"id":4,"name":"4倍チケット","detail":"区域前进4倍票","areaPercent":400,"charaMagnification":400,"isMoney":true,"creditNum":3,"maxCount":0,"kind":"Paid","event":"解放なし"},{"id":5,"name":"5倍チケット","detail":"区域前进5倍票","areaPercent":500,"charaMagnification":500,"isMoney":true,"creditNum":4,"maxCount":0,"kind":"Paid","event":"解放なし"},{"id":6,"name":"6倍チケット","detail":"区域前进6倍票","areaPercent":600,"charaMagnification":600,"isMoney":true,"creditNum":5,"maxCount":0,"kind":"Paid","event":"解放なし"},{"id":10005,"name":"イベント5倍チケット_1","detail":"区域前进5倍票","areaPercent":500,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"Event","event":"250611_06_1:イベント5倍チケット配布_1(1.50予備)"},{"id":10105,"name":"イベント5倍チケット_2","detail":"区域前进5倍票","areaPercent":500,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"Event","event":"250611_06_2:イベント5倍チケット配布_2(1.50予備)"},{"id":10205,"name":"イベント5倍チケット_3","detail":"区域前进5倍票","areaPercent":500,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"Event","event":"250611_06_3:イベント5倍チケット配布_3(1.50予備)"},{"id":11001,"name":"無料1.5倍チケット","detail":"区域前进1.5倍票","areaPercent":150,"charaMagnification":150,"isMoney":false,"creditNum":0,"maxCount":5,"kind":"Free","event":"無期限常時解放"},{"id":30001,"name":"連動チケット","detail":"特殊功能票","areaPercent":200,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"Link","event":"無期限常時解放"},{"id":30002,"name":"每周区域前进2倍票","detail":"每周区域前进2倍票","areaPercent":200,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"ShopWeekly","event":"無期限常時解放"},{"id":30003,"name":"旅行伙伴等级提升5倍票","detail":"旅行伙伴等级提升5倍票","areaPercent":100,"charaMagnification":500,"isMoney":false,"creditNum":0,"maxCount":5,"kind":"ShopChara","event":"無期限常時解放"}]},"partner":{"kind":"partner","count":23,"items":[{"id":1,"name":"でらっくま","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"はじめから所持","isDefault":true,"disable":false},{"id":11,"name":"乙姫","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"乙姫のスタンプを10個集める","isDefault":false,"disable":false},{"id":12,"name":"ラズ","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"ラズのスタンプを10個集める","isDefault":false,"disable":false},{"id":13,"name":"シフォン","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"シフォンのスタンプを10個集める","isDefault":false,"disable":false},{"id":14,"name":"ソルト","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"ソルトのスタンプを10個集める","isDefault":false,"disable":false},{"id":15,"name":"しゃま","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"しゃまのスタンプを10個集める","isDefault":false,"disable":false},{"id":16,"name":"みるく","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"みるくのスタンプを10個集める","isDefault":false,"disable":false},{"id":17,"name":"らいむっくま&れもんっくま","genre":"ALL","release":"Ver1.10.00","lock":"None","norm":"はじめから所持","isDefault":true,"disable":false},{"id":18,"name":"乙姫(すぷらっしゅ)","genre":"ALL","release":"Ver1.15.00","lock":"None","norm":"乙姫(すぷらっしゅ)のスタンプを10個集める","isDefault":false,"disable":false},{"id":19,"name":"しゃま(ゆにばーす)","genre":"ALL","release":"Ver1.20.00","lock":"None","norm":"しゃま(ゆにばーす)のスタンプを10個集める","isDefault":false,"disable":false},{"id":20,"name":"みるく(ゆにばーす)","genre":"ALL","release":"Ver1.20.00","lock":"None","norm":"みるく(ゆにばーす)のスタンプを10個集める","isDefault":false,"disable":false},{"id":21,"name":"ちびみるく","genre":"ALL","release":"Ver1.25.00","lock":"None","norm":"ちびみるくのスタンプを10個集める","isDefault":false,"disable":false},{"id":22,"name":"百合咲ミカ","genre":"ALL","release":"Ver1.25.00","lock":"None","norm":"百合咲ミカのスタンプを10個集める","isDefault":false,"disable":false},{"id":2Line truncated
{"ticket":{"kind":"ticket","count":12,"items":[{"id":2,"name":"2倍チケット","detail":"区域前进2倍票","areaPercent":200,"charaMagnification":200,"isMoney":true,"creditNum":1,"maxCount":0,"kind":"Paid","event":"無期限常時解放"},{"id":3,"name":"3倍チケット","detail":"区域前进3倍票","areaPercent":300,"charaMagnification":300,"isMoney":true,"creditNum":2,"maxCount":0,"kind":"Paid","event":"無期限常時解放"},{"id":4,"name":"4倍チケット","detail":"区域前进4倍票","areaPercent":400,"charaMagnification":400,"isMoney":true,"creditNum":3,"maxCount":0,"kind":"Paid","event":"解放なし"},{"id":5,"name":"5倍チケット","detail":"区域前进5倍票","areaPercent":500,"charaMagnification":500,"isMoney":true,"creditNum":4,"maxCount":0,"kind":"Paid","event":"解放なし"},{"id":6,"name":"6倍チケット","detail":"区域前进6倍票","areaPercent":600,"charaMagnification":600,"isMoney":true,"creditNum":5,"maxCount":0,"kind":"Paid","event":"解放なし"},{"id":10005,"name":"イベント5倍チケット_1","detail":"区域前进5倍票","areaPercent":500,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"Event","event":"250611_06_1:イベント5倍チケット配布_1(1.50予備)"},{"id":10105,"name":"イベント5倍チケット_2","detail":"区域前进5倍票","areaPercent":500,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"Event","event":"250611_06_2:イベント5倍チケット配布_2(1.50予備)"},{"id":10205,"name":"イベント5倍チケット_3","detail":"区域前进5倍票","areaPercent":500,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"Event","event":"250611_06_3:イベント5倍チケット配布_3(1.50予備)"},{"id":11001,"name":"無料1.5倍チケット","detail":"区域前进1.5倍票","areaPercent":150,"charaMagnification":150,"isMoney":false,"creditNum":0,"maxCount":5,"kind":"Free","event":"無期限常時解放"},{"id":30001,"name":"連動チケット","detail":"特殊功能票","areaPercent":200,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"Link","event":"無期限常時解放"},{"id":30002,"name":"每周区域前进2倍票","detail":"每周区域前进2倍票","areaPercent":200,"charaMagnification":100,"isMoney":false,"creditNum":0,"maxCount":1,"kind":"ShopWeekly","event":"無期限常時解放"},{"id":30003,"name":"旅行伙伴等级提升5倍票","detail":"旅行伙伴等级提升5倍票","areaPercent":100,"charaMagnification":500,"isMoney":false,"creditNum":0,"maxCount":5,"kind":"ShopChara","event":"無期限常時解放"}]},"partner":{"kind":"partner","count":23,"items":[{"id":1,"name":"でらっくま","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"はじめから所持","isDefault":true,"disable":false},{"id":11,"name":"乙姫","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"乙姫のスタンプを10個集める","isDefault":false,"disable":false},{"id":12,"name":"ラズ","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"ラズのスタンプを10個集める","isDefault":false,"disable":false},{"id":13,"name":"シフォン","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"シフォンのスタンプを10個集める","isDefault":false,"disable":false},{"id":14,"name":"ソルト","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"ソルトのスタンプを10個集める","isDefault":false,"disable":false},{"id":15,"name":"しゃま","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"しゃまのスタンプを10個集める","isDefault":false,"disable":false},{"id":16,"name":"みるく","genre":"ALL","release":"Ver1.05.00","lock":"None","norm":"みるくのスタンプを10個集める","isDefault":false,"disable":false},{"id":17,"name":"らいむっくま&れもんっくま","genre":"ALL","release":"Ver1.10.00","lock":"None","norm":"はじめから所持","isDefault":true,"disable":false},{"id":18,"name":"乙姫(すぷらっしゅ)","genre":"ALL","release":"Ver1.15.00","lock":"None","norm":"乙姫(すぷらっしゅ)のスタンプを10個集める","isDefault":false,"disable":false},{"id":19,"name":"しゃま(ゆにばーす)","genre":"ALL","release":"Ver1.20.00","lock":"None","norm":"しゃま(ゆにばーす)のスタンプを10個集める","isDefault":false,"disable":false},{"id":20,"name":"みるく(ゆにばーす)","genre":"ALL","release":"Ver1.20.00","lock":"None","norm":"みるく(ゆにばーす)のスタンプを10個集める","isDefault":false,"disable":false},{"id":21,"name":"ちびみるく","genre":"ALL","release":"Ver1.25.00","lock":"None","norm":"ちびみるくのスタンプを10個集める","isDefault":false,"disable":false},{"id":22,"name":"百合咲ミカ","genre":"ALL","release":"Ver1.25.00","lock":"None","norm":"百合咲ミカのスタンプを10個集める","isDefault":false,"disable":false},{"id":2Line truncated
+1 -1
View File
@@ -1 +1 @@
[{"id":8,"name":"True Love Song","sortName":"TRUELOVESONG","artist":"Kai/クラシック「G線上のアリア」","genre":"maimai","bpm":150,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":5,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":2,"designer":"-"},{"level":10,"levelDecimal":2,"designer":"譜面-100号"},{"level":12,"levelDecimal":4,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}]},{"id":9,"name":"Color My World","sortName":"COLORMYWORLD","artist":"GOSH/クラシック「悲愴」","genre":"maimai","bpm":94,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":4,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":1,"designer":"-"},{"level":9,"levelDecimal":8,"designer":"譜面-100号"},{"level":12,"levelDecimal":4,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}]},{"id":17,"name":"Future","sortName":"FUTURE","artist":"★STAR GUiTAR [cover]","genre":"POPSアニメ","bpm":130,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":7,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":8,"designer":"-"},{"level":9,"levelDecimal":5,"designer":"maimai TEAM"},{"level":10,"levelDecimal":7,"designer":"譜面-100号"},{"level":12,"levelDecimal":7,"designer":"はっぴー"},{"level":0,"levelDecimal":0,"designer":""}]},{"id":18,"name":"Love You","sortName":"LOVEYOU","artist":"Q;indivi [cover]","genre":"POPSアニメ","bpm":135,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":6,"levelDecimal":0,"designer":"-"},{"level":6,"levelDecimal":8,"designer":"-"},{"level":9,"levelDecimal":6,"designer":"譜面-100号"},{"level":11,"levelDecimal":7,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}]},{"id":22,"name":"In Chaos","sortName":"INCHAOS","artist":"Hiro「Crackin’DJ」","genre":"ゲームバラエティ","bpm":140,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":6,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":6,"designer":"-"},{"level":9,"levelDecimal":7,"designer":"譜面-100号"},{"level":12,"levelDecimal":5,"designer":"ニャイン"},{"level":14,"levelDecimal":8,"designer":"チャン@DP皆伝"},{"level":0,"levelDecimal":0,"designer":""}]},{"id":23,"name":"Crush On You","sortName":"CRUSHONYOU","artist":"福山光晴「Crackin’DJ」","genre":"ゲームバラエティ","bpm":125,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":4,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":6,"designer":"-"},{"level":9,"levelDecimal":2,"designer":"譜面-100号"},{"level":11,"levelDecimal":4,"designer":"ニャイン"},{"level":13,"levelDecimal":7,"designer":"Jack"},{"level":0,"levelDecimal":0,"designer":""}]},{"id":24,"name":"Sun Dance","sortName":"SUNDANCE","artist":"Hiro「Crackin’DJ」","genre":"ゲームバラエティ","bpm":102,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":4,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":6,"designer":"-"},{"level":8,"levelDecimal":9,"designer":"譜面-100号"},{"level":11,"levelDecimal":3,"designer":"ニャイン"},{"level":14,"levelDecimal":0,"designer":"譜面-100号"},{"level":0,"levelDecimal":0,"designer":""}]},{"id":25,"name":"Sweets×Sweets","sortName":"SWEETSSWEETS","artist":"Team-D","genre":"maimai","bpm":132,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":6,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":4,"designer":"-"},{"level":9,"levelDecimal":3,"designer":"譜面-100号"},{"level":11,"levelDecimal":4,"designer":"maimai TEAM"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}]},{"id":27,"name":"Love or Lies","sortName":"LOVEORLIES","artist":"capsule [cover]","genre":"POPSアニメ","bpm":128,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":5,"levelDecimal":0,"designer":"-"},{"level":6,"levelDecimal":8,"designer":"-"},{"level":9,"levelDecimal":5,"designer":"譜面-100号"},{"level":11,"levelDecimal":7,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}]},{"id":30,"name":"ネコ日和。","sortName":"ネコヒヨリ","artist":"Kai/能登有沙","genre":"maimai","bpm":171,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":6,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":9,"designer":"-"},{"level":8,"levelDecimal":8,"designer":"譜面-100号"},{"level":12,"levelDecimal":6,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}]},{"id":31,"name":"虹と太陽","sortName":"ニシトタイヨウ","artist":"GOSHLine truncated
[{"id":8,"name":"True Love Song","sortName":"TRUELOVESONG","artist":"Kai/クラシック「G線上のアリア」","genre":"maimai","bpm":150,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":5,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":2,"designer":"-"},{"level":10,"levelDecimal":2,"designer":"譜面-100号"},{"level":12,"levelDecimal":4,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":9,"name":"Color My World","sortName":"COLORMYWORLD","artist":"GOSH/クラシック「悲愴」","genre":"maimai","bpm":94,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":4,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":1,"designer":"-"},{"level":9,"levelDecimal":8,"designer":"譜面-100号"},{"level":12,"levelDecimal":4,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":17,"name":"Future","sortName":"FUTURE","artist":"★STAR GUiTAR [cover]","genre":"POPSアニメ","bpm":130,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":7,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":8,"designer":"-"},{"level":9,"levelDecimal":5,"designer":"maimai TEAM"},{"level":10,"levelDecimal":7,"designer":"譜面-100号"},{"level":12,"levelDecimal":7,"designer":"はっぴー"},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":18,"name":"Love You","sortName":"LOVEYOU","artist":"Q;indivi [cover]","genre":"POPSアニメ","bpm":135,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":6,"levelDecimal":0,"designer":"-"},{"level":6,"levelDecimal":8,"designer":"-"},{"level":9,"levelDecimal":6,"designer":"譜面-100号"},{"level":11,"levelDecimal":7,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":22,"name":"In Chaos","sortName":"INCHAOS","artist":"Hiro「Crackin’DJ」","genre":"ゲームバラエティ","bpm":140,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":6,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":6,"designer":"-"},{"level":9,"levelDecimal":7,"designer":"譜面-100号"},{"level":12,"levelDecimal":5,"designer":"ニャイン"},{"level":14,"levelDecimal":8,"designer":"チャン@DP皆伝"},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":23,"name":"Crush On You","sortName":"CRUSHONYOU","artist":"福山光晴「Crackin’DJ」","genre":"ゲームバラエティ","bpm":125,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":4,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":6,"designer":"-"},{"level":9,"levelDecimal":2,"designer":"譜面-100号"},{"level":11,"levelDecimal":4,"designer":"ニャイン"},{"level":13,"levelDecimal":7,"designer":"Jack"},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":24,"name":"Sun Dance","sortName":"SUNDANCE","artist":"Hiro「Crackin’DJ」","genre":"ゲームバラエティ","bpm":102,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":4,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":6,"designer":"-"},{"level":8,"levelDecimal":9,"designer":"譜面-100号"},{"level":11,"levelDecimal":3,"designer":"ニャイン"},{"level":14,"levelDecimal":0,"designer":"譜面-100号"},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":25,"name":"Sweets×Sweets","sortName":"SWEETSSWEETS","artist":"Team-D","genre":"maimai","bpm":132,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":6,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":4,"designer":"-"},{"level":9,"levelDecimal":3,"designer":"譜面-100号"},{"level":11,"levelDecimal":4,"designer":"maimai TEAM"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":27,"name":"Love or Lies","sortName":"LOVEORLIES","artist":"capsule [cover]","genre":"POPSアニメ","bpm":128,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":5,"levelDecimal":0,"designer":"-"},{"level":6,"levelDecimal":8,"designer":"-"},{"level":9,"levelDecimal":5,"designer":"譜面-100号"},{"level":11,"levelDecimal":7,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""},{"level":0,"levelDecimal":0,"designer":""}],"pack":"A000"},{"id":30,"name":"ネコ日和。","sortName":"ネコヒヨリ","artist":"Kai/能登有沙","genre":"maimai","bpm":171,"version":10000,"addVersion":"maimai","lockType":0,"disable":false,"notes":[{"level":6,"levelDecimal":0,"designer":"-"},{"level":7,"levelDecimal":9,"designer":"-"},{"level":8,"levelDecimal":8,"designer":"譜面-100号"},{"level":12,"levelDecimal":6,"designer":"ニャイン"},{"level":0,"levelDecimal":0,"designer":""}Line truncated
+330 -20
View File
@@ -6,9 +6,11 @@
<title>maipulse · 舞萌DX 发包台</title>
<style>
:root{
/* 浅色 Material(对齐手机端那套:白卡 + 灰底 + 深蓝主按钮) */
/* 浅色 Material(对齐手机端那套:白卡 + 灰底 + 主按钮)
主色可在设置页换,applyLook 会覆盖 --accent / --accent-soft */
--bg:#eef0f4; --panel:#ffffff; --line:#d9dce4; --text:#1b1c21;
--dim:#6b7280; --accent:#3f5f8f; --green:#1f9d63; --red:#c8382f; --amber:#a87510;
--dim:#6b7280; --accent:#d6336c; --accent-soft:#fdeef3;
--green:#1f9d63; --red:#c8382f; --amber:#a87510;
--soft:#f4f5f9;
/* 外观:卡片不透明度 / 背景虚化 / 背景压暗(设置页可调,存 localStorage) */
--alpha:1; --bgblur:0px; --bgdim:.12;
@@ -62,8 +64,10 @@
body.hasbg #bgwrap{display:block}
#bgwrap::after{content:'';position:absolute;inset:0;background:rgba(238,240,244,var(--bgdim))}
.card{background:rgba(255,255,255,var(--alpha));border:1px solid var(--line);border-radius:14px;padding:15px;
margin-bottom:12px;max-width:100%;min-width:0;
margin-bottom:12px;max-width:100%;min-width:0;position:relative;
-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}
/* 卡片带 backdrop-filter 会自成一层,下拉浮层要盖过下面的卡片就得把所在卡片抬上来 */
.card.front{z-index:70}
body.hasbg header{background:rgba(238,240,244,calc(var(--alpha) * .72));
-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}
.card h2{font-size:14px;margin:0 0 6px;font-weight:600}
@@ -106,8 +110,81 @@
.pitem{display:flex;gap:9px;align-items:center;padding:7px 10px;border-radius:7px;cursor:pointer;font-size:13px}
.pitem:hover{background:#f2f4f9}
.pitem input{width:auto;flex:none}
/* 主色选择:一排圆点,选中外面套一圈 */
.themes{display:flex;gap:11px;flex-wrap:wrap;margin:8px 0 4px}
.themes .tc{width:30px;height:30px;border-radius:50%;cursor:pointer;border:0;padding:0;
box-shadow:inset 0 0 0 1px rgba(0,0,0,.10);transition:transform .12s,box-shadow .12s;
position:relative}
.themes .tc:hover{transform:scale(1.08)}
.themes .tc.on{box-shadow:inset 0 0 0 1px rgba(0,0,0,.10),0 0 0 2px #fff,0 0 0 4px var(--accent)}
.themes .tc::after{content:attr(data-n);position:absolute;left:50%;bottom:-17px;
transform:translateX(-50%);font-size:11px;color:var(--dim);white-space:nowrap;pointer-events:none}
.themes{padding-bottom:18px}
/* 复选框统一成圆角小方块,替掉系统那个方框 */
input[type=checkbox]{-webkit-appearance:none;appearance:none;
width:18px;height:18px;min-width:18px;flex:none;margin:0;padding:0;
position:relative;box-sizing:border-box;vertical-align:middle;
border:1.5px solid #c6ceda;border-radius:6px;background:#fff;cursor:pointer;
transition:background .14s,border-color .14s,box-shadow .14s}
input[type=checkbox]:hover{border-color:var(--accent)}
input[type=checkbox]:checked{background:var(--accent);border-color:var(--accent)}
input[type=checkbox]:checked::after{content:'';position:absolute;left:50%;top:50%;
width:4px;height:8px;box-sizing:border-box;
border-right:2px solid #fff;border-bottom:2px solid #fff;
transform:translate(-55%,-58%) rotate(45deg)}
input[type=checkbox]:focus{outline:none}
input[type=checkbox]:focus-visible{box-shadow:0 0 0 3px rgba(63,95,143,.16)}
input[type=checkbox]:disabled{opacity:.45;cursor:default}
.pitem .pid{color:var(--dim);font-family:Consolas,monospace;min-width:60px;font-size:12px}
.pitem .pver{color:var(--dim);font-size:11px;margin-left:auto;flex:none}
#pfoot{padding:10px 14px;border-top:1px solid var(--line);display:flex;gap:8px;align-items:center}
/* 曲目选择器的版本分类条 */
#pgroups{display:none;gap:6px;padding:9px 14px;border-bottom:1px solid var(--line);
overflow-x:auto;white-space:nowrap;flex:none;
scrollbar-width:none;-ms-overflow-style:none}
#pgroups::-webkit-scrollbar{display:none;height:0}
#pgroups.on{display:flex}
#pgroups .g{flex:none;padding:4px 10px;border:1px solid var(--line);border-radius:99px;
font-size:12px;cursor:pointer;color:var(--dim);background:#fff;user-select:none}
#pgroups .g:hover{border-color:var(--accent);color:var(--accent)}
#pgroups .g.on{background:var(--accent);border-color:var(--accent);color:#fff}
#pgroups .g.sep{background:transparent;border:none;padding:4px 2px;color:var(--line);cursor:default}
#pgroups .g.sep:hover{border:none;color:var(--line)}
/* 自定义下拉:替掉原生 select 的系统样式,展开的浮层也走自己的配色 */
.ns{position:relative;width:100%}
.ns>select{position:absolute;left:0;top:0;width:100%;height:0;opacity:0;
pointer-events:none;border:0;padding:0}
.ns-btn{display:flex;align-items:center;gap:8px;box-sizing:border-box;
width:100%;height:40px;background:#fff;border:1px solid var(--line);border-radius:10px;
padding:0 12px;font-size:13px;line-height:1;color:var(--text);
cursor:pointer;user-select:none;-webkit-tap-highlight-color:transparent}
.ns-btn:hover{border-color:var(--accent)}
.ns-btn .txt{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ns-btn .arw{flex:none;width:0;height:0;border-left:4px solid transparent;
border-right:4px solid transparent;border-top:5px solid var(--dim);
transition:transform .15s}
.ns.open .ns-btn{border-color:var(--accent)}
.ns.open .ns-btn .arw{transform:rotate(180deg)}
.ns-panel{position:absolute;left:0;top:calc(100% + 5px);z-index:60;
display:none;background:#fff;border:1px solid var(--line);border-radius:12px;
padding:5px;max-height:290px;overflow:auto;
min-width:100%;width:max-content;max-width:min(330px,90vw);
box-shadow:0 10px 28px rgba(20,28,44,.16)}
.ns.open .ns-panel{display:block}
.ns-panel::-webkit-scrollbar{width:7px}
.ns-panel::-webkit-scrollbar-track{background:transparent}
.ns-panel::-webkit-scrollbar-thumb{background:rgba(140,150,170,.32);border-radius:4px}
.ns-panel::-webkit-scrollbar-thumb:hover{background:rgba(140,150,170,.5)}
.ns-opt{padding:9px 11px;border-radius:8px;font-size:13px;color:var(--text);
cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ns-opt:hover{background:#f0f4fb}
.ns-opt.on{background:var(--accent-soft);color:var(--accent);font-weight:600}
/* 和 select 并排的按钮:和下拉同高、底边对齐 */
.rowfoot{display:flex;flex-direction:column;justify-content:flex-end}
.rowfoot>button{margin:0;width:100%;box-sizing:border-box;height:40px;padding:0 16px;
font-size:13px;line-height:1;display:flex;align-items:center;justify-content:center}
/* B50 查分(游戏风卡片墙 + 海报) */
.b50top{display:flex;align-items:baseline;gap:10px;margin:8px 0 2px;flex-wrap:wrap}
@@ -252,7 +329,7 @@
background:currentColor;
-webkit-mask:var(--m) center/contain no-repeat;
mask:var(--m) center/contain no-repeat}
#tabs button.on{color:var(--accent);font-weight:700;background:#eef2f9}
#tabs button.on{color:var(--accent);font-weight:700;background:var(--accent-soft)}
/* 主页资料 */
.uhead{display:flex;gap:12px;align-items:center;background:#fff;border:1px solid var(--line);
@@ -266,7 +343,7 @@
padding:6px 14px 10px;margin-bottom:12px}
.info h3{font-size:13px;color:var(--accent);margin:12px 0 6px;font-weight:700}
.kv{display:flex;justify-content:space-between;gap:14px;padding:7px 0;font-size:13px;
border-bottom:1px solid #f0f2f6}
border-bottom:1px solid rgba(125,135,155,.13)}
.kv:last-child{border-bottom:0}
.kv b{font-weight:400;color:var(--dim);flex:none}
.kv span{text-align:right;word-break:break-all;font-family:Consolas,monospace}
@@ -310,9 +387,9 @@
#ticker.ok{background:rgba(24,110,72,.95)}
.lvtabs{display:flex;gap:6px;margin:10px 0 2px}
.lvtabs .ghost{flex:1;margin:0;padding:7px 0;font-size:12px}
.lvtabs .ghost.on{background:#eef2f9;color:var(--accent);border-color:var(--accent)}
.lvtabs .ghost.on{background:var(--accent-soft);color:var(--accent);border-color:var(--accent)}
.recorow .nm em.onb{font-style:normal;font-size:10px;margin-left:5px;padding:1px 5px;
border-radius:6px;background:#e8f0ff;color:var(--accent);vertical-align:1px}
border-radius:6px;background:var(--accent-soft);color:var(--accent);vertical-align:1px}
/* 设置页正上方的彩虹字 */
.rainbow-top{display:block;text-align:center;
margin:6px 0 14px;font-size:34px;font-weight:900;letter-spacing:.12em;
@@ -491,8 +568,18 @@
<section class="card" data-tab="risk">
<h2>解锁歌曲</h2>
<p class="desc">选中当场发;全量按每包 4 首分批换局,耗时长但稳。</p>
<button class="ghost" onclick="pick('music','songIds')">选择曲目</button>
<p class="desc">按版本分类挑,选中当场发;全曲解锁一次发完整个曲库。</p>
<div class="row">
<div>
<label>按版本快速选</label>
<select id="songGroup" onchange="pickSongGroup()">
<option value="">— 选择分类 —</option>
</select>
</div>
<div class="rowfoot">
<button class="ghost" onclick="pick('music','songIds')">手动挑选曲目</button>
</div>
</div>
<div id="songIds" class="picked"></div>
<div class="check">
<label><input id="allSongs" type="checkbox"> 全量解锁</label>
@@ -625,6 +712,8 @@
<input id="bgFile" type="file" accept="image/*" style="display:none" onchange="pickBg(this.files[0])">
</div>
<div id="bgState" class="fine" style="margin-top:8px">当前没有自定义背景。</div>
<label>主色</label>
<div id="themePick" class="themes"></div>
<label>卡片不透明度 <b id="alphaVal" style="color:var(--accent)">100%</b></label>
<input id="alphaRange" type="range" min="30" max="100" step="1" value="100"
oninput="onLookInput('alpha', this.value)">
@@ -706,10 +795,12 @@
<input id="psearch" placeholder="搜索名称或 id" style="max-width:240px" oninput="renderPicks()">
<button class="ghost" style="margin:0" onclick="closePicker()">关闭</button>
</header>
<div id="pgroups"></div>
<div id="plist"></div>
<div id="pfoot">
<span id="pcount" class="dim" style="font-size:12px"></span>
<span style="flex:1"></span>
<button class="ghost" style="margin:0;display:none" id="pall" onclick="addGroupAll()">本类全选</button>
<button class="ghost" style="margin:0" onclick="clearPicks()">清空</button>
<button style="margin:0" onclick="confirmPicks()">确定</button>
</div>
@@ -720,7 +811,7 @@
<script>
const $ = id => document.getElementById(id);
let cur = {userId:0, token:""};
let pickState = {cat:"", target:"", items:[], chosen:new Set(), kind:0};
let pickState = {cat:"", target:"", items:[], chosen:new Set(), kind:0, groups:[], group:'all'};
function log(msg, cls){
const el = $('log');
@@ -986,9 +1077,38 @@ async function delAccount(ref, name){
else ticker(r.error || '删除失败', 'err');
}
/* ---------- 外观:背景图 / 卡片不透明度 / 虚化 / 压暗 ---------- */
/* ---------- 外观:背景图 / 卡片不透明度 / 虚化 / 压暗 / 主色 ---------- */
const LOOK_KEY = 'maipulse.look.v1';
/* 主色板:accent 用足够深的色,保证白底彩字、彩底白字都看得清 */
const THEMES = [
{ k:'pink', n:'粉色', c:'#d6336c', s:'#fdeef3' },
{ k:'blue', n:'蓝色', c:'#3f5f8f', s:'#eef2f9' },
{ k:'green', n:'绿色', c:'#1a7d52', s:'#e9f5ee' },
{ k:'purple', n:'紫色', c:'#6b46c1', s:'#f1ecfb' },
{ k:'orange', n:'橙色', c:'#b05a15', s:'#fbf0e6' },
{ k:'teal', n:'青色', c:'#0f7576', s:'#e6f4f4' },
{ k:'wine', n:'酒红', c:'#a52a3a', s:'#fbebed' },
{ k:'graph', n:'墨黑', c:'#3a3f4b', s:'#eef0f3' },
];
function themeOf(k){ return THEMES.find(t => t.k === k) || THEMES[0]; }
function renderThemes(cur){
const box = $('themePick');
if(!box) return;
cur = cur || (readLook().theme || THEMES[0].k);
box.innerHTML = THEMES.map(t =>
`<button type="button" class="tc${t.k === cur ? ' on' : ''}" data-k="${t.k}"
data-n="${t.n}" title="${t.n}"
style="background:${t.c}" onclick="pickTheme('${t.k}')"></button>`).join('');
}
function pickTheme(k){
const o = readLook();
o.theme = k;
saveLook(o);
applyLook(o);
renderThemes(k);
}
function readLook(){
try { return JSON.parse(localStorage.getItem(LOOK_KEY) || '{}') || {}; }
catch(e){ return {}; }
@@ -1006,6 +1126,10 @@ function applyLook(o){
root.setProperty('--alpha', String(alpha));
root.setProperty('--bgblur', blur + 'px');
root.setProperty('--bgdim', String(dim));
const th = themeOf(o.theme);
root.setProperty('--accent', th.c);
root.setProperty('--accent-soft', th.s);
renderThemes(th.k);
const wrap = $('bgwrap');
if(o.bg){
wrap.style.backgroundImage = 'url("' + o.bg + '")';
@@ -1072,7 +1196,7 @@ function onLookInput(field, value){
}
function resetLook(){
const o = readLook();
delete o.alpha; delete o.blur; delete o.dim; delete o.bg;
delete o.alpha; delete o.blur; delete o.dim; delete o.bg; delete o.theme;
saveLook(o);
applyLook(o);
ticker('外观已恢复默认', 'ok');
@@ -1689,33 +1813,103 @@ async function loadState(){
}
/* ---------- picker ---------- */
const CAT_NAME = {music:'曲目', title:'称号', icon:'头像', plate:'名牌', frame:'边框', chara:'角色', partner:'搭档', ticket:'功能票'};
async function pick(cat, target){
const r = await get('/api/master?cat='+cat);
if(!r.ok) return log('载入失败:'+r.error, 'err');
pickState = {cat, target, items:r.items, chosen:new Set(), kind:r.kind};
$('ptitle').textContent = `${cat} · 共 ${r.count} 项`;
// groups 只有曲目接口会返回,收藏品那一路保持原样
const groups = cat === 'music' ? (r.groups || []) : [];
pickState = {cat, target, items:r.items, chosen:new Set(), kind:r.kind,
groups, group:'all'};
$('ptitle').textContent = `${CAT_NAME[cat]||cat} · 共 ${r.count} 项`;
$('psearch').value = '';
renderGroups();
$('mask').classList.add('on');
renderPicks();
}
/* 曲目选择器的分类条:全曲 / 各版本 / 当前版本 / 各差分包 / 无归属 */
function renderGroups(){
const box = $('pgroups');
const all = $('pall');
if(!pickState.groups.length){
box.classList.remove('on'); box.innerHTML = '';
if(all) all.style.display = 'none';
return;
}
box.classList.add('on');
if(all) all.style.display = '';
box.innerHTML = pickState.groups.map(g => {
const on = pickState.group === g.key ? ' on' : '';
return `<span class="g${on}" data-k="${g.key}" onclick="selectGroup('${g.key}')">`
+ `${g.label} <b>${g.count}</b></span>`;
}).join('');
}
function selectGroup(key){
pickState.group = key;
renderGroups();
$('plist').scrollTop = 0;
renderPicks();
}
function groupIds(key){
const g = pickState.groups.find(x => x.key === key);
return g ? g.ids : null;
}
/* 把当前分类的曲目全部勾上(再点一次取消这一批) */
function addGroupAll(){
const ids = groupIds(pickState.group);
if(!ids) return;
const allIn = ids.every(i => pickState.chosen.has(i));
if(allIn) ids.forEach(i => pickState.chosen.delete(i));
else ids.forEach(i => pickState.chosen.add(i));
// 只同步已渲染的行,不重画列表(重画会把滚动位置顶回顶部)
document.querySelectorAll('#plist input[type=checkbox]').forEach(c => {
c.checked = pickState.chosen.has(parseInt(c.dataset.id, 10));
});
paintCount();
}
function paintCount(){
const gname = pickState.groups.length
? (pickState.groups.find(g => g.key === pickState.group)||{}).label : '';
const n = pickState.matched || 0;
$('pcount').textContent = `已选 ${pickState.chosen.size}`
+ (gname ? ` / ${gname} ${n}` : ` / 匹配 ${n}`);
}
function renderPicks(){
const kw = $('psearch').value.trim().toLowerCase();
const list = $('plist');
const src = pickState.items.filter(x => !kw ||
String(x.id).includes(kw) || (x.name||'').toLowerCase().includes(kw));
const gids = pickState.groups.length ? groupIds(pickState.group) : null;
const inGroup = gids ? new Set(gids) : null;
const src = pickState.items.filter(x => {
if(inGroup && !inGroup.has(x.id)) return false;
return !kw || String(x.id).includes(kw) || (x.name||'').toLowerCase().includes(kw);
});
const show = src.slice(0, 300);
const withVer = pickState.groups.length > 0;
pickState.matched = src.length;
list.innerHTML = show.map(x => `
<label class="pitem">
<input type="checkbox" ${pickState.chosen.has(x.id)?'checked':''}
<input type="checkbox" data-id="${x.id}" ${pickState.chosen.has(x.id)?'checked':''}
onchange="togglePick(${x.id}, this.checked)">
<span class="pid">${x.id}</span>
<span>${(x.name||'').replace(/</g,'&lt;')}</span>
${withVer ? `<span class="pver">${(x.addVersion||'').replace(/</g,'&lt;')}</span>` : ''}
</label>`).join('') + (src.length > 300
? `<div class="dim" style="padding:10px;font-size:12px">还有 ${src.length-300} 项,输入关键词缩小范围</div>` : '');
$('pcount').textContent = `已选 ${pickState.chosen.size} / 匹配 ${src.length}`;
paintCount();
}
/* 单个勾选只改计数,不重画列表——否则每点一下滚动位置就跳回顶部 */
function togglePick(id, on){
on ? pickState.chosen.add(id) : pickState.chosen.delete(id);
paintCount();
}
function clearPicks(){
pickState.chosen.clear();
document.querySelectorAll('#plist input[type=checkbox]').forEach(c => { c.checked = false; });
paintCount();
}
function togglePick(id, on){ on ? pickState.chosen.add(id) : pickState.chosen.delete(id); $('pcount').textContent = `已选 ${pickState.chosen.size}`; }
function clearPicks(){ pickState.chosen.clear(); renderPicks(); }
function closePicker(){ $('mask').classList.remove('on'); }
function confirmPicks(){
const ids = [...pickState.chosen];
@@ -1737,6 +1931,120 @@ function pickedIds(target){
}
function pickedKind(target){ return parseInt($(target).dataset.kind || '0', 10); }
/* ---------- 自定义下拉:替掉原生 select,避免系统浮层跟页面配色打架 ---------- */
function closeAllNs(except){
document.querySelectorAll('.ns.open').forEach(w => {
if(w !== except){
w.classList.remove('open');
const c = w.closest('.card');
if(c) c.classList.remove('front');
}
});
}
function nsUpgrade(sel){
if(!sel || sel.dataset.ns) return;
sel.dataset.ns = '1';
const wrap = document.createElement('div');
wrap.className = 'ns';
sel.parentNode.insertBefore(wrap, sel);
wrap.appendChild(sel);
const btn = document.createElement('div');
btn.className = 'ns-btn';
btn.innerHTML = '<span class="txt"></span><i class="arw"></i>';
const panel = document.createElement('div');
panel.className = 'ns-panel';
wrap.appendChild(btn);
wrap.appendChild(panel);
wrap._sync = () => {
const i = Math.max(0, sel.selectedIndex);
const cur = sel.options[i];
btn.querySelector('.txt').textContent = cur ? cur.textContent : '';
panel.innerHTML = [...sel.options].map((o, k) =>
`<div class="ns-opt${k === i ? ' on' : ''}" data-k="${k}">${o.textContent}</div>`).join('');
};
btn.addEventListener('click', e => {
e.stopPropagation();
const willOpen = !wrap.classList.contains('open');
closeAllNs(wrap);
wrap.classList.toggle('open', willOpen);
const card = wrap.closest('.card');
if(card) card.classList.toggle('front', willOpen);
if(willOpen){
const on = panel.querySelector('.ns-opt.on');
if(on) panel.scrollTop = Math.max(0, on.offsetTop - panel.clientHeight / 2);
}
});
panel.addEventListener('click', e => {
const t = e.target.closest('.ns-opt');
if(!t) return;
const k = parseInt(t.dataset.k, 10);
if(sel.selectedIndex !== k){
sel.selectedIndex = k;
sel.dispatchEvent(new Event('change', { bubbles:true }));
}
wrap.classList.remove('open');
const c = wrap.closest('.card');
if(c) c.classList.remove('front');
wrap._sync();
});
wrap._sync();
}
/* 选项被 JS 改过之后调一次,把显示同步回去 */
function nsSync(id){
const s = $(id);
if(s && s.parentNode && typeof s.parentNode._sync === 'function') s.parentNode._sync();
}
function nsAll(){ document.querySelectorAll('select').forEach(nsUpgrade); }
document.addEventListener('click', () => closeAllNs(null));
/* 面板上的版本下拉:只拉分类清单,不背 1343 条曲目 */
let songGroupsCache = null;
async function loadSongGroups(){
const sel = $('songGroup');
if(!sel) return;
try {
const r = await get('/api/master?cat=music&brief=1');
if(!r.ok || !r.groups) return;
songGroupsCache = r;
sel.innerHTML = '<option value="">— 选择分类 —</option>'
+ r.groups.map(g => `<option value="${g.key}">${g.label}(${g.count})</option>`).join('');
sel.value = '';
nsSync('songGroup');
} catch(e){}
}
async function pickSongGroup(){
const key = $('songGroup').value;
const el = $('songIds');
if(!key){
el.dataset.ids = '[]';
el.textContent = '(未选择)';
return;
}
if(!songGroupsCache){
const r = await get('/api/master?cat=music&brief=1');
if(!r.ok) return log('载入分类失败:'+r.error, 'err');
songGroupsCache = r;
}
const g = (songGroupsCache.groups || []).find(x => x.key === key);
if(!g) return log('分类不存在:'+key, 'err');
if(g.key === 'all'){
// 全曲走「全量解锁」那条路:一包发完,失败还能自动降级重试
$('allSongs').checked = true;
el.dataset.ids = '[]';
el.textContent = `[全曲解锁] 已勾选全量,共 ${g.count} 首(发送时一并带上 MASTER/Re:MASTER)`;
log(`按分类选中:全曲解锁 · ${g.count} 首(已勾上「全量解锁」)`);
return;
}
$('allSongs').checked = false;
el.dataset.ids = JSON.stringify(g.ids);
el.dataset.kind = 5;
el.dataset.cat = 'music';
el.textContent = `[${g.label}] 已选 ${g.ids.length} 首`;
log(`按分类选中:${g.label} · ${g.ids.length} 首`);
}
/* ---------- 功能票 ---------- */
async function loadCharge(){
const r = await get('/api/charge');
@@ -1918,6 +2226,8 @@ log('就绪。先扫码 → 登录 → 选功能。', 'dim');
loadVersion();
confSummary();
loadAccounts();
nsAll();
loadSongGroups();
// URL 带 ?uid= 时自动查这个账号的 B50 与推分(方便直接看别人的分)
const uidParam = new URLSearchParams(location.search).get('uid');
if(uidParam && $('b50uid')){
+28 -3
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import io
import json
import os
import re
import shutil
import sys
import time
@@ -99,7 +100,19 @@ def parse_title(path: str) -> dict:
}
def collect(pkg: str, sub: str, filename: str, parser) -> list:
def pack_of(pkg: str) -> str:
"""从包目录名取出包号:SDGB_A041_20261020134418_0 -> A041。
目录名里没有 A0xx 时回退取全部数字段里第一个 3 位纯数字,
再不行返回空串(这条就归到「无归属」)。
"""
for part in re.split(r"[\\/_\-\s]+", os.path.basename(os.path.normpath(pkg))):
if re.fullmatch(r"A\d{3}", part):
return part
return ""
def collect(pkg: str, sub: str, filename: str, parser, pack: str = "") -> list:
out = []
base = os.path.join(pkg, sub)
if not os.path.isdir(base):
@@ -113,6 +126,8 @@ def collect(pkg: str, sub: str, filename: str, parser) -> list:
print(" 跳过 %s:%s" % (p, exc))
continue
if item.get("id"):
if pack:
item["pack"] = pack
out.append(item)
return out
@@ -133,6 +148,13 @@ def merge(old: list, new: list):
cur = old[i]
dirty = False
for k, v in item.items():
if k == "pack":
# 包归属记首次出现的那个包:已有值不覆盖,
# 空值(早先水鱼补的曲)才由包补上
if v and not cur.get("pack"):
cur["pack"] = v
dirty = True
continue
if k == "notes":
# 谱面:包里启用的档位比旧的更权威
if any(n["level"] or n["levelDecimal"] for n in v):
@@ -214,8 +236,11 @@ def main():
music_old = json.load(io.open(music_path, encoding="utf-8"))
title_old = json.load(io.open(title_path, encoding="utf-8"))
pkg_music = collect(pkg, "music", "Music.xml", parse_music)
pkg_title = collect(pkg, "title", "Title.xml", parse_title)
pkg_pack = pack_of(pkg)
pkg_music = collect(pkg, "music", "Music.xml", parse_music, pkg_pack)
pkg_title = collect(pkg, "title", "Title.xml", parse_title, pkg_pack)
print("包号:%s%s" % (pkg_pack or "(未识别)",
"" if pkg_pack else ",曲目会归到「无归属」"))
print("包里:曲目 %d 首 / 称号 %d 个" % (len(pkg_music), len(pkg_title)))
music_new, m_add, m_upd = merge(music_old, pkg_music)
+148
View File
@@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
"""backfill_pack.py —— 给曲目回填「差分包归属」字段。
扫描 StreamingAssets 下的 A000 / A005 / A011 ... 各包的 music 目录,
得到「曲目 id -> 首次出现的包号」,写进:
data/master/music.json 每条记录加 pack 字段
data/master.json music.items 同步(/api/master 读的是这个)
归属规则:A000 之外的包优先(大包号覆盖小包号),剩下的归 A000,
哪个包目录都没有的记空串。
用法:
python tools/backfill_pack.py
python tools/backfill_pack.py --src "D:\\bs\\Package\\Sinmai_Data\\StreamingAssets" --dry
"""
from __future__ import annotations
import argparse
import io
import json
import os
import re
import sys
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join(BASE_DIR, "data")
MASTER_DIR = os.path.join(DATA_DIR, "master")
MASTER_INDEX = os.path.join(DATA_DIR, "master.json")
DEFAULT_SRC = r"D:\bs\Package\Sinmai_Data\StreamingAssets"
def read_json(path):
with io.open(path, encoding="utf-8") as fp:
return json.load(fp)
def write_json(path, obj):
"""无 BOM UTF-8,缩进 1 空格保持体积。"""
tmp = path + ".tmp"
with io.open(tmp, "w", encoding="utf-8", newline="\n") as fp:
json.dump(obj, fp, ensure_ascii=False, separators=(",", ":"))
os.replace(tmp, path)
def scan_packs(src):
"""返回 (pack 列表, {pack: set(id)}),A000 排第一,其余按包号升序。"""
if not os.path.isdir(src):
raise SystemExit("找不到 StreamingAssets 目录:" + src)
packs = sorted([d for d in os.listdir(src) if re.fullmatch(r"A\d{3}", d)],
key=lambda x: (x != "A000", x))
out = {}
for p in packs:
d = os.path.join(src, p, "music")
if not os.path.isdir(d):
continue
ids = set()
for name in os.listdir(d):
if not os.path.isdir(os.path.join(d, name)):
continue
m = re.search(r"(\d+)$", name)
if m:
ids.add(int(m.group(1)))
if ids:
out[p] = ids
return packs, out
def owner_map(packs, pack_ids):
"""id -> 包号。A000 之外的包按顺序覆盖,没有目录的留空。"""
owner = {}
for p in packs:
if p == "A000":
continue
for i in pack_ids.get(p, ()):
owner[i] = p
for i in pack_ids.get("A000", ()):
owner.setdefault(i, "A000")
return owner
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--src", default=DEFAULT_SRC)
ap.add_argument("--dry", action="store_true", help="只打印统计,不写盘")
args = ap.parse_args(argv)
packs, pack_ids = scan_packs(args.src)
print("扫到包:", ", ".join(packs))
for p in packs:
if p in pack_ids:
print(" %-6s %5d 首" % (p, len(pack_ids[p])))
owner = owner_map(packs, pack_ids)
# ---- master/music.json
mpath = os.path.join(MASTER_DIR, "music.json")
music = read_json(mpath)
from collections import Counter
stat = Counter()
changed = 0
for row in music:
p = owner.get(int(row["id"]), "")
if row.get("pack") != p:
changed += 1
row["pack"] = p
stat[p or "(无归属)"] += 1
order = [p for p in packs if p in pack_ids] + [""]
print("\n=== 回填结果:%d 首,变更 %d 条 ===" % (len(music), changed))
for p in order:
key = p or "(无归属)"
if stat.get(key):
print(" %-8s %5d" % (key, stat[key]))
total = sum(stat.values())
print(" 合计 %5d" % total)
if args.dry:
print("\n--dry,未写盘")
return 0
write_json(mpath, music)
print("\n已写", mpath, os.path.getsize(mpath), "字节")
# ---- master.json 里的 music 镜像
idx = read_json(MASTER_INDEX)
node = idx.get("music")
if isinstance(node, dict) and isinstance(node.get("items"), list):
pmap = {int(r["id"]): r.get("pack", "") for r in music}
n = 0
for row in node["items"]:
p = pmap.get(int(row["id"]), "")
if row.get("pack") != p:
n += 1
row["pack"] = p
node["count"] = len(node["items"])
write_json(MASTER_INDEX, idx)
print("已写 %s %d 字节(music %d 条,变更 %d)"
% (MASTER_INDEX, os.path.getsize(MASTER_INDEX), len(node["items"]), n))
else:
print("master.json 里没有 music.items,跳过")
return 0
if __name__ == "__main__":
sys.exit(main())
+79
View File
@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
"""公开仓推之前的路径脱敏。
源码里带着开发机的绝对路径(C:\\Users\\xxx、D:\\DSH\\...),
这些东西推到公开仓库等于把自己的目录结构、用户名、SDK 位置全挂出去。
本地源码保持原样(能直接跑),只在同步进公开仓的那份副本上做替换。
用法:
python tools/blank_paths.py <公开仓里的 astral/maipulse/android 根目录>
python tools/blank_paths.py D:\\DSH\\maimai\\_repo --dry
"""
from __future__ import annotations
import io
import os
import re
import sys
# (匹配模式, 替换成) —— 顺序有讲究:长的先替换,否则 \maimai 会先被短规则吃掉
RULES = [
(re.compile(r'C:[\\/]Users[\\/]20558[\\/]AppData[\\/]Local[\\/]Programs[\\/]Python[\\/]Python38[\\/]python\.exe'),
'python'),
(re.compile(r'C:[\\/]Users[\\/]20558'), '%USERPROFILE%'),
(re.compile(r'D:[\\/]DSH[\\/]_androidsdk'), '%ANDROID_HOME%'),
(re.compile(r'D:[\\/]DSH[\\/]maimai[\\/]maipulse-android'), '<android 工程>'),
(re.compile(r'D:[\\/]DSH[\\/]maimai[\\/]maipulse'), '<maipulse>'),
(re.compile(r'D:[\\/]DSH[\\/]maimai[\\/]Astral'), '<astral>'),
(re.compile(r'D:[\\/]DSH[\\/]maimai[\\/]_repo'), '<仓库>'),
(re.compile(r'D:[\\/]DSH[\\/]maimai'), '<项目根>'),
(re.compile(r'qmy2055885931@gmail\.com'), '[email protected]'),
]
TEXT_EXT = ('.py', '.ps1', '.gradle', '.xml', '.md', '.json', '.properties',
'.java', '.txt', '.yml', '.yaml', '.gitignore', '.pro')
SKIP_DIRS = {'.git', '__pycache__', 'data', 'logs'}
def process(root, dry=False):
changed = []
for dp, dn, fn in os.walk(root):
dn[:] = [d for d in dn if d not in SKIP_DIRS]
for f in fn:
if not f.endswith(TEXT_EXT) and f not in ('.gitignore', 'gradlew'):
continue
fp = os.path.join(dp, f)
if os.path.getsize(fp) > 3_000_000:
continue
try:
t = io.open(fp, encoding='utf-8').read()
except (UnicodeDecodeError, OSError):
continue
orig = t
for rx, rep in RULES:
t = rx.sub(rep, t)
if t != orig:
changed.append(os.path.relpath(fp, root))
if not dry:
io.open(fp, 'w', encoding='utf-8', newline='\n').write(t)
return changed
def main():
if len(sys.argv) < 2:
raise SystemExit(__doc__)
root = sys.argv[1]
dry = '--dry' in sys.argv
if not os.path.isdir(root):
raise SystemExit('不是目录:%s' % root)
changed = process(root, dry)
print('%s %d 个文件%s' % ('会改' if dry else '已改', len(changed), '(--dry 未落盘)' if dry else ''))
for c in changed[:40]:
print(' ', c)
if len(changed) > 40:
print(' ... 还有 %d 个' % (len(changed) - 40))
return 0
if __name__ == '__main__':
sys.exit(main())
+1 -1
View File
@@ -22,7 +22,7 @@ import re
import shutil
import sys
SRC = r"D:\DSH\maimai\Astral"
SRC = r"<astral>"
HEADER = "# -*- coding: utf-8 -*-\n"
+95 -36
View File
@@ -1,25 +1,30 @@
# maipulse APK 打包:同步资源 -> Gradle -> 16K 页对齐 -> v1/v2/v3 签名 -> 放桌面
# 用法:powershell -NoProfile -ExecutionPolicy Bypass -File D:\DSH\maimai\maipulse\tools\build_apk.ps1
# maipulse APK 打包:自检 -> 同步资源(剔掉跑不到的东西)-> Gradle release -> 16K 对齐 -> 签名 -> 放桌面
# 用法:powershell -NoProfile -ExecutionPolicy Bypass -File <maipulse>\tools\build_apk.ps1
# 加 -Debug 出 debug 包(可 chrome://inspect,仅本地排障用)
param(
[switch]$Debug
)
$ErrorActionPreference = 'Stop'
$proj = 'D:\DSH\maimai\maipulse-android'
$app = 'D:\DSH\maimai\maipulse'
$bt = 'D:\DSH\_androidsdk\build-tools\36.0.0'
$ks = 'D:\DSH\maimai\maipulse-android\maipulse-debug.keystore'
$dest = 'C:\Users\20558\Desktop\maipulse-debug.apk'
$proj = '<android 工程>'
$app = '<maipulse>'
$bt = '%ANDROID_HOME%\build-tools\36.0.0'
$ks = '<android 工程>\maipulse-debug.keystore'
$dest = '%USERPROFILE%\Desktop\maipulse-debug.apk'
$assets = "$proj\app\src\main\assets\app"
$tmpDir = 'D:\DSH\maimai\_buildtmp'
$tmpDir = '<项目根>\_buildtmp'
$aligned = "$tmpDir\maipulse-aligned.apk"
$signed = "$proj\app\build\outputs\apk\debug\app-debug-aligned.apk"
$raw = "$proj\app\build\outputs\apk\debug\app-debug.apk"
$varName = if ($Debug) { 'debug' } else { 'release' }
$signed = "$proj\app\build\outputs\apk\$varName\app-$varName-aligned.apk"
$raw = "$proj\app\build\outputs\apk\$varName\app-$varName.apk"
New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null
$env:JAVA_HOME = 'C:\Program Files\BellSoft\LibericaJDK-21\'
$env:ANDROID_HOME = 'D:\DSH\_androidsdk'
$env:ANDROID_SDK_ROOT = 'D:\DSH\_androidsdk'
$env:ANDROID_HOME = '%ANDROID_HOME%'
$env:ANDROID_SDK_ROOT = '%ANDROID_HOME%'
# 0) 先清干净:旧产物、旧的 assets 副本一律删掉再全量重建,
# 0) 清干净:旧产物、旧的 assets 副本一律删掉再全量重建,
# 避免 robocopy 只增不删导致删过的文件还残留在包里
if (Test-Path "$proj\app\build\outputs\apk") {
Remove-Item "$proj\app\build\outputs\apk" -Recurse -Force
@@ -28,66 +33,120 @@ if (Test-Path $assets) {
Remove-Item $assets -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $assets | Out-Null
Write-Host '已清空旧产物与 assets 副本'
Write-Host '[0/6] 已清空旧产物与 assets 副本'
# 1) 同步代码与前端
Copy-Item "$app\app.py" "$assets\app.py" -Force
robocopy "$app\core" "$assets\core" /E /NJH /NJS /NDL /NC /NS /XD __pycache__ | Out-Null
robocopy "$app\html" "$assets\html" /E /NJH /NJS /NDL /NC /NS /XD __pycache__ | Out-Null
# astral 从主仓复制,排掉用户快照、日志与真实配置
robocopy 'D:\DSH\maimai\Astral' "$assets\astral" /E /NJH /NJS /NDL /NC /NS `
/XD __pycache__ data logs /XF "selftest.py" "config.py" "*.json" | Out-Null
# 删掉开发探针 _xxx.py —— 注意 __init__.py 也是下划线开头,必须留下,否则包没有入口
# astral:只要协议实现本体(api/),其余全部剔掉
# gamehook = C# 抓包工具的源码/dll/pdb,跑不到
# icon = 514KB 的源项目图标,跑不到
# tools = 成绩图表生成脚本,跑不到
# data/logs = 用户快照与日志,不能带
# *.md/.cs/.dll/.pdb/.example 之类都是开发期物料
robocopy '<astral>' "$assets\astral" /E /NJH /NJS /NDL /NC /NS `
/XD __pycache__ data logs gamehook icon tools .git `
/XF "selftest.py" "config.py" "*.json" "*.md" "*.cs" "*.dll" "*.pdb" "*.txt" "*.log" `
"*.csproj" "*.props" "*.targets" "*.editorconfig" "*.cache" "*.example" `
".gitignore" ".gitattributes" | Out-Null
# 开发探针 _xxx.py —— 注意 __init__.py 也是下划线开头,必须留下,否则包没有入口
Get-ChildItem "$assets\astral" -Recurse -File -Filter "_*.py" |
Where-Object { $_.Name -ne '__init__.py' } |
Remove-Item -Force
# 复查:包的 __init__.py 一个都不能少
$must = @("$assets\astral\api\__init__.py", "$assets\core\__init__.py")
foreach ($m in $must) {
if (-not (Test-Path $m)) { throw "缺包入口:$m" }
# astral 根目录下这 7 个是独立 CLI 脚本:app.py / core / api 全不 import,删掉省 110KB
$deadScripts = @('authv1', 'buy_ticket', 'netdelivery', 'reincarnate',
'unlockmusic', 'upsert', 'use_ticket')
foreach ($d in $deadScripts) {
Remove-Item "$assets\astral\$d.py" -Force -ErrorAction SilentlyContinue
}
# 2) 曲库与道具索引(缺了会报 master.json 不存在)
New-Item -ItemType Directory -Force -Path "$assets\data" | Out-Null
Copy-Item "$app\data\master.json" "$assets\data\master.json" -Force
robocopy "$app\data\master" "$assets\data\master" /E /NJH /NJS /NDL /NC /NS | Out-Null
# 2b) 曲库更新用的抓取脚本(更新曲库按钮会 import fetch_df)
New-Item -ItemType Directory -Force -Path "$assets\tools" | Out-Null
Copy-Item "$app\tools\fetch_df.py" "$assets\tools\fetch_df.py" -Force
if (Test-Path "$app\data\icons") {
robocopy "$app\data\icons" "$assets\data\icons" /E /NJH /NJS /NDL /NC /NS | Out-Null
}
# 2b) 曲库更新用的抓取脚本(更新曲库按钮会 import fetch_df)
New-Item -ItemType Directory -Force -Path "$assets\tools" | Out-Null
Copy-Item "$app\tools\fetch_df.py" "$assets\tools\fetch_df.py" -Force
Write-Host '[1/6] 代码与资源已同步(已剔除 gamehook / 图标 / 图表工具 / 独立脚本 / 文档)'
# 2c) 写入资源版本号:内容随每次打包变化,手机上靠它判断要不要重建 filesDir/app
$rev = Get-Date -Format 'yyyyMMddHHmmss'
Set-Content -Path "$assets\asset.rev" -Value $rev -Encoding ASCII -NoNewline
Write-Host "asset.rev = $rev"
# 3) 脱敏:抹掉真实机台身份(BASE_URL / AES / 混淆盐 / 店铺名),config.py 换成空模板
& 'C:\Users\20558\AppData\Local\Programs\Python\Python313\python.exe' "$app\tools\blank_secrets.py" "$assets\astral"
if ($LASTEXITCODE -ne 0) { throw '脱敏失败,停止打包' }
Write-Host " asset.rev = $rev"
# 4) 编译
# 3) 脱敏:抹掉真实机台身份(BASE_URL / AES / 混淆盐 / 店铺名),config.py 换成空模板
& '%USERPROFILE%\AppData\Local\Programs\Python\Python313\python.exe' "$app\tools\blank_secrets.py" "$assets\astral"
if ($LASTEXITCODE -ne 0) { throw '脱敏失败,停止打包' }
Write-Host '[2/6] 脱敏完成'
# 4) 打包前自检:少一个关键文件手机上就起不来,宁可在这里炸
$must = @(
"$assets\app.py",
"$assets\asset.rev",
"$assets\core\__init__.py",
"$assets\astral\config.py",
"$assets\astral\api\__init__.py",
"$assets\astral\api\MaimaiNet.py",
"$assets\astral\api\Chime.py",
"$assets\data\master.json",
"$assets\data\master\music.json",
"$assets\html\index.html",
"$assets\tools\fetch_df.py"
)
$missing = $must | Where-Object { -not (Test-Path $_) }
if ($missing) { throw ("自检失败,缺文件:`n " + ($missing -join "`n ")) }
# 关键模块里不该再出现任何真实机台身份
$leak = Select-String -Path "$assets\astral\config.py", "$assets\astral\api\MaimaiNet.py" `
-Pattern 'CLIENT_ID\s*=\s*"[0-9A-F]{6,}"|BASE_URL\s*=\s*"https?://[^"]+"' -ErrorAction SilentlyContinue
if ($leak) { throw ("脱敏不干净,还留着真实身份:`n" + ($leak | ForEach-Object { $_.Line.Trim() }) -join "`n") }
$pyCount = (Get-ChildItem "$assets" -Recurse -Filter *.py).Count
$totalMB = [math]::Round(((Get-ChildItem $assets -Recurse -File | Measure-Object Length -Sum).Sum) / 1MB, 2)
Write-Host "[3/6] 自检通过:$pyCount 个 .py,资源总计 ${totalMB}MB"
# 5) 编译
# 注意:javac 会把「使用了过时的 API」这类提示写到 stderr,
# PowerShell 遇到外部命令的 stderr 且 ErrorActionPreference=Stop 时会直接终止脚本。
# 所以整段输出重定向进日志文件,PowerShell 只读文件、只看退出码。
Push-Location $proj
cmd /c "gradlew.bat assembleDebug --no-daemon" 2>&1 |
Select-String -Pattern 'BUILD|error:|错误|FAILED' | Select-Object -Last 4
$task = if ($Debug) { 'assembleDebug' } else { 'assembleRelease' }
$gradleLog = "$tmpDir\gradle-$varName.log"
cmd /c "gradlew.bat $task --no-daemon > `"$gradleLog`" 2>&1"
$gradleCode = $LASTEXITCODE
Pop-Location
if (-not (Test-Path $raw)) { throw "没编出 APK: $raw" }
Get-Content $gradleLog -Encoding utf8 -ErrorAction SilentlyContinue |
Select-String -Pattern 'BUILD SUCCESSFUL|BUILD FAILED|FAILURE:|error:' |
Select-Object -Last 6 | ForEach-Object { ' ' + $_.Line.Trim() }
# 5) 16KB 页对齐(Android 15+ / 17 正式签名强制)
if ($gradleCode -ne 0 -or -not (Test-Path $raw)) {
throw "Gradle 失败(退出码 $gradleCode),完整日志:$gradleLog"
}
Write-Host "[4/6] 编译完成:$raw"
# 6) 16KB 页对齐(Android 15+ / 17 正式签名强制)
if (Test-Path $aligned) { Remove-Item $aligned -Force }
& "$bt\zipalign.exe" -P 16 -f 4 $raw $aligned | Out-Null
& "$bt\zipalign.exe" -c -P 16 4 $aligned | Out-Null
if ($LASTEXITCODE -ne 0) { throw '16K 对齐校验失败' }
# 6) 重新签名(zipalign 会让原签名失效)
# 7) 重新签名(zipalign 会让原签名失效)
if (Test-Path $signed) { Remove-Item $signed -Force }
& "$bt\apksigner.bat" sign --ks $ks --ks-pass pass:android --key-pass pass:android `
--min-sdk-version 24 --v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true `
--out $signed $aligned | Out-Null
& "$bt\apksigner.bat" verify $signed | Out-Null
if ($LASTEXITCODE -ne 0) { throw '签名校验失败' }
Write-Host '[5/6] 16K 对齐 + 签名校验通过'
Copy-Item $signed $dest -Force
$mb = [math]::Round((Get-Item $dest).Length / 1MB, 2)
Write-Host "OK: $dest ${mb}MB (16K aligned + signed)"
Write-Host "[6/6] OK: $dest ${mb}MB ($varName, 16K aligned + signed)"
+189
View File
@@ -0,0 +1,189 @@
# -*- coding: utf-8 -*-
"""
collect_masterdata.py —— 从国服客户端 StreamingAssets 抽取 masterdata 索引表
================================================================================
扫描 A000(基础)与 A005/A006/.../A041(增量),按目录类型解析 XML,
输出 data/master/<kind>.json,供发包程序的下拉框 / 全选使用。
用法:
python tools/collect_masterdata.py --src "D:\\sdgb\\Sinmai_Data\\StreamingAssets"
产物(每条都带 id + name):
music.json id / title / artist / genre / version / bpm / difficulties[]
plate.json id / name / genre / release / lock
title.json id / name / genre / release / lock
icon.json id / name / genre / release / lock
frame.json id / name / genre / release / lock
partner.json id / name / genre / release / lock
chara.json id / name / genre / release / lock
ticket.json id / name / detail / areaPercent / charaMagnification / isMoney
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import xml.etree.ElementTree as ET
KIND_SPEC = {
"music": ("music", "MusicData"),
"plate": ("plate", "PlateData"),
"title": ("title", "TitleData"),
"icon": ("icon", "IconData"),
"frame": ("frame", "FrameData"),
"partner": ("partner", "PartnerData"),
"chara": ("chara", "CharaData"),
"ticket": ("ticket", "TicketData"),
}
DIFF_NAMES = ["Basic", "Advanced", "Expert", "Master", "Re:Master"]
def text(node, path, default=""):
if node is None:
return default
el = node.find(path)
if el is None or el.text is None:
return default
return el.text.strip()
def int_of(node, path, default=0):
raw = text(node, path, "")
try:
return int(raw)
except (TypeError, ValueError):
return default
def parse_music(root):
notes = []
nd = root.find("notesData")
if nd is not None:
for i, n in enumerate(nd.findall("Notes")):
notes.append({
"level": int_of(n, "level"),
"levelDecimal": int_of(n, "levelDecimal"),
"designer": text(n, "notesDesigner/str"),
})
return {
"id": int_of(root, "name/id"),
"name": text(root, "name/str"),
"sortName": text(root, "sortName"),
"artist": text(root, "artistName/str"),
"genre": text(root, "genreName/str"),
"bpm": int_of(root, "bpm"),
"version": int_of(root, "version"),
"addVersion": text(root, "AddVersion/str"),
"lockType": int_of(root, "lockType"),
"disable": text(root, "disable") == "true",
"notes": notes,
}
def parse_item(root):
return {
"id": int_of(root, "name/id"),
"name": text(root, "name/str"),
"genre": text(root, "genre/str"),
"release": text(root, "releaseTagName/str"),
"lock": text(root, "dispCond"),
"norm": text(root, "normText"),
"isDefault": text(root, "isDefault") == "true",
"disable": text(root, "disable") == "true",
}
def parse_ticket(root):
return {
"id": int_of(root, "name/id"),
"name": text(root, "name/str"),
"detail": text(root, "detail"),
"areaPercent": int_of(root, "areaPercent"),
"charaMagnification": int_of(root, "charaMagnification"),
"isMoney": text(root, "isMoney") == "true",
"creditNum": int_of(root, "creditNum"),
"maxCount": int_of(root, "maxCount"),
"kind": text(root, "ticketKind/str"),
"event": text(root, "ticketEvent/str"),
}
def collect(src):
packs = sorted(
[d for d in os.listdir(src) if re.fullmatch(r"A\d{3}", d)],
key=lambda x: x,
)
packs.sort(key=lambda x: (x != "A000", x)) # A000 先,其余按序覆盖
out = {k: {} for k in KIND_SPEC}
stats = {k: {"files": 0, "added": 0} for k in KIND_SPEC}
for pack in packs:
base = os.path.join(src, pack)
for kind, (folder, root_tag) in KIND_SPEC.items():
d = os.path.join(base, folder)
if not os.path.isdir(d):
continue
for name in os.listdir(d):
sub = os.path.join(d, name)
if not os.path.isdir(sub):
continue
xml = None
for f in os.listdir(sub):
if f.lower() == folder + ".xml" or f.lower().endswith(".xml"):
if "sort" in f.lower():
continue
xml = os.path.join(sub, f)
break
if not xml:
continue
try:
root = ET.parse(xml).getroot()
except ET.ParseError:
continue
if root.tag != root_tag:
continue
try:
if kind == "music":
rec = parse_music(root)
elif kind == "ticket":
rec = parse_ticket(root)
else:
rec = parse_item(root)
except Exception:
continue
if not rec.get("id"):
continue
if rec["id"] not in out[kind]:
stats[kind]["added"] += 1
out[kind][rec["id"]] = rec
stats[kind]["files"] += 1
return out, stats
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--src", default=r"D:\sdgb\Sinmai_Data\StreamingAssets")
ap.add_argument("--out", default=os.path.join(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))), "data", "master"))
args = ap.parse_args(argv)
out, stats = collect(args.src)
os.makedirs(args.out, exist_ok=True)
for kind, items in out.items():
rows = sorted(items.values(), key=lambda r: r["id"])
path = os.path.join(args.out, kind + ".json")
with open(path, "w", encoding="utf-8") as f:
json.dump(rows, f, ensure_ascii=False, indent=1)
s = stats[kind]
print("%-8s %5d 条(扫描 %d 个文件) -> %s" % (kind, len(rows), s["files"], path))
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -6,7 +6,7 @@ import sys
import xml.etree.ElementTree as ET
BASE = r"D:\bs\Package\Sinmai_Data\StreamingAssets\A000"
OUT = r"D:\DSH\maimai\maipulse\data\master.json"
OUT = r"<maipulse>\data\master.json"
CATS = {
"ticket": ("ticket", 12),
+261 -102
View File
@@ -1,16 +1,21 @@
# -*- coding: utf-8 -*-
"""发版:改版本号 -> 打包 -> 推仓库 -> 新开一个 Release -> 上传 APK。
"""发版:改版本号 -> 打包 -> 推两个仓 -> 新开一个 Release -> 上传 APK。
两个仓库,两套同步面:
· Gitea 公开仓 shouko/maipulse —— 脱敏:不推设计稿,本机路径/邮箱全部替换成占位符
· GitHub 私有仓 gu-kaf/maimai-backup —— 完整备份,含 keychips 与真实配置,不脱敏
★ 每次发版都新建 Release,绝不覆盖旧的那个:
旧版本留着,别人手上的旧包点「检查更新」还能按 tag 找到自己的历史版本,
也能回滚;覆盖 asset 会让同一个 tag 指向不同内容,hash 对不上,排查时非常难缠。
用法:
python tools/release.py 1.0.1 "修了什么"
python tools/release.py 1.0.1 "修了什么" --dry 只打印计划
python tools/release.py 1.0.1 --no-build 复用桌面上已有的 APK
python tools/release.py 1.0.2 "修了什么"
python tools/release.py 1.0.2 "修了什么" --dry 只打印计划
python tools/release.py 1.0.2 --no-build 复用桌面上已有的 APK
token 从环境变量 GITEA_TOKEN 读,读不到再看 tools/.release-token(该文件不入库)。
GitHub 的凭据在 _gh_backup 的 remote 里,直接 git push 即可。
"""
from __future__ import annotations
@@ -24,8 +29,12 @@ import urllib.request
APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
APP_PY = os.path.join(APP_DIR, "app.py")
REPO_DIR = r"D:\DSH\maimai\_repo"
APK = r"C:\Users\20558\Desktop\maipulse-debug.apk"
ANDROID_DIR = r"<android 工程>"
REPO_DIR = r"<仓库>" # Gitea 公开仓的工作副本
GH_DIR = r"<项目根>\_gh_backup" # GitHub 私有仓的工作副本
APK = r"%USERPROFILE%\Desktop\maipulse-debug.apk"
BUILD_SCRIPT = os.path.join(APP_DIR, "tools", "build_apk.ps1")
SITE = "https://git.fragrance.moe"
@@ -35,17 +44,23 @@ REPO = "maipulse"
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"]
# 数据目录里只带运行必需的资产,其余(用户数据 / 快照 / 缓存)排除
# ---- 公开仓同步面 ----
# 设计稿(PLAN*.md)不进公开仓:里面全是开发过程、逆向出处和本机路径
PUB_FILES = ["app.py"]
PUB_DIRS = ["core", "html", "tools", "data"]
# tools 里只带运行与发版必需的,探针/dump 一律不进库
PUB_TOOLS = {"build_apk.ps1", "blank_secrets.py", "blank_paths.py", "apply_update.py",
"fetch_df.py", "dump_master.py", "release.py", "restart.ps1",
"backfill_pack.py", "collect_masterdata.py"}
DATA_KEEP = ["master.json", "master", "icons"]
# ---- 私有仓同步面(完整备份,不脱敏)----
PRIV_FILES = ["app.py", "version.json", "config.filled.json",
"PLAN.md", "PLAN-APP.md", "PLAN-EXT.md"]
PRIV_DIRS = ["core", "html", "tools", "data", "keychips"]
SKIP_NAMES = {"__pycache__", ".release-token", "accounts.json", "shops.json",
"shop_current.json", "config.filled.json", "covers", "_backup",
"logs", "data"}
"shop_current.json", "covers", "_backup", "logs"}
SKIP_SUFFIX = (".pyc", ".pyo")
@@ -59,17 +74,34 @@ def _force_copy(src, dst):
os.chmod(dst, stat.S_IWRITE | stat.S_IREAD)
except OSError:
pass
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
def sync_source(verbose=True):
"""把源码面同步进 REPO_DIR/maipulse。返回同步的文件数。"""
def _walk_copy(src_dir, dst_dir, only_names=None, skip_names=SKIP_NAMES):
count = 0
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)
for f in files:
if f in skip_names or f.endswith(SKIP_SUFFIX):
continue
if only_names is not None and f not in only_names:
continue
_force_copy(os.path.join(root, f), os.path.join(target, f))
count += 1
return count
def sync_public():
"""源码面 -> 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:
for name in PUB_FILES:
src = os.path.join(APP_DIR, name)
if os.path.isfile(src):
_force_copy(src, os.path.join(dst_root, name))
@@ -77,49 +109,126 @@ def sync_source(verbose=True):
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
if os.path.isdir(src_dir):
count += _walk_copy(src_dir, os.path.join(dst_root, d),
only_names=PUB_TOOLS if d == "tools" else None)
# 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))
p = os.path.join(data_src, name)
if os.path.isfile(p):
_force_copy(p, 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
elif os.path.isdir(p):
count += _walk_copy(p, os.path.join(data_dst, name))
# 公开仓里不放设计稿:上一次推上去的可能是旧的,主动删掉
for stale in ("PLAN.md", "PLAN-APP.md", "PLAN-EXT.md", "config.filled.json"):
p = os.path.join(dst_root, stale)
if os.path.exists(p):
os.remove(p)
print(" 公开仓移除设计稿/私有配置:%s" % stale)
return count
def sync_android_public():
"""Android 工程 -> REPO_DIR/android。返回文件数。"""
dst = os.path.join(REPO_DIR, "android")
if os.path.isdir(dst):
import shutil
shutil.rmtree(dst, ignore_errors=True)
count = 0
src_main = os.path.join(ANDROID_DIR, "app", "src", "main")
# assets 是打包时生成的,仓库里不要
count += _walk_copy(src_main, os.path.join(dst, "app", "src", "main"),
skip_names=SKIP_NAMES | {"assets"})
for f in ("build.gradle", "proguard-rules.pro", "app/build.gradle",
"settings.gradle", "gradle.properties",
"gradle/wrapper/gradle-wrapper.properties"):
s = os.path.join(ANDROID_DIR, f)
if os.path.isfile(s):
_force_copy(s, os.path.join(dst, f))
count += 1
return count
def sync_astro_public():
"""astral 根目录的漂移(config.py 除外,它由 blank_secrets 生成空模板)"""
src = r"<astral>"
dst = os.path.join(REPO_DIR, "astral")
if not os.path.isdir(src):
return 0
count = 0
for f in ("authv1.py", "buy_ticket.py", "netdelivery.py", "reincarnate.py",
"unlockmusic.py", "upsert.py", "use_ticket.py", "README.md",
"config.py.example"):
s = os.path.join(src, f)
if os.path.isfile(s):
_force_copy(s, os.path.join(dst, f))
count += 1
count += _walk_copy(os.path.join(src, "api"), os.path.join(dst, "api"))
count += _walk_copy(os.path.join(src, "tools"), os.path.join(dst, "tools"))
return count
def blank_public():
"""公开仓副本做路径脱敏(本机用户名 / 目录结构 / 邮箱)"""
sys.path.insert(0, os.path.join(APP_DIR, "tools"))
import blank_paths
changed = []
for sub in ("maipulse", "android", "astral"):
p = os.path.join(REPO_DIR, sub)
if os.path.isdir(p):
changed += blank_paths.process(p)
return changed
def sync_private():
"""完整源码 -> GH_DIR/maipulse(不脱敏,保留 keychips 与真实配置)"""
dst_root = os.path.join(GH_DIR, "maipulse")
os.makedirs(dst_root, exist_ok=True)
count = 0
for name in PRIV_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 os.path.isdir(src_dir):
count += _walk_copy(src_dir, os.path.join(dst_root, d))
data_src = os.path.join(APP_DIR, "data")
data_dst = os.path.join(dst_root, "data")
for name in DATA_KEEP:
p = os.path.join(data_src, name)
if os.path.isfile(p):
_force_copy(p, os.path.join(data_dst, name))
count += 1
elif os.path.isdir(p):
count += _walk_copy(p, os.path.join(data_dst, name))
# Android 工程也备一份(公开仓删了旧的,私有仓要跟着更新)
if os.path.isdir(ANDROID_DIR):
ah = os.path.join(GH_DIR, "android")
import shutil
if os.path.isdir(ah):
shutil.rmtree(ah, ignore_errors=True)
count += _walk_copy(os.path.join(ANDROID_DIR, "app", "src", "main"),
os.path.join(ah, "app", "src", "main"),
skip_names=SKIP_NAMES | {"assets"})
for f in ("build.gradle", "proguard-rules.pro", "app/build.gradle",
"settings.gradle", "gradle.properties",
"gradle/wrapper/gradle-wrapper.properties"):
s = os.path.join(ANDROID_DIR, f)
if os.path.isfile(s):
_force_copy(s, os.path.join(ah, f))
count += 1
if verbose:
print(" 源码同步 %d 个文件 -> %s" % (count, dst_root))
return count
@@ -133,23 +242,36 @@ def token() -> str:
raise SystemExit("没有 token:设置环境变量 GITEA_TOKEN,或写 tools/.release-token")
def api(path: str, method: str = "GET", body=None, raw: bytes = None, ctype: str = "application/json"):
def api(path: str, method: str = "GET", body=None, tries: int = 5):
"""Gitea 走 Cloudflare,偶发 [SSL: UNEXPECTED_EOF] 直接断连,重试几次就好。"""
import ssl
import time
url = "%s/api/v1/repos/%s/%s%s" % (SITE, OWNER, REPO, path)
data = raw if raw is not None else (json.dumps(body, ensure_ascii=False).encode("utf-8") if body else None)
headers = {"Authorization": "token " + token(), "User-Agent": UA, "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = ctype
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
resp = urllib.request.urlopen(req, timeout=60)
txt = resp.read().decode("utf-8")
return json.loads(txt) if txt.strip() else {}
except urllib.error.HTTPError as exc:
raise SystemExit("%s %s -> HTTP %s %s" % (method, path, exc.code, exc.read()[:300]))
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body else None
headers = {"Authorization": "token " + token(), "User-Agent": UA,
"Accept": "application/json", "Content-Type": "application/json"}
last = None
for attempt in range(tries):
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
resp = urllib.request.urlopen(req, timeout=90)
txt = resp.read().decode("utf-8")
return json.loads(txt) if txt.strip() else {}
except urllib.error.HTTPError as exc:
detail = exc.read()[:400]
# 4xx 是请求本身的问题,重试没意义,直接抛
if 400 <= exc.code < 500:
raise SystemExit("%s %s -> HTTP %s %s" % (method, path, exc.code, detail))
last = "HTTP %s %s" % (exc.code, detail)
except (urllib.error.URLError, ssl.SSLError, OSError) as exc:
last = "%s: %s" % (type(exc).__name__, exc)
wait = 2 * (attempt + 1)
print(" 第 %d 次失败(%s),%ds 后重试" % (attempt + 1, last, wait))
time.sleep(wait)
raise SystemExit("%s %s 连续 %d 次失败:%s" % (method, path, tries, last))
def tag_exists(tag: str) -> bool:
"""只问存在性,不抛异常。"""
url = "%s/api/v1/repos/%s/%s/releases/tags/%s" % (SITE, OWNER, REPO, tag)
req = urllib.request.Request(url, headers={
"Authorization": "token " + token(), "User-Agent": UA, "Accept": "application/json"})
@@ -184,14 +306,33 @@ def bump(ver: str) -> str:
def run(cmd, cwd=None, check=True):
print(" $", " ".join(cmd) if isinstance(cmd, list) else cmd)
shown = " ".join(cmd) if isinstance(cmd, list) else cmd
# 绝不把带 token 的 URL 打出来
shown = re.sub(r'://[^@\s]*@', '://***@', shown)
print(" $", shown)
r = subprocess.run(cmd, cwd=cwd, shell=isinstance(cmd, str),
capture_output=True, text=True, encoding="utf-8", errors="replace")
if check and r.returncode != 0:
raise SystemExit("命令失败:%s\n%s\n%s" % (cmd, r.stdout[-800:], r.stderr[-800:]))
out = (r.stdout or "")[-800:]
err = (r.stderr or "")[-800:]
raise SystemExit("命令失败:%s\n%s\n%s" % (shown, out, err))
return r
def git_commit_push(where, msg):
run(["git", "add", "-A"], cwd=where)
r = run(["git", "status", "--porcelain"], cwd=where, check=False)
if (r.stdout or "").strip():
run(["git", "-c", "user.name=maipulse", "-c", "[email protected]",
"commit", "-m", msg], cwd=where, check=False)
else:
print(" 工作区无变更")
# 不管这次有没有新提交,都推一次:
# 上一次可能 commit 了但 push 失败,工作区是干净的,只看 status 会误判成"没变化"。
run(["git", "push", "origin", "HEAD:main"], cwd=where)
return True
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
dry = "--dry" in sys.argv
@@ -209,7 +350,6 @@ def main():
print("构建日期:%s" % build)
print("更新说明:%s" % (notes or "(空)"))
# 已有同名 tag 就直接停,绝不复用(404 = 不存在,正常继续)
if tag_exists("v" + new_ver):
raise SystemExit("tag v%s 已存在 —— 每次发版要用新版本号,不能覆盖旧的" % new_ver)
@@ -217,32 +357,39 @@ def main():
print("\n--dry:不写文件、不打包、不推送")
return 0
# 1) 改版本号
print("\n[1/5] 写版本号")
msg = "release: v%s\n\n%s" % (new_ver, notes or "-")
print("\n[1/7] 写版本号")
set_version(new_ver, build)
print(" app.py -> %s / %s" % (new_ver, build))
# 2) 打包
print("\n[2/7] 打包")
if no_build:
print("\n[2/5] 跳过打包,用现有 APK")
print(" 跳过,用现有 APK")
else:
print("\n[2/5] 打包")
run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", BUILD_SCRIPT])
if not os.path.isfile(APK):
raise SystemExit("没有 APK:%s" % APK)
size = os.path.getsize(APK)
print(" %s %.2f MB" % (APK, size / 1048576))
# 3) 同步进仓库并推送
print("\n[3/5] 推仓库")
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)
run(["git", "push", "origin", "main"], cwd=REPO_DIR)
print("\n[3/7] 同步公开仓(脱敏)")
n = sync_public()
n += sync_android_public()
n += sync_astro_public()
print(" 源码 %d 个文件" % n)
changed = blank_public()
print(" 路径脱敏 %d 个文件" % len(changed))
# 4) 新开 Release(不是复用)
print("\n[4/5] 新建 Release v%s" % new_ver)
print("\n[4/7] 推 Gitea")
pushed_pub = git_commit_push(REPO_DIR, msg)
print("\n[5/7] 同步 GitHub 私有仓(完整,不脱敏)")
n2 = sync_private()
print(" 源码 %d 个文件" % n2)
pushed_priv = git_commit_push(GH_DIR, msg)
print("\n[6/7] 新建 Release v%s" % new_ver)
rel = api("/releases", "POST", {
"tag_name": "v" + new_ver,
"target_commitish": "main",
@@ -253,30 +400,42 @@ def main():
})
print(" id=%s %s" % (rel.get("id"), rel.get("html_url")))
# 5) 上传 APK 到这个新 Release
print("\n[5/5] 上传 APK")
print("\n[7/7] 上传 APK")
boundary = "----maipulse" + os.urandom(8).hex()
with open(APK, "rb") as fp:
blob = fp.read()
body_parts = []
body_parts.append(("--%s\r\nContent-Disposition: form-data; name=\"attachment\"; "
"filename=\"maipulse.apk\"\r\nContent-Type: application/vnd.android.package-archive\r\n\r\n"
% boundary).encode("utf-8"))
body_parts.append(blob)
body_parts.append(("\r\n--%s--\r\n" % boundary).encode("utf-8"))
payload = b"".join(body_parts)
url = "%s/api/v1/repos/%s/%s/releases/%s/assets?name=maipulse.apk" % (SITE, OWNER, REPO, rel.get("id"))
req = urllib.request.Request(url, data=payload, method="POST", headers={
"Authorization": "token " + token(), "User-Agent": UA,
"Content-Type": "multipart/form-data; boundary=" + boundary,
})
asset = json.loads(urllib.request.urlopen(req, timeout=600).read().decode("utf-8"))
head = ("--%s\r\nContent-Disposition: form-data; name=\"attachment\"; "
"filename=\"maipulse-%s.apk\"\r\nContent-Type: application/vnd.android.package-archive\r\n\r\n"
% (boundary, new_ver)).encode("utf-8")
payload = head + blob + ("\r\n--%s--\r\n" % boundary).encode("utf-8")
url = "%s/api/v1/repos/%s/%s/releases/%s/assets?name=maipulse-%s.apk" % (
SITE, OWNER, REPO, rel.get("id"), new_ver)
asset = None
import ssl
import time
for attempt in range(5):
req = urllib.request.Request(url, data=payload, method="POST", headers={
"Authorization": "token " + token(), "User-Agent": UA,
"Content-Type": "multipart/form-data; boundary=" + boundary,
})
try:
asset = json.loads(urllib.request.urlopen(req, timeout=600).read().decode("utf-8"))
break
except urllib.error.HTTPError as exc:
raise SystemExit("上传失败 HTTP %s %s" % (exc.code, exc.read()[:400]))
except (urllib.error.URLError, ssl.SSLError, OSError) as exc:
print(" 第 %d 次上传失败(%s),重试" % (attempt + 1, exc))
time.sleep(3 * (attempt + 1))
if asset is None:
raise SystemExit("上传 APK 连续失败")
print(" %s %d B" % (asset.get("name"), asset.get("size")))
print("\n发版完成")
print(" 公开仓 : %s/%s/%s (%s)" % (SITE, OWNER, REPO, "已推送" if pushed_pub else "无变更"))
print(" 私有仓 : github.com/gu-kaf/maimai-backup (%s)" % ("已推送" if pushed_priv else "无变更"))
print(" Release : %s" % rel.get("html_url"))
print(" 下载 : %s/%s/%s/releases/download/v%s/maipulse.apk" % (SITE, OWNER, REPO, new_ver))
print(" 旧版本 : 都保留着,没有被覆盖")
print(" 下载 : %s/%s/%s/releases/download/v%s/maipulse-%s.apk" % (
SITE, OWNER, REPO, new_ver, new_ver))
return 0
+2 -2
View File
@@ -8,8 +8,8 @@
# powershell -ExecutionPolicy Bypass -File tools\restart.ps1
param(
[string]$Py = "C:\Users\20558\AppData\Local\Programs\Python\Python313\python.exe",
[string]$AppDir = "D:\DSH\maimai\maipulse",
[string]$Py = "%USERPROFILE%\AppData\Local\Programs\Python\Python313\python.exe",
[string]$AppDir = "<maipulse>",
[string]$Url = "http://127.0.0.1:8787"
)