一、先交代背景:我们搭了什么

秦巴牧云(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 为准
}

配套两条铁律:

  1. 全局查询过滤器合并软删除与租户:query = query.Where(e => e.TenantId == tid && !e.IsDeleted)
  2. 任何"让客户端自选租户"的改动,都必须守住 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 直接项目引用后端的 DTOsDomain.* 枚举。于是字段一致性由 C# 编译器保证,结构上不可能错位——这是 Blazor 相比 JS 前端的天然优势。

但小程序是 JS 宽松类型,必须人肉对齐。我们立了几条前端红线(写进 AGENTS.md 让 AI 也遵守):

  • 禁止用数字/中文比对枚举,必须比 === 'OnFarm'
  • 禁止用旧字段名(earTagIdearTagCodebreedbreedNamebirthDatebirthDateUtcweightentryWeightKg);
  • 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 序列化后,字段变成了 ttempC——不是 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 都强制遵守):

  1. 业务 ID 必须来自后端真实返回,禁止推断或编造(尤其溯源码,必须用户明确提供);
  2. 写操作(饲喂录入、预警处置)先二次确认
  3. 失败如实说,不假装成功。

我们用 uni-app 的 vite 插件桥接:编译 mp-weixin 时,仅在 NODE_ENV !== 'production' 注入 app.jsonagent 配置并拷贝 skills/ 分包;生产审核版本自动跳过——beta 能力绝不进审核包,这是平台红线。

八、我们在工程上做对的三件事

踩坑之外,也有几个现在看很值的决定:

  1. Web 强类型引用后端 DTO:Blazor 端字段一致性由编译器兜底,24 个页面逐字段核对过零不一致。JS 前端则靠 AGENTS.md 红线条例 + 静态核验兜底。
  2. DemoDataSeeder 回填基线:MVP 演示不用等 7 天硬件时序,直接回填 Baseline*C / Activity / ComputedAtUtc 跳过等待期,一键 POST /api/dev/seed-demo 造出 10 头牲畜 + 历史体温 + 触发预警,联调和演示都稳。
  3. IoT 用 MockDeviceAdapter 解耦:硬件未到位时 mock 跑通全链路,适配器抽象让真实设备后续无痛接入。

九、给同样在做产业 SaaS 的你

如果只带走一句话:

产业系统的复杂度,八成不在功能,而在"数据可信"与"环境一致"。 租户别让客户端自选、枚举别让前端猜、字段别让开发库替生产库背锅、AI 别替用户编 ID。

秦巴牧云还在长。上面每一个坑,都是从一次真实的"线上缺字段 / 图表 NaN / 越权告警"里抠出来的。希望它们帮你省下几个下午。

—— 秦巴牧云后端团队