适用范围:新 Laravel 项目、后台系统、多端统一后端、开放接口、运营管理系统、需要权限、审计、队列、定时任务、上线规范的中大型项目。
本文档定义默认工程规范。历史项目可在迁移期兼容,但新代码、Code Review、架构评审、接口设计、测试与上线检查,应以本文档为准。
Table of contents
Open Table of contents
- 1. 文档定位与基本原则
- 2. 架构总览与术语统一
- 3. 标准目录结构
- 4. 请求处理主链路规范
- 5. 输入校验与 Data 对象规范
- 6. Model、Eloquent 与数据库规范
- 7. 查询、筛选、排序、分页与导出规范
- 8. 统一响应、异常处理与业务错误码
- 9. 认证、权限与 API 安全
- 10. 状态枚举与状态机规范
- 11. 事务、并发、锁与幂等规范
- 12. 缓存与 Redis 规范
- 13. 队列、Job、Command 与 Scheduler 规范
- 14. 文件上传与存储规范
- 15. 日志、审计与观测规范
- 16. API 版本与兼容策略
- 17. 生产上线与运行规范
- 18. 测试规范与工程门禁
- 19. 命名与编码约束
- 20. 评审检查清单
- 21. 反模式与禁止事项
- 22. 推荐 Composer 包与使用边界
- 23. 附录使用说明与代码模板校验报告
- 附录 A. 统一响应与异常完整模板
- 附录 B. 输入校验、Data、Controller、Resource 模板
- 附录 C. Model、Enum、状态机模板
- 附录 D. 查询、分页、导出模板
- 附录 E. 请求日志与请求中心完整模板
- 附录 F. 业务操作审计完整模板
- 附录 G. SQL 执行审计模板
- 附录 H. Open API 签名与 Webhook 幂等模板
- 附录 I. 文件上传与存储模板
- 附录 J. 队列、Horizon、Scheduler 生产模板
- 附录 K. Nginx、PHP-FPM 与上线命令模板
- 附录 L. 测试与工程门禁模板
- 24. 最终结论
1. 文档定位与基本原则
1.1 文档目标
本规范用于统一 Laravel 项目的分层、目录、输入输出、认证权限、状态流转、事务并发、缓存、日志审计、队列任务、上线运行和工程门禁。
目标不是解释 Laravel 原理,而是定义团队默认怎么写、什么不能写、评审看什么、上线检查什么。
1.2 默认原则
- 先按端口 / 调用方 / 系统边界拆路由,再按业务模块组织代码。
- Controller 保持薄,只负责接收请求、调用用例、返回响应。
- Application Service 承接用例编排、事务边界和业务流程。
- Domain Service 承接跨用例复用的领域规则、状态机、金额计算、资格判断和算法。
- Model 是 Eloquent 实体表达层,不是业务流程层。
- FormRequest 负责请求校验与轻量预处理,Resource 负责输出映射。
- 统一响应、统一错误码、统一异常出口。
- 资金、库存、权限、状态流转、支付回调、Open API 写接口必须考虑事务、并发和幂等。
- 日志、请求追踪、业务审计、SQL 审计职责分离。
- 生产运行方案必须覆盖发布、回滚、队列、定时任务、备份、监控和健康检查。
1.3 代码示例保留策略
本规范不是纯原则文档。正文保留能解释主链路的最小关键代码;完整可复制的 Middleware、Migration、Service、配置模板和部署脚本统一放入附录。
阅读方式:
- 快速理解:阅读正文。
- 直接落地:按正文规则复制附录模板。
- Code Review:以正文规则为准,以附录模板作为默认实现参考。
2. 架构总览与术语统一
2.1 标准请求链路
Route
-> Middleware
-> FormRequest
-> Controller
-> Application Service
-> Domain Service / Model / Repository / Query Object
-> Event / Listener / Job
-> Resource / Transformer
-> ApiResponder
-> JsonResponse
2.2 术语
| 术语 | 定位 |
|---|---|
| Application Service | 用例编排层,负责一个业务动作的流程、事务、依赖协调、事件分发。 |
| Domain Service | 领域规则层,负责跨用例复用规则、状态机、金额计算、资格判断、算法。 |
| Repository | 可选的数据访问抽象,用于复杂查询、多数据源、读写隔离。普通 CRUD 不强制使用。 |
| Query Object | 专用查询类,适合复杂列表、导出、统计查询复用。 |
| Resource | API 输出映射层,负责字段、格式、状态、嵌套结构。 |
| ApiResponder | 顶层响应协议构建器,负责 code / message / data / meta / links / errors。 |
历史项目中若存在 Service / CoreService,新规范统一映射为:
旧 Service -> Application Service
旧 CoreService -> Domain Service
3. 标准目录结构
app/
├── Data/
│ └── {Domain}/
├── Domain/
│ └── {Domain}/
├── Enums/
│ └── {Domain}/
├── Exceptions/
├── Http/
│ ├── Controllers/
│ │ └── {Port}/{Module}/
│ ├── Middleware/
│ ├── Requests/
│ │ └── {Port}/{Module}/
│ └── Resources/
│ └── {Port}/{Module}/
├── Jobs/
│ └── {Domain}/
├── Models/
│ └── {Domain}/
├── Policies/
├── Repositories/
│ └── {Domain}/
├── Rules/
├── Services/
│ └── {Port}/{Module}/
├── Support/
│ ├── Cache/
│ ├── ErrorCodes/
│ └── Http/
└── Validation/
└── Schemas/
routes/
├── admin.php
├── store.php
├── client.php
├── open.php
├── webhook.php
└── shared.php
目录不为“显得架构完整”而强制创建。项目规模较小时允许简化,但职责边界必须保持清晰。
4. 请求处理主链路规范
4.1 本章定位
本章定义一次 API 请求从入口到响应的标准处理链路,重点约束每一层“做什么、不做什么、向下一层交付什么”。
标准链路:
Route
-> Middleware
-> FormRequest
-> Controller
-> Application Service
-> Domain Service / Model / Repository / Query Object
-> Event / Listener / Job
-> Resource
-> ApiResponder
-> JsonResponse
默认规则:
- Controller 只做 HTTP 编排,不写业务流程。
- Application Service 是业务用例入口,默认承接事务边界。
- Domain Service 只承接跨用例领域规则,不感知 HTTP。
- Resource 只做输出映射,不触发查询和业务副作用。
- ApiResponder 只负责顶层响应协议,不承载业务判断。
4.2 层间交付物
| 层 | 输入 | 输出 | 不允许 |
|---|---|---|---|
| Route | HTTP 请求 | 命中路由与中间件组 | 写业务逻辑 |
| Middleware | Request | 通过 / 拒绝 / 注入上下文 | 编排业务状态流转 |
| FormRequest | Request | validated() 数据 | 查询数据库做复杂业务判断 |
| Controller | FormRequest、Service | Resource + ApiResponder | 事务、复杂查询、复杂响应数组 |
| Application Service | Input Data / Query Data / Model ID | Model、DTO、Paginator、Collection | 返回 JsonResponse |
| Domain Service | 显式传入的领域对象和值 | 判断结果、计算结果、异常 | 读取 request/auth、控制事务 |
| Repository / Query Object | Query Data、上下文 | Builder、Collection、Paginator | 包装 HTTP 响应 |
| Resource | Model / DTO / Collection | 数组结构 | 触发查询、写入、复杂业务判断 |
| ApiResponder | Resource / array / null | JsonResponse | 处理业务规则 |
4.3 Route 规范
Route 负责定义接口入口、端口边界、认证策略、签名策略、权限策略和上下文选择规则。
推荐路由文件:
routes/admin.php
routes/store.php
routes/client.php
routes/open.php
routes/webhook.php
routes/shared.php
路由分组示例:
use App\Http\Controllers\Admin\Order\OrderController;
use Illuminate\Support\Facades\Route;
Route::prefix('admin')
->as('admin.')
->middleware(['api', 'auth:admin', 'request.context', 'request.log'])
->group(function (): void {
Route::prefix('orders')
->as('orders.')
->controller(OrderController::class)
->group(function (): void {
Route::get('/', 'index')->name('index');
Route::get('{order}', 'show')->name('show');
Route::post('{order}/cancel', 'cancel')->name('cancel');
});
});
规则:
- 不把后台、客户端、开放接口、Webhook 全部混写在
routes/api.php。 - 有权限校验、审计、菜单绑定需求的路由必须具备稳定 route name。
- 路由只声明入口与中间件,不写业务闭包。
- Webhook / Open API 必须独立路由文件或独立路由组,避免和内部端口共享安全策略。
- 路由参数模型绑定可以使用,但对象级权限仍必须在 Policy、FormRequest 或 Application Service 中校验。
禁止:
Route::post('/orders/{order}/cancel', function (Order $order) {
$order->update(['status' => 3]);
return response()->json(['ok' => true]);
});
4.4 Middleware 规范
Middleware 承接横切能力:认证、验签、权限、上下文选择、请求日志、限流、anti-abuse、request_id 注入。
允许:
- 校验 token、签名、权限、IP、限流规则。
- 注入
request_id、trace_id、租户、门店、语言、币种等上下文。 - 对非法请求提前返回统一错误响应。
不允许:
- 编排订单取消、退款审核、支付成功等业务动作。
- 修改核心业务状态。
- 写复杂查询与复杂业务分支。
- 把当前用户权限范围偷偷塞到全局 Scope。
建议拆分:
AssignRequestContextMiddleware -> 分配 request_id / trace_id
RequestLogMiddleware -> 请求摘要日志
RoutePermissionMiddleware -> 路由权限校验
ResolveTenantContextMiddleware -> 租户 / 门店上下文
VerifyOpenApiSignatureMiddleware -> Open API 验签
中间件顺序建议:
request.context
-> request.log
-> auth
-> signature / permission / tenant-context
-> controller
4.5 FormRequest 规范
FormRequest 是当前接口的输入校验与授权入口。
推荐目录:
app/Http/Requests/{Port}/{Module}/{Action}Request.php
职责:
authorize():当前用户是否允许发起这个请求。prepareForValidation():trim、默认值、类型归一、空字符串归一。rules():字段级合法性校验。messages()/attributes():按需提供错误信息。
示例:
final class CancelOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user('admin') !== null;
}
protected function prepareForValidation(): void
{
$this->merge([
'reason' => is_string($this->reason) ? trim($this->reason) : $this->reason,
]);
}
public function rules(): array
{
return [
'reason' => ['nullable', 'string', 'max:500'],
];
}
}
边界:
- 字段格式、长度、必填、枚举值属于 FormRequest。
- “订单当前状态是否可取消”属于 Application Service / Domain Service。
- “当前管理员是否有这个门店的数据权限”可在 Policy / FormRequest / Application Service 中做,但必须保持项目内一致。
4.6 Controller 规范
Controller 是 HTTP 编排层,只负责把请求接入主链路。
推荐写法:
final class OrderController extends BaseApiController
{
public function index(
ListOrderRequest $request,
ListOrderService $service,
): JsonResponse {
$query = OrderListQueryData::fromArray($request->validated());
$paginator = $service->handle($query, $request->user('admin'));
return $this->paginated(
data: OrderResource::collection($paginator->getCollection()),
paginator: $paginator,
);
}
public function cancel(
CancelOrderRequest $request,
Order $order,
CancelOrderService $service,
): JsonResponse {
$order = $service->handle(
order: $order,
reason: $request->validated('reason'),
actor: $request->user('admin'),
);
return $this->success(
data: new OrderResource($order),
message: 'Order cancelled successfully.',
);
}
}
Controller 可以做:
- 从 FormRequest 获取
validated()。 - 构造 Input Data / Query Data。
- 调用 Application Service。
- 包装 Resource。
- 调用
success()、created()、paginated()。
Controller 禁止:
DB::transaction()。- 复杂
where / join / orderBy查询。 - 状态流转判断。
- 直接调用外部接口。
- 直接写业务审计。
- 长期手写复杂 JSON 响应。
4.7 Application Service 规范
Application Service 是一个业务用例的主入口。
推荐目录:
app/Services/{Port}/{Module}/{Action}Service.php
命名示例:
CreateOrderService
CancelOrderService
ApproveRefundService
ListOrderService
ExportOrderService
职责:
- 编排一个业务动作。
- 控制事务边界。
- 调用 Domain Service / Repository / Query Object。
- 触发 Event、Job、业务操作审计。
- 返回业务结果,不返回 HTTP 响应。
示例:
final class CancelOrderService
{
public function __construct(
private readonly OrderStatusTransitionService $transitionService,
private readonly OperationAuditService $auditService,
) {
}
public function handle(Order $order, ?string $reason, AdminUser $actor): Order
{
return DB::transaction(function () use ($order, $reason, $actor) {
$order = Order::query()
->whereKey($order->id)
->lockForUpdate()
->firstOrFail();
$this->transitionService->ensureCanCancel($order);
$order->update([
'status' => OrderStatus::Canceled,
'cancel_reason' => $reason,
'canceled_at' => now(),
]);
$this->auditService->success(
subject: $order,
reason: $reason,
eventKey: 'order.cancelled',
eventLabel: '取消订单',
);
OrderCancelled::dispatch($order->id)->afterCommit();
return $order->refresh();
}, attempts: 3);
}
}
规则:
- 一个复杂业务动作优先一个 Service 类。
- 事务默认放在 Application Service。
- Service 返回 Model、DTO、Collection、Paginator、标量或空值。
- Service 不返回
JsonResponse。 - Service 不拼顶层响应协议。
- 跨用例复用规则下沉到 Domain Service。
4.8 Domain Service 规范
Domain Service 承接跨用例复用的领域规则。
适合放入:
- 状态机与状态流转规则。
- 金额、折扣、结算、库存算法。
- 资格判断。
- 时间区间规则。
- 第三方领域能力适配。
示例:
final class OrderStatusTransitionService
{
public function ensureCanCancel(Order $order): void
{
if (! in_array($order->status, [OrderStatus::PendingPay, OrderStatus::Paid], true)) {
throw ApiHttpException::fromError(
OrderError::STATUS_NOT_CANCELABLE,
errors: [
'order_id' => $order->id,
'current_status' => $order->status->value,
],
);
}
}
}
规则:
- 不依赖 Request、Response、Session。
- 不直接读取当前登录用户;必要上下文由调用方显式传入。
- 不控制事务提交与回滚。
- 不拼 API 响应结构。
- 不写 UI 逻辑。
4.9 Repository / Query Object 规范
Repository 是可选层,Query Object 是复杂查询的推荐落点。
引入 Repository 的条件:
- 一个领域对象背后有多数据源。
- 需要读写隔离。
- 需要隐藏持久化细节。
- 查询策略稳定复用且跨多个用例。
引入 Query Object 的条件:
- 列表筛选复杂。
- 列表与导出复用同一套条件。
- 需要封装排序、include、数据范围、统计字段。
普通 CRUD 不强制 Repository。
推荐:
final class OrderListQuery
{
public function build(OrderListQueryData $query, AdminUser $actor): Builder
{
return Order::query()
->whereIn('orders.store_id', $actor->accessibleStoreIds())
->when($query->status !== null, fn (Builder $builder) => $builder->where('orders.status', $query->status))
->when($query->keyword, fn (Builder $builder) => $this->applyKeyword($builder, $query->keyword));
}
}
4.10 Event / Listener / Job 规范
Event / Listener 用于解耦主流程后的副作用,Job 用于异步、重试、耗时任务。
适合放到 Event / Listener / Job:
- 邮件、短信、站内信、推送。
- ERP / CRM / 第三方系统同步。
- 缓存刷新。
- 异步统计。
- 文件处理。
不适合隐藏到 Event:
- 主流程强依赖步骤。
- 必须按固定顺序执行的核心业务动作。
- 失败后必须阻断当前请求的逻辑。
事务后副作用必须使用 afterCommit() 或事务提交后的调度方式:
SyncOrderToErpJob::dispatch($order->id)->afterCommit();
4.11 Resource 与 Response 边界
Resource 负责“业务数据怎么映射成 JSON 字段”。
ApiResponder 负责“顶层响应协议怎么输出”。
Application Service -> Model / DTO / Paginator
Controller -> Resource
ApiResponder -> code / message / data / meta / links
禁止:
- Application Service 返回 Resource。
- Application Service 返回 JsonResponse。
- Resource 触发数据库查询。
- Resource 拼
code / message顶层响应壳。
4.12 本章检查清单
Code Review 时必须确认:
- 路由是否按端口拆分,后台路由是否具备稳定 route name。
- Middleware 是否只处理横切能力。
- 写接口和复杂列表是否使用 FormRequest。
- Controller 是否保持薄,是否没有事务和复杂查询。
- Application Service 是否是业务动作主入口。
- Domain Service 是否不依赖 HTTP 上下文。
- Resource 是否没有触发数据库查询。
- 事务后副作用是否使用
afterCommit()。
5. 输入校验与 Data 对象规范
5.1 本章定位
本章定义请求输入从 HTTP 参数进入应用层之前的处理方式。
目标:
- 字段校验集中在 FormRequest。
- 字段规则复用沉淀到 Schema / Ruleset。
- 复杂单字段规则使用 Rule Object。
- Application Service 接收明确结构,不长期接收大体量裸数组。
5.2 校验三层职责
| 层 | 职责 | 示例 |
|---|---|---|
| Rule Object | 复杂单字段规则 | ValidMobileRule、StrongPasswordRule |
| Schema / Ruleset | 字段规则复用、场景基线 | UserSchema::email() |
| FormRequest | 当前接口入口校验、授权、轻量预处理 | CreateUserRequest |
业务流程合法性不属于校验层。
示例边界:
| 判断 | 所属层 |
|---|---|
email 是否是合法邮箱 | FormRequest / Schema |
status 是否是合法枚举值 | FormRequest |
| 邮箱是否已被使用 | Domain Service / Application Service |
| 订单是否允许取消 | Domain Service / Application Service |
| 当前管理员是否能操作该订单 | Policy / FormRequest authorize / Application Service |
5.3 FormRequest 标准写法
final class CreateUserRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user('admin')?->can('admin.users.create') === true;
}
protected function prepareForValidation(): void
{
$this->merge([
'nickname' => is_string($this->nickname) ? trim($this->nickname) : $this->nickname,
'email' => is_string($this->email) ? strtolower(trim($this->email)) : $this->email,
]);
}
public function rules(): array
{
return [
'nickname' => UserSchema::nickname(),
'email' => UserSchema::email(),
'password' => UserSchema::password(),
];
}
}
规则:
- 所有写接口必须使用 FormRequest。
- 列表接口必须使用 FormRequest 校验筛选、排序、分页参数。
authorize()不应长期直接写复杂业务流程。prepareForValidation()只做轻量归一,不写数据库查询。rules()不写业务状态流转判断。
5.4 Schema / Ruleset 规范
Schema 用于复用实体字段规则,避免字段长度、格式、枚举规则散落在多个 Request。
推荐目录:
app/Validation/Schemas/{Domain}/{Entity}Schema.php
示例:
final class UserSchema
{
public static function nickname(bool $required = true): array
{
return [
$required ? 'required' : 'nullable',
'string',
'min:2',
'max:30',
];
}
public static function email(bool $required = true): array
{
return [
$required ? 'required' : 'nullable',
'email:rfc,dns',
'max:64',
];
}
public static function password(bool $required = true): array
{
return [
$required ? 'required' : 'nullable',
'string',
'min:8',
'max:64',
];
}
}
规则:
- Schema 只返回规则数组。
- Schema 不读取 Request。
- Schema 不查询数据库。
- Schema 不承载业务流程合法性。
5.5 Rule Object 规范
复杂单字段校验使用 Rule Object。
适用场景:
- 规则复杂,多个 Request 复用。
- 需要封装正则、算法、格式校验。
- 需要清晰错误消息。
推荐目录:
app/Rules/{Name}Rule.php
不建议把大段复杂正则和判断直接塞进 FormRequest。
5.6 Data 对象定位
Data 用于承接应用层输入或输出结构。
Data 不替代:
- FormRequest。
- Resource。
- Model。
- DTO 之外的业务服务。
三类 Data:
| 类型 | 用途 |
|---|---|
| Input Data | 承接 validated() 后的写入输入,传给 Application Service。 |
| Query Data | 承接列表、导出、统计查询条件。 |
| Output Data | 承接复杂聚合结果,交给 Resource。 |
推荐目录:
app/Data/{Domain}/{Name}Data.php
5.7 Input Data 示例
final class CreateUserData
{
public function __construct(
public readonly string $nickname,
public readonly string $email,
public readonly string $password,
) {
}
public static function fromArray(array $data): self
{
return new self(
nickname: $data['nickname'],
email: $data['email'],
password: $data['password'],
);
}
}
规则:
- Data 不依赖 Request。
- Data 不查询数据库。
- Data 不写业务逻辑。
- 可在
fromArray()中做安全的类型转换,但不做业务判断。
5.8 Query Data 示例
final class OrderListQueryData
{
public function __construct(
public readonly ?string $keyword = null,
public readonly ?int $status = null,
public readonly ?string $createdFrom = null,
public readonly ?string $createdTo = null,
public readonly string $sort = 'id',
public readonly string $order = 'desc',
public readonly int $page = 1,
public readonly int $pageSize = 20,
) {
}
public static function fromArray(array $data): self
{
return new self(
keyword: $data['keyword'] ?? null,
status: isset($data['status']) ? (int) $data['status'] : null,
createdFrom: $data['created_from'] ?? null,
createdTo: $data['created_to'] ?? null,
sort: $data['sort'] ?? 'id',
order: $data['order'] ?? 'desc',
page: (int) ($data['page'] ?? 1),
pageSize: (int) ($data['page_size'] ?? 20),
);
}
}
5.9 Output Data 示例
当一个接口返回多个区块、统计汇总、权限布尔值等复合结果时,使用 Output Data。
final class OrderDetailPageData
{
public function __construct(
public readonly Order $order,
public readonly bool $canCancel,
public readonly bool $canApplyRefund,
public readonly array $timeline,
) {
}
}
Resource 消费 Output Data:
final class OrderDetailPageResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'order' => new OrderResource($this->resource->order),
'permissions' => [
'can_cancel' => $this->resource->canCancel,
'can_apply_refund' => $this->resource->canApplyRefund,
],
'timeline' => $this->resource->timeline,
];
}
}
5.10 本章检查清单
- 写接口是否使用 FormRequest。
- 列表接口是否校验分页、筛选、排序。
- 字段规则是否沉淀到 Schema / Ruleset。
- 复杂单字段规则是否使用 Rule Object。
- Service 入参数量较多时是否使用 Input Data / Query Data。
- Data 是否没有依赖 Request、数据库、Response。
- 业务流程合法性是否没有塞进 rules。
6. Model、Eloquent 与数据库规范
6.1 本章定位
本章定义 Model、Eloquent、Migration、数据库字段与索引的默认写法。
核心原则:
Model 是 Eloquent 实体表达层,不是业务流程层。
数据库是核心事实来源,不把一致性寄托在前端、缓存或临时代码判断上。
6.2 Model 职责边界
Model 负责:
- 表映射。
- 字段白名单。
- 字段类型转换。
- 关联关系。
- 简单 Scope。
- 简单状态判断。
- 简单实体辅助方法。
Model 不负责:
- HTTP Request / Response。
- Controller 流程。
- 事务编排。
- 跨模型业务流程。
- 外部接口调用。
- 业务操作审计。
- 复杂状态流转。
- 当前用户权限上下文判断。
允许:
$order->isPaid();
$order->isCancelable();
$order->user();
Order::query()->paid();
禁止:
$order->approveRefundAndNotifyUser();
$order->payWithBalanceAndCreateTransaction();
$order->cancelAndRestoreStockAndWriteAudit();
6.3 Model 目录与文件顺序
推荐目录:
app/Models/{Domain}/{ModelName}.php
示例:
app/Models/Order/Order.php
app/Models/Order/OrderItem.php
app/Models/Refund/RefundOrder.php
app/Models/Permission/UiNode.php
Model 内部顺序:
1. $table / $connection / $primaryKey
2. $fillable
3. $hidden
4. casts()
5. 关联关系
6. Scope
7. 简单状态判断
8. 简单实体辅助方法
6.4 $fillable 与写入字段规范
新项目默认使用 $fillable 白名单。
protected $fillable = [
'user_id',
'order_no',
'status',
'total_amount',
'paid_at',
];
规则:
- Controller / Service 不得直接使用
$request->all()创建或更新模型。 - 写入字段必须来自
validated()、Input Data 或 Service 中显式组装的数组。 - 系统字段、权限字段、金额字段、审核字段、状态流转字段不得随意加入
$fillable。
敏感字段示例:
id
created_by
updated_by
deleted_by
is_admin
balance
paid_at
approved_at
role_id
permission_version
禁止:
User::query()->create($request->all());
推荐:
User::query()->create([
'nickname' => $data->nickname,
'email' => $data->email,
'status' => UserStatus::Enabled,
]);
6.5 casts 规范
Laravel 11 新代码推荐使用 casts() 方法。
protected function casts(): array
{
return [
'status' => OrderStatus::class,
'is_paid' => 'boolean',
'snapshot' => 'array',
'paid_at' => 'datetime',
'total_amount' => 'integer',
'settings' => 'array',
];
}
必须显式 cast 的字段:
| 字段类型 | 推荐 cast |
|---|---|
| boolean | boolean |
| JSON | array / collection / 自定义 Cast |
| 时间 | datetime / immutable_datetime |
| 金额、数量、排序 | integer |
| 小数 | decimal:2,注意返回值可能是字符串 |
| 状态 | PHP backed enum |
| 加密字段 | encrypted / encrypted:array |
| 密码 | hashed |
禁止长期手工转换:
$isPaid = (int) $order->is_paid === 1;
$snapshot = json_decode($order->snapshot, true);
$paidAt = Carbon::parse($order->paid_at);
6.6 Enum 状态字段规范
核心业务状态字段推荐使用 int backed enum。
enum OrderStatus: int
{
case PendingPay = 1;
case Paid = 2;
case Canceled = 3;
case Finished = 4;
public function key(): string
{
return match ($this) {
self::PendingPay => 'pending_pay',
self::Paid => 'paid',
self::Canceled => 'canceled',
self::Finished => 'finished',
};
}
public function label(): string
{
return match ($this) {
self::PendingPay => '待支付',
self::Paid => '已支付',
self::Canceled => '已取消',
self::Finished => '已完成',
};
}
}
规则:
- 数据库存储稳定数字值。
- 业务代码使用 Enum case。
value是数据库值和机器判断值。key()是稳定英文标识。label()是展示文案,不用于条件判断。- 二值开关字段使用 boolean cast,不强制 Enum。
不推荐:
enum OrderStatus: string
{
case Paid = '已支付';
}
6.7 关系方法规范
关系方法必须声明返回类型。
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
关系方法可以包含简单稳定条件:
public function enabledItems(): HasMany
{
return $this->hasMany(OrderItem::class)
->where('is_enabled', true);
}
禁止在关系方法中依赖当前登录用户:
public function visibleItemsForCurrentAdmin(): HasMany
{
return $this->hasMany(OrderItem::class)
->whereIn('store_id', auth('admin')->user()->store_ids);
}
6.8 Scope 规范
Scope 只放单一、稳定、可复用、无 HTTP 感知的查询条件。
public function scopePaid(Builder $query): Builder
{
return $query->where('status', OrderStatus::Paid->value);
}
public function scopeOfStore(Builder $query, int $storeId): Builder
{
return $query->where('store_id', $storeId);
}
不推荐把完整后台列表查询塞进 Model:
public function scopeAdminOrderList(Builder $query, array $filters, AdminUser $user): Builder
{
// 关键词、状态、时间、门店、权限、排序、关联、统计全部写这里
}
复杂列表查询应放到 Application Service、Query Object 或 Repository。
6.9 Accessor / Mutator 规范
Accessor / Mutator 只用于简单字段转换。
protected function nickname(): Attribute
{
return Attribute::make(
set: fn (?string $value) => is_string($value) ? trim($value) : $value,
);
}
禁止:
- 查询数据库。
- 调外部接口。
- 依赖当前登录用户。
- 写日志。
- 修改其他模型。
- 承载复杂业务规则。
展示型字段优先交给 Resource,不建议大量塞入 $appends。
6.10 Observer、软删除与全局 Scope
Observer 只适合低业务语义、稳定、通用的生命周期逻辑。
可以放 Observer:
- 自动生成 UUID / 编号。
- 自动填充
created_by、updated_by。 - 删除附件后的低风险清理任务。
- 清理模型相关缓存。
不建议放 Observer:
- 订单支付成功后发货。
- 退款审核通过后打款。
- 权限变更后通知用户。
- 商品删除后同步第三方系统。
SoftDeletes 不默认全表使用:
| 场景 | 建议 |
|---|---|
| 用户、商品、客户、订单类主数据 | 可以 |
| 日志、流水、审计记录 | 通常不用 |
| 中间表、绑定表 | 按是否需要恢复决定 |
| 临时表、缓存表 | 不用 |
全局 Scope 必须谨慎。不得用全局 Scope 做后台权限过滤、当前用户过滤或复杂业务状态隐藏。
6.11 Migration 与字段规范
默认规则:
- 主键默认使用
$table->id()。 - 金额以整数存储,单位为分。
- 状态字段使用
unsignedTinyInteger或unsignedSmallInteger,并按查询需要建索引。 - 业务唯一约束必须落数据库唯一索引。
- 时间字段使用明确业务命名,如
paid_at、approved_at、cancelled_at。 - JSON 字段用于扩展快照、配置、低频查询数据,不作为高频筛选主条件。
- 字段注释按团队规范使用,不替代代码中的 Enum / 文档。
示例:
Schema::create('orders', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->index();
$table->string('order_no', 64)->unique();
$table->unsignedTinyInteger('status')->default(OrderStatus::PendingPay->value)->index();
$table->unsignedBigInteger('total_amount')->default(0);
$table->timestamp('paid_at')->nullable()->index();
$table->json('snapshot')->nullable();
$table->timestamps();
$table->index(['user_id', 'status']);
});
6.12 索引与唯一约束
必须使用唯一索引兜底的场景:
- 订单号。
- 支付流水号。
- 第三方回调事件 ID。
- Open API 调用方请求号。
- 用户领取优惠券记录。
- 幂等键。
示例:
$table->unique(['provider', 'event_id']);
$table->unique(['scope', 'idempotency_key']);
$table->unique(['user_id', 'coupon_id', 'source_id']);
规则:
- 代码层
exists()只能作为提前提示,不是唯一性最终保障。 - 高风险唯一业务约束必须由数据库保证。
- 捕获唯一冲突后应转换为稳定业务异常或幂等成功结果。
6.13 本章检查清单
- Model 是否没有依赖
request()、auth()、response()。 - Model 是否没有编排跨模型业务流程。
- 是否使用
$fillable白名单。 - 是否显式声明
casts()。 - 核心状态字段是否使用 int backed enum。
- 关系方法是否声明返回类型。
- Scope 是否保持简单稳定。
- 复杂列表查询是否没有塞进 Model。
- 强唯一业务约束是否有数据库唯一索引。
- 金额是否使用整数存储。
7. 查询、筛选、排序、分页与导出规范
7.1 本章定位
本章约束列表页、搜索页、导出、统计查询的统一写法。
核心目标:
- Controller 不写复杂查询。
- 查询参数必须校验。
- 排序、include、数据范围必须白名单控制。
- Resource 不触发数据库查询。
- 列表和导出复用同一套查询条件。
标准链路:
FormRequest
-> Query Data
-> ListXxxService / ExportXxxService
-> Query Object / Repository / Builder
-> Paginator / Collection / cursor
-> Resource / Export Writer
7.2 查询参数命名规范
| 参数 | 说明 |
|---|---|
page | 当前页,默认 1 |
page_size | 每页数量,默认 20,普通后台最大 100 |
keyword | 关键词,必须 trim 并限制长度 |
sort | 排序字段,只能使用白名单映射 |
order | 排序方向,只允许 asc / desc |
{field}_from | 时间 / 数值范围起点 |
{field}_to | 时间 / 数值范围终点 |
include | 可选关联加载,必须白名单 |
7.3 列表 FormRequest 示例
final class ListOrderRequest extends FormRequest
{
protected function prepareForValidation(): void
{
$this->merge([
'keyword' => is_string($this->keyword) ? trim($this->keyword) : $this->keyword,
'page' => $this->input('page', 1),
'page_size' => $this->input('page_size', 20),
'order' => strtolower((string) $this->input('order', 'desc')),
]);
}
public function rules(): array
{
return [
'keyword' => ['nullable', 'string', 'max:100'],
'status' => ['nullable', 'integer', Rule::in(array_column(OrderStatus::cases(), 'value'))],
'store_id' => ['nullable', 'integer', 'min:1'],
'created_from' => ['nullable', 'date'],
'created_to' => ['nullable', 'date', 'after_or_equal:created_from'],
'sort' => ['nullable', 'string', 'max:50'],
'order' => ['nullable', Rule::in(['asc', 'desc'])],
'include' => ['nullable', 'string', 'max:255'],
'page' => ['nullable', 'integer', 'min:1'],
'page_size' => ['nullable', 'integer', 'min:1', 'max:100'],
];
}
}
规则:
- 所有列表接口必须校验分页、筛选、排序参数。
page_size必须有上限。- 前端传入字段不得直接进入
where、orderBy、with。
7.4 Query Data 与 Query Object
查询条件较多、需要导出复用、涉及数据范围过滤时,必须使用 Query Data + Query Object。
final class OrderListQuery
{
public function build(OrderListQueryData $query, AdminUser $actor): Builder
{
$builder = Order::query()
->select([
'orders.id',
'orders.order_no',
'orders.user_id',
'orders.status',
'orders.total_amount',
'orders.created_at',
])
->whereIn('orders.store_id', $actor->accessibleStoreIds());
$this->applyKeyword($builder, $query);
$this->applyStatus($builder, $query);
$this->applyCreatedRange($builder, $query);
$this->applyIncludes($builder, $query);
$this->applySort($builder, $query);
return $builder;
}
}
7.5 排序白名单
禁止:
$builder->orderBy($request->input('sort'), $request->input('order'));
推荐:
private function applySort(Builder $builder, OrderListQueryData $query): void
{
$sortMap = [
'id' => 'orders.id',
'created_at' => 'orders.created_at',
'paid_at' => 'orders.paid_at',
'total_amount' => 'orders.total_amount',
];
$column = $sortMap[$query->sort] ?? 'orders.id';
$direction = $query->order === 'asc' ? 'asc' : 'desc';
$builder->orderBy($column, $direction);
if ($column !== 'orders.id') {
$builder->orderByDesc('orders.id');
}
}
规则:
sort必须通过白名单映射为真实数据库字段。- 涉及 join 的排序字段必须显式带表名。
- 默认排序必须稳定,推荐
id desc或created_at desc + id desc。
7.6 keyword 搜索规范
private function escapeLike(string $value): string
{
return addcslashes($value, '%_\\');
}
private function applyKeyword(Builder $builder, OrderListQueryData $query): void
{
if (! $query->keyword) {
return;
}
$keyword = $this->escapeLike($query->keyword);
$builder->where(function (Builder $builder) use ($keyword): void {
$builder->where('orders.order_no', 'like', '%' . $keyword . '%')
->orWhere('orders.receiver_phone', 'like', '%' . $keyword . '%')
->orWhereHas('user', function (Builder $builder) use ($keyword): void {
$builder->where('nickname', 'like', '%' . $keyword . '%');
});
});
}
规则:
keyword必须 trim 并限制长度。LIKE查询建议转义%、_、\。- 多字段 OR 必须用
where(function)包裹。 - 高频复杂搜索应迁移到专门搜索方案,不应无限叠加 LIKE。
7.7 时间范围规范
日期型参数必须明确边界:
created_from = 2026-04-01 -> 2026-04-01 00:00:00
created_to = 2026-04-29 -> 2026-04-29 23:59:59
示例:
$createdFrom = $query->createdFrom
? CarbonImmutable::parse($query->createdFrom)->startOfDay()
: null;
$createdTo = $query->createdTo
? CarbonImmutable::parse($query->createdTo)->endOfDay()
: null;
$builder
->when($createdFrom, fn (Builder $builder) => $builder->where('orders.created_at', '>=', $createdFrom))
->when($createdTo, fn (Builder $builder) => $builder->where('orders.created_at', '<=', $createdTo));
规则:
- 日期型参数扩展到当天开始和结束。
- 完整时间型参数尊重前端传入时间。
- 涉及时区时,接口合同必须明确前端传入时区和后端存储时区。
7.8 数据范围过滤
数据范围必须在 Query Builder 阶段完成。
推荐:
Order::query()
->whereIn('orders.store_id', $actor->accessibleStoreIds())
->latest('orders.id')
->paginate($query->pageSize);
禁止:
- 先查全量,再在 PHP 内存中过滤。
- 先返回给前端,再让前端隐藏越权数据。
- 只在详情和更新接口做权限,列表接口不做数据范围。
7.9 N+1 与 select 字段
Resource 不得触发数据库查询。
不推荐:
return [
'user_name' => $this->user->nickname,
'item_count' => $this->items()->count(),
];
推荐查询阶段处理:
$builder = Order::query()
->select([
'orders.id',
'orders.order_no',
'orders.user_id',
'orders.status',
'orders.total_amount',
'orders.created_at',
])
->with(['user:id,nickname'])
->withCount('items');
Resource:
return [
'id' => $this->id,
'order_no' => $this->order_no,
'user_name' => $this->user?->nickname,
'item_count' => $this->items_count,
];
7.10 include 白名单
支持 include=user,items,payment 时,必须白名单映射。
private function applyIncludes(Builder $builder, OrderListQueryData $query): void
{
$allowedIncludes = [
'user' => 'user:id,nickname',
'items' => 'items:id,order_id,sku_name,quantity',
'payment' => 'payment:id,order_id,pay_no,amount',
];
$includes = collect(explode(',', $query->include ?? ''))
->map(fn (string $item) => trim($item))
->filter()
->intersect(array_keys($allowedIncludes))
->map(fn (string $item) => $allowedIncludes[$item])
->values()
->all();
$builder->with($includes);
}
禁止:
$builder->with(explode(',', $request->input('include')));
7.11 列表与导出复用
列表和导出应复用同一个 Query Data 和 Query Object。
OrderListQueryData
-> OrderListQuery::build()
-> ListOrderService
-> ExportOrderService
列表:
$builder = $this->orderListQuery->build($query, $actor);
return $builder->paginate(
perPage: $query->pageSize,
page: $query->page,
);
导出:
$builder = $this->orderListQuery->build($query, $actor);
$builder->chunkById(500, function (Collection $orders): void {
// 写入 Excel / CSV
});
规则:
- 导出不通过
paginate()循环全部页实现。 - 大数据导出必须使用
chunkById、cursor或队列任务。 - 导出仍必须执行权限校验、数据范围过滤、请求日志和必要业务审计。
- 导出字段可以和列表不同,但筛选条件和数据范围应共用。
7.12 本章检查清单
- Controller 是否没有复杂查询。
- 列表参数是否全部经过 FormRequest 校验。
page_size是否有上限。sort、include是否白名单。- keyword 是否 trim、限长、LIKE 转义。
- 数据范围是否在 Query Builder 阶段应用。
- Resource 是否没有触发查询。
- 列表和导出是否复用查询条件。
- 导出是否没有使用 paginate 循环全量数据。
8. 统一响应、异常处理与业务错误码
8.1 本章定位
本章定义 API 成功响应、失败响应、分页响应、异常渲染和业务错误码的统一协议。
核心目标:
- 前端永远收到稳定 JSON 协议。
- 成功和失败出口复用同一个 ApiResponder。
- HTTP 状态码和业务错误码分层。
- 错误码、错误标识、错误文案有唯一事实来源。
8.2 成功响应结构
{
"code": 0,
"message": "success",
"data": {},
"meta": {},
"links": {}
}
字段职责:
| 字段 | 职责 |
|---|---|
code | 业务状态码,成功默认为 0 |
message | 人类可读消息 |
data | 业务数据主体 |
meta | 元信息,如 request_id、server_time、pagination、warnings |
links | 分页或导航链接,按需使用 |
规则:
data不混入分页元信息。meta承载扩展元信息。links无需求时可为空对象或省略,但项目内必须统一。
8.3 错误响应结构
{
"code": 120101,
"error_key": "ORDER_STATUS_NOT_CANCELABLE",
"message": "Order status does not allow cancellation.",
"errors": {
"order_id": 123,
"current_status": 4
},
"meta": {}
}
字段职责:
| 字段 | 职责 |
|---|---|
code | 数字型业务错误码,全局唯一 |
error_key | 稳定字符串错误标识,便于联调、日志检索、告警聚合 |
message | 人类可读消息,可展示,可本地化 |
errors | 字段错误或结构化错误详情 |
meta | 扩展信息 |
规则:
- 前端不得基于
message做业务分支判断。 - 程序判断优先使用
code或error_key。 error_key使用全大写蛇形命名。- HTTP 状态码表达协议层结果,业务错误码表达业务语义。
8.4 Resource、Controller、ApiResponder 边界
Application Service -> 返回业务结果
Resource -> 映射 data 内容
ApiResponder -> 包装顶层响应协议
Exception Handler -> 复用 ApiResponder 输出错误
禁止:
- Application Service 返回
JsonResponse。 - Resource 拼
code / message / meta / links顶层协议。 - Controller 长期手写复杂响应数组。
- 异常渲染绕过 ApiResponder。
8.5 ApiResponder 标准模板
final class ApiResponder
{
public function success(
mixed $data = null,
string $message = 'success',
int $code = 0,
array $meta = [],
array $links = [],
int $status = 200,
): JsonResponse {
return response()->json([
'code' => $code,
'message' => $message,
'data' => $this->normalizeData($data),
'meta' => $meta,
'links' => $links,
], $status);
}
public function created(
mixed $data = null,
string $message = 'created',
int $code = 0,
array $meta = [],
array $links = [],
): JsonResponse {
return $this->success(
data: $data,
message: $message,
code: $code,
meta: $meta,
links: $links,
status: 201,
);
}
public function paginated(
mixed $data,
LengthAwarePaginator|Paginator $paginator,
string $message = 'success',
int $code = 0,
array $meta = [],
array $links = [],
int $status = 200,
): JsonResponse {
$meta['pagination'] = array_filter([
'page' => method_exists($paginator, 'currentPage') ? $paginator->currentPage() : null,
'page_size' => method_exists($paginator, 'perPage') ? $paginator->perPage() : null,
'total' => method_exists($paginator, 'total') ? $paginator->total() : null,
'has_more' => method_exists($paginator, 'hasMorePages') ? $paginator->hasMorePages() : null,
], static fn (mixed $value) => $value !== null);
$links = array_merge([
'next' => method_exists($paginator, 'nextPageUrl') ? $paginator->nextPageUrl() : null,
'prev' => method_exists($paginator, 'previousPageUrl') ? $paginator->previousPageUrl() : null,
], $links);
return $this->success(
data: $data,
message: $message,
code: $code,
meta: $meta,
links: $links,
status: $status,
);
}
public function error(
string $message = 'error',
int $code = 1,
?string $errorKey = null,
array $errors = [],
array $meta = [],
int $status = 400,
): JsonResponse {
return response()->json([
'code' => $code,
'error_key' => $errorKey,
'message' => $message,
'errors' => $errors,
'meta' => $meta,
], $status);
}
private function normalizeData(mixed $data): mixed
{
if ($data instanceof JsonResource || $data instanceof AnonymousResourceCollection) {
return $data->resolve(request());
}
return $data;
}
}
8.6 BaseApiController 标准模板
abstract class BaseApiController extends Controller
{
protected function success(
mixed $data = null,
string $message = 'success',
int $code = 0,
array $meta = [],
array $links = [],
int $status = 200,
): JsonResponse {
return app(ApiResponder::class)->success($data, $message, $code, $meta, $links, $status);
}
protected function created(
mixed $data = null,
string $message = 'created',
int $code = 0,
array $meta = [],
array $links = [],
): JsonResponse {
return app(ApiResponder::class)->created($data, $message, $code, $meta, $links);
}
protected function paginated(
mixed $data,
LengthAwarePaginator|Paginator $paginator,
string $message = 'success',
int $code = 0,
array $meta = [],
array $links = [],
int $status = 200,
): JsonResponse {
return app(ApiResponder::class)->paginated($data, $paginator, $message, $code, $meta, $links, $status);
}
}
8.7 分页响应规范
分页响应:
{
"code": 0,
"message": "success",
"data": [{ "id": 1, "name": "A" }],
"meta": {
"pagination": {
"page": 1,
"page_size": 20,
"total": 100,
"has_more": true
}
},
"links": {
"next": "https://example.com/orders?page=2",
"prev": null
}
}
规则:
- 当前页数据放
data。 - 分页信息统一放
meta.pagination。 - 不直接嵌入 Laravel 默认分页结构,避免响应合同不稳定。
8.8 业务错误码体系
业务错误码必须全局唯一。
推荐目录:
app/Support/ErrorCodes/
├── Contracts/BusinessError.php
├── Common/CommonError.php
├── Auth/AuthError.php
├── Permission/PermissionError.php
├── Order/OrderError.php
└── Coupon/CouponError.php
接口:
interface BusinessError
{
public function code(): int;
public function key(): string;
public function httpStatus(): int;
public function defaultMessage(): string;
}
示例:
enum OrderError: int implements BusinessError
{
case STATUS_NOT_CANCELABLE = 120101;
case ALREADY_PAID = 120102;
case NOT_FOUND = 120103;
public function code(): int
{
return $this->value;
}
public function key(): string
{
return match ($this) {
self::STATUS_NOT_CANCELABLE => 'ORDER_STATUS_NOT_CANCELABLE',
self::ALREADY_PAID => 'ORDER_ALREADY_PAID',
self::NOT_FOUND => 'ORDER_NOT_FOUND',
};
}
public function httpStatus(): int
{
return match ($this) {
self::STATUS_NOT_CANCELABLE => 422,
self::ALREADY_PAID => 409,
self::NOT_FOUND => 404,
};
}
public function defaultMessage(): string
{
return match ($this) {
self::STATUS_NOT_CANCELABLE => 'Order status does not allow cancellation.',
self::ALREADY_PAID => 'Order has already been paid.',
self::NOT_FOUND => 'Order not found.',
};
}
}
规则:
- 不在业务代码中手写裸数字错误码。
- 不让前端依赖
message做判断。 - 不复用旧错误码表达新语义。
- 新增错误码必须同步文档和测试。
8.9 ApiHttpException 标准模板
final class ApiHttpException extends Exception
{
public function __construct(
private readonly BusinessError $businessError,
private readonly ?string $apiMessage = null,
private readonly array $errors = [],
?Throwable $previous = null,
) {
parent::__construct($apiMessage ?? $businessError->defaultMessage(), 0, $previous);
}
public static function fromError(
BusinessError $businessError,
array $errors = [],
?string $message = null,
?Throwable $previous = null,
): self {
return new self($businessError, $message, $errors, $previous);
}
public function getBusinessCode(): int
{
return $this->businessError->code();
}
public function getErrorKey(): string
{
return $this->businessError->key();
}
public function getStatusCode(): int
{
return $this->businessError->httpStatus();
}
public function getErrors(): array
{
return $this->errors;
}
}
抛出示例:
throw ApiHttpException::fromError(
OrderError::STATUS_NOT_CANCELABLE,
errors: [
'order_id' => $order->id,
'current_status' => $order->status->value,
],
);
8.10 Laravel 11 异常渲染
在 bootstrap/app.php 中统一渲染 JSON 异常。
$exceptions->render(function (ValidationException $e, Request $request) {
if (! $request->expectsJson()) {
return null;
}
return app(ApiResponder::class)->error(
message: 'The given data was invalid.',
code: 42200,
errorKey: 'VALIDATION_ERROR',
errors: $e->errors(),
status: 422,
);
});
$exceptions->render(function (ApiHttpException $e, Request $request) {
if (! $request->expectsJson()) {
return null;
}
return app(ApiResponder::class)->error(
message: $e->getMessage(),
code: $e->getBusinessCode(),
errorKey: $e->getErrorKey(),
errors: $e->getErrors(),
status: $e->getStatusCode(),
);
});
规则:
- API 异常必须输出 JSON,不返回框架 HTML 错误页。
- 成功响应和失败响应都复用 ApiResponder。
- 未认证、无权限、签名失败、限流等异常也应映射到统一错误协议。
- 生产环境不得向前端暴露异常堆栈。
8.11 错误码测试要求
至少覆盖:
- 错误码唯一性。
error_key唯一性。- 关键业务异常响应结构。
- ValidationException 响应结构。
- ApiHttpException 到 ApiResponder 的映射。
示例:
public function test_order_error_codes_are_unique(): void
{
$codes = array_map(fn (OrderError $error) => $error->code(), OrderError::cases());
$this->assertSame($codes, array_unique($codes));
}
8.12 本章检查清单
- Controller 是否不直接
response()->json()。 - 成功响应和失败响应是否都经过 ApiResponder。
- 分页信息是否放在
meta.pagination。 - 业务错误是否使用 BusinessError 定义。
- 是否没有手写裸数字错误码。
- 前端判断是否依赖
code或error_key,而不是message。 - 异常渲染是否覆盖 Validation、认证、权限、业务异常和 500。
- 错误码是否有唯一性测试。
9. 认证、权限与 API 安全
9.1 本章定位
认证、权限与 API 安全用于回答三个问题:
认证:当前调用方是谁。
权限:当前调用方能不能执行这个动作。
API 安全:这个入口是否具备验签、限流、防重放、幂等和审计能力。
本章只规定默认安全链路,不替代具体业务规则。业务规则仍由 Application Service / Domain Service 判断,最终执行接口必须再次校验权限与状态。
默认链路:
Route
-> authentication middleware
-> signature / csrf / throttle / anti-abuse
-> route permission middleware
-> FormRequest::authorize()
-> Application Service business check
-> operation audit
9.2 认证默认方案
新项目默认使用 Laravel Sanctum 承接后台 SPA 和普通 API Token 认证。
| 场景 | 默认方案 | 说明 |
|---|---|---|
| 同主域 / 同站后台 SPA | Sanctum Cookie Session | 适合后台管理系统 |
| App / 小程序 / 多端 API | Sanctum Personal Access Token | 适合 Bearer Token |
| 第三方开放平台 | Passport / OAuth2 或自建 AccessKey 签名 | 按开放平台复杂度决定 |
| 登录注册脚手架 | Fortify 可选 | 不作为权限系统 |
规范要求:
- 认证只确认“调用方是谁”,不表达“能做什么”。
- Token abilities 只做粗粒度能力限制,不替代后台权限、对象级授权和业务状态校验。
- 后台 SPA 使用 Cookie Session 时,必须正确配置
SANCTUM_STATEFUL_DOMAINS、SESSION_DOMAIN、CORS 和 CSRF。 - Token 模式下,Token 必须支持过期、吊销、设备标识和最后使用时间记录。
- 登录、刷新 Token、退出登录、重置密码等入口必须限流并记录安全日志。
后台 SPA 环境变量示例:
SESSION_DRIVER=redis
SESSION_DOMAIN=.example.com
SANCTUM_STATEFUL_DOMAINS=admin.example.com,api.example.com,localhost:5173
Token 模式调用示例:
Authorization: Bearer 1|xxxxxxxxxxxxxxxxxxxxxxxx
9.3 Guard 与用户边界
不同调用方必须使用明确 Guard,不得混用后台用户、前台用户和开放平台调用方。
推荐 Guard:
admin -> 管理后台用户
web -> 普通 Web 用户
api -> App / 小程序 / 客户端用户
open -> 开放平台调用方
规则:
- 管理后台接口默认使用
auth:admin或团队统一命名的后台 guard。 - 前台用户接口不得复用后台 AdminUser 模型。
- Open API 调用方应使用独立
ApiClient/OpenClient模型,不应伪装成后台用户。 - 审计日志必须记录
actor_type,避免不同用户表的id冲突。 - 多 Guard 项目中,Policy、FormRequest、RoutePermissionMiddleware 必须明确使用哪个 guard。
路由分组示例:
Route::prefix('admin')
->name('admin.')
->middleware(['auth:admin', 'route.permission', 'request.log'])
->group(function () {
Route::get('users', [UserController::class, 'index'])->name('users.index');
Route::post('users', [UserController::class, 'store'])->name('users.store');
});
Route::prefix('open')
->name('open.')
->middleware(['open.signature', 'throttle:open-api'])
->group(function () {
Route::post('orders', [OpenOrderController::class, 'store'])->name('orders.store');
});
9.4 权限推荐架构
后台权限默认采用以下四层结构:
spatie/laravel-permission
-> UI 语义层 ui_nodes
-> UI 节点与权限绑定
-> 路由与权限绑定
-> Middleware / Policy / Gate / FormRequest::authorize
各层定位:
| 层级 | 职责 |
|---|---|
| spatie permission | 权限事实源,承接角色、权限、用户授权 |
ui_nodes | 表达菜单、页面、Tab、按钮、操作入口 |
ui_node_permission_bindings | 前端节点与权限的展示关系 |
route_permission_bindings | 后端路由与权限的强制校验关系 |
| Middleware | 对接口入口做权限硬校验 |
| Policy / Gate | 对对象级、细粒度场景做授权 |
| FormRequest::authorize | 对当前请求做轻量授权入口 |
规范要求:
- 前端菜单和按钮显隐只能提升体验,不能作为安全边界。
- 所有敏感写接口必须有后端权限校验。
- 权限事实源以 spatie 权限表为准,不以
ui_nodes为准。 ui_nodes只表达 UI 入口语义,可用于菜单、按钮、审计摘要和前端权限树。- 路由权限绑定必须稳定,不应依赖 Controller 方法名临时拼接。
推荐表:
permissions
roles
model_has_roles
model_has_permissions
role_has_permissions
ui_nodes
ui_node_permission_bindings
route_permission_bindings
9.5 权限命名规范
权限名使用稳定英文标识,不使用中文,不跟随前端文案变化。
推荐格式:
{domain}.{resource}.{action}
示例:
admin.user.view
admin.user.create
admin.user.update
admin.user.delete
order.refund.approve
product.stock.adjust
system.config.update
动作命名建议:
| 动作 | 含义 |
|---|---|
view | 查看列表或详情 |
create | 创建 |
update | 修改 |
delete | 删除 |
export | 导出 |
import | 导入 |
approve | 审核通过 |
reject | 审核拒绝 |
adjust | 调整 |
force_close | 强制关闭 |
禁止:
- 一个权限名覆盖多个高风险动作。
- 权限名包含中文、页面标题或按钮文案。
- 为了省事使用
admin.*作为普通角色权限。 - 前端自定义权限字符串后端不校验。
9.6 RoutePermissionMiddleware 标准流程
后台路由必须命名。权限中间件根据当前 route name 查找权限绑定,再调用用户权限判断。
推荐流程:
request route name
-> route_permission_bindings.route_name
-> permission name
-> current admin user can(permission)
-> allow / deny
最小实现示例:
<?php
namespace App\Http\Middleware;
use App\Exceptions\ApiHttpException;
use App\Models\Permission\RoutePermissionBinding;
use App\Support\ErrorCodes\Permission\PermissionError;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RoutePermissionMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$routeName = $request->route()?->getName();
if (! $routeName) {
throw ApiHttpException::fromError(PermissionError::ROUTE_NAME_MISSING);
}
$permission = RoutePermissionBinding::query()
->where('route_name', $routeName)
->where('is_active', true)
->value('permission_name');
if (! $permission) {
throw ApiHttpException::fromError(PermissionError::ROUTE_PERMISSION_NOT_BOUND);
}
$user = $request->user('admin');
if (! $user || ! $user->can($permission)) {
throw ApiHttpException::fromError(PermissionError::FORBIDDEN);
}
return $next($request);
}
}
规则:
- 查不到路由权限绑定时,生产环境默认拒绝。
- 开发环境可以通过配置允许临时放行,但必须记录 warning 日志。
- Super Admin 可以绕过普通权限,但必须进入业务操作审计。
- 权限绑定变更后必须清理 spatie 权限缓存,并让前端权限树缓存失效。
9.7 Policy、Gate 与 FormRequest::authorize 边界
不同授权能力的边界必须清楚。
| 位置 | 适合判断 |
|---|---|
| Middleware | 当前路由是否有权限访问 |
| FormRequest::authorize | 当前请求入口的轻量授权 |
| Policy / Gate | 当前用户是否能操作某个对象 |
| Application Service | 业务状态、数据范围、流程合法性 |
示例:对象级授权放 Policy。
public function update(AdminUser $admin, Order $order): bool
{
return $admin->can('order.update')
&& in_array($order->store_id, $admin->accessibleStoreIds(), true);
}
FormRequest 示例:
public function authorize(): bool
{
$order = $this->route('order');
return $order
? $this->user('admin')?->can('update', $order) === true
: $this->user('admin')?->can('order.create') === true;
}
规则:
- Middleware 解决路由级权限,不解决对象级授权。
- Policy / Gate 解决对象级授权,不替代业务状态机。
- Application Service 执行核心业务前仍需校验业务状态。
- 列表、详情、导出、更新接口必须使用同一套数据范围规则。
9.8 UI 节点 Header 与审计上下文
前端统一通过 Header 传递 UI 节点:
X-Ui-Node-Id: 1001
用途:
- 补全业务操作审计中的页面、按钮、路径语义。
- 关联
ui_nodes,生成“用户在某页面点击某按钮”的摘要。 - 与
request_id一起串联请求日志、业务审计、SQL 执行审计。
规则:
X-Ui-Node-Id不作为授权依据。- 后端不得因为前端传入某个 UI 节点就认为用户有权限。
- UI 节点不存在时,审计可以降级为 route name / event key。
- 高风险操作必须有
event_key,不能完全依赖 UI 节点自动推断。
9.9 SPA API 安全规范
SPA 的前端代码、接口地址、请求参数都可以被用户看到。不要把“接口只给我的前端调用”理解为安全边界。
默认规则:
- CORS 只控制浏览器跨域访问,不阻止 curl、Postman、脚本直接请求。
- 所有敏感接口必须认证、授权、限流和审计。
- Cookie Session 模式必须启用 CSRF。
- Token 模式必须保护 Token 存储和过期策略。
- 公共接口必须限流、缓存、Bot 防护和请求日志。
- 展示型公共接口可以加入短期签名、Referer / Origin 检查、设备指纹、验证码等提高滥用成本,但不能视为绝对安全。
- 不得把内部管理数据做成“无鉴权但只在指定前端展示”的裸接口。
CORS 配置原则:
允许域名:精确列出后台和前台域名
允许 Header:只开放实际需要的 Header
允许 Credentials:仅 Cookie Session 场景开启
生产环境:不得使用 * 搭配 credentials
公共接口最低要求:
throttle
request log
response cache 按需
anti-abuse 按需
敏感字段不返回
错误响应不泄露内部异常细节
9.10 Open API 签名规范
Open API 写接口必须具备身份识别、验签、防重放、限流和幂等。
推荐 Header:
X-Client-Id: client_1001
X-Timestamp: 1760000000
X-Nonce: 2f4f5a7c8c
X-Signature: hex_hmac_sha256_signature
Idempotency-Key: order-create-20260429-0001
推荐签名串:
METHOD\nPATH\nTIMESTAMP\nNONCE\nBODY_SHA256
验签流程:
1. 根据 X-Client-Id 查找调用方和 secret
2. 校验调用方状态、IP 白名单、权限范围
3. 校验 X-Timestamp 是否在允许窗口内
4. 校验 X-Nonce 是否已使用,防重放
5. 计算 body sha256
6. 计算 HMAC-SHA256 签名并使用 hash_equals 比较
7. 校验 Idempotency-Key
8. 进入业务处理
验签骨架示例:
$payloadHash = hash('sha256', $request->getContent());
$signaturePayload = implode("\n", [
strtoupper($request->method()),
'/' . ltrim($request->path(), '/'),
$request->header('X-Timestamp'),
$request->header('X-Nonce'),
$payloadHash,
]);
$expected = hash_hmac('sha256', $signaturePayload, $client->secret);
if (! hash_equals($expected, (string) $request->header('X-Signature'))) {
throw ApiHttpException::fromError(OpenApiError::INVALID_SIGNATURE);
}
规则:
- 签名密钥不得明文返回给前端。
- 时间戳窗口建议 5 分钟以内。
- nonce 必须短期缓存或落库防重放。
- 写接口必须要求幂等键或外部业务单号。
- 签名失败、重放、限流、幂等冲突都必须记录安全日志。
9.11 Webhook 安全规范
Webhook 必须先验签,再处理业务。
规则:
- 平台事件 ID 必须建立唯一索引。
- 同一个事件重复到达时,返回幂等成功或明确“已处理”。
- 回调原始 payload 应保存摘要或完整内容,按安全和隐私要求决定。
- 业务处理必须在事务内完成核心状态变更。
- 外部副作用必须
afterCommit。 - Webhook 不应依赖前端登录态和 CSRF。
推荐处理链路:
Webhook Route
-> provider signature middleware
-> callback FormRequest
-> callback Data
-> HandleWebhookService
-> event unique record
-> transaction + lockForUpdate
-> state transition
-> afterCommit Job
9.12 本章检查清单
- 后台敏感接口是否全部有认证、权限和 route name。
- 权限名是否稳定、英文、可检索。
- 前端按钮隐藏是否没有替代后端权限校验。
- RoutePermissionMiddleware 查不到绑定时生产环境是否默认拒绝。
- Policy / Gate 是否覆盖对象级授权。
- 列表、详情、导出是否使用同一套数据范围。
X-Ui-Node-Id是否只用于审计,不用于授权。- SPA 公共接口是否限流、缓存、脱敏和记录日志。
- Open API 是否具备签名、时间戳、nonce、幂等和限流。
- Webhook 是否先验签、再幂等、再处理业务。
10. 状态枚举与状态机规范
10.1 本章定位
状态规范用于统一数据库状态值、后端判断、API 输出、前端判断和状态流转规则。
默认结构:
int backed enum
-> Model casts()
-> Domain 状态机 / 状态流转服务
-> Application Service 事务执行
-> Resource 显式输出 status / status_key / status_label / can_xxx
-> Test 覆盖允许与禁止流转
适合状态机的对象:订单、支付、退款、售后、优惠券、发货、审核、结算、导入导出任务、工单、权限审批。
普通二值开关,例如 is_active、is_default、is_deleted,使用 boolean cast 即可。
判断原则:
只表示开关:boolean cast。
驱动业务流程:Enum + 状态机。
涉及钱、库存、权益、权限、结算:必须集中管理流转规则。
10.2 状态字段数据库规范
核心状态字段推荐使用 unsignedTinyInteger 或 unsignedSmallInteger,并建立必要索引。
$table->unsignedTinyInteger('status')
->default(OrderStatus::PendingPay->value)
->index();
规则:
- 核心状态字段不建议 nullable。
- 数据库存储稳定数字值,不存中文文案。
- 新增状态值时不得复用历史状态值。
- 删除状态值必须考虑历史数据兼容。
- 状态字段参与列表筛选时必须建索引。
10.3 Enum 职责与标准模板
Enum 负责表达“状态是什么”,不负责执行业务流程。
Enum 可以包含:
- 数据库存储值。
- 稳定英文 key。
- 展示 label。
- 是否终态。
- 状态分组。
- 前端 options 输出。
- 简单纯状态判断。
标准模板:
<?php
namespace App\Enums\Order;
enum OrderStatus: int
{
case PendingPay = 1;
case Paid = 2;
case Canceled = 3;
case Finished = 4;
case Refunding = 5;
case Refunded = 6;
public function key(): string
{
return match ($this) {
self::PendingPay => 'pending_pay',
self::Paid => 'paid',
self::Canceled => 'canceled',
self::Finished => 'finished',
self::Refunding => 'refunding',
self::Refunded => 'refunded',
};
}
public function label(): string
{
return match ($this) {
self::PendingPay => '待支付',
self::Paid => '已支付',
self::Canceled => '已取消',
self::Finished => '已完成',
self::Refunding => '退款中',
self::Refunded => '已退款',
};
}
public function isTerminal(): bool
{
return in_array($this, [
self::Canceled,
self::Finished,
self::Refunded,
], true);
}
public static function options(): array
{
return array_map(
fn (self $status) => [
'value' => $status->value,
'key' => $status->key(),
'label' => $status->label(),
],
self::cases(),
);
}
}
Enum 禁止:
- 开事务。
- 保存模型。
- 调外部接口。
- 写审计日志。
- 派发 Job。
- 读取 Request / Auth。
- 编排跨模型状态流转。
10.4 Model casts 绑定规范
Model 必须将状态字段绑定到 Enum。
use App\Enums\Order\OrderStatus;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected function casts(): array
{
return [
'status' => OrderStatus::class,
'paid_at' => 'datetime',
'finished_at' => 'datetime',
];
}
public function isPaid(): bool
{
return $this->status === OrderStatus::Paid;
}
}
规则:
- 业务代码使用 Enum case,不使用魔法数字。
- 查询条件使用
EnumCase->value。 - 中文 label 不得参与业务判断。
- Model 只保留简单状态辅助方法,不编排状态流转。
10.5 状态机 / 状态流转服务职责
状态机负责表达“能不能从 A 到 B”,以及“某个业务动作是否允许”。
状态机负责:
- 定义允许流转关系。
- 判断业务动作是否允许。
- 不允许时抛统一业务异常。
- 集中承接状态规则测试。
状态机不负责:
- 控制数据库事务。
- 保存模型。
- 写审计。
- 派发事件或 Job。
- 输出 API 响应。
推荐目录:
app/Domain/Order/OrderStatusTransitionService.php
app/Domain/Refund/RefundStatusTransitionService.php
标准模板:
<?php
namespace App\Domain\Order;
use App\Enums\Order\OrderStatus;
use App\Exceptions\ApiHttpException;
use App\Models\Order\Order;
use App\Support\ErrorCodes\Order\OrderError;
class OrderStatusTransitionService
{
public function ensureCanCancel(Order $order): void
{
if (! $this->canTransition($order->status, OrderStatus::Canceled)) {
throw ApiHttpException::fromError(
OrderError::STATUS_NOT_CANCELABLE,
errors: [
'order_id' => $order->id,
'current_status' => $order->status->value,
'current_status_key' => $order->status->key(),
],
);
}
}
public function ensureCanMarkAsPaid(Order $order): void
{
if (! $this->canTransition($order->status, OrderStatus::Paid)) {
throw ApiHttpException::fromError(OrderError::STATUS_NOT_PAYABLE);
}
}
public function ensureCanApplyRefund(Order $order): void
{
if (! $this->canTransition($order->status, OrderStatus::Refunding)) {
throw ApiHttpException::fromError(OrderError::STATUS_NOT_REFUNDABLE);
}
}
public function canTransition(OrderStatus $from, OrderStatus $to): bool
{
return in_array($to, $this->allowedTargets($from), true);
}
/** @return list<OrderStatus> */
public function allowedTargets(OrderStatus $from): array
{
return match ($from) {
OrderStatus::PendingPay => [
OrderStatus::Paid,
OrderStatus::Canceled,
],
OrderStatus::Paid => [
OrderStatus::Finished,
OrderStatus::Refunding,
],
OrderStatus::Finished => [
OrderStatus::Refunding,
],
OrderStatus::Refunding => [
OrderStatus::Refunded,
],
OrderStatus::Canceled,
OrderStatus::Refunded => [],
};
}
}
10.6 状态动作命名规范
业务动作不应只用目标状态表达。
推荐动作命名:
ensureCanCancel()
ensureCanPay()
ensureCanMarkAsPaid()
ensureCanApplyRefund()
ensureCanApproveRefund()
ensureCanRejectRefund()
ensureCanFinish()
ensureCanForceClose()
不推荐业务代码长期只写:
ensureCanTransition($order, OrderStatus::Canceled);
原因:真实业务动作往往不只判断状态,还可能判断支付、退款、发货、时间窗口、权限、组织范围、售后记录等上下文。
规则:
- 面向用例调用时优先使用动作语义方法。
canTransition()作为底层通用能力存在,不替代动作方法。- 强制操作必须单独命名,例如
ensureCanForceClose(),并进入业务审计。
10.7 Application Service 执行状态流转
Application Service 负责在事务中加锁、调用状态机、更新状态、记录审计、派发事件。
<?php
namespace App\Services\Admin\Order;
use App\Domain\Order\OrderStatusTransitionService;
use App\Enums\Order\OrderStatus;
use App\Models\Order\Order;
use App\Services\Audit\InteractsWithOperationAudit;
use Illuminate\Support\Facades\DB;
class CancelOrderService
{
use InteractsWithOperationAudit;
public function __construct(
private readonly OrderStatusTransitionService $transitionService,
) {
}
public function handle(Order $order, ?string $reason = null): Order
{
return DB::transaction(function () use ($order, $reason) {
$order = Order::query()
->whereKey($order->id)
->lockForUpdate()
->firstOrFail();
$this->transitionService->ensureCanCancel($order);
$order->update([
'status' => OrderStatus::Canceled,
'cancel_reason' => $reason,
'canceled_at' => now(),
]);
$this->auditSuccess(
subject: $order,
reason: $reason,
eventKey: 'order.cancelled',
eventLabel: '取消订单',
);
return $order->refresh();
}, attempts: 3);
}
}
规则:
- Controller 不直接判断复杂状态流转。
- Model 不编排跨模型状态变更。
- 状态机不控制事务。
- 状态变更涉及审计时,由 Application Service 显式调用。
10.8 状态并发控制
状态机的 PHP 判断不能替代数据库并发控制。
单表简单状态变更优先使用条件更新:
$affected = Order::query()
->whereKey($order->id)
->where('status', OrderStatus::PendingPay->value)
->update([
'status' => OrderStatus::Canceled->value,
'canceled_at' => now(),
]);
if ($affected === 0) {
throw ApiHttpException::fromError(OrderError::STATUS_NOT_CANCELABLE);
}
复杂状态流转使用事务 + 行锁:
DB::transaction(function () use ($refundId) {
$refund = RefundOrder::query()
->whereKey($refundId)
->lockForUpdate()
->firstOrFail();
$this->transitionService->ensureCanApprove($refund);
$refund->update([
'status' => RefundStatus::Approved,
'approved_at' => now(),
]);
}, attempts: 3);
规则:
- 状态变更必须校验当前状态。
- 高并发状态流转优先使用条件更新或行锁。
- 不得在无状态条件或无锁保护的情况下直接更新核心状态字段。
- 涉及金钱、库存、优惠券、积分、权限、结算的状态流转必须有事务和并发控制策略。
10.9 API 状态输出规范
状态字段必须由 Resource 显式输出,不依赖 Enum 自动序列化。
{
"status": 2,
"status_key": "paid",
"status_label": "已支付",
"can_cancel": true,
"can_apply_refund": true
}
字段职责:
| 字段 | 用途 | 稳定性 |
|---|---|---|
status | 数字值,机器判断和兼容数据库值 | 高 |
status_key | 稳定英文标识,前端可读判断 | 高 |
status_label | 展示文案,只用于 UI | 低 |
can_xxx | 当前上下文下是否允许某动作 | 按业务规则计算 |
Resource 示例:
return [
'id' => $this->id,
'order_no' => $this->order_no,
'status' => $this->status->value,
'status_key' => $this->status->key(),
'status_label' => $this->status->label(),
'can_cancel' => $this->status === OrderStatus::PendingPay,
];
规则:
- 前端不得使用中文
status_label做条件判断。 status和status_key属于接口合同。status_label允许跟随产品文案、多语言和 UI 调整。can_xxx只用于前端交互体验,执行接口仍必须后端重新校验。- Resource 不得为计算复杂
can_xxx触发数据库查询。
复杂 can_xxx 应由 Application Service / Domain Service 计算后通过 Output Data 交给 Resource。
10.10 状态文档与测试要求
核心业务对象必须维护状态流转说明。
示例:
PendingPay
-> Paid
-> Canceled
Paid
-> Finished
-> Refunding
Finished
-> Refunding
Refunding
-> Refunded
Canceled / Refunded
-> 无后续普通流转
Mermaid 可选:
stateDiagram-v2
PendingPay --> Paid
PendingPay --> Canceled
Paid --> Finished
Paid --> Refunding
Finished --> Refunding
Refunding --> Refunded
测试至少覆盖:
- 允许的状态流转可以成功。
- 不允许的状态流转必须失败。
- 终态不能继续普通流转。
- 重复回调不会重复变更状态。
- 并发状态变更只有一个成功。
- Resource 输出
status / status_key / status_label / can_xxx符合接口合同。
10.11 本章检查清单
- 状态字段是否使用 int backed enum。
- Model 是否通过
casts()绑定状态 Enum。 - 是否存在中文 label 参与判断。
- 核心状态流转是否集中在状态机 / Domain Service。
- Application Service 是否负责事务、锁、审计和事件。
- 状态变更是否校验当前状态。
- 高风险状态变更是否有条件更新或行锁。
- API 是否显式输出
status / status_key / status_label / can_xxx。 - 新增状态是否同步更新 Enum、状态机、接口文档和测试。
11. 事务、并发、锁与幂等规范
11.1 本章定位
事务、并发、锁与幂等用于保证核心写入流程在重复请求、并发请求、回调重放和队列重试下保持可预测、可追踪、可恢复。
默认规则:
- Application Service 是事务边界默认承载层。
- Controller、Model、Resource 不开启事务。
- Domain Service 可以参与事务内判断,但不控制事务。
- 事务只解决一次流程内部原子性,不解决重复请求和并发冲突。
- 并发与幂等必须结合事务、行锁、条件更新、唯一索引、缓存锁、幂等键和状态机。
11.2 事务边界规范
事务用于保证一组数据库写入要么全部成功,要么全部失败。
推荐写法:
return DB::transaction(function () use ($refund, $reason) {
$refund = RefundOrder::query()
->whereKey($refund->id)
->lockForUpdate()
->firstOrFail();
$this->transitionService->ensureCanApprove($refund);
$refund->update([
'status' => RefundStatus::Approved,
'approve_reason' => $reason,
'approved_at' => now(),
]);
$this->auditSuccess(
subject: $refund,
reason: $reason,
eventKey: 'refund.approved',
eventLabel: '退款审核通过',
);
return $refund->refresh();
}, attempts: 3);
规则:
- 多表写入一致性必须放进事务。
- 事务闭包内只放必须保持原子一致的数据库写入和必要判断。
- 事务内代码应尽量短,避免长时间持锁。
- 事务重试次数普通业务建议
2 ~ 3次。 - 启用重试时,闭包内不得包含不可安全重复执行的副作用。
11.3 事务内副作用规范
事务内不得执行不可回滚的外部副作用。
不推荐:
DB::transaction(function () use ($order) {
$order->update(['status' => OrderStatus::Paid]);
Http::post('https://erp.example.com/orders/sync', [
'order_id' => $order->id,
]);
});
推荐:
DB::transaction(function () use ($order) {
$order->update([
'status' => OrderStatus::Paid,
'paid_at' => now(),
]);
SyncOrderToErpJob::dispatch($order->id)->afterCommit();
SendOrderPaidNotificationJob::dispatch($order->id)->afterCommit();
}, attempts: 3);
规则:
- 外部 HTTP、短信、邮件、Webhook、文件处理不得直接放在事务内。
- 事务提交后才允许执行的副作用使用
afterCommit、Listener、Job 或 Outbox。 - 事务内可以写数据库审计表,但外部审计传播仍应提交后执行。
- 副作用任务必须具备重试安全性。
11.4 条件更新规范
库存、余额、次数、名额扣减优先使用带条件的原子更新。
不推荐:
$product = Product::query()->findOrFail($productId);
if ($product->stock < $quantity) {
throw ApiHttpException::fromError(ProductError::STOCK_NOT_ENOUGH);
}
$product->decrement('stock', $quantity);
推荐:
$affected = Product::query()
->whereKey($productId)
->where('stock', '>=', $quantity)
->decrement('stock', $quantity);
if ($affected === 0) {
throw ApiHttpException::fromError(ProductError::STOCK_NOT_ENOUGH);
}
状态流转也应带当前状态条件:
$affected = Order::query()
->whereKey($orderId)
->where('status', OrderStatus::PendingPay->value)
->update([
'status' => OrderStatus::Canceled->value,
'canceled_at' => now(),
]);
if ($affected === 0) {
throw ApiHttpException::fromError(OrderError::STATUS_NOT_CANCELABLE);
}
规则:
- 数值扣减不得长期采用“先查再无条件更新”。
- 状态流转不得无条件直接更新
status。 - 条件更新失败必须转换为业务异常。
- 高并发下优先让数据库条件成为最终保障。
11.5 行级锁规范
当业务必须读取当前行状态,并基于该状态执行多步写入时,使用 lockForUpdate()。
DB::transaction(function () use ($orderId) {
$order = Order::query()
->whereKey($orderId)
->lockForUpdate()
->firstOrFail();
if (! $order->status->canPay()) {
throw ApiHttpException::fromError(OrderError::STATUS_NOT_PAYABLE);
}
$order->update([
'status' => OrderStatus::Paid,
'paid_at' => now(),
]);
PaymentLog::query()->create([
'order_id' => $order->id,
'amount' => $order->pay_amount,
]);
}, attempts: 3);
适用:
- 订单支付状态变更。
- 退款审核。
- 余额扣减并写流水。
- 优惠券领取。
- 权限角色变更。
- 需要读取当前行并派生多表写入的流程。
规则:
lockForUpdate()必须在事务内使用。- 多行加锁必须保持稳定顺序。
- 持锁期间不得调用外部接口或耗时任务。
- 条件更新已经能解决时,不滥用行锁。
11.6 唯一索引兜底规范
任何强唯一业务约束,最终必须落到数据库唯一索引。
常见唯一约束:
- 订单号
order_no - 支付流水号
transaction_no - 第三方回调事件
provider + event_id - 外部订单号
api_client_id + external_order_no - 用户优惠券领取记录
user_id + coupon_id + source_id - 幂等请求
scope + idempotency_key
迁移示例:
$table->string('transaction_no', 100)->unique();
$table->unique(['provider', 'event_id']);
$table->unique(['scope', 'idempotency_key']);
规则:
- 代码层
exists()只能作为提前提示,不能作为最终保障。 - 不能重复创建的业务数据,必须有唯一索引兜底。
- 捕获唯一冲突后,应转换为稳定业务异常或幂等成功结果。
- 不得依赖前端禁用按钮保证唯一性。
11.7 缓存锁 / 分布式锁规范
缓存锁用于降低同一资源并发进入概率,不是数据一致性的最终保障。
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
$lock = Cache::lock("order:pay:{$orderId}", 10);
try {
return $lock->block(3, function () use ($orderId) {
return app(PayOrderService::class)->handle($orderId);
});
} catch (LockTimeoutException) {
throw ApiHttpException::fromError(CommonError::OPERATION_TOO_FREQUENT);
}
规则:
- 缓存锁用于入口互斥,不是最终一致性保障。
- 核心数据仍必须依赖事务、行锁、条件更新、唯一索引或幂等表。
- 多服务器部署必须使用共享 Redis。
- 锁 key 必须包含明确业务资源 ID。
- 锁超时时间必须有限,不得无限持有。
11.8 幂等规范
幂等解决的是:同一个业务请求重复到达多次,只应产生一次业务结果。
常见重复来源:前端重复点击、网关重试、网络超时后重试、支付平台重复回调、Webhook 重放、队列任务重试、定时任务重复执行、Open API 调用方重试。
推荐幂等键:
| 场景 | 幂等键 |
|---|---|
| 前端创建订单 | Idempotency-Key Header |
| 支付回调 | 第三方 transaction_id / event_id |
| 退款回调 | 第三方 refund_id / event_id |
| Open API 创建订单 | request_no / external_order_no |
| 队列任务 | 业务资源 ID + 动作名 |
| 定时任务 | 任务名 + 执行周期 |
高风险入口必须做幂等:支付、退款、Webhook、Open API 写接口、创建订单、余额扣减、优惠券领取。
11.9 幂等表标准结构
第一版可以不做复杂通用幂等中心,但高风险入口必须至少有业务唯一索引或幂等记录。
可选通用表:
Schema::create('idempotency_keys', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('scope', 64);
$table->string('idempotency_key', 128);
$table->string('status', 32)->default('processing');
$table->string('request_hash', 64)->nullable();
$table->json('response_snapshot')->nullable();
$table->timestamp('locked_until')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->unique(['scope', 'idempotency_key']);
$table->index(['expires_at']);
});
状态建议:
processing -> 处理中
succeeded -> 已成功
failed -> 已失败,可按业务决定是否允许重试
规则:
- 幂等键必须绑定业务范围,避免不同业务误用同一个 key。
- 相同 key 但 request hash 不一致,应返回幂等冲突。
- 成功后的重复请求可以返回上次成功快照,或返回明确“已处理”。
- processing 超时必须有恢复策略。
11.10 支付回调 / Webhook 幂等示例
DB::transaction(function () use ($data) {
$event = PaymentCallbackEvent::query()->firstOrCreate(
[
'provider' => $data->provider,
'event_id' => $data->eventId,
],
[
'payload' => $data->payload,
'status' => 'processing',
],
);
if ($event->status === 'succeeded') {
return;
}
$order = Order::query()
->where('order_no', $data->orderNo)
->lockForUpdate()
->firstOrFail();
if ($order->status === OrderStatus::Paid) {
$event->update(['status' => 'succeeded']);
return;
}
if (! $order->status->canMarkAsPaid()) {
$event->update(['status' => 'failed']);
throw ApiHttpException::fromError(OrderError::STATUS_NOT_PAYABLE);
}
$order->update([
'status' => OrderStatus::Paid,
'paid_at' => now(),
]);
PaymentLog::query()->create([
'order_id' => $order->id,
'transaction_no' => $data->transactionNo,
'amount' => $data->amount,
]);
$event->update(['status' => 'succeeded']);
SyncOrderToErpJob::dispatch($order->id)->afterCommit();
}, attempts: 3);
规则:
- 回调必须先验签,再进入业务。
- 回调事件必须有唯一索引。
- 核心对象必须加锁或条件更新。
- 外部同步必须提交后执行。
- 重复事件不得重复写流水、重复改状态、重复发通知。
11.11 队列任务并发与幂等
ShouldBeUnique 防止重复投递,WithoutOverlapping 防止并发执行,二者都不能替代业务幂等。
class SyncOrderToErpJob implements ShouldQueue, ShouldBeUnique
{
public int $uniqueFor = 3600;
public function __construct(
public readonly int $orderId,
) {
}
public function uniqueId(): string
{
return 'sync-order-to-erp:' . $this->orderId;
}
public function handle(): void
{
// 仍需检查本地同步状态或外部幂等键。
}
}
规则:
ShouldBeUnique解决重复投递。WithoutOverlapping解决同时执行。- Job 的
handle()里仍必须基于业务状态、唯一索引或幂等记录兜底。 - 第三方同步 Job 必须可安全重试。
11.12 选择规则
| 问题 | 首选方案 |
|---|---|
| 多表写入原子性 | DB::transaction() |
| 同一行串行处理 | lockForUpdate() |
| 库存 / 余额 / 次数扣减 | 条件 update / decrement |
| 防重复创建唯一业务数据 | 数据库唯一索引 |
| 防同一资源跨请求并发执行 | Cache::lock() |
| 防 Open API / 支付回调重复生效 | 幂等 key + 唯一索引 |
| 防重复 Job 投递 | ShouldBeUnique |
| 防同类 Job 同时执行 | WithoutOverlapping |
| 防状态乱跳 | Enum + 状态机 + 条件更新 / 行锁 |
11.13 本章检查清单
- 事务是否只放在 Application Service。
- 事务内是否没有外部 HTTP、短信、邮件、文件处理。
- 数值扣减是否使用条件更新。
- 状态更新是否带当前状态条件或行锁。
- 强唯一业务约束是否有数据库唯一索引。
- 缓存锁是否没有被当作唯一一致性保障。
- 支付、退款、Webhook、Open API 写接口是否具备幂等。
- 队列任务是否区分防重复投递、防并发执行和业务幂等。
- 启用事务重试的闭包是否可安全重复执行。
12. 缓存与 Redis 规范
12.1 本章定位
缓存是性能优化层,不是业务事实来源。
默认原则:
数据库 = 事实来源
缓存 = 加速读取
缓存锁 = 降低并发冲突
事务 / 行锁 / 条件更新 / 唯一索引 / 状态机 = 一致性保障
不得只依赖缓存作为以下数据的最终判断依据:订单状态、支付状态、退款状态、库存、余额、权限最终授权结果、审计日志、支付回调 / Webhook 幂等结果。
12.2 适合缓存的数据
适合缓存:
- 低频变化配置,例如站点配置、系统参数、业务字典。
- 权限树、菜单树、UI 节点树。
- 国家、省市区、分类、品牌、枚举 options。
- 读多写少聚合数据,例如 Dashboard、排行榜、热门列表。
- 昂贵查询结果,例如多表聚合、复杂统计。
- 第三方接口结果,例如汇率、物流轨迹摘要、地理编码。
- 防重复与限流辅助,例如验证码、anti-abuse 计数、登录失败计数。
不适合只靠缓存:
- 库存扣减。
- 余额扣减。
- 订单 / 支付 / 退款状态。
- 权限最终授权。
- 幂等最终结果。
- 审计日志。
12.3 Cache Key 规范
缓存 Key 必须具备稳定命名空间和隔离维度。
推荐结构:
{app}:{env}:{domain}:{resource}:{identifier}:{field_or_version}
项目内部简化结构:
{domain}:{resource}:{identifier}
示例:
admin:user:1001:permissions:v12
admin:ui_nodes:tree:v12
system:config:site
product:category:tree:v3
order:stats:store:12:2026-04-29
anti_abuse:login:sha1
统一 Key 生成类:
<?php
namespace App\Support\Cache;
final class CacheKeys
{
public static function adminUserPermissions(int $userId, int $version): string
{
return "admin:user:{$userId}:permissions:v{$version}";
}
public static function uiNodeTree(int $version): string
{
return "admin:ui_nodes:tree:v{$version}";
}
public static function systemConfig(string $key): string
{
return "system:config:{$key}";
}
public static function productCategoryTree(int $version): string
{
return "product:category:tree:v{$version}";
}
public static function loginThrottle(string $account, string $ip): string
{
return 'anti_abuse:login:' . sha1(strtolower($account) . '|' . $ip);
}
}
规则:
- 禁止业务代码长期散落手写 Key。
- 用户、租户、门店、组织、语言、币种、权限版本必须进入 Key。
- 筛选条件较多的列表缓存必须使用参数 hash。
- 手机号、邮箱、IP 等敏感维度建议摘要化。
筛选参数 hash 示例:
$filterHash = sha1(json_encode([
'keyword' => $query->keyword,
'status' => $query->status,
'created_from' => $query->createdFrom,
'created_to' => $query->createdTo,
'sort' => $query->sort,
'order' => $query->order,
], JSON_UNESCAPED_UNICODE));
12.4 多用户 / 多租户 / 多门店缓存隔离
凡是查询结果受用户、角色、组织、门店、客户、供应商、仓库、语言、币种、权限影响,缓存 Key 必须包含对应隔离维度。
错误示例:
Cache::remember('order:list', 600, function () {
return Order::query()->latest()->limit(20)->get();
});
推荐:
$key = "admin:orders:list:store:{$storeId}:user:{$userId}:hash:{$filterHash}";
规则:
- 不得把带权限范围的数据缓存成全局 Key。
- 用户个性化数据必须带用户隔离维度。
- 门店、组织、客户、供应商、仓库范围数据必须带对应业务边界 ID。
- 列表类缓存如果受筛选条件影响,Key 中必须体现筛选条件摘要。
12.5 TTL 规范
缓存必须显式设置 TTL,除非是明确可永久缓存且具备主动失效机制的数据。
| 类型 | TTL 建议 |
|---|---|
| 验证码 | 5 ~ 10 分钟 |
| 登录失败计数 | 5 ~ 30 分钟 |
| CSRF / guest token 辅助缓存 | 10 ~ 60 分钟 |
| 系统配置 | 10 ~ 60 分钟,或版本号失效 |
| 权限树 / 菜单树 | 10 ~ 60 分钟,配合版本号 |
| 分类树 / 字典 | 30 分钟 ~ 24 小时 |
| Dashboard 统计 | 1 ~ 10 分钟 |
| 第三方接口结果 | 按业务时效设置 |
| 热门列表 | 1 ~ 10 分钟 |
| 幂等短期结果 | 10 分钟 ~ 24 小时 |
规则:
- 默认不得无期限缓存。
forever必须有明确清理入口或版本失效机制。- 热点缓存建议增加 TTL 抖动,避免同一时间批量失效。
- 高风险业务幂等缓存 TTL 必须与业务重试窗口匹配。
TTL 抖动示例:
$ttl = now()->addMinutes(30 + random_int(0, 5));
12.6 Cache Aside 默认模式
普通业务缓存默认采用 Cache Aside。
读:先读缓存,未命中查数据库,再写缓存。
写:先写数据库,事务提交后删除缓存。
读取示例:
public function getSiteConfig(string $key): mixed
{
return Cache::remember(
CacheKeys::systemConfig($key),
now()->addMinutes(30),
fn () => SystemConfig::query()
->where('key', $key)
->value('value')
);
}
写入示例:
public function updateSiteConfig(string $key, mixed $value): void
{
DB::transaction(function () use ($key, $value) {
SystemConfig::query()->updateOrCreate(
['key' => $key],
['value' => $value],
);
DB::afterCommit(fn () => Cache::forget(CacheKeys::systemConfig($key)));
});
}
规则:
- 默认写操作后删除缓存,而不是直接更新缓存。
- 缓存重建成本高时,结合缓存锁、防击穿或异步预热。
- 缓存失效失败不得影响主业务提交,但必须记录日志。
12.7 缓存与事务
缓存操作必须考虑数据库事务提交时机。
不推荐:
DB::transaction(function () use ($order) {
$order->update(['status' => OrderStatus::Paid]);
Cache::put("order:{$order->id}", $order, now()->addMinutes(10));
});
推荐:
DB::transaction(function () use ($order) {
$order->update(['status' => OrderStatus::Paid]);
DB::afterCommit(function () use ($order) {
Cache::forget("order:{$order->id}");
});
});
规则:
- 事务内不得写最终缓存结果。
- 数据库变更后的缓存失效应在事务提交后执行。
- 事务回滚时,不应对外发布基于未提交数据的缓存、事件、通知或 Job。
12.8 防击穿、防雪崩、防穿透
防击穿
热点 Key 过期时,用缓存锁保护重建过程。
$key = CacheKeys::productCategoryTree($version);
$value = Cache::get($key);
if ($value !== null) {
return $value;
}
return Cache::lock("lock:{$key}", 10)->block(3, function () use ($key) {
return Cache::remember($key, now()->addMinutes(30), function () {
return Category::query()
->active()
->orderBy('sort')
->get()
->toArray();
});
});
防雪崩
批量缓存或热点缓存增加 TTL 随机抖动,不使用完全相同过期时间。
防穿透
不存在的数据可缓存短 TTL 空结果。
$value = Cache::remember($key, now()->addMinutes(5), function () use ($id) {
return Product::query()->find($id)?->toArray() ?? [];
});
规则:
- 空结果缓存必须短 TTL。
- 必须区分“缓存未命中”和“数据确实不存在”。
- 不得让空结果缓存导致后续真实创建数据后长期不可见。
12.9 权限缓存规范
权限缓存属于安全敏感缓存。
规则:
- 权限变更后必须清理 spatie permission cache。
- 权限变更后应更新相关用户的
permission_version。 - 前端权限树缓存 Key 必须包含
permission_version或等价版本维度。 - 后端授权不得只依赖前端缓存的权限树。
- 权限缓存失效应进入安全敏感操作审计链路。
示例:
use Spatie\Permission\PermissionRegistrar;
DB::transaction(function () use ($role, $permissions) {
$role->syncPermissions($permissions);
AdminUser::query()
->whereHas('roles', fn ($query) => $query->whereKey($role->id))
->increment('permission_version');
DB::afterCommit(function () {
app(PermissionRegistrar::class)->forgetCachedPermissions();
});
});
12.10 缓存数据结构规范
不建议长期缓存完整 Eloquent Model 实例。
不推荐:
Cache::put($key, $order, now()->addMinutes(10));
推荐缓存数组、标量或 DTO 序列化结果:
Cache::put($key, [
'id' => $order->id,
'order_no' => $order->order_no,
'status' => $order->status->value,
'status_key' => $order->status->key(),
], now()->addMinutes(10));
规则:
- 缓存内容优先使用数组、标量、DTO 序列化结果。
- 不建议长期缓存完整 Eloquent Model 实例。
- 缓存内容不得包含
password、token、secret、cookie、authorization等敏感字段。 - 缓存结构应尽量稳定。
12.11 缓存预热与降级
对于分类树、权限树、系统配置、Dashboard 汇总等高频读取数据,可以按需提供预热。
推荐命令:
php artisan cache:warmup system-config
php artisan cache:warmup permission-tree
php artisan cache:warmup category-tree
规则:
- 预热不是必须项,按访问量和查询成本决定。
- 缓存预热失败不得影响系统启动或主业务流程。
- 预热命令必须记录日志。
- 热点缓存读取失败时,应支持降级到数据库读取。
12.12 Redis 生产基线
生产环境推荐:
CACHE_STORE=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
REDIS_CLIENT=phpredis
规则:
- 多服务器部署时,缓存锁必须使用共享 Redis。
- Redis 必须设置密码、内网访问、持久化和监控。
- Session、Queue、Cache 建议使用不同 Redis database 或清晰前缀隔离。
- 不在生产使用
array、file作为队列和关键缓存驱动。 - 关键 Redis 连接必须有超时配置,避免服务异常时拖垮 PHP 请求。
推荐隔离:
cache -> Redis db 0 或 prefix cache:
session -> Redis db 1 或 prefix session:
queue -> Redis db 2 或 prefix queue:
lock -> 与 cache 同库但使用 lock: prefix
12.13 本章检查清单
- 缓存是否只作为加速,不作为核心事实来源。
- Cache Key 是否通过统一类生成。
- Key 是否包含用户、租户、门店、组织、权限版本等隔离维度。
- TTL 是否显式设置。
- 写操作是否在事务提交后删除缓存。
- 是否长期缓存了完整 Eloquent Model。
- 热点缓存是否有防击穿策略。
- 批量缓存是否有 TTL 抖动。
- 权限变更是否清理 spatie cache 并更新
permission_version。 - Redis 是否用于生产的 cache、session、queue 和 lock。
13. 队列、Job、Command 与 Scheduler 规范
13.1 本章定位
本章约束异步任务、命令行任务和定时调度的开发期设计。生产部署、Supervisor、Horizon、cron、Schedule Monitor、Uptime Kuma 等运行细节放在第 17 章。
职责边界必须明确:
| 类型 | 职责 | 不负责 |
|---|---|---|
Command | 命令入口,接收参数、输出执行结果、调用 Application Service | 不堆复杂业务流程,不直接写大量 SQL |
Job | 异步执行单元,负责延迟、重试、队列隔离 | 不访问 Request,不替代业务幂等 |
Scheduler | 调度入口,决定什么时候触发 Command / Job | 不直接写复杂业务闭包 |
Application Service | 真正的业务流程编排 | 不承担调度器职责 |
Domain Service | 可复用领域规则 | 不感知队列、HTTP、Scheduler |
默认链路:
Scheduler -> Command -> Application Service -> Job / Domain Service
HTTP Request -> Application Service -> Job::dispatch()->afterCommit()
Job -> Application Service / Domain Service
13.2 Job 开发规范
Job 适合处理:耗时任务、第三方接口调用、异步通知、文件处理、报表导出、批量同步、缓存预热、不需要阻塞请求返回的副作用。
推荐目录:
app/Jobs/{领域}/{动作}Job.php
示例:
app/Jobs/Order/SyncOrderToErpJob.php
app/Jobs/Notification/SendSmsJob.php
app/Jobs/File/GenerateExportFileJob.php
app/Jobs/Cache/WarmupCategoryTreeJob.php
Job 标准规则:
- Job 名称必须表达动作和目标,例如
SyncOrderToErpJob,不要使用OrderJob这种泛名。 - 构造参数优先传业务 ID、请求 ID、必要上下文,不传大数组、大文件内容、完整 Eloquent Model。
handle()内必须重新查询最新业务对象,不能依赖序列化时的模型快照。- 第三方调用 Job 必须设置
tries、backoff、timeout。 - 涉及外部副作用的 Job 必须能安全重试。
- 关键 Job 必须记录开始、成功、失败、耗时、业务 ID、request_id。
- Job 失败必须进入
failed_jobs,并被 Horizon / 监控系统发现。
标准示例:
<?php
namespace App\Jobs\Order;
use App\Services\Order\SyncOrderToErpService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SyncOrderToErpJob implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 60;
public function __construct(
public readonly int $orderId,
public readonly ?string $requestId = null,
) {
$this->onQueue('third-party');
}
public function backoff(): array
{
return [60, 300, 900];
}
public function handle(SyncOrderToErpService $service): void
{
$service->handle(
orderId: $this->orderId,
requestId: $this->requestId,
);
}
}
禁止:
// 禁止:Job 中依赖当前请求
$userId = request()->user()->id;
// 禁止:传入大对象或完整请求数据
GenerateExportFileJob::dispatch($request->all());
// 禁止:重试型 Job 内部没有幂等判断
ExternalPaymentCreateJob::dispatch($orderId);
13.3 Job 幂等、唯一性与并发控制
队列重试、Worker 重启、网络超时、人工重跑都可能导致同一个 Job 被重复执行。允许重试的 Job 必须考虑幂等。
常用手段:
| 问题 | 首选方案 |
|---|---|
| 防止相同 Job 重复投递 | ShouldBeUnique |
| 防止相同资源并发执行 | WithoutOverlapping / Cache::lock() |
| 防止业务重复生效 | 业务状态判断 + 唯一索引 + 幂等表 |
| 第三方重复创建资源 | 第三方 idempotency key / 本地同步状态 |
| 回调类任务重复处理 | provider event id 唯一索引 |
ShouldBeUnique 只解决“重复投递”,不保证 handle() 绝不会重复执行;WithoutOverlapping 只解决“并发执行”,不保证业务结果幂等。核心业务仍必须靠数据库约束和业务状态兜底。
示例:
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SyncOrderToErpJob implements ShouldQueue, ShouldBeUnique
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $uniqueFor = 3600;
public function __construct(
public readonly int $orderId,
) {
}
public function uniqueId(): string
{
return 'sync-order-to-erp:' . $this->orderId;
}
}
WithoutOverlapping 示例:
use Illuminate\Queue\Middleware\WithoutOverlapping;
public function middleware(): array
{
return [
(new WithoutOverlapping('sync-order-to-erp:' . $this->orderId))
->releaseAfter(60)
->expireAfter(300),
];
}
高风险 Job 必须具备幂等控制:支付、退款、发券、积分变更、库存扣减、权限变更、API Token 创建、第三方订单同步、Webhook 处理。
13.4 Command 开发规范
Command 适合处理:运维任务、数据修复、批量处理、手工重跑、日志清理、缓存预热、一次性迁移辅助任务。
推荐目录:
app/Console/Commands/{领域}/{动作}Command.php
命名建议:
order:cancel-expired
coupon:expire
report:daily-summary
log:prune-request-logs
cache:warmup-category-tree
Command 标准规则:
$signature必须清晰表达参数和选项。- 高风险写操作必须支持
--dry-run或--pretend。 - 批量任务必须使用
chunkById()、lazyById()、cursor(),禁止一次性加载全量。 - 必须输出处理数量、成功数量、失败数量、跳过数量、耗时。
- 复杂逻辑必须委托 Application Service,Command 只做入口。
- 生产执行前必须确认环境、参数、影响范围。
标准示例:
<?php
namespace App\Console\Commands\Order;
use App\Services\Console\Order\CancelExpiredOrdersService;
use Illuminate\Console\Command;
class CancelExpiredOrdersCommand extends Command
{
protected $signature = 'order:cancel-expired {--dry-run : Preview only, do not write data}';
protected $description = 'Cancel expired unpaid orders.';
public function handle(CancelExpiredOrdersService $service): int
{
$startedAt = microtime(true);
$dryRun = (bool) $this->option('dry-run');
$result = $service->handle(dryRun: $dryRun);
$this->info(sprintf(
'checked=%d canceled=%d skipped=%d failed=%d dry_run=%s duration_ms=%d',
$result->checked,
$result->canceled,
$result->skipped,
$result->failed,
$dryRun ? 'yes' : 'no',
(int) ((microtime(true) - $startedAt) * 1000),
));
return self::SUCCESS;
}
}
13.5 Scheduler 开发规范
Laravel Scheduler 只负责调度入口。任务逻辑必须放在 Command、Job 或 Application Service 中。
推荐写法:
use Illuminate\Support\Facades\Schedule;
Schedule::command('order:cancel-expired')
->everyTenMinutes()
->withoutOverlapping()
->onOneServer()
->name('order:cancel-expired');
规则:
- 不在
routes/console.php闭包中写复杂业务。 - 每个关键任务必须有稳定
name(),便于 Schedule Monitor 识别。 - 同一任务不得并发重入;单机用
withoutOverlapping(),多机配合onOneServer()和共享缓存。 - 长任务不要直接阻塞 Scheduler,应调度 Job,由 Horizon 监控。
- 定时任务必须记录执行结果,失败不得静默吞掉异常。
- 关键任务必须接入监控:
schedule:list、Schedule Monitor、Uptime Kuma Push Monitor 至少三者之一可追踪。
13.6 日志通道与运行时上下文
日志不应全部混入 laravel.log。建议至少拆分以下通道:
| 通道 | 用途 |
|---|---|
request | HTTP 请求摘要日志 |
security | 认证失败、签名失败、权限异常、风控 |
audit | 业务操作审计相关异常 |
queue | 队列任务运行与失败上下文 |
schedule | 定时任务执行摘要 |
third_party | 第三方接口请求、响应摘要、重试失败 |
payment | 支付、退款、回调、资金链路 |
sql_audit | SQL 审计采集异常和辅助日志 |
统一运行时上下文:
request_idtrace_idactor_idactor_typeroute_nameui_node_idipuser_agent
HTTP 请求中的上下文由 Middleware 采集;异步 Job 来源于 HTTP 请求时,应显式传入 request_id。
SyncOrderToErpJob::dispatch(
orderId: $order->id,
requestId: request()->attributes->get('request_id'),
)->afterCommit();
13.7 本章检查清单
- Job 是否只传轻量参数。
- Job 是否能安全重试。
- 第三方调用是否设置
tries / backoff / timeout。 - 高风险 Job 是否有业务幂等兜底。
- Command 是否支持 dry-run。
- 批处理是否使用 chunk / cursor。
- Scheduler 是否只负责调度,不写复杂业务闭包。
- 关键定时任务是否接入监控。
- 日志是否包含
request_id,敏感字段是否脱敏。
14. 文件上传与存储规范
14.1 本章定位
文件上传规范用于约束上传文件从接收、校验、命名、存储、访问、业务绑定、清理、备份到迁移的完整链路。
文件不是普通字段。生产事故中,上传文件丢失、私有文件误公开、发布覆盖上传目录、路径穿越、MIME 伪造、孤儿文件膨胀,都会造成真实损失。
14.2 文件分类
上传文件必须先分类,再决定存储盘、访问方式和清理策略。
| 类型 | 示例 | 存储建议 | 访问方式 |
|---|---|---|---|
| 公开文件 | 商品图、头像、公开附件 | public uploads / OSS public bucket | 静态 URL / CDN |
| 私有文件 | 合同、凭证、导入源文件、发票 | private disk / OSS private bucket | 鉴权下载 / 临时签名 URL |
| 临时文件 | 分片、待提交文件、导入中间文件 | tmp disk | 不直接访问,定时清理 |
| 可再生成文件 | 缩略图、导出文件 | generated / exports | 可过期清理 |
| 不可丢失业务文件 | 合同、付款凭证、售后证据 | private + 备份 | 严格鉴权 |
14.3 单机项目存储目录
普通单机后台项目,推荐把上传资源放在项目目录之外,避免发布、回滚、重新拉代码时误删。
推荐:
/var/www/hitek-api/current # 当前代码目录
/var/www/hitek-api/releases # release 历史目录,可选
/data/hitek/uploads # 公开上传资源
/data/hitek/private # 私有业务文件
/data/hitek/tmp # 临时上传文件
/data/hitek/exports # 导出文件
不推荐:
/var/www/hitek-api/current/public/uploads
原因:代码目录切换、覆盖部署、误删 public,都可能影响业务文件。
14.4 Laravel Filesystem 配置
config/filesystems.php 推荐增加独立 disk:
'uploads' => [
'driver' => 'local',
'root' => env('UPLOADS_ROOT', '/data/hitek/uploads'),
'url' => env('UPLOADS_URL', 'https://res.example.com/uploads'),
'visibility' => 'public',
'throw' => false,
],
'private' => [
'driver' => 'local',
'root' => env('PRIVATE_FILES_ROOT', '/data/hitek/private'),
'visibility' => 'private',
'throw' => false,
],
'tmp' => [
'driver' => 'local',
'root' => env('TMP_FILES_ROOT', '/data/hitek/tmp'),
'throw' => false,
],
.env:
UPLOADS_ROOT=/data/hitek/uploads
UPLOADS_URL=https://res.example.com/uploads
PRIVATE_FILES_ROOT=/data/hitek/private
TMP_FILES_ROOT=/data/hitek/tmp
规则:
- 不同类型文件使用不同 disk。
- 私有文件不得存入公开 disk。
- 生产目录必须有备份策略。
- 多机部署时,本地目录必须替换为共享存储或对象存储。
14.5 上传校验
上传校验必须同时覆盖大小、MIME、扩展名、业务类型、数量、图片尺寸。
FormRequest 示例:
public function rules(): array
{
return [
'image' => [
'required',
'file',
'max:5120',
'mimetypes:image/jpeg,image/png,image/webp',
'extensions:jpg,jpeg,png,webp',
'dimensions:max_width=4000,max_height=4000',
],
];
}
规则:
- 不能只校验扩展名。
- 不能信任客户端传入的
Content-Type。 - 文件名、MIME、扩展名必须经过服务端白名单判断。
- 图片建议重新编码或至少读取图片元数据,降低伪造风险。
- Excel / CSV 导入必须限制行数、列数、文件大小、编码。
14.6 文件命名与路径规则
真实存储名不得使用用户原始文件名。
推荐结构:
{domain}/{yyyy}/{mm}/{dd}/{hash_prefix}/{ulid}_{sha256_short}.{ext}
示例:
product/2026/04/29/ab/01HWZ7N8K2G6Z5G1M5R9T8P2Q1_ab34cd56.jpg
规则:
- 原始文件名只保存为展示字段
original_name。 - 真实文件名使用 ULID / UUID + hash 短值。
- hash 建议来自文件内容
sha256_file(),可用于去重、追踪、完整性校验。 - 扩展名必须由服务端白名单决定。
- 禁止路径中出现用户可控的上级目录跳转、绝对路径、反斜杠。
- 路径只保存相对路径,不保存本机绝对路径。
14.7 文件记录表
重要业务文件建议有文件记录表,避免只有路径字符串散落在业务表中。
推荐字段:
id
biz_type
biz_id
disk
path
url
original_name
mime_type
extension
size_bytes
sha256
visibility
uploaded_by
status
created_at
updated_at
状态建议:
temporary -> attached -> deleted
规则:
- 业务提交成功后,临时文件才转为 attached。
- 私有文件下载必须基于文件记录做权限判断。
- 删除业务对象不一定立即物理删除文件;重要文件建议延迟清理或软删除。
- 文件表本身也应纳入审计和备份。
14.8 访问方式
公开文件:
- 本地单机可用 Nginx
alias指向/data/hitek/uploads。 - 可接 CDN 或独立资源域名。
- 不包含敏感信息。
私有文件:
- 不暴露静态目录。
- 通过鉴权接口下载。
- 对象存储场景使用短期签名 URL。
- 下载接口必须检查操作者权限、业务归属和文件状态。
Laravel 下载示例:
return Storage::disk('private')->download(
$file->path,
$file->original_name,
);
14.9 清理策略
必须清理:
- 过期临时文件。
- 上传成功但业务未提交的孤儿文件。
- 过期导出文件。
- 失败分片。
- 可重新生成的缩略图缓存。
清理任务要求:
- 支持 dry-run。
- 分批删除,不一次性扫描全量。
- 记录删除数量、失败数量、耗时。
- 重要文件不做硬删除,优先标记 deleted 或移入回收区。
示例命令:
php artisan file:prune-temp --dry-run
php artisan file:prune-temp
14.10 文件上传检查清单
- 上传目录是否在代码目录之外。
- 公开文件和私有文件是否分 disk。
- 是否限制 MIME、扩展名、大小、尺寸。
- 是否使用服务端生成文件名。
- 是否保存原始文件名、大小、MIME、hash。
- 私有文件是否只能通过鉴权接口访问。
- 临时文件和导出文件是否有清理任务。
- 上传目录是否纳入备份。
- 多机部署是否使用共享存储或对象存储。
15. 日志、审计与观测规范
15.1 本章定位
日志、审计与观测解决三类问题:
请求日志:这次 HTTP 请求发生了什么。
业务操作审计:谁在什么入口执行了什么业务动作。
SQL 执行审计:这次请求执行了哪些写入类 SQL。
三者通过 request_id 关联,trace_id 作为未来接入 OpenTelemetry、APM、网关追踪时的扩展字段。
15.2 三类日志边界
| 类型 | 表 / 通道 | 主要用途 | 不负责 |
|---|---|---|---|
| 请求日志 | request_logs / request | 请求摘要、排障、接口链路 | 不回答业务对象怎么变了 |
| 业务操作审计 | audit_logs / audit | 谁做了什么业务动作 | 不做字段级 diff |
| SQL 执行审计 | audit_sql_logs / sql_audit | 写入类 SQL 兜底追踪 | 不保证 old/new values |
禁止用 request log 替代 audit log,也禁止用 SQL 审计替代业务操作审计。
15.3 request_id 与 trace_id
每个 HTTP 请求必须有 request_id。来源优先级:
X-Request-Id Header -> Request-Id Header -> 后端生成 UUID
要求:
- 写入 request attributes。
- 写入
Log::shareContext()。 - 写入响应头
X-Request-Id。 - 业务审计、SQL 审计、第三方日志尽量带上同一个
request_id。
简化示例:
$requestId = $request->headers->get('X-Request-Id')
?: $request->headers->get('Request-Id')
?: (string) Str::uuid();
$request->attributes->set('request_id', $requestId);
Log::shareContext(['request_id' => $requestId]);
$response = $next($request);
$response->headers->set('X-Request-Id', $requestId);
return $response;
15.4 日志通道规范
建议 config/logging.php 至少定义:
| 通道 | 用途 |
|---|---|
request | HTTP 请求摘要 |
security | 安全、认证、权限、签名、风控 |
audit | 业务操作审计异常 |
queue | 队列运行与失败 |
schedule | 定时任务运行 |
third_party | 第三方接口摘要 |
payment | 支付、退款、回调 |
sql_audit | SQL 审计采集异常 |
日志字段推荐:
request_idtrace_iduser_idroute_namejob_idcommandproviderbusiness_idduration_msresultexception_classexception_message
禁止记录:明文密码、明文 token、明文 secret、完整银行卡、未脱敏 Cookie、未脱敏 Authorization Header、大文件内容、无限制完整响应体。
15.5 请求日志规范
请求日志只记录摘要,不记录完整敏感报文。
推荐字段:
request_id, trace_id, method, path, full_url, route_name, controller_action,
status_code, duration_ms, ip, user_id, user_type,
request_headers, request_body, response_preview,
exception_class, is_exception, logged_at
触发策略:
- 默认记录写请求:
POST / PUT / PATCH / DELETE。 - 默认记录异常请求:状态码
>= 400。 - 默认记录慢请求:超过阈值。
- 默认记录关键接口:登录、支付、回调、权限变更、敏感配置。
- 忽略健康检查、调试面板、静态资源。
脱敏字段至少包括:
authorization, cookie, password, password_confirmation,
old_password, new_password, token, access_token,
refresh_token, secret, sign
规则:
- Header、请求体、响应摘要必须脱敏和截断。
- 文件上传不得记录文件实体内容。
- 写库失败不得影响主请求。
- 后台“请求中心”应查询结构化
request_logs,不要直接解析本地日志文件。
15.6 业务操作审计规范
业务操作审计记录业务语义,不记录字段 diff。
默认规则:
一次业务动作 = 一条业务操作审计主日志
即使该动作内部影响多张表,也不自动拆成多条主审计日志。
核心字段:
event_key, event_label, result,
actor_id, actor_type, actor_name_snapshot,
ui_node_id, ui_node_key, ui_node_type, ui_path_snapshot,
page_label_snapshot, trigger_label_snapshot,
subject_type, subject_id, subject_name_snapshot,
summary, reason,
request_id, trace_id, route_name, ip, user_agent,
properties, tags, audited_at
前端通过 Header 传 UI 上下文:
X-Ui-Node-Id: 1001
规则:
X-Ui-Node-Id只用于审计语义补全,不作为权限依据。- 关键敏感操作必须审计:权限变更、角色分配、退款审核、财务操作、配置变更、导出敏感数据、强制状态变更。
- 审计失败应记录
security或audit日志;默认不阻断已经成功的主业务。 - 审计主对象只保留一个
subject,字段级变化留给未来数据审计。
15.7 SQL 执行审计规范
SQL 执行审计是数据变化审计的低成本兜底方案,只记录写入类 SQL。
默认记录:
insert, update, delete, replace, truncate, alter, drop, create
默认不记录:
select, show, describe, explain
推荐字段:
request_id, trace_id, connection, sql_type, table_names,
sql_template, bindings, duration_ms, route_name,
actor_id, ui_node_id, executed_at
规则:
- SQL 审计不替代业务操作审计。
- SQL 审计不承诺字段级 old/new diff。
- 绑定参数必须脱敏、截断。
- 必须有存储上限和清理周期。
- 只作为排障和兜底追踪,不作为精确数据审计结论。
15.8 监控与告警
生产必须至少监控:
- 应用健康检查。
- HTTP 5xx 与异常率。
- 慢请求。
- 队列积压与 failed_jobs。
- 定时任务失败或漏跑。
- 磁盘空间。
- 数据库连接、慢 SQL、备份状态。
- Redis 内存、连接数、队列长度。
- 证书过期时间。
- 备份成功率。
普通项目推荐组合:
| 工具 | 负责内容 |
|---|---|
| Uptime Kuma | HTTP / TCP / Cron 心跳外部监控 |
| Horizon | Laravel 队列监控 |
| Spatie Laravel Schedule Monitor | Scheduler 任务执行状态 |
| Laravel 日志通道 | 本地兜底排障 |
| 数据库备份脚本 / 云备份 | 数据恢复能力 |
15.9 本章检查清单
- 每个请求是否有
request_id。 - request log、audit log、sql audit 是否职责分离。
- 敏感字段是否脱敏。
- 请求日志是否避免记录完整响应体和文件内容。
- 敏感业务动作是否进入业务审计。
- SQL 审计是否只记录写入类 SQL。
- request_logs / audit_sql_logs 是否有清理策略。
- 关键队列、定时任务、健康检查是否有告警。
16. API 版本与兼容策略
16.1 本章定位
API 版本规范用于保证多端、后台、开放接口、第三方调用方在长期演进中不被破坏性变更直接影响。
只要接口被多个端稳定依赖,或对外开放给第三方,就必须纳入版本策略。
16.2 默认版本策略
推荐使用路径版本:
/api/v1/admin/orders
/api/v1/client/orders
/api/v1/open/orders
路由组织:
Route::prefix('v1/admin')
->middleware(['auth:sanctum', 'permission'])
->group(base_path('routes/v1/admin.php'));
Route::prefix('v1/open')
->middleware(['open-api.sign'])
->group(base_path('routes/v1/open.php'));
规则:
- 后台、客户端、Open API 可分别版本化。
- 内部短期接口可不强制版本化,但被多端稳定依赖后必须纳入版本管理。
- 新版本不等于复制所有接口;只复制发生破坏性变化的模块。
- 同一版本内必须保持响应结构、错误码、状态值稳定。
16.3 兼容性变更
兼容变更:
- 新增可选请求字段。
- 新增可选查询参数。
- 新增响应字段。
- 新增
meta信息。 - 新增枚举值,但旧调用方可忽略。
- 错误消息文案调整,但
code / error_key不变。
破坏性变更:
- 删除字段。
- 修改字段类型。
- 修改字段含义。
- 修改状态数字值或
status_key。 - 修改业务错误码或
error_key。 - 修改分页结构。
- 修改鉴权方式。
- 修改默认排序且影响调用方业务结果。
- 将可空字段改为必填。
破坏性变更必须走新版本或明确迁移期。
16.4 废弃策略
接口废弃必须提供:
- 废弃原因。
- 替代接口。
- 迁移说明。
- 影响范围。
- 停用时间。
- 联系人或负责团队。
对外 API 建议通过文档、响应 Header 或后台公告提示废弃状态。
示例 Header:
Deprecation: true
Sunset: Wed, 29 Jul 2026 00:00:00 GMT
Link: </api/v2/open/orders>; rel="successor-version"
16.5 接口合同管理
以下内容属于接口合同,变更必须评审:
- URL 与 HTTP method。
- 请求参数名、类型、是否必填。
- 响应字段名、类型、语义。
- 分页结构。
- 状态值、
status_key、can_xxx。 - 错误码、
error_key、HTTP status。 - 权限要求。
- 幂等键要求。
规则:
- 新增 Open API 必须同步接口文档和错误码文档。
- 新增状态值必须同步前端常量、测试和接口文档。
- 对外接口的破坏性变更必须给迁移期。
16.6 API 文档生成与管理规范
API 文档是接口合同的一部分,不是上线后的补充说明。
本规范只要求维护机器可读的 OpenAPI JSON,不强制维护人工可视化文档、Postman 文档或额外 Markdown 接口说明。
默认规则:
代码是实现来源
OpenAPI JSON 是接口合同产物
BusinessError 是错误码事实来源
API changelog 是破坏性变更记录
16.6.1 默认工具
Laravel 项目默认使用 dedoc/scramble 生成 OpenAPI 文档。
安装:
composer require dedoc/scramble --dev
发布配置:
php artisan vendor:publish --provider="Dedoc\Scramble\ScrambleServiceProvider" --tag="scramble-config"
导出 OpenAPI 文件:
php artisan scramble:export --path=docs/api/openapi.json
多版本或多端口项目可按端口和版本导出:
php artisan scramble:export --api=v1 --path=docs/api/open/v1/openapi.json
php artisan scramble:export --api=admin-v1 --path=docs/api/admin/v1/openapi.json
规范要求:
- OpenAPI JSON 必须由代码生成,不得长期手写维护。
- OpenAPI JSON 必须提交到仓库。
- OpenAPI JSON 是 AI、前端、测试、Mock、接口平台读取接口合同的默认入口。
- 不引入第二套 API 文档生成工具作为主事实源。
- 不为了可视化展示额外维护一套与 OpenAPI 不一致的接口文档。
16.6.2 文档产物目录
推荐目录:
docs/
└── api/
├── openapi.json
├── error-codes.md
├── changelog.md
├── open/
│ └── v1/
│ ├── openapi.json
│ └── changelog.md
└── admin/
└── v1/
├── openapi.json
└── changelog.md
规则:
- 单版本项目可只维护
docs/api/openapi.json。 - Open API 必须按版本维护独立 OpenAPI 文件。
- Admin API 可只维护内部 OpenAPI 文件,不要求生产公开。
- 错误码文档统一放在
docs/api/error-codes.md。 - 破坏性变更记录统一放在对应版本的
changelog.md。
16.6.3 文档内容要求
每个接口至少应能从 OpenAPI 中表达:
- 所属端口,例如
admin、open、store、client。 - API version。
- HTTP method。
- URL。
- 路由名。
- 认证方式。
- 请求 Header。
- Path / Query / Body 参数。
- 参数类型、必填、枚举值和边界。
- 成功响应结构。
- 错误响应结构。
- 分页结构。
- 状态字段。
- 业务错误码。
- deprecated 字段或 deprecated 接口。
统一成功响应必须体现:
{
"code": 0,
"message": "success",
"data": {},
"meta": {},
"links": {}
}
统一错误响应必须体现:
{
"code": 120101,
"error_key": "ORDER_STATUS_NOT_CANCELABLE",
"message": "Order status does not allow cancellation.",
"errors": {},
"meta": {}
}
16.6.4 代码与文档的关系
API 文档应从以下代码结构生成或推导:
Route
FormRequest
Enum
Resource / Output Data
ApiResponder
BusinessError
PHPDoc / OpenAPI 注解补充
规范要求:
- 请求参数必须通过 FormRequest 明确类型、必填、枚举和边界。
- 响应字段必须通过 Resource / Output Data 明确结构。
- 状态字段必须输出
status / status_key / status_label。 - 复杂动作权限必须输出
can_xxx。 - 业务错误码必须来自统一
BusinessError定义。 - 文档中不得出现代码中不存在的字段。
- 代码中新增对外字段,必须同步生成 OpenAPI 产物。
- 不得直接修改 OpenAPI JSON 来掩盖代码与文档不一致。
16.6.5 AI 生成代码约束
当 AI 新增或修改 API 时,必须同步维护接口合同。
AI 必须执行:
- 新增路由后,确保该路由能被 OpenAPI 生成工具识别。
- 新增 FormRequest 后,补齐参数类型、枚举、范围和必填规则。
- 新增 Resource 字段后,确保响应结构同步进入 OpenAPI。
- 新增状态字段后,同步输出
status / status_key / status_label。 - 新增业务错误码后,同步更新
docs/api/error-codes.md。 - 修改字段名、字段类型、枚举值、分页结构、认证方式或错误码后,同步更新 OpenAPI 产物。
- 破坏性变更必须同步更新对应
changelog.md。
AI 禁止:
- 只改 Controller / Service,不更新 OpenAPI 产物。
- 手写一份与代码不一致的接口文档。
- 只维护成功响应,不维护错误响应。
- 新增错误码但不更新错误码文档。
- 删除字段、改字段类型、改枚举值但不按 API 兼容策略处理。
- 直接编辑 OpenAPI JSON 冒充文档已同步。
16.6.6 CI 检查要求
API 文档生成必须进入 CI 或发布前检查。
推荐命令:
php artisan route:list
php artisan scramble:export --path=docs/api/openapi.json
git diff --exit-code docs/api/openapi.json
多版本项目应检查对应版本文件:
php artisan scramble:export --api=v1 --path=docs/api/open/v1/openapi.json
git diff --exit-code docs/api/open/v1/openapi.json
规范要求:
- 对外 API 变更后,OpenAPI 产物必须同步提交。
- CI 发现 OpenAPI 产物未更新,应阻断合并。
- 关键接口必须有响应结构 Feature Test。
- Open API 必须覆盖认证失败、权限失败、参数错误、业务错误响应结构。
- 只写
assertOk()不视为接口合同测试。
16.6.7 访问控制
本规范不要求生产环境公开 API 文档页面。
规则:
- 本地和 CI 只需要能生成 OpenAPI JSON。
- 测试环境如开放
/docs/api,必须限制内网、VPN 或受控账号。 - 生产环境默认不公开后台 API 文档。
- Open API 文档如需对外公开,必须确认不包含内部接口、测试接口、敏感 Header 示例、真实 token、真实客户数据。
- 文档页面不得暴露生产密钥、签名 secret、Authorization token 或真实业务数据。
禁止:
- 对外 API 无 OpenAPI 合同上线。
- API 文档长期手写且不校验。
- 接口代码和 OpenAPI 产物不一致。
- 生产公开包含后台接口或内部字段的文档。
- 新增错误码但不更新错误码文档。
16.7 本章检查清单
- 多端稳定依赖接口是否有版本号。
- Open API 是否有独立版本路径。
- 破坏性变更是否走新版本或迁移期。
- 状态值、错误码、分页结构是否保持兼容。
- 废弃接口是否有替代方案和停用时间。
- OpenAPI JSON 是否已重新生成并提交。
- CI 是否检查 OpenAPI 产物漂移。
- 新增错误码是否同步更新
docs/api/error-codes.md。
17. 生产上线与运行规范
17.1 本章定位
本章约束 Laravel 项目从发布到稳定运行所需的生产基线:发布、回滚、环境变量、数据库迁移、队列、定时任务、备份、文件存储、Nginx / PHP-FPM、健康检查、监控告警和上线清单。
生产规范不替代前文的架构、安全、权限、审计和测试规范,而是在这些基础上保证项目可发布、可回滚、可监控、可恢复。
17.2 发布方式选择
普通单机项目默认使用:
git pull + composer install + artisan cache + migrate + queue restart + health check
适用:中小型后台 / API、单台服务器、发布频率不高、暂无独立 CI/CD 平台。
更稳的项目推荐 release 软链接发布:
/var/www/app/releases/20260429180000
/var/www/app/shared/.env
/var/www/app/shared/storage
/var/www/app/current -> /var/www/app/releases/20260429180000
适用:需要快速回滚、多人协作、发布频率较高、上传文件在 shared 目录或外部存储。
17.3 普通单机发布流程
推荐命令:
set -euo pipefail
git pull --ff-only
composer install --no-dev --prefer-dist --optimize-autoloader
php artisan down --render="errors::503" || true
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
php artisan queue:restart
php artisan horizon:terminate || true
php artisan up
curl -fsS https://example.com/up
规则:
- 发布脚本必须
set -e,失败立即中断。 composer.lock必须提交。- 生产不得安装 dev 依赖。
- 发布后必须做健康检查和关键接口冒烟。
- 回滚代码不等于回滚数据库;涉及 migration 的发布必须提前设计兼容策略。
17.4 环境变量基线
生产必须明确:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com
LOG_CHANNEL=stack
CACHE_STORE=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
SESSION_SECURE_COOKIE=true
规则:
- 生产禁止
APP_DEBUG=true。 .env不得提交 Git。- 密钥类配置不得出现在日志、异常响应、前端构建产物中。
- 多环境必须区分数据库、Redis、队列、缓存前缀、第三方回调地址。
- Open API signing secret、JWT secret、支付密钥必须安全保管。
17.5 数据库迁移与回滚
migration 生产规则:
- 高风险 migration 发布前必须备份。
- 大表变更必须评估锁表风险。
- 字段删除、字段改名、类型缩窄属于高风险操作。
- 新字段优先 nullable 或有默认值,避免旧代码写入失败。
- 破坏性数据库变更必须拆成多阶段发布。
推荐扩展字段流程:
第 1 次发布:新增 nullable 字段,新旧代码兼容
第 2 次发布:代码开始写入新字段
第 3 次发布:回填历史数据
第 4 次发布:确认无旧代码依赖后再加 not null / 删除旧字段
发布前预检:
php artisan migrate:status
php artisan db:show
禁止:
- 未备份直接改大表结构。
- 发布时直接删除生产字段。
- migration 中写不可控的大批量数据修复。
- 依赖
migrate:rollback作为唯一回滚方案。
17.6 队列生产运行
推荐使用 Redis 队列 + Horizon。
生产要求:
- Horizon 必须由 Supervisor / systemd 守护。
- 发布后使用
php artisan horizon:terminate平滑重启。 - 普通 queue worker 发布后使用
php artisan queue:restart。 - 必须监控队列等待时间、失败数、重试数、进程存活。
- 不同任务按队列隔离:
default、emails、third-party、exports、logs、payment。
Supervisor 示例见附录 J。
17.7 定时任务生产运行
服务器 cron 只配置一个 Laravel Scheduler 入口:
* * * * * cd /path/to/project && php artisan schedule:run >> /dev/null 2>&1
推荐组合:
Laravel Scheduler + 系统 cron + Spatie Laravel Schedule Monitor + 自建 Uptime Kuma
规则:
- 任务定义写在代码中,不在服务器维护零散 cron。
schedule:list必须能看到任务。schedule-monitor:list必须能看到被监控任务。- Scheduler 整体心跳和关键任务心跳必须分开。
- 长任务必须 Job 化,并由 Horizon 监控执行状态。
- 关键任务上线前必须验证告警渠道。
17.8 备份与恢复
生产至少备份:
| 类型 | 要求 |
|---|---|
| MySQL 数据库 | 必须,建议每日全量 + 发布前临时备份 |
| 上传公开文件 | 必须 |
| 上传私有文件 | 必须 |
.env / 密钥 | 必须安全保管,不和普通备份混放 |
| 对象存储 | 必须开启版本或跨区域备份,按业务风险决定 |
| 审计日志 | 按合规和排障周期保留 |
不需要备份:vendor/、node_modules/、Laravel cache、临时文件、可重新生成的前端产物。
规则:
- 只备份不验证等于没有备份。
- 至少每季度做一次恢复演练。
- 备份文件必须加密或放在安全位置。
- 数据库和上传文件的备份时间点要尽量可对齐。
- 删除备份必须有保留周期策略。
17.9 Nginx / PHP-FPM / HTTPS 基线
Nginx 必须:
- 强制 HTTPS。
- 禁止访问
.env、.git、storage、vendor、composer.json、composer.lock。 - 限制
client_max_body_size。 - 配置访问日志、错误日志。
- 上传公开目录使用
alias时必须确认不会暴露私有目录。 - SPA 或 API 网关根据项目需要配置 CORS 和 OPTIONS。
PHP-FPM 必须:
- 设置合理
pm.max_children。 - 设置
request_terminate_timeout。 - 根据上传需求配置
upload_max_filesize、post_max_size。 - 生产关闭错误展示,错误写日志。
17.10 健康检查与冒烟测试
Laravel 默认 /up 可作为基础存活检查。生产建议扩展只读健康检查:
- 应用存活。
- 数据库连接。
- Redis 连接。
- 队列基础状态。
- 磁盘空间。
健康检查禁止泄露敏感配置、数据库名称、密钥、内部网络细节。
发布后冒烟测试至少覆盖:
- 登录。
- 当前用户信息。
- 一个列表接口。
- 一个详情接口。
- 一个低风险写接口。
- 队列是否消费。
- 定时任务是否可见。
/up是否正常。
17.11 上线前检查清单
代码检查:
- 当前分支和 commit hash 已确认。
- 工作区无未提交变更。
-
composer.lock已提交。 -
.env未提交。 - Pint 通过。
- Test 通过。
- 关键 API 合同测试通过。
- 错误码唯一性测试通过。
配置检查:
-
APP_ENV=production。 -
APP_DEBUG=false。 - 生产数据库、Redis、Mail、SMS、Payment 配置正确。
- Cookie、CORS、Session 配置符合前端部署方式。
- Open API 密钥未泄露。
数据库检查:
-
migrate:status已确认。 - 高风险 migration 已备份。
- 大表变更已评估。
- 回滚或补救方案明确。
运行检查:
- Horizon / queue worker 正常。
- Scheduler cron 已配置。
- 关键定时任务已接入监控。
- 日志目录可写。
- 上传目录可写且已纳入备份。
- 健康检查通过。
18. 测试规范与工程门禁
18.1 本章定位
测试与工程门禁用于保证规范不是纸面约束,而是在提交、合并、发布前被自动或半自动验证。
第一版不追求 100% 覆盖率,优先覆盖高风险业务和长期容易回归的系统边界。
18.2 测试目录
推荐结构:
tests/
├── Feature/
│ ├── Admin/
│ ├── Client/
│ └── Open/
├── Unit/
│ ├── Domain/
│ ├── Support/
│ └── Services/
└── TestCase.php
规则:
- Feature Test 覆盖 HTTP 入口、认证权限、响应合同、关键业务流程。
- Unit Test 覆盖状态机、错误码、Domain Service、纯规则判断。
- Open API 必须覆盖签名、时间戳、nonce、幂等、错误响应。
- 队列和定时任务至少覆盖核心 Service,不强制真实跑完整 Worker。
18.3 优先测试范围
必须优先覆盖:
- 状态机允许 / 禁止流转。
- 资金、库存、优惠券、权限、结算相关流程。
- 支付 / 退款 / Webhook 幂等。
- 权限与对象级授权。
- 统一响应结构。
- 业务错误码唯一性。
- 关键异常响应合同。
- 查询排序白名单。
- 导出与列表查询条件一致性。
- 请求日志、业务审计关键链路。
不建议第一版过度投入:
- 简单 getter / setter。
- Laravel 框架自身行为。
- 无业务规则的简单 CRUD 全量测试。
- 低价值 UI 文案字段。
18.4 Feature Test 标准
接口测试至少验证:
- HTTP status。
- 顶层响应结构
code / message / data / meta或错误结构。 - 权限未通过时的错误码。
- 参数非法时的错误结构。
- 成功后数据库状态。
- 敏感接口是否写入审计。
示例:
public function test_admin_can_cancel_pending_order(): void
{
$admin = AdminUser::factory()->create();
$order = Order::factory()->pendingPay()->create();
$this->actingAs($admin, 'admin')
->postJson("/api/v1/admin/orders/{$order->id}/cancel", [
'reason' => 'customer request',
])
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.status_key', 'canceled');
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'status' => OrderStatus::Canceled->value,
]);
}
18.5 Unit Test 标准
状态机和纯领域规则应优先写 Unit Test。
示例:
public function test_paid_order_can_apply_refund(): void
{
$service = new OrderStatusTransitionService();
$this->assertTrue(
$service->canTransition(OrderStatus::Paid, OrderStatus::Refunding)
);
}
public function test_canceled_order_can_not_apply_refund(): void
{
$service = new OrderStatusTransitionService();
$this->assertFalse(
$service->canTransition(OrderStatus::Canceled, OrderStatus::Refunding)
);
}
18.6 错误码测试
业务错误码必须纳入工程门禁。
必须验证:
code全局唯一。error_key全局唯一。code / error_key / defaultMessage / httpStatus不为空。- 关键异常能渲染为统一错误响应。
18.7 CI 规范
CI 是持续集成,不等同自动部署。第一版推荐在 Pull Request 和 push 到 main / develop 时触发。
基础命令:
composer install --prefer-dist --no-interaction
cp .env.example .env
php artisan key:generate
vendor/bin/pint --test
php artisan test
如果项目使用 PHPStan / Larastan:
vendor/bin/phpstan analyse
如果项目有前端:
pnpm install --frozen-lockfile
pnpm lint
pnpm test
pnpm build
18.8 GitHub Actions 示例
name: CI
on:
pull_request:
push:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_DATABASE: testing
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot"
--health-interval=10s
--health-timeout=5s
--health-retries=5
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
extensions: mbstring, intl, pdo_mysql, redis, fileinfo
coverage: none
- uses: actions/cache@v4
with:
path: vendor
key: composer-${{ hashFiles('composer.lock') }}
- run: composer install --prefer-dist --no-interaction --no-progress
- run: cp .env.example .env
- run: php artisan key:generate
- run: vendor/bin/pint --test
- run: php artisan test
18.9 合并门禁
合并前必须满足:
- Pint 通过。
- Test 通过。
- 新增业务错误码无重复。
- 新增核心状态流转有测试。
- 新增权限路由已绑定权限。
- 新增敏感操作已接入审计。
- 新增队列 / 定时任务有失败处理和监控方案。
- 新增 Open API 已覆盖签名和错误响应。
- 新增 migration 已评估回滚与兼容。
18.10 发布门禁
发布前必须满足:
- CI 通过。
- 发布分支、commit hash 已记录。
- 数据库备份已完成或确认无需备份。
- 环境变量检查通过。
- 队列、定时任务、上传目录、日志目录检查通过。
- 健康检查通过。
- 冒烟测试通过。
- 回滚方案明确。
19. 命名与编码约束
19.1 通用代码风格基线
新项目默认遵守以下代码风格基线:
- PHP 代码风格以 PSR-12 为基线。
- PHP 基础编码标准随 PSR-12 一并遵守。
- PHP 自动加载、namespace 与文件路径映射必须遵守 PSR-4。
- Laravel 项目统一使用 Laravel Pint 做代码格式化。
- 默认使用 Pint 的
laravelpreset。 - 若团队决定使用
psr12preset,必须在项目初始化时统一确定,不得在同一项目内混用。 - 所有提交到主分支的 PHP 代码必须通过
./vendor/bin/pint --test。 - 所有新增类必须满足 Composer PSR-4 自动加载规则。
推荐 pint.json:
{
"preset": "laravel"
}
规范要求:
- 不得手工维护与 namespace 不一致的文件路径。
- 不得在一个 PHP 文件中定义多个业务类。
- 不得通过
require/include手工加载项目内业务类。 - 不得绕过 Pint 提交未经格式化的 PHP 代码。
19.2 命名总原则
命名必须表达业务语义和分层职责,不为“显得架构完整”制造抽象。
默认规则:
- 目录表达边界,类名表达职责,方法名表达动作,变量名表达业务含义。
- 同类能力使用同一套后缀,不在项目内混用多种命名体系。
- 稳定接口字段、状态 key、错误 key、权限 key 一旦对外使用,即视为接口合同。
- 禁止使用
Manager、Helper、Util、CommonService、BaseService承载核心业务流程。 - 确需使用工具类时,只能用于无业务状态、无业务流程、无持久化副作用的基础能力。
19.3 PHP 类、文件与目录命名
PHP 类名、文件名、namespace 必须保持一致。
推荐:
app/Services/Admin/Order/CancelOrderService.php
App\Services\Admin\Order\CancelOrderService
规则:
- Class / Interface / Trait / Enum 使用
PascalCase。 - PHP 文件名必须与类名完全一致。
- namespace 必须与目录结构一致。
- 一个 PHP 文件默认只定义一个业务类。
- 抽象类使用
Abstract前缀,例如AbstractPaymentGateway。 - Trait 使用能力描述命名,例如
InteractsWithOperationAudit。 - Interface 使用业务能力命名,例如
PaymentGateway,不强制使用Interface后缀。
架构类命名必须表达职责:
| 类型 | 命名规则 | 推荐目录 | 示例 |
|---|---|---|---|
| Controller | {Module}Controller | app/Http/Controllers/{端}/{模块} | OrderController |
| FormRequest | {Action}{Module}Request | app/Http/Requests/{端}/{模块} | CreateOrderRequest |
| Application Service | {Action}{Module}Service | app/Services/{端}/{模块} | CancelOrderService |
| Domain Service | {Domain}{Capability}Service | app/Domain/{领域} | OrderStatusTransitionService |
| State Machine | {Domain}StateMachine 或 {Domain}StatusTransitionService | app/Domain/{领域} | OrderStatusTransitionService |
| Query Object | {Module}ListQuery / {Module}Query | app/Queries/{领域} 或 app/Repositories/{领域} | OrderListQuery |
| Resource | {Module}Resource / {Module}ListResource / {Page}Resource | app/Http/Resources/{端}/{模块} | OrderResource |
| Input Data | {Action}{Module}Data | app/Data/{领域} | CreateOrderData |
| Query Data | {Module}ListQueryData | app/Data/{领域} | OrderListQueryData |
| Output Data | {Module}{Scene}Data | app/Data/{领域} | DashboardPageData |
| Job | {Action}{Domain}Job | app/Jobs/{领域} | SyncOrderToErpJob |
| Command | {domain}:{action} | app/Console/Commands | orders:expire-unpaid |
| Enum | {Domain}{Field} / {Domain}Status | app/Enums/{领域} | OrderStatus |
| Error Enum | {Domain}Error | app/Support/ErrorCodes/{领域} | OrderError |
| Middleware | {Action}{Concern}Middleware | app/Http/Middleware | VerifyOpenApiSignatureMiddleware |
禁止:
OrderManager
CommonService
BaseService
Helper
Util
OrderService2
NewOrderService
19.4 变量、方法、常量与字段命名
19.4.1 变量与属性
PHP 变量、对象属性使用 camelCase。
推荐:
$orderId
$userId
$pageSize
$createdFrom
$createdTo
$paymentAmount
$accessibleStoreIds
规则:
- 变量名必须表达业务含义。
- 集合变量使用复数或明确集合语义,例如
$orders、$orderItems、$accessibleStoreIds。 - 布尔变量使用
is/has/can/should前缀,例如$isPaid、$hasExpired、$canCancel。 - 临时变量可以短,但作用域必须很小。
禁止长期使用无语义变量承载核心业务数据:
$data
$result
$tmp
$arr
$obj
$res
$list
$info
$data 仅允许用于短距离数据转换;进入 Application Service、Domain Service、Resource 等长期逻辑后,必须转换为明确命名的变量或 Data 对象。
19.4.2 方法命名
方法使用 camelCase,并优先以动作开头。
推荐:
handle()
build()
toArray()
fromArray()
ensureCanCancel()
markAsPaid()
calculatePayAmount()
resolveUserPermissions()
规则:
- Application Service 主入口统一使用
handle()。 - Query Object 构建查询统一使用
build()。 - Data 构造入口统一使用
fromArray()。 - 状态机校验使用
ensureCanXxx()。 - 布尔判断方法使用
isXxx()/hasXxx()/canXxx()。 - 方法名必须体现业务动作,不得使用泛化动词隐藏业务语义。
不推荐:
process()
doSomething()
handleData()
runLogic()
manage()
test()
19.4.3 常量、Enum 与标识符
常量使用 UPPER_SNAKE_CASE。
public const DEFAULT_PAGE_SIZE = 20;
public const MAX_PAGE_SIZE = 100;
Enum case 使用 PascalCase。
OrderStatus::PendingPay
OrderStatus::Paid
OrderStatus::Canceled
error_key 使用 UPPER_SNAKE_CASE。
ORDER_STATUS_NOT_CANCELABLE
VALIDATION_ERROR
UNAUTHENTICATED
status_key 使用 snake_case。
pending_pay
paid
canceled
refunding
19.5 API、数据库与配置命名
19.5.1 API 字段与数组 key
API JSON 字段统一使用 snake_case。
{
"order_id": 1001,
"order_no": "SO202604290001",
"status_key": "paid",
"created_at": "2026-04-29 10:00:00"
}
规则:
- PHP 变量使用
camelCase。 - API JSON 字段使用
snake_case。 - 数据库字段使用
snake_case。 - 前后端字段转换由 Resource 显式完成。
- 不得直接把 Eloquent Model 自动序列化作为 API 合同。
19.5.2 数据库命名
数据库表名使用 snake_case 复数形式。
orders
order_items
refund_orders
payment_logs
audit_logs
字段名使用 snake_case。
id
order_no
user_id
status
paid_at
created_at
updated_at
deleted_at
规则:
- 主键默认使用
id。 - 外键使用
{singular_table}_id,例如user_id、order_id。 - 时间字段使用
_at后缀,例如paid_at、approved_at。 - 布尔字段使用
is_/has_前缀,例如is_active、has_invoice。 - 状态字段统一使用
status;同一表存在多个状态字段时,必须带业务前缀,例如payment_status、refund_status。 - 金额默认以整数存储,单位为分;若项目未统一该约定,字段名必须显式体现单位,例如
amount_cent。
索引命名应表达表、字段和索引类型。
orders_user_id_status_index
orders_order_no_unique
payment_logs_transaction_no_unique
19.5.3 路由、权限、缓存、配置与环境变量
路由名使用点分隔 snake_case。
admin.orders.index
admin.orders.cancel
admin.refunds.approve
open.payment.callback
权限 key 使用点分隔动作语义。
admin.order.view
admin.order.cancel
admin.refund.approve
system.config.update
缓存 key 使用冒号分隔,并通过统一 CacheKeys 生成。
admin:user:1001:permissions:v12
product:category:tree:v3
order:stats:store:12:2026-04-29
配置 key 使用点分隔。
config('business.upload.max_size')
config('security.open_api.allowed_skew_seconds')
环境变量使用 UPPER_SNAKE_CASE。
LOG_REQUEST_DAYS=14
UPLOAD_PUBLIC_ROOT=/data/www/resources/public
OPEN_API_ALLOWED_SKEW_SECONDS=300
19.6 测试命名
测试方法名必须表达业务场景。
推荐:
public function test_pending_order_can_be_cancelled(): void
public function test_paid_order_can_not_be_cancelled_twice(): void
public function test_open_api_request_requires_valid_signature(): void
不推荐:
public function test_handle(): void
public function test_order(): void
public function test_case_1(): void
19.7 AI 生成代码约束
当使用 AI 生成代码时,必须遵守本规范的目录、命名、分层、响应、异常、状态、事务、权限与测试要求。
AI 生成代码必须满足:
- 类名必须表达明确职责。
- 方法名必须表达明确业务动作。
- 变量名必须表达明确业务含义。
- namespace 必须与目录结构一致。
- 新增业务类必须放入本规范定义的对应层级。
- 业务流程必须进入 Application Service。
- 跨用例领域规则必须进入 Domain Service。
- API 输出必须通过 Resource + ApiResponder。
- 请求校验必须通过 FormRequest。
- 复杂入参必须使用 Data 对象。
- 状态判断必须使用 Enum / 状态机,不得散落魔法数字。
- 错误返回必须使用业务错误码和统一异常结构。
AI 生成代码禁止:
- 生成
Manager、Helper、Util、CommonService、BaseService承载业务流程。 - 使用
$data、$result、$tmp、$arr、$obj长期承载核心业务数据。 - 使用
process()、doSomething()、handleData()、runLogic()这类无法表达业务语义的方法名。 - 为了复用而提前抽象不存在的公共父类。
- 生成与目录 namespace 不一致的类。
- 绕过 FormRequest、Resource、ApiResponder、Application Service 生成临时代码。
- 在 Controller、Model、Resource 中堆叠业务流程。
- 直接返回
response()->json()。 - 使用中文状态文案作为判断条件。
- 在业务代码中散落魔法数字、魔法字符串、裸错误码、裸权限 key、裸缓存 key。
- 创建无测试、无错误处理、无权限校验、无事务边界的核心写入代码。
19.8 代码注释规范
注释只解释“为什么”,不重复“代码做了什么”。
允许:
// 支付平台可能重复推送同一 event_id,必须依赖唯一索引兜底。
不推荐:
// 设置订单状态为已支付
$order->status = OrderStatus::Paid;
规范要求:
- 复杂业务规则必须在代码附近保留简短说明。
- 临时兼容逻辑必须标注移除条件。
- 禁止留下无负责人、无期限的
TODO。
19.9 命名与编码检查清单
代码评审时至少检查:
- PHP 文件名是否与类名一致。
- namespace 是否与目录结构一致。
- 类名是否表达职责。
- 方法名是否表达业务动作。
- 变量名是否表达业务含义。
- API 字段是否统一使用
snake_case。 - 数据库表、字段、索引是否符合命名规则。
- 路由名、权限 key、缓存 key、配置 key 是否使用统一格式。
- 是否存在
Manager、Helper、Util、CommonService等泛化类名。 - 是否存在长期使用
$data、$result、$tmp等无语义变量。 - 是否存在魔法数字、魔法字符串、裸错误码、裸权限 key、裸缓存 key。
- 是否通过
./vendor/bin/pint --test。
20. 评审检查清单
本章用于 Code Review、架构评审和上线前自检。检查清单不是形式要求,任何高风险项未满足,都应阻止合并或发布。
20.1 接口开发检查
- 路由已按端口 / 调用方 / 系统边界归类。
- 请求参数已使用 FormRequest 校验。
- 列表接口已校验
page、page_size、sort、order、筛选参数。 -
page_size已设置上限。 - Controller 只负责接收请求、调用 Service、返回 Resource / ApiResponder。
- Application Service 不返回
JsonResponse。 - Resource 不触发数据库查询。
- 响应结构符合
code / message / data / meta / links。 - 错误响应符合
code / error_key / message / errors / meta。 - 新增业务失败场景已定义业务错误码。
20.2 查询与导出检查
- 排序字段使用白名单映射,前端字段未直接进入
orderBy()。 -
include使用白名单,前端字段未直接进入with()。 - keyword 已 trim、限长,并对
%、_、\做 LIKE 转义。 - 时间范围边界明确。
- 数据范围过滤在 Query Builder 阶段完成。
- 列表关联数据已通过
with、withCount、join、subquery 预处理。 - 导出复用列表查询条件。
- 大数据导出使用 chunk / cursor / 队列,不使用全量内存加载。
20.3 业务流程检查
- 业务流程由 Application Service 承接。
- 跨用例规则已沉淀到 Domain Service。
- 多表写入已放入事务。
- 事务内没有 HTTP、短信、邮件、Webhook、文件处理等不可回滚副作用。
- 副作用已使用
afterCommit、Job、Listener 或 Outbox 模式处理。 - 状态变更已校验当前状态。
- 高风险状态流转已使用条件更新或行级锁。
- 强唯一业务约束已有数据库唯一索引兜底。
- 支付回调、Webhook、Open API 写接口、创建订单等高风险入口已做幂等。
20.4 权限与安全检查
- 认证方案、guard、token 策略明确。
- 路由权限已绑定到稳定 permission key。
- 菜单 / 页面 / 按钮显隐不替代后端授权。
- 对象级操作已使用 Policy / Gate / FormRequest::authorize 或等价方案。
-
X-Ui-Node-Id只用于审计上下文,不用于授权。 - SPA 公共接口已校验 Origin / Referer、CORS、限流和行为风控边界。
- Open API 已验签、限流、幂等、时间戳窗口校验。
- Webhook 已先验签再处理业务。
20.5 日志、审计与观测检查
- 请求链路包含
request_id。 - 响应头返回
X-Request-Id。 - 请求日志、业务操作审计、SQL 执行审计职责分离。
- 请求日志已脱敏 authorization、cookie、password、token、secret 等字段。
- 关键业务动作已记录业务操作审计。
- 审计日志通过
request_id与请求日志关联。 - SQL 写入审计如已启用,不记录查询类 SQL。
- 日志表、请求日志文件已有清理策略。
- 关键失败路径有 warning / error 日志。
20.6 生产运行检查
-
.env生产基线检查通过:APP_ENV=production、APP_DEBUG=false。 - migration 已评估兼容性和回滚策略。
- 发布前已备份数据库或确认无需备份。
- 队列由 Supervisor / systemd / Horizon 守护。
- Scheduler 已配置系统 cron。
- 关键定时任务已接入 Schedule Monitor / Uptime Kuma 或等价监控。
- 上传目录不在代码发布目录内,且已纳入备份。
- Nginx / PHP-FPM / HTTPS / 静态资源缓存配置已确认。
- 健康检查、冒烟测试和回滚命令已准备。
20.7 测试与门禁检查
- 新增核心用例有 Feature Test 或 Unit Test。
- 状态机、错误码、权限、幂等、回调等高风险逻辑有测试覆盖。
- CI 已执行 Pint。
- CI 已执行测试。
- 若项目启用 PHPStan / Larastan,静态分析必须通过。
- 合并前没有跳过失败测试。
- 发布前记录 commit hash、发布人、发布时间和回滚方案。
21. 反模式与禁止事项
本章只保留高风险反模式。一般编码偏好不放在这里,避免稀释重点。
21.1 分层反模式
禁止:
- Controller 中写复杂业务流程、事务、复杂查询。
- Model 中开事务、调外部接口、发送通知、写业务审计。
- Resource 中查询数据库、排序、分页、聚合。
- Domain Service 依赖 Request / Auth / Response / Session。
- Application Service 返回
JsonResponse或直接调用response()->json()。 - 为所有 Model 强行创建 Repository。
21.2 查询反模式
禁止:
- 前端字段直接进入
orderBy()。 - 前端
include直接进入with()。 - 列表接口无
page_size上限。 - 权限范围先查全量,再在内存、Resource 或前端过滤。
- Resource 中调用
$this->relation()->count()触发 N+1。 - 导出复制一套与列表不一致的查询条件。
21.3 一致性反模式
禁止:
- 事务中执行 HTTP、短信、邮件、Webhook、文件处理等不可回滚副作用。
- 状态流转无当前状态条件直接 update。
- 库存、余额、次数扣减采用先查后无条件更新。
- 只靠缓存锁保证资金、库存、订单状态一致性。
- 支付回调、Webhook、Open API 写接口不做幂等。
- 只靠前端禁用按钮防重复提交。
- 强唯一业务约束只靠
exists()判断,不建唯一索引。
21.4 状态与响应反模式
禁止:
- 用中文
status_label做程序判断。 - 状态魔法数字散落在业务代码中。
- 业务错误只返回 message,不返回稳定
code / error_key。 - 成功响应和失败响应使用两套互不兼容结构。
- 对外 API 直接暴露 PHP Enum 自动序列化结构。
21.5 安全与审计反模式
禁止:
- 前端权限隐藏替代后端授权。
X-Ui-Node-Id作为权限依据。- request log 替代业务操作审计。
- 业务操作审计混入字段级 diff。
- SQL 执行审计替代字段级数据审计。
- 日志记录 authorization、cookie、password、token、secret 明文。
- 生产开启
APP_DEBUG=true。
21.6 生产运行反模式
禁止:
- 上传文件放在代码发布目录且无备份策略。
- 队列只靠手工执行
queue:work。 - Scheduler 未配置系统 cron。
- migration 未评估兼容性直接发布。
- 没有数据库备份和回滚方案就执行高风险发布。
- Horizon、Telescope、日志中心等内部工具无鉴权暴露。
22. 推荐 Composer 包与使用边界
包选择必须服务于明确工程问题,不为“技术完整性”强行引入。新项目可按下表选择,不要求全部安装。
22.1 默认推荐
| 包 | 用途 | 使用边界 |
|---|---|---|
laravel/sanctum | SPA / Token 认证 | 只解决认证,不替代权限和业务授权。 |
spatie/laravel-permission | 角色、权限事实源 | 不直接表达菜单、页面、按钮和路由绑定的全部语义。 |
laravel/pint | 代码格式化 | CI 必须执行。 |
dedoc/scramble | OpenAPI JSON 生成 | 仅作为接口合同生成工具;默认导出并提交 docs/api/openapi.json。 |
22.2 按场景引入
| 包 | 用途 | 引入条件 |
|---|---|---|
laravel/horizon | Redis 队列监控 | 使用 Redis 队列且需要可视化队列状态。 |
spatie/laravel-schedule-monitor | 定时任务执行监控 | 有关键 Scheduler 任务需要失败告警。 |
maatwebsite/excel | Excel 导入导出 | 业务存在 Excel 导入导出;大数据必须配合队列 / chunk。 |
laravel/pulse | 应用观测 | 项目需要应用级指标观测。 |
laravel/telescope | 本地 / 测试调试 | 生产谨慎启用,必须鉴权和限制访问。 |
spatie/laravel-activitylog | 活动日志 | 仅在其模型事件日志能力符合项目边界时使用;不替代本文业务操作审计方案。 |
22.3 开发辅助
| 包 | 用途 | 使用边界 |
|---|---|---|
laravel/boost | 开发辅助 | 开发环境使用,不作为运行时核心依赖。 |
nunomaduro/larastan | 静态分析 | 建议中大型项目启用,CI 执行。 |
fakerphp/faker | 测试数据 | 仅用于 factory / seed / test。 |
22.4 包引入评审标准
新增包前必须确认:
- 是否解决明确问题。
- 是否长期维护。
- 是否与 Laravel 当前主版本兼容。
- 是否引入安全风险或额外运行组件。
- 是否需要配置、队列、缓存、数据库表、定时任务或监控。
- 是否有可替代的 Laravel 原生能力。
- 是否进入生产依赖,还是仅开发依赖。
禁止:
- 为了一个很小的 helper 引入重型包。
- 同一能力引入多个包并行存在。
- 包接入后没有文档、配置说明和回退方案。
- 未锁定版本范围就直接用于生产核心链路。
23. 附录使用说明与代码模板校验报告
23.1 附录定位
附录不是第二套规范,而是正文规范的可复制实现模板。
使用顺序:
- 先阅读正文,确认默认规则和边界。
- 再复制对应附录模板。
- 按项目实际命名空间、guard、错误码段位、业务字段做最小调整。
- 最后用本章验收标准校验接入是否完成。
23.2 模板落地标准
每个附录模板默认需要满足:
- 文件路径明确。
- 依赖前置明确。
- 接入步骤明确。
- 验收标准明确。
- 不破坏正文中的分层边界。
- 不引入与前文冲突的命名、字段和配置 key。
23.3 本轮代码模板语法检查结果
本轮对附录中带有 php file=... 标记的完整 PHP 模板进行了语法检查。
检查方式:
php -l <extracted-template-file.php>
检查结果:
| 项目 | 结果 |
|---|---|
| 已提取完整 PHP 模板 | 47 个 |
php -l 通过 | 47 个 |
php -l 失败 | 0 个 |
已检查范围包括:
ApiResponderBaseApiControllerBusinessErrorOrderErrorApiHttpExceptionbootstrap/app.php异常渲染模板- FormRequest / Data / Resource / Controller / Service 模板
- Model / Enum / 状态机模板
- 查询、分页、导出模板
- 请求日志、请求中心模板
- 业务操作审计模板
- SQL 执行审计模板
- Open API 签名模板
- 文件上传模板
- Job / Scheduler / 测试模板
23.4 未覆盖的验证范围
本轮没有声明以下内容已经完成真实运行验证:
- 未在真实 Laravel 11 项目中执行完整启动。
- 未执行 Composer 依赖安装验证。
- 未执行 Laravel 容器解析验证。
- 未执行数据库 migration 实际迁移。
- 未执行 Horizon、Redis、Scheduler、Nginx、PHP-FPM 的真实部署验证。
- 未运行 Pint、PHPStan / Larastan、Feature Test。
因此,本文档可以作为团队规范与模板基线,但在具体项目首次落地时,仍应执行:
composer install
php artisan test
./vendor/bin/pint --test
php artisan config:clear
php artisan route:list
php artisan migrate --pretend
若项目启用 Larastan:
./vendor/bin/phpstan analyse
23.5 全文一致性检查结果
本轮已按以下规则做一致性收口:
- 业务错误标识统一使用
error_key。 - 异常方法统一使用
getErrorKey(),不使用getBusinessKey()。 BusinessError接口统一为code()、key()、httpStatus()、defaultMessage()。- 状态输出统一为
status / status_key / status_label / can_xxx。 - 请求链路主关联键统一为
request_id。 - 审计 UI 上下文统一使用
X-Ui-Node-Id,且只用于审计,不用于授权。 - Application Service 统一不返回
JsonResponse。 - Domain Service 统一不依赖 HTTP 上下文。
- Resource 统一不触发数据库查询。
- Repository / Query Object 统一为条件性引入,不作为所有模块必选层。
附录 A. 统一响应与异常完整模板
附录 A 使用说明
- 适用场景:统一 API 成功响应、错误响应、业务异常和 Laravel 11 异常渲染。
- 推荐文件:
app/Support/Http/ApiResponder.php、app/Exceptions/ApiHttpException.php、bootstrap/app.php。 - 依赖前置:已确定统一响应结构和业务错误码分段。
- 接入步骤:先复制
BusinessError与错误枚举,再复制ApiHttpException,最后在bootstrap/app.php注册异常渲染。 - 验收标准:成功响应、校验失败、未认证、无权限、业务异常、500 异常均返回统一 JSON。
正文只定义响应协议;本附录给出可直接复制的第一版实现。项目可以按业务调整 message、错误码段位和 meta 字段,但不得破坏统一出口。
A.1 ApiResponder
<?php
namespace App\Support\Http;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Resources\Json\JsonResource;
class ApiResponder
{
public function success(
mixed $data = null,
string $message = 'success',
int $code = 0,
array $meta = [],
array $links = [],
int $status = 200,
): JsonResponse {
return response()->json([
'code' => $code,
'message' => $message,
'data' => $this->normalizeData($data),
'meta' => $meta,
'links' => $links,
], $status);
}
public function created(
mixed $data = null,
string $message = 'created',
int $code = 0,
array $meta = [],
array $links = [],
): JsonResponse {
return $this->success(
data: $data,
message: $message,
code: $code,
meta: $meta,
links: $links,
status: 201,
);
}
public function paginated(
mixed $data,
LengthAwarePaginator|Paginator $paginator,
string $message = 'success',
int $code = 0,
array $meta = [],
array $links = [],
int $status = 200,
): JsonResponse {
$meta['pagination'] = array_filter([
'page' => method_exists($paginator, 'currentPage') ? $paginator->currentPage() : null,
'page_size' => method_exists($paginator, 'perPage') ? $paginator->perPage() : null,
'total' => method_exists($paginator, 'total') ? $paginator->total() : null,
'has_more' => method_exists($paginator, 'hasMorePages') ? $paginator->hasMorePages() : null,
], static fn (mixed $value) => $value !== null);
$links = array_merge([
'next' => method_exists($paginator, 'nextPageUrl') ? $paginator->nextPageUrl() : null,
'prev' => method_exists($paginator, 'previousPageUrl') ? $paginator->previousPageUrl() : null,
], $links);
return $this->success(
data: $data,
message: $message,
code: $code,
meta: $meta,
links: $links,
status: $status,
);
}
public function error(
string $message = 'error',
int $code = 1,
?string $errorKey = null,
array $errors = [],
array $meta = [],
int $status = 400,
): JsonResponse {
return response()->json([
'code' => $code,
'error_key' => $errorKey,
'message' => $message,
'errors' => $errors,
'meta' => $meta,
], $status);
}
protected function normalizeData(mixed $data): mixed
{
if ($data instanceof JsonResource || $data instanceof AnonymousResourceCollection) {
return $data->resolve(request());
}
return $data;
}
}app/Support/Http/ApiResponder.php
A.2 BaseApiController
<?php
namespace App\Http\Controllers;
use App\Support\Http\ApiResponder;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Http\JsonResponse;
abstract class BaseApiController extends Controller
{
protected function success(
mixed $data = null,
string $message = 'success',
int $code = 0,
array $meta = [],
array $links = [],
int $status = 200,
): JsonResponse {
return app(ApiResponder::class)->success($data, $message, $code, $meta, $links, $status);
}
protected function created(
mixed $data = null,
string $message = 'created',
int $code = 0,
array $meta = [],
array $links = [],
): JsonResponse {
return app(ApiResponder::class)->created($data, $message, $code, $meta, $links);
}
protected function paginated(
mixed $data,
LengthAwarePaginator|Paginator $paginator,
string $message = 'success',
int $code = 0,
array $meta = [],
array $links = [],
int $status = 200,
): JsonResponse {
return app(ApiResponder::class)->paginated($data, $paginator, $message, $code, $meta, $links, $status);
}
protected function error(
string $message = 'error',
int $code = 1,
?string $errorKey = null,
array $errors = [],
array $meta = [],
int $status = 400,
): JsonResponse {
return app(ApiResponder::class)->error($message, $code, $errorKey, $errors, $meta, $status);
}
}app/Http/Controllers/BaseApiController.php
A.3 BusinessError 与业务错误枚举
<?php
namespace App\Support\ErrorCodes\Contracts;
interface BusinessError
{
public function code(): int;
public function key(): string;
public function httpStatus(): int;
public function defaultMessage(): string;
}app/Support/ErrorCodes/Contracts/BusinessError.php
<?php
namespace App\Support\ErrorCodes\Order;
use App\Support\ErrorCodes\Contracts\BusinessError;
enum OrderError: int implements BusinessError
{
case STATUS_NOT_CANCELABLE = 120101;
case ALREADY_PAID = 120102;
case NOT_FOUND = 120103;
public function code(): int
{
return $this->value;
}
public function key(): string
{
return match ($this) {
self::STATUS_NOT_CANCELABLE => 'ORDER_STATUS_NOT_CANCELABLE',
self::ALREADY_PAID => 'ORDER_ALREADY_PAID',
self::NOT_FOUND => 'ORDER_NOT_FOUND',
};
}
public function httpStatus(): int
{
return match ($this) {
self::STATUS_NOT_CANCELABLE => 422,
self::ALREADY_PAID => 409,
self::NOT_FOUND => 404,
};
}
public function defaultMessage(): string
{
return match ($this) {
self::STATUS_NOT_CANCELABLE => 'Order status does not allow cancellation.',
self::ALREADY_PAID => 'Order has already been paid.',
self::NOT_FOUND => 'Order not found.',
};
}
}app/Support/ErrorCodes/Order/OrderError.php
A.4 ApiHttpException
<?php
namespace App\Exceptions;
use App\Support\ErrorCodes\Contracts\BusinessError;
use Exception;
use Throwable;
class ApiHttpException extends Exception
{
public function __construct(
protected BusinessError $businessError,
protected ?string $apiMessage = null,
protected array $errors = [],
?Throwable $previous = null,
) {
parent::__construct($apiMessage ?? $businessError->defaultMessage(), 0, $previous);
}
public static function fromError(
BusinessError $businessError,
array $errors = [],
?string $message = null,
?Throwable $previous = null,
): static {
return new static($businessError, $message, $errors, $previous);
}
public function getBusinessCode(): int
{
return $this->businessError->code();
}
public function getErrorKey(): string
{
return $this->businessError->key();
}
public function getStatusCode(): int
{
return $this->businessError->httpStatus();
}
public function getErrors(): array
{
return $this->errors;
}
}app/Exceptions/ApiHttpException.php
A.5 Laravel 11 异常渲染入口
<?php
use App\Exceptions\ApiHttpException;
use App\Support\Http\ApiResponder;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Throwable;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
api: __DIR__ . '/../routes/api.php',
commands: __DIR__ . '/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// ...
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->render(function (ValidationException $e, Request $request) {
if (! $request->expectsJson()) {
return null;
}
return app(ApiResponder::class)->error(
message: 'The given data was invalid.',
code: 42200,
errorKey: 'VALIDATION_ERROR',
errors: $e->errors(),
status: 422,
);
});
$exceptions->render(function (ApiHttpException $e, Request $request) {
if (! $request->expectsJson()) {
return null;
}
return app(ApiResponder::class)->error(
message: $e->getMessage(),
code: $e->getBusinessCode(),
errorKey: $e->getErrorKey(),
errors: $e->getErrors(),
status: $e->getStatusCode(),
);
});
$exceptions->render(function (UnauthorizedHttpException $e, Request $request) {
if (! $request->expectsJson()) {
return null;
}
return app(ApiResponder::class)->error(
message: 'Unauthenticated.',
code: 40100,
errorKey: 'UNAUTHENTICATED',
status: 401,
);
});
$exceptions->render(function (AccessDeniedHttpException $e, Request $request) {
if (! $request->expectsJson()) {
return null;
}
return app(ApiResponder::class)->error(
message: 'Forbidden.',
code: 40300,
errorKey: 'FORBIDDEN',
status: 403,
);
});
$exceptions->render(function (Throwable $e, Request $request) {
if (! $request->expectsJson()) {
return null;
}
report($e);
return app(ApiResponder::class)->error(
message: 'Server error.',
code: 50000,
errorKey: 'SERVER_ERROR',
status: 500,
);
});
})
->create();bootstrap/app.php
附录 B. 输入校验、Data、Controller、Resource 模板
附录 B 使用说明
- 适用场景:标准 CRUD、复杂输入、列表查询和 Resource 输出。
- 推荐文件:
FormRequest、Data、Controller、Application Service、Resource成套复制。 - 依赖前置:已接入
ApiResponder和BaseApiController。 - 接入步骤:先写 Request,再写 Data,再写 Service,最后在 Controller 中组合 Resource 与响应。
- 验收标准:Controller 无复杂业务逻辑,Service 不返回响应对象,Resource 不查询数据库。
B.1 Schema / Ruleset
<?php
namespace App\Validation\Schemas\User;
final class UserSchema
{
public static function nickname(bool $required = true): array
{
$rules = ['string', 'min:2', 'max:30'];
array_unshift($rules, $required ? 'required' : 'nullable');
return $rules;
}
public static function email(bool $required = true): array
{
$rules = ['email:rfc,dns', 'max:64'];
array_unshift($rules, $required ? 'required' : 'nullable');
return $rules;
}
}app/Validation/Schemas/User/UserSchema.php
B.2 FormRequest
<?php
namespace App\Http\Requests\Admin\User;
use App\Validation\Schemas\User\UserSchema;
use Illuminate\Foundation\Http\FormRequest;
class CreateUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->merge([
'nickname' => is_string($this->nickname) ? trim($this->nickname) : $this->nickname,
'email' => is_string($this->email) ? strtolower(trim($this->email)) : $this->email,
]);
}
public function rules(): array
{
return [
'nickname' => UserSchema::nickname(),
'email' => UserSchema::email(),
];
}
}app/Http/Requests/Admin/User/CreateUserRequest.php
B.3 Input Data
<?php
namespace App\Data\User;
class CreateUserData
{
public function __construct(
public readonly string $nickname,
public readonly string $email,
) {
}
public static function fromArray(array $data): self
{
return new self(
nickname: $data['nickname'],
email: $data['email'],
);
}
}app/Data/User/CreateUserData.php
B.4 Resource
<?php
namespace App\Http\Resources\Admin\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'nickname' => $this->nickname,
'email' => $this->email,
'status' => $this->status instanceof \BackedEnum ? $this->status->value : (int) $this->status,
'created_at' => $this->created_at?->toDateTimeString(),
];
}
}app/Http/Resources/Admin/User/UserResource.php
B.5 Controller
<?php
namespace App\Http\Controllers\Admin\User;
use App\Data\User\CreateUserData;
use App\Http\Controllers\BaseApiController;
use App\Http\Requests\Admin\User\CreateUserRequest;
use App\Http\Resources\Admin\User\UserResource;
use App\Services\Admin\User\CreateUserService;
use Illuminate\Http\JsonResponse;
class UserController extends BaseApiController
{
public function store(CreateUserRequest $request, CreateUserService $service): JsonResponse
{
$data = CreateUserData::fromArray($request->validated());
$user = $service->handle($data);
return $this->created(
data: new UserResource($user),
message: 'User created successfully',
);
}
}app/Http/Controllers/Admin/User/UserController.php
B.6 Application Service
<?php
namespace App\Services\Admin\User;
use App\Data\User\CreateUserData;
use App\Domain\User\UserUniquenessService;
use App\Models\User\User;
use Illuminate\Support\Facades\DB;
class CreateUserService
{
public function __construct(
private readonly UserUniquenessService $userUniquenessService,
) {
}
public function handle(CreateUserData $data): User
{
return DB::transaction(function () use ($data) {
$this->userUniquenessService->ensureEmailCanUse($data->email);
return User::query()->create([
'nickname' => $data->nickname,
'email' => $data->email,
'status' => 1,
]);
});
}
}app/Services/Admin/User/CreateUserService.php
附录 C. Model、Enum、状态机模板
附录 C 使用说明
- 适用场景:核心状态字段、状态流转、状态 API 输出和状态机测试。
- 推荐文件:
app/Enums/{领域}、app/Models/{领域}、app/Domain/{领域}、app/Services/{端}/{模块}。 - 依赖前置:数据库状态字段已使用稳定 int 值。
- 接入步骤:先定义 Enum,再绑定 Model casts,再实现状态机,最后由 Application Service 在事务中执行状态变更。
- 验收标准:状态判断无魔法数字,状态流转集中,API 显式输出
status / status_key / status_label。
C.1 Enum
<?php
namespace App\Enums\Order;
enum OrderStatus: int
{
case PendingPay = 1;
case Paid = 2;
case Canceled = 3;
case Finished = 4;
case Refunding = 5;
case Refunded = 6;
public function key(): string
{
return match ($this) {
self::PendingPay => 'pending_pay',
self::Paid => 'paid',
self::Canceled => 'canceled',
self::Finished => 'finished',
self::Refunding => 'refunding',
self::Refunded => 'refunded',
};
}
public function label(): string
{
return match ($this) {
self::PendingPay => '待支付',
self::Paid => '已支付',
self::Canceled => '已取消',
self::Finished => '已完成',
self::Refunding => '退款中',
self::Refunded => '已退款',
};
}
public function isTerminal(): bool
{
return in_array($this, [self::Canceled, self::Finished, self::Refunded], true);
}
}app/Enums/Order/OrderStatus.php
C.2 Model
<?php
namespace App\Models\Order;
use App\Enums\Order\OrderStatus;
use App\Models\User\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Order extends Model
{
protected $table = 'orders';
protected $fillable = [
'user_id',
'order_no',
'status',
'total_amount',
'paid_at',
];
protected function casts(): array
{
return [
'status' => OrderStatus::class,
'total_amount' => 'integer',
'paid_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function scopePaid(Builder $query): Builder
{
return $query->where('status', OrderStatus::Paid->value);
}
public function isPaid(): bool
{
return $this->status === OrderStatus::Paid;
}
public function getAuditSubjectName(): string
{
return $this->order_no;
}
}app/Models/Order/Order.php
C.3 状态机
<?php
namespace App\Domain\Order;
use App\Enums\Order\OrderStatus;
use App\Exceptions\ApiHttpException;
use App\Models\Order\Order;
use App\Support\ErrorCodes\Order\OrderError;
class OrderStatusTransitionService
{
public function ensureCanCancel(Order $order): void
{
if (! $this->canTransition($order->status, OrderStatus::Canceled)) {
throw ApiHttpException::fromError(
OrderError::STATUS_NOT_CANCELABLE,
errors: [
'order_id' => $order->id,
'current_status' => $order->status->value,
'current_status_key' => $order->status->key(),
],
);
}
}
public function canTransition(OrderStatus $from, OrderStatus $to): bool
{
return in_array($to, $this->allowedTargets($from), true);
}
/** @return list<OrderStatus> */
public function allowedTargets(OrderStatus $from): array
{
return match ($from) {
OrderStatus::PendingPay => [OrderStatus::Paid, OrderStatus::Canceled],
OrderStatus::Paid => [OrderStatus::Finished, OrderStatus::Refunding],
OrderStatus::Finished => [OrderStatus::Refunding],
OrderStatus::Refunding => [OrderStatus::Refunded],
OrderStatus::Canceled, OrderStatus::Refunded => [],
};
}
}app/Domain/Order/OrderStatusTransitionService.php
C.4 状态流转 Application Service
<?php
namespace App\Services\Admin\Order;
use App\Domain\Order\OrderStatusTransitionService;
use App\Enums\Order\OrderStatus;
use App\Models\Order\Order;
use Illuminate\Support\Facades\DB;
class CancelOrderService
{
public function __construct(
private readonly OrderStatusTransitionService $transitionService,
) {
}
public function handle(Order $order, ?string $reason = null): Order
{
return DB::transaction(function () use ($order, $reason) {
$order = Order::query()
->whereKey($order->id)
->lockForUpdate()
->firstOrFail();
$this->transitionService->ensureCanCancel($order);
$order->update([
'status' => OrderStatus::Canceled,
'cancel_reason' => $reason,
'canceled_at' => now(),
]);
return $order->refresh();
}, attempts: 3);
}
}app/Services/Admin/Order/CancelOrderService.php
附录 D. 查询、分页、导出模板
附录 D 使用说明
- 适用场景:后台列表、复杂筛选、排序、分页和导出复用。
- 推荐文件:
QueryData、Query Object、ListService、ExportService。 - 依赖前置:FormRequest 已校验分页、筛选、排序参数。
- 接入步骤:Request 负责校验,QueryData 承接参数,Query Object 统一构建 Builder,列表和导出复用 Builder。
- 验收标准:排序和 include 均有白名单,导出与列表筛选条件一致。
D.1 Query Data
<?php
namespace App\Data\Order;
class OrderListQueryData
{
public function __construct(
public readonly ?string $keyword = null,
public readonly ?int $status = null,
public readonly ?int $storeId = null,
public readonly ?string $createdFrom = null,
public readonly ?string $createdTo = null,
public readonly string $sort = 'id',
public readonly string $order = 'desc',
public readonly int $page = 1,
public readonly int $pageSize = 20,
) {
}
public static function fromArray(array $data): self
{
return new self(
keyword: $data['keyword'] ?? null,
status: isset($data['status']) ? (int) $data['status'] : null,
storeId: isset($data['store_id']) ? (int) $data['store_id'] : null,
createdFrom: $data['created_from'] ?? null,
createdTo: $data['created_to'] ?? null,
sort: $data['sort'] ?? 'id',
order: $data['order'] ?? 'desc',
page: (int) ($data['page'] ?? 1),
pageSize: (int) ($data['page_size'] ?? 20),
);
}
}app/Data/Order/OrderListQueryData.php
D.2 Query Object
<?php
namespace App\Repositories\Order;
use App\Data\Order\OrderListQueryData;
use App\Enums\Order\OrderStatus;
use App\Models\Order\Order;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
class OrderListQuery
{
public function build(OrderListQueryData $query): Builder
{
$createdFrom = $query->createdFrom
? CarbonImmutable::parse($query->createdFrom)->startOfDay()
: null;
$createdTo = $query->createdTo
? CarbonImmutable::parse($query->createdTo)->endOfDay()
: null;
$builder = Order::query()
->select([
'orders.id',
'orders.order_no',
'orders.user_id',
'orders.status',
'orders.total_amount',
'orders.created_at',
])
->with(['user:id,nickname'])
->withCount('items')
->when($query->keyword, fn (Builder $builder) => $this->applyKeyword($builder, $query->keyword))
->when($query->status !== null, function (Builder $builder) use ($query) {
$status = OrderStatus::tryFrom($query->status);
if ($status) {
$builder->where('orders.status', $status->value);
}
})
->when($query->storeId, fn (Builder $builder) => $builder->where('orders.store_id', $query->storeId))
->when($createdFrom, fn (Builder $builder) => $builder->where('orders.created_at', '>=', $createdFrom))
->when($createdTo, fn (Builder $builder) => $builder->where('orders.created_at', '<=', $createdTo));
$this->applySort($builder, $query);
return $builder;
}
private function applyKeyword(Builder $builder, string $keyword): void
{
$keyword = addcslashes(trim($keyword), '%_\\');
$builder->where(function (Builder $builder) use ($keyword) {
$builder->where('orders.order_no', 'like', '%' . $keyword . '%')
->orWhereHas('user', function (Builder $builder) use ($keyword) {
$builder->where('nickname', 'like', '%' . $keyword . '%');
});
});
}
private function applySort(Builder $builder, OrderListQueryData $query): void
{
$sortMap = [
'id' => 'orders.id',
'created_at' => 'orders.created_at',
'paid_at' => 'orders.paid_at',
'total_amount' => 'orders.total_amount',
];
$column = $sortMap[$query->sort] ?? 'orders.id';
$direction = $query->order === 'asc' ? 'asc' : 'desc';
$builder->orderBy($column, $direction);
if ($column !== 'orders.id') {
$builder->orderByDesc('orders.id');
}
}
}app/Repositories/Order/OrderListQuery.php
D.3 列表 Service
<?php
namespace App\Services\Admin\Order;
use App\Data\Order\OrderListQueryData;
use App\Repositories\Order\OrderListQuery;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class ListOrderService
{
public function __construct(
private readonly OrderListQuery $orderListQuery,
) {
}
public function handle(OrderListQueryData $query): LengthAwarePaginator
{
return $this->orderListQuery
->build($query)
->paginate(
perPage: $query->pageSize,
columns: ['*'],
pageName: 'page',
page: $query->page,
);
}
}app/Services/Admin/Order/ListOrderService.php
D.4 导出复用查询
$builder = $this->orderListQuery->build($query);
$builder->chunkById(500, function ($orders): void {
foreach ($orders as $order) {
// 写入 CSV / Excel
}
}, column: 'orders.id');
附录 E. 请求日志与请求中心完整模板
附录 E 使用说明
- 适用场景:请求摘要日志、请求中心、近期排障和链路关联。
- 推荐文件:
AssignRequestContextMiddleware、RequestLogMiddleware、request_logsmigration、PersistRequestLogJob。 - 依赖前置:已配置独立 request 日志通道;如启用写库,需具备队列或接受轻量同步写库。
- 接入步骤:先注册请求上下文中间件,再注册请求日志中间件,再配置日志通道与表结构。
- 验收标准:响应头包含
X-Request-Id,写请求和异常请求被记录,敏感字段已脱敏。
E.1 AssignRequestContextMiddleware
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class AssignRequestContextMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$requestId = $request->headers->get('X-Request-Id')
?: $request->headers->get('Request-Id')
?: (string) Str::uuid();
$request->attributes->set('request_id', $requestId);
Log::shareContext([
'request_id' => $requestId,
]);
$response = $next($request);
$response->headers->set('X-Request-Id', $requestId);
return $response;
}
}app/Http/Middleware/AssignRequestContextMiddleware.php
E.2 RequestLogMiddleware
<?php
namespace App\Http\Middleware;
use App\Jobs\Log\PersistRequestLogJob;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
class RequestLogMiddleware
{
private const MAX_BODY_LENGTH = 4000;
private array $hiddenHeaders = [
'authorization',
'cookie',
'set-cookie',
'x-api-key',
];
private array $hiddenInputs = [
'password',
'password_confirmation',
'old_password',
'new_password',
'token',
'access_token',
'refresh_token',
'secret',
'sign',
];
public function handle(Request $request, Closure $next): Response
{
$start = microtime(true);
$exception = null;
$response = null;
try {
/** @var Response $response */
$response = $next($request);
} catch (Throwable $e) {
$exception = $e;
throw $e;
} finally {
$durationMs = (int) round((microtime(true) - $start) * 1000);
$this->record(
request: $request,
response: $response,
durationMs: $durationMs,
exception: $exception,
);
}
return $response;
}
private function record(Request $request, ?Response $response, int $durationMs, ?Throwable $exception): void
{
if (! $this->shouldLog($request, $response, $exception)) {
return;
}
$payload = $this->buildPayload($request, $response, $durationMs, $exception);
Log::channel('request')->info('http_request', $payload);
if ((bool) config('logging.request_log.persist_to_database', true)) {
PersistRequestLogJob::dispatch($payload)->onQueue('logs')->afterResponse();
}
}
private function buildPayload(Request $request, ?Response $response, int $durationMs, ?Throwable $exception): array
{
$user = $request->user();
return [
'request_id' => $request->attributes->get('request_id'),
'trace_id' => $this->resolveTraceId($request),
'method' => $request->method(),
'path' => $request->path(),
'full_url' => $request->fullUrl(),
'route_name' => optional($request->route())->getName(),
'controller_action' => optional($request->route())->getActionName(),
'status_code' => $response?->getStatusCode(),
'duration_ms' => $durationMs,
'ip' => $request->ip(),
'user_id' => $user?->getAuthIdentifier(),
'user_type' => $user ? class_basename($user) : null,
'request_headers' => $this->sanitizeHeaders($request->headers->all()),
'request_body' => $this->normalizeForStorage($this->sanitizeAndTrim($this->extractRequestBody($request))),
'response_preview' => $this->normalizeForStorage($this->sanitizeAndTrim($this->extractResponseBody($response))),
'exception_class' => $exception ? $exception::class : null,
'is_exception' => (bool) $exception,
'logged_at' => now(),
];
}
private function shouldLog(Request $request, ?Response $response, ?Throwable $exception): bool
{
$method = strtoupper($request->method());
$path = ltrim($request->path(), '/');
if (in_array($path, ['up'], true) || str_starts_with($path, '_debugbar')) {
return false;
}
if (preg_match('#^(telescope|horizon|pulse)(/|$)#', $path)) {
return false;
}
return $exception
|| in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)
|| (($response?->getStatusCode() ?? 200) >= 400);
}
private function extractRequestBody(Request $request): array|string|null
{
$contentType = strtolower($request->header('Content-Type', ''));
if (str_starts_with($contentType, 'application/json')) {
return $request->json()->all();
}
if (str_starts_with($contentType, 'multipart/form-data')) {
$data = $request->all();
foreach ($request->allFiles() as $key => $file) {
$data[$key] = '[uploaded_file]';
}
return $data;
}
$formData = $request->all();
if (! empty($formData)) {
return $formData;
}
$raw = $request->getContent();
return $raw !== '' ? $raw : null;
}
private function extractResponseBody(?Response $response): array|string|null
{
if (! $response) {
return null;
}
if ($response instanceof JsonResponse) {
$data = $response->getData(true);
return is_array($data) ? $data : null;
}
$contentType = strtolower($response->headers->get('Content-Type', ''));
return str_starts_with($contentType, 'text/plain') ? $response->getContent() : null;
}
private function sanitizeHeaders(array $headers): array
{
$result = [];
foreach ($headers as $key => $value) {
$result[$key] = in_array(strtolower($key), $this->hiddenHeaders, true)
? ['********']
: $value;
}
return $result;
}
private function sanitizeAndTrim(array|string|null $data): array|string|null
{
if (is_array($data)) {
foreach ($this->hiddenInputs as $key) {
if (Arr::has($data, $key)) {
Arr::set($data, $key, '********');
}
}
$json = json_encode($data, JSON_UNESCAPED_UNICODE);
if ($json !== false && mb_strlen($json) > self::MAX_BODY_LENGTH) {
return [
'_truncated' => true,
'_preview' => mb_substr($json, 0, self::MAX_BODY_LENGTH),
];
}
return $data;
}
if (is_string($data) && mb_strlen($data) > self::MAX_BODY_LENGTH) {
return mb_substr($data, 0, self::MAX_BODY_LENGTH) . '... [truncated]';
}
return $data;
}
private function normalizeForStorage(array|string|null $data): ?array
{
if ($data === null) {
return null;
}
return is_array($data) ? $data : ['_text' => $data];
}
private function resolveTraceId(Request $request): ?string
{
$traceparent = $request->headers->get('traceparent');
if (is_string($traceparent)
&& preg_match('/^[\da-f]{2}-([\da-f]{32})-[\da-f]{16}-[\da-f]{2}$/', $traceparent, $matches)
) {
return $matches[1];
}
return $request->attributes->get('trace_id')
?: $request->headers->get('X-Trace-Id')
?: $request->headers->get('Trace-Id');
}
}app/Http/Middleware/RequestLogMiddleware.php
E.3 logging.php 通道配置
'channels' => [
// ...
'request' => [
'driver' => 'daily',
'path' => storage_path('logs/request/request.log'),
'level' => env('LOG_REQUEST_LEVEL', 'info'),
'days' => (int) env('LOG_REQUEST_DAYS', 14),
'replace_placeholders' => true,
'locking' => true,
'permission' => 0644,
],
],
'request_log' => [
'persist_to_database' => env('LOG_REQUEST_PERSIST_TO_DATABASE', true),
'prune_days' => (int) env('LOG_REQUEST_PRUNE_DAYS', 14),
],config/logging.php
E.4 request_logs migration
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('request_logs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('request_id', 64)->nullable()->index();
$table->string('trace_id', 64)->nullable()->index();
$table->string('method', 16);
$table->string('path', 1024);
$table->text('full_url')->nullable();
$table->string('route_name', 255)->nullable()->index();
$table->string('controller_action', 255)->nullable();
$table->unsignedSmallInteger('status_code')->nullable()->index();
$table->unsignedInteger('duration_ms')->default(0);
$table->string('ip', 64)->nullable()->index();
$table->unsignedBigInteger('user_id')->nullable()->index();
$table->string('user_type', 64)->nullable();
$table->json('request_headers')->nullable();
$table->json('request_body')->nullable();
$table->json('response_preview')->nullable();
$table->string('exception_class', 255)->nullable()->index();
$table->boolean('is_exception')->default(false)->index();
$table->timestamp('logged_at')->useCurrent()->index();
$table->timestamps();
$table->index(['logged_at', 'status_code']);
});
}
public function down(): void
{
Schema::dropIfExists('request_logs');
}
};database/migrations/xxxx_xx_xx_xxxxxx_create_request_logs_table.php
E.5 RequestLog Model 与 Job
<?php
namespace App\Models\Log;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\MassPrunable;
class RequestLog extends Model
{
use MassPrunable;
protected $table = 'request_logs';
protected $fillable = [
'request_id', 'trace_id', 'method', 'path', 'full_url', 'route_name',
'controller_action', 'status_code', 'duration_ms', 'ip', 'user_id', 'user_type',
'request_headers', 'request_body', 'response_preview', 'exception_class',
'is_exception', 'logged_at',
];
protected $casts = [
'request_headers' => 'array',
'request_body' => 'array',
'response_preview' => 'array',
'is_exception' => 'boolean',
'logged_at' => 'datetime',
];
public function prunable(): Builder
{
return static::query()
->where('logged_at', '<', now()->subDays((int) config('logging.request_log.prune_days', 14)));
}
}app/Models/Log/RequestLog.php
<?php
namespace App\Jobs\Log;
use App\Models\Log\RequestLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class PersistRequestLogJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(public array $payload)
{
}
public function handle(): void
{
RequestLog::query()->create($this->payload);
}
}app/Jobs/Log/PersistRequestLogJob.php
E.6 中间件挂载与日志清理调度
use App\Http\Middleware\AssignRequestContextMiddleware;
use App\Http\Middleware\RequestLogMiddleware;
use Illuminate\Foundation\Configuration\Middleware;
->withMiddleware(function (Middleware $middleware): void {
$middleware->append(AssignRequestContextMiddleware::class);
$middleware->append(RequestLogMiddleware::class);
})bootstrap/app.php
<?php
use App\Models\Log\RequestLog;
use Illuminate\Support\Facades\Schedule;
Schedule::command('model:prune', [
'--model' => [RequestLog::class],
])->dailyAt('02:10');routes/console.php
php artisan model:prune --model="App\Models\Log\RequestLog" --pretend
附录 F. 业务操作审计完整模板
附录 F 使用说明
- 适用场景:后台敏感业务动作、审批、状态变更、权限变更、资金相关操作审计。
- 推荐文件:
audit_logsmigration、OperationAuditService、InteractsWithOperationAudit。 - 依赖前置:请求链路已有
request_id;前端按需通过 Header 传递X-Ui-Node-Id。 - 接入步骤:先建审计表,再接入上下文解析服务,最后在关键 Application Service 中调用
auditSuccess()。 - 验收标准:审计能回答“谁、在哪里、对什么对象、做了什么、结果如何”。
F.1 audit_logs migration
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('event_key', 255)->default('generic.operation')->index();
$table->string('event_label', 255)->default('执行操作');
$table->string('result', 32)->default('success')->index();
$table->unsignedBigInteger('actor_id')->nullable()->index();
$table->string('actor_type', 255)->nullable();
$table->string('actor_name_snapshot', 255)->nullable();
$table->unsignedBigInteger('ui_node_id')->nullable()->index();
$table->string('ui_node_key', 255)->nullable()->index();
$table->string('ui_node_type', 32)->nullable()->index();
$table->string('ui_path_snapshot', 1000)->nullable();
$table->string('page_label_snapshot', 255)->nullable();
$table->string('trigger_label_snapshot', 255)->nullable();
$table->string('subject_type', 255)->nullable()->index();
$table->string('subject_id', 64)->nullable()->index();
$table->string('subject_name_snapshot', 255)->nullable();
$table->text('summary')->nullable();
$table->string('reason', 500)->nullable();
$table->string('request_id', 64)->nullable()->index();
$table->string('trace_id', 64)->nullable()->index();
$table->string('route_name', 255)->nullable()->index();
$table->string('ip', 64)->nullable()->index();
$table->text('user_agent')->nullable();
$table->json('properties')->nullable();
$table->json('tags')->nullable();
$table->timestamp('audited_at')->useCurrent()->index();
$table->timestamps();
$table->index(['audited_at', 'result'], 'audit_logs_time_result_idx');
$table->index(['event_key', 'ui_node_id'], 'audit_logs_event_ui_idx');
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};database/migrations/xxxx_xx_xx_xxxxxx_create_audit_logs_table.php
F.2 AuditLog Model
<?php
namespace App\Models\Audit;
use Illuminate\Database\Eloquent\Model;
class AuditLog extends Model
{
protected $table = 'audit_logs';
protected $fillable = [
'event_key', 'event_label', 'result',
'actor_id', 'actor_type', 'actor_name_snapshot',
'ui_node_id', 'ui_node_key', 'ui_node_type', 'ui_path_snapshot',
'page_label_snapshot', 'trigger_label_snapshot',
'subject_type', 'subject_id', 'subject_name_snapshot',
'summary', 'reason', 'request_id', 'trace_id', 'route_name', 'ip', 'user_agent',
'properties', 'tags', 'audited_at',
];
protected $casts = [
'properties' => 'array',
'tags' => 'array',
'audited_at' => 'datetime',
];
}app/Models/Audit/AuditLog.php
F.3 RecordOperationAuditData
<?php
namespace App\Data\Audit;
use Carbon\CarbonInterface;
class RecordOperationAuditData
{
public function __construct(
public readonly string $eventKey,
public readonly string $eventLabel,
public readonly string $result,
public readonly ?int $actorId = null,
public readonly ?string $actorType = null,
public readonly ?string $actorNameSnapshot = null,
public readonly ?int $uiNodeId = null,
public readonly ?string $uiNodeKey = null,
public readonly ?string $uiNodeType = null,
public readonly ?string $uiPathSnapshot = null,
public readonly ?string $pageLabelSnapshot = null,
public readonly ?string $triggerLabelSnapshot = null,
public readonly ?string $subjectType = null,
public readonly ?string $subjectId = null,
public readonly ?string $subjectNameSnapshot = null,
public readonly ?string $summary = null,
public readonly ?string $reason = null,
public readonly ?string $requestId = null,
public readonly ?string $traceId = null,
public readonly ?string $routeName = null,
public readonly ?string $ip = null,
public readonly ?string $userAgent = null,
public readonly array $properties = [],
public readonly array $tags = [],
public readonly ?CarbonInterface $auditedAt = null,
) {
}
public function toArray(): array
{
return [
'event_key' => $this->eventKey,
'event_label' => $this->eventLabel,
'result' => $this->result,
'actor_id' => $this->actorId,
'actor_type' => $this->actorType,
'actor_name_snapshot' => $this->actorNameSnapshot,
'ui_node_id' => $this->uiNodeId,
'ui_node_key' => $this->uiNodeKey,
'ui_node_type' => $this->uiNodeType,
'ui_path_snapshot' => $this->uiPathSnapshot,
'page_label_snapshot' => $this->pageLabelSnapshot,
'trigger_label_snapshot' => $this->triggerLabelSnapshot,
'subject_type' => $this->subjectType,
'subject_id' => $this->subjectId,
'subject_name_snapshot' => $this->subjectNameSnapshot,
'summary' => $this->summary,
'reason' => $this->reason,
'request_id' => $this->requestId,
'trace_id' => $this->traceId,
'route_name' => $this->routeName,
'ip' => $this->ip,
'user_agent' => $this->userAgent,
'properties' => $this->properties,
'tags' => $this->tags,
'audited_at' => $this->auditedAt ?? now(),
];
}
}app/Data/Audit/RecordOperationAuditData.php
F.4 上下文解析服务
<?php
namespace App\Services\Audit;
use Illuminate\Http\Request;
class AuditRuntimeContextResolver
{
public function resolve(?Request $request = null, string $guard = 'admin'): array
{
$request ??= request();
$user = $request?->user($guard);
return [
'actor_id' => $user?->getAuthIdentifier(),
'actor_type' => $user ? $user::class : null,
'actor_name_snapshot' => $user?->name ?? $user?->nickname ?? null,
'request_id' => $request?->attributes->get('request_id') ?: $request?->headers->get('X-Request-Id'),
'trace_id' => $this->resolveTraceId($request),
'route_name' => $request?->route()?->getName(),
'ip' => $request?->ip(),
'user_agent' => $request?->userAgent(),
'ui_node_id' => $this->resolveUiNodeId($request),
];
}
protected function resolveUiNodeId(?Request $request): ?int
{
$value = $request?->headers->get('X-Ui-Node-Id');
if ($value === null || $value === '') {
return null;
}
$id = (int) $value;
return $id > 0 ? $id : null;
}
protected function resolveTraceId(?Request $request): ?string
{
if (! $request) {
return null;
}
$traceparent = $request->headers->get('traceparent');
if (is_string($traceparent)
&& preg_match('/^[\da-f]{2}-([\da-f]{32})-[\da-f]{16}-[\da-f]{2}$/', $traceparent, $matches)
) {
return $matches[1];
}
return $request->attributes->get('trace_id')
?: $request->headers->get('X-Trace-Id')
?: $request->headers->get('Trace-Id');
}
}app/Services/Audit/AuditRuntimeContextResolver.php
F.5 UI 与 Subject 解析
<?php
namespace App\Services\Audit;
use App\Models\Permission\UiNode;
class UiAuditContextResolver
{
public function resolve(?int $uiNodeId): array
{
if (! $uiNodeId) {
return $this->empty(null);
}
$uiNode = UiNode::query()->find($uiNodeId);
if (! $uiNode) {
return $this->empty($uiNodeId);
}
$pathLabels = [];
$current = $uiNode;
while ($current) {
array_unshift($pathLabels, $current->display_name);
$current = $current->parent;
}
return [
'ui_node_id' => $uiNode->id,
'ui_node_key' => $uiNode->key,
'ui_node_type' => $uiNode->type,
'ui_path_snapshot' => implode(' / ', $pathLabels),
'page_label_snapshot' => $this->resolvePageLabel($uiNode),
'trigger_label_snapshot' => $uiNode->type === 'button' ? $uiNode->display_name : null,
];
}
private function empty(?int $uiNodeId): array
{
return [
'ui_node_id' => $uiNodeId,
'ui_node_key' => null,
'ui_node_type' => null,
'ui_path_snapshot' => null,
'page_label_snapshot' => null,
'trigger_label_snapshot' => null,
];
}
protected function resolvePageLabel(UiNode $uiNode): ?string
{
$current = $uiNode;
while ($current) {
if ($current->type === 'page') {
return $current->display_name;
}
$current = $current->parent;
}
return null;
}
}app/Services/Audit/UiAuditContextResolver.php
<?php
namespace App\Services\Audit;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use RuntimeException;
class AuditSubjectResolver
{
public function resolve(mixed $subject): array
{
if (! $subject instanceof Model) {
throw new RuntimeException('Unsupported audit subject.');
}
$subjectType = Str::snake(class_basename($subject));
$subjectId = (string) $subject->getKey();
$subjectName = method_exists($subject, 'getAuditSubjectName')
? (string) $subject->getAuditSubjectName()
: $subjectType . '#' . $subjectId;
return [
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'subject_name_snapshot' => $subjectName,
];
}
}app/Services/Audit/AuditSubjectResolver.php
F.6 摘要、记录器、服务与 Trait
<?php
namespace App\Services\Audit;
class GenericAuditSummaryBuilder
{
public function build(array $payload): string
{
$actor = $payload['actor_name_snapshot'] ?? '未知用户';
$page = $payload['page_label_snapshot'] ?? null;
$trigger = $payload['trigger_label_snapshot'] ?? null;
$eventLabel = $payload['event_label'] ?? null;
$subject = $payload['subject_name_snapshot'] ?? null;
$routeName = $payload['route_name'] ?? null;
if ($page && $trigger && $subject) {
return sprintf('%s在%s点击“%s”,目标对象:%s', $actor, $page, $trigger, $subject);
}
if ($eventLabel && $subject) {
return sprintf('%s执行了“%s”,目标对象:%s', $actor, $eventLabel, $subject);
}
if ($routeName && $subject) {
return sprintf('%s通过%s执行了操作,目标对象:%s', $actor, $routeName, $subject);
}
return sprintf('%s执行了操作', $actor);
}
}app/Services/Audit/GenericAuditSummaryBuilder.php
<?php
namespace App\Services\Audit;
use App\Data\Audit\RecordOperationAuditData;
use App\Models\Audit\AuditLog;
use Illuminate\Support\Facades\Log;
use Throwable;
class OperationAuditRecorder
{
public function record(RecordOperationAuditData $data): void
{
try {
AuditLog::query()->create($data->toArray());
} catch (Throwable $e) {
Log::channel('security')->error('operation audit record failed', [
'event_key' => $data->eventKey,
'request_id' => $data->requestId,
'exception_class' => $e::class,
'exception_message' => $e->getMessage(),
]);
}
}
}app/Services/Audit/OperationAuditRecorder.php
<?php
namespace App\Services\Audit;
use App\Data\Audit\RecordOperationAuditData;
class OperationAuditService
{
public function __construct(
private readonly AuditRuntimeContextResolver $runtimeContextResolver,
private readonly UiAuditContextResolver $uiAuditContextResolver,
private readonly AuditSubjectResolver $auditSubjectResolver,
private readonly GenericAuditSummaryBuilder $summaryBuilder,
private readonly OperationAuditRecorder $recorder,
) {
}
public function success(
mixed $subject,
?string $reason = null,
array $properties = [],
array $tags = [],
?string $eventKey = null,
?string $eventLabel = null,
): void {
$runtime = $this->runtimeContextResolver->resolve();
$ui = $this->uiAuditContextResolver->resolve($runtime['ui_node_id']);
$subjectData = $this->auditSubjectResolver->resolve($subject);
$payload = array_merge($runtime, $ui, $subjectData, [
'event_key' => $eventKey ?: ($runtime['route_name'] ?: 'generic.operation'),
'event_label' => $eventLabel ?: $ui['trigger_label_snapshot'] ?: ($runtime['route_name'] ?: '执行操作'),
'result' => 'success',
'reason' => $reason,
'properties' => $properties,
'tags' => $tags,
]);
$this->recorder->record(new RecordOperationAuditData(
eventKey: $payload['event_key'],
eventLabel: $payload['event_label'],
result: $payload['result'],
actorId: $payload['actor_id'],
actorType: $payload['actor_type'],
actorNameSnapshot: $payload['actor_name_snapshot'],
uiNodeId: $payload['ui_node_id'],
uiNodeKey: $payload['ui_node_key'],
uiNodeType: $payload['ui_node_type'],
uiPathSnapshot: $payload['ui_path_snapshot'],
pageLabelSnapshot: $payload['page_label_snapshot'],
triggerLabelSnapshot: $payload['trigger_label_snapshot'],
subjectType: $payload['subject_type'],
subjectId: $payload['subject_id'],
subjectNameSnapshot: $payload['subject_name_snapshot'],
summary: $this->summaryBuilder->build($payload),
reason: $payload['reason'],
requestId: $payload['request_id'],
traceId: $payload['trace_id'],
routeName: $payload['route_name'],
ip: $payload['ip'],
userAgent: $payload['user_agent'],
properties: $payload['properties'],
tags: $payload['tags'],
));
}
}app/Services/Audit/OperationAuditService.php
<?php
namespace App\Services\Audit;
trait InteractsWithOperationAudit
{
protected function auditSuccess(
mixed $subject,
?string $reason = null,
array $properties = [],
array $tags = [],
?string $eventKey = null,
?string $eventLabel = null,
): void {
app(OperationAuditService::class)->success(
subject: $subject,
reason: $reason,
properties: $properties,
tags: $tags,
eventKey: $eventKey,
eventLabel: $eventLabel,
);
}
}app/Services/Audit/InteractsWithOperationAudit.php
F.7 业务调用示例
$this->auditSuccess(
subject: $refund,
reason: $reason,
properties: [
'refund_status' => RefundStatus::Approved->value,
],
tags: ['refund', 'approve'],
eventKey: 'order.refund.approved',
eventLabel: '退款审核通过',
);
前端统一传递 UI 节点:
X-Ui-Node-Id: 1001
附录 G. SQL 执行审计模板
附录 G 使用说明
- 适用场景:低成本记录应用执行过的写入类 SQL,用于数据变化追踪兜底。
- 推荐文件:
audit_sql_logsmigration、AuditSqlLog、SqlAuditRecorder、AppServiceProvider。 - 依赖前置:已建立 request_id 上下文;已确认只记录写入类 SQL。
- 接入步骤:配置
config/audit.php,在 Provider 中监听 DB query,筛选写入 SQL 后落库。 - 验收标准:insert / update / delete 被记录,select 不被记录,日志可通过
request_id关联请求和业务审计。
G.1 audit_sql_logs migration
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('audit_sql_logs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('request_id', 64)->nullable()->index();
$table->string('trace_id', 64)->nullable()->index();
$table->unsignedBigInteger('actor_id')->nullable()->index();
$table->string('actor_type', 255)->nullable();
$table->unsignedBigInteger('ui_node_id')->nullable()->index();
$table->string('route_name', 255)->nullable()->index();
$table->string('connection_name', 64)->nullable();
$table->string('operation', 32)->index();
$table->string('table_name', 255)->nullable()->index();
$table->longText('sql_template');
$table->json('bindings')->nullable();
$table->unsignedInteger('duration_ms')->default(0);
$table->timestamp('logged_at')->useCurrent()->index();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('audit_sql_logs');
}
};database/migrations/xxxx_xx_xx_xxxxxx_create_audit_sql_logs_table.php
G.2 AuditSqlLog Model
<?php
namespace App\Models\Audit;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model;
class AuditSqlLog extends Model
{
use MassPrunable;
protected $table = 'audit_sql_logs';
protected $fillable = [
'request_id', 'trace_id', 'actor_id', 'actor_type', 'ui_node_id', 'route_name',
'connection_name', 'operation', 'table_name', 'sql_template', 'bindings', 'duration_ms', 'logged_at',
];
protected $casts = [
'bindings' => 'array',
'logged_at' => 'datetime',
];
public function prunable(): Builder
{
return static::query()->where('logged_at', '<', now()->subDays((int) config('audit.sql.prune_days', 30)));
}
}app/Models/Audit/AuditSqlLog.php
G.3 SQL 监听服务
<?php
namespace App\Services\Audit;
use App\Models\Audit\AuditSqlLog;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Throwable;
class SqlAuditRecorder
{
private array $writeOperations = ['insert', 'update', 'delete', 'replace', 'truncate', 'alter', 'drop', 'create'];
public function record(QueryExecuted $query): void
{
$operation = $this->resolveOperation($query->sql);
if (! $operation || ! in_array($operation, $this->writeOperations, true)) {
return;
}
try {
/** @var Request|null $request */
$request = app()->bound('request') ? request() : null;
$user = $request?->user('admin') ?: $request?->user();
AuditSqlLog::query()->create([
'request_id' => $request?->attributes->get('request_id') ?: $request?->headers->get('X-Request-Id'),
'trace_id' => $request?->attributes->get('trace_id') ?: $request?->headers->get('X-Trace-Id'),
'actor_id' => $user?->getAuthIdentifier(),
'actor_type' => $user ? $user::class : null,
'ui_node_id' => (int) ($request?->headers->get('X-Ui-Node-Id') ?: 0) ?: null,
'route_name' => $request?->route()?->getName(),
'connection_name' => $query->connectionName,
'operation' => $operation,
'table_name' => $this->resolveTableName($query->sql, $operation),
'sql_template' => $query->sql,
'bindings' => $this->sanitizeBindings($query->bindings),
'duration_ms' => (int) round($query->time),
'logged_at' => now(),
]);
} catch (Throwable $e) {
Log::channel('security')->warning('sql audit record failed', [
'exception_class' => $e::class,
'exception_message' => $e->getMessage(),
]);
}
}
private function resolveOperation(string $sql): ?string
{
$sql = ltrim(strtolower($sql));
foreach ($this->writeOperations as $operation) {
if (str_starts_with($sql, $operation)) {
return $operation;
}
}
return null;
}
private function resolveTableName(string $sql, string $operation): ?string
{
$patterns = [
'insert' => '/insert\s+into\s+[`"]?([\w.]+)[`"]?/i',
'update' => '/update\s+[`"]?([\w.]+)[`"]?/i',
'delete' => '/delete\s+from\s+[`"]?([\w.]+)[`"]?/i',
'replace' => '/replace\s+into\s+[`"]?([\w.]+)[`"]?/i',
'truncate' => '/truncate\s+table\s+[`"]?([\w.]+)[`"]?/i',
'alter' => '/alter\s+table\s+[`"]?([\w.]+)[`"]?/i',
'drop' => '/drop\s+table\s+[`"]?([\w.]+)[`"]?/i',
'create' => '/create\s+table\s+[`"]?([\w.]+)[`"]?/i',
];
if (isset($patterns[$operation]) && preg_match($patterns[$operation], $sql, $matches)) {
return $matches[1];
}
return null;
}
private function sanitizeBindings(array $bindings): array
{
return array_map(function (mixed $binding) {
if (is_string($binding) && mb_strlen($binding) > 500) {
return mb_substr($binding, 0, 500) . '... [truncated]';
}
return $binding;
}, $bindings);
}
}app/Services/Audit/SqlAuditRecorder.php
G.4 监听注册
<?php
namespace App\Providers;
use App\Services\Audit\SqlAuditRecorder;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
if ((bool) config('audit.sql.enabled', false)) {
DB::listen(function (QueryExecuted $query): void {
app(SqlAuditRecorder::class)->record($query);
});
}
}
}app/Providers/AppServiceProvider.php
G.5 配置
<?php
return [
'sql' => [
'enabled' => env('AUDIT_SQL_ENABLED', false),
'prune_days' => (int) env('AUDIT_SQL_PRUNE_DAYS', 30),
],
];config/audit.php
SQL 审计默认建议关闭,只有需要兜底追踪写入 SQL 的后台系统或排障期再开启。高流量生产环境必须评估存储量与性能影响。
附录 H. Open API 签名与 Webhook 幂等模板
附录 H 使用说明
- 适用场景:Open API 调用方验签、Webhook 回调验签与重复推送处理。
- 推荐文件:签名 Middleware、Webhook Data、回调处理 Service。
- 依赖前置:调用方有稳定 app key / secret,回调平台提供事件 ID 或交易号。
- 接入步骤:先验签,再限流,再按事件唯一 ID 幂等处理,最后进入业务事务。
- 验收标准:签名错误被拒绝,重复 event_id 不重复执行业务变更。
H.1 Open API 签名 Middleware
<?php
namespace App\Http\Middleware;
use App\Exceptions\ApiHttpException;
use App\Support\ErrorCodes\Common\CommonError;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class VerifyOpenApiSignature
{
public function handle(Request $request, Closure $next): Response
{
$appId = (string) $request->header('X-App-Id');
$timestamp = (int) $request->header('X-Timestamp');
$nonce = (string) $request->header('X-Nonce');
$signature = (string) $request->header('X-Signature');
if ($appId === '' || $timestamp <= 0 || $nonce === '' || $signature === '') {
throw ApiHttpException::fromError(CommonError::INVALID_SIGNATURE);
}
if (abs(time() - $timestamp) > 300) {
throw ApiHttpException::fromError(CommonError::SIGNATURE_EXPIRED);
}
$secret = $this->resolveSecret($appId);
$bodyHash = hash('sha256', $request->getContent() ?: '');
$payload = implode("\n", [
strtoupper($request->method()),
'/' . ltrim($request->path(), '/'),
(string) $timestamp,
$nonce,
$bodyHash,
]);
$expected = hash_hmac('sha256', $payload, $secret);
if (! hash_equals($expected, $signature)) {
throw ApiHttpException::fromError(CommonError::INVALID_SIGNATURE);
}
return $next($request);
}
private function resolveSecret(string $appId): string
{
// 实际项目应从数据库或配置读取,并校验 app 状态、IP 白名单、权限范围。
return (string) config("open_api.clients.{$appId}.secret");
}
}app/Http/Middleware/VerifyOpenApiSignature.php
H.2 Webhook 幂等表
Schema::create('webhook_events', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('provider', 64);
$table->string('event_id', 128);
$table->string('status', 32)->default('processing');
$table->json('payload')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamps();
$table->unique(['provider', 'event_id']);
});
H.3 支付回调处理模板
DB::transaction(function () use ($data) {
$event = PaymentCallbackEvent::query()->firstOrCreate(
['provider' => $data->provider, 'event_id' => $data->eventId],
['payload' => $data->payload, 'status' => 'processing'],
);
if ($event->status === 'succeeded') {
return;
}
$order = Order::query()
->where('order_no', $data->orderNo)
->lockForUpdate()
->firstOrFail();
if ($order->status === OrderStatus::Paid) {
$event->update(['status' => 'succeeded']);
return;
}
if (! $order->status->canMarkAsPaid()) {
$event->update(['status' => 'failed']);
throw ApiHttpException::fromError(OrderError::STATUS_NOT_PAYABLE);
}
$order->update([
'status' => OrderStatus::Paid,
'paid_at' => now(),
]);
PaymentLog::query()->create([
'order_id' => $order->id,
'transaction_no' => $data->transactionNo,
'amount' => $data->amount,
]);
$event->update(['status' => 'succeeded', 'processed_at' => now()]);
SyncOrderToErpJob::dispatch($order->id)->afterCommit();
}, attempts: 3);
附录 I. 文件上传与存储模板
附录 I 使用说明
- 适用场景:单机项目、本地磁盘上传、公开资源、私有文件和文件记录表。
- 推荐文件:
config/filesystems.php、UploadFileService、上传文件表 migration。 - 依赖前置:已确定上传根目录是否放在项目外部资源目录。
- 接入步骤:配置 disk,校验文件类型和大小,使用 hash 文件名,落库记录访问路径和存储路径。
- 验收标准:上传文件不覆盖,路径不可预测,公开 / 私有访问边界清晰,目录已纳入备份。
I.1 filesystems.php
'uploads' => [
'driver' => 'local',
'root' => env('UPLOADS_ROOT', '/data/hitek/uploads'),
'throw' => true,
],
'private' => [
'driver' => 'local',
'root' => env('PRIVATE_FILES_ROOT', '/data/hitek/private'),
'throw' => true,
],config/filesystems.php
I.2 UploadFileService
<?php
namespace App\Services\File;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class UploadFileService
{
public function storePublicImage(UploadedFile $file, string $directory = 'images'): array
{
$this->ensureAllowedImage($file);
$extension = strtolower($file->getClientOriginalExtension() ?: $file->extension());
$hash = hash_file('sha256', $file->getRealPath());
$name = now()->format('Ymd') . '/' . Str::uuid() . '_' . substr($hash, 0, 16) . '.' . $extension;
$path = trim($directory, '/') . '/' . $name;
Storage::disk('uploads')->putFileAs(dirname($path), $file, basename($path));
return [
'disk' => 'uploads',
'path' => $path,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'sha256' => $hash,
];
}
private function ensureAllowedImage(UploadedFile $file): void
{
$allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (! in_array($file->getMimeType(), $allowed, true)) {
throw new \InvalidArgumentException('Unsupported image type.');
}
if ($file->getSize() > 5 * 1024 * 1024) {
throw new \InvalidArgumentException('File too large.');
}
}
}app/Services/File/UploadFileService.php
I.3 Nginx 静态资源映射
location /uploads/ {
alias /data/hitek/uploads/;
try_files $uri =404;
add_header X-Content-Type-Options nosniff;
}
私有文件不得直接用 Nginx alias 暴露,应通过后端鉴权后返回临时下载流或签名 URL。
附录 J. 队列、Horizon、Scheduler 生产模板
附录 J 使用说明
- 适用场景:生产队列、Horizon、Scheduler、定时任务监控。
- 推荐文件:Job、Supervisor / systemd 配置、
routes/console.php调度配置。 - 依赖前置:Redis、队列连接、cron、Horizon 权限已配置。
- 接入步骤:先配置队列和 Horizon,再配置 Scheduler cron,最后接入 Schedule Monitor / Uptime Kuma。
- 验收标准:Job 失败可见,Scheduler 每分钟触发,关键定时任务失败有告警。
J.1 Job 模板
<?php
namespace App\Jobs\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
class SyncOrderToErpJob implements ShouldQueue, ShouldBeUnique
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $timeout = 60;
public int $uniqueFor = 3600;
public function __construct(public readonly int $orderId)
{
$this->onQueue('integrations');
}
public function uniqueId(): string
{
return 'sync-order-to-erp:' . $this->orderId;
}
public function middleware(): array
{
return [(new WithoutOverlapping($this->uniqueId()))->expireAfter(300)];
}
public function handle(): void
{
// 重新查询订单,执行可重试且幂等的同步逻辑。
}
}app/Jobs/Order/SyncOrderToErpJob.php
J.2 Supervisor 普通队列模板
[program:hitek-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/hitek-api/current/artisan queue:work redis --queue=default,logs,integrations --sleep=3 --tries=3 --timeout=90
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/supervisor/hitek-worker.log
stopwaitsecs=3600/etc/supervisor/conf.d/hitek-worker.conf
发布后:
php artisan queue:restart
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl restart hitek-worker:*
J.3 Horizon 发布命令
php artisan horizon:terminate
Horizon 应由 Supervisor 守护,发布时使用 horizon:terminate 让 Horizon 平滑退出并由 Supervisor 拉起新进程。
J.4 Scheduler cron
* * * * * cd /var/www/hitek-api/current && php artisan schedule:run >> /dev/null 2>&1
J.5 Uptime Kuma + Schedule Monitor 落地方式
定时任务监控建议两层:
Spatie Laravel Schedule Monitor:监控 Laravel 内部任务是否按计划运行。
Uptime Kuma:从外部监控 HTTP / ping / push 心跳是否正常。
推荐做法:
composer require spatie/laravel-schedule-monitor
php artisan vendor:publish --provider="Spatie\ScheduleMonitor\ScheduleMonitorServiceProvider"
php artisan migrate
在 routes/console.php 中给关键任务命名:
Schedule::command('model:prune', ['--model' => [RequestLog::class]])
->dailyAt('02:10')
->name('request-log-prune')
->onOneServer();
Uptime Kuma 配置:
1. 新增 Monitor。
2. 类型选择 HTTP(s) 或 Push。
3. HTTP(s):监控 /up 或内部健康检查接口。
4. Push:定时任务成功后请求 Uptime Kuma 生成的 Push URL。
5. 设置通知渠道:企业微信、Telegram、邮件或 Webhook。
附录 K. Nginx、PHP-FPM 与上线命令模板
附录 K 使用说明
- 适用场景:普通单机部署、Nginx、PHP-FPM、HTTPS、发布和回滚命令。
- 推荐文件:Nginx server 配置、发布脚本、回滚脚本、环境变量检查清单。
- 依赖前置:服务器已安装 PHP、Composer、Nginx、数据库、Redis。
- 接入步骤:先配置站点目录和 PHP-FPM,再配置 Nginx,最后执行发布命令和健康检查。
- 验收标准:
/up可访问,静态资源正常,错误日志无明显异常,回滚命令可执行。
K.1 Nginx API 站点模板
server {
listen 80;
server_name api.example.com;
root /var/www/hitek-api/current/public;
index index.php index.html;
client_max_body_size 20m;
add_header X-Frame-Options SAMEORIGIN;
add_header X-Content-Type-Options nosniff;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_read_timeout 120;
}
location ~ /\.(?!well-known).* {
deny all;
}
}/etc/nginx/conf.d/hitek-api.conf
K.2 单机发布命令模板
cd /var/www/hitek-api/current
git fetch --all --prune
git checkout main
git pull --ff-only
composer install --no-dev --prefer-dist --optimize-autoloader
php artisan migrate --force
php artisan optimize:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache
git rev-parse --short HEAD
php artisan about
php artisan queue:restart
php artisan horizon:terminate || true
curl -fsS https://api.example.com/up
K.3 发布前检查
php artisan migrate:status
php artisan test
composer validate
php -v
php artisan about
附录 L. 测试与工程门禁模板
附录 L 使用说明
- 适用场景:项目测试基线、CI、错误码唯一性、发布门禁。
- 推荐文件:Feature Test、Unit Test、GitHub Actions workflow、错误码测试。
- 依赖前置:项目已有测试数据库或 sqlite 测试环境。
- 接入步骤:先补关键业务测试,再接入 Pint 和 test,最后按需接入 PHPStan / Larastan。
- 验收标准:CI 自动运行,失败不能合并,核心状态机和错误码测试稳定通过。
L.1 GitHub Actions 示例
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: testing
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
extensions: mbstring, dom, fileinfo, mysql, redis
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Prepare env
run: |
cp .env.example .env
php artisan key:generate
- name: Pint
run: ./vendor/bin/pint --test
- name: Test
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: root
run: php artisan test.github/workflows/ci.yml
L.2 错误码唯一性测试
<?php
namespace Tests\Unit\Support\ErrorCodes;
use App\Support\ErrorCodes\Contracts\BusinessError;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class ErrorCodeUniquenessTest extends TestCase
{
#[Test]
public function business_error_codes_and_keys_are_unique(): void
{
$errors = [
...\App\Support\ErrorCodes\Order\OrderError::cases(),
// ...继续加入其他错误枚举
];
$codes = [];
$keys = [];
foreach ($errors as $error) {
$this->assertInstanceOf(BusinessError::class, $error);
$codes[] = $error->code();
$keys[] = $error->key();
}
$this->assertSame($codes, array_unique($codes));
$this->assertSame($keys, array_unique($keys));
}
}tests/Unit/Support/ErrorCodes/ErrorCodeUniquenessTest.php
24. 最终结论
新 Laravel 项目默认采用:
薄 Controller
+ FormRequest 输入校验
+ Data 结构化传参
+ Application Service 用例编排
+ Domain Service 领域规则
+ Eloquent Model 实体表达
+ Resource 输出映射
+ ApiResponder 统一响应
+ BusinessError 统一错误码
+ Sanctum 认证
+ spatie 权限事实源
+ UI 节点语义层
+ 状态机治理核心状态
+ 事务 / 锁 / 幂等保障核心写入
+ Cache Aside 缓存策略
+ request_id 串联请求日志、业务审计、SQL 审计
+ CI / Pint / Test 工程门禁
判断一份代码是否符合规范,只看三个问题:
- 职责是否放在正确层级。
- 核心业务是否具备一致性、权限、审计、幂等和测试保障。
- 生产环境是否可发布、可回滚、可监控、可排查。