Skip to content

Laravel 项目开发架构规范

Jamie Liu
Published date:
Edit this post

适用范围:新 Laravel 项目、后台系统、多端统一后端、开放接口、运营管理系统、需要权限、审计、队列、定时任务、上线规范的中大型项目。

本文档定义默认工程规范。历史项目可在迁移期兼容,但新代码、Code Review、架构评审、接口设计、测试与上线检查,应以本文档为准。


Table of contents

Open Table of contents

1. 文档定位与基本原则

1.1 文档目标

本规范用于统一 Laravel 项目的分层、目录、输入输出、认证权限、状态流转、事务并发、缓存、日志审计、队列任务、上线运行和工程门禁。

目标不是解释 Laravel 原理,而是定义团队默认怎么写、什么不能写、评审看什么、上线检查什么。

1.2 默认原则

1.3 代码示例保留策略

本规范不是纯原则文档。正文保留能解释主链路的最小关键代码;完整可复制的 Middleware、Migration、Service、配置模板和部署脚本统一放入附录。

阅读方式:


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专用查询类,适合复杂列表、导出、统计查询复用。
ResourceAPI 输出映射层,负责字段、格式、状态、嵌套结构。
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

默认规则:

4.2 层间交付物

输入输出不允许
RouteHTTP 请求命中路由与中间件组写业务逻辑
MiddlewareRequest通过 / 拒绝 / 注入上下文编排业务状态流转
FormRequestRequestvalidated() 数据查询数据库做复杂业务判断
ControllerFormRequest、ServiceResource + ApiResponder事务、复杂查询、复杂响应数组
Application ServiceInput Data / Query Data / Model IDModel、DTO、Paginator、Collection返回 JsonResponse
Domain Service显式传入的领域对象和值判断结果、计算结果、异常读取 request/auth、控制事务
Repository / Query ObjectQuery Data、上下文Builder、Collection、Paginator包装 HTTP 响应
ResourceModel / DTO / Collection数组结构触发查询、写入、复杂业务判断
ApiResponderResource / array / nullJsonResponse处理业务规则

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');
            });
    });

规则:

禁止:

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 注入。

允许:

不允许:

建议拆分:

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

职责:

示例:

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'],
        ];
    }
}

边界:

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 可以做:

Controller 禁止:

4.7 Application Service 规范

Application Service 是一个业务用例的主入口。

推荐目录:

app/Services/{Port}/{Module}/{Action}Service.php

命名示例:

CreateOrderService
CancelOrderService
ApproveRefundService
ListOrderService
ExportOrderService

职责:

示例:

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);
    }
}

规则:

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,
                ],
            );
        }
    }
}

规则:

4.9 Repository / Query Object 规范

Repository 是可选层,Query Object 是复杂查询的推荐落点。

引入 Repository 的条件:

引入 Query Object 的条件:

普通 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:

不适合隐藏到 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

禁止:

4.12 本章检查清单

Code Review 时必须确认:


5. 输入校验与 Data 对象规范

5.1 本章定位

本章定义请求输入从 HTTP 参数进入应用层之前的处理方式。

目标:

5.2 校验三层职责

职责示例
Rule Object复杂单字段规则ValidMobileRuleStrongPasswordRule
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(),
        ];
    }
}

规则:

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',
        ];
    }
}

规则:

5.5 Rule Object 规范

复杂单字段校验使用 Rule Object。

适用场景:

推荐目录:

app/Rules/{Name}Rule.php

不建议把大段复杂正则和判断直接塞进 FormRequest。

5.6 Data 对象定位

Data 用于承接应用层输入或输出结构。

Data 不替代:

三类 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'],
        );
    }
}

规则:

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 本章检查清单


6. Model、Eloquent 与数据库规范

6.1 本章定位

本章定义 Model、Eloquent、Migration、数据库字段与索引的默认写法。

核心原则:

Model 是 Eloquent 实体表达层,不是业务流程层。
数据库是核心事实来源,不把一致性寄托在前端、缓存或临时代码判断上。

6.2 Model 职责边界

Model 负责:

Model 不负责:

允许:

$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',
];

规则:

敏感字段示例:

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
booleanboolean
JSONarray / 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 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:

