This commit is contained in:
Your Name
2026-09-01 11:26:47 +08:00
parent 5bd5eae62d
commit 398f9f3726
357 changed files with 733 additions and 343 deletions
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
$root = dirname(__DIR__, 2);
$paths = [
'callback' => $root . '/server/app/api/controller/QywxExternalContactCallbackController.php',
'logic' => $root . '/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php',
'initialMigration' => $root . '/server/sql/1.9.20260831/add_wecom_promotion_automation.sql',
'upgradeMigration' => $root . '/server/sql/1.9.20260901/upgrade_qywx_promotion_automation_runtime.sql',
'compose' => $root . '/docker/docker-compose.yml',
];
$sources = [];
foreach ($paths as $name => $path) {
$source = file_get_contents($path);
if (!is_string($source)) {
throw new RuntimeException("无法读取 {$name}: {$path}");
}
$sources[$name] = $source;
}
if (!str_contains($sources['callback'], 'enqueueVerifiedEvent($event, true)')) {
throw new RuntimeException('企微回调未启用欢迎语和标签即时通道');
}
if (!str_contains($sources['logic'], "'automation_saved' => \$automation !== null")) {
throw new RuntimeException('保存接口未向前端确认自动化配置已落库');
}
foreach (['initialMigration', 'upgradeMigration'] as $name) {
foreach (['qywx:retry-promotion-automation', 'qywx:refresh-promotion-media'] as $command) {
if (!str_contains($sources[$name], $command)) {
throw new RuntimeException("{$name} 缺少 {$command}");
}
}
}
if (!str_contains($sources['compose'], 'qywx:work-promotion-automation')) {
throw new RuntimeException('Docker 部署未启动企微欢迎语常驻进程');
}
echo "QYWX_PROMOTION_AUTOMATION_RUNTIME_CONTRACT_OK\n";
@@ -72,9 +72,10 @@ final class AutomationMemoryStore extends QywxPromotionAutomationStore
public function due(string $lane, int $now, int $limit): array {
return array_keys(array_filter($this->rows, static function (array $r) use ($lane, $now): bool {
$pending = in_array($r['welcome_status'], ['pending', 'retry', 'running'], true);
return $r['status'] !== 'done' && $r['lock_until'] <= $now && ($lane === 'welcome'
? $pending && $r['welcome_next_retry'] <= $now
: (!$pending || $r['welcome_expires_at'] <= $now) && $r['next_retry'] <= $now);
if ($r['status'] === 'done' || $r['lock_until'] > $now) { return false; }
if ($lane === 'welcome') { return $pending && $r['welcome_next_retry'] <= $now; }
if ($lane === 'inline_metadata') { return $r['next_retry'] <= $now; }
return (!$pending || $r['welcome_expires_at'] <= $now) && $r['next_retry'] <= $now;
}));
}
public function claim(int $id, string $lane, int $now): ?array {
@@ -130,6 +131,17 @@ automationCheck(count(array_filter($api->calls, static fn (array $v): bool => $v
automationCheck(count(array_filter($api->calls, static fn (array $v): bool => $v[0] === 'remark')) === 2, 'do not repeat successful remark/description');
automationCheck(!str_contains(json_encode($store->logs), 'NEVER_LOG_THIS_CODE'), 'audit contains no code');
[$service, $store, $api] = $fixture();
$api->welcomeError = new QywxPromotionContactApiException('other app currently sending', 41096);
automationCheck($service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now]), true), 'callback immediate lane handled');
$actions = json_decode($store->rows[1]['actions_json'], true);
automationCheck(array_column($api->calls, 0) === ['welcome', 'tags'], 'callback immediately attempts welcome then tags');
automationCheck(
$actions['welcome']['status'] === 'retry' && $actions['tags']['status'] === 'success',
'tag send is independent from retryable welcome: ' . json_encode([$actions['welcome'], $actions['tags']])
);
automationCheck($store->syncs === [], 'callback immediate lane leaves slow actions to background job');
foreach (['default', 'none'] as $mode) {
[$service, $store, $api] = $fixture(); $store->config['welcome_mode'] = $mode;
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now]));
@@ -3,6 +3,8 @@ import fs from 'node:fs'
import ts from '../../admin/node_modules/typescript/lib/typescript.js'
const source = fs.readFileSync(new URL('../../admin/src/views/first_visit/wecom_promotion/components/promotion-automation.ts', import.meta.url), 'utf8')
const formSource = fs.readFileSync(new URL('../../admin/src/views/first_visit/wecom_promotion/components/PromotionAutomationForm.vue', import.meta.url), 'utf8')
const pageSource = fs.readFileSync(new URL('../../admin/src/views/first_visit/wecom_promotion/index.vue', import.meta.url), 'utf8')
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
const { defaultAutomationConfig, cloneAutomationConfig, serializeAutomationConfig, validateAutomationConfig, validateCustomTagName, validateWelcomeMessage, welcomeScheduleOverlap, previewTemplate } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`)
@@ -58,4 +60,7 @@ assert.equal(welcomeScheduleOverlap([
]), false)
assert.match(previewTemplate('{customer_name}-{employee_name}-{add_time}', '小陈'), /^张女士-小陈-\d{4}-\d{2}-\d{2}$/)
assert.equal(Array.from(previewTemplate('王'.repeat(30), '小陈', 20)).length, 20)
assert.match(formSource, /不会写入企微获客链接详情中的“欢迎语\/客户标签”配置/)
assert.match(formSource, /客户添加回调中立即发送渠道欢迎语并添加标签/)
assert.match(pageSource, /result\?\.automation_saved !== true/)
console.log('WECOM_PROMOTION_AUTOMATION_UI_OK')
@@ -13,6 +13,18 @@ namespace app\adminapi\logic\auth {
namespace app\common\service\qywx {
// API行为由MockHandler测试;此处只验证控制器权限、HTTP方法及参数边界。
class QywxPromotionOperatorAccess
{
public static function hasBasePagePermission(int $adminId, array $adminInfo): bool
{
return !empty($adminInfo['root'])
|| in_array('firstvisit.wecomPromotion/overview', \app\adminapi\logic\auth\AuthLogic::getAuthByAdminId($adminId), true);
}
public static function hasPagePermission(int $adminId, array $adminInfo): bool
{
return self::hasBasePagePermission($adminId, $adminInfo);
}
}
class QywxPromotionContactApiService
{
public static array $calls = [];