Skip to content
nnbw-liuPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

KidPlan · 小孩成长计划

给自家孩子用的成长记录 + 饮食安排工具。一个账号可以带多个孩子,每个孩子的数据完全独立。

技术栈:.NET 10 / ASP.NET Core Blazor(Interactive Server)/ MudBlazor / MySQL 8 + EF Core 9 / ASP.NET Core Identity。 模型接入:任意 OpenAI 兼容接口(含跑在自己电脑上的本地模型,用附带的 KidPlan.Relay 打通)。 部署方式:Docker Compose 跑「应用 + MySQL」两个容器,宝塔面板的 Nginx 做域名反代与 SSL 终结 → 详见 docs/DEPLOY.md。


一、功能

账号

  • 邮箱 + 密码注册、登录、退出、修改密码。
  • 账号之间完全隔离:所有业务查询都以当前登录用户的 ID 作为过滤条件,别人的孩子既看不到也改不了(有专门的测试覆盖这一点)。
  • 一个账号可以建多个孩子,界面上随时切换当前孩子。
  • 密码策略刻意宽松:最短 6 位,不要求大小写/数字/符号(自建给家里人用)。
  • 注册后要去邮箱点确认链接才能登录(RequireConfirmedAccount=true,见 第六节第 4 条)。
  • 忘记密码有自助入口:填邮箱 → 收到重置链接 → 设置新密码。链接 24 小时内有效,且只能用一次。
  • 不泄露账号是否存在:「忘记密码」和「重发确认邮件」无论该邮箱有没有注册过,都给出同样的提示。

小孩档案(首次登记时一次填完)

别名、姓名、出生年月、性别、爱好、喜欢的运动、血型、头像表情、备注。

出生年月会自动换算成「X 岁 Y 个月」,在首页和档案页都能看到。

过敏原管理(分等级)

过敏原单独一个页面(/children/{id}/allergens),按等级区别对待——因为「喝牛奶有点胀气」和「吃到花生会休克」显然不该是同一套反应。

等级 界面提示 安排含它的餐时
不耐受 小字提示 直接放行
轻微 小字提示 直接放行
中等 橙色警告 必须勾「我知道风险」才能保存
强过敏 红色警告 勾选 + 二次确认(客户端要把看到的等级回填回来)
过敏性休克 红色警告 勾选 + 二次确认;AI 推荐永远不会自动替你确认,含它的建议会被直接拦掉
  • 每条记录可填:名称、类别(食物 / 其他)、等级、备注、检测结果。
  • 列表按等级从重到轻分组,一组一张卡片、组内用细线分隔:一行里就能看到名字、等级、检测结果、备注和操作,等级下拉是就地改的、改完立刻存——不再是一条一张大卡片、一行占掉半屏。
  • 只有「食物」类会参与菜单比对。否则「花粉」会在「花卷」上天天误报。
  • 支持一次粘贴多个(牛奶、鸡蛋,花生 这种中英文标点混着写都能拆开)。
  • 等级可以随时改高改低;把等级调高之后,之前已经确认过风险的那一餐会重新变回「需要你看一眼」的状态(见「餐饮记录」的过敏回顾)。
  • 旧版本里那两栏自由文本(「过敏物」「过敏食物」)会在首次启动时自动搬迁成分级记录,默认等级取强过敏(宁可猜高,猜高了只是多确认一次,猜低了会漏掉真正的危险),并标成「待复核」提醒你逐个确认。原文保留在库里做存档。

成长与身体管理

「记数据」和「看曲线」是两个页面:身体管理(/children/{id}/body)负责记录、最新数值和历史列表;成长曲线(/children/{id}/growth)只负责画图。以前挤在一页里,想看曲线要先滚过半屏表单,想改一条记录又得滚回去。

  • 一个「记录」按钮,可以记录当前时刻,也可以补录过去的某个时间点或某个时间段。
  • 每条记录可填:身高、体重、体温、睡眠时长、运动时长、食欲、情绪、症状、备注(都不填也不行,至少要有一项测量值)。
  • 自动算 BMI;每条记录都入库,随时可编辑、可删除。
  • 身体管理页顶部是最新数值卡片(身高 / 体重 / 体温 / BMI,带「较上次 ↑↓」的对比)和这个孩子的过敏原一览(含「管理过敏原」入口)。
  • 成长曲线可以自己挑要画哪几个指标:
    • 身高、体重、BMI、睡眠时长、运动时长、食欲、精神状态,外加体温(默认不勾,因为多数日子没有体温数据,默认画出来只会多一张空图);
    • 两种画法:分图(一个指标一张图,默认,量纲差得远的指标不会被压成直线)和叠加(画在一张图上,可选是否归一化——归一化之后只比走势,不比绝对值);
    • 可以只看单次记录或只看时段记录,也可以按 3 / 6 / 12 个月 / 全部 / 自定义区间筛选。
    • 筛选完如果勾的指标一个数都没有(最典型的就是切到「只看时段记录」,而那条时段记录只记了睡眠 / 食欲 / 精神),页面会直接说清是哪一档、这一档里有数据的是哪几个指标,并给一个「改勾这几个指标」的按钮,一键把勾选换成有数据的那些——不会只剩下一片空白让人以为曲线坏了。
    • 一档里连一条记录都没有时,给的是「去身体管理记一条」的按钮,而不是一句干巴巴的「没有数据」。
    • 只有 1 个数据点时不画线,而是写成「目前只有 1 个数据点(2026-07-20),再记录一次就能画出趋势」——点数不足以成线这件事说清楚,比画一根看不见的线好。
    • 选择会记在本次浏览器会话里,从曲线页跳到饮食页再跳回来不用重新点一遍;也能通过 URL 分享同一组指标(?metrics=height,weight&view=overlay&kind=point)。

饮食安排

  • 按 固定三餐(早餐 / 午餐 / 晚餐)+ 不定时的点心、零食 安排。加餐一天可以记任意多顿。

  • 每条含计划时间、菜单、食物清单、预估热量、备注。

  • 三餐必须有饭点(没有饭点就没法算状态,属于填错了会明确拒绝);点心 / 零食的饭点可以留空——没定时间的加餐永远不会变成「已过饭点待确认」,它本来就不催。想定时间也可以(例如「下午三点吃酸奶」)。

  • 默认饭点跟着餐次走:切到早餐 / 午餐 / 晚餐分别是 07:30 / 12:00 / 18:00(如果这个孩子以前记过同一餐次,就用上次那个时间);切到点心 / 零食则留空,因为加餐本来就没有固定饭点,默认填「现在几点」反而是错的。

  • 「+ 记一顿点心 / 记一顿零食」这类快捷按钮和上方餐次下拉走的是同一套默认值,不会出现两条路给出的默认时间不一样。

  • 根据当前的系统时间自动判断这一餐处于什么状态(这是需求里明确要求的):

    状态 什么时候出现 界面文案
    未到饭点 距离计划时间还有 30 分钟以上 还有 1 小时 20 分钟 到饭点
    用餐中 计划时间前 30 分钟 ~ 计划时间后 60 分钟 已开饭 15 分钟 / 10 分钟后开饭
    已过点没确认 计划时间 60 分钟之后,还没确认 已过饭点 2 小时,还没确认
    不定时 点心 / 零食没有填饭点 不定时,随时都可以
    已吃完 手动确认过 已于 12:15 确认吃完
    已跳过 手动标记过 这一餐已跳过

    前面这些窗口宽度(前 30 分钟、后 60 分钟)在 appsettings.json 的 MealSchedule 节点里可以调。

  • 「+ 记一顿点心」「+ 记一顿零食」两个按钮一点就能记,不用先选餐次、不用填时间。

  • 点一下就能确认「吃完了」,顺便记下吃了什么、食欲如何;也能标记跳过、或撤销确认重新回到时间推算。

  • 安排含过敏原的餐会按等级拦截(见上面的过敏原管理);确认过一次且等级没变高的话,该餐不再重复报警。

  • 一天有汇总,固定三餐和加餐分开统计:计划热量、已吃热量、各状态计数。

  • 新增一餐时,固定三餐的计划时间默认给 07:30 / 12:00 / 18:00,如果这一餐以前记过就沿用上次用过的时间;加餐默认留空。

