Markdown 目录预览

共找到 30 个 Markdown 文档。

目录:F:\CodeWorkspace\project-pshang-app\docs\流程

00-流程总入口.md

PShang SaaS 代码级离线开发流程

本目录是 backend-laravel 的学习式实施手册。它不仅说明“写什么代码”,还必须保证开发顺序不会迫使后续功能反复修改早期代码。

一、唯一入口

开始任何功能前按顺序阅读:

  1. 01-工程宪法与分层规范.md
  2. 02-基础设施依赖与实施顺序.md
  3. 03-代码引导与学习验收规范.md
  4. 04-功能闭环代码模板.md

文件名已经按照真实学习和实施顺序排列,可以直接从 00 向下执行。阶段依赖和按需能力仍以 02-基础设施依赖与实施顺序.md 为准。

二、执行原则

  • 先固定架构与横切契约,再开发业务。
  • 必须前置能力先形成最小闭环,不要求提前建设所有高级运维功能。
  • 按需能力必须早于第一个依赖它的业务功能。
  • 每次只执行一个完整功能闭环,失败时暂停后续步骤并解决当前问题。
  • 当前代码、Migration、配置和测试高于文档中的历史描述。
  • 文档与代码冲突时先记录差异、确认方案,再同步修改。

三、阶段路线

阶段 内容 主要文档 类型
P00 工程宪法、分层、学习规则 01-04 必须前置
P01 环境、依赖、配置和时区 05 必须前置
P02 目录、分层和代码规范 01backend-laravel/AGENTS.md 必须前置
P03 API 响应、异常、验证、分页、文档 06 必须前置
P04 中央/租户数据、事务、迁移 07 必须前置
P05 Request ID、日志、审计、安全 08 必须前置最小闭环
P06 测试、Factory、Pint、CI 09 必须前置最小闭环
P07 Redis、限流、锁、幂等 10 登录前完成限流,其余按需
P08 Event、Queue、Notification 1112 邀请/异步任务前完成
P09 Identity 13-15 SaaS 核心
P10 Tenant 16 SaaS 核心
P11 Authorization 17-19 SaaS 核心
P12 中央用户与 Operator 2021 平台管理
P13 模块和按需平台能力 22-25 按需
P14 Chat、钉钉 2627 业务模块
P15 监控、备份、生产和私有化 2829 生产前完成

四、完整功能输出格式

状态与依赖
 -> 功能目的和边界
 -> 影响文件、表、缓存、队列和外部服务
 -> 完整文件或明确的局部修改
 -> Migration / Model / Request / Service / Resource / Controller
 -> Middleware / Policy / Permission / Route / Job / Listener
 -> 自动测试
 -> 单行 PowerShell 验证命令
 -> ApiPost 完整请求
 -> 成功与失败预期
 -> 数据库、Redis、队列、Token、日志和 API 文档验证
 -> 为什么这样设计
 -> 完成清单

某层不需要时,必须说明原因。禁止只给方法体、只给命令或省略引入。

五、学习模式

  1. 助手先检查当前代码、依赖、Migration、路由和测试。
  2. 助手一次性提供一个功能的完整闭环和设计解释。
  3. 学习者亲自创建、修改和执行。
  4. 命令全部使用一行 PowerShell,并注明工作目录。
  5. 学习者返回输出后,助手先验证当前步骤,不直接跳到下一功能。
  6. 语法、局部测试、全量测试、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/Platformapp/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 必须前置

任何正式业务功能开始前必须完成:

  1. 环境和依赖锁定。
  2. 工程目录、依赖方向和分层职责。
  3. 统一响应、错误码、异常和 FormRequest 基类。
  4. 中央/租户连接、Migration、Model 和事务规范。
  5. Request ID、日志脱敏和统一审计入口。
  6. 最小测试环境、Factory、Pint 和 CI 质量门禁。
  7. 认证、租户和权限中间件的执行顺序。

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-工程宪法与分层规范.mdbackend-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和调度.md12-通知中心邮件和站内信.md 邀请前完成最小闭环
P09 13-15 先登录安全,再邀请和 MFA
P10 16 Tenant 生命周期早于租户业务
P11 17-19 权限作用域必须早于角色分配
P12 2021 先中央用户,再 Operator 运营业务
P13 22-25 按模块依赖启用
P14 2627 业务模块
P15 2829 上线前完成

