01

先看全景:这不是一个服务,而是一套平台

上一版课程偏重“发一条消息会经过什么”。这一版我们先把镜头拉远:`im-app-server`、`im-admin-server`、AWS 运行层与发布脚本,合起来才是你真正要指挥的 IM 平台。

先认角色,再看细节

你可把它想成一座商场。`im-app-server` 面向普通用户,像前台营业区;`im-admin-server` 面向租户管理员、开放平台和运营动作,像后场管理区;AWS 则是整栋楼的水电、仓储与安保系统。

入口层

🌐
公网入口 / ALB

应用层

💬
im-app-server
🛠️
im-admin-server

基础设施层

☁️
ECS / Fargate + 数据层
🚀
spug/aws 脚本
逐个点卡片。先看平台分层,再回到代码,理解会快得多。
💡
为什么先看全景

若你一开始只盯某个控制器,就容易误把“业务代码问题”当成“架构分工问题”。真正成熟的排障,先问哪一层,再问哪一行。

三个目录,各管一摊

把仓库拆成三块看,会比把几百个文件一把抓更容易。你以后让 AI 干活,也该先点名“去哪块地里挖”。

im-app-server/ 对终端用户开放的主业务后端:登录、消息、群组、上传、会话、消费者。
im-admin-server/ 租户后台与运营后端:管理员登录、租户配置、开放平台、翻译、聊天分享、共享会议。
spug/aws/ 部署与运行脚本:构建镜像、升级 ECS、跑 PG/DDB 迁移、同步配置与 Secret。

真实代码:Admin 服务先放公开接口,再进后台授权区

这段入口代码很能说明 `im-admin-server` 的定位。它不是一个静态后台页面接口,而是一套带公开分享口、开放平台口、再加后台受权区的独立服务。

代码

app.use(bodyParser());
app.use(SetRequest);
app.use(Access);
app.use(apiRoutes.base.router.routes());
app.use(apiRoutes.chatShare.publicRouter.routes());
app.use(apiRoutes.openPlatform.router.routes());
app.use(Authorize);
app.use(apiRoutes.admin.router.routes());
app.use(apiRoutes.tenant.router.routes());
app.use(apiRoutes.referral.router.routes());
app.use(apiRoutes.translation.router.routes());
app.use(apiRoutes.sharedMeeting.router.routes());
app.use(apiRoutes.externalData.router.routes());
app.use(apiRoutes.chatShare.router.routes());
          
白话

先装请求体解析、上下文与基础访问控制,这是所有接口都要走的底层工序。

`base`、公开分享 `chatShare.publicRouter`、开放平台 `openPlatform` 被放在授权中间件之前,说明它们有独立认证方式或公开入口。

`Authorize` 从这里开始像一道闸门,后面的 `admin`、`tenant`、`translation`、`sharedMeeting` 等都属于后台受控区域。

所以 `im-admin-server` 是“多入口的后台服务”,不是单纯的管理页 BFF。

小测:你已经分清平台边界了吗

若需求是“普通用户发送一条群消息并更新未读数”,你首先应把 AI 引到哪块?

为什么这个仓库把 `im-admin-server` 单独拉出来,而不是全塞进 `im-app-server`?

如果你要查“某次上线后为何版本没有切到新镜像”,最该优先看哪里?

02

门禁、身份与租户

无论请求最终去 `im-app-server` 还是 `im-admin-server`,都逃不过一条底线:先认人,再认租户,再决定你能看哪份数据。多租户系统最怕的不是慢,而是串线。

同一套服务,为什么不会把甲公司的数据给乙公司

秘密不在某个超复杂算法,而在一串 中间件。认证回答“你是谁”,租户中间件回答“你该看哪一摊数据”。这类规则一旦写错,比普通功能 bug 更危险。

请求入口

📱
客户端请求

门禁链路

🪪
Authentication
🏢
resolveTenant
validateTenantAccess

业务层

🗂️
Controller / Repo
点任一模块,看它在门禁链中的职责。

看一场中间件群聊