模型设置(接入 OpenAI 兼容接口)

「模型设置」页(/settings/ai)分两个页签,「全局配置」只有管理员看得到:

  • 全局配置改的是这个部署上所有人的默认值(含 API Key),所以它是一份 config/ai.json,只有 admin 角色能看能改。普通账号打开这一页只会看到「我的账号覆盖」和自己那条提示。
  • 谁是管理员由配置决定(Admin__Emails,逗号 / 分号 / 空格分隔;也可以直接往 AspNetUserRoles 里插一行)。角色写在登录 Cookie 里,改完要重新登录一次。
  • 接入方式就是标准的 OpenAI 兼容协议:POST {baseUrl}/chat/completions、GET {baseUrl}/models。DeepSeek、OpenAI、通义、Kimi、本地 LM Studio / Ollama / vLLM 都能用。
  • 每个端点可配:名称、BaseUrl、API Key、启停、接入方式、模型名列表、额外请求头。
  • 模型名可以手工输入,不要求能从 /models 拉到(自建网关、重命名过的模型都能填)。也可以点「获取模型列表」从接口拉。
  • 「测试连接」会真的发一次最小请求,把结果直接显示出来(HTTP 状态码 + 响应片段),不用猜。
  • 添加端点时表单里就有保存按钮(「保存这个端点」)——填完直接落盘,不用先记住「只放进列表、稍后再去页面上方点保存」。另外页签里只要有没保存的改动,顶部就会浮出一条「有未保存的改动」提示条,避免改了忘了保存。
  • 全局配置改完保存即生效,不用重启服务;每个账号可以在「我的账号覆盖」里单独覆盖(换默认模型、停用某个端点、加一个只有自己用的端点)。
  • 全局 API Key 在界面上只显示掩码(••••••••1234)——只改名字再保存不会把密钥写坏。

AI 饮食推荐(按天 / 按餐 / 按周)

