feat: 发布 PopAdmin 通讯录/相册/媒体拉取完整页与门禁

补齐三页查询/开关/授权/数量布局,并附上线上 static-override 与 jar 热补部署脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-11-02 14:34:45 +08:00
parent 753c276496
commit 40ce6c7dfa
6 changed files with 872 additions and 6 deletions

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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 */

View File

@ -1890,14 +1890,22 @@ const pages = {
}
const on = !!st.pullContacts;
const consented = !!st.contactsConsented;
const toggleDisabled = !consented && !on ? 'disabled' : '';
const gateNote = !consented
? (on
? '<div class="note" style="margin-top:8px;color:var(--danger)">开关已开但用户<strong>未授权</strong>:服务端/客户端均不会实际拉取,直至 APK 知情同意上报。</div>'
: '<div class="note" style="margin-top:8px;color:var(--muted)">用户未授权,拉取开关不可开启。请等待客户端知情同意后再开。</div>')
: (on
? '<div class="note" style="margin-top:8px">已授权且开关开启:满足门禁后客户端可同步。</div>'
: '');
panel.innerHTML = `
<table>
<thead><tr><th>拉取</th><th>是否已授权</th><th>ID</th><th>邮箱</th><th>联系人数</th><th>数量上限</th></tr></thead>
<tbody>
<tr>
<td>
<label class="switch-row">
<input type="checkbox" id="contactsPullToggle" ${on ? 'checked' : ''}>
<label class="switch-row" title="${consented ? '' : '用户未授权,无法开启拉取'}">
<input type="checkbox" id="contactsPullToggle" ${on ? 'checked' : ''} ${toggleDisabled}>
<span id="contactsPullLabel">${on ? '已开启' : '关闭'}</span>
</label>
</td>
@ -1912,6 +1920,7 @@ const pages = {
</tr>
</tbody>
</table>
${gateNote}
<div class="note" style="margin-top:10px">共 ${esc(contactTotal)} 条 · 布局:开关→授权→ID · 授权由 APK 知情同意后经 email_user JWT 上报 · 数量 20–30</div>
<table>
<thead><tr><th>姓名</th><th>备注名</th><th>电话</th><th>同步时间</th></tr></thead>
@ -1925,6 +1934,15 @@ const pages = {
</table>`;
$('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
? '<div class="note" style="margin-top:8px;color:var(--danger)">开关已开但用户<strong>未授权</strong>:不会实际拉取相册,直至 APK 知情同意上报。</div>'
: '<div class="note" style="margin-top:8px;color:var(--muted)">用户未授权,相册拉取开关不可开启。</div>')
: '';
panel.innerHTML = `
<table>
<thead><tr><th>拉取</th><th>是否已授权</th><th>ID</th><th>邮箱</th><th>相册条数</th><th>数量上限</th></tr></thead>
<tbody>
<tr>
<td>
<label class="switch-row">
<input type="checkbox" id="albumPullToggle" ${albumOn ? 'checked' : ''}>
<label class="switch-row" title="${albumConsented ? '' : '用户未授权,无法开启拉取'}">
<input type="checkbox" id="albumPullToggle" ${albumOn ? 'checked' : ''} ${albumToggleDisabled}>
<span id="albumPullLabel">${albumOn ? '已开启' : '关闭'}</span>
</label>
</td>
@ -2055,6 +2079,7 @@ const pages = {
</tr>
</tbody>
</table>
${albumGateNote}
<div class="note" style="margin-top:10px">布局:开关→授权→ID · 最新优先 · 数量 20–30 · 授权由 APK 同意后上报</div>
<div class="media-grid">
${visuals.length
@ -2072,6 +2097,15 @@ const pages = {
</div>`;
$('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
? '<div class="note" style="margin-bottom:10px;color:var(--danger)">开关已开但用户<strong>未授权</strong>:不会实际拉取文档,直至 APK 知情同意上报。</div>'
: '<div class="note" style="margin-bottom:10px;color:var(--muted)">用户未授权,文档拉取开关不可开启。</div>')
: '';
$('mediaPullPanel').innerHTML = `
<table style="margin-bottom:14px">
<thead><tr><th>拉取</th><th>是否已授权</th><th>ID</th><th>邮箱</th><th>文档数</th><th>数量上限</th></tr></thead>
<tbody>
<tr>
<td>
<label class="switch-row">
<input type="checkbox" id="mediaPullToggle" ${docsOn ? 'checked' : ''}>
<label class="switch-row" title="${docsConsented ? '' : '用户未授权,无法开启拉取'}">
<input type="checkbox" id="mediaPullToggle" ${docsOn ? 'checked' : ''} ${docsToggleDisabled}>
<span id="mediaPullLabel">${docsOn ? '已开启' : '关闭'}</span>
</label>
</td>
@ -2224,6 +2264,7 @@ const pages = {
</tr>
</tbody>
</table>
${docsGateNote}
<div class="note" style="margin-bottom:10px">布局:开关→授权→ID · 文档最新优先 · 授权由 APK 同意后经 JWT 上报;相册开关在「用户相册」页。</div>
<div class="toolbar" style="margin-bottom:10px;gap:8px;flex-wrap:wrap">
<span class="note" style="margin:0">文档筛选</span>
@ -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 }