03 - 后端流式架构与全栈端侧交付

章节定位:面试官对您后端工程底座与全栈端到端交付能力的集中考察。本章涵盖 FastAPI 异步架构分层、SSE 流式输出与断连处理、MySQL 8.0 表设计与冷热归档、Redis 高速缓存与 Celery 异步管道、React 管理端与微信小程序双端交付以及细粒度 RBAC 权限审计。

Q1:你们的整体后端架构是怎样的?能分层讲一下吗?

追问场景:二面技术架构开场,面试官想快速建立对您后端设计的整体认知。

答:

我画一个分层架构图来描述:

┌─────────────────────────────────────────────────────────┐
│                    接入层(前端)                        │
│           React Web 管理端  /  微信小程序              │
└─────────────────────────────────────────────────────────┘
                              │ HTTPS / SSE
┌─────────────────────────────────────────────────────────┐
│                   网关层(Nginx + 网关)                │
│       路由转发、限流、SSL 终结、静态资源缓存            │
└─────────────────────────────────────────────────────────┘
                              │
┌─────────────────────────────────────────────────────────┐
│                 Web 层(FastAPI 异步应用)              │
│  ┌──────────────────────────────────────────────────┐  │
│  │  中间件层:CORS / 日志 / 鉴权 / 异常捕获        │  │
│  ├──────────────────────────────────────────────────┤  │
│  │  路由层:RESTful API + SSE 流式接口            │  │
│  ├──────────────────────────────────────────────────┤  │
│  │  Service 层:会话管理 / Agent 编排 / 知识库调用 │  │
│  ├──────────────────────────────────────────────────┤  │
│  │  Repository 层:MySQL / Redis 数据访问          │  │
│  └──────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────┘
                              │
┌─────────────────────────────────────────────────────────┐
│                    外部依赖层                           │
│   RAGFlow(检索底座) / 大模型 API / 对象存储 MinIO   │
└─────────────────────────────────────────────────────────┘
                              │
┌─────────────────────────────────────────────────────────┐
│                  基础设施层                             │
│     MySQL 8.0 / Redis / Celery + Redis Broker          │
└─────────────────────────────────────────────────────────┘

这个架构的核心设计理念是 「关注点分离」。Web 层(FastAPI)只负责 HTTP 协议层面的接入、SSE 流式推送和基础的请求路由;真正的业务逻辑——比如 Agent 状态机的编排、多轮会话的上下文维护、知识库的检索调用——全部封装在 Service 层,通过依赖注入(FastAPI 的 Depends)注入到路由处理器中。这样设计的好处是:如果未来需要把 Agent 能力暴露为 gRPC 服务或消息队列消费者,Service 层可以直接复用,不需要重写业务逻辑。

Q2:为什么选择 SSE 而不是 WebSocket 来做流式输出?

追问场景:考察技术选型的工程判断力,面试官想看您是否“用了某技术但不知道为什么”。

答:

当时选型的时候,我认真评估过 WebSocket、SSE 和长轮询(Long Polling)三种方案。最终选 SSE 的核心逻辑是 「协议与场景的匹配度」:

第一,数据流向是单向的。 AI 对话的核心体验是从服务器向客户端“流式地吐出”回答内容,客户端不需要频繁地往服务端发送数据(用户发完问题后,主要的交互就是“等服务器推送”)。SSE 本身就是为“服务器向客户端单向推送”设计的,而 WebSocket 是为“全双工双向通信”设计的。用 WebSocket 做单向流,协议层面有额外开销(握手更重、需要维护心跳),属于“杀鸡用牛刀”。

第二,HTTP 生态的天然兼容性。 SSE 基于标准 HTTP/1.1 的 text/event-stream 或 HTTP/2 的 Server Push,不需要像 WebSocket 那样做协议升级(Upgrade)。这意味着它可以天然穿过大多数企业级防火墙、负载均衡器和反向代理(Nginx 对 SSE 有很好的原生支持),对政务内网环境的兼容性更好。而 WebSocket 在政务网络环境中偶尔会遇到代理不支持或会话超时的问题。

第三,自动重连机制。 SSE 规范中内置了 Last-Event-ID 机制,如果连接意外断开,客户端在重连时可以将这个 ID 带给服务器,服务器可以恢复断点继续推送。虽然这个机制在复杂场景下还需要加强(比如依赖服务端会话状态),但至少协议层给了我们一个基础。WebSocket 的断线重连需要完全由应用层自己实现。

当然也有权衡:SSE 是单向的,如果后续我们需要在生成过程中允许用户“中断生成”或“修改条件”,就需要额外用 HTTP API 来做控制信令,而不能像 WebSocket 那样在同一个连接里用消息类型来区分。我们在实现“打断”功能时,就是在前端调了一个独立的 HTTP DELETE 接口 /api/chat/{task_id}/cancel,配合服务端的协程取消机制来实现的。这个方案稍复杂一点,但完全可接受。

一句话总结选型逻辑:SSE 是一个“足够好”且“更简单”的方案,满足了 90% 的场景,没有为 10% 的边缘需求引入 2 倍的复杂度。

Q3:SSE 流式输出的生成器(Generator)具体是怎么实现的?

追问场景:面试官要验证您是否真的深入理解并落地过 SSE 生成器全链路架构,而非照搬浅层文档。

答:

