数据中心同步

This commit is contained in:
乘风
2026-01-05 10:16:20 +08:00
parent 0457528dd0
commit ba0ebcf273
98 changed files with 28583 additions and 0 deletions

View File

@@ -0,0 +1,282 @@
<?php
namespace app\service;
use app\repository\ConsumptionRecordRepository;
use app\repository\UserProfileRepository;
use app\service\TagService;
use app\service\IdentifierService;
use app\utils\QueueService;
use app\utils\LoggerHelper;
use Ramsey\Uuid\Uuid as UuidGenerator;
/**
* 消费记录服务
*
* 职责:
* - 校验基础入参
* - 根据手机号/身份证解析用户IDperson_id
* - 写入消费记录集合
* - 更新用户在 user_profile 中的基础统计信息
*/
class ConsumptionService
{
public function __construct(
protected ConsumptionRecordRepository $consumptionRecordRepository,
protected UserProfileRepository $userProfileRepository,
protected IdentifierService $identifierService,
protected ?TagService $tagService = null
) {
}
/**
* 创建一条消费记录并更新用户统计信息
*
* 支持两种方式指定用户:
* 1. 直接提供 user_id
* 2. 提供 phone_number 或 id_card或两者系统自动解析用户ID
*
* @param array<string, mixed> $payload
* @return array<string, mixed>|null 如果手机号和身份证号都为空返回null跳过该记录
*/
public function createRecord(array $payload): ?array
{
// 基础必填字段校验
foreach (['amount', 'actual_amount', 'store_id', 'consume_time'] as $field) {
if (!isset($payload[$field]) || $payload[$field] === '') {
throw new \InvalidArgumentException("缺少必填字段:{$field}");
}
}
$amount = (float)$payload['amount'];
$actual = (float)$payload['actual_amount'];
$storeId = (string)$payload['store_id'];
$consumeTime = new \DateTimeImmutable((string)$payload['consume_time']);
// 解析用户ID优先使用user_id如果没有则通过手机号/身份证解析
$userId = null;
if (!empty($payload['user_id'])) {
$userId = (string)$payload['user_id'];
} else {
// 通过手机号或身份证解析用户ID
$phoneNumber = trim($payload['phone_number'] ?? '');
$idCard = trim($payload['id_card'] ?? '');
// 如果手机号和身份证号都为空,直接跳过该记录
if (empty($phoneNumber) && empty($idCard)) {
LoggerHelper::logBusiness('consumption_record_skipped_no_identifier', [
'reason' => 'phone_number and id_card are both empty',
'consume_time' => $consumeTime->format('Y-m-d H:i:s'),
]);
return null;
}
// 传入 consume_time 作为查询时间点
$userId = $this->identifierService->resolvePersonId($phoneNumber, $idCard, $consumeTime);
// 如果同时提供了手机号和身份证,检查是否需要合并
if (!empty($phoneNumber) && !empty($idCard)) {
$userId = $this->handleMergeIfNeeded($phoneNumber, $idCard, $userId, $consumeTime);
}
}
$now = new \DateTimeImmutable('now');
$recordId = UuidGenerator::uuid4()->toString();
// 写入消费记录
$record = new ConsumptionRecordRepository();
$record->record_id = $recordId;
$record->user_id = $userId;
$record->consume_time = $consumeTime;
$record->amount = $amount;
$record->actual_amount = $actual;
$record->currency = $payload['currency'] ?? 'CNY';
$record->store_id = $storeId;
$record->status = 0;
$record->create_time = $now;
$record->save();
// 更新用户统计信息
$user = $this->userProfileRepository->increaseStats($userId, $actual, $consumeTime);
// 触发标签计算(异步方式)
$tags = [];
$useAsync = getenv('TAG_CALCULATION_ASYNC') !== 'false'; // 默认使用异步,可通过环境变量关闭
if ($useAsync) {
// 异步方式:推送到消息队列
try {
$success = QueueService::pushTagCalculation([
'user_id' => $userId,
'tag_ids' => null, // null 表示计算所有 real_time 标签
'trigger_type' => 'consumption_record',
'record_id' => $recordId,
'timestamp' => time(),
]);
if ($success) {
LoggerHelper::logBusiness('tag_calculation_queued', [
'user_id' => $userId,
'record_id' => $recordId,
]);
} else {
// 如果推送失败,降级到同步调用
LoggerHelper::logBusiness('tag_calculation_queue_failed_fallback', [
'user_id' => $userId,
'record_id' => $recordId,
]);
$useAsync = false;
}
} catch (\Throwable $e) {
// 如果队列服务异常,降级到同步调用
LoggerHelper::logError($e, [
'component' => 'ConsumptionService',
'action' => 'pushTagCalculation',
'user_id' => $userId,
]);
$useAsync = false;
}
}
// 同步方式(降级方案或配置关闭异步时使用)
if (!$useAsync && $this->tagService) {
try {
$tags = $this->tagService->calculateTags($userId);
} catch (\Throwable $e) {
// 标签计算失败不影响消费记录写入,只记录错误
LoggerHelper::logError($e, [
'component' => 'ConsumptionService',
'action' => 'calculateTags',
'user_id' => $userId,
]);
}
}
return [
'record_id' => $recordId,
'user_id' => $userId,
'user' => [
'total_amount' => $user->total_amount ?? 0,
'total_count' => $user->total_count ?? 0,
'last_consume_time' => $user->last_consume_time,
],
'tags' => $tags, // 异步模式下为空数组,同步模式下包含标签信息
'tag_calculation_mode' => $useAsync ? 'async' : 'sync',
];
}
/**
* 当手机号和身份证号同时出现时,检查是否需要合并用户
*
* @param string $phoneNumber 手机号
* @param string $idCard 身份证号
* @param string $currentUserId 当前解析出的用户ID
* @param \DateTimeInterface $consumeTime 消费时间
* @return string 最终使用的用户ID
*/
private function handleMergeIfNeeded(
string $phoneNumber,
string $idCard,
string $currentUserId,
\DateTimeInterface $consumeTime
): string {
// 通过身份证查找用户
$userIdByIdCard = $this->identifierService->resolvePersonIdByIdCard($idCard);
// 在消费时间点查询手机号关联(使用反射或公共方法)
$userPhoneService = new \app\service\UserPhoneService(
new \app\repository\UserPhoneRelationRepository()
);
$userIdByPhone = $userPhoneService->findUserByPhone($phoneNumber, $consumeTime);
// 如果身份证找到用户A手机号关联到用户B且A≠B
if ($userIdByIdCard && $userIdByPhone && $userIdByIdCard !== $userIdByPhone) {
// 检查用户B是否为临时用户
$userB = $this->userProfileRepository->findByUserId($userIdByPhone);
$userA = $this->userProfileRepository->findByUserId($userIdByIdCard);
if ($userB && $userB->is_temporary) {
// 情况1用户B是临时用户 → 合并到正式用户A
// 需要合并服务,动态创建
$tagService = $this->tagService ?? new TagService(
new \app\repository\TagDefinitionRepository(),
$this->userProfileRepository,
new \app\repository\UserTagRepository(),
new \app\repository\TagHistoryRepository(),
new \app\service\TagRuleEngine\SimpleRuleEngine()
);
$mergeService = new PersonMergeService(
$this->userProfileRepository,
new \app\repository\UserTagRepository(),
$userPhoneService,
$tagService
);
// 合并临时用户B到正式用户A
$mergeService->mergeUsers($userIdByPhone, $userIdByIdCard);
// 将旧的手机关联标记为过期(使用消费时间作为过期时间)
$userPhoneService->removePhoneFromUser($userIdByPhone, $phoneNumber, $consumeTime);
// 建立新的手机关联到用户A使用消费时间作为生效时间
$userPhoneService->addPhoneToUser($userIdByIdCard, $phoneNumber, [
'source' => 'merge_after_id_card_binding',
'effective_time' => $consumeTime,
'type' => 'personal',
]);
LoggerHelper::logBusiness('auto_merge_triggered', [
'phone_number' => $phoneNumber,
'source_user_id' => $userIdByPhone,
'target_user_id' => $userIdByIdCard,
'consume_time' => $consumeTime->format('Y-m-d H:i:s'),
'reason' => 'temporary_user_merge',
]);
return $userIdByIdCard;
} elseif ($userA && !$userA->is_temporary && $userB && !$userB->is_temporary) {
// 情况2两者都是正式用户如酒店预订代订场景
// 策略:以身份证为准,消费记录归属到身份证用户,但手机号关联保持不变
// 原因:手机号和身份证同时出现时,身份证更可信;但手机号可能是代订,不应自动转移
// 检查手机号在消费时间点是否已经关联到用户A可能之前已经转移过
$phoneRelationAtTime = $userPhoneService->findUserByPhone($phoneNumber, $consumeTime);
if ($phoneRelationAtTime !== $userIdByIdCard) {
// 手机号在消费时间点还未关联到身份证用户
// 记录异常情况,但不强制转移手机号(可能是代订场景)
LoggerHelper::logBusiness('phone_id_card_mismatch_formal_users', [
'phone_number' => $phoneNumber,
'phone_user_id' => $userIdByPhone,
'id_card_user_id' => $userIdByIdCard,
'consume_time' => $consumeTime->format('Y-m-d H:i:s'),
'decision' => 'use_id_card_user',
'note' => '正式用户冲突,以身份证为准(可能是代订场景)',
]);
}
// 以身份证用户为准返回身份证用户ID
return $userIdByIdCard;
} else {
// 其他情况(理论上不应该发生),记录日志并返回身份证用户
LoggerHelper::logBusiness('phone_id_card_mismatch_unknown', [
'phone_number' => $phoneNumber,
'phone_user_id' => $userIdByPhone,
'id_card_user_id' => $userIdByIdCard,
'phone_user_is_temporary' => $userB ? $userB->is_temporary : 'unknown',
'id_card_user_is_temporary' => $userA ? $userA->is_temporary : 'unknown',
'consume_time' => $consumeTime->format('Y-m-d H:i:s'),
]);
// 默认返回身份证用户(更可信)
return $userIdByIdCard;
}
}
return $currentUserId;
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace app\service\DataCollection\Handler;
use app\repository\DataSourceRepository;
use app\service\DataSourceService;
use app\utils\LoggerHelper;
use app\utils\MongoDBHelper;
use MongoDB\Client;
/**
* 数据采集 Handler 基类
*
* 提供通用的数据采集功能:
* - MongoDB 客户端创建
* - 数据源配置获取
* - 目标数据源连接
* - 公共服务实例IdentifierService、ConsumptionService、StoreService
*/
abstract class BaseCollectionHandler
{
use Trait\DataCollectionHelperTrait;
protected DataSourceService $dataSourceService;
protected \app\service\IdentifierService $identifierService;
protected \app\service\ConsumptionService $consumptionService;
protected \app\service\StoreService $storeService;
public function __construct()
{
$this->dataSourceService = new DataSourceService(
new DataSourceRepository()
);
// 初始化公共服务(避免在子类中重复实例化)
$this->identifierService = new \app\service\IdentifierService(
new \app\repository\UserProfileRepository(),
new \app\service\UserPhoneService(
new \app\repository\UserPhoneRelationRepository()
)
);
$this->consumptionService = new \app\service\ConsumptionService(
new \app\repository\ConsumptionRecordRepository(),
new \app\repository\UserProfileRepository(),
$this->identifierService
);
$this->storeService = new \app\service\StoreService(
new \app\repository\StoreRepository()
);
}
/**
* 获取 MongoDB 客户端
*
* @param array<string, mixed> $taskConfig 任务配置
* @return Client MongoDB 客户端实例
* @throws \InvalidArgumentException 如果数据源配置不存在
*/
protected function getMongoClient(array $taskConfig): Client
{
$dataSourceId = $taskConfig['data_source_id']
?? $taskConfig['data_source']
?? 'sync_mongodb';
$dataSourceConfig = $this->dataSourceService->getDataSourceConfigById($dataSourceId);
if (empty($dataSourceConfig)) {
throw new \InvalidArgumentException("数据源配置不存在: {$dataSourceId}");
}
return MongoDBHelper::createClient($dataSourceConfig);
}
/**
* 连接到目标数据源
*
* @param string $targetDataSourceId 目标数据源ID
* @param string|null $targetDatabase 目标数据库名(可选,默认使用数据源配置中的数据库)
* @return array{client: Client, database: \MongoDB\Database, dbName: string, config: array} 连接信息
* @throws \InvalidArgumentException 如果目标数据源配置不存在
*/
protected function connectToTargetDataSource(
string $targetDataSourceId,
?string $targetDatabase = null
): array {
$targetDataSourceConfig = $this->dataSourceService->getDataSourceConfigById($targetDataSourceId);
if (empty($targetDataSourceConfig)) {
throw new \InvalidArgumentException("目标数据源配置不存在: {$targetDataSourceId}");
}
$client = MongoDBHelper::createClient($targetDataSourceConfig);
$dbName = $targetDatabase ?? $targetDataSourceConfig['database'] ?? 'ckb';
$database = $client->selectDatabase($dbName);
return [
'client' => $client,
'database' => $database,
'dbName' => $dbName,
'config' => $targetDataSourceConfig,
];
}
/**
* 采集数据(抽象方法,由子类实现)
*
* @param mixed $adapter 数据源适配器
* @param array<string, mixed> $taskConfig 任务配置
* @return void
*/
abstract public function collect($adapter, array $taskConfig): void;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,427 @@
<?php
namespace app\service\DataCollection\Handler;
use app\service\DataSource\DataSourceAdapterInterface;
use app\service\DatabaseSyncService;
use app\service\DataSourceService;
use app\repository\DataSourceRepository;
use app\utils\LoggerHelper;
use MongoDB\Client;
/**
* 数据库同步采集处理类
*
* 职责:
* - 从源数据库同步数据到目标数据库
* - 支持全量同步和增量同步Change Streams
* - 处理同步进度和错误恢复
*/
class DatabaseSyncHandler
{
private DatabaseSyncService $syncService;
private array $taskConfig;
private int $progressTimerId = 0; // 进度日志定时器ID
/**
* 采集/同步数据库
*
* @param DataSourceAdapterInterface $adapter 数据源适配器(源数据库)
* @param array<string, mixed> $taskConfig 任务配置
* @return void
*/
public function collect(DataSourceAdapterInterface $adapter, array $taskConfig): void
{
$this->taskConfig = $taskConfig;
$taskId = $taskConfig['task_id'] ?? '';
$taskName = $taskConfig['name'] ?? '数据库同步';
LoggerHelper::logBusiness('database_sync_collection_started', [
'task_id' => $taskId,
'task_name' => $taskName,
]);
// 控制台直接输出一条提示,方便在启动时观察数据库同步任务是否真正开始执行
error_log("[DatabaseSyncHandler] 数据库同步任务已启动task_id={$taskId}, task_name={$taskName}");
try {
// 创建 DatabaseSyncService使用任务配置中的源和目标数据源
$this->syncService = $this->createSyncService($taskConfig);
// 获取要同步的数据库列表
$databases = $this->getDatabasesToSync($taskConfig);
if (empty($databases)) {
LoggerHelper::logBusiness('database_sync_no_databases', [
'task_id' => $taskId,
'message' => '没有找到要同步的数据库',
]);
return;
}
// 启动进度日志定时器(定期输出同步进度)
$this->startProgressTimer($taskConfig);
// 是否执行全量同步(从业务配置中获取)
$businessConfig = $this->getBusinessConfig();
$fullSyncEnabled = $businessConfig['change_stream']['full_sync_on_start'] ?? false;
if ($fullSyncEnabled) {
// 执行全量同步
$this->performFullSync($databases, $taskConfig);
}
// 启动增量同步监听Change Streams
$this->startIncrementalSync($databases, $taskConfig);
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'DatabaseSyncHandler',
'action' => 'collect',
'task_id' => $taskId,
]);
throw $e;
}
}
/**
* 获取业务配置(从独立配置文件或使用默认值)
*
* @return array<string, mixed> 业务配置
*/
private function getBusinessConfig(): array
{
// 可以从独立配置文件读取,或使用默认值
// 这里使用默认值,业务逻辑统一在代码中管理
return [
// 数据库同步配置
'databases' => [], // 空数组表示同步所有数据库
'exclude_databases' => ['admin', 'local', 'config'], // 排除的系统数据库
'exclude_collections' => ['system.profile', 'system.js'], // 排除的系统集合
// Change Streams 配置
'change_stream' => [
'batch_size' => 100,
'max_await_time_ms' => 1000,
'full_sync_on_start' => true, // 首次启动时是否执行全量同步
'full_sync_batch_size' => 1000,
],
// 重试配置
'retry' => [
'max_connect_retries' => 10,
'retry_interval' => 5,
'max_sync_retries' => 3,
'sync_retry_interval' => 2,
],
// 性能配置
'performance' => [
'concurrent_databases' => 5,
'concurrent_collections' => 10,
'batch_write_size' => 5000,
// 为了让断点续传逻辑简单可靠,这里关闭集合级并行同步
// 后续如果需要再做更复杂的分片断点策略,可以重新打开
'enable_parallel_sync' => false,
'max_parallel_tasks_per_collection' => 4,
'documents_per_task' => 100000,
],
// 监控配置
'monitoring' => [
'log_sync' => true,
'log_detail' => false,
'stats_interval' => 10, // 每10秒输出一次进度日志
],
];
}
/**
* 创建 DatabaseSyncService 实例
*
* @param array<string, mixed> $taskConfig 任务配置
* @return DatabaseSyncService
*/
private function createSyncService(array $taskConfig): DatabaseSyncService
{
// 从数据库获取源和目标数据源配置
$dataSourceService = new DataSourceService(new DataSourceRepository());
$sourceDataSourceId = $taskConfig['source_data_source'] ?? 'kr_mongodb';
$targetDataSourceId = $taskConfig['target_data_source'] ?? 'sync_mongodb';
$sourceConfig = $dataSourceService->getDataSourceConfigById($sourceDataSourceId);
$targetConfig = $dataSourceService->getDataSourceConfigById($targetDataSourceId);
if (empty($sourceConfig) || empty($targetConfig)) {
throw new \InvalidArgumentException("数据源配置不存在: source={$sourceDataSourceId}, target={$targetDataSourceId}");
}
// 获取业务配置(统一在代码中管理)
$businessConfig = $this->getBusinessConfig();
// 构建同步配置
$syncConfig = [
'enabled' => true,
'source' => [
'host' => $sourceConfig['host'],
'port' => $sourceConfig['port'],
'username' => $sourceConfig['username'] ?? '',
'password' => $sourceConfig['password'] ?? '',
'auth_source' => $sourceConfig['auth_source'] ?? 'admin',
'options' => array_merge([
'connectTimeoutMS' => 10000,
'socketTimeoutMS' => 30000,
'serverSelectionTimeoutMS' => 10000,
'heartbeatFrequencyMS' => 10000,
], $sourceConfig['options'] ?? []),
],
'target' => [
'host' => $targetConfig['host'],
'port' => $targetConfig['port'],
'username' => $targetConfig['username'] ?? '',
'password' => $targetConfig['password'] ?? '',
'auth_source' => $targetConfig['auth_source'] ?? 'admin',
'options' => array_merge([
'connectTimeoutMS' => 10000,
'socketTimeoutMS' => 30000,
'serverSelectionTimeoutMS' => 10000,
], $targetConfig['options'] ?? []),
],
'sync' => [
'databases' => $businessConfig['databases'],
'exclude_databases' => $businessConfig['exclude_databases'],
'exclude_collections' => $businessConfig['exclude_collections'],
'change_stream' => $businessConfig['change_stream'],
'retry' => $businessConfig['retry'],
'performance' => $businessConfig['performance'],
],
'monitoring' => $businessConfig['monitoring'],
];
// 直接传递配置给 DatabaseSyncService 构造函数
return new DatabaseSyncService($syncConfig);
}
/**
* 获取要同步的数据库列表
*
* @param array<string, mixed> $taskConfig 任务配置
* @return array<string> 数据库名称列表
*/
private function getDatabasesToSync(array $taskConfig): array
{
return $this->syncService->getDatabasesToSync();
}
/**
* 执行全量同步(支持多进程数据库级并行)
*
* @param array<string> $databases 数据库列表
* @param array<string, mixed> $taskConfig 任务配置
* @return void
*/
private function performFullSync(array $databases, array $taskConfig): void
{
// 获取 Worker 信息(用于多进程分配)
$workerId = $taskConfig['worker_id'] ?? 0;
$workerCount = $taskConfig['worker_count'] ?? 1;
// 分配数据库给当前 Worker负载均衡算法
$assignedDatabases = $this->assignDatabasesToWorker($databases, $workerId, $workerCount);
LoggerHelper::logBusiness('database_sync_full_sync_start', [
'worker_id' => $workerId,
'worker_count' => $workerCount,
'total_databases' => count($databases),
'assigned_databases' => $assignedDatabases,
'assigned_count' => count($assignedDatabases),
]);
foreach ($assignedDatabases as $databaseName) {
try {
$this->syncService->fullSyncDatabase($databaseName);
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'DatabaseSyncHandler',
'action' => 'performFullSync',
'database' => $databaseName,
'worker_id' => $workerId,
]);
// 继续同步其他数据库
}
}
LoggerHelper::logBusiness('database_sync_full_sync_completed', [
'worker_id' => $workerId,
'databases' => $assignedDatabases,
]);
}
/**
* 分配数据库给当前 Worker负载均衡算法
*
* 策略:
* 1. 按数据库大小排序(小库优先,提升完成感)
* 2. 使用贪心算法:每次分配给当前负载最小的 Worker
* 3. 考虑 Worker 当前处理的数据库数量
*
* @param array<string> $databases 数据库列表(已按大小排序)
* @param int $workerId 当前 Worker ID
* @param int $workerCount Worker 总数
* @return array<string> 分配给当前 Worker 的数据库列表
*/
private function assignDatabasesToWorker(array $databases, int $workerId, int $workerCount): array
{
// 如果只有一个 Worker返回所有数据库
if ($workerCount <= 1) {
return $databases;
}
// 方案A简单取模分配快速实现
// 适用于数据库数量较多且大小相近的场景
$assignedDatabases = [];
foreach ($databases as $index => $databaseName) {
if ($index % $workerCount === $workerId) {
$assignedDatabases[] = $databaseName;
}
}
// 方案B负载均衡分配推荐但需要数据库大小信息
// 由于 getDatabasesToSync 已经按大小排序,简单取模即可实现较好的负载均衡
// 如果后续需要更精确的负载均衡,可以从 DatabaseSyncService 获取数据库大小信息
return $assignedDatabases;
}
/**
* 启动增量同步监听(支持多进程数据库级并行)
*
* @param array<string> $databases 数据库列表
* @param array<string, mixed> $taskConfig 任务配置
* @return void
*/
private function startIncrementalSync(array $databases, array $taskConfig): void
{
// 获取 Worker 信息(用于多进程分配)
$workerId = $taskConfig['worker_id'] ?? 0;
$workerCount = $taskConfig['worker_count'] ?? 1;
// 分配数据库给当前 Worker与全量同步使用相同的分配策略
$assignedDatabases = $this->assignDatabasesToWorker($databases, $workerId, $workerCount);
LoggerHelper::logBusiness('database_sync_incremental_sync_start', [
'worker_id' => $workerId,
'worker_count' => $workerCount,
'total_databases' => count($databases),
'assigned_databases' => $assignedDatabases,
'assigned_count' => count($assignedDatabases),
]);
// 为分配给当前 Worker 的数据库启动监听(在后台进程中)
foreach ($assignedDatabases as $databaseName) {
// 使用 Timer 在后台启动监听,避免阻塞
\Workerman\Timer::add(0, function () use ($databaseName) {
try {
$this->syncService->watchDatabase($databaseName);
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'DatabaseSyncHandler',
'action' => 'startIncrementalSync',
'database' => $databaseName,
]);
// 重试逻辑(从业务配置中获取)
$businessConfig = $this->getBusinessConfig();
$retryConfig = $businessConfig['retry'] ?? [];
$maxRetries = $retryConfig['max_connect_retries'] ?? 10;
$retryInterval = $retryConfig['retry_interval'] ?? 5;
static $retryCount = [];
if (!isset($retryCount[$databaseName])) {
$retryCount[$databaseName] = 0;
}
if ($retryCount[$databaseName] < $maxRetries) {
$retryCount[$databaseName]++;
\Workerman\Timer::add($retryInterval, function () use ($databaseName) {
$this->startIncrementalSync([$databaseName], $this->taskConfig);
}, [], false);
}
}
}, [], false);
}
}
/**
* 启动进度日志定时器
*
* @param array<string, mixed> $taskConfig 任务配置
* @return void
*/
private function startProgressTimer(array $taskConfig): void
{
$businessConfig = $this->getBusinessConfig();
$statsInterval = $businessConfig['monitoring']['stats_interval'] ?? 10; // 默认10秒输出一次进度
// 使用 Workerman Timer 定期输出进度
$this->progressTimerId = \Workerman\Timer::add($statsInterval, function () use ($taskConfig) {
try {
// 重新加载最新进度(从文件读取)
$this->syncService->loadProgress();
$progress = $this->syncService->getProgress();
$stats = $this->syncService->getStats();
// 输出格式化的进度信息
$progressInfo = [
'task_id' => $taskConfig['task_id'] ?? '',
'task_name' => $taskConfig['name'] ?? '数据库同步',
'status' => $progress['status'],
'progress_percent' => $progress['progress_percent'] . '%',
'current_database' => $progress['current_database'] ?? '无',
'current_collection' => $progress['current_collection'] ?? '无',
'databases' => "{$progress['databases']['completed']}/{$progress['databases']['total']}",
'collections' => "{$progress['collections']['completed']}/{$progress['collections']['total']}",
'documents' => "{$progress['documents']['processed']}/{$progress['documents']['total']}",
'documents_inserted' => $stats['documents_inserted'],
'documents_updated' => $stats['documents_updated'],
'documents_deleted' => $stats['documents_deleted'],
'errors' => $stats['errors'],
'elapsed_time' => round($progress['time']['elapsed_seconds'], 2) . 's',
'estimated_remaining' => $progress['time']['estimated_remaining_seconds']
? round($progress['time']['estimated_remaining_seconds'], 2) . 's'
: '计算中...',
];
// 输出到日志
LoggerHelper::logBusiness('database_sync_progress_report', $progressInfo);
// 如果状态是错误,输出错误信息
if ($progress['status'] === 'error' && isset($progress['last_error'])) {
LoggerHelper::logBusiness('database_sync_error_info', [
'error_message' => $progress['last_error']['message'] ?? '未知错误',
'error_database' => $progress['error_database'] ?? '未知',
'error_collection' => $progress['last_error']['collection'] ?? '未知',
]);
}
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'DatabaseSyncHandler',
'action' => 'startProgressTimer',
]);
}
});
}
/**
* 停止进度日志定时器
*
* @return void
*/
public function stopProgressTimer(): void
{
if ($this->progressTimerId > 0) {
\Workerman\Timer::del($this->progressTimerId);
$this->progressTimerId = 0;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
<?php
namespace app\service\DataCollection\Handler;
use app\service\TagTaskService;
use app\repository\TagTaskRepository;
use app\repository\TagTaskExecutionRepository;
use app\repository\UserProfileRepository;
use app\service\TagService;
use app\repository\TagDefinitionRepository;
use app\repository\UserTagRepository;
use app\repository\TagHistoryRepository;
use app\service\TagRuleEngine\SimpleRuleEngine;
use app\utils\LoggerHelper;
/**
* 标签任务处理类
*
* 职责:
* - 执行标签计算任务
* - 批量遍历用户数据打标签
*/
class TagTaskHandler
{
/**
* 执行标签任务
*
* @param mixed $adapter 数据源适配器(标签任务不需要)
* @param array<string, mixed> $taskConfig 任务配置
* @return void
*/
public function collect($adapter, array $taskConfig): void
{
$taskId = $taskConfig['task_id'] ?? '';
$taskName = $taskConfig['name'] ?? '标签任务';
LoggerHelper::logBusiness('tag_task_handler_started', [
'task_id' => $taskId,
'task_name' => $taskName,
]);
try {
// 创建TagTaskService实例
$tagTaskService = new TagTaskService(
new TagTaskRepository(),
new TagTaskExecutionRepository(),
new UserProfileRepository(),
new TagService(
new TagDefinitionRepository(),
new UserProfileRepository(),
new UserTagRepository(),
new TagHistoryRepository(),
new SimpleRuleEngine()
)
);
// 执行任务
$tagTaskService->executeTask($taskId);
LoggerHelper::logBusiness('tag_task_handler_completed', [
'task_id' => $taskId,
'task_name' => $taskName,
]);
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'TagTaskHandler',
'action' => 'collect',
'task_id' => $taskId,
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace app\service\DataCollection\Handler\Trait;
use MongoDB\BSON\UTCDateTime;
/**
* 数据采集辅助方法 Trait
*
* 提供通用的工具方法给各个 Handler 使用
*/
trait DataCollectionHelperTrait
{
/**
* 将 MongoDB 文档转换为数组
*
* @param mixed $document MongoDB 文档对象或数组
* @return array<string, mixed> 数组格式的数据
*/
protected function convertMongoDocumentToArray($document): array
{
if (is_array($document)) {
return $document;
}
if (is_object($document) && method_exists($document, 'toArray')) {
return $document->toArray();
}
return json_decode(json_encode($document), true) ?? [];
}
/**
* 解析日期时间字符串
*
* @param mixed $dateTimeStr 日期时间字符串或对象
* @return \DateTimeImmutable|null 解析后的日期时间对象
*/
protected function parseDateTime($dateTimeStr): ?\DateTimeImmutable
{
if (empty($dateTimeStr)) {
return null;
}
// 如果是 MongoDB 的 UTCDateTime 对象
if ($dateTimeStr instanceof UTCDateTime) {
return \DateTimeImmutable::createFromMutable($dateTimeStr->toDateTime());
}
// 如果是 DateTime 对象
if ($dateTimeStr instanceof \DateTime || $dateTimeStr instanceof \DateTimeImmutable) {
if ($dateTimeStr instanceof \DateTime) {
return \DateTimeImmutable::createFromMutable($dateTimeStr);
}
return $dateTimeStr;
}
// 尝试解析字符串
try {
return new \DateTimeImmutable((string)$dateTimeStr);
} catch (\Exception $e) {
\app\utils\LoggerHelper::logBusiness('datetime_parse_failed', [
'input' => $dateTimeStr,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* 解析金额
*
* @param mixed $amount 金额字符串或数字
* @return float 解析后的金额
*/
protected function parseAmount($amount): float
{
if (is_numeric($amount)) {
return (float)$amount;
}
if (is_string($amount)) {
// 移除所有非数字字符(除了小数点)
$cleaned = preg_replace('/[^\d.]/', '', $amount);
return (float)$cleaned;
}
return 0.0;
}
/**
* 过滤手机号中的非数字字符
*
* @param string $phoneNumber 原始手机号
* @return string 过滤后的手机号(只包含数字)
*/
protected function filterPhoneNumber(string $phoneNumber): string
{
// 移除所有非数字字符
return preg_replace('/\D/', '', $phoneNumber);
}
/**
* 验证手机号格式
*
* @param string $phone 手机号(已经过滤过非数字字符)
* @return bool 是否有效11位数字1开头
*/
protected function isValidPhone(string $phone): bool
{
// 如果为空,直接返回 false
if (empty($phone)) {
return false;
}
// 中国大陆手机号11位数字以1开头
return preg_match('/^1[3-9]\d{9}$/', $phone) === 1;
}
/**
* 根据消费时间生成月份集合名
*
* @param string $baseCollectionName 基础集合名
* @param mixed $dateTimeStr 日期时间字符串或对象
* @return string 带月份后缀的集合名consumption_records_202512
*/
protected function getMonthlyCollectionName(string $baseCollectionName, $dateTimeStr = null): string
{
$consumeTime = $this->parseDateTime($dateTimeStr);
if ($consumeTime === null) {
$consumeTime = new \DateTimeImmutable();
}
$monthSuffix = $consumeTime->format('Ym');
return "{$baseCollectionName}_{$monthSuffix}";
}
/**
* 转换为 MongoDB UTCDateTime
*
* @param mixed $dateTimeStr 日期时间字符串或对象
* @return UTCDateTime|null MongoDB UTCDateTime 对象
*/
protected function convertToUTCDateTime($dateTimeStr): ?UTCDateTime
{
if (empty($dateTimeStr)) {
return null;
}
// 如果已经是 UTCDateTime直接返回
if ($dateTimeStr instanceof UTCDateTime) {
return $dateTimeStr;
}
// 如果是 DateTime 对象
if ($dateTimeStr instanceof \DateTime || $dateTimeStr instanceof \DateTimeImmutable) {
return new UTCDateTime($dateTimeStr->getTimestamp() * 1000);
}
// 尝试解析字符串
try {
$dateTime = new \DateTimeImmutable((string)$dateTimeStr);
return new UTCDateTime($dateTime->getTimestamp() * 1000);
} catch (\Exception $e) {
\app\utils\LoggerHelper::logBusiness('convert_to_utcdatetime_failed', [
'input' => $dateTimeStr,
'error' => $e->getMessage(),
]);
return null;
}
}
}

View File

@@ -0,0 +1,660 @@
<?php
namespace app\service;
use app\repository\DataCollectionTaskRepository;
use app\utils\LoggerHelper;
use app\utils\RedisHelper;
use Ramsey\Uuid\Uuid as UuidGenerator;
/**
* 数据采集任务管理服务
*
* 职责:
* - 创建、更新、删除采集任务
* - 管理任务状态(启动、暂停、停止)
* - 追踪任务进度和统计信息
*/
class DataCollectionTaskService
{
public function __construct(
protected DataCollectionTaskRepository $taskRepository
) {
}
/**
* 创建采集任务
*
* @param array<string, mixed> $taskData 任务数据
* @return array<string, mixed> 创建的任务信息
*/
public function createTask(array $taskData): array
{
// 生成任务ID
$taskId = UuidGenerator::uuid4()->toString();
// 根据Handler类型自动处理目标数据源配置
$targetType = $taskData['target_type'] ?? '';
$targetDataSourceId = $taskData['target_data_source_id'] ?? '';
$targetDatabase = $taskData['target_database'] ?? '';
$targetCollection = $taskData['target_collection'] ?? '';
if ($targetType === 'consumption_record') {
// 消费记录Handler自动使用标签数据库配置
$dataSourceService = new \app\service\DataSourceService(new \app\repository\DataSourceRepository());
$dataSources = $dataSourceService->getDataSourceList(['status' => 1]);
// 查找标签数据库数据源通过名称或ID匹配
$tagDataSource = null;
foreach ($dataSources['list'] ?? [] as $ds) {
$dsName = strtolower($ds['name'] ?? '');
$dsId = strtolower($ds['data_source_id'] ?? '');
if ($dsId === 'tag_mongodb' ||
$dsName === 'tag_mongodb' ||
stripos($dsName, '标签') !== false ||
stripos($dsName, 'tag') !== false) {
$tagDataSource = $ds;
break;
}
}
if ($tagDataSource) {
$targetDataSourceId = $tagDataSource['data_source_id'];
$targetDatabase = $tagDataSource['database'] ?? 'ckb';
$targetCollection = 'consumption_records'; // 消费记录Handler会自动按时间分表
} else {
// 如果找不到,使用默认值
$targetDataSourceId = 'tag_mongodb'; // 尝试使用配置key作为ID
$targetDatabase = 'ckb';
$targetCollection = 'consumption_records';
}
} elseif ($targetType === 'generic') {
// 通用Handler验证用户是否提供了配置
if (empty($targetDataSourceId) || empty($targetDatabase) || empty($targetCollection)) {
throw new \InvalidArgumentException('通用Handler必须配置目标数据源、目标数据库和目标集合');
}
}
// 构建任务文档
$task = [
'task_id' => $taskId,
'name' => $taskData['name'] ?? '未命名任务',
'description' => $taskData['description'] ?? '',
'data_source_id' => $taskData['data_source_id'] ?? '',
'database' => $taskData['database'] ?? '',
'collection' => $taskData['collection'] ?? null,
'collections' => $taskData['collections'] ?? null,
'target_type' => $targetType,
'target_data_source_id' => $targetDataSourceId,
'target_database' => $targetDatabase,
'target_collection' => $targetCollection,
'mode' => $taskData['mode'] ?? 'batch', // batch: 批量采集, realtime: 实时监听
'field_mappings' => $this->cleanFieldMappings($taskData['field_mappings'] ?? []),
'collection_field_mappings' => $taskData['collection_field_mappings'] ?? [],
'lookups' => $taskData['lookups'] ?? [],
'collection_lookups' => $taskData['collection_lookups'] ?? [],
'filter_conditions' => $taskData['filter_conditions'] ?? [],
'schedule' => $taskData['schedule'] ?? [
'enabled' => false,
'cron' => null,
],
'status' => 'pending', // pending: 待启动, running: 运行中, paused: 已暂停, stopped: 已停止, error: 错误
'progress' => [
'status' => 'idle', // idle, running, paused, completed, error
'processed_count' => 0,
'success_count' => 0,
'error_count' => 0,
'total_count' => 0,
'percentage' => 0,
'start_time' => null,
'end_time' => null,
'last_sync_time' => null,
],
'statistics' => [
'total_processed' => 0,
'total_success' => 0,
'total_error' => 0,
'last_run_time' => null,
],
'created_by' => $taskData['created_by'] ?? 'system',
];
// 保存到数据库使用原生MongoDB客户端明确指定集合名
// 注意MongoDB Laravel的Model在数据中包含collection字段时可能会误用该字段作为集合名
// 因此使用原生客户端明确指定集合名为data_collection_tasks
$dbConfig = config('database.connections.mongodb');
// 使用 MongoDBHelper 创建客户端统一DSN构建逻辑
$client = \app\utils\MongoDBHelper::createClient([
'host' => parse_url($dbConfig['dsn'], PHP_URL_HOST) ?? '192.168.1.106',
'port' => parse_url($dbConfig['dsn'], PHP_URL_PORT) ?? 27017,
'username' => $dbConfig['username'] ?? '',
'password' => $dbConfig['password'] ?? '',
'auth_source' => $dbConfig['options']['authSource'] ?? 'admin',
], array_filter($dbConfig['options'] ?? [], function ($value) {
return $value !== '' && $value !== null;
}));
$database = $client->selectDatabase($dbConfig['database']);
$collection = $database->selectCollection('data_collection_tasks');
// 添加时间戳
$task['created_at'] = new \MongoDB\BSON\UTCDateTime(time() * 1000);
$task['updated_at'] = new \MongoDB\BSON\UTCDateTime(time() * 1000);
// 插入文档
$result = $collection->insertOne($task);
// 验证插入成功
if ($result->getInsertedCount() !== 1) {
throw new \RuntimeException("任务创建失败:未能插入到数据库");
}
// 如果任务状态是 running立即设置 Redis 启动标志,让调度器启动采集进程
if ($task['status'] === 'running') {
try {
\app\utils\RedisHelper::set("data_collection_task:{$taskId}:start", '1', 3600); // 1小时过期
LoggerHelper::logBusiness('data_collection_task_start_flag_set', [
'task_id' => $taskId,
'task_name' => $task['name'],
]);
} catch (\Throwable $e) {
// Redis 设置失败不影响任务创建,只记录日志
LoggerHelper::logError($e, [
'component' => 'DataCollectionTaskService',
'action' => 'createTask',
'task_id' => $taskId,
'message' => '设置启动标志失败',
]);
}
}
LoggerHelper::logBusiness('data_collection_task_created', [
'task_id' => $taskId,
'task_name' => $task['name'],
]);
return $task;
}
/**
* 清理字段映射数据,移除无效的映射项
*
* @param array $fieldMappings 原始字段映射数组
* @return array 清理后的字段映射数组
*/
private function cleanFieldMappings(array $fieldMappings): array
{
$cleaned = [];
foreach ($fieldMappings as $mapping) {
// 如果缺少target_field跳过该项
if (empty($mapping['target_field'])) {
continue;
}
// 清理状态值映射中的源状态值(移除多余的引号)
if (isset($mapping['value_mapping']) && is_array($mapping['value_mapping'])) {
foreach ($mapping['value_mapping'] as &$vm) {
if (isset($vm['source_value'])) {
// 移除字符串两端的单引号或双引号
$vm['source_value'] = trim($vm['source_value'], "'\"");
}
}
unset($vm); // 解除引用
}
$cleaned[] = $mapping;
}
return $cleaned;
}
/**
* 更新任务
*
* @param string $taskId 任务ID
* @param array<string, mixed> $taskData 任务数据
* @return bool 是否更新成功
*/
public function updateTask(string $taskId, array $taskData): bool
{
// 使用where查询因为主键是task_id而不是_id
$task = $this->taskRepository->where('task_id', $taskId)->first();
if (!$task) {
throw new \InvalidArgumentException("任务不存在: {$taskId}");
}
// 如果任务正在运行,完全禁止编辑(与前端逻辑保持一致)
if ($task->status === 'running') {
throw new \RuntimeException("运行中的任务不允许编辑,请先停止任务: {$taskId}");
}
// timestamps会自动处理updated_at
$result = $this->taskRepository->where('task_id', $taskId)->update($taskData);
LoggerHelper::logBusiness('data_collection_task_updated', [
'task_id' => $taskId,
'updated_fields' => array_keys($taskData),
]);
return $result > 0;
}
/**
* 删除任务
*
* 如果任务正在运行或已暂停,会先停止任务再删除
*
* @param string $taskId 任务ID
* @return bool 是否删除成功
*/
public function deleteTask(string $taskId): bool
{
// 使用where查询因为主键是task_id而不是_id
$task = $this->taskRepository->where('task_id', $taskId)->first();
if (!$task) {
throw new \InvalidArgumentException("任务不存在: {$taskId}");
}
// 如果任务正在运行或已暂停,先停止
if (in_array($task->status, ['running', 'paused'])) {
$this->stopTask($taskId);
}
$result = $this->taskRepository->where('task_id', $taskId)->delete();
LoggerHelper::logBusiness('data_collection_task_deleted', [
'task_id' => $taskId,
'previous_status' => $task->status,
]);
return $result > 0;
}
/**
* 启动任务
*
* 允许从以下状态启动:
* - pending (待启动) -> running
* - paused (已暂停) -> running (恢复)
* - stopped (已停止) -> running (重新启动)
* - completed (已完成) -> running (重新启动)
* - error (错误) -> running (重新启动)
*
* @param string $taskId 任务ID
* @return bool 是否启动成功
*/
public function startTask(string $taskId): bool
{
// 使用where查询因为主键是task_id而不是_id
$task = $this->taskRepository->where('task_id', $taskId)->first();
if (!$task) {
throw new \InvalidArgumentException("任务不存在: {$taskId}");
}
// 只允许从特定状态启动
$allowedStatuses = ['pending', 'paused', 'stopped', 'completed', 'error'];
if (!in_array($task->status, $allowedStatuses)) {
if ($task->status === 'running') {
throw new \RuntimeException("任务已在运行中: {$taskId}");
}
throw new \RuntimeException("任务当前状态不允许启动: {$taskId} (当前状态: {$task->status})");
}
// 如果是从 paused, stopped, completed, error 状态启动(重新启动),需要重置进度
$progress = $task->progress ?? [];
if (in_array($task->status, ['paused', 'stopped', 'completed', 'error'])) {
// 重新启动时重置进度保留总数为0表示重新开始
$progress = [
'status' => 'running',
'processed_count' => 0,
'success_count' => 0,
'error_count' => 0,
'total_count' => 0, // 总数量会在采集开始时设置
'percentage' => 0,
'start_time' => new \MongoDB\BSON\UTCDateTime(time() * 1000),
'end_time' => null,
'last_sync_time' => null,
];
} else {
// 从 pending 状态启动,初始化进度
$progress['status'] = 'running';
$progress['start_time'] = new \MongoDB\BSON\UTCDateTime(time() * 1000);
}
$this->taskRepository->where('task_id', $taskId)->update([
'status' => 'running',
'progress' => $progress,
]);
// 清除之前的暂停和停止标志(如果存在)
RedisHelper::del("data_collection_task:{$taskId}:pause");
RedisHelper::del("data_collection_task:{$taskId}:stop");
// 设置Redis标志通知调度器启动任务
RedisHelper::set("data_collection_task:{$taskId}:start", '1', 3600);
LoggerHelper::logBusiness('data_collection_task_started', [
'task_id' => $taskId,
'previous_status' => $task->status,
]);
return true;
}
/**
* 暂停任务
*
* @param string $taskId 任务ID
* @return bool 是否暂停成功
*/
public function pauseTask(string $taskId): bool
{
// 使用where查询因为主键是task_id而不是_id
$task = $this->taskRepository->where('task_id', $taskId)->first();
if (!$task) {
throw new \InvalidArgumentException("任务不存在: {$taskId}");
}
if ($task->status !== 'running') {
throw new \RuntimeException("任务未在运行中: {$taskId}");
}
// 更新任务状态
// 注意需要使用完整的数组来更新嵌套字段timestamps会自动处理updated_at
$progress = $task->progress ?? [];
$progress['status'] = 'paused';
$this->taskRepository->where('task_id', $taskId)->update([
'status' => 'paused',
'progress' => $progress,
]);
// 设置Redis标志通知调度器暂停任务
RedisHelper::set("data_collection_task:{$taskId}:pause", '1', 3600);
LoggerHelper::logBusiness('data_collection_task_paused', [
'task_id' => $taskId,
]);
return true;
}
/**
* 停止任务
*
* 只允许从以下状态停止:
* - running (运行中) -> stopped
* - paused (已暂停) -> stopped
*
* @param string $taskId 任务ID
* @return bool 是否停止成功
*/
public function stopTask(string $taskId): bool
{
// 使用where查询因为主键是task_id而不是_id
$task = $this->taskRepository->where('task_id', $taskId)->first();
if (!$task) {
throw new \InvalidArgumentException("任务不存在: {$taskId}");
}
// 只允许从 running 或 paused 状态停止
if (!in_array($task->status, ['running', 'paused'])) {
throw new \RuntimeException("任务当前状态不允许停止: {$taskId} (当前状态: {$task->status})");
}
// 停止任务时,保持当前进度,不重置(只更新状态)
$currentProgress = $task->progress ?? [];
$progress = [
'status' => 'idle', // idle, running, paused, completed, error
'processed_count' => $currentProgress['processed_count'] ?? 0,
'success_count' => $currentProgress['success_count'] ?? 0,
'error_count' => $currentProgress['error_count'] ?? 0,
'total_count' => $currentProgress['total_count'] ?? 0,
'percentage' => $currentProgress['percentage'] ?? 0, // 保持当前进度百分比
'start_time' => $currentProgress['start_time'] ?? null,
'end_time' => new \MongoDB\BSON\UTCDateTime(time() * 1000), // 记录停止时间
'last_sync_time' => $currentProgress['last_sync_time'] ?? null,
];
$this->taskRepository->where('task_id', $taskId)->update([
'status' => 'stopped',
'progress' => $progress,
]);
// 设置Redis标志通知调度器停止任务
RedisHelper::set("data_collection_task:{$taskId}:stop", '1', 3600);
// 如果任务之前是 paused也需要清除暂停标志
if ($task->status === 'paused') {
RedisHelper::del("data_collection_task:{$taskId}:pause");
}
LoggerHelper::logBusiness('data_collection_task_stopped', [
'task_id' => $taskId,
'previous_status' => $task->status,
'progress_reset' => true,
]);
return true;
}
/**
* 获取任务列表
*
* @param array<string, mixed> $filters 过滤条件
* @param int $page 页码
* @param int $pageSize 每页数量
* @return array<string, mixed> 任务列表
*/
public function getTaskList(array $filters = [], int $page = 1, int $pageSize = 20): array
{
$query = $this->taskRepository->query();
// 应用过滤条件(只处理非空值,如果筛选条件为空则返回所有任务)
if (!empty($filters['status']) && $filters['status'] !== '') {
$query->where('status', $filters['status']);
}
if (!empty($filters['data_source_id']) && $filters['data_source_id'] !== '') {
$query->where('data_source_id', $filters['data_source_id']);
}
if (!empty($filters['name']) && $filters['name'] !== '') {
// MongoDB 使用正则表达式进行模糊查询
$namePattern = preg_quote($filters['name'], '/');
$query->where('name', 'regex', "/{$namePattern}/i");
}
// 分页
$total = $query->count();
$taskModels = $query->orderBy('created_at', 'desc')
->skip(($page - 1) * $pageSize)
->take($pageSize)
->get();
// 手动转换为数组,避免 cast 机制对数组字段的错误处理
$tasks = [];
foreach ($taskModels as $model) {
$task = $model->getAttributes();
// 使用统一的日期字段处理方法
$task = $this->normalizeDateFields($task);
$tasks[] = $task;
}
return [
'tasks' => $tasks,
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
'total_pages' => ceil($total / $pageSize),
];
}
/**
* 获取任务详情
*
* @param string $taskId 任务ID
* @return array<string, mixed>|null 任务详情
*/
public function getTask(string $taskId): ?array
{
// 使用where查询因为主键是task_id而不是_id
$task = $this->taskRepository->where('task_id', $taskId)->first();
if (!$task) {
return null;
}
// 手动转换为数组,避免 cast 机制对数组字段的错误处理
$taskArray = $task->getAttributes();
// 使用统一的日期字段处理方法
$taskArray = $this->normalizeDateFields($taskArray);
return $taskArray;
}
/**
* 更新任务进度
*
* @param string $taskId 任务ID
* @param array<string, mixed> $progress 进度信息
* @return bool 是否更新成功
*/
public function updateProgress(string $taskId, array $progress): bool
{
$updateData = [
'progress' => $progress,
];
// 如果进度包含统计信息,也更新统计
// 注意这里的统计应该是累加的但进度字段processed_count等应该直接设置
if (isset($progress['success_count']) || isset($progress['error_count'])) {
// 使用where查询因为主键是task_id而不是_id
$task = $this->taskRepository->where('task_id', $taskId)->first();
if ($task) {
$statistics = $task->statistics ?? [];
// 统计信息使用增量更新(累加本次运行的数据)
// 但这里需要判断是增量还是绝对值,如果是绝对值则应该直接设置
// 由于进度更新传入的是绝对值,所以这里应该直接使用最新值而不是累加
if (isset($progress['processed_count'])) {
$statistics['total_processed'] = $progress['processed_count'];
}
if (isset($progress['success_count'])) {
$statistics['total_success'] = $progress['success_count'];
}
if (isset($progress['error_count'])) {
$statistics['total_error'] = $progress['error_count'];
}
$statistics['last_run_time'] = new \MongoDB\BSON\UTCDateTime(time() * 1000);
$updateData['statistics'] = $statistics;
}
}
// 使用 where()->update() 更新文档
// 注意MongoDB Laravel Eloquent 的 update() 返回匹配的文档数量通常是1或0
$result = $this->taskRepository->where('task_id', $taskId)->update($updateData);
// 添加日志以便调试
if ($result === false || $result === 0) {
\Workerman\Worker::safeEcho("[DataCollectionTaskService] ⚠️ 更新进度失败: task_id={$taskId}, result={$result}\n");
} else {
\Workerman\Worker::safeEcho("[DataCollectionTaskService] ✅ 更新进度成功: task_id={$taskId}, 匹配文档数={$result}\n");
}
return $result > 0;
}
/**
* 统一处理日期字段,转换为 ISO 8601 字符串格式
*
* @param array<string, mixed> $task 任务数据
* @return array<string, mixed> 处理后的任务数据
*/
private function normalizeDateFields(array $task): array
{
foreach (['created_at', 'updated_at'] as $dateField) {
if (isset($task[$dateField])) {
if ($task[$dateField] instanceof \MongoDB\BSON\UTCDateTime) {
$task[$dateField] = $task[$dateField]->toDateTime()->format('Y-m-d\TH:i:s.000\Z');
} elseif ($task[$dateField] instanceof \DateTime || $task[$dateField] instanceof \DateTimeInterface) {
$task[$dateField] = $task[$dateField]->format('Y-m-d\TH:i:s.000\Z');
} elseif (is_array($task[$dateField]) && isset($task[$dateField]['$date'])) {
// 处理 JSON 编码后的日期格式
$dateValue = $task[$dateField]['$date'];
if (is_numeric($dateValue)) {
// 如果是数字,假设是毫秒时间戳
$timestamp = $dateValue / 1000;
$task[$dateField] = date('Y-m-d\TH:i:s.000\Z', (int)$timestamp);
} elseif (is_array($dateValue) && isset($dateValue['$numberLong'])) {
// MongoDB 扩展 JSON 格式:{"$date": {"$numberLong": "1640000000000"}}
$timestamp = intval($dateValue['$numberLong']) / 1000;
$task[$dateField] = date('Y-m-d\TH:i:s.000\Z', (int)$timestamp);
} else {
// 其他格式,尝试解析或保持原样
$task[$dateField] = is_string($dateValue) ? $dateValue : json_encode($dateValue);
}
}
}
}
return $task;
}
/**
* 获取所有运行中的任务
*
* @return array<int, array<string, mixed>> 运行中的任务列表
*/
public function getRunningTasks(): array
{
// 使用原生 MongoDB 查询,避免 Model 的 cast 机制导致数组字段被错误处理
$dbConfig = config('database.connections.mongodb');
// 使用 MongoDBHelper 创建客户端统一DSN构建逻辑
$client = \app\utils\MongoDBHelper::createClient([
'host' => parse_url($dbConfig['dsn'], PHP_URL_HOST) ?? '192.168.1.106',
'port' => parse_url($dbConfig['dsn'], PHP_URL_PORT) ?? 27017,
'username' => $dbConfig['username'] ?? '',
'password' => $dbConfig['password'] ?? '',
'auth_source' => $dbConfig['options']['authSource'] ?? 'admin',
], array_filter($dbConfig['options'] ?? [], function ($value) {
return $value !== '' && $value !== null;
}));
$database = $client->selectDatabase($dbConfig['database']);
$collection = $database->selectCollection('data_collection_tasks');
// 查询所有运行中的任务
$cursor = $collection->find(['status' => 'running']);
$tasks = [];
foreach ($cursor as $document) {
// MongoDB BSONDocument 需要转换为数组
if ($document instanceof \MongoDB\Model\BSONDocument) {
$task = json_decode(json_encode($document), true);
} elseif (is_array($document)) {
$task = $document;
} else {
// 其他类型,尝试转换为数组
$task = (array)$document;
}
// 处理 MongoDB 的 _id 字段
if (isset($task['_id'])) {
if (is_object($task['_id'])) {
$task['_id'] = (string)$task['_id'];
}
}
// 使用统一的日期字段处理方法
$task = $this->normalizeDateFields($task);
$tasks[] = $task;
}
return $tasks;
}
}

View File

@@ -0,0 +1,309 @@
<?php
namespace app\service\DataSource\Adapter;
use app\service\DataSource\DataSourceAdapterInterface;
use app\utils\LoggerHelper;
use MongoDB\Client;
use MongoDB\Driver\Exception\Exception as MongoDBException;
/**
* MongoDB 数据源适配器
*
* 职责:
* - 封装 MongoDB 数据库连接和查询操作
* - 实现 DataSourceAdapterInterface 接口
*/
class MongoDBAdapter implements DataSourceAdapterInterface
{
private ?Client $client = null;
private ?\MongoDB\Database $database = null;
private string $type = 'mongodb';
private string $databaseName = '';
/**
* 建立数据库连接
*
* @param array<string, mixed> $config 数据源配置
* @return bool 是否连接成功
*/
public function connect(array $config): bool
{
try {
$host = $config['host'] ?? '127.0.0.1';
$port = (int)($config['port'] ?? 27017);
$this->databaseName = $config['database'] ?? '';
$username = $config['username'] ?? '';
$password = $config['password'] ?? '';
$authSource = $config['auth_source'] ?? $this->databaseName;
// 构建 DSN
$dsn = "mongodb://";
if (!empty($username) && !empty($password)) {
$dsn .= urlencode($username) . ':' . urlencode($password) . '@';
}
$dsn .= "{$host}:{$port}";
if (!empty($this->databaseName)) {
$dsn .= "/{$this->databaseName}";
}
if (!empty($authSource)) {
$dsn .= "?authSource=" . urlencode($authSource);
}
// MongoDB 连接选项
$options = [];
if (isset($config['options'])) {
$options = array_filter($config['options'], function ($value) {
return $value !== '' && $value !== null;
});
}
// 设置超时选项
if (!isset($options['connectTimeoutMS'])) {
$options['connectTimeoutMS'] = ($config['timeout'] ?? 10) * 1000;
}
if (!isset($options['socketTimeoutMS'])) {
$options['socketTimeoutMS'] = ($config['timeout'] ?? 10) * 1000;
}
$this->client = new Client($dsn, $options);
// 选择数据库
if (!empty($this->databaseName)) {
$this->database = $this->client->selectDatabase($this->databaseName);
}
// 测试连接
$this->client->getManager()->selectServer();
LoggerHelper::logBusiness('mongodb_adapter_connected', [
'host' => $host,
'port' => $port,
'database' => $this->databaseName,
]);
return true;
} catch (MongoDBException $e) {
LoggerHelper::logError($e, [
'component' => 'MongoDBAdapter',
'action' => 'connect',
'config' => array_merge($config, ['password' => '***']), // 隐藏密码
]);
return false;
}
}
/**
* 关闭数据库连接
*
* @return void
*/
public function disconnect(): void
{
if ($this->client !== null) {
$this->client = null;
$this->database = null;
LoggerHelper::logBusiness('mongodb_adapter_disconnected', []);
}
}
/**
* 测试连接是否有效
*
* @return bool 连接是否有效
*/
public function isConnected(): bool
{
if ($this->client === null) {
return false;
}
try {
// 执行 ping 命令测试连接
$adminDb = $this->client->selectDatabase('admin');
$adminDb->command(['ping' => 1]);
return true;
} catch (MongoDBException $e) {
LoggerHelper::logError($e, [
'component' => 'MongoDBAdapter',
'action' => 'isConnected',
]);
return false;
}
}
/**
* 执行查询(返回多条记录)
*
* 注意:对于 MongoDB$sql 参数表示集合名称,$params 是一个包含 'filter' 和 'options' 的数组
*
* @param string $sql 集合名称MongoDB 中相当于表名)
* @param array<string, mixed> $params 查询参数,格式:['filter' => [...], 'options' => [...]]
* @return array<array<string, mixed>> 查询结果数组
*/
public function query(string $sql, array $params = []): array
{
if ($this->database === null) {
throw new \RuntimeException('数据库连接未建立或未选择数据库');
}
try {
$collection = $sql; // $sql 参数在 MongoDB 中表示集合名
$filter = $params['filter'] ?? [];
$options = $params['options'] ?? [];
$cursor = $this->database->selectCollection($collection)->find($filter, $options);
$results = [];
foreach ($cursor as $document) {
$results[] = $this->convertMongoDocumentToArray($document);
}
LoggerHelper::logBusiness('mongodb_query_executed', [
'collection' => $collection,
'filter' => $filter,
'result_count' => count($results),
]);
return $results;
} catch (MongoDBException $e) {
LoggerHelper::logError($e, [
'component' => 'MongoDBAdapter',
'action' => 'query',
'collection' => $sql,
'params' => $params,
]);
throw $e;
}
}
/**
* 执行查询(返回单条记录)
*
* 注意:对于 MongoDB$sql 参数表示集合名称,$params 是一个包含 'filter' 和 'options' 的数组
*
* @param string $sql 集合名称
* @param array<string, mixed> $params 查询参数,格式:['filter' => [...], 'options' => [...]]
* @return array<string, mixed>|null 查询结果(单条记录)或 null
*/
public function queryOne(string $sql, array $params = []): ?array
{
if ($this->database === null) {
throw new \RuntimeException('数据库连接未建立或未选择数据库');
}
try {
$collection = $sql; // $sql 参数在 MongoDB 中表示集合名
$filter = $params['filter'] ?? [];
$options = $params['options'] ?? [];
$document = $this->database->selectCollection($collection)->findOne($filter, $options);
if ($document === null) {
return null;
}
LoggerHelper::logBusiness('mongodb_query_one_executed', [
'collection' => $collection,
'filter' => $filter,
'has_result' => true,
]);
return $this->convertMongoDocumentToArray($document);
} catch (MongoDBException $e) {
LoggerHelper::logError($e, [
'component' => 'MongoDBAdapter',
'action' => 'queryOne',
'collection' => $sql,
'params' => $params,
]);
throw $e;
}
}
/**
* 批量查询(分页查询,用于大数据量场景)
*
* 注意:对于 MongoDB$sql 参数表示集合名称,$params 是一个包含 'filter' 和 'options' 的数组
*
* @param string $sql 集合名称
* @param array<string, mixed> $params 查询参数,格式:['filter' => [...], 'options' => [...]]
* @param int $offset 偏移量
* @param int $limit 每页数量
* @return array<array<string, mixed>> 查询结果数组
*/
public function queryBatch(string $sql, array $params = [], int $offset = 0, int $limit = 1000): array
{
if ($this->database === null) {
throw new \RuntimeException('数据库连接未建立或未选择数据库');
}
try {
$collection = $sql; // $sql 参数在 MongoDB 中表示集合名
$filter = $params['filter'] ?? [];
$options = $params['options'] ?? [];
// 设置分页选项
$options['skip'] = $offset;
$options['limit'] = $limit;
$cursor = $this->database->selectCollection($collection)->find($filter, $options);
$results = [];
foreach ($cursor as $document) {
$results[] = $this->convertMongoDocumentToArray($document);
}
LoggerHelper::logBusiness('mongodb_query_batch_executed', [
'collection' => $collection,
'offset' => $offset,
'limit' => $limit,
'result_count' => count($results),
]);
return $results;
} catch (MongoDBException $e) {
LoggerHelper::logError($e, [
'component' => 'MongoDBAdapter',
'action' => 'queryBatch',
'collection' => $sql,
'params' => $params,
'offset' => $offset,
'limit' => $limit,
]);
throw $e;
}
}
/**
* 获取数据源类型
*
* @return string 数据源类型
*/
public function getType(): string
{
return $this->type;
}
/**
* 将 MongoDB 文档转换为数组
*
* @param mixed $document MongoDB 文档对象
* @return array<string, mixed> 数组格式的数据
*/
private function convertMongoDocumentToArray($document): array
{
if (is_array($document)) {
return $document;
}
// MongoDB\BSON\Document 或 MongoDB\Model\BSONDocument
if (method_exists($document, 'toArray')) {
return $document->toArray();
}
// 转换为数组
return json_decode(json_encode($document), true) ?? [];
}
}

View File

@@ -0,0 +1,234 @@
<?php
namespace app\service\DataSource\Adapter;
use app\service\DataSource\DataSourceAdapterInterface;
use app\utils\LoggerHelper;
use PDO;
use PDOException;
/**
* MySQL 数据源适配器
*
* 职责:
* - 封装 MySQL 数据库连接和查询操作
* - 实现 DataSourceAdapterInterface 接口
*/
class MySQLAdapter implements DataSourceAdapterInterface
{
private ?PDO $connection = null;
private string $type = 'mysql';
/**
* 建立数据库连接
*
* @param array<string, mixed> $config 数据源配置
* @return bool 是否连接成功
*/
public function connect(array $config): bool
{
try {
$host = $config['host'] ?? '127.0.0.1';
$port = $config['port'] ?? 3306;
$database = $config['database'] ?? '';
$username = $config['username'] ?? '';
$password = $config['password'] ?? '';
$charset = $config['charset'] ?? 'utf8mb4';
// 构建 DSN
$dsn = "mysql:host={$host};port={$port};dbname={$database};charset={$charset}";
// PDO 选项
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // 禁用预处理语句模拟
PDO::ATTR_PERSISTENT => $config['persistent'] ?? false, // 是否持久连接
PDO::ATTR_TIMEOUT => $config['timeout'] ?? 10, // 连接超时
];
$this->connection = new PDO($dsn, $username, $password, $options);
LoggerHelper::logBusiness('mysql_adapter_connected', [
'host' => $host,
'port' => $port,
'database' => $database,
]);
return true;
} catch (PDOException $e) {
LoggerHelper::logError($e, [
'component' => 'MySQLAdapter',
'action' => 'connect',
'config' => array_merge($config, ['password' => '***']), // 隐藏密码
]);
return false;
}
}
/**
* 关闭数据库连接
*
* @return void
*/
public function disconnect(): void
{
if ($this->connection !== null) {
$this->connection = null;
LoggerHelper::logBusiness('mysql_adapter_disconnected', []);
}
}
/**
* 测试连接是否有效
*
* @return bool 连接是否有效
*/
public function isConnected(): bool
{
if ($this->connection === null) {
return false;
}
try {
// 执行简单查询测试连接
$this->connection->query('SELECT 1');
return true;
} catch (PDOException $e) {
LoggerHelper::logError($e, [
'component' => 'MySQLAdapter',
'action' => 'isConnected',
]);
return false;
}
}
/**
* 执行查询(返回多条记录)
*
* @param string $sql SQL 查询语句
* @param array<string, mixed> $params 查询参数(绑定参数)
* @return array<array<string, mixed>> 查询结果数组
*/
public function query(string $sql, array $params = []): array
{
if ($this->connection === null) {
throw new \RuntimeException('数据库连接未建立');
}
try {
$stmt = $this->connection->prepare($sql);
$stmt->execute($params);
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
LoggerHelper::logBusiness('mysql_query_executed', [
'sql' => $sql,
'params_count' => count($params),
'result_count' => count($results),
]);
return $results;
} catch (PDOException $e) {
LoggerHelper::logError($e, [
'component' => 'MySQLAdapter',
'action' => 'query',
'sql' => $sql,
'params' => $params,
]);
throw $e;
}
}
/**
* 执行查询(返回单条记录)
*
* @param string $sql SQL 查询语句
* @param array<string, mixed> $params 查询参数
* @return array<string, mixed>|null 查询结果(单条记录)或 null
*/
public function queryOne(string $sql, array $params = []): ?array
{
if ($this->connection === null) {
throw new \RuntimeException('数据库连接未建立');
}
try {
$stmt = $this->connection->prepare($sql);
$stmt->execute($params);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
LoggerHelper::logBusiness('mysql_query_one_executed', [
'sql' => $sql,
'params_count' => count($params),
'has_result' => $result !== false,
]);
return $result !== false ? $result : null;
} catch (PDOException $e) {
LoggerHelper::logError($e, [
'component' => 'MySQLAdapter',
'action' => 'queryOne',
'sql' => $sql,
'params' => $params,
]);
throw $e;
}
}
/**
* 批量查询(分页查询,用于大数据量场景)
*
* @param string $sql SQL 查询语句(需要包含 LIMIT 和 OFFSET或由适配器自动添加
* @param array<string, mixed> $params 查询参数
* @param int $offset 偏移量
* @param int $limit 每页数量
* @return array<array<string, mixed>> 查询结果数组
*/
public function queryBatch(string $sql, array $params = [], int $offset = 0, int $limit = 1000): array
{
if ($this->connection === null) {
throw new \RuntimeException('数据库连接未建立');
}
try {
// 如果 SQL 中已包含 LIMIT则直接使用否则自动添加
if (stripos($sql, 'LIMIT') === false) {
$sql .= " LIMIT {$limit} OFFSET {$offset}";
}
$stmt = $this->connection->prepare($sql);
$stmt->execute($params);
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
LoggerHelper::logBusiness('mysql_query_batch_executed', [
'sql' => $sql,
'offset' => $offset,
'limit' => $limit,
'result_count' => count($results),
]);
return $results;
} catch (PDOException $e) {
LoggerHelper::logError($e, [
'component' => 'MySQLAdapter',
'action' => 'queryBatch',
'sql' => $sql,
'params' => $params,
'offset' => $offset,
'limit' => $limit,
]);
throw $e;
}
}
/**
* 获取数据源类型
*
* @return string 数据源类型
*/
public function getType(): string
{
return $this->type;
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace app\service\DataSource;
use app\service\DataSource\Adapter\MySQLAdapter;
use app\service\DataSource\Adapter\MongoDBAdapter;
use app\utils\LoggerHelper;
/**
* 数据源适配器工厂
*
* 职责:
* - 根据数据源类型创建对应的适配器实例
* - 管理适配器实例(单例模式,避免重复创建连接)
*/
class DataSourceAdapterFactory
{
/**
* 适配器实例缓存(单例模式)
*
* @var array<string, DataSourceAdapterInterface>
*/
private static array $instances = [];
/**
* 创建数据源适配器
*
* @param string $type 数据源类型mysql、postgresql、mongodb 等)
* @param array<string, mixed> $config 数据源配置
* @return DataSourceAdapterInterface 适配器实例
* @throws \InvalidArgumentException 不支持的数据源类型
*/
public static function create(string $type, array $config): DataSourceAdapterInterface
{
// 生成缓存键(基于类型和配置)
$cacheKey = self::generateCacheKey($type, $config);
// 如果已存在实例,直接返回
if (isset(self::$instances[$cacheKey])) {
$adapter = self::$instances[$cacheKey];
// 检查连接是否有效
if ($adapter->isConnected()) {
return $adapter;
}
// 连接已断开,重新创建
unset(self::$instances[$cacheKey]);
}
// 根据类型创建适配器
$adapter = match (strtolower($type)) {
'mysql' => new MySQLAdapter(),
'mongodb' => new MongoDBAdapter(),
// 'postgresql' => new PostgreSQLAdapter(),
default => throw new \InvalidArgumentException("不支持的数据源类型: {$type}"),
};
// 建立连接
if (!$adapter->connect($config)) {
throw new \RuntimeException("无法连接到数据源: {$type}");
}
// 缓存实例
self::$instances[$cacheKey] = $adapter;
LoggerHelper::logBusiness('data_source_adapter_created', [
'type' => $type,
'cache_key' => $cacheKey,
]);
return $adapter;
}
/**
* 生成缓存键
*
* @param string $type 数据源类型
* @param array<string, mixed> $config 数据源配置
* @return string 缓存键
*/
private static function generateCacheKey(string $type, array $config): string
{
// 基于类型、主机、端口、数据库名生成唯一键
$host = $config['host'] ?? 'unknown';
$port = $config['port'] ?? 'unknown';
$database = $config['database'] ?? 'unknown';
return md5("{$type}:{$host}:{$port}:{$database}");
}
/**
* 清除所有适配器实例(用于测试或重新连接)
*
* @return void
*/
public static function clearInstances(): void
{
foreach (self::$instances as $adapter) {
try {
$adapter->disconnect();
} catch (\Throwable $e) {
LoggerHelper::logError($e, ['component' => 'DataSourceAdapterFactory', 'action' => 'clearInstances']);
}
}
self::$instances = [];
}
/**
* 获取所有已创建的适配器实例
*
* @return array<string, DataSourceAdapterInterface>
*/
public static function getInstances(): array
{
return self::$instances;
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace app\service\DataSource;
/**
* 数据源适配器接口
*
* 职责:
* - 定义统一的数据源访问接口
* - 支持多种数据库类型MySQL、PostgreSQL、MongoDB 等)
* - 提供基础查询能力
*/
interface DataSourceAdapterInterface
{
/**
* 建立数据库连接
*
* @param array<string, mixed> $config 数据源配置
* @return bool 是否连接成功
*/
public function connect(array $config): bool;
/**
* 关闭数据库连接
*
* @return void
*/
public function disconnect(): void;
/**
* 测试连接是否有效
*
* @return bool 连接是否有效
*/
public function isConnected(): bool;
/**
* 执行查询(返回多条记录)
*
* @param string $sql SQL 查询语句(或 MongoDB 查询条件)
* @param array<string, mixed> $params 查询参数(绑定参数或 MongoDB 查询选项)
* @return array<array<string, mixed>> 查询结果数组
*/
public function query(string $sql, array $params = []): array;
/**
* 执行查询(返回单条记录)
*
* @param string $sql SQL 查询语句(或 MongoDB 查询条件)
* @param array<string, mixed> $params 查询参数
* @return array<string, mixed>|null 查询结果(单条记录)或 null
*/
public function queryOne(string $sql, array $params = []): ?array;
/**
* 批量查询(分页查询,用于大数据量场景)
*
* @param string $sql SQL 查询语句
* @param array<string, mixed> $params 查询参数
* @param int $offset 偏移量
* @param int $limit 每页数量
* @return array<array<string, mixed>> 查询结果数组
*/
public function queryBatch(string $sql, array $params = [], int $offset = 0, int $limit = 1000): array;
/**
* 获取数据源类型
*
* @return string 数据源类型mysql、postgresql、mongodb 等)
*/
public function getType(): string;
}

View File

@@ -0,0 +1,68 @@
<?php
namespace app\service\DataSource;
use app\service\DataSource\Strategy\DefaultConsumptionStrategy;
use app\utils\LoggerHelper;
/**
* 轮询策略工厂
*
* 职责:
* - 根据配置创建对应的轮询策略实例
* - 支持自定义策略类
*/
class PollingStrategyFactory
{
/**
* 创建轮询策略
*
* @param string|array<string, mixed> $strategyConfig 策略配置(字符串为策略类名,数组包含 class 和 config
* @return PollingStrategyInterface 策略实例
* @throws \InvalidArgumentException 无效的策略配置
*/
public static function create(string|array $strategyConfig): PollingStrategyInterface
{
// 如果配置是字符串,则作为策略类名
if (is_string($strategyConfig)) {
$className = $strategyConfig;
$strategyConfig = ['class' => $className];
}
// 获取策略类名
$className = $strategyConfig['class'] ?? null;
if (!$className) {
// 如果没有指定策略,使用默认策略
$className = DefaultConsumptionStrategy::class;
}
// 验证类是否存在
if (!class_exists($className)) {
throw new \InvalidArgumentException("策略类不存在: {$className}");
}
// 验证类是否实现了接口
if (!is_subclass_of($className, PollingStrategyInterface::class)) {
throw new \InvalidArgumentException("策略类必须实现 PollingStrategyInterface: {$className}");
}
// 创建策略实例
try {
$strategy = new $className();
LoggerHelper::logBusiness('polling_strategy_created', [
'class' => $className,
]);
return $strategy;
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'PollingStrategyFactory',
'action' => 'create',
'class' => $className,
]);
throw new \RuntimeException("无法创建策略实例: {$className}", 0, $e);
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace app\service\DataSource;
/**
* 轮询策略接口
*
* 职责:
* - 定义自定义轮询业务逻辑的接口
* - 每个数据源可配置独立的轮询策略
* - 支持自定义查询、转换、验证逻辑
*/
interface PollingStrategyInterface
{
/**
* 执行轮询查询
*
* @param DataSourceAdapterInterface $adapter 数据源适配器
* @param array<string, mixed> $config 数据源配置
* @param array<string, mixed> $lastSyncInfo 上次同步信息(包含 last_sync_time、last_sync_id 等)
* @return array<array<string, mixed>> 查询结果数组(原始数据)
*/
public function poll(
DataSourceAdapterInterface $adapter,
array $config,
array $lastSyncInfo = []
): array;
/**
* 数据转换
*
* @param array<array<string, mixed>> $rawData 原始数据
* @param array<string, mixed> $config 数据源配置
* @return array<array<string, mixed>> 转换后的数据(标准格式)
*/
public function transform(array $rawData, array $config): array;
/**
* 数据验证
*
* @param array<string, mixed> $record 单条记录
* @param array<string, mixed> $config 数据源配置
* @return bool 是否通过验证
*/
public function validate(array $record, array $config): bool;
/**
* 获取策略名称
*
* @return string 策略名称
*/
public function getName(): string;
}

View File

@@ -0,0 +1,197 @@
<?php
namespace app\service\DataSource\Strategy;
use app\service\DataSource\DataSourceAdapterInterface;
use app\service\DataSource\PollingStrategyInterface;
use app\utils\LoggerHelper;
/**
* 默认消费记录轮询策略(示例)
*
* 职责:
* - 提供默认的轮询策略实现示例
* - 展示如何实现自定义业务逻辑
* - 可根据实际需求扩展或替换
*/
class DefaultConsumptionStrategy implements PollingStrategyInterface
{
/**
* 执行轮询查询
*
* @param DataSourceAdapterInterface $adapter 数据源适配器
* @param array<string, mixed> $config 数据源配置
* @param array<string, mixed> $lastSyncInfo 上次同步信息
* @return array<array<string, mixed>> 查询结果数组
*/
public function poll(
DataSourceAdapterInterface $adapter,
array $config,
array $lastSyncInfo = []
): array {
// 从配置中获取表名和查询条件
$tableName = $config['table'] ?? 'consumption_records';
$lastSyncTime = $lastSyncInfo['last_sync_time'] ?? null;
$lastSyncId = $lastSyncInfo['last_sync_id'] ?? null;
// 构建 SQL 查询(增量查询)
$sql = "SELECT * FROM `{$tableName}` WHERE 1=1";
$params = [];
// 如果有上次同步时间,只查询新增或更新的记录
if ($lastSyncTime !== null) {
$sql .= " AND (`created_at` > :last_sync_time OR `updated_at` > :last_sync_time)";
$params[':last_sync_time'] = $lastSyncTime;
}
// 如果有上次同步ID用于去重可选
if ($lastSyncId !== null) {
$sql .= " AND `id` > :last_sync_id";
$params[':last_sync_id'] = $lastSyncId;
}
// 按创建时间排序
$sql .= " ORDER BY `created_at` ASC, `id` ASC";
// 执行查询批量查询每次最多1000条
$limit = $config['batch_size'] ?? 1000;
$offset = 0;
$allResults = [];
do {
$batchSql = $sql . " LIMIT {$limit} OFFSET {$offset}";
$results = $adapter->queryBatch($batchSql, $params, $offset, $limit);
if (empty($results)) {
break;
}
$allResults = array_merge($allResults, $results);
$offset += $limit;
// 防止无限循环最多查询10万条
if (count($allResults) >= 100000) {
LoggerHelper::logBusiness('polling_batch_limit_reached', [
'table' => $tableName,
'count' => count($allResults),
]);
break;
}
} while (count($results) === $limit);
LoggerHelper::logBusiness('polling_query_completed', [
'table' => $tableName,
'result_count' => count($allResults),
'last_sync_time' => $lastSyncTime,
]);
return $allResults;
}
/**
* 数据转换
*
* @param array<array<string, mixed>> $rawData 原始数据
* @param array<string, mixed> $config 数据源配置
* @return array<array<string, mixed>> 转换后的数据
*/
public function transform(array $rawData, array $config): array
{
// 字段映射配置(从外部数据库字段映射到标准字段)
$fieldMapping = $config['field_mapping'] ?? [
// 默认映射(如果外部数据库字段名与标准字段名一致,则无需映射)
'id' => 'id',
'user_id' => 'user_id',
'amount' => 'amount',
'store_id' => 'store_id',
'product_id' => 'product_id',
'consume_time' => 'consume_time',
'created_at' => 'created_at',
];
$transformedData = [];
foreach ($rawData as $record) {
$transformed = [];
// 应用字段映射
foreach ($fieldMapping as $standardField => $sourceField) {
if (isset($record[$sourceField])) {
$transformed[$standardField] = $record[$sourceField];
}
}
// 确保必要字段存在
if (!empty($transformed)) {
$transformedData[] = $transformed;
}
}
LoggerHelper::logBusiness('polling_transform_completed', [
'input_count' => count($rawData),
'output_count' => count($transformedData),
]);
return $transformedData;
}
/**
* 数据验证
*
* @param array<string, mixed> $record 单条记录
* @param array<string, mixed> $config 数据源配置
* @return bool 是否通过验证
*/
public function validate(array $record, array $config): bool
{
// 必填字段验证
$requiredFields = $config['required_fields'] ?? ['user_id', 'amount', 'consume_time'];
foreach ($requiredFields as $field) {
if (!isset($record[$field]) || $record[$field] === null || $record[$field] === '') {
LoggerHelper::logBusiness('polling_validation_failed', [
'reason' => "缺少必填字段: {$field}",
'record' => $record,
]);
return false;
}
}
// 金额验证(必须为正数)
if (isset($record['amount'])) {
$amount = (float)$record['amount'];
if ($amount <= 0) {
LoggerHelper::logBusiness('polling_validation_failed', [
'reason' => '金额必须大于0',
'amount' => $amount,
]);
return false;
}
}
// 时间格式验证(可选)
if (isset($record['consume_time'])) {
$time = strtotime($record['consume_time']);
if ($time === false) {
LoggerHelper::logBusiness('polling_validation_failed', [
'reason' => '时间格式无效',
'consume_time' => $record['consume_time'],
]);
return false;
}
}
return true;
}
/**
* 获取策略名称
*
* @return string 策略名称
*/
public function getName(): string
{
return 'default_consumption';
}
}

View File

@@ -0,0 +1,225 @@
<?php
namespace app\service\DataSource\Strategy;
use app\service\DataSource\DataSourceAdapterInterface;
use app\service\DataSource\PollingStrategyInterface;
use app\utils\LoggerHelper;
/**
* MongoDB 消费记录轮询策略
*
* 职责:
* - 提供 MongoDB 专用的轮询策略实现
* - 展示如何实现自定义业务逻辑
*/
class MongoDBConsumptionStrategy implements PollingStrategyInterface
{
/**
* 执行轮询查询
*
* @param DataSourceAdapterInterface $adapter 数据源适配器
* @param array<string, mixed> $config 数据源配置
* @param array<string, mixed> $lastSyncInfo 上次同步信息
* @return array<array<string, mixed>> 查询结果数组
*/
public function poll(
DataSourceAdapterInterface $adapter,
array $config,
array $lastSyncInfo = []
): array {
// 从配置中获取集合名和查询条件
$collectionName = $config['collection'] ?? 'consumption_records';
$lastSyncTime = $lastSyncInfo['last_sync_time'] ?? null;
$lastSyncId = $lastSyncInfo['last_sync_id'] ?? null;
// 构建 MongoDB 查询过滤器
$filter = [];
// 如果有上次同步时间,只查询新增或更新的记录
if ($lastSyncTime !== null) {
$lastSyncTimestamp = is_numeric($lastSyncTime) ? (int)$lastSyncTime : strtotime($lastSyncTime);
$lastSyncDate = new \MongoDB\BSON\UTCDateTime($lastSyncTimestamp * 1000);
$filter['$or'] = [
['created_at' => ['$gt' => $lastSyncDate]],
['updated_at' => ['$gt' => $lastSyncDate]],
];
}
// 如果有上次同步ID用于去重可选
if ($lastSyncId !== null) {
$filter['_id'] = ['$gt' => $lastSyncId];
}
// 查询选项
$options = [
'sort' => ['created_at' => 1, '_id' => 1], // 按创建时间和ID排序
];
// 执行查询批量查询每次最多1000条
$limit = $config['batch_size'] ?? 1000;
$offset = 0;
$allResults = [];
do {
// MongoDB 适配器的 queryBatch 方法签名queryBatch(string $sql, array $params = [], int $offset = 0, int $limit = 1000)
// 对于 MongoDB$sql 是集合名,$params 包含 'filter' 和 'options'
$results = $adapter->queryBatch($collectionName, [
'filter' => $filter,
'options' => $options,
], $offset, $limit);
if (empty($results)) {
break;
}
$allResults = array_merge($allResults, $results);
$offset += $limit;
// 防止无限循环最多查询10万条
if (count($allResults) >= 100000) {
LoggerHelper::logBusiness('polling_batch_limit_reached', [
'collection' => $collectionName,
'count' => count($allResults),
]);
break;
}
} while (count($results) === $limit);
LoggerHelper::logBusiness('polling_query_completed', [
'collection' => $collectionName,
'result_count' => count($allResults),
'last_sync_time' => $lastSyncTime,
]);
return $allResults;
}
/**
* 数据转换
*
* @param array<array<string, mixed>> $rawData 原始数据
* @param array<string, mixed> $config 数据源配置
* @return array<array<string, mixed>> 转换后的数据
*/
public function transform(array $rawData, array $config): array
{
// 字段映射配置(从外部数据库字段映射到标准字段)
$fieldMapping = $config['field_mapping'] ?? [
// 默认映射MongoDB 使用 _id需要转换为 id
'_id' => 'id',
'user_id' => 'user_id',
'amount' => 'amount',
'store_id' => 'store_id',
'product_id' => 'product_id',
'consume_time' => 'consume_time',
'created_at' => 'created_at',
];
$transformedData = [];
foreach ($rawData as $record) {
$transformed = [];
// 处理 MongoDB 的 _id 字段(转换为字符串)
if (isset($record['_id'])) {
if (is_object($record['_id']) && method_exists($record['_id'], '__toString')) {
$record['id'] = (string)$record['_id'];
} else {
$record['id'] = (string)$record['_id'];
}
}
// 处理 MongoDB 的日期字段UTCDateTime 转换为字符串)
foreach (['created_at', 'updated_at', 'consume_time'] as $dateField) {
if (isset($record[$dateField])) {
if (is_object($record[$dateField]) && method_exists($record[$dateField], 'toDateTime')) {
$record[$dateField] = $record[$dateField]->toDateTime()->format('Y-m-d H:i:s');
} elseif (is_object($record[$dateField]) && method_exists($record[$dateField], '__toString')) {
$record[$dateField] = (string)$record[$dateField];
}
}
}
// 应用字段映射
foreach ($fieldMapping as $standardField => $sourceField) {
if (isset($record[$sourceField])) {
$transformed[$standardField] = $record[$sourceField];
}
}
// 确保必要字段存在
if (!empty($transformed)) {
$transformedData[] = $transformed;
}
}
LoggerHelper::logBusiness('polling_transform_completed', [
'input_count' => count($rawData),
'output_count' => count($transformedData),
]);
return $transformedData;
}
/**
* 数据验证
*
* @param array<string, mixed> $record 单条记录
* @param array<string, mixed> $config 数据源配置
* @return bool 是否通过验证
*/
public function validate(array $record, array $config): bool
{
// 必填字段验证
$requiredFields = $config['required_fields'] ?? ['user_id', 'amount', 'consume_time'];
foreach ($requiredFields as $field) {
if (!isset($record[$field]) || $record[$field] === null || $record[$field] === '') {
LoggerHelper::logBusiness('polling_validation_failed', [
'reason' => "缺少必填字段: {$field}",
'record' => $record,
]);
return false;
}
}
// 金额验证(必须为正数)
if (isset($record['amount'])) {
$amount = (float)$record['amount'];
if ($amount <= 0) {
LoggerHelper::logBusiness('polling_validation_failed', [
'reason' => '金额必须大于0',
'amount' => $amount,
]);
return false;
}
}
// 时间格式验证(可选)
if (isset($record['consume_time'])) {
$time = strtotime($record['consume_time']);
if ($time === false) {
LoggerHelper::logBusiness('polling_validation_failed', [
'reason' => '时间格式无效',
'consume_time' => $record['consume_time'],
]);
return false;
}
}
return true;
}
/**
* 获取策略名称
*
* @return string 策略名称
*/
public function getName(): string
{
return 'mongodb_consumption';
}
}

View File

@@ -0,0 +1,498 @@
<?php
namespace app\service;
use app\repository\DataSourceRepository;
use app\service\DataSource\DataSourceAdapterFactory;
use app\utils\LoggerHelper;
use MongoDB\Client;
use Ramsey\Uuid\Uuid as UuidGenerator;
/**
* 数据源服务
*
* 职责:
* - 管理数据源的CRUD操作
* - 验证数据源连接
* - 提供数据源配置
*/
class DataSourceService
{
public function __construct(
protected DataSourceRepository $repository
) {
}
/**
* 创建数据源
*
* @param array<string, mixed> $data
* @return DataSourceRepository
* @throws \Exception
*/
public function createDataSource(array $data): DataSourceRepository
{
// 生成ID
if (empty($data['data_source_id'])) {
$data['data_source_id'] = UuidGenerator::uuid4()->toString();
}
// 验证必填字段
$requiredFields = ['name', 'type', 'host', 'port', 'database'];
foreach ($requiredFields as $field) {
if (empty($data[$field])) {
throw new \InvalidArgumentException("缺少必填字段: {$field}");
}
}
// 验证类型
$allowedTypes = ['mongodb', 'mysql', 'postgresql'];
if (!in_array(strtolower($data['type']), $allowedTypes)) {
throw new \InvalidArgumentException("不支持的数据源类型: {$data['type']}");
}
// 验证ID唯一性
$existing = $this->repository->newQuery()
->where('data_source_id', $data['data_source_id'])
->first();
if ($existing) {
throw new \InvalidArgumentException("数据源ID已存在: {$data['data_source_id']}");
}
// 验证名称唯一性
$existingByName = $this->repository->newQuery()
->where('name', $data['name'])
->first();
if ($existingByName) {
throw new \InvalidArgumentException("数据源名称已存在: {$data['name']}");
}
// 设置默认值
$data['status'] = $data['status'] ?? 1; // 1:启用, 0:禁用
$data['options'] = $data['options'] ?? [];
$data['is_tag_engine'] = $data['is_tag_engine'] ?? false; // 默认不是标签引擎数据库
// 创建数据源
$dataSource = new DataSourceRepository($data);
$dataSource->save();
// 如果设置为标签引擎数据库,自动将其他数据源设置为 false确保只有一个
if (!empty($data['is_tag_engine'])) {
// 将所有其他数据源的 is_tag_engine 设置为 false
$this->repository->newQuery()
->where('data_source_id', '!=', $dataSource->data_source_id)
->update(['is_tag_engine' => false]);
LoggerHelper::logBusiness('tag_engine_set', [
'data_source_id' => $dataSource->data_source_id,
'action' => 'create',
]);
}
LoggerHelper::logBusiness('data_source_created', [
'data_source_id' => $dataSource->data_source_id,
'name' => $dataSource->name,
'type' => $dataSource->type,
]);
return $dataSource;
}
/**
* 更新数据源
*
* @param string $dataSourceId
* @param array<string, mixed> $data
* @return bool
*/
public function updateDataSource(string $dataSourceId, array $data): bool
{
$dataSource = $this->repository->find($dataSourceId);
if (!$dataSource) {
throw new \InvalidArgumentException("数据源不存在: {$dataSourceId}");
}
// 如果更新名称,验证唯一性
if (isset($data['name']) && $data['name'] !== $dataSource->name) {
$existing = $this->repository->newQuery()
->where('name', $data['name'])
->where('data_source_id', '!=', $dataSourceId)
->first();
if ($existing) {
throw new \InvalidArgumentException("数据源名称已存在: {$data['name']}");
}
}
// 如果设置为标签引擎数据库,自动将其他数据源设置为 false确保只有一个
if (isset($data['is_tag_engine']) && !empty($data['is_tag_engine'])) {
// 将所有其他数据源的 is_tag_engine 设置为 false
$this->repository->newQuery()
->where('data_source_id', '!=', $dataSourceId)
->update(['is_tag_engine' => false]);
LoggerHelper::logBusiness('tag_engine_set', [
'data_source_id' => $dataSourceId,
'action' => 'update',
]);
}
// 更新数据
$dataSource->fill($data);
$result = $dataSource->save();
if ($result) {
LoggerHelper::logBusiness('data_source_updated', [
'data_source_id' => $dataSourceId,
]);
}
return $result;
}
/**
* 删除数据源
*
* @param string $dataSourceId
* @return bool
*/
public function deleteDataSource(string $dataSourceId): bool
{
$dataSource = $this->repository->find($dataSourceId);
if (!$dataSource) {
throw new \InvalidArgumentException("数据源不存在: {$dataSourceId}");
}
// TODO: 检查是否有任务在使用此数据源
// 可以查询 DataCollectionTask 中是否有引用此数据源
$result = $dataSource->delete();
if ($result) {
LoggerHelper::logBusiness('data_source_deleted', [
'data_source_id' => $dataSourceId,
]);
}
return $result;
}
/**
* 获取数据源列表
*
* @param array<string, mixed> $filters
* @return array{list: array, total: int}
*/
public function getDataSourceList(array $filters = []): array
{
try {
$query = $this->repository->newQuery();
// 筛选条件
if (isset($filters['type'])) {
$query->where('type', $filters['type']);
}
if (isset($filters['status'])) {
$query->where('status', $filters['status']);
}
if (isset($filters['name'])) {
$query->where('name', 'like', '%' . $filters['name'] . '%');
}
// 排序
$query->orderBy('created_at', 'desc');
// 分页
$page = (int)($filters['page'] ?? 1);
$pageSize = (int)($filters['page_size'] ?? 20);
$total = $query->count();
$list = $query->skip(($page - 1) * $pageSize)
->take($pageSize)
->get()
->map(function ($item) {
// 不返回密码
$data = $item->toArray();
unset($data['password']);
return $data;
})
->toArray();
return [
'list' => $list,
'total' => $total,
];
} catch (\MongoDB\Driver\Exception\Exception $e) {
// MongoDB 连接错误
LoggerHelper::logError($e, [
'component' => 'DataSourceService',
'action' => 'getDataSourceList',
]);
throw new \RuntimeException('无法连接到 MongoDB 数据库,请检查数据库服务是否正常运行', 500, $e);
} catch (\Exception $e) {
LoggerHelper::logError($e, [
'component' => 'DataSourceService',
'action' => 'getDataSourceList',
]);
throw $e;
}
}
/**
* 获取数据源详情(不包含密码)
*
* @param string $dataSourceId
* @return array<string, mixed>|null
*/
public function getDataSourceDetail(string $dataSourceId): ?array
{
$dataSource = $this->repository->find($dataSourceId);
if (!$dataSource) {
return null;
}
$data = $dataSource->toArray();
unset($data['password']);
return $data;
}
/**
* 获取数据源详情(包含密码,用于连接)
*
* @param string $dataSourceId
* @return array<string, mixed>|null
*/
public function getDataSourceConfig(string $dataSourceId): ?array
{
$dataSource = $this->repository->find($dataSourceId);
if (!$dataSource) {
return null;
}
if ($dataSource->status != 1) {
throw new \RuntimeException("数据源已禁用: {$dataSourceId}");
}
return $dataSource->toConfigArray();
}
/**
* 测试数据源连接
*
* @param array<string, mixed> $config
* @return bool
*/
public function testConnection(array $config): bool
{
try {
$type = strtolower($config['type'] ?? '');
// MongoDB特殊处理
if ($type === 'mongodb') {
// 使用 MongoDBHelper 创建客户端统一DSN构建逻辑
$client = \app\utils\MongoDBHelper::createClient($config, [
'connectTimeoutMS' => 3000,
'socketTimeoutMS' => 5000,
]);
// 尝试列出数据库来测试连接
$client->listDatabases();
return true;
}
// 其他类型使用适配器
$adapter = DataSourceAdapterFactory::create($type, $config);
$connected = $adapter->isConnected();
$adapter->disconnect();
return $connected;
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'DataSourceService',
'action' => 'testConnection',
]);
return false;
}
}
/**
* 获取所有启用的数据源用于替代config('data_sources'),从数据库读取)
*
* @return array<string, array> 以data_source_id为key的配置数组
*/
public function getAllEnabledDataSources(): array
{
$dataSources = $this->repository->newQuery()
->where('status', 1)
->get();
$result = [];
foreach ($dataSources as $ds) {
$result[$ds->data_source_id] = $ds->toConfigArray();
}
return $result;
}
/**
* 根据数据源ID获取配置从数据库读取
*
* 支持两种查询方式:
* 1. 通过 data_source_id (UUID) 查询
* 2. 通过 name 字段查询(兼容配置文件中的 key如 sync_mongodb, tag_mongodb
*
* @param string $dataSourceId 数据源ID或名称
* @return array<string, mixed>|null 数据源配置不存在或禁用时返回null
*/
public function getDataSourceConfigById(string $dataSourceId): ?array
{
// \Workerman\Worker::safeEcho("[DataSourceService] 查询数据源配置: data_source_id={$dataSourceId}\n");
// 先尝试通过 data_source_id 查询UUID 格式)
$dataSource = $this->repository->newQuery()
->where('data_source_id', $dataSourceId)
->where('status', 1)
->first();
if ($dataSource) {
// \Workerman\Worker::safeEcho("[DataSourceService] ✓ 通过 data_source_id 查询成功: name={$dataSource->name}\n");
return $dataSource->toConfigArray();
}
// 如果通过 data_source_id 查不到,尝试通过 name 字段查询(兼容配置文件中的 key
// \Workerman\Worker::safeEcho("[DataSourceService] 通过 data_source_id 未找到,尝试通过 name 查询\n");
// 处理配置文件中的常见 key 映射
// 注意:这些映射需要根据实际数据库中的 name 字段值来调整
$nameMapping = [
'sync_mongodb' => '本地大数据库', // 根据实际数据库中的名称调整
'tag_mongodb' => '主数据库', // 标签引擎数据库is_tag_engine=true
'kr_mongodb' => '卡若的主机', // 卡若数据库
];
$searchName = $nameMapping[$dataSourceId] ?? null;
if ($searchName) {
// \Workerman\Worker::safeEcho("[DataSourceService] 使用映射名称查询: {$dataSourceId} -> {$searchName}\n");
// 使用映射的名称查询
$dataSource = $this->repository->newQuery()
->where('name', $searchName)
->where('status', 1)
->first();
if ($dataSource) {
// \Workerman\Worker::safeEcho("[DataSourceService] ✓ 通过映射名称查询成功: name={$dataSource->name}, data_source_id={$dataSource->data_source_id}\n");
return $dataSource->toConfigArray();
}
}
// 如果还是查不到,尝试直接使用 dataSourceId 作为 name 查询
// \Workerman\Worker::safeEcho("[DataSourceService] 尝试直接使用 dataSourceId 作为 name 查询: {$dataSourceId}\n");
$dataSource = $this->repository->newQuery()
->where('name', $dataSourceId)
->where('status', 1)
->first();
if ($dataSource) {
// \Workerman\Worker::safeEcho("[DataSourceService] ✓ 通过 name 直接查询成功: name={$dataSource->name}\n");
return $dataSource->toConfigArray();
}
// 如果还是查不到,对于 tag_mongodb尝试查询 is_tag_engine=true 的数据源
if ($dataSourceId === 'tag_mongodb') {
// \Workerman\Worker::safeEcho("[DataSourceService] 对于 tag_mongodb尝试查询 is_tag_engine=true 的数据源\n");
$dataSource = $this->repository->newQuery()
->where('is_tag_engine', true)
->where('status', 1)
->first();
if ($dataSource) {
// \Workerman\Worker::safeEcho("[DataSourceService] ✓ 通过 is_tag_engine 查询成功: name={$dataSource->name}, data_source_id={$dataSource->data_source_id}\n");
return $dataSource->toConfigArray();
}
}
// \Workerman\Worker::safeEcho("[DataSourceService] ✗ 未找到数据源配置: data_source_id={$dataSourceId}\n");
return null;
}
/**
* 获取标签引擎数据库配置is_tag_engine = true的数据源
*
* @return array<string, mixed>|null 标签引擎数据库配置未找到时返回null
*/
public function getTagEngineDataSourceConfig(): ?array
{
$dataSource = $this->repository->newQuery()
->where('is_tag_engine', true)
->where('status', 1)
->first();
if (!$dataSource) {
return null;
}
return $dataSource->toConfigArray();
}
/**
* 获取标签引擎数据库的data_source_id
*
* @return string|null 标签引擎数据库的data_source_id未找到时返回null
*/
public function getTagEngineDataSourceId(): ?string
{
$dataSource = $this->repository->newQuery()
->where('is_tag_engine', true)
->where('status', 1)
->first();
return $dataSource ? $dataSource->data_source_id : null;
}
/**
* 验证标签引擎数据库配置是否存在
*
* @return bool 是否存在标签引擎数据库
*/
public function hasTagEngineDataSource(): bool
{
$count = $this->repository->newQuery()
->where('is_tag_engine', true)
->where('status', 1)
->count();
return $count > 0;
}
/**
* 获取所有标签引擎数据库(理论上应该只有一个,但允许有多个)
*
* @return array 标签引擎数据库列表
*/
public function getAllTagEngineDataSources(): array
{
$dataSources = $this->repository->newQuery()
->where('is_tag_engine', true)
->where('status', 1)
->get();
$result = [];
foreach ($dataSources as $ds) {
$data = $ds->toArray();
unset($data['password']); // 不返回密码
$result[] = $data;
}
return $result;
}
}

View File

@@ -0,0 +1,242 @@
<?php
namespace app\service;
use app\repository\ConsumptionRecordRepository;
use app\repository\UserProfileRepository;
use app\utils\QueueService;
use app\utils\LoggerHelper;
use Ramsey\Uuid\Uuid;
/**
* 数据同步服务
*
* 职责:
* - 消费消息队列中的数据同步消息
* - 批量写入 MongoDBconsumption_records
* - 更新用户统计user_profile
* - 触发标签计算(推送消息到标签计算队列)
*/
class DataSyncService
{
public function __construct(
protected ConsumptionRecordRepository $consumptionRecordRepository,
protected UserProfileRepository $userProfileRepository
) {
}
/**
* 同步数据到 MongoDB
*
* @param array<string, mixed> $messageData 消息数据(包含 source_id、data 等)
* @return array<string, mixed> 同步结果
*/
public function syncData(array $messageData): array
{
$sourceId = $messageData['source_id'] ?? 'unknown';
$data = $messageData['data'] ?? [];
$count = count($data);
if (empty($data)) {
LoggerHelper::logBusiness('data_sync_empty', [
'source_id' => $sourceId,
]);
return [
'success' => true,
'synced_count' => 0,
'skipped_count' => 0,
];
}
LoggerHelper::logBusiness('data_sync_service_started', [
'source_id' => $sourceId,
'data_count' => $count,
]);
$syncedCount = 0;
$skippedCount = 0;
$userIds = [];
// 批量写入消费记录
foreach ($data as $record) {
try {
// 数据验证
if (!$this->validateRecord($record)) {
$skippedCount++;
continue;
}
// 确保有 record_id
if (empty($record['record_id'])) {
$record['record_id'] = (string)Uuid::uuid4();
}
// 写入消费记录(使用 Eloquent Model 方式)
$consumptionRecord = new ConsumptionRecordRepository();
$consumptionRecord->record_id = $record['record_id'] ?? (string)Uuid::uuid4();
$consumptionRecord->user_id = $record['user_id'];
$consumptionRecord->consume_time = new \DateTimeImmutable($record['consume_time']);
$consumptionRecord->amount = (float)($record['amount'] ?? 0);
$consumptionRecord->actual_amount = (float)($record['actual_amount'] ?? $record['amount'] ?? 0);
$consumptionRecord->currency = $record['currency'] ?? 'CNY';
$consumptionRecord->store_id = $record['store_id'] ?? '';
$consumptionRecord->status = $record['status'] ?? 0;
$consumptionRecord->create_time = new \DateTimeImmutable('now');
$consumptionRecord->save();
$syncedCount++;
// 收集用户ID用于后续批量更新统计
$userId = $record['user_id'] ?? null;
if ($userId) {
$userIds[] = $userId;
}
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'DataSyncService',
'action' => 'syncData',
'source_id' => $sourceId,
'record' => $record,
]);
$skippedCount++;
}
}
// 批量更新用户统计(去重)
$uniqueUserIds = array_unique($userIds);
foreach ($uniqueUserIds as $userId) {
try {
$this->updateUserStatistics($userId);
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'DataSyncService',
'action' => 'updateUserStatistics',
'user_id' => $userId,
]);
}
}
// 触发标签计算(为每个用户推送消息)
$tagCalculationCount = 0;
foreach ($uniqueUserIds as $userId) {
try {
$message = [
'user_id' => $userId,
'tag_ids' => null, // null 表示计算所有 real_time 标签
'trigger_type' => 'data_sync',
'source_id' => $sourceId,
'timestamp' => time(),
];
if (QueueService::pushTagCalculation($message)) {
$tagCalculationCount++;
}
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'DataSyncService',
'action' => 'triggerTagCalculation',
'user_id' => $userId,
]);
}
}
$result = [
'success' => true,
'synced_count' => $syncedCount,
'skipped_count' => $skippedCount,
'user_count' => count($uniqueUserIds),
'tag_calculation_triggered' => $tagCalculationCount,
];
LoggerHelper::logBusiness('data_sync_service_completed', array_merge([
'source_id' => $sourceId,
], $result));
return $result;
}
/**
* 验证记录
*
* @param array<string, mixed> $record 记录数据
* @return bool 是否通过验证
*/
private function validateRecord(array $record): bool
{
// 必填字段验证
$requiredFields = ['user_id', 'amount', 'consume_time'];
foreach ($requiredFields as $field) {
if (!isset($record[$field]) || $record[$field] === null || $record[$field] === '') {
return false;
}
}
// 金额验证
$amount = (float)($record['amount'] ?? 0);
if ($amount <= 0) {
return false;
}
// 时间格式验证
$consumeTime = $record['consume_time'] ?? '';
if (strtotime($consumeTime) === false) {
return false;
}
return true;
}
/**
* 更新用户统计
*
* @param string $userId 用户ID
* @return void
*/
private function updateUserStatistics(string $userId): void
{
// 获取用户的所有消费记录(用于重新计算统计)
// 这里简化处理,只更新最近的数据
// 实际场景中,可以增量更新或全量重新计算
// 查询用户最近的消费记录(使用 Eloquent 查询)
$records = ConsumptionRecordRepository::where('user_id', $userId)
->orderBy('consume_time', 'desc')
->limit(1000)
->get()
->toArray();
if (empty($records)) {
return;
}
// 计算统计值
$totalAmount = 0;
$totalCount = count($records);
$lastConsumeTime = null;
foreach ($records as $record) {
$amount = (float)($record['amount'] ?? 0);
$totalAmount += $amount;
$consumeTime = $record['consume_time'] ?? null;
if ($consumeTime) {
$time = strtotime($consumeTime);
if ($time && ($lastConsumeTime === null || $time > $lastConsumeTime)) {
$lastConsumeTime = $time;
}
}
}
// 更新用户档案(使用 increaseStats 方法,但这里需要全量更新)
// 简化处理:直接更新统计字段
$user = $this->userProfileRepository->findByUserId($userId);
if ($user) {
$user->total_amount = $totalAmount;
$user->total_count = $totalCount;
if ($lastConsumeTime) {
$user->last_consume_time = new \DateTimeImmutable('@' . $lastConsumeTime);
}
$user->save();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,312 @@
<?php
namespace app\service;
use app\repository\UserProfileRepository;
use app\repository\UserPhoneRelationRepository;
use app\service\UserPhoneService;
use app\utils\EncryptionHelper;
use app\utils\IdCardHelper;
use app\utils\LoggerHelper;
use Ramsey\Uuid\Uuid as UuidGenerator;
/**
* 身份解析服务
*
* 职责:
* - 根据手机号解析person_iduser_id
* - 如果找不到,创建临时人
* - 支持身份证绑定,将临时人转为正式人
* - 处理多手机号到同一人的映射
*/
class IdentifierService
{
public function __construct(
protected UserProfileRepository $userProfileRepository,
protected UserPhoneService $userPhoneService
) {
}
/**
* 根据手机号解析用户IDperson_id
*
* 流程:
* 1. 查询手机号关联表找到指定时间点有效的user_id
* 2. 如果找不到,创建临时人并建立关联
*
* @param string $phoneNumber 手机号
* @param \DateTimeInterface|null $atTime 查询时间点(默认为当前时间)
* @return string user_idperson_id
*/
public function resolvePersonIdByPhone(string $phoneNumber, ?\DateTimeInterface $atTime = null): string
{
// 检查手机号是否为空
$trimmedPhone = trim($phoneNumber);
if (empty($trimmedPhone)) {
// 如果手机号为空,创建一个没有手机号的临时用户
$userId = $this->createTemporaryPerson(null, $atTime);
LoggerHelper::logBusiness('temporary_person_created_no_phone', [
'user_id' => $userId,
'note' => '手机号为空,创建无手机号的临时用户',
]);
return $userId;
}
// 1. 先查询手机号关联表(使用指定的时间点)
$userId = $this->userPhoneService->findUserByPhone($trimmedPhone, $atTime);
if ($userId !== null) {
LoggerHelper::logBusiness('person_resolved_by_phone', [
'phone_number' => $trimmedPhone,
'user_id' => $userId,
'source' => 'existing_relation',
'at_time' => $atTime ? $atTime->format('Y-m-d H:i:s') : null,
]);
return $userId;
}
// 2. 如果找不到创建临时人使用atTime作为生效时间
$userId = $this->createTemporaryPerson($trimmedPhone, $atTime);
LoggerHelper::logBusiness('temporary_person_created', [
'phone_number' => $trimmedPhone,
'user_id' => $userId,
'effective_time' => $atTime ? $atTime->format('Y-m-d H:i:s') : null,
]);
return $userId;
}
/**
* 根据身份证解析用户IDperson_id
*
* @param string $idCard 身份证号
* @return string|null user_idperson_id如果不存在返回null
*/
public function resolvePersonIdByIdCard(string $idCard): ?string
{
$idCardHash = EncryptionHelper::hash($idCard);
$user = $this->userProfileRepository->findByIdCardHash($idCardHash);
if ($user) {
LoggerHelper::logBusiness('person_resolved_by_id_card', [
'id_card_hash' => $idCardHash,
'user_id' => $user->user_id,
]);
return $user->user_id;
}
return null;
}
/**
* 绑定身份证到用户(将临时人转为正式人,或创建正式人)
*
* @param string $userId 用户ID
* @param string $idCard 身份证号
* @return bool 是否成功
* @throws \InvalidArgumentException
*/
public function bindIdCardToPerson(string $userId, string $idCard): bool
{
$idCardHash = EncryptionHelper::hash($idCard);
$idCardEncrypted = EncryptionHelper::encrypt($idCard);
// 检查该身份证是否已被其他用户使用
$existingUser = $this->userProfileRepository->findByIdCardHash($idCardHash);
if ($existingUser && $existingUser->user_id !== $userId) {
throw new \InvalidArgumentException("身份证号已被其他用户使用user_id: {$existingUser->user_id}");
}
// 更新用户信息
$user = $this->userProfileRepository->findByUserId($userId);
if (!$user) {
throw new \InvalidArgumentException("用户不存在: {$userId}");
}
// 如果用户已经是正式人且身份证匹配,无需更新
if (!$user->is_temporary && $user->id_card_hash === $idCardHash) {
return true;
}
// 更新身份证信息并标记为正式人
$user->id_card_hash = $idCardHash;
$user->id_card_encrypted = $idCardEncrypted;
$user->id_card_type = '身份证';
$user->is_temporary = false;
// 从身份证号中自动提取基础信息(如果字段为空才更新)
$idCardInfo = IdCardHelper::extractInfo($idCard);
if ($idCardInfo['birthday'] !== null && $user->birthday === null) {
$user->birthday = $idCardInfo['birthday'];
}
// 只有当性别解析成功且当前值为 null 时才更新0 也被认为是未设置)
if ($idCardInfo['gender'] > 0 && ($user->gender === null || $user->gender === 0)) {
$user->gender = $idCardInfo['gender'];
}
$user->update_time = new \DateTimeImmutable('now');
$user->save();
LoggerHelper::logBusiness('id_card_bound_to_person', [
'user_id' => $userId,
'id_card_hash' => $idCardHash,
'was_temporary' => $user->is_temporary ?? true,
]);
return true;
}
/**
* 创建临时人
*
* @param string|null $phoneNumber 手机号(可选,用于建立关联)
* @param \DateTimeInterface|null $effectiveTime 生效时间(用于手机关联,默认当前时间)
* @return string user_id
*/
private function createTemporaryPerson(?string $phoneNumber = null, ?\DateTimeInterface $effectiveTime = null): string
{
$now = new \DateTimeImmutable('now');
$userId = UuidGenerator::uuid4()->toString();
// 创建临时人记录
$user = new UserProfileRepository();
$user->user_id = $userId;
$user->is_temporary = true;
$user->status = 0;
$user->total_amount = 0;
$user->total_count = 0;
$user->create_time = $now;
$user->update_time = $now;
$user->save();
// 如果有手机号建立关联使用effectiveTime作为生效时间
// 检查手机号不为空null 或空字符串都跳过)
if ($phoneNumber !== null && trim($phoneNumber) !== '') {
try {
$trimmedPhone = trim($phoneNumber);
$this->userPhoneService->addPhoneToUser($userId, $trimmedPhone, [
'source' => 'auto_created',
'type' => 'personal',
'effective_time' => $effectiveTime ?? $now,
]);
LoggerHelper::logBusiness('phone_relation_created_success', [
'user_id' => $userId,
'phone_number' => $trimmedPhone,
'effective_time' => ($effectiveTime ?? $now)->format('Y-m-d H:i:s'),
]);
} catch (\Throwable $e) {
// 手机号关联失败不影响用户创建,只记录详细的错误日志
LoggerHelper::logError($e, [
'component' => 'IdentifierService',
'action' => 'createTemporaryPerson',
'user_id' => $userId,
'phone_number' => $phoneNumber,
'phone_number_length' => strlen($phoneNumber),
'error_message' => $e->getMessage(),
'error_type' => get_class($e),
]);
// 同时记录业务日志,便于排查
LoggerHelper::logBusiness('phone_relation_create_failed', [
'user_id' => $userId,
'phone_number' => $phoneNumber,
'error_message' => $e->getMessage(),
'note' => '用户已创建,但手机关联失败',
]);
}
} elseif ($phoneNumber !== null && trim($phoneNumber) === '') {
// 手机号是空字符串,记录日志
LoggerHelper::logBusiness('phone_relation_skipped_empty', [
'user_id' => $userId,
'note' => '手机号为空字符串,跳过关联创建',
]);
}
return $userId;
}
/**
* 根据手机号或身份证解析用户ID
*
* 优先级:身份证 > 手机号
*
* @param string|null $phoneNumber 手机号
* @param string|null $idCard 身份证号
* @param \DateTimeInterface|null $atTime 查询时间点(用于手机号查询,默认为当前时间)
* @return string user_id
*/
public function resolvePersonId(?string $phoneNumber = null, ?string $idCard = null, ?\DateTimeInterface $atTime = null): string
{
$atTime = $atTime ?? new \DateTimeImmutable('now');
// 优先使用身份证
if ($idCard !== null && !empty($idCard)) {
$userId = $this->resolvePersonIdByIdCard($idCard);
if ($userId !== null) {
// 如果身份证存在,但提供了手机号,确保手机号关联到该用户
if ($phoneNumber !== null && !empty($phoneNumber)) {
// 在atTime时间点查询手机号关联
$existingUserId = $this->userPhoneService->findUserByPhone($phoneNumber, $atTime);
if ($existingUserId === null) {
// 手机号未关联建立关联使用atTime作为生效时间
$this->userPhoneService->addPhoneToUser($userId, $phoneNumber, [
'source' => 'id_card_resolved',
'type' => 'personal',
'effective_time' => $atTime,
]);
} elseif ($existingUserId !== $userId) {
// 手机号已关联到其他用户需要合并由PersonMergeService处理
LoggerHelper::logBusiness('phone_bound_to_different_person', [
'phone_number' => $phoneNumber,
'existing_user_id' => $existingUserId,
'id_card_user_id' => $userId,
'at_time' => $atTime->format('Y-m-d H:i:s'),
]);
}
}
return $userId;
} else {
// 身份证不存在,但有身份证信息,创建一个临时用户并绑定身份证(使其成为正式用户)
$userId = $this->createTemporaryPerson($phoneNumber, $atTime);
try {
$this->bindIdCardToPerson($userId, $idCard);
} catch (\Throwable $e) {
// 绑定失败不影响返回user_id
LoggerHelper::logError($e, [
'component' => 'IdentifierService',
'action' => 'resolvePersonId',
'user_id' => $userId,
]);
}
return $userId;
}
}
// 使用手机号传入atTime
if ($phoneNumber !== null && !empty($phoneNumber)) {
$userId = $this->resolvePersonIdByPhone($phoneNumber, $atTime);
// 如果同时提供了身份证,绑定身份证
if ($idCard !== null && !empty($idCard)) {
try {
$this->bindIdCardToPerson($userId, $idCard);
} catch (\Throwable $e) {
// 绑定失败不影响返回user_id
LoggerHelper::logError($e, [
'component' => 'IdentifierService',
'action' => 'resolvePersonId',
'user_id' => $userId,
]);
}
}
return $userId;
}
// 都没有提供,创建临时人
return $this->createTemporaryPerson(null, $atTime);
}
}

View File

@@ -0,0 +1,497 @@
<?php
namespace app\service;
use app\repository\UserProfileRepository;
use app\repository\UserTagRepository;
use app\repository\UserPhoneRelationRepository;
use app\repository\ConsumptionRecordRepository;
use app\service\TagService;
use app\service\UserPhoneService;
use app\utils\QueueService;
use app\utils\LoggerHelper;
/**
* 身份合并服务
*
* 职责:
* - 合并临时人到正式人
* - 合并多个用户到同一人(基于身份证)
* - 合并标签、统计数据、手机号关联等
*/
class PersonMergeService
{
public function __construct(
protected UserProfileRepository $userProfileRepository,
protected UserTagRepository $userTagRepository,
protected UserPhoneService $userPhoneService,
protected TagService $tagService
) {
}
/**
* 合并临时人到正式人
*
* 场景:手机号发现了对应的身份证号
*
* @param string $tempUserId 临时人user_id
* @param string $idCard 身份证号
* @return string 正式人的user_id
* @throws \InvalidArgumentException
*/
public function mergeTemporaryToFormal(string $tempUserId, string $idCard): string
{
$tempUser = $this->userProfileRepository->findByUserId($tempUserId);
if (!$tempUser) {
throw new \InvalidArgumentException("临时人不存在: {$tempUserId}");
}
if (!$tempUser->is_temporary) {
throw new \InvalidArgumentException("用户不是临时人: {$tempUserId}");
}
$idCardHash = \app\utils\EncryptionHelper::hash($idCard);
// 查找该身份证是否已有正式人
$formalUser = $this->userProfileRepository->findByIdCardHash($idCardHash);
if ($formalUser) {
// 情况1身份证已存在合并临时人到正式人
if ($formalUser->user_id === $tempUserId) {
// 已经是同一个人,只需标记为正式人(传入原始身份证号以提取信息)
$this->userProfileRepository->markAsFormal($tempUserId, $idCardHash, \app\utils\EncryptionHelper::encrypt($idCard), $idCard);
return $tempUserId;
}
// 合并到已存在的正式人
$this->mergeUsers($tempUserId, $formalUser->user_id);
return $formalUser->user_id;
} else {
// 情况2身份证不存在将临时人转为正式人传入原始身份证号以提取信息
$this->userProfileRepository->markAsFormal($tempUserId, $idCardHash, \app\utils\EncryptionHelper::encrypt($idCard), $idCard);
$tempUser->id_card_type = '身份证';
$tempUser->save();
LoggerHelper::logBusiness('temporary_person_converted_to_formal', [
'user_id' => $tempUserId,
'id_card_hash' => $idCardHash,
]);
// 重新计算标签
$this->recalculateTags($tempUserId);
return $tempUserId;
}
}
/**
* 合并两个用户将sourceUserId合并到targetUserId
*
* @param string $sourceUserId 源用户ID将被合并的用户
* @param string $targetUserId 目标用户ID保留的用户
* @return bool 是否成功
*/
public function mergeUsers(string $sourceUserId, string $targetUserId): bool
{
if ($sourceUserId === $targetUserId) {
return true;
}
$sourceUser = $this->userProfileRepository->findByUserId($sourceUserId);
$targetUser = $this->userProfileRepository->findByUserId($targetUserId);
if (!$sourceUser || !$targetUser) {
throw new \InvalidArgumentException("用户不存在: source={$sourceUserId}, target={$targetUserId}");
}
LoggerHelper::logBusiness('person_merge_started', [
'source_user_id' => $sourceUserId,
'target_user_id' => $targetUserId,
]);
try {
// 1. 合并统计数据
$this->mergeStatistics($sourceUser, $targetUser);
// 2. 合并手机号关联
$this->mergePhoneRelations($sourceUserId, $targetUserId);
// 3. 合并标签
$this->mergeTags($sourceUserId, $targetUserId);
// 4. 合并消费记录更新user_id
$this->mergeConsumptionRecords($sourceUserId, $targetUserId);
// 5. 记录合并历史
$this->recordMergeHistory($sourceUserId, $targetUserId);
// 6. 标记源用户为已合并
$sourceUser->status = 1; // 标记为已删除/已合并
$sourceUser->merged_from_user_id = $targetUserId; // 记录合并到的目标用户ID
$sourceUser->update_time = new \DateTimeImmutable('now');
$sourceUser->save();
// 7. 更新目标用户的标签更新时间
$targetUser->tags_update_time = new \DateTimeImmutable('now');
$targetUser->update_time = new \DateTimeImmutable('now');
$targetUser->save();
LoggerHelper::logBusiness('person_merge_completed', [
'source_user_id' => $sourceUserId,
'target_user_id' => $targetUserId,
]);
// 8. 重新计算目标用户的标签
$this->recalculateTags($targetUserId);
return true;
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'PersonMergeService',
'action' => 'mergeUsers',
'source_user_id' => $sourceUserId,
'target_user_id' => $targetUserId,
]);
throw $e;
}
}
/**
* 合并统计数据
*
* @param UserProfileRepository $sourceUser
* @param UserProfileRepository $targetUser
*/
private function mergeStatistics(UserProfileRepository $sourceUser, UserProfileRepository $targetUser): void
{
// 合并总金额和总次数
$targetUser->total_amount = (float)($targetUser->total_amount ?? 0) + (float)($sourceUser->total_amount ?? 0);
$targetUser->total_count = (int)($targetUser->total_count ?? 0) + (int)($sourceUser->total_count ?? 0);
// 取更晚的最后消费时间
if ($sourceUser->last_consume_time &&
(!$targetUser->last_consume_time || $sourceUser->last_consume_time > $targetUser->last_consume_time)) {
$targetUser->last_consume_time = $sourceUser->last_consume_time;
}
$targetUser->save();
}
/**
* 合并手机号关联
*
* @param string $sourceUserId
* @param string $targetUserId
*/
private function mergePhoneRelations(string $sourceUserId, string $targetUserId): void
{
// 获取源用户的所有手机号
$sourcePhones = $this->userPhoneService->getUserPhoneNumbers($sourceUserId, false);
foreach ($sourcePhones as $phoneNumber) {
try {
// 将手机号关联到目标用户
$this->userPhoneService->addPhoneToUser($targetUserId, $phoneNumber, [
'source' => 'person_merge',
'type' => 'personal',
]);
// 失效源用户的手机号关联
$this->userPhoneService->removePhoneFromUser($sourceUserId, $phoneNumber);
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'PersonMergeService',
'action' => 'mergePhoneRelations',
'source_user_id' => $sourceUserId,
'target_user_id' => $targetUserId,
'phone_number' => $phoneNumber,
]);
}
}
}
/**
* 合并标签
*
* 智能合并策略:
* 1. 如果目标用户没有该标签,直接复制源用户的标签
* 2. 如果目标用户已有该标签,根据标签类型和定义决定合并策略:
* - 数值型标签number根据标签定义的聚合方式sum/max/min/avg合并
* - 布尔型标签boolean取 OR任一为true则为true
* - 字符串型标签string保留目标用户的值不覆盖
* - 枚举型标签:保留目标用户的值(不覆盖)
* 3. 置信度取两者中的较高值
*
* @param string $sourceUserId
* @param string $targetUserId
*/
private function mergeTags(string $sourceUserId, string $targetUserId): void
{
$sourceTags = $this->userTagRepository->newQuery()
->where('user_id', $sourceUserId)
->get();
// 获取标签定义,用于判断合并策略
$tagDefinitionRepo = new \app\repository\TagDefinitionRepository();
foreach ($sourceTags as $sourceTag) {
// 检查目标用户是否已有该标签
$targetTag = $this->userTagRepository->newQuery()
->where('user_id', $targetUserId)
->where('tag_id', $sourceTag->tag_id)
->first();
if (!$targetTag) {
// 目标用户没有该标签,复制源用户的标签
$newTag = new UserTagRepository();
$newTag->user_id = $targetUserId;
$newTag->tag_id = $sourceTag->tag_id;
$newTag->tag_value = $sourceTag->tag_value;
$newTag->tag_value_type = $sourceTag->tag_value_type;
$newTag->confidence = $sourceTag->confidence;
$newTag->effective_time = $sourceTag->effective_time;
$newTag->expire_time = $sourceTag->expire_time;
$newTag->create_time = new \DateTimeImmutable('now');
$newTag->update_time = new \DateTimeImmutable('now');
$newTag->save();
} else {
// 目标用户已有标签,根据类型智能合并
$mergedValue = $this->mergeTagValue(
$sourceTag,
$targetTag,
$tagDefinitionRepo->newQuery()->where('tag_id', $sourceTag->tag_id)->first()
);
if ($mergedValue !== null) {
$targetTag->tag_value = $mergedValue;
$targetTag->confidence = max((float)$sourceTag->confidence, (float)$targetTag->confidence);
$targetTag->update_time = new \DateTimeImmutable('now');
$targetTag->save();
}
}
}
// 删除源用户的标签
$this->userTagRepository->newQuery()
->where('user_id', $sourceUserId)
->delete();
}
/**
* 合并标签值
*
* @param UserTagRepository $sourceTag 源标签
* @param UserTagRepository $targetTag 目标标签
* @param \app\repository\TagDefinitionRepository|null $tagDef 标签定义(可选)
* @return string|null 合并后的标签值如果不需要更新返回null
*/
private function mergeTagValue(
UserTagRepository $sourceTag,
UserTagRepository $targetTag,
?\app\repository\TagDefinitionRepository $tagDef = null
): ?string {
$sourceValue = $sourceTag->tag_value;
$targetValue = $targetTag->tag_value;
$sourceType = $sourceTag->tag_value_type;
$targetType = $targetTag->tag_value_type;
// 如果类型不一致,保留目标值
if ($sourceType !== $targetType) {
return null;
}
// 根据类型合并
switch ($targetType) {
case 'number':
// 数值型:根据标签定义的聚合方式合并
$aggregation = null;
if ($tagDef && isset($tagDef->rule_config)) {
$ruleConfig = is_string($tagDef->rule_config)
? json_decode($tagDef->rule_config, true)
: $tagDef->rule_config;
$aggregation = $ruleConfig['aggregation'] ?? 'sum';
} else {
$aggregation = 'sum'; // 默认累加
}
$sourceNum = (float)$sourceValue;
$targetNum = (float)$targetValue;
return match($aggregation) {
'sum' => (string)($sourceNum + $targetNum),
'max' => (string)max($sourceNum, $targetNum),
'min' => (string)min($sourceNum, $targetNum),
'avg' => (string)(($sourceNum + $targetNum) / 2),
default => (string)($sourceNum + $targetNum), // 默认累加
};
case 'boolean':
// 布尔型:取 OR任一为true则为true
$sourceBool = $sourceValue === 'true' || $sourceValue === '1';
$targetBool = $targetValue === 'true' || $targetValue === '1';
return ($sourceBool || $targetBool) ? 'true' : 'false';
case 'string':
case 'json':
default:
// 字符串型、JSON型等保留目标值不覆盖
return null;
}
}
/**
* 合并消费记录
*
* @param string $sourceUserId
* @param string $targetUserId
*/
private function mergeConsumptionRecords(string $sourceUserId, string $targetUserId): void
{
// 更新消费记录的user_id
ConsumptionRecordRepository::where('user_id', $sourceUserId)
->update(['user_id' => $targetUserId]);
}
/**
* 重新计算用户标签
*
* @param string $userId
*/
private function recalculateTags(string $userId): void
{
try {
// 异步触发标签计算
QueueService::pushTagCalculation([
'user_id' => $userId,
'tag_ids' => null, // 计算所有标签
'trigger_type' => 'person_merge',
'timestamp' => time(),
]);
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'PersonMergeService',
'action' => 'recalculateTags',
'user_id' => $userId,
]);
}
}
/**
* 根据手机号发现身份证后,合并相关用户
*
* 这是场景4的实现如果某个手机号发现了对应的身份证号
* 查询该身份下是否有标签,如果有就会将对应的这个身份证号的所有标签重新计算同步。
*
* @param string $phoneNumber 手机号
* @param string $idCard 身份证号
* @return string 正式人的user_id
*/
public function mergePhoneToIdCard(string $phoneNumber, string $idCard): string
{
// 1. 查找手机号对应的用户
$phoneUserId = $this->userPhoneService->findUserByPhone($phoneNumber);
// 2. 查找身份证对应的用户
$idCardHash = \app\utils\EncryptionHelper::hash($idCard);
$idCardUser = $this->userProfileRepository->findByIdCardHash($idCardHash);
if ($idCardUser && $phoneUserId && $idCardUser->user_id === $phoneUserId) {
// 已经是同一个人,只需确保是正式人(传入原始身份证号以提取信息)
if ($idCardUser->is_temporary) {
$this->userProfileRepository->markAsFormal($phoneUserId, $idCardHash, \app\utils\EncryptionHelper::encrypt($idCard), $idCard);
}
$this->recalculateTags($phoneUserId);
return $phoneUserId;
}
if ($idCardUser && $phoneUserId && $idCardUser->user_id !== $phoneUserId) {
// 身份证和手机号对应不同用户,需要合并
$this->mergeUsers($phoneUserId, $idCardUser->user_id);
$this->recalculateTags($idCardUser->user_id);
return $idCardUser->user_id;
}
if ($idCardUser && !$phoneUserId) {
// 身份证存在,但手机号未关联,建立关联
$this->userPhoneService->addPhoneToUser($idCardUser->user_id, $phoneNumber, [
'source' => 'id_card_discovered',
'type' => 'personal',
]);
$this->recalculateTags($idCardUser->user_id);
return $idCardUser->user_id;
}
if (!$idCardUser && $phoneUserId) {
// 手机号存在,但身份证不存在,将临时人转为正式人
return $this->mergeTemporaryToFormal($phoneUserId, $idCard);
}
// 都不存在,创建正式人
$userId = \Ramsey\Uuid\Uuid::uuid4()->toString();
$now = new \DateTimeImmutable('now');
// 从身份证号中自动提取基础信息
$idCardInfo = \app\utils\IdCardHelper::extractInfo($idCard);
$user = new UserProfileRepository();
$user->user_id = $userId;
$user->id_card_hash = $idCardHash;
$user->id_card_encrypted = \app\utils\EncryptionHelper::encrypt($idCard);
$user->id_card_type = '身份证';
$user->is_temporary = false;
$user->status = 0;
$user->total_amount = 0;
$user->total_count = 0;
$user->birthday = $idCardInfo['birthday']; // 可能为 null
$user->gender = $idCardInfo['gender'] > 0 ? $idCardInfo['gender'] : null; // 解析失败则为 null
$user->create_time = $now;
$user->update_time = $now;
$user->save();
$this->userPhoneService->addPhoneToUser($userId, $phoneNumber, [
'source' => 'new_created',
'type' => 'personal',
]);
return $userId;
}
/**
* 记录合并历史
*
* @param string $sourceUserId 源用户ID
* @param string $targetUserId 目标用户ID
*/
private function recordMergeHistory(string $sourceUserId, string $targetUserId): void
{
try {
$sourceUser = $this->userProfileRepository->findByUserId($sourceUserId);
$targetUser = $this->userProfileRepository->findByUserId($targetUserId);
if (!$sourceUser || !$targetUser) {
return;
}
// 记录合并信息到日志(可以扩展为独立的合并历史表)
LoggerHelper::logBusiness('person_merge_history', [
'source_user_id' => $sourceUserId,
'target_user_id' => $targetUserId,
'source_is_temporary' => $sourceUser->is_temporary ?? true,
'target_is_temporary' => $targetUser->is_temporary ?? false,
'source_id_card_hash' => $sourceUser->id_card_hash ?? null,
'target_id_card_hash' => $targetUser->id_card_hash ?? null,
'merge_time' => date('Y-m-d H:i:s'),
]);
} catch (\Throwable $e) {
// 记录历史失败不影响合并流程
LoggerHelper::logError($e, [
'component' => 'PersonMergeService',
'action' => 'recordMergeHistory',
'source_user_id' => $sourceUserId,
'target_user_id' => $targetUserId,
]);
}
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace app\service;
use app\repository\StoreRepository;
use app\utils\LoggerHelper;
use Ramsey\Uuid\Uuid as UuidGenerator;
/**
* 门店服务
*
* 职责:
* - 创建门店
* - 查询门店信息
* - 根据门店名称获取或创建门店ID
*/
class StoreService
{
public function __construct(
protected StoreRepository $storeRepository
) {
}
/**
* 根据门店名称获取或创建门店ID
*
* @param string $storeName 门店名称
* @param string|null $source 数据源标识(用于生成默认门店编码)
* @param array<string, mixed> $extraData 额外的门店信息
* @return string 门店ID
*/
public function getOrCreateStoreByName(
string $storeName,
?string $source = null,
array $extraData = []
): string {
// 1. 先查找是否已存在同名门店(正常状态)
$existingStore = $this->storeRepository->findByStoreName($storeName);
if ($existingStore) {
LoggerHelper::logBusiness('store_found_by_name', [
'store_name' => $storeName,
'store_id' => $existingStore->store_id,
]);
return $existingStore->store_id;
}
// 2. 如果不存在,创建新门店
$storeId = $this->createStore($storeName, $source, $extraData);
LoggerHelper::logBusiness('store_created_by_name', [
'store_name' => $storeName,
'store_id' => $storeId,
'source' => $source,
]);
return $storeId;
}
/**
* 创建门店
*
* @param string $storeName 门店名称
* @param string|null $source 数据源标识
* @param array<string, mixed> $extraData 额外的门店信息
* @return string 门店ID
*/
public function createStore(string $storeName, ?string $source = null, array $extraData = []): string
{
$now = new \DateTimeImmutable('now');
$storeId = UuidGenerator::uuid4()->toString();
// 生成门店编码如果提供了store_code则使用否则自动生成
$storeCode = $extraData['store_code'] ?? $this->generateStoreCode($storeName, $source);
// 检查门店编码是否已存在
$existingStore = $this->storeRepository->findByStoreCode($storeCode);
if ($existingStore) {
// 如果编码已存在,重新生成
$storeCode = $this->generateStoreCode($storeName, $source, true);
}
// 创建门店记录
$store = new StoreRepository();
$store->store_id = $storeId;
$store->store_code = $storeCode;
$store->store_name = $storeName;
$store->store_type = $extraData['store_type'] ?? '线上店'; // 默认线上店
$store->store_level = $extraData['store_level'] ?? null;
$store->industry_id = $extraData['industry_id'] ?? 'default'; // 默认行业ID后续可配置
$store->industry_detail_id = $extraData['industry_detail_id'] ?? null;
$store->store_address = $extraData['store_address'] ?? null;
$store->store_province = $extraData['store_province'] ?? null;
$store->store_city = $extraData['store_city'] ?? null;
$store->store_district = $extraData['store_district'] ?? null;
$store->store_business_area = $extraData['store_business_area'] ?? null;
$store->store_longitude = isset($extraData['store_longitude']) ? (float)$extraData['store_longitude'] : null;
$store->store_latitude = isset($extraData['store_latitude']) ? (float)$extraData['store_latitude'] : null;
$store->store_phone = $extraData['store_phone'] ?? null;
$store->status = 0; // 0-正常
$store->create_time = $now;
$store->update_time = $now;
$store->save();
LoggerHelper::logBusiness('store_created', [
'store_id' => $storeId,
'store_name' => $storeName,
'store_code' => $storeCode,
'store_type' => $store->store_type,
]);
return $storeId;
}
/**
* 生成门店编码
*
* @param string $storeName 门店名称
* @param string|null $source 数据源标识
* @param bool $addTimestamp 是否添加时间戳(用于避免重复)
* @return string 门店编码
*/
private function generateStoreCode(string $storeName, ?string $source = null, bool $addTimestamp = false): string
{
// 清理门店名称,移除特殊字符,保留中英文和数字
$cleanedName = preg_replace('/[^\p{L}\p{N}]/u', '', $storeName);
// 如果名称过长截取前20个字符
if (mb_strlen($cleanedName) > 20) {
$cleanedName = mb_substr($cleanedName, 0, 20);
}
// 如果名称为空,使用默认值
if (empty($cleanedName)) {
$cleanedName = 'STORE';
}
// 生成编码:{source}_{cleaned_name}_{hash}
$hash = substr(md5($storeName . ($source ?? '')), 0, 8);
$code = strtoupper(($source ? $source . '_' : '') . $cleanedName . '_' . $hash);
// 如果需要添加时间戳(用于避免重复)
if ($addTimestamp) {
$code .= '_' . time();
}
return $code;
}
/**
* 根据门店ID获取门店信息
*
* @param string $storeId 门店ID
* @return StoreRepository|null
*/
public function getStoreById(string $storeId): ?StoreRepository
{
return $this->storeRepository->newQuery()
->where('store_id', $storeId)
->first();
}
}

View File

@@ -0,0 +1,226 @@
<?php
namespace app\service;
use app\repository\TagDefinitionRepository;
use Ramsey\Uuid\Uuid as UuidGenerator;
/**
* 标签初始化服务
*
* 用于预置初始标签定义
*/
class TagInitService
{
public function __construct(
protected TagDefinitionRepository $tagDefinitionRepository
) {
}
/**
* 初始化基础标签定义
*
* 创建几个简单的标签示例,用于测试标签计算引擎
*/
public function initBasicTags(): void
{
$now = new \DateTimeImmutable('now');
// 标签1高消费用户总消费金额 >= 5000
$this->createTagIfNotExists([
'tag_id' => UuidGenerator::uuid4()->toString(),
'tag_code' => 'high_consumer',
'tag_name' => '高消费用户',
'category' => '消费能力',
'rule_type' => 'simple',
'rule_config' => [
'rule_type' => 'simple',
'conditions' => [
[
'field' => 'total_amount',
'operator' => '>=',
'value' => 5000,
],
],
'tag_value' => 'high',
'confidence' => 1.0,
],
'update_frequency' => 'real_time',
'priority' => 1,
'description' => '总消费金额大于等于5000的用户',
'status' => 0,
'version' => 1,
'create_time' => $now,
'update_time' => $now,
]);
// 标签2活跃用户消费次数 >= 10
$this->createTagIfNotExists([
'tag_id' => UuidGenerator::uuid4()->toString(),
'tag_code' => 'active_user',
'tag_name' => '活跃用户',
'category' => '活跃度',
'rule_type' => 'simple',
'rule_config' => [
'rule_type' => 'simple',
'conditions' => [
[
'field' => 'total_count',
'operator' => '>=',
'value' => 10,
],
],
'tag_value' => 'active',
'confidence' => 1.0,
],
'update_frequency' => 'real_time',
'priority' => 2,
'description' => '消费次数大于等于10次的用户',
'status' => 0,
'version' => 1,
'create_time' => $now,
'update_time' => $now,
]);
// 标签3中消费用户总消费金额 >= 1000 且 < 5000
$this->createTagIfNotExists([
'tag_id' => UuidGenerator::uuid4()->toString(),
'tag_code' => 'medium_consumer',
'tag_name' => '中消费用户',
'category' => '消费能力',
'rule_type' => 'simple',
'rule_config' => [
'rule_type' => 'simple',
'conditions' => [
[
'field' => 'total_amount',
'operator' => '>=',
'value' => 1000,
],
[
'field' => 'total_amount',
'operator' => '<',
'value' => 5000,
],
],
'tag_value' => 'medium',
'confidence' => 1.0,
],
'update_frequency' => 'real_time',
'priority' => 3,
'description' => '总消费金额在1000-5000之间的用户',
'status' => 0,
'version' => 1,
'create_time' => $now,
'update_time' => $now,
]);
// 标签4低消费用户总消费金额 < 1000
$this->createTagIfNotExists([
'tag_id' => UuidGenerator::uuid4()->toString(),
'tag_code' => 'low_consumer',
'tag_name' => '低消费用户',
'category' => '消费能力',
'rule_type' => 'simple',
'rule_config' => [
'rule_type' => 'simple',
'conditions' => [
[
'field' => 'total_amount',
'operator' => '<',
'value' => 1000,
],
],
'tag_value' => 'low',
'confidence' => 1.0,
],
'update_frequency' => 'real_time',
'priority' => 4,
'description' => '总消费金额小于1000的用户',
'status' => 0,
'version' => 1,
'create_time' => $now,
'update_time' => $now,
]);
// 标签5新用户消费次数 < 3
$this->createTagIfNotExists([
'tag_id' => UuidGenerator::uuid4()->toString(),
'tag_code' => 'new_user',
'tag_name' => '新用户',
'category' => '活跃度',
'rule_type' => 'simple',
'rule_config' => [
'rule_type' => 'simple',
'conditions' => [
[
'field' => 'total_count',
'operator' => '<',
'value' => 3,
],
],
'tag_value' => 'new',
'confidence' => 1.0,
],
'update_frequency' => 'real_time',
'priority' => 5,
'description' => '消费次数小于3次的用户',
'status' => 0,
'version' => 1,
'create_time' => $now,
'update_time' => $now,
]);
// 标签6沉睡用户最后消费时间超过90天
$this->createTagIfNotExists([
'tag_id' => UuidGenerator::uuid4()->toString(),
'tag_code' => 'dormant_user',
'tag_name' => '沉睡用户',
'category' => '活跃度',
'rule_type' => 'simple',
'rule_config' => [
'rule_type' => 'simple',
'conditions' => [
[
'field' => 'last_consume_time',
'operator' => '<',
'value' => time() - 90 * 24 * 3600, // 90天前的时间戳
],
],
'tag_value' => 'dormant',
'confidence' => 1.0,
],
'update_frequency' => 'real_time',
'priority' => 6,
'description' => '最后消费时间超过90天的用户',
'status' => 0,
'version' => 1,
'create_time' => $now,
'update_time' => $now,
]);
}
/**
* 如果标签不存在则创建
*
* @param array<string, mixed> $tagData
*/
private function createTagIfNotExists(array $tagData): void
{
$existing = $this->tagDefinitionRepository->newQuery()
->where('tag_code', $tagData['tag_code'])
->first();
if (!$existing) {
$tag = new TagDefinitionRepository();
foreach ($tagData as $key => $value) {
$tag->$key = $value;
}
$tag->save();
echo "已创建标签: {$tagData['tag_name']} ({$tagData['tag_code']})\n";
} else {
echo "标签已存在: {$tagData['tag_name']} ({$tagData['tag_code']})\n";
}
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace app\service\TagRuleEngine;
/**
* 简单标签规则引擎
*
* 支持基础条件判断,用于计算简单标签(如:总消费金额等级、消费频次等级等)
*/
class SimpleRuleEngine
{
/**
* 计算标签值
*
* @param array<string, mixed> $ruleConfig 规则配置(从 tag_definitions.rule_config 解析)
* @param array<string, mixed> $userData 用户数据(从 user_profile 获取)
* @return array{value: mixed, confidence: float} 返回标签值和置信度
*/
public function calculate(array $ruleConfig, array $userData): array
{
if (!isset($ruleConfig['rule_type']) || $ruleConfig['rule_type'] !== 'simple') {
throw new \InvalidArgumentException('规则类型必须是 simple');
}
if (!isset($ruleConfig['conditions']) || !is_array($ruleConfig['conditions'])) {
throw new \InvalidArgumentException('规则配置中缺少 conditions');
}
// 执行所有条件判断
$allMatch = true;
foreach ($ruleConfig['conditions'] as $condition) {
if (!$this->evaluateCondition($condition, $userData)) {
$allMatch = false;
break;
}
}
// 如果所有条件都满足,返回标签值
if ($allMatch) {
// 简单标签:如果满足条件,标签值为 true 或指定的值
$tagValue = $ruleConfig['tag_value'] ?? true;
$confidence = $ruleConfig['confidence'] ?? 1.0;
return [
'value' => $tagValue,
'confidence' => (float)$confidence,
];
}
// 条件不满足,返回 false
return [
'value' => false,
'confidence' => 0.0,
];
}
/**
* 评估单个条件
*
* @param array<string, mixed> $condition 条件配置:{field, operator, value}
* @param array<string, mixed> $userData 用户数据
* @return bool
*/
private function evaluateCondition(array $condition, array $userData): bool
{
if (!isset($condition['field']) || !isset($condition['operator']) || !isset($condition['value'])) {
throw new \InvalidArgumentException('条件配置不完整:需要 field, operator, value');
}
$field = $condition['field'];
$operator = $condition['operator'];
$expectedValue = $condition['value'];
// 从用户数据中获取字段值
if (!isset($userData[$field])) {
// 字段不存在,根据运算符判断(例如 > 0 时,不存在视为 0
$actualValue = 0;
} else {
$actualValue = $userData[$field];
}
// 根据运算符进行比较
return match ($operator) {
'>' => $actualValue > $expectedValue,
'>=' => $actualValue >= $expectedValue,
'<' => $actualValue < $expectedValue,
'<=' => $actualValue <= $expectedValue,
'=' => $actualValue == $expectedValue,
'!=' => $actualValue != $expectedValue,
'in' => in_array($actualValue, (array)$expectedValue),
'not_in' => !in_array($actualValue, (array)$expectedValue),
default => throw new \InvalidArgumentException("不支持的运算符: {$operator}"),
};
}
}

View File

@@ -0,0 +1,587 @@
<?php
namespace app\service;
use app\repository\TagDefinitionRepository;
use app\repository\UserProfileRepository;
use app\repository\UserTagRepository;
use app\repository\TagHistoryRepository;
use app\service\TagRuleEngine\SimpleRuleEngine;
use app\utils\LoggerHelper;
use Ramsey\Uuid\Uuid as UuidGenerator;
/**
* 标签服务
*
* 职责:
* - 根据用户数据计算标签值
* - 更新用户标签
* - 记录标签变更历史
*/
class TagService
{
public function __construct(
protected TagDefinitionRepository $tagDefinitionRepository,
protected UserProfileRepository $userProfileRepository,
protected UserTagRepository $userTagRepository,
protected TagHistoryRepository $tagHistoryRepository,
protected SimpleRuleEngine $ruleEngine
) {
}
/**
* 为指定用户计算并更新标签
*
* @param string $userId 用户ID
* @param array<string>|null $tagIds 要计算的标签ID列表null 表示计算所有启用且更新频率为 real_time 的标签)
* @return array<string, mixed> 返回更新的标签信息
*/
public function calculateTags(string $userId, ?array $tagIds = null): array
{
// 获取用户数据
$user = $this->userProfileRepository->findByUserId($userId);
if (!$user) {
throw new \InvalidArgumentException("用户不存在: {$userId}");
}
// 准备用户数据(用于规则引擎计算)
$userData = [
'total_amount' => (float)($user->total_amount ?? 0),
'total_count' => (int)($user->total_count ?? 0),
'last_consume_time' => $user->last_consume_time ? $user->last_consume_time->getTimestamp() : 0,
];
// 获取要计算的标签定义
$tagDefinitions = $this->getTagDefinitions($tagIds);
$updatedTags = [];
$now = new \DateTimeImmutable('now');
foreach ($tagDefinitions as $tagDef) {
try {
// 解析规则配置
$ruleConfig = is_string($tagDef->rule_config)
? json_decode($tagDef->rule_config, true)
: $tagDef->rule_config;
if (!$ruleConfig) {
continue;
}
// 根据规则类型选择计算引擎
if ($ruleConfig['rule_type'] === 'simple') {
$result = $this->ruleEngine->calculate($ruleConfig, $userData);
} else {
// 其他规则类型pipeline/custom暂不支持
continue;
}
// 获取旧标签值(用于历史记录)
$oldTag = $this->userTagRepository->newQuery()
->where('user_id', $userId)
->where('tag_id', $tagDef->tag_id)
->first();
$oldValue = $oldTag ? $oldTag->tag_value : null;
// 更新或创建标签
if ($oldTag) {
$oldTag->tag_value = $this->formatTagValue($result['value']);
$oldTag->tag_value_type = $this->getTagValueType($result['value']);
$oldTag->confidence = $result['confidence'];
$oldTag->update_time = $now;
$oldTag->save();
$userTag = $oldTag;
} else {
$userTag = new UserTagRepository();
$userTag->user_id = $userId;
$userTag->tag_id = $tagDef->tag_id;
$userTag->tag_value = $this->formatTagValue($result['value']);
$userTag->tag_value_type = $this->getTagValueType($result['value']);
$userTag->confidence = $result['confidence'];
$userTag->effective_time = $now;
$userTag->create_time = $now;
$userTag->update_time = $now;
$userTag->save();
}
// 记录标签变更历史(仅当值发生变化时)
if ($oldValue !== $userTag->tag_value) {
$this->recordTagHistory($userId, $tagDef->tag_id, $oldValue, $userTag->tag_value, $now);
}
$updatedTags[] = [
'tag_id' => $tagDef->tag_id,
'tag_code' => $tagDef->tag_code,
'tag_name' => $tagDef->tag_name,
'value' => $userTag->tag_value,
'confidence' => $userTag->confidence,
];
// 记录标签计算日志
LoggerHelper::logTagCalculation($userId, $tagDef->tag_id, [
'tag_code' => $tagDef->tag_code,
'value' => $userTag->tag_value,
'confidence' => $userTag->confidence,
]);
} catch (\Throwable $e) {
// 记录错误但继续处理其他标签
LoggerHelper::logError($e, [
'user_id' => $userId,
'tag_id' => $tagDef->tag_id ?? null,
'tag_code' => $tagDef->tag_code ?? null,
]);
}
}
// 更新用户的标签更新时间
$user->tags_update_time = $now;
$user->save();
return $updatedTags;
}
/**
* 获取标签定义列表
*
* @param array<string>|null $tagIds
* @return \Illuminate\Database\Eloquent\Collection
*/
private function getTagDefinitions(?array $tagIds = null)
{
$query = $this->tagDefinitionRepository->newQuery()
->where('status', 0); // 只获取启用的标签
if ($tagIds !== null) {
$query->whereIn('tag_id', $tagIds);
} else {
// 默认只计算实时更新的标签
$query->where('update_frequency', 'real_time');
}
return $query->get();
}
/**
* 格式化标签值
*
* @param mixed $value
* @return string
*/
private function formatTagValue($value): string
{
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_array($value) || is_object($value)) {
return json_encode($value);
}
return (string)$value;
}
/**
* 获取标签值类型
*
* @param mixed $value
* @return string
*/
private function getTagValueType($value): string
{
if (is_bool($value)) {
return 'boolean';
}
if (is_int($value) || is_float($value)) {
return 'number';
}
if (is_array($value) || is_object($value)) {
return 'json';
}
return 'string';
}
/**
* 记录标签变更历史
*
* @param string $userId
* @param string $tagId
* @param mixed $oldValue
* @param string $newValue
* @param \DateTimeInterface $changeTime
*/
private function recordTagHistory(string $userId, string $tagId, $oldValue, string $newValue, \DateTimeInterface $changeTime): void
{
$history = new TagHistoryRepository();
$history->history_id = UuidGenerator::uuid4()->toString();
$history->user_id = $userId;
$history->tag_id = $tagId;
$history->old_value = $oldValue !== null ? (string)$oldValue : null;
$history->new_value = $newValue;
$history->change_reason = 'auto_calculate';
$history->change_time = $changeTime;
$history->operator = 'system';
$history->save();
}
/**
* 根据标签筛选用户
*
* @param array<string, mixed> $conditions 查询条件数组,每个条件包含:
* - tag_code: 标签编码(必填)
* - operator: 操作符(=, !=, >, >=, <, <=, in, not_in必填
* - value: 标签值(必填)
* @param string $logic 多个条件之间的逻辑关系AND 或 OR默认 AND
* @param int $page 页码从1开始
* @param int $pageSize 每页数量
* @param bool $includeUserInfo 是否包含用户基本信息
* @return array<string, mixed> 返回符合条件的用户列表
*/
public function filterUsersByTags(
array $conditions,
string $logic = 'AND',
int $page = 1,
int $pageSize = 20,
bool $includeUserInfo = false
): array {
if (empty($conditions)) {
return [
'users' => [],
'total' => 0,
'page' => $page,
'page_size' => $pageSize,
];
}
// 1. 根据 tag_code 获取 tag_id 列表
$tagCodes = array_column($conditions, 'tag_code');
$tagDefinitions = $this->tagDefinitionRepository->newQuery()
->whereIn('tag_code', $tagCodes)
->get()
->keyBy('tag_code');
$tagIdMap = [];
foreach ($tagDefinitions as $tagDef) {
$tagIdMap[$tagDef->tag_code] = $tagDef->tag_id;
}
// 验证所有 tag_code 都存在
$missingTags = array_diff($tagCodes, array_keys($tagIdMap));
if (!empty($missingTags)) {
throw new \InvalidArgumentException('标签编码不存在: ' . implode(', ', $missingTags));
}
// 2. 根据逻辑类型处理查询
if (strtoupper($logic) === 'OR') {
// OR 逻辑:使用 orWhere查询满足任一条件的用户
$query = $this->userTagRepository->newQuery();
$query->where(function ($q) use ($conditions, $tagIdMap) {
$first = true;
foreach ($conditions as $condition) {
$tagId = $tagIdMap[$condition['tag_code']];
$operator = $condition['operator'] ?? '=';
$value = $condition['value'];
$formattedValue = $this->formatTagValue($value);
if ($first) {
$this->applyTagCondition($q, $tagId, $operator, $formattedValue, $value);
$first = false;
} else {
$q->orWhere(function ($subQ) use ($tagId, $operator, $formattedValue, $value) {
$this->applyTagCondition($subQ, $tagId, $operator, $formattedValue, $value);
});
}
}
});
// 分页查询
$total = $query->count();
$userTags = $query->skip(($page - 1) * $pageSize)
->take($pageSize)
->get();
// 提取 user_id 列表
$userIds = $userTags->pluck('user_id')->unique()->toArray();
} else {
// AND 逻辑:所有条件都必须满足
// 由于每个标签是独立的记录,需要分别查询每个条件,然后取交集
$userIdsSets = [];
foreach ($conditions as $condition) {
$tagId = $tagIdMap[$condition['tag_code']];
$tagDef = $tagDefinitions->get($condition['tag_code']);
$operator = $condition['operator'] ?? '=';
$value = $condition['value'];
$formattedValue = $this->formatTagValue($value);
// 为每个条件单独查询满足条件的 user_id先从标签表查询
$subQuery = $this->userTagRepository->newQuery();
$this->applyTagCondition($subQuery, $tagId, $operator, $formattedValue, $value);
$tagUserIds = $subQuery->pluck('user_id')->unique()->toArray();
// 如果标签表中没有符合条件的记录,且标签定义中有规则,则基于规则从用户档案表筛选
// 这样可以处理用户还没有计算标签的情况
if ($tagDef && $tagDef->rule_type === 'simple') {
$ruleConfig = is_string($tagDef->rule_config)
? json_decode($tagDef->rule_config, true)
: $tagDef->rule_config;
if ($ruleConfig && isset($ruleConfig['tag_value']) && $ruleConfig['tag_value'] === $value) {
// 基于规则从用户档案表筛选
$profileQuery = $this->userProfileRepository->newQuery();
$this->applyRuleToProfileQuery($profileQuery, $ruleConfig);
$profileUserIds = $profileQuery->pluck('user_id')->unique()->toArray();
// 合并标签表和用户档案表的查询结果(去重)
$tagUserIds = array_unique(array_merge($tagUserIds, $profileUserIds));
}
}
$userIdsSets[] = $tagUserIds;
}
// 取交集所有条件都满足的用户ID
if (empty($userIdsSets)) {
$userIds = [];
} else {
$userIds = $userIdsSets[0];
for ($i = 1; $i < count($userIdsSets); $i++) {
$userIds = array_intersect($userIds, $userIdsSets[$i]);
}
}
// 如果没有满足所有条件的用户,直接返回空结果
if (empty($userIds)) {
return [
'users' => [],
'total' => 0,
'page' => $page,
'page_size' => $pageSize,
'total_pages' => 0,
];
}
// 分页处理
$total = count($userIds);
$offset = ($page - 1) * $pageSize;
$userIds = array_slice($userIds, $offset, $pageSize);
}
// 5. 如果需要用户信息,则关联查询
$users = [];
if ($includeUserInfo && !empty($userIds)) {
$userProfiles = $this->userProfileRepository->newQuery()
->whereIn('user_id', $userIds)
->get()
->keyBy('user_id');
foreach ($userIds as $userId) {
$userProfile = $userProfiles->get($userId);
if ($userProfile) {
$users[] = [
'user_id' => $userId,
'name' => $userProfile->name ?? null,
'phone' => $userProfile->phone ?? null,
'total_amount' => $userProfile->total_amount ?? 0,
'total_count' => $userProfile->total_count ?? 0,
'last_consume_time' => $userProfile->last_consume_time ?? null,
];
} else {
$users[] = [
'user_id' => $userId,
];
}
}
} else {
foreach ($userIds as $userId) {
$users[] = ['user_id' => $userId];
}
}
return [
'users' => $users,
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
'total_pages' => (int)ceil($total / $pageSize),
];
}
/**
* 应用标签查询条件到查询构建器
*
* @param \Illuminate\Database\Eloquent\Builder $query 查询构建器
* @param string $tagId 标签ID
* @param string $operator 操作符
* @param string $formattedValue 格式化后的标签值
* @param mixed $originalValue 原始标签值(用于 in/not_in
*/
private function applyTagCondition($query, string $tagId, string $operator, string $formattedValue, $originalValue): void
{
$query->where('tag_id', $tagId);
switch ($operator) {
case '=':
case '==':
$query->where('tag_value', $formattedValue);
break;
case '!=':
case '<>':
$query->where('tag_value', '!=', $formattedValue);
break;
case '>':
$query->where('tag_value', '>', $formattedValue);
break;
case '>=':
$query->where('tag_value', '>=', $formattedValue);
break;
case '<':
$query->where('tag_value', '<', $formattedValue);
break;
case '<=':
$query->where('tag_value', '<=', $formattedValue);
break;
case 'in':
if (!is_array($originalValue)) {
throw new \InvalidArgumentException('in 操作符的值必须是数组');
}
$query->whereIn('tag_value', array_map([$this, 'formatTagValue'], $originalValue));
break;
case 'not_in':
if (!is_array($originalValue)) {
throw new \InvalidArgumentException('not_in 操作符的值必须是数组');
}
$query->whereNotIn('tag_value', array_map([$this, 'formatTagValue'], $originalValue));
break;
default:
throw new \InvalidArgumentException("不支持的操作符: {$operator}");
}
}
/**
* 将标签规则应用到用户档案查询
*
* @param \Illuminate\Database\Eloquent\Builder $query 查询构建器
* @param array<string, mixed> $ruleConfig 规则配置
*/
private function applyRuleToProfileQuery($query, array $ruleConfig): void
{
if (!isset($ruleConfig['conditions']) || !is_array($ruleConfig['conditions'])) {
return;
}
foreach ($ruleConfig['conditions'] as $condition) {
if (!isset($condition['field']) || !isset($condition['operator']) || !isset($condition['value'])) {
continue;
}
$field = $condition['field'];
$operator = $condition['operator'];
$value = $condition['value'];
// 将规则条件转换为用户档案表的查询条件
switch ($operator) {
case '>':
$query->where($field, '>', $value);
break;
case '>=':
$query->where($field, '>=', $value);
break;
case '<':
$query->where($field, '<', $value);
break;
case '<=':
$query->where($field, '<=', $value);
break;
case '=':
case '==':
$query->where($field, $value);
break;
case '!=':
case '<>':
$query->where($field, '!=', $value);
break;
case 'in':
if (is_array($value)) {
$query->whereIn($field, $value);
}
break;
case 'not_in':
if (is_array($value)) {
$query->whereNotIn($field, $value);
}
break;
}
}
// 只查询未删除的用户
$query->where('status', 0);
}
/**
* 获取指定用户的标签列表
*
* @param string $userId
* @return array<int, array<string, mixed>>
*/
public function getUserTags(string $userId): array
{
$userTags = $this->userTagRepository->newQuery()
->where('user_id', $userId)
->get();
$result = [];
foreach ($userTags as $userTag) {
$tagDef = $this->tagDefinitionRepository->newQuery()
->where('tag_id', $userTag->tag_id)
->first();
$result[] = [
'tag_id' => $userTag->tag_id,
'tag_code' => $tagDef ? $tagDef->tag_code : null,
'tag_name' => $tagDef ? $tagDef->tag_name : null,
'category' => $tagDef ? $tagDef->category : null,
'tag_value' => $userTag->tag_value,
'tag_value_type' => $userTag->tag_value_type,
'confidence' => $userTag->confidence,
'effective_time' => $userTag->effective_time,
'expire_time' => $userTag->expire_time,
'update_time' => $userTag->update_time,
];
}
return $result;
}
/**
* 删除用户的指定标签
*
* @param string $userId 用户ID
* @param string $tagId 标签ID
* @return bool 是否删除成功
*/
public function deleteUserTag(string $userId, string $tagId): bool
{
$userTag = $this->userTagRepository->newQuery()
->where('user_id', $userId)
->where('tag_id', $tagId)
->first();
if (!$userTag) {
return false;
}
$oldValue = $userTag->tag_value;
// 删除标签
$userTag->delete();
// 记录历史
$now = new \DateTimeImmutable('now');
$this->recordTagHistory($userId, $tagId, $oldValue, null, $now, 'tag_deleted');
LoggerHelper::logBusiness('tag_deleted', [
'user_id' => $userId,
'tag_id' => $tagId,
]);
return true;
}
}

View File

@@ -0,0 +1,331 @@
<?php
namespace app\service;
use app\repository\TagTaskRepository;
use app\repository\TagTaskExecutionRepository;
use app\repository\UserProfileRepository;
use app\repository\TagDefinitionRepository;
use app\service\TagService;
use app\utils\LoggerHelper;
use app\utils\RedisHelper;
use Ramsey\Uuid\Uuid as UuidGenerator;
/**
* 标签任务执行器
*
* 职责:
* - 执行标签计算任务
* - 批量遍历用户数据打标签
* - 更新任务进度和统计信息
*/
class TagTaskExecutor
{
public function __construct(
protected TagTaskRepository $taskRepository,
protected TagTaskExecutionRepository $executionRepository,
protected UserProfileRepository $userProfileRepository,
protected TagDefinitionRepository $tagDefinitionRepository,
protected TagService $tagService
) {
}
/**
* 执行标签任务
*
* @param string $taskId 任务ID
* @return void
*/
public function execute(string $taskId): void
{
$task = $this->taskRepository->find($taskId);
if (!$task) {
throw new \InvalidArgumentException("任务不存在: {$taskId}");
}
// 创建执行记录
$executionId = UuidGenerator::uuid4()->toString();
$execution = $this->executionRepository->create([
'execution_id' => $executionId,
'task_id' => $taskId,
'started_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000),
'status' => 'running',
'processed_users' => 0,
'success_count' => 0,
'error_count' => 0,
]);
try {
// 获取用户列表
$userIds = $this->getUserIds($task);
$totalUsers = count($userIds);
// 更新任务进度
$this->updateTaskProgress($taskId, [
'total_users' => $totalUsers,
'processed_users' => 0,
'success_count' => 0,
'error_count' => 0,
'percentage' => 0,
]);
// 获取目标标签ID列表
$targetTagIds = $task->target_tag_ids ?? null;
// 批量处理用户
$batchSize = $task->config['batch_size'] ?? 100;
$processedCount = 0;
$successCount = 0;
$errorCount = 0;
foreach (array_chunk($userIds, $batchSize) as $batch) {
// 检查任务状态(是否被暂停或停止)
if (!$this->checkTaskStatus($taskId)) {
LoggerHelper::logBusiness('tag_task_paused_or_stopped', [
'task_id' => $taskId,
'execution_id' => $executionId,
'processed' => $processedCount,
]);
break;
}
// 批量处理用户
foreach ($batch as $userId) {
try {
// 计算用户标签
$this->tagService->calculateTags($userId, $targetTagIds);
$successCount++;
} catch (\Exception $e) {
$errorCount++;
LoggerHelper::logError($e, [
'component' => 'TagTaskExecutor',
'action' => 'calculateTags',
'task_id' => $taskId,
'user_id' => $userId,
]);
// 根据错误处理策略决定是否继续
$errorHandling = $task->config['error_handling'] ?? 'skip';
if ($errorHandling === 'stop') {
throw $e;
}
}
$processedCount++;
// 每处理一定数量更新一次进度
if ($processedCount % 10 === 0) {
$this->updateTaskProgress($taskId, [
'processed_users' => $processedCount,
'success_count' => $successCount,
'error_count' => $errorCount,
'percentage' => $totalUsers > 0 ? round(($processedCount / $totalUsers) * 100, 2) : 0,
]);
// 更新执行记录
$this->executionRepository->where('execution_id', $executionId)->update([
'processed_users' => $processedCount,
'success_count' => $successCount,
'error_count' => $errorCount,
]);
}
}
}
// 更新最终进度
$this->updateTaskProgress($taskId, [
'processed_users' => $processedCount,
'success_count' => $successCount,
'error_count' => $errorCount,
'percentage' => $totalUsers > 0 ? round(($processedCount / $totalUsers) * 100, 2) : 100,
]);
// 更新执行记录为完成
$this->executionRepository->where('execution_id', $executionId)->update([
'status' => 'completed',
'finished_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000),
'processed_users' => $processedCount,
'success_count' => $successCount,
'error_count' => $errorCount,
]);
// 更新任务统计
$this->updateTaskStatistics($taskId, $successCount, $errorCount);
LoggerHelper::logBusiness('tag_task_execution_completed', [
'task_id' => $taskId,
'execution_id' => $executionId,
'total_users' => $totalUsers,
'processed' => $processedCount,
'success' => $successCount,
'error' => $errorCount,
]);
} catch (\Throwable $e) {
// 更新执行记录为失败
$this->executionRepository->where('execution_id', $executionId)->update([
'status' => 'failed',
'finished_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000),
'error_message' => $e->getMessage(),
]);
// 更新任务状态为错误
$this->taskRepository->where('task_id', $taskId)->update([
'status' => 'error',
'progress.status' => 'error',
'progress.last_error' => $e->getMessage(),
]);
LoggerHelper::logError($e, [
'component' => 'TagTaskExecutor',
'action' => 'execute',
'task_id' => $taskId,
'execution_id' => $executionId,
]);
throw $e;
}
}
/**
* 获取用户ID列表
*
* @param mixed $task 任务对象
* @return array<string> 用户ID列表
*/
private function getUserIds($task): array
{
$userScope = $task->user_scope ?? ['type' => 'all'];
$scopeType = $userScope['type'] ?? 'all';
switch ($scopeType) {
case 'all':
// 获取所有用户
$users = $this->userProfileRepository->newQuery()
->where('status', 0) // 只获取正常状态的用户
->get();
return $users->pluck('user_id')->toArray();
case 'list':
// 指定用户列表
return $userScope['user_ids'] ?? [];
case 'filter':
// 按条件筛选
// 这里可以扩展支持更复杂的筛选条件
$query = $this->userProfileRepository->newQuery()
->where('status', 0);
// 可以添加更多筛选条件
if (isset($userScope['conditions']) && is_array($userScope['conditions'])) {
foreach ($userScope['conditions'] as $condition) {
$field = $condition['field'] ?? '';
$operator = $condition['operator'] ?? '=';
$value = $condition['value'] ?? null;
if (empty($field)) {
continue;
}
switch ($operator) {
case '>':
$query->where($field, '>', $value);
break;
case '>=':
$query->where($field, '>=', $value);
break;
case '<':
$query->where($field, '<', $value);
break;
case '<=':
$query->where($field, '<=', $value);
break;
case '=':
$query->where($field, $value);
break;
case '!=':
$query->where($field, '!=', $value);
break;
case 'in':
if (is_array($value)) {
$query->whereIn($field, $value);
}
break;
}
}
}
$users = $query->get();
return $users->pluck('user_id')->toArray();
default:
throw new \InvalidArgumentException("不支持的用户范围类型: {$scopeType}");
}
}
/**
* 更新任务进度
*/
private function updateTaskProgress(string $taskId, array $progress): void
{
$task = $this->taskRepository->find($taskId);
if (!$task) {
return;
}
$currentProgress = $task->progress ?? [];
$currentProgress = array_merge($currentProgress, $progress);
$currentProgress['status'] = 'running';
$this->taskRepository->where('task_id', $taskId)->update([
'progress' => $currentProgress,
'updated_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000),
]);
}
/**
* 更新任务统计
*/
private function updateTaskStatistics(string $taskId, int $successCount, int $errorCount): void
{
$task = $this->taskRepository->find($taskId);
if (!$task) {
return;
}
$statistics = $task->statistics ?? [];
$statistics['total_executions'] = ($statistics['total_executions'] ?? 0) + 1;
$statistics['success_executions'] = ($statistics['success_executions'] ?? 0) + ($errorCount === 0 ? 1 : 0);
$statistics['failed_executions'] = ($statistics['failed_executions'] ?? 0) + ($errorCount > 0 ? 1 : 0);
$statistics['last_run_time'] = new \MongoDB\BSON\UTCDateTime(time() * 1000);
$this->taskRepository->where('task_id', $taskId)->update([
'statistics' => $statistics,
'updated_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000),
]);
}
/**
* 检查任务状态
*/
private function checkTaskStatus(string $taskId): bool
{
// 检查Redis标志
if (RedisHelper::exists("tag_task:{$taskId}:pause")) {
return false;
}
if (RedisHelper::exists("tag_task:{$taskId}:stop")) {
return false;
}
// 检查数据库状态
$task = $this->taskRepository->find($taskId);
if ($task && in_array($task->status, ['paused', 'stopped', 'error'])) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,283 @@
<?php
namespace app\service;
use app\repository\TagTaskRepository;
use app\repository\TagTaskExecutionRepository;
use app\repository\UserProfileRepository;
use app\service\TagService;
use app\utils\LoggerHelper;
use app\utils\RedisHelper;
use Ramsey\Uuid\Uuid as UuidGenerator;
/**
* 标签任务管理服务
*
* 职责:
* - 创建、更新、删除标签任务
* - 管理任务状态(启动、暂停、停止)
* - 执行标签计算任务
* - 追踪任务进度和统计信息
*/
class TagTaskService
{
public function __construct(
protected TagTaskRepository $taskRepository,
protected TagTaskExecutionRepository $executionRepository,
protected UserProfileRepository $userProfileRepository,
protected TagService $tagService
) {
}
/**
* 创建标签任务
*
* @param array<string, mixed> $taskData 任务数据
* @return array<string, mixed> 创建的任务信息
*/
public function createTask(array $taskData): array
{
$taskId = UuidGenerator::uuid4()->toString();
$task = [
'task_id' => $taskId,
'name' => $taskData['name'] ?? '未命名标签任务',
'description' => $taskData['description'] ?? '',
'task_type' => $taskData['task_type'] ?? 'full',
'target_tag_ids' => $taskData['target_tag_ids'] ?? [],
'user_scope' => $taskData['user_scope'] ?? ['type' => 'all'],
'schedule' => $taskData['schedule'] ?? [
'enabled' => false,
'cron' => null,
],
'config' => $taskData['config'] ?? [
'concurrency' => 10,
'batch_size' => 100,
'error_handling' => 'skip',
],
'status' => 'pending',
'progress' => [
'total_users' => 0,
'processed_users' => 0,
'success_count' => 0,
'error_count' => 0,
'percentage' => 0,
],
'statistics' => [
'total_executions' => 0,
'success_executions' => 0,
'failed_executions' => 0,
'last_run_time' => null,
],
'created_by' => $taskData['created_by'] ?? 'system',
'created_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000),
'updated_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000),
];
$this->taskRepository->create($task);
LoggerHelper::logBusiness('tag_task_created', [
'task_id' => $taskId,
'task_name' => $task['name'],
]);
return $task;
}
/**
* 更新任务
*/
public function updateTask(string $taskId, array $taskData): bool
{
$task = $this->taskRepository->find($taskId);
if (!$task) {
throw new \InvalidArgumentException("任务不存在: {$taskId}");
}
if ($task->status === 'running') {
$allowedFields = ['name', 'description', 'schedule'];
$taskData = array_intersect_key($taskData, array_flip($allowedFields));
}
$taskData['updated_at'] = new \MongoDB\BSON\UTCDateTime(time() * 1000);
return $this->taskRepository->where('task_id', $taskId)->update($taskData) > 0;
}
/**
* 删除任务
*/
public function deleteTask(string $taskId): bool
{
$task = $this->taskRepository->find($taskId);
if (!$task) {
throw new \InvalidArgumentException("任务不存在: {$taskId}");
}
if ($task->status === 'running') {
$this->stopTask($taskId);
}
return $this->taskRepository->where('task_id', $taskId)->delete() > 0;
}
/**
* 启动任务
*/
public function startTask(string $taskId): bool
{
$task = $this->taskRepository->find($taskId);
if (!$task) {
throw new \InvalidArgumentException("任务不存在: {$taskId}");
}
if ($task->status === 'running') {
throw new \RuntimeException("任务已在运行中: {$taskId}");
}
$this->taskRepository->where('task_id', $taskId)->update([
'status' => 'running',
'updated_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000),
]);
// 设置Redis标志通知调度器启动任务
RedisHelper::set("tag_task:{$taskId}:start", '1', 3600);
LoggerHelper::logBusiness('tag_task_started', [
'task_id' => $taskId,
]);
return true;
}
/**
* 暂停任务
*/
public function pauseTask(string $taskId): bool
{
$task = $this->taskRepository->find($taskId);
if (!$task) {
throw new \InvalidArgumentException("任务不存在: {$taskId}");
}
if ($task->status !== 'running') {
throw new \RuntimeException("任务未在运行中: {$taskId}");
}
$this->taskRepository->where('task_id', $taskId)->update([
'status' => 'paused',
'updated_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000),
]);
RedisHelper::set("tag_task:{$taskId}:pause", '1', 3600);
return true;
}
/**
* 停止任务
*/
public function stopTask(string $taskId): bool
{
$task = $this->taskRepository->find($taskId);
if (!$task) {
throw new \InvalidArgumentException("任务不存在: {$taskId}");
}
$this->taskRepository->where('task_id', $taskId)->update([
'status' => 'stopped',
'updated_at' => new \MongoDB\BSON\UTCDateTime(time() * 1000),
]);
RedisHelper::set("tag_task:{$taskId}:stop", '1', 3600);
return true;
}
/**
* 获取任务列表
*/
public function getTaskList(array $filters = [], int $page = 1, int $pageSize = 20): array
{
$query = $this->taskRepository->query();
if (isset($filters['status'])) {
$query->where('status', $filters['status']);
}
if (isset($filters['task_type'])) {
$query->where('task_type', $filters['task_type']);
}
if (isset($filters['name'])) {
$query->where('name', 'like', '%' . $filters['name'] . '%');
}
$total = $query->count();
$tasks = $query->orderBy('created_at', 'desc')
->skip(($page - 1) * $pageSize)
->take($pageSize)
->get()
->toArray();
return [
'tasks' => $tasks,
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
'total_pages' => ceil($total / $pageSize),
];
}
/**
* 获取任务详情
*/
public function getTask(string $taskId): ?array
{
$task = $this->taskRepository->find($taskId);
return $task ? $task->toArray() : null;
}
/**
* 获取任务执行记录
*/
public function getExecutions(string $taskId, int $page = 1, int $pageSize = 20): array
{
$query = $this->executionRepository->query()->where('task_id', $taskId);
$total = $query->count();
$executions = $query->orderBy('started_at', 'desc')
->skip(($page - 1) * $pageSize)
->take($pageSize)
->get()
->toArray();
return [
'executions' => $executions,
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
'total_pages' => ceil($total / $pageSize),
];
}
/**
* 执行任务(供调度器调用)
*/
public function executeTask(string $taskId): void
{
$executor = new \app\service\TagTaskExecutor(
$this->taskRepository,
$this->executionRepository,
$this->userProfileRepository,
new \app\repository\TagDefinitionRepository(),
$this->tagService
);
$executor->execute($taskId);
}
}

View File

@@ -0,0 +1,537 @@
<?php
namespace app\service;
use app\repository\UserPhoneRelationRepository;
use app\utils\EncryptionHelper;
use app\utils\LoggerHelper;
use Ramsey\Uuid\Uuid as UuidGenerator;
/**
* 用户手机号服务
*
* 职责:
* - 管理用户与手机号的关联关系
* - 处理手机号的历史记录(支持手机号回收后重新分配)
* - 根据手机号查找当前用户
* - 获取用户的所有手机号
*/
class UserPhoneService
{
public function __construct(
protected UserPhoneRelationRepository $phoneRelationRepository
) {
}
/**
* 为用户添加手机号
*
* @param string $userId 用户ID
* @param string $phoneNumber 手机号
* @param array<string, mixed> $options 可选参数
* - type: 手机号类型personal/work/backup/other
* - is_verified: 是否已验证
* - effective_time: 生效时间(默认当前时间)
* - expire_time: 失效时间默认null表示当前有效
* - source: 来源registration/update/manual/import
* @return string 关联ID
* @throws \InvalidArgumentException
*/
public function addPhoneToUser(string $userId, string $phoneNumber, array $options = []): string
{
\Workerman\Worker::safeEcho("\n");
\Workerman\Worker::safeEcho("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
\Workerman\Worker::safeEcho("[UserPhoneService::addPhoneToUser] 【断点1-方法入口】开始执行\n");
\Workerman\Worker::safeEcho("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
\Workerman\Worker::safeEcho("【断点1】原始传入参数:\n");
\Workerman\Worker::safeEcho(" - userId: {$userId}\n");
\Workerman\Worker::safeEcho(" - phoneNumber: {$phoneNumber}\n");
\Workerman\Worker::safeEcho(" - options: " . json_encode($options, JSON_UNESCAPED_UNICODE) . "\n");
\Workerman\Worker::safeEcho("\n【断点2-参数处理】开始处理参数\n");
$phoneNumber = trim($phoneNumber);
\Workerman\Worker::safeEcho(" - trim后phoneNumber: {$phoneNumber}\n");
// 检查手机号是否为空
if (empty($phoneNumber)) {
\Workerman\Worker::safeEcho("【断点2】❌ 手机号为空,抛出异常\n");
throw new \InvalidArgumentException('手机号不能为空');
}
// 过滤非数字字符
$originalPhone = $phoneNumber;
\Workerman\Worker::safeEcho("\n【断点3-过滤处理】开始过滤非数字字符\n");
$phoneNumber = $this->filterPhoneNumber($phoneNumber);
\Workerman\Worker::safeEcho(" - 原始手机号: {$originalPhone}\n");
\Workerman\Worker::safeEcho(" - 过滤后手机号: {$phoneNumber}\n");
\Workerman\Worker::safeEcho(" - 过滤后长度: " . strlen($phoneNumber) . "\n");
// 检查过滤后是否为空
if (empty($phoneNumber)) {
\Workerman\Worker::safeEcho("【断点3】❌ 手机号过滤后为空,抛出异常\n");
throw new \InvalidArgumentException("手机号过滤后为空: {$originalPhone}");
}
\Workerman\Worker::safeEcho("\n【断点4-格式验证】开始验证手机号格式\n");
// 验证手机号格式(过滤后的手机号)
$isValid = $this->validatePhoneNumber($phoneNumber);
\Workerman\Worker::safeEcho(" - 验证结果: " . ($isValid ? '通过 ✓' : '失败 ✗') . "\n");
if (!$isValid) {
\Workerman\Worker::safeEcho("【断点4】❌ 手机号格式验证失败,抛出异常\n");
\Workerman\Worker::safeEcho(" - 验证规则: /^1[3-9]\\d{9}$/\n");
\Workerman\Worker::safeEcho(" - 实际值: {$phoneNumber}\n");
\Workerman\Worker::safeEcho(" - 长度: " . strlen($phoneNumber) . "\n");
throw new \InvalidArgumentException("手机号格式不正确: {$originalPhone} -> {$phoneNumber} (长度: " . strlen($phoneNumber) . ")");
}
\Workerman\Worker::safeEcho("【断点4】✓ 格式验证通过\n");
\Workerman\Worker::safeEcho("\n【断点5-哈希计算】开始计算手机号哈希\n");
$phoneHash = EncryptionHelper::hash($phoneNumber);
$now = new \DateTimeImmutable('now');
$effectiveTime = $options['effective_time'] ?? $now;
\Workerman\Worker::safeEcho(" - phoneHash: {$phoneHash}\n");
\Workerman\Worker::safeEcho(" - effectiveTime: " . $effectiveTime->format('Y-m-d H:i:s') . "\n");
\Workerman\Worker::safeEcho("\n【断点6-冲突检查】检查是否存在冲突关联\n");
// 检查该手机号在effectiveTime是否已有有效关联
// 使用effectiveTime作为查询时间点查找是否有冲突的关联
$existingActive = $this->phoneRelationRepository->findActiveByPhoneHash($phoneHash, $effectiveTime);
\Workerman\Worker::safeEcho(" - 查询结果: " . ($existingActive ? "找到冲突关联 (user_id: {$existingActive->user_id})" : "无冲突") . "\n");
if ($existingActive && $existingActive->user_id !== $userId) {
\Workerman\Worker::safeEcho("【断点6】⚠ 发现冲突,需要失效旧关联\n");
// 如果手机号在effectiveTime已被其他用户使用需要先失效旧关联
// 过期时间设置为新关联的effectiveTime保证时间连续避免间隙
$existingActive->expire_time = $effectiveTime;
$existingActive->is_active = false;
$existingActive->update_time = $now;
$existingActive->save();
\Workerman\Worker::safeEcho(" - 旧关联已失效expire_time: " . $effectiveTime->format('Y-m-d H:i:s') . "\n");
LoggerHelper::logBusiness('phone_relation_expired_due_to_conflict', [
'phone_number' => $phoneNumber,
'old_user_id' => $existingActive->user_id,
'new_user_id' => $userId,
'expire_time' => $effectiveTime->format('Y-m-d H:i:s'),
'effective_time' => $effectiveTime->format('Y-m-d H:i:s'),
]);
} else {
\Workerman\Worker::safeEcho("【断点6】✓ 无冲突,继续创建新关联\n");
}
\Workerman\Worker::safeEcho("\n【断点7-数据准备】开始准备要保存的数据\n");
try {
\Workerman\Worker::safeEcho(" [7.1] 创建 UserPhoneRelationRepository 对象...\n");
// 创建新关联
$relation = new UserPhoneRelationRepository();
\Workerman\Worker::safeEcho(" [7.1] ✓ 对象创建成功\n");
\Workerman\Worker::safeEcho(" [7.2] 设置 relation_id...\n");
$relation->relation_id = UuidGenerator::uuid4()->toString();
\Workerman\Worker::safeEcho(" [7.2] ✓ relation_id = {$relation->relation_id}\n");
\Workerman\Worker::safeEcho(" [7.3] 设置 phone_number...\n");
$relation->phone_number = $phoneNumber;
\Workerman\Worker::safeEcho(" [7.3] ✓ phone_number = {$relation->phone_number}\n");
\Workerman\Worker::safeEcho(" [7.4] 设置 phone_hash...\n");
$relation->phone_hash = $phoneHash;
\Workerman\Worker::safeEcho(" [7.4] ✓ phone_hash = {$relation->phone_hash}\n");
\Workerman\Worker::safeEcho(" [7.5] 设置 user_id...\n");
$relation->user_id = $userId;
\Workerman\Worker::safeEcho(" [7.5] ✓ user_id = {$relation->user_id}\n");
\Workerman\Worker::safeEcho(" [7.6] 设置 effective_time...\n");
\Workerman\Worker::safeEcho(" - effectiveTime类型: " . get_class($effectiveTime) . "\n");
\Workerman\Worker::safeEcho(" - effectiveTime值: " . $effectiveTime->format('Y-m-d H:i:s') . "\n");
$relation->effective_time = $effectiveTime;
\Workerman\Worker::safeEcho(" [7.6] ✓ effective_time 设置完成\n");
\Workerman\Worker::safeEcho(" [7.7] 设置 expire_time...\n");
$expireTimeValue = $options['expire_time'] ?? null;
\Workerman\Worker::safeEcho(" - expireTime值: " . ($expireTimeValue ? (is_object($expireTimeValue) ? $expireTimeValue->format('Y-m-d H:i:s') : $expireTimeValue) : 'null') . "\n");
$relation->expire_time = $expireTimeValue;
\Workerman\Worker::safeEcho(" [7.7] ✓ expire_time 设置完成\n");
\Workerman\Worker::safeEcho(" [7.8] 设置 is_active...\n");
// 如果 expire_time 为 null 或不存在,则 is_active 为 true
$isActiveValue = ($options['expire_time'] ?? null) === null;
\Workerman\Worker::safeEcho(" - isActive值: " . ($isActiveValue ? 'true' : 'false') . "\n");
$relation->is_active = $isActiveValue;
\Workerman\Worker::safeEcho(" [7.8] ✓ is_active 设置完成\n");
\Workerman\Worker::safeEcho(" [7.9] 设置 type...\n");
$typeValue = $options['type'] ?? 'personal';
\Workerman\Worker::safeEcho(" - type值: {$typeValue}\n");
$relation->type = $typeValue;
\Workerman\Worker::safeEcho(" [7.9] ✓ type 设置完成\n");
\Workerman\Worker::safeEcho(" [7.10] 设置 is_verified...\n");
$isVerifiedValue = $options['is_verified'] ?? false;
\Workerman\Worker::safeEcho(" - isVerified值: " . ($isVerifiedValue ? 'true' : 'false') . "\n");
$relation->is_verified = $isVerifiedValue;
\Workerman\Worker::safeEcho(" [7.10] ✓ is_verified 设置完成\n");
\Workerman\Worker::safeEcho(" [7.11] 设置 source...\n");
$sourceValue = $options['source'] ?? 'manual';
\Workerman\Worker::safeEcho(" - source值: {$sourceValue}\n");
$relation->source = $sourceValue;
\Workerman\Worker::safeEcho(" [7.11] ✓ source 设置完成\n");
\Workerman\Worker::safeEcho(" [7.12] 设置 create_time...\n");
\Workerman\Worker::safeEcho(" - now类型: " . get_class($now) . "\n");
\Workerman\Worker::safeEcho(" - now值: " . $now->format('Y-m-d H:i:s') . "\n");
$relation->create_time = $now;
\Workerman\Worker::safeEcho(" [7.12] ✓ create_time 设置完成\n");
\Workerman\Worker::safeEcho(" [7.13] 设置 update_time...\n");
$relation->update_time = $now;
\Workerman\Worker::safeEcho(" [7.13] ✓ update_time 设置完成\n");
\Workerman\Worker::safeEcho(" [7.14] ✓ 所有属性设置完成,准备打印数据详情\n");
} catch (\Throwable $e) {
\Workerman\Worker::safeEcho("\n【断点7】❌ 数据准备过程中发生异常!\n");
\Workerman\Worker::safeEcho(" - 错误信息: " . $e->getMessage() . "\n");
\Workerman\Worker::safeEcho(" - 错误类型: " . get_class($e) . "\n");
\Workerman\Worker::safeEcho(" - 文件: " . $e->getFile() . ":" . $e->getLine() . "\n");
\Workerman\Worker::safeEcho(" - 堆栈跟踪:\n");
$trace = $e->getTraceAsString();
$traceLines = explode("\n", $trace);
foreach (array_slice($traceLines, 0, 10) as $line) {
\Workerman\Worker::safeEcho(" " . $line . "\n");
}
throw $e;
}
\Workerman\Worker::safeEcho("【断点7】准备保存的数据详情:\n");
\Workerman\Worker::safeEcho(" - relation_id: {$relation->relation_id}\n");
\Workerman\Worker::safeEcho(" - phone_number: {$relation->phone_number}\n");
\Workerman\Worker::safeEcho(" - phone_hash: {$relation->phone_hash}\n");
\Workerman\Worker::safeEcho(" - user_id: {$relation->user_id}\n");
\Workerman\Worker::safeEcho(" - effective_time: " . ($relation->effective_time ? $relation->effective_time->format('Y-m-d H:i:s') : 'null') . "\n");
\Workerman\Worker::safeEcho(" - expire_time: " . ($relation->expire_time ? $relation->expire_time->format('Y-m-d H:i:s') : 'null') . "\n");
\Workerman\Worker::safeEcho(" - is_active: " . ($relation->is_active ? 'true' : 'false') . "\n");
\Workerman\Worker::safeEcho(" - type: {$relation->type}\n");
\Workerman\Worker::safeEcho(" - is_verified: " . ($relation->is_verified ? 'true' : 'false') . "\n");
\Workerman\Worker::safeEcho(" - source: {$relation->source}\n");
\Workerman\Worker::safeEcho(" - create_time: " . ($relation->create_time ? $relation->create_time->format('Y-m-d H:i:s') : 'null') . "\n");
\Workerman\Worker::safeEcho(" - update_time: " . ($relation->update_time ? $relation->update_time->format('Y-m-d H:i:s') : 'null') . "\n");
\Workerman\Worker::safeEcho("\n【断点8-数据库配置检查】检查数据库配置\n");
\Workerman\Worker::safeEcho("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
try {
// 获取表名
$tableName = $relation->getTable();
\Workerman\Worker::safeEcho(" ✓ 目标表名: {$tableName}\n");
// 获取连接名
$connectionName = $relation->getConnectionName();
\Workerman\Worker::safeEcho(" ✓ 数据库连接名: {$connectionName}\n");
// 获取连接对象
$connection = $relation->getConnection();
\Workerman\Worker::safeEcho(" ✓ 连接对象获取成功\n");
// 获取数据库名
$databaseName = $connection->getDatabaseName();
\Workerman\Worker::safeEcho(" ✓ 数据库名: {$databaseName}\n");
// 获取配置信息
$config = config('database.connections.' . $connectionName, []);
\Workerman\Worker::safeEcho("\n 数据库配置详情:\n");
\Workerman\Worker::safeEcho(" - driver: " . ($config['driver'] ?? 'unknown') . "\n");
\Workerman\Worker::safeEcho(" - dsn: " . ($config['dsn'] ?? 'unknown') . "\n");
\Workerman\Worker::safeEcho(" - database: " . ($config['database'] ?? 'unknown') . "\n");
\Workerman\Worker::safeEcho(" - username: " . (isset($config['username']) ? $config['username'] : 'null') . "\n");
\Workerman\Worker::safeEcho(" - has_password: " . (isset($config['password']) ? 'yes' : 'no') . "\n");
// 尝试获取MongoDB客户端信息
try {
$mongoClient = $connection->getMongoClient();
if ($mongoClient) {
\Workerman\Worker::safeEcho(" - MongoDB客户端: 已获取 ✓\n");
}
} catch (\Throwable $e) {
\Workerman\Worker::safeEcho(" - MongoDB客户端获取失败: " . $e->getMessage() . "\n");
}
// 测试连接
try {
$testCollection = $connection->getCollection($tableName);
\Workerman\Worker::safeEcho(" - 集合对象获取: 成功 ✓\n");
\Workerman\Worker::safeEcho(" - 集合名: {$tableName}\n");
} catch (\Throwable $e) {
\Workerman\Worker::safeEcho(" - 集合对象获取失败: " . $e->getMessage() . "\n");
}
\Workerman\Worker::safeEcho("\n 最终写入目标:\n");
\Workerman\Worker::safeEcho(" - 数据库: {$databaseName}\n");
\Workerman\Worker::safeEcho(" - 集合: {$tableName}\n");
\Workerman\Worker::safeEcho(" - 连接: {$connectionName}\n");
\Workerman\Worker::safeEcho(" - 连接状态: 已连接 ✓\n");
} catch (\Throwable $e) {
\Workerman\Worker::safeEcho(" ❌ 数据库配置检查失败!\n");
\Workerman\Worker::safeEcho(" - 错误信息: " . $e->getMessage() . "\n");
\Workerman\Worker::safeEcho(" - 错误类型: " . get_class($e) . "\n");
\Workerman\Worker::safeEcho(" - 文件: " . $e->getFile() . ":" . $e->getLine() . "\n");
\Workerman\Worker::safeEcho(" - 堆栈: " . $e->getTraceAsString() . "\n");
}
\Workerman\Worker::safeEcho("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
\Workerman\Worker::safeEcho("\n【断点9-执行保存】开始执行 save() 操作\n");
\Workerman\Worker::safeEcho(" - 调用: \$relation->save()\n");
// 执行保存
try {
$saveResult = $relation->save();
\Workerman\Worker::safeEcho("【断点9】save() 执行完成\n");
\Workerman\Worker::safeEcho(" - save() 返回值: " . ($saveResult ? 'true ✓' : 'false ✗') . "\n");
if (!$saveResult) {
\Workerman\Worker::safeEcho(" - ❌ 警告save() 返回 false数据可能未保存\n");
}
\Workerman\Worker::safeEcho("\n【断点10-保存后验证】验证数据是否真的写入数据库\n");
\Workerman\Worker::safeEcho(" - 查询条件: relation_id = {$relation->relation_id}\n");
// 验证是否真的保存成功(尝试查询)
$savedRelation = $this->phoneRelationRepository->findByRelationId($relation->relation_id);
if ($savedRelation) {
\Workerman\Worker::safeEcho(" - ✅ 验证成功:查询到保存的数据\n");
\Workerman\Worker::safeEcho(" - 查询到的 relation_id: {$savedRelation->relation_id}\n");
\Workerman\Worker::safeEcho(" - 查询到的 user_id: {$savedRelation->user_id}\n");
\Workerman\Worker::safeEcho(" - 查询到的 phone_number: {$savedRelation->phone_number}\n");
} else {
\Workerman\Worker::safeEcho(" - ❌ 验证失败save()返回true但查询不到数据\n");
\Workerman\Worker::safeEcho(" - 可能原因:\n");
\Workerman\Worker::safeEcho(" 1. MongoDB写入确认问题w=0模式\n");
\Workerman\Worker::safeEcho(" 2. 数据库连接问题\n");
\Workerman\Worker::safeEcho(" 3. 事务未提交\n");
\Workerman\Worker::safeEcho(" 4. 写入延迟\n");
}
} catch (\Throwable $e) {
\Workerman\Worker::safeEcho("\n【断点9】❌ 保存过程中发生异常!\n");
\Workerman\Worker::safeEcho(" - 错误信息: " . $e->getMessage() . "\n");
\Workerman\Worker::safeEcho(" - 错误类型: " . get_class($e) . "\n");
\Workerman\Worker::safeEcho(" - 文件: " . $e->getFile() . ":" . $e->getLine() . "\n");
\Workerman\Worker::safeEcho(" - 堆栈跟踪:\n");
$trace = $e->getTraceAsString();
$traceLines = explode("\n", $trace);
foreach (array_slice($traceLines, 0, 5) as $line) {
\Workerman\Worker::safeEcho(" " . $line . "\n");
}
throw $e;
}
\Workerman\Worker::safeEcho("\n【断点11-日志记录】记录业务日志\n");
LoggerHelper::logBusiness('phone_relation_created', [
'relation_id' => $relation->relation_id,
'user_id' => $userId,
'phone_number' => $phoneNumber,
'type' => $relation->type,
'effective_time' => $effectiveTime->format('Y-m-d H:i:s'),
]);
\Workerman\Worker::safeEcho("【断点11】✓ 业务日志已记录\n");
\Workerman\Worker::safeEcho("\n【断点12-方法返回】准备返回结果\n");
\Workerman\Worker::safeEcho(" - 返回 relation_id: {$relation->relation_id}\n");
\Workerman\Worker::safeEcho("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
\Workerman\Worker::safeEcho("[UserPhoneService::addPhoneToUser] ✅ 方法执行完成\n");
\Workerman\Worker::safeEcho("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
return $relation->relation_id;
}
/**
* 移除用户的手机号(失效关联)
*
* @param string $userId 用户ID
* @param string $phoneNumber 手机号
* @param \DateTimeInterface|null $expireTime 过期时间(默认当前时间)
* @return bool 是否成功
*/
public function removePhoneFromUser(string $userId, string $phoneNumber, ?\DateTimeInterface $expireTime = null): bool
{
// 过滤非数字字符
$phoneNumber = $this->filterPhoneNumber(trim($phoneNumber));
if (empty($phoneNumber)) {
return false;
}
$phoneHash = EncryptionHelper::hash($phoneNumber);
$expireTime = $expireTime ?? new \DateTimeImmutable('now');
$relations = $this->phoneRelationRepository->newQuery()
->where('phone_hash', $phoneHash)
->where('user_id', $userId)
->where('is_active', true)
->where(function($q) use ($expireTime) {
$q->whereNull('expire_time')
->orWhere('expire_time', '>', $expireTime);
})
->get();
if ($relations->isEmpty()) {
return false;
}
foreach ($relations as $relation) {
$relation->expire_time = $expireTime;
$relation->is_active = false;
$relation->update_time = new \DateTimeImmutable('now');
$relation->save();
}
LoggerHelper::logBusiness('phone_relation_removed', [
'user_id' => $userId,
'phone_number' => $phoneNumber,
'expire_time' => $expireTime->format('Y-m-d H:i:s'),
]);
return true;
}
/**
* 根据手机号查找当前用户
*
* @param string $phoneNumber 手机号
* @param \DateTimeInterface|null $atTime 查询时间点(默认为当前时间)
* @return string|null 用户ID
*/
public function findUserByPhone(string $phoneNumber, ?\DateTimeInterface $atTime = null): ?string
{
// 过滤非数字字符
$phoneNumber = $this->filterPhoneNumber(trim($phoneNumber));
if (empty($phoneNumber)) {
return null;
}
$phoneHash = EncryptionHelper::hash($phoneNumber);
$relation = $this->phoneRelationRepository->findActiveByPhoneHash($phoneHash, $atTime);
return $relation ? $relation->user_id : null;
}
/**
* 获取用户的所有手机号
*
* @param string $userId 用户ID
* @param bool $includeHistory 是否包含历史记录
* @return array<array<string, mixed>> 手机号列表
*/
public function getUserPhones(string $userId, bool $includeHistory = false): array
{
$relations = $this->phoneRelationRepository->findByUserId($userId, $includeHistory);
return array_map(function($relation) {
return [
'phone_number' => $relation->phone_number,
'type' => $relation->type,
'is_verified' => $relation->is_verified,
'effective_time' => $relation->effective_time,
'expire_time' => $relation->expire_time,
'is_active' => $relation->is_active,
'source' => $relation->source,
];
}, $relations);
}
/**
* 获取用户的所有手机号号码(仅号码列表)
*
* @param string $userId 用户ID
* @param bool $includeHistory 是否包含历史记录
* @return array<string> 手机号列表
*/
public function getUserPhoneNumbers(string $userId, bool $includeHistory = false): array
{
$relations = $this->phoneRelationRepository->findByUserId($userId, $includeHistory);
return array_map(function($relation) {
return $relation->phone_number;
}, $relations);
}
/**
* 获取手机号的历史关联记录
*
* @param string $phoneNumber 手机号
* @return array<array<string, mixed>> 历史关联记录
*/
public function getPhoneHistory(string $phoneNumber): array
{
// 过滤非数字字符
$phoneNumber = $this->filterPhoneNumber(trim($phoneNumber));
if (empty($phoneNumber)) {
return [];
}
$phoneHash = EncryptionHelper::hash($phoneNumber);
$relations = $this->phoneRelationRepository->findHistoryByPhoneHash($phoneHash);
return array_map(function($relation) {
return [
'relation_id' => $relation->relation_id,
'user_id' => $relation->user_id,
'effective_time' => $relation->effective_time,
'expire_time' => $relation->expire_time,
'is_active' => $relation->is_active,
'type' => $relation->type,
'is_verified' => $relation->is_verified,
'source' => $relation->source,
];
}, $relations);
}
/**
* 检查手机号是否已被使用(当前有效)
*
* @param string $phoneNumber 手机号
* @return bool
*/
public function isPhoneInUse(string $phoneNumber): bool
{
// 过滤非数字字符
$phoneNumber = $this->filterPhoneNumber(trim($phoneNumber));
if (empty($phoneNumber)) {
return false;
}
$phoneHash = EncryptionHelper::hash($phoneNumber);
$relation = $this->phoneRelationRepository->findActiveByPhoneHash($phoneHash);
return $relation !== null;
}
/**
* 过滤手机号中的非数字字符
*
* @param string $phoneNumber 原始手机号
* @return string 过滤后的手机号(只包含数字)
*/
protected function filterPhoneNumber(string $phoneNumber): string
{
// 移除所有非数字字符
return preg_replace('/\D/', '', $phoneNumber);
}
/**
* 验证手机号格式(内部使用,假设已经过滤过非数字字符)
*
* @param string $phoneNumber 已过滤的手机号(只包含数字)
* @return bool
*/
protected function validatePhoneNumber(string $phoneNumber): bool
{
// 中国大陆手机号11位数字以1开头
return preg_match('/^1[3-9]\d{9}$/', $phoneNumber) === 1;
}
}

View File

@@ -0,0 +1,395 @@
<?php
namespace app\service;
use app\repository\UserProfileRepository;
use app\utils\EncryptionHelper;
use app\utils\IdCardHelper;
use app\utils\LoggerHelper;
use Ramsey\Uuid\Uuid as UuidGenerator;
/**
* 用户服务
*
* 职责:
* - 创建用户(包含身份证加密)
* - 查询用户信息(支持解密身份证)
* - 根据身份证哈希匹配用户
*/
class UserService
{
public function __construct(
protected UserProfileRepository $userProfileRepository
) {
}
/**
* 创建用户
*
* @param array<string, mixed> $data 用户数据
* @return array<string, mixed> 创建的用户信息
* @throws \InvalidArgumentException
*/
public function createUser(array $data): array
{
// 验证必填字段
if (empty($data['id_card'])) {
throw new \InvalidArgumentException('身份证号不能为空');
}
$idCard = trim($data['id_card']);
$idCardType = $data['id_card_type'] ?? '身份证';
// 验证身份证格式(简单验证)
if ($idCardType === '身份证' && !$this->validateIdCard($idCard)) {
throw new \InvalidArgumentException('身份证号格式不正确');
}
// 检查是否已存在(通过身份证哈希)
$idCardHash = EncryptionHelper::hash($idCard);
$existingUser = $this->userProfileRepository->newQuery()
->where('id_card_hash', $idCardHash)
->first();
if ($existingUser) {
throw new \InvalidArgumentException('该身份证号已存在user_id: ' . $existingUser->user_id);
}
// 加密身份证
$idCardEncrypted = EncryptionHelper::encrypt($idCard);
// 生成用户ID
$userId = $data['user_id'] ?? UuidGenerator::uuid4()->toString();
$now = new \DateTimeImmutable('now');
// 从身份证号中自动提取基础信息(如果未提供)
$idCardInfo = IdCardHelper::extractInfo($idCard);
$gender = isset($data['gender']) ? (int)$data['gender'] : ($idCardInfo['gender'] > 0 ? $idCardInfo['gender'] : null);
$birthday = isset($data['birthday']) ? new \DateTimeImmutable($data['birthday']) : $idCardInfo['birthday'];
// 创建用户记录
$user = new UserProfileRepository();
$user->user_id = $userId;
$user->id_card_hash = $idCardHash;
$user->id_card_encrypted = $idCardEncrypted;
$user->id_card_type = $idCardType;
$user->name = $data['name'] ?? null;
$user->phone = $data['phone'] ?? null;
$user->address = $data['address'] ?? null;
$user->email = $data['email'] ?? null;
$user->gender = $gender;
$user->birthday = $birthday;
$user->total_amount = isset($data['total_amount']) ? (float)$data['total_amount'] : 0;
$user->total_count = isset($data['total_count']) ? (int)$data['total_count'] : 0;
$user->last_consume_time = isset($data['last_consume_time']) ? new \DateTimeImmutable($data['last_consume_time']) : null;
$user->status = isset($data['status']) ? (int)$data['status'] : 0;
$user->create_time = $now;
$user->update_time = $now;
$user->save();
LoggerHelper::logBusiness('user_created', [
'user_id' => $userId,
'name' => $user->name,
'id_card_type' => $idCardType,
]);
return [
'user_id' => $userId,
'name' => $user->name,
'phone' => $user->phone,
'id_card_type' => $idCardType,
'create_time' => $user->create_time,
];
}
/**
* 根据 user_id 获取用户信息
*
* @param string $userId 用户ID
* @param bool $decryptIdCard 是否解密身份证(需要权限控制)
* @return array<string, mixed>|null 用户信息
*/
public function getUserById(string $userId, bool $decryptIdCard = false): ?array
{
$user = $this->userProfileRepository->findByUserId($userId);
if (!$user) {
return null;
}
$result = [
'user_id' => $user->user_id,
'name' => $user->name,
'phone' => $user->phone,
'address' => $user->address,
'email' => $user->email,
'gender' => $user->gender,
'birthday' => $user->birthday,
'id_card_type' => $user->id_card_type,
'total_amount' => $user->total_amount,
'total_count' => $user->total_count,
'last_consume_time' => $user->last_consume_time,
'tags_update_time' => $user->tags_update_time,
'status' => $user->status,
'create_time' => $user->create_time,
'update_time' => $user->update_time,
];
// 如果需要解密身份证(需要权限控制)
if ($decryptIdCard) {
try {
$result['id_card'] = EncryptionHelper::decrypt($user->id_card_encrypted);
} catch (\Throwable $e) {
LoggerHelper::logError($e, ['user_id' => $userId, 'action' => 'decrypt_id_card']);
$result['id_card'] = null;
$result['decrypt_error'] = '解密失败';
}
} else {
// 返回脱敏的身份证
$result['id_card_encrypted'] = $user->id_card_encrypted;
}
return $result;
}
/**
* 根据身份证号查找用户(通过哈希匹配)
*
* @param string $idCard 身份证号
* @return array<string, mixed>|null 用户信息
*/
public function findUserByIdCard(string $idCard): ?array
{
$idCardHash = EncryptionHelper::hash($idCard);
$user = $this->userProfileRepository->newQuery()
->where('id_card_hash', $idCardHash)
->first();
if (!$user) {
return null;
}
return $this->getUserById($user->user_id, false);
}
/**
* 更新用户信息
*
* @param string $userId 用户ID
* @param array<string, mixed> $data 要更新的用户数据
* @return array<string, mixed> 更新后的用户信息
* @throws \InvalidArgumentException
*/
public function updateUser(string $userId, array $data): array
{
$user = $this->userProfileRepository->findByUserId($userId);
if (!$user) {
throw new \InvalidArgumentException("用户不存在: {$userId}");
}
$now = new \DateTimeImmutable('now');
// 更新允许修改的字段
if (isset($data['name'])) {
$user->name = $data['name'];
}
if (isset($data['phone'])) {
$user->phone = $data['phone'];
}
if (isset($data['email'])) {
$user->email = $data['email'];
}
if (isset($data['address'])) {
$user->address = $data['address'];
}
if (isset($data['gender'])) {
$user->gender = (int)$data['gender'];
}
if (isset($data['birthday'])) {
$user->birthday = new \DateTimeImmutable($data['birthday']);
}
if (isset($data['status'])) {
$user->status = (int)$data['status'];
}
$user->update_time = $now;
$user->save();
LoggerHelper::logBusiness('user_updated', [
'user_id' => $userId,
'updated_fields' => array_keys($data),
]);
return $this->getUserById($userId, false);
}
/**
* 删除用户(软删除,设置状态为禁用)
*
* @param string $userId 用户ID
* @return bool 是否删除成功
* @throws \InvalidArgumentException
*/
public function deleteUser(string $userId): bool
{
$user = $this->userProfileRepository->findByUserId($userId);
if (!$user) {
throw new \InvalidArgumentException("用户不存在: {$userId}");
}
// 软删除:设置状态为禁用
$user->status = 1; // 1 表示禁用
$user->update_time = new \DateTimeImmutable('now');
$user->save();
LoggerHelper::logBusiness('user_deleted', [
'user_id' => $userId,
]);
return true;
}
/**
* 搜索用户(支持多种条件组合)
*
* @param array<string, mixed> $conditions 搜索条件
* - name: 姓名(模糊搜索)
* - phone: 手机号(精确或模糊)
* - email: 邮箱(精确或模糊)
* - id_card: 身份证号(精确匹配)
* - gender: 性别0-未知1-男2-女)
* - status: 状态0-正常1-禁用)
* - min_total_amount: 最小总消费金额
* - max_total_amount: 最大总消费金额
* - min_total_count: 最小消费次数
* - max_total_count: 最大消费次数
* @param int $page 页码从1开始
* @param int $pageSize 每页数量
* @return array<string, mixed> 返回用户列表和分页信息
*/
public function searchUsers(array $conditions, int $page = 1, int $pageSize = 20): array
{
$query = $this->userProfileRepository->newQuery();
// 姓名模糊搜索MongoDB 使用正则表达式)
if (!empty($conditions['name'])) {
$namePattern = preg_quote($conditions['name'], '/');
$query->where('name', 'regex', "/{$namePattern}/i");
}
// 手机号搜索(支持精确和模糊)
if (!empty($conditions['phone'])) {
if (isset($conditions['phone_exact']) && $conditions['phone_exact']) {
// 精确匹配
$query->where('phone', $conditions['phone']);
} else {
// 模糊匹配MongoDB 使用正则表达式)
$phonePattern = preg_quote($conditions['phone'], '/');
$query->where('phone', 'regex', "/{$phonePattern}/i");
}
}
// 邮箱搜索(支持精确和模糊)
if (!empty($conditions['email'])) {
if (isset($conditions['email_exact']) && $conditions['email_exact']) {
// 精确匹配
$query->where('email', $conditions['email']);
} else {
// 模糊匹配MongoDB 使用正则表达式)
$emailPattern = preg_quote($conditions['email'], '/');
$query->where('email', 'regex', "/{$emailPattern}/i");
}
}
// 如果指定了 user_ids限制搜索范围
if (!empty($conditions['user_ids']) && is_array($conditions['user_ids'])) {
$query->whereIn('user_id', $conditions['user_ids']);
}
// 身份证号精确匹配(通过哈希)
if (!empty($conditions['id_card'])) {
$idCardHash = EncryptionHelper::hash($conditions['id_card']);
$query->where('id_card_hash', $idCardHash);
}
// 性别筛选
if (isset($conditions['gender']) && $conditions['gender'] !== '') {
$query->where('gender', (int)$conditions['gender']);
}
// 状态筛选
if (isset($conditions['status']) && $conditions['status'] !== '') {
$query->where('status', (int)$conditions['status']);
}
// 总消费金额范围
if (isset($conditions['min_total_amount'])) {
$query->where('total_amount', '>=', (float)$conditions['min_total_amount']);
}
if (isset($conditions['max_total_amount'])) {
$query->where('total_amount', '<=', (float)$conditions['max_total_amount']);
}
// 消费次数范围
if (isset($conditions['min_total_count'])) {
$query->where('total_count', '>=', (int)$conditions['min_total_count']);
}
if (isset($conditions['max_total_count'])) {
$query->where('total_count', '<=', (int)$conditions['max_total_count']);
}
// 分页
$total = $query->count();
$users = $query->skip(($page - 1) * $pageSize)
->take($pageSize)
->orderBy('create_time', 'desc')
->get();
// 转换为数组格式
$result = [];
foreach ($users as $user) {
$result[] = [
'user_id' => $user->user_id,
'name' => $user->name,
'phone' => $user->phone,
'email' => $user->email,
'address' => $user->address,
'gender' => $user->gender,
'birthday' => $user->birthday,
'id_card_type' => $user->id_card_type,
'total_amount' => $user->total_amount,
'total_count' => $user->total_count,
'last_consume_time' => $user->last_consume_time,
'tags_update_time' => $user->tags_update_time,
'status' => $user->status,
'create_time' => $user->create_time,
'update_time' => $user->update_time,
];
}
return [
'users' => $result,
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
'total_pages' => (int)ceil($total / $pageSize),
];
}
/**
* 验证身份证号格式(简单验证)
*
* @param string $idCard 身份证号
* @return bool
*/
protected function validateIdCard(string $idCard): bool
{
// 15位或18位数字最后一位可能是X
return preg_match('/^(\d{15}|\d{17}[\dXx])$/', $idCard) === 1;
}
}