接手博物馆展览与服务一体化平台这个项目之前,我原本以为又是一套常规的CRUD管理系统。真正把需求梳理完才发现,这里头藏着一个很典型的Nodejs+PHP+Vue三端协作问题:观众端要流畅、管理端要高效、接口还得扛得住节假日的流量高峰。等项目完整落地之后再看,最有价值的反而不是某个单一功能,而是把三套技术栈的职责边界理清楚、让它们各干各的活又不互相打架的经验。这篇文章就认真复盘一下这套博物馆展览与服务一体化平台从技术选型、架构设计、模块落地到部署上线的完整过程,包括我实际踩过的一些坑和沉淀下来的处理思路。
1. 需求梳理与选型:这座博物馆平台为什么是"三件套"
1.1 博物馆数字化场景的特殊性
我做过的信息化项目里,博物馆是个比较特殊的场景。它不像电商那样纯粹由C端流量驱动,也不像企业内部系统那样只求流程合规。博物馆的核心是"展览"和"观众服务"两条线,而且这两条线在现场是强耦合的——观众因为某个展览来,到了现场需要引导、需要讲解、需要互动,离馆之后还可能想反馈、想再次预约。传统做法通常是把展览公告挂在官网、票务走第三方平台、导览用线下设备,各管各的,数据互相不打通。
这种割裂带来的直接问题是:观众看展前要去好几个渠道查信息,到现场还要重新填预约、重新关注公众号;运营人员更难受,一个展览的数据要分别从三个后台导出再人工拼起来看。所以客户想要的"一体化平台",本质上不是为了炫技,而是要把"看展前—在馆中—离馆后"这个完整链路的数据和服务统一起来。
开发过程中我对这个需求的理解还在加深。比如票务不只是出票,它和展览排期绑定、和导览推荐绑定,甚至和当天的人流预警绑定。一个问卷调查或失物招领,背后也要和用户体系关联。如果一开始没把模块边界划清楚,后面每加一个功能都会多一层混乱。
1.2 为什么最后定了Nodejs+PHP+Vue
聊技术选型之前,先说清楚团队背景:我们是做PHP起家的,PHP在业务接口和后台管理上积累了大量现成轮子,验证码、文件上传、Excel导出都有成熟的类库。如果整个平台全用PHP写,开发速度确实不慢,但有两个地方会很别扭——一是C端页面需要大量异步交互和动态渲染,PHP输出模板的方式做起来体验一般;二是接口层要频繁聚合多个数据源、做临时结构裁剪,用PHP硬拼也能做,但写起来非常啰嗦。
前端交给Vue,这一点没有争议。Vue的生态对这类偏展示型的C端应用非常友好,组件化开发效率高,Vue Router做页面路由、Pinia或Vuex管状态都顺手。真正让我犹豫的是中间层要不要上Nodejs。后来促使我下决定的是三个具体需求:实时通知、接口聚合、动态权限路由。这些用Nodejs做非常顺手,而且Nodejs和PHP并列跑在Nginx后面,部署和维护成本并不高。
选型也不是越新越好。用PHP做核心业务层,是因为它稳定、部署简单、文档多;用Nodejs做中间层,是因为它轻量、事件驱动、适合做转发和聚合;用Vue做前端,是因为它组件化和生态都成熟。三层边界划清楚之后,各自都用自己最舒服的方式工作,项目推起来就顺了。
1.3 上线后整体效果
简单说下上线之后的形态:观众在H5和小程序里可以查看当季展览列表、预约最近场次、扫码听语音讲解、离馆后提交反馈;管理端在Web后台维护展览和展品信息、查看预约与核销数据、处理反馈工单。API层由Nodejs统一暴露给前端,PHP只做内部业务接口,Nginx统一收接请求。高峰期实测支撑几千人在线,没有出现明显的接口拥堵,这个结果对博物馆场景来说已经够用了。
2. 环境搭建三个坑:npm执行策略、PHPStudy升级、Vite依赖
2.1 Nodejs安装与npm脚本被禁止执行
项目开发机有Windows也有Mac。Mac那边一切顺利,Windows这边几乎必踩一个坑:装完Nodejs,在PowerShell里敲npm,直接报"npm : 无法加载文件 ...npm.ps1,因为在此系统上禁止运行脚本"。这个问题的原因是PowerShell的执行策略默认限制脚本运行,npm的npm.ps1包装脚本被拦住了。
解决办法有两个。一个是永久解决,在PowerShell里执行Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser,然后正常使用npm;另一个更省事,直接用cmd窗口或Git Bash,它们不走PowerShell脚本策略。我个人建议顺手把执行策略改成RemoteSigned,因为后面用pnpm、yarn作为包管理器时,同样会遇到这类脚本执行问题。
Nodejs版本上,生产环境我选了18 LTS,开发机用nvm切版本。注意不要贪新上22,除非确认团队依赖全部兼容。还有一个细节:装完Nodejs以后npm默认源在国外,国内网络环境下安装依赖会很慢,把registry切换到国内镜像源之后,速度提升非常明显,这个操作不影响功能和安全性。
2.2 PHP环境升级:从老版本到PHP 8.x
项目业务层选的是PHP,但开发机上的PHPStudy默认版本比较老,直接跑会有一堆语法兼容问题,所以第一步就是把PHP升级到8.1以上。我用的是PHPStudy方式,在软件管理里下载PHP 8.x版本,然后切换默认PHP版本,再把pdo_mysql、openssl、fileinfo、gd这几个常用扩展开起来。这里有个容易漏的扩展是fileinfo,很多PHP包依赖它做文件类型检查,漏了会导致文件上传模块报莫名其妙的错误。
如果不用PHPStudy,手动下载PHP二进制包也可以,但要自己配置php.ini-development到php.ini、设置extension_dir,相比之下PHPStudy省事很多。升级之后还要注意PHP 8.x和旧代码的兼容性,最典型的是字符串大括号下标写法$str{0}在PHP 8里直接废弃,数组函数each()移除,还有一些内部函数的返回类型更严格了。我们业务代码基本是新写的,影响不大;如果接手老项目,升级前一定要运行一遍静态扫描。
2.3 Vue工程初始化与依赖管理的经验
Vue这边选了Vue 3 + Vite。Vue CLI虽然成熟,但Vite的启动速度和热更新体验确实好很多,对开发效率提升明显。初始化用npm create vite@latest,选vue模板,装上vue-router、pinia就行。如果你是老项目迁移,新模块建议也用Vite单独起,别直接动Webpack配置。
依赖安装阶段有几个常见坑值得说。第一个是node-sass,这个老牌依赖和Node版本绑定非常死,Node一升级它就不编译,新项目直接换sass(dart-sass)就行,API基本兼容。第二个是特殊功能依赖,项目里要播放m3u8格式的语音讲解流,前端用hls.js直接npm install hls.js就能用,不依赖其他大库。第三个最容易忽略:装了新依赖后开发服务器没重启,导致模块找不到。Vite虽然理论上能自动重新处理依赖,但有些情况下还是要重启一下开发服务器才可靠。
3. 三层架构拼图:Vue做展示、Nodejs做编排、PHP做业务
3.1 一次完整的请求链路
先看一条请求是怎么走通的:观众在Vue页面上点击"查看展览详情"→ Vue Router跳转到详情页,组件里通过axios发请求 → 请求先到Nginx → Nginx转发给Nodejs中间层 → Nodejs先做token校验,然后根据路由把请求转发给PHP业务服务 → PHP查询数据库、执行业务逻辑,返回JSON → Nodejs拿到JSON后按前端的字段约定做二次裁剪,甚至可能同时合并另一个接口的数据 → 最后返回给Vue渲染。
这条链路看着多了一层,但实际体验很好,因为前端只需要面对一个统一的API域名,不需要关心上游有几个服务。Nginx的作用是把/api/**指向Nodejs,把/admin/**指向PHP管理端,再把静态资源指向构建后的前端产物,一层搞定。
3.2 各层职责边界与数据库设计
我不太推荐在业务初期就把所有逻辑塞进Nodejs,这样会把中间层搞得特别重。我们的划分很明确:
- Vue层只负责页面渲染和用户交互,直接调用中间层暴露的接口。
- Nodejs中间层负责鉴权、路由转发、数据聚合、SSE推送,不做复杂业务计算,也不直接操作业务表。
- PHP层负责核心业务CRUD、事务处理、文件存储、导出报表等重活,Nodejs对PHP来说就是一个外部调用方。
数据库核心表大概这么几张:展览表(museum_exhibition)存展览基本信息和展期;展品表(museum_exhibit)挂靠在展览下,包含展品名称、图片、讲解音频地址、是否重点展品等字段;预约单表(museum_ticket_order)存观众预约信息、时间段、人数、核销状态;导览点位表(museum_guide_point)存展厅点位编号、位置、对应展品;反馈表(museum_feedback)存用户留言和服务工单。
建表时有几个索引设计我特别留意了。预约单表上(exhibition_id, visit_date, period)建联合索引,查询预约量和核销量会快很多;展品表按展览ID做普通索引就够了;反馈表按处理状态和创建时间建索引,方便管理端拉取待处理工单。
3.3 中间这层Nodejs到底解决了什么实际问题
读者可能会问:PHP直接对前端不行吗,非要中间加一个Nodejs?分开说。
第一,接口聚合。前端一个导览首页,需要展览信息、当前讲解场次、当日预约余量三个数据,如果直接请求PHP,前端就要发三个请求自己拼,不同接口出错处理还很麻烦。Nodejs中间层用一个聚合接口并行请求三个PHP内部接口,合成一个返回体,前端只请求一次。这个改动对移动端弱网环境的体验提升非常明显。
第二,鉴权统一。观众端token校验、管理端会话校验都能在Nodejs入口统一做,PHP内部接口只信任来自中间层的请求,PHP不用在几十个接口里重复写权限判断。这个看起来只是"少写几行代码",实际上避免了大量权限漏洞——因为很多开发在写新接口时会忘记加权限校验,但在中间层统一挡一道就安全得多。
第三,实时推送。人流预警、讲解场次提醒这类场景需要长连接,Nodejs的事件驱动模型做SSE很顺手,PHP那边只需要把事件数据写入一张待推送表即可。
当然,不是所有场景都需要中间层。如果项目只是一个简单后台,PHP直出完全够用;像这种既要C端又要B端、接口来源多、还有实时通知的项目,加一层BFF的性价比是很高的。
4. 展览、票务、导览、服务:四个核心模块的落地细节
4.1 展览与展品展示模块
这个模块是平台的门面。PHP的接口我拆成三个:展览列表、展览详情、展品详情。列表返回展览ID、名称、封面、区域、展期和状态(进行中/即将开始/已结束),详情里带展览介绍和展品列表。展品详情页除了文字和图片外,有个比较特殊的需求:不少展品附了高清PDF介绍文件,用户可能在手机上想直接预览。
这里就遇到Vue前端一个很实际的问题:img标签不能显示PDF,要用iframe或pdf.js这类组件做预览。我们最后选了折中方案:小文件用iframe直接打开,大文件用pdf.js分页渲染,避免整个PDF加载过慢导致白屏。
前端这块我还做了一个可复用的展品卡片组件,不同页面(首页、展览详情页、导览页)对卡片的展示侧重不同,就直接用Vue的slot插槽做定制:卡片主体结构固定,但右上角角标、底部按钮由外部通过插槽传入。这样一个组件喂不同插槽内容就能满足三种场景,不用复制三份组件。
4.2 票务预约与核销流程
预约流程的状态机是:未支付/待使用/已使用/已取消/已过期。支付接的是第三方支付回调,支付成功后PHP更新预约单状态,Nodejs中间层监听到支付成功事件后,给前端推送一条"预约成功"的实时通知。核销发生在观众到馆后,工作人员扫预约二维码,PHP接口核对预约单信息和当天日期,把状态从"待使用"改成"已使用"。
这里有个特别容易出问题的点:超卖。一个展厅每天可预约总量有限,如果两个人同时提交最后两个名额,PHP这边的处理应该是事务加条件更新,而不是先查再插入。具体做法是执行一条带条件的UPDATE:
UPDATE museum_ticket_order SET booked_count = booked_count + 1 WHERE exhibition_id = ? AND visit_date = ? AND period = ? AND booked_count < max_count影响行数为1才说明名额抢到了,否则直接返回"已满"。这个写法比先SELECT再INSERT要安全得多,也省了显式加锁的麻烦。
另一个小细节:预约单金额在前端展示时需要把数字金额转成中文大写,PHP里要写一个金额转大写的函数。这类代码网上一搜一大把,但要注意"零"和"整"的处理逻辑,比如10005这种带零的金额很容易出bug,实测下来要专门写几个边界用例。
4.3 智能导览与流媒体播放
导览功能是现场体验的关键。我们在展厅里给每个重点展品设置点位编号,观众用小程序或H5扫描展牌上的二维码,带上展品ID跳转到Vue的导览页。页面加载对应的语音讲解,讲解音频没有存普通mp3,而是存成m3u8切片流。这里就必须在前端做HLS流播放,hls.js的方案比较干净:
const hls = new Hls(); hls.loadSource(url); hls.attachMedia(videoElement); hls.on(Hls.Events.MANIFEST_PARSED, () => { videoElement.play(); });几行代码就能在普通浏览器里播放m3u8,不需要额外安装播放器套件,这也正好回应了"播放m3u8免安装"的那种诉求。
音频的URL不是直接给静态地址,而是先经过Nodejs中间层发一个带时效的签名URL,防止资源被到处转发。签名逻辑在Nodejs里实现:用展品ID和过期时间生成一个临时token拼在URL后面,PHP文件服务校验通过后才返回流数据。这样即使有人拿到URL,过期后也无法继续访问,实测对防盗链很有效。
4.4 服务台与反馈闭环
离馆后的反馈与服务是我们比较看重的模块。反馈表单分三种类型:咨询、留言、失物招领。因为要支持图片上传,PHP这边就涉及文件上传和路径存储,这里有一个坑:PHP把包含中文的数组内容做缓存或Session存储时,如果直接serialize()后入库,取出来要非常注意编码处理。我建议统一用json_encode/json_decode,配合数据库utf8mb4,中文基本不会乱。
后台处理失物招领时,需要把用户留言和关联的预约单一起导出给值班人员,Excel批量导出用PHP的PhpSpreadsheet,导出的sheet按日期分页。数据量大的时候要注意脚本超时限制,这个我在第6章会详细说。
这个模块看上去不起眼,但实际体验影响很大。观众在馆内遇到问题找不到人工服务时,线上反馈入口就是唯一的求助通道。所以我们在接口设计上给反馈类接口单独加了优先级字段,紧急工单会通过Nodejs中间层直接推给管理端的值班大屏。
5. 动态路由、跨域与SSE:打通前后端的三场硬仗
5.1 动态路由与基于角色的权限控制
管理端和观众端走的是一套Vue代码库,但可访问页面完全不同。常规写法是把所有路由写死,然后在导航守卫里判断角色再拦截,这种方式页面多了以后维护成本很高。我们的做法是动态路由:登录成功后,PHP返回当前用户可访问的菜单和路由配置列表,Nodejs中间层透传给前端;前端拿到后在Vue Router里调用router.addRoute()逐条注册,同时把菜单数据交给侧边栏组件渲染。
动态路由里有个容易忽略的细节:路由表的component字段在服务端返回的是字符串(比如"views/exhibition/detail.vue"),前端不能用字符串直接作为组件,需要先用import.meta.glob把页面组件预加载成映射表,再根据字符串找到对应组件:
const modules = import.meta.glob('../views/**/*.vue'); const component = modules[`../views/${route.component}`]; router.addRoute({ path: route.path, name: route.name, component });另外,Vue Router 4里同名路由会叠加,刷新页面要记得先重置路由再重新添加,否则会出现路由重复警告、页面空白的问题。用router.removeRoute(name)或者每次登录成功后重建一个全新的router实例都可以。
5.2 跨域处理:从JSONP到CORS的取舍
开发阶段最省事的方案是Vite的proxy配置,让前端请求走开发服务器转发,根本不触发跨域。但生产环境必须面对真实跨域:前端域名和API域名不同。这里有两种主流方案:CORS和JSONP。JSONP是很老的方案,只支持GET请求,靠动态script标签绕过同源策略,早期不少PHP老接口用了这个方式,我们项目里有些历史遗留接口还依赖它。但新写的接口我强烈建议统一用CORS。
CORS在PHP端的实现很简单,在响应头里设置Access-Control-Allow-Origin,指定允许的前端域名,别直接用*,因为带cookie鉴权时*是无效的。如果用了复杂请求(PUT、DELETE或自定义Header),还要处理预检请求OPTIONS:PHP接口要识别OPTIONS请求并直接返回200,否则前端会收到跨域报错,而后端实际没执行任何代码。
实际部署时更推荐在Nginx层面统一加跨域头,PHP代码就不用每个接口管这件事,逻辑也更干净。JSONP只作为老接口的兼容方案保留,新功能一律走CORS。
5.3 SSE实时推送:Nodejs做展厅实时通知
项目里有两个实时场景:一是节假日人流预警,展馆某个区域人数超过阈值时后台要提醒现场调度;二是公益讲解开讲前给已预约观众推送提醒。考虑再三我们选了SSE而不是WebSocket,理由很直接:这两个场景都是服务端单向推送,不需要客户端频繁上行消息,SSE足够;而且SSE基于HTTP,复用现有链路,在Nodejs中间层实现非常轻量。
Nodejs侧做法不复杂,在Express路由里设置text/event-stream响应头,把需要推送的事件按固定格式写流:
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); const timer = setInterval(() => { res.write(': heartbeat\n\n'); }, 30000);前端用EventSource接收:
const source = new EventSource('/api/sse/notify'); source.onmessage = (event) => { // 更新页面状态、弹提示 };要注意Nodejs做SSE时连接数会一直占着,需要定期发送心跳注释行防止连接被中间网络设备断开。PHP侧只需要在业务操作后把待推送事件写入一张事件表,Nodejs轮询这张表并推送,两边解耦,不会互相阻塞。
6. 联调踩坑实录与上线部署形态
6.1 PHP接口返回数组还是对象:前端总报"格式不对"
这是联调阶段最折磨人的一个坑。PHP的json_encode在处理空数组时输出[],在处理关联数组时输出{},前端如果固定按数组去遍历,遇到{}就会报错。更隐蔽的是,PHP从数据库取出的行,如果字段全是数字索引,编码出来是数组;如果有关联键名,编码出来是对象。同一个列表接口,有数据时是数组,没数据时变成空对象,前端代码里到处都得判断Array.isArray。
解法是要求PHP层统一封装返回结构:
return json_encode([ 'code' => 0, 'message' => 'ok', 'data' => array_values($list) ]);data要么是null,要么是明确的数组,并且序列化前用array_values()把索引数组强制重置。这个规范在接口文档里写清楚之后,联调问题少了一半。
还有一个小问题:PHP整数转成JSON后,前端拿到的可能是字符串。如果前端拿到的是订单金额且要参与计算,建议在PHP端明确用number类型,或统一转成字符串后在Nodejs层再转成Number。总之,接口返回的数据类型要当成接口契约的一部分来管理,不能靠前端猜。
6.2 验证码识别的折腾过程
后台登录页有验证码,开发环境下每次手动输入效率很低。最初想写脚本自动识别,试过直接OCR识别PHP生成的验证码图片,效果一般,干扰线和字体扭曲让识别率只有六七成。后来发现最实用的做法是开发环境直接把验证码校验关掉,或者统一填一个万能验证码;生产环境才启用真实校验。如果接口要被外部系统调用、必须自动识别,那就得上更专业的图片识别方案,但识别率也做不到百分百,还要配合错误重试机制。
这个经验说起来有点"不登大雅之堂",但确实很影响开发效率。我们最后保留了一个只在测试环境有效的调试接口,生产环境编译时不包含该路由,既保证开发效率又不引入安全问题。
6.3 PHP执行超时与长任务处理
后台有一项功能是批量导出某个月的预约Excel,数据量一上去,PHP经常报执行超时。一开始想到的是set_time_limit(0),但这只是在单个进程内延长执行时间,如果Nginx/PHP-FPM的fastcgi_read_timeout配置更短,照样会被掐断。而且长时间占用PHP-FPM进程会拖垮并发。
更科学的做法是把长任务从请求链路里摘出去。比如PHP先把导出任务写入任务队列,由队列进程去执行生成文件,完成后把文件地址更新到任务记录里,前端轮询或通过Nodejs的SSE通知"文件准备好了"。类似的教训也适用于外部命令调用:不要用exec()去调外部进程再想办法中断PHP,PHP脚本层面很难干净地管理外部子进程生命周期,容易留下僵尸进程。能用PHP原生库解决的(比如PhpSpreadsheet写Excel)就尽量用原生库解决。
6.4 服务器部署与上线优化
服务器环境是Ubuntu,架构是Nginx + PHP-FPM 8.1 + MySQL 8 + Nodejs(PM2守护)。部署细节有几个值得记录:一是Nodejs进程用PM2管理,设置了max_memory_restart自动重启,日志走PM2统一收集;二是Nginx的location规则区分了静态资源、Vue前端路由(history模式要配try_files)和API反向代理;三是前端构建产物做gzip压缩,图片和音视频走CDN缓存,减少源站压力。
上线前我还做了一轮性能体检,重点优化了三块。第一,数据库慢查询日志,发现预约列表页有个关联查询没走索引,加了联合索引后从2秒降到几十毫秒。第二,接口聚合改造,把首页的7个请求合并成2个聚合请求,移动端弱网环境下体验改善很明显。第三,缓存策略,展览列表这类变化频率低的数据在Nodejs中间层加了内存缓存,过期时间1分钟,能挡住大量重复请求。
最后压测下来,核心接口的P99响应时间控制在500毫秒以内,节假日高峰期也能稳定运行。做完这个项目回头再看,我发现多技术栈项目的成败往往不在于某个框架有多高级,而在于边界能不能守住。PHP老老实实做业务,Nodejs老老实实做编排,Vue老老实实做UI,谁也别越界,联调阶段就会顺畅很多。最后再多说一句:如果你也打算做类似的多端平台,一开始就花半天时间把接口返回规范、错误码约定、路由命名规则定下来,绝对值得。