把请求当成一个访客,就容易理解这一串检查。谁先说话,谁后放行,顺序绝不能乱。

⚠️
常见误判

很多人把“401 未登录”和“400/404 租户问题”混成一团。这里其实是两层不同的门禁,修法完全不同。

真实代码:租户 ID 是怎样找出来的

下面这段是 `tenant.js` 的核心片段。顺序很关键,因为它定义了系统优先信谁,也决定了一个 UUID 格式的租户 ID 从哪里来。

代码

    // Priority 1: request header
    tenantId = ctx.headers[TenantConfig.HEADER_NAME.toLowerCase()];

    // Priority 2: tenant ID from JWT token
    if (!tenantId && ctx.state.user && ctx.state.user.tenant_id) {
      tenantId = ctx.state.user.tenant_id;
    }

    // Priority 3: query parameter
    if (!tenantId && ctx.query[TenantConfig.QUERY_PARAM_NAME]) {
      tenantId = ctx.query[TenantConfig.QUERY_PARAM_NAME];
    }

    // Tenant ID must exist
    if (!tenantId) {
      ctx.throw(400, "Tenant ID is required");
    }

    // Validate tenant ID format
    if (tenantId && !isValidUUID(tenantId)) {
      ctx.throw(400, "Invalid tenant ID format");
    }

    // Persist tenant ID
    ctx.state.tenantId = tenantId;
          
白话

先信请求头。这通常是客户端最明确地告诉服务器:我要进哪个租户。

如果头里没有,再看登录后的 JWT 数据。也就是“你上次登录时系统记住的租户”。

前两处都拿不到,才退而求其次去查 Query 参数。

如果三处都没有,系统直接打回,因为它根本不知道你该看哪份数据。

就算有值,也要校验 UUID 格式,避免拿一串乱值继续向后传染。

最后把结果挂进 `ctx.state`,这样后面的控制器和仓储层都能复用。

小测:你能看出是哪道门没开吗

用户明明带着有效登录态,却收到 `Tenant ID is required`。第一站该查哪里?

为什么系统不把“认证”和“租户校验”写成一个大函数?

如果用户看到了别的租户的数据,你会先怀疑哪一层?

03

数据与 AWS 组件:为何不是一库到底

这个平台选了 PostgreSQL、DynamoDB、Redis、S3、CloudFront、SQS 组合拳。看上去复杂,其实每一件都在解决一种不同的数据性格与流量压力。

先别背服务名,先认数据性格

最实用的架构眼光,不是记住 AWS 名词,而是先问:这份数据更像账本、流水、临时状态,还是大文件?问对这句,选型通常就八九不离十。

🧾

Aurora PostgreSQL

适合用户、租户、好友、群组、角色这类关系密、查询规则复杂、要事务一致的核心业务数据。

💬

DynamoDB

扛聊天正文这类高写入、按会话和时间翻页读取的数据,避免主库被海量消息拖慢。

Redis

拿来装未读数、在线状态、缓存等“要快、可重算、可失效”的状态值。

📦

S3 + CloudFront

文件不经业务服务中转,而是直传对象存储,再通过 CloudFront 或签名 URL 做下载与加速。

📮

SQS

把异步任务从主请求链摘出来,用来削峰、解耦、重试与后台补账。

💡
选型不是“哪个更高级”,而是“哪种压力更适合谁”

行业里常见的好架构,几乎都不是单库万能,而是把一致性、吞吐、延迟、成本分给不同组件去扛。

真实代码:下载链路区分公开 URL 与签名 URL

这段来自 `cloudfront-client.js`。它说明文件访问不是一刀切,而是按场景决定“直接公开”还是“临时签名后访问”。

代码

