防止淘系账单筛选重复扫表 #268
@ -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]);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user