Files
cunkebao_v3/Moncter/app/service/ConsumptionService.php
2026-01-05 10:16:20 +08:00

283 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}