feat: 微信分享钩子全页面接入 + 订阅信息结构化映射 + JWT 错误处理加强

- client: 全部页面接入 onShareAppMessage/onShareTimeline 分享钩子
- client: login 页增强、profile 订阅展示优化
- client/stores: 订阅字段扁平结构映射为 subInfo(plan/expiresAt/startDate)
- server: auth 路由 JWT 无效/过期统一返回 401 未授权响应
- server: models/tests 同步更新
This commit is contained in:
Yuzhiran Dev
2026-09-04 11:47:29 +08:00
parent de8ca9c420
commit 66de1dc396
37 changed files with 2138 additions and 1830 deletions
+113 -73
View File
@@ -1,4 +1,4 @@
#!/www/server/nodejs/v24.14.0/bin/node
#!/usr/bin/node
/**
* AI 趋势数据更新脚本 —— 真实数据版
* 数据源:
@@ -62,7 +62,7 @@ function getSource(url, from) {
function httpGet(url) {
return new Promise((resolve, reject) => {
https.get(url, { timeout: 12000 }, (res) => {
https.get(url, { timeout: 20000 }, (res) => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
@@ -75,7 +75,12 @@ function httpGet(url) {
/** 从 Hacker News 获取 AI 相关热门(精确匹配) */
async function fetchHN() {
const ids = await httpGet('https://hacker-news.firebaseio.com/v0/topstories.json');
let ids;
for (let attempt = 1; attempt <= 3; attempt++) {
try { ids = await httpGet('https://hacker-news.firebaseio.com/v0/topstories.json'); break; }
catch (e) { console.log(` HN topstories 重试 ${attempt}: ${e.message}`); await new Promise(r => setTimeout(r, 500)); }
}
if (!Array.isArray(ids)) throw new Error('HN topstories 获取失败');
const batch = ids.slice(0, 120);
const results = await Promise.allSettled(
batch.map(id => httpGet(`https://hacker-news.firebaseio.com/v0/item/${id}.json`))
@@ -121,6 +126,7 @@ async function fetchArxiv() {
const date = new Date(pub);
return {
title, summary: summary || title,
body: summary, // arXiv 已经有完整摘要,直接作为正文
source: 'arXiv', sourceUrl: link,
category: '论文', tags: [], hot: date.getTime() > Date.now() - 2 * 86400000,
newsDate: date, status: 'published'
@@ -133,89 +139,112 @@ function hasChinese(text) {
return /[\u4e00-\u9fff]/.test(text);
}
/** 调用 AI 模型批量翻译英文标题和摘要到中文 */
// 合规底线:命中以下关键词的内容不发布(可在后续按需扩充)
const COMPLIANCE_BLOCKLIST = ['色情', '赌博', '博彩', '毒品', '暴力恐怖', '涉政敏感', '代开发票', '办证'];
function isCompliant(item) {
const text = [item.title, item.summary, item.body].filter(Boolean).join(' ');
return !COMPLIANCE_BLOCKLIST.some(w => text.includes(w));
}
// AI 模型统一从数据库 AIModel 集合读取(与线上 AI 问答同款,优先 SenseNova),避免硬编码失效密钥
let _models = null;
async function loadModels() {
if (_models) return _models;
try {
const AIModel = require('../models/AIModel');
_models = await AIModel.getActiveModels();
} catch (e) {
console.log(' ⚠ 读取 AIModel 失败:', e.message);
_models = [];
}
return _models;
}
/** 调用 AI(60s 超时 + 单次重试;自动在主备模型间切换;兼容 content / reasoning_content / reasoning) */
async function callAI(messages, maxTokens = 1024) {
const models = await loadModels();
if (!models.length) { console.log(' ⚠ 无可用 AI 模型,跳过本次 AI 处理'); return ''; }
let lastErr = null;
for (const m of models) {
for (let attempt = 1; attempt <= 2; attempt++) {
try {
const signal = AbortSignal.timeout(60000);
const res = await fetch(m.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + m.apiKey },
body: JSON.stringify({ model: m.modelId, messages, temperature: 0.1, max_tokens: maxTokens }),
signal
});
const data = await res.json();
const msg = data?.choices?.[0]?.message || {};
const text = (msg.content || msg.reasoning_content || msg.reasoning || '').trim();
if (text) return text;
} catch (e) {
lastErr = e;
console.log(` ⚠ 模型 ${m.modelId} 第${attempt}次失败: ${e.message}`);
}
}
}
if (lastErr) console.log(' ⚠ 所有模型调用失败:', lastErr.message);
return '';
}
/** 翻译英文内容到中文 */
async function translateItems(items) {
const needTranslate = items.filter(i => !hasChinese(i.title));
if (needTranslate.length === 0) return items;
const apiUrl = process.env.OPENAI_API_URL || 'https://opencode.ai/zen/v1/chat/completions';
const apiKey = ''; // OpenCode Zen 无需 API Key
const model = process.env.OPENAI_MODEL || 'deepseek-v4-flash-free';
console.log(` → 翻译 ${needTranslate.length} 条英文内容...`);
const BATCH = 1;
// 分批翻译,每次最多 4 条
const BATCH = 4;
for (let i = 0; i < needTranslate.length; i += BATCH) {
const batch = needTranslate.slice(i, i + BATCH);
const input = batch.map((item, idx) => {
return `[${idx + 1}] Title: ${item.title}\nSummary: ${item.summary}`;
}).join('\n\n');
const prompt = `You are an AI news translator. Translate the following AI-related news headlines to Chinese.
Rules:
- Keep technical terms/model names/company names in English (e.g., GPT-4, arXiv, NVIDIA, GitHub, Qwen, Grok)
- Keep proper nouns like "Show HN:", "Mindwalk" untranslated
- Output ONLY a JSON array in this exact format, no other text:
[
{"title": "translated title 1", "summary": "translated summary 1"},
{"title": "translated title 2", "summary": "translated summary 2"}
]
News to translate:
${input}`;
const input = batch.map((item, idx) =>
`[${idx + 1}] Title: ${item.title}\nSummary: ${(item.summary || '').slice(0, 250)}`
).join('\n\n');
try {
const result = await httpPost(apiUrl, {
model,
messages: [{ role: 'user', content: prompt }],
temperature: 0.1,
max_tokens: 2048
}, apiKey);
const text = await callAI([{
role: 'user',
content: `Translate AI news to Chinese. Keep technical terms in English. Output ONLY this JSON array:\n[\n {"title":"chinese title","summary":"chinese summary"}\n]\n\nNews:\n${input}`
}], 400);
const text = result?.choices?.[0]?.message?.content || '';
console.log(` ↳ 翻译响应(前100): ${text.slice(0, 100)}`);
// 尝试提取 JSON - 用贪婪匹配(] 只在数组末尾出现)
const jsonMatch = text.match(/\[[\s\S]*\]/);
if (jsonMatch) {
// 尝试修复常见 JSON 截断问题
let jsonStr = jsonMatch[0];
try {
const translations = JSON.parse(jsonStr);
const translations = JSON.parse(jsonMatch[0]);
if (Array.isArray(translations)) {
translations.forEach((t, idx) => {
if (idx < batch.length && t.title) {
if (idx < batch.length && t.title && !hasChinese(batch[idx].title)) {
batch[idx].title = t.title;
if (t.summary) batch[idx].summary = t.summary;
}
});
continue;
}
} catch (parseErr) {
// 截断了,尝试逐行模式:按标题-摘要配对解析
console.log(` ↳ JSON 截断,尝试文本解析...`);
// 从响应中提取所有 title: 和 summary: 字段
const titles = [...jsonStr.matchAll(/"title"\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map(m => m[1]);
const summaries = [...jsonStr.matchAll(/"summary"\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map(m => m[1]);
titles.forEach((t, idx) => {
if (idx < batch.length && !hasChinese(batch[idx].title)) {
batch[idx].title = t;
if (summaries[idx]) batch[idx].summary = summaries[idx];
}
});
}
} else {
console.log(` ⚠ 批次 ${i / BATCH + 1} 解析失败: ${text.slice(0, 100)}`);
} catch (_) {}
// JSON 截断,尝试正则提取
const titles = [...jsonMatch[0].matchAll(/"title"\s*:\s*"((?:[^"\\]|\\.)*)"/g)].map(m => m[1]);
titles.forEach((t, idx) => {
if (idx < batch.length && !hasChinese(batch[idx].title)) batch[idx].title = t;
});
}
} catch (e) {
console.log(` ⚠ 翻译批次 ${i / BATCH + 1} 失败: ${e.message}`);
}
// 等一秒避免限流
if (i + BATCH < needTranslate.length) {
await new Promise(r => setTimeout(r, 1000));
}
if (i + BATCH < needTranslate.length) await new Promise(r => setTimeout(r, 500));
}
return items;
}
/** 为趋势生成正文内容:直接用已翻译的中文 summary 作为正文(避免长文本生成超时,保证稳定产出中文) */
async function enrichBody(items) {
const needBody = items.filter(i => !i.body);
if (needBody.length === 0) return items;
console.log(` → 正文使用中文摘要填充: ${needBody.length} 条`);
needBody.forEach(item => {
item.body = item.summary || item.title;
});
return items;
}
@@ -287,22 +316,34 @@ async function main() {
// 合并数据
let items = [...hn, ...arxiv];
// 如果不够,补保底
if (items.length < 6) {
const need = Math.min(FALLBACK.length, MAX_ITEMS - items.length);
const fallback = FALLBACK.slice(0, need).map(f => ({ ...f, newsDate: f.newsDate() }));
items.push(...fallback);
console.log(` ✓ 保底: ${need} 条`);
}
// 翻译英文内容为中文
if (items.length > 0) {
items = await translateItems(items);
}
// 清理旧数据
const cutoff = new Date(Date.now() - MAX_AGE_DAYS * 86400000);
const deleted = await Trend.deleteMany({ newsDate: { $lt: cutoff } });
// 生成正文内容(body 字段,中文)
if (items.length > 0) {
items = await enrichBody(items);
}
// 合规过滤:命中底线的内容不发布
items = items.filter(isCompliant);
console.log(` → 合规过滤后: ${items.length} 条`);
// 默认中文:丢弃翻译/生成后仍非中文的条目,保证 feed 全中文
items = items.filter(i => hasChinese(i.title));
console.log(` → 仅保留中文: ${items.length} 条`);
// 数量不足时用中文保底数据补充
if (items.length < 8) {
const need = Math.min(FALLBACK.length, MAX_ITEMS - items.length);
const fallback = FALLBACK.slice(0, need).map(f => ({ ...f, newsDate: f.newsDate() }));
items.push(...fallback);
console.log(` ✓ 中文保底补充: ${need} 条`);
}
// 全量刷新:清空旧数据(避免残留英文 / 测试数据)
await Trend.deleteMany({});
// 去重插入
let inserted = 0;
@@ -331,11 +372,10 @@ async function main() {
console.log(` HN=${hn.length} arXiv=${arxiv.length} 新增=${inserted} 数据库共=${finalCount} 条`);
if (items.length > 0) {
console.log('\n📰 最新趋势:');
console.log('\n📰 最新趋势(中文):');
items.slice(0, Math.min(items.length, 10)).forEach((t, i) => {
const tag = t.hot ? '🔥' : t.picked ? '📌' : ' ';
const cat = t.category?.padEnd(4) || '';
const date = t.newsDate ? new Date(t.newsDate).toLocaleDateString('zh-CN') : '';
const cat = (t.category || '').padEnd(4);
console.log(` ${tag} [${cat}] ${t.title} (${t.source})`);
});
}