SYSTEM ARCHITECTURE

系统架构

这不是一个内容网站,而是一个数据产品:底层是演出档案库与预聚合视图,上面是节目单浏览与逐章数据分析。 本页回答三件事:数据从哪来、到页面怎么走;要改东西该动哪里;改完怎么验证没改坏。

00我改东西前先知道该去哪

一条贯穿全项目的原则:同一个概念在不同来源出现时,必须相等 —— 人物数、条目数、天数、节目数、剧场数、搭档对数。 这些不是靠自觉维持的,有可执行的恒等式在盯(见 怎么验证)。

01数据从哪来,怎么变成页面上的数字

这一节是全项目最容易被跳过、也最容易出问题的一段

① 录入 运营在后台导入节目单(粘贴表格或上传文件),先预览再提交。 /admin
② 归一 不同写法的剧场 / 演出队 / 曲种按别名表统一到规范名,并写回维度编码。映射不到的不静默留空,而是进「待归类队列」。 056 触发器
③ 预聚合 分析类查询只读物化视图,不扫主表。定时任务按 pg_matviews 实际存在的视图逐个刷新。
⚠️ 视图有「粒度」,取数必须按业务主键再聚合一次。例如 mv_program_stat 的一行是「某节目 · 某门类 · 某曲种」,不是「某节目」—— 跨门类演过的节目在视图里是多行,直接取 cnt 拿到的是单行值而非合计。 2026-11-02 就因此让「节目热度」榜少算了(打灯谜 5004 而真实 5015)。
DB/refresh_mvs.sh
④ API FastAPI 出数。写 SQL 时用视图暴露的规范名列,不要碰原始串。 DB/server/app
⑤ 呈现 前端只做展示与交互聚合,不做业务规则判断,也绝不直连数据库。 web-src/src
② 是最容易出事的一环。过去「映射不到就留空编码」的写法,会让同一个剧场的不同写法 在统计里算成两个 —— 于是「剧场数」在不同页面给出不同答案,而且没人知道哪个对。 现在写入前由数据库触发器兜底:查得到别名就自动填编码,查不到就落进待归类队列等人处理。
规范名 vs 原始串:剧场的「吉林德云社剧场」和「长春德云社剧场」是同一个地方。 视图同时给出两者 —— *_name(归并后的规范名,统计一律用这个)与 *_raw(录入原样,仅用于搜索兜底)。按原始串分组会绕过归并, 是口径分裂的头号来源。
物化视图清单不在代码里。定时脚本从 pg_matviews 读取本库实际有哪些视图, 逐个刷新。不要维护硬编码清单 —— 过去就是这么漏刷的。

02各层负责什么、不负责什么

层负责不负责(越界就是设计错误)
前端 SPA 呈现、路由、主题、自绘图表、展示层聚合 业务规则判断、直连数据库、口径计算
后端 API 参数校验、鉴权、SQL 执行、结果整形 重计算(交给物化视图)、模板渲染
数据层 预聚合、维度归一、写入兜底 把口径规则写在视图注释里而不落成检查
Nginx TLS 终止、反向代理、静态缓存 任何业务路由(只有一条 SPA 回退规则)
为什么分析查询不扫主表:主表是明细(近十万条目), 而分析页面要做的是聚合与排名。扫主表会让统计页在数据量涨上去后先垮掉。

03数据库与迁移的规矩

迁移必须成对写 BEGIN; 和 COMMIT;。
只写 BEGIN 不写 COMMIT 时,psql 执行完关掉连接会 自动回滚未提交事务,数据全部丢失 —— 而输出里显示 INSERT 0 8、 (8 rows)、退出码 0,从头到尾没有任何错误信号。
这类故障没有任何运行时痕迹,只能靠 DB/check_migrations.py 在打包时拦。
表结构别手改。新增字段走迁移;DB/schema.sql 开头是 DROP TABLE … CASCADE,禁止对生产库执行,仅供建测试库。

04后端:写 SQL 必须守的规则

代码在 DB/server/app/,路由模块在 routers/

① 维度列只用规范名。用视图的 venue_name / troupe_name, 不要用原始串列(视图里根本没有裸的 venue / troupe, 引用会直接抛 column does not exist 让端点 500)。
② 同场 / 同组判定也用规范名。判「同一场」时若用原始串,剧场归并后 (吉林 → 长春)同一天的两场会被判成不同场,接龙/关联类结果会静默漏掉。
③ GROUP BY 与 ORDER BY 要对齐。 ORDER BY 里出现不在 GROUP BY 的列,Postgres 直接报错。
④ 500 要能被看见。未捕获异常返回时会带一段受控摘要 (PostgreSQL 自己那一句错误,如 column "x" does not exist), 不含 SQL 语句、连接串、路径、堆栈。介意暴露列名时在 .env 设 EXPOSE_ERROR_HINT=false 完全关掉。
为什么值得做:线索只写服务端日志时,排障必须上服务器翻日志, 而前端往往又把 500 显示成「不存在」,导致排查方向从第一步就错。

