33 Commits

Author SHA1 Message Date
吕新雨
bb7a4c7361 fix:main.py 2026-02-03 01:44:25 +08:00
吕新雨
17d19f9172 更新蓝v 2026-02-03 01:36:35 +08:00
吕新雨
b35b71470d upload 2026-02-03 01:25:47 +08:00
吕新雨
ccdc6350a1 最后一班 2026-02-03 01:22:25 +08:00
吕新雨
7fea799087 最后一班 2026-02-03 01:21:10 +08:00
吕新雨
e6b64b39f2 最后一班 2026-02-03 01:18:47 +08:00
吕新雨
a457186f46 放在宿主机 2026-02-03 01:07:06 +08:00
吕新雨
2d5b8b094f fix:upload 2026-02-03 00:44:00 +08:00
吕新雨
ef7c4e774d end 2026-02-03 00:41:38 +08:00
吕新雨
cc36301638 u 2026-02-03 00:36:48 +08:00
吕新雨
5dd632d4d3 fix:更新 2026-02-03 00:26:13 +08:00
吕新雨
4d70f16b69 更新部署工作了 2026-02-03 00:23:48 +08:00
吕新雨
2ddee6b8f8 fix:更新runner 2026-02-02 23:02:49 +08:00
吕新雨
aa530b0ce3 fix:更新runner 2026-02-02 22:58:42 +08:00
cdd0ac32de Merge pull request 'fix:更新自建仓库' (#13) from damer into main
Reviewed-on: #13
2026-02-02 14:50:28 +00:00
吕新雨
d98ec76dc0 fix:更新自建仓库 2026-02-02 22:50:11 +08:00
3c35e14c3d Merge pull request 'damer' (#12) from damer into main
Reviewed-on: #12
2026-02-02 14:47:30 +00:00
吕新雨
c0deea9318 fix:更新自建仓库 2026-02-02 22:47:09 +08:00
915e995ab7 Merge pull request 'fix:更新' (#11) from damer into main
Reviewed-on: #11
2026-02-02 14:26:16 +00:00
吕新雨
0e42e6f2a9 fix:更新 2026-02-02 22:25:48 +08:00
39e4bcab6c Merge pull request 'fix:更新server' (#10) from damer into main
Reviewed-on: #10
2026-02-02 14:22:49 +00:00
吕新雨
69a1046ff4 fix:更新server 2026-02-02 22:22:20 +08:00
ceaf459d97 Merge pull request 'damer' (#9) from damer into main
Reviewed-on: #9
2026-02-02 14:01:03 +00:00
吕新雨
d9a5dbafd6 fix:新增工作流 2026-02-02 21:59:15 +08:00
吕新雨
86e4853709 Merge branch 'main' into damer 2026-02-02 18:37:44 +08:00
吕新雨
2adf2475fa fix:git忽略文件 2026-02-02 18:37:00 +08:00
Hao
9dbba04408 Merge pull request 'Hao' (#8) from Hao into main
Reviewed-on: #8
2026-02-02 09:33:41 +00:00
1
64b8352ad3 fix:并 2026-02-02 17:33:20 +08:00
1
4b739dd194 feat: 完善启动分发逻辑,优化小组件背景适配与点赞图标交互 2026-02-02 17:17:10 +08:00
吕新雨
240cdda68f Merge branch 'main' into damer 2026-02-02 16:54:51 +08:00
吕新雨
ce48e54c03 增加git忽略 2026-02-02 16:53:26 +08:00
吕新雨
d045237952 chore: 清理 Python 运行产物并忽略
- 从仓库移除 __pycache__/ 与 *.pyc 等运行产物,避免 git pull 覆盖未跟踪文件导致合并失败
- 在 .gitignore 增加 Python 运行产物忽略规则
2026-02-02 16:52:22 +08:00
1
228fd7fd84 feat: 首页支持风景图与纯色背景随滑动自动切换,优化点赞图标样式与交互 2026-02-02 16:50:38 +08:00
108 changed files with 1250 additions and 223 deletions

View File

@@ -0,0 +1,5 @@
docker exec -it gitea-runner bash
# 然后在容器里安装 Node.js
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
node -v

View File

@@ -0,0 +1,104 @@
name: Build and Push Server Docker Image
# 手动触发 workflow从哪个分支运行就打包哪个分支的代码
on:
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
# 1⃣ Checkout 仓库代码
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# 需要能 push tag请在仓库 Secrets 配置 RUNNER_TOKEN
token: ${{ secrets.RUNNER_TOKEN }}
persist-credentials: true
# 2⃣ 自动递增 tag 并推送回 Gitea 仓库(默认按 vX.Y.Z 的 patch +1
- name: Auto bump tag and push to repository
shell: bash
run: |
set -euo pipefail
# 配置提交信息(用于创建注释 tag
git config user.name "gitea-actions"
git config user.email "actions@local"
# 确保本地有最新 tags
git fetch --tags --force
# 取最新的 semver tagvX.Y.Z按版本号排序
LATEST_TAG="$(git tag --list 'v*' --sort=-v:refname | head -n 1 || true)"
echo "LATEST_TAG=${LATEST_TAG}"
if [[ -z "${LATEST_TAG}" ]]; then
NEXT_TAG="v1.0.0"
else
if [[ "${LATEST_TAG}" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
NEXT_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))"
else
# 如果最新 tag 不符合 vX.Y.Z回退到 v1.0.0,避免误解析
NEXT_TAG="v1.0.0"
fi
fi
echo "NEXT_TAG=${NEXT_TAG}"
# 如果 tag 已存在则直接复用(避免重复运行失败)
if git rev-parse -q --verify "refs/tags/${NEXT_TAG}" >/dev/null; then
echo "Tag ${NEXT_TAG} 已存在,跳过创建。"
else
git tag -a "${NEXT_TAG}" -m "Release ${NEXT_TAG}"
git push origin "${NEXT_TAG}"
fi
# 输出给后续步骤使用
if [[ -n "${GITHUB_ENV:-}" ]]; then
echo "IMAGE_TAG=${NEXT_TAG}" >> "$GITHUB_ENV"
fi
# 兼容部分 Gitea Runner 环境变量命名
if [[ -n "${GITEA_ENV:-}" ]]; then
echo "IMAGE_TAG=${NEXT_TAG}" >> "$GITEA_ENV"
fi
# 3⃣ 设置镜像仓库与镜像名称(自建 Registry / Docker Hub 都可)
- name: Set image variables
shell: bash
run: |
# 直接写死:推送到自建仓库
IMAGE_NAME="docker.damer.fun/damer/mindfulness-server"
if [[ -n "${GITHUB_ENV:-}" ]]; then
echo "IMAGE_NAME=$IMAGE_NAME" >> "$GITHUB_ENV"
fi
if [[ -n "${GITEA_ENV:-}" ]]; then
echo "IMAGE_NAME=$IMAGE_NAME" >> "$GITEA_ENV"
fi
# 4⃣ 登录镜像仓库(自建 Registry / Docker Hub
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
# 与 IMAGE_NAME 的 registry 保持一致
registry: docker.damer.fun
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
# 5⃣ 构建 Docker 镜像(使用 server/ 作为构建上下文)
- name: Build Docker Image
shell: bash
run: |
docker build -f server/Dockerfile -t "$IMAGE_NAME:$IMAGE_TAG" server
# 6⃣ 推送 Docker 镜像到镜像仓库
- name: Push Docker Image
shell: bash
run: |
docker push "$IMAGE_NAME:$IMAGE_TAG"

View File

@@ -0,0 +1,431 @@
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 }}
# SSHdev 用 secretspro 用 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
# 从 Secrets 写入私钥(推荐使用 *_SSH_KEY_B64
SSH_KEY_PATH="${HOME}/.ssh/id_rsa"
if [[ -n "${SSH_KEY_B64:-}" ]]; then
# 兼容两种常见误配置:
# 1) *_SSH_KEY_B64 里其实粘贴的是“原始私钥”(包含 -----BEGIN ... PRIVATE KEY-----
# 2) base64 值在粘贴/保存过程中混入空白或其他无关字符
if printf '%s' "${SSH_KEY_B64}" | grep -qE 'BEGIN[[:space:]].*PRIVATE[[:space:]]KEY'; then
echo "提示:检测到 *_SSH_KEY_B64 看起来是原始私钥内容,将按原始私钥写入(建议你改用真正的 base64 单行值)。"
echo "SSH_KEY_B64 字符数(原始):${#SSH_KEY_B64}"
printf '%s' "${SSH_KEY_B64}" | tr -d '\r' > "${SSH_KEY_PATH}"
else
CLEAN_B64="$(printf '%s' "${SSH_KEY_B64}" | tr -d '\r\n\t ')"
echo "SSH_KEY_B64 字符数(原始/清理后):${#SSH_KEY_B64}/${#CLEAN_B64}"
if ! printf '%s' "${CLEAN_B64}" | base64 -d -i | tr -d '\r' > "${SSH_KEY_PATH}"; then
echo "私钥 base64 解码失败:"
echo "- 请确认你在 Gitea Secrets 配置的 *_SSH_KEY_B64 是“单行 base64 字符串”,不要带引号/前后空格。"
echo "- 推荐用仓库里的脚本生成并复制node scripts/ssh-key-to-b64.mjs ~/.ssh/你的私钥 --clipboard"
exit 1
fi
fi
else
echo "使用 SSH_KEY原始私钥写入。SSH_KEY 字符数:${#SSH_KEY}"
printf '%s' "${SSH_KEY}" | tr -d '\r' > "${SSH_KEY_PATH}"
fi
chmod 600 "${SSH_KEY_PATH}"
# 自检(不输出私钥内容,仅输出元信息,方便定位是否写入了错误文件/被截断)
echo "SSH_KEY_PATH=${SSH_KEY_PATH}"
KEY_BYTES="$(wc -c < "${SSH_KEY_PATH}" | tr -d ' ')"
KEY_LINES="$(wc -l < "${SSH_KEY_PATH}" | tr -d ' ')"
echo "SSH key 字节数:${KEY_BYTES}"
echo "SSH key 行数:${KEY_LINES}"
echo "SSH key 首行:$(head -n 1 "${SSH_KEY_PATH}" | tr -d '\r')"
echo "SSH key 末行:$(tail -n 1 "${SSH_KEY_PATH}" | tr -d '\r')"
if command -v stat >/dev/null 2>&1; then
# Ubuntu runner: stat -c不同系统做兼容
if stat -c '%a %U %G %n' "${SSH_KEY_PATH}" >/dev/null 2>&1; then
echo "SSH key 权限:$(stat -c '%a %U %G %n' "${SSH_KEY_PATH}")"
else
echo "SSH key 权限:$(stat -f '%Lp %Su %Sg %N' "${SSH_KEY_PATH}" 2>/dev/null || true)"
fi
fi
if command -v file >/dev/null 2>&1; then
echo "file(1) 识别:$(file "${SSH_KEY_PATH}")"
fi
if command -v sha256sum >/dev/null 2>&1; then
echo "SSH key sha256(前12位)$(sha256sum "${SSH_KEY_PATH}" | awk '{print substr($1,1,12)}')"
fi
echo "ssh 版本:$(ssh -V 2>&1 || true)"
echo "ssh-keygen 版本:$(ssh-keygen -V 2>&1 || true)"
echo "openssl 版本:$(openssl version 2>&1 || true)"
# 进一步校验OpenSSH 私钥中间的 base64 块是否可解码(不输出内容)
if grep -q '^-----BEGIN OPENSSH PRIVATE KEY-----$' "${SSH_KEY_PATH}" && grep -q '^-----END OPENSSH PRIVATE KEY-----$' "${SSH_KEY_PATH}"; then
# 注意:不要用变量名 in是 awk 关键字,部分实现会报语法错)
OPENSSH_B64_LEN="$(
awk '
BEGIN{in_block=0; n=0}
/^-----BEGIN OPENSSH PRIVATE KEY-----$/{in_block=1; next}
/^-----END OPENSSH PRIVATE KEY-----$/{in_block=0; exit}
in_block==1{gsub(/\r/,""); n+=length($0)}
END{print n}
' "${SSH_KEY_PATH}" 2>/dev/null || true
)"
if [[ -n "${OPENSSH_B64_LEN:-}" ]]; then
echo "OpenSSH base64 块字符数(合计):${OPENSSH_B64_LEN}"
fi
# 该校验仅用于提示,不应阻断部署流程
if ! awk '
BEGIN{in_block=0}
/^-----BEGIN OPENSSH PRIVATE KEY-----$/{in_block=1; next}
/^-----END OPENSSH PRIVATE KEY-----$/{in_block=0; exit}
in_block==1{gsub(/\r/,""); print}
' "${SSH_KEY_PATH}" 2>/dev/null | tr -d '\n' | base64 -d >/dev/null 2>&1; then
echo "提示OpenSSH 私钥的 base64 块无法解码(疑似内容被截断/损坏),将继续执行 ssh-keygen 校验定位。"
fi
fi
# 额外信息ssh-keygen -lf 的报错(不输出私钥内容)
if ! ssh-keygen -lf "${SSH_KEY_PATH}" >/dev/null 2>~/.ssh/ssh_key_fingerprint.err; then
echo "ssh-keygen -lf 报错:"
cat ~/.ssh/ssh_key_fingerprint.err || true
fi
# 快速校验私钥是否可解析(不会输出私钥内容)
if ! ssh-keygen -y -f "${SSH_KEY_PATH}" >/dev/null 2>~/.ssh/ssh_key_check.err; then
echo "SSH 私钥无法解析。下面是 ssh-keygen 的报错(不包含私钥内容):"
cat ~/.ssh/ssh_key_check.err || true
echo
echo "常见原因:"
echo "- 你填的是公钥(.pub不是私钥"
echo "- 私钥带口令CI 无法交互输入 passphrase"
echo "- 内容复制丢换行/被截断/不是正确的 base64"
echo
echo "推荐:生成一把“无口令”的部署专用私钥,并用 base64 存储到 Secrets*_SSH_KEY_B64。"
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/api.damer.fun.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

4
.gitignore vendored
View File

@@ -4,6 +4,10 @@
.DS_Store
*.pem
# Python运行产物
__pycache__/
*.py[cod]
# Node / JS
node_modules/
npm-debug.*

View File

@@ -13,7 +13,9 @@ git pull
# 创建自己的分支
git checkout -b 姓名拼写
# 生产密钥
ssh-keygen -t rsa -b 4096 -m PEM -N '' -f deploy_key_rsa
# 目录结构
/mindfulness

View File

@@ -1,11 +1,11 @@
{
"expo": {
"name": "Hey Mama",
"slug": "hey-mama",
"name": "client",
"slug": "client",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "heymama",
"scheme": "client",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
@@ -15,7 +15,7 @@
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.heymama.app"
"bundleIdentifier": "com.damer.mindfulness"
},
"android": {
"adaptiveIcon": {

View File

@@ -1,5 +1,5 @@
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated } from 'react-native';
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useNavigation, useFocusEffect } from 'expo-router';
import Animated, {
@@ -39,6 +39,40 @@ import LikeIcon from '@/assets/images/icon/like_icon.svg';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
// 预定义风景图列表
const NATURE_IMAGES = [
require('@/assets/theme/nature/1.png'),
require('@/assets/theme/nature/2.png'),
require('@/assets/theme/nature/3.png'),
require('@/assets/theme/nature/4.png'),
require('@/assets/theme/nature/5.png'),
require('@/assets/theme/nature/6.png'),
require('@/assets/theme/nature/7.png'),
require('@/assets/theme/nature/8.png'),
require('@/assets/theme/nature/9.png'),
require('@/assets/theme/nature/10.png'),
require('@/assets/theme/nature/11.png'),
require('@/assets/theme/nature/12.png'),
require('@/assets/theme/nature/13.png'),
require('@/assets/theme/nature/14.png'),
require('@/assets/theme/nature/15.png'),
require('@/assets/theme/nature/17.png'),
require('@/assets/theme/nature/18.png'),
require('@/assets/theme/nature/19.png'),
require('@/assets/theme/nature/20.png'),
require('@/assets/theme/nature/22.png'),
];
// 预定义颜色列表
const THEME_COLORS = [
'#F7D9BF',
'#CBF2D8',
'#F5CDDE',
'#F2ECCB',
'#E2CBF2',
'#CBD9F2',
];
export default function HomeScreen() {
const { t } = useTranslation();
const navigation = useNavigation();
@@ -126,12 +160,26 @@ export default function HomeScreen() {
};
}, []);
const backgroundColor = themeMode === 'color' ? '#F3D0E1' : '#F4D6C2';
const backgroundColor = useMemo(() => {
if (themeMode === 'color') {
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
return THEME_COLORS[colorIndex];
}
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
}, [themeMode, index]);
// 计算当前应该显示的风景图索引(滑动 10 次切换一张)
const natureImageIndex = useMemo(() => {
return Math.floor(index / 10) % NATURE_IMAGES.length;
}, [index]);
const currentNatureImage = NATURE_IMAGES[natureImageIndex];
useLayoutEffect(() => {
navigation.setOptions({
headerShadowVisible: false,
headerStyle: { backgroundColor },
headerStyle: { backgroundColor: themeMode === 'scenery' ? 'transparent' : backgroundColor },
headerTransparent: themeMode === 'scenery',
headerRight: () => (
<View style={styles.headerRight}>
<CircleIconButton
@@ -149,7 +197,7 @@ export default function HomeScreen() {
</View>
),
});
}, [backgroundColor, navigation, t]);
}, [backgroundColor, themeMode, navigation, t]);
const textAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
@@ -241,10 +289,11 @@ export default function HomeScreen() {
// 2. 保存到收藏夹,包含当前背景信息
await addFavorite({
id: typeof currentContentId === 'number' ? String(currentContentId) : (item as any).id,
favId: String(Date.now()), // 生成唯一 ID
id: item.id,
date: dateStr,
themeMode: themeMode,
background: backgroundColor, // 目前存储的是颜色值
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
});
// 3. 爱心缩放动画
@@ -267,8 +316,15 @@ export default function HomeScreen() {
return (
<View style={[styles.container, { backgroundColor }]} {...panResponder.panHandlers}>
<Animated.View style={[styles.card, textAnimatedStyle]}>
<Text style={styles.text}>{item.text}</Text>
{themeMode === 'scenery' && (
<ImageBackground
source={currentNatureImage}
style={StyleSheet.absoluteFill}
resizeMode="cover"
/>
)}
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
<Text style={[styles.text, themeMode === 'scenery' && styles.sceneryText]}>{item.text}</Text>
</Animated.View>
<View style={styles.actions}>
@@ -281,9 +337,13 @@ export default function HomeScreen() {
style={styles.reactionInner}
>
{likeFilled ? (
<LikeFilledIcon width={35} height={36} />
<LikeFilledIcon width={35} height={36} style={{ color: '#EA6969' }} />
) : (
<LikeIcon width={35} height={36} />
<LikeIcon
width={35}
height={36}
style={{ color: themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28' }}
/>
)}
</Pressable>
</Animated.View>
@@ -342,9 +402,15 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
card: {
paddingHorizontal: 30,
alignItems: 'center',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 30,
zIndex: 5, // 降低层级,防止遮挡底部按钮
},
text: {
fontSize: 22,
@@ -353,6 +419,16 @@ const styles = StyleSheet.create({
fontWeight: '700',
textAlign: 'center',
},
sceneryCard: {
// 风景模式下稍微收窄文案宽度,增加呼吸感
paddingHorizontal: 50,
},
sceneryText: {
color: '#FFFFFF',
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 4,
},
actions: {
position: 'absolute',
bottom: SCREEN_HEIGHT * 0.16,
@@ -360,6 +436,7 @@ const styles = StyleSheet.create({
right: 0,
flexDirection: 'row',
justifyContent: 'center',
zIndex: 20, // 提升层级,确保在最顶层可点击
},
reactionButton: {
alignItems: 'center',

View File

@@ -140,14 +140,13 @@ export default function OnboardingScreen() {
}
};
const onSkip = () => {
const onSkip = async () => {
// 跳过整个 Onboarding仍生成一个“全跳过”的最小画像保证下游可用
const scoringProfile = buildUserProfileFromQuestionnaire({});
void setUserProfileScoring(scoringProfile);
await setUserProfileScoring(scoringProfile);
// 标记已完成,避免下次启动再次进入 Onboarding
void setOnboardingCompleted(true);
await setOnboardingCompleted(true);
router.replace('/(app)/home');
};

View File

@@ -1,9 +1,8 @@
import { useEffect } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { useRouter } from 'expo-router';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getOnboardingCompleted, getConsentAccepted, setOnboardingCompleted, setConsentAccepted } from '@/src/storage/appStorage';
import { getOnboardingCompleted, getConsentAccepted } from '@/src/storage/appStorage';
/**
* 启动分发:根据 consent 和 onboarding 状态跳转
@@ -14,9 +13,6 @@ export default function Index() {
useEffect(() => {
let cancelled = false;
(async () => {
// 注意:不要在启动时无条件清空存储,否则 Onboarding/画像等数据无法持久化。
// 如需调试重置,请在开发期手动清空或自行加调试开关。
// 1. 检查是否同意协议
const consentAccepted = await getConsentAccepted();
if (cancelled) return;
@@ -26,9 +22,17 @@ export default function Index() {
return;
}
// 2. 检查 Onboarding
// 2. 检查 Onboarding 是否已完成
const completed = await getOnboardingCompleted();
router.replace(completed ? '/(app)/home' : '/(onboarding)/onboarding');
if (cancelled) return;
if (completed) {
// 如果已经完成过流程,直接进 Home
router.replace('/(app)/home');
} else {
// 如果是首次进入(或未完成流程),进入 Onboarding
router.replace('/(onboarding)/onboarding');
}
})();
return () => {
cancelled = true;
@@ -45,4 +49,3 @@ export default function Index() {
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

View File

@@ -50,6 +50,29 @@ type Props = {
type Page = 'root' | 'favorites' | 'dailyReminder' | 'widget' | 'language' | 'widgetHowTo';
type NavDirection = 'forward' | 'back';
const NATURE_IMAGES = [
require('@/assets/theme/nature/1.png'),
require('@/assets/theme/nature/2.png'),
require('@/assets/theme/nature/3.png'),
require('@/assets/theme/nature/4.png'),
require('@/assets/theme/nature/5.png'),
require('@/assets/theme/nature/6.png'),
require('@/assets/theme/nature/7.png'),
require('@/assets/theme/nature/8.png'),
require('@/assets/theme/nature/9.png'),
require('@/assets/theme/nature/10.png'),
require('@/assets/theme/nature/11.png'),
require('@/assets/theme/nature/12.png'),
require('@/assets/theme/nature/13.png'),
require('@/assets/theme/nature/14.png'),
require('@/assets/theme/nature/15.png'),
require('@/assets/theme/nature/17.png'),
require('@/assets/theme/nature/18.png'),
require('@/assets/theme/nature/19.png'),
require('@/assets/theme/nature/20.png'),
require('@/assets/theme/nature/22.png'),
];
export default function ProfileModal({ visible, name: propName, onClose }: Props) {
const { t } = useTranslation();
@@ -260,11 +283,11 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
setFavorites(list);
}
async function handleRemove(id: string) {
async function handleRemove(favId: string) {
// 1. 调用存储层移除收藏
await removeFavorite(id);
await removeFavorite(favId);
// 2. 更新本地状态
setFavorites(prev => prev.filter(item => item.id !== id));
setFavorites(prev => prev.filter(item => item.favId !== favId));
}
return (
@@ -274,7 +297,7 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
) : (
<FlatList
data={favorites}
keyExtractor={(it) => it.id}
keyExtractor={(it) => it.favId}
contentContainerStyle={styles.favList}
showsVerticalScrollIndicator={false}
renderItem={({ item }) => (
@@ -290,11 +313,30 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
<View style={styles.favRight}>
<View style={[
styles.favThumb,
{ backgroundColor: item.background } // 动态同步 Home 页的背景
item.themeMode === 'scenery' ? {} : { backgroundColor: item.background }
]}>
<Text style={styles.favThumbText} numberOfLines={4}>{item.text}</Text>
{item.themeMode === 'scenery' ? (
<View style={StyleSheet.absoluteFill}>
<Image
source={NATURE_IMAGES[parseInt(item.background)]}
style={{
width: width * 0.6,
height: 800, // 假设原图较高,设置一个较大的高度
position: 'absolute',
bottom: 0, // 关键:将图片底部对齐容器底部
}}
resizeMode="cover"
/>
</View>
) : null}
<Text style={[
styles.favThumbText,
item.themeMode === 'scenery' && { color: '#FFFFFF', textShadowColor: 'rgba(0,0,0,0.5)', textShadowOffset: {width:0, height:1}, textShadowRadius: 3 }
]} numberOfLines={4}>
{item.text}
</Text>
<Pressable
onPress={() => handleRemove(item.id)}
onPress={() => handleRemove(item.favId)}
style={styles.favRemoveBtn}
hitSlop={10}
>
@@ -765,6 +807,7 @@ const styles = StyleSheet.create({
position: 'relative',
borderWidth: 1,
borderColor: 'rgba(119, 47, 0, 0.05)',
overflow: 'hidden',
},
favThumbText: {
fontSize: 15,

View File

@@ -59,5 +59,13 @@ target 'client' do
:mac_catalyst_enabled => false,
:ccache_enabled => ccache_enabled?(podfile_properties),
)
# 生成并随归档产物携带 dSYM用于崩溃符号化与 Upload Symbols Failed 修复)
installer.pods_project.targets.each do |target|
target.build_configurations.each do |build_config|
build_config.build_settings['DEBUG_INFORMATION_FORMAT'] = 'dwarf-with-dsym'
build_config.build_settings['DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT'] = 'YES'
end
end
end
end

View File

@@ -2397,6 +2397,6 @@ SPEC CHECKSUMS:
RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
PODFILE CHECKSUM: dfe3cc75dee014a0abd367bc9e1bdbab0ba64ee3
PODFILE CHECKSUM: 4d5c52f9fa870c1d398cf59e37c149f66700c061
COCOAPODS: 1.16.2

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -11,7 +11,7 @@
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 */; };
@@ -50,7 +50,7 @@
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; };
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 = "<group>"; };
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
@@ -66,7 +66,7 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = {
EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
EmotionWidget.swift,
@@ -77,18 +77,7 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = "情绪小组件";
sourceTree = "<group>";
};
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -200,7 +189,7 @@
EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */,
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
@@ -455,7 +444,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */,
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -475,9 +464,11 @@
baseConfigurationReference = FFF632A94C7A551AAA096858 /* Pods-client.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = WS92GPX9H2;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
@@ -489,14 +480,14 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client;
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
PRODUCT_NAME = client;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
@@ -505,7 +496,7 @@
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
@@ -515,23 +506,27 @@
baseConfigurationReference = 3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = WS92GPX9H2;
DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT = YES;
INFOPLIST_FILE = client/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client;
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
PRODUCT_NAME = client;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
@@ -539,7 +534,7 @@
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OBJC_BRIDGING_HEADER = "client/client-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
@@ -676,8 +671,9 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = WS92GPX9H2;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -695,7 +691,7 @@
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client.emotionwidget;
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness.emotionwidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -709,7 +705,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
@@ -728,8 +724,9 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = WS92GPX9H2;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -746,7 +743,7 @@
MARKETING_VERSION = 1.0.0;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.client.emotionwidget;
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness.emotionwidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -759,7 +756,7 @@
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -28,12 +28,12 @@
<key>CFBundleURLSchemes</key>
<array>
<string>client</string>
<string>com.anonymous.client</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
@@ -52,7 +52,7 @@
<key>RCTNewArchEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<string></string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>

View File

@@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<string>production</string>
</dict>
</plist>

View File

@@ -1,54 +1,28 @@
import WidgetKit
import SwiftUI
// V2 + Small/Medium/Large + Home
// V1Small/Medium/Large + Home
struct EmotionProvider: TimelineProvider {
private let quotes = [
"你已经很努力了,今天也值得被温柔对待。",
"轻轻呼吸,感受当下的每一刻。",
"所有的压力,都会在深呼吸中慢慢消散。",
"给生活一点留白,给自己一点温柔。",
"不要走得太快,等一等落下的灵魂。",
"世界虽嘈杂,但你可以拥有一颗宁静的心。",
"每一个瞬间,都是生命最好的安排。",
"抱抱自己,辛苦了,亲爱的。",
"慢一点也没关系,只要你在前行。",
"今天,你对自己微笑了吗?",
"愿你历经山河,仍觉得人间值得。",
"心简单,世界就简单;心平顺,生活就平顺。",
"即使生活偶尔晦暗,你也要成为自己的光。",
"别让琐事挤走生活的快乐,别让压力消磨奋斗的激情。"
]
func placeholder(in context: Context) -> EmotionEntry {
EmotionEntry(date: Date(), text: quotes[0])
EmotionEntry(date: Date())
}
func getSnapshot(in context: Context, completion: @escaping (EmotionEntry) -> ()) {
let entry = EmotionEntry(date: Date(), text: quotes.randomElement() ?? quotes[0])
completion(entry)
completion(EmotionEntry(date: Date()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) {
var entries: [EmotionEntry] = []
let currentDate = Date()
// 24 6 4
for hourOffset in 0..<6 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset * 4, to: currentDate)!
let entry = EmotionEntry(date: entryDate, text: quotes.randomElement() ?? quotes[0])
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
// V1
let entry = EmotionEntry(date: Date())
let nextUpdate = Calendar.current.date(byAdding: .day, value: 7, to: Date())
?? Date().addingTimeInterval(60 * 60 * 24 * 7)
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
}
}
struct EmotionEntry: TimelineEntry {
let date: Date
let text: String
}
struct EmotionWidgetView: View {
@@ -56,37 +30,160 @@ struct EmotionWidgetView: View {
@Environment(\.widgetFamily) var family
private let title = "正念"
private let text = "你已经很努力了,今天也值得被温柔对待。"
private let deepLink = URL(string: "client:///(app)/home")
// #F7D9BF
private let backgroundColor = Color(red: 247/255, green: 217/255, blue: 191/255)
//
private let textColor = Color(red: 74/255, green: 52/255, blue: 40/255)
var body: some View {
VStack(alignment: .center, spacing: 0) {
Spacer(minLength: 0)
switch family {
case .systemSmall:
smallView()
case .systemMedium:
mediumView()
case .systemLarge:
largeView()
default:
smallView()
}
}
Text(entry.text)
.font(.system(size: family == .systemSmall ? 17 : 20, weight: .medium))
.foregroundColor(textColor)
.lineSpacing(6)
.multilineTextAlignment(.center)
.minimumScaleFactor(0.7)
.fixedSize(horizontal: false, vertical: true)
// iOS 15
private func cardBackground(colors: [Color]) -> some View {
ZStack {
LinearGradient(
colors: colors,
startPoint: .topLeading,
endPoint: .bottomTrailing
)
//
RadialGradient(
gradient: Gradient(colors: [Color.white.opacity(0.16), Color.white.opacity(0.0)]),
center: .topTrailing,
startRadius: 10,
endRadius: 180
)
}
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(Color.white.opacity(0.14), lineWidth: 1)
)
.cornerRadius(18)
}
private func chip(_ text: String) -> some View {
Text(text)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.9))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.white.opacity(0.14))
.cornerRadius(999)
}
private func smallView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.13, green: 0.16, blue: 0.22),
])
VStack(alignment: .leading, spacing: 10) {
HStack {
chip(title)
Spacer(minLength: 0)
}
Text(text)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(2)
.lineLimit(4)
Spacer(minLength: 0)
if family != .systemSmall {
Text("Hey Mama")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(textColor.opacity(0.3))
.padding(.bottom, 4)
Text("点我回到 App")
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color.white.opacity(0.65))
}
.padding(14)
}
.widgetURL(deepLink)
}
private func mediumView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.09, green: 0.11, blue: 0.17),
])
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: 10) {
chip(title)
Text(text)
.font(.system(size: 17, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(3)
.lineLimit(5)
Spacer(minLength: 0)
Text("轻轻呼吸,回到当下")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.white.opacity(0.7))
}
//
VStack(alignment: .trailing, spacing: 8) {
Text(entry.date, style: .time)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.8))
Spacer(minLength: 0)
Text("今日")
.font(.system(size: 28, weight: .bold))
.foregroundColor(Color.white.opacity(0.12))
}
}
.padding(family == .systemSmall ? 16 : 24)
.frame(maxWidth: .infinity, maxHeight: .infinity) //
.background(backgroundColor) //
.padding(16)
}
.widgetURL(deepLink)
}
private func largeView() -> some View {
ZStack {
cardBackground(colors: [
Color(red: 0.06, green: 0.08, blue: 0.12),
Color(red: 0.14, green: 0.18, blue: 0.28),
])
VStack(alignment: .leading, spacing: 14) {
HStack {
chip(title)
Spacer(minLength: 0)
Text(entry.date, style: .time)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.white.opacity(0.78))
}
Text(text)
.font(.system(size: 20, weight: .semibold))
.foregroundColor(Color.white.opacity(0.92))
.lineSpacing(4)
.lineLimit(8)
Spacer(minLength: 0)
HStack {
Text("点我回到 Home")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.white.opacity(0.7))
Spacer(minLength: 0)
Text("🌿")
.font(.system(size: 18))
.opacity(0.9)
}
}
.padding(18)
}
.widgetURL(deepLink)
}
}
@@ -97,13 +194,7 @@ struct EmotionWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in
if #available(iOS 17.0, *) {
EmotionWidgetView(entry: entry)
.containerBackground(Color(red: 247/255, green: 217/255, blue: 191/255), for: .widget)
} else {
EmotionWidgetView(entry: entry)
.background(Color(red: 247/255, green: 217/255, blue: 191/255))
}
}
.configurationDisplayName("情绪小组件")
.description("一段温柔提醒,陪你回到当下。")

View File

@@ -105,6 +105,7 @@ export async function setReaction(contentId: string, reaction: Reaction): Promis
}
export type FavoriteItem = {
favId: string; // 唯一标识,支持重复点赞同一文案
id: string;
date: string;
themeMode: ThemeMode;
@@ -117,15 +118,15 @@ export async function getFavorites(): Promise<FavoriteItem[]> {
export async function addFavorite(item: FavoriteItem): Promise<void> {
const list = await getFavorites();
if (list.some(i => i.id === item.id)) return;
// 允许重复点赞,不再根据 id 去重
const newList = [item, ...list];
console.log('Adding to favorites, new list size:', newList.length);
await setJson(KEY_FAVORITES_ITEMS, newList);
}
export async function removeFavorite(contentId: string): Promise<void> {
export async function removeFavorite(favId: string): Promise<void> {
const list = await getFavorites();
const next = list.filter(item => item.id !== contentId);
const next = list.filter(item => item.favId !== favId);
await setJson(KEY_FAVORITES_ITEMS, next);
}

View File

@@ -0,0 +1,89 @@
#!/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] [--stdout]
node scripts/ssh-key-to-b64.mjs - [--clipboard] [--stdout]
示例:
node scripts/ssh-key-to-b64.mjs ~/.ssh/deploy_key
node scripts/ssh-key-to-b64.mjs - --clipboard
node scripts/ssh-key-to-b64.mjs ~/.ssh/deploy_key --clipboard --stdout
`);
}
const args = process.argv.slice(2);
const clipboard = args.includes('--clipboard') || args.includes('-c');
const forceStdout = args.includes('--stdout');
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);
}
// 重要:按“原始字节”做 base64避免任何编码/换行处理导致私钥内容变化
// 这样工作流解码后能 100% 还原原文件内容
const asText = buf.toString('utf8');
if (!asText.includes('BEGIN') || !asText.includes('PRIVATE KEY')) {
console.error('提示:输入内容看起来不像 SSH 私钥(未检测到 PRIVATE KEY 头部)。仍会继续转换,但请确认输入正确。');
}
const b64 = buf.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。');
}
}
}
// 默认:如果使用了 --clipboard就不再把超长 base64 打到终端(避免影响后续命令输出)。
// 如需同时打印,请加 --stdout。
if (!clipboard || forceStdout || process.platform !== 'darwin') {
process.stdout.write(b64 + '\n');
}

20
server/.dockerignore Normal file
View File

@@ -0,0 +1,20 @@
.venv
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.mypy_cache/
.ruff_cache/
# 本地/测试数据
.test.db
*.db
# 测试与开发脚本(按需移除)
tests/
.env*
# Git 元数据
.git/
.gitignore

32
server/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM python:3.11-slim
# 运行时基础环境
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# 系统依赖(按需扩展;多数依赖为纯 Python
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential curl \
&& rm -rf /var/lib/apt/lists/*
# 先复制依赖清单以利用 Docker layer cache
COPY requirements.txt /app/requirements.txt
RUN python -m pip install -U pip \
&& pip install --no-cache-dir -r /app/requirements.txt
# 复制后端代码与迁移配置
COPY app /app/app
COPY alembic /app/alembic
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"]

View File

@@ -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` 中配置数据库连接(或从环境变量读取)。

View File

@@ -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)
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

View File

@@ -9,7 +9,7 @@ def create_app() -> FastAPI:
"""
创建 FastAPI 应用实例。
说明:目前仅提供最小可运行骨架(health check),后续逐步加入路由与中间件。
说明:目前仅提供最小可运行骨架(健康检查),后续逐步加入路由与中间件。
"""
settings = get_settings()
@@ -20,6 +20,13 @@ def create_app() -> FastAPI:
app.include_router(user_profile_router)
app.include_router(reco_router)
@app.get("/health")
async def health() -> dict:
"""
部署健康检查(兼容常见探针路径)。
"""
return {"status": "ok", "env": settings.app_env}
@app.get("/healthz")
async def healthz() -> dict:
return {"status": "ok", "env": settings.app_env}

View File

@@ -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

Some files were not shown because too many files have changed in this diff Show More