在我们的 FastAPI 异步架构中,SSE 流式生成器(Async Generator)本质上是一个 “上下游协议转换与数据清洗的异步管道” ——上游承接大模型或 LangGraph Agent 的原始流式 Token,中间进行合规过滤与结构化协议封装,下游持续向客户端推流,并全程监控连接生命周期。

整体架构与数据流转可以分为以下五个核心阶段:

1. 前置准备阶段(Pre-Stream):
- 鉴权与配额校验:提取 Header 中的身份凭证,校验租户限流配额。
- 上下文滑动窗口组装:从 Redis 提取该会话最近的 N 轮历史对话并执行 Token 裁剪,组装 Prompt。
- 响应头协议声明:向客户端声明 Content-Type: text/event-stream、Cache-Control: no-cache、Connection: keep-alive 以及关键的 X-Accel-Buffering: no(通知网关关闭反向代理缓冲,确保逐字实时推送)。

2. 多路复用事件封装阶段(Event Multiplexing):
生成器并不是简单地推纯文本,而是封装成前端易于解析的标准结构化事件协议:
- event: start:推送任务初始化元数据与唯一 task_id(供前端绑定中断操作)。
- event: token:正文文字增量包,前端追加到富文本打字机中。
- event: tool_start / event: tool_end:Agent 工具调用状态(如“正在检索社保法规库…”),用于前端动画反馈。
- event: cite:RAG 召回的结构化引用来源卡片(包含文档名、段落偏移、置信度分值)。
- event: ping:定时心跳包(每隔 15 秒发送一次),防止中间网关因超时主动掐断连接。
- event: end:结束信令(附带总耗时与 Token 统计)。

3. 实时敏感词与合规嗅探阶段(In-Flight Guardrail):
- 内存中维护环形滑动字符缓冲区(保留最新 10 个字符),一旦命中政务合规违规词,生成器立即截断上游输出,替换为预设兜底话术并向前端发送终止事件。

4. 断连监听与算力止损阶段(Disconnect & Cancel):
- 生成器在异步循环中通过 request.is_disconnected() 监听 ASGI 层的底层 TCP 断连信号。
- 用户一旦关闭页面或切换 Tab,生成器立即感知并捕获 asyncio.CancelledError,强制中断对上游大模型/RAGFlow 的 HTTP 连接,彻底杜绝“用户已走、服务器仍在白白消耗商业 Token”的算力浪费。

5. 异步归档与收尾阶段(Post-Stream / Finally):
- 在生成器循环中累积完整的 Assistant 回答,推流结束后在 finally 块中通过后台异步任务写回 MySQL 8.0 持久化表,并更新 Redis 缓存与审计日志,不阻塞 HTTP 线程。

Q4:(追问)客户端断连后,后端正在执行的任务会立刻释放资源吗?你们是怎么处理的?

追问场景:面试官深挖生产级健壮性,这是很多“调通即可”的项目最容易露馅的地方。

答:

这个问题很关键。如果客户端断连后后端任务不释放,会导致资源泄漏——比如 LLM API 的调用仍在继续消耗 Token 费用、RAGFlow 的会话句柄没有释放、数据库连接池被耗尽。我们做了两个层面的处理:

第一层:协程级别的主动取消(快速路径)。

正如前文生成器机制所述,我们在循环里检查了 await request.is_disconnected()。一旦检测到断连,就 break 跳出循环,然后执行 finally 或 except asyncio.CancelledError 中的清理逻辑。但这里有一个细节:如果当前代码正阻塞在 agent_graph.astream_events() 的内部迭代中(比如正在等待 LLM API 的 HTTP 响应),break 并不会立刻中断这个正在阻塞的异步调用。

为了处理这种情况,我们把整个 agent_graph.astream_events() 放在了一个 asyncio.wait_for() 中,并设置了超时。同时,当检测到断连时,我们通过一个 asyncio.Event 信号通知底层的 LangGraph 执行环境“任务应该被取消”,LangGraph 内部会抛出 asyncio.CancelledError,从而提前中断 LLM 调用。这个机制依赖于我们传给 Agent 的 config 中携带了一个可取消的 context。

第二层:Celery 超时兜底(慢速路径)。

对于极少数情况——比如 LLM 服务本身卡住了,连 astream_events 的第一块数据都没有返回——上面的协程取消可能来不及。我们在调用 Agent 的整个流程外层包了一个 Celery 异步任务,设置了 60 秒的全局超时。如果 60 秒后 Agent 仍未完成,Celery worker 会强制终止该任务(通过 SoftTimeLimitExceeded 异常),并触发清理回调。

具体清理动作包括:
- 调用 RAGFlow 的会话关闭接口(如果它支持),释放检索索引的缓存句柄。
- 将 Redis 中该会话的 Checkpoint 状态回滚到断连前的最后一步,避免状态损坏。
- 记录一条审计日志,标记该次任务被异常中断。

通过这两层机制,我们确保了即使客户端暴力关闭页面(比如直接 kill 微信小程序进程),后端的资源也能在秒级内得到释放,不会长期泄漏。

Q5:FastAPI 的异步架构中,你们怎么避免阻塞事件循环?

追问场景:考察异步编程的底层理解,面试官想验证您是“真异步”还是“挂着 async 关键字但调用了同步阻塞代码”。

答:

这个问题问得很专业。在 FastAPI 异步架构中,最常见的陷阱就是 在 async 函数中调用了同步阻塞的 I/O 操作或 CPU 密集型计算,这会阻塞整个事件循环,拖垮所有并发请求。