在「饮食安排」页里直接生成,用上面选中的模型推理:

  • 范围:单天、单餐(可指定哪一餐)、单周(7 天,每天都有三餐)。
  • 前置周期:可以喂给模型多少天的历史(饮食 + 身体情况:体温、食欲、精神、睡眠、体重、症状)。滑块 + 输入框都能调,最大 30 天。
  • 过敏原一定会进提示词(让模型有机会避开),但最终以本地复核为准:模型给回来的每一条都会再拿数据库里的过敏原比对一次,含强过敏 / 休克级的直接拦掉并说明原因,中等的会标出来等你自己判断——AI 永远不会替你勾「我知道风险」。
  • 可以填一句额外要求(「这周想清淡一点」「不要辣」),也可以选择要不要安排点心零食。
  • 生成后能逐条看、逐条写入,或一键全部写入饮食安排;写入的条目会带一个 AI 标记,方便回头区分哪些是自己排的。
  • 还有一个「看看会发什么给模型」按钮,把完整的系统提示词 + 用户提示词原样显示出来——省得对着一个不理想的推荐结果猜它到底看到了什么。
  • 模型输出是 JSON,但没按格式回、用 ```json 包起来、前后加一段寒暄,都会被容错处理;实在解析不出来也会把原文留着让你换模型重试。
  • 推理模型(reasoning model)需要更大的输出额度:默认 maxOutputTokens 是 8192。把额度设小(比如 2500),推理模型会把额度全花在思考过程上,返回的正文是空的(finish_reason=length)、JSON 一个字都没有——这不是它「不会写」,而是「没轮到写」。出现这种情况时页面会说清是「额度全花在推理上了」、这次用掉多少 token,并给一个「去模型设置调大『最大输出 token』」的按钮。另外:本地小模型的上下文窗口往往只有 4096,接这类模型时可以把 maxOutputTokens 调回 2000 左右,否则接口可能直接返回 400。
  • 失败时页面上显示的是整理过的诊断(finish_reason、token 用量、正文或推理过程的开头),不是整包 HTTP 响应。原始报文会写进服务端日志——推理模型把额度用光时,原始响应里是一整包英文思考链,铺在页面上只会让人以为「输出内容不对」。

餐饮记录(按周 / 按月回顾)

「饮食安排」是往前看的(今天要吃什么、确认吃完),「餐饮记录」是往回看的:按周或月把这段时间吃过什么摊开。

  • 每天一个卡片,固定三餐和「点心 · 零食」分两段列出,逐条给出菜单、食材、饭点、状态、热量和吃完情况。
  • 区间汇总:记录了几天的餐、已吃完 / 跳过 / 待确认各多少餐、固定三餐与加餐各多少、热量(已吃 / 计划)、平均每天多少千卡。
  • 漏记提醒:只看固定三餐——「已经过去、却一顿固定三餐都没安排」的日子会列出来,连续的日子会合并成一段(例如「10-01 ~ 10-03」),点一下就能跳到那天的饮食安排去补录(今天和未来的日子不算漏记)。只记了一顿零食不算「这天记过了」。
  • 过敏回顾分三档,因为「命中过敏原」和「需要你现在去处理」是三件事:
    • 还没确认过风险的命中 → 红字警告。典型场景是先安排了那一餐、之后才把某种食物登记成过敏;
    • 安排时勾过「我知道风险」的 → 蓝色提示,只做记录、不重复报警(和饮食页一个口径,否则同一餐天天弹红字就变成狼来了);
    • 确认之后等级被调高了 → 单独列出来。这是最需要看的一类:「我当时按中等确认的,后来才知道是过敏性休克」——已经确认过的记录不会自己变红,所以必须专门找出来。

首页

每个孩子一张卡片:年龄、最新身高体重、BMI、今天的吃饭进度、快捷入口。

本机中继(让云端用上家里的模型)

KidPlan 部署在云端,而模型常常跑在家里的电脑上——服务器永远连不到你家的 127.0.0.1:1234,这是网络拓扑决定的,改配置解决不了。

所以把方向反过来:家里那台电脑上的 KidPlan.Relay 主动连出去挂一条 WebSocket 长连接,服务器把模型请求顺着这条连接发回来,由中继在本地发起真实请求。

手机 / 浏览器 ──①访问网页──> 云端服务器 ──②顺着长连接派发──> 家里的 KidPlan.Relay ──③本机请求──> 本地模型
  • 不需要公网 IP、不需要改路由器端口映射、不需要开任何入站端口(连接是出站的)。
  • 「本机中继」页(/settings/relay)里创建节点、拿一次性令牌、看在线状态;停用会立刻踢掉已建立的长连接。
  • 令牌在数据库里只存 SHA-256,明文只在创建时显示一次。
  • 于是「在外面用手机调家里的模型」也顺带成立了:手机访问云端页面,模型请求走「云端 → 家里电脑 → 本地模型」。
  • 完整说明(发布 exe、开机自启、安全边界、排错清单)见 src/KidPlan.Relay/README.md。

二、页面与路由

页面 路由 说明
首页 / 所有孩子的概览
小孩列表 /children 含每个孩子的记录数、最近一次测量,可归档/删除
新建 / 编辑档案 /children/new、/children/{id}/edit 登记与修改档案
过敏原管理 /children/{childId}/allergens 按等级管理过敏原、批量录入、待复核提醒
身体管理 /children/{childId}/body 记录 / 编辑 / 删除身体记录,最新数值卡片,历史列表,过敏原一览
成长曲线 /children/{childId}/growth 只画曲线:自选指标、分图 / 叠加、记录方式与时间范围(支持 ?metrics=…&view=overlay&kind=period)
饮食安排 /children/{childId}/meals 按天安排与确认 + AI 推荐(支持 ?date=yyyy-MM-dd 直接定位到某天)
餐饮记录 /children/{childId}/meal-history 按周 / 按月回顾吃过什么、汇总、漏记、过敏回顾
模型设置 /settings/ai 多个 OpenAI 兼容端点、全局配置与账号覆盖
本机中继 /settings/relay 中继节点与令牌、在线状态、部署说明
登录 / 注册 /Account/Login、/Account/Register
确认邮箱 /Account/ConfirmEmail 邮件里那条链接的落地页
重发确认邮件 /Account/ResendEmailConfirmation 也用于「登录时提示邮箱没确认」的分支
忘记密码 /Account/ForgotPassword、/Account/ForgotPasswordConfirmation
重置密码 /Account/ResetPassword、/Account/ResetPasswordConfirmation 链接里的 code 通过隐藏字段随表单回传
账号管理 /Account/Manage、/Account/Manage/ChangePassword

以上 Account 页面全部是静态 SSR(原因见 第六节第 1 条),所以它们不能用需要交互的 MudBlazor 表单组件,用的是原生表单 + auth.css。


三、快速开始

只想跑测试(不需要 MySQL)

dotnet test KidPlan.sln

测试用 SQLite 内存库跑真实的数据模型,不需要任何数据库服务。覆盖:按孩子隔离数据、过敏匹配与分级拦截、年龄换算、吃饭状态机(用注入的假时钟,不依赖你运行测试时的真实时间)、固定三餐与不定时加餐的拆分、成长曲线的指标取值与多线对齐(插值 / 归一化 / 端点截断)、AI 配置的热加载与账号覆盖、AI 推荐的提示词与本地过敏复核、中继的令牌与在线连接注册表、旧过敏文本的一次性搬迁、各服务的增删改查、账号注册/改密码规则,以及「账号页必须静态渲染」「侧边栏链接不能指向不存在的页面」这类 HTTP 回归测试。

在本机跑起来

前置:.NET 10 SDK、一个能连的 MySQL 8。

  1. 建库建账号(用 root 连上 MySQL 执行):

    CREATE DATABASE kidplan CHARACTER SET utf8mb4;
    CREATE USER 'kidplan'@'%' IDENTIFIED BY '换成一个你自己的密码';
    GRANT ALL PRIVILEGES ON kidplan.* TO 'kidplan'@'%';
    FLUSH PRIVILEGES;
  2. 改连接串。src/KidPlan.Web/appsettings.json 里的默认值是 Server=localhost;Database=kidplan;User=kidplan;Password=kidplan,改密码建议用 user secrets 或环境变量,别提交到仓库:

    cd src/KidPlan.Web
    dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=localhost;Port=3306;Database=kidplan;User=kidplan;Password=你的密码;CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=True;TreatTinyAsBoolean=true"
  3. 配置 SMTP。这一步不能跳过:邮箱确认是强制开启的,配不好邮件就没人能注册成功。

    cd src/KidPlan.Web
    dotnet user-secrets set "Smtp:UserName"    "你的邮箱@qq.com"
    dotnet user-secrets set "Smtp:Password"    "邮箱的 SMTP 授权码"   # 不是邮箱登录密码
    dotnet user-secrets set "Smtp:FromAddress" "你的邮箱@qq.com"

    appsettings.json 里已经填好 QQ 邮箱的 smtp.qq.com:465(SSL);换 163 邮箱就把 Smtp:Host 改成 smtp.163.com。 授权码只放在 user secrets(Windows 上是 %APPDATA%\Microsoft\UserSecrets\<项目 Id>\secrets.json),既不进仓库,也不进 Docker 镜像。

    只想本机试玩、不想配邮箱:把 appsettings.json 的 Identity:RequireConfirmedAccount 改成 false, 注册完就能直接登录。「忘记密码」仍然需要 SMTP。

  4. 运行:

    dotnet run --project src/KidPlan.Web

    首次启动会自动建表(Database:AutoMigrate 默认 true,带指数退避重试,所以 MySQL 还没就绪也不会立刻失败)。 打开后先注册 → 去邮箱点确认链接 → 登录 → 建第一个小孩档案。

  5. (可选)配置模型。到「模型设置」页里加一个 OpenAI 兼容端点即可,配置会写进 src/KidPlan.Web/config/ai.json;也可以直接改这个文件,保存即生效,不用重启。 带注释的模板见 config/ai.example.json。 想接家里电脑上的本地模型,见 src/KidPlan.Relay/README.md。

本机没有 MySQL 时,应用只能跑到「登录页能打开」这一步:任何需要读写数据库的页面都会失败,/healthz 会返回 503。这是预期行为,不是 bug。

Windows 上免管理员跑一个 MySQL(开发用)

winget install --id Oracle.MySQL 装的是 MySQL Installer 图形界面,不是数据库服务器——装完 C:\Program Files\MySQL 还是空的,也没有任何服务。想不装服务直接用,可以从 Installer 已经缓存好的 MSI 里把服务器文件解出来(msiexec /a 只是解包,不需要管理员权限):

$root = 'C:\Code\SDemo\_tools\mysql'   # 放哪儿都行

# 1. 解包(MSI 路径见 MySQL Installer 的 Product Cache 目录)
msiexec /a "C:\ProgramData\MySQL\MySQL Installer for Windows\Product Cache\mysql-8.0.36-winx64.msi" /qn TARGETDIR="$root"

# 2. 初始化数据目录(root 空密码,仅适合本机开发)
& "$root\server\bin\mysqld.exe" --no-defaults --initialize-insecure `
    --basedir="$root\server" --datadir="$root\data" --console