const cloudfrontClient = isLocal
  ? {
      getSignedUrl: async (objectKey, options = {}) => {
        return await s3Client.getPresignedUrl(
          process.env.S3_PRIVATE_BUCKET,
          objectKey,
          options
        );
      },
      getPublicUrl: (objectKey) => {
        const endpoint = process.env.S3_ENDPOINT || "http://localhost:4566";
        const bucket = process.env.S3_PUBLIC_BUCKET;
        return `${endpoint}/${bucket}/assets/${objectKey}`;
      },
    }
  : new CloudFrontClientWrapper({
      region: process.env.AWS_REGION,
      distributionDomain: process.env.CLOUDFRONT_DOMAIN,
      keyPairId: process.env.CLOUDFRONT_KEY_PAIR_ID,
      privateKeyPath: process.env.CLOUDFRONT_PRIVATE_KEY_PATH,
    });
          
白话

本地环境先走简化模式:私有文件直接生成预签名 S3 地址,公开文件拼一个公开 URL。

真正线上环境则交给 `CloudFrontClientWrapper`,说明文件访问已被当成正式能力来治理。

它要求区域、分发域名、密钥对 ID 与私钥路径,表示私有内容访问是带签名校验的。

这比“把所有文件都挂成公网地址”更稳,也比“每次都让业务服务器自己转发文件”更省资源。

拖一拖:把数据交给更像它的服务

这不是考试,而是训练你的架构直觉。以后你跟 AI 说“这块应该放哪”,说的就是这一层判断。

租户配置与角色权限
聊天消息正文
在线状态与未读角标
图片、视频、附件原文件
补发通知、异步修正、后台派生任务

结构稳定、关系复杂、需要事务一致

Drop here

吞吐高、访问模式偏按键与时序

Drop here

极快读取、可重算、适合短期状态

Drop here

大对象、媒体文件、要和 CDN 配合

Drop here

先接住事件,后续慢慢处理和重试

Drop here

小测:你会怎样做 AWS 侧取舍

如果用户大量上传视频,哪种做法更像这套架构的思路?

为什么“未读数”这类状态更适合 Redis,而不是只靠 PostgreSQL 现算?

系统为何把部分任务丢给 SQS,而不是每次请求同步做完所有收尾?

04

实时消息与 Ably:为何不自己扛全套 WebSocket

这套平台没有把“实时连接管理、频道发布、在线状态、推送设备管理”全压在业务服务上,而是借 Ably 拿到一条更轻的主链。是否值得,取决于你的复杂度与团队成本。

Ably 在这里,不只是“发消息 SDK”

从代码看,它承担了 Token 授权、频道收发、批量发布、推送设备与在线态相关能力。换句话说,这里不是“业务服务顺手连了个推送工具”,而是把实时基础设施外包给了托管服务。

🎫

Token 授权

业务服务只签权限票据,不亲自维护所有实时连接,职责更清楚。

📡

频道分发

单聊 mailbox、群聊 channel、会议 signal 都走统一实时通道,路径短、感知快。

🔔

推送配套

设备管理与批量推送能力已在同一服务栈里,减少自建消息网关成本。

🧮

业务服务减负

App 服务不必自己扛海量长连接,更多精力放在鉴权、持久化、未读与业务规则。

看数据怎么在实时层与后台层分流

真正聪明的地方不只是“用了 Ably”,而是“用了 Ably 以后,业务服务没有被绑死”。消息先被实时送达,后面再用 SQS 和消费者做持久化与派生。

📱
客户端
🛂
im-app-server
Ably
📮
SQS
🧰
Consumer
点“下一步”看实时链与后台链怎样分开

真实代码:Token 能做什么,基本写死在这一处

这段码是实时边界的钥匙孔。你要理解 Ably 在项目中的地位,看这一段就够了。

代码

    const token = await ablyservice.createTokenRequest(userHandle, {
      capability: {
        "chat:mailbox:*": [
          "publish",
          "subscribe",
          "history",
          "presence",
          "push-subscribe",
        ],
        "chat:group:*": [
          "publish",
          "subscribe",
          "history",
          "presence",
          "push-subscribe",
        ],
        "chat:ack": ["publish"],
      },
      ttl: 60 * 60 * 1000,
    });
          
白话

业务服务不是直接替客户端开频道,而是签一张限权 Token。

mailbox、group、ack 被拆成不同频道类型,说明实时系统内部也在分职责。