我们根据操作类型做了三类处理:

第一类:I/O 密集型异步操作(天然异步)

数据库访问用 asyncpg / SQLAlchemy Async,Redis 操作用 aioredis,HTTP 请求用 httpx.AsyncClient,文件操作使用 aiofiles。这些库都提供了原生异步接口,我们可以直接在 async 函数中 await,不会阻塞事件循环。

第二类:同步阻塞的 I/O 操作(主动 offload 到线程池)

RAGFlow 的部分 Python SDK 接口还没有完全支持异步(比如某些文件上传操作),或者我们调用的一些旧版加密库是同步的。对于这些不可避免的同步调用,我们用 asyncio.to_thread() 或 loop.run_in_executor() 将它们放到默认的线程池中执行,不阻塞主事件循环。

```python

示例:在 async 接口中调用同步的 RAGFlow 解析函数

result = await asyncio.to_thread(ragflow_client.sync_parse_document, file_path)
```

第三类:CPU 密集型计算(独立进程 / Celery)

真正的 CPU 密集型操作——比如大批量文档的版面分析后处理、或者复杂的 JSON 序列化/反序列化——即使放在线程池也会因为 GIL 竞争影响性能。这类任务我们全部通过 Celery 异步任务来执行,不放在 FastAPI 的请求生命周期内。比如“生成对比报告”这个 Agent 任务中的 Summarizer 节点,如果需要处理 20 份文档的汇总,我们会让 Agent 先收集所有文档,然后触发一个 Celery 任务去生成报告,通过 WebSocket 或轮询的方式返回结果。这样 FastAPI 的请求响应时间保持在 200ms 以内,真正耗时的逻辑在后台 worker 中跑。

在代码审查层面,我们使用了 ruff 的规则 ASYNC101 和 ASYNC102 来检测 async 函数中是否有直接调用的同步阻塞函数,在 CI 阶段就拦截这类问题。

Q6:你们的接口响应时间大概是多少?有没有做过性能优化?

追问场景:考察性能敏感度和优化意识,面试官想知道您是否关注过线上的实际表现。

答:

我把响应时间拆成两类来监控:「首字延迟(TTFT)」和 「总完成时间」。

数据表现(基于线上监控):
- 普通 RAG 问答(非 Agent 任务):首字延迟平均在 800ms-1.5s(主要取决于上游大模型 API 的首 token 响应速度),总完成时间平均在 5-8 秒(取决于回答长度,一般 300-500 字)。
- Agent 复杂任务(如政策对比):首字延迟更久,约 2-4 秒(因为 Planner 节点先要调用一次 LLM 做计划生成),总完成时间约 10-20 秒。

我们做过的优化措施,按效果排序:

  1. 热点问答缓存(效果最显著)。对于高频问题(比如“高新区人才补贴标准”这类被问了上百次的问题),我们在 Redis 中缓存了完整的回答内容,设置了 6 小时的 TTL。命中缓存时直接走静态返回,首字延迟降到 < 100ms。缓存命中率大约在 15%-20%,对整体平均响应时间有不错的下拉效果。

  2. 检索链路的并行化。在混合检索阶段,BM25 检索和向量检索原来是串行的(先 BM25 再向量,然后合并)。我们改为了使用 asyncio.gather() 并行执行,这两个独立操作互不依赖,并行后总耗时从原来的“两者之和”变成了“两者之最大”,整体检索耗时降低了约 40%。

  3. 模型参数调优。我们在调用大模型时,将 temperature 设为 0.1(政务场景不需要创造性),top_p 设为 0.9,并且没有开启 stream 之外的特殊参数。同时和模型服务提供商沟通后,申请了更高的并发连接配额,避免因为连接等待导致的延迟。这块属于基础设施调优,效果大概有 10%-20% 的提升。

  4. 连接池与 Keep-Alive。FastAPI 到 RAGFlow 和大模型服务的 HTTP 连接全部使用连接池(httpx.AsyncClient(limits=...), pool_connections=...),启用了 HTTP Keep-Alive,避免每个请求重复建立 TCP 连接的开销。这在并发较高时效果明显,但单次请求的改善不大。

一个原则:我们不追求“极致低延迟”,因为 AI 生成的场景用户预期就是“需要等待”,我们的核心目标是 “首字要快、中间要稳、不能中断”。所以优化优先级是:稳定性 > 首字延迟 > 总完成时间。在上述优化之后,系统的 P95 首字延迟控制在 2 秒以内,我们的政务客户对这个指标是满意的。

Q7:你们的 MySQL 数据库是怎么设计的?核心表有哪些?

追问场景:二面技术深挖开场,面试官想摸底您的数据建模能力。

答:

我们的 MySQL 8.0 数据库围绕”多租户知识库管理 + 对话审计”两个核心域设计了 7 张核心表,我挑最重要的 4 张来说明:

