feat: 同步今日小程序与后台迭代版本

集中提交今日 API、管理端、小程序与部署文档调整,确保 Gitea 主分支与本地最新开发版本一致。

Made-with: Cursor
This commit is contained in:
卡若
2026-04-20 11:07:55 +08:00
parent 7108f28280
commit ce55c48c64
180 changed files with 11659 additions and 6078 deletions

View File

@@ -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' => '点击·上传简历',

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

View File

@@ -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 .= "第一句就要切入正题;分段清晰;单次回复以 180380 字为宜(用户明确要求更长时再略增)。不堆砌术语。\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);
}

View File

@@ -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();

View File

@@ -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` 共用)
*/

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

View 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,
];
}
}

View File

@@ -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);
}
/**

View 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));
}
}

View File

@@ -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);

View 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,
];
}
}

View File

@@ -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];
}
/**
* 查询最新一次代码提审状态(官方 status0 成功 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;
}
}