这里不只有发和收,还有历史、在线态、推送订阅等能力位。

所以 Ably 在本架构中更像“托管式实时总线”,不是单点消息 SDK。

🧭
那有没有更好的选择

没有绝对更好,只有更适合。若你要极快起量、又不想自养连接集群,Ably 很顺手;若你已深度押注 GraphQL,可考虑 AppSync;若需求更轻,Pusher 也常见;若规模巨大且团队强,自建 WebSocket 网关也并非不可。

小测:什么时候该继续用 Ably,什么时候考虑换

一个小团队要快速上线多端 IM,且不想先维护大规模长连接集群,哪种取舍最像当前架构?

什么时候你更可能认真评估 AppSync 一类替代,而不是继续沿 Ably 路线?

若用户说“对方已收到消息,但未读数和历史列表不同步”,你第一反应应是什么?

05

im-admin-server:后台、运营与开放平台的独立后场

`im-admin-server` 不是“给管理页凑个接口”。它单独承接租户后台、聊天分享、共享会议、翻译中台与开放平台接口,所以值得被当作第二个核心服务看待。

它到底管哪些事

若 `im-app-server` 是面向终端用户的营业前台,`im-admin-server` 就像商场的运营后台。它关注租户配置、管理员身份、外部系统接入和运营动作,而不是普通用户每天点开的主聊天界面。

1
租户后台

管理员登录、租户配置、用户查询、角色权限、标签、组织与群管理都在这里。

2
运营动作

共享会议、邀请消息、聊天分享与报表类接口都偏运营流程,不宜混进用户主链。

3
开放平台

创建开放应用、校验租户域名、拉会话记录、做翻译,这些都更像平台接口而非终端用户接口。

4
翻译中台

同一服务里统一编排 OpenAI、AWS Translate、Google Translate,不把多供应商逻辑散落到处都是。

真实代码:它的开放平台已经是一套独立接口面

下面这段路由定义很能说明问题。`im-admin-server` 不只给管理员页面用,它还对外提供应用管理、会话读取和翻译能力。

代码

router.post(
  "/api/v1/open-platform/apps",
  OpenPlatformManageAuthorize,
  CreateOpenApp,
);
router.get(
  "/api/v1/open-platform/session-records/conversations",
  OpenPlatformAuthorize,
  GetSessionConversations,
);
router.get(
  "/api/v1/open-platform/session-records/messages",
  OpenPlatformAuthorize,
  GetSessionMessages,
);
router.post(
  "/api/v1/open-platform/translation/translate",
  OpenPlatformAuthorize,
  TranslateText,
);
          
白话

第一组接口是“管应用本身”,比如创建开放应用和管理密钥。

第二组接口开始直接开放会话记录与消息读取,说明后台服务已经深入到了审计、平台接入和运营场景。

再往后连翻译能力都被纳入开放平台接口,表明它不只是内部后台,而是一个平台能力出口。

真实代码:翻译服务为何像个小型编排器

这一段最像中台思路。它不把“翻译”绑定死在某一家供应商,而是统一封装 provider 选择、缓存和 fallback。

代码

    switch (currentProvider) {
      case "aws":
        return this.awsClient.translate(params);
      case "openai":
        return this.openaiClient.translate(params);
      case "google":
        return this.googleClient.translate(params);
      case "google_llm":
        return this.googleClient.translateWithLLM(text, targetLang, sourceLang);
      default:
        throw new BaseError(
          BizErrorCode.ParamsError.code,
          `Unsupported translation provider: ${currentProvider}`
        );
    }
          
白话

同一业务入口背后,可以切 AWS、OpenAI、Google、Google LLM 四种翻译通道。

这样做的好处不是“炫技”,而是让成本、质量、可用性和区域可用性可以被运营化调度。

真正的平台型后端,常常不止做功能,还要做供应商编排与降级。

💡
为何把这类能力放 Admin 服务

它们更偏租户运营、内容治理、开放能力和后台工作流,不该拖累普通用户聊天主链,也不该和用户权限混成一锅。

小测:你会把需求放到正确服务吗

