深入 Papercups:基于 Elixir/Phoenix 的自托管客服系统与 conversations REST API 全流程实战

原创2026-10-14 15:54:411,695 阅读
文章标签:后端前端即时通讯

深入 Papercups:基于 Elixir/Phoenix 的自托管客服系统与 conversations REST API 全流程实战

Papercups 是一个用 Elixir 编写的开源实时客服 Web 应用,定位是为对客户数据隐私、安全性有顾虑的团队提供一款可以完全自托管、像 Zendesk / Intercom 一样的客服工具。本文将以仓库中的实战笔记 docs/examples/add_conversations_api.md 为骨架,完整复现"从零为 conversations 资源新增一套 REST API"的 Phoenix 开发流程,并结合当前仓库的真实源码(Ecto Schema、Migration、Context、Controller、View、Router 与测试)逐层验证每个步骤背后的实现原理,帮助你掌握在 Papercups 这类 Phoenix 项目上扩展后端 API 的完整套路。

一、项目定位:为什么要自托管客服系统

在动手改代码之前,先理解 Papercups 的核心设计动机。根据仓库根目录 README.md 的说明,它的哲学是:

我们想做一款可以自托管的客服工具,专门服务于那些担心客户数据流向第三方服务、有隐私与安全顾虑的公司。

在此基础上,Papercups 提供了以下能力(对应 README 的 Features 清单,且均能在本仓库源码中找到实现):

  • 邮件回复:把邮件转发到 Papercups 工单进行回复(对应 lib/chat_api_emails 相关模块 与 SES/Gmail 邮件管线);
  • SMS 回复:转发 Twilio 会话,直接在 Papercups 中回复短信(Twilio 集成模块);
  • 可定制聊天组件:通过一段 HTML 或 React 组件嵌入到你的网站(构建产物见 assets/public/widget.js,前端依赖见 assets/package.json);
  • React / React Native / Flutter 支持:聊天组件可以嵌入多种客户端;
  • Slack / Mattermost 集成:直接在 Slack 或 Mattermost 频道里查看和回复消息;
  • Markdown 与 emoji 支持:消息正文支持 markdown 与表情;
  • 邀请团队成员、会话管理:可以关闭、指派、设置优先级会话;
  • 基于 Elixir 构建:充分利用 Erlang VM 的高响应性、容错性与实时更新能力。

需要特别说明的是,README 顶部标注了 Maintenance Mode(维护模式):项目近期不会有大版本新功能,但仍会接受 PR 并修复主要 Bug。这一点在评估扩展方向时值得留意。

二、快速启动:Heroku 一键部署与 docker-compose 自托管

1. Heroku 一键部署

README 提供了最快上手方式:在 Heroku 上点击 Deploy 按钮(模板指向本仓库)即可一键部署,这也是官方托管的快捷路径。仓库根目录的 app.json 与 Procfile 就是为 Heroku 构建准备的。

2. 使用 docker-compose 本地自托管

如果你更倾向于完全自托管,仓库提供了现成的 docker-compose.yml,包含 papercups 应用容器与 postgres:alpine 数据库容器两个服务:

version: "3.2"

services:
  papercups:
    image: papercups/papercups:latest
    restart: always
    ports:
      - "3000:3000"
      - "4000:4000"
    command: sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && echo 'running' && /entrypoint.sh run"
    depends_on:
      - db
    environment:
      DATABASE_URL: "ecto://postgres:postgres@db/postgres"
      # PUT YOUR OWN SECRET KEYBASE HERE (MUST BE AT LEAST 64 BYTES)
      SECRET_KEY_BASE: "dvPPvOjpgX2Wk8Y3ONrqWsgM9ZtU4sSrs4l/5CFD1sLm4H+CjLU+EidjNGuSz7bz"
      BACKEND_URL: "localhost"
      MIX_ENV: "prod"
      REQUIRE_DB_SSL: "false"
      REACT_APP_FILE_UPLOADS_ENABLED: 1
      # Replace with your domain name
      REACT_APP_URL: "app.papercups.io"
  db:
    image: postgres:alpine
    restart: always
    container_name: papercups
    ports:
      - "5432:5432"
    environment:
      POSTGRES_PASSWORD: postgres

关键点解读:

  • 容器入口会依次执行 db createdb、db migrate,然后启动应用,即"建库 → 迁移 → 运行"三步完成初始化;
  • DATABASE_URL 指向 compose 内部的 db 服务,格式为 ecto://USER:PASS@HOST/DATABASE;
  • SECRET_KEY_BASE 是 Phoenix 用于签名/加密的密钥,必须替换为你自己生成的至少 64 字节随机串(可用 mix phx.gen.secret 生成,见 config/runtime.exs 中对缺失变量的报错提示);
  • BACKEND_URL 是后端对外域名(生产环境必填,缺失时 runtime 配置会直接 raise);
  • REQUIRE_DB_SSL: "false" 在自托管内网数据库场景下关闭 SSL 要求(默认值为 true)。

