feat: 小程序与管理端迭代(神仙AI、了解自己CRM、AI测试入口、报表与分润等)
Made-with: Cursor
This commit is contained in:
132
api/app/common/AnalyticsEventLabels.php
Normal file
132
api/app/common/AnalyticsEventLabels.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
namespace app\common;
|
||||
|
||||
/**
|
||||
* 埋点事件中文名字典
|
||||
* 配合 analytics_events.eventName 展示
|
||||
*/
|
||||
class AnalyticsEventLabels
|
||||
{
|
||||
/** @var array<string,string> */
|
||||
private static $MAP = [
|
||||
// 基础
|
||||
'page_view' => '页面浏览',
|
||||
'app_launch' => '小程序启动',
|
||||
'share' => '分享',
|
||||
'pay_success' => '支付成功',
|
||||
'pay_fail' => '支付失败',
|
||||
'pay_success_attribution' => '支付成功·归因',
|
||||
|
||||
// 首页 / 拍照入口
|
||||
'tap_start_camera' => '点击·开始拍照测试',
|
||||
'tap_questionnaire_home' => '点击·首页·去做问卷',
|
||||
'tap_enterprise_entry' => '点击·切换企业版',
|
||||
'tap_test_select' => '点击·选择测评类型',
|
||||
|
||||
// 问卷类
|
||||
'test_start' => '开始答题',
|
||||
'test_submit' => '提交答题',
|
||||
'test_complete' => '答题完成',
|
||||
'test_next' => '下一题',
|
||||
'test_prev' => '上一题',
|
||||
'tap_upload_photo_home' => '点击·首页·上传照片',
|
||||
|
||||
// 付费墙
|
||||
'paywall_view' => '付费墙·曝光',
|
||||
|
||||
// 结果页通用
|
||||
'tap_read_full' => '点击·看全文',
|
||||
'tap_share_moment' => '点击·分享到朋友圈',
|
||||
'tap_share_friend' => '点击·分享给好友',
|
||||
'tap_face_camera' => '点击·去拍照',
|
||||
'tap_complete_profile' => '点击·去完善资料',
|
||||
'tap_unlock_full' => '点击·解锁完整报告',
|
||||
'tap_retake_test' => '点击·重新测试',
|
||||
|
||||
// 深度解读 / 推广(带测评类型后缀)
|
||||
'tap_deep_service_from_mbti' => '点击·MBTI·深度解读方案',
|
||||
'tap_deep_service_from_disc' => '点击·DISC·深度解读方案',
|
||||
'tap_deep_service_from_pdp' => '点击·PDP·深度解读方案',
|
||||
'tap_deep_service_from_sbti' => '点击·SBTI·深度解读方案',
|
||||
'tap_deep_service_face_result' => '点击·面相·深度解读方案',
|
||||
|
||||
'tap_promo_from_mbti' => '点击·MBTI·推广中心',
|
||||
'tap_promo_from_disc' => '点击·DISC·推广中心',
|
||||
'tap_promo_from_pdp' => '点击·PDP·推广中心',
|
||||
'tap_promo_from_sbti' => '点击·SBTI·推广中心',
|
||||
'tap_promo_face_result' => '点击·面相·推广中心',
|
||||
|
||||
// 面相成交链路
|
||||
'tap_questionnaire_face_result' => '点击·面相页·补做问卷',
|
||||
|
||||
// 我的 / 推广中心
|
||||
'tap_deep_service' => '点击·我的·深度解读方案',
|
||||
'tap_my_orders' => '点击·我的·我的订单',
|
||||
'tap_promo_center' => '点击·进入推广中心',
|
||||
'tap_promo_poster' => '点击·生成推广海报',
|
||||
'tap_promo_withdraw' => '点击·提现',
|
||||
'tap_promo_share' => '点击·推广分享',
|
||||
|
||||
// 面相 / AI 结果页
|
||||
'face_analyze_success' => '面相分析成功',
|
||||
'face_analyze_fail' => '面相分析失败',
|
||||
|
||||
// 企业 / 简历
|
||||
'tap_resume_upload' => '点击·上传简历',
|
||||
'tap_resume_analyze' => '点击·简历分析',
|
||||
|
||||
// 登录 / 绑手机
|
||||
'login_silent_success' => '静默登录成功',
|
||||
'login_silent_fail' => '静默登录失败',
|
||||
'bind_phone_success' => '绑定手机成功',
|
||||
'bind_phone_fail' => '绑定手机失败',
|
||||
|
||||
// 神仙 AI(功能六)
|
||||
'tap_tab_ai_chat' => '点击·底部Tab·神仙AI',
|
||||
'ai_chat_send' => '神仙AI·发送消息',
|
||||
'ai_chat_receive' => '神仙AI·收到回复',
|
||||
'ai_chat_degrade' => '神仙AI·降级兜底',
|
||||
'ai_quick_question_click' => '神仙AI·快捷问题点击',
|
||||
'tap_ai_article' => '神仙AI·点击推荐文章',
|
||||
'ai_article_read' => '神仙AI·文章阅读完成',
|
||||
'ai_chat_share' => '神仙AI·分享对话',
|
||||
'ai_chat_share_invite_tap' => '神仙AI·点击邀请赚佣金',
|
||||
'ai_report_cta_tap' => '神仙AI·点击深度报告CTA',
|
||||
'ai_report_pay_tap' => '神仙AI·深度报告点击支付',
|
||||
'ai_report_pay_success' => '神仙AI·深度报告支付成功',
|
||||
'ai_report_share' => '神仙AI·分享深度报告',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取事件中文名。未命中时回退到原 eventName。
|
||||
*/
|
||||
public static function cn(string $eventName): string
|
||||
{
|
||||
$name = trim($eventName);
|
||||
if ($name === '') return '';
|
||||
return self::$MAP[$name] ?? $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量翻译:在数组每行补 eventNameCn 字段
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
*/
|
||||
public static function withCn(array $rows, string $field = 'eventName', string $cnField = 'eventNameCn'): array
|
||||
{
|
||||
foreach ($rows as &$r) {
|
||||
$name = isset($r[$field]) ? (string) $r[$field] : '';
|
||||
$r[$cnField] = self::cn($name);
|
||||
}
|
||||
unset($r);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回整张字典(供管理端下拉筛选)
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return self::$MAP;
|
||||
}
|
||||
}
|
||||
128
api/app/common/service/AiBalanceAlertService.php
Normal file
128
api/app/common/service/AiBalanceAlertService.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use app\model\AiProvider as AiProviderModel;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* AI 服务商欠费预警
|
||||
*
|
||||
* 复用 `feishu_lead_webhook` 的 webhookUrl,但触发文案独立(不用走 lead 模板);
|
||||
* 每个服务商·每天·同阈值只推一次(ai_balance_alerts 表)。
|
||||
*
|
||||
* 触发建议:
|
||||
* 1) 超管页面手动点击「余额检查」
|
||||
* 2) 宝塔/Linux crontab 每天 9:00 + 21:00 POST 调用
|
||||
* curl -X POST -H "Authorization: Bearer {SUPERADMIN_TOKEN}" https://.../api/v1/superadmin/ai/balance-check
|
||||
*/
|
||||
class AiBalanceAlertService
|
||||
{
|
||||
/**
|
||||
* 扫描所有启用且开启预警的服务商,低于阈值则推飞书
|
||||
* @return array{alerted:int,skipped:int,items:array<int,array<string,mixed>>}
|
||||
*/
|
||||
public static function scanAndAlert(): array
|
||||
{
|
||||
$rows = AiProviderModel::where('enabled', 1)
|
||||
->where('balanceAlertEnabled', 1)
|
||||
->whereNotNull('lastBalance')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$alerted = 0;
|
||||
$skipped = 0;
|
||||
$items = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$threshold = (float) ($row['balanceAlertThreshold'] ?? 0);
|
||||
$balance = (float) $row['lastBalance'];
|
||||
if ($threshold <= 0 || $balance > $threshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dateStr = date('Y-m-d');
|
||||
$dup = Db::name('ai_balance_alerts')
|
||||
->where('providerId', $row['providerId'])
|
||||
->where('dateStr', $dateStr)
|
||||
->find();
|
||||
if ($dup) {
|
||||
$skipped++;
|
||||
$items[] = ['providerId' => $row['providerId'], 'status' => 'dedup'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$ok = self::pushFeishu($row, $balance, $threshold);
|
||||
if ($ok) {
|
||||
try {
|
||||
Db::name('ai_balance_alerts')->insert([
|
||||
'providerId' => $row['providerId'],
|
||||
'balance' => $balance,
|
||||
'threshold' => $threshold,
|
||||
'currency' => $row['lastBalanceCurrency'] ?? 'CNY',
|
||||
'alertedAt' => time(),
|
||||
'dateStr' => $dateStr,
|
||||
]);
|
||||
} catch (\Throwable $e) {}
|
||||
$alerted++;
|
||||
$items[] = ['providerId' => $row['providerId'], 'status' => 'alerted', 'balance' => $balance, 'threshold' => $threshold];
|
||||
} else {
|
||||
$items[] = ['providerId' => $row['providerId'], 'status' => 'send-failed'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['alerted' => $alerted, 'skipped' => $skipped, 'items' => $items];
|
||||
}
|
||||
|
||||
private static function pushFeishu(array $row, float $balance, float $threshold): bool
|
||||
{
|
||||
$cfg = FeishuLeadWebhookService::getConfig();
|
||||
$url = trim((string) ($cfg['webhookUrl'] ?? ''));
|
||||
if ($url === '' || stripos($url, 'http') !== 0) {
|
||||
Log::warning('AiBalanceAlertService: feishu webhookUrl 未配置');
|
||||
return false;
|
||||
}
|
||||
|
||||
$currency = ($row['lastBalanceCurrency'] ?? 'CNY') === 'USD' ? '$' : '¥';
|
||||
$now = date('Y-m-d H:i');
|
||||
$text = "⚠️ AI 服务余额预警\n"
|
||||
. "服务商: {$row['name']} ({$row['providerId']})\n"
|
||||
. "当前余额: {$currency}" . number_format($balance, 2) . "\n"
|
||||
. "告警阈值: {$currency}" . number_format($threshold, 2) . "\n"
|
||||
. "━━━━━━━━━━\n"
|
||||
. "神仙 AI 对话 / 面相分析可能受影响,已自动把该服务商降权到故障切换队尾。\n"
|
||||
. "请尽快到后台充值:\n"
|
||||
. "时间: {$now}";
|
||||
|
||||
$payload = stripos($url, 'qyapi.weixin.qq.com') !== false
|
||||
? ['msgtype' => 'text', 'text' => ['content' => $text]]
|
||||
: ['msg_type' => 'text', 'content' => ['text' => $text]];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json; charset=utf-8'],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code >= 400) {
|
||||
Log::warning("AiBalanceAlertService feishu push http={$code}");
|
||||
return false;
|
||||
}
|
||||
if ($body !== false && $body !== '') {
|
||||
$resp = json_decode($body, true);
|
||||
if (is_array($resp)) {
|
||||
if (isset($resp['code']) && (int) $resp['code'] !== 0) return false;
|
||||
if (isset($resp['StatusCode']) && (int) $resp['StatusCode'] !== 0) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
623
api/app/common/service/AiCallService.php
Normal file
623
api/app/common/service/AiCallService.php
Normal file
@@ -0,0 +1,623 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use app\model\AiProvider as AiProviderModel;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 神仙 AI · 统一对话调用服务
|
||||
*
|
||||
* 能力:
|
||||
* 1. 自动按 sortWeight / id 升序挑选可用服务商
|
||||
* 2. 余额低于阈值的服务商自动降权沉底
|
||||
* 3. 故障自动切换(429/401/402/5xx/解析错误)
|
||||
* 4. 支持 OpenAI 兼容 / Anthropic 两类协议
|
||||
* 5. 全部失败返回降级兜底回答,**不抛给前端**
|
||||
*
|
||||
* 典型用法:
|
||||
* $r = AiCallService::chat([
|
||||
* ['role'=>'system','content'=>'你是…'],
|
||||
* ['role'=>'user','content'=>'我是 INFP,今天压力大'],
|
||||
* ]);
|
||||
* // => ['content'=>'...', 'providerId'=>'deepseek', 'isDegraded'=>false]
|
||||
*/
|
||||
class AiCallService
|
||||
{
|
||||
/**
|
||||
* 对话入口
|
||||
*
|
||||
* @param array $messages OpenAI messages 格式
|
||||
* @param array $options ['temperature'=>0.7, 'maxTokens'=>2048, 'stream'=>false]
|
||||
* @return array ['content'=>string, 'providerId'=>string, 'isDegraded'=>bool, 'tokensIn'=>int, 'tokensOut'=>int]
|
||||
*/
|
||||
public static function chat(array $messages, array $options = []): array
|
||||
{
|
||||
$maxTokens = (int) ($options['maxTokens'] ?? 2048);
|
||||
$temperature = isset($options['temperature']) ? (float) $options['temperature'] : 0.7;
|
||||
|
||||
$providers = self::resolveProviders();
|
||||
if (empty($providers)) {
|
||||
return self::degrade('no-provider', '小神仙在喝茶呢,超管还没配置好法力,稍后再来找我呀~');
|
||||
}
|
||||
|
||||
$lastError = '';
|
||||
foreach ($providers as $provider) {
|
||||
$providerId = strtolower((string) ($provider->providerId ?? ''));
|
||||
$apiKey = $provider->getRawApiKey();
|
||||
if ($apiKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$endpoint = self::resolveEndpoint($provider);
|
||||
$model = !empty($provider->model) ? $provider->model : self::defaultModel($providerId);
|
||||
|
||||
try {
|
||||
if ($providerId === 'anthropic') {
|
||||
$content = self::callAnthropic($endpoint, $apiKey, $model, $messages, $maxTokens);
|
||||
} else {
|
||||
// openai / deepseek / moonshot / qwen / zhipu / zhizengzeng / groq 等
|
||||
$content = self::callOpenAICompatible($endpoint, $apiKey, $model, $messages, $maxTokens, $temperature);
|
||||
}
|
||||
|
||||
if ($content === '') {
|
||||
$lastError = "provider={$providerId} empty-content";
|
||||
continue;
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => $content,
|
||||
'providerId' => $providerId,
|
||||
'model' => $model,
|
||||
'isDegraded' => false,
|
||||
'tokensIn' => 0, // 各家返回字段不一,留空(可二期补 usage)
|
||||
'tokensOut' => 0,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$lastError = "provider={$providerId} error=" . $e->getMessage();
|
||||
Log::warning('AiCallService chat fallback: ' . $lastError);
|
||||
// 遇到明显欠费/鉴权类错误,给该服务商的余额打低权重沉底
|
||||
if (self::looksLikeBillingError($e->getMessage())) {
|
||||
try {
|
||||
$provider->sortWeight = 999;
|
||||
$provider->save();
|
||||
} catch (\Throwable $ignore) {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return self::degrade('all-failed', '小神仙暂时联系不上天界了(' . $lastError . '),工程师已收到告警,请稍后再试 🌿');
|
||||
}
|
||||
|
||||
/**
|
||||
* 选出可用服务商列表(按 sortWeight 升序、余额不足的沉底)
|
||||
* @return AiProviderModel[]
|
||||
*/
|
||||
private static function resolveProviders(): array
|
||||
{
|
||||
$all = AiProviderModel::where('enabled', 1)
|
||||
->whereRaw('(visible IS NULL OR visible = 1)')
|
||||
->whereRaw('(apiKey IS NOT NULL AND LENGTH(TRIM(apiKey)) > 0)')
|
||||
->order('sortWeight', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if (empty($all)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 把「余额低于阈值」的服务商排到队尾
|
||||
$healthy = [];
|
||||
$depleted = [];
|
||||
foreach ($all as $row) {
|
||||
$provider = AiProviderModel::where('id', $row['id'])->find();
|
||||
if (!$provider) continue;
|
||||
|
||||
$isLowBalance = $provider->balanceAlertEnabled == 1
|
||||
&& $provider->lastBalance !== null
|
||||
&& $provider->lastBalance <= (float) ($provider->balanceAlertThreshold ?? 0);
|
||||
|
||||
if ($isLowBalance) {
|
||||
$depleted[] = $provider;
|
||||
} else {
|
||||
$healthy[] = $provider;
|
||||
}
|
||||
}
|
||||
|
||||
return array_merge($healthy, $depleted);
|
||||
}
|
||||
|
||||
private static function resolveEndpoint($provider): string
|
||||
{
|
||||
$endpoint = !empty($provider->apiEndpoint) ? rtrim($provider->apiEndpoint, '/') : '';
|
||||
if ($endpoint !== '') return $endpoint;
|
||||
return self::defaultEndpoint(strtolower((string) $provider->providerId));
|
||||
}
|
||||
|
||||
private static function defaultEndpoint(string $providerId): string
|
||||
{
|
||||
switch ($providerId) {
|
||||
case 'openai': return 'https://api.openai.com/v1';
|
||||
case 'groq': return 'https://api.groq.com/openai/v1';
|
||||
case 'deepseek': return 'https://api.deepseek.com/v1';
|
||||
case 'moonshot': return 'https://api.moonshot.ai/v1';
|
||||
case 'qwen': return 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||
case 'anthropic': return 'https://api.anthropic.com/v1';
|
||||
case 'zhipu': return 'https://api.z.ai/api/paas/v4';
|
||||
case 'zhizengzeng': return 'https://api.zhizengzeng.com/v1';
|
||||
default: return 'https://api.openai.com/v1';
|
||||
}
|
||||
}
|
||||
|
||||
private static function defaultModel(string $providerId): string
|
||||
{
|
||||
switch ($providerId) {
|
||||
case 'deepseek': return 'deepseek-chat';
|
||||
case 'moonshot': return 'moonshot-v1-8k';
|
||||
case 'qwen': return 'qwen-turbo';
|
||||
case 'zhipu': return 'glm-4-flash';
|
||||
case 'anthropic': return 'claude-3-5-haiku-latest';
|
||||
case 'groq': return 'llama-3.1-70b-versatile';
|
||||
default: return 'gpt-4o-mini';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI 兼容协议(deepseek / moonshot / qwen / zhipu / zhizengzeng / groq / openai…)
|
||||
*/
|
||||
private static function callOpenAICompatible(string $endpoint, string $apiKey, string $model, array $messages, int $maxTokens, float $temperature): string
|
||||
{
|
||||
$body = [
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'temperature' => $temperature,
|
||||
];
|
||||
|
||||
if (strpos($endpoint, 'api.zhizengzeng.com') !== false) {
|
||||
$body['max_completion_tokens'] = $maxTokens;
|
||||
} else {
|
||||
$body['max_tokens'] = $maxTokens;
|
||||
}
|
||||
|
||||
$url = rtrim($endpoint, '/') . '/chat/completions';
|
||||
$payload = json_encode($body, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
],
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErrNo = curl_errno($ch);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $response === '') {
|
||||
throw new \RuntimeException("curl-failed code={$curlErrNo} msg={$curlError}");
|
||||
}
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
throw new \RuntimeException("http-{$httpCode}: " . substr((string) $response, 0, 400));
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (!is_array($data)) {
|
||||
throw new \RuntimeException('invalid-json');
|
||||
}
|
||||
|
||||
if (!empty($data['error'])) {
|
||||
$errMsg = is_array($data['error']) ? ($data['error']['message'] ?? json_encode($data['error'])) : (string) $data['error'];
|
||||
throw new \RuntimeException('api-error: ' . $errMsg);
|
||||
}
|
||||
|
||||
$text = $data['choices'][0]['message']['content'] ?? '';
|
||||
return trim((string) $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic Messages 协议
|
||||
*/
|
||||
private static function callAnthropic(string $endpoint, string $apiKey, string $model, array $messages, int $maxTokens): string
|
||||
{
|
||||
// Anthropic 需要单独传 system,messages 只接受 user/assistant
|
||||
$system = '';
|
||||
$userMessages = [];
|
||||
foreach ($messages as $m) {
|
||||
if (($m['role'] ?? '') === 'system') {
|
||||
$system .= "\n" . ($m['content'] ?? '');
|
||||
} else {
|
||||
$userMessages[] = [
|
||||
'role' => $m['role'] ?? 'user',
|
||||
'content' => [['type' => 'text', 'text' => (string) ($m['content'] ?? '')]],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$body = [
|
||||
'model' => $model,
|
||||
'max_tokens' => $maxTokens,
|
||||
'messages' => $userMessages,
|
||||
];
|
||||
if (trim($system) !== '') {
|
||||
$body['system'] = trim($system);
|
||||
}
|
||||
|
||||
$url = rtrim($endpoint, '/') . '/messages';
|
||||
$payload = json_encode($body, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'x-api-key: ' . $apiKey,
|
||||
'anthropic-version: 2023-06-01',
|
||||
],
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $response === '') {
|
||||
throw new \RuntimeException('anthropic-no-response');
|
||||
}
|
||||
if ($httpCode >= 400) {
|
||||
throw new \RuntimeException("http-{$httpCode}: " . substr((string) $response, 0, 400));
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$text = '';
|
||||
if (!empty($data['content']) && is_array($data['content'])) {
|
||||
foreach ($data['content'] as $block) {
|
||||
if (($block['type'] ?? '') === 'text') {
|
||||
$text .= (string) ($block['text'] ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
return trim($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 常见欠费/鉴权类错误关键字 → 触发降权
|
||||
*/
|
||||
private static function looksLikeBillingError(string $msg): bool
|
||||
{
|
||||
$lower = strtolower($msg);
|
||||
foreach (['insufficient', 'balance', 'quota', 'rate_limit', '402', '429', 'unauthorized', '401'] as $kw) {
|
||||
if (strpos($lower, $kw) !== false) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function degrade(string $reason, string $content): array
|
||||
{
|
||||
Log::warning('AiCallService degraded: ' . $reason);
|
||||
return [
|
||||
'content' => $content,
|
||||
'providerId' => 'degrade',
|
||||
'model' => '',
|
||||
'isDegraded' => true,
|
||||
'tokensIn' => 0,
|
||||
'tokensOut' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造针对某用户的 system prompt(结合 MBTI 类型 + 画像)
|
||||
*/
|
||||
public static function buildSystemPrompt(array $userContext): string
|
||||
{
|
||||
$mbtiType = trim((string) ($userContext['mbtiType'] ?? ''));
|
||||
$summary = trim((string) ($userContext['summary'] ?? ''));
|
||||
$nickname = trim((string) ($userContext['nickname'] ?? ''));
|
||||
|
||||
$prompt = "你是「神仙 AI」,神仙团队 MBTI 性格小程序的专属人格伙伴。\n";
|
||||
$prompt .= "说话风格:像朋友一样亲切、简短(每次回答 150 字以内,必要时可延展),不堆砌术语;适度鼓励用户完成其他测评(DISC / PDP / SBTI / 面相)或去「一场 soul 创业实验」公众号阅读相关文章。\n";
|
||||
$prompt .= "禁止:做医疗诊断 / 政治议题 / 具体投资建议。\n";
|
||||
if ($nickname !== '') {
|
||||
$prompt .= "称呼:可以偶尔叫用户「{$nickname}」。\n";
|
||||
}
|
||||
if ($mbtiType !== '') {
|
||||
$prompt .= "用户 MBTI:{$mbtiType}。请结合此类型做个性化回答。\n";
|
||||
} else {
|
||||
$prompt .= "用户尚未完成 MBTI 测试。回答时可友好建议先去做一下测评。\n";
|
||||
}
|
||||
if ($summary !== '') {
|
||||
$prompt .= "用户性格画像摘要:{$summary}\n";
|
||||
}
|
||||
|
||||
$appendix = trim((string) ($userContext['testAppendix'] ?? ''));
|
||||
if ($appendix !== '') {
|
||||
$prompt .= "\n【用户测评客观记录(含问卷选项,供你结合其当前提问做针对性回答;勿机械罗列题号套话)】\n";
|
||||
$prompt .= $appendix . "\n";
|
||||
}
|
||||
|
||||
return $prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取用户 MBTI 画像(最近一次 test_results 结果)
|
||||
*/
|
||||
public static function fetchUserContext(int $userId): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return ['mbtiType' => '', 'summary' => '', 'nickname' => '', 'testAppendix' => ''];
|
||||
}
|
||||
|
||||
$nickname = (string) Db::name('wechat_users')->where('id', $userId)->value('nickname');
|
||||
|
||||
$mbtiType = '';
|
||||
$summary = '';
|
||||
$row = Db::name('test_results')
|
||||
->where('userId', $userId)
|
||||
->where('testType', 'mbti')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if ($row && !empty($row['resultData'])) {
|
||||
$data = is_string($row['resultData']) ? json_decode($row['resultData'], true) : $row['resultData'];
|
||||
if (is_array($data)) {
|
||||
$mbtiType = (string) ($data['mbtiType'] ?? ($data['mbti']['type'] ?? ''));
|
||||
if (!empty($data['description']['summary'])) {
|
||||
$summary = (string) $data['description']['summary'];
|
||||
} elseif (!empty($data['description']['overview'])) {
|
||||
$summary = (string) $data['description']['overview'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 若无 MBTI,尝试回落到面相分析给出的 mbti
|
||||
if ($mbtiType === '') {
|
||||
$faceRow = Db::name('test_results')
|
||||
->where('userId', $userId)
|
||||
->where('testType', 'face')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if ($faceRow && !empty($faceRow['resultData'])) {
|
||||
$data = is_string($faceRow['resultData']) ? json_decode($faceRow['resultData'], true) : $faceRow['resultData'];
|
||||
if (is_array($data) && isset($data['mbti']['type'])) {
|
||||
$mbtiType = (string) $data['mbti']['type'];
|
||||
$summary = (string) ($data['personalitySummary'] ?? ($data['overview'] ?? ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$testAppendix = self::buildLatestTestsAppendix($userId);
|
||||
|
||||
return [
|
||||
'mbtiType' => $mbtiType,
|
||||
'summary' => $summary,
|
||||
'nickname' => $nickname,
|
||||
'testAppendix' => $testAppendix,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总用户最近一次各类型测评的答题与结果,供 system prompt 使用(有长度上限)
|
||||
*/
|
||||
private static function buildLatestTestsAppendix(int $userId): string
|
||||
{
|
||||
$types = ['mbti', 'sbti', 'disc', 'pdp'];
|
||||
$blocks = [];
|
||||
foreach ($types as $type) {
|
||||
$row = Db::name('test_results')
|
||||
->where('userId', $userId)
|
||||
->where('testType', $type)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!$row || empty($row['resultData'])) {
|
||||
continue;
|
||||
}
|
||||
$data = is_string($row['resultData']) ? json_decode($row['resultData'], true) : $row['resultData'];
|
||||
if (!is_array($data)) {
|
||||
continue;
|
||||
}
|
||||
$block = self::formatTestBlockForPrompt($type, $data);
|
||||
if ($block !== '') {
|
||||
$blocks[] = $block;
|
||||
}
|
||||
}
|
||||
$text = implode("\n——\n", $blocks);
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
$maxLen = 3800;
|
||||
if (mb_strlen($text, 'UTF-8') > $maxLen) {
|
||||
$text = mb_substr($text, 0, $maxLen, 'UTF-8') . '…(档案已截断)';
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function formatTestBlockForPrompt(string $testType, array $data): string
|
||||
{
|
||||
switch ($testType) {
|
||||
case 'mbti':
|
||||
return self::formatMbtiBlockForPrompt($data);
|
||||
case 'sbti':
|
||||
return self::formatSbtiBlockForPrompt($data);
|
||||
case 'disc':
|
||||
return self::formatDiscBlockForPrompt($data);
|
||||
case 'pdp':
|
||||
return self::formatPdpBlockForPrompt($data);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $answers
|
||||
*/
|
||||
private static function formatAnswersCompact(?array $answers): string
|
||||
{
|
||||
if (!is_array($answers) || $answers === []) {
|
||||
return '';
|
||||
}
|
||||
$keys = array_keys($answers);
|
||||
sort($keys, SORT_NATURAL);
|
||||
$parts = [];
|
||||
foreach ($keys as $k) {
|
||||
$v = $answers[$k];
|
||||
if (is_array($v) || is_object($v)) {
|
||||
$v = json_encode($v, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$parts[] = (string) $k . '→' . (string) $v;
|
||||
if (count($parts) >= 100) {
|
||||
$parts[] = '…共' . count($keys) . '题';
|
||||
break;
|
||||
}
|
||||
}
|
||||
return implode(',', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function formatMbtiBlockForPrompt(array $data): string
|
||||
{
|
||||
$lines = ['【MBTI·最近一次】'];
|
||||
$type = (string) ($data['mbtiType'] ?? ($data['mbti']['type'] ?? ''));
|
||||
if ($type !== '') {
|
||||
$lines[] = '类型:' . $type;
|
||||
}
|
||||
if (isset($data['confidence'])) {
|
||||
$lines[] = '置信度:' . (int) $data['confidence'] . '%';
|
||||
}
|
||||
if (!empty($data['dimensionScores']) && is_array($data['dimensionScores'])) {
|
||||
$lines[] = '四轴:' . json_encode($data['dimensionScores'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
if (!empty($data['scores']) && is_array($data['scores'])) {
|
||||
$lines[] = '字母计数:' . json_encode($data['scores'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
|
||||
if ($ans !== '') {
|
||||
$lines[] = '逐题选项(题号/ID→所选):' . $ans;
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function formatSbtiBlockForPrompt(array $data): string
|
||||
{
|
||||
$lines = ['【SBTI·最近一次】'];
|
||||
$final = $data['finalType'] ?? null;
|
||||
$fcode = is_array($final) ? (string) ($final['code'] ?? '') : '';
|
||||
$code = (string) ($data['sbtiType'] ?? $fcode);
|
||||
if ($code !== '') {
|
||||
$lines[] = '类型代码:' . $code;
|
||||
}
|
||||
if (!empty($data['special'])) {
|
||||
$lines[] = '特殊标记:' . json_encode($data['special'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
|
||||
if ($ans !== '') {
|
||||
$lines[] = '逐题选项:' . $ans;
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function formatDiscBlockForPrompt(array $data): string
|
||||
{
|
||||
$lines = ['【DISC·最近一次】'];
|
||||
if (!empty($data['dominantType'])) {
|
||||
$lines[] = '主导:' . (string) $data['dominantType'] . ',次要:' . (string) ($data['secondaryType'] ?? '');
|
||||
}
|
||||
if (!empty($data['scores']) && is_array($data['scores'])) {
|
||||
$lines[] = '得分:' . json_encode($data['scores'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
|
||||
if ($ans !== '') {
|
||||
$lines[] = '逐题选项:' . $ans;
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function formatPdpBlockForPrompt(array $data): string
|
||||
{
|
||||
$lines = ['【PDP·最近一次】'];
|
||||
if (!empty($data['dominantType'])) {
|
||||
$lines[] = '主导:' . (string) $data['dominantType'] . ',次要:' . (string) ($data['secondaryType'] ?? '');
|
||||
}
|
||||
if (!empty($data['scores']) && is_array($data['scores'])) {
|
||||
$lines[] = '得分:' . json_encode($data['scores'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
|
||||
if ($ans !== '') {
|
||||
$lines[] = '逐题选项:' . $ans;
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤不在小程序展示的快捷问句(与产品配置一致)
|
||||
*
|
||||
* @param string[] $questions
|
||||
* @return string[]
|
||||
*/
|
||||
public static function filterQuickQuestions(array $questions): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($questions as $q) {
|
||||
$s = trim((string) $q);
|
||||
if ($s === '') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/记\s*一下\s*我的\s*MBTI/ui', $s)) {
|
||||
continue;
|
||||
}
|
||||
$out[] = $s;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 MBTI 类型返回 3 条快捷问句
|
||||
*/
|
||||
public static function quickQuestions(string $mbtiType): array
|
||||
{
|
||||
$mbtiType = strtoupper(trim($mbtiType));
|
||||
$generic = [
|
||||
'我应该找什么样的工作?',
|
||||
'我适合什么样的伴侣?',
|
||||
'我的职业发展方向是什么?',
|
||||
'我最近有点迷茫,有什么建议?',
|
||||
'帮我做一个简短的自我介绍',
|
||||
'我有哪些需要警惕的盲点?',
|
||||
];
|
||||
if ($mbtiType === '') return $generic;
|
||||
|
||||
return [
|
||||
"作为 {$mbtiType},我最大的优势和盲点是什么?",
|
||||
"{$mbtiType} 适合什么样的工作?",
|
||||
"{$mbtiType} 适合找什么样的伴侣?",
|
||||
"{$mbtiType} 的职业发展路径推荐?",
|
||||
"{$mbtiType} 如何处理职场人际关系?",
|
||||
"{$mbtiType} 最容易掉进什么心理陷阱?",
|
||||
];
|
||||
}
|
||||
}
|
||||
116
api/app/common/service/AiChatArticleDisplayService.php
Normal file
116
api/app/common/service/AiChatArticleDisplayService.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 神仙 AI 页 · 推荐文章区块展示(超管可配)
|
||||
*
|
||||
* system_config.key = ai_chat_articles, enterprise_id = 0
|
||||
* JSON: { enabled, maxShow(1-3), sectionExpandedDefault, profileRecoEnabled, profileSectionLabel }
|
||||
*/
|
||||
class AiChatArticleDisplayService
|
||||
{
|
||||
public const CONFIG_KEY = 'ai_chat_articles';
|
||||
|
||||
/** 我的页推荐条默认标题(后台可改) */
|
||||
public const DEFAULT_PROFILE_SECTION_LABEL = '推荐阅读';
|
||||
|
||||
/**
|
||||
* @return array{enabled:bool,maxShow:int,sectionExpandedDefault:bool,profileRecoEnabled:bool,profileSectionLabel:string}
|
||||
*/
|
||||
public static function getSettings(): array
|
||||
{
|
||||
$defaults = [
|
||||
'enabled' => false,
|
||||
'maxShow' => 1,
|
||||
'sectionExpandedDefault' => false,
|
||||
'profileRecoEnabled' => false,
|
||||
'profileSectionLabel' => self::DEFAULT_PROFILE_SECTION_LABEL,
|
||||
];
|
||||
$row = Db::name('system_config')
|
||||
->where('key', self::CONFIG_KEY)
|
||||
->where('enterprise_id', 0)
|
||||
->find();
|
||||
if (!$row || empty($row['value'])) {
|
||||
return $defaults;
|
||||
}
|
||||
$v = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
|
||||
if (!is_array($v)) {
|
||||
return $defaults;
|
||||
}
|
||||
$maxShow = (int) ($v['maxShow'] ?? 1);
|
||||
if ($maxShow < 1) {
|
||||
$maxShow = 1;
|
||||
}
|
||||
if ($maxShow > 3) {
|
||||
$maxShow = 3;
|
||||
}
|
||||
|
||||
$profileLabel = isset($v['profileSectionLabel']) ? trim((string) $v['profileSectionLabel']) : '';
|
||||
if ($profileLabel === '') {
|
||||
$profileLabel = self::DEFAULT_PROFILE_SECTION_LABEL;
|
||||
}
|
||||
if (function_exists('mb_substr')) {
|
||||
$profileLabel = mb_substr($profileLabel, 0, 32, 'UTF-8');
|
||||
} else {
|
||||
$profileLabel = substr($profileLabel, 0, 32);
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => !empty($v['enabled']),
|
||||
'maxShow' => $maxShow,
|
||||
'sectionExpandedDefault' => !empty($v['sectionExpandedDefault']),
|
||||
'profileRecoEnabled' => !empty($v['profileRecoEnabled']),
|
||||
'profileSectionLabel' => $profileLabel,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{enabled:bool,maxShow:int,sectionExpandedDefault:bool,profileRecoEnabled:bool,profileSectionLabel:string}
|
||||
*/
|
||||
public static function saveSettings(array $input): array
|
||||
{
|
||||
$rawLabel = isset($input['profileSectionLabel']) ? trim((string) $input['profileSectionLabel']) : '';
|
||||
if ($rawLabel === '') {
|
||||
$rawLabel = self::DEFAULT_PROFILE_SECTION_LABEL;
|
||||
}
|
||||
if (function_exists('mb_substr')) {
|
||||
$rawLabel = mb_substr($rawLabel, 0, 32, 'UTF-8');
|
||||
} else {
|
||||
$rawLabel = substr($rawLabel, 0, 32);
|
||||
}
|
||||
|
||||
$settings = [
|
||||
'enabled' => !empty($input['enabled']),
|
||||
'maxShow' => max(1, min(3, (int) ($input['maxShow'] ?? 1))),
|
||||
'sectionExpandedDefault' => !empty($input['sectionExpandedDefault']),
|
||||
'profileRecoEnabled' => !empty($input['profileRecoEnabled']),
|
||||
'profileSectionLabel' => $rawLabel,
|
||||
];
|
||||
$json = json_encode($settings, JSON_UNESCAPED_UNICODE);
|
||||
$now = time();
|
||||
$exists = Db::name('system_config')
|
||||
->where('key', self::CONFIG_KEY)
|
||||
->where('enterprise_id', 0)
|
||||
->find();
|
||||
if ($exists) {
|
||||
Db::name('system_config')
|
||||
->where('key', self::CONFIG_KEY)
|
||||
->where('enterprise_id', 0)
|
||||
->update(['value' => $json, 'updatedAt' => $now]);
|
||||
} else {
|
||||
Db::name('system_config')->insert([
|
||||
'key' => self::CONFIG_KEY,
|
||||
'enterprise_id' => 0,
|
||||
'value' => $json,
|
||||
'description' => '神仙 AI / 我的页:推荐文章展示配置',
|
||||
'createdAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
206
api/app/common/service/AiReportService.php
Normal file
206
api/app/common/service/AiReportService.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use app\model\AiReport as AiReportModel;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* AI 深度画像报告服务
|
||||
*
|
||||
* 生命周期:
|
||||
* pending(创建)→ paid(支付成功 / dev:markPaidDev)
|
||||
* → generating(后台开始调 AI) → done(生成完成)
|
||||
* 失败时置 failed + 自动重试(retryCount<=3)
|
||||
*/
|
||||
class AiReportService
|
||||
{
|
||||
const PRICE_FEN = 990; // 9.9 元
|
||||
|
||||
public static function createOrGetPending(int $userId, int $conversationId = 0, string $mbtiType = ''): array
|
||||
{
|
||||
// 已有 pending/paid/generating/done → 返回最新那条
|
||||
$existing = AiReportModel::where('userId', $userId)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if ($existing && in_array($existing->status, ['pending', 'paid', 'generating', 'done'])) {
|
||||
// 已完成的报告允许用户再做一份?按默认:只有 done 才允许再发起新的
|
||||
if ($existing->status !== 'done') {
|
||||
return self::toArray($existing);
|
||||
}
|
||||
}
|
||||
|
||||
$orderSn = 'AIR' . date('YmdHis') . mt_rand(1000, 9999);
|
||||
$model = AiReportModel::create([
|
||||
'userId' => $userId,
|
||||
'conversationId' => $conversationId ?: null,
|
||||
'mbtiType' => $mbtiType ?: null,
|
||||
'orderSn' => $orderSn,
|
||||
'priceFen' => self::PRICE_FEN,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
return self::toArray($model);
|
||||
}
|
||||
|
||||
public static function myLatest(int $userId): ?array
|
||||
{
|
||||
$m = AiReportModel::where('userId', $userId)->order('id', 'desc')->find();
|
||||
return $m ? self::toArray($m) : null;
|
||||
}
|
||||
|
||||
public static function get(int $id, int $userId): ?array
|
||||
{
|
||||
$m = AiReportModel::where('id', $id)->where('userId', $userId)->find();
|
||||
return $m ? self::toArray($m) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付成功回调:把 report 置为 paid,并触发异步生成
|
||||
* 同时执行订单分账(走 ProfitSharingService)
|
||||
*/
|
||||
public static function markPaid(string $orderSn): array
|
||||
{
|
||||
$m = AiReportModel::where('orderSn', $orderSn)->find();
|
||||
if (!$m) return ['status' => 'not-found'];
|
||||
if ($m->status !== 'pending') {
|
||||
// 已处理过
|
||||
return ['status' => 'already', 'reportStatus' => $m->status];
|
||||
}
|
||||
|
||||
$m->status = 'paid';
|
||||
$m->paidAt = time();
|
||||
$m->save();
|
||||
|
||||
// 分账(idempotent)
|
||||
try {
|
||||
ProfitSharingService::executeSharing(
|
||||
$m->orderSn,
|
||||
'ai_deep_report',
|
||||
(int) $m->priceFen,
|
||||
['userId' => (int) $m->userId, 'orderId' => (int) $m->id]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('AiReportService profit-sharing failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// 同步生成报告(若耗时 >30s 可改异步队列,此处 PHP fpm 环境先同步,失败自动降级)
|
||||
self::generate((int) $m->id);
|
||||
|
||||
$fresh = AiReportModel::find($m->id);
|
||||
return ['status' => 'ok', 'report' => self::toArray($fresh)];
|
||||
}
|
||||
|
||||
/** 调试/内部:跳过支付直接 markPaid */
|
||||
public static function markPaidDev(string $orderSn): array
|
||||
{
|
||||
return self::markPaid($orderSn);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成报告正文(调 AI)
|
||||
*/
|
||||
public static function generate(int $reportId): void
|
||||
{
|
||||
$m = AiReportModel::find($reportId);
|
||||
if (!$m) return;
|
||||
if (!in_array($m->status, ['paid', 'failed', 'generating'])) return;
|
||||
|
||||
$m->status = 'generating';
|
||||
$m->retryCount = (int) $m->retryCount + 1;
|
||||
$m->save();
|
||||
|
||||
try {
|
||||
$user = Db::name('user')->where('id', (int) $m->userId)->find();
|
||||
$mbti = $m->mbtiType ?: ($user['mbti_type'] ?? '未知');
|
||||
$nickname = $user['nickname'] ?? '朋友';
|
||||
|
||||
// 拉取最近对话作为输入素材
|
||||
$history = [];
|
||||
if (!empty($m->conversationId)) {
|
||||
$msgs = Db::name('ai_messages')
|
||||
->where('conversationId', (int) $m->conversationId)
|
||||
->order('id', 'asc')
|
||||
->limit(40)
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($msgs as $msg) {
|
||||
if ($msg['role'] === 'system') continue;
|
||||
$history[] = ($msg['role'] === 'user' ? '【你】' : '【神仙AI】') . ":" . $msg['content'];
|
||||
}
|
||||
}
|
||||
|
||||
$system = "你是「神仙 AI」,要基于用户的 MBTI 类型与最近的对话,输出一份具个性化的深度画像报告。\n"
|
||||
. "报告要求:\n"
|
||||
. "1) 长度 1200-1800 字,分 5 个章节(性格内核 / 优势雷达 / 潜在盲点 / 亲密关系建议 / 下一步成长路径)\n"
|
||||
. "2) 每章 2-3 段,语气温暖不说教;禁止玄学与迷信\n"
|
||||
. "3) 首段总结一句,用一条「超短金句」概括这个人\n"
|
||||
. "4) 使用第二人称「你」,称呼:{$nickname}\n"
|
||||
. "5) Markdown 纯文本,章节用 「## 章节标题」 "
|
||||
. "";
|
||||
|
||||
$prompt = "【用户 MBTI】{$mbti}\n【最近对话】\n" . (empty($history) ? '(无历史对话)' : implode("\n", $history));
|
||||
|
||||
$r = AiCallService::chat([
|
||||
['role' => 'system', 'content' => $system],
|
||||
['role' => 'user', 'content' => $prompt],
|
||||
], ['maxTokens' => 3000, 'temperature' => 0.75]);
|
||||
|
||||
$content = (string) ($r['content'] ?? '');
|
||||
if ($content === '' || !empty($r['isDegraded'])) {
|
||||
throw new \RuntimeException('AI 生成失败或降级:' . (string) ($r['content'] ?? ''));
|
||||
}
|
||||
|
||||
$summary = self::extractSummary($content);
|
||||
$title = self::extractTitle($content, $mbti);
|
||||
|
||||
$m->title = $title;
|
||||
$m->summary = $summary;
|
||||
$m->content = $content;
|
||||
$m->status = 'done';
|
||||
$m->generatedAt = time();
|
||||
$m->lastError = null;
|
||||
$m->save();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('AiReportService generate fail: ' . $e->getMessage());
|
||||
$m->status = $m->retryCount >= 3 ? 'failed' : 'paid';
|
||||
$m->lastError = mb_substr($e->getMessage(), 0, 480);
|
||||
$m->save();
|
||||
}
|
||||
}
|
||||
|
||||
private static function extractSummary(string $md): string
|
||||
{
|
||||
$plain = preg_replace('/[#>*_`\[\]]+/', '', $md);
|
||||
$plain = trim((string) $plain);
|
||||
return mb_substr($plain, 0, 180);
|
||||
}
|
||||
|
||||
private static function extractTitle(string $md, string $mbti): string
|
||||
{
|
||||
if (preg_match('/^\s*#+\s*(.+)$/m', $md, $mch)) {
|
||||
return mb_substr(trim($mch[1]), 0, 80);
|
||||
}
|
||||
return "{$mbti} · 神仙 AI 深度画像";
|
||||
}
|
||||
|
||||
private static function toArray(AiReportModel $m): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $m->id,
|
||||
'userId' => (int) $m->userId,
|
||||
'conversationId' => (int) $m->conversationId,
|
||||
'mbtiType' => $m->mbtiType,
|
||||
'orderSn' => $m->orderSn,
|
||||
'priceFen' => (int) $m->priceFen,
|
||||
'priceYuan' => round($m->priceFen / 100, 2),
|
||||
'status' => $m->status,
|
||||
'title' => $m->title,
|
||||
'summary' => $m->summary,
|
||||
'content' => $m->content,
|
||||
'posterUrl' => $m->posterUrl,
|
||||
'paidAt' => (int) $m->paidAt,
|
||||
'generatedAt' => (int) $m->generatedAt,
|
||||
'createdAt' => (int) $m->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,26 @@ class FeishuLeadWebhookService
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序「了解自己」页提交咨询申请(与 CrmReport 存客宝上报并行;飞书机器人需 feishu_lead_webhook.enabled)
|
||||
*/
|
||||
public static function onDeepServiceConsultApply(int $userId, string $source, string $categoryTag): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
$src = trim($source) !== '' ? trim($source) : '深度服务·申请咨询';
|
||||
$tag = trim($categoryTag);
|
||||
$dedup = 'deep_consult_apply:' . $userId . ':' . md5($src . '|' . $tag) . ':' . date('YmdH');
|
||||
$extra = $tag !== '' ? ('套餐: ' . self::oneLine($tag, 60)) : '';
|
||||
self::pushLead([
|
||||
'dedupKey' => $dedup,
|
||||
'userId' => $userId,
|
||||
'source' => $src,
|
||||
'extraLine' => $extra,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{dedupKey:string,userId:int,source:string,phone?:string,extraLine?:string} $p
|
||||
*/
|
||||
@@ -127,7 +147,7 @@ class FeishuLeadWebhookService
|
||||
$text .= "\n" . $p['extraLine'];
|
||||
}
|
||||
|
||||
$lines = self::recentBehaviorLines($userId, 8);
|
||||
$lines = UserJourneyService::recentBehaviorLines($userId, 8);
|
||||
if (count($lines) > 0) {
|
||||
$text .= "\n━━━━━━━━━━\n最近行为:";
|
||||
$i = 1;
|
||||
@@ -184,85 +204,6 @@ class FeishuLeadWebhookService
|
||||
return $s;
|
||||
}
|
||||
|
||||
private static function recentBehaviorLines(int $userId, int $limit): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$rows = Db::name('analytics_events')
|
||||
->where('userId', $userId)
|
||||
->order('id', 'desc')
|
||||
->limit($limit)
|
||||
->select()
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$out[] = self::formatAnalyticsLine($r);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function formatAnalyticsLine(array $r): string
|
||||
{
|
||||
$name = (string) ($r['eventName'] ?? '');
|
||||
$path = trim((string) ($r['pagePath'] ?? ''));
|
||||
$props = [];
|
||||
if (!empty($r['propsJson'])) {
|
||||
$decoded = is_string($r['propsJson']) ? json_decode($r['propsJson'], true) : [];
|
||||
$props = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
$labelMap = [
|
||||
'page_view' => '浏览页面',
|
||||
'button_click' => '按钮点击',
|
||||
'click_pay' => '发起支付',
|
||||
'click_recharge'=> '点击充值',
|
||||
];
|
||||
$label = $labelMap[$name] ?? $name;
|
||||
$detail = '';
|
||||
if ($name === 'page_view' && $path !== '') {
|
||||
$detail = $path;
|
||||
}
|
||||
if (isset($props['action']) && (string) $props['action'] !== '') {
|
||||
$detail = (string) $props['action'];
|
||||
if (!empty($props['productType'])) {
|
||||
$detail .= ' · ' . (string) $props['productType'];
|
||||
}
|
||||
} elseif (isset($props['label']) && (string) $props['label'] !== '') {
|
||||
$detail = (string) $props['label'];
|
||||
} elseif ($path !== '' && $detail === '') {
|
||||
$detail = $path;
|
||||
}
|
||||
$line = $detail !== '' ? "{$label}: {$detail}" : $label;
|
||||
$ts = isset($r['clientTs']) ? (int) $r['clientTs'] : null;
|
||||
if (!$ts && !empty($r['createdAt'])) {
|
||||
$ts = strtotime((string) $r['createdAt']) * 1000;
|
||||
}
|
||||
if ($ts) {
|
||||
$line .= ' · ' . self::humanTimeAgoCn((int) round($ts));
|
||||
}
|
||||
return $line;
|
||||
}
|
||||
|
||||
private static function humanTimeAgoCn(int $clientTsMs): string
|
||||
{
|
||||
$now = (int) (microtime(true) * 1000);
|
||||
$sec = max(0, (int) (($now - $clientTsMs) / 1000));
|
||||
if ($sec < 60) {
|
||||
return '刚刚';
|
||||
}
|
||||
if ($sec < 3600) {
|
||||
return (int) floor($sec / 60) . '分钟前';
|
||||
}
|
||||
if ($sec < 86400) {
|
||||
return (int) floor($sec / 3600) . '小时前';
|
||||
}
|
||||
return (int) floor($sec / 86400) . '天前';
|
||||
}
|
||||
|
||||
private static function beginDedup(string $dedupKey): bool
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -243,8 +243,29 @@ class OutboundPushHookService
|
||||
$lines[] = '测试结果: ' . self::truncatePlainText((string) ($payload['resultSummary'] ?? ''), 100) . "\n";
|
||||
}
|
||||
$lines[] = '测试时间: ' . $testTime;
|
||||
$body = implode('', $lines);
|
||||
$mgmtSummary = trim((string) ($payload['managementSummary'] ?? ''));
|
||||
if ($mgmtSummary !== '') {
|
||||
$body .= "\n━━━━━━━━━━\n用户管理:\n" . $mgmtSummary;
|
||||
}
|
||||
$um = $payload['userManagement'] ?? null;
|
||||
if (is_array($um) && !empty($um['openidTail6'])) {
|
||||
$body .= "\nOpenID尾号(脱敏): " . (string) $um['openidTail6'];
|
||||
}
|
||||
$beh = $payload['recentBehaviors'] ?? [];
|
||||
if (is_array($beh) && count($beh) > 0) {
|
||||
$body .= "\n━━━━━━━━━━\n最近行为:";
|
||||
$i = 1;
|
||||
foreach ($beh as $bl) {
|
||||
if (!is_string($bl) || $bl === '') {
|
||||
continue;
|
||||
}
|
||||
$body .= "\n {$i}. {$bl}";
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
|
||||
return implode('', $lines);
|
||||
return $body;
|
||||
default:
|
||||
return $head . "\n事件: " . $event;
|
||||
}
|
||||
@@ -590,6 +611,50 @@ class OutboundPushHookService
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测评完成 JSON / 飞书文本:附加用户管理字段 + analytics 用户旅程(与 FeishuLeadWebhookService 获客卡片「最近行为」同源)
|
||||
*
|
||||
* @param array<string,mixed> $payload 引用
|
||||
* @param array<string,mixed> $testResultRow test_results 一行
|
||||
* @param array<string,mixed>|null $wuArr wechat_users 行(可含 openid)
|
||||
*/
|
||||
private static function mergeTestResultUserJourneyPayload(array &$payload, int $userId, array $testResultRow, ?array $wuArr): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
$payload['recentBehaviors'] = [];
|
||||
$payload['userManagement'] = [
|
||||
'wechatUserId' => 0,
|
||||
'enterpriseId' => 0,
|
||||
'enterpriseName' => null,
|
||||
'openidTail6' => null,
|
||||
'testScope' => (string) ($testResultRow['testScope'] ?? ''),
|
||||
];
|
||||
$payload['managementSummary'] = '';
|
||||
|
||||
return;
|
||||
}
|
||||
$eidResolved = self::resolveEnterpriseIdForTestResult($testResultRow, $wuArr);
|
||||
$tenant = self::tenantPayload($eidResolved);
|
||||
$openid = '';
|
||||
if ($wuArr !== null && isset($wuArr['openid'])) {
|
||||
$openid = trim((string) $wuArr['openid']);
|
||||
}
|
||||
$tail = '';
|
||||
if ($openid !== '') {
|
||||
$tail = strlen($openid) >= 6 ? substr($openid, -6) : $openid;
|
||||
}
|
||||
$payload['userManagement'] = [
|
||||
'wechatUserId' => $userId,
|
||||
'enterpriseId' => $eidResolved,
|
||||
'enterpriseName' => $tenant['enterpriseName'],
|
||||
'openidTail6' => $tail !== '' ? $tail : null,
|
||||
'testScope' => (string) ($testResultRow['testScope'] ?? ''),
|
||||
];
|
||||
$payload['recentBehaviors'] = UserJourneyService::recentBehaviorLines($userId, 12);
|
||||
$trEid = (int) ($testResultRow['enterpriseId'] ?? 0);
|
||||
$payload['managementSummary'] = UserJourneyService::managementSummaryLine($userId, $trEid);
|
||||
}
|
||||
|
||||
public static function isEventEnabled(string $event, array $cfg): bool
|
||||
{
|
||||
if (empty($cfg['enabled'])) {
|
||||
@@ -712,7 +777,7 @@ class OutboundPushHookService
|
||||
$completedAt = date('Y-m-d H:i:s', $createdAt);
|
||||
|
||||
$wu = $userId > 0
|
||||
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId')->find()
|
||||
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId,openid')->find()
|
||||
: null;
|
||||
$userName = $wu ? trim((string) ($wu['nickname'] ?? '')) : '';
|
||||
if ($userName === '') {
|
||||
@@ -765,6 +830,7 @@ class OutboundPushHookService
|
||||
$payload['resultDisc'] = $dims['disc'];
|
||||
}
|
||||
}
|
||||
self::mergeTestResultUserJourneyPayload($payload, $userId, $row, $wuArr);
|
||||
|
||||
self::dispatch('test.result_completed', [
|
||||
'event' => 'test.result_completed',
|
||||
@@ -807,7 +873,7 @@ class OutboundPushHookService
|
||||
$completedAt = date('Y-m-d H:i:s', $createdAt);
|
||||
|
||||
$wu = $userId > 0
|
||||
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId')->find()
|
||||
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId,openid')->find()
|
||||
: null;
|
||||
$userName = $wu ? trim((string) ($wu['nickname'] ?? '')) : '';
|
||||
if ($userName === '') {
|
||||
@@ -860,6 +926,7 @@ class OutboundPushHookService
|
||||
$payload['resultDisc'] = $dims['disc'];
|
||||
}
|
||||
}
|
||||
self::mergeTestResultUserJourneyPayload($payload, $userId, $row, $wuArr);
|
||||
|
||||
return self::dispatchDetailed('test.result_completed', [
|
||||
'event' => 'test.result_completed',
|
||||
|
||||
259
api/app/common/service/ProfitSharingService.php
Normal file
259
api/app/common/service/ProfitSharingService.php
Normal file
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 订单分账服务(参考知己 profit-sharing 思路)
|
||||
*
|
||||
* 用法:订单支付成功后调用 ::executeSharing($orderSn, $productType, $totalFen, $extra)
|
||||
* - 按 mbti_profit_sharing_rules 配置把 totalFen 拆给多个 receiver
|
||||
* - 对 distributor_l1 / distributor_l2:会联动现有 mbti_commission_records( 复用项目已有分销系统 )
|
||||
* - 最终写入 mbti_profit_sharing_records 一行(orderSn 唯一),details JSON 里记所有明细
|
||||
*
|
||||
* 该服务 **idempotent**:同一 orderSn 重复调用不会重复分账。
|
||||
*/
|
||||
class ProfitSharingService
|
||||
{
|
||||
/**
|
||||
* @param string $orderSn 订单号(唯一)
|
||||
* @param string $productType 产品类型,对应 mbti_profit_sharing_rules.productType,找不到则走 default
|
||||
* @param int $totalFen 订单总金额(分)
|
||||
* @param array $extra 扩展 ['userId'=>购买者id, 'orderId'=>订单表id, 'consultantId'=>?]
|
||||
* @return array 分账明细
|
||||
*/
|
||||
public static function executeSharing(string $orderSn, string $productType, int $totalFen, array $extra = []): array
|
||||
{
|
||||
if ($totalFen <= 0 || $orderSn === '') {
|
||||
return ['status' => 'skipped', 'reason' => 'invalid-input'];
|
||||
}
|
||||
|
||||
$dup = Db::name('profit_sharing_records')->where('orderSn', $orderSn)->find();
|
||||
if ($dup) {
|
||||
return ['status' => 'dedup', 'recordId' => $dup['id']];
|
||||
}
|
||||
|
||||
$rule = self::resolveRule($productType);
|
||||
$receivers = $rule['receivers'] ?? [];
|
||||
if (!is_array($receivers) || count($receivers) === 0) {
|
||||
$receivers = [['type' => 'platform', 'name' => '平台', 'ratio' => 1.0]];
|
||||
}
|
||||
|
||||
$totalRatio = 0.0;
|
||||
foreach ($receivers as $r) { $totalRatio += (float) ($r['ratio'] ?? 0); }
|
||||
if (abs($totalRatio - 1.0) > 0.001) {
|
||||
Log::warning("ProfitSharingService: rule {$productType} ratio sum={$totalRatio}, will renormalize");
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$details = [];
|
||||
$buyerUserId = (int) ($extra['userId'] ?? 0);
|
||||
|
||||
// 一级 / 二级分销人解析(复用现有 distribution_bindings)
|
||||
list($inviterL1, $inviterL2) = self::resolveInviters($buyerUserId);
|
||||
|
||||
$allocated = 0;
|
||||
foreach ($receivers as $idx => $r) {
|
||||
$ratio = (float) ($r['ratio'] ?? 0);
|
||||
$amount = (int) floor($totalFen * $ratio);
|
||||
$type = (string) ($r['type'] ?? 'platform');
|
||||
$account = null;
|
||||
$status = 'success';
|
||||
|
||||
if ($type === 'distributor_l1') {
|
||||
$account = $inviterL1 ? (string) $inviterL1 : null;
|
||||
if (!$inviterL1) {
|
||||
// 没有一级分销 → 金额归还平台
|
||||
$status = 'no-inviter';
|
||||
$amount = 0;
|
||||
}
|
||||
} elseif ($type === 'distributor_l2') {
|
||||
$account = $inviterL2 ? (string) $inviterL2 : null;
|
||||
if (!$inviterL2) {
|
||||
$status = 'no-inviter';
|
||||
$amount = 0;
|
||||
}
|
||||
} elseif ($type === 'consultant') {
|
||||
$account = isset($extra['consultantId']) ? (string) $extra['consultantId'] : null;
|
||||
}
|
||||
|
||||
$details[] = [
|
||||
'receiverType' => $type,
|
||||
'receiverName' => (string) ($r['name'] ?? $type),
|
||||
'amount' => $amount,
|
||||
'ratio' => $ratio,
|
||||
'account' => $account,
|
||||
'status' => $status,
|
||||
];
|
||||
$allocated += $amount;
|
||||
}
|
||||
|
||||
// 尾差归平台
|
||||
if ($allocated < $totalFen) {
|
||||
$diff = $totalFen - $allocated;
|
||||
foreach ($details as &$d) {
|
||||
if ($d['receiverType'] === 'platform') { $d['amount'] += $diff; break; }
|
||||
}
|
||||
unset($d);
|
||||
}
|
||||
|
||||
// 写主记录
|
||||
$recordId = Db::name('profit_sharing_records')->insertGetId([
|
||||
'orderSn' => $orderSn,
|
||||
'orderId' => (int) ($extra['orderId'] ?? 0) ?: null,
|
||||
'productType' => $productType,
|
||||
'totalAmount' => $totalFen,
|
||||
'details' => json_encode($details, JSON_UNESCAPED_UNICODE),
|
||||
'status' => 'processing',
|
||||
'createdAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
|
||||
// 联动现有分销系统:把 distributor_l1/l2 的金额写进 mbti_commission_records
|
||||
try {
|
||||
foreach ($details as $d) {
|
||||
if (in_array($d['receiverType'], ['distributor_l1', 'distributor_l2'], true)
|
||||
&& (int) $d['amount'] > 0 && !empty($d['account'])) {
|
||||
self::createCommissionRecord(
|
||||
(int) $d['account'],
|
||||
$buyerUserId,
|
||||
$d['receiverType'] === 'distributor_l1' ? 1 : 2,
|
||||
(int) $d['amount'],
|
||||
(float) $d['ratio'],
|
||||
$totalFen,
|
||||
$orderSn,
|
||||
(int) ($extra['orderId'] ?? 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('ProfitSharingService commission hook failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
Db::name('profit_sharing_records')->where('id', $recordId)->update([
|
||||
'status' => 'completed',
|
||||
'processedAt' => time(),
|
||||
'updatedAt' => time(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'status' => 'completed',
|
||||
'recordId' => $recordId,
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
|
||||
private static function resolveRule(string $productType): array
|
||||
{
|
||||
$row = Db::name('profit_sharing_rules')
|
||||
->where('productType', $productType)
|
||||
->where('status', 'active')
|
||||
->find();
|
||||
if (!$row) {
|
||||
$row = Db::name('profit_sharing_rules')
|
||||
->where('productType', 'default')
|
||||
->where('status', 'active')
|
||||
->find();
|
||||
}
|
||||
if (!$row) {
|
||||
return ['receivers' => [['type' => 'platform', 'name' => '平台', 'ratio' => 1.0]]];
|
||||
}
|
||||
$receivers = json_decode($row['receivers'] ?? '[]', true) ?: [];
|
||||
return ['receivers' => $receivers];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一级/二级分销人(复用 mbti_distribution_bindings)
|
||||
* @return array [inviterL1_userId|null, inviterL2_userId|null]
|
||||
*/
|
||||
private static function resolveInviters(int $buyerUserId): array
|
||||
{
|
||||
if ($buyerUserId <= 0) return [null, null];
|
||||
try {
|
||||
$now = time();
|
||||
$bind1 = Db::name('distribution_bindings')
|
||||
->where('inviteeId', $buyerUserId)
|
||||
->where('status', 'active')
|
||||
->where('expireAt', '>', $now)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!$bind1) return [null, null];
|
||||
$l1 = (int) $bind1['inviterId'];
|
||||
$bind2 = Db::name('distribution_bindings')
|
||||
->where('inviteeId', $l1)
|
||||
->where('status', 'active')
|
||||
->where('expireAt', '>', $now)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
$l2 = $bind2 ? (int) $bind2['inviterId'] : null;
|
||||
return [$l1, $l2];
|
||||
} catch (\Throwable $e) {
|
||||
return [null, null];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 mbti_commission_records(复用项目已有的分销佣金表结构)
|
||||
* 若该表结构/字段命名不同,请在本方法内做适配;当前按 mbti_data.sql 中看到的字段写入
|
||||
*/
|
||||
private static function createCommissionRecord(
|
||||
int $inviterId,
|
||||
int $inviteeId,
|
||||
int $level,
|
||||
int $amountFen,
|
||||
float $rate,
|
||||
int $orderTotalFen,
|
||||
string $orderSn,
|
||||
int $orderId
|
||||
): void {
|
||||
$now = time();
|
||||
// 防重:相同 orderSn + inviterId + level 只插一次
|
||||
$dup = Db::name('commission_records')
|
||||
->where('orderSn', $orderSn)
|
||||
->where('inviterId', $inviterId)
|
||||
->where('level', $level)
|
||||
->find();
|
||||
if ($dup) return;
|
||||
|
||||
// 字段按 mbti_commission_records 实际结构写;amountFen 存分、amount 存元(容错)
|
||||
$data = [
|
||||
'inviterId' => $inviterId,
|
||||
'inviteeId' => $inviteeId,
|
||||
'level' => $level,
|
||||
'rate' => $rate,
|
||||
'amount' => round($amountFen / 100, 2),
|
||||
'amountFen' => $amountFen,
|
||||
'orderId' => $orderId ?: null,
|
||||
'orderSn' => $orderSn,
|
||||
'orderAmountFen'=> $orderTotalFen,
|
||||
'status' => 'pending',
|
||||
'scope' => 'personal',
|
||||
'source' => 'ai_deep_report',
|
||||
'createdAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
];
|
||||
|
||||
try {
|
||||
Db::name('commission_records')->insert($data);
|
||||
} catch (\Throwable $e) {
|
||||
// 字段不匹配时退化:只写关键字段
|
||||
Log::warning('commission_records insert fallback: ' . $e->getMessage());
|
||||
try {
|
||||
Db::name('commission_records')->insert([
|
||||
'inviterId' => $inviterId,
|
||||
'inviteeId' => $inviteeId,
|
||||
'level' => $level,
|
||||
'rate' => $rate,
|
||||
'amount' => round($amountFen / 100, 2),
|
||||
'status' => 'pending',
|
||||
'orderId' => $orderId ?: null,
|
||||
'createdAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
} catch (\Throwable $e2) {
|
||||
Log::error('commission_records insert failed: ' . $e2->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
441
api/app/common/service/SoulArticleService.php
Normal file
441
api/app/common/service/SoulArticleService.php
Normal file
@@ -0,0 +1,441 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use app\model\SoulArticle as SoulArticleModel;
|
||||
use app\model\SystemConfig as SystemConfigModel;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* Soul 文章采集与推荐服务
|
||||
*
|
||||
* 数据源:一场 soul 创业实验 对外 API(默认 base 见 resolveConfig)
|
||||
* 采集方式:仅本服务内 curl HTTP(S) 请求上述接口;不写磁盘拉取、不经 SSH、不连对方服务器 shell
|
||||
* 鉴权:system_config.key=`soul_api` 的 {baseUrl, token};环境变量 SOUL_API_BASE / SOUL_API_TOKEN 兜底
|
||||
*/
|
||||
class SoulArticleService
|
||||
{
|
||||
/**
|
||||
* 按关键词搜索 Soul 内容并写入本地文章池
|
||||
*/
|
||||
public static function syncByKeyword(string $keyword, int $limit = 10, string $tag = 'MBTI'): array
|
||||
{
|
||||
$keyword = trim($keyword);
|
||||
if ($keyword === '') {
|
||||
return ['fetched' => 0, 'created' => 0, 'updated' => 0, 'items' => [], 'error' => '关键词不能为空'];
|
||||
}
|
||||
|
||||
$cfg = self::resolveConfig();
|
||||
if ($cfg['baseUrl'] === '') {
|
||||
return ['fetched' => 0, 'created' => 0, 'updated' => 0, 'items' => [], 'error' => 'soul_api.baseUrl 未配置'];
|
||||
}
|
||||
$base = rtrim($cfg['baseUrl'], '/');
|
||||
|
||||
$headers = ['Accept: application/json'];
|
||||
if ($cfg['token'] !== '') {
|
||||
$headers[] = 'Authorization: Bearer ' . $cfg['token'];
|
||||
}
|
||||
|
||||
// 优先尝试通用文章接口(若支持 keyword)
|
||||
$url = $base . '/api/articles?' . http_build_query([
|
||||
'keyword' => $keyword,
|
||||
'limit' => $limit,
|
||||
'sort' => 'publishedAt:desc',
|
||||
]);
|
||||
[$response] = self::httpGet($url, $headers);
|
||||
$data = is_string($response) ? json_decode($response, true) : null;
|
||||
$list = [];
|
||||
if (is_array($data)) {
|
||||
if (isset($data['data']['list']) && is_array($data['data']['list'])) {
|
||||
$list = $data['data']['list'];
|
||||
} elseif (isset($data['data']) && is_array($data['data']) && isset($data['data'][0])) {
|
||||
$list = $data['data'];
|
||||
} elseif (isset($data['items']) && is_array($data['items'])) {
|
||||
$list = $data['items'];
|
||||
} elseif (isset($data[0])) {
|
||||
$list = $data;
|
||||
}
|
||||
}
|
||||
|
||||
// 若无结果,回退到 book API 全量抓取后关键词过滤
|
||||
if (empty($list)) {
|
||||
$list = self::fetchFromSoulBookApi($base, max($limit * 3, 30), $tag, $keyword);
|
||||
}
|
||||
|
||||
return self::upsertArticles($list, $tag, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Soul 后台拉取最新 N 篇 MBTI 主题文章并写入本地 soul_articles 表
|
||||
*
|
||||
* @return array ['fetched'=>int, 'created'=>int, 'updated'=>int, 'items'=>[…]]
|
||||
*/
|
||||
public static function syncLatest(int $limit = 10, string $tag = 'MBTI'): array
|
||||
{
|
||||
$cfg = self::resolveConfig();
|
||||
if ($cfg['baseUrl'] === '') {
|
||||
return ['fetched' => 0, 'created' => 0, 'updated' => 0, 'items' => [], 'error' => 'soul_api.baseUrl 未配置'];
|
||||
}
|
||||
|
||||
$base = rtrim($cfg['baseUrl'], '/');
|
||||
$url = $base . '/api/articles';
|
||||
$params = [
|
||||
'tag' => $tag,
|
||||
'limit' => $limit,
|
||||
'sort' => 'publishedAt:desc',
|
||||
];
|
||||
$url .= '?' . http_build_query($params);
|
||||
|
||||
$headers = ['Accept: application/json'];
|
||||
if ($cfg['token'] !== '') {
|
||||
$headers[] = 'Authorization: Bearer ' . $cfg['token'];
|
||||
}
|
||||
|
||||
[$response, $httpCode, $err] = self::httpGet($url, $headers);
|
||||
|
||||
if ($response === false || $response === '') {
|
||||
Log::warning("SoulArticleService sync failed: http={$httpCode} err={$err}");
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
|
||||
// 兼容常见返回结构:{data:{list:[]}} / {data:[]} / {items:[]}
|
||||
$list = [];
|
||||
if (isset($data['data']['list']) && is_array($data['data']['list'])) {
|
||||
$list = $data['data']['list'];
|
||||
} elseif (isset($data['data']) && is_array($data['data']) && isset($data['data'][0])) {
|
||||
$list = $data['data'];
|
||||
} elseif (isset($data['items']) && is_array($data['items'])) {
|
||||
$list = $data['items'];
|
||||
} elseif (isset($data[0])) {
|
||||
$list = $data;
|
||||
}
|
||||
|
||||
// 兼容「一场 soul 创业实验」新接口:/api/miniprogram/book/*
|
||||
if (empty($list)) {
|
||||
$list = self::fetchFromSoulBookApi($base, $limit, $tag);
|
||||
}
|
||||
|
||||
return self::upsertArticles($list, $tag, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动推送(傻瓜模式):检查 last_sync_at,超过 intervalSec 则自动执行一次采集
|
||||
* - 非阻塞:调用方可忽略返回值
|
||||
* - 配置:system_config.key='soul_article_auto_sync' {enabled, intervalSec, limit}
|
||||
* @return array ['didSync'=>bool, ...syncResult]
|
||||
*/
|
||||
public static function autoSyncIfStale(): array
|
||||
{
|
||||
try {
|
||||
$cfgRow = SystemConfigModel::where('key', 'soul_article_auto_sync')->find();
|
||||
$enabled = true;
|
||||
$intervalSec = 3600;
|
||||
$limit = 10;
|
||||
if ($cfgRow && !empty($cfgRow->value)) {
|
||||
$v = $cfgRow->value;
|
||||
if (is_string($v)) {
|
||||
$d = json_decode($v, true);
|
||||
if (is_array($d)) $v = $d;
|
||||
}
|
||||
if (is_array($v)) {
|
||||
$enabled = !isset($v['enabled']) ? true : (bool) $v['enabled'];
|
||||
$intervalSec = (int) ($v['intervalSec'] ?? 3600);
|
||||
$limit = (int) ($v['limit'] ?? 10);
|
||||
}
|
||||
}
|
||||
if (!$enabled) return ['didSync' => false, 'reason' => 'disabled'];
|
||||
|
||||
$lastRow = SystemConfigModel::where('key', 'soul_article_last_sync_at')->find();
|
||||
$last = 0;
|
||||
if ($lastRow && !empty($lastRow->value)) {
|
||||
$last = is_numeric($lastRow->value) ? (int) $lastRow->value : (int) strtotime($lastRow->value);
|
||||
}
|
||||
$now = time();
|
||||
if ($last > 0 && ($now - $last) < $intervalSec) {
|
||||
return ['didSync' => false, 'lastAt' => $last, 'reason' => 'fresh'];
|
||||
}
|
||||
|
||||
$r = self::syncLatest($limit, 'MBTI');
|
||||
|
||||
// 写回 last sync(即使采集 0 篇也写回,避免高频重试)
|
||||
$val = (string) $now;
|
||||
$exist = SystemConfigModel::where('key', 'soul_article_last_sync_at')->find();
|
||||
if ($exist) {
|
||||
$exist->value = $val;
|
||||
$exist->save();
|
||||
} else {
|
||||
SystemConfigModel::create([
|
||||
'key' => 'soul_article_last_sync_at',
|
||||
'value' => $val,
|
||||
'description' => 'Soul 文章最近一次自动采集时间戳',
|
||||
]);
|
||||
}
|
||||
return array_merge(['didSync' => true, 'lastAt' => $now], $r);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SoulArticleService autoSync failed: ' . $e->getMessage());
|
||||
return ['didSync' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前已推荐(最多 3 篇)
|
||||
*/
|
||||
public static function getRecommended(int $limit = 3): array
|
||||
{
|
||||
// 仅返回后台「推荐位」文章,不用候选池补齐(与超管「当前推荐」严格一致)
|
||||
$rows = SoulArticleModel::where('isRecommended', 1)
|
||||
->order('recommendedOrder', 'asc')
|
||||
->order('publishedAt', 'desc')
|
||||
->limit($limit)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return array_map(function ($r) {
|
||||
$pub = (int) ($r['publishedAt'] ?? 0);
|
||||
$upd = (int) ($r['updatedAt'] ?? 0);
|
||||
// 避免 0 / 无效时间戳被格式化成 1970-01-01
|
||||
$dateStr = '';
|
||||
if ($pub > 946684800) {
|
||||
$dateStr = date('Y-m-d', $pub);
|
||||
} elseif ($upd > 946684800) {
|
||||
$dateStr = date('Y-m-d', $upd);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $r['id'],
|
||||
'sourceId' => $r['sourceId'],
|
||||
'title' => $r['title'],
|
||||
'cover' => $r['cover'],
|
||||
'url' => $r['url'],
|
||||
'summary' => $r['summary'],
|
||||
'author' => $r['author'],
|
||||
'tag' => $r['tag'],
|
||||
'publishedAt' => $dateStr,
|
||||
];
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把某篇文章设为推荐(至多 3 篇;超出时自动顶掉最老的一篇)
|
||||
*/
|
||||
public static function recommend(int $articleId): array
|
||||
{
|
||||
$article = SoulArticleModel::find($articleId);
|
||||
if (!$article) {
|
||||
return ['ok' => false, 'message' => '文章不存在'];
|
||||
}
|
||||
if ($article->isRecommended) {
|
||||
$article->isRecommended = 0;
|
||||
$article->save();
|
||||
return ['ok' => true, 'message' => '已取消推荐', 'isRecommended' => false];
|
||||
}
|
||||
|
||||
$current = SoulArticleModel::where('isRecommended', 1)->order('recommendedOrder', 'asc')->select()->toArray();
|
||||
if (count($current) >= 3) {
|
||||
// 顶掉最老的一篇
|
||||
$oldest = end($current);
|
||||
$old = SoulArticleModel::find($oldest['id']);
|
||||
if ($old) {
|
||||
$old->isRecommended = 0;
|
||||
$old->save();
|
||||
}
|
||||
}
|
||||
|
||||
$maxOrder = (int) SoulArticleModel::where('isRecommended', 1)->max('recommendedOrder');
|
||||
$article->isRecommended = 1;
|
||||
$article->recommendedOrder = $maxOrder + 1;
|
||||
$article->save();
|
||||
|
||||
return ['ok' => true, 'message' => '已设为推荐', 'isRecommended' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* soul_api 配置:system_config.key='soul_api' / env 兜底
|
||||
*/
|
||||
private static function resolveConfig(): array
|
||||
{
|
||||
$baseUrl = trim((string) getenv('SOUL_API_BASE'));
|
||||
$token = trim((string) getenv('SOUL_API_TOKEN'));
|
||||
|
||||
$row = SystemConfigModel::where('key', 'soul_api')->find();
|
||||
if ($row && !empty($row->value)) {
|
||||
$v = $row->value;
|
||||
if (is_string($v)) {
|
||||
$decoded = json_decode($v, true);
|
||||
if (is_array($decoded)) $v = $decoded;
|
||||
}
|
||||
if (is_array($v)) {
|
||||
if (!empty($v['baseUrl'])) $baseUrl = (string) $v['baseUrl'];
|
||||
if (!empty($v['token'])) $token = (string) $v['token'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($baseUrl === '') {
|
||||
$baseUrl = 'https://soulapi.quwanzhi.com';
|
||||
}
|
||||
|
||||
return ['baseUrl' => $baseUrl, 'token' => $token];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Soul 小程序图书接口拉取最新章节,映射成文章池结构(用于 /api/articles 不可用时兜底)
|
||||
*/
|
||||
private static function fetchFromSoulBookApi(string $baseUrl, int $limit, string $tag, string $keyword = ''): array
|
||||
{
|
||||
$partsUrl = rtrim($baseUrl, '/') . '/api/miniprogram/book/parts';
|
||||
[$rawParts] = self::httpGet($partsUrl, ['Accept: application/json']);
|
||||
$partsData = is_string($rawParts) ? json_decode($rawParts, true) : null;
|
||||
if (!is_array($partsData)) return [];
|
||||
$parts = $partsData['parts'] ?? $partsData['data'] ?? [];
|
||||
if (!is_array($parts) || empty($parts)) return [];
|
||||
|
||||
$chapters = [];
|
||||
foreach ($parts as $p) {
|
||||
$partId = (string) ($p['id'] ?? '');
|
||||
if ($partId === '') continue;
|
||||
$chaptersUrl = rtrim($baseUrl, '/') . '/api/miniprogram/book/chapters-by-part?partId=' . rawurlencode($partId);
|
||||
[$rawRows] = self::httpGet($chaptersUrl, ['Accept: application/json']);
|
||||
$rowsData = is_string($rawRows) ? json_decode($rawRows, true) : null;
|
||||
if (!is_array($rowsData)) continue;
|
||||
$rows = $rowsData['data'] ?? $rowsData['list'] ?? [];
|
||||
if (!is_array($rows)) continue;
|
||||
foreach ($rows as $row) {
|
||||
$id = (string) ($row['id'] ?? '');
|
||||
$title = trim((string) ($row['sectionTitle'] ?? $row['title'] ?? ''));
|
||||
if ($id === '' || $title === '') continue;
|
||||
// 只抓 MBTI 相关,减少噪音;标题无 MBTI 时保留“第x场”高频经营内容
|
||||
$lc = mb_strtolower($title, 'UTF-8');
|
||||
if (mb_strpos($lc, 'mbti') === false && mb_strpos($title, '第') === false) {
|
||||
continue;
|
||||
}
|
||||
if ($keyword !== '') {
|
||||
$kw = mb_strtolower($keyword, 'UTF-8');
|
||||
$summary = trim((string) ($row['summary'] ?? ''));
|
||||
$haystack = mb_strtolower($title . ' ' . $summary, 'UTF-8');
|
||||
if (mb_strpos($haystack, $kw) === false) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$publishedAt = 0;
|
||||
$pubRaw = $row['updatedAt'] ?? ($row['createdAt'] ?? 0);
|
||||
if (is_numeric($pubRaw)) {
|
||||
$publishedAt = (int) $pubRaw;
|
||||
} elseif (is_string($pubRaw) && trim($pubRaw) !== '') {
|
||||
$t = strtotime((string) $pubRaw);
|
||||
if ($t) $publishedAt = (int) $t;
|
||||
}
|
||||
// 异常时间戳(如 0 / 1970)兜底当前时间
|
||||
if ($publishedAt < 946684800) {
|
||||
$publishedAt = time();
|
||||
}
|
||||
$cover = (string) ($row['coverUrl'] ?? $row['cover'] ?? '');
|
||||
$summary = trim((string) ($row['summary'] ?? ''));
|
||||
$chapters[] = [
|
||||
'id' => $id,
|
||||
'title' => $title,
|
||||
'url' => rtrim($baseUrl, '/') . '/read/' . rawurlencode($id),
|
||||
'cover' => $cover,
|
||||
'summary' => $summary,
|
||||
'author' => '一场 soul 创业实验',
|
||||
'tag' => $tag,
|
||||
'publishedAt' => $publishedAt ?: time(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($chapters, function ($a, $b) {
|
||||
return (int)($b['publishedAt'] ?? 0) <=> (int)($a['publishedAt'] ?? 0);
|
||||
});
|
||||
if (count($chapters) > $limit) {
|
||||
$chapters = array_slice($chapters, 0, $limit);
|
||||
}
|
||||
return $chapters;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一写入文章池(幂等按 sourceId)
|
||||
*/
|
||||
private static function upsertArticles(array $list, string $tag, int $limit): array
|
||||
{
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$items = [];
|
||||
$now = time();
|
||||
|
||||
if (!empty($list) && count($list) > $limit) {
|
||||
$list = array_slice($list, 0, $limit);
|
||||
}
|
||||
|
||||
foreach ($list as $raw) {
|
||||
$sourceId = (string) ($raw['id'] ?? $raw['sourceId'] ?? $raw['articleId'] ?? '');
|
||||
$title = trim((string) ($raw['title'] ?? ''));
|
||||
$url2 = trim((string) ($raw['url'] ?? $raw['link'] ?? $raw['shareUrl'] ?? ''));
|
||||
if ($sourceId === '' || $title === '' || $url2 === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cover = trim((string) ($raw['cover'] ?? $raw['coverUrl'] ?? $raw['thumbnail'] ?? ''));
|
||||
$summary = trim((string) ($raw['summary'] ?? $raw['excerpt'] ?? $raw['description'] ?? ''));
|
||||
$author = trim((string) ($raw['author'] ?? ($raw['authorName'] ?? '')));
|
||||
$publishedAt = 0;
|
||||
$pub = $raw['publishedAt'] ?? ($raw['createdAt'] ?? null);
|
||||
if (is_numeric($pub)) {
|
||||
$publishedAt = (int) $pub > 2000000000 ? (int)($pub / 1000) : (int) $pub;
|
||||
} elseif (is_string($pub) && $pub !== '') {
|
||||
$t = strtotime($pub);
|
||||
if ($t) $publishedAt = $t;
|
||||
}
|
||||
|
||||
if ($publishedAt < 946684800) {
|
||||
$publishedAt = $now;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'title' => mb_substr($title, 0, 250, 'UTF-8'),
|
||||
'cover' => $cover,
|
||||
'url' => $url2,
|
||||
'summary' => mb_substr($summary, 0, 500, 'UTF-8'),
|
||||
'author' => mb_substr($author, 0, 60, 'UTF-8'),
|
||||
'tag' => $tag,
|
||||
'publishedAt' => $publishedAt ?: $now,
|
||||
];
|
||||
|
||||
$existed = SoulArticleModel::where('sourceId', $sourceId)->find();
|
||||
if ($existed) {
|
||||
$existed->save($payload);
|
||||
$updated++;
|
||||
} else {
|
||||
$payload['sourceId'] = $sourceId;
|
||||
SoulArticleModel::create($payload);
|
||||
$created++;
|
||||
}
|
||||
$items[] = array_merge(['sourceId' => $sourceId], $payload);
|
||||
}
|
||||
|
||||
return ['fetched' => count($list), 'created' => $created, 'updated' => $updated, 'items' => $items];
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一 HTTP GET(返回 body/httpCode/error)
|
||||
*/
|
||||
private static function httpGet(string $url, array $headers = []): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
return [$response, $httpCode, $err];
|
||||
}
|
||||
}
|
||||
152
api/app/common/service/UserJourneyService.php
Normal file
152
api/app/common/service/UserJourneyService.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 用户旅程:从 analytics_events 聚合最近行为,供飞书获客、出站 Hook、存客宝备注等复用。
|
||||
*/
|
||||
class UserJourneyService
|
||||
{
|
||||
/**
|
||||
* @return string[] 每条一行展示文案(与 FeishuLeadWebhookService 历史格式一致)
|
||||
*/
|
||||
public static function recentBehaviorLines(int $userId, int $limit = 8): array
|
||||
{
|
||||
if ($userId <= 0 || $limit <= 0) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$rows = Db::name('analytics_events')
|
||||
->where('userId', $userId)
|
||||
->order('id', 'desc')
|
||||
->limit($limit)
|
||||
->select()
|
||||
->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
if (is_array($r)) {
|
||||
$out[] = self::formatAnalyticsLine($r);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户管理摘要一行(平台用户 ID、归属企业),用于 CRM 备注与推送文案。
|
||||
*/
|
||||
public static function managementSummaryLine(int $userId, int $testResultEnterpriseId = 0): string
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return '';
|
||||
}
|
||||
$parts = ['平台用户ID:' . $userId];
|
||||
$eid = $testResultEnterpriseId > 0 ? $testResultEnterpriseId : 0;
|
||||
if ($eid <= 0) {
|
||||
try {
|
||||
$bound = Db::name('wechat_users')->where('id', $userId)->value('enterpriseId');
|
||||
$eid = (int) ($bound ?? 0);
|
||||
} catch (\Throwable $e) {
|
||||
$eid = 0;
|
||||
}
|
||||
}
|
||||
if ($eid > 0) {
|
||||
try {
|
||||
$name = Db::name('enterprises')->where('id', $eid)->value('name');
|
||||
} catch (\Throwable $e) {
|
||||
$name = null;
|
||||
}
|
||||
$label = $name !== null && (string) $name !== '' ? (string) $name : '企业';
|
||||
$parts[] = '归属企业:' . $label . '(ID' . $eid . ')';
|
||||
}
|
||||
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $r analytics_events 一行
|
||||
*/
|
||||
public static function formatAnalyticsLine(array $r): string
|
||||
{
|
||||
$name = (string) ($r['eventName'] ?? '');
|
||||
$path = trim((string) ($r['pagePath'] ?? ''));
|
||||
$props = [];
|
||||
if (!empty($r['propsJson'])) {
|
||||
$decoded = is_string($r['propsJson']) ? json_decode($r['propsJson'], true) : [];
|
||||
$props = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
$labelMap = [
|
||||
'page_view' => '浏览页面',
|
||||
'button_click' => '按钮点击',
|
||||
'click_pay' => '发起支付',
|
||||
'click_recharge' => '点击充值',
|
||||
];
|
||||
$label = $labelMap[$name] ?? $name;
|
||||
$detail = '';
|
||||
if ($name === 'page_view' && $path !== '') {
|
||||
$detail = $path;
|
||||
}
|
||||
if (isset($props['action']) && (string) $props['action'] !== '') {
|
||||
$detail = (string) $props['action'];
|
||||
if (!empty($props['productType'])) {
|
||||
$detail .= ' · ' . (string) $props['productType'];
|
||||
}
|
||||
} elseif (isset($props['label']) && (string) $props['label'] !== '') {
|
||||
$detail = (string) $props['label'];
|
||||
} elseif ($path !== '' && $detail === '') {
|
||||
$detail = $path;
|
||||
}
|
||||
$line = $detail !== '' ? "{$label}: {$detail}" : $label;
|
||||
$ts = isset($r['clientTs']) ? (int) $r['clientTs'] : null;
|
||||
if (!$ts && !empty($r['createdAt'])) {
|
||||
$ts = strtotime((string) $r['createdAt']) * 1000;
|
||||
}
|
||||
if ($ts) {
|
||||
$line .= ' · ' . self::humanTimeAgoCn((int) round($ts));
|
||||
}
|
||||
|
||||
return $line;
|
||||
}
|
||||
|
||||
public static function humanTimeAgoCn(int $clientTsMs): string
|
||||
{
|
||||
$now = (int) (microtime(true) * 1000);
|
||||
$sec = max(0, (int) (($now - $clientTsMs) / 1000));
|
||||
if ($sec < 60) {
|
||||
return '刚刚';
|
||||
}
|
||||
if ($sec < 3600) {
|
||||
return (int) floor($sec / 60) . '分钟前';
|
||||
}
|
||||
if ($sec < 86400) {
|
||||
return (int) floor($sec / 3600) . '小时前';
|
||||
}
|
||||
|
||||
return (int) floor($sec / 86400) . '天前';
|
||||
}
|
||||
|
||||
/**
|
||||
* 将旅程行压成一段备注(用于存客宝 remark 等),超长截断。
|
||||
*
|
||||
* @param string[] $lines
|
||||
*/
|
||||
public static function journeyLinesToRemarkBlock(array $lines, int $maxChars = 600): string
|
||||
{
|
||||
if (count($lines) === 0) {
|
||||
return '';
|
||||
}
|
||||
$body = '用户旅程:' . implode(';', $lines);
|
||||
if (function_exists('mb_strlen') && mb_strlen($body) > $maxChars) {
|
||||
return mb_substr($body, 0, $maxChars) . '…';
|
||||
}
|
||||
if (!function_exists('mb_strlen') && strlen($body) > $maxChars) {
|
||||
return substr($body, 0, $maxChars) . '…';
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user