1. 项目整体思路为什么用“JavaSSMFlask”这个组合1.1 先想清楚戏剧推广网站到底要做什么我之前接手了一个偏冷门的Web项目需求方是一家做戏剧文化传播的团队他们手里有大量线下剧场资源但线上展示和推广一直比较原始。他们要的是一个“既能撑起内容展示又能对接线上售票最好还能有一些自动化推广能力”的网站。说白了这不是一个单纯的静态宣传页而是一个带运营后台、用户体系、订单流程最好还能自动整理行业资讯、辅助运营决策的综合性平台。这类项目最头疼的不是页面好不好看而是业务链条比较长前台要展示剧目、场次、票价用户要注册登录、选座下单后台要维护剧目信息、管理订单、发布公告运营还想要一些外部数据来辅助判断什么剧目该重点推。如果只用一个技术栈硬扛比如纯SSM也能做但做Python侧的推荐逻辑、爬虫采集、定时统计会很别扭如果完全用Flask做业务模块一多代码维护成本又会几何级增长。所以当时定了一个“双引擎”方案JavaSSM负责核心业务与数据持久化Flask负责数据采集、推荐和统计分析类服务。两者通过HTTP接口协作不搞复杂的分布式架构部署时一台服务器就能全跑起来。1.2 SSM和Flask的分工逻辑很多同学一看“JavaSSMFlask”就以为是很重的微服务架构其实不是。实际项目里就是两个独立进程彼此之间通过JSON接口通信。我把项目拆成了三块Java侧SSM用户、角色、剧目、场次、订单、评论、资讯、活动、基础配置等核心业务表全部落在这里。SpringMVC负责路由和参数校验MyBatis负责SQL操作Spring负责事务和Bean管理。Flask侧负责不依赖Java内部实现的“辅助业务”比如从公开网站抓取戏剧行业资讯、给用户做简单的协同过滤推荐、生成运营日报的统计数据。Flask的Requests和BeautifulSoup生态确实比Java侧轻快许多。静态资源与前端前端使用服务端渲染的JSP页面配合少量Ajax和Vue.js只做局部组件化没有单独搞前后端分离项目因为团队人手有限JSPJQuery的维护成本最低。这个分工最大的好处是Java侧不用去管Python代码Flask侧也不用去理解MyBatis里的复杂SQL。两边只约定好接口协议独立开发、独立测试、独立重启。而且Flask服务本身无状态如果后续要做推荐服务扩容多开几个实例挂到同一套MySQL前面也没问题。2. 核心功能模块与数据建模拆解2.1 用户与权限模块把“游客/会员/运营/管理员”分开任何推广网站的第一步都是用户体系。我建议不要一开始就设计特别复杂的角色权限而是把身份分成四类未登录游客、普通会员、运营编辑、系统管理员。游客可以浏览剧目信息但下单前必须登录会员可以购票、发表剧评、收藏剧运营编辑可以维护剧目和资讯内容管理员负责账号审核、订单退款、活动配置。数据库里我按经典的“用户表角色表用户角色关联表”设计。用户表的核心字段包括user_id、username、password_hash、phone、email、avatar_url、status、register_time。这里特别提醒密码绝对不要明文存储我用的BCrypt加密不要用MD5或者SHA1这些快速哈希算法因为现在GPU跑字典太快了BCrypt自带salt和迭代成本算是Web系统对密码最基本的尊重。用户角色表不直接挂字符串而是用role_id关联方便后续扩展运营、代理、渠道商等多角色。Spring拦截器里只判断当前用户是否登录具体操作权限再用注解或者页面标签判断。比如运营编辑可以进/admin/content/**但是只有管理员能访问/admin/user/**。用这种粗粒度的权限管理对中小型推广网站完全够用不用把Shiro或Spring Security的过滤器链调得特别复杂降低团队上手门槛。2.2 剧目信息与内容管理模块推广网站的脸面戏剧推广网站的核心资产是剧目内容而不是代码。剧目的结构化程度比普通新闻资讯高很多至少包括剧名、类型话剧/音乐剧/舞剧/戏曲、主创团队、剧情简介、演职人员、宣传海报、视频预告、演出时长、语言、适合年龄段、获奖情况、评分。这个模块直接影响搜索、筛选和推荐效果所以数据建模要尽量细。我设计了drama表包含drama_id、title、subtitle、category_id、director、playwright、cast_members用JSON数组存方便前台演员列表渲染、synopsis、highlight、cover_url、video_url、duration_minutes、language、age_requirement、is_recommended、status、view_count、create_time、update_time。category_id关联类型表避免直接用字符串作为分类维度。海报图和视频建议用对象存储数据库只保留URL。上传时通过Java侧接口将文件传到云存储然后返回URL给前端。如果项目部署在内网或本地可以用Nginx的静态目录来存但那一块要记得做防盗链和大小限制不然很容易被人塞一堆大文件把磁盘打满。内容运营还有一个很实际的需求一个剧目往往有多套演职人员阵容比如同一部话剧在不同城市、不同轮次演出时主角会换人。所以我没有把演职人员直接塞进drama表而是单独建了一个drama_cast表通过drama_id关联包含actor_name、role_name、cast_photo等字段。列表页显示默认阵容详情页可以切换场次后展示对应阵容。这个细节如果前期不做后面运营一定让你改。2.3 票务订单与库存模块最容易出事故的地方购票流程是整个网站最核心的链路也是技术难点最集中的地方。它的难点不在于订单表设计而在于“座位/库存的并发控制”。通常戏剧演出有两种售卖模式一种是固定座位用户选座购票一种是不选座只按票价分区统计余票。为了避免过度设计我给项目做了两种模式通过剧目的ticket_mode字段区分seat_mode1时使用drama_session_seat表每一行对应一个真实座位字段为seat_id、session_id、row_no、column_no、seat_status0未售、1锁定、2已售。seat_mode0时使用drama_session表的remain_tickets字段做简单的扣减。不管哪种模式订单主表都包括order_id、order_no、user_id、session_id、total_amount、status待支付/已支付/已取消/已退款、create_time、pay_time。订单明细表order_item记录每个票品的drama_id、session_id、seat_id、ticket_price、ticket_type全价/学生票/惠民票。这里我要特别强调一个隐藏需求订单号必须全局唯一且可读。我用“日期随机数自增序号”拼了一个形如202505211420301234的订单号前端抢票生成的订单号足够分散。数据库为order_no加上唯一索引防止并发下重复。库存扣减逻辑没有用“先查库再更新”这种有并发漏洞的方式而是直接执行update ... set remain_tickets remain_tickets - #{count} where session_id #{id} and remain_tickets #{count}。通过数据库行锁保证不会超卖Java代码里不用先select再update否则两台服务器同时读到余票为10各卖一张后仍然显示10就出大问题。2.4 推广与数据服务模块Flask在这里发光推广网站的“推广”不只是前端宣传页还需要持续的运营动作比如跟踪外部行业热点、给用户做剧目推荐、统计各渠道带来的订单转化情况。这些工作在Java里写并不是不可能只是代码会很啰嗦。我让Flask承担了三件事第一资讯采集。写了一个定时任务每天从几个公开的戏剧资讯站点抓取标题、摘要、来源、发布时间。抓下来的数据经过简单清洗后调用Java侧开放的POST /api/external/article/push接口写入数据库运营在后台人工审核后发布。这种“半自动采集人工审核”的模式既能保证内容更新频率又能避免抓进垃圾内容。第二用户推荐。基于用户的历史浏览和订单数据Flask每天聚合同类用户喜欢的剧目生成每个用户的推荐列表存到一张recommend_cache表里。前台推荐位直接查这张缓存表不再实时触发运算。对中小型网站来说完全没必要上Spark或者推荐引擎几百行Python脚本就够了。第三运营日报。每天早上8点由定时任务触发Flask从MySQL里读昨天的订单数、新注册用户数、各剧目销售排行、各渠道访问数据汇总成一份Markdown日报然后调用企业微信机器人接口推送给运营群。Flask侧apscheduler实现定时任务非常方便几行代码就能跑起来。3. 工程搭建与关键实操记录3.1 SSM项目结构的搭建很多人喜欢用IDE直接生成Maven项目但团队协作时我更推荐先定好包结构避免每个人写得五花八门。我把Java侧代码按controller/service/mapper/entity/common四层分包另外单独放了config和interceptor。com.drama.web ├── controller │ ├── admin │ ├── api │ └── front ├── service │ ├── impl │ └── iface ├── mapper ├── entity ├── common │ ├── result │ ├── exception │ └── utils ├── config └── interceptorController里的front是面向游客和会员的页面admin是后台管理接口api是开放给Flask等外部系统调用的JSON接口。这样分层之后权限拦截器也可以按URL前缀来配置比如/admin/**需要登录且拥有运营角色/api/**用IP白名单或固定Token认证。Spring配置文件我拆成了三份spring-dao.xml只负责数据源、事务管理器、SqlSessionFactoryspring-mvc.xml负责组件扫描、视图解析器、静态资源、拦截器web.xml里配置Spring监听器和DispatcherServlet。这个拆分看起来很基础但很多项目出问题就是因为把所有配置混在一个applicationContext.xml里改一处全盘重启排查问题时非常痛苦。3.2 后端核心配置与Spring整合数据源连接池我用的Druid因为它自带监控页面一眼能看到当前连接数、慢SQL、活跃连接等情况。在spring-dao.xml里我配置了这样的核心参数bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property namedriverClassName valuecom.mysql.cj.jdbc.Driver / property nameurl valuejdbc:mysql://127.0.0.1:3306/drama_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue / property nameusername valueroot / property namepassword value****** / property nameinitialSize value5 / property nameminIdle value5 / property namemaxActive value20 / /bean这里务必注意serverTimezoneAsia/Shanghai如果不写MySQL驱动通常会拿本地时区跟服务器UTC时间比较然后报错“The server time zone value йʱ is unrecognized”。我之前第一次联调就因此浪费了半小时。连接池的maxActive不要设置太大单机部署时20就够设100不但浪费内存还会让数据库并发压力变大。事务管理用注解Transactional但只加到需要写操作的方法上。查询方法不加事务因为加了事务会占用数据库连接高并发下很容易把连接池打满。比如OrderServiceImpl.createOrder()方法必须加事务插入主单、插入明细、扣减库存、清除缓存只要任何一步失败就整体回滚不然会出现“订单已支付但票未锁定”的数据不一致。3.3 Flask侧接口开发与联调Flask侧并不是一个完整的Web应用更像是几个独立服务的组合。目录结构相对简单service_engine/ ├── app.py ├── requirements.txt ├── collector/ │ ├── drama_news.py │ └── source_config.py ├── recommend/ │ ├── load_data.py │ └── recommend_items.py ├── report/ │ ├── daily_report.py │ └── notifier.py └── config.pyapp.py里注册了几个Blueprint比如/api/recommend/top、/api/report/summary启动后默认跑在5000端口。与Java侧对接时Flask只需要发送HTTP请求我用的requests库设置超时时间10秒避免因为Java侧接口慢导致Python线程堆积。app.route(/api/recommend/top, methods[POST]) def push_recommend(): payload request.get_json() user_id payload.get(user_id) items load_user_recommend(user_id) return jsonify({code: 0, data: items})Java侧通过RestTemplate或者HttpClient访问Flask接口。由于两边都部署在同一台服务器上接口地址直接用http://127.0.0.1:5000不走公网减少不必要的网络延迟。两边接口的返回结构统一为{ code: 0, message: success, data: {} }这样Java侧解析时只需要写一个统一的Result类不用每个接口单独适配。3.4 联调流程与部署细节联调阶段最怕的是各写各的到了最后一两天才对接。我通常建议先定义好接口文档至少包含路径、请求方法、请求参数、返回结构、错误码。我用的是Swagger文档Java侧每个接口都加ApiOperation注解Flask侧手动写一个api.md两边对齐字段名后再开始写代码。部署时我用的是传统方式Java侧打War包放进Tomcat的webapps目录Flask侧用gunicorn启动。Tomcat端口8080Flask端口5000Nginx监听80端口统一反向代理。Nginx配置里把/api/recommend和/api/report开头的请求直接转发到Flask其他请求转发到Tomcat。server { listen 80; server_name drama.example.com; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /api/recommend/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; } location /api/report/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; } location ~* \.(gif|jpg|jpeg|png|css|js|ico)$ { proxy_pass http://127.0.0.1:8080; expires 7d; } }这里有一个容易踩的坑Nginx转发时如果没有设置X-Forwarded-ForJava侧获取不到真实用户IP所有访问日志都会变成127.0.0.1。为了统计推广来源我在Java侧写了一个IpUtil来读取请求头优先取X-Forwarded-For的第一个IP再取X-Real-IP最后才取getRemoteAddr()。4. 高频问题与排查技巧实录4.1 前后端联调的“隐形坑”JSP页面与后端Controller之间最常见的坑就是中文乱码和参数传错。中文乱码不只是response.setCharacterEncoding的问题还要保证三层都一致数据库连接串用characterEncodingutf8JSP页面顶部设置pageEncodingUTF-8Spring CharacterEncodingFilter设置encodingUTF-8并让forceEncodingtrue。漏掉任意一层就可能出现“查询结果正常但写入数据库后变成问号”的情况。另一个坑是表单提交时日期格式不匹配。前台传来2025-05-21 14:30:00如果实体类用java.util.DateSpring需要加DateTimeFormat(patternyyyy-MM-dd HH:mm:ss)。我建议前后端时间格式统一用yyyy-MM-dd HH:mm:ss不用时间戳因为时间戳在页面上没法直接阅读增加调试成本。再补充一个真实遇到的状况前端Ajax调/front/drama/detail?id12Controller方法接收的是RequestParam(id) Integer dramaId但如果前端因为跳转拼接URL时带了空参数比如?idSpring会直接报400。为了稳健我一般把参数类型设为Integer同时加requiredfalse然后在业务层判断空值。4.2 数据库事务与超卖问题前面提到的库存扣减SQL虽然能避免超卖但还有“锁等待超时”的风险。如果同一场演出很多人同时下单数据库里remain_tickets这一行会被行锁串行化更新当并发达到每秒几十单时InnoDB默认的50秒锁超时不太会触发但应用层的数据库连接池可能先耗尽。我的解决办法是“库存扣减前先获取分布式锁”。项目里没有额外引入Redis就用MySQL的select ... for update锁住drama_session这一行再执行扣减。或者直接用一条带条件的UPDATE。实测小并发的情况下一条UPDATE已经够稳。更复杂的场景是锁票和支付并发用户选了座位后系统先在座位表将seat_status置为1但用户可能迟迟不付款。这里需要一个定时任务把超过15分钟未支付的订单取消并释放座位。用Quartz定时批量扫描订单表select order_id from orders where status0 and lock_expire_time now()。然后将对应订单置为已取消同时将座位状态改回0。一定注意不能用Java内存计时器因为服务器重启后计时状态会全部丢失数据库状态才是最可靠的。4.3 并发、缓存与接口性能推广网站的一大特点就是“热点剧目集中访问”。同一部爆款话剧开票时首页和详情页的QPS可能瞬间飙高。我选用了一个简单的缓存策略把剧目详情、场次列表、首页推荐位这些读取量大的接口加上Redis缓存缓存Key设计为drama:info:{id}、session:list:{id}缓存时间15分钟。如果后台编辑修改了剧目信息需要立即修复缓存。但缓存更新最容易出的问题是“缓存穿透”。用户恶意使用不存在的剧目ID频繁刷接口时每次都会打到数据库。我的做法是“缓存空值”如果数据库查不到就在缓存里写一个null标记同样设置过期时间。另外在Controller入口做简单参数校验dramaId必须大于0否则直接返回错误。在没接Redis之前我用的是本地ConcurrentHashMap做临时缓存但多台Tomcat实例之间缓存不一致。后来项目部署在一台服务器上本地缓存还能勉强接受但只要横向扩展就出现问题。最终我统一用Redis并且给缓存操作封装成CacheServiceJava侧所有的get/set/delete都走这个Service后面接什么缓存实现都能随时替换。4.4 部署环境差异带来的问题很多项目在本地跑得好好的一到服务器就崩溃。这个项目最常见的部署差异是Python依赖版本问题。开发机用的是Python 3.10服务器上是3.7Flask里如果用了新版语法比如list[str]在3.7下面直接SyntaxError。解决方案是在requirements.txt里锁定所有依赖的精确版本并且用虚拟环境创建隔离的Python环境部署脚本里先执行python3 -m venv venv然后pip install -r requirements.txt。Java侧的部署差异主要在JDK版本和Tomcat版本。本机JDK 17编译的代码放到JDK 8的服务器上运行会出现“UnsupportedClassVersionError”。所以pom.xml里显式指定maven.compiler.source和maven.compiler.target为1.8确保兼容。数据库编码更是重量级选手。服务器MySQL的character_set_server默认可能是latin1你dao层的连接串写了characterEncodingutf8也没用因为建表时如果没有指定默认字符集表结构还是会以服务器默认字符集为准。我强烈建议建库时统一加CREATE DATABASE drama_db DEFAULT CHARACTER SET utf8mb4。utf8mb4比utf8更完整支持Emoji和一些生僻字戏剧剧名里经常有特殊符号必须要用。5. 实测之后的优化方向与个人体会这个项目做完之后我最大的感受是技术选型不要太迷信“全栈统一”也不要盲目拆微服务。JavaSSM和Flask双进程看起来有点“混搭”但实际运行起来各司其职、互相不拖累团队里懂Java的人写核心业务懂Python的人写采集推荐效率反而最高。如果后续要继续扩展我会建议在三个方向上做增量优化第一获取更多渠道数据。现在Flask只抓了资讯其实还可以抓公开的演出排期、场馆档期甚至豆瓣上的剧目评分。通过定时任务维护一份“行业观察库”给运营的选题和排片做辅助。第二把推荐算法升级成“人群实时行为”。现在的协同过滤是离线跑的只能做到“昨天用户看了什么今天推荐什么”。如果接入用户实时点击流可以做“看了这个剧的人也看了那个剧”的实时推荐转化率会更明显。第三完善支付和退款状态机。现在订单状态只有待支付、已支付、已取消、已退款四个状态实际上很多剧场还有“已检票”、“已过期”、“部分退款”等状态。状态机如果不设计好后续统计报表会出很多对不上的数据。最后还想分享一个小经验这个项目的前端页面没有用任何重型前端框架就是JSPJQueryBootstrap。很多开发者觉得这种技术栈“老”但在做推广类网站时运营人员经常需要改页面上的文案、图片、推荐位服务端渲染的方式让她们改起来更有安全感不用等前端重新打包部署。与其追求技术新不如先让业务跑起来。这个项目上线后的第一个季度线上售票转化率提升了约三成团队最开心的事不是表扬架构而是运营同事终于能自己维护首页专题位了。能让项目真正被人用起来才是技术人员最实在的成就感。