防止淘系账单筛选重复扫表 #268

Merged
WuYongJie merged 1 commits from feature/WuYongJie-alipay-wechat-filters-integration into main 2026-09-24 15:22:41 +08:00
6 changed files with 135 additions and 43 deletions

View File

@ -38,6 +38,16 @@ class WarmReportCaches extends Command
try {
$result = $alipay->paginate([], 'occurred_at', 'desc', 1, 10);
$this->info(sprintf('淘系支付宝首页已预热,共 %d 行。', $result['total']));
$options = $alipay->filterOptionsForChannel(
'alipayCSV',
StoreReceivingAlipayBillMirrorRepository::DATASET,
true,
);
$this->info(sprintf(
'淘系支付宝筛选选项已预热,共 %d 个店铺、%d 个账单项目。',
count($options['shops']),
count($options['bill_items']),
));
} catch (Throwable $exception) {
$failed = true;
Log::error('淘系支付宝报表缓存预热失败。', ['exception' => $exception::class]);

View File

@ -166,12 +166,19 @@ class AccountBills extends Controller
$this->mirrorRepository($bill_type)->filterOptions()
);
} catch (RuntimeException $exception) {
if (! in_array($exception->getMessage(), ['store_receiving_alipay_bill_mirror_not_ready', 'jd_daily_bill_mirror_not_ready', 'jd_wallet_bill_mirror_not_ready'], true)) {
if (! in_array($exception->getMessage(), [
'store_receiving_alipay_bill_mirror_not_ready',
'store_receiving_bill_filter_options_busy',
'jd_daily_bill_mirror_not_ready',
'jd_wallet_bill_mirror_not_ready',
], true)) {
throw $exception;
}
return response()->json([
'message' => $label . '账户账单共享业务镜像正在首次同步,筛选选项稍后可用。',
'message' => $exception->getMessage() === 'store_receiving_bill_filter_options_busy'
? $label . '账户账单筛选选项正在生成,请稍后重试。'
: $label . '账户账单共享业务镜像正在首次同步,筛选选项稍后可用。',
'shops' => [],
'bill_items' => [],
], 503);

View File

@ -94,12 +94,17 @@ class AlipayBills extends Controller
'alipayCSV', StoreReceivingAlipayBillMirrorRepository::DATASET, true
));
} catch (RuntimeException $exception) {
if ($exception->getMessage() !== 'store_receiving_alipay_bill_mirror_not_ready') {
if (! in_array($exception->getMessage(), [
'store_receiving_alipay_bill_mirror_not_ready',
'store_receiving_bill_filter_options_busy',
], true)) {
throw $exception;
}
return response()->json([
'message' => '淘系支付宝账单共享业务镜像正在首次同步,筛选选项稍后可用。',
'message' => $exception->getMessage() === 'store_receiving_bill_filter_options_busy'
? '淘系支付宝筛选选项正在生成,请稍后重试。'
: '淘系支付宝账单共享业务镜像正在首次同步,筛选选项稍后可用。',
'shops' => [],
'bill_items' => [],
], 503);

View File

@ -22,6 +22,8 @@ class StoreReceivingAlipayBillMirrorRepository
private const AGGREGATE_CACHE_WAIT_SECONDS = 35;
private const FILTER_OPTIONS_CACHE_SECONDS = 900;
/** 淘系支付宝页面不展示其他平台店铺;头条放心购是镜像中的抖音平台名称。 */
private const EXCLUDED_TAOBAO_SHOP_SITE_PREFIXES = [
'拼多多', '京东', '抖音', '头条放心购', '得物', '唯品会',
@ -101,7 +103,7 @@ class StoreReceivingAlipayBillMirrorRepository
$channel,
$excludeNonTaobaoPlatforms,
),
fn (): array => $this->summary($query, $filters, $channel),
fn (): array => $this->summary($query, $filters, $channel, ! $useRecordedCount),
);
$total = $useRecordedCount
? (int) $recordedCount
@ -190,46 +192,51 @@ class StoreReceivingAlipayBillMirrorRepository
throw new RuntimeException('store_receiving_alipay_bill_mirror_not_ready');
}
$channelQuery = $this->billQuery($dataset->targetTable());
$this->applyChannel($channelQuery, $channel, $dataset->fixedFilters());
if ($excludeNonTaobaoPlatforms) {
$this->applyTaobaoPlatformScope($channelQuery);
}
return $this->rememberFilterOptions(
$this->filterOptionsCacheKey($datasetKey, $channel, $excludeNonTaobaoPlatforms),
function () use ($dataset, $channel, $excludeNonTaobaoPlatforms): array {
$channelQuery = $this->billQuery($dataset->targetTable());
$this->applyChannel($channelQuery, $channel, $dataset->fixedFilters());
if ($excludeNonTaobaoPlatforms) {
$this->applyTaobaoPlatformScope($channelQuery);
}
$shopIds = (clone $channelQuery)
->whereNotNull('b.shop_id')
->distinct()
->pluck('b.shop_id')
->filter(static fn (mixed $value): bool => $value !== null && $value !== '')
->all();
$shopIds = (clone $channelQuery)
->whereNotNull('b.shop_id')
->distinct()
->pluck('b.shop_id')
->filter(static fn (mixed $value): bool => $value !== null && $value !== '')
->all();
$shops = $shopIds === []
? collect()
: $this->mirror->connection()->table('ops_jst_shop_info')
->whereIn('shop_id', $shopIds)
->whereNotNull('shop_name')
->orderBy('shop_name')
->pluck('shop_name');
$shops = $shopIds === []
? collect()
: $this->mirror->connection()->table('ops_jst_shop_info')
->whereIn('shop_id', $shopIds)
->whereNotNull('shop_name')
->orderBy('shop_name')
->pluck('shop_name');
$billItems = (clone $channelQuery)
->whereNotNull('b.fee_name')
->where('b.fee_name', '<>', '')
->select('b.fee_name')
->distinct()
->orderBy('b.fee_name')
->pluck('b.fee_name');
$billItems = (clone $channelQuery)
->whereNotNull('b.fee_name')
->where('b.fee_name', '<>', '')
->select('b.fee_name')
->distinct()
->orderBy('b.fee_name')
->pluck('b.fee_name');
$normalize = static fn ($values): array => collect($values)
->map(static fn (mixed $value): string => trim((string) $value))
->filter()
->unique()
->values()
->all();
$normalize = static fn ($values): array => collect($values)
->map(static fn (mixed $value): string => trim((string) $value))
->filter()
->unique()
->values()
->all();
return [
'shops' => $normalize($shops),
'bill_items' => $normalize($billItems),
];
return [
'shops' => $normalize($shops),
'bill_items' => $normalize($billItems),
];
},
);
}
/** Read bounded pages so a full export does not sort/buffer millions of rows at once. */
@ -332,6 +339,7 @@ class StoreReceivingAlipayBillMirrorRepository
Builder $billQuery,
array $filters,
string|array|null $channel = null,
bool $includeCount = true,
): array
{
if ($channel === null && $this->dailySummarySupports($filters)) {
@ -342,7 +350,7 @@ class StoreReceivingAlipayBillMirrorRepository
}
$aggregate = (clone $billQuery)->selectRaw(
"COUNT(*) AS aggregate_count, "
($includeCount ? 'COUNT(*) AS aggregate_count, ' : '')
. "COALESCE(SUM(CASE WHEN b.inout_type = '1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS income, "
. "COALESCE(SUM(CASE WHEN b.inout_type = '-1' THEN ABS(COALESCE(b.amount, 0)) ELSE 0 END), 0) AS expense"
);
@ -351,7 +359,7 @@ class StoreReceivingAlipayBillMirrorRepository
return [
'income' => round((float) ($row->income ?? 0), 2),
'expense' => round((float) ($row->expense ?? 0), 2),
'count' => (int) ($row->aggregate_count ?? 0),
'count' => $includeCount ? (int) ($row->aggregate_count ?? 0) : null,
];
}
@ -870,6 +878,54 @@ class StoreReceivingAlipayBillMirrorRepository
}
}
private function filterOptionsCacheKey(
string $datasetKey,
string|array $channel,
bool $excludeNonTaobaoPlatforms,
): string {
$normalizedChannel = is_array($channel) ? array_map('strval', $channel) : $channel;
if (is_array($normalizedChannel)) {
sort($normalizedChannel);
}
return 'store-receiving-bills:filter-options:v1:' . hash('sha256', (string) json_encode([
'dataset' => $datasetKey,
'channel' => $normalizedChannel,
'exclude_non_taobao' => $excludeNonTaobaoPlatforms,
], JSON_UNESCAPED_UNICODE));
}
/** @param callable():array{shops:array<int,string>,bill_items:array<int,string>} $resolver */
private function rememberFilterOptions(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::FILTER_OPTIONS_CACHE_SECONDS);
return $resolved;
});
} catch (LockTimeoutException $exception) {
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
throw new RuntimeException('store_receiving_bill_filter_options_busy', 0, $exception);
}
}
private function escapeLike(string $value): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);

