diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 2f66bc9f..00000000 --- a/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ - -soul-api/wechat/info.log -next-project -soul-admin/node_modules -soul-api.exe -Mycontent-temp -Mycontent-temp diff --git a/Gitea同步说明.md b/Gitea同步说明.md deleted file mode 100644 index f5db4ca4..00000000 --- a/Gitea同步说明.md +++ /dev/null @@ -1,54 +0,0 @@ -# 一场soul的创业实验-永平 → Gitea 同步说明 - -**Gitea 仓库**:`fnvtk/soul-yongping` -**地址**: - -本仓库已配置:**每次 `git commit` 后自动推送到 Gitea**(见 `.git/hooks/post-commit`),有更新即同步。 - ---- - -## 一、首次使用(完成一次推送后,之后都会自动同步) - -本仓库的 **gitea 远程已使用与卡若AI 相同的 Gitea Token**,只需在 Gitea 上建仓后推送即可。 - -### 1. 在 Gitea 上创建仓库(若还没有) - -1. 打开 ,登录 **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 -``` diff --git a/content_upload.py b/content_upload.py deleted file mode 100644 index e14f20c2..00000000 --- a/content_upload.py +++ /dev/null @@ -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"![图片{i+1}]({img_url})") - 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() diff --git a/devlop.py b/devlop.py deleted file mode 100644 index 5219b34d..00000000 --- a/devlop.py +++ /dev/null @@ -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()) diff --git a/从GitHub下载最新.sh b/从GitHub下载最新.sh deleted file mode 100755 index 37c56e10..00000000 --- a/从GitHub下载最新.sh +++ /dev/null @@ -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" diff --git a/从GitHub下载最新_devlop.sh b/从GitHub下载最新_devlop.sh deleted file mode 100755 index c9b83043..00000000 --- a/从GitHub下载最新_devlop.sh +++ /dev/null @@ -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" diff --git a/分销提现流程图.md b/分销提现流程图.md deleted file mode 100644 index 45835765..00000000 --- a/分销提现流程图.md +++ /dev/null @@ -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,可在此完成用户确认收款闭环。 diff --git a/技术文档.md b/技术文档.md deleted file mode 100644 index 2a479f92..00000000 --- a/技术文档.md +++ /dev/null @@ -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。 diff --git a/本机运行文档.md b/本机运行文档.md deleted file mode 100644 index 16bcdfb2..00000000 --- a/本机运行文档.md +++ /dev/null @@ -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,避免再次触发封禁。 diff --git a/部署到GitHub与宝塔.sh b/部署到GitHub与宝塔.sh deleted file mode 100755 index 08957aef..00000000 --- a/部署到GitHub与宝塔.sh +++ /dev/null @@ -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 "===== 部署完成 =====" diff --git a/部署到Kr宝塔.sh b/部署到Kr宝塔.sh deleted file mode 100755 index 16e88eea..00000000 --- a/部署到Kr宝塔.sh +++ /dev/null @@ -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宝塔 完成 =====" diff --git a/部署永平到Kr宝塔.sh b/部署永平到Kr宝塔.sh deleted file mode 100755 index 060a2257..00000000 --- a/部署永平到Kr宝塔.sh +++ /dev/null @@ -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 \"永平同步部署\""