不建议放 Observer:

SoftDeletes 不默认全表使用:

场景建议
用户、商品、客户、订单类主数据可以
日志、流水、审计记录通常不用
中间表、绑定表按是否需要恢复决定
临时表、缓存表不用

全局 Scope 必须谨慎。不得用全局 Scope 做后台权限过滤、当前用户过滤或复杂业务状态隐藏。

6.11 Migration 与字段规范

默认规则:

示例:

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 索引与唯一约束

必须使用唯一索引兜底的场景:

示例:

$table->unique(['provider', 'event_id']);
$table->unique(['scope', 'idempotency_key']);
$table->unique(['user_id', 'coupon_id', 'source_id']);

规则:

6.13 本章检查清单


7. 查询、筛选、排序、分页与导出规范

7.1 本章定位

本章约束列表页、搜索页、导出、统计查询的统一写法。

核心目标:

标准链路:

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'],
        ];
    }
}

规则:

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');
    }
}

规则:

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 . '%');
            });
    });
}

规则:

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);

禁止:

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
});

规则:

7.12 本章检查清单


8. 统一响应、异常处理与业务错误码

8.1 本章定位

本章定义 API 成功响应、失败响应、分页响应、异常渲染和业务错误码的统一协议。

核心目标:

8.2 成功响应结构

{
  "code": 0,
  "message": "success",
  "data": {},
  "meta": {},
  "links": {}
}

字段职责:

字段职责
code业务状态码,成功默认为 0
message人类可读消息
data业务数据主体
meta元信息,如 request_idserver_timepaginationwarnings
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扩展信息

规则:

8.4 Resource、Controller、ApiResponder 边界

Application Service -> 返回业务结果
Resource            -> 映射 data 内容
ApiResponder        -> 包装顶层响应协议
Exception Handler   -> 复用 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
  }
}

规则:

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.',
        };
    }
}

规则:

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(),
    );
});

规则:

8.11 错误码测试要求

至少覆盖:

示例:

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 本章检查清单


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 认证。

场景默认方案说明
同主域 / 同站后台 SPASanctum Cookie Session适合后台管理系统
App / 小程序 / 多端 APISanctum Personal Access Token适合 Bearer Token
第三方开放平台Passport / OAuth2 或自建 AccessKey 签名按开放平台复杂度决定
登录注册脚手架Fortify 可选不作为权限系统

规范要求:

后台 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   -> 开放平台调用方

规则:

路由分组示例:

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对当前请求做轻量授权入口

规范要求:

推荐表:

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强制关闭

禁止:

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);
    }
}

规则:

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;
}

规则:

9.8 UI 节点 Header 与审计上下文

前端统一通过 Header 传递 UI 节点:

X-Ui-Node-Id: 1001

用途:

规则:

9.9 SPA API 安全规范

SPA 的前端代码、接口地址、请求参数都可以被用户看到。不要把“接口只给我的前端调用”理解为安全边界。

默认规则:

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);
}

规则:

9.11 Webhook 安全规范

Webhook 必须先验签,再处理业务。

规则:

推荐处理链路:

Webhook Route
  -> provider signature middleware
  -> callback FormRequest
  -> callback Data
  -> HandleWebhookService
  -> event unique record
  -> transaction + lockForUpdate
  -> state transition
  -> afterCommit Job

9.12 本章检查清单


10. 状态枚举与状态机规范

10.1 本章定位

状态规范用于统一数据库状态值、后端判断、API 输出、前端判断和状态流转规则。

默认结构:

int backed enum
  -> Model casts()
  -> Domain 状态机 / 状态流转服务
  -> Application Service 事务执行
  -> Resource 显式输出 status / status_key / status_label / can_xxx
  -> Test 覆盖允许与禁止流转

适合状态机的对象:订单、支付、退款、售后、优惠券、发货、审核、结算、导入导出任务、工单、权限审批。

普通二值开关,例如 is_activeis_defaultis_deleted,使用 boolean cast 即可。

判断原则:

只表示开关:boolean cast。
驱动业务流程:Enum + 状态机。
涉及钱、库存、权益、权限、结算:必须集中管理流转规则。

10.2 状态字段数据库规范

