数据同步
This commit is contained in:
110
Moncter/app/controller/ConsumptionController.php
Normal file
110
Moncter/app/controller/ConsumptionController.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use app\repository\ConsumptionRecordRepository;
|
||||
use app\repository\UserProfileRepository;
|
||||
use app\service\ConsumptionService;
|
||||
use app\utils\ApiResponseHelper;
|
||||
use app\utils\LoggerHelper;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
class ConsumptionController
|
||||
{
|
||||
|
||||
/**
|
||||
* 创建消费记录
|
||||
*
|
||||
* POST /api/consumption/record
|
||||
*/
|
||||
public function store(Request $request): Response
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
// 记录请求日志
|
||||
LoggerHelper::logRequest('POST', '/api/consumption/record', [
|
||||
'ip' => $request->getRealIp(),
|
||||
'user_agent' => $request->header('user-agent'),
|
||||
]);
|
||||
|
||||
// 获取 JSON 请求体
|
||||
$rawBody = $request->rawBody();
|
||||
|
||||
if (empty($rawBody)) {
|
||||
return ApiResponseHelper::error('请求体为空,请确保 Content-Type 为 application/json 并发送有效的 JSON 数据', 400);
|
||||
}
|
||||
|
||||
$payload = json_decode($rawBody, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return ApiResponseHelper::error('JSON 格式错误: ' . json_last_error_msg(), 400);
|
||||
}
|
||||
|
||||
// 验证用户标识:必须提供 user_id、phone_number 或 id_card 之一
|
||||
// 注意:如果手机号和身份证号都为空(但提供了user_id),仍然可以处理
|
||||
$phoneNumber = trim($payload['phone_number'] ?? '');
|
||||
$idCard = trim($payload['id_card'] ?? '');
|
||||
if (empty($payload['user_id']) && empty($phoneNumber) && empty($idCard)) {
|
||||
throw new \InvalidArgumentException('缺少用户标识:必须提供 user_id、phone_number 或 id_card 之一');
|
||||
}
|
||||
|
||||
// 简单手动组装 Service 依赖,后续可接入容器配置
|
||||
$tagService = new \app\service\TagService(
|
||||
new \app\repository\TagDefinitionRepository(),
|
||||
new \app\repository\UserProfileRepository(),
|
||||
new \app\repository\UserTagRepository(),
|
||||
new \app\repository\TagHistoryRepository(),
|
||||
new \app\service\TagRuleEngine\SimpleRuleEngine()
|
||||
);
|
||||
|
||||
$identifierService = new \app\service\IdentifierService(
|
||||
new UserProfileRepository(),
|
||||
new \app\service\UserPhoneService(
|
||||
new \app\repository\UserPhoneRelationRepository()
|
||||
)
|
||||
);
|
||||
|
||||
$service = new ConsumptionService(
|
||||
new ConsumptionRecordRepository(),
|
||||
new UserProfileRepository(),
|
||||
$identifierService,
|
||||
$tagService
|
||||
);
|
||||
|
||||
$result = $service->createRecord($payload);
|
||||
|
||||
// 如果返回 null,说明手机号和身份证号都为空,跳过该记录
|
||||
if ($result === null) {
|
||||
LoggerHelper::logBusiness('consumption_record_skipped_no_identifier', [
|
||||
'reason' => 'phone_number and id_card are both empty',
|
||||
'consume_time' => $payload['consume_time'] ?? null,
|
||||
]);
|
||||
return ApiResponseHelper::error('记录已跳过:手机号和身份证号都为空', 400, 400);
|
||||
}
|
||||
|
||||
// 记录业务日志
|
||||
LoggerHelper::logBusiness('consumption_record_created', [
|
||||
'user_id' => $result['user_id'] ?? null,
|
||||
'record_id' => $result['record_id'] ?? null,
|
||||
'amount' => $payload['amount'] ?? null,
|
||||
'phone_number' => $payload['phone_number'] ?? null,
|
||||
'id_card_provided' => !empty($payload['id_card']),
|
||||
]);
|
||||
|
||||
$duration = microtime(true) - $startTime;
|
||||
LoggerHelper::logPerformance('consumption_record_create', $duration, [
|
||||
'user_id' => $result['user_id'] ?? null,
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success($result);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return ApiResponseHelper::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
850
Moncter/app/controller/DataCollectionTaskController.php
Normal file
850
Moncter/app/controller/DataCollectionTaskController.php
Normal file
@@ -0,0 +1,850 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use app\service\DataCollectionTaskService;
|
||||
use app\utils\ApiResponseHelper;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 数据采集任务管理控制器
|
||||
*
|
||||
* 提供任务创建、管理、进度查询等接口
|
||||
*/
|
||||
class DataCollectionTaskController
|
||||
{
|
||||
/**
|
||||
* 获取任务服务实例
|
||||
*/
|
||||
private function getService(): DataCollectionTaskService
|
||||
{
|
||||
return new DataCollectionTaskService(
|
||||
new \app\repository\DataCollectionTaskRepository()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建采集任务
|
||||
*
|
||||
* POST /api/data-collection-tasks
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$data = $request->post();
|
||||
|
||||
// 验证必填字段
|
||||
$requiredFields = ['name', 'data_source_id', 'database', 'target_type'];
|
||||
foreach ($requiredFields as $field) {
|
||||
if (empty($data[$field])) {
|
||||
return ApiResponseHelper::error("缺少必填字段: {$field}", 400);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证目标类型
|
||||
if (!in_array($data['target_type'], ['consumption_record', 'generic'])) {
|
||||
return ApiResponseHelper::error("目标类型必须是 consumption_record 或 generic", 400);
|
||||
}
|
||||
|
||||
// 如果是通用Handler,需要目标数据源配置(后端会自动处理consumption_record的配置)
|
||||
if ($data['target_type'] === 'generic') {
|
||||
$genericRequiredFields = ['target_data_source_id', 'target_database', 'target_collection'];
|
||||
foreach ($genericRequiredFields as $field) {
|
||||
if (empty($data[$field])) {
|
||||
return ApiResponseHelper::error("通用Handler缺少必填字段: {$field}", 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证模式
|
||||
if (isset($data['mode']) && !in_array($data['mode'], ['batch', 'realtime'])) {
|
||||
return ApiResponseHelper::error("模式必须是 batch 或 realtime", 400);
|
||||
}
|
||||
|
||||
// 验证集合配置
|
||||
if (empty($data['collection']) && empty($data['collections'])) {
|
||||
return ApiResponseHelper::error("必须指定 collection 或 collections", 400);
|
||||
}
|
||||
|
||||
$service = $this->getService();
|
||||
$task = $service->createTask($data);
|
||||
|
||||
return ApiResponseHelper::success($task, '任务创建成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务
|
||||
*
|
||||
* PUT /api/data-collection-tasks/{task_id}
|
||||
*/
|
||||
public function update(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 从请求路径中解析 task_id
|
||||
$path = $request->path();
|
||||
if (preg_match('#/api/data-collection-tasks/([^/]+)#', $path, $matches)) {
|
||||
$taskId = $matches[1];
|
||||
} else {
|
||||
$taskId = $request->get('task_id');
|
||||
if (!$taskId) {
|
||||
throw new \InvalidArgumentException('缺少 task_id 参数');
|
||||
}
|
||||
}
|
||||
|
||||
$data = $request->post();
|
||||
|
||||
$service = $this->getService();
|
||||
$result = $service->updateTask($taskId, $data);
|
||||
|
||||
if ($result) {
|
||||
return ApiResponseHelper::success(null, '任务更新成功');
|
||||
} else {
|
||||
return ApiResponseHelper::error('任务更新失败', 500);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除任务
|
||||
*
|
||||
* DELETE /api/data-collection-tasks/{task_id}
|
||||
*/
|
||||
public function delete(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 从请求路径中解析 task_id
|
||||
$path = $request->path();
|
||||
if (preg_match('#/api/data-collection-tasks/([^/]+)#', $path, $matches)) {
|
||||
$taskId = $matches[1];
|
||||
} else {
|
||||
$taskId = $request->get('task_id');
|
||||
if (!$taskId) {
|
||||
throw new \InvalidArgumentException('缺少 task_id 参数');
|
||||
}
|
||||
}
|
||||
|
||||
$service = $this->getService();
|
||||
$result = $service->deleteTask($taskId);
|
||||
|
||||
if ($result) {
|
||||
return ApiResponseHelper::success(null, '任务删除成功');
|
||||
} else {
|
||||
return ApiResponseHelper::error('任务删除失败', 500);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动任务
|
||||
*
|
||||
* POST /api/data-collection-tasks/{task_id}/start
|
||||
*/
|
||||
public function start(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 从请求路径中解析 task_id
|
||||
$path = $request->path();
|
||||
if (preg_match('#/api/data-collection-tasks/([^/]+)/start#', $path, $matches)) {
|
||||
$taskId = $matches[1];
|
||||
} else {
|
||||
$taskId = $request->get('task_id');
|
||||
if (!$taskId) {
|
||||
throw new \InvalidArgumentException('缺少 task_id 参数');
|
||||
}
|
||||
}
|
||||
|
||||
$service = $this->getService();
|
||||
$result = $service->startTask($taskId);
|
||||
|
||||
if ($result) {
|
||||
return ApiResponseHelper::success(null, '任务启动成功');
|
||||
} else {
|
||||
return ApiResponseHelper::error('任务启动失败', 500);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停任务
|
||||
*
|
||||
* POST /api/data-collection-tasks/{task_id}/pause
|
||||
*/
|
||||
public function pause(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 从请求路径中解析 task_id
|
||||
$path = $request->path();
|
||||
if (preg_match('#/api/data-collection-tasks/([^/]+)/pause#', $path, $matches)) {
|
||||
$taskId = $matches[1];
|
||||
} else {
|
||||
$taskId = $request->get('task_id');
|
||||
if (!$taskId) {
|
||||
throw new \InvalidArgumentException('缺少 task_id 参数');
|
||||
}
|
||||
}
|
||||
|
||||
$service = $this->getService();
|
||||
$result = $service->pauseTask($taskId);
|
||||
|
||||
if ($result) {
|
||||
return ApiResponseHelper::success(null, '任务暂停成功');
|
||||
} else {
|
||||
return ApiResponseHelper::error('任务暂停失败', 500);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止任务
|
||||
*
|
||||
* POST /api/data-collection-tasks/{task_id}/stop
|
||||
*/
|
||||
public function stop(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 从请求路径中解析 task_id
|
||||
$path = $request->path();
|
||||
if (preg_match('#/api/data-collection-tasks/([^/]+)/stop#', $path, $matches)) {
|
||||
$taskId = $matches[1];
|
||||
} else {
|
||||
$taskId = $request->get('task_id');
|
||||
if (!$taskId) {
|
||||
throw new \InvalidArgumentException('缺少 task_id 参数');
|
||||
}
|
||||
}
|
||||
|
||||
$service = $this->getService();
|
||||
$result = $service->stopTask($taskId);
|
||||
|
||||
if ($result) {
|
||||
return ApiResponseHelper::success(null, '任务停止成功');
|
||||
} else {
|
||||
return ApiResponseHelper::error('任务停止失败', 500);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务列表
|
||||
*
|
||||
* GET /api/data-collection-tasks
|
||||
*/
|
||||
public function list(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 只收集非空的筛选条件
|
||||
$filters = [];
|
||||
if ($request->get('status') !== null && $request->get('status') !== '') {
|
||||
$filters['status'] = $request->get('status');
|
||||
}
|
||||
if ($request->get('data_source_id') !== null && $request->get('data_source_id') !== '') {
|
||||
$filters['data_source_id'] = $request->get('data_source_id');
|
||||
}
|
||||
if ($request->get('name') !== null && $request->get('name') !== '') {
|
||||
$filters['name'] = $request->get('name');
|
||||
}
|
||||
|
||||
$page = (int)($request->get('page', 1));
|
||||
$pageSize = (int)($request->get('page_size', 20));
|
||||
|
||||
$service = $this->getService();
|
||||
$result = $service->getTaskList($filters, $page, $pageSize);
|
||||
|
||||
return ApiResponseHelper::success($result, '查询成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务详情
|
||||
*
|
||||
* GET /api/data-collection-tasks/{task_id}
|
||||
*/
|
||||
public function detail(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 从请求路径中解析 task_id
|
||||
$path = $request->path();
|
||||
if (preg_match('#/api/data-collection-tasks/([^/]+)$#', $path, $matches)) {
|
||||
$taskId = $matches[1];
|
||||
} else {
|
||||
$taskId = $request->get('task_id');
|
||||
if (!$taskId) {
|
||||
throw new \InvalidArgumentException('缺少 task_id 参数');
|
||||
}
|
||||
}
|
||||
|
||||
$service = $this->getService();
|
||||
$task = $service->getTask($taskId);
|
||||
|
||||
if ($task === null) {
|
||||
return ApiResponseHelper::error('任务不存在', 404);
|
||||
}
|
||||
|
||||
return ApiResponseHelper::success($task, '查询成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务进度
|
||||
*
|
||||
* GET /api/data-collection-tasks/{task_id}/progress
|
||||
*/
|
||||
public function progress(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 从请求路径中解析 task_id
|
||||
$path = $request->path();
|
||||
if (preg_match('#/api/data-collection-tasks/([^/]+)/progress#', $path, $matches)) {
|
||||
$taskId = $matches[1];
|
||||
} else {
|
||||
$taskId = $request->get('task_id');
|
||||
if (!$taskId) {
|
||||
throw new \InvalidArgumentException('缺少 task_id 参数');
|
||||
}
|
||||
}
|
||||
|
||||
$service = $this->getService();
|
||||
$task = $service->getTask($taskId);
|
||||
|
||||
if ($task === null) {
|
||||
return ApiResponseHelper::error('任务不存在', 404);
|
||||
}
|
||||
|
||||
$progress = $task['progress'] ?? [];
|
||||
|
||||
return ApiResponseHelper::success($progress, '查询成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据源列表
|
||||
*
|
||||
* GET /api/data-collection-tasks/data-sources
|
||||
*/
|
||||
public function getDataSources(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 优先使用数据库中的数据源,如果没有则使用配置文件
|
||||
$service = new \app\service\DataSourceService(new \app\repository\DataSourceRepository());
|
||||
$result = $service->getDataSourceList(['status' => 1]);
|
||||
|
||||
if (!empty($result['list'])) {
|
||||
// 使用数据库中的数据源
|
||||
$list = array_map(function ($ds) {
|
||||
return [
|
||||
'id' => $ds['data_source_id'],
|
||||
'name' => $ds['name'] ?? $ds['data_source_id'], // 添加名称字段
|
||||
'type' => $ds['type'] ?? 'unknown',
|
||||
'host' => $ds['host'] ?? '',
|
||||
'port' => $ds['port'] ?? 0,
|
||||
'database' => $ds['database'] ?? '',
|
||||
];
|
||||
}, $result['list']);
|
||||
}
|
||||
// 注意:现在数据源配置统一从数据库读取,不再使用config('data_sources')
|
||||
|
||||
// 如果数据库中没有数据源,返回空列表
|
||||
if (!isset($list)) {
|
||||
$list = [];
|
||||
}
|
||||
|
||||
return ApiResponseHelper::success($list, '查询成功');
|
||||
} catch (\MongoDB\Driver\Exception\Exception $e) {
|
||||
// MongoDB 连接错误,返回友好提示
|
||||
$errorMessage = '无法连接到 MongoDB 数据库,请检查数据库服务是否正常运行。错误详情:' . $e->getMessage();
|
||||
return ApiResponseHelper::error($errorMessage, 500);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据源的数据库列表
|
||||
*
|
||||
* GET /api/data-collection-tasks/data-sources/{data_source_id}/databases
|
||||
*/
|
||||
public function getDatabases(Request $request, string $data_source_id): Response
|
||||
{
|
||||
try {
|
||||
// 从数据库获取数据源配置
|
||||
$service = new \app\service\DataSourceService(new \app\repository\DataSourceRepository());
|
||||
$dataSourceConfig = $service->getDataSourceConfig($data_source_id);
|
||||
|
||||
if (!$dataSourceConfig) {
|
||||
return ApiResponseHelper::error('数据源不存在', 404);
|
||||
}
|
||||
|
||||
$dataSource = $dataSourceConfig;
|
||||
|
||||
// 如果是MongoDB,连接并获取数据库列表
|
||||
if ($dataSource['type'] === 'mongodb') {
|
||||
$client = $this->getMongoClient($dataSource);
|
||||
$databases = $client->listDatabases();
|
||||
|
||||
$list = [];
|
||||
foreach ($databases as $database) {
|
||||
$dbName = $database->getName();
|
||||
// 同时返回原始名称和base64编码的ID(URL友好)
|
||||
$list[] = [
|
||||
'name' => $dbName,
|
||||
'id' => base64_encode($dbName), // URL友好的标识符
|
||||
];
|
||||
}
|
||||
|
||||
return ApiResponseHelper::success($list, '查询成功');
|
||||
}
|
||||
|
||||
return ApiResponseHelper::error('不支持的数据源类型', 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库的集合列表
|
||||
*
|
||||
* GET /api/data-collection-tasks/data-sources/{data_source_id}/databases/{database}/collections
|
||||
*/
|
||||
public function getCollections(Request $request, string $data_source_id, string $database): Response
|
||||
{
|
||||
try {
|
||||
// 解码数据库名称(支持base64编码和URL编码)
|
||||
$database = $this->decodeName($database);
|
||||
|
||||
// 从数据库获取数据源配置
|
||||
$service = new \app\service\DataSourceService(new \app\repository\DataSourceRepository());
|
||||
$dataSourceConfig = $service->getDataSourceConfig($data_source_id);
|
||||
|
||||
if (!$dataSourceConfig) {
|
||||
return ApiResponseHelper::error('数据源不存在', 404);
|
||||
}
|
||||
|
||||
$dataSource = $dataSourceConfig;
|
||||
|
||||
// 如果是MongoDB,连接并获取集合列表
|
||||
if ($dataSource['type'] === 'mongodb') {
|
||||
$client = $this->getMongoClient($dataSource);
|
||||
$db = $client->selectDatabase($database);
|
||||
$collections = $db->listCollections();
|
||||
|
||||
$list = [];
|
||||
foreach ($collections as $collection) {
|
||||
$collName = $collection->getName();
|
||||
// 同时返回原始名称和base64编码的ID(URL友好)
|
||||
$list[] = [
|
||||
'name' => $collName,
|
||||
'id' => base64_encode($collName), // URL友好的标识符
|
||||
];
|
||||
}
|
||||
|
||||
return ApiResponseHelper::success($list, '查询成功');
|
||||
}
|
||||
|
||||
return ApiResponseHelper::error('不支持的数据源类型', 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Handler的目标字段列表
|
||||
*
|
||||
* GET /api/data-collection-tasks/handlers/{handler_type}/target-fields
|
||||
*/
|
||||
public function getHandlerTargetFields(Request $request, string $handler_type): Response
|
||||
{
|
||||
try {
|
||||
$fields = [];
|
||||
|
||||
switch ($handler_type) {
|
||||
case 'consumption_record':
|
||||
// 消费记录Handler的目标字段列表
|
||||
// 包含原始输入字段(推荐)和转换后字段(可选)
|
||||
// Handler会自动进行转换:phone_number/id_card -> user_id, store_name -> store_id
|
||||
$fields = [
|
||||
// 用户标识字段(原始输入,推荐使用)
|
||||
['name' => 'phone_number', 'label' => '手机号', 'type' => 'string', 'required' => false, 'description' => '手机号,Handler会自动解析为user_id', 'is_original' => true],
|
||||
['name' => 'id_card', 'label' => '身份证', 'type' => 'string', 'required' => false, 'description' => '身份证号,Handler会自动解析为user_id', 'is_original' => true],
|
||||
// 用户ID(转换后字段,由Handler自动生成,不需要映射)
|
||||
['name' => 'user_id', 'label' => '用户ID', 'type' => 'string', 'required' => false, 'description' => '用户ID,由Handler通过phone_number/id_card自动解析生成,无需映射', 'is_original' => false, 'no_mapping' => true],
|
||||
|
||||
// 门店标识字段(原始输入,推荐使用)
|
||||
['name' => 'store_name', 'label' => '门店名称', 'type' => 'string', 'required' => false, 'description' => '门店名称,Handler会自动转换为store_id', 'is_original' => true],
|
||||
// 门店ID(转换后字段,由Handler自动生成,不需要映射)
|
||||
['name' => 'store_id', 'label' => '门店ID', 'type' => 'string', 'required' => false, 'description' => '门店ID,由Handler通过store_name自动转换生成,无需映射', 'is_original' => false, 'no_mapping' => true],
|
||||
|
||||
// 订单标识字段(用于去重)
|
||||
['name' => 'source_order_id', 'label' => '原始订单ID', 'type' => 'string', 'required' => false, 'description' => '原始订单ID,配合店铺名称做去重唯一标识(建议配置)', 'is_original' => true],
|
||||
// 注意:order_no 由系统自动生成(自动递增),不需要映射
|
||||
|
||||
// 金额和时间字段(直接字段)
|
||||
['name' => 'amount', 'label' => '消费金额', 'type' => 'float', 'required' => true, 'description' => '消费金额(必填)', 'is_original' => true],
|
||||
['name' => 'actual_amount', 'label' => '实际金额', 'type' => 'float', 'required' => true, 'description' => '实际支付金额(必填)', 'is_original' => true],
|
||||
['name' => 'consume_time', 'label' => '消费时间', 'type' => 'datetime', 'required' => true, 'description' => '消费时间,用于时间分片存储(必填)', 'is_original' => true],
|
||||
|
||||
// 其他可选字段
|
||||
['name' => 'currency', 'label' => '币种', 'type' => 'string', 'required' => false, 'description' => '币种,默认CNY(人民币)', 'is_original' => true, 'fixed_options' => true, 'options' => [['value' => 'CNY', 'label' => '人民币(CNY)'], ['value' => 'USD', 'label' => '美元(USD)']], 'default_value' => 'CNY'],
|
||||
['name' => 'status', 'label' => '记录状态', 'type' => 'int', 'required' => false, 'description' => '记录状态:0-正常,1-异常,2-已删除。默认0。需要配置源状态值到标准状态值的映射', 'is_original' => true, 'value_mapping' => true, 'target_values' => [['value' => 0, 'label' => '正常(0)'], ['value' => 1, 'label' => '异常(1)'], ['value' => 2, 'label' => '已删除(2)']], 'default_value' => 0],
|
||||
];
|
||||
break;
|
||||
|
||||
case 'generic':
|
||||
// 通用Handler - 没有固定的字段列表,由用户自定义
|
||||
$fields = [];
|
||||
break;
|
||||
|
||||
default:
|
||||
return ApiResponseHelper::error("未知的Handler类型: {$handler_type}", 400);
|
||||
}
|
||||
|
||||
return ApiResponseHelper::success($fields, '查询成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取集合的字段列表(采样)
|
||||
*
|
||||
* GET /api/data-collection-tasks/data-sources/{data_source_id}/databases/{database}/collections/{collection}/fields
|
||||
*/
|
||||
public function getFields(Request $request, string $data_source_id, string $database, string $collection): Response
|
||||
{
|
||||
try {
|
||||
// 解码数据库名称和集合名称(支持base64编码和URL编码)
|
||||
$database = $this->decodeName($database);
|
||||
$collection = $this->decodeName($collection);
|
||||
|
||||
// 从数据库获取数据源配置
|
||||
$service = new \app\service\DataSourceService(new \app\repository\DataSourceRepository());
|
||||
$dataSourceConfig = $service->getDataSourceConfig($data_source_id);
|
||||
|
||||
if (!$dataSourceConfig) {
|
||||
return ApiResponseHelper::error('数据源不存在', 404);
|
||||
}
|
||||
|
||||
$dataSource = $dataSourceConfig;
|
||||
|
||||
// 如果是MongoDB,采样获取字段
|
||||
if ($dataSource['type'] === 'mongodb') {
|
||||
$client = $this->getMongoClient($dataSource);
|
||||
$db = $client->selectDatabase($database);
|
||||
$coll = $db->selectCollection($collection);
|
||||
|
||||
// 采样一条数据
|
||||
$sample = $coll->findOne([]);
|
||||
|
||||
if ($sample) {
|
||||
$fields = [];
|
||||
$this->extractFields($sample, '', $fields);
|
||||
|
||||
return ApiResponseHelper::success($fields, '查询成功');
|
||||
} else {
|
||||
return ApiResponseHelper::success([], '集合为空,无法获取字段');
|
||||
}
|
||||
}
|
||||
|
||||
return ApiResponseHelper::error('不支持的数据源类型', 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归提取字段
|
||||
*/
|
||||
private function extractFields($data, string $prefix, array &$fields): void
|
||||
{
|
||||
if (is_array($data) || is_object($data)) {
|
||||
foreach ($data as $key => $value) {
|
||||
$fieldName = $prefix ? "{$prefix}.{$key}" : $key;
|
||||
|
||||
if (is_array($value) || is_object($value)) {
|
||||
if (empty($value)) {
|
||||
$fields[] = [
|
||||
'name' => $fieldName,
|
||||
'type' => 'array',
|
||||
];
|
||||
} else {
|
||||
$this->extractFields($value, $fieldName, $fields);
|
||||
}
|
||||
} else {
|
||||
$fields[] = [
|
||||
'name' => $fieldName,
|
||||
'type' => gettype($value),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览查询结果(包含lookup)
|
||||
*
|
||||
* POST /api/data-collection-tasks/preview-query
|
||||
*/
|
||||
public function previewQuery(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$data = $request->post();
|
||||
|
||||
$dataSourceId = $data['data_source_id'] ?? '';
|
||||
$database = $data['database'] ?? '';
|
||||
$collection = $data['collection'] ?? '';
|
||||
$lookups = $data['lookups'] ?? [];
|
||||
$filterConditions = $data['filter_conditions'] ?? [];
|
||||
$limit = (int)($data['limit'] ?? 5); // 默认预览5条
|
||||
|
||||
if (empty($dataSourceId) || empty($database) || empty($collection)) {
|
||||
return ApiResponseHelper::error('缺少必要参数:data_source_id, database, collection', 400);
|
||||
}
|
||||
|
||||
// 获取数据源配置
|
||||
$service = new \app\service\DataSourceService(new \app\repository\DataSourceRepository());
|
||||
$dataSourceConfig = $service->getDataSourceConfig($dataSourceId);
|
||||
|
||||
if (!$dataSourceConfig) {
|
||||
return ApiResponseHelper::error('数据源不存在', 404);
|
||||
}
|
||||
|
||||
if ($dataSourceConfig['type'] !== 'mongodb') {
|
||||
return ApiResponseHelper::error('目前只支持MongoDB数据源预览', 400);
|
||||
}
|
||||
|
||||
// 连接MongoDB
|
||||
$client = $this->getMongoClient($dataSourceConfig);
|
||||
$db = $client->selectDatabase($database);
|
||||
$coll = $db->selectCollection($collection);
|
||||
|
||||
// 构建聚合管道
|
||||
$pipeline = [];
|
||||
|
||||
// 1. 添加过滤条件($match)- 必须在最前面
|
||||
$filter = $this->buildFilterForPreview($filterConditions);
|
||||
if (!empty($filter)) {
|
||||
$pipeline[] = ['$match' => $filter];
|
||||
}
|
||||
|
||||
// 2. 添加lookup查询
|
||||
foreach ($lookups as $lookup) {
|
||||
if (empty($lookup['from']) || empty($lookup['local_field']) || empty($lookup['foreign_field'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lookupStage = [
|
||||
'$lookup' => [
|
||||
'from' => $lookup['from'],
|
||||
'localField' => $lookup['local_field'],
|
||||
'foreignField' => $lookup['foreign_field'],
|
||||
'as' => $lookup['as'] ?? 'joined'
|
||||
]
|
||||
];
|
||||
|
||||
$pipeline[] = $lookupStage;
|
||||
|
||||
// 如果配置了解构
|
||||
if (!empty($lookup['unwrap'])) {
|
||||
$pipeline[] = [
|
||||
'$unwind' => [
|
||||
'path' => '$' . ($lookup['as'] ?? 'joined'),
|
||||
'preserveNullAndEmptyArrays' => !empty($lookup['preserve_null'])
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 限制返回数量
|
||||
$pipeline[] = ['$limit' => $limit];
|
||||
|
||||
// 执行聚合查询
|
||||
$cursor = $coll->aggregate($pipeline);
|
||||
$results = [];
|
||||
$fields = [];
|
||||
|
||||
foreach ($cursor as $doc) {
|
||||
$docArray = $this->convertMongoDocumentToArray($doc);
|
||||
$results[] = $docArray;
|
||||
|
||||
// 提取字段
|
||||
$this->extractFields($docArray, '', $fields);
|
||||
}
|
||||
|
||||
// 去重字段
|
||||
$uniqueFields = [];
|
||||
$fieldMap = [];
|
||||
foreach ($fields as $field) {
|
||||
if (!isset($fieldMap[$field['name']])) {
|
||||
$fieldMap[$field['name']] = true;
|
||||
$uniqueFields[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return ApiResponseHelper::success([
|
||||
'fields' => $uniqueFields,
|
||||
'data' => $results,
|
||||
'count' => count($results)
|
||||
], '预览成功');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将MongoDB文档转换为数组
|
||||
*/
|
||||
private function convertMongoDocumentToArray($document): array
|
||||
{
|
||||
if (is_array($document)) {
|
||||
return $document;
|
||||
}
|
||||
|
||||
if (is_object($document)) {
|
||||
$array = [];
|
||||
foreach ($document as $key => $value) {
|
||||
if ($value instanceof \MongoDB\BSON\UTCDateTime) {
|
||||
$array[$key] = $value->toDateTime()->format('Y-m-d H:i:s');
|
||||
} elseif (is_object($value) && method_exists($value, '__toString')) {
|
||||
$array[$key] = (string)$value;
|
||||
} elseif (is_array($value) || is_object($value)) {
|
||||
$array[$key] = $this->convertMongoDocumentToArray($value);
|
||||
} else {
|
||||
$array[$key] = $value;
|
||||
}
|
||||
}
|
||||
return $array;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建过滤条件(用于预览查询)
|
||||
*
|
||||
* @param array $filterConditions 过滤条件列表
|
||||
* @return array MongoDB查询过滤器
|
||||
*/
|
||||
private function buildFilterForPreview(array $filterConditions): array
|
||||
{
|
||||
$filter = [];
|
||||
|
||||
foreach ($filterConditions as $condition) {
|
||||
$field = $condition['field'] ?? '';
|
||||
$operator = $condition['operator'] ?? 'eq';
|
||||
$value = $condition['value'] ?? null;
|
||||
|
||||
if (empty($field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理值的类型转换
|
||||
if ($value !== null && $value !== '') {
|
||||
// 尝试转换为数字(如果是数字字符串)
|
||||
if (is_numeric($value)) {
|
||||
// 判断是整数还是浮点数
|
||||
if (strpos($value, '.') !== false) {
|
||||
$value = (float)$value;
|
||||
} else {
|
||||
$value = (int)$value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch ($operator) {
|
||||
case 'eq':
|
||||
$filter[$field] = $value;
|
||||
break;
|
||||
case 'ne':
|
||||
$filter[$field] = ['$ne' => $value];
|
||||
break;
|
||||
case 'gt':
|
||||
$filter[$field] = ['$gt' => $value];
|
||||
break;
|
||||
case 'gte':
|
||||
$filter[$field] = ['$gte' => $value];
|
||||
break;
|
||||
case 'lt':
|
||||
$filter[$field] = ['$lt' => $value];
|
||||
break;
|
||||
case 'lte':
|
||||
$filter[$field] = ['$lte' => $value];
|
||||
break;
|
||||
case 'in':
|
||||
// in操作符的值应该是数组
|
||||
$valueArray = is_array($value) ? $value : explode(',', (string)$value);
|
||||
$filter[$field] = ['$in' => $valueArray];
|
||||
break;
|
||||
case 'nin':
|
||||
// nin操作符的值应该是数组
|
||||
$valueArray = is_array($value) ? $value : explode(',', (string)$value);
|
||||
$filter[$field] = ['$nin' => $valueArray];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码数据库或集合名称(支持base64编码和URL编码)
|
||||
*
|
||||
* @param string $name 编码后的名称
|
||||
* @return string 解码后的名称
|
||||
*/
|
||||
private function decodeName(string $name): string
|
||||
{
|
||||
// 尝试base64解码(如果前端使用的是编码后的ID)
|
||||
// 检查是否可能是base64编码(只包含base64字符且长度合理)
|
||||
if (preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $name) && strlen($name) > 0) {
|
||||
$decoded = @base64_decode($name, true);
|
||||
if ($decoded !== false && $decoded !== '') {
|
||||
// 解码成功,使用解码后的值
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// 不是base64格式或解码失败,使用URL解码(处理中文等特殊字符)
|
||||
return rawurldecode($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取MongoDB客户端
|
||||
*/
|
||||
private function getMongoClient(array $config): \MongoDB\Client
|
||||
{
|
||||
$host = $config['host'] ?? '';
|
||||
$port = (int)($config['port'] ?? 27017);
|
||||
$username = $config['username'] ?? '';
|
||||
$password = $config['password'] ?? '';
|
||||
$authSource = $config['auth_source'] ?? 'admin';
|
||||
|
||||
if (!empty($username) && !empty($password)) {
|
||||
$dsn = "mongodb://{$username}:{$password}@{$host}:{$port}/{$authSource}";
|
||||
} else {
|
||||
$dsn = "mongodb://{$host}:{$port}";
|
||||
}
|
||||
|
||||
return new \MongoDB\Client($dsn, $config['options'] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
173
Moncter/app/controller/DataSourceController.php
Normal file
173
Moncter/app/controller/DataSourceController.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use app\repository\DataSourceRepository;
|
||||
use app\service\DataSourceService;
|
||||
use app\utils\ApiResponseHelper;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 数据源管理控制器
|
||||
*/
|
||||
class DataSourceController
|
||||
{
|
||||
/**
|
||||
* 获取数据源服务实例
|
||||
*/
|
||||
private function getService(): DataSourceService
|
||||
{
|
||||
return new DataSourceService(new DataSourceRepository());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据源列表
|
||||
*
|
||||
* GET /api/data-sources
|
||||
*/
|
||||
public function list(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$service = $this->getService();
|
||||
$filters = [
|
||||
'type' => $request->get('type'),
|
||||
'status' => $request->get('status'),
|
||||
'name' => $request->get('name'),
|
||||
'page' => $request->get('page', 1),
|
||||
'page_size' => $request->get('page_size', 20),
|
||||
];
|
||||
|
||||
$result = $service->getDataSourceList($filters);
|
||||
|
||||
return ApiResponseHelper::success([
|
||||
'data_sources' => $result['list'],
|
||||
'total' => $result['total'],
|
||||
'page' => (int)$filters['page'],
|
||||
'page_size' => (int)$filters['page_size'],
|
||||
], '查询成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据源详情
|
||||
*
|
||||
* GET /api/data-sources/{data_source_id}
|
||||
*/
|
||||
public function detail(Request $request, string $data_source_id): Response
|
||||
{
|
||||
try {
|
||||
$service = $this->getService();
|
||||
$dataSource = $service->getDataSourceDetail($data_source_id);
|
||||
|
||||
if (!$dataSource) {
|
||||
return ApiResponseHelper::error('数据源不存在', 404);
|
||||
}
|
||||
|
||||
return ApiResponseHelper::success($dataSource, '查询成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建数据源
|
||||
*
|
||||
* POST /api/data-sources
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$data = $request->post();
|
||||
|
||||
$service = $this->getService();
|
||||
$dataSource = $service->createDataSource($data);
|
||||
|
||||
// 不返回密码
|
||||
$result = $dataSource->toArray();
|
||||
unset($result['password']);
|
||||
|
||||
return ApiResponseHelper::success($result, '创建成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新数据源
|
||||
*
|
||||
* PUT /api/data-sources/{data_source_id}
|
||||
*/
|
||||
public function update(Request $request, string $data_source_id): Response
|
||||
{
|
||||
try {
|
||||
$data = $request->post();
|
||||
|
||||
$service = $this->getService();
|
||||
$result = $service->updateDataSource($data_source_id, $data);
|
||||
|
||||
if ($result) {
|
||||
return ApiResponseHelper::success(null, '更新成功');
|
||||
} else {
|
||||
return ApiResponseHelper::error('更新失败', 500);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除数据源
|
||||
*
|
||||
* DELETE /api/data-sources/{data_source_id}
|
||||
*/
|
||||
public function delete(Request $request, string $data_source_id): Response
|
||||
{
|
||||
try {
|
||||
$service = $this->getService();
|
||||
$result = $service->deleteDataSource($data_source_id);
|
||||
|
||||
if ($result) {
|
||||
return ApiResponseHelper::success(null, '删除成功');
|
||||
} else {
|
||||
return ApiResponseHelper::error('删除失败', 500);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试数据源连接
|
||||
*
|
||||
* POST /api/data-sources/test-connection
|
||||
*/
|
||||
public function testConnection(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$data = $request->post();
|
||||
|
||||
// 验证必填字段
|
||||
$requiredFields = ['type', 'host', 'port', 'database'];
|
||||
foreach ($requiredFields as $field) {
|
||||
if (empty($data[$field])) {
|
||||
return ApiResponseHelper::error("缺少必填字段: {$field}", 400);
|
||||
}
|
||||
}
|
||||
|
||||
$service = $this->getService();
|
||||
$connected = $service->testConnection($data);
|
||||
|
||||
if ($connected) {
|
||||
return ApiResponseHelper::success(['connected' => true], '连接成功');
|
||||
} else {
|
||||
return ApiResponseHelper::error('连接失败,请检查配置', 400);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::error('连接测试失败: ' . $e->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
455
Moncter/app/controller/DatabaseSyncController.php
Normal file
455
Moncter/app/controller/DatabaseSyncController.php
Normal file
@@ -0,0 +1,455 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use app\service\DatabaseSyncService;
|
||||
use app\utils\ApiResponseHelper;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 数据库同步控制器
|
||||
*
|
||||
* 提供同步进度查询接口
|
||||
*/
|
||||
class DatabaseSyncController
|
||||
{
|
||||
/**
|
||||
* 获取同步进度看板页面
|
||||
*
|
||||
* GET /database-sync/dashboard
|
||||
*/
|
||||
public function dashboard(Request $request): Response
|
||||
{
|
||||
$htmlPath = __DIR__ . '/../../public/database-sync-dashboard.html';
|
||||
if (!file_exists($htmlPath)) {
|
||||
return response('<h1>404 Not Found</h1><p>看板页面不存在</p>', 404)
|
||||
->withHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
}
|
||||
|
||||
$html = file_get_contents($htmlPath);
|
||||
return response($html)->withHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取同步进度
|
||||
*
|
||||
* GET /api/database-sync/progress
|
||||
*/
|
||||
public function progress(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 创建 DatabaseSyncService 实例(传递最小配置,仅用于读取进度)
|
||||
// 注意:数据库同步功能已迁移到 data_collection_tasks.php,这里仅用于查询进度
|
||||
$minimalConfig = [
|
||||
'source' => ['host' => '', 'port' => 27017], // 占位符,不会实际连接
|
||||
'target' => ['host' => '', 'port' => 27017], // 占位符,不会实际连接
|
||||
'sync' => [],
|
||||
'monitoring' => [],
|
||||
];
|
||||
$syncService = new DatabaseSyncService($minimalConfig);
|
||||
// 加载最新进度
|
||||
$syncService->loadProgress();
|
||||
$progress = $syncService->getProgress();
|
||||
|
||||
// 获取多进程状态信息
|
||||
$workerStatus = $this->getWorkerStatus();
|
||||
$progress['worker_status'] = $workerStatus;
|
||||
|
||||
// 获取数据库连接状态
|
||||
$connectionStatus = $this->getConnectionStatus();
|
||||
$progress['connection_status'] = $connectionStatus;
|
||||
|
||||
// 获取数据库列表信息(已完成和待同步)
|
||||
$databaseList = $this->getDatabaseList($syncService);
|
||||
$progress['database_list'] = $databaseList;
|
||||
|
||||
// 检查进度文件最后修改时间
|
||||
$runtimePath = function_exists('runtime_path') ? runtime_path() : (config('app.runtime_path', base_path() . DIRECTORY_SEPARATOR . 'runtime'));
|
||||
$progressFile = $runtimePath . DIRECTORY_SEPARATOR . 'database_sync_progress.json';
|
||||
if (file_exists($progressFile)) {
|
||||
$fileTime = filemtime($progressFile);
|
||||
$progress['progress_file_last_modified'] = date('Y-m-d H:i:s', $fileTime);
|
||||
$progress['progress_file_age_seconds'] = time() - $fileTime;
|
||||
} else {
|
||||
$progress['progress_file_last_modified'] = null;
|
||||
$progress['progress_file_age_seconds'] = null;
|
||||
}
|
||||
|
||||
// 如果状态是idle且没有开始时间,尝试检查是否真的在运行
|
||||
if ($progress['status'] === 'idle' && $progress['time']['start_time'] === null) {
|
||||
if (!file_exists($progressFile)) {
|
||||
$progress['hint'] = '请执行: php start.php status 查看 data_sync_scheduler 进程是否运行(数据库同步任务由 data_sync_scheduler 管理)';
|
||||
}
|
||||
}
|
||||
|
||||
return ApiResponseHelper::success($progress, '同步进度查询成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Worker 进程状态信息
|
||||
*
|
||||
* @return array<string, mixed> Worker 状态信息
|
||||
*/
|
||||
private function getWorkerStatus(): array
|
||||
{
|
||||
$status = [
|
||||
'total_workers' => 0,
|
||||
'active_workers' => 0,
|
||||
'workers' => [],
|
||||
];
|
||||
|
||||
try {
|
||||
// 从配置中获取 Worker 数量
|
||||
$processConfig = config('process.data_sync_scheduler', []);
|
||||
$totalWorkers = (int)($processConfig['count'] ?? 10);
|
||||
$status['total_workers'] = $totalWorkers;
|
||||
|
||||
// 检查进度文件中的 checkpoints,推断每个 Worker 处理的数据库
|
||||
$runtimePath = function_exists('runtime_path') ? runtime_path() : (config('app.runtime_path', base_path() . DIRECTORY_SEPARATOR . 'runtime'));
|
||||
$progressFile = $runtimePath . DIRECTORY_SEPARATOR . 'database_sync_progress.json';
|
||||
|
||||
if (file_exists($progressFile)) {
|
||||
// 使用文件锁读取,避免并发问题
|
||||
$fp = fopen($progressFile, 'r');
|
||||
if ($fp && flock($fp, LOCK_SH)) {
|
||||
try {
|
||||
$content = stream_get_contents($fp);
|
||||
$progressData = json_decode($content, true);
|
||||
|
||||
if ($progressData && isset($progressData['checkpoints'])) {
|
||||
$checkpoints = $progressData['checkpoints'];
|
||||
$databases = array_keys($checkpoints);
|
||||
|
||||
// 根据数据库分配推断每个 Worker 的状态(使用取模分配)
|
||||
foreach ($databases as $index => $database) {
|
||||
$workerId = $index % $totalWorkers;
|
||||
if (!isset($status['workers'][$workerId])) {
|
||||
$status['workers'][$workerId] = [
|
||||
'worker_id' => $workerId,
|
||||
'databases' => [],
|
||||
'collections' => 0,
|
||||
'documents_processed' => 0,
|
||||
'status' => 'active',
|
||||
];
|
||||
}
|
||||
|
||||
$status['workers'][$workerId]['databases'][] = $database;
|
||||
|
||||
// 统计该 Worker 处理的集合和文档数
|
||||
if (isset($checkpoints[$database]) && is_array($checkpoints[$database])) {
|
||||
foreach ($checkpoints[$database] as $collection => $checkpoint) {
|
||||
$status['workers'][$workerId]['collections']++;
|
||||
if (isset($checkpoint['processed'])) {
|
||||
$status['workers'][$workerId]['documents_processed'] += (int)$checkpoint['processed'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$status['active_workers'] = count($status['workers']);
|
||||
|
||||
// 将 workers 数组转换为索引数组(便于前端遍历)
|
||||
$status['workers'] = array_values($status['workers']);
|
||||
}
|
||||
} finally {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
}
|
||||
} else {
|
||||
if ($fp) {
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有活动的 Worker,但配置了 Worker 数量,显示所有 Worker(等待状态)
|
||||
if ($status['active_workers'] === 0 && $status['total_workers'] > 0) {
|
||||
// 创建所有 Worker 的占位信息
|
||||
for ($i = 0; $i < $totalWorkers; $i++) {
|
||||
$status['workers'][] = [
|
||||
'worker_id' => $i,
|
||||
'databases' => [],
|
||||
'collections' => 0,
|
||||
'documents_processed' => 0,
|
||||
'status' => 'waiting', // 等待状态
|
||||
];
|
||||
}
|
||||
$status['message'] = '所有 Worker 处于等待状态,同步尚未开始或进度文件为空';
|
||||
} elseif ($status['total_workers'] === 0) {
|
||||
$status['message'] = '未配置 Worker 数量,请检查 config/process.php';
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$status['error'] = '获取 Worker 状态失败: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取同步统计信息
|
||||
*
|
||||
* GET /api/database-sync/stats
|
||||
*/
|
||||
public function stats(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 创建 DatabaseSyncService 实例(传递最小配置,仅用于读取统计)
|
||||
$minimalConfig = [
|
||||
'source' => ['host' => '', 'port' => 27017],
|
||||
'target' => ['host' => '', 'port' => 27017],
|
||||
];
|
||||
$syncService = new DatabaseSyncService($minimalConfig);
|
||||
$stats = $syncService->getStats();
|
||||
|
||||
return ApiResponseHelper::success($stats, '统计信息查询成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置同步进度
|
||||
*
|
||||
* POST /api/database-sync/reset
|
||||
*/
|
||||
public function reset(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 创建 DatabaseSyncService 实例(传递最小配置,仅用于重置进度)
|
||||
$minimalConfig = [
|
||||
'source' => ['host' => '', 'port' => 27017],
|
||||
'target' => ['host' => '', 'port' => 27017],
|
||||
];
|
||||
$syncService = new DatabaseSyncService($minimalConfig);
|
||||
$syncService->resetProgress();
|
||||
|
||||
return ApiResponseHelper::success(null, '同步进度已重置');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳过错误数据库,继续同步
|
||||
*
|
||||
* POST /api/database-sync/skip-error
|
||||
*/
|
||||
public function skipError(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 创建 DatabaseSyncService 实例(传递最小配置,仅用于跳过错误)
|
||||
$minimalConfig = [
|
||||
'source' => ['host' => '', 'port' => 27017],
|
||||
'target' => ['host' => '', 'port' => 27017],
|
||||
];
|
||||
$syncService = new DatabaseSyncService($minimalConfig);
|
||||
$syncService->loadProgress();
|
||||
|
||||
$skipped = $syncService->skipErrorDatabase();
|
||||
|
||||
if ($skipped) {
|
||||
return ApiResponseHelper::success(null, '已跳过错误数据库,将继续同步下一个数据库');
|
||||
} else {
|
||||
return ApiResponseHelper::error('当前没有错误数据库需要跳过', 400);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库连接状态
|
||||
*
|
||||
* @return array<string, mixed> 连接状态信息
|
||||
*/
|
||||
private function getConnectionStatus(): array
|
||||
{
|
||||
$status = [
|
||||
'source' => [
|
||||
'connected' => false,
|
||||
'host' => '',
|
||||
'port' => 0,
|
||||
'error' => null,
|
||||
],
|
||||
'target' => [
|
||||
'connected' => false,
|
||||
'host' => '',
|
||||
'port' => 0,
|
||||
'error' => null,
|
||||
],
|
||||
];
|
||||
|
||||
try {
|
||||
// 从数据库获取数据源配置
|
||||
$dataSourceService = new \app\service\DataSourceService(new \app\repository\DataSourceRepository());
|
||||
|
||||
// 检查源数据库连接
|
||||
$sourceDataSourceId = 'kr_mongodb'; // 默认源数据库ID,可以从任务配置中获取
|
||||
$sourceConfig = $dataSourceService->getDataSourceConfigById($sourceDataSourceId);
|
||||
|
||||
if ($sourceConfig) {
|
||||
$status['source']['host'] = $sourceConfig['host'] ?? '';
|
||||
$status['source']['port'] = (int)($sourceConfig['port'] ?? 27017);
|
||||
|
||||
// 尝试连接源数据库
|
||||
try {
|
||||
$sourceDsn = $this->buildDsn($sourceConfig);
|
||||
$sourceClient = new \MongoDB\Client($sourceDsn, $sourceConfig['options'] ?? []);
|
||||
// 执行一个简单的命令来测试连接
|
||||
$sourceClient->selectDatabase('admin')->command(['ping' => 1]);
|
||||
$status['source']['connected'] = true;
|
||||
} catch (\Throwable $e) {
|
||||
$status['source']['connected'] = false;
|
||||
$status['source']['error'] = $e->getMessage();
|
||||
}
|
||||
} else {
|
||||
$status['source']['error'] = '源数据库配置不存在';
|
||||
}
|
||||
|
||||
// 检查目标数据库连接
|
||||
$targetDataSourceId = 'sync_mongodb'; // 默认目标数据库ID,可以从任务配置中获取
|
||||
$targetConfig = $dataSourceService->getDataSourceConfigById($targetDataSourceId);
|
||||
|
||||
if ($targetConfig) {
|
||||
$status['target']['host'] = $targetConfig['host'] ?? '';
|
||||
$status['target']['port'] = (int)($targetConfig['port'] ?? 27017);
|
||||
|
||||
// 尝试连接目标数据库
|
||||
try {
|
||||
$targetDsn = $this->buildDsn($targetConfig);
|
||||
$targetClient = new \MongoDB\Client($targetDsn, $targetConfig['options'] ?? []);
|
||||
// 执行一个简单的命令来测试连接
|
||||
$targetClient->selectDatabase('admin')->command(['ping' => 1]);
|
||||
$status['target']['connected'] = true;
|
||||
} catch (\Throwable $e) {
|
||||
$status['target']['connected'] = false;
|
||||
$status['target']['error'] = $e->getMessage();
|
||||
}
|
||||
} else {
|
||||
$status['target']['error'] = '目标数据库配置不存在';
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$status['error'] = '检查连接状态失败: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 MongoDB DSN
|
||||
*
|
||||
* @param array<string, mixed> $config 数据库配置
|
||||
* @return string DSN 字符串
|
||||
*/
|
||||
private function buildDsn(array $config): string
|
||||
{
|
||||
$host = $config['host'] ?? '';
|
||||
$port = (int)($config['port'] ?? 27017);
|
||||
|
||||
$dsn = 'mongodb://';
|
||||
if (!empty($config['username']) && !empty($config['password'])) {
|
||||
$dsn .= urlencode($config['username']) . ':' . urlencode($config['password']) . '@';
|
||||
}
|
||||
$dsn .= $host . ':' . $port;
|
||||
if (!empty($config['auth_source'])) {
|
||||
$dsn .= '/?authSource=' . urlencode($config['auth_source']);
|
||||
}
|
||||
return $dsn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库列表信息(已完成和待同步)
|
||||
*
|
||||
* @param DatabaseSyncService $syncService 同步服务实例
|
||||
* @return array<string, mixed> 数据库列表信息
|
||||
*/
|
||||
private function getDatabaseList(DatabaseSyncService $syncService): array
|
||||
{
|
||||
$list = [
|
||||
'completed' => [],
|
||||
'pending' => [],
|
||||
'processing' => [],
|
||||
];
|
||||
|
||||
try {
|
||||
$runtimePath = function_exists('runtime_path') ? runtime_path() : (config('app.runtime_path', base_path() . DIRECTORY_SEPARATOR . 'runtime'));
|
||||
$progressFile = $runtimePath . DIRECTORY_SEPARATOR . 'database_sync_progress.json';
|
||||
|
||||
if (file_exists($progressFile)) {
|
||||
$fp = fopen($progressFile, 'r');
|
||||
if ($fp && flock($fp, LOCK_SH)) {
|
||||
try {
|
||||
$content = stream_get_contents($fp);
|
||||
$progressData = json_decode($content, true);
|
||||
|
||||
if ($progressData) {
|
||||
$checkpoints = $progressData['checkpoints'] ?? [];
|
||||
$collectionsSnapshot = $progressData['collections_snapshot'] ?? [];
|
||||
$currentDatabase = $progressData['current_database'] ?? null;
|
||||
|
||||
// 获取所有数据库名称
|
||||
$allDatabases = array_keys($collectionsSnapshot);
|
||||
|
||||
foreach ($allDatabases as $database) {
|
||||
$dbCheckpoints = $checkpoints[$database] ?? [];
|
||||
|
||||
// 检查该数据库的所有集合是否都已完成
|
||||
$collections = $collectionsSnapshot[$database] ?? [];
|
||||
$allCompleted = true;
|
||||
$hasData = false;
|
||||
|
||||
foreach ($collections as $collection) {
|
||||
$checkpoint = $dbCheckpoints[$collection] ?? null;
|
||||
if ($checkpoint) {
|
||||
$hasData = true;
|
||||
if (!($checkpoint['completed'] ?? false)) {
|
||||
$allCompleted = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$allCompleted = false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($database === $currentDatabase) {
|
||||
$list['processing'][] = [
|
||||
'name' => $database,
|
||||
'collections' => count($collections),
|
||||
'collections_completed' => count(array_filter($dbCheckpoints, fn($cp) => $cp['completed'] ?? false)),
|
||||
];
|
||||
} elseif ($allCompleted && $hasData) {
|
||||
$list['completed'][] = [
|
||||
'name' => $database,
|
||||
'collections' => count($collections),
|
||||
];
|
||||
} else {
|
||||
$list['pending'][] = [
|
||||
'name' => $database,
|
||||
'collections' => count($collections),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
}
|
||||
} else {
|
||||
if ($fp) {
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 忽略错误,返回空列表
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
}
|
||||
154
Moncter/app/controller/IndexController.php
Normal file
154
Moncter/app/controller/IndexController.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use support\Request;
|
||||
|
||||
class IndexController
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
return "我是数据中心,有何贵干?";
|
||||
}
|
||||
|
||||
public function view(Request $request)
|
||||
{
|
||||
return view('index/view', ['name' => 'webman']);
|
||||
}
|
||||
|
||||
public function json(Request $request)
|
||||
{
|
||||
return json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 MongoDB 数据库连接
|
||||
* GET /api/test/db
|
||||
*/
|
||||
public function testDb(Request $request)
|
||||
{
|
||||
$result = [
|
||||
'code' => 0,
|
||||
'msg' => 'ok',
|
||||
'data' => [
|
||||
'config' => [],
|
||||
'connection' => [],
|
||||
'test_query' => [],
|
||||
],
|
||||
];
|
||||
|
||||
try {
|
||||
// 读取数据库配置
|
||||
$dbConfig = config('database', []);
|
||||
$mongoConfig = $dbConfig['connections']['mongodb'] ?? null;
|
||||
|
||||
if (!$mongoConfig) {
|
||||
throw new \Exception('MongoDB 配置不存在');
|
||||
}
|
||||
|
||||
$result['data']['config'] = [
|
||||
'driver' => $mongoConfig['driver'] ?? 'unknown',
|
||||
'database' => $mongoConfig['database'] ?? 'unknown',
|
||||
'dsn' => $mongoConfig['dsn'] ?? 'unknown',
|
||||
'has_username' => !empty($mongoConfig['username']),
|
||||
'has_password' => !empty($mongoConfig['password']),
|
||||
];
|
||||
|
||||
// 尝试使用 MongoDB 客户端直接连接
|
||||
try {
|
||||
// 构建包含认证信息的 DSN(如果配置了用户名和密码)
|
||||
$dsn = $mongoConfig['dsn'];
|
||||
if (!empty($mongoConfig['username']) && !empty($mongoConfig['password'])) {
|
||||
// 如果 DSN 中不包含认证信息,则添加
|
||||
if (strpos($dsn, '@') === false) {
|
||||
// 从 mongodb://host:port 格式转换为 mongodb://username:password@host:port/database
|
||||
$dsn = str_replace(
|
||||
'mongodb://',
|
||||
'mongodb://' . urlencode($mongoConfig['username']) . ':' . urlencode($mongoConfig['password']) . '@',
|
||||
$dsn
|
||||
);
|
||||
// 添加数据库名和认证源
|
||||
$dsn .= '/' . $mongoConfig['database'];
|
||||
if (!empty($mongoConfig['options']['authSource'])) {
|
||||
$dsn .= '?authSource=' . urlencode($mongoConfig['options']['authSource']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤掉空字符串的选项(MongoDB 客户端不允许空字符串)
|
||||
$options = array_filter($mongoConfig['options'] ?? [], function ($value) {
|
||||
return $value !== '';
|
||||
});
|
||||
|
||||
$client = new \MongoDB\Client(
|
||||
$dsn,
|
||||
$options
|
||||
);
|
||||
|
||||
// 尝试执行 ping 命令
|
||||
$adminDb = $client->selectDatabase('admin');
|
||||
$pingResult = $adminDb->command(['ping' => 1])->toArray();
|
||||
|
||||
$result['data']['connection'] = [
|
||||
'status' => 'connected',
|
||||
'ping' => 'ok',
|
||||
'server_info' => $client->getManager()->getServers(),
|
||||
];
|
||||
|
||||
// 尝试选择目标数据库并列出集合
|
||||
$targetDb = $client->selectDatabase($mongoConfig['database']);
|
||||
$collections = $targetDb->listCollections();
|
||||
$collectionNames = [];
|
||||
foreach ($collections as $collection) {
|
||||
$collectionNames[] = $collection->getName();
|
||||
}
|
||||
|
||||
$result['data']['test_query'] = [
|
||||
'database' => $mongoConfig['database'],
|
||||
'collections_count' => count($collectionNames),
|
||||
'collections' => $collectionNames,
|
||||
];
|
||||
|
||||
} catch (\MongoDB\Driver\Exception\Exception $e) {
|
||||
$result['data']['connection'] = [
|
||||
'status' => 'failed',
|
||||
'error' => $e->getMessage(),
|
||||
'code' => $e->getCode(),
|
||||
];
|
||||
$result['code'] = 500;
|
||||
$result['msg'] = 'MongoDB 连接失败';
|
||||
}
|
||||
|
||||
// 尝试使用 Repository 查询(如果连接成功)
|
||||
if ($result['data']['connection']['status'] === 'connected') {
|
||||
try {
|
||||
$userRepo = new \app\repository\UserProfileRepository();
|
||||
$count = $userRepo->newQuery()->count();
|
||||
$result['data']['repository_test'] = [
|
||||
'status' => 'ok',
|
||||
'user_profile_count' => $count,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$result['data']['repository_test'] = [
|
||||
'status' => 'failed',
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$result = [
|
||||
'code' => 500,
|
||||
'msg' => '测试失败: ' . $e->getMessage(),
|
||||
'data' => [
|
||||
'error' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return json($result);
|
||||
}
|
||||
|
||||
}
|
||||
169
Moncter/app/controller/PersonMergeController.php
Normal file
169
Moncter/app/controller/PersonMergeController.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use app\service\PersonMergeService;
|
||||
use app\service\IdentifierService;
|
||||
use app\repository\UserProfileRepository;
|
||||
use app\repository\UserTagRepository;
|
||||
use app\repository\UserPhoneRelationRepository;
|
||||
use app\service\UserPhoneService;
|
||||
use app\service\TagService;
|
||||
use app\repository\TagDefinitionRepository;
|
||||
use app\repository\TagHistoryRepository;
|
||||
use app\service\TagRuleEngine\SimpleRuleEngine;
|
||||
use app\utils\ApiResponseHelper;
|
||||
use app\utils\LoggerHelper;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 身份合并控制器
|
||||
*
|
||||
* 提供身份合并相关接口,实现场景4:手机号发现身份证后合并
|
||||
*/
|
||||
class PersonMergeController
|
||||
{
|
||||
/**
|
||||
* 合并手机号到身份证(场景4的实现)
|
||||
*
|
||||
* 如果某个手机号发现了对应的身份证号,查询该身份下是否有标签,
|
||||
* 如果有就会将对应的这个身份证号的所有标签重新计算同步。
|
||||
*
|
||||
* POST /api/person-merge/phone-to-id-card
|
||||
*/
|
||||
public function mergePhoneToIdCard(Request $request): Response
|
||||
{
|
||||
try {
|
||||
LoggerHelper::logRequest('POST', '/api/person-merge/phone-to-id-card');
|
||||
|
||||
$rawBody = $request->rawBody();
|
||||
|
||||
if (empty($rawBody)) {
|
||||
return ApiResponseHelper::error('请求体为空,请确保 Content-Type 为 application/json 并发送有效的 JSON 数据', 400);
|
||||
}
|
||||
|
||||
$body = json_decode($rawBody, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return ApiResponseHelper::error('JSON 格式错误: ' . json_last_error_msg(), 400);
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if (empty($body['phone_number'])) {
|
||||
throw new \InvalidArgumentException('缺少必填字段:phone_number');
|
||||
}
|
||||
if (empty($body['id_card'])) {
|
||||
throw new \InvalidArgumentException('缺少必填字段:id_card');
|
||||
}
|
||||
|
||||
$phoneNumber = (string)$body['phone_number'];
|
||||
$idCard = (string)$body['id_card'];
|
||||
|
||||
// 创建服务实例
|
||||
$mergeService = new PersonMergeService(
|
||||
new UserProfileRepository(),
|
||||
new UserTagRepository(),
|
||||
new UserPhoneService(
|
||||
new UserPhoneRelationRepository()
|
||||
),
|
||||
new TagService(
|
||||
new TagDefinitionRepository(),
|
||||
new UserProfileRepository(),
|
||||
new UserTagRepository(),
|
||||
new TagHistoryRepository(),
|
||||
new SimpleRuleEngine()
|
||||
)
|
||||
);
|
||||
|
||||
// 执行合并
|
||||
$formalUserId = $mergeService->mergePhoneToIdCard($phoneNumber, $idCard);
|
||||
|
||||
LoggerHelper::logBusiness('person_merge_phone_to_id_card', [
|
||||
'phone_number' => $phoneNumber,
|
||||
'id_card_provided' => true,
|
||||
'formal_user_id' => $formalUserId,
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success([
|
||||
'phone_number' => $phoneNumber,
|
||||
'formal_user_id' => $formalUserId,
|
||||
'message' => '身份合并成功,标签已重新计算',
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return ApiResponseHelper::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并临时人到正式人
|
||||
*
|
||||
* POST /api/person-merge/temporary-to-formal
|
||||
*/
|
||||
public function mergeTemporaryToFormal(Request $request): Response
|
||||
{
|
||||
try {
|
||||
LoggerHelper::logRequest('POST', '/api/person-merge/temporary-to-formal');
|
||||
|
||||
$rawBody = $request->rawBody();
|
||||
|
||||
if (empty($rawBody)) {
|
||||
return ApiResponseHelper::error('请求体为空,请确保 Content-Type 为 application/json 并发送有效的 JSON 数据', 400);
|
||||
}
|
||||
|
||||
$body = json_decode($rawBody, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return ApiResponseHelper::error('JSON 格式错误: ' . json_last_error_msg(), 400);
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if (empty($body['user_id'])) {
|
||||
throw new \InvalidArgumentException('缺少必填字段:user_id');
|
||||
}
|
||||
if (empty($body['id_card'])) {
|
||||
throw new \InvalidArgumentException('缺少必填字段:id_card');
|
||||
}
|
||||
|
||||
$tempUserId = (string)$body['user_id'];
|
||||
$idCard = (string)$body['id_card'];
|
||||
|
||||
// 创建服务实例
|
||||
$mergeService = new PersonMergeService(
|
||||
new UserProfileRepository(),
|
||||
new UserTagRepository(),
|
||||
new UserPhoneService(
|
||||
new UserPhoneRelationRepository()
|
||||
),
|
||||
new TagService(
|
||||
new TagDefinitionRepository(),
|
||||
new UserProfileRepository(),
|
||||
new UserTagRepository(),
|
||||
new TagHistoryRepository(),
|
||||
new SimpleRuleEngine()
|
||||
)
|
||||
);
|
||||
|
||||
// 执行合并
|
||||
$formalUserId = $mergeService->mergeTemporaryToFormal($tempUserId, $idCard);
|
||||
|
||||
LoggerHelper::logBusiness('person_merge_temporary_to_formal', [
|
||||
'temp_user_id' => $tempUserId,
|
||||
'formal_user_id' => $formalUserId,
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success([
|
||||
'temp_user_id' => $tempUserId,
|
||||
'formal_user_id' => $formalUserId,
|
||||
'message' => '临时人已转为正式人,标签已重新计算',
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return ApiResponseHelper::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
308
Moncter/app/controller/TagCohortController.php
Normal file
308
Moncter/app/controller/TagCohortController.php
Normal file
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use app\repository\TagCohortRepository;
|
||||
use app\repository\UserTagRepository;
|
||||
use app\service\TagService;
|
||||
use app\repository\TagDefinitionRepository;
|
||||
use app\repository\UserProfileRepository;
|
||||
use app\repository\TagHistoryRepository;
|
||||
use app\service\TagRuleEngine\SimpleRuleEngine;
|
||||
use app\utils\ApiResponseHelper;
|
||||
use app\utils\LoggerHelper;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
class TagCohortController
|
||||
{
|
||||
/**
|
||||
* 获取人群快照列表
|
||||
*
|
||||
* GET /api/tag-cohorts
|
||||
*/
|
||||
public function list(Request $request): Response
|
||||
{
|
||||
try {
|
||||
LoggerHelper::logRequest('GET', '/api/tag-cohorts');
|
||||
|
||||
$page = (int)($request->get('page') ?? 1);
|
||||
$pageSize = (int)($request->get('page_size') ?? 20);
|
||||
|
||||
if ($page < 1) {
|
||||
$page = 1;
|
||||
}
|
||||
if ($pageSize < 1 || $pageSize > 100) {
|
||||
$pageSize = 20;
|
||||
}
|
||||
|
||||
$cohortRepo = new TagCohortRepository();
|
||||
|
||||
$total = $cohortRepo->newQuery()->count();
|
||||
|
||||
$cohorts = $cohortRepo->newQuery()
|
||||
->orderBy('created_at', 'desc')
|
||||
->skip(($page - 1) * $pageSize)
|
||||
->take($pageSize)
|
||||
->get();
|
||||
|
||||
$result = [];
|
||||
foreach ($cohorts as $cohort) {
|
||||
$result[] = [
|
||||
'cohort_id' => $cohort->cohort_id,
|
||||
'name' => $cohort->name,
|
||||
'user_count' => $cohort->user_count ?? 0,
|
||||
'created_at' => $cohort->created_at ? $cohort->created_at->format('Y-m-d H:i:s') : null,
|
||||
];
|
||||
}
|
||||
|
||||
LoggerHelper::logBusiness('get_tag_cohort_list', [
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success([
|
||||
'cohorts' => $result,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取人群快照详情
|
||||
*
|
||||
* GET /api/tag-cohorts/{cohort_id}
|
||||
*/
|
||||
public function detail(Request $request, string $cohortId): Response
|
||||
{
|
||||
try {
|
||||
LoggerHelper::logRequest('GET', "/api/tag-cohorts/{$cohortId}");
|
||||
|
||||
$cohortRepo = new TagCohortRepository();
|
||||
$cohort = $cohortRepo->newQuery()->where('cohort_id', $cohortId)->first();
|
||||
|
||||
if (!$cohort) {
|
||||
return ApiResponseHelper::error('人群快照不存在', 404, 404);
|
||||
}
|
||||
|
||||
$result = [
|
||||
'cohort_id' => $cohort->cohort_id,
|
||||
'name' => $cohort->name,
|
||||
'description' => $cohort->description ?? '',
|
||||
'conditions' => $cohort->conditions ?? [],
|
||||
'logic' => $cohort->logic ?? 'AND',
|
||||
'user_ids' => $cohort->user_ids ?? [],
|
||||
'user_count' => $cohort->user_count ?? 0,
|
||||
'created_at' => $cohort->created_at ? $cohort->created_at->format('Y-m-d H:i:s') : null,
|
||||
];
|
||||
|
||||
LoggerHelper::logBusiness('get_tag_cohort_detail', [
|
||||
'cohort_id' => $cohortId,
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success($result);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建人群快照
|
||||
*
|
||||
* POST /api/tag-cohorts
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
try {
|
||||
LoggerHelper::logRequest('POST', '/api/tag-cohorts');
|
||||
|
||||
$rawBody = $request->rawBody();
|
||||
|
||||
if (empty($rawBody)) {
|
||||
return ApiResponseHelper::error('请求体为空,请确保 Content-Type 为 application/json 并发送有效的 JSON 数据', 400);
|
||||
}
|
||||
|
||||
$body = json_decode($rawBody, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return ApiResponseHelper::error('JSON 格式错误: ' . json_last_error_msg(), 400);
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if (empty($body['name'])) {
|
||||
throw new \InvalidArgumentException('缺少必填字段:name');
|
||||
}
|
||||
|
||||
if (empty($body['conditions']) || !is_array($body['conditions'])) {
|
||||
throw new \InvalidArgumentException('缺少必填字段:conditions(必须为数组)');
|
||||
}
|
||||
|
||||
if (empty($body['user_ids']) || !is_array($body['user_ids'])) {
|
||||
throw new \InvalidArgumentException('缺少必填字段:user_ids(必须为数组)');
|
||||
}
|
||||
|
||||
// 使用标签筛选服务获取用户列表
|
||||
$tagService = new TagService(
|
||||
new TagDefinitionRepository(),
|
||||
new UserProfileRepository(),
|
||||
new UserTagRepository(),
|
||||
new TagHistoryRepository(),
|
||||
new SimpleRuleEngine()
|
||||
);
|
||||
|
||||
$conditions = $body['conditions'];
|
||||
$logic = $body['logic'] ?? 'AND';
|
||||
|
||||
// 筛选用户(获取所有用户,不包含用户信息)
|
||||
$filterResult = $tagService->filterUsersByTags(
|
||||
$conditions,
|
||||
$logic,
|
||||
1,
|
||||
10000, // 最多获取10000个用户
|
||||
false
|
||||
);
|
||||
|
||||
// 从返回结果中提取用户ID
|
||||
$userIds = [];
|
||||
if (isset($filterResult['users']) && is_array($filterResult['users'])) {
|
||||
foreach ($filterResult['users'] as $user) {
|
||||
if (isset($user['user_id'])) {
|
||||
$userIds[] = $user['user_id'];
|
||||
} elseif (is_string($user)) {
|
||||
// 如果直接返回的是用户ID字符串
|
||||
$userIds[] = $user;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果用户提供了 user_ids,使用提供的列表(优先级更高)
|
||||
if (!empty($body['user_ids']) && is_array($body['user_ids'])) {
|
||||
$userIds = $body['user_ids'];
|
||||
}
|
||||
|
||||
// 创建人群快照
|
||||
$cohortRepo = new TagCohortRepository();
|
||||
$cohort = new TagCohortRepository();
|
||||
$cohort->cohort_id = Uuid::uuid4()->toString();
|
||||
$cohort->name = $body['name'];
|
||||
$cohort->description = $body['description'] ?? '';
|
||||
$cohort->conditions = $conditions;
|
||||
$cohort->logic = $logic;
|
||||
$cohort->user_ids = $userIds;
|
||||
$cohort->user_count = count($userIds);
|
||||
$cohort->created_by = $body['created_by'] ?? 'system';
|
||||
$cohort->created_at = new \DateTime();
|
||||
$cohort->updated_at = new \DateTime();
|
||||
$cohort->save();
|
||||
|
||||
LoggerHelper::logBusiness('create_tag_cohort', [
|
||||
'cohort_id' => $cohort->cohort_id,
|
||||
'name' => $cohort->name,
|
||||
'user_count' => $cohort->user_count,
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success([
|
||||
'cohort_id' => $cohort->cohort_id,
|
||||
'name' => $cohort->name,
|
||||
'user_count' => $cohort->user_count,
|
||||
], '人群快照创建成功');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return ApiResponseHelper::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除人群快照
|
||||
*
|
||||
* DELETE /api/tag-cohorts/{cohort_id}
|
||||
*/
|
||||
public function delete(Request $request, string $cohortId): Response
|
||||
{
|
||||
try {
|
||||
LoggerHelper::logRequest('DELETE', "/api/tag-cohorts/{$cohortId}");
|
||||
|
||||
$cohortRepo = new TagCohortRepository();
|
||||
$cohort = $cohortRepo->newQuery()->where('cohort_id', $cohortId)->first();
|
||||
|
||||
if (!$cohort) {
|
||||
return ApiResponseHelper::error('人群快照不存在', 404, 404);
|
||||
}
|
||||
|
||||
$cohort->delete();
|
||||
|
||||
LoggerHelper::logBusiness('delete_tag_cohort', [
|
||||
'cohort_id' => $cohortId,
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success(null, '人群快照删除成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出人群快照
|
||||
*
|
||||
* POST /api/tag-cohorts/{cohort_id}/export
|
||||
*/
|
||||
public function export(Request $request, string $cohortId): Response
|
||||
{
|
||||
try {
|
||||
LoggerHelper::logRequest('POST', "/api/tag-cohorts/{$cohortId}/export");
|
||||
|
||||
$cohortRepo = new TagCohortRepository();
|
||||
$cohort = $cohortRepo->newQuery()->where('cohort_id', $cohortId)->first();
|
||||
|
||||
if (!$cohort) {
|
||||
return ApiResponseHelper::error('人群快照不存在', 404, 404);
|
||||
}
|
||||
|
||||
$userIds = $cohort->user_ids ?? [];
|
||||
$userProfileRepo = new UserProfileRepository();
|
||||
|
||||
// 获取用户信息
|
||||
$users = [];
|
||||
foreach ($userIds as $userId) {
|
||||
$user = $userProfileRepo->findByUserId($userId);
|
||||
if ($user) {
|
||||
$users[] = [
|
||||
'user_id' => $user->user_id,
|
||||
'phone' => $user->phone ?? '',
|
||||
'name' => $user->name ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 生成 CSV 内容
|
||||
$csvContent = "用户ID,手机号,姓名\n";
|
||||
foreach ($users as $user) {
|
||||
$csvContent .= sprintf(
|
||||
"%s,%s,%s\n",
|
||||
$user['user_id'],
|
||||
$user['phone'],
|
||||
$user['name']
|
||||
);
|
||||
}
|
||||
|
||||
LoggerHelper::logBusiness('export_tag_cohort', [
|
||||
'cohort_id' => $cohortId,
|
||||
'user_count' => count($users),
|
||||
]);
|
||||
|
||||
// 返回 CSV 文件
|
||||
return response($csvContent)
|
||||
->header('Content-Type', 'text/csv; charset=utf-8')
|
||||
->header('Content-Disposition', "attachment; filename=\"cohort_{$cohortId}.csv\"");
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
521
Moncter/app/controller/TagController.php
Normal file
521
Moncter/app/controller/TagController.php
Normal file
@@ -0,0 +1,521 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use app\repository\TagDefinitionRepository;
|
||||
use app\repository\UserProfileRepository;
|
||||
use app\repository\UserTagRepository;
|
||||
use app\repository\TagHistoryRepository;
|
||||
use app\service\TagService;
|
||||
use app\service\TagRuleEngine\SimpleRuleEngine;
|
||||
use app\utils\ApiResponseHelper;
|
||||
use app\utils\DataMaskingHelper;
|
||||
use app\utils\LoggerHelper;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
class TagController
|
||||
{
|
||||
/**
|
||||
* 查询用户的标签列表
|
||||
*
|
||||
* GET /api/users/{user_id}/tags
|
||||
*/
|
||||
public function listByUser(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 从请求路径中解析 user_id
|
||||
$path = $request->path();
|
||||
if (preg_match('#/api/users/([^/]+)/tags#', $path, $matches)) {
|
||||
$userId = $matches[1];
|
||||
} else {
|
||||
// 如果路径解析失败,尝试从查询参数获取
|
||||
$userId = $request->get('user_id');
|
||||
if (!$userId) {
|
||||
throw new \InvalidArgumentException('缺少 user_id 参数');
|
||||
}
|
||||
}
|
||||
|
||||
LoggerHelper::logRequest('GET', $path, ['user_id' => $userId]);
|
||||
|
||||
$userTagRepo = new UserTagRepository();
|
||||
$tagDefRepo = new TagDefinitionRepository();
|
||||
|
||||
// 查询用户的所有标签
|
||||
$userTags = $userTagRepo->newQuery()
|
||||
->where('user_id', $userId)
|
||||
->get();
|
||||
|
||||
// 关联标签定义信息
|
||||
$result = [];
|
||||
foreach ($userTags as $userTag) {
|
||||
$tagDef = $tagDefRepo->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,
|
||||
];
|
||||
}
|
||||
|
||||
LoggerHelper::logBusiness('get_user_tags', [
|
||||
'user_id' => $userId,
|
||||
'tag_count' => count($result),
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success([
|
||||
'user_id' => $userId,
|
||||
'tags' => $result,
|
||||
'count' => count($result),
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return ApiResponseHelper::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新/计算用户标签
|
||||
*
|
||||
* PUT /api/users/{user_id}/tags
|
||||
*/
|
||||
public function calculate(Request $request): Response
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
// 从请求路径中解析 user_id
|
||||
$path = $request->path();
|
||||
if (preg_match('#/api/users/([^/]+)/tags#', $path, $matches)) {
|
||||
$userId = $matches[1];
|
||||
} else {
|
||||
// 如果路径解析失败,尝试从查询参数获取
|
||||
$userId = $request->get('user_id');
|
||||
if (!$userId) {
|
||||
throw new \InvalidArgumentException('缺少 user_id 参数');
|
||||
}
|
||||
}
|
||||
|
||||
LoggerHelper::logRequest('PUT', $path, ['user_id' => $userId]);
|
||||
|
||||
$tagService = new TagService(
|
||||
new TagDefinitionRepository(),
|
||||
new UserProfileRepository(),
|
||||
new UserTagRepository(),
|
||||
new TagHistoryRepository(),
|
||||
new SimpleRuleEngine()
|
||||
);
|
||||
|
||||
$tags = $tagService->calculateTags($userId);
|
||||
|
||||
$duration = microtime(true) - $startTime;
|
||||
LoggerHelper::logBusiness('calculate_tags', [
|
||||
'user_id' => $userId,
|
||||
'updated_count' => count($tags),
|
||||
], 'info');
|
||||
LoggerHelper::logPerformance('tag_calculation', $duration, [
|
||||
'user_id' => $userId,
|
||||
'tag_count' => count($tags),
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success([
|
||||
'user_id' => $userId,
|
||||
'updated_tags' => $tags,
|
||||
'count' => count($tags),
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return ApiResponseHelper::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户的指定标签
|
||||
*
|
||||
* DELETE /api/users/{user_id}/tags/{tag_id}
|
||||
*/
|
||||
public function destroy(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 从请求路径中解析 user_id 和 tag_id
|
||||
$path = $request->path();
|
||||
if (preg_match('#/api/users/([^/]+)/tags/([^/]+)#', $path, $matches)) {
|
||||
$userId = $matches[1];
|
||||
$tagId = $matches[2];
|
||||
} else {
|
||||
throw new \InvalidArgumentException('缺少 user_id 或 tag_id 参数');
|
||||
}
|
||||
|
||||
LoggerHelper::logRequest('DELETE', $path, ['user_id' => $userId, 'tag_id' => $tagId]);
|
||||
|
||||
$tagService = new TagService(
|
||||
new TagDefinitionRepository(),
|
||||
new UserProfileRepository(),
|
||||
new UserTagRepository(),
|
||||
new TagHistoryRepository(),
|
||||
new SimpleRuleEngine()
|
||||
);
|
||||
|
||||
$deleted = $tagService->deleteUserTag($userId, $tagId);
|
||||
|
||||
if (!$deleted) {
|
||||
return ApiResponseHelper::error('标签不存在', 404, 404);
|
||||
}
|
||||
|
||||
LoggerHelper::logBusiness('tag_deleted', [
|
||||
'user_id' => $userId,
|
||||
'tag_id' => $tagId,
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success(null, '标签删除成功');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return ApiResponseHelper::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据标签筛选用户
|
||||
*
|
||||
* POST /api/tags/filter
|
||||
*/
|
||||
public function filter(Request $request): Response
|
||||
{
|
||||
try {
|
||||
LoggerHelper::logRequest('POST', '/api/tags/filter');
|
||||
|
||||
$rawBody = $request->rawBody();
|
||||
|
||||
if (empty($rawBody)) {
|
||||
return ApiResponseHelper::error('请求体为空,请确保 Content-Type 为 application/json 并发送有效的 JSON 数据', 400);
|
||||
}
|
||||
|
||||
$body = json_decode($rawBody, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return ApiResponseHelper::error('JSON 格式错误: ' . json_last_error_msg(), 400);
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if (empty($body['tag_conditions']) || !is_array($body['tag_conditions'])) {
|
||||
throw new \InvalidArgumentException('缺少必填字段:tag_conditions(必须为数组)');
|
||||
}
|
||||
|
||||
// 验证条件格式
|
||||
foreach ($body['tag_conditions'] as $condition) {
|
||||
if (!isset($condition['tag_code']) || !isset($condition['operator']) || !isset($condition['value'])) {
|
||||
throw new \InvalidArgumentException('每个条件必须包含 tag_code、operator 和 value 字段');
|
||||
}
|
||||
}
|
||||
|
||||
$tagService = new TagService(
|
||||
new TagDefinitionRepository(),
|
||||
new UserProfileRepository(),
|
||||
new UserTagRepository(),
|
||||
new TagHistoryRepository(),
|
||||
new SimpleRuleEngine()
|
||||
);
|
||||
|
||||
$conditions = $body['tag_conditions'];
|
||||
$logic = $body['logic'] ?? 'AND';
|
||||
$page = (int)($body['page'] ?? 1);
|
||||
$pageSize = (int)($body['page_size'] ?? 20);
|
||||
$includeUserInfo = (bool)($body['include_user_info'] ?? false);
|
||||
|
||||
if ($page < 1) {
|
||||
$page = 1;
|
||||
}
|
||||
if ($pageSize < 1 || $pageSize > 100) {
|
||||
$pageSize = 20;
|
||||
}
|
||||
|
||||
$result = $tagService->filterUsersByTags(
|
||||
$conditions,
|
||||
$logic,
|
||||
$page,
|
||||
$pageSize,
|
||||
$includeUserInfo
|
||||
);
|
||||
|
||||
// 对返回的用户信息进行脱敏处理
|
||||
if ($includeUserInfo && isset($result['users']) && is_array($result['users'])) {
|
||||
foreach ($result['users'] as &$user) {
|
||||
$user = DataMaskingHelper::maskArray($user, ['phone', 'email']);
|
||||
}
|
||||
unset($user);
|
||||
}
|
||||
|
||||
LoggerHelper::logBusiness('filter_users_by_tags', [
|
||||
'conditions_count' => count($conditions),
|
||||
'logic' => $logic,
|
||||
'result_count' => $result['total'] ?? 0,
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success($result);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return ApiResponseHelper::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量初始化标签定义
|
||||
*
|
||||
* POST /api/tag-definitions/batch
|
||||
*/
|
||||
public function init(Request $request): Response
|
||||
{
|
||||
try {
|
||||
LoggerHelper::logRequest('POST', '/api/tag-definitions/batch');
|
||||
|
||||
$initService = new \app\service\TagInitService(
|
||||
new TagDefinitionRepository()
|
||||
);
|
||||
|
||||
$initService->initBasicTags();
|
||||
|
||||
LoggerHelper::logBusiness('init_tags', []);
|
||||
|
||||
return ApiResponseHelper::success([
|
||||
'message' => '标签初始化完成',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标签统计信息
|
||||
*
|
||||
* GET /api/tags/statistics
|
||||
*/
|
||||
public function statistics(Request $request): Response
|
||||
{
|
||||
try {
|
||||
LoggerHelper::logRequest('GET', '/api/tags/statistics');
|
||||
|
||||
$tagId = $request->get('tag_id');
|
||||
$startDate = $request->get('start_date');
|
||||
$endDate = $request->get('end_date');
|
||||
|
||||
$userTagRepo = new UserTagRepository();
|
||||
$tagDefRepo = new TagDefinitionRepository();
|
||||
|
||||
$result = [
|
||||
'value_distribution' => [],
|
||||
'trend_data' => [],
|
||||
'coverage_stats' => [],
|
||||
];
|
||||
|
||||
// 如果指定了 tag_id,统计该标签的值分布
|
||||
if ($tagId) {
|
||||
$tagDef = $tagDefRepo->newQuery()->where('tag_id', $tagId)->first();
|
||||
if ($tagDef) {
|
||||
// 统计标签值分布
|
||||
$userTags = $userTagRepo->newQuery()
|
||||
->where('tag_id', $tagId)
|
||||
->get(['tag_value']);
|
||||
|
||||
$valueCounts = [];
|
||||
foreach ($userTags as $userTag) {
|
||||
$value = (string)$userTag->tag_value;
|
||||
if (!isset($valueCounts[$value])) {
|
||||
$valueCounts[$value] = 0;
|
||||
}
|
||||
$valueCounts[$value]++;
|
||||
}
|
||||
|
||||
// 按数量排序
|
||||
arsort($valueCounts);
|
||||
$valueCounts = array_slice($valueCounts, 0, 20, true);
|
||||
|
||||
foreach ($valueCounts as $value => $count) {
|
||||
$result['value_distribution'][] = [
|
||||
'value' => $value,
|
||||
'count' => $count
|
||||
];
|
||||
}
|
||||
|
||||
// 统计标签覆盖度
|
||||
$totalUsers = $userTagRepo->newQuery()
|
||||
->distinct('user_id')
|
||||
->count();
|
||||
|
||||
$taggedUsers = $userTagRepo->newQuery()
|
||||
->where('tag_id', $tagId)
|
||||
->distinct('user_id')
|
||||
->count();
|
||||
|
||||
$result['coverage_stats'][] = [
|
||||
'tag_id' => $tagId,
|
||||
'tag_name' => $tagDef->tag_name ?? '',
|
||||
'total_users' => $totalUsers,
|
||||
'tagged_users' => $taggedUsers,
|
||||
'coverage_rate' => $totalUsers > 0 ? round($taggedUsers / $totalUsers * 100, 2) : 0
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// 统计所有标签的覆盖度
|
||||
$tagDefs = $tagDefRepo->newQuery()->where('status', 1)->get();
|
||||
$totalUsers = $userTagRepo->newQuery()->distinct('user_id')->count();
|
||||
|
||||
foreach ($tagDefs as $tagDef) {
|
||||
$taggedUsers = $userTagRepo->newQuery()
|
||||
->where('tag_id', $tagDef->tag_id)
|
||||
->distinct('user_id')
|
||||
->count();
|
||||
|
||||
$result['coverage_stats'][] = [
|
||||
'tag_id' => $tagDef->tag_id,
|
||||
'tag_name' => $tagDef->tag_name ?? '',
|
||||
'total_users' => $totalUsers,
|
||||
'tagged_users' => $taggedUsers,
|
||||
'coverage_rate' => $totalUsers > 0 ? round($taggedUsers / $totalUsers * 100, 2) : 0
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 趋势数据(如果有时间范围)
|
||||
if ($startDate && $endDate) {
|
||||
$historyRepo = new TagHistoryRepository();
|
||||
$start = new \DateTime($startDate);
|
||||
$end = new \DateTime($endDate);
|
||||
|
||||
// 按日期统计标签变更次数
|
||||
$trendData = [];
|
||||
$current = clone $start;
|
||||
while ($current <= $end) {
|
||||
$dateStr = $current->format('Y-m-d');
|
||||
$nextDay = clone $current;
|
||||
$nextDay->modify('+1 day');
|
||||
|
||||
$count = $historyRepo->newQuery()
|
||||
->where('change_time', '>=', $current)
|
||||
->where('change_time', '<', $nextDay)
|
||||
->count();
|
||||
|
||||
$trendData[] = [
|
||||
'date' => $dateStr,
|
||||
'count' => $count
|
||||
];
|
||||
|
||||
$current->modify('+1 day');
|
||||
}
|
||||
|
||||
$result['trend_data'] = $trendData;
|
||||
}
|
||||
|
||||
LoggerHelper::logBusiness('get_tag_statistics', [
|
||||
'tag_id' => $tagId,
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success($result);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标签历史记录
|
||||
*
|
||||
* GET /api/tags/history
|
||||
*/
|
||||
public function history(Request $request): Response
|
||||
{
|
||||
try {
|
||||
LoggerHelper::logRequest('GET', '/api/tags/history');
|
||||
|
||||
$userId = $request->get('user_id');
|
||||
$tagId = $request->get('tag_id');
|
||||
$startDate = $request->get('start_date');
|
||||
$endDate = $request->get('end_date');
|
||||
$page = (int)($request->get('page') ?? 1);
|
||||
$pageSize = (int)($request->get('page_size') ?? 20);
|
||||
|
||||
if ($page < 1) {
|
||||
$page = 1;
|
||||
}
|
||||
if ($pageSize < 1 || $pageSize > 100) {
|
||||
$pageSize = 20;
|
||||
}
|
||||
|
||||
$historyRepo = new TagHistoryRepository();
|
||||
$tagDefRepo = new TagDefinitionRepository();
|
||||
|
||||
$query = $historyRepo->newQuery();
|
||||
|
||||
if ($userId) {
|
||||
$query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
if ($tagId) {
|
||||
$query->where('tag_id', $tagId);
|
||||
}
|
||||
|
||||
if ($startDate) {
|
||||
$query->where('change_time', '>=', new \DateTime($startDate));
|
||||
}
|
||||
|
||||
if ($endDate) {
|
||||
$endDateTime = new \DateTime($endDate);
|
||||
$endDateTime->modify('+1 day');
|
||||
$query->where('change_time', '<', $endDateTime);
|
||||
}
|
||||
|
||||
$total = $query->count();
|
||||
|
||||
$histories = $query->orderBy('change_time', 'desc')
|
||||
->skip(($page - 1) * $pageSize)
|
||||
->take($pageSize)
|
||||
->get();
|
||||
|
||||
$items = [];
|
||||
foreach ($histories as $history) {
|
||||
$tagDef = $tagDefRepo->newQuery()->where('tag_id', $history->tag_id)->first();
|
||||
|
||||
$items[] = [
|
||||
'user_id' => $history->user_id,
|
||||
'tag_id' => $history->tag_id,
|
||||
'tag_name' => $tagDef ? $tagDef->tag_name : null,
|
||||
'old_value' => $history->old_value,
|
||||
'new_value' => $history->new_value,
|
||||
'change_reason' => $history->change_reason,
|
||||
'change_time' => $history->change_time ? $history->change_time->format('Y-m-d H:i:s') : null,
|
||||
'operator' => $history->operator,
|
||||
];
|
||||
}
|
||||
|
||||
LoggerHelper::logBusiness('get_tag_history', [
|
||||
'user_id' => $userId,
|
||||
'tag_id' => $tagId,
|
||||
'total' => $total,
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success([
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
155
Moncter/app/controller/TagDefinitionController.php
Normal file
155
Moncter/app/controller/TagDefinitionController.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use app\repository\TagDefinitionRepository;
|
||||
use app\utils\ApiResponseHelper;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 标签定义管理控制器
|
||||
*/
|
||||
class TagDefinitionController
|
||||
{
|
||||
/**
|
||||
* 获取标签定义列表
|
||||
*
|
||||
* GET /api/tag-definitions
|
||||
*/
|
||||
public function list(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$repo = new TagDefinitionRepository();
|
||||
$query = $repo->query();
|
||||
|
||||
// 筛选条件
|
||||
if ($request->get('category')) {
|
||||
$query->where('category', $request->get('category'));
|
||||
}
|
||||
if ($request->get('status')) {
|
||||
$query->where('status', $request->get('status'));
|
||||
}
|
||||
if ($request->get('name')) {
|
||||
$query->where('tag_name', 'like', '%' . $request->get('name') . '%');
|
||||
}
|
||||
|
||||
$page = (int)($request->get('page', 1));
|
||||
$pageSize = (int)($request->get('page_size', 20));
|
||||
|
||||
$total = $query->count();
|
||||
$definitions = $query->orderBy('created_at', 'desc')
|
||||
->skip(($page - 1) * $pageSize)
|
||||
->take($pageSize)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return ApiResponseHelper::success([
|
||||
'definitions' => $definitions,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
'total_pages' => ceil($total / $pageSize),
|
||||
], '查询成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标签定义详情
|
||||
*
|
||||
* GET /api/tag-definitions/{tag_id}
|
||||
*/
|
||||
public function detail(Request $request, string $tagId): Response
|
||||
{
|
||||
try {
|
||||
$repo = new TagDefinitionRepository();
|
||||
$definition = $repo->find($tagId);
|
||||
|
||||
if (!$definition) {
|
||||
return ApiResponseHelper::error('标签定义不存在', 404);
|
||||
}
|
||||
|
||||
return ApiResponseHelper::success($definition->toArray(), '查询成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建标签定义
|
||||
*
|
||||
* POST /api/tag-definitions
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$data = $request->post();
|
||||
|
||||
$requiredFields = ['tag_code', 'tag_name', 'category'];
|
||||
foreach ($requiredFields as $field) {
|
||||
if (empty($data[$field])) {
|
||||
return ApiResponseHelper::error("缺少必填字段: {$field}", 400);
|
||||
}
|
||||
}
|
||||
|
||||
$repo = new TagDefinitionRepository();
|
||||
$definition = $repo->create($data);
|
||||
|
||||
return ApiResponseHelper::success($definition->toArray(), '创建成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新标签定义
|
||||
*
|
||||
* PUT /api/tag-definitions/{tag_id}
|
||||
*/
|
||||
public function update(Request $request, string $tagId): Response
|
||||
{
|
||||
try {
|
||||
$data = $request->post();
|
||||
|
||||
$repo = new TagDefinitionRepository();
|
||||
$definition = $repo->find($tagId);
|
||||
|
||||
if (!$definition) {
|
||||
return ApiResponseHelper::error('标签定义不存在', 404);
|
||||
}
|
||||
|
||||
$definition->fill($data);
|
||||
$definition->save();
|
||||
|
||||
return ApiResponseHelper::success($definition->toArray(), '更新成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除标签定义
|
||||
*
|
||||
* DELETE /api/tag-definitions/{tag_id}
|
||||
*/
|
||||
public function delete(Request $request, string $tagId): Response
|
||||
{
|
||||
try {
|
||||
$repo = new TagDefinitionRepository();
|
||||
$definition = $repo->find($tagId);
|
||||
|
||||
if (!$definition) {
|
||||
return ApiResponseHelper::error('标签定义不存在', 404);
|
||||
}
|
||||
|
||||
$definition->delete();
|
||||
|
||||
return ApiResponseHelper::success(null, '删除成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
227
Moncter/app/controller/TagTaskController.php
Normal file
227
Moncter/app/controller/TagTaskController.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
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\ApiResponseHelper;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 标签任务管理控制器
|
||||
*/
|
||||
class TagTaskController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
// 初始化服务(使用依赖注入或直接创建)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建标签任务
|
||||
*
|
||||
* POST /api/tag-tasks
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$data = $request->post();
|
||||
|
||||
$requiredFields = ['name', 'task_type'];
|
||||
foreach ($requiredFields as $field) {
|
||||
if (empty($data[$field])) {
|
||||
return ApiResponseHelper::error("缺少必填字段: {$field}", 400);
|
||||
}
|
||||
}
|
||||
|
||||
$service = $this->getService();
|
||||
$task = $service->createTask($data);
|
||||
|
||||
return ApiResponseHelper::success($task, '任务创建成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务
|
||||
*
|
||||
* PUT /api/tag-tasks/{task_id}
|
||||
*/
|
||||
public function update(Request $request, string $taskId): Response
|
||||
{
|
||||
try {
|
||||
$data = $request->post();
|
||||
|
||||
$service = $this->getService();
|
||||
$result = $service->updateTask($taskId, $data);
|
||||
|
||||
return ApiResponseHelper::success(null, '任务更新成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除任务
|
||||
*
|
||||
* DELETE /api/tag-tasks/{task_id}
|
||||
*/
|
||||
public function delete(Request $request, string $taskId): Response
|
||||
{
|
||||
try {
|
||||
$service = $this->getService();
|
||||
$result = $service->deleteTask($taskId);
|
||||
|
||||
return ApiResponseHelper::success(null, '任务删除成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动任务
|
||||
*
|
||||
* POST /api/tag-tasks/{task_id}/start
|
||||
*/
|
||||
public function start(Request $request, string $taskId): Response
|
||||
{
|
||||
try {
|
||||
$service = $this->getService();
|
||||
$result = $service->startTask($taskId);
|
||||
|
||||
return ApiResponseHelper::success(null, '任务启动成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停任务
|
||||
*
|
||||
* POST /api/tag-tasks/{task_id}/pause
|
||||
*/
|
||||
public function pause(Request $request, string $taskId): Response
|
||||
{
|
||||
try {
|
||||
$service = $this->getService();
|
||||
$result = $service->pauseTask($taskId);
|
||||
|
||||
return ApiResponseHelper::success(null, '任务暂停成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止任务
|
||||
*
|
||||
* POST /api/tag-tasks/{task_id}/stop
|
||||
*/
|
||||
public function stop(Request $request, string $taskId): Response
|
||||
{
|
||||
try {
|
||||
$service = $this->getService();
|
||||
$result = $service->stopTask($taskId);
|
||||
|
||||
return ApiResponseHelper::success(null, '任务停止成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务列表
|
||||
*
|
||||
* GET /api/tag-tasks
|
||||
*/
|
||||
public function list(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$filters = [
|
||||
'status' => $request->get('status'),
|
||||
'task_type' => $request->get('task_type'),
|
||||
'name' => $request->get('name'),
|
||||
];
|
||||
|
||||
$page = (int)($request->get('page', 1));
|
||||
$pageSize = (int)($request->get('page_size', 20));
|
||||
|
||||
$service = $this->getService();
|
||||
$result = $service->getTaskList($filters, $page, $pageSize);
|
||||
|
||||
return ApiResponseHelper::success($result, '查询成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务详情
|
||||
*
|
||||
* GET /api/tag-tasks/{task_id}
|
||||
*/
|
||||
public function detail(Request $request, string $taskId): Response
|
||||
{
|
||||
try {
|
||||
$service = $this->getService();
|
||||
$task = $service->getTask($taskId);
|
||||
|
||||
if ($task === null) {
|
||||
return ApiResponseHelper::error('任务不存在', 404);
|
||||
}
|
||||
|
||||
return ApiResponseHelper::success($task, '查询成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务执行记录
|
||||
*
|
||||
* GET /api/tag-tasks/{task_id}/executions
|
||||
*/
|
||||
public function executions(Request $request, string $taskId): Response
|
||||
{
|
||||
try {
|
||||
$page = (int)($request->get('page', 1));
|
||||
$pageSize = (int)($request->get('page_size', 20));
|
||||
|
||||
$service = $this->getService();
|
||||
$result = $service->getExecutions($taskId, $page, $pageSize);
|
||||
|
||||
return ApiResponseHelper::success($result, '查询成功');
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务实例
|
||||
*/
|
||||
private function getService(): TagTaskService
|
||||
{
|
||||
return new TagTaskService(
|
||||
new TagTaskRepository(),
|
||||
new TagTaskExecutionRepository(),
|
||||
new UserProfileRepository(),
|
||||
new TagService(
|
||||
new TagDefinitionRepository(),
|
||||
new UserProfileRepository(),
|
||||
new UserTagRepository(),
|
||||
new TagHistoryRepository(),
|
||||
new SimpleRuleEngine()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
557
Moncter/app/controller/UserController.php
Normal file
557
Moncter/app/controller/UserController.php
Normal file
@@ -0,0 +1,557 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use app\repository\UserProfileRepository;
|
||||
use app\service\UserService;
|
||||
use app\utils\ApiResponseHelper;
|
||||
use app\utils\DataMaskingHelper;
|
||||
use app\utils\LoggerHelper;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
class UserController
|
||||
{
|
||||
/**
|
||||
* 创建用户
|
||||
*
|
||||
* POST /api/users
|
||||
*
|
||||
* 请求体示例:
|
||||
* {
|
||||
* "id_card": "110101199001011234",
|
||||
* "id_card_type": "身份证",
|
||||
* "name": "张三",
|
||||
* "phone": "13800138000",
|
||||
* "email": "zhangsan@example.com",
|
||||
* "gender": 1,
|
||||
* "birthday": "1990-01-01",
|
||||
* "address": "北京市朝阳区"
|
||||
* }
|
||||
*/
|
||||
public function store(Request $request): Response
|
||||
{
|
||||
try {
|
||||
LoggerHelper::logRequest('POST', '/api/users');
|
||||
|
||||
$rawBody = $request->rawBody();
|
||||
|
||||
// 调试:记录原始请求体
|
||||
if (empty($rawBody)) {
|
||||
return ApiResponseHelper::error('请求体为空,请确保 Content-Type 为 application/json 并发送有效的 JSON 数据', 400);
|
||||
}
|
||||
|
||||
$body = json_decode($rawBody, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$errorMsg = '请求体必须是有效的 JSON 格式';
|
||||
$jsonError = json_last_error_msg();
|
||||
if ($jsonError) {
|
||||
$errorMsg .= ': ' . $jsonError;
|
||||
}
|
||||
// 开发环境输出更多调试信息
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
$errorMsg .= ' (原始请求体: ' . substr($rawBody, 0, 200) . ')';
|
||||
}
|
||||
return ApiResponseHelper::error($errorMsg, 400);
|
||||
}
|
||||
|
||||
$userService = new UserService(new UserProfileRepository());
|
||||
$result = $userService->createUser($body);
|
||||
|
||||
return ApiResponseHelper::success($result, '用户创建成功');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return ApiResponseHelper::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户信息
|
||||
*
|
||||
* GET /api/users/{user_id}?decrypt_id_card=1
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
public function show(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 从请求路径中解析 user_id
|
||||
$path = $request->path();
|
||||
if (preg_match('#/api/users/([^/]+)#', $path, $matches)) {
|
||||
$userId = $matches[1];
|
||||
} else {
|
||||
$userId = $request->get('user_id');
|
||||
if (!$userId) {
|
||||
throw new \InvalidArgumentException('缺少 user_id 参数');
|
||||
}
|
||||
}
|
||||
|
||||
LoggerHelper::logRequest('GET', $path, ['user_id' => $userId]);
|
||||
|
||||
// 检查是否需要解密身份证(需要权限控制,这里简单用参数控制)
|
||||
$decryptIdCard = (bool)$request->get('decrypt_id_card', false);
|
||||
|
||||
$userService = new UserService(new UserProfileRepository());
|
||||
$user = $userService->getUserById($userId, $decryptIdCard);
|
||||
|
||||
if (!$user) {
|
||||
return ApiResponseHelper::error('用户不存在', 404, 404);
|
||||
}
|
||||
|
||||
// 如果不需要解密身份证,对敏感字段进行脱敏
|
||||
if (!$decryptIdCard) {
|
||||
$user = DataMaskingHelper::maskArray($user, ['phone', 'email']);
|
||||
}
|
||||
|
||||
LoggerHelper::logBusiness('get_user_info', [
|
||||
'user_id' => $userId,
|
||||
'decrypt_id_card' => $decryptIdCard,
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success($user);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return ApiResponseHelper::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
*
|
||||
* PUT /api/users/{user_id}
|
||||
*
|
||||
* 请求体示例:
|
||||
* {
|
||||
* "name": "张三",
|
||||
* "phone": "13800138000",
|
||||
* "email": "zhangsan@example.com",
|
||||
* "gender": 1,
|
||||
* "birthday": "1990-01-01",
|
||||
* "address": "北京市朝阳区",
|
||||
* "status": 0
|
||||
* }
|
||||
*/
|
||||
public function update(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 从请求路径中解析 user_id
|
||||
$path = $request->path();
|
||||
if (preg_match('#/api/users/([^/]+)#', $path, $matches)) {
|
||||
$userId = $matches[1];
|
||||
} else {
|
||||
$userId = $request->get('user_id');
|
||||
if (!$userId) {
|
||||
throw new \InvalidArgumentException('缺少 user_id 参数');
|
||||
}
|
||||
}
|
||||
|
||||
LoggerHelper::logRequest('PUT', $path, ['user_id' => $userId]);
|
||||
|
||||
$rawBody = $request->rawBody();
|
||||
|
||||
// 调试:记录原始请求体
|
||||
if (empty($rawBody)) {
|
||||
return ApiResponseHelper::error('请求体为空,请确保 Content-Type 为 application/json 并发送有效的 JSON 数据', 400);
|
||||
}
|
||||
|
||||
$body = json_decode($rawBody, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$errorMsg = '请求体必须是有效的 JSON 格式';
|
||||
$jsonError = json_last_error_msg();
|
||||
if ($jsonError) {
|
||||
$errorMsg .= ': ' . $jsonError;
|
||||
}
|
||||
// 开发环境输出更多调试信息
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
$errorMsg .= ' (原始请求体: ' . substr($rawBody, 0, 200) . ')';
|
||||
}
|
||||
return ApiResponseHelper::error($errorMsg, 400);
|
||||
}
|
||||
|
||||
if (empty($body)) {
|
||||
return ApiResponseHelper::error('请求体不能为空', 400);
|
||||
}
|
||||
|
||||
$userService = new UserService(new UserProfileRepository());
|
||||
$result = $userService->updateUser($userId, $body);
|
||||
|
||||
// 脱敏处理
|
||||
$result = DataMaskingHelper::maskArray($result, ['phone', 'email']);
|
||||
|
||||
return ApiResponseHelper::success($result, '用户更新成功');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return ApiResponseHelper::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密身份证号
|
||||
*
|
||||
* GET /api/users/{user_id}/decrypt-id-card
|
||||
*/
|
||||
public function decryptIdCard(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 从请求路径中解析 user_id
|
||||
$path = $request->path();
|
||||
if (preg_match('#/api/users/([^/]+)/decrypt-id-card#', $path, $matches)) {
|
||||
$userId = $matches[1];
|
||||
} else {
|
||||
$userId = $request->get('user_id');
|
||||
if (!$userId) {
|
||||
throw new \InvalidArgumentException('缺少 user_id 参数');
|
||||
}
|
||||
}
|
||||
|
||||
LoggerHelper::logRequest('GET', $path, ['user_id' => $userId]);
|
||||
|
||||
$userService = new UserService(new UserProfileRepository());
|
||||
$user = $userService->getUserById($userId, true); // 强制解密
|
||||
|
||||
if (!$user) {
|
||||
return ApiResponseHelper::error('用户不存在', 404, 404);
|
||||
}
|
||||
|
||||
LoggerHelper::logBusiness('decrypt_id_card', [
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success([
|
||||
'user_id' => $user['user_id'],
|
||||
'id_card' => $user['id_card'] ?? ''
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return ApiResponseHelper::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户(软删除)
|
||||
*
|
||||
* DELETE /api/users/{user_id}
|
||||
*/
|
||||
public function destroy(Request $request): Response
|
||||
{
|
||||
try {
|
||||
// 从请求路径中解析 user_id
|
||||
$path = $request->path();
|
||||
if (preg_match('#/api/users/([^/]+)#', $path, $matches)) {
|
||||
$userId = $matches[1];
|
||||
} else {
|
||||
$userId = $request->get('user_id');
|
||||
if (!$userId) {
|
||||
throw new \InvalidArgumentException('缺少 user_id 参数');
|
||||
}
|
||||
}
|
||||
|
||||
LoggerHelper::logRequest('DELETE', $path, ['user_id' => $userId]);
|
||||
|
||||
$userService = new UserService(new UserProfileRepository());
|
||||
$userService->deleteUser($userId);
|
||||
|
||||
return ApiResponseHelper::success(null, '用户删除成功');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return ApiResponseHelper::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索用户(支持多种搜索条件组合)
|
||||
*
|
||||
* POST /api/users/search
|
||||
*
|
||||
* 支持以下搜索方式:
|
||||
* 1. 基础字段搜索:姓名、手机号、邮箱、身份证号等
|
||||
* 2. 标签筛选:根据用户标签筛选
|
||||
* 3. 组合搜索:基础字段 + 标签筛选
|
||||
*
|
||||
* 请求体示例1(姓名模糊搜索):
|
||||
* {
|
||||
* "name": "张三",
|
||||
* "page": 1,
|
||||
* "page_size": 20
|
||||
* }
|
||||
*
|
||||
* 请求体示例2(组合搜索:姓名 + 手机号):
|
||||
* {
|
||||
* "name": "张",
|
||||
* "phone": "138",
|
||||
* "page": 1,
|
||||
* "page_size": 20
|
||||
* }
|
||||
*
|
||||
* 请求体示例3(根据标签筛选):
|
||||
* {
|
||||
* "tag_conditions": [
|
||||
* {
|
||||
* "tag_code": "high_consumer",
|
||||
* "operator": "=",
|
||||
* "value": "high"
|
||||
* }
|
||||
* ],
|
||||
* "logic": "AND",
|
||||
* "page": 1,
|
||||
* "page_size": 20
|
||||
* }
|
||||
*
|
||||
* 请求体示例4(组合搜索:基础字段 + 标签):
|
||||
* {
|
||||
* "name": "张",
|
||||
* "min_total_amount": 1000,
|
||||
* "tag_conditions": [
|
||||
* {
|
||||
* "tag_code": "active_user",
|
||||
* "operator": "=",
|
||||
* "value": "active"
|
||||
* }
|
||||
* ],
|
||||
* "page": 1,
|
||||
* "page_size": 20
|
||||
* }
|
||||
*/
|
||||
public function search(Request $request): Response
|
||||
{
|
||||
try {
|
||||
LoggerHelper::logRequest('POST', '/api/users/search');
|
||||
|
||||
$rawBody = $request->rawBody();
|
||||
|
||||
// 调试:记录原始请求体
|
||||
if (empty($rawBody)) {
|
||||
return ApiResponseHelper::error('请求体为空,请确保 Content-Type 为 application/json 并发送有效的 JSON 数据', 400);
|
||||
}
|
||||
|
||||
$body = json_decode($rawBody, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$errorMsg = '请求体必须是有效的 JSON 格式';
|
||||
$jsonError = json_last_error_msg();
|
||||
if ($jsonError) {
|
||||
$errorMsg .= ': ' . $jsonError;
|
||||
}
|
||||
// 开发环境输出更多调试信息
|
||||
if (getenv('APP_DEBUG') === 'true') {
|
||||
$errorMsg .= ' (原始请求体: ' . substr($rawBody, 0, 200) . ')';
|
||||
}
|
||||
return ApiResponseHelper::error($errorMsg, 400);
|
||||
}
|
||||
|
||||
$page = (int)($body['page'] ?? 1);
|
||||
$pageSize = (int)($body['page_size'] ?? 20);
|
||||
|
||||
if ($page < 1) {
|
||||
$page = 1;
|
||||
}
|
||||
if ($pageSize < 1 || $pageSize > 100) {
|
||||
$pageSize = 20;
|
||||
}
|
||||
|
||||
$userService = new UserService(new UserProfileRepository());
|
||||
|
||||
// 情况1:仅根据身份证号查找(返回单个用户,不分页)
|
||||
if (!empty($body['id_card']) && empty($body['tag_conditions']) && empty($body['name']) && empty($body['phone']) && empty($body['email'])) {
|
||||
$user = $userService->findUserByIdCard($body['id_card']);
|
||||
|
||||
if (!$user) {
|
||||
return ApiResponseHelper::error('未找到该身份证号对应的用户', 404, 404);
|
||||
}
|
||||
|
||||
// 脱敏处理
|
||||
$user = DataMaskingHelper::maskArray($user, ['phone', 'email']);
|
||||
|
||||
LoggerHelper::logBusiness('search_user_by_id_card', [
|
||||
'found' => true,
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success($user);
|
||||
}
|
||||
|
||||
// 情况2:根据标签筛选用户(可能结合基础字段搜索)
|
||||
if (!empty($body['tag_conditions'])) {
|
||||
$tagService = new \app\service\TagService(
|
||||
new \app\repository\TagDefinitionRepository(),
|
||||
new UserProfileRepository(),
|
||||
new \app\repository\UserTagRepository(),
|
||||
new \app\repository\TagHistoryRepository(),
|
||||
new \app\service\TagRuleEngine\SimpleRuleEngine()
|
||||
);
|
||||
|
||||
$conditions = $body['tag_conditions'];
|
||||
$logic = $body['logic'] ?? 'AND';
|
||||
$includeUserInfo = true; // 标签筛选需要用户信息
|
||||
|
||||
// 验证条件格式
|
||||
foreach ($conditions as $condition) {
|
||||
if (!isset($condition['tag_code']) || !isset($condition['operator']) || !isset($condition['value'])) {
|
||||
throw new \InvalidArgumentException('每个条件必须包含 tag_code、operator 和 value 字段');
|
||||
}
|
||||
}
|
||||
|
||||
// 先根据标签筛选用户
|
||||
$tagResult = $tagService->filterUsersByTags(
|
||||
$conditions,
|
||||
$logic,
|
||||
1, // 先获取所有符合条件的用户ID
|
||||
10000, // 临时设置大值,获取所有用户ID
|
||||
true
|
||||
);
|
||||
|
||||
$userIds = array_column($tagResult['users'], 'user_id');
|
||||
|
||||
if (empty($userIds)) {
|
||||
return ApiResponseHelper::success([
|
||||
'users' => [],
|
||||
'total' => 0,
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
'total_pages' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
// 如果有基础字段搜索条件,进一步筛选
|
||||
$baseConditions = [];
|
||||
if (!empty($body['name'])) {
|
||||
$baseConditions['name'] = $body['name'];
|
||||
}
|
||||
if (!empty($body['phone'])) {
|
||||
$baseConditions['phone'] = $body['phone'];
|
||||
$baseConditions['phone_exact'] = $body['phone_exact'] ?? false;
|
||||
}
|
||||
if (!empty($body['email'])) {
|
||||
$baseConditions['email'] = $body['email'];
|
||||
$baseConditions['email_exact'] = $body['email_exact'] ?? false;
|
||||
}
|
||||
if (isset($body['gender']) && $body['gender'] !== '') {
|
||||
$baseConditions['gender'] = $body['gender'];
|
||||
}
|
||||
if (isset($body['status']) && $body['status'] !== '') {
|
||||
$baseConditions['status'] = $body['status'];
|
||||
}
|
||||
if (isset($body['min_total_amount'])) {
|
||||
$baseConditions['min_total_amount'] = $body['min_total_amount'];
|
||||
}
|
||||
if (isset($body['max_total_amount'])) {
|
||||
$baseConditions['max_total_amount'] = $body['max_total_amount'];
|
||||
}
|
||||
if (isset($body['min_total_count'])) {
|
||||
$baseConditions['min_total_count'] = $body['min_total_count'];
|
||||
}
|
||||
if (isset($body['max_total_count'])) {
|
||||
$baseConditions['max_total_count'] = $body['max_total_count'];
|
||||
}
|
||||
|
||||
// 如果有基础字段条件,需要进一步筛选
|
||||
if (!empty($baseConditions)) {
|
||||
$baseConditions['user_ids'] = $userIds; // 限制在标签筛选的用户范围内
|
||||
$result = $userService->searchUsers($baseConditions, $page, $pageSize);
|
||||
} else {
|
||||
// 没有基础字段条件,直接使用标签筛选结果并分页
|
||||
$total = count($userIds);
|
||||
$offset = ($page - 1) * $pageSize;
|
||||
$pagedUserIds = array_slice($userIds, $offset, $pageSize);
|
||||
|
||||
// 获取用户详细信息
|
||||
$users = [];
|
||||
foreach ($pagedUserIds as $userId) {
|
||||
$user = $userService->getUserById($userId, false);
|
||||
if ($user) {
|
||||
$users[] = $user;
|
||||
}
|
||||
}
|
||||
|
||||
$result = [
|
||||
'users' => $users,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
'total_pages' => (int)ceil($total / $pageSize),
|
||||
];
|
||||
}
|
||||
|
||||
// 对返回的用户信息进行脱敏处理
|
||||
if (isset($result['users']) && is_array($result['users'])) {
|
||||
foreach ($result['users'] as &$user) {
|
||||
$user = DataMaskingHelper::maskArray($user, ['phone', 'email']);
|
||||
}
|
||||
unset($user);
|
||||
}
|
||||
|
||||
LoggerHelper::logBusiness('search_users_by_tags', [
|
||||
'conditions_count' => count($conditions),
|
||||
'base_conditions' => !empty($baseConditions),
|
||||
'result_count' => $result['total'] ?? 0,
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success($result);
|
||||
}
|
||||
|
||||
// 情况3:仅基础字段搜索(无标签条件)
|
||||
$baseConditions = [];
|
||||
if (!empty($body['name'])) {
|
||||
$baseConditions['name'] = $body['name'];
|
||||
}
|
||||
if (!empty($body['phone'])) {
|
||||
$baseConditions['phone'] = $body['phone'];
|
||||
$baseConditions['phone_exact'] = $body['phone_exact'] ?? false;
|
||||
}
|
||||
if (!empty($body['email'])) {
|
||||
$baseConditions['email'] = $body['email'];
|
||||
$baseConditions['email_exact'] = $body['email_exact'] ?? false;
|
||||
}
|
||||
if (!empty($body['id_card'])) {
|
||||
$baseConditions['id_card'] = $body['id_card'];
|
||||
}
|
||||
if (isset($body['gender']) && $body['gender'] !== '') {
|
||||
$baseConditions['gender'] = $body['gender'];
|
||||
}
|
||||
if (isset($body['status']) && $body['status'] !== '') {
|
||||
$baseConditions['status'] = $body['status'];
|
||||
}
|
||||
if (isset($body['min_total_amount'])) {
|
||||
$baseConditions['min_total_amount'] = $body['min_total_amount'];
|
||||
}
|
||||
if (isset($body['max_total_amount'])) {
|
||||
$baseConditions['max_total_amount'] = $body['max_total_amount'];
|
||||
}
|
||||
if (isset($body['min_total_count'])) {
|
||||
$baseConditions['min_total_count'] = $body['min_total_count'];
|
||||
}
|
||||
if (isset($body['max_total_count'])) {
|
||||
$baseConditions['max_total_count'] = $body['max_total_count'];
|
||||
}
|
||||
|
||||
if (empty($baseConditions)) {
|
||||
return ApiResponseHelper::error('请提供至少一个搜索条件', 400);
|
||||
}
|
||||
|
||||
$result = $userService->searchUsers($baseConditions, $page, $pageSize);
|
||||
|
||||
// 对返回的用户信息进行脱敏处理
|
||||
if (isset($result['users']) && is_array($result['users'])) {
|
||||
foreach ($result['users'] as &$user) {
|
||||
$user = DataMaskingHelper::maskArray($user, ['phone', 'email']);
|
||||
}
|
||||
unset($user);
|
||||
}
|
||||
|
||||
LoggerHelper::logBusiness('search_users_by_base_fields', [
|
||||
'conditions' => array_keys($baseConditions),
|
||||
'result_count' => $result['total'] ?? 0,
|
||||
]);
|
||||
|
||||
return ApiResponseHelper::success($result);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return ApiResponseHelper::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return ApiResponseHelper::exception($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user