关于 9pm
为已安装并完成授权的 9pm.ai CLI 提供操作指南,面向使用 Agent 管理应用部署的开发者。内容涵盖身份验证、应用盘点、密钥范围和沙箱中的 MCP 连接器部署。
主要功能
- 01CLI 登录与密钥管理指引
- 02列出应用并检查部署状态
- 03部署前检查与部署流程指导
- 04受限权限密钥及其范围说明
- 05沙箱环境中的 MCP 连接器说明
适用场景
- 01检查账户中的应用并选择正确项目
- 02为自动化工作进程配置受限密钥
- 03在部署前运行项目检查
- 04通过 MCP 连接器从沙箱部署应用
MCP 相关
9pm.ai 提供位于 `https://api.9pm.ai/mcp` 的 MCP 连接器,可用于身份查询、应用管理、部署及查看部署状态和应用 URL。连接器需要用户在 Agent 平台设置中添加并授权。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ tmrw-realityos/ 9pm-skill --skill 9pm安装后在 Claude Code 中输入/9pm确认已加载。
详细介绍
9pm.ai
在 CLI 安装完成且用户已授权访问后,使用 9pm.ai 的产品指南。不是一次性设置提示词。
如果未将此技能安装在你的运行环境读取技能的位置(或需要更新),请运行 npx skills add tmrw-realityos/9pm-skill --skill 9pm -g——这是用户级安装,并会在 ~/.agents/skills/9pm/SKILL.md 保存一份共享副本(需要访问 npm-registry 和 github.com;末尾出现 PromptScript“Failed to install”一行只是无关信息——安装已成功)。请保留 -g:如果不加,安装程序会将文件写入用户的项目,而此文件绝不能复制到用户的项目目录中。
操作规则
- 修改前先检查。
- 为新应用创建文件或修改现有应用前,先征求用户同意。
- 部署用户构建的内容。优先采用最小的兼容性改动,同时保留用户的技术栈和意图。
- 部署用户正在编辑的工作副本——9pm 上传的是你指定的目录,而不是 git 分支。如果用户使用分支或 git worktree,请在运行前确认部署路径指向该检出目录(参见“预览与分支”)。
- 绝不要为了适配 9pm.ai 而重构应用,尤其是其存储层,除非获得用户明确同意。如果应用已有可用的数据库,请将存储连接到该数据库(参见“持久化”),而不是重写应用。
- 部署前,尽可能运行应用现有的构建和测试检查。
- 如果支持,请在实际部署前运行
9pm deploy <dir> --check。 - 应用可以归属于某个组织,而非部署者个人。使用
--owner <organisation-slug>部署到组织(账户必须是该组织的管理员或所有者),并使用9pm apps --owner <organisation-slug>列出其应用;不加此标志时,所有内容都属于账户本人。组织需在控制面板中创建,成员也需在那里邀请。 9pm deploy会在本地运行项目自身的构建——对于框架应用,这意味着npm install(及其生命周期脚本)、框架构建或docker build都会在你的机器上执行。只部署你信任的目录,就像你会信任运行该项目的构建一样。- 清楚地报告部署 URL 及后续风险。
身份验证
此聊天 Agent 没有安全的机密信息输入方式。粘贴到对话中的任何内容都会记录在模型提供商的对话记录、工具调用历史记录和提示词缓存中。不要索要用户的 API 密钥。如果用户主动提供,也不要接受。如果用户还是粘贴了密钥,请立即停止,告知他们该密钥已泄露,并指示他们从控制面板撤销并重新生成密钥。
身份验证方式,按优先顺序排列:
- 对于已有账户(包括用户刚通过邀请邮件创建的账户),使用
9pm login。采用设备码流程;令牌存储在原生受保护的存储中。仅当原生存储不可用时,才可选择将令牌存储为明文文件。Agent 永远不会看到密钥。再次运行9pm login时,如果已存储的凭据仍然有效,则会继续使用该凭据,而不会另行创建;9pm login --fresh会替换该凭据并撤销旧凭据。 - 新账户必须在浏览器中创建,绝不可通过终端创建。无论公开注册是否开放,
9pm signup都无法创建账户:它会立即拒绝(不会一直等待审批),并显示下一步操作:公开注册开放时前往注册页面,关闭时申请邀请。账户创建后,使用9pm login。无法通过9pm signup兑换邀请链接;邮件中的一次性链接是使用邀请的唯一方式。 - 用户在自己的终端中设置环境变量:
export NINEPM_API_KEY=9pm_...。提醒用户,该值可能会被记录在 Shell 历史中。 - 在 CI 中,
NINEPM_API_KEY的优先级高于已存储的登录凭据。 - 用于 CI 或无人值守服务的密钥,应在已登录的机器上通过
9pm api-key create --name <name>创建;令牌只会显示一次,因此用户应在自己的终端中运行此命令,并自行保存该值。可以使用9pm api-key ls和9pm api-key revoke <key-id>管理密钥;每个账户最多可同时持有 10 个密钥,登录令牌不计入其中。
无人值守工作器的作用域密钥
对于任何无人值守运行的工作器,优先使用作用域密钥,而不是完全访问密钥,这样即使密钥泄露,也无法删除该账户的其他应用。9pm api-key create --preset <preset> 用于选择密钥可执行的操作:
monitor可读取应用状态和部署历史,范围限于通过--project <app>指定的应用(可重复使用此标志)。deploy在--project指定的应用范围内,额外允许部署、设置环境变量和添加数据库。provision额外允许创建应用,但仅限于一个自动化(--automation <id>)。自动化用于分组 provision 密钥可访问的应用:9pm automation create <name>、attach <id> <app>、detach、apps、ls、rename和rm <id> --yes。执行rm <id> --yes会撤销该自动化的密钥,但保留其应用。full(默认值)可对所有应用执行所有操作。
对某个应用拥有部署权限,实际上就等于可以访问该应用的运行时密钥和数据:可以替换应用代码的密钥,就能读取应用可以读取的任何内容。作用域限制能保护其他应用,但无法保护该密钥可以部署到的应用。任何作用域密钥都不能删除应用、更改访问权限、管理域名或文件、创建密钥、管理自动化、代表组织执行操作(--owner),也不能读取应用的日志、数据库或分析数据;这些操作需要使用完全访问登录,创建作用域密钥和自动化也同样需要完全访问登录。使用作用域密钥的工作器若需要运行 9pm logs,请让用户以自己的登录身份运行;不要为此给工作器完全访问密钥。
--expires 用于设置固定的到期时间,可以是带时区的 ISO 8601 日期时间(2026-12-31T00:00:00Z),也可以是相对时间 30d / 12h,且最长不超过一年。它不能替代闲置规则:每个密钥在 90 天未使用后都会失效;设置了 --expires 的密钥则会在两者中较早的时间失效。闲置自动化的密钥可能因此失效,之后其工作器会因“expired”拒绝而停止;9pm login 无法解决此问题。若要恢复,请使用完全访问登录创建替代密钥,并设置相同的预设和应用;若要替换 provision 密钥,则使用相同的自动化(其应用仍处于关联状态),然后撤销旧密钥。
作用域密钥被拒绝时,CLI 会说明原因。缺少的权限会与包含该权限的预设一同列出。“not found”表示该应用不存在,或者此密钥无法访问该应用:请在控制台 Settings 的 API keys 下检查该密钥的应用,或使用以用户完全访问登录身份登录的 CLI 检查。使用作用域密钥运行 9pm whoami 时,会显示密钥前缀、预设和到期时间,而不是账户的套餐和用量。
除非用户指定其他端点,否则默认使用 https://api.9pm.ai。命令需要显式设置端点时:
export NINEPM_API_URL=https://api.9pm.ai
预检:
9pm doctor
9pm whoami
仅当 whoami 不可用且用户自行设置了 NINEPM_API_KEY 时,才使用以下回退方式:
curl -fsS "$NINEPM_API_URL/v1/sites" -H "authorization: Bearer $NINEPM_API_KEY" >/dev/null
资源清单
部署或修改任何内容之前,请先列出用户已有的资源,以免与现有应用 slug 冲突,或对错误的项目执行操作:
9pm apps
9pm apps --json
slug 列是后续每条命令(deploy、access、delete、env、files --project)所使用的标识符。active 表示应用已完成部署;no-deploy 表示应用已创建,但从未完成部署。
流量分析
每个已部署的应用都在控制台中内置 Analytics 视图(按应用查看,可选择 24h / 7d / 30d 时间范围):浏览量、独立访客数、主要引荐来源、国家/地区、人类访客与爬虫的比例,以及返回 404 的路径。系统会自动采集数据,无需在应用中添加任何内容,也无需使用第三方脚本。访客数采用保护隐私的方式统计(每日轮换哈希值、不记录原始 IP、不使用 Cookie)。请引导用户前往控制台中的应用页面查看。
版本管理
9pm doctor 会输出一行 CLI:,比较已安装版本与最新版本。如果用户遇到“未知命令”错误,或实际行为与本指南不符,请怀疑 CLI 版本过旧并运行:
9pm latest
使用以下命令升级:
npm i -g ninepm@latest
成功运行 9pm deploy 后,CLI 也会自行检查更新(每天最多探测一次注册表),并在已安装版本落后时输出一行 Update available: ...。将该行视为立即升级的提示,同时也要刷新此技能(见下文 保持此技能更新)。设置 NINEPM_NO_UPDATE_CHECK=1 可关闭检查。
CLI 是公共 npm 注册表上的 npm 包 ninepm。可通过 npx ninepm <args> 临时运行(无需安装),也可以使用 npm i -g ninepm 安装持久可用的 9pm 二进制文件:
npx ninepm <args>
在受限沙盒中,npx ninepm 是最简单的方式:公共 npm 注册表默认位于“Trusted”允许列表中,因此运行时无需信任任何 9pm 主机来获取 CLI(见 沙盒环境)。读取是匿名的;安装无需令牌。
保持此技能更新
本指南与 CLI 一同发布,也会以同样的方式过时。每当 CLI 报告版本落后时——无论是部署后的 Update available: 行、9pm latest,还是 9pm doctor——都要使用安装该技能的同一条命令刷新已安装的技能:
npx skills add tmrw-realityos/9pm-skill --skill 9pm -g
若要检查是否存在差异而不重新安装,请运行 9pm doctor——其 Skill: 行会将已安装副本的 Skill guide stamp 与公开源进行比较;也可以自行将此文件中的戳记行与 https://raw.githubusercontent.com/tmrw-realityos/9pm-skill/main/skills/9pm/SKILL.md 上的副本进行比较。安装命令使用的正是此来源,且该来源在沙盒中默认受信任。任何差异都意味着需要刷新。(https://9pm.ai/skills/9pm/SKILL.md 提供的副本会跟随平台版本更新,可能会暂时落后于该来源,因此不要用它作为判断是否为最新版本的依据。)
技能指南戳记:2026-10-22.4
沙盒环境
如果你在网络出口受限的沙盒中运行(Claude 云沙盒,或 Claude Code CLI bash 沙盒——两者默认都使用“Trusted”允许列表),那么在信任相关主机之前,某些步骤会失败。
无需修改允许列表即可获取 CLI:通过 npx ninepm <args> 运行(或使用 npm i -g ninepm 安装持久可用的二进制文件)。公共 npm 注册表默认位于 Trusted 允许列表中,因此无需额外信任。
无需修改允许列表即可部署:9pm 连接器
在云沙箱中,网络策略会在环境构建时应用,因此已经在沙箱中的 Agent 无法更改它;而用户也可能根本不想更改。针对这种情况,9pm 提供了一个 MCP 连接器:https://api.9pm.ai/mcp。连接器流量会通过 Agent 平台自己的服务器路由,而不是从沙箱直接发出,因此在默认的“可信”设置下即可连接到该平台,无需将任何内容添加到允许的域名中。
用户只需在会话外,通过 Agent 平台的连接器设置执行一次添加操作,然后在浏览器中登录以批准访问。此后,工具会出现在之后的每个会话中。你无法在沙箱内添加它。
让用户执行此设置前,请先确认:必须为每个环境启用通过连接器部署的功能。 如果未启用,身份和应用管理工具仍可使用,但 deploy_begin 会返回一条指示,要求信任 api.9pm.ai,而不是使用上传 URL;此时只能使用下方的允许列表方案,连接器无法带来任何便利。如果你已经有连接器,只需调用一次 deploy_begin,就能知道自己属于哪种情况。
连接器支持身份信息(whoami)、列出和创建应用、部署、部署状态以及应用 URL。部署分为开始、上传、完成三个阶段:
- 使用应用 slug 和已构建文件清单(路径、大小、小写十六进制 SHA-256、内容类型)调用
deploy_begin。它会返回一个deployId,以及平台尚未拥有其内容的文件对应的预签名上传 URL——每次响应最多 50 个。如果响应还包含nextCursor,则使用该游标调用deploy_upload_targets并继续,直到不再返回游标:后续分页包含其余文件;如果跳过这些分页,包含 50 个以上新文件的构建将无法完成部署。容器应用则会获得一个用于镜像 tar 文件的 URL。 - 在沙箱内,将每个文件的字节内容分别 PUT 到对应的 URL。
- 使用
deployId调用deploy_finalize。它会分批导入内容,因此如果返回结果显示仍在上传,请再次调用,直到完成发布并返回线上 URL。对于容器应用,该调用会在 tar 文件读回后返回;请轮询get_deployment_status,直到应用启动。
连接器不支持以下内容:
- Worker 运行时应用。 连接器会拒绝此类应用,而不会错误发布。请信任
api.9pm.ai,然后改用 CLI 部署。 - 单次部署包含超过 500 个文件的静态构建。 解决方法相同。
- 此技能中的其他所有功能——环境变量、密钥、访问模式、托管文件、删除——都只能通过 CLI 使用。连接器提供的是额外的部署途径,并非 CLI 的替代品。
信任 API 主机
部署时需要信任的主机:
api.9pm.ai- CLI 调用的部署 API(9pm login、deploy、apps等)。这是唯一需要添加的主机:CLI 会通过此主机访问平台,而且它不在默认信任列表中。(CLI 来自公共 npm,且此技能来自 GitHub——两者都已受信任,因此无需将其他 9pm 主机加入允许列表。)- 容器部署(
--runtime container)会在本地构建镜像,因此会从 Docker Hub 拉取基础层(registry-1.docker.io/auth.docker.io默认受信任;可能需要添加用于传输层数据块的 CDN)。随后,构建的镜像会上传到api.9pm.ai(/v1/container-uploads/<id>/imagePUT——与上文相同的主机)。推送到托管镜像注册表的操作会在服务器端进行,因此无需在沙盒中将注册表主机加入允许列表。
信任这些主机的方式取决于环境:
- Claude 云端(claude.ai/code):在环境的网络设置(云朵图标)中配置,而不是在任何设置文件中配置。选择自定义网络访问,并在允许的域名字段中添加主机。策略会在环境(重新)构建时应用,因此请在会话开始之前设置——已经进入沙盒的 Agent 无法在会话期间更改策略,而且云端会话不会加载
~/.claude/settings.json。请参阅 https://code.claude.com/docs/en/claude-code-on-the-web。 - Claude Code CLI(本地 bash 沙盒):将主机添加到
.claude/settings.json中的sandbox.network.allowedDomains(支持*.9pm.ai这样的通配符)。请参阅 https://code.claude.com/docs/en/sandboxing。
请在首次运行之前,从沙盒外部(云端环境设置或 CLI 设置)应用允许列表——已经进入沙盒的 Agent 无法在会话期间更改策略。运行 npx ninepm 即可获取 CLI,无需添加允许列表,但在进行任何部署之前,仍必须从外部信任 api.9pm.ai。
此技能发布在公开的 GitHub 镜像中(raw.githubusercontent.com/tmrw-realityos/9pm-skill/main/skills/9pm/SKILL.md),沙盒默认信任该镜像——因此,即使 9pm.ai 不受信任,也无需添加允许列表即可获取指南。
命令审批
与上述网络出口限制不同:编码 Agent 的运行环境可能会要求用户在每条 9pm 命令运行前都批准,因此一次部署会变成十几个审批提示。这是 Agent 自身的命令审批关卡,而不是 9pm 的设置——9pm 本身并非不可信,只是 Agent 没有预设规则,认定它可以安全运行。请在 Agent 的权限配置中预先信任 9pm 命令范围,让常规部署流程无需人工干预即可运行,同时仍让破坏性命令先请求批准。具体机制因运行环境而异——只应用与你的运行环境对应的分支。
仅限 Claude Code: 在用户的项目中将以下内容添加到 .claude/settings.local.json,与任何现有的 permissions 块合并,而不是替换它:
{
"permissions": {
"ask": [
"Bash(9pm delete *)",
"Bash(9pm deployments rm *)",
"Bash(9pm deployments delete *)",
"Bash(9pm files delete *)",
"Bash(9pm files share *)",
"Bash(9pm env rm *)",
"Bash(9pm access *)"
],
"allow": ["Bash(9pm *)"]
}
}
ask 规则优先于 allow,因此常规命令范围(deploy、apps、doctor、logs、whoami、env ls、files 读取等)无需人工干预即可运行,而所有破坏性或会更改访问权限的命令仍会先请求批准:9pm delete(销毁应用)、9pm deployments rm/deployments delete(移除部署)、9pm files delete、9pm files share(为文件生成链接;使用 --public 时,任何人无需登录即可访问)、9pm env rm,以及任何 9pm access 更改。使用 ask,不要使用 deny——deny 会直接阻止这些命令,而不是提示用户批准。
这会信任已安装的 9pm 二进制文件。如果使用 npx ninepm 形式,请添加相同规则,并为命令加上 npx ninepm 前缀(包括在 ask 下添加 Bash(npx ninepm delete *));这些规则会直接匹配 npx ninepm <args>。
任何其他运行环境(Cursor、Codex 或其他): 不要创建 .claude/settings.local.json——只有 Claude Code 会读取它,对其他运行环境而言,它只是用户项目中的无用杂项。这些运行环境没有简洁的单二进制白名单:请通过相应 Agent 自身的审批策略或沙盒设置来信任 9pm,或者在命令出现时逐条批准。
支持的应用形态
- 静态 Web 构建产物,输出目录中包含
index.html和资源。常见示例:Vite、React、Vue、Svelte、Solid、Astro 静态输出、原生 HTML/CSS/JS,以及静态导出。每个文件的大小上限为 25 MiB。单个资源超过此大小时——最常见的是 Godot/Unity/Flutter Web 导出中较大的 WebAssembly 引擎二进制文件——无法托管在静态或服务端运行时上;部署会在9pm deploy --check和创建时被直接拒绝,并报出asset_file_too_large错误,其中会指出文件名和大小上限。这是一个明确且确定的限制,并非暂时性故障——重试无济于事。请拆分或将超大文件托管到外部,或者改为将应用打包为容器。 - 使用客户端路由的单页应用,从其构建输出目录部署时加上
--spa,这样访问深层链接或刷新客户端路由页面时会加载应用(请参阅下文的 不存在的页面)。 - 运行时应用,包含一个导出兼容
fetch的处理程序的打包 JavaScript 服务端入口。常见示例:Hono 风格的应用、小型 API 后端,以及生成单个打包入口的框架运行时适配器。 - 框架应用无需任何标志即可部署:在 Next.js 或 Vite 项目中运行
9pm deploy,会自动检测框架并自行执行完整流程——按需安装依赖、运行生产构建,然后上传。构建可能会向项目添加适配器配置文件;如果 CLI 提示,请将其提交。构建错误会原样输出——这些错误来自框架本身;修复应用代码后重新运行。--no-build会跳过检测;--bundle <dir>会部署预先组装好的包(入口为worker.js,另含assets/目录)。目前尚不支持自动构建 Astro/SvelteKit——9pm deploy会说明这一点并告知后续操作:自行运行框架的 Worker 运行时适配器构建(9pm deploy会指出要运行的适配器及确切的部署命令),然后使用9pm deploy . --bundle <adapter output dir>部署输出(Worker 包),或使用9pm deploy <static output dir> --no-build部署(静态输出)。已知的 Next.js 限制:使用next/og(ImageResponse)的路由会引入yoga.wasm,运行时构建无法解析该文件,因此构建会失败。部署前,请将这些 Open Graph 图片预渲染为静态文件(或移除动态 OG 路由)。 - 当应用无法适配静态或服务端运行时时,使用
Dockerfile部署容器应用。CLI 会在本地为linux/amd64构建,要求使用--port,接受--health-path,并支持与提供商无关的--region(auto、us-east、us-west、europe、asia、south-america、middle-east、oceania、africa)。应用必须监听0.0.0.0:$PORT并公开 HTTP 服务——请读取PORT环境变量,切勿将所选的--port硬编码:在某些部署位置,平台本身会占用所选端口,并通过PORT将另一个端口传给应用;因此,硬编码端口会与平台冲突,导致部署无法通过健康检查,且没有明显原因。默认情况下,文件系统是临时的。持久磁盘(--volume <gb>,挂载到/data)、始终运行的实例(--always-on)和自定义规格(--cpus <n>、--memory <size>,例如4g)仅适用于支持这些功能的部署位置,并且可能需要平台启用;如果尚未启用,部署会快速失败并报出container_capability_unavailable错误,而不会静默忽略该选项。 - 部署时使用
--with-db的存储托管应用。服务端/运行时代码会从运行时接收托管存储句柄;请勿在浏览器代码中公开密钥。 - 部署时使用
--with-auth的逐用户账号应用——由 9pm 托管终端用户登录(电子邮件一次性验证码),并将当前用户注入应用。此选项隐含--with-db。请参阅“终端用户账号”。 - 用户请求限制访问时,使用 9pm.ai 访问命令部署私有应用。
一个已部署应用如何调用另一个应用。 运行时(服务器)应用无法通过另一个 9pm.ai 应用的公共主机名访问它。此类请求不会离开平台的内部网络,因此会因边缘错误而失败,即使另一个应用运行正常,且其他用户都能正常访问——调用方应用的日志中不会说明原因,而从外部看,另一个应用也一切正常。请改为从浏览器调用另一个应用,或从容器应用自身的运行时调用;这两种方式都会像其他客户端一样访问互联网,因此能正常工作。
9pm deploy会在发现服务器代码中包含另一个应用的主机名时发出警告,但它只能检测到直接写出的主机名——在运行时拼接出来或从环境变量读取的主机名不会被检测到。这不适用于平台自身的 API(api.9pm.ai),服务器代码可以正常调用它。
运行时应用的公共文件。 使用 --entry 时,请选择要公开的文件:将浏览器文件(HTML、CSS、图片、客户端脚本)放在一个目录中,并传入 --assets <dir>——只有该目录会被提供,目录会映射到应用根目录(public/index.html 可通过 / 访问),部署目录中的其他文件都不会上传。如果入口会自行提供所有内容,则传入 --no-assets。创建新的运行时应用时,务必传入这两个选项之一:如果两者都不传,部署目录中除入口文件以外的每个文件都可供任何人下载——包括服务器辅助文件、配置文件、备份文件(例如 index.js.orig)。出现这种情况时,部署会发出警告并列出相关文件。应用会记住此选择,因此后续部署即使不带标志也会沿用;如需更改,请传入另一个标志(或指定不同目录)。9pm deploy --check 会列出部署会公开的所有文件,其摘要会统计已扫描文件的数量,不包括选定上传的文件;--check 本身不会上传任何内容。这不适用于 --bundle 部署,因为它们只会提供 assets/ 目录的内容;也不适用于静态网站,因为静态网站默认是公开的。
服务器代码大小。 服务器代码的压缩大小(gzip)上限为 9.9 MB。该限额涵盖所有已部署的服务器模块——对于框架打包产物,这是所有模块的总大小,而非单个文件的上限——静态资源不计入该限额,因为它们由资源存储提供。检查通过时,9pm deploy --check 会输出测得的压缩大小,格式为 Server code (…): N bytes compressed of M allowed.;请查看此行以了解剩余空间。无论是执行 --check 还是实际部署,超出限额的打包产物都会在创建应用前被拒绝。需要更多服务器代码的应用应改为以容器形式部署。这适用于上述两种服务器运行时形式——单个 --entry 模块和 --bundle 构建产物。
--runtime worker 应用如何共享请求与其静态文件。 部署目录中除入口文件外的所有文件都会作为公开静态文件发布;入口文件本身不会被提供。对于路径与其中某个文件完全一致的请求(/menu.html、/app.js),系统会直接从静态文件提供响应,不会到达处理程序。其他所有请求——包括 / 和任何客户端路由——都会先交给处理程序,静态文件仅作为其回退:当处理程序返回 null 或 undefined(没有 Response)时,对 / 的 GET/HEAD 请求会提供 index.html。其他任何未匹配的 GET/HEAD 路径会得到什么响应,取决于应用的缺失页面模式(见下文的不存在的页面):使用 --spa 时返回 index.html,使用 --no-spa 时返回 404。无论处理程序返回什么 Response(包括 404),所有模式下都会原样发送。因此,对于处理程序不负责的路由,请返回 null,绝不要返回自己的 404:兜底 404 会隐藏首页,在 --spa 模式下还会隐藏所有深层链接。升级后,兜底 404 因此可能会隐藏首页。若要在处理程序内提供静态文件(例如在检查之后),env.ASSETS.fetch(new Request(new URL("/index.html", request.url), request)) 会按文件的字面路径(/index.html,而非 /)读取应用的静态文件,且仅当部署包含静态文件时才存在。由于字面文件路径会绕过处理程序,应用代码无法保护静态文件:请勿将任何私密内容放入部署目录的静态文件中,而应通过会检查用户的处理程序路由提供这些内容。
持久化
大多数存储数据的应用已经在使用本地数据库或文件。请部署它们现有的内容——除非用户明确要求,否则不要为了适配平台而重写存储层。以下两种持久化选项按对现有应用的改动从小到大排列:
- 容器应用的持久磁盘(
--volume <gb>),仅在已启用时可用。 对于已经使用本地数据库(SQLite 文件、嵌入式存储、本地磁盘数据)的容器,这是改动最小的方案:磁盘会挂载到/data,因此将应用的数据库/数据目录指向/data,并保留现有代码。容器的文件系统在其他情况下都是临时的,因此没有卷时,本地磁盘数据会在部署/重启之间丢失。重新部署会自动保留现有磁盘(省略--volume);比当前磁盘更大的--volume会在保留数据的同时原地扩容;磁盘大小不会缩小——较小的--volume会被拒绝;若要重建更小的磁盘,先使用--no-volume --allow-data-wipe删除现有磁盘(这会销毁其中的数据),然后以较小的大小再次部署(会创建全新的空磁盘)。请注意,此选项要求部署位置支持卷,并且可能需要平台启用——如果--volume返回container_capability_unavailable,说明此账号未启用磁盘选项;请改用下文的托管 SQL,不要凭猜测行事。 - 托管 SQL(
--with-db)。 始终可用、无需维护,是没有自有磁盘的服务运行时应用,或未启用卷时的正确选择。对于已有自有数据库的容器应用,切换到托管 SQL 意味着要根据下文的 SQL 桥接层重写其数据访问方式——请将其作为一项有意的改动提出,并先取得用户批准;绝不要仅为部署应用就悄悄重构其存储。
托管 SQL
- 使用
--with-db部署的服务器运行时应用会获得一个DB绑定。请使用预处理语句:env.DB.prepare("SELECT ...").bind(...).all()。 - 使用
--with-db部署的容器应用会获得NINEPM_SQL_URL和NINEPM_DB_BINDINGS,如果桥接服务需要,还会获得NINEPM_SQL_TOKEN。向NINEPM_SQL_URL发送POST请求,JSON 格式如下:{ "binding": "DB", "sql": "SELECT 1", "params": [] }。当设置了NINEPM_SQL_TOKEN时,还要发送Authorization: Bearer <that token>——需要该令牌的桥接服务会拒绝所有未携带令牌的请求(401 {"error":"unauthorized"}),因此省略它会让数据库看起来像是出了故障,而实际上只是缺少请求头。每次调用时都从环境中读取 URL 和令牌,不要将它们硬编码:不同部署位置对应的 URL 不同,而且每次部署都会重新签发令牌。 - SQL 需与 SQLite 兼容。在本地开发时,通过精简的仓储层使用本地 SQLite/Postgres 适配器,并仅在部署后的运行时切换到 9pm.ai 绑定或 SQL 桥接服务。
- 除非应用已有迁移系统,否则请使用
CREATE TABLE IF NOT EXISTS幂等地初始化架构。 - 托管数据库属于应用,而非某次部署:同一 slug 的每次重新部署都会保留数据库及其行,包括未带
--with-db的重新部署。只有9pm delete才会删除它。
终端用户账户
当应用需要按用户划分的账户时——例如登录、“我的内容”视图、按当前登录用户隔离的数据——9pm 提供了这项托管功能。在接入外部身份提供商之前,优先考虑使用它:它能省去登录、会话和身份管理等繁琐工作,正如 9pm 已经为托管和存储省去了相应工作。只要用户提到按用户划分的数据或登录,就应提供这项功能。
在部署时启用:
9pm deploy ./app --name "App Name" --slug app-name --with-auth
--with-auth 会隐含启用 --with-db(按用户划分的数据需要托管数据库),且只能单向启用——启用后无法关闭。启用后,应用之后的每次部署都会保持开启,无论是否带上该标志。v1 仅支持通过电子邮件发送一次性验证码登录;目前还没有托管的第三方身份提供商(有关此类提供商,请参阅 Deploy Workflow 中的外部提供商备用方案)。
你将获得:
- 9pm 会在应用自身域名的
/__9pm/auth处理整个登录流程——应用无需构建登录界面或处理会话密钥。托管登录页面位于/__9pm/auth,应用自身的界面也可以调用以下端点来完成登录流程:POST /__9pm/auth/request-code({ email },发送验证码邮件)、POST /__9pm/auth/verify({ email, code },开始会话)、GET /__9pm/auth/me(返回当前用户或 null),以及POST /__9pm/auth/logout。 - 让用户返回原来的位置。 未登录访客访问需要登录的页面时,应使用
return参数链接或重定向到托管页面:`/__9pm/auth?return=${encodeURIComponent(location.pathname + location.search)}`(在服务端,根据请求 URL 的路径和查询字符串构造相同的值)。务必对其进行 URL 编码。成功登录后,页面会跳转到该地址,包括查询字符串(如果将location.hash加入该值,也会包括#fragment)。如果没有return,用户会进入/——如果应用首页没有链接到该页面,用户就无法继续访问。只接受应用自身域名下的路径:完整 URL、其他域名或/__9pm/...路径都会回退到/。 - 应用从一个位置读取当前用户。 Worker 运行时应用从请求上下文的
ctx.user读取——它是处理函数的第三个参数fetch(request, env, ctx)——已登录时为{ id, email, isOwner },未登录时为null。容器应用读取请求标头X-9pm-User(JSON{ id, email, isOwner };未登录时不提供该标头);9pm 会验证并设置此标头,因此应信任它,并忽略客户端提供的同名值。GET /__9pm/auth/me返回相同的数据结构。 - 对于同一个人,用户
id在该应用内保持稳定,因此可将其用作应用自有托管表中的每用户外键(参见 Managed SQL),例如WHERE user_id = ?。身份由 9pm 管理;数据由应用管理。 isOwner用于标识应用所有者——当已登录用户就是部署该应用的人时,其值为true,无需任何配置。当用户请求仅限所有者或管理员访问的区域时,应在服务端通过ctx.user.isOwner对相关路由进行权限控制(绝不能在客户端代码中检查,也绝不能依赖客户端提供的值)。该字段仅标记一个人;其他管理员或角色由应用自行管理。
与访问模式组合使用(参见 Access):托管账号适用于 public 应用,也适用于 private_otp / private_idp 应用。在私有应用中,访问门禁已识别访客,因此 ctx.user 会被填充,无需再次登录,也不会使用邮箱验证码端点。此功能不能用于 private_service 应用——服务令牌背后没有人类用户。
登录行为与边缘情况——当应用通过这些端点构建自己的界面时需要了解(位于 /__9pm/auth 的托管页面已处理以下所有情况):
- 验证码 10 分钟后过期,且只能使用一次;请求新验证码会使上一个验证码失效。验证码过期或错误时,
verify会返回400,错误代码为invalid_code,并附带面向用户的提示(“验证码错误或已过期。”)——出现此错误时,请提供“重新获取验证码”操作。 - 重新发送冷却时间:每个地址每 60 秒只能获取一个验证码。在此时间窗口内,
request-code会返回429(rate_limited)。之前的验证码仍然有效,因此应显示“请检查收件箱,验证码正在发送中”,而不是将其视为失败。此外还有更大范围的限制(每个地址每小时、每位访客和每个应用的限制;均返回429)。 - 输错五次会使验证码失效——按设计,错误仍使用通用的
invalid_code。 request-code始终返回202 {"ok":true},无论地址是否已知,也无论邮件能否送达——此端点无法用于枚举账户,因此不要将202视为已送达的证明。如果有人误将陌生人的地址输入,收件邮件会说明可以放心忽略;只有首次成功执行verify时才会创建账户。- 直接向用户展示
400/429响应中的error.message——这些内容是为最终用户编写的。
原生和移动客户端
原生客户端(iOS 或 Android 应用、桌面应用或 CLI)没有浏览器 Cookie 存储,因此会对相同的会话凭证使用Bearer 传输方式。账户相同,ctx.user / X-9pm-User 也相同,无需更改后端:无论请求来自浏览器还是手机,应用的服务端代码都会从原本读取用户的位置获取当前用户。
- 按照上述流程登录——先调用
POST /__9pm/auth/request-code,再调用POST /__9pm/auth/verify——但在 verify 请求中发送请求头X-9pm-Session-Transport: bearer。响应将为{ user, token, expiresAt },且不包含Set-Cookie。将token存储在平台的安全存储中(iOS 上使用 Keychain,Android 上使用 Keystore),切勿存入普通偏好设置或日志。 - 之后的每个请求都要进行身份验证,使用
Authorization: Bearer <token>。GET /__9pm/auth/me、应用自己的路由和POST /__9pm/email/invite均接受此方式。请求头会经过验证,并在到达应用代码之前被移除——请从ctx.user/X-9pm-User读取用户,切勿自行解析请求头。Authorization中不属于 9pm 会话凭证的值会保持不变,因此应用可以同时保留自己的 API 密钥方案。 - **浏览器应用不得启用此方式。**继续使用 Cookie 方式(不要发送传输请求头):页面脚本可以持有的凭证会失去
HttpOnlyCookie 提供的保护。此请求头仅供无法使用 Cookie 的客户端使用。 - **退出登录即删除存储的令牌。**会话是无状态的,有效期至
expiresAt(7 天);原生客户端调用POST /__9pm/auth/logout不会造成问题,但也不会撤销任何内容。请在应用界面中如实说明,并在令牌过期后重新执行验证码流程。 - 如果 Cookie 和 Bearer 同时存在(嵌入式 Web 视图可能出现这种情况),则以 Bearer 为准——无论其有效还是过期,本次请求都会忽略 Cookie。
- 原生客户端不支持私有访问模式:对于任何
private_*应用,访问控制会通过浏览器会话识别访客,而此传输方式不会用于身份识别(9pm 会话令牌仍会在到达应用之前从Authorization中移除)。原生配套客户端需要使用配置了托管账户的public应用。 - 此传输方式推出前部署的应用必须重新部署一次才能使用;Bearer 方式依赖 9pm 在部署时围绕每个应用封装的代码。
用户文件
当已登录用户需要管理自己的文件——照片、文档、头像——托管账户会提供按用户划分的文件存储,因此应用无需创建存储桶、签发上传 URL 或保管存储凭据。它随 --with-auth 提供;无需单独的标志。切勿将二进制文件存入托管数据库,也切勿引入存储桶——请使用此功能。文件归应用所有,由应用决定哪些用户可以查看哪些文件。
使用 Worker 运行时的应用会获得一个 ctx.files 客户端。如果应用没有托管账户,或是私有/受访问权限控制的应用(这类应用的文件通过访问会话传输),它会是 null,因此请先测试该功能是否可用。对于未登录的请求,每个调用都会在执行任何操作前抛出异常,因此请先检查 ctx.user。
上传需要三步授权,因此文件字节会直接从客户端传到存储中,不会经过应用:
- 申请授权。
const { grant } = await ctx.files.createUploadGrant({ path, contentType, sizeBytes })。path是应用选择的键——请按用户划分作用域,例如`${ctx.user.id}/avatar.jpg`。sizeBytes必须是文件的准确大小;contentType必须在下方的允许列表中。grant的格式为{ fileId, url, method, headers, expiresAt },有效期为 5 分钟。同一用户在已有path下完成第二次上传时,会替换旧文件——旧文件会被删除。对于avatar.jpg这类稳定键,这正是所需行为;如果必须保留每个文件,请使用唯一的path(随机 ID、时间戳)。 - 上传文件字节。 客户端使用原样的
grant.method和grant.headers,向grant.urlPUT文件——这些标头包含准确的Content-Length,任何大小不同的请求体都会被拒绝。在浏览器中,这是一次跨源PUT;除此之外没有任何特殊之处。 - 完成上传。
await ctx.files.complete(grant.fileId)。在此调用之前,文件会保持待处理状态且无法读取。
然后:
- 读取:
const { url } = await ctx.files.createReadGrant(fileId)——生成一个位于文件域名下的短时链接,用于提供文件字节,并带有nosniff和沙箱策略(图片以内联方式显示,其他内容均作为下载提供)。将它放入<img src>,或交给客户端。 - 列出:
const { files, nextCursor } = await ctx.files.list({ prefix })——原始列表的作用范围是整个应用,因此请使用prefix限定范围(例如用户 ID)。每次最多返回 200 个文件;如果nextCursor不为 null,则表示还有更多文件,请通过list({ prefix, cursor: nextCursor })翻页,直到返回 null;否则,拥有大量文件的用户只能看到最新文件。 - 删除:
await ctx.files.remove(fileId)。
createReadGrant和remove接收的是原始fileId,其作用范围仅限应用,并不限定为当前用户——平台不会检查文件是否属于已登录用户。(例外是complete:只有上传者才能完成自己的待处理上传。)因此,在读取或删除客户端提供的 ID 之前,请在服务端验证文件属于该用户——例如确认其path位于该用户自己的前缀下,或确认该用户的list({ prefix })结果中包含此文件。如果跳过这项检查,已登录用户就可以猜测 ID,读取或永久删除其他用户的文件。“已登录”不等于已获授权——所有权检查是应用的职责,与托管数据库完全相同。
失败时会抛出 NinepmFilesError,其中包含 code、适合直接向用户展示的 message 和 HTTP status——not_signed_in、invalid_content_type、invalid_size、file_too_large、file_storage_quota_exceeded(所有者的账户空间已满)、app_file_ceiling_exceeded(此应用已达到自身上限)。请向用户展示 message。
- 允许的内容类型:
image/jpeg、image/png、image/webp、image/gif、image/heic、application/pdf、text/plain、application/octet-stream。大小上限: 每个文件 25 MiB(约 26 MB)。 - 容器应用无法获取
ctx.files;它们通过NINEPM_FILES_URL使用桥接服务调用相同操作:POST $NINEPM_FILES_URL/<op>,其中<op>为grants/upload、complete、grants/read、list或delete,JSON 请求体也相同。9pm 会为已登录用户在转发到容器的每个请求中添加X-9pm-Files-Ticket标头,容器必须将该标头从传入请求复制到桥接POST中,否则桥接服务会返回401 files_ticket_invalid。票据按请求生成且有效期很短,因此要从当前处理的请求中读取,切勿缓存;容器不会持有长期凭据。 - 在此功能推出前部署的应用必须重新部署一次才能使用它——原生传输也需要进行同样的一次性重新部署,因为文件客户端位于 9pm 为每个应用封装的代码中。
多用户模式
以下所有多用户模式的做法都相同:在应用自己的托管数据库中创建表(参见 Managed SQL),以稳定的 ctx.user.id 为键,并基于 9pm 注入的身份信息构建。所有模式都适用两条规则:每项授权检查都必须在应用中由服务端执行;身份信息只能来自 ctx.user / X-9pm-User,绝不能使用客户端提供的 ID、邮箱或角色值。对于公开应用,9pm 控制面板已显示注册人数,因此除非有人提出要求,否则不要构建用户计数页面;私有应用没有控制面板计数,因此可以合理地将其作为一项功能。
所有者管理区——“只有我能看到的管理页面”:
- 在服务端使用
ctx.user.isOwner限制仅所有者可访问的路由。无需配置:所有者像其他人一样使用邮箱登录,系统会自动识别。 isOwner只标记一个人——部署该应用的账号。如需添加其他管理员,请在应用中维护角色表,并允许isOwner会话授予角色。- 移除用户意味着删除或隐藏其在应用自有表中的行。这不会撤销其登录权限——现有会话在到期前仍然有效(最长 7 天)。如果用户要求封禁或立即锁定,请明确说明这一点,并改为根据应用表中的
banned标记限制应用路由:被封禁者仍可能处于登录状态,但看不到任何内容。
由用户创建和管理的群组——旅行团组、共享项目、俱乐部:
- 应用数据库中的两个表:groups(id、name、owner user id)和 memberships(group id、email、nullable user id、role)。
- 每次读取或写入特定群组的数据前,都要验证用户是否属于该群组:从请求中解析群组 id,然后检查
ctx.user.id是否为该群组的所有者或活跃成员,再访问该群组的行。仅仅「已登录」并不代表有权限——跳过此检查会导致所有账户都能访问每个群组。 - 在成员尚未登录过时,也可以通过邮箱添加成员:将成员关系关联到去除首尾空格并转换为小写的邮箱;通过
ctx.user.email查找待处理成员关系时,也要使用相同的规范化方式,然后在首次登录时关联稳定的用户 id。(对于私有应用,邮箱会保留原始大小写;只有 id 会由系统为你规范化——同一个人始终会对应同一个 id。) - 邀请可通过两种方式送达。默认情况下,被邀请者登录后即可看到待加入的群组(通过邮箱添加,或使用可分享链接)。如果应用部署时使用了
--with-email,已登录用户还可以发送托管邀请邮件——从应用自身的界面调用POST /__9pm/email/invite,并传入{ to, mode: "invited" | "added", inviterName, thingName }。9pm 会负责发送邮件、限制发送频率并处理退订;应用无需接触邮件服务商。此功能需要托管账户,且仅适用于公开应用,并设有发送额度(每人每天大约二十多封邀请)。名称中不能包含链接。不要自行实现邀请邮件发送。202表示已受理,不代表已送达。 按设计,无论邮件已发送、因收件人已退订而被静默丢弃,还是在邮件服务商处发送失败,端点都会返回相同的202——这是刻意设计的(不能探测退订状态和邮件送达情况)。429 rate_limited表示已触发额度限制或冷却时间。因此,绝不要告知用户「邮件已发送」——应说「如果该地址接受邮件,邀请就会发送」,并且始终提供可分享链接(无论收到429还是202),因为202并不能证明邮件已送达。
两类用户——教师/学生、主持人/访客:
- 在应用自己的个人资料表中设置角色列,并使用关系表(班级、选课)。在服务器端读取应用表中的角色,为不同角色的路由设置访问控制。
- 用户如何获得较高权限角色,由应用自身的规则决定,而非平台决定——例如由所有者在管理区域审批、使用邀请码或开放开关。询问用户想采用哪种方式;不要擅自决定。
- 根据关系表限定各角色可见的内容:低权限角色只能看到自己加入的内容,高权限角色只能看到自己的名单。
带员工区域的公开应用——访客无需登录即可使用应用,员工则登录后台(例如餐厅的订单页面、商店的库存页面)。这是将上面两种方案结合用于公开应用:
- 使用
--with-auth部署并将应用设为public。访问模式(见 Access)保护的是应用的整个地址,而不是其中某个部分,因此员工专区由应用自身的代码保护。 - 在应用的托管数据库中建立员工表,以去除首尾空格并转为小写的电子邮件地址作为键,这样所有者可以在对方首次登录之前就将其添加到表中(首次登录时关联
ctx.user.id,如 Groups 中所示)。所有者通过由ctx.user.isOwner控制访问权限的路由管理该表;所有者无需在表中有记录,也算员工。 - 每个员工路由都必须在服务端、每次请求时检查该表:当
ctx.user为 null 时返回401;当已登录的人既不是所有者、也不在名单中时返回403。绝不要将检查结果缓存到 Cookie、客户端状态或本地存储中。由于每次都会读取该表,移除某人后会在其下一次请求时生效:其登录状态仍然有效,直到过期(见 Owner admin area),但不再能访问任何内容。 - 访客绝不会收到员工数据。员工数据只能由这些经过检查的路由提供,绝不能放入部署目录中的静态文件,因为静态文件是公开的(见
--runtime worker应用如何与其静态文件共享请求)。员工页面的 HTML 可以公开,但它加载的内容不能公开。 - 将已退出登录的员工发送到
`/__9pm/auth?return=${encodeURIComponent(location.pathname + location.search)}`,这样登录后会返回员工页面,而不是/(见 End-User Accounts 下的 将用户送回原来的位置)。
按链接共享的应用
当一个 URL 对应一个私有工作区时(例如共享看板、文档,或下方的待办事项演示),URL 本身就是访问密钥:
- 使用
crypto.randomUUID()为每个工作区生成一个workspace_id。拒绝格式错误的:workspace路径值。 - 每次数据库读写都必须限定
workspace_id,以确保工作区彼此隔离。 - 为每个工作区响应设置
Referrer-Policy: no-referrer和Cache-Control: private, no-store。 - 不要从 SPA 加载第三方资源——它们可能会通过
Referer标头泄露工作区路径。 - 部署前,明确告知用户该 URL 就是访问密钥。
- 当工作区需要真实身份——具名成员、所有者、角色——这就是 End-User Accounts 下的 Groups 模式:采用相同的隔离方式,但使用账户而非秘密 URL。
部署工作流
- 确认身份验证(
9pm whoami)并查看应用清单(9pm apps),以免与已有的 slug 冲突。 - 检查应用:技术栈、包管理器、构建命令、输出目录、运行时类型和持久化需求。
- 如果需要修改,说明最小且兼容的改动,并等待明确批准。
- 在可用时,使用应用现有的命令进行构建和测试。
- 运行本地预检:
9pm deploy ./dist --check
- 部署静态构建输出:
9pm deploy ./dist --name "App Name" --slug app-name
可选传入 --description "<short note>",标注此次部署的改动(例如 --description "Fix login redirect")。它会显示在仪表板的版本旁边,方便用户之后区分不同的部署。说明应简短(一句话,最多 200 个字符),并尽量在每次部署时都添加。该选项并非必需——省略后,部署记录只会显示版本号。之后也可以在仪表板中编辑说明。适用于任何运行时:
9pm deploy ./dist --name "App Name" --slug app-name --description "Initial deploy"
- 对于由应用管理的服务端代码,部署一个运行时入口,并使用
--assets指定公开文件所在目录(如果没有公开文件,则传入--no-assets),以确保服务端文件保持私有:
9pm deploy ./app --check --runtime worker --entry src/index.js --assets public
9pm deploy ./app --name "App Name" --slug app-name --runtime worker --entry src/index.js --assets public
- 对于 Docker/容器应用,确认已安装 Docker,并确认应用会读取
PORT环境变量以设置监听端口(绝不能硬编码端口——见上文的容器运行时说明),然后:
9pm deploy ./app --check --runtime container --port 8080
9pm deploy ./app --name "App Name" --slug app-name --runtime container --port 8080 --health-path /health --region auto
关于持久化,请参阅 Persistence 部分:优先使用 --volume <gb>(挂载到 /data),在应用支持的情况下保留其现有本地数据库;如果无法使用卷,或应用本身没有磁盘,则使用 --with-db(托管 SQL——应用会读取 NINEPM_SQL_URL 和 NINEPM_DB_BINDINGS 以连接桥接服务)。如果部署因镜像大小受阻,请说明触发的是单应用镜像大小限制、账户保留镜像配额,还是平台容量限制。请准确报告 CLI 输出的 queued、publishing、active 或 failed。
容器就绪检查清单:存在 Dockerfile;HTTP 服务绑定到 0.0.0.0,并从 PORT 环境变量读取监听端口(--port 选择公开端口;平台可能通过 PORT 为应用分配不同的端口);可选的健康检查端点返回 2xx;持久化状态使用已附加的 --volume(写入 /data)或托管 SQL;机密信息不得写入镜像——请改用 9pm env 设置。健康检查端点失败时应返回 500(或任何非 502/503 的错误):平台保留 502/503 作为其自身的“机器未启动”信号,因此在会停止空闲应用的部署环境中,健康检查返回这些状态会被视为机器正在唤醒,而不是对应用作出判断。
如果容器应用无法启动,或你需要事后排查,请获取其最近记录的日志:
9pm logs <app> # recent container logs + the failure reason, if any
9pm logs <app> --lines 50 # last N lines
9pm logs <app> --json # machine-readable
9pm logs 会报告最新容器部署的状态、健康状况、与平台无关的失败原因,以及部署未能启动时捕获的构建/启动输出。对于带有持久化磁盘的应用,它还会报告磁盘使用率——在断定有状态应用启动失败是因为磁盘已满之前,请先检查这一点,因为否则无法从应用内部验证这种猜测。此命令为只读,不会影响部署。已报告为 active、但仅在实时请求中出错的部署,可能尚无捕获的日志;此时命令会明确说明,而不会暗示应用运行正常。
- 对生成的产物或交接文件使用托管文件。绝不要请求存储凭据:
9pm files put ./report.pdf --project app-name
9pm files share <file-id> --expires 24h
9pm files share <file-id> --project app-name --public
默认情况下,共享仅限所有者访问:只有已登录的所有者才能打开链接。如果接收者没有账户,且内容可以安全公开,请添加
--public——该链接无需登录,因此任何获得 URL 的人都能在链接过期前下载文件。对于包含个人数据、凭据或数据库导出的内容,绝不要传入 --public。
- 报告 CLI 输出的实时 URL 和仪表板 URL。
对于按用户区分的账户,优先使用 9pm 的托管终端用户身份验证(--with-auth)——请参阅 End-User Accounts——这样用户就不必自行运行身份系统。只有当用户明确要求使用自己的外部提供商时,才适用以下说明。
如果应用使用外部身份验证/身份提供商(Supabase、Auth0、Clerk、Firebase 等),其登录、密码重置和 magic link 重定向应在该提供商的仪表板中配置,而不是在应用的 env 中配置。部署到 9pm URL 后,请告知用户将提供商的 Site URL 和允许的重定向 URL 更新为部署时输出的确切实时 URL(步骤 10)——请直接复制,不要根据 slug 重新拼接。主机名包含每个应用各自的后缀(<slug>-<suffix>.<domain>),因此仅用 slug 构造的 URL 对应的主机名并未托管任何应用:它会解析成功,随后却无法访问,看起来像是平台宕机,而不是重定向地址错误;所有登录和 magic link 都会因此失效。只有真实用户尝试登录时,才能发现此配置错误。Agent 无法更改提供商的仪表板,因此请将此项标为必须手动完成的步骤,并同时设置应用读取的任何 *_SITE_URL env。
打印出来的链接或二维码还能继续使用吗? 在用户打印或分发应用 URL 之前,请告知用户。<slug>-<suffix>.<domain> 中的后缀来自应用的内部 ID,而不是 slug,因此:
- 在同一 slug 下重新部署会保留地址。
- 删除应用并在同一 slug 下重新创建,会获得一个新地址,因为新应用会获得新的 id。所有使用旧地址生成的内容都会失效,因此不要删除并重新创建地址已经公开的应用。
- 无法重命名:地址不同,就代表是不同的应用。
- 用户拥有的自定义域名可为应用提供一个不依赖应用 id 的专属地址。如何添加自定义域名,请参阅 Custom Domains;并非所有套餐都包含自定义域名。
制作二维码。 运行 9pm qr <app>,不要自行生成:它会将可打印的 SVG 保存到文件(图片使用 --format png,指定文件名使用 --out <file>),二维码指向应用实际提供服务的地址:自定义域名已启用时使用该域名,否则使用 9pm 地址。注明它打印的最小打印宽度,以及它附加的任何说明:自定义域名的二维码会随该域名生效;扫描私有应用的二维码不会让任何人获得访问权限。--terminal 也会在终端中绘制二维码。对于组织的应用,传入 --owner <organisation>。如果它提示服务器暂时无法生成二维码,说明平台尚未跟上 CLI;不要退而使用 slug 拼接 URL。
不存在的页面。 静态网站或使用 --entry 的 worker 应用遇到没有对应文件的路径时,会以两种方式之一响应,每次部署都会通过 Missing pages: 行打印所用方式:
- 404(
--no-spa,新应用的默认值):该路径返回状态码 404;如果部署中有404.html,则显示该文件。/仍会提供index.html,省略扩展名的链接也能正常工作:/menu会提供menu.html;如果存在menu/index.html,则会重定向到/menu/;而/menu/会提供menu/index.html。适用于多页面网站:对于已删除页面的链接,或打印的菜单中的拼写错误链接,会显示“未找到”,而不是悄悄显示首页。添加一个链接回首页的404.html。 - 首页(
--spa):所有没有对应文件的路径都会以状态码 200 提供index.html,因此使用浏览器端路由的单页应用可以通过深层链接或刷新加载页面。所有未匹配的路径都会返回该页面,包括/users/jane.smith这样的带点路径,以及/missing.json这样的缺失文件——应用自身的路由器必须显示未找到页面。
对于使用客户端路由的单页应用(React Router、Vue Router 等),传入 --spa;页面分别存放在 .html 文件中的网站无需设置此选项,即使它只有一个页面也一样。应用会记住此选择:未指定任一标志的部署会保留该选择;发布失败的部署不会更改此选择。在此选项推出前创建的应用会继续提供首页(部署行会显示 "kept from before"),直到某次部署传入其中一个标志。此设置不适用于 --bundle 或容器应用,因为它们会自行响应所有路径。
返回较早的版本
如果部署导致静态应用或 worker 应用出现故障,所有者可以直接在控制面板中恢复较早的版本,无需 Agent:进入应用页面 → 版本历史记录 → 在目标版本上选择 恢复此版本,然后确认。当用户要求撤销或回滚部署时,提供这个选项并引导他们前往该处,而不是手动重新部署旧代码。
- 恢复操作会使用所选版本中保存的代码和文件创建一个新版本,无需重新上传任何内容。该版本会成为当前提供服务的版本,历史记录中会显示为“恢复自 vN”;较新的版本仍会保留在历史记录中,因此撤销恢复操作需要再次恢复。
- 恢复操作仅还原代码和文件。应用的数据库及其数据、环境变量和机密、访问模式、登录、自定义域名、用户文件、地址、哪些文件公开,以及缺失页面选项,都会保持当前状态。自该版本以来改为私有的文件仍会保持私有。
- 如果较新的版本更改了应用的数据库表,较早的代码可能无法与这些表配合使用。恢复操作不会撤销数据库更改,也不会恢复数据。
- 以下情况不提供恢复选项:当前正在提供服务的版本、从未上线的版本、容器应用(暂不支持),或与应用当前运行类型不同的版本(将静态站点恢复到 worker 应用,或反向恢复,或将打包的 worker 恢复到入口文件 worker)。如果某个版本的文件已不再保存,系统会拒绝恢复,应用会继续提供当前版本的服务。
预览和分支
9pm 会将你指定路径下的文件部署到你传入的 slug 下。它不识别 git 分支或 worktree——部署不会“获取当前分支”,而是上传部署目录中的所有内容。要确保部署的是用户想要的代码,需要处理以下两点:
- 部署用户正在编辑的检出副本。 当用户在某个分支或 git worktree 中迭代并要求部署“这个”时,要将部署路径解析为该工作副本,而不是沿用之前部署时的路径,也不是仓库的默认检出副本。原封不动地重新运行之前的
9pm deploy命令,是 Agent 在用户期待部署 worktree 更改时却重新部署了旧代码的常见原因;运行前请确认路径(以及当前工作目录)。 - 相同的 slug 会替换线上应用;新的 slug 会创建单独的预览。 使用现有 slug 部署会覆盖该应用的线上部署。若要预览尚未完成的工作而不影响线上应用,请使用不同的 slug 部署分支或 worktree,使其拥有独立的 URL:
9pm deploy ./dist --name "App Name (preview)" --slug app-name-preview
如果用户是想预览尚未完成的工作,默认使用单独的预览 slug,并向用户确认他们是想这样做,还是要替换现有 slug 下的线上应用。使用 9pm delete <slug> --confirm <slug> 删除不再需要的预览(必须提供 --confirm 标志,且其值必须与 slug 一致)。
- Free 套餐不支持单独的预览。 使用 Free 套餐时,账户的一个应用名额无法同时用于单独的预览应用。部署到现有应用会更新其线上版本。在 Free 套餐上部署有风险的更改之前,请告知用户该部署会更新线上应用。不要建议在应用内排练,作为尝试更改的方式:练习模式排练的是当前应用的工作流;若要单独测试更改后的版本,则需要另一个应用名额。如果用户有此需求,建议升级到可创建更多应用的付费套餐(Hobby 套餐允许创建 3 个应用),以创建单独的预览应用。
自定义域名
应用也可以通过用户拥有的域名提供服务,例如 app.example.com。当用户要求使用“自己的域名”时,请使用 9pm domains 进行连接(CLI 0.72.0 或更高版本;如果无法识别此命令,请先更新 CLI):
9pm domains add <app> <hostname> # prints the DNS records to publish, and who hosts the domain's DNS
9pm domains status <app> <hostname> # what DNS shows now for each record; --json for {state, records[...]}
9pm domains wait <app> <hostname> # polls until live (--timeout 30m, --interval 20s)
对于组织的应用,请添加 --owner <organisation>。将 add 返回的记录原样展示给用户;他们应在域名注册商或 DNS 主机处发布这些记录,而不是在 9pm 上发布。然后运行 wait 并根据其退出代码采取相应操作:
0:已上线。该域名会提供应用。10:等待超时,但用户有事要处理:记录缺失或无法检查、CAA 记录阻止了证书签发(CLI 0.74.0 或更高版本;status会显示要修改的内容和位置),或域名已准备好切换(见下文)。请让用户将已发布的内容与9pm domains status的结果进行比较,然后再次运行wait。从 CLI 0.89.0 起,如果域名指向其他位置(见下文),wait也会立即以10退出。11:等待超时,所有记录均已找到,用户无需处理任何事项。平台仍在完成相关操作;再次运行wait,无需修改任何内容。20:失败或已过期。消息会说明原因。9pm domains check <app> <hostname>会重新读取状态;如果证书验证失败,9pm domains restart <app> <hostname>会重新开始验证(记录值可能会变化,因此请重新发布命令显示的内容;仅处于等待状态的域名切勿重启,这不会加快进度);过期的域名需要用add重新添加。1:用法、登录或权限错误。add、check、restart、remove和move需要配置应用的权限;status和wait只会读取信息。
无论以哪种方式添加记录,都应遵守以下规则:
- **套餐:**免费套餐不包含此功能。如果
add或控制面板提示不支持自定义域名,这是套餐限制,不是故障。服务令牌应用(private_service)不能使用自定义域名。 - 子域名(
app.example.com、www.example.com)需要添加一条指向所示值的 CNAME 记录。 - 根域名(
example.com)不能使用 CNAME;9pm domains会将其显示为ALIAS / ANAME(或者,对于可识别的 DNS 服务商,会显示该服务商对此记录的称呼):需要添加服务商提供的 ALIAS、ANAME 或 CNAME 扁平化记录。有些服务商(例如 GoDaddy,或目标不在 AWS 中的 Route 53)没有可用的记录类型,此时请使用www.或其他子域名。正确的 ALIAS 会返回地址而非目标名称,因此其状态行会遵循平台的路由检查结果。 - 主机部分必须按显示内容准确填写(
app、_acme-challenge.app)。大多数服务商会自动附加域名,因此输入完整名称会导致重复(app.example.com.example.com)。 - 两个
_acme-challengeTXT 值是两条独立的 TXT 记录,名称相同。请分别添加,并保留任何现有 TXT 记录。 - 切勿删除 MX 记录或根域名下的其他 TXT 记录来腾出空间:这些记录用于域名的电子邮件和验证。
- 缓存的解析结果:
status会读取两个公共解析器,而两者都使用缓存。如果它们的结果不一致(一方显示该值,另一方不显示),则都不能证明哪个结果是最新的:最近更改的记录通常仍在传播中。请让用户在其 DNS 服务商处确认记录,然后再次检查;不要告诉他们已经完成,也不要让他们修改记录。刚更改的记录也可能在几分钟内仍显示旧值(状态行会说明持续时间),因此再次修改前请先重新检查。从 CLI 0.92.0 开始,对于托管在 GoDaddy、Namecheap、Google Cloud DNS 或 NS1 的 DNS,状态行还可能显示 DNS 服务商本身发布的内容(“your DNS host publishes …”,--json中为authoritative)。如果服务商已发布所示值,说明记录在服务商处已经配置完成,只是公共缓存尚未更新:请告诉用户等待并再次检查,不要修改记录。“Unable to verify directly”仅表示这项额外读取失败,绝不代表记录缺失。状态行的状态仍以公共解析器的判定为准,因此不要仅凭服务商处的结果就说域名已就绪。如果服务商的通配符记录(例如*.example.com)为某个名称提供了解析,状态行会提示在该名称处添加记录;通配符记录可以保留,TXT 值始终应添加在现有记录旁。 - **时间:**记录配置完成后,实际情况下证书通常会在几分钟内就绪,但 DNS 传播可能需要更久,因此不要承诺具体时间。如果所有记录均已找到,
status会指出平台下次自动检查的时间;请等待该检查,不要重启。只有在确认记录正确后域名仍卡住时,才使用restart。 9pm domains remove <app> <hostname> --confirm <hostname>会断开域名;该域名会在几秒内停止提供服务。只有在记录值仍与命令输出一致时,才转述其列出的可删除记录:只有值仍是所示值时才能删除;如果名称现在对应其他值,则该记录属于其他用途。如果没有列出记录,不要建议删除任何记录。如果命令提示移除仍在完成,请告诉用户该过程会自动完成;当9pm domains list <app>不再显示该域名时,它就已停止通过 9pm 提供服务。在此之前,请告诉用户保留其记录,不要提及任何记录。移除完成后,9pm domains removed(CLI 0.84.0 或更高版本;组织的域名需添加--owner <organisation>)会列出已释放的记录,条件相同;仅转述命令输出的内容。如果该域名没有列出任何记录、提示无法检查域名(稍后运行它打印的--hostname命令),或提示服务器无法列出记录,不要建议删除任何记录。9pm delete会为应用的域名打印相同的逐域名列表,并会为仍在完成移除的域名指向9pm domains removed。- **移除会释放域名:**9pm 不会保留与该域名相关的任何内容;移除后,如果 DNS 仍指向 9pm,访客可能会看到 TLS 或路由错误。请说“可能”,不要指定某一种错误,也不要承诺切换过程不会出错或停机。关于要删除的内容,只转述
remove或delete打印的清理列表,并且只有在记录值仍匹配时才能删除;切勿告诉用户删除“他们添加的记录”。从 CLI 0.86.0 开始,remove和delete会打印此警告。 - **将域名从其他主机迁移到 9pm:**请让用户先发布 TXT 记录,并在
status显示证书就绪前,将路由记录(CNAME,或根域名上的 ALIAS)留在旧主机上;然后再将这条记录改为所示值。在记录更改前,访客仍会访问旧主机。从 CLI 0.89.0 开始,如果服务器支持报告此状态,此时状态会显示为ready_to_switch(“ready to switch”),并指出要更改的记录及其目标值;只有在公共 DNS 显示该记录指向 9pm 后,状态才会变为live。在此之前,不要告诉用户域名已上线。根域名(ALIAS)以及某些配置(解析器结果不一致、记录返回地址)无法从外部区分,读取结果会显示为已上线;对于这些情况,请让用户在其 DNS 服务商处确认记录。 - 将域名从 9pm 迁移到其他主机:请让用户先在新主机上设置域名,将 DNS 指向新主机,等待 DNS TTL 过期,然后再在此处移除域名。缓存更新期间,部分访客可能仍会访问 9pm,或短暂看到新主机上的错误。DNS 指向其他位置后,
status会显示域名指向其他位置(points_elsewhere,CLI 0.89.0 或更高版本,且服务器支持报告此状态),下一步为remove:这是预期情况,请移除域名,不要升级处理。如果用户并非有意迁移,状态文本会说明要将哪条记录指回;但仅指回还不够,因为提供商会一直将已迁移的域名保持为已断开状态,直到重新开始验证,因此接下来请运行9pm domains restart <app> <hostname>(控制面板:从头开始),发布命令输出的任何新 TXT 值,然后运行wait。记录指回后,status会显示失败,原因是moved_needs_restart,下一步是start_over(“points back at the platform, but the provider still has it disconnected”):这表示应重启,而不是联系支持。仅重新检查无法恢复域名;提供商会删除已迁移且保留 7 天的域名。此恢复流程尚未进行过实时演练;如果域名未上线,请联系支持。较旧的 CLI 或服务器可能会将其显示为失败并建议联系支持;如果用户是有意迁移的,请按相同方式处理。不要承诺切换过程不会出错或停机。对于其他类型的迁移,请让用户先联系支持,不要自行推断操作顺序。从 CLI 0.87.0 开始,remove和delete会打印此操作顺序,并附上自定义域名指南中“将域名从 9pm 迁移出去”部分的链接。 - 域名上线后,
9pm domains cleanup <app> <hostname>(CLI 0.83.0 或更高版本)会列出 9pm 不再需要的设置记录(较早的_acme-challenge值或所有权 TXT),并在运行时向证书提供商核实。请按相同条件转述:只有记录值仍与所示值一致时才能删除;如果该名称对应其他值,则该记录属于其他用途。该域名的其他所有记录都应保留。只建议删除命令列出的记录:切勿根据 DNS 显示内容或status自行推断剩余记录;如果命令未列出任何记录、执行失败或提示域名尚未上线,不要建议删除任何记录。remove会在其列表中包含这些值。restart后,对于命令指出不再有效的值,也适用相同规则。 9pm domains move <hostname> --to <other-app>会将域名迁移到同一账户下的另一个应用,并保留其证书和已发布的记录。如果操作被拒绝,请遵循命令给出的原因(属于另一个账户、目标应用已达上限、移除仍在完成,或该域名没有可保留的内容,因此会先移除再重新添加)。 无需使用 CLI 时(或用户更喜欢这样操作时),也可以在控制面板中完成相同流程:应用自己的页面 → Custom domains → Add a domain;仅当证书失败时才会显示 Re-check 和 Start over。账户级 Domains 页面只会列出已添加的域名;其中没有添加表单。
第一个 Todo App
Todo 演示应用是可选的。对于已有账户(已有凭据、已运行 9pm login,或 9pm apps 中已有应用),以及在现有仓库或项目中工作时,请完全跳过它——改为检查并部署该应用。对于在空工作区中首次注册的用户,请询问他们是否需要演示应用,仅在用户明确同意后才构建。构建时,采用上述通过链接分享的模式,询问目标目录,并在写入前确认文件列表。
演示应用的构建规格:
- 运行时配置:
--runtime worker --with-db;入口接收env.DB。首次收到请求时再初始化架构。 - 路由:在没有工作区的情况下,对
GET /生成一个workspace_id并重定向到/w/<id>;所有查询都限定在对应的workspace_id范围内;验证:workspace并拒绝格式不正确的值(遵循上述通过链接分享的模式)。 - 架构:
CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
text TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS todos_workspace_created ON todos(workspace_id, created_at DESC);
- API:
GET/POST/w/:workspace/api/todos,PATCH/DELETE/w/:workspace/api/todos/:id { text?, done? }。 - 技术栈:Vite + React + TypeScript,使用 vite-plugin-singlefile 将 SPA 合并为一个 HTML 文件;将 worker 入口打包为 ESM,导出一个兼容
fetch的默认处理器(支持export { app as default })。 - 布局:构建后的 SPA 位于
deploy/public/index.html,打包后的入口位于deploy/src/index.js。 - 部署:
9pm deploy ./deploy --check --runtime worker --entry src/index.js --assets public
9pm deploy ./deploy --name "Todo" --slug todo --runtime worker --entry src/index.js --assets public --with-db
- 润色要求:精心设计的字体、合理的间距、通过
prefers-color-scheme支持深色模式、支持键盘操作、图标按钮带有 aria-label、仅使用内联 SVG 图标、不使用第三方资源。报告 CLI 输出的线上 URL 和控制面板 URL。
访问权限
访问权限控制的是谁可以访问应用(外围边界),与终端用户账户不同;终端用户账户用于识别进入应用后访问者是谁(参见“终端用户账户”)。两者可以结合使用:应用可以设为私有,同时也为每位用户设置账户。
应用默认公开。仅在用户提出要求时管理访问权限:
9pm access show app-name
9pm access otp app-name --switch-mode
9pm access idp app-name --provider google --switch-mode
9pm access allow email app-name [email protected]
9pm access allow domain app-name example.com
9pm access revoke email app-name [email protected]
9pm access service create app-name --name backend --switch-mode
9pm access public app-name --switch-mode
更改访问模式会决定谁可以访问应用,因此除非传入 --switch-mode,否则平台会拒绝更改。不带该标志时,不会有任何变更;拒绝信息会说明变更的影响:谁会失去访问权限,以及应用的自定义域名会如何处理。切换为服务令牌模式(在尚未处于该模式的应用上运行 service create)会将所有人拒之门外,并移除其自定义域名;切换回其他模式时,会重新配置这些域名并生成新的 DNS 记录,用户必须发布这些记录。在使用 --switch-mode 重新运行命令前,向用户说明这些后果并征得同意。对于已处于服务令牌模式的应用,创建额外的服务令牌无需标志。
环境变量和密钥
每个应用的配置在静态存储时均已加密,并会在下次部署时生效。默认情况下,变量值为密钥(只写);传入 --var 可设置可读取的配置值:
9pm env set <app> KEY=value [KEY2=value2 ...] # secret by default (write-only)
9pm env set <app> KEY=value --var # readable config value
9pm env set <app> --from-file ./.env # KEY=value lines; # comments and quotes ok
9pm env set <app> SECRET_NAME --stdin # value read verbatim from stdin (never the chat)
9pm env request <app> SECRET_NAME # prints a one-time browser link to enter the value
9pm env ls <app>
9pm env rm <app> <NAME>
名称采用 UPPER_SNAKE_CASE;PORT 和 NINEPM_* 为平台保留名称。更改环境变量后,重新部署应用以使其生效。
当应用需要你尚未持有的真实密钥——例如第三方 API 密钥或令牌——时,不要让用户将其粘贴到此聊天中(参见“身份验证”:粘贴到此处的任何内容都会被记录)。请改为运行 9pm env request <app> SECRET_NAME,并将输出的链接提供给用户。用户打开链接后,以应用所有者身份登录,并在浏览器中输入密钥;密钥会被封装并存储,不会进入本次对话。该链接只能使用一次,并且会很快过期。使用 9pm env ls <app> 确认密钥现已存在(仅显示名称——无法读取密钥值),然后重新部署。只有在设置非密钥、可读取的配置值时才传入 --var。
文件
无需处理存储凭据,即可存储生成的产物或分享文件:
9pm files list [--project app-name]
9pm files put <local-path> [--path remote/path] [--project app-name]
9pm files get <file-id> [--output ./local] [--project app-name]
9pm files share <file-id> [--expires 24h] [--public] [--project app-name]
9pm files delete <file-id> [--project app-name]
删除
9pm delete 会销毁应用、托管数据库、托管容器应用、保留的镜像和项目文件。数据行无法恢复。应用地址也会一并失效:使用相同 slug 重新创建应用时,会获得一个新地址,因此已打印的链接和二维码将无法使用。如果 9pm delete 输出 Its address stops serving the app within about a minute.,请转告用户这一点。如果没有输出(例如,应用使用了自定义域名,或某些容器应用),不要承诺任何时间:在告知用户内容已消失前,先自行打开该地址确认。运行命令前,必须向用户逐字说明这一点,并取得明确确认,即使用户已要求删除也不例外。
9pm delete app-name --confirm app-name
遇到故障时
大多数故障都可以自行修复——先阅读 CLI 输出的错误信息,然后在升级处理前按以下列表逐项排查:
- **身份验证 / “not signed in” / 401:**运行
9pm doctor(检查已存储的凭据和连通性),然后运行9pm whoami确认你的身份。如果凭据缺失或已过期,请重新运行9pm login。 - **“Unknown command” 或行为与本指南不符:**可能是 CLI 版本过旧。运行
9pm latest,如果版本落后,请升级(参见 版本管理)。 - **在上传前部署被拒绝(对
api.9pm.ai的调用遭到阻止):**你处于出站连接受限的沙盒环境中。请信任api.9pm.ai,或添加 9pm 连接器(参见 沙盒环境)——这两种方式都需在会话开始前从沙盒外部配置。(npx ninepm本身无需加入允许列表。) - **部署/构建失败:**CLI 会打印原因。常见的解决方法:确保构建输出目录存在且非空;重新运行
9pm deploy <dir> --check,在部署前验证目录;对于--runtime container,确认镜像可在本地构建,且应用监听0.0.0.0:$PORT;如果请求的能力(始终运行、卷、自定义规格)遭到拒绝,可能是此账户未启用该功能——请移除该能力或升级处理。 - **
asset_file_too_large:**单个静态文件超过每个文件 25 MiB 的限制(参见 支持的应用形态)。这是确定性问题——不要重试。请拆分超大资源文件(通常是大型 WASM 二进制文件),或将应用部署为容器。
如果按上述步骤处理后仍无法解决,请提供相关背景信息并升级处理。在终端中:
9pm support --site app-name -m "<what you tried and what happened>"
9pm support -m "<what happened>" # when it isn't about a specific app
这会提交一条可跟踪的请求,并自动附上你的账户信息,以及(使用 --site 时)应用最近一次部署的状态和错误——无需手动复制这些信息。人工回复会发送到操作者账户的电子邮件地址,而不是此对话。