feat: 小程序 AI 底栏与聊天页、出站推送调试与 tabbar 迁移
- 自定义 tabBar:AI 入口图标与样式,相关页面与审核门控调整 - API:MpTabbar、出站推送 Hook、后台设置与迁移 SQL(含 FAB 居中排序) - 管理端:推送 Hook 面板、出站推送调试工具与生产环境注释说明 Made-with: Cursor
This commit is contained in:
@@ -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],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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_turn(dispatch 须先快速返回 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()),
|
||||
|
||||
7
api/database/migrations/reorder_mp_tabbar_fab_center.sql
Normal file
7
api/database/migrations/reorder_mp_tabbar_fab_center.sql
Normal 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';
|
||||
Reference in New Issue
Block a user