feat: 退款当前状态改为分钟级同步 (#899)

This commit is contained in:
renshen 2026-11-05 19:05:23 +08:00
parent 40d3ce162a
commit e8d4e8e345
11 changed files with 79 additions and 13 deletions

View File

@ -53,8 +53,14 @@ class Kernel extends ConsoleKernel
->withoutOverlapping(60)
->runInBackground();
// 退款待确认的当前状态按分钟增量刷新,确认完成后页面可及时移除。
$schedule->command('business-mirror:sync-tables jiansun.dbo.jst_refund_view')
->everyMinute()
->withoutOverlapping(5)
->runInBackground();
// 退款待确认四张镜像每天 06:00 专属刷新,确保 10:00 前完成;
// 不再参与分钟级通用轮转,失败状态由财务驾驶舱展示。
// 当前退款状态单独按分钟刷新,失败状态由财务驾驶舱展示。
$schedule->command('business-mirror:sync-tables --daily-only --max-batches=0')
->dailyAt('06:00')
->timezone('Asia/Shanghai')

View File

@ -325,14 +325,15 @@ return [
'full_reconcile_hours' => (int) env('BUSINESS_MIRROR_FULL_RECONCILE_HOURS', 168),
'dedicated_datasets' => [
'operations_replica.rpt.jst_sku',
'jiansun.dbo.jst_refund_view',
],
// 退款待确认:每天 10 点前由专属批次刷新一次,不再进入分钟轮转。
// 四张退款基础镜像每天 10 点前由专属批次刷新一次。
// 聚水潭当前退款状态按分钟增量刷新,保证退款确认后页面及时更新。
'daily_datasets' => [
'operations_replica.rpt.jst_returnAndRefundInfo',
'operations_replica.rpt.jst_returnAndRefundItem',
'operations_replica.rpt.jst_qmcust_refundlist',
'operations_replica.rpt.jst_qmcust_refundlist_item',
'jiansun.dbo.jst_refund_view',
],
],
@ -491,6 +492,7 @@ return [
'keys' => ['as_id'],
'watermark' => 'modified',
'minimum_datetime' => ['column' => 'as_date', 'value' => '2026-10-01 00:00:00'],
'overlap_hours' => 1,
'full_reconcile_hours' => 168,
'columns' => ['as_id', 'status', 'shop_status', 'confirm_date', 'modified', 'as_date'],
'indexes' => [

View File

@ -200,7 +200,7 @@ mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
# 普通商品优先高频增量,并在配置的每周低峰窗口自动继续全量校准。
cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF'
CRON_TZ=Asia/Shanghai
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables jiansun.dbo.jst_refund_view --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1
# 店铺经营报表按 15 分钟刷新;单轮覆盖 50000 行回看窗口,避免空闲时持续重读千万级账单。
7,22,37,52 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-store-report --max-batches=12 --quiet >/dev/null 2>&1
# 店铺候选平时仅读镜像变更;夜间全量核对历史名称。冷页面不扫描订单宽表。
@ -230,7 +230,8 @@ CRON_TZ=Asia/Shanghai
15 10 * * * root /usr/bin/flock -n /run/caiwu-inventory-warehouse-alert.lock /usr/bin/docker exec -u www-data caiwu_app php artisan inventory:sync-warehouse-mapping-alert --quiet >/dev/null 2>&1
# 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。
15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1
# 退款待确认四张镜像每天 06:00 在独立批容器完整跑完一轮,确保 10:00 前完成。
# 退款待确认四张基础镜像每天 06:00 在独立批容器完整跑完一轮,确保 10:00 前完成;
# 当前退款状态在上面的分钟任务中优先增量刷新。
0 6 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-tables --daily-only --max-batches=0 --quiet >/dev/null 2>&1
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
# 财务台账:每天 02:30 同步 AI 库动账并重算流水;10:30 生成前一晚店铺余额快照。

View File

@ -204,7 +204,7 @@ mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
# 普通商品优先高频增量,并在配置的每周低峰窗口自动继续全量校准。
cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF'
CRON_TZ=Asia/Shanghai
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables jiansun.dbo.jst_refund_view --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1
# 店铺经营报表按 15 分钟刷新;单轮覆盖 50000 行回看窗口,避免空闲时持续重读千万级账单。
7,22,37,52 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-store-report --max-batches=12 --quiet >/dev/null 2>&1
# 店铺候选平时仅读镜像变更;夜间全量核对历史名称。冷页面不扫描订单宽表。
@ -234,7 +234,8 @@ CRON_TZ=Asia/Shanghai
15 10 * * * root /usr/bin/flock -n /run/caiwu-inventory-warehouse-alert.lock /usr/bin/docker exec -u www-data caiwu_app php artisan inventory:sync-warehouse-mapping-alert --quiet >/dev/null 2>&1
# 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。
15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1
# 退款待确认四张镜像每天 06:00 在独立批容器完整跑完一轮,确保 10:00 前完成。
# 退款待确认四张基础镜像每天 06:00 在独立批容器完整跑完一轮,确保 10:00 前完成;
# 当前退款状态在上面的分钟任务中优先增量刷新。
0 6 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh business-mirror:sync-tables --daily-only --max-batches=0 --quiet >/dev/null 2>&1
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
# 财务台账:每天 02:30 同步 AI 库动账并重算流水;10:30 生成前一晚店铺余额快照。

View File

@ -78,7 +78,7 @@
售后(退货退款)页面及其导出共同读取上述两套售后主单、各自商品明细与店铺镜像,按 `as_date` 筛选。原售后资料优先;退款清单按 `as_id`、或非空的 `(shop_id, outer_as_id)` 排除已存在的业务单。无商品明细的主单仍显示,商品数量、明细金额及成本留空;店铺平台无法唯一确认时标为“待确认”,不推断平台。两个来源的订单关联不得混用。订单号筛选按完整内部订单号、线上订单号或平台售后单号精确查询,避免长文本标识的全表扫描;店铺和商品仍支持包含搜索。分页先限制各来源的候选主单及行标识,再加载当前页明细;导出使用同一筛选及稳定游标。
“退款待确认”复用同一套售后查询,并用 `dbo.jst_refund_view` 的当前 `as_id => status/shop_status` 覆盖报表副本里的旧状态后固定筛选 `status=WaitConfirm`、`shop_status=SUCCESS`;页面分别显示为“待确认”和“退款成功”。覆盖表只保留 2026-10-01 起的当前状态字段,按 `as_id` 建立索引并每日同步。筛选与导出始终保留该固定条件。
“退款待确认”复用同一套售后查询,并用 `dbo.jst_refund_view` 的当前 `as_id => status/shop_status` 覆盖报表副本里的旧状态后固定筛选 `status=WaitConfirm`、`shop_status=SUCCESS`;页面分别显示为“待确认”和“退款成功”。覆盖表只保留 2026-10-01 起的当前状态字段,按 `as_id` 建立索引并按分钟增量同步,使用 1 小时重叠窗口和每周全量校准。筛选与导出始终保留该固定条件。
`jst_returnAndRefundItem` 没有更新时间,除按 `Id` 追加外,每 168 小时通过现有调度分批执行可续传全量校准,覆盖历史编号变化及删除;全量完成后才清理上一代残留。人工历史校准前须备份相关镜像、目录及同步状态,并持有生产上游同步互斥锁。退款清单两表继续按 `local_updated` 使用重叠增量,不新建重复镜像。
@ -119,7 +119,7 @@
供应商管理保留现有后台轮转刷新,同时每天北京时间 08:15 通过 `business-mirror:sync-supplier-management` 顺序完整校准供应商档案(含易签宝账号)、合同、联系人和打款资料。生产任务使用现有上游同步锁和 `www-data`,有其他同步占用时排队执行;每批沿用现有行数限制,直到四个数据集分别完成,失败记录在原镜像同步状态中。固定任务随正式发布安装,页面继续显示最近同步时间。
1. 首次全量只能通过 `business-mirror:sync-tables <dataset> --full` 显式启动;失败或中断后使用同一命令继续。
2. 日常任务只处理已经完成首次全量的数据集。存在可靠更新时间的表使用 48 小时重叠窗口;没有可靠更新时间的表按稳定主键追加。`rpt.jst_sku` 由普通商品专用任务每分钟优先处理,每轮默认最多 5 批、每批 1000 行,不再参加通用数据集轮转。
2. 日常任务只处理已经完成首次全量的数据集。存在可靠更新时间的表默认使用 48 小时重叠窗口;退款当前状态使用 1 小时重叠窗口并每分钟刷新;没有可靠更新时间的表按稳定主键追加。`rpt.jst_sku` 由普通商品专用任务每分钟优先处理,每轮默认最多 5 批、每批 1000 行,不再参加通用数据集轮转。
3. 全量校准使用新的同步代次,只有全部完成后才删除镜像中本轮未出现的旧行,因此中途失败不会清空当前可用数据。
4. 普通商品在最近完整校准超过 168 小时后,于周日 02:00-08:00 低峰窗口启动全量校准;一旦启动,后续每分钟从稳定游标继续,直至完整结束。首次全量由 Codex 备份后自主执行,定时任务保持既有初始化开关。
5. 除专用数据集外,通用任务每轮默认只处理 1 个数据集、每个数据集 2 批,每批 500 行,避免同时压迫两个旧系统。

View File

@ -411,7 +411,11 @@ class BusinessMirrorSynchronizer
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);
$overlap = max((int) $this->datasets->definitionValue(
$dataset->key(),
'overlap_hours',
config('upstream_mirrors.table_sync.overlap_hours', 48),
), 1);
$last = Carbon::parse((string) ($state['last_succeeded_at'] ?? now()->subHours($overlap)));
return [$last->subHours($overlap), now()->addSecond()];

View File

@ -131,8 +131,8 @@ class RefundPendingSyncStatus
'state' => $running ? 'syncing' : 'healthy',
'title' => $running ? '退款待确认正在同步' : '退款待确认同步正常',
'message' => $running
? '每日同步正在执行,完成后会自动更新。'
: '每日 06:00 自动同步,10:00 前完成。',
? '退款镜像正在同步,完成后会自动更新。'
: '基础退款镜像每日 06:00 同步,当前退款状态每分钟刷新。',
'last_succeeded_at' => $this->latest($successTimes),
'last_attempted_at' => $this->latest($attemptTimes),
'issues' => [],

View File

@ -23,8 +23,15 @@ class BusinessMirrorScheduleTest extends TestCase
'operations_replica.rpt.jst_returnAndRefundItem',
'operations_replica.rpt.jst_qmcust_refundlist',
'operations_replica.rpt.jst_qmcust_refundlist_item',
'jiansun.dbo.jst_refund_view',
], config('upstream_mirrors.table_sync.daily_datasets'));
$this->assertContains(
'jiansun.dbo.jst_refund_view',
config('upstream_mirrors.table_sync.dedicated_datasets'),
);
$this->assertSame(
1,
config('upstream_mirrors.datasets')['jiansun.dbo.jst_refund_view']['overlap_hours'] ?? null,
);
}
public function test_stale_source_does_not_block_healthy_datasets_and_reports_failure(): void

