connection = app('db')->connection( (string) config('upstream_mirrors.connection', 'business_mirror') ); } /** 切换要同步的钉钉流程配置键(复用同一套同步链路)。 */ public function forConfigKey(string $configKey): self { $this->configKey = $configKey; return $this; } /** @return array{status:string,mode:string,batches:int,rows:int,dataset:string} */ public function sync(bool $full = false, ?int $batchSize = null, ?int $maxBatches = null): array { $config = (array) config('upstream_mirrors.' . $this->configKey, []); $dataset = (string) ($config['dataset_key'] ?? 'operations_replica.rpt.ding_payable_workflows'); $target = (string) ($config['target_table'] ?? 'ding_payable_workflow_mirror'); $source = (string) ($config['source_key'] ?? 'operations_replica'); $processCode = (string) ($config['process_code'] ?? ''); if ($processCode === '') { throw new RuntimeException('ding_payable_workflow_mirror_not_initialized'); } $this->ensureMirrorTable($target); UpstreamOdbcConnection::reset($source); $state = $this->state($dataset); if (! $full && ($state['initial_completed_at'] ?? null) === null) { return ['status' => 'requires_full_sync', 'mode' => 'incremental', 'batches' => 0, 'rows' => 0, 'dataset' => $dataset]; } $mode = $full ? 'full' : 'incremental'; $stateCursor = $this->decodeJson($state['cursor'] ?? null); $highWatermark = $this->cursorFromState($this->decodeJson($state['high_watermark'] ?? null)); $resume = is_array($state) && ($state['run_mode'] ?? null) === $mode && in_array($state['status'] ?? null, ['running', 'failed'], true) && is_string($state['generation'] ?? null) && $this->isCompatibleCursor($stateCursor); $generation = $resume ? (string) $state['generation'] : (string) Str::uuid(); [$windowStart, $windowEnd] = $this->window($state, $mode, $resume); $cursor = $resume ? $this->cursorFromState($stateCursor) : null; $rows = $resume ? (int) ($state['last_upserted_rows'] ?? 0) : 0; $batchSize = min(max($batchSize ?? (int) ($config['batch_size'] ?? 100), 1), 500); $maxBatches = max($maxBatches ?? (int) ($config['max_batches_per_run'] ?? 2), 0); $this->start($dataset, $target, $source, $mode, $generation, $resume, $windowStart, $windowEnd); $batches = 0; try { while (true) { $workflows = $this->readWorkflows($source, $processCode, $cursor, $windowStart, $windowEnd, $batchSize); if ($workflows === []) { $this->complete($dataset, $target, $mode, $generation, $rows, $cursor ?? $highWatermark); return ['status' => 'succeeded', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows, 'dataset' => $dataset]; } $ids = array_values(array_filter(array_map( static fn (array $row): string => (string) ($row['processInstanceId'] ?? ''), $workflows, ))); $originatorIds = array_values(array_unique(array_filter(array_map( static fn (array $row): string => trim((string) ($row['originatorUserId'] ?? '')), $workflows, )))); $schemes = $this->readRelated($source, 'ding_WorkflowScheme', ['id', 'processInstanceId', 'componentType', 'name', 'value', 'extValue'], $ids, 'id'); $tasks = $this->readRelated($source, 'ding_WorkflowTask', ['processInstanceId', 'result', 'activityId', 'finishTime', 'createTime', 'userId', 'taskId', 'status', 'id'], $ids, 'id'); $originatorNames = $this->readOriginatorNames($source, $originatorIds); $schemeById = $this->groupBy($schemes, 'processInstanceId'); $taskById = $this->groupBy($tasks, 'processInstanceId'); $now = now(); $payloads = []; $extendColumns = ! empty($config['extend_columns']); $columnMap = $extendColumns ? (array) ($config['column_map'] ?? []) : []; $columnTypes = $extendColumns ? (array) ($config['column_types'] ?? []) : []; $detailTable = $extendColumns ? trim((string) ($config['detail_table'] ?? '')) : ''; $resolveProductName = $extendColumns && ! empty($config['resolve_product_name']); $rowFilters = $extendColumns ? (array) ($config['row_filters'] ?? []) : []; $onlyStatus = strtoupper(trim((string) ($config['only_status'] ?? ''))); foreach ($workflows as $workflow) { $id = (string) ($workflow['processInstanceId'] ?? ''); if ($id === '') { continue; } // 工作流级状态过滤:只保留指定审批状态(默认只要“已完成/已通过”), // 未通过/已拒绝/撤回等整单不写入镜像。置空 only_status 则不限状态。 if ($onlyStatus !== '' && DingPayableWorkflowStatus::key($workflow) !== $onlyStatus) { continue; } $originatorId = trim((string) ($workflow['originatorUserId'] ?? '')); $originatorName = trim((string) ($originatorNames[$originatorId] ?? '')); if ($originatorName !== '') { // 保留钉钉原始用户 ID,同时把稳定的中文姓名写入镜像, // 页面无需在请求期间再次直连上游或猜测标题内容。 $workflow['originatorUserName'] = $originatorName; } $schemeRows = $schemeById[$id] ?? []; $detailFieldsList = $this->buildDetailRowFields($schemeRows, $detailTable); $detailIndex = 0; foreach ($detailFieldsList as $fields) { $payload = [ 'process_instance_id' => $id, 'workflow_payload' => json_encode($workflow, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), 'scheme_payload' => json_encode($schemeRows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), 'task_payload' => json_encode($taskById[$id] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), 'workflow_created_at' => isset($workflow['createAt']) ? (string) $workflow['createAt'] : null, 'sync_generation' => $generation, 'synced_at' => $now, ]; if ($extendColumns) { foreach ($columnMap as $column => $label) { $payload[$column] = $this->castColumn($fields[$label] ?? null, (string) ($columnTypes[$column] ?? 'string')); } // 行级过滤(如“收款人全称”限定):不匹配的明细行直接跳过, // 不写入镜像,也不占用 detail_index,保证保留行编号连续。 if ($rowFilters !== [] && ! $this->matchesRowFilters($payload, $rowFilters)) { continue; } $payload['detail_index'] = $detailIndex; } $payloads[] = $payload; $detailIndex++; } } if ($resolveProductName && $payloads !== []) { $this->resolveProductNames($payloads); } $nextCursor = $this->cursorFromWorkflow($workflows[array_key_last($workflows)]); if ($nextCursor === null) { throw new RuntimeException('ding_payable_workflow_missing_watermark'); } $cursor = $nextCursor; $rows += count($payloads); $batches++; $upsertKeys = $extendColumns ? ['process_instance_id', 'detail_index'] : ['process_instance_id']; $updateColumns = ['workflow_payload', 'scheme_payload', 'task_payload', 'workflow_created_at', 'sync_generation', 'synced_at']; if ($extendColumns) { $updateColumns = array_merge($updateColumns, array_keys($columnMap)); if ($resolveProductName) { $updateColumns[] = 'product_name'; } } $this->connection->transaction(function () use ($dataset, $target, $payloads, $cursor, $generation, $rows, $now, $upsertKeys, $updateColumns, $extendColumns, $ids): void { // A changed workflow may have fewer details or no longer match // the approved status/payee. Replace its rows atomically. if ($extendColumns && $ids !== []) { $this->connection->table($target)->whereIn('process_instance_id', $ids)->delete(); } if ($payloads !== []) { $this->connection->table($target)->upsert( $payloads, $upsertKeys, $updateColumns, ); } $this->connection->table('business_mirror_table_states') ->where('dataset_key', $dataset) ->update([ 'cursor' => $cursor === null ? null : json_encode($cursor, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'last_upserted_rows' => $rows, 'updated_at' => $now, ]); }); if (count($workflows) < $batchSize) { $this->complete($dataset, $target, $mode, $generation, $rows, $cursor); return ['status' => 'succeeded', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows, 'dataset' => $dataset]; } if ($maxBatches > 0 && $batches >= $maxBatches) { return ['status' => 'running', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows, 'dataset' => $dataset]; } } } catch (Throwable $exception) { $this->connection->table('business_mirror_table_states') ->where('dataset_key', $dataset) ->update(['status' => 'failed', 'last_error_code' => mb_substr($exception::class, 0, 120), 'updated_at' => now()]); throw $exception; } } private function ensureMirrorTable(string $target): void { $schema = Schema::connection($this->connection->getName()); if ($schema->hasTable($target)) { return; } $config = (array) config('upstream_mirrors.' . $this->configKey, []); if (! empty($config['extend_columns'])) { $this->createExtendedMirrorTable($schema, $target); return; } $schema->create($target, function (Blueprint $table): void { $table->string('process_instance_id', 100)->primary(); $table->json('workflow_payload'); $table->json('scheme_payload'); $table->json('task_payload')->nullable(); $table->string('workflow_created_at', 32)->nullable()->index(); $table->uuid('sync_generation')->nullable()->index(); $table->dateTime('synced_at', 6); }); } /** * 扩展镜像表:在原始 JSON 载荷之外抽取宽表列并建立索引,便于按商品ID * 关联商品目录、按单据号/店铺筛选。结构与对应迁移保持一致。 */ private function createExtendedMirrorTable(Builder $schema, string $target): void { $schema->create($target, function (Blueprint $table): void { $table->string('process_instance_id', 100); $table->unsignedTinyInteger('detail_index')->default(0); $table->string('product_id', 64)->nullable()->index(); $table->string('daifa_trade_no', 64)->nullable()->index(); $table->string('shop_trade_no', 64)->nullable()->index(); $table->string('shop_name', 120)->nullable()->index(); $table->string('payee_name', 120)->nullable()->index(); $table->string('supplier_name', 120)->nullable(); $table->string('material_no', 120)->nullable(); $table->string('product_name', 255)->nullable()->comment('按商品ID关联商品目录得到的名称'); $table->unsignedInteger('ship_qty')->nullable(); $table->decimal('paid_amount', 14, 2)->nullable(); $table->decimal('daifa_amount', 14, 2)->nullable(); $table->decimal('price_diff', 14, 2)->nullable(); $table->json('workflow_payload'); $table->json('scheme_payload'); $table->json('task_payload')->nullable(); $table->string('workflow_created_at', 32)->nullable()->index(); $table->uuid('sync_generation')->nullable()->index(); $table->dateTime('synced_at', 6); $table->primary(['process_instance_id', 'detail_index']); }); } /** @return array|null */ private function state(string $dataset): ?array { $row = $this->connection->table('business_mirror_table_states')->where('dataset_key', $dataset)->first(); if ($row === null) { return null; } $state = (array) $row; $state['cursor'] = is_string($state['cursor'] ?? null) ? (json_decode($state['cursor'], true) ?: []) : ($state['cursor'] ?? []); return $state; } private function start( string $dataset, string $target, string $source, string $mode, string $generation, bool $resume, ?Carbon $windowStart, ?Carbon $windowEnd, ): void { $now = now(); $values = [ 'source_key' => $source, 'source_object' => 'rpt.ding_Workflow + rpt.ding_WorkflowScheme + rpt.ding_WorkflowTask', 'target_table' => $target, 'status' => 'running', 'run_mode' => $mode, 'generation' => $generation, 'last_attempted_at' => $now, 'last_error_code' => null, 'updated_at' => $now, ]; if (! $resume) { $values += [ 'cursor' => null, 'window_started_at' => $windowStart, 'window_ends_at' => $windowEnd, 'last_upserted_rows' => 0, ]; } $query = $this->connection->table('business_mirror_table_states')->where('dataset_key', $dataset); $query->exists() ? $query->update($values) : $this->connection->table('business_mirror_table_states')->insert(['dataset_key' => $dataset, 'created_at' => $now] + $values); } private function complete(string $dataset, string $target, string $mode, string $generation, int $rows, ?array $cursor): void { $now = now(); $this->connection->transaction(function () use ($dataset, $target, $mode, $generation, $rows, $cursor, $now): void { if ($mode === 'full') { $this->connection->table($target)->where(function ($query) use ($generation): void { $query->whereNull('sync_generation')->orWhere('sync_generation', '!=', $generation); })->delete(); } $values = [ 'status' => 'succeeded', 'cursor' => null, 'high_watermark' => $cursor === null ? null : json_encode($cursor, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'last_succeeded_at' => $now, 'last_upserted_rows' => $rows, 'mirror_row_count' => $this->connection->table($target)->count(), 'last_error_code' => null, 'updated_at' => $now, ]; if ($mode === 'full') { $values['initial_completed_at'] = $now; $values['last_full_succeeded_at'] = $now; } $this->connection->table('business_mirror_table_states')->where('dataset_key', $dataset)->update($values); }); } /** @return array */ private function decodeJson(mixed $value): array { if (is_array($value)) { return $value; } if (! is_string($value) || $value === '') { return []; } $decoded = json_decode($value, true); return is_array($decoded) ? $decoded : []; } /** @return list> */ private function readWorkflows( string $source, string $processCode, ?array $cursor, ?Carbon $windowStart, ?Carbon $windowEnd, int $limit, ): array { $watermark = 'COALESCE([updateAt], [createAt])'; $where = '[processCode] = ?'; $bindings = [$processCode]; if ($windowStart !== null && $windowEnd !== null) { $where .= ' AND ' . $watermark . ' >= ? AND ' . $watermark . ' < ?'; $bindings[] = $this->sqlServerDateTime($windowStart); $bindings[] = $this->sqlServerDateTime($windowEnd); } if ($cursor !== null) { $where .= ' AND (' . $watermark . ' > ? OR (' . $watermark . ' = ? AND [processInstanceId] > ?))'; $bindings[] = (string) ($cursor['updateAt'] ?? ''); $bindings[] = (string) ($cursor['updateAt'] ?? ''); $bindings[] = (string) ($cursor['processInstanceId'] ?? ''); } return $this->queries->forSource($source)->select( 'SELECT TOP (' . $limit . ') [finishTime],[businessId],[title],[result],[originatorUserId],[originatorDeptId],[originatorDeptName],[status],[bizAction],[processInstanceId], [joinAt], COALESCE([updateAt], [createAt]) AS [updateAt], [createAt], [processCode], [flowType] FROM [rpt].[ding_Workflow] WHERE ' . $where . ' ORDER BY ' . $watermark . ', [processInstanceId]', $bindings, ); } /** @return array{0:?Carbon,1:?Carbon} */ private function window(?array $state, string $mode, bool $resume): array { if ($mode === 'full') { return [null, null]; } if ($resume && ($state['window_started_at'] ?? null) !== null && ($state['window_ends_at'] ?? null) !== null) { return [ Carbon::parse((string) $state['window_started_at']), Carbon::parse((string) $state['window_ends_at']), ]; } $overlap = max((int) config('upstream_mirrors.table_sync.overlap_hours', 48), 1); $lastSucceeded = ($state['last_succeeded_at'] ?? null) !== null ? Carbon::parse((string) $state['last_succeeded_at']) : now(); return [$lastSucceeded->copy()->subHours($overlap), now()->addSecond()]; } /** @return array{updateAt:string,processInstanceId:string}|null */ private function cursorFromState(array $cursor): ?array { if (($cursor['updateAt'] ?? '') === '' && ($cursor['processInstanceId'] ?? '') === '') { return null; } if (($cursor['updateAt'] ?? '') === '') { return null; } return [ 'updateAt' => (string) $cursor['updateAt'], 'processInstanceId' => (string) ($cursor['processInstanceId'] ?? ''), ]; } /** @param array $workflow @return array{updateAt:string,processInstanceId:string}|null */ private function cursorFromWorkflow(array $workflow): ?array { $id = trim((string) ($workflow['processInstanceId'] ?? '')); $updateAt = trim((string) ($workflow['updateAt'] ?? $workflow['createAt'] ?? '')); if ($id === '' || $updateAt === '') { return null; } return ['updateAt' => $updateAt, 'processInstanceId' => $id]; } private function isCompatibleCursor(array $cursor): bool { return $cursor === [] || array_key_exists('updateAt', $cursor); } private function sqlServerDateTime(Carbon $value): string { return $value->format('Y-m-d\TH:i:s.v'); } /** @param list $ids @param list $columns @return list> */ private function readRelated(string $source, string $table, array $columns, array $ids, string $order): array { if ($ids === []) { return []; } $placeholders = implode(',', array_fill(0, count($ids), '?')); return $this->queries->forSource($source)->select( 'SELECT ' . implode(', ', array_map(static fn (string $column): string => '[' . $column . ']', $columns)) . ' FROM [rpt].[' . $table . '] WHERE [processInstanceId] IN (' . $placeholders . ') ORDER BY [' . $order . ']', $ids, ); } /** @param list $userIds @return array */ private function readOriginatorNames(string $source, array $userIds): array { if ($userIds === []) { return []; } $names = []; try { $names = $this->readUserNameRows($source, 'hr_employee', ['userid', 'name'], $userIds); } catch (Throwable) { // 姓名目录短暂不可用时不应阻断应付流程同步;页面会回退显示原始用户 ID。 } $missing = array_values(array_diff($userIds, array_keys($names))); if ($missing !== []) { try { // 已离职申请人不一定还存在于 hr_employee,使用同一只读来源的 // 离职视图补齐姓名;两个查询均为固定字段和参数化 IN 条件。 $retired = $this->readUserNameRows($source, 'hr_employee_dimission', ['userid', 'realName', 'name'], $missing); $names += $retired; } catch (Throwable) { // 离职目录不可用时保留已解析的在职姓名。 } } return $names; } /** @param list $columns @param list $userIds @return array */ private function readUserNameRows(string $source, string $table, array $columns, array $userIds): array { $placeholders = implode(',', array_fill(0, count($userIds), '?')); $rows = $this->queries->forSource($source)->select( 'SELECT ' . implode(', ', array_map(static fn (string $column): string => '[' . $column . ']', $columns)) . ' FROM [rpt].[' . $table . '] WHERE [userid] IN (' . $placeholders . ')', $userIds, ); $names = []; foreach ($rows as $row) { $id = trim((string) ($row['userid'] ?? $row['userId'] ?? '')); $name = trim((string) ($row['name'] ?? $row['realName'] ?? '')); if ($id !== '' && $name !== '') { $names[$id] = $name; } } return $names; } /** @param list> $rows @return array>> */ private function groupBy(array $rows, string $column): array { $grouped = []; foreach ($rows as $row) { $key = (string) ($row[$column] ?? ''); if ($key !== '') { $grouped[$key][] = $row; } } return $grouped; } /** * 从表单 scheme 解析每一行的“字段名 -> 值”映射。 * * 若配置了明细表格字段名,则按该表格的每一行展开(并合并顶层字段, * 如店铺名称),否则整张表单视为单行。返回数组长度即镜像明细行数。 * * @param array> $schemeRows * @return list> */ private function buildDetailRowFields(array $schemeRows, string $detailTable): array { $topLevel = $this->formFieldLookup($schemeRows); if ($detailTable === '') { return [$topLevel]; } $grid = null; foreach ($schemeRows as $row) { if (! is_array($row)) { continue; } if (trim((string) ($row['name'] ?? '')) === $detailTable) { $value = $row['value'] ?? null; // 钉钉明细表格在上游/镜像中以 JSON 字符串存储,需先解码为数组再按行展开。 if (is_string($value)) { $decoded = json_decode($value, true); $value = is_array($decoded) ? $decoded : null; } $grid = is_array($value) ? $value : null; break; } } if ($grid === null) { return [$topLevel]; } $rows = []; foreach ($grid as $gridRow) { if (! is_array($gridRow) || ! isset($gridRow['rowValue']) || ! is_array($gridRow['rowValue'])) { continue; } $cellLookup = []; foreach ($gridRow['rowValue'] as $cell) { if (! is_array($cell) || ! isset($cell['label'])) { continue; } $cellLookup[(string) $cell['label']] = $this->scalarValue($cell['value'] ?? null); } $rows[] = array_merge($topLevel, $cellLookup); } return $rows === [] ? [$topLevel] : $rows; } /** @param array> $schemeRows @return array */ private function formFieldLookup(array $schemeRows): array { $lookup = []; foreach ($schemeRows as $row) { if (! is_array($row)) { continue; } $name = trim((string) ($row['name'] ?? '')); if ($name === '') { continue; } $lookup[$name] = $this->scalarValue($row['value'] ?? null); } return $lookup; } private function scalarValue(mixed $value): mixed { if (is_scalar($value) || $value === null) { return $value; } return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } /** * 行级过滤:按“镜像列 => 目标值”精确匹配(去首尾空白)。任一条件不满足即 * 返回 false,该明细行不写入镜像。目标值为空表示不对该列过滤;过滤集合为 * 空则全部通过。用于把“线上代发业务报销”限定到指定收款人全称等场景。 * * @param array $payload * @param array $filters */ private function matchesRowFilters(array $payload, array $filters): bool { foreach ($filters as $column => $expected) { $expected = trim((string) $expected); if ($expected === '') { continue; } if (trim((string) ($payload[$column] ?? '')) !== $expected) { return false; } } return true; } private function castColumn(mixed $value, string $type): mixed { if ($value === null || $value === '') { return null; } return match ($type) { 'int' => is_numeric($value) ? (int) $value : null, 'decimal' => is_numeric($value) ? (float) $value : null, default => is_scalar($value) ? (string) $value : json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), }; } /** * 同步阶段按商品ID(钉钉表单“商品ID”即 ops_jst_sku_map.shop_i_id)关联商品目录, * 将已匹配的商品名称写入镜像宽表的 product_name 列。页面只读镜像,不在此处临时 * JOIN 2500 万行的商品映射表,避免页面查询超时。所有商品ID一次性批量关联, * 未命中的再回退到 ops_jst_sku 的 i_id/sku_id。 * * @param list> $payloads */ private function resolveProductNames(array &$payloads): void { $ids = []; foreach ($payloads as $payload) { $id = trim((string) ($payload['product_id'] ?? '')); if ($id !== '' && ! isset($ids[$id])) { $ids[$id] = true; } } if ($ids === []) { return; } $idList = array_keys($ids); $names = []; if ($this->connection->getSchemaBuilder()->hasTable('ops_jst_sku_map')) { $rows = $this->guardedQuery(4000, fn () => $this->connection->table('ops_jst_sku_map') ->whereIn('shop_i_id', $idList) ->select(['shop_i_id', 'sku_id', 'i_id', 'name']) ->limit(5000) ->get()); foreach ($rows as $row) { $id = (string) $row->shop_i_id; if (! isset($names[$id])) { $name = $this->cleanProductName($row->name ?? null); if ($name !== null) { $names[$id] = $name; } } } } $missing = array_values(array_filter($idList, static fn (string $id): bool => ! isset($names[$id]))); if ($missing !== [] && $this->connection->getSchemaBuilder()->hasTable('ops_jst_sku')) { $rows = $this->guardedQuery(4000, fn () => $this->connection->table('ops_jst_sku') ->whereIn('i_id', $missing) ->orWhereIn('sku_id', $missing) ->select(['i_id', 'sku_id', 'name']) ->limit(5000) ->get()); foreach ($rows as $row) { foreach ([(string) $row->i_id, (string) $row->sku_id] as $candidate) { if ($candidate !== '' && isset($names[$candidate]) === false) { $name = $this->cleanProductName($row->name ?? null); if ($name !== null) { $names[$candidate] = $name; } } } } } foreach ($payloads as &$payload) { $id = trim((string) ($payload['product_id'] ?? '')); $payload['product_name'] = $id !== '' && isset($names[$id]) ? $names[$id] : null; } unset($payload); } private function cleanProductName(mixed $value): ?string { if ($value === null) { return null; } $name = trim((string) $value); return $name === '' ? null : $name; } /** * 对可能命中大表的查询加会话级超时,避免索引缺失时整批同步被拖垮。 * 超时后该批商品名称留空,页面仍可正常展示(仅缺失匹配名),不影响同步完成。 * * @template T * @param callable():T $callback * @return T */ private function guardedQuery(int $milliseconds, callable $callback) { $conn = $this->connection; try { $conn->statement('SET SESSION MAX_EXECUTION_TIME=' . $milliseconds); return $callback(); } catch (Throwable $exception) { // 超时(SQLSTATE HY000/3024)或其它只读异常:放弃匹配该批商品名称, // 不让同步链路中断。 return []; } finally { try { $conn->statement('SET SESSION MAX_EXECUTION_TIME=0'); } catch (Throwable) { // 忽略重置失败。 } } } }