Merge remote-tracking branch 'origin/main'

This commit is contained in:
chenxi 2026-10-07 16:18:47 +08:00
commit 701bc42f44
4 changed files with 251 additions and 5 deletions

View File

@ -65,6 +65,14 @@ if ! flock -n 9; then
exit 1
fi
# 容器替换会终止其中的同步进程;与生产刷新任务共享同一把锁。
exec 8>/run/caiwu-upstream-sync.lock
echo "Waiting for active upstream synchronization before replacing the application container"
if ! flock -w 1800 8; then
echo "Upstream synchronization is still running; application container was not replaced" >&2
exit 1
fi
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"

View File

@ -69,6 +69,14 @@ if ! flock -n 9; then
exit 1
fi
# 容器替换会终止其中的同步进程;与生产刷新任务共享同一把锁。
exec 8>/run/caiwu-upstream-sync.lock
echo "Waiting for active upstream synchronization before replacing the application container"
if ! flock -w 1800 8; then
echo "Upstream synchronization is still running; application container was not replaced" >&2
exit 1
fi
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"

View File

@ -17,6 +17,9 @@ class AccountLedgerRemarkMap
{
public const CACHE_KEY = 'ec_account_ledger_remarks_map_v1';
/** 「平台 + 账单项目」→ 科目映射表规则(请求内缓存,避免逐行扫描全部规则)。 */
private ?array $accountMappingIndex = null;
/** 页签默认平台(店铺名没有平台前缀时使用)。 */
private const TAB_PLATFORM = [
'taobao-alipay' => '淘宝',
@ -241,31 +244,152 @@ class AccountLedgerRemarkMap
return $tabPlatform;
}
/** 查总账备注;没有匹配返回 null。 */
public function remark(?string $storeName, ?string $tabPlatform, ?string $billItem): ?string
/**
* 查总账备注;没有匹配返回 null。
*
* 取值顺序:
* ①「财务规则 › 科目映射表」里同平台同账单项目的「财务备注」(匹配器按业务备注/商品名称关键词取最具体的一条)
* ② 上面没匹到时,回落到本表(账单项目 → 总账备注)原有的口径
*/
public function remark(
?string $storeName,
?string $tabPlatform,
?string $billItem,
array $businessRemarks = [],
string $goodsName = '',
): ?string
{
$item = trim((string) $billItem);
if ($item === '') {
return null;
}
$platforms = [$this->platform($storeName, $tabPlatform), $tabPlatform];
foreach ($platforms as $platform) {
if ($platform === null || $platform === '') {
continue;
}
$fromRule = $this->accountMappingRemark($platform, $item, $businessRemarks, $goodsName);
if ($fromRule !== null) {
return $fromRule;
}
}
$map = $this->map();
if ($map === []) {
return null;
}
foreach ([$this->platform($storeName, $tabPlatform), $tabPlatform] as $platform) {
foreach ($platforms as $platform) {
if ($platform === null || $platform === '') {
continue;
}
if (isset($map[$platform][$item])) {
return $map[$platform][$item];
// 先按原样匹配,再按「去掉 wx 前缀」的账单项目匹配(淘系部分行带 wx 前缀)
foreach ($this->itemLookupKeys($item) as $candidate) {
if (isset($map[$platform][$candidate])) {
return $map[$platform][$candidate];
}
}
}
return null;
}
/** 科目映射表按「平台 + 账单项目」预索引,避免逐行扫全部规则。 */
private function accountMappingIndex(): array
{
if ($this->accountMappingIndex !== null) {
return $this->accountMappingIndex;
}
$index = [];
try {
foreach (app(AccountMappingStore::class)->all() as $rule) {
if (! is_array($rule)) {
continue;
}
$platform = trim((string) ($rule['platform'] ?? ''));
$item = trim((string) ($rule['item'] ?? ''));
if ($platform === '' || $item === '') {
continue;
}
// 平台/账单项目做大小写与空格归一:映射表里写的是「唯品会JX」,页签内部键是「唯品会jx」
$index[$this->normalizeKey($platform)][$this->normalizeKey($item)][] = $rule;
}
} catch (Throwable) {
$index = [];
}
return $this->accountMappingIndex = $index;
}
/** 同平台同账单项目的「财务备注」;没有规则或财务备注为空时返回 null(由调用方回落)。 */
private function accountMappingRemark(string $platform, string $item, array $businessRemarks, string $goodsName): ?string
{
$index = $this->accountMappingIndex();
$platformKey = $this->normalizeKey($platform);
// 先按原样匹配,再按「去掉 wx 前缀」的账单项目匹配(科目映射表里部分项目不带 wx 前缀)
foreach ($this->itemLookupKeys($item) as $candidate) {
$rules = $index[$platformKey][$this->normalizeKey($candidate)] ?? [];
if ($rules === []) {
continue;
}
if (count($rules) === 1) {
$remark = trim((string) ($rules[0]['memo'] ?? ''));
if ($remark !== '') {
return $remark;
}
continue;
}
// 同一账单项目有多条规则时,用映射表自己的匹配器(按商品名称/业务备注关键词取最具体的)
try {
$result = app(AccountMappingMatcher::class)->match($platform, $candidate, $businessRemarks, $goodsName);
} catch (Throwable) {
$result = null;
}
$remark = trim((string) ($result['rule']['memo'] ?? ''));
if ($remark !== '') {
return $remark;
}
}
return null;
}
/**
* 账单项目的查找候选:原样优先,其次去掉 wx 前缀。
*
* 淘系同一笔费用在不同页签/导出里会出现带 wx 前缀(wx基础软件服务费)与不带前缀(基础软件服务费)两种写法,
* 按口径应当是同一个总账备注;去掉前缀后再查一次即可命中同一批规则。
*
* @return list<string>
*/
private function itemLookupKeys(string $item): array
{
$item = trim($item);
$keys = [$item];
if ($item !== '' && stripos($item, 'wx') === 0) {
$withoutPrefix = ltrim(mb_substr($item, 2));
if ($withoutPrefix !== '') {
$keys[] = $withoutPrefix;
}
}
return array_values(array_unique($keys));
}
private function normalizeKey(string $value): string
{
return mb_strtolower(preg_replace('/\s+/u', '', $value) ?? $value);
}
/**
* 给一行账单补上 payload.ledger_remark(内部字段名 ledger_remark)。
*
@ -279,6 +403,8 @@ class AccountLedgerRemarkMap
$row['store_name'] ?? null,
$tabPlatform,
$row['bill_item'] ?? null,
$this->businessRemarks($row),
$this->goodsName($row),
);
if ($remark === null) {
return $row;
@ -293,6 +419,53 @@ class AccountLedgerRemarkMap
return $row;
}
/**
* 账单上的业务备注(用于科目映射表的「业务备注关键词」条件)。
*
* @param array<string,mixed> $row
* @return list<string>
*/
private function businessRemarks(array $row): array
{
$payload = is_array($row['payload'] ?? null) ? $row['payload'] : [];
$values = [
$row['custom_remark'] ?? null,
$row['finance_remark'] ?? null,
$payload['second_remark'] ?? null,
$payload['business_description'] ?? null,
$payload['move_summary'] ?? null,
$payload['settle_remark'] ?? null,
$payload['tx_remark'] ?? null,
$payload['cost_name'] ?? null,
];
$remarks = [];
foreach ($values as $value) {
$text = trim((string) $value);
if ($text !== '') {
$remarks[] = $text;
}
}
return $remarks;
}
/** 商品名称(用于科目映射表的「商品名称关键词」条件)。 */
private function goodsName(array $row): string
{
$payload = is_array($row['payload'] ?? null) ? $row['payload'] : [];
foreach (['goods_name', 'product_name', 'goods_title'] as $key) {
$value = trim((string) ($payload[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
}
/**
* @param iterable<int,array<string,mixed>> $rows
* @return \Generator<int,array<string,mixed>>

View File

@ -0,0 +1,57 @@
<?php
namespace Tests\Unit\Deployment;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Process\Process;
class DeploymentSyncLockTest extends TestCase
{
public function test_container_replacement_waits_for_sync_and_fails_safely_on_lock_timeout(): void
{
foreach (['activate-release.sh', 'activate-incremental-release.sh'] as $script) {
$contents = file_get_contents(dirname(__DIR__, 3) . '/deploy/' . $script);
$start = strpos($contents, 'exec 8>/run/caiwu-upstream-sync.lock');
$end = strpos($contents, 'PREVIOUS_IMAGE=', $start ?: 0);
$this->assertNotFalse($start, $script);
$this->assertNotFalse($end, $script);
$this->assertLessThan(strpos($contents, 'docker compose -f compose.jiangling.yml up'), $end);
$guard = substr($contents, $start, $end - $start);
$directory = sys_get_temp_dir() . '/caiwu-deploy-lock-' . bin2hex(random_bytes(8));
mkdir($directory, 0700);
$holder = new Process(['sh', '-c', 'exec 8>"$1"; flock 8; touch "$2"; sleep 2', 'holder', $directory . '/sync.lock', $directory . '/held']);
try {
$holder->start();
$deadline = microtime(true) + 3;
while (! file_exists($directory . '/held') && microtime(true) < $deadline) {
usleep(10000);
}
$this->assertFileExists($directory . '/held');
$guard = str_replace('/run/caiwu-upstream-sync.lock', $directory . '/sync.lock', $guard);
$guard .= "touch '$directory/activated'\n";
$timeout = new Process(['sh', '-c', str_replace('-w 1800', '-w 0', $guard)]);
$timeout->run();
$this->assertSame(1, $timeout->getExitCode());
$this->assertStringContainsString('application container was not replaced', $timeout->getErrorOutput());
$this->assertFileDoesNotExist($directory . '/activated');
$release = new Process(['sh', '-c', $guard]);
$release->start();
usleep(100000);
$this->assertTrue($release->isRunning());
$this->assertFileDoesNotExist($directory . '/activated');
$release->wait();
$this->assertTrue($release->isSuccessful(), $release->getErrorOutput());
$this->assertFileExists($directory . '/activated');
} finally {
$holder->stop();
foreach (glob($directory . '/*') as $file) {
unlink($file);
}
rmdir($directory);
}
}
}
}