config = (array) \Hyperf\Config\config('regulatory_platform', []); $this->container = ApplicationContext::getContainer(); $this->client = $this->container->get(Client::class); $this->redis = $this->container->get(Redis::class); $this->protocol = RegulatoryPlatformProtocol::fromConfig($this->config); $this->client_id = $this->protocol->getClientId(); $this->client_secret = $this->protocol->getClientSecret(); $this->api_url = $this->resolveConfigValue([ 'api_url', 'v2.api_url', 'v2.data_upload_base_url', 'data_upload_base_url', 'data_api_url', 'base_url', ]); } public function protocol(): RegulatoryPlatformProtocol { return $this->protocol; } public function buildV2RequestEnvelope( array $payload, bool $includeClientId = false, ?string $clientId = null, ?string $clientSecret = null ): array { return $this->protocol->buildRequestEnvelope($payload, $includeClientId, $clientId, $clientSecret); } public function decodeV2ResponseEnvelope( array $response, bool $verifySignature = false, ?string $clientId = null, ?string $clientSecret = null ): array { return $this->protocol->decodeResponseEnvelope($response, $verifySignature, $clientId, $clientSecret); } public function getAccessToken(bool $forceRefresh = false): string { if (! $forceRefresh) { $cachedAccessToken = $this->getCachedAccessToken(); if (! empty($cachedAccessToken)) { return $cachedAccessToken; } } $refreshToken = $this->getCachedRefreshToken(); if (! empty($refreshToken)) { try { $tokenPayload = $this->refreshAccessToken($refreshToken); return $tokenPayload['accessToken']; } catch (\Throwable $throwable) { Log::getInstance('regulatoryPlatform-token', 'regulatory_platform_token')->warning($throwable->getMessage()); $this->clearCachedRefreshToken(); } } $tokenPayload = $this->requestAccessToken(); return $tokenPayload['accessToken']; } public function refreshAccessToken(?string $refreshToken = null): array { $refreshToken ??= $this->getCachedRefreshToken(); if (empty($refreshToken)) { throw new BusinessException('Regulatory platform refresh token cache is empty'); } return $this->requestToken( $this->buildAuthEndpoint('refresh'), [ 'grantType' => 'refresh_token', 'refreshToken' => $refreshToken, ] ); } public function clearTokenCache(): void { $this->redis->del(self::ACCESS_TOKEN_CACHE_KEY); $this->redis->del(self::REFRESH_TOKEN_CACHE_KEY); $this->redis->del(self::EXPIRES_AT_CACHE_KEY); } public function requestV2Decoded( string $url, array $payload, bool $includeClientId = false, bool $verifySignature = false ): array { try { $envelope = $this->buildV2RequestEnvelope($payload, $includeClientId); $this->logBusinessRequest('requestV2Decoded', $url, [ 'payload' => $payload, 'includeClientId' => $includeClientId, 'verifySignature' => $verifySignature, 'requestEnvelope' => $envelope, ]); $response = $this->httpRequestV2($url, ['json' => $envelope]); $this->assertV2SuccessResponse($response); $decoded = $this->decodeV2ResponseEnvelope($response, $verifySignature); $this->logBusinessResponse('requestV2Decoded', $url, [ 'verifySignature' => $verifySignature, 'response' => $response, 'decodedResponse' => $decoded, ]); return $decoded; } catch (GuzzleException $e) { $this->logBusinessException('requestV2Decoded', $url, $e, [ 'payload' => $payload, 'includeClientId' => $includeClientId, 'verifySignature' => $verifySignature, ]); throw new BusinessException($e->getMessage()); } catch (\Throwable $throwable) { $this->logBusinessException('requestV2Decoded', $url, $throwable, [ 'payload' => $payload, 'includeClientId' => $includeClientId, 'verifySignature' => $verifySignature, ]); throw $throwable; } } public function requestV2Raw(string $url, array $payload, bool $includeClientId = false): array { try { $envelope = $this->buildV2RequestEnvelope($payload, $includeClientId); $this->logBusinessRequest('requestV2Raw', $url, [ 'payload' => $payload, 'includeClientId' => $includeClientId, 'requestEnvelope' => $envelope, ]); $response = $this->httpRequestV2($url, ['json' => $envelope]); $this->assertV2SuccessResponse($response); $this->logBusinessResponse('requestV2Raw', $url, [ 'response' => $response, ]); return $response; } catch (GuzzleException $e) { $this->logBusinessException('requestV2Raw', $url, $e, [ 'payload' => $payload, 'includeClientId' => $includeClientId, ]); throw new BusinessException($e->getMessage()); } catch (\Throwable $throwable) { $this->logBusinessException('requestV2Raw', $url, $throwable, [ 'payload' => $payload, 'includeClientId' => $includeClientId, ]); throw $throwable; } } public function extractV2UploadErrors(array $decodedResponse): array { $rows = $decodedResponse['data']['list'] ?? []; if (! is_array($rows)) { return []; } $errors = []; foreach ($rows as $row) { if (! is_array($row)) { continue; } $rowErrors = $row['errors'] ?? []; if (! is_array($rowErrors) || $rowErrors === []) { continue; } $errors[] = [ 'tableName' => isset($row['tableName']) && is_string($row['tableName']) ? $row['tableName'] : '', 'errors' => $rowErrors, ]; } return $errors; } public function hasV2UploadErrors(array $decodedResponse): bool { return $this->extractV2UploadErrors($decodedResponse) !== []; } /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface * 上报网络咨询(网络门诊)服务 */ public function uploadConsult(array $arg): array { return $this->uploadData('api_upload_consult', $arg); } /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface * 上报网络复诊服务 */ public function uploadFurtherConsult(array $arg): array { return $this->uploadData('api_upload_further_consult', $arg); } /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface * 上报电子处方服务 */ public function uploadRecipe(array $arg): array { return $this->uploadData('api_upload_recipe', $arg); } /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface * 上报药品处方明细 */ public function uploadRecipeDetail(array $arg): array { return $this->uploadData('api_upload_recipe_detail_yp', $arg); } protected function requestAccessToken(): array { return $this->requestToken( $this->buildAuthEndpoint('access'), [ 'grantType' => 'client_credentials', ] ); } protected function requestToken(string $url, array $payload): array { $decoded = $this->requestV2Decoded($url, $payload, true, false); $tokenPayload = $this->extractTokenPayload($decoded['decoded']); $this->cacheTokenPayload($tokenPayload); return $tokenPayload; } protected function extractTokenPayload(array $decoded): array { $result = $decoded['result'] ?? null; if (! is_array($result)) { throw new BusinessException('Regulatory platform token response missing result'); } $accessToken = $result['accessToken'] ?? ''; if (! is_string($accessToken) || $accessToken === '') { throw new BusinessException('Regulatory platform token response missing accessToken'); } $refreshToken = $result['refreshToken'] ?? $this->getCachedRefreshToken(); $expiresIn = (int) ($result['expiresIn'] ?? self::DEFAULT_ACCESS_TOKEN_TTL); if ($expiresIn <= 0) { $expiresIn = self::DEFAULT_ACCESS_TOKEN_TTL; } return [ 'accessToken' => $accessToken, 'refreshToken' => is_string($refreshToken) ? $refreshToken : '', 'expiresIn' => $expiresIn, 'tokenType' => isset($result['tokenType']) && is_string($result['tokenType']) ? $result['tokenType'] : '', ]; } protected function uploadData(string $tableName, array $rows): array { $accessToken = $this->getAccessToken(); $payload = [ $tableName => $this->normalizeUploadRows($rows), ]; $envelope = $this->buildV2RequestEnvelope($payload, false); $response = $this->httpRequestV2($this->buildDataUploadRequestUrl(), [ 'query' => $this->buildClientCredentialQuery(), 'headers' => [ 'du-token' => $accessToken, ], 'json' => $envelope, ]); $this->assertV2SuccessResponse($response); $decodedResponse = $this->decodeV2ResponseEnvelope($response, false); $decodedPayload = $decodedResponse['decoded']; if ($this->hasV2UploadErrors($decodedPayload)) { throw new BusinessException($this->formatV2UploadErrors($this->extractV2UploadErrors($decodedPayload))); } return $decodedPayload; } protected function cacheTokenPayload(array $tokenPayload): void { $expiresIn = (int) ($tokenPayload['expiresIn'] ?? self::DEFAULT_ACCESS_TOKEN_TTL); if ($expiresIn <= 0) { $expiresIn = self::DEFAULT_ACCESS_TOKEN_TTL; } $accessTokenTtl = max($expiresIn - self::ACCESS_TOKEN_BUFFER, 1); $refreshTokenTtl = max($expiresIn, self::DEFAULT_REFRESH_TOKEN_TTL); $this->redis->setex(self::ACCESS_TOKEN_CACHE_KEY, $accessTokenTtl, $tokenPayload['accessToken']); $this->redis->setex(self::EXPIRES_AT_CACHE_KEY, $refreshTokenTtl, (string) (time() + $accessTokenTtl)); if (! empty($tokenPayload['refreshToken'])) { $this->redis->setex(self::REFRESH_TOKEN_CACHE_KEY, $refreshTokenTtl, $tokenPayload['refreshToken']); } } protected function getCachedAccessToken(): ?string { $token = $this->redis->get(self::ACCESS_TOKEN_CACHE_KEY); return is_string($token) && $token !== '' ? $token : null; } protected function getCachedRefreshToken(): ?string { $token = $this->redis->get(self::REFRESH_TOKEN_CACHE_KEY); return is_string($token) && $token !== '' ? $token : null; } protected function clearCachedRefreshToken(): void { $this->redis->del(self::REFRESH_TOKEN_CACHE_KEY); } protected function httpRequest(string $path, array $arg = []): array { $body = $this->sendJsonRequest($path, $arg, 'regulatoryPlatform-httpRequest', false); if (isset($body['code'])) { if (isset($body['message'])) { throw new BusinessException((string) $body['message']); } throw new BusinessException(HttpEnumCode::getMessage(HttpEnumCode::SERVER_ERROR)); } return $body; } protected function httpRequestV2(string $path, array $arg = []): array { return $this->sendJsonRequest($path, $arg, 'regulatoryPlatform-httpRequestV2', true); } protected function assertV2SuccessResponse(array $response): void { if (isset($response['code']) && (int) $response['code'] !== 0) { throw new BusinessException($this->extractV2ErrorMessage($response)); } if (isset($response['success']) && $response['success'] !== true) { throw new BusinessException($this->extractV2ErrorMessage($response)); } if (isset($response['serviceSuccess']) && $response['serviceSuccess'] !== true) { throw new BusinessException($this->extractV2ErrorMessage($response)); } } protected function extractV2ErrorMessage(array $response): string { if (! empty($response['message']) && is_string($response['message'])) { return $response['message']; } if (! empty($response['errors']) && is_array($response['errors'])) { $first = $response['errors'][0] ?? null; if (is_string($first) && $first !== '') { return $first; } if (is_array($first)) { $json = json_encode($first, JSON_UNESCAPED_UNICODE); if ($json !== false) { return $json; } } } return HttpEnumCode::getMessage(HttpEnumCode::SERVER_ERROR); } protected function buildAuthEndpoint(string $type): string { if (empty($this->api_url)) { throw new BusinessException('Regulatory platform api_url is not configured'); } if ($type === 'access') { return $this->joinUrl($this->api_url, '/auth-api/oauth2/accessToken'); } return $this->joinUrl($this->api_url, '/auth-api/oauth2/refreshToken'); } protected function buildDataUploadEndpoint(): string { if (empty($this->api_url)) { throw new BusinessException('Regulatory platform api_url is not configured'); } return $this->joinUrl($this->api_url, '/du-api/v1/dataUpload'); } protected function buildDataUploadRequestUrl(): string { return $this->buildDataUploadEndpoint(); } protected function sendJsonRequest(string $path, array $arg, string $logChannel, bool $sanitize): array { $option = [ 'verify' => false, 'timeout' => self::HTTP_TIMEOUT, 'connect_timeout' => self::HTTP_CONNECT_TIMEOUT, 'http_errors' => false, ]; if (! empty($option)) { $arg = array_merge($arg, $option); } $requestLog = $sanitize ? $this->sanitizeLogContext($arg) : $arg; $requestStartAt = microtime(true); Log::getInstance($logChannel)->info(json_encode([ 'phase' => 'request', 'url' => $path, 'request' => $requestLog, ], JSON_UNESCAPED_UNICODE)); $httpLogChannel = 'regulatoryPlatform-http-detail'; $requestDetail = $this->buildHttpRequestLogContext($path, $arg, $sanitize); Log::getInstance($httpLogChannel, 'regulatory_platform_http_detail')->info(json_encode([ 'phase' => 'request', 'url' => $path, 'request' => $requestDetail, ], JSON_UNESCAPED_UNICODE)); try { $response = $this->client->post($path, $arg); $statusCode = $response->getStatusCode(); $bodyText = (string) $response->getBody(); $body = json_decode($bodyText, true); $durationMs = $this->toDurationMs($requestStartAt); $responseLog = $sanitize ? $this->sanitizeLogContext(is_array($body) ? $body : ['raw' => $bodyText]) : (is_array($body) ? $body : ['raw' => $bodyText]); Log::getInstance($logChannel)->info(json_encode([ 'phase' => 'response', 'status' => $statusCode, 'duration_ms' => $durationMs, 'response' => $responseLog, ], JSON_UNESCAPED_UNICODE)); Log::getInstance($httpLogChannel, 'regulatory_platform_http_detail')->info(json_encode([ 'phase' => 'response', 'url' => $path, 'status' => $statusCode, 'duration_ms' => $durationMs, 'response' => $this->buildHttpResponseLogContext($response, $bodyText, $body, $sanitize), ], JSON_UNESCAPED_UNICODE)); if ($statusCode !== 200) { $message = is_array($body) ? $this->extractV2ErrorMessage($body) : $bodyText; throw new BusinessException(sprintf('Regulatory platform HTTP request failed[%s]: %s', $statusCode, $message)); } if (! is_array($body) || $body === []) { throw new BusinessException(HttpEnumCode::getMessage(HttpEnumCode::SERVER_ERROR)); } return $body; } catch (\Throwable $throwable) { Log::getInstance($httpLogChannel, 'regulatory_platform_http_detail')->error(json_encode([ 'phase' => 'exception', 'url' => $path, 'duration_ms' => $this->toDurationMs($requestStartAt), 'request' => $requestDetail, 'exception' => [ 'message' => $throwable->getMessage(), 'file' => $throwable->getFile(), 'line' => $throwable->getLine(), ], ], JSON_UNESCAPED_UNICODE)); throw $throwable; } } protected function buildHttpRequestLogContext(string $url, array $arg, bool $sanitize): array { $query = $arg['query'] ?? []; $headers = $arg['headers'] ?? []; $json = $arg['json'] ?? null; $formParams = $arg['form_params'] ?? null; return [ 'method' => 'POST', 'url' => $this->appendQueryString($url, is_array($query) ? $query : []), 'headers' => $sanitize ? $this->sanitizeLogContext(is_array($headers) ? $headers : []) : $headers, 'query' => $sanitize ? $this->sanitizeLogContext(is_array($query) ? $query : []) : $query, 'json' => $sanitize ? $this->sanitizeLogContext($json) : $json, 'form_params' => $sanitize ? $this->sanitizeLogContext($formParams) : $formParams, 'timeout' => $arg['timeout'] ?? null, 'connect_timeout' => $arg['connect_timeout'] ?? null, 'verify' => $arg['verify'] ?? null, ]; } protected function buildHttpResponseLogContext(object $response, string $bodyText, mixed $body, bool $sanitize): array { $headers = []; foreach ($response->getHeaders() as $key => $value) { $headers[$key] = is_array($value) ? implode('; ', $value) : $value; } $normalizedBody = is_array($body) ? $body : ['raw' => $bodyText]; return [ 'headers' => $sanitize ? $this->sanitizeLogContext($headers) : $headers, 'body' => $sanitize ? $this->sanitizeLogContext($normalizedBody) : $normalizedBody, ]; } protected function appendQueryString(string $url, array $query): string { if ($query === []) { return $url; } $queryString = http_build_query($query); if ($queryString === '') { return $url; } return str_contains($url, '?') ? $url . '&' . $queryString : $url . '?' . $queryString; } protected function toDurationMs(float $requestStartAt): int { return (int) round((microtime(true) - $requestStartAt) * 1000); } protected function logBusinessRequest(string $action, string $url, array $context): void { Log::getInstance('regulatoryPlatform-business', 'regulatory_platform_business')->info(json_encode([ 'phase' => 'request', 'action' => $action, 'url' => $url, 'context' => $this->sanitizeLogContext($context), ], JSON_UNESCAPED_UNICODE)); } protected function logBusinessResponse(string $action, string $url, array $context): void { Log::getInstance('regulatoryPlatform-business', 'regulatory_platform_business')->info(json_encode([ 'phase' => 'response', 'action' => $action, 'url' => $url, 'context' => $this->sanitizeLogContext($context), ], JSON_UNESCAPED_UNICODE)); } protected function logBusinessException(string $action, string $url, \Throwable $throwable, array $context = []): void { Log::getInstance('regulatoryPlatform-business', 'regulatory_platform_business')->error(json_encode([ 'phase' => 'exception', 'action' => $action, 'url' => $url, 'context' => $this->sanitizeLogContext($context), 'exception' => [ 'message' => $throwable->getMessage(), 'file' => $throwable->getFile(), 'line' => $throwable->getLine(), ], ], JSON_UNESCAPED_UNICODE)); } protected function sanitizeLogContext(mixed $payload): mixed { if (! is_array($payload)) { if (is_string($payload) && strlen($payload) > 160) { return substr($payload, 0, 24) . '...' . substr($payload, -24); } return $payload; } $masked = []; foreach ($payload as $key => $value) { if (is_array($value)) { $masked[$key] = $this->sanitizeLogContext($value); continue; } $keyString = strtolower((string) $key); if (in_array($keyString, ['sign', 'data', 'accesstoken', 'refreshtoken', 'client_secret', 'key'], true)) { if (is_string($value)) { $masked[$key] = [ 'len' => strlen($value), 'preview' => strlen($value) > 16 ? substr($value, 0, 8) . '...' . substr($value, -8) : $value, ]; } else { $masked[$key] = '[masked]'; } continue; } $masked[$key] = $value; } return $masked; } protected function normalizeUploadRows(array $rows): array { $normalized = []; foreach ($rows as $row) { if (! is_array($row)) { continue; } unset($row['accessToken'], $row['clientId']); if (! isset($row['client_id']) || ! is_string($row['client_id']) || trim($row['client_id']) === '') { $row['client_id'] = (string) $this->client_id; } if (array_key_exists('patientSex', $row)) { $row['patientSex'] = $this->normalizePatientSex( $row['patientSex'], isset($row['patientIdcardType']) ? (int) $row['patientIdcardType'] : null, isset($row['patientIdcardNum']) && is_scalar($row['patientIdcardNum']) ? (string) $row['patientIdcardNum'] : null ); } $normalized[] = $row; } return $normalized; } protected function normalizePatientSex(mixed $patientSex, ?int $patientIdcardType = null, ?string $patientIdcardNum = null): int { $inferredSex = $this->inferPatientSexFromIdCard($patientIdcardType, $patientIdcardNum); if ($inferredSex !== null) { return $inferredSex; } $patientSex = (int) $patientSex; return in_array($patientSex, [1, 2], true) ? $patientSex : 0; } protected function inferPatientSexFromIdCard(?int $patientIdcardType, ?string $patientIdcardNum): ?int { if ($patientIdcardType !== 1 || empty($patientIdcardNum)) { return null; } $patientIdcardNum = strtoupper(trim($patientIdcardNum)); if (preg_match('/^\d{17}[\dX]$/', $patientIdcardNum) === 1) { return ((int) $patientIdcardNum[16]) % 2 === 1 ? 1 : 2; } if (preg_match('/^\d{15}$/', $patientIdcardNum) === 1) { return ((int) $patientIdcardNum[14]) % 2 === 1 ? 1 : 2; } return null; } protected function formatV2UploadErrors(array $errors): string { $messages = []; foreach ($errors as $error) { $tableName = isset($error['tableName']) && is_string($error['tableName']) ? $error['tableName'] : ''; $rowErrors = $error['errors'] ?? []; if (! is_array($rowErrors) || $rowErrors === []) { continue; } $message = implode('; ', array_map(static function (mixed $item): string { if (is_string($item)) { return $item; } $json = json_encode($item, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return $json === false ? '[invalid error payload]' : $json; }, $rowErrors)); $messages[] = $tableName !== '' ? sprintf('%s: %s', $tableName, $message) : $message; } if ($messages === []) { return 'Regulatory platform upload validation failed'; } return implode(' | ', $messages); } protected function buildClientCredentialQuery(): array { return array_filter([ 'client_id' => $this->client_id, 'client_secret' => $this->client_secret, ], static fn (?string $value): bool => is_string($value) && $value !== ''); } protected function joinUrl(string $baseUrl, string $path): string { return rtrim($baseUrl, '/') . '/' . ltrim($path, '/'); } protected function resolveConfigValue(array $paths): ?string { foreach ($paths as $path) { $value = $this->getByPath($this->config, $path); if ($value !== null && $value !== '') { return (string) $value; } } return null; } protected function getByPath(array $config, string $path): mixed { $current = $config; foreach (explode('.', $path) as $segment) { if (! is_array($current) || ! array_key_exists($segment, $current)) { return null; } $current = $current[$segment]; } return $current; } }