文件
xionggeshop/addons/shopro_huifu/library/onboard/HuifuOnboardService.php
可乐加冰 abc40ef2a9 feat(huifu): 支持汇付进件通过后更换结算银行卡
- 用户端及后台管理端新增更换结算卡接口与页面交互
- 服务层实现换卡核心逻辑、参数校验及汇付接口调用
- 增加换卡异步通知处理与定时轮询补偿以同步审核状态
- 完善进件状态网关,补充换卡审核状态及驳回原因等字段
2026-11-15 00:52:59 +08:00

614 行
23 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?php
namespace addons\shopro_huifu\library\onboard;
use addons\shopro_huifu\model\Onboard;
use think\Db;
use think\Log;
class HuifuOnboardService
{
protected $sdk;
protected $picture;
protected $basic;
protected $basicEnt;
protected $busi;
protected $busiModify;
protected $query;
protected function sdk()
{
if (!$this->sdk) {
$this->sdk = new HuifuOnboardSdk();
$this->picture = new HuifuOnboardPicture($this->sdk);
$this->basic = new HuifuOnboardBasicIndv($this->sdk);
$this->basicEnt = new HuifuOnboardBasicEnt($this->sdk);
$this->busi = new HuifuOnboardBusiOpen($this->sdk);
$this->busiModify = new HuifuOnboardBusiModify($this->sdk);
$this->query = new HuifuOnboardQuery($this->sdk);
}
return $this->sdk;
}
/**
* 仅保存进件资料(后台代录 / 兼容旧路径);用户端已改为 submit 直连汇付
*/
public function saveDraft($userId, $roleType, $roleRefId, array $formData, $onboardId = 0, $force = false)
{
if (!$force && !HuifuOnboardGate::isOnboardRequired()) {
return null;
}
if (!in_array($roleType, ['promoter', 'regional_agent'], true)) {
throw new \InvalidArgumentException('无效角色类型');
}
$form = HuifuOnboardValidator::validateSubmit($formData);
$onboardId = intval($onboardId);
if ($onboardId > 0) {
$row = Onboard::get($onboardId);
if (!$row || intval($row['user_id']) !== intval($userId)) {
throw new \InvalidArgumentException('进件记录不存在');
}
} else {
$row = Onboard::findByRole($userId, $roleType, $roleRefId);
}
if ($row && $row['onboard_status'] === Onboard::STATUS_APPROVED) {
return $row;
}
if ($row && in_array($row['onboard_status'], [
Onboard::STATUS_SUBMITTING,
Onboard::STATUS_BASIC_OK,
Onboard::STATUS_BUSI_PENDING,
], true)) {
throw new \RuntimeException('汇付进件处理中,请耐心等待');
}
if (!$row) {
$row = Onboard::create([
'user_id' => $userId,
'role_type' => $roleType,
'role_ref_id' => $roleRefId,
'onboard_status' => Onboard::STATUS_DRAFT,
]);
}
$row->onboard_status = Onboard::STATUS_DRAFT;
$row->payload = json_encode($form, JSON_UNESCAPED_UNICODE);
$row->reject_reason = '';
$row->save();
HuifuOnboardReceiverSync::bindOnboardId($roleType, $roleRefId, $row['id']);
return $row;
}
/**
* 后台业务审核通过后,读取 draft 资料提交汇付
*/
public function submitFromDraft($userId, $roleType, $roleRefId)
{
if (!HuifuOnboardGate::isOnboardRequired()) {
return null;
}
$row = Onboard::findByRole($userId, $roleType, $roleRefId);
if (!$row || $row['onboard_status'] !== Onboard::STATUS_DRAFT) {
return null;
}
$payload = json_decode($row['payload'] ?? '', true);
if (!$payload || !is_array($payload)) {
throw new \RuntimeException('缺少汇付进件资料,请用户在小程序重新提交');
}
return $this->submit($userId, $roleType, $roleRefId, $payload);
}
public function submit($userId, $roleType, $roleRefId, array $formData)
{
if (!HuifuOnboardGate::isOnboardRequired()) {
return null;
}
if (!in_array($roleType, ['promoter', 'regional_agent'], true)) {
throw new \InvalidArgumentException('无效角色类型');
}
$form = HuifuOnboardValidator::validateSubmit($formData);
$row = Onboard::findByRole($userId, $roleType, $roleRefId);
if ($row && $row['onboard_status'] === Onboard::STATUS_APPROVED) {
return $row;
}
if ($row && in_array($row['onboard_status'], [Onboard::STATUS_SUBMITTING, Onboard::STATUS_BUSI_PENDING], true)) {
throw new \RuntimeException('进件审核中,请耐心等待');
}
if (!$row) {
$row = Onboard::create([
'user_id' => $userId,
'role_type' => $roleType,
'role_ref_id' => $roleRefId,
'onboard_status' => Onboard::STATUS_DRAFT,
]);
}
$row->onboard_status = Onboard::STATUS_SUBMITTING;
$row->payload = json_encode($form, JSON_UNESCAPED_UNICODE);
$row->reject_reason = '';
$row->save();
try {
$this->sdk();
$fileMap = $this->uploadFormPictures($form);
$row->file_map = json_encode($fileMap, JSON_UNESCAPED_UNICODE);
$accountType = $form['account_type'] ?? 'indv';
$basicResult = $accountType === 'ent'
? $this->basicEnt->submit($form, $fileMap)
: $this->basic->submit($form, $fileMap);
$row->huifu_id = $basicResult['huifu_id'];
$row->basic_req_seq_id = $basicResult['req_seq_id'];
$row->req_date = $basicResult['req_date'];
$row->req_seq_id = $basicResult['req_seq_id'];
$row->onboard_status = Onboard::STATUS_BASIC_OK;
$row->save();
$busiResult = $this->busi->submit($row['huifu_id'], $form, $fileMap);
$row->busi_req_seq_id = $busiResult['req_seq_id'];
$row->req_seq_id = $busiResult['req_seq_id'];
$row->apply_no = $busiResult['apply_no'] ?? '';
$row->onboard_status = Onboard::STATUS_BUSI_PENDING;
$row->resp_snapshot = json_encode($busiResult['resp'], JSON_UNESCAPED_UNICODE);
$row->save();
HuifuOnboardReceiverSync::bindOnboardId($roleType, $roleRefId, $row['id']);
$row = $this->query->pollAndUpdate($row);
return $row;
} catch (\Throwable $e) {
$friendly = HuifuOnboardError::translate($e->getMessage());
Log::error('[shopro_huifu] onboard submit fail uid=' . $userId . ' ' . $e->getMessage());
$row->onboard_status = Onboard::STATUS_FAILED;
$row->reject_reason = $friendly;
$row->save();
throw new \RuntimeException($friendly, 0, $e);
}
}
public function resubmit($userId, $roleType, $roleRefId, array $formData)
{
$row = Onboard::findByRole($userId, $roleType, $roleRefId);
if ($row && $row['onboard_status'] === Onboard::STATUS_APPROVED) {
return $row;
}
if ($row && in_array($row['onboard_status'], [Onboard::STATUS_REJECTED, Onboard::STATUS_FAILED], true)) {
$row->onboard_status = Onboard::STATUS_DRAFT;
$row->save();
}
return $this->submit($userId, $roleType, $roleRefId, $formData);
}
public function pollPending($limit = 50)
{
$list = Onboard::where('onboard_status', 'in', [
Onboard::STATUS_BUSI_PENDING,
Onboard::STATUS_BASIC_OK,
])->limit($limit)->select();
if (!$list || count($list) === 0) {
return 0;
}
$this->sdk();
$count = 0;
foreach ($list as $row) {
try {
$this->query->pollAndUpdate($row);
$count++;
} catch (\Throwable $e) {
Log::error('[shopro_huifu] onboard poll fail id=' . $row['id'] . ' ' . $e->getMessage());
}
}
return $count;
}
protected function uploadFormPictures(array $form)
{
$fileMap = [];
$accountType = $form['account_type'] ?? 'indv';
if ($accountType === 'ent') {
if (!empty($form['license_url'])) {
$fileMap['F01'] = $this->picture->uploadByUrl('F01', $form['license_url']);
}
if (!empty($form['legal_id_front_url'])) {
$fileMap['F02'] = $this->picture->uploadByUrl('F02', $form['legal_id_front_url']);
}
if (!empty($form['legal_id_back_url'])) {
$fileMap['F03'] = $this->picture->uploadByUrl('F03', $form['legal_id_back_url']);
}
} else {
$fileMap['F02'] = $this->picture->uploadByUrl('F02', $form['id_card_front_url']);
$fileMap['F03'] = $this->picture->uploadByUrl('F03', $form['id_card_back_url']);
}
if (!empty($form['bank_card_url'])) {
$fileMap['F13'] = $this->picture->uploadByUrl('F13', $form['bank_card_url']);
}
if (($form['bank_account_type'] ?? '0') === '1' && !empty($form['account_permit_url'])) {
$fileMap['F08'] = $this->picture->uploadByUrl('F08', $form['account_permit_url']);
}
return $fileMap;
}
/**
* 已通过进件后更换结算银行卡(V2UserBusiModify,仅 card_info)
*/
public function modifySettleCard($userId, $roleType, $roleRefId, array $formData, $onboardId = 0)
{
if (!HuifuOnboardGate::isOnboardRequired()) {
throw new \RuntimeException('当前未开启汇付进件');
}
if (!in_array($roleType, ['promoter', 'regional_agent'], true)) {
throw new \InvalidArgumentException('无效角色类型');
}
$onboardId = intval($onboardId);
if ($onboardId > 0) {
$row = Onboard::get($onboardId);
if (!$row || intval($row['user_id']) !== intval($userId)) {
throw new \InvalidArgumentException('进件记录不存在');
}
} else {
$row = Onboard::findByRole($userId, $roleType, $roleRefId);
}
if (!$row || $row['onboard_status'] !== Onboard::STATUS_APPROVED) {
throw new \RuntimeException('仅进件已通过时可更换结算卡');
}
if (empty($row['huifu_id'])) {
throw new \RuntimeException('缺少汇付商户号,无法更换结算卡');
}
$payload = json_decode($row['payload'] ?? '', true);
if (!is_array($payload)) {
$payload = [];
}
$cardModify = self::getCardModifyMeta($payload);
if (($cardModify['status'] ?? '') === 'pending') {
throw new \RuntimeException('结算卡更换审核中,请稍后再试');
}
$form = HuifuOnboardValidator::validateCardModify($formData, $payload);
$oldCard = preg_replace('/\s+/', '', (string) ($payload['card_no'] ?? ''));
$newCard = preg_replace('/\s+/', '', (string) ($form['card_no'] ?? ''));
try {
$this->sdk();
$fileMap = $this->uploadCardModifyPictures($form, $payload, $oldCard !== $newCard);
$result = $this->busiModify->modifyCard($row['huifu_id'], $form, $fileMap);
$resp = is_array($result['resp'] ?? null) ? $result['resp'] : [];
$applyNo = (string) ($result['apply_no'] ?? '');
$pending = $this->isCardModifyPending($resp, $applyNo);
if ($pending) {
$payload['_card_modify'] = [
'status' => 'pending',
'apply_no' => $applyNo,
'req_seq_id' => (string) ($result['req_seq_id'] ?? ''),
'req_date' => (string) ($result['req_date'] ?? ''),
'pending_form' => $this->cardFieldsOnly($form),
'reject_reason' => '',
'updated_at' => time(),
];
$row->payload = json_encode($payload, JSON_UNESCAPED_UNICODE);
$row->resp_snapshot = json_encode($resp, JSON_UNESCAPED_UNICODE);
$row->save();
return $row;
}
if (!$this->isCardModifySyncSuccess($resp)) {
$msg = (string) ($resp['resp_desc'] ?? '更换结算卡失败');
throw new \RuntimeException(HuifuOnboardError::translate($msg));
}
return $this->commitCardModifySuccess($row, $payload, $form, $resp);
} catch (\RuntimeException $e) {
throw $e;
} catch (\Throwable $e) {
$friendly = HuifuOnboardError::translate($e->getMessage());
Log::error('[shopro_huifu] card modify fail uid=' . $userId . ' id=' . $row['id'] . ' ' . $e->getMessage());
throw new \RuntimeException($friendly, 0, $e);
}
}
public function handleNotify(array $data)
{
$huifuId = trim((string) ($data['huifu_id'] ?? ''));
if ($huifuId === '') {
return false;
}
$auditInfo = $data['audit_info'] ?? null;
if (is_string($auditInfo)) {
$auditInfo = json_decode($auditInfo, true) ?: [];
}
if (!is_array($auditInfo)) {
$auditInfo = [];
}
$applyNo = trim((string) ($data['apply_no'] ?? ($auditInfo['apply_no'] ?? '')));
$reqSeqId = trim((string) ($data['req_seq_id'] ?? ''));
$row = Onboard::where('huifu_id', $huifuId)->order('id desc')->find();
if (!$row) {
return false;
}
if ($this->tryApplyCardModifyNotify($row, $data, $auditInfo, $applyNo, $reqSeqId)) {
return true;
}
// 原进件审核:优先按 apply_no 精确匹配,避免误更新其他记录
if ($applyNo !== '') {
$byApply = Onboard::where('huifu_id', $huifuId)->where('apply_no', $applyNo)->order('id desc')->find();
if ($byApply) {
$row = $byApply;
}
}
$this->sdk();
$this->query->pollAndUpdate($row);
return true;
}
/**
* 补偿:扫描换卡 pending,查单后更新(不改 onboard_status)
*/
public function pollCardModifyPending($limit = 50)
{
$list = Onboard::where('onboard_status', Onboard::STATUS_APPROVED)
->where('payload', 'like', '%"_card_modify"%')
->limit($limit)
->select();
if (!$list || count($list) === 0) {
return 0;
}
$this->sdk();
$count = 0;
foreach ($list as $row) {
try {
$payload = json_decode($row['payload'] ?? '', true);
if (!is_array($payload)) {
continue;
}
$meta = self::getCardModifyMeta($payload);
if (($meta['status'] ?? '') !== 'pending') {
continue;
}
$applyNo = trim((string) ($meta['apply_no'] ?? ''));
if ($applyNo === '' || empty($row['huifu_id'])) {
continue;
}
$status = $this->query->queryApply($row['huifu_id'], $applyNo);
$this->applyCardModifyRemoteStatus($row, $payload, $meta, $status);
$count++;
} catch (\Throwable $e) {
Log::error('[shopro_huifu] card modify poll fail id=' . $row['id'] . ' ' . $e->getMessage());
}
}
return $count;
}
protected function uploadCardModifyPictures(array $form, array $stored, $cardChanged)
{
$fileMap = [];
$needBankPic = $cardChanged || empty($stored['bank_card_url']) || (!empty($form['bank_card_url']) && ($form['bank_card_url'] !== ($stored['bank_card_url'] ?? '')));
if ($needBankPic && !empty($form['bank_card_url'])) {
$fileMap['F13'] = $this->picture->uploadByUrl('F13', $form['bank_card_url']);
}
if ((string) ($form['bank_account_type'] ?? '0') === '1' && !empty($form['account_permit_url'])) {
$oldPermit = (string) ($stored['account_permit_url'] ?? '');
if ($form['account_permit_url'] !== $oldPermit || empty($oldPermit)) {
$fileMap['F08'] = $this->picture->uploadByUrl('F08', $form['account_permit_url']);
}
}
return $fileMap;
}
protected function isCardModifyPending(array $resp, $applyNo)
{
$applyNo = trim((string) $applyNo);
if ($applyNo === '') {
return false;
}
// 有申请单号且绑卡结果未明确成功 → 审核中
if ($this->cardBusinessSucceeded($resp)) {
return false;
}
return true;
}
protected function isCardModifySyncSuccess(array $resp)
{
$code = (string) ($resp['resp_code'] ?? '');
if (!in_array($code, ['00000000', '00', '0000'], true) && $code !== '') {
// parseResponse 成功时通常已是业务成功码;若无码但有 token_no 也视为成功
if (empty($resp['token_no']) && !$this->cardBusinessSucceeded($resp)) {
return false;
}
}
if ($this->cardBusinessFailed($resp)) {
return false;
}
return true;
}
protected function cardBusinessSucceeded(array $resp)
{
$list = $resp['resp_business'] ?? [];
if (is_string($list)) {
$list = json_decode($list, true) ?: [];
}
if (!is_array($list) || !$list) {
return false;
}
foreach ($list as $item) {
if (!is_array($item)) {
continue;
}
if ((string) ($item['type'] ?? '') === '1' && strtoupper((string) ($item['code'] ?? '')) === 'S') {
return true;
}
}
return false;
}
protected function cardBusinessFailed(array $resp)
{
$list = $resp['resp_business'] ?? [];
if (is_string($list)) {
$list = json_decode($list, true) ?: [];
}
if (!is_array($list)) {
return false;
}
foreach ($list as $item) {
if (!is_array($item)) {
continue;
}
if ((string) ($item['type'] ?? '') === '1' && strtoupper((string) ($item['code'] ?? '')) === 'F') {
return true;
}
}
return false;
}
protected function commitCardModifySuccess(Onboard $row, array $payload, array $form, array $resp = [])
{
foreach ($this->cardFieldsOnly($form) as $key => $val) {
$payload[$key] = $val;
}
unset($payload['_card_modify']);
$row->payload = json_encode($payload, JSON_UNESCAPED_UNICODE);
if ($resp) {
$row->resp_snapshot = json_encode($resp, JSON_UNESCAPED_UNICODE);
}
// 保持 approved,不改 apply_no
$row->save();
return $row;
}
protected function cardFieldsOnly(array $form)
{
$keys = [
'bank_account_type', 'card_no', 'card_name', 'branch_code', 'branch_name',
'prov_id', 'area_id', 'district_id', 'shopro_province_id', 'shopro_city_id', 'shopro_district_id',
'mp', 'mobile_no', 'legal_mobile', 'bank_card_url', 'account_permit_url',
// normalize 可能同步的证件字段(对私)
'cert_no', 'cert_validity_type', 'cert_begin_date', 'cert_end_date', 'name',
];
$out = [];
foreach ($keys as $key) {
if (array_key_exists($key, $form)) {
$out[$key] = $form[$key];
}
}
return $out;
}
protected function tryApplyCardModifyNotify(Onboard $row, array $data, array $auditInfo, $applyNo, $reqSeqId)
{
$payload = json_decode($row['payload'] ?? '', true);
if (!is_array($payload)) {
return false;
}
$meta = self::getCardModifyMeta($payload);
if (($meta['status'] ?? '') !== 'pending') {
return false;
}
$metaApply = trim((string) ($meta['apply_no'] ?? ''));
$metaReq = trim((string) ($meta['req_seq_id'] ?? ''));
$matched = false;
if ($applyNo !== '' && $metaApply !== '' && $applyNo === $metaApply) {
$matched = true;
} elseif ($reqSeqId !== '' && $metaReq !== '' && $reqSeqId === $metaReq) {
$matched = true;
}
if (!$matched) {
return false;
}
$auditStatus = strtoupper(trim((string) (
$auditInfo['audit_status']
?? $data['audit_status']
?? $data['apply_status']
?? ''
)));
$auditDesc = (string) (
$auditInfo['audit_desc']
?? $data['audit_desc']
?? $data['sub_resp_desc']
?? $data['resp_desc']
?? ''
);
if (in_array($auditStatus, ['Y', 'S', 'SUCCESS', 'APPROVED', '1', '02', '2'], true)) {
$pendingForm = is_array($meta['pending_form'] ?? null) ? $meta['pending_form'] : [];
$this->commitCardModifySuccess($row, $payload, $pendingForm, $data);
return true;
}
if (in_array($auditStatus, ['N', 'F', 'FAIL', 'FAILED', 'REJECTED', '3', '03', '-1'], true)) {
$payload['_card_modify'] = [
'status' => 'rejected',
'apply_no' => $metaApply,
'req_seq_id' => $metaReq,
'pending_form' => $meta['pending_form'] ?? [],
'reject_reason' => $auditDesc ?: '结算卡更换被驳回',
'updated_at' => time(),
];
$row->payload = json_encode($payload, JSON_UNESCAPED_UNICODE);
$row->resp_snapshot = json_encode($data, JSON_UNESCAPED_UNICODE);
$row->save();
return true;
}
// 审核中:保持 pending
$payload['_card_modify']['updated_at'] = time();
$row->payload = json_encode($payload, JSON_UNESCAPED_UNICODE);
$row->resp_snapshot = json_encode($data, JSON_UNESCAPED_UNICODE);
$row->save();
return true;
}
protected function applyCardModifyRemoteStatus(Onboard $row, array $payload, array $meta, array $status)
{
if (!$status) {
return;
}
$stat = strtoupper(trim((string) (
$status['audit_status']
?? $status['apply_status']
?? $status['status']
?? ''
)));
$desc = (string) ($status['audit_desc'] ?? $status['apply_desc'] ?? $status['resp_desc'] ?? '');
if (in_array($stat, ['Y', 'S', 'SUCCESS', 'APPROVED', '1', '02', '2'], true)) {
$pendingForm = is_array($meta['pending_form'] ?? null) ? $meta['pending_form'] : [];
$this->commitCardModifySuccess($row, $payload, $pendingForm, $status);
return;
}
if (in_array($stat, ['N', 'F', 'FAIL', 'FAILED', 'REJECTED', '3', '03', '-1'], true)) {
$payload['_card_modify'] = [
'status' => 'rejected',
'apply_no' => (string) ($meta['apply_no'] ?? ''),
'req_seq_id' => (string) ($meta['req_seq_id'] ?? ''),
'pending_form' => $meta['pending_form'] ?? [],
'reject_reason' => $desc ?: '结算卡更换被驳回',
'updated_at' => time(),
];
$row->payload = json_encode($payload, JSON_UNESCAPED_UNICODE);
$row->resp_snapshot = json_encode($status, JSON_UNESCAPED_UNICODE);
$row->save();
}
}
public static function getCardModifyMeta(array $payload)
{
$meta = $payload['_card_modify'] ?? null;
return is_array($meta) ? $meta : [];
}
}