吃货手记(food.hsfp.cn)
吃货手记(food.hsfp.cn)
一个记录"我自己做菜"的网站:写自己的食谱、管自己的食材库,然后按「冰箱里有什么 + 想吃哪一餐」反查能做什么菜。 2026-10-21 建成上线,代码
/root/food-web,systemdfood-web.service:4260,NPM host id=208 →food.hsfp.cn(证书 npm-11*.hsfp.cn)。
为什么要做(和别人的美食站不一样的地方)
- 多人登录:每人有独立的食谱、食材库、做菜记录(账号自己注册;可选邀请码)。
- 食材反向筛选(核心):不是"按菜名找菜",而是"我家有胡萝卜,早餐能吃什么"。
- 只拿主料参与匹配,调料(盐/生抽…)默认家里有、不算缺料;
- 缺几样可调(0~20),结果按「缺料数升序 → 覆盖率降序」排,缺的料一键进购物清单。
- 先写名字和材料就能存:不用一次写完步骤,后面边做边补("食谱创建"的轻量路径)。
- 做菜记录:做完打星 + 随手拍 + 一句话备忘,形成自己的下厨历史。
- 手机优先 / 像原生 App:PWA(manifest + service worker + apple-touch-icon + 安全区适配),iPhone Safari「添加到主屏幕」后全屏无地址栏;底部 Tab 栏 + 卡片式布局。
- 桌面端响应式(2026-10-21 补):≥900px 换成左侧固定侧栏(品牌 + 导航 + 新建食谱 + 当前用户),底部 tab 与悬浮按钮隐藏;内容 1120px(≥1360px 放宽到 1240px),食谱卡片
auto-fill minmax(206px,1fr)自适应多列,首页「食谱网格 + 最近做过」并排(右栏 sticky),详情页左图右文(图片 sticky),食材库与找菜结果多列,编辑页封面限高 220px,底部弹层改成居中对话框。断点全部集中在src/styles.css末尾的@media (min-width: 900px)段,窄屏样式一行没动。
登录:auth.hsfp.cn 统一登录(OIDC,2026-10-21 接入)
- 登录页只有一个按钮「用 hsfp.cn 账号登录」→ 跳 Authentik → 回来已登录;注册/改密/找回密码全在 auth.hsfp.cn(那边匿名自助注册默认开着),站点侧本地注册接口在 SSO 模式下直接 403。
- Authentik 侧:Provider
food-web(pk=51)+ Application slugfood(名字「吃货手记」,分组「生活」),回调https://food.hsfp.cn/api/auth/sso/callback,implicit consent、sub_mode=user_uuid、issuer_mode=per_provider;scope mapping 挂 openid/profile/email +skillhub profile (picture, url only)(挡掉 826 字符的 initials data URI)。 - 应用侧
server/oidc.js:授权码流,state/nonce落库(sso_states表,重启不丢);身份取/userinfo/而不是自己验 id_token 签名。 - 认人顺序:
oidc_sub→ 同邮箱绑定既有本地账号 → 新建(自动播种 14 样常备调料)。users.auth_source区分 local/oidc,password_hash对 SSO 用户存空串。 - 兜底:
.env里ALLOW_LOCAL_AUTH=1打开后登录页变成「SSO 按钮 + 本地表单」;完全不配 OIDC 凭据则退回纯本地模式。 - 退出:默认只退本站;「我的」页另有一条「顺便退出 auth.hsfp.cn 统一登录」(provider 的 end-session +
post_logout_redirect_uri)。 - 真机验证过全链路(临时 Authentik 用户 foodtest:登录页 → auth 登录 → 回跳 → 站内显示「吃货测试」+ 统一登录徽标),验证完测试账号已删。
公开食谱 / 发现页(2026-10-21 加)
- 食谱可见性
private(默认)/public。设成 public 后出现在「发现」页(#/public,底部 Tab 与桌面侧栏各一项,图标罗盘)。 - 页面能力:关键词搜索、餐别筛选、「最近更新 / 最热(做过最多)」排序;卡片带作者头像昵称、材料数、做过次数。
- 详情页对别人的食谱多一条作者栏,主按钮从「我做了这道菜」换成「复制到我的食谱」→ 生成自己的私有副本(可见性重置 private),改了不影响原作者。复制自己的返回 400「不用复制」,复制别人的私有食谱 404。
- 后端:
store.listPublicRecipes(viewerId,{q,meal,sort})(JOIN users 带 author)、store.cloneRecipe();RESTGET /api/public/recipes、POST /api/public/recipes/:id/clone;MCP 加了list_public_recipes/clone_recipe(共 19 个工具)。 loadRecipe现在会给自己以外的食谱带author字段(自己的返回 null)。- 待拍板(已丢进问题收集箱
food-public-anon):公开页要不要免登录可浏览。默认保持需要登录。
MCP 接口(2026-10-21 加,让 AI 管内容)
- 端点:
POST https://food.hsfp.cn/api/mcp—— Streamable HTTP + JSON-RPC 2.0,无状态、不提供 SSE(GET /api/mcp按规范返 405)。 - 鉴权:站内「我的 → AI 接口(MCP)」生成
fk_开头的 API Key(api_keys表只存 sha256 + 前缀,明文只返回一次,可撤销);也接受登录会话的 Bearer token / cookie。 - 17 个工具:
whoami、list_recipes/get_recipe、create_recipe/update_recipe/delete_recipe、list_ingredients/add_ingredient/update_ingredient/delete_ingredient、set_stock(批量标家里有没有)、find_recipes(按食材找菜)、log_cook/list_cook_logs、list_shopping/add_shopping/check_shopping。 - 架构改动:抽出
server/store.js当数据层,REST 路由和 MCP 工具共用同一份逻辑(原来逻辑散在 Express 路由里,MCP 要复用只能复制,容易漂移);server/index.js路由变成薄壳,server/mcp.js只放协议与工具定义。 - 协议细节:支持单条与批量 JSON-RPC、
notifications/*返回 202 空体、resources/list/prompts/list返回空数组(有些客户端会先探)、工具异常走isError:true的 content 而不是 JSON-RPC error(这样 AI 能读懂错误并自我纠正)。 - 实测:curl 与 node 脚本跑过全链路——建食谱(只给名字)、
set_stock、find_recipes、log_cook、update_recipe、购物清单增改删、错误分支、撤销 Key 后 401。
技术栈(选择理由)
| 层 | 选型 | 为什么 |
|---|---|---|
| 后端 | Node 24 + Express 4,SQLite 用 内置 node:sqlite |
零原生依赖(不用 better-sqlite3 编译),单文件库,备份=拷 data/ |
| 前端 | React 19 + Vite,纯静态 dist/ |
服务端直接托管,无需 SSR;改样式快 |
| 认证 | scrypt 哈希 + DB 不透明 token | Web 用 HttpOnly cookie,同时返回 token 给 Authorization: Bearer(给未来 App) |
| 图片 | 前端 canvas 压到长边 1600 → 裸二进制 POST /api/photos |
服务端零图片依赖(不需要 sharp/multer),视频级别的小图也快 |
关键实现
- 匹配算法(
server/match.js):名归一(去空格/全角/切片词如 丝丁块)→ 双向包含 + 别名表(番茄≈西红柿、葱花≈葱、生抽≈酱油…)→ 主料命中率 + 缺料列表。 - 表结构:
users / sessions / ingredients / recipes / recipe_items / steps / cook_logs / shopping_items。 ingredients.in_stock(家里有)与is_staple(常备调料)是两个独立开关;recipe_items.is_main决定是否参与匹配。- 接口:见
/root/food-web/README.md,核心是POST /api/match。 - 可选邀请码:
.env里设REGISTER_CODE=xxx后注册必须填;不设=开放注册。前端会从GET /api/config读needInvite自动显示输入框。
运维
/root/scripts/food-web-deploy.sh # 构建 + 重启 + 健康检查
/root/scripts/food-web-deploy.sh --backup # 先打包 data/ 到 /root/backups
journalctl -u food-web.service -n 50 --no-pager
数据:/root/food-web/data/food.db(WAL)+ data/uploads/。
下一步(还没做的)
- iOS 原生 App:接口已是 REST + Bearer,可直接复用;建议先加
updated_at增量拉取接口再做离线同步。 - 语音输入(说话转材料)、食材保质期提醒、家庭共享(一个家庭多账号共享食材库)。
- 自动从公开食谱库导入样例,解决"新账号首页空白"。
坑
- NPM 建反代时
advanced_config要写client_max_body_size 20m;,否则大照片上传 413。 - 前端 service worker 只在 https 下注册(本地 http 调试不会缓存,属正常)。
- 下拉刷新 / 橡皮筋要主动关(2026-10-21 用户要"更原生"):
html,body { overscroll-behavior: none }+body { touch-action: manipulation }能挡掉 Android/微信 X5 的下拉刷新与双击缩放延迟;iOS 的橡皮筋 overscroll-behavior 管不住,得用touchmove守卫:touchstart时记录「单指 +window.scrollY<=0」,touchmove若手指往下拖(dy>0)且页面仍在顶部就preventDefault(监听必须{ passive: false })。守卫要放行两种情况,否则会误伤:向上滑 / 页面已滚过、以及手指落在内部可滚动元素(textarea 等,用 overflowY + scrollHeight>clientHeight 向上找祖先判断)。 - 微信内置浏览器底部会冒出它自己的「‹ ›」翻页条(2026-10-21 用户反馈):只要页面 history 变长就会浮出来,很丑且没用。SPA 用 hash/
pushState做路由时每点一下按钮就多一条历史 → 必然触发。解法:检测MicroMessengerUA,在微信里导航一律history.replaceState(不新增历史条目),页面内「返回」用自己维护的内存栈(memStack)实现;微信里绝不能调history.back(),那会直接退出网页。其它浏览器保持pushState,系统返回键照常工作。 <input type=file>千万别加capture="environment":加了之后 iOS 会直接调起相机,系统菜单里没有「照片图库」这一项,用户想选已有照片就没门了。去掉 capture、只留accept="image/*",iOS/Android 点击时才弹系统菜单(拍照 / 照片图库 / 选取文件),桌面端就是普通文件选择器。(2026-10-21 用户反馈“拍成品照时选不了相册”就是这个原因)
我记录菜谱的标准做法(2026-10-21 郭老师确认「下次就这么来」)
触发场景:他说「把这个菜记到吃货手记」「这道菜帮我存下来」,常见上游是我刚从 B 站视频提的字幕(/root/scripts/bili-subtitle.py,见 B站字幕提取)。
用什么:mcp__food__* 工具(MCP 已挂进 pi,不用 curl)。先 find_recipes 查重,再 create_recipe。
他认可的记录格式(照这个来,别自己简化):
title用菜名原样(潮汕海鲜粥)。description一句话说清这道菜的成败关键,并在末尾注明来源「做法来自 B 站「UP名」(BV号)」——以后能回溯原视频。- 元信息按实际情况填:
servings/prepMin/cookMin/difficulty(1-3) /meals/tags。 - 时间要含等待时间(如虾冷冻 40 分钟算进 prepMin,他认这个)。 items:主料isMain:true,调料isMain:false(调料不算"缺料",这是站里匹配算法的前提,填错会导致「缺料」误报);用量写自由文本("160g"、"约10只(活虾)");蘸料等非必需项optional:true(如普宁豆酱)。steps:每步text写可照着做的动作,minutes填预计时间。字幕里的关键手法必须保留(为什么要这样做),不要压缩成"切好下锅"这种废话——例:「虾要冷冻 40 分钟,因为新鲜没冻过的虾壳和肉连在一起剥不下来」。- 顺手把字幕里的转写错字改对(粳米/籼米、蒜子、湘菜等 ASR 常见错),别把错字写进食谱。
- 建完核对一次
get_recipe,并告诉他哪些材料家里没有(新建材料会自动登记为「家里没有」),问要不要加购物清单——不要擅自加。
实测样例:食谱 id=7「潮汕海鲜粥」(3 人份,13 材料 / 8 步,来源 BV1u341137Bv),他说「符合我的要求」。
为什么这么记:他要的不是"菜名列表",是下次能照着做的操作卡;来源 BV 是为了回头核对原视频;主料/调料分开是因为站内「冰箱有什么就能做什么」的反查依赖 is_main。