提示信息
# PHP 后端项目架构搭建指南
> 基于同伴 App 后端架构提炼
> PHP 8.4 · Slim 4 · PostgreSQL · Eloquent ORM · PHP-DI · Swagger PHP v4
---
## 一、技术栈选型
| 层级 | 技术 | 说明 |
|------|------|------|
| HTTP 框架 | Slim 4 | 轻量级,符合 PSR-7/PSR-15 |
| 数据库 | PostgreSQL | 主键用 IDENTITY,支持 JSONB/PostGIS |
| ORM | Eloquent(illuminate/database) | 无需 Laravel 全家桶 |
| 依赖注入 | PHP-DI 7 | 支持 Autowire,Service 构造函数自动注入 |
| 缓存 | Redis(predis/predis) | 封装为 RedisClient,统一 key 前缀 |
| 认证 | JWT(firebase/php-jwt) | Access Token + Refresh Token 双 Token |
| 日志 | Monolog | 按日期轮转,自动注入 request_id |
| 参数断言 | webmozart/assert | 验参失败自动抛异常 |
| API 文档 | zircote/swagger-php v4 | PHP 8 Attributes 风格 |
| 环境变量 | vlucas/phpdotenv | .env 文件驱动配置 |
---
## 二、目录结构
```
my-project/
├── public/
│ └── index.php # 唯一入口
├── app/
│ ├── Services/ # 业务逻辑层(无 Controller)
│ ├── Models/ # 数据模型
│ ├── Middleware/ # HTTP 中间件
│ ├── Exceptions/ # 异常定义
│ ├── Enums/ # PHP 枚举
│ └── Support/ # 基类、工具类、辅助函数
├── routes/
│ └── api.php # 路由定义
├── config/
│ ├── app.php
│ ├── database.php
│ ├── redis.php
│ ├── jwt.php
│ ├── log.php
│ └── container.php # DI 容器
├── database/
│ └── schema/ # SQL 建表脚本
├── docs/ # 文档和 openapi.json
├── logs/ # 运行日志(gitignore)
├── .env
├── .env.example
└── composer.json
```
---
## 三、composer.json 依赖
与同伴后端保持完全一致的版本号,新项目按需删减业务无关的包。
```json
{
"require": {
"php": "^8.4",
"slim/slim": "^4.0",
"slim/psr7": "^1.6",
"php-di/php-di": "^7.0",
"illuminate/database": "^11.0",
"illuminate/pagination": "^11.0",
"illuminate/events": "^11.0",
"illuminate/encryption": "^11.0",
"firebase/php-jwt": "^7.0",
"predis/predis": "^2.2",
"webmozart/assert": "^1.11",
"vlucas/phpdotenv": "^5.6",
"monolog/monolog": "^3.5",
"zircote/swagger-php": "^4.8",
"按需引入,不用就删": "",
"aliyuncs/oss-sdk-php": "^2.7",
"zoujingli/wechat-developer": "^1.2",
"zoujingli/ip2region": "^3.0",
"alibabacloud/cloudauth-20190307": "^3.13",
"alibabacloud/tea-utils": "^0.2.22",
"alibabacloud/dysmsapi-20170525": "^4.5"
},
"require-dev": {
"phpstan/phpstan": "^1.10"
},
"config": {
"platform": {
"php": "8.4"
}
},
"autoload": {
"psr-4": {
"App\\": "app/"
},
"files": [
"app/Support/helpers.php",
"app/Enums/AppEnums.php"
]
},
"scripts": {
"gen-doc": "php -r \"require __DIR__.'/vendor/autoload.php'; file_put_contents('docs/openapi.json', \\OpenApi\\Generator::scan([__DIR__.'/app'])->toJson());\""
}
}
```
> 注:`"按需引入,不用就删": ""` 那一行只是注释占位,实际删掉整行。
---
## 四、环境变量(.env.example)
```bash
# 应用
APP_ENV=local
APP_DEBUG=true
APP_KEY=base64: # openssl rand -base64 32
# 数据库
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=myproject
DB_USERNAME=postgres
DB_PASSWORD=
DB_SSLMODE=prefer
# Redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_PREFIX=myproject:
# JWT
JWT_SECRET= # 至少 32 位随机字符串
# 日志
LOG_LEVEL=debug
```
---
## 五、入口文件(public/index.php)
```php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use DI\ContainerBuilder;
use Slim\Factory\AppFactory;
// 1. 加载环境变量
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
// 2. 加载全局配置
$GLOBALS['_config'] = [
'app' => require __DIR__ . '/../config/app.php',
'database' => require __DIR__ . '/../config/database.php',
'redis' => require __DIR__ . '/../config/redis.php',
'jwt' => require __DIR__ . '/../config/jwt.php',
'log' => require __DIR__ . '/../config/log.php',
];
// 3. 初始化日志
$GLOBALS['_loggers'] = \App\Support\LoggerFactory::create(config('log'));
// 4. 初始化 Eloquent ORM
$capsule = new \Illuminate\Database\Capsule\Manager();
$capsule->addConnection(config('database'));
$capsule->setAsGlobal();
$capsule->bootEloquent();
// 5. 创建 Slim App
$builder = new ContainerBuilder();
$builder->addDefinitions(require __DIR__ . '/../config/container.php');
$container = $builder->build();
AppFactory::setContainer($container);
$app = AppFactory::create();
// 6. 全局中间件
$app->add(\App\Middleware\CorsMiddleware::class);
$app->add(\App\Middleware\RequestIdMiddleware::class);
// 7. 异常处理
$errorMiddleware = $app->addErrorMiddleware(
config('app.debug', false),
true,
true
);
$errorMiddleware->setDefaultErrorHandler(
new \App\Exceptions\Handler($app->getCallableResolver(), $app->getResponseFactory())
);
// 8. 加载路由
require __DIR__ . '/../routes/api.php';
// 9. 运行
$app->run();
```
---
## 六、配置文件
### config/app.php
```php
<?php
return [
'env' => $_ENV['APP_ENV'] ?? 'production',
'debug' => filter_var($_ENV['APP_DEBUG'] ?? false, FILTER_VALIDATE_BOOLEAN),
'key' => $_ENV['APP_KEY'] ?? '',
// 请求签名(如果需要)
'sign_time_window' => 300,
'sign_nonce_ttl' => 600,
'keys' => [
// 'app_key' => 'app_secret'
],
];
```
### config/database.php
```php
<?php
return [
'driver' => 'pgsql',
'host' => $_ENV['DB_HOST'] ?? '127.0.0.1',
'port' => $_ENV['DB_PORT'] ?? '5432',
'database' => $_ENV['DB_DATABASE'] ?? '',
'username' => $_ENV['DB_USERNAME'] ?? '',
'password' => $_ENV['DB_PASSWORD'] ?? '',
'charset' => 'utf8',
'schema' => 'public',
'sslmode' => $_ENV['DB_SSLMODE'] ?? 'prefer',
];
```
### config/redis.php
```php
<?php
return [
'host' => $_ENV['REDIS_HOST'] ?? '127.0.0.1',
'port' => (int)($_ENV['REDIS_PORT'] ?? 6379),
'password' => $_ENV['REDIS_PASSWORD'] ?: null,
'database' => (int)($_ENV['REDIS_DB'] ?? 0),
'prefix' => $_ENV['REDIS_PREFIX'] ?? 'myproject:',
];
```
### config/jwt.php
```php
<?php
return [
'secret' => $_ENV['JWT_SECRET'] ?? '',
'expire' => 7200, // Access Token 2小时
'refresh_expire' => 2592000, // Refresh Token 30天
'algo' => 'HS256',
];
```
### config/log.php
```php
<?php
return [
'level' => $_ENV['LOG_LEVEL'] ?? 'info',
'path' => __DIR__ . '/../logs',
];
```
### config/container.php
```php
<?php
use App\Support\RedisClient;
use Predis\Client as Predis;
return [
RedisClient::class => function () {
$cfg = config('redis');
$predis = new Predis([
'scheme' => 'tcp',
'host' => $cfg['host'],
'port' => $cfg['port'],
'password' => $cfg['password'],
'database' => $cfg['database'],
]);
return new RedisClient($predis, $cfg['prefix']);
},
// 其他 Service 由 PHP-DI 自动 Autowire,无需手动注册
];
```
---
## 七、核心基类
### app/Support/helpers.php
```php
<?php
function config(string $key, mixed $default = null): mixed
{
$keys = explode('.', $key);
$value = $GLOBALS['_config'] ?? [];
foreach ($keys as $segment) {
if (!is_array($value) || !array_key_exists($segment, $value)) {
return $default;
}
$value = $value[$segment];
}
return $value;
}
function logger(string $channel = 'app'): \Monolog\Logger
{
return $GLOBALS['_loggers'][$channel]
?? throw new \RuntimeException("Logger channel '$channel' not found");
}
```
### app/Support/BaseService.php
```php
<?php
namespace App\Support;
use App\Exceptions\AppException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Webmozart\Assert\Assert;
use Webmozart\Assert\InvalidArgumentException;
abstract class BaseService
{
protected function uid(Request $request): int
{
return (int)$request->getAttribute('uid');
}
protected function params(Request $request): array
{
return (array)$request->getParsedBody();
}
protected function query(Request $request): array
{
return (array)$request->getQueryParams();
}
protected function validate(callable $assertions): void
{
try {
$assertions();
} catch (InvalidArgumentException $e) {
throw new AppException($e->getMessage(), 1002);
}
}
protected function success(Response $response, mixed $data = null, string $msg = 'success'): Response
{
return $this->json($response, [
'code' => 0,
'msg' => $msg,
'data' => $data,
'server_time' => time(),
]);
}
protected function fail(Response $response, int $code, string $msg, mixed $data = null): Response
{
return $this->json($response, [
'code' => $code,
'msg' => $msg,
'data' => $data,
'server_time' => time(),
]);
}
private function json(Response $response, array $data): Response
{
$response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json');
}
}
```
### app/Models/BaseModel.php
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
abstract class BaseModel extends Model
{
protected $primaryKey = 'id';
public $incrementing = true;
protected $keyType = 'int'; // PostgreSQL IDENTITY 返回字符串,必须强制 int
public $timestamps = false; // 手动管理时间字段
// 软删除:查询活跃记录
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('deleted_at');
}
// 软删除:执行删除
public function softDelete(): bool
{
return $this->update(['deleted_at' => now()]);
}
// 游标分页
public function scopeCursorBefore(Builder $query, ?int $cursor, string $col = 'id'): Builder
{
if ($cursor) {
$query->where($col, '<', $cursor);
}
return $query;
}
}
```
---
## 八、异常处理
### app/Exceptions/AppException.php
```php
<?php
namespace App\Exceptions;
use RuntimeException;
class AppException extends RuntimeException
{
public function __construct(
string $message,
private readonly int $appCode = 1002,
private readonly mixed $extraData = null,
) {
parent::__construct($message);
}
public function getAppCode(): int { return $this->appCode; }
public function getExtraData(): mixed { return $this->extraData; }
}
```
### app/Exceptions/Handler.php(核心逻辑)
```php
<?php
namespace App\Exceptions;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Exception\HttpException;
use Slim\Interfaces\CallableResolverInterface;
use Slim\Interfaces\ResponseFactoryInterface;
class Handler
{
public function __construct(
private CallableResolverInterface $callableResolver,
private ResponseFactoryInterface $responseFactory,
) {}
public function __invoke(
ServerRequestInterface $request,
\Throwable $exception,
bool $displayErrorDetails,
bool $logErrors,
bool $logErrorDetails
): ResponseInterface {
$response = $this->responseFactory->createResponse();
$path = $request->getUri()->getPath();
$uid = $request->getAttribute('uid', 0);
// 业务异常
if ($exception instanceof AppException) {
logger()->warning('app_exception', [
'uid' => $uid,
'path' => $path,
'app_code' => $exception->getAppCode(),
'message' => $exception->getMessage(),
]);
$payload = [
'code' => $exception->getAppCode(),
'msg' => $exception->getMessage(),
'data' => $exception->getExtraData(),
'server_time' => time(),
];
$response->getBody()->write(json_encode($payload, JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json');
}
// HTTP 异常(404/405 等)
if ($exception instanceof HttpException) {
$code = $exception->getCode();
$payload = [
'code' => ($code === 404) ? 1004 : 1009,
'msg' => $exception->getMessage(),
'data' => null,
'server_time' => time(),
];
$response->getBody()->write(json_encode($payload, JSON_UNESCAPED_UNICODE));
return $response->withStatus($code)->withHeader('Content-Type', 'application/json');
}
// 系统异常
logger()->error('unhandled_exception', [
'path' => $path,
'message' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
$payload = [
'code' => 1009,
'msg' => $displayErrorDetails ? $exception->getMessage() : '服务器内部错误',
'data' => null,
'server_time' => time(),
];
$response->getBody()->write(json_encode($payload, JSON_UNESCAPED_UNICODE));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}
}
```
---
## 九、中间件
### app/Middleware/RequestIdMiddleware.php
```php
<?php
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class RequestIdMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$requestId = $request->getHeaderLine('X-Request-Id') ?: bin2hex(random_bytes(8));
$GLOBALS['_request_id'] = $requestId;
$request = $request->withAttribute('request_id', $requestId);
$response = $handler->handle($request);
return $response->withHeader('X-Request-Id', $requestId);
}
}
```
### app/Middleware/CorsMiddleware.php
```php
<?php
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Factory\AppFactory;
class CorsMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$origin = $request->getHeaderLine('Origin');
$allowed = explode(',', $_ENV['CORS_ORIGINS'] ?? '');
$allowOrigin = in_array($origin, array_map('trim', $allowed)) ? $origin : '';
if ($request->getMethod() === 'OPTIONS') {
$response = AppFactory::determineResponseFactory()->createResponse(204);
return $this->withCorsHeaders($response, $allowOrigin);
}
$response = $handler->handle($request);
return $this->withCorsHeaders($response, $allowOrigin);
}
private function withCorsHeaders(ResponseInterface $response, string $origin): ResponseInterface
{
return $response
->withHeader('Access-Control-Allow-Origin', $origin)
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-App-Key, X-Timestamp, X-Nonce, X-Sign')
->withHeader('Access-Control-Max-Age', '86400');
}
}
```
### app/Middleware/AuthMiddleware.php
```php
<?php
namespace App\Middleware;
use App\Exceptions\AppException;
use App\Support\JwtSupport;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class AuthMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$token = ltrim($request->getHeaderLine('Authorization'), 'Bearer ');
if (!$token) {
throw new AppException('未登录', 1001);
}
try {
$payload = JwtSupport::decodeToken($token);
} catch (\Exception $e) {
throw new AppException('Token 无效', 1001);
}
$request = $request
->withAttribute('uid', (int)$payload['uid'])
->withAttribute('token_payload', $payload);
return $handler->handle($request);
}
}
```
---
## 十、路由结构(routes/api.php)
```php
<?php
use App\Middleware\AuthMiddleware;
use App\Services\AuthService;
use App\Services\UserService;
// 示例路由结构
$app->group('/v1', function ($group) {
// ── 公开接口(无需认证)──
$group->get('/app/config', [\App\Services\AppService::class, 'config']);
$group->post('/auth/login', [AuthService::class, 'login']);
$group->post('/auth/refresh', [AuthService::class, 'refresh']);
// ── 需要认证的接口 ──
$group->group('', function ($g) {
$g->post('/auth/logout', [AuthService::class, 'logout']);
$g->get('/user/me', [UserService::class, 'me']);
$g->post('/user/update', [UserService::class, 'update']);
// 添加更多路由...
})->add(AuthMiddleware::class);
});
```
---
## 十一、Service 开发规范
### 标准方法注释块(强制)
```
// ─────────────────────────────────────────────────────────────────────────
// METHOD /path
// 一句话描述接口的核心动作
//
// 【调用场景】
// 场景描述
// 【错误码】1002=参数错误 1004=记录不存在
// ─────────────────────────────────────────────────────────────────────────
```
> 分隔线固定 73 个 `─`(U+2500)
### Service 示例
```php
<?php
namespace App\Services;
use App\Exceptions\AppException;
use App\Models\User;
use App\Support\BaseService;
use App\Support\RedisClient;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Webmozart\Assert\Assert;
class UserService extends BaseService
{
public function __construct(
private RedisClient $redis,
) {}
// ─────────────────────────────────────────────────────────────────────────
// GET /user/me
// 获取当前登录用户的个人信息
//
// 【调用场景】
// 用户登录后查看自己的资料
// 【错误码】1004=用户不存在
// ─────────────────────────────────────────────────────────────────────────
#[OA\Get(path: "/user/me", tags: ["用户"], summary: "获取当前用户信息", security: [["bearerAuth" => []]])]
#[OA\Response(response: 200, description: "成功")]
public function me(Request $request, Response $response): Response
{
$uid = $this->uid($request);
$user = User::active()->find($uid);
if (!$user) {
throw new AppException('用户不存在', 1004);
}
return $this->success($response, [
'id' => $user->id,
'nickname' => $user->nickname,
'avatar' => $user->avatar,
]);
}
// ─────────────────────────────────────────────────────────────────────────
// POST /user/update
// 更新用户个人信息
//
// 【调用场景】
// 用户编辑资料
// 【错误码】1002=参数错误 1004=用户不存在
// ─────────────────────────────────────────────────────────────────────────
#[OA\Post(path: "/user/update", tags: ["用户"], summary: "更新用户信息", security: [["bearerAuth" => []]])]
#[OA\Response(response: 200, description: "成功")]
public function update(Request $request, Response $response): Response
{
$uid = $this->uid($request);
$params = $this->params($request);
$this->validate(function () use ($params) {
Assert::keyExists($params, 'nickname', '缺少昵称');
Assert::maxLength($params['nickname'], 20, '昵称最多20字');
});
$user = User::active()->find($uid);
if (!$user) {
throw new AppException('用户不存在', 1004);
}
$user->update(['nickname' => $params['nickname']]);
return $this->success($response);
}
}
```
---
## 十二、Model 规范
```php
<?php
namespace App\Models;
class User extends BaseModel
{
protected $table = 'users';
// 显式声明可填充字段(禁止 guarded = [])
protected $fillable = [
'nickname',
'avatar',
'phone',
'created_at',
'deleted_at',
];
// 字段类型转换
protected $casts = [
'created_at' => 'datetime',
'deleted_at' => 'datetime',
// 'status' => UserStatus::class, // Enum 映射示例
];
// 跨 Service 复用的静态方法放 Model
public static function getUserMap(array $uids): array
{
return static::active()
->whereIn('id', $uids)
->get(['id', 'nickname', 'avatar'])
->keyBy('id')
->toArray();
}
}
```
---
## 十三、数据库 Schema 规范
```sql
-- 建表示例(PostgreSQL)
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nickname VARCHAR(100) NOT NULL DEFAULT '',
avatar TEXT NOT NULL DEFAULT '',
phone VARCHAR(20) NOT NULL DEFAULT '',
status SMALLINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL -- 软删除
);
CREATE INDEX idx_users_phone ON users(phone);
CREATE INDEX idx_users_created_at ON users(created_at DESC);
```
### 约定
- 主键:`BIGINT GENERATED ALWAYS AS IDENTITY`
- 时间:`TIMESTAMPTZ NOT NULL DEFAULT NOW()`
- 软删除:`deleted_at TIMESTAMPTZ NULL`
- 枚举值:`SMALLINT`(对应 PHP Enum backed by int)
- JSON 数据:`JSONB`
---
## 十四、响应格式与错误码
### 统一响应格式
```json
{
"code": 0,
"msg": "success",
"data": null,
"server_time": 1234567890
}
```
> HTTP 状态码固定 200(除支付回调/系统错误外)
### 通用错误码
| 码 | 含义 |
|----|------|
| `1001` | 未登录 / Token 无效 |
| `1002` | 参数校验失败 |
| `1003` | 签名校验失败 |
| `1004` | 记录不存在 |
| `1005` | Token 已过期(用 refresh token 刷新)|
| `1006` | Refresh Token 过期(重新登录)|
| `1007` | 权限不足 |
| `1008` | 账号被封禁 |
| `1009` | 服务器内部错误 |
业务错误码从 `2001` 起,按模块分段定义。
---
## 十五、OpenAPI 文档规范
在 `app/Support/BaseService.php` 顶部定义全局 OA 信息:
```php
#[OA\Info(version: "1.0.0", title: "My Project API")]
#[OA\Server(url: "https://api.dev.example.com/v1", description: "开发环境")]
#[OA\Server(url: "https://api.example.com/v1", description: "生产环境")]
#[OA\SecurityScheme(securityScheme: "bearerAuth", type: "http", scheme: "bearer", bearerFormat: "JWT")]
```
接口注解规则:
- 需登录:加 `security: [["bearerAuth" => []]]`
- 公开接口:加 `#[\App\Support\Annotations\PublicApi]`
- `#[OA\Post]` 只写 path/tags/summary/security,**禁止嵌套** requestBody/responses
- requestBody 和 responses 各用独立的 Attribute
生成文档:
```bash
composer run-script gen-doc
```
---
## 十六、模块开发顺序
```
1. database/schema/xx.sql 建表脚本
2. app/Enums/XxxStatus.php 枚举定义
3. app/Models/Xxx.php fillable / casts / scope / 复用方法
4. app/Services/XxxService.php validate + 业务 + return success/fail
5. routes/api.php 注册路由
6. /docs/api 验证文档
```
---
## 十七、架构核心原则
1. **两层架构,无 Controller**:路由 → Service → Model,禁止 Service 之间互相注入
2. **跨模块复用放 Model**:如 `User::getUserMap()`,不放 Service
3. **所有异常用 throw**:`throw new AppException('消息', 错误码)`,避免用 `$this->fail()`
4. **参数验证统一用 validate**:失败自动抛 1002 异常
5. **软删除统一用 deleted_at**:查询用 `scopeActive()`,删除用 `softDelete()`
6. **游标分页替代 OFFSET**:`where('id','<',$cursor)->orderBy('id','desc')` → `{list, next_cursor, has_more}`
7. **事务用闭包**:`DB::transaction(fn() => ...)`
8. **禁止 `$guarded = []`**:Model 必须显式声明 `$fillable`