
前阵子帮一所高校做一个教师答疑平台开发过程中和教务处的老师聊了聊发现了一个特别典型的现象线下坐班答疑两小时来问问题的学生不到三个期末复习那几天微信群里的问题却刷得飞快消息一滚动就被淹没老师压根看不见谁问过什么。两边都有苦衷但问题一直没人解决。我们最后做出来的这套高校教师答疑互动交流系统把提问、回答、追问、知识标签和消息通知整个流程都搬到了线上小程序端给学生用管理后台给老师用。这个项目的后端同时用了ThinkPHP和Laravel两个 PHP 框架Laravel 负责小程序和 App 调用的 API 主站ThinkPHP 负责管理后台前端是微信小程序。选双框架不是为了一时的技术热情而是两个框架各自擅长的领域刚好互补。这篇文章把我从数据库设计到部署上线踩过的坑完整写下来给正在做校园信息化项目、课程设计或者接外包单子的开发者一个参考。1. 两个PHP框架混用不是炫技是各取所长很多人一听一个项目里用两个框架就觉得是过度设计其实换个角度看就很好理解主站 API 和管理后台是两种完全不同性格的应用。一个偏重业务逻辑、并发请求、数据关系一个偏重 CRUD、表单、权限、报表。用一套技术硬扛两个场景当然也能跑但效率不是最优的。1.1 Laravel做主站API值得依赖的不只是优雅我在主站选择 Laravel最重要的原因是它的 ORM、队列和生态。先说话最多的 ORM。答疑系统里最核心的关系是学生—问题—回答—追问—标签一个列表页往往要同时展示提问人信息、课程名称、回答数量、标签名称。如果这些靠手写 JOIN一个接口维护起来就是噩梦。Eloquent 的关联模型和 with() 预加载几句代码就能把要用的数据一次性取出来还不容易被注入问题。然后是队列。学生提交问题之后系统要干的事情不止往表里插一条记录这么简单要给任课老师发微信订阅消息要生成站内通知要做敏感词过滤可能要发邮件。这些操作如果全在请求周期里同步完成接口延迟会很难看。Laravel 的队列把这类任务丢进 Redis 或者数据库队列后台跑一个php artisan queue:work就全部异步消化掉代码只需要写一个 Job 类和一个dispatch()。再就是生态。Sanctum 做 API token 鉴权几十行代码就能接入模型事件做状态流转写起来语义也清楚。Laravel 的学习曲线确实比 ThinkPHP 陡但你仔细算一笔账业务系统越写越大之后结构约束反而是在帮你省时间。1.2 ThinkPHP拿来写管理后台用它的快管理后台这个场景坦白讲 Laravel 也能做但 ThinkPHP 的开发效率更高。原因很实际中文文档和现成方案多团队里其他成员接手时成本低后台页面本质上就是表格、表单、筛选、批量操作ThinkPHP 自带的分页、验证器、多应用模式正好把这些杂活包裹得很舒服。我们在这个项目里后台只做教师审核、课程管理、内容管理、数据报表四件事——每一件都是典型的后台 CRUD。用 ThinkPHP 写这类功能半天基本能出一个能用的模块。网上还有大量基于 ThinkPHP 的权限组件和后台模板改改菜单和字段就能直接落进项目这是快速交付很关键的一环。1.3 双框架共存的边界同库不同表逻辑不交叉双框架最大的风险是两套代码在同一张表上互相踩脚。我的处理方法是同一个 MySQL 数据库但业务边界清晰分割。Laravel 这边管理业务主表比如用户、问题、回答、通知ThinkPHP 后台只管读取和运维类操作比如审核教师、删除违规内容、生成统计报表。后台也可以改业务表状态但只通过标准 Service 层的更新方法去改不在控制器里随手 update。跨框架的数据同步不做 HTTP API 调用因为性能损耗没必要。两边直接操作同一个库只是通过字段命名、状态常量、更新规范来约束彼此。如果后期要拆服务再通过消息队列或者 Redis 做中间层目前阶段保持简单就好。2. 答疑系统的数据模型从提问到追问的表结构设计整个系统里最值钱的部分不是代码而是数据表设计。答疑场景里有一个容易被忽略的点问题不是一次性答完就结束的学生可能追问老师可能补充状态可能反复变化。如果只按一问一答的模型设计后面改起来会很痛苦。2.1 用户体系一张users表加一张扩展表第一版我也纠结过要不要把教师和学生拆成两张独立表后来放弃了。原因是账号体系和业务资料要解耦users表只存通用登录信息openid、nickname、avatar、roleteacher/student/admin、statususer_profiles表存扩展资料教师存工号、职称、学院、研究方向学生存学号、专业、年级用user_id一对一关联这样设计的好处非常明显发通知的时候一条查询覆盖所有角色不用分别查两张表再合并权限中间件只看role字段就能判断以后接入 App 登录也不用动业务表结构。2.2 提问、回答、追问一张answers表靠parent_id自关联核心业务表有三张questions是提问主表answers是回答表courses和teachers是多对多关系。questions表的字段大致如下CREATE TABLE questions ( id bigint unsigned NOT NULL AUTO_INCREMENT, user_id bigint unsigned NOT NULL COMMENT 提问学生ID, course_id bigint unsigned NOT NULL COMMENT 所属课程, title varchar(200) NOT NULL, content longtext NOT NULL COMMENT 过滤后的HTML, status tinyint NOT NULL DEFAULT 0 COMMENT 0待回答 1已回答 2已关闭, view_count int unsigned NOT NULL DEFAULT 0, answer_count int unsigned NOT NULL DEFAULT 0, created_at datetime DEFAULT NULL, updated_at datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_status_created (status, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT提问表;answers表最关键的是parent_id。parent_id 0表示这是一条直接回答parent_id指向某条回答 ID 时就是针对这条回答的追问。一张表就能表达回答和追问两种关系代码里递归查询时注意层数限制就行实际业务里追问一般不会超过三层。2.3 冗余统计字段列表页不实时count刚开始做列表页的时候我直接在 SQL 里 LEFT JOIN answers 之后COUNT(*)数据量少时完全没问题。但到了第二周首页问题列表和教师工作台高频查询开始出现慢查询问题就出在这些实时统计上——问题一多每次列表查询都要同时做多次子查询计数。解决办法很土但很有效在questions表上直接冗余answer_count和view_count。回答新增或者删除时在 Laravel 模型事件里更新这个字段。为了数字尽量实时我还在 Redis 里做了一层 10 秒的短缓存缓存过期后由第一个请求回源数据库并重建缓存。这套方案在日均万级访问的小程序场景下非常稳。3. Laravel API认证与小程序登录态把微信用户映射到系统用户小程序端的登录验证流程是刚接触这类项目时最容易绕晕的地方。它不是传统的前端传用户名密码而是需要跟微信服务器做一次 code 换 openid 的交换再用 openid 去系统里找用户。3.1 微信登录code换openid的业务流整个流程只有三步小程序端调用wx.login()拿到临时凭证code后端拿着 code 去微信服务端换openid和session_key用openid在系统 users 表里查找用户不存在就自动注册Laravel 端核心代码大概是这样的// routes/api.php Route::post(/login, [AuthController::class, login]); // AuthController.php public function login(Request $request) { $code $request-input(code); $wxResp Http::get(https://api.weixin.qq.com/sns/jscode2session, [ appid config(wechat.mini_appid), secret config(wechat.mini_secret), js_code $code, grant_type authorization_code, ])-json(); if (isset($wxResp[errcode]) $wxResp[errcode] ! 0) { return response()-json([message 微信登录失败], 401); } $user User::firstOrCreate( [openid $wxResp[openid]], [nickname 微信用户, avatar , role student] ); return response()-json([ token $user-createToken(miniapp)-plainTextToken, role $user-role, ]); }有个安全细节容易被忽略session_key绝对不能返回给前端。它是之后解密手机号、敏感信息的密钥一旦在小程序端被人截获攻击者就能伪造用户身份。后端换到之后只在服务端短期保存即可。3.2 API token方案为什么这里不用Laravel SessionLaravel 框架默认的认证方式是基于 Session 和 Cookie 的但小程序端根本没有 Cookie 机制每次请求也不会像浏览器那样自动携带 Session ID。硬要用 Session就得在前端手动保存并回传 Cookie还要处理 CSRF Token这套操作在跨端场景下纯属给自己找麻烦。所以 API 场景一定用 Token。选型时我在 Sanctum 和 Passport 之间对比了一下对比项SanctumPassport授权流程简单适合第一方应用完整 OAuth2适合第三方开放平台实现成本低composer 安装后迁移表即可用高需要处理客户端密钥、重定向Token 类型个人访问令牌访问令牌 刷新令牌适合场景小程序、App、SPA开放平台、第三方登录高校答疑系统是典型的第一方应用用户就在自己的小程序里玩不需要给第三方授权用 Sanctum 最合适。createToken()返回的plainTextToken在前端保存好之后请求头里加Authorization: Bearer token就能通过auth:sanctum中间件。3.3 登录态续期与teacher/student角色权限控制Token 要不要过期、过期了怎么办是我实际思考了很久的一个点。参考了不少校园项目的做法最后选择Sanctum Token 不设硬过期但每次请求都会更新last_used_at超过 30 天没活跃就自动失效。这样学生一学期内打开小程序都不会被反复要求重新登录又能在暑假寒假之后自然清理掉僵尸会话。权限控制也很简单。给接口分组时用中间件限制角色// Kernel.php 里注册 teacher \App\Http\Middleware\CheckRole::class, // 路由里使用 Route::middleware([auth:sanctum, teacher])-group(function () { Route::post(/questions/{question}/answers, [AnswerController::class, store]); });// CheckRole.php public function handle($request, Closure $next, $role) { if ($request-user()-role ! $role) { return response()-json([message 无权限操作], 403); } return $next($request); }这样一套下来接口层的身份问题全部归口到同一套机制控制器里不用到处写 if 判断角色。4. 小程序端接入富文本、消息通知和追问状态机后端接口稳定之后小程序端接入反而是坑最多的地方。很多问题在 Chrome 里看着完全正常一进rich-text就翻车。4.1 富文本内容存储、渲染与XSS过滤老师回答问题时内容里可能包含图片、公式、表格。前端我用的是支持图片插入的编辑器提交到后端时内容是 HTML 片段。服务端在入库前必须做 XSS 过滤不是简单strip_tags而是用专业的过滤库把script、onclick这类危险内容剥掉。这个不能省因为管理后台审核是滞后的万一有学生传了一段恶意脚本影响面不好控制。小程序端展示时用rich-text组件直接渲染 HTML 字符串。这里要注意rich-text不是万能的部分 CSS 和脚本会被剥掉表格样式可能错乱。如果数学公式多建议服务端把 LaTeX 公式渲染成图片再存入内容小程序端就不要处理复杂的公式排版了。4.2 消息触达轮询未读数配合订阅消息老师多久才会看到我的问题是答疑系统里最影响体验的问题。技术上有三个选择WebSocket实时性最好但要自己维护长连接服务成本高轮询小程序进入前台时请求一次未读数实现简单用户感知也够微信订阅消息触达能力最强但用户必须主动授权一次授权只能推送一次我的方案是轮询加订阅消息混合。数据层用轮询小程序每次onShow都去拉一次未读数接口把 TabBar 上的红点数字更新掉触达层用订阅消息老师在小程序里点一次允许新问题提醒就消耗一次订阅额度给他推一条通知。未读数接口的代码非常简单但要注意加缓存Route::middleware([auth:sanctum, throttle:20,1])-group(function () { Route::get(/notifications/unread, [NotificationController::class, unreadCount]); });4.3 追问状态机让每个问题都有明确归宿一个没有状态流转的答疑系统时间长了会变成垃圾场——所有问题都躺在列表里永远显示待回答。我们的做法是给问题定义了三个状态pending待回答、answered已回答、closed已关闭。状态迁移规则学生创建问题 →pending老师提交首答 →answered学生在answered状态下追答 → 问题回到pending超过 7 天没有新回复或追问次数达到 3 次上限 → 自动转closed老师和管理员可以手动关闭问题这套状态机在 Laravel 模型事件里实现不散落在控制器中后面拆逻辑或者测试都会轻松很多。5. ThinkPHP管理后台开发要点从权限控制到内容审核管理后台是整个系统里看起来最不起眼、但真正决定运营效率的部分。做后台最容易犯的错是功能越加越多最后变成一个没人能维护的怪兽。我给自己定的原则是后台只做必要的事。5.1 后台不多做四个功能范围就够这个系统后台只承担四项职责教师注册审核教师注册后必须后台审核通过才能接收学生提问。避免任何一个人注册后就能收到全校学生的问题轰炸。课程和学科管理维护课程列表、任课教师分配。内容管理违规问题删除、敏感词过滤、问题关闭和重新开启。数据简表统计教师答疑量、学生提问量、最近七天的活跃趋势。不做复杂 RBAC后台只分超级管理员和内容编辑两种角色。权限层级越简单出问题的可能性越小。5.2 ThinkPHP读取Laravel建的表模型与跨框架注意事项因为 Laravel 和 ThinkPHP 共用同一个数据库ThinkPHP 这边只需要用门面 Db 直接查询 Laravel 建的表use think\facade\Db; public function questionList() { $list Db::name(questions) -alias(q) -leftJoin(users u, q.user_id u.id) -where(q.status, 0) -order(q.created_at, desc) -paginate(15); return view(question/index, [list $list]); }跨框架协作有两条硬性原则要守住。第一所有字段名必须统一用 snake_caseThinkPHP 和 Laravel 对驼峰命名转下划线策略不同为了这个翻车不值得。第二不要两套代码同时维护同一张表的高频写操作比如回答表Laravel 负责写入后台只读审核操作只改状态的独立字段避免互相覆盖。5.3 小皮面板部署后台运行目录、伪静态与路由配置后台我用小皮面板phpStudy部署细节最坑的是运行目录。ThinkPHP 应用入口在public子目录如果站点根目录直接指向项目根目录访问时会暴露框架目录结构而且 URL 会带着/public/前缀看起来很业余。正确做法是在站点设置里把运行目录指定为public。Nginx 下还需要配 ThinkPHP 伪静态否则路由会被 404 吃掉location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s$1 last; } }小程序请求后台接口不涉及跨域问题但如果是给 H5 管理端用还要在后端加上跨域中间件。上线前检查一下小程序的 request 合法域名必须是已备案的 HTTPS 域名不能带端口。这个很多人上线前才发现临时改配置折腾一天。6. 线上踩坑记录日期时区、图片防盗链和轮询压力这一章是实际运行阶段的血泪教训。有的问题不跑到真实流量负载上根本不会暴露。6.1 日期显示差8小时从现象到根因的排查链路上线第二天学生在反馈群里发了一张截图问题列表里所有提交时间都显示成NaN-NaN-NaN。第一反应是前端解析函数写错了但排查到最后才发现锅在后端时区配置。完整链路是这样的数据库里created_at是正常的2025-01-14 10:30:00接口返回的 JSON 却是2025-01-14T02:30:00.000000ZLaravel 的 Eloquent 会把时间字段转成 Carbon 对象序列化时输出 ISO8601 格式且默认采用配置里的timezone当时配的是 UTC所以比北京时间早了 8 小时微信小程序的new Date(2025-01-14T02:30:00.000000Z)理论上能正确转时区但部分低版本 WebView 对带 Z 格式解析不稳定某些解析分支直接把时间戳算成了 NaN修复方式是在 Laravel Model 里统一控制时间输出格式protected function serializeDate(DateTimeInterface $date) { return Carbon::instance($date) -timezone(Asia/Shanghai) -format(Y-m-d H:i:s); }教训很简单对外 API 的时间格式一定要在服务端钉死不要指望前端帮你去猜时区。6.2 富文本图片裂图Referer防盗链的三种解法系统刚上线时老师在电脑后台编辑答案时插入的图片在小程序端经常裂图。检查小程序代码没问题才发现是图床/CDN 的 Referer 防盗链机制在起作用——小程序请求图片时携带的 Referer 不在图床白名单里图片直接被拒。解决方案有三条路按一劳永逸程度排序富文本内容入库时服务端启动一个 Job 把外部图片抓取下来转存到自己的 COS 或 OSS再替换内容里的图片地址。这是最彻底的图片永久可控访问速度也有保障。如果是自己 OSS把小程序域名加进防盗链白名单。写一个图片代理接口服务端 curl 抓取后输出前端图片地址指向代理。这种方式性能差只适合临时兜底。我建议直接用方案一。抓取外部图片时注意设置超时和大小限制避免小作文里十几张大图把 Job 队列拖死。6.3 未读数接口被刷爆缓存与限流的一次实测未读数接口在开发环境毫无压力上线后却被同一个老师在一小时内请求了两千多次。原因是小程序端在某些低版本微信里会高频触发onShow加上老师会不断从后台切回前台刷新数据。这个接口虽然没有复杂查询但每次都查一遍通知表数量高峰期也扛不住。我做了两层处理第一层在 Laravel 里做短缓存$count Cache::remember(unread_ . $userId, 10, function () use ($userId) { return Notification::where(user_id, $userId) -where(is_read, 0) -count(); });第二次给所有高频接口加 throttle 中间件限制每分钟最多20次请求。实测有过缓存和限流之后接口响应从平均 120ms 降到 20ms 以内老师端操作明显跟手了。最后分享一个这个项目做下来印象最深的体会答疑系统的核心难点从来不在技术而在怎么把确定感做出来。学生问一个问题要让他知道老师大概多久会回、被回答后怎么第一时间知道老师打开后台要让他一眼看出哪些问题最紧急。代码里的队列、状态机、轮询和缓存最终都是为了支撑这种确定感。如果你打算做类似项目先把交互流程和状态流转画清楚再动数据库后面能少走很多弯路。