# 2026-07-21 代码优化 本文档说明 **2026-07-21** 后端改动。重点为 **泰额版** 平台级管理员: 1. 「公司列表」返回账号密码、支持修改(前端密码 AES 加盐) 2. **菜单权限分配**、**角色菜单分配** 另含美国版/泰额版 Dashboard `Last Updated` 修复。前端本次不改。 --- ## 一、变更概述 | 项 | 说明 | |----|------| | 范围 | 泰额版 `Yi.Abp.Net8`(主);美国版 `Last Updated` 同步修复 | | 公司 = 租户 | 平台主库 `YiTenant`;前端常把 `name` 映射为 `companyName` | | 账号密码 | `GET company-list`、`PUT company-admin` | | 菜单/角色 | `menu-permission-tree`、`company-menus`、`company-roles`、`company-role-menus` | | 凭据存储 | 主库表 `fl_th_tenant_admin_credential`(可回显 AES 密文,非登录哈希) | | 菜单存储 | 主库表 `fl_th_tenant_menu_permission`(SaaS `menuPermissionKeys`) | | 传输加盐 | 与 `FoodLabeling:TenantSelectCrypto` 一致(AES-CBC + `passwordSalt`=IV) | | 落库哈希 | 租户业务库 `User`:`MD5Helper.GenerateSalt` + `SHA2Encode`(`UserPasswordHelper`) | --- ## 二、平台公司列表(返账号密码) ### 2.1 接口 | 项 | 值 | |----|------| | 方法 / 路径 | `GET /api/app/th-multi-tenancy/company-list` | | 鉴权 | 需登录(Bearer) | | 说明 | 分页查询主库租户,并返回可解密的管理员账号/密码 | > **路由说明**:ABP 常规控制器 RootPath 为 `api/app`;自定义 `[HttpGet(...)]` / `[HttpPut(...)]` 必须带 `th-multi-tenancy/` 前缀,完整路径才是 `/api/app/th-multi-tenancy/...`。此前误写为 `[HttpGet("company-list")]` 时实际注册在 `/api/app/company-list`(404 于文档路径);已纠正。 ### 2.2 入参 | 字段 | 说明 | |------|------| | `keyword` / `name` | 租户名称模糊匹配(`keyword` 优先) | | `skipCount` | 跳过条数 | | `maxResultCount` | 每页条数 | ### 2.3 出参示例 ```json { "totalCount": 1, "items": [ { "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "name": "测试公司", "creationTime": "2026-01-01T08:00:00", "loginAccount": "admin", "password": "Base64CipherText", "passwordSalt": "Base64Iv", "menuPermissionKeys": ["dashboard", "labeling:labels"] } ] } ``` ### 2.4 密码来源优先级 1. 主库 `fl_th_tenant_admin_credential` 中已保存的 AES 密文 / IV 2. 无记录时回退 `RbacOptions.AdminPassword`,再经 AES 加密返回 默认账号:`admin`(与 `UserDataSeed` / `ThTenantSelectConsts.DefaultAdminLoginAccount` 一致)。 --- ## 三、修改公司管理员账号/密码 ### 3.1 接口 | 项 | 值 | |----|------| | 方法 / 路径 | `PUT /api/app/th-multi-tenancy/company-admin` | | 鉴权 | 需登录(Bearer) | ### 3.2 入参 ```json { "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "loginAccount": "admin", "password": "Base64CipherText", "passwordSalt": "Base64Iv" } ``` | 字段 | 必填 | 说明 | |------|------|------| | `tenantId` | 是 | 租户 Guid | | `loginAccount` | 否 | 新登录账号;空则不改账号 | | `password` | 否 | AES 密文;与 `passwordSalt` 都空则不改密码 | | `passwordSalt` | 改密时必填 | AES IV(Base64) | ### 3.3 处理流程 1. 主库校验租户存在,读取凭据表当前账号 2. `CurrentTenant.Change(tenantId)` 定位租户库管理员用户 3. 若改账号:校验 UserName 不重复后更新 4. 若改密码:`TenantSelectCredentialCipher.DecryptPassword` → `UserPasswordHelper.ApplyPlainPassword` + `EnsurePasswordColumnsPersistedAsync` 5. Upsert 主库凭据表(可回显 AES 密文),供列表再次返回 ### 3.4 curl 示例 ```bash # 1) 登录拿 Token(按环境改 host / 账号) curl -X POST "http://127.0.0.1:19002/api/app/th-web-auth/login" \ -H "Content-Type: application/json" \ -d "{\"tenantName\":\"Default\",\"userName\":\"admin\",\"password\":\"123456\"}" # 2) 公司列表 curl -X GET "http://127.0.0.1:19002/api/app/th-multi-tenancy/company-list?skipCount=0&maxResultCount=20" \ -H "Authorization: Bearer " # 3) 修改管理员(password / passwordSalt 须为前端 AES 加密结果) curl -X PUT "http://127.0.0.1:19002/api/app/th-multi-tenancy/company-admin" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d "{\"tenantId\":\"<租户Guid>\",\"loginAccount\":\"admin\",\"password\":\"<密文>\",\"passwordSalt\":\"\"}" ``` --- ## 四、AES 加盐约定(与 07-16 一致) | 项 | 值 | |----|------| | 算法 | AES-256-CBC | | 填充 | PKCS7 | | Key | `SHA256(UTF8(SecretKey))` → 32 字节 | | IV | 随机 16 字节,Base64 → `passwordSalt` | | 密文 | Base64 → `password` | | 配置 | `FoodLabeling:TenantSelectCrypto:SecretKey` | ```json "FoodLabeling": { "TenantSelectCrypto": { "SecretKey": "FoodLabelingThTenantSelect2026Key!" } } ``` - **出参**:后端加密,前端用同一 SecretKey + `passwordSalt` 解密展示 - **入参(改密)**:前端加密后传 `password` + `passwordSalt`,后端 `DecryptPassword` 后再做登录哈希落库 解密失败统一:`UserFriendlyException("密码解密失败")`。 --- ## 五、主库凭据表 表名:`fl_th_tenant_admin_credential`(实体带 `[DefaultTenantTable]`,走平台主库;CodeFirst 开启时随主库启动建表) | 字段 | 说明 | |------|------| | `TenantId` | PK,对应 `YiTenant.Id` | | `LoginAccount` | 管理员登录账号 | | `PasswordCipher` | AES 可回显密文 | | `PasswordIv` | AES IV | | `LastModificationTime` | 最后修改时间 | 说明:此表存的是**可逆展示密文**,不是 `User.EncryPassword` 的不可逆哈希。 --- ## 六、平台菜单权限分配 平台管理员可为每个公司(租户)勾选可用 SaaS 菜单模块;Key 与前端 `saas-menu-tree` / `tenant-menu-drawer` 的 `menuPermissionKeys` 对齐。 ### 6.1 菜单权限目录树 | 项 | 值 | |----|------| | 方法 / 路径 | `GET /api/app/th-multi-tenancy/menu-permission-tree` | | 鉴权 | 需登录 | | 说明 | 返回可分配的 SaaS 菜单树(`key` / `title` / `children`) | 出参示例: ```json [ { "key": "dashboard", "title": "仪表盘", "children": [{ "key": "dashboard:analytics", "title": "数据分析" }] }, { "key": "labeling", "title": "标签管理", "children": [ { "key": "labeling:labels", "title": "标签列表" }, { "key": "labeling:categories", "title": "标签分类" }, { "key": "labeling:types", "title": "标签类型" }, { "key": "labeling:templates", "title": "标签模板" }, { "key": "labeling:multiple-options", "title": "多选项" } ] }, { "key": "management", "title": "管理", "children": [ { "key": "management:account", "title": "账号管理" }, { "key": "management:menu", "title": "菜单管理" }, { "key": "management:reports", "title": "报表" } ] } ] ``` 完整 Key 目录(与前端一致): | 分组 | Key | |------|-----| | dashboard | `dashboard`、`dashboard:analytics` | | labeling | `labeling`、`labeling:labels`、`labeling:categories`、`labeling:types`、`labeling:templates`、`labeling:multiple-options` | | modules | `modules`、`modules:training`、`modules:alerts`、`modules:tasks`、`modules:food-waste`、`modules:e-label` | | management | `management`、`management:account`、`management:menu`、`management:devices`、`management:reports`、`management:invoices`、`management:qr-codes`、`management:support`、`management:api` | ### 6.2 获取 / 设置公司菜单 | 操作 | 方法 / 路径 | |------|-------------| | 获取 | `GET /api/app/th-multi-tenancy/company-menus?tenantId={guid}` | | 设置 | `PUT /api/app/th-multi-tenancy/company-menus`(覆盖式) | 设置入参: ```json { "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "menuPermissionKeys": ["dashboard", "labeling:labels", "management:account"] } ``` - 非法 Key → `400`,提示非法项 - `menuPermissionKeys` 为空数组 → 清空该公司全部菜单权限 - `company-list` 出参已增加 `menuPermissionKeys` ### 6.3 主库表 `fl_th_tenant_menu_permission` | 字段 | 说明 | |------|------| | `Id` | 主键,`YitIdHelper.NextId().ToString()` | | `TenantId` | 租户 Guid | | `PermissionKey` | SaaS 菜单 Key | | `CreationTime` | 创建时间 | 唯一逻辑:同一 `TenantId + PermissionKey` 不重复;写接口覆盖式先清后插。 --- ## 七、平台角色菜单分配 平台管理员可跨租户业务库,查看该公司角色并为角色绑定菜单(`Role` / `RoleMenu` / `Menu`)。 ### 7.1 租户角色列表 | 项 | 值 | |----|------| | 方法 / 路径 | `GET /api/app/th-multi-tenancy/company-roles?tenantId={guid}` | | 鉴权 | 需登录 | 出参示例: ```json [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "roleName": "管理员", "roleCode": "admin", "state": true, "menuIds": ["11111111-1111-1111-1111-111111111111"] } ] ``` ### 7.2 设置角色菜单(覆盖式) | 项 | 值 | |----|------| | 方法 / 路径 | `PUT /api/app/th-multi-tenancy/company-role-menus` | | 鉴权 | 需登录 | 入参: ```json { "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "roleId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "menuIds": ["11111111-1111-1111-1111-111111111111"] } ``` 逻辑:`CurrentTenant.Change(tenantId)` 后覆盖写 `RoleMenu`(对齐 `RbacRoleMenuAppService.Set`);校验角色/菜单属于该租户库且未删除。 --- ## 八、tenant-select 同步 `GET /api/app/th-multi-tenancy/tenant-select`(匿名,登录页): | 项 | 说明 | |----|------| | `SkipCount` | 由错误的 `1` 修正为 `0` | | 账号/密码 | 与公司列表相同:优先凭据表,否则 `AdminPassword` | | `id` | 仍不返回(保持 `ThTenantSelectDto`) | --- ## 九、Dashboard Last Updated(同日附带) | 项 | 说明 | |----|------| | 接口 | `GET /api/app/auth-session/my-menus` | | 问题 | `lastUpdated` 误用用户 `LastModificationTime`,相对时间与业务编辑不符 | | 修复 | 优先系统编辑 stamp → 业务表最近修改 → 用户资料时间 | | 过滤器 | `SystemEditStampGlobalFilter` 注册到 MVC;写接口成功刷新 stamp | | 范围 | 美国版 + 泰额版 `food-labeling-us` 同步 | --- ## 十、改动文件列表 ### 泰额版 — 公司账号密码 | 文件 | 变更 | |------|------| | `FoodLabeling.Th.Domain/Entities/ThTenantAdminCredentialEntity.cs` | 新建主库凭据实体 | | `FoodLabeling.Th.Application/MultiTenancy/TenantSelectCredentialCipher.cs` | 新增 `DecryptPassword` | | `FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyListInput.cs` | 新建 | | `FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyListItemDto.cs` | 新建(含 `menuPermissionKeys`) | | `FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyAdminInputVo.cs` | 新建 | | `FoodLabeling.Th.Application.Contracts/IServices/IThMultiTenancyAppService.cs` | 公司列表/改密/菜单/角色接口 | | `FoodLabeling.Th.Application/Services/ThMultiTenancyAppService.cs` | 全部实现 + 修复 tenant-select | ### 泰额版 — 菜单 / 角色分配 | 文件 | 变更 | |------|------| | `FoodLabeling.Th.Domain/Entities/ThTenantMenuPermissionEntity.cs` | 主库菜单权限表实体 | | `FoodLabeling.Th.Domain.Shared/Helpers/YitIdHelper.cs` | 雪花 Id(若新建) | | `FoodLabeling.Th.Application/MultiTenancy/ThSaasMenuPermissionCatalog.cs` | SaaS 菜单 Key 目录 | | `FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThSaasMenuPermissionTreeNodeDto.cs` | 权限树节点 | | `FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyMenusDto.cs` | 公司菜单出参 | | `FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyMenusInputVo.cs` | 设置公司菜单入参 | | `FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyRoleItemDto.cs` | 租户角色列表项 | | `FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyRoleMenusInputVo.cs` | 设置角色菜单入参 | ### 美国版 / 泰额版 — Last Updated | 文件 | 变更 | |------|------| | `FoodLabeling.Application/Services/AuthSessionAppService.cs` | `lastUpdated` 取值顺序调整 | | `FoodLabeling.Application/FoodLabelingApplicationModule.cs` | 注册 `SystemEditStampGlobalFilter` | | (既有)`SystemEditStampGlobalFilter.cs` / `SystemEditStampCache.cs` | 写成功刷新全局 stamp | --- ## 十一、前端对接说明(本次未改前端) - 平台公司列表请对接 **`company-list`**(含 `id`、加密凭据、`menuPermissionKeys`),勿仅依赖匿名 `tenant-select`(无 `id`)。 - 改密时密码须按第四节 AES 约定加密后再提交,**不要**直接传明文。 - `tenant-admin-modal`:对接 `PUT company-admin`。 - `tenant-menu-drawer`:树数据用 `menu-permission-tree`;回显/保存用 `company-menus`(字段 `menuPermissionKeys`)。 - 角色菜单:列表用 `company-roles`,保存用 `company-role-menus`(`menuIds` 为租户库 Menu.Id)。 --- ## 十二、自检清单 - [ ] 主库已建表 `fl_th_tenant_admin_credential`、`fl_th_tenant_menu_permission`(或 CodeFirst 已开启并重启成功) - [ ] `TenantSelectCrypto:SecretKey` 已配置且与前端一致 - [ ] 自定义 HttpGet/HttpPut 已带 `th-multi-tenancy/` 前缀(勿访问误路由 `/api/app/company-list` 等短路径) - [ ] `GET company-list` 返回 `loginAccount` / `password` / `passwordSalt` / `menuPermissionKeys` - [ ] `PUT company-admin` 改密后可用新密码登录该租户 - [ ] 再次拉列表能解出与改密后一致的明文 - [ ] `GET menu-permission-tree` 返回 SaaS 菜单树 - [ ] `PUT company-menus` 后 `GET company-menus` / `company-list` 一致 - [ ] `GET company-roles` 能列出租户角色及 `menuIds` - [ ] `PUT company-role-menus` 后该角色菜单绑定已覆盖更新 - [ ] `tenant-select` 分页从第一条开始(不再 Skip 掉首条) - [ ] (可选)改业务数据后 `my-menus.lastUpdated` 接近当前时间 --- ## 十三、主库建表 DDL(缺表报错时执行) 环境:`antis-foodlabeling-host`。若接口报 `Table '...fl_th_tenant_admin_credential' doesn't exist`,在主库执行: ```sql CREATE TABLE IF NOT EXISTS `fl_th_tenant_admin_credential` ( `TenantId` char(36) NOT NULL COMMENT '租户Id,对应 YiTenant.Id', `LoginAccount` varchar(64) NOT NULL DEFAULT '' COMMENT '管理员登录账号', `PasswordCipher` varchar(512) NOT NULL DEFAULT '' COMMENT 'AES可回显密文', `PasswordIv` varchar(64) NOT NULL DEFAULT '' COMMENT 'AES IV Base64', `LastModificationTime` datetime(6) NULL COMMENT '最后修改时间', PRIMARY KEY (`TenantId`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台主库-租户管理员可回显凭据'; CREATE TABLE IF NOT EXISTS `fl_th_tenant_menu_permission` ( `Id` varchar(32) NOT NULL COMMENT '雪花Id', `TenantId` char(36) NOT NULL COMMENT '租户Id', `PermissionKey` varchar(128) NOT NULL COMMENT 'SaaS菜单权限Key', `CreationTime` datetime(6) NOT NULL COMMENT '创建时间', PRIMARY KEY (`Id`), UNIQUE KEY `uk_fl_th_tenant_menu_permission_tenant_key` (`TenantId`, `PermissionKey`), KEY `IX_fl_th_tenant_menu_permission_TenantId` (`TenantId`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台主库-租户SaaS菜单权限'; ``` > 2026-07-21 已在 `antis-foodlabeling-host` 执行上述 DDL(两表已存在)。