本文主要介绍一类自托管媒体库(以 Octans 后端为例)在两件看似无关、实则都决定「长期可维护性」的决策上如何收口:一是内部结果模型与错误契约统一,二是配置可视化首版的边界。前者解决 Controller / Service 各造一套 *ServiceResult 的分裂;后者解决「配置能改、能看、能审计」与「部署覆盖、启动链路、权限面」之间的张力。讨论的是可被 supersede 的工程边界,不是某一版本的接口字段全集。
媒体库后端会同时长出两套压力:对前端要有稳定的 { data, meta?, error? } 消费面;对运维与 Root 用户又要能在控制台看见配置从哪来、能否改、改完要不要重启。如果内部结果模型继续按模块私有演进,错误码在字面量里散落,配置定义又以数据库行或临时 JSON 为「事实来源」,后续任何重构都会同时伤到 API 契约与部署习惯。下面按两份正式决策的口径合成一版可对外表述的主线。
一、结果模型与错误契约
问题:四套并行结果 + 查询双层包装
在结果模型统一之前,后端容易出现按模块各自生长的结果类型,例如认证、库管理、任务查询、通用查询各有一套 *ServiceResult<T>。查询主线还可能再套一层只负责「把 Data 和 Meta 绑在一起」的转运 DTO,形成:
QueryServiceResult<QueryResultDto<T>>
直接后果是:
- Controller 与 mapper 反复拆
result.Data!.Data/result.Data.Meta。 - 分页与能力类元数据本就来自 query service,却被塞进临时壳,语义不稳定。
- 为迁移方便引入的过渡结构,会在没有明确删除决策时变成「事实标准」。
与此同时,前端其实只依赖线协议:HTTP 响应仍是稳定的 data / meta? / error.code / error.message。内部结果模型怎么设计,不该再牵动这层消费契约。
决策:内统一、外不变
正式口径可以压成四条:
| 层面 | 决策 |
|---|---|
| 对外线协议 | 继续 ApiResponse<T> / QueryApiResponse<T>;前端只依赖 data、meta、error.code、error.message |
| 内部结果内核 | 统一到共享三类:Result<T>(中性)、CommandResult<T>(命令 / 动作 / 管理)、QueryResult<T>(查询) |
| 查询主线 | 删除纯转运的 QueryResultDto<T>;QueryResult<T> 直接承载 Data、Meta、StatusCode、ErrorCode、ErrorMessage |
| 错误码 | API 面向外仍是字符串;只允许从集中目录(如 ApiErrorCodes)引用,禁止 Controller / Service 再散落写字面量 |
内部结果模型不直接成为公开 API schema;映射由共享 mapper 完成,例如:
QueryResult<T> → QueryApiResponse<T>
CommandResult<T> → ApiResponse<T>
是否用继承、record 或组合属于实现细节;「query 结果直接带 Meta」与「错误码集中出口」是契约层决策。
为什么 Meta 必须挂在 QueryResult 上
- Meta 是查询语义的一部分。
total、skip、take、capabilities、flags等不是 Controller 临时拼出来的 HTTP 装饰,而是 query service 在业务查询时一并产出的正式结果。 - 转运层没有独立领域语义。只把 Data/Meta 绑一下再拆一次,只会增加样板,不会增加边界清晰度。
- 共享 mapper 需要稳定输入形状。若仍是「服务结果再包一层 DTO」,query 路径会长期停留在临时过渡态,和 command / auth / libraries 路径长得不一样。
明确拒绝的替代方案
| 方案 | 拒绝原因 |
|---|---|
长期保留 QueryResultDto<T> | 纯转运;保留 Data!.Data 样板;query 与其它路径不一致 |
Meta 只存在于 QueryApiResponse | 来源是 service 不是 Controller;内部不带 Meta 只会逼出 tuple / out / 再包装 |
让 QueryResult 混入 HttpContext / ActionResult | 把 service 绑回 ASP.NET 运行时;毁掉共享 mapper 与进程无关测试 |
决策后果与非目标
后果:这是改动面较广的机械收口,不是局部试点;查询、认证、库管理、系统任务读取侧应同时替换到共享结果模型。对前端 API 契约不应产生结构性变化;变化的是后端内部统一方式。长期约束应沉淀到开发规范,不能只留在某次改造计划里。
非目标(刻意不做):
- 把全部业务日志结果码、任务明细码并进同一层结果模型。
- 顺手重命名全部业务 DTO 命名空间。
- 把内部结果类型直接文档化为公开 OpenAPI 的唯一 schema 来源。
对同类 .NET / 多模块后端,可复用的一句话是:线协议稳定给前端,结果内核稳定给服务层,错误码只从一个集中出口出去。
二、配置可视化首版边界
背景:从 appsettings 到「可看、可改、可审计」
配置一旦从「改文件重启」演进到控制台可视化,就会同时碰到:定义从哪来、数据库写什么、环境变量如何仍能压过 UI、改完要不要重启、哪些 key 能热生效、Secret 能不能回显、启动链路配置能不能被 UI 改坏。旧方案若带过宽的 Dynamic 分类、进程内软重启,或把 CORS / 权限面扩得过大,会和「同源 publish、个人开发者主线、单实例」的产品形态不匹配。
首版可视化的目标不是「所有配置热更新 + 多租户 scope」,而是把长期边界钉死,避免计划里的候选方案被误写成已落地事实。
决策摘要
1. Registry 是定义的唯一事实来源
配置定义以代码侧 registry(定义注册表)为 canonical source。数据库中的定义表只是运行期镜像,用于查询、审计和 inactive 标记。
这样可以避免:
- 定义被运行时数据悄悄改写;
- 配置 provider 把 factory default 误当成真实 override。
2. 数据库只保存用户 override
值表只存用户写入的覆盖项。恢复默认 = 删除 override,不做 factory default 复制进库。
生效优先级固定为:
环境变量 > 命令行参数 > 数据库 override > User Secrets > 环境配置文件 > 基础配置文件
命令行参数与环境变量保持最高优先级,并在 UI 中显示为 locked(部署侧最终覆盖,控制台不可与之对打)。
3. 首版只做全局配置
不做 tenant / user / library scope;配置键直接作为主键维度。当前主线按「个人开发者 + 单一真实用户 + 单一主线环境」决策,没有必要为假想多租户提前引入复合 scope 模型。
4. 只提供 Root-only 应用层重启
- 不做进程内 Host 软重启。
- 不暴露 Docker API 或 systemd 重启控制面。
- 保存后需要重启的配置可写 pending restart 状态;Root 可通过设置页或专用 restart API 请求应用层重启。
重启链路约定:应用进程按约定退出码停止;Docker 场景下由 entrypoint 监督子进程,约定退出码时重新拉起、容器保持运行;docker stop / compose stop / systemd stop 等外部停止仍按正常停机,不进入重启循环。非 Docker 部署不承诺自动拉起,由 systemd、supervisor 或手工启动承接。
拒绝软重启与 Docker socket 的原因:
- 进程内重启易与 Host 生命周期、后台任务、实时连接、播放会话、数据库上下文深度耦合。
- 暴露 Docker / systemd 控制面会显著放大权限边界,不符合当前单实例主线。
- 配置变更后的重启是真实产品闭环,用 Root 确认 + 容器内 exit-code supervisor 即可覆盖,不必引入热更新复杂度。
5. Dynamic 必须逐 key 审计
只有消费端完成审计与迁移的 key 才能标为 Dynamic(运行中可读到新值)。扩大 allowlist 前,必须证明消费端不再在构造期缓存旧值。
大量与文件、扫描、刮削、播放、认证、存储相关的关键配置,首版仍应保持 AppRestart(改完需应用重启才完全生效),避免「UI 显示已保存、进程仍用旧缓存」的假热更新。
6. Secret 不回显明文
Secret key 不通过 API 返回明文;审计记录也不存明文,只存 hash。首版不承诺数据库 secret 加密落盘;若要做 encrypted-at-rest,需另开决策明确密钥来源、备份恢复与启动解锁。
7. Bootstrap 配置禁止 UI 写入
数据库连接、监听地址、静态资源根目录等启动链路配置不允许 UI 写入。数据库配置 provider 也应过滤需外部重启或只读的 key,避免 override 反过来破坏 provider 自身与启动顺序。
正向影响与代价
收益:
- Root 可在控制台查看来源、override 状态、locked 状态与生效模式。
- 变更可走 revision、并发令牌、changeset 与条目级审计。
- 数据库 override 不污染
appsettings*.json。 - 环境变量与命令行仍适合部署侧最终覆盖。
- Docker 下应用层重启可保持容器存活。
代价:
- 多数配置仍需重启后完全生效;重启期间播放会话与后台任务可能中断。
- pending restart 状态随下次启动清理;标记为外部重启的项仍按部署层处理。
- Secret 当前是「不回显、不审计明文」,不是落盘加密。
后续能力的准入条件
| 能力 | 先决条件 |
|---|---|
| 扩大 Dynamic | 消费端迁到可监控选项或入口实时读取,并做端到端验证 |
| 引入 scope | 先确认真实存在多用户 / 多库独立配置需求,再设计迁移 |
| 扩展重启控制面 | 若接 Docker API、systemd、多实例编排或重启审计,须另开决策写清权限、失败恢复与用户确认 |
| Secret 加密落盘 | 先确定密钥来源与备份恢复流程 |
两条决策如何叠在一起
结果契约解决的是模块内部如何返回、如何映射、如何报错;配置可视化解决的是实例级参数如何被看见与被安全修改。叠在一起时,有几条值得显式对齐:
- 配置读写 API 也走统一结果内核。设置页保存、列表查询、重启请求不应再发明第四套
ConfigServiceResult;查询类带 Meta(分页 / 能力),命令类走CommandResult,错误码进集中目录。 - 线协议对前端仍是同一套封套。设置页与媒体查询页可以共用请求层对
error.code/error.message的处理,而不是配置模块单独一套错误形状。 - 权限面与角色模型衔接。配置可视化与应用层重启应落在 Root(或等价的实例超级用户)能力上,而不是「能登录就能改启动参数」;角色分层见同系列 IAM 文。
- 部署覆盖高于 UI。结果契约保证 API 稳定;配置优先级保证 env / CLI 在可视化之上,避免控制台「保存成功」却与编排层意图对打。
架构上可以粗略画成:
前端 / 控制台
│ 只消费 { data, meta?, error? }
▼
API 封套(ApiResponse / QueryApiResponse)
│ 共享 mapper
▼
内部结果内核(Result / CommandResult / QueryResult)
│
├─ 业务查询 / 命令服务
└─ 系统配置读写、审计、pending restart
│
▼
定义 registry(canonical) + 值 override(DB)
+ 环境变量 / 命令行(最高优先级,UI locked)
对同类产品的可复用清单
结果 / 错误
- 是否已消灭「每模块一套 ServiceResult」?
- 查询结果是否直接带 Meta,而不是转运 DTO?
- 对外 JSON 是否与内部结果类型解耦?
- 错误码是否字符串契约 + 集中目录,禁止字面量散落?
- 是否明确「不把内部 Result 当 OpenAPI 唯一真相」?
配置可视化
- 定义是否在代码 registry,而不是以库表为 canonical?
- DB 是否只存 override,恢复默认是否等于删除?
- 优先级是否写死,且 env / CLI 在 UI 上 locked?
- 首版是否拒绝假想多租户 scope?
- 重启是否收敛为 Root-only 应用层路径,而非进程内热重启 / Docker socket?
- Dynamic 是否按 key 审计,默认偏 AppRestart?
- Secret 是否不回显、审计不存明文?
- Bootstrap / 启动链路 key 是否禁止 UI 写入?
局限与注意事项
- 本文是决策边界与选型说明,不是接口字段字典、迁移 runbook 或配置项全表。
- 结果统一往往是大范围机械替换;验收应同时看内部类型收敛与前端契约零结构破坏,而不是只看编译通过。
- 配置可视化首版刻意保守:多数 key 仍需重启;播放与后台任务会中断,产品文案与 Root 确认流程需要配套。
- Secret 与加密落盘、多实例滚动重启、库级配置 scope 等,均属后续条件触发的议题,不应被本文写成已交付能力。
- 具体类名、路径与退出码以目标版本实现为准;公开表述只保留机制与边界。
小结
- 对外封套稳定,对内结果统一:
ApiResponse/QueryApiResponse不变;内部收敛为Result/CommandResult/QueryResult,查询直接带 Meta,错误码集中出口。 - 配置定义在代码,值覆盖在库:registry 为事实来源;DB 只存 override;env / CLI 最高且 locked。
- 首版全局、Root 重启、Dynamic 慎开:不做假想 scope;应用层退出码重启替代软重启与 Docker 控制面;热生效必须逐 key 证明。
- 两条线共用同一套 API 纪律:配置读写也走统一结果与错误契约,权限与部署覆盖另由角色与优先级保证。
把「怎么返回」和「怎么改配置」同时钉在可审计、可映射、可部署的边界上,比先做满功能再回头拆临时模型更便宜。
相关阅读
同系列还可对照:
- 媒体库用户角色与 IAM 原则:全局角色、库级 R/W 与访问范围分层:Root / Admin / User 分层;配置可视化与应用层重启应落在实例超级用户能力上。
- Octans Docker 私有化部署:单镜像、Compose 叠加与健康检查:容器内进程监督与部署覆盖;与「应用层退出码重启、容器保持运行」衔接。
- 媒体内容产品 + 管理后台混合前端的 UI 基座选型:控制台设置页所在的 Web UI 基座;前端只消费稳定 API 封套。
来源
本文综合改写自 Octans 项目决策层文档中关于后端结果模型与错误契约统一,以及后端配置可视化首版边界的说明。表述面向公开架构选型,已去掉内部仓库路径、计划编号与未发布实现细节;具体字段与 key 清单以目标版本开发规范与实现为准。