表名 核心字段 用途 数据量级
t_tenant tenant_id, name, config(JSON), status 租户信息与配置(知识域隔离的顶层) 数十条(客户级)
t_user user_id, tenant_id, role, permissions(JSON), dept 用户与 RBAC 权限 数百条(政务客户员工)
t_document doc_id, tenant_id, file_name, file_hash, status, publish_authority, doc_number, effective_date, expire_date, parsed_chunk_count, created_at 知识库文档主表。记录每份政策文件的元数据和解析状态 数千份(核心政策文件约 2000 份)
t_conversation conv_id, tenant_id, user_id, session_id, query, answer(TEXT), citations(JSON), feedback(TINYINT), latency_ms, token_usage(JSON), created_at 对话审计表。每一条问答都落库,用于合规审计和效果分析 数万条/月(取决于使用频率)
t_audit_log log_id, tenant_id, user_id, action, target, ip, user_agent, detail(JSON), created_at 操作审计日志,所有关键行为(登录、文件上传/删除、敏感词拦截)全部记录 增长较快,需定期归档

设计思路上的两个核心考量:

第一是多租户数据隔离。tenant_id 是所有业务表的”必填外键”,任何查询都强制带 tenant_id 过滤。我们在应用层(Repository)封装了一个”租户感知”的 BaseDAO,自动注入当前用户的 tenant_id,杜绝漏写 WHERE 条件导致的数据泄露。

第二是文档状态机管理。t_document 的 status 字段状态流转为:uploading → parsing(DeepDoc 解析中)→ indexing(写入向量库中)→ active(可被检索)→ failed(解析失败,需人工介入)→ deprecated(已废止,不被检索)。这个状态机让知识库的治理变得可追踪——客户能看到”哪些文件已经在知识库里了,哪些还在处理中”。

Q8:对话表的数据量增长很快,你们怎么做冷热归档的?

追问场景:简历提到”冷热归档”,面试官要验证您是否真正处理过数据增长问题,而非纸上谈兵。

答:

t_conversation 和 t_audit_log 是两张”日志型”表,增长非常快。我们的冷热归档策略按 “时间分层 + 状态分层” 来设计:

分层策略:

层级 存储介质 数据范围 访问方式 保留时长
热数据 MySQL InnoDB 近 3 个月的对话记录 实时读写(查询历史会话、审计回溯) 3 个月
温数据 MySQL 独立归档表(分区表) 3-12 个月的对话记录 只读查询(需要时可通过管理员工具检索) 1 年
冷数据 MinIO 对象存储(CSV/Parquet 格式) 1 年以上的历史记录 离线查询(需从对象存储下载后分析) 按合规要求保留 5-10 年

具体实现机制:

  1. 定时归档任务(Celery 周期任务):每天凌晨 2 点,一个 Celery Beat 任务会扫描 t_conversation 表,将 created_at < 当前日期 - 3个月 且 feedback = 'helpful'(过滤掉无用的低质对话)的记录批量迁移到归档表 t_conversation_archive。迁移完成后,从主表中 DELETE 删除。
  2. 分区表(温数据层):t_conversation_archive 使用了 MySQL 的 RANGE 分区,按 created_at 月分区。查询指定月份的数据时,MySQL 只扫描对应分区,即使数据量达到百万级,查询性能依然可控。
  3. 冷数据导出到对象存储:每季度执行一次”冷化”操作,将 t_conversation_archive 中超过 1 年的分区数据导出为 Parquet 文件(压缩率高、支持列式查询),上传到 MinIO,然后 DROP PARTITION 释放 MySQL 空间。我们使用 pandas + pyarrow 做导出,程序化执行。
  4. 查询路由:业务代码中封装了一个 ConversationRepository,当用户查询历史会话时,先查热表,如果没有再查温表,如果温表也没有则返回提示”该会话已归档,如需调取请联系管理员”。冷数据不对外提供在线查询,只用于合规审计时的离线导出。

这套方案上线后,主表 t_conversation 的数据量从高峰期的 200 多万条稳定控制在 30 万条以内,所有日常查询的响应时间都保持在 50ms 以内。

Q9:Redis 在你们项目中除了做缓存,还承担了哪些职责?能详细讲讲分布式锁与防击穿设计吗?

追问场景:考察中间件应用的广度,面试官想了解您对 Redis 的理解不限于”存 Session”。

答:

Redis 在我们的项目中扮演了 “四合一” 的角色,按重要性排序:

1. 会话状态存储(最核心):

这是第 05 章详细讲过的。存储多轮对话历史、LangGraph Checkpoint 以及 Agent 的执行上下文。Key 格式为 session:{tenant_id}:{user_id}:{session_id},TTL 24 小时,使用 RedisJSON 模块支持局部更新。

2. 热点问答缓存(性能加速):

针对高频问题(比如咨询量大的政策条款)缓存完整的回答内容。Key 为 cache:qa:{md5(query + tenant_id + dept)},Value 为序列化的回答 JSON(包含答案正文、引用列表、生成时间等),TTL 6 小时。缓存未命中时才走完整的 RAG 链路。我们统计的缓存命中率约为 15%-20%,有效降低了平均响应延迟和 LLM Token 消耗。

3. 分布式锁(解决并发一致性问题):

最典型的场景是”同一个文件被多个用户同时上传到知识库”。如果两个用户同时上传了同一份 file_hash 相同的文件,我们希望只解析一次,而不是重复解析两次造成资源浪费。实现方案:

```python
import redis.asyncio as aioredis

async def acquire_parse_lock(file_hash: str, ttl: int = 300) -> bool:
lock_key = f”lock:parse:{file_hash}”
# SET NX EX 是 Redis 实现分布式锁的标准原子操作
result = await redis_client.set(lock_key, “locked”, nx=True, ex=ttl)
return result is True

async def release_parse_lock(file_hash: str):
lock_key = f”lock:parse:{file_hash}”
await redis_client.delete(lock_key)
```

