数据中心同步
This commit is contained in:
282
Moncter/app/service/ConsumptionService.php
Normal file
282
Moncter/app/service/ConsumptionService.php
Normal 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;
|
||||
|
||||
/**
|
||||
* 消费记录服务
|
||||
*
|
||||
* 职责:
|
||||
* - 校验基础入参
|
||||
* - 根据手机号/身份证解析用户ID(person_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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
660
Moncter/app/service/DataCollectionTaskService.php
Normal file
660
Moncter/app/service/DataCollectionTaskService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
309
Moncter/app/service/DataSource/Adapter/MongoDBAdapter.php
Normal file
309
Moncter/app/service/DataSource/Adapter/MongoDBAdapter.php
Normal 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) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
234
Moncter/app/service/DataSource/Adapter/MySQLAdapter.php
Normal file
234
Moncter/app/service/DataSource/Adapter/MySQLAdapter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
116
Moncter/app/service/DataSource/DataSourceAdapterFactory.php
Normal file
116
Moncter/app/service/DataSource/DataSourceAdapterFactory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
68
Moncter/app/service/DataSource/PollingStrategyFactory.php
Normal file
68
Moncter/app/service/DataSource/PollingStrategyFactory.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
54
Moncter/app/service/DataSource/PollingStrategyInterface.php
Normal file
54
Moncter/app/service/DataSource/PollingStrategyInterface.php
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
498
Moncter/app/service/DataSourceService.php
Normal file
498
Moncter/app/service/DataSourceService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
242
Moncter/app/service/DataSyncService.php
Normal file
242
Moncter/app/service/DataSyncService.php
Normal 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;
|
||||
|
||||
/**
|
||||
* 数据同步服务
|
||||
*
|
||||
* 职责:
|
||||
* - 消费消息队列中的数据同步消息
|
||||
* - 批量写入 MongoDB(consumption_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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1417
Moncter/app/service/DatabaseSyncService.php
Normal file
1417
Moncter/app/service/DatabaseSyncService.php
Normal file
File diff suppressed because it is too large
Load Diff
312
Moncter/app/service/IdentifierService.php
Normal file
312
Moncter/app/service/IdentifierService.php
Normal 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_id(user_id)
|
||||
* - 如果找不到,创建临时人
|
||||
* - 支持身份证绑定,将临时人转为正式人
|
||||
* - 处理多手机号到同一人的映射
|
||||
*/
|
||||
class IdentifierService
|
||||
{
|
||||
public function __construct(
|
||||
protected UserProfileRepository $userProfileRepository,
|
||||
protected UserPhoneService $userPhoneService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据手机号解析用户ID(person_id)
|
||||
*
|
||||
* 流程:
|
||||
* 1. 查询手机号关联表,找到指定时间点有效的user_id
|
||||
* 2. 如果找不到,创建临时人并建立关联
|
||||
*
|
||||
* @param string $phoneNumber 手机号
|
||||
* @param \DateTimeInterface|null $atTime 查询时间点(默认为当前时间)
|
||||
* @return string user_id(person_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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据身份证解析用户ID(person_id)
|
||||
*
|
||||
* @param string $idCard 身份证号
|
||||
* @return string|null user_id(person_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);
|
||||
}
|
||||
}
|
||||
|
||||
497
Moncter/app/service/PersonMergeService.php
Normal file
497
Moncter/app/service/PersonMergeService.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
164
Moncter/app/service/StoreService.php
Normal file
164
Moncter/app/service/StoreService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
226
Moncter/app/service/TagInitService.php
Normal file
226
Moncter/app/service/TagInitService.php
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
96
Moncter/app/service/TagRuleEngine/SimpleRuleEngine.php
Normal file
96
Moncter/app/service/TagRuleEngine/SimpleRuleEngine.php
Normal 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}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
587
Moncter/app/service/TagService.php
Normal file
587
Moncter/app/service/TagService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
331
Moncter/app/service/TagTaskExecutor.php
Normal file
331
Moncter/app/service/TagTaskExecutor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
283
Moncter/app/service/TagTaskService.php
Normal file
283
Moncter/app/service/TagTaskService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
537
Moncter/app/service/UserPhoneService.php
Normal file
537
Moncter/app/service/UserPhoneService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
395
Moncter/app/service/UserService.php
Normal file
395
Moncter/app/service/UserService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user