Просмотр исходного кода

feat(lakala): 异步刷新商户支付授权状态

* 将授权状态查询迁移至 Class 层和 Yii 控制台命令
* 添加通用异步控制台执行器,支持配置 PHP CLI 路径
* 使用 Redis 短期锁避免重复调度同一账户的刷新任务
* 解析微信和支付宝的已授权、未授权及未知状态
* 增强拉卡拉请求异常响应头和任务执行日志
* 禁止控制台日志记录超全局变量,减少无关日志内容
* 补充拉卡拉账户类型字段说明
* 避免账户列表和商户信息同步流程被授权查询阻塞
shizhongqi 1 месяц назад
Родитель
Сommit
68e0150469

+ 7 - 4
.env.example

@@ -1,8 +1,11 @@
 #环境 local dev test production
 YII_ENV=local
-YII_DEBUG=true
-
-DB_DSN='mysql:host=118.178.193.23;dbname=huahuibao'
+YII_DEBUG=true
+
+# CLI PHP used by detached Yii console processes
+PHP_CLI_BINARY=/usr/local/bin/php
+
+DB_DSN='mysql:host=118.178.193.23;dbname=huahuibao'
 DB_DSN_CONSOLE='mysql:host=118.178.193.23;dbname=huahuibao'
 DB_DSN_BAK='mysql:host=118.178.193.23;dbname=hhb'
 DB_USERNAME='root'
@@ -52,4 +55,4 @@ HD_NEW_APK_VERSION=1.0.0
 HD_NEW_APK_VERSION_MUST_UPDATE=0
 
 #订单有效时长
-ORDER_VALID_TIME=30
+ORDER_VALID_TIME=30

+ 119 - 0
biz-ghs/lakala/classes/LakalaAccountClass.php

@@ -3,8 +3,127 @@
 namespace bizGhs\lakala\classes;
 
 use bizGhs\base\classes\BaseClass;
