第一次更新,完成了初版调整

This commit is contained in:
2026-09-07 22:05:51 +08:00
parent b5a26a8848
commit 4d6a7e7aac
198 changed files with 6142 additions and 3856 deletions
+182
View File
@@ -0,0 +1,182 @@
<?php
// +----------------------------------------------------------------------
// | 企油通 · 签名 / 验签 / 请求信封
// +----------------------------------------------------------------------
// | 第二阶段 A-1 骨架。标 【TODO 待对接人确认】 处需按企油通实际规则微调。
// |
// | 三种签名场景(见《企油通接口对接文档.md》§2.1 / §2.4):
// | 1. 业务信封:外层 { appId, timestamp, sign, params(JSON字符串) }
// | 2. 7.15 H5 免登地址:ksort → http_build_query → urldecode → 大写MD5 → SHA1withRSA → base64
// | 3. 7.16 / 7.17:必须真实 RSA 验签调用(本质同 1,但企油通侧强校验)
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\common\crypto;
class Qyt
{
/** @var string 我方私钥资源 */
protected $privateKey;
/** @var string|false 企油通平台公钥资源 */
protected $platformPublicKey;
/** @var array config('qyt') */
protected $conf;
public function __construct(array $conf = null)
{
$this->conf = $conf ?: (array) config('qyt');
$this->privateKey = $this->loadPrivateKey($this->conf['private_key'] ?? '');
$this->platformPublicKey = $this->loadPublicKey($this->conf['platform_public_key'] ?? '');
}
// ================================================================
// 1. 业务信封
// ================================================================
/**
* 构建业务请求信封
* @param array $bizParams 业务参数(会被 json_encode 成字符串放进 params)
* @return array{appId:string,timestamp:int,sign:string,params:string}
*/
public function envelope(array $bizParams): array
{
$appId = (string) ($this->conf['app_id'] ?? '');
$timestamp = time();
// ⚠️ params 必须是 JSON 字符串(转义),不是对象
$paramsJson = json_encode($bizParams, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$sign = $this->bizSign([
'appId' => $appId,
'timestamp' => $timestamp,
'params' => $paramsJson,
]);
return [
'appId' => $appId,
'timestamp' => $timestamp,
'sign' => $sign,
'params' => $paramsJson,
];
}
/**
* 业务信封签名
* 【TODO 待对接人确认】业务接口的具体拼签字段与顺序,文档 §2.4 只给了 7.15 的算法,
* 业务接口写的是「密钥、公私钥与具体拼签字段请向对接人员索取」。
* 当前默认实现:对参与字段 ksort 后 http_build_query → urldecode → 大写MD5 → SHA1withRSA → base64
* (与 7.15 同族算法,落地时按实际规则替换本方法即可,调用方无需改动)
*
* @param array $fields 参与签名的字段(appId / timestamp / params)
*/
public function bizSign(array $fields): string
{
$mode = $this->conf['sign_mode'] ?? 'ksort_md5_sha1rsa';
switch ($mode) {
// 预留:'raw_concat_sha256rsa' 等其它模式
case 'ksort_md5_sha1rsa':
default:
return $this->md5Sha1RsaSign($fields);
}
}
// ================================================================
// 2. 7.15 H5 免登地址
// ================================================================
/**
* 生成司机免登加油 H5 跳转地址(7.15)
* @param string $mobile 司机手机号
* @param string $creditcode 企业营业执照号;传空则不带(不参与签名)
* @return string 完整可跳转 URL
*/
public function h5Url(string $mobile, string $creditcode = ''): string
{
$base = rtrim((string) ($this->conf['h5_base_url'] ?? ''), '/');
$appId = (string) ($this->conf['h5_app_id'] ?: $this->conf['app_id']);
$timestamp = date('YmdHis');
$params = [
'appId' => $appId,
'mobile' => $mobile,
'timestamp' => $timestamp,
];
if ($creditcode !== '') {
$params['creditcode'] = $creditcode; // 传了必须一并入签
}
$params['sign'] = $this->md5Sha1RsaSign($params);
// 文档示例形如 https://paytaskpage.worknoya.vip/#/?appId=..&mobile=..&timestamp=..&sign=..#/
return $base . '/?' . http_build_query($params) . '#/';
}
// ================================================================
// 3. 验签(企油通响应 / 回调)
// ================================================================
/**
* 验签企油通返回 / 回调报文
* @param array $data 业务数据(通用响应里的 data,或回调 params)
* @param string $sign 报文里的 sign
* 【TODO 待对接人确认】验签串的构造(对 data 的 json 形态 / 是否含外层字段)
*/
public function verify($data, string $sign): bool
{
if ($sign === '' || $this->platformPublicKey === false) {
return false;
}
$payload = is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$ok = openssl_verify($payload, base64_decode($sign), $this->platformPublicKey, OPENSSL_ALGO_SHA256);
return $ok === 1;
}
// ================================================================
// 内部:算法与密钥
// ================================================================
/**
* 同族签名算法:ksort → http_build_query → urldecode → 大写MD5 → SHA1withRSA → base64
*/
protected function md5Sha1RsaSign(array $params): string
{
ksort($params);
$raw = urldecode(http_build_query($params));
$md5Upper = strtoupper(md5($raw));
$signature = '';
openssl_sign($md5Upper, $signature, $this->privateKey, OPENSSL_ALGO_SHA1);
return base64_encode($signature);
}
/**
* 载入私钥:支持 PEM 全文 / 去头尾的单行 base64
* @return \OpenSSLAsymmetricKey|resource|string
*/
protected function loadPrivateKey(string $key)
{
if ($key === '') {
return '';
}
if (strpos($key, '-----BEGIN') === false) {
$key = "-----BEGIN PRIVATE KEY-----\n" . chunk_split(trim($key), 64, "\n") . "-----END PRIVATE KEY-----\n";
}
$res = openssl_pkey_get_private($key);
return $res === false ? '' : $res;
}
/**
* 载入公钥:支持 PEM 全文 / 去头尾的单行 base64
* @return \OpenSSLAsymmetricKey|resource|false
*/
protected function loadPublicKey(string $key)
{
if ($key === '') {
return false;
}
if (strpos($key, '-----BEGIN') === false) {
$key = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(trim($key), 64, "\n") . "-----END PUBLIC KEY-----\n";
}
return openssl_pkey_get_public($key);
}
}
@@ -1,85 +0,0 @@
<?php
// +----------------------------------------------------------------------
// | 萤火商城系统 [ 致力于通过产品和服务,帮助商家高效化开拓市场 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2017~2023 https://www.yiovo.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed 这不是一个自由软件,不允许对程序代码以任何形式任何目的的再发行
// +----------------------------------------------------------------------
// | Author: 萤火科技 <admin@yiovo.com>
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\common\model;
use cores\BaseModel;
use app\common\library\helper;
/**
* 合作商关联商品券模型
* Class PartnersGoods
* @package app\common\model
*/
class PartnersGoods extends BaseModel
{
// 定义表名
protected $name = 'partners_goods';
// 定义主键
protected $pk = 'id';
/**
* 关联模型:商品
* @return \think\model\relation\HasOne
*/
public function goods(): \think\model\relation\HasOne
{
return $this->hasOne('Goods', 'id', 'goods_id');
}
/**
* 获取商品信息详情
* @param int $partnersId
* @param int $goodsId
* @return array|null|static
*/
public static function detail(int $partnersId, int $goodsId)
{
return static::get(['partners_id' => $partnersId, 'goods_id' => $goodsId]);
}
/**
* 获取合作商商品列表
* @param int $partnersId 合作商ID
* @return \think\Collection
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\DbException
* @throws \think\db\exception\ModelNotFoundException
*/
public static function getGoodsList(int $partnersId): \think\Collection
{
return (new static)->where('partners_id', '=', $partnersId)->order(['id' => 'asc'])->select();
}
/**
* 累计合作商使用数量
* @param int|array $where
* @param int $num
* @return mixed
*/
public static function setIncUseNum($where, int $num)
{
return (new static)->setInc($where, 'use_num', $num);
}
/**
* 减少合作商库存数量
* @param int|array $where
* @param int $num
* @return mixed
*/
public static function setDecStockNum($where, int $num)
{
return (new static)->setDec($where, 'stock_num', $num);
}
}
@@ -1,54 +0,0 @@
<?php
// +----------------------------------------------------------------------
// | 萤火商城系统 [ 致力于通过产品和服务,帮助商家高效化开拓市场 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2017~2023 https://www.yiovo.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed 这不是一个自由软件,不允许对程序代码以任何形式任何目的的再发行
// +----------------------------------------------------------------------
// | Author: 萤火科技 <admin@yiovo.com>
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\common\model\partners;
use cores\BaseModel;
/**
* 合作商库存变动记录
* Class StockLog
* @package app\common\model\partners
*/
class StockLog extends BaseModel
{
// 定义表名
protected $name = 'partners_stock_log';
// 定义主键
protected $pk = 'id';
protected $updateTime = false;
/**
* 关联合作商记录表
* @return \think\model\relation\BelongsTo
*/
public function partners()
{
$module = self::getCalledModule();
return $this->belongsTo("app\\{$module}\\model\\Partners");
}
/**
* 新增记录
* @param array $data
*/
public static function add(array $data)
{
$model = new static;
$model->save(array_merge([
'store_id' => $model::$storeId
], $data));
}
}
@@ -0,0 +1,105 @@
<?php
// +----------------------------------------------------------------------
// | 合作商(下单平台)余额管控
// +----------------------------------------------------------------------
// | 企油通改造后,下单按金额直传,不再有商品/库存额度概念,
// | 改由 yoshop_partners.balance 承担下单额度管控:
// | 下单时原子扣减(余额不足直接拒单),发放失败/撤单时原路回补。
// | 扣减用 UPDATE ... WHERE balance >= amount,天然防并发超扣。
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\common\service;
use app\common\enum\partners\balanceLog\Scene as SceneEnum;
use think\facade\Db;
class PartnersBalance
{
/**
* 原子扣减合作商余额(余额不足返回 false,不做任何变更)
* @param int $partnersId 合作商ID
* @param float $amount 扣减金额(元)
* @param string $orderNo 业务单号(写入流水描述)
* @param int $orderId 关联订单ID
* @param int $storeId 商城ID
* @return int|false 成功返回流水ID(供建单后回填 order_id),余额不足返回 false
*/
public static function deduct(int $partnersId, float $amount, string $orderNo, int $orderId = 0, int $storeId = 0)
{
if ($amount <= 0) {
return false;
}
$affected = Db::name('partners')
->where('partners_id', '=', $partnersId)
->where('balance', '>=', $amount)
->update(['balance' => Db::raw('`balance` - ' . self::money($amount))]);
if (!$affected) {
return false;
}
return self::log(SceneEnum::CONSUME, $partnersId, -$amount, $orderNo, $orderId, $storeId);
}
/**
* 回填流水的关联订单ID
*/
public static function bindOrder($logId, int $orderId): void
{
if ($logId && $orderId) {
Db::name('partners_balance_log')->where('log_id', '=', (int)$logId)->update(['order_id' => $orderId]);
}
}
/**
* 回补合作商余额(发放失败 / 撤单)
*/
public static function refund(int $partnersId, float $amount, string $orderNo, int $orderId = 0, int $storeId = 0): void
{
if ($amount <= 0) {
return;
}
Db::name('partners')
->where('partners_id', '=', $partnersId)
->update(['balance' => Db::raw('`balance` + ' . self::money($amount))]);
self::log(SceneEnum::REFUND, $partnersId, $amount, $orderNo, $orderId, $storeId);
}
/**
* 查询当前余额
*/
public static function balance(int $partnersId): float
{
return (float) Db::name('partners')->where('partners_id', '=', $partnersId)->value('balance');
}
/**
* 写余额变动流水(失败不影响主流程),返回流水ID
* @return int
*/
protected static function log(int $scene, int $partnersId, float $money, string $orderNo, int $orderId, int $storeId): int
{
try {
return (int) Db::name('partners_balance_log')->insertGetId([
'partners_id' => $partnersId,
'scene' => $scene,
'money' => round($money, 2),
'describe' => vsprintf(SceneEnum::data()[$scene]['describe'], [$orderNo]),
'remark' => '',
'order_id' => $orderId,
'store_id' => $storeId,
'create_time' => time(),
]);
} catch (\Throwable $e) {
log_record(['name' => '合作商余额流水写入失败', 'partners_id' => $partnersId, 'msg' => $e->getMessage()]);
return 0;
}
}
/**
* 金额安全格式化(仅允许数字与小数点,防拼接注入)
*/
protected static function money(float $amount): string
{
return number_format(round($amount, 2), 2, '.', '');
}
}
@@ -44,19 +44,21 @@ class QueueService
/**
* 检查延迟队列,将到期任务迁移到普通队列
* 并发安全:先 zrem,只有「移除成功(返回>0)」的进程才把任务放进就绪队列,
* 避免多个消费者同时迁移导致同一任务被投递多次。
* @param string $queueName
*/
public function migrateDelayedToReady($queueName)
{
$now = time();
// 获取到期任务
$tasks = $this->redis->zrangebyscore($queueName . ':delayed', 0, $now);
if (!empty($tasks)) {
foreach ($tasks as $task) {
// 放回普通队列
if (empty($tasks)) {
return;
}
foreach ($tasks as $task) {
// 先抢占式移除,再入队
if ($this->redis->zrem($queueName . ':delayed', $task) > 0) {
$this->push($queueName, $task);
// 从延迟队列中移除
$this->redis->zrem($queueName . ':delayed', $task);
}
}
}
+189
View File
@@ -0,0 +1,189 @@
<?php
// +----------------------------------------------------------------------
// | 企油通 · 上游接口客户端(支付宝企业码通道)
// +----------------------------------------------------------------------
// | 本次实际对接仅 3 个接口:
// | 7.7 运单油费下发 createAndSendQuota —— 本类 createAndSendQuota()
// | 7.15 H5 跳转地址 (本地签名生成 URL) —— 本类 h5Url()
// | 7.10 运单加油明细推送(异步回调) —— app\api\controller\QytNotify
// | 通道固定为支付宝企业码(passageway=1),司机手机号 + 姓名必填。
// | 金额:对外「元」入参,7.7 内部换算为「分」。
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\common\service;
use app\common\crypto\Qyt as QytCrypto;
class QytOil extends BaseService
{
// 支付宝企业码通道
const PASSAGEWAY_ALIPAY = 1;
/** @var array config('qyt') */
protected $conf;
/** @var QytCrypto */
protected $crypto;
protected function initialize()
{
$this->conf = (array) config('qyt');
$this->crypto = new QytCrypto($this->conf);
}
// ================================================================
// 7.7 运单油费下发(一张订单 = 1 batch / 1 waybill / 1 employee)
// ================================================================
/**
* @param array $data ['batch_no','waybill_no','waybill_no_code','mobile','name','amount'(元)]
* @return array 归一化 ['code'=>'00'|其它, 'data'=>[], 'message'=>'', 'dup_batch'=>bool]
*/
public function createAndSendQuota(array $data): array
{
$params = [
'batch_no' => $data['batch_no'],
'business_license' => $this->conf['business_license'],
'passageway' => (string) self::PASSAGEWAY_ALIPAY,
'institution_id' => $this->conf['institution_id'],
'waybill_list' => [[
'waybill_no' => $data['waybill_no'],
'employee_list' => [[
'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'],
]],
]],
];
return $this->post('/enterprise/api/zhyk/createAndSendQuota', $params);
}
// ================================================================
// 7.15 H5 免登加油地址(本地签名生成 URL,不发起接口请求)
// ================================================================
public function h5Url(string $mobile): string
{
$creditcode = !empty($this->conf['h5_with_creditcode']) ? (string) $this->conf['business_license'] : '';
return $this->crypto->h5Url($mobile, $creditcode);
}
// ================================================================
// 内部:统一请求
// ================================================================
/**
* @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(['url' => $url, 'params' => $bizParams], JSON_UNESCAPED_UNICODE));
$resp = $this->curlPost($url, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$this->saveLog("[响应] {$path} " . $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';
// 【TODO 待对接人确认】是否所有响应都带 sign、验签串如何构造
// if ($ok && !empty($json['sign'])) { $ok = $this->crypto->verify($json['data'] ?? [], $json['sign']); }
$message = (string) ($json['message'] ?? '');
// 幂等:batch_no 已存在 —— 视为「已受理」,供 7.7 重试判定
$dupBatch = false;
if (!$ok && $message !== '') {
foreach ((array) (config('qyt.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;
}
// ================================================================
// 工具
// ================================================================
/** 元 → 分 */
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);
}
/**
* 生成企油通全平台唯一编号(加企业前缀),确定性派生 -> 重试保持一致
* @param string $type B=batch / W=waybill / C=waybill_no_code
* @param string $seed 订单号 / order_id 等业务种子
*/
public static function genNo(string $type, string $seed): string
{
$prefix = (string) (config('qyt.no_prefix') ?: 'HM');
return $prefix . $type . $seed;
}
/** 记录日志到 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
);
}
}