做这个“ThinkPHP-Laravel微信小程序 个人身体健康饮食推荐系统”的时候我前后换了两版技术路线。第一版用ThinkPHP快速搭建原型三天就把用户登录、健康档案录入跑通了后面因为要上推荐算法和更规范的接口层直接用Laravel重写了核心模块——这也是为什么项目标题里两个PHP框架都挂着。如果你也是做毕业设计、面试项目或个人全栈作品想拿PHP后端接微信小程序做一套带筛选推荐逻辑的业务系统这篇文章应该能帮你省掉不少弯路。我会把健康数据怎么建模、推荐规则怎么设计、小程序端登录态和请求层怎么对接这些关键环节全部拆开讲每个部分都配上我实际调试过的参数和代码。1. 项目整体设计与技术选型背后的事1.1 为什么标题里挂着两个PHP框架这个项目的技术选型其实经历了两次决策。刚开始我图快直接选了ThinkPHP 8因为它的中文文档非常全Db::name(user)-where()这种链式查询写起来是真的顺手而且自带的后台脚手架能省掉很多增删改查的页面工作量。原型阶段所有接口我都用ThinkPHP的路由直接映射Controller方法本地几分钟就能重启验证一遍逻辑。但进入推荐算法和微信登录态这一层就发现不对劲了。健康推荐的筛选规则经常要组合好几个表和标签条件ThinkPHP的查询构造器写嵌套条件时很容易堆成一坨维护起来头疼。加上小程序端要求每个接口都有统一的返回格式和异常拦截Laravel的中间件机制处理这些天生就更顺手。于是我做了个折中决定原型保留ThinkPHP版本作为业务验证正式版整体迁到Laravel 10把原有接口按RESTful规范重写。这里给正在纠结选型的同学一个建议如果你的项目核心是“大量数据管理和界面展示”ThinkPHP完全够用但如果核心是“规则计算、算法推荐、多层中间件鉴权”直接上Laravel省得后面返工。两个框架在同一个业务下的差异我用一张表总结过对比维度ThinkPHP 8Laravel 10查询构造器直观简单适合快速CRUDEloquent ORM关联模型处理复杂关系更优雅中间件有但配置起来稍显原始内置auth、throttle等自定义中间件很顺手路由跳转配置Route::rule(old,new)为主Route::redirect(/old,/new,301)一行搞定队列/任务调度需要扩展包自带Queue Scheduler社区生态中文资料多适合速成Composer包丰富安全更新响应快顺便答一下热搜里那个“ThinkPHP route 地址跳转配置”ThinkPHP 8里做旧地址跳转可以这样写Route::rule(index.php?s/index/index, home/index, GET)-redirect(/home/index, 302);也可以直接在控制器里return redirect(/new/path);。Laravel那边更简单Route::redirect(/old,/new,301)直接声明即可。1.2 健康饮食推荐的业务核心链路这个系统的业务链路不算复杂但环环相扣。用户从小程序端授权登录后第一步是填写健康档案——性别、年龄、身高、体重、日常活动量、目标诉求减脂/增肌/保持、过敏原、慢性病史。这些原始数据会进入推荐引擎算出用户每日热量需求和需要避开的禁忌标签。然后推荐模块从食谱库里筛选出热量区间匹配、无禁忌成分的食谱按匹配度排序返回给小程序展示。用户选中某餐后可以记录到每日饮食日志系统再根据累计摄入做营养分析反馈到首页的今日概览。这个链路里最关键的埋点在于“档案驱动推荐、记录反馈推荐”的闭环。也就是说用户每次新增饮食记录都应该反哺到下一轮推荐——比如用户连续三天早餐都选了高蛋白食谱说明他对这类食物接受度高后续筛选就可以在同等得分下优先推同类。我实现这个功能时只做了一个很轻量的行为权重表但效果比预期好很多。1.3 技术栈边界后端管什么小程序管什么后端Laravel负责所有业务逻辑微信登录凭证换openid、用户健康档案CRUD、食谱和食材库管理、推荐引擎计算、饮食记录与营养统计、管理员后台的数据维护。小程序端只做三件事收集表单数据、展示服务端返回的结果、维护本地登录态。所有热量计算和推荐排序必须放在服务端主要是两个原因——第一个原因是数据安全。前端做计算等于把算法规则全部暴露给用户脚本选手能直接篡改推荐参数。第二个原因是同步一致性。小程序端每次计算规则有差异或者后端推荐策略升级前端还是要发新版才能更新逻辑这在微信审核周期里非常被动。放在后端就灵活了接口返回结构不变推荐规则怎么调都是后端的事。还有一块容易忽略的是“管理端”。虽然主体是微信小程序但食材库和食谱库不能靠写SQL维护我直接用Laravel自带的Blade写了一个极简后台放在/admin路由下管理员登录后可以批量导入食材数据、给食谱打标签、动态调整禁忌标签配置。这个后台只用了两个晚上做的但整个系统的可维护性一下就上来了。2. 健康数据建模与推荐逻辑全项目的灵魂2.1 BMR和TDEE先把热量账算清楚推荐系统的地基是热量计算。市面上的健康App算法五花八门但医学营养学里最常用的基础代谢率公式是Mifflin-St Jeor公式相比传统的Harris-Benedict公式它针对现代人群的体脂率估算更准确世界卫生组织推荐的很多膳食指南也以它为基础。公式长这样// 男性 BMR 10 × 体重(kg) 6.25 × 身高(cm) - 5 × 年龄 5 // 女性 BMR 10 × 体重(kg) 6.25 × 身高(cm) - 5 × 年龄 - 161举个例子一个28岁、180cm、75kg的男性BMR 10×75 6.25×180 - 5×28 5 750 1125 - 140 5 1740。这个数值代表他躺在那里一天什么都不做身体维持基础运转要消耗的热量。但现实中没人整天躺着所以要再乘一个活动系数得到每日总热量消耗TDEE活动水平描述系数久坐办公室工作基本不运动1.2轻度活动每周运动1-3次1.375中度活动每周运动3-5次1.55高强度活动每周运动6-7次1.725同一个男生如果每周健身4次TDEE 1740 × 1.55 ≈ 2697千卡。接下来还要根据用户目标做热量修正减脂期摄入建议是TDEE的0.85倍左右增肌期是1.1倍左右保持期就按TDEE来。这套计算逻辑我直接写成PHP服务类方便推荐引擎复用而不是散落在接口里。2.2 推荐筛选规则一票否决与多维度打分有了热量目标推荐引擎还需要做两件事硬性排除和软性排序。硬性排除就是“一票否决制”——用户对花生过敏任何含花生的食谱直接不出现用户有糖尿病史高GI食材直接剔除用户有高血压钠含量超过阈值的一票否决。这个规则必须严格执行营养推荐业务的容错率为零推荐错了可能真出事。我设计了一个resolveExcludedTags方法专门把用户的过敏原和慢性病史转换成食材标签的黑名单private function resolveExcludedTags(array $chronicDiseases, array $allergies): array { $tags []; $ruleMap [ hypertension [high_sodium], diabetes [high_gi], gout [high_purine], hyperlipidemia [high_fat], ]; foreach ($chronicDiseases as $disease) { $tags array_merge($tags, $ruleMap[$disease] ?? []); } return array_merge($tags, $allergies); }硬性排除过完之后剩下的食谱按匹配度打分。打分的维度我用了三个热量匹配度占60%权重、蛋白质占比占25%、食材多样性占15%。热量匹配度就是食谱实际热量与目标热量的接近程度越接近得分越高。做法是把差值映射到0-100分——每偏离50千卡扣5分下限为0分。蛋白质占比则看食谱中蛋白质供能比例是否在用户目标范围内减脂用户我会把蛋白质供能比调高一些因为蛋白质饱腹感强而且不容易转化为脂肪。排序完成后返回得分Top 10的食谱如果筛选后不足10个我会额外提供一个“相近替换”逻辑在不触发禁忌标签的前提下允许某一类主食替换为热量相近的同类食材比如米饭换藜麦、猪肉换鸡胸肉。这个功能用户反馈特别好因为现实中很多人会缺食材。2.3 食材库与食谱库的数据结构设计数据表是整个推荐系统最基础的骨架。食材表我命名为foods食谱表recipes以及它们之间的多对多关联表recipe_food。食材表的核心字段不只是基础营养素还要包含标签字段用于推荐过滤Schema::create(foods, function (Blueprint $table) { $table-id(); $table-string(name)-comment(食材名称); $table-decimal(calories, 8, 2)-comment(热量 千卡/100g); $table-decimal(protein, 8, 2)-comment(蛋白质 g/100g); $table-decimal(fat, 8, 2)-comment(脂肪 g/100g); $table-decimal(carb, 8, 2)-comment(碳水化合物 g/100g); $table-decimal(sodium, 8, 2)-default(0)-comment(钠 mg/100g); $table-decimal(purine, 8, 2)-default(0)-comment(嘌呤 mg/100g); $table-unsignedInteger(gi_value)-default(0)-comment(升糖指数); $table-string(tags)-nullable()-comment(逗号分隔的标签high_sodium,high_gi,high_purine,gluten_free等); $table-string(category)-comment(分类主食/蔬菜/肉类/水果等); $table-timestamps(); });食谱表本身不直接存营养素汇总值而是通过关联食材实时累计。这样一个食谱的食材调整后营养数据不会出现不一致。查询时我用Eloquent的with(ingredients)加载食材再在内存里做累加。实测一个拥有800条食材记录、200条食谱记录的项目单次推荐接口的响应时间稳定在280ms左右完全没有性能压力。食谱表里还有一个image_url字段小程序推荐卡片直接展示菜品图片对用户体验提升非常明显。3. Laravel后端实操从登录到推荐接口的完整闭环3.1 微信小程序登录与登录态设计微信小程序登录和传统网页登录差异很大。小程序端通过wx.login()拿到一个临时code这个code只能用一次由后端拿去微信的code2Session接口换取openid和session_key。我这边直接按官方文档封装了一个服务类public function code2Session(string $code) { $appid config(wechat.mini_program_appid); $secret config(wechat.mini_program_secret); $url https://api.weixin.qq.com/sns/jscode2session? . appid{$appid}secret{$secret}js_code{$code}grant_typeauthorization_code; return Http::get($url)-json(); }换取到openid后先查用户表里有没有这条openid没有就创建新用户有就直接复用。这里千万不要用Laravel自带的Session机制因为小程序不是浏览器环境没有CookieSession那一套完全不适用。我采用的是后端生成一个自定义tokenStr::random(64)把user_id和expire_at存到数据库token原样返回给小程序端后续所有请求在Authorization: Bearer token头里带上。中间件里做校验public function handle($request, \Closure $next) { $token $request-bearerToken(); $accessToken UserToken::where(token, $token) -where(expire_at, , now()) -first(); if (!$accessToken) { return response()-json([code 401, message 登录态已过期], 401); } $request-setUserResolver(fn() $accessToken-user); return $next($request); }登录态我设置的是7天有效期每次请求成功就顺延一天过期时间这样用户只要每周至少打开一次小程序就不会被中途踢下线。中间件挂在/api路由组里但/api/auth/login这个接口要放在组外面。3.2 推荐接口的完整实现推荐接口是整个系统的核心输出我用一个RecommendService来处理避免Controller里堆业务代码。核心流程四条线串起来读用户档案、算热量目标、拿禁忌标签、过滤加打分。关键代码如下public function dailyRecommendation(User $user): array { $profile $user-profile; if (!$profile || !$profile-is_completed) { throw new RecommendException(健康档案未完善); } // 1. 热量目标 $bmr $this-calcBMR($profile); $tdee $bmr * $this-activityFactor($profile-activity_level); $targetCalories $tdee * $this-goalFactor($profile-goal); $range [$targetCalories * 0.9, $targetCalories * 1.1]; // 2. 禁忌集合 $excludedTags $this-resolveExcludedTags($profile-chronic_diseases ?? [], $profile-allergies ?? []); // 3. 过滤食谱 $recipes Recipe::with(ingredients) -whereDoesntHave(ingredients, function ($query) use ($excludedTags) { $query-whereIn(tag, $excludedTags); }) -get() -filter(function ($recipe) use ($range) { $cal $this-recipeCalories($recipe); return $cal $range[0] $cal $range[1]; }); // 4. 打分排序 return $recipes-map(function ($recipe) use ($targetCalories) { $cal $this-recipeCalories($recipe); $calScore max(0, 100 - abs($cal - $targetCalories) / 50 * 5); $proteinScore $this-proteinScore($recipe, $targetCalories); $diversityScore min(15, count($recipe-ingredients) * 3); $totalScore $calScore * 0.6 $proteinScore * 0.25 $diversityScore; return [recipe $recipe, score round($totalScore, 2)]; })-sortByDesc(score)-take(10)-values()-toArray(); }我给推荐接口加了一层cache——同一用户一天内的推荐结果如果健康档案没变就直接返回缓存。设置键为recommend:user:{id}:{date}有效期到当天24点。这样既保证用户反复进入推荐页不重复计算也不会影响第二天获取新推荐。小程序端要做的是下拉刷新时调用一个?refresh1参数强制绕过缓存。3.3 饮食记录与营养统计接口饮食记录这块我设计了两个核心接口POST /api/diet-records记录用户某餐吃了哪个食谱GET /api/nutrition/stats?date2025-01-01查询某天的营养汇总。记录表结构很简单user_id、recipe_id、meal_typebreakfast/lunch/dinner/snack、record_date。之所以单独存record_date而不是直接用created_at是因为用户可能凌晨补录前一天的记录按创建时间统计会出错。营养统计接口会把这天所有饮食记录关联的食谱拿出来逐一累加营养素。累计完成后再和用户的目标热量做对比生成一个简单的“今天超了多少/还能吃多少”的结论字段。这个字段在首页展示效果很好用户看进度条就有动力去记录下一餐。另外我建议在记录接口做重复校验同一用户在同一餐次不能重复记录同一食谱防止用户手滑连点两次。我一开始没加这个校验测试阶段自己点出过双倍热量后来加了唯一索引unique(user_id, recipe_id, meal_type, record_date)从数据库层面就杜绝了这个问题。4. 微信小程序端实现与适配细节4.1 请求封装与登录态拦截小程序端我没有用任何第三方请求库直接用官方wx.request做了一层Promise封装。封装的目的有三个统一接口地址前缀、自动携带token、集中处理错误码。代码不复杂但收益很高const BASE_URL https://your-domain.com/api; function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL path, method, data, header: { Authorization: Bearer (wx.getStorageSync(token) || ) }, success(res) { if (res.statusCode 401) { wx.removeStorageSync(token); handleSilentLogin().then(() resolve(retry)); return; } if (res.statusCode 200 res.statusCode 300) { resolve(res.data); } else { reject(res); } }, fail(err) { reject(err); } }); }); }401拦截这里大有文章。用户网络请求到了后端发现token过期了如果不做处理就把用户弹回登录页体验非常差。正确做法是“静默登录”先用wx.login()拿新code后端换新token然后重新发起刚才那个请求。我把这个逻辑写成了handleSilentLogin实际操作中用户几乎感知不到登录态过期这个过程这个细节我认为是全小程序端最重要的优化之一。4.2 健康档案与推荐页面的关键交互健康档案表单的交互设计直接影响用户完成率。我用了一个分步表单第一步采集身高体重年龄这类基础数据第二步是活动量和目标诉求第三步才是过敏原和慢病史。分步的原因很简单一次性塞十几项输入项会吓跑用户分步后每一步最多4-5个控件滑动输入框、picker选择器、switch开关混排视觉上清爽很多。这里有两个小坑提醒一下。第一个是picker的range-key属性如果直接用对象数组渲染不指定range-key的话value显示会变成[object Object]。第二个是体重输入建议用typedigit而不是typenumber前者会弹出带小数点的键盘因为很多人体重是有小数的比如62.5kg。我第一版用了number结果用户输入62.5的时候傻眼了。推荐页我用了列表卡片形式每张卡片显示菜品图片、名称、热量数、主要营养素比例条、匹配度得分和一个“加入今日记录”按钮。得分只取整数展示营养素比例条用CSS宽度百分比模拟进度条不用canvas性能好很多。卡片数据服务端已经算好放返回结构里了小程序端只做渲染逻辑非常薄。4.3 顶部导航栏高度与其他机型适配微信小程序的顶部导航适配是新手最容易翻车的点。iPhone 12和iPhone SE的刘海不一样高胶囊按钮位置也不一样。所以导航栏高度千万不能写死。官方提供的方法是wx.getMenuButtonBoundingClientRect()拿胶囊位置再结合wx.getSystemInfoSync()拿状态栏高度两者一算就能得到导航栏自定义内容的精确高度const menuButton wx.getMenuButtonBoundingClientRect(); const systemInfo wx.getSystemInfoSync(); const navBarHeight (menuButton.top - systemInfo.statusBarHeight) * 2 menuButton.height;这个navBarHeight就是页面自定义标题栏应该占用的高度。我用这段逻辑封装成工具函数所有二级页面的自定义导航都调用它。另外提一下热词里的“微信小程序设置缓存时间”wx.setStorageSync是不支持单独设置过期时间的需要自己在存的时候带上时间戳读取时判断是否超时。我封装了一个setStorageWithExpire(key, value, days)超过指定天数就返回null重新请求服务端。5. 实操中踩过的坑与排查实录5.1 框架安全补丁Laravel CVE-2024-29291处理记录这个项目上线前我做过一轮依赖安全检查发现用的Laravel版本存在反序列化相关的安全通告也就是常说的CVE-2024-29291。受影响的范围覆盖了多个历史分支官方给出的修复方案是升级到对应的安全版本。我处理的步骤很简单先看composer.json里的laravel/framework约束然后把版本号提到安全版之上执行composer update laravel/framework最后跑一遍完整接口回归。这类框架级漏洞只要依赖保持最新就不会有大问题但要注意不能只升级核心框架不升级相关扩展包laravel/passport这类和框架耦合深的包也要同步检查。我踩过一次坑只锁了laravel/framework版本扩展包还是旧的结果跑出老版本的反序列化链。后来学乖了每次部署前都跑composer audit比肉眼检查靠谱得多。5.2 域名、缓存与发布配置微信小程序正式版要求所有请求域名必须是HTTPS且在后台配置了合法域名。开发阶段可以在开发者工具里勾选“不校验合法域名”但真机预览和提审前一定要换成正式域名。这里有个细节是如果你买了云服务器但没备案域名解析到这台服务器后微信还是会校验失败必须走完备案流程才能上线这个周期通常要一两周务必提前规划。后端缓存这块我给热门食谱列表加了Redis缓存第一次从MySQL读取后序列化存入Redis设置24小时过期。实测压测时接口QPS从50翻到了300数据库压力小了很多。但有一句忠告缓存一定要有失效策略我最初设置了永久缓存管理员后台改完食谱数据前端一直不变排查了半小时才发现是缓存作祟。现在所有后台写操作都会主动清理对应缓存键。5.3 常见问题速查表我把开发过程中遇到的高频问题整理成了表格方便你快速定位问题现象根因解决办法接口请求失败开发者工具显示域名不在列表合法域名未配置正式版需在mp后台添加request合法域名开发环境可临时勾选不校验真机上登录失效刷新页面就退出token存储在内存变量token统一存wx.setStorageSync启动时先从本地读取iOS请求慢/失败率偏高未配置TLS 1.2或服务器证书链不完整同时排查Nginx SSL配置和Apple ATS要求推荐接口数据不变缓存未刷新后台修改数据后调用Cache::forget清理对应缓存键数字算出来一堆小数PHP浮点精度问题数据库存decimal接口返回前统一round($value, 1)用户体重带小数时键盘不正常input类型写成了number统一用typedigit图片加载很慢小程序包内图片过大图片上传到CDN或用外链图片包内只留icon最后一列总结一下这个项目完整做完之后我个人感触最深的不是算法多复杂、代码多优雅而是“推荐规则必须可配置、可解释”。后面版本里我加了一块后台日志记录每个用户被推荐了哪些食谱、为什么没推荐每次推荐都给出命中和排除的规则说明。用户点进食谱详情能看到“匹配你减脂目标”“已排除含花生”这类明确文案信任感一下子不一样了。如果你打算在这个系统上加新功能我建议优先做“推荐理由解释”比再做十个花哨页面都有价值。