+use common\components\AsyncConsole;
+use common\components\lakala\LakalaOnboardingClient;
+use Yii;
 
 class LakalaAccountClass extends BaseClass
 {
     public static $baseFile = '\bizGhs\lakala\models\LakalaAccount';
+
+    const AUTHORIZE_REFRESH_LOCK_PREFIX = 'lakala:authorize-refresh-';
+    const AUTHORIZE_REFRESH_LOCK_SECONDS = 120;
+
+    public static function dispatchAuthorizeStatesRefresh(array $accountIds)
+    {
+        $accountIds = array_values(array_unique(array_filter(array_map('intval', $accountIds), function ($id) {
+            return $id > 0;
+        })));
+        if (empty($accountIds)) {
+            return false;
+        }
+
+        $lockedIds = [];
+        try {
+            foreach ($accountIds as $accountId) {
+                $key = self::AUTHORIZE_REFRESH_LOCK_PREFIX . $accountId;
+                $locked = Yii::$app->redis->executeCommand('SET', [
+                    $key,
+                    (string)time(),
+                    'EX',
+                    self::AUTHORIZE_REFRESH_LOCK_SECONDS,
+                    'NX',
+                ]);
+                if ($locked === true || $locked === 'OK') {
+                    $lockedIds[] = $accountId;
+                }
+            }
+        } catch (\Throwable $e) {
+            Yii::warning('Unable to lock Lakala authorize refresh task: ' . $e->getMessage());
+            self::releaseAuthorizeRefreshLocks($lockedIds);
+            return false;
+        }
+
+        if (empty($lockedIds)) {
+            return false;
+        }
+
+        $started = AsyncConsole::run([
+            'lakala-account/refresh-authorize-states',
+            implode(',', $lockedIds),
+        ], 'lakala-authorize-refresh.log');
+        if (!$started) {
+            self::releaseAuthorizeRefreshLocks($lockedIds);
+        }
+        return $started;
+    }
+
+    public static function refreshAuthorizeStates($accountId)
+    {
+        try {
+            $account = self::getById((int)$accountId, true);
+            if (empty($account)) {
+                return true;
+            }
+            if ($account->wxAuthorizeState == 1 && $account->zfbAuthorizeState == 1) {
+                return true;
+            }
+
+            $merchantNo = $account->merchantNo ?: '';
+            $scanTermNo = $account->scanTermNo ?: '';
+            $b2bTermNo = $account->b2bTermNo ?: '';
+            if ($merchantNo === '' || $scanTermNo === '' || $b2bTermNo === '') {
+                return true;
+            }
+
+            $client = new LakalaOnboardingClient();
+            $account->wxAuthorizeState = self::authorizeState($client, $merchantNo, 'WXZF');
+            $account->zfbAuthorizeState = self::authorizeState($client, $merchantNo, 'ZFBZF');
+            $account->updateTime = date('Y-m-d H:i:s');
+            if (!$account->save()) {
+                throw new \RuntimeException(json_encode($account->getErrors(), JSON_UNESCAPED_UNICODE));
+            }
+            return true;
+        } catch (\Throwable $e) {
+            Yii::warning('Unable to refresh Lakala authorize state, accountId=' . (int)$accountId . ': ' . $e->getMessage());
+            return false;
+        }
+    }
+
+    private static function authorizeState($client, $merchantNo, $registerType)
+    {
+        $response = $client->query('/api/v3/tkbs/open_merchant_register_status_query', [
+            'merchant_no' => $merchantNo,
+            'register_type' => $registerType,
+        ], 'both');
+        Yii::info(json_encode($response));
+        $data = $response['data'] ?? [];
+
+        if (isset($data['authorize_state']) && ($data['authorize_state'] == 'AUTHORIZE_STATE_UNAUTHORIZED' || $data['authorize_state'] == 'UNAUTHORIZED')) {
+            Yii::info('Lakala '.$merchantNo. ' '.$registerType. ' authorize state is UNAUTHORIZED');
+            // 未认证
+            return 0;
+        }
+
+        if (isset($data['authorize_state']) && ($data['authorize_state'] == 'AUTHORIZE_STATE_AUTHORIZED' || $data['authorize_state'] == 'AUTHORIZED')) {
+            Yii::info('Lakala '.$merchantNo. ' '.$registerType. ' authorize state is AUTHORIZED');
+            // 已认证
+            return 1;
+        }
+
+        // 其他状态,默认未认证
+        Yii::error('Lakala '.$merchantNo. ' '.$registerType. ' authorize state is UNKNOWN');
+        return 0;
+    }
+
+    private static function releaseAuthorizeRefreshLocks(array $accountIds)
+    {
+        foreach ($accountIds as $accountId) {
+            try {
+                Yii::$app->redis->executeCommand('DEL', [self::AUTHORIZE_REFRESH_LOCK_PREFIX . $accountId]);
+            } catch (\Throwable $e) {
+                Yii::warning('Unable to release Lakala authorize refresh lock: ' . $e->getMessage());
+            }
+        }
+    }
 }

+ 5 - 37
biz-ghs/lakala/services/LakalaAccountService.php

@@ -32,15 +32,18 @@ class LakalaAccountService
             ['isCurrent' => SORT_DESC, 'id' => SORT_DESC],
             '*', null, true
         );
+
         foreach ($applications as &$application) {
             $application['formData'] = self::decode($application['formData'] ?? '');
         }
+        $refreshAccountIds = [];
         foreach ($accounts as $account) {
             if ($account->wxAuthorizeState == 1 && $account->zfbAuthorizeState == 1) {
                 continue;
             }
-            self::refreshAuthorizeStates($account);
+            $refreshAccountIds[] = $account->id;
         }
+        LakalaAccountClass::dispatchAuthorizeStatesRefresh($refreshAccountIds);
         $accountList = [];
         foreach ($accounts as $account) {
             $accountList[] = $account->attributes;
@@ -543,47 +546,12 @@ class LakalaAccountService
             $account->updateTime = date('Y-m-d H:i:s');
             $account->save();
             $application->save();
-            self::refreshAuthorizeStates($account);
+            LakalaAccountClass::dispatchAuthorizeStatesRefresh([$account->id]);
         } catch (\Throwable $e) {
             Yii::warning('Unable to sync Lakala merchant info terms: ' . $e->getMessage());
         }
     }
 