如果需求是“租户管理员批量调整某些用户的后台访问配置”,你更该先查哪个服务?

为什么翻译能力更适合被封进统一服务,而不是每个接口自己去调 OpenAI 或 Google?

哪个观察最能证明 `im-admin-server` 已经是独立平台服务,而非单纯后台页面接口?

06

部署链路、运行形态与行业取舍

到最后一章,臣不想只告诉陛下“它现在怎么跑”,还要告诉陛下“为什么这么跑、何时该换、业界通常怎么选”。这才是架构视角真正有用的地方。

从脚本看,这套平台上线不是一键黑箱

`spug/aws` 目录把发布拆成了构建、升级、迁移、环境变量同步与版本校验几个环节。拆得越清楚,越容易审计,也越容易在某一步失败时止损。

1
构建镜像

Git tag 触发构建,把 `im-app-server` 与 `im-admin-server` 分别打成 `.server` / `.admin` 两个镜像标签。

2
推送 ECR 与更新 ECS

镜像先入 ECR,再由升级脚本注册新任务定义并更新服务。

3
跑迁移

PostgreSQL 与 DynamoDB 迁移被单独包装成 ECS 一次性任务,而不是混进应用启动里赌运气。

4
同步环境与 Secret

脚本会把配置中心数据同步进 ECS 与 Secrets Manager,并区分敏感项。

5
验证版本与通知

发布脚本还会检查版本接口、回报构建状态,并把关键信息发到飞书。

真实代码:一条 Git tag 会产出两张镜像

这段来自构建工作流。它清楚说明:虽然仓库是一个 monorepo,但运行产物是两套独立镜像。

代码

      - name: Build and push im-app-server image
        uses: docker/build-push-action@v5
        with:
          context: ./im-app-server
          file: ./im-app-server/Dockerfile
          push: true
          tags: |
            ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ steps.metadata.outputs.TAG }}.server
            ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest.server

      - name: Build and push im-admin-server image
        uses: docker/build-push-action@v5
        with:
          context: ./im-admin-server
          file: ./im-admin-server/Dockerfile
          push: true
          tags: |
            ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ steps.metadata.outputs.TAG }}.admin
            ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest.admin
          
白话

同一个仓库里,`im-app-server` 和 `im-admin-server` 分别按自己的 Dockerfile 构建。

镜像标签也故意拆成 `.server` 和 `.admin`,避免发布时把两者混淆。

这意味着它们可以一起发版,也可以在部署脚本里选择只升级其中一个组件。

那么,更好的选择有吗

更好的意思,永远要补一句“对谁、更在哪”。下面这些是业界常见替代路径。没有一条是普世答案,但你至少该知道什么时候值得认真评估。

🚢

ECS Fargate vs Kubernetes

若团队更重交付速度与较低运维,Fargate 很合适;若你要跨云、复杂调度、统一平台团队,业界常转 EKS/Kubernetes。

📡

Ably vs Pusher vs AppSync

Ably 偏全能托管实时总线;Pusher 更轻;AppSync 更适合 GraphQL 订阅一体化。团队已有技术重心,常决定答案。

🧵

SQS vs Kafka / RabbitMQ

SQS 简洁、云原生、够稳;若你要强回放、复杂流处理、长保留与多消费者分析,业界会考虑 Kafka。若你更重复杂路由,可看 RabbitMQ。

🗄️

Aurora + DynamoDB + Redis 是否过重

对小团队或早期产品,也许偏重;但对多租户 IM 来说,这是一条很典型的“按数据性格拆层”的行业路线。

⚠️
别把“能换”误听成“该换”

大多数架构事故,不是因为没选最潮的技术,而是因为在没有足够收益前提下,过早改底座。

小测:何时该守、何时该换

团队只有少数后端,当前最痛的是发版稳不稳,不是跨云调度。此时对运行平台更务实的态度是什么?

什么时候你更可能认真评估 Kafka,而不是继续沿 SQS 路线加码?

若有人问“Ably、SQS、DynamoDB 会不会太多了”,最成熟的回答起点是什么?