diff --git a/.env b/.env index 103dbfa..1d10a27 100644 --- a/.env +++ b/.env @@ -10,3 +10,5 @@ AI_MODEL=deepseek-chat AI_TEMPERATURE=0.2 PORT=8770 +PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple +BASE_IMAGE=docker.1ms.run/library/python:3.12-slim diff --git a/.env.example b/.env.example index b64918d..28ad250 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,11 @@ SESSION_DAYS=30 HOST_PORT=8770 # 周期/周次/入库时间等日期按此时区计算,别留 UTC,否则整体差 8 小时 TZ=Asia/Shanghai + +# ---- 国内网络加速(构建镜像用,deploy.sh 首次 up 时自动探测并写回这里)---- +# 国内直连 docker.io / pypi.org 基本超时,deploy.sh 会先探一个能真拉下镜像的加速站。 +# 手工钉死(推荐内网环境写死,省掉每次探测): +# BASE_IMAGE=docker.1ms.run/library/python:3.12-slim +# PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple +# 关掉自动探测(源已在上面配好,或机器能直连官方源):CN_MIRROR=0 ./deploy.sh up +# 跳过构建、直接用本地已有镜像(离线机器上 docker load 完之后用):NO_BUILD=1 ./deploy.sh up diff --git a/Dockerfile b/Dockerfile index 0e358a5..4f27c35 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,12 @@ # # 镜像里只有依赖和代码,数据库/附件都在 data/,靠 bind mount 挂进来。 # data/ 已被 .dockerignore 排除,不会把本机 board.db 打进镜像。 -FROM python:3.12-slim +# +# 基础镜像。国内直连 docker.io 会在 load metadata 阶段 TLS 超时,所以支持换仓库前缀: +# docker build --build-arg BASE_IMAGE=docker.1ms.run/library/python:3.12-slim . +# ./deploy.sh up 会自动探测可用加速站并把结果写进 .env,正常情况下不用手工传。 +ARG BASE_IMAGE=python:3.12-slim +FROM ${BASE_IMAGE} ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ @@ -20,9 +25,11 @@ WORKDIR /app # 依赖单独一层:改业务代码不触发重装依赖 COPY requirements.txt ./ -# 国内网络直连 PyPI 容易超时,构建时换源即可: -# PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple ./deploy.sh up -ARG PIP_INDEX_URL=https://pypi.org/simple +# pip 源:默认清华源(国内直连 pypi.org 基本超时,而且换基础镜像/代理都救不了它, +# 构建容器里的请求只能靠这里换源)。境外机器或内网自建源就覆盖: +# docker build --build-arg PIP_INDEX_URL=https://pypi.org/simple . +# ./deploy.sh up 会自动探测可用源并写进 .env,正常不用手工传。 +ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple RUN pip install --retries 5 --timeout 60 -i "$PIP_INDEX_URL" -r requirements.txt # 用 MySQL 部署时放开下面这行(与 requirements.txt 的注释一致) # RUN pip install "pymysql>=1.1" "cryptography>=42.0" diff --git a/README.md b/README.md index 12d21d7..e847dd1 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ cd /path/to/unis_manager |---|---| | `Dockerfile` | 运行镜像:装依赖 + 拷 `app/ web/ scripts/`;启动时先跑 `init_db.py`(幂等)再起 uvicorn | | `docker-compose.yml` | 编排:`./data` 挂进容器存数据库、`HOST_PORT` 控对外端口、`restart: unless-stopped` 随 Docker 自启 | -| `deploy.sh` | 命令封装:`up` / `update` / `restart` / `down` / `logs` / `status`,顺带检查 docker 是否在跑、`.env` 与 `data/` 是否存在 | +| `deploy.sh` | 命令封装:`up` / `update` / `restart` / `down` / `logs` / `status` / `backup` / `mirror`,顺带检查 docker 是否在跑、`.env` 与 `data/` 是否存在,并在构建前探测国内可用源 | | `.dockerignore` | 保证 `data/`、`.env`、`.git` 不会被烤进镜像 | | 场景 | 命令 | @@ -122,9 +122,42 @@ cd /path/to/unis_manager | 停止(数据保留) | `./deploy.sh down` | | 换对外端口 | `.env` 里 `HOST_PORT=8080`,再 `./deploy.sh up` | | 裸 compose 命令 | `docker compose up -d --build` / `docker compose down` | -| 构建时 pip 下载超时 | `PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple ./deploy.sh up`(也可写进 `.env`) | +| 构建时基础镜像 / pip 下载超时 | `./deploy.sh mirror` 重探,或手工指定 `PIP_INDEX_URL=... BASE_IMAGE=... ./deploy.sh up`(见下「国内网络加速」) | | 把现有数据带到新机器 | `./deploy.sh backup` → 拷到目标机 `data/board.db` → `./deploy.sh up` | +#### 国内网络加速(docker.io / pypi.org 连不上时) + +国内构建会撞两道**互不相干**的墙,得分开治,只治一道必然还剩一道: + +| 卡在哪 | 典型报错 | 治法 | +|---|---|---| +| ① 守护进程拉基础镜像 | `failed to resolve source metadata for docker.io/library/python:3.12-slim: ... TLS handshake timeout` | 换仓库前缀 `BASE_IMAGE`(走加速站,或给守护进程配 registry-mirrors) | +| ② 构建容器里装依赖 | `[4/8] RUN pip install ... -i https://pypi.org/simple` 非零退出 | 换 pip 源 `PIP_INDEX_URL`(清华 / 阿里 / 腾讯) | + +`./deploy.sh up` 在构建前会自动把两道源都探出来(判据是真的 `docker pull`、是在容器里 `urlopen`,不信宿主机 curl,也不信 `docker manifest inspect` 的假 `denied`),结果写进 `.env`,下次以及裸跑 compose 都复用: + +```bash +./deploy.sh up # 自动探测 → 写 .env → 构建 → 等健康检查 +./deploy.sh mirror # 只想重探一遍源(不会冲掉 .env 里手工钉死的 BASE_IMAGE) +``` + +不想用脚本,等价的两条 build-arg: + +```bash +docker compose build \ + --build-arg BASE_IMAGE=docker.1ms.run/library/python:3.12-slim \ + --build-arg PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple +docker compose up -d +``` + +几个必须知道的点: + +- **基础镜像拉成功 ≠ 构建能过**:`docker pull` 只治①,容器里的 `pip install` 照旧撞 pypi.org,必须另外给②换源。 +- **shell 里的 `HTTP(S)_PROXY` 对构建没用**:Docker 守护进程和构建容器都不读宿主机的代理变量。要走代理得在 Docker Desktop → Settings → Resources → Proxies 里配 `http://host.docker.internal:7897`(端口换成你本机代理的)。 +- **境外机器 / 内网有自建源**:`CN_MIRROR=0 ./deploy.sh up` 关掉探测,源完全由 `.env` 说了算。 +- **目标机完全不能上网**:走离线路。`docker save -o unis-board.tar unis-board:latest` → 拷过去 `docker load -i unis-board.tar` → `NO_BUILD=1 ./deploy.sh up`(跳过构建直接用本地镜像)。 +- 构建失败时脚本会自己打印一份排查清单(`deploy.sh` 的 `build_hint`),照抄命令即可。 + 几个坑: - **数据只落在 `./data`**:重建镜像、`down` 再 `up` 都不动数据;备份依旧是拷走 `data/board.db`。 diff --git a/app/__pycache__/ai_parser.cpython-313.pyc b/app/__pycache__/ai_parser.cpython-313.pyc index e8d4e14..8c30824 100644 Binary files a/app/__pycache__/ai_parser.cpython-313.pyc and b/app/__pycache__/ai_parser.cpython-313.pyc differ diff --git a/app/__pycache__/constants.cpython-313.pyc b/app/__pycache__/constants.cpython-313.pyc index 01bba38..7d37c08 100644 Binary files a/app/__pycache__/constants.cpython-313.pyc and b/app/__pycache__/constants.cpython-313.pyc differ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index 7602ede..8c1b9e9 100644 Binary files a/app/__pycache__/main.cpython-313.pyc and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/__pycache__/models.cpython-313.pyc b/app/__pycache__/models.cpython-313.pyc index 8a190d3..f42c98b 100644 Binary files a/app/__pycache__/models.cpython-313.pyc and b/app/__pycache__/models.cpython-313.pyc differ diff --git a/app/__pycache__/schema_patch.cpython-313.pyc b/app/__pycache__/schema_patch.cpython-313.pyc index 044a280..e697d73 100644 Binary files a/app/__pycache__/schema_patch.cpython-313.pyc and b/app/__pycache__/schema_patch.cpython-313.pyc differ diff --git a/app/routers/__pycache__/board.cpython-313.pyc b/app/routers/__pycache__/board.cpython-313.pyc index c42d560..3e1626c 100644 Binary files a/app/routers/__pycache__/board.cpython-313.pyc and b/app/routers/__pycache__/board.cpython-313.pyc differ diff --git a/app/routers/__pycache__/custom.cpython-313.pyc b/app/routers/__pycache__/custom.cpython-313.pyc index 6abc1f6..b156750 100644 Binary files a/app/routers/__pycache__/custom.cpython-313.pyc and b/app/routers/__pycache__/custom.cpython-313.pyc differ diff --git a/app/routers/__pycache__/export.cpython-313.pyc b/app/routers/__pycache__/export.cpython-313.pyc index 6e1fb64..257e635 100644 Binary files a/app/routers/__pycache__/export.cpython-313.pyc and b/app/routers/__pycache__/export.cpython-313.pyc differ diff --git a/app/routers/__pycache__/focus.cpython-313.pyc b/app/routers/__pycache__/focus.cpython-313.pyc index 552229c..a620997 100644 Binary files a/app/routers/__pycache__/focus.cpython-313.pyc and b/app/routers/__pycache__/focus.cpython-313.pyc differ diff --git a/app/routers/__pycache__/staff.cpython-313.pyc b/app/routers/__pycache__/staff.cpython-313.pyc index 214587a..0773610 100644 Binary files a/app/routers/__pycache__/staff.cpython-313.pyc and b/app/routers/__pycache__/staff.cpython-313.pyc differ diff --git a/app/routers/__pycache__/weekly.cpython-313.pyc b/app/routers/__pycache__/weekly.cpython-313.pyc index dd7adc6..7eba175 100644 Binary files a/app/routers/__pycache__/weekly.cpython-313.pyc and b/app/routers/__pycache__/weekly.cpython-313.pyc differ diff --git a/deploy.sh b/deploy.sh index e2cca0a..11b3ee3 100755 --- a/deploy.sh +++ b/deploy.sh @@ -7,6 +7,8 @@ # ./deploy.sh logs 跟踪日志(Ctrl+C 退出) # ./deploy.sh backup 导出数据库快照(部署到新机器时带走数据) # ./deploy.sh status 容器状态 + 健康检查 + 访问地址 +# ./deploy.sh mirror 重新探测国内加速源(基础镜像 + pip 源)并写进 .env +# 环境变量:CN_MIRROR=0 跳过加速探测;NO_BUILD=1 不构建、直接用本地镜像 set -euo pipefail cd "$(dirname "$0")" @@ -32,7 +34,135 @@ fi HOST_PORT="${HOST_PORT:-8770}" URL="http://127.0.0.1:${HOST_PORT}" -# ---------- 1. 准备 .env 与 data 目录 ---------- +# ---------- 0.5 通用小工具(供后面读写 .env、探测、加超时用)---------- +env_get() { { [ -f .env ] && sed -n "s/^$1=//p" .env | tail -1; } || true; } + +# 覆盖式写回 .env(没有这个键就追加) +env_set() { + awk -v k="$1" -v v="$2" 'BEGIN{hit=0} index($0,k"=")==1{print k"="v; hit=1; next} {print} END{if(!hit)print k"="v}' .env > .env.tmp && mv .env.tmp .env +} + +# 站点可达?能握手且不是 5xx 就算数(401/403 只是不允许匿名列表) +http_ok() { + local code + code="$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 3 --max-time "${2:-8}" "$1" 2>/dev/null || true)" + case "$code" in 000|"") return 1 ;; 5*) return 1 ;; *) return 0 ;; esac +} + +# with_timeout 秒 命令…(macOS 没有 timeout 命令,自己搭一个,防止探测卡死) +with_timeout() { + local t="$1" rc=0 pid wpid; shift + "$@" >/dev/null 2>&1 & pid=$! + ( sleep "$t"; kill "$pid" 2>/dev/null ) >/dev/null 2>&1 & wpid=$! + wait "$pid" || rc=$? + kill "$wpid" 2>/dev/null || true; wait "$wpid" 2>/dev/null || true + return "$rc" +} + +# ---------- 1. 国内加速:基础镜像 + pip 源 ---------- +# 国内直连 docker.io / pypi.org 会 TLS 超时(报错形如 failed to resolve source +# metadata for docker.io/library/python),所以构建前先把「真能拉到东西的源」探出来, +# 并写回 .env —— 之后裸跑 docker compose up -d --build 用的也是同一套配置。 + +BASE_IMAGE="${BASE_IMAGE:-$(env_get BASE_IMAGE)}" +BASE_IMAGE="${BASE_IMAGE:-python:3.12-slim}" # 想换加速站就写进 .env,例:docker.1ms.run/library/python:3.12-slim +BASE_TAG="${BASE_TAG:-${BASE_IMAGE##*/}}" # 加速站上的固定路径是 library/ +MIRROR_HOSTS="${MIRROR_HOSTS:-docker.1ms.run docker.m.daocloud.io docker.1panel.live docker.xuanyuan.me}" +PIP_URLS="${PIP_URLS:-https://pypi.tuna.tsinghua.edu.cn/simple https://mirrors.aliyun.com/pypi/simple https://mirrors.cloud.tencent.com/pypi/simple https://pypi.org/simple}" + +configure_mirror() { + local force="${1:-}" host url ref tag pip_picked=0 base_picked=0 + PIP_INDEX_URL="${PIP_INDEX_URL:-$(env_get PIP_INDEX_URL)}" + if [ "$force" = "--force" ]; then + PIP_INDEX_URL="" + # 手工钉死过的基础镜像不冲掉,只重探 pip 源 + if [ -z "$(env_get BASE_IMAGE)" ]; then BASE_IMAGE="python:3.12-slim"; fi # .env 里钉死过的基础镜像不冲掉 + fi + BASE_IMAGE="${BASE_IMAGE:-python:3.12-slim}" + + if [ "${CN_MIRROR:-1}" = "0" ] || [ "${NO_BUILD:-0}" = "1" ]; then + export BASE_IMAGE PIP_INDEX_URL + echo "==> 跳过加速探测(CN_MIRROR=0 或 NO_BUILD=1):${BASE_IMAGE:-官方 python} / ${PIP_INDEX_URL:-官方 pypi}" + return 0 + fi + + # 基础镜像的判据是「Docker 守护进程能不能拿到它」,不是宿主机 curl 能不能通: + # 宿主机 curl 走 HTTP_PROXY,守护进程和构建容器都不认 shell 的代理变量。 + tag="${BASE_IMAGE##*/}" + if docker image inspect "$BASE_IMAGE" >/dev/null 2>&1; then + echo "==> 基础镜像本地已有,不联网:$BASE_IMAGE" + elif with_timeout 25 docker pull --quiet "$BASE_IMAGE"; then + echo "==> 基础镜像可从当前配置源拉取:$BASE_IMAGE" + else + echo " $BASE_IMAGE 守护进程侧拉不到,开始找加速站" + for host in $MIRROR_HOSTS; do + ref="$host/library/$tag" + echo -n "==> 测试加速站 $host " + if with_timeout 120 docker pull --quiet "$ref"; then + BASE_IMAGE="$ref"; base_picked=1; echo " 可用"; break + else + echo " 不可用" + fi + done + if [ "$base_picked" = 0 ]; then + echo "!! 所有加速站都拉不到 $tag;可以先在服务器上手拉,再 ./deploy.sh up(本地有就不会再联网):" >&2 + echo " docker pull docker.1ms.run/library/$tag && docker tag docker.1ms.run/library/$tag $BASE_IMAGE" >&2 + fi + fi + + # pip 源:装依赖是在构建容器里发生的,容器不继承 shell 的代理, + # 所以拿刚选好的基础镜像真跑一次访问探测,比在宿主机上 curl 靠谱。 + if [ -n "${PIP_INDEX_URL:-}" ]; then + echo "==> pip 源(沿用已有配置):$PIP_INDEX_URL" + else + docker image inspect "$BASE_IMAGE" >/dev/null 2>&1 \ + || with_timeout 120 docker pull --quiet "$BASE_IMAGE" \ + || BASE_IMAGE="$(docker images --format '{{.Repository}}:{{.Tag}}' | grep -m1 '^python:3' || true)" + for url in $PIP_URLS; do + if [ -n "${BASE_IMAGE:-}" ] && with_timeout 40 docker run --rm --entrypoint python "$BASE_IMAGE" \ + -c "import sys,urllib.request;urllib.request.urlopen(sys.argv[1],timeout=8)" "$url/"; then + PIP_INDEX_URL="$url"; pip_picked=1; break + fi + done + if [ -z "${PIP_INDEX_URL:-}" ]; then + PIP_INDEX_URL="https://pypi.org/simple" + echo "!! 构建容器里访问不到任何国内 pip 源,先用官方源;超时就手工指定:" >&2 + echo " PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple ./deploy.sh up" >&2 + fi + fi + + # 探出来的结果落盘,下次(以及裸 compose 命令)直接复用 + if [ "$pip_picked" = 1 ]; then env_set PIP_INDEX_URL "$PIP_INDEX_URL"; fi + if [ "$base_picked" = 1 ]; then env_set BASE_IMAGE "$BASE_IMAGE"; fi + export BASE_IMAGE PIP_INDEX_URL + echo "==> 本次构建用:基础镜像 $BASE_IMAGE | pip 源 $PIP_INDEX_URL" +} + +# 构建失败时给一份可操作的排查清单(国内环境九成是源的问题) +build_hint() { + if [ -n "${HTTPS_PROXY:-}${HTTP_PROXY:-}${https_proxy:-}${http_proxy:-}" ]; then + echo "!! 注意:当前 shell 配了 HTTP(S)_PROXY,但 Docker 守护进程不认 shell 环境变量。" >&2 + echo " 要用代理出网,得在 Docker Desktop → Settings → Resources → Proxies 里配" >&2 + echo " http://host.docker.internal:7897(端口换成你本机代理的),否则只能走加速站。" >&2 + fi + cat >&2 <<'HINT' +!! 构建失败。国内环境最常见的两个原因: + 1) 基础镜像拉不到(failed to resolve source metadata / TLS handshake timeout) + ./deploy.sh mirror # 重跑自动探测 + BASE_IMAGE=docker.1ms.run/library/python:3.12-slim ./deploy.sh up # 手工指定加速站 + 也可以给 Docker 守护进程配 registry-mirrors(Docker Desktop → Settings → Docker Engine): + { "registry-mirrors": ["https://docker.1ms.run"] } + 保存并重启 Docker 后,把 .env 里的 BASE_IMAGE 那行删掉再 ./deploy.sh up + 2) pip 装依赖超时 + PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple ./deploy.sh up + 完全不能上网的机器:在能上网的机器上构建后拷镜像过去 + docker save -o unis-board.tar unis-board:latest && scp unis-board.tar host:/srv/unis_manager/ + ssh host 'docker load -i /srv/unis_manager/unis-board.tar' + 然后在目标机 NO_BUILD=1 ./deploy.sh up +HINT +} + +# ---------- 2. 准备 .env 与 data 目录 ---------- prepare() { if [ ! -f .env ]; then cp .env.example .env @@ -42,7 +172,7 @@ prepare() { mkdir -p data/uploads data/exports } -# ---------- 2. 等服务就绪 ---------- +# ---------- 3. 等服务就绪 ---------- wait_ready() { echo -n "==> 等待服务就绪 " for _ in $(seq 1 30); do @@ -58,13 +188,26 @@ wait_ready() { return 1 } -# ---------- 3. 分发命令 ---------- +# ---------- 4. 构建并启动(NO_BUILD=1 时直接用本地镜像)---------- +up_or_exit() { + if [ "${NO_BUILD:-0}" = "1" ]; then + echo "==> NO_BUILD=1:跳过构建,直接用本地镜像 unis-board:latest" + "${COMPOSE[@]}" up -d + elif "${COMPOSE[@]}" up -d --build; then + return 0 + else + build_hint; return 1 + fi +} + +# ---------- 5. 分发命令 ---------- case "${1:-up}" in up|start) first_run=0 [ -f data/board.db ] || first_run=1 prepare - "${COMPOSE[@]}" up -d --build + configure_mirror + up_or_exit || exit 1 wait_ready echo " API 文档:$URL/api/docs" if [ "$first_run" = "1" ]; then @@ -73,7 +216,10 @@ case "${1:-up}" in ;; update) prepare - "${COMPOSE[@]}" up -d --build --force-recreate + configure_mirror + if [ "${NO_BUILD:-0}" = "1" ]; then "${COMPOSE[@]}" up -d --force-recreate + else "${COMPOSE[@]}" up -d --build --force-recreate || { build_hint; exit 1; } + fi wait_ready ;; restart) @@ -104,6 +250,12 @@ print(f"[ok] 已导出 {sys.argv[1]}({os.path.getsize(sys.argv[1]) // 1024} KB PYEOF echo "==> 部署到新机器:把这个文件放到目标机的 data/board.db,再执行 ./deploy.sh up" ;; + mirror) + prepare + configure_mirror --force + echo "==> 已写入 .env 的配置:" + grep -E '^(BASE_IMAGE|PIP_INDEX_URL)=' .env 2>/dev/null || echo " (无:说明用的是默认源或 CN_MIRROR=0)" + ;; status) "${COMPOSE[@]}" ps echo -n "==> /health:" @@ -112,7 +264,7 @@ PYEOF echo "==> 访问地址:$URL" ;; *) - sed -n '3,9p' "$0" + sed -n '3,11p' "$0" exit 1 ;; esac diff --git a/docker-compose.yml b/docker-compose.yml index b5f3967..45f3875 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,9 +13,9 @@ services: build: context: . args: - # 传给 Dockerfile 的 pip 源,默认官方;国内网络可在 .env 里设 - # PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple - PIP_INDEX_URL: "${PIP_INDEX_URL:-https://pypi.org/simple}" + # 基础镜像仓库前缀 + pip 源,都由 deploy.sh 探测后写进 .env(也可手工在 .env 里钉死) + BASE_IMAGE: "${BASE_IMAGE:-python:3.12-slim}" + PIP_INDEX_URL: "${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}" image: unis-board:latest container_name: unis-board restart: unless-stopped