自动登录(此前只能人工跑 scripts/login.py 再 /api/auth/reload):
- 新增 POST /api/auth/login:动作顺序与下单前的 require_logged_in 一致(探测 →
未登录则按 account.yaml 登一次 → 再探测),已登录直接跳过不白起浏览器。
刻意**不抛 5001**:失败以 logged_in=false + 各站 detail 正常返回,调用方自己
决定是人工接管还是换账号。
- 新增 RAKUTEN_AUTO_LOGIN_ON_START(默认 false):启动即准备登录态,为容器部署
而存在(镜像里没有落盘的 storage_state)。做成后台任务而非启动阻塞——登录最长
等 relogin_timeout_seconds(默认 300s,撞验证码时在等人工),阻塞会让 /health
在这段时间里连端口都不通;关服务时 cancel 掉在途的那次。
- 自动登录不绕过站点校验:凭据是用户自己配在 account.yaml 里的,代填进站点自己的
登录表单,撞 reCAPTCHA / 设备验证会停在有头浏览器等人工,等不到就超时失败。
容器化部署(新增 Dockerfile.trading + docker-compose.yml):
- 有状态端单独出镜像不是为了整洁:下单/结算必须用**有头** Chromium(headless 会让
结算 SPA 失灵),镜像要带 Xvfb + 日文字体 + 给人工接管用的可选 x11vnc,抓取镜像
没有这些。网关复用同一镜像只换 command。
- Jenkinsfile 一条流水线产出两个镜像,BUILD_SCRAPING / BUILD_TRADING 两个开关控制。
- .dockerignore 补上 account.yaml / .auth/ / .browser-data/ / data/:明文密码+卡号、
可直接冒充账号的 cookie、带登录态的浏览器 profile、含真实 PII 的证据快照,都不该
进镜像也不该进 build context,运行时一律走挂载。
- .env.example 里 RAKUTEN_AUTO_LOGIN_ON_START 刻意留成注释:compose 的变量插值与
env_file 读的是同一个 ./.env,这里写成显式值会让 compose 的 `${...:-true}` 失效,
按 compose 文件头「cp .env.example .env」走反而不会自动登录。
openapi 导出(scripts/export_openapi.py):三服务合并成一份可直接导入 Apifox /
Postman 的文档,每条接口带 operation 级 servers(不必手动切端口)。鉴权标注是遍历
FastAPI 依赖树认出真的挂了 require_bearer_token 的接口,不按路径猜。
openapi.json 本身仍是 gitignore 的本地生成物,因此 tests/test_openapi_export.py
只在内存里校验合并逻辑(三服务覆盖、operation 级 servers、除 /health 外全部标鉴权、
operationId 唯一、$ref 可解析),不断言「文件内容 == 当前导出结果」——CI 的全新
clone 里没有这个文件,那种断言必然失败。代价是「改了接口忘了重新导出」没有自动
兜底,得手动跑 --check,已在 README 里点明。
398 测试全绿;另单独验证过缺 openapi.json 时该文件 5 个用例仍通过(CI 场景)。
compose 的变量插值行为只按文档核对,本机没有 docker 未能实测。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
177 lines
8.4 KiB
Docker
177 lines
8.4 KiB
Docker
# syntax=docker/dockerfile:1.7
|
|
#
|
|
# 有状态端镜像:交易服务(下单购买 + 订单查询/监控),默认 python -m app.trading.main,:31108。
|
|
#
|
|
# 为什么不复用根目录 Dockerfile(抓取服务镜像):
|
|
# 1. 下单/结算/加购必须用**有头** Chromium——site_interact.py 里 headless 是硬编码
|
|
# False(2026-08-14 实测:无头会让购物车/结算 SPA 失灵)。抓取镜像里没有任何 X
|
|
# 显示,交易进程一 launch 就崩。这里内置 Xvfb 虚拟显示解决。
|
|
# 2. 这条链路上有设计好的人工接管点(登录验证码、3DS/OTP、needs_human),远程主机上
|
|
# 必须能真的看见那个浏览器窗口 → 内置可选 x11vnc(默认关闭)。
|
|
# 3. 页面是日文,证据截图要有日文字形 → fonts-ipafont(抓取侧只取 HTML,不需要)。
|
|
#
|
|
# 同一镜像也能跑下单任务网关(python -m app.gateway.main,:31109):网关不碰浏览器,
|
|
# 用 RAKUTEN_XVFB_ENABLED=false 关掉虚拟显示即可。
|
|
#
|
|
# 交易服务与网关都**只能单实例**:登录态 cookie 全局唯一、订单监控是常驻轮询、
|
|
# SQLite 单连接。不要 --scale,也不要在前面挂多副本负载均衡。
|
|
|
|
# ----------------------------- builder -----------------------------
|
|
FROM registry.jerryyan.top/library/python:3.13-slim AS builder
|
|
|
|
ENV PYTHONUNBUFFERED=1 \
|
|
PYTHONDONTWRITEBYTECODE=1 \
|
|
UV_LINK_MODE=copy \
|
|
UV_COMPILE_BYTECODE=1 \
|
|
# 国内 PyPI 镜像:避免从官方 PyPI 拉包缓慢
|
|
UV_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/
|
|
|
|
# 与根 Dockerfile 相同来源:python3.13-* 标签的 uv 在 /usr/local/bin/
|
|
COPY --from=registry-ghcr.jerryyan.top/astral-sh/uv:python3.13-bookworm-slim /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
|
|
|
|
WORKDIR /app
|
|
|
|
# 只装运行期依赖:playwright / aiosqlite 都在 [project].dependencies 主表里,
|
|
# 交易服务不需要 --extra browser(那是抓取侧的可选兜底)。
|
|
COPY uv.lock pyproject.toml ./
|
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
uv sync --frozen --no-dev
|
|
|
|
COPY app ./app
|
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
uv sync --frozen --no-dev
|
|
|
|
# ----------------------------- runtime -----------------------------
|
|
FROM registry.jerryyan.top/library/python:3.13-slim AS runtime
|
|
|
|
ENV PYTHONUNBUFFERED=1 \
|
|
PYTHONDONTWRITEBYTECODE=1 \
|
|
VIRTUAL_ENV=/app/.venv \
|
|
PATH="/app/.venv/bin:${PATH}" \
|
|
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
|
PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright \
|
|
# 落库时间戳全是 UTC aware(local_db/task_queue 用 datetime.now(timezone.utc)),
|
|
# 这里设日本时区只影响日志与调试快照文件名的可读性,不改变任何持久化语义。
|
|
TZ=Asia/Tokyo \
|
|
# 虚拟显示:Chromium 有头模式必须有 DISPLAY。跑网关时置 false 可完全跳过 Xvfb。
|
|
DISPLAY=:99 \
|
|
RAKUTEN_XVFB_ENABLED=true \
|
|
RAKUTEN_XVFB_SCREEN=1280x1024x24 \
|
|
# 远程接管(验证码 / 3DS / needs_human 时肉眼看浏览器):默认关闭。
|
|
# 开启前务必设 RAKUTEN_VNC_PASSWORD,且端口只对内网/SSH 隧道开放——
|
|
# 这个屏幕上是已登录的真实账号与结算页。
|
|
RAKUTEN_VNC_ENABLED=false \
|
|
RAKUTEN_VNC_PORT=5900 \
|
|
RAKUTEN_TRADING_HOST=0.0.0.0 \
|
|
RAKUTEN_TRADING_PORT=31108 \
|
|
# 与根镜像保持一致:默认导出 traces 到自建 OTLP(裸跑无鉴权),
|
|
# 服务名由 app/trading/main.py 显式设为 rakuten-trading。
|
|
RAKUTEN_OTEL_ENABLED=true \
|
|
RAKUTEN_OTEL_ENDPOINT=https://oltp.jerryyan.top/v1/traces
|
|
|
|
# 有头 Chromium 运行依赖 + Xvfb + 日文字体。
|
|
# 显式列包而不用 `playwright install-deps`(后者装的是一整套 apt 源里的当前版本,
|
|
# 构建结果随源漂移)。Debian t64 过渡把一批库改名(libasound2 → libasound2t64 等),
|
|
# 所以先整表装一次,失败再逐包回退试 ${pkg}t64,让镜像在 bookworm/trixie 基础镜像上都能建起来。
|
|
RUN set -eu; \
|
|
{ \
|
|
if [ -f /etc/apt/sources.list ]; then \
|
|
sed -i 's|deb.debian.org|mirrors.aliyun.com|g; s|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list; \
|
|
fi; \
|
|
if [ -f /etc/apt/sources.list.d/debian.sources ]; then \
|
|
sed -i 's|deb.debian.org|mirrors.aliyun.com|g; s|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources; \
|
|
fi; \
|
|
}; \
|
|
PKGS="ca-certificates curl tzdata \
|
|
xvfb x11vnc \
|
|
fonts-liberation fonts-ipafont-gothic fonts-ipafont-mincho \
|
|
libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 \
|
|
libcairo2 libcairo-gobject2 libcups2 libdbus-1-3 libdrm2 libgbm1 \
|
|
libgdk-pixbuf-2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 \
|
|
libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 \
|
|
libxkbcommon0 libxrandr2 libxshmfence1 xdg-utils"; \
|
|
apt-get update; \
|
|
if ! apt-get install -y --no-install-recommends $PKGS; then \
|
|
for pkg in $PKGS; do \
|
|
apt-get install -y --no-install-recommends "$pkg" \
|
|
|| apt-get install -y --no-install-recommends "${pkg}t64"; \
|
|
done; \
|
|
fi; \
|
|
rm -rf /var/lib/apt/lists/*
|
|
|
|
# 非 root 账号(uid/gid 与根镜像一致,方便共用宿主机上的 .auth / data 目录属主)
|
|
RUN groupadd --system --gid 10001 rakuten \
|
|
&& useradd --system --uid 10001 --gid rakuten --no-create-home --shell /usr/sbin/nologin rakuten
|
|
|
|
WORKDIR /app
|
|
|
|
COPY --from=builder /app/.venv /app/.venv
|
|
COPY --from=builder /app/app /app/app
|
|
# 只带 login.py:容器里首次产出登录态要用它(配合 VNC 人工过验证码),
|
|
# scripts/ 下其余都是探针脚本,属开发工具,不进镜像。
|
|
COPY scripts/login.py /app/scripts/login.py
|
|
|
|
# 入口:拉起 Xvfb(可选 x11vnc)后 exec 真正的进程,保证信号直达 PID 1。
|
|
COPY --chmod=0755 <<'ENTRYPOINT_SH' /usr/local/bin/rakuten-entrypoint.sh
|
|
#!/bin/sh
|
|
set -e
|
|
|
|
if [ "${RAKUTEN_XVFB_ENABLED:-true}" = "true" ]; then
|
|
screen_num="${DISPLAY#:}"
|
|
socket="/tmp/.X11-unix/X${screen_num%%.*}"
|
|
Xvfb "$DISPLAY" -screen 0 "${RAKUTEN_XVFB_SCREEN:-1280x1024x24}" -nolisten tcp &
|
|
|
|
# 等 X socket 就绪再放行;否则 Chromium 会以 "Missing X server" 直接失败
|
|
waited=0
|
|
while [ ! -e "$socket" ]; do
|
|
waited=$((waited + 1))
|
|
if [ "$waited" -ge 100 ]; then
|
|
echo "Xvfb 10 秒内未就绪($socket 不存在),放弃启动" >&2
|
|
exit 1
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
echo "Xvfb 已就绪:DISPLAY=$DISPLAY screen=${RAKUTEN_XVFB_SCREEN:-1280x1024x24}"
|
|
|
|
if [ "${RAKUTEN_VNC_ENABLED:-false}" = "true" ]; then
|
|
# 有密码就用密码,没有则明文无鉴权——后者只允许在 SSH 隧道/内网里用
|
|
if [ -n "${RAKUTEN_VNC_PASSWORD:-}" ]; then
|
|
mkdir -p /tmp/.vnc
|
|
x11vnc -storepasswd "$RAKUTEN_VNC_PASSWORD" /tmp/.vnc/passwd >/dev/null 2>&1
|
|
auth_args="-rfbauth /tmp/.vnc/passwd"
|
|
else
|
|
echo "警告:RAKUTEN_VNC_ENABLED=true 但未设 RAKUTEN_VNC_PASSWORD,VNC 无鉴权" >&2
|
|
auth_args="-nopw"
|
|
fi
|
|
# shellcheck disable=SC2086
|
|
x11vnc -display "$DISPLAY" -forever -shared -bg -quiet \
|
|
-rfbport "${RAKUTEN_VNC_PORT:-5900}" $auth_args
|
|
echo "x11vnc 已启动:端口 ${RAKUTEN_VNC_PORT:-5900}(屏幕上是真实已登录账号,勿暴露公网)"
|
|
fi
|
|
fi
|
|
|
|
exec "$@"
|
|
ENTRYPOINT_SH
|
|
|
|
# 可写目录:logs(Loguru)、data(订单 SQLite + 证据)、.auth(登录态 cookie)、
|
|
# .browser-data(login.py 的持久化浏览器目录)、/ms-playwright(Chromium 二进制)、
|
|
# /tmp/.X11-unix(Xvfb socket,非 root 也要能建)
|
|
RUN mkdir -p /app/logs /app/data /app/.auth /app/.browser-data /ms-playwright /tmp/.X11-unix \
|
|
&& chmod 1777 /tmp/.X11-unix \
|
|
&& chown -R rakuten:rakuten /app /ms-playwright
|
|
|
|
USER rakuten
|
|
|
|
RUN /app/.venv/bin/playwright install chromium
|
|
|
|
# /health 只读缓存登录态,不触发站点请求,适合高频探活。
|
|
# 端口取 RAKUTEN_HEALTH_PORT,未设时用交易端口;跑网关的容器把它设成 31109。
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
|
|
CMD curl -fsS http://127.0.0.1:${RAKUTEN_HEALTH_PORT:-${RAKUTEN_TRADING_PORT}}/health || exit 1
|
|
|
|
# 31108 交易服务,31109 下单任务网关(同镜像换 command),5900 可选 VNC
|
|
EXPOSE 31108 31109 5900
|
|
|
|
ENTRYPOINT ["/usr/local/bin/rakuten-entrypoint.sh"]
|
|
CMD ["python", "-m", "app.trading.main"]
|