修复生产报表负载根因 (#257)

消除报表缓存穿透与后台千万级全表计数,降低同步频率并调整共享数据库资源。
This commit is contained in:
WuYongJie 2026-09-23 20:15:59 +08:00
parent 3f80b2f226
commit 374df29d2b
11 changed files with 289 additions and 49 deletions

View File

@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
use Throwable;
class WarmReportCaches extends Command
{
protected $signature = 'business-mirror:warm-report-caches';
protected $description = '预热默认进销存与淘系支付宝报表,避免镜像切换后的首批请求重复扫表';
public function handle(
ThirdPartyWarehouseStorageMirrorRepository $inventory,
StoreReceivingAlipayBillMirrorRepository $alipay,
): int {
$failed = false;
try {
$month = now()->startOfMonth()->subMonth()->format('Y-m');
$result = $inventory->paginateMonthlyInventoryReport([
'month' => $month,
'product_code' => '',
'warehouse_provider' => '',
'category' => '',
], 1, 25);
$this->info(sprintf('进销存 %s 首页已预热,共 %d 行。', $month, $result['total']));
} catch (Throwable $exception) {
$failed = true;
Log::error('默认进销存报表缓存预热失败。', ['exception' => $exception::class]);
$this->error('默认进销存报表缓存预热失败。');
}
try {
$result = $alipay->paginate([], 'occurred_at', 'desc', 1, 10);
$this->info(sprintf('淘系支付宝首页已预热,共 %d 行。', $result['total']));
} catch (Throwable $exception) {
$failed = true;
Log::error('淘系支付宝报表缓存预热失败。', ['exception' => $exception::class]);
$this->error('淘系支付宝报表缓存预热失败。');
}
return $failed ? self::FAILURE : self::SUCCESS;
}
}

View File

@ -27,11 +27,16 @@ services:
env_file: env_file:
- ./runtime/config-db.env - ./runtime/config-db.env
command: command:
- "--innodb-buffer-pool-size=512M" - "--innodb-buffer-pool-size=4G"
- "--innodb-log-buffer-size=32M" - "--innodb-log-buffer-size=64M"
- "--max-connections=60" - "--max-connections=60"
- "--table-open-cache=600" - "--table-open-cache=600"
- "--performance-schema=OFF" - "--performance-schema=OFF"
- "--slow-query-log=ON"
- "--long-query-time=5"
- "--min-examined-row-limit=10000"
- "--log-output=FILE"
- "--slow-query-log-file=/var/lib/mysql/caiwu-slow.log"
ports: ports:
- "0.0.0.0:13306:3306" - "0.0.0.0:13306:3306"
volumes: volumes:
@ -40,8 +45,8 @@ services:
networks: networks:
- default - default
- config_database - config_database
mem_limit: 1536m mem_limit: 6g
cpus: "2.00" cpus: "4.00"
healthcheck: healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s interval: 10s

View File

@ -178,8 +178,10 @@ mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
# 普通商品优先高频增量,并在配置的每周低峰窗口自动继续全量校准。 # 普通商品优先高频增量,并在配置的每周低峰窗口自动继续全量校准。
cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF' cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF'
CRON_TZ=Asia/Shanghai 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 business-mirror:sync-store-report --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-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
0 0 * * * 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-third-party-warehouse-storage --max-batches=0 --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
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
0 0 * * * 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-jushuitan-cost-price-history --quiet >/dev/null 2>&1 0 0 * * * 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-jushuitan-cost-price-history --quiet >/dev/null 2>&1
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/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 business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1 0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/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 business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --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 */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

View File

@ -182,8 +182,10 @@ mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
# 普通商品优先高频增量,并在配置的每周低峰窗口自动继续全量校准。 # 普通商品优先高频增量,并在配置的每周低峰窗口自动继续全量校准。
cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF' cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF'
CRON_TZ=Asia/Shanghai 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 business-mirror:sync-store-report --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-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
0 0 * * * 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-third-party-warehouse-storage --max-batches=0 --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
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
0 0 * * * 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-jushuitan-cost-price-history --quiet >/dev/null 2>&1 0 0 * * * 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-jushuitan-cost-price-history --quiet >/dev/null 2>&1
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/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 business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1 0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/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 business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --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 */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

View File

@ -96,7 +96,7 @@
6. 页面切换必须以“总记录数、第一页、第二页、末页、筛选、汇总、空数据和更新时间”作为验收,不能只看首页有数据。 6. 页面切换必须以“总记录数、第一页、第二页、末页、筛选、汇总、空数据和更新时间”作为验收,不能只看首页有数据。
7. 普通商品的采购单同步以采购主表 `updateTime` 加明细 `Id` 作为稳定游标;镜像保存来源单号、供应商、数量、单价和全部状态。普通商品页面按商品编码选择最近完成的 1688 一键代发价格;线上代发价格页面只展示 `Confirmed` / `Finished`,并在共享镜像内关联订单。完成单后续取消时可撤回旧价格;新增投影字段完成新一轮全量前不展示采购单管理数据。 7. 普通商品的采购单同步以采购主表 `updateTime` 加明细 `Id` 作为稳定游标;镜像保存来源单号、供应商、数量、单价和全部状态。普通商品页面按商品编码选择最近完成的 1688 一键代发价格;线上代发价格页面只展示 `Confirmed` / `Finished`,并在共享镜像内关联订单。完成单后续取消时可撤回旧价格;新增投影字段完成新一轮全量前不展示采购单管理数据。
8. 聚水潭官方接口尚未接入时,普通商品历史残留排除由负责人提供的最新完整 XLSX 导出刷新。只排除“导出生成前已创建、但导出中缺失”的镜像行,导出生成后的新增商品保持可见。导入使用新同步代次,只有完整解析成功才原子替换排除表;中断或错误保留上一版。后续接入聚水潭官方“普通商品资料查询”及删除/恢复记录后,由官方接口取代人工导出校准。 8. 聚水潭官方接口尚未接入时,普通商品历史残留排除由负责人提供的最新完整 XLSX 导出刷新。只排除“导出生成前已创建、但导出中缺失”的镜像行,导出生成后的新增商品保持可见。导入使用新同步代次,只有完整解析成功才原子替换排除表;中断或错误保留上一版。后续接入聚水潭官方“普通商品资料查询”及删除/恢复记录后,由官方接口取代人工导出校准。
9. 店铺维度报表是已批准的中央专用数据集:生产节点按 `Id` 断点推进并回看最近 50000 行;原始账单同步后增量维护按店铺/日期汇总的 `store_report_daily_rows`,页面不得现场扫描千万级原始账单。开发机不运行刷新 worker。 9. 店铺维度报表是已批准的中央专用数据集:生产节点每 15 分钟按 `Id` 断点推进并回看最近 50000 行,一轮最多处理 60000 行;同步批次按受影响主键精确维护行数,不得在完成阶段对千万级原始表执行 `COUNT(*)`。原始账单同步后增量维护按店铺/日期汇总的 `store_report_daily_rows`,页面不得现场扫描千万级原始账单。开发机不运行刷新 worker。
10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。 淘系支付宝账户账单当前仍读取此 MariaDB 镜像,统一调度须持续增量,不因 ClickHouse 尚未启用而暂停。账单冲减则复用“订单数据 → 原始数据 → 订单售后费用明细”,不按该表费用名称推断 500112。 10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。 淘系支付宝账户账单当前仍读取此 MariaDB 镜像,统一调度须持续增量,不因 ClickHouse 尚未启用而暂停。账单冲减则复用“订单数据 → 原始数据 → 订单售后费用明细”,不按该表费用名称推断 500112。
11. 拼多多货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving` 数据集。同步器必须以参数绑定固定 `channel = PinduoduoImport`,只复制该渠道切片;页面 Repository 只读 `js_jst_pdd_receiving_bill_rows`。首次全量按 `Id` 断点推进,后续按 `updateTime` 保留重叠窗口,不得切回落后一天的 `operations_replica` 副本。 11. 拼多多货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving` 数据集。同步器必须以参数绑定固定 `channel = PinduoduoImport`,只复制该渠道切片;页面 Repository 只读 `js_jst_pdd_receiving_bill_rows`。首次全量按 `Id` 断点推进,后续按 `updateTime` 保留重叠窗口,不得切回落后一天的 `operations_replica` 副本。
12. 得物完整账单使用聚胜算权威 XLSX,通过 `dewu-bills:import-authoritative` 写入 `dewu_settlement_bill_rows`。导入必须核验文件 SHA-256、账期、记录编码唯一性、行数、收入和支出控制数,并保留得物、退货退款订单、扣减其他费用明细三类渠道;页面 Repository 不得回退 `jiansun.dbo.jst_originalBillInfo.dewu_receiving`。详细流程见 `docs/得物完整账单取数方案留档.md`。 12. 得物完整账单使用聚胜算权威 XLSX,通过 `dewu-bills:import-authoritative` 写入 `dewu_settlement_bill_rows`。导入必须核验文件 SHA-256、账期、记录编码唯一性、行数、收入和支出控制数,并保留得物、退货退款订单、扣减其他费用明细三类渠道;页面 Repository 不得回退 `jiansun.dbo.jst_originalBillInfo.dewu_receiving`。详细流程见 `docs/得物完整账单取数方案留档.md`。

View File

@ -2,6 +2,7 @@
namespace Modules\EcommerceFinance\Repositories; namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@ -15,6 +16,12 @@ class StoreReceivingAlipayBillMirrorRepository
{ {
private const PAGE_QUERY_TIMEOUT_SECONDS = 30; private const PAGE_QUERY_TIMEOUT_SECONDS = 30;
private const AGGREGATE_CACHE_SECONDS = 900;
private const AGGREGATE_CACHE_LOCK_SECONDS = 45;
private const AGGREGATE_CACHE_WAIT_SECONDS = 35;
/** 淘系支付宝页面不展示其他平台店铺;头条放心购是镜像中的抖音平台名称。 */ /** 淘系支付宝页面不展示其他平台店铺;头条放心购是镜像中的抖音平台名称。 */
private const EXCLUDED_TAOBAO_SHOP_SITE_PREFIXES = [ private const EXCLUDED_TAOBAO_SHOP_SITE_PREFIXES = [
'拼多多', '京东', '抖音', '头条放心购', '得物', '唯品会', '拼多多', '京东', '抖音', '头条放心购', '得物', '唯品会',
@ -87,15 +94,13 @@ class StoreReceivingAlipayBillMirrorRepository
&& $this->channelCoversDataset($channel, $dataset->fixedFilters()) && ! $hasFilters && $this->channelCoversDataset($channel, $dataset->fixedFilters()) && ! $hasFilters
&& (($state['status'] ?? null) === 'succeeded' || (bool) ($state['mirror_row_count_is_exact'] ?? false)) && (($state['status'] ?? null) === 'succeeded' || (bool) ($state['mirror_row_count_is_exact'] ?? false))
&& $recordedCount !== null && ctype_digit((string) $recordedCount); && $recordedCount !== null && ctype_digit((string) $recordedCount);
$summary = Cache::remember( $summary = $this->rememberAggregateSummary(
$this->aggregateCacheKey( $this->aggregateCacheKey(
$datasetKey, $datasetKey,
$filters, $filters,
$channel, $channel,
$excludeNonTaobaoPlatforms, $excludeNonTaobaoPlatforms,
$state,
), ),
120,
fn (): array => $this->summary($query, $filters, $channel), fn (): array => $this->summary($query, $filters, $channel),
); );
$total = $useRecordedCount $total = $useRecordedCount
@ -803,13 +808,12 @@ class StoreReceivingAlipayBillMirrorRepository
return (int) ($this->boundedGet($count)->first()->aggregate ?? 0); return (int) ($this->boundedGet($count)->first()->aggregate ?? 0);
} }
/** @param array<string,mixed> $filters @param array<string,mixed> $state */ /** @param array<string,mixed> $filters */
private function aggregateCacheKey( private function aggregateCacheKey(
string $datasetKey, string $datasetKey,
array $filters, array $filters,
string|array|null $channel, string|array|null $channel,
bool $excludeNonTaobaoPlatforms, bool $excludeNonTaobaoPlatforms,
array $state,
): string { ): string {
$normalizedFilters = array_filter( $normalizedFilters = array_filter(
array_map( array_map(
@ -824,18 +828,45 @@ class StoreReceivingAlipayBillMirrorRepository
sort($normalizedChannel); sort($normalizedChannel);
} }
return 'store-receiving-bills:aggregate:v2:' . hash('sha256', (string) json_encode([ return 'store-receiving-bills:aggregate:v3:' . hash('sha256', (string) json_encode([
'dataset' => $datasetKey, 'dataset' => $datasetKey,
'filters' => $normalizedFilters, 'filters' => $normalizedFilters,
'channel' => $normalizedChannel, 'channel' => $normalizedChannel,
'exclude_non_taobao' => $excludeNonTaobaoPlatforms, 'exclude_non_taobao' => $excludeNonTaobaoPlatforms,
'status' => $state['status'] ?? null,
'last_succeeded_at' => $state['last_succeeded_at'] ?? null,
'mirror_row_count' => $state['mirror_row_count'] ?? null,
'mirror_row_count_is_exact' => $state['mirror_row_count_is_exact'] ?? null,
], JSON_UNESCAPED_UNICODE)); ], JSON_UNESCAPED_UNICODE));
} }
/** @param callable():array{income:float,expense:float,count:?int} $resolver */
private function rememberAggregateSummary(string $key, callable $resolver): array
{
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
try {
return Cache::lock($key . ':lock', self::AGGREGATE_CACHE_LOCK_SECONDS)
->block(self::AGGREGATE_CACHE_WAIT_SECONDS, function () use ($key, $resolver): array {
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
$resolved = $resolver();
Cache::put($key, $resolved, self::AGGREGATE_CACHE_SECONDS);
return $resolved;
});
} catch (LockTimeoutException $exception) {
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
throw new RuntimeException('store_receiving_bill_aggregate_busy', 0, $exception);
}
}
private function escapeLike(string $value): string private function escapeLike(string $value): string
{ {
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);

View File

@ -4,6 +4,7 @@ namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
use Generator; use Generator;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager; use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
@ -24,6 +25,12 @@ class ThirdPartyWarehouseStorageMirrorRepository
{ {
private const REPORT_QUERY_TIMEOUT_SECONDS = 30; private const REPORT_QUERY_TIMEOUT_SECONDS = 30;
private const REPORT_CACHE_SECONDS = 93600;
private const REPORT_CACHE_LOCK_SECONDS = 45;
private const REPORT_CACHE_WAIT_SECONDS = 35;
public const DATASET_KEY = 'jiansun.third-party-warehouse-storage'; public const DATASET_KEY = 'jiansun.third-party-warehouse-storage';
public const WAREHOUSE_ID = 15045364; public const WAREHOUSE_ID = 15045364;
@ -371,7 +378,7 @@ class ThirdPartyWarehouseStorageMirrorRepository
$state, $state,
); );
$rolledForward = $includeTotal && $perPage <= 100 && $authority !== null $rolledForward = $includeTotal && $perPage <= 100 && $authority !== null
? Cache::remember( ? $this->rememberMonthlyInventoryReport(
$this->monthlyInventoryReportCacheKey( $this->monthlyInventoryReportCacheKey(
$period, $period,
$filters, $filters,
@ -381,13 +388,14 @@ class ThirdPartyWarehouseStorageMirrorRepository
$inventoryGeneration, $inventoryGeneration,
$page, $page,
$perPage, $perPage,
$state,
), ),
120,
$resolveRollForward, $resolveRollForward,
) )
: $resolveRollForward(); : $resolveRollForward();
if ($rolledForward !== null) { if ($rolledForward !== null) {
$rolledForward['state'] = $state;
$rolledForward['sync'] = $this->syncStatus($state);
return $rolledForward; return $rolledForward;
} }
@ -904,7 +912,6 @@ class ThirdPartyWarehouseStorageMirrorRepository
* @param array{start:string,end:string} $period * @param array{start:string,end:string} $period
* @param array<string,mixed> $filters * @param array<string,mixed> $filters
* @param array{generation:string,source_month:string} $authority * @param array{generation:string,source_month:string} $authority
* @param array<string,mixed> $state
*/ */
private function monthlyInventoryReportCacheKey( private function monthlyInventoryReportCacheKey(
array $period, array $period,
@ -915,7 +922,6 @@ class ThirdPartyWarehouseStorageMirrorRepository
string $inventoryGeneration, string $inventoryGeneration,
int $page, int $page,
int $perPage, int $perPage,
array $state,
): string { ): string {
$normalizedFilters = [ $normalizedFilters = [
'product_code' => trim((string) ($filters['product_code'] ?? '')), 'product_code' => trim((string) ($filters['product_code'] ?? '')),
@ -923,20 +929,51 @@ class ThirdPartyWarehouseStorageMirrorRepository
'category' => trim((string) ($filters['category'] ?? '')), 'category' => trim((string) ($filters['category'] ?? '')),
]; ];
return 'inventory-report:monthly-page:v2:' . hash('sha256', (string) json_encode([ return 'inventory-report:monthly-page:v3:' . hash('sha256', (string) json_encode([
'period' => $period, 'period' => $period,
'filters' => $normalizedFilters, 'filters' => $normalizedFilters,
'authority' => $authority, 'authority' => $authority,
'header_generation' => $headerGeneration, 'header_generation' => $headerGeneration,
'item_generation' => $itemGeneration, 'item_generation' => $itemGeneration,
'inventory_generation' => $inventoryGeneration, 'inventory_generation' => $inventoryGeneration,
'status' => $state['status'] ?? null,
'last_succeeded_at' => $state['last_succeeded_at'] ?? null,
'page' => $page, 'page' => $page,
'per_page' => $perPage, 'per_page' => $perPage,
], JSON_UNESCAPED_UNICODE)); ], JSON_UNESCAPED_UNICODE));
} }
/** @param callable():?array $resolver */
private function rememberMonthlyInventoryReport(string $key, callable $resolver): ?array
{
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
try {
return Cache::lock($key . ':lock', self::REPORT_CACHE_LOCK_SECONDS)
->block(self::REPORT_CACHE_WAIT_SECONDS, function () use ($key, $resolver): ?array {
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
$resolved = $resolver();
if (is_array($resolved)) {
Cache::put($key, $resolved, self::REPORT_CACHE_SECONDS);
}
return $resolved;
});
} catch (LockTimeoutException $exception) {
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
throw new RuntimeException('inventory_report_generation_busy', 0, $exception);
}
}
/** @param array{start:string,end:string} $period */ /** @param array{start:string,end:string} $period */
private function previousMonthlyInventoryAuthority(array $period): ?array private function previousMonthlyInventoryAuthority(array $period): ?array
{ {

View File

@ -119,10 +119,16 @@ class StoreReportMirrorSynchronizer
} }
$mapped = $map($rows, $generation); $mapped = $map($rows, $generation);
$this->storeBatch($dataset, $table, $generation, $nextCursor, $mapped, $initial, $storedRows); $storedRows = $this->storeBatch(
$dataset,
$table,
$generation,
$nextCursor,
$mapped,
$storedRows,
);
$cursor = $nextCursor; $cursor = $nextCursor;
$sourceRows += count($rows); $sourceRows += count($rows);
$storedRows += $initial ? count($mapped['upserts']) : 0;
$batches++; $batches++;
if (count($rows) < $batchSize) { if (count($rows) < $batchSize) {
@ -163,12 +169,30 @@ class StoreReportMirrorSynchronizer
string $generation, string $generation,
int $cursor, int $cursor,
array $mapped, array $mapped,
bool $initial,
int $storedRows, int $storedRows,
): void { ): int {
$this->connection->transaction(function () use ( return $this->connection->transaction(function () use (
$dataset, $table, $generation, $cursor, $mapped, $initial, $storedRows, $dataset, $table, $generation, $cursor, $mapped, $storedRows,
): void { ): int {
$upsertIds = [];
foreach ($mapped['upserts'] as $row) {
$upsertIds[(int) $row['source_id']] = true;
}
$deleteIds = array_fill_keys(array_map('intval', $mapped['deletes']), true);
$touchedIds = array_keys($upsertIds + $deleteIds);
$existingIds = [];
foreach (array_chunk($touchedIds, 1000) as $chunk) {
foreach ($this->connection->table($table)
->where('generation', $generation)
->whereIn('source_id', $chunk)
->pluck('source_id') as $sourceId) {
$existingIds[(int) $sourceId] = true;
}
}
$inserted = count(array_diff_key($upsertIds, $existingIds));
$deleted = count(array_diff_key(array_intersect_key($deleteIds, $existingIds), $upsertIds));
$nextStoredRows = max($storedRows + $inserted - $deleted, 0);
$dailyDeltas = $table === 'store_report_bill_rows' && $this->dailySummaryReady($generation) $dailyDeltas = $table === 'store_report_bill_rows' && $this->dailySummaryReady($generation)
? $this->dailyDeltas($generation, $mapped) ? $this->dailyDeltas($generation, $mapped)
: []; : [];
@ -195,9 +219,11 @@ class StoreReportMirrorSynchronizer
} }
$this->connection->table('store_report_import_states')->where('dataset_key', $dataset)->update([ $this->connection->table('store_report_import_states')->where('dataset_key', $dataset)->update([
'cursor_id' => $cursor, 'cursor_id' => $cursor,
'row_count' => $initial ? $storedRows + count($mapped['upserts']) : $storedRows, 'row_count' => $nextStoredRows,
'updated_at' => now(), 'updated_at' => now(),
]); ]);
return $nextStoredRows;
}); });
} }
@ -212,18 +238,13 @@ class StoreReportMirrorSynchronizer
int $sourceRows, int $sourceRows,
): array { ): array {
$now = now(); $now = now();
$actualRows = (int) $this->connection->table(
$dataset === StoreReportRepository::BILLS_DATASET
? 'store_report_bill_rows'
: 'store_report_lifecycle_rows',
)->where('generation', $generation)->count();
$values = [ $values = [
'status' => 'succeeded', 'status' => 'succeeded',
'run_mode' => null, 'run_mode' => null,
'cursor_id' => $cursor, 'cursor_id' => $cursor,
'last_succeeded_at' => $now, 'last_succeeded_at' => $now,
'last_error_code' => null, 'last_error_code' => null,
'row_count' => $actualRows, 'row_count' => $storedRows,
'updated_at' => $now, 'updated_at' => $now,
]; ];
if ($mode === 'full') { if ($mode === 'full') {
@ -235,7 +256,7 @@ class StoreReportMirrorSynchronizer
} }
$this->connection->table('store_report_import_states')->where('dataset_key', $dataset)->update($values); $this->connection->table('store_report_import_states')->where('dataset_key', $dataset)->update($values);
return $this->result('succeeded', $mode, $batches, $sourceRows, $actualRows, $cursor); return $this->result('succeeded', $mode, $batches, $sourceRows, $storedRows, $cursor);
} }
/** @return array{status:string,mode:string,batches:int,source_rows:int,stored_rows:int,cursor_id:int} */ /** @return array{status:string,mode:string,batches:int,source_rows:int,stored_rows:int,cursor_id:int} */

View File

@ -69,7 +69,7 @@ class StoreReportMirrorSyncTest extends TestCase
public function test_incremental_sync_reuses_active_generation_and_overlaps_recent_ids(): void public function test_incremental_sync_reuses_active_generation_and_overlaps_recent_ids(): void
{ {
$this->activeState(StoreReportRepository::LIFECYCLES_DATASET, 'lifecycle-live', 20, 1); $this->activeState(StoreReportRepository::LIFECYCLES_DATASET, 'lifecycle-live', 20, 1);
$this->activeState(StoreReportRepository::BILLS_DATASET, 'bill-live', 100, 10); $this->activeState(StoreReportRepository::BILLS_DATASET, 'bill-live', 100, 1);
$this->activeState(StoreReportRepository::DAILY_DATASET, 'bill-live', 100, 1); $this->activeState(StoreReportRepository::DAILY_DATASET, 'bill-live', 100, 1);
DB::connection('business_mirror')->table('store_report_bill_rows')->insert([ DB::connection('business_mirror')->table('store_report_bill_rows')->insert([
'generation' => 'bill-live', 'source_id' => 101, 'shop_id' => 88, 'generation' => 'bill-live', 'source_id' => 101, 'shop_id' => 88,
@ -104,6 +104,52 @@ class StoreReportMirrorSyncTest extends TestCase
], 'business_mirror'); ], 'business_mirror');
} }
public function test_incremental_sync_tracks_inserts_and_deletes_without_counting_the_fact_table(): void
{
$this->activeState(StoreReportRepository::LIFECYCLES_DATASET, 'lifecycle-live', 20, 0);
$this->activeState(StoreReportRepository::BILLS_DATASET, 'bill-live', 100, 2);
$this->activeState(StoreReportRepository::DAILY_DATASET, 'bill-live', 100, 1);
$db = DB::connection('business_mirror');
$db->table('store_report_bill_rows')->insert([
$this->storedBill(101, 10),
$this->storedBill(102, 20),
]);
$db->table('store_report_daily_rows')->insert([
'generation' => 'bill-live', 'store_name' => '店铺A', 'occurred_on' => '2026-08-01',
'income' => 30, 'expense' => 0, 'updated_at' => now(),
]);
$deleted = $this->bill(102, '1', 20);
$deleted['del_flag'] = 1;
$upstream = $this->createMock(StoreReportUpstreamRepository::class);
$upstream->expects($this->once())->method('lifecycleBatch')->with(0, 100)->willReturn([]);
$upstream->expects($this->once())->method('billBatch')->with(50, 100)->willReturn([
$this->bill(101, '1', 11),
$deleted,
$this->bill(103, '-1', 7),
]);
$db->enableQueryLog();
$result = (new StoreReportMirrorSynchronizer($upstream, app('db')))->sync(100, 2);
$queries = $db->getQueryLog();
$db->disableQueryLog();
$this->assertSame(2, $result['bills']['stored_rows']);
$this->assertDatabaseMissing('store_report_bill_rows', [
'generation' => 'bill-live', 'source_id' => 102,
], 'business_mirror');
$this->assertDatabaseHas('store_report_bill_rows', [
'generation' => 'bill-live', 'source_id' => 103, 'expense' => 7,
], 'business_mirror');
$this->assertDatabaseHas('store_report_import_states', [
'dataset_key' => StoreReportRepository::BILLS_DATASET, 'row_count' => 2,
], 'business_mirror');
$this->assertFalse(collect($queries)->contains(
static fn (array $query): bool => str_contains(strtolower($query['query']), 'count(*)')
&& str_contains(strtolower($query['query']), 'store_report_bill_rows'),
));
}
private function bill(int $id, string $direction, float $amount): array private function bill(int $id, string $direction, float $amount): array
{ {
return [ return [
@ -115,6 +161,18 @@ class StoreReportMirrorSyncTest extends TestCase
]; ];
} }
/** @return array<string,mixed> */
private function storedBill(int $id, float $income): array
{
return [
'generation' => 'bill-live', 'source_id' => $id, 'shop_id' => 88,
'platform' => 'TouTiaoFXGZJ', 'platform_label' => '抖音', 'store_name' => '店铺A',
'occurred_at' => '2026-08-01 10:00:00', 'income' => $income, 'expense' => 0,
'bill_item' => '订单收入', 'record_code' => (string) $id, 'source_file' => 'source',
'row_hash' => 'old-' . $id, 'source_updated_at' => null, 'imported_at' => now(),
];
}
private function activeState(string $dataset, string $generation, int $cursor, int $rows): void private function activeState(string $dataset, string $generation, int $cursor, int $rows): void
{ {
DB::connection('business_mirror')->table('store_report_import_states')->insert([ DB::connection('business_mirror')->table('store_report_import_states')->insert([

View File

@ -12,7 +12,7 @@ class BusinessMirrorScheduleTest extends TestCase
$contents = file_get_contents(dirname(__DIR__, 3) . '/deploy/' . $script); $contents = file_get_contents(dirname(__DIR__, 3) . '/deploy/' . $script);
$catalogPosition = strpos($contents, 'business-mirror:sync-product-catalog --quiet'); $catalogPosition = strpos($contents, 'business-mirror:sync-product-catalog --quiet');
$purchasePricePosition = strpos($contents, 'business-mirror:sync-product-purchase-prices --quiet'); $purchasePricePosition = strpos($contents, 'business-mirror:sync-product-purchase-prices --quiet');
$storeReportPosition = strpos($contents, 'business-mirror:sync-store-report --quiet'); $storeReportPosition = strpos($contents, 'business-mirror:sync-store-report --max-batches=12 --quiet');
$snapshotPosition = strpos($contents, 'upstream:snapshots:refresh-all --quiet'); $snapshotPosition = strpos($contents, 'upstream:snapshots:refresh-all --quiet');
$pricePosition = strpos($contents, 'business-mirror:sync-product-prices --quiet'); $pricePosition = strpos($contents, 'business-mirror:sync-product-prices --quiet');
$genericPosition = strpos($contents, 'business-mirror:sync-tables --quiet'); $genericPosition = strpos($contents, 'business-mirror:sync-tables --quiet');
@ -25,13 +25,24 @@ class BusinessMirrorScheduleTest extends TestCase
$this->assertNotFalse($genericPosition, $script); $this->assertNotFalse($genericPosition, $script);
$this->assertLessThan($snapshotPosition, $catalogPosition, $script); $this->assertLessThan($snapshotPosition, $catalogPosition, $script);
$this->assertLessThan($snapshotPosition, $purchasePricePosition, $script); $this->assertLessThan($snapshotPosition, $purchasePricePosition, $script);
$this->assertLessThan($snapshotPosition, $storeReportPosition, $script);
$this->assertLessThan($purchasePricePosition, $catalogPosition, $script); $this->assertLessThan($purchasePricePosition, $catalogPosition, $script);
$this->assertLessThan($pricePosition, $catalogPosition, $script); $this->assertLessThan($pricePosition, $catalogPosition, $script);
$this->assertLessThan($genericPosition, $catalogPosition, $script); $this->assertLessThan($genericPosition, $catalogPosition, $script);
$this->assertSame(1, substr_count($contents, 'business-mirror:sync-product-catalog --quiet'), $script); $this->assertSame(1, substr_count($contents, 'business-mirror:sync-product-catalog --quiet'), $script);
$this->assertSame(2, substr_count($contents, 'business-mirror:sync-product-purchase-prices --quiet'), $script); $this->assertSame(2, substr_count($contents, 'business-mirror:sync-product-purchase-prices --quiet'), $script);
$this->assertSame(1, substr_count($contents, 'business-mirror:sync-store-report --quiet'), $script); $this->assertSame(1, substr_count($contents, 'business-mirror:sync-store-report'), $script);
$this->assertStringContainsString(
'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',
$contents,
$script,
);
$minuteLines = array_values(array_filter(
preg_split('/\R/', $contents) ?: [],
static fn (string $line): bool => str_starts_with($line, '* * * * * root'),
));
$minuteLine = $minuteLines[0] ?? null;
$this->assertIsString($minuteLine, $script);
$this->assertStringNotContainsString('business-mirror:sync-store-report', $minuteLine, $script);
$this->assertStringContainsString('docker exec -u www-data caiwu_app', $contents, $script); $this->assertStringContainsString('docker exec -u www-data caiwu_app', $contents, $script);
$this->assertStringContainsString('/run/caiwu-upstream-sync.lock', $contents, $script); $this->assertStringContainsString('/run/caiwu-upstream-sync.lock', $contents, $script);
$this->assertStringContainsString("CRON_TZ=Asia/Shanghai\n", $contents, $script); $this->assertStringContainsString("CRON_TZ=Asia/Shanghai\n", $contents, $script);
@ -52,6 +63,12 @@ class BusinessMirrorScheduleTest extends TestCase
$contents, $contents,
$script, $script,
); );
$this->assertStringContainsString(
'business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet',
$contents,
$script,
);
$this->assertSame(1, substr_count($contents, 'business-mirror:warm-report-caches --quiet'), $script);
$this->assertSame( $this->assertSame(
1, 1,
substr_count($contents, 'business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet'), substr_count($contents, 'business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet'),
@ -103,6 +120,20 @@ class BusinessMirrorScheduleTest extends TestCase
} }
} }
public function test_shared_config_database_is_sized_for_reports_and_records_slow_queries(): void
{
$compose = file_get_contents(dirname(__DIR__, 3) . '/compose.jiangling.yml');
$start = strpos($compose, " config_db:\n");
$end = strpos($compose, " clickhouse:\n", $start);
$service = substr($compose, $start, $end - $start);
$this->assertStringContainsString('--innodb-buffer-pool-size=4G', $service);
$this->assertStringContainsString('--slow-query-log=ON', $service);
$this->assertStringContainsString('--long-query-time=5', $service);
$this->assertStringContainsString('mem_limit: 6g', $service);
$this->assertStringContainsString('cpus: "4.00"', $service);
}
public function test_production_supplier_payable_maintenance_uses_the_business_mirror(): void public function test_production_supplier_payable_maintenance_uses_the_business_mirror(): void
{ {
$compose = file_get_contents(dirname(__DIR__, 3) . '/compose.jiangling.yml'); $compose = file_get_contents(dirname(__DIR__, 3) . '/compose.jiangling.yml');

View File

@ -3,6 +3,7 @@
namespace Tests\Unit\EcommerceFinance; namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository; use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
@ -333,7 +334,7 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
$this->assertSame(['income' => 100.25, 'expense' => 0.0, 'balance' => 100.25, 'count' => 1], $result['summary']); $this->assertSame(['income' => 100.25, 'expense' => 0.0, 'balance' => 100.25, 'count' => 1], $result['summary']);
} }
public function test_taobao_scoped_pages_recount_the_subset_and_observe_the_next_sync(): void public function test_taobao_scoped_pages_share_one_aggregate_across_sync_state_churn(): void
{ {
$this->markReady(); $this->markReady();
$db = DB::connection('business_mirror'); $db = DB::connection('business_mirror');
@ -361,9 +362,12 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
$db->table('ops_jst_original_bill_info')->insert($this->row(3, 7, '2026-08-03 09:00:00', '1', 4)); $db->table('ops_jst_original_bill_info')->insert($this->row(3, 7, '2026-08-03 09:00:00', '1', 4));
$state->update(['mirror_row_count' => 3, 'last_succeeded_at' => '2026-09-08 09:00:00']); $state->update(['mirror_row_count' => 3, 'last_succeeded_at' => '2026-09-08 09:00:00']);
$this->assertSame(3, $this->repository->paginate([], 'occurred_at', 'desc', 1, 1)['total']); $this->assertSame(2, $this->repository->paginate([], 'occurred_at', 'desc', 1, 1)['total']);
$this->assertSame(1, $this->repository->paginate(['business_type' => '支出'], 'occurred_at', 'desc', 1, 1)['total']); $this->assertSame(1, $this->repository->paginate(['business_type' => '支出'], 'occurred_at', 'desc', 1, 1)['total']);
Cache::flush();
$this->assertSame(3, $this->repository->paginate([], 'occurred_at', 'desc', 1, 1)['total']);
$state->update(['status' => 'running', 'mirror_row_count' => 2]); $state->update(['status' => 'running', 'mirror_row_count' => 2]);
$this->assertSame(3, $this->repository->paginate([], 'occurred_at', 'desc', 1, 1)['total']); $this->assertSame(3, $this->repository->paginate([], 'occurred_at', 'desc', 1, 1)['total']);
$state->update(['status' => 'failed']); $state->update(['status' => 'failed']);
@ -372,7 +376,7 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
$db->flushQueryLog(); $db->flushQueryLog();
$db->enableQueryLog(); $db->enableQueryLog();
$this->assertSame(3, $this->repository->paginate([], 'occurred_at', 'desc', 1, 1)['total']); $this->assertSame(3, $this->repository->paginate([], 'occurred_at', 'desc', 1, 1)['total']);
$this->assertCount(1, array_filter($db->getQueryLog(), fn ($q) => str_contains(strtolower($q['query']), 'count(*)'))); $this->assertCount(0, array_filter($db->getQueryLog(), fn ($q) => str_contains(strtolower($q['query']), 'count(*)')));
$db->disableQueryLog(); $db->disableQueryLog();
$state->update(['status' => 'succeeded', 'mirror_row_count' => null]); $state->update(['status' => 'succeeded', 'mirror_row_count' => null]);
$this->assertSame(3, $this->repository->paginate([], 'occurred_at', 'desc', 1, 1)['total']); $this->assertSame(3, $this->repository->paginate([], 'occurred_at', 'desc', 1, 1)['total']);