Improve WeChat MP replies and ship MindSpace/H5 production updates.
Add WeChat service account routing with sync acks, connectivity tests, and context isolation; document deploy runbooks; and bundle related MindSpace, voice, Plaza, and server gateway changes for production rollout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,49 +0,0 @@
|
||||
# ============================================================
|
||||
# rsync 排除规则:Studio(100) → 105 生产
|
||||
# 在 Studio 上执行,路径相对于 ~/Project/Memind
|
||||
# ============================================================
|
||||
|
||||
# ── 机密配置(105 有自己的 .env,含独立 API_TARGET 等)──
|
||||
.env
|
||||
|
||||
# ── 生产用户数据 ──────────────────────────────────────────
|
||||
# MindSpace:105 经 rclone 挂载共享,不能用 rsync 覆盖
|
||||
# data/mindspace:同上,共享挂载树
|
||||
# users:105 用 /Users/john/Project/Memind/users 空目录占位
|
||||
MindSpace/
|
||||
data/
|
||||
users/
|
||||
|
||||
# ── 运行时临时文件 ────────────────────────────────────────
|
||||
temp/
|
||||
*.pid
|
||||
.h5.pid
|
||||
*.log
|
||||
h5.log
|
||||
|
||||
# ── 构建产物(dist 单独同步;node_modules 在 105 上安装)─
|
||||
node_modules/
|
||||
ops/node_modules/
|
||||
|
||||
# ── 版本控制 ──────────────────────────────────────────────
|
||||
.git/
|
||||
.gitignore
|
||||
.DS_Store
|
||||
|
||||
# ── Studio 本机专用配置(105 不需要)─────────────────────
|
||||
# LaunchAgent 相关脚本只在 Studio(macOS) 有意义
|
||||
scripts/install-*.sh
|
||||
scripts/memind-mac-tunnel.sh
|
||||
scripts/memind-fwd-tunnel.sh
|
||||
# LB 由 Studio 的 Caddy 管理,105 上不需要这些文件
|
||||
scripts/g2-lb.Caddyfile
|
||||
scripts/g2-lb.Caddyfile.active
|
||||
scripts/g2-lb.Caddyfile.bak-*
|
||||
scripts/install-g2-lb.sh
|
||||
# Studio 生产备份不同步到 105
|
||||
server.mjs.bak-*
|
||||
*.bak-20*
|
||||
|
||||
# ── 本地开发专用 ──────────────────────────────────────────
|
||||
.claude/
|
||||
.local/
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env
|
||||
.env.local
|
||||
.git/
|
||||
*.log
|
||||
@@ -1,56 +0,0 @@
|
||||
# ============================================================
|
||||
# rsync 排除规则:本地 dev → Studio(100) 生产
|
||||
# 所有列出的路径均相对于项目根目录
|
||||
# ============================================================
|
||||
|
||||
# ── 机密配置(绝不覆盖,Studio 自己维护)──────────────────
|
||||
.env
|
||||
|
||||
# ── 生产用户数据(绝对不能覆盖)──────────────────────────
|
||||
MindSpace/
|
||||
data/
|
||||
users/
|
||||
|
||||
# ── 运行时临时文件 ────────────────────────────────────────
|
||||
temp/
|
||||
*.pid
|
||||
.h5.pid
|
||||
*.log
|
||||
h5.log
|
||||
|
||||
# ── 构建产物(在 Studio 本地构建,不从 dev 覆盖)─────────
|
||||
node_modules/
|
||||
ops/node_modules/
|
||||
dist/
|
||||
ops/dist/
|
||||
|
||||
# ── 版本控制 ──────────────────────────────────────────────
|
||||
.git/
|
||||
.gitignore
|
||||
.DS_Store
|
||||
|
||||
# ── 本地开发专用(不上生产)──────────────────────────────
|
||||
.claude/
|
||||
.local/
|
||||
|
||||
# ── Studio LaunchAgent 启动脚本(由 plist 直接引用,删除即服务无法重启)──
|
||||
scripts/run-memind-portal-prod.sh
|
||||
scripts/run-plaza-prod.sh
|
||||
scripts/memind-fwd-tunnel.sh
|
||||
scripts/install-prod-services.sh
|
||||
|
||||
# ── 生产运营工具(只在 Studio 上使用,dev 无需保留)──────
|
||||
scripts/compensate-user-billing.mjs
|
||||
scripts/plaza-demo-posts.mjs
|
||||
|
||||
# ── 生产媒体资源(运营上传,dev 仓库不维护)──────────────
|
||||
public/plaza-covers/
|
||||
|
||||
# ── Studio 生产端独立管理的配置(不从 dev 覆盖)──────────
|
||||
# LB 权重配置直接在 Studio 上用 caddy reload 调整
|
||||
scripts/g2-lb.Caddyfile
|
||||
scripts/g2-lb.Caddyfile.active
|
||||
scripts/g2-lb.Caddyfile.bak-*
|
||||
# 生产备份,只在 Studio 存在
|
||||
server.mjs.bak-*
|
||||
*.bak-20*
|
||||
@@ -12,7 +12,7 @@
|
||||
# pnpm deploy:plaza-105 -- --no-restart
|
||||
#
|
||||
# 前置:
|
||||
# 1. DNS: plaza.tkmind.cn A → 120.26.184.105
|
||||
# 1. DNS: plaza.tkmind.cn A → 120.26.184.105(公网入口;部署链路本身由 ssh105(Tailscale) 触达)
|
||||
# 2. H5 已部署:pnpm deploy:105(提供 Plaza API + 登录 + 发布页)
|
||||
# 3. 可选:cp deploy/plaza-105/plaza-105.env.example deploy/plaza-105/plaza-105.env
|
||||
set -euo pipefail
|
||||
@@ -23,7 +23,7 @@ DEPLOY_ENV="${DEPLOY_DIR}/plaza-105.env"
|
||||
EXCLUDE="${ROOT}/scripts/.rsync-exclude-plaza-web"
|
||||
|
||||
PLAZA_APP_DIR="${PLAZA_APP_DIR:-${ROOT}/../tkmind_go/ui/plaza}"
|
||||
PLAZA_DEPLOY_HOST="${PLAZA_DEPLOY_HOST:-root@120.26.184.105}"
|
||||
PLAZA_DEPLOY_HOST="${PLAZA_DEPLOY_HOST:-root@ssh105}"
|
||||
PLAZA_REMOTE_DIR="${PLAZA_REMOTE_DIR:-/root/plaza/web}"
|
||||
PLAZA_PROD_URL="${PLAZA_PROD_URL:-https://plaza.tkmind.cn}"
|
||||
PLAZA_PORT="${PLAZA_PORT:-3002}"
|
||||
|
||||
Executable
+237
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 一键把源码同步到开发机测试目录:Memind / memind_adm / Plaza
|
||||
# 适用:更新基于源码的开发机器环境。goose 不在本流程内。
|
||||
|
||||
HOST="${DEPLOY_HOST:-192.168.1.9}"
|
||||
USER="${DEPLOY_USER:-john}"
|
||||
TARGET="${DEPLOY_USER_HOST:-${USER}@${HOST}}"
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
EXCLUDE_FILE="${ROOT_DIR}/scripts/.rsync-exclude-test-host"
|
||||
|
||||
MEMIND_SRC="${MEMIND_SRC:-${ROOT_DIR}}"
|
||||
ADM_SRC="${ADM_SRC:-${ROOT_DIR}/../memind_adm}"
|
||||
PLAZA_SRC="${PLAZA_SRC:-${ROOT_DIR}/../tkmind_go/ui/plaza}"
|
||||
|
||||
MEMIND_DST="${MEMIND_DST:-/Users/john/PycharmProjects/test/test-memind}"
|
||||
ADM_DST="${ADM_DST:-/Users/john/PycharmProjects/test/test-memindadm}"
|
||||
PLAZA_DST="${PLAZA_DST:-/Users/john/PycharmProjects/test/test-memindplaza}"
|
||||
|
||||
RSYNC_OPTS=(
|
||||
-az
|
||||
--delete
|
||||
--exclude-from="${EXCLUDE_FILE}"
|
||||
)
|
||||
|
||||
DRY_RUN=0
|
||||
CHECK_ONLY=0
|
||||
ONLY_MEMIND=0
|
||||
ONLY_ADM=0
|
||||
ONLY_PLAZA=0
|
||||
SKIP_MV=0
|
||||
AUTO_YES=0
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
用法: ./scripts/deploy-to-test-host.sh [选项]
|
||||
|
||||
目标目录(支持环境变量覆盖)
|
||||
DEPLOY_HOST, DEPLOY_USER, DEPLOY_USER_HOST
|
||||
MEMIND_SRC, ADM_SRC, PLAZA_SRC
|
||||
MEMIND_DST, ADM_DST, PLAZA_DST
|
||||
MEMIND_STATUS_URL, ADMIN_STATUS_URL, PLAZA_URL
|
||||
|
||||
常用选项
|
||||
--only-memind 仅同步 test-memind
|
||||
--only-adm 仅同步 test-memindadm
|
||||
--only-plaza 仅同步 test-memindplaza
|
||||
--skip-delete 同步时不做 --delete(避免误删未排除文件)
|
||||
--dry-run 预览变更,不实际写入
|
||||
--check-only 只做连通性和目录检查,不做 rsync
|
||||
--yes 非交互,自动确认
|
||||
--help 显示帮助
|
||||
|
||||
示例
|
||||
./scripts/deploy-to-test-host.sh --dry-run
|
||||
./scripts/deploy-to-test-host.sh --only-memind --skip-delete
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--only-memind) ONLY_MEMIND=1 ;;
|
||||
--only-adm) ONLY_ADM=1 ;;
|
||||
--only-plaza) ONLY_PLAZA=1 ;;
|
||||
--skip-delete) SKIP_MV=1 ;;
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--check-only) CHECK_ONLY=1 ;;
|
||||
--yes) AUTO_YES=1 ;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "未知参数: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ "${ONLY_MEMIND}" -eq 1 && ("${ONLY_ADM}" -eq 1 || "${ONLY_PLAZA}" -eq 1) ]] ||
|
||||
[[ "${ONLY_ADM}" -eq 1 && "${ONLY_PLAZA}" -eq 1 ]]; then
|
||||
echo "错误: --only-* 只能选一个" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_MV}" -eq 1 ]]; then
|
||||
RSYNC_OPTS=(
|
||||
-az
|
||||
--exclude-from="${EXCLUDE_FILE}"
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ ! -f "${EXCLUDE_FILE}" ]]; then
|
||||
echo "错误: 未找到排除规则文件 ${EXCLUDE_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=12)
|
||||
|
||||
log() { printf '%s\n' "$*"; }
|
||||
err() { printf '%s\n' "$*" >&2; }
|
||||
fail() { err "✗ $*"; exit 1; }
|
||||
ok() { printf '%s\n' "✓ $*"; }
|
||||
|
||||
ssh_to_host() {
|
||||
ssh "${SSH_OPTS[@]}" "${TARGET}" "$*"
|
||||
}
|
||||
|
||||
preflight_local() {
|
||||
local src=$1
|
||||
[[ -d "$src" ]] || fail "本地源目录不存在: $src"
|
||||
[[ -f "$src/.gitignore" ]] || true
|
||||
ok "本地源目录存在: $src"
|
||||
}
|
||||
|
||||
preflight_remote() {
|
||||
local dst=$1
|
||||
local label=$2
|
||||
ssh_to_host "mkdir -p '${dst}'" >/dev/null
|
||||
if ! ssh_to_host "test -w '${dst}'" >/dev/null 2>&1; then
|
||||
fail "远端目标不可写: ${label} -> ${TARGET}:${dst}"
|
||||
fi
|
||||
ok "远端目录可写: ${label} -> ${dst}"
|
||||
}
|
||||
|
||||
ensure_host() {
|
||||
log "检查 SSH: ${TARGET}"
|
||||
ssh_to_host "echo connected" >/dev/null || fail "SSH 连接失败:${TARGET}"
|
||||
ok "SSH 连接成功: ${TARGET}"
|
||||
}
|
||||
|
||||
need_confirm() {
|
||||
local target=$1
|
||||
[[ "${AUTO_YES}" -eq 1 ]] && return 0
|
||||
read -r -p "确认同步 ${target}?[y/N] " input
|
||||
[[ "$input" == "y" || "$input" == "Y" ]]
|
||||
}
|
||||
|
||||
do_sync() {
|
||||
local label=$1
|
||||
local src=$2
|
||||
local dst=$3
|
||||
local flags=("${RSYNC_OPTS[@]}")
|
||||
|
||||
preflight_local "$src"
|
||||
preflight_remote "$dst" "$label"
|
||||
|
||||
if [[ "${DRY_RUN}" -eq 1 ]]; then
|
||||
flags+=(--dry-run --itemize-changes)
|
||||
log "[预览] ${label}: ${src} -> ${TARGET}:${dst}"
|
||||
rsync "${flags[@]}" "${src}/" "${TARGET}:${dst}/" | head -80
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! need_confirm "$label"; then
|
||||
fail "用户取消:${label}"
|
||||
fi
|
||||
|
||||
log "同步中 ${label}: ${src} -> ${TARGET}:${dst}"
|
||||
rsync "${flags[@]}" "${src}/" "${TARGET}:${dst}/"
|
||||
ok "同步完成 ${label}"
|
||||
}
|
||||
|
||||
health_check() {
|
||||
local label=$1
|
||||
local url=$2
|
||||
|
||||
if [[ -z "${url}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ssh_to_host "command -v curl >/dev/null && curl -fsS '${url}' >/dev/null" >/dev/null 2>&1; then
|
||||
ok "${label} 健康检查通过:${url}"
|
||||
else
|
||||
log "⚠ ${label} 健康检查失败:${url}(仅提示)"
|
||||
fi
|
||||
}
|
||||
|
||||
export MEMIND_STATUS_URL="${MEMIND_STATUS_URL:-http://127.0.0.1:18081/api/status}"
|
||||
export ADMIN_STATUS_URL="${ADMIN_STATUS_URL:-http://127.0.0.1:18082/healthz}"
|
||||
export PLAZA_URL="${PLAZA_URL:-http://127.0.0.1:13001/plaza}"
|
||||
|
||||
ensure_host
|
||||
|
||||
if [[ "${CHECK_ONLY}" -eq 1 ]]; then
|
||||
preflight_remote "${MEMIND_DST}" "Memind"
|
||||
preflight_remote "${ADM_DST}" "memind_adm"
|
||||
preflight_remote "${PLAZA_DST}" "Plaza"
|
||||
ok "检查通过:远端目录可用"
|
||||
health_check "Memind" "${MEMIND_STATUS_URL}"
|
||||
health_check "memind_adm" "${ADMIN_STATUS_URL}"
|
||||
health_check "Plaza" "${PLAZA_URL}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TARGETS=()
|
||||
if [[ "${ONLY_MEMIND}" -eq 1 ]]; then
|
||||
TARGETS+=("memind")
|
||||
elif [[ "${ONLY_ADM}" -eq 1 ]]; then
|
||||
TARGETS+=("adm")
|
||||
elif [[ "${ONLY_PLAZA}" -eq 1 ]]; then
|
||||
TARGETS+=("plaza")
|
||||
else
|
||||
TARGETS+=("memind" "adm" "plaza")
|
||||
fi
|
||||
|
||||
for t in "${TARGETS[@]}"; do
|
||||
case "$t" in
|
||||
memind)
|
||||
do_sync "Memind" "${MEMIND_SRC}" "${MEMIND_DST}"
|
||||
;;
|
||||
adm)
|
||||
do_sync "memind_adm" "${ADM_SRC}" "${ADM_DST}"
|
||||
;;
|
||||
plaza)
|
||||
do_sync "Plaza" "${PLAZA_SRC}" "${PLAZA_DST}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
log "同步完成。建议检查:"
|
||||
log " Memind: ${MEMIND_STATUS_URL}"
|
||||
log " memind_adm: ${ADMIN_STATUS_URL}"
|
||||
log " Plaza: ${PLAZA_URL}"
|
||||
|
||||
echo "\n可在远端执行 restart(不包含 goose)"
|
||||
echo " ssh ${TARGET}"
|
||||
echo " cd ${MEMIND_DST} && pnpm install && pnpm dev:server 或 pm2 reload memind"
|
||||
echo " cd ${ADM_DST} && pnpm install && pnpm start:??"
|
||||
echo " cd ${PLAZA_DST} && pnpm install && pnpm dev:plaza"
|
||||
|
||||
echo ""
|
||||
echo "如果需要,我也可以把 restart 命令固定成你们当前启动脚架构(例如 launchctl/systemd/pm2/nodemon)。"
|
||||
@@ -0,0 +1,8 @@
|
||||
# 阿里云 DNS API(RAM 子账号,权限:AliyunDNSFullAccess 或仅 DNS 读写)
|
||||
# 复制为 g2-aliyun-cert.env 后填入真实值,勿提交 git
|
||||
Ali_Key=YOUR_ALIYUN_ACCESS_KEY_ID
|
||||
Ali_Secret=YOUR_ALIYUN_ACCESS_KEY_SECRET
|
||||
|
||||
# 可选:证书与 DNS 域名
|
||||
G2_DOMAIN=g2.tkmind.cn
|
||||
G2_PUBLIC_IP=58.38.22.103
|
||||
@@ -1,18 +0,0 @@
|
||||
# g2.tkmind.cn 负载均衡:Studio 本机 H5 (:8081) 主 + 105 portal (经正向隧道 :18080) 灰度副
|
||||
# 权重 19:1 ≈ 95% / 5%;active 健康检查 /api/status,挂掉自动摘除
|
||||
:8090 {
|
||||
reverse_proxy {
|
||||
to 127.0.0.1:8081 127.0.0.1:18080
|
||||
lb_policy weighted_round_robin 19 1
|
||||
header_down +X-Memind-Upstream {http.reverse_proxy.upstream.hostport}
|
||||
health_uri /api/status
|
||||
health_interval 10s
|
||||
health_timeout 5s
|
||||
health_status 200
|
||||
flush_interval -1
|
||||
transport http {
|
||||
read_timeout 0
|
||||
write_timeout 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
# g2.tkmind.cn 本地发布:仅本机 Mac H5 (:8081),不经过 105
|
||||
:8090 {
|
||||
reverse_proxy 127.0.0.1:8081 {
|
||||
flush_interval -1
|
||||
transport http {
|
||||
read_timeout 0
|
||||
write_timeout 0
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# g2.tkmind.cn:Let's Encrypt + 阿里云 DNS-01,供 Caddy :8444 使用
|
||||
#
|
||||
# 前置(阿里云控制台):
|
||||
# 1. 云解析 DNS 添加域名 tkmind.cn,NS 改到阿里云(见脚本末尾说明)
|
||||
# 2. 添加 A 记录:g2 -> 58.38.22.103
|
||||
# 3. RAM 创建 AccessKey,权限 AliyunDNSFullAccess
|
||||
# 4. cp scripts/g2-aliyun-cert.env.example scripts/g2-aliyun-cert.env 并填入密钥
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/install-g2-aliyun-cert.sh
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
ENV_FILE="${ROOT}/scripts/g2-aliyun-cert.env"
|
||||
CADDYFILE="${ROOT}/scripts/g2-lb.Caddyfile"
|
||||
CADDY="/opt/homebrew/bin/caddy"
|
||||
ACME="${HOME}/.acme.sh/acme.sh"
|
||||
CERT_DIR="${HOME}/Downloads/g2.tkmind.cn-certs"
|
||||
DOMAIN="${G2_DOMAIN:-g2.tkmind.cn}"
|
||||
GUI="gui/$(id -u)"
|
||||
|
||||
die() { echo "错误: $*" >&2; exit 1; }
|
||||
|
||||
[[ -f "$ENV_FILE" ]] || die "请先创建 ${ENV_FILE}(参考 g2-aliyun-cert.env.example)"
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
[[ -n "${Ali_Key:-}" && "${Ali_Key}" != YOUR_* ]] || die "请在 ${ENV_FILE} 填入 Ali_Key"
|
||||
[[ -n "${Ali_Secret:-}" && "${Ali_Secret}" != YOUR_* ]] || die "请在 ${ENV_FILE} 填入 Ali_Secret"
|
||||
DOMAIN="${G2_DOMAIN:-g2.tkmind.cn}"
|
||||
|
||||
[[ -x "$ACME" ]] || die "未找到 acme.sh,请先安装: curl https://get.acme.sh | sh"
|
||||
|
||||
export Ali_Key Ali_Secret
|
||||
|
||||
echo "==> 签发证书(DNS-01 / 阿里云): ${DOMAIN}"
|
||||
"$ACME" --set-default-ca --server letsencrypt
|
||||
"$ACME" --issue --dns dns_ali -d "$DOMAIN" --keylength ec-256
|
||||
|
||||
CERT_HOME="${HOME}/.acme.sh/${DOMAIN}_ecc"
|
||||
[[ -d "${HOME}/.acme.sh/${DOMAIN}_ecc" ]] && CERT_HOME="${HOME}/.acme.sh/${DOMAIN}_ecc"
|
||||
[[ -d "${HOME}/.acme.sh/${DOMAIN}" ]] && CERT_HOME="${HOME}/.acme.sh/${DOMAIN}"
|
||||
[[ -f "${CERT_HOME}/fullchain.cer" ]] || die "证书签发失败,请检查阿里云 DNS 权限与域名是否在云解析"
|
||||
|
||||
echo "==> 配置 Caddy 使用证书"
|
||||
cp "$CADDYFILE" "${CADDYFILE}.bak-$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
python3 <<PY
|
||||
from pathlib import Path
|
||||
path = Path("${CADDYFILE}")
|
||||
text = path.read_text()
|
||||
cert = "${CERT_HOME}/fullchain.cer"
|
||||
key = "${CERT_HOME}/${DOMAIN}.key"
|
||||
block = f'''# g2.tkmind.cn:公网入口转发到 Studio 本机 H5 (:8081)
|
||||
{DOMAIN} {{
|
||||
\ttls {cert} {key}
|
||||
|
||||
\treverse_proxy 127.0.0.1:8081 {{'''
|
||||
import re
|
||||
text = re.sub(
|
||||
r'# g2\.tkmind\.cn:.*?\n' + re.escape("${DOMAIN}") + r' \{.*?\n\treverse_proxy 127\.0\.0\.1:8081 \{',
|
||||
block,
|
||||
text,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
path.write_text(text)
|
||||
PY
|
||||
|
||||
"$CADDY" validate --config "$CADDYFILE"
|
||||
|
||||
echo "==> 安装续期后自动 reload Caddy"
|
||||
RELOAD="launchctl kickstart -k ${GUI}/cn.tkmind.g2-lb"
|
||||
"$ACME" --install-cert -d "$DOMAIN" \
|
||||
--reloadcmd "$RELOAD"
|
||||
|
||||
launchctl kickstart -k "${GUI}/cn.tkmind.g2-lb" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
mkdir -p "$CERT_DIR"
|
||||
cp "${CERT_HOME}/fullchain.cer" "${CERT_DIR}/fullchain.pem"
|
||||
cp "${CERT_HOME}/${DOMAIN}.key" "${CERT_DIR}/${DOMAIN}.key"
|
||||
cp "${CERT_HOME}/fullchain.cer" "${CERT_DIR}/g2.tkmind.cn.crt"
|
||||
openssl x509 -in "${CERT_DIR}/fullchain.pem" -noout -subject -issuer -dates
|
||||
|
||||
echo ""
|
||||
echo "==> 完成"
|
||||
echo " 证书: ${CERT_HOME}/"
|
||||
echo " 导出: ${CERT_DIR}/"
|
||||
echo " 验证: curl -s https://${DOMAIN}/api/status"
|
||||
echo ""
|
||||
echo "==> 阿里云 DNS 迁移检查清单"
|
||||
echo " 1. 云解析 DNS 已添加 tkmind.cn"
|
||||
echo " 2. 域名注册商 NS 已改为阿里云(例如 vip1.alidns.com / vip2.alidns.com)"
|
||||
echo " 3. A 记录 g2 -> ${G2_PUBLIC_IP:-58.38.22.103}"
|
||||
echo " 4. 删除 Cloudflare 上 g2 的 CNAME/代理记录,避免冲突"
|
||||
echo " 5. dig @223.5.5.5 +short ${DOMAIN} A 应返回 ${G2_PUBLIC_IP:-58.38.22.103}"
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 下线 john 账户多余的 Plaza 演示公开页,释放发布配额。
|
||||
* 用法:node scripts/offline-john-demo-pages.mjs [--keep=N]
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { createPublicationService } from '../mindspace-publications.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
loadEnvFile(path.join(root, '../../.env.local'));
|
||||
|
||||
const keepCount = Number(process.argv.find((arg) => arg.startsWith('--keep='))?.split('=')[1] ?? 3);
|
||||
|
||||
const pool = createDbPool();
|
||||
const publications = createPublicationService(pool);
|
||||
|
||||
const [users] = await pool.query(
|
||||
`SELECT id FROM h5_users WHERE username = 'john' LIMIT 1`,
|
||||
);
|
||||
const userId = users[0]?.id;
|
||||
if (!userId) {
|
||||
console.error('未找到 john 用户');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, url_slug, published_at
|
||||
FROM h5_publish_records
|
||||
WHERE user_id = ? AND status = 'online' AND url_slug LIKE 'demo-%'
|
||||
ORDER BY published_at DESC`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
const keep = rows.slice(0, keepCount);
|
||||
const offline = rows.slice(keepCount);
|
||||
|
||||
if (offline.length === 0) {
|
||||
console.log(`当前 demo 在线页 ${rows.length} 个,无需下线(保留 ${keepCount} 个)。`);
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`保留 ${keep.length} 个 demo 页,下线 ${offline.length} 个…`);
|
||||
for (const row of offline) {
|
||||
await publications.offline(userId, row.id);
|
||||
console.log(`✓ 已下线 ${row.url_slug}`);
|
||||
}
|
||||
|
||||
const [usage] = await pool.query(
|
||||
`SELECT COUNT(DISTINCT page_id) AS public_page_used
|
||||
FROM h5_publish_records WHERE user_id = ? AND status = 'online'`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
console.log(`\n完成:john 当前在线公开页 ${usage[0]?.public_page_used ?? 0} 个。`);
|
||||
await pool.end();
|
||||
@@ -14,6 +14,8 @@ fi
|
||||
|
||||
export NODE_ENV=production
|
||||
export H5_PORT="${H5_PORT:-8081}"
|
||||
export TKMIND_API_TARGET="${TKMIND_API_TARGET_OVERRIDE:-https://127.0.0.1:18006}"
|
||||
export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1_OVERRIDE:-https://127.0.0.1:18007}"
|
||||
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
if [[ ! -x "${NODE_BIN}" ]]; then
|
||||
|
||||
@@ -35,8 +35,12 @@ const sourceUrl =
|
||||
process.env.SYNC_SOURCE_DATABASE_URL ?? 'mysql://boot:password@127.0.0.1:3306/tkmind';
|
||||
const targetUrl =
|
||||
process.env.SYNC_TARGET_DATABASE_URL ??
|
||||
process.env.PLAZA_PROD_DATABASE_URL ??
|
||||
'mysql://boot:%40Abc888888@rm-uf6h1j53vtuxi78i90o.mysql.rds.aliyuncs.com:3306/goose';
|
||||
process.env.PLAZA_PROD_DATABASE_URL;
|
||||
|
||||
if (!targetUrl) {
|
||||
console.error('缺少 SYNC_TARGET_DATABASE_URL 或 PLAZA_PROD_DATABASE_URL');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** RDS wins — do not overwrite from local. */
|
||||
const KEEP_TARGET = new Set(['h5_publication_views']);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* Env:
|
||||
* DATABASE_URL local source (default from .env)
|
||||
* PLAZA_PROD_DATABASE_URL production MySQL (required)
|
||||
* PLAZA_PROD_SSH e.g. root@120.26.184.105
|
||||
* PLAZA_PROD_SSH e.g. root@ssh105
|
||||
* MINDSPACE_STORAGE_ROOT local storage (default data/mindspace)
|
||||
* PLAZA_PROD_STORAGE_ROOT remote storage (default .../h5/data/mindspace)
|
||||
*/
|
||||
@@ -42,10 +42,8 @@ loadEnvFile(path.join(root, '../../.env.local'));
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
|
||||
const sourceUrl = process.env.DATABASE_URL;
|
||||
const targetUrl =
|
||||
process.env.PLAZA_PROD_DATABASE_URL ??
|
||||
'mysql://boot:%40Abc888888@rm-uf6h1j53vtuxi78i90o.mysql.rds.aliyuncs.com:3306/goose';
|
||||
const prodSsh = process.env.PLAZA_PROD_SSH ?? 'root@120.26.184.105';
|
||||
const targetUrl = process.env.PLAZA_PROD_DATABASE_URL;
|
||||
const prodSsh = process.env.PLAZA_PROD_SSH ?? 'root@ssh105';
|
||||
const localStorageRoot = path.resolve(
|
||||
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(root, 'data', 'mindspace'),
|
||||
);
|
||||
@@ -56,6 +54,10 @@ if (!sourceUrl) {
|
||||
console.error('缺少 DATABASE_URL');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!targetUrl) {
|
||||
console.error('缺少 PLAZA_PROD_DATABASE_URL');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function quoteId(value) {
|
||||
return mysql.escape(value);
|
||||
|
||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
EXCLUDE="${ROOT}/scripts/.rsync-exclude-105"
|
||||
HOST="${H5_DEPLOY_HOST:-root@120.26.184.105}"
|
||||
HOST="${H5_DEPLOY_HOST:-root@ssh105}"
|
||||
REMOTE="${H5_REMOTE_DIR:-/root/tkmind_go/ui/h5}"
|
||||
SERVICE="${H5_SYSTEMD_SERVICE:-goose-h5}"
|
||||
|
||||
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
ALLOWED_PATHS = {
|
||||
"/cgi-bin/stable_token",
|
||||
"/cgi-bin/message/custom/send",
|
||||
"/cgi-bin/menu/create",
|
||||
"/cgi-bin/menu/get",
|
||||
"/cgi-bin/ticket/getticket",
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/healthz":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"ok")
|
||||
return
|
||||
self._proxy("GET")
|
||||
|
||||
def do_POST(self):
|
||||
self._proxy("POST")
|
||||
|
||||
def _proxy(self, method):
|
||||
parsed = urllib.parse.urlsplit(self.path)
|
||||
if parsed.path not in ALLOWED_PATHS:
|
||||
self.send_error(403)
|
||||
return
|
||||
|
||||
length = int(self.headers.get("Content-Length", "0")) if method == "POST" else 0
|
||||
body = self.rfile.read(length) if length else None
|
||||
url = "https://api.weixin.qq.com" + self.path
|
||||
headers = {
|
||||
"Content-Type": self.headers.get("Content-Type", "application/json"),
|
||||
}
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
payload = resp.read()
|
||||
self.send_response(resp.status)
|
||||
self.send_header("Content-Type", resp.headers.get("Content-Type", "application/json"))
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
except urllib.error.HTTPError as err:
|
||||
payload = err.read()
|
||||
self.send_response(err.code)
|
||||
self.send_header("Content-Type", err.headers.get("Content-Type", "application/json"))
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = os.environ.get("WECHAT_EGRESS_HOST", "127.0.0.1")
|
||||
port = int(os.environ.get("WECHAT_EGRESS_PORT", "19090"))
|
||||
HTTPServer((host, port), Handler).serve_forever()
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SSH_HOST="${WECHAT_EGRESS_SSH_HOST:-ssh105}"
|
||||
LOCAL_PORT="${WECHAT_EGRESS_LOCAL_PORT:-19090}"
|
||||
REMOTE_HOST="${WECHAT_EGRESS_REMOTE_HOST:-127.0.0.1}"
|
||||
REMOTE_PORT="${WECHAT_EGRESS_REMOTE_PORT:-19090}"
|
||||
|
||||
exec ssh \
|
||||
-N \
|
||||
-o ExitOnForwardFailure=yes \
|
||||
-o ServerAliveInterval=30 \
|
||||
-o ServerAliveCountMax=3 \
|
||||
-L "127.0.0.1:${LOCAL_PORT}:${REMOTE_HOST}:${REMOTE_PORT}" \
|
||||
"${SSH_HOST}"
|
||||
Executable
+126
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
loadH5Environment(__dirname);
|
||||
|
||||
const DEFAULT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
|
||||
const DEFAULT_MENU_CREATE_URL = 'https://api.weixin.qq.com/cgi-bin/menu/create';
|
||||
const DEFAULT_MENU_GET_URL = 'https://api.weixin.qq.com/cgi-bin/menu/get';
|
||||
|
||||
const MENU = {
|
||||
button: [
|
||||
{
|
||||
type: 'view',
|
||||
name: 'TKMind智取',
|
||||
url: 'https://g2.tkmind.cn',
|
||||
},
|
||||
{
|
||||
type: 'view',
|
||||
name: 'MiSpace',
|
||||
url: 'https://g2.tkmind.cn/space',
|
||||
},
|
||||
{
|
||||
type: 'view',
|
||||
name: 'MiWorld',
|
||||
url: 'https://plaza.tkmind.cn',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function endpointFromTokenUrl(pathname, fallback) {
|
||||
const tokenUrl = process.env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_TOKEN_URL;
|
||||
try {
|
||||
const url = new URL(tokenUrl);
|
||||
url.pathname = pathname;
|
||||
url.search = '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(response) {
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
async function getAccessToken() {
|
||||
const appid = process.env.H5_WECHAT_MP_APP_ID?.trim();
|
||||
const secret = process.env.H5_WECHAT_MP_APP_SECRET?.trim();
|
||||
if (!appid || !secret) {
|
||||
throw new Error('缺少 H5_WECHAT_MP_APP_ID / H5_WECHAT_MP_APP_SECRET');
|
||||
}
|
||||
|
||||
const tokenUrl = process.env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_TOKEN_URL;
|
||||
const payload = await readJson(
|
||||
await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'client_credential',
|
||||
appid,
|
||||
secret,
|
||||
force_refresh: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (!payload?.access_token) {
|
||||
throw new Error(payload?.errmsg || `获取 access_token 失败 (${payload?.errcode ?? 'unknown'})`);
|
||||
}
|
||||
return payload.access_token;
|
||||
}
|
||||
|
||||
async function createMenu(accessToken) {
|
||||
const baseUrl =
|
||||
process.env.H5_WECHAT_MP_MENU_CREATE_URL?.trim() ||
|
||||
endpointFromTokenUrl('/cgi-bin/menu/create', DEFAULT_MENU_CREATE_URL);
|
||||
const url = `${baseUrl}?access_token=${encodeURIComponent(accessToken)}`;
|
||||
const payload = await readJson(
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(MENU),
|
||||
}),
|
||||
);
|
||||
if (Number(payload?.errcode ?? 0) !== 0) {
|
||||
throw new Error(payload?.errmsg || `创建菜单失败 (${payload?.errcode})`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function getMenu(accessToken) {
|
||||
const baseUrl =
|
||||
process.env.H5_WECHAT_MP_MENU_GET_URL?.trim() ||
|
||||
endpointFromTokenUrl('/cgi-bin/menu/get', DEFAULT_MENU_GET_URL);
|
||||
const url = `${baseUrl}?access_token=${encodeURIComponent(accessToken)}`;
|
||||
return readJson(await fetch(url, { method: 'GET' }));
|
||||
}
|
||||
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
if (dryRun) {
|
||||
console.log(JSON.stringify(MENU, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const accessToken = await getAccessToken();
|
||||
const created = await createMenu(accessToken);
|
||||
const current = await getMenu(accessToken).catch((err) => ({ warning: err.message }));
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
created,
|
||||
menu: MENU,
|
||||
current,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user