技术架构
四端同源是怎么做到的
只有一个答案:判据全在服务端,四个端都只是它的显示器。下面是这句话落到工程上的样子。
技术选型
- 服务端
- Spring Boot 4 · Spring Data JPA · Redis · MySQL 8 · JDK 25
- 网页与管理台
- Next.js 16 App Router · shadcn/ui · Tailwind CSS 4 · TanStack Query
- 小程序
- 微信原生框架(非 uni-app / Taro)· TypeScript 逻辑层
- 存储
- MySQL 8 · Redis · 对象存储(客户端直传)
按业务域纵向切分
不是按 controller / service / repository 分层——那样一个目录会装近百个文件,改一处业务要同时打开四个目录。现在改树洞只需要打开一个目录。
- admin126
管理端的全部接口:审核、运营配置、订单、权限
- user69
资料、关注、隐私、签到、举报、角色
- message65
系统消息、私聊额度、WebSocket、订阅消息
- ugc63
动态、话题、点赞收藏分享、搜索
- campus48
学校、学院、校区、地点
- showcase39
广告位、表情、诗词、轮播、能力标签
- form35
问卷表单、题目、CP 匹配
- student34
学生身份认证(邮箱 / 照片 / 答题)
- activity27
活动、场次、报名、活动评论
- score23
微校分、充值、兑换、支付
- organization22
社团组织与身份
- course21
课程、教师、教室、课程评价
- treehole16
匿名树洞
- tracking14
打开事件与行为埋点
- attachment14
附件(多域共用,独立成域)
- wechat9
小程序扫码登录与内部回调
- idle11
闲置交易
- ai11
AI 供应商与内容初筛
- comment7
评论(从 ugc 拆出,四类内容共用)
右上角的数字是该域的文件数,条形按最大的那个域等比缩放。跨域共用的东西只放 shared,且必须真的跨域。
浏览器里的那两个端
「学生在浏览器里也能用」「校级管理员自己就能用起来」——这两句光靠文字说都没有说服力。
网页版
与小程序共用同一套后端、同一批账号与内容。
新鲜事:桌面三栏,右上角是浏览范围 动态详情与楼中楼评论 私信:与同学的一对一会话 小程序扫码确认:当前账号清楚显示,确认后网页才登录
运营管理台
审核、运营配置、题库、配对、订单都在这里。
数据概览:校级管理员看到的是本校的数字 学生认证审核:AI 初筛给的理由摆在显眼处,人做最后一判(证件照已打码) 内容审核:删除是软删,勾「含已删除」可回看并一键恢复 CP 配对工作台:待匹配与已配对一目了然,人来拍板 投放范围:同一条轮播可以只给一所学校、甚至只给一个校区看 通用订单:买的是什么是一个字段,不是写死的「充值」
接口契约
这五条是写死的。四个端各自实现一次客户端,靠的就是它们不变。
01HTTP 状态码表意
成功就是 2xx 且响应体直接是数据,没有外层包裹;失败是对应的状态码,响应体固定为
{error, message}。分支判断只看error常量,永远不比对message——文案会改,还会随语言变。02401 分三态
TOKEN_EXPIRED触发单飞刷新后重放原请求;TOKEN_MISSING与TOKEN_INVALID直接回登录态。三态不分开的话,一次过期就会把用户踢回登录页。03游标分页
{start, limit}换{elements, total, hasMore, nextStart}。start是不透明游标,续页只能用上一页给的nextStart——自己算start + limit会串页。04空字段被省略
服务端 JSON 不下发 null。所以读任何可空字段之前先回答一句:这个键不存在的时候算什么?把它写进代码注释里,比记在脑子里可靠。
05错误文案有语言
服务端按
Accept-Language返回对应语言的错误文案。所以客户端固定发自己界面的语言,而不是透传浏览器的——否则中文界面里会冒出英文提示。
认证与令牌
学生侧
自建不透明令牌存在 Redis 里。访问令牌 4 小时且每次请求滑动续期,刷新令牌 7 天且一次性使用。同一用户重复登录会吊销之前的令牌。Redis 里存的只是一个
User:123的引用,用户信息每次读取时回查——所以处罚、认证状态一改立刻生效,不用等令牌到期;也没有一份序列化的用户对象躺在缓存里。两个登录入口
小程序走微信 code 登录;网页展示打开确认页的小程序码。扫码后小程序先用自己的令牌确认当前用户,用户明确点确认,浏览器轮询拿一次性 Web 令牌。身份直接来自小程序会话。
管理侧
与学生是两套完全独立的账号体系,令牌互不通用。访问令牌是 30 分钟的 JWT,服务端登记 jti 所以能即时吊销;刷新令牌不透明、7 天、用完即焚——因此客户端的单飞刷新是必需品而不是优化。
四个端各自的取舍
网页必须走服务端代理
服务端只给管理端接口注册了 CORS,学生侧接口一个跨源头都不发(小程序不是浏览器环境,给学生侧开 CORS 只会扩大攻击面)。所以浏览器直连一律被同源策略拦下,请求统一经过 Next 的路由处理器转发。
WebSocket 不经代理
路由处理器不转发协议升级,所以 WebSocket 直连服务端。浏览器又不能给 WS 请求自定义头,令牌只能走查询参数——服务端的令牌过滤器支持这一路。没配 WS 地址时整段跳过,列表本来就有轮询兜底。
图片直传对象存储
服务端只签发一份有时效的表单凭据,文件从客户端直接传到存储,不经过应用服务器。省带宽,也免得一个大图把连接池占满。
小程序按分包切
主包只放高频页面,课程、CP、摄影、树洞、地图、活动、闲置、积分各自成包——首次打开只下载主包。
这些检查是拿故障换来的
这个项目最常见的故障有一个共同点。它不报错:没有异常、没有红日志、请求照样 200 —— 只是那件事没发生。这类问题 review 看不出来(代码本身没毛病),单测也测不了(测什么?测一个字符串等于另一个字符串?),只能一类一类单独拦一道。下面每一条都对应一次真的踩过。
模板绑到了不存在的东西
小程序对悬空绑定是静默的:
bindtap指向一个不存在的方法,点上去什么都不发生;wx:if里引用一个不存在的字段,条件恒假,那一块永远不出现。重命名时只改了一边就是这种症状 —— 页面空着,控制台一行日志都没有。图标类名写错了一个词
图标是纯 CSS 背景图,类名写错时那个位置只是空着 —— 而「空着」和「这里本来就没图标」在界面上一模一样。网页登录确认页重写时写了个
icon-info-muted,图标表里只有icon-close-muted那一族,结果态的圆圈里什么都没有。是逐个核对图标表才发现的,不是页面告诉我的。推送模板 id 被写死在客户端
十几处调用点各自硬编码一串模板 id,而服务端配额认的是另一套码表,两边谁也对不上谁。授权框照弹、用户照点、接口照返回 200,只是推送永远不到。这是这个项目上一个真实故障的根因。
漏翻的 key 被原样发给用户
服务端返回的是文案 key,翻译表里没有就原样下发。学生撤回一条不存在的入驻申请时,弹窗里写的是
entity.school_application。而它只在「那条 404 真的发生」的那一刻现形 —— 接口通、功能对、平时一切正常。拦截名单靠字符串对上
受限动作名单里放的是路径字符串,和 controller 上的注解没有任何编译期联系。谁把
/dm/message改个名,那一条就再也匹配不上 —— 被禁言的用户从那一刻起能发私信,而没有任何测试会红、任何日志会响。放行名单失配只是白放一条;拦截名单失配是放开一条。响应体里塞了整个实体
把 JPA 实体直接放进 VO,实体上有什么就发什么。闲置详情里的卖家字段就这么带出了 openid、unionId、手机号、学生邮箱、地址 —— 列表页一次是一整页的量。讽刺的是那 8 个隐私开关管的是 QQ、微信号、生日,比这些轻得多,还有专门的单测盯着。
用棘轮,不用清单
还没清干净的那些(比如仍写死在服务端的中文提示)不列成一份「待迁清单」—— 清单是第二个真相来源:会忘记更新、会过期,而且没人去对账。改成让测试自己说还差哪些文件:新增一处,构建当场红;转掉一处就从名单里删掉。名单只会变短。
会误报的检查比没有检查更糟
先被无视,然后被绕过,最后连它抓到的真问题也一起没人看了。所以每条检查的判据都刻意收窄,只报确定性错误:字段的深层属性不查(那要类型信息),wxs 语法只收实机真撞出过编译错误的写法,导航栏底色按解析出来的 token 判而不是按类名判 —— 按类名判会把七八个正常页面全部误报。
- 121
- 服务端测试类
- 11
- 小程序静态检查
部署需要准备什么
系统本身自部署。真正的前置条件是这几个外部账号,其中两个的申请周期比写代码长。
一台服务器
跑应用、MySQL 8、Redis。JDK 25。
对象存储
存图片与视频,需要配好跨域规则与访问域名。
小程序账号
学生端入口。
商户号
只有要做充值才需要。
发信邮箱
邮箱认证的验证码从这里发出。
所有凭据从环境变量注入,缺一个应用在启动期就会失败并指出缺哪一项——不会带着未解析的占位符跑起来。