05前端:结构、主题、两类页面

源码 web-src/src,构建产物 web-src/dist

独立页不进 Vue

这几页是手写 HTML,放在 web-src/public/,构建时原样拷进产物, 由后端直接返回(SPA 内没有它们的路由):

地址给谁看
/guide访客 —— 按「你想查什么」组织的使用指南
/tree访客 / 维护者 —— 全站结构树:一级栏目一屏地图 + 自动同步的真实页面清单 + 逐层展开到最小控件(由 content/site-tree.json 生成;旧地址 /site-tree 仍可用)
/ops运营 —— 后台操作与异常处理
/architecture开发者 —— 本页
/admin运营后台(自包含,含登录)
/theme-preview洛基单主题的设计稿预览(深 / 浅两态,键名沿用旧版 loki / loki-dark)—— 不是全站 10 种外观的矩阵
/offline.htmlPWA 离线页 —— sw.js 里写死引用,刻意不做无后缀短路径
/stats-classic.html旧版数据中心归档(页面顶部有「旧版」横幅);/stats 已被 SPA 占用,故不做短路径
/person.html1K 跳转壳 —— 旧版独立人物页已废弃,一律 location.replace 到 SPA 的 /person/:code

以上全部在 web-src/public/,构建时原样拷进产物。 同目录还有两个不属于页面的脚本:shell.js(同步主题 + 页脚补「数据覆盖至 X」) 与 umami.js(按 hostname 选统计站点 id,本地域名不统计)。
新增独立页记得在 <head> 末尾补一行 <script defer src="/umami.js"></script>,否则那一页的访问不会计入统计。

只有 7 个页有无后缀短路径。后端 DB/server/app/main.py 的 PAGE_ROUTES 只登记了 /admin、/guide、/ops、/architecture、 /theme-preview、/tree、/site-tree(全站结构树的旧地址, 与 /tree 指向同一个文件);其余页面必须带 .html (/offline.html 被 sw.js 写死,/stats-classic.html 回避 /stats, /person.html 只是跳转壳 —— 三者都是刻意排除,不是漏配)。 写文档时把上表里带 .html 的地址省略后缀,就是一个点不动的死链 —— 这条由 scripts/check-docs-facts.mjs 自动盯着。

路由

HTML5 history 模式。主要页面:/ 首页(数据驾驶舱)· /schedule 节目单看板 · /discover 发现页(浏览与检索)· /stats 数据中心 · /network 合作网络 · /yearbook/:year? 报告中心 · /person/:code 人物页 · /person/:code/partner/:other 两人合作页 · /program/:name 作品页 · /session/:id 场次页。

模块聚合页(2026-10-30 IA 落地 B 阶段): /actors 演员 · /programs 节目 · /quzhongs 曲种 · /art/:cat 门类 · 另有剧场与演出队模块页、/metrics 指标口径表、 /about 数据说明。

两个容易记错的旧入口:/persons 是重定向,跳到 /actors(演员模块页);而 /programs 不是重定向, 它是独立的节目列表页。
另有一个无导航入口、需口令的内部页(重复节目周报)—— 它刻意不出现在任何对外文档里,知道有这回事即可。

主题

5 套主题 × 明暗 = 10 种外观(没有集中预览页:/theme-preview 只是洛基一个主题的设计稿)。 主题键的唯一出处是 src/composables/themeKeys.ts 的 THEMES,新增主题只改这一处。两个属性正交: data-theme 管个性、data-mode 管亮度; 选择写入 localStorage,独立页用同一套约定跟随。 支持网址参数强制:/stats?theme=pop&dark=0。

主题变量名是契约。改名会让大量组件失色,属于重大变更。

06改完怎么验证没改坏

这五条命令构成本项目的验证闭环,改后端必跑前两条

命令回答什么问题
python3 DB/api_sweep.py有没有端点报错?5xx 一律是真故障,能穿透前端把 500 显示成「不存在」的伪装
python3 DB/check_invariants.py同一个概念在不同来源还对得上吗?不一致会直接告诉你差多少、差百分之几
python3 DB/check_migrations.py新迁移会不会执行完就丢数据(BEGIN 无 COMMIT 静默回滚)
python3 DB/verify_dims.py维度归并还完整吗:原始串 / 编码 / 归并后三个数是否一致
python3 DB/diag_program_500.py端点 500 时,直接打出真实 SQL 错误,并判断「代码部署了没」
为什么恒等式检查不是「又一套要人维护的文档」:它不判断 SQL 对不对, 只判断两个数字相等不相等 —— 要么成立要么报错,没有「误报」这个失败模式。 (对比:曾经想用静态分析检查 SQL 写法,4 轮仍不可靠已删除 —— 正则认不准「这个别名属于哪张表」。)
它自己也要被校验。恒等式的 SQL 写错时必须报「失效」而不是「跳过」—— 否则那条根本没跑,却混在「跳过」里让汇总看起来通过。 判据用驱动异常类名(对象未建 = 环境问题;列不存在 = SQL 写错)。
前端侧的两条浏览器门禁(2026-11-03 新增):
web-src/tests/spa-smoke.spec.ts 把 19 个有快照的路由在 360 / 390 / 1440 三个宽度各开一遍, 断言「挂载成功、无横向溢出、没出现失败态、无控制台报错」—— 在此之前所有宽度断言都只看静态页,SPA 一条都没有。
另有一条布局契约用例:把年份筛选条克隆到 20 个再量宽度 —— 真实数据里那位演员只有 4 个年份,靠快照测不出「控件变长会不会撑破页面」。
web-src/tests/spa-failure.spec.ts 把接口全打成 500,要求每页都留下「这是一次失败」的证据 (重试入口或明确字样),不许静默显示 0 / 暂无。
两条都走 web-src/tests/utils/api-fixtures.ts 的快照 mock,不连真后端 —— 判据不能时红时绿。改页面跑 npm run check:smoke,全套仍是 npm run check。