核心状态字段推荐使用 unsignedTinyIntegerunsignedSmallInteger,并建立必要索引。

$table->unsignedTinyInteger('status')
    ->default(OrderStatus::PendingPay->value)
    ->index();

规则:

10.3 Enum 职责与标准模板

Enum 负责表达“状态是什么”,不负责执行业务流程。

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);
    }

    public static function options(): array
    {
        return array_map(
            fn (self $status) => [
                'value' => $status->value,
                'key' => $status->key(),
                'label' => $status->label(),
            ],
            self::cases(),
        );
    }
}

Enum 禁止:

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;
    }
}

规则:

10.5 状态机 / 状态流转服务职责

状态机负责表达“能不能从 A 到 B”,以及“某个业务动作是否允许”。

状态机负责:

状态机不负责:

推荐目录:

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);

原因:真实业务动作往往不只判断状态,还可能判断支付、退款、发货、时间窗口、权限、组织范围、售后记录等上下文。

规则:

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);
    }
}

规则:

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,
];

规则:

复杂 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

测试至少覆盖:

10.11 本章检查清单


11. 事务、并发、锁与幂等规范

11.1 本章定位

事务、并发、锁与幂等用于保证核心写入流程在重复请求、并发请求、回调重放和队列重试下保持可预测、可追踪、可恢复。

默认规则:

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);

规则:

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);

规则:

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);
}

规则:

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);

适用:

规则:

11.6 唯一索引兜底规范

任何强唯一业务约束,最终必须落到数据库唯一索引。

常见唯一约束:

迁移示例:

$table->string('transaction_no', 100)->unique();
$table->unique(['provider', 'event_id']);
$table->unique(['scope', 'idempotency_key']);

规则:

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);
}

规则:

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     -> 已失败,可按业务决定是否允许重试

规则:

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
    {
        // 仍需检查本地同步状态或外部幂等键。
    }
}

规则:

11.12 选择规则

问题首选方案
多表写入原子性DB::transaction()
同一行串行处理lockForUpdate()
库存 / 余额 / 次数扣减条件 update / decrement
防重复创建唯一业务数据数据库唯一索引
防同一资源跨请求并发执行Cache::lock()
防 Open API / 支付回调重复生效幂等 key + 唯一索引
防重复 Job 投递ShouldBeUnique
防同类 Job 同时执行WithoutOverlapping
防状态乱跳Enum + 状态机 + 条件更新 / 行锁

11.13 本章检查清单


12. 缓存与 Redis 规范

12.1 本章定位

缓存是性能优化层,不是业务事实来源。

默认原则:

数据库 = 事实来源
缓存 = 加速读取
缓存锁 = 降低并发冲突
事务 / 行锁 / 条件更新 / 唯一索引 / 状态机 = 一致性保障

不得只依赖缓存作为以下数据的最终判断依据:订单状态、支付状态、退款状态、库存、余额、权限最终授权结果、审计日志、支付回调 / Webhook 幂等结果。

12.2 适合缓存的数据

适合缓存:

不适合只靠缓存:

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);
    }
}

规则:

筛选参数 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}";

规则:

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 小时

规则:

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}");
    });
});

规则:

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() ?? [];
});

规则:

12.9 权限缓存规范

权限缓存属于安全敏感缓存。

规则:

示例:

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));

规则:

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

规则:

推荐隔离:

cache   -> Redis db 0 或 prefix cache:
session -> Redis db 1 或 prefix session:
queue   -> Redis db 2 或 prefix queue:
lock    -> 与 cache 同库但使用 lock: prefix

12.13 本章检查清单


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 标准规则:

标准示例:

<?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 标准规则:

标准示例:

<?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');

规则:

13.6 日志通道与运行时上下文

日志不应全部混入 laravel.log。建议至少拆分以下通道:

通道用途
requestHTTP 请求摘要日志
security认证失败、签名失败、权限异常、风控
audit业务操作审计相关异常
queue队列任务运行与失败上下文
schedule定时任务执行摘要
third_party第三方接口请求、响应摘要、重试失败
payment支付、退款、回调、资金链路
sql_auditSQL 审计采集异常和辅助日志

统一运行时上下文:

