00-流程总入口.md
PShang SaaS 代码级离线开发流程
本目录是 backend-laravel 的学习式实施手册。它不仅说明“写什么代码”,还必须保证开发顺序不会迫使后续功能反复修改早期代码。
一、唯一入口
开始任何功能前按顺序阅读:
文件名已经按照真实学习和实施顺序排列,可以直接从 00 向下执行。阶段依赖和按需能力仍以 02-基础设施依赖与实施顺序.md 为准。
二、执行原则
- 先固定架构与横切契约,再开发业务。
- 必须前置能力先形成最小闭环,不要求提前建设所有高级运维功能。
- 按需能力必须早于第一个依赖它的业务功能。
- 每次只执行一个完整功能闭环,失败时暂停后续步骤并解决当前问题。
- 当前代码、Migration、配置和测试高于文档中的历史描述。
- 文档与代码冲突时先记录差异、确认方案,再同步修改。
三、阶段路线
| 阶段 | 内容 | 主要文档 | 类型 |
|---|---|---|---|
| P00 | 工程宪法、分层、学习规则 | 01-04 |
必须前置 |
| P01 | 环境、依赖、配置和时区 | 05 |
必须前置 |
| P02 | 目录、分层和代码规范 | 01、backend-laravel/AGENTS.md |
必须前置 |
| P03 | API 响应、异常、验证、分页、文档 | 06 |
必须前置 |
| P04 | 中央/租户数据、事务、迁移 | 07 |
必须前置 |
| P05 | Request ID、日志、审计、安全 | 08 |
必须前置最小闭环 |
| P06 | 测试、Factory、Pint、CI | 09 |
必须前置最小闭环 |
| P07 | Redis、限流、锁、幂等 | 10 |
登录前完成限流,其余按需 |
| P08 | Event、Queue、Notification | 11、12 |
邀请/异步任务前完成 |
| P09 | Identity | 13-15 |
SaaS 核心 |
| P10 | Tenant | 16 |
SaaS 核心 |
| P11 | Authorization | 17-19 |
SaaS 核心 |
| P12 | 中央用户与 Operator | 20、21 |
平台管理 |
| P13 | 模块和按需平台能力 | 22-25 |
按需 |
| P14 | Chat、钉钉 | 26、27 |
业务模块 |
| P15 | 监控、备份、生产和私有化 | 28、29 |
生产前完成 |
四、完整功能输出格式
状态与依赖
-> 功能目的和边界
-> 影响文件、表、缓存、队列和外部服务
-> 完整文件或明确的局部修改
-> Migration / Model / Request / Service / Resource / Controller
-> Middleware / Policy / Permission / Route / Job / Listener
-> 自动测试
-> 单行 PowerShell 验证命令
-> ApiPost 完整请求
-> 成功与失败预期
-> 数据库、Redis、队列、Token、日志和 API 文档验证
-> 为什么这样设计
-> 完成清单
某层不需要时,必须说明原因。禁止只给方法体、只给命令或省略引入。
五、学习模式
- 助手先检查当前代码、依赖、Migration、路由和测试。
- 助手一次性提供一个功能的完整闭环和设计解释。
- 学习者亲自创建、修改和执行。
- 命令全部使用一行 PowerShell,并注明工作目录。
- 学习者返回输出后,助手先验证当前步骤,不直接跳到下一功能。
- 语法、局部测试、全量测试、Pint 和副作用验证完成后才能标记
[x]。
六、代码状态
已实现并验证:代码和自动测试都存在。已实现待对齐:已有代码需要按工程宪法审计。下一步实施:可进入当前学习步骤。规划草案:只能用于设计参考,执行前必须重新核对。
现阶段旧流程默认视为“已实现待对齐”或“规划草案”,不能因为文档中存在代码块就认定底座已经完成。
七、当前纠偏原则
当前仓库已经实现部分身份、租户、权限和 Operator 功能。接下来先对齐 P00-P07 的基础契约并完成回归,再继续新增业务。纠偏过程使用新 Migration 和兼容升级,不修改已经执行过的 Migration 来掩盖架构变化。
01-工程宪法与分层规范.md
工程宪法与分层规范
本文是
backend-laravel开发的第一优先级规则。任何身份、租户、权限、Operator 或业务模块流程都必须先遵守本文,再使用对应功能文档。
1. 为什么必须先建立工程宪法
如果先写业务、后补响应、事务、日志、队列和测试规则,同一个功能会被反复重构。阶段 0 的目标不是提前实现所有基础设施,而是先固定不会随着业务变化而频繁改变的边界和契约。
HTTP Request
-> Middleware(请求上下文、认证、租户、权限)
-> FormRequest(输入验证和标准化)
-> Controller(HTTP 编排)
-> Service / Action(业务用例和事务边界)
-> Model / Repository / 外部 Client(数据和外部能力)
-> JsonResource(输出字段)
-> ApiResponse(统一响应信封)
2. 代码区域和依赖方向
| 区域 | 职责 | 可以依赖 | 禁止依赖 |
|---|---|---|---|
app/Platform |
SaaS 可交付核心:身份、租户、权限、通用平台能力 | Laravel、稳定 Composer 包、app/Support |
app/Operator、具体业务模块 |
app/Operator |
云端运营能力:运营人员、套餐、订阅、客户管理 | app/Platform、app/Support |
具体业务模块的内部实现 |
app/Modules |
Chat、钉钉等可裁剪业务模块 | app/Platform、模块自己的代码 |
app/Operator、其他模块内部实现 |
app/Support |
不含具体业务语义的技术契约和工具 | Laravel、通用包 | Identity、Tenancy、Operator、Modules |
依赖必须从外层业务指向稳定内核,不能让 Platform 反向依赖 Operator。跨模块协作优先使用公开 Service 契约、事件或 HTTP API,不直接访问其他模块内部模型。
3. 数据归属
开始写 Migration 和 Model 前必须先回答数据归属:
| 数据 | 归属 | 示例 |
|---|---|---|
| 全平台共享身份和控制面 | 中央数据库 | User、Tenant、Membership、OAuth、Role、Permission、Operator、Subscription |
| 租户独占业务数据 | 租户数据库 | Department、ChatConversation、ChatMessage、TenantConfig |
| 外部系统权威数据 | 外部服务 | AI 推理状态、钉钉组织原始数据、支付渠道流水 |
中央模型必须显式使用 CentralConnection。租户模型必须显式使用租户连接。租户表保存中央 user_id 时不建立 PostgreSQL 跨数据库外键,由 Service 验证成员关系。
4. 各层职责
4.1 FormRequest
- 负责输入格式、必填、长度、枚举、字段级校验和通用标准化。
- 不负责数据库事务、发送通知、写审计日志或改变业务状态。
- API Request 统一继承项目的
ApiRequest;框架页面可直接继承 Fortify 对应请求体系。
4.2 Controller
- 接收已验证的 Request、路由模型和当前用户。
- 调用一个清晰的 Service/Action 用例。
- 使用 Resource 和 ApiResponse 组织 HTTP 响应。
- 不直接开启事务,不散落审计写入,不编写复杂查询,不调用多个外部服务完成长流程。
4.3 Service 与 Action
Service:一个领域内可复用的业务能力,允许被多个入口调用。Action:边界清晰、通常只有一个公开execute()/handle()的单用例编排;Fortify 官方扩展点继续使用 Action。- 不因“只有一个调用者”机械创建 DTO、接口或 Repository;只有跨层数据结构复杂、多个调用者共享、需要稳定契约或多实现替换时才新增抽象。
- 事务边界由执行写操作的 Service/Action 负责。
4.4 Model
- 描述表、连接、主键、类型转换、关系和局部查询作用域。
- 不承担 HTTP 响应、权限提示和跨服务编排。
- 简单单表行为可以放在 Model;跨多个聚合、通知、审计和外部调用进入 Service/Action。
4.5 Enum
- 用于有限且稳定的业务状态、权限名、事件结果和错误原因。
- 数据库存储枚举值,不存中文显示文字。
- 不为只有两个临时分支、没有业务含义的条件创建枚举。
4.6 JsonResource
- 明确允许返回给前端的字段,避免返回完整 Model。
- 统一处理嵌套关系和时间输出。
- 不执行写操作,不临时查询大量关系;Controller/Service 应提前 eager load。
4.7 Event、Listener、Job、Notification
- Event 表达已经发生的事实,不伪装成同步命令。
- Listener 处理解耦副作用;需要重试、耗时或依赖外部系统时进入 Queue Job。
- 数据库提交后才能执行的副作用必须使用
afterCommit或 Outbox,不能让队列先读取到未提交数据。 - 核心状态变更和必须原子成功的审计不能随意异步化。
5. 横切能力唯一入口
| 能力 | 统一入口 | 禁止做法 |
|---|---|---|
| 成功/错误响应 | ApiResponse + ErrorCode |
Controller 手写不同 JSON 结构和魔法错误码 |
| 参数验证 | ApiRequest / 具体 FormRequest |
Controller 中散落 validate() |
| 事务 | Service/Action 从参与写入的模型取得连接后 transaction() |
Controller 开事务;默认连接包裹租户写入 |
| 审计 | 平台级 Audit Service | Controller 直接散落 activity() |
| 普通日志 | Laravel Log + Request Context + 脱敏 Processor | 记录完整 Request、Token、密码和 Secret |
| 缓存 Key | 统一 Key Builder | 手写无租户前缀的 Redis Key |
| 限流 | 命名 RateLimiter | 每条路由随意写数字且没有统一错误响应 |
| 权限 | 权限枚举/常量 + Middleware/Policy | Controller 散落权限字符串 |
| 时间 | Asia/Shanghai + ISO 8601 输出 |
每个 Resource 自行拼时区字符串 |
6. 事务规则
标准单库事务写法:
$connection = $model->getConnection();
return $connection->transaction(function () use ($model, $data): Model {
$model->fill($data)->save();
return $model->refresh();
});
规则:
- 使用真正参与写入的中央或租户模型取得连接,不依赖当前默认连接。
- 一个事务只保证一个数据库连接内的原子性。
- 跨中央库、租户库和外部 API 使用状态机、幂等、补偿或 Outbox,不使用一个伪跨库事务。
- 只读查询不为了形式统一而开启事务。
7. 审计与日志边界
- 普通日志用于诊断系统运行,例如外部请求超时、队列失败。
- 审计日志用于回答谁在何时对什么资源执行了什么业务操作,以及结果是什么。
- 登录、密码、MFA、账号状态和强制下线属于中央安全审计。
- 租户业务操作按数据归属写中央审计或租户审计。
- 审计内容只记录标识、允许的业务字段和脱敏前后值,不记录 Token、密码、验证码和 Secret。
8. 文件和方法注释
业务 PHP 文件必须保留:
<?php
/**
* @description 文件的单一职责
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
公开方法必须说明用途、参数和返回值。复杂安全、事务、租户切换和失败恢复逻辑应解释“为什么”,不为明显赋值添加无意义注释。
9. 功能开始前检查
- [ ] 已判断属于 Platform、Operator、Modules 还是外部服务。
- [ ] 已判断数据属于中央库、租户库还是外部服务。
- [ ] 已判断 Cloud、Private 和模块裁剪影响。
- [ ] 已明确认证、成员、权限、Policy 和资源归属顺序。
- [ ] 已检查 Laravel 内置能力和现有 Composer 包。
- [ ] 已明确事务、审计、队列、缓存、通知和失败路径。
- [ ] 已明确成功、验证失败、未认证、无权限、不存在和冲突测试。
未完成以上判断,不得先创建 Controller 或数据表再反向解释架构。
02-基础设施依赖与实施顺序.md
基础设施依赖与实施顺序
1. 目的
本文说明连续编号背后的阶段依赖。文件已经按真实执行顺序编号,可以从 00 向下阅读;标记为按需的能力,只在第一个依赖它的业务功能前实施。
2. 三类能力
2.1 必须前置
任何正式业务功能开始前必须完成:
- 环境和依赖锁定。
- 工程目录、依赖方向和分层职责。
- 统一响应、错误码、异常和 FormRequest 基类。
- 中央/租户连接、Migration、Model 和事务规范。
- Request ID、日志脱敏和统一审计入口。
- 最小测试环境、Factory、Pint 和 CI 质量门禁。
- 认证、租户和权限中间件的执行顺序。
2.2 按使用场景前置
这些能力不需要在空项目一次性全部实现,但必须在第一个依赖它的功能之前完成:
| 能力 | 必须早于 |
|---|---|
| Redis Key、限流 | 登录、验证码、租户缓存 |
| 锁和幂等 | 支付回调、Webhook、重复提交、租户开通 |
| Event/Queue/afterCommit | 邀请邮件、外部同步、长任务 |
| Notification | 邀请、密码安全通知、站内信 |
| 文件基础设施 | 知识库上传、头像、附件 |
| HTTP Client/Outbox | FastAPI、Go、钉钉、支付、Webhook |
| Feature Flag | Edition 和灰度功能 |
2.3 生产前完成
Horizon 运维页、指标监控、备份恢复、Nginx、制品裁剪、发布回滚等可以在核心业务形成后建设,但上线前必须通过验收。
3. 权威执行路线
P00 工程宪法和学习规则
-> P01 环境、依赖和配置
-> P02 目录、分层和代码规范
-> P03 API 契约、异常、验证和文档
-> P04 中央/租户数据、事务和迁移
-> P05 Request ID、日志、审计和安全
-> P06 测试、Factory、Pint 和 CI
-> P07 Redis Key、限流、锁和基础幂等
-> P08 Event、Queue、afterCommit 和 Notification 基础
-> P09 Identity 核心
-> P10 Tenant 核心
-> P11 Authorization Scope 和权限核心
-> P12 Operator 和中央用户管理
-> P13 模块、Edition 和按需平台能力
-> P14 Chat、钉钉等业务模块
-> P15 生产化和私有化交付
4. 现有文件映射
| 执行阶段 | 主要复用文档 | 执行说明 |
|---|---|---|
| P00 | 01-04 |
第一批阅读并固定规则 |
| P01 | 05-Laravel环境配置和启动.md |
必须前置 |
| P02 | 01-工程宪法与分层规范.md、backend-laravel/AGENTS.md |
必须前置 |
| P03 | 06-统一响应异常验证和API版本.md |
必须前置 |
| P04 | 07-数据库迁移模型事务和连接.md |
必须前置 |
| P05 | 08-请求ID日志审计和安全.md |
必须前置最小闭环 |
| P06 | 09-测试Factory和CI.md |
必须前置最小闭环 |
| P07 | 10-Redis缓存锁限流和幂等.md |
登录前完成限流;锁和幂等按需 |
| P08 | 11-队列事件Horizon和调度.md、12-通知中心邮件和站内信.md |
邀请前完成最小闭环 |
| P09 | 13-15 |
先登录安全,再邀请和 MFA |
| P10 | 16 |
Tenant 生命周期早于租户业务 |
| P11 | 17-19 |
权限作用域必须早于角色分配 |
| P12 | 20、21 |
先中央用户,再 Operator 运营业务 |
| P13 | 22-25 |
按模块依赖启用 |
| P14 | 26、27 |
业务模块 |
| P15 | 28、29 |
上线前完成 |
5. 依赖闸门
进入下一阶段前,必须满足:
- 文档标记为“当前实现”的代码确实存在。
- Migration 状态与文档一致。
- 局部测试和全量测试通过。
- Pint 检查通过。
- 对应数据库、Redis、队列或日志副作用经过验证。
- 当前阶段的关键失败路径有自动测试。
只有文档存在而代码、测试或配置未完成,不得把阶段标记为完成。
6. 当前项目纠偏顺序
当前项目已经先实现了一部分 Identity、Tenant、Authorization 和 Operator。为了避免继续返工,应暂停新增业务,按以下顺序补齐基线:
- 对齐 P02 分层和目录规则。
- 对齐 P03
ApiRequest、异常和分页契约。 - 对齐 P04 连接与事务规则。
- 对齐 P05 Request ID、统一审计和日志脱敏。
- 对齐 P06 测试环境和 CI。
- 对齐 P07/P08 已经被登录、邀请和通知使用的最小能力。
- 回归 Identity、Tenant、Authorization、Operator 全部测试。
- 基线通过后才继续新增功能。
03-代码引导与学习验收规范.md
代码引导与学习验收规范
1. 目的
解决文档代码只有方法体、缺少 use、不知道放在哪个文件、复制后无法运行的问题。所有流程文档必须使用“完整文件”或“局部修改”两种格式之一。
2. 完整文件格式
适用于新建文件。代码块前必须写绝对项目相对路径,并提供可以直接保存的完整内容:
文件:app/Platform/Example/Services/ExampleService.php
操作:新建完整文件
完整 PHP 文件必须包含:
<?php。- 项目文件头。
namespace。- 完整
use。 - 类声明。
- 构造函数和公开方法注释。
- 完整方法体。
禁止使用 ...、省略、未定义变量或没有来源的辅助方法代替正式代码。
3. 局部修改格式
适用于修改已有大文件。必须同时说明:
文件:app/Platform/Identity/Services/AuthService.php
操作:局部修改
位置:AuthService 类内,revokeAllSessions() 方法之后
新增引入:use App\Platform\Audit\Services\AuditService;
替换范围:完整替换 login() 方法
局部代码块必须满足:
- 给出完整方法或完整属性声明,不能只给中间几行。
- 列出所有新增
use;已有引入要明确写“无需新增”。 - 说明是新增、替换还是删除。
- 如果依赖前一步创建的类,必须给出文件链接和依赖名称。
- 修改文件后更新
@lastModified,不得修改@createTime。
4. Migration 引导
每个 Migration 必须包含:
- 创建命令,且 PowerShell 命令保持单行。
- 中央库或租户库归属。
- 完整
up()和down()。 - 索引、唯一约束、外键或为什么不能有外键。
- 执行前数据检查。
- 执行、状态检查和数据库验证命令。
- 已执行 Migration 不回改,结构调整创建新 Migration。
5. API 功能闭环
每个 API 必须依次提供:
- 功能目的和边界。
- 前置依赖与受影响文件。
- Migration/Model(需要时)。
- FormRequest。
- Service/Action。
- Resource。
- Controller。
- Middleware/Policy/Permission。
- Route 和路由注释。
- 自动测试。
- Scramble 文档检查。
- ApiPost 完整方法、URL、Header、Query、Path、Body。
- 成功、401、403、404、409/422 等预期。
- 数据库、Redis、队列、Token 和审计验证。
某层不需要时必须写明“不需要及原因”,不能直接跳过。
6. 命令格式
- 所有用户执行的 PowerShell 命令必须单行。
- 命令必须注明工作目录。
- 不使用依赖
rg的命令,除非先确认已安装;默认提供 PowerShellGet-ChildItem | Select-String版本。 - 数据库命令必须明确数据库名称,危险删除操作必须单独确认。
- 测试顺序为语法检查、配置/路由检查、局部测试、全量测试、Pint。
7. 解释要求
每个步骤都必须回答:
- 做什么?
- 为什么现在做?
- 为后续建立什么能力?
- 如何证明成功?
代码解释必须说明 Request、Controller、Service、Model、表、连接、事务、事件和中间件之间的调用关系,不能只解释 PHP 语法。
8. 文档代码状态
每个功能顶部必须标记一种状态:
已实现并验证:代码存在且自动测试通过。已实现待对齐:代码存在,但不完全符合当前工程宪法。下一步实施:已经核对当前版本和依赖,可进入学习执行。规划草案:尚未核对执行时的代码和包版本,禁止直接复制。
文档中的规划代码不能伪装成当前代码。进入该功能前必须重新检查仓库和 Composer 锁定版本。
9. 完成标准
- [ ] 新文件代码可直接保存并通过
php -l。 - [ ] 局部修改列出了路径、位置、引入和替换范围。
- [ ] Route、Request、Response 和权限契约完整。
- [ ] 数据库和外部副作用可验证。
- [ ] 失败路径有自动测试。
- [ ] 全量测试和 Pint 通过。
- [ ] 文档状态和执行计划已同步。
04-功能闭环代码模板.md
功能闭环代码模板
使用本模板前必须先阅读
01-工程宪法与分层规范.md、02-基础设施依赖与实施顺序.md和03-代码引导与学习验收规范.md。每次只复制本模板完成一个独立业务用例。
0. 状态与依赖
功能状态:下一步实施 / 已实现待对齐 / 已实现并验证 / 规划草案
所属区域:Platform / Operator / Modules / External Service
数据归属:中央数据库 / 租户数据库 / 外部服务 / 无持久化
Edition:Cloud / Private / 指定模块
前置流程:Pxx、具体文件和已完成能力
列出受影响的表、缓存 Key、锁、队列、Token、通知、审计和外部服务。没有影响也要写“无”。
1. 功能目的和边界
说明解决什么问题、谁可以调用、哪些内容明确不属于本功能,以及为什么现在实施。
2. 请求生命周期
Route
-> Middleware
-> FormRequest
-> Controller
-> Service/Action
-> Model/Client
-> Event/Audit/Queue
-> Resource
-> ApiResponse
标出认证、租户、权限、Policy、事务和审计分别在哪一层发生。
3. 文件清单
| 操作 | 文件 | 职责 |
|---|---|---|
| 新建/修改 | 完整路径 |
单一职责 |
4. Migration(需要时)
必须给出单行创建命令、数据库归属、完整文件、执行前检查、迁移和回滚策略。无 Migration 时说明原因。
5. Model(需要时)
新文件必须提供完整文件头、命名空间、引入、连接、主键、fillable/casts、关系和 Factory 绑定。无 Model 时说明原因。
6. Enum / DTO / Exception(需要时)
说明为什么需要独立类型。只有稳定有限状态才使用 Enum;只有跨层复杂数据契约或多调用者共享时才创建 DTO,避免一次性包装类。
7. FormRequest(有输入时)
文件示例:app/Platform/Identity/Http/Requests/XxxRequest.php
必须提供完整文件或明确局部修改,包含规则、中文消息、标准化和权限边界。没有请求参数时明确写“不需要 Body,也不需要 FormRequest”。
8. Service / Action
说明事务连接来源、业务规则、异常、审计和副作用。Controller 不开启事务。跨数据库和外部服务流程必须说明幂等、状态和失败恢复。
9. Event / Listener / Job / Notification(需要时)
说明同步或异步、是否 afterCommit、队列名、重试、退避、超时、唯一性、租户上下文和失败处理。不需要时说明原因。
10. JsonResource(有模型输出时)
只输出允许字段,说明时间格式和嵌套关系。无模型输出时说明为什么直接返回标量或固定结构。
11. Controller
只完成 HTTP 编排:获取当前用户和路由模型、调用 Service、包装 Resource 和返回 ApiResponse。必须提供完整方法 PHPDoc。
12. Middleware / Policy / Permission
按顺序说明:
认证 -> 租户初始化 -> 活跃成员 -> Permission -> Policy/资源归属
列出权限枚举/常量、Seeder 和缓存刷新方式。
13. Route
提供路由文件、分组位置、完整路由注释、HTTP 方法、完整 URL、中间件、约束和路由名。静态路由必须放在可能冲突的动态参数路由前。
14. 自动测试
至少覆盖:
- 成功路径。
- 参数验证失败。
- 未认证。
- 无权限。
- 资源不存在或不属于当前租户。
- 状态冲突/重复请求。
- 事务回滚和关键副作用。
明确哪些测试可使用 Fake,哪些必须真实执行数据库、Token、Job 或缓存。
15. 检查命令
工作目录:F:\CodeWorkspace\project-pshang-app\backend-laravel
php -l app\完整路径\File.php
php artisan route:list --path=目标路径 -v
php artisan test --filter=目标测试类
php artisan test
.\vendor\bin\pint --test
16. ApiPost
Method:GET/POST/PATCH/DELETE
URL:http://demo.localhost:8000/api/完整路径
Header:
Accept: application/json
Authorization: Bearer <Access Token>
Query:无 / 完整参数
Path:无 / 完整参数
Body:无 / raw JSON 完整内容
不得只给 URL 片段。Token 使用占位符,不把真实 Token 写进文档。
17. 预期结果
分别给出成功、401、403、404、409/422 和重要业务失败响应,OAuth 标准端点保留 OAuth 错误结构。
18. 副作用验证
根据功能检查数据库行、Redis Key、锁、队列、通知、Token revoked 状态、审计记录、Request ID 和 Scramble 文档。
19. 为什么这样设计
回答:为什么属于该区域和数据库、为什么需要或不需要 Service/DTO/Event/Job、事务边界在哪里、如何避免跨租户和重复执行。
20. 完成清单
- [ ] 代码路径、完整引入和注释齐全。
- [ ] Migration、配置和 Seeder 可重复执行。
- [ ] API 契约和权限边界明确。
- [ ] 局部和全量测试通过。
- [ ] Pint 通过。
- [ ] ApiPost 和副作用验证完成。
- [ ] Scramble 文档正确。
- [ ] 执行计划和文档状态已同步。
05-Laravel环境配置和启动.md
Laravel 环境、依赖和配置基线
0. 状态与依赖
执行阶段:P01
功能状态:已实现待复核
所属区域:工程基础设施
数据归属:无
前置流程:P00 工程宪法和学习规则
本流程必须在创建业务 Migration、Model 和 Controller 前完成。它固定 PHP、Laravel、扩展、数据库、Redis、时区和 Secret 的运行基础。
1. 当前锁定版本
以 composer.lock 和实际命令输出为准,不根据文档猜版本。当前项目基线:
PHP:8.4.x
Laravel Framework:13.x
Composer:2.x
PostgreSQL:17.x
Redis Server:本地受控实例
Node.js:仅前端和 Laravel 资源构建需要
工作目录:F:\CodeWorkspace\project-pshang-app\backend-laravel
php -v; composer --version; php artisan --version; psql --version; redis-cli --version
2. PHP 扩展
php -m | Select-String 'bcmath|curl|fileinfo|mbstring|openssl|pdo_pgsql|pgsql|redis|sodium|tokenizer|xml|ctype|json'
Passport 必须启用 sodium。PostgreSQL 必须启用 pdo_pgsql 和 pgsql。Redis 扩展用于 Laravel Redis 客户端。
确认 CLI 使用的配置文件:
php --ini
Composer 必须和 php -v 使用同一个 PHP:
where.exe php; where.exe composer; composer diagnose
3. 依赖安装和安全检查
已有项目只安装锁定依赖,不重新执行 create-project:
composer install --prefer-dist
检查已安装包和安全公告:
composer show --direct; composer audit
新增 Composer 包前必须检查 Laravel 13、PHP 8.4、PostgreSQL、许可证、维护状态以及中央/租户 Migration 影响,并先获得确认。
4. 环境文件边界
.env 只保存本机 Secret,不提交 Git。.env.example 只保存变量名和无敏感示例。
APP_NAME=PShangSaaS
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://127.0.0.1:8000
APP_TIMEZONE=Asia/Shanghai
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=ps_central
DB_USERNAME=ps_sass_user
DB_PASSWORD=
CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_DB=0
REDIS_CACHE_DB=1
MAIL_MAILER=smtp
MAIL_HOST=smtp.example.com
MAIL_PORT=465
MAIL_USERNAME=no-reply@example.com
MAIL_PASSWORD=
MAIL_ENCRYPTION=ssl
MAIL_FROM_ADDRESS=no-reply@example.com
MAIL_FROM_NAME="${APP_NAME}"
不得把真实数据库密码、SMTP 授权码、OAuth Client Secret、Passport 私钥和 Token 写入文档。
5. 北京时间基线
文件:config/app.php
操作:确认配置项,不硬编码到每个 Resource。
'timezone' => env('APP_TIMEZONE', 'Asia/Shanghai'),
数据库统一保存带时区时间或 UTC 语义;API 通过全局序列化输出 +08:00。业务代码使用 now(),不要手写 date() 或到处调用 setTimezone()。
修改配置后:
php artisan config:clear; php artisan config:show app.timezone
预期输出包含 Asia/Shanghai。
6. PostgreSQL 和 Redis 连通性
Test-NetConnection 127.0.0.1 -Port 5432; Test-NetConnection 127.0.0.1 -Port 6379
psql -h 127.0.0.1 -p 5432 -U ps_sass_user -d ps_central -c "SELECT current_database(), current_user, now();"
redis-cli -h 127.0.0.1 -p 6379 ping
Redis 预期返回 PONG。不要使用 KEYS * 检查生产 Redis,开发阶段使用 SCAN。
7. Laravel 配置验收
php artisan about; php artisan config:show database.default; php artisan config:show cache.default; php artisan config:show queue.default
检查中央 Migration:
php artisan migrate:status
只有数据库名称、用户和密码确认无误后才能执行:
php artisan migrate
8. Secret 和密钥
首次安装:
php artisan key:generate
Passport 密钥由正式命令生成并保存在受控路径,不能提交版本库:
php artisan passport:keys
如果密钥已经存在,不要重复生成,否则现有 Access Token 会全部失效。
9. 邮件验收
先确认 Laravel 实际 Mailer:
php artisan config:clear; php artisan config:show mail.default
开发环境可以先用 log 或 array Mailer 验证模板;SMTP 验收必须确认服务商要求的是账号密码还是独立授权码。不得把授权码贴入聊天、日志和测试代码。
10. 启动命令
API:
php artisan serve --host=127.0.0.1 --port=8000
队列 Worker 在 P07 完成后使用:
php artisan queue:work --tries=3 --timeout=90
不要在队列契约尚未完成时把 queue:work 当作后台常驻服务。
11. 验收
php artisan route:list --path=up; Invoke-RestMethod -Method Get -Uri 'http://127.0.0.1:8000/up'
- [ ] PHP、Composer 和扩展版本正确。
- [ ] Composer 使用正确 PHP。
- [ ] PostgreSQL 和 Redis 连通。
- [ ]
APP_TIMEZONE=Asia/Shanghai生效。 - [ ]
.env不进入版本库。 - [ ] Migration 状态可读取。
- [ ] Laravel
/up可访问。 - [ ]
composer audit没有未处理的高风险公告。
06-统一响应异常验证和API版本.md
统一响应、异常、验证、分页和 API 文档
0. 状态与依赖
执行阶段:P03
功能状态:已实现待对齐
所属区域:app/Support + Laravel bootstrap
数据归属:无
前置流程:P00、P01、P02
当前项目已有 ErrorCode 和 ApiResponse,但 bootstrap/app.php 仍直接拼 JSON,API Request 也全部直接继承 FormRequest。本流程用于建立一次性统一契约,后续业务不得重复实现。
1. 响应契约
平台业务 API:
{
"code": 0,
"message": "操作成功",
"data": null
}
HTTP 状态表达协议结果,code 表达稳定业务错误。OAuth 标准端点 /oauth/token 保留 RFC/OAuth 错误结构,不强行包装。
2. ErrorCode
文件:app/Enums/ErrorCode.php
操作:完整文件。新增错误码必须有唯一值和默认消息,禁止 Controller 使用魔法数字。
<?php
/**
* @description API 统一业务错误码
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-02 12:30:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Enums;
enum ErrorCode: int
{
case SUCCESS = 0;
case BUSINESS_ERROR = 40000;
case UNAUTHENTICATED = 40100;
case TOKEN_EXPIRED = 40101;
case REFRESH_TOKEN_INVALID = 40102;
case CAPTCHA_INVALID = 40110;
case ACCOUNT_LOCKED = 40120;
case ACCOUNT_DISABLED = 40121;
case FORBIDDEN = 40300;
case NOT_FOUND = 40400;
case VALIDATION_ERROR = 42200;
case TOO_MANY_ATTEMPTS = 42900;
case SERVER_ERROR = 50000;
/** 获取错误码默认中文消息。 */
public function message(): string
{
return match ($this) {
self::SUCCESS => '操作成功',
self::BUSINESS_ERROR => '业务处理失败',
self::UNAUTHENTICATED => '未登录或登录已过期',
self::TOKEN_EXPIRED => 'Token 已过期',
self::REFRESH_TOKEN_INVALID => 'Refresh Token 无效',
self::CAPTCHA_INVALID => '验证码错误',
self::ACCOUNT_LOCKED => '账号已被锁定',
self::ACCOUNT_DISABLED => '账号已被禁用',
self::FORBIDDEN => '无权限执行此操作',
self::NOT_FOUND => '资源不存在',
self::VALIDATION_ERROR => '参数验证失败',
self::TOO_MANY_ATTEMPTS => '请求过于频繁,请稍后再试',
self::SERVER_ERROR => '系统内部错误',
};
}
}
3. ApiResponse
文件:app/Support/ApiResponse.php
操作:完整文件。
<?php
/**
* @description API 统一响应封装
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-02 12:29:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Support;
use App\Enums\ErrorCode;
use Illuminate\Http\JsonResponse;
final class ApiResponse
{
/** 返回统一成功响应。 */
public static function success(
mixed $data = null,
string $message = '操作成功',
int $status = 200,
): JsonResponse {
return response()->json([
'code' => ErrorCode::SUCCESS->value,
'message' => $message,
'data' => $data,
], $status);
}
/** 返回统一错误响应。 */
public static function error(
ErrorCode $code,
?string $message = null,
mixed $data = null,
int $status = 400,
): JsonResponse {
return response()->json([
'code' => $code->value,
'message' => $message ?? $code->message(),
'data' => $data,
], $status);
}
}
4. ApiRequest 基类
文件:app/Support/Http/Requests/ApiRequest.php
操作:新建完整文件。只有所有 API 都一致的标准化才放入基类,业务字段规则仍由具体 Request 定义。
<?php
/**
* @description 平台 API 参数请求基类
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Support\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
abstract class ApiRequest extends FormRequest
{
/** 路由中间件和 Policy 负责授权,具体 Request 可按需覆盖。 */
public function authorize(): bool
{
return true;
}
}
已有 API Request 应逐步改为:
use App\Support\Http\Requests\ApiRequest;
-use Illuminate\Foundation\Http\FormRequest;
-final class LoginRequest extends FormRequest
+final class LoginRequest extends ApiRequest
这是局部修改:替换父类并删除不再使用的 FormRequest 引入。不要一次批量修改后不跑测试。
5. PaginationRequest
文件:app/Support/Http/Requests/PaginationRequest.php
操作:新建完整文件。
<?php
/**
* @description API 列表分页和排序基础请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Support\Http\Requests;
class PaginationRequest extends ApiRequest
{
/** 获取通用分页参数规则。 */
public function rules(): array
{
return [
'page' => ['sometimes', 'integer', 'min:1'],
'page_size' => ['sometimes', 'integer', 'min:1', 'max:100'],
'keyword' => ['sometimes', 'nullable', 'string', 'max:255'],
'sort_field' => ['sometimes', 'nullable', 'string', 'max:64'],
'sort_order' => ['sometimes', 'nullable', 'in:asc,desc'],
];
}
/** 获取经过边界限制的每页数量。 */
public function pageSize(): int
{
return (int) $this->validated('page_size', 20);
}
}
排序字段必须由具体 Service 使用白名单映射,不能直接把 sort_field 交给 orderBy()。
6. 全局异常处理
文件:bootstrap/app.php
操作:局部修改。
新增引入:
use App\Support\ApiResponse;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
在 withExceptions() 中统一使用 ApiResponse。下面是完整闭包,替换现有异常闭包;Throwable 位于全局命名空间,不添加无效 use Throwable;:
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request, Throwable $exception): bool =>
$request->is('api/*') || $request->expectsJson(),
);
$exceptions->render(
fn (AuthenticationException $exception, Request $request) =>
$request->is('api/*')
? ApiResponse::error(
ErrorCode::UNAUTHENTICATED,
status: 401,
)
: null,
);
$exceptions->render(
fn (AuthorizationException|UnauthorizedException $exception, Request $request) =>
$request->is('api/*')
? ApiResponse::error(
ErrorCode::FORBIDDEN,
$exception->getMessage() ?: null,
status: 403,
)
: null,
);
$exceptions->render(
fn (ModelNotFoundException|NotFoundHttpException $exception, Request $request) =>
$request->is('api/*')
? ApiResponse::error(
ErrorCode::NOT_FOUND,
status: 404,
)
: null,
);
$exceptions->render(
fn (ValidationException $exception, Request $request) =>
$request->is('api/*')
? ApiResponse::error(
ErrorCode::VALIDATION_ERROR,
data: $exception->errors(),
status: 422,
)
: null,
);
$exceptions->render(function (Throwable $exception, Request $request) {
if (! $request->is('api/*') || config('app.debug')) {
return null;
}
report($exception);
return ApiResponse::error(
ErrorCode::SERVER_ERROR,
status: 500,
);
});
})
生产 500 响应不得包含 SQL、路径和堆栈;真实异常通过日志和 Request ID 定位。
7. Resource 和分页结构
Resource 负责字段,分页响应统一使用:
return ApiResponse::success(data: [
'items' => UserResource::collection($paginator->items()),
'pagination' => [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
],
]);
项目已经采用 items + pagination,后续接口不得改成另一种字段命名。
8. API 版本策略
当前兼容路径保留 /api/admin、/api/operator 和租户 API。只有破坏性变更才新增 /api/v2/...;不要为尚无第二版的接口提前复制全部路由。
9. Scramble
GET http://127.0.0.1:8000/docs/api
GET http://127.0.0.1:8000/docs/api.json
文档来源是 Route、FormRequest、Resource、返回类型和 PHPDoc。每个 API 完成后必须检查 Bearer 认证、请求字段、成功响应和主要错误。
10. 自动测试
创建 tests/Feature/Support/ApiContractTest.php,至少验证:
- 不存在的 API 返回
40400。 - 未认证返回
40100。 - 验证失败返回
42200和字段错误。 - 无权限返回
40300。 - 非 debug 的 500 不泄露异常。
- 分页最大
100。 - 非白名单排序字段不会进入 SQL。
11. 验证命令
工作目录:F:\CodeWorkspace\project-pshang-app\backend-laravel
php -l app\Enums\ErrorCode.php; php -l app\Support\ApiResponse.php; php -l app\Support\Http\Requests\ApiRequest.php; php -l app\Support\Http\Requests\PaginationRequest.php; php -l bootstrap\app.php
php artisan test --filter=ApiContractTest; php artisan test; .\vendor\bin\pint --test
12. ApiPost 验收
Method:GET
URL:http://127.0.0.1:8000/api/not-exists
Header:Accept: application/json
Body:无
预期:HTTP 404,code=40400
Method:GET
URL:http://127.0.0.1:8000/api/admin/auth/me
Header:Accept: application/json
Body:无
预期:HTTP 401,code=40100
13. 完成标准
- [ ] 所有业务响应通过
ApiResponse。 - [ ] 全局异常不再重复拼 JSON。
- [ ] API Request 统一继承
ApiRequest。 - [ ] 分页字段固定为
items + pagination。 - [ ] OAuth 标准端点未被错误包装。
- [ ] Scramble 可生成。
- [ ] 契约测试、全量测试和 Pint 通过。
07-数据库迁移模型事务和连接.md
数据库、Migration、Model、事务和连接基线
0. 状态与依赖
执行阶段:P04
功能状态:已实现待对齐
所属区域:Platform 数据基础
数据归属:中央数据库 + 租户数据库
前置流程:P00-P03
本流程必须在正式业务表之前完成。目标是让任何租户请求都不会把 User、OAuth、Permission 或 Operator 错查到租户数据库,并让所有写操作使用一致的事务边界。
1. 数据分类决策
创建表前填写:
表名:
业务所有者:Platform / Operator / Module
数据库:central / tenant / external
Edition:Cloud / Private / Module
主键:bigint / ULID / UUID
唯一约束:
索引:
外键:
删除策略:物理删除 / SoftDeletes / 归档
事务参与者:
没有完成归属判断,不创建 Migration。
2. 中央 Model 完整模板
文件:app/Platform/Example/Models/CentralExample.php
操作:新建完整文件。
<?php
/**
* @description 中央示例数据模型
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Example\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Concerns\CentralConnection;
final class CentralExample extends Model
{
use CentralConnection;
use HasFactory;
/** @var list<string> */
protected $fillable = [
'name',
'status',
'settings',
];
/** 获取字段类型转换。 */
protected function casts(): array
{
return [
'settings' => 'array',
];
}
}
适用:User、Tenant、TenantMembership、OAuth、Authorization Scope、Role、Permission、Operator、Plan 和 Subscription。
3. 租户 Model 完整模板
文件:app/Modules/Example/Models/TenantExample.php
操作:新建完整文件。
<?php
/**
* @description 租户示例业务模型
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Modules\Example\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Concerns\TenantConnection;
final class TenantExample extends Model
{
use HasFactory;
use TenantConnection;
/** @var list<string> */
protected $fillable = [
'name',
'status',
];
}
适用:Department、ChatConversation、ChatMessage、TenantConfig 和模块业务数据。
4. Migration 创建规则
中央 Migration:
php artisan make:migration create_examples_table
租户 Migration:
php artisan make:migration create_examples_table --path=database/migrations/tenant
已执行的 Migration 不删除、不改名、不直接修改;结构变化创建新 Migration,并提供数据检查、回填、约束和回滚方案。
Migration 必须解释:
- 为什么选择该主键。
- 查询条件对应哪些索引。
- 哪些字段需要唯一约束。
- 外键是否跨数据库。
down()是否会丢失数据以及生产回滚策略。
执行:
php artisan migrate; php artisan tenants:migrate
状态:
php artisan migrate:status; php artisan tenants:run migrate:status --tenants=demo --option=path=database/migrations/tenant
5. 跨数据库引用
租户表引用中央用户时只保存 user_id,不建立 PostgreSQL 跨数据库外键。Service 先用中央模型验证租户成员,再写租户表。
禁止:
Rule::exists('users', 'id')
在租户上下文中,这可能查询租户数据库。应使用明确的中央 Model 查询,或显式指定中央连接的验证规则。
6. 标准单数据库事务
规则:Service/Action 从真正参与写入的模型取得连接,Controller 不开事务。
$connection = $membership->getConnection();
return $connection->transaction(function () use (
$membership,
$attributes,
): TenantMembership {
$membership->fill($attributes)->save();
return $membership->refresh();
});
这是项目默认写法。不要混用 DB::transaction()、配置字符串和模型连接,除非存在明确特殊原因。
7. 合法特殊情况
Passport 会话事务需要同时修改 Access Token 和 Refresh Token,应由 Passport Token Model 提供连接:
$connection = Passport::token()->getConnection();
return $connection->transaction(function () use ($user): int {
return $this->revokeAllSessionsWithinTransaction($user);
});
它是合法特殊情况,因为 OAuth 表的真实连接由 Passport 模型决定。特殊写法必须在方法注释中解释,不能扩散成每个 Service 随意从配置取得连接。
8. 嵌套业务与已有事务
需要把 Token 撤销和审计写入组成同一中央事务时,由上层用例 Service 开启一次事务,底层方法提供 WithinTransaction 版本并检查事务已开启。
不要让多个 Service 各自开启彼此不知情的事务,也不要在 Controller 中协调事务。
9. 跨库和外部服务流程
一个数据库事务不能覆盖中央库、租户库和 HTTP API:
中央 provisioning 状态写入
-> 创建租户数据库
-> 执行租户 Migration
-> 初始化数据
-> 更新中央状态 active
失败 -> 状态 failed + error_code + retry_count + 可重试入口
使用状态机、幂等、补偿和 Outbox。禁止用一个 DB::transaction() 假装提供分布式事务。
10. Seeder
Seeder 必须幂等:
Permission::query()->updateOrCreate(
[
'name' => 'tenant.members.view',
'guard_name' => 'api',
],
[],
);
不依赖固定自增 ID。权限、角色、模块和套餐初始化放 Seeder/Registrar,不写入结构 Migration。
11. Factory
目录重构后的 Model 显式绑定 Factory,防止 Laravel 推导旧命名空间:
protected static function newFactory(): UserFactory
{
return UserFactory::new();
}
Factory 显式声明:
/** @var class-string<User> */
protected $model = User::class;
12. 删除、SoftDeletes 和归档
- 可恢复业务数据根据需求使用
SoftDeletes。 - OAuth Token、审计日志和中间表不盲目软删除。
- 租户物理删除必须经过禁用、保留期、备份、审批和异步清理。
- 普通 Controller 不直接删除租户数据库。
13. 连接自动测试
必须验证:
- User、Role、Permission、Operator 在租户上下文中仍使用中央连接。
- Tenant Model 在租户 A/B 上数据隔离。
- 中央 Service 不因
tenant()初始化而切到租户库。 - 事务失败会回滚业务数据和同库审计。
- 跨库长流程失败会进入可恢复状态。
14. 验证命令
php artisan migrate:status; php artisan tenants:run migrate:status --tenants=demo --option=path=database/migrations/tenant; php artisan test --filter=Connection; php artisan test; .\vendor\bin\pint --test
15. 完成标准
- [ ] 每个表的数据归属明确。
- [ ] 中央和租户 Model 连接显式。
- [ ] 默认事务写法统一。
- [ ] 特殊事务有原因注释和测试。
- [ ] 跨库流程不伪装成单库事务。
- [ ] Seeder 可重复执行。
- [ ] 中央/租户连接和事务回滚有自动测试。
08-请求ID日志审计和安全.md
Request ID、结构化日志、统一审计和 HTTP 安全
0. 状态与依赖
执行阶段:P05
功能状态:部分实现待对齐
所属区域:app/Support + app/Platform/Audit
数据归属:中央审计表 + 日志系统
前置流程:P00-P04
当前登录事件会读取 request_id,但项目尚未建立 Request ID 中间件;IdentityAuditService 还被 Operator 直接依赖,说明审计入口归属需要统一。本流程先建立最小正式闭环,再开发更多安全和运营功能。
1. Request ID 中间件
文件:app/Support/Http/Middleware/AssignRequestId.php
操作:新建完整文件。
<?php
/**
* @description 为每个 HTTP 请求生成或透传安全的请求标识
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Support\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
final class AssignRequestId
{
/** 生成请求上下文并把 Request ID 返回给调用方。 */
public function handle(Request $request, Closure $next): Response
{
$requestId = $request->header('X-Request-ID');
if (
! is_string($requestId)
|| ! preg_match('/^[A-Za-z0-9._-]{8,100}$/', $requestId)
) {
$requestId = (string) Str::uuid();
}
$request->attributes->set('request_id', $requestId);
Log::withContext([
'request_id' => $requestId,
]);
$response = $next($request);
$response->headers->set('X-Request-ID', $requestId);
return $response;
}
}
2. 注册中间件
文件:bootstrap/app.php
操作:局部修改。
新增引入:
use App\Support\Http\Middleware\AssignRequestId;
在 withMiddleware() 开头增加:
$middleware->prependToGroup('api', AssignRequestId::class);
Request ID 必须早于认证、租户、权限和 Controller。用户和租户上下文可由认证后的审计服务读取,不强迫第一个中间件提前获得尚未解析的用户。
3. 日志与审计区别
| 类型 | 目的 | 示例 |
|---|---|---|
| 运行日志 | 故障诊断、性能和外部依赖错误 | SMTP 超时、队列失败、HTTP 502 |
| 审计日志 | 谁对什么资源执行什么业务操作 | 强制下线、账号禁用、角色变更 |
| 登录安全事件 | 认证行为和风险分析 | 成功、密码错误、限流、MFA 失败 |
三者可以关联同一个 request_id,但不能混成一张无边界日志表。
4. 敏感数据脱敏
文件:app/Support/Logging/SensitiveDataRedactor.php
操作:新建完整文件。
<?php
/**
* @description 对日志和审计上下文中的敏感字段进行递归脱敏
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Support\Logging;
use Illuminate\Support\Str;
final class SensitiveDataRedactor
{
private const SENSITIVE_KEYS = [
'password',
'password_confirmation',
'access_token',
'refresh_token',
'authorization',
'cookie',
'client_secret',
'code',
'code_verifier',
'two_factor_secret',
'two_factor_recovery_codes',
];
/** 对任意嵌套日志上下文进行脱敏。 */
public function redact(array $context): array
{
$redacted = [];
foreach ($context as $key => $value) {
if (
is_string($key)
&& in_array(Str::lower($key), self::SENSITIVE_KEYS, true)
) {
$redacted[$key] = '[REDACTED]';
continue;
}
$redacted[$key] = is_array($value)
? $this->redact($value)
: $value;
}
return $redacted;
}
}
正式接入 Monolog Processor 前先为该类建立单元测试。即使有脱敏器,也不要默认记录完整 Request Body;日志字段采用白名单。
5. 统一中央审计入口
目标目录:app/Platform/Audit,因为审计是 Identity、Tenancy 和 Operator 共同依赖的平台能力,不应让 Operator 反向依赖 IdentityAuditService。
迁移原则:
- 保留现有
activity_log中央表和CentralActivity连接。 - 新建通用
CentralAuditService,只负责可靠写入公共审计字段。 - Identity、Operator、Tenancy 的业务 Service 在各自用例中决定事件名和业务属性。
- Controller 不直接调用
activity()。 - 必须与业务写入原子成功的审计使用同一个中央事务。
文件:app/Platform/Audit/Services/CentralAuditService.php
操作:新建完整文件。
<?php
/**
* @description 中央平台统一审计写入服务
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Audit\Services;
use App\Platform\Identity\Models\CentralActivity;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
final class CentralAuditService
{
/** 注入当前请求以补充 Request ID 和客户端上下文。 */
public function __construct(
private readonly Request $request,
) {}
/**
* 写入一条中央审计记录。
*
* @param string $logName 审计分类
* @param string $event 稳定事件名称
* @param string $description 中文描述
* @param Model|null $causer 操作者
* @param Model|null $subject 目标资源
* @param array<string, mixed> $properties 已脱敏业务属性
*/
public function record(
string $logName,
string $event,
string $description,
?Model $causer,
?Model $subject,
array $properties = [],
): CentralActivity {
$activity = new CentralActivity;
$activity->log_name = $logName;
$activity->event = $event;
$activity->description = $description;
$activity->properties = [
...$properties,
'request_id' => $this->request->attributes->get('request_id'),
'ip_address' => $this->request->ip(),
];
if ($causer !== null) {
$activity->causer()->associate($causer);
}
if ($subject !== null) {
$activity->subject()->associate($subject);
}
$activity->save();
return $activity;
}
}
这是公共基础服务,不为每个单一事件创建一个 DTO。需要强类型事件名称时,可以在所属领域使用 Enum,再传入其 value。
6. 事务与审计
业务变更和审计都在中央库时:
return $target->getConnection()->transaction(function () use ($operator, $target): User {
$target->save();
$this->audit->record(
logName: 'identity-security',
event: 'account_status_changed',
description: 'Operator 修改中央账号状态',
causer: $operator,
subject: $target,
properties: ['status' => $target->status->value],
);
return $target->refresh();
});
如果审计写入失败,业务事务回滚。纯诊断日志失败不应影响业务。
7. Security Headers
文件:app/Support/Http/Middleware/SecurityHeaders.php
操作:新建完整文件。
<?php
/**
* @description 为 HTTP 响应设置通用安全头
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Support\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class SecurityHeaders
{
/** 设置不影响 OAuth 和 API 调试的基础安全头。 */
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
if (app()->environment('production')) {
$response->headers->set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains',
);
}
return $response;
}
}
CSP 必须根据 Blade、Scramble 和前端部署方式单独设计,不能复制过严策略破坏 OAuth 页面。
8. CORS、Proxy 和凭证
- CORS 只允许配置中的前端 Origin。
- Bearer Token API 默认不使用跨站 Cookie。
- Trusted Proxy 只信任真实网关网段,否则 IP、审计和限流可被伪造。
.env、Passport 私钥、SMTP 授权码和外部 Secret 不提交。- Token、密码、验证码、PKCE verifier 和 Client Secret 不进入日志与审计。
9. 自动测试
至少覆盖:
- Request ID 生成、合法透传和非法替换。
- 响应包含
X-Request-ID。 - 登录事件和审计包含 Request ID。
- 脱敏器递归移除敏感值。
- 审计失败回滚同库业务事务。
- Operator 不再依赖 Identity 专用审计服务。
- 生产响应包含 HSTS,本地不强制。
10. ApiPost 验收
Method:GET
URL:http://127.0.0.1:8000/api/admin/auth/me
Header:
Accept: application/json
X-Request-ID: local-test-20260805
Authorization: Bearer <Access Token>
Body:无
预期:响应 Header 原样返回 X-Request-ID
11. 验证命令
php -l app\Support\Http\Middleware\AssignRequestId.php; php -l app\Support\Logging\SensitiveDataRedactor.php; php -l app\Platform\Audit\Services\CentralAuditService.php; php -l app\Support\Http\Middleware\SecurityHeaders.php; php -l bootstrap\app.php
php artisan test --filter=RequestId; php artisan test --filter=Audit; php artisan test; .\vendor\bin\pint --test
12. 完成标准
- [ ] 每个 API 响应都有 Request ID。
- [ ] 日志上下文可以关联请求。
- [ ] 敏感字段不进入日志和审计。
- [ ] 审计入口位于共享 Platform 能力,不发生 Operator 反向依赖 Identity。
- [ ] 同库关键审计与业务事务原子提交。
- [ ] 安全头、CORS 和 Proxy 有测试。
09-测试Factory和CI.md
测试、Factory、代码质量和 CI 基线
0. 状态与依赖
执行阶段:P06
功能状态:本地测试已实现,集成环境和 CI 待完善
所属区域:工程质量基础设施
数据归属:隔离测试数据库
前置流程:P00-P05
测试不是最后补充的流程。每个业务功能开始前必须先有可重复运行的测试环境、Factory、Pint 和基本 CI,否则后续目录、连接、权限和事务调整无法安全回归。
1. 两层测试策略
| 层级 | 数据库/依赖 | 用途 |
|---|---|---|
| 快速 Feature/Unit | SQLite memory、array cache、sync queue | 响应、验证、Service 分支和大部分业务回归 |
| Integration | 独立 PostgreSQL、Redis、真实 Passport/租户库 | PostgreSQL 约束、中央/租户连接、Token、锁、队列和隔离 |
禁止把 DB_CONNECTION=pgsql 和 DB_DATABASE=:memory: 混用。测试禁止连接开发库 ps_central。
2. 快速测试配置
文件:phpunit.xml
当前项目已使用 SQLite memory,可继续作为快速测试基线:
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_DEBUG" value="true"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite" force="true"/>
<env name="DB_DATABASE" value=":memory:" force="true"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
</php>
SQLite 测试通过不代表 PostgreSQL 部分唯一索引、JSON、时区、外键和并发锁一定正确,因此关键数据能力必须补 Integration Test。
3. PostgreSQL 集成测试环境
环境文件:.env.integration.example
APP_ENV=testing
APP_DEBUG=true
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=ps_central_test
DB_USERNAME=ps_sass_test
DB_PASSWORD=
CACHE_STORE=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=array
REDIS_DB=14
REDIS_CACHE_DB=15
MAIL_MAILER=array
TENANT_TEST_DATABASE_PREFIX=ps_test_tenant_
所有测试租户数据库必须以 ps_test_tenant_ 开头。创建或删除前再次断言前缀,绝不根据未验证的动态字符串删除数据库。
4. TestCase
文件:tests/TestCase.php
只提供所有测试都需要的清理。不要全局固定时间;需要固定时间的测试自行设置并恢复。
<?php
/**
* @description Laravel 测试基类
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
/** 清理可能残留的租户上下文。 */
protected function tearDown(): void
{
if (tenancy()->initialized) {
tenancy()->end();
}
parent::tearDown();
}
}
如果测试使用 Carbon::setTestNow(),必须在该测试的 finally 或 tearDown() 中恢复。
5. Factory 规则
文件:database/factories/UserFactory.php
目录重构后显式绑定 Model:
<?php
/**
* @description 中央用户测试数据工厂
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace Database\Factories;
use App\Platform\Identity\Enums\UserStatus;
use App\Platform\Identity\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
/** @extends Factory<User> */
final class UserFactory extends Factory
{
protected $model = User::class;
/** 获取中央用户测试数据。 */
/** @return array<string, mixed> */
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => Hash::make('password-for-test'),
'status' => UserStatus::Active,
];
}
}
Model 显式绑定:
protected static function newFactory(): UserFactory
{
return UserFactory::new();
}
Factory 只创建合法默认状态;禁用、锁定、未验证等使用命名 state。
6. API Feature Test 模板
文件:tests/Feature/Example/ExampleApiTest.php
操作:新建完整文件时必须补项目测试命名空间和所需引入。
<?php
/**
* @description API Feature Test 完整模板
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace Tests\Feature\Example;
use App\Enums\ErrorCode;
use App\Platform\Identity\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Passport\Passport;
use Tests\TestCase;
final class ExampleApiTest extends TestCase
{
use RefreshDatabase;
/** 未认证用户不能读取受保护资源。 */
public function test_resource_requires_authentication(): void
{
$this->getJson('/api/example')
->assertUnauthorized()
->assertJsonPath(
'code',
ErrorCode::UNAUTHENTICATED->value,
);
}
/** 已认证用户可以读取资源且不会泄露密码。 */
public function test_authenticated_user_can_view_resource(): void
{
$user = User::factory()->create();
Passport::actingAs($user);
$this->getJson('/api/example')
->assertOk()
->assertJsonPath('code', 0)
->assertJsonMissingPath('data.password');
}
}
Passport::actingAs() 适合认证保护测试,但不会完整创建 OAuth Token。撤销、刷新和 Refresh Token 测试必须创建真实 Passport Token 记录或请求真实 /oauth/token。
7. 必测失败路径
每个写接口至少覆盖:
- 422 参数验证。
- 401 未认证。
- 403 无权限。
- 404 资源不存在。
- 跨租户或非资源所有者。
- 业务状态冲突和重复请求。
- 事务内关键副作用失败回滚。
不能只测试成功响应。
8. Fake 使用边界
Event::fake();
Queue::fake();
Notification::fake();
Http::fake();
Fake 用于验证是否派发和请求参数;每个关键 Job、Listener、Notification 和 HTTP Client 还要有少量真实 handle()/发送转换测试。只 Fake 不执行无法发现租户连接、序列化和事务提交问题。
9. PostgreSQL/Redis 必须真实验证的能力
- PostgreSQL 部分唯一索引和并发约束。
- 中央模型在租户上下文中的连接。
- 租户 A/B 数据隔离。
- Passport Access/Refresh Token 撤销。
- Redis Lock、限流和幂等。
- Queue Job 的租户上下文恢复。
- Outbox 的并发领取和重试。
10. 测试命令
工作目录:F:\CodeWorkspace\project-pshang-app\backend-laravel
快速测试:
php artisan test
局部测试:
php artisan test --filter=AuthTest
格式检查:
.\vendor\bin\pint --test
只修指定文件:
.\vendor\bin\pint app\Platform\Identity\Services\AuthService.php
依赖安全:
composer audit
11. CI 工作流
文件:.github/workflows/backend-laravel.yml
CI 必须使用独立 PostgreSQL 和 Redis Service,并执行:
composer install --no-interaction --prefer-dist
-> php artisan key:generate
-> php artisan migrate --force
-> vendor/bin/pint --test
-> composer audit
-> php artisan test
-> Integration Test
第三方 Action 固定到经过核对的版本或 commit SHA。CI Secret 通过仓库环境注入,不写 YAML。
12. 当前项目回归基线
当前代码结构变更后必须至少验证:
- Auth 登录、限流、当前用户和退出。
- Identity 安全事件。
- Authorization Scope、Role 和 Permission。
- Tenant owner 唯一约束和成员接口。
- Operator 强制下线、状态变更和审计回滚。
13. 完成标准
- [ ] 快速测试不依赖 PostgreSQL 开发库。
- [ ] 集成测试使用独立 PostgreSQL/Redis。
- [ ] 测试租户数据库有安全前缀。
- [ ] Factory 与重构后的 Model 显式绑定。
- [ ] Token、连接、锁和租户隔离有真实集成测试。
- [ ] Pint、Composer Audit 和测试进入 CI。
- [ ] 任一质量门禁失败都会阻止合并。
10-Redis缓存锁限流和幂等.md
Redis 缓存、分布式锁、限流和幂等完整流程
0. 状态与依赖
执行阶段:P07
功能状态:登录限流已实现;缓存 Key、锁和幂等为下一步实施
所属区域:app/Support/Cache + app/Support/Http
数据归属:Redis + 中央幂等表
前置流程:P00-P06
执行边界:登录前必须完成命名限流器;只有第一个需要缓存、分布式锁或 HTTP 幂等的业务出现时,才实施对应部分。本文代码未标记“完整文件”的片段只用于解释,不得直接保存。
功能 A:租户缓存 Key
1. 目的
所有租户缓存必须包含应用、环境和 tenant_id,避免 A/B 租户使用相同业务 Key。
2. Key Builder
文件:app/Support/Cache/CacheKey.php
操作:新建完整文件。
<?php
/**
* @description 中央和租户 Redis Key 统一构建器
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Support\Cache;
use Illuminate\Support\Str;
final class CacheKey
{
/** 构建中央平台缓存 Key。 */
public static function central(string ...$segments): string
{
return implode(':', [
Str::slug(config('app.name')),
app()->environment(),
'central',
...$segments,
]);
}
/** 构建带租户隔离前缀的缓存 Key。 */
public static function tenant(string $tenantId, string ...$segments): string
{
return implode(':', [
Str::slug(config('app.name')),
app()->environment(),
'tenant',
$tenantId,
...$segments,
]);
}
}
使用:
$key = CacheKey::tenant(tenant('id'), 'menus', (string) $user->id);
$menus = Cache::remember($key, now()->addMinutes(10), function () use ($user) {
return $this->menuService->buildFor($user);
});
角色、模块授权或菜单变更后删除相同 Key。不要使用 Cache::flush(),它可能清空整个 Redis Store。
功能 B:分布式锁
1. 目的
防止同一租户开通、支付回调、文件处理或组织同步同时执行两次。
$lock = Cache::lock(
CacheKey::central('tenant-provision', $tenantId),
120,
);
try {
return $lock->block(5, fn () => $this->provision($tenantId));
} catch (LockTimeoutException) {
throw new DomainException('该租户正在处理中,请稍后重试');
} finally {
$lock->release();
}
锁有过期时间,业务仍需数据库唯一约束和幂等,不能把 Redis 锁当唯一一致性保证。
功能 C:RateLimiter
限流器统一放入专用 RateLimitServiceProvider;当前项目已有登录限流时,先迁移配置再删除旧位置,避免重复注册。局部代码需要引入:
use App\Enums\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
RateLimiter::for('login', function (Request $request): Limit {
$email = Str::lower((string) $request->input('email'));
return Limit::perMinute(5)
->by($email.'|'.$request->ip())
->response(fn () => ApiResponse::error(
ErrorCode::TOO_MANY_ATTEMPTS,
'登录尝试过于频繁,请稍后再试',
status: 429,
));
});
通用写接口:
RateLimiter::for('admin-write', fn (Request $request) =>
Limit::perMinute(60)->by((string) $request->user('api')?->id.'|'.$request->ip())
);
路由文件需要已有引入 use Illuminate\Support\Facades\Route;:
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:identity-api-login');
功能 D:幂等请求
状态:规划草案。开始实现前必须把下面片段升级为完整 Migration、Model、Middleware、异常并发测试和清理 Command。当前不得直接复制到生产。
1. Migration(中央库)
Schema::create('idempotency_keys', function (Blueprint $table): void {
$table->id();
$table->string('tenant_id')->nullable();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('route_name');
$table->string('key', 100);
$table->string('request_hash', 64);
$table->string('status', 20)->default('processing');
$table->unsignedSmallInteger('response_status')->nullable();
$table->jsonb('response_body')->nullable();
$table->timestampTz('expires_at');
$table->timestampsTz();
$table->unique(['tenant_id', 'user_id', 'route_name', 'key']);
});
2. Model
<?php
/**
* @description 中央幂等请求记录模型
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Idempotency\Models;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Concerns\CentralConnection;
final class IdempotencyKey extends Model
{
use CentralConnection;
protected $fillable = [
'tenant_id', 'user_id', 'route_name', 'key', 'request_hash',
'status', 'response_status', 'response_body', 'expires_at',
];
/** @return array<string, string> */
protected function casts(): array
{
return [
'response_body' => 'array',
'expires_at' => 'datetime',
];
}
}
3. Middleware
<?php
/**
* @description API 幂等请求中间件
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Idempotency\Http\Middleware;
use App\Enums\ErrorCode;
use App\Platform\Idempotency\Models\IdempotencyKey;
use App\Support\ApiResponse;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class EnsureIdempotentRequest
{
/** 识别重复请求,并复用第一次成功响应。 */
public function handle(Request $request, Closure $next): Response
{
$key = $request->header('Idempotency-Key');
if (! is_string($key) || $key === '') {
return ApiResponse::error(
ErrorCode::VALIDATION_ERROR,
'Idempotency-Key 请求头不能为空',
status: 422,
);
}
$identity = [
'tenant_id' => tenant()?->getTenantKey(),
'user_id' => $request->user('api')?->id,
'route_name' => (string) $request->route()?->getName(),
'key' => $key,
];
$requestHash = hash('sha256', $request->getContent());
$record = IdempotencyKey::firstOrCreate($identity, [
'request_hash' => $requestHash,
'status' => 'processing',
'expires_at' => now()->addDay(),
]);
if (! hash_equals($record->request_hash, $requestHash)) {
return ApiResponse::error(
ErrorCode::BUSINESS_ERROR,
'同一幂等键不能用于不同请求',
status: 409,
);
}
if (! $record->wasRecentlyCreated && $record->status === 'completed') {
return response()->json($record->response_body, $record->response_status);
}
if (! $record->wasRecentlyCreated) {
return ApiResponse::error(
ErrorCode::BUSINESS_ERROR,
'相同请求正在处理中',
status: 409,
);
}
$response = $next($request);
if ($response->isSuccessful()) {
$record->forceFill([
'status' => 'completed',
'response_status' => $response->getStatusCode(),
'response_body' => json_decode($response->getContent(), true),
])->save();
} else {
$record->delete();
}
return $response;
}
}
正式实现必须捕获唯一约束并重新查询记录,不能假设 firstOrCreate() 消除了并发竞争;必须限制可缓存响应大小,且仅缓存明确允许重放的成功响应。支付和 AI 请求除 HTTP 幂等外,业务表还必须有 provider event ID 或 message idempotency_key 唯一约束。
4. Route 和 ApiPost
Route::post(
'/conversations/{conversation}/messages',
[MessageController::class, 'send'],
)->middleware('idempotent');
Idempotency-Key: 每次业务动作生成的UUID
相同 Key + 相同 Body 返回第一次成功响应;相同 Key + 不同 Body 返回 409。
功能 E:清理
定时删除过期 idempotency_keys 和业务允许清理的缓存。清理 Command 按批次执行,不用全表一次删除。
测试
- tenant A/B 相同业务 Key 不冲突。
- 分布式锁竞争只有一个执行。
- 登录第 6 次返回 429。
- 幂等重复请求只写一次数据库。
- 幂等相同 Key 不同 Body 返回 409。
- 过期记录可清理。
完成标准
- [ ] 所有 Redis Key 通过
CacheKey构建并包含环境和作用域。 - [ ] 不使用
Cache::flush()清理单租户缓存。 - [ ] 锁有 TTL、等待时间、数据库约束和幂等后备。
- [ ] RateLimiter 使用稳定名称和统一 42900 响应。
- [ ] 幂等实现处理并发唯一键冲突、请求哈希、响应大小和过期清理。
- [ ] Redis 集成测试验证锁和幂等,不只使用 array cache。
11-队列事件Horizon和调度.md
队列、事件、Horizon 和调度完整流程
0. 状态与依赖
执行阶段:P08
功能状态:邀请通知已使用队列;统一事件和 Job 契约待对齐
所属区域:各领域 Events/Listeners/Jobs + 工程 Queue 配置
数据归属:Redis Queue + failed_jobs
前置流程:P00-P07
第一个异步功能开始前必须先完成 Event、Job、afterCommit、重试、租户上下文和失败处理最小闭环。Horizon Dashboard 和失败任务运营 API 属于生产前能力,不阻塞早期同步业务。
功能 A:领域事件和 Listener
事件表示已经发生的事实,不把复杂业务全部塞进 Observer。
文件:app/Platform/Tenancy/Events/TenantMemberAdded.php
操作:新建完整文件。
<?php
/**
* @description 租户成员添加完成领域事件
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Tenancy\Events;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
final readonly class TenantMemberAdded implements ShouldDispatchAfterCommit
{
/** 保存异步 Listener 所需的稳定标识。 */
public function __construct(
public string $tenantId,
public int $membershipId,
public string $requestId,
) {}
}
文件:app/Platform/Tenancy/Listeners/SendTenantMemberWelcomeNotification.php
操作:新建完整文件。
<?php
/**
* @description 异步发送租户成员欢迎通知
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Tenancy\Listeners;
use App\Platform\Tenancy\Events\TenantMemberAdded;
use App\Platform\Tenancy\Models\TenantMembership;
use App\Platform\Tenancy\Notifications\TenantMemberWelcomeNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
final class SendTenantMemberWelcomeNotification implements ShouldQueue
{
public string $queue = 'notifications';
/** 根据提交后的成员记录发送通知。 */
public function handle(TenantMemberAdded $event): void
{
$membership = TenantMembership::findOrFail($event->membershipId);
$membership->user->notify(new TenantMemberWelcomeNotification(
$event->tenantId,
));
}
}
事件已经实现 ShouldDispatchAfterCommit,避免 Listener 读取尚未提交的数据。Event 表达已发生事实,不能在 Listener 中补做必须原子成功的核心状态写入。
功能 B:租户 Job
文件:app/Platform/Tenancy/Jobs/ProcessTenantTask.php
操作:新建完整文件。正式创建时不得保留示例中的“幂等业务处理”占位,必须替换为具体 Service 调用。
<?php
/**
* @description 在明确租户上下文中执行异步任务
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Tenancy\Jobs;
use App\Platform\Tenancy\Models\Tenant;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;
final class ProcessTenantTask implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $timeout = 120;
public bool $afterCommit = true;
public function __construct(
public readonly string $tenantId,
public readonly string $requestId,
public readonly string $idempotencyKey,
) {
$this->onQueue('tenant-tasks');
}
/** 获取指数退避秒数。 */
public function backoff(): array
{
return [10, 60, 300];
}
/** 初始化租户上下文并执行幂等任务。 */
public function handle(): void
{
$tenant = Tenant::findOrFail($this->tenantId);
tenancy()->initialize($tenant);
Log::withContext([
'request_id' => $this->requestId,
'tenant_id' => $this->tenantId,
]);
try {
// 调用一个可重试且具备业务幂等键的 Service。
} finally {
tenancy()->end();
}
}
/** 记录最终失败且不包含敏感参数。 */
public function failed(Throwable $exception): void
{
Log::error('租户任务最终失败', [
'tenant_id' => $this->tenantId,
'request_id' => $this->requestId,
'exception' => $exception,
]);
}
}
Job 显式携带 tenant_id,不依赖派发时的连接。跨版本长队列避免直接序列化复杂 Model,优先传 ID。
功能 C:Horizon
安装前核对当前 Laravel/PHP 兼容性:
composer require laravel/horizon
php artisan horizon:install
php artisan migrate
.env:
QUEUE_CONNECTION=redis
config/horizon.php 为队列分组:
'environments' => [
'production' => [
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default', 'notifications'],
'balance' => 'auto',
'maxProcesses' => 10,
'tries' => 3,
],
'supervisor-ai' => [
'connection' => 'redis',
'queue' => ['ai', 'tenant-tasks'],
'balance' => 'auto',
'maxProcesses' => 5,
'timeout' => 300,
'tries' => 3,
],
],
],
Horizon Dashboard Gate 只允许 Operator。Private Edition 可由唯一客户系统管理员按配置访问,但不能公开。
功能 D:失败任务管理 API
生产不建议任意重试所有失败 Job。Operator API 根据失败 ID、队列和租户展示脱敏摘要,重试前检查 Job 类仍存在和 Edition 仍启用。
Artisan 运维命令:
php artisan queue:failed
php artisan queue:retry 失败任务UUID
php artisan queue:forget 失败任务UUID
功能 E:Scheduler
routes/console.php:
Schedule::command('passport:purge')
->daily()
->withoutOverlapping()
->onOneServer();
Schedule::command('app:purge-idempotency-keys')
->hourly()
->withoutOverlapping()
->onOneServer();
Schedule::command('app:check-subscription-expiration')
->everyTenMinutes()
->withoutOverlapping()
->onOneServer();
生产 Cron 每分钟:
php artisan schedule:run
本地:
php artisan schedule:work
onOneServer() 依赖共享 Cache Lock;多实例不能使用本机 array/file cache。
测试
Queue::fake();
ProcessTenantTask::dispatch('demo', 'request-id', 'idempotency-key');
Queue::assertPushed(ProcessTenantTask::class);
还要执行真实 Job 集成测试:tenant A Job 只能写 A;重试不重复写;failed() 记录 request_id;Scheduler 防重入。
验收
- Event 在事务提交后处理。
- Job 恢复和结束租户上下文。
- Horizon 能查看吞吐、失败和运行时间。
- 失败 Job 可按 ID 安全重试。
- Scheduler 多实例只执行一次。
完成标准
- [ ] Event 使用过去式并只表达已发生事实。
- [ ] 事务内事件在提交后派发。
- [ ] Job 明确队列、重试、退避、超时、
afterCommit和幂等键。 - [ ] 租户 Job 显式携带 tenant_id,并在 finally 结束上下文。
- [ ] Fake 测试和真实 handle 集成测试同时存在。
- [ ] Horizon 和 Scheduler 在生产部署前完成安全与多实例验收。
12-通知中心邮件和站内信.md
通知中心、邮件和站内信完整流程
0. 状态与依赖
执行阶段:P08 按需能力
功能状态:SMTP 和租户邀请邮件已实现;数据库通知中心为规划草案
所属区域:Platform Notification
数据归属:中央 notifications 表 + Queue + Mail Provider
前置流程:P00-P08 队列最小闭环
邀请邮件只需要 Mail Notification,不应被完整站内信系统阻塞;但只要 Notification 实现 ShouldQueue,就必须先完成队列、afterCommit、重试和失败处理规则。
1. 功能目的
统一处理邀请、密码安全、订阅到期、任务失败和业务消息。业务只发送 Notification,不直接拼 SMTP 和站内信表。
2. Notification 表(中央库)
使用 Laravel notifications migration,并增加 tenant_id:
下面 Migration 为规划代码。执行前必须生成完整 Migration 文件并确认 notifiable_id 兼容当前 User 主键类型:
Schema::create('notifications', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->string('tenant_id')->nullable()->index();
$table->text('data');
$table->timestampTz('read_at')->nullable();
$table->timestampsTz();
});
User 是中央 Model,因此 notifications 必须使用中央连接。若默认 DatabaseNotification 连接不明确,创建项目自定义模型并在 User relationship 中指定。
3. Notification
文件:app/Platform/Identity/Notifications/TenantInvitationNotification.php
操作:完整文件应以当前仓库实现为准;新增时必须包含以下引入:
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use App\Platform\Identity\Models\UserInvitation;
类必须实现:
<?php
/**
* @description 租户成员邀请邮件通知
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-03 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Identity\Notifications;
use App\Platform\Identity\Models\UserInvitation;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
final class TenantInvitationNotification extends Notification implements ShouldBeEncrypted, ShouldQueue
{
use Queueable;
public function __construct(
private readonly UserInvitation $invitation,
private readonly string $plainToken,
) {
$this->onQueue('notifications');
$this->afterCommit();
}
/** @return array<int, string> */
public function via(object $notifiable): array
{
return ['mail'];
}
/** 构建不包含明文 Token 持久化数据的邀请邮件。 */
public function toMail(object $notifiable): MailMessage
{
$url = config('services.frontend.url')
.'/invitations/accept?token='.urlencode($this->plainToken);
return (new MailMessage())
->subject('租户成员邀请')
->line("你被邀请加入租户 {$this->invitation->tenant_id}")
->action('接受邀请', $url)
->line('邀请链接将在指定时间后过期。');
}
}
ShouldBeEncrypted 防止明文邀请 Token 出现在队列 Payload;afterCommit() 防止队列在邀请记录提交前发送邮件。当前邀请通知只使用 mail Channel。站内信上线时建立不含 plainToken 的独立 Notification 数据,不直接复用敏感邮件 Payload。
数据库 data 不能保存 plainToken。邀请邮件 Token 只通过邮件内容发送。
4. Resource
<?php
/**
* @description 用户站内通知资源
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Notification\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class NotificationResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'tenant_id' => $this->data['tenant_id'] ?? null,
'type' => $this->data['type'] ?? null,
'title' => $this->data['title'] ?? null,
'message' => $this->data['message'] ?? null,
'read_at' => $this->read_at,
'created_at' => $this->created_at,
];
}
}
5. Controller
public function index(PaginationRequest $request): JsonResponse
{
$paginator = $request->user('api')
->notifications()
->latest()
->paginate($request->pageSize());
return ApiResponse::success(data: [
'items' => NotificationResource::collection($paginator->items()),
'pagination' => [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
],
]);
}
public function markRead(Request $request, string $notification): JsonResponse
{
$model = $request->user('api')
->notifications()
->whereKey($notification)
->first();
if ($model === null) {
return ApiResponse::error(ErrorCode::NOT_FOUND, '通知不存在', status: 404);
}
$model->markAsRead();
return ApiResponse::success(message: '通知已读');
}
通过 $user->notifications() 限制归属,不能全局查通知 ID。
6. Routes
Route::get('/notifications', [NotificationController::class, 'index']);
Route::patch('/notifications/{notification}/read', [NotificationController::class, 'markRead']);
Route::patch('/notifications/read-all', [NotificationController::class, 'markAllRead']);
全部位于 auth:api。租户通知展示时可按当前 tenant_id 过滤,平台安全通知 tenant_id=null。
7. ApiPost
GET http://127.0.0.1:8000/api/admin/notifications?page=1&page_size=20
Authorization: Bearer AccessToken
PATCH http://127.0.0.1:8000/api/admin/notifications/{id}/read
Authorization: Bearer AccessToken
8. 邮件模板和队列
通知实现 ShouldQueue,邮件队列独立 notifications。模板不硬编码站点域名,读取配置;所有 URL 使用 HTTPS 生产地址。邮件失败进入 failed_jobs,可重试并记录 request_id/notification_id。
9. 偏好和渠道
后续建立 notification_preferences:用户可关闭普通业务邮件,但密码重置、安全告警和合规通知不可被普通偏好关闭。短信、钉钉和企微通过 Channel Adapter 接入。
10. 测试
Notification::fake();
$user->notify(new TenantInvitationNotification(
invitation: $invitation,
plainToken: 'test-plain-token',
));
Notification::assertSentTo($user, TenantInvitationNotification::class);
还要测试:用户不能读取他人通知;数据库 data 无 Token;邮件队列失败可重试;mark read 幂等;tenant A 通知不会错误展示在 B 上下文。
11. 完成标准
- [ ] 业务只发送 Notification,不直接调用 SMTP。
- [ ] 含敏感链接的队列 Notification 已加密并在事务提交后发送。
- [ ] 邮件 URL 来自配置且生产使用 HTTPS。
- [ ] 普通偏好不能关闭密码重置和安全告警。
- [ ] 用户只能通过自己的 notifications 关系读取和标记通知。
- [ ] Fake、邮件内容、队列失败和数据不泄密测试通过。
13-身份中心-会话和强制下线.md
身份中心:会话管理和强制下线完整流程
0. 状态与依赖
执行阶段:P09 Identity 核心
功能状态:A-D 已实现并验证;E 已实现待统一审计归属
所属区域:Platform/Identity + Operator 安全用例
数据归属:中央数据库 OAuth 与审计表
前置流程:P00-P08、Passport、中央 User
依赖闸门:统一响应、异常、事务、Request ID、审计、限流和测试基线必须先完成。本文局部修改必须按 03-代码引导与学习验收规范.md 同时核对目标文件、引入、插入位置和 @lastModified。
对应执行计划:阶段 3“登录设备和会话管理”“强制下线和 Token 撤销”。
当前状态:A-D 已实现并验证;E 已完成业务闭环,需按 P05 统一审计归属完成最终对齐和回归。
0.1 文件地图
| 职责 | 文件 | 操作类型 |
|---|---|---|
| OAuth Token 能力 | app/Platform/Identity/Services/AuthService.php |
局部修改 |
| 会话 API | app/Platform/Identity/Http/Controllers/AuthController.php |
局部修改 |
| 会话输出 | app/Platform/Identity/Http/Resources/AuthSessionResource.php |
完整文件或核对现有实现 |
| 强制下线参数 | app/Platform/Identity/Http/Requests/ForceLogoutRequest.php |
完整文件 |
| Operator 强制下线用例 | app/Operator/Services/ForceLogoutUserService.php |
完整文件 |
| Operator HTTP 入口 | app/Operator/Http/Controllers/UserSessionController.php |
完整文件 |
| 统一身份审计 | app/Platform/Identity/Services/IdentityAuditService.php |
复用,不在控制器直接写审计 |
| Identity 路由 | routes/api.php |
局部修改 |
| Operator 路由 | routes/operator.php |
局部修改 |
| 自动测试 | tests/Feature/Api/AuthTest.php、tests/Feature/Operator/ForceLogoutUserTest.php |
新增或补充用例 |
Passport Token 操作必须从 Passport Token 模型取得真实连接。强制下线用例的 Token 撤销和中央审计必须位于同一个中央数据库事务内。
功能 A:本地快速获取测试 Token
1. 功能目的
本地频繁测试 Refresh Token、退出和会话撤销时,快速获得一组 Access Token 和 Refresh Token。只允许 APP_ENV=local,不能替代生产 PKCE。
2. 配置
在 AppServiceProvider::boot() 中:
/** 仅本地开发启用 Password Grant。 */
if (app()->environment('local')) {
Passport::enablePasswordGrant();
}
.env:
DEV_OAUTH_CLIENT_ID=本地PasswordClientID
DEV_OAUTH_CLIENT_SECRET=本地PasswordClientSecret
DEV_OAUTH_EMAIL=admin@example.com
DEV_OAUTH_PASSWORD=本地测试密码
config/services.php:
'dev_oauth' => [
'client_id' => env('DEV_OAUTH_CLIENT_ID'),
'client_secret' => env('DEV_OAUTH_CLIENT_SECRET'),
'email' => env('DEV_OAUTH_EMAIL'),
'password' => env('DEV_OAUTH_PASSWORD'),
],
3. Service
app/Platform/Identity/Services/AuthService.php 引入:
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
use Symfony\Component\HttpFoundation\Response;
添加:
/**
* 本地开发环境快速获取 Access Token 和 Refresh Token。
*
* 通过当前 Laravel 进程内部调用 Passport Token 路由,
* 避免 php artisan serve 单进程请求自己造成超时。
*
* @return Response Passport Token 响应
*
* @throws \LogicException 非本地环境禁止调用
*/
public function issueDevelopmentToken(): Response
{
if (! app()->environment('local')) {
throw new \LogicException('快速 Token 接口只允许在本地环境使用');
}
$request = SymfonyRequest::create(
uri: '/oauth/token',
method: 'POST',
parameters: [
'grant_type' => 'password',
'client_id' => config('services.dev_oauth.client_id'),
'client_secret' => config('services.dev_oauth.client_secret'),
'username' => config('services.dev_oauth.email'),
'password' => config('services.dev_oauth.password'),
'scope' => '',
],
);
return app()->handle($request);
}
4. Controller
AuthController.php:
/**
* 本地开发环境快速获取 Access Token 和 Refresh Token。
*
* @return JsonResponse Passport Token 响应
*/
public function developmentToken(): JsonResponse
{
$response = $this->authService->issueDevelopmentToken();
return response()->json(
json_decode($response->getContent(), true),
$response->getStatusCode(),
);
}
5. Route
放在 admin/auth 前缀内、auth:api 组外:
/**
* 本地开发环境快速获取 Access Token 和 Refresh Token。
*
* @method POST
* @url /api/admin/auth/dev/token
*/
Route::post('/dev/token', [AuthController::class, 'developmentToken'])
->name('dev.token');
6. ApiPost
POST http://127.0.0.1:8000/api/admin/auth/dev/token
不需要 Header 和 Body。每次请求签发新会话,旧会话仍然存在。
7. 预期结果
{
"token_type": "Bearer",
"expires_in": 900,
"access_token": "...",
"refresh_token": "..."
}
生产环境请求应返回 404 或被发行构建完全移除,不能返回 Token。
功能 B:查询当前用户会话
1. 功能目的
返回当前用户未撤销、未过期的 Access Token 元数据,让前端展示登录会话。不能返回 Token 原文。
2. Resource
app/Platform/Identity/Http/Resources/AuthSessionResource.php:
<?php
/**
* @description OAuth 登录会话资源
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-03 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Identity\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \Laravel\Passport\Token
*/
final class AuthSessionResource extends JsonResource
{
/**
* 转换为登录会话响应数据。
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$currentTokenId = $request->user('api')?->token()?->id;
return [
'id' => $this->id,
'client_id' => $this->client_id,
'created_at' => $this->created_at,
'expires_at' => $this->expires_at,
'revoked' => $this->revoked,
'is_current' => $currentTokenId === $this->id,
];
}
}
3. Controller
/**
* 获取当前用户的有效登录会话。
*
* @param Request $request 当前请求
* @return JsonResponse 登录会话列表
*/
public function sessions(Request $request): JsonResponse
{
$sessions = $request->user('api')
->tokens()
->where('revoked', false)
->where('expires_at', '>', now())
->latest('created_at')
->get();
return ApiResponse::success(
data: [
'items' => AuthSessionResource::collection($sessions),
],
message: '获取登录会话成功',
);
}
4. Route
放入 auth:api 组:
/**
* 获取当前用户的有效登录会话。
*
* @method GET
* @url /api/admin/auth/sessions
* @auth Bearer Token
*/
Route::get('/sessions', [AuthController::class, 'sessions'])
->name('sessions.index');
5. ApiPost
GET http://127.0.0.1:8000/api/admin/auth/sessions
Authorization: Bearer 当前AccessToken
Accept: application/json
预期 items 至少有一个 is_current=true,且响应中没有 access_token、refresh_token 和 Secret。
功能 C:撤销指定会话
1. 功能目的
允许用户从会话列表退出某个设备,同时撤销该会话 Refresh Token。
2. 参数验证
不需要 FormRequest。只有路径参数 session,由路由正则验证十六进制格式,再由 $user->tokens() 验证资源归属。不存在或不属于当前用户统一返回 404。
3. Service
/**
* 撤销当前用户指定的登录会话。
*
* @param User $user 当前用户
* @param string $sessionId 登录会话 ID
* @return bool 会话存在并撤销成功返回 true
*/
public function revokeSession(User $user, string $sessionId): bool
{
$token = $user->tokens()
->with('refreshToken')
->whereKey($sessionId)
->first();
if ($token === null) {
return false;
}
$token->revoke();
$token->refreshToken?->revoke();
return true;
}
4. Controller
/**
* 撤销当前用户指定的登录会话。
*
* @param Request $request 当前请求
* @param string $session 会话 ID
* @return JsonResponse 撤销结果
*/
public function destroySession(Request $request, string $session): JsonResponse
{
$revoked = $this->authService->revokeSession(
$request->user('api'),
$session,
);
if (! $revoked) {
return ApiResponse::error(
code: ErrorCode::NOT_FOUND,
message: '登录会话不存在或不属于当前用户',
status: 404,
);
}
return ApiResponse::success(
message: '删除登录会话成功',
);
}
5. Route
Route::delete('/sessions/{session}', [AuthController::class, 'destroySession'])
->where('session', '[a-f0-9]+')
->name('sessions.destroy');
6. ApiPost 和预期
DELETE http://127.0.0.1:8000/api/admin/auth/sessions/会话ID
Authorization: Bearer 当前AccessToken
Accept: application/json
{
"code": 0,
"message": "删除登录会话成功",
"data": null
}
再次查询会话列表时该 ID 消失;该会话 Access Token 请求 /me 返回 401;关联 Refresh Token 返回 invalid_grant。
功能 D:退出其他设备
1. 功能目的
保留当前请求 Token,撤销同一用户其他所有 Access Token 和 Refresh Token。
2. Service
/**
* 撤销当前用户除当前会话外的所有登录会话。
*
* Access Token 即使已经过期,也可能仍有关联的有效 Refresh Token,
* 因此这里只排除当前 Token,不按 expires_at 过滤。
*
* @param User $user 当前用户
* @param string $currentTokenId 当前 Access Token ID
* @return int 实际撤销的其他会话数量
*/
public function revokeOtherSessions(
User $user,
string $currentTokenId,
): int {
$tokens = $user->tokens()
->with('refreshToken')
->where('id', '!=', $currentTokenId)
->where('revoked', false)
->get();
$tokens->each(function ($token): void {
$token->revoke();
$token->refreshToken?->revoke();
});
return $tokens->count();
}
3. Controller
/**
* 撤销当前用户除当前会话外的所有登录会话。
*
* @param Request $request 当前请求
* @return JsonResponse 撤销结果
*/
public function destroyOtherSessions(Request $request): JsonResponse
{
$user = $request->user('api');
$currentToken = $user->token();
$revokedCount = $this->authService->revokeOtherSessions(
$user,
$currentToken->id,
);
return ApiResponse::success(
data: [
'revoked_count' => $revokedCount,
],
message: '其他登录会话已退出',
);
}
4. Route
静态路由必须放在 {session} 前:
Route::delete('/sessions/others', [AuthController::class, 'destroyOtherSessions'])
->name('sessions.destroy-others');
Route::delete('/sessions/{session}', [AuthController::class, 'destroySession'])
->where('session', '[a-f0-9]+')
->name('sessions.destroy');
5. 检查命令
php -l app\Platform\Identity\Services\AuthService.php
php -l app\Platform\Identity\Http\Controllers\AuthController.php
php artisan route:clear
php artisan route:list --path=api/admin/auth/sessions
6. ApiPost
先调用三次:
POST http://127.0.0.1:8000/api/admin/auth/dev/token
保存最后一个 Access Token,调用:
DELETE http://127.0.0.1:8000/api/admin/auth/sessions/others
Authorization: Bearer 最后一个AccessToken
Accept: application/json
预期:
{
"code": 0,
"message": "其他登录会话已退出",
"data": {
"revoked_count": 2
}
}
再次查询只剩当前会话;两个旧 Access Token 返回 401;两个旧 Refresh Token 返回 Token has been revoked。
功能 E:管理员强制下线指定用户
当前代码状态:ForceLogoutRequest、AuthService::revokeAllSessions() 和控制器方法已经有基础代码,但权限初始化、中央审计日志、租户边界和自动化测试仍需要按本节补齐后,才能标记为完成。
1. 功能目的和边界
平台管理员可以在账号被盗、员工离职或安全事件时撤销目标用户的全部 OAuth 会话,包括 Access Token 和 Refresh Token。
本功能分成两个边界,不允许混用:
- 平台运营端:只能由具有
platform.users.force_logout权限的平台管理员操作,可以处理平台用户的全部会话。 - 租户管理端:后续单独实现,只能处理当前租户的成员,必须校验
tenant_memberships,不能通过全局用户 ID 越权操作其他租户成员。
本节先实现平台运营端。目标用户、操作人、权限和 Passport Token 都属于中央数据库;审计日志也必须写入中央数据库。
2. 影响范围
- 数据表:
oauth_access_tokens、oauth_refresh_tokens、中央activity_log。 - 不影响:租户业务表、Redis、用户密码和租户成员关系。
- 不新增 Token 黑名单表:直接复用 Passport 的
revoke(),避免重复实现 OAuth 逻辑。 - 不返回 Token 原文:接口只返回撤销数量。
3. 文件顶部注释和引入
app/Platform/Identity/Http/Requests/ForceLogoutRequest.php 必须完整写成:
<?php
/**
* @description 管理员强制下线请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-03 18:40:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Identity\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
final class ForceLogoutRequest extends ApiRequest
{
/**
* 权限由路由 auth:api 和 permission 中间件负责,
* 本 Request 只负责验证强制下线原因。
*/
public function authorize(): bool
{
return true;
}
/**
* 获取强制下线请求的验证规则。
*
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'reason' => ['required', 'string', 'max:500'],
];
}
/**
* 获取面向前端的中文验证消息。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'reason.required' => '强制下线原因不能为空',
'reason.string' => '强制下线原因必须是文本',
'reason.max' => '强制下线原因不能超过 500 个字符',
];
}
}
AuthService.php 的引入区需要补充事务和 Passport Token 类型:
use Illuminate\Support\Facades\DB;
use Laravel\Passport\Token;
AuthController.php 的引入区需要补充:
use App\Platform\Identity\Http\Requests\ForceLogoutRequest;
use App\Platform\Identity\Models\User;
控制器中原有的 use Laravel\Passport\Token; 要保留,因为它用于确认当前请求确实由数据库 Access Token 认证。
4. Platform Service:只负责 Token 能力
文件:app/Platform/Identity/Services/AuthService.php
当前实现已经提供 revokeAllSessionsWithinTransaction(User $user): int。该方法不自行开启事务,只允许上层用例在已经开启的中央事务中调用,并检查 transactionLevel()。
Identity 只提供可复用的 Token 撤销能力,不关心调用者是 Operator、密码修改还是其他安全用例。
5. Operator Service:事务和审计用例
文件:app/Operator/Services/ForceLogoutUserService.php
当前正式调用关系:
ForceLogoutUserService::execute()
-> 从 Passport Token Model 取得中央连接
-> 验证 Token 与中央审计使用同一连接
-> 开启一次中央事务
-> AuthService::revokeAllSessionsWithinTransaction()
-> 统一审计 Service 写 force_logout
-> 提交事务并返回 revoked_count
审计写入失败时 Token 撤销必须回滚。P05 对齐后,Operator 应依赖 Platform/Audit 的公共审计入口,而不是反向依赖 Identity 专用审计服务。
6. Controller:只做 HTTP 编排
文件:app/Operator/Http/Controllers/UserSessionController.php
/**
* 强制撤销指定中央用户的全部登录会话。
*
* @param ForceLogoutRequest $request 强制下线参数
* @param User $user 目标中央用户
* @return JsonResponse 强制下线结果
*/
public function forceLogout(
ForceLogoutRequest $request,
User $user,
): JsonResponse {
$revokedCount = $this->forceLogoutService->execute(
operator: $request->user('api'),
target: $user,
reason: $request->validated('reason'),
);
return ApiResponse::success(
data: ['revoked_count' => $revokedCount],
message: '用户已强制下线',
);
}
Controller 不开事务、不直接撤销 Token、不直接调用 activity()。中央 activity_log Migration 和 CentralActivity 已存在,不重复创建第二张审计表。
7. Route 和权限
放入 app/Operator/Routes/api.php 的 Operator 认证组中。新增引入:UserSessionController 和 OperatorPermission。
/**
* 强制下线指定中央用户的全部登录会话。
*
* @method POST
* @url /api/operator/users/{user}/force-logout
* @auth Bearer Token
* @permission platform.users.force_logout
*/
Route::post('/users/{user}/force-logout', [UserSessionController::class, 'forceLogout'])
->whereNumber('user')
->middleware(
'permission:'.OperatorPermission::ForceLogoutUser->value,
)
->name('users.force-logout');
权限中间件的作用是先阻止无权限用户;ForceLogoutRequest 的作用是验证 reason 参数,两者职责不同,不能只保留其中一个。
权限初始化必须在中央权限数据中增加:
platform.users.force_logout
平台角色还必须绑定该权限。租户角色不能直接绑定这个平台权限;租户管理员版本后续应使用独立的租户 Policy,并先检查目标用户是否属于当前租户。
8. ApiPost 测试
请求 URL:
POST http://127.0.0.1:8000/api/operator/users/2/force-logout
Header:
Authorization: Bearer 有 platform.users.force_logout 权限的管理员Token
Accept: application/json
Content-Type: application/json
Body:
{
"reason": "账号安全处置测试"
}
成功响应:
{
"code": 0,
"message": "用户已强制下线",
"data": {
"revoked_count": 2
}
}
无权限响应:
{
"code": 40300,
"message": "没有执行强制下线的权限",
"data": null
}
参数错误响应:
{
"code": 42200,
"message": "参数验证失败",
"data": {
"reason": ["强制下线原因不能为空"]
}
}
9. 自动化测试清单
- [ ] 未登录调用返回
40100。 - [ ] 没有
platform.users.force_logout权限返回40300。 - [ ]
reason缺失、非字符串或超过 500 字符返回42200。 - [ ] 目标用户全部未撤销 Access Token 被撤销。
- [ ] 目标用户关联 Refresh Token 被撤销。
- [ ] 已过期 Access Token 关联的有效 Refresh Token 也被撤销。
- [ ] 目标用户再次请求
/api/admin/auth/me返回 401。 - [ ] 目标用户使用旧 Refresh Token 请求
/oauth/token返回invalid_grant。 - [ ] 审计日志包含操作人、目标用户、原因和撤销数量。
- [ ] 审计日志不包含任何 Token、密码或 Secret。
10. 完成标准
只有以下内容全部通过,功能 E 才能从“规划实现”改为“已完成”:
- [ ] 中央审计日志迁移成功。
- [ ] 权限和平台角色初始化成功。
- [ ] Service、Controller、Request、Route PHP 语法检查通过。
- [ ] 成功、无权限、参数错误三条 ApiPost 链路通过。
- [ ] 目标用户旧 Access Token 和 Refresh Token 均已失效。
- [ ] 自动化测试和全量测试通过。
自动测试清单
- [ ] 会话列表未登录返回 401。
- [ ] 会话列表不返回 Token 原文。
- [ ] 用户不能撤销他人的会话。
- [ ] 指定会话撤销 Access 和 Refresh。
- [ ] 退出其他设备保留当前会话。
- [ ] 退出其他设备撤销已过期 Access 对应的有效 Refresh。
- [ ] 平台强制下线要求平台权限和原因;租户版本另行校验目标租户成员归属。
- [ ] 强制下线产生脱敏审计日志。
- [ ] 本地快速 Token 在非 local 环境不可用。
14-身份中心-邀请注册和账号状态.md
身份中心:邀请注册和账号状态完整流程
0. 状态与依赖
执行阶段:P09 Identity 核心
功能状态:邀请与账号状态已有实现,待按统一审计和队列规则回归
所属区域:Platform/Identity + Platform/Tenancy
数据归属:中央数据库 + Notification Queue
前置流程:P00-P09 登录安全、P10 Tenant 核心、P08 Queue/Notification
邀请功能不得早于 Tenant、Membership、统一审计和队列提交后发送规则。账号状态改变与 Token 撤销必须处于同一个中央事务。
当前状态:已有邀请和账号状态基础实现;执行本文时只补齐统一事务、提交后通知、审计和失败路径测试,不重复创建现有能力。
0.1 文件地图
| 职责 | 文件 | 操作类型 |
|---|---|---|
| 邀请数据 | app/Platform/Identity/Models/UserInvitation.php |
完整文件或核对现有实现 |
| 邀请参数 | app/Platform/Identity/Http/Requests/InviteUserRequest.php、AcceptInvitationRequest.php |
完整文件 |
| 邀请用例 | app/Platform/Identity/Services/UserInvitationService.php |
完整文件 |
| 邀请通知 | app/Platform/Identity/Notifications/TenantInvitationNotification.php |
复用文档 12 的队列通知规范 |
| 账号状态 | app/Platform/Identity/Enums/UserStatus.php |
完整文件 |
| 登录状态检查 | app/Platform/Identity/Services/AuthService.php |
局部修改 |
| Operator 状态用例 | app/Operator/Services/UpdateUserStatusService.php |
完整文件 |
| Operator 请求与入口 | app/Operator/Http/Requests/UpdateUserStatusRequest.php、app/Operator/Http/Controllers/UserStatusController.php |
完整文件 |
| 路由 | routes/api.php、routes/operator.php |
局部修改 |
| 自动测试 | tests/Feature/Identity、tests/Feature/Operator 下对应测试 |
新增完整成功和失败路径 |
邀请和状态变更不能共用一个“大而全”服务:邀请属于 Identity 成员接入,平台管理员停用账号属于 Operator 用例;两者只复用 Token、通知和审计基础能力。
功能 A:租户成员邀请
1. 功能目的
租户管理员通过邮箱邀请人员。已有中央用户接受后直接加入租户;新用户设置姓名和密码后创建中央 User,再建立 tenant_membership。业务模块不能自己创建第二套账号。
2. Migration
创建:
php artisan make:migration create_user_invitations_table
迁移属于中央库:
// 文件:database/migrations/YYYY_MM_DD_HHMMSS_create_user_invitations_table.php
// 需要引入:use Illuminate\Database\Schema\Blueprint;
// 需要引入:use Illuminate\Support\Facades\Schema;
// 该迁移使用中央数据库连接,不能放入 database/migrations/tenant。
Schema::create('user_invitations', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->string('tenant_id');
$table->string('email');
$table->string('role_name')->nullable();
$table->string('token_hash', 64)->unique();
$table->string('status', 20)->default('pending');
$table->foreignId('invited_by')->constrained('users');
$table->timestampTz('expires_at');
$table->timestampTz('accepted_at')->nullable();
$table->timestampsTz();
$table->index(['tenant_id', 'email']);
$table->index(['tenant_id', 'status']);
});
down():
// 文件:database/migrations/YYYY_MM_DD_HHMMSS_create_user_invitations_table.php
// 需要引入:use Illuminate\Support\Facades\Schema;
Schema::dropIfExists('user_invitations');
为什么保存 token_hash:邮件中的原始 Token 相当于临时密码,数据库泄漏时不能直接使用。
3. Model
app/Platform/Identity/Models/UserInvitation.php:
<?php
/**
* @description 中央用户邀请模型
* @author Yanhong <jackyan@pshang.net>
* @createTime YYYY-MM-DD HH:mm:ss
* @lastModified YYYY-MM-DD HH:mm:ss
*/
namespace App\Platform\Identity\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Stancl\Tenancy\Database\Concerns\CentralConnection;
class UserInvitation extends Model
{
/**
* 中央数据库中的租户成员邀请模型。
*
* 原始邀请 Token 只在邮件中出现,模型只保存 token_hash。
*/
use CentralConnection;
use HasUuids;
protected $fillable = [
'tenant_id',
'email',
'role_name',
'token_hash',
'status',
'invited_by',
'expires_at',
'accepted_at',
];
protected $hidden = [
'token_hash',
];
/**
* 获取邀请时间字段的类型转换规则。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'accepted_at' => 'datetime',
];
}
/** 获取发起邀请的中央用户。 */
public function inviter(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_by');
}
}
4. Request
InviteUserRequest.php:
<?php
/**
* @description 租户管理员邀请用户请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Identity\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
final class InviteUserRequest extends ApiRequest
{
/**
* 权限由路由中间件负责,本 Request 只负责参数验证。
*/
public function authorize(): bool
{
return true;
}
/**
* 获取创建邀请的验证规则。
*
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255'],
'role_name' => ['nullable', 'string', 'max:100'],
];
}
/**
* 获取创建邀请的中文验证消息。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'email.required' => '邮箱不能为空',
'email.email' => '邮箱格式不正确',
'role_name.max' => '角色名称不能超过 100 个字符',
];
}
}
AcceptInvitationRequest.php:
<?php
/**
* @description 用户接受租户邀请请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Identity\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
use Illuminate\Validation\Rules\Password;
final class AcceptInvitationRequest extends ApiRequest
{
/**
* 接受邀请不依赖登录态,Token 由 Service 校验。
*/
public function authorize(): bool
{
return true;
}
/**
* 获取接受邀请的验证规则。
*
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'token' => ['required', 'string'],
'name' => ['nullable', 'string', 'max:255'],
'password' => ['nullable', 'confirmed', Password::default()],
];
}
/**
* 获取接受邀请的中文验证消息。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'token.required' => '邀请 Token 不能为空',
'password.confirmed' => '两次输入的密码不一致',
];
}
}
Controller 还需要根据“邮箱是否已有 User”决定新用户必须填写 name/password,该业务分支放 Service 中二次检查。
5. Service
本流程统一事务规则
本功能的邀请记录、中央用户和租户成员关系都属于中央数据库。凡是一次业务操作需要写入两张或以上中央表,必须由 Service 在最外层开启一次事务。
统一写法为:从本次业务涉及的中央模型获取数据库连接,再调用该连接的 transaction()。本流程不在业务代码中直接使用 DB::transaction()、手写 pgsql 连接名,也不让模型自行分别提交。
这样做的意义是:
- 避免依赖默认数据库连接,防止未来启用租户连接后误写租户库。
- 保证“撤销旧邀请并创建新邀请”要么全部成功,要么全部回滚。
- 保证“创建中央用户、创建成员关系、更新邀请状态”不会只完成一半。
- 后续其他身份中心 Service 继续复制这一规则,保持事务风格一致。
只有跨数据库操作、需要 Outbox/补偿机制,或确实需要手动控制提交时,才允许使用特殊事务方案,并必须在代码注释中说明原因。
app/Platform/Identity/Services/InvitationService.php 关键代码:
// 文件:app/Platform/Identity/Services/InvitationService.php
// 需要引入:use App\Platform\Identity\Models\UserInvitation;
// 需要引入:use App\Platform\Identity\Models\User;
// 需要引入:use App\Platform\Tenancy\Models\TenantMembership;
// 需要引入:use Illuminate\Support\Str;
// 需要引入:use DomainException;
/**
* 创建租户成员邀请。
*
* @return array{invitation: UserInvitation, plain_token: string}
*/
public function create(
User $inviter,
string $tenantId,
string $email,
?string $roleName,
): array {
// 事务连接从中央模型获取,避免依赖默认连接或手写连接名称。
$connection = (new UserInvitation())->getConnection();
return $connection->transaction(function () use ($inviter, $tenantId, $email, $roleName): array {
$exists = TenantMembership::query()
->where('tenant_id', $tenantId)
->whereHas('user', fn ($query) => $query->where('email', $email))
->exists();
if ($exists) {
throw new DomainException('该用户已经是当前租户成员');
}
UserInvitation::query()
->where('tenant_id', $tenantId)
->where('email', $email)
->where('status', 'pending')
->update(['status' => 'revoked']);
$plainToken = Str::random(64);
$invitation = UserInvitation::create([
'tenant_id' => $tenantId,
'email' => Str::lower($email),
'role_name' => $roleName,
'token_hash' => hash('sha256', $plainToken),
'status' => 'pending',
'invited_by' => $inviter->id,
'expires_at' => now()->addDays(3),
]);
return [
'invitation' => $invitation,
'plain_token' => $plainToken,
];
});
}
接受邀请:
// 文件:app/Platform/Identity/Services/InvitationService.php
// 需要引入:use App\Platform\Identity\Models\User;
// 需要引入:use App\Platform\Identity\Models\UserInvitation;
// 需要引入:use App\Platform\Tenancy\Models\TenantMembership;
// 需要引入:use Illuminate\Support\Facades\Hash;
// 需要引入:use Illuminate\Validation\ValidationException;
/**
* 接受邀请并建立中央用户与租户成员关系。
*/
public function accept(
string $plainToken,
?string $name,
?string $password,
): TenantMembership {
$invitation = UserInvitation::query()
->where('token_hash', hash('sha256', $plainToken))
->first();
if ($invitation === null
|| $invitation->status !== 'pending'
|| $invitation->expires_at->isPast()) {
throw new DomainException('邀请不存在或已失效');
}
// 事务连接从邀请模型获取,确保相关记录使用同一中央数据库。
$connection = $invitation->getConnection();
return $connection->transaction(function () use ($invitation, $name, $password): TenantMembership {
$user = User::where('email', $invitation->email)->first();
if ($user === null) {
if ($name === null || $password === null) {
throw ValidationException::withMessages([
'password' => ['新用户必须填写姓名和密码'],
]);
}
$user = User::create([
'name' => $name,
'email' => $invitation->email,
'password' => Hash::make($password),
]);
}
$membership = TenantMembership::firstOrCreate(
[
'tenant_id' => $invitation->tenant_id,
'user_id' => $user->id,
],
[
'status' => 'active',
'is_owner' => false,
'joined_at' => now(),
],
);
$invitation->forceFill([
'status' => 'accepted',
'accepted_at' => now(),
])->save();
return $membership;
});
}
角色分配应在 membership 创建后设置当前 team_id 再调用 Spatie assignRole();执行时必须验证角色属于邀请租户。
6. Controller
// 文件:app/Platform/Identity/Http/Controllers/TenantInvitationController.php
// 需要引入:use App\Platform\Identity\Http\Requests\InviteUserRequest;
// 需要引入:use App\Platform\Identity\Services\InvitationService;
// 需要引入:use App\Support\ApiResponse;
// 需要引入:use Illuminate\Http\JsonResponse;
/**
* 创建租户成员邀请并触发邀请通知。
*
* @param InviteUserRequest $request 已验证的邀请参数
* @param InvitationService $service 邀请业务服务
* @return JsonResponse 邀请创建结果
*/
public function invite(
InviteUserRequest $request,
InvitationService $service,
): JsonResponse {
$result = $service->create(
$request->user('api'),
tenant('id'),
$request->validated('email'),
$request->validated('role_name'),
);
// 正式环境使用 Notification 发送 Token,不在 API 返回原始 Token。
// local 环境如需调试,可以只写 Mailpit 或测试日志。
return ApiResponse::success(
data: ['id' => $result['invitation']->id],
message: '邀请邮件已发送',
);
}
// 文件:app/Platform/Identity/Http/Controllers/AuthController.php
// 需要引入:use App\Platform\Identity\Http\Requests\AcceptInvitationRequest;
// 需要引入:use App\Platform\Identity\Services\InvitationService;
// 需要引入:use App\Platform\Tenancy\Http\Resources\TenantMemberResource;
// 需要引入:use App\Support\ApiResponse;
// 需要引入:use Illuminate\Http\JsonResponse;
/**
* 接受公开邀请并建立中央用户与租户成员关系。
*
* @param AcceptInvitationRequest $request 已验证的邀请参数
* @param InvitationService $service 邀请业务服务
* @return JsonResponse 新成员关系
*/
public function acceptInvitation(
AcceptInvitationRequest $request,
InvitationService $service,
): JsonResponse {
$membership = $service->accept(
$request->validated('token'),
$request->validated('name'),
$request->validated('password'),
);
return ApiResponse::success(
data: new TenantMemberResource($membership->load('user')),
message: '接受邀请成功',
);
}
7. Routes
租户管理员邀请,放租户认证和权限组:
/**
* 创建租户成员邀请。
*
* @method POST
* @url /api/tenant/members/invitations
* @auth Bearer Token
* @permission tenant.members.manage
*/
Route::post('/members/invitations', [TenantInvitationController::class, 'invite'])
->middleware('permission:tenant.members.manage')
->name('tenant.members.invitations.store');
接受邀请是公开中央接口,不依赖租户域名:
/**
* 接受公开的租户成员邀请。
*
* @method POST
* @url /api/admin/auth/invitations/accept
* @auth Public + throttle:login
*/
Route::post('/admin/auth/invitations/accept', [AuthController::class, 'acceptInvitation'])
->middleware('throttle:login')
->name('admin.auth.invitations.accept');
8. ApiPost
创建邀请:
POST http://demo.localhost:8000/api/tenant/members/invitations
Authorization: Bearer 租户管理员AccessToken
Content-Type: application/json
{
"email": "new-user@example.com",
"role_name": "tenant-member"
}
接受邀请:
POST http://127.0.0.1:8000/api/admin/auth/invitations/accept
Content-Type: application/json
{
"token": "邮件中的邀请Token",
"name": "新成员",
"password": "符合规则的密码",
"password_confirmation": "相同密码"
}
9. 测试
- 已有成员不能重复邀请。
- 旧 pending 邀请被撤销,新 Token 有效。
- 原始 Token 不进入数据库和 API 响应。
- 过期、撤销和已使用 Token 失败。
- 并发接受只创建一个 membership。
- 新用户必须提供姓名密码,已有用户不修改原密码。
- 邀请者必须有当前租户成员管理权限。
功能 B:账号状态
1. Migration
php artisan make:migration add_status_columns_to_users_table
// 文件:database/migrations/YYYY_MM_DD_HHMMSS_add_status_columns_to_users_table.php
// 需要引入:use Illuminate\Database\Schema\Blueprint;
// 需要引入:use Illuminate\Support\Facades\Schema;
Schema::table('users', function (Blueprint $table): void {
$table->string('status', 20)->default('active')->index();
$table->timestampTz('locked_at')->nullable();
$table->string('disabled_reason', 500)->nullable();
});
2. User Model
增加 fillable/cast,但不要通过普通资料更新接口允许用户自行修改状态:
// 文件:app/Platform/Identity/Models/User.php
// User 模型已经引入 CentralConnection;这里只补充 casts 方法的 PHPDoc。
/**
* 获取中央用户状态字段的类型转换规则。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'locked_at' => 'datetime',
];
}
3. Login Service
密码正确后、签发 Token 前检查:
/**
* 在签发 Access Token 前拒绝非 active 状态的用户。
*
* @throws DomainException 账号不可登录时抛出
*/
if ($user->status !== 'active') {
throw new DomainException('账号当前不可登录');
}
Controller 将该异常映射为 ErrorCode::ACCOUNT_LOCKED,但不要向外暴露内部风控细节。
4. 状态管理 Service
文件:app/Operator/Services/UpdateUserStatusService.php
当前正式 execute() 用例一次完成:
验证不能禁用当前 Operator 自己
-> 验证 User、Passport Token、中央审计使用同一连接
-> 开启中央事务并 lockForUpdate
-> 相同状态幂等返回 changed=false
-> 禁用前保护最后一个有效平台超级管理员
-> 更新 UserStatus、操作人、时间和原因
-> 禁用时撤销全部 Access/Refresh Token
-> 写中央安全审计
-> 返回 user/revoked_count/changed
状态只使用 UserStatus Enum。Operator Controller 不直接更新 User,也不自行开启事务。P05 对齐后审计依赖迁移到共享 Platform/Audit 入口。
5. Route 和权限
/**
* 更新中央用户账号状态。
*
* @method PATCH
* @url /api/operator/users/{user}/status
* @auth Bearer Token
* @permission platform.users.status.update
*/
Route::patch('/users/{user}/status', [UserAccountController::class, 'updateStatus'])
->whereNumber('user')
->middleware(
'permission:'.OperatorPermission::ManageUserStatus->value,
)
->name('users.status.update');
租户管理员只能停用当前租户 membership,不能停用中央 User;中央账号停用属于平台运营或身份安全权限。
6. ApiPost
PATCH http://127.0.0.1:8000/api/operator/users/2/status
Authorization: Bearer 平台安全管理员Token
Content-Type: application/json
{
"status": "locked",
"reason": "连续异常登录"
}
预期:用户状态更新、全部 Token 撤销、后续登录返回账号不可用、审计日志记录原因但不记录凭证。
15-身份中心-MFA外部身份和登录日志.md
身份中心:MFA、外部身份和登录日志完整流程
0. 状态与依赖
执行阶段:P09 Identity 扩展
功能状态:登录安全事件部分实现;MFA 与外部身份为规划草案
所属区域:Platform/Identity
数据归属:中央数据库
前置流程:Identity 登录闭环、Request ID、审计、限流、Queue
执行前必须重新核对 Laravel Fortify、Passport 和 Passkey 相关包的锁定版本。未标记“完整文件”的代码块只用于设计解释,不得直接保存。
0.1 文件和代码引导
| 功能 | 目标文件 | 代码方式 |
|---|---|---|
| MFA Request | app/Platform/Identity/Http/Requests/ConfirmMfaRequest.php |
新建完整文件,继承 ApiRequest |
| 外部身份模型 | app/Platform/Identity/Models/UserIdentity.php |
新建完整中央模型 |
| Provider 契约 | app/Platform/Identity/Contracts/ExternalIdentityProvider.php |
新建完整接口 |
| Provider 数据 | app/Platform/Identity/Data/ExternalIdentityData.php |
新建共享 DTO,多个 Provider 共用才成立 |
| 外部身份服务 | app/Platform/Identity/Services/ExternalIdentityService.php |
新建完整 Service |
| 登录安全事件 | IdentityLoginEvent、IdentityLoginEventService、IdentityLoginOccurred、两个 Listener |
当前实现,禁止再建第二套 login_logs |
以下方法片段均属于“局部修改”。执行时必须按 03-代码引导与学习验收规范.md 补齐文件头、namespace、use 和完整类体。
当前状态:登录安全事件已有实现;MFA 和外部身份仍为规划功能,必须在账号状态和邀请注册回归后实施。
功能 A:Fortify MFA/2FA
1. 功能目的
密码泄漏时仍要求第二因素。优先复用 Fortify 已安装能力,不自己实现 TOTP 算法和恢复码生成。
2. 配置
config/fortify.php 启用:
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]),
确保 users 已有:
two_factor_secret
two_factor_recovery_codes
two_factor_confirmed_at
User 使用 Fortify TwoFactorAuthenticatable Trait:
这是对现有 app/Platform/Identity/Models/User.php 的局部修改,不可用下面片段覆盖整个模型。
在文件引入区新增:
use Laravel\Fortify\TwoFactorAuthenticatable;
在 User 类的 trait 使用区新增:
use TwoFactorAuthenticatable;
3. Controller/Service 边界
Fortify 已提供启用、确认、恢复码和挑战动作。项目只新增统一 API 响应适配和安全审计,不复制 Fortify 算法。
建议 MfaController 提供:
POST /api/admin/auth/mfa/enable
POST /api/admin/auth/mfa/confirm
GET /api/admin/auth/mfa/recovery-codes
POST /api/admin/auth/mfa/recovery-codes
DELETE /api/admin/auth/mfa
Controller 调用 Fortify Action 或框架能力后返回 ApiResponse。Secret、二维码内容和恢复码只在必要步骤返回,绝不进入 UserResource 和日志。
4. 示例确认 Request
<?php
/**
* @description 确认多因素认证验证码请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Identity\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
final class ConfirmMfaRequest extends ApiRequest
{
/** 认证和权限由路由中间件负责。 */
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'code' => ['required', 'string', 'size:6'],
];
}
}
5. 测试
- 未确认前不视为启用。
- 错误 TOTP 失败且限流。
- 正确密码但未完成 MFA 挑战不能得到最终 Token。
- 恢复码只能使用一次。
- 禁用 MFA 需要重新确认密码。
- API、Resource 和日志不暴露 secret/recovery codes。
功能 B:外部身份表和绑定
1. Migration
php artisan make:migration create_user_identities_table
Schema::create('user_identities', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('provider', 50);
$table->string('provider_subject');
$table->jsonb('profile')->nullable();
$table->timestampTz('last_login_at')->nullable();
$table->timestampsTz();
$table->unique(['provider', 'provider_subject']);
$table->unique(['user_id', 'provider']);
});
表属于中央库。provider_subject 使用第三方稳定用户标识,不使用可能变化的昵称和手机号作为唯一键。
2. Model
<?php
/**
* @description 中央用户外部身份绑定模型
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Identity\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Stancl\Tenancy\Database\Concerns\CentralConnection;
final class UserIdentity extends Model
{
use CentralConnection;
protected $fillable = [
'user_id',
'provider',
'provider_subject',
'profile',
'last_login_at',
];
/** @return array<string, string> */
protected function casts(): array
{
return [
'profile' => 'array',
'last_login_at' => 'datetime',
];
}
/** 获取该外部身份所属的中央用户。 */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
3. Adapter 契约
<?php
/**
* @description 外部身份提供方适配契约
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Identity\Contracts;
use App\Platform\Identity\Data\ExternalIdentityData;
interface ExternalIdentityProvider
{
/** 生成带状态参数的提供方授权地址。 */
public function authorizationUrl(string $state): string;
/** 使用一次性授权码换取标准化外部身份。 */
public function exchangeCode(string $code): ExternalIdentityData;
}
<?php
/**
* @description 标准化外部身份数据
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Identity\Data;
final readonly class ExternalIdentityData
{
/** @param array<string, mixed> $profile 提供方返回的非敏感资料 */
public function __construct(
public string $provider,
public string $subject,
public ?string $email,
public array $profile,
) {}
}
4. Service
public function loginOrBind(ExternalIdentityData $identity): User
{
$binding = UserIdentity::query()
->where('provider', $identity->provider)
->where('provider_subject', $identity->subject)
->first();
if ($binding === null) {
throw new DomainException('该外部身份尚未绑定平台账号');
}
if ($binding->user->status !== 'active') {
throw new DomainException('账号当前不可登录');
}
$binding->forceFill([
'profile' => $identity->profile,
'last_login_at' => now(),
])->save();
return $binding->user;
}
不能只因为第三方返回相同邮箱就自动合并中央用户。首次绑定必须由已登录用户确认,或经过明确邀请/管理员审批流程。
5. Routes
Route::get('/external/{provider}/redirect', [ExternalAuthController::class, 'redirect']);
Route::get('/external/{provider}/callback', [ExternalAuthController::class, 'callback']);
Route::post('/external/{provider}/bind', [ExternalIdentityController::class, 'bind'])
->middleware('auth:api');
Route::delete('/external/{provider}', [ExternalIdentityController::class, 'unbind'])
->middleware('auth:api');
回调必须校验 state、code 一次性、provider 白名单、超时和第三方错误;绑定解绑必须审计。
功能 C:登录安全事件
1. 唯一数据模型
当前项目统一使用中央表 identity_login_events,禁止再创建 login_logs。事件至少包含:用户、脱敏标识哈希、认证方式、结果、失败原因、IP、User-Agent、OAuth Client、Request ID 和发生时间。
2. 唯一写入链路
密码错误/账号禁用/限流
-> IdentityLoginEventService
-> IdentityLoginOccurred
-> PersistIdentityLoginEvent
Passport 成功签发 Token
-> AccessTokenCreated
-> RecordPassportAccessToken
-> IdentityLoginEventService
成功事件只由 Passport Token Listener 记录,避免 Controller 和 Service 重复写两次。失败事件在掌握失败原因的 Service/Response 中记录。
3. 当前文件
app/Platform/Identity/Models/IdentityLoginEvent.phpapp/Platform/Identity/Services/IdentityLoginEventService.phpapp/Platform/Identity/Events/IdentityLoginOccurred.phpapp/Platform/Identity/Listeners/PersistIdentityLoginEvent.phpapp/Platform/Identity/Listeners/RecordPassportAccessToken.phpapp/Platform/Identity/Enums/AuthenticationMethod.phpapp/Platform/Identity/Enums/LoginOutcome.phpapp/Platform/Identity/Enums/LoginFailureCode.php
4. 查询接口
Method:GET
URL:http://127.0.0.1:8000/api/admin/auth/login-events?page=1&page_size=20
Header:Accept: application/json、Authorization: Bearer <Access Token>
Body:无
用户只能查看自己的记录;Operator 查看其他用户需要独立权限。Resource 默认返回设备摘要,不返回完整敏感 User-Agent。
5. 测试
- 登录成功、密码错误、账号禁用和限流各记录一次。
- 不存在账号时记录不可逆标识哈希,不泄露原邮箱。
- 密码、Token、code、verifier 和 Secret 不进入事件。
- Request ID 可以关联应用日志。
- Passport 成功事件不会重复。
- 保留期清理 Command 可分批删除过期事件。
16-租户-自动开通生命周期和隔离.md
租户:自动开通、生命周期和隔离完整流程
0. 状态与依赖
执行阶段:P10 Tenant 核心
功能状态:租户、域名、成员和独立库基础已实现;生命周期状态机待完善
所属区域:Platform/Tenancy
数据归属:中央控制面 + 独立租户数据库
前置流程:P00-P08、Identity 中央用户
租户开通是跨中央库、数据库管理和租户 Migration 的长流程,不得使用单个事务伪装原子性。正式异步化前先完成状态机、幂等、重试和失败恢复。
0.1 文件地图
| 层 | 目标文件 | 说明 |
|---|---|---|
| Migration | database/migrations/*_add_provisioning_columns_to_tenants_table.php |
中央库完整 Migration |
| Request | app/Operator/Http/Requests/CreateTenantRequest.php |
继承 ApiRequest |
| Service | app/Platform/Tenancy/Services/TenantProvisioningService.php |
局部替换完整 provision();跨库状态机 |
| Resource | app/Platform/Tenancy/Http/Resources/TenantResource.php |
新建完整文件 |
| Controller | app/Operator/Http/Controllers/OperatorTenantController.php |
Operator HTTP 编排 |
| Middleware | app/Platform/Tenancy/Http/Middleware/EnsureTenantIsActive.php |
租户初始化后执行 |
| 配置 | config/edition.php |
Cloud/Private 部署模式 |
下方裸方法均为局部修改,执行时必须补齐新增 use、构造注入、文件头和方法 PHPDoc。
当前状态:demo 租户、数据库、域名和健康接口已验证;本文只规划尚未完成的自动开通、生命周期状态机和 Private 单租户模式。
功能 A:自动开通租户
1. 功能目的
一次请求完成中央租户记录、租户数据库、迁移、owner membership 和域名,且失败后可以识别和重试。
2. Tenant 状态字段 Migration
php artisan make:migration add_provisioning_columns_to_tenants_table
Schema::table('tenants', function (Blueprint $table): void {
$table->string('name')->nullable();
$table->string('status', 30)->default('provisioning')->index();
$table->text('provisioning_error')->nullable();
$table->timestampTz('provisioned_at')->nullable();
$table->timestampTz('suspended_at')->nullable();
});
状态:provisioning/active/failed/suspended/archived。
3. Request
CreateTenantRequest.php:
<?php
/**
* @description 创建并开通租户请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Operator\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
final class CreateTenantRequest extends ApiRequest
{
/** 授权由 Operator 路由权限中间件负责。 */
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'id' => ['required', 'string', 'min:3', 'max:50', 'regex:/^[a-z0-9-]+$/', 'unique:tenants,id'],
'name' => ['required', 'string', 'max:255'],
'domain' => ['required', 'string', 'max:255', 'unique:domains,domain'],
'owner_user_id' => ['required', 'integer', 'exists:users,id'],
];
}
}
该接口属于 Operator,验证中央表;不能在租户连接中执行 exists:users。
4. Service
在现有 TenantProvisioningService 中形成单一入口:
/**
* 创建并初始化一个数据库隔离租户。
*
* @param array{id:string,name:string,domain:string,owner_user_id:int} $data
* @return Tenant
*/
public function provision(array $data): Tenant
{
$tenant = Tenant::firstOrCreate(
['id' => $data['id']],
[
'name' => $data['name'],
'status' => 'provisioning',
],
);
if ($tenant->status === 'active') {
return $tenant;
}
try {
if (! $tenant->domains()->where('domain', $data['domain'])->exists()) {
$tenant->domains()->create(['domain' => $data['domain']]);
}
// Stancl 的 TenantCreated 监听器负责创建数据库时,不要再次手工创建。
tenancy()->initialize($tenant);
Artisan::call('tenants:migrate', [
'--tenants' => [$tenant->getTenantKey()],
'--force' => true,
]);
tenancy()->end();
TenantMembership::firstOrCreate(
[
'tenant_id' => $tenant->getTenantKey(),
'user_id' => $data['owner_user_id'],
],
[
'status' => 'active',
'is_owner' => true,
'joined_at' => now(),
],
);
$tenant->forceFill([
'status' => 'active',
'provisioning_error' => null,
'provisioned_at' => now(),
])->save();
return $tenant->refresh();
} catch (Throwable $exception) {
tenancy()->end();
$tenant->forceFill([
'status' => 'failed',
'provisioning_error' => Str::limit($exception->getMessage(), 2000),
])->save();
throw $exception;
}
}
注意:先检查项目 Tenancy Pipeline 是否已经自动创建数据库和迁移。监听器和 Service 只能保留一个职责来源,否则会重复创建数据库。正式实现前用 config/tenancy.php 和 EventServiceProvider 核对。
5. Resource
public function toArray(Request $request): array
{
return [
'id' => $this->getTenantKey(),
'name' => $this->name,
'status' => $this->status,
'domains' => $this->whenLoaded('domains', fn () => $this->domains->pluck('domain')),
'provisioned_at' => $this->provisioned_at,
];
}
不返回数据库密码和 provisioning_error 原始堆栈。
6. Controller
public function store(
CreateTenantRequest $request,
TenantProvisioningService $service,
): JsonResponse {
$tenant = $service->provision($request->validated());
return ApiResponse::success(
data: new TenantResource($tenant->load('domains')),
message: '租户创建成功',
);
}
7. Route
Route::post('/operator/tenants', [OperatorTenantController::class, 'store'])
->middleware(['auth:api', 'permission:operator.tenants.create'])
->name('operator.tenants.store');
该路由只进入 Cloud Operator,Private Edition 不加载。
8. ApiPost
POST http://127.0.0.1:8000/api/operator/tenants
Authorization: Bearer 运营管理员Token
Content-Type: application/json
{
"id": "customer-a",
"name": "客户 A",
"domain": "customer-a.localhost",
"owner_user_id": 2
}
9. 验收
- tenants 状态 active。
- domains 存在且唯一。
- 租户数据库存在并完成全部租户迁移。
- owner membership 只创建一次。
- 重复相同请求不重复建库和成员。
- 迁移失败时状态 failed,修复后可以重试。
功能 B:暂停和恢复租户
1. Request
<?php
/**
* @description 暂停租户请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Operator\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
final class SuspendTenantRequest extends ApiRequest
{
/** @return array<string, array<int, string>> */
public function rules(): array
{
return ['reason' => ['required', 'string', 'max:500']];
}
}
2. Service
public function suspend(Tenant $tenant, string $reason): Tenant
{
if ($tenant->status !== 'active') {
throw new DomainException('只有正常租户可以暂停');
}
$tenant->forceFill([
'status' => 'suspended',
'suspended_at' => now(),
'data' => array_merge($tenant->data ?? [], ['suspend_reason' => $reason]),
])->save();
return $tenant->refresh();
}
public function resume(Tenant $tenant): Tenant
{
if ($tenant->status !== 'suspended') {
throw new DomainException('只有暂停租户可以恢复');
}
// 恢复前应检查数据库连接和迁移状态。
$tenant->forceFill([
'status' => 'active',
'suspended_at' => null,
])->save();
return $tenant->refresh();
}
3. Middleware
在租户初始化后、业务 Controller 前:
if (tenant('status') !== 'active') {
return ApiResponse::error(
code: ErrorCode::FORBIDDEN,
message: '当前租户已暂停服务',
status: 403,
);
}
健康检查和必要的运营恢复接口可以使用独立路由组绕过业务阻断。
4. Routes
Route::post('/operator/tenants/{tenant}/suspend', [OperatorTenantController::class, 'suspend']);
Route::post('/operator/tenants/{tenant}/resume', [OperatorTenantController::class, 'resume']);
5. ApiPost
POST http://127.0.0.1:8000/api/operator/tenants/customer-a/suspend
{
"reason": "订阅到期测试"
}
暂停后租户业务 API 返回 403;恢复后健康接口和业务接口重新可用。
功能 C:Private 单租户模式
1. 配置
DEPLOYMENT_MODE=private
TENANT_MODE=single
PRIVATE_TENANT_ID=customer-a
config/edition.php:
return [
'deployment_mode' => env('DEPLOYMENT_MODE', 'cloud'),
'tenant_mode' => env('TENANT_MODE', 'multi'),
'private_tenant_id' => env('PRIVATE_TENANT_ID'),
];
2. Middleware
Private 请求在没有域名识别时初始化唯一租户:
if (config('edition.tenant_mode') === 'single') {
$tenant = Tenant::find(config('edition.private_tenant_id'));
abort_if($tenant === null, 503, '私有化租户尚未初始化');
tenancy()->initialize($tenant);
}
Cloud 继续使用域名识别。Operator 路由注册时检查 deployment_mode=cloud。
功能 D:隔离自动测试
创建 tenant A/B,各自迁移相同测试表。测试中依次 tenancy()->initialize($tenantA) 和 B,写入不同记录,再断言彼此看不到。测试结束必须 tenancy()->end(),避免后续测试继承旧上下文。
还要覆盖:
- 中央 User/Role 在租户上下文中仍连接 pgsql。
- tenant A Job 执行时恢复 A,不能写 B。
- Redis Key 包含 tenant ID。
- 文件路径包含 tenant ID,下载接口仍检查 membership。
17-权限作用域基础设施.md
权限作用域基础设施完整流程
0. 状态与依赖
执行阶段:P11 Authorization 核心第一步
功能状态:已实现并通过作用域、外键、Seeder 和权限回归测试
所属区域:Platform/Authorization
数据归属:中央数据库
前置流程:Identity、Tenant、中央/租户连接基线
本流程必须早于角色管理和成员角色分配。后续权限功能统一使用 authorization_scopes.id 作为 Spatie team_id,不再使用 tenant_id 冒充权限作用域主键。
当前状态:已实施并验证;本文件保留为实现说明、数据迁移依据和后续权限功能的强制前置规则。
0.1 文件地图
| 职责 | 文件 | 操作类型 |
|---|---|---|
| 作用域表和旧数据迁移 | database/migrations/*_create_authorization_scopes_and_migrate_permission_teams.php |
完整 Migration |
| 作用域类型 | app/Platform/Authorization/Enums/AuthorizationScopeType.php |
完整文件 |
| 作用域模型 | app/Platform/Authorization/Models/AuthorizationScope.php |
完整文件 |
| 作用域统一入口 | app/Platform/Authorization/Services/AuthorizationScopeService.php |
完整文件 |
| Spatie Team 模型配置 | config/permission.php |
局部修改 |
| Tenant 关系 | app/Platform/Tenancy/Models/Tenant.php |
局部修改 |
| 租户开通 | app/Platform/Tenancy/Services/TenantProvisioningService.php |
局部修改 |
| 租户权限上下文 | app/Platform/Tenancy/Http/Middleware/EnsureTenantMember.php |
局部修改 |
| 权限初始化 | database/seeders/PermissionSeeder.php |
局部修改 |
| 自动测试 | tests/Feature/Authorization/AuthorizationScopeTest.php |
完整测试 |
所有局部修改都必须同时列出新增 use、替换位置和旧代码删除范围,不能只复制方法体。Migration 在执行前必须备份中央库并先检查未知 team_id。
本流程建立正式的权限作用域模型,替代“直接把租户 ID 当作 Spatie Team ID”以及使用特殊字符串模拟平台范围的临时做法。
一、功能目的和边界
1. 归属
- 产品层:
app/Platform/Authorization - 数据库:中央数据库
- Edition:Cloud、Private 和所有模块 Edition 都包含
- 权限引擎:继续使用已安装的
spatie/laravel-permission - 多租户:继续使用 Spatie Teams,但 Team ID 指向真实的
authorization_scopes记录
2. 最终关系
Tenant
-> AuthorizationScope(type=tenant, tenant_id=Tenant.id)
-> roles.team_id = AuthorizationScope.id
-> model_has_roles.team_id = AuthorizationScope.id
-> model_has_permissions.team_id = AuthorizationScope.id
Platform / Operator
-> AuthorizationScope(type=platform, tenant_id=null)
-> 后续平台运营角色和权限使用该作用域
3. 本功能不包含
- 不创建 Operator 管理接口。
- 不创建强制下线路由。
- 不新增 FormRequest、JsonResource 或 Controller,因为本功能没有新的 HTTP 输入和输出。
- 不修改租户业务权限名称。
完成本功能后,现有租户成员接口应继续正常工作,但权限查询使用的是作用域 ID,而不再直接使用租户 ID。
二、创建中央迁移
执行:
php artisan make:migration create_authorization_scopes_and_migrate_permission_teams
本次实际生成的文件为 database/migrations/2026_08_04_181755_create_authorization_scopes_and_migrate_permission_teams.php。将文件内容完整替换为:
<?php
/**
* @description 创建权限作用域并迁移现有 Spatie Team 数据
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-04 18:17:55
* @lastModified 2026-08-04 18:17:55
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* 创建权限作用域,并将旧 tenant_id Team 值迁移为作用域 ID。
*/
public function up(): void
{
$connectionName = config('tenancy.database.central_connection');
$connection = DB::connection($connectionName);
$connection->transaction(function () use (
$connection,
$connectionName,
): void {
$tenantIds = $connection->table('tenants')
->pluck('id')
->map(static fn ($id): string => (string) $id);
$usedTeamIds = $connection->table('roles')
->whereNotNull('team_id')
->pluck('team_id')
->merge($connection->table('model_has_roles')->pluck('team_id'))
->merge($connection->table('model_has_permissions')->pluck('team_id'))
->map(static fn ($id): string => (string) $id)
->unique()
->values();
$unknownTeamIds = $usedTeamIds->diff($tenantIds);
if ($unknownTeamIds->isNotEmpty()) {
throw new RuntimeException(
'存在无法映射到租户的权限 Team ID:'.$unknownTeamIds->implode(', ')
);
}
Schema::connection($connectionName)
->create('authorization_scopes', function (Blueprint $table): void {
$table->string('id')->primary();
$table->enum('type', ['platform', 'tenant', 'instance']);
$table->string('tenant_id')->nullable()->unique();
$table->string('name');
$table->timestampsTz();
$table->index('type');
$table->foreign('tenant_id')
->references('id')
->on('tenants')
->cascadeOnDelete();
});
// PostgreSQL 和测试使用的 SQLite 都支持带条件的唯一索引。
$connection->statement(
'CREATE UNIQUE INDEX authorization_scopes_singleton_type_unique '
.'ON authorization_scopes (type) WHERE tenant_id IS NULL'
);
$now = now();
$connection->table('authorization_scopes')->insert([
'id' => (string) Str::ulid(),
'type' => 'platform',
'tenant_id' => null,
'name' => '平台权限作用域',
'created_at' => $now,
'updated_at' => $now,
]);
foreach ($tenantIds as $tenantId) {
$scopeId = (string) Str::ulid();
$connection->table('authorization_scopes')->insert([
'id' => $scopeId,
'type' => 'tenant',
'tenant_id' => $tenantId,
'name' => "租户 {$tenantId} 权限作用域",
'created_at' => $now,
'updated_at' => $now,
]);
foreach (['roles', 'model_has_roles', 'model_has_permissions'] as $table) {
$connection->table($table)
->where('team_id', $tenantId)
->update(['team_id' => $scopeId]);
}
}
foreach (['roles', 'model_has_roles', 'model_has_permissions'] as $table) {
Schema::connection($connectionName)
->table($table, function (Blueprint $table): void {
$table->foreign('team_id')
->references('id')
->on('authorization_scopes')
->cascadeOnDelete();
});
}
});
}
/**
* 将租户权限数据恢复为旧 tenant_id Team 值并删除作用域表。
*/
public function down(): void
{
$connectionName = config('tenancy.database.central_connection');
$connection = DB::connection($connectionName);
$connection->transaction(function () use (
$connection,
$connectionName,
): void {
foreach (['roles', 'model_has_roles', 'model_has_permissions'] as $table) {
Schema::connection($connectionName)
->table($table, function (Blueprint $table): void {
$table->dropForeign(['team_id']);
});
}
$tenantScopes = $connection->table('authorization_scopes')
->where('type', 'tenant')
->whereNotNull('tenant_id')
->get(['id', 'tenant_id']);
foreach ($tenantScopes as $scope) {
foreach (['roles', 'model_has_roles', 'model_has_permissions'] as $table) {
$connection->table($table)
->where('team_id', $scope->id)
->update(['team_id' => $scope->tenant_id]);
}
}
$systemScopeIds = $connection->table('authorization_scopes')
->whereIn('type', ['platform', 'instance'])
->pluck('id');
if ($systemScopeIds->isNotEmpty()) {
$roleIds = $connection->table('roles')
->whereIn('team_id', $systemScopeIds)
->pluck('id');
$connection->table('model_has_roles')
->whereIn('team_id', $systemScopeIds)
->delete();
$connection->table('model_has_permissions')
->whereIn('team_id', $systemScopeIds)
->delete();
if ($roleIds->isNotEmpty()) {
$connection->table('role_has_permissions')
->whereIn('role_id', $roleIds)
->delete();
}
$connection->table('roles')
->whereIn('team_id', $systemScopeIds)
->delete();
}
Schema::connection($connectionName)
->dropIfExists('authorization_scopes');
});
}
};
迁移先检查所有旧 team_id 是否都能映射到真实租户。发现未知值时主动失败,不能静默产生孤立权限数据。
迁移完成后,三个 Spatie Team 字段都会外键关联 authorization_scopes.id。这不是只靠代码约定的“逻辑关联”:数据库会阻止不存在的作用域 ID,并在作用域删除时清理其角色和用户权限关联。外键必须在旧数据回填完成后再添加,否则旧的租户 ID 会因为尚未对应作用域主键而导致迁移失败。
领域约束同时由数据库和服务共同负责:数据库唯一约束保证一个租户只能对应一个作用域;部分唯一索引保证平台、私有实例这类无租户作用域各类型最多一条;AuthorizationScopeService 保证业务代码不能自行伪造 Team ID。
三、创建作用域类型枚举
创建 app/Platform/Authorization/Enums/AuthorizationScopeType.php:
<?php
/**
* @description 权限作用域类型枚举
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-04 18:08:59
* @lastModified 2026-08-04 18:08:59
*/
namespace App\Platform\Authorization\Enums;
/**
* 定义平台、租户和私有实例三种正式权限边界。
*/
enum AuthorizationScopeType: string
{
case Platform = 'platform';
case Tenant = 'tenant';
case Instance = 'instance';
}
四、创建中央作用域模型
创建 app/Platform/Authorization/Models/AuthorizationScope.php:
<?php
/**
* @description 中央权限作用域模型
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-04 18:08:59
* @lastModified 2026-08-04 18:08:59
*/
namespace App\Platform\Authorization\Models;
use App\Platform\Authorization\Enums\AuthorizationScopeType;
use App\Platform\Tenancy\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Stancl\Tenancy\Database\Concerns\CentralConnection;
#[Fillable([
'type',
'tenant_id',
'name',
])]
class AuthorizationScope extends Model
{
use CentralConnection, HasUlids;
/**
* 获取当前权限作用域关联的租户。
*
* 平台和私有实例作用域没有关联租户,因此可能返回 null。
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* 获取字段类型转换规则。
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'type' => AuthorizationScopeType::class,
];
}
}
五、创建作用域服务
创建 app/Platform/Authorization/Services/AuthorizationScopeService.php:
<?php
/**
* @description 权限作用域创建和解析服务
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-04 18:08:59
* @lastModified 2026-08-04 18:08:59
*/
namespace App\Platform\Authorization\Services;
use App\Platform\Authorization\Enums\AuthorizationScopeType;
use App\Platform\Authorization\Models\AuthorizationScope;
use App\Platform\Tenancy\Models\Tenant;
use LogicException;
/**
* 统一管理权限作用域,禁止业务代码自行拼接或伪造 Team ID。
*/
final class AuthorizationScopeService
{
/**
* 确保平台权限作用域存在。
*/
public function ensurePlatformScope(): AuthorizationScope
{
return AuthorizationScope::query()->firstOrCreate(
[
'type' => AuthorizationScopeType::Platform->value,
'tenant_id' => null,
],
[
'name' => '平台权限作用域',
],
);
}
/**
* 获取平台权限作用域,缺失时视为平台配置错误。
*/
public function platformScope(): AuthorizationScope
{
$scope = AuthorizationScope::query()
->where('type', AuthorizationScopeType::Platform->value)
->whereNull('tenant_id')
->first();
if ($scope === null) {
throw new LogicException('平台权限作用域尚未初始化。');
}
return $scope;
}
/**
* 确保指定租户拥有唯一的权限作用域。
*
* @param Tenant $tenant 目标租户
*/
public function ensureTenantScope(Tenant $tenant): AuthorizationScope
{
$tenantId = (string) $tenant->getTenantKey();
return AuthorizationScope::query()->firstOrCreate(
[
'type' => AuthorizationScopeType::Tenant->value,
'tenant_id' => $tenantId,
],
[
'name' => "租户 {$tenantId} 权限作用域",
],
);
}
/**
* 获取指定租户的权限作用域,缺失时视为租户未完整开通。
*
* @param Tenant $tenant 当前租户
*/
public function tenantScope(Tenant $tenant): AuthorizationScope
{
$scope = AuthorizationScope::query()
->where('type', AuthorizationScopeType::Tenant->value)
->where('tenant_id', (string) $tenant->getTenantKey())
->first();
if ($scope === null) {
throw new LogicException(
"租户 {$tenant->getTenantKey()} 的权限作用域尚未初始化。"
);
}
return $scope;
}
}
六、配置 Spatie Team 模型
编辑 config/permission.php,新增引入:
use App\Platform\Authorization\Models\AuthorizationScope;
将:
'team' => null,
修改为:
'team' => AuthorizationScope::class,
保留 team_foreign_key => team_id。team_id 是 Spatie 的包级字段名,但它现在保存真实的 authorization_scopes.id。
七、增加 Tenant 模型关系
编辑 app/Platform/Tenancy/Models/Tenant.php,更新 @lastModified,新增引入:
use App\Platform\Authorization\Models\AuthorizationScope;
use Illuminate\Database\Eloquent\Relations\HasOne;
在类中增加:
/**
* 获取租户对应的中央权限作用域。
*/
public function authorizationScope(): HasOne
{
return $this->hasOne(AuthorizationScope::class, 'tenant_id');
}
八、修改租户开通服务
编辑 app/Platform/Tenancy/Services/TenantProvisioningService.php,更新 @lastModified。
新增引入:
use App\Platform\Authorization\Services\AuthorizationScopeService;
删除:
use Illuminate\Support\Facades\DB;
在类中增加构造函数:
/** 注入权限作用域服务。 */
public function __construct(
private readonly AuthorizationScopeService $authorizationScopeService,
) {
}
在 provision() 获取 $owner 后增加:
$centralConnection = (new TenantMembership())->getConnection();
对于已存在租户,将成员关系、域名和作用域写入统一放入:
$centralConnection->transaction(function () use (
$tenant,
$owner,
$domain,
): void {
$this->authorizationScopeService->ensureTenantScope($tenant);
$membership = TenantMembership::query()
->where('tenant_id', $tenant->getTenantKey())
->where('user_id', $owner->getKey())
->first();
if ($membership === null) {
TenantMembership::query()->create([
'tenant_id' => $tenant->getTenantKey(),
'user_id' => $owner->getKey(),
'status' => 'active',
'is_owner' => true,
'joined_at' => now(),
]);
} else {
$membership->update([
'status' => 'active',
'is_owner' => true,
]);
}
if ($domain !== null && ! $tenant->domains()->where('domain', $domain)->exists()) {
$tenant->domains()->create(['domain' => $domain]);
}
});
保留原有“其他所有者”和“成员不是所有者”的冲突检查,但删除事务外重复的成员和域名写入代码。
对于新租户,将原来的中央事务替换为:
$centralConnection->transaction(function () use (
$tenant,
$owner,
$domain,
): void {
$this->authorizationScopeService->ensureTenantScope($tenant);
TenantMembership::query()->create([
'tenant_id' => $tenant->getTenantKey(),
'user_id' => $owner->getKey(),
'status' => 'active',
'is_owner' => true,
'joined_at' => now(),
]);
if ($domain !== null) {
$tenant->domains()->create([
'domain' => $domain,
]);
}
});
这里从 TenantMembership 中央模型取得真实连接对象。模型本身不提供事务,但模型所属的数据库连接提供 transaction()。
九、修改租户权限中间件
编辑 app/Platform/Tenancy/Http/Middleware/EnsureTenantMember.php,更新 @lastModified。
新增引入:
use App\Platform\Authorization\Services\AuthorizationScopeService;
use App\Platform\Tenancy\Models\Tenant;
增加构造函数:
/** 注入权限作用域服务。 */
public function __construct(
private readonly AuthorizationScopeService $authorizationScopeService,
) {
}
将 handle() 完整替换为:
/**
* 校验租户成员身份,并初始化真实的权限作用域。
*
* @param Request $request 当前请求
* @param Closure(Request): Response $next 下一个中间件
* @return Response 请求响应
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user('api');
$tenant = tenant();
if (! $user || ! ($tenant instanceof Tenant)) {
return ApiResponse::error(
code: ErrorCode::UNAUTHENTICATED,
status: 401,
);
}
$membership = $user->tenantMemberships()
->where('tenant_id', $tenant->getTenantKey())
->where('status', 'active')
->first();
if (! $membership) {
return ApiResponse::error(
code: ErrorCode::FORBIDDEN,
message: '你不是当前租户的成员',
status: 403,
);
}
$scope = $this->authorizationScopeService->tenantScope($tenant);
$registrar = app(PermissionRegistrar::class);
$previousScopeId = $registrar->getPermissionsTeamId();
$registrar->setPermissionsTeamId($scope->getKey());
$user->unsetRelation('roles')->unsetRelation('permissions');
$request->attributes->set('tenant_membership', $membership);
$request->attributes->set('authorization_scope', $scope);
try {
return $next($request);
} finally {
$registrar->setPermissionsTeamId($previousScopeId);
}
}
执行顺序现在是:认证用户、识别租户、验证成员、解析作用域、设置 Team ID、清理关系缓存、检查权限。
十、修改 PermissionSeeder
编辑 database/seeders/PermissionSeeder.php,更新文件说明和 @lastModified,新增引入:
use App\Platform\Authorization\Services\AuthorizationScopeService;
将方法签名修改为:
public function run(
AuthorizationScopeService $authorizationScopeService,
): void
在取得 $registrar 后增加:
$authorizationScopeService->ensurePlatformScope();
将租户循环和权限上下文清理放进 try/finally。finally 的意义是:即使某个租户初始化角色时报错,也必须恢复进入 Seeder 前的 Team ID,避免同一 PHP 进程后续权限操作误用失败租户的作用域。
核心结构为:
$previousScopeId = $registrar->getPermissionsTeamId();
try {
Tenant::query()->each(function (Tenant $tenant) use (
$authorizationScopeService,
$registrar,
$permissionModels,
): void {
// 下方放置原有角色、权限和成员角色初始化逻辑。
});
} finally {
$registrar->setPermissionsTeamId($previousScopeId);
$registrar->forgetCachedPermissions();
}
在租户循环的 use 中增加 $authorizationScopeService,并将:
$tenantId = $tenant->getTenantKey();
$registrar->setPermissionsTeamId($tenantId);
替换为:
$tenantId = (string) $tenant->getTenantKey();
$scope = $authorizationScopeService->ensureTenantScope($tenant);
$scopeId = (string) $scope->getKey();
$registrar->setPermissionsTeamId($scopeId);
将两个角色创建数组中的:
'team_id' => $tenantId,
替换为:
'team_id' => $scopeId,
不要创建平台角色和平台权限;它们属于后续 Operator 功能。
十一、自动化测试
创建 tests/Feature/Authorization/AuthorizationScopeTest.php:
<?php
/**
* @description 权限作用域基础设施测试
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-04 18:08:59
* @lastModified 2026-08-04 18:08:59
*/
namespace Tests\Feature\Authorization;
use App\Platform\Authorization\Enums\AuthorizationScopeType;
use App\Platform\Authorization\Models\Role;
use App\Platform\Authorization\Services\AuthorizationScopeService;
use App\Platform\Tenancy\Models\Tenant;
use Database\Seeders\PermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class AuthorizationScopeTest extends TestCase
{
use RefreshDatabase;
/**
* 中央迁移会创建唯一的平台权限作用域。
*/
public function test_platform_scope_is_created_by_migration(): void
{
$scope = app(AuthorizationScopeService::class)->platformScope();
$this->assertSame(AuthorizationScopeType::Platform, $scope->type);
$this->assertNull($scope->tenant_id);
}
/**
* 同一租户重复初始化时只会得到一个权限作用域。
*/
public function test_tenant_scope_creation_is_idempotent(): void
{
$tenant = $this->createCentralTenant('scope-demo');
$service = app(AuthorizationScopeService::class);
$first = $service->ensureTenantScope($tenant);
$second = $service->ensureTenantScope($tenant);
$this->assertSame($first->getKey(), $second->getKey());
$this->assertSame(AuthorizationScopeType::Tenant, $first->type);
$this->assertSame('scope-demo', $first->tenant_id);
$this->assertNotSame('scope-demo', $first->getKey());
}
/**
* 权限 Seeder 使用作用域 ID 创建租户角色。
*/
public function test_permission_seeder_uses_authorization_scope_id(): void
{
$tenant = $this->createCentralTenant('scope-seeder');
$this->seed(PermissionSeeder::class);
$scope = app(AuthorizationScopeService::class)
->tenantScope($tenant);
$ownerRole = Role::query()
->where('team_id', $scope->getKey())
->where('name', 'tenant-owner')
->first();
$this->assertNotNull($ownerRole);
$this->assertSame($scope->getKey(), $ownerRole->team_id);
$this->assertNotSame($tenant->getTenantKey(), $ownerRole->team_id);
}
/**
* 直接在中央测试数据库创建租户,避免触发真实租户数据库创建事件。
*/
private function createCentralTenant(string $tenantId): Tenant
{
DB::connection(config('tenancy.database.central_connection'))
->table('tenants')
->insert([
'id' => $tenantId,
'data' => null,
'created_at' => now(),
'updated_at' => now(),
]);
return Tenant::query()->findOrFail($tenantId);
}
}
十二、执行顺序和验证
先进行语法检查:
php -l app\Platform\Authorization\Enums\AuthorizationScopeType.php
php -l app\Platform\Authorization\Models\AuthorizationScope.php
php -l app\Platform\Authorization\Services\AuthorizationScopeService.php
php -l app\Platform\Tenancy\Http\Middleware\EnsureTenantMember.php
php -l app\Platform\Tenancy\Services\TenantProvisioningService.php
php -l database\seeders\PermissionSeeder.php
php -l tests\Feature\Authorization\AuthorizationScopeTest.php
先在 SQLite 测试数据库验证迁移和业务代码:
php artisan test --filter=AuthorizationScopeTest
测试通过后检查正式中央库现有 Team 数据:
psql -h 127.0.0.1 -p 5432 -U ps_sass_user -d ps_central -c "SELECT team_id, COUNT(*) FROM roles GROUP BY team_id ORDER BY team_id;"
确认输出中的非空 team_id 都是现有租户 ID 后执行:
php artisan migrate
再执行幂等 Seeder 和缓存清理:
php artisan db:seed --class=PermissionSeeder
php artisan config:clear
php artisan permission:cache-reset
最后运行回归测试:
php artisan test
十三、数据库验证
psql -h 127.0.0.1 -p 5432 -U ps_sass_user -d ps_central -c "SELECT s.id, s.type, s.tenant_id, r.name, r.team_id FROM authorization_scopes s LEFT JOIN roles r ON r.team_id = s.id ORDER BY s.type, s.tenant_id, r.name;"
预期:
- 有一条
type=platform、tenant_id=NULL的作用域。 demo有一条type=tenant的作用域。tenant-owner和tenant-member的team_id等于 demo 作用域 ID。- 角色的
team_id不再等于字符串demo。
十四、ApiPost 回归验证
本功能没有新接口,只验证现有租户接口。
- Method:
GET - URL:
http://demo.localhost:8000/api/tenant/members - Header:
Authorization: Bearer 当前AccessToken - Header:
Accept: application/json - Body:不需要 Body
预期仍返回成功响应。items 中的成员数量取决于当前数据库,不应固定为空;关键契约如下:
{
"code": 0,
"message": "获取租户成员成功",
"data": {
"items": [
{
"id": 1,
"tenant_id": "demo",
"user_id": 1,
"status": "active",
"is_owner": true
}
],
"pagination": {
"current_page": 1,
"per_page": 20,
"total": 1,
"last_page": 1
}
}
}
上面的成员 ID 和分页数量只是结构示例,以数据库实际数据为准。验收重点是 HTTP 200、code=0,并且现有成员及分页字段可正常读取。
如果返回 403,优先检查 roles.team_id、model_has_roles.team_id 是否都已经迁移为同一个 authorization_scopes.id,以及权限缓存是否已经清理。
十五、完成标准
- [ ] 中央迁移和回填成功。
- [ ] 平台和租户作用域真实存在。
- [ ] 相同租户重复初始化不会产生重复作用域。
- [ ] Spatie Team 模型指向
AuthorizationScope。 - [ ] 租户开通时自动创建作用域。
- [ ] 租户中间件使用作用域 ID 检查权限。
- [ ] PermissionSeeder 使用作用域 ID 创建角色。
- [ ]
AuthorizationScopeTest通过。 - [ ] 全量测试通过。
- [ ] ApiPost 租户成员接口回归通过。
- [ ] 数据库查询证明旧 tenant ID Team 值已迁移。
以上全部完成后,才能继续实现 operator_users、平台运营角色和全局强制下线入口。
18-组织权限-角色权限管理.md
组织权限:角色和权限管理完整流程
0. 状态与依赖
执行阶段:P11 Authorization 核心第二步
功能状态:规划草案
所属区域:Platform/Authorization
数据归属:中央数据库
前置流程:17 权限作用域、Tenant Membership、统一审计
执行前必须核对当前 Spatie Permission 配置、作用域服务和缓存清理方式。本文现有片段需按 03-代码引导与学习验收规范.md 升级为完整文件后才能实施。
0.1 文件地图与强制引入
| 层 | 目标文件 | 关键依赖 |
|---|---|---|
| Request | app/Platform/Authorization/Http/Requests/StoreRoleRequest.php |
ApiRequest |
| Service | app/Platform/Authorization/Services/RoleService.php |
AuthorizationScopeService、Role、PermissionRegistrar |
| Resource | app/Platform/Authorization/Http/Resources/RoleResource.php |
PermissionResource、JsonResource |
| Controller | app/Platform/Authorization/Http/Controllers/RoleController.php |
RoleService、ApiResponse |
| 成员角色 Request | app/Platform/Authorization/Http/Requests/SyncMemberRolesRequest.php |
ApiRequest |
| Routes | routes/tenant.php |
复用现有租户中间件组 |
所有 setPermissionsTeamId() 参数必须是 AuthorizationScope.id,绝不能直接传 tenant_id。
状态:规划实现。Spatie Permission Teams、中央 Role/Permission 模型和权限中间件已有基础。
功能 A:获取当前租户权限清单
1. 功能目的
给角色编辑页面提供可分配权限。只返回当前 Edition、当前租户已授权模块的权限。
2. Resource
<?php
/**
* @description 权限资源
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Authorization\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class PermissionResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'guard_name' => $this->guard_name,
];
}
}
3. Service
public function availablePermissions(string $tenantId): Collection
{
$enabledNamespaces = $this->moduleEntitlementService
->permissionNamespaces($tenantId);
return Permission::query()
->where('guard_name', 'api')
->where(function (Builder $query) use ($enabledNamespaces): void {
$query->where('name', 'like', 'tenant.%');
foreach ($enabledNamespaces as $namespace) {
$query->orWhere('name', 'like', $namespace.'.%');
}
})
->orderBy('name')
->get();
}
模块授权服务尚未实现时,第一版可以只返回 tenant.*,不要暂时返回所有 platform.*。
4. Controller
public function permissions(Request $request): JsonResponse
{
$permissions = $this->roleService->availablePermissions(tenant('id'));
return ApiResponse::success(
data: ['items' => PermissionResource::collection($permissions)],
message: '获取权限列表成功',
);
}
5. Route
Route::get('/permissions', [RoleController::class, 'permissions'])
->middleware('permission:tenant.roles.view')
->name('tenant.permissions.index');
功能 B:创建角色并分配权限
1. Request
<?php
/**
* @description 创建租户角色请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Authorization\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
final class StoreRoleRequest extends ApiRequest
{
/** 授权由租户权限中间件负责。 */
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100', 'regex:/^[a-z0-9._-]+$/'],
'permission_names' => ['array'],
'permission_names.*' => ['string', 'distinct'],
];
}
}
2. Service
public function createRole(
Tenant $tenant,
string $name,
array $permissionNames,
): Role {
$scope = $this->authorizationScopes->tenantScope($tenant);
setPermissionsTeamId($scope->getKey());
$allowed = $this->availablePermissions(
(string) $tenant->getTenantKey(),
)
->whereIn('name', $permissionNames);
if ($allowed->count() !== count(array_unique($permissionNames))) {
throw ValidationException::withMessages([
'permission_names' => ['包含当前租户不可分配的权限'],
]);
}
$role = new Role;
return $role->getConnection()
->transaction(function () use ($scope, $name, $allowed): Role {
$role = Role::create([
'team_id' => $scope->getKey(),
'name' => $name,
'guard_name' => 'api',
]);
$role->syncPermissions($allowed);
app(PermissionRegistrar::class)->forgetCachedPermissions();
return $role->load('permissions');
});
}
3. Resource
<?php
/**
* @description 租户角色资源
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Authorization\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class RoleResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'team_id' => $this->team_id,
'permissions' => PermissionResource::collection($this->whenLoaded('permissions')),
'created_at' => $this->created_at,
];
}
}
4. Controller
public function store(StoreRoleRequest $request): JsonResponse
{
$role = $this->roleService->createRole(
tenant(),
$request->validated('name'),
$request->validated('permission_names', []),
);
return ApiResponse::success(
data: new RoleResource($role),
message: '创建角色成功',
);
}
5. Route
Route::post('/roles', [RoleController::class, 'store'])
->middleware('permission:tenant.roles.manage')
->name('tenant.roles.store');
6. ApiPost
POST http://demo.localhost:8000/api/tenant/roles
Authorization: Bearer 租户所有者Token
Content-Type: application/json
{
"name": "member-manager",
"permission_names": [
"tenant.members.view",
"tenant.members.manage"
]
}
预期中央 roles.team_id 等于 demo 对应的 authorization_scopes.id,而不是字符串 demo;role_has_permissions 有关联。
功能 C:给成员分配角色
1. Request
<?php
/**
* @description 同步租户成员角色请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Authorization\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
final class SyncMemberRolesRequest extends ApiRequest
{
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'role_names' => ['required', 'array', 'min:1'],
'role_names.*' => ['required', 'string', 'distinct'],
];
}
}
2. Service
public function syncMemberRoles(
TenantMembership $membership,
array $roleNames,
): User {
if ($membership->tenant_id !== tenant('id')) {
throw new ModelNotFoundException();
}
$tenant = Tenant::query()->findOrFail($membership->tenant_id);
$scope = $this->authorizationScopes->tenantScope($tenant);
setPermissionsTeamId($scope->getKey());
$roles = Role::query()
->where('team_id', $scope->getKey())
->whereIn('name', $roleNames)
->get();
if ($roles->count() !== count(array_unique($roleNames))) {
throw ValidationException::withMessages([
'role_names' => ['包含当前租户不存在的角色'],
]);
}
$user = $membership->user;
$user->unsetRelation('roles')->unsetRelation('permissions');
$user->syncRoles($roles);
app(PermissionRegistrar::class)->forgetCachedPermissions();
return $user->load('roles');
}
3. Route
Route::put('/members/{membership}/roles', [TenantMemberController::class, 'syncRoles'])
->middleware('permission:tenant.roles.manage')
->name('tenant.members.roles.update');
4. 重要业务规则
- membership 必须属于当前 tenant。
- 不能移除最后一个 owner 的 owner 角色。
- 操作者不能分配自己无权管理的高权限角色。
- 同一用户在其他 tenant 的角色不受影响。
功能 D:更新和删除角色
更新复用 StoreRoleRequest 或独立 UpdateRoleRequest;Service 必须先取得当前租户的 AuthorizationScope,再限定 team_id=$scope->getKey()。删除前检查:系统 owner 角色、是否仍有用户关联、是否为模块保留角色。删除后清理权限缓存,并通过统一审计服务记录。
Routes:
Route::put('/roles/{role}', [RoleController::class, 'update'])
->middleware('permission:tenant.roles.manage');
Route::delete('/roles/{role}', [RoleController::class, 'destroy'])
->middleware('permission:tenant.roles.manage');
不能使用未限定 team_id 的隐式 Role 绑定;建议自定义绑定或 Controller 通过当前 AuthorizationScope 查询,查不到返回 404。
检查和测试
php artisan route:list --path=api/tenant/roles
php artisan test --filter=RoleManagementTest
测试:同名角色在不同权限作用域可存在;同一作用域重复失败;A 不能修改 B 角色;模块未授权权限不能分配;切换 scope ID 后 can() 正确;最后 owner 规则有效。
19-组织权限-组织菜单数据范围和日志.md
组织权限:组织、菜单、数据范围和日志完整流程
0. 状态与依赖
执行阶段:P11 Authorization 扩展
功能状态:规划草案
所属区域:Platform/Authorization + 租户组织模块
数据归属:组织数据在租户库;菜单定义和平台权限按模块归属
前置流程:角色权限、租户隔离、统一审计、缓存 Key
组织、菜单、数据范围和审计是四个独立功能闭环,学习执行时必须逐个完成,不得作为一个大步骤同时创建全部表和服务。
0.1 文件地图
| 功能 | 目录 | 说明 |
|---|---|---|
| 组织/部门 | app/Platform/Organization 或首个正式组织模块目录 |
Tenant Model、Request、Service、Resource、Controller 分层完整 |
| 菜单注册 | app/Platform/Navigation |
菜单定义、Registry 和当前用户菜单 Service |
| 数据范围 | app/Platform/Authorization/Services/DataScopeService.php |
只修改 Query Builder,不在 PHP 过滤全量数据 |
| 审计 | app/Platform/Audit/Services/CentralAuditService.php 或租户审计服务 |
Controller 禁止直接 activity() |
| 路由 | routes/tenant.php |
复用认证、租户和成员中间件组 |
每个功能先按 04-功能闭环代码模板.md 拆成独立文档步骤,再执行下方局部片段。
状态:规划实现。依赖角色权限管理完成。
功能 A:组织和部门
1. Migration(租户库)
php artisan make:migration create_organizations_table --path=database/migrations/tenant
Schema::create('organizations', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('status', 20)->default('active')->index();
$table->timestampsTz();
});
Schema::create('departments', function (Blueprint $table): void {
$table->id();
$table->foreignId('organization_id')->constrained()->cascadeOnDelete();
$table->foreignId('parent_id')->nullable()->constrained('departments')->nullOnDelete();
$table->string('name');
$table->string('path')->nullable()->index();
$table->integer('sort')->default(0);
$table->string('status', 20)->default('active')->index();
$table->timestampsTz();
});
Schema::create('organization_members', function (Blueprint $table): void {
$table->id();
$table->foreignId('organization_id')->constrained()->cascadeOnDelete();
$table->foreignId('department_id')->nullable()->constrained()->nullOnDelete();
$table->unsignedBigInteger('user_id')->index();
$table->string('position')->nullable();
$table->boolean('is_leader')->default(false);
$table->string('status', 20)->default('active')->index();
$table->timestampsTz();
$table->unique(['organization_id', 'user_id']);
});
user_id 引用中央用户,PostgreSQL 不建立跨数据库外键。
2. Models
<?php
/**
* @description 租户组织模型
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Organization\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Stancl\Tenancy\Database\Concerns\TenantConnection;
final class Organization extends Model
{
use TenantConnection;
protected $fillable = ['name', 'status'];
/** 获取组织下的部门。 */
public function departments(): HasMany
{
return $this->hasMany(Department::class);
}
}
<?php
/**
* @description 租户部门模型
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Organization\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Stancl\Tenancy\Database\Concerns\TenantConnection;
final class Department extends Model
{
use TenantConnection;
protected $fillable = [
'organization_id', 'parent_id', 'name', 'path', 'sort', 'status',
];
/** 获取上级部门。 */
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
/** 获取直属子部门。 */
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->orderBy('sort');
}
}
3. Request
<?php
/**
* @description 创建租户部门请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Organization\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
final class StoreDepartmentRequest extends ApiRequest
{
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'organization_id' => ['required', 'integer', 'exists:organizations,id'],
'parent_id' => ['nullable', 'integer', 'exists:departments,id'],
'name' => ['required', 'string', 'max:255'],
'sort' => ['integer', 'min:0'],
];
}
}
因为请求已经初始化租户连接,exists:organizations 和 exists:departments 查询当前租户库。
4. Service
public function createDepartment(array $data): Department
{
$parent = null;
if ($data['parent_id'] ?? null) {
$parent = Department::findOrFail($data['parent_id']);
if ((int) $parent->organization_id !== (int) $data['organization_id']) {
throw ValidationException::withMessages([
'parent_id' => ['上级部门不属于当前组织'],
]);
}
}
$department = Department::create($data);
$department->forceFill([
'path' => $parent ? $parent->path.'/'.$department->id : (string) $department->id,
])->save();
return $department->refresh();
}
移动部门还必须防止把父节点移动到自己的子树中:目标 parent 的 path 不能以当前部门 path 开头。
5. Resource
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'organization_id' => $this->organization_id,
'parent_id' => $this->parent_id,
'name' => $this->name,
'sort' => $this->sort,
'status' => $this->status,
'children' => DepartmentResource::collection($this->whenLoaded('children')),
];
}
6. Controller/Route
public function store(StoreDepartmentRequest $request): JsonResponse
{
$department = $this->organizationService
->createDepartment($request->validated());
return ApiResponse::success(
data: new DepartmentResource($department),
message: '创建部门成功',
);
}
Route::get('/organizations', [OrganizationController::class, 'index'])
->middleware('permission:tenant.organizations.view');
Route::post('/organizations', [OrganizationController::class, 'store'])
->middleware('permission:tenant.organizations.manage');
Route::get('/departments/tree', [DepartmentController::class, 'tree'])
->middleware('permission:tenant.organizations.view');
Route::post('/departments', [DepartmentController::class, 'store'])
->middleware('permission:tenant.organizations.manage');
7. ApiPost
POST http://demo.localhost:8000/api/tenant/departments
Authorization: Bearer 租户管理员Token
Content-Type: application/json
{
"organization_id": 1,
"parent_id": null,
"name": "研发部",
"sort": 10
}
功能 B:组织成员
Request
<?php
/**
* @description 添加组织成员请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Organization\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
final class StoreOrganizationMemberRequest extends ApiRequest
{
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'user_id' => ['required', 'integer'],
'organization_id' => ['required', 'integer', 'exists:organizations,id'],
'department_id' => ['nullable', 'integer', 'exists:departments,id'],
'position' => ['nullable', 'string', 'max:100'],
'is_leader' => ['boolean'],
];
}
}
user_id 不能用租户连接的 exists:users。Service 查询中央 TenantMembership,确认该用户是当前租户 active 成员,再写租户库 organization_members。
public function addOrganizationMember(array $data): OrganizationMember
{
$membership = TenantMembership::query()
->where('tenant_id', tenant('id'))
->where('user_id', $data['user_id'])
->where('status', 'active')
->first();
if ($membership === null) {
throw ValidationException::withMessages([
'user_id' => ['该用户不是当前租户的正常成员'],
]);
}
return OrganizationMember::updateOrCreate(
[
'organization_id' => $data['organization_id'],
'user_id' => $data['user_id'],
],
$data,
);
}
功能 C:菜单注册和当前用户菜单
1. 菜单定义
平台和模块使用配置文件注册,不由 Controller 临时创建:
return [
[
'key' => 'tenant.members',
'title' => '成员管理',
'route' => '/tenant/members',
'permission' => 'tenant.members.view',
'module' => 'platform',
'sort' => 100,
],
];
2. Service
public function menusFor(User $user, Tenant $tenant): array
{
$scope = $this->authorizationScopes->tenantScope($tenant);
setPermissionsTeamId($scope->getKey());
$user->unsetRelation('roles')->unsetRelation('permissions');
return collect($this->menuRegistry->all())
->filter(fn (array $menu): bool =>
$this->edition->allowsModule($menu['module'])
&& $this->entitlements->tenantAllows(
(string) $tenant->getTenantKey(),
$menu['module'],
)
&& $user->can($menu['permission'])
)
->sortBy('sort')
->values()
->all();
}
3. API
Route::get('/menus', [MenuController::class, 'index'])
->middleware(['auth:api', EnsureTenantMember::class]);
前端菜单隐藏不能代替后端权限中间件。
功能 D:数据范围
1. 规则
self
department
department_and_children
organization
tenant_all
custom_departments
2. Query Service
public function apply(
Builder $query,
User $user,
string $ownerColumn = 'user_id',
string $departmentColumn = 'department_id',
): Builder {
$scope = $this->resolveFor($user, tenant('id'));
return match ($scope->type) {
'self' => $query->where($ownerColumn, $user->id),
'department' => $query->whereIn($departmentColumn, $scope->departmentIds),
'department_and_children' => $query->whereIn($departmentColumn, $scope->departmentIds),
'organization' => $query->where('organization_id', $scope->organizationId),
'tenant_all' => $query,
'custom_departments' => $query->whereIn($departmentColumn, $scope->departmentIds),
default => $query->whereRaw('1 = 0'),
};
}
数据范围必须在 SQL 查询阶段生效,不能查询全部后在 PHP 过滤。
功能 E:操作日志
关键 Service 成功后通过统一审计服务记录,禁止 Controller 直接调用 activity():
$this->audit->record(
logName: 'tenant-operation',
event: 'department_updated',
description: '更新部门',
causer: $operator,
subject: $department,
properties: [
'tenant_id' => tenant('id'),
'changes' => $department->getChanges(),
],
);
密码、Token、Secret 和完整请求 Body 不进入日志。
测试
- 部门不能跨组织设置 parent。
- 不能产生部门循环。
- 中央非成员不能写 organization_members。
- tenant A 组织数据在 B 不可见。
- 菜单同时受 Edition、tenant_apps 和 permission 控制。
- self/department/tenant_all 数据范围产生正确 SQL 结果。
- 日志包含 tenant_id/request_id 且不含敏感字段。
20-中央用户管理.md
中央用户管理完整流程
0. 状态与依赖
执行阶段:P12 中央管理
功能状态:当前用户、状态和会话安全已有部分实现;完整 CRUD 为规划草案
所属区域:Platform/Identity + Operator 管理入口
数据归属:中央数据库
前置流程:Identity、Authorization、Operator 身份、统一审计
中央用户管理复用 Identity Service,不复制密码、状态和 Token 撤销逻辑。Operator Controller 只调用公开平台能力并记录中央审计。
0.1 文件地图
| 层 | 目标文件 | 说明 |
|---|---|---|
| Request | app/Operator/Http/Requests/*User*Request.php |
继承 ApiRequest,Operator 权限在路由/Policy |
| Resource | app/Platform/Identity/Http/Resources/UserResource.php |
中央用户公开字段唯一出口 |
| 查询 Service | app/Platform/Identity/Services/UserQueryService.php |
排序白名单和分页 |
| 状态 Service | 复用现有 UpdateUserStatusService 或下沉公开 Platform 用例 |
状态、Token 和审计同事务 |
| Controller | app/Operator/Http/Controllers/UserAccountController.php |
HTTP 编排 |
| Routes | app/Operator/Routes/api.php |
Operator 身份、权限和限流 |
所有列表、创建、详情、状态和安全操作必须拆成独立功能闭环,不能把完整用户后台塞进一个 Controller 和一个 Service。
1. 功能目的
提供平台统一用户查询、详情、创建/邀请、状态和安全操作。租户业务只引用中央 user_id,不重复创建账号表。
2. UserResource
app/Platform/Identity/Http/Resources/UserResource.php:
<?php
/**
* @description Operator 中央用户资源
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Operator\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class UserResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'status' => $this->status,
'email_verified_at' => $this->email_verified_at,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
不返回 password、remember_token、two_factor_secret、recovery_codes、OAuth Token 和 disabled_reason 内部细节。
3. 用户列表 Request
<?php
/**
* @description Operator 中央用户列表请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Operator\Http\Requests;
use App\Support\Http\Requests\PaginationRequest;
final class UserIndexRequest extends PaginationRequest
{
/** @return array<string, array<int, string>> */
public function rules(): array
{
return array_merge(parent::rules(), [
'status' => ['nullable', 'in:active,locked,disabled'],
]);
}
}
4. Service 列表
public function paginate(UserIndexRequest $request): LengthAwarePaginator
{
$sortFields = [
'name' => 'name',
'email' => 'email',
'created_at' => 'created_at',
];
$sortField = $sortFields[$request->input('sortField')] ?? 'created_at';
$sortDirection = $request->input('sortOrder') === 'ascend' ? 'asc' : 'desc';
return User::query()
->when($request->filled('keyword'), function (Builder $query) use ($request): void {
$keyword = '%'.addcslashes($request->string('keyword')->toString(), '%_').'%' ;
$query->where(fn (Builder $inner) => $inner
->where('name', 'ilike', $keyword)
->orWhere('email', 'ilike', $keyword));
})
->when($request->filled('status'), fn (Builder $query) =>
$query->where('status', $request->input('status')))
->orderBy($sortField, $sortDirection)
->paginate($request->pageSize());
}
PostgreSQL 使用 ilike;如未来兼容其他数据库,封装搜索策略。
5. Controller
public function index(UserIndexRequest $request): JsonResponse
{
$paginator = $this->userService->paginate($request);
return ApiResponse::success(data: [
'items' => UserResource::collection($paginator->items()),
'pagination' => [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
],
], message: '获取用户列表成功');
}
6. 创建用户
优先使用邀请流程,让用户自己设置密码。平台安全管理员确需直接创建时:
<?php
/**
* @description Operator 创建中央用户请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Operator\Http\Requests;
use App\Platform\Identity\Models\User;
use App\Support\Http\Requests\ApiRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
final class StoreUserRequest extends ApiRequest
{
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique(User::class, 'email')],
'password' => ['required', 'confirmed', Password::default()],
];
}
}
public function create(array $data): User
{
return User::create([
'name' => $data['name'],
'email' => Str::lower($data['email']),
'password' => Hash::make($data['password']),
'status' => 'active',
]);
}
API 不返回初始密码;管理员输入密码也不能进入日志。更推荐生成邀请而不是系统生成弱密码。
7. 用户详情
public function show(User $user): JsonResponse
{
return ApiResponse::success(
data: new UserResource($user),
message: '获取用户详情成功',
);
}
租户管理员不能通过中央 /users/{id} 枚举全平台用户;租户内查询使用 tenant memberships API。中央用户管理只给 Operator/平台安全管理员。
8. 状态、安全和密码
状态更新调用 14-身份中心-邀请注册和账号状态.md 的 Service;强制下线调用 13-身份中心-会话和强制下线.md。管理员不直接读取/设置用户旧密码。重置密码发送 Password Broker 邮件,完成后撤销全部 Token。
9. Routes
Route::prefix('users')
->group(function (): void {
Route::get('/', [UserController::class, 'index'])
->middleware('permission:platform.users.view');
Route::post('/', [UserController::class, 'store'])
->middleware('permission:platform.users.create');
Route::get('/{user}', [UserController::class, 'show'])
->middleware('permission:platform.users.view');
Route::patch('/{user}/status', [UserSecurityController::class, 'updateStatus'])
->middleware('permission:platform.users.status.update');
Route::post('/{user}/force-logout', [UserSecurityController::class, 'forceLogout'])
->middleware('permission:platform.users.force-logout');
});
Operator 是否使用 /api/operator/users 应在开发前固定,避免与租户后台管理员概念混淆。当前 /api/admin 表示平台管理 API 时,必须由权限隔离。
10. ApiPost
GET http://127.0.0.1:8000/api/operator/users?page=1&page_size=20&keyword=admin
Authorization: Bearer 平台管理员Token
POST http://127.0.0.1:8000/api/operator/users
Authorization: Bearer 平台管理员Token
Content-Type: application/json
{
"name": "测试用户",
"email": "new@example.com",
"password": "符合规则的密码",
"password_confirmation": "相同密码"
}
11. 测试
- 无平台权限不能列出中央用户。
- 租户管理员不能枚举其他租户用户。
- UserResource 不含敏感字段。
- 邮箱大小写标准化且唯一。
- 排序字段白名单防止 SQL 注入。
- 锁定/禁用后全部 Token 失效。
- 管理员密码重置后旧 Refresh Token 失效。
- 用户创建、状态和强制下线产生安全审计。
21-运营平台套餐订阅和模块授权.md
运营平台:套餐、订阅和模块授权完整流程
0. 状态与依赖
执行阶段:P12 Operator
功能状态:Operator 身份和部分安全操作已实现;套餐订阅为规划草案
所属区域:Operator(Cloud-only)
数据归属:中央数据库
前置流程:Identity、Tenant、Authorization、中央用户、统一审计
Operator 可以依赖 Platform,Platform 不得反向依赖 Operator。Private Edition 默认不包含本流程的云端运营代码。
0.1 文件地图
| 功能 | 目标区域 | 说明 |
|---|---|---|
| Plan/Subscription | app/Operator/Models、app/Operator/Services |
中央连接;Cloud-only |
| Operator API | app/Operator/Http、app/Operator/Routes/api.php |
必须通过 Operator 身份和权限 |
| Tenant App 授权 | app/Platform/Modules |
Platform 只提供模块授权内核,不依赖 Operator UI |
| 支付 Webhook | app/Operator/Integrations/Billing |
验签、Inbox 幂等、Queue、审计 |
下方裸方法均是局部修改。事务统一从参与写入的 Model 取得连接;外部支付调用不得放在数据库事务中等待。
状态:规划实现。只进入 Cloud Edition,依赖模块注册和 tenant_apps 完成。
功能 A:套餐
1. Migration
Schema::create('plans', function (Blueprint $table): void {
$table->id();
$table->string('key', 100)->unique();
$table->string('name');
$table->decimal('price', 12, 2)->default(0);
$table->string('billing_cycle', 20)->default('month');
$table->jsonb('features')->nullable();
$table->jsonb('quotas')->nullable();
$table->string('status', 20)->default('active')->index();
$table->timestampsTz();
});
2. Model
<?php
/**
* @description 云端运营套餐模型
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Operator\Models;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Concerns\CentralConnection;
final class Plan extends Model
{
use CentralConnection;
protected $fillable = [
'key', 'name', 'price', 'billing_cycle', 'features', 'quotas', 'status',
];
/** @return array<string, string> */
protected function casts(): array
{
return [
'price' => 'decimal:2',
'features' => 'array',
'quotas' => 'array',
];
}
}
3. Request
<?php
/**
* @description 创建云端运营套餐请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Operator\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
final class StorePlanRequest extends ApiRequest
{
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'key' => ['required', 'string', 'max:100', 'regex:/^[a-z0-9-]+$/', 'unique:plans,key'],
'name' => ['required', 'string', 'max:255'],
'price' => ['required', 'numeric', 'min:0'],
'billing_cycle' => ['required', 'in:month,year,once'],
'features' => ['array'],
'quotas' => ['array'],
];
}
}
4. Service/Controller/Route
public function createPlan(array $data): Plan
{
return Plan::create($data);
}
public function store(StorePlanRequest $request): JsonResponse
{
return ApiResponse::success(
data: new PlanResource($this->planService->createPlan($request->validated())),
message: '创建套餐成功',
);
}
Route::post('/operator/plans', [PlanController::class, 'store'])
->middleware(['auth:api', 'permission:operator.plans.manage']);
ApiPost:
POST http://127.0.0.1:8000/api/operator/plans
{
"key": "pro",
"name": "专业版",
"price": 999,
"billing_cycle": "month",
"features": ["chat"],
"quotas": {"users": 100, "ai_tokens": 1000000}
}
功能 B:订阅
1. Migration
Schema::create('subscriptions', function (Blueprint $table): void {
$table->id();
$table->string('tenant_id')->index();
$table->foreignId('plan_id')->constrained();
$table->string('provider', 50)->default('manual');
$table->string('provider_subscription_id')->nullable()->unique();
$table->string('status', 30)->index();
$table->timestampTz('trial_ends_at')->nullable();
$table->timestampTz('current_period_starts_at')->nullable();
$table->timestampTz('current_period_ends_at')->nullable();
$table->timestampTz('canceled_at')->nullable();
$table->timestampsTz();
});
2. Service
public function activate(
string $tenantId,
Plan $plan,
array $providerData = [],
): Subscription {
$subscription = new Subscription;
return $subscription->getConnection()
->transaction(function () use ($tenantId, $plan, $providerData): Subscription {
$subscription = Subscription::updateOrCreate(
['tenant_id' => $tenantId],
[
'plan_id' => $plan->id,
'provider' => $providerData['provider'] ?? 'manual',
'provider_subscription_id' => $providerData['id'] ?? null,
'status' => 'active',
'current_period_starts_at' => now(),
'current_period_ends_at' => $plan->billing_cycle === 'year'
? now()->addYear()
: now()->addMonth(),
],
);
$this->syncTenantAppsFromPlan($tenantId, $plan);
return $subscription->refresh();
});
}
支付调用不能包在数据库事务内长时间等待;先验签和记录 provider event,再通过幂等 Job 执行本地事务。
功能 C:模块授权
Request
<?php
/**
* @description 更新租户模块授权请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Operator\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
final class UpdateTenantAppRequest extends ApiRequest
{
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'status' => ['required', 'in:active,disabled'],
'expires_at' => ['nullable', 'date'],
'config' => ['array'],
'quotas' => ['array'],
];
}
}
Service
public function updateTenantApp(
string $tenantId,
AppModel $app,
array $data,
): TenantApp {
$tenantApp = TenantApp::updateOrCreate(
['tenant_id' => $tenantId, 'app_id' => $app->id],
$data,
);
Cache::forget("tenant:{$tenantId}:module:{$app->key}");
TenantAppChanged::dispatch($tenantApp);
return $tenantApp->refresh();
}
Controller/Route
public function update(
UpdateTenantAppRequest $request,
Tenant $tenant,
AppModel $app,
): JsonResponse {
$tenantApp = $this->entitlementService->updateTenantApp(
$tenant->getTenantKey(),
$app,
$request->validated(),
);
return ApiResponse::success(
data: new TenantAppResource($tenantApp),
message: '租户模块授权已更新',
);
}
Route::put('/operator/tenants/{tenant}/apps/{app}', [TenantAppController::class, 'update'])
->middleware('permission:operator.tenant-apps.manage');
功能 D:支付回调幂等
新增 billing_webhook_events,provider event ID 唯一。Controller 只验签并 firstOrCreate,然后派发 Job;重复事件返回 200 但不重复执行订阅变更。
$event = BillingWebhookEvent::firstOrCreate(
['provider' => 'stripe', 'event_id' => $providerEvent->id],
['type' => $providerEvent->type, 'payload' => $providerEvent->payload],
);
if ($event->wasRecentlyCreated) {
ProcessBillingWebhook::dispatch($event->id);
}
验收
- 租户管理员无法访问 Operator。
- Private Edition 不注册 Operator Route。
- 套餐状态和模块授权一致。
- 重复支付回调不重复授权和记账。
- 订阅过期后模块中间件拒绝访问。
- 所有运营操作写入 operator_audit_logs。
22-模块注册和Edition构建.md
模块注册和 Edition 构建完整流程
0. 状态与依赖
执行阶段:P13 模块和 Edition
功能状态:规划草案
所属区域:Platform/Modules + Build Tooling
数据归属:中央模块授权 + 租户模块状态
前置流程:Identity、Tenant、Authorization、配置/Feature Flag
模块边界必须先于 Chat 和钉钉接入。Platform 不能依赖具体模块,Private 制品不能包含 Operator 源码和未购买模块。
0.1 文件地图
| 能力 | 目标文件/目录 |
|---|---|
| 模块 Manifest | app/Modules/<Module>/module.json |
| Manifest 数据对象 | app/Platform/Modules/Data/ModuleManifest.php |
| 模块注册器 | app/Platform/Modules/Services/ModuleRegistry.php |
| 模块 Provider | app/Modules/<Module>/Providers/ModuleServiceProvider.php |
| Edition 配置 | config/edition.php |
| 模块可用中间件 | app/Platform/Modules/Http/Middleware/EnsureModuleEnabled.php |
| 权限注册 | app/Platform/Modules/Services/ModulePermissionRegistrar.php |
| 构建脚本 | scripts/build-edition.ps1 |
Manifest DTO 只有 Registry、构建器和模块 Provider 多处共享时才保留;不要为一个 Controller 的一次返回创建 DTO。
状态:规划实现。组织权限和租户隔离验收后执行。
功能 A:模块 manifest 和注册器
1. 目录
app/Modules/Chat/
├── Config/permissions.php
├── Config/menus.php
├── Database/Migrations/Central/
├── Database/Migrations/Tenant/
├── Http/Controllers/
├── Http/Requests/
├── Http/Resources/
├── Models/
├── Routes/api.php
├── Services/
├── Tests/
├── ModuleServiceProvider.php
└── module.json
2. module.json
{
"id": "chat",
"name": "AI Chat",
"version": "1.0.0",
"provider": "App\\Modules\\Chat\\ModuleServiceProvider",
"requires": [],
"editions": ["cloud", "private-chat"]
}
3. Manifest DTO
final readonly class ModuleManifest
{
public function __construct(
public string $id,
public string $name,
public string $version,
public string $provider,
public array $requires,
public array $editions,
) {}
public static function fromFile(string $path): self
{
$data = json_decode(file_get_contents($path), true, flags: JSON_THROW_ON_ERROR);
foreach (['id', 'name', 'version', 'provider', 'requires', 'editions'] as $field) {
if (! array_key_exists($field, $data)) {
throw new InvalidArgumentException("模块 manifest 缺少字段:{$field}");
}
}
return new self(...$data);
}
}
4. Registry
<?php
/**
* @description 模块清单发现与 Edition 过滤注册器
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Modules\Services;
use App\Platform\Modules\Data\ModuleManifest;
use Illuminate\Support\Facades\File;
use LogicException;
final class ModuleRegistry
{
/** @var array<string, ModuleManifest> */
private array $modules = [];
/** 从模块根目录发现并校验唯一模块清单。 */
public function discover(string $path): void
{
foreach (File::directories($path) as $directory) {
$manifestPath = $directory.DIRECTORY_SEPARATOR.'module.json';
if (! File::exists($manifestPath)) {
continue;
}
$manifest = ModuleManifest::fromFile($manifestPath);
if (isset($this->modules[$manifest->id])) {
throw new LogicException("模块 ID 重复:{$manifest->id}");
}
$this->modules[$manifest->id] = $manifest;
}
}
/** @return array<string, ModuleManifest> */
public function enabled(): array
{
return array_filter(
$this->modules,
fn (ModuleManifest $module): bool => in_array(
config('edition.name'),
$module->editions,
true,
),
);
}
}
5. ModuleServiceProvider
<?php
/**
* @description Chat 模块服务提供者
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Modules\Chat\Providers;
use Illuminate\Support\ServiceProvider;
final class ModuleServiceProvider extends ServiceProvider
{
/** 注册模块配置和容器绑定。 */
public function register(): void
{
$this->mergeConfigFrom(__DIR__.'/Config/chat.php', 'modules.chat');
}
/** 加载模块路由和中央迁移。 */
public function boot(): void
{
$this->loadRoutesFrom(__DIR__.'/Routes/api.php');
$this->loadMigrationsFrom(__DIR__.'/Database/Migrations/Central');
// 租户迁移不能当中央迁移自动执行,交给模块租户迁移命令加载。
}
}
功能 B:apps 和 tenant_apps
1. Migration
Schema::create('apps', function (Blueprint $table): void {
$table->id();
$table->string('key', 100)->unique();
$table->string('name');
$table->string('version', 50);
$table->string('status', 20)->default('active');
$table->timestampsTz();
});
Schema::create('tenant_apps', function (Blueprint $table): void {
$table->id();
$table->string('tenant_id');
$table->foreignId('app_id')->constrained()->cascadeOnDelete();
$table->string('status', 20)->default('active');
$table->jsonb('config')->nullable();
$table->jsonb('quotas')->nullable();
$table->timestampTz('expires_at')->nullable();
$table->timestampsTz();
$table->unique(['tenant_id', 'app_id']);
});
2. Middleware
public function handle(Request $request, Closure $next, string $module): Response
{
if (! $this->edition->allowsModule($module)) {
abort(404);
}
$allowed = TenantApp::query()
->where('tenant_id', tenant('id'))
->whereHas('app', fn ($query) => $query->where('key', $module))
->where('status', 'active')
->where(fn ($query) => $query
->whereNull('expires_at')
->orWhere('expires_at', '>', now()))
->exists();
if (! $allowed) {
return ApiResponse::error(
ErrorCode::FORBIDDEN,
'当前租户未开通该模块',
status: 403,
);
}
return $next($request);
}
模块路由同时使用:租户上下文、成员、module:chat、具体 permission。
功能 C:权限和菜单注册
模块 Config/permissions.php:
return [
'chat.conversation.read',
'chat.conversation.send',
'chat.conversation.delete',
'chat.knowledge.manage',
];
幂等 Registrar:
foreach ($permissions as $name) {
Permission::updateOrCreate(
['name' => $name, 'guard_name' => 'api'],
[],
);
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
菜单注册读取模块配置,最终由当前用户菜单 Service 按 Edition、tenant_apps 和 permission 过滤。
功能 D:Edition 配置
.env:
APP_EDITION=private-chat
DEPLOYMENT_MODE=private
ENABLED_MODULES=chat
OPERATOR_ENABLED=false
config/edition.php:
return [
'name' => env('APP_EDITION', 'cloud'),
'deployment_mode' => env('DEPLOYMENT_MODE', 'cloud'),
'enabled_modules' => array_values(array_filter(explode(',', env('ENABLED_MODULES', '')))),
'operator_enabled' => filter_var(env('OPERATOR_ENABLED', true), FILTER_VALIDATE_BOOL),
];
业务代码通过 Edition Service 查询,不在各 Controller 重复解析字符串。
功能 E:构建脚本流程
构建脚本读取 Edition manifest,复制允许文件到独立 dist/<edition>,再移除未启用模块和 Operator。不能在源仓库执行删除。
校验工作区
-> 读取 Edition
-> 解析模块依赖
-> 复制基础仓库到 dist
-> 裁剪 app/Modules 和 app/Operator
-> 裁剪前端模块和路由
-> 生成 .env.example
-> composer install --no-dev --optimize-autoloader
-> 前端生产构建
-> 生成版本/commit/文件哈希/迁移清单
-> 在空数据库安装测试
-> 打包制品
建议 Artisan 命令入口:
php artisan edition:build private-chat
Command 只编排,复制、裁剪、manifest 和验证拆分为 Service,便于测试。
验收测试
- 重复模块 ID 和缺少依赖时构建失败。
- Cloud 加载 Operator,Private 不加载。
- 未启用模块路由不存在。
- Edition 包含模块但 tenant_apps 未授权返回 403。
- 授权且有 permission 才可访问。
- Private + Chat 制品从空环境完成安装、迁移和测试。
- 制品不包含
.env、测试 Token、OAuth 私钥和 Operator 源码。
23-文件上传对象存储和下载.md
文件上传、对象存储和授权下载完整流程
0. 状态与依赖
执行阶段:P13 按需平台能力
功能状态:规划草案
所属区域:Platform/File + 具体模块附件能力
数据归属:租户元数据表 + Object Storage
前置流程:Tenant、Authorization、Queue、审计
只有第一个上传业务出现时实施。执行前将本文片段升级为完整文件,并核对 Laravel Filesystem、对象存储驱动和病毒扫描方案。
0.1 文件地图
| 层 | 目标目录 | 说明 |
|---|---|---|
| 文件元数据 | app/Platform/File/Models 或模块自己的 Models |
根据共享范围决定,租户数据使用租户连接 |
| 上传 Request | app/Platform/File/Http/Requests |
MIME、大小、扩展名和业务归属验证 |
| Storage Service | app/Platform/File/Services |
生成不可猜测路径,不公开底层磁盘路径 |
| Policy | app/Platform/File/Policies |
下载前验证租户、成员和资源归属 |
| 清理 Job | app/Platform/File/Jobs |
afterCommit、重试、幂等和孤儿文件清理 |
| 配置 | config/filesystems.php、.env.example |
Secret 仅环境注入 |
1. 功能目的
提供统一文件能力,业务模块只引用 file_id,不自己处理随机文件名、租户目录、MIME、大小、下载权限和清理。
2. Tenant Migration
租户业务文件元数据进入租户库:
Schema::create('files', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->unsignedBigInteger('uploaded_by')->index();
$table->string('disk', 50);
$table->string('path');
$table->string('original_name');
$table->string('mime_type', 255);
$table->unsignedBigInteger('size');
$table->string('sha256', 64)->index();
$table->string('visibility', 20)->default('private');
$table->string('status', 20)->default('ready')->index();
$table->jsonb('metadata')->nullable();
$table->timestampsTz();
$table->softDeletesTz();
$table->unique(['disk', 'path']);
});
用户头像等中央文件可建立独立中央表,不让租户业务文件混入中央库。
3. Model
<?php
/**
* @description 租户文件元数据模型
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Files\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Stancl\Tenancy\Database\Concerns\TenantConnection;
final class StoredFile extends Model
{
use HasUuids;
use SoftDeletes;
use TenantConnection;
protected $table = 'files';
protected $fillable = [
'uploaded_by', 'disk', 'path', 'original_name', 'mime_type',
'size', 'sha256', 'visibility', 'status', 'metadata',
];
/** @return array<string, string> */
protected function casts(): array
{
return ['metadata' => 'array'];
}
}
4. Request
<?php
/**
* @description 租户文件上传请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Files\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
final class UploadFileRequest extends ApiRequest
{
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'file' => [
'required',
'file',
'max:20480',
'mimes:jpg,jpeg,png,pdf,doc,docx,xls,xlsx,txt,md',
],
];
}
}
不同模块可追加更严格规则,不能为了方便允许所有扩展名。服务端检查 MIME,不能只信任浏览器文件名。
5. Service
public function store(
UploadedFile $uploadedFile,
User $user,
string $tenantId,
): StoredFile {
$disk = config('filesystems.default');
$extension = Str::lower($uploadedFile->getClientOriginalExtension());
$filename = Str::uuid().($extension ? '.'.$extension : '');
$path = "tenants/{$tenantId}/uploads/".now()->format('Y/m/d')."/{$filename}";
$stream = fopen($uploadedFile->getRealPath(), 'rb');
try {
Storage::disk($disk)->put($path, $stream, ['visibility' => 'private']);
} finally {
if (is_resource($stream)) {
fclose($stream);
}
}
try {
return StoredFile::create([
'uploaded_by' => $user->id,
'disk' => $disk,
'path' => $path,
'original_name' => $uploadedFile->getClientOriginalName(),
'mime_type' => $uploadedFile->getMimeType() ?: 'application/octet-stream',
'size' => $uploadedFile->getSize(),
'sha256' => hash_file('sha256', $uploadedFile->getRealPath()),
'visibility' => 'private',
'status' => 'ready',
]);
} catch (Throwable $exception) {
Storage::disk($disk)->delete($path);
throw $exception;
}
}
数据库失败时删除已上传对象。反过来删除业务记录时先软删除,异步清理对象,避免事务中外部存储失败造成数据无法恢复。
6. Resource
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'disk' => $this->disk,
'path' => $this->path,
'name' => $this->original_name,
'mime' => $this->mime_type,
'size' => $this->size,
'sha256' => $this->sha256,
'url' => route('tenant.files.download', ['file' => $this->id]),
'created_at' => $this->created_at,
];
}
私有对象不返回永久对象存储 URL。
7. Controller
public function store(UploadFileRequest $request): JsonResponse
{
$file = $this->fileService->store(
$request->file('file'),
$request->user('api'),
tenant('id'),
);
return ApiResponse::success(
data: new FileResource($file),
message: '文件上传成功',
);
}
下载:
public function download(Request $request, StoredFile $file): StreamedResponse
{
$this->authorize('download', $file);
abort_unless(Storage::disk($file->disk)->exists($file->path), 404);
return Storage::disk($file->disk)->download(
$file->path,
$file->original_name,
['Content-Type' => $file->mime_type],
);
}
8. Policy
public function download(User $user, StoredFile $file): bool
{
return $file->uploaded_by === $user->id
|| $user->can('tenant.files.download-any');
}
模块附件还需验证用户能访问关联业务资源,不能只有 file_id 就下载。
9. Routes
Route::post('/files/upload', [FileController::class, 'store'])
->middleware('permission:tenant.files.upload')
->name('tenant.files.upload');
Route::get('/files/{file}/download', [FileController::class, 'download'])
->name('tenant.files.download');
两条都位于 auth、tenant、membership 组。
10. ApiPost
POST http://demo.localhost:8000/api/tenant/files/upload
Authorization: Bearer AccessToken
Content-Type: multipart/form-data
Body form-data:字段名 file,类型 File。
11. 对象存储
生产配置 S3/OSS 兼容磁盘。大文件使用“后端签发临时上传凭证 -> 前端直传 -> 回调确认元数据”,但上传凭证必须限制 tenant path、文件大小、MIME 和有效期。
12. 安全和清理
- 图片/文档按需接入病毒扫描,扫描完成前 status=quarantined。
- 不执行用户上传的脚本和宏。
- 原始文件名只用于下载显示,不作为磁盘路径。
- 删除引用前检查是否仍被模块使用。
- 定时清理未确认直传、软删除到期和孤儿对象。
13. 测试
- 文件过大/扩展名错误返回 422。
- tenant A 不能下载 B 文件。
- 无权限和非资源成员不能下载。
- 数据库失败会删除对象。
- 对象缺失返回 404。
- Resource 不返回永久私有 URL。
24-配置中心字典和功能开关.md
配置中心、字典和功能开关完整流程
0. 状态与依赖
执行阶段:P13 按需平台能力
功能状态:规划草案
所属区域:Platform/Configuration
数据归属:中央配置 + 租户配置
前置流程:Tenant、缓存 Key、审计、Edition 边界
Secret 不进入普通配置表。Feature Flag 不替代 Edition 制品裁剪;未交付模块不能仅靠数据库开关隐藏源码。
0.1 文件地图
| 能力 | 目标目录 | 数据库 |
|---|---|---|
| 平台配置 | app/Platform/Configuration |
中央 |
| 租户配置 | app/Platform/Configuration/Tenant |
租户或明确中央控制面 |
| 字典 | app/Platform/Dictionary |
根据是否全平台共享决定 |
| Feature Flag | app/Platform/Features |
中央定义 + 租户覆盖 |
| 缓存 | app/Support/Cache/CacheKey.php |
Redis,必须含作用域 |
配置写入必须由 Service 负责类型转换、缓存失效和审计;Controller 不直接写 key/value。
功能 A:平台配置
1. Migration(中央库)
Schema::create('system_configs', function (Blueprint $table): void {
$table->id();
$table->string('group', 100)->index();
$table->string('key', 150)->unique();
$table->jsonb('value')->nullable();
$table->string('type', 30)->default('string');
$table->boolean('is_public')->default(false)->index();
$table->boolean('is_secret')->default(false);
$table->string('description')->nullable();
$table->timestampsTz();
});
2. Model
<?php
/**
* @description 中央系统配置模型
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Settings\Models;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Database\Concerns\CentralConnection;
final class SystemConfig extends Model
{
use CentralConnection;
protected $fillable = [
'group', 'key', 'value', 'type', 'is_public', 'is_secret', 'description',
];
/** @return array<string, string> */
protected function casts(): array
{
return [
'value' => 'array',
'is_public' => 'boolean',
'is_secret' => 'boolean',
];
}
}
Secret 不建议和普通 JSON 一样保存。执行时使用 Laravel Encrypter 或专用 Secret Store,Resource 永不返回明文。
3. Request
<?php
/**
* @description 更新中央系统配置请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Settings\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
final class UpdateSystemConfigRequest extends ApiRequest
{
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'value' => ['present'],
];
}
}
配置 key 不由用户创建时,Route 参数从预注册 Schema 中取规则:
'site.name' => ['value' => ['required', 'string', 'max:100']],
'site.registration_enabled' => ['value' => ['required', 'boolean']],
4. Service
public function update(string $key, mixed $value): SystemConfig
{
$definition = $this->registry->get($key);
Validator::validate(['value' => $value], $definition->rules);
$config = SystemConfig::updateOrCreate(
['key' => $key],
[
'group' => $definition->group,
'value' => $definition->secret ? Crypt::encrypt($value) : $value,
'type' => $definition->type,
'is_public' => $definition->public,
'is_secret' => $definition->secret,
'description' => $definition->description,
],
);
Cache::forget(CacheKey::central('config', $key));
return $config;
}
5. Controller/Routes
Route::get('/admin/system/config/public', [SystemConfigController::class, 'public']);
Route::get('/admin/system/config', [SystemConfigController::class, 'index'])
->middleware('permission:platform.config.view');
Route::put('/admin/system/config/{key}', [SystemConfigController::class, 'update'])
->where('key', '[A-Za-z0-9._-]+')
->middleware('permission:platform.config.update');
公开接口只返回 is_public=true,且绝不包含 secret。
功能 B:租户配置
Tenant Migration:
Schema::create('tenant_configs', function (Blueprint $table): void {
$table->id();
$table->string('group', 100)->index();
$table->string('key', 150)->unique();
$table->jsonb('value')->nullable();
$table->string('type', 30)->default('string');
$table->boolean('is_secret')->default(false);
$table->timestampsTz();
});
Service 与平台配置类似,但 Model 使用 TenantConnection,缓存 Key 使用当前 tenant_id。Module 配置 key 使用命名空间,例如 chat.default_model。
API:
GET /api/tenant/configs
PUT /api/tenant/configs/{key}
权限:tenant.config.view/update;模块配置还检查 module entitlement。
功能 C:字典
Tenant Migration:
Schema::create('dictionaries', function (Blueprint $table): void {
$table->id();
$table->string('key', 100)->unique();
$table->string('name');
$table->string('status', 20)->default('active');
$table->timestampsTz();
});
Schema::create('dictionary_items', function (Blueprint $table): void {
$table->id();
$table->foreignId('dictionary_id')->constrained()->cascadeOnDelete();
$table->string('value', 100);
$table->string('label');
$table->integer('sort')->default(0);
$table->string('status', 20)->default('active');
$table->jsonb('metadata')->nullable();
$table->timestampsTz();
$table->unique(['dictionary_id', 'value']);
});
Service 在事务中创建字典和 items;更新后清理 tenant:<id>:dictionary:<key> 缓存。已经被业务数据引用的 item 不物理删除,改为 disabled。
功能 D:Feature Flag
功能渐进发布优先评估 Laravel Pennant。Feature Flag 不替代 Edition 和 tenant_apps:
Edition 是否包含代码
tenant_apps 租户是否购买
Permission 用户是否有权
Pennant 已有能力是否对某用户/租户渐进开启
定义:
Feature::define('new-chat-ui', function (User $user): bool {
return tenant() !== null
&& $this->entitlements->tenantAllows(tenant('id'), 'chat')
&& in_array(tenant('id'), config('features.new_chat_ui_tenants'), true);
});
后端仍校验正式权限,Feature 关闭时 Route 返回 404 或明确功能未开放。
ApiPost
PUT http://demo.localhost:8000/api/tenant/configs/chat.default_model
Authorization: Bearer 有配置权限Token
Content-Type: application/json
{
"value": "deepseek-chat"
}
测试
- 公开配置不泄露 secret。
- 配置类型和 key 规则正确。
- tenant A 配置不影响 B。
- 字典 value 在同一字典唯一。
- disabled item 不出现在公开选项。
- Feature Flag 不能绕过 tenant_apps 和 permission。
25-跨服务认证事件和Webhook.md
跨服务认证、可靠事件和 Webhook 完整流程
0. 状态与依赖
执行阶段:P13 按需平台能力
功能状态:规划草案
所属区域:Platform/Integration
数据归属:中央服务凭证 + 业务 Outbox/Inbox
前置流程:Passport、Queue、Redis 幂等、Request ID、日志脱敏
第一个 FastAPI、Go、钉钉或支付集成前完成最小 HTTP Client 和认证闭环;第一个可靠跨服务事件前完成 Outbox。不得让外部服务直接读 Laravel 数据库。
0.1 文件地图
| 能力 | 目标目录 | 规则 |
|---|---|---|
| 服务 OAuth | app/Platform/Integration/Auth |
Passport Client Credentials + 最小 Scope |
| HTTP Client | app/Platform/Integration/Http |
timeout、retry、Request ID、错误映射和脱敏 |
| Outbox | app/Platform/Integration/Outbox |
与业务数据同库事务写入 |
| Publisher Job | app/Platform/Integration/Jobs |
幂等、领取锁、重试和死信 |
| Webhook Inbox | app/Platform/Integration/Webhooks |
原始 Body 验签、事件 ID 唯一、快速响应 |
外部协议 DTO 只有多个 Client/Adapter 共享稳定契约时创建,禁止为一次 HTTP 调用堆叠无价值包装类。
功能 A:服务间 OAuth Client Credentials
1. 目的
FastAPI、Go 和 Adapter 使用服务身份调用 Laravel,不共享管理员账号密码,也不直接读取中央数据库。
创建独立 confidential Client,记录 Client ID/Secret 到服务 Secret Store。Token 请求:
POST http://127.0.0.1:8000/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
client_id=服务ClientID
client_secret=服务ClientSecret
scope=service.permissions.check
2. Scope
AppServiceProvider::boot():
Passport::tokensCan([
'service.permissions.check' => '查询平台授权结果',
'service.chat.execute' => '执行 AI Chat 任务',
'service.events.publish' => '发布服务事件',
]);
路由必须校验 Bearer Token 和 Scope,不能自写解析 JWT 后跳过 Passport 的签名、过期和撤销检查。当前项目 Passport 13 使用以下中间件别名。
bootstrap/app.php:
use Laravel\Passport\Http\Middleware\CheckToken;
use Laravel\Passport\Http\Middleware\CheckTokenForAnyScope;
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'scopes' => CheckToken::class,
'scope' => CheckTokenForAnyScope::class,
]);
})
scopes 要求同时具备列出的全部 Scope;scope 表示具备其中任意一个。
Route::post('/internal/permissions/check', [InternalPermissionController::class, 'check'])
->middleware('scopes:service.permissions.check');
内部路由仍只监听内网或经过网关,OAuth 不是暴露互联网的理由。
功能 B:权限检查 API
Request
<?php
/**
* @description 跨服务权限批量检查请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Integration\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
final class CheckPermissionRequest extends ApiRequest
{
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'user_id' => ['required', 'integer'],
'tenant_id' => ['required', 'string'],
'permissions' => ['required', 'array', 'min:1', 'max:100'],
'permissions.*' => ['required', 'string', 'distinct'],
];
}
}
Service
public function check(
int $userId,
string $tenantId,
array $permissions,
): array {
$membership = TenantMembership::query()
->where('tenant_id', $tenantId)
->where('user_id', $userId)
->where('status', 'active')
->first();
if ($membership === null) {
return array_fill_keys($permissions, false);
}
$tenant = Tenant::query()->findOrFail($tenantId);
$scope = $this->authorizationScopes->tenantScope($tenant);
$user = User::findOrFail($userId);
setPermissionsTeamId($scope->getKey());
$user->unsetRelation('roles')->unsetRelation('permissions');
return collect($permissions)
->mapWithKeys(fn (string $permission): array => [
$permission => $user->can($permission),
])
->all();
}
Controller
public function check(CheckPermissionRequest $request): JsonResponse
{
return ApiResponse::success(data: [
'permissions' => $this->authorizationService->check(
$request->integer('user_id'),
$request->string('tenant_id'),
$request->validated('permissions'),
),
]);
}
跨服务结果可短期缓存,但权限/成员/模块授权变更必须使缓存失效。
功能 C:HTTP Client 基类
<?php
/**
* @description 服务间 OAuth Access Token 提供器
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Platform\Integration\Services;
use App\Platform\Cache\Support\CacheKey;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
final class ServiceTokenProvider
{
/** 获取并缓存指定内部服务的短期 Access Token。 */
public function token(string $service): string
{
return Cache::remember(
CacheKey::central('service-token', $service),
now()->addMinutes(10),
function () use ($service): string {
$config = config("services.{$service}");
$response = Http::asForm()
->timeout(10)
->post(config('app.url').'/oauth/token', [
'grant_type' => 'client_credentials',
'client_id' => $config['client_id'],
'client_secret' => $config['client_secret'],
'scope' => $config['scope'],
])
->throw()
->json();
return $response['access_token'];
},
);
}
}
Laravel 调自己时不要通过 php artisan serve 单进程 HTTP 自请求;该 Provider 用于其他独立服务地址或生产多进程环境。
业务 Client 设置 connect timeout、timeout、有限重试、request_id、tenant_id 和幂等键;非幂等 POST 不盲目 retry。
功能 D:Outbox
1. 目的
数据库提交成功但消息发布失败时不丢事件。业务写入和 Outbox 记录放同一数据库事务,异步 Publisher 再发送。
2. Tenant Migration
Schema::create('outbox_events', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->string('type');
$table->string('aggregate_type');
$table->string('aggregate_id');
$table->jsonb('payload');
$table->string('status', 20)->default('pending')->index();
$table->unsignedInteger('attempts')->default(0);
$table->timestampTz('available_at')->index();
$table->timestampTz('published_at')->nullable();
$table->text('last_error')->nullable();
$table->timestampsTz();
});
3. 写入
DB::transaction(function () use ($conversation): void {
$conversation->save();
OutboxEvent::create([
'type' => 'chat.conversation.created.v1',
'aggregate_type' => 'chat_conversation',
'aggregate_id' => $conversation->id,
'payload' => [
'tenant_id' => tenant('id'),
'conversation_id' => $conversation->id,
'user_id' => $conversation->created_by,
],
'available_at' => now(),
]);
});
4. Publisher
按 tenant 批次初始化上下文,使用 lockForUpdate()->skipLocked() 领取事件,发布成功标记 published,失败增加 attempts 和 available_at。消费者用 event ID 幂等。
事件类型带版本 .v1,字段破坏性变化发布 .v2。
功能 E:Inbound Webhook
Migration
Schema::create('webhook_events', function (Blueprint $table): void {
$table->id();
$table->string('provider', 50);
$table->string('event_id');
$table->string('tenant_id')->nullable();
$table->string('type');
$table->jsonb('payload');
$table->string('status', 20)->default('pending');
$table->timestampsTz();
$table->unique(['provider', 'event_id']);
});
Controller 顺序:读取原始 Body -> 验签和时间戳 -> 解析白名单事件 -> firstOrCreate -> 新事件派发 Job -> 立即 2xx。不能先写业务数据再验签。
测试
- 无 Client Token、错误 Scope 和撤销 Client Token 被拒绝。
- 服务权限 API 验证 active membership 和 team_id。
- Outbox 与业务事务一起提交/回滚。
- Publisher 重试不重复消费。
- Webhook 验签失败不落业务表。
- 重复 provider event ID 只处理一次。
- 跨服务日志携带 request_id/tenant_id 且不含 Secret。
26-Chat模块完整接入.md
Chat 模块完整接入流程
0. 状态与依赖
执行阶段:P14 业务模块
功能状态:规划草案
所属区域:Modules/Chat + FastAPI 外部服务
数据归属:租户数据库 + 外部 AI 服务
前置流程:模块注册、文件、HTTP Client、Outbox、Tenant、Authorization
Chat 不得直接依赖 Operator。Laravel 负责身份、租户、权限、会话元数据和审计;FastAPI/Go 通过版本化 API 和事件协作,不共享 Laravel Model。
0.1 文件地图
| 层 | 目标目录 | 规则 |
|---|---|---|
| Tenant Migration | app/Modules/Chat/Database/Migrations 或统一 tenant migration 目录 |
由模块 Provider 注册 |
| Model | app/Modules/Chat/Models |
TenantConnection |
| HTTP | app/Modules/Chat/Http |
Request/Resource/Controller 完整分层 |
| Service | app/Modules/Chat/Services |
会话、消息用例和租户事务 |
| 外部 Client | app/Modules/Chat/Clients/AiChatClient.php |
使用统一 HTTP Client 基类 |
| Job/Event | app/Modules/Chat/Jobs、Events |
显式 tenant_id、幂等键、afterCommit |
| Route | app/Modules/Chat/Routes/api.php |
由模块 Provider 加载,不在核心 tenant.php 堆叠 |
下方片段是领域设计草案,执行时必须按当前 FastAPI 契约补成完整文件和 Contract Test。
状态:规划实现。依赖租户隔离、组织权限和模块注册验收。
一、职责边界
Laravel Chat Module
模块授权、配置、权限、会话元数据、成员、审计、服务凭证
FastAPI AI
模型调用、Prompt 编排、知识库、Embedding、RAG、文档处理
Go/Reatime(需要时)
WebSocket、在线状态、实时消息分发
功能 A:租户聊天会话
1. Tenant Migration
Schema::create('chat_conversations', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->unsignedBigInteger('created_by')->index();
$table->string('title')->nullable();
$table->string('status', 20)->default('active')->index();
$table->jsonb('settings')->nullable();
$table->timestampsTz();
$table->softDeletesTz();
});
Schema::create('chat_messages', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->uuid('conversation_id');
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->string('role', 20);
$table->text('content');
$table->string('status', 20)->default('completed');
$table->string('idempotency_key')->nullable()->unique();
$table->jsonb('usage')->nullable();
$table->timestampsTz();
$table->foreign('conversation_id')->references('id')->on('chat_conversations')->cascadeOnDelete();
});
2. Models
<?php
/**
* @description 租户聊天会话模型
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Modules\Chat\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Stancl\Tenancy\Database\Concerns\TenantConnection;
final class Conversation extends Model
{
use HasUuids;
use SoftDeletes;
use TenantConnection;
protected $table = 'chat_conversations';
protected $fillable = ['created_by', 'title', 'status', 'settings'];
/** @return array<string, string> */
protected function casts(): array
{
return ['settings' => 'array'];
}
/** 获取会话消息。 */
public function messages(): HasMany
{
return $this->hasMany(Message::class, 'conversation_id');
}
}
3. Request
<?php
/**
* @description 创建聊天会话请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Modules\Chat\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
final class StoreConversationRequest extends ApiRequest
{
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'title' => ['nullable', 'string', 'max:255'],
'settings' => ['array'],
];
}
}
4. Service
public function createConversation(User $user, array $data): Conversation
{
return Conversation::create([
'created_by' => $user->id,
'title' => $data['title'] ?? null,
'settings' => $data['settings'] ?? [],
'status' => 'active',
]);
}
5. Resource/Controller
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'status' => $this->status,
'created_by' => $this->created_by,
'created_at' => $this->created_at,
];
}
public function store(StoreConversationRequest $request): JsonResponse
{
$conversation = $this->chatService->createConversation(
$request->user('api'),
$request->validated(),
);
return ApiResponse::success(
data: new ConversationResource($conversation),
message: '创建会话成功',
);
}
6. Route
Route::middleware([
'auth:api',
InitializeTenancyByDomain::class,
EnsureTenantMember::class,
'module:chat',
])->prefix('api/tenant/chat')->group(function (): void {
Route::get('/conversations', [ConversationController::class, 'index'])
->middleware('permission:chat.conversation.read');
Route::post('/conversations', [ConversationController::class, 'store'])
->middleware('permission:chat.conversation.send');
});
7. ApiPost
POST http://demo.localhost:8000/api/tenant/chat/conversations
Authorization: Bearer AccessToken
Content-Type: application/json
{
"title": "Laravel SaaS 讨论"
}
功能 B:发送消息和调用 FastAPI
1. Request
<?php
/**
* @description 发送聊天消息请求
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Modules\Chat\Http\Requests;
use App\Support\Http\Requests\ApiRequest;
final class SendMessageRequest extends ApiRequest
{
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'content' => ['required', 'string', 'max:50000'],
'idempotency_key' => ['required', 'uuid'],
];
}
}
2. FastAPI Client
<?php
/**
* @description AI Chat 服务 HTTP 客户端
* @author Yanhong <jackyan@pshang.net>
* @createTime 2026-08-05 00:00:00
* @lastModified 2026-08-05 00:00:00
*/
namespace App\Modules\Chat\Integrations;
use App\Modules\Chat\Models\Conversation;
use App\Modules\Chat\Models\Message;
use Illuminate\Support\Facades\Http;
final class AiChatClient
{
/** @return array<string, mixed> */
public function send(
string $tenantId,
int $userId,
Conversation $conversation,
Message $message,
string $requestId,
): array {
return Http::baseUrl(config('services.ai.url'))
->withToken(config('services.ai.service_token'))
->acceptJson()
->timeout(30)
->retry(2, 500)
->post('/v1/chat/completions', [
'tenant_id' => $tenantId,
'user_id' => $userId,
'conversation_id' => $conversation->id,
'message_id' => $message->id,
'content' => $message->content,
'request_id' => $requestId,
])
->throw()
->json();
}
}
正式服务间认证应使用短期服务凭证或 OAuth Client Credentials,不长期共享固定用户 Token。
3. Service
public function enqueueMessage(
User $user,
Conversation $conversation,
array $data,
string $requestId,
): Message {
if ($conversation->created_by !== $user->id
&& ! $user->can('chat.conversation.manage')) {
throw new AuthorizationException();
}
$message = Message::firstOrCreate(
['idempotency_key' => $data['idempotency_key']],
[
'conversation_id' => $conversation->id,
'user_id' => $user->id,
'role' => 'user',
'content' => $data['content'],
'status' => 'pending',
],
);
if ($message->wasRecentlyCreated) {
ProcessChatMessage::dispatch(
tenant('id'),
$message->id,
$requestId,
);
}
return $message;
}
Job 恢复 tenant 上下文,调用 AI,写 assistant message 和 usage。重试使用相同 idempotency_key,避免重复扣费和重复消息。
4. Controller/Route
public function send(
SendMessageRequest $request,
Conversation $conversation,
): JsonResponse {
$message = $this->chatService->enqueueMessage(
$request->user('api'),
$conversation,
$request->validated(),
$request->attributes->get('request_id'),
);
return ApiResponse::success(
data: new MessageResource($message),
message: '消息已提交处理',
);
}
Route::post('/conversations/{conversation}/messages', [MessageController::class, 'send'])
->middleware('permission:chat.conversation.send');
5. ApiPost
POST http://demo.localhost:8000/api/tenant/chat/conversations/{conversation}/messages
Authorization: Bearer AccessToken
Content-Type: application/json
{
"content": "请解释当前租户权限流程",
"idempotency_key": "每次业务请求生成的UUID"
}
功能 C:知识库文件
文件上传进入模块 API,Laravel 验证大小、MIME、权限和租户路径,保存附件元数据后派发文档处理 Job。FastAPI 只接收带 tenant_id、file_id 和受控下载地址的任务。向量表必须携带 tenant_id 或按租户数据库隔离,检索时强制租户过滤。
功能 D:流式响应
第一版可由 Laravel 反向代理 FastAPI SSE;连接规模增大后引入 Go/Realtime。无论通道如何,Token 校验、tenant membership、module entitlement 和 permission 必须在建立流前完成。断线重连使用 message ID/事件序号,不能重复生成 AI 请求。
测试
- tenant A 无法访问 B conversation/message/file/vector。
- 无 chat tenant_apps 返回 403。
- 无权限返回 403。
- conversation Policy 限制资源归属。
- 重复 idempotency_key 不重复写入和扣费。
- Job 正确恢复 tenant。
- AI 超时、失败和重试状态可查询。
- Token、Prompt 敏感数据和服务密钥按规则脱敏。
27-钉钉和外部集成.md
钉钉和外部集成完整流程
0. 状态与依赖
执行阶段:P14 外部集成模块
功能状态:规划草案
所属区域:Modules/DingTalk + External Service
数据归属:中央外部身份、租户同步状态、外部钉钉权威数据
前置流程:Identity 外部身份契约、Tenant、Queue、HTTP Client、Webhook 幂等
执行时必须联网核对钉钉当前官方 OAuth、扫码和组织同步文档。Secret 不写数据库明文、日志、文档或前端。
0.1 文件地图
| 层 | 目标目录 | 说明 |
|---|---|---|
| 通用外部身份契约 | app/Platform/Identity/Contracts、Data、Services |
不含钉钉专属字段 |
| 钉钉 Adapter | app/Modules/DingTalk/Adapters |
只实现官方协议转换 |
| OAuth Controller | app/Modules/DingTalk/Http/Controllers |
state、一次性 ticket、回调编排 |
| 同步 Job | app/Modules/DingTalk/Jobs |
tenant_id、重试、幂等和失败状态 |
| Webhook | app/Modules/DingTalk/Webhooks |
验签优先于解析和入库 |
| Route | app/Modules/DingTalk/Routes |
Module Provider 加载 |
钉钉 URL、字段和签名算法必须来自执行时的官方文档,本文不使用占位地址冒充可运行代码。
状态:规划实现。依赖中央外部身份表、组织模型和模块机制完成。
说明:钉钉接口地址、签名算法和字段可能升级。执行本流程前必须对照当时钉钉官方文档核对 Adapter 内部请求;平台侧目录、身份绑定、幂等和权限流程保持不变。
功能 A:钉钉配置
.env:
DINGTALK_CLIENT_ID=
DINGTALK_CLIENT_SECRET=
DINGTALK_REDIRECT_URI=http://127.0.0.1:8000/api/admin/auth/external/dingtalk/callback
DINGTALK_WEBHOOK_TOKEN=
config/services.php:
'dingtalk' => [
'client_id' => env('DINGTALK_CLIENT_ID'),
'client_secret' => env('DINGTALK_CLIENT_SECRET'),
'redirect_uri' => env('DINGTALK_REDIRECT_URI'),
'webhook_token' => env('DINGTALK_WEBHOOK_TOKEN'),
],
Secret 只在部署环境,不能放租户普通配置表明文。如果每个租户有独立钉钉应用,使用加密 Settings/Secrets 存储,并按 tenant_id 读取。
功能 B:OAuth/扫码登录 Adapter
1. 契约
直接实现文档 15 中唯一的 App\Platform\Identity\Contracts\ExternalIdentityProvider,并返回 App\Platform\Identity\Data\ExternalIdentityData。钉钉模块不得复制第二个同名契约,否则 Identity Service 将无法统一替换不同提供方。
Adapter 文件必须包含:
use App\Platform\Identity\Contracts\ExternalIdentityProvider;
use App\Platform\Identity\Data\ExternalIdentityData;
2. Adapter
文件:app/Modules/DingTalk/Adapters/DingTalkIdentityProvider.php
状态:规划草案,当前禁止创建占位实现。进入本步骤时必须联网核对钉钉官方文档,并一次性提供包含文件头、namespace、use、构造注入和完整错误映射的文件。
Adapter 必须完成:
- 使用官方当前授权端点和参数生成 URL。
- 使用统一 HTTP Client 设置连接超时、总超时、有限重试和 Request ID。
- 交换授权码时校验响应状态和必需字段。
- 将钉钉响应转换成稳定的
ExternalIdentityData,不让钉钉字段渗透 Identity Service。 - 日志只记录 provider、状态码和 Request ID,不记录 code、access_token 和 client_secret。
- 使用
Http::fake()做请求 Contract Test,并增加至少一个沙箱环境集成测试。
在官方端点、Scope、subject 字段、签名要求和错误码没有确认前,不得用假 URL 生成“可运行”PHP 代码。
3. Redirect Controller
public function redirect(DingTalkIdentityProvider $provider): RedirectResponse
{
$state = Str::random(40);
Cache::put(
"oauth-state:dingtalk:{$state}",
true,
now()->addMinutes(10),
);
return redirect()->away($provider->authorizationUrl($state));
}
4. Callback Controller
public function callback(
Request $request,
DingTalkIdentityProvider $provider,
ExternalIdentityService $identityService,
): RedirectResponse {
$request->validate([
'code' => ['required', 'string'],
'state' => ['required', 'string'],
]);
$stateKey = "oauth-state:dingtalk:{$request->string('state')}";
if (! Cache::pull($stateKey)) {
throw ValidationException::withMessages([
'state' => ['授权状态不存在或已过期'],
]);
}
$identity = $provider->exchangeCode($request->string('code'));
$user = $identityService->loginOrBind($identity);
// Web 回调进入前端一次性登录交换流程,不把 Access Token 放 URL。
$loginTicket = $identityService->createOneTimeLoginTicket($user);
return redirect()->away(
config('services.frontend.url').'/oauth/callback?ticket='.urlencode($loginTicket),
);
}
不要把完整 Access Token 放到浏览器 URL,URL 会进入历史、代理日志和 Referer。使用一次性 ticket,再由前端 POST 兑换 Token。
5. Routes
Route::get('/admin/auth/external/dingtalk/redirect', [DingTalkAuthController::class, 'redirect']);
Route::get('/admin/auth/external/dingtalk/callback', [DingTalkAuthController::class, 'callback']);
Route::post('/admin/auth/external/ticket/exchange', [ExternalAuthController::class, 'exchangeTicket'])
->middleware('throttle:login');
功能 C:绑定与解绑
已登录用户发起绑定时,把 user_id 写入一次性 state 上下文;回调后创建 user_identities。唯一约束防止同一钉钉 subject 绑定多个中央用户。
public function bind(User $user, ExternalIdentityData $identity): UserIdentity
{
$occupied = UserIdentity::query()
->where('provider', $identity->provider)
->where('provider_subject', $identity->subject)
->where('user_id', '!=', $user->id)
->exists();
if ($occupied) {
throw new DomainException('该钉钉账号已绑定其他平台用户');
}
return UserIdentity::updateOrCreate(
['user_id' => $user->id, 'provider' => $identity->provider],
['provider_subject' => $identity->subject, 'profile' => $identity->profile],
);
}
解绑前确认用户仍有密码或其他可用登录方式,避免把自己锁在账号外。
功能 D:组织同步
1. 同步任务表
中央记录同步批次,租户库写 organizations/departments/members:
Schema::create('integration_sync_runs', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->string('tenant_id');
$table->string('provider', 50);
$table->string('type', 50);
$table->string('status', 20)->index();
$table->string('cursor')->nullable();
$table->jsonb('stats')->nullable();
$table->text('error')->nullable();
$table->timestampsTz();
});
2. Job
public function handle(DingTalkOrganizationAdapter $adapter): void
{
$tenant = Tenant::findOrFail($this->tenantId);
tenancy()->initialize($tenant);
try {
foreach ($adapter->departments($this->tenantId) as $externalDepartment) {
Department::updateOrCreate(
['external_provider' => 'dingtalk', 'external_id' => $externalDepartment->id],
[
'name' => $externalDepartment->name,
'parent_external_id' => $externalDepartment->parentId,
'status' => 'active',
],
);
}
// 用户同步先匹配/创建中央身份,再写当前租户 organization_members。
} finally {
tenancy()->end();
}
}
Job 必须携带 tenant_id、sync_run_id 和分页 cursor;每一页幂等 updateOrCreate;不能因为一次失败把缺失用户立即删除,使用 last_seen_at 后再做停用确认。
3. Route
Route::post('/integrations/dingtalk/organization/sync', [DingTalkSyncController::class, 'store'])
->middleware(['module:dingtalk', 'permission:dingtalk.organization.sync']);
ApiPost:
POST http://demo.localhost:8000/api/tenant/integrations/dingtalk/organization/sync
Authorization: Bearer 有同步权限的Token
返回 sync_run_id,前端轮询状态,而不是等待整个组织同步完成。
功能 E:Webhook
Controller 必须先验签、时间戳和 nonce,再保存 provider event ID。事件 ID 唯一,重复回调直接返回成功但不重复派发 Job。
$event = IntegrationWebhookEvent::firstOrCreate(
['provider' => 'dingtalk', 'event_id' => $verifiedEvent->id],
['tenant_id' => $tenantId, 'type' => $verifiedEvent->type, 'payload' => $verifiedEvent->payload],
);
if ($event->wasRecentlyCreated) {
ProcessDingTalkWebhook::dispatch($event->id);
}
测试
- state 一次性且过期失败。
- 同一 subject 不能绑定多个 User。
- 回调 URL 不包含平台 Access Token。
- tenant A 钉钉配置不能用于 B。
- 组织同步分页、重试和重复执行幂等。
- Webhook 验签失败不写业务数据。
- Secret、第三方 Token 和回调签名不进入日志。
28-健康监控备份和恢复.md
健康检查、监控、备份和恢复完整流程
0. 状态与依赖
执行阶段:P15 生产运维
功能状态:Laravel /up 已有;深度健康、监控、备份恢复为规划草案
所属区域:Operations
数据归属:中央库、全部租户库、Redis、对象存储
前置流程:目标 Edition 功能完成
备份成功不等于可恢复,生产前必须在隔离环境完成实际恢复演练。健康接口不得泄露数据库密码、主机拓扑和异常堆栈。
0.1 文件地图
| 能力 | 目标位置 |
|---|---|
| 浅健康检查 | Laravel /up |
| 深健康检查 | app/Support/Health,仅受控访问 |
| 备份编排 | app/Console/Commands + 独立备份脚本/服务 |
| 调度 | routes/console.php |
| 指标/错误监控 | 受控监控 SDK 与部署配置 |
| 恢复手册 | docs/部署/恢复演练.md |
备份 Command 只编排受控工具,不在 PHP 中自行实现数据库文件格式。所有命令必须验证目标数据库和备份目录。
功能 A:健康检查
优先使用维护中的 spatie/laravel-health,安装前核对当时 Laravel 13/PHP 8.4 兼容性。
若先实现基础版本,Controller:
public function ready(): JsonResponse
{
$checks = [
'database' => $this->check(fn () => DB::select('select 1')),
'redis' => $this->check(fn () => Cache::store('redis')->get('health-check')),
'storage' => is_writable(storage_path()) ? 'ok' : 'failed',
];
$ready = ! in_array('failed', $checks, true);
return response()->json([
'status' => $ready ? 'ok' : 'failed',
'checks' => $checks,
'time' => now(),
], $ready ? 200 : 503);
}
private function check(Closure $callback): string
{
try {
$callback();
return 'ok';
} catch (Throwable) {
return 'failed';
}
}
健康接口是基础设施协议,可不使用业务 code/message/data,但不能暴露异常、密码、主机内部路径。/up 只检查进程;/health/ready 检查依赖。
功能 B:指标和错误监控
记录:请求量、延迟、5xx、队列等待/失败、数据库连接、Redis、租户开通失败、OAuth 登录失败、AI 调用时长和用量。标签 tenant_id 时控制基数,不能把 user_id、conversation_id 等无限值直接作为指标标签。
错误监控发送 exception class、request_id、release、edition 和脱敏上下文。生产响应只给 request_id,排查通过监控关联。
功能 C:备份 Command
创建:
php artisan make:command BackupTenantDatabase
签名:
protected $signature = 'tenant:backup {tenant} {--output=}';
核心代码使用 Symfony Process,不拼接 shell 字符串:
public function handle(): int
{
$tenant = Tenant::findOrFail((string) $this->argument('tenant'));
$database = $tenant->tenancy_db_name;
$output = $this->option('output')
?: storage_path("app/backups/tenants/{$tenant->getTenantKey()}-".now()->format('YmdHis').'.dump');
File::ensureDirectoryExists(dirname($output));
$process = new Process([
config('backup.pg_dump_path', 'pg_dump'),
'--host='.config('database.connections.pgsql.host'),
'--port='.config('database.connections.pgsql.port'),
'--username='.config('database.connections.pgsql.username'),
'--format=custom',
'--file='.$output,
$database,
]);
$process->setEnv([
...getenv(),
'PGPASSWORD' => config('database.connections.pgsql.password'),
]);
$process->setTimeout(3600);
$process->mustRun();
$hash = hash_file('sha256', $output);
$this->info("备份完成:{$output} SHA256={$hash}");
return self::SUCCESS;
}
不要把密码放命令参数和日志。getenv() 返回处理需按当前 PHP 验证;生产更推荐 .pgpass 或 Secret 注入。
功能 D:中央库和全租户备份编排
获得 backup 全局锁
-> 中央库 pg_dump
-> 查询 active/suspended tenants
-> 每个 tenant 派发 BackupTenant Job
-> 生成 manifest(版本、迁移、文件、SHA256)
-> 上传异地对象存储
-> 校验
-> 发送成功/失败通知
每个租户独立结果,单个失败不抹掉其他成功记录;总任务最终显示 partial_failed。
功能 E:恢复 Command
恢复先到新数据库:
$process = new Process([
config('backup.pg_restore_path', 'pg_restore'),
'--host='.$host,
'--port='.$port,
'--username='.$username,
'--dbname='.$temporaryDatabase,
'--clean',
'--if-exists',
$backupFile,
]);
执行前:验证 tenant ID、备份 manifest、SHA256、目标数据库名和环境;生产切换前暂停租户。恢复后运行 migration status、关键表计数和只读业务检查,再切换租户数据库映射。失败恢复原映射。
功能 F:保留和清理
按日/周/月保留,不只保留本机。清理 Command 只删除 manifest 已登记且超过保留期的备份;路径必须解析并确认位于备份根目录。审计谁发起了备份、恢复和删除。
Scheduler
Schedule::command('backup:central-and-tenants')
->dailyAt('02:00')
->onOneServer()
->withoutOverlapping(360);
Schedule::command('backup:cleanup')
->dailyAt('05:00')
->onOneServer();
验收
- readiness 在数据库/Redis 故障时返回 503 且不泄露详情。
- 错误监控可用 request_id 定位。
- 中央库和单个 tenant 独立备份。
- SHA256 校验错误拒绝恢复。
- 恢复先使用临时数据库并可回滚映射。
- 至少定期执行真实恢复演练,不能只相信备份命令成功。
29-生产化和私有化交付.md
生产化和私有化交付完整流程
0. 状态与依赖
执行阶段:P15 生产交付
功能状态:规划草案
所属区域:Deployment / Edition Build
数据归属:全部运行资源
前置流程:目标 Edition 的 Platform 和业务模块全部验收
本文是生产验收汇总,不再重复发明 Request ID、Queue、Health 和 Backup 实现;这些能力分别以前置流程为准,本文只完成环境固化、制品、发布、回滚和私有化安装演练。
0.1 交付文件地图
| 能力 | 目标位置 |
|---|---|
| 环境模板 | .env.production.example、.env.private.example |
| Nginx/Gateway | deploy/nginx 或独立部署仓库 |
| Edition 构建 | scripts/build-edition.ps1 |
| 容器制品 | docker/、不可包含开发 Secret |
| 发布检查 | app/Console/Commands/ReleaseCheckCommand.php |
| 回滚手册 | docs/部署/回滚.md |
生产命令、配置和脚本必须以实际部署环境复核,文档中的示例不能替代真实演练。
状态:规划实现,贯穿所有阶段。每个 Edition 正式交付前必须完整执行。
功能 A:请求 ID 和结构化日志
Middleware
本流程不再复制 Request ID 中间件源码。唯一实现和完整代码位于文档 08 的 app/Support/Http/Middleware/AssignRequestId.php;生产阶段只验证它已经注册、响应头存在、日志上下文可检索,并确认跨服务请求和 Job 继续传递 request_id。
如果文档 08 的验收尚未通过,必须返回 P05 完成基础能力,不能在生产流程临时再写一个中间件。
日志处理器必须脱敏:Authorization、Cookie、password、token、secret、code_verifier、SMTP 密码和 OAuth 私钥。
功能 B:健康检查
优先安装 spatie/laravel-health,执行时先核对 Laravel 13/PHP 8.4 当前兼容版本。
composer require spatie/laravel-health
配置检查:中央 PostgreSQL、Redis、队列、磁盘空间和必要外部服务。公开健康接口只返回状态,不返回 DSN、密码、路径和异常堆栈。
建议分开:
GET /up 进程存活
GET /health/ready 数据库、Redis、队列等就绪
外部 AI 服务失败是否导致整体 not ready 取决于 Edition;可选模块故障通常返回 degraded,而不是阻止整个后台启动。
功能 C:Horizon
composer require laravel/horizon
php artisan horizon:install
php artisan migrate
.env:
QUEUE_CONNECTION=redis
生产使用 Supervisor/systemd/Docker 保持 php artisan horizon 常驻。Horizon Dashboard 使用 Operator 权限 Gate,不公开访问。
租户 Job 模板:
public function handle(): void
{
$tenant = Tenant::findOrFail($this->tenantId);
tenancy()->initialize($tenant);
try {
// 当前租户业务逻辑
} finally {
tenancy()->end();
}
}
Job 构造参数包含 tenant_id、request_id 和幂等键;不要序列化依赖当前连接的租户 Model 后假设队列仍有上下文。
功能 D:备份与恢复
优先评估 spatie/laravel-backup,但 db-per-tenant 需要额外编排:中央库和每个租户库分别生成备份、清单和校验和。
备份流程:
锁定备份任务
-> 备份中央库
-> 列出 active/suspended tenants
-> 逐租户 pg_dump
-> 备份对象存储/文件清单
-> 生成时间、版本、迁移批次、SHA256
-> 上传异地存储
-> 验证可读取
-> 记录结果和告警
恢复必须先在新数据库演练,不能直接覆盖生产:
选择 tenant_id 和备份
-> 校验哈希
-> 创建临时恢复数据库
-> pg_restore
-> 运行只读一致性检查
-> 暂停租户
-> 切换数据库映射
-> 健康检查
-> 恢复服务或回滚映射
所有命令执行时使用明确数据库名,不用通配符和未验证变量。
功能 E:生产配置
必须建立 .env.production.example,只列变量名和说明,不包含值:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com
APP_TIMEZONE=Asia/Shanghai
APP_EDITION=private-chat
DEPLOYMENT_MODE=private
TENANT_MODE=single
PRIVATE_TENANT_ID=
DB_CONNECTION=pgsql
REDIS_CLIENT=phpredis
QUEUE_CONNECTION=redis
CACHE_STORE=redis
SESSION_DRIVER=redis
MAIL_MAILER=smtp
生产执行:
php artisan optimize
部署新版本前维护模式、迁移和队列重启必须编排;不能在多个实例并发执行同一迁移。
功能 F:Nginx/API Gateway
网关职责:TLS、Host、真实 IP、请求大小、超时、限流、静态资源和反向代理。Laravel 仍负责认证、租户、权限和业务校验。
必须透传:
Host
Authorization
X-Request-ID
X-Forwarded-For
X-Forwarded-Proto
OAuth redirect URI 必须使用外部 HTTPS 地址并和 Client 注册完全一致。
功能 G:数据库发布
备份
-> 维护/兼容窗口
-> php artisan migrate --force
-> 模块中央迁移
-> 按租户批次迁移
-> 记录成功/失败租户
-> 健康检查
-> Horizon terminate 让进程加载新代码
大表修改使用向前兼容迁移:先加 nullable 字段和双写,再回填,再切读,最后后续版本清理旧字段。不要一次发布中直接改名并删除旧列。
功能 H:Edition 制品
制品必须包含:允许的 Platform/Modules、生产依赖锁文件、迁移、前端静态资源、部署模板、版本 manifest 和安装文档。
制品不能包含:
.env
OAuth 私钥
Client Secret
开发测试账号密码
storage/logs
测试 Token
Operator(Private Edition)
未购买 Modules
Git 历史
构建后在全新目录、空中央库和空租户库执行安装测试,不能只测试开发仓库。
功能 I:私有化安装
检查 PHP/PostgreSQL/Redis
-> 解压 Edition 制品
-> 创建中央数据库和账号
-> 配置 .env
-> composer install --no-dev
-> 生成 APP_KEY 和 Passport keys
-> 中央 migrate --force
-> 创建唯一租户
-> 租户 migrate
-> 初始化 owner/role/permission/module
-> 构建或部署前端
-> 配置 Nginx、Horizon 和定时任务
-> 健康检查
-> 登录验收
私有化客户拥有平台核心和购买模块,不包含 Cloud Operator。中央库仍然存在,用于身份、OAuth、唯一租户和中央权限,不代表客户获得多租户运营能力。
功能 J:回滚
代码回滚和数据库回滚分开。已经被新代码写入的新字段或新格式可能无法安全 migrate:rollback,因此优先采用向前修复和兼容发布。
发布记录必须包含:Edition、版本、commit、Composer/NPM lock hash、迁移清单、备份 ID、发布时间和操作者。
最终验收
- [ ] APP_DEBUG=false 不暴露堆栈和 SQL。
- [ ] HTTPS、代理 Header 和 OAuth 回调正确。
- [ ] Horizon 常驻、失败 Job 可查看和重试。
- [ ] 中央库和指定租户可以独立备份恢复。
- [ ] 日志有 request_id 且不含凭证。
- [ ] 租户数据库迁移可分批、重试和记录。
- [ ] Private 制品不含 Operator 和未购模块。
- [ ] 新环境能按安装文档从零部署。
- [ ] 升级和回滚演练通过。