07我不知道会踩的坑(都真实发生过)

① 本地 dev 的接口代理指向远程测试服务器,不是本机后端。
后果:改了后端代码,本地看不到任何变化 —— 因为跑的是远程那份。 本地只让前端改动立即生效(HMR),后端改动必须部署才生效。
② 故障被伪装成「不存在」。
实体页的 catch 曾不区分状态码,接口 500 / 超时一律渲染成「未找到该人物」。 于是排查方向从第一步就错:先怀疑数据没有,实际是 SQL 写错。 现在只有后端明确回 404 才算「不存在」。
③ 静态检查会骗你,而且两种骗法都伤人。
假绿:路径写错 → 扫到空目录 → 无论代码多脏都报「全部合规」。 假红:路径拼写对但匹配规则错 → 全报「缺文件」。 自检常报警,人就会开始忽略它,于是检查等于没有。宁可没有检查,也不要会误报的检查。
④ 测试本身会错,而且比代码错更隐蔽。
代码红了一眼就知道;测试红了却会让人怀疑被测代码。 写测试时先验证「这个尺子能量吗」—— 故意注入一个已知错误,看它抓不抓得到。
⑤ 文档里的数字必然过时。本页曾写「51,587 条目」,后来实际早翻了一倍多; 修这句时若再写一个新数字,它同样会过期 —— 这正是这条教训本身。 所以对外文档不写计数,一律指向数据中心的实时值; 需要写具体值的地方必须提成单一常量,全站引用同一处 (如后端 core/constants.py 经 /api/meta 下发)。
⑥ 打包清单不能靠人记。曾连续两次漏掉包外的运维脚本, 导致「cron 没生效」「脚本 not found」。现在打包只有一个入口, 脚本与迁移都打进包,打完自检,缺关键文件直接拒绝出包。

08部署怎么走

后端包只有一个入口:bash scripts/pack_backend.sh vNN。 不要手敲 zip —— 手敲就是上面第 ⑥ 条那个坑。 打包前会跑迁移事务检查,打完会自检关键文件是否齐全。

顺序

  1. 传包到服务器 /tmp/
  2. 先备份现有 DB/server/app 与 web/dist 到 /www/backup/yqdy/deploy_时间戳/ —— 出问题就靠它回滚
  3. 解后端包到 /www/wwwroot/yqdy_fastapi/(路径必须到这一层)
    ⚠️ 顺序不能反:新迁移的 SQL 文件在包里, 没解压之前 DB/migrations/ 下根本没有这个文件,先跑迁移会报「找不到文件」。
  4. 跑这次新增的迁移(解包脚本不会替你执行 SQL):按编号顺序, psql -h 127.0.0.1 -U postgres -d yqdy_db -f DB/migrations/NNN_xxx.sql
  5. 重启后端(由 systemd 托管,不要退化成 nohup 野进程)
  6. 发前端包(纯静态改动不需重启后端)
  7. 跑 api_sweep 复验,别只看页面
怎么确认线上跑的是哪一版:访问 /version.txt, 显示内容应与本地 web-src/dist/version.txt 完全一致。 不一致说明没传上去或传错目录。

09要改 X,去哪个文件

要改的去这里
某个数字算错了先跑 check_invariants.py 定位是哪两个来源不一致,再顺着 SQL 找
接口 500diag_program_500.py(直接打真实错误,不要靠猜)
新增/修改表字段写迁移 → check_migrations.py → 执行
维度归并(剧场/演出队/曲种)对应维表与别名表的迁移;运行期由写入触发器兜底
页面界面web-src/src/views/;共用组件在 components/
新增主题composables/themeKeys.ts 一处,其余自动跟随
这三页对外文档web-src/public/{guide,architecture,ops}.html(手写,不在 Vue 里)
节目单录入状态已录完,没有补录进度或边界。状态常量在后端 DB/server/app/core/constants.py(DATA_ENTRY_STATUS),经 /api/meta 下发 data_entry_status;对外文案不得再出现任何补录进度说法
口径定义数据库 metric_dict 表(文档 + 可执行 SQL + 恒等式)
版本号根目录 VERSION,构建时自动递增并注入