监管平台更新接口 V2
Build Docker / build (push) Has been cancelled

This commit is contained in:
haomingming
2026-05-12 14:49:47 +08:00
parent f9ba59aa55
commit 012984f636
15 changed files with 1773 additions and 204 deletions
+31 -1
View File
@@ -110,6 +110,36 @@ abstract class Ca
* @param array $data
* @return bool
*/
/**
* Timestamp sign service.
* @param string $to_sign
* @return mixed
*/
public function getTimestampSign(string $to_sign): mixed
{
$generator = $this->container->get(IdGeneratorInterface::class);
$option = [
'form_params' => [
'requestId' => $generator->generate(),
'toSign' => $to_sign,
]
];
try {
$response = $this->httpRequest(
$this->api_url . '/signgw-service/api/signgw/timestamap/sign',
$option
);
if (empty($response)) {
throw new BusinessException(HttpEnumCode::getMessage(HttpEnumCode::SERVER_ERROR));
}
return $response;
} catch (GuzzleException $e) {
throw new BusinessException($e->getMessage());
}
}
public function addUserSignConfig(string $user_id, string $card_num, array $data): bool
{
$arg = [
@@ -455,4 +485,4 @@ abstract class Ca
return hash_hmac("sha1", $data, $this->secret);
}
}
}
@@ -0,0 +1,595 @@
<?php
declare(strict_types=1);
namespace Extend\RegulatoryPlatform;
use App\Exception\BusinessException;
use function Hyperf\Config\config;
class RegulatoryPlatformProtocol
{
protected const SM2_CURVE_OID = '1.2.156.10197.1.301';
protected const EC_PUBLIC_KEY_OID = '1.2.840.10045.2.1';
protected ?string $clientId;
protected ?string $clientSecret;
protected ?string $sm4KeyHex;
protected ?string $sm4IvHex;
protected ?string $sm2PrivateKey;
protected ?string $sm2PublicKey;
public function __construct(array $config = [])
{
$this->clientId = $this->resolveConfigValue($config, [
'client_id',
'v2.client_id',
'prod.client_id',
'default.client_id',
]);
$this->clientSecret = $this->resolveConfigValue($config, [
'client_secret',
'v2.client_secret',
'prod.client_secret',
'default.client_secret',
]);
$this->sm4KeyHex = $this->resolveConfigValue($config, [
'sm4_key',
'encrypt_key',
'v2.sm4_key',
'crypto.sm4_key',
'sm4.key',
]);
$this->sm4IvHex = $this->resolveConfigValue($config, [
'sm4_iv',
'encrypt_iv',
'v2.sm4_iv',
'crypto.sm4_iv',
'sm4.iv',
]);
$this->sm2PrivateKey = $this->resolveConfigValue($config, [
'sm2_private_key',
'sign_private_key',
'v2.sm2_private_key',
'crypto.sm2_private_key',
'sm2.private_key',
]);
$this->sm2PublicKey = $this->resolveConfigValue($config, [
'sm2_public_key',
'verify_public_key',
'platform_public_key',
'v2.sm2_public_key',
'crypto.sm2_public_key',
'sm2.public_key',
]);
}
public static function fromConfig(?array $config = null): self
{
$config ??= (array) config('regulatory_platform', []);
return new self($config);
}
public function getClientId(): ?string
{
return $this->clientId;
}
public function getClientSecret(): ?string
{
return $this->clientSecret;
}
public function buildRequestEnvelope(
array $payload,
bool $includeClientId = false,
?string $clientId = null,
?string $clientSecret = null
): array {
$plainText = $this->encodeCanonicalJson($payload, true);
return $this->buildRequestEnvelopeFromPlainText($plainText, $includeClientId, $clientId, $clientSecret);
}
public function buildRequestEnvelopeFromPlainText(
string $plainText,
bool $includeClientId = false,
?string $clientId = null,
?string $clientSecret = null
): array {
$resolvedClientId = $clientId ?? $this->requireClientId();
$resolvedClientSecret = $clientSecret ?? $this->requireClientSecret();
$encryptedHex = $this->encrypt($plainText);
$sign = $this->sign($this->buildSignString($resolvedClientId, $encryptedHex, $resolvedClientSecret));
$envelope = [
'sign' => $sign,
'data' => $encryptedHex,
];
if ($includeClientId) {
$envelope['client_id'] = $resolvedClientId;
}
return $envelope;
}
public function decodeResponseEnvelope(
array $response,
bool $verifySignature = false,
?string $clientId = null,
?string $clientSecret = null
): array {
$envelope = $this->extractResponseEnvelope($response);
$resolvedClientId = $clientId ?? $this->requireClientId();
$resolvedClientSecret = $clientSecret ?? $this->requireClientSecret();
if ($verifySignature) {
$verified = $this->verify(
$this->buildSignString($resolvedClientId, $envelope['data'], $resolvedClientSecret),
$envelope['sign']
);
if (! $verified) {
throw new BusinessException('监管平台响应验签失败');
}
}
$plainText = $this->decrypt($envelope['data']);
$decoded = json_decode($plainText, true);
if (! is_array($decoded)) {
throw new BusinessException('监管平台响应解密成功,但 JSON 解析失败');
}
return [
'envelope' => $envelope,
'plain_text' => $plainText,
'decoded' => $decoded,
];
}
public function buildSignString(string $clientId, string $encryptedHex, ?string $clientSecret = null): string
{
$resolvedClientSecret = $clientSecret ?? $this->requireClientSecret();
return sprintf('client_id=%s&data=%s&key=%s', $clientId, $encryptedHex, $resolvedClientSecret);
}
public function encodeCanonicalJson(array $payload, bool $removeEmptyValues = true): string
{
$normalized = $this->canonicalizePayload($payload, $removeEmptyValues);
$json = json_encode(
$normalized,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION
);
if ($json === false) {
throw new BusinessException('监管平台请求 JSON 编码失败');
}
return $json;
}
public function canonicalizePayload(array $payload, bool $removeEmptyValues = true): array
{
$normalized = $this->normalizeValue($payload, $removeEmptyValues);
if (! is_array($normalized)) {
throw new BusinessException('监管平台请求体格式错误');
}
return $normalized;
}
public function encrypt(string $plainText): string
{
$key = $this->decodeHex($this->requireSm4Key(), 'SM4 key');
$iv = $this->decodeHex($this->requireSm4Iv(), 'SM4 iv');
$cipherText = openssl_encrypt($plainText, 'sm4-cbc', $key, OPENSSL_RAW_DATA, $iv);
if ($cipherText === false) {
throw new BusinessException('监管平台 SM4 加密失败: ' . $this->getLastOpenSslError());
}
return strtolower(bin2hex($cipherText));
}
public function decrypt(string $cipherHex): string
{
$cipherRaw = $this->decodeHex($cipherHex, 'SM4 cipher');
$key = $this->decodeHex($this->requireSm4Key(), 'SM4 key');
$iv = $this->decodeHex($this->requireSm4Iv(), 'SM4 iv');
$plainText = openssl_decrypt($cipherRaw, 'sm4-cbc', $key, OPENSSL_RAW_DATA, $iv);
if ($plainText === false) {
throw new BusinessException('监管平台 SM4 解密失败: ' . $this->getLastOpenSslError());
}
return $plainText;
}
public function sign(string $plainText): string
{
$privateKey = openssl_pkey_get_private($this->resolveKeyMaterial($this->sm2PrivateKey, 'SM2 private key'));
if ($privateKey === false) {
throw new BusinessException('监管平台 SM2 私钥加载失败: ' . $this->getLastOpenSslError());
}
$signature = '';
$result = openssl_sign($plainText, $signature, $privateKey, 'sm3');
if ($result !== true) {
throw new BusinessException('监管平台 SM2 签名失败: ' . $this->getLastOpenSslError());
}
return strtolower(bin2hex($signature));
}
public function verify(string $plainText, string $signatureHex): bool
{
$publicKey = openssl_pkey_get_public($this->resolveKeyMaterial($this->sm2PublicKey, 'SM2 public key'));
if ($publicKey === false) {
throw new BusinessException('监管平台 SM2 公钥加载失败: ' . $this->getLastOpenSslError());
}
$signatureRaw = $this->decodeHex($signatureHex, 'SM2 signature');
$result = openssl_verify($plainText, $signatureRaw, $publicKey, 'sm3');
if ($result === -1) {
throw new BusinessException('监管平台 SM2 验签失败: ' . $this->getLastOpenSslError());
}
return $result === 1;
}
protected function extractResponseEnvelope(array $response): array
{
if (isset($response['data']) && is_array($response['data']) && isset($response['data']['data'])) {
$response = $response['data'];
}
$sign = $response['sign'] ?? null;
$data = $response['data'] ?? null;
if (! is_string($sign) || ! is_string($data) || $sign === '' || $data === '') {
throw new BusinessException('监管平台响应报文缺少 sign/data');
}
return [
'sign' => $sign,
'data' => $data,
];
}
protected function normalizeValue(mixed $value, bool $removeEmptyValues): mixed
{
if (is_array($value)) {
if ($this->isAssoc($value)) {
$normalized = [];
ksort($value);
foreach ($value as $key => $item) {
$item = $this->normalizeValue($item, $removeEmptyValues);
if ($removeEmptyValues && $this->isEmptyValue($item)) {
continue;
}
$normalized[$key] = $item;
}
return $normalized;
}
$normalized = [];
foreach ($value as $item) {
$item = $this->normalizeValue($item, $removeEmptyValues);
if ($removeEmptyValues && $this->isEmptyValue($item)) {
continue;
}
$normalized[] = $item;
}
return $normalized;
}
return $value;
}
protected function isAssoc(array $value): bool
{
return array_keys($value) !== range(0, count($value) - 1);
}
protected function isEmptyValue(mixed $value): bool
{
if ($value === null) {
return true;
}
if (is_string($value)) {
return trim($value) === '';
}
if (is_array($value)) {
return $value === [];
}
return false;
}
protected function decodeHex(string $value, string $label): string
{
$value = trim($value);
if ($value === '' || strlen($value) % 2 !== 0 || ! ctype_xdigit($value)) {
throw new BusinessException(sprintf('监管平台 %s 不是合法十六进制字符串', $label));
}
$decoded = hex2bin($value);
if ($decoded === false) {
throw new BusinessException(sprintf('监管平台 %s 十六进制解码失败', $label));
}
return $decoded;
}
protected function resolveKeyMaterial(?string $key, string $label): string
{
$key = trim((string) $key);
if ($key === '') {
throw new BusinessException(sprintf('监管平台 %s 未配置', $label));
}
if (str_starts_with($key, 'file://')) {
$key = substr($key, 7);
}
if (is_file($key)) {
$contents = file_get_contents($key);
if ($contents === false || trim($contents) === '') {
throw new BusinessException(sprintf('监管平台 %s 文件读取失败', $label));
}
return $contents;
}
if ($label === 'SM2 private key' && $this->isRawHexPrivateKey($key)) {
return $this->buildSm2PrivateKeyPem($key, $this->sm2PublicKey);
}
if ($label === 'SM2 public key' && $this->isRawHexPublicKey($key)) {
return $this->buildSm2PublicKeyPem($key);
}
return $key;
}
protected function resolveConfigValue(array $config, array $paths): ?string
{
foreach ($paths as $path) {
$value = $this->getByPath($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;
}
protected function requireClientId(): string
{
if (empty($this->clientId)) {
throw new BusinessException('监管平台 client_id 未配置');
}
return $this->clientId;
}
protected function requireClientSecret(): string
{
if (empty($this->clientSecret)) {
throw new BusinessException('监管平台 client_secret 未配置');
}
return $this->clientSecret;
}
protected function requireSm4Key(): string
{
if (empty($this->sm4KeyHex)) {
throw new BusinessException('监管平台 SM4 key 未配置');
}
return $this->sm4KeyHex;
}
protected function requireSm4Iv(): string
{
if (empty($this->sm4IvHex)) {
throw new BusinessException('监管平台 SM4 iv 未配置');
}
return $this->sm4IvHex;
}
protected function getLastOpenSslError(): string
{
$errors = [];
while (($error = openssl_error_string()) !== false) {
$errors[] = $error;
}
return $errors === [] ? 'unknown openssl error' : implode(' | ', $errors);
}
protected function isRawHexPrivateKey(string $key): bool
{
return strlen($key) === 64 && ctype_xdigit($key);
}
protected function isRawHexPublicKey(string $key): bool
{
if (! ctype_xdigit($key)) {
return false;
}
return in_array(strlen($key), [128, 130], true);
}
protected function buildSm2PrivateKeyPem(string $privateKeyHex, ?string $publicKeyHex = null): string
{
$privateKeyRaw = $this->decodeHex($privateKeyHex, 'SM2 private key');
$sequence = $this->derSequence(
$this->derInteger(1),
$this->derOctetString($privateKeyRaw),
$this->derContextSpecific(0, $this->derObjectIdentifier(self::SM2_CURVE_OID))
);
if (is_string($publicKeyHex) && $this->isRawHexPublicKey(trim($publicKeyHex))) {
$publicKeyRaw = $this->normalizeRawPublicKey(trim($publicKeyHex));
$sequence .= $this->derContextSpecific(1, $this->derBitString($publicKeyRaw));
$sequence = $this->derSequence($this->derInteger(1), $this->derOctetString($privateKeyRaw), $this->derContextSpecific(0, $this->derObjectIdentifier(self::SM2_CURVE_OID)), $this->derContextSpecific(1, $this->derBitString($publicKeyRaw)));
}
return $this->pemEncode('EC PRIVATE KEY', $sequence);
}
protected function buildSm2PublicKeyPem(string $publicKeyHex): string
{
$publicKeyRaw = $this->normalizeRawPublicKey($publicKeyHex);
$algorithm = $this->derSequence(
$this->derObjectIdentifier(self::EC_PUBLIC_KEY_OID),
$this->derObjectIdentifier(self::SM2_CURVE_OID)
);
$subjectPublicKeyInfo = $this->derSequence(
$algorithm,
$this->derBitString($publicKeyRaw)
);
return $this->pemEncode('PUBLIC KEY', $subjectPublicKeyInfo);
}
protected function normalizeRawPublicKey(string $publicKeyHex): string
{
$publicKeyHex = strtolower($publicKeyHex);
if (strlen($publicKeyHex) === 128) {
$publicKeyHex = '04' . $publicKeyHex;
}
return $this->decodeHex($publicKeyHex, 'SM2 public key');
}
protected function pemEncode(string $label, string $der): string
{
return "-----BEGIN {$label}-----\n"
. chunk_split(base64_encode($der), 64, "\n")
. "-----END {$label}-----\n";
}
protected function derSequence(string ...$parts): string
{
return $this->derWrap(0x30, implode('', $parts));
}
protected function derInteger(int $value): string
{
$encoded = '';
$current = $value;
do {
$encoded = chr($current & 0xff) . $encoded;
$current >>= 8;
} while ($current > 0);
if ((ord($encoded[0]) & 0x80) !== 0) {
$encoded = "\x00" . $encoded;
}
return $this->derWrap(0x02, $encoded);
}
protected function derOctetString(string $value): string
{
return $this->derWrap(0x04, $value);
}
protected function derBitString(string $value): string
{
return $this->derWrap(0x03, "\x00" . $value);
}
protected function derObjectIdentifier(string $oid): string
{
$parts = array_map('intval', explode('.', $oid));
if (count($parts) < 2) {
throw new BusinessException('监管平台 OID 格式错误');
}
$encoded = chr(($parts[0] * 40) + $parts[1]);
for ($i = 2; $i < count($parts); $i++) {
$encoded .= $this->encodeOidPart($parts[$i]);
}
return $this->derWrap(0x06, $encoded);
}
protected function derContextSpecific(int $index, string $value): string
{
return $this->derWrap(0xa0 + $index, $value);
}
protected function derWrap(int $tag, string $value): string
{
return chr($tag) . $this->derLength(strlen($value)) . $value;
}
protected function derLength(int $length): string
{
if ($length < 0x80) {
return chr($length);
}
$encoded = '';
$current = $length;
while ($current > 0) {
$encoded = chr($current & 0xff) . $encoded;
$current >>= 8;
}
return chr(0x80 | strlen($encoded)) . $encoded;
}
protected function encodeOidPart(int $value): string
{
if ($value === 0) {
return "\x00";
}
$encoded = '';
$current = $value;
while ($current > 0) {
$encoded = chr($current & 0x7f) . $encoded;
$current >>= 7;
}
$length = strlen($encoded);
for ($i = 0; $i < $length - 1; $i++) {
$encoded[$i] = chr(ord($encoded[$i]) | 0x80);
}
return $encoded;
}
}
+727 -179
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace Extend\RegulatoryPlatform;
use App\Constants\HttpEnumCode;
@@ -7,18 +9,31 @@ use App\Exception\BusinessException;
use App\Utils\Log;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Hyperf\Context\ApplicationContext;
use Hyperf\Di\Annotation\Inject;
use Hyperf\Redis\Redis;
use Hyperf\Context\ApplicationContext;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 四川省互联网医疗服务监管平台
*/
class regulatoryPlatform
{
protected const ACCESS_TOKEN_CACHE_KEY = 'regulatory_platform_access_token';
protected const REFRESH_TOKEN_CACHE_KEY = 'regulatory_platform_refresh_token';
protected const EXPIRES_AT_CACHE_KEY = 'regulatory_platform_access_token_expires_at';
protected const DEFAULT_ACCESS_TOKEN_TTL = 7200;
protected const DEFAULT_REFRESH_TOKEN_TTL = 2592000;
protected const ACCESS_TOKEN_BUFFER = 60;
protected const HTTP_TIMEOUT = 30;
protected const HTTP_CONNECT_TIMEOUT = 10;
#[Inject]
protected ContainerInterface $container;
@@ -28,237 +43,770 @@ class regulatoryPlatform
#[Inject]
protected Redis $redis;
protected string $api_url;
protected string $client_id;
protected string $client_secret;
protected array $config;
protected ?string $api_url;
protected ?string $client_id;
protected ?string $client_secret;
protected RegulatoryPlatformProtocol $protocol;
public function __construct()
{
// 请求地址
$this->api_url = \Hyperf\Config\config('regulatory_platform.api_url');
$this->client_id = \Hyperf\Config\config('regulatory_platform.client_id');
$this->client_secret = \Hyperf\Config\config('regulatory_platform.client_secret');
$this->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',
]);
}
/**
* 获取请求token
* @return string
*/
public function getAccessToken(): string
public function protocol(): RegulatoryPlatformProtocol
{
// 获取token
$option = [
"json" => array(
"clientId" => $this->client_id,
"appSecret" => $this->client_secret,
),
];
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 {
$response = $this->httpRequest($this->api_url . 'wjw/third/oauth/getAccessToken', $option);
if (isset($response['status'])) {
if ($response['status'] != 0) {
if (!empty($response['message'])) {
throw new BusinessException($response['message']);
}
}
}
$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);
if (empty($response['data'])) {
// 返回值为空
if (!empty($response['message'])) {
throw new BusinessException($response['message']);
}
throw new BusinessException(HttpEnumCode::getMessage(HttpEnumCode::SERVER_ERROR));
}
$decoded = $this->decodeV2ResponseEnvelope($response, $verifySignature);
$this->logBusinessResponse('requestV2Decoded', $url, [
'verifySignature' => $verifySignature,
'response' => $response,
'decodedResponse' => $decoded,
]);
$data = json_decode($response['data'], true);
if (empty($data['accessToken'])) {
// 返回值为空
throw new BusinessException(HttpEnumCode::getMessage(HttpEnumCode::SERVER_ERROR));
}
// 默认为6天
$expires_in = 60 * 60 * 24 * 6;
if (!empty($data['expiresIn'])) {
if ($data['expiresIn'] > 100){
$expires_in = $data['expiresIn'];
}
}
$this->redis->set("regulatory_platform_access_token", $data['accessToken'],$expires_in);
return $data['accessToken'];
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) !== [];
}
/**
* 上报 网络咨询(网络门诊)服务
* @param array $arg
* @return array
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* 上报网络咨询(网络门诊)服务
*/
public function uploadConsult(array $arg): array
{
try {
$this->redis = $this->container->get(Redis::class);
$access_token = $this->redis->get("regulatory_platform_access_token");
if (empty($access_token)) {
$access_token = $this->getAccessToken();
}
foreach ($arg as &$item){
$item['accessToken'] = $access_token;
$item['clientId'] = $this->client_id;
}
$option = [
"json" => $arg
];
$response = $this->httpRequest($this->api_url . '/wjw/upload/uploadConsult', $option);
if (isset($response['status'])) {
if ($response['status'] != 0) {
if (!empty($response['message'])) {
throw new BusinessException($response['message']);
}
}
}
return $response;
} catch (GuzzleException $e) {
throw new BusinessException($e->getMessage());
}
return $this->uploadData('api_upload_consult', $arg);
}
/**
* 上报 网络复诊服务
* @param array $arg
* @return array
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* 上报网络复诊服务
*/
public function uploadFurtherConsult(array $arg): array
{
try {
$this->redis = $this->container->get(Redis::class);
$access_token = $this->redis->get("regulatory_platform_access_token");
if (empty($access_token)) {
$access_token = $this->getAccessToken();
}
foreach ($arg as &$item){
$item['accessToken'] = $access_token;
$item['clientId'] = $this->client_id;
}
$option = [
"json" => $arg
];
$response = $this->httpRequest($this->api_url . '/wjw/upload/uploadFurtherConsult', $option);
if (isset($response['status'])) {
if ($response['status'] != 0) {
if (!empty($response['message'])) {
throw new BusinessException($response['message']);
}
}
}
return $response;
} catch (GuzzleException $e) {
throw new BusinessException($e->getMessage());
}
return $this->uploadData('api_upload_further_consult', $arg);
}
/**
* 上报 电子处方服务
* @param array $arg
* @return array
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* 上报电子处方服务
*/
public function uploadRecipe(array $arg): array
{
try {
$this->redis = $this->container->get(Redis::class);
return $this->uploadData('api_upload_recipe', $arg);
}
$access_token = $this->redis->get("regulatory_platform_access_token");
if (empty($access_token)) {
$access_token = $this->getAccessToken();
}
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* 上报药品处方明细
*/
public function uploadRecipeDetail(array $arg): array
{
return $this->uploadData('api_upload_recipe_detail_yp', $arg);
}
foreach ($arg as &$item){
$item['accessToken'] = $access_token;
$item['clientId'] = $this->client_id;
}
protected function requestAccessToken(): array
{
return $this->requestToken(
$this->buildAuthEndpoint('access'),
[
'grantType' => 'client_credentials',
]
);
}
$option = [
"json" => $arg
];
protected function requestToken(string $url, array $payload): array
{
$decoded = $this->requestV2Decoded($url, $payload, true, false);
$tokenPayload = $this->extractTokenPayload($decoded['decoded']);
$this->cacheTokenPayload($tokenPayload);
$response = $this->httpRequest($this->api_url . '/wjw/upload/uploadRecipe', $option);
if (isset($response['status'])) {
if ($response['status'] != 0) {
if (!empty($response['message'])) {
throw new BusinessException($response['message']);
}
}
}
return $tokenPayload;
}
return $response;
} catch (GuzzleException $e) {
throw new BusinessException($e->getMessage());
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);
}
/**
* 请求封装
* @param string $path
* @param array $arg
* @return array
* @throws GuzzleException
*/
protected function httpRequest(string $path, array $arg = []): array
{
$option = [
"verify" => false
];
if (!empty($option)) {
$arg = array_merge($arg, $option);
}
$body = $this->sendJsonRequest($path, $arg, 'regulatoryPlatform-httpRequest', false);
Log::getInstance("regulatoryPlatform-httpRequest")->info(json_encode($arg,JSON_UNESCAPED_UNICODE));
$response = $this->client->post($path, $arg);
if ($response->getStatusCode() != '200') {
// 请求失败
throw new BusinessException($response->getBody()->getContents());
}
$body = json_decode($response->getBody(), true);
Log::getInstance("regulatoryPlatform-httpRequest")->info(json_encode($body,JSON_UNESCAPED_UNICODE));
if (empty($body)) {
// 返回值为空
throw new BusinessException(HttpEnumCode::getMessage(HttpEnumCode::SERVER_ERROR));
}
// 特殊情况下会返回携带code的数据
if (isset($body['code'])) {
if (isset($body['message'])) {
throw new BusinessException($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;
}
}