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:
john
2026-06-19 23:06:43 +08:00
parent b0f5d6a51c
commit 229805a070
241 changed files with 13190 additions and 902 deletions
-49
View File
@@ -1,49 +0,0 @@
# ============================================================
# rsync 排除规则:Studio(100) → 105 生产
# 在 Studio 上执行,路径相对于 ~/Project/Memind
# ============================================================
# ── 机密配置(105 有自己的 .env,含独立 API_TARGET 等)──
.env
# ── 生产用户数据 ──────────────────────────────────────────
# MindSpace105 经 rclone 挂载共享,不能用 rsync 覆盖
# data/mindspace:同上,共享挂载树
# users105 用 /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/
-6
View File
@@ -1,6 +0,0 @@
node_modules/
.next/
.env
.env.local
.git/
*.log
-56
View File
@@ -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*
+2 -2
View File
@@ -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}"
+237
View File
@@ -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)。"
+8
View File
@@ -0,0 +1,8 @@
# 阿里云 DNS APIRAM 子账号,权限: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
-18
View File
@@ -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
}
}
}
-10
View File
@@ -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
}
}
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# g2.tkmind.cnLet'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}"
+73
View File
@@ -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();
+2
View File
@@ -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
+6 -2
View File
@@ -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']);
+7 -5
View File
@@ -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);
+1 -1
View File
@@ -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}"
+67
View File
@@ -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()
+15
View File
@@ -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}"
+126
View File
@@ -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,
),
);