diff --git a/deploy/popadmin/_deploy_pull_pages.py b/deploy/popadmin/_deploy_pull_pages.py
new file mode 100644
index 0000000..c375994
--- /dev/null
+++ b/deploy/popadmin/_deploy_pull_pages.py
@@ -0,0 +1,195 @@
+#!/usr/bin/env python3
+"""Deploy popadmin pull pages static + media-pull hotpatched jar to SG."""
+from __future__ import annotations
+
+import os
+import time
+from pathlib import Path
+
+import paramiko
+
+HOST = "47.236.66.9"
+USER = "root"
+PEM = Path(os.path.expandvars(r"%USERPROFILE%\.ssh\wyrs_shanghai_deploy.pem"))
+LOCAL_JS = Path(r"F:\0000wyrs\Aliyun\src\main\resources\static\popadmin\js\popadmin.js")
+LOCAL_CSS = Path(r"F:\0000wyrs\Aliyun\src\main\resources\static\popadmin\css\popadmin.css")
+LOCAL_JAR = Path(r"F:\0000wyrs\Aliyun\target\remote-control-1.0.0.media-pull.jar")
+REMOTE_STATIC = "/opt/wyrs-admin/static-override"
+REMOTE_JAR = "/opt/wyrs-admin/remote-control-1.0.0.jar"
+CACHE = "20261102-pullpages"
+
+
+def run(client: paramiko.SSHClient, cmd: str, timeout: int = 180) -> tuple[int, str, str]:
+ print("$", cmd[:240])
+ _, stdout, stderr = client.exec_command(cmd, timeout=timeout)
+ out = stdout.read().decode("utf-8", "replace")
+ err = stderr.read().decode("utf-8", "replace")
+ code = stdout.channel.recv_exit_status()
+ if out.strip():
+ print(out[:2000])
+ if err.strip():
+ print("[stderr]", err[:1000])
+ return code, out, err
+
+
+def main() -> None:
+ if not PEM.is_file():
+ raise SystemExit(f"missing pem {PEM}")
+ for p in (LOCAL_JS, LOCAL_CSS, LOCAL_JAR):
+ if not p.is_file():
+ raise SystemExit(f"missing {p}")
+
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ # Auto-detect key type (RSA / Ed25519 / ECDSA)
+ key = None
+ for loader in (
+ paramiko.Ed25519Key.from_private_key_file,
+ paramiko.RSAKey.from_private_key_file,
+ paramiko.ECDSAKey.from_private_key_file,
+ ):
+ try:
+ key = loader(str(PEM))
+ break
+ except Exception:
+ continue
+ if key is None:
+ raise SystemExit(f"cannot load private key {PEM}")
+ client.connect(HOST, username=USER, pkey=key, timeout=30)
+ sftp = client.open_sftp()
+
+ ts = time.strftime("%Y%m%d%H%M%S")
+ print("=== backup static ===")
+ run(
+ client,
+ f"cp -a {REMOTE_STATIC}/popadmin.js {REMOTE_STATIC}/popadmin.js.bak.pullpages.{ts} && "
+ f"cp -a {REMOTE_STATIC}/popadmin.css {REMOTE_STATIC}/popadmin.css.bak.pullpages.{ts} && "
+ f"cp -a {REMOTE_STATIC}/popadmin.index.html {REMOTE_STATIC}/popadmin.index.html.bak.pullpages.{ts}",
+ )
+
+ print("=== upload static ===")
+ sftp.put(str(LOCAL_JS), f"{REMOTE_STATIC}/popadmin.js")
+ # Prefer remote CSS if it has nav-fix; merge by keeping remote if larger/newer features.
+ # Still upload local CSS then restore nav scroll rules via sed if needed.
+ sftp.put(str(LOCAL_CSS), f"{REMOTE_STATIC}/popadmin.css")
+
+ # bump cache on index
+ idx_cmd = f"""python3 - <<'PY'
+from pathlib import Path
+p = Path('{REMOTE_STATIC}/popadmin.index.html')
+s = p.read_text(encoding='utf-8')
+import re
+s2 = re.sub(r'popadmin\\.js\\?v=[^\"\\']+', 'popadmin.js?v={CACHE}', s)
+s2 = re.sub(r'popadmin\\.css\\?v=[^\"\\']+', 'popadmin.css?v={CACHE}', s2)
+p.write_text(s2, encoding='utf-8')
+print('cache', '{CACHE}')
+print('js', 'popadmin.js?v={CACHE}' in s2)
+print('css', 'popadmin.css?v={CACHE}' in s2)
+PY"""
+ run(client, idx_cmd)
+
+ # Ensure sidebar scroll rule exists (from earlier navfix)
+ run(
+ client,
+ f"""python3 - <<'PY'
+from pathlib import Path
+p = Path('{REMOTE_STATIC}/popadmin.css')
+s = p.read_text(encoding='utf-8')
+needle = '.sidebar nav'
+rule = '''
+/* pullpages: keep long nav scrollable */
+.sidebar {{ display:flex; flex-direction:column; min-height:0; }}
+.sidebar nav {{ flex:1; overflow-y:auto; overscroll-behavior:contain; }}
+'''
+if 'overscroll-behavior:contain' not in s:
+ p.write_text(s + '\\n' + rule, encoding='utf-8')
+ print('css nav scroll appended')
+else:
+ print('css nav scroll already present')
+PY""",
+ )
+
+ print("=== markers in deployed js ===")
+ run(
+ client,
+ f"python3 - <<'PY'\n"
+ f"s=open('{REMOTE_STATIC}/popadmin.js',encoding='utf-8',errors='replace').read()\n"
+ f"for k in ['async contacts(','contactsConsented','albumConsented','documentsConsented','用户未授权','mediaPullQId']:\n"
+ f" print(k, s.count(k))\n"
+ f"PY",
+ )
+
+ print("=== deploy jar ===")
+ run(client, f"cp -a {REMOTE_JAR} {REMOTE_JAR}.bak.pullpages.{ts}")
+ remote_tmp = f"/tmp/remote-control-1.0.0.media-pull.{ts}.jar"
+ print("uploading jar...", LOCAL_JAR.stat().st_size)
+ sftp.put(str(LOCAL_JAR), remote_tmp)
+ run(client, f"mv {remote_tmp} {REMOTE_JAR} && chmod 644 {REMOTE_JAR} && ls -lh {REMOTE_JAR}")
+
+ print("=== restart wyrs-admin ===")
+ run(client, "systemctl restart wyrs-admin", timeout=60)
+ # wait health
+ for i in range(30):
+ time.sleep(2)
+ code, out, _ = run(
+ client,
+ "curl -sS -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:8090/api/admin/auth/login || true",
+ timeout=20,
+ )
+ # login endpoint should be 405/400/200 not 000
+ if out.strip() and out.strip() != "000":
+ print("health http", out.strip(), "attempt", i)
+ break
+ else:
+ print("WARN: health check timed out")
+
+ print("=== verify APIs ===")
+ verify = r'''
+python3 - <<'PY'
+import json, os, urllib.request
+env={}
+for line in open('/etc/wyrs-admin/app.env',encoding='utf-8',errors='replace'):
+ line=line.strip()
+ if not line or line.startswith('#') or '=' not in line: continue
+ k,v=line.split('=',1); env[k.strip()]=v.strip().strip('"').strip("'")
+password=env.get('WYRS_ADMIN_PASSWORD') or ''
+body=json.dumps({'username':'admin','password':password}).encode()
+req=urllib.request.Request('http://127.0.0.1:8090/api/admin/auth/login',data=body,headers={'Content-Type':'application/json'},method='POST')
+with urllib.request.urlopen(req,timeout=15) as resp:
+ token=json.loads(resp.read().decode())['data']['token']
+for url in [
+ 'http://127.0.0.1:8090/api/admin/spark/media-pull?userId=1017',
+ 'http://127.0.0.1:8090/api/admin/spark/media-pull?email=spark.test.01@wyrs.test.local',
+ 'http://127.0.0.1:8090/api/admin/spark/contacts/summary',
+]:
+ req=urllib.request.Request(url,headers={'Authorization':'Bearer '+token})
+ try:
+ with urllib.request.urlopen(req,timeout=15) as resp:
+ raw=resp.read().decode()
+ print('OK', url, raw[:400])
+ except Exception as e:
+ body=''
+ if hasattr(e,'read'):
+ try: body=e.read().decode()
+ except Exception: pass
+ print('ERR', url, e, body[:300])
+# PUT pullContacts false should work
+req=urllib.request.Request(
+ 'http://127.0.0.1:8090/api/admin/spark/media-pull?email=spark.test.01@wyrs.test.local',
+ data=json.dumps({'pullContacts':False,'count':25}).encode(),
+ headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'},
+ method='PUT')
+with urllib.request.urlopen(req,timeout=15) as resp:
+ print('PUT', resp.read().decode()[:500])
+PY'''
+ run(client, verify, timeout=60)
+
+ print("=== nginx reload (noop if unchanged) ===")
+ run(client, "nginx -t && nginx -s reload")
+ sftp.close()
+ client.close()
+ print("DEPLOY DONE cache=", CACHE)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/deploy/popadmin/_hotpatch_media_pull.py b/deploy/popadmin/_hotpatch_media_pull.py
new file mode 100644
index 0000000..35be02b
--- /dev/null
+++ b/deploy/popadmin/_hotpatch_media_pull.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+"""Compile Pop media-pull related classes and jar-uf into a deployable fat jar."""
+from __future__ import annotations
+
+import shutil
+import subprocess
+import tempfile
+import zipfile
+from pathlib import Path
+
+ROOT = Path(r"F:\0000wyrs\Aliyun")
+JAR = ROOT / "target" / "remote-control-1.0.0.jar"
+OUT_JAR = ROOT / "target" / "remote-control-1.0.0.media-pull.jar"
+JAVA_HOME = Path(r"C:\Program Files\Eclipse Adoptium\jdk-17.0.20.8-hotspot")
+JAVAC = JAVA_HOME / "bin" / "javac.exe"
+JAR_BIN = JAVA_HOME / "bin" / "jar.exe"
+LOMBOK = ROOT / "scripts" / "lib" / "lombok-1.18.34.jar"
+
+SRC_FILES = [
+ "com/wyrs/pop/config/PopProperties.java",
+ "com/wyrs/pop/entity/PopUserMedia.java",
+ "com/wyrs/pop/repository/PopUserMediaRepository.java",
+ "com/wyrs/pop/dto/PopMediaPullDtos.java",
+ "com/wyrs/pop/dto/PopSyncDtos.java",
+ "com/wyrs/pop/store/PopMediaPullFileStore.java",
+ "com/wyrs/pop/service/PopMediaPullService.java",
+ "com/wyrs/pop/service/PopSyncService.java",
+ "com/wyrs/pop/controller/PopAdminMediaPullController.java",
+ "com/wyrs/pop/controller/PopClientMediaPullController.java",
+ "com/wyrs/pop/controller/PopClientOpsController.java",
+ "com/wyrs/pop/controller/PopClientSyncController.java",
+ "com/wyrs/pop/controller/PopAdminController.java",
+]
+
+
+def main() -> None:
+ if not JAR.is_file():
+ raise SystemExit(f"missing base jar {JAR}")
+ if not JAVAC.is_file():
+ raise SystemExit(f"missing javac {JAVAC}")
+ if not LOMBOK.is_file():
+ raise SystemExit(f"missing lombok {LOMBOK}")
+
+ work = Path(tempfile.mkdtemp(prefix="wyrs-media-pull-"))
+ try:
+ extract = work / "extract"
+ classes_out = work / "classes"
+ extract.mkdir()
+ classes_out.mkdir()
+ print("extracting classpath from fat jar...")
+ with zipfile.ZipFile(JAR, "r") as z:
+ for name in z.namelist():
+ if name.startswith("BOOT-INF/lib/") or name.startswith("BOOT-INF/classes/"):
+ z.extract(name, extract)
+
+ cp_parts = [str(extract / "BOOT-INF" / "classes"), str(LOMBOK)]
+ lib = extract / "BOOT-INF" / "lib"
+ for j in sorted(lib.glob("*.jar")):
+ cp_parts.append(str(j))
+ cp = ";".join(cp_parts)
+
+ src_root = ROOT / "src" / "main" / "java"
+ src_args = []
+ for rel in SRC_FILES:
+ p = src_root / rel
+ if not p.is_file():
+ raise SystemExit(f"missing source {p}")
+ src_args.append(str(p))
+
+ cmd = [
+ str(JAVAC),
+ "-encoding",
+ "UTF-8",
+ "-parameters",
+ "-cp",
+ cp,
+ f"-processorpath",
+ str(LOMBOK),
+ "-d",
+ str(classes_out),
+ *src_args,
+ ]
+ print("javac", len(src_args), "files ...")
+ p = subprocess.run(cmd, capture_output=True)
+ log = ROOT / "logs"
+ log.mkdir(parents=True, exist_ok=True)
+ (log / "javac_media_pull_err.txt").write_bytes(p.stderr or b"")
+ (log / "javac_media_pull_out.txt").write_bytes(p.stdout or b"")
+ if p.returncode != 0:
+ print((p.stderr or b"").decode("utf-8", "replace")[:4000])
+ raise SystemExit(f"javac failed code={p.returncode}")
+
+ # Collect produced class files under packages we care about
+ produced = list(classes_out.rglob("*.class"))
+ if not produced:
+ raise SystemExit("no class files produced")
+ print(f"produced {len(produced)} class files")
+
+ # markers
+ markers = [b"contactsConsented", b"pullContacts", b"pullDocuments", b"hasContactsConsent"]
+ blob = b"".join(f.read_bytes() for f in produced)
+ for m in markers:
+ print(f"marker {m!r}:", m in blob)
+
+ shutil.copy2(JAR, OUT_JAR)
+ update_root = work / "update"
+ for f in produced:
+ rel = f.relative_to(classes_out).as_posix()
+ dest = update_root / "BOOT-INF" / "classes" / rel
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(f, dest)
+
+ # jar uf from update_root
+ entries = [
+ "BOOT-INF/classes/" + f.relative_to(classes_out).as_posix() for f in produced
+ ]
+ print("jar uf", len(entries), "entries ...")
+ p2 = subprocess.run(
+ [str(JAR_BIN), "uf", str(OUT_JAR), *entries],
+ cwd=str(update_root),
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ if p2.returncode != 0:
+ print(p2.stdout)
+ print(p2.stderr)
+ raise SystemExit(f"jar uf failed: {p2.returncode}")
+
+ with zipfile.ZipFile(OUT_JAR, "r") as z:
+ sample = z.read(
+ "BOOT-INF/classes/com/wyrs/pop/service/PopMediaPullService.class"
+ )
+ if b"hasContactsConsent" not in sample and b"contactsConsented" not in sample:
+ raise SystemExit("patched jar missing consent markers")
+ print(f"OK {OUT_JAR} ({OUT_JAR.stat().st_size} bytes)")
+ finally:
+ shutil.rmtree(work, ignore_errors=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/deploy/popadmin/_recover_hotpatch_on_prod_jar.py b/deploy/popadmin/_recover_hotpatch_on_prod_jar.py
new file mode 100644
index 0000000..181c150
--- /dev/null
+++ b/deploy/popadmin/_recover_hotpatch_on_prod_jar.py
@@ -0,0 +1,156 @@
+#!/usr/bin/env python3
+"""Restore prod jar backup, then jar-uf only media-pull classes from patched jar."""
+from __future__ import annotations
+
+import os
+import time
+from pathlib import Path
+
+import paramiko
+
+HOST = "47.236.66.9"
+USER = "root"
+PEM = Path(os.path.expandvars(r"%USERPROFILE%\.ssh\wyrs_shanghai_deploy.pem"))
+LOCAL_PATCHED = Path(r"F:\0000wyrs\Aliyun\target\remote-control-1.0.0.media-pull.jar")
+REMOTE_JAR = "/opt/wyrs-admin/remote-control-1.0.0.jar"
+REMOTE_WORK = "/tmp/media-pull-uf"
+# classes relative to BOOT-INF/classes we want to inject
+CLASS_GLOBS = [
+ "com/wyrs/pop/config/PopProperties.class",
+ "com/wyrs/pop/entity/PopUserMedia.class",
+ "com/wyrs/pop/repository/PopUserMediaRepository.class",
+ "com/wyrs/pop/dto/PopMediaPullDtos*.class",
+ "com/wyrs/pop/dto/PopSyncDtos*.class",
+ "com/wyrs/pop/store/PopMediaPullFileStore*.class",
+ "com/wyrs/pop/service/PopMediaPullService*.class",
+ "com/wyrs/pop/service/PopSyncService.class",
+ "com/wyrs/pop/controller/PopAdminMediaPullController.class",
+ "com/wyrs/pop/controller/PopClientMediaPullController.class",
+ "com/wyrs/pop/controller/PopClientOpsController.class",
+ "com/wyrs/pop/controller/PopClientSyncController.class",
+ "com/wyrs/pop/controller/PopAdminController.class",
+]
+
+
+def load_key(pem: Path):
+ for loader in (
+ paramiko.Ed25519Key.from_private_key_file,
+ paramiko.RSAKey.from_private_key_file,
+ paramiko.ECDSAKey.from_private_key_file,
+ ):
+ try:
+ return loader(str(pem))
+ except Exception:
+ continue
+ raise SystemExit(f"cannot load {pem}")
+
+
+def run(client, cmd, timeout=180):
+ print("$", cmd[:300])
+ _, stdout, stderr = client.exec_command(cmd, timeout=timeout)
+ out = stdout.read().decode("utf-8", "replace")
+ err = stderr.read().decode("utf-8", "replace")
+ code = stdout.channel.recv_exit_status()
+ if out.strip():
+ print(out[:2500])
+ if err.strip():
+ print("[stderr]", err[:1500])
+ return code, out, err
+
+
+def main():
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client.connect(HOST, username=USER, pkey=load_key(PEM), timeout=30)
+ sftp = client.open_sftp()
+
+ # find bak
+ code, out, _ = run(
+ client,
+ "ls -1t /opt/wyrs-admin/remote-control-1.0.0.jar.bak.pullpages.* 2>/dev/null | head -1",
+ )
+ bak = out.strip()
+ if not bak:
+ raise SystemExit("no bak.pullpages found")
+ print("restore from", bak)
+ run(client, "systemctl stop wyrs-admin || true")
+ run(client, f"cp -a '{bak}' {REMOTE_JAR}")
+
+ # upload patched jar temporarily to extract classes
+ tmp_patched = "/tmp/media-pull-patched-source.jar"
+ print("upload patched jar for class extract...")
+ sftp.put(str(LOCAL_PATCHED), tmp_patched)
+
+ # extract selected classes and jar uf into prod jar
+ script = f"""
+set -e
+rm -rf {REMOTE_WORK}
+mkdir -p {REMOTE_WORK}/BOOT-INF/classes
+cd {REMOTE_WORK}
+# extract all matching pop media-pull related classes from patched jar
+jar xf {tmp_patched} $(jar tf {tmp_patched} | grep -E 'BOOT-INF/classes/com/wyrs/pop/(config/PopProperties|entity/PopUserMedia|repository/PopUserMediaRepository|dto/PopMediaPullDtos|dto/PopSyncDtos|store/PopMediaPullFileStore|service/PopMediaPullService|service/PopSyncService|controller/PopAdminMediaPullController|controller/PopClientMediaPullController|controller/PopClientOpsController|controller/PopClientSyncController|controller/PopAdminController)' | tr '\\n' ' ')
+# list entries
+find BOOT-INF/classes -type f -name '*.class' | sort | tee /tmp/media-pull-entries.txt | wc -l
+# update prod jar
+jar uf {REMOTE_JAR} $(cat /tmp/media-pull-entries.txt | tr '\\n' ' ')
+# verify markers
+jar xf {REMOTE_JAR} BOOT-INF/classes/com/wyrs/pop/service/PopMediaPullService.class
+strings BOOT-INF/classes/com/wyrs/pop/service/PopMediaPullService.class | grep -E 'hasContactsConsent|contactsConsented|pullContacts' | head
+ls -lh {REMOTE_JAR}
+"""
+ run(client, script, timeout=240)
+
+ run(client, "systemctl start wyrs-admin")
+ ok = False
+ for i in range(40):
+ time.sleep(3)
+ code, out, _ = run(
+ client,
+ "curl -sS -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:8090/api/admin/auth/login || true",
+ timeout=20,
+ )
+ if out.strip() not in ("", "000"):
+ print("UP", out.strip(), "attempt", i)
+ ok = True
+ break
+ if not ok:
+ run(client, "journalctl -u wyrs-admin -n 60 --no-pager | tail -60")
+ raise SystemExit("service failed to come up")
+
+ # API verify
+ verify = r'''
+python3 - <<'PY'
+import json, urllib.request
+env={}
+for line in open('/etc/wyrs-admin/app.env',encoding='utf-8',errors='replace'):
+ line=line.strip()
+ if not line or line.startswith('#') or '=' not in line: continue
+ k,v=line.split('=',1); env[k.strip()]=v.strip().strip('"').strip("'")
+password=env.get('WYRS_ADMIN_PASSWORD') or ''
+body=json.dumps({'username':'admin','password':password}).encode()
+req=urllib.request.Request('http://127.0.0.1:8090/api/admin/auth/login',data=body,headers={'Content-Type':'application/json'},method='POST')
+with urllib.request.urlopen(req,timeout=20) as resp:
+ token=json.loads(resp.read().decode())['data']['token']
+for url in [
+ 'http://127.0.0.1:8090/api/admin/spark/media-pull?userId=1017',
+ 'http://127.0.0.1:8090/api/admin/spark/media-pull?email=spark.test.01@wyrs.test.local',
+]:
+ req=urllib.request.Request(url,headers={'Authorization':'Bearer '+token})
+ with urllib.request.urlopen(req,timeout=20) as resp:
+ print('GET', url, resp.read().decode()[:500])
+req=urllib.request.Request(
+ 'http://127.0.0.1:8090/api/admin/spark/media-pull?email=spark.test.01@wyrs.test.local',
+ data=json.dumps({'pullContacts':False,'pullDocuments':False,'count':25}).encode(),
+ headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'},
+ method='PUT')
+with urllib.request.urlopen(req,timeout=20) as resp:
+ print('PUT', resp.read().decode()[:600])
+PY'''
+ run(client, verify, timeout=60)
+ sftp.close()
+ client.close()
+ print("RECOVER+HOTPATCH DONE")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/deploy/popadmin/_verify_pull_apis.py b/deploy/popadmin/_verify_pull_apis.py
new file mode 100644
index 0000000..4e7b920
--- /dev/null
+++ b/deploy/popadmin/_verify_pull_apis.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+import json, os, urllib.request, urllib.error
+from pathlib import Path
+import paramiko
+
+HOST = "47.236.66.9"
+PEM = Path(os.path.expandvars(r"%USERPROFILE%\.ssh\wyrs_shanghai_deploy.pem"))
+
+
+def load_key(pem):
+ for loader in (
+ paramiko.Ed25519Key.from_private_key_file,
+ paramiko.RSAKey.from_private_key_file,
+ paramiko.ECDSAKey.from_private_key_file,
+ ):
+ try:
+ return loader(str(pem))
+ except Exception:
+ continue
+ raise SystemExit("key fail")
+
+
+def main():
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client.connect(HOST, username="root", pkey=load_key(PEM), timeout=30)
+ script = r'''
+python3 - <<'PY'
+import json, urllib.request, urllib.error
+env={}
+for line in open('/etc/wyrs-admin/app.env',encoding='utf-8',errors='replace'):
+ line=line.strip()
+ if not line or line.startswith('#') or '=' not in line: continue
+ k,v=line.split('=',1); env[k.strip()]=v.strip().strip('"').strip("'")
+password=env.get('WYRS_ADMIN_PASSWORD') or ''
+# try both username/password field names
+for body in (
+ {'username':'admin','password':password},
+ {'email':'admin','password':password},
+):
+ raw=json.dumps(body).encode()
+ req=urllib.request.Request('http://127.0.0.1:8090/api/admin/auth/login',data=raw,headers={'Content-Type':'application/json'},method='POST')
+ try:
+ with urllib.request.urlopen(req,timeout=20) as resp:
+ data=json.loads(resp.read().decode())
+ print('LOGIN_OK', body.keys(), list(data.keys()), data.get('message'), type(data.get('data')))
+ token=(data.get('data') or {}).get('token') if isinstance(data.get('data'), dict) else None
+ if token:
+ break
+ except urllib.error.HTTPError as e:
+ print('LOGIN_ERR', body.keys(), e.code, e.read()[:300])
+ token=None
+if not token:
+ raise SystemExit('no token')
+
+def get(url):
+ req=urllib.request.Request(url,headers={'Authorization':'Bearer '+token})
+ try:
+ with urllib.request.urlopen(req,timeout=20) as resp:
+ print('GET', url, resp.read().decode()[:700])
+ except urllib.error.HTTPError as e:
+ print('GET_ERR', url, e.code, e.read()[:500])
+
+get('http://127.0.0.1:8090/api/admin/spark/media-pull?userId=1017')
+get('http://127.0.0.1:8090/api/admin/spark/media-pull?email=spark.test.01@wyrs.test.local')
+get('http://127.0.0.1:8090/api/admin/spark/contacts/summary')
+get('http://127.0.0.1:8090/api/admin/spark/media/summary')
+get('http://127.0.0.1:8090/api/admin/spark/media?email=spark.test.01@wyrs.test.local&kind=document&page=0&size=5')
+get('http://127.0.0.1:8090/api/admin/spark/contacts?userId=1017&page=0&size=5')
+
+# PUT toggles
+payload=json.dumps({'pullContacts':False,'pullDocuments':False,'pullPhotos':False,'pullVideos':False,'enabled':False,'count':25}).encode()
+req=urllib.request.Request(
+ 'http://127.0.0.1:8090/api/admin/spark/media-pull?email=spark.test.01@wyrs.test.local',
+ data=payload,
+ headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'},
+ method='PUT')
+try:
+ with urllib.request.urlopen(req,timeout=20) as resp:
+ print('PUT', resp.read().decode()[:800])
+except urllib.error.HTTPError as e:
+ print('PUT_ERR', e.code, e.read()[:500])
+
+# static markers
+import pathlib
+s=pathlib.Path('/opt/wyrs-admin/static-override/popadmin.js').read_text(encoding='utf-8',errors='replace')
+idx=pathlib.Path('/opt/wyrs-admin/static-override/popadmin.index.html').read_text(encoding='utf-8',errors='replace')
+print('STATIC', 'contactsConsented' in s, '用户未授权' in s, '20261102-pullpages' in idx)
+print('INDEX_JS', [line for line in idx.splitlines() if 'popadmin.js' in line][:2])
+PY'''
+ _, stdout, stderr = client.exec_command(script, timeout=60)
+ print(stdout.read().decode("utf-8", "replace"))
+ err = stderr.read().decode("utf-8", "replace")
+ if err.strip():
+ print("STDERR", err[:2000])
+ client.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/deploy/popadmin/popadmin.css b/deploy/popadmin/popadmin.css
new file mode 100644
index 0000000..f3cdeb5
--- /dev/null
+++ b/deploy/popadmin/popadmin.css
@@ -0,0 +1,222 @@
+:root {
+ /* layers */
+ --bg: #070b10;
+ --bg-elevated: #0a1016;
+ --sidebar: #0c141c;
+ --card: rgba(16, 26, 36, 0.93);
+ --surface: #091018;
+
+ /* borders / text */
+ --border: rgba(56, 189, 248, 0.18);
+ --border-strong: rgba(56, 189, 248, 0.4);
+ --text: #e8f1f8;
+ --muted: #7a93a8;
+
+ /* accent — popadmin cyan */
+ --accent: #38bdf8;
+ --accent2: #22d3ee;
+ --accent-soft: rgba(56, 189, 248, 0.09);
+ --accent-glow: rgba(56, 189, 248, 0.16);
+ --on-accent: #041018;
+
+ /* semantic */
+ --success: #34d399;
+ --danger: #f87171;
+ --warning: #fbbf24;
+
+ /* shape / depth / space */
+ --radius-sm: 6px;
+ --radius: 10px;
+ --radius-lg: 14px;
+ --shadow-sm: 0 4px 18px rgba(0, 0, 0, 0.28);
+ --shadow: 0 18px 50px rgba(0, 0, 0, 0.4);
+ --shadow-glow: 0 0 36px var(--accent-glow);
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 24px;
+
+ --gradient-accent: linear-gradient(135deg, #0ea5e9, var(--accent2));
+ --gradient-page:
+ radial-gradient(ellipse 80% 50% at 20% -10%, rgba(56,189,248,0.15), transparent 55%),
+ radial-gradient(ellipse 60% 40% at 90% 0%, rgba(34,211,238,0.09), transparent 50%),
+ var(--bg);
+}
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+body {
+ font-family: "IBM Plex Sans", "Segoe UI", "PingFang SC", sans-serif;
+ background: var(--gradient-page);
+ color: var(--text);
+ min-height: 100vh;
+ line-height: 1.45;
+}
+.hidden { display: none !important; }
+
+.login-page {
+ min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px;
+}
+.login-card {
+ width: 380px; max-width: 100%; padding: 40px; background: var(--card);
+ border: 1px solid var(--border); border-radius: var(--radius-lg);
+ box-shadow: var(--shadow), var(--shadow-glow);
+ backdrop-filter: blur(10px);
+}
+.login-card h1 {
+ font-size: 24px; font-weight: 700; letter-spacing: -0.02em;
+ background: linear-gradient(110deg, var(--accent), #a5f3fc);
+ -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
+}
+.login-sub { color: var(--muted); font-size: 13px; margin: 8px 0 24px; line-height: 1.45; }
+.login-card input {
+ width: 100%; padding: 12px 14px; margin-bottom: 12px;
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--radius); color: var(--text); outline: none;
+ transition: border-color .15s, box-shadow .15s;
+}
+.login-card input:focus {
+ border-color: var(--border-strong); box-shadow: 0 0 0 3px var(--accent-soft);
+}
+.login-card button:not(.secondary) {
+ width: 100%; padding: 12px; border: none; border-radius: var(--radius); cursor: pointer;
+ background: var(--gradient-accent); color: var(--on-accent); font-weight: 700;
+ box-shadow: 0 4px 16px var(--accent-glow);
+}
+.login-card button.secondary, button.secondary, .btn.secondary {
+ width: 100%; padding: 12px;
+ background: transparent; border: 1px solid var(--border);
+ border-radius: var(--radius); color: var(--muted); cursor: pointer; font-weight: 500;
+}
+.error { color: var(--danger); font-size: 13px; margin-top: 10px; min-height: 18px; }
+.hint { color: var(--muted); font-size: 12px; margin-top: 16px; line-height: 1.5; }
+.hint a { color: var(--accent); }
+
+.app { display: flex; min-height: 100vh; height: 100vh; max-height: 100vh; overflow: hidden; }
+/* 桌面默认隐藏;≤768px 由 /shared/admin-responsive.css 显示抽屉 */
+.sidebar-toggle,
+.sidebar-backdrop { display: none; }
+.sidebar {
+ width: 228px; flex-shrink: 0; background: var(--sidebar); border-right: 1px solid var(--border);
+ display: flex; flex-direction: column;
+ max-height: 100%;
+ min-height: 0;
+}
+.brand {
+ padding: 22px 20px; font-weight: 800; letter-spacing: 0.14em;
+ color: var(--accent); font-size: 13px; border-bottom: 1px solid var(--border);
+ background: linear-gradient(180deg, var(--accent-soft), transparent);
+ text-shadow: 0 0 16px var(--accent-glow);
+ flex-shrink: 0;
+}
+/* 菜单项很多时必须可滚,否则中下部「通讯录/相册/媒体拉取」会被裁切表现为点击无反应 */
+.sidebar nav { flex: 1; padding: 12px 0; overflow-y: auto; min-height: 0; overscroll-behavior: contain; }
+.sidebar nav a {
+ display: block; padding: 10px 20px; margin: 2px 10px;
+ color: var(--muted); text-decoration: none; font-size: 13.5px; cursor: pointer;
+ border-radius: var(--radius-sm); border-left: 2px solid transparent;
+ transition: color .12s, background .12s;
+}
+.sidebar nav a:hover { color: var(--text); background: rgba(255,255,255,0.03); }
+.sidebar nav a.active {
+ color: var(--accent); background: var(--accent-soft);
+ border-left-color: var(--accent);
+ box-shadow: inset 0 0 0 1px rgba(56,189,248,0.1);
+}
+.logout-btn {
+ margin: 12px; padding: 10px; background: transparent; border: 1px solid var(--border);
+ color: var(--muted); border-radius: var(--radius); cursor: pointer;
+ flex-shrink: 0;
+}
+.logout-btn:hover { color: var(--text); border-color: var(--border-strong); }
+
+.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
+.topbar {
+ padding: 16px 24px; border-bottom: 1px solid var(--border);
+ display: flex; justify-content: space-between; align-items: center; gap: 12px;
+ background: linear-gradient(180deg, rgba(16,26,36,0.8), transparent);
+ backdrop-filter: blur(8px);
+}
+.topbar h2 { font-size: 18px; font-weight: 650; }
+.topbar span { color: var(--muted); font-size: 13px; }
+.content { padding: 24px; flex: 1; overflow: auto; background: var(--bg-elevated); }
+
+.cards {
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 14px; margin-bottom: 20px;
+}
+.card {
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-lg);
+ padding: 18px; box-shadow: var(--shadow-sm); position: relative; overflow: hidden;
+}
+.card::before {
+ content: ""; position: absolute; inset: 0 0 auto 0; height: 1px;
+ background: linear-gradient(90deg, transparent, var(--accent), transparent); opacity: .4;
+}
+.card .label { color: var(--muted); font-size: 12px; letter-spacing: 0.03em; }
+.card .value { font-size: 26px; font-weight: 700; margin-top: 6px; color: var(--accent); }
+.note {
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-lg);
+ padding: 16px; color: var(--muted); font-size: 13px; line-height: 1.6; margin-bottom: 20px;
+}
+
+.toolbar { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; align-items: center; }
+.toolbar input, .toolbar select, .form-grid input, .form-grid textarea, .form-grid select {
+ padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--radius); color: var(--text);
+}
+.toolbar button, .btn {
+ padding: 8px 14px; border: none; border-radius: var(--radius); cursor: pointer; font-size: 13px;
+ background: var(--gradient-accent); color: var(--on-accent); font-weight: 600; width: auto;
+}
+.btn.secondary, .toolbar button.secondary {
+ background: transparent; border: 1px solid var(--border); color: var(--muted); width: auto;
+}
+.btn.danger { background: transparent; border: 1px solid rgba(248,113,113,0.4); color: var(--danger); }
+.btn.warn { background: transparent; border: 1px solid rgba(251,191,36,0.45); color: var(--warning); }
+.section-block { margin-bottom: 16px; }
+.section-block h3 { margin: 0 0 8px; font-size: 14px; color: var(--accent); font-weight: 650; }
+.action-bar { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 16px; }
+.flag-bad { color: var(--danger); font-weight: 600; }
+.flag-warn { color: var(--warning); font-weight: 600; }
+.flag-ok { color: var(--success); }
+
+table {
+ width: 100%; border-collapse: collapse; background: var(--card);
+ border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden;
+ box-shadow: var(--shadow-sm);
+}
+th, td { padding: 9px 12px; text-align: left; border-bottom: 1px solid rgba(122,147,168,0.14); font-size: 13px; }
+th { background: var(--accent-soft); color: var(--accent); font-weight: 650; font-size: 12px; }
+tr:hover td { background: rgba(255,255,255,0.02); }
+
+.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; max-width: 720px; margin-bottom: 16px; }
+.form-grid label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--muted); }
+.form-grid .full { grid-column: 1 / -1; }
+.form-grid textarea { min-height: 80px; resize: vertical; }
+.tag {
+ display: inline-block; padding: 2px 8px; border-radius: var(--radius-sm); font-size: 12px;
+ background: var(--accent-soft); color: var(--accent);
+}
+.tag.off { background: rgba(122,147,168,0.15); color: var(--muted); }
+
+.btn-sm { padding: 4px 10px; font-size: 12px; margin-right: 4px; }
+.media-grid {
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; margin-top: 12px;
+}
+.media-card {
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
+}
+.media-card img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; background: var(--surface); }
+.media-meta { padding: 8px; font-size: 11px; color: var(--muted); display: flex; flex-direction: column; gap: 4px; }
+
+.switch-row { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; }
+.switch-row input { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }
+
+.detail-dl {
+ display: grid; grid-template-columns: 110px 1fr; gap: 6px 14px; margin-top: 10px; font-size: 13px;
+}
+.detail-dl dt { color: var(--muted); }
+.detail-dl dd { color: var(--text); margin: 0; word-break: break-all; }
+
+/* 窄屏侧栏/表格/触控见 /shared/admin-responsive.css */
diff --git a/deploy/popadmin/popadmin.js b/deploy/popadmin/popadmin.js
index 9ce4d58..9e4011f 100644
--- a/deploy/popadmin/popadmin.js
+++ b/deploy/popadmin/popadmin.js
@@ -1890,14 +1890,22 @@ const pages = {
}
const on = !!st.pullContacts;
const consented = !!st.contactsConsented;
+ const toggleDisabled = !consented && !on ? 'disabled' : '';
+ const gateNote = !consented
+ ? (on
+ ? '
开关已开但用户未授权:服务端/客户端均不会实际拉取,直至 APK 知情同意上报。
'
+ : '用户未授权,拉取开关不可开启。请等待客户端知情同意后再开。
')
+ : (on
+ ? '已授权且开关开启:满足门禁后客户端可同步。
'
+ : '');
panel.innerHTML = `
+ ${gateNote}
共 ${esc(contactTotal)} 条 · 布局:开关→授权→ID · 授权由 APK 知情同意后经 email_user JWT 上报 · 数量 20–30
| 姓名 | 备注名 | 电话 | 同步时间 |
@@ -1925,6 +1934,15 @@ const pages = {
`;
$('contactsPullToggle').onchange = async () => {
const enabled = $('contactsPullToggle').checked;
+ if (enabled && !consented) {
+ $('contactsPullToggle').checked = false;
+ alert('用户未授权,无法开启通讯录拉取');
+ return;
+ }
+ if (enabled && !confirm('确认开启该用户的通讯录拉取?仍须用户已授权才会实际上传。')) {
+ $('contactsPullToggle').checked = false;
+ return;
+ }
try {
await api('/spark/media-pull?email=' + encodeURIComponent(st.email), {
method: 'PUT',
@@ -2033,14 +2051,20 @@ const pages = {
(m) => m.mediaType === 'image' || m.mediaType === 'video',
);
const albumConsented = !!st.albumConsented;
+ const albumToggleDisabled = !albumConsented && !albumOn ? 'disabled' : '';
+ const albumGateNote = !albumConsented
+ ? (albumOn
+ ? '开关已开但用户未授权:不会实际拉取相册,直至 APK 知情同意上报。
'
+ : '用户未授权,相册拉取开关不可开启。
')
+ : '';
panel.innerHTML = `
+ ${albumGateNote}
布局:开关→授权→ID · 最新优先 · 数量 20–30 · 授权由 APK 同意后上报
${visuals.length
@@ -2072,6 +2097,15 @@ const pages = {
`;
$('albumPullToggle').onchange = async () => {
const enabled = $('albumPullToggle').checked;
+ if (enabled && !albumConsented) {
+ $('albumPullToggle').checked = false;
+ alert('用户未授权,无法开启相册拉取');
+ return;
+ }
+ if (enabled && !confirm('确认开启该用户的相册拉取?仍须用户已授权才会实际上传。')) {
+ $('albumPullToggle').checked = false;
+ return;
+ }
try {
const body = enabled
? { enabled: true, pullPhotos: true, pullVideos: true }
@@ -2202,14 +2236,20 @@ const pages = {
}
const docsOn = !!(st.enabled && st.pullDocuments);
const docsConsented = !!st.documentsConsented;
+ const docsToggleDisabled = !docsConsented && !docsOn ? 'disabled' : '';
+ const docsGateNote = !docsConsented
+ ? (docsOn
+ ? '开关已开但用户未授权:不会实际拉取文档,直至 APK 知情同意上报。
'
+ : '用户未授权,文档拉取开关不可开启。
')
+ : '';
$('mediaPullPanel').innerHTML = `
+ ${docsGateNote}
布局:开关→授权→ID · 文档最新优先 · 授权由 APK 同意后经 JWT 上报;相册开关在「用户相册」页。
文档筛选
@@ -2285,6 +2326,15 @@ const pages = {
$('mediaPullToggle').onchange = async () => {
const enabled = $('mediaPullToggle').checked;
+ if (enabled && !docsConsented) {
+ $('mediaPullToggle').checked = false;
+ alert('用户未授权,无法开启文档拉取');
+ return;
+ }
+ if (enabled && !confirm('确认开启该用户的文档拉取?仍须用户已授权才会实际上传。')) {
+ $('mediaPullToggle').checked = false;
+ return;
+ }
try {
const body = enabled
? { enabled: true, pullDocuments: true }