Day 13:Session 管理与生命周期

ERP 系统的每一次请求都要先回答两件事:谁在调用、他在哪一家公司。框架若不统一管理,这两个问题会长进每一段业务逻辑的开头,变成到处拷贝的模板代码。

框架的做法是把答案集中在一个对象上:SessionInfo。它看起来只记着「谁登录了」,实际上数据库路由、定制化、语言、时区、加密、权限这几条线全部在它身上取值。

本篇说明:

  1. Business Object 取得运行上下文的方式
  2. Session 的两阶段生命周期
  3. Session 的持久化与重建
  4. Culture 与 TimeZone 默认值的设计取舍

一、Business Object 的运行上下文

写第一个 BO 方法时就会碰到的问题:它要先知道「现在是谁」,而取得的方式有三种。

做法 问题
静态的「目前用户」 同时处理多个请求的服务器上必定互相污染
每个方法签名多挂参数 每一层都要转手用不到的东西,漏传不会有人告诉你
构造时交到手上 框架采用这一种

BO 的构造器固定四个参数,声明在 BusinessObject.cs:

csharp 复制代码
protected BusinessObject(IBeeContext ctx, Guid accessToken, string progId, bool isLocalCall = true)
  • ctx:一整包已解析的服务(见下)
  • accessToken:这次调用的 session 标识
  • progId:调用对象的程序标识码
  • isLocalCall:是否为同进程调用

所有 BO 共用同一个签名,是为了让工厂不必先知道要建的是哪一个家族(注册表本身是 Day 15 的题目)。

IBeeContext 装了什么

这一包的内容全声明在 IBeeContext 上:

csharp 复制代码
public interface IBeeContext
{
    IDefineAccess DefineAccess { get; }               // 定义访问
    ISessionInfoService SessionInfoService { get; }   // Session 访问
    ILanguageService LanguageService { get; }         // 语言文字
    IBusinessObjectFactory BoFactory { get; }         // BO 之间互相调用
    IServiceProvider Services { get; }                // 逃生门
}

前四个具名,因为几乎每个 BO 方法都会用到。第五个是逃生门,只给少数方法用,例如登录才需要的那几个辅助服务。它刻意留成同一个固定写法,所以谁用了它、用在哪里,全局搜索一次就盘得出来。开一个有记号的逃生门,比让人另外挖一个没记号的洞好。

这一包里刻意没有的东西:

  • 没有 DbConnection
  • 没有 DbTransaction
  • 没有连接字符串

BO 要碰数据一律走 Repository,由 Repository 自己声明的数据库分类,加上 session 解析出的公司,决定连哪一个数据库(Day 11 已展开)。

还有一个问题,答案同样不在这一包里:这一段做完算不算成功。transaction 不是 BO 开的。保存时由 Repository 开一个 transaction 包住整笔单据,master 先、detail 后,全成功才 commit。BO 重写进去的那几小段跑在 transaction 的哪一边,是明天整篇的题目。

四个参数里最不起眼的是 accessToken,它只是一个 Guid。而 BO 需要知道的每一件事,最后都是拿它换来的。


二、Session 的两阶段生命周期

多公司部署时的实际问题:把 companyId 并进 Login 会碰到三个结构性障碍。

  • 顺序是反的:用户能进哪些公司,要验证身份之后才查得出来
  • 切换成本高:切公司等于注销再登录,token 重发、加密密钥重建,界面上未存的数据全消失
  • 中间状态无处表达:「已登录、尚未进公司」没有字段可以放

框架因此把 session 切成两段,四个对称方法:

复制代码
Login(account, password)   ←→  Logout()
EnterCompany(companyId)    ←→  LeaveCompany()

两对是嵌套的,外层管身份,内层管公司:

方法 做完之后
Login 有身份。能调用不绑公司的方法,EnterCompany 就是其中一个
EnterCompany 有公司上下文。company 那一类要连哪一个数据库,这时才解得出来
LeaveCompany 公司上下文清掉,session 还活着
Logout session 销毁,内部会先清一次公司上下文

表上看不出来的两件事:

  • 切换公司不必先 LeaveCompany,EnterCompany 直接重写旧值(两步骤会产生非原子的中间状态)
  • 公司不存在、公司禁用、无访问权三种失败合并为同一个错误,避免被拿来枚举系统里有哪些公司

登录失败处理

Login 是匿名的,任何人都打得到,所以它另外挂着一个计数器。ILoginAttemptTracker 记同一个账号连续失败几次,登录成功就归零;框架附的默认实现在连续失败到一定次数之后把那个账号锁上一段时间,而宿主可以整个换掉。