HTTP 请求中的上下文由 Middleware 采集;异步 Job 来源于 HTTP 请求时,应显式传入 request_id

SyncOrderToErpJob::dispatch(
    orderId: $order->id,
    requestId: request()->attributes->get('request_id'),
)->afterCommit();

13.7 本章检查清单


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

规则:

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',
        ],
    ];
}

规则:

14.6 文件命名与路径规则

真实存储名不得使用用户原始文件名。

推荐结构:

{domain}/{yyyy}/{mm}/{dd}/{hash_prefix}/{ulid}_{sha256_short}.{ext}

示例:

product/2026/04/29/ab/01HWZ7N8K2G6Z5G1M5R9T8P2Q1_ab34cd56.jpg

规则:

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

规则:

14.8 访问方式

公开文件:

私有文件:

Laravel 下载示例:

return Storage::disk('private')->download(
    $file->path,
    $file->original_name,
);

14.9 清理策略

必须清理:

清理任务要求:

示例命令:

php artisan file:prune-temp --dry-run
php artisan file:prune-temp

14.10 文件上传检查清单


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

要求:

简化示例:

$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 至少定义:

通道用途
requestHTTP 请求摘要
security安全、认证、权限、签名、风控
audit业务操作审计异常
queue队列运行与失败
schedule定时任务运行
third_party第三方接口摘要
payment支付、退款、回调
sql_auditSQL 审计采集异常

日志字段推荐:

禁止记录:明文密码、明文 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

触发策略:

脱敏字段至少包括:

authorization, cookie, password, password_confirmation,
old_password, new_password, token, access_token,
refresh_token, secret, sign

规则:

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

规则:

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

规则:

15.8 监控与告警

生产必须至少监控:

普通项目推荐组合:

工具负责内容
Uptime KumaHTTP / TCP / Cron 心跳外部监控
HorizonLaravel 队列监控
Spatie Laravel Schedule MonitorScheduler 任务执行状态
Laravel 日志通道本地兜底排障
数据库备份脚本 / 云备份数据恢复能力

15.9 本章检查清单


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'));

规则:

16.3 兼容性变更

兼容变更:

破坏性变更:

破坏性变更必须走新版本或明确迁移期。

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 接口合同管理

以下内容属于接口合同,变更必须评审:

规则:

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

规范要求:

16.6.2 文档产物目录

推荐目录:

docs/
└── api/
    ├── openapi.json
    ├── error-codes.md
    ├── changelog.md
    ├── open/
    │   └── v1/
    │       ├── openapi.json
    │       └── changelog.md
    └── admin/
        └── v1/
            ├── openapi.json
            └── changelog.md

规则:

16.6.3 文档内容要求

每个接口至少应能从 OpenAPI 中表达:

统一成功响应必须体现:

