From 4d70f16b692a0adc8e0442f33903f49ad3fb9779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=96=B0=E9=9B=A8?= Date: Tue, 3 Feb 2026 00:23:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=83=A8=E7=BD=B2=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/server-deploy.yml | 350 ++++++++++++++++++++ client/ios/client.xcodeproj/project.pbxproj | 31 +- scripts/ssh | 49 +++ scripts/ssh-key-to-b64.mjs | 85 +++++ server/Dockerfile | 5 + server/README.md | 37 +++ server/app/core/config.py | 39 ++- server/env.example | 31 ++ spec_kit/overview.md | 2 + 9 files changed, 604 insertions(+), 25 deletions(-) create mode 100644 .gitea/workflows/server-deploy.yml create mode 100644 scripts/ssh create mode 100644 scripts/ssh-key-to-b64.mjs diff --git a/.gitea/workflows/server-deploy.yml b/.gitea/workflows/server-deploy.yml new file mode 100644 index 0000000..7571d03 --- /dev/null +++ b/.gitea/workflows/server-deploy.yml @@ -0,0 +1,350 @@ +name: Deploy Server (SSH + Nginx 蓝绿) + +# 手动触发:选择部署环境(dev/pro)并输入要部署的镜像 tag +on: + workflow_dispatch: + inputs: + env: + description: '部署环境' + type: choice + required: true + options: + - dev + - pro + tag: + description: '要部署的镜像 Tag(例如:v1.2.7)' + required: true + +# 同一环境同一时间只允许一个部署在跑,避免互相覆盖 +concurrency: + group: deploy-server-${{ github.event.inputs.env }} + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + env: + # 统一在 env 中注入变量,减少 runner 差异带来的兼容性问题 + DEPLOY_ENV: ${{ github.event.inputs.env }} + DEPLOY_TAG: ${{ github.event.inputs.tag }} + + # 镜像名(优先 vars.DOCKER_IMAGE;未配置则步骤里兜底) + DOCKER_IMAGE: ${{ vars.DOCKER_IMAGE }} + + # 可选:明确 registry(私有仓库用)。未配置会从 DOCKER_IMAGE 推断 + DOCKER_REGISTRY: ${{ vars.DOCKER_REGISTRY }} + + # SSH:dev 用 secrets,pro 用 vars(按你现有用法) + SSH_HOST: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_HOST || vars.PRO_SSH_HOST }} + SSH_USER: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_USER || vars.PRO_SSH_USER }} + SSH_PORT: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_PORT || vars.PRO_SSH_PORT }} + SSH_KEY: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_KEY || secrets.PRO_SSH_KEY }} + # 推荐把私钥做成 base64(单行)存到 Secrets,避免多行变量丢换行 + SSH_KEY_B64: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_KEY_B64 || secrets.PRO_SSH_KEY_B64 }} + + # Nginx upstream 配置(建议放到 vars) + NGINX_UPSTREAM_FILE: ${{ vars.NGINX_UPSTREAM_FILE }} + NGINX_UPSTREAM_NAME: ${{ vars.NGINX_UPSTREAM_NAME }} + + # 蓝绿端口(宿主机端口,建议放到 vars;未配置则脚本内有默认值) + BLUE_PORT: ${{ vars.BLUE_PORT }} + GREEN_PORT: ${{ vars.GREEN_PORT }} + + # 容器内监听端口(FastAPI 常用 8000;未配置则默认 8000) + CONTAINER_PORT: ${{ vars.CONTAINER_PORT }} + + # 健康检查路径(未配置则默认 /health;如果你没有 health 接口,可改为 /docs 或 /) + HEALTHCHECK_PATH: ${{ vars.HEALTHCHECK_PATH }} + + # 可选:远端 env 文件路径(例如 /opt/mindfulness-server/.env.prod),存在则 docker run --env-file + REMOTE_ENV_FILE: ${{ vars.REMOTE_ENV_FILE }} + + steps: + - name: 配置 SSH Key(密钥登陆) + shell: bash + run: | + set -euo pipefail + + # 镜像名兜底(与 .gitea/workflows/server-build.yml 的默认一致) + if [[ -z "${DOCKER_IMAGE:-}" ]]; then + DOCKER_IMAGE="docker.damer.fun/damer/mindfulness-server" + fi + if [[ -n "${GITHUB_ENV:-}" ]]; then + echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> "$GITHUB_ENV" + fi + if [[ -n "${GITEA_ENV:-}" ]]; then + echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> "$GITEA_ENV" + fi + + mkdir -p ~/.ssh + chmod 700 ~/.ssh + + # 写入私钥 + if [[ -n "${SSH_KEY_B64:-}" ]]; then + if ! printf '%s' "${SSH_KEY_B64}" | base64 -d | tr -d '\r' > ~/.ssh/id_rsa; then + echo "私钥 base64 解码失败:请确认你配置的 *_SSH_KEY_B64 是对私钥文件做的 base64(单行),且未被截断。" + exit 1 + fi + else + printf '%s' "${SSH_KEY}" | tr -d '\r' > ~/.ssh/id_rsa + fi + chmod 600 ~/.ssh/id_rsa + + # 快速校验私钥是否可解析(不会输出私钥内容) + if ! ssh-keygen -y -f ~/.ssh/id_rsa >/dev/null 2>&1; then + echo "SSH 私钥无法解析(常见原因:内容复制丢换行/截断、格式不兼容、或 base64 不正确)。" + echo "推荐:生成一把“无口令”的部署专用私钥,并用 base64 存储到 Secrets。" + exit 1 + fi + + SSH_PORT="${SSH_PORT:-22}" + if [[ -n "${GITHUB_ENV:-}" ]]; then + echo "SSH_PORT=${SSH_PORT}" >> "$GITHUB_ENV" + fi + if [[ -n "${GITEA_ENV:-}" ]]; then + echo "SSH_PORT=${SSH_PORT}" >> "$GITEA_ENV" + fi + + # 预写 known_hosts,避免交互 + ssh-keyscan -p "${SSH_PORT}" -H "${SSH_HOST}" >> ~/.ssh/known_hosts 2>/dev/null + + - name: 验证 SSH 连接(快速定位公钥/用户/端口问题) + shell: bash + run: | + set -euo pipefail + SSH_PORT="${SSH_PORT:-22}" + ssh -p "${SSH_PORT}" \ + -i ~/.ssh/id_rsa \ + -o StrictHostKeyChecking=yes \ + -o BatchMode=yes \ + -o IdentitiesOnly=yes \ + "${SSH_USER}@${SSH_HOST}" 'echo "SSH_OK $(whoami)@$(hostname)"' + + - name: 远程登录 Docker Registry(如需私有镜像) + shell: bash + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} + run: | + set -euo pipefail + + # 如果镜像是公开的,可不配 DOCKER_USERNAME/DOCKER_TOKEN;此步骤会自动跳过 + if [[ -z "${DOCKER_USERNAME:-}" || -z "${DOCKER_TOKEN:-}" ]]; then + echo "未配置 DOCKER_USERNAME/DOCKER_TOKEN,跳过远程 docker login。" + exit 0 + fi + + SSH_PORT="${SSH_PORT:-22}" + + # 推断 registry:优先使用 DOCKER_REGISTRY;否则从 DOCKER_IMAGE 的第一个段推断 + REG="${DOCKER_REGISTRY:-}" + if [[ -z "${REG}" ]]; then + FIRST_SEG="${DOCKER_IMAGE%%/*}" + if [[ "${FIRST_SEG}" == *.* || "${FIRST_SEG}" == *:* || "${FIRST_SEG}" == "localhost" ]]; then + REG="${FIRST_SEG}" + fi + fi + if [[ -z "${REG}" ]]; then + echo "无法推断 registry(看起来像 Docker Hub 公有镜像),跳过 docker login。" + exit 0 + fi + + ssh -p "${SSH_PORT}" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes \ + "${SSH_USER}@${SSH_HOST}" bash -s -- "${DOCKER_TOKEN}" "${DOCKER_USERNAME}" "${REG}" <<'REMOTE' + set -euo pipefail + + TOKEN="$1" + USERNAME="$2" + REGISTRY="$3" + + SUDO="" + if [[ "$(id -u)" -ne 0 ]] && command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + fi + + printf '%s' "$TOKEN" | ${SUDO} docker login "${REGISTRY}" -u "$USERNAME" --password-stdin + REMOTE + + - name: SSH 部署(Nginx 蓝绿切换) + shell: bash + run: | + set -euo pipefail + + SSH_PORT="${SSH_PORT:-22}" + NGINX_UPSTREAM_NAME="${NGINX_UPSTREAM_NAME:-mindfulness_backend}" + NGINX_UPSTREAM_FILE="${NGINX_UPSTREAM_FILE:-/etc/nginx/conf.d/mindfulness-server.conf}" + BLUE_PORT="${BLUE_PORT:-8001}" + GREEN_PORT="${GREEN_PORT:-8002}" + CONTAINER_PORT="${CONTAINER_PORT:-8000}" + HEALTHCHECK_PATH="${HEALTHCHECK_PATH:-/health}" + + echo "准备部署:${DOCKER_IMAGE}:${DEPLOY_TAG} -> ${DEPLOY_ENV} (${SSH_USER}@${SSH_HOST}:${SSH_PORT})" + + ssh -p "${SSH_PORT}" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes "${SSH_USER}@${SSH_HOST}" bash -s -- \ + "${DOCKER_IMAGE}" "${DEPLOY_TAG}" "${NGINX_UPSTREAM_FILE}" "${NGINX_UPSTREAM_NAME}" "${BLUE_PORT}" "${GREEN_PORT}" "${CONTAINER_PORT}" "${HEALTHCHECK_PATH}" "${REMOTE_ENV_FILE:-}" <<'REMOTE' + set -euo pipefail + + IMAGE="$1" + TAG="$2" + UPSTREAM_FILE="$3" + UPSTREAM_NAME="$4" + BLUE_PORT="$5" + GREEN_PORT="$6" + CONTAINER_PORT="$7" + HEALTHCHECK_PATH="$8" + REMOTE_ENV_FILE="$9" + + APP_DIR="/opt/mindfulness-server" + ACTIVE_FILE="${APP_DIR}/active_color" + mkdir -p "${APP_DIR}" + + # 判断 sudo(如果非 root 且存在 sudo,则使用 sudo) + SUDO="" + if [[ "$(id -u)" -ne 0 ]] && command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + fi + + ACTIVE_COLOR="blue" + if [[ -f "${ACTIVE_FILE}" ]]; then + ACTIVE_COLOR="$(cat "${ACTIVE_FILE}" || echo blue)" + fi + + if [[ "${ACTIVE_COLOR}" == "blue" ]]; then + NEW_COLOR="green" + NEW_PORT="${GREEN_PORT}" + OLD_COLOR="blue" + OLD_PORT="${BLUE_PORT}" + else + NEW_COLOR="blue" + NEW_PORT="${BLUE_PORT}" + OLD_COLOR="green" + OLD_PORT="${GREEN_PORT}" + fi + + echo "当前在线:${ACTIVE_COLOR}(${OLD_PORT}),准备发布:${NEW_COLOR}(${NEW_PORT})" + + # 拉取镜像 + ${SUDO} docker pull "${IMAGE}:${TAG}" + + # 启动新颜色容器 + ${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" >/dev/null 2>&1 || true + + ENV_FILE_ARGS=() + if [[ -n "${REMOTE_ENV_FILE}" && -f "${REMOTE_ENV_FILE}" ]]; then + ENV_FILE_ARGS=(--env-file "${REMOTE_ENV_FILE}") + echo "将使用远端 env 文件:${REMOTE_ENV_FILE}" + elif [[ -n "${REMOTE_ENV_FILE}" ]]; then + echo "提示:REMOTE_ENV_FILE 已配置但文件不存在:${REMOTE_ENV_FILE}(将忽略 env-file)" + fi + + ${SUDO} docker run -d \ + --name "mindfulness-server-${NEW_COLOR}" \ + --restart=always \ + -p "${NEW_PORT}:${CONTAINER_PORT}" \ + "${ENV_FILE_ARGS[@]}" \ + "${IMAGE}:${TAG}" + + # 健康检查 + if [[ "${HEALTHCHECK_PATH}" != /* ]]; then + HEALTHCHECK_PATH="/${HEALTHCHECK_PATH}" + fi + HEALTH_URL="http://127.0.0.1:${NEW_PORT}${HEALTHCHECK_PATH}" + echo "健康检查:${HEALTH_URL}" + + for i in $(seq 1 30); do + if command -v curl >/dev/null 2>&1; then + if curl -fsS "${HEALTH_URL}" >/dev/null; then + echo "健康检查通过" + break + fi + elif command -v wget >/dev/null 2>&1; then + if wget -q -O /dev/null "${HEALTH_URL}"; then + echo "健康检查通过" + break + fi + else + echo "远端缺少 curl/wget,跳过 HTTP 健康检查" + break + fi + + if [[ "$i" -eq 30 ]]; then + echo "健康检查失败:新版本未就绪,回滚并退出" + ${SUDO} docker logs --tail 200 "mindfulness-server-${NEW_COLOR}" || true + ${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true + exit 1 + fi + sleep 2 + done + + # 切换 Nginx upstream(在同一个 conf 文件中通过 backup 做主备切换) + if [[ ! -f "${UPSTREAM_FILE}" ]]; then + echo "未找到 Nginx upstream 配置文件:${UPSTREAM_FILE}" + ${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true + exit 1 + fi + + if ! ${SUDO} grep -qE "upstream[[:space:]]+${UPSTREAM_NAME}[[:space:]]*\\{" "${UPSTREAM_FILE}"; then + echo "在 ${UPSTREAM_FILE} 中未找到 upstream:${UPSTREAM_NAME}" + ${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true + exit 1 + fi + + if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${BLUE_PORT}" "${UPSTREAM_FILE}"; then + echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${BLUE_PORT}(请先按参考配置写入 upstream)" + ${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true + exit 1 + fi + if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${GREEN_PORT}" "${UPSTREAM_FILE}"; then + echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${GREEN_PORT}(请先按参考配置写入 upstream)" + ${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true + exit 1 + fi + + # 备份(回滚用) + BACKUP_FILE="${UPSTREAM_FILE}.bak.$(date +%s)" + ${SUDO} cp -f "${UPSTREAM_FILE}" "${BACKUP_FILE}" + + echo "切换 upstream:${UPSTREAM_NAME}(${OLD_PORT} -> ${NEW_PORT})通过 backup 切换" + + TMP_FILE="$(mktemp)" + ${SUDO} awk -v name="${UPSTREAM_NAME}" -v old="${OLD_PORT}" -v new="${NEW_PORT}" ' + BEGIN { in_up = 0 } + $0 ~ ("^[[:space:]]*upstream[[:space:]]+" name "[[:space:]]*\\{[[:space:]]*$") { in_up = 1 } + in_up == 1 { + # 新端口:主(去掉 backup) + if ($0 ~ ("server[[:space:]]+127\\.0\\.0\\.1:" new)) { + gsub(/[[:space:]]+backup[[:space:]]*;/, ";") + } + # 旧端口:备(确保有 backup;) + if ($0 ~ ("server[[:space:]]+127\\.0\\.0\\.1:" old)) { + gsub(/[[:space:]]+backup[[:space:]]*;/, ";") + sub(/;[[:space:]]*$/, " backup;") + } + } + in_up == 1 && $0 ~ /^[[:space:]]*\}[[:space:]]*$/ { in_up = 0 } + { print } + ' "${UPSTREAM_FILE}" > "${TMP_FILE}" + + ${SUDO} cp -f "${TMP_FILE}" "${UPSTREAM_FILE}" + rm -f "${TMP_FILE}" + + # 校验并 reload nginx(失败则回滚并退出) + if ${SUDO} nginx -t; then + ${SUDO} nginx -s reload + else + echo "Nginx 配置校验失败,回滚 upstream 配置并退出" + ${SUDO} cp -f "${BACKUP_FILE}" "${UPSTREAM_FILE}" || true + ${SUDO} nginx -t && ${SUDO} nginx -s reload || true + ${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true + exit 1 + fi + + # 记录当前在线颜色 + echo "${NEW_COLOR}" | ${SUDO} tee "${ACTIVE_FILE}" >/dev/null + + # 下线旧容器(切流后再停旧的) + ${SUDO} docker rm -f "mindfulness-server-${OLD_COLOR}" >/dev/null 2>&1 || true + + echo "部署完成:${NEW_COLOR} 已上线" + REMOTE + diff --git a/client/ios/client.xcodeproj/project.pbxproj b/client/ios/client.xcodeproj/project.pbxproj index 99cfd9b..b5b59a4 100644 --- a/client/ios/client.xcodeproj/project.pbxproj +++ b/client/ios/client.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 77; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -11,14 +11,12 @@ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; - A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */; }; + A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */; }; B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; }; EB3DAF832F2A4B8E00450593 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */; }; EB3DAF942F2A4B8F00450593 /* 情绪小组件Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - EB630B302F30BE2700DC761A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EB630B2F2F30BE2700DC761A /* Assets.xcassets */; }; - EB630B312F30BE2700DC761A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EB630B2F2F30BE2700DC761A /* Assets.xcassets */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; /* End PBXBuildFile section */ @@ -52,7 +50,7 @@ 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = ""; }; 3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = ""; }; 75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = ""; }; - A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = ""; }; @@ -61,7 +59,6 @@ EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; EB3DAF9A2F2A4D0900450593 /* MindfulnessWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindfulnessWidget.swift; sourceTree = ""; }; - EB630B2F2F30BE2700DC761A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = client/AppDelegate.swift; sourceTree = ""; }; F11748442D0722820044C1D9 /* client-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "client-Bridging-Header.h"; path = "client/client-Bridging-Header.h"; sourceTree = ""; }; @@ -69,7 +66,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = { + EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( EmotionWidget.swift, @@ -80,18 +77,7 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */, - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = "情绪小组件"; - sourceTree = ""; - }; + EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -159,7 +145,6 @@ 83CBB9F61A601CBA00E9B192 = { isa = PBXGroup; children = ( - EB630B2F2F30BE2700DC761A /* Assets.xcassets */, 13B07FAE1A68108700A75B9A /* client */, 832341AE1AAA6A7D00B99B32 /* Libraries */, EB3DAF842F2A4B8E00450593 /* 情绪小组件 */, @@ -204,7 +189,7 @@ EB3DAFD42F2A5FC100450593 /* Recovered References */ = { isa = PBXGroup; children = ( - A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */, + A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */, ); name = "Recovered References"; sourceTree = ""; @@ -309,7 +294,6 @@ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, 0BE245B56A79D95AB0A7B4BA /* PrivacyInfo.xcprivacy in Resources */, - EB630B312F30BE2700DC761A /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -317,7 +301,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - EB630B302F30BE2700DC761A /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -461,7 +444,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */, + A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/scripts/ssh b/scripts/ssh new file mode 100644 index 0000000..475a603 --- /dev/null +++ b/scripts/ssh @@ -0,0 +1,49 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAACFwAAAAdzc2gtcn +NhAAAAAwEAAQAAAgEAu/+1e//7mPiim2alNSZ4Jt7NiXg9yD47Gil4wIyU2+WMkK14Fe/y +QEl5FmcbxfY105lO3ToIcrguNymOtj7aTcqT7tOtmDsVwRlJ1PNK4Qs8jIDYcuLJjot076 +UJrFTgIbJQzF7Eqsly5ORUa17WaugQX6TRE6jwblZ1kXrvXKt+QbiQvjnI9rqo+ANTZAln +jzdkWR7iRYObkRN6KUPJg1h3B6Xw/1qAi7KTxn23G4B01RI7wZw4D1NFR4gCQBrgGLyCpa +ePEEg1RXZi2ZM9to7xUj68/37lg+p2KCmWUFK1aavSTupm9cpDovdXej4MAGom2cQNi8us +5vs3XIgR6OKg8kW+YyKmv5KzUAngu2q/Q+flpLsHF7s9YvZnB0qtXUQR8ceXcmXdYwoOar +RcIKbcZX0vDfsEkisjholMgwlNOID/ZZJhLSUNpbZZOFlKk921KH89j2xkdYqe5S2psNwq +w0TzsUD3OPnuhaFvQVnTnSdRCF3fs+bCpz/lTQYGtoZbvuzydgDlPF8hy/Hz2mAbwk8CW+ +xU9eoa3+gAub+bzLYzNf8nMQtt3OxmvTlrodLnZZqpRarranghYr1cfIfUCUhBg4cPyvEg +KMmz+T8Lyfypq9sh4wB6RKBBa2KPdgD7tkvcHDhAYOLn+ZLPODSm0rNjUHfWehb4YAAWpY +MAAAdAxzVZC8c1WQsAAAAHc3NoLXJzYQAAAgEAu/+1e//7mPiim2alNSZ4Jt7NiXg9yD47 +Gil4wIyU2+WMkK14Fe/yQEl5FmcbxfY105lO3ToIcrguNymOtj7aTcqT7tOtmDsVwRlJ1P +NK4Qs8jIDYcuLJjot076UJrFTgIbJQzF7Eqsly5ORUa17WaugQX6TRE6jwblZ1kXrvXKt+ +QbiQvjnI9rqo+ANTZAlnjzdkWR7iRYObkRN6KUPJg1h3B6Xw/1qAi7KTxn23G4B01RI7wZ +w4D1NFR4gCQBrgGLyCpaePEEg1RXZi2ZM9to7xUj68/37lg+p2KCmWUFK1aavSTupm9cpD +ovdXej4MAGom2cQNi8us5vs3XIgR6OKg8kW+YyKmv5KzUAngu2q/Q+flpLsHF7s9YvZnB0 +qtXUQR8ceXcmXdYwoOarRcIKbcZX0vDfsEkisjholMgwlNOID/ZZJhLSUNpbZZOFlKk921 +KH89j2xkdYqe5S2psNwqw0TzsUD3OPnuhaFvQVnTnSdRCF3fs+bCpz/lTQYGtoZbvuzydg +DlPF8hy/Hz2mAbwk8CW+xU9eoa3+gAub+bzLYzNf8nMQtt3OxmvTlrodLnZZqpRarrangh +Yr1cfIfUCUhBg4cPyvEgKMmz+T8Lyfypq9sh4wB6RKBBa2KPdgD7tkvcHDhAYOLn+ZLPOD +Sm0rNjUHfWehb4YAAWpYMAAAADAQABAAACAQCvHyt3LqS3giQ2jg8my4bw7mqXu6IRfaag +YDFTa6DelZ2yx2QtSy5mJArfi3ybkxXg3dZQzVZBRsAGMnf12+aOwD29IwF55NAG5/+Wlm +sR5t2oI/18RzVpyw5KjXnNReLigLAzL7QTQ0P7Y5Yfzl/9hd56uZTos14D6hXqRpHhyP37 +SmnKSRmie44aOT7c2MXW6spXGiW8izPGmFYbSkHUT/vQ7ArqUuubNeXh6AlBXQ93y1xOpa +6y1+aZE2DFRkOpxzAMD2F80UaORhEei8HY5J529montZUmpKkcEGEswV6r6JonWMJAAwGu +NKkP/exAFqHKCI51wmx1XV3cnAPRS0zUwC7AoPuRGaeYZQ+A96+Cq8CFQkEQpTIQEWOQrM +c67M/3B+vQ6u3PRR7LlCW2mI6Ho2d+XgZYHP87FHBD2OkDxhOEclVQXD9V1BBOBw8ut7W0 +VB2Ur0t1yoT2zwYu6poYCj3nothVhebUPZs7rwrnYjLWOR8zFqePjFTiF4H0Gkti7JX+km +vAxlTSZLo/ADLmxbAs+AW/n9Cz6Xb7EEF2ihtlnKq9RH0k6TsxK0NmXJHlZ7PFEOdeyjBn +z9qzPGRcwtQZqBHCDe+7kxZKadOV907/t/xNi3suzsXyjNVR4vLJjgvZ6iRD9fNXzGBFK4 +ULWxX+mVPq3yBaX1pugQAAAQEA1YojZRdtMOaglUENmfZIj9UAjQK7LdfW98pGfl+2RoTJ +L/O6CP0DhqrZNcK0Ps+aCSnE+UIwYRcD2hhrocDWR06IETALqFxaUbEbCh5cGHUTkpVzp+ +wD0lG/gT08tK7flJdI3On1UVjEHeHG8Z0yRPXYHNzaElM98l0eaxgnUbbTHflTa0jM7G8H +NOnA36ANc8QNcpWh/RYHAzDI6CQcJO1R+D8VGXC2rgZYlTxzUHcaBCEIoDwggvQwwf90rw +y/hQTTOI+z0y1fLvZPjMA3qtw73WptIdkHkHC3IdaASOnLOD25d26ArwL8iJGs+gej5XgU +HyXdOQpxoo3g70EuBAAAAQEA3fE6+lM1SUSqzvI2pLBGnxMboXStoB/Uu14M8ziuxNj31s +KCF8SdOiolkUBSdyNIzchQkRII6wS3UoUyIqm4Co1oz68hJYxbSunWh33dszXoSQdX67Qk +8zJViWoA1kvkZRdBwZbcmRVizoDgzjYvjksLBZX+YaATehk4Zr9RUhNwIbqeMqfBggHoLI +vQQjPE5L5YxbIfk7gtPWqhzYFEIOIBsq5OCblnlI7ucxDeNgGOAVwmdhbk2FVmPpMVr1+j +D0A1frf7UTCDi8/TWGipb/arfde8IzdWowXVrKSqfoIdD8fnGOizFSwuarg63aeU1pcs/z +zZzDFd+xygxnBeWwAAAQEA2NkL0B4M34nK3xetJuRpoLJAyNwtrM7TuuvTu8bpHEo/1kSV +mIedsEjiv10A6VCMtYfAk3wegHbJwazBNpUA98jZdW84jvEOKeqz9jyZYo/Dzn7/zaqXRb +MxqLsK1kFTWxfsT05DZHxSGd1hk9qpa20aJRwOZaGzlslG6RksuxQ0DvOZVKOxaFWchFnU +ZWPQIRS4K0BBi487c0mbBUioWMtGuLNNo/lvwwv604KlljLRbawdYzMmmSr31tzk45LCfq +9MOch7T44eb4nLSFvLQ0PzvnBIuU7usXzpSmm47a5HkEOxIxgC03HvvAWvnMyVh68i3lBK +QbEaJnq4wHTN+QAAAAPlhY0BAgMEBQY= +-----END OPENSSH PRIVATE KEY----- \ No newline at end of file diff --git a/scripts/ssh-key-to-b64.mjs b/scripts/ssh-key-to-b64.mjs new file mode 100644 index 0000000..d3e845d --- /dev/null +++ b/scripts/ssh-key-to-b64.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * 把 SSH 私钥转换为“单行 base64”,用于放入 Gitea Secrets(例如 DEV_SSH_KEY_B64)。 + * + * 用法: + * 1) 从文件读取: + * node scripts/ssh-key-to-b64.mjs ~/.ssh/deploy_key + * + * 2) 从 stdin 读取(直接粘贴私钥内容,结束后按 Ctrl+D): + * node scripts/ssh-key-to-b64.mjs - + * + * 3) 输出同时复制到剪贴板(仅 macOS,需系统自带 pbcopy): + * node scripts/ssh-key-to-b64.mjs ~/.ssh/deploy_key --clipboard + * + * 注意: + * - 请不要把输出写入仓库或提交到 git。 + * - 工作流会优先使用 *_SSH_KEY_B64(更稳,避免多行换行丢失)。 + */ + +import fs from 'node:fs'; +import { spawnSync } from 'node:child_process'; + +function printHelp() { + console.log(`用法: + node scripts/ssh-key-to-b64.mjs <私钥文件路径> [--clipboard] + node scripts/ssh-key-to-b64.mjs - [--clipboard] + +示例: + node scripts/ssh-key-to-b64.mjs ~/.ssh/deploy_key + node scripts/ssh-key-to-b64.mjs - --clipboard +`); +} + +const args = process.argv.slice(2); +const clipboard = args.includes('--clipboard') || args.includes('-c'); +const help = args.includes('--help') || args.includes('-h'); +const input = args.find((a) => !a.startsWith('-')); + +if (help || !input) { + printHelp(); + process.exit(help ? 0 : 1); +} + +function readAllStdin() { + return fs.readFileSync(0); +} + +let buf; +try { + if (input === '-') { + buf = readAllStdin(); + } else { + buf = fs.readFileSync(input); + } +} catch (e) { + console.error(`读取失败:${String(e?.message || e)}`); + process.exit(1); +} + +// 规范化:去掉末尾的换行/回车(避免少数环境里复制粘贴多一个空行) +let text = buf.toString('utf8').replace(/\r/g, ''); +text = text.replace(/\n+$/g, '\n'); + +if (!text.includes('BEGIN') || !text.includes('PRIVATE KEY')) { + console.error('提示:输入内容看起来不像 SSH 私钥(未检测到 PRIVATE KEY 头部)。仍会继续转换,但请确认输入正确。'); +} + +const b64 = Buffer.from(text, 'utf8').toString('base64'); + +if (clipboard) { + if (process.platform !== 'darwin') { + console.error('当前不是 macOS,无法使用 --clipboard(需要 pbcopy)。将仅输出到 stdout。'); + } else { + const r = spawnSync('pbcopy', [], { input: b64, encoding: 'utf8' }); + if (r.status !== 0) { + console.error(`复制到剪贴板失败:pbcopy 退出码=${r.status}`); + } else { + console.error('已复制到剪贴板:请粘贴到 Gitea Secrets(例如 DEV_SSH_KEY_B64)。'); + } + } +} + +// 只输出 base64(单行) +process.stdout.write(b64); + diff --git a/server/Dockerfile b/server/Dockerfile index 23318fe..4da3e8a 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -23,5 +23,10 @@ COPY alembic.ini /app/alembic.ini EXPOSE 8000 +# 注意: +# - 镜像内不会打包 `.env.dev/.env.prod`(避免把敏感信息烘焙进镜像) +# - 运行容器时请通过 `--env-file` 或 `-e` 注入 DATABASE_URL / REDIS_URL / CELERY_BROKER_URL +# - 参考文档:server/README.md + # 生产镜像默认不开启 reload CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/server/README.md b/server/README.md index 6915301..b3448d2 100644 --- a/server/README.md +++ b/server/README.md @@ -82,6 +82,11 @@ CELERY_BROKER_URL=redis://dev_user:devpassword@127.0.0.1:6379/0 `.env.prod` 同理,替换为生产环境地址与密钥即可。 +补充: + +- 仓库内提供了一个不包含真实值的模板文件 `server/env.example`,可复制为 `.env.dev/.env.prod` 后再填写。 +- **Docker 不会自动读取 `.env.*`**,容器运行时需要通过 `--env-file` 或 `-e` 注入环境变量(见下方 Docker 运行)。 + ### 1.1 MySQL 命名与 dev/pro 区分(约定) - **生产库(prod)**:`mindfulness` @@ -146,6 +151,38 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload - OpenAPI 文档:`/docs` - ReDoc:`/redoc` +## Docker 运行 + +后端启动至少需要以下 3 个环境变量: + +- `DATABASE_URL` +- `REDIS_URL` +- `CELERY_BROKER_URL` + +### 方式 A:使用 env 文件(推荐) + +1) 在宿主机准备 `server/.env.prod`(或 `.env.dev`),内容为 `KEY=value`: + +- 可从 `server/env.example` 复制后填写 + +2) 运行容器时通过 `--env-file` 注入: + +```bash +docker run --rm -p 8000:8000 \ + --env-file server/.env.prod \ + mindfulness-server:latest +``` + +### 方式 B:直接用 -e 注入 + +```bash +docker run --rm -p 8000:8000 \ + -e DATABASE_URL="mysql+aiomysql://用户名:密码@mysql:3306/mindfulness?charset=utf8mb4" \ + -e REDIS_URL="redis://:密码@redis:6379/0" \ + -e CELERY_BROKER_URL="redis://:密码@redis:6379/0" \ + mindfulness-server:latest +``` + ## 数据库迁移(Alembic) > 若你采用 Alembic:建议把迁移脚本放在 `server/alembic/`,并在 `alembic.ini` 中配置数据库连接(或从环境变量读取)。 diff --git a/server/app/core/config.py b/server/app/core/config.py index 744d3ae..81a5889 100644 --- a/server/app/core/config.py +++ b/server/app/core/config.py @@ -4,6 +4,7 @@ from functools import lru_cache from pathlib import Path from typing import Literal, Optional +from pydantic import ValidationError from pydantic_settings import BaseSettings, SettingsConfigDict @@ -67,5 +68,41 @@ def get_settings() -> Settings: env = os.getenv("APP_ENV", "dev").strip() or "dev" env_file = _guess_env_file(env) - return Settings(_env_file=env_file) + try: + return Settings(_env_file=env_file) + except ValidationError as e: + # 给出更可执行的错误信息,避免只看到一串校验栈。 + missing_fields: list[str] = [] + for err in e.errors(): + if err.get("type") == "missing": + loc = err.get("loc") or () + if loc: + missing_fields.append(str(loc[0])) + + # 将字段名映射为常见的环境变量名(默认规则:字段名大写) + missing_env_keys = [f.upper() for f in missing_fields] if missing_fields else [] + + env_file_hint = env_file or f".env.{env}(未找到,已回退到系统环境变量)" + required_hint = ( + "、".join(missing_env_keys) + if missing_env_keys + else "DATABASE_URL、REDIS_URL、CELERY_BROKER_URL" + ) + + msg = ( + "应用启动失败:缺少必填配置。\n\n" + f"- 当前 APP_ENV:{env}\n" + f"- 期望读取的 env 文件:{env_file_hint}\n" + f"- 缺少的环境变量:{required_hint}\n\n" + "修复方式(任选其一):\n" + "1) 直接注入环境变量(推荐):\n" + " - DATABASE_URL=...\n" + " - REDIS_URL=...\n" + " - CELERY_BROKER_URL=...\n" + "2) 使用 env 文件:在 `server/` 下准备 `.env.dev` 或 `.env.prod`(KEY=value 格式),\n" + " 本地可通过 `server/run.sh --env dev|prod` 自动加载;Docker 运行可用 `--env-file` 传入。\n" + ) + # 不附带原始 ValidationError 的异常上下文,减少日志噪音; + # msg 已包含缺失项与修复方式,足够定位问题。 + raise RuntimeError(msg) from None diff --git a/server/env.example b/server/env.example index 64f08bb..70170c5 100644 --- a/server/env.example +++ b/server/env.example @@ -1,3 +1,34 @@ +# 说明: +# - 这是后端环境变量模板,请复制为 `.env.dev` 或 `.env.prod` 再填写真实值 +# - 请勿把包含真实账号密码/Token 的 `.env.*` 提交到仓库 +# +# 用法示例: +# - 本地:`./run.sh --env dev` +# - Docker:`docker run --env-file server/.env.prod ...` + +# 运行环境 +APP_ENV=dev +APP_NAME=mindfulness-server +APP_HOST=0.0.0.0 +APP_PORT=8000 + +# 数据库(SQLAlchemy 异步连接串) +# MySQL 示例(aiomysql): +# DATABASE_URL=mysql+aiomysql://用户名:密码@127.0.0.1:3306/mindfulness_dev?charset=utf8mb4 +DATABASE_URL= + +# Redis(缓存/任务队列) +# 示例: +# REDIS_URL=redis://:密码@127.0.0.1:6379/0 +REDIS_URL= + +# Celery(建议先只配 broker;如需结果存储可另配 CELERY_RESULT_BACKEND) +# 示例: +# CELERY_BROKER_URL=redis://:密码@127.0.0.1:6379/0 +CELERY_BROKER_URL= + +# 可选:Celery 结果存储 +# CELERY_RESULT_BACKEND=redis://:密码@127.0.0.1:6379/0 # 运行环境:dev 或 prod APP_ENV=dev diff --git a/spec_kit/overview.md b/spec_kit/overview.md index 41f50b7..2a9098a 100644 --- a/spec_kit/overview.md +++ b/spec_kit/overview.md @@ -39,6 +39,8 @@ - **近期变更**: - 新增后端镜像构建基础:`server/Dockerfile`、`server/.dockerignore` - 新增后端镜像打包与推送工作流:`.gitea/workflows/server-build.yml`(自动递增 semver tag 并推送到 Docker Hub) + - 新增后端蓝绿部署工作流:`.gitea/workflows/server-deploy.yml`(SSH 上机 + Docker 蓝绿启动 + Nginx upstream 切流 + 健康检查与失败回滚) + - 后端配置缺失时报错增强:`app/core/config.py` 将缺失的必填环境变量用中文提示并给出注入方式;补充 `server/env.example` 与 Docker 运行说明 ## Onboarding App Shell