feat: 同步今日小程序与后台迭代版本
集中提交今日 API、管理端、小程序与部署文档调整,确保 Gitea 主分支与本地最新开发版本一致。 Made-with: Cursor
This commit is contained in:
@@ -14,6 +14,8 @@ MBTI 王:通过 kr 宝塔面板 API 上传 PHP 与 admin 静态资源并触发
|
||||
BT_API_KEY=xxx python3 scripts/deploy_mbti_bt_api.py --api-only
|
||||
BT_API_KEY=xxx python3 scripts/deploy_mbti_bt_api.py --admin-only
|
||||
|
||||
也可将密钥写入 scripts/.env.bt(见 scripts/env.bt.example),勿提交;脚本启动时会自动加载。
|
||||
|
||||
可选覆盖(自动识别失败时):
|
||||
MBTI_API_CODE_ROOT 服务器上与本地 api/ 同级的目录(含 app/、public/),例:/www/wwwroot/self/mbti-api/api
|
||||
MBTI_ADMIN_SITE_ROOT 静态站点根(含 index.html),例:/www/wwwroot/self/mbti-admin
|
||||
@@ -41,6 +43,31 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
API_LOCAL = REPO_ROOT / "api"
|
||||
ADMIN_DIST = REPO_ROOT / "admin" / "dist"
|
||||
|
||||
|
||||
def load_env_bt() -> None:
|
||||
"""从 scripts/.env.bt 注入环境变量(仅当当前环境未设置同名变量时)。"""
|
||||
p = REPO_ROOT / "scripts" / ".env.bt"
|
||||
if not p.is_file():
|
||||
return
|
||||
try:
|
||||
raw_text = p.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return
|
||||
for raw in raw_text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("export "):
|
||||
line = line[7:].strip()
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
key = key.strip()
|
||||
val = val.strip().strip("'").strip('"')
|
||||
# 跳过空值,避免 .env.bt 里 BT_API_KEY= 占位导致覆盖不了终端里已 export 的密钥
|
||||
if key and val and key not in os.environ:
|
||||
os.environ[key] = val
|
||||
|
||||
DEFAULT_API_REL_PATHS = [
|
||||
"app/controller/admin/AppUser.php",
|
||||
"app/controller/admin/Order.php",
|
||||
@@ -48,6 +75,45 @@ DEFAULT_API_REL_PATHS = [
|
||||
"app/controller/admin/Finance.php",
|
||||
"app/controller/admin/concern/ExtractsTestResults.php",
|
||||
"app/model/WechatUser.php",
|
||||
# 路由与小程序核心 API
|
||||
"route/api.php",
|
||||
"app/controller/api/Auth.php",
|
||||
"app/controller/api/AppConfig.php",
|
||||
"app/controller/api/MpConfig.php",
|
||||
"app/controller/api/Order.php",
|
||||
# 神仙 AI 异步投递(triggerAiChatDeferredJob → /api/internal/outbound-push/dispatch)
|
||||
"app/controller/api/InternalPushHook.php",
|
||||
# 神仙 AI(对话 + 报告)
|
||||
"app/controller/api/AiChat.php",
|
||||
"app/controller/api/AiReport.php",
|
||||
"app/controller/api/Analyze.php",
|
||||
"app/controller/api/Test.php",
|
||||
"app/common/service/AiCallService.php",
|
||||
"app/common/service/AiChatArticleDisplayService.php",
|
||||
"app/common/service/SoulArticleService.php",
|
||||
"app/common/service/AiReportService.php",
|
||||
# 神仙 AI 依赖模型(避免线上仍为旧版表映射)
|
||||
"app/model/AiConversation.php",
|
||||
"app/model/AiMessage.php",
|
||||
"app/model/AiProvider.php",
|
||||
"app/model/SoulArticle.php",
|
||||
"app/model/SystemConfig.php",
|
||||
# 微信支付 / 转账回调
|
||||
"app/controller/api/Payment.php",
|
||||
"app/controller/api/WechatTransferNotify.php",
|
||||
"app/common/service/WechatService.php",
|
||||
"app/common/service/WechatAuditSyncService.php",
|
||||
"app/common/service/WechatTransferService.php",
|
||||
"app/controller/superadmin/Settings.php",
|
||||
# 认证、配置、埋点/推送依赖
|
||||
"app/common/service/JwtService.php",
|
||||
"app/common/service/MpTabbarService.php",
|
||||
"app/common/service/FeishuLeadWebhookService.php",
|
||||
"app/common/service/OutboundPushHookService.php",
|
||||
"app/common/service/ThirdPartyChannelService.php",
|
||||
# 上线自检 / 冒烟(可选;上传至服务器 api/scripts 供 SSH 执行)
|
||||
"scripts/check_ai_chat_ready.php",
|
||||
"scripts/smoke_ai_provider.php",
|
||||
]
|
||||
|
||||
API_DOMAIN_HINT = "mbtiapi"
|
||||
@@ -222,6 +288,7 @@ def service_admin(name: str, op: str, key: str) -> None:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
load_env_bt()
|
||||
ap = argparse.ArgumentParser(description="MBTI 宝塔 API 部署")
|
||||
ap.add_argument("--list-sites", action="store_true", help="列出站点并匹配 mbti 路径后退出")
|
||||
ap.add_argument("--all", action="store_true", help="上传 API 默认文件 + admin/dist")
|
||||
|
||||
@@ -22,12 +22,51 @@ echo "== API PHP -> ${SSH_USER}@${SSH_HOST}:${REMOTE_API}"
|
||||
"${ROOT}/api/app/controller/admin/AppUser.php" \
|
||||
"${ROOT}/api/app/controller/admin/Order.php" \
|
||||
"${ROOT}/api/app/controller/admin/Dashboard.php" \
|
||||
"${ROOT}/api/app/controller/admin/Finance.php" \
|
||||
"${SSH_USER}@${SSH_HOST}:${REMOTE_API}/app/controller/admin/"
|
||||
|
||||
"${RSYNC[@]}" \
|
||||
"${ROOT}/api/app/controller/admin/concern/ExtractsTestResults.php" \
|
||||
"${SSH_USER}@${SSH_HOST}:${REMOTE_API}/app/controller/admin/concern/"
|
||||
|
||||
"${RSYNC[@]}" \
|
||||
"${ROOT}/api/app/model/WechatUser.php" \
|
||||
"${SSH_USER}@${SSH_HOST}:${REMOTE_API}/app/model/"
|
||||
|
||||
"${RSYNC[@]}" \
|
||||
"${ROOT}/api/route/api.php" \
|
||||
"${SSH_USER}@${SSH_HOST}:${REMOTE_API}/route/"
|
||||
|
||||
"${RSYNC[@]}" \
|
||||
"${ROOT}/api/app/controller/api/Auth.php" \
|
||||
"${ROOT}/api/app/controller/api/AppConfig.php" \
|
||||
"${ROOT}/api/app/controller/api/MpConfig.php" \
|
||||
"${ROOT}/api/app/controller/api/Order.php" \
|
||||
"${ROOT}/api/app/controller/api/AiChat.php" \
|
||||
"${ROOT}/api/app/controller/api/AiReport.php" \
|
||||
"${ROOT}/api/app/controller/api/Payment.php" \
|
||||
"${ROOT}/api/app/controller/api/WechatTransferNotify.php" \
|
||||
"${SSH_USER}@${SSH_HOST}:${REMOTE_API}/app/controller/api/"
|
||||
|
||||
"${RSYNC[@]}" \
|
||||
"${ROOT}/api/app/common/service/AiCallService.php" \
|
||||
"${ROOT}/api/app/common/service/AiChatArticleDisplayService.php" \
|
||||
"${ROOT}/api/app/common/service/SoulArticleService.php" \
|
||||
"${ROOT}/api/app/common/service/AiReportService.php" \
|
||||
"${ROOT}/api/app/common/service/WechatService.php" \
|
||||
"${ROOT}/api/app/common/service/WechatAuditSyncService.php" \
|
||||
"${ROOT}/api/app/common/service/WechatTransferService.php" \
|
||||
"${ROOT}/api/app/common/service/JwtService.php" \
|
||||
"${ROOT}/api/app/common/service/MpTabbarService.php" \
|
||||
"${ROOT}/api/app/common/service/FeishuLeadWebhookService.php" \
|
||||
"${ROOT}/api/app/common/service/OutboundPushHookService.php" \
|
||||
"${ROOT}/api/app/common/service/ThirdPartyChannelService.php" \
|
||||
"${SSH_USER}@${SSH_HOST}:${REMOTE_API}/app/common/service/"
|
||||
|
||||
"${RSYNC[@]}" \
|
||||
"${ROOT}/api/app/controller/superadmin/Settings.php" \
|
||||
"${SSH_USER}@${SSH_HOST}:${REMOTE_API}/app/controller/superadmin/"
|
||||
|
||||
if [[ ! -d "${ROOT}/admin/dist" ]]; then
|
||||
echo "缺少 admin/dist,正在构建..." >&2
|
||||
(cd "${ROOT}/admin" && npm run build)
|
||||
|
||||
20
scripts/env.bt.example
Normal file
20
scripts/env.bt.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# 复制为同目录下的 .env.bt 后填写(.env.bt 已 gitignore,勿提交)
|
||||
# cp scripts/env.bt.example scripts/.env.bt
|
||||
#
|
||||
# 若密钥放在腾讯云「凭据管理系统」:可用脚本拉取并生成 .env.bt(宝塔密钥仍需你在面板复制进凭据)
|
||||
# export MBTI_BT_SECRET_NAME=你的凭据名
|
||||
# .venv-bt/bin/python scripts/sync_env_bt_from_tencent_ssm.py
|
||||
# .venv-bt/bin/python scripts/sync_env_bt_from_tencent_ssm.py --deploy-api
|
||||
|
||||
BT_API_KEY=
|
||||
# 可选:与面板不一致时覆盖
|
||||
# BT_PANEL_URL=https://你的面板:端口
|
||||
|
||||
# 可选:面板自动识别失败时,写服务器上 api 代码根(含 app/、route/)
|
||||
# MBTI_API_CODE_ROOT=/www/wwwroot/xxx/mbti-api/api
|
||||
|
||||
# 可选:仅上传 admin 静态时需要
|
||||
# MBTI_ADMIN_SITE_ROOT=/www/wwwroot/xxx/mbti-admin
|
||||
|
||||
# 可选:上传后重载 PHP
|
||||
# BT_PHP_FPM_SERVICE=php-fpm-82
|
||||
9
scripts/path1_api.sh
Executable file
9
scripts/path1_api.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# 路径一:一键调用宝塔 API 上传 API 相关文件(依赖 scripts/.env.bt 或环境变量 BT_API_KEY)
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
if [[ ! -f scripts/.env.bt ]]; then
|
||||
cp scripts/env.bt.example scripts/.env.bt
|
||||
fi
|
||||
exec python3 scripts/deploy_mbti_bt_api.py --api-only
|
||||
2
scripts/requirements_bt_tencent.txt
Normal file
2
scripts/requirements_bt_tencent.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
# 仅用于 scripts/sync_env_bt_from_tencent_ssm.py(本地 venv 安装)
|
||||
tencentcloud-sdk-python>=3.0.1200
|
||||
198
scripts/sync_env_bt_from_tencent_ssm.py
Normal file
198
scripts/sync_env_bt_from_tencent_ssm.py
Normal file
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
用腾讯云 API 从「凭据管理系统 Secrets Manager / SSM」拉取宝塔相关配置,写入 scripts/.env.bt
|
||||
(宝塔 BT_API_KEY 只能在面板里生成,腾讯云不会替你生成;需你先在控制台把密钥存成一条凭据。)
|
||||
|
||||
凭据内容(自定义 SecretString,推荐 JSON)示例:
|
||||
{
|
||||
"BT_API_KEY": "面板 设置→API接口 的密钥",
|
||||
"BT_PANEL_URL": "https://你的面板:端口",
|
||||
"MBTI_API_CODE_ROOT": "/www/wwwroot/xxx/mbti-api/api",
|
||||
"MBTI_ADMIN_SITE_ROOT": "/www/wwwroot/xxx/mbti-admin",
|
||||
"BT_PHP_FPM_SERVICE": "php-fpm-82"
|
||||
}
|
||||
|
||||
也可存纯文本:整段 SecretString 仅一行,则视为 BT_API_KEY。
|
||||
|
||||
腾讯云 CAM 凭证(与 COS/TAT 脚本相同):
|
||||
- 环境变量 TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY
|
||||
- 或 卡若AI「运营中枢/工作台/00_账号与API索引.md」§ 腾讯云 内 SecretId / SecretKey
|
||||
|
||||
本脚本依赖:
|
||||
python3 -m venv .venv-bt && .venv-bt/bin/pip install -r scripts/requirements_bt_tencent.txt
|
||||
|
||||
环境变量:
|
||||
MBTI_BT_SECRET_NAME 必填,凭据名称
|
||||
MBTI_BT_SSM_REGION 可选,默认 ap-guangzhou
|
||||
MBTI_BT_SECRET_VERSION 可选,默认 SSM_Current
|
||||
|
||||
用法:
|
||||
MBTI_BT_SECRET_NAME=mbti-baota .venv-bt/bin/python scripts/sync_env_bt_from_tencent_ssm.py
|
||||
MBTI_BT_SECRET_NAME=mbti-baota .venv-bt/bin/python scripts/sync_env_bt_from_tencent_ssm.py --deploy-api
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
ENV_BT_PATH = REPO_ROOT / "scripts" / ".env.bt"
|
||||
KARUO_IDX_DEFAULT = Path("/Users/karuo/Documents/个人/卡若AI/运营中枢/工作台/00_账号与API索引.md")
|
||||
|
||||
|
||||
def read_tencent_creds() -> tuple[str | None, str | None]:
|
||||
sid = os.environ.get("TENCENTCLOUD_SECRET_ID")
|
||||
skey = os.environ.get("TENCENTCLOUD_SECRET_KEY")
|
||||
if sid and skey:
|
||||
return sid, skey
|
||||
idx = Path(os.environ.get("KARUO_API_INDEX_MD", str(KARUO_IDX_DEFAULT)))
|
||||
if not idx.is_file():
|
||||
return None, None
|
||||
text = idx.read_text(encoding="utf-8")
|
||||
in_tx = False
|
||||
sid = skey = None
|
||||
for line in text.splitlines():
|
||||
if "### 腾讯云" in line:
|
||||
in_tx = True
|
||||
continue
|
||||
if in_tx and line.strip().startswith("###"):
|
||||
break
|
||||
if not in_tx:
|
||||
continue
|
||||
if "SecretId" in line and "`" in line:
|
||||
m = re.search(r"`([^`]+)`", line)
|
||||
if m and m.group(1).strip().startswith("AKID"):
|
||||
sid = m.group(1).strip()
|
||||
if "SecretKey" in line and "`" in line:
|
||||
m = re.search(r"`([^`]+)`", line)
|
||||
if m:
|
||||
skey = m.group(1).strip()
|
||||
return sid, skey
|
||||
|
||||
|
||||
def fetch_secret_string(secret_name: str, version_id: str, region: str, sid: str, skey: str) -> str:
|
||||
try:
|
||||
from tencentcloud.common import credential
|
||||
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
|
||||
from tencentcloud.ssm.v20190923 import models, ssm_client
|
||||
except ImportError as e:
|
||||
raise SystemExit(
|
||||
"缺少腾讯云 SDK。请执行:\n"
|
||||
" cd \"%s\"\n"
|
||||
" python3 -m venv .venv-bt\n"
|
||||
" .venv-bt/bin/pip install -r scripts/requirements_bt_tencent.txt\n"
|
||||
"原错误: %s" % (REPO_ROOT, e)
|
||||
) from e
|
||||
|
||||
cred = credential.Credential(sid, skey)
|
||||
client = ssm_client.SsmClient(cred, region)
|
||||
req = models.GetSecretValueRequest()
|
||||
req.SecretName = secret_name
|
||||
req.VersionId = version_id
|
||||
try:
|
||||
resp = client.GetSecretValue(req)
|
||||
except TencentCloudSDKException as err:
|
||||
raise SystemExit("GetSecretValue 失败: %s" % err) from err
|
||||
s = (resp.SecretString or "").strip()
|
||||
if not s:
|
||||
raise SystemExit("凭据明文为空(请检查凭据类型与版本)")
|
||||
return s
|
||||
|
||||
|
||||
def secret_to_env_lines(secret_raw: str) -> list[str]:
|
||||
lines: list[str] = []
|
||||
secret_raw = secret_raw.strip()
|
||||
if not secret_raw:
|
||||
return lines
|
||||
if secret_raw.startswith("{") and secret_raw.endswith("}"):
|
||||
try:
|
||||
obj = json.loads(secret_raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise SystemExit("SecretString 不是合法 JSON: %s" % e) from e
|
||||
if not isinstance(obj, dict):
|
||||
raise SystemExit("SecretString JSON 须为对象")
|
||||
order = [
|
||||
"BT_API_KEY",
|
||||
"MBTI_BT_API_KEY",
|
||||
"BT_PANEL_URL",
|
||||
"MBTI_API_CODE_ROOT",
|
||||
"MBTI_ADMIN_SITE_ROOT",
|
||||
"BT_PHP_FPM_SERVICE",
|
||||
]
|
||||
for k in order:
|
||||
if k in obj and obj[k] is not None and str(obj[k]).strip() != "":
|
||||
lines.append("%s=%s" % (k, str(obj[k]).strip()))
|
||||
for k, v in sorted(obj.items()):
|
||||
if k in order:
|
||||
continue
|
||||
if v is None or str(v).strip() == "":
|
||||
continue
|
||||
lines.append("%s=%s" % (k, str(v).strip()))
|
||||
return lines
|
||||
# 纯文本 → 仅宝塔密钥
|
||||
return ["BT_API_KEY=%s" % secret_raw]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="从腾讯云 SSM 同步 scripts/.env.bt")
|
||||
ap.add_argument(
|
||||
"--deploy-api",
|
||||
action="store_true",
|
||||
help="写入后执行 scripts/deploy_mbti_bt_api.py --api-only",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--print-only",
|
||||
action="store_true",
|
||||
help="只打印将写入的内容,不写文件",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
name = (os.environ.get("MBTI_BT_SECRET_NAME") or "").strip()
|
||||
if not name:
|
||||
raise SystemExit("请设置环境变量 MBTI_BT_SECRET_NAME(腾讯云凭据名称)")
|
||||
|
||||
region = (os.environ.get("MBTI_BT_SSM_REGION") or "ap-guangzhou").strip()
|
||||
version = (os.environ.get("MBTI_BT_SECRET_VERSION") or "SSM_Current").strip()
|
||||
|
||||
sid, skey = read_tencent_creds()
|
||||
if not sid or not skey:
|
||||
raise SystemExit(
|
||||
"未找到腾讯云 CAM 凭证。请设置 TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY,\n"
|
||||
"或配置 KARUO_API_INDEX_MD 指向含「### 腾讯云」与 SecretId/SecretKey 的索引文件。"
|
||||
)
|
||||
|
||||
raw = fetch_secret_string(name, version, region, sid, skey)
|
||||
env_lines = secret_to_env_lines(raw)
|
||||
if not any(x.startswith("BT_API_KEY=") or x.startswith("MBTI_BT_API_KEY=") for x in env_lines):
|
||||
raise SystemExit("凭据中未解析出 BT_API_KEY,请检查 JSON 字段名或改用纯文本凭据。")
|
||||
|
||||
header = (
|
||||
"# 由 sync_env_bt_from_tencent_ssm.py 生成,勿提交。来源凭据: %s (%s)\n" % (name, region)
|
||||
)
|
||||
body = header + "\n".join(env_lines) + "\n"
|
||||
|
||||
if args.print_only:
|
||||
print(body)
|
||||
return 0
|
||||
|
||||
ENV_BT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
ENV_BT_PATH.write_text(body, encoding="utf-8")
|
||||
print("已写入:", ENV_BT_PATH)
|
||||
|
||||
if args.deploy_api:
|
||||
deploy = REPO_ROOT / "scripts" / "deploy_mbti_bt_api.py"
|
||||
r = subprocess.run([sys.executable, str(deploy), "--api-only"], cwd=str(REPO_ROOT))
|
||||
return int(r.returncode)
|
||||
|
||||
print("下一步: python3 scripts/deploy_mbti_bt_api.py --api-only")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
330
scripts/tencent_cos_tat_deploy_mbti.py
Normal file
330
scripts/tencent_cos_tat_deploy_mbti.py
Normal file
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
MBTI 王 → kr 宝塔:本机 npm build → 打 tar(api + admin/dist)→ 腾讯云 COS → TAT 在 CVM 内拉取解压同步。
|
||||
|
||||
不依赖本机 SSH/rsync、不依赖宝塔面板 API(避免出口 IP 白名单与封禁)。
|
||||
|
||||
前置:
|
||||
- pip install cos-python-sdk-v5 tencentcloud-sdk-python-tat
|
||||
- 凭证:TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY,或卡若AI
|
||||
「运营中枢/工作台/00_账号与API索引.md」§ 腾讯云(SecretId / SecretKey 行内 `...`)
|
||||
- COS:MBTI_COS_BUCKET(或 YINZHANGUI_COS_BUCKET),未设则 ListBuckets 取首个桶
|
||||
|
||||
用法:
|
||||
python3 scripts/tencent_cos_tat_deploy_mbti.py
|
||||
python3 scripts/tencent_cos_tat_deploy_mbti.py --dry-run
|
||||
python3 scripts/tencent_cos_tat_deploy_mbti.py --tat-url 'https://...' # 仅下发 TAT
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
KR_INSTANCE_ID = os.environ.get("MBTI_CVM_INSTANCE_ID", "ins-aw0tnqjo")
|
||||
REGION = os.environ.get("MBTI_COS_REGION", "ap-guangzhou")
|
||||
DEST_API_PARENT = "/www/wwwroot/self/mbti-api"
|
||||
DEST_ADMIN = "/www/wwwroot/self/mbti-admin"
|
||||
|
||||
KARUO_IDX_DEFAULT = Path("/Users/karuo/Documents/个人/卡若AI/运营中枢/工作台/00_账号与API索引.md")
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _read_tencent_creds() -> tuple[str | None, str | None]:
|
||||
sid = os.environ.get("TENCENTCLOUD_SECRET_ID")
|
||||
skey = os.environ.get("TENCENTCLOUD_SECRET_KEY")
|
||||
if sid and skey:
|
||||
return sid, skey
|
||||
idx = Path(os.environ.get("KARUO_API_INDEX_MD", str(KARUO_IDX_DEFAULT)))
|
||||
if not idx.is_file():
|
||||
return None, None
|
||||
text = idx.read_text(encoding="utf-8")
|
||||
in_tx = False
|
||||
sid = skey = None
|
||||
for line in text.splitlines():
|
||||
if "### 腾讯云" in line:
|
||||
in_tx = True
|
||||
continue
|
||||
if in_tx and line.strip().startswith("###"):
|
||||
break
|
||||
if not in_tx:
|
||||
continue
|
||||
if "SecretId" in line and "`" in line:
|
||||
m = re.search(r"`([^`]+)`", line)
|
||||
if m and m.group(1).strip().startswith("AKID"):
|
||||
sid = m.group(1).strip()
|
||||
if "SecretKey" in line and "`" in line:
|
||||
m = re.search(r"`([^`]+)`", line)
|
||||
if m:
|
||||
skey = m.group(1).strip()
|
||||
return sid, skey
|
||||
|
||||
|
||||
def _npm_build_admin(root: Path) -> None:
|
||||
admin = root / "admin"
|
||||
if not admin.is_dir():
|
||||
raise SystemExit(f"缺少 admin/: {admin}")
|
||||
r = subprocess.run(
|
||||
["npm", "run", "build"],
|
||||
cwd=str(admin),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
raise SystemExit(f"npm run build 失败:\n{r.stderr or r.stdout}")
|
||||
|
||||
|
||||
def _make_tarball(root: Path) -> Path:
|
||||
api = root / "api"
|
||||
dist = root / "admin" / "dist"
|
||||
if not api.is_dir():
|
||||
raise SystemExit(f"缺少 api/: {api}")
|
||||
if not dist.is_dir():
|
||||
raise SystemExit(f"缺少 admin/dist,请先构建: {dist}")
|
||||
tmp = Path(tempfile.gettempdir()) / f"mbti_cos_{int(time.time())}.tar.gz"
|
||||
cmd = [
|
||||
"tar",
|
||||
"-czf",
|
||||
str(tmp),
|
||||
"--exclude=.git",
|
||||
"--exclude=__pycache__",
|
||||
"--exclude=.DS_Store",
|
||||
"--exclude=api/runtime",
|
||||
"-C",
|
||||
str(root),
|
||||
"api",
|
||||
"-C",
|
||||
str(root / "admin"),
|
||||
"dist",
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
raise SystemExit(f"tar 失败: {r.stderr or r.stdout}")
|
||||
return tmp
|
||||
|
||||
|
||||
def _pick_bucket(sid: str, skey: str) -> str:
|
||||
b = (os.environ.get("MBTI_COS_BUCKET") or os.environ.get("YINZHANGUI_COS_BUCKET", "")).strip()
|
||||
if b:
|
||||
return b
|
||||
try:
|
||||
from qcloud_cos import CosConfig, CosS3Client
|
||||
except ImportError:
|
||||
raise SystemExit("请设置 MBTI_COS_BUCKET 并 pip install cos-python-sdk-v5")
|
||||
cfg = CosConfig(Region=REGION, SecretId=sid, SecretKey=skey, Scheme="https")
|
||||
svc = CosS3Client(cfg)
|
||||
resp = svc.list_buckets()
|
||||
raw = (resp or {}).get("Buckets") or {}
|
||||
buckets = raw.get("Bucket") or []
|
||||
if isinstance(buckets, dict):
|
||||
buckets = [buckets]
|
||||
if not buckets:
|
||||
raise SystemExit("账号下无 COS 桶,请设置 MBTI_COS_BUCKET=桶名-APPID")
|
||||
pref = [x for x in buckets if "wordpress-serverless" not in (x.get("Name") or "")]
|
||||
if pref:
|
||||
buckets = pref
|
||||
name = buckets[0].get("Name")
|
||||
if not name:
|
||||
raise SystemExit("ListBuckets 返回异常")
|
||||
print(f" 未指定 MBTI_COS_BUCKET,自动使用: {name}")
|
||||
return name
|
||||
|
||||
|
||||
def _upload_cos(local_path: Path, bucket: str, sid: str, skey: str) -> tuple[str, str]:
|
||||
from qcloud_cos import CosConfig, CosS3Client
|
||||
|
||||
key = f"deploy/mbti_wang/{int(time.time())}_{local_path.name}"
|
||||
cfg = CosConfig(Region=REGION, SecretId=sid, SecretKey=skey, Scheme="https")
|
||||
client = CosS3Client(cfg)
|
||||
with open(local_path, "rb") as f:
|
||||
client.put_object(Bucket=bucket, Body=f, Key=key, EnableMD5=False)
|
||||
url = client.get_presigned_url(Method="GET", Bucket=bucket, Key=key, Expired=3600)
|
||||
return key, url
|
||||
|
||||
|
||||
def _tat_shell(presigned_url: str) -> str:
|
||||
url_b64 = base64.b64encode(presigned_url.encode()).decode()
|
||||
d_api = DEST_API_PARENT
|
||||
d_adm = DEST_ADMIN
|
||||
return f"""#!/bin/bash
|
||||
set -euo pipefail
|
||||
B64="{url_b64}"
|
||||
URL="$(echo "$B64" | base64 -d)"
|
||||
TMP=/tmp/mbti_cos_deploy.tgz
|
||||
WORKDIR=/tmp/mbti_cos_deploy_$$
|
||||
echo "=== MBTI 王 COS+TAT 部署 ==="
|
||||
curl -fsSL "$URL" -o "$TMP"
|
||||
mkdir -p "$WORKDIR"
|
||||
tar xzf "$TMP" -C "$WORKDIR"
|
||||
rm -f "$TMP"
|
||||
|
||||
API_DST="{d_api}/api"
|
||||
ADM_DST="{d_adm}"
|
||||
if [ -f "$API_DST/.env" ]; then cp -a "$API_DST/.env" /tmp/mbti_api_env.bak; fi
|
||||
|
||||
mkdir -p "$API_DST" "$ADM_DST"
|
||||
# 包内顶层为 api/ 与 dist/
|
||||
cp -a "$WORKDIR/api/." "$API_DST/"
|
||||
# 静态站:清空旧资源(保留 .user.ini)
|
||||
find "$ADM_DST" -mindepth 1 -maxdepth 1 ! -name '.user.ini' -exec rm -rf {{}} +
|
||||
cp -a "$WORKDIR/dist/." "$ADM_DST/"
|
||||
|
||||
if [ -f /tmp/mbti_api_env.bak ]; then mv -f /tmp/mbti_api_env.bak "$API_DST/.env"; fi
|
||||
chown -R www:www "$API_DST" "$ADM_DST" || true
|
||||
|
||||
/www/server/nginx/sbin/nginx -t && /www/server/nginx/sbin/nginx -s reload || true
|
||||
systemctl reload php-fpm-82 2>/dev/null || systemctl reload php-fpm-81 2>/dev/null || systemctl reload php-fpm-80 2>/dev/null || true
|
||||
rm -rf "$WORKDIR"
|
||||
echo "=== 完成 ==="
|
||||
"""
|
||||
|
||||
|
||||
def _decode_tat_task_text(task) -> tuple[str, str]:
|
||||
"""从 InvocationTask 解析终端输出文本,返回 (TaskStatus, 解码后的 Output)。"""
|
||||
st = getattr(task, "TaskStatus", "") or ""
|
||||
tr = getattr(task, "TaskResult", None)
|
||||
if not tr:
|
||||
op = getattr(task, "Output", None)
|
||||
if op:
|
||||
return st, str(op)[:12000]
|
||||
return st, ""
|
||||
|
||||
try:
|
||||
jj = json.loads(tr) if isinstance(tr, str) else tr
|
||||
except Exception:
|
||||
return st, str(tr)[:4000]
|
||||
|
||||
exit_code = jj.get("ExitCode")
|
||||
raw = jj.get("Output", "") or ""
|
||||
if raw:
|
||||
try:
|
||||
raw = base64.b64decode(raw).decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
if exit_code is not None:
|
||||
raw = f"ExitCode: {exit_code}\n{raw}"
|
||||
return st, raw[:12000]
|
||||
|
||||
|
||||
def _run_tat(shell_text: str, timeout: int = 600) -> str:
|
||||
try:
|
||||
from tencentcloud.common import credential
|
||||
from tencentcloud.tat.v20201028 import models, tat_client
|
||||
except ImportError:
|
||||
raise SystemExit("请安装: pip install tencentcloud-sdk-python-tat")
|
||||
|
||||
sid, skey = _read_tencent_creds()
|
||||
if not sid or not skey:
|
||||
raise SystemExit("未配置腾讯云 SecretId/SecretKey")
|
||||
|
||||
cred = credential.Credential(sid, skey)
|
||||
client = tat_client.TatClient(cred, REGION)
|
||||
req = models.RunCommandRequest()
|
||||
req.Content = base64.b64encode(shell_text.encode()).decode()
|
||||
req.InstanceIds = [KR_INSTANCE_ID]
|
||||
req.CommandType = "SHELL"
|
||||
req.Timeout = timeout
|
||||
req.CommandName = "mbti_wang_cos_deploy"
|
||||
resp = client.RunCommand(req)
|
||||
inv = resp.InvocationId
|
||||
print(f"✅ TAT 已下发 InvocationId={inv},等待回传(首轮 60s)…")
|
||||
time.sleep(60)
|
||||
|
||||
req2 = models.DescribeInvocationTasksRequest()
|
||||
flt = models.Filter()
|
||||
flt.Name = "invocation-id"
|
||||
flt.Values = [inv]
|
||||
req2.Filters = [flt]
|
||||
|
||||
chunks: list[str] = []
|
||||
last_text = ""
|
||||
terminal = ("SUCCESS", "FAILED", "TIMEOUT", "CANCELLED")
|
||||
for i in range(48):
|
||||
r2 = client.DescribeInvocationTasks(req2)
|
||||
tasks = r2.InvocationTaskSet or []
|
||||
done = bool(
|
||||
tasks
|
||||
and all(getattr(x, "TaskStatus", "") in terminal for x in tasks)
|
||||
)
|
||||
for t in tasks:
|
||||
st, text = _decode_tat_task_text(t)
|
||||
if text and text != last_text:
|
||||
chunks.append(f"--- 状态: {st} ---\n{text}")
|
||||
last_text = text
|
||||
elif done and not text and st:
|
||||
chunks.append(f"--- 状态: {st} ---\n(无标准输出)")
|
||||
|
||||
if done:
|
||||
break
|
||||
time.sleep(10)
|
||||
|
||||
return "\n".join(chunks) if chunks else "(无输出,请到腾讯云控制台 TAT 查看)"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="MBTI 王:COS + TAT 部署到 kr")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--tat-url", default="", help="已有可下载 URL 时跳过打包与上传")
|
||||
ap.add_argument("--skip-build", action="store_true", help="跳过 npm run build(需已有 admin/dist)")
|
||||
args = ap.parse_args()
|
||||
|
||||
root = repo_root()
|
||||
print("=" * 60)
|
||||
print(" MBTI 王 · 腾讯云 COS + TAT 部署")
|
||||
print("=" * 60)
|
||||
print(f" 仓库: {root}")
|
||||
print(f" CVM: {KR_INSTANCE_ID} / {REGION}")
|
||||
print(f" API: {DEST_API_PARENT}/api")
|
||||
print(f" 站点: {DEST_ADMIN}")
|
||||
print("=" * 60)
|
||||
|
||||
sid, skey = _read_tencent_creds()
|
||||
if not sid or not skey:
|
||||
print("❌ 未找到腾讯云凭证")
|
||||
return 1
|
||||
|
||||
presigned = args.tat_url.strip()
|
||||
|
||||
if not presigned:
|
||||
if not args.skip_build:
|
||||
print(" 执行 npm run build …")
|
||||
_npm_build_admin(root)
|
||||
tar_path = _make_tarball(root)
|
||||
print(f" 本地包: {tar_path} ({tar_path.stat().st_size // 1024} KB)")
|
||||
if args.dry_run:
|
||||
print(" --dry-run 结束")
|
||||
return 0
|
||||
bucket = _pick_bucket(sid, skey)
|
||||
_, presigned = _upload_cos(tar_path, bucket, sid, skey)
|
||||
try:
|
||||
tar_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
shell = _tat_shell(presigned)
|
||||
else:
|
||||
if args.dry_run:
|
||||
print(" --dry-run:已有 URL,跳过")
|
||||
return 0
|
||||
shell = _tat_shell(presigned)
|
||||
|
||||
print(_run_tat(shell, timeout=600))
|
||||
print("\n 验证:")
|
||||
print(" curl -sI https://mbti.quwanzhi.com/ | head -3")
|
||||
print(" curl -sI https://mbtiadmin.quwanzhi.com/ | head -3")
|
||||
print(" curl -s -o /dev/null -w '%{http_code}\\n' https://mbtiapi.quwanzhi.com/api/v1/admin/app-users")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user