-    private static function refreshAuthorizeStates(&$account)
-    {
-        $merchantNo = $account['merchantNo'] ?? '';
-        $scanTermNo = $account['scanTermNo'] ?? '';
-        $b2bTermNo = $account['b2bTermNo'] ?? '';
-        if ($merchantNo === '' || $scanTermNo === '' || $b2bTermNo === '') {
-            return;
-        }
-
-        try {
-            $client = new LakalaOnboardingClient();
-            $wxState = self::authorizeState($client, $merchantNo, 'WXZF');
-            $zfbState = self::authorizeState($client, $merchantNo, 'ZFBZF');
-            $account->wxAuthorizeState = $wxState;
-            $account->zfbAuthorizeState = $zfbState;
-            $account->updateTime = date('Y-m-d H:i:s');
-            $account->save();
-        } catch (\Throwable $e) {
-            Yii::warning('Unable to refresh Lakala authorize state: ' . $e->getMessage());
-        }
-    }
-
-    private static function authorizeState($client, $merchantNo, $registerType)
-    {
-        $response = $client->query('/api/v3/tkbs/open_merchant_register_status_query', [
-            'merchant_no' => $merchantNo,
-            'register_type' => $registerType,
-        ], 'both');
-        $data = $response['data'] ?? [];
-        if (isset($data['authorize_state']) && $data['authorize_state'] == '') { // TODO 根据实际情况进行补充
-            return 1;
-        }
-        return  0;
-    }
-
     private static function upsertAccount($application, array $remote)
     {
         $account = LakalaAccountClass::getByCondition(['mainId' => $application->mainId, 'merchantNo' => $application->merchantNo], true);

+ 68 - 0
common/components/AsyncConsole.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace common\components;
+
+use Yii;
+
+class AsyncConsole
+{
+    /**
+     * Start a detached Yii console command.
+     *
+     * @param array $arguments Command route followed by positional arguments.
+     * @param string $logName
+     * @return bool
+     */
+    public static function run(array $arguments, $logName = 'async-console.log')
+    {
+        if (empty($arguments)) {
+            Yii::warning('Unable to start async console command: command arguments are empty');
+            return false;
+        }
+        if (!self::canExec()) {
+            Yii::warning('Unable to start async console command: exec is disabled');
+            return false;
+        }
+
+        $phpBinary = getenv('PHP_CLI_BINARY') ?: '/usr/local/bin/php';
+        $yii = dirname(Yii::getAlias('@console')) . '/yii';
+        if (!is_file($phpBinary) || !is_executable($phpBinary)) {
+            Yii::warning('Unable to start async console command: invalid PHP_CLI_BINARY ' . $phpBinary);
+            return false;
+        }
+        if (!is_file($yii) || !is_executable($yii)) {
+            Yii::warning('Unable to start async console command: yii entrypoint is not executable ' . $yii);
+            return false;
+        }
+
+        $logName = basename($logName);
+        $logFile = Yii::getAlias('@console/runtime/logs') . '/' . $logName;
+        $escapedArguments = array_map(function ($argument) {
+            return escapeshellarg((string)$argument);
+        }, $arguments);
+        $command = 'nohup '
+            . escapeshellarg($phpBinary) . ' '
+            . escapeshellarg($yii) . ' '
+            . implode(' ', $escapedArguments)
+            . ' >> ' . escapeshellarg($logFile)
+            . ' 2>&1 < /dev/null &';
+
+        $output = [];
+        $exitCode = 0;
+        exec($command, $output, $exitCode);
+        if ($exitCode !== 0) {
+            Yii::warning('Unable to start async console command, exit code: ' . $exitCode);
+            return false;
+        }
+        return true;
+    }
+
+    private static function canExec()
+    {
+        if (!is_callable('exec')) {
+            return false;
+        }
+        $disabled = array_filter(array_map('trim', explode(',', (string)ini_get('disable_functions'))));
+        return !in_array('exec', $disabled, true);
+    }
+}

+ 6 - 1
common/components/lakala/LakalaOnboardingClient.php

@@ -83,6 +83,7 @@ class LakalaOnboardingClient
             if ($response->getCode() !== '000000' && $response->getCode() !== 'BBS00000') {
                 throw new \RuntimeException($response->getMsg() ?: '拉卡拉接口请求失败');
             }
+
             return [
                 'code' => $response->getCode(),
                 'msg' => $response->getMsg(),
@@ -90,7 +91,11 @@ class LakalaOnboardingClient
                 'raw' => $raw,
             ];
         } catch (\Throwable $e) {
-            Yii::error('Lakala onboarding ' . $path . ': ' . $e->getMessage()); // . $e->getResponseHeaders()
+            Yii::error('Lakala onboarding ' . $path . ': ' . $e->getMessage());
+            //检测 $e 有没有getResponseHeaders方法
+            if (method_exists($e, 'getResponseHeaders')) {
+                Yii::error('Lakala onboarding ' . $path . ' headers error: ' . json_encode($e->getResponseHeaders()));
+            }
             throw new \RuntimeException('拉卡拉接口请求失败:' . $e->getMessage());
         }
     }