{
  "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 注解补充

规范要求:

16.6.5 AI 生成代码约束

当 AI 新增或修改 API 时,必须同步维护接口合同。

AI 必须执行:

AI 禁止:

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

规范要求:

16.6.7 访问控制

本规范不要求生产环境公开 API 文档页面。

规则:

禁止:

16.7 本章检查清单


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

规则:

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

规则:

17.5 数据库迁移与回滚

migration 生产规则:

推荐扩展字段流程:

第 1 次发布:新增 nullable 字段,新旧代码兼容
第 2 次发布:代码开始写入新字段
第 3 次发布:回填历史数据
第 4 次发布:确认无旧代码依赖后再加 not null / 删除旧字段

发布前预检:

php artisan migrate:status
php artisan db:show

禁止:

17.6 队列生产运行

推荐使用 Redis 队列 + Horizon。

生产要求:

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

规则:

17.8 备份与恢复

生产至少备份:

类型要求
MySQL 数据库必须,建议每日全量 + 发布前临时备份
上传公开文件必须
上传私有文件必须
.env / 密钥必须安全保管,不和普通备份混放
对象存储必须开启版本或跨区域备份,按业务风险决定
审计日志按合规和排障周期保留

不需要备份:vendor/node_modules/、Laravel cache、临时文件、可重新生成的前端产物。

规则:

17.9 Nginx / PHP-FPM / HTTPS 基线

Nginx 必须:

PHP-FPM 必须:

17.10 健康检查与冒烟测试

Laravel 默认 /up 可作为基础存活检查。生产建议扩展只读健康检查:

健康检查禁止泄露敏感配置、数据库名称、密钥、内部网络细节。

发布后冒烟测试至少覆盖:

17.11 上线前检查清单

代码检查:

配置检查:

数据库检查:

运行检查:


18. 测试规范与工程门禁

18.1 本章定位

测试与工程门禁用于保证规范不是纸面约束,而是在提交、合并、发布前被自动或半自动验证。

第一版不追求 100% 覆盖率,优先覆盖高风险业务和长期容易回归的系统边界。

18.2 测试目录

推荐结构:

tests/
├── Feature/
│   ├── Admin/
│   ├── Client/
│   └── Open/
├── Unit/
│   ├── Domain/
│   ├── Support/
│   └── Services/
└── TestCase.php

规则:

18.3 优先测试范围

必须优先覆盖:

不建议第一版过度投入:

18.4 Feature Test 标准

接口测试至少验证:

示例:

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 错误码测试

业务错误码必须纳入工程门禁。

必须验证:

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 合并门禁

合并前必须满足:

18.10 发布门禁

发布前必须满足:


19. 命名与编码约束

19.1 通用代码风格基线

新项目默认遵守以下代码风格基线:

推荐 pint.json

{
  "preset": "laravel"
}

规范要求:

19.2 命名总原则

命名必须表达业务语义和分层职责,不为“显得架构完整”制造抽象。

默认规则:

19.3 PHP 类、文件与目录命名

PHP 类名、文件名、namespace 必须保持一致。

推荐:

app/Services/Admin/Order/CancelOrderService.php
App\Services\Admin\Order\CancelOrderService

规则:

架构类命名必须表达职责:

类型命名规则推荐目录示例
Controller{Module}Controllerapp/Http/Controllers/{端}/{模块}OrderController
FormRequest{Action}{Module}Requestapp/Http/Requests/{端}/{模块}CreateOrderRequest
Application Service{Action}{Module}Serviceapp/Services/{端}/{模块}CancelOrderService
Domain Service{Domain}{Capability}Serviceapp/Domain/{领域}OrderStatusTransitionService
State Machine{Domain}StateMachine{Domain}StatusTransitionServiceapp/Domain/{领域}OrderStatusTransitionService
Query Object{Module}ListQuery / {Module}Queryapp/Queries/{领域}app/Repositories/{领域}OrderListQuery
Resource{Module}Resource / {Module}ListResource / {Page}Resourceapp/Http/Resources/{端}/{模块}OrderResource
Input Data{Action}{Module}Dataapp/Data/{领域}CreateOrderData
Query Data{Module}ListQueryDataapp/Data/{领域}OrderListQueryData
Output Data{Module}{Scene}Dataapp/Data/{领域}DashboardPageData
Job{Action}{Domain}Jobapp/Jobs/{领域}SyncOrderToErpJob
Command{domain}:{action}app/Console/Commandsorders:expire-unpaid
Enum{Domain}{Field} / {Domain}Statusapp/Enums/{领域}OrderStatus
Error Enum{Domain}Errorapp/Support/ErrorCodes/{领域}OrderError
Middleware{Action}{Concern}Middlewareapp/Http/MiddlewareVerifyOpenApiSignatureMiddleware

禁止:

OrderManager
CommonService
BaseService
Helper
Util
OrderService2
NewOrderService

19.4 变量、方法、常量与字段命名

19.4.1 变量与属性

PHP 变量、对象属性使用 camelCase

推荐:

$orderId
$userId
$pageSize
$createdFrom
$createdTo
$paymentAmount
$accessibleStoreIds

规则:

禁止长期使用无语义变量承载核心业务数据:

$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()

规则:

不推荐:

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"
}

规则:

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

规则:

索引命名应表达表、字段和索引类型。

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 生成代码必须满足:

AI 生成代码禁止:

19.8 代码注释规范

注释只解释“为什么”,不重复“代码做了什么”。

允许:

// 支付平台可能重复推送同一 event_id,必须依赖唯一索引兜底。

不推荐:

// 设置订单状态为已支付
$order->status = OrderStatus::Paid;