3. 本地开发启动

从 mix.exs 可以看到项目定义了一组 Mix aliases:

setup: ["deps.get", "ecto.setup"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"]

本地开发流程通常是:mix setup(拉依赖、建库、迁移、跑种子数据)→ mix phx.server。开发模式下前端 React 应用通过 watcher 由 Phoenix 自动拉起(见 config/dev.exs 中指向 react-scripts start 的 watchers 配置),并通过 assets/package.json 中的 proxy: "http://localhost:4000" 把开发服务器的 API 请求代理到后端 4000 端口。生产静态资源则由 postbuild 脚本将 assets/build 拷入 priv/static。

三、从零新增 conversations REST API:完整实操流程

下面进入本文的核心:按 docs/examples/add_conversations_api.md 的笔记,走一遍"为 conversations 资源新增 API 端点"的全过程。前提是你已经按上文准备好 Phoenix 1.5 开发环境(项目要求 elixir: "~> 1.10",见 mix.exs)。

1. 生成 Conversations Context

第一步是生成 Conversation 的 schema/模型及其 Context,并附带 conversations 表的初始字段:

mix phx.gen.context Conversations Conversation conversations status:string

该命令会创建以下四个文件:

lib/chat_api/conversations/conversation.ex
priv/repo/migrations/20200710123644_create_conversations.exs
lib/chat_api/conversations.ex
test/chat_api/conversations_test.exs

各文件职责(笔记原文要点 + 仓库现状验证):

  • lib/chat_api/conversations/conversation.ex:定义 Conversation 模型的 Ecto Schema。如今仓库中该 schema 已经扩展为包含 status(默认 "open")、priority(默认 "not_priority")、source(默认 "chat",可选值 chat/slack/email/sms/api/sandbox)、subject、read、archived_at、closed_at、last_activity_at、metadata 等字段,以及 belongs_to(assignee/account/customer/inbox)和 has_many(messages、mentions、tags、issues)等关联;
  • priv/repo/migrations/20200710123644_create_conversations.exs:建表迁移,初始版本仅含 id(binary_id 主键)与 status 两个字段:
defmodule ChatApi.Repo.Migrations.CreateConversations do
  use Ecto.Migration

  def change do
    create table(:conversations, primary_key: false) do
      add(:id, :binary_id, primary_key: true)
      add(:status, :string)

      timestamps()
    end
  end
end

提示:项目全局启用了 generators: <a href="https://link.gitcode.com/i/c73fb173d4e34e2550409e81c3d9e7e1" target="_blank">binary_id: true](见 [config/config.exs),因此生成的主键与关联外键都是 UUID 类型。

2. 建立 conversations 与 messages 的关联

生成基础结构后,为了让会话与消息关联,需要再生成一个迁移,为 messages 表增加 conversation_id 外键:

mix ecto.gen.migration add_conversation_id_to_messages

该命令生成新的迁移脚本(仓库中即 priv/repo/migrations/20200710124618_add_conversation_id_to_messages.exs),在其中添加外键:

defmodule ChatApi.Repo.Migrations.AddConversationIdToMessages do
  use Ecto.Migration

  def change do
    alter table(:messages) do
      add(:conversation_id, references(:conversations, type: :uuid, on_delete: :delete_all))
    end

    create(index(:messages, [:conversation_id]))
  end
end

要点:

  • on_delete: :delete_all 表示删除会话时级联删除其消息;
  • type: :uuid 与 binary_id 主键配套;
  • 同时为 conversation_id 建了索引,保证按会话查消息的高效性。

随后在 Conversation 与 Message 两个 schema 中补充 has_many / belongs_to 关系。仓库中这两个文件均已实现(可对照 lib/chat_api/conversations/conversation.ex 与 lib/chat_api/messages/message.ex):

# in conversation.ex
alias ChatApi.Messages.Message

schema "conversations" do
  # ...
  has_many(:messages, Message)
  # ...
end

# and in message.ex
alias ChatApi.Conversations.Conversation

schema "messages" do
  # ...
  belongs_to(:conversation, Conversation)
  # ...
end

3. 查询中预加载关联数据

建立关联后,要在查询会话时一并取出消息,只需调用 Repo.preload(:messages):

def get_conversation!(id) do
  Conversation |> Repo.get!(id) |> Repo.preload(:messages)
end

在仓库实际代码中,这一思路被进一步放大:列表查询 list_conversations_by_account_paginated/3(见 lib/chat_api/conversations.ex)不仅 preload 了 :customer、mentions: [:user],还 preload 了嵌套的 messages: [:attachments, :customer, user: :profile],并按 last_activity_at desc, id desc 排序后交给 Repo.paginate_with_cursor 做游标分页——这比笔记里的单条 preload 更进一步,是生产级列表接口的标准写法。

4. 生成控制器与 JSON 视图

运行下面的命令生成 conversations API 的 controller 与 JSON views:

mix phx.gen.json Conversations Conversation conversations status:string --no-context --no-schema

(注意:因为第一步已经生成了 context 与 schema,这里必须传 --no-context 和 --no-schema,避免重复生成。)

生成的文件:

lib/chat_api_web/controllers/conversation_controller.ex
lib/chat_api_web/views/conversation_view.ex
test/chat_api_web/controllers/conversation_controller_test.exs

5. 挂载路由

要让 API 端点跑起来,只需在路由文件中加入资源声明。笔记给出的是受保护路由写法:

# Protected routes
scope "/api", ChatApiWeb do
  pipe_through([:api, :api_protected])

  # ...
  resources("/conversations", ConversationController, except: [:new, :edit])
end

仓库的 lib/chat_api_web/router.ex 中实际存在更完整的路由布局,可对照学习:

  • 公开路由(pipe_through(:api),无需登录):POST /api/conversations 用于客户从聊天组件直接建会话(见第 35-77 行),另外还有 GET /api/conversations/customer、GET /api/conversations/shared 等面向 widget 的公开端点;
  • 受保护路由(pipe_through([:api, :api_protected])):resources("/conversations", ConversationController, except: [:new, :edit, :create]) 提供 index/show/update/delete,同时有 GET /api/conversations/count、GET /api/conversations/unread、POST /api/conversations/:conversation_id/archive、/share、/tags 等业务子端点(第 79-181 行);
  • 公开 API(API Key):scope "/api/v1" 走 pipe_through([:public_api, :api_protected]),也包含 resources("/conversations", ...),面向使用 Personal API Key 的第三方集成。

注意:pipe_through(:api) 中的 lib/chat_api_web/api_auth_plug.ex 负责从 Authorization 请求头解析并校验会话 token(走 Pow 的 CredentialsCache),而 lib/chat_api_web/public_api_auth_plug.ex 则支持 Bearer <token> 形式的 Personal API Key。这些是理解"哪些端点需要鉴权"的关键。

6. 定制视图:区分 basic 与 expanded 响应

笔记强调,要区分"只包含会话基本字段"与"包含关联消息"的两类响应:

# in conversation_view.ex

defmodule ChatApiWeb.ConversationView do
  use ChatApiWeb, :view
  alias ChatApiWeb.ConversationView
  alias ChatApiWeb.MessageView

  def render("index.json", %{conversations: conversations}) do
    %{data: render_many(conversations, ConversationView, "basic.json")}
  end

  def render("show.json", %{conversation: conversation}) do
    %{data: render_one(conversation, ConversationView, "expanded.json")}
  end

  # 区分包含关联消息的 expanded.json 与只含基本字段的 basic.json
  def render("basic.json", %{conversation: conversation}) do
    %{
      id: conversation.id,
      status: conversation.status
    }
  end

  def render("expanded.json", %{conversation: conversation}) do
    %{
      id: conversation.id,
      status: conversation.status,
      messages: render_many(conversation.messages, MessageView, "message.json")
    }
  end
end

仓库版本的 lib/chat_api_web/views/conversation_view.ex 沿用了同样的"basic / expanded"设计,但字段丰富得多:

  • basic.json 输出 id / object / source / created_at / closed_at / last_activity_at / status / read / priority / subject / account_id / customer_id / assignee_id / inbox_id / metadata;
  • expanded.json 在 basic 基础上追加 customer(嵌套 CustomerView)、messages(嵌套 MessageView 的 expanded 渲染)、tags、mentions;
  • index.json 在分页场景下还输出 next / previous / limit / total(来自 Paginator 元数据)。

这套"列表用 basic、详情用 expanded"的视图约定,可以有效控制列表接口的响应体积,值得在新资源上复用。

7. 为受保护路由编写测试

笔记指出,受保护(需登录)路由的测试主要在 setup 中加入 authed_conn:

setup %{conn: conn} do
  user = %ChatApi.Users.User{email: "test@example.com"}
  conn = put_req_header(conn, "accept", "application/json")
  authed_conn = Pow.Plug.assign_current_user(conn, user, [])

  {:ok, conn: conn, authed_conn: authed_conn}
end

(本质是利用 pow 库向请求上下文注入 "current user"。)

仓库中的 test/chat_api_web/controllers/conversation_controller_test.exs 把这一模式发展得更完整,值得作为模板:

  • setup 中使用工厂函数 insert(:account)、insert(:user, ...)、insert(:customer, ...)、insert(:conversation, ...) 构造数据(工厂定义见 test/support/factory.ex),并用 Pow.Plug.assign_current_user 生成 authed_conn;
  • 覆盖 index(校验列表返回的 id 集合)、create(校验 201 与返回体、422 错误)、update(重点断言 status: "closed" 时 closed_at 非空、status: "open" 时 closed_at 为 nil——对应 schema changeset 中 put_closed_and_last_activity_at/1 的自动逻辑)、delete(204 后再次 GET 返回 404)、archive(归档后列表不再出现)、标签增删、以及跨账户越权访问返回 404(authorization is required 测试组)。

这些测试与 lib/chat_api_web/controllers/conversation_controller.ex 中 authorize plug 的实现一一对应:plug 从 conn.assigns.current_user 取 account_id,与目标会话的 account_id 比对,不匹配即返回 404,从源码层面杜绝了租户间数据泄露。

四、从源码看 conversations 资源的技术细节

1. Schema 与 Changeset:状态机式的字段联动

lib/chat_api/conversations/conversation.ex 中值得注意的实现细节是 changeset/2 末尾的 put_closed_and_last_activity_at/1:

defp put_closed_and_last_activity_at(
       %Ecto.Changeset{valid?: true, changes: %{status: status}} = changeset
     ) do
  now = DateTime.utc_now() |> DateTime.truncate(:second)

  case status do
    "closed" ->
      changeset
      |> put_change(:last_activity_at, now)
      |> put_change(:closed_at, now)

    "open" ->
      changeset
      |> put_change(:last_activity_at, now)
      |> put_change(:closed_at, nil)
  end
end

这意味着:只要前端/API 把 status 更新为 "closed",系统会自动写入 closed_at;重新置为 "open" 则清空 closed_at。测试文件中对 closed_at 的断言正好验证了这条业务规则。

2. Context 查询:账户隔离、软删除与过滤器

lib/chat_api/conversations.ex 中所有列表/计数查询都遵守三条硬约束:

  1. where(account_id: ^account_id) —— 强制按账户隔离;
  2. where([c], is_nil(c.archived_at)) —— 排除已归档(软删除)会话;
  3. 可组合的 filter_by_tag / filter_by_mention / filter_by_text —— 支持按标签、@提及、正文关键词过滤,配合 count_conversations_where 实现未读计数(GET /api/conversations/unread 即基于此统计每个 inbox 的未读会话数)。

这些模式对新增任何客户数据资源都有直接参考价值。

3. 运行时配置与第三方依赖

从 config/runtime.exs 可以看到生产环境的完整环境变量面:DATABASE_URL、SECRET_KEY_BASE、BACKEND_URL 为必填;PORT、POOL_SIZE、REQUIRE_DB_SSL、USE_IP_V6、MAILER_ADAPTER(默认 Swoosh.Adapters.Local)、SENTRY_DSN、AWS 系列变量(BUCKET_NAME 默认 papercups-files、SES_FORWARDING_DOMAIN 默认 chat.papercups.io 等)、PAPERCUPS_STRIPE_SECRET、APPSIGNAL_API_KEY 均为可选。自托管时按需配置即可。

五、小结:如何按这套方法论扩展 Papercups

回顾整个流程,一次完整的 Phoenix API 扩展可以归纳为五步:

  1. Context/Schema:mix phx.gen.context 生成模型、迁移、Context 与测试;
  2. 关联与迁移:mix ecto.gen.migration 补外键、建索引,schema 中补 has_many / belongs_to;
  3. 预加载:在 Context 查询中使用 Repo.preload 控制关联数据的加载;
  4. Controller/View/Router:mix phx.gen.json ... --no-context --no-schema 生成控制器与视图,按 basic/expanded 约定设计响应体,在 lib/chat_api_web/router.ex 中决定端点走公开、登录保护还是 API Key 保护;
  5. 测试:用 Pow.Plug.assign_current_user 注入登录态,覆盖 CRUD、状态联动(如 closed_at)与跨账户越权(404)。

对 Papercups 而言,仓库中的 companies、issues、notes、tags、canned_responses、inboxes 等资源全部沿用同一套模式(各自的 controller/view/test 一一对应),因此学会了 conversations 的全流程,就等于掌握了整个项目后端 API 的扩展方法论。需要继续深入时,可以直接阅读 lib/chat_api/conversations.ex、lib/chat_api_web/controllers/conversation_controller.ex 与 test/chat_api_web/controllers/conversation_controller_test.exs 三份核心文件作为参照。

登录后查看全文
papercups