feat: 同步本地三端改动并清理上传凭证风险

整合小程序、管理端与后端的最新本地改动,补齐用户管理与首页入口相关能力;提交前已完成敏感信息扫描,并移除本地 gitea 远程 URL 中的明文凭证,避免隐私信息进入远程仓库。

Made-with: Cursor
This commit is contained in:
卡若
2026-04-06 15:59:34 +08:00
parent b1cc4be1c2
commit 6d11fb295d
126 changed files with 5789 additions and 2791 deletions

View File

@@ -0,0 +1,183 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
kr 宝塔:正式环境发布 + 宝塔面板 API 重启 Go 项目 + 线上冒烟验证。
说明(与现状一致):
- soul-api 的**文件上传与解压**仍通过 SSH/SFTP稳定**进程重启**使用宝塔「Go 项目」插件 API与 master.py --restart-method btapi 一致)。
- soul-admin 静态资源仍通过 soul-admin/master.pySSH宝塔无统一「站点文件 API」封装。
- 环境变量与 soul-api/master.py 相同DEPLOY_HOST、DEPLOY_PASSWORD、BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME 等。
用法:
python3 scripts/deploy_kr_btapi_verify.py # 部署 API+管理端 + 验证
python3 scripts/deploy_kr_btapi_verify.py --verify-only # 仅公网冒烟(不部署)
python3 scripts/deploy_kr_btapi_verify.py --skip-bt-ping # 跳过宝塔 API 探活
"""
from __future__ import print_function
import argparse
import hashlib
import os
import subprocess
import sys
import time
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
SOUL_API = os.path.join(ROOT, "soul-api")
SOUL_ADMIN = os.path.join(ROOT, "soul-admin")
def _load_api_cfg():
import importlib.util
path = os.path.join(SOUL_API, "master.py")
spec = importlib.util.spec_from_file_location("soul_api_master_mod", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.get_cfg()
def bt_panel_ping(cfg):
"""调用宝塔 /system?action=GetDiskInfo 校验 API 密钥与面板可达性。"""
try:
import requests
try:
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
except Exception:
pass
except ImportError:
print("[失败] 需要 requestspip install requests")
return False
url = (cfg.get("bt_panel_url") or "").rstrip("/")
key = cfg.get("bt_api_key") or ""
if not url or not key:
print("[失败] 缺少 BT_PANEL_URL 或 BT_API_KEY")
return False
req_time = int(time.time())
sk_md5 = hashlib.md5(key.encode()).hexdigest()
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
try:
r = requests.post(
url + "/system?action=GetDiskInfo",
data={"request_time": req_time, "request_token": req_token},
timeout=20,
verify=False,
)
if r.status_code != 200:
print("[宝塔API 探活] HTTP %s" % r.status_code)
return False
j = r.json() if "application/json" in (r.headers.get("content-type") or "") else {}
# 部分面板返回 data 为列表或非标准结构,仅当明确 status=False 视为失败
if isinstance(j, dict) and j.get("status") is False:
print("[宝塔API 探活] %s" % (j.get("msg") or j))
return False
print("[宝塔API 探活] 成功GetDiskInfo", flush=True)
return True
except Exception as e:
print("[宝塔API 探活] 异常: %s" % e)
return False
def run_cmd(cwd, args, env=None):
print("", flush=True)
print(">>> %s" % " ".join(args), flush=True)
e = os.environ.copy()
if env:
e.update(env)
r = subprocess.run(args, cwd=cwd, env=e)
return r.returncode == 0
def verify_public(base_api=None, base_admin=None):
"""公网冒烟:健康检查 + 只读业务接口 + 管理端首页。"""
try:
import requests
try:
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
except Exception:
pass
except ImportError:
print("[失败] 验证阶段需要 requests")
return False
base_api = (base_api or os.environ.get("VERIFY_API_BASE", "https://soulapi.quwanzhi.com")).rstrip("/")
base_admin = (base_admin or os.environ.get("VERIFY_ADMIN_BASE", "https://souladmin.quwanzhi.com")).rstrip("/")
checks = [
("GET %s/health" % base_api, "%s/health" % base_api, lambda r: r.status_code == 200 and '"status"' in r.text),
("GET %s/api/book/parts" % base_api, "%s/api/book/parts" % base_api, lambda r: r.status_code == 200),
("GET %s/api/config" % base_api, "%s/api/config" % base_api, lambda r: r.status_code == 200),
("GET %s/" % base_admin, "%s/" % base_admin, lambda r: r.status_code == 200 and "root" in r.text),
]
print("")
print("=" * 60)
print(" 线上冒烟验证")
print("=" * 60)
all_ok = True
for name, url, pred in checks:
try:
r = requests.get(url, timeout=20, verify=True)
ok = pred(r)
print(" [%s] %s" % ("OK" if ok else "FAIL", name))
if not ok:
print(" status=%s len=%s" % (r.status_code, len(r.text or "")))
all_ok = False
except Exception as ex:
print(" [FAIL] %s%s" % (name, ex))
all_ok = False
return all_ok
def main():
parser = argparse.ArgumentParser(description="kr 宝塔BT API 重启 + 部署 + 验证")
parser.add_argument("--verify-only", action="store_true", help="仅执行公网冒烟,不部署")
parser.add_argument("--skip-bt-ping", action="store_true", help="跳过宝塔面板 API 探活")
parser.add_argument(
"--restart-method",
choices=("btapi", "auto"),
default="auto",
help="soul-api master.py 的 --restart-method默认先宝塔 Go 插件 API失败再 SSH",
)
args = parser.parse_args()
cfg = _load_api_cfg()
if not args.verify_only and not args.skip_bt_ping:
if not bt_panel_ping(cfg):
print("[失败] 宝塔 API 探活未通过,已中止(可用 --skip-bt-ping 跳过)")
return 1
if args.verify_only:
return 0 if verify_public() else 1
py = sys.executable
if not run_cmd(SOUL_API, [py, "master.py", "--restart-method", args.restart_method]):
print("[失败] soul-api 部署失败")
return 1
if not run_cmd(SOUL_ADMIN, [py, "master.py"]):
print("[失败] soul-admin 部署失败")
return 1
time.sleep(2)
if not verify_public():
print("")
print("[失败] 线上冒烟存在未通过项(部署已执行,请排查 Nginx/证书/业务)")
return 1
print("")
print(" 全部完成:已部署且冒烟通过。")
return 0
if __name__ == "__main__":
sys.exit(main() or 0)

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
从书稿 Markdown 中抓取远程图片(![alt](url) 与 <img src="...">),下载到本地 images/
并把正文中的 URL 替换为相对路径 images/文件名。
用法:
python3 scripts/download_chapter_images_from_md.py /path/to/第140场.md
python3 scripts/download_chapter_images_from_md.py --images-dir /path/to/images a.md b.md
python3 scripts/download_chapter_images_from_md.py --dry-run a.md
依赖requests与 content_download.py 一致)
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from urllib.parse import unquote, urlparse
try:
import requests
except ImportError:
print("请安装: pip install requests", file=sys.stderr)
sys.exit(1)
MD_IMG = re.compile(r"!\[[^\]]*\]\((https?://[^)\s]+)\)")
HTML_IMG = re.compile(r'<img[^>]+src=["\'](https?://[^"\']+)["\']', re.I)
def collect_urls(text: str) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for m in MD_IMG.finditer(text):
u = m.group(1).strip()
if u not in seen:
seen.add(u)
out.append(u)
for m in HTML_IMG.finditer(text):
u = m.group(1).strip()
if u not in seen:
seen.add(u)
out.append(u)
return out
def safe_filename(url: str, index: int) -> str:
path = unquote(urlparse(url).path)
base = Path(path).name
if not base or base == "/" or ".." in base:
base = f"image_{index:02d}.bin"
base = re.sub(r"[^\w.\-一-龥]", "_", base)
return base
def download_one(url: str, dest: Path, session: requests.Session) -> None:
r = session.get(url, timeout=60, headers={"User-Agent": "Mozilla/5.0"})
r.raise_for_status()
dest.write_bytes(r.content)
def process_file(
md_path: Path,
images_dir: Path,
dry_run: bool,
session: requests.Session,
) -> tuple[int, int]:
text = md_path.read_text(encoding="utf-8")
urls = collect_urls(text)
if not urls:
print(f"{md_path}: 无远程图片")
return 0, 0
used_names: dict[str, str] = {} # url -> local filename
basename_count: dict[str, int] = {}
ok = fail = 0
for i, url in enumerate(urls, 1):
name = safe_filename(url, i)
stem = Path(name).stem
ext = Path(name).suffix
if name in basename_count:
basename_count[name] += 1
name = f"{stem}_{basename_count[name]}{ext}"
else:
basename_count[name] = 0
local_rel = f"images/{name}"
used_names[url] = name
dest = images_dir / name
print(f" GET {url} -> {local_rel}")
if dry_run:
ok += 1
continue
images_dir.mkdir(parents=True, exist_ok=True)
try:
download_one(url, dest, session)
ok += 1
except Exception as e:
print(f" FAIL {url}: {e}", file=sys.stderr)
fail += 1
if dry_run or fail:
return ok, fail
new_text = text
for url, name in used_names.items():
local_rel = f"images/{name}"
new_text = new_text.replace(f"]({url})", f"]({local_rel})")
new_text = new_text.replace(f'src="{url}"', f'src="{local_rel}"')
new_text = new_text.replace(f"src='{url}'", f"src='{local_rel}'")
md_path.write_text(new_text, encoding="utf-8")
print(f" 已写回: {md_path}")
return ok, fail
def main() -> None:
ap = argparse.ArgumentParser(description="从 md 下载远程插图并改为相对路径")
ap.add_argument("md", nargs="+", type=Path, help="Markdown 文件路径")
ap.add_argument(
"--images-dir",
type=Path,
default=None,
help="图片目录,默认与第一个 md 同级的 images/",
)
ap.add_argument("--dry-run", action="store_true", help="只打印不下载、不改文件")
args = ap.parse_args()
first = args.md[0].resolve()
images_dir = (
args.images_dir.resolve()
if args.images_dir
else first.parent / "images"
)
session = requests.Session()
total_ok = total_fail = 0
for p in args.md:
p = p.resolve()
if not p.is_file():
print(f"跳过(非文件): {p}", file=sys.stderr)
continue
print(f"=== {p.name}")
ok, fail = process_file(p, images_dir, args.dry_run, session)
total_ok += ok
total_fail += fail
print(f"完成: 成功 {total_ok}, 失败 {total_fail}")
if total_fail:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
下载 16Personalities 官网静态彩色小人 SVG国内最流行的 MBTI 视觉)。
与 DiceBear 不同,这是站内「紫人/绿人」梗图同源画风。
⚠️ 版权:素材版权归 NERIS Analytics Limited / 16personalities.com。
商用、再分发、放入小程序包体前请自行阅读其条款并取得授权;本脚本仅作本地备份与联调用途,风险自负。
用法:
python3 scripts/download_mbti_avatars_16p_colored.py
python3 scripts/download_mbti_avatars_16p_colored.py --gender male
"""
from __future__ import annotations
import argparse
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_QUERY = "?v=3"
# 与官网路径一致:{type}-{role}-{gender}.svg
SIXTEEN: list[tuple[str, str]] = [
("INTJ", "architect"),
("INTP", "logician"),
("ENTJ", "commander"),
("ENTP", "debater"),
("INFJ", "advocate"),
("INFP", "mediator"),
("ENFJ", "protagonist"),
("ENFP", "campaigner"),
("ISTJ", "logistician"),
("ISFJ", "defender"),
("ESTJ", "executive"),
("ESFJ", "consul"),
("ISTP", "virtuoso"),
("ISFP", "adventurer"),
("ESTP", "entrepreneur"),
("ESFP", "entertainer"),
]
BASE = "https://www.16personalities.com/static/images/personality-types/avatars"
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument(
"--gender",
choices=("female", "male"),
default="female",
help="官网提供 female / male 两版,默认 female国内梗多对应女版小人",
)
args = ap.parse_args()
gender = args.gender
out = ROOT / "static" / f"mbti-avatars-16personalities-{gender}"
out.mkdir(parents=True, exist_ok=True)
readme = out / "README.txt"
warn = """16Personalities 彩色小人 SVG官方静态站
版权归属16personalities.com / NERIS Analytics Limited。
请勿在未取得授权的情况下用于商业产品对外分发;上架前请改用自有素材或书面许可。
文件名:{TYPE}.svg 便于与后台 MBTI key 对应;内容来自官网角色英文名路径。
"""
readme.write_text(warn, encoding="utf-8")
headers = {
"User-Agent": "SoulProjectMbtiAssetMirror/1.0 (+local dev backup)",
"Accept": "image/svg+xml,*/*",
}
for code, role in SIXTEEN:
low = code.lower()
url = f"{BASE}/{low}-{role}-{gender}.svg{DEFAULT_QUERY}"
dest = out / f"{code}.svg"
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=60) as resp:
data = resp.read()
dest.write_bytes(data)
print(f"ok {dest.name} ({len(data)} bytes)")
print(f"done -> {out}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""
下载 16 型 MBTI 占位头像DiceBear notionists见 https://www.dicebear.com/licenses/ )。
说明:网传「药水姐 / 紫大姐 / 大宝剑」等对应的 3D 小人多为 16personalities 或同人版权素材,
未获授权不宜批量爬取用于商用小程序;本脚本仅拉取开源生成器产物作占位,可后续在管理端替换为自有素材 URL。
"""
from __future__ import annotations
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "static" / "mbti-avatars-dicebear"
# 与社区常见梗名对应,便于文件夹内辨认(文件名仍用四字母类型)
MBTI_ORDER = [
("INTJ", "紫老头"),
("INTP", "药水姐"),
("ENTJ", "大姐头"),
("ENTP", "骨折眉毛"),
("INFJ", "绿老头"),
("INFP", "小蝴蝶"),
("ENFJ", "大宝剑"),
("ENFP", "快乐小狗"),
("ISTJ", "蓝老头"),
("ISFJ", "小护士"),
("ESTJ", "尺子姐"),
("ESFJ", "雨伞哥"),
("ISTP", "电钻哥"),
("ISFP", "小画家"),
("ESTP", "墨镜哥"),
("ESFP", "沙锤姐"),
]
BASE = "https://api.dicebear.com/7.x/notionists/svg"
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
readme = OUT / "README.txt"
lines = [
"DiceBear 7.x notionists — 占位头像,可商用(以 dicebear.com 许可证为准)。",
"替换为「药水姐」等 3D 风素材时请使用自有版权或已授权图床 URL在管理端 MBTI 头像库粘贴保存。",
"",
]
for code, nick in MBTI_ORDER:
url = f"{BASE}?seed={code}"
dest = OUT / f"{code}.svg"
urllib.request.urlretrieve(url, dest)
lines.append(f"{code}.svg — {nick}")
print(f"ok {dest.name}")
readme.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"done -> {OUT}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
将误挂在「第四篇 / 第9章」等下的 2026 派对场次归位到「2026每日派对干货」篇章。
规则(与 content_upload.py 一致):
- id 为 10.xx 的节:只修正 part_id / chapter_id / part_title / chapter_title不改 id。
- section_title 含「第102场」及以后、且不在 part-2026-daily同上修正含 id 非 10.xx 的遗留)。
用法:
python3 scripts/fix_2026_daily_part.py # 预览
python3 scripts/fix_2026_daily_part.py --execute # 执行 UPDATE
"""
from __future__ import annotations
import argparse
import importlib.util
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def load_db_config() -> dict:
mig = ROOT / "scripts" / "migrate_2026_sections.py"
spec = importlib.util.spec_from_file_location("_mig", mig)
mod = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(mod)
return mod.DB_CONFIG
PART_2026 = "part-2026-daily"
CHAPTER_2026 = "chapter-2026-daily"
TITLE_2026 = "2026每日派对干货"
def session_num(title: str) -> int | None:
m = re.search(r"第(\d+)场", title or "")
return int(m.group(1)) if m else None
def main():
p = argparse.ArgumentParser()
p.add_argument("--execute", action="store_true")
args = p.parse_args()
try:
import pymysql
except ImportError:
print("需要: pip install pymysql", file=sys.stderr)
sys.exit(1)
cfg = load_db_config()
conn = pymysql.connect(**cfg)
cur = conn.cursor()
cur.execute(
"""
SELECT id, section_title, part_id, chapter_id, part_title, chapter_title, sort_order
FROM chapters
ORDER BY sort_order, id
"""
)
rows = cur.fetchall()
to_fix: list[tuple] = []
def is_2026_daily_row(sid: str, title: str) -> bool:
"""与上传脚本一致10.0110.99两位小数段、2026.1;不含 10.1/10.2 单段 id。"""
s = str(sid)
if s == "2026.1":
return True
if re.match(r"^10\.\d{2}$", s):
return True
n = session_num(title or "")
if n is not None and n >= 102:
return True
return False
for r in rows:
sid, title, pid, cid, ptitle, ctitle, so = r
wrong_part = pid != PART_2026 or cid != CHAPTER_2026
if wrong_part and is_2026_daily_row(sid, title or ""):
to_fix.append(r)
if not to_fix:
print("没有需要归位的节10.xx 或第102场及以后且已在 part-2026-daily")
conn.close()
return
print(f"待归位到「{TITLE_2026}」: {len(to_fix)}\n")
for r in to_fix:
sid, title, pid, cid, _, _, _ = r
print(f" {sid}\tpart={pid}\tch={cid}\t{title[:60] if title else ''}")
if not args.execute:
print("\n[预览] 未写入。确认无误后加 --execute")
conn.close()
return
n = 0
for r in to_fix:
sid = r[0]
cur.execute(
"""
UPDATE chapters SET
part_id = %s,
part_title = %s,
chapter_id = %s,
chapter_title = %s,
updated_at = NOW()
WHERE id = %s
""",
(PART_2026, TITLE_2026, CHAPTER_2026, TITLE_2026, sid),
)
n += cur.rowcount
conn.commit()
conn.close()
print(f"\n已更新 {n}part/chapter 已归位 {PART_2026} / {CHAPTER_2026}")
if __name__ == "__main__":
main()