Swee-面试高频问题与回答思路
Swee 是一个 AI 驱动的社交娱乐平台,包含管理后台(UmiJS)、移动端 PWA(Next.js)、后端服务(NestJS)、官网(Next.js)和 H5 页面(Vite)。以下问答覆盖全栈架构设计、核心技术实现、数据库设计、性能优化等面试高频方向。
一、后端架构类
Q1:NestJS 后端的整体架构是怎么设计的?
答:后端基于 NestJS 10 + TypeORM + MySQL 构建,采用模块化分层架构。
模块划分:按业务域拆分为 5 个核心模块:
- Copywriting 模块:多语言文案管理(CRUD + 批量导入/导出 + 微软翻译 API 集成)
- File 模块:文件上传(阿里云 OSS 直传 + CDN 自动刷新)
- Setting 模块:配置管理(JSON 配置 + 版本控制 + 回滚)
- Material 模块:素材库(Materialized Path 树形结构 + 文件夹导航)
- Task 模块:定时任务(9 大新闻源聚合 + Levenshtein 去重 + 飞书推送)
请求处理管道:每个请求经过完整的管道式处理:
请求 → TraceMiddleware → AuthGuard → ValidationPipe → Handler → ResponseInterceptor → AllExceptionsFilter → 响应
(链路追踪ID) (权限校验) (参数校验) (业务处理) (响应格式统一) (异常兜底)
这套管道的好处是新增 API 自动享有完整保护——开发者只需写 Controller 和 Service,链路追踪、权限校验、参数验证、响应格式化、异常处理全部由管道自动完成。
追问:TraceMiddleware 是怎么实现链路追踪的?
TraceMiddleware 在管道最前端为每个请求生成唯一的 traceId(UUID),注入到 request 对象中。后续的所有日志、错误上报都带上这个 traceId。ResponseInterceptor 在响应体中也返回 traceId,前端遇到问题时可以拿着 traceId 让后端定位日志,实现前后端联动排查。
Q2:权限控制是怎么设计的?
答:我设计了一套三级权限体系,从前端到后端层层拦截:
第一级:路由级权限(前端 UmiJS access 插件)
→ 用户登录后拉取权限列表,access 插件自动过滤菜单和路由
→ 无权限的页面直接不显示在菜单中,URL 直接访问也会 403
第二级:组件级权限(前端 useAccess Hook)
→ 页面内部的操作按钮(删除、编辑、导出等)通过 useAccess() 判断显隐
→ 有查看权限但无编辑权限的用户,只能看不能改
第三级:API 级权限(后端 @Auth 装饰器)
→ 即使前端被绕过,后端最终拦截
→ @Auth({ type: 'admin', permissionId: 'system:copywriting:collection' })
@Auth 装饰器的实现原理:
它是一个自定义的复合装饰器,内部组合了 @UseGuards(AuthAdminGuard) 和 @SetMetadata('permissionId', ...)。AuthGuard 执行时:
- 从请求头提取 Token
- 调用外部用户服务验证 Token 并获取用户信息和权限列表
- 从 Reflector 读取
permissionId元数据 - 比对用户权限列表是否包含该
permissionId - 通过后将用户信息注入到
request.adminUserInfo,Service 层可直接使用
Controller 只需一行装饰器声明权限,业务代码完全不用关心权限逻辑。这是 NestJS 装饰器模式的典型应用。
追问:这个设计有什么不足?
有一个性能瓶颈:每个 API 请求都远程调用外部 Java 服务来验证 Token。高并发时每个请求额外增加一次网络往返(10-50ms)。改进方案是:
- 本地 JWT 验证:Java 服务签发 JWT,NestJS 本地验证签名,无需远程调用
- 用户信息缓存:验证后缓存用户信息(Redis / 内存,TTL 5 分钟),相同 Token 后续请求命中缓存
Q3:多环境配置是怎么管理的?
答:采用「通用基础 + 环境覆盖」的深度合并策略:
common.config.ts(通用配置:OSS 区域、端口、API URL 模板...)
↓ mergeDeep()
development.config.ts / test.config.ts / production.config.ts
↓
最终运行时配置
关键设计点:
- 深度合并而非浅层覆盖:
common.config.ts定义完整的配置树,环境配置只覆盖需要差异化的字段。比如数据库配置在 common 中定义了 host、port、charset 等通用项,production 只覆盖 host 和 password - TConfig 类型约束:导出统一的 TypeScript 类型,Service 中使用
config.get('oss.accessKeyId', { infer: true })获取配置项时有完整的类型提示 - 环境切换:通过
NEST_ENV环境变量控制,Docker 构建时注入,不同部署环境不需要修改代码
追问:这个方案的安全隐患是什么?
当时的实现是把密钥(数据库密码、OSS Key、API Key)直接写在了 .ts 配置文件中,这些文件会被 Git 追踪,等于密钥提交到了代码仓库。这是一个严重的安全问题。
正确做法是将密钥读取改为 process.env.*,配合 @nestjs/config 的 ConfigModule.forRoot() 从 .env 文件加载。.env 必须在 .gitignore 中,只提交 .env.example 模板。不同环境的密钥注入方式:
- 本地开发:
.env文件(gitignore) - CI/CD:GitHub Secrets / GitLab CI Variables
- K8s 生产环境:kubectl create secret
Q4:你怎么设计 RESTful API?当前项目的 API 有什么问题?
答:当前项目的 API 设计偏离了 RESTful 原则,所有操作都使用 POST 方法 + 动词命名:
POST /copywriting/collection/admin/add_collection → 创建
POST /copywriting/collection/admin/delete_collection → 删除
POST /copywriting/collection/admin/get_collection_list → 查询(也是 POST)
问题:
- HTTP 方法的语义(GET/POST/PUT/DELETE)完全丢失
- CDN 和浏览器无法对 POST 请求做缓存
- 动词命名和 HTTP 方法语义重复
- 不支持 API 版本管理
如果重新设计,会遵循 RESTful 规范:
POST /api/v1/admin/copywriting/collections → 创建
DELETE /api/v1/admin/copywriting/collections/:id → 删除
PATCH /api/v1/admin/copywriting/collections/:id → 更新
GET /api/v1/admin/copywriting/collections → 列表查询
GET /api/v1/admin/copywriting/collections/:id → 单个查询
核心原则:资源用名词复数,HTTP 方法表示操作类型,路径参数表示资源 ID,查询参数用于筛选和分页,/api/v1/ 前缀支持版本管理。
NestJS 原生支持版本管理:app.enableVersioning({ type: VersioningType.URI }) + @Controller({ path: 'copywriting', version: '1' })。
Q5:定时任务(新闻聚合)是怎么设计的?
答:这是一个完整的新闻聚合定时任务系统,每天自动从 9 个新闻源 抓取内容,经过去重、翻译后推送到飞书。
流程:
@Cron('0 0 * * *') → 9 个新闻源并行抓取
↓
Cheerio HTML 解析 → 提取标题/内容/图片
↓
Levenshtein 距离去重(0.25 阈值 + 7 天缓存窗口)
↓
微软翻译 API 多语言翻译
↓
存储到数据库
↓
飞书 Bot 推送(Markdown 卡片)+ 写入云文档
Levenshtein 去重原理: 对比两篇新闻标题的编辑距离(将一个字符串转换为另一个所需的最少操作次数),归一化后得到相似度。如果相似度 > 0.75(即编辑距离 < 0.25),判定为重复。同时维护 7 天的缓存窗口,只与近期新闻比较,避免性能问题。
追问:这个设计有什么架构问题?
有几个问题:
- 同步阻塞:9 个新闻源抓取 + 翻译 + 飞书推送全在一个同步方法中执行,任何一步超时或失败都会中断整个流程
- 无分布式锁:多实例部署时每个实例都会执行定时任务,导致重复抓取和推送
- 无熔断:微软翻译 API 或飞书 API 故障时没有降级策略
改进方案:
- 引入 BullMQ 消息队列,定时任务只负责发消息,Worker 异步消费
- 用 Redis SETNX 做分布式锁,确保只有一个实例执行
- 外部 API 调用加熔断器(连续失败 5 次 → 熔断 30 秒 → 降级)
二、核心技术实现类
Q6:VTable 大规模多语言编辑方案是怎么实现的?为什么从 V1 升级到 V2?
这是整个 Swee 项目最具工程思考的技术决策——从发现 DOM 瓶颈到 Canvas 渲染方案的选型过程,体现了完整的性能工程思维。
答:
V1 方案(Ant Design EditableProTable):
- 基于 DOM 渲染,每个单元格是一个真实的
<td>+<input>元素 - 当数据量达到约 1000 行 × 8 语言 = 8000 个单元格时,DOM 操作开始明显卡顿
- 滚动、编辑、保存都有肉眼可见的延迟
V2 方案(VTable Canvas 渲染):
- 基于 Canvas 绘制,所有单元格都是画布上的图形,不产生 DOM 节点
- 只渲染可视区域 + 小范围缓冲区(类似虚拟列表的思想)
- 10000+ 行流畅编辑,性能提升约 10 倍
关键技术点:
- React in Canvas:通过 VTable 的
<ListColumn>组件 +react属性,在 Canvas 单元格中注入 React 操作按钮(编辑、删除),实现了 Canvas 高性能 + React 交互灵活性 的融合 - 自定义 TextAreaEditorWithValidator:继承 VTable 的编辑器接口,实现单元格级验证 + 三色状态标记(新增绿色、编辑蓝色、错误红色)
- 微软翻译器集成:选中原文一键翻译为 8 种目标语言,减少运营人员的翻译负担
- 字符超长检测:每种语言的文案有字符数限制,编辑时实时检测并标红
追问:Canvas 渲染和 DOM 渲染的本质区别是什么?
DOM 渲染:每个单元格是一个 HTML 元素(有自己的盒模型、事件绑定、样式计算),浏览器需要维护 DOM 树 + CSSOM 树 + Layout 树。8000 个元素意味着 8000 次 Layout 计算。
Canvas 渲染:整个表格是一个 <canvas> 元素,单元格只是画布上的像素绘制。滚动时重新绘制可视区域,不需要 DOM 操作。事件通过坐标计算映射到对应单元格(Hit Test)。
本质差异:DOM 是声明式的(告诉浏览器"有 8000 个元素"),Canvas 是命令式的(告诉浏览器"画这些像素")。当元素数量大时,命令式更高效。
Q7:配置管理的可视化编辑器是怎么实现智能类型推断的?
答:核心思路是根据 JSON 值的内容自动推断 UI 组件类型。
JSON 值 → 正则匹配判断类型 → 动态渲染对应组件
"https://cdn.xxx.com/a.png" → 匹配图片后缀 → 图片预览 + 上传组件
"https://cdn.xxx.com/b.mp4" → 匹配视频后缀 → 视频播放器 + 上传组件
"https://cdn.xxx.com/c.mp3" → 匹配音频后缀 → 音频播放器 + 上传组件
"2024-01-01T00:00:00Z" → 匹配 ISO 日期 → 日期选择器
"#FF5733" → 匹配颜色值 → 颜色选择器
其他字符串 → 默认 → 文本输入框
设计优势:运营人员不需要手动配置每个字段的类型,系统自动识别。当 JSON 配置增加新的图片字段时,编辑器自动展示预览和上传功能,零配置。
另外还支持 JSONEditor 6 种编辑模式(tree/view/form/code/text/preview),不同复杂度的配置场景可以切换合适的编辑模式。配置还有版本回退功能,通过 lastContent 字段保存上一版,误操作可以一键回滚。
Q8:OpenAPI 代码生成是怎么工作的?
答:实现了从后端 DTO 到前端 API 客户端的全链路自动化。
流程:
后端 DTO + @ApiProperty() 装饰器
↓
NestJS Swagger 插件 → 自动生成 Swagger JSON(/api-docs)
↓
前端 pnpm openapi → @umijs/openapi 读取 Swagger JSON
↓
自动生成 TypeScript API 函数 + 类型定义
实际效果:
// 后端 DTO(唯一数据源)
export class CreateCollectionDto {
@ApiProperty({ description: '集合名称' })
@IsString()
name: string;
}
// 前端自动生成(无需手写)
export async function addCollection(body: CreateCollectionDto) {
return request<ApiResponse<Collection>>('/copywriting/collection/admin/add_collection', {
method: 'POST',
data: body,
});
}
好处:
- 后端改了 DTO → 前端
pnpm openapi→ TypeScript 编译报错 → 精确定位需要适配的地方 - swee-mobile 同时对接 6 个 Java 微服务(user、recommend、live、model、interact、im),全部通过 codegen 生成,节省大量手写 API 代码的工作
- 保证前后端类型 100% 一致
Q9:PWA 方案是怎么设计的?
答:swee-mobile 使用 next-pwa 实现 PWA 支持,配置了完整的 Service Worker 和 Web App Manifest。
配置要点:
manifest.json:display: "standalone"实现全屏 App 体验、深色主题、应用图标和安装截图- Service Worker:自动注册 +
skipWaiting(有新版本时立即更新,不等用户关闭) - 多入口适配:同时支持 App WebView 内嵌、PWA 独立模式、移动浏览器访问
支持的场景:
- 用户在手机浏览器中访问 → 提示"添加到主屏幕"
- 添加后以 standalone 模式运行,看起来和原生 App 一样(隐藏浏览器地址栏)
- App 内嵌 WebView 也能正常运行
追问:PWA 方案有什么不足?
manifest.json图标缺少maskable变体,Android 设备上图标显示效果不够好- Service Worker 使用默认缓存策略,没有针对 API 请求和图片做差异化缓存(API 应该用 NetworkFirst,图片应该用 CacheFirst)
- 没有离线页面(离线时应该展示友好的提示页,而非浏览器默认的离线错误)
Q10:Easemob IM 即时通讯是怎么集成的?
答:swee-mobile 使用 Easemob WebSDK 4.13 的 MiniCore 轻量模式 集成即时通讯功能。
为什么选 MiniCore? 标准 SDK 包含音视频通话、群组管理等完整功能,体积大。MiniCore 只保留核心的消息收发能力,适合我们「AI 伴侣对话」这种以文本为主的场景。
架构设计:
EasemobProvider(全局 Context)
├── SDK 初始化 + 连接管理
├── 消息监听 + 统一转换
├── 用户信息缓存(Zustand Store)
└── 对外暴露 sendMessage / fetchHistory 等方法
消息转换管线:Easemob 原始消息格式和我们的 UI 展示格式不同,中间有一层转换逻辑,将不同类型的消息(文本、图片、自定义卡片)统一格式化为 UI 组件可以直接渲染的结构。
用户信息缓存:使用 Zustand Store 缓存已查询过的用户信息,避免每次展示消息时都调用 API 查头像和昵称。
三、数据库与性能类
Q11:数据库 Entity 是怎么设计的?
答:所有实体统一实现了软删除 + 时间戳审计:
@CreateDateColumn() createdAt: Date; // 自动记录创建时间
@UpdateDateColumn() updatedAt: Date; // 自动记录更新时间
@DeleteDateColumn() deletedAt: Date; // 软删除(非空 = 已删除)
软删除的好处是数据不会真正删除,可以恢复。TypeORM 的 @DeleteDateColumn 会自动在查询时过滤已删除的记录(WHERE deleted_at IS NULL),业务代码无需关心。
另外有一个独立的 LogEntity 记录操作审计日志:操作人、操作 IP、操作类型、变更前后的 JSON 对比。这样任何配置变更都有完整的追溯链。
追问:Entity 设计有什么不足?
主要有两个问题:
-
缺少索引:所有实体都没有
@Index()装饰器。copywriting.entity的key字段、setting.entity的name字段都是高频查询字段,没有索引会随着数据量增长产生性能问题。 -
无数据库迁移:开发环境使用
synchronize: true自动同步 schema,但整个项目没有迁移文件。生产环境的 schema 变更只能手动执行 SQL,多人开发时容易冲突,回滚困难。应该使用 TypeORM 的migration:generate和migration:run管理。
Q12:N+1 查询问题是怎么产生的?怎么解决?
答:在文案管理的 getCopywritingListByCollections 方法中,需要根据多个 collectionId 查询对应的文案列表。
N+1 问题:
// 当前实现 — N+1 查询
for (const collectionId of collectionIds) {
const items = await this.copywritingRepository.find({
where: { collection: { id: collectionId } }
});
results.push(...items);
}
// 如果有 10 个 collectionId,就发 10 次 SQL 查询
解决方案:使用 TypeORM 的 In() 操作符,一次查询所有:
const items = await this.copywritingRepository.find({
where: { collection: { id: In(collectionIds) } }
});
// 无论多少个 collectionId,只有 1 次 SQL 查询
// SELECT * FROM copywriting WHERE collection_id IN (?, ?, ?, ...)
N+1 的本质:循环中的每次迭代都发起一次数据库查询,导致查询次数 = 1(主查询)+ N(循环查询)。解决思路是将循环查询合并为一次批量查询。
Q13:Materialized Path 树形结构是怎么实现的?
答:素材库的文件夹采用 Materialized Path(物化路径) 算法实现树形结构。
原理:每个节点存储从根到自身的完整路径,用分隔符连接:
根文件夹 path = "1"
├── 图片 path = "1.2"
│ ├── 封面 path = "1.2.5"
│ └── 头像 path = "1.2.6"
└── 视频 path = "1.3"
核心操作的 SQL:
- 查询子节点:
WHERE path LIKE '1.2.%'(查 "图片" 下的所有子文件夹) - 查询祖先链:解析 path 字符串
"1.2.5"→ 查询 id IN (1, 2, 5) 得到面包屑 - 移动节点:更新自身及所有子节点的 path 前缀
- 删除节点:
DELETE WHERE path LIKE '1.2.%'(级联删除所有子节点)
对比其他方案:
| 方案 | 查询子树 | 移动节点 | 适用场景 |
|---|---|---|---|
| 邻接表(parent_id) | 递归查询,性能差 | 简单 | 层级少 |
| Materialized Path | LIKE 查询,简单高效 | 需更新所有子节点路径 | 读多写少 |
| 嵌套集(左右值) | 范围查询,最快 | 需重算大量左右值 | 极少变更 |
素材库是典型的「读多写少」场景(浏览远多于创建/移动),所以 Materialized Path 是最合适的选择。
Q14:缓存是怎么设计的?
答:使用 NestJS 的 @nestjs/cache-manager,通过 @CacheInterceptor 按路由粒度控制缓存。
当前实现:
@UseInterceptors(CacheInterceptor)
@CacheTTL(60) // 60 秒缓存
@Get('config')
getConfig() { ... }
问题:使用默认的内存缓存(CacheModule.register() 无 store 配置),存在两个问题:
- 进程重启缓存丢失:NestJS 重启后缓存清空,需要重新预热
- 多实例无法共享:如果部署了 2 个 Pod,各自维护独立缓存,数据不一致
改进方案:接入 Redis 作为缓存后端:
CacheModule.register({
store: redisStore,
host: process.env.REDIS_HOST,
ttl: 60,
});
Redis 作为独立服务,所有实例共享同一份缓存,且进程重启不丢失。
四、前端架构类
Q15:swee-mobile 的状态管理是怎么设计的?
答:使用 Zustand 5.0 做状态管理,选择它而非 Redux 的原因是:
- 体积极小(~1KB),适合移动端 PWA
- API 简洁,无 Action/Reducer 模板代码
- 支持中间件(persist、devtools)
当前使用方式:主要用于 IM 用户信息缓存——Easemob 消息只包含用户 ID,展示时需要查询用户信息(头像、昵称)。使用 Zustand Store 缓存查询结果,避免重复 API 请求。
追问:当前的 Zustand 设计有什么不足?
几个问题:
- Map 类型不可序列化:
userInfoMap使用new Map()存储,但 Map 不支持 JSON 序列化,DevTools 无法查看,persist 中间件也无法持久化。应该改为Record<string, UserInfo> - 整个应用只有一个 Store:应该按业务域拆分(
useIMStore、useUserStore、useAIStore) - 无持久化:用户信息缓存应该用
zustand/middleware的persist中间件存到 localStorage,避免每次打开 App 重新请求
Q16:Framer Motion 页面转场动画是怎么实现的?
答:swee-mobile 使用 Framer Motion 的 AnimatePresence 实现原生 App 级别的页面切换动画。
实现方式:
<AnimatePresence mode="wait">
<motion.div
key={router.pathname}
initial={{ x: '100%', opacity: 0 }} // 新页面从右侧滑入
animate={{ x: 0, opacity: 1 }} // 定位到中间
exit={{ x: '-30%', opacity: 0 }} // 旧页面向左滑出
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
<Component {...pageProps} />
</motion.div>
</AnimatePresence>
关键点:
key={router.pathname}告诉 AnimatePresence 路由变化 = 组件切换mode="wait"确保旧页面退出动画完成后新页面才进入- 动画在 GPU 上执行(
transform: translateX),不触发 Layout/Paint
这让 PWA 的页面切换体验接近原生 App,用户感觉不到是 Web 应用。
Q17:三平台埋点事件管理系统是怎么设计的?
答:swee-admin 中有一个完整的埋点事件管理系统,通过三层嵌套编辑实现:
第一层:ModalForm — 事件基本信息(事件名、描述、所属模块)
└── 第二层:EditableProTable — 事件属性列表(属性名、类型、是否必须)
└── 第三层:嵌套 EditableProTable — 枚举值列表(枚举名、枚举值、描述)
设计亮点:
- 动态列生成:事件的上报数据是动态结构,后台根据事件定义自动生成查看表格的列
- @swee/codegen 集成:埋点事件定义完成后,通过代码生成工具自动生成前端上报代码,减少手写工作量
- 三层嵌套编辑的 UX:虽然数据结构复杂,但用户操作是层层展开的自然流程
Q18:VTable 大规模编辑中怎么处理用户体验?
答:除了 Canvas 渲染解决性能问题外,我们在 UX 上也做了细致设计:
-
三色状态标记:
- 绿色底色 = 新增的文案(还未保存)
- 蓝色底色 = 修改过的文案(与原始值不同)
- 红色边框 = 验证不通过(超长、格式错误)
-
字符计数实时反馈:编辑器底部显示当前字数 / 最大字数,超长时实时变红
-
一键翻译:选中中文原文,点击翻译按钮,微软翻译 API 同时生成 8 种语言的翻译,填入对应列
-
批量操作:支持 Excel 格式导入/导出,运营人员可以在 Excel 中批量编辑后导入
-
撤销/重做:VTable 内置的编辑历史支持 Ctrl+Z / Ctrl+Y
五、问题排查与改进类
Q19:项目中遇到过哪些安全问题?
答:做代码审查时发现了多个安全问题:
最严重的:后端 6 个生产密钥(数据库密码、OSS AccessKey、微软翻译 Key、飞书 Secret)明文硬编码在 .ts 配置文件中,等于所有有代码读取权限的人都能访问生产数据库。
其他安全问题:
- Token 日志泄露:Guard 中
console.log(accessToken)将用户 Token 写入服务器日志 - CORS 全开:
cors: true允许任意域名跨域请求 - 无 Helmet:缺少 HTTP 安全头(X-Content-Type-Options、X-Frame-Options、CSP)
- 无速率限制:没有 ThrottlerModule,暴力破解和 DDoS 无防护
- 硬编码鉴权:一个下载接口用固定字符串做 Token 校验
- 超级管理员 userId===1:硬编码 ID 判断超级权限
修复方案:
- P0:立即轮换所有泄露密钥,迁移到环境变量
- P0:移除 Token 日志,配置 CORS 白名单,添加 Helmet 和 ThrottlerModule
- P1:硬编码鉴权改为 Guard 装饰器,userId 判断改为 RBAC 角色系统
Q20:HTTP 状态码全部返回 200 有什么问题?
答:当前后端所有业务错误都返回 HTTP 200,通过响应体的 code 字段区分成功和失败。
// 当前实现
super({ code: 10001, message: '参数错误' }, HttpStatus.OK); // HTTP 200
super({ code: 10002, message: '未授权' }, HttpStatus.OK); // 也是 HTTP 200
问题:
- 监控盲区:Nginx、CDN、APM 工具都通过 HTTP 状态码统计错误率。全部 200 = 错误率永远是 0%
- 前端无法统一拦截:Axios 的
interceptors.response.error只捕获非 2xx 状态码,全部 200 意味着错误处理只能在每个请求的.then()中手动判断code - 缓存问题:HTTP 缓存机制基于状态码,错误响应不应该被缓存
正确做法:按错误类型返回对应的 HTTP 状态码:
- 400 Bad Request — 参数错误
- 401 Unauthorized — 未认证
- 403 Forbidden — 无权限
- 404 Not Found — 资源不存在
- 500 Internal Server Error — 服务器内部错误
Q21:如果你要给这个项目添加事件驱动架构,会怎么设计?
答:当前所有操作都是同步 HTTP 请求-响应模式,长耗时操作(新闻抓取、批量翻译、OSS 上传)直接阻塞连接。
设计方案:引入 BullMQ(基于 Redis 的消息队列):
┌→ 新闻抓取 Worker
API → 发送消息到队列 → Redis ──┼→ 翻译 Worker
返回任务 ID └→ 飞书推送 Worker
前端通过任务 ID 轮询状态 或 WebSocket 接收完成通知
NestJS 中的实现:
// Producer(Controller)
@Post('batch-translate')
async batchTranslate(@Body() dto: BatchTranslateDto) {
const job = await this.translateQueue.add('translate', dto);
return { taskId: job.id, status: 'processing' };
}
// Consumer(Worker)
@Processor('translate')
export class TranslateProcessor {
@Process('translate')
async handleTranslate(job: Job<BatchTranslateDto>) {
// 异步执行翻译
await this.translateService.batchTranslate(job.data);
// 完成后可以通过 WebSocket / 飞书 通知
}
}
好处:
- API 立即响应(用户不用等),Worker 异步处理
- 失败自动重试(BullMQ 内置重试机制)
- 可以监控队列长度和处理速度
- 多实例环境下 Worker 自动负载均衡
六、技术选型与权衡类
Q22:为什么选 NestJS 而不是 Express 或 Koa?
答:NestJS 的核心优势在于它是一个有架构约束的框架,而 Express/Koa 只是 HTTP 库。
具体原因:
- 模块化架构:NestJS 的 Module/Controller/Service/Provider 模式强制了代码组织规范,团队多人协作时代码结构一致
- 装饰器驱动:
@Controller、@Guard、@Interceptor等装饰器让代码更声明式,比 Express 的app.use()中间件链更清晰 - 依赖注入:Service 之间通过构造函数注入,方便单元测试(可以 mock 依赖)
- 内置集成:TypeORM、Swagger、Cache、Schedule、Throttle 等都有官方模块,不需要自己选型和集成
- TypeScript 优先:从设计之初就是 TypeScript,类型支持完善
Express/Koa 更适合轻量的 API 服务或需要极致灵活性的场景。我们的后端有多个业务模块、需要权限控制、定时任务、缓存等功能,NestJS 的架构约束反而是优势。
Q23:VTable(Canvas)vs EditableProTable(DOM),你是怎么做技术决策的?
答:决策过程:
-
明确瓶颈:用 Chrome Performance 面板分析发现,EditableProTable 在 1000+ 行时,滚动帧率从 60fps 降到 15fps。主要耗时在 Layout 和 Paint——8000 个 DOM 节点的重排重绘
-
评估方案:
- 方案 A:虚拟滚动(react-virtualized)— 只渲染可视区域的 DOM 节点。但 EditableProTable 的编辑态需要维护每个单元格的状态,虚拟滚动时滚出视口的编辑状态会丢失
- 方案 B:Canvas 表格(VTable)— 根本性解决 DOM 瓶颈,10K+ 行无压力。但需要解决 Canvas 中的交互问题(编辑器、按钮)
-
选择 VTable 的原因:
- 性能数量级提升(方案 B >> 方案 A)
- VTable 支持 "React in Canvas",可以在 Canvas 单元格中注入 React 组件,解决交互问题
- 字节跳动(VisActor)团队维护,质量和迭代可靠
-
迁移策略:V1(EditableProTable)和 V2(VTable)并行存在一段时间,通过特性开关切换,确认 V2 稳定后再下线 V1
Q24:你觉得 Swee 项目中最有价值的技术实现是什么?
答:VTable 大规模多语言编辑方案。
这不只是"换了一个表格组件"的事情,而是一次完整的性能工程实践:
- 发现问题:通过性能分析定位到 DOM 渲染瓶颈
- 技术评估:对比虚拟滚动和 Canvas 两种方案
- 技术融合:React in Canvas 实现了"Canvas 高性能 + React 灵活交互"
- 产品体验:三色状态标记、一键翻译、字符检测等 UX 细节
最终效果是从「1000 行卡顿」到「10000+ 行流畅」的数量级提升,直接解放了运营团队的生产力。
Q25:如果让你从零重新设计 Swee 后端,你会怎么做?
答:保持不变的部分:
- NestJS 模块化架构 + TypeORM
- 管道式请求处理链(Middleware → Guard → Pipe → Handler → Interceptor → Filter)
- @Auth 装饰器的声明式权限控制
- OpenAPI 全链路自动化
会改进的部分:
- 密钥管理:从第一天起就用环境变量 +
.env.example,绝不在源码中硬编码 - RESTful API:严格遵循 HTTP 方法语义 + 资源名词命名 + 版本管理
- JWT 本地验证:替代每请求远程校验,加用户信息缓存
- Redis:作为缓存后端(替代内存缓存)+ 消息队列后端(BullMQ)+ 分布式锁
- 数据库迁移:从第一天起使用 TypeORM Migration,关闭 synchronize
- 严格 TypeScript:启用
strict: true - 测试:核心 Service 100% 单元测试覆盖
- 健康检查:
/health+/ready端点 +app.enableShutdownHooks()优雅关机 - 日志系统:NestJS Logger 替代 console.log,按级别过滤
七、综合能力类
Q26:你在这个项目中踩过最大的坑是什么?
答:VTable V1 到 V2 的迁移过程。
V1 使用 Ant Design 的 EditableProTable,我们在它基础上做了大量定制(三色状态标记、字符验证、一键翻译按钮等)。迁移到 VTable 时,这些定制功能需要全部用 VTable 的 API 重新实现。
最大的挑战是 React in Canvas——在 Canvas 单元格中嵌入 React 操作按钮。VTable 的渲染引擎和 React 的渲染树是两个独立系统,需要通过 VTable Group 组件 + react 属性建立桥接。调试起来也很困难,因为 Canvas 元素在 React DevTools 中看不到。
解决方法是:
- 先实现纯数据展示(Canvas 渲染),确认性能
- 再逐步添加交互功能(编辑器、按钮),每添加一个功能都做性能回归
- V1 和 V2 并行运行一段时间,确认稳定后下线 V1
Q27:你对 TypeORM 的使用有什么心得?有什么注意事项?
答:TypeORM 的优点是 Active Record / Data Mapper 两种模式灵活选择,装饰器语法和 NestJS 风格一致。
注意事项:
-
不要在生产用 synchronize: true:它会自动修改数据库 schema,可能导致数据丢失。应该用 Migration
-
关注 N+1 问题:关联关系默认是懒加载,循环中访问关联属性会产生 N+1 查询。解决方案:
find()时用relations选项或 QueryBuilder 的leftJoinAndSelect -
索引要加:TypeORM 不会自动为频繁查询的字段加索引,需要手动
@Index()装饰器 -
软删除配合:
@DeleteDateColumn+softRemove()/softDelete()实现软删除,但查询时 TypeORM 会自动加WHERE deleted_at IS NULL,如果需要查已删除数据要用withDeleted()选项 -
QueryBuilder 更灵活:简单查询用
find()/findOne(),复杂查询(JOIN、子查询、聚合)用 QueryBuilder
Q28:你们怎么处理前后端联调?遇到类型不一致的问题怎么办?
答:我们通过 OpenAPI codegen 最大化减少联调问题。流程是:
- 后端写好 DTO 和 Controller → Swagger 自动生成 API 文档
- 前端执行
pnpm openapi→ 自动生成 TypeScript API 函数和类型 - 前端直接使用生成的函数调用 API,类型由 codegen 保证一致
类型不一致的情况:
- 如果后端改了 DTO 但没通知前端 → 前端重新
pnpm openapi后 TypeScript 编译报错 → 精确定位需要适配的代码 - 如果是对接外部 Java 微服务(不用 NestJS Swagger)→ 需要从 Java 服务的 Swagger 文档生成,偶尔有字段类型不一致的情况,需要手动修正
最佳实践:后端 DTO 的每个字段都加 @ApiProperty({ description: '...', example: '...' }),这样生成的文档和 TypeScript 类型都更完善。
Q29:你对代码质量和工程规范有什么看法?
答:基于 Swee 项目的经验,我认为几个关键的工程规范:
-
安全是底线:密钥绝不能出现在源码中,这是第一天就应该建立的规范。我们项目因为早期没注意,导致了严重的安全隐患
-
测试不是可选项:Swee 后端的核心 Service(文案管理、新闻聚合、配置管理)没有任何测试,每次修改都是"盲改"。至少核心模块应该有单元测试覆盖
-
日志要规范:
console.log(2222)这种调试代码不应该出现在主分支。应该用 NestJS Logger,生产环境只输出 warn/error 级别 -
TypeScript strict:后端没有启用 strict 模式,允许隐式 any,失去了 TypeScript 的核心价值
-
数据库变更要可追溯:
synchronize: true在生产是定时炸弹,应该从项目初始化就用 Migration
这些问题不是不知道,而是在快速迭代中被忽略了。工程规范应该在项目初始化时就建立,而不是等出了问题再补。初期多投入 1 天建立规范,能避免后期几周的修复成本。
Q30:如果面试官问你这个项目的不足,你会怎么回答?
答:我会坦诚地分层说明:
安全层面(最严重):
- 6 个生产密钥硬编码在源码中
- CORS 全开 + 无速率限制 + Token 日志泄露
- 这些问题说明团队的安全编码意识需要加强
质量保障层面:
- 测试覆盖接近零
- Sentry 错误监控被注释
- 前端无 ErrorBoundary
- 说明质量保障体系不完善
架构层面:
- API 设计偏离 RESTful(全 POST + 动词命名)
- 每请求远程认证(性能瓶颈)
- 无事件驱动架构(同步耦合过重)
- 无 API 版本管理
发现问题并知道怎么改进,比假装没有问题更有价值。坦诚说明不足的同时,展示你的分析能力和改进方案,面试官会更认可你的工程素养。
但我会强调:
- 这些问题我都已经识别到了,并且有明确的改进方案和优先级排序
- 安全问题我归类为 P0 需要立即修复,架构问题作为中长期优化方向
- 发现问题并知道怎么改进,比假装没有问题更有价值
- 这些经验让我在未来的项目中,从第一天就会建立安全规范、测试覆盖和数据库迁移机制