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

@@ -8,3 +8,6 @@
# 示例:强制本机调试
# APP_DEBUG = true
# 神仙 AI 异步对话:向本站 POST /api/internal/outbound-push/dispatch。若线上 HTTPS 自调用失败,可指定本机入口(无尾斜杠):
# MBTI_INTERNAL_DISPATCH_URL = http://127.0.0.1:8787

View File

@@ -1,121 +1,141 @@
<?php
namespace app;
use think\App;
use think\exception\ValidateException;
use think\Validate;
use app\common\service\JwtService;
/**
* 控制器基础类
*/
abstract class BaseController
{
/**
* Request实例
* @var \think\Request
*/
protected $request;
/**
* 应用实例
* @var \think\App
*/
protected $app;
/**
* 是否批量验证
* @var bool
*/
protected $batchValidate = false;
/**
* 控制器中间件
* @var array
*/
protected $middleware = [];
/**
* 构造方法
* @access public
* @param App $app 应用对象
*/
public function __construct(App $app)
{
$this->app = $app;
$this->request = $this->app->request;
// 控制器初始化
$this->initialize();
}
// 初始化
protected function initialize()
{}
/**
* 验证数据
* @access protected
* @param array $data 数据
* @param string|array $validate 验证器名或者验证规则数组
* @param array $message 提示信息
* @param bool $batch 是否批量验证
* @return array|string|true
* @throws ValidateException
*/
/**
* 从请求中解析当前登录用户(兼容中间件注入和 JWT 直接解析两种方式)
*/
protected function resolveUser(): ?array
{
$user = $this->request->user ?? null;
if ($user) {
return is_array($user) ? $user : (array) $user;
}
$token = JwtService::getTokenFromRequest($this->request);
if (!$token) {
return null;
}
$payload = JwtService::verifyToken($token);
if (!$payload) {
return null;
}
return [
'source' => $payload['source'] ?? '',
'user_id' => $payload['user_id'] ?? $payload['userId'] ?? null,
'userId' => $payload['user_id'] ?? $payload['userId'] ?? null,
];
}
protected function validate(array $data, $validate, array $message = [], bool $batch = false)
{
if (is_array($validate)) {
$v = new Validate();
$v->rule($validate);
} else {
if (strpos($validate, '.')) {
// 支持场景
[$validate, $scene] = explode('.', $validate);
}
$class = false !== strpos($validate, '\\') ? $validate : $this->app->parseClass('validate', $validate);
$v = new $class();
if (!empty($scene)) {
$v->scene($scene);
}
}
$v->message($message);
// 是否批量验证
if ($batch || $this->batchValidate) {
$v->batch(true);
}
return $v->failException(true)->check($data);
}
}
<?php
namespace app;
use think\App;
use think\exception\ValidateException;
use think\Validate;
use app\common\service\JwtService;
/**
* 控制器基础类
*/
abstract class BaseController
{
/**
* Request实例
* @var \think\Request
*/
protected $request;
/**
* 应用实例
* @var \think\App
*/
protected $app;
/**
* 是否批量验证
* @var bool
*/
protected $batchValidate = false;
/**
* 控制器中间件
* @var array
*/
protected $middleware = [];
/**
* 构造方法
* @access public
* @param App $app 应用对象
*/
public function __construct(App $app)
{
$this->app = $app;
$this->request = $this->app->request;
// 控制器初始化
$this->initialize();
}
// 初始化
protected function initialize()
{}
/**
* 验证数据
* @access protected
* @param array $data 数据
* @param string|array $validate 验证器名或者验证规则数组
* @param array $message 提示信息
* @param bool $batch 是否批量验证
* @return array|string|true
* @throws ValidateException
*/
/**
* 从请求中解析当前登录用户(兼容中间件注入和 JWT 直接解析两种方式)
*/
protected function resolveUser(): ?array
{
$user = $this->request->user ?? null;
if ($user) {
return is_array($user) ? $user : (array) $user;
}
$token = JwtService::getTokenFromRequest($this->request);
if (!$token) {
return null;
}
$payload = JwtService::verifyToken($token);
if (!$payload) {
return null;
}
return [
'source' => $payload['source'] ?? '',
'user_id' => $payload['user_id'] ?? $payload['userId'] ?? null,
'userId' => $payload['user_id'] ?? $payload['userId'] ?? null,
];
}
/**
* 当前请求 JWT 主体用户 id与 Auth 中间件一致user_id / userId兼容历史 id
*/
protected function jwtSubjectUserId(): int
{
$fromReq = $this->request->userId ?? null;
if ($fromReq !== null && $fromReq !== '') {
$n = (int) $fromReq;
if ($n > 0) {
return $n;
}
}
$user = $this->request->user ?? [];
if (!is_array($user)) {
return 0;
}
return (int) ($user['user_id'] ?? $user['userId'] ?? $user['id'] ?? 0);
}
protected function validate(array $data, $validate, array $message = [], bool $batch = false)
{
if (is_array($validate)) {
$v = new Validate();
$v->rule($validate);
} else {
if (strpos($validate, '.')) {
// 支持场景
[$validate, $scene] = explode('.', $validate);
}
$class = false !== strpos($validate, '\\') ? $validate : $this->app->parseClass('validate', $validate);
$v = new $class();
if (!empty($scene)) {
$v->scene($scene);
}
}
$v->message($message);
// 是否批量验证
if ($batch || $this->batchValidate) {
$v->batch(true);
}
return $v->failException(true)->check($data);
}
}

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

View File

