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

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

View File

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

View File

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

View File

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