# 3. 启动(前台运行;关掉窗口或重启机器就停了)
& "$root\server\bin\mysqld.exe" --no-defaults `
    --basedir="$root\server" --datadir="$root\data" `
    --port=3306 --bind-address=127.0.0.1 `
    --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci --console

这样起的 mysqld 不是 Windows 服务,开机不会自启,每次都要手动执行第 3 步。解包出来的目录整个删掉就能清理干净。

健康检查

GET /healthz(匿名可访问)

  • 健康:200 {"status":"healthy"}
  • 数据库连不上:503 {"status":"unhealthy","reason":"database unreachable"}
  • 有没应用的迁移:503 {"status":"unhealthy","reason":"pending migrations","pending":[...]}

Docker 的 HEALTHCHECK 用的就是它。


四、部署到阿里云(宝塔面板)

服务器上装好宝塔面板和 Docker,然后按 docs/DEPLOY.md 走一遍即可,里面有 deploy/deploy.sh 一键脚本和 Nginx 反代配置。

架构:

浏览器 ──HTTPS──> 宝塔 Nginx(域名 + SSL + WebSocket 升级)
                     │  proxy_pass http://127.0.0.1:8080
                     ▼
             kidplan-app 容器(.NET 10,明文 HTTP 8080)
                     │  Server=mysql:3306            ▲
                     ▼                              │ 出站 WebSocket(家里的电脑主动连出去)
             kidplan-mysql 容器(MySQL 8,数据在具名卷里)

家里的电脑上线后,长这样:

家里的电脑(KidPlan.Relay)──本机 HTTP──> 本地模型(LM Studio / Ollama / vLLM…)
       │
       └──出站 wss://你的域名/api/relay/connect──> kidplan-app 容器

四个容易踩的点,先写在前面:

  1. 容器时区必须对。吃饭状态完全靠系统本地时间判断,TZ 设错会让「是否已经吃完了」整体偏掉。compose 里已经设成 TZ=Asia/Shanghai,别删。
  2. DataProtection 密钥卷别删。它负责加密认证 Cookie 和 Identity 令牌;删掉不丢业务数据,但所有人会被登出。compose 里已经挂了具名卷。
  3. Nginx 必须传 X-Forwarded-Proto,并且要支持 WebSocket(Blazor 的交互式渲染走 SignalR,本机中继也走 WebSocket)。deploy/nginx-kidplan.conf 里这些都配好了,照抄即可。
  4. ./config 目录的属主要是容器里的应用用户(uid 64198),否则「模型设置」页会显示「配置文件不可写」。deploy/deploy.sh 会自动处理;手工部署时执行一次 sudo chown -R 64198:64198 ./config。

五、数据模型

业务表六张,加 Identity 的一整套表(AspNetUsers 等)。所有子表对 Children / AspNetUsers 都是 ON DELETE CASCADE,删掉一个孩子会连同他的记录、过敏原和饮食安排一起删掉。

表 说明 关键字段
Children 小孩档案 OwnerId(账号)、别名、出生年月、性别、爱好、血型
ChildAllergens 分级过敏原 ChildId、名称、类别(食物/其他)、等级、备注、检测结果、待复核
GrowthRecords 身体记录 ChildId、记录时间、身高/体重/体温/睡眠/运动、食欲、情绪、症状
MealPlans 饮食安排 ChildId、日期、餐次、计划时间(可空)、菜单、食物清单、状态、确认时间、过敏确认等级、是否来自 AI 推荐
AiUserSettings 账号级模型覆盖 OwnerId(主键)、覆盖内容 JSON
RelayNodes 本机中继节点 OwnerId、名称、令牌哈希(SHA-256)、前缀、最近心跳、启停

几个约定:

  • 枚举一律以字符串入库(varchar(16)),直接读库也看得懂,不用查映射表。过敏等级、餐次、类别都是这样。
  • 字符集 utf8mb4,中文和 emoji 都能存。
  • date / time(6) 存日期和时间点(DateOnly / TimeOnly)。MealPlans.PlannedTime 可空——空值就是「不定时的加餐」。
  • 中继令牌只存哈希,明文不入库。
  • 主查询模式都建了索引:(ChildId, RecordedAt)、(ChildId, Date)、(OwnerId, IsArchived)、(ChildId, Kind)、(OwnerId, Enabled)。

六、关键设计决定与取舍

这一节把「为什么这么选、代价是什么」写清楚,方便以后改。

1. 账号页走静态 SSR,业务页走交互式渲染

Blazor Web App 是全局交互式的(App.razor 里 <Routes @rendermode="PageRenderMode" />),但注册/登录/改密码这些页面必须退回静态 SSR:它们要在请求-响应周期里由 SignInManager 读写认证 Cookie,而交互式渲染跑在 SignalR circuit 上,那里没有 HttpContext,写 Cookie 必然失败——用户会永远登录不上。

注意一个坑:光给账号页加 [ExcludeFromInteractiveRouting] 不够。那个特性只负责让「入站导航强制整页加载」,初始请求还得让 App.razor 里的 PageRenderMode 返回 null 才真正静态渲染。这个坑已被 RenderModeTests 用真实 HTTP 请求锁住(不需要数据库就能跑)。

2. 用 MudBlazor 的 MudChart,而不是最初选的 ApexCharts

这一条和最初的技术选型不一致,需要你确认。 实现过程中改成了 MudBlazor 自带的 MudChart,原因和事实如下:

  • 主要原因:MudBlazor 已经是 UI 依赖,MudChart 直接可用,不必再引入第二个前端图表库和它的一整套 JS 资源,自建服务器上的部署更简单。
  • 关于授权,网上容易传错,准确情况是:ApexCharts 于 2025-06-26 改了授权模型 —— 年营收/运营预算/融资达到 200 万美元的组织需要购买商业授权($199/开发者/年),低于这个门槛仍然按 Community 授权免费使用,核心库也依然开源。所以授权不是硬性阻碍,对个人项目来说本来也是免费的,它只是次要考虑。
  • 换回去的成本很低:图形渲染集中在 MetricChart.razor 一个文件里,它只接收 Title / Unit / Labels / Values(后两个是 List<string> 和 List<double>)。IGrowthService.BuildChartAsync 返回的是和图表库无关的 DTO,所以想换回 ApexCharts、或者换 ECharts,都只改这一个组件。

3. EF Core 固定在 9.0.x,并且没有启用 Passkey

.NET 是 10,但 EF Core / Identity 的包固定在 9.0.20,因为 Pomelo.EntityFrameworkCore.MySql 9.0.0 是目前对 MySQL 8 + Identity 最成熟的适配器,它的依赖范围是 [9.0.0, 9.0.999],挡住了 EF Core 10。EF Core 10 对应的 Pomelo 还没发布;而 Oracle 官方的 MySql.EntityFrameworkCore 10.x 不支持 EF 基元集合(string[]),会导致 Identity 的 Passkey 实体无法建模。

代价:没有 .NET 10 的 Passkey(v3)登录,也没有外部登录(微信/Google 等)。等 Pomelo 10 发布后,把 csproj 里那 5 个包一起升到 10.x 就能恢复。

4. 邮箱确认是强制的,忘记密码走邮件自助

注册后必须先点邮件里的确认链接才能登录(RequireConfirmedAccount=true);「忘记密码」也是发邮件重置。发信走 SMTP(用 MailKit,MIT 许可),配置在 Smtp 节。

几个刻意的选择:

  • 授权码只从 user secrets / 环境变量读,不写进 appsettings.json——那个文件会被打进 Docker 镜像。服务器上通过 .env 注入。
  • 发信失败不静默。注册时邮件发不出去,页面会明确告诉你「账号已经创建成功,但确认邮件没能发出去」,并给出「重新发送确认邮件」入口。否则用户会以为注册失败而反复注册,最后撞上「邮箱已存在」。
  • 不暴露账号是否存在。「忘记密码」和「重发确认邮件」无论邮箱有没有注册过,都跳到同样的提示页。
  • 启动时自检:开了邮箱确认却没配好 SMTP 时,启动日志直接打一条 Error,而不是让你上线一个谁都注册不了的系统。
  • 邮件里的链接域名由 Smtp:PublicBaseUrl 决定,留空则按当前请求推断。部署在宝塔 Nginx 后面时建议显式配置——Nginx 若没转发 X-Forwarded-Proto/Host,链接会变成容器内网地址,用户点不开。
  • 令牌的编码很关键。重置链接里的 token 含 + / =,必须按查询参数正确转义,手工拼字符串很容易拼坏(AccountLinkBuilder 专门负责这件事)。

仍然没有做:双因素认证、修改邮箱、外部登录。需求里没提,加上去会让账号流程复杂不少,需要的话可以后补。

5. 时间判断依赖容器时区

吃饭状态是拿「系统本地时间」和「计划日期 + 计划时间」直接比墙上时钟的。容器里必须设对 TZ(compose 已设 Asia/Shanghai),否则整套判断会整体偏移。详见 第四节 和 DEPLOY.md 的排错一节。

6. MySQL 的 datetime 不带时区,所以显式按「墙上时间」存取

MySQL 的 datetime 没有时区信息,Pomelo 写 DateTimeOffset 时其实只保留了墙上时间,偏移量在入库那一刻就丢了。与其依赖驱动的隐式行为,ApplicationDbContext 里用一个显式的值转换器把这件事写明:数据库里存的就是「当时的本地时间」,和界面、饭点判断的口径一致。列类型仍然是 datetime(6),生成的迁移和 DDL 一字未变。

顺带的好处:SQLite 不支持对 DateTimeOffset 做 ORDER BY,显式转换后单元测试能用 SQLite 跑真实模型,而不是另建一套测试专用模型。

7. 成长曲线只画孩子自己的数据,没有 WHO 参考曲线

现在每张图上只有一条线:你家孩子自己的测量值。所谓「标准曲线」指的是把同龄、同性别的参考百分位也叠上去做对照——比如 WHO 儿童生长标准按月龄给出的 P3 / P15 / P50 / P85 / P97 五条线。

有了它,「3 岁 4 个月、身高 92 cm」就不只是「比上次高了 2.5 cm」,而能看出「在同龄女孩里大约处于第 25 百分位」「最近 3 个月长得比参考曲线慢」。体重别身高(BMI 别年龄)尤其有用,因为它才是判断胖瘦的指标。

真要做的话需要引入外部数据源:WHO 的 LMS 参数表,或中国九市儿童体格发育调查的百分位表,按 性别 + 月龄 查表并插值算出 Z 分数。这属于需求里没提的附加值,而且是个外部数据源,所以我没有自己编造参考数据——要做的话你说一声。

8. 不引用任何外部 CDN

所有 JS/CSS 都由应用自己提供(MudBlazor 的资源随 NuGet 包走静态资源管道),没有 Google Fonts、没有 CDN。理由是部署在国内服务器,外部 CDN 会让首屏变慢甚至在断网时直接不可用。中文字体走系统字体栈。

9. 密码策略宽松

最短 6 位,不要求大小写、数字、符号。自建家用,复杂度规则只会让人把密码写在便签上。要收紧就改 appsettings.json 的 Identity:PasswordMinLength 和 Program.cs 里的密码选项。

10. AI 配置自己监听文件,不用 AddJsonFile(reloadOnChange: true)

需求是「要有不重启服务就改全局配置的办法」,最顺手的做法当然是配置系统的 reloadOnChange: true。不能用:配置系统在文件变更回调里解析失败会直接抛异常,那个异常跑在线程池线程上没人接得住——在配置文件里打错一个逗号就能把整个应用搞崩。

所以 AiSettingsStore 自己拿 FileSystemWatcher,400ms 去抖(编辑器保存一次常常触发好几个事件),并且:

  • 解析失败不改变当前生效的配置,只记下来给界面看(「模型设置」页顶部会显示配置文件路径、上次读取时间、上次的错误);
  • 写盘用「先写临时文件再 File.Move 覆盖」,避免监听线程读到半个 JSON;
  • 读文件带重试,因为宝塔文件管理器 / vim 保存的瞬间文件可能被独占。

代价是这套逻辑得自己写、自己测(AiSettingsStoreTests 里一半用例在验证「坏输入不会让配置丢失」,包括模拟编辑器「先清空再写入」造出的半截文件)。

11. 过敏原分等级,而不是「有 / 没有」

需求里给的等级建议是「轻微、不耐受、中等、强过敏、过敏性休克」。实现时把顺序理成 不耐受 < 轻微 < 中等 < 强过敏 < 过敏性休克——「不耐受」放在最低,因为它是机制不同的反应(乳糖不耐受这类),不是免疫反应,通常也更轻。

等级到行为的映射(EnumDisplay.ToRiskAction,唯一的事实来源):

等级 行为
不耐受 / 轻微 只提示,不拦截,不计入「有风险」统计
中等 必须勾选确认才能保存
强过敏 / 过敏性休克 勾选 + 二次确认(客户端要把看到的等级回填回来,否则服务端拒绝);AI 推荐直接拦掉,不会替你确认

两个刻意的设计:

  • 等级是唯一事实来源,界面上的颜色 / 图标 / 提示文案全部从它推出来(Ui.AllergyColor 等),所以以后加等级只需要动一处。
  • 优先级用 强过敏/休克 而不是「按数字最大」:业务上「休克」和「强过敏」在拦截力度上是同一档(都要二次确认),区别只在文案。代价值得——它避免了「家长把等级从强过敏改成休克之后,原本该拦的反而没拦」。

旧数据(自由文本那一栏)默认按强过敏迁移:猜高了只是多一次确认,猜低了会漏掉真正的危险。同时打上「待复核」,避免这批猜出来的等级被当成你自己确认过的。

12. 中继:云端与家里的模型之间那一步,方向是反的

「服务端连不上家里的模型」不是配置问题,是网络拓扑问题——家里的电脑在 NAT 后面、没有公网 IP,运营商还可能封了入站端口。所以中继让家里的电脑主动连出去挂一条 WebSocket,服务器顺着这条连接把 HTTP 请求发下来。

这个设计带来几个必须做的事:

  • 转发目标默认只放行本机和内网(127.0.0.1 / 10.x / 172.16–31.x / 192.168.x / 169.254.x / fe80:: / fc00:: / .local / .lan)。中继的本质是「服务器借你的电脑去访问网络」,门关小一点更安心;要连公网服务应该用「服务端直连」,那才是它该用的方式。只在明确加 --allow-public 时才放开。
  • 只认 http / https。file:// 之类放过去就变成「让服务器读我硬盘上的文件」了。
  • 令牌只存 SHA-256,明文只在创建时显示一次;停用 / 删除节点会立刻踢掉已建立的长连接(只改数据库字段的话,那条长连接不会自己断,「禁用」就是假的)。
  • BaseUrl 要写「本机视角」的地址(http://127.0.0.1:1234/v1),因为真实请求是在家里那台电脑上发的。这是最容易填错的一格,所以中继页和 README 里都反复写了。
  • 用 Windows 计划任务而不是 Windows 服务来自启:服务要管理员权限注册,而且在服务里跑的东西看不到界面、出问题不好查。
  • KidPlan.Relay 刻意不引用 KidPlan.Web(它是一个要拷到别人电脑上的独立小 exe,不想拖着一整套 ASP.NET Core)。代价是协议常量写了两份,所以有一个测试专门把两边的路径与版本号钉在一起。

13. 加餐没有饭点,所以状态机必须容得下 null

「固定三餐 + 不定时的点心零食」这个需求,最容易漏掉的是状态的推算方式:吃饭状态是拿「现在」和「计划时间」比出来的,而加餐可以没有计划时间。

如果给无饭点的加餐补一个默认时间(原本的做法是 15:30),它会立刻变成「已过饭点没确认」,家长会被一堆莫名其妙的红字淹没。所以:

  • MealPlans.PlannedTime 改成可空,null → 状态 Unscheduled(「不定时,随时都可以」),永远不会转成「待确认」;
  • 三餐没有饭点直接拒绝保存(没有饭点就没法算状态,属于填错了);
  • 加餐不提供「建议饭点」(GetSuggestedTimeAsync 对加餐返回 null)——预填一个时间反而会让人以为要按点喂;
  • 汇总里「还没确认」只统计有饭点的餐,漏记只看固定三餐(只记了一顿零食不算「这天记过了」)。

14. 曲线对齐是自己写的,因为测量日期几乎不会重合

身高每月量一次、体温可能每天都记,两条线的 X 轴基本不可能对齐。MudChart 只吃两个等长数组,所以对齐得自己做(ChartAlign):

  • 取所有序列标签的并集,按序数排序——日期用 yyyy-MM-dd 格式,所以字典序就是时间序,不必解析成日期再排;
  • 缺的点用线性插值补,范围外取端点值(外推会画出「没测过的地方也在涨」的假趋势);
  • 归一化时每条线各自按自己的最小/最大值拉到 0~100;全程不变的线给 50(分母为 0 会得到 NaN,画出来是整条线消失,比画在中间更让人困惑);
  • 标签和值的个数不一致就直接丢掉那条线——那是上游组装的 bug,宁可少画一条,也不能让图表拿到错位的数据。

这些规则全是纯函数,有 9 个用例盯着(ChartAlignTests)。


七、目录结构

KidPlan.sln
Dockerfile  docker-compose.yml  .env.example
config/ai.example.json      模型全局配置的带注释模板(真正的 ai.json 不进仓库)
deploy/                     一键部署脚本、Nginx 配置
docs/DEPLOY.md              部署详解
src/KidPlan.Web/
  Program.cs                DI 与 HTTP 管道
  Data/                     实体、DbContext、迁移
                   AllergenBackfill.cs  旧过敏文本 → 分级记录的一次性搬迁
  Models/                   业务模型与枚举
  Services/                 业务逻辑(全部可单测)
    MealStatusCalculator    吃饭状态机(纯函数 + 注入时钟)
    AllergyMatcher          过敏匹配(纯函数)
    AgeCalculator           年龄换算(纯函数)
    GrowthMetrics           可选的曲线指标(唯一对照表)
    ChartAlign              多条曲线的横轴对齐/插值/归一化(纯函数)
    Ai/                     模型接入:配置热加载、直连、中继、推荐
  Components/
    App.razor               根组件,决定每个请求的渲染模式
    Pages/                  首页、小孩、身体管理、成长曲线、过敏原、饮食、餐饮记录、设置
    Account/                账号相关(静态 SSR)
    Shared/                 图表、切换器、工具类
src/KidPlan.Relay/          本机中继(独立控制台程序,拷到家里电脑上跑)
tests/KidPlan.Tests/        xunit,299 个用例,不需要 MySQL
scripts/                    本机端到端验证(冒烟 + 无头浏览器),不参与构建
docs/verify/                上面那些验证跑出来的截图

八、排错

现象 原因与处理
/healthz 返回 503 database unreachable 应用连不上 MySQL。检查 ConnectionStrings__DefaultConnection 里的主机名(容器内是 mysql)、账号密码、以及 MySQL 容器是否健康。
/healthz 返回 503 pending migrations 有迁移没应用。看应用启动日志;Database:AutoMigrate 为 true 时会自动应用。
部署后一直跳登录页 正常,未登录访问受保护页面会被重定向到 /Account/Login。
登录成功后立刻又被登出 DataProtection 密钥丢了(容器重建且没挂持久化卷)。给 /home/<应用用户>/.aspnet 挂个具名卷,见 DEPLOY.md。
吃饭状态不对、整体偏几个小时 容器 TZ 不对。
排序/图表的日期和预期差一天 同上,时区问题。
AI 推荐提示「模型把输出额度全花在推理上了」 接的是推理模型,maxOutputTokens 太小(2500 这种会被思考过程吃光)。到「模型设置」把它调到 8000 以上再试;反过来,本地小模型上下文只有 4096 时把它调回 2000 左右,否则接口会直接 400。
AI 推荐提示「写到一半被截断」 也是额度不够,但这次正文已经开始输出了(finish_reason=length)。调大 maxOutputTokens,或者把范围从「单周」改成「单天」。
AI 推荐的失败提示里看不到完整报文 有意的:页面上给的是整理过的诊断(finish_reason / token 用量 / 正文或推理开头),原始响应在服务端日志里(grep 模型返回了空正文)。
按钮点了没反应 该页面没走交互式渲染。业务页应该由 App.razor 的 PageRenderMode 返回 InteractiveServer;账号页本来就是静态的,靠表单 POST。
右上角切换小孩的菜单点不开 MudBlazor 9.11.0 的 MudMenu 在自定义 ActivatorContent 时不会挂开关菜单的点击处理(aria-expanded 恒为 false)。ChildSwitcher.razor 里靠激活器自己的 OnClick="ToggleMenu" + @bind-Open 绕过了它,改的时候别把这一步删掉。
全页面所有按钮都失效 MudBlazor 的四个 Provider(MudThemeProvider / MudPopoverProvider / MudDialogProvider / MudSnackbarProvider)必须和页面处在同一个渲染作用域,所以它们放在 MainLayout.razor,不能放进 App.razor 的 <body>(那是静态 SSR 作用域,交互式组件看不到)。
「模型设置」页显示「配置文件不可写」 config/ 目录的属主不是容器里的应用用户(uid 64198)。执行 sudo chown -R 64198:64198 ./config。
改完 config/ai.json 没生效 页面上会显示「上次读取时间」和「上次的错误」。JSON 写坏了会明确报错并继续用上一份好配置,不会静默失败。也可以点「重新从文件加载」。
「模型设置」里测试连接失败 先确认 BaseUrl 写到版本号为止(…/v1,不要再带 /chat/completions);接了中继的话看 中继排错清单。
中继页显示在线、过一会儿又离线,反复重连 Nginx 的长连接超时太小。/api/relay/connect 需要 proxy_read_timeout 3600s,见 deploy/nginx-kidplan.conf。
中继在线但一测试就说「只转发到本机和内网地址」 BaseUrl 填成了公网域名。这种情况本来就该用「服务端直连」;确实要走中继就加 --allow-public。
AI 推荐把含过敏原的菜列出来了 正常,它会在写入前被本地复核拦掉并显示原因。模型不总是听话,所以以本地复核为准,不以模型的自我声明为准。
忘了密码 见 第六节第 4 条。

九、验证情况说明

诚实交代哪些验过、哪些没验过。

已验证(在真实 MySQL 8.0.36 上端到端跑过)

  • dotnet build 全解决方案 0 错误 0 警告;dotnet test 299 个用例全绿。
  • 迁移的模型与运行时模型一致(has-pending-model-changes 无输出,启动日志不再出现 pending model changes)。
  • 新迁移 GradedAllergensAiSettingsAndAdHocMeals 在保留既有数据的真实 MySQL 上应用成功:三张新表建出来,MealPlans.PlannedTime 改成可空,原有 3 条饮食安排、5 条成长记录一字未动。
  • 旧的自由文本过敏原搬迁在真实数据上跑通:糖糖的「牛奶、鸡蛋」+「花粉、青霉素」拆成 4 条(食物/其他分类正确),念念的「牛奶,鸡蛋,」拆成 2 条(尾随逗号没造成空条目),全部落成 Severe + 待复核;再启动一次不重复搬迁。
  • 注册 → 收到确认邮件 → 点链接确认 → 登录 → 访问受保护页面,全流程真实跑通(库里 EmailConfirmed 由 0 变 1,登录后拿到 KidPlan.Auth Cookie)。
  • 未确认邮箱时登录被拦住,并给出「重新发送确认邮件」入口。
  • 忘记密码 → 收到重置邮件 → 设置新密码 → 新密码可登录、旧密码失效;同一个重置令牌重复使用被拒绝。
  • 未注册的邮箱走「忘记密码」不发任何邮件,也不暴露账号是否存在。
  • 真实 QQ 邮箱(smtp.qq.com:465 SSL)发信成功;本地假 SMTP 另外验证了认证过程、收件人,以及邮件正文里的链接确实可用(提取出来直接访问即确认成功)。
  • 中文、emoji、DateOnly(date)、TimeOnly(time(6))、枚举(varchar(16))、DateTimeOffset(datetime(6))、小数精度都能正确写入并读回。
  • 饭点判断在真实系统时间下的结果:早餐 06:00 未确认 → 已过饭点 16 小时 19 分钟,还没确认;午餐已确认 → 已于 22:19 确认吃完;晚餐 23:30 → 还有 1 小时 11 分钟 到饭点;当日汇总 1 / 3 餐。
  • /healthz 返回 200 {"status":"healthy"};未登录访问受保护页面 302 到登录页并带 ReturnUrl;账号页静态 SSR、业务页交互式渲染(有 HTTP 回归测试锁住这一行为)。
  • 侧边栏每一个链接都能匹配到一条真实注册的路由(NavigationTests 把菜单里的 URL 和 @page 模板对起来,改坏会立刻变红)。
  • 每个功能页都在真实浏览器里打开、并真的点了按钮(用无头 Edge + CDP 驱动,脚本就在 scripts/,见下):
    • 「餐饮记录 → 全部」点了之后内容正常切换、页面上只有 11 个 chip(这条正是崩溃的回归);
    • 「餐饮记录 → 按月」出现了「漏记」提示,chip 数 12;
    • 成长曲线勾上「体温」后图从 3 张变 4 张,切「叠加」后 4 个指标合成 1 张图,归一化开关出现且给出 0~100 的说明;
    • 身体管理和成长曲线是两个页面:身体管理页上「记录身体情况」真的能展开(10 个字段 + 保存按钮都在,收起按钮也能收回去),记录列表和最新数值卡片都在这一页;曲线页只有指标 / 分图叠加 / 记录方式 / 时间范围四组控件,没有表单和表格,并给了「去记录身体情况」的回跳入口(smoke.ps1 还会断言两页互不串味);
    • 「饮食安排」昨天那一屏读得到备注与 AI 推荐标记;点「+ 记一顿点心」展开内联表单,餐次已预置为点心、并给出「清空饭点(不定时)」。
    • 换餐次时默认饭点真的跟着换(断言的是 DOM 里输入框的值,不是内部状态):切到午餐 12:00、晚餐 18:00、早餐 07:30、点心留空;
    • 「只看时段记录」但没有可画的数据时给得出提示(指出这一档里有数据的是体温 / 睡眠 / 运动 / 食欲 / 精神),点「改勾这几个指标」之后勾选真的换掉、身高体重变成「(无数据)」、睡眠与精神的图出现;
    • 模型设置里加端点时表单内就有保存按钮,点了之后 config/ai.json 里真的多出那个端点(脚本跑完会把文件还原);
    • 过敏原列表每一行实测 45px(改动前是 95px 上下),脚本直接量像素高度,间距再变大就会失败。
    • 截图存在 docs/verify/。
  • 管理员才看得到全局配置:非管理员打开 /settings/ai 看不到「保存全局配置」「重新从文件加载」,也拿不到服务器上的配置文件路径;把这个账号加进 admin 角色并重新登录(角色写在登录 Cookie 里)之后,全局配置页签和保存按钮都出来了。这一整条(含直接写 AspNetUserRoles 的路径)在 scripts/smoke.ps1 里是自动化断言的。
  • 点心 / 零食的默认饭点是真的空,不是只在界面上看着空:从界面点「+ 记一顿零食」、填个菜单直接保存,查库那一条 PlannedTime 是 NULL。
  • 本机中继这条链路是真跑通的(不是只跑了单元测试):把服务端、中继进程、假模型服务三个真实进程都拉起来,走真实的 WebSocket 与真实 HTTP。验证顺序刻意做成「先反证、再正向」:
    1. 中继没启动时,模型设置里的「测试连接」必须失败,且失败原因必须是「还没有任何中继在线」——注意 127.0.0.1:1234 在这台机器上直连也是通的,如果这一步反而成功,就说明请求根本没走中继、整套验证都是假的;
    2. 启动 KidPlan.Relay,节点上线(机器名、中继版本、心跳都落了库),中继日志出现「已连接」和「服务器已确认节点」;
    3. 再点「测试连接」→ 拉到模型列表、试调用拿到假模型的回复「可以」;假模型服务的日志里出现了 GET /v1/models 与 POST /v1/chat/completions;
    4. 中继自己的日志里能看到它去请求 http://127.0.0.1:1234/...——请求确实是在「家里」那一端发出去的;
    5. 在「本机中继」页面上把节点停用 → 已经挂着的长连接真的被断开(中继侧能观察到),而不只是数据库里改了个字段。
  • AI 饮食推荐这条链路也真跑通了(用一个假的 OpenAI 兼容服务顶替真模型,scripts/fake-openai.ps1):
    1. 推荐面板里的前置周期默认值来自配置文件(7 天),上限 30 天,滑块与数字输入框都在;
    2. 点「生成推荐」→ 真的发了一次 /v1/chat/completions,拿回一份 4 道菜的菜单;
    3. 其中含腰果的那道(冒烟数据里腰果是「过敏性休克」级)在界面上被标成「不能写入」——模型说了不算,本地过敏复核说了算;
    4. 点「全部写入饮食安排」→ 数据库里正好多了 3 条(4 条减掉被拦的那条),且 Menu LIKE '%腰果%' 查出来是 0 条;
    5. 刷新页面,刚写进去的早餐就在饮食安排里。
    6. 推理模型把额度花光的场景也复现并验证过:假模型在收到 max_tokens ≤ 2500 的推荐请求时,会照着用户那次真实抓包的样子回一份「content 为空 + 2500 token 的 reasoning_content + finish_reason=length」;脚本把账号覆盖里的「最大输出 token」改成 2500 再点生成,界面上出现的是「模型把输出额度全花在推理上了…」+ finish_reason=length + 「全是推理过程,没有正文」+ 一个「去模型设置调大『最大输出 token』」的按钮,而整包 HTTP 响应(service_tier、completion_tokens_details)不再出现在页面上。默认值本身也有单元测试钉住(AiCallOptions.Default.MaxOutputTokens >= 8000)。

怎么自己再跑一遍

端到端验证的脚本都在 scripts/,都是本机开发用的,不参与产品构建:

文件 作用
scripts/smoke.ps1 注册一个临时账号 → 造测试数据 → 用真实 HTTP 把每个功能页拉一遍并断言关键内容。可重复跑(会自己清掉上次的账号)。
scripts/smoke-data.sql 冒烟用的数据:五档过敏原各一条、五条身体记录、两天固定三餐 + 不定时加餐。日期全部相对 CURDATE() 写,任何一天重跑都成立。
scripts/smoke-cleanup.sql 删掉临时账号(子表级联),把库恢复原样。
scripts/cdp-lib.ps1 CDP 公共管道(起无头 Edge、发命令、跑 JS、截图、收尾)。下面几个脚本共用。
scripts/cdp.ps1 无头 Edge + CDP,真的点「全部 / 按月 / 叠加 / 记一顿点心 / 换餐次 / 加端点并保存 / 改勾指标 / 展开身体记录表单」,并量像素高度、截图。
scripts/fake-openai.ps1 假的 OpenAI 兼容服务:请求里带 meals 就回一份菜单,否则回一句话;max_tokens ≤ 2500 的推荐请求会照真实抓包的样子只回推理、不给正文(用来验证额度耗尽的诊断)。每个请求都写日志。
scripts/relay-e2e.ps1 本机中继端到端:反证 → 连上 → 经中继调模型 → 停用节点踢连接。-Prepare 造账号与令牌,-Cleanup 清掉。
scripts/ai-e2e.ps1 AI 饮食推荐端到端:生成 → 本地过敏复核拦下危险菜 → 一键写入 → 查库核对;再把额度调回 2500 复现推理耗尽,核对诊断文案与「调大额度」入口。-PrepareConfig 写配置文件。
# 1) 启动应用(这些环境变量只为本机验证服务)
$env:ASPNETCORE_ENVIRONMENT='Development'
$env:ASPNETCORE_URLS='http://localhost:5195'
$env:Identity__RequireConfirmedAccount='false'   # 否则新账号登不进去,HTTP 拿不到登录态
$env:Admin__Emails='你想当管理员的邮箱'            # 不设就没人能看到「全局配置」,启动日志会警告
dotnet run --project src\KidPlan.Web

# 2) 另开一个窗口:页面内容 + 交互行为
pwsh -File scripts\smoke.ps1      # HTTP(会顺便验证「非管理员看不到全局配置 → 提权后看得到」)
pwsh -File scripts\cdp.ps1        # 浏览器 + 截图(要管理员那一份 Cookie,所以放在 smoke 之后)

# 3) 可选:中继端到端 + AI 推荐端到端(都要先起假模型服务)
pwsh -File scripts\fake-openai.ps1 -Port 1234     # 再开一个窗口常驻
pwsh -File scripts\relay-e2e.ps1 -Prepare         # 会写出 %TEMP%\kidplan-e2e\ai-relay.json
#   然后按它的提示用 $env:Ai__ConfigFile 指向那个文件,重启应用,再跑:
pwsh -File scripts\relay-e2e.ps1
pwsh -File scripts\relay-e2e.ps1 -Cleanup

pwsh -File scripts\ai-e2e.ps1 -PrepareConfig      # 写配置文件
#   然后按它的提示用 $env:Ai__ConfigFile 指向那个文件,重启应用,再跑:
pwsh -File scripts\ai-e2e.ps1

# 4) 收工(回到默认的 config/ai.json 再启动一次)
& mysql -h 127.0.0.1 -u root kidplan -e "source scripts/smoke-cleanup.sql"

几个脚本里踩过的坑写在注释里,改的时候别删:curl 的 cookie jar 里 KidPlan.Auth 是 HttpOnly 的(行首有 #HttpOnly_,按 # 跳过就找不到了); Blazor 静态 SSR 的表单必须带 _handler;动态渲染的中文在 HTML 里是 &#x...; 实体, 必须先 HtmlDecode 再断言,否则会出现「一半能搜到一半搜不到」的假象; 「等页面出现某段文字」可能在预渲染阶段就为真(那时交互还没接上), 所以「点一下看效果」的地方必须重试,不能只点一次就等; System.Text.Json 默认把提示词里的引号写成 \u0022,所以假模型判断 「这是不是推荐请求」时不能去匹配 "meals"。

跑起来才暴露、已经修掉的坑

  1. 设计时模型 ≠ 运行时模型(影响最大)。dotnet ef 用的设计时工厂只 new 了一个 DbContext、没有注册 Identity;而 Identity 一旦注册进 DI,就会把 IdentityOptions 的键长限制(128)等套用到 EF 模型上。后果是每次启动都报 pending model changes,而且工具生成的迁移与实际运行的模型是两份(PhoneNumber longtext vs varchar(256)、LoginProvider varchar(255) vs varchar(128))。现在数据库与账号体系的注册只写一份(KidPlanServiceCollectionExtensions),运行时和设计时都调它,从根上消除漂移。
  2. winget install --id Oracle.MySQL 装的是 MySQL Installer 图形界面,不是数据库服务器。装完没有 mysqld、也没有服务,这是「安装成功了但跑不起来」的直接原因。
  3. 本地假 SMTP 测试工具(_tools/fakesmtp.ps1)一开始用 StreamWriter + Encoding.UTF8,会写出 BOM,MailKit 直接报 Unable to parse status code returned by the server。这是测试工具的问题,不是应用的。
  4. 「全部」区间把浏览器标签页撑爆了(这一条就是你反馈的「进入餐饮记录后卡死闪退」)。「餐饮记录」的「全部」模式原本传 1900-01-01 进去,于是服务端会 materialize 四万多天的对象,界面再为每个空白天渲染一个 MudChip。事件日志里没有 .NET Runtime 崩溃记录,是浏览器标签页先被撑死的。现在区间有硬上限 MealService.MaxRangeDays = 400,并且「漏记」提示改成把连续的日子合并成一段(最多显示 24 个)而不是一天一个 chip。有一条专门的回归测试盯着这个上限。
  5. 「切换小孩」点不开(前述)。
  6. 等级调高之后,已经确认过的那一餐不会重新报警。因为「已确认」是一个保存在 MealPlans 上的标记,跟它在确认当时看到的等级无关。修法:确认时把当时看到的最高等级一起写进 AllergyAcknowledgedMaxSeverity,回顾时拿它和当前等级比,变高了就单独列出来(IsAllergenEscalated)。这个字段还兼作「强过敏二次确认」的凭据,所以服务端在二次确认通过后会强制把它规范化成实际等级——否则客户端不传这个值时,等级提升永远检测不出来。
  7. 「停用中继」只改了一个字段,长连接其实没断。RelayConnectionRegistry.Kick() 原本只做了「把它从注册表里移除 + 把还挂着的请求放掉」,没有碰那条 WebSocket。于是服务端的接收循环还在原来的 socket 上等消息,你家里的中继也一直以为自己连着——界面上提示「已禁用,正在挂着的连接已经断开」,实际上连接好好的。单元测试当时只断言了注册表状态和挂起请求,所以是绿的。端到端验证(把真进程拉起来)才暴露出来。修法是 RelayConnection.Dispose() 里真正 Abort() 掉 socket:接收循环立刻抛异常走 finally 正常注销,中继那边也会看到断开;顺手把这个 Dispose 做成幂等的(正常收尾时它会被调用两次),并且不再 Dispose() 那个信号量(发送循环可能正握着它)。新增了 3 条测试盯着「socket 真的被断了」这件事。

没验证(本机确实没有对应环境,请在服务器上按 DEPLOY.md 重点确认)

  • 没有构建过 Docker 镜像,也没跑过 docker compose config(本机没有 Docker)。
  • 没有跑过 nginx -t,没在宝塔面板上实际反代过(本次新增的 /api/relay/connect 配置块同理)。
  • deploy/deploy.sh 本次新增的 prepare_config_dir(建 config/、按模板生成 ai.json、改属主)只做了人工复核 + bash -n 语法检查,没有在真实 Linux 上执行过。
  • 没有用真正的模型跑过端到端推荐。整条链路(提示词内容、JSON 容错解析、本地过敏复核、写入落库)已经用一个假的 OpenAI 兼容服务在浏览器里真的点过一遍(见上),但「某个真实模型返回的菜谱合不合口味、格式稳不稳定」只有你实际用起来才知道。
  • 中继这条链路在本机(ws://localhost + 同一个进程边界内的假模型)是真跑通的,但没有跨过公网:wss:// 经 Nginx 反代、证书、以及手机在外面用 4G 访问,都没有真实环境验证过。中继的地址解析、内网白名单、令牌哈希、在线注册表、断线重连另有单元测试覆盖。
  • 成长曲线的视觉效果没有逐像素复核(配色、留白、手机窄屏下的图高)。功能行为(勾指标、分图/叠加、归一化的说明文案)都在浏览器里点过,截图在 docs/verify/。刻度文案(「1 很差 · 2 一般 …」)是画在图下方的说明行,因为 MudBlazor 9 的 MudChart 去掉了 XAxisLabels,没法把刻度文字直接写在坐标轴上。

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages