CaiWuXiTong/modules/EcommerceFinance/Repositories/StoreAccountMirrorRepository.php
renshen 401dfb089a 店铺账号密码:上游新增字段自动跟随(镜像补列 + 页面自动多出这一列)
新增 business-mirror:follow-upstream-columns:先把数据字典刷新到最新,再把上游
视图里新增、当前数据集还没同步的字段追加进镜像定义并立刻建列,下一次常规同步
就会带回数据。只做新增,不删除/不改写已有字段;字段名带 password/secret/token/
cipher/credential 等敏感关键字的一律跳过。已登记到发布 cron(每天 06:40)。

页面侧:config/upstream_mirrors.php 的 auto_follow_columns 用「页面 => 数据集」
登记,FollowedUpstreamColumns 服务把 followed_columns 变成表格列;这类列只展示、
不生成查询条件(后端还没有对应筛选口径)。

数据集自带的数据变动仍然由每分钟的 business-mirror:sync-tables 增量同步。
2026-10-08 13:19:53 +08:00

339 lines
15 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\FollowedUpstreamColumns;
use RuntimeException;
class StoreAccountMirrorRepository
{
public const DATASET = 'database_4.external_store_accounts.accounts_v1';
/** 页面已接入的字段:页面字段名 => 镜像列名。 */
private const FIELD_COLUMNS = [
'store_name' => 'storeName', 'operation_owner' => 'operationOwner',
'operation_department_head' => 'operationDepartmentHead', 'operation_director' => 'operationDirector',
'profit_center' => 'profitCenter', 'account_type' => 'accountType', 'account' => 'account',
'password_configured' => 'passwordConfigured', 'bound_phone' => 'boundPhone',
'verification_contact' => 'verificationContact', 'account_group_name' => 'accountGroupName',
'created_at' => 'createdAt', 'updated_at' => 'updatedAt',
];
/** 店铺档案(fys_shopInfo.shopState)到页面文案的映射。 */
private const STORE_STATUS_LABELS = [0 => '正常', 3 => '关闭中', 6 => '已关闭'];
/** 授权状态文案;顺序同时用于下拉和排序。 */
private const AUTHORIZATION_LABELS = ['已授权', '已过期', '未授权'];
/** 账号店铺名 -> 状态 的缓存键(两张档案表要全表读,缓存 10 分钟)。 */
private const STATUS_CACHE_KEY = 'store-accounts.statuses:v1';
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {}
/** @return array{data:array<int,array<string,mixed>>,total:int} */
public function paginate(int $page, int $perPage, array $filters = []): array
{
$dataset = $this->datasets->get(self::DATASET);
if (! $this->mirror->ready($dataset)) {
throw new RuntimeException('store_account_mirror_not_ready');
}
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$query = $this->mirror->connection()->table($dataset->targetTable());
$columns = [
'store_name' => 'storeName', 'operation_owner' => 'operationOwner',
'operation_department_head' => 'operationDepartmentHead', 'operation_director' => 'operationDirector',
'profit_center' => 'profitCenter', 'account' => 'account', 'bound_phone' => 'boundPhone',
'verification_contact' => 'verificationContact', 'account_group_name' => 'accountGroupName',
];
foreach ($columns as $key => $column) {
$value = mb_substr(trim((string) ($filters[$key] ?? '')), 0, 191);
if ($value !== '') $query->where($column, 'like', '%' . addcslashes($value, '%_\\') . '%');
}
$accountType = trim((string) ($filters['account_type'] ?? ''));
if ($accountType !== '') $query->where('accountType', '=', $accountType === '主账号' ? 'MAIN' : ($accountType === '子账号' ? 'SUB' : $accountType));
$passwordState = trim((string) ($filters['password_configured'] ?? ''));
if (in_array($passwordState, ['已设置', '未设置', '状态未同步'], true)) {
if ($passwordState === '状态未同步') $query->whereNull('passwordConfigured');
else $query->where('passwordConfigured', $passwordState === '已设置');
}
foreach (['created_at' => 'createdAt', 'updated_at' => 'updatedAt'] as $key => $column) {
foreach (['_from' => '>=', '_to' => '<='] as $suffix => $operator) {
$date = trim((string) ($filters[$key . $suffix] ?? ''));
if (preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date)) $query->whereDate($column, $operator, $date);
}
}
// 授权状态 / 店铺状态来自另两张镜像表(按店铺名关联),这里先在 PHP 里
// 把"账号店铺名 -> 状态"算出来,再用 whereIn 下推到 SQL,分页和总数不受影响。
$statuses = (trim((string) ($filters['authorization_status'] ?? '')) !== ''
|| trim((string) ($filters['store_status'] ?? '')) !== '')
? $this->statusesByStoreName()
: null;
if ($statuses !== null) {
foreach ([
'store_status' => $statuses['store'],
'authorization_status' => $statuses['authorization'],
] as $key => $byStore) {
$expected = trim((string) ($filters[$key] ?? ''));
if ($expected === '') {
continue;
}
$query->whereIn('storeName', $this->storeNamesWithStatus($byStore, $expected));
}
}
$total = (clone $query)->count();
$sortColumns = [
'store_name' => 'storeName', 'operation_owner' => 'operationOwner',
'operation_department_head' => 'operationDepartmentHead', 'operation_director' => 'operationDirector',
'profit_center' => 'profitCenter', 'account_type' => 'accountType', 'account' => 'account',
'password_configured' => 'passwordConfigured', 'bound_phone' => 'boundPhone',
'verification_contact' => 'verificationContact', 'account_group_name' => 'accountGroupName',
'created_at' => 'createdAt', 'updated_at' => 'updatedAt',
];
$sortColumn = $sortColumns[$filters['_sort'] ?? ''] ?? null;
$direction = ($filters['_direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc';
if ($sortColumn !== null) {
$query->orderBy($sortColumn, $direction)->orderByDesc('id');
} elseif (in_array($filters['_sort'] ?? '', ['store_status', 'authorization_status'], true)) {
// 关联出来的状态列按业务顺序排(正常/关闭中/已关闭、已授权/已过期/未授权)。
$sortKey = $filters['_sort'] === 'store_status' ? 'store' : 'authorization';
$labels = $filters['_sort'] === 'store_status'
? array_values(self::STORE_STATUS_LABELS)
: self::AUTHORIZATION_LABELS;
$statuses ??= $this->statusesByStoreName();
[$sql, $bindings] = $this->statusOrderBy($statuses[$sortKey], $labels, $direction);
$query->orderByRaw($sql, $bindings)->orderByDesc('id');
} else {
$query->orderByDesc('id');
}
$statuses ??= $this->statusesByStoreName();
$followedColumns = $this->followedColumns();
$rows = $query
->forPage($page, $perPage)
->get()
->map(function (object $row) use ($statuses, $followedColumns): array {
$mapped = [
'id' => $row->id,
'store_name' => $row->storeName,
'operation_owner' => $row->operationOwner,
'operation_department_head' => $row->operationDepartmentHead,
'operation_director' => $row->operationDirector,
'profit_center' => $row->profitCenter,
'account_type' => $this->accountType($row->accountType),
'account' => $row->account,
// Only the safe boolean may enter the mirror, never a password.
'password_configured' => isset($row->passwordConfigured) ? (bool) $row->passwordConfigured : null,
'bound_phone' => $row->boundPhone,
'verification_contact' => $row->verificationContact,
'account_group_name' => $row->accountGroupName,
'authorization_status' => $statuses['authorization'][$row->storeName] ?? '',
'store_status' => $statuses['store'][$row->storeName] ?? '',
'created_at' => $row->createdAt,
'updated_at' => $row->updatedAt,
];
// 自动跟随上游补进来的字段:镜像里有什么就带什么,页面据此多出对应列。
foreach ($followedColumns as $column) {
$mapped[$column] = $row->{$column} ?? null;
}
return $mapped;
})
->all();
return ['data' => $rows, 'total' => $total];
}
/**
* 账号店铺名 -> 授权状态 / 店铺状态。
*
* - 授权状态:聚水潭店铺档案(jst_shopInfo)的 session_expired —— 无授权时间为未授权,
* 已过期;否则为已授权。
* - 店铺状态:店铺档案(fys_shopInfo)的 shopState —— 0 正常 / 3 关闭中 / 6 已关闭。
*
* 两张表按店铺名(把下划线、空格统一成短横线后)关联,实测账号表 966 个店铺里
* 授权命中 963、店铺状态命中 948。
*
* @return array{store:array<string,string>,authorization:array<string,string>}
*/
private function statusesByStoreName(): array
{
if (is_array($cached = Cache::get(self::STATUS_CACHE_KEY))) {
return $cached;
}
$storeStates = [];
foreach ($this->mirror->connection()->table('ops_fys_shop_info')
->select(['Id', 'erpShopName', 'shopState'])->orderBy('Id')->get() as $row) {
$key = $this->shopKey($row->erpShopName);
if ($key !== '') {
$storeStates[$key] = self::STORE_STATUS_LABELS[(int) $row->shopState] ?? '';
}
}
$authorizations = [];
$now = now()->getTimestamp();
foreach ($this->mirror->connection()->table('ops_jst_shop_info')
->select(['Id', 'shop_name', 'session_expired'])->orderBy('Id')->get() as $row) {
$key = $this->shopKey($row->shop_name);
if ($key === '') {
continue;
}
$expired = trim((string) $row->session_expired);
$timestamp = $expired === '' ? false : strtotime($expired);
$authorizations[$key] = $timestamp === false
? '未授权'
: ($timestamp >= $now ? '已授权' : '已过期');
}
$result = ['store' => [], 'authorization' => []];
foreach ($this->mirror->connection()->table($this->datasets->get(self::DATASET)->targetTable())
->select('storeName')->whereNotNull('storeName')->distinct()->pluck('storeName') as $name) {
$name = (string) $name;
$key = $this->shopKey($name);
$result['store'][$name] = $storeStates[$key] ?? '';
$result['authorization'][$name] = $authorizations[$key] ?? '';
}
Cache::put(self::STATUS_CACHE_KEY, $result, now()->addMinutes(10));
return $result;
}
/** @return list<string> 指定状态下的账号店铺名 */
private function storeNamesWithStatus(array $byStore, string $expected): array
{
$names = [];
foreach ($byStore as $name => $status) {
if ($status === $expected) {
$names[] = $name;
}
}
return $names;
}
/**
* 把状态列翻译成 SQL 排序:按给定文案顺序生成 CASE WHEN。
*
* @param array<string,string> $byStore
* @param list<string> $labels
* @return array{0:string,1:list<string>}
*/
private function statusOrderBy(array $byStore, array $labels, string $direction): array
{
$cases = [];
$bindings = [];
foreach ($labels as $index => $label) {
$names = $this->storeNamesWithStatus($byStore, $label);
if ($names === []) {
continue;
}
$cases[] = 'WHEN storeName IN (' . implode(', ', array_fill(0, count($names), '?')) . ') THEN ' . $index;
array_push($bindings, ...$names);
}
if ($cases === []) {
return ['1', []];
}
return ['CASE ' . implode(' ', $cases) . ' ELSE ' . count($labels) . ' END ' . $direction, $bindings];
}
/** 店铺名归一键:下划线、半/全角空格统一成短横线,便于跨表关联。 */
private function shopKey(mixed $name): string
{
return str_replace(['_', ' ', ' '], ['-', '', ''], trim((string) $name));
}
/** @return list<string> 自动跟随上游补进来的字段(镜像列名) */
public function followedColumns(): array
{
return app(FollowedUpstreamColumns::class)->columns(self::DATASET);
}
/**
* 给页面字段列表补上自动跟随进来的列(字段名与标签都用上游列名)。
*
* @param list<array<string,mixed>> $fields
* @return list<array<string,mixed>>
*/
public function withFollowedFields(array $fields): array
{
return app(FollowedUpstreamColumns::class)->appendFields(self::DATASET, $fields);
}
public function containsAccount(string $id): bool
{
$dataset = $this->datasets->get(self::DATASET);
if (! $this->mirror->ready($dataset)) {
throw new RuntimeException('store_account_mirror_not_ready');
}
return $this->mirror->connection()->table($dataset->targetTable())->where('id', $id)->exists();
}
public function containsAccounts(array $ids): bool
{
$ids = array_values(array_unique($ids));
if ($ids === []) {
return false;
}
$dataset = $this->datasets->get(self::DATASET);
if (! $this->mirror->ready($dataset)) {
throw new RuntimeException('store_account_mirror_not_ready');
}
return $this->mirror->connection()->table($dataset->targetTable())
->whereIn('id', $ids)->distinct()->count('id') === count($ids);
}
/**
* 店铺维度报表的归属字段覆盖表,以店铺名称(storeName)为键。
*
* @return array<string,array{operation_owner:string,operation_director:string,profit_center:string}>
*/
public function ownershipByStoreName(): array
{
$dataset = $this->datasets->get(self::DATASET);
if (! $this->mirror->ready($dataset)) {
throw new RuntimeException('store_account_mirror_not_ready');
}
$map = [];
foreach ($this->mirror->connection()->table($dataset->targetTable())
->select('storeName', 'operationOwner', 'operationDirector', 'profitCenter')
->get() as $row) {
$name = trim((string) $row->storeName);
if ($name === '') {
continue;
}
$map[$name] = [
'operation_owner' => trim((string) $row->operationOwner),
'operation_director' => trim((string) $row->operationDirector),
'profit_center' => trim((string) $row->profitCenter),
];
}
return $map;
}
private function accountType(mixed $value): ?string
{
$type = trim((string) $value);
return match (strtoupper($type)) {
'MAIN' => '主账号',
'SUB' => '子账号',
'', '/' => null,
default => $type,
};
}
}