+ 2 - 0
console/config/main.php

@@ -17,6 +17,8 @@ return [
                 [
                     'class' => 'yii\log\FileTarget',
                     'levels' => ['info', 'error', 'warning'],
+                    // 决定了日志记录时会额外保存哪些 PHP 超全局变量
+                    'logVars' => [], // ['_GET', '_POST', '_FILES', '_SESSION', '_SERVER']
                 ],
             ],
         ],

+ 46 - 0
console/controllers/LakalaAccountController.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace console\controllers;
+
+use bizGhs\lakala\classes\LakalaAccountClass;
+use yii\console\Controller;
+use yii\console\ExitCode;
+
+class LakalaAccountController extends Controller
+{
+    /**
+     * Refresh Lakala authorization states from CLI.
+     *
+     * Usage: ./yii lakala-account/refresh-authorize-states 1,2,3
+     *
+     * @param string $accountIds Comma-separated account IDs.
+     * @return int
+     */
+    public function actionRefreshAuthorizeStates($accountIds)
+    {
+        $values = explode(',', (string)$accountIds);
+        $ids = [];
+        foreach ($values as $value) {
+            $value = trim($value);
+            if ($value === '' || !ctype_digit($value) || (int)$value <= 0) {
+                $this->stderr('Invalid Lakala account ID: ' . $value . PHP_EOL);
+                return ExitCode::USAGE;
+            }
+            $ids[] = (int)$value;
+        }
+        $ids = array_values(array_unique($ids));
+        if (empty($ids)) {
+            $this->stderr('At least one Lakala account ID is required.' . PHP_EOL);
+            return ExitCode::USAGE;
+        }
+
+        $failed = 0;
+        foreach ($ids as $accountId) {
+            if (!LakalaAccountClass::refreshAuthorizeStates($accountId)) {
+                $failed++;
+            }
+        }
+        $this->stdout('Lakala authorize refresh completed: total=' . count($ids) . ', failed=' . $failed . ', accountIds=' . $accountIds . PHP_EOL);
+        return $failed === 0 ? ExitCode::OK : ExitCode::UNSPECIFIED_ERROR;
+    }
+}

+ 1 - 1
scripts/sql/add_lakala_onboarding.sql

@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS `xhLakalaAccount` (
     `accountName` varchar(100) NOT NULL DEFAULT '' COMMENT '银行结算账户开户名',
     `accountNo` varchar(64) NOT NULL DEFAULT '' COMMENT '银行结算账号',
     `bankName` varchar(150) NOT NULL DEFAULT '' COMMENT '开户银行名称',
-    `accountType` varchar(8) NOT NULL DEFAULT '' COMMENT '结算账户类型:对公或对私',
+    `accountType` varchar(8) NOT NULL DEFAULT '' COMMENT '结算账户类型:58对公、59对私',
     `licenseName` varchar(150) NOT NULL DEFAULT '' COMMENT '营业执照名称',
     `identityExpire` varchar(32) NOT NULL DEFAULT '' COMMENT '法人或结算人身份证有效期',
     `scanTermNo` varchar(64) NOT NULL DEFAULT '' COMMENT 'WECHAT_PAY扫码终端号',