5. 依赖闸门

进入下一阶段前,必须满足:

  • 文档标记为“当前实现”的代码确实存在。
  • Migration 状态与文档一致。
  • 局部测试和全量测试通过。
  • Pint 检查通过。
  • 对应数据库、Redis、队列或日志副作用经过验证。
  • 当前阶段的关键失败路径有自动测试。

只有文档存在而代码、测试或配置未完成,不得把阶段标记为完成。

6. 当前项目纠偏顺序

当前项目已经先实现了一部分 Identity、Tenant、Authorization 和 Operator。为了避免继续返工,应暂停新增业务,按以下顺序补齐基线:

  1. 对齐 P02 分层和目录规则。
  2. 对齐 P03 ApiRequest、异常和分页契约。
  3. 对齐 P04 连接与事务规则。
  4. 对齐 P05 Request ID、统一审计和日志脱敏。
  5. 对齐 P06 测试环境和 CI。
  6. 对齐 P07/P08 已经被登录、邀请和通知使用的最小能力。
  7. 回归 Identity、Tenant、Authorization、Operator 全部测试。
  8. 基线通过后才继续新增功能。

03-代码引导与学习验收规范.md

代码引导与学习验收规范

1. 目的

解决文档代码只有方法体、缺少 use、不知道放在哪个文件、复制后无法运行的问题。所有流程文档必须使用“完整文件”或“局部修改”两种格式之一。

2. 完整文件格式

适用于新建文件。代码块前必须写绝对项目相对路径,并提供可以直接保存的完整内容:

文件:app/Platform/Example/Services/ExampleService.php
操作:新建完整文件

完整 PHP 文件必须包含:

  1. <?php
  2. 项目文件头。
  3. namespace
  4. 完整 use
  5. 类声明。
  6. 构造函数和公开方法注释。
  7. 完整方法体。

禁止使用 ...省略、未定义变量或没有来源的辅助方法代替正式代码。

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 必须依次提供:

  1. 功能目的和边界。
  2. 前置依赖与受影响文件。
  3. Migration/Model(需要时)。
  4. FormRequest。
  5. Service/Action。
  6. Resource。
  7. Controller。
  8. Middleware/Policy/Permission。
  9. Route 和路由注释。
  10. 自动测试。
  11. Scramble 文档检查。
  12. ApiPost 完整方法、URL、Header、Query、Path、Body。
  13. 成功、401、403、404、409/422 等预期。
  14. 数据库、Redis、队列、Token 和审计验证。

某层不需要时必须写明“不需要及原因”,不能直接跳过。

6. 命令格式

  • 所有用户执行的 PowerShell 命令必须单行。
  • 命令必须注明工作目录。
  • 不使用依赖 rg 的命令,除非先确认已安装;默认提供 PowerShell Get-ChildItem | Select-String 版本。
  • 数据库命令必须明确数据库名称,危险删除操作必须单独确认。
  • 测试顺序为语法检查、配置/路由检查、局部测试、全量测试、Pint。

7. 解释要求

每个步骤都必须回答:

  • 做什么?
  • 为什么现在做?
  • 为后续建立什么能力?
  • 如何证明成功?

代码解释必须说明 Request、Controller、Service、Model、表、连接、事务、事件和中间件之间的调用关系,不能只解释 PHP 语法。

8. 文档代码状态

每个功能顶部必须标记一种状态:

  • 已实现并验证:代码存在且自动测试通过。
  • 已实现待对齐:代码存在,但不完全符合当前工程宪法。
  • 下一步实施:已经核对当前版本和依赖,可进入学习执行。
  • 规划草案:尚未核对执行时的代码和包版本,禁止直接复制。

文档中的规划代码不能伪装成当前代码。进入该功能前必须重新检查仓库和 Composer 锁定版本。

9. 完成标准

  • [ ] 新文件代码可直接保存并通过 php -l
  • [ ] 局部修改列出了路径、位置、引入和替换范围。
  • [ ] Route、Request、Response 和权限契约完整。
  • [ ] 数据库和外部副作用可验证。
  • [ ] 失败路径有自动测试。
  • [ ] 全量测试和 Pint 通过。
  • [ ] 文档状态和执行计划已同步。

