跳转至

DeepTutor 容器化

手册 · Agent 设置 · 存储

本文档介绍如何通过容器镜像部署 DeepTutor:推荐的 docker run 方式、使用只读根文件系统的加固型 rootless Podman 方式、运行时配置、可选的 PocketBase sidecar 容器,以及支撑默认安全姿态的安全说明。

PyPI / 源码安装请参阅主 README.md。本文件仅涉及运行已发布镜像。


概述

已发布的 ghcr.io/hkuds/deeptutor 镜像在 python:3.11-slim 之上,通过 supervisord 在单个容器中同时运行 FastAPI 后端(:8001)和 Next.js 前端(:3782)。容器内的私有运行时目录(/app/data)保存设置、凭据、数据库、记忆、知识库和日志。默认的 Content Workspace 也位于其中,但也可以单独挂载到 /workspace。通过绑定挂载运行时目录和任何独立的 Content Workspace,可让两者在重启后仍然保留。

该镜像支持以下三种部署形态:

  1. docker run — 简单路径。Rootful(使用 root)、可写 rootfs,在 /app/data 上单个绑定挂载。
  2. docker compose(docker-compose.yml)— 相同的镜像,外加 PocketBase sidecar 和 sandbox-runner sidecar。仍然是 rootful,可写 rootfs。
  3. podman compose -f compose.yaml — 加固路径。Rootless(userns_mode: keep-id)、只读 rootfs、用 tmpfs 替代可写的系统目录、绑定挂载 ./data。