@@ -120,12 +120,35 @@ class AppUser extends BaseController
if ($enterpriseId) {
$total = (int) (clone $baseQuery)->distinct(true)->count('w.id');
$list = (clone $baseQuery)
->field('w.id,w.nickname,w.openid,w.avatar,w.phone,w.gender,w.country,w.province,w.city,w.status,w.lastLoginAt,w.createdAt')
// 先按「去重后的 w.id」分页再拉全字段避免 JOIN 放大行数导致 LIMIT 作用在重复用户上,
// 进而出现「一页里混入全库统计感」或本页人数与 pageSize 不一致。
$idRows = (clone $baseQuery)
->field('w.id')
->group('w.id')
->order('w.id', 'desc')
->page($page, $pageSize)
->select()
->toArray();
$orderedIds = array_values(array_filter(array_map('intval', array_column($idRows, 'id'))));
if ($orderedIds === []) {
$list = [];
} else {
$rows = Db::name('wechat_users')->alias('w')
->whereIn('w.id', $orderedIds)
->field('w.id,w.nickname,w.openid,w.avatar,w.phone,w.gender,w.country,w.province,w.city,w.status,w.lastLoginAt,w.createdAt')
->select()
->toArray();
$byId = [];
foreach ($rows as $r) {
$byId[(int) ($r['id'] ?? 0)] = $r;
}
$list = [];
foreach ($orderedIds as $oid) {
if (isset($byId[$oid])) {
$list[] = $byId[$oid];
}
}
}
} else {
$total = (int) (clone $baseQuery)->count();
$list = (clone $baseQuery)

View File

@@ -361,7 +361,7 @@ class Settings extends BaseController
$tcDefaults = [
'analyzingTitle' => '正在分析中',
'startButtonText' => '开始面相测试',
'startButtonText' => '30秒测出你的性格',
'startButtonEnterprise' => '开始面部测试',
'reportTitle' => '分析报告',
'aiAnalysisText' => '智能分析',
@@ -645,7 +645,7 @@ class Settings extends BaseController
$tcKeys = ['analyzingTitle', 'startButtonText', 'startButtonEnterprise', 'reportTitle', 'aiAnalysisText'];
$tcDefaults = [
'analyzingTitle' => '正在分析中',
'startButtonText' => '开始面相测试',
'startButtonText' => '30秒测出你的性格',
'startButtonEnterprise' => '开始面部测试',
'reportTitle' => '分析报告',
'aiAnalysisText' => '智能分析',

View File

@@ -4,9 +4,13 @@ namespace app\controller\api;
use app\BaseController;
use app\common\service\AiCallService;
use app\common\service\AiChatArticleDisplayService;
use app\common\service\OutboundPushHookService;
use app\common\service\MiniprogramAuditMode;
use app\common\service\ResumeFileExtractService;
use app\common\service\SoulArticleService;
use app\model\AiConversation as AiConversationModel;
use app\model\AiMessage as AiMessageModel;
use think\facade\Cache;
use think\facade\Db;
use think\facade\Log;
use think\facade\Request;
@@ -19,46 +23,210 @@ use think\facade\Request;
*/
class AiChat extends BaseController
{
/** 每用户每日发送上限(超出则返回 429 */
private const DAILY_LIMIT = 20;
/** 单次对话最多携带的历史上下文user+assistant 轮数);减小可降低上游耗时 */
private const CONTEXT_TURNS = 5;
/** 单次对话最多携带的历史上下文user+assistant 轮数) */
private const CONTEXT_TURNS = 8;
/** 异步任务缓存 TTL须大于模型最坏耗时 */
private const CHAT_JOB_TTL = 900;
private static function chatJobCacheKey(int $userId, string $jobId): string
{
return 'ai_chat_job:' . $userId . ':' . $jobId;
}
/** 任务状态写入 MySQL避免多机环境下文件 Cache 不一致导致轮询失败 */
private static function jobTableTryInsertRunning(int $userId, string $jobId, int $conversationId): void
{
try {
$now = time();
Db::name('ai_chat_jobs')->insert([
'userId' => $userId,
'jobId' => $jobId,
'conversationId' => $conversationId,
'status' => 'running',
'resultJson' => null,
'errorMessage' => null,
'createdAt' => $now,
'updatedAt' => $now,
]);
} catch (\Throwable $e) {
Log::warning('AiChat jobTableTryInsertRunning: ' . $e->getMessage());
}
}
private static function jobTableTrySetDone(int $userId, string $jobId, array $payload): void
{
try {
$json = json_encode($payload, JSON_UNESCAPED_UNICODE);
if ($json === false) {
Log::error('AiChat jobTableTrySetDone: json_encode failed for jobId=' . $jobId);
return;
}
$n = Db::name('ai_chat_jobs')->where('userId', $userId)->where('jobId', $jobId)->update([
'status' => 'done',
'resultJson' => $json,
'updatedAt' => time(),
]);
if ($n < 1) {
Log::error('AiChat jobTableTrySetDone: no row updated userId=' . $userId . ' jobId=' . $jobId);
}
} catch (\Throwable $e) {
Log::error('AiChat jobTableTrySetDone: ' . $e->getMessage());
}
}
private static function jobTableTrySetError(int $userId, string $jobId, int $conversationId, string $err): void
{
try {
$msg = mb_substr($err, 0, 500, 'UTF-8');
$n = Db::name('ai_chat_jobs')->where('userId', $userId)->where('jobId', $jobId)->update([
'status' => 'error',
'errorMessage' => $msg,
'conversationId' => $conversationId,
'updatedAt' => time(),
]);
if ($n < 1) {
Log::error('AiChat jobTableTrySetError: no row updated userId=' . $userId . ' jobId=' . $jobId);
}
} catch (\Throwable $e) {
Log::error('AiChat jobTableTrySetError: ' . $e->getMessage());
}
}
/** @return array<string, mixed>|null */
private static function jobTableTryFetch(int $userId, string $jobId): ?array
{
try {
$row = Db::name('ai_chat_jobs')->where('userId', $userId)->where('jobId', $jobId)->find();
if (!$row) {
return null;
}
return is_object($row) ? $row->toArray() : (array) $row;
} catch (\Throwable $e) {
Log::warning('AiChat jobTableTryFetch: ' . $e->getMessage());
return null;
}
}
/**
* @param array<string, mixed> $row DB 行或 Cache 数组
* @param 'db'|'cache' $source
*/
private function respondChatJobPayload(array $row, string $source)
{
$st = (string) ($row['status'] ?? '');
if ($st === 'running') {
return success([
'pending' => true,
'conversationId' => (int) ($row['conversationId'] ?? 0),
'usageToday' => 0,
'dailyLimit' => 0,
]);
}
if ($st === 'done') {
if ($source === 'db') {
$raw = $row['resultJson'] ?? '';
$data = is_string($raw) ? json_decode($raw, true) : null;
} else {
$data = $row['data'] ?? null;
}
if (!is_array($data)) {
return error('任务结果异常', 500);
}
return success($data);
}
if ($st === 'error') {
$err = $source === 'db'
? (string) ($row['errorMessage'] ?? '')
: (string) ($row['error'] ?? '');
Log::warning('AiChat chatJobStatus error job: ' . $err);
return success([
'pending' => false,
'conversationId' => (int) ($row['conversationId'] ?? 0),
'message' => [
'id' => 0,
'role' => 'assistant',
'content' => '小神仙这边出了点状况,请稍后再试~',
'providerId' => 'degrade',
'isDegraded' => true,
'createdAt' => time(),
],
'usageToday' => 0,
'dailyLimit' => 0,
]);
}
return error('任务状态未知', 500);
}
/**
* POST /api/ai/chat
* body: { conversationId?: int, message: string }
* 返回 { conversationId, message: {role, content, providerId, isDegraded, createdAt}, usageToday, dailyLimit }
* body: { conversationId?: int, message: string, resumeFileUrl?: string, resumeFileName?: string }
*
* 小程序真机 wx.request 约 60s 上限,大模型单次同步易超时;此处固定走异步:
* 立即返回 { async, jobId, conversationId },客户端轮询 GET /api/ai/chat/job?jobId=
* 完成后 data 与旧版同步接口一致:{ conversationId, message, usageToday, dailyLimit }
*/
public function chat()
{
$userId = $this->currentUserId();
if ($userId <= 0) return error('请先登录', 401);
if (MiniprogramAuditMode::isOn()) {
return error('对话功能升级中,请稍后再试', 503);
}
$message = trim((string) Request::post('message', ''));
if ($message === '') return error('消息不能为空', 400);
if (mb_strlen($message, 'UTF-8') > 800) {
// param 合并 post/json 体,避免个别网关/客户端下 post 键未进 $_POST 的边角情况
$message = trim((string) Request::param('message', ''));
$resumeFileUrl = trim((string) Request::param('resumeFileUrl', ''));
$resumeFileName = trim((string) Request::param('resumeFileName', ''));
$resumeExtra = '';
if ($resumeFileUrl !== '') {
if (!filter_var($resumeFileUrl, FILTER_VALIDATE_URL)) {
return error('简历地址无效', 400);
}
$resumeText = ResumeFileExtractService::extractFromUrl($resumeFileUrl);
$resumeText = trim(preg_replace('/\s+/u', ' ', $resumeText));
if ($resumeText === '') {
$resumeText = '(未能从文件中提取可读正文;若为扫描件或图片简历,请你在下一条消息里补充关键经历。)';
}
$cap = 8000;
if (mb_strlen($resumeText, 'UTF-8') > $cap) {
$resumeText = mb_substr($resumeText, 0, $cap, 'UTF-8') . "\n…(正文已截断)";
}
$fnHint = $resumeFileName !== '' ? '《' . $resumeFileName . '》' : '简历附件';
$resumeExtra = "\n\n" . $fnHint . " · 系统提取正文】\n" . $resumeText;
}
if ($message === '' && $resumeExtra === '') {
return error('消息不能为空', 400);
}
$fullMessage = $message;
if ($resumeExtra !== '') {
if ($fullMessage === '') {
$fullMessage = '我刚上传了简历'
. ($resumeFileName !== '' ? '「' . $resumeFileName . '」' : '')
. ',请结合我已有测评类型,帮我做简历诊断:优势、匹配岗位、改进建议与下一步行动。';
}
$fullMessage .= $resumeExtra;
}
if (mb_strlen($fullMessage, 'UTF-8') > 12000) {
return error('简历内容过长,请换较短文件或分段说明', 400);
}
if ($resumeExtra === '' && mb_strlen($message, 'UTF-8') > 800) {
return error('消息过长,请精简到 800 字以内', 400);
}
$conversationId = (int) Request::post('conversationId', 0);
$conversationId = (int) Request::param('conversationId', 0);
// 每日限流(必须在 try 外先取 used失败时返回 200+降级,避免未捕获异常导致 HTTP 500
$used = 0;
try {
$used = $this->incrementAndCheckDaily($userId, self::DAILY_LIMIT);
} catch (\Throwable $e) {
Log::warning('AiChat incrementAndCheckDaily: ' . $e->getMessage());
return $this->chatDegradeResponse(
$conversationId,
0,
'小神仙这边计数服务抖了一下,请稍后再发一条消息~'
);
}
if ($used === -1) {
return error('今日对话次数已用完,明天再来找我呀~', 429);
}
// 每日对话条数:已永久取消(不设上限、不计数、不返回 429
$userMsgSaved = false;
$conversation = null;
@@ -67,10 +235,11 @@ class AiChat extends BaseController
$userContext = [
'mbtiType' => '', 'summary' => '', 'nickname' => '', 'testAppendix' => '',
];
// 首包须尽快返回 { jobId };完整测评附录仅在异步 executeAssistantTurn 中拉取,避免 buildLatestTestsAppendix 拖垮 wx.request
try {
$userContext = AiCallService::fetchUserContext($userId);
$userContext = AiCallService::fetchUserContextLight($userId);
} catch (\Throwable $e) {
Log::warning('AiChat fetchUserContext: ' . $e->getMessage());
Log::warning('AiChat fetchUserContextLight: ' . $e->getMessage());
}
$conversation = $conversationId > 0
@@ -79,9 +248,9 @@ class AiChat extends BaseController
$now = time();
if (!$conversation) {
$title = mb_substr($message, 0, 24, 'UTF-8');
$title = mb_substr($message !== '' ? $message : $fullMessage, 0, 24, 'UTF-8');
if ($title === false) {
$title = mb_substr($message, 0, 24);
$title = mb_substr($message !== '' ? $message : $fullMessage, 0, 24);
}
if (!is_string($title) || $title === '') {
$title = '新对话';
@@ -97,11 +266,11 @@ class AiChat extends BaseController
$conversationId = (int) $conversation->id;
}
// 写入 user 消息
// 写入 user 消息(含简历正文时 content 较长,供异步轮与模型上下文一致)
AiMessageModel::create([
'conversationId' => $conversationId,
'role' => 'user',
'content' => $message,
'content' => $fullMessage,
'tokensIn' => 0,
'tokensOut' => 0,
'providerId' => '',
@@ -110,61 +279,33 @@ class AiChat extends BaseController
]);
$userMsgSaved = true;
// 组装 messagessystem + 最近 N 轮历史 + 本次 user
try {
$systemPrompt = AiCallService::buildSystemPrompt($userContext);
} catch (\Throwable $e) {
Log::warning('AiChat buildSystemPrompt: ' . $e->getMessage());
$systemPrompt = "你是「神仙 AI」神仙团队 MBTI 小程序的专属伙伴。请亲切、简短地回答用户;禁止医疗诊断与投资建议。\n";
}
$history = AiMessageModel::where('conversationId', $conversationId)
->order('id', 'desc')
->limit(self::CONTEXT_TURNS * 2)
->select()
->toArray();
$history = array_reverse($history);
$messages = [['role' => 'system', 'content' => $systemPrompt]];
foreach ($history as $row) {
if (!in_array($row['role'], ['user', 'assistant'], true)) {
continue;
}
$messages[] = ['role' => $row['role'], 'content' => (string) $row['content']];
}
$r = AiCallService::chat($messages, ['temperature' => 0.75, 'maxTokens' => 1024]);
$assistantContent = $r['content'] !== '' ? $r['content'] : '(小神仙被问住了,换个问法试试?)';
// 写入 assistant 消息
$msgRow = AiMessageModel::create([
$jobId = bin2hex(random_bytes(16));
$jobKey = self::chatJobCacheKey($userId, $jobId);
Cache::set($jobKey, [
'status' => 'running',
'conversationId' => $conversationId,
'role' => 'assistant',
'content' => $assistantContent,
'tokensIn' => (int) ($r['tokensIn'] ?? 0),
'tokensOut' => (int) ($r['tokensOut'] ?? 0),
'providerId' => (string) ($r['providerId'] ?? ''),
'isDegraded' => !empty($r['isDegraded']) ? 1 : 0,
'createdAt' => time(),
]);
'userId' => $userId,
], self::CHAT_JOB_TTL);
self::jobTableTryInsertRunning($userId, $jobId, $conversationId);
// 更新对话元数据
$conversation->lastMessageAt = time();
$conversation->messageCount = AiMessageModel::where('conversationId', $conversationId)->count();
$conversation->providerId = (string) ($r['providerId'] ?? '');
$conversation->save();
// 优先:与本站 InternalPushHook 同源的自建 HTTP 异步(独立 FPM 请求,完整 max_execution_time
$dispatched = OutboundPushHookService::triggerAiChatDeferredJob($userId, $conversationId, $jobId);
if (!$dispatched) {
$uidF = $userId;
$cidF = $conversationId;
$jidF = $jobId;
register_shutdown_function(static function () use ($uidF, $cidF, $jidF) {
self::runDeferredChatJob($uidF, $cidF, $jidF);
});
Log::warning('AiChat: internal async dispatch failed, fallback to shutdown handler');
}
return success([
'conversationId' => $conversationId,
'message' => [
'id' => (int) $msgRow->id,
'role' => 'assistant',
'content' => $assistantContent,
'providerId' => $r['providerId'] ?? '',
'isDegraded' => (bool) ($r['isDegraded'] ?? false),
'createdAt' => (int) $msgRow->createdAt,
],
'usageToday' => $used,
'dailyLimit' => self::DAILY_LIMIT,
'async' => true,
'jobId' => $jobId,
'conversationId' => $conversationId,
'usageToday' => 0,
'dailyLimit' => 0,
]);
} catch (\Throwable $e) {
Log::error('AiChat::chat 异常: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
@@ -198,23 +339,253 @@ class AiChat extends BaseController
'isDegraded' => true,
'createdAt' => (int) $msgRow->createdAt,
],
'usageToday' => $used,
'dailyLimit' => self::DAILY_LIMIT,
'usageToday' => 0,
'dailyLimit' => 0,
]);
} catch (\Throwable $e2) {
Log::error('AiChat::chat 降级回复写入失败: ' . $e2->getMessage());
}
}
// 统一不再返回业务 code=500前端只认 code=200避免用户看到「服务异常(500)」
return $this->chatDegradeResponse(
$conversationId,
$used,
0,
'小神仙服务暂时不可用,请稍后再试。如反复出现可在「我的」联系客服~'
);
}
}
/**
* GET /api/ai/chat/job?jobId=
* 查询异步聊天任务running 时 data.pending=true完成时 data 与旧版 chat 成功响应一致(含 message
*/
public function chatJobStatus()
{
$userId = $this->currentUserId();
if ($userId <= 0) {
return error('请先登录', 401);
}
$jobId = trim((string) Request::get('jobId', ''));
if ($jobId === '' || strlen($jobId) > 64 || !preg_match('/^[a-f0-9]+$/', $jobId)) {
return error('任务无效', 400);
}
$jobKey = self::chatJobCacheKey($userId, $jobId);
$dbRow = self::jobTableTryFetch($userId, $jobId);
$cacheRow = Cache::get($jobKey);
if (is_array($cacheRow) && (int) ($cacheRow['userId'] ?? 0) === $userId) {
$cSt = (string) ($cacheRow['status'] ?? '');
if ($cSt === 'done' || $cSt === 'error') {
$dbSt = $dbRow ? (string) ($dbRow['status'] ?? '') : '';
// 多机/DB 更新失败时:库中仍 running但本机 Cache 已写入终态 → 以 Cache 为准,避免永久 pending
if ($dbSt === '' || $dbSt === 'running') {
return $this->respondChatJobPayload($cacheRow, 'cache');
}
}
}
if ($dbRow) {
if ((int) ($dbRow['userId'] ?? 0) !== $userId) {
return error('任务不存在或已过期', 404);
}
return $this->respondChatJobPayload($dbRow, 'db');
}
$row = $cacheRow;
if ($row === null || $row === false) {
return error('任务不存在或已过期', 404);
}
if (!is_array($row)) {
return error('任务状态异常', 500);
}
if ((int) ($row['userId'] ?? 0) !== $userId) {
return error('任务不存在或已过期', 404);
}
return $this->respondChatJobPayload($row, 'cache');
}
/**
* 在 PHP 向客户端输出完毕后的 shutdown 阶段调用模型,避免占用 HTTP 连接 60s+
*
* @internal
*/
public static function runDeferredChatJob(int $userId, int $conversationId, string $jobId): void
{
@ignore_user_abort(true);
@set_time_limit(300);
$jobKey = self::chatJobCacheKey($userId, $jobId);
if (MiniprogramAuditMode::isOn()) {
$msg = '当前为提审模式,对话暂不可用';
self::jobTableTrySetError($userId, $jobId, $conversationId, $msg);
Cache::set($jobKey, [
'status' => 'error',
'userId' => $userId,
'conversationId' => $conversationId,
'error' => $msg,
], self::CHAT_JOB_TTL);
return;
}
try {
$payload = self::executeAssistantTurn($userId, $conversationId);
self::jobTableTrySetDone($userId, $jobId, $payload);
Cache::set($jobKey, [
'status' => 'done',
'userId' => $userId,
'conversationId' => $conversationId,
'data' => $payload,
], self::CHAT_JOB_TTL);
} catch (\Throwable $e) {
Log::error('AiChat::runDeferredChatJob: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
$conversation = null;
if ($conversationId > 0) {
try {
$conversation = AiConversationModel::where('userId', $userId)
->where('id', $conversationId)
->find();
} catch (\Throwable $e2) {
Log::warning('runDeferredChatJob reload conversation: ' . $e2->getMessage());
}
}
if ($conversation) {
try {
$assistantContent = '小神仙这边刚刚抖了一下(已自动记录)。你可以把问题缩短一点重发,或稍后再试~';
$msgRow = AiMessageModel::create([
'conversationId' => $conversationId,
'role' => 'assistant',
'content' => $assistantContent,
'tokensIn' => 0,
'tokensOut' => 0,
'providerId' => 'degrade',
'isDegraded' => 1,
'createdAt' => time(),
]);
$conversation->lastMessageAt = time();
$conversation->messageCount = AiMessageModel::where('conversationId', $conversationId)->count();
$conversation->providerId = 'degrade';
$conversation->save();
$donePayload = [
'conversationId' => $conversationId,
'message' => [
'id' => (int) $msgRow->id,
'role' => 'assistant',
'content' => $assistantContent,
'providerId' => 'degrade',
'isDegraded' => true,
'createdAt' => (int) $msgRow->createdAt,
],
'usageToday' => 0,
'dailyLimit' => 0,
];
self::jobTableTrySetDone($userId, $jobId, $donePayload);
Cache::set($jobKey, [
'status' => 'done',
'userId' => $userId,
'conversationId' => $conversationId,
'data' => $donePayload,
], self::CHAT_JOB_TTL);
return;
} catch (\Throwable $e3) {
Log::error('AiChat::runDeferredChatJob 降级写入失败: ' . $e3->getMessage());
}
}
self::jobTableTrySetError($userId, $jobId, $conversationId, $e->getMessage());
Cache::set($jobKey, [
'status' => 'error',
'userId' => $userId,
'conversationId' => $conversationId,
'error' => $e->getMessage(),
], self::CHAT_JOB_TTL);
}
}
/**
* 基于已落库的 user 消息调用模型并写入 assistant与原先同步 chat 核心逻辑一致)
*
* @return array{conversationId:int, message:array, usageToday:int, dailyLimit:int}
*/
private static function executeAssistantTurn(int $userId, int $conversationId): array
{
$conversation = AiConversationModel::where('userId', $userId)->where('id', $conversationId)->find();
if (!$conversation) {
throw new \RuntimeException('会话不存在');
}
$userContext = [
'mbtiType' => '', 'summary' => '', 'nickname' => '', 'testAppendix' => '',
];
try {
$userContext = AiCallService::fetchUserContext($userId);
} catch (\Throwable $e) {
Log::warning('AiChat executeAssistantTurn fetchUserContext: ' . $e->getMessage());
}
try {
$systemPrompt = AiCallService::buildSystemPrompt($userContext);
} catch (\Throwable $e) {
Log::warning('AiChat executeAssistantTurn buildSystemPrompt: ' . $e->getMessage());
$systemPrompt = "你是「神仙 AI」性格与成长助手。用完整通顺、像真人深聊的语气直接回答禁止机械套话、禁止自称任何回复格式或版本名禁止医疗诊断与投资建议。\n";
}
$history = AiMessageModel::where('conversationId', $conversationId)
->order('id', 'desc')
->limit(self::CONTEXT_TURNS * 2)
->select()
->toArray();
$history = array_reverse($history);
$messages = [['role' => 'system', 'content' => $systemPrompt]];
foreach ($history as $row) {
if (!in_array($row['role'], ['user', 'assistant'], true)) {
continue;
}
$messages[] = ['role' => $row['role'], 'content' => (string) $row['content']];
}
$r = AiCallService::chat($messages, ['temperature' => 0.7, 'maxTokens' => 1024]);
$assistantContent = $r['content'] !== '' ? $r['content'] : '(小神仙被问住了,换个问法试试?)';
$msgRow = AiMessageModel::create([
'conversationId' => $conversationId,
'role' => 'assistant',
'content' => $assistantContent,
'tokensIn' => (int) ($r['tokensIn'] ?? 0),
'tokensOut' => (int) ($r['tokensOut'] ?? 0),
'providerId' => (string) ($r['providerId'] ?? ''),
'isDegraded' => !empty($r['isDegraded']) ? 1 : 0,
'createdAt' => time(),
]);
$conversation->lastMessageAt = time();
$conversation->messageCount = AiMessageModel::where('conversationId', $conversationId)->count();
$conversation->providerId = (string) ($r['providerId'] ?? '');
$conversation->save();
return [
'conversationId' => $conversationId,
'message' => [
'id' => (int) $msgRow->id,
'role' => 'assistant',
'content' => $assistantContent,
'providerId' => $r['providerId'] ?? '',
'isDegraded' => (bool) ($r['isDegraded'] ?? false),
'createdAt' => (int) $msgRow->createdAt,
],
'usageToday' => 0,
'dailyLimit' => 0,
];
}
/**
* 聊天失败时仍返回 HTTP JSON code=200由 message.isDegraded 标记,小程序可正常展示气泡
*
@@ -237,16 +608,45 @@ class AiChat extends BaseController
'createdAt' => $now,
],
'usageToday' => max(0, $usageToday),
'dailyLimit' => self::DAILY_LIMIT,
'dailyLimit' => 0,
]);
}
/**
* GET /api/ai/articles/recommended
* 无需登录(也可以;这里先要求登录保持一致)
*
* 兼容usage=profile 时返回与 profileArticleTeaser 相同结构(我的页底部推荐),
* 避免部分线上 Nginx 对「profile-teaser」路径返回 404而 recommended 已放行。
*/
public function recommendedArticles()
{
if (MiniprogramAuditMode::isOn()) {
return success([
'list' => [],
'recoCount' => 0,
'display' => [
'enabled' => false,
'maxShow' => 0,
'sectionExpandedDefault' => false,
'profileSectionLabel' => '',
'recoJumpMiniAppId' => '',
'recoJumpMiniPath' => '',
'recoJumpMiniEnvVersion' => 'release',
'inlineRecoMinUserTurns' => 2,
'inlineRecoInterval' => 3,
'inlineRecoRoll' => 0.0,
'inlineRecoIconCount' => 0,
'inlineRecoIcons' => [],
],
]);
}
$usage = trim((string) Request::get('usage', ''));
if ($usage === 'profile') {
return $this->profileArticleTeaser();
}
$display = AiChatArticleDisplayService::getSettings();
$rows = [];
if ($display['enabled']) {
@@ -266,6 +666,15 @@ class AiChat extends BaseController
'enabled' => $display['enabled'],
'maxShow' => $display['maxShow'],
'sectionExpandedDefault' => $display['sectionExpandedDefault'],
'profileSectionLabel' => $display['profileSectionLabel'] ?? '',
'recoJumpMiniAppId' => $display['recoJumpMiniAppId'] ?? '',
'recoJumpMiniPath' => $display['recoJumpMiniPath'] ?? '',
'recoJumpMiniEnvVersion' => $display['recoJumpMiniEnvVersion'] ?? 'release',
'inlineRecoMinUserTurns' => (int) ($display['inlineRecoMinUserTurns'] ?? 2),
'inlineRecoInterval' => (int) ($display['inlineRecoInterval'] ?? 3),
'inlineRecoRoll' => (float) ($display['inlineRecoRoll'] ?? 0.5),
'inlineRecoIconCount' => (int) ($display['inlineRecoIconCount'] ?? 3),
'inlineRecoIcons' => $display['inlineRecoIcons'] ?? ['✨', '💬', '📌'],
],
]);
if (method_exists($resp, 'header')) {
@@ -283,31 +692,60 @@ class AiChat extends BaseController
*/
public function profileArticleTeaser()
{
$display = AiChatArticleDisplayService::getSettings();
if (empty($display['profileRecoEnabled'])) {
$resp = success([
'enabled' => false,
'sectionLabel' => $display['profileSectionLabel'] ?? '',
'article' => null,
if (MiniprogramAuditMode::isOn()) {
return success([
'enabled' => false,
'sectionLabel' => '',
'article' => null,
'recoJumpMiniAppId' => '',
'recoJumpMiniPath' => '',
'recoJumpMiniEnvVersion' => 'release',
]);
} else {
try {
SoulArticleService::autoSyncIfStale();
} catch (\Throwable $e) {
}
$rows = SoulArticleService::getRecommended(1);
if (empty($rows)) {
}
try {
$display = AiChatArticleDisplayService::getSettings();
if (empty($display['profileRecoEnabled'])) {
$resp = success([
'enabled' => false,
'sectionLabel' => $display['profileSectionLabel'] ?? '',
'article' => null,
'recoJumpMiniAppId' => $display['recoJumpMiniAppId'] ?? '',
'recoJumpMiniPath' => $display['recoJumpMiniPath'] ?? '',
'recoJumpMiniEnvVersion' => $display['recoJumpMiniEnvVersion'] ?? 'release',
]);
} else {
try {
SoulArticleService::syncLatest(10, 'MBTI');
SoulArticleService::autoSyncIfStale();
} catch (\Throwable $e) {
}
$rows = SoulArticleService::getRecommended(1);
if (empty($rows)) {
try {
SoulArticleService::syncLatest(10, 'MBTI');
} catch (\Throwable $e) {
}
$rows = SoulArticleService::getRecommended(1);
}
$article = !empty($rows[0]) ? $rows[0] : null;
$resp = success([
'enabled' => true,
'sectionLabel' => $display['profileSectionLabel'] ?? '',
'article' => $article,
'recoJumpMiniAppId' => $display['recoJumpMiniAppId'] ?? '',
'recoJumpMiniPath' => $display['recoJumpMiniPath'] ?? '',
'recoJumpMiniEnvVersion' => $display['recoJumpMiniEnvVersion'] ?? 'release',
]);
}
$article = !empty($rows[0]) ? $rows[0] : null;
$resp = success([
'enabled' => true,
'sectionLabel' => $display['profileSectionLabel'] ?? '',
'article' => $article,
} catch (\Throwable $e) {
Log::warning('profileArticleTeaser: ' . $e->getMessage());
$resp = success([
'enabled' => false,
'sectionLabel' => '',
'article' => null,
'recoJumpMiniAppId' => '',
'recoJumpMiniPath' => '',
'recoJumpMiniEnvVersion' => 'release',
]);
}
if (method_exists($resp, 'header')) {
@@ -326,21 +764,61 @@ class AiChat extends BaseController
*/
public function quickQuestions()
{
// 公开接口:无中间件时从 Authorization 解析微信用户(可选)
$userId = $this->currentUserId();
if ($userId <= 0) {
$u = $this->resolveUser();
if ($u && ($u['source'] ?? '') === 'wechat') {
$userId = (int) ($u['user_id'] ?? $u['userId'] ?? 0);
}
if (MiniprogramAuditMode::isOn()) {
return success([
'mbtiType' => '',
'nickname' => '',
'questions' => [],
]);
}
$ctx = AiCallService::fetchUserContext($userId);
$qs = AiCallService::filterQuickQuestions(AiCallService::quickQuestions($ctx['mbtiType']));
return success([
'mbtiType' => $ctx['mbtiType'],
'nickname' => $ctx['nickname'],
'questions' => $qs,
]);
$emptyPayload = [
'mbtiType' => '',
'nickname' => '',
'questions' => [
'我应该找什么样的工作?',
'我适合什么样的伴侣?',
'我的职业发展方向是什么?',
'我最近有点迷茫,有什么建议?',
'帮我做一个简短的自我介绍',
'我有哪些需要警惕的盲点?',
],
];
try {
// 公开接口:无中间件时从 Authorization 解析微信用户(可选)
$userId = $this->currentUserId();
if ($userId <= 0) {
$u = $this->resolveUser();
if ($u && ($u['source'] ?? '') === 'wechat') {
$userId = (int) ($u['user_id'] ?? $u['userId'] ?? 0);
}
}
$ctx = AiCallService::fetchUserContextLight($userId);
$qs = AiCallService::filterQuickQuestions(AiCallService::quickQuestions($ctx['mbtiType'] ?? ''));
return success([
'mbtiType' => $ctx['mbtiType'] ?? '',
'nickname' => $ctx['nickname'] ?? '',
'questions' => $qs,
]);
} catch (\Throwable $e) {
Log::warning('quickQuestions: ' . $e->getMessage());
}
try {
$qs = AiCallService::filterQuickQuestions(AiCallService::quickQuestions(''));
return success([
'mbtiType' => '',
'nickname' => '',
'questions' => $qs,
]);
} catch (\Throwable $e2) {
Log::warning('quickQuestions fallback: ' . $e2->getMessage());
}
return success($emptyPayload);
}
/**
@@ -351,6 +829,12 @@ class AiChat extends BaseController
{
$userId = $this->currentUserId();
if ($userId <= 0) return error('请先登录', 401);
if (MiniprogramAuditMode::isOn()) {
$page = max(1, (int) Request::get('page', 1));
$pageSize = min(50, max(1, (int) Request::get('pageSize', 20)));
return paginate_response([], 0, $page, $pageSize);
}
$page = max(1, (int) Request::get('page', 1));
$pageSize = min(50, max(1, (int) Request::get('pageSize', 20)));
@@ -373,6 +857,9 @@ class AiChat extends BaseController
{
$userId = $this->currentUserId();
if ($userId <= 0) return error('请先登录', 401);
if (MiniprogramAuditMode::isOn()) {
return error('会话不存在', 404);
}
$cid = (int) Request::param('id', 0);
$conversation = AiConversationModel::where('userId', $userId)->where('id', $cid)->find();
@@ -417,6 +904,9 @@ class AiChat extends BaseController
{
$userId = $this->currentUserId();
if ($userId <= 0) return error('请先登录', 401);
if (MiniprogramAuditMode::isOn()) {
return error('功能升级中', 503);
}
$file = Request::file('audio');
if (!$file) return error('未收到音频文件', 400);
@@ -455,49 +945,4 @@ class AiChat extends BaseController
if (!$user || ($user['source'] ?? '') !== 'wechat') return 0;
return (int) ($user['user_id'] ?? $user['userId'] ?? 0);
}
/**
* 自增今日用量,若已达上限返回 -1
*/
private function incrementAndCheckDaily(int $userId, int $limit): int
{
$dateStr = date('Y-m-d');
$now = time();
$row = Db::name('ai_usage_daily')
->where('userId', $userId)
->where('dateStr', $dateStr)
->find();
if (!$row) {
try {
Db::name('ai_usage_daily')->insert([
'userId' => $userId,
'dateStr' => $dateStr,
'messageCount' => 1,
'createdAt' => $now,
'updatedAt' => $now,
]);
return 1;
} catch (\Throwable $e) {
// 并发首条:另一请求已插入,回读后再自增
$row = Db::name('ai_usage_daily')
->where('userId', $userId)
->where('dateStr', $dateStr)
->find();
if (!$row) {
throw $e;
}
}
}
$used = (int) $row['messageCount'];
if ($used >= $limit) return -1;
Db::name('ai_usage_daily')
->where('id', $row['id'])
->update(['messageCount' => $used + 1, 'updatedAt' => $now]);
return $used + 1;
}
}

View File

@@ -3,6 +3,7 @@ namespace app\controller\api;
use app\BaseController;
use app\common\service\AiReportService;
use app\common\service\MiniprogramAuditMode;
use think\facade\Request;
/**
@@ -10,6 +11,7 @@ use think\facade\Request;
*
* POST /api/ai/report/create 创建或返回现有 pending 报告
* GET /api/ai/report/my-latest 我最近的一份报告(判断是否已买)
* GET /api/ai/my-report/latest 同上(无歧义别名)
* GET /api/ai/report/:id 获取报告正文(必须属于当前用户)
* POST /api/ai/report/:id/mark-paid-dev 调试用:跳过支付直接置已付
* POST /api/ai/report/:id/regenerate 失败后重试生成(仅管理员/作者)
@@ -18,29 +20,47 @@ class AiReport extends BaseController
{
public function create()
{
$user = $this->request->user ?? null;
if (!$user || empty($user['id'])) return error('请先登录', 401);
$userId = $this->jwtSubjectUserId();
if ($userId <= 0) return error('请先登录', 401);
if (MiniprogramAuditMode::isOn()) {
return error('功能升级中', 503);
}
$conversationId = (int) Request::param('conversationId', 0);
$mbtiType = trim((string) Request::param('mbtiType', ''));
$r = AiReportService::createOrGetPending((int) $user['id'], $conversationId, $mbtiType);
$r = AiReportService::createOrGetPending($userId, $conversationId, $mbtiType);
return success($r);
}
public function myLatest()
{
$user = $this->request->user ?? null;
if (!$user || empty($user['id'])) return success(['status' => '']);
$r = AiReportService::myLatest((int) $user['id']);
if (!$r) return success(['status' => '']);
return success($r);
try {
if (MiniprogramAuditMode::isOn()) {
return success(['status' => '']);
}
$userId = $this->jwtSubjectUserId();
if ($userId <= 0) {
return success(['status' => '']);
}
$r = AiReportService::myLatest($userId);
if (!$r) {
return success(['status' => '']);
}
return success($r);
} catch (\Throwable $e) {
return success(['status' => '']);
}
}
public function show($id)
{
$user = $this->request->user ?? null;
if (!$user || empty($user['id'])) return error('请先登录', 401);
$r = AiReportService::get((int) $id, (int) $user['id']);
$userId = $this->jwtSubjectUserId();
if ($userId <= 0) return error('请先登录', 401);
if (MiniprogramAuditMode::isOn()) {
return error('报告不存在', 404);
}
$r = AiReportService::get((int) $id, $userId);
if (!$r) return error('报告不存在', 404);
// 未付费时content 不下发,只给 summary + 解锁引导
@@ -52,9 +72,10 @@ class AiReport extends BaseController
public function markPaidDev($id)
{
$user = $this->request->user ?? null;
if (!$user || empty($user['id'])) return error('请先登录', 401);
$r = AiReportService::get((int) $id, (int) $user['id']);
$userId = $this->jwtSubjectUserId();
if ($userId <= 0) return error('请先登录', 401);
$user = $this->request->user ?? [];
$r = AiReportService::get((int) $id, $userId);
if (!$r) return error('报告不存在', 404);
// 仅超管或本地 debug 开关允许
@@ -70,15 +91,18 @@ class AiReport extends BaseController
public function regenerate($id)
{
$user = $this->request->user ?? null;
if (!$user || empty($user['id'])) return error('请先登录', 401);
$r = AiReportService::get((int) $id, (int) $user['id']);
$userId = $this->jwtSubjectUserId();
if ($userId <= 0) return error('请先登录', 401);
if (MiniprogramAuditMode::isOn()) {
return error('功能升级中', 503);
}
$r = AiReportService::get((int) $id, $userId);
if (!$r) return error('报告不存在', 404);
if (!in_array($r['status'], ['failed', 'paid'])) {
return error('当前状态不支持重试:' . $r['status']);
}
AiReportService::generate((int) $r['id']);
$r2 = AiReportService::get((int) $id, (int) $user['id']);
$r2 = AiReportService::get((int) $id, $userId);
return success($r2);
}
}

View File

@@ -5,10 +5,12 @@ use app\BaseController;
use app\common\service\EnterpriseBillingService;
use app\controller\api\Test as TestController;
use app\model\AiProvider as AiProviderModel;
use app\common\service\AiCallService;
use app\model\SystemConfig as SystemConfigModel;
use app\model\PricingConfig as PricingConfigModel;
use app\model\UserProfile as UserProfileModel;
use app\common\service\JwtService;
use app\common\service\MiniprogramAuditMode;
use think\facade\Db;
use think\facade\Log;
use think\facade\Request;
@@ -66,12 +68,8 @@ class Analyze extends BaseController
return error('请上传至少一张人脸照片', 400);
}
$provider = AiProviderModel::where('enabled', 1)
->whereRaw('(visible IS NULL OR visible = 1)')
->whereRaw('(apiKey IS NOT NULL AND LENGTH(TRIM(apiKey)) > 0)')
->order('id', 'asc')
->find();
// 与神仙 AI 对话共用同一套服务商排序sortWeight、余额沉底避免「聊天用 A、面相用 B」
$provider = AiCallService::firstOrderedProvider();
if (!$provider) {
return error('暂无可用的 AI 服务,请联系管理员配置', 503);
}
@@ -143,6 +141,10 @@ class Analyze extends BaseController
// $enterpriseId=非null 时走企业版定价;=null 时走个人版定价(含绑定企业的 admin_personal
$standardAmountFen = $this->getStandardAmountByTestType('face', $enterpriseId, $pricingEnterpriseId);
$requiresPayment = $standardAmountFen > 0 ? 1 : 0;
if (MiniprogramAuditMode::isOn()) {
$standardAmountFen = 0;
$requiresPayment = 0;
}
// 有 token 时顺带写入测试记录,避免小程序多请求一次 /api/test/submit
$testResultId = null;
@@ -281,7 +283,7 @@ class Analyze extends BaseController
$input = Request::post();
$resumeText = trim((string) ($input['resumeText'] ?? $input['resume'] ?? ''));
$fileUrl = trim((string) ($input['fileUrl'] ?? ''));
$fileUrl = trim((string) ($input['fileUrl'] ?? $input['resumeUrl'] ?? ''));
// 当前企业:优先用请求体传入,其次用用户绑定企业(需在简历自动读取之前确定 enterpriseId
$enterpriseIdFromRequest = isset($input['enterpriseId']) && (int) $input['enterpriseId'] > 0;
@@ -313,10 +315,13 @@ class Analyze extends BaseController
$pricingEnterpriseId = $enterpriseId > 0 ? $enterpriseId : null;
$standardAmountFen = $this->getStandardAmountByTestType('resume', $pricingEnterpriseId, $pricingEnterpriseId);
$requiresPayment = $standardAmountFen > 0 ? 1 : 0;
if (MiniprogramAuditMode::isOn()) {
$standardAmountFen = 0;
$requiresPayment = 0;
}
$latest = TestController::getLatestResultsForResume($userId, $enterpriseId > 0 ? $enterpriseId : null);
// face/ai 暂不计入,有 MBTI / DISC / PDP 任意一项即可视为有测试数据
$hasTests = $latest['mbti'] !== null || $latest['disc'] !== null || $latest['pdp'] !== null;
$hasTests = $latest['mbti'] !== null || $latest['sbti'] !== null || $latest['disc'] !== null || $latest['pdp'] !== null || $latest['face'] !== null;
$hasAnySource = $hasTests || $resumeText !== '' || $fileUrl !== '';
if (!$hasAnySource) {
return error('暂无可用于分析的信息,请先上传简历或完成至少一项测试', 422);
@@ -326,11 +331,7 @@ class Analyze extends BaseController
// 仅统计 MBTI / DISC / PDP 是否齐全face/ai 暂不参与)
$hasAllFour = $latest['mbti'] !== null && $latest['disc'] !== null && $latest['pdp'] !== null;
$systemPrompt = $this->getReportSummaryPrompt($hasAllFour, $hasTests);
$provider = AiProviderModel::where('enabled', 1)
->whereRaw('(visible IS NULL OR visible = 1)')
->whereRaw('(apiKey IS NOT NULL AND LENGTH(TRIM(apiKey)) > 0)')
->order('id', 'asc')
->find();
$provider = AiCallService::firstOrderedProvider();
if (!$provider) {
return error('暂无可用的 AI 服务,请联系管理员配置', 503);
}
@@ -554,9 +555,9 @@ PROMPT;
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$sections[] = "## 简历文件\n\n候选人已上传简历文件:{$filename}\n文件类型:{$ext}\n文件地址:{$fileUrl}\n(内容无法自动解析,请结合测试数据与面相进行分析)";
}
// face/ai 临时去除,仅拼接 MBTI / PDP / DISC
$order = [
'mbti' => 'MBTI',
'sbti' => 'SBTI',
'pdp' => 'PDP',
'disc' => 'DISC',
];
@@ -575,6 +576,20 @@ PROMPT;
}
$sections[] = "## {$title}\n\n" . $text;
}
$faceRow = $latest['face'] ?? null;
if ($faceRow && !empty($faceRow['resultData'])) {
$raw = $faceRow['resultData'];
$data = is_string($raw) ? json_decode($raw, true) : $raw;
if (is_array($data)) {
$data = $this->sanitizeResumeTestData('face', $data);
$text = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
} else {
$text = (string) $raw;
}
$sections[] = "## 面相/拍照分析\n\n" . $text;
}
return implode("\n\n", $sections);
}
@@ -586,93 +601,7 @@ PROMPT;
*/
private function extractResumeFileText(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 $this->extractDocxText($raw);
}
if ($ext === 'pdf') {
return $this->extractPdfText($raw);
}
return '';
}
/** 从 docx 二进制内容提取文本docx 为 ZIP内含 word/document.xml */
private 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 '';
}
// 提取所有 <w:t>...</w:t> 文本节点
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));
}
/** 从 PDF 二进制内容提取文本 */
private 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));
return \app\common\service\ResumeFileExtractService::extractFromUrl($fileUrl);
}
/**
@@ -736,6 +665,18 @@ PROMPT;
}
return $keep;
case 'sbti':
$keep = [];
if (!empty($data['sbtiType'])) $keep['sbtiType'] = $data['sbtiType'];
if (!empty($data['sbtiCn'])) $keep['sbtiCn'] = $data['sbtiCn'];
if (!empty($data['finalType']) && is_array($data['finalType'])) {
$keep['finalType'] = $data['finalType'];
}
if (!empty($data['levels']) && is_array($data['levels'])) {
$keep['levels'] = $data['levels'];
}
return $keep;
default:
return $data;
}

View File

@@ -4,8 +4,11 @@ namespace app\controller\api;
use app\BaseController;
use app\model\Enterprise as EnterpriseModel;
use app\model\PricingConfig as PricingConfigModel;
use app\model\AiProvider as AiProviderModel;
use app\common\service\AiCallService;
use app\common\service\AiChatArticleDisplayService;
use app\common\service\JwtService;
use app\common\service\MiniprogramAuditMode;
use app\common\service\MpTabbarService;
use think\facade\Db;
/**
@@ -59,6 +62,7 @@ class AppConfig extends BaseController
// 系统配置:审核模式、默认企业(无带参入口时小程序回落)
$maintenanceMode = false;
$miniprogramAuditMode = false;
$defaultEnterpriseId = null;
$systemRow = Db::name('system_config')->where('key', 'system')->find();
if ($systemRow && !empty($systemRow['value'])) {
@@ -67,6 +71,9 @@ class AppConfig extends BaseController
if (!empty($sysValEarly['maintenanceMode'])) {
$maintenanceMode = true;
}
if (!empty($sysValEarly['miniprogramAuditMode'])) {
$miniprogramAuditMode = true;
}
if (!empty($sysValEarly['defaultEnterpriseId'])) {
$de = (int) $sysValEarly['defaultEnterpriseId'];
if ($de > 0) {
@@ -88,12 +95,8 @@ class AppConfig extends BaseController
$pricing = is_array($rawConfig) ? $rawConfig : (array) $rawConfig;
}
// 超管 AI 配置:第一个 enabled=1、visible=1显示且 apiKey 非空;隐藏的服务商不参与选用
$firstProvider = AiProviderModel::where('enabled', 1)
->whereRaw('(visible IS NULL OR visible = 1)')
->whereRaw('(apiKey IS NOT NULL AND LENGTH(TRIM(apiKey)) > 0)')
->order('id', 'asc')
->find();
// 与 /api/ai/chat、/api/analyze 相同的第一优先服务商sortWeight、余额沉底
$firstProvider = AiCallService::firstOrderedProvider();
$aiProviderId = null;
$aiProviderName = null;
@@ -110,6 +113,11 @@ class AppConfig extends BaseController
$reportRequiresPayment[$k] = 1;
}
}
if ($miniprogramAuditMode) {
foreach (array_keys($reportRequiresPayment) as $k) {
$reportRequiresPayment[$k] = 0;
}
}
// 站点信息(网站名称、小程序名称):供小程序导航栏等展示
$siteName = '';
@@ -137,7 +145,7 @@ class AppConfig extends BaseController
// 小程序文案配置(分析中提示、按钮、报告标题等)
$textConfig = [
'analyzingTitle' => '正在分析中',
'startButtonText' => '开始面相测试',
'startButtonText' => '30秒测出你的性格',
'startButtonEnterprise' => '开始面部测试',
'reportTitle' => '分析报告',
'aiAnalysisText' => '智能分析'
@@ -182,23 +190,87 @@ class AppConfig extends BaseController
}
}
// 与已放行的本接口一并下发,避免 Nginx 仅允许 /api/config/runtime 时 /api/mp/tabbar、/api/config/* 子路径 404
$tabBar = null;
try {
$tabBar = MpTabbarService::getPayload();
} catch (\Throwable $e) {
$tabBar = ['items' => [], 'version' => 0];
}
$aiQuickQuestions = $miniprogramAuditMode
? ['mbtiType' => '', 'nickname' => '', 'questions' => []]
: $this->embeddedAiQuickQuestionsForRuntime($user);
// 神仙 AI与 AiChat 一致,不设每日条数上限;内嵌推荐抽检参数与 articles/recommended.display 同源
$aiDisp = AiChatArticleDisplayService::getSettings();
$aiChatRuntime = [
'dailyMessageLimit' => 0,
'unlimited' => true,
'inlineRecoMinUserTurns'=> (int) ($aiDisp['inlineRecoMinUserTurns'] ?? 2),
'inlineRecoInterval' => (int) ($aiDisp['inlineRecoInterval'] ?? 3),
'inlineRecoRoll' => (float) ($aiDisp['inlineRecoRoll'] ?? 0.5),
'inlineRecoIconCount' => (int) ($aiDisp['inlineRecoIconCount'] ?? 3),
'inlineRecoIcons' => $aiDisp['inlineRecoIcons'] ?? ['✨', '💬', '📌'],
];
return success([
'pricingType' => $pricingType,
'pricing' => $pricing,
'aiProviderId' => $aiProviderId,
'aiProviderName' => $aiProviderName,
'aiChat' => $aiChatRuntime,
'reportRequiresPayment' => $reportRequiresPayment,
'siteName' => $siteName,
'miniprogramName' => $miniprogramName,
'siteTitle' => $siteTitle,
'textConfig' => $textConfig,
'maintenanceMode' => $maintenanceMode,
/** 提审模式:隐藏神仙 AI 对话等(与 maintenanceMode 面相审核可独立开关) */
'miniprogramAuditMode' => $miniprogramAuditMode,
'reviewMode' => $reviewMode,
'defaultEnterpriseId' => $defaultEnterpriseId,
'enterprisePermissions' => $enterprisePermissions,
'tabBar' => $tabBar,
'aiQuickQuestions' => $aiQuickQuestions,
// 与 /api/config/deep-pricing 同源;提审模式下不下发类目(避免虚拟商品购买页)
'deepPricingPersonal' => ['categories' => $miniprogramAuditMode ? [] : $this->normalizeDeepPricingCategories('personal')],
'deepPricingEnterprise' => ['categories' => $miniprogramAuditMode ? [] : $this->normalizeDeepPricingCategories('enterprise')],
]);
}
/**
* runtime 内嵌快捷问句(与 AiChat::quickQuestions 同源),免再请求易被网关拦截的路径
*
* @param array<string, mixed>|null $user 与 runtime() 中解析的 JWT 用户一致
* @return array{mbtiType: string, nickname: string, questions: string[]}
*/
private function embeddedAiQuickQuestionsForRuntime(?array $user): array
{
$userId = 0;
if ($user && ($user['source'] ?? '') === 'wechat') {
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
}
try {
$ctx = AiCallService::fetchUserContextLight($userId);
$qs = AiCallService::filterQuickQuestions(AiCallService::quickQuestions($ctx['mbtiType']));
return [
'mbtiType' => $ctx['mbtiType'],
'nickname' => $ctx['nickname'],
'questions' => $qs,
];
} catch (\Throwable $e) {
$qs = AiCallService::filterQuickQuestions(AiCallService::quickQuestions(''));
return [
'mbtiType' => '',
'nickname' => '',
'questions' => $qs,
];
}
}
/**
* GET api/config/deep-pricing?scope=personal|enterprise
* 深度服务价格(开通会员页):个人版与企业版分别返回可配置的类目列表,支持后台新增类目
@@ -206,6 +278,23 @@ class AppConfig extends BaseController
public function deepPricing()
{
$scope = (string) ($this->request->param('scope', 'personal') ?? 'personal');
if (MiniprogramAuditMode::isOn()) {
return success(['scope' => $scope, 'categories' => []]);
}
$categories = $this->normalizeDeepPricingCategories($scope);
return success(['scope' => $scope, 'categories' => $categories]);
}
/**
* 读取并规范化 deep_personal / deep_enterprise 类目(与 deepPricing、runtime 内嵌共用)
*
* @param string $scope personal|enterprise
* @return array<int, array<string, mixed>>
*/
private function normalizeDeepPricingCategories(string $scope): array
{
$scope = $scope === 'enterprise' ? 'enterprise' : 'personal';
$type = $scope === 'enterprise' ? 'deep_enterprise' : 'deep_personal';
$config = PricingConfigModel::where('type', $type)->whereNull('enterpriseId')->find();
@@ -214,40 +303,61 @@ class AppConfig extends BaseController
$raw = $config->config;
$data = is_array($raw) ? $raw : (array) $raw;
$categories = isset($data['categories']) && is_array($data['categories']) ? $data['categories'] : [];
// 兼容旧数据:补全可能缺失的字段,确保前端始终能读到完整结构
foreach ($categories as &$cat) {
// features旧数据只存 featuresText动态拆成数组
if (!isset($cat['features']) || !is_array($cat['features'])) {
if (!empty($cat['featuresText']) && is_string($cat['featuresText'])) {
$lines = preg_split('/\r?\n/', $cat['featuresText']);
$cat['features'] = array_values(array_filter(array_map('trim', $lines), static function ($s) {
return $s !== '';
}));
} else {
$cat['features'] = [];
}
}
// serviceWechat客服微信展示给用户的微信号
if (!isset($cat['serviceWechat'])) {
$cat['serviceWechat'] = '';
}
// consultWechat存客宝KEY旧类目可能没有该字段补空字符串
if (!isset($cat['consultWechat'])) {
$cat['consultWechat'] = '';
}
// promptText同样补全
if (!isset($cat['promptText'])) {
$cat['promptText'] = '';
}
// successMessage成功提示词补全
if (!isset($cat['successMessage'])) {
$cat['successMessage'] = '';
}
}
unset($cat);
}
return success(['scope' => $scope, 'categories' => $categories]);
$categories = array_values(array_filter($categories, static function ($row) {
return is_array($row);
}));
foreach ($categories as $idx => &$cat) {
// features旧数据只存 featuresText动态拆成数组
if (!isset($cat['features']) || !is_array($cat['features'])) {
if (!empty($cat['featuresText']) && is_string($cat['featuresText'])) {
$lines = preg_split('/\r?\n/', $cat['featuresText']);
$cat['features'] = array_values(array_filter(array_map('trim', $lines), static function ($s) {
return $s !== '';
}));
} else {
$cat['features'] = [];
}
}
if (!isset($cat['serviceWechat'])) {
$cat['serviceWechat'] = '';
}
if (!isset($cat['consultWechat'])) {
$cat['consultWechat'] = '';
}
if (!isset($cat['promptText'])) {
$cat['promptText'] = '';
}
if (!isset($cat['successMessage'])) {
$cat['successMessage'] = '';
}
$id = trim((string) ($cat['id'] ?? ''));
$pk = trim((string) ($cat['productKey'] ?? ''));
if ($id === '' && $pk !== '') {
$cat['id'] = $pk;
} elseif ($id === '') {
$cat['id'] = ($scope === 'enterprise' ? 'enterprise_cat_' : 'personal_cat_') . $idx;
}
if ($pk === '') {
$cat['productKey'] = (string) $cat['id'];
}
$action = (string) ($cat['actionType'] ?? '');
if ($scope === 'enterprise') {
if ($action !== 'buy' && trim((string) ($cat['buttonText'] ?? '')) === '') {
$cat['buttonText'] = '申请咨询并降低30%成本';
}
} else {
if ($action === 'buy' && trim((string) ($cat['purchaseButtonText'] ?? '')) === '') {
$cat['purchaseButtonText'] = '了解自己并付款';
}
}
}
unset($cat);
return $categories;
}
}

