432 lines
17 KiB
PHP
432 lines
17 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | 企油通 · 上游接口客户端
|
||
// +----------------------------------------------------------------------
|
||
// | 多企业:一个接入方 appId 下可挂多个企业,业务接口靠 business_license 区分。
|
||
// | 用法:QytOil::forEnterprise($enterprise)->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;
|
||
|
||
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);
|
||
}
|
||
|
||
/** 当前通道是否必须上送司机身份证号(平安银行=是) */
|
||
public function requireIdCard(): bool
|
||
{
|
||
return !empty($this->conf['require_id_card']);
|
||
}
|
||
|
||
/**
|
||
* 生成企油通全平台唯一编号(加企业前缀),确定性派生 -> 重试保持一致
|
||
* 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),支付宝企业码通道无法下发");
|
||
}
|
||
|
||
$idCard = trim((string) ($data['id_card'] ?? ''));
|
||
if ($this->requireIdCard() && $idCard === '') {
|
||
return $this->fail("通道[{$this->channelName()}]要求实名,缺少司机身份证号(idCard)");
|
||
}
|
||
|
||
$employee = [
|
||
'mobile' => $data['mobile'],
|
||
'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'],
|
||
];
|
||
// 身份证号仅在要求实名的通道上送,避免向不需要的通道多传敏感信息
|
||
if ($this->requireIdCard()) {
|
||
$employee['id_card'] = $idCard;
|
||
}
|
||
|
||
$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.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));
|
||
|
||
$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, 'raw' => ['body' => $resp]];
|
||
}
|
||
|
||
// 通用响应结构:{ 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,
|
||
'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);
|
||
return json_encode(['status' => 'error', 'message' => 'cURL: ' . $err], JSON_UNESCAPED_UNICODE);
|
||
}
|
||
curl_close($ch);
|
||
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
|
||
);
|
||
}
|
||
}
|