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

@@ -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=免费完整)