Files
cunkebao_v3/Moncter/app/service/ConsumptionService.php

283 lines
12 KiB
PHP
Raw Permalink Normal View History

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