吃货手记(food.hsfp.cn)

吃货手记(food.hsfp.cn)

一个记录"我自己做菜"的网站:写自己的食谱、管自己的食材库,然后按「冰箱里有什么 + 想吃哪一餐」反查能做什么菜。 2026-10-21 建成上线,代码 /root/food-web,systemd food-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 slug food(名字「吃货手记」,分组「生活」),回调 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();REST GET /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 做路由时每点一下按钮就多一条历史 → 必然触发。解法:检测 MicroMessenger UA,在微信里导航一律 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。

他认可的记录格式(照这个来,别自己简化):

  1. title 用菜名原样(潮汕海鲜粥)。
  2. description 一句话说清这道菜的成败关键,并在末尾注明来源「做法来自 B 站「UP名」(BV号)」——以后能回溯原视频。
  3. 元信息按实际情况填:servings / prepMin / cookMin / difficulty(1-3) / meals / tags。 - 时间要含等待时间(如虾冷冻 40 分钟算进 prepMin,他认这个)。
  4. items:主料 isMain:true,调料 isMain:false(调料不算"缺料",这是站里匹配算法的前提,填错会导致「缺料」误报);用量写自由文本("160g"、"约10只(活虾)");蘸料等非必需项 optional:true(如普宁豆酱)。
  5. steps:每步 text 写可照着做的动作,minutes 填预计时间。字幕里的关键手法必须保留(为什么要这样做),不要压缩成"切好下锅"这种废话——例:「虾要冷冻 40 分钟,因为新鲜没冻过的虾壳和肉连在一起剥不下来」。
  6. 顺手把字幕里的转写错字改对(粳米/籼米、蒜子、湘菜等 ASR 常见错),别把错字写进食谱。
  7. 建完核对一次 get_recipe,并告诉他哪些材料家里没有(新建材料会自动登记为「家里没有」),问要不要加购物清单——不要擅自加。

实测样例:食谱 id=7「潮汕海鲜粥」(3 人份,13 材料 / 8 步,来源 BV1u341137Bv),他说「符合我的要求」。

为什么这么记:他要的不是"菜名列表",是下次能照着做的操作卡;来源 BV 是为了回头核对原视频;主料/调料分开是因为站内「冰箱有什么就能做什么」的反查依赖 is_main。