规范要求:

19.9 命名与编码检查清单

代码评审时至少检查:


20. 评审检查清单

本章用于 Code Review、架构评审和上线前自检。检查清单不是形式要求,任何高风险项未满足,都应阻止合并或发布。

20.1 接口开发检查

20.2 查询与导出检查

20.3 业务流程检查

20.4 权限与安全检查

20.5 日志、审计与观测检查

20.6 生产运行检查

20.7 测试与门禁检查


21. 反模式与禁止事项

本章只保留高风险反模式。一般编码偏好不放在这里,避免稀释重点。

21.1 分层反模式

禁止:

21.2 查询反模式

禁止:

21.3 一致性反模式

禁止:

21.4 状态与响应反模式

禁止:

21.5 安全与审计反模式

禁止:

21.6 生产运行反模式

禁止:


22. 推荐 Composer 包与使用边界

包选择必须服务于明确工程问题,不为“技术完整性”强行引入。新项目可按下表选择,不要求全部安装。

22.1 默认推荐

用途使用边界
laravel/sanctumSPA / Token 认证只解决认证,不替代权限和业务授权。
spatie/laravel-permission角色、权限事实源不直接表达菜单、页面、按钮和路由绑定的全部语义。
laravel/pint代码格式化CI 必须执行。
dedoc/scrambleOpenAPI JSON 生成仅作为接口合同生成工具;默认导出并提交 docs/api/openapi.json

22.2 按场景引入

用途引入条件
laravel/horizonRedis 队列监控使用 Redis 队列且需要可视化队列状态。
spatie/laravel-schedule-monitor定时任务执行监控有关键 Scheduler 任务需要失败告警。
maatwebsite/excelExcel 导入导出业务存在 Excel 导入导出;大数据必须配合队列 / chunk。
laravel/pulse应用观测项目需要应用级指标观测。
laravel/telescope本地 / 测试调试生产谨慎启用,必须鉴权和限制访问。
spatie/laravel-activitylog活动日志仅在其模型事件日志能力符合项目边界时使用;不替代本文业务操作审计方案。

22.3 开发辅助

用途使用边界
laravel/boost开发辅助开发环境使用,不作为运行时核心依赖。
nunomaduro/larastan静态分析建议中大型项目启用,CI 执行。
fakerphp/faker测试数据仅用于 factory / seed / test。

22.4 包引入评审标准

新增包前必须确认:

禁止:


23. 附录使用说明与代码模板校验报告

23.1 附录定位

附录不是第二套规范,而是正文规范的可复制实现模板。

使用顺序:

  1. 先阅读正文,确认默认规则和边界。
  2. 再复制对应附录模板。
  3. 按项目实际命名空间、guard、错误码段位、业务字段做最小调整。
  4. 最后用本章验收标准校验接入是否完成。

23.2 模板落地标准

每个附录模板默认需要满足:

23.3 本轮代码模板语法检查结果

本轮对附录中带有 php file=... 标记的完整 PHP 模板进行了语法检查。

检查方式:

php -l <extracted-template-file.php>

检查结果:

项目结果
已提取完整 PHP 模板47 个
php -l 通过47 个
php -l 失败0 个

已检查范围包括:

23.4 未覆盖的验证范围

本轮没有声明以下内容已经完成真实运行验证:

因此,本文档可以作为团队规范与模板基线,但在具体项目首次落地时,仍应执行:

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 全文一致性检查结果

本轮已按以下规则做一致性收口:


附录 A. 统一响应与异常完整模板

附录 A 使用说明

正文只定义响应协议;本附录给出可直接复制的第一版实现。项目可以按业务调整 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 使用说明

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 使用说明

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 使用说明

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 使用说明

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 使用说明

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 使用说明

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 使用说明

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 使用说明

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 使用说明

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 使用说明

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 使用说明

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 工程门禁

判断一份代码是否符合规范,只看三个问题:

  1. 职责是否放在正确层级。
  2. 核心业务是否具备一致性、权限、审计、幂等和测试保障。
  3. 生产环境是否可发布、可回滚、可监控、可排查。
Next
Vibe Coding 实战复盘:我的经验、踩坑与方法