diff --git a/composer.json b/composer.json index 8bd4044..9c562a6 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,8 @@ "hyperf/carbon": "^3.1", "hyperf/validation": "^3.1", "hyperf/http-server": "^3.1", - "hyperf/rpc-client": "^3.1" + "hyperf/rpc-client": "^3.1", + "hyperf/paginator": "^3.1" }, "autoload": { "psr-4": { diff --git a/src/Contract/TreeableInterface.php b/src/Contract/TreeableInterface.php new file mode 100644 index 0000000..7354fac --- /dev/null +++ b/src/Contract/TreeableInterface.php @@ -0,0 +1,24 @@ +message = $message; return $this; } - public function setData(array $data): self + public function setData(array $data): static { $this->data = $data; return $this; } - public function setThrowable(Throwable $throwable): self + public function setThrowable(Throwable $throwable): static { $this->throwable = $throwable; return $this; } - public function setTraceId(string $traceId): self + public function setTraceId(string $traceId): static { $this->traceId = $traceId; return $this; } - public function setContext(array $context): self + public function setContext(array $context): static { $this->context = $context; return $this; @@ -56,7 +56,7 @@ class Logger return $this->isError; } - public function setErrorStatus(bool $isError): self + public function setErrorStatus(bool $isError): static { $this->isError = $isError; return $this; diff --git a/src/Middleware/HttpMiddleware.php b/src/Middleware/HttpMiddleware.php deleted file mode 100644 index e69de29..0000000 diff --git a/src/Middleware/JsonRpcMiddleware.php b/src/Middleware/JsonRpcMiddleware.php deleted file mode 100644 index 4dc1a06..0000000 --- a/src/Middleware/JsonRpcMiddleware.php +++ /dev/null @@ -1,28 +0,0 @@ -getHeaderLine('X-Trace-Id') - ?: uniqid('', true) . bin2hex(random_bytes(2)); - Util::get(RpcContext::class)->set('trace_id', $traceId); - - return $handler->handle($request); - } -} diff --git a/src/Middleware/RequestContextMiddleware.php b/src/Middleware/RequestContextMiddleware.php new file mode 100644 index 0000000..456f424 --- /dev/null +++ b/src/Middleware/RequestContextMiddleware.php @@ -0,0 +1,46 @@ +getHeaderLine('X-Trace-Id') + ?: uniqid('t-', true); + Context::set('trace_id', $traceId); + $rpcContext->set('trace_id', $traceId); + + // app版本 + $appVersion = $request->getHeaderLine('X-App-Version') ?: ''; + Context::set('app_version', $appVersion); + $rpcContext->set('app_version', $appVersion); + + //平台名称 + // APP: app + // 微信小程序: wx_mini + // 官网: web_official + $platformName = $request->getHeaderLine('X-Platform-Name') ?: ''; + + Context::set('platform_name', $platformName); + $rpcContext->set('platform_name', $platformName); + + return $handler->handle($request); + } +} diff --git a/src/Model.php b/src/Model.php index 1507e25..c4fb4cc 100644 --- a/src/Model.php +++ b/src/Model.php @@ -11,6 +11,21 @@ namespace Dhd\Micro\Core; use Hyperf\DbConnection\Model\Model as HyperfModel; use Hyperf\ModelCache\Cacheable; use Hyperf\ModelCache\CacheableInterface; +use Closure; +use DateTime; +use Exception; +use Hyperf\Context\ApplicationContext; +use Hyperf\Database\Model\Builder; +use Hyperf\Database\Model\Collection; +use Hyperf\DbConnection\Db; +use Hyperf\HttpServer\Contract\RequestInterface; +use Hyperf\ModelCache\Manager; +use Dhd\Micro\Core\Contract\TreeableInterface; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; +use Throwable; + +use Dhd\Micro\Core\Exception\BusinessException; abstract class Model extends HyperfModel implements CacheableInterface { @@ -35,4 +50,515 @@ abstract class Model extends HyperfModel implements CacheableInterface * - 数组: ['field' => '字段名', 'direction' => 'asc/desc']. */ protected array|bool|string $sortable = false; // 'sort'; // 默认按sort字段升序 + + public function __construct(array $attributes = [], $needBoot = true) + { + parent::__construct($attributes); + if ($needBoot) + $this->bootBaseModel(); + } + + public function scopeEnabled(Builder $query): Builder + { + return $query->where('status', 1); + } + + /** + * 获取排序配置. + * @return null|array [field, direction] 或 null(禁用排序) + */ + public function getSortConfig(): ?array + { + if ($this->sortable === false) { + return null; + } + + // 处理字符串配置(如 'sort' 或 'created_at') + if (is_string($this->sortable)) { + return [ + 'field' => $this->sortable, + 'direction' => 'asc', + ]; + } + + // 处理数组配置(如 ['field' => 'id', 'direction' => 'desc']) + if (is_array($this->sortable)) { + $field = $this->sortable['field'] ?? null; + $direction = $this->sortable['direction'] ?? 'asc'; + // 验证字段有效性,避免空字段导致错误 + if (!empty($field) && is_string($field)) { + return [ + 'field' => $field, + 'direction' => strtolower($direction) === 'desc' ? 'desc' : 'asc', + ]; + } + // 数组配置无效时返回null(禁用排序) + return null; + } + + // 其他无效类型(如true)返回null + return null; + } + + public function list(array $params = [], $isTree = true, $callback = null, $eachCallback = null): array + { + $params = $this->paramsHandler($params); + + $query = static::query(); + + $with = $params['with'] ?? []; + if (!empty($with)) { + if (is_string($with)) { + $with = explode(',', $with); + } + if (is_array($with)) { + $query->with($with); + } + } + + $this->applySearch($query, $params); + + if ($this->sortable) { + $sortConfig = $this->getSortConfig(); + if ($sortConfig) { + $query->orderBy($sortConfig['field'], $sortConfig['direction']); + } + } + + $withDeleted = filter_var($params['with_deleted'] ?? false, FILTER_VALIDATE_BOOLEAN); + if ($withDeleted) { + $query->withTrashed(); + } + + if (is_callable($callback)) { + call_user_func($callback, $query); + } + + if (isset($params['page']) || isset($params['size'])) { + $page = (int) ($params['page'] ?? 1); + $size = (int) ($params['size'] ?? 10); + $page = max(1, $page); + $size = max(1, min(100, $size)); + + $result = $query->paginate($size, ['*'], 'page', $page); + if (is_callable($eachCallback) && method_exists($result, 'each')) { + $result->each($eachCallback); + } + return [ + 'list' => $result->items(), + 'page' => $page, + 'size' => $size, + 'total' => $result->total(), + 'pages' => $result->lastPage() + ]; + } + + $items = $query->get()->toArray(); + + if ($isTree) { + if ($this instanceof TreeableInterface) { + return $this::buildTree($items, (int) ($params['parent_id'] ?? 0)); + } + } + + return $items; + } + + /** + * 处理列表查询参数 + */ + protected function paramsHandler(array $params): array + { + // 移除空键 + $params = array_filter($params, function ($key) { + return is_string($key) && trim($key) !== ''; + }, ARRAY_FILTER_USE_KEY); + + // 移除null和空字符串(保留0、false等有意义的值) + $params = array_filter($params, function ($value) { + return $value !== null && $value !== ''; + }); + + // 去除首尾空格 + foreach ($params as $key => &$value) { + if (is_string($value)) { + $value = trim($value); + } + } + unset($value); // 解除引用 + + // 分页参数处理:确保为有效整数 + // $params['page'] = isset($params['page']) ? max(1, (int)$params['page']) : 1; + // $params['size'] = isset($params['size']) ? max(1, min(100, (int)$params['size'])) : 10; + + // 布尔值参数处理(如with_deleted) + if (isset($params['with_deleted'])) { + $params['with_deleted'] = filter_var($params['with_deleted'], FILTER_VALIDATE_BOOLEAN); + } + + // 树形结构参数处理(parent_id) + if (isset($params['parent_id']) && $this instanceof TreeableInterface) { + $params['parent_id'] = (int)$params['parent_id']; + } + + // 搜索参数白名单:仅保留$search中定义的字段(防止无效参数注入) + $allowedSearchFields = array_keys($this->search); + $searchParams = array_intersect_key($params, array_flip($allowedSearchFields)); + + // 合并系统参数与合法搜索参数(排除其他未知参数) + $systemParams = ['page', 'size', 'with_deleted', 'parent_id']; + $validSystemParams = array_intersect_key($params, array_flip($systemParams)); + + return array_merge($validSystemParams, $searchParams); + } + + /** + * 快捷创建. + */ + public static function createOne(array $data): Model|Builder|HyperfModel + { + return static::query()->create($data); + } + + /** + * 快捷更新. + */ + public static function updateById(int $id, array $data): int + { + $result = static::query()->where('id', $id)->update($data); + + // 主动清除缓存(query()->update() 不会触发模型事件) + if ($result > 0) { + try { + $container = ApplicationContext::getContainer(); + $manager = $container->get(Manager::class); + $manager->destroy([$id], static::class); + } catch (\Throwable $e) { + // 缓存删除失败不影响主业务 + } + } + + return $result; + } + + /** + * 快捷删除指定ID的记录. + * + * @param int $id 要删除的记录ID + * @return bool 成功删除返回true + * @throws BusinessException 当记录不存在时抛出 + * @throws Exception 当删除操作发生其他错误时抛出 + */ + public static function deleteById(int $id): bool + { + if (! static::query()->where('id', $id)->exists()) { + throw new BusinessException(sprintf('找不到 ID 为 %d 的记录', $id), 404); + } + return static::query()->where('id', $id)->delete() > 0; + } + + /** + * 快捷查找. + * @return Builder|Builder[]|Collection|HyperfModel + * @throws Exception 当删除操作发生其他错误时抛出 + * @throws BusinessException 当记录不存在时抛出 + */ + public static function findById(int $id, $with = []): array|Builder|Collection|HyperfModel + { + $record = static::query()->with($with)->find($id); + if (is_null($record)) { + throw new BusinessException(sprintf('找不到 ID 为 %d 的记录', $id), 404); + } + return $record; + } + + /** + * 快捷查找或失败. + * @param int $id 要查找的记录ID + * @return Model 根据ID查找记录,不存在则抛出异常 + * @throws Exception 当删除操作发生其他错误时抛出 + * @throws BusinessException 当记录不存在时抛出 + */ + public static function findOrFailById(int $id, $with = []): static + { + $record = static::query()->find($id); + if (is_null($record)) { + throw new BusinessException(sprintf('找不到 ID 为 %d 的记录', $id), 404); + } + return $record; + } + + /** + * 获取器处理. + */ + public function getAttribute(string $key): mixed + { + $value = parent::getAttribute($key); + + // 检查是否有自定义获取器 + $getterMethod = 'get' . ucfirst($key) . 'Attr'; + if (method_exists($this, $getterMethod)) { + return $this->{$getterMethod}($value); + } + + // 应用获取器规则 + if (isset($this->append[$key])) { + return $this->applyGetterRule($value, $this->append[$key]); + } + + return $value; + } + + /** + * 批量更新. + */ + public static function batchUpdate(array $conditions, array $data): int + { + return static::query()->where($conditions)->update($data); + } + + /** + * 事务处理. + * @throws Throwable + */ + public static function transaction(Closure $closure): mixed + { + return Db::transaction($closure); + } + + /** + * 初始化模型. + */ + protected function bootBaseModel(): void + { + // 自动注册搜索器和获取器 + $this->registerSearchers(); + $this->registerGetters(); + } + + /** + * 注册搜索器. + */ + protected function registerSearchers(): void + { + // 为模型查询添加全局作用域,自动应用搜索规则 + static::addGlobalScope('auto_search', function (Builder $query) { + $searchConditions = $this->getSearchConditions(); + if (empty($searchConditions)) { + return; + } + // 应用搜索条件到查询 + $this->applySearch($query, $searchConditions); + }); + } + + /** + * 获取搜索条件. + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + protected function getSearchConditions(): array + { + try { + $request = ApplicationContext::getContainer()->get(RequestInterface::class); + $allParams = $request->all(); + + if (is_array($allParams) && count($allParams) === 1 && is_array($allParams[0])) { + $allParams = $allParams[0]; + } + + $allowedFields = array_keys($this->search); + return array_intersect_key($allParams, array_flip($allowedFields)); + } catch (\Throwable $e) { + return []; + } + } + + protected function registerGetters(): void + { + foreach ($this->append as $field => $rule) { + // 为字段注册获取器回调 + $this->registerGetter($field, $rule); + } + } + + /** + * 为单个字段注册获取器. + * @param string $field 字段名 + * @param array|Closure|string $rule 规则(类型/带参数的类型/闭包) + */ + protected function registerGetter(string $field, array|Closure|string $rule): void + { + // 生成获取器方法名(遵循 Hyperf 模型获取器规范:get{Field}Attr) + $getterMethod = 'get' . ucfirst($field) . 'Attr'; + + // 若已存在自定义获取器方法,则不覆盖 + if (method_exists($this, $getterMethod)) { + return; + } + + // 动态定义获取器方法 + $this->{$getterMethod} = function ($value) use ($rule) { + // 闭包规则直接执行 + if ($rule instanceof Closure) { + return $rule($value, $this); // 传入当前模型实例方便关联字段处理 + } + + // 解析规则类型和参数 + if (is_array($rule)) { + $type = $rule[0]; + $params = $rule[1] ?? []; + } else { + $type = $rule; + $params = []; + } + + // 应用规则(复用/扩展 applyGetterRule 方法) + return $this->applyGetterRule($value, $type, $params); + }; + } + + /** + * 应用获取器规则(支持参数). + * @param mixed $value 字段原始值 + * @param string $type 规则类型 + * @param array $params 规则参数 + */ + protected function applyGetterRule(mixed $value, string $type, array $params = []): mixed + { + return match ($type) { + // 基础规则 + 'date' => $value instanceof DateTime ? $value->format('Y-m-d') : $value, + 'datetime' => $value instanceof DateTime ? $value->format('Y-m-d H:i:s') : $value, + 'timestamp' => $value instanceof DateTime ? $value->getTimestamp() : $value, + 'boolean' => (bool) $value, + 'json' => is_string($value) ? json_decode($value, true) : $value, + + // 参数化规则 + 'number' => $this->formatNumber($value, $params), // 数字格式化 + 'truncate' => $this->truncateString($value, $params), // 字符串截断 + 'enum' => $this->mapEnum($value, $params), // 枚举映射 + default => $value, + }; + } + + // 数字格式化 + protected function formatNumber($value, array $params): string + { + $precision = $params['precision'] ?? 2; // 默认保留2位小数 + return number_format((float) $value, $precision); + } + + // 字符串截断 + protected function truncateString($value, array $params): string + { + $length = $params['length'] ?? 20; // 默认截断到20字符 + $suffix = $params['suffix'] ?? '...'; // 省略符 + if (! is_string($value)) { + $value = (string) $value; + } + return mb_strlen($value) > $length + ? mb_substr($value, 0, $length) . $suffix + : $value; + } + + // 枚举映射 + protected function mapEnum($value, array $params): mixed + { + $map = $params['map'] ?? []; // 枚举映射表,如 [1 => '男', 2 => '女'] + return $map[$value] ?? $value; + } + + /** + * 应用搜索条件. + */ + protected function applySearch(Builder $query, array $conditions): void + { + foreach ($conditions as $field => $value) { + // 基础过滤:非字符串字段名或未设置值的参数直接跳过 + if (! is_string($field) || ! isset($value)) { + continue; + } + + // 核心限制:只处理$search数组中定义的字段 + if (! isset($this->search[$field])) { + continue; + } + + // 处理嵌套关系查询(如:user.name,需在$search中配置完整键名) + if (str_contains($field, '.')) { + [$relation, $relationField] = explode('.', $field, 2); + $query->whereHas($relation, function ($q) use ($relationField, $value) { + // 嵌套查询默认使用精确匹配,如需特殊规则可在$search中自定义处理 + $q->where($relationField, $value); + }); + continue; + } + + // 优先使用自定义搜索器方法(仅对$search中存在的字段生效) + $searchMethod = 'search' . ucfirst($field); + if (method_exists($this, $searchMethod)) { + $this->{$searchMethod}($query, $value); + continue; + } + + // 应用$search中定义的搜索规则(如=、like等) + $this->applySearchRule($query, $field, $value, $this->search[$field]); + } + } + + /** + * 应用搜索规则. + */ + protected function applySearchRule(Builder $query, string $field, mixed $value, array|string $rule): void + { + if (is_array($rule)) { + $type = $rule[0]; + $params = $rule[1] ?? []; + } else { + $type = $rule; + $params = []; + } + + switch ($type) { + case 'like': + $query->where($field, 'like', "%{$value}%"); + break; + case 'like_left': + $query->where($field, 'like', "%{$value}"); + break; + case 'like_right': + $query->where($field, 'like', "{$value}%"); + break; + case 'in': + $query->whereIn($field, (array) $value); + break; + case 'not_in': + $query->whereNotIn($field, (array) $value); + break; + case 'gt': + $query->where($field, '>', $value); + break; + case 'lt': + $query->where($field, '<', $value); + break; + case 'gte': + $query->where($field, '>=', $value); + break; + case 'lte': + $query->where($field, '<=', $value); + break; + case 'between': + $query->whereBetween($field, (array) $value); + break; + case 'null': + $query->whereNull($field); + break; + case 'not_null': + $query->whereNotNull($field); + break; + default: + $query->where($field, $type, $value); + } + } } diff --git a/src/Repository.php b/src/Repository.php index e69de29..5b51b41 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -0,0 +1,150 @@ +getCollection(); + } else { + $items = Collection::make($paginator->items()); + } + $items = $this->handleItems($items); + return [ + 'list' => $items->toArray(), + 'total' => $paginator->total(), + ]; + } + + public function list(array $params = []): Collection + { + return $this->handleItems($this->perQuery($this->getQuery(), $params)->get()); + } + + public function count(array $params = []): int + { + return $this->perQuery($this->getQuery(), $params)->count(); + } + + public function page(array $params = [], ?int $page = null, ?int $pageSize = null): array + { + $result = $this->perQuery($this->getQuery(), $params)->paginate( + perPage: $pageSize, + pageName: static::PER_PAGE_PARAM_NAME, + page: $page, + ); + return $this->handlePage($result); + } + + /** + * @return T + */ + public function create(array $data): mixed + { + // @phpstan-ignore-next-line + return $this->getQuery()->create($data); + } + + public function updateById(mixed $id, array $data): bool + { + return (bool) $this->getQuery()->whereKey($id)->first()?->update($data); + } + + /** + * @return null|T + */ + public function saveById(mixed $id, array $data): mixed + { + $model = $this->getQuery()->whereKey($id)->first(); + if ($model) { + $model->fill($data)->save(); + return $model; + } + return null; + } + + public function deleteById(mixed $id): int + { + // @phpstan-ignore-next-line + return $this->model::destroy($id); + } + + public function forceDeleteById(mixed $id): bool + { + return (bool) $this->getQuery()->whereKey($id)->forceDelete(); + } + + /** + * @return null|T + */ + public function findById(mixed $id): mixed + { + return $this->getQuery()->whereKey($id)->first(); + } + + public function findByField(mixed $id, string $field): mixed + { + return $this->getQuery()->whereKey($id)->value($field); + } + + /** + * @return null|T + */ + public function findByFilter(array $params): mixed + { + return $this->perQuery($this->getQuery(), $params)->first(); + } + + public function perQuery(Builder $query, array $params): Builder + { + return $this->handleSearch($query, $params); + } + + public function getQuery(): Builder + { + return $this->model->newQuery(); + } + + public function existsById(mixed $id): bool + { + return (bool) $this->getQuery()->whereKey($id)->exists(); + } + + /** + * @return T + */ + public function getModel() + { + return $this->model; + } +} diff --git a/src/Service.php b/src/Service.php index e69de29..049856a 100644 --- a/src/Service.php +++ b/src/Service.php @@ -0,0 +1,368 @@ + $repository + * @property Validator $validator + */ +abstract class Service +{ + protected array $search = []; + + protected array $ignoreSearchFields = []; + + public function count(array $params): int + { + return $this->repository->count($params); + } + + public function page(array $params, int $page = 1, int $pageSize = 10): array + { + return $this->repository->page($params, $page, $pageSize); + } + + public function getList(array $paras): Collection + { + return $this->repository->list($paras); + } + + /** + * @return T + */ + public function create(array $data): mixed + { + $this->validator->scene('create', $data)->check(); + return $this->repository->create($data); + } + + /** + * @return T + */ + public function save(array $data): mixed + { + $this->validator->scene('create', $data)->check(); + return $this->create($data); + } + + /** + * @return null|mixed|T + */ + public function updateById(mixed $id, array $data): mixed + { + // 数据验证 + $this->validator->scene('update', $data)->check(); + return $this->repository->updateById($id, $data); + } + + public function deleteById(mixed $id): int + { + return $this->repository->deleteById($id); + } + + /** + * @return null|T + */ + public function findById(mixed $id): mixed + { + return $this->repository->findById($id); + } + + public function existsById(mixed $id): bool + { + return $this->repository->existsById($id); + } + + /** + * 通用列表查询(支持分页和树形结构). + */ + public function list(array $params = []): array + { + $model = $this->repository->getQuery(); + $query = $model->newQuery(); + + // 通过模型配置自动应用所有搜索条件 + $this->applySearch($query, $params); + + // 动态应用排序 + $sortConfig = $model->getSortConfig(); + if ($sortConfig) { + $query->orderBy($sortConfig['field'], $sortConfig['direction']); + } + + $withDeleted = filter_var($params['with_deleted'] ?? false, FILTER_VALIDATE_BOOLEAN); + if ($withDeleted) { + $query->withTrashed(); + } + + // 存在分页参数(page或size)则进行分页查询 + if (isset($params['page']) || isset($params['size'])) { + $page = (int) ($params['page'] ?? 1); + $size = (int) ($params['size'] ?? 10); + // 确保分页参数合法性 + $page = max(1, $page); + $size = max(1, min(100, $size)); // 限制最大页大小为100 + + $result = $query->paginate($size, ['*'], 'page', $page); + return [ + 'list' => $result->items(), + 'page' => $page, + 'size' => $size, + 'total' => $result->total(), + 'pages' => $result->lastPage() + ]; + } + + // 无分页参数时返回完整数据集合 + $items = $query->get()->toArray(); + + // 若模型支持树形结构则构建树形,否则返回普通数组 + if ($model instanceof TreeableInterface) { + return $model::buildTree($items, (int) ($params['parent_id'] ?? 0)); + } + + return $items; + } + + /** + * 通用更新逻辑. + * @throws BusinessException|Throwable + */ + public function update(int $id, array $data): bool + { + $model = $this->repository->getQuery(); + $resource = $model->findById($id); + $this->checkResourceExists($resource); + + // 数据验证 + $this->validator->scene('update', $data)->check(); + + // 钩子:处理更新时的特殊逻辑(如禁止自身为父级) + $this->handleUpdateSpecialLogic($id, $data); + + return $this->repository->getModel() + ->updateById($id, $data) > 0; + } + + /** + * 通用删除逻辑. + * @throws BusinessException|Throwable + */ + public function delete(int $id): bool + { + $model = $this->repository->getQuery(); + $resource = $model->findById($id); + + $this->checkResourceExists($resource); + + // 钩子:删除前检查(如子级存在性) + $this->checkChildrenBeforeDelete($id); + + return $this->repository->getModel()->deleteById($id); + } + + /** + * 软删除恢复. + * @throws BusinessException|Throwable + */ + public function restore(int $id): bool + { + $model = $this->repository->getQuery(); + // 必须使用withTrashed()才能查询到已删除记录 + $resource = $model->withTrashed()->find($id); + $this->checkResourceExists($resource, '恢复的资源不存在'); + + $result = $this->repository->getQuery()->withTrashed() + ->where('id', $id)->restore(); + return $result; + } + + /** + * 批量软删除. + * @throws BusinessException|Throwable + */ + public function batchDelete(array $ids): bool + { + if (empty($ids)) { + throw new BusinessException('请选择要删除的记录', 400); + } + + $model = $this->repository->getQuery(); + $exists = $model->whereIn('id', $ids)->exists(); + if (! $exists) { + throw new BusinessException('部分记录不存在', 404); + } + + return $this->transaction(function () use ($ids) { + $result = $this->repository->getQuery()->whereIn('id', $ids)->delete(); + return $result > 0; + }); + } + + /** + * 批量恢复软删除. + * @throws BusinessException|Throwable + */ + public function batchRestore(array $ids): bool + { + if (empty($ids)) { + throw new BusinessException('请选择要恢复的记录', 400); + } + + $model = $this->repository->getQuery(); + $exists = $model->withTrashed() + ->whereIn('id', $ids) + ->whereNotNull('deleted_at') + ->exists(); + + if (! $exists) { + throw new BusinessException('部分记录不存在或未被删除', 404); + } + + return $this->transaction(function () use ($ids) { + $result = $this->repository->getQuery()->withTrashed() + ->whereIn('id', $ids)->restore(); + return $result > 0; + }); + } + + /** + * 永久删除(物理删除). + * @throws BusinessException|Throwable + */ + public function forceDelete(int $id): bool + { + $model = $this->repository->getQuery(); + $resource = $model->withTrashed()->find($id); + $this->checkResourceExists($resource, '要删除的资源不存在'); + + $result = $this->repository->getQuery()->withTrashed() + ->where('id', $id)->forceDelete(); + return $result; + } + + protected function getSearch(): array + { + return $this->search; + } + + protected function getIgnoreSearchFields(): array + { + return $this->ignoreSearchFields; + } + + + /** + * 根据模型的$search配置,自动应用搜索条件到查询构建器. + */ + public function applySearch(Builder $query, array $params): void + { + foreach ($this->search as $field => $rule) { + // 跳过未传递的参数 + if (! isset($params[$field])) { + continue; + } + + $value = $params[$field]; + $this->applySearchRule($query, $field, $value, $rule); + } + } + + /** + * 应用单个搜索规则. + * @param mixed $value + * @param mixed $rule + */ + protected function applySearchRule(Builder $query, string $field, $value, $rule): void + { + // 处理规则格式(支持字符串简写或数组配置) + $config = is_array($rule) ? $rule : ['type' => $rule]; + $type = $config['type']; + + switch ($type) { + case '=': // 精确匹配 + $query->where($field, $value); + break; + case 'like': // 模糊匹配 + $query->where($field, 'like', "%{$value}%"); + break; + case 'between': // 范围查询(支持数组或两个参数) + $values = is_array($value) ? $value : [$value, $params[$field . '_end'] ?? $value]; + $query->whereBetween($field, $values); + break; + case 'callback': // 自定义回调 + if (isset($config['handler']) && is_callable($config['handler'])) { + call_user_func($config['handler'], $query, $value); + } + break; + // 可扩展其他类型:in、>、< 等 + } + } + + /** + * 通用详情查询. + * @throws Exception + */ + public function detail(int $id): array + { + return $this->repository->getQuery()->findOrFailById($id)->toArray(); + } + + + /** + * 检查资源是否存在(不存在则抛出异常). + * @throws BusinessException + */ + protected function checkResourceExists(?object $resource, string $message = '资源不存在'): void + { + if (empty($resource)) { + throw new BusinessException($message, 404); + } + } + + /** + * 钩子方法:更新时的特殊逻辑(子类可重写). + */ + protected function handleUpdateSpecialLogic(int $id, array &$data): void + { + // 通用逻辑:禁止将自身设为父级(适用于有parent_id的场景) + if (isset($data['parent_id']) && $data['parent_id'] == $id) { + throw new BusinessException('不能将自身设为父级', 400); + } + } + + /** + * 钩子方法:删除前检查子级(子类可重写). + */ + protected function checkChildrenBeforeDelete(int $id): void + { + // 默认不检查,需要的子类重写 + } + + /** + * 事务处理. + * @throws Throwable + */ + protected function transaction(callable $callback) + { + return Db::transaction($callback); + } +} diff --git a/src/Validator.php b/src/Validator.php new file mode 100644 index 0000000..4e71aeb --- /dev/null +++ b/src/Validator.php @@ -0,0 +1,135 @@ + 闭包/类方法]. + */ + protected array $customRules = []; + + /** + * 静态快捷验证方法(简化调用). + */ + public static function validate(string $scene, array $data = []): array + { + $instance = ApplicationContext::getContainer()->get(static::class); + return $instance->scene($scene, $data)->check(); + } + + /** + * 设置验证场景和数据(支持链式调用). + */ + public function scene(string $scene, array $data = []): self + { + $this->currentScene = $scene; + $this->data = $data; + return $this; + } + + /** + * 执行验证(失败抛出异常). + */ + public function check(): array + { + if (empty($this->currentScene)) { + throw new RuntimeException('请先设置验证场景'); + } + + $scenes = $this->scenes(); + if (! isset($scenes[$this->currentScene])) { + return $this->data; + } + + $sceneConfig = $scenes[$this->currentScene]; + return $this->validateData( + $this->data, + $sceneConfig['rules'], + $sceneConfig['messages'] ?? [], + $sceneConfig['attributes'] ?? [] + ); + } + + /** + * 格式化验证错误信息(统一格式,供异常处理器复用). + */ + public function formatValidationErrors(HyperfValidator $validator): array + { + $errors = []; + $failedRules = $validator->failed(); + $errorMessages = $validator->errors()->getMessages(); + $attributes = $validator->attributes(); + + foreach ($failedRules as $field => $rules) { + $errors[] = [ + 'field' => $field, + 'field_label' => $attributes[$field] ?? $field, + 'message' => $errorMessages[$field][0] ?? '', + 'rules' => array_keys($rules), + 'value' => $validator->getValue($field), + ]; + } + + return $errors; + } + + /** + * 实际执行验证的逻辑(重命名方法名更清晰). + */ + protected function validateData(array $data, array $rules, array $messages = [], array $attributes = []): array + { + $validator = $this->validationFactory->make($data, $rules, $messages, $attributes); + $this->registerRules($validator); + + if ($validator->fails()) { + throw new ValidateException( + $validator->errors()->first(), + ); + } + + return $validator->validated(); + } + + /** + * 自动注册自定义规则(优先使用$customRules属性). + */ + protected function registerRules(HyperfValidator $validator): void + { + foreach ($this->customRules as $ruleName => $rule) { + $validator->extend($ruleName, $rule); + } + } + + /** + * 定义场景验证规则(子类实现). + */ + abstract protected function scenes(): array; +}