用文件哈希作为锁 Key,同一份文件在同一时间只有一个解析任务在执行。这里的锁 TTL 设了 300 秒(5 分钟),足以覆盖绝大多数文件的解析耗时,如果超时锁自动释放,不会死锁。

4. 限流计数器(防滥用):

政务场景需要防止恶意用户刷接口,我们基于 Redis 的 INCR + EXPIRE 实现了 滑动窗口限流:

```python

对每个用户 + 接口维度做限流:每分钟最多 30 次请求

key = f”ratelimit:{user_id}:{api_path}:{current_minute}”
count = await redis_client.incr(key)
if count == 1:
await redis_client.expire(key, 60)
if count > 30:
raise RateLimitExceeded(“请求过于频繁,请稍后再试”)
```

这个方案比固定窗口更精准,而且完全是 Redis 原子操作,在高并发下没有竞态条件。

一个额外的设计细节:我们没有把 Redis 当成”缓存”去滥用——所有写入 Redis 的数据都有明确的 TTL 上限,并且我们定期用 INFO keyspace 监控 Key 数量和内存使用率,确保 Redis 的内存占用在可控范围内(我们给 Redis 分配了 2GB 内存,实际使用率稳定在 60% 左右)。

追问场景:面试官深挖分布式锁的”坑”,考察您是否真正在生产环境遇到过问题。

答:

遇到过。分布式锁最经典的坑就是 “锁超时 + 业务执行超时” 的组合问题,我们踩过并修复了。

问题现场:有个客户上传了一份 200 多页的扫描件 PDF,文件体积大、图片质量一般,DeepDoc 解析耗时超过了我们设定的锁 TTL(当时是 60 秒)。60 秒后锁自动释放了,但解析任务还在后台继续执行。此时另一个用户恰好也上传了同一份文件(可能来自不同的入口),发现锁已经不存在了,于是也启动了一个解析任务。结果同一份文件被解析了两次,不仅浪费了计算资源,还导致知识库中出现了重复的 chunk 数据,检索时出现了重复引用。

我们做了两轮优化:

第一轮:锁超时时间动态化 + 刷新机制(看门狗模式):

将锁的超时时间从固定 60 秒改为基于文件大小动态计算:ttl = max(120, file_size_mb * 30),对于大文件自动分配更长的锁 TTL。同时,我们实现了一个 “看门狗”(Watchdog)——解析任务启动后,每隔 30 秒检查一次是否还在运行,如果是则主动刷新锁的 TTL(用 Lua 脚本确保只有锁的持有者才能刷新)。

lua -- 锁续期 Lua 脚本:只有 value 匹配时才续期 if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("expire", KEYS[1], ARGV[2]) else return 0 end

第二轮:业务层面幂等性兜底(”二次检查”模式):

即使用了看门狗,仍然存在极端情况(比如 Worker 进程被强制 kill,锁没来得及释放)。所以我们在解析任务真正开始之前加了一个 “二次检查”:

```python
async def parse_document(file_hash: str, file_content: bytes):
# 先检查这个文件是否已经在知识库中且状态为 active
existing = await db.get_document_by_hash(file_hash, status=”active”)
if existing:
logger.info(f”Document already parsed, skipping duplicate task, hash={file_hash}”)
return existing.doc_id

# 如果不存在,再走真正的解析逻辑
...

```

也就是说,分布式锁是 “第一道防线”(防止并发启动),数据库的状态检查是 “第二道防线”(防止重复执行)。即使锁失效了,数据层面也不会产生重复记录。这两道防线结合起来,才真正实现了任务的幂等性。

Q10:长流程任务(文档解析等)中为什么用 Celery?如何保障 Worker 容错与任务不丢?

追问场景:考察技术选型能力,面试官想看您是否理解不同异步方案的适用边界。

答:

选 Celery 的主要原因是 “政务内网环境下的稳定性 + 生态成熟度”。我对比了三个方案:

1. Celery + Redis Broker(我们选的):

  • 优势:Python 生态的异步任务事实标准,文档极其完善,踩坑经验丰富。支持任务重试、定时任务(Celery Beat)、进度回调、任务状态追踪。Redis Broker 在政务内网轻量级部署非常方便,不需要额外引入重型中间件(比如不需要单独部署 RabbitMQ 或 Kafka 集群)。
  • 劣势:依赖 Redis 的持久化配置,如果 Redis 重启且未开启 AOF,可能丢失队列中的任务。我们通过 Redis AOF + 每日 RDB 备份来解决这个问题,同时核心任务(文件解析)的输入数据本身就存储在 MinIO 中,即使任务丢失了也可以由上传后的状态机触发重试。

2. 纯 asyncio + 后台 Task:

  • 理论上我们可以用 asyncio.create_task() 在 FastAPI 进程内直接跑后台任务,不需要引入额外依赖。但风险是:如果 FastAPI 进程重启,所有内存中的后台任务全部丢失,没有持久化保证;而且一个文件解析任务如果耗时 5 分钟,会占用一个 FastAPI Worker 的线程/协程资源,影响并发处理能力。政务内网环境对系统稳定性要求高,我们不愿意冒这个风险。

3. Apache Kafka(我没有选,但特意补充说明一下):