锁多久、锁账号还是锁来源、要不要通知,是各家信息安全策略决定的:有的要配合公司既有的账号规定,有的要接通知渠道,有的根本不锁只记录。而「连续失败要被数到」各家都一样。Day 2 那条判别法在认证上就长这个样子。

不一样的那一半,框架现在开的是一个可替换的实现,不是一份设置。ERP 的信息安全管理最后多半会落在设置层,一家公司一组门槛;要走到那里,得先有够多部署把同一组参数提出来。

SessionInfo 装了什么

两阶段不只是流程约定,它就是 SessionInfo 这个类型的形状:公开属性依「由哪一段填入」分成两组。

Login 填入(七个)

属性 内容 消费者
AccessToken session 标识(Guid) 每次身份验证
ExpiredAt 到期时间(UTC) 每次身份验证
UserId 账号 审计(Day 25)
UserName 显示名称 审计(Day 25)
Culture BCP-47 语言代码 多语言(Day 20)
TimeZone IANA 时区代码 时区换算(Day 28)
ApiEncryptionKey 本连接的对称密钥 Payload 加密(Day 23)

EnterCompany 填入(六个)

属性 内容 消费者
CompanyId 目前公司 数据库路由(Day 11)
CustomizeId 该公司套用的定制化代码 定制化(Day 19)
Roles 用户在该公司的角色 权限(Day 24)
UserRowId st_user.sys_rowid 权限(Day 24)
EmployeeRowId st_employee.sys_rowid 权限(Day 24)
DeptRowId st_employee.dept_rowid 权限(Day 24)

CompanyId 是其中唯一可为 null 的,语义明确:已登录、尚未进公司。Day 11 讲过 Repository 在构造当下就解析路由,而这个状态正是解不出来的情况之一,会被当场挡掉。

公司代号还会换到一份 CompanyInfo,这一份在缓存里,来源是一句查询,第一次用到才读。它除了回答公司数据库地址,还带着公司层的重写:数字位数、公司本币、现金最小收付单位、可用币种清单。进公司备好的不只是一个地址,数值语义本身是 Day 26 的题目。

六个属性同进同出

这六个值由同一个私有方法一次清掉,它与本篇后面几个片段都出自 SystemBusinessObject.Session.cs:

csharp 复制代码
private static void ClearCompanyContext(SessionInfo sessionInfo)
{
    sessionInfo.CompanyId = null;
    sessionInfo.CustomizeId = string.Empty;
    sessionInfo.Roles = [];
    sessionInfo.UserRowId = Guid.Empty;
    sessionInfo.EmployeeRowId = Guid.Empty;
    sessionInfo.DeptRowId = Guid.Empty;
}

LeaveCompany 与 Logout 走的都是这一个。


三、Session 的持久化与重建

服务器重启或负载均衡把下一个请求送到另一台机器,手上的 token 还算不算数?

  • 只放内存:不算数,每次发版都要把所有人踢下线
  • 整份写进数据库:登录当下快照的权限,会在用户被调离公司之后继续生效

框架两份都留,但写进 st_session 的只有 session 自己的身份与到期时间,加上重建时凑不回来的那一个值。这一份称为 seed,类型是 SessionUser:

csharp 复制代码
new SessionUser
{
    AccessToken = sessionInfo.AccessToken,
    UserID = sessionInfo.UserId,
    UserName = sessionInfo.UserName,
    EndTime = sessionInfo.ExpiredAt,
    CompanyId = sessionInfo.CompanyId,
}

seed 只放五个值,其余的都能重新生成:

值 重建方式
Culture / TimeZone 重读 st_user
ApiEncryptionKey 由 token 重新推导
CustomizeId / Roles / 三个 RowId 重跑 EnterCompany 的解析

只有「用户选了哪一家公司」在数据库里没有其他地方记得,所以必须存。

缓存没命中时的重建有三个性质:

  • 重跑推导,不是还原快照。登录后才被撤销的公司权限不会存活,该 session 直接回不来。
  • EnterCompany 与重建共用同一段程序(SessionCompanyBinder)。两条路必须落在相同状态,否则权限会取决于请求打到哪一台机器。
  • seed 先写、缓存后放。两种中间状态不等价:有 seed 没缓存,下个请求重建即可;有缓存没 seed,这个 token 下次重启就死,且在别台机器上根本不存在,client 无从察觉。

另一个发得出 token 的方法

CreateSession 拿一个账号直接发一张 token,完全不验凭证,与 Login 的差别只有这一件;后面推导密钥、写 seed、放缓存那一整段共用同一个方法。

用途是调度、导入、对账这类没有人坐在前面打密码、却仍然需要一个身份才跑得动的作业。没有它,那些作业只剩两条路:在配置文件里放一组服务账号的密码,或干脆绕过整套权限自己开连接。

