From 012984f6363dead4eb801c85c07ee7e23e64923d Mon Sep 17 00:00:00 2001 From: haomingming Date: Tue, 12 May 2026 14:49:47 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9B=91=E7=AE=A1=E5=B9=B3=E5=8F=B0=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=8E=A5=E5=8F=A3=20V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...oPharmacistCaVerifyDelayDirectConsumer.php | 14 +- app/Command/ReportRegulatoryCommand.php | 325 ++++++- app/Model/OrderPrescriptionFile.php | 2 +- app/Model/OrderPrescriptionProduct.php | 2 +- app/Services/CaService.php | 31 +- app/Services/OrderPrescriptionService.php | 9 +- app/Services/UserDoctorService.php | 8 +- app/Utils/Log.php | 4 +- config/autoload/logger.php | 30 + config/config.php | 6 + extend/Ca/Ca.php | 32 +- .../RegulatoryPlatformProtocol.php | 595 ++++++++++++ .../RegulatoryPlatform/regulatoryPlatform.php | 906 ++++++++++++++---- ..._add_order_prescription_file_ca_fields.sql | 11 + ...2_add_order_prescription_product_price.sql | 2 + 15 files changed, 1773 insertions(+), 204 deletions(-) create mode 100644 extend/RegulatoryPlatform/RegulatoryPlatformProtocol.php create mode 100644 sql/20260512_add_order_prescription_file_ca_fields.sql create mode 100644 sql/20260512_add_order_prescription_product_price.sql diff --git a/app/Amqp/Consumer/AutoPharmacistCaVerifyDelayDirectConsumer.php b/app/Amqp/Consumer/AutoPharmacistCaVerifyDelayDirectConsumer.php index d263d5b..582740f 100644 --- a/app/Amqp/Consumer/AutoPharmacistCaVerifyDelayDirectConsumer.php +++ b/app/Amqp/Consumer/AutoPharmacistCaVerifyDelayDirectConsumer.php @@ -261,7 +261,12 @@ class AutoPharmacistCaVerifyDelayDirectConsumer extends ConsumerMessage } // 修改处方文件表,添加prescription_pdf_oss_path字段 - $this->modifyOrderPrescriptionFile($data['prescription_file_id'],$prescription_pdf_oss_path,$prescription_open_result['file_id']); + $this->modifyOrderPrescriptionFile( + $data['prescription_file_id'], + $prescription_pdf_oss_path, + $prescription_open_result['file_id'], + $prescription_open_result + ); // 修改处方表为通过 $this->modifyOrderPrescription($data['order_prescription_id'],1); @@ -438,7 +443,7 @@ class AutoPharmacistCaVerifyDelayDirectConsumer extends ConsumerMessage * @param string $hospital_ca_file_id 医院签章文件id * @return void */ - protected function modifyOrderPrescriptionFile(string $prescription_file_id,string $prescription_pdf_oss_path,string $hospital_ca_file_id): void + protected function modifyOrderPrescriptionFile(string $prescription_file_id,string $prescription_pdf_oss_path,string $hospital_ca_file_id,array $pharmacist_ca_data = []): void { try { $params = array(); @@ -447,6 +452,11 @@ class AutoPharmacistCaVerifyDelayDirectConsumer extends ConsumerMessage $data = array(); $data['hospital_ca_file_id'] = $hospital_ca_file_id; $data['prescription_pdf_oss_path'] = $prescription_pdf_oss_path; + $data['pharmacist_ca_supplier'] = $pharmacist_ca_data['ca_supplier'] ?? ""; + $data['pharmacist_ca_sign_type'] = $pharmacist_ca_data['ca_sign_type'] ?? ""; + $data['pharmacist_ca_sign_originaltext'] = $pharmacist_ca_data['ca_sign_originaltext'] ?? ""; + $data['pharmacist_ca_sign_text'] = $pharmacist_ca_data['ca_sign_text'] ?? ""; + $data['pharmacist_ca_sign_time'] = $pharmacist_ca_data['ca_sign_time'] ?? ""; OrderPrescriptionFile::edit($params,$data); }catch(\Exception $e){ diff --git a/app/Command/ReportRegulatoryCommand.php b/app/Command/ReportRegulatoryCommand.php index e501102..7ed9c4f 100644 --- a/app/Command/ReportRegulatoryCommand.php +++ b/app/Command/ReportRegulatoryCommand.php @@ -9,12 +9,12 @@ use App\Model\HospitalDepartmentCustom; use App\Model\OrderInquiry; use App\Model\OrderInquiryCase; use App\Model\OrderPrescription; +use App\Model\OrderPrescriptionFile; use App\Model\OrderPrescriptionIcd; use App\Model\OrderPrescriptionProduct; use App\Model\PatientFamily; use App\Model\Product; use App\Model\ReportRegulatory; -use App\Model\UserCaCert; use App\Model\UserDoctor; use App\Model\UserDoctorInfo; use App\Model\UserPharmacist; @@ -188,7 +188,7 @@ class ReportRegulatoryCommand extends HyperfCommand }else{ $this->line("C-2、检测处方(抄方)数据"); // 获取上报数据-处方 (抄方类型) - $report_prescription_data = $this->getReportTransferPrescriptionData($order_inquiry, $order_prescription); + $report_prescription_data = $this->getReportTransferPrescriptionDataV2($order_inquiry, $order_prescription); } $this->line("C-3、上报处方"); @@ -198,6 +198,17 @@ class ReportRegulatoryCommand extends HyperfCommand $this->line("C-4、上报处方成功" . json_encode($result,JSON_UNESCAPED_UNICODE)); + $report_prescription_detail_data = $this->getReportPrescriptionDetailData( + $order_prescription, + $report_prescription_data + ); + if (empty($report_prescription_detail_data)) { + throw new BusinessException("处方明细数据错误"); + } + + $result = $regulatoryPlatform->uploadRecipeDetail($report_prescription_detail_data); + $this->line("C-4-1、上报处方明细成功" . json_encode($result,JSON_UNESCAPED_UNICODE)); + // 上报成功 $res = $this->modifyReportRegulatoryPrescription($report_regulatory, 1); if (!$res) { @@ -474,7 +485,7 @@ class ReportRegulatoryCommand extends HyperfCommand $params['user_id'] = $user_doctor['user_id']; $params['type'] = 2; $params['is_latest'] = 1; - $doctor_user_ca_cert = UserCaCert::getOne($params); + $doctor_user_ca_cert = $this->buildLegacyCaCertPayload($order_prescription['order_prescription_id'], 'doctor'); if (empty($doctor_user_ca_cert)){ $this->line("错误:无医生ca数据"); return false; @@ -485,7 +496,7 @@ class ReportRegulatoryCommand extends HyperfCommand $params['user_id'] = $user_pharmacist['user_id']; $params['type'] = 2; $params['is_latest'] = 1; - $pharmacist_user_ca_cert = UserCaCert::getOne($params); + $pharmacist_user_ca_cert = $this->buildLegacyCaCertPayload($order_prescription['order_prescription_id'], 'pharmacist'); if (empty($pharmacist_user_ca_cert)){ $this->line("错误:无药师ca数据"); return false; @@ -533,6 +544,13 @@ class ReportRegulatoryCommand extends HyperfCommand $data['recipeNo'] = $order_prescription['order_prescription_id']; // 医院处方编号 $data['cityId'] = "510100"; // 城市ID(参考地区字段) + $pharmacist_ca_data = $this->getStoredPrescriptionCaData($order_prescription['order_prescription_id'], 'pharmacist'); + $data['caSupplier'] = $pharmacist_ca_data['caSupplier'] ?? ""; + $data['caSignType'] = $pharmacist_ca_data['caSignType'] ?? ""; + $data['caSignTime'] = $pharmacist_ca_data['caSignTime'] ?? ""; + $data['caSignOriginaltext'] = $pharmacist_ca_data['caSignOriginaltext'] ?? ""; + $data['caSignText'] = $pharmacist_ca_data['caSignText'] ?? ""; + return $data; } @@ -544,6 +562,7 @@ class ReportRegulatoryCommand extends HyperfCommand */ private function getReportTransferPrescriptionData(array|object $order_inquiry, array|object $order_prescription): bool|array { + return $this->getReportTransferPrescriptionDataV2($order_inquiry, $order_prescription); // 获取医生数据 $params = array(); $params['doctor_id'] = $order_prescription['doctor_id']; @@ -638,7 +657,7 @@ class ReportRegulatoryCommand extends HyperfCommand $params['user_id'] = $user_doctor['user_id']; $params['type'] = 2; $params['is_latest'] = 1; - $doctor_user_ca_cert = UserCaCert::getOne($params); + $doctor_user_ca_cert = $this->buildLegacyCaCertPayload($order_prescription['order_prescription_id'], 'doctor'); if (empty($doctor_user_ca_cert)){ $this->line("错误:无医生ca数据"); return false; @@ -649,7 +668,7 @@ class ReportRegulatoryCommand extends HyperfCommand $params['user_id'] = $user_pharmacist['user_id']; $params['type'] = 2; $params['is_latest'] = 1; - $pharmacist_user_ca_cert = UserCaCert::getOne($params); + $pharmacist_user_ca_cert = $this->buildLegacyCaCertPayload($order_prescription['order_prescription_id'], 'pharmacist'); if (empty($pharmacist_user_ca_cert)){ $this->line("错误:无药师ca数据"); return false; @@ -697,6 +716,160 @@ class ReportRegulatoryCommand extends HyperfCommand $data['recipeNo'] = $order_prescription['order_prescription_id']; // 医院处方编号 $data['cityId'] = "510100"; // 城市ID(参考地区字段) + $pharmacist_ca_data = $this->getStoredPrescriptionCaData($order_prescription['order_prescription_id'], 'pharmacist'); + $data['caSupplier'] = $pharmacist_ca_data['caSupplier'] ?? ""; + $data['caSignType'] = $pharmacist_ca_data['caSignType'] ?? ""; + $data['caSignTime'] = $pharmacist_ca_data['caSignTime'] ?? ""; + $data['caSignOriginaltext'] = $pharmacist_ca_data['caSignOriginaltext'] ?? ""; + $data['caSignText'] = $pharmacist_ca_data['caSignText'] ?? ""; + + return $data; + } + + private function getReportTransferPrescriptionDataV2(array|object $order_inquiry, array|object $order_prescription): bool|array + { + $params = array(); + $params['doctor_id'] = $order_prescription['doctor_id']; + $user_doctor = UserDoctor::getOne($params); + if (empty($user_doctor)) { + $this->line("閿欒锛氬尰鐢熸暟鎹敊璇?"); + return false; + } + + $params = array(); + $params['doctor_id'] = $order_prescription['doctor_id']; + $user_doctor_info = UserDoctorInfo::getOne($params); + if (empty($user_doctor_info)) { + $this->line("閿欒锛氬尰鐢熻鎯呮暟鎹敊璇?"); + return false; + } + + $params = array(); + $params['department_custom_id'] = $user_doctor['department_custom_id']; + $hospital_department_custom = HospitalDepartmentCustom::getOne($params); + if (empty($hospital_department_custom)) { + $this->line("閿欒锛氬尰鐢熻嚜瀹氫箟鏁版嵁閿欒"); + return false; + } + + $params = array(); + $params['family_id'] = $order_inquiry['family_id']; + $patient_family = PatientFamily::getOne($params); + if (empty($patient_family)) { + $this->line("閿欒锛氶棶璇婃偅鑰呮暟鎹敊璇?"); + return false; + } + + $params = array(); + $params['order_inquiry_id'] = $order_inquiry['order_inquiry_id']; + $params['status'] = 1; + $order_inquiry_case = OrderInquiryCase::getOne($params); + if (empty($order_inquiry_case)) { + $this->line("閿欒锛氭偅鑰呴棶璇婄梾渚嬮敊璇?"); + return false; + } + + $params = array(); + $params['pharmacist_id'] = $order_prescription['pharmacist_id']; + $user_pharmacist = UserPharmacist::getOne($params); + if (empty($user_pharmacist)) { + $this->line("閿欒锛氳嵂甯堟暟鎹敊璇?"); + return false; + } + + $params = array(); + $params['pharmacist_id'] = $order_prescription['pharmacist_id']; + $user_pharmacist_info = UserPharmacistInfo::getOne($params); + if (empty($user_pharmacist_info)) { + $this->line("閿欒锛氳嵂甯堣鎯呮暟鎹敊璇?"); + return false; + } + + $params = array(); + $params['order_prescription_id'] = $order_prescription['order_prescription_id']; + $order_prescription_icd = OrderPrescriptionIcd::getList($params); + if (empty($order_prescription_icd)) { + $this->line("閿欒锛氭棤澶嶈瘖鐤剧梾璇婃柇鏁版嵁"); + return false; + } + + $icd_name = ""; + $icd_name_data = array_column($order_prescription_icd->toArray(), 'icd_name'); + if (!empty($icd_name_data)) { + if (count($icd_name_data) > 1) { + $icd_name = implode('|', $icd_name_data); + } else { + $icd_name = $icd_name_data[0]; + } + } + + $order_prescription_product = $this->getPreProductData($order_prescription['order_prescription_id']); + if (empty($order_prescription_product)) { + $this->line("閿欒锛氭棤澶勬柟鍟嗗搧鏁版嵁"); + return false; + } + + $doctor_user_ca_cert = $this->buildLegacyCaCertPayload($order_prescription['order_prescription_id'], 'doctor'); + if (empty($doctor_user_ca_cert)) { + $this->line("閿欒锛氭棤鍖荤敓ca鏁版嵁"); + return false; + } + + $pharmacist_user_ca_cert = $this->buildLegacyCaCertPayload($order_prescription['order_prescription_id'], 'pharmacist'); + if (empty($pharmacist_user_ca_cert)) { + $this->line("閿欒锛氭棤鑽笀ca鏁版嵁"); + return false; + } + + $data = array(); + $data['thirdUniqueid'] = $order_inquiry['order_inquiry_id']; + $data['orgName'] = "鎴愰兘閲戠墰娆f鐩哥収浜掕仈缃戝尰闄?"; + $data['orgCode'] = "MA6CGUDA251010619D2112"; + $data['section'] = $hospital_department_custom['department_name']; + $data['sectionCode'] = $hospital_department_custom['department_code']; + $data['docName'] = $user_doctor['user_name']; + $data['docCertificateNum'] = $user_doctor_info['qualification_cert_num']; + $data['pharmacistName'] = $user_pharmacist_info['card_name']; + $data['pharmacistOrg'] = "鎴愰兘閲戠墰娆f鐩哥収浜掕仈缃戝尰闄?"; + $data['pharmacistCertificateNum'] = $user_pharmacist_info['qualification_cert_num']; + $data['furtherConsultNo'] = $order_inquiry['order_inquiry_id']; + $data['furtherConsultDiagnosis'] = $icd_name; + $data['patientName'] = $order_inquiry['patient_name']; + $data['patientSex'] = $order_inquiry['patient_sex'] == 0 ?: 1; + $data['patientAge'] = (int) $order_inquiry['patient_age']; + $data['patientIdcardType'] = 1; + $data['patientIdcardNum'] = $patient_family['id_number']; + $data['feeType'] = 1; + $data['medicalHistory'] = $order_inquiry_case['disease_desc']; + $data['recipeTime'] = $order_prescription['doctor_created_time']; + $data['recipeType'] = 2; + $data['reviewTime'] = $order_prescription['pharmacist_verify_time']; + $data['recipeUnitPrice'] = $order_prescription_product['amount_total']; + $data['drugName'] = $order_prescription_product['drug_name']; + $data['drugCode'] = $order_prescription_product['drug_code']; + $data['drugCommonName'] = $order_prescription_product['drug_common_name']; + $data['specification'] = $order_prescription_product['specification']; + $data['frequency'] = $order_prescription_product['frequency']; + $data['usage'] = $order_prescription_product['usage']; + $data['doseUnit'] = $order_prescription_product['dose_unit']; + $data['doseEachTime'] = $order_prescription_product['dose_each_time']; + $data['medicationDays'] = $order_prescription_product['medication_days']; + $data['quantity'] = $order_prescription_product['quantity']; + $data['drugPackage'] = $order_prescription_product['drug_package']; + $data['recipeAllPrice'] = $order_prescription_product['amount_total']; + $data['uploadTime'] = date("Y-m-d H:i:s", time()); + $data['docCaSign'] = $doctor_user_ca_cert['cert_base64']; + $data['pharmacistCaSign'] = $pharmacist_user_ca_cert['cert_base64']; + $data['recipeNo'] = $order_prescription['order_prescription_id']; + $data['cityId'] = "510100"; + + $pharmacist_ca_data = $this->getStoredPrescriptionCaData($order_prescription['order_prescription_id'], 'pharmacist'); + $data['caSupplier'] = $pharmacist_ca_data['caSupplier'] ?? ""; + $data['caSignType'] = $pharmacist_ca_data['caSignType'] ?? ""; + $data['caSignTime'] = $pharmacist_ca_data['caSignTime'] ?? ""; + $data['caSignOriginaltext'] = $pharmacist_ca_data['caSignOriginaltext'] ?? ""; + $data['caSignText'] = $pharmacist_ca_data['caSignText'] ?? ""; + return $data; } @@ -736,9 +909,9 @@ class ReportRegulatoryCommand extends HyperfCommand $specification = $item['product_spec']; // 规格 $frequency = $item['frequency_use']; // 使用频度 $usage = $item['single_use']; // 用法 - $dose_unit = $product['single_unit']; // 剂量单位 - $dose_each_time = $product['single_unit']; // 每次剂量 - $medication_days = (double)$product['available_days']; // 用药天数 + $dose_unit = $item['single_unit']; // 剂量单位 + $dose_each_time = $item['single_unit']; // 每次剂量 + $medication_days = (double)$item['available_days']; // 用药天数 $quantity = (double)$item['prescription_product_num']; // 数量 $drug_package = $item['packaging_unit']; // 药品包装 } else { @@ -748,14 +921,22 @@ class ReportRegulatoryCommand extends HyperfCommand $specification = $specification . "|" . $item['product_spec']; // 规格 $frequency = $frequency . "|" . $item['frequency_use']; // 使用频度 $usage = $usage . "|" . $item['single_use']; // 用法 - $dose_unit = $dose_unit . "|" . $product['single_unit']; // 剂量单位 - $dose_each_time = $dose_each_time . "|" . $product['single_unit']; // 每次剂量 - $medication_days = $medication_days . "|" . (double)$product['available_days']; // 用药天数 + $dose_unit = $dose_unit . "|" . $item['single_unit']; // 剂量单位 + $dose_each_time = $dose_each_time . "|" . $item['single_unit']; // 每次剂量 + $medication_days = $medication_days . "|" . (double)$item['available_days']; // 用药天数 $quantity = $quantity . "|" . (double)$item['prescription_product_num']; // 数量 $drug_package = $drug_package . "|" . $item['packaging_unit']; // 药品包装 } - $amount_total += $item['product_price'] * $item['prescription_product_num']; + $amount_total = bcadd( + (string) $amount_total, + bcmul( + (string) ($item['product_price'] ?? $product['product_price'] ?? 0), + (string) $item['prescription_product_num'], + 2 + ), + 2 + ); } $result = array(); @@ -775,11 +956,121 @@ class ReportRegulatoryCommand extends HyperfCommand return $result; } + private function getPreProductDetailData(string $order_prescription_id): array + { + $params = array(); + $params['order_prescription_id'] = $order_prescription_id; + $order_prescription_product = OrderPrescriptionProduct::getList($params); + if (empty($order_prescription_product)) { + $this->line("错误:无处方药品数据"); + return []; + } + + $result = []; + foreach ($order_prescription_product as $item) { + $params = array(); + $params['product_id'] = $item['product_id']; + $product = Product::getWithAmountOne($params); + if (empty($product)) { + $this->line("错误:无药品数据"); + return []; + } + + $amount_total = bcmul( + (string) ($item['product_price'] ?? $product['product_price'] ?? 0), + (string) $item['prescription_product_num'], + 2 + ); + + $result[] = [ + 'drug_name' => (string) ($item['product_name'] ?? ''), + 'drug_code' => (string) ($product['product_pharmacy_code'] ?? ''), + 'drug_common_name' => (string) ($product['common_name'] ?? ''), + 'specification' => (string) ($item['product_spec'] ?? ''), + 'frequency' => (string) ($item['frequency_use'] ?? ''), + 'usage' => (string) ($item['single_use'] ?? ''), + 'dose_unit' => (string) ($item['single_unit'] ?? $product['single_unit'] ?? ''), + 'dose_each_time' => (string) ($item['single_unit'] ?? $product['single_unit'] ?? ''), + 'medication_days' => (string) ($item['available_days'] ?? $product['available_days'] ?? ''), + 'quantity' => (string) $item['prescription_product_num'], + 'drug_package' => (string) ($item['packaging_unit'] ?? ''), + 'product_price' => (string) ($item['product_price'] ?? $product['product_price'] ?? 0), + 'amount_total' => $amount_total, + ]; + } + + return $result; + } + + private function getReportPrescriptionDetailData( + array|object $order_prescription, + array $report_prescription_data + ): array { + $product_details = $this->getPreProductDetailData((string) $order_prescription['order_prescription_id']); + if (empty($product_details)) { + return []; + } + + $result = []; + foreach ($product_details as $detail) { + $row = $report_prescription_data; + $row['recipeUnitPrice'] = $detail['product_price']; + $row['drugName'] = $detail['drug_name']; + $row['drugCode'] = $detail['drug_code']; + $row['drugCommonName'] = $detail['drug_common_name']; + $row['specification'] = $detail['specification']; + $row['frequency'] = $detail['frequency']; + $row['usage'] = $detail['usage']; + $row['doseUnit'] = $detail['dose_unit']; + $row['doseEachTime'] = $detail['dose_each_time']; + $row['medicationDays'] = $detail['medication_days']; + $row['quantity'] = $detail['quantity']; + $row['drugPackage'] = $detail['drug_package']; + $row['recipeAllPrice'] = $detail['amount_total']; + $row['uploadTime'] = date("Y-m-d H:i:s", time()); + + $result[] = $row; + } + + return $result; + } + /** * 获取上报数据-网络咨询 * @param array|object $order_inquiry * @return array */ + private function getStoredPrescriptionCaData(string|int $order_prescription_id, string $role): array + { + $params = array(); + $params['order_prescription_id'] = $order_prescription_id; + $order_prescription_file = OrderPrescriptionFile::getOne($params); + if (empty($order_prescription_file)) { + return []; + } + + $prefix = $role === 'doctor' ? 'doctor' : 'pharmacist'; + return [ + 'caSupplier' => (string) ($order_prescription_file[$prefix . '_ca_supplier'] ?? ''), + 'caSignType' => (string) ($order_prescription_file[$prefix . '_ca_sign_type'] ?? ''), + 'caSignTime' => (string) ($order_prescription_file[$prefix . '_ca_sign_time'] ?? ''), + 'caSignOriginaltext' => (string) ($order_prescription_file[$prefix . '_ca_sign_originaltext'] ?? ''), + 'caSignText' => (string) ($order_prescription_file[$prefix . '_ca_sign_text'] ?? ''), + ]; + } + + private function buildLegacyCaCertPayload(string|int $order_prescription_id, string $role): array + { + $ca_data = $this->getStoredPrescriptionCaData($order_prescription_id, $role); + if (empty($ca_data['caSignText'])) { + return []; + } + + return [ + 'cert_base64' => $ca_data['caSignText'], + ]; + } + private function getConsultData(array|object $order_inquiry): array { // 获取医生数据 @@ -836,6 +1127,7 @@ class ReportRegulatoryCommand extends HyperfCommand $data['section'] = $hospital_department_custom['department_name'];//科室名称 $data['sectionCode'] = $hospital_department_custom['department_code'];//科室编码 $data['docName'] = $user_doctor['user_name'];// 姓名(医师、护师、技师) + $data['doc_idcard'] = $user_doctor_info['card_num']; // 医生身份证号 $data['certificateNum'] = $user_doctor_info['qualification_cert_num']; // 执业资格证号 $data['patientName'] = $order_inquiry['patient_name']; // 患者姓名 $data['patientAge'] = (int)$order_inquiry['patient_age']; // 患者年龄 @@ -992,6 +1284,13 @@ class ReportRegulatoryCommand extends HyperfCommand $data['cityId'] = "510100"; // 城市ID(参考地区字段) $data['isMark'] = 1;//是否留痕 1:代表留痕;0:代表未留痕 + $doctor_ca_data = $this->getStoredPrescriptionCaData($order_prescription['order_prescription_id'], 'doctor'); + $data['caSupplier'] = $doctor_ca_data['caSupplier'] ?? ""; + $data['caSignType'] = $doctor_ca_data['caSignType'] ?? ""; + $data['caSignTime'] = $doctor_ca_data['caSignTime'] ?? ""; + $data['caSignOriginaltext'] = $doctor_ca_data['caSignOriginaltext'] ?? ""; + $data['caSignText'] = $doctor_ca_data['caSignText'] ?? ""; + return $data; } } diff --git a/app/Model/OrderPrescriptionFile.php b/app/Model/OrderPrescriptionFile.php index 37b5f4f..4ece00a 100644 --- a/app/Model/OrderPrescriptionFile.php +++ b/app/Model/OrderPrescriptionFile.php @@ -33,7 +33,7 @@ class OrderPrescriptionFile extends Model /** * The attributes that are mass assignable. */ - protected array $fillable = ['prescription_file_id', 'order_prescription_id', 'doctor_ca_file_id', 'hospital_ca_file_id', 'prescription_img_oss_path', 'prescription_pdf_oss_path', 'is_converted_pdf', 'created_at', 'updated_at']; + protected array $fillable = ['prescription_file_id', 'order_prescription_id', 'doctor_ca_file_id', 'doctor_ca_supplier', 'doctor_ca_sign_type', 'doctor_ca_sign_originaltext', 'doctor_ca_sign_text', 'doctor_ca_sign_time', 'pharmacist_ca_supplier', 'pharmacist_ca_sign_type', 'pharmacist_ca_sign_originaltext', 'pharmacist_ca_sign_text', 'pharmacist_ca_sign_time', 'hospital_ca_file_id', 'prescription_img_oss_path', 'prescription_pdf_oss_path', 'is_converted_pdf', 'created_at', 'updated_at']; protected string $primaryKey = "prescription_file_id"; diff --git a/app/Model/OrderPrescriptionProduct.php b/app/Model/OrderPrescriptionProduct.php index bacf4e1..17a51d4 100644 --- a/app/Model/OrderPrescriptionProduct.php +++ b/app/Model/OrderPrescriptionProduct.php @@ -40,7 +40,7 @@ class OrderPrescriptionProduct extends Model /** * The attributes that are mass assignable. */ - protected array $fillable = ['prescription_product_id', 'order_prescription_id', 'product_id', 'use_status', 'prescription_product_num', 'product_name', 'product_spec', 'license_number', 'manufacturer', 'single_unit', 'single_use', 'packaging_unit', 'frequency_use', 'available_days', 'created_at', 'updated_at']; + protected array $fillable = ['prescription_product_id', 'order_prescription_id', 'product_id', 'use_status', 'prescription_product_num', 'product_name', 'product_price', 'product_spec', 'license_number', 'manufacturer', 'single_unit', 'single_use', 'packaging_unit', 'frequency_use', 'available_days', 'created_at', 'updated_at']; protected string $primaryKey = "prescription_product_id"; diff --git a/app/Services/CaService.php b/app/Services/CaService.php index b4669e2..a8935d1 100644 --- a/app/Services/CaService.php +++ b/app/Services/CaService.php @@ -56,6 +56,8 @@ class CaService extends BaseService // 处方pdf oss地址 protected string $prescription_pdf_oss_path; + protected array $sign_result = []; + /** * 初始化类,此处会获取基础数据 * @param array|object $order_prescription 处方表数据 @@ -254,13 +256,33 @@ class CaService extends BaseService $data['product'][] = $product; } - dump($this->entity_id); $cert_sign_result = $CaOnline->getCertSign($this->entity_id, $this->entity_id, $data); // 验证云证书签名 验证无需处理,只要不返回错误即可 $CaOnline->verifyPkcs7($cert_sign_result['signP7'], $data); $this->cert_serial_number = $cert_sign_result['certSerialnumber']; + $sign_originaltext = hash_hmac("sha1", json_encode($data, JSON_UNESCAPED_UNICODE), config('ca.online.secret')); + $timestamp_sign = ""; + try { + $timestamp_result = $CaOnline->getTimestampSign($sign_originaltext); + if (is_array($timestamp_result)) { + $timestamp_sign = $timestamp_result['signedData'] ?? ""; + } elseif (is_string($timestamp_result)) { + $timestamp_sign = $timestamp_result; + } + } catch (\Throwable $throwable) { + $timestamp_sign = ""; + } + + $this->sign_result = [ + 'ca_supplier' => (string) config('regulatory_platform.ca_supplier', '2'), + 'ca_sign_type' => (string) config('regulatory_platform.ca_sign_type', '1'), + 'ca_sign_originaltext' => $sign_originaltext, + 'ca_sign_text' => $cert_sign_result['signP7'] ?? "", + 'ca_sign_time' => $timestamp_sign, + 'cert_serial_number' => $cert_sign_result['certSerialnumber'] ?? "", + ]; } /** @@ -268,6 +290,11 @@ class CaService extends BaseService * @param array|object $order_prescription * @return string */ + public function getSignResult(): array + { + return $this->sign_result; + } + public function createPrescriptionImgPdf(array|object $order_prescription): string { // 打开基础处方图片 @@ -542,4 +569,4 @@ class CaService extends BaseService return $sign_pdf_result[0]['fileId']; } -} \ No newline at end of file +} diff --git a/app/Services/OrderPrescriptionService.php b/app/Services/OrderPrescriptionService.php index c9ca983..36e0213 100644 --- a/app/Services/OrderPrescriptionService.php +++ b/app/Services/OrderPrescriptionService.php @@ -152,7 +152,6 @@ class OrderPrescriptionService extends BaseService throw new BusinessException("医生开方日期错误"); } - dump($user_id); $CaService = new CaService($order_prescription,$type,$user_id); // 获取云证书签名+验证云证书签名 @@ -167,9 +166,15 @@ class OrderPrescriptionService extends BaseService // 进行处方pdf签章 $file_id = $CaService->addSignPdf($type); + $sign_result = $CaService->getSignResult(); $result = array(); $result['prescription_img_oss_path'] = $prescription_img_oss_path ?? ""; $result['file_id'] = $file_id; + $result['ca_supplier'] = $sign_result['ca_supplier'] ?? ""; + $result['ca_sign_type'] = $sign_result['ca_sign_type'] ?? ""; + $result['ca_sign_originaltext'] = $sign_result['ca_sign_originaltext'] ?? ""; + $result['ca_sign_text'] = $sign_result['ca_sign_text'] ?? ""; + $result['ca_sign_time'] = $sign_result['ca_sign_time'] ?? ""; return $result; } catch (\Throwable $e) { @@ -570,4 +575,4 @@ class OrderPrescriptionService extends BaseService return $promotion; } -} \ No newline at end of file +} diff --git a/app/Services/UserDoctorService.php b/app/Services/UserDoctorService.php index 6061d6c..da8b96b 100644 --- a/app/Services/UserDoctorService.php +++ b/app/Services/UserDoctorService.php @@ -1730,6 +1730,7 @@ class UserDoctorService extends BaseService $data['product_id'] = $item['product_id']; $data['prescription_product_num'] = $item['prescription_product_num']; $data['product_name'] = $product['product_name']; + $data['product_price'] = $product['product_price']; $data['product_spec'] = $product['product_spec']; $data['license_number'] = $product['license_number']; $data['manufacturer'] = $product['manufacturer']; @@ -1799,6 +1800,11 @@ class UserDoctorService extends BaseService $data = array(); $data['order_prescription_id'] = $order_prescription->order_prescription_id; $data['doctor_ca_file_id'] = $prescription_open_result['file_id']; + $data['doctor_ca_supplier'] = $prescription_open_result['ca_supplier'] ?? ""; + $data['doctor_ca_sign_type'] = $prescription_open_result['ca_sign_type'] ?? ""; + $data['doctor_ca_sign_originaltext'] = $prescription_open_result['ca_sign_originaltext'] ?? ""; + $data['doctor_ca_sign_text'] = $prescription_open_result['ca_sign_text'] ?? ""; + $data['doctor_ca_sign_time'] = $prescription_open_result['ca_sign_time'] ?? ""; $data['prescription_img_oss_path'] = $prescription_open_result['prescription_img_oss_path']; $order_prescription_file = OrderPrescriptionFile::addOrderPrescriptionFile($data); if (empty($order_prescription_file)){ @@ -3159,4 +3165,4 @@ class UserDoctorService extends BaseService return $multi_point_enable; } -} \ No newline at end of file +} diff --git a/app/Utils/Log.php b/app/Utils/Log.php index e9a4033..78a2b49 100644 --- a/app/Utils/Log.php +++ b/app/Utils/Log.php @@ -25,8 +25,8 @@ class Log self::getInstance()->{$name}(...$arguments); } - public static function getInstance(string $name = 'app'): LoggerInterface + public static function getInstance(string $name = 'app', string $group = 'default'): LoggerInterface { - return ApplicationContext::getContainer()->get(LoggerFactory::class)->get($name); + return ApplicationContext::getContainer()->get(LoggerFactory::class)->get($name, $group); } } diff --git a/config/autoload/logger.php b/config/autoload/logger.php index c2f815c..ed793b9 100644 --- a/config/autoload/logger.php +++ b/config/autoload/logger.php @@ -51,4 +51,34 @@ return [ ], 'formatter' => $formatter, ], + 'regulatory_platform_http_detail' => [ + 'handler' => [ + 'class' => Monolog\Handler\RotatingFileHandler::class, + 'constructor' => [ + 'filename' => BASE_PATH . '/runtime/logs/regulatory-platform-http-detail.log', + 'level' => Monolog\Logger::INFO, + ], + ], + 'formatter' => $formatter, + ], + 'regulatory_platform_business' => [ + 'handler' => [ + 'class' => Monolog\Handler\RotatingFileHandler::class, + 'constructor' => [ + 'filename' => BASE_PATH . '/runtime/logs/regulatory-platform-business.log', + 'level' => Monolog\Logger::INFO, + ], + ], + 'formatter' => $formatter, + ], + 'regulatory_platform_token' => [ + 'handler' => [ + 'class' => Monolog\Handler\RotatingFileHandler::class, + 'constructor' => [ + 'filename' => BASE_PATH . '/runtime/logs/regulatory-platform-token.log', + 'level' => Monolog\Logger::INFO, + ], + ], + 'formatter' => $formatter, + ], ]; diff --git a/config/config.php b/config/config.php index 6497f4a..a64511c 100644 --- a/config/config.php +++ b/config/config.php @@ -121,6 +121,12 @@ return [ "client_id" => env('REG_PLAT_CLIENT_ID', '09b117f8d1eb4dbfbf565447205ea60f'), "client_secret" => env('REG_PLAT_CLIENT_SECRET', 'dcfd9223a3f448b0aae83ce22cdcc015'), "api_url" => env('REG_PLAT_APP_URL', 'https://202.61.88.184:19200/'), + "sm4_key" => env('REG_PLAT_SM4_KEY', 'e0e295da97ed46cc9ad71b61ed361721'), + "sm4_iv" => env('REG_PLAT_SM4_IV', '8a1f69a58603416a9d5b93c4bb67f72f'), + "sm2_private_key" => env('REG_PLAT_SM2_PRIVATE_KEY', 'bbc97b0418cc92b8f3b7b7764e554beed6b3adb1aa9bfb9e7839583e5ccf4722'), + "sm2_public_key" => env('REG_PLAT_SM2_PUBLIC_KEY', '04fca188451748a35b8c14890a0270fbe787e74f2286b72f6305939c8d527d1fd9fec2926cc0ff6c4f04cfbee2a3d65230565691a8f8453b44665d2b6fbbc79c91'), + "ca_supplier" => env('REG_PLAT_CA_SUPPLIER', '2'), + "ca_sign_type" => env('REG_PLAT_CA_SIGN_TYPE', '1'), ], 'kuaidi100' => [ // 快递100 "key" => env('LOGISTICS_KEY', 'Mpjjgebe8764'), diff --git a/extend/Ca/Ca.php b/extend/Ca/Ca.php index d86e86b..c913869 100644 --- a/extend/Ca/Ca.php +++ b/extend/Ca/Ca.php @@ -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); } -} \ No newline at end of file +} diff --git a/extend/RegulatoryPlatform/RegulatoryPlatformProtocol.php b/extend/RegulatoryPlatform/RegulatoryPlatformProtocol.php new file mode 100644 index 0000000..b8e2287 --- /dev/null +++ b/extend/RegulatoryPlatform/RegulatoryPlatformProtocol.php @@ -0,0 +1,595 @@ +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; + } +} diff --git a/extend/RegulatoryPlatform/regulatoryPlatform.php b/extend/RegulatoryPlatform/regulatoryPlatform.php index 952bfa9..80a37b8 100644 --- a/extend/RegulatoryPlatform/regulatoryPlatform.php +++ b/extend/RegulatoryPlatform/regulatoryPlatform.php @@ -1,5 +1,7 @@ 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; } -} \ No newline at end of file + + 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; + } +} diff --git a/sql/20260512_add_order_prescription_file_ca_fields.sql b/sql/20260512_add_order_prescription_file_ca_fields.sql new file mode 100644 index 0000000..d88af98 --- /dev/null +++ b/sql/20260512_add_order_prescription_file_ca_fields.sql @@ -0,0 +1,11 @@ +ALTER TABLE `order_prescription_file` +ADD COLUMN `doctor_ca_supplier` varchar(16) NOT NULL DEFAULT '' COMMENT '医生CA供应商' AFTER `doctor_ca_file_id`, +ADD COLUMN `doctor_ca_sign_type` varchar(16) NOT NULL DEFAULT '' COMMENT '医生CA签名类型' AFTER `doctor_ca_supplier`, +ADD COLUMN `doctor_ca_sign_originaltext` text COMMENT '医生CA签名原文' AFTER `doctor_ca_sign_type`, +ADD COLUMN `doctor_ca_sign_text` longtext COMMENT '医生CA签名值' AFTER `doctor_ca_sign_originaltext`, +ADD COLUMN `doctor_ca_sign_time` longtext COMMENT '医生CA时间戳签名值' AFTER `doctor_ca_sign_text`, +ADD COLUMN `pharmacist_ca_supplier` varchar(16) NOT NULL DEFAULT '' COMMENT '药师CA供应商' AFTER `doctor_ca_sign_time`, +ADD COLUMN `pharmacist_ca_sign_type` varchar(16) NOT NULL DEFAULT '' COMMENT '药师CA签名类型' AFTER `pharmacist_ca_supplier`, +ADD COLUMN `pharmacist_ca_sign_originaltext` text COMMENT '药师CA签名原文' AFTER `pharmacist_ca_sign_type`, +ADD COLUMN `pharmacist_ca_sign_text` longtext COMMENT '药师CA签名值' AFTER `pharmacist_ca_sign_originaltext`, +ADD COLUMN `pharmacist_ca_sign_time` longtext COMMENT '药师CA时间戳签名值' AFTER `pharmacist_ca_sign_text`; diff --git a/sql/20260512_add_order_prescription_product_price.sql b/sql/20260512_add_order_prescription_product_price.sql new file mode 100644 index 0000000..40e1d7a --- /dev/null +++ b/sql/20260512_add_order_prescription_product_price.sql @@ -0,0 +1,2 @@ +ALTER TABLE `order_prescription_product` +ADD COLUMN `product_price` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '开方时商品单价' AFTER `product_name`;