diff --git a/.gitignore b/.gitignore index a6217ed..3228f02 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,4 @@ vendor/ .vscode/ /phpstan.neon /phpunit.xml -.composer.lock \ No newline at end of file +composer.lock \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..57048d9 --- /dev/null +++ b/composer.json @@ -0,0 +1,32 @@ +{ + "name": "dhd/hyperf-micro-service-core", + "type": "library", + "description": "app微服务核心库,所有微服务均以本库为基础", + "require": { + "php": ">=8.1", + "hyperf/cache": "~3.1.0", + "hyperf/config": "~3.1.0", + "hyperf/context": "~3.1.0", + "hyperf/contract": "~3.1.0", + "hyperf/database": "~3.1.0", + "hyperf/db-connection": "~3.1.0", + "hyperf/di": "~3.1.0", + "hyperf/exception-handler": "~3.1.0", + "hyperf/http-message": "~3.1.0", + "hyperf/json-rpc": "~3.1.0", + "hyperf/logger": "~3.1.0", + "hyperf/model-cache": "^3.1", + "hyperf/rpc": "~3.1.0", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-middleware": "^1.0", + "psr/log": "^2.0 || ^3.0", + "hyperf/helper": "^3.1", + "hyperf/carbon": "^3.1", + "hyperf/validation": "^3.1" + }, + "autoload": { + "psr-4": { + "Dhd\\Micro\\Core\\": "src/" + } + } +} diff --git a/src/Exception/BusinessException.php b/src/Exception/BusinessException.php new file mode 100644 index 0000000..fdddc21 --- /dev/null +++ b/src/Exception/BusinessException.php @@ -0,0 +1,41 @@ +code = $code; + $this->data = $data; + + parent::__construct($message, $code, $previous); + } + + /** + * 获取额外数据. + */ + public function getData(): array + { + return $this->data; + } +} diff --git a/src/Exception/Handler/GatewayExceptionHandler.php b/src/Exception/Handler/GatewayExceptionHandler.php new file mode 100644 index 0000000..bd9980c --- /dev/null +++ b/src/Exception/Handler/GatewayExceptionHandler.php @@ -0,0 +1,64 @@ +stopPropagation(); // 阻止异常冒泡 + + $traceId = $this->getTraceId(); + + $code = 500; + $message = '服务器内部错误'; + + if (($throwable instanceof BusinessException)) { + Util::recordLog($throwable->getMessage(), $throwable, $traceId); + $code = $throwable->getCode(); + $message = $throwable->getMessage(); + } else if ($throwable instanceof ClientException) { + $code = $throwable->getCode() ?: 500; + $message = $throwable->getMessage() ?: '下游服务异常'; + Util::recordLog($message, $throwable, $traceId); + } + + $responseData = [ + 'code' => $code, + 'message' => $message, + 'data' => null, + ]; + + return $response + ->withHeader('Content-Type', 'application/json') + ->withHeader('X-Trace-Id', $traceId) + ->withStatus(200) + ->withBody(new SwooleStream(json_encode($responseData, JSON_UNESCAPED_UNICODE))); + } + + public function isValid(Throwable $throwable): bool + { + return true; + } +} diff --git a/src/Exception/Handler/JsonRpcBusinessExceptionHandler.php b/src/Exception/Handler/JsonRpcBusinessExceptionHandler.php new file mode 100644 index 0000000..9ea0418 --- /dev/null +++ b/src/Exception/Handler/JsonRpcBusinessExceptionHandler.php @@ -0,0 +1,60 @@ +stopPropagation(); // 阻止异常冒泡 + + $traceId = $this->getTraceId(); + + $isError = !($throwable instanceof BusinessException); + Util::recordLog($isError ? '服务器内部错误' : $throwable->getMessage(), $throwable, $traceId, $isError); + + $request = Util::get(ServerRequestInterface::class); + // 关键优化:从 Request 属性中动态获取 ID + $id = $request ? $request->getAttribute('request_id', null) : null; // Hyperf 注入的 ID + $responseData = [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'error' => [ + 'code' => $isError ? 500 : $throwable->getCode(), + 'message' => $isError ? '服务器内部错误' : $throwable->getMessage(), + 'data' => method_exists($throwable, 'getData') ? $throwable->getData() : null, + ], + ]; + return $response + ->withHeader('Content-Type', 'application/json; charset=utf-8') + ->withHeader('X-Trace-Id', $traceId) + ->withStatus(200) + ->withBody(new SwooleStream(json_encode($responseData, JSON_UNESCAPED_UNICODE))); + } + + public function isValid(Throwable $throwable): bool + { + return true; + } +} diff --git a/src/Library/Util.php b/src/Library/Util.php new file mode 100644 index 0000000..84c0a2e --- /dev/null +++ b/src/Library/Util.php @@ -0,0 +1,300 @@ +get($name); + } + + /** + * 记录日志信息 + * + * 此方法使用Hyperf的LoggerFactory创建一个日志记录器,并将指定的消息和数据记录到日志中 + * 日志内容会被格式化为JSON字符串并以info级别记录 + * + * @param string $msg 日志消息内容 + * @param mixed $data 需要记录的额外数据,可以是任意类型的数据 + * @param bool $isError 是否为异常日志,默认为false + * @return void + * + * 示例: + * Util::recordLog('用户登录', ['user_id' => 123, 'ip' => '192.168.1.1']); + * // 这将在日志中记录类似 {"msg":"用户登录","data":{"user_id":123,"ip":"192.168.1.1"}} 的信息 + */ + public static function recordLog(string $msg, mixed $data, string $traceId = '', bool $isError = false): void + { + $fatory = self::get(LoggerFactory::class); + $logger = $fatory->get('log', 'default'); + $content = [ + 'msg' => $msg, + 'trace_id' => $traceId, + 'data' => ($data instanceof \Throwable) ? [ + 'message' => $data->getMessage(), + 'code' => $data->getCode(), + 'file' => $data->getFile(), + 'line' => $data->getLine(), + 'trace' => $isError ? $data->getTrace() : null, + 'data' => method_exists($data, 'getData') ? $data->getData() : null, + ] : $data, + ]; + if ($isError) { + $logger->error(json_encode($content, JSON_UNESCAPED_UNICODE)); + } else { + $logger->info(json_encode($content, JSON_UNESCAPED_UNICODE)); + } + } + + /** + * 从原始数据中提取指定字段的值,并可进行键名映射、设置默认值和字符串裁剪 + * + * @param array $originalData 原始数据数组 + * @param array $fields 字段配置数组,每个元素为一个字段的配置,格式如下: + * [原键名, 默认值, 新键名, 是否裁剪(可选,默认true)] + * - 原键名: 在原始数据中的键名 + * - 默认值: 如果原始数据中不存在该键,则使用此默认值 + * - 新键名: 提取后的新键名(可选,如果不填则使用原键名) + * - 是否裁剪: 是否对字符串值进行trim操作(可选,默认true) + * @return array 提取处理后的数据数组 + * + * 示例: + * $originalData = ['name' => ' test ', 'age' => 25, 'email' => 'test@example.com']; + * $fields = [ + * ['name', '', 'new_name'], // 从'name'提取到'new_name',并裁剪空格 + * ['age', 0], // 从'age'提取,保持原键名 + * ['nonexistent', 'default_value'], // 键不存在,使用默认值 + * ['email', '', 'mail', false] // 从'email'提取到'mail',但不裁剪 + * ]; + */ + public static function getParams(array $originalData, array $fields): array + { + $data = Arr::mapWithKeys($fields, static function ($field) use ($originalData) { + if (!is_array($field)) { + return []; + } + + $originalKey = Arr::get($field, 0); // 获取原始键名 + if (!$originalKey) { + return []; + } + + $value = Arr::get($originalData, $originalKey, Arr::get($field, 1)); // 获取原始数据中的值,如果不存在则使用默认值 + if (is_string($value) && Arr::get($field, 3, true)) { // 检查是否需要裁剪字符串 + $value = trim($value); + } + + $newKey = Arr::get($field, 2) ?: $originalKey; // 获取新键名,如果未指定则使用原始键名 + return [$newKey => $value]; // 返回新键值对 + }); + + return $data; + } + + /** + * 获取数组最大嵌套深度(标准定义) + * + * @param mixed $arr 待检测的变量 + * @return int 深度值(空数组为 1,非数组为 0) + */ + public static function getArrayDepth(mixed $arr): int + { + if (!is_array($arr)) { + return 0; + } + + if (empty($arr)) { + return 1; + } + + $stack = [[$arr, 1]]; // [当前数组, 当前层级] + $maxDepth = 1; + + while (!empty($stack)) { + [$currentArray, $level] = array_pop($stack); + $maxDepth = max($maxDepth, $level); + + foreach ($currentArray as $value) { + if (is_array($value)) { + $stack[] = [$value, $level + 1]; + } + } + } + + return $maxDepth; + } + + /** + * 将扁平化的数组结构转换为树形结构 + * + * 此方法通过指定的ID字段和父级ID字段,将一维数组转换为具有父子关系的多维树形结构 + * 支持自动生成子节点列表,如果根节点无法直接识别(没有id为0的项),则会尝试找出最顶层的节点作为根节点 + * + * @param array $data 包含扁平化数据的数组,每个元素应包含id和parent_id字段 + * @param string $idKey 用于标识唯一性的字段名称,通常是主键字段名 + * @param string $parentIdKey 用于表示父级关系的字段名称,通常是外键字段名 + * @return array 转换后的树形结构数组 + * + * 示例: + * $data = [ + * ['id' => 1, 'name' => 'Parent 1', 'pid' => 0], + * ['id' => 2, 'name' => 'Child 1', 'pid' => 1], + * ['id' => 3, 'name' => 'Child 2', 'pid' => 1], + * ['id' => 4, 'name' => 'Grandchild 1', 'pid' => 2] + * ]; + * $tree = CustomUtil::generateTree($data, 'id', 'pid'); + * + * 结果将是: + * [ + * [ + * 'id' => 1, + * 'name' => 'Parent 1', + * 'pid' => 0, + * 'child' => [ + * [ + * 'id' => 2, + * 'name' => 'Child 1', + * 'pid' => 1, + * 'child' => [ + * // ... + * ] + * ], + * // ... + * ] + * ] + * ] + */ + public static function generateTree(array $data, string $idKey, string $parentIdKey): array + { + // 创建一个以父级ID为键的映射表,用于快速查找子节点 + $map = []; + foreach ($data as &$item) { + $map[$item[$parentIdKey]][] = &$item; + } + + // 遍历所有数据项,将子节点添加到父节点的child属性中 + foreach ($data as &$item) { + if (isset($map[$item[$idKey]])) { + $item['child'] = $map[$item[$idKey]]; + } + } + // 解除引用,避免潜在的内存泄漏或意外修改 + unset($item); + + // 如果存在以0为键的项(通常代表顶级节点),则直接返回 + if (isset($map[0])) { + return $map[0]; + } else { + // 否则,寻找最深层级的节点作为根节点返回 + $depths = Arr::mapWithKeys($map, function ($item, $id) { + return [$id => self::getArrayDepth($item)]; + }); + $maxDepth = max($depths); + $parentIds = []; + foreach ($depths as $parentId => $depth) { + if ($depth === $maxDepth) { + $parentIds[] = $parentId; + } + } + return array_column(Arr::only($map, $parentIds), 0); + } + } + + /** + * 将数组的值赋值给对象,指定某些字段 + * @param type $obj 对象实例 + * @param array $params 源数据数组,['k1'=>'v1'] + * @param array $only 指定的数组字段,['k1','k2'] + */ + public static function setObjectValue($obj, array $params, array $only = []) + { + foreach ($params as $key => $value) { + if (!empty($only) && !in_array($key, $only)) { + continue; + } + $obj->$key = $value; + } + } + + /** + * 将数组的值赋值给对象,排除某些字段 + * @param type $obj 对象实例 + * @param array $params 源数据数组,['k1'=>'v1'] + * @param array $except 排除的数组字段,['k1','k2'] + */ + public static function setObjectValueExcept($obj, array $params, array $except = []) + { + foreach ($params as $key => $value) { + if (!in_array($key, $except)) { + $obj->$key = $value; + } + } + } + + /** + * 进行hash_hmac加密 + * @param string $config 待加密的字符串 + * @param string $key 密钥 + * @return string 加密后的字符串 + */ + public static function hashHmac(string $config, string $key): string + { + $hmac = hash_hmac('md5', $config, $key, true); // true 返回原始二进制 + return base64_encode($hmac); + } + + /** + * 获取下一天日期 + * @param string $date 日期 + * @return string + */ + public static function getNextDate(string $date): string + { + return Carbon::parse($date)->addDay()->format('Y-m-d'); + } + + /** + * 获取上一天日期 + * @param string $date 日期 + * @return string + */ + public static function getLastDate(string $date): string + { + return Carbon::parse($date)->subDay()->format('Y-m-d'); + } + + /** + * 判断时间是否在时间段内 + * @param array $dateTimeData 时间段数据 + * @param string $dateTime 待判断的时间 + * @param string $startKey 开始时间字段名 + * @param string $endKey 结束时间字段名 + * @return bool + */ + public static function isInDateTime(array $dateTimeData, string $dateTime, string $startKey = 'start_date', string $endKey = 'end_date'): bool + { + foreach ($dateTimeData as $item) { + $startDate = Arr::get($item, $startKey) ?? min($item); + $endDate = Arr::get($item, $endKey) ?? max($item); + if ($dateTime >= $startDate && $dateTime < $endDate) { + return true; + } + } + return false; + } +} diff --git a/src/Middleware/HttpMiddleware.php b/src/Middleware/HttpMiddleware.php new file mode 100644 index 0000000..e69de29 diff --git a/src/Middleware/JsonRpcMiddleware.php b/src/Middleware/JsonRpcMiddleware.php new file mode 100644 index 0000000..4dc1a06 --- /dev/null +++ b/src/Middleware/JsonRpcMiddleware.php @@ -0,0 +1,28 @@ +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/Model.php b/src/Model.php new file mode 100644 index 0000000..1507e25 --- /dev/null +++ b/src/Model.php @@ -0,0 +1,38 @@ + '字段名', 'direction' => 'asc/desc']. + */ + protected array|bool|string $sortable = false; // 'sort'; // 默认按sort字段升序 +} diff --git a/src/Repository.php b/src/Repository.php new file mode 100644 index 0000000..e69de29 diff --git a/src/Service.php b/src/Service.php new file mode 100644 index 0000000..e69de29 diff --git a/src/Trait/RpcContextTrait.php b/src/Trait/RpcContextTrait.php new file mode 100644 index 0000000..d7de288 --- /dev/null +++ b/src/Trait/RpcContextTrait.php @@ -0,0 +1,39 @@ +get('trace_id'); + } + + /** + * 获取app版本号 + */ + public function getAppVersion(): ?string + { + return Util::get(RpcContext::class)->get('app_version'); + } + + /** + * 获取平台名称 + */ + public function getPlatformName(): ?string + { + return Util::get(RpcContext::class)->get('platform_name'); + } +}