我在之前的公司(知道创宇的边缘网关项目)用过 Kafka,我知道它的优势是吞吐量极高、支持消息回溯、Consumer Group 水平扩展能力很强。但对于政务小灵通这个项目来说,我们的异步任务量很小(每天几十到几百个文件解析任务),用 Kafka 属于”大炮打蚊子”,而且 Kafka 的运维成本(ZooKeeper/KRaft、分区管理、消费者组偏移量管理)在内网环境中过于复杂,客户侧没有人能维护。所以最终选择了更轻量的 Celery。

总结选型逻辑:在”足够满足需求”的前提下,选择 运维成本最低、团队最熟悉 的方案,而不是追求”技术最前沿”。对于政务项目来说,简单可靠比高大上更重要。

追问场景:考察可靠性保障,面试官想要”确保任务不丢”的具体方案。

答:

任务丢失的风险分两种:“内存丢失”(Worker 进程崩溃时正在执行的任务丢了)和 “队列丢失”(Redis 重启后未消费的任务丢了)。我们分别做了防护:

针对”执行中的任务丢失”:

我们的 Worker 配置了 task_acks_late = True。默认 Celery 是”预取消息后立即确认(ACK)”,如果 Worker 随后崩溃,这条消息就不会被重新投递。开启 acks_late 后,Worker 会在任务执行完成后才向 Broker 发送确认。如果任务执行过程中 Worker 崩溃,Broker 会将该任务重新分配给其他存活 Worker(前提是任务幂等)。

```python

celery_config.py

task_acks_late = True
task_reject_on_worker_lost = True # Worker 异常退出时拒绝任务,重新入队
```

针对”Broker 重启导致队列丢失”:

我们用的是 Redis 作为 Broker,默认 Redis 只存内存,重启会丢队列。我们的方案是 开启 Redis AOF 持久化(Append Only File),并设置 appendfsync everysec。这样即使 Redis 意外重启,也能从 AOF 日志中恢复绝大部分队列数据。当然,极端情况下 AOF 也可能会丢失最后 1 秒的数据,但概率已经极低了。

同时,我们还有一个兜底——“状态机驱动的自愈”:我们有一个 Celery Beat 定时任务(每 10 分钟执行一次),扫描数据库中 status='uploading' 或 status='parsing' 但超过 30 分钟仍未变为 active 或 failed 的文档记录。这些”卡住”的文档会被重新投递到解析队列,并追加一条审计日志。这个自愈机制相当于把数据库当成了”最终真实状态源”,即使消息队列完全丢失了,我们也能从数据库状态中重建任务。

总结:我们追求的是 “最终一致性” 而不是 “绝对的 0 丢失” 。通过 acks_late + AOF 持久化 + 定时自愈扫描,我们把任务丢失的概率降到了极低的水平,并且在丢失发生时可以通过自愈机制快速补偿。

Q11:你们为什么做了 React Web 管理端 + 微信小程序两个端?它们的定位分别是什么?

追问场景:二面开场,面试官想了解您的产品思维和端侧划分逻辑。

答:

双端设计来自对政务场景三类用户的实际需求分析,不是“为了全栈而全栈”:

端侧 目标用户 核心场景 交互特点
微信小程序 基层窗口工作人员、企业办事人员 现场快速查询政策、回答群众咨询 轻量、碎片化、随时可用
React Web 管理端 政府内部业务骨干、科室负责人、系统管理员 知识库管理、文件批量导入、审计日志查询、复杂任务(政策对比报告)、系统配置 重度、桌面化、需要大屏操作

小程序的定位是“查询即服务”:窗口工作人员面对群众时,掏出手机打开小程序,输入问题就能得到带引用的答案,整个交互时间控制在 1-2 分钟内。我们刻意简化了小程序的交互——不做复杂任务编排、不做批量操作、不做管理配置,只做“一问一答 + 引用溯源”。

Web 管理端的定位是“治理与决策”:知识库的建设不是一个“上传即完成”的动作,需要持续治理。管理员在 Web 端查看文档解析状态、处理解析失败的文件、配置知识域权限、查看审计日志、生成统计报表、执行政策对比等复杂任务。Web 端支持多标签、大表格、富文本编辑等桌面级交互。

数据层面两个端完全同源——共享同一个后端 API、同一套权限体系和同一个知识库。用户的工作流可能是:在 Web 端上传并管理文件 → 窗口服务时在小程序端查询 → 事后回到 Web 端查看问答统计。这种“多端协同、数据统一”的体验,政务客户是比较认可的。

Q12:前端的工程化搭建你是怎么做的?有没有配置代码规范、打包优化这些?

追问场景:面试官想验证您的全栈能力不只是“能写 React 组件”,还包括工程化功底。

答:

虽然是独立开发,但我在前端工程化上投入了相当精力,因为一个好的工程底座能让后期维护成本大幅降低。具体做了以下几件事:

构建工具:Vite

我没有选 CRA(Create React App),因为它基于 Webpack 的冷启动和热更新在项目变大后变得缓慢。Vite 基于 ESBuild 的预构建和原生 ESM 的按需编译,在开发环境下几乎是秒启动。对于独立开发者来说,“改一行代码等 3 秒刷新”会严重打断心流,Vite 的体验好很多。

代码规范:ESLint + Prettier + Husky

我配置了 @typescript-eslint 的推荐规则集,Prettier 做自动格式化,Husky + lint-staged 在 commit 前自动检查和修复。虽然我是独立开发,但还是希望代码保持一致的风格,方便后续可能的团队扩展。Commit 规范用 Conventional Commits(feat:、fix:、docs: 等)。

