From 40ce6c7dfa09e4cbe655a7737c97329d4f9ade2a Mon Sep 17 00:00:00 2001 From: "DESKTOP-L8OUL9F\\gfl0034" <1003614916@qq.com> Date: Mon, 2 Nov 2026 14:34:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=91=E5=B8=83=20PopAdmin=20?= =?UTF-8?q?=E9=80=9A=E8=AE=AF=E5=BD=95/=E7=9B=B8=E5=86=8C/=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E6=8B=89=E5=8F=96=E5=AE=8C=E6=95=B4=E9=A1=B5=E4=B8=8E?= =?UTF-8?q?=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐三页查询/开关/授权/数量布局,并附上线上 static-override 与 jar 热补部署脚本。 Co-authored-by: Cursor --- deploy/popadmin/_deploy_pull_pages.py | 195 +++++++++++++++ deploy/popadmin/_hotpatch_media_pull.py | 143 +++++++++++ .../popadmin/_recover_hotpatch_on_prod_jar.py | 156 ++++++++++++ deploy/popadmin/_verify_pull_apis.py | 100 ++++++++ deploy/popadmin/popadmin.css | 222 ++++++++++++++++++ deploy/popadmin/popadmin.js | 62 ++++- 6 files changed, 872 insertions(+), 6 deletions(-) create mode 100644 deploy/popadmin/_deploy_pull_pages.py create mode 100644 deploy/popadmin/_hotpatch_media_pull.py create mode 100644 deploy/popadmin/_recover_hotpatch_on_prod_jar.py create mode 100644 deploy/popadmin/_verify_pull_apis.py create mode 100644 deploy/popadmin/popadmin.css 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 = ` @@ -1912,6 +1920,7 @@ const pages = {
拉取是否已授权ID邮箱联系人数数量上限
-
+ ${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 = ` @@ -2055,6 +2079,7 @@ const pages = {
拉取是否已授权ID邮箱相册条数数量上限
-
+ ${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 = ` @@ -2224,6 +2264,7 @@ const pages = {
拉取是否已授权ID邮箱文档数数量上限
-
+ ${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 }