View File

@ -59,6 +59,11 @@ class BusinessMirrorScheduleTest extends TestCase
$minuteLine = $minuteLines[0] ?? null;
$this->assertIsString($minuteLine, $script);
$this->assertStringNotContainsString('business-mirror:sync-store-report', $minuteLine, $script);
$statusPosition = strpos($minuteLine, 'business-mirror:sync-tables jiansun.dbo.jst_refund_view --quiet');
$catalogPosition = strpos($minuteLine, 'business-mirror:sync-product-catalog --quiet');
$this->assertNotFalse($statusPosition, $script);
$this->assertNotFalse($catalogPosition, $script);
$this->assertLessThan($catalogPosition, $statusPosition, $script);
$this->assertStringContainsString('docker exec -u www-data caiwu_app', $contents, $script);
$this->assertStringContainsString('/run/caiwu-upstream-sync.lock', $contents, $script);
$this->assertStringContainsString("CRON_TZ=Asia/Shanghai\n", $contents, $script);

View File

@ -252,6 +252,31 @@ class BusinessMirrorSynchronizerTest extends TestCase
}
}
public function test_incremental_sync_can_use_a_dataset_specific_overlap_window(): void
{
config([
'upstream_mirrors.datasets.test.dbo.records.overlap_hours' => 1,
'app.timezone' => 'Asia/Shanghai',
]);
\Illuminate\Support\Carbon::setTestNow('2026-08-15 12:30:00');
try {
$this->query->batches = [[
['Id' => 1, 'updateTime' => '2026-08-15 10:00:00', 'displayName' => '初始'],
]];
$this->synchronizer->sync('test.dbo.records', true, 10, 1);
$this->query->calls = [];
$this->query->batches = [[]];
$this->synchronizer->sync('test.dbo.records', false, 10, 1);
$this->assertSame('2026-08-15T11:30:00.000', $this->query->calls[0]['bindings'][0]);
$this->assertSame('2026-08-15T12:30:01.000', $this->query->calls[0]['bindings'][1]);
} finally {
\Illuminate\Support\Carbon::setTestNow();
}
}
public function test_key_first_incremental_read_preserves_filters_projection_and_resume_keys(): void
{
$this->query->batches = [[['Id' => 1, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => '旧值']]];

View File

@ -64,6 +64,21 @@ class RefundPendingSyncStatusTest extends TestCase
$this->assertCount(5, $status['issues']);
}
public function test_healthy_message_describes_realtime_status_refresh(): void
{
$states = $this->states();
foreach ($states as &$state) {
$state['last_attempted_at'] = '2026-11-04 10:29:00';
$state['last_succeeded_at'] = '2026-11-04 10:29:00';
}
unset($state);
$status = $this->service($states)->current(Carbon::parse('2026-11-04 10:30:00'));
$this->assertSame('healthy', $status['state']);
$this->assertSame('基础退款镜像每日 06:00 同步,当前退款状态每分钟刷新。', $status['message']);
}
/** @param array<string,array<string,mixed>> $states */
private function service(array $states): RefundPendingSyncStatus
{