部署:先选路线,再过安全基线
你已经用 dotnet new smart-app 在本地跑通了,npm run dev 一切正常,其实靠 Vite dev server(配置在 web/vite.config.ts)把 /api 反代到了后端,这层代理只在开发期存在。上线后只剩 web/dist 一堆静态文件:谁托管它、它怎么找到后端,这两个问题决定了部署方式。
选一条托管路线
四条去处的区别只在两点:前端产物谁托管、前后端是不是同源。
| 路线 | 谁托管前端 | 同源 | 什么时候选它 |
|---|---|---|---|
| 路线 A:单体部署 | 后端进程自己(UseStaticFiles) | 是 | 一个进程一个端口,内部系统最省心 |
| 路线 B:反向代理 | nginx / Caddy | 是 | 已有网关,或想让 Caddy 自动签发 TLS 证书 |
| 路线 C:真跨源(CDN) | CDN / 独立域名 | 否 | 前端上 CDN,唯一要配 CORS 的路线 |
| 容器化与多副本 | 容器里的 Caddy | 是 | 上 Docker / K8s,或要横向扩容 |
同源省事,路线 A、B 都算。web/dist 默认就按同源请求后端,不用配 CORS。背后是 main.ts 传给 createSmartAdmin 的 apiBase 取自 VITE_API_BASE,没设就是空串,路径本身已经含 /api/v1。只有路线 C 前后端不同源,才要两端都配 CORS。
四条路线的第一步都一样,先把前端构建出来:
cd web
npm ci
npm run build # 产物在 web/dist/上线前必过的安全基线
无论选哪条路线,下面这些在生产都躲不过。有几条内核认死理,不满足就拒绝启动,绝不带着隐患把进程跑起来。
| 配置项 | 为什么必须处理 |
|---|---|
SmartAdmin:Jwt:SecretKey | 生产(任何非 Development 环境)不配就拒绝启动,直接抛异常。只有 Development 下才会自动生成一把密钥落到 ./data/dev-jwt.key 并打印警告。生产必须显式配置(≥32 字节随机串),且不要进版本库,改用环境变量或密钥管理服务。 |
SmartAdmin:Database | 默认 SQLite ./data/SmartAdmin.db(相对 ContentRoot)。多实例、或有并发写,就换 MySQL / SqlServer / PostgreSQL(改 DbType + ConnectionString 两项)。 |
SmartAdmin:Id:WorkerId | 雪花发号器的机器位。同一台机器上不配即可,启动时由文件锁自动抢号;跨机器、跨容器时每个副本必须各不相同(0–63),否则同毫秒发号会撞主键。配了 Redis 却不显式给它会直接拒绝启动。详解见容器化与多副本。 |
SmartAdmin:Upload:RootPath | 默认 ./wwwroot/upload。声明成数据卷,否则重部署丢文件;走路线 A(后端顺带托管前端)还必须把它挪出 wwwroot,否则上传文件会被静态中间件匿名直出。见路线 A 的鉴权绕过警告。 |
SmartAdmin:Api:ForwardedHeaders | 在任何反向代理 / 负载均衡之后都必须配。不配的话后端看到的永远是代理那一个 IP:全体用户共享一个限流桶、按 IP 的爆破防护归零、审计日志的 IP 列作废。配置细节见路线 B。 |
SmartAdmin:Cache:Provider | 单实例可留 Memory。多副本必须换 Redis,否则强制下线、撤权、登录锁定会在副本之间失效,而且一失效就是好几天。改这一项还不够:宿主项目要装 SmartAdmin.Caching.Redis 包,并在 AddSmartAdmin() 之前调用 AddSmartAdminRedisCache(builder.Configuration),两个条件缺一个就静默退回内存缓存。详解见容器化与多副本。 |
上面这些都能走环境变量,层级用双下划线(容器化部署常用):
SmartAdmin__Jwt__SecretKey='...'
SmartAdmin__Database__DbType='MySql'
SmartAdmin__Database__ConnectionString='Server=db;Port=3306;Database=smart;User ID=...;Password=...'
SmartAdmin__Upload__RootPath='/data/upload'表外还有一项慢 SQL 告警阈值,配置键是 SmartAdmin:Database:SlowSqlMillis,默认 1000 毫秒。执行耗时超过它的语句,会连同 SQL 和参数打一条 Warning。失败的 SQL 则总是打 Error,带语句和参数,不受这项控制,也没有开关能关掉。想看全部语句就把它调小,比如 1,但生产上这么干会把日志淹掉。日志类别是 SmartAdmin.Sql,想单独调级别就调它。
生产建表闸门:首次建表与升级补列
生产环境有一道建表安全闸门。只要 ASPNETCORE_ENVIRONMENT=Production,哪怕 EnableCodeFirst=true(默认就是 true),也不会自动建表或改表。生产库通常由 DBA 手工维护,应用不该擅自 ALTER。想放行,显式打开这一项:
{ "SmartAdmin": { "Database": { "EnableCodeFirstInProduction": true } } }它默认 false,管两件事:
- 空库首次上生产:表还没建,种子无处可写。这时候两条路二选一。要么临时打开这项,让它自己建表、写种子,建完可以再关掉。要么让 DBA 照启动错误里点名的表先建好,再启动。
- 升级内核版本补列:新版内核可能给自己的表加列。加字段是常态,删列或改窄内核不会做。同样两条路:本次启动打开这项,让它自己补列;或者让 DBA 照错误里点名的表和列,手工
ALTER TABLE ... ADD COLUMN。
破坏性变更闸门:AllowDestructiveSchemaChange
CodeFirst 对已存在的表做的不只是加列。SqlSugar 会把实体没声明的列直接 DROP,把长度从 255 收窄到 100 原样执行,可空改非空也照改,数据就这么没了。SQLite 是例外,它的 CodeFirst 只加列。所以扫描真要跑之前,内核先逐表比对实体列与库里的列,挑出会丢数据的四类差异:实体没声明的列、字符串长度收窄、可空改非空、文本与数值或时间互换。有差异就拒绝启动,错误里按「表.列:变更(库里现状 → 实体要求)」列清单:
SmartAdmin 启动失败:CodeFirst 将对库执行破坏性变更,已拒绝。差异清单:sys_user.Remark: 收窄(varchar(255) → 100); sys_user.LegacyCode: 删列(varchar(32))。...确认清单无误(那列确实该删,收窄的列里没有超长数据)就放行这一次:
{ "SmartAdmin": { "Database": { "AllowDestructiveSchemaChange": true } } }放行后变更执行完,把它关回去。不想让 CodeFirst 碰某张表里 DBA 自己加的列,在实体上标 [SugarTable(IsDisabledDelete = true)]。SQLite 上这些差异只打一条 Warning,不拦,换到别的数据库前处理掉就好。闸门骑在扫描上:配了 CodeFirstVersion 且版本没变时整个扫描跳过,闸门也不跑,不给平时启动加开销。它只判「确定会丢数据」的形状,库列长度报不出来(nvarchar(max)、text)或类型各方言映射不一(Guid、二进制)的一律放行,宁可漏判,不误拦一个今天能正常跑的库。
表多、库远时启动慢:CodeFirstVersion
CodeFirst 每次启动都逐表比对列定义。实体上百张、数据库又在远端时,这一步就是启动慢的大头。配一个版本号,扫描只在它变化时跑:
{ "SmartAdmin": { "Database": { "CodeFirstVersion": "2026.09.06" } } }建表成功后它被记进 sys_schema_version。下次启动版本没变、实体表齐全,整个扫描直接跳过。实体改了就改这个号,用日期最省事。加了新表忘了改号有兜底,缺表照样建。改了列忘了改号不会补列,所以改实体时顺手改它。不配就保持每次启动都扫。启动日志里「CodeFirst 建表完成」那行带着 SQL 条数与耗时,慢在建表还是别处一眼能分。
演进列为什么是可空的
内核给已有表加的字段一律使用可空数据库列(IsNullable)。MSSQL 无法对「表里已有数据」直接 ADD 无默认值的 NOT NULL 列;可空补列后,旧行是 NULL,业务读侧按默认语义处理(例如 MFA 标志读为 false,绝对过期回退到会话 ExpiresAt)。新属性可以用 T? 表达该语义,但已发布的公共属性保留原 CLR 类型,并用回归测试锁住 ORM 的默认值物化。DBA 手工补列时也请加可空列,不要自作主张加 NOT NULL 除非同时写了 DEFAULT 并回填旧行。
没放行会在启动时点名报错,这是故意的
两种场景都不会带病启动,报的错都点到了名。空库缺表,抛的错点名到表:...种子要写的表在库中不存在:sys_schema_version, ...。升级缺列,抛的错点名到列:库表结构落后于当前实体,以下表缺少列:sys_user(Avatar)。照它说的二选一就行。为什么宁可启动就炸?因为一旦放行,进程会正常起来,直到第一次查到那张表,才炸在驱动层的「列不存在」上。那种错误没有表名,也没有列名,谁都不知道该 ALTER 什么。守卫只查缺列,不查类型、长度、可空性的变化。DBA 有意把 varchar 放宽,或者加了自己的列,都不会被判死。
首次写种子时,如果没有显式配 SmartAdmin:Seed:AdminPassword,控制台会打印一次随机超管密码,16 位,仅这一次显示,记得留存。想固定账号密码,把它配上就行。
升级时种子数据怎么处理
种子默认只插不改,判存靠主键。所以内核新增的种子行,比如新菜单、新配置项,升级后会自动流进你的库,不用管。内核改动已有行是另一回事,比如把某个权限按钮挪到别的页面下、给内置模块补图标。这类改动由 sys_schema_version 的版本闸门驱动。内核 bump 了种子版本,下次启动就把菜单树和模块这两张结构表的内置行刷回新结构,再写回版本号。
内置菜单:结构归内核,外观归你
升级只刷内置菜单的结构列:挂载点、类型、权限码、路由、组件、图标、所属应用。你在菜单管理页改过的标题、排序、可见、启用照常留着,藏起来的「文件管理」不会弹回来。你自己新增的菜单不受影响。模块表(sys_module)仍是整行刷回。配置中心(sys_config)只刷展示名、分组、排序、备注,值一个不碰;字典、用户、角色授权都是你的数据,升级一行都不动。
上线后自检
三条 curl 就能确认全链路通了:
curl https://<你的域名>/health # Healthy:进程存活
curl https://<你的域名>/health/ready # Healthy:数据库 + 缓存都连得上
curl -i https://<你的域名>/api/v1/ping # 401:API 路由通了(该端点需要登录)/health 和 /health/ready 语义不同,别探错。/health 只看进程本身还在不在响应,对应 k8s 的 livenessProbe、进程级重启。/health/ready 会真去连数据库和缓存,对应 readinessProbe、负载均衡摘节点。要判断能不能接流量,探后者。
再打开前端登录一次,能拿到菜单就说明 JWT 密钥、数据库、种子数据全对上了。
最后提一个容易误报的点。/openapi/v1.json 在生产返回 404 是预期行为,不是部署漏了什么。它只在 Development 环境挂载,是给前端 npm run gen:api 用的契约源,不是生产端点。
版本回滚
回滚没有专门脚本,就是把上一个版本重新部署一遍。消费方把 NuGet 包引用和 smart-admin-web 一起退回上一个版本号,两边照旧同号;Docker 部署把镜像 tag 换回上一个,docker compose up -d 即可。数据库不用跟着退:CodeFirst 只加列,从不删列或改窄,旧代码不认识的新列留在原地,不影响它照常跑。
真正回不去的是发布这一步本身。release workflow 一跑完,包就在 nuget.org 和 npmjs.com 上了:nuget.org 只能 unlist,不能删除;npm 上发过的版本号也不能再用。完整节奏写在更新日志和发布流程里。回滚退的是你自己部署的那个实例,退不掉已经发出去的包。