让形态 3 得以成立的关键架构变更,是 URL 知识不再存放在前端打包产物中。具体来说:

  • 该打包产物在构建时不包含 NEXT_PUBLIC_API_BASE 占位符,也不对构建输出执行 sed -i。web/lib/api.ts 将 apiUrl 和 wsUrl 以单行直通方式导出,因此浏览器通过前端(:3782/api/...)获取相对路径。
  • web/proxy.ts 拦截 /api/* 和 /ws/* 请求,并在请求时将其改写为 DEEPTUTOR_API_BASE_URL。该环境变量由容器入口点每次启动时设置,取值来自 data/user/settings/system.json。
  • start-frontend.sh 设置 PORT/HOSTNAME,并 exec 运行 node /app/web/server.js。不会对打包产物做任何改动。
  • supervisord 以 root(PID 1)身份运行,并通过每个程序的 user= 指令将各程序(后端、前端)降权为名为 deeptutor 的非 root 用户(UID 1000),因此应用进程保持非 root 运行。在宿主机使用 userns_mode: keep-id 时,该 UID 会映射到你的宿主 UID;普通 docker run 下,它只是容器内的普通非特权用户。

完整的按安装方式指南如下。


Docker(默认)

最简单的部署方式:一个容器、一个私有数据卷、一个端口映射。

docker run --rm --name deeptutor \
  -p 127.0.0.1:3782:3782 \
  -v deeptutor-data:/app/data \
  ghcr.io/hkuds/deeptutor:latest

打开 http://127.0.0.1:3782。容器在首次启动时创建 /app/data/user/settings/*.json;请在 Web 设置页面配置模型提供商。配置、API 密钥、日志、工作区文件、记忆和知识库都会持久化保存在 deeptutor-data 命名卷中。

Unraid / NAS 绑定挂载(PUID / PGID)

Unraid 和许多 NAS 模板会以宿主机用户身份持有 appdata,而该用户通常不是 UID 1000(通常是 nobody:users,即 99:100,或某个命名共享用户)。这些宿主机还会在重启时撤销容器内执行的 chown。镜像仍会将后端/前端降权为非 root 的 deeptutor 用户;入口点会重新映射该用户的 UID/GID,使其与卷匹配:

docker run --rm --name deeptutor \
  -e PUID=99 -e PGID=100 \
  -p 127.0.0.1:3782:3782 \
  -v /mnt/user/appdata/deeptutor:/app/data \
  ghcr.io/hkuds/deeptutor:latest

如果运行时用户仍然无法写入 /app/data,入口点会退出并显示一条有关所有者/权限模式的信息,而不会启动后再报 Knowledge base not initialized (llamaindex)。Rootless Podman 会跳过该重新映射(因为没有 CAP_SETUID),并保持当前用户不变。PUID=0 / PGID=0 会被拒绝,因此应用永远不会以 root 身份运行。

选择宿主机上的 Content Workspace

应用数据挂载中包含私有设置、凭据、数据库、Memory 和其他运行时状态。请将其与 agent 可读取的 Content Workspace 分开。要暴露宿主机文件夹,可将其挂载到固定的 /workspace 路径,并将部署锁定到该路径:

mkdir -p "$PWD/deeptutor-workspace/outputs"
docker run --rm --name deeptutor \
  -p 127.0.0.1:3782:3782 \
  -v deeptutor-data:/app/data \
  -v "$PWD/deeptutor-workspace:/workspace" \
  -e DEEPTUTOR_WORKSPACE_ROOT=/workspace \
  -e DEEPTUTOR_WORKSPACE_ALLOWED_ROOTS=/workspace \
  ghcr.io/hkuds/deeptutor:latest

对于随附的 Compose 文件:

DEEPTUTOR_WORKSPACE_HOST=/absolute/host/folder \
  python scripts/docker_compose.py -f docker-compose.yml up -d

该辅助脚本会在 Compose 启动前创建宿主机文件夹及其嵌套的 outputs/ 目录,避免出现 root 拥有的绑定挂载。若省略该变量,则使用 ./data/user/workspace。容器始终看到 /workspace,因此已保存的设置和面向模型的路径可在不同宿主机间保持可移植。这是一个启动/部署时的选择,并会在 设置 → Workspace 中显示为已锁定。

使用 docker-compose.yml 时,sandbox runner 会以只读方式接收内容树,并获得用于 /workspace/outputs 的可写 overlay;它不会收到 /app/data、用户设置或凭据。因此,生成的程序只能读取所选内容,并只写入当前轮次范围内的输出。单容器和 rootless-Podman 形态会使用该容器中可用的最佳隔离后端,并在 Workspace 设置中显示实际生效的隔离级别。

注意:

  • 只需要发布 3782。 浏览器只与前端源(:3782)通信;所有 /api/* 和 /ws/* 流量都由 Next.js 中间件(web/proxy.ts)转发到容器内部的 FastAPI 后端,该中间件在请求时读取 DEEPTUTOR_API_BASE_URL(默认为 http://localhost:8001)。UI 正常工作时,你不需要向宿主机暴露 :8001。发布 :8001(-p 127.0.0.1:8001:8001)是可选的——只有你想直接调用 API(curl、脚本)或调试时才有用。
  • 不同的宿主机端口: 修改每个 -p 宿主机端口:容器端口 映射的左侧(例如 -p 127.0.0.1:8088:3782)。如果你在 data/user/settings/system.json(backend_port、frontend_port)中更改了容器侧端口,请重启容器并更新每个映射的右侧以保持一致。
  • 后台运行: 添加 -d,然后使用 docker logs -f deeptutor 跟踪日志,docker stop deeptutor 停止,docker rm deeptutor 在重复使用该名称前删除容器。deeptutor-data 卷会跨重启保留私有运行时数据和默认 Content Workspace;单独挂载的 Content Workspace 则持久化在其宿主机路径上。

临时本地 Codex OAuth 桥接

OpenAI Codex 会将浏览器重定向到固定的回环端口 1455 或 1457。默认的容器网络与宿主机回环隔离,因此仅在登录期间将这两个端口发布到 Web 前端。启动临时桥接前,两个宿主机端口都必须是空闲的。

对于 docker run,先停止正常容器,然后使用同一镜像和数据卷临时重新运行,并额外添加两个仅限回环的映射:

docker run --rm --name deeptutor \
  -p 127.0.0.1:3782:3782 \
  -p 127.0.0.1:1455:3782 \
  -p 127.0.0.1:1457:3782 \
  -v deeptutor-data:/app/data \
  ghcr.io/hkuds/deeptutor:latest

对于 Compose,在通常使用的基础文件上添加相同的临时覆盖层:

# Source build with sidecars
python scripts/docker_compose.py \
  -f docker-compose.yml -f compose.codex-oauth.yaml \
  up -d --force-recreate deeptutor

# Pre-built GHCR image
python scripts/docker_compose.py \
  -f docker-compose.ghcr.yml -f compose.codex-oauth.yaml \
  up -d --force-recreate deeptutor

# Rootless Podman
podman compose -f compose.yaml -f compose.codex-oauth.yaml \
  up -d --force-recreate deeptutor

完成 Settings → Models → OpenAI Codex → Sign in with Codex。状态变为 Connected 后,停止临时的 docker run 容器并恢复使用上面的正常命令。对于 Compose,重新运行相同的基础命令但不带 compose.codex-oauth.yaml;保留 --force-recreate deeptutor,以移除临时的端口绑定。

这会释放宿主机端口 1455 和 1457;凭据保留在持久的 /app/data/system 目录树中。请将每个回调映射都绑定到 127.0.0.1,切勿将其暴露在局域网或公共接口上——该覆盖层会将整个前端发布到这两个端口,而不仅仅是 /auth/callback。对于手动 docker run,如果容器侧前端端口不是 3782,请修改右侧的 3782 目标;scripts/docker_compose.py 会处理已配置的自定义端口。对于单容器安装,如果模型生成的代码不得与机密共享容器信任边界,请将 sandbox_allow_subprocess 设为 false。

一次性迁移:docker-compose.ghcr.yml 现在挂载整个 ./data

docker-compose.ghcr.yml 过去只绑定挂载三个子树(data/user、data/memory、data/knowledge_bases),因此其他所有内容——data/system(JWT 签名密钥、账户、授权、审计日志、每个所有者的 Codex 令牌)、data/users(每个用户的工作区)、data/partners、data/cli-apps——都位于容器的可写层中,并在每次重新创建时被丢弃。现在它挂载整棵目录树,与 docker-compose.yml 和 compose.yaml 保持一致。

在升级后首次运行 up -d 之前,请将该状态从正在运行的容器中复制出来;否则空的宿主目录会遮蔽它,DeepTutor 将重新生成认证密钥(使所有人退出登录),并以没有任何非管理员账户的状态启动:

for tree in system users partners cli-apps; do
  docker cp "deeptutor:/app/data/$tree" "./data/$tree" 2>/dev/null || true
done

使用命名卷(-v deeptutor-data:/app/data)或源码构建 Compose 文件的部署从未受到影响——它们本来就会持久化整棵目录树。

远程 / 反向代理部署

对于常见的单容器场景(本镜像),您完全不需要配置 API 基地址。浏览器会向提供 UI 的任何源(https://deeptutor.example.com)发出相对的 /api/* 和 /ws/* 请求,容器内的 Next.js 中间件会将这些请求转发到 localhost:8001 上的后端。只需将您的反向代理 / TLS 终止器指向已发布的 :3782 即可。

只有在拆分部署(后端运行在独立容器中)时才需要设置 API 基地址。在宿主机上编辑 data/user/settings/system.json(位于 deeptutor-data 卷内——可用 docker volume inspect deeptutor-data 查看其挂载点),并设置前端容器用于访问后端容器的网络内地址:

{
  "next_public_api_base": "http://backend:8001"
}

入口点会在每次启动时读取此配置,并为 proxy.ts 导出 DEEPTUTOR_API_BASE_URL(优先级:next_public_api_base,然后是 next_public_api_base_external,最后是 http://localhost:8001)。请注意,由于代理是服务端的,DEEPTUTOR_API_BASE_URL 是前端服务器用来访问后端的地址——而不是浏览器会看到的 URL。public_api_base 作为兼容性别名被接受,并在保存时规范化为 next_public_api_base_external。

CORS 使用前端的源,而不是 API URL。在禁用认证的情况下,DeepTutor 默认允许常规的 HTTP/HTTPS 浏览器源。在启用认证的情况下,请添加精确的前端源:

{
  "cors_origins": ["https://deeptutor.example.com"]
}

宿主机 LLM 提供商(Ollama / LM Studio / llama.cpp / vLLM / Lemonade)

在 Docker 内部,localhost 指的是容器本身,而不是您的宿主机。要访问运行在宿主机上的模型服务,请使用宿主机网关(推荐):

docker run --rm --name deeptutor \
  -p 127.0.0.1:3782:3782 -p 127.0.0.1:8001:8001 \
  --add-host=host.docker.internal:host-gateway \
  -v deeptutor-data:/app/data \
  ghcr.io/hkuds/deeptutor:latest

然后在 Settings → Models 中,将提供商的 Base URL 指向 host.docker.internal:

  • Ollama LLM:http://host.docker.internal:11434/v1
  • Ollama 嵌入:http://host.docker.internal:11434/api/embed
  • LM Studio:http://host.docker.internal:1234/v1
  • llama.cpp:http://host.docker.internal:8080/v1
  • Lemonade:http://host.docker.internal:13305/api/v1

Docker Desktop(macOS/Windows)通常无需 --add-host 即可解析 host.docker.internal。在 Linux 上,使用该标志是可移植的方式。

Linux 备选方案——宿主机网络: 添加 --network=host 并去掉 -p 标志。容器直接共享宿主机网络,因此打开 http://127.0.0.1:3782(或 system.json 中的 frontend_port),即可使用正常的 localhost URL 访问宿主机服务。

在宿主机网络模式下,进程直接绑定到宿主机接口上(没有 -p 127.0.0.1: 前缀来限定它们)。要让它们不暴露到局域网中,请设置 BACKEND_HOST=127.0.0.1 和 FRONTEND_HOST=127.0.0.1 —— 它们会覆盖 uvicorn 的 --host 和 Next.js 的 HOSTNAME(两者默认都是 0.0.0.0)。仅在 --network=host 下使用这些设置:在桥接模式下绑定到回环地址会破坏已发布的 -p 端口转发。


Podman / rootless / 只读 rootfs

对于希望采用最强默认安全姿态(rootless,且根文件系统只读)的用户,compose.yaml 是受支持的起点。它会拉取相同的 ghcr.io/hkuds/deeptutor:latest 镜像,并依赖入口点的 chown + supervisord 的逐程序权限降级、URL 转发 proxy.ts,以及宿主机侧的 bind mount 来让这一切正常运转。

cp .env.example .env       # then edit if needed
podman compose -f compose.yaml up -d
podman compose -f compose.yaml ps
podman compose -f compose.yaml logs -f deeptutor

验证 rootless 已激活(podman info | grep -i rootless 应返回 true)。

compose.yaml 做了什么,以及为什么:

  • 每个服务都设置 read_only: true。 容器的 rootfs 是只读的。唯一可写的区域是每个服务下所列出的 tmpfs: 挂载,以及 bind-mount 的 ./data 和 Content Workspace 目录。
  • userns_mode: keep-id。 使用运行时的 keep-id 用户命名空间。请在目标宿主机上检查实际的映射;不要假设容器 UID 0 或 UID 1000 对每个宿主机账户的映射都相同。:U 挂载选项会根据容器映射调整属主,并可能改变宿主机的目录属主。请使用专用的数据目录并验证写入权限。
  • 为运行时需要写入的系统目录提供 tmpfs: 挂载。 /tmp(Python/Node 临时文件)、/run 和 /var/run(pid 文件)、/var/log、/root、/home。大小故意留得比较充裕;可按需裁剪。
  • 不使用命名卷。 Podman 会使用 userns 映射的 root(UID 100000)自动创建命名卷,因此 755 权限 + 属主错误 = 第一次写入 JSON 时就出现 PermissionError。在你自己拥有的宿主机目录上进行 bind mount 可以干净利落地工作。
  • 仅回环地址的端口绑定。 每个 ports: 条目都带有 127.0.0.1: 前缀。去掉前缀即可在所有接口上暴露。
  • 没有 sandbox-runner 伴生容器。 docker-compose.yml 的形态包含一个加固的伴生容器,用于在最小权限容器中运行不受信任的模型生成代码。podman 形态则没有——主应用会回退到 bwrap(Linux,如果镜像中已安装)或受 system.json 中 sandbox_allow_subprocess 控制的受限子进程后端。

在 compose.yaml 之外运行

compose.yaml 是一个起点,而不是唯一的形态。如果你想直接使用 podman run,同样的不变式也适用:

mkdir -p data/user/settings
# Create defaults only when no settings exist; preserve existing configuration.
if [ ! -f data/user/settings/system.json ]; then
  printf '{}\n' > data/user/settings/system.json
fi

podman run --rm -d --name deeptutor \
  -p 127.0.0.1:8001:8001 \
  -p 127.0.0.1:3782:3782 \
  -v $(pwd)/data:/app/data:U \
  --read-only \
  --tmpfs /tmp:size=512m,mode=1777 \
  --tmpfs /run:size=32m,mode=0755 \
  --tmpfs /var/run:size=8m,mode=0755 \
  --tmpfs /var/log:size=64m,mode=0755 \
  --tmpfs /root:size=16m,mode=0700 \
  --tmpfs /home:size=16m,mode=0755 \
  --userns=keep-id \
  ghcr.io/hkuds/deeptutor:latest

容器启动后,后端和前端始终以非 root 的 deeptutor 用户(UID 1000)运行——podman exec deeptutor ps -o user,pid,comm 会显示 uvicorn/node 子进程为 deeptutor。supervisord 本身(PID 1)以运行时启动它时的任何 UID 运行:在 rootful Docker/Podman 下是 root,在 rootless podman + userns_mode: keep-id 下则是宿主机用户。

Supervisord 的 pidfile

[supervisord] 部分没有 user= 指令,因此 supervisord 以 PID 1 的 UID 运行,并且从不尝试降低自身权限;只有它的子程序通过每个程序的 user= 指令降级为 deeptutor。在这里固定 user=root(早期设计)会破坏 rootless keep-id,因为此时 PID 1 是非 root 的宿主机用户且缺少 CAP_SETUID:supervisord 拒绝降低权限并在启动时以 Can't drop privilege as nonroot user 退出(参见 supervisord 的 options.py)。

pidfile 被写入 /tmp/supervisord.pid。在上述所有运行配置中,/tmp 都是 mode=1777(世界可写),因此无论 PID 1 是 root 还是宿主机 UID,也无论 /var/run 的属主是谁,pidfile 都是可写的。早期版本曾将 pidfile 指向 root 拥有的 /var/run/supervisord.pid;在 rootless keep-id 下,非 root 的 PID 1 无法写入它,并在每次启动时记录一条无伤大雅的 CRIT could not write pidfile。将它放在 /tmp 中完全消除了对 /var/run 属主和模式的依赖。


运行时配置

你几乎要调优的所有内容都位于数据树中的 data/user/settings/ 下。容器入口点会在每次启动时取消设置一系列相关的环境变量(BACKEND_PORT、FRONTEND_PORT、NEXT_PUBLIC_API_BASE、NEXT_PUBLIC_API_BASE_EXTERNAL、AUTH_ENABLED、POCKETBASE_URL 等),并重新从 JSON 文件中导出值。所以:编辑 JSON 文件,重启,不要尝试用 compose 环境变量来驱动这些设置。

文件 用途
system.json 后端/前端端口、公共 API 基础地址、CORS、SSL 验证、附件目录
auth.json 可选的认证开关、用户名、密码哈希、token/cookie 设置
integrations.json 可选的 PocketBase 和伴生容器集成设置
model_catalog.json LLM、嵌入和搜索提供方配置;API 密钥;激活的模型
interface.json UI 语言 / 主题 / 侧边栏偏好
main.yaml 运行时行为默认值和路径注入
agents.yaml 能力/工具 temperature 和 token 设置

与全新安装最相关的两个设置:

  • system.json → next_public_api_base(网络内)和 next_public_api_base_external(云/外部覆盖)。入口点读取这些值并导出 DEEPTUTOR_API_BASE_URL,web/proxy.ts 会使用该变量。public_api_base 作为兼容性别名被接受,并在保存时归一化到 next_public_api_base_external。
  • system.json → backend_port / frontend_port。这是 supervisor 在容器内部绑定的容器端口。如果更改这些端口,请同步更新每个 -p host:container 映射的右侧(或 compose.yaml 读取的 HOST_PORT_* 环境变量)以保持一致。

项目根目录的 .env 文件被有意忽略,不作为应用配置。Web 设置页面是编辑 JSON/YAML 文件的推荐方式;每个部分的深层链接位于页面侧边栏。

PocketBase

PocketBase 是一个可选的认证 + 存储 sidecar。通过在 data/user/settings/integrations.json 中将 integrations.pocketbase_url 设置为 http://pocketbase:8090,并将 pocketbase 服务与主 deeptutor 服务一起启动来激活它。运行后,主应用会将用户账户和会话存储在 PocketBase 中,而不是回退到 SQLite 单用户布局。

compose.yaml 中的 pocketbase 服务(以及 docker-compose.yml 中修正后的挂载)将 ./data 的三个子目录 —— /pb_data、/pb_public、/pb_hooks —— 以 bind-mount 方式挂载,这与上游 ghcr.io/muchobien/pocketbase:latest 镜像的入口点(使用绝对路径)相匹配。之前的 docker-compose.yml 示例挂载的是 /pb/pb_data,首次启动时会因 mkdir /pb_data: read-only file system 崩溃;本 PR 修复了这个问题。

PocketBase 保持为单用户集成——在多用户部署中,请将 integrations.pocketbase_url 留空,除非你已经接入了外部用户存储。

故障排查

容器启动时出现 CRIT could not write pidfile /var/run/supervisord.pid。 仅在 pidfile 移动到 /tmp/supervisord.pid(mode=1777,始终可写)之前构建的镜像上出现;当前镜像不会输出此信息。受监督的子进程无论如何都会启动——该日志行一直只是表面问题。修复方法:拉取当前镜像(或在旧镜像上,将 /var/run tmpfs 设置为 mode=1777)。

页面加载正常,但设置页显示 “Backend unreachable”。 UI 通过容器内代理访问后端,而非主机端口,因此这几乎总是后端启动失败(查看 docker logs deeptutor 中的 [program:backend] 日志行)或拆分部署中 DEEPTUTOR_API_BASE_URL 配置错误所致——不是缺少 :8001 主机映射(UI 并不需要它)。

在 podman 主机上出现 Cannot connect to the Docker daemon。 运行 systemctl --user start podman.socket(rootless)或为 docker-compose CLI 设置 DOCKER_HOST=unix:///run/user/$UID/podman/podman.sock,以使用 podman socket。

在命名卷下首次写入 JSON 时出现 Permission denied。 这是 userns 映射的 root 问题;请切换到你所拥有的主机目录上的 bind mount,或在卷挂载上使用 :U。

在 Unraid 上出现 Data directory is not writable / Knowledge base not initialized (llamaindex)。 应用以非 root 的 deeptutor 用户运行。将 PUID/PGID 设置为主机 bind mount 的所有者(参见 Unraid / NAS 绑定挂载)。不要通过以 root 运行容器来规避此问题。

在新镜像上出现 sed -i 错误。 本不应出现——运行时不再修改 bundle。URL 会在请求时转发。如果你看到此类错误,很可能使用的是旧镜像;请重新拉取 ghcr.io/hkuds/deeptutor:latest。

设置页不接受 API 基础 URL。 在主机上打开 data/user/settings/system.json,直接设置 next_public_api_base_external。页面 UI 绑定的是 public_api_base(旧版别名),旧版字段会在保存时重新归一化。

安全说明

  • 镜像在启动 supervisord 前会将权限降级为非 root 的 deeptutor 用户(默认 UID 1000,或在 rootful Docker 下使用 PUID/PGID)。以 root 身份运行的只有入口点、对 /app/data 的 chown 尝试以及环境变量导出。应用进程本身不会保持 root 权限。
  • read_only: true 加上为预期可写的系统目录配置的 tmpfs:,意味着容器的根文件系统在运行时不可变。任何试图写入所列 tmpfs 路径之外或 bind-mounted ./data 目录树之外的进程都将失败。
  • 主机上的 userns_mode: keep-id 意味着容器逃逸后将以你主机用户的权限运行,而不是 root。
  • sandbox-runner sidecar(位于 docker-compose.yml 中,不在 compose.yaml 中)是处理不可信模型生成代码的最强姿态:沙箱逃逸后会落入一个精简、无特权的容器,不包含应用机密,而不是进入主应用。podman 形态会以此换取 rootless-podman 形态;主应用会回退到 bwrap 或由 sandbox_allow_subprocess 控制的受限子进程后端。
  • 认证(data/user/settings/auth.json → auth_enabled = true)通过 dt_token cookie 对 /api/* 和 /ws/* 进行门控。web/proxy.ts 读取 DEEPTUTOR_AUTH_ENABLED(每次启动时由入口点导出)来决定是否需要该 cookie。
  • CORS 使用前端的 origins,而非 API URL。启用认证后,将 system.json 中的 cors_origins 设置为部署所服务的精确前端 origins。
  • 可选的请求筛查(速率限制、IP 允许/拒绝列表、攻击模式扫描)可通过 system.json 中的 security_guard 以及 guard extra(DEEPTUTOR_EXTRAS: "guard")获得。默认关闭;参见 SECURITY_GUARD.md。

实现与验证

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 dee/DeepTutor