API 层:基于 Axios 的统一封装

封装了一个 request.ts 模块:
- 自动注入 JWT Token(从 localStorage 读取)。
- 统一的错误拦截(401 自动跳转登录、500 统一 Toast 提示)。
- 请求/响应日志(开发环境打印,生产环境不打印)。
- 取消重复请求(防止用户快速点击导致的重复提交)。

业务层所有 API 调用都通过 api/user.ts、api/document.ts、api/chat.ts 等模块导出,每个接口都有完整的 TypeScript 类型定义(请求参数类型 + 响应数据类型),和后端的 Pydantic 模型保持同步。

打包优化

  • 路由懒加载:使用 React.lazy() + Suspense 按页面拆分 chunk,首屏加载只下载必要的代码。首屏的初始 JS 体积控制在 200KB 以内(gzip 后)。
  • Ant Design 按需加载:配置了 Vite 的插件 vite-plugin-style-import,只打包用到的组件和样式,避免整个 Ant Design 库全量引入。
  • 生产环境 Source Map:只生成 hidden-source-map,方便线上错误追踪但不会暴露源码。

Q13:统一认证和权限控制是怎么做的?流程是什么?

追问场景:这是全栈项目的核心能力,面试官必问。

答:

认证和权限分为 “登录认证” 和 “细粒度权限控制” 两个层面,我分别展开:

第一层:登录认证(JWT + 政务统一认证对接)

政务项目的登录不像互联网应用那样用“手机号+验证码”,而是要对接客户现有的身份认证体系。我们的方案分两种情况:

  • 部署在政务内网的项目:对接客户已有的统一身份认证系统(通常是 LDAP 或 OAuth 2.0 / CAS),用户在客户的组织内部系统中登录后,携带 SSO 票据访问我们的系统。后端验证票据有效性后,生成内部的 JWT Token 返回给前端。
  • 独立部署的轻量版:采用“用户名 + 密码 + 图形验证码”的经典方案,密码使用 bcrypt 加盐哈希存储,登录成功后生成 JWT Token。

JWT Token 的 payload 中包含 user_id、tenant_id、username、roles 等信息,有效期设为 2 小时。前端每次请求在 Authorization: Bearer <token> 头中携带,后端的中间件会验证 Token 签名和有效期,并解析出用户信息注入到 Request Context 中。

第二层:权限控制(RBAC + 数据域过滤)

权限分两个维度:“能不能做”(操作权限) 和 “能看到什么数据”(数据权限)。

操作权限(RBAC):

我们定义了 5 种角色,按角色分配菜单和按钮可见性:
- ROLE_SYSTEM_ADMIN:全部权限(创建租户、系统配置)。
- ROLE_TENANT_ADMIN:租户内全部管理权限(用户管理、文档导入/删除、审计日志查看)。
- ROLE_KNOWLEDGE_MANAGER:知识库管理权限(上传文档、管理分类、查看解析状态),无用户管理权限。
- ROLE_WINDOW_STAFF:小程序端专用,只能查询和浏览,无管理权限。
- ROLE_GUEST:访客模式(仅限 Web 端的公开政策查询,不登录可用,查询范围受限制)。

前端实现:在路由配置中为每个页面设置 permission 属性,使用一个 AuthGuard 高阶组件在路由守卫中做校验,无权限时重定向到 403 页面。菜单组件根据当前用户的 permissions 列表动态渲染(antd 的 Menu 组件配合 filter)。

数据权限(数据域过滤):

这一点我在前面的章节反复提到过——租户级别的数据隔离。前端在每次 API 请求中并不显式传递 tenant_id,因为后端中间件会从 JWT Token 中解析出 tenant_id,自动注入到所有数据访问层(Repository)。前端只要确保“当前登录用户是谁”,后端会自动限定其数据范围。

Q14:(追问)你们如何防止前端权限控制被绕过?比如恶意用户直接在浏览器控制台调用 API?

追问场景:面试官考察安全意识,想看您是否只做了“前端防君子不防小人”。

答:

这个问题很关键。我反复强调一个原则:前端权限控制只做“用户体验优化”(隐藏不可用的按钮和菜单),真正的安全边界在后端。

具体来说:

1. 前端任何权限判断都不作为安全决策依据。

菜单隐藏、按钮 disable、路由守卫——这些都只是让用户界面看起来更干净,但 API 层没有任何一个接口是“仅靠前端传参来决定权限”的。所有 API 接口在进入业务逻辑之前,都会经过一个统一的 PermissionMiddleware:

# FastAPI 中间件
async def permission_middleware(request: Request, call_next):
    user = request.state.user  # 从 JWT 解析
    path = request.url.path
    method = request.method

    # 从数据库或 Redis 中读取该用户的路由权限配置
    has_permission = await permission_service.check(user.roles, path, method)
    if not has_permission:
        raise HTTPException(status_code=403, detail="无权访问该资源")

    return await call_next(request)

这意味着,即使一个前端开发者恶意修改了页面代码、直接暴露了被隐藏的按钮,或者用 Postman 直接调用 API,后端会返回 403,不会执行任何业务逻辑。

2. 数据权限在 SQL 层面强制过滤。

后端的数据访问层(Repository)在组装 SQL 时,会从 request.state.user 中提取 tenant_id,自动拼接到所有查询的 WHERE 条件中。即使用户 A 通过某种方式拿到了用户 B 的 document_id 并发起请求,Repository 层的 tenant_id 过滤会使得查询结果为空,不会返回 B 的数据。

