createAndSendQuota([...]) // | 不带企业的接口(7.22 企业列表)用 new QytOil() 即可。 // | // | 已实现: // | 7.2 企业申请创建 openAnAccount // | 7.5 企业账户查询 accountQuery // | 7.7 运单油费下发 createAndSendQuota // | 7.15 H5免登地址 h5Url(本地签名,不发请求) // | 7.22 企业列表查询 enterpriseList // | 7.10 消费回调由 app\api\controller\QytNotify 接收 // | // | 通道:alipay=支付宝企业码(passageway=1) / pingan=平安银行(passageway=2), // | 通道开关存在企业信息里。两个通道都要求实名,7.7 必传 id_card。 // | 金额:对外「元」入参,7.7 内部换算为「分」。 // | // | ⚠️ 7.7 是异步接口,返回成功只代表【受理】,真实发放结果需用 7.8 // | waybillBatch 按 batch_no 查询(见文档 §5 第 7 条)。当前状态机仍按 // | 「受理即完成」处理,7.8 轮询尚未接入。 // +---------------------------------------------------------------------- declare (strict_types=1); namespace app\common\service; use app\common\crypto\Qyt as QytCrypto; use app\common\model\qyt\Enterprise as EnterpriseModel; class QytOil extends BaseService { // 通道标识 const CHANNEL_ALIPAY = 'alipay'; const CHANNEL_PINGAN = 'pingan'; /** @var array 合并后的配置(接入方级 + 企业级) */ protected $conf; /** @var QytCrypto */ protected $crypto; /** @var EnterpriseModel|array|null 当前企业 */ protected $enterprise = null; /** * @var bool 最近一次请求是否属于「网络层异常」(超时、连不上、响应不是 JSON)。 * 这类失败不能说明上游没处理——对 7.16 这种没有幂等键的写接口,必须和 * 「上游明确返回失败」区分开,前者转人工确认,后者才可以放心重试。 */ protected $transportError = false; protected function initialize() { $this->conf = QytConfig::get(); $this->crypto = new QytCrypto($this->conf); } // ================================================================ // 企业上下文 // ================================================================ /** * 绑定企业后返回实例(业务接口的常规入口) * @param EnterpriseModel|array $enterprise */ public static function forEnterprise($enterprise): self { return (new static())->useEnterprise($enterprise); } /** * 切换当前企业:执照号 / 通道 / 机构号 / 编号前缀等随之变化 * @param EnterpriseModel|array $enterprise */ public function useEnterprise($enterprise): self { $this->enterprise = $enterprise; $this->conf = QytConfig::forEnterprise($enterprise); $this->crypto = new QytCrypto($this->conf); return $this; } public function enterpriseId(): int { return (int) ($this->conf['enterprise_id'] ?? 0); } public function enterpriseName(): string { return (string) ($this->conf['enterprise_name'] ?? ''); } public function businessLicense(): string { return (string) ($this->conf['business_license'] ?? ''); } /** 当前通道标识 alipay / pingan */ public function channelKey(): string { return (string) ($this->conf['passageway'] ?? self::CHANNEL_ALIPAY); } /** 当前通道中文名 */ public function channelName(): string { return (string) ($this->conf['passageway_name'] ?? ''); } /** 当前通道的企油通 passageway 编码:1 支付宝企业码 / 2 平安银行 */ public function channelCode(): int { return (int) ($this->conf['passageway_code'] ?? 1); } /** * 生成企油通全平台唯一编号(加企业前缀),确定性派生 -> 重试保持一致 * batch_no / waybill_no / waybill_no_code 都是【全平台唯一】而非接入方内唯一, * 所以各企业建议用不同前缀,避免与其他接入方或兄弟企业撞号。 * @param string $type B=batch / W=waybill / C=waybill_no_code * @param string $seed 订单号 / order_id 等业务种子 */ public function no(string $type, string $seed): string { return ((string) ($this->conf['no_prefix'] ?: 'HM')) . $type . $seed; } // ================================================================ // 7.7 运单油费下发(一张订单 = 1 batch / 1 waybill / 1 employee) // ================================================================ /** * @param array $data ['batch_no','waybill_no','waybill_no_code','mobile','name','amount'(元),'id_card'] * @return array 归一化 ['code'=>'00'|其它, 'data'=>[], 'message'=>'', 'dup_batch'=>bool] */ public function createAndSendQuota(array $data): array { if (($err = $this->requireEnterprise()) !== null) { return $err; } // 支付宝企业码通道下 institution_id 必填;缺了就地失败,不把空值发给企油通 if ($this->channelCode() === 1 && $this->conf['institution_id'] === '') { return $this->fail("企业[{$this->enterpriseName()}]未配置机构号(institution_id),支付宝企业码通道无法下发"); } // 两个通道都要求实名,id_card 无条件必传 $idCard = trim((string) ($data['id_card'] ?? '')); if ($idCard === '') { return $this->fail('缺少司机身份证号(idCard),两个支付通道都要求实名'); } $employee = [ 'mobile' => $data['mobile'], 'id_card' => $idCard, 'name' => $data['name'], 'quota_amount' => self::yuanToFen($data['amount']), // 分 'voucher_name' => $this->conf['voucher_name'], 'effective_start' => '', // 空=当日生效 'effective_end' => '', // 空=长期有效 'waybill_no_code' => $data['waybill_no_code'], ]; $params = [ 'batch_no' => $data['batch_no'], 'business_license' => $this->businessLicense(), 'passageway' => (string) $this->channelCode(), 'institution_id' => (string) $this->conf['institution_id'], 'waybill_list' => [[ 'waybill_no' => $data['waybill_no'], 'employee_list' => [$employee], ]], ]; return $this->post('/enterprise/api/zhyk/createAndSendQuota', $params); } // ================================================================ // 7.8 运单发放结果查询(7.7 是异步的,真实结果只能从这里拿) // ================================================================ /** * @param string $batchNo 7.7 提交时用的批次号 * @return array data: batch_no / status(1待处理 2处理中 3全部成功 4部分成功 5处理失败) / list[] * list[] 里每条含 waybill_no_code / handle_status(1待处理 2成功 3失败) / error_msg */ public function waybillBatch(string $batchNo): array { $params = ['batch_no' => $batchNo]; // business_license 可选;传了会校验归属,能更早发现串企业的问题 $this->businessLicense() !== '' && $params['business_license'] = $this->businessLicense(); return $this->post('/enterprise/api/zhyk/waybillBatch', $params); } /** * 从 7.8 的返回里挑出某条运单流水的处理结果 * @return array|null ['handle_status'=>int, 'error_msg'=>string, 'remark'=>string] */ public static function pickWaybill(array $data, string $waybillNoCode): ?array { foreach ((array) ($data['list'] ?? []) as $row) { if ((string) ($row['waybill_no_code'] ?? '') === $waybillNoCode) { return [ 'handle_status' => (int) ($row['handle_status'] ?? 0), 'error_msg' => (string) ($row['error_msg'] ?? ''), 'remark' => (string) ($row['remark'] ?? ''), ]; } } return null; } // ================================================================ // 7.17 获取支付凭证(须真实 RSA 验签) // ================================================================ /** * 查询某司机在当前企业下的支付凭证,退款(7.16)要用其中的 quota_id * @return array data.list[]: quota_id / total_amount / available_amount / used_amount / status ...(金额单位:元) */ public function getVoucherList(string $mobile, string $idCard, int $page = 1, int $pageSize = 50): array { if (($err = $this->requireEnterprise()) !== null) { return $err; } return $this->post('/enterprise/api/quota/getVoucherList', [ 'passageway' => (string) $this->channelCode(), 'business_license' => $this->businessLicense(), 'mobile' => $mobile, 'id_card' => $idCard, 'page' => $page, 'page_size' => $pageSize, ]); } // ================================================================ // 7.16 司机额度调整(须真实 RSA 验签;⚠️ 无幂等键,重复调用会重复调额) // ================================================================ /** * @param int|string $quotaId 7.17 返回的凭证主键 * @param string $changeType 02 增加额度 / 03 减少额度 * @param float $amount 调整金额(元) * @param string $reason 操作原因(≤200 字);退款时写入退款单号,确认结果时靠它在 7.13 流水里定位 * @param string $waybillNo 运单号(可选) */ public function adjustVoucher($quotaId, string $changeType, float $amount, string $reason = '', string $waybillNo = ''): array { if (($err = $this->requireEnterprise()) !== null) { return $err; } $params = [ 'passageway' => (string) $this->channelCode(), 'business_license' => $this->businessLicense(), 'quota_id' => (int) $quotaId, 'change_type' => $changeType, 'amount' => round($amount, 2), 'operation_reason' => mb_substr($reason, 0, 200), ]; $waybillNo !== '' && $params['waybill_no'] = $waybillNo; return $this->post('/enterprise/api/quota/adjustVoucher', $params); } // ================================================================ // 7.11 企业账户余额(仅平安银行通道支持,金额:分) // ================================================================ public function distributable(): array { if (($err = $this->requireEnterprise()) !== null) { return $err; } if ($this->channelCode() !== 2) { return $this->fail('企业账户余额查询仅支持平安银行通道,支付宝企业码没有独立资金账户'); } return $this->post('/enterprise/api/zhyk/distributable', [ 'business_license' => $this->businessLicense(), 'passageway' => (string) $this->channelCode(), ]); } // ================================================================ // 7.12 司机钱包金额 / 7.13 司机钱包明细(金额:分) // ================================================================ public function driverBalanceQuery(string $mobile, string $idCard, int $page = 1, int $pageSize = 20): array { if (($err = $this->requireEnterprise()) !== null) { return $err; } return $this->post('/enterprise/api/zhyk/driverBalanceQuery', [ 'business_license' => $this->businessLicense(), 'passageway' => (string) $this->channelCode(), 'mobile' => $mobile, 'id_card' => $idCard, 'page' => $page, 'page_size' => $pageSize, ]); } public function driverWalletQuery(string $mobile, string $idCard, int $page = 1, int $pageSize = 20): array { if (($err = $this->requireEnterprise()) !== null) { return $err; } return $this->post('/enterprise/api/zhyk/driverWalletQuery', [ 'business_license' => $this->businessLicense(), 'passageway' => (string) $this->channelCode(), 'mobile' => $mobile, 'id_card' => $idCard, 'page' => $page, 'page_size' => $pageSize, ]); } // ================================================================ // 7.14 企业发票下载(粒度:企业 + 通道 + 月份,可选按司机手机号收窄) // ================================================================ /** * @param string $month YYYY-MM * @param string $mobile 司机手机号;传了只返回该司机相关发票与加油明细 * @return array data: business_license / invoice[] 发票地址集 / order_list[] 加油明细(分) */ public function invoice(string $month, string $mobile = ''): array { if (($err = $this->requireEnterprise()) !== null) { return $err; } $params = [ 'business_license' => $this->businessLicense(), 'passageway' => (string) $this->channelCode(), 'month' => $month, ]; $mobile !== '' && $params['mobile'] = $mobile; return $this->post('/enterprise/api/zhyk/invoice', $params); } // ================================================================ // 7.2 企业申请创建 // ================================================================ /** * @param array $data ['business_license','business_license_url','passageway'(如 "1,2"),'company_name','contact_mobile','contact_name'] */ public function openAnAccount(array $data): array { return $this->post('/enterprise/api/zhyk/openAnAccount', [ 'business_license' => $data['business_license'], 'business_license_url' => $data['business_license_url'], 'passageway' => (string) $data['passageway'], 'company_name' => $data['company_name'], 'contact_mobile' => $data['contact_mobile'], 'contact_name' => $data['contact_name'], ]); } // ================================================================ // 7.5 企业账户信息查询 // ================================================================ public function accountQuery(): array { if (($err = $this->requireEnterprise()) !== null) { return $err; } return $this->post('/enterprise/api/zhyk/accountQuery', [ 'business_license' => $this->businessLicense(), 'passageway' => (string) $this->channelCode(), ]); } // ================================================================ // 7.22 企业列表查询(枚举本 appId 名下企业,需先开通权限,未开通返回 403) // ================================================================ public function enterpriseList(array $params = []): array { return $this->post('/enterprise/api/enterprise/list', [ 'page' => $params['page'] ?? 1, 'page_size' => $params['page_size'] ?? 100, ]); } // ================================================================ // 7.15 H5 免登加油地址(本地签名生成 URL,不发起接口请求) // 文档:本地址与支付通道无关,落地页由系统按企业实际通道自动判定 // ================================================================ public function h5Url(string $mobile): string { // creditcode 传了则本次登录固定到该企业,且必须一并入签 $creditcode = !empty($this->conf['h5_with_creditcode']) ? $this->businessLicense() : ''; return $this->crypto->h5Url($mobile, $creditcode); } // ================================================================ // 内部 // ================================================================ /** 未绑定企业 / 企业没有执照号时的统一拦截 */ protected function requireEnterprise(): ?array { if ($this->businessLicense() === '') { return $this->fail('未指定企业或企业缺少统一社会信用代码(business_license)'); } return null; } /** 构造一个失败返回,并记日志 */ protected function fail(string $message): array { $this->saveLog('[配置错误] ' . $message); return ['code' => 'ERR', 'data' => [], 'message' => $message, 'dup_batch' => false, 'raw' => []]; } /** * @return array ['code' => '00'成功 | 其它, 'data' => array, 'message' => string, 'dup_batch' => bool, 'raw' => array] */ protected function post(string $path, array $bizParams): array { $url = rtrim((string) $this->conf['base_url'], '/') . $path; $body = $this->crypto->envelope($bizParams); // 日志脱敏:手机号 / 身份证号不落明文 $this->saveLog("[请求] {$path} " . json_encode( ['企业' => $this->enterpriseName(), 'url' => $url, 'params' => self::maskDeep($bizParams)], JSON_UNESCAPED_UNICODE)); $this->transportError = false; $resp = $this->curlPost($url, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $this->saveLog("[响应] {$path} " . self::maskText($resp)); $json = json_decode($resp, true); if (!is_array($json)) { // 拿不到可解析的响应,无法判断上游是否已处理 return ['code' => 'ERR', 'data' => [], 'message' => '响应解析失败', 'dup_batch' => false, 'transport_error' => true, 'raw' => ['body' => mb_substr((string) $resp, 0, 500)]]; } // 通用响应结构:{ status: ok|error, data, message, serialNo, sign } $ok = ($json['status'] ?? '') === 'ok'; $message = (string) ($json['message'] ?? ''); // 幂等:batch_no 已存在 —— 视为「已受理」,供 7.7 重试判定 $dupBatch = false; if (!$ok && $message !== '') { foreach ((array) ($this->conf['dup_batch_hints'] ?: ['已存在', '重复', 'duplicate', 'exist']) as $hint) { if (mb_stripos($message, $hint) !== false) { $dupBatch = true; break; } } } return [ 'code' => $ok ? '00' : 'ERR', 'data' => $json['data'] ?? [], 'message' => $message, 'dup_batch' => $dupBatch, // cURL 失败时 curlPost 会伪造一个 {"status":"error"} 的 JSON,靠标记区分 'transport_error' => $this->transportError, 'raw' => $json, ]; } protected function curlPost(string $url, string $jsonBody): string { $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $jsonBody, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => (int) ($this->conf['timeout'] ?? 15), CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Charset: UTF-8'], ]); $resp = curl_exec($ch); if ($resp === false) { $err = curl_error($ch); curl_close($ch); $this->transportError = true; return json_encode(['status' => 'error', 'message' => 'cURL: ' . $err], JSON_UNESCAPED_UNICODE); } $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); // 网关层 5xx:请求可能已到达上游业务层,也按结果未知处理 if ($httpCode >= 500) { $this->transportError = true; } return (string) $resp; } // ---------------- 敏感信息脱敏(日志 / 后台展示复用) ---------------- /** 手机号:138****8000 */ public static function maskMobile(string $s): string { return strlen($s) === 11 ? substr($s, 0, 3) . '****' . substr($s, -4) : $s; } /** 身份证号:4501**********1234 */ public static function maskIdCard(string $s): string { $len = strlen($s); return $len >= 8 ? substr($s, 0, 4) . str_repeat('*', $len - 8) . substr($s, -4) : $s; } /** 递归脱敏数组(按键名识别) */ public static function maskDeep($data) { if (!is_array($data)) { return $data; } foreach ($data as $k => $v) { if (is_array($v)) { $data[$k] = self::maskDeep($v); } elseif ($k === 'mobile') { $data[$k] = self::maskMobile((string) $v); } elseif ($k === 'id_card' || $k === 'idCard') { $data[$k] = self::maskIdCard((string) $v); } } return $data; } /** 脱敏 JSON 文本里的手机号 / 身份证号(用于原始响应报文落日志) */ public static function maskText(string $s): string { $s = preg_replace_callback('/"(id_card|idCard)"\s*:\s*"([^"]+)"/i', function ($m) { return '"' . $m[1] . '":"' . self::maskIdCard($m[2]) . '"'; }, $s); return (string) preg_replace_callback('/"mobile"\s*:\s*"(\d{11})"/', function ($m) { return '"mobile":"' . self::maskMobile($m[1]) . '"'; }, $s); } // ---------------- 工具 ---------------- /** 元 → 分 */ public static function yuanToFen($yuan): int { return (int) bcmul((string) $yuan, '100', 0); } /** 分 → 元(字符串,保留 2 位) */ public static function fenToYuan($fen): string { return bcdiv((string) (int) $fen, '100', 2); } /** 记录日志到 runtime/storelog */ public function saveLog($data): void { if (empty($this->conf['log'])) { return; } $dir = runtime_path('storelog/') . date('Ym') . '/' . date('Ymd'); is_dir($dir) || @mkdir($dir, 0777, true); @file_put_contents( $dir . '/qyt_' . date('YmdH') . '.txt', date('Y-m-d H:i:s ') . (is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_UNICODE)) . "\r\n", FILE_APPEND ); } }