View File

@ -154,7 +154,7 @@ export default {
@media(max-width:900px){.table-workspace{padding:.75rem}.tw-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.tw-filter>label,.tw-filter .tw-keyword{width:100%}.tw-filter input,.tw-filter select{width:100%}.tw-date input{min-width:0}.tw-actions{margin-left:0}.tw-toolbar,.tw-pager{align-items:flex-start;flex-direction:column}.tw-tools{justify-content:flex-start}.tw-scroll{max-height:58vh;width:100%}}@media(max-width:520px){.tw-stats{grid-template-columns:1fr}.tw-date span{align-items:stretch;flex-direction:column}.tw-tools>select{min-width:0;max-width:100%}.tw-page-actions{flex-wrap:wrap}}
.tw-toolbar { flex-wrap: wrap; }
.tw-tools { flex: 1 1 24rem; min-width: 0; }
.tw-filter { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.tw-filter { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.tw-filter .tw-date { grid-column: span 2; }
.tw-filter ::v-deep .el-select { width: 100%; min-width: 0; }
.tw-filter ::v-deep .el-select .el-input__inner { height: 34px; min-width: 0; padding-right: 30px; border-color: #d8d2c7; border-radius: 8px; color: #171717; font: inherit; }

View File

@ -469,6 +469,7 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
public function test_alipay_filter_options_match_the_visible_taobao_channel_and_platform_scope(): void
{
Cache::flush();
$this->markReady();
$db = DB::connection('business_mirror');
$db->table('ops_jst_shop_info')->insert([
@ -482,12 +483,25 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase
$this->row(3, 9, '2026-08-01 11:00:00', '1', 30, 'OtherChannel', '其他渠道费用'),
]);
$db->flushQueryLog();
$db->enableQueryLog();
$options = $this->repository->filterOptionsForChannel(
'alipayCSV', StoreReceivingAlipayBillMirrorRepository::DATASET, true
);
$cachedOptions = $this->repository->filterOptionsForChannel(
'alipayCSV', StoreReceivingAlipayBillMirrorRepository::DATASET, true
);
$this->assertSame(['天猫A店'], $options['shops']);
$this->assertSame(['交易收款'], $options['bill_items']);
$this->assertSame($options, $cachedOptions);
$optionQueries = array_filter(
$db->getQueryLog(),
static fn (array $query): bool => str_contains($query['query'], 'select distinct')
&& (str_contains($query['query'], '"b"."shop_id"') || str_contains($query['query'], '"b"."fee_name"')),
);
$this->assertCount(2, $optionQueries, '相同筛选范围只应扫描一次店铺和账单项目选项。');
$db->disableQueryLog();
}
public function test_shared_dataset_keeps_channel_predicates_for_all_query_entry_points(): void