07 前端架构与管理后台深挖问答(简历 bullet ④)
这一册的主战场是"重构的真实收益与代价"。面试官看到"把 15008 行的 App.tsx 拆分"一定会追问一句"拆完真的好了吗",而这一册的价值就在于:我能给出拆分前后都能自证的数字,包括对我自己不利的那些。 阅读顺序建议:先看第一节的架构总览,再按 Q1→Q24 顺序读。 每题统一四段式:【考点】(面试官在测什么)→ 【口述答案】(背这段)→ 【备注讲解】(不要背出声,是理解用的)→ 【代码依据】(IDE 里能跳过去验证)。
本册的证据纪律:所有数字都是我从代码里数出来的,并给了
文件路径:行号。本机没有安装node_modules(node_modules/backend/node_modules/admin/node_modules都不存在),所以测试用例数、tsc报错条数我跑不出来,只能引用仓库自己的记录并注明来源——凡是这类数字我都会写明"我未实测"。
〇、一页总览(先在纸上画出来再开口)
┌─ 主站 src/(React 19 + Vite 8,1104 个 ts/tsx、18.7 万行、54 个 feature 域、49 个页面)──┐
│ │
│ main.tsx ── 只做 5 件事:themeStore 副作用导入 / AppRouter / Toaster / │
│ NoticeHost / CreditRechargePrompt,外面套 ErrorBoundary + QueryClientProvider
│ │
│ ▼
│ router/index.tsx ── BrowserRouter + useRoutes(routes) + Suspense
│ │
│ ▼
│ router/routes.tsx ── 57 条 path,全部走 lazyWithReload(分片 404 → 重试 2 次 → 整页刷一次)
│ │
│ ▼
│ pages/*.tsx(49 个,一个路由一个 Page,Page 只做编排,不写业务)
│ │
│ ▼
│ features/<域>/(54 个域,每个域内部四件套)
│ ├── api.ts → react-query 的 queryOptions / hooks(服务端资源)
│ ├── hooks/ → 流程编排(useXxxFlow / useXxxSubmit)
│ ├── components/ → 展示组件
│ └── store.ts → 仅 7 个域有,其余走全局 src/store/
│ │
│ ├── 客户端/UI 态 ──► Zustand(src/store 19 个文件 + features 下 7 个 = 25 处 create)
│ └── 服务端资源 ──► TanStack Query(staleTime 30s / gcTime 5min / retry 1 / 关焦点重取)
│
│ 网络出口(三条,职责重叠)
│ ├── shared/lib/apiClient.ts 500 行,138 个文件引用 ← 主力
│ ├── shared/utils/request.ts 128 行,17 处引用 ← 历史遗留
│ └── 裸 fetch 6 处(2 处写死 /api,4 处用 API_BASE_URL)
└────────────────────────────────────────────────────────────────────────────────────────┘
┌─ 管理后台 admin/(独立 React 项目,87 个 tsx、28 个页面、32 个 <Route>、28 条 path)──────┐
│ router/index.tsx ── 三套布局:AdminLayout / DistributorLayout / SalesLayout │
│ 鉴权:adminAuthStore(token 存 shatangai_admin_token) + guards.tsx │
│ 后端同一 SECRET_KEY(JWT 24h)、payload 带 loginMode: admin|distributor|sales │
│ 唯一 strict:true 的包 + 唯一 build = tsc -b && vite build 的包 │
│ Recharts 只有三张图:MetricTrendChart / UserDistributionChart(饼) / RegistrationTrend(柱)│
│ 告警:useFailureAlerts 30s 轮询 + 5min 退避 + favicon 闪烁(故意绕开 admin request 封装)│
└────────────────────────────────────────────────────────────────────────────────────────┘三条口述铁律(一定要说出来)
- 拆分不是目的,边界才是。App.tsx 拆掉的收益是真的(Page 独立 chunk、职责可读),但它没有解决"巨型文件"这个模式问题——巨型文件只是从
App.tsx转移到了 hook 层(useAssistantChat.ts5228 行)。 - 状态管理的分界线是"谁拥有这份数据的真相":服务端数据归 TanStack Query,客户端/UI 态归 Zustand。我们有明确的越界案例,而且我知道它会在哪里出血。
- 前端能拿到多少信息,取决于后端愿意给多少。我们的进度条大部分时候是确定性伪随机动画,因为上游不返回细粒度进度——这件事我会主动讲,不会包装成"丝滑进度体验"。
一、技术栈与架构现状
Q1. 你简历上写的技术栈,能在代码里指给我看吗?
【考点】 这是"简历真实性"的第一道闸。答不上来后面全废。
【口述答案】 可以,我按实际装的版本说,不按我印象说。 主站 src/:React 19.2.4 + Vite 8.0.16 + Zustand 5.0.12 + react-router-dom 7.14.2 + TanStack Query 5.100.11 + Tailwind 3.4.1,加上 sonner 做 toast、lucide-react 做图标。 这里有个细节我会主动说清:react、tailwindcss 写的是 ^,锁文件实际解析到的是 React 19.2.5、Tailwind 3.4.19;而 vite 是钉死的 8.0.16(没有 ^),因为它跟 @vitejs/plugin-react 6 是绑着升的,我们不想被一个 patch 版本搞崩构建。 两个业务性的重要依赖:
@elah/editor0.3.2,三个包(/editor、/core、/timeline)全部钉死到0.3.2。它是我们的浏览器内剪辑器内核,上游是 0.x,一个 patch 就可能改内部 API,所以我们钉死并在src/features/clip-editor/里包了一层editorAdapter,业务代码不直接碰它的类型。mediabunny1.51.0 用来解析视频音轨(判"成片有没有音轨"),exceljs4.4.0 用来做剧本的 Excel 批量导入导出。 还有一个我要主动交底的:gsap3.15.0 是事实上的死依赖——全仓库只有一个文件shared/components/IntroSplash.tsx用它,而那个组件的引入在main.tsx里是被注释掉的。
【备注讲解】 技术栈题最容易被抓的是"你说了但代码里没有"。"^ 声明的版本"和"锁文件解析出的版本"不一致是常态,主动先说锁文件版本能立刻建立可信度。 gsap 那条要自己说出来,因为它证明你看过依赖树而不是抄了 package.json。这也自然引到 Q11 的"死代码"话题。
【代码依据】 package.json:27(@elah/editor: 0.3.2)、:35(@tanstack/react-query)、:37(exceljs)、:39(gsap)、:41(mediabunny: 1.51.0 钉死)、:45-50(react/react-dom/router/sonner/zustand)、:68-72(tailwind/typescript/vite 钉死 8.0.16/vitest);pnpm-lock.yaml:2390(react 19.2.5)、:2585(tailwindcss 3.4.19)、:262(@elah/editor@0.3.2);src/main.tsx:15(IntroSplash 被注释)、src/shared/components/IntroSplash.tsx:19-25(唯一的 gsap 使用点)。
Q2. 这个前端有多大?你是怎么量出来的?
【考点】 你是不是真的做过一个"大到需要治理"的项目,以及你对"规模"有没有量化习惯。
【口述答案】 我数了四个维度,都是可以现场敲命令复现的: 一、代码量:src/ 下有 1104 个 .ts/.tsx,合计 187676 行。 二、功能域:src/features/ 下 54 个目录,每个目录是一个独立业务域,比如 viral-remake(爆款复刻)、canvas(画布编排)、assistant(对话生成)、clip-editor(剪辑器)、script-library、long-video、digital-human…… 三、路由:src/pages/ 下 49 个页面组件,src/router/routes.tsx 里 57 条 path——页面数少于路由数是因为有一部分路由指向别的 feature 里的组件,也有重定向和参数化路由。 四、样式:src/index.css 3173 行。 最后那个数字其实是一个缺点:它说明我们没有把样式约束在组件里。index.css 里超过 1300 条自定义类选择器,本质是在 Tailwind 之上又手搓了一套设计系统,而且这套系统没有强约束——两个 feature 的人可以各自写一个几乎一样的 .glass-card。
【备注讲解】 面试官问"多大"真正想听的是**"大到什么程度开始出问题"。所以最后一定要拐到治理上:单靠约定维持的设计系统,在 54 个域、多人协作的规模下一定会漂移。 index.css 3173 行这个数字我建议主动交底**,因为它同时暴露了两个信息:我们确实做了统一视觉(加分),但用了最不可维护的方式(减分),而且我知道该怎么改(把设计 token 收进 Tailwind theme,把重复组件抽成 shared/components)。
【代码依据】 src/ 下 find src -name "*.ts" -o -name "*.tsx" | wc -l = 1104、find src \( -name "*.ts" -o -name "*.tsx" \) -exec cat {} + | wc -l = 187676;ls src/features | wc -l = 54;find src/pages -name "*.tsx" | wc -l = 49;grep -c "path:" src/router/routes.tsx = 57;wc -l src/index.css = 3173(其中以 . 开头的自定义选择器行 1308 条)。
Q3. App.tsx 不在了——你是怎么拆的?拆完真的好了吗?
【考点】 这是一整册里最核心的一题。面试官在测:你能否同时说出重构的收益和它的失败。只会吹收益的人答案一定被压穿。
【口述答案】 先说拆之前的样子:src/App.tsx 是 15008 行的单文件,一个文件里同时有路由分发、所有工作区的 JSX、几十个 useEffect、还有一堆业务函数。 我起的头,分支 refactor/front,合并提交的说明里写得很清楚:把它完全拆解为路由级独立 Page,业务逻辑抽到 feature 级 hook。 具体做了四件事:
- 删掉 App.tsx,建立 10 个独立 Page 路由(Shots / Storyboard / ViralRemake / Keyframes / CommonGen / AssetLibrary / VideoHistory / CreditsHistory / CreditsTopUp / Login);
- 抽出 14 个业务 hook(
useShotsFlow/useSubmitShotsGeneration/useWorkspaceHistoryFeed/useAuthAndCredits/useWorkspaceMedia/useStoryboardFlow/useViralRemakeFlow/useLongVideoFlow/useVideoHistory等); - 抽出 2 个非 hook 的 props builder(
buildWorkspaceLeftColumnProps/buildViralRemakeTabProps),专门收那些"纯拼 props"的代码; - 按 feature 切 store,并把
authUiStore合并进authStore;顺手把 整个src/的// @ts-nocheck清零。 收益是可以量化的,也是我敢直接说的:ShotsWorkspace.tsx从巨型文件降到 338 行,ViralRemakeWorkspace.tsx292 行;每个 Page 是独立 chunk,构建始终绿。但"拆完真的好了吗"——我的诚实答案是"一半好"。 好在:职责边界清楚了,Page 是编排层,一个路由的问题可以只看一个目录。 没好在:我们只搬走了代码,没有杀掉"巨型文件"这个模式。 拆完之后:
src/features/assistant/hooks/useAssistantChat.ts5228 行;src/features/canvas/CanvasWorkspace.tsx5121 行;src/features/canvas/CanvasScriptRunner.tsx4757 行。 而且我要主动说一个更难看的事实:后两个画布文件是在这次重构合并之后才创建的——CanvasWorkspace.tsx第一次提交是 2026-05-29,比重构合并(2026-05-21)晚 8 天。也就是说,同一支团队、刚做完一次巨型文件拆分,8 天之后又交了一个 5000 行的新文件。 所以那次重构给我的真正教训不是"怎么拆文件",而是:光靠一次 heroic refactor 治不了巨型文件,必须有持续生效的阈值门禁——而且我的这次拆分连一个文件行数上限的 lint 都没留下。
【备注讲解】 这题的杀伤力在于"8 天"。只讲"15008 → 0"是自我表扬;讲"5228 / 5121 / 4757 而且其中两个是重构之后新建的"才是对自己的方案下判断。 可以再往上抽象一层:"一次性的重构如果没有配套的约束,收益会在几个月内被下一个功能吃掉。正确做法是把'文件不能超过 N 行'放进 CI,让超限的 PR 红掉,而不是靠人记得。"——这句话正好可以接到 Q21 的 CI 现状(我们的 CI 只有一个后端 job,连前端 lint 都没有)。 如果面试官追问"那 5228 行的 useAssistantChat 里到底有什么",可以答:它其实不是组件,是一个模块级的单例状态 + 一大组纯函数(getChatState / setChatState / appendMessageFor / patchBatchFor / pollVideoTask / planLongVideoForMessage ……),它是被慢慢堆出来的,不是一次写成的——所以它的正确拆法是按"状态容器 / 消息树操作 / 轮询器 / 生成确认流程"四块切开,而不是按行数硬切。
【代码依据】 合并提交 60dc4809(git show -1 --format=%B 60dc4809:15008 行、10 个 Page、14 个 hook、2 个 builder、@ts-nocheck 清空、ShotsWorkspace 338 行 / −97.7%);ls src/App.tsx → No such file;wc -l src/features/assistant/hooks/useAssistantChat.ts = 5228、src/features/canvas/CanvasWorkspace.tsx = 5121、src/features/canvas/CanvasScriptRunner.tsx = 4757;git log --diff-filter=A -1 -- src/features/canvas/CanvasWorkspace.tsx → 3fb438d2 2026-05-29 增加canvs(对比合并提交 2026-05-21);grep -rn "@ts-nocheck" src/ 仅 1 处命中且在注释文本里,src/ 实际用法为 0 处。
Q4. 54 个 feature 域,目录是按什么切的?边界清楚吗?
【考点】 你有没有一套自己的模块划分原则,还是"来一个功能开一个文件夹"。
【口述答案】features/ 下的 54 个域基本按"用户能看到的一个功能入口"切,而不是按技术分层切。所以你会看到三种粒度的目录并存:
- 大域带完整四件套:
viral-remake/下面有api.ts、hooks/、components/、store.ts,还有流程步骤组件BatchStep1Upload/BatchStep2Combinations/BatchStep3Progress; - 薄域只有一层:比如
remake-guide/、landing/、home/、tour/; - 纯内核域:
clip-editor/里几乎没有页面组件,全是适配器(editorAdapter/editorCommands/editorMappers/projectPersistence/browserExport/embeddedAudio)和它们的测试。 store 的位置是有规矩的:src/store/放跨域共享的 19 个文件(authStore/historyUiStore/shotsStore/themeStore/tabConfigStore/preferenceModelStore/assetStore…);features/<域>/store.ts放只属于这个域的,一共只有 7 个(ai-clone/complex-remake/creative-remake/face-swap/pic-script-video/standard-remake/video-edit)。 边界不清的地方我也知道在哪:assistant/assistant-batch/assistant-skills/assistant-video-edit/assistant-run/agent-run这六个域名字上都是"助手",实际是同一条对话生成链路在不同时期的六个落点;canvas/canvas-image-node/canvas-video-edit-node同理。这些是演进留下的接缝,不是设计出来的边界。
【备注讲解】 主动指出"哪几个域之间边界是假的"比把目录念一遍有用得多。可以抽象成一句:"按功能入口切域的好处是找代码快,坏处是同一个能力在不同时期会开新域,最后靠命名前缀掩盖了它们是同一个东西。更好的做法是按能力切域,把"旧入口"退化成薄适配层。"
【代码依据】 ls src/features = 54 项;src/features/viral-remake/ 目录结构;ls src/features/*/store.ts = 7 个(ai-clone / complex-remake / creative-remake / face-swap / pic-script-video / standard-remake / video-edit);ls src/store/ = 19 个文件(含 3 个 .test.ts)。
二、状态与数据流
Q5. Zustand 和 TanStack Query 你各管什么?有没有越界?
【考点】 这是现代 React 项目最标准的考点。能自己说出越界案例说明你不是照文档抄的架构。
【口述答案】 分界线我讲成一句话:"这份数据的真相在服务端还是在浏览器里"。
- 服务端资源归 TanStack Query:视频历史列表、积分、素材库、任务状态、剧本、订单——有
queryKey、有staleTime、靠invalidateQueries刷新。 - 客户端/UI 态归 Zustand:当前激活的 tab、筛选条件、上传进度、主题、未提交的表单草稿、以及"这个工作区现在是不是在生成中"这种跨路由要保留的瞬时状态。 我们全局 query client 的策略是:
staleTime 30s、gcTime 5min、retry 1、refetchOnWindowFocus: false。最后这条是我们业务决定的:电商团队经常开两个窗口对着改,窗口一切回来就狂刷列表会把后端打爆,也会让用户正在填的表单被数据刷新打断。 越界案例有两个,我都能指给你看:第一个是重复实现请求层。features/shots/hooks/useWorkspaceHistoryFeed.ts不用 react-query,直接用api.get拉/video/history,然后把结果写进historyUiStore.videoHistory;而/history独立页面用的是features/video-history/hooks/useVideoHistory.ts,那是正牌 react-query。两份独立缓存,写同一个 store 字段——这个 hook 的注释里我自己写的第 10-12 行就是这个意思。 第二个是职责泄漏到 store 里。有些 store 的 action 内部会去发请求(比如assetStore的上传 action 直接调uploadFilesWithProgress),于是"客户端状态容器"变成了"半个数据层"。
【备注讲解】 这题的标准答案结构是:分界线 → 我们的实际策略 → 我们的越界 → 越界的代价。前两段大多数人都能说,后两段是分水岭。 可以再补一个抽象原则:"Zustand 里不应该出现 async 的网络调用。一旦某个 store action 是 async 并且 await fetch,它就已经是数据层了,就该被 react-query 的 useMutation 接管——因为 mutation 帮你管了 pending / error / 重试 / 失效,而你手搓的一定会漏掉其中一两样。"
【代码依据】 src/shared/lib/queryClient.ts:10-22(staleTime 30_000 / gcTime 5*60_000 / retry 1 / refetchOnWindowFocus: false / mutations retry 0);src/features/shots/hooks/useWorkspaceHistoryFeed.ts:2(import { api },不是 react-query)、:4(useHistoryUiStore)、:31(注释原文"是两份独立缓存,但都写到 historyUiStore.videoHistory(共享 state)");src/features/video-history/hooks/useVideoHistory.ts:11-33(react-query 版);src/store/assetStore.ts:215、:294(store action 里直接 uploadFilesWithProgress)。
Q6. 那"两份缓存写同一个 store"具体会出什么问题?
【考点】 能不能把抽象缺陷落成具体的、可观测的故障现象。
【口述答案】 会出三种可观测的错,我都说得出现象: 第一种,列表抖动(跳变)。useWorkspaceHistoryFeed 是 8 秒固定轮询,它拿到的分页顺序和 react-query 那份不是同一个时间点的。用户在 /history 页看到 12 条,切到工作区又变成 11 条,再切回来 12 条——同一个字段被两个写入者按各自的节奏覆盖。 第二种,进度被回退。两份数据里 progress 字段的值不一样。前端还有个本地进度模拟 ticker 每 1200ms 往 videoHistory 里写一次进度(useWorkspaceHistoryFeed.ts 里那个 8 秒轮询之外的另一条 1.2 秒定时器)。如果这时 8 秒轮询的响应里 progress 落后,先把进度条推到 70% 又被拉回 30%,用户会以为任务重跑了。 第三种,刷新节奏失控。同一份数据有 8 秒定时器、1.2 秒进度 ticker、focus/visibilitychange 唤醒刷新三套触发器,还有 react-query 那份的 refetchInterval。四路并发刷新同一份列表——这在 2026-08-04 那次"看起来像被 DDoS"的事故里,前端也是放大器之一。
【备注讲解】 面试官很吃"四路触发器打同一份数据"这种量化的说法,因为它说明你真的顺着代码数过。 要主动给出正确解法:"同一份服务端资源只能有一个缓存所有者。正确做法是把这个 feed 也改成同一个 queryKey 的 react-query 查询,让 react-query 去做去重和合并;本地进度模拟不要写回缓存,而是渲染时叠加计算(我们现在已经有 useDisplayProgress 这个纯函数能干这件事,等于工具都现成,只是历史原因没接上)。"
【代码依据】 src/features/shots/hooks/useWorkspaceHistoryFeed.ts:197(setInterval(fetchHistoryPreservingState, 8000))、:249(进度 ticker setInterval(tick, 1200))、:204-215(focus / visibilitychange 唤醒刷新)、:220-238(ticker 里直接 setVideoHistory 改 progress);src/features/video-history/hooks/useVideoHistory.ts:83-90(另一路的 refetchInterval)。
Q7. 请求层为什么有两套?apiClient 和 request.ts 的分工是什么?
【考点】 看你能不能正视"历史遗留代码",而不是假装架构是干净的。
【口述答案】没有分工,是历史遗留——我直接这么说。
src/shared/lib/apiClient.ts500 行,是主力:导出api.get/post/patch/put/del/upload/getBlob/postStream,138 个文件从它导入。src/shared/utils/request.ts128 行,是更早的一套:apiRequest/get/post/patch/del/uploadFiles/uploadFilesWithProgress,只有 17 处引用,几乎全集中在assetStore那几个老模块。 两者能力上重叠得很厉害,但行为不一致,这才是真正的问题。我举三个具体的:
- 401 的行为不一样。
apiClient的 401 会先判断"本地到底有没有登录态":有登录态才强制登出跳/login,访客在只读页面拿 401 是预期结果,留在原地。而request.ts根本没有 401 处理——它只处理 402 的充值弹窗,401 直接抛给业务侧。 - 鉴权头覆盖不一样。
apiClient三个入口(request/getBlob/upload/postStream)都注入X-Login-User+Bearer+X-Scene-Id。而request.ts的uploadFilesWithProgress只带X-Login-User和Bearer,没有X-Scene-Id;它的uploadFiles更彻底——一个鉴权头都不带。 - 超时能力不一样。
apiClient支持timeout+AbortController(还会正确合并外部signal),request.ts完全没有超时。 另外还有 6 处裸fetch绕过了这两套:2 处写死/api(useChatStream.ts:37、authStore.ts:150),4 处用API_BASE_URL变量拼(uploadAssistantVideo.ts/creative-remake/uploadVideo.ts/utils/upload.ts两处)。这些地方每一处都得自己重新拼鉴权头,漏一个就是 403。
【备注讲解】 这题最好的答法是把"重复"翻译成"不一致",再翻译成"后果"。只说"有两套,应该合并"很平淡;说出"访客态 401 在两套里行为相反、uploadFiles 不带鉴权头"就很硬。 正确解法也给一句:"统一到一个 transport 层,把 401/402/429/5xx 的策略集中在那里,然后删掉 request.ts。要做无痛迁移,可以先把 request.ts 改成薄壳包装 apiClient,再逐个迁调用点——因为只有 17 处引用,一天就能做完,它一直没做纯粹是因为没人被它咬过。"
【代码依据】 src/shared/lib/apiClient.ts:1-500(request 在 :228-293);src/shared/utils/request.ts:1-128;apiClient.ts:236-241 / :299-304 / :365-370 / :427-432(四处一致的鉴权头注入);request.ts:99-101(有 X-Login-User+Bearer、无 X-Scene-Id)、request.ts:66-71(uploadFiles 无任何鉴权头);apiClient.ts:154-156(shouldForceLogoutOn401)、:186-202(401 分支);request.ts 无 401 分支;裸 fetch:src/features/assistant/hooks/useChatStream.ts:37、src/store/authStore.ts:150、src/features/canvas/uploadAssistantVideo.ts:30、src/features/creative-remake/uploadVideo.ts:30、src/utils/upload.ts:29、src/utils/upload.ts:103。
Q8. apiClient 里那些全局错误处理,你是怎么设计的?踩过坑吗?
【考点】 全局拦截器是前端最容易被写坏的地方。这里能看出你对"通用 vs 业务"的边界感。
【口述答案】 它做四件事,每件都有一个"为什么长这样": 第一,401 的判据是"本地有没有登录态",不是"服务端返回了 401"。 见 Q7。这是被咬过的:原来无脑强制登出,结果访客停在只读首页时会被整页弹回 /login。我把这个判据抽成了一个纯函数 shouldForceLogoutOn401(storedLoginUser),就是为了能脱离 window / localStorage 直接单测。 第二,402 必须再分一次类。 后端用 402 表示"需要付费",但它同时用在并发扣费的锁争用上。我们只在文案真的是余额不足时才弹充值弹窗(用 isInsufficientCreditsText 判),否则只提示重试——因为给一个只是被锁撞了的用户弹充值页,等于骗他充钱。 第三,429 / 5xx 默认 toast,但留了 silent 开关。 所有轮询类请求都传 silent: true——否则一次 5xx 会弹一屏 toast。 第四,超时用 AbortController,并且要正确合并外部 signal。 这里有个容易错的细节:如果外部 signal 已经 aborted,要立刻 abort;否则要挂 addEventListener 并且用 { once: true }。另外报错时要区分"是我的超时触发的 abort"还是"外部主动取消的 abort"——外部取消不该弹"请求超时"。 还有一个我很满意的设计:handleGlobalError 里对 tabMaintenance / tabDisabled / tabRoleDenied 这三种场景级错误,会动态 import tabConfigStore 并更新本地 Tab 状态,让侧栏立刻把不可用的 tab 置灰,而不是等用户刷新。用动态 import 是为了打破 apiClient → store 的循环依赖。
【备注讲解】 可以补一句原则:"全局拦截器只该处理"所有调用方都会做出同样反应"的错误。一旦某个错误有一半调用方想自己处理,它就不该放在全局——这就是 silent 存在的意义,也是为什么 handleGlobalError 最后的注释是"其余客户端错误:交业务侧"。" 坑也可以讲成通用教训:"同一个 HTTP 状态码承载了两种业务语义(402 = 余额不足 / 锁争用)时,全局层必须能区分它们,否则你会在错误的场景里弹出最重的那张 UI。"
【代码依据】 src/shared/lib/apiClient.ts:166-226(handleGlobalError 全貌)、:154-156(shouldForceLogoutOn401)、:169-184(tab 场景错误 + 动态 import)、:203-215(402 二次分类)、:217-224(429/5xx)、:245-258(超时 + signal 合并)、:270-277(区分超时 abort 与外部 abort);src/features/credits/insufficientCredits.ts(isInsufficientCreditsText)。
三、长任务体验
Q9. 生成一条视频要几分钟,前端怎么知道它好了?你们用 SSE 还是 WebSocket?
【考点】 实时性方案的选型判断,以及"没有轮询的话我可以背很多名词"的照妖镜。
【口述答案】生成类任务我们既没有 SSE 也没有 WebSocket,用的是 react-query 的 refetchInterval 条件轮询。 全仓库 refetchInterval 出现在 13 个文件、19 处。 写法上有个统一套路——"存在非终态才轮询,到终态立刻停":
const TERMINAL: ReadonlySet<string> = new Set(['done', 'partial', 'failed'])
refetchInterval: (q) => (TERMINAL.has(q.state.data?.batch?.status ?? '') ? false : 3000)最强的档位是 3 秒(批量出片、任务详情),最弱的是 30 秒(一些说明性数据)。 为什么不用 SSE / WebSocket:
- 语义不匹配。SSE / WS 的价值在"服务端主动推"。但我们的生成进度本来就是服务端反过来去轮询上游供应商拿的(供应商不给我们回调),所以服务端自己也没有"新事件"可推——它只能转述它上次轮到的状态。上一个环节没有推,这一环推就没有意义。
- 多实例 + nginx 的成本。WS 要处理粘性会话、断线重连、跨实例广播,而我们目前的架构是"权威状态在数据库和 Redis",轮询天然对多实例友好、天然能在断线后自愈。
- 正确顺序是先解决上游:要让前端实时,第一件事是让供应商支持回调(我们在支付侧做过微信/支付宝回调,视频侧没做)。上游有了回调,SSE 才有东西可推。 但我得说清一个例外:我们有 SSE,只是只用在 LLM 对话的 token 流式输出上——
assistant和canvas那条链路的parse-stream/chat/stream,而且是用POST+fetch读ReadableStream手写解析data:行的,没有用浏览器原生的EventSource(因为EventSource只支持 GET、不能带自定义头,而我们需要带X-Login-User和Bearer)。所以准确说法是:"流式只服务于大模型的逐字输出,生成类任务的状态推进完全是轮询。"
【备注讲解】 这题的加分点全在**"为什么不用"而不是"用了什么"。而且"上游是轮询,下游推没意义"这句话,会让面试官觉得你在沿着数据流往上游追了一步**——这正是资深和普通的差别。 "EventSource 只支持 GET"是很硬的知识点,顺手说出来会显得你真写过。 如果追问"那轮询成本多大":可以说"3 秒一次、页面在跑才轮询、到终态立刻停;主要成本不是带宽而是 DB 查询,我们已经加了 5 分钟窗口的分页缓存,但没有做 ETag / 条件请求——这是可以继续优化的地方。"
【代码依据】 src/features/batch-production/api.ts:7(TERMINAL 集合)、:27(TERMINAL.has(...) ? false : 3000)、:38 / :110(15s);src/features/video-history/hooks/useVideoHistory.ts:83-90(按"生成中/部分完成"决定 3000 或 false);src/features/orders/OrdersPage.tsx:66(10s);src/features/remake-guide/api.ts:29(30s);SSE 流式:src/features/assistant/hooks/useChatStream.ts:37-80(fetch + res.body.getReader() + 手写 data: 解析)、src/features/assistant/batchPlanStream.ts:58、src/features/canvas/canvasRuntime.ts:453(都走 api.postStream);grep -rn "EventSource" src/ → 0 处。
Q10. 那个进度条是真的吗?(诚实加分题)
【考点】 面试官在测你敢不敢承认一个自己实现过的"善意的谎言"。这题答得越诚实分数越高。
【口述答案】大部分时候不是真实的进度,是一个确定性的伪随机动画。 我把它讲清楚。 展示层收口在一个纯函数 getDisplayProgress:
const raw = Math.max(backend, persisted, simulated, phaseFloor)
return Math.min(raw, profile.cap, 99)四个来源:
backend:后端给的progress。这是唯一真实的那个。但绝大多数供应商接口不返回细粒度进度,只在终态才告诉我们结果,所以它长期是 0 或几个粗粒度值。persisted:上一轮写进 localStorage 的值,作用是刷新页面别让进度条倒退。simulated:这一条才是进度条肉眼可见在动的原因。它是一个用mulberry32(hash(taskType:taskId:startedAt))播种的伪随机 S 曲线:种子拼的是任务类型:任务id:开始时间,所以同一个任务的每一次渲染都得到同一条曲线(不会乱跳),但不同任务看起来速度不同。曲线有 7 个时间控制点和 7 个数值控制点,用smoothstep分段插值,超时之后再用指数收敛缓慢爬升。phaseFloor:按状态/阶段给一个保底值。queued→5、preparing→4、uploading→8、submitted/polling→12、concatenating→94、muxing→96。 最后那个profile.cap是一个 94–99 之间的随机数(94 + Math.floor(rnd() * 6)),所以进度条永远停在 94~99% 而不是 100%,直到后端真的落终态才跳到 100。 为什么这么做,我的理由是:供应商不返回真实进度,而如果老老实实显示 0%,用户会以为卡死了、刷新、重复下单——用户看不到动的东西就会认为系统坏了。所以我们选择"用一个稳定的动画表达'还活着',而不是用一个真实的 0% 表达'不知道'"。 但我很清楚这个方案的问题:它在说谎。用户看到 96% 等了 5 分钟,感知上比看到"处理中"更糟——假精度会制造假预期。 改进路径我们已经留了口子:phaseFloor就是为真实阶段码准备的。正确做法是让上游产出真实的阶段码(腾讯 VOD 的PROCESSING/ 超分中 / 转存中 其实是可以拿到的),然后用真实阶段映射到一个离散的进度区间(0-100 换成"阶段 3/6"这种),同时把连续百分比在整个 UI 里去掉。这样用户看到的是"正在超分,第 4/6 步",既有信息量又不需要编数字。
【备注讲解】 这题的讲法顺序很重要:先承认"是假的" → 再解释"假在哪里(四个来源、播种算法、cap 94-99)" → 再讲"为什么当初这么做" → 最后给"怎么改进"。 绝对不要用"我们做了丝滑的进度动画"这种包装。面试官一句"所以这个数字不是真的?"就能把你打成不诚实。 可以补一句很有分量的话:"这件事让我改了一个判断习惯:UI 上任何"精确的数字"都在向用户承诺精度。如果我们给不出这个精度,就不该给这个数字——宁可给一个诚实的阶段描述。"这是从具体工程细节上升到产品判断,字节这种岗位很吃。
【代码依据】 src/shared/lib/progressSimulation.ts:101-129(getProgressProfile:种子 :103、cap = 94 + Math.floor(rnd() * 6) :107)、:70-77(mulberry32)、:136-153(interpolateProgress + smoothstep)、:155-164(getPhaseFloor 六档)、:172-191(getDisplayProgress 的 Math.max(backend, persisted, simulated, phaseFloor) 与 Math.min(raw, profile.cap, 99));src/shared/hooks/useDisplayProgress.ts:16(1200ms 重算 ticker);消费方 20+ 处,如 src/shared/components/ProgressBar.tsx:69、src/features/video-history/VideoHistoryPanel.tsx:28。
Q11. 你们仓库里有没有"死代码"?你怎么发现的?
【考点】 这是"别把死代码当亮点"的正面素材。看你会不会做自己的代码审计。
【口述答案】 有两个,我都主动交代: 第一个是 src/shared/hooks/usePolling.ts,完全没有调用点。 我当时的判断很直接:它写错了,所以没人敢用。它的问题有三个——默认 2 秒固定间隔、没有任何退避、没有次数上限;而且它的终止条件是看返回体里有没有 done / status === 'done' / 'succeeded' 三个魔法字符串——这三个字符串在我们后端一个都不存在(我们用的是 生成中 / 已完成 / 失败 和 done/partial/failed)。所以它天然永远不终止。 它为什么会存在?我猜是某次想抽一个通用轮询器,写完之后大家发现已经有 react-query 了,react-query 的 refetchInterval 是函数式、能拿到 query 状态、到终态自动停、还能复用缓存——没有任何理由再手搓一个 setInterval。于是它就留在那儿了。 第二个是 gsap。 全仓库只有 shared/components/IntroSplash.tsx 一个文件用它,而 IntroSplash 在 main.tsx 里是被注释掉的(注释说"开场动画暂时隐藏,保留组件备用")。所以 gsap 3.15.0 + @gsap/react 是一个 100% 进不了产物、也进不了任何执行路径的依赖。 我也说说我的结论:这两条的危害都不是性能(死代码和死依赖都会被 tree-shaking 或直接不被打包)。真正的危害是它们污染判断——usePolling.ts 会让后来的人以为"我们有一套自研轮询器",gsap 会让别人以为"动画是这个项目的一等公民"。所以我宁愿删掉,需要的时候从 git 历史里捞。
【备注讲解】 这题的价值是审美和判断,不是发现能力。"默认 2 秒无退避无上限"和"终止条件匹配的是不存在的字符串"这两个观察,说明我是读懂了才判断它是死代码,不是靠 grep 到 0 引用就下结论。 最后那句"死代码的危害是污染判断"是个很好的抽象,可以推广:"仓库里的每一个文件都是一个隐含的承诺。留着不用的东西,等于对后来的人撒谎。"
【代码依据】 src/shared/hooks/usePolling.ts:10-14(interval = 2000 默认值)、:27(done / 'done' / 'succeeded' 三条件)、:50(setInterval(tick, interval) 无上限无退避);grep -rn "usePolling" src/ 只有它自己定义处,0 处调用;package.json:39(gsap)、src/main.tsx:15(IntroSplash 被注释)、src/shared/components/IntroSplash.tsx:19-25(唯一使用点)、grep -rl "gsap" src/ = 1 个文件。
Q12. 用户看到生成失败时,他到底看到了什么?
【考点】 错误处理链路的设计,以及"你有没有想过用户看不懂英文报错"这个产品视角。
【口述答案】 链路上是三层,我一次说完。 第一层,收口在一个组件。 所有失败提示都走 toastGenerationFailure(raw, prefix),它把内容交给 <FailureReason raw={raw} /> 渲染,文案的唯一真相在 generationErrorUserMessage。这里有个刻意的设计:FailureReason 的 raw 类型是 unknown 而不是 string——因为提交前门禁的标记挂在 ApiError.payload 上,调用方一 String() 就把它丢了,那条能告诉用户"改哪里"的文案就会被兜底模板盖掉。 第二层,分类靠一份前后端共用的词典。 shared/generation-error-dict.json 是一份正则数组,把上游错误分类成五档:audit(内容审核)/ material(素材、版权)/ ours(疑似我方故障)/ billing(积分不足)/ unknown。前后端都读这同一份文件——前端 import 它,后端 require 它。 第三层,"我们能改的错"必须原样透传。 有三类错误绝不套兜底模板:① precheck(提交前门禁,后端在 4xx body 里带 errorKind: 'precheck',文案是我们自己写的、可执行的,比如"镜头15 台词 16 个朗读字,3s 最多 12 字");② curated(后端已经写好的成稿文案,这条盖住"失败原因根本不是审核"的一整类:上游欠费、限流、鉴权、参数拒绝);③ 积分不足(原文自带"剩余 X / 需 Y")。 第四件事,我们主动删掉了"查看英文原文"的入口。 FailureReason 的注释里写得很明确:2026-08-28 改造,原来的"查看原始报错"折叠 + 复制按钮 + "翻译成中文"按钮全部删除。理由是"用户读不懂的英文原文是'你们软件有 bug'这个印象的主要来源;折叠起来照样有人展开,等于这次改造只做一半"。原始报错只在管理端可见(客服按用户名+时间定位)。 所以现在的效果是:用户永远只看到一句成稿中文;要排查就找客服,客服在管理后台看得到英文原文。
【备注讲解】 这题可以升华成一个产品判断:"在用户端暴露机器码,收益是"工程师省事",成本是"用户对你的信任"。所以正确的做法不是"藏起来",而是分层:给用户结论,给客服证据。" 也有个诚实的补充可以说:"这套兜底模板本身是个风险——unknown 一律换成'常见原因是内容涉及版权形象…',而上游 502 和超时也落进 unknown。所以后来又补了一个 isTransportFailure 分支,把 502/504/超时/断网单独处理,因为'传输故障不是生成结论'。这类"补一个分支"的过程说明:兜底文案一旦写死原因,就会在下一类没见过的故障上变成谎报。"
【代码依据】 src/shared/lib/toastGenerationFailure.tsx:16-27(toastGenerationFailure 全貌)、:20(积分不足让路)、:27(duration: 15000);src/shared/components/FailureReason.tsx:6-9("不再有任何通往英文原文的入口")、:11("完整原始报错在管理端照常显示")、:18(raw: unknown);src/shared/constants/generationErrorDict.ts:43-49(RULES 从 json 构造)、:29(GenerationErrorCategory 五档)、:38-41(GENERATION_FAILURE_TEMPLATE)、:49-51(generationErrorUserMessage 统一过 hideVendorNames)、:57-76(precheck / curated / transport / credits 四条透传分支);shared/generation-error-dict.json;后端同一份:backend/services/errorExplain/staticDict.ts:58。
四、上传与素材
Q13. 上传一共有几条链路?为什么不是一条?
【考点】 系统的混乱程度你清不清楚;以及能不能为历史选择给出合理解释。
【口述答案】三条,而且我自己认为它应该收敛到一条。第一条,api.upload(apiClient 里的 multipart 方法),36 处调用。 走服务端中转,POST 到 /api/video/upload(或画布/创意复刻各自的 upload 路由),由后端再转存 OSS。它自动带 X-Login-User + Bearer + X-Scene-Id,还会先过 normalizeImageEntries 做图片归一。这是主链路。第二条,uploadFilesWithProgress(request.ts 里的 XHR 版),只有 assetStore 两个地方在用。 它存在的唯一理由是进度条——fetch 拿不到上传进度,只有 XMLHttpRequest 的 xhr.upload.onprogress 能拿到。所以这里是"为了一个浏览器的能力缺口,单独开了一条链路"。 第三条,smartUploadFile(src/utils/upload.ts),6 处调用,全部在 viral-remake 域。 它是"按大小分流"的:> 2GB 直接报错;> 200MB 走 OSS 表单直传(先向 /video/upload-signature 拿预签名,然后 POST 到 OSS host,不经过后端);≤ 200MB 才走服务端中转。 为什么会有三条:第一条是"最省事的默认选择";第二条是在某个页面上发现"用户看不到进度会以为卡死"临时加的;第三条是真的业务需要——爆款复刻用户上传的是参考原片,动辄几百 MB,走服务端中转会同时吃掉后端带宽、磁盘和 nginx 超时额度(我们把 Vite 的代理超时开到了 1800_000ms,也就是 30 分钟,就是为了它)。 我的判断:三条各自都说得通,但它们共享的能力没有抽出来——鉴权头、错误分类、402 充值弹窗判断、图片归一,每一处都是复制的。这就是为什么 uploadFiles(request.ts 里那个)一个鉴权头都不带却没人发现:它没有走任何一条主链路。
【备注讲解】 "三条链路各自都合理,但共享能力是复制的"是本答案的核心判断。可以再抽象:"当一条能力出现第二个实现时,就该抽公共 transport,而不是复制第三个。我们允许了第三次,于是鉴权头漏掉了 X-Scene-Id、402 判断漏了、错误文案格式也不一样。"
【代码依据】 grep -rn "api\.upload" src/ | wc -l = 36;src/shared/utils/request.ts:89-128(uploadFilesWithProgress,XHR);src/store/assetStore.ts:4(唯一导入者)、:215、:294(两处调用);src/utils/upload.ts:1(OSS_DIRECT_THRESHOLD = 200 * 1024 * 1024)、:2(OSS_DIRECT_MAX = 2GB)、:85-119(smartUploadFile 分流)、:22-79(uploadDirectToOSS);调用方 6 处全在 src/features/viral-remake/(KeyframeTestPanel.tsx:39,64、CreativeStep1Upload.tsx:88、BatchStep2Combinations.tsx:530、hooks/useViralRemakeFlow.ts:504、BatchStep1Upload.tsx:114);vite.config.ts:79-80(代理超时 1800s)。
Q14. 上传没有分片、没有断点续传——隐患是什么?
【考点】 你能不能主动指出一个用户能感知、但自己没解决的问题。
【口述答案】隐患很实在:一次几十分钟的上传,只要中途断一次就全部作废。 具体说:
- 没有分片:一个 800MB 的参考原片,是一次
FormData请求发出去的。OSS 表单直传的超时我设的是 10 分钟(xhr.timeout = 10 * 60 * 1000)。慢网用户(上行 2Mbps)传 800MB 需要约 53 分钟,会必然超时失败——不是概率问题,是必然。 - 没有断点续传:失败后只能从头再来。而失败原因最常见的恰恰是用户自己不会修的——OSS Bucket 没配 CORS(我把这个提示写进了报错文案里:
'OSS 直传失败,可能原因:1) OSS Bucket 未配置 CORS 跨域规则;2) 网络不通'),用户看到的是一句他看不懂、也改不了的话,他能做的只有再传一次然后再次失败。 - 服务端侧也不算可靠:后端火山 VOD 的上传重试是 默认 4 次(
VOLC_VOD_UPLOAD_MAX_ATTEMPTS,允许 1–8),退避是min(8000, 1000 * 2^(attempt-1)),也就是 1s / 2s / 4s / 8s——是指数退避,但没有 jitter。没有 jitter 的问题是:如果上游抖了一下导致一批客户端同时失败,它们会在同一毫秒一起重试,形成一次自制的惊群。 - 还有一个体验缺口:
> 2GB是直接抛错的("文件超过 2GB,请压缩后再上传"),没有给任何替代路径。
【备注讲解】 这题的关键是算一个数字:"上行 2Mbps 传 800MB 需要 53 分钟,而超时是 10 分钟" —— 这种"用具体数字证明这不是概率问题是必然问题"的说法非常有说服力。 改进方向也给清楚:"正确做法是改成分片 + 断点续传:OSS 支持 MultipartUpload,把文件切成 5–10MB 的分片,每片独立重试,并用 uploadId + 已完成分片列表做续传(状态存 localStorage 就够了,不需要服务端)。第二步是把退避加上 jitter(base * 2^n * (0.5 + random())),这是分布式重试的基本功。第三步是把 2GB 上限改成软限制并给出压缩/切片引导,而不是一句报错。"
【代码依据】 src/utils/upload.ts:76(xhr.timeout = 10 * 60 * 1000)、:69-72(CORS 报错文案)、:91-93(>2GB 直接抛错)、:42-46(一次性 FormData 装整个 file,无切片);全仓库 grep 无 MultipartUpload / uploadId / partNumber 相关实现;后端重试:backend/services/volcVod/upload.ts:14-17(configuredAttempts 默认 4、范围 1–8)、:89(backoffMs = Math.min(8_000, 1_000 * (2 ** (attempt - 1))),无 jitter)、:69-77(retryTransientOperation)。
Q15. 大文件 OSS 直传的签名流程,你是怎么设计的?
【考点】 前端能不能设计一条"不经过自己后端传文件"的链路,以及有没有安全边界意识。
【口述答案】 流程是四步:
- 前端向前端后端要签名:
POST /api/video/upload-signature,body 只有{ filename, contentType },请求头带我们的鉴权(X-Login-User+Bearer)。注意这一步走的是fetch手写,不是apiClient。 - 后端返回
{ host, objectKey, publicUrl, formFields }。formFields是 OSS 表单直传要的一堆预签名字段(policy、Signature、OSSAccessKeyId、key等)。 - 前端把这堆字段逐个
formData.append,最后再 appendfile,然后XHR POST到host。 关键点:签名里已经把 key、过期时间、大小上限都限制住了,所以前端拿到的这个签名只能传这一个对象——这是这套方案的安全边界,AccessKeySecret从来没到过浏览器。 - 返回的
publicUrl直接当成素材 URL用,响应结构故意跟/video/upload保持一致({ url, name, originalname, mimetype }),这样调用方不需要知道自己走的是哪条链路。 另外三个细节:
- 进度用了 XHR:
xhr.upload.addEventListener('progress', ...),只在e.lengthComputable时回调。 - 错误分了四类:
load里判 2xx;error事件给"可能是 CORS 或网络不通"的文案;abort给"上传已取消";timeout给"OSS 上传超时"。 - 阈值是 200MB,不是注释里最早写的 50MB——
uploadDirectToOSS上方的注释还写着">50MB",跟常量已经不一致了,这是一处我知道的注释腐化。
【备注讲解】 这题答的是"签名的安全边界":预签名表单直传的意义在于让浏览器绕过自己的服务器,但又不给浏览器凭据。要说清"签名的 policy 限制了 key 和过期时间"。 主动指出注释与常量不一致(>50MB vs 200MB)是个小加分——说明你读注释也读代码,而且知道注释会腐化。
【代码依据】 src/utils/upload.ts:29-37(拿签名 + 校验 sigData.success/host/formFields)、:39-46(formFields 逐个 append 再 file)、:48-78(XHR 上传::54-58 进度、:61-67 成功、:69-72 CORS 文案、:73 abort、:74 timeout)、:19(注释写 ">50MB",与 :1 的 200MB 常量不一致)、:1(OSS_DIRECT_THRESHOLD);:113-118(返回结构与 /video/upload 对齐)。
五、管理后台
Q16. 管理后台为什么是独立项目?它和主站是什么关系?
【考点】 多前端项目的组织判断,以及"一个仓库多个 app"的工程约束你清不清楚。
【口述答案】admin/ 是一个完全独立的 Vite + React 项目,跟主站共享的只有后端和 shared/ 下那几份 json。 规模我数过:admin/src 下 87 个 .tsx、28 个页面组件、router/index.tsx 里 32 个 <Route>、其中 28 条带 path,加 admin-dist 独立产物目录、独立端口 5175。 独立的三条理由:
- 交付节奏不同。管理端改的是运营看的报表和客服工具,主站改的是生成链路,同一套发布流程会把两边互相阻塞。
- 体积。主站已经在做逐页 chunk 了,没有理由再往主站的依赖图里塞
recharts和xlsx。管理端现在有recharts2.15 和xlsx0.18 两个主站完全没有的依赖。 - 权限模型不同。主站是"用户 + 积分",管理端是"三种角色的后台":
AdminLayout/DistributorLayout(分销商)/SalesLayout(商务),由loginMode: 'admin' | 'distributor' | 'sales'驱动,guards.tsx里三个RequireXxxAuth分别守三类路由。 但我要主动说清"独立"带来的代价,这是它真正的问题:
shared/下的 json 是通过相对路径../../../shared/...引入的,前端import、后端require。这不叫类型共享,叫文件共享(详见 Q19)。- 后端路由挂载方式不一致:管理端的路由整体挂在
/api/video前缀下(app.use('/api/video', adminRoutes)),而少数几条(比如企业)单独挂在别的路径上。写错前缀后端照样 404,而单测如果拿同一个错 URL 断言会全绿——我们的告警 hook 注释里专门警告了这一点,因为踩过。 - 它没有复用主站的任何 UI 组件。同一个"失败原因展示"在主站是
FailureReason,在管理端是另一套FailureReasonBlock。这是故意的(管理端要看英文原文、主站不看),但两份实现的维护成本是真的。
【备注讲解】 "独立 app"这题的正解是给出代价,不是只给理由。可以补一句:"多 app 仓库的成熟标志是抽出一个共享包(UI kit + contracts),而不是靠相对路径互相 import。我们现在停在'文件共享'这一级。"
【代码依据】 admin/package.json:8(build: tsc -b && vite build)、:18-19(recharts / xlsx);admin/vite.config.ts:24(outDir: 'admin-dist')、:10(端口 5175);admin/src/router/index.tsx:1-36(三布局 + lazy() 28 个页面)、grep -c '<Route' admin/src/router/index.tsx = 32、grep -o 'path="[^"]*"' | wc -l = 28;admin/src/router/guards.tsx:22 / :34 / :47(三种角色守卫);admin/src/features/alerts/useFailureAlerts.ts:41-44(前缀警告注释);shared/*.json 相对路径引用(如 src/shared/constants/generationErrorDict.ts:1、backend/services/errorExplain/staticDict.ts:58)。
Q17. 管理后台的工程化比主站严格——这是怎么形成的?
【考点】 你会不会给不同风险的代码配不同的门禁。这是"工程判断"而不是"配置抄写"。
【口述答案】 是的,而且这个差异是有理由的,不是随手配的: 第一,admin/ 是仓库里唯一 strict: true 的包。 主站根 tsconfig.json 和 backend/tsconfig.json 都是 strict: false。 理由我讲得出来:主站和后端是"一边长一边松"的存量代码(后端甚至有 54 个文件首行 // @ts-nocheck),在存量上打开 strict 会一次性产生几百上千个错误,没人有产能去修;而管理端是从零开始的新项目,从第一天就 strict 的成本几乎是零。这就是"新代码有特权"的现实版——新项目应该把标准拉满,因为它是唯一成本低的时刻。第二,admin 的 build 是 tsc -b && vite build,是仓库里唯一把类型检查挂在构建上的包。 主站的 build 只有 vite build——Vite 用 esbuild 转译,不做类型检查,所以主站构建永远不会因为类型错误失败。这意味着主站的 typecheck 是"可选动作",而管理端的类型检查是"过不去就出不了包"。 第三,测试密度不同。 admin 有 18 个测试文件,覆盖的都是"错一次代价很大"的地方:useFailureAlerts、faviconFlasher、路由守卫。主站 221 个。 但我要主动交底一个反例:tsconfig.node.json——那个只为 vite.config.ts 服务的小项目——也是 strict: true。所以严格说,"唯一 strict 的包"这个说法只对应用代码成立,配置文件那一档一直是严的。我不想把这个说法说得比事实更漂亮。
【备注讲解】 核心观点是"新代码的特权":在存量上补 strict 是昂贵且低回报的,在新项目上开 strict 是几乎免费的。判断标准是"这段代码未来还会不会长",而不是"哪个包更重要"。 可以延伸:"我后来想的更彻底的做法是给新文件开 strict、给存量文件留白名单(TS 支持 per-file @ts-strict 或者用单独 tsconfig include 新目录),否则'整个包 strict: false'会让所有新代码一起降级——我们主站现在就是这个状态。"
【代码依据】 admin/tsconfig.json:15("strict": true);tsconfig.json:14("strict": false);backend/tsconfig.json:9("strict": false);tsconfig.node.json:8(也是 "strict": true);admin/package.json:8(tsc -b && vite build)对比根 package.json:12("build": "vite build");admin/src/features/alerts/__tests__/useFailureAlerts.test.ts、faviconFlasher.test.ts,共 18 个测试文件。
Q18. 告警轮询为什么绕过自己的 request 封装?这不会不一致吗?
【考点】 这题很妙——它测你会不会为了一个正确的例外打破抽象,以及你能不能讲清"代价"。
【口述答案】 会不一致,是我们主动选的不一致,注释里写得很直白。 背景是这样的:管理端有个"生成失败实时告警",useFailureAlerts 每 30 秒轮询一次 /api/video/admin/generation-alerts,有新失败就在页面顶部出横幅 + 让 favicon 闪烁。 而 admin/src/shared/utils/request.ts 的 apiRequest 遇到 401 就清 token 并整页跳 /login。问题就来了:一个后台轮询,会因为一次 401 把一个正在填报表的管理员直接踹下线,填了一半的东西全丢了。 而这个时间段还特别危险——注释里说"管理端令牌吊销上线后会强制重登一次,正是高发期"。 所以 fetchAlerts 故意不用 apiRequest,直接 fetch,而且任何非 200 都只返回 null,绝不抛错、绝不跳登录,由调用方静默退避:
- 正常间隔 30 秒(
POLL_INTERVAL_MS = 30_000); - 连续失败后退避到 5 分钟(
BACKOFF_INTERVAL_MS = 5 * 60_000),注释写的是"别对着一个挂了的接口每 30 秒敲一次"。 另外两个细节: - "已读水位"存 localStorage(
admin.alerts.lastSeenAt),而且首次挂载的 since 取now(),不翻旧账——否则一登录就被历史失败炸一脸。 - 写 localStorage 失败不报错(隐私模式下写不进去),退化成"本次会话内存",刷新后重新从
now()起算。这是功能降级而不是故障。 代价我也认:这条链路和apiRequest的行为不一致(一个会跳登录、一个不会)。所以我的判断是——"一次性/用户主动触发"的请求走有 401 跳转的封装;"后台轮询"必须走不跳转的路径,因为轮询失败的成本必须远小于它保护的东西。
【备注讲解】 这题的关键是**"抽象有例外,例外要有理由,理由要写进注释"。很多候选人会把"绕过封装"当成技术债来道歉,实际上这是一个正确的设计决定**——判断依据是"这个请求失败的代价应该由谁承担",而不是"代码要统一"。 可以抽象成一句:"不是所有请求都该共用同一个错误策略。策略应该跟'这次请求失败的后果'绑定,而不是跟'它是不是 HTTP 请求'绑定。" 主动指出代价(两套行为并存)会让这个答案更可信。
【代码依据】 admin/src/features/alerts/useFailureAlerts.ts:36-44("故意不走 apiRequest"的完整注释 + 路由前缀警告)、:21(POLL_INTERVAL_MS = 30_000)、:23(BACKOFF_INTERVAL_MS = 5 * 60_000)、:46-63(fetchAlerts:非 200 返回 null)、:96-98(首次 since 取 now())、:65-80(lastSeen 读写 + 静默降级)、:3(faviconFlasher);对比 admin/src/shared/utils/request.ts:26-34(401 清 token + 跳 /login)。
六、类型共享与工程质量
Q19. 前后端类型是怎么共享的?(面试官最爱问)
【考点】 这是可以拿高分也可以翻车的一题。标准答案是"抽 contracts 包",而我们要讲的是**"我们没做到,我用什么替代了"**。
【口述答案】准确说,我们没有共享 TypeScript 类型。我们共享的是"数据文件 + 一份手工镜像的联合类型 + 测试",三件拼起来的替代品。第一件,9 份 shared/*.json 规则文件,前后端读同一份物理文件。 前端走 import(靠根 tsconfig 的 resolveJsonModule),后端走 require:
generation-error-dict.json:错误分类正则 + 中文文案(前端import、后端require);script-shot-parsing.json、shot-beat-allocation.json、storyboard-styles.json、storyboard-reference-billing.json、script-video-commands.json、remake-mode-options.json、character-role-normalization.json、aspect-ratio-mentions.json。 共享的是"内容",不是"结构"。 后端require一个 json 不产生任何静态类型——它拿到的是any。 第二件,手工镜像的联合类型。 举个我能当场指出来的例子:错误的五个分类'audit' | 'material' | 'ours' | 'billing' | 'unknown'在两个地方各自写了一遍——前端src/shared/constants/generationErrorDict.ts:29和后端backend/services/errorExplain/staticDict.ts:56。两份字面量手工保持同步,没有任何机制保证它们一致。 如果有人只改一边,tsc不会报错,因为它们本来就是两个无关的类型。 第三件,测试直接读前端源码文本做断言。backend/services/__tests__/navCatalog.test.ts里有一条"哨兵测试",它用readFileSync读主站src/shared/components/MainNavTabs.tsx的源码文本,用正则抠出ALL_TABS数组里的 id,再跟后端的NAV_CATALOG做deepEqual。它连"抠出 0 个 id"这种情况都做了防御(assert.ok(ids.length > 0, '从 ALL_TABS 里一个 id 都没抠出来,正则失效了,不是导航清单真的清空了'))——说明作者很清楚这种测试的失效模式:它必须防止自己假绿。根 tsconfig 的paths里只有"@/*": ["./src/*"],没有任何跨包映射。 后端有自己独立的tsconfig.json和独立的node_modules(pnpm install --prefix backend),两边在编译器眼里是两个互不相干的世界。 我的判断很明确:这套做法的好处是它真的抓到了漂移(哨兵测试比人工 review 强),坏处是它用运行时的文本匹配去代替编译期的类型检查,代价是脆:前端改个变量名、换个数组写法,测试会说"正则失效了",而不是"类型不匹配了"。 正确做法是抽一个packages/contracts(或者shared/直接变成一个 workspace 包):
- 把类型定义放进去(导航项、错误分类、分镜结构、任务状态),前端和后端都
import type; - json 也变成有类型的导出(
export const RULES: ErrorRule[] = [...]),或者用as const+ zod schema 提供运行时校验; - 根 tsconfig 加
paths映射,让tsc在编译期就抓住不一致,那条读源码的哨兵测试就可以删掉了——它存在的唯一理由,就是编译期抓不到。
【备注讲解】 这题必须先承认"没有类型共享",再说替代品,最后给正确做法。任何试图把"共享 json"说成"类型共享"的答案都会被一句"那后端 require 一个 json 拿到的是什么类型"打穿。 "哨兵测试存在的唯一理由是编译期抓不到"是这个答案的点睛之笔,它把"我们有一个聪明的 workaround"转化成了"我清楚这个 workaround 的病根在哪"。 如果追问"为什么不直接做":可以说"因为后端和前端是两个独立的 pnpm 项目,没有 workspace 根(根 postinstall 只是 pnpm install --prefix backend),要抽 contracts 得先把仓库改成 pnpm workspace。这是一个结构性改动,不是随手能加的——但它是值得做的第一件事。"
【代码依据】 tsconfig.json:19-20(baseUrl + paths 只有 @/*,无跨包映射);package.json:9(postinstall: pnpm install --prefix backend --frozen-lockfile,说明后端是独立安装);src/shared/constants/generationErrorDict.ts:1(import dict from '../../../shared/generation-error-dict.json')对比 backend/services/errorExplain/staticDict.ts:58(require('../../../shared/generation-error-dict.json'));手工镜像联合类型:src/shared/constants/generationErrorDict.ts:29 vs backend/services/errorExplain/staticDict.ts:56;哨兵测试:backend/services/__tests__/navCatalog.test.ts:4(readFileSync)、:32-62(读 MainNavTabs.tsx + 正则 + deepEqual)、:55-59(防假绿断言);9 份 json:ls shared/*.json。
Q20. 你说"全栈 TypeScript"——类型安全的真实边界在哪?
【考点】 简历上写了"TypeScript 全栈",面试官就会来拆。你必须比面试官更清楚哪里是空的。
【口述答案】 我先把仓库自己的说法和事实的差距摆出来,因为这是我简历上最需要修正的地方。 ARCH.md 里写着"前后端 51 个文件已迁移为 .ts/.tsx,类型检查零错误"。这句话是不成立的。 有两处硬证据:
ARCH.md说"51 个文件",而现在是 1104 个 ts/tsx(仅src/),后端还有几百个——这句话是几年前的快照,早就失效了(同页还写着"单文件前端:App.tsx仍约 10k 行",而App.tsx现在已经不存在了)。- "零错误"更不成立:仓库自己的计划文档里反复记录着后端
tsc --noEmit的基线——159条(2026-08-27)→282条(2026-08-28)→~335条(2026-09-02)。这些文档的措辞是"只比增量,不要清零"。到 2026-09-05 的文档里,"约 335"已经是默认基线了。 事实层面,四个数字: strict只有 admin 开着。根tsconfig.json和backend/tsconfig.json都是strict: false。(严格说tsconfig.node.json也是true,但它只管vite.config.ts一个文件。)- 后端有 54 个文件首行是
// @ts-nocheck——也就是整文件类型检查被关掉。其中最大的是backend/routes/video.ts(8573 行),另有routes/longVideo.ts、routes/picScript.ts、routes/standardRemake.ts,以及services/viral/下一整个目录(viralRemakeService.ts、creativeRemakeVideoService.ts、keyframeGrid.ts、productExtractor.ts等十几个)和三个视频供应商实现(providers/ark.ts、kuaizi.ts、seedance.ts)。 src/里: any出现了 990 次(后端: any是 3194 次,admin 是 107 次)。- ESLint 里
@typescript-eslint/no-explicit-any是'off'——所以那 990 个any没有任何工具会提醒你。而且pnpm lint的作用域是eslint src,backend/和admin/根本不在 lint 范围内。 但我要为其中一部分辩护:
- 前端
src/的@ts-nocheck是被清到 0 的,这是那次 App.tsx 重构的一部分(合并提交里写着"整个 src 范围内 @ts-nocheck 清空为 0 个文件")。所以前端的类型状况比后端好得多。 - 后端的
@ts-nocheck很多是"被迫"的:routes/video.ts8573 行里混着十几种生成模式的历史分支,还有一些第三方调用是require进来的 js。在 tsx 直跑、没有编译步骤的运行时下,关掉类型检查能让它跑起来——这是一个"能跑"换"能查"的交易。 strict: false不等于没有类型:我们仍然有接口定义、有 react-query 的泛型、有as const的联合类型。它只是放弃了 null 检查和隐式 any 检查这两类最有价值的部分。如果面试官问我"这算不算简历造假"——我会说:简历写"TypeScript 全栈"是准确的(我们真的全栈用 TS),但"零错误"那句话我不该抄进简历,因为它来自一份过期的文档,而且后端 tsc 现在是红的。这是我的问题,我应该先跑一遍再写。
【备注讲解】 这题的正确姿态是比面试官更严格地审视自己。四层结构:引用仓库自己的说法 → 指出它不成立 → 给出真实数字 → 为其中合理的部分辩护 → 给改进路线。 主动讲"59 个文件 @ts-nocheck"时要说清分布(routes 4 个 + services 48 个 + scripts 4 个左右),因为"大面积集中在 services/viral/"说明这是一个特定模块的历史负担,而不是全仓库的性质——这个区分很重要,它让问题看起来是可收敛的。 改进路线:"① 把 @ts-nocheck 变成一个白名单文件(写在 CI 里),只减不增;② 后端按目录逐步开 strictNullChecks(先开这一个,收益最大、代价最小);③ ESLint 打开 no-explicit-any 为 warn,作用域扩到 backend + admin;④ 修正 ARCH.md 里那两处过期声明。**"
【代码依据】 ARCH.md:289("51 个文件…类型检查零错误")、ARCH.md:295("App.tsx 仍约 10k 行");tsconfig.json:14(strict: false)、backend/tsconfig.json:9(strict: false)、admin/tsconfig.json:15(strict: true)、tsconfig.node.json:8;grep -rl "^// @ts-nocheck" backend/ --include=*.ts | wc -l = 54(分布:routes/ 4 + services/ 48 + scripts/ 4);wc -l backend/routes/video.ts = 8573;grep -rn ": any" src/ --include=*.ts --include=*.tsx | wc -l = 990(admin 107、backend 3194);eslint.config.js:30('@typescript-eslint/no-explicit-any': 'off')、:13(files: ['src/**/*...'],不含 backend/admin);package.json:20(lint: eslint src);基线来源:docs/superpowers/plans/2026-08-27-enterprise-projects-cost-attribution.md:27(159)、docs/superpowers/plans/2026-08-28-enterprise-cycles-p3-reporting.md:24(约 282)、docs/superpowers/plans/2026-09-05-alipay-payment-channel.md:550(约 335)。(这四类数字中,用例数/报错条数我未在本机实测——仓库没有安装 node_modules;文件计数与 grep 计数是我实测的。)
Q21. 测试和 CI 的真实情况是什么?
【考点】 看你会不会甄别"看起来很多"和"真的在跑"。
【口述答案】测试文件数看着很健康:后端 349 个、前端 src/ 221 个、admin/ 18 个。(这三个数是我 find 出来的。用例条数我未实测——本机没有 node_modules,跑不了 suite;简历上如果要写"约 3600 个用例",得先跑一遍才能写。) 但"真的在跑"这件事上,有三个洞我主动说:第一个洞:前端有两套测试 runner,而且大部分不在自动门禁里。
vitest 4(锁文件解析到 4.1.10);- legacy 的
node:test——pnpm run test实际执行的是node tools/run-frontend-tests.cjs,另有test:frontend-legacy跑src/**/__tests__/*.test.ts。 两个 runner 不能混跑:vitest 的include里不能收src/**/__tests__/*.test.ts,因为那批文件import 'node:test',被 vitest 收进去会直接失败——这个坑写在vitest.config.ts的注释里。于是就有了下面这个更严重的问题。 第二个洞(这个最要命):vitest.config.ts的include是一份约 60 条的显式清单,不是 glob。 它一条条列出src/features/clip-editor/**/*.test.{ts,tsx}、src/features/video-edit/**/*.test.tsx……后果是:新开一个 feature 目录写测试,如果没人记得往这个数组里加一行,这个测试永远不会被执行,而且永远不会有人发现。 更糟的是——在 CI 里也是绿的,因为它根本没被收集。这是一个"静默失效"型的工程缺陷,比测试失败危险得多。 第三个洞:CI 实际上没有在跑。 .github/workflows/ci.yml只有一个 job:backend-tests,跑cd backend && pnpm test。前端和 admin 的测试、tsc、eslint一个都不在 CI 里。- 而且这个唯一的 job 从 2026-08-08 起因账户欠费根本没启动过。这不是我猜的——
.githooks/pre-push的注释原文写着:"GitHub Actions 自 2026-08-08 起因账户欠费被拦,job 根本没启动,ci.yml 实际上一直没执行。" - 团队的反应是写了一个 pre-push hook 去顶替 CI(跑 backend 单测,约 30 秒,拦住"重复迁移版本号"这类会让后端启动崩的回归)。但——这个 hook 实际上没安装。
core.hooksPath没有设置(git config core.hooksPath返回空),.git/hooks/下只有*.sample模板文件,仓库里的.githooks/pre-push只是一个躺在目录里、永远不会被 git 调用的文件。要启用它得先执行git config core.hooksPath .githooks,而仓库里没有任何脚本或文档做这件事(我 grep 过package.json和scripts/,没有相关配置)。 所以最诚实的总结是:我们写了 588 个测试文件,但唯一自动化执行它们的通道在 2026-08-08 就断了;补上的那条通道(pre-push)没有被激活;前端和 admin 从一开始就不在任何门禁里。 目前测试主要靠开发者手动跑。
【备注讲解】 这题的杀伤力在于"588 个测试文件"和"0 个自动门禁"的对比。要主动讲出来,因为它同时展示了两个能力:会数,和敢说。 可以升华一句:"测试的价值不在数量,在'它会不会在你没注意的时候替你失败'。一份需要人记得去跑的测试套件,和一份不存在的测试套件,在一次匆忙的发版里没有区别。" 改进路线(很短,都能落地):"① 把 core.hooksPath 写进 package.json 的 prepare 脚本,hook 就装上了;② 把 vitest 的 include 换成合理的 glob(比如 src/**/*.test.tsx 全部),把 __tests__/*.test.ts 交给 node:test,两个 runner 用目录区分而不是用文件清单区分;③ CI 里把前端 lint + typecheck + admin 测试加进来;④ 修 ARCH.md 的过期声明。"
【代码依据】 文件计数实测:find backend -name "*.test.ts" -o -name "*.test.js" | grep -v node_modules | wc -l = 349、find src -name "*.test.ts" -o -name "*.test.tsx" | wc -l = 221、find admin -name "*.test.ts" -o -name "*.test.tsx" | grep -v node_modules | wc -l = 18;.github/workflows/ci.yml:1-32(唯一 job backend-tests,只跑 backend 的 pnpm test);.githooks/pre-push:5-7(Actions 欠费的原文注释)、:15(git config --unset core.hooksPath 提示);实测:git config core.hooksPath 无输出(未设置)、ls .git/hooks/ 只有 *.sample、grep -rn "hooksPath\|githooks" package.json scripts/ 无命中;vitest.config.ts:20-80(include 显式清单,60 条)、:22-24(注释解释为什么不能收 __tests__/*.test.ts);package.json:21-22(test = node tools/run-frontend-tests.cjs、test:frontend-legacy)、:72(vitest ^4.0.18);pnpm-lock.yaml:2793(vitest 4.1.10)。("约 3642 个用例"我未核实——无法运行 suite。)
七、自省与改进
Q22. 这套前端现在最大的问题是什么?(主动交底清单)
【考点】 字节必问的自我批判题。准备好的不足比准备好的亮点更有杀伤力。
【口述答案】(挑 4~5 条说,不要全倒) 1. 巨型文件从 App.tsx 转移到了 hook 层,而且我留下了"拆分成功"的错觉。 15008 行的 App.tsx 确实删了,但 useAssistantChat.ts 5228 行、CanvasWorkspace.tsx 5121 行、CanvasScriptRunner.tsx 4757 行。更难看的是后两个是在拆分之后 8 天创建的新文件。我做完那次重构没有留下任何行数门禁(连一条 lint 规则都没有),所以收益被下一个功能吃掉了。如果要改:把文件行数上限放进 CI,超限的 PR 直接红。2. 同一份服务端资源有多个写入者。 useWorkspaceHistoryFeed(裸 api.get + 8 秒轮询 + 1.2 秒进度 ticker + focus 唤醒)和 useVideoHistory(react-query)是两份缓存写同一个 historyUiStore.videoHistory。已知会导致列表抖动和进度回退。如果要改:两者收敛到同一个 queryKey,本地进度改成渲染期叠加而不是写回缓存。3. 请求层有三套,且行为不一致。 apiClient(500 行 / 138 文件)、request.ts(128 行 / 17 处)、6 处裸 fetch。其中 request.ts 的 uploadFiles 一个鉴权头都不带、uploadFilesWithProgress 漏了 X-Scene-Id、两套的 401 行为相反。如果要改:把 request.ts 改成 apiClient 的薄壳,17 个调用点一天能迁完。4. 进度条是伪随机动画,它在制造假预期。 simulated 是 mulberry32 播种的 S 曲线,cap 随机在 94–99。如果要改:让上游产出真实阶段码(phaseFloor 已经留了口子),UI 上把连续百分比换成"阶段 N/M"。5. 类型安全的边界比简历上写的窄得多。 后端 54 个文件 @ts-nocheck、根和后端 strict: false、src/ 990 处 : any、ESLint 把 no-explicit-any 关掉了;ARCH.md 里"类型检查零错误"是过期声明,仓库自己的计划文档记的后端 tsc 基线是 159 → 282 → ~335。如果要改:@ts-nocheck 建白名单只减不增;后端只先打开 strictNullChecks(收益最大、代价最小);ESLint 作用域扩到 backend + admin。6. 唯一会跑测试的通道是断的。 CI 只有一个后端 job,且自 2026-08-08 欠费未执行;补位的 pre-push hook 因为 core.hooksPath 没设而根本没安装;vitest 的 include 是 60 条显式清单,新目录不注册就等于不跑。如果要改:core.hooksPath 写进 prepare 脚本;include 换成目录 glob;CI 加前端 lint + typecheck。
【备注讲解】 交底的三条纪律:① 每条都要能说出"为什么当初这么做"(不是懒,是有约束);② 每条都要有"如果要改怎么改";③ 不要一次说完——留两三条给面试官挖出来。 这一册的特别之处在于:第 1 条和第 6 条是"我的方案失败了",第 4 条是"我在说谎",第 5 条是"我简历写错了"。 主动交出这四条,面试官对你的可信度评价会高于只交出"代码注释不够多"的人。
Q23. 如果让你重新设计这套前端,你会怎么改?
【考点】 收尾题,看有没有系统性思考,以及能不能区分"原则"和"实现"。
【口述答案】 我按"改起来收益最大"排序,说五件事: 第一,加一条"资源所有权"规则,而不是加一堆重构。 规定:每一个服务端资源只能有一个缓存所有者(queryKey),Zustand 只存 UI 态、且 store action 里不允许出现 await fetch。 这条规则能用 ESLint 静态检查掉一大半(no-restricted-syntax 拦 store 文件里的 await + 网络调用)。它比再拆一次文件有用,因为它防的是"再长出来"。第二,抽 packages/contracts,把 shared/*.json 从"文件共享"升级成"类型共享"。 把 9 份 json 的类型定义、错误分类联合类型、导航项定义全部放进去,前端 import type、后端 import type,根 tsconfig 加 paths 映射。做完之后就可以删掉 navCatalog.test.ts 里那条读源码正则的哨兵测试——它存在的唯一理由就是编译期抓不到。第三,把长任务从"轮询 + 伪进度"改成"事件 + 真实阶段"。 顺序是先推动上游给阶段码或回调,然后前端用 SSE(不是 WS——我们的流量是单向的)推阶段事件,phaseFloor 那套离散阶段直接变成事件驱动的进度。在拿到真实阶段之前,宁可显示"阶段 4/6:正在超分",也不显示 96%。第四,上传收敛成一条链路 + 分片续传。 统一到 apiClient 的一个 upload 能力里(带进度回调、带完整鉴权头、按大小自动分流),下面用 OSS MultipartUpload 做分片和断点续传,状态存 localStorage。后端退避加 jitter。 第五,把门禁修好,这是最便宜的。 core.hooksPath 写进 prepare;vitest 的 include 换成目录 glob;CI 加前端 lint + typecheck + admin 测试;再加一条文件行数上限。 但有一点我会保留:shouldForceLogoutOn401 那条"访客态 401 不强制登出"的判据,和 handleGlobalError 里"402 必须区分余额不足与锁争用"的判断。 这两条是我用真实的用户投诉换来的,任何重构都不能把它们简化成"401 就跳登录、402 就弹充值"。
【备注讲解】 最后那句"有一点我会保留"是点睛之笔。重构题最怕答成"我全都要改";能指出什么是不能动的,才说明你分得清原则和实现——"401/402 的语义细分是业务事实,不是实现细节"。 另外一个特点是排序:我把"改门禁"放在最后但强调它"最便宜"。这种"先修约束、再修代码"的思路是资深工程师的标志,因为约束修好之后,后面的重构才不会被下一个功能吃掉(正好呼应 Q3 的教训)。
Q24. 口述速记版(把 Q1~Q23 压成 60 秒,用于突击复习)
我们的前端是 React 19 + Vite 8 + Zustand 5 + react-router 7 + TanStack Query 5 + Tailwind 3,
src/有 1104 个 ts/tsx、18.7 万行、54 个 feature 域、49 个页面、57 条路由。 最大的那次改动是我把 15008 行的App.tsx拆成了 10 个 Page + 14 个 feature hook,并清空了整个src/的@ts-nocheck。 但诚实地说只成功了一半——巨型文件转移到了 hook 层(useAssistantChat5228 行、CanvasWorkspace5121 行),而且后两个是拆分之后 8 天新建的,因为我没留下任何行数门禁。 状态管理的分界是"服务端资源归 react-query(staleTime 30s、关掉焦点重取),客户端/UI 态归 Zustand(25 处 create)"。越界案例是useWorkspaceHistoryFeed(裸api.get+ 8 秒轮询 + 1.2 秒进度 ticker)和useVideoHistory(react-query)两份缓存写同一个historyUiStore。 请求层有三套:apiClient500 行 138 文件引用、request.ts128 行 17 处引用(uploadFiles甚至不带鉴权头)、6 处裸 fetch。 长任务没有 SSE/WS,用refetchInterval条件轮询(有非终态 3 秒,终态false;SSE 只用于 LLM 逐字输出,而且是POST+ 手写ReadableStream解析,不是EventSource)。进度条大部分时候是mulberry32播种的伪随机 S 曲线,cap随机在 94–99——因为供应商不给细粒度进度;改进方向是让上游给真实阶段码,phaseFloor已经留了口子。 上传三条链路(api.upload36 处 / XHR 带进度只有assetStore用 />200MBOSS 表单直传),无分片、无断点续传,而 OSS 直传超时是 10 分钟——慢网传 800MB 必然失败。 管理后台admin/是独立项目:28 个页面、32 个<Route>、三套布局、唯一strict: true且build = tsc -b && vite build的包;告警轮询useFailureAlerts故意绕过自己的request封装(30s,失败退避 5min),因为那个封装遇 401 会把正在填表的管理员踹下线。 类型共享的真相:不是类型共享,是 9 份shared/*.json文件共享 + 手工镜像的联合类型(错误五分类前后端各写了一遍)+ 一条读前端源码文本做断言的哨兵测试。正确做法是抽contracts包让tsc把关。 类型安全的边界:根和后端strict: false、后端 54 个文件@ts-nocheck、src/990 处: any、ESLint 的no-explicit-any是关的;ARCH.md写的"类型检查零错误"不成立,仓库自己的计划文档记的后端 tsc 基线是 159 → 282 → ~335。 工程质量:349 / 221 / 18 个测试文件,但唯一的 CI job 自 2026-08-08 因欠费未执行,补位的 pre-push hook 因为没有core.hooksPath而根本没安装,vitest的include是 60 条显式清单——新目录不注册就等于不跑。