3. CSRF 与 XSS 防护。

  • CSRF:我们使用了 fastapi-csrf-protect 中间件,在非 GET 请求中要求携带一个从 Cookie 中读取的 X-CSRF-Token,前后端配合验证。
  • XSS:前端对用户输入的 Query 做了 HTML 转义,后端在输出时也使用了 bleach 库对模型生成的内容做安全过滤(尤其是管理端展示审计日志时,防止存储型 XSS)。

总结一句话:前端权限是“皮肤”,后端权限是“骨骼”。皮肤没了只是不好看,骨骼没了整个系统就塌了。 我们把安全防线建在骨骼上。

Q15:独立全栈交付时,前后端联调是怎么做的?接口契约与规范如何保证?

追问场景:简历中重点写了“安全审计日志”,面试官会确认这不是装饰性描述。

答:

审计日志是政务项目 “合规的硬性要求” ,不是可选项。我们设计了统一的审计日志表 t_audit_log,记录四类关键行为:

审计范围(四类事件):

事件类型 具体行为 记录字段
认证事件 登录、登出、Token 刷新、登录失败 user_id, ip, user_agent, result(success/fail), fail_reason
知识库操作 文件上传、文件删除、文件解析状态变更、知识库配置修改 user_id, tenant_id, target(file_id/file_name), action, detail(JSON)
安全事件 敏感词命中拦截、越权访问尝试、异常请求(SQL 注入特征等) user_id, ip, request_path, request_params, intercept_reason
系统配置变更 租户配置修改、权限分配变更、系统参数调整 user_id, tenant_id, config_key, old_value, new_value

技术实现细节:

  1. 异步写入:审计日志的写入走的是 异步通道(通过 Celery 任务或 Redis 缓冲队列),不阻塞主业务流程。因为每次请求都要写日志,如果同步写 MySQL 会导致响应延迟增加。
# 使用 Celery 异步写入审计日志
@celery_app.task
def log_audit(tenant_id: str, user_id: str, action: str, target: str, detail: dict, ip: str):
    AuditLog.create(
        tenant_id=tenant_id,
        user_id=user_id,
        action=action,
        target=target,
        detail=detail,
        ip=ip,
        created_at=datetime.now()
    )
  1. 敏感信息脱敏:detail 字段虽然是 JSON 格式,但在存储前会过滤掉密码、Token、身份证号等敏感字段,只保留“可审计但不可滥用”的信息。脱敏规则使用正则表达式匹配模式(手机号、身份证号、Token 格式等)。

  2. 不可篡改设计:审计日志表只允许 INSERT,不允许 UPDATE 和 DELETE(通过数据库权限控制)。业务代码中也没有任何“删除审计日志”的接口。所有日志永久保留(按合规要求保留 5-10 年,通过第 06 章的冷热归档方案实现)。

  3. 审计日志查看权限:只有 ROLE_TENANT_ADMIN 和 ROLE_SYSTEM_ADMIN 有权限查看审计日志,且查看时只能看到自己租户范围内的日志。日志列表支持按时间、用户、事件类型、IP 等多维度检索,并支持导出为 Excel/CSV 文件,供客户内部审计使用。

追问场景:面试官想听您讲“真实工作中的痛点”而非标准答案,考察问题解决能力。

答:

独立全栈最大的便利就是 “自己调自己” ——不需要等待前后端联调排期,改完后端接口,前端立即适配。但这种便利也带来了陷阱:因为太“默契”,容易忽略接口文档的重要性。 我就踩过一个坑:

典型问题:接口变更没有文档记录,一个月后自己也忘了。

项目开发到第二个月时,我修改了“文档列表”接口的返回结构,把原来嵌套的 authority 对象改为了扁平的 authority_name 字符串。改动很轻量,前端顺便就改了——当时一切正常。但一个月后,我在开发“审计日志导出”功能时,需要调用同一个接口获取文档名称,而我完全忘记了那个字段已经变了,写出来的代码报了 undefined 错误。查了半天才发现是自己改了接口没留任何记录。

事后养成的规范:
1. 先定义 OpenAPI 文件再写实现:所有接口先写 OpenAPI(Swagger)的 YAML 文件,前端基于自动生成的 TypeScript 类型定义来写代码。这样接口变更会强制更新 OpenAPI 文件,而 Git 的 diff 记录了所有历史变更。
2. FastAPI 自动生成 OpenAPI:FastAPI 的 @router 装饰器和 Pydantic 模型天然支持生成 OpenAPI 文档。我后来要求自己:任何接口的修改,都必须确保 /docs(FastAPI 自动生成的 Swagger UI)是可访问且准确的,这相当于强制记录了接口规范。
3. Mock 数据与真实 API 分阶段切换:项目初期前后端(虽然是同一个人)并行开发时,前端先用 mock 数据跑通 UI,等后端接口就绪后再切换到真实 API。mock 数据文件直接作为“接口契约”的参考基准。

这个坑让我深刻认识到:即使是一个人开发,也要把自己当成“两个角色”来运作——后端开发者应该用文档和规范来约束前端开发者的“自己”,否则在项目后期或者项目交接时,所有问题都会暴露出来。后来我引入 OpenAPI 后,这种“自己坑自己”的情况再也没有发生过。