From 75054454fc5eecc16bfa73a55113009720e5a4b5 Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Mon, 2 Nov 2026 18:13:45 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96OA=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E7=AD=89=E5=BE=85=E4=B8=8E=E5=B9=B6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/dingtalk.php | 1 + deploy/activate-incremental-release.sh | 2 +- deploy/activate-release.sh | 2 +- deploy/run-finance-batch.sh | 11 +- docs/上游业务镜像清单.md | 2 +- .../DingProcessDetailMirrorSynchronizer.php | 83 ++++++++++++ .../DingTalkProcessInstanceClient.php | 127 +++++++++++++++++- .../process-details-content.blade.php | 37 ++++- scripts/install-ding-process-sync-cron.ps1 | 4 +- .../Deployment/BusinessMirrorScheduleTest.php | 5 + .../Deployment/FinanceBatchIsolationTest.php | 51 ++++++- ...ingProcessDetailMirrorSynchronizerTest.php | 34 +++++ .../DingTalkProcessInstanceClientTest.php | 66 +++++++++ .../ProcessDetailsPageDataTest.php | 4 + 14 files changed, 410 insertions(+), 19 deletions(-) create mode 100644 tests/Unit/EcommerceFinance/DingTalkProcessInstanceClientTest.php diff --git a/config/dingtalk.php b/config/dingtalk.php index 46e6fdb2..457b4a92 100644 --- a/config/dingtalk.php +++ b/config/dingtalk.php @@ -40,5 +40,6 @@ return [ 'user_detail_url' => env('DINGTALK_USER_DETAIL_URL', 'https://oapi.dingtalk.com/topapi/v2/user/get'), 'timeout' => (int) env('DINGTALK_HTTP_TIMEOUT', 10), + 'process_concurrency' => (int) env('DINGTALK_PROCESS_CONCURRENCY', 5), 'state_ttl_minutes' => (int) env('DINGTALK_STATE_TTL_MINUTES', 5), ]; diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index b1335561..ee3ccf31 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -231,7 +231,7 @@ CRON_TZ=Asia/Shanghai 30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet >/dev/null 2>&1 30 10 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-shop-data --balance-only --quiet >/dev/null 2>&1 # 财务流程明细每周完整校准一次,修正超过 48 小时重叠窗口才出现的上游改单或晚到付款。 -20 2 * * 0 root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-ding-process-details --full --quiet >/dev/null 2>&1 +20 2 * * 0 root /usr/bin/flock -w 3600 /run/caiwu-ding-process-details.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-ding-process-details --full --quiet >/dev/null 2>&1 # 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。 * * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:auto-resync --quiet >/dev/null 2>&1 * * * * * root /usr/bin/flock -n /run/caiwu-account-bills-export.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh account-bills:exports:run --quiet >/dev/null 2>&1 diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index ff208a2b..b0fb396e 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -235,7 +235,7 @@ CRON_TZ=Asia/Shanghai 30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet >/dev/null 2>&1 30 10 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-shop-data --balance-only --quiet >/dev/null 2>&1 # 财务流程明细每周完整校准一次,修正超过 48 小时重叠窗口才出现的上游改单或晚到付款。 -20 2 * * 0 root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-ding-process-details --full --quiet >/dev/null 2>&1 +20 2 * * 0 root /usr/bin/flock -w 3600 /run/caiwu-ding-process-details.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-ding-process-details --full --quiet >/dev/null 2>&1 # 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。 * * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:auto-resync --quiet >/dev/null 2>&1 * * * * * root /usr/bin/flock -n /run/caiwu-account-bills-export.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh account-bills:exports:run --quiet >/dev/null 2>&1 diff --git a/deploy/run-finance-batch.sh b/deploy/run-finance-batch.sh index 683581d7..0a90b63f 100644 --- a/deploy/run-finance-batch.sh +++ b/deploy/run-finance-batch.sh @@ -2,7 +2,8 @@ set -eu REMOTE_DIR="${CAIWU_REMOTE_DIR:-/opt/caiwuxitong}" -LOCK_FILE="${CAIWU_UPSTREAM_LOCK:-/run/caiwu-upstream-sync.lock}" +GENERAL_LOCK_FILE="${CAIWU_GENERAL_LOCK:-/run/caiwu-upstream-sync.lock}" +OA_LOCK_FILE="${CAIWU_OA_LOCK:-/run/caiwu-oa-process.lock}" is_import=0 CAIWU_BATCH_CALLER_DIR="$PWD" export CAIWU_BATCH_CALLER_DIR @@ -11,6 +12,14 @@ case "${1:-}" in jushuitan:sync-shipping|store-receipt:order-match:sync|store-receipt:product-cost:sync|store-receipt:auto-resync|order-data:warm-shop-candidates|business-mirror:sync-tables|business-mirror:sync-ding-process-details|business-mirror:process-ding-refresh-requests|business-mirror:index-work-order-waybills|business-mirror:refresh-paid-sales|account-bills:exports:run|finance-ledger:recalculate|product:monthly-weighted-average:auto|product:monthly-weighted-average:compute|product:monthly-weighted-average:report|finance:monthly-weighted-product-cost:auto) ;; *) echo 'Unsupported finance batch command' >&2; exit 64 ;; esac +case "${1:-}" in + business-mirror:sync-ding-process-details|business-mirror:process-ding-refresh-requests) + LOCK_FILE="${CAIWU_UPSTREAM_LOCK:-$OA_LOCK_FILE}" + ;; + *) + LOCK_FILE="${CAIWU_UPSTREAM_LOCK:-$GENERAL_LOCK_FILE}" + ;; +esac cd "$REMOTE_DIR" if [ "$1" = 'account-bills:exports:run' ]; then storage_root="$(docker inspect caiwu_app --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/storage"}}{{.Source}}{{end}}{{end}}')" diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 7e2fa230..9b7a1faa 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -36,7 +36,7 @@ | 本次接入 | 流程明细读取 `operations_replica/rpt.ding_Workflow + rpt.ding_WorkflowScheme` 的结构化审批主表、费用明细和付款明细,写入 `ding_process_detail_mirror`、`ding_process_payment_mirror` 与付款按明细行分摊的 `ding_process_payment_line_mirror`;主表的 `originatorDeptName` 作为发起人部门单独保留。禁止先把明细表和付款表铺平成 CSV 后逐行求和;付款明细必须按“对应序号”关联到代表明细行,但合计始终读取独立付款事实。管理费用按分摊后的付款月份统计,跨月分笔付款不会把整张审批重复计入多月。每个纳入范围的源审批至少保留一条页面记录,没有费用金额的审批以空金额显示,不静默隐藏。首次全量先运行 `business-mirror:sync-ding-process-details --full --dry-run` 校验,再使用 `business-mirror:sync-ding-process-details --full` 发布;日常通过 `updateAt/createAt` 48 小时重叠增量同步。当前纳入日常报销、应付、线上代发、种菜、快递面单、固定资产、日常物品、样品、一次性采买、板材、工资、外贸费用和种菜充值等 14 个财务流程;发现新的明细流程编码时全量同步报错并保留上一版。 | | 本次接入 | 财务经营报表 › 管理费用使用 `oa_process_account_mapping` 保存流程科目映射;当前快照包含原工作簿 191 个科目和补充的 `5601.11.01 货代费`、`1221.07 种菜备用金`、`5602.11.03.19 AI阿里云软件费`,共 194 个科目,其中 37 个进费比科目和 126 个分摊规则。页面按打款时间提供整月选择并默认展示上个月 OA 流程中审批状态为“已完成”、涉及付款且出纳已打款的明细,不涉及付款或出纳未打款的数据不进入汇总,按费用大类和二级汇总进费比/不进费比/未映射金额,点击二级查看三级汇总与 9 列明细模板。审批完成时间写入 `ding_process_detail_mirror.approval_finished_at`,页面月份筛选使用 `ding_process_detail_mirror.paid_at`;钉钉副本把费用明细表保存为未命名字段时,按配置中的金额列识别包含该列的未命名表格组件,避免只抓到付款明细而漏掉费用科目和金额。明细行没有科目时,使用同一审批表单表头的 `科目编码 / 科目名称` 回填;行级科目始终优先。 | -流程明细另设独立定时任务,避免与每分钟科目映射消费争用同一上游目录时被静默跳过。正式发布脚本会覆盖安装 `/etc/cron.d/caiwu-ding-process-details`:每 5 分钟尝试运行 `business-mirror:sync-ding-process-details`,使用独立调度锁并在共用上游锁上最多等待 15 分钟;`scripts/install-ding-process-sync-cron.ps1` 仅作为手工修复入口。任务时间和镜像状态统一使用平台固定时区,审批等待时长按实际小时展示。OA 页面每小时最多把一次当前页“审批中”的审批编号写入定向刷新队列,由每分钟检查一次的批处理命令消费;其他时间直接展示最近镜像,已完成流程不进入即时刷新链路,仍由常规增量和每周全量校准处理付款等后续变化。增量同步除跟进审批主表更新时间外,还以 `ding_WorkflowScheme` 的数字 `id` 高水位发现后补表单行,避免付款明细等新组件写入后主审批 `updateAt` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。付款状态只按钉钉“出纳打款”节点判断,分为“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”。金额差异不参与付款状态判断,另以“付款差额 = 费用合计 − 已付款合计”展示。不能因为金额存在少量差异就覆盖出纳节点事实,也不能因为出纳已通过就隐藏金额差异。 +流程明细另设独立定时任务,避免与每分钟科目映射消费争用同一上游目录时被静默跳过。正式发布脚本会覆盖安装 `/etc/cron.d/caiwu-ding-process-details`:每 5 分钟尝试运行 `business-mirror:sync-ding-process-details`,页面定向刷新和一周一次的全量校准与增量同步共用 `/run/caiwu-oa-process.lock`,只在这些 OA 镜像任务之间排队,不再被店铺对账、商品同步等无关任务占用的全局上游锁阻塞;`scripts/install-ding-process-sync-cron.ps1` 仅作为手工修复入口。任务时间和镜像状态统一使用平台固定时区,审批等待时长按实际小时展示。OA 页面每小时最多把一次当前页“审批中”的审批编号写入定向刷新队列,由每分钟检查一次的批处理命令消费;其他时间直接展示最近镜像,已完成流程不进入即时刷新链路,仍由常规增量和每周全量校准处理付款等后续变化。进行中审批的钉钉接口按配置并发读取(默认 5 个、上限 10 个),单个审批失败不会丢弃同批成功结果。增量同步除跟进审批主表更新时间外,还以 `ding_WorkflowScheme` 的数字 `id` 高水位发现后补表单行,避免付款明细等新组件写入后主审批 `updateAt` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。付款状态只按钉钉“出纳打款”节点判断,分为“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”。金额差异不参与付款状态判断,另以“付款差额 = 费用合计 − 已付款合计”展示。不能因为金额存在少量差异就覆盖出纳节点事实,也不能因为出纳已通过就隐藏金额差异。 `operations_replica` 缺失付款组件或进行中流程需要刷新出纳节点时,同步器复用现有钉钉应用凭据调用 `https://api.dingtalk.com/v1.0/workflow/processInstances`。官方接口同时返回表单、`tasks.taskGroupName` 和 `operationRecords.showName`,用于识别“出纳打款”节点及退回/待处理/已通过状态。付款来源标记为 `api`,付款事实仍进入 `ding_process_payment_mirror`;不会用官方接口覆盖副本已有的业务明细,只补缺失或空的付款字段,并独立保存“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”,`cashier_checked_at` 用于记录近期已检查时间。自动增量每 5 分钟按 `process_instance_id` 游标轮转最多 50 个缺失或需要刷新出纳状态的流程,避免单个失败记录阻塞队列或瞬时打满接口;人工核查可执行 `business-mirror:sync-ding-process-details --business-id=审批编号1,审批编号2 --dry-run`,确认后再去掉 `--dry-run`。单次最多 50 个审批编号。 diff --git a/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php b/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php index 6cfb3be4..b31d27e3 100644 --- a/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php +++ b/modules/EcommerceFinance/Services/DingProcessDetailMirrorSynchronizer.php @@ -1010,6 +1010,7 @@ class DingProcessDetailMirrorSynchronizer $processInstanceIds = []; $processes = (array) config('upstream_mirrors.ding_process_details.processes', []); $scope = $this->scope(); + $this->prefetchApprovalSnapshots($workflows, $scheme, $processes); foreach ($workflows as $workflow) { $processCode = trim((string) ($workflow['processCode'] ?? '')); @@ -1142,6 +1143,88 @@ class DingProcessDetailMirrorSynchronizer ]; } + /** + * Warm the current-run snapshot cache in bounded parallel batches. The + * later normalization pass reuses these snapshots without extra API calls. + * + * @param list> $workflows + * @param array> $scheme + * @param array> $processes + */ + private function prefetchApprovalSnapshots( + array $workflows, + array $scheme, + array $processes, + ): void { + if (! $this->approvalClient->isConfigured() || ! $this->approvalApiAvailable) { + return; + } + + $remainingAttempts = self::APPROVAL_API_DRAIN_LIMIT - $this->approvalApiAttempts; + if ($remainingAttempts <= 0) { + return; + } + + $processInstanceIds = []; + foreach ($workflows as $workflow) { + $processInstanceId = trim((string) ($workflow['processInstanceId'] ?? '')); + $process = (array) ($processes[$workflow['processCode'] ?? ''] ?? []); + if ($processInstanceId === '' + || isset($this->approvalSnapshots[$processInstanceId]) + || $process === []) { + continue; + } + + $fields = (array) ($scheme[$processInstanceId] ?? []); + $paymentSourceState = $this->paymentSourceState($process, $fields); + if ($this->needsApprovalApiFallback($process, $fields, $paymentSourceState) + || $this->needsCashierApi($process, $workflow) + || $this->needsDetailAccountFallback($process, $fields) + || $this->status($workflow) === '审批中') { + $processInstanceIds[] = $processInstanceId; + } + } + + $processInstanceIds = array_slice( + array_values(array_unique($processInstanceIds)), + 0, + $remainingAttempts, + ); + if ($processInstanceIds === []) { + return; + } + + try { + $result = $this->approvalClient->processSnapshots($processInstanceIds); + } catch (Throwable $exception) { + $this->approvalApiAvailable = false; + Log::warning('钉钉官方审批接口批量读取失败。', [ + 'attempted' => count($processInstanceIds), + 'exception' => $exception::class, + ]); + + return; + } + + $this->approvalApiAttempts += (int) ($result['attempted'] ?? 0); + foreach ((array) ($result['snapshots'] ?? []) as $processInstanceId => $snapshot) { + if (is_array($snapshot)) { + $this->approvalSnapshots[(string) $processInstanceId] = $snapshot; + } + } + foreach ((array) ($result['errors'] ?? []) as $processInstanceId => $error) { + // 标记本轮已尝试,避免后续主流程对同一失败审批再次串行请求。 + $this->approvalSnapshots[(string) $processInstanceId] = []; + Log::warning('钉钉官方审批接口读取流程状态失败。', [ + 'process_instance_id' => (string) $processInstanceId, + 'error' => (string) $error, + ]); + } + if ((bool) ($result['fatal'] ?? false)) { + $this->approvalApiAvailable = false; + } + } + /** * @param array $workflow * @param array $process diff --git a/modules/EcommerceFinance/Services/DingTalkProcessInstanceClient.php b/modules/EcommerceFinance/Services/DingTalkProcessInstanceClient.php index 3e740127..2c45a49c 100644 --- a/modules/EcommerceFinance/Services/DingTalkProcessInstanceClient.php +++ b/modules/EcommerceFinance/Services/DingTalkProcessInstanceClient.php @@ -3,9 +3,12 @@ namespace Modules\EcommerceFinance\Services; use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\Pool; use Illuminate\Http\Client\RequestException; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Modules\EcommerceFinance\Exceptions\DingTalkProcessInstanceException; +use Throwable; class DingTalkProcessInstanceClient { @@ -83,13 +86,129 @@ class DingTalkProcessInstanceClient ); } - $result = $response->json('result'); - if (! is_array($result)) { + $snapshot = $this->normalizeSnapshot($response->json('result')); + if ($snapshot === null) { throw new DingTalkProcessInstanceException( 'DingTalk process instance response was incomplete.', ); } + return $this->snapshots[$processInstanceId] = $snapshot; + } + + /** + * Fetch a bounded batch concurrently while preserving one request per + * process instance. Individual HTTP errors do not hide successful rows. + * + * @param list $processInstanceIds + * @return array{ + * snapshots:array>, + * errors:array, + * fatal:bool, + * attempted:int + * } + */ + public function processSnapshots(array $processInstanceIds): array + { + $ids = array_values(array_unique(array_filter(array_map( + static fn (mixed $value): string => trim((string) $value), + $processInstanceIds, + )))); + $snapshots = []; + $pending = []; + foreach ($ids as $processInstanceId) { + if (isset($this->snapshots[$processInstanceId])) { + $snapshots[$processInstanceId] = $this->snapshots[$processInstanceId]; + } else { + $pending[] = $processInstanceId; + } + } + if ($pending === []) { + return [ + 'snapshots' => $snapshots, + 'errors' => [], + 'fatal' => false, + 'attempted' => 0, + ]; + } + + $accessToken = $this->appAccessToken(); + $errors = []; + $fatal = false; + $attempted = 0; + $concurrency = max(1, min(10, (int) config('dingtalk.process_concurrency', 5))); + + foreach (array_chunk($pending, $concurrency) as $chunk) { + $attempted += count($chunk); + try { + $responses = Http::acceptJson() + ->timeout($this->timeout()) + ->withHeaders([ + 'x-acs-dingtalk-access-token' => $accessToken, + ]) + ->pool(function (Pool $pool) use ($chunk, $accessToken): void { + foreach ($chunk as $index => $processInstanceId) { + $pool->as((string) $index) + ->acceptJson() + ->timeout($this->timeout()) + ->withHeaders([ + 'x-acs-dingtalk-access-token' => $accessToken, + ]) + ->get(self::PROCESS_INSTANCE_URL, [ + 'processInstanceId' => $processInstanceId, + ]); + } + }); + } catch (Throwable $exception) { + foreach ($chunk as $processInstanceId) { + $errors[$processInstanceId] = 'DingTalk process instance lookup failed.'; + } + $fatal = true; + break; + } + + foreach ($responses as $index => $response) { + $processInstanceId = $chunk[(int) $index] ?? ''; + if ($processInstanceId === '' || ! $response instanceof Response) { + continue; + } + if (! $response->successful()) { + $status = $response->status(); + $errors[$processInstanceId] = 'DingTalk process instance lookup returned HTTP ' + . $status . '.'; + $fatal = $fatal + || in_array($status, [401, 403, 429], true) + || $status >= 500; + continue; + } + + $snapshot = $this->normalizeSnapshot($response->json('result')); + if ($snapshot === null) { + $errors[$processInstanceId] = 'DingTalk process instance response was incomplete.'; + continue; + } + $snapshots[$processInstanceId] = $this->snapshots[$processInstanceId] = $snapshot; + } + if ($fatal) { + break; + } + } + + return [ + 'snapshots' => $snapshots, + 'errors' => $errors, + 'fatal' => $fatal, + 'attempted' => $attempted, + ]; + } + + /** @return array|null */ + private function normalizeSnapshot(mixed $result): ?array + { + if (! is_array($result)) { + return null; + } + $values = []; foreach ((array) ($result['formComponentValues'] ?? []) as $component) { if (! is_array($component)) { @@ -101,7 +220,7 @@ class DingTalkProcessInstanceClient } } - return $this->snapshots[$processInstanceId] = [ + return [ 'form_values' => $values, 'tasks' => array_values(array_filter( (array) ($result['tasks'] ?? []), @@ -136,7 +255,7 @@ class DingTalkProcessInstanceClient 'appsecret' => (string) config('dingtalk.client_secret'), ]) ->throw(); - } catch (RequestException $exception) { + } catch (ConnectionException|RequestException $exception) { throw new DingTalkProcessInstanceException( 'DingTalk application token lookup failed.', true, diff --git a/resources/views/business/pages/financial-operations/process-details-content.blade.php b/resources/views/business/pages/financial-operations/process-details-content.blade.php index ec0d547e..06dea1f2 100644 --- a/resources/views/business/pages/financial-operations/process-details-content.blade.php +++ b/resources/views/business/pages/financial-operations/process-details-content.blade.php @@ -1523,7 +1523,7 @@ const buttonDefaultTitle = button?.title || ''; const refreshIntervalMs = 60 * 60 * 1000; const pollIntervalMs = 2000; - const pollLimit = 90; + const pollLimit = 480; const storage = window.localStorage; const storageScope = [ window.location.pathname, @@ -1552,6 +1552,32 @@ } }; + const refreshProgressMessage = (refresh, manual) => { + const state = String(refresh?.status || ''); + const referenceAt = Date.parse( + state === 'processing' + ? String(refresh?.started_at || refresh?.created_at || '') + : String(refresh?.created_at || ''), + ); + const elapsedSeconds = Number.isFinite(referenceAt) + ? Math.max(0, Math.floor((Date.now() - referenceAt) / 1000)) + : null; + const elapsedText = elapsedSeconds === null + ? '' + : `(${elapsedSeconds < 60 ? `${elapsedSeconds}秒` : `${Math.floor(elapsedSeconds / 60)}分${elapsedSeconds % 60}秒`})`; + const prefix = manual ? '手动刷新' : '自动刷新'; + if (state === 'pending') { + return `${prefix}请求已入队,等待 OA 同步任务…${elapsedText}`; + } + if (state === 'processing') { + return `OA 同步任务正在读取钉钉最新状态…${elapsedText}`; + } + + return manual + ? '正在手动刷新当前页审批中的流程…' + : '正在按小时刷新当前页审批中的流程…'; + }; + const statusUrl = (token) => statusUrlTemplate.replace( '__TOKEN__', encodeURIComponent(token), @@ -1601,7 +1627,8 @@ return response.json(); }) .then((payload) => { - const state = payload.refresh?.status; + const refresh = payload.refresh || {}; + const state = refresh.status; if (state === 'completed') { const completedAt = String(Date.now()); storage.setItem(attemptKey, completedAt); @@ -1625,11 +1652,7 @@ ); return; } - setStatus( - manual - ? '正在手动刷新当前页审批中的流程…' - : '正在按小时刷新当前页审批中的流程…', - ); + setStatus(refreshProgressMessage(refresh, manual)); window.setTimeout( () => poll(token, attempt + 1, manual), pollIntervalMs, diff --git a/scripts/install-ding-process-sync-cron.ps1 b/scripts/install-ding-process-sync-cron.ps1 index 733b983e..e112c3a3 100644 --- a/scripts/install-ding-process-sync-cron.ps1 +++ b/scripts/install-ding-process-sync-cron.ps1 @@ -15,8 +15,10 @@ if ($RemotePath -notmatch '^/[A-Za-z0-9._/-]+$') { $cron = @( 'CRON_TZ=Asia/Shanghai' - '# Process details use a dedicated schedule and wait for the shared upstream lock.' + '# Process details use a dedicated schedule and OA-only mirror lock.' "2-59/5 * * * * root CAIWU_BATCH_LOCK_WAIT=900 /usr/bin/flock -n /run/caiwu-ding-process-details.lock /bin/sh $RemotePath/run-finance-batch.sh business-mirror:sync-ding-process-details --quiet >/dev/null 2>&1" + '# Consume one queued current-page refresh per minute; idle runs do not start a batch container.' + "* * * * * root CAIWU_BATCH_LOCK_WAIT=900 /usr/bin/flock -n /run/caiwu-ding-process-refresh.lock /bin/sh $RemotePath/run-finance-batch.sh business-mirror:process-ding-refresh-requests --quiet >/dev/null 2>&1" ) -join "`n" if ($DryRun) { diff --git a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php index 63b46a7a..69982e2c 100644 --- a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php +++ b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php @@ -146,6 +146,11 @@ class BusinessMirrorScheduleTest extends TestCase $contents, $script, ); + $this->assertStringContainsString( + '20 2 * * 0 root /usr/bin/flock -w 3600 /run/caiwu-ding-process-details.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-ding-process-details --full --quiet', + $contents, + $script, + ); $this->assertStringContainsString( "cat > /etc/cron.d/caiwu-ding-process-details <<'EOF'\n" . "CRON_TZ=Asia/Shanghai\n" diff --git a/tests/Unit/Deployment/FinanceBatchIsolationTest.php b/tests/Unit/Deployment/FinanceBatchIsolationTest.php index be669cb8..75a97694 100644 --- a/tests/Unit/Deployment/FinanceBatchIsolationTest.php +++ b/tests/Unit/Deployment/FinanceBatchIsolationTest.php @@ -35,18 +35,32 @@ class FinanceBatchIsolationTest extends TestCase } } - private function runBatch(string $directory, array $arguments): Process + /** @param array $environment */ + private function runBatch( + string $directory, + array $arguments, + array $environment = [], + ): Process { file_put_contents($directory . '/docker', '#!/bin/sh' . "\n" . 'if [ "$1" = inspect ]; then case "$4" in *Mounts*) echo "$CAIWU_STORAGE_ROOT" ;; *) echo caiwu_akaunting:123456789abc ;; esac; exit 0; fi' . "\n" . 'printf "%s\n" "image=$IMAGE_TAG" "$@" > "$CAIWU_CAPTURE"' . "\n"); chmod($directory . '/docker', 0700); - $process = new Process(array_merge(['sh', dirname(__DIR__, 3) . '/deploy/run-finance-batch.sh'], $arguments), $directory, [ + $processEnvironment = [ 'PATH' => $directory . ':' . getenv('PATH'), 'CAIWU_REMOTE_DIR' => $directory, 'CAIWU_UPSTREAM_LOCK' => $directory . '/sync.lock', 'CAIWU_BATCH_LOCK_WAIT' => '0.1', 'CAIWU_CAPTURE' => $directory . '/called', 'CAIWU_STORAGE_ROOT' => $directory . '/storage', - ]); + ...$environment, + ]; + if ($processEnvironment['CAIWU_UPSTREAM_LOCK'] === null) { + unset($processEnvironment['CAIWU_UPSTREAM_LOCK']); + } + $process = new Process( + array_merge(['sh', dirname(__DIR__, 3) . '/deploy/run-finance-batch.sh'], $arguments), + $directory, + $processEnvironment, + ); $process->run(); return $process; @@ -120,4 +134,35 @@ class FinanceBatchIsolationTest extends TestCase rmdir($directory); } } + + public function test_oa_sync_uses_its_own_lock_when_general_upstream_job_is_busy(): void + { + $directory = sys_get_temp_dir() . '/caiwu-oa-lock-' . bin2hex(random_bytes(6)); + mkdir($directory); + $generalLockPath = $directory . '/general.lock'; + $oaLockPath = $directory . '/oa.lock'; + $generalLock = fopen($generalLockPath, 'c'); + flock($generalLock, LOCK_EX); + try { + $process = $this->runBatch( + $directory, + ['business-mirror:sync-ding-process-details'], + [ + 'CAIWU_UPSTREAM_LOCK' => null, + 'CAIWU_GENERAL_LOCK' => $generalLockPath, + 'CAIWU_OA_LOCK' => $oaLockPath, + ], + ); + + $this->assertTrue($process->isSuccessful(), $process->getErrorOutput()); + $this->assertFileExists($directory . '/called'); + } finally { + flock($generalLock, LOCK_UN); + fclose($generalLock); + foreach (glob($directory . '/*') as $path) { + unlink($path); + } + rmdir($directory); + } + } } diff --git a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php index eb6f47a6..c3633202 100644 --- a/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/DingProcessDetailMirrorSynchronizerTest.php @@ -1275,6 +1275,11 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase $backlog, ); $this->assertCount(90, $this->approvalClient->calls); + $this->assertNotEmpty($this->approvalClient->bulkCalls); + $this->assertContains( + 'backlog-000', + array_merge(...$this->approvalClient->bulkCalls), + ); $this->assertSame($expectedBacklog, array_slice($this->approvalClient->calls, 0, 30)); $this->assertSame( 'backlog-029', @@ -1697,6 +1702,9 @@ class FakeDingTalkProcessInstanceClient extends DingTalkProcessInstanceClient /** @var list */ public array $calls = []; + /** @var list> */ + public array $bulkCalls = []; + /** @var list */ public array $failingInstances = []; @@ -1752,4 +1760,30 @@ class FakeDingTalkProcessInstanceClient extends DingTalkProcessInstanceClient 'result' => $this->resultByInstance[$processInstanceId] ?? null, ]; } + + public function processSnapshots(array $processInstanceIds): array + { + $this->bulkCalls[] = array_values($processInstanceIds); + $snapshots = []; + $errors = []; + $fatal = false; + foreach ($processInstanceIds as $processInstanceId) { + try { + $snapshots[$processInstanceId] = $this->processSnapshot($processInstanceId); + } catch (DingTalkProcessInstanceException $exception) { + $errors[$processInstanceId] = $exception->getMessage(); + $fatal = $fatal || $exception->stopsRun(); + if ($fatal) { + break; + } + } + } + + return [ + 'snapshots' => $snapshots, + 'errors' => $errors, + 'fatal' => $fatal, + 'attempted' => count($processInstanceIds), + ]; + } } diff --git a/tests/Unit/EcommerceFinance/DingTalkProcessInstanceClientTest.php b/tests/Unit/EcommerceFinance/DingTalkProcessInstanceClientTest.php new file mode 100644 index 00000000..87911646 --- /dev/null +++ b/tests/Unit/EcommerceFinance/DingTalkProcessInstanceClientTest.php @@ -0,0 +1,66 @@ + 'test-client', + 'dingtalk.client_secret' => 'test-secret', + 'dingtalk.app_token_url' => 'https://example.test/token', + 'dingtalk.timeout' => 3, + 'dingtalk.process_concurrency' => 2, + ]); + + Http::fake([ + 'https://example.test/token*' => Http::response([ + 'errcode' => 0, + 'access_token' => 'test-token', + 'expires_in' => 7200, + ]), + 'https://api.dingtalk.com/v1.0/workflow/processInstances*' => function (Request $request) { + $processInstanceId = (string) ($request->data()['processInstanceId'] ?? ''); + + return Http::response([ + 'result' => [ + 'status' => 'RUNNING', + 'result' => 'NONE', + 'formComponentValues' => [[ + 'name' => '用途', + 'value' => '批量读取-' . $processInstanceId, + ]], + 'tasks' => [[ + 'taskGroupName' => '出纳打款', + 'status' => 'RUNNING', + 'result' => 'NONE', + ]], + 'operationRecords' => [], + ], + ]); + }, + ]); + + $result = app(DingTalkProcessInstanceClient::class) + ->processSnapshots(['process-a', 'process-b']); + + $this->assertFalse($result['fatal']); + $this->assertSame(2, $result['attempted']); + $this->assertSame([], $result['errors']); + $this->assertSame( + '批量读取-process-a', + $result['snapshots']['process-a']['form_values']['用途'], + ); + $this->assertSame( + '批量读取-process-b', + $result['snapshots']['process-b']['form_values']['用途'], + ); + Http::assertSentCount(3); + } +} diff --git a/tests/Unit/EcommerceFinance/ProcessDetailsPageDataTest.php b/tests/Unit/EcommerceFinance/ProcessDetailsPageDataTest.php index 19fcd556..2020dbaa 100644 --- a/tests/Unit/EcommerceFinance/ProcessDetailsPageDataTest.php +++ b/tests/Unit/EcommerceFinance/ProcessDetailsPageDataTest.php @@ -36,6 +36,10 @@ class ProcessDetailsPageDataTest extends TestCase $this->assertStringContainsString('$statusClass ??=', $content); $this->assertStringNotContainsString('$processRows->links()', $content); $this->assertStringContainsString('const refreshIntervalMs = 60 * 60 * 1000;', $content); + $this->assertStringContainsString('const pollLimit = 480;', $content); + $this->assertStringContainsString('refreshProgressMessage', $content); + $this->assertStringContainsString('请求已入队,等待 OA 同步任务', $content); + $this->assertStringContainsString('OA 同步任务正在读取钉钉最新状态', $content); $this->assertStringContainsString('oa-refresh-attempt-at:', $content); $this->assertStringContainsString('window.localStorage', $content); $this->assertStringContainsString('scheduleNextReload', $content); -- 2.47.2