View File

@@ -20,8 +20,8 @@ class Distribution extends BaseController
const BINDING_DAYS = 30;
const BINDING_TTL = 30 * 86400;
/** 提现金额下限1 */
const MIN_WITHDRAW_FEN = 100;
/** 提现金额下限10.01 元);具体规则以超管 minWithdrawFen 为准,不低于本值 */
const MIN_WITHDRAW_FEN = 1;
/** 提现金额上限200元 */
const MAX_WITHDRAW_FEN = 20000;
/** 待收款过期时间24小时 */
@@ -297,7 +297,8 @@ class Distribution extends BaseController
}
} catch (\Exception $e) {}
$cfg = is_array($globalDistConfig) ? $globalDistConfig : [];
$minWithdrawFen = (int)($cfg['minWithdrawFen'] ?? 100);
$minWithdrawFen = (int)($cfg['minWithdrawFen'] ?? 1);
$minWithdrawFen = max(1, min(self::MAX_WITHDRAW_FEN, $minWithdrawFen));
$maxWithdrawFen = (int)($cfg['maxWithdrawFen'] ?? 0);
$withdrawFee = (float)($cfg['withdrawFee'] ?? 0);
$requireAudit = (isset($cfg['requireAudit']) ? $cfg['requireAudit'] : true) !== false;
@@ -495,7 +496,7 @@ class Distribution extends BaseController
$feeFen = (int) round($amountFen * $feePct / 100);
$actualFen = $amountFen - $feeFen;
if ($actualFen < $minFen) {
return error('实际到账金额不得低于最低提现金额 ' . number_format($minFen / 100, 2, '.', '') . ' 元', 400);
return error('实际到账金额不得低于最低提现金额 ¥' . number_format($minFen / 100, 2, '.', ''), 400);
}
$requireAudit = (isset($cfg['requireAudit']) ? $cfg['requireAudit'] : true) !== false;
@@ -1133,7 +1134,8 @@ class Distribution extends BaseController
private static function getWithdrawLimits(string $scope, ?int $enterpriseId): array
{
$cfg = self::getDistributionConfig($scope, $enterpriseId);
$minFen = max(self::MIN_WITHDRAW_FEN, min(self::MAX_WITHDRAW_FEN, (int)($cfg['minWithdrawFen'] ?? self::MIN_WITHDRAW_FEN)));
$rawMin = (int)($cfg['minWithdrawFen'] ?? self::MIN_WITHDRAW_FEN);
$minFen = max(self::MIN_WITHDRAW_FEN, min(self::MAX_WITHDRAW_FEN, $rawMin));
$maxFen = (int)($cfg['maxWithdrawFen'] ?? 0);
if ($maxFen > 0) {
$maxFen = min(self::MAX_WITHDRAW_FEN, $maxFen);

View File

@@ -48,6 +48,15 @@ class InternalPushHook extends BaseController
case 'test.result_completed':
OutboundPushHookService::onTestResultCompleted((int) ($payload['testResultId'] ?? 0));
break;
case 'ai.chat_turn':
$uid = (int) ($payload['userId'] ?? 0);
$cid = (int) ($payload['conversationId'] ?? 0);
$jid = trim((string) ($payload['jobId'] ?? ''));
if ($uid <= 0 || $cid <= 0 || $jid === '' || strlen($jid) > 64 || !preg_match('/^[a-f0-9]+$/', $jid)) {
return error('invalid ai chat job', 400);
}
\app\controller\api\AiChat::runDeferredChatJob($uid, $cid, $jid);
break;
default:
return error('unsupported job', 400);
}

View File

@@ -2,7 +2,7 @@
namespace app\controller\api;
use app\BaseController;
use think\facade\Db;
use app\common\service\MpTabbarService;
/**
* 小程序公开运行配置
@@ -14,45 +14,6 @@ class MpConfig extends BaseController
{
public function tabbar()
{
$items = Db::name('mp_tabbar_items')
->where('visible', 1)
->order('sortOrder', 'asc')
->order('id', 'asc')
->select()
->toArray();
$list = [];
foreach ($items as $row) {
$iconKey = $row['iconKey'] ?? 'home';
$iconUrl = $row['iconUrl'] ?? null;
if ($iconKey === 'ai') {
// 神仙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,
];
}
// 兜底:后台还没配/表空,返回硬编码 4 项,保证小程序永远可用
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],
];
}
return success([
'items' => $list,
'version' => (int) Db::name('mp_tabbar_items')->max('updatedAt'),
]);
return success(MpTabbarService::getPayload());
}
}

View File

@@ -5,6 +5,7 @@ use app\BaseController;
use app\model\PricingConfig as PricingConfigModel;
use app\model\UserProfile as UserProfileModel;
use app\common\service\JwtService;
use app\common\service\MiniprogramAuditMode;
use app\common\service\FeishuLeadWebhookService;
use app\common\service\AiReportService;
use think\facade\Request;
@@ -60,6 +61,10 @@ class Payment extends BaseController
return error('用户信息异常', 400);
}
if (MiniprogramAuditMode::isOn()) {
return error('小程序版本审核期间暂不可发起虚拟商品支付,请审核结束后再试', 400);
}
// 企业ID 与金额优先从 test_results 读取历史记录进入enterpriseId 为空则按个人价;金额用 paidAmount
$enterpriseId = null;
$fixedAmountFen = null;

View File

@@ -8,6 +8,7 @@ use app\model\Enterprise as EnterpriseModel;
use app\model\PricingConfig as PricingConfigModel;
use app\model\Question as QuestionModel;
use app\model\UserProfile as UserProfileModel;
use app\common\service\MiniprogramAuditMode;
use think\facade\Db;
use think\facade\Request;
@@ -107,6 +108,9 @@ class Test extends BaseController
$orderId = isset($row['orderId']) ? (int) $row['orderId'] : null;
$paidAmountRow = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0;
$needPayUnlock = $requiresPayment && !$isPaid && $paidAmountRow > 0;
if (MiniprogramAuditMode::isOn()) {
$needPayUnlock = false;
}
$raw = $row['resultDataLite'] ?? ($row['resultData'] ?? null);
$data = null;
@@ -125,7 +129,7 @@ class Test extends BaseController
}
$paymentFields = [
'requiresPayment' => $requiresPayment,
'requiresPayment' => MiniprogramAuditMode::isOn() ? 0 : $requiresPayment,
'isPaid' => $isPaid,
'orderId' => $orderId,
'enterpriseName' => $enterpriseName,
@@ -423,6 +427,9 @@ class Test extends BaseController
$isPaid = (int) ($row['isPaid'] ?? 0);
$paidAmountRow = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0;
$needPayUnlock = $requiresPayment && !$isPaid && $paidAmountRow > 0;
if (MiniprogramAuditMode::isOn()) {
$needPayUnlock = false;
}
if ($data !== []) {
if (in_array($testType, ['face', 'ai'], true)) {
if ($needPayUnlock || $profileIncomplete) {
@@ -531,7 +538,7 @@ class Test extends BaseController
'resultText' => $resultText,
'testTime' => $createdAt ? date('Y-m-d', (int) $createdAt) : '',
'isPaid' => (int) ($row['isPaid'] ?? 0),
'requiresPayment' => (int) ($row['requiresPayment'] ?? 0),
'requiresPayment' => MiniprogramAuditMode::isOn() ? 0 : (int) ($row['requiresPayment'] ?? 0),
];
if ($gallupPreview !== '') {
$out['gallupPreview'] = $gallupPreview;
@@ -662,6 +669,9 @@ class Test extends BaseController
$paidAmount = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0;
$testType = $row['testType'] ?? '';
$needPaymentToUnlock = $requiresPayment && !$isPaid && $paidAmount > 0;
if (MiniprogramAuditMode::isOn()) {
$needPaymentToUnlock = false;
}
$subjectUserId = (int) ($row['userId'] ?? 0);
$profileIncomplete = $subjectUserId > 0 ? !self::isWechatProfileComplete($subjectUserId) : false;
$applyProfileGate = $profileIncomplete && !$forShareViewer;
@@ -678,16 +688,20 @@ class Test extends BaseController
}
}
$auditMp = MiniprogramAuditMode::isOn();
$respRequires = $auditMp ? 0 : $requiresPayment;
$respPaidAmount = $auditMp ? 0 : $paidAmount;
return [
'id' => $row['id'],
'testType' => $testType,
'createdAt' => $row['createdAt'],
'data' => $data,
'requiresPayment' => $requiresPayment,
'requiresPayment' => $respRequires,
'isPaid' => $isPaid,
'paidAmount' => $paidAmount,
'amountYuan' => $paidAmount > 0 ? round($paidAmount / 100, 2) : 0,
'needPaymentToUnlock' => $needPaymentToUnlock,
'paidAmount' => $respPaidAmount,
'amountYuan' => $respPaidAmount > 0 ? round($respPaidAmount / 100, 2) : 0,
'needPaymentToUnlock' => $auditMp ? false : $needPaymentToUnlock,
'profileIncomplete' => $forShareViewer ? false : $profileIncomplete,
'orderId' => isset($row['orderId']) ? (int) $row['orderId'] : null,
'paidAt' => isset($row['paidAt']) ? (int) $row['paidAt'] : null,
@@ -780,6 +794,10 @@ class Test extends BaseController
}
$requiresPayment = $this->getRequiresPaymentByTestType($testType, $enterpriseId, $pricingEnterpriseId);
$standardAmountFen = $requiresPayment ? $this->getStandardAmountFenByTestType($testType, $enterpriseId, $pricingEnterpriseId) : 0;
if (MiniprogramAuditMode::isOn()) {
$requiresPayment = 0;
$standardAmountFen = 0;
}
$id = Db::name('test_results')->insertGetId([
'userId' => $userId,
'enterpriseId' => $writeEnterpriseId,
@@ -1088,25 +1106,30 @@ class Test extends BaseController
}
/**
* 获取当前用户最近的 MBTI / DISC / PDP 测试记录(暂不使用人脸/AI 结果),供简历综合分析使用
* 获取当前用户最近的 MBTI / DISC / PDP / 面相(face|ai) 测试记录,供简历综合分析使用
* @param int $userId 微信用户 ID
* @param int|null $enterpriseId 当前企业ID仅返回该企业下的记录为空则不按企业过滤
* @return array ['face' => row|null, 'mbti' => row|null, 'disc' => row|null, 'pdp' => row|null]row 含 id, testType, resultData, createdAt
* @return array ['face' => row|null, 'mbti' => row|null, 'sbti' => row|null, 'disc' => row|null, 'pdp' => row|null]row 含 id, testType, resultData, createdAt
*/
public static function getLatestResultsForResume(int $userId, ?int $enterpriseId = null): array
{
if ($userId <= 0) {
return ['face' => null, 'mbti' => null, 'disc' => null, 'pdp' => null];
return ['face' => null, 'mbti' => null, 'sbti' => null, 'disc' => null, 'pdp' => null];
}
$out = ['face' => null, 'mbti' => null, 'disc' => null, 'pdp' => null];
$out = ['face' => null, 'mbti' => null, 'sbti' => null, 'disc' => null, 'pdp' => null];
$base = Db::name('test_results')->where('userId', $userId);
if ($enterpriseId !== null && $enterpriseId > 0) {
$base = $base->where('enterpriseId', (int) $enterpriseId);
}
// face/ai 暂不参与简历分析,保持为 null避免写入上下文
// 面相 / 神仙 AI 拍照分析:取 face 或 ai 最新一条,供 buildResumeContext 拼接
$out['face'] = (clone $base)
->whereIn('testType', ['face', 'ai'])
->field('id, testType, resultData, createdAt')
->order('createdAt', 'desc')
->find();
// mbti
$out['mbti'] = (clone $base)
@@ -1115,6 +1138,13 @@ class Test extends BaseController
->order('createdAt', 'desc')
->find();
// sbti
$out['sbti'] = (clone $base)
->where('testType', 'sbti')
->field('id, testType, resultData, createdAt')
->order('createdAt', 'desc')
->find();
// pdp
$out['pdp'] = (clone $base)
->where('testType', 'pdp')

View File

@@ -315,7 +315,7 @@ class Distribution extends BaseController
'enabled' => true,
'promoCenterTitle' => '推广中心',
'bindingDays' => 30,
'minWithdrawFen' => 100,
'minWithdrawFen' => 1,
'maxWithdrawFen' => 0,
'requireAudit' => true,
'withdrawFee' => 0,
@@ -327,7 +327,7 @@ class Distribution extends BaseController
} else {
$settings = $default;
}
$settings['minWithdraw'] = round((float)($settings['minWithdrawFen'] ?? 100) / 100, 2);
$settings['minWithdraw'] = round((float)($settings['minWithdrawFen'] ?? 1) / 100, 2);
$settings['maxWithdraw'] = ($max = (int)($settings['maxWithdrawFen'] ?? 0)) > 0 ? round($max / 100, 2) : 0;
$settings['testSettings'] = self::appendTestSettingsAmount(
array_merge(self::defaultTestSettings(), $settings['testSettings'] ?? [])
@@ -351,11 +351,11 @@ class Distribution extends BaseController
$minWithdrawFen = isset($settings['minWithdraw'])
? (int) round((float)$settings['minWithdraw'] * 100)
: (int)($settings['minWithdrawFen'] ?? 100);
: (int)($settings['minWithdrawFen'] ?? 1);
$maxWithdrawFen = isset($settings['maxWithdraw'])
? (int) round((float)$settings['maxWithdraw'] * 100)
: (int)($settings['maxWithdrawFen'] ?? 0);
$minWithdrawFen = max(100, min(20000, $minWithdrawFen));
$minWithdrawFen = max(1, min(20000, $minWithdrawFen));
$maxWithdrawFen = $maxWithdrawFen > 0 ? min(20000, max(100, $maxWithdrawFen)) : 0;
$promoTitle = trim((string)($settings['promoCenterTitle'] ?? ''));

View File

@@ -4,6 +4,7 @@ namespace app\controller\superadmin;
use app\BaseController;
use app\common\service\FeishuLeadWebhookService;
use app\common\service\OutboundPushHookService;
use app\common\service\WechatAuditSyncService;
use app\model\SystemConfig as SystemConfigModel;
use app\model\User as UserModel;
use app\model\Enterprise as EnterpriseModel;
@@ -57,9 +58,17 @@ class Settings extends BaseController
'siteDescription' => '专业的AI性格测试平台',
'miniprogramName' => '神仙团队AI性格测试',
'maintenanceMode' => false,
/** 小程序提审:隐藏神仙 AI 对话/深度报告等深度合成入口,与 maintenanceMode面相审核可分开 */
'miniprogramAuditMode' => false,
'maxTestsPerDay' => 100,
'trialTestCount' => 10,
'defaultEnterpriseId' => null,
/** 为 true默认打开超管设置会按间隔拉微信审核状态并自动切换 miniprogramAuditMode */
'wechatAuditAutoMiniprogramMode' => true,
'wechatLastAuditErrcode' => null,
'wechatLastAuditStatus' => null,
'wechatLastAuditReason' => '',
'wechatLastAuditSyncedAt' => null,
];
$systemOut = $systemDefault;
// 仅当 system 行「未包含」maintenanceMode 键时,才用旧表 review_mode 回退(否则关闭审核后会被 review_mode.enabled 再次顶成「已开启」)
@@ -84,6 +93,27 @@ class Settings extends BaseController
}
}
$systemOut['maintenanceMode'] = $maint;
if (!array_key_exists('miniprogramAuditMode', $systemOut)) {
$systemOut['miniprogramAuditMode'] = false;
}
$systemOut['miniprogramAuditMode'] = (bool) ($systemOut['miniprogramAuditMode'] ?? false);
if (!array_key_exists('wechatAuditAutoMiniprogramMode', $systemOut)) {
$systemOut['wechatAuditAutoMiniprogramMode'] = true;
} else {
$systemOut['wechatAuditAutoMiniprogramMode'] = $systemOut['wechatAuditAutoMiniprogramMode'] !== false;
}
// 跟随微信审核:非手动请求时按节流调用,避免每次打开页面都打微信接口
try {
if (WechatAuditSyncService::shouldAutoSync($systemOut)) {
$sync = WechatAuditSyncService::run(false);
if (!empty($sync['systemBroadcast']) && is_array($sync['systemBroadcast'])) {
$systemOut = array_merge($systemOut, $sync['systemBroadcast']);
}
}
} catch (\Throwable $e) {
\think\facade\Log::error('wechat audit sync on settings index: ' . $e->getMessage());
}
// 与前端 el-option 的 number value 对齐,避免类型不一致导致下拉不反显
if (array_key_exists('defaultEnterpriseId', $systemOut)) {
@@ -111,7 +141,7 @@ class Settings extends BaseController
'reportRequiresPayment' => $reportRequiresPaymentConfig && !empty($reportRequiresPaymentConfig->value) ? $reportRequiresPaymentConfig->value : ['face' => 1, 'mbti' => 0, 'disc' => 0, 'pdp' => 0],
'textConfig' => $textConfigModel && !empty($textConfigModel->value) ? $textConfigModel->value : [
'analyzingTitle' => '正在分析中',
'startButtonText' => '开始面相测试',
'startButtonText' => '30秒测出你的性格',
'startButtonEnterprise' => '开始面部测试',
'reportTitle' => '分析报告',
'aiAnalysisText' => '智能分析'
@@ -141,7 +171,7 @@ class Settings extends BaseController
$input = [];
}
$allowedKeys = ['siteName', 'siteDescription', 'miniprogramName', 'maintenanceMode', 'maxTestsPerDay', 'trialTestCount', 'defaultEnterpriseId'];
$allowedKeys = ['siteName', 'siteDescription', 'miniprogramName', 'maintenanceMode', 'miniprogramAuditMode', 'wechatAuditAutoMiniprogramMode', 'maxTestsPerDay', 'trialTestCount', 'defaultEnterpriseId'];
$data = array_intersect_key($input, array_flip($allowedKeys));
// 兼容 fallbackJSON 解析失败时尝试 Request::only
if (empty($data)) {
@@ -150,6 +180,12 @@ class Settings extends BaseController
if (array_key_exists('maintenanceMode', $data)) {
$data['maintenanceMode'] = filter_var($data['maintenanceMode'], FILTER_VALIDATE_BOOLEAN);
}
if (array_key_exists('miniprogramAuditMode', $data)) {
$data['miniprogramAuditMode'] = filter_var($data['miniprogramAuditMode'], FILTER_VALIDATE_BOOLEAN);
}
if (array_key_exists('wechatAuditAutoMiniprogramMode', $data)) {
$data['wechatAuditAutoMiniprogramMode'] = filter_var($data['wechatAuditAutoMiniprogramMode'], FILTER_VALIDATE_BOOLEAN);
}
// 默认企业0 或空视为不启用
if (array_key_exists('defaultEnterpriseId', $data)) {
$de = $data['defaultEnterpriseId'];
@@ -210,7 +246,7 @@ class Settings extends BaseController
if (is_array($textConfig)) {
$tcKeys = ['analyzingTitle', 'startButtonText', 'startButtonEnterprise', 'reportTitle', 'aiAnalysisText'];
$tcData = array_intersect_key($textConfig, array_flip($tcKeys));
$tcDefaults = ['analyzingTitle' => '正在分析中', 'startButtonText' => '开始面相测试', 'startButtonEnterprise' => '开始面部测试', 'reportTitle' => '分析报告', 'aiAnalysisText' => '智能分析'];
$tcDefaults = ['analyzingTitle' => '正在分析中', 'startButtonText' => '30秒测出你的性格', 'startButtonEnterprise' => '开始面部测试', 'reportTitle' => '分析报告', 'aiAnalysisText' => '智能分析'];
$tcConfig = SystemConfigModel::where('key', 'text_config')->where('enterprise_id', 0)->find();
if (!$tcConfig) {
$tcConfig = new SystemConfigModel();
@@ -228,6 +264,30 @@ class Settings extends BaseController
}
}
/**
* POST /api/v1/superadmin/settings/wechat-audit-sync
* 立即请求微信 get_latest_auditstatus 并写入 miniprogramAuditMode不受节流限制
*/
public function syncWechatAuditStatus()
{
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
try {
$sync = WechatAuditSyncService::run(true);
return success([
'applied' => !empty($sync['applied']),
'miniprogramAuditMode' => (bool) ($sync['miniprogramAuditMode'] ?? false),
'wechat' => $sync['wechat'] ?? [],
'systemBroadcast' => $sync['systemBroadcast'] ?? [],
], '已同步微信审核状态');
} catch (\Throwable $e) {
return error('同步失败:' . $e->getMessage(), 500);
}
}
/**
* 更新「报告需付费」配置:哪些测试类型需付费后才显示完整报告
* PUT body: { "face": 1, "mbti": 0, "disc": 0, "pdp": 0 }1=需付费解锁完整0=免费完整)

View File

@@ -11,7 +11,12 @@ class AiConversation extends Model
{
use SoftDelete;
protected $strict = false;
/**
* 须为 true库表字段为驼峰 userId / lastMessageAt 等。
* strict=false 时 ORM 会把属性转成 snake_case 写入,与真实列名不一致 → userId、时间戳落库为 0
* 异步 executeAssistantTurn 按 userId 查会话失败 →「会话不存在」与小程序兜底文案。
*/
protected $strict = true;
protected $name = 'ai_conversations';
protected $deleteTime = 'deletedAt';

View File

@@ -10,8 +10,11 @@ class AiMessage extends Model
{
protected $name = 'ai_messages';
/** 避免因库表字段与模型缓存不一致导致写入抛错 */
protected $strict = false;
/**
* 须为 true库表列为 conversationId / isDegraded 等驼峰。
* strict=false 时会转成 conversation_id 写入 → conversationId 恒为 0上下文与异步任务错乱。
*/
protected $strict = true;
protected $autoWriteTimestamp = false;

View File

@@ -1,7 +1,7 @@
-- 深度服务价格类目:个人版(deep_personal) 与 企业版(deep_enterprise),可配置、可新增类目
-- 表需有 UNIQUE(type, enterpriseId)。若已存在对应 type 会更新 config。
INSERT INTO `mbti_pricing_config` (`type`, `enterpriseId`, `config`, `createdAt`, `updatedAt`) VALUES
('deep_personal', NULL, '{"categories":[{"id":"personal_insight","title":"个人深度洞察版","price":198,"priceUnit":"/","subtitle":"三张照片+问卷,全面解锁你的内在潜能","features":["AI面部分析基于东方面相学与西方心理学","MBTI性格测试16型人格完整解读","盖洛普优势Top5识别发现你的核心天赋","PDP行为偏好分析了解你的行为模式","DISC沟通风格分析提升沟通效率","多维度综合性格报告(包含优势解读、潜在盲区提示)","职业发展方向推荐(匹配最适合你的职业)"],"actionType":"buy","productKey":"personal_insight"}]}', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
('deep_enterprise', NULL, '{"categories":[{"id":"startup","title":"团队启动版","priceDisplay":"¥19,800","subtitle":"适合初创团队、小型部门","userLimit":"最多10人","features":["10人完成个人深度洞察报告","1次团队动力诊断分析会(2-3小时)","识别团队优势组合与潜在风险点"],"actionType":"consult","consultWechat":"28533368","buttonText":"申请咨询"},{"id":"growth","title":"团队成长版","priceDisplay":"¥39,800","subtitle":"适合成长型团队","userLimit":"10-30人","features":["最多30人完成个人深度洞察报告","包含\"启动版\"所有内容","1次定制化团队协作增效工作坊(7天)","聚焦沟通优化、优势协作等主题"],"actionType":"consult","consultWechat":"28533368","buttonText":"申请咨询"},{"id":"transform","title":"团队蜕变版","priceDisplay":"¥98,000","subtitle":"适合中大型团队","userLimit":"最多100人","features":["最多100人完成个人深度洞察报告","包含\"成长版\"所有内容","更深度、更定制化的系列组合(≥2工作日)","3个月的落地跟进顾问服务"],"actionType":"consult","consultWechat":"28533368","buttonText":"申请咨询"}]}', UNIX_TIMESTAMP(), UNIX_TIMESTAMP())
ON DUPLICATE KEY UPDATE `config` = VALUES(`config`), `updatedAt` = UNIX_TIMESTAMP();
-- 深度服务价格类目:个人版(deep_personal) 与 企业版(deep_enterprise),可配置、可新增类目
-- 表需有 UNIQUE(type, enterpriseId)。若已存在对应 type 会更新 config。
INSERT INTO `mbti_pricing_config` (`type`, `enterpriseId`, `config`, `createdAt`, `updatedAt`) VALUES
('deep_personal', NULL, '{"categories":[{"id":"1772268461984","title":"个人深度洞察版","price":198,"priceUnit":"/小时","subtitle":"三张照片+问卷,全面解锁你的内在潜能","features":["AI面部分析基于东方面相学与西方心理学","MBTI性格测试16型人格完整解读","盖洛普优势Top5识别发现你的核心天赋","PDP行为偏好分析了解你的行为模式","DISC沟通风格分析提升沟通效率","多维度综合性格报告(包含优势解读、潜在盲区提示)","职业发展方向推荐(匹配最适合你的职业)"],"actionType":"buy","productKey":"1772268461984","purchaseButtonText":"了解自己并付款"},{"id":"deep_personal_vmp","title":"VMP个人定位","price":1980,"priceUnit":"/2小时","subtitle":"2小时深度咨询聚焦VMP定位与职业破局交付可执行路径","features":["V/M/P报告导读校准价值观、动机与人格优势","自我认知与角色定位:梳理岗位角色与发展瓶颈","咨询交付:问题界定→方案共创→书面行动清单","适用:转型犹豫、晋升卡顿、职业焦虑与方向不清"],"actionType":"buy","productKey":"deep_personal_vmp","purchaseButtonText":"了解套餐并付款"}]}', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
('deep_enterprise', NULL, '{"categories":[{"id":"startup","title":"团队启动版","priceDisplay":"¥19,800","subtitle":"适合初创团队、小型部门","userLimit":"最多10人","features":["10人完成个人深度洞察报告","1次团队动力诊断分析会(2-3小时)","识别团队优势组合与潜在风险点"],"actionType":"consult","consultWechat":"28533368","buttonText":"申请咨询"},{"id":"growth","title":"团队成长版","priceDisplay":"¥39,800","subtitle":"适合成长型团队","userLimit":"10-30人","features":["最多30人完成个人深度洞察报告","包含\"启动版\"所有内容","1次定制化团队协作增效工作坊(7天)","聚焦沟通优化、优势协作等主题"],"actionType":"consult","consultWechat":"28533368","buttonText":"申请咨询"},{"id":"transform","title":"团队蜕变版","priceDisplay":"¥98,000","subtitle":"适合中大型团队","userLimit":"最多100人","features":["最多100人完成个人深度洞察报告","包含\"成长版\"所有内容","更深度、更定制化的系列组合(≥2工作日)","3个月的落地跟进顾问服务"],"actionType":"consult","consultWechat":"28533368","buttonText":"申请咨询"}]}', UNIX_TIMESTAMP(), UNIX_TIMESTAMP())
ON DUPLICATE KEY UPDATE `config` = VALUES(`config`), `updatedAt` = UNIX_TIMESTAMP();

View File

@@ -0,0 +1,17 @@
-- 神仙 AI 异步对话任务表(多机/负载均衡下文件 Cache 不共享会导致轮询 404 或任务丢失)
-- 表名前缀请与 .env database.prefix 一致(示例为 mbti_
CREATE TABLE IF NOT EXISTS `mbti_ai_chat_jobs` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`userId` INT UNSIGNED NOT NULL,
`jobId` CHAR(32) NOT NULL COMMENT 'hex 任务 id',
`conversationId` INT UNSIGNED NOT NULL DEFAULT 0,
`status` VARCHAR(16) NOT NULL DEFAULT 'running' COMMENT 'running|done|error',
`resultJson` MEDIUMTEXT NULL COMMENT 'done 时存接口 data 的 JSON',
`errorMessage` VARCHAR(512) NULL,
`createdAt` INT UNSIGNED NOT NULL DEFAULT 0,
`updatedAt` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_job` (`userId`, `jobId`),
KEY `idx_updated` (`updatedAt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='神仙 AI 异步 chat 任务';

View File

@@ -0,0 +1,5 @@
-- 若线上 ai_messages 早于 isDegraded 字段创建,助手消息 INSERT 会失败 → 异步任务 error → 小程序「小神仙这边出了点状况」
-- 将表名前缀改为与 .env database.prefix 一致(示例为 mbti_
ALTER TABLE `mbti_ai_messages`
ADD COLUMN `isDegraded` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为降级兜底回答' AFTER `providerId`;

View File

@@ -0,0 +1,8 @@
-- 个人深度服务 deep_personal¥198/小时(洞察版)+ ¥1980/2小时VMP个人定位
-- 文案以套餐与交付为主,不含人物背书;超管「深度服务价格」可继续改。
-- 执行mysql ... < update_deep_personal_vip_20260417.sql
UPDATE `mbti_pricing_config`
SET `config` = '{\"categories\":[{\"id\":\"1772268461984\",\"price\":198,\"title\":\"个人深度洞察版\",\"features\":[\"AI面部分析基于东方面相学与西方心理学\",\"MBTI性格测试16型人格完整解读\",\"盖洛普优势Top5识别发现你的核心天赋\",\"PDP行为偏好分析了解你的行为模式\",\"DISC沟通风格分析提升沟通效率\",\"多维度综合性格报告(包含优势解读、潜在盲区提示)\",\"职业发展方向推荐(匹配最适合你的职业)\"],\"subtitle\":\"三张照片+问卷,全面解锁你的内在潜能\",\"priceUnit\":\"/小时\",\"actionType\":\"buy\",\"productKey\":\"1772268461984\",\"purchaseButtonText\":\"了解自己并付款\",\"consultWechat\":\"mi5p9-f4gx6-tl4nw-a2qb8-4wgap\",\"serviceWechat\":\"Lkdie01\",\"successMessage\":\"购买成功!我们的顾问会尽快与您联系,为您提供专属深度解读服务。\"},{\"id\":\"deep_personal_vmp\",\"price\":1980,\"title\":\"VMP个人定位\",\"features\":[\"V/M/P报告导读校准价值观、动机与人格优势\",\"自我认知与角色定位:梳理岗位角色与发展瓶颈\",\"咨询交付:问题界定→方案共创→书面行动清单\",\"适用:转型犹豫、晋升卡顿、职业焦虑与方向不清\"],\"subtitle\":\"2小时深度咨询聚焦VMP定位与职业破局交付可执行路径\",\"priceUnit\":\"/2小时\",\"actionType\":\"buy\",\"productKey\":\"deep_personal_vmp\",\"purchaseButtonText\":\"了解套餐并付款\",\"consultWechat\":\"mi5p9-f4gx6-tl4nw-a2qb8-4wgap\",\"serviceWechat\":\"Lkdie01\",\"successMessage\":\"购买成功顾问将按预约与您对接VMP个人定位服务。\"}]}',
`updatedAt` = UNIX_TIMESTAMP()
WHERE `type` = 'deep_personal' AND `enterpriseId` IS NULL;

View File

@@ -0,0 +1,8 @@
-- 推广提现:最低金额改为 1 分0.01 元)。需 MySQL 5.7+ 且 value 为合法 JSON。
-- 也可直接在超管「分销设置」将最低提现改为 0.01 并保存。
UPDATE `mbti_system_config`
SET `value` = JSON_SET(CAST(`value` AS JSON), '$.minWithdrawFen', 1),
`updatedAt` = UNIX_TIMESTAMP()
WHERE `key` = 'distribution'
AND `enterprise_id` = 0
AND JSON_EXTRACT(CAST(`value` AS JSON), '$.minWithdrawFen') = 100;

File diff suppressed because one or more lines are too long

View File

@@ -22,6 +22,11 @@ Route::group('api', function () {
Route::group('api', function () {
Route::get('config/runtime', 'api.AppConfig/runtime');
Route::get('config/deep-pricing', 'api.AppConfig/deepPricing');
// 与 ai/quick-questions 同源;部分线上网关仅放行 /api/config/*,供小程序公开拉取快捷问句
Route::get('config/ai-quick-questions', 'api.AiChat/quickQuestions');
Route::get('config/quick-questions', 'api.AiChat/quickQuestions');
// 与 mp/tabbar 同源;部分线上对 /api/mp/* 返回 nginx 404与 config/runtime 一并放行
Route::get('config/mp-tabbar', 'api.MpConfig/tabbar');
Route::post('analyze', 'api.Analyze/index');
// 小程序埋点批量上报(无需登录;带 token 时关联 user_id
Route::post('analytics/events', 'api.Analytics/batch');
@@ -92,6 +97,7 @@ Route::group('api', function () {
// ==================== 神仙 AI需要微信登录====================
Route::post('ai/chat', 'api.AiChat/chat');
Route::get('ai/chat/job', 'api.AiChat/chatJobStatus');
Route::get('ai/conversations', 'api.AiChat/conversations');
Route::get('ai/conversations/:id/messages', 'api.AiChat/messages');
Route::post('ai/transcribe', 'api.AiChat/transcribe');
@@ -99,7 +105,10 @@ Route::group('api', function () {
// AI 深度画像报告
Route::post('ai/report/create', 'api.AiReport/create');
Route::get('ai/report/my-latest', 'api.AiReport/myLatest');
Route::get('ai/report/:id', 'api.AiReport/show');
// 无歧义别名(避免网关/旧路由把 my-latest 吞掉或误匹配)
Route::get('ai/my-report/latest', 'api.AiReport/myLatest');
// :id 仅匹配数字,避免 my-latest 被误路由到 show 导致 404
Route::get('ai/report/:id', 'api.AiReport/show')->pattern(['id' => '\d+']);
Route::post('ai/report/:id/mark-paid-dev', 'api.AiReport/markPaidDev');
Route::post('ai/report/:id/regenerate', 'api.AiReport/regenerate');
})->middleware(['cors', 'auth']);
@@ -275,6 +284,7 @@ Route::group('api/v1/superadmin', function () {
Route::get('settings/poster', 'superadmin.Settings/getPosterConfig');
Route::put('settings/poster', 'superadmin.Settings/updatePosterConfig');
Route::put('settings/review-mode', 'superadmin.Settings/updateReviewMode');
Route::post('settings/wechat-audit-sync', 'superadmin.Settings/syncWechatAuditStatus');
Route::get('settings', 'superadmin.Settings/index');
Route::put('settings/system', 'superadmin.Settings/updateSystem');
Route::put('settings/report-requires-payment', 'superadmin.Settings/updateReportRequiresPayment');

View File

@@ -0,0 +1,75 @@
<?php
/**
* 神仙 AI 对话上线自检(在服务器 api 目录执行)
* php scripts/check_ai_chat_ready.php
*
* 检查ai_chat_jobs 表是否存在、是否有已启用的 AI 服务商、InternalPushHook 路由是否可达(仅提示)
*/
declare(strict_types=1);
use app\model\AiProvider as AiProviderModel;
use think\App;
use think\facade\Db;
require dirname(__DIR__) . '/vendor/autoload.php';
$app = new App();
$app->initialize();
$errors = [];
$ok = [];
$prefix = (string) config('database.connections.mysql.prefix', '');
$tableFull = $prefix . 'ai_chat_jobs';
try {
Db::name('ai_chat_jobs')->limit(1)->select();
$ok[] = '数据表 ' . $tableFull . ' 可访问';
} catch (\Throwable $e) {
$errors[] = '数据表 ai_chat_jobs 不可用(请执行 database/migrations/add_ai_chat_jobs.sql前缀与 database.prefix 一致): ' . $e->getMessage();
}
try {
$n = AiProviderModel::where('enabled', 1)
->whereRaw('(apiKey IS NOT NULL AND LENGTH(TRIM(apiKey)) > 0)')
->count();
if ($n > 0) {
$ok[] = '已启用且含 apiKey 的 AI 服务商: ' . $n . ' 条';
} else {
$errors[] = '无可用 AI 服务商:请在超管启用至少一条 provider 并填写 apiKey';
}
} catch (\Throwable $e) {
$errors[] = '读取 ai_providers 失败: ' . $e->getMessage();
}
try {
$cfg = config('database.connections.mysql');
$dbName = (string) ($cfg['database'] ?? '');
$msgTable = $prefix . 'ai_messages';
if ($dbName !== '') {
$hit = Db::query(
'SELECT COUNT(*) AS c FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?',
[$dbName, $msgTable, 'isDegraded']
);
$c = (int) (($hit[0]['c'] ?? $hit[0]['C'] ?? 0));
if ($c > 0) {
$ok[] = '数据表 ' . $msgTable . ' 含 isDegraded 字段(与 AiChat 写入一致)';
} else {
$errors[] = '数据表 ' . $msgTable . ' 缺少 isDegraded 列:请执行 database/migrations/add_ai_chat_and_soul_articles.sql 或 patch_ai_messages_isdegraded.sql前缀与 database.prefix 一致),否则助手消息无法落库';
}
}
} catch (\Throwable $e) {
$errors[] = '检查 ai_messages.isDegraded 失败: ' . $e->getMessage();
}
$ok[] = '异步投递:确保已部署 InternalPushHook.php且 POST /api/internal/outbound-push/dispatch 不被 Nginx 拦截';
$ok[] = '小程序:上传含 ai-chat 轮询与超时逻辑的最新代码包request 合法域名包含 API 域名';
foreach ($ok as $line) {
echo '[OK] ' . $line . "\n";
}
foreach ($errors as $line) {
echo '[!!] ' . $line . "\n";
}
exit($errors ? 1 : 0);

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env node
/**
* 生成与 JwtService::generateToken 同格式的 token签名段为 hex仅供本机冒烟。
* 读取 api/.env 中的 JWT_SECRET。
*/
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const envPath = path.join(__dirname, '..', '.env')
let secret = 'mbti_jwt_secret_key_2024_change_in_production'
try {
const raw = fs.readFileSync(envPath, 'utf8')
const m = raw.match(/JWT_SECRET\s*=\s*(\S+)/)
if (m) secret = m[1].trim()
} catch (_) {}
const header = Buffer.from(JSON.stringify({ typ: 'JWT', alg: 'HS256' })).toString('base64')
const now = Math.floor(Date.now() / 1000)
const payload = Buffer.from(
JSON.stringify({
userId: 1,
user_id: 1,
source: 'wechat',
exp: now + 3600,
iat: now,
})
).toString('base64')
const sig = crypto.createHmac('sha256', secret).update(header + '.' + payload).digest('hex')
process.stdout.write(header + '.' + payload + '.' + sig + '\n')

View File

@@ -0,0 +1,64 @@
<?php
/**
* 用 PDO 执行 SQL 文件(去掉行注释 -- 后整段 exec适配多行 UPDATE
*/
declare(strict_types=1);
if ($argc < 2) {
fwrite(STDERR, "用法: php scripts/run_sql_file_pdo.php <相对api根目录的sql路径>\n");
exit(1);
}
$root = dirname(__DIR__);
$sqlPath = $root . '/' . ltrim($argv[1], '/');
if (!is_readable($sqlPath)) {
fwrite(STDERR, "找不到文件: {$sqlPath}\n");
exit(1);
}
$envFile = $root . '/.env';
$env = [];
if (is_readable($envFile)) {
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim(str_replace("\r", '', $line));
if ($line === '' || (isset($line[0]) && $line[0] === '#')) {
continue;
}
if (!preg_match('/^([A-Za-z0-9_]+)\s*=\s*(.*)$/', $line, $m)) {
continue;
}
$env[$m[1]] = trim($m[2], " \t\"'");
}
}
$host = $env['DATABASE_HOSTNAME'] ?? '127.0.0.1';
$port = (int) ($env['DATABASE_HOSTPORT'] ?? 3306);
$db = $env['DATABASE_DATABASE'] ?? '';
$user = $env['DATABASE_USERNAME'] ?? '';
$pass = $env['DATABASE_PASSWORD'] ?? '';
if ($db === '' || $user === '') {
fwrite(STDERR, ".env 缺少 DATABASE_DATABASE / DATABASE_USERNAME\n");
exit(1);
}
$raw = file_get_contents($sqlPath);
if ($raw === false || trim($raw) === '') {
fwrite(STDERR, "SQL 为空\n");
exit(1);
}
// 去掉整行 -- 注释(本仓库迁移文件常用)
$sql = preg_replace('/^\s*--.*$/m', '', $raw);
$sql = trim(preg_replace("/\n{3,}/", "\n\n", $sql));
if ($sql === '') {
fwrite(STDERR, "去掉注释后无 SQL\n");
exit(1);
}
$dsn = "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->exec($sql);
echo "OK\n";

View File

@@ -0,0 +1,62 @@
<?php
/**
* 神仙 AI 冒烟:与线上 AiChat 相同调用链AiCallService::chat → OpenAI 兼容 /chat/completions 或 Anthropic
* 用法:在 api 目录下执行 php scripts/smoke_ai_provider.php
*/
declare(strict_types=1);
use app\common\service\AiCallService;
use app\model\AiProvider as AiProviderModel;
use think\App;
require dirname(__DIR__) . '/vendor/autoload.php';
$app = new App();
$app->initialize();
$row = AiProviderModel::where('enabled', 1)
->whereRaw('(visible IS NULL OR visible = 1)')
->whereRaw('(apiKey IS NOT NULL AND LENGTH(TRIM(apiKey)) > 0)')
->order('sortWeight', 'asc')
->order('id', 'asc')
->find();
$providerHint = $row ? [
'id' => (int) $row->id,
'providerId' => (string) ($row->providerId ?? ''),
'endpoint' => trim((string) ($row->apiEndpoint ?? '')) ?: '(默认内置)',
'model' => (string) ($row->model ?? ''),
] : null;
$messages = [
['role' => 'system', 'content' => AiCallService::buildSystemPrompt([
'mbtiType' => 'ENTJ',
'summary' => '',
'nickname' => '冒烟测试',
'testAppendix' => '',
])],
[
'role' => 'user',
'content' => '用两三句话回答ENTJ 常见的一个优势和一个盲点分别是什么?不要以#或@开头,不要人设签名。',
],
];
$r = AiCallService::chat($messages, ['temperature' => 0.45, 'maxTokens' => 512]);
$content = trim((string) ($r['content'] ?? ''));
$ok = $content !== '' && empty($r['isDegraded']);
$payload = [
'first_provider' => $providerHint,
'api_path' => strtolower((string) ($row->providerId ?? '')) === 'anthropic'
? 'POST {endpoint}/v1/messages (Anthropic)'
: 'POST {endpoint}/chat/completions (OpenAI 兼容)',
'isDegraded' => !empty($r['isDegraded']),
'providerId' => (string) ($r['providerId'] ?? ''),
'content_preview'=> $ok ? mb_substr($content, 0, 280) . (mb_strlen($content) > 280 ? '…' : '') : $content,
'ok' => $ok,
];
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), "\n";
exit($ok ? 0 : 1);