feat: 同步今日小程序与后台迭代版本
集中提交今日 API、管理端、小程序与部署文档调整,确保 Gitea 主分支与本地最新开发版本一致。 Made-with: Cursor
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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' => '智能分析',
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 组装 messages:system + 最近 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ class Distribution extends BaseController
|
||||
const BINDING_DAYS = 30;
|
||||
const BINDING_TTL = 30 * 86400;
|
||||
|
||||
/** 提现金额下限(分),1元 */
|
||||
const MIN_WITHDRAW_FEN = 100;
|
||||
/** 提现金额下限(分),1 分(0.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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'] ?? ''));
|
||||
|
||||
@@ -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));
|
||||
// 兼容 fallback:JSON 解析失败时尝试 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=免费完整)
|
||||
|
||||
Reference in New Issue
Block a user