04-功能闭环代码模板.md

功能闭环代码模板

使用本模板前必须先阅读 01-工程宪法与分层规范.md02-基础设施依赖与实施顺序.md03-代码引导与学习验收规范.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_pgsqlpgsql。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

开发环境可以先用 logarray 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

当前项目已有 ErrorCodeApiResponse,但 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

迁移原则:

  1. 保留现有 activity_log 中央表和 CentralActivity 连接。
  2. 新建通用 CentralAuditService,只负责可靠写入公共审计字段。
  3. Identity、Operator、Tenancy 的业务 Service 在各自用例中决定事件名和业务属性。
  4. Controller 不直接调用 activity()
  5. 必须与业务写入原子成功的审计使用同一个中央事务。

文件: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=pgsqlDB_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(),必须在该测试的 finallytearDown() 中恢复。

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.phptests/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_tokenrefresh_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:管理员强制下线指定用户

当前代码状态:ForceLogoutRequestAuthService::revokeAllSessions() 和控制器方法已经有基础代码,但权限初始化、中央审计日志、租户边界和自动化测试仍需要按本节补齐后,才能标记为完成。

1. 功能目的和边界

平台管理员可以在账号被盗、员工离职或安全事件时撤销目标用户的全部 OAuth 会话,包括 Access Token 和 Refresh Token。

本功能分成两个边界,不允许混用:

  • 平台运营端:只能由具有 platform.users.force_logout 权限的平台管理员操作,可以处理平台用户的全部会话。
  • 租户管理端:后续单独实现,只能处理当前租户的成员,必须校验 tenant_memberships,不能通过全局用户 ID 越权操作其他租户成员。

本节先实现平台运营端。目标用户、操作人、权限和 Passport Token 都属于中央数据库;审计日志也必须写入中央数据库。

2. 影响范围

  • 数据表:oauth_access_tokensoauth_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 认证组中。新增引入:UserSessionControllerOperatorPermission

/**
 * 强制下线指定中央用户的全部登录会话。
 *
 * @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.phpAcceptInvitationRequest.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.phpapp/Operator/Http/Controllers/UserStatusController.php 完整文件
路由 routes/api.phproutes/operator.php 局部修改
自动测试 tests/Feature/Identitytests/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
登录安全事件 IdentityLoginEventIdentityLoginEventServiceIdentityLoginOccurred、两个 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.php
  • app/Platform/Identity/Services/IdentityLoginEventService.php
  • app/Platform/Identity/Events/IdentityLoginOccurred.php
  • app/Platform/Identity/Listeners/PersistIdentityLoginEvent.php
  • app/Platform/Identity/Listeners/RecordPassportAccessToken.php
  • app/Platform/Identity/Enums/AuthenticationMethod.php
  • app/Platform/Identity/Enums/LoginOutcome.php
  • app/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_idteam_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/finallyfinally 的意义是:即使某个租户初始化角色时报错,也必须恢复进入 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=platformtenant_id=NULL 的作用域。
  • demo 有一条 type=tenant 的作用域。
  • tenant-ownertenant-memberteam_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_idmodel_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 AuthorizationScopeServiceRolePermissionRegistrar
Resource app/Platform/Authorization/Http/Resources/RoleResource.php PermissionResourceJsonResource
Controller app/Platform/Authorization/Http/Controllers/RoleController.php RoleServiceApiResponse
成员角色 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,而不是字符串 demorole_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:organizationsexists: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/Modelsapp/Operator/Services 中央连接;Cloud-only
Operator API app/Operator/Httpapp/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/JobsEvents 显式 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/ContractsDataServices 不含钉钉专属字段
钉钉 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 必须完成:

  1. 使用官方当前授权端点和参数生成 URL。
  2. 使用统一 HTTP Client 设置连接超时、总超时、有限重试和 Request ID。
  3. 交换授权码时校验响应状态和必需字段。
  4. 将钉钉响应转换成稳定的 ExternalIdentityData,不让钉钉字段渗透 Identity Service。
  5. 日志只记录 provider、状态码和 Request ID,不记录 code、access_token 和 client_secret。
  6. 使用 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 和未购模块。
  • [ ] 新环境能按安装文档从零部署。
  • [ ] 升级和回滚演练通过。