先看全景:这不是一个服务,而是一套平台
上一版课程偏重“发一条消息会经过什么”。这一版我们先把镜头拉远:`im-app-server`、`im-admin-server`、AWS 运行层与发布脚本,合起来才是你真正要指挥的 IM 平台。
先认角色,再看细节
你可把它想成一座商场。`im-app-server` 面向普通用户,像前台营业区;`im-admin-server` 面向租户管理员、开放平台和运营动作,像后场管理区;AWS 则是整栋楼的水电、仓储与安保系统。
入口层
应用层
基础设施层
若你一开始只盯某个控制器,就容易误把“业务代码问题”当成“架构分工问题”。真正成熟的排障,先问哪一层,再问哪一行。
三个目录,各管一摊
把仓库拆成三块看,会比把几百个文件一把抓更容易。你以后让 AI 干活,也该先点名“去哪块地里挖”。
真实代码: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`?
如果你要查“某次上线后为何版本没有切到新镜像”,最该优先看哪里?
门禁、身份与租户
无论请求最终去 `im-app-server` 还是 `im-admin-server`,都逃不过一条底线:先认人,再认租户,再决定你能看哪份数据。多租户系统最怕的不是慢,而是串线。
同一套服务,为什么不会把甲公司的数据给乙公司
秘密不在某个超复杂算法,而在一串 中间件。认证回答“你是谁”,租户中间件回答“你该看哪一摊数据”。这类规则一旦写错,比普通功能 bug 更危险。
请求入口
门禁链路
业务层
看一场中间件群聊
把请求当成一个访客,就容易理解这一串检查。谁先说话,谁后放行,顺序绝不能乱。
很多人把“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`。第一站该查哪里?
为什么系统不把“认证”和“租户校验”写成一个大函数?
如果用户看到了别的租户的数据,你会先怀疑哪一层?
数据与 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 说“这块应该放哪”,说的就是这一层判断。
结构稳定、关系复杂、需要事务一致
吞吐高、访问模式偏按键与时序
极快读取、可重算、适合短期状态
大对象、媒体文件、要和 CDN 配合
先接住事件,后续慢慢处理和重试
小测:你会怎样做 AWS 侧取舍
如果用户大量上传视频,哪种做法更像这套架构的思路?
为什么“未读数”这类状态更适合 Redis,而不是只靠 PostgreSQL 现算?
系统为何把部分任务丢给 SQS,而不是每次请求同步做完所有收尾?
实时消息与 Ably:为何不自己扛全套 WebSocket
这套平台没有把“实时连接管理、频道发布、在线状态、推送设备管理”全压在业务服务上,而是借 Ably 拿到一条更轻的主链。是否值得,取决于你的复杂度与团队成本。
Ably 在这里,不只是“发消息 SDK”
从代码看,它承担了 Token 授权、频道收发、批量发布、推送设备与在线态相关能力。换句话说,这里不是“业务服务顺手连了个推送工具”,而是把实时基础设施外包给了托管服务。
Token 授权
业务服务只签权限票据,不亲自维护所有实时连接,职责更清楚。
频道分发
单聊 mailbox、群聊 channel、会议 signal 都走统一实时通道,路径短、感知快。
推送配套
设备管理与批量推送能力已在同一服务栈里,减少自建消息网关成本。
业务服务减负
App 服务不必自己扛海量长连接,更多精力放在鉴权、持久化、未读与业务规则。
看数据怎么在实时层与后台层分流
真正聪明的地方不只是“用了 Ably”,而是“用了 Ably 以后,业务服务没有被绑死”。消息先被实时送达,后面再用 SQS 和消费者做持久化与派生。
真实代码: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 路线?
若用户说“对方已收到消息,但未读数和历史列表不同步”,你第一反应应是什么?
im-admin-server:后台、运营与开放平台的独立后场
`im-admin-server` 不是“给管理页凑个接口”。它单独承接租户后台、聊天分享、共享会议、翻译中台与开放平台接口,所以值得被当作第二个核心服务看待。
它到底管哪些事
若 `im-app-server` 是面向终端用户的营业前台,`im-admin-server` 就像商场的运营后台。它关注租户配置、管理员身份、外部系统接入和运营动作,而不是普通用户每天点开的主聊天界面。
管理员登录、租户配置、用户查询、角色权限、标签、组织与群管理都在这里。
共享会议、邀请消息、聊天分享与报表类接口都偏运营流程,不宜混进用户主链。
创建开放应用、校验租户域名、拉会话记录、做翻译,这些都更像平台接口而非终端用户接口。
同一服务里统一编排 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 四种翻译通道。
这样做的好处不是“炫技”,而是让成本、质量、可用性和区域可用性可以被运营化调度。
真正的平台型后端,常常不止做功能,还要做供应商编排与降级。
它们更偏租户运营、内容治理、开放能力和后台工作流,不该拖累普通用户聊天主链,也不该和用户权限混成一锅。
小测:你会把需求放到正确服务吗
如果需求是“租户管理员批量调整某些用户的后台访问配置”,你更该先查哪个服务?
为什么翻译能力更适合被封进统一服务,而不是每个接口自己去调 OpenAI 或 Google?
哪个观察最能证明 `im-admin-server` 已经是独立平台服务,而非单纯后台页面接口?
部署链路、运行形态与行业取舍
到最后一章,臣不想只告诉陛下“它现在怎么跑”,还要告诉陛下“为什么这么跑、何时该换、业界通常怎么选”。这才是架构视角真正有用的地方。
从脚本看,这套平台上线不是一键黑箱
`spug/aws` 目录把发布拆成了构建、升级、迁移、环境变量同步与版本校验几个环节。拆得越清楚,越容易审计,也越容易在某一步失败时止损。
Git tag 触发构建,把 `im-app-server` 与 `im-admin-server` 分别打成 `.server` / `.admin` 两个镜像标签。
镜像先入 ECR,再由升级脚本注册新任务定义并更新服务。
PostgreSQL 与 DynamoDB 迁移被单独包装成 ECS 一次性任务,而不是混进应用启动里赌运气。
脚本会把配置中心数据同步进 ECS 与 Secrets Manager,并区分敏感项。
发布脚本还会检查版本接口、回报构建状态,并把关键信息发到飞书。
真实代码:一条 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 来说,这是一条很典型的“按数据性格拆层”的行业路线。
大多数架构事故,不是因为没选最潮的技术,而是因为在没有足够收益前提下,过早改底座。