清理
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,7 +0,0 @@
|
||||
|
||||
soul-api/wechat/info.log
|
||||
next-project
|
||||
soul-admin/node_modules
|
||||
soul-api.exe
|
||||
Mycontent-temp
|
||||
Mycontent-temp
|
||||
54
Gitea同步说明.md
54
Gitea同步说明.md
@@ -1,54 +0,0 @@
|
||||
# 一场soul的创业实验-永平 → Gitea 同步说明
|
||||
|
||||
**Gitea 仓库**:`fnvtk/soul-yongping`
|
||||
**地址**:<http://open.quwanzhi.com:3000/fnvtk/soul-yongping>
|
||||
|
||||
本仓库已配置:**每次 `git commit` 后自动推送到 Gitea**(见 `.git/hooks/post-commit`),有更新即同步。
|
||||
|
||||
---
|
||||
|
||||
## 一、首次使用(完成一次推送后,之后都会自动同步)
|
||||
|
||||
本仓库的 **gitea 远程已使用与卡若AI 相同的 Gitea Token**,只需在 Gitea 上建仓后推送即可。
|
||||
|
||||
### 1. 在 Gitea 上创建仓库(若还没有)
|
||||
|
||||
1. 打开 <http://open.quwanzhi.com:3000>,登录 **fnvtk**。
|
||||
2. 点击「新建仓库」。
|
||||
3. **仓库名称**填:`soul-yongping`。
|
||||
4. 描述可填:`一场soul的创业实验-永平 网站与小程序`。
|
||||
5. 不要勾选「使用自述文件初始化」,创建空仓库。
|
||||
|
||||
### 2. 执行首次推送
|
||||
|
||||
```bash
|
||||
cd "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平"
|
||||
git push -u gitea main
|
||||
```
|
||||
|
||||
外网需代理时先设置再推送:
|
||||
|
||||
```bash
|
||||
export GITEA_HTTP_PROXY=http://127.0.0.1:7897
|
||||
git push -u gitea main
|
||||
```
|
||||
|
||||
首次推送成功后,**之后每次在本项目里 `git commit`,都会自动执行 `git push gitea main`**,无需再手动上传。
|
||||
|
||||
---
|
||||
|
||||
## 二、自动同步机制
|
||||
|
||||
- **触发条件**:在本项目执行 `git commit`(任意分支的提交都会触发 hook,但推送的是 `main`)。
|
||||
- **执行动作**:`post-commit` 钩子会执行 `git push gitea main`。
|
||||
- **关闭自动推送**:删除或改名 `.git/hooks/post-commit` 即可。
|
||||
|
||||
---
|
||||
|
||||
## 三、手动推送(可选)
|
||||
|
||||
若需要单独推送到 Gitea(不依赖 commit):
|
||||
|
||||
```bash
|
||||
git push gitea main
|
||||
```
|
||||
@@ -1,275 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Soul 内容上传接口
|
||||
可从 Cursor Skill / 命令行直接调用,将新内容写入数据库
|
||||
|
||||
用法:
|
||||
python3 content_upload.py --title "标题" --price 1.0 --content "正文" \
|
||||
--part part-1 --chapter chapter-1 --format markdown
|
||||
|
||||
python3 content_upload.py --json '{
|
||||
"title": "标题",
|
||||
"price": 1.0,
|
||||
"content": "正文内容...",
|
||||
"part_id": "part-1",
|
||||
"chapter_id": "chapter-1",
|
||||
"format": "markdown",
|
||||
"images": ["https://xxx.com/img1.png"]
|
||||
}'
|
||||
|
||||
python3 content_upload.py --list-structure # 查看篇章结构
|
||||
|
||||
环境依赖: pip install pymysql
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
import pymysql
|
||||
except ImportError:
|
||||
print("需要安装 pymysql: pip3 install pymysql")
|
||||
sys.exit(1)
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": "56b4c23f6853c.gz.cdb.myqcloud.com",
|
||||
"port": 14413,
|
||||
"user": "cdb_outerroot",
|
||||
"password": "Zhiqun1984",
|
||||
"database": "soul_miniprogram",
|
||||
"charset": "utf8mb4",
|
||||
}
|
||||
|
||||
PART_MAP = {
|
||||
"part-1": "第一篇|真实的人",
|
||||
"part-2": "第二篇|真实的行业",
|
||||
"part-3": "第三篇|真实的错误",
|
||||
"part-4": "第四篇|真实的赚钱",
|
||||
"part-5": "第五篇|真实的社会",
|
||||
"appendix": "附录",
|
||||
"intro": "序言",
|
||||
"outro": "尾声",
|
||||
}
|
||||
|
||||
CHAPTER_MAP = {
|
||||
"chapter-1": "第1章|人与人之间的底层逻辑",
|
||||
"chapter-2": "第2章|人性困境案例",
|
||||
"chapter-3": "第3章|电商篇",
|
||||
"chapter-4": "第4章|内容商业篇",
|
||||
"chapter-5": "第5章|传统行业篇",
|
||||
"chapter-6": "第6章|我人生错过的4件大钱",
|
||||
"chapter-7": "第7章|别人犯的错误",
|
||||
"chapter-8": "第8章|底层结构",
|
||||
"chapter-9": "第9章|我在Soul上亲访的赚钱案例",
|
||||
"chapter-10": "第10章|未来职业的变化趋势",
|
||||
"chapter-11": "第11章|中国社会商业生态的未来",
|
||||
"appendix": "附录",
|
||||
"preface": "序言",
|
||||
"epilogue": "尾声",
|
||||
}
|
||||
|
||||
|
||||
def get_connection():
|
||||
return pymysql.connect(**DB_CONFIG)
|
||||
|
||||
|
||||
def list_structure():
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT part_id, part_title, chapter_id, chapter_title, COUNT(*) as sections
|
||||
FROM chapters
|
||||
GROUP BY part_id, part_title, chapter_id, chapter_title
|
||||
ORDER BY part_id, chapter_id
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
print("篇章结构:")
|
||||
for part_id, part_title, ch_id, ch_title, cnt in rows:
|
||||
print(f" {part_id} ({part_title}) / {ch_id} ({ch_title}) - {cnt}节")
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM chapters")
|
||||
total = cur.fetchone()[0]
|
||||
print(f"\n总计: {total} 节")
|
||||
conn.close()
|
||||
|
||||
|
||||
def generate_section_id(cur, chapter_id):
|
||||
"""根据 chapter 编号自动生成下一个 section id"""
|
||||
ch_num = re.search(r"\d+", chapter_id)
|
||||
if not ch_num:
|
||||
cur.execute("SELECT MAX(CAST(REPLACE(id, '.', '') AS UNSIGNED)) FROM chapters")
|
||||
max_id = cur.fetchone()[0] or 0
|
||||
return str(max_id + 1)
|
||||
|
||||
prefix = ch_num.group()
|
||||
cur.execute(
|
||||
"SELECT id FROM chapters WHERE id LIKE %s ORDER BY CAST(SUBSTRING_INDEX(id, '.', -1) AS UNSIGNED) DESC LIMIT 1",
|
||||
(f"{prefix}.%",),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
last_num = int(row[0].split(".")[-1])
|
||||
return f"{prefix}.{last_num + 1}"
|
||||
return f"{prefix}.1"
|
||||
|
||||
|
||||
def upload_content(data):
|
||||
title = data.get("title", "").strip()
|
||||
if not title:
|
||||
print("错误: 标题不能为空")
|
||||
return False
|
||||
|
||||
content = data.get("content", "").strip()
|
||||
if not content:
|
||||
print("错误: 内容不能为空")
|
||||
return False
|
||||
|
||||
price = float(data.get("price", 1.0))
|
||||
is_free = 1 if price == 0 else 0
|
||||
part_id = data.get("part_id", "part-1")
|
||||
chapter_id = data.get("chapter_id", "chapter-1")
|
||||
fmt = data.get("format", "markdown")
|
||||
images = data.get("images", [])
|
||||
section_id = data.get("id", "")
|
||||
|
||||
if images:
|
||||
for i, img_url in enumerate(images):
|
||||
placeholder = f"{{{{image_{i+1}}}}}"
|
||||
if placeholder in content:
|
||||
if fmt == "markdown":
|
||||
content = content.replace(placeholder, f"")
|
||||
else:
|
||||
content = content.replace(placeholder, img_url)
|
||||
|
||||
word_count = len(re.sub(r"\s+", "", content))
|
||||
|
||||
part_title = PART_MAP.get(part_id, part_id)
|
||||
chapter_title = CHAPTER_MAP.get(chapter_id, chapter_id)
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
if not section_id:
|
||||
section_id = generate_section_id(cur, chapter_id)
|
||||
|
||||
cur.execute("SELECT mid FROM chapters WHERE id = %s", (section_id,))
|
||||
existing = cur.fetchone()
|
||||
|
||||
try:
|
||||
if existing:
|
||||
cur.execute("""
|
||||
UPDATE chapters SET
|
||||
section_title = %s, content = %s, word_count = %s,
|
||||
is_free = %s, price = %s, part_id = %s, part_title = %s,
|
||||
chapter_id = %s, chapter_title = %s, status = 'published'
|
||||
WHERE id = %s
|
||||
""", (title, content, word_count, is_free, price, part_id, part_title,
|
||||
chapter_id, chapter_title, section_id))
|
||||
action = "更新"
|
||||
else:
|
||||
cur.execute("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM chapters")
|
||||
next_order = cur.fetchone()[0]
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title,
|
||||
section_title, content, word_count, is_free, price, sort_order, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'published')
|
||||
""", (section_id, part_id, part_title, chapter_id, chapter_title,
|
||||
title, content, word_count, is_free, price, next_order))
|
||||
action = "创建"
|
||||
|
||||
conn.commit()
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"action": action,
|
||||
"data": {
|
||||
"id": section_id,
|
||||
"title": title,
|
||||
"part": f"{part_id} ({part_title})",
|
||||
"chapter": f"{chapter_id} ({chapter_title})",
|
||||
"price": price,
|
||||
"is_free": bool(is_free),
|
||||
"word_count": word_count,
|
||||
"format": fmt,
|
||||
"images_count": len(images),
|
||||
}
|
||||
}
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return True
|
||||
|
||||
except pymysql.err.IntegrityError as e:
|
||||
print(json.dumps({"success": False, "error": f"ID冲突: {e}"}, ensure_ascii=False))
|
||||
return False
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(json.dumps({"success": False, "error": str(e)}, ensure_ascii=False))
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Soul 内容上传接口")
|
||||
parser.add_argument("--json", help="JSON格式的完整数据")
|
||||
parser.add_argument("--title", help="标题")
|
||||
parser.add_argument("--price", type=float, default=1.0, help="定价(0=免费)")
|
||||
parser.add_argument("--content", help="内容正文")
|
||||
parser.add_argument("--content-file", help="从文件读取内容")
|
||||
parser.add_argument("--format", default="markdown", choices=["markdown", "text", "html"])
|
||||
parser.add_argument("--part", default="part-1", help="所属篇 (part-1 ~ part-5)")
|
||||
parser.add_argument("--chapter", default="chapter-1", help="所属章 (chapter-1 ~ chapter-11)")
|
||||
parser.add_argument("--id", help="指定 section ID (如 1.6),不指定则自动生成")
|
||||
parser.add_argument("--images", nargs="*", help="图片URL列表")
|
||||
parser.add_argument("--list-structure", action="store_true", help="查看篇章结构")
|
||||
parser.add_argument("--list-chapters", action="store_true", help="列出所有章节")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list_structure:
|
||||
list_structure()
|
||||
return
|
||||
|
||||
if args.list_chapters:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id, section_title, is_free, price FROM chapters ORDER BY sort_order")
|
||||
for row in cur.fetchall():
|
||||
free_tag = "[免费]" if row[2] else f"[¥{row[3]}]"
|
||||
print(f" {row[0]} {row[1]} {free_tag}")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
if args.json:
|
||||
data = json.loads(args.json)
|
||||
else:
|
||||
if not args.title or (not args.content and not args.content_file):
|
||||
parser.print_help()
|
||||
print("\n错误: 需要 --title 和 --content (或 --content-file)")
|
||||
sys.exit(1)
|
||||
|
||||
content = args.content
|
||||
if args.content_file:
|
||||
with open(args.content_file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
data = {
|
||||
"title": args.title,
|
||||
"price": args.price,
|
||||
"content": content,
|
||||
"format": args.format,
|
||||
"part_id": args.part,
|
||||
"chapter_id": args.chapter,
|
||||
"images": args.images or [],
|
||||
}
|
||||
if args.id:
|
||||
data["id"] = args.id
|
||||
|
||||
upload_content(data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
775
devlop.py
775
devlop.py
@@ -1,775 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import tempfile
|
||||
import argparse
|
||||
import json
|
||||
import zipfile
|
||||
import tarfile
|
||||
import subprocess
|
||||
import time
|
||||
import hashlib
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
except ImportError:
|
||||
print("错误: 请先安装 paramiko")
|
||||
print(" pip install paramiko")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
import requests
|
||||
import urllib3
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
except ImportError:
|
||||
print("错误: 请先安装 requests")
|
||||
print(" pip install requests")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
# 端口统一从环境变量 DEPLOY_PORT 读取,未设置时使用此默认值(需与 Nginx proxy_pass、ecosystem.config.cjs 一致)
|
||||
DEPLOY_PM2_APP = "soul"
|
||||
DEFAULT_DEPLOY_PORT = 3006
|
||||
DEPLOY_PROJECT_PATH = "/www/wwwroot/自营/soul"
|
||||
DEPLOY_SITE_URL = "https://soul.quwanzhi.com"
|
||||
# SSH 端口(支持环境变量 DEPLOY_SSH_PORT,未设置时默认为 22022)
|
||||
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
|
||||
|
||||
def get_cfg():
|
||||
"""获取基础部署配置(deploy 模式与 devlop 共用 SSH/宝塔)"""
|
||||
return {
|
||||
"host": os.environ.get("DEPLOY_HOST", "43.139.27.93"),
|
||||
"user": os.environ.get("DEPLOY_USER", "root"),
|
||||
"password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"),
|
||||
"ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""),
|
||||
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH),
|
||||
"panel_url": os.environ.get("BAOTA_PANEL_URL", "https://43.139.27.93:9988"),
|
||||
"api_key": os.environ.get("BAOTA_API_KEY", "hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd"),
|
||||
"pm2_name": os.environ.get("DEPLOY_PM2_APP", DEPLOY_PM2_APP),
|
||||
"site_url": os.environ.get("DEPLOY_SITE_URL", DEPLOY_SITE_URL),
|
||||
"port": int(os.environ.get("DEPLOY_PORT", str(DEFAULT_DEPLOY_PORT))),
|
||||
"node_version": os.environ.get("DEPLOY_NODE_VERSION", "v22.14.0"),
|
||||
"node_path": os.environ.get("DEPLOY_NODE_PATH", "/www/server/nodejs/v22.14.0/bin"),
|
||||
}
|
||||
|
||||
|
||||
def get_cfg_devlop():
|
||||
"""devlop 模式配置:在基础配置上增加 base_path / dist / dist2。
|
||||
实际运行目录为 dist_path(切换后新版本在 dist),宝塔 PM2 项目路径必须指向 dist_path,
|
||||
否则会从错误目录启动导致 .next/static 等静态资源 404。"""
|
||||
cfg = get_cfg().copy()
|
||||
cfg["base_path"] = os.environ.get("DEVOP_BASE_PATH", DEPLOY_PROJECT_PATH)
|
||||
cfg["dist_path"] = cfg["base_path"] + "/dist"
|
||||
cfg["dist2_path"] = cfg["base_path"] + "/dist2"
|
||||
return cfg
|
||||
|
||||
|
||||
# ==================== 宝塔 API ====================
|
||||
|
||||
def _get_sign(api_key):
|
||||
now_time = int(time.time())
|
||||
sign_str = str(now_time) + hashlib.md5(api_key.encode("utf-8")).hexdigest()
|
||||
request_token = hashlib.md5(sign_str.encode("utf-8")).hexdigest()
|
||||
return now_time, request_token
|
||||
|
||||
|
||||
def _baota_request(panel_url, api_key, path, data=None):
|
||||
req_time, req_token = _get_sign(api_key)
|
||||
payload = {"request_time": req_time, "request_token": req_token}
|
||||
if data:
|
||||
payload.update(data)
|
||||
url = panel_url.rstrip("/") + "/" + path.lstrip("/")
|
||||
try:
|
||||
r = requests.post(url, data=payload, verify=False, timeout=30)
|
||||
return r.json() if r.text else {}
|
||||
except Exception as e:
|
||||
print(" API 请求失败: %s" % str(e))
|
||||
return None
|
||||
|
||||
|
||||
def get_node_project_list(panel_url, api_key):
|
||||
for path in ["/project/nodejs/get_project_list", "/plugin?action=a&name=nodejs&s=get_project_list"]:
|
||||
result = _baota_request(panel_url, api_key, path)
|
||||
if result and (result.get("status") is True or "data" in result):
|
||||
return result.get("data", [])
|
||||
return None
|
||||
|
||||
|
||||
def get_node_project_status(panel_url, api_key, pm2_name):
|
||||
projects = get_node_project_list(panel_url, api_key)
|
||||
if projects:
|
||||
for p in projects:
|
||||
if p.get("name") == pm2_name:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def start_node_project(panel_url, api_key, pm2_name):
|
||||
for path in ["/project/nodejs/start_project", "/plugin?action=a&name=nodejs&s=start_project"]:
|
||||
result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name})
|
||||
if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)):
|
||||
print(" [成功] 启动成功: %s" % pm2_name)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def stop_node_project(panel_url, api_key, pm2_name):
|
||||
for path in ["/project/nodejs/stop_project", "/plugin?action=a&name=nodejs&s=stop_project"]:
|
||||
result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name})
|
||||
if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)):
|
||||
print(" [成功] 停止成功: %s" % pm2_name)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def restart_node_project(panel_url, api_key, pm2_name):
|
||||
project_status = get_node_project_status(panel_url, api_key, pm2_name)
|
||||
if project_status:
|
||||
print(" 项目状态: %s" % project_status.get("status", "未知"))
|
||||
for path in ["/project/nodejs/restart_project", "/plugin?action=a&name=nodejs&s=restart_project"]:
|
||||
result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name})
|
||||
if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)):
|
||||
print(" [成功] 重启成功: %s" % pm2_name)
|
||||
return True
|
||||
if result and "msg" in result:
|
||||
print(" API 返回: %s" % result.get("msg"))
|
||||
print(" [警告] 重启失败,请检查宝塔 Node 插件是否安装、API 密钥是否正确")
|
||||
return False
|
||||
|
||||
|
||||
def add_or_update_node_project(panel_url, api_key, pm2_name, project_path, port=None, node_path=None):
|
||||
if port is None:
|
||||
port = int(os.environ.get("DEPLOY_PORT", str(DEFAULT_DEPLOY_PORT)))
|
||||
port_env = "PORT=%d " % port
|
||||
run_cmd = port_env + ("%s/node server.js" % node_path if node_path else "node server.js")
|
||||
payload = {"name": pm2_name, "path": project_path, "run_cmd": run_cmd, "port": str(port)}
|
||||
for path in ["/project/nodejs/add_project", "/plugin?action=a&name=nodejs&s=add_project"]:
|
||||
result = _baota_request(panel_url, api_key, path, payload)
|
||||
if result and result.get("status") is True:
|
||||
print(" [成功] 项目配置已更新: %s" % pm2_name)
|
||||
return True
|
||||
if result and "msg" in result:
|
||||
print(" API 返回: %s" % result.get("msg"))
|
||||
return False
|
||||
|
||||
|
||||
# ==================== 本地构建 ====================
|
||||
|
||||
def run_build(root):
|
||||
"""执行本地 pnpm build"""
|
||||
use_shell = sys.platform == "win32"
|
||||
standalone = os.path.join(root, ".next", "standalone")
|
||||
server_js = os.path.join(standalone, "server.js")
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["pnpm", "build"],
|
||||
cwd=root,
|
||||
shell=use_shell,
|
||||
timeout=600,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
stdout_text = r.stdout or ""
|
||||
stderr_text = r.stderr or ""
|
||||
combined = stdout_text + stderr_text
|
||||
is_windows_symlink_error = (
|
||||
sys.platform == "win32"
|
||||
and r.returncode != 0
|
||||
and ("EPERM" in combined or "symlink" in combined.lower() or "operation not permitted" in combined.lower() or "errno: -4048" in combined)
|
||||
)
|
||||
|
||||
if r.returncode != 0:
|
||||
if is_windows_symlink_error:
|
||||
print(" [警告] Windows 符号链接权限错误(EPERM)")
|
||||
print(" 解决方案:开启开发者模式 / 以管理员运行 / 或使用 --no-build")
|
||||
if os.path.isdir(standalone) and os.path.isfile(server_js):
|
||||
print(" [成功] standalone 输出可用,继续部署")
|
||||
return True
|
||||
return False
|
||||
print(" [失败] 构建失败,退出码:", r.returncode)
|
||||
for line in (stdout_text.strip().split("\n") or [])[-10:]:
|
||||
print(" " + line)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
print(" [失败] 构建超时(超过10分钟)")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
print(" [失败] 未找到 pnpm,请安装: npm install -g pnpm")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(" [失败] 构建异常:", str(e))
|
||||
if os.path.isdir(standalone) and os.path.isfile(server_js):
|
||||
print(" [提示] 可尝试使用 --no-build 跳过构建")
|
||||
return False
|
||||
|
||||
if not os.path.isdir(standalone) or not os.path.isfile(server_js):
|
||||
print(" [失败] 未找到 .next/standalone 或 server.js")
|
||||
return False
|
||||
print(" [成功] 构建完成")
|
||||
return True
|
||||
|
||||
|
||||
def clean_standalone_before_build(root, retries=3, delay=2):
|
||||
"""构建前删除 .next/standalone,避免 Windows EBUSY"""
|
||||
standalone = os.path.join(root, ".next", "standalone")
|
||||
if not os.path.isdir(standalone):
|
||||
return True
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
shutil.rmtree(standalone)
|
||||
print(" [清理] 已删除 .next/standalone(第 %d 次尝试)" % attempt)
|
||||
return True
|
||||
except (OSError, PermissionError):
|
||||
if attempt < retries:
|
||||
print(" [清理] 被占用,%ds 后重试 (%d/%d) ..." % (delay, attempt, retries))
|
||||
time.sleep(delay)
|
||||
else:
|
||||
print(" [失败] 无法删除 .next/standalone,可改用 --no-build")
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# ==================== 打包(deploy 模式:tar.gz) ====================
|
||||
|
||||
def _copy_with_dereference(src, dst):
|
||||
if os.path.islink(src):
|
||||
link_target = os.readlink(src)
|
||||
real_path = link_target if os.path.isabs(link_target) else os.path.join(os.path.dirname(src), link_target)
|
||||
if os.path.exists(real_path):
|
||||
if os.path.isdir(real_path):
|
||||
shutil.copytree(real_path, dst, symlinks=False, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(real_path, dst)
|
||||
else:
|
||||
shutil.copy2(src, dst, follow_symlinks=False)
|
||||
elif os.path.isdir(src):
|
||||
if os.path.exists(dst):
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst, symlinks=False, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def pack_standalone_tar(root):
|
||||
"""打包 standalone 为 tar.gz(deploy 模式用)"""
|
||||
print("[2/4] 打包 standalone ...")
|
||||
standalone = os.path.join(root, ".next", "standalone")
|
||||
static_src = os.path.join(root, ".next", "static")
|
||||
public_src = os.path.join(root, "public")
|
||||
ecosystem_src = os.path.join(root, "ecosystem.config.cjs")
|
||||
|
||||
if not os.path.isdir(standalone) or not os.path.isdir(static_src):
|
||||
print(" [失败] 未找到 .next/standalone 或 .next/static")
|
||||
return None
|
||||
chunks_dir = os.path.join(static_src, "chunks")
|
||||
if not os.path.isdir(chunks_dir):
|
||||
print(" [失败] .next/static/chunks 不存在,请先完整执行 pnpm build(本地 pnpm start 能正常打开页面后再部署)")
|
||||
return None
|
||||
|
||||
staging = tempfile.mkdtemp(prefix="soul_deploy_")
|
||||
try:
|
||||
for name in os.listdir(standalone):
|
||||
_copy_with_dereference(os.path.join(standalone, name), os.path.join(staging, name))
|
||||
node_modules_dst = os.path.join(staging, "node_modules")
|
||||
pnpm_dir = os.path.join(node_modules_dst, ".pnpm")
|
||||
if os.path.isdir(pnpm_dir):
|
||||
for dep in ["styled-jsx"]:
|
||||
dep_in_root = os.path.join(node_modules_dst, dep)
|
||||
if not os.path.exists(dep_in_root):
|
||||
for pnpm_pkg in os.listdir(pnpm_dir):
|
||||
if pnpm_pkg.startswith(dep + "@"):
|
||||
src_dep = os.path.join(pnpm_dir, pnpm_pkg, "node_modules", dep)
|
||||
if os.path.isdir(src_dep):
|
||||
shutil.copytree(src_dep, dep_in_root, symlinks=False, dirs_exist_ok=True)
|
||||
break
|
||||
static_dst = os.path.join(staging, ".next", "static")
|
||||
if os.path.exists(static_dst):
|
||||
shutil.rmtree(static_dst)
|
||||
os.makedirs(os.path.dirname(static_dst), exist_ok=True)
|
||||
shutil.copytree(static_src, static_dst)
|
||||
# 同步构建索引(与 start-standalone.js 一致),避免宝塔上 server 用错导致页面空白/404
|
||||
next_root = os.path.join(root, ".next")
|
||||
next_staging = os.path.join(staging, ".next")
|
||||
index_files = [
|
||||
"BUILD_ID",
|
||||
"build-manifest.json",
|
||||
"app-path-routes-manifest.json",
|
||||
"routes-manifest.json",
|
||||
"prerender-manifest.json",
|
||||
"required-server-files.json",
|
||||
"fallback-build-manifest.json",
|
||||
]
|
||||
for name in index_files:
|
||||
src = os.path.join(next_root, name)
|
||||
if os.path.isfile(src):
|
||||
shutil.copy2(src, os.path.join(next_staging, name))
|
||||
print(" [已同步] 构建索引: BUILD_ID, build-manifest, routes-manifest 等")
|
||||
if os.path.isdir(public_src):
|
||||
shutil.copytree(public_src, os.path.join(staging, "public"), dirs_exist_ok=True)
|
||||
if os.path.isfile(ecosystem_src):
|
||||
shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs"))
|
||||
pkg_json = os.path.join(staging, "package.json")
|
||||
if os.path.isfile(pkg_json):
|
||||
try:
|
||||
with open(pkg_json, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data.setdefault("scripts", {})["start"] = "node server.js"
|
||||
with open(pkg_json, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
tarball = os.path.join(tempfile.gettempdir(), "soul_deploy.tar.gz")
|
||||
with tarfile.open(tarball, "w:gz") as tf:
|
||||
for name in os.listdir(staging):
|
||||
tf.add(os.path.join(staging, name), arcname=name)
|
||||
print(" [成功] 打包完成: %s (%.2f MB)" % (tarball, os.path.getsize(tarball) / 1024 / 1024))
|
||||
return tarball
|
||||
except Exception as e:
|
||||
print(" [失败] 打包异常:", str(e))
|
||||
return None
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
# ==================== Node 环境检查 & SSH 上传(deploy 模式) ====================
|
||||
|
||||
def check_node_environments(cfg):
|
||||
print("[检查] Node 环境 ...")
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
if cfg.get("ssh_key"):
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
|
||||
else:
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
|
||||
stdin, stdout, stderr = client.exec_command("which node && node -v", timeout=10)
|
||||
print(" 默认 Node: %s" % (stdout.read().decode("utf-8", errors="replace").strip() or "未找到"))
|
||||
node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin")
|
||||
stdin, stdout, stderr = client.exec_command("%s/node -v 2>/dev/null" % node_path, timeout=5)
|
||||
print(" 配置 Node: %s" % (stdout.read().decode("utf-8", errors="replace").strip() or "不可用"))
|
||||
return True
|
||||
except Exception as e:
|
||||
print(" [警告] %s" % str(e))
|
||||
return False
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def upload_and_extract(cfg, tarball_path):
|
||||
"""SSH 上传 tar.gz 并解压到 project_path(deploy 模式)"""
|
||||
print("[3/4] SSH 上传并解压 ...")
|
||||
if not cfg.get("password") and not cfg.get("ssh_key"):
|
||||
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
|
||||
return False
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
|
||||
else:
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
|
||||
sftp = client.open_sftp()
|
||||
remote_tar = "/tmp/soul_deploy.tar.gz"
|
||||
remote_script = "/tmp/soul_deploy_extract.sh"
|
||||
sftp.put(tarball_path, remote_tar)
|
||||
node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin")
|
||||
project_path = cfg["project_path"]
|
||||
script_content = """#!/bin/bash
|
||||
export PATH=%s:$PATH
|
||||
cd %s
|
||||
rm -rf .next public ecosystem.config.cjs server.js package.json 2>/dev/null
|
||||
tar -xzf %s
|
||||
rm -f %s
|
||||
echo OK
|
||||
""" % (node_path, project_path, remote_tar, remote_tar)
|
||||
with sftp.open(remote_script, "w") as f:
|
||||
f.write(script_content)
|
||||
sftp.close()
|
||||
client.exec_command("chmod +x %s" % remote_script, timeout=10)
|
||||
stdin, stdout, stderr = client.exec_command("bash %s" % remote_script, timeout=120)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
if exit_status != 0 or "OK" not in out:
|
||||
print(" [失败] 解压失败,退出码:", exit_status)
|
||||
return False
|
||||
print(" [成功] 解压完成: %s" % project_path)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(" [失败] SSH 错误:", str(e))
|
||||
return False
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def deploy_via_baota_api(cfg):
|
||||
"""宝塔 API 重启 Node 项目(deploy 模式)"""
|
||||
print("[4/4] 宝塔 API 管理 Node 项目 ...")
|
||||
panel_url, api_key, pm2_name = cfg["panel_url"], cfg["api_key"], cfg["pm2_name"]
|
||||
project_path = cfg["project_path"]
|
||||
node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin")
|
||||
port = cfg["port"]
|
||||
|
||||
if not get_node_project_status(panel_url, api_key, pm2_name):
|
||||
add_or_update_node_project(panel_url, api_key, pm2_name, project_path, port, node_path)
|
||||
stop_node_project(panel_url, api_key, pm2_name)
|
||||
time.sleep(2)
|
||||
ok = restart_node_project(panel_url, api_key, pm2_name)
|
||||
if not ok:
|
||||
ok = start_node_project(panel_url, api_key, pm2_name)
|
||||
if not ok:
|
||||
print(" 请到宝塔 Node 项目手动重启 %s,路径: %s" % (pm2_name, project_path))
|
||||
return ok
|
||||
|
||||
|
||||
# ==================== 打包(devlop 模式:zip) ====================
|
||||
|
||||
ZIP_EXCLUDE_DIRS = {".cache", "__pycache__", ".git", "node_modules", "cache", "test", "tests", "coverage", ".nyc_output", ".turbo", "开发文档"}
|
||||
ZIP_EXCLUDE_FILE_NAMES = {".DS_Store", "Thumbs.db"}
|
||||
ZIP_EXCLUDE_FILE_SUFFIXES = (".log", ".map")
|
||||
|
||||
|
||||
def _should_exclude_from_zip(arcname, is_file=True):
|
||||
parts = arcname.replace("\\", "/").split("/")
|
||||
for part in parts:
|
||||
if part in ZIP_EXCLUDE_DIRS:
|
||||
return True
|
||||
if is_file and parts:
|
||||
name = parts[-1]
|
||||
if name in ZIP_EXCLUDE_FILE_NAMES or any(name.endswith(s) for s in ZIP_EXCLUDE_FILE_SUFFIXES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def pack_standalone_zip(root):
|
||||
"""打包 standalone 为 zip(devlop 模式用)"""
|
||||
print("[2/7] 打包 standalone 为 zip ...")
|
||||
standalone = os.path.join(root, ".next", "standalone")
|
||||
static_src = os.path.join(root, ".next", "static")
|
||||
public_src = os.path.join(root, "public")
|
||||
ecosystem_src = os.path.join(root, "ecosystem.config.cjs")
|
||||
|
||||
if not os.path.isdir(standalone) or not os.path.isdir(static_src):
|
||||
print(" [失败] 未找到 .next/standalone 或 .next/static")
|
||||
return None
|
||||
chunks_dir = os.path.join(static_src, "chunks")
|
||||
if not os.path.isdir(chunks_dir):
|
||||
print(" [失败] .next/static/chunks 不存在,请先完整执行 pnpm build(本地 pnpm start 能正常打开页面后再部署)")
|
||||
return None
|
||||
|
||||
staging = tempfile.mkdtemp(prefix="soul_devlop_")
|
||||
try:
|
||||
for name in os.listdir(standalone):
|
||||
_copy_with_dereference(os.path.join(standalone, name), os.path.join(staging, name))
|
||||
node_modules_dst = os.path.join(staging, "node_modules")
|
||||
pnpm_dir = os.path.join(node_modules_dst, ".pnpm")
|
||||
if os.path.isdir(pnpm_dir):
|
||||
for dep in ["styled-jsx"]:
|
||||
dep_in_root = os.path.join(node_modules_dst, dep)
|
||||
if not os.path.exists(dep_in_root):
|
||||
for pnpm_pkg in os.listdir(pnpm_dir):
|
||||
if pnpm_pkg.startswith(dep + "@"):
|
||||
src_dep = os.path.join(pnpm_dir, pnpm_pkg, "node_modules", dep)
|
||||
if os.path.isdir(src_dep):
|
||||
shutil.copytree(src_dep, dep_in_root, symlinks=False, dirs_exist_ok=True)
|
||||
break
|
||||
os.makedirs(os.path.join(staging, ".next"), exist_ok=True)
|
||||
shutil.copytree(static_src, os.path.join(staging, ".next", "static"), dirs_exist_ok=True)
|
||||
# 同步构建索引(与 start-standalone.js 一致),避免宝塔上 server 用错导致页面空白/404
|
||||
next_root = os.path.join(root, ".next")
|
||||
next_staging = os.path.join(staging, ".next")
|
||||
index_files = [
|
||||
"BUILD_ID",
|
||||
"build-manifest.json",
|
||||
"app-path-routes-manifest.json",
|
||||
"routes-manifest.json",
|
||||
"prerender-manifest.json",
|
||||
"required-server-files.json",
|
||||
"fallback-build-manifest.json",
|
||||
]
|
||||
for name in index_files:
|
||||
src = os.path.join(next_root, name)
|
||||
if os.path.isfile(src):
|
||||
shutil.copy2(src, os.path.join(next_staging, name))
|
||||
print(" [已同步] 构建索引: BUILD_ID, build-manifest, routes-manifest 等")
|
||||
if os.path.isdir(public_src):
|
||||
shutil.copytree(public_src, os.path.join(staging, "public"), dirs_exist_ok=True)
|
||||
if os.path.isfile(ecosystem_src):
|
||||
shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs"))
|
||||
pkg_json = os.path.join(staging, "package.json")
|
||||
if os.path.isfile(pkg_json):
|
||||
try:
|
||||
with open(pkg_json, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data.setdefault("scripts", {})["start"] = "node server.js"
|
||||
with open(pkg_json, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
server_js = os.path.join(staging, "server.js")
|
||||
if os.path.isfile(server_js):
|
||||
try:
|
||||
deploy_port = int(os.environ.get("DEPLOY_PORT", str(DEFAULT_DEPLOY_PORT)))
|
||||
with open(server_js, "r", encoding="utf-8") as f:
|
||||
c = f.read()
|
||||
if "|| 3000" in c:
|
||||
with open(server_js, "w", encoding="utf-8") as f:
|
||||
f.write(c.replace("|| 3000", "|| %d" % deploy_port))
|
||||
except Exception:
|
||||
pass
|
||||
zip_path = os.path.join(tempfile.gettempdir(), "soul_devlop.zip")
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name in os.listdir(staging):
|
||||
path = os.path.join(staging, name)
|
||||
if os.path.isfile(path):
|
||||
if not _should_exclude_from_zip(name):
|
||||
zf.write(path, name)
|
||||
else:
|
||||
for dirpath, dirs, filenames in os.walk(path):
|
||||
dirs[:] = [d for d in dirs if not _should_exclude_from_zip(os.path.join(name, os.path.relpath(os.path.join(dirpath, d), path)), is_file=False)]
|
||||
for f in filenames:
|
||||
full = os.path.join(dirpath, f)
|
||||
arcname = os.path.join(name, os.path.relpath(full, path))
|
||||
if not _should_exclude_from_zip(arcname):
|
||||
zf.write(full, arcname)
|
||||
print(" [成功] 打包完成: %s (%.2f MB)" % (zip_path, os.path.getsize(zip_path) / 1024 / 1024))
|
||||
return zip_path
|
||||
except Exception as e:
|
||||
print(" [失败] 打包异常:", str(e))
|
||||
return None
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
def upload_zip_and_extract_to_dist2(cfg, zip_path):
|
||||
"""上传 zip 并解压到 dist2(devlop 模式)"""
|
||||
print("[3/7] SSH 上传 zip 并解压到 dist2 ...")
|
||||
sys.stdout.flush()
|
||||
if not cfg.get("password") and not cfg.get("ssh_key"):
|
||||
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
|
||||
return False
|
||||
zip_size_mb = os.path.getsize(zip_path) / (1024 * 1024)
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
print(" 正在连接 %s@%s:%s ..." % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
|
||||
sys.stdout.flush()
|
||||
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=30, banner_timeout=30)
|
||||
else:
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=30, banner_timeout=30)
|
||||
print(" [OK] SSH 已连接,正在上传 zip(%.1f MB)..." % zip_size_mb)
|
||||
sys.stdout.flush()
|
||||
remote_zip = cfg["base_path"].rstrip("/") + "/soul_devlop.zip"
|
||||
sftp = client.open_sftp()
|
||||
# 上传进度:每 5MB 打印一次
|
||||
chunk_mb = 5.0
|
||||
last_reported = [0]
|
||||
|
||||
def _progress(transferred, total):
|
||||
if total and total > 0:
|
||||
now_mb = transferred / (1024 * 1024)
|
||||
if now_mb - last_reported[0] >= chunk_mb or transferred >= total:
|
||||
last_reported[0] = now_mb
|
||||
print("\r 上传进度: %.1f / %.1f MB" % (now_mb, total / (1024 * 1024)), end="")
|
||||
sys.stdout.flush()
|
||||
|
||||
sftp.put(zip_path, remote_zip, callback=_progress)
|
||||
if zip_size_mb >= chunk_mb:
|
||||
print("")
|
||||
print(" [OK] zip 已上传,正在服务器解压(约 1–3 分钟)...")
|
||||
sys.stdout.flush()
|
||||
sftp.close()
|
||||
dist2 = cfg["dist2_path"]
|
||||
cmd = "rm -rf %s && mkdir -p %s && unzip -o -q %s -d %s && rm -f %s && echo OK" % (dist2, dist2, remote_zip, dist2, remote_zip)
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=300)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
err = stderr.read().decode("utf-8", errors="replace").strip()
|
||||
if err:
|
||||
print(" 服务器 stderr: %s" % err[:500])
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
if exit_status != 0 or "OK" not in out:
|
||||
print(" [失败] 解压失败,退出码: %s" % exit_status)
|
||||
if out:
|
||||
print(" stdout: %s" % out[:300])
|
||||
return False
|
||||
print(" [成功] 已解压到: %s" % dist2)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(" [失败] SSH 错误: %s" % str(e))
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def run_pnpm_install_in_dist2(cfg):
|
||||
"""服务器 dist2 内执行 pnpm install,阻塞等待完成后再返回(改目录前必须完成)"""
|
||||
print("[4/7] 服务器 dist2 内执行 pnpm install(等待完成后再切换目录)...")
|
||||
sys.stdout.flush()
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
|
||||
else:
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
|
||||
stdin, stdout, stderr = client.exec_command("bash -lc 'which pnpm'", timeout=10)
|
||||
pnpm_path = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
if not pnpm_path:
|
||||
return False, "未找到 pnpm,请服务器安装: npm install -g pnpm"
|
||||
cmd = "bash -lc 'cd %s && %s install'" % (cfg["dist2_path"], pnpm_path)
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=300)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
err = stderr.read().decode("utf-8", errors="replace").strip()
|
||||
if stdout.channel.recv_exit_status() != 0:
|
||||
return False, "pnpm install 失败\n" + (err or out)
|
||||
print(" [成功] dist2 内 pnpm install 已执行完成,可安全切换目录")
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def remote_swap_dist_and_restart(cfg):
|
||||
"""暂停 → dist→dist1, dist2→dist → 删除 dist1 → 更新 PM2 项目路径 → 重启(devlop 模式)"""
|
||||
print("[5/7] 宝塔 API 暂停 Node 项目 ...")
|
||||
stop_node_project(cfg["panel_url"], cfg["api_key"], cfg["pm2_name"])
|
||||
time.sleep(2)
|
||||
print("[6/7] 服务器切换目录: dist→dist1, dist2→dist ...")
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
|
||||
else:
|
||||
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
|
||||
cmd = "cd %s && mv dist dist1 2>/dev/null; mv dist2 dist && rm -rf dist1 && echo OK" % cfg["base_path"]
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=60)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
if stdout.channel.recv_exit_status() != 0 or "OK" not in out:
|
||||
print(" [失败] 切换失败")
|
||||
return False
|
||||
print(" [成功] 新版本位于 %s" % cfg["dist_path"])
|
||||
finally:
|
||||
client.close()
|
||||
# 关键:devlop 实际运行目录是 dist_path,必须让宝塔 PM2 从该目录启动,否则会从错误目录跑导致静态资源 404
|
||||
print("[7/7] 更新宝塔 Node 项目路径并重启 ...")
|
||||
add_or_update_node_project(
|
||||
cfg["panel_url"], cfg["api_key"], cfg["pm2_name"],
|
||||
cfg["dist_path"], # 使用 dist_path,不是 project_path
|
||||
port=cfg["port"],
|
||||
node_path=cfg.get("node_path"),
|
||||
)
|
||||
if not start_node_project(cfg["panel_url"], cfg["api_key"], cfg["pm2_name"]):
|
||||
print(" [警告] 请到宝塔手动启动 %s,并确认项目路径为: %s" % (cfg["pm2_name"], cfg["dist_path"]))
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Soul 创业派对 - 统一部署脚本", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__)
|
||||
parser.add_argument("--mode", choices=["devlop", "deploy"], default="devlop", help="devlop=dist切换(默认), deploy=直接覆盖")
|
||||
parser.add_argument("--no-build", action="store_true", help="跳过本地构建")
|
||||
parser.add_argument("--no-upload", action="store_true", help="仅 deploy 模式:跳过 SSH 上传")
|
||||
parser.add_argument("--no-api", action="store_true", help="仅 deploy 模式:上传后不调宝塔 API")
|
||||
args = parser.parse_args()
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# 支持 devlop.py 在项目根或 scripts/ 下:以含 package.json 的目录为 root
|
||||
if os.path.isfile(os.path.join(script_dir, "package.json")):
|
||||
root = script_dir
|
||||
else:
|
||||
root = os.path.dirname(script_dir)
|
||||
|
||||
if args.mode == "devlop":
|
||||
cfg = get_cfg_devlop()
|
||||
print("=" * 60)
|
||||
print(" Soul 自动部署(dist 切换)")
|
||||
print("=" * 60)
|
||||
print(" 服务器: %s@%s 目录: %s Node: %s" % (cfg["user"], cfg["host"], cfg["base_path"], cfg["pm2_name"]))
|
||||
print("=" * 60)
|
||||
if not args.no_build:
|
||||
print("[1/7] 本地构建 pnpm build ...")
|
||||
if sys.platform == "win32" and not clean_standalone_before_build(root):
|
||||
return 1
|
||||
if not run_build(root):
|
||||
return 1
|
||||
elif not os.path.isfile(os.path.join(root, ".next", "standalone", "server.js")):
|
||||
print("[错误] 未找到 .next/standalone/server.js")
|
||||
return 1
|
||||
else:
|
||||
print("[1/7] 跳过本地构建")
|
||||
zip_path = pack_standalone_zip(root)
|
||||
if not zip_path:
|
||||
return 1
|
||||
if not upload_zip_and_extract_to_dist2(cfg, zip_path):
|
||||
return 1
|
||||
try:
|
||||
os.remove(zip_path)
|
||||
except Exception:
|
||||
pass
|
||||
# 必须在 dist2 内 pnpm install 执行完成后再切换目录
|
||||
ok, err = run_pnpm_install_in_dist2(cfg)
|
||||
if not ok:
|
||||
print(" [失败] %s" % (err or "pnpm install 失败"))
|
||||
return 1
|
||||
# install 已完成,再执行 dist→dist1、dist2→dist 切换
|
||||
if not remote_swap_dist_and_restart(cfg):
|
||||
return 1
|
||||
print("")
|
||||
print(" 部署完成!运行目录: %s" % cfg["dist_path"])
|
||||
return 0
|
||||
|
||||
# deploy 模式
|
||||
cfg = get_cfg()
|
||||
print("=" * 60)
|
||||
print(" Soul 一键部署(直接覆盖)")
|
||||
print("=" * 60)
|
||||
print(" 服务器: %s@%s 项目路径: %s PM2: %s" % (cfg["user"], cfg["host"], cfg["project_path"], cfg["pm2_name"]))
|
||||
print("=" * 60)
|
||||
if not args.no_upload:
|
||||
check_node_environments(cfg)
|
||||
if not args.no_build:
|
||||
print("[1/4] 本地构建 ...")
|
||||
if not run_build(root):
|
||||
return 1
|
||||
elif not os.path.isfile(os.path.join(root, ".next", "standalone", "server.js")):
|
||||
print("[错误] 未找到 .next/standalone/server.js")
|
||||
return 1
|
||||
else:
|
||||
print("[1/4] 跳过本地构建")
|
||||
tarball = pack_standalone_tar(root)
|
||||
if not tarball:
|
||||
return 1
|
||||
if not args.no_upload:
|
||||
if not upload_and_extract(cfg, tarball):
|
||||
return 1
|
||||
try:
|
||||
os.remove(tarball)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
print(" 压缩包: %s" % tarball)
|
||||
if not args.no_api and not args.no_upload:
|
||||
deploy_via_baota_api(cfg)
|
||||
print("")
|
||||
print(" 部署完成!站点: %s" % cfg["site_url"])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 从 GitHub fnvtk/Mycontent 的 yongpxu-soul 分支下载最新到「一场soul的创业实验-永平」
|
||||
# 用法: bash 从GitHub下载最新.sh
|
||||
|
||||
set -e
|
||||
REPO="https://github.com/fnvtk/Mycontent.git"
|
||||
BRANCH="yongpxu-soul"
|
||||
YONGPING_ROOT="/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平"
|
||||
TMP_DIR="/tmp/Mycontent_yongpxu_soul_dl"
|
||||
|
||||
echo "===== 1. 查询远程最新 commit ====="
|
||||
git ls-remote "$REPO" "refs/heads/$BRANCH" 2>/dev/null || { echo "无法连接 GitHub,请检查网络或代理"; exit 1; }
|
||||
|
||||
echo ""
|
||||
echo "===== 2. 克隆/更新 $BRANCH 到临时目录 ====="
|
||||
if [ -d "$TMP_DIR" ]; then
|
||||
cd "$TMP_DIR"
|
||||
git fetch origin "$BRANCH" 2>/dev/null || true
|
||||
git checkout "$BRANCH" 2>/dev/null || true
|
||||
git pull origin "$BRANCH" 2>/dev/null || true
|
||||
else
|
||||
git clone --depth 1 --branch "$BRANCH" "$REPO" "$TMP_DIR"
|
||||
cd "$TMP_DIR"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "===== 3. 最近 5 次提交(本次会同步的内容)====="
|
||||
git log -5 --oneline
|
||||
|
||||
echo ""
|
||||
echo "===== 4. 同步到永平目录(覆盖 soul-admin / soul-api / miniprogram / 开发文档 / scripts 等)====="
|
||||
# 不删本地独有文件,只覆盖仓库里有的
|
||||
for dir in soul-admin soul-api miniprogram 开发文档 scripts; do
|
||||
if [ -d "$TMP_DIR/$dir" ]; then
|
||||
echo " 同步 $dir ..."
|
||||
rsync -a --exclude='node_modules' --exclude='.next' --exclude='dist' "$TMP_DIR/$dir/" "$YONGPING_ROOT/$dir/"
|
||||
fi
|
||||
done
|
||||
# 根目录常见文件
|
||||
for f in content_upload.py 本机运行文档.md; do
|
||||
if [ -f "$TMP_DIR/$f" ]; then
|
||||
cp "$TMP_DIR/$f" "$YONGPING_ROOT/$f"
|
||||
echo " 复制 $f"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "===== 完成 ====="
|
||||
echo "最新已同步到: $YONGPING_ROOT"
|
||||
echo "如需查看完整差异,可到 $TMP_DIR 执行 git log"
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 从 GitHub fnvtk/Mycontent 的 devlop 分支下载最新到「一场soul的创业实验-永平」
|
||||
# 用法: bash 从GitHub下载最新_devlop.sh
|
||||
|
||||
set -e
|
||||
REPO="https://github.com/fnvtk/Mycontent.git"
|
||||
BRANCH="devlop"
|
||||
YONGPING_ROOT="/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平"
|
||||
TMP_DIR="/tmp/Mycontent_devlop_dl"
|
||||
|
||||
echo "===== 1. 查询远程最新 commit ($BRANCH) ====="
|
||||
git ls-remote "$REPO" "refs/heads/$BRANCH" 2>/dev/null || { echo "无法连接 GitHub,请检查网络或代理"; exit 1; }
|
||||
|
||||
echo ""
|
||||
echo "===== 2. 克隆/更新 $BRANCH 到临时目录 ====="
|
||||
if [ -d "$TMP_DIR" ]; then
|
||||
cd "$TMP_DIR"
|
||||
git fetch origin "$BRANCH" 2>/dev/null || true
|
||||
git checkout "$BRANCH" 2>/dev/null || true
|
||||
git pull origin "$BRANCH" 2>/dev/null || true
|
||||
else
|
||||
git clone --depth 1 --branch "$BRANCH" "$REPO" "$TMP_DIR"
|
||||
cd "$TMP_DIR"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "===== 3. 最近 5 次提交(本次会同步的内容)====="
|
||||
git log -5 --oneline
|
||||
echo ""
|
||||
echo "最新一条提交时间:"
|
||||
git log -1 --format="%ci %s"
|
||||
|
||||
echo ""
|
||||
echo "===== 4. 同步到永平目录 ====="
|
||||
for dir in soul-admin soul-api miniprogram 开发文档 scripts; do
|
||||
if [ -d "$TMP_DIR/$dir" ]; then
|
||||
echo " 同步 $dir ..."
|
||||
rsync -a --exclude='node_modules' --exclude='.next' --exclude='dist' "$TMP_DIR/$dir/" "$YONGPING_ROOT/$dir/"
|
||||
fi
|
||||
done
|
||||
for f in content_upload.py 本机运行文档.md; do
|
||||
if [ -f "$TMP_DIR/$f" ]; then
|
||||
cp "$TMP_DIR/$f" "$YONGPING_ROOT/$f"
|
||||
echo " 复制 $f"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "===== 完成 ====="
|
||||
echo "devlop 分支最新已同步到: $YONGPING_ROOT"
|
||||
127
分销提现流程图.md
127
分销提现流程图.md
@@ -1,127 +0,0 @@
|
||||
# 分销提现流程图
|
||||
|
||||
## 一、整体流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 小 程 序 端 │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
[用户] 推广中心 → 可提现金额 ≥ 最低额 → 点击「申请提现」
|
||||
│
|
||||
▼
|
||||
POST /api/miniprogram/withdraw (WithdrawPost)
|
||||
│ 校验:可提现余额、最低金额、用户 openId
|
||||
▼
|
||||
写入 withdrawals:status = pending
|
||||
│
|
||||
▼
|
||||
提示「提现申请已提交,审核通过后将打款至您的微信零钱」
|
||||
|
||||
─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 管 理 端 (soul-admin) │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
[管理员] 分销 / 提现审核 → GET /api/admin/withdrawals 拉列表
|
||||
│
|
||||
├── 点「拒绝」 → PUT /api/admin/withdrawals { action: "reject" }
|
||||
│ ▼
|
||||
│ status = failed,写 error_message
|
||||
│
|
||||
└── 点「通过」 → PUT /api/admin/withdrawals { action: "approve" }
|
||||
│
|
||||
▼
|
||||
调 wechat.InitiateTransferByFundApp (FundApp 单笔)
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
[微信报错] [未返回单号] [成功受理]
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
status=failed status=failed status=processing
|
||||
返回报错信息 返回提示 写 detail_no,batch_no,batch_id
|
||||
返回「已发起打款,微信处理中」
|
||||
|
||||
─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 微 信 侧 与 回 调 │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
微信异步打款
|
||||
│
|
||||
▼
|
||||
打款结果 → POST /api/payment/wechat/transfer/notify (PaymentWechatTransferNotify)
|
||||
│ 验签、解密,得到 out_bill_no / transfer_bill_no / state / fail_reason
|
||||
│ 用 detail_no = out_bill_no 找到提现记录,且仅当 status 为 processing / pending_confirm 时更新
|
||||
▼
|
||||
state=SUCCESS → status = success
|
||||
state=FAIL/CANCELLED → status = failed,写 fail_reason
|
||||
|
||||
─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 可 选:主 动 同 步 │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
管理端 POST /api/admin/withdrawals/sync(可带 id 同步单条,或不带 id 同步所有)
|
||||
│ 只处理 status IN (processing, pending_confirm)
|
||||
│ FundApp 单笔:用 detail_no 调 QueryTransferByOutBill
|
||||
▼
|
||||
按微信返回的 state 更新 status = success / failed(与回调逻辑一致)
|
||||
|
||||
─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 小 程 序「我 的」- 待 确 认 收 款 │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
[用户] 我的页 → 仅登录显示「待确认收款」区块
|
||||
│
|
||||
▼
|
||||
GET /api/miniprogram/withdraw/pending-confirm?userId=xxx (WithdrawPendingConfirm)
|
||||
│ 只返回 status IN (processing, pending_confirm) 的提现(审核通过后的)
|
||||
▼
|
||||
展示列表:金额、日期、「确认收款」按钮
|
||||
│
|
||||
▼
|
||||
点击「确认收款」→ 需要 item.package + mchId + appId 调 wx.requestMerchantTransfer
|
||||
│ 当前后端 list 里 package 为空,故会提示「请稍后刷新再试」
|
||||
└─ 若后续接入微信返回的 package,可在此完成「用户确认收款」闭环
|
||||
```
|
||||
|
||||
## 二、状态流转
|
||||
|
||||
| 阶段 | 状态 (status) | 含义 |
|
||||
|--------------|----------------|------|
|
||||
| 用户申请 | **pending** | 待审核,已占可提现额度 |
|
||||
| 管理员通过 | **processing** | 已发起打款,微信处理中 |
|
||||
| 微信回调成功 | **success** | 打款成功(已到账) |
|
||||
| 微信回调失败/拒绝 | **failed** | 打款失败,写 fail_reason |
|
||||
| 预留 | **pending_confirm** | 待用户确认收款(当前流程未改此状态,仅接口可返回) |
|
||||
|
||||
## 三、可提现与待确认口径
|
||||
|
||||
- **可提现** = 累计佣金 − 已提现 − 待审核金额
|
||||
待审核金额 = 所有 status 为 `pending`、`processing`、`pending_confirm` 的提现金额之和。
|
||||
- **待确认收款列表**:仅包含 **审核已通过** 的提现,即 status 为 `processing` 或 `pending_confirm`,不包含 `pending`。
|
||||
|
||||
## 四、主要接口与代码位置
|
||||
|
||||
| 环节 | 接口/行为 | 代码位置 |
|
||||
|------------|-----------|----------|
|
||||
| 用户申请 | POST `/api/miniprogram/withdraw` | soul-api `internal/handler/withdraw.go` WithdrawPost |
|
||||
| 可提现计算 | referral/data、withdraw 校验 | `withdraw.go` computeAvailableWithdraw;`referral.go` 提现统计 |
|
||||
| 管理端列表 | GET `/api/admin/withdrawals` | `internal/handler/admin_withdrawals.go` AdminWithdrawalsList |
|
||||
| 管理端通过/拒绝 | PUT `/api/admin/withdrawals` | `admin_withdrawals.go` AdminWithdrawalsAction |
|
||||
| 微信打款 | FundApp 单笔 | soul-api `internal/wechat/transfer.go` InitiateTransferByFundApp |
|
||||
| 微信回调 | POST `/api/payment/wechat/transfer/notify` | `internal/handler/payment.go` PaymentWechatTransferNotify |
|
||||
| 管理端同步 | POST `/api/admin/withdrawals/sync` | `admin_withdrawals.go` AdminWithdrawalsSync |
|
||||
| 待确认列表 | GET `/api/miniprogram/withdraw/pending-confirm` | `withdraw.go` WithdrawPendingConfirm |
|
||||
|
||||
## 五、说明
|
||||
|
||||
- 当前实现:审核通过后直接调微信 FundApp 单笔打款,最终由**微信回调**或**管理端同步**把状态更新为 success/failed。
|
||||
- 「待确认收款」列表只展示已审核通过的记录;点击「确认收款」需后端下发的 `package` 才能调起 `wx.requestMerchantTransfer`,目前该字段为空,前端会提示「请稍后刷新再试」。若后续接入微信返回的 package,可在此完成用户确认收款闭环。
|
||||
54
技术文档.md
54
技术文档.md
@@ -1,54 +0,0 @@
|
||||
# soul-api Go 技术栈
|
||||
|
||||
## 语言与运行时
|
||||
|
||||
- **Go 1.25**
|
||||
|
||||
## Web 框架与 HTTP
|
||||
|
||||
- **Gin**(`github.com/gin-gonic/gin`):HTTP 路由与请求处理
|
||||
- **gin-contrib/cors**:跨域
|
||||
- **unrolled/secure**:安全头(HTTPS 重定向、HSTS 等,在 `middleware.Secure()` 中使用)
|
||||
|
||||
## 数据层
|
||||
|
||||
- **GORM**(`gorm.io/gorm`):ORM
|
||||
- **GORM MySQL 驱动**(`gorm.io/driver/mysql`):连接 MySQL
|
||||
- **go-sql-driver/mysql**:底层 MySQL 驱动(GORM 间接依赖)
|
||||
|
||||
## 微信生态
|
||||
|
||||
- **PowerWeChat**(`github.com/ArtisanCloud/PowerWeChat/v3`):微信开放能力(小程序、支付、商家转账等)
|
||||
- **PowerLibs**(`github.com/ArtisanCloud/PowerLibs/v3`):PowerWeChat 依赖
|
||||
|
||||
## 配置与环境
|
||||
|
||||
- **godotenv**(`github.com/joho/godotenv`):从 `.env` 加载环境变量
|
||||
- 业务配置集中在 `internal/config`,通过 `config.Load()` 读取
|
||||
|
||||
## 鉴权与安全
|
||||
|
||||
- **golang-jwt/jwt/v5**:管理端 JWT 签发与校验(`internal/auth/adminjwt.go`)
|
||||
- 管理端路由使用 `middleware.AdminAuth()` 做 JWT 校验
|
||||
|
||||
## 工具与间接依赖
|
||||
|
||||
- **golang.org/x/time**:时间/限流相关(如 `rate`)
|
||||
- **gin-contrib/sse**:SSE(Gin 间接)
|
||||
- **bytedance/sonic**:JSON 编解码(Gin 默认)
|
||||
- **go-playground/validator**:请求体校验(Gin 的 `ShouldBindJSON` 等)
|
||||
- **redis/go-redis**:仅在依赖图中出现(PowerWeChat 等间接引入),项目代码中未直接使用 Redis
|
||||
|
||||
## 项目结构(技术栈视角)
|
||||
|
||||
| 层级 | 技术/约定 |
|
||||
|----------|------------|
|
||||
| 入口 | `cmd/server/main.go`,标准库 `net/http` + Gin |
|
||||
| 路由 | `internal/router`,Gin Group(`/api`、`/admin`、`/miniprogram` 等) |
|
||||
| 中间件 | CORS、Secure、限流(`middleware.RateLimiter`)、管理端 JWT |
|
||||
| 业务逻辑 | `internal/handler`,GORM + `internal/model` |
|
||||
| 数据访问 | `internal/database` 提供 `DB() *gorm.DB`,统一用 GORM |
|
||||
| 微信相关 | `internal/wechat`(小程序、支付、转账等封装) |
|
||||
| 开发工具 | `.air.toml` 热重载、Makefile |
|
||||
|
||||
整体上是一个 **Gin + GORM + MySQL + 微信 PowerWeChat + JWT 管理端鉴权** 的 Go 后端,面向小程序与管理端 API。
|
||||
239
本机运行文档.md
239
本机运行文档.md
@@ -1,239 +0,0 @@
|
||||
# Soul 永平版 · 本机运行文档
|
||||
|
||||
> 基于 KR 宝塔 (43.139.27.93) 实际运行配置整理,目录与线上一致。
|
||||
|
||||
---
|
||||
|
||||
## 一、服务器实际运行架构
|
||||
|
||||
### 1.1 进程与端口
|
||||
|
||||
| 进程 | 路径 | 端口 | 域名 |
|
||||
|------|------|------|------|
|
||||
| soul-api(正式) | `/www/wwwroot/自营/soul-api/soul-api` | 8080 | soulapi.quwanzhi.com |
|
||||
| soul-dev(开发) | `/www/wwwroot/自营/soul-dev/soul-api` | 8081 | souldev.quwanzhi.com |
|
||||
| soul-admin | 静态 | - | souladmin.quwanzhi.com |
|
||||
| soul 主站(Next.js) | `/www/wwwroot/soul` (PM2) | 3006 | soul.quwanzhi.com |
|
||||
| soul-book-api | `/www/wwwroot/self/soul-book-api` (systemd) | 3007 | 内部中间件 |
|
||||
|
||||
### 1.2 目录对应关系
|
||||
|
||||
| 服务器路径 | 本地路径 | 说明 |
|
||||
|------------|----------|------|
|
||||
| 自营/soul-api | soul-api/ | Go API 二进制 + .env + certs |
|
||||
| 自营/soul-dev | soul-dev/ | Go 开发 API(端口 8081) |
|
||||
| 自营/soul-admin | soul-admin/ | Vue 管理后台 dist |
|
||||
| 自营/soul | soul/ | Next.js 主站(含 dist/.next/standalone) |
|
||||
|
||||
---
|
||||
|
||||
## 二、本机运行步骤
|
||||
|
||||
### 2.1 启动 Go API(soul-api)
|
||||
|
||||
```bash
|
||||
cd soul-api
|
||||
./soul-api
|
||||
```
|
||||
|
||||
- **注意**:`soul-api` 为 Linux x86-64 可执行文件,**Mac 无法直接运行**。可选:
|
||||
- 使用线上 API:`https://soulapi.quwanzhi.com`(管理后台默认)
|
||||
- 在 Linux 服务器或 Docker 中运行 soul-api
|
||||
- 若有 Go 源码,可在本机 `go build` 后运行
|
||||
- 默认端口:8080(由 `.env` 中 `PORT=8080` 控制)
|
||||
- 依赖:`.env`、`certs/apiclient_cert.pem`、`certs/apiclient_key.pem`
|
||||
- 数据库:腾讯云 MySQL `soul_miniprogram`(.env 已配置)
|
||||
- 健康检查:`curl http://localhost:8080/health`
|
||||
|
||||
### 2.2 启动管理后台(soul-admin)
|
||||
|
||||
```bash
|
||||
cd soul-admin && pnpm dev
|
||||
```
|
||||
|
||||
- 访问:http://localhost:5174(端口占用时自动切到 5175)
|
||||
- API 地址:本地开发自动请求 `http://localhost:8080`(见 `.env.development`)
|
||||
|
||||
### 2.2.1 一键本地启动(推荐)
|
||||
|
||||
```bash
|
||||
bash scripts/本地启动.sh
|
||||
```
|
||||
|
||||
- 自动编译并启动 soul-api(Mac 版),再启动 soul-admin
|
||||
- 访问 http://localhost:5174,账号 `admin` / `admin123`
|
||||
|
||||
### 2.3 启动主站(soul 主站)
|
||||
|
||||
```bash
|
||||
cd soul/dist
|
||||
PORT=3006 node server.js
|
||||
```
|
||||
|
||||
- 访问:http://localhost:3006
|
||||
- 依赖:`soul/dist/.env` 中的 `DATABASE_URL`(已配置腾讯云 MySQL)
|
||||
- 如缺少依赖:`cd soul/dist && pnpm install`(可选)
|
||||
|
||||
### 2.4 一键启动(三服务)
|
||||
|
||||
```bash
|
||||
# 终端 1:Go API
|
||||
cd soul-api && ./soul-api
|
||||
|
||||
# 终端 2:管理后台
|
||||
npx serve soul-admin/dist -p 5174
|
||||
|
||||
# 终端 3:主站
|
||||
cd soul/dist && PORT=3006 node server.js
|
||||
```
|
||||
|
||||
访问:
|
||||
|
||||
- 主站:http://localhost:3006
|
||||
- 管理后台:http://localhost:5174
|
||||
- API:http://localhost:8080
|
||||
|
||||
---
|
||||
|
||||
## 三、关键配置文件
|
||||
|
||||
### 3.1 soul-api/.env
|
||||
|
||||
| 配置项 | 说明 | 本机 |
|
||||
|--------|------|------|
|
||||
| PORT | 服务端口 | 8080 |
|
||||
| DB_DSN | 数据库连接串 | 已配置腾讯云 |
|
||||
| WECHAT_* | 微信支付/转账 | 已配置,本机一般不影响浏览 |
|
||||
| WECHAT_CERT_PATH / WECHAT_KEY_PATH | 证书路径 | certs/ 下 |
|
||||
| CORS_ORIGINS | 允许的跨域源 | 含 localhost、soul.quwanzhi.com |
|
||||
|
||||
### 3.2 soul-admin 的 API 地址(本地 vs 部署)
|
||||
|
||||
| 环境 | 配置文件 | API 地址 |
|
||||
|------|----------|----------|
|
||||
| 本地开发 `pnpm dev` | `.env.development` | `http://localhost:8080` |
|
||||
| 部署构建 `pnpm build` | `.env.production` | `https://soulapi.quwanzhi.com` |
|
||||
|
||||
**流程**:本地改代码用 `pnpm dev`,会自动请求本机 soul-api;部署时执行 `pnpm build`,产物自动用线上 API,无需改配置。
|
||||
|
||||
---
|
||||
|
||||
## 四、常见问题
|
||||
|
||||
1. **Mac 无法运行 soul-api**
|
||||
soul-api 为 Linux 二进制,Mac 上需用线上 API 或 Docker/Linux 环境。管理后台默认请求 soulapi.quwanzhi.com,网络可达即可用。
|
||||
|
||||
2. **Linux 下 soul-api 权限不足**
|
||||
`chmod +x soul-api/soul-api`
|
||||
|
||||
3. **soul-admin 请求 API 跨域**
|
||||
soul-api 已配置 CORS,包含 `http://localhost:5174`;本机通过 hosts 指向 8080 时一般无跨域问题。
|
||||
|
||||
4. **主站 3006 端口被占用**
|
||||
修改启动命令:`PORT=3007 node server.js`
|
||||
|
||||
5. **soul-api 连接数据库失败**
|
||||
检查 `.env` 中 DB_DSN 及本机网络是否能访问腾讯云 MySQL。
|
||||
|
||||
---
|
||||
|
||||
## 五、线上 Nginx 参考
|
||||
|
||||
- soulapi.quwanzhi.com → `proxy_pass http://127.0.0.1:8080`
|
||||
- souldev.quwanzhi.com → `proxy_pass http://127.0.0.1:8081`
|
||||
- souladmin.quwanzhi.com → `root .../soul-admin/dist`(静态)
|
||||
- soul.quwanzhi.com → Kr宝塔本机:
|
||||
- `/api/book/latest-chapters`、`/api/book/all-chapters` → 3007(soul-book-api)
|
||||
- `/api/vip/`、`/api/withdraw/`、`/api/match/`、`/api/user/`、`/api/admin/` 等 → 3006(Next.js)
|
||||
- `/api/miniprogram/login`、`/api/miniprogram/pay`、`/api/referral/` 等 → 8080(Go API)
|
||||
- `/admin` → 3006(Next.js 管理后台)
|
||||
- `/_next/` → 3006(Next.js 静态资源)
|
||||
- `/` → soul-admin/dist(SPA 前端)
|
||||
|
||||
### Nginx 路由优先级
|
||||
1. 精确匹配 `= /api/book/latest-chapters` → 3007
|
||||
2. 前缀匹配 `/api/vip/`、`/api/withdraw/` 等 → 3006
|
||||
3. 默认 `/api/` → 8080(Go API)
|
||||
4. `/admin` → 3006
|
||||
5. `/_next/` → 3006
|
||||
6. `/` → 静态文件
|
||||
|
||||
---
|
||||
|
||||
## 六、HTTP 502 问题排查与预防(管理后台登录)
|
||||
|
||||
### 6.1 问题原因
|
||||
|
||||
souladmin.quwanzhi.com 登录时出现 **HTTP 502**,通常由以下其一导致:
|
||||
|
||||
1. **Nginx 未运行** → 整站不可用
|
||||
2. **soul-dev (8081) 未运行** → souladmin 的 `/api/` 代理失败
|
||||
3. **api-proxy 错误配置** → 代理到外网 `https://souldev.quwanzhi.com` 易超时/502
|
||||
|
||||
### 6.2 修复步骤(Kr宝塔 43.139.27.93:22022)
|
||||
|
||||
```bash
|
||||
# 1. 检查 Nginx
|
||||
systemctl status nginx
|
||||
systemctl start nginx # 若未运行
|
||||
|
||||
# 2. 检查 8081
|
||||
ss -tlnp | grep 8081
|
||||
# 无则启动: cd /www/wwwroot/自营/soul-dev && ./soul-api &
|
||||
|
||||
# 3. 确认 api-proxy 直连本机(避免外网绕行)
|
||||
# 文件: /www/server/panel/vhost/nginx/extension/souladmin.quwanzhi.com/api-proxy.conf
|
||||
# proxy_pass 应为: http://127.0.0.1:8081
|
||||
```
|
||||
|
||||
### 6.3 预防(开机自启)
|
||||
|
||||
已配置 systemd 服务,重启后自动拉起:
|
||||
|
||||
- `soul-api.service` → 8080
|
||||
- `soul-dev.service` → 8081
|
||||
- `nginx.service` → 默认 enabled
|
||||
|
||||
若需改为 systemd 管理:
|
||||
|
||||
```bash
|
||||
systemctl enable soul-api soul-dev
|
||||
systemctl restart soul-api soul-dev
|
||||
```
|
||||
|
||||
### 6.4 souladmin 登录「Failed to fetch」
|
||||
|
||||
与 502 同源:前端无法连到 `/api/admin` 后端。处理方式同上(确保 Nginx、soul-dev 运行,api-proxy 指向 `http://127.0.0.1:8081`)。若本机 IP 被 fail2ban 封禁,解封后再试(见第七章)。
|
||||
|
||||
---
|
||||
|
||||
## 七、SSH 封禁与免密配置(避免 sshpass 触发限制)
|
||||
|
||||
### 7.1 现象
|
||||
|
||||
使用 `sshpass` 频繁 SSH 登录后,出现 `Connection closed by ... port 22022`,多为 fail2ban 封禁。
|
||||
|
||||
### 7.2 解封(需通过宝塔终端或 VNC)
|
||||
|
||||
**无法 SSH 时**:登录 宝塔面板 → 终端,在服务器上执行:
|
||||
|
||||
```bash
|
||||
# 解封所有 fail2ban 封禁
|
||||
fail2ban-client unban --all 2>/dev/null
|
||||
|
||||
# 放宽 SSH 限制(可选)
|
||||
sed -i 's/maxretry = .*/maxretry = 15/' /etc/fail2ban/jail.local 2>/dev/null || true
|
||||
systemctl restart fail2ban 2>/dev/null
|
||||
```
|
||||
|
||||
完整脚本见:`scripts/服务器解封与免密配置.sh`(需先 scp 到服务器或复制内容执行)。
|
||||
|
||||
### 7.3 改用 SSH 密钥(推荐)
|
||||
|
||||
本机执行:
|
||||
|
||||
```bash
|
||||
bash scripts/本机配置SSH免密登录.sh
|
||||
```
|
||||
|
||||
配置完成后,`部署永平到Kr宝塔.sh` 会优先使用密钥,不再用 sshpass,避免再次触发封禁。
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 1) 以本地为准推送到 GitHub yongpxu-soul
|
||||
# 2) 打包 → SCP 上传 → SSH 解压并 pnpm install + build
|
||||
# 3) 使用宝塔 API 重启 Node 项目(不用 pm2 命令)
|
||||
# 在「一场soul的创业实验」目录下执行
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "===== 1. 推送到 GitHub(以本地为准)====="
|
||||
git push origin yongpxu-soul --force-with-lease
|
||||
|
||||
echo "===== 2. 打包 ====="
|
||||
tar --exclude='node_modules' --exclude='.next' --exclude='.git' -czf /tmp/soul_update.tar.gz .
|
||||
|
||||
echo "===== 3. 上传到宝塔服务器 ====="
|
||||
sshpass -p 'Zhiqun1984' scp /tmp/soul_update.tar.gz root@42.194.232.22:/tmp/
|
||||
|
||||
echo "===== 4. SSH:解压、安装、构建(不执行 pm2)====="
|
||||
sshpass -p 'Zhiqun1984' ssh root@42.194.232.22 "
|
||||
cd /www/wwwroot/soul
|
||||
rm -rf app components lib public styles *.json *.js *.ts *.mjs *.md .next
|
||||
tar -xzf /tmp/soul_update.tar.gz
|
||||
rm /tmp/soul_update.tar.gz
|
||||
export PATH=/www/server/nodejs/v22.14.0/bin:\$PATH
|
||||
pnpm install
|
||||
pnpm run build
|
||||
"
|
||||
|
||||
echo "===== 5. 宝塔 API 重启 Node 项目 soul ====="
|
||||
BT_HOST="42.194.232.22"
|
||||
BT_PORT="9988"
|
||||
BT_KEY="hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd"
|
||||
REQUEST_TIME=$(date +%s)
|
||||
# request_token = md5( request_time + md5(api_key) ),兼容 macOS/Linux
|
||||
md5hex() { printf '%s' "$1" | openssl md5 2>/dev/null | awk '{print $NF}' || true; }
|
||||
MD5_KEY=$(md5hex "$BT_KEY")
|
||||
SIGN_STR="${REQUEST_TIME}${MD5_KEY}"
|
||||
REQUEST_TOKEN=$(md5hex "$SIGN_STR")
|
||||
|
||||
RESP=$(curl -s -k -X POST "https://${BT_HOST}:${BT_PORT}/project/nodejs/restart_project" \
|
||||
-d "request_time=${REQUEST_TIME}" \
|
||||
-d "request_token=${REQUEST_TOKEN}" \
|
||||
-d "project_name=soul" 2>/dev/null || true)
|
||||
|
||||
if echo "$RESP" | grep -q '"status":true\|"status": true'; then
|
||||
echo "宝塔 API 重启成功: $RESP"
|
||||
else
|
||||
echo "宝塔 API 返回(若失败请到面板手动重启): $RESP"
|
||||
fi
|
||||
|
||||
echo "===== 部署完成 ====="
|
||||
52
部署到Kr宝塔.sh
52
部署到Kr宝塔.sh
@@ -1,52 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 部署到 Kr宝塔 (43.139.27.93):打包 → SCP(端口22022) → SSH 解压构建 → 宝塔 API 重启
|
||||
# 不用 pm2 命令,用宝塔 API 操作。在「一场soul的创业实验」目录下执行。
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
SSH_PORT="22022"
|
||||
BT_HOST="43.139.27.93"
|
||||
BT_PORT="9988"
|
||||
BT_KEY="qcWubCdlfFjS2b2DMT1lzPFaDfmv1cBT"
|
||||
PROJECT_PATH="/www/wwwroot/soul"
|
||||
PROJECT_NAME="soul"
|
||||
|
||||
echo "===== 1. 打包 ====="
|
||||
tar --exclude='node_modules' --exclude='.next' --exclude='.git' -czf /tmp/soul_update.tar.gz .
|
||||
|
||||
echo "===== 2. 上传到 Kr宝塔 (${BT_HOST}:${SSH_PORT}) ====="
|
||||
sshpass -p 'Zhiqun1984' scp -P "$SSH_PORT" /tmp/soul_update.tar.gz root@${BT_HOST}:/tmp/
|
||||
|
||||
echo "===== 3. SSH:解压、安装、构建(不执行 pm2)====="
|
||||
sshpass -p 'Zhiqun1984' ssh -p "$SSH_PORT" root@${BT_HOST} "
|
||||
mkdir -p ${PROJECT_PATH}
|
||||
cd ${PROJECT_PATH}
|
||||
rm -rf app components lib public styles *.json *.js *.ts *.mjs *.md .next
|
||||
tar -xzf /tmp/soul_update.tar.gz
|
||||
rm /tmp/soul_update.tar.gz
|
||||
export PATH=/www/server/nodejs/v22.14.0/bin:\$PATH
|
||||
[ -x \"\$(command -v pnpm)\" ] || npm i -g pnpm
|
||||
pnpm install
|
||||
pnpm run build
|
||||
"
|
||||
|
||||
echo "===== 4. 宝塔 API 重启 Node 项目 ${PROJECT_NAME} ====="
|
||||
REQUEST_TIME=$(date +%s)
|
||||
md5hex() { printf '%s' "$1" | openssl md5 2>/dev/null | awk '{print $NF}' || true; }
|
||||
MD5_KEY=$(md5hex "$BT_KEY")
|
||||
SIGN_STR="${REQUEST_TIME}${MD5_KEY}"
|
||||
REQUEST_TOKEN=$(md5hex "$SIGN_STR")
|
||||
|
||||
RESP=$(curl -s -k -X POST "https://${BT_HOST}:${BT_PORT}/project/nodejs/restart_project" \
|
||||
-d "request_time=${REQUEST_TIME}" \
|
||||
-d "request_token=${REQUEST_TOKEN}" \
|
||||
-d "project_name=${PROJECT_NAME}" 2>/dev/null || true)
|
||||
|
||||
if echo "$RESP" | grep -q '"status":true\|"status": true'; then
|
||||
echo "宝塔 API 重启成功: $RESP"
|
||||
else
|
||||
echo "宝塔 API 返回(若失败请到面板 网站→Node项目→${PROJECT_NAME}→重启): $RESP"
|
||||
fi
|
||||
|
||||
echo "===== 部署到 Kr宝塔 完成 ====="
|
||||
89
部署永平到Kr宝塔.sh
89
部署永平到Kr宝塔.sh
@@ -1,89 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 从「一场soul的创业实验-永平」部署到 Kr宝塔 (43.139.27.93)
|
||||
# 部署 next-project (Next.js 应用) + miniprogram (小程序)
|
||||
|
||||
set -e
|
||||
YONGPING_ROOT="/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平"
|
||||
MAIN_PROJECT="/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验"
|
||||
|
||||
SSH_PORT="22022"
|
||||
BT_HOST="43.139.27.93"
|
||||
PROJECT_PATH="/www/wwwroot/soul"
|
||||
|
||||
SSH_KEY="$HOME/.ssh/id_ed25519_soul_kr"
|
||||
if [ -f "$SSH_KEY" ]; then
|
||||
SSH_CMD="ssh -i $SSH_KEY -p $SSH_PORT -o StrictHostKeyChecking=no"
|
||||
SCP_CMD="scp -i $SSH_KEY -P $SSH_PORT -o StrictHostKeyChecking=no"
|
||||
else
|
||||
SSH_CMD="sshpass -p 'Zhiqun1984' ssh -p $SSH_PORT -o StrictHostKeyChecking=no"
|
||||
SCP_CMD="sshpass -p 'Zhiqun1984' scp -P $SSH_PORT -o StrictHostKeyChecking=no"
|
||||
echo "提示: 运行 scripts/本机配置SSH免密登录.sh 可改为密钥登录"
|
||||
fi
|
||||
|
||||
echo "===== 1. 同步主项目到永平/soul ====="
|
||||
mkdir -p "$YONGPING_ROOT/soul"
|
||||
rsync -a --delete \
|
||||
--exclude='node_modules' --exclude='.next' --exclude='.git' --exclude='*.sh' \
|
||||
"$MAIN_PROJECT/" "$YONGPING_ROOT/soul/"
|
||||
echo "同步完成"
|
||||
|
||||
echo "===== 2. 打包 next-project ====="
|
||||
cd "$YONGPING_ROOT/soul/next-project"
|
||||
tar --exclude='node_modules' --exclude='.next' --exclude='.git' -czf /tmp/soul_nextjs.tar.gz .
|
||||
echo "打包完成: $(du -h /tmp/soul_nextjs.tar.gz | awk '{print $1}')"
|
||||
|
||||
echo "===== 3. 上传到 Kr宝塔 ====="
|
||||
$SCP_CMD /tmp/soul_nextjs.tar.gz root@${BT_HOST}:/tmp/soul_nextjs.tar.gz
|
||||
rm -f /tmp/soul_nextjs.tar.gz
|
||||
echo "上传完成"
|
||||
|
||||
echo "===== 4. SSH: 解压 + 安装 + 构建 + 重启 ====="
|
||||
$SSH_CMD root@${BT_HOST} '
|
||||
set -e
|
||||
PROJECT="/www/wwwroot/soul"
|
||||
cd "$PROJECT"
|
||||
|
||||
echo "[4.1] 备份 .env..."
|
||||
cp .env /tmp/soul_env_bak 2>/dev/null || true
|
||||
|
||||
echo "[4.2] 清理旧文件..."
|
||||
rm -rf app components lib public styles prisma scripts addons api *.ts *.tsx *.mjs *.json .next
|
||||
|
||||
echo "[4.3] 解压新文件..."
|
||||
tar -xzf /tmp/soul_nextjs.tar.gz
|
||||
rm -f /tmp/soul_nextjs.tar.gz
|
||||
|
||||
echo "[4.4] 恢复 .env..."
|
||||
cp /tmp/soul_env_bak .env 2>/dev/null || true
|
||||
|
||||
echo "[4.5] 安装依赖..."
|
||||
export PATH=/www/server/nodejs/v22.14.0/bin:$PATH
|
||||
pnpm install --frozen-lockfile 2>/dev/null || pnpm install 2>&1 | tail -3
|
||||
|
||||
echo "[4.6] 生成 Prisma Client..."
|
||||
npx prisma generate 2>&1 | tail -3
|
||||
|
||||
echo "[4.7] 构建..."
|
||||
rm -rf .next
|
||||
pnpm run build 2>&1 | tail -5
|
||||
|
||||
echo "[4.8] 移除 turbopack (如有)..."
|
||||
rm -f .next/static/chunks/turbopack-*.js
|
||||
node scripts/prepare-standalone.js 2>&1 | tail -3
|
||||
|
||||
echo "[4.9] 重启服务..."
|
||||
pm2 restart soul 2>/dev/null || pm2 start .next/standalone/server.js --name soul -- -p 3006
|
||||
sleep 3
|
||||
|
||||
echo "[4.10] 验证..."
|
||||
STATUS=$(curl -sI http://localhost:3006 2>&1 | head -1)
|
||||
echo "状态: $STATUS"
|
||||
'
|
||||
|
||||
echo ""
|
||||
echo "===== 部署完成(永平 → Kr宝塔)====="
|
||||
echo "网站: https://soul.quwanzhi.com"
|
||||
echo "后台: https://soul.quwanzhi.com/admin"
|
||||
echo ""
|
||||
echo "===== 5. 上传小程序? ====="
|
||||
echo "执行: /Applications/wechatwebdevtools.app/Contents/MacOS/cli upload --project \"$YONGPING_ROOT/soul/miniprogram\" -v \"1.19\" -d \"永平同步部署\""
|
||||
Reference in New Issue
Block a user