代价是这个方法本身就是一把万能钥匙,所以它在访问控制上声明成只接受同进程调用:走 HTTP 进来的请求在进到方法之前就被挡掉,能调用它的只有跟后端跑在同一个进程里的程序。第一节构造器那个 isLocalCall 收的是同一个值,不经过那道验证的调用路径,就靠它挡。


四、Culture 与 TimeZone 的默认值

这两个属性的默认值都是空字符串,而且是刻意的。

  • 语言服务遇到空值 → 退回系统默认语言
  • FrameworkClock / DateTimeZoneConverter / PayloadZoneConverter 遇到空时区 → 一律当 UTC

空字符串的语义是「走默认路径」,不是「还没填」。

真正填值的是登录那一段(repo 是用户数据表的 Repository):

csharp 复制代码
var locale = repo.GetLocale(sessionInfo.UserId);
var backend = DefineAccess.GetSystemSettings().BackendConfiguration;
sessionInfo.TimeZone = StringUtilities.IsNotEmpty(locale.TimeZone)
    ? locale.TimeZone : backend.DefaultTimeZone;
sessionInfo.Culture = StringUtilities.IsNotEmpty(locale.Culture)
    ? locale.Culture : backend.DefaultLanguage;

这两个值往回退有三层:st_user 那一行 → BackendConfiguration → 各消费端自己的默认。

BackendConfiguration 这两个设置的默认值是 zh-TW 与 Asia/Taipei,所以什么都没调的部署拿到的就是它们。这个决定放在设置里,不在源代码里:填空字符串即可让整个部署走各消费端自己的默认。未登录的调用没有 session,走的一直是最后那一层。


回到 Northwind

案例只有一家公司,登录之后照样走完进公司那一步。

  • 两段都零应用代码:登录走框架自己的 st_user(哈希比对、显示名称同行读出),进公司走框架自己的 EnterCompany。应用只 seed 两行数据,写在 NorthwindSchemaSeeder.cs:st_company 一行,定制化代码就填在它的 customize_id;st_user_company 一行,少了它 EnterCompany 一律当成无访问权
  • 前端登录成功之后就接着进公司,中间没有公司清单,因为只有一家。有好几家的时候,那份清单就插在这两步中间
  • 看得出来的两件小事:示范账号在 st_user 填了 Asia/Taipei 与 en-US,两列都走「用户自有值」那条路;语言那一列与部署默认的 zh-TW 不同,所以分得出来走的是哪一条;CompanyId 一路走到路由,把所有归在 company 那一类的表单接到同一个数据库

一家公司也要走两段,看起来是形式。真正省不掉的是进公司那一步写进 seed 的公司代号,session 上其他的值都能重算,只有这一个不行。


小结

一个 session 要管的值,分两段到位:

  • Login 答完「你是谁」,同时备好语言、时区与加密密钥
  • EnterCompany 答完「在哪一家公司」,同时把跟着公司走的那几个一起填上

这条界线不是约定,是依赖关系算出来的:第二组的每一个值都得先知道公司才推导得出来。先把值与值之间的依赖排清楚,生命周期通常就已经被决定了。

可以带走的判准:框架要求应用「只写业务判断」的时候,它欠应用一份清单,写明哪些问题已经被答完。清单缺一项,那一项就会以某种形式长回每一段业务逻辑的开头:一个静态的目前用户、一个到处转手的参数、或一个每次都要重查的查询。

明天谈框架把保存切成哪几个扩展点,以及什么时候该接手其中一个。


本文为 2026 iThome 铁人赛参赛文章,原文为繁体中文。

iThome 原文 | HackMD 版

系列完整目录:ERP 架构师笔记:定义驱动的框架设计

相关推荐
Lorenzo56516 小时前
扫码点餐里的并发和断网:我在第一个全栈项目里做的几个决定
.net
1-1=01 天前
C# IO和序列化
c#·.net·.netcore
Jeff-Tsai1 天前
Day 12:定义与数据缓存的跨节点失效
.net·架构设计·erp·definition-driven
CSharp精选营1 天前
从控制台到「会动手」的 AI:用 Semantic Kernel 搭应用(小白教程)
c#·.net·ai agent·semantickernel·functioncalling
Jeff-Tsai2 天前
Day 11:数据访问层的连接、Transaction 与查询条件
.net·架构设计·erp·definition-driven
宝桥南山2 天前
.NET 11 - 尝试一下Blazor中的新特性SupplyParameterFromTempData
microsoft·微软·c#·asp.net·.net·.netcore
rockey6272 天前
AScript之LINQ扩展
c#·.net·script·动态脚本