init
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
final class HealthController extends Controller
|
||||
{
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
return ApiResponse::success([
|
||||
'status' => 'ok',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class AssignRequestId
|
||||
{
|
||||
public const ATTRIBUTE = 'request_id';
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$incoming = $request->headers->get('X-Request-Id');
|
||||
$requestId = is_string($incoming) && preg_match('/^[A-Za-z0-9_-]{1,64}$/', $incoming) === 1
|
||||
? $incoming
|
||||
: (string) Str::uuid();
|
||||
|
||||
$request->attributes->set(self::ATTRIBUTE, $requestId);
|
||||
|
||||
/** @var Response $response */
|
||||
$response = $next($request);
|
||||
$response->headers->set('X-Request-Id', $requestId);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use App\Support\TenantContext;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* 业务模型使用此 Trait 后,查询自动限定当前租户,创建时写入 tenant_code。
|
||||
* withoutGlobalScope('tenant') 只允许出现在平台级管理中,并在调用处说明原因。
|
||||
*/
|
||||
trait BelongsToTenant
|
||||
{
|
||||
public static function bootBelongsToTenant(): void
|
||||
{
|
||||
static::addGlobalScope('tenant', function (Builder $builder): void {
|
||||
$builder->where(
|
||||
$builder->getModel()->qualifyColumn('tenant_code'),
|
||||
app(TenantContext::class)->code(),
|
||||
);
|
||||
});
|
||||
|
||||
static::creating(function (Model $model): void {
|
||||
$model->setAttribute('tenant_code', app(TenantContext::class)->code());
|
||||
});
|
||||
|
||||
static::updating(function (Model $model): void {
|
||||
if ($model->isDirty('tenant_code')) {
|
||||
$model->setAttribute('tenant_code', $model->getOriginal('tenant_code'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Support\TenantContext;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(TenantContext::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
RateLimiter::for('api', function (Request $request): Limit {
|
||||
$tenantCode = app(TenantContext::class)->get() ?? 'guest';
|
||||
|
||||
return Limit::perMinute(60)->by($tenantCode.'|'.($request->ip() ?? 'unknown'));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class ApiExceptionResponse
|
||||
{
|
||||
public static function normalize(Response $response, Request $request): Response
|
||||
{
|
||||
if (! $request->is('api/*')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$status = $response->getStatusCode();
|
||||
$payload = self::payload($response);
|
||||
|
||||
if (($payload['success'] ?? null) === false && array_key_exists('message', $payload)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
if ($status === 422 && isset($payload['errors']) && is_array($payload['errors'])) {
|
||||
$errors = $payload['errors'];
|
||||
}
|
||||
|
||||
$fallback = is_string($payload['message'] ?? null) ? $payload['message'] : '';
|
||||
|
||||
return ApiResponse::failure(self::messageForStatus($status, $fallback), $errors, $status);
|
||||
}
|
||||
|
||||
public static function messageForStatus(int $status, string $fallback): string
|
||||
{
|
||||
return match ($status) {
|
||||
401 => '未认证',
|
||||
403 => '无权访问',
|
||||
404 => '资源不存在',
|
||||
405 => '方法不允许',
|
||||
419 => '页面已过期',
|
||||
422 => '参数错误',
|
||||
429 => '请求过于频繁',
|
||||
default => $status >= 500 ? '操作失败' : ($fallback !== '' ? $fallback : '操作失败'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function payload(Response $response): array
|
||||
{
|
||||
if (! $response instanceof JsonResponse) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $response->getData(true);
|
||||
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use stdClass;
|
||||
|
||||
final class ApiResponse
|
||||
{
|
||||
public const DEFAULT_PER_PAGE = 20;
|
||||
|
||||
public const MAX_PER_PAGE = 100;
|
||||
|
||||
public static function success(mixed $data = null, string $message = 'OK', int $status = 200): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
], $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $errors
|
||||
*/
|
||||
public static function failure(string $message, array $errors = [], int $status = 400): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
'errors' => $errors === [] ? new stdClass : $errors,
|
||||
], $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param LengthAwarePaginator<int, mixed> $paginator
|
||||
*/
|
||||
public static function paginated(LengthAwarePaginator $paginator, string $message = 'OK'): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
'data' => array_values($paginator->items()),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public static function perPage(mixed $requested): int
|
||||
{
|
||||
if (! is_numeric($requested)) {
|
||||
return self::DEFAULT_PER_PAGE;
|
||||
}
|
||||
|
||||
$pageSize = (int) $requested;
|
||||
|
||||
if ($pageSize < 1) {
|
||||
return self::DEFAULT_PER_PAGE;
|
||||
}
|
||||
|
||||
return min($pageSize, self::MAX_PER_PAGE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class MissingTenantContextException extends RuntimeException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('当前没有租户上下文。');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class TenantContext
|
||||
{
|
||||
private ?string $tenantCode = null;
|
||||
|
||||
public function set(string $tenantCode): void
|
||||
{
|
||||
if ($tenantCode === '') {
|
||||
throw new \InvalidArgumentException('tenant_code 不能为空。');
|
||||
}
|
||||
|
||||
$this->tenantCode = $tenantCode;
|
||||
}
|
||||
|
||||
public function forget(): void
|
||||
{
|
||||
$this->tenantCode = null;
|
||||
}
|
||||
|
||||
public function has(): bool
|
||||
{
|
||||
return $this->tenantCode !== null;
|
||||
}
|
||||
|
||||
public function get(): ?string
|
||||
{
|
||||
return $this->tenantCode;
|
||||
}
|
||||
|
||||
public function code(): string
|
||||
{
|
||||
if ($this->tenantCode === null) {
|
||||
throw new MissingTenantContextException;
|
||||
}
|
||||
|
||||
return $this->tenantCode;
|
||||
}
|
||||
|
||||
public function cacheKey(string $key): string
|
||||
{
|
||||
return 'tenant:'.$this->code().':'.$key;
|
||||
}
|
||||
|
||||
public function lockKey(string $key): string
|
||||
{
|
||||
return 'lock:tenant:'.$this->code().':'.$key;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
*
|
||||
* @param callable(): T $callback
|
||||
* @return T
|
||||
*/
|
||||
public function run(string $tenantCode, callable $callback): mixed
|
||||
{
|
||||
$previous = $this->tenantCode;
|
||||
$this->set($tenantCode);
|
||||
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
$this->tenantCode = $previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user