打好基版
This commit is contained in:
775
devlop.py
Normal file
775
devlop.py
Normal file
@@ -0,0 +1,775 @@
|
||||
#!/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,89 +0,0 @@
|
||||
# Mycontent-book 项目总览
|
||||
|
||||
**我是卡若。**
|
||||
|
||||
做这个项目,逻辑很简单:**把书卖出去,把私域做起来,把钱分下去。**
|
||||
|
||||
这就不是一个普通的博客网站,这是一个**内容变现系统**。所有的技术架构,都要围绕着“阅读体验”、“流量承接”和“变现转化”来做。
|
||||
|
||||
别整那些虚头巴脑的概念,咱们直接看这个盘子怎么搭。
|
||||
|
||||
## 1. 核心逻辑
|
||||
|
||||
这个项目的生意逻辑就是三层:
|
||||
1. **流量层(前端)**:让用户看着爽,像刷抖音、看公众号一样流畅。必须移动端优先,模拟 iOS 的原生质感。
|
||||
2. **内容层(数据)**:`book/` 目录下的 Markdown 文件就是我们的资产。改个字,推送到 GitHub,网站立马更新。
|
||||
3. **变现层(后端/接口)**:谁看了?谁买了?谁推荐的?这些数据要跑通。
|
||||
|
||||
## 2. 为什么这么架构?
|
||||
|
||||
我选 Next.js,不是因为流行,是因为它**省事**。
|
||||
- **SSR(服务端渲染)**:SEO 友好,百度谷歌能搜到,自带流量。
|
||||
- **API Routes**:不用单独起个 Java 或 Python 服务,省服务器钱。
|
||||
- **Vercel/宝塔部署**:自动化流水线,我只管写文章,代码自动跑。
|
||||
|
||||
## 3. 当前项目结构(Next 前端)
|
||||
|
||||
- **app/view/**:移动端(C 端)页面。根路径 `/` 重定向到 `/view`;路由为 `/view`、`/view/chapters`、`/view/read/[id]`、`/view/match`、`/view/my`、`/view/about`、`/view/login` 等。
|
||||
- **app/admin/**:管理端页面,路由为 `/admin`、`/admin/*`。
|
||||
- **app/api/**:接口不变,仍为 `/api/*`。
|
||||
- **components/view/**:移动端布局与组件(如 layout-wrapper、bottom-nav、config);**components/admin/**:管理端用 UI(如 admin/ui)。
|
||||
- 通用 UI 在 view 与 admin 各保留一份(见 `components/README.md`)。
|
||||
|
||||
## 4. 目录导航(别迷路)
|
||||
|
||||
- **[1、需求](1、需求/业务需求.md)**:我们要干啥,成本多少,技术要求;[TDD 需求方案](1、需求/TDD_创业派对项目方案_v1.0.md)。
|
||||
- **[2、架构](2、架构/系统架构.md)**:整体怎么搭,前后端怎么分。
|
||||
- [技术选型与全景图](2、架构/技术选型与全景图.md)、[前后端架构分离策略](2、架构/前后端架构分离策略.md)
|
||||
- **[3、原型](3、原型/原型设计规范.md)**:原型设计规范。
|
||||
- **[4、前端](4、前端/前端架构.md)**:前端架构、模块详解、开发规范;[当前小程序开发细则](4、前端/当前小程序开发细则.md);[ui 子目录](4、前端/ui/):项目概述、页面功能、组件清单、API/状态/分销/支付/管理后台/部署说明等。
|
||||
- **[5、接口](5、接口/API接口.md)**:前后端怎么说话。
|
||||
- **[6、后端](6、后端/后端架构.md)**:数据处理,后端开发规范。
|
||||
- **[7、数据库](7、数据库/数据库设计.md)**:数据存哪,怎么存。
|
||||
- **[8、部署](8、部署/部署总览.md)**:怎么上线、本地运行、宝塔部署、新分销部署、修复与优化记录等。
|
||||
- **[9、手册](9、手册/写作与结构维护手册.md)**:怎么写书,怎么维护。
|
||||
- **[10、项目管理](10、项目管理/项目落地推进表.md)**:项目推进与提示词。
|
||||
|
||||
## 5. 开发约束(重要)
|
||||
|
||||
> **2026-02-04 起生效**
|
||||
|
||||
### 5.1 前端开发策略
|
||||
|
||||
| 端 | 路径 | 开发状态 | 说明 |
|
||||
|---|------|---------|-----|
|
||||
| **微信小程序** | `miniprogram/` | ✅ 活跃开发 | 所有 C 端新功能在此开发 |
|
||||
| **Next.js C端** | `app/view/` | 🔒 冻结维护 | 不再新增功能,仅修复严重 Bug |
|
||||
| **Next.js 管理端** | `app/admin/` | ✅ 活跃开发 | 管理后台继续在此开发 |
|
||||
| **API 接口** | `app/api/` | ✅ 活跃开发 | 小程序和管理端共用 |
|
||||
|
||||
### 5.2 核心原则
|
||||
|
||||
1. **小程序优先**:所有面向用户的新功能,只在小程序端开发
|
||||
2. **Next.js C端冻结**:`app/view/` 目录不再新增功能,保持现状作为 Web 备用入口
|
||||
3. **管理端继续**:`app/admin/` 管理后台功能继续在 Next.js 开发
|
||||
4. **API 统一**:接口层保持统一,小程序和 Web 端共用同一套 API
|
||||
|
||||
### 5.3 登录体系差异
|
||||
|
||||
| 端 | 登录方式 | 说明 |
|
||||
|---|---------|-----|
|
||||
| 小程序 | 微信一键登录 / 手机号快速授权 | 保持原生体验,不复刻 Next.js 登录页 |
|
||||
| Next.js | 手机号 + 密码 | 保持现状 |
|
||||
| 账号统一 | 以手机号为唯一标识 | 两端数据互通 |
|
||||
|
||||
### 5.4 为什么这样做?
|
||||
|
||||
- **用户体量**:90%+ 用户来自小程序,优先保障主要渠道体验
|
||||
- **开发效率**:集中精力做好一端,避免两端同步维护的成本
|
||||
- **原生体验**:小程序有更好的分享、支付、订阅消息等微信生态能力
|
||||
|
||||
## 6. 这里的规矩
|
||||
|
||||
- **行动至上**:文档是用来指导干活的,不是写来看的。
|
||||
- **数据说话**:所有优化要有数据支撑,加载快了多少?转化高了多少?
|
||||
- **保持简单**:能用现成的库就别自己造轮子。
|
||||
|
||||
---
|
||||
**复盘:**
|
||||
目前项目处于“文件数据库”阶段,适合我这种单人高频写作。等流量上来了,用户系统一接,立马切 MongoDB。这一步步来,别贪多。
|
||||
@@ -1,70 +0,0 @@
|
||||
# 前后端分离开发架构图
|
||||
|
||||
**我是卡若。**
|
||||
|
||||
为了让你(开发人员)能闭着眼睛把活干了,我把前后端分离的流程画得清清楚楚。
|
||||
|
||||
我们采用**接口契约驱动开发 (Contract-First Development)**。
|
||||
|
||||
## 1. 分离开发流程
|
||||
|
||||
\`\`\`mermaid
|
||||
sequenceDiagram
|
||||
participant PM as 卡若 (PM)
|
||||
participant Doc as API 文档
|
||||
participant FE as 前端开发
|
||||
participant BE as 后端开发
|
||||
|
||||
PM->>Doc: 1. 定义需求与接口 (API接口.md)
|
||||
Note over Doc: 确定 URL, Params, Response
|
||||
|
||||
par 并行开发
|
||||
FE->>Doc: 2. 查阅接口定义
|
||||
FE->>FE: 3. Mock 数据 (假数据)
|
||||
FE->>FE: 4. 开发 UI 与交互
|
||||
|
||||
BE->>Doc: 2. 查阅接口定义
|
||||
BE->>BE: 3. 实现业务逻辑
|
||||
BE->>BE: 4. 单元测试 (Postman)
|
||||
end
|
||||
|
||||
FE->>BE: 5. 联调 (对接真实接口)
|
||||
BE-->>FE: 返回真实数据
|
||||
|
||||
FE->>PM: 6. 验收与上线
|
||||
\`\`\`
|
||||
|
||||
## 2. 架构交互图 (Data Flow)
|
||||
|
||||
\`\`\`mermaid
|
||||
graph LR
|
||||
subgraph "前端域 (Browser)"
|
||||
UI[页面组件] --> API_Client[API 请求封装层]
|
||||
API_Client -- JSON 请求 --> API_Route
|
||||
end
|
||||
|
||||
subgraph "后端域 (Server/Next.js)"
|
||||
API_Route[API 路由入口] --> Controller[业务控制器]
|
||||
Controller --> Service[逻辑服务层]
|
||||
|
||||
Service --> Content[内容解析器]
|
||||
Service --> Config[配置加载器]
|
||||
|
||||
Content -- 读取 --> FS[文件系统 book/]
|
||||
Config -- 读取 --> DB[(MongoDB/JSON)]
|
||||
end
|
||||
|
||||
Service -- JSON 响应 --> Controller
|
||||
Controller -- HTTP 响应 --> API_Client
|
||||
API_Client -- 数据 --> UI
|
||||
\`\`\`
|
||||
|
||||
## 3. 落地执行规范
|
||||
|
||||
1. **接口先行**: 没定义好接口文档,不许写代码。
|
||||
2. **Mock 优先**: 前端别等后端,自己造个 JSON 数据先跑起来。
|
||||
3. **统一封装**: 前端所有请求必须走 `lib/api.ts`,禁止在组件里直接写 `fetch('/api/...')`。
|
||||
|
||||
---
|
||||
**卡若说:**
|
||||
按这个流程走,前后端吵架的概率降低 90%。效率就是这么抠出来的。
|
||||
@@ -1,61 +0,0 @@
|
||||
# 系统架构
|
||||
|
||||
**我是卡若。**
|
||||
|
||||
架构不是为了画图好看,是为了**省事**和**赚钱**。
|
||||
|
||||
我们的架构设计,核心围绕两个字:**分离**。
|
||||
- 内容和代码分离。
|
||||
- 前端和后端分离。
|
||||
- 静态和动态分离。
|
||||
|
||||
## 1. 架构全景图
|
||||
|
||||
\`\`\`mermaid
|
||||
graph TD
|
||||
subgraph "内容生产 (Content)"
|
||||
Typora[本地写作] --> Git[Git 仓库]
|
||||
Git --> AutoSync[自动同步脚本]
|
||||
end
|
||||
|
||||
subgraph "应用层 (Next.js)"
|
||||
AutoSync --> FileSys[文件系统 book/]
|
||||
FileSys --> Backend[后端 API]
|
||||
Backend --> Frontend[前端 UI]
|
||||
end
|
||||
|
||||
subgraph "用户触达 (User)"
|
||||
Frontend --> Wechat[微信环境]
|
||||
Frontend --> Browser[手机浏览器]
|
||||
end
|
||||
\`\`\`
|
||||
|
||||
## 2. 核心设计理念
|
||||
|
||||
### 2.1 “内容即产品”
|
||||
我们的核心资产不是代码,是 `book/` 目录下的文章。
|
||||
- 代码丢了可以重写,文章丢了就完了。
|
||||
- 所以,文章用 Markdown 存,Git 管,最安全。
|
||||
|
||||
### 2.2 前后端分离 (即使在 Next.js 里)
|
||||
为了以后能扩展(比如招人开发,或者把后端换成 Java),我们在代码逻辑上做了强制隔离。
|
||||
- 详见:**[前后端架构分离策略](file:///Users/karuo/Documents/个人/2、我写的书/《一场soul的创业实验》/开发文档/2、架构/前后端架构分离策略.md)**
|
||||
|
||||
### 2.3 极简部署
|
||||
- 不要 Docker(除非必要)。
|
||||
- 不要 K8s。
|
||||
- 既然是 Node.js 项目,PM2 或者 Vercel 就够了。宝塔面板配个 Webhook 自动拉代码,是最适合个人开发者的方案。
|
||||
|
||||
## 3. 关键约束
|
||||
|
||||
1. **本地优先**: 写作在本地,代码开发也在本地。
|
||||
2. **稀疏检出 (Sparse Checkout)**: 如果你只负责写作,就别拉取代码;如果你负责开发,就拉取全部。
|
||||
3. **单向流动**: 数据流向是 `Markdown -> API -> UI`。除非是评论或订单,否则 UI 不反向修改 Markdown。
|
||||
|
||||
## 4. 详细技术栈
|
||||
|
||||
详见:**[技术选型与全景图](file:///Users/karuo/Documents/个人/2、我写的书/《一场soul的创业实验》/开发文档/2、架构/技术选型与全景图.md)**
|
||||
|
||||
---
|
||||
**总结:**
|
||||
保持架构的简单性,就是保持业务的灵活性。能在文件里解决的,就别上数据库;能在 Next.js 里解决的,就别拆微服务。
|
||||
@@ -1,56 +0,0 @@
|
||||
# 前端开发规范 (Frontend Specs) - 智能自生长文档
|
||||
|
||||
> **提示词功能 (Prompt Function)**: 将本文件拖入 AI 对话框,即可激活“前端技术专家”角色,生成符合 iOS 风格的 React 代码。
|
||||
|
||||
## 1. 基础上下文 (The Two Basic Files)
|
||||
### 1.1 角色档案:卡若 (Karuo)
|
||||
- **视觉标准**:像素级复刻 iOS (San Francisco, 1:1 间距, 弥散阴影)。
|
||||
- **体验标准**:无白屏 (Skeleton),丝滑转场 (Transition)。
|
||||
|
||||
### 1.2 技术栈
|
||||
- **核心**:React + Shadcn UI + Tailwind CSS。
|
||||
- **辅助**:Vant UI (移动端组件)。
|
||||
- **构建**:Vite / Next.js。
|
||||
|
||||
## 2. 开发规范核心 (Master Content)
|
||||
### 2.1 视觉与风格 (iOS)
|
||||
- **字体**:San Francisco > PingFang SC。
|
||||
- **色彩**:
|
||||
- 背景:`#F2F2F7` (Grouped Background)。
|
||||
- 分割:`#C6C6C8`。
|
||||
- 交互:`#007AFF` (System Blue)。
|
||||
- **细节**:
|
||||
- 圆角:统一 `rounded-lg` 或 `rounded-xl`。
|
||||
- 阴影:柔和弥散,非生硬投影。
|
||||
|
||||
### 2.2 交互与性能 (Mandatory)
|
||||
- **骨架屏**:数据加载必须显示 Skeleton,严禁 Spinner。
|
||||
- **转场**:路由切换必须有动画。
|
||||
- **图片**:懒加载 + 失败占位。
|
||||
|
||||
### 2.3 目录结构
|
||||
- `/src/components`: 原子组件。
|
||||
- `/scenarios/new`: 场景获客页。
|
||||
- `/src/hooks`: 逻辑复用。
|
||||
|
||||
## 3. AI 协作指令 (Expanded Function)
|
||||
**角色**:你是我(卡若)的前端主程。
|
||||
**任务**:
|
||||
1. **代码生成**:生成 React 组件代码,**必须**包含 Tailwind 类名。
|
||||
2. **样式检查**:确保所有 UI 元素符合 iOS 规范(检查圆角、阴影、字体)。
|
||||
3. **结构分析**:用 Mermaid 展示组件依赖。
|
||||
|
||||
### 示例 Mermaid (组件结构)
|
||||
\`\`\`mermaid
|
||||
classDiagram
|
||||
Page <|-- Header
|
||||
Page <|-- Content
|
||||
Page <|-- Footer
|
||||
Content <|-- SkeletonLoader
|
||||
Content <|-- DataList
|
||||
DataList <|-- ListItem
|
||||
class Page{
|
||||
+state: loading
|
||||
+effect: fetchData()
|
||||
}
|
||||
\`\`\`
|
||||
@@ -1,94 +0,0 @@
|
||||
# 前端架构
|
||||
|
||||
**我是卡若。**
|
||||
|
||||
前端就是项目的脸。用户不管是通过朋友圈、抖音还是私域进来,第一眼看到的就是这个页面。如果加载慢、长得丑、滑动卡,人家转头就走,我的流量就浪费了。
|
||||
|
||||
所以,前端的核心目标只有一个:**极致的移动端阅读体验,像原生 App 一样丝滑。**
|
||||
|
||||
## 1. 技术底座
|
||||
|
||||
别跟我说什么技术先进,我要的是**稳**和**快**。
|
||||
|
||||
- **框架**: Next.js 14 (App Router) - 必须用最新的 App Router,路由管理更清晰。
|
||||
- **语言**: TypeScript - 必须用 TS,类型安全,少出低级 Bug。
|
||||
- **样式**: Tailwind CSS - 写样式最快,没有之一。配合 `globals.css` 做全局控制。
|
||||
- **UI 组件库**: Shadcn UI (基于 Radix UI) + Vant UI (风格参考)。
|
||||
- *注意*:我们要像素级复刻 iOS 风格,字体用 San Francisco,圆角、阴影都要对齐。
|
||||
|
||||
## 2. 目录结构(我的地盘)
|
||||
|
||||
前端代码主要集中在 `app/` 和 `components/`。
|
||||
|
||||
\`\`\`
|
||||
app/
|
||||
├── (routes)/ # 路由组,逻辑隔离
|
||||
│ ├── page.tsx # 首页:封面、简介、购买按钮
|
||||
│ ├── chapters/ # 目录页:章节列表
|
||||
│ ├── read/[id]/ # 阅读页:核心体验区
|
||||
│ ├── my/ # 个人中心:购买记录、分销
|
||||
│ ├── admin/ # 管理后台:给自己用的
|
||||
│ └── documentation/ # 文档生成:内部工具
|
||||
├── layout.tsx # 全局布局:导航栏、SEO Meta
|
||||
├── globals.css # 全局样式
|
||||
└── error.tsx # 错误处理页面
|
||||
|
||||
components/
|
||||
├── ui/ # 通用组件 (Button, Input, Skeleton)
|
||||
├── modules/ # 业务模块组件 (新增)
|
||||
│ ├── auth/ # 认证模块 (AuthModal)
|
||||
│ ├── payment/ # 支付模块 (PaymentModal)
|
||||
│ ├── marketing/ # 营销模块 (QRCodeModal)
|
||||
│ └── referral/ # 分销模块 (ReferralShare)
|
||||
├── book-cover.tsx # 书籍封面展示
|
||||
├── chapter-content.tsx # 章节内容渲染器
|
||||
├── bottom-nav.tsx # 底部导航栏 (手机端核心)
|
||||
└── theme-provider.tsx # 主题管理 (深色/浅色模式)
|
||||
\`\`\`
|
||||
|
||||
## 2.1 业务模块化 (Modularization)
|
||||
|
||||
为了支持“云阿米巴”模式的快速迭代,我们将核心业务逻辑封装为独立模块:
|
||||
|
||||
- **支付模块 (Payment)**: 统一管理微信、支付宝、USDT 等支付方式,支持整书/单章购买。
|
||||
- **营销模块 (Marketing)**: 负责引流(如二维码弹窗、倒计时Banner),连接私域流量池。
|
||||
- **分销模块 (Referral)**: 负责裂变传播(如分享按钮、返利计算),让用户帮我们卖书。
|
||||
- **认证模块 (Auth)**: 统一的用户登录与权限校验。
|
||||
|
||||
这种设计允许我们在不修改页面核心逻辑的情况下,插拔不同的变现策略。
|
||||
|
||||
## 3. 核心交互设计
|
||||
|
||||
### 3.1 骨架屏 (Skeleton)
|
||||
**规则**:凡是需要加载数据的地方,必须先展示骨架屏。
|
||||
- 用户不能看白屏,哪怕等 0.5 秒,也要让他看到“东西正在来”的样子。
|
||||
- 强制引入 `Skeleton` 组件。
|
||||
|
||||
### 3.2 路由动画 (Transition)
|
||||
**规则**:页面切换不能生硬地跳。
|
||||
- 使用 Framer Motion 或 CSS Transition。
|
||||
- 模拟 iOS 的滑动切换或淡入淡出。
|
||||
|
||||
### 3.3 阅读体验
|
||||
- **字体**:针对不同设备优化,保证字号适中,行间距舒服(建议 1.6-1.8)。
|
||||
- **图片**:懒加载 (Lazy Load),点击可放大预览。
|
||||
- **代码块**:虽然是书,但如果有代码,要有高亮和复制按钮。
|
||||
|
||||
## 4. 数据获取 (Fetching)
|
||||
|
||||
- **服务端组件 (Server Components)**:
|
||||
- `page.tsx`, `read/[id]/page.tsx` 默认都是服务端组件。
|
||||
- 直接在组件内 `await` 获取数据(通过 `lib/book-data.ts`),SEO 极佳。
|
||||
- **客户端组件 (Client Components)**:
|
||||
- 需要交互的(点击、弹窗、状态变化),头部加 `'use client'`。
|
||||
- 比如 `auth-modal.tsx`, `purchase-section.tsx`。
|
||||
|
||||
## 5. 待办事项 (Todo)
|
||||
|
||||
- [ ] 全局引入 Skeleton,替换掉所有的 `Loading...` 文字。
|
||||
- [ ] 检查所有页面的 Mobile 适配,在 Chrome 开发者工具里用 iPhone SE 和 iPhone 14 Pro Max 两个尺寸测。
|
||||
- [ ] 优化字体栈,确保在安卓上也不难看。
|
||||
|
||||
---
|
||||
**总结**:
|
||||
前端不仅是写代码,是**做产品**。每一个像素的偏移都影响用户的信任感。把细节抠好,转化率自然就高了。
|
||||
@@ -1,64 +0,0 @@
|
||||
# 后端开发规范 (Backend Specs) - 智能自生长文档
|
||||
|
||||
> **提示词功能 (Prompt Function)**: 将本文件拖入 AI 对话框,即可激活“Python 后端专家”角色,生成高效、规范的 FastAPI 代码。
|
||||
|
||||
## 1. 基础上下文 (The Two Basic Files)
|
||||
### 1.1 角色档案:卡若 (Karuo)
|
||||
- **核心**:开发快、性能好、支持 AI。
|
||||
- **习惯**:优先使用异步 (`async/await`),强制类型提示 (`Type Hints`)。
|
||||
|
||||
### 1.2 技术栈
|
||||
- **语言**:Python 3.10+。
|
||||
- **框架**:FastAPI (Web), Pydantic (Validation), LangChain (AI)。
|
||||
- **数据**:Motor (Async Mongo), Redis。
|
||||
|
||||
## 2. 开发规范核心 (Master Content)
|
||||
### 2.1 代码规范
|
||||
- **风格**:遵循 PEP 8,使用 Black 格式化。
|
||||
- **类型**:**强制 Type Hints** (如 `def get_user(id: int) -> User:`)。
|
||||
- **注释**:**强制中文注释**,解释“业务逻辑”与“AI 处理流程”。
|
||||
- **结构**:
|
||||
- `app/routers`: 路由
|
||||
- `app/models`: Pydantic 模型
|
||||
- `app/services`: 业务逻辑
|
||||
- `app/core`: 配置与工具
|
||||
|
||||
### 2.2 AI 与安全规范
|
||||
- **AI 调用**:所有 LLM 调用必须封装在 Service 层,并包含重试机制与超时控制。
|
||||
- **安全**:
|
||||
- **命令执行**:严禁使用 `os.system`,必须使用 `subprocess` 并校验参数。
|
||||
- **SQL/NoSQL**:使用 ORM 或参数化查询,防止注入。
|
||||
|
||||
### 2.3 异常与日志
|
||||
- **异常**:使用 FastAPI `HTTPException` 或自定义 Exception Handler。
|
||||
- **日志**:使用 `loguru` 或 Python 标准 `logging`,必须记录 Traceback。
|
||||
|
||||
### 2.4 依赖管理
|
||||
- **工具**:`pip` 或 `poetry`。
|
||||
- **原则**:提交代码前更新 `requirements.txt` 或 `pyproject.toml`。
|
||||
|
||||
## 3. AI 协作指令 (Expanded Function)
|
||||
**角色**:你是我(卡若)的 Python 架构师。
|
||||
**任务**:
|
||||
1. **代码实现**:生成 FastAPI 的 Router/Model/Service 代码。
|
||||
2. **AI 集成**:编写 LangChain 调用逻辑或向量检索代码。
|
||||
3. **逻辑图解**:用 Mermaid 展示异步处理流程。
|
||||
|
||||
### 示例 Mermaid (类图)
|
||||
\`\`\`mermaid
|
||||
classDiagram
|
||||
class UserRouter {
|
||||
+get_user()
|
||||
+create_user()
|
||||
}
|
||||
class UserService {
|
||||
+verify_token()
|
||||
+process_ai_request()
|
||||
}
|
||||
class VectorStore {
|
||||
+search_similarity()
|
||||
+add_documents()
|
||||
}
|
||||
UserRouter --> UserService
|
||||
UserService --> VectorStore
|
||||
\`\`\`
|
||||
@@ -1,68 +0,0 @@
|
||||
# 后端架构与业务逻辑
|
||||
|
||||
**我是卡若。**
|
||||
|
||||
后端不仅仅是读写数据库,它是**业务逻辑的翻译官**。
|
||||
|
||||
我们要把“私域引流”、“内容分发”这些生意话术,翻译成代码逻辑。
|
||||
|
||||
## 1. 核心业务模块
|
||||
|
||||
### 1.1 内容服务 (Content Service)
|
||||
这是最基础的。
|
||||
- **逻辑**:
|
||||
- 扫描 `book/` 目录,生成目录树 (Tree)。
|
||||
- 解析 Markdown,提取 Frontmatter (标题、日期、标签)。
|
||||
- **缓存策略**: 既然是读文件,IO 慢。要在内存里做一个 LRU 缓存,读取一次后由内存直接返回,直到文件发生变更。
|
||||
|
||||
### 1.2 配置服务 (Config Service)
|
||||
我的微信号、群二维码、价格,这些东西会变,不能写死在代码里。
|
||||
- **实现**:
|
||||
- 一个 `config/settings.json` 文件(或者未来的 MongoDB `settings` 表)。
|
||||
- 接口: `GET /api/config`。
|
||||
- 前端拿到配置,动态展示微信号。
|
||||
|
||||
### 1.3 引流服务 (Lead Service)
|
||||
这是赚钱的关键。
|
||||
- **埋点逻辑**:
|
||||
- 记录 `UserView` (用户看了哪章)。
|
||||
- 记录 `UserClick` (用户点了“加微信”)。
|
||||
- 虽然不存库,但可以先打到日志文件里,或者调一个飞书的 Webhook,实时通知我“有人对这章感兴趣”。
|
||||
|
||||
## 2. 接口设计原则
|
||||
|
||||
- **RESTful**: 资源导向。`GET /articles`, `GET /articles/:id`。
|
||||
- **统一响应体**:
|
||||
\`\`\`typescript
|
||||
interface ApiResponse<T> {
|
||||
code: number; // 0 成功, >0 错误
|
||||
data: T;
|
||||
msg: string;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 3. 目录结构 (后端专用)
|
||||
|
||||
\`\`\`
|
||||
app/api/
|
||||
├── content/ # 内容相关
|
||||
├── config/ # 全局配置
|
||||
└── track/ # 埋点上报
|
||||
|
||||
lib/
|
||||
├── content/
|
||||
│ ├── parser.ts # Markdown 解析器
|
||||
│ └── cache.ts # 内存缓存
|
||||
├── config/
|
||||
│ └── loader.ts # 配置加载器
|
||||
└── db/ # 数据库连接 (预留)
|
||||
\`\`\`
|
||||
|
||||
## 4. 扩展性预留
|
||||
|
||||
- **鉴权中间件**: 现在是裸奔,未来加 `middleware.ts` 拦截 `/admin` 开头的请求。
|
||||
- **任务队列**: 未来如果生成文档太慢,就扔到 Redis 队列里异步处理。
|
||||
|
||||
---
|
||||
**卡若说:**
|
||||
后端代码要写得像瑞士军刀一样,功能明确,结实耐用。
|
||||
@@ -1,3 +1,5 @@
|
||||
# 对接后端 base URL(不改 API 路径,仅改此处即可切换 Next → Gin)
|
||||
# VITE_API_BASE_URL=http://localhost:3006
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
# VITE_API_BASE_URL=http://localhost:8080
|
||||
VITE_API_BASE_URL=http://soulapi.quwanzhi.com
|
||||
|
||||
|
||||
1
soul-admin/.gitignore
vendored
Normal file
1
soul-admin/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -6,7 +6,7 @@ import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
|
||||
238
soul-api/devlop.py
Normal file
238
soul-api/devlop.py
Normal file
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
soul-api Go 项目一键部署到宝塔
|
||||
- 本地交叉编译 Linux 二进制
|
||||
- 上传到 /www/wwwroot/自营/soul-api
|
||||
- 重启服务(nohup 或跳过)
|
||||
"""
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import argparse
|
||||
import subprocess
|
||||
import shutil
|
||||
import tarfile
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
except ImportError:
|
||||
print("错误: 请先安装 paramiko")
|
||||
print(" pip install paramiko")
|
||||
sys.exit(1)
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
DEPLOY_PROJECT_PATH = "/www/wwwroot/自营/soul-api"
|
||||
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
|
||||
|
||||
|
||||
def get_cfg():
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
# ==================== 本地构建 ====================
|
||||
|
||||
|
||||
def run_build(root):
|
||||
"""交叉编译 Go 二进制(Linux amd64)"""
|
||||
print("[1/4] 本地交叉编译 Go 二进制 ...")
|
||||
env = os.environ.copy()
|
||||
env["GOOS"] = "linux"
|
||||
env["GOARCH"] = "amd64"
|
||||
env["CGO_ENABLED"] = "0"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["go", "build", "-o", "soul-api", "./cmd/server"],
|
||||
cwd=root,
|
||||
env=env,
|
||||
shell=(sys.platform == "win32"),
|
||||
timeout=120,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if r.returncode != 0:
|
||||
print(" [失败] go build 失败,退出码:", r.returncode)
|
||||
if r.stderr:
|
||||
for line in (r.stderr or "").strip().split("\n")[-10:]:
|
||||
print(" " + line)
|
||||
return None
|
||||
out_path = os.path.join(root, "soul-api")
|
||||
if not os.path.isfile(out_path):
|
||||
print(" [失败] 未找到编译产物 soul-api")
|
||||
return None
|
||||
print(" [成功] 编译完成: %s (%.2f MB)" % (out_path, os.path.getsize(out_path) / 1024 / 1024))
|
||||
return out_path
|
||||
except subprocess.TimeoutExpired:
|
||||
print(" [失败] 编译超时")
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
print(" [失败] 未找到 go 命令,请安装 Go")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(" [失败] 编译异常:", str(e))
|
||||
return None
|
||||
|
||||
|
||||
# ==================== 打包 ====================
|
||||
|
||||
|
||||
def pack_deploy(root, binary_path, include_env=True):
|
||||
"""打包二进制和 .env 为 tar.gz"""
|
||||
print("[2/4] 打包部署文件 ...")
|
||||
staging = tempfile.mkdtemp(prefix="soul_api_deploy_")
|
||||
try:
|
||||
shutil.copy2(binary_path, os.path.join(staging, "soul-api"))
|
||||
env_src = os.path.join(root, ".env")
|
||||
if include_env and os.path.isfile(env_src):
|
||||
shutil.copy2(env_src, os.path.join(staging, ".env"))
|
||||
print(" [已包含] .env")
|
||||
else:
|
||||
env_example = os.path.join(root, ".env.example")
|
||||
if os.path.isfile(env_example):
|
||||
shutil.copy2(env_example, os.path.join(staging, ".env"))
|
||||
print(" [已包含] .env.example -> .env (请服务器上检查配置)")
|
||||
tarball = os.path.join(tempfile.gettempdir(), "soul_api_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)
|
||||
|
||||
|
||||
# ==================== SSH 上传 ====================
|
||||
|
||||
|
||||
def upload_and_extract(cfg, tarball_path, no_restart=False):
|
||||
"""上传 tar.gz 到服务器并解压、重启"""
|
||||
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_api_deploy.tar.gz"
|
||||
project_path = cfg["project_path"]
|
||||
sftp.put(tarball_path, remote_tar)
|
||||
sftp.close()
|
||||
|
||||
cmd = (
|
||||
"mkdir -p %s && cd %s && tar -xzf %s && "
|
||||
"chmod +x soul-api && rm -f %s && echo OK"
|
||||
) % (project_path, project_path, remote_tar, remote_tar)
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=60)
|
||||
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)
|
||||
|
||||
if not no_restart:
|
||||
print("[4/4] 重启 soul-api 服务 ...")
|
||||
restart_cmd = (
|
||||
"cd %s && pkill -f 'soul-api' 2>/dev/null; sleep 2; "
|
||||
"nohup ./soul-api >> soul-api.log 2>&1 & sleep 1; "
|
||||
"pgrep -f soul-api >/dev/null && echo RESTART_OK || echo RESTART_FAIL"
|
||||
) % project_path
|
||||
stdin, stdout, stderr = client.exec_command(restart_cmd, timeout=15)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
if "RESTART_OK" in out:
|
||||
print(" [成功] soul-api 已重启")
|
||||
else:
|
||||
print(" [警告] 重启状态未知,请手动检查: cd %s && ./soul-api" % project_path)
|
||||
else:
|
||||
print("[4/4] 跳过重启 (--no-restart)")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(" [失败] SSH 错误:", str(e))
|
||||
return False
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="soul-api Go 项目一键部署到宝塔",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--no-build", action="store_true", help="跳过本地编译(使用已有 soul-api 二进制)")
|
||||
parser.add_argument("--no-env", action="store_true", help="不打包 .env(保留服务器现有 .env)")
|
||||
parser.add_argument("--no-restart", action="store_true", help="上传后不重启服务")
|
||||
args = parser.parse_args()
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
root = script_dir
|
||||
|
||||
cfg = get_cfg()
|
||||
print("=" * 60)
|
||||
print(" soul-api 一键部署到宝塔")
|
||||
print("=" * 60)
|
||||
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
|
||||
print(" 目标目录: %s" % cfg["project_path"])
|
||||
print("=" * 60)
|
||||
|
||||
binary_path = os.path.join(root, "soul-api")
|
||||
if not args.no_build:
|
||||
p = run_build(root)
|
||||
if not p:
|
||||
return 1
|
||||
else:
|
||||
if not os.path.isfile(binary_path):
|
||||
print("[错误] 未找到 soul-api 二进制,请先编译或去掉 --no-build")
|
||||
return 1
|
||||
print("[1/4] 跳过编译,使用现有 soul-api")
|
||||
|
||||
tarball = pack_deploy(root, binary_path, include_env=not args.no_env)
|
||||
if not tarball:
|
||||
return 1
|
||||
|
||||
if not upload_and_extract(cfg, tarball, no_restart=args.no_restart):
|
||||
return 1
|
||||
|
||||
try:
|
||||
os.remove(tarball)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("")
|
||||
print(" 部署完成!目录: %s" % cfg["project_path"])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
BIN
soul-api/soul-api
Normal file
BIN
soul-api/soul-api
Normal file
Binary file not shown.
BIN
开发文档/.DS_Store
vendored
Normal file
BIN
开发文档/.DS_Store
vendored
Normal file
Binary file not shown.
99
开发文档/0、Mycontent-book 项目总览.md
Normal file
99
开发文档/0、Mycontent-book 项目总览.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Mycontent-book 项目总览
|
||||
|
||||
**我是卡若。**
|
||||
|
||||
做这个项目,逻辑很简单:**把书卖出去,把私域做起来,把钱分下去。**
|
||||
|
||||
这就不是一个普通的博客网站,这是一个**内容变现系统**。所有的技术架构,都要围绕着“阅读体验”、“流量承接”和“变现转化”来做。
|
||||
|
||||
别整那些虚头巴脑的概念,咱们直接看这个盘子怎么搭。
|
||||
|
||||
## 1. 核心逻辑
|
||||
|
||||
这个项目的生意逻辑就是三层:
|
||||
1. **流量层(前端)**:让用户看着爽,像刷抖音、看公众号一样流畅。必须移动端优先,模拟 iOS 的原生质感。
|
||||
2. **内容层(数据)**:`book/` 目录下的 Markdown 文件就是我们的资产。改个字,推送到 GitHub,网站立马更新。
|
||||
3. **变现层(后端/接口)**:谁看了?谁买了?谁推荐的?这些数据要跑通。
|
||||
|
||||
## 2. 为什么这么架构?
|
||||
|
||||
我选 Next.js,不是因为流行,是因为它**省事**。
|
||||
- **SSR(服务端渲染)**:SEO 友好,百度谷歌能搜到,自带流量。
|
||||
- **API Routes**:不用单独起个 Java 或 Python 服务,省服务器钱。
|
||||
- **Vercel/宝塔部署**:自动化流水线,我只管写文章,代码自动跑。
|
||||
|
||||
## 3. 当前项目结构(前后端已分离)
|
||||
|
||||
### 3.1 仓库根目录
|
||||
|
||||
| 目录/项目 | 技术栈 | 说明 |
|
||||
|-----------|--------|------|
|
||||
| **soul-api/** | Go 1.25 + Gin + GORM + MySQL | 独立后端 API 服务,路径与现网一致 `/api/*` |
|
||||
| **soul-admin/** | React 18 + Vite 6 + TypeScript + Tailwind 4 + Radix UI | 管理后台 SPA,请求通过 `VITE_API_BASE_URL` 对接 soul-api 或 Next |
|
||||
| **miniprogram/** | 微信小程序原生 | C 端主阵地,用户阅读、购买、分销、提现等 |
|
||||
| **next-project/** | Next.js(可选保留) | 原单体:含 `app/view/` C 端、`app/admin/` 管理端、`app/api/`;可作备用或逐步下线 |
|
||||
|
||||
### 3.2 接口与前端对应关系
|
||||
|
||||
- **API 服务**:由 **soul-api**(Go)提供,端口默认 8080;路径与现网完全一致(如 `/api/user/profile`、`/api/admin/withdrawals`)。
|
||||
- **管理端**:**soul-admin** 独立部署,环境变量 `VITE_API_BASE_URL` 指向 soul-api 或 Next 的 API 基地址。
|
||||
- **小程序**:通过 `app.request()` 等封装请求 API,baseUrl 可配置,与 soul-api 对接。
|
||||
- **API 字段规范**:对外请求/响应**统一使用小写开头驼峰(camelCase)**,如 `userId`、`referralCode`、`createdAt`;数据库列名仍为 snake_case,仅在服务端内部使用。
|
||||
|
||||
## 4. 目录导航(别迷路)
|
||||
|
||||
- **[1、需求](1、需求/业务需求.md)**:我们要干啥,成本多少,技术要求;[TDD 需求方案](1、需求/TDD_创业派对项目方案_v1.0.md)。
|
||||
- **[2、架构](2、架构/系统架构.md)**:整体怎么搭,前后端怎么分。
|
||||
- [技术选型与全景图](2、架构/技术选型与全景图.md)、[前后端架构分离策略](2、架构/前后端架构分离策略.md)
|
||||
- **[3、原型](3、原型/原型设计规范.md)**:原型设计规范。
|
||||
- **[4、前端](4、前端/前端架构.md)**:前端架构(含 **soul-admin**、小程序)、模块详解、开发规范;[当前小程序开发细则](4、前端/当前小程序开发细则.md);[ui 子目录](4、前端/ui/):项目概述、页面功能、组件清单、API/状态/分销/支付/管理后台/部署说明等。
|
||||
- **[5、接口](5、接口/API接口.md)**:前后端怎么说话。
|
||||
- **[6、后端](6、后端/后端架构.md)**:**soul-api**(Go + Gin + GORM)架构与业务模块,后端开发规范。
|
||||
- **[7、数据库](7、数据库/数据库设计.md)**:数据存哪,怎么存。
|
||||
- **[8、部署](8、部署/部署总览.md)**:怎么上线、本地运行、宝塔部署、新分销部署、修复与优化记录等。
|
||||
- **[9、手册](9、手册/写作与结构维护手册.md)**:怎么写书,怎么维护。
|
||||
- **[10、项目管理](10、项目管理/项目落地推进表.md)**:项目推进与提示词。
|
||||
|
||||
## 5. 开发约束(重要)
|
||||
|
||||
> **2026-02-07 更新:前后端已分离,soul-api + soul-admin 为主力**
|
||||
|
||||
### 5.1 项目与开发策略
|
||||
|
||||
| 项目/端 | 路径/端口 | 开发状态 | 说明 |
|
||||
|---------|-----------|----------|------|
|
||||
| **soul-api** | 端口 8080 | ✅ 主力后端 | Go + Gin,提供全部 `/api/*` 接口,MySQL + GORM |
|
||||
| **soul-admin** | 独立 SPA | ✅ 主力管理端 | React + Vite,通过 `VITE_API_BASE_URL` 对接 soul-api |
|
||||
| **微信小程序** | `miniprogram/` | ✅ 主力 C 端 | 所有面向用户的新功能在此开发 |
|
||||
| **Next.js** | `next-project/` 或原 app | 🔒 备用/冻结 | C 端 `app/view/` 冻结;`app/api/` 可作过渡或下线 |
|
||||
|
||||
### 5.2 核心原则
|
||||
|
||||
1. **后端统一走 soul-api**:新接口在 soul-api 实现,路径与现网一致,响应字段统一 **camelCase**。
|
||||
2. **管理端统一走 soul-admin**:新管理功能在 soul-admin 开发,请求体与展示字段统一 **camelCase**。
|
||||
3. **小程序优先**:C 端新功能只在小程序开发,请求/响应已按 camelCase 对接。
|
||||
4. **API 契约统一**:查询、新增、编辑、删除的请求/响应字段全部小写开头驼峰(如 `userId`、`createdAt`、`referralCode`)。
|
||||
|
||||
### 5.3 登录与鉴权
|
||||
|
||||
| 端 | 登录方式 | 说明 |
|
||||
|---|----------|------|
|
||||
| 小程序 | 微信一键登录 / 手机号快速授权 | 与 soul-api 或 Next 的 `/api/miniprogram/login` 等对接 |
|
||||
| soul-admin | 手机号 + 密码 | POST `/api/admin` 登录,GET 鉴权,Cookie/Token |
|
||||
| 账号统一 | 以手机号/用户 ID 为唯一标识 | 多端数据互通 |
|
||||
|
||||
### 5.4 为什么前后端分离?
|
||||
|
||||
- **独立部署与扩展**:后端 Go 可单独扩容,前端静态资源可 CDN。
|
||||
- **多端复用 API**:小程序、soul-admin、未来 App 共用同一套 soul-api。
|
||||
- **开发效率**:前后端并行、接口契约清晰(camelCase 统一)。
|
||||
|
||||
## 6. 这里的规矩
|
||||
|
||||
- **行动至上**:文档是用来指导干活的,不是写来看的。
|
||||
- **数据说话**:所有优化要有数据支撑,加载快了多少?转化高了多少?
|
||||
- **保持简单**:能用现成的库就别自己造轮子。
|
||||
|
||||
---
|
||||
**复盘:**
|
||||
项目已完成前后端分离:soul-api(Go)负责接口与数据库,soul-admin(React)负责管理后台,miniprogram 负责 C 端。API 与前端字段已统一为 camelCase,便于多端对接与后续扩展。
|
||||
BIN
开发文档/1、需求/.DS_Store
vendored
Normal file
BIN
开发文档/1、需求/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
开发文档/1、需求/修改/.DS_Store
vendored
Normal file
BIN
开发文档/1、需求/修改/.DS_Store
vendored
Normal file
Binary file not shown.
66
开发文档/2、架构/前后端架构分离策略.md
Normal file
66
开发文档/2、架构/前后端架构分离策略.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 前后端分离开发架构
|
||||
|
||||
**我是卡若。**
|
||||
|
||||
当前项目已完成**前后端物理分离**:后端 soul-api(Go),管理端 soul-admin(React),C 端小程序。开发流程仍采用**接口契约驱动**。
|
||||
|
||||
## 1. 当前架构(已落地)
|
||||
|
||||
\`\`\`mermaid
|
||||
graph LR
|
||||
subgraph "前端"
|
||||
Admin[soul-admin<br/>React+Vite]
|
||||
Mini[微信小程序]
|
||||
end
|
||||
|
||||
subgraph "后端 soul-api (Go)"
|
||||
Gin[Gin Router /api/*]
|
||||
Handler[Handler 层]
|
||||
GORM[GORM]
|
||||
Gin --> Handler --> GORM
|
||||
end
|
||||
|
||||
MySQL[(MySQL)]
|
||||
GORM --> MySQL
|
||||
|
||||
Admin -->|VITE_API_BASE_URL + path| Gin
|
||||
Mini -->|baseUrl + path| Gin
|
||||
\`\`\`
|
||||
|
||||
## 2. 分离开发流程
|
||||
|
||||
\`\`\`mermaid
|
||||
sequenceDiagram
|
||||
participant Doc as API 文档
|
||||
participant FE as 前端 (soul-admin / 小程序)
|
||||
participant BE as 后端 (soul-api)
|
||||
|
||||
Doc->>FE: 1. 接口定义 (URL、camelCase 字段)
|
||||
Doc->>BE: 1. 同上
|
||||
|
||||
par 并行开发
|
||||
FE->>FE: 2. Mock 或对接已有 API
|
||||
FE->>FE: 3. UI 与交互,字段用 camelCase
|
||||
BE->>BE: 2. Handler + Model,json 标签 camelCase
|
||||
BE->>BE: 3. 单元测试 / 联调
|
||||
end
|
||||
|
||||
FE->>BE: 4. 联调(path 一致,body/response camelCase)
|
||||
BE-->>FE: 返回 camelCase JSON
|
||||
\`\`\`
|
||||
|
||||
## 3. 数据流与规范
|
||||
|
||||
- **请求**:前端只发 camelCase(如 `userId`、`referralCode`)。soul-api 的 Go 结构体用 `json:"userId"` 等接收。
|
||||
- **响应**:soul-api 通过 GORM 模型 `json:"userId"` 等输出 camelCase;前端 TypeScript 类型与接口字段一致(camelCase)。
|
||||
- **数据库**:表名列名保持 snake_case(如 `user_id`、`created_at`),仅在 soul-api 内部使用,不暴露给前端。
|
||||
|
||||
## 4. 落地执行规范
|
||||
|
||||
1. **接口先行**:新增/修改接口先在文档约定 URL、方法、请求/响应体(字段 camelCase)。
|
||||
2. **统一封装**:soul-admin 所有请求走 `src/api/client.ts`(get/post/put/del),path 与现网一致;小程序走 `app.request()`,不写死域名。
|
||||
3. **字段统一**:禁止在 API 响应或前端类型中使用 snake_case 对外字段;表单提交、列表展示一律 camelCase。
|
||||
|
||||
---
|
||||
**卡若说:**
|
||||
按这个流程走,前后端对接清晰。路径一致、字段 camelCase 统一,多端复用同一套 soul-api 即可。
|
||||
78
开发文档/2、架构/系统架构.md
Normal file
78
开发文档/2、架构/系统架构.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 系统架构
|
||||
|
||||
**我是卡若。**
|
||||
|
||||
架构不是为了画图好看,是为了**省事**和**赚钱**。
|
||||
|
||||
我们的架构设计,核心围绕两个字:**分离**。
|
||||
- 内容和代码分离。
|
||||
- 前端和后端分离(已落地:soul-api + soul-admin + 小程序)。
|
||||
- 静态和动态分离。
|
||||
|
||||
## 1. 架构全景图(当前)
|
||||
|
||||
\`\`\`mermaid
|
||||
graph TD
|
||||
subgraph "内容生产 (Content)"
|
||||
Typora[本地写作] --> Git[Git 仓库]
|
||||
Git --> AutoSync[自动同步]
|
||||
end
|
||||
|
||||
subgraph "后端 (soul-api - Go)"
|
||||
API[Gin API /api/*]
|
||||
API --> GORM[GORM]
|
||||
GORM --> MySQL[(MySQL)]
|
||||
AutoSync --> FileSys[文件/配置]
|
||||
FileSys --> API
|
||||
end
|
||||
|
||||
subgraph "前端"
|
||||
Admin[soul-admin React+Vite]
|
||||
Mini[微信小程序]
|
||||
Admin --> API
|
||||
Mini --> API
|
||||
end
|
||||
|
||||
subgraph "用户触达"
|
||||
Mini --> Wechat[微信]
|
||||
Admin --> Browser[浏览器]
|
||||
end
|
||||
\`\`\`
|
||||
|
||||
## 2. 当前项目分工
|
||||
|
||||
| 层级 | 项目 | 技术 | 说明 |
|
||||
|------|------|------|------|
|
||||
| **后端** | soul-api | Go 1.25 + Gin + GORM + MySQL | 提供全部 `/api/*`,路径与现网一致,响应 camelCase |
|
||||
| **管理端** | soul-admin | React 18 + Vite 6 + TS + Tailwind + Radix UI | SPA,`VITE_API_BASE_URL` 指向 soul-api |
|
||||
| **C 端** | miniprogram | 微信小程序原生 | 阅读、购买、分销、提现,请求/响应 camelCase |
|
||||
| **备用** | next-project | Next.js | 原单体,可保留 app/api 过渡或逐步下线 |
|
||||
|
||||
## 3. 核心设计理念
|
||||
|
||||
### 3.1 “内容即产品”
|
||||
核心资产是内容(书籍章节、配置)。文章用 Markdown/数据库存,Git 或脚本同步,安全可控。
|
||||
|
||||
### 3.2 前后端已分离
|
||||
- **后端**:soul-api(Go)独立部署,可单独扩容、多端复用。
|
||||
- **管理端**:soul-admin 纯静态 SPA,部署到任意静态托管或 CDN。
|
||||
- **接口契约**:路径不变,请求/响应字段统一 **camelCase**,详见 [前后端架构分离策略](前后端架构分离策略.md)。
|
||||
|
||||
### 3.3 极简部署
|
||||
- 后端:Go 二进制 + 环境变量,或 Docker 单容器。
|
||||
- 管理端:`npm run build` 后部署到 Nginx/Vercel/OSS。
|
||||
- 小程序:微信后台发布。宝塔/Webhook 可按需做自动拉代码。
|
||||
|
||||
## 4. 关键约束
|
||||
|
||||
1. **API 字段统一**:对外一律 camelCase(如 `userId`、`createdAt`);数据库列名保持 snake_case 仅在服务端使用。
|
||||
2. **本地优先**:写作与开发在本地,通过 soul-api 连接数据库。
|
||||
3. **单向流动**:数据流向 `内容/配置 -> API -> 前端`;订单、用户等由 API 写库。
|
||||
|
||||
## 5. 详细技术栈
|
||||
|
||||
详见:**[技术选型与全景图](技术选型与全景图.md)**、**[前后端架构分离策略](前后端架构分离策略.md)**。
|
||||
|
||||
---
|
||||
**总结:**
|
||||
当前已实现前后端分离:soul-api 负责接口与数据,soul-admin 负责管理后台,小程序负责 C 端。保持接口路径与字段规范统一,便于多端对接与扩展。
|
||||
52
开发文档/4、前端/前端开发规范.md
Normal file
52
开发文档/4、前端/前端开发规范.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 前端开发规范 (Frontend Specs) - 智能自生长文档
|
||||
|
||||
> **提示词功能 (Prompt Function)**: 将本文件拖入 AI 对话框,即可激活“前端技术专家”角色,生成符合项目规范的代码。
|
||||
|
||||
## 1. 基础上下文
|
||||
|
||||
### 1.1 角色档案:卡若 (Karuo)
|
||||
- **管理端 (soul-admin)**:深色后台风格,稳、快、信息密度合理。
|
||||
- **C 端 (小程序)**:移动端优先,阅读与转化体验顺畅。
|
||||
|
||||
### 1.2 技术栈(当前)
|
||||
|
||||
| 项目 | 技术栈 |
|
||||
|------|--------|
|
||||
| **soul-admin** | React 18 + Vite 6 + TypeScript + Tailwind 4 + Radix UI(类 Shadcn)+ React Router 6 |
|
||||
| **miniprogram** | 微信小程序原生 (JS/WXML/WXSS) |
|
||||
|
||||
## 2. 开发规范核心
|
||||
|
||||
### 2.1 API 与字段规范(强制)
|
||||
|
||||
- **请求/响应字段**:一律**小写开头驼峰(camelCase)**。
|
||||
- 正确:`userId`、`referralCode`、`createdAt`、`hasFullBook`、`pendingEarnings`。
|
||||
- 错误:`user_id`、`referral_code`、`created_at`(仅数据库内部使用,不暴露给前端)。
|
||||
- **类型定义**:TypeScript 接口与 API 约定一致,全部 camelCase。
|
||||
- **表单提交**:提交 body 的字段名使用 camelCase(如 `isAdmin`、`hasFullBook`)。
|
||||
|
||||
### 2.2 soul-admin 规范
|
||||
|
||||
- **请求**:所有接口请求通过 `src/api/client.ts` 的 get/post/put/del,path 与现网一致,不写死域名。
|
||||
- **环境变量**:`VITE_API_BASE_URL` 指向 soul-api 或现有 API 基地址(开发/生产分开配置)。
|
||||
- **目录**:页面在 `src/pages/`,通用 UI 在 `src/components/ui/`,业务模块在 `src/components/modules/`。
|
||||
- **样式**:Tailwind 为主,深色主题可用 `#0f2137`、`#38bdac` 等品牌色。
|
||||
|
||||
### 2.3 小程序规范
|
||||
|
||||
- **请求**:通过 `app.request()` 等封装,baseUrl 可配置;请求体与后端约定一致(camelCase)。
|
||||
- **存储**:需要与后端一致的标识(如 `referral_code` 存为业务值可保留 key 名)时,注意与接口字段区分;接口层面仍用 camelCase。
|
||||
|
||||
### 2.4 交互与性能
|
||||
|
||||
- **加载**:列表/详情需 loading 或骨架屏,避免白屏。
|
||||
- **错误**:接口失败需有明确提示(Toast/Alert)。
|
||||
- **图片**:懒加载 + 失败占位。
|
||||
|
||||
## 3. AI 协作指令
|
||||
|
||||
**角色**:前端主程(soul-admin + 小程序)。
|
||||
**任务**:
|
||||
1. **代码生成**:React 组件带 Tailwind 类名;类型与 API 字段 camelCase 一致。
|
||||
2. **接口对接**:使用统一 client(get/post/put/del),不直接写死 fetch 域名;请求体/响应类型为 camelCase。
|
||||
3. **结构分析**:复杂页面可用 Mermaid 展示组件或数据流依赖。
|
||||
75
开发文档/4、前端/前端架构.md
Normal file
75
开发文档/4、前端/前端架构.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# 前端架构
|
||||
|
||||
**我是卡若。**
|
||||
|
||||
前端分两块:**管理端 soul-admin**(给运营/自己用)、**C 端 miniprogram**(给用户用)。目标都是稳、快、体验好。
|
||||
|
||||
## 1. 当前前端分工
|
||||
|
||||
| 项目 | 技术栈 | 说明 |
|
||||
|------|--------|------|
|
||||
| **soul-admin** | React 18 + Vite 6 + TypeScript + Tailwind 4 + Radix UI (Shadcn 风格) | 管理后台 SPA,对接 soul-api,路由与现网 /admin/* 对应 |
|
||||
| **miniprogram** | 微信小程序原生 (JS/WXML/WXSS) | C 端主阵地:阅读、购买、分销、提现等 |
|
||||
|
||||
(原 Next.js 的 `app/view/`、`app/admin/` 可保留为备用或逐步下线。)
|
||||
|
||||
## 2. soul-admin 技术底座
|
||||
|
||||
- **框架**: React 18 + Vite 6,构建快、热更新稳。
|
||||
- **语言**: TypeScript,类型与 API 字段一致(camelCase)。
|
||||
- **样式**: Tailwind CSS 4,全局与组件级样式。
|
||||
- **UI**: Radix UI 系(Dialog、Tabs、Switch 等),类 Shadcn 用法,深色管理后台风格。
|
||||
- **路由**: React Router 6,路径与现网管理端一致(如 `/dashboard`、`/users`、`/withdrawals`)。
|
||||
- **请求**: 统一走 `src/api/client.ts`(get/post/put/del),baseUrl 为 `VITE_API_BASE_URL`,path 与 soul-api 一致;**请求体与响应字段一律 camelCase**。
|
||||
|
||||
## 3. soul-admin 目录结构
|
||||
|
||||
\`\`\`
|
||||
soul-admin/src/
|
||||
├── api/
|
||||
│ └── client.ts # 统一请求封装,baseUrl + path
|
||||
├── components/
|
||||
│ ├── ui/ # 通用 UI (Button, Card, Dialog, Table...)
|
||||
│ └── modules/ # 业务模块 (如 UserDetailModal)
|
||||
├── layouts/
|
||||
│ └── AdminLayout.tsx # 管理端布局与侧栏
|
||||
├── pages/ # 页面(与路由一一对应)
|
||||
│ ├── login/
|
||||
│ ├── dashboard/
|
||||
│ ├── users/
|
||||
│ ├── orders/
|
||||
│ ├── distribution/
|
||||
│ ├── withdrawals/
|
||||
│ ├── content/
|
||||
│ ├── chapters/
|
||||
│ ├── settings/
|
||||
│ ├── referral-settings/
|
||||
│ ├── payment/
|
||||
│ ├── match/
|
||||
│ └── ...
|
||||
├── lib/
|
||||
│ └── utils.ts
|
||||
├── App.tsx
|
||||
└── main.tsx
|
||||
\`\`\`
|
||||
|
||||
## 4. 小程序 (miniprogram)
|
||||
|
||||
- **技术**: 微信原生,`app.js` 全局、`app.request()` 封装请求,baseUrl 可配置指向 soul-api。
|
||||
- **页面**: 首页、阅读、个人中心、分销、提现等;与 soul-api 接口对接,**请求/响应字段已统一为 camelCase**(如 `userId`、`referralCode`、`createdAt`)。
|
||||
- **登录**: 微信登录 + 手机号授权,与后端 `/api/miniprogram/login` 等对接。
|
||||
|
||||
## 5. API 与数据规范
|
||||
|
||||
- **路径**: 与现网完全一致(如 `/api/user/profile`、`/api/admin/withdrawals`),由 soul-api 提供。
|
||||
- **字段**: 所有请求体、响应体、前端类型定义**统一小写开头驼峰(camelCase)**:`userId`、`createdAt`、`referralCode`、`hasFullBook` 等。禁止对外使用 snake_case。
|
||||
- **类型**: TypeScript 接口与 API 响应一一对应,便于联调与维护。
|
||||
|
||||
## 6. 交互与体验(通用)
|
||||
|
||||
- **加载**: 列表/详情加载时使用骨架屏或明确 loading 状态,避免白屏。
|
||||
- **错误**: 接口失败要有提示(Toast/Alert),必要处可重试。
|
||||
- **表单**: 提交字段与后端约定一致(camelCase),校验与错误信息清晰。
|
||||
|
||||
---
|
||||
**总结**:管理端在 soul-admin(React+Vite),C 端在小程序;两者都通过统一 API 封装对接 soul-api,字段规范统一为 camelCase。
|
||||
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 125 KiB |
54
开发文档/6、后端/后端开发规范.md
Normal file
54
开发文档/6、后端/后端开发规范.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 后端开发规范 (Backend Specs) - 智能自生长文档
|
||||
|
||||
> **提示词功能 (Prompt Function)**: 将本文件拖入 AI 对话框,即可激活“Go 后端专家”角色,生成符合项目规范的 soul-api 代码。
|
||||
|
||||
## 1. 基础上下文
|
||||
|
||||
### 1.1 角色档案:卡若 (Karuo)
|
||||
- **核心**:接口稳、性能好、与现网路径和契约一致。
|
||||
- **习惯**:请求/响应统一 camelCase,数据库列名 snake_case 仅内部使用。
|
||||
|
||||
### 1.2 技术栈(当前)
|
||||
|
||||
- **语言**:Go 1.25+。
|
||||
- **框架**:Gin (HTTP),GORM (ORM)。
|
||||
- **数据**:MySQL。
|
||||
- **配置**:环境变量(godotenv),.env 不提交。
|
||||
|
||||
## 2. 开发规范核心
|
||||
|
||||
### 2.1 项目结构 (soul-api)
|
||||
|
||||
- **handler**:按业务拆分文件(如 `user.go`、`order.go`、`admin_withdrawals.go`),每个 handler 对应现网 API 路径与行为。
|
||||
- **model**:GORM 模型,表名与列名 snake_case;**JSON 标签必须 camelCase**(如 `json:"userId"`、`json:"createdAt"`)。
|
||||
- **router**:在 `router.go` 中集中注册,路径与现网 `/api/*` 一致。
|
||||
- **middleware**:CORS、AdminAuth、限流、安全头等。
|
||||
|
||||
### 2.2 接口与字段规范(强制)
|
||||
|
||||
- **路径**:与现网完全一致,例如 `GET /api/user/profile`、`PUT /api/admin/withdrawals`。
|
||||
- **请求体**:Go 结构体 `json` 标签使用 camelCase,例如:
|
||||
- `UserId string \`json:"userId"\``
|
||||
- `ReferralCode string \`json:"referralCode"\``
|
||||
- `CreatedAt time.Time \`json:"createdAt"\``
|
||||
- **响应**:通过 GORM 模型或 `gin.H` 返回时,键名一律 camelCase;禁止对外返回 `user_id`、`created_at` 等 snake_case。
|
||||
- **数据库**:表/列名保持 snake_case,仅在 GORM 与 SQL 中使用。
|
||||
|
||||
### 2.3 安全与错误
|
||||
|
||||
- **SQL**:一律使用 GORM 或参数化查询,禁止拼接 SQL。
|
||||
- **鉴权**:管理端接口使用 `middleware.AdminAuth()`,未登录返回 401 或统一错误体。
|
||||
- **错误响应**:统一格式如 `gin.H{"success": false, "error": "错误说明"}`。
|
||||
|
||||
### 2.4 配置与依赖
|
||||
|
||||
- **配置**:从环境变量读取(如 `DB_HOST`、`PORT`、`CORS_ORIGINS`),参考 `.env.example`。
|
||||
- **依赖**:`go mod tidy`,提交前确保 go.mod/go.sum 已更新。
|
||||
|
||||
## 3. AI 协作指令
|
||||
|
||||
**角色**:Go 后端架构师(soul-api)。
|
||||
**任务**:
|
||||
1. **代码实现**:新增或修改 handler/model/router,路径与现网一致,请求/响应字段 camelCase。
|
||||
2. **模型定义**:GORM 的 `gorm` 标签用 snake_case,`json` 标签用 camelCase。
|
||||
3. **逻辑图解**:复杂流程可用 Mermaid 展示调用关系或数据流。
|
||||
69
开发文档/6、后端/后端架构.md
Normal file
69
开发文档/6、后端/后端架构.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# 后端架构与业务逻辑
|
||||
|
||||
**我是卡若。**
|
||||
|
||||
当前后端为独立项目 **soul-api**(Go + Gin + GORM),提供全部 `/api/*` 接口,与 soul-admin、小程序对接。
|
||||
|
||||
## 1. 技术栈(当前)
|
||||
|
||||
| 项目 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| **soul-api** | Go 1.25 + Gin + GORM + MySQL | 独立 API 服务,路径与现网一致,响应 camelCase |
|
||||
|
||||
## 2. soul-api 目录结构
|
||||
|
||||
\`\`\`
|
||||
soul-api/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # 入口
|
||||
├── internal/
|
||||
│ ├── config/ # 配置(环境变量)
|
||||
│ ├── database/ # 数据库连接 (GORM)
|
||||
│ ├── handler/ # API 处理函数(按业务拆分)
|
||||
│ │ ├── admin.go, admin_chapters.go, admin_withdrawals.go ...
|
||||
│ │ ├── auth.go, user.go, book.go, orders.go
|
||||
│ │ ├── withdraw.go, referral.go, distribution.go
|
||||
│ │ ├── config.go, db.go, miniprogram.go ...
|
||||
│ ├── middleware/ # 中间件(CORS、鉴权、限流、安全头)
|
||||
│ ├── model/ # 数据模型(GORM + json 标签 camelCase)
|
||||
│ │ ├── user.go, order.go, chapter.go, withdrawal.go ...
|
||||
│ └── router/
|
||||
│ └── router.go # 路由注册,路径与现网 /api/* 一致
|
||||
├── .env, .env.example
|
||||
├── go.mod, go.sum
|
||||
└── Makefile
|
||||
\`\`\`
|
||||
|
||||
## 3. 核心业务模块(在 handler 中实现)
|
||||
|
||||
- **鉴权**:`/api/admin` 登录与鉴权,管理端 Cookie/Token;小程序走 `/api/miniprogram/login` 等。
|
||||
- **用户**:`/api/user/profile`、`/api/user/update`、`/api/user/track`、`/api/user/purchase-status` 等。
|
||||
- **书籍/章节**:`/api/book/*`(目录、章节、搜索、统计)。
|
||||
- **订单与支付**:`/api/orders`、`/api/miniprogram/pay`、支付回调等。
|
||||
- **分销与提现**:`/api/referral/*`、`/api/distribution/*`、`/api/withdraw/*`、`/api/admin/withdrawals`。
|
||||
- **配置与内容**:`/api/config`、`/api/content`、`/api/db/config`、`/api/db/book` 等。
|
||||
- **管理端**:`/api/admin/*`(章节、分销概览、提现审核等)、`/api/db/*`(用户、配置、初始化等)。
|
||||
|
||||
## 4. 接口与字段规范
|
||||
|
||||
- **路径**:与现网完全一致(如 `GET /api/user/profile`、`PUT /api/admin/withdrawals`),便于前端只改 baseUrl 切换后端。
|
||||
- **请求/响应字段**:一律**小写开头驼峰(camelCase)**。
|
||||
- Go 结构体:`json:"userId"`、`json:"createdAt"`、`json:"referralCode"`。
|
||||
- 数据库列名:保持 snake_case(如 `user_id`、`created_at`),仅在 GORM 与 SQL 中使用,不暴露给前端。
|
||||
- **统一响应**:成功可返回 `gin.H{"success": true, "data": ...}`;失败 `gin.H{"success": false, "error": "..."}`。
|
||||
|
||||
## 5. 数据库与配置
|
||||
|
||||
- **数据库**:MySQL,GORM 连接;表结构可与原 Next/Prisma 保持一致,便于迁移。
|
||||
- **配置**:环境变量(.env),如 `DB_*`、`PORT`、`CORS_ORIGINS`、`JWT_SECRET` 等;敏感信息不提交仓库。
|
||||
|
||||
## 6. 扩展性
|
||||
|
||||
- **鉴权**:管理端已用 `middleware.AdminAuth()` 保护 `/api/admin/*`、`/api/db/*`。
|
||||
- **CORS**:已配置 AllowOrigins/AllowCredentials,支持 soul-admin 与小程序跨域。
|
||||
- **限流**:可按需在 middleware 中增加 rate limit。
|
||||
|
||||
---
|
||||
**卡若说:**
|
||||
soul-api 是唯一对外后端,功能明确、路径与字段规范统一,便于多端复用与维护。
|
||||
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 183 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user