一、先交代背景:我们搭了什么
秦巴牧云(QinbaPastureCloud)是一套面向富硒畜牧养殖场的多租户 SaaS。一个服务商可以管多个农场,每个农场只看到自己的数据。整体技术栈:
- 后端:ASP.NET Core 8(MVC 风格 API)+ EF Core 8 + SQL Server / SQLite 双 Provider;
- Web 后台:Blazor(.NET 8),直接项目引用后端 DTO,强类型到底;
- 小程序:uni-app(Vue3),编译到微信小程序;
- AI 能力:微信小程序「开发模式」beta,把业务封装成一组 Skill(原子接口 + 原子组件)。
这套组合在产业互联网里很典型:重后台 + 轻前端 + 微信触达。但落到代码里,有几个坑是我们真正掉进去、又爬出来的。下面按"价值密度"排序分享。
二、坑 1:多租户隔离,别让客户端自选租户
多租户最危险的写法,是"前端传个租户 ID,后端就信"。我们早期也差点这么干。
正确做法是租户身份只从认证上下文取:
// TenantProvider:仅 Admin 才允许通过请求头切换租户
public string? ResolveTenantId(ClaimsPrincipal user, IHeaderDictionary headers)
{
var tid = user.FindFirst("tid")?.Value; // JWT 里的租户键,最高优先级
if (user.IsInRole("Admin") && headers.TryGetValue("X-Tenant-Id", out var h))
return h!; // 只有平台管理员能越租户查看
return tid; // 普通用户一律以 JWT 为准
}
配套两条铁律:
- 全局查询过滤器合并软删除与租户:
query = query.Where(e => e.TenantId == tid && !e.IsDeleted); - 任何"让客户端自选租户"的改动,都必须守住
X-Tenant-Id仅 Admin 可信这条线——否则任意客户端可越权读别人农场的牛。
角色层级也别含糊:Admin(0) 是平台级超级管理员,权限高于单租户的 Owner。授权统一写 User.IsInRole("Owner") || User.IsInRole("Admin"),前端对应 IsOwnerOrAdmin,避免散落的魔法字符串。
三、坑 2:前后端序列化契约,前端别用数字比对枚举
后端 .NET 8 统一用 CamelCase + JsonStringEnumConverter,意味着:
- 属性全 camelCase;
- 枚举一律以字符串输出(
OnFarm/Cattle/Male/Income/Red/Estrous…)。
我们最庆幸的一个决定:Web 端 ApiClient 直接项目引用后端的 DTOs 与 Domain.* 枚举。于是字段一致性由 C# 编译器保证,结构上不可能错位——这是 Blazor 相比 JS 前端的天然优势。
但小程序是 JS 宽松类型,必须人肉对齐。我们立了几条前端红线(写进 AGENTS.md 让 AI 也遵守):
- 禁止用数字/中文比对枚举,必须比
=== 'OnFarm'; - 禁止用旧字段名(
earTagId→earTagCode、breed→breedName、birthDate→birthDateUtc、weight→entryWeightKg); ApiResponse<T>结构是{code, message, data, timestamp},消费前先res.code === 200 && res.data;amount这类 decimal 在后端的字符串,前端要parseFloat。
教训:契约不是文档,是两人之间的暗号。一端改了枚举顺序或字段名,另一端不报错但悄悄出错,比编译错误更难查。
四、坑 3:record 位置参数的 DTO,序列化后字段名短得吓人
最阴间的一个 bug。健康看板的体温点 DTO 长这样:
public record TemperaturePointDto(DateTime T, double TempC);
camelCase 序列化后,字段变成了 t 和 tempC——不是 recordedAtUtc / temperatureC。前端照着实体属性名去读 t.temperatureC / t.recordedAtUtc,拿到 undefined,于是体温图高度算成 NaN%、日期标签 NaN/NaN。
修复很简单,但教训在流程:
前端取数必须严格对照 DTO 定义的参数名,切忌按实体属性名臆测。 尤其是"图/曲线单点"这类位置参数 record,字段名往往极短。
我们在 health.vue 里改成读 t.tempC / t.t,并加了 isFinite 防御 + 高度夹取(8%~100%)+ 柱上显示数值,杜绝 NaN 残留。
五、坑 4:开发改了字段,发布到线上"没了"
这是产业项目最高频、也最隐蔽的事故。根因在 Program.cs 按 Provider 分流:
if (dbOpts.IsSqlite)
db.Database.EnsureCreated(); // 开发:按当前模型自动建/改表
else
db.Database.Migrate(); // 生产:只认 EF 迁移
EnsureCreated() 的语义是:数据库文件不存在才建表,存在就什么都不做,绝不 alter 已有表。所以——
- 开发时你给
Tenant加了TenantKey/Version,本地库被重建过(或恰好新建),本地有这两列; - 线上的旧 SQLite 文件在模型变更前就存在 →
EnsureCreated看到文件在、跳过 → 缺列; - 表现就是"开发能看到、线上缺失",而且因为迁移异常被
try/catch吞掉只记日志,应用还不报错,只是字段缺。
修复(测试环境):删掉线上旧 SQLite 文件,重启让 EnsureCreated 用最新模型重建(代价是清空测试数据,但播种会重跑)。
根治建议:开发 / 测试 / 生产统一走 Migrate(),改实体必 Add-Migration + Update-Database,三套环境 schema 一致,不再漂移。代价是现有 EnsureCreated 库无 __EFMigrationsHistory,需先删库让 Migrate 从 Initial 全新建。
另外,把"迁移失败即 Fatal 退出"加上——沉默的失败比崩溃更贵。
六、坑 5:Razor 里的 @page 不是你想的那个 page
给预警列表加分页时,分页条页号变量叫 page,标记区原样写 @page:
@page / @(page) ← 编译报 RZ2005/RZ1016
Razor 把 @page 当成路由指令解析了。正确写法是用括号包起来:@(page)。一个括号,半下午。
类似的 Razor 泛型坑:@EnumHelper.GetDisplayName<AppRole>(x) 里 <AppRole> 会被当成 HTML 标签(RZ9980)。解法:把泛型调用移进 @code 方法,标记区只调无泛型参数的方法。
七、坑 6:微信 AI 开发模式——原子接口要返回"三层"
微信小程序 AI 开发模式(beta)要求我们把自己的业务封装成 Skill。一个 Skill = mcp.json(能力声明)+ index.js(注册)+ apis/(原子接口)+ components/(原子组件,页面接力渲染卡片)。
原子接口的返回我们沉淀成三层契约,框架才好消费:
return {
isError: false,
content: [{ type: 'text', text: '摘要文案(给对话流)' }],
structuredContent: { /* 卡片真正消费的强结构数据 */ items: [...] },
_meta: { /* 可选调试信息 */ }
};
卡片组件只读 structuredContent.items(或 detail.* / summary.*),绝不依赖 content 里的自然语言去解析数据——自然语言是给人看的,结构化数据才是给组件的。
关键工程纪律(这些写进 SKILL 的 description 高权重区,连 AI 都强制遵守):
- 业务 ID 必须来自后端真实返回,禁止推断或编造(尤其溯源码,必须用户明确提供);
- 写操作(饲喂录入、预警处置)先二次确认;
- 失败如实说,不假装成功。
我们用 uni-app 的 vite 插件桥接:编译 mp-weixin 时,仅在 NODE_ENV !== 'production' 注入 app.json 的 agent 配置并拷贝 skills/ 分包;生产审核版本自动跳过——beta 能力绝不进审核包,这是平台红线。
八、我们在工程上做对的三件事
踩坑之外,也有几个现在看很值的决定:
- Web 强类型引用后端 DTO:Blazor 端字段一致性由编译器兜底,24 个页面逐字段核对过零不一致。JS 前端则靠
AGENTS.md红线条例 + 静态核验兜底。 - DemoDataSeeder 回填基线:MVP 演示不用等 7 天硬件时序,直接回填
Baseline*C/Activity/ComputedAtUtc跳过等待期,一键POST /api/dev/seed-demo造出 10 头牲畜 + 历史体温 + 触发预警,联调和演示都稳。 - IoT 用 MockDeviceAdapter 解耦:硬件未到位时 mock 跑通全链路,适配器抽象让真实设备后续无痛接入。
九、给同样在做产业 SaaS 的你
如果只带走一句话:
产业系统的复杂度,八成不在功能,而在"数据可信"与"环境一致"。 租户别让客户端自选、枚举别让前端猜、字段别让开发库替生产库背锅、AI 别替用户编 ID。
秦巴牧云还在长。上面每一个坑,都是从一次真实的"线上缺字段 / 图表 NaN / 越权告警"里抠出来的。希望它们帮你省下几个下午。
—— 秦巴牧云后端团队