feat: 小程序 AI 底栏与聊天页、出站推送调试与 tabbar 迁移

- 自定义 tabBar:AI 入口图标与样式,相关页面与审核门控调整

- API:MpTabbar、出站推送 Hook、后台设置与迁移 SQL(含 FAB 居中排序)

- 管理端:推送 Hook 面板、出站推送调试工具与生产环境注释说明

Made-with: Cursor
This commit is contained in:
Ghost
2026-04-20 15:29:10 +08:00
parent ce55c48c64
commit d07235b64c
26 changed files with 486 additions and 317 deletions

View File

@@ -25,10 +25,20 @@ class MpTabbarService
foreach ($items as $row) {
$iconKey = $row['iconKey'] ?? 'home';
$iconUrl = $row['iconUrl'] ?? null;
$iconUrlActive = null;
if ($iconKey === 'ai') {
$iconUrl = '/images/shenxian-oldman-circle.png';
$u = is_string($iconUrl) ? trim($iconUrl) : '';
if ($u !== '') {
$iconUrl = $u;
$iconUrlActive = isset($row['iconUrlActive']) && $row['iconUrlActive'] !== ''
? $row['iconUrlActive']
: '/images/tab-ai-active.png';
} else {
$iconUrl = null;
$iconUrlActive = null;
}
}
$list[] = [
$item = [
'id' => (int) $row['id'],
'pagePath' => $row['pagePath'],
'text' => $row['text'],
@@ -37,16 +47,21 @@ class MpTabbarService
'highlight' => (int) ($row['highlight'] ?? 0) === 1,
'badgeKey' => $row['badgeKey'] ?? null,
];
if ($iconUrlActive !== null) {
$item['iconUrlActive'] = $iconUrlActive;
}
$list[] = $item;
}
} catch (\Throwable $e) {
$list = [];
}
if (empty($list)) {
// 顺序:首页 · 第2项凸起拍摄 · 神仙AI · 我(与小程序 app.json tabBar.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/ai-chat/index', 'text' => '神仙AI', 'iconKey' => 'ai', 'iconUrl' => null, 'highlight' => false, 'badgeKey' => null],
['id' => 0, 'pagePath' => 'pages/profile/index', 'text' => '我', 'iconKey' => 'profile', 'iconUrl' => null, 'highlight' => false, 'badgeKey' => null],
];
}

View File

@@ -511,10 +511,12 @@ class OutboundPushHookService
'X-MBTI-Internal-Signature: ' . self::signAsyncInternalDispatch($body, $timestamp),
];
if (!self::postJsonAsyncNoWait($url, $body, $headers)) {
$verifyHttp2xx = (($payload['job'] ?? '') === 'ai.chat_turn');
if (!self::postJsonAsyncNoWait($url, $body, $headers, $verifyHttp2xx)) {
// 偶发 TLS 握手/链路抖动:短间隔重试一次再回落 shutdown
usleep(150000);
if (!self::postJsonAsyncNoWait($url, $body, $headers)) {
if (!self::postJsonAsyncNoWait($url, $body, $headers, $verifyHttp2xx)) {
Log::warning('OutboundPushHook async enqueue failed', [
'url' => self::maskUrl($url),
'payload' => $payload,
@@ -566,11 +568,13 @@ class OutboundPushHookService
}
/**
* fire-and-forget 异步 POST只负责把请求投出去,不等待接口处理完成
* fire-and-forget 异步 POST把请求写出;可选读取 HTTP 首行状态码
* - verifyHttp2xx=false与旧版一致仅判断 fwrite其它 job 可能同步较慢,不宜等响应头)。
* - verifyHttp2xx=true用于 ai.chat_turndispatch 须先快速返回 2xx否则会误判失败且无法触发 AiChat shutdown 兜底)。
*
* @param array<int,string> $headers
*/
private static function postJsonAsyncNoWait(string $url, string $body, array $headers): bool
private static function postJsonAsyncNoWait(string $url, string $body, array $headers, bool $verifyHttp2xx = false): bool
{
$parts = parse_url($url);
if (!is_array($parts) || empty($parts['host'])) {
@@ -633,9 +637,44 @@ class OutboundPushHookService
$rawRequest = implode("\r\n", $requestLines) . "\r\n\r\n" . $body;
$written = @fwrite($socket, $rawRequest);
@fclose($socket);
if ($written === false) {
@fclose($socket);
return $written !== false;
return false;
}
if (!$verifyHttp2xx) {
@fclose($socket);
return true;
}
$statusLine = @fgets($socket);
@fclose($socket);
if ($statusLine === false || $statusLine === '') {
Log::warning('OutboundPushHook async: no HTTP status line', ['url' => self::maskUrl($url)]);
return false;
}
if (!preg_match('#HTTP/\d\.\d\s+(\d{3})#', $statusLine, $m)) {
Log::warning('OutboundPushHook async: bad status line', [
'url' => self::maskUrl($url),
'prefix' => mb_substr($statusLine, 0, 80),
]);
return false;
}
$code = (int) $m[1];
if ($code < 200 || $code >= 300) {
Log::warning('OutboundPushHook async: non-2xx from internal dispatch', [
'url' => self::maskUrl($url),
'http' => $code,
]);
return false;
}
return true;
}
/**

View File

@@ -554,17 +554,11 @@ class Settings extends BaseController
/**
* POST /api/v1/admin/settings/push-hook/test
* 向当前解析到的 URL 发送 hook.ping不写去重表
* 企业后台不提供:请在超管后台调试,避免误触对外 URL / 重放业务数据。
*/
public function testPushHookConfig()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$ctx = $this->resolveAdminPushHookEnterpriseId();
$r = OutboundPushHookService::sendTestPing($ctx);
return success($r, $r['ok'] ? '测试推送已发出' : ($r['message'] ?? '测试失败'));
return error('连接测试与重放调试仅在超管后台开放', 403);
}
/**
@@ -573,16 +567,7 @@ class Settings extends BaseController
*/
public function testPushHookTestResult()
{
$user = $this->request->user ?? null;
if (!$user || !in_array($user['role'], ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$testResultId = (int) Request::param('testResultId', 0);
$force = (int) Request::param('force', 0) === 1;
$r = OutboundPushHookService::replayTestResultForDebug($testResultId, $force);
return success($r, $r['message'] ?? ($r['ok'] ? '已重放推送' : '重放失败'));
return error('连接测试与重放调试仅在超管后台开放', 403);
}
/**

View File

@@ -55,7 +55,14 @@ class InternalPushHook extends BaseController
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);
// 大模型耗时长:须先结束本 HTTP 响应,再在 shutdown 中跑任务;否则主请求里 postJsonAsyncNoWait 无法读到 2xx且易误判投递失败
register_shutdown_function(static function () use ($uid, $cid, $jid) {
try {
\app\controller\api\AiChat::runDeferredChatJob($uid, $cid, $jid);
} catch (\Throwable $e) {
Log::error('InternalPushHook ai.chat_turn deferred: ' . $e->getMessage());
}
});
break;
default:
return error('unsupported job', 400);

View File

@@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `mbti_mp_tabbar_items` (
KEY `idx_sort` (`sortOrder`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小程序 TabBar 后台可配';
-- 初始 4 项(默认顺序:首页·拍摄·神仙AI·我
-- 初始 4 项(默认顺序:首页·拍摄(第2项凸起)·神仙AI·我与 app.json tabBar 一致
INSERT INTO `mbti_mp_tabbar_items` (sortOrder, pagePath, text, iconKey, highlight, visible, createdAt, updatedAt) VALUES
(10, 'pages/index/index', '首页', 'home', 0, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(20, 'pages/index/camera', '拍摄', 'camera', 1, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),

View File

@@ -0,0 +1,7 @@
-- 已有库迁移:底部 Tab 顺序改为「首页 · 拍摄(第2项凸起) · 神仙AI · 我」
-- 在已存在 mbti_mp_tabbar_items 数据时执行;按 pagePath 更新,不依赖自增 id
UPDATE `mbti_mp_tabbar_items` SET `sortOrder` = 10, `highlight` = 0 WHERE `pagePath` = 'pages/index/index';
UPDATE `mbti_mp_tabbar_items` SET `sortOrder` = 20, `highlight` = 1 WHERE `pagePath` = 'pages/index/camera';
UPDATE `mbti_mp_tabbar_items` SET `sortOrder` = 30, `highlight` = 0 WHERE `pagePath` = 'pages/ai-chat/index';
UPDATE `mbti_mp_tabbar_items` SET `sortOrder` = 40, `highlight` = 0 WHERE `pagePath` = 'pages/profile/index';