feat: 同步今日小程序与后台迭代版本
集中提交今日 API、管理端、小程序与部署文档调整,确保 Gitea 主分支与本地最新开发版本一致。 Made-with: Cursor
This commit is contained in:
@@ -20,19 +20,27 @@ class AnalyticsEventLabels
|
||||
// 首页 / 拍照入口
|
||||
'tap_start_camera' => '点击·开始拍照测试',
|
||||
'tap_questionnaire_home' => '点击·首页·去做问卷',
|
||||
'tap_home_test_select' => '点击·首页·去选测评',
|
||||
'tap_home_camera_tab' => '点击·首页·拍照 Tab',
|
||||
'tap_enterprise_entry' => '点击·切换企业版',
|
||||
'tap_test_select' => '点击·选择测评类型',
|
||||
'tap_test_select_from_profile' => '点击·我的·选择测评',
|
||||
'tap_test_history' => '点击·测评记录',
|
||||
'tap_my_resume' => '点击·我的简历',
|
||||
'tap_match_job' => '点击·职位匹配',
|
||||
'tap_promo_withdrawals' => '点击·提现记录',
|
||||
|
||||
// 问卷类
|
||||
'test_start' => '开始答题',
|
||||
'test_submit' => '提交答题',
|
||||
'test_complete' => '答题完成',
|
||||
'test_complete' => '测评提交完成',
|
||||
'test_next' => '下一题',
|
||||
'test_prev' => '上一题',
|
||||
'tap_upload_photo_home' => '点击·首页·上传照片',
|
||||
|
||||
// 付费墙
|
||||
'paywall_view' => '付费墙·曝光',
|
||||
'tap_unlock_pay' => '点击·解锁支付',
|
||||
|
||||
// 结果页通用
|
||||
'tap_read_full' => '点击·看全文',
|
||||
@@ -70,6 +78,20 @@ class AnalyticsEventLabels
|
||||
// 面相 / AI 结果页
|
||||
'face_analyze_success' => '面相分析成功',
|
||||
'face_analyze_fail' => '面相分析失败',
|
||||
'ai_analysis_complete' => 'AI 分析完成',
|
||||
|
||||
'take_photo' => '拍照',
|
||||
'photo_upload_success' => '照片上传成功',
|
||||
'photo_upload_fail' => '照片上传失败',
|
||||
|
||||
'tap_ai_test_entry' => '点击·AI 测试入口',
|
||||
'ai_chat_go_test_select' => '神仙AI·去选测评',
|
||||
'ai_chat_feature_reco_tap' => '神仙AI·功能推荐点击',
|
||||
'ai_chat_inline_reco_show' => '神仙AI·内联推荐展示',
|
||||
'ai_chat_resume_entry_tap' => '神仙AI·简历入口',
|
||||
|
||||
'tap_match_upload_resume' => '职位匹配·上传简历',
|
||||
'tap_match_analyze' => '职位匹配·开始分析',
|
||||
|
||||
// 企业 / 简历
|
||||
'tap_resume_upload' => '点击·上传简历',
|
||||
|
||||
65
api/app/common/AnalyticsPagePathLabels.php
Normal file
65
api/app/common/AnalyticsPagePathLabels.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
namespace app\common;
|
||||
|
||||
/**
|
||||
* 小程序 pagePath → 中文页面名(用于飞书/出站推送「最近行为」展示)
|
||||
*/
|
||||
class AnalyticsPagePathLabels
|
||||
{
|
||||
/** @var array<string,string> */
|
||||
private static $MAP = [
|
||||
'pages/index/index' => '首页',
|
||||
'pages/index/camera' => '拍照测评',
|
||||
'pages/index/upload' => '上传照片',
|
||||
'pages/index/result' => '面相/AI 结果',
|
||||
'pages/test-select/index' => '选择测评类型',
|
||||
'pages/test/mbti' => 'MBTI 答题',
|
||||
'pages/test/disc' => 'DISC 答题',
|
||||
'pages/test/pdp' => 'PDP 答题',
|
||||
'pages/test/sbti' => 'SBTI 答题',
|
||||
'pages/result/mbti' => 'MBTI 结果',
|
||||
'pages/result/disc' => 'DISC 结果',
|
||||
'pages/result/pdp' => 'PDP 结果',
|
||||
'pages/result/sbti' => 'SBTI 结果',
|
||||
'pages/result/resume' => '简历分析结果',
|
||||
'pages/profile/index' => '我的',
|
||||
'pages/user-profile/index' => '个人资料',
|
||||
'pages/history/index' => '测评记录',
|
||||
'pages/order/index' => '我的订单',
|
||||
'pages/purchase/index' => '深度服务',
|
||||
'pages/promo/index' => '推广中心',
|
||||
'pages/promo/poster' => '推广海报',
|
||||
'pages/promo/withdrawals' => '提现记录',
|
||||
'pages/phone-auth/index' => '手机授权',
|
||||
'pages/enterprise/index' => '企业版',
|
||||
'pages/enterprise/resume-history'=> '企业简历记录',
|
||||
'pages/recharge/index' => '充值',
|
||||
'pages/match-job/index' => '职位匹配',
|
||||
'pages/ai-test/index' => 'AI 测试',
|
||||
'pages/ai-test/camera' => 'AI 测试·拍照',
|
||||
'pages/ai-test/result' => 'AI 测试结果',
|
||||
'pages/ai-chat/index' => '神仙 AI',
|
||||
'pages/ai-chat/report' => '神仙 AI 报告',
|
||||
'pages/ai-chat/history' => '神仙 AI 历史',
|
||||
'pages/webview/index' => '内置网页',
|
||||
];
|
||||
|
||||
public static function cn(string $path): string
|
||||
{
|
||||
$p = trim($path);
|
||||
if ($p === '') {
|
||||
return '';
|
||||
}
|
||||
$p = ltrim($p, '/');
|
||||
if (isset(self::$MAP[$p])) {
|
||||
return self::$MAP[$p];
|
||||
}
|
||||
foreach (self::$MAP as $k => $v) {
|
||||
if (strpos($p, $k) === 0) {
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
|
||||
return $p;
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,19 @@ class AiCallService
|
||||
return array_merge($healthy, $depleted);
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 POST /api/ai/chat 相同的第一优先服务商(sortWeight 升序 + 余额预警沉底)。
|
||||
* 面相 POST /api/analyze、简历分析等应与此一致,保证密钥、线路、优先级与神仙 AI 对齐。
|
||||
*
|
||||
* @return AiProviderModel|null
|
||||
*/
|
||||
public static function firstOrderedProvider(): ?AiProviderModel
|
||||
{
|
||||
$list = self::resolveProviders();
|
||||
|
||||
return $list[0] ?? null;
|
||||
}
|
||||
|
||||
private static function resolveEndpoint($provider): string
|
||||
{
|
||||
$endpoint = !empty($provider->apiEndpoint) ? rtrim($provider->apiEndpoint, '/') : '';
|
||||
@@ -187,7 +200,8 @@ class AiCallService
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
// 异步任务在 shutdown 中执行时可等待更久;同步面相等场景亦避免慢模型被 60s 截断
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
@@ -259,7 +273,7 @@ class AiCallService
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
@@ -327,90 +341,124 @@ class AiCallService
|
||||
$summary = trim((string) ($userContext['summary'] ?? ''));
|
||||
$nickname = trim((string) ($userContext['nickname'] ?? ''));
|
||||
|
||||
$prompt = "你是「神仙 AI」,神仙团队 MBTI 性格小程序的专属人格伙伴。\n";
|
||||
$prompt .= "说话风格:像朋友一样亲切、简短(每次回答 150 字以内,必要时可延展),不堆砌术语;适度鼓励用户完成其他测评(DISC / PDP / SBTI / 面相)或去「一场 soul 创业实验」公众号阅读相关文章。\n";
|
||||
$prompt .= "禁止:做医疗诊断 / 政治议题 / 具体投资建议。\n";
|
||||
$prompt = "你是「神仙 AI」,微信小程序里的性格与成长助手。你必须**直接生成**对当前这条消息的回答,禁止套用固定模板或预置长文案。\n";
|
||||
$prompt .= "【语气】像真人深聊:完整、通顺的句子;先接住对方的问题或情绪,再展开;有轻有重、有节奏,不要电报式短句堆砌。口语自然,但保持可读与分寸,像值得信赖的朋友。**禁止**「作为 AI」「根据您的问题」等机械开场,**禁止**元叙述(解释自己怎么回答、用什么结构)。\n";
|
||||
$prompt .= "**禁止**在正文里提及任何「回复格式名」「模式名」「版本代号」或类似自报(含 Human、HUMAN、3.0、秋门等谐音或变体);只自然说话,不要标签。\n";
|
||||
$prompt .= "第一句就要切入正题;分段清晰;单次回复以 180~380 字为宜(用户明确要求更长时再略增)。不堆砌术语。\n";
|
||||
$prompt .= "【时效】用户端网络请求约 60 秒内必须结束:请优先完整答完当前问题,避免冗长铺垫,以免用户看到超时错误。\n";
|
||||
$prompt .= "【重要】下方「测评档案」仅供你理解用户背景:**禁止**把档案整段抄给用户、**禁止**逐条罗列题号或问卷选项。\n";
|
||||
$prompt .= "**禁止**以 #、@、「我是xxx」、括号人设、运营标签或任何签名行开场;**禁止**自称/提及「卡若」或类似第三方运营人设;**禁止**编造「几点起床」等噱头头衔。**禁止**主动插入公众号、外链或营销话术;用户若问起其他测评,用一句话带过即可。\n";
|
||||
$prompt .= "禁止:医疗诊断、政治敏感、具体投资建议。\n";
|
||||
if ($nickname !== '') {
|
||||
$prompt .= "称呼:可以偶尔叫用户「{$nickname}」。\n";
|
||||
$prompt .= "可偶尔称呼用户「{$nickname}」。\n";
|
||||
}
|
||||
if ($mbtiType !== '') {
|
||||
$prompt .= "用户 MBTI:{$mbtiType}。请结合此类型做个性化回答。\n";
|
||||
$prompt .= "已知 MBTI:{$mbtiType},请结合类型作答。\n";
|
||||
} else {
|
||||
$prompt .= "用户尚未完成 MBTI 测试。回答时可友好建议先去做一下测评。\n";
|
||||
$prompt .= "用户可能尚未测 MBTI;可温和建议先完成站内测评。\n";
|
||||
}
|
||||
if ($summary !== '') {
|
||||
$prompt .= "用户性格画像摘要:{$summary}\n";
|
||||
$prompt .= "性格摘要(勿照抄,仅作理解):{$summary}\n";
|
||||
}
|
||||
|
||||
$appendix = trim((string) ($userContext['testAppendix'] ?? ''));
|
||||
if ($appendix !== '') {
|
||||
$prompt .= "\n【用户测评客观记录(含问卷选项,供你结合其当前提问做针对性回答;勿机械罗列题号套话)】\n";
|
||||
$prompt .= "\n【测评档案·内部参考】\n";
|
||||
$prompt .= $appendix . "\n";
|
||||
}
|
||||
|
||||
return $prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取用户 MBTI 画像(轻量:不含测评附录,供快捷问句/runtime 嵌入等避免重型查询与异常导致 500)
|
||||
*/
|
||||
public static function fetchUserContextLight(int $userId): array
|
||||
{
|
||||
$empty = ['mbtiType' => '', 'summary' => '', 'nickname' => '', 'testAppendix' => ''];
|
||||
if ($userId <= 0) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
try {
|
||||
$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'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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'] ?? ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'mbtiType' => $mbtiType,
|
||||
'summary' => $summary,
|
||||
'nickname' => $nickname,
|
||||
'testAppendix' => '',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('fetchUserContextLight: ' . $e->getMessage());
|
||||
|
||||
return $empty;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取用户 MBTI 画像(最近一次 test_results 结果)
|
||||
*/
|
||||
public static function fetchUserContext(int $userId): array
|
||||
{
|
||||
$base = self::fetchUserContextLight($userId);
|
||||
if ($userId <= 0) {
|
||||
return ['mbtiType' => '', 'summary' => '', 'nickname' => '', 'testAppendix' => ''];
|
||||
return $base;
|
||||
}
|
||||
|
||||
$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'];
|
||||
}
|
||||
}
|
||||
$testAppendix = '';
|
||||
try {
|
||||
// 对话场景不传逐题选项,避免模型照抄冗长问卷,仍走真实模型与类型/得分摘要
|
||||
$testAppendix = self::buildLatestTestsAppendix($userId, false);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('fetchUserContext buildLatestTestsAppendix: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// 若无 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'] ?? ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
$base['testAppendix'] = $testAppendix;
|
||||
|
||||
$testAppendix = self::buildLatestTestsAppendix($userId);
|
||||
|
||||
return [
|
||||
'mbtiType' => $mbtiType,
|
||||
'summary' => $summary,
|
||||
'nickname' => $nickname,
|
||||
'testAppendix' => $testAppendix,
|
||||
];
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总用户最近一次各类型测评的答题与结果,供 system prompt 使用(有长度上限)
|
||||
* 汇总用户最近一次各类型测评结果,供 system prompt 使用(有长度上限)
|
||||
*
|
||||
* @param bool $includeAnswerDetail 为 true 时附带逐题选项(仅排查/特殊场景;对话默认 false)
|
||||
*/
|
||||
private static function buildLatestTestsAppendix(int $userId): string
|
||||
private static function buildLatestTestsAppendix(int $userId, bool $includeAnswerDetail = false): string
|
||||
{
|
||||
$types = ['mbti', 'sbti', 'disc', 'pdp'];
|
||||
$blocks = [];
|
||||
@@ -427,7 +475,7 @@ class AiCallService
|
||||
if (!is_array($data)) {
|
||||
continue;
|
||||
}
|
||||
$block = self::formatTestBlockForPrompt($type, $data);
|
||||
$block = self::formatTestBlockForPrompt($type, $data, $includeAnswerDetail);
|
||||
if ($block !== '') {
|
||||
$blocks[] = $block;
|
||||
}
|
||||
@@ -436,7 +484,7 @@ class AiCallService
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
$maxLen = 3800;
|
||||
$maxLen = 2800;
|
||||
if (mb_strlen($text, 'UTF-8') > $maxLen) {
|
||||
$text = mb_substr($text, 0, $maxLen, 'UTF-8') . '…(档案已截断)';
|
||||
}
|
||||
@@ -446,17 +494,17 @@ class AiCallService
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function formatTestBlockForPrompt(string $testType, array $data): string
|
||||
private static function formatTestBlockForPrompt(string $testType, array $data, bool $includeAnswerDetail = false): string
|
||||
{
|
||||
switch ($testType) {
|
||||
case 'mbti':
|
||||
return self::formatMbtiBlockForPrompt($data);
|
||||
return self::formatMbtiBlockForPrompt($data, $includeAnswerDetail);
|
||||
case 'sbti':
|
||||
return self::formatSbtiBlockForPrompt($data);
|
||||
return self::formatSbtiBlockForPrompt($data, $includeAnswerDetail);
|
||||
case 'disc':
|
||||
return self::formatDiscBlockForPrompt($data);
|
||||
return self::formatDiscBlockForPrompt($data, $includeAnswerDetail);
|
||||
case 'pdp':
|
||||
return self::formatPdpBlockForPrompt($data);
|
||||
return self::formatPdpBlockForPrompt($data, $includeAnswerDetail);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -490,7 +538,7 @@ class AiCallService
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function formatMbtiBlockForPrompt(array $data): string
|
||||
private static function formatMbtiBlockForPrompt(array $data, bool $includeAnswerDetail = false): string
|
||||
{
|
||||
$lines = ['【MBTI·最近一次】'];
|
||||
$type = (string) ($data['mbtiType'] ?? ($data['mbti']['type'] ?? ''));
|
||||
@@ -506,9 +554,11 @@ class AiCallService
|
||||
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;
|
||||
if ($includeAnswerDetail) {
|
||||
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
|
||||
if ($ans !== '') {
|
||||
$lines[] = '逐题选项(题号/ID→所选):' . $ans;
|
||||
}
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
@@ -516,7 +566,7 @@ class AiCallService
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function formatSbtiBlockForPrompt(array $data): string
|
||||
private static function formatSbtiBlockForPrompt(array $data, bool $includeAnswerDetail = false): string
|
||||
{
|
||||
$lines = ['【SBTI·最近一次】'];
|
||||
$final = $data['finalType'] ?? null;
|
||||
@@ -528,9 +578,11 @@ class AiCallService
|
||||
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;
|
||||
if ($includeAnswerDetail) {
|
||||
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
|
||||
if ($ans !== '') {
|
||||
$lines[] = '逐题选项:' . $ans;
|
||||
}
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
@@ -538,7 +590,7 @@ class AiCallService
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function formatDiscBlockForPrompt(array $data): string
|
||||
private static function formatDiscBlockForPrompt(array $data, bool $includeAnswerDetail = false): string
|
||||
{
|
||||
$lines = ['【DISC·最近一次】'];
|
||||
if (!empty($data['dominantType'])) {
|
||||
@@ -547,9 +599,11 @@ class AiCallService
|
||||
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;
|
||||
if ($includeAnswerDetail) {
|
||||
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
|
||||
if ($ans !== '') {
|
||||
$lines[] = '逐题选项:' . $ans;
|
||||
}
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
@@ -557,7 +611,7 @@ class AiCallService
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function formatPdpBlockForPrompt(array $data): string
|
||||
private static function formatPdpBlockForPrompt(array $data, bool $includeAnswerDetail = false): string
|
||||
{
|
||||
$lines = ['【PDP·最近一次】'];
|
||||
if (!empty($data['dominantType'])) {
|
||||
@@ -566,9 +620,11 @@ class AiCallService
|
||||
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;
|
||||
if ($includeAnswerDetail) {
|
||||
$ans = self::formatAnswersCompact(isset($data['answers']) && is_array($data['answers']) ? $data['answers'] : null);
|
||||
if ($ans !== '') {
|
||||
$lines[] = '逐题选项:' . $ans;
|
||||
}
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
@@ -7,26 +7,84 @@ use think\facade\Db;
|
||||
* 神仙 AI 页 · 推荐文章区块展示(超管可配)
|
||||
*
|
||||
* system_config.key = ai_chat_articles, enterprise_id = 0
|
||||
* JSON: { enabled, maxShow(1-3), sectionExpandedDefault, profileRecoEnabled, profileSectionLabel }
|
||||
* JSON: enabled, maxShow(1-3), sectionExpandedDefault, profileRecoEnabled, profileSectionLabel,
|
||||
* recoJumpMiniAppId, recoJumpMiniPath, recoJumpMiniEnvVersion,
|
||||
* inlineRecoMinUserTurns(1-10 从第几条用户消息起可抽检), inlineRecoInterval(2-10 间隔),
|
||||
* inlineRecoRoll(0-1 抽检概率), inlineRecoIconCount(1-3), inlineRecoIcons(string[] emoji)
|
||||
*/
|
||||
class AiChatArticleDisplayService
|
||||
{
|
||||
public const CONFIG_KEY = 'ai_chat_articles';
|
||||
|
||||
/** 我的页推荐条默认标题(后台可改) */
|
||||
public const DEFAULT_PROFILE_SECTION_LABEL = '推荐阅读';
|
||||
/** 我的页推荐条默认标题(超管「小程序 · 推荐文章展示」可改) */
|
||||
public const DEFAULT_PROFILE_SECTION_LABEL = '我的由来';
|
||||
|
||||
/** 未在库中配置时:精选推荐点击跳转「一场 soul / 双赢实验」同源小程序(可在后台清空 AppID 关闭跳转) */
|
||||
public const DEFAULT_RECO_JUMP_APP_ID = 'wxb8bbb2b10dec74aa';
|
||||
|
||||
public const DEFAULT_RECO_JUMP_PATH = 'pages/index/index';
|
||||
|
||||
private const ENV_VERSIONS = ['release', 'trial', 'develop'];
|
||||
|
||||
public static function normalizeRecoJumpAppId(string $raw): string
|
||||
{
|
||||
$s = strtolower(trim($raw));
|
||||
if ($s === '') {
|
||||
return '';
|
||||
}
|
||||
return preg_match('/^wx[0-9a-f]{16}$/', $s) ? $s : '';
|
||||
}
|
||||
|
||||
public static function normalizeRecoJumpPath(string $raw): string
|
||||
{
|
||||
$s = trim($raw);
|
||||
$s = ltrim($s, '/');
|
||||
if ($s === '') {
|
||||
return self::DEFAULT_RECO_JUMP_PATH;
|
||||
}
|
||||
if (strlen($s) > 512) {
|
||||
$s = substr($s, 0, 512);
|
||||
}
|
||||
if (str_contains($s, '..')) {
|
||||
return self::DEFAULT_RECO_JUMP_PATH;
|
||||
}
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
public static function normalizeRecoJumpEnvVersion(string $raw): string
|
||||
{
|
||||
$s = strtolower(trim($raw));
|
||||
if (in_array($s, self::ENV_VERSIONS, true)) {
|
||||
return $s;
|
||||
}
|
||||
|
||||
return 'release';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{enabled:bool,maxShow:int,sectionExpandedDefault:bool,profileRecoEnabled:bool,profileSectionLabel:string}
|
||||
* @return array{
|
||||
* enabled:bool,maxShow:int,sectionExpandedDefault:bool,profileRecoEnabled:bool,profileSectionLabel:string,
|
||||
* recoJumpMiniAppId:string,recoJumpMiniPath:string,recoJumpMiniEnvVersion:string
|
||||
* }
|
||||
*/
|
||||
public static function getSettings(): array
|
||||
{
|
||||
// 无库表记录时:神仙 AI 首屏最多 3 条推荐,默认折叠精选条
|
||||
$defaults = [
|
||||
'enabled' => false,
|
||||
'maxShow' => 1,
|
||||
'enabled' => true,
|
||||
'maxShow' => 3,
|
||||
'sectionExpandedDefault' => false,
|
||||
'profileRecoEnabled' => false,
|
||||
'profileSectionLabel' => self::DEFAULT_PROFILE_SECTION_LABEL,
|
||||
'recoJumpMiniAppId' => self::DEFAULT_RECO_JUMP_APP_ID,
|
||||
'recoJumpMiniPath' => self::DEFAULT_RECO_JUMP_PATH,
|
||||
'recoJumpMiniEnvVersion' => 'release',
|
||||
'inlineRecoMinUserTurns' => 2,
|
||||
'inlineRecoInterval' => 3,
|
||||
'inlineRecoRoll' => 0.5,
|
||||
'inlineRecoIconCount' => 3,
|
||||
'inlineRecoIcons' => ['✨', '💬', '📌'],
|
||||
];
|
||||
$row = Db::name('system_config')
|
||||
->where('key', self::CONFIG_KEY)
|
||||
@@ -57,18 +115,126 @@ class AiChatArticleDisplayService
|
||||
$profileLabel = substr($profileLabel, 0, 32);
|
||||
}
|
||||
|
||||
// 未配置该键时默认折叠;仅当库中显式为真时展开
|
||||
$sectionExpanded = isset($v['sectionExpandedDefault'])
|
||||
? !empty($v['sectionExpandedDefault'])
|
||||
: false;
|
||||
|
||||
// 跳转小程序:库中无键时用默认 AppID;键存在且为空字符串表示运营主动关闭跳转
|
||||
if (array_key_exists('recoJumpMiniAppId', $v)) {
|
||||
$jumpApp = self::normalizeRecoJumpAppId((string) $v['recoJumpMiniAppId']);
|
||||
} else {
|
||||
$jumpApp = self::normalizeRecoJumpAppId(self::DEFAULT_RECO_JUMP_APP_ID);
|
||||
}
|
||||
$jumpPath = isset($v['recoJumpMiniPath'])
|
||||
? self::normalizeRecoJumpPath((string) $v['recoJumpMiniPath'])
|
||||
: self::DEFAULT_RECO_JUMP_PATH;
|
||||
$jumpEnv = isset($v['recoJumpMiniEnvVersion'])
|
||||
? self::normalizeRecoJumpEnvVersion((string) $v['recoJumpMiniEnvVersion'])
|
||||
: 'release';
|
||||
|
||||
$minTurns = (int) ($v['inlineRecoMinUserTurns'] ?? $defaults['inlineRecoMinUserTurns']);
|
||||
if ($minTurns < 1) {
|
||||
$minTurns = 1;
|
||||
}
|
||||
if ($minTurns > 10) {
|
||||
$minTurns = 10;
|
||||
}
|
||||
|
||||
$interval = (int) ($v['inlineRecoInterval'] ?? $defaults['inlineRecoInterval']);
|
||||
if ($interval < 2) {
|
||||
$interval = 2;
|
||||
}
|
||||
if ($interval > 10) {
|
||||
$interval = 10;
|
||||
}
|
||||
|
||||
$roll = isset($v['inlineRecoRoll']) ? (float) $v['inlineRecoRoll'] : (float) $defaults['inlineRecoRoll'];
|
||||
if ($roll < 0.05) {
|
||||
$roll = 0.05;
|
||||
}
|
||||
if ($roll > 1.0) {
|
||||
$roll = 1.0;
|
||||
}
|
||||
|
||||
$iconCount = (int) ($v['inlineRecoIconCount'] ?? $defaults['inlineRecoIconCount']);
|
||||
if ($iconCount < 1) {
|
||||
$iconCount = 1;
|
||||
}
|
||||
if ($iconCount > 3) {
|
||||
$iconCount = 3;
|
||||
}
|
||||
|
||||
$icons = self::normalizeInlineRecoIcons($v['inlineRecoIcons'] ?? null, $iconCount);
|
||||
|
||||
return [
|
||||
'enabled' => !empty($v['enabled']),
|
||||
'maxShow' => $maxShow,
|
||||
'sectionExpandedDefault' => !empty($v['sectionExpandedDefault']),
|
||||
'sectionExpandedDefault' => $sectionExpanded,
|
||||
'profileRecoEnabled' => !empty($v['profileRecoEnabled']),
|
||||
'profileSectionLabel' => $profileLabel,
|
||||
'recoJumpMiniAppId' => $jumpApp,
|
||||
'recoJumpMiniPath' => $jumpPath,
|
||||
'recoJumpMiniEnvVersion' => $jumpEnv,
|
||||
'inlineRecoMinUserTurns' => $minTurns,
|
||||
'inlineRecoInterval' => $interval,
|
||||
'inlineRecoRoll' => $roll,
|
||||
'inlineRecoIconCount' => $iconCount,
|
||||
'inlineRecoIcons' => $icons,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $raw JSON 数组或逗号分隔字符串
|
||||
* @return string[]
|
||||
*/
|
||||
public static function normalizeInlineRecoIcons($raw, int $iconCount): array
|
||||
{
|
||||
$iconCount = max(1, min(3, $iconCount));
|
||||
$defaults = ['✨', '💬', '📌', '💼', '📝'];
|
||||
$list = [];
|
||||
if (is_array($raw)) {
|
||||
foreach ($raw as $item) {
|
||||
$s = trim((string) $item);
|
||||
if ($s !== '' && !in_array($s, $list, true)) {
|
||||
$list[] = $s;
|
||||
}
|
||||
if (count($list) >= 10) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} elseif (is_string($raw) && trim($raw) !== '') {
|
||||
foreach (preg_split('/[,,\s]+/u', $raw) as $part) {
|
||||
$s = trim($part);
|
||||
if ($s !== '' && !in_array($s, $list, true)) {
|
||||
$list[] = $s;
|
||||
}
|
||||
if (count($list) >= 10) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (empty($list)) {
|
||||
$list = $defaults;
|
||||
}
|
||||
foreach ($defaults as $d) {
|
||||
if (count($list) >= $iconCount) {
|
||||
break;
|
||||
}
|
||||
if (!in_array($d, $list, true)) {
|
||||
$list[] = $d;
|
||||
}
|
||||
}
|
||||
|
||||
return array_slice($list, 0, $iconCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array{enabled:bool,maxShow:int,sectionExpandedDefault:bool,profileRecoEnabled:bool,profileSectionLabel:string}
|
||||
* @return array{
|
||||
* enabled:bool,maxShow:int,sectionExpandedDefault:bool,profileRecoEnabled:bool,profileSectionLabel:string,
|
||||
* recoJumpMiniAppId:string,recoJumpMiniPath:string,recoJumpMiniEnvVersion:string
|
||||
* }
|
||||
*/
|
||||
public static function saveSettings(array $input): array
|
||||
{
|
||||
@@ -82,12 +248,40 @@ class AiChatArticleDisplayService
|
||||
$rawLabel = substr($rawLabel, 0, 32);
|
||||
}
|
||||
|
||||
$jumpApp = isset($input['recoJumpMiniAppId'])
|
||||
? self::normalizeRecoJumpAppId((string) $input['recoJumpMiniAppId'])
|
||||
: '';
|
||||
$jumpPath = isset($input['recoJumpMiniPath'])
|
||||
? self::normalizeRecoJumpPath((string) $input['recoJumpMiniPath'])
|
||||
: self::DEFAULT_RECO_JUMP_PATH;
|
||||
$jumpEnv = isset($input['recoJumpMiniEnvVersion'])
|
||||
? self::normalizeRecoJumpEnvVersion((string) $input['recoJumpMiniEnvVersion'])
|
||||
: 'release';
|
||||
|
||||
$minTurns = (int) ($input['inlineRecoMinUserTurns'] ?? 2);
|
||||
$minTurns = max(1, min(10, $minTurns));
|
||||
$interval = (int) ($input['inlineRecoInterval'] ?? 3);
|
||||
$interval = max(2, min(10, $interval));
|
||||
$roll = isset($input['inlineRecoRoll']) ? (float) $input['inlineRecoRoll'] : 0.5;
|
||||
$roll = max(0.05, min(1.0, $roll));
|
||||
$iconCnt = (int) ($input['inlineRecoIconCount'] ?? 3);
|
||||
$iconCnt = max(1, min(3, $iconCnt));
|
||||
$icons = self::normalizeInlineRecoIcons($input['inlineRecoIcons'] ?? null, $iconCnt);
|
||||
|
||||
$settings = [
|
||||
'enabled' => !empty($input['enabled']),
|
||||
'maxShow' => max(1, min(3, (int) ($input['maxShow'] ?? 1))),
|
||||
'sectionExpandedDefault' => !empty($input['sectionExpandedDefault']),
|
||||
'profileRecoEnabled' => !empty($input['profileRecoEnabled']),
|
||||
'profileSectionLabel' => $rawLabel,
|
||||
'recoJumpMiniAppId' => $jumpApp,
|
||||
'recoJumpMiniPath' => $jumpPath,
|
||||
'recoJumpMiniEnvVersion' => $jumpEnv,
|
||||
'inlineRecoMinUserTurns' => $minTurns,
|
||||
'inlineRecoInterval' => $interval,
|
||||
'inlineRecoRoll' => $roll,
|
||||
'inlineRecoIconCount' => $iconCnt,
|
||||
'inlineRecoIcons' => $icons,
|
||||
];
|
||||
$json = json_encode($settings, JSON_UNESCAPED_UNICODE);
|
||||
$now = time();
|
||||
|
||||
@@ -62,12 +62,28 @@ class FeishuLeadWebhookService
|
||||
|
||||
/**
|
||||
* 首次绑定手机号(测试完成留资)
|
||||
* 若 7 天内有测评记录:与「测评完成」合并为一条纯文本(与出站 Hook 机器人文案一致),不再单独发「首次绑定手机」
|
||||
*/
|
||||
public static function onPhoneBound(int $userId, string $phone): void
|
||||
{
|
||||
if ($userId <= 0 || trim($phone) === '') {
|
||||
return;
|
||||
}
|
||||
$latest = Db::name('test_results')->where('userId', $userId)->order('id', 'desc')->find();
|
||||
if ($latest) {
|
||||
$tid = (int) ($latest['id'] ?? 0);
|
||||
$testTs = isset($latest['createdAt']) ? (int) $latest['createdAt'] : 0;
|
||||
if ($tid > 0 && $testTs > 0 && (time() - $testTs) <= 604800) {
|
||||
$boundAt = date('Y-m-d H:i:s');
|
||||
$plain = OutboundPushHookService::botPlainTextTestResultCompleted($tid, $phone, $boundAt);
|
||||
if ($plain !== null && $plain !== '') {
|
||||
self::pushPlainDedup('merged_test_phone:' . $tid, $plain);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::pushLead([
|
||||
'dedupKey' => 'phone_bind:' . $userId,
|
||||
'userId' => $userId,
|
||||
@@ -163,6 +179,31 @@ class FeishuLeadWebhookService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅投递纯文本(无「新获客」头),用于测评+手机号合并等与出站机器人对齐的文案
|
||||
*/
|
||||
private static function pushPlainDedup(string $dedupKey, string $text): void
|
||||
{
|
||||
$cfg = self::getConfig();
|
||||
if (empty($cfg['enabled'])) {
|
||||
return;
|
||||
}
|
||||
$url = trim((string) ($cfg['webhookUrl'] ?? ''));
|
||||
if ($url === '' || stripos($url, 'http') !== 0) {
|
||||
return;
|
||||
}
|
||||
if ($dedupKey === '') {
|
||||
return;
|
||||
}
|
||||
if (!self::beginDedup($dedupKey)) {
|
||||
return;
|
||||
}
|
||||
$ok = self::postWebhook($url, $text);
|
||||
if (!$ok) {
|
||||
self::rollbackDedup($dedupKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单来源文案(飞书「来源」与 HTTP 出站 Hook `sourceLabel` 共用)
|
||||
*/
|
||||
|
||||
28
api/app/common/service/MiniprogramAuditMode.php
Normal file
28
api/app/common/service/MiniprogramAuditMode.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 小程序「提审模式」:
|
||||
* - 隐藏神仙 AI 对话等深度合成能力;
|
||||
* - 关闭虚拟商品/付费解锁与「了解自己」深度套餐展示(应对 iOS 虚拟支付类审核意见)。
|
||||
* 配置存储:system_config.key = system 的 JSON 内 miniprogramAuditMode = true
|
||||
*/
|
||||
class MiniprogramAuditMode
|
||||
{
|
||||
public static function isOn(): bool
|
||||
{
|
||||
try {
|
||||
$row = Db::name('system_config')->where('key', 'system')->find();
|
||||
if (!$row || empty($row['value'])) {
|
||||
return false;
|
||||
}
|
||||
$v = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
|
||||
|
||||
return is_array($v) && !empty($v['miniprogramAuditMode']);
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
65
api/app/common/service/MpTabbarService.php
Normal file
65
api/app/common/service/MpTabbarService.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 小程序底部 TabBar 配置(与 MpConfig::tabbar 同源,供 runtime 一并下发)
|
||||
*/
|
||||
class MpTabbarService
|
||||
{
|
||||
/**
|
||||
* @return array{items: array<int, array<string, mixed>>, version: int}
|
||||
*/
|
||||
public static function getPayload(): array
|
||||
{
|
||||
$list = [];
|
||||
try {
|
||||
$items = Db::name('mp_tabbar_items')
|
||||
->where('visible', 1)
|
||||
->order('sortOrder', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($items as $row) {
|
||||
$iconKey = $row['iconKey'] ?? 'home';
|
||||
$iconUrl = $row['iconUrl'] ?? null;
|
||||
if ($iconKey === 'ai') {
|
||||
$iconUrl = '/images/shenxian-oldman-circle.png';
|
||||
}
|
||||
$list[] = [
|
||||
'id' => (int) $row['id'],
|
||||
'pagePath' => $row['pagePath'],
|
||||
'text' => $row['text'],
|
||||
'iconKey' => $iconKey,
|
||||
'iconUrl' => $iconUrl,
|
||||
'highlight' => (int) ($row['highlight'] ?? 0) === 1,
|
||||
'badgeKey' => $row['badgeKey'] ?? null,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$list = [];
|
||||
}
|
||||
|
||||
if (empty($list)) {
|
||||
$list = [
|
||||
['id' => 0, 'pagePath' => 'pages/index/index', 'text' => '首页', 'iconKey' => 'home', 'iconUrl' => null, 'highlight' => false, 'badgeKey' => null],
|
||||
['id' => 0, 'pagePath' => 'pages/index/camera', 'text' => '拍摄', 'iconKey' => 'camera', 'iconUrl' => null, 'highlight' => true, 'badgeKey' => null],
|
||||
['id' => 0, 'pagePath' => 'pages/ai-chat/index', 'text' => '神仙AI', 'iconKey' => 'ai', 'iconUrl' => '/images/shenxian-oldman-circle.png', 'highlight' => false, 'badgeKey' => null],
|
||||
['id' => 0, 'pagePath' => 'pages/profile/index', 'text' => '我', 'iconKey' => 'profile', 'iconUrl' => null, 'highlight' => false, 'badgeKey' => null],
|
||||
];
|
||||
}
|
||||
|
||||
$version = 0;
|
||||
try {
|
||||
$version = (int) Db::name('mp_tabbar_items')->max('updatedAt');
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $list,
|
||||
'version' => $version,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@ class OutboundPushHookService
|
||||
'test.result_completed',
|
||||
];
|
||||
|
||||
/** 首次绑手机与「刚完成的测评」合并推送时,测评落库时间距现在最长间隔(秒) */
|
||||
private const TEST_PHONE_MERGE_MAX_AGE_SEC = 604800;
|
||||
|
||||
/**
|
||||
* 读取指定作用域配置(不合并回落;回落在 getEffectiveConfigForEvent / dispatch 中处理)
|
||||
*/
|
||||
@@ -217,8 +220,10 @@ class OutboundPushHookService
|
||||
}
|
||||
|
||||
$tt = (string) ($payload['testType'] ?? '');
|
||||
$mergedBind = trim((string) ($payload['phoneBoundAt'] ?? '')) !== '';
|
||||
$titleLine = $mergedBind ? "📊 测评完成·手机号已授权\n" : "📊 测评完成\n";
|
||||
$lines = [
|
||||
$head . "📊 测评完成\n",
|
||||
$head . $titleLine,
|
||||
'类型: ' . ($payload['testTypeLabel'] ?? $payload['testType'] ?? '') . "\n",
|
||||
'用户: ' . ($payload['userName'] ?? '') . "\n",
|
||||
'手机: ' . ($payload['phone'] ?? '') . "\n",
|
||||
@@ -243,6 +248,9 @@ class OutboundPushHookService
|
||||
$lines[] = '测试结果: ' . self::truncatePlainText((string) ($payload['resultSummary'] ?? ''), 100) . "\n";
|
||||
}
|
||||
$lines[] = '测试时间: ' . $testTime;
|
||||
if ($mergedBind) {
|
||||
$lines[] = '授权手机时间: ' . trim((string) ($payload['phoneBoundAt'] ?? '')) . "\n";
|
||||
}
|
||||
$body = implode('', $lines);
|
||||
$mgmtSummary = trim((string) ($payload['managementSummary'] ?? ''));
|
||||
if ($mgmtSummary !== '') {
|
||||
@@ -451,10 +459,32 @@ class OutboundPushHookService
|
||||
return $expected === $signature;
|
||||
}
|
||||
|
||||
/**
|
||||
* 神仙 AI 异步对话:与订单/测评异步同源,走独立 HTTP 请求执行,避免 PHP-FPM 在响应后提前掐断 register_shutdown_function。
|
||||
*
|
||||
* @return bool 是否已成功把请求写出到 socket(不代表模型已跑完)
|
||||
*/
|
||||
public static function triggerAiChatDeferredJob(int $userId, int $conversationId, string $jobId): bool
|
||||
{
|
||||
if ($userId <= 0 || $conversationId <= 0 || $jobId === '' || strlen($jobId) > 64) {
|
||||
return false;
|
||||
}
|
||||
if (!preg_match('/^[a-f0-9]+$/', $jobId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::triggerAsyncInternalDispatch([
|
||||
'job' => 'ai.chat_turn',
|
||||
'userId' => $userId,
|
||||
'conversationId' => $conversationId,
|
||||
'jobId' => $jobId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private static function triggerAsyncInternalDispatch(array $payload): void
|
||||
private static function triggerAsyncInternalDispatch(array $payload): bool
|
||||
{
|
||||
$url = self::resolveAsyncDispatchUrl();
|
||||
if ($url === '') {
|
||||
@@ -462,7 +492,7 @@ class OutboundPushHookService
|
||||
'payload' => $payload,
|
||||
]);
|
||||
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||
@@ -471,7 +501,7 @@ class OutboundPushHookService
|
||||
'payload' => $payload,
|
||||
]);
|
||||
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
$timestamp = (string) time();
|
||||
@@ -482,15 +512,28 @@ class OutboundPushHookService
|
||||
];
|
||||
|
||||
if (!self::postJsonAsyncNoWait($url, $body, $headers)) {
|
||||
Log::warning('OutboundPushHook async enqueue failed', [
|
||||
'url' => self::maskUrl($url),
|
||||
'payload' => $payload,
|
||||
]);
|
||||
// 偶发 TLS 握手/链路抖动:短间隔重试一次再回落 shutdown
|
||||
usleep(150000);
|
||||
if (!self::postJsonAsyncNoWait($url, $body, $headers)) {
|
||||
Log::warning('OutboundPushHook async enqueue failed', [
|
||||
'url' => self::maskUrl($url),
|
||||
'payload' => $payload,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function resolveAsyncDispatchUrl(): string
|
||||
{
|
||||
$override = trim((string) (env('MBTI_INTERNAL_DISPATCH_URL') ?: getenv('MBTI_INTERNAL_DISPATCH_URL') ?: ''));
|
||||
if ($override !== '') {
|
||||
return rtrim($override, '/') . self::ASYNC_ROUTE;
|
||||
}
|
||||
|
||||
$host = trim((string) Request::server('HTTP_HOST', ''));
|
||||
if ($host !== '') {
|
||||
$scheme = Request::isSsl() ? 'https' : 'http';
|
||||
@@ -542,7 +585,26 @@ class OutboundPushHookService
|
||||
$path .= '?' . $parts['query'];
|
||||
}
|
||||
$transport = $scheme === 'https' ? 'ssl://' : '';
|
||||
$socket = @stream_socket_client($transport . $host . ':' . $port, $errno, $errstr, 1);
|
||||
// HTTPS 自调用:须带 SSL 上下文(SNI/校验策略与 AiCallService 侧 curl 一致),否则部分环境握手直接失败
|
||||
$ctx = null;
|
||||
if ($scheme === 'https') {
|
||||
$ctx = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'peer_name' => $host,
|
||||
'SNI_enabled' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
$socket = @stream_socket_client(
|
||||
$transport . $host . ':' . $port,
|
||||
$errno,
|
||||
$errstr,
|
||||
3,
|
||||
STREAM_CLIENT_CONNECT,
|
||||
$ctx
|
||||
);
|
||||
if (!is_resource($socket)) {
|
||||
Log::warning('OutboundPushHook async socket open failed', [
|
||||
'url' => self::maskUrl($url),
|
||||
@@ -553,7 +615,7 @@ class OutboundPushHookService
|
||||
return false;
|
||||
}
|
||||
|
||||
stream_set_timeout($socket, 1);
|
||||
stream_set_timeout($socket, 3);
|
||||
$hostHeader = $host;
|
||||
if (($scheme === 'http' && $port !== 80) || ($scheme === 'https' && $port !== 443)) {
|
||||
$hostHeader .= ':' . $port;
|
||||
@@ -732,6 +794,21 @@ class OutboundPushHookService
|
||||
if ($userId <= 0 || trim($phone) === '') {
|
||||
return;
|
||||
}
|
||||
$latest = Db::name('test_results')->where('userId', $userId)->order('id', 'desc')->find();
|
||||
if ($latest) {
|
||||
$tid = (int) ($latest['id'] ?? 0);
|
||||
$testTs = isset($latest['createdAt']) ? (int) $latest['createdAt'] : 0;
|
||||
if ($tid > 0 && $testTs > 0 && (time() - $testTs) <= self::TEST_PHONE_MERGE_MAX_AGE_SEC) {
|
||||
$boundAt = date('Y-m-d H:i:s');
|
||||
$pack = self::assembleTestResultCompletedEnvelope($tid, $phone, $boundAt);
|
||||
if ($pack !== null) {
|
||||
self::dispatch('test.result_completed', $pack['envelope'], $pack['eid']);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$wu = Db::name('wechat_users')->where('id', $userId)->field('nickname,enterpriseId')->find();
|
||||
$userName = trim((string) ($wu['nickname'] ?? ''));
|
||||
if ($userName === '') {
|
||||
@@ -760,16 +837,31 @@ class OutboundPushHookService
|
||||
}
|
||||
|
||||
/**
|
||||
* 测评记录落库后推送(问卷 submit / 分析写库)
|
||||
* 飞书获客 webhook 生成与出站机器人一致的测评完成纯文本(不写去重、不发起 HTTP)
|
||||
*/
|
||||
public static function onTestResultCompleted(int $testResultId): void
|
||||
public static function botPlainTextTestResultCompleted(int $testResultId, ?string $overridePhone = null, ?string $phoneBoundAt = null): ?string
|
||||
{
|
||||
$pack = self::assembleTestResultCompletedEnvelope($testResultId, $overridePhone, $phoneBoundAt);
|
||||
if ($pack === null) {
|
||||
return null;
|
||||
}
|
||||
$env = $pack['envelope'];
|
||||
unset($env['_dedupKey']);
|
||||
|
||||
return self::envelopeToBotPlainText('test.result_completed', $env);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{eid:int,envelope:array<string,mixed>}|null
|
||||
*/
|
||||
private static function assembleTestResultCompletedEnvelope(int $testResultId, ?string $overridePhone = null, ?string $phoneBoundAt = null): ?array
|
||||
{
|
||||
if ($testResultId <= 0) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
$row = Db::name('test_results')->where('id', $testResultId)->find();
|
||||
if (!$row) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
$userId = (int) ($row['userId'] ?? 0);
|
||||
$testType = (string) ($row['testType'] ?? '');
|
||||
@@ -784,6 +876,9 @@ class OutboundPushHookService
|
||||
$userName = '微信用户';
|
||||
}
|
||||
$phone = $wu ? trim((string) ($wu['phone'] ?? '')) : '';
|
||||
if ($overridePhone !== null && trim($overridePhone) !== '') {
|
||||
$phone = trim($overridePhone);
|
||||
}
|
||||
|
||||
$raw = $row['resultData'] ?? null;
|
||||
$data = is_string($raw) ? json_decode($raw, true) : $raw;
|
||||
@@ -818,6 +913,9 @@ class OutboundPushHookService
|
||||
'resultSummary' => $summary,
|
||||
'completedAt' => $completedAt,
|
||||
];
|
||||
if ($phoneBoundAt !== null && trim($phoneBoundAt) !== '') {
|
||||
$payload['phoneBoundAt'] = trim($phoneBoundAt);
|
||||
}
|
||||
if (in_array($testType, ['face', 'ai'], true)) {
|
||||
$dims = self::buildFaceAiBotDimensions($data);
|
||||
if ($dims['mbti'] !== '') {
|
||||
@@ -832,14 +930,34 @@ class OutboundPushHookService
|
||||
}
|
||||
self::mergeTestResultUserJourneyPayload($payload, $userId, $row, $wuArr);
|
||||
|
||||
self::dispatch('test.result_completed', [
|
||||
'event' => 'test.result_completed',
|
||||
'occurredAt' => self::iso8601Cn(),
|
||||
'environment' => self::appEnv(),
|
||||
'tenant' => self::tenantPayload($eid),
|
||||
'payload' => $payload,
|
||||
'_dedupKey' => 'test.result_completed:' . $testResultId,
|
||||
], $eid);
|
||||
return [
|
||||
'eid' => $eid,
|
||||
'envelope' => [
|
||||
'event' => 'test.result_completed',
|
||||
'occurredAt' => self::iso8601Cn(),
|
||||
'environment' => self::appEnv(),
|
||||
'tenant' => self::tenantPayload($eid),
|
||||
'payload' => $payload,
|
||||
'_dedupKey' => 'test.result_completed:' . $testResultId,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 测评记录落库后推送(问卷 submit / 分析写库)
|
||||
* 无手机号时不推送,待用户授权手机号后与测评合并为一条(见 onPhoneBound)
|
||||
*/
|
||||
public static function onTestResultCompleted(int $testResultId): void
|
||||
{
|
||||
$pack = self::assembleTestResultCompletedEnvelope($testResultId, null, null);
|
||||
if ($pack === null) {
|
||||
return;
|
||||
}
|
||||
$phone = trim((string) ($pack['envelope']['payload']['phone'] ?? ''));
|
||||
if ($phone === '') {
|
||||
return;
|
||||
}
|
||||
self::dispatch('test.result_completed', $pack['envelope'], $pack['eid']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -857,8 +975,8 @@ class OutboundPushHookService
|
||||
];
|
||||
}
|
||||
|
||||
$row = Db::name('test_results')->where('id', $testResultId)->find();
|
||||
if (!$row) {
|
||||
$pack = self::assembleTestResultCompletedEnvelope($testResultId, null, null);
|
||||
if ($pack === null) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => 'not_found',
|
||||
@@ -867,75 +985,7 @@ class OutboundPushHookService
|
||||
];
|
||||
}
|
||||
|
||||
$userId = (int) ($row['userId'] ?? 0);
|
||||
$testType = (string) ($row['testType'] ?? '');
|
||||
$createdAt = isset($row['createdAt']) ? (int) $row['createdAt'] : time();
|
||||
$completedAt = date('Y-m-d H:i:s', $createdAt);
|
||||
|
||||
$wu = $userId > 0
|
||||
? Db::name('wechat_users')->where('id', $userId)->field('nickname,phone,enterpriseId,openid')->find()
|
||||
: null;
|
||||
$userName = $wu ? trim((string) ($wu['nickname'] ?? '')) : '';
|
||||
if ($userName === '') {
|
||||
$userName = '微信用户';
|
||||
}
|
||||
$phone = $wu ? trim((string) ($wu['phone'] ?? '')) : '';
|
||||
|
||||
$raw = $row['resultData'] ?? null;
|
||||
$data = is_string($raw) ? json_decode($raw, true) : $raw;
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
|
||||
$summary = self::formatTestResultSummary($testType, $data);
|
||||
$label = self::testTypeLabel($testType);
|
||||
|
||||
$wuArr = null;
|
||||
if ($wu !== null) {
|
||||
if (is_array($wu)) {
|
||||
$wuArr = $wu;
|
||||
} elseif (is_object($wu) && method_exists($wu, 'toArray')) {
|
||||
$wuArr = $wu->toArray();
|
||||
}
|
||||
}
|
||||
$eid = self::resolveEnterpriseIdForTestResult($row, $wuArr);
|
||||
|
||||
$payload = [
|
||||
'display' => [
|
||||
'title' => '用户测评完成(实时推送)',
|
||||
'emoji' => '📊',
|
||||
],
|
||||
'testResultId' => $testResultId,
|
||||
'userId' => $userId,
|
||||
'userName' => $userName,
|
||||
'phone' => $phone,
|
||||
'testType' => $testType,
|
||||
'testTypeLabel' => $label,
|
||||
'resultSummary' => $summary,
|
||||
'completedAt' => $completedAt,
|
||||
];
|
||||
if (in_array($testType, ['face', 'ai'], true)) {
|
||||
$dims = self::buildFaceAiBotDimensions($data);
|
||||
if ($dims['mbti'] !== '') {
|
||||
$payload['resultMbti'] = $dims['mbti'];
|
||||
}
|
||||
if ($dims['pdp'] !== '') {
|
||||
$payload['resultPdp'] = $dims['pdp'];
|
||||
}
|
||||
if ($dims['disc'] !== '') {
|
||||
$payload['resultDisc'] = $dims['disc'];
|
||||
}
|
||||
}
|
||||
self::mergeTestResultUserJourneyPayload($payload, $userId, $row, $wuArr);
|
||||
|
||||
return self::dispatchDetailed('test.result_completed', [
|
||||
'event' => 'test.result_completed',
|
||||
'occurredAt' => self::iso8601Cn(),
|
||||
'environment' => self::appEnv(),
|
||||
'tenant' => self::tenantPayload($eid),
|
||||
'payload' => $payload,
|
||||
'_dedupKey' => 'test.result_completed:' . $testResultId,
|
||||
], $eid, $force);
|
||||
return self::dispatchDetailed('test.result_completed', $pack['envelope'], $pack['eid'], $force);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
99
api/app/common/service/ResumeFileExtractService.php
Normal file
99
api/app/common/service/ResumeFileExtractService.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 从简历文件 URL 提取可读文本(PDF / docx),供神仙 AI 对话与简历分析共用。
|
||||
*/
|
||||
class ResumeFileExtractService
|
||||
{
|
||||
public static function extractFromUrl(string $fileUrl): string
|
||||
{
|
||||
if ($fileUrl === '' || !filter_var($fileUrl, FILTER_VALIDATE_URL)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo(parse_url($fileUrl, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
|
||||
|
||||
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'timeout' => 15,
|
||||
'user_agent' => 'Mozilla/5.0',
|
||||
],
|
||||
'ssl' => [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
],
|
||||
]);
|
||||
$raw = @file_get_contents($fileUrl, false, $ctx);
|
||||
if ($raw === false || strlen($raw) < 10) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($ext === 'docx') {
|
||||
return self::extractDocxText($raw);
|
||||
}
|
||||
|
||||
if ($ext === 'pdf') {
|
||||
return self::extractPdfText($raw);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function extractDocxText(string $raw): string
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'docx_');
|
||||
if ($tmp === false) {
|
||||
return '';
|
||||
}
|
||||
if (file_put_contents($tmp, $raw) === false) {
|
||||
@unlink($tmp);
|
||||
|
||||
return '';
|
||||
}
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($tmp) !== true) {
|
||||
@unlink($tmp);
|
||||
|
||||
return '';
|
||||
}
|
||||
$xml = $zip->getFromName('word/document.xml');
|
||||
$zip->close();
|
||||
@unlink($tmp);
|
||||
if ($xml === false || $xml === '') {
|
||||
return '';
|
||||
}
|
||||
preg_match_all('/<w:t[^>]*>([^<]*)<\/w:t>/u', $xml, $m);
|
||||
$text = isset($m[1]) ? implode('', $m[1]) : '';
|
||||
$text = html_entity_decode($text, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
$text = preg_replace('/[^\x{4e00}-\x{9fff}\x{3000}-\x{303f}a-zA-Z0-9\s\.,;:!?()()【】\[\]\/\-_@]/u', ' ', $text);
|
||||
|
||||
return trim(preg_replace('/\s{2,}/', ' ', $text));
|
||||
}
|
||||
|
||||
private static function extractPdfText(string $raw): string
|
||||
{
|
||||
$text = '';
|
||||
if (preg_match_all('/BT\s+(.*?)\s+ET/s', $raw, $blocks)) {
|
||||
foreach ($blocks[1] as $block) {
|
||||
if (preg_match_all('/\(([^)]*)\)\s*Tj/s', $block, $tj)) {
|
||||
$text .= implode(' ', $tj[1]) . ' ';
|
||||
}
|
||||
if (preg_match_all('/\[(.*?)]\s*TJ/s', $block, $tjArr)) {
|
||||
foreach ($tjArr[1] as $inner) {
|
||||
if (preg_match_all('/\(([^)]*)\)/s', $inner, $parts)) {
|
||||
$text .= implode('', $parts[1]) . ' ';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$text = preg_replace('/[^\x{4e00}-\x{9fff}\x{3000}-\x{303f}a-zA-Z0-9\s\.,;:!?()()【】\[\]\/\-_@]/u', ' ', $text);
|
||||
|
||||
return trim(preg_replace('/\s{2,}/', ' ', $text));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\AnalyticsEventLabels;
|
||||
use app\common\AnalyticsPagePathLabels;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
@@ -79,28 +81,26 @@ class UserJourneyService
|
||||
$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;
|
||||
$label = AnalyticsEventLabels::cn($name);
|
||||
$pathCn = $path !== '' ? AnalyticsPagePathLabels::cn($path) : '';
|
||||
$detail = '';
|
||||
if ($name === 'page_view' && $path !== '') {
|
||||
$detail = $path;
|
||||
}
|
||||
if (isset($props['action']) && (string) $props['action'] !== '') {
|
||||
$detail = (string) $props['action'];
|
||||
if ($name === 'page_view' && $pathCn !== '') {
|
||||
$detail = $pathCn;
|
||||
} elseif (isset($props['action']) && (string) $props['action'] !== '') {
|
||||
$act = (string) $props['action'];
|
||||
$detail = AnalyticsPagePathLabels::cn($act);
|
||||
if (!empty($props['productType'])) {
|
||||
$detail .= ' · ' . (string) $props['productType'];
|
||||
$pt = (string) $props['productType'];
|
||||
$detail .= ' · ' . self::productTypeCn($pt);
|
||||
}
|
||||
} elseif ($pathCn !== '' && $detail === '') {
|
||||
$detail = $pathCn;
|
||||
} elseif (isset($props['type']) && (string) $props['type'] !== '' && $detail === '') {
|
||||
$detail = self::productTypeCn((string) $props['type']);
|
||||
} elseif (isset($props['label']) && (string) $props['label'] !== '') {
|
||||
$detail = (string) $props['label'];
|
||||
} elseif ($path !== '' && $detail === '') {
|
||||
$detail = $path;
|
||||
}
|
||||
$line = $detail !== '' ? "{$label}: {$detail}" : $label;
|
||||
$line = $detail !== '' ? "{$label} · {$detail}" : $label;
|
||||
$ts = isset($r['clientTs']) ? (int) $r['clientTs'] : null;
|
||||
if (!$ts && !empty($r['createdAt'])) {
|
||||
$ts = strtotime((string) $r['createdAt']) * 1000;
|
||||
@@ -112,6 +112,24 @@ class UserJourneyService
|
||||
return $line;
|
||||
}
|
||||
|
||||
/**
|
||||
* 埋点 props 里常见测评类型 → 中文
|
||||
*/
|
||||
private static function productTypeCn(string $t): string
|
||||
{
|
||||
$t = strtolower(trim($t));
|
||||
$m = [
|
||||
'mbti' => 'MBTI',
|
||||
'disc' => 'DISC',
|
||||
'pdp' => 'PDP',
|
||||
'sbti' => 'SBTI',
|
||||
'face' => '面相',
|
||||
'ai' => 'AI 综合',
|
||||
];
|
||||
|
||||
return $m[$t] ?? strtoupper($t);
|
||||
}
|
||||
|
||||
public static function humanTimeAgoCn(int $clientTsMs): string
|
||||
{
|
||||
$now = (int) (microtime(true) * 1000);
|
||||
|
||||
126
api/app/common/service/WechatAuditSyncService.php
Normal file
126
api/app/common/service/WechatAuditSyncService.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
namespace app\common\service;
|
||||
|
||||
use app\model\SystemConfig as SystemConfigModel;
|
||||
|
||||
/**
|
||||
* 根据微信 get_latest_auditstatus 自动切换 system.miniprogramAuditMode
|
||||
* 审核中(2)/延后(4) → 开启提审隐藏;成功(0)/拒绝(1)/撤回(3)/无审核单 → 关闭
|
||||
*/
|
||||
class WechatAuditSyncService
|
||||
{
|
||||
/** 无有效审核单等:关闭提审模式 */
|
||||
public const NO_AUDIT_ERRCODES = [85058, 86001];
|
||||
|
||||
public const THROTTLE_SECONDS = 300;
|
||||
|
||||
public static function shouldAutoSync(array $systemOut): bool
|
||||
{
|
||||
if (array_key_exists('wechatAuditAutoMiniprogramMode', $systemOut)
|
||||
&& $systemOut['wechatAuditAutoMiniprogramMode'] === false) {
|
||||
return false;
|
||||
}
|
||||
$last = (int) ($systemOut['wechatLastAuditSyncedAt'] ?? 0);
|
||||
if ($last > 0 && (time() - $last) < self::THROTTLE_SECONDS) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $bypassThrottle 管理端「立即同步」为 true;定时/打开设置页自动为 false
|
||||
* @return array{
|
||||
* ok: bool,
|
||||
* skipped?: bool,
|
||||
* applied: bool,
|
||||
* miniprogramAuditMode: bool,
|
||||
* wechat: array,
|
||||
* message?: string,
|
||||
* systemBroadcast?: array
|
||||
* }
|
||||
*/
|
||||
public static function run(bool $bypassThrottle = false): array
|
||||
{
|
||||
$config = SystemConfigModel::where('key', 'system')->where('enterprise_id', 0)->find();
|
||||
$oldVal = $config ? $config->value : null;
|
||||
$systemArr = is_array($oldVal) ? $oldVal : (is_string($oldVal) ? (json_decode($oldVal, true) ?: []) : []);
|
||||
if (!is_array($systemArr)) {
|
||||
$systemArr = [];
|
||||
}
|
||||
|
||||
$currentMpAudit = !empty($systemArr['miniprogramAuditMode']);
|
||||
|
||||
if (!$bypassThrottle && !self::shouldAutoSync($systemArr)) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'skipped' => true,
|
||||
'applied' => false,
|
||||
'miniprogramAuditMode' => $currentMpAudit,
|
||||
'wechat' => [],
|
||||
'message' => 'skipped_throttle_or_auto_off',
|
||||
];
|
||||
}
|
||||
|
||||
$wx = WechatService::getLatestAuditStatus();
|
||||
$now = time();
|
||||
|
||||
$systemArr['wechatLastAuditSyncedAt'] = $now;
|
||||
|
||||
$target = null;
|
||||
$errcode = isset($wx['errcode']) ? (int) $wx['errcode'] : -9;
|
||||
|
||||
if (isset($wx['errcode']) && $wx['errcode'] === 0 && array_key_exists('status', $wx)) {
|
||||
$st = (int) $wx['status'];
|
||||
if (in_array($st, [2, 4], true)) {
|
||||
$target = true;
|
||||
} elseif (in_array($st, [0, 1, 3], true)) {
|
||||
$target = false;
|
||||
}
|
||||
$systemArr['wechatLastAuditErrcode'] = 0;
|
||||
$systemArr['wechatLastAuditStatus'] = $st;
|
||||
$systemArr['wechatLastAuditReason'] = (string) ($wx['reason'] ?? '');
|
||||
} elseif (in_array($errcode, self::NO_AUDIT_ERRCODES, true)) {
|
||||
$target = false;
|
||||
$systemArr['wechatLastAuditErrcode'] = $errcode;
|
||||
$systemArr['wechatLastAuditStatus'] = null;
|
||||
$systemArr['wechatLastAuditReason'] = (string) ($wx['errmsg'] ?? '');
|
||||
} else {
|
||||
$systemArr['wechatLastAuditErrcode'] = $errcode;
|
||||
$systemArr['wechatLastAuditStatus'] = isset($wx['status']) ? (int) $wx['status'] : null;
|
||||
$systemArr['wechatLastAuditReason'] = (string) ($wx['errmsg'] ?? '');
|
||||
}
|
||||
|
||||
$applied = false;
|
||||
if ($target !== null && (bool) $target !== $currentMpAudit) {
|
||||
$systemArr['miniprogramAuditMode'] = (bool) $target;
|
||||
$applied = true;
|
||||
}
|
||||
|
||||
if (!$config) {
|
||||
$config = new SystemConfigModel();
|
||||
$config->key = 'system';
|
||||
$config->enterprise_id = 0;
|
||||
$config->description = '系统基础配置';
|
||||
}
|
||||
$config->value = $systemArr;
|
||||
$config->save();
|
||||
|
||||
$broadcast = [
|
||||
'miniprogramAuditMode' => !empty($systemArr['miniprogramAuditMode']),
|
||||
'wechatAuditAutoMiniprogramMode' => !array_key_exists('wechatAuditAutoMiniprogramMode', $systemArr)
|
||||
|| $systemArr['wechatAuditAutoMiniprogramMode'] !== false,
|
||||
'wechatLastAuditErrcode' => $systemArr['wechatLastAuditErrcode'] ?? null,
|
||||
'wechatLastAuditStatus' => $systemArr['wechatLastAuditStatus'] ?? null,
|
||||
'wechatLastAuditReason' => $systemArr['wechatLastAuditReason'] ?? '',
|
||||
'wechatLastAuditSyncedAt' => $systemArr['wechatLastAuditSyncedAt'] ?? null,
|
||||
];
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'applied' => $applied,
|
||||
'miniprogramAuditMode' => (bool) ($systemArr['miniprogramAuditMode'] ?? false),
|
||||
'wechat' => $wx,
|
||||
'systemBroadcast' => $broadcast,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ class WechatService
|
||||
protected static $tokenUrl = 'https://api.weixin.qq.com/cgi-bin/token';
|
||||
protected static $getPhoneNumberUrl = 'https://api.weixin.qq.com/wxa/business/getuserphonenumber';
|
||||
protected static $getWxacodeUnlimitedUrl = 'https://api.weixin.qq.com/wxa/getwxacodeunlimit';
|
||||
protected static $getLatestAuditStatusUrl = 'https://api.weixin.qq.com/wxa/get_latest_auditstatus';
|
||||
|
||||
/** @var string|null 内存缓存的 access_token */
|
||||
protected static $cachedAccessToken = null;
|
||||
@@ -191,4 +192,27 @@ class WechatService
|
||||
}
|
||||
return ['binary' => $resp];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询最新一次代码提审状态(官方 status:0 成功 1 拒绝 2 审核中 3 撤回 4 延后)
|
||||
* @return array 微信原始 JSON(含 errcode);失败时含 errcode/errmsg
|
||||
*/
|
||||
public static function getLatestAuditStatus(): array
|
||||
{
|
||||
$tokenResult = self::getAccessToken();
|
||||
if (isset($tokenResult['errcode'])) {
|
||||
return $tokenResult;
|
||||
}
|
||||
$accessToken = $tokenResult['access_token'];
|
||||
$url = self::$getLatestAuditStatusUrl . '?access_token=' . urlencode($accessToken);
|
||||
$resp = @file_get_contents($url);
|
||||
if ($resp === false) {
|
||||
return ['errcode' => -2, 'errmsg' => '请求微信接口失败'];
|
||||
}
|
||||
$data = json_decode($resp, true);
|
||||
if (empty($data) || !is_array($data)) {
|
||||
return ['errcode' => -3, 'errmsg' => '微信接口返回异常'];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user