Files
oil/server-tp6/app/common/service/QytOil.php
T
2026-09-10 20:56:46 +08:00

432 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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
);
}
}