feat: 首次推送到Gitea - 完整项目代码 + 安全加固 + 知识库
This commit is contained in:
@@ -0,0 +1,762 @@
|
||||
/**
|
||||
* weekly-recommend-email.mjs
|
||||
* 每周一个性化推荐邮件
|
||||
*
|
||||
* 功能:
|
||||
* 1. 分析每个用户的兴趣(收藏 + 浏览历史 + 社区活动)
|
||||
* 2. 基于兴趣匹配推荐 AI 工具
|
||||
* 3. 生成个性化 HTML 邮件
|
||||
* 4. 输出到 data/emails/weekly-recommend-{date}.json
|
||||
*
|
||||
* 调度: 每周一上午 10:30 (crontab: 30 10 * * 1)
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import "dotenv/config";
|
||||
import { withRetry } from "./lib/retry.mjs";
|
||||
import { cacheGet, cacheSet, disconnectRedis } from "./lib/redis-cache.mjs";
|
||||
|
||||
// ─── Prisma 初始化 ────────────────────────────────────────────
|
||||
const rawUrl = process.env.DATABASE_URL;
|
||||
if (!rawUrl) {
|
||||
console.error("DATABASE_URL not set");
|
||||
process.exit(1);
|
||||
}
|
||||
const base = rawUrl.replace("mysql://", "mariadb://");
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// ─── 工具函数 ─────────────────────────────────────────────────
|
||||
function getWeekNumber(d) {
|
||||
const startOfYear = new Date(d.getFullYear(), 0, 1);
|
||||
const diff = d - startOfYear;
|
||||
const oneWeek = 7 * 24 * 60 * 60 * 1000;
|
||||
return Math.ceil((diff / oneWeek + startOfYear.getDay() + 1) / 7);
|
||||
}
|
||||
|
||||
function truncate(str, maxLen) {
|
||||
if (!str) return "";
|
||||
return str.length > maxLen ? str.slice(0, maxLen - 1) + "…" : str;
|
||||
}
|
||||
|
||||
function getBaseUrl() {
|
||||
return process.env.NEXT_PUBLIC_BASE_URL || "https://zhuiguang.ai";
|
||||
}
|
||||
|
||||
// ─── 兴趣分析 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 分析单个用户的兴趣偏好,返回按分数排序的类别列表。
|
||||
*
|
||||
* 数据来源:
|
||||
* - Favorite 表(权重 3)→ 明确喜欢
|
||||
* - BrowsingHistory 表(权重 1)→ 浏览过
|
||||
* - ForumTopic 表(通过论坛板块名称与工具类别名称的语义匹配,权重 1)
|
||||
*
|
||||
* 返回格式: [{ categoryId: number, categoryName: string, categorySlug: string, score: number }]
|
||||
*/
|
||||
async function analyzeUserInterests(userId) {
|
||||
const categoryScores = {};
|
||||
|
||||
function addScore(categoryId, categoryName, categorySlug, weight) {
|
||||
if (!categoryId) return;
|
||||
const key = categoryId;
|
||||
if (!categoryScores[key]) {
|
||||
categoryScores[key] = { categoryId, categoryName, categorySlug, score: 0, sources: new Set() };
|
||||
}
|
||||
categoryScores[key].score += weight;
|
||||
}
|
||||
|
||||
// 1. 收藏的工具 → 类别映射(权重 3)
|
||||
const favorites = await prisma.favorite.findMany({
|
||||
where: { userId, toolId: { not: null } },
|
||||
include: {
|
||||
tool: {
|
||||
select: {
|
||||
categoryId: true,
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 200,
|
||||
});
|
||||
|
||||
for (const fav of favorites) {
|
||||
if (fav.tool?.category) {
|
||||
addScore(fav.tool.category.id, fav.tool.category.name, fav.tool.category.slug, 3);
|
||||
categoryScores[fav.tool.category.id].sources.add("favorite");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 浏览历史 → 类别映射(权重 1)
|
||||
const history = await prisma.browsingHistory.findMany({
|
||||
where: { userId, toolId: { not: null } },
|
||||
include: {
|
||||
tool: {
|
||||
select: {
|
||||
categoryId: true,
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 500,
|
||||
});
|
||||
|
||||
for (const h of history) {
|
||||
if (h.tool?.category) {
|
||||
addScore(h.tool.category.id, h.tool.category.name, h.tool.category.slug, 1);
|
||||
categoryScores[h.tool.category.id].sources.add("browsing");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 社区发帖 → 尝试通过论坛板块名称匹配工具类别
|
||||
// ForumCategory 名称与 Category 名称可能有重叠,做模糊匹配
|
||||
const forumTopics = await prisma.forumTopic.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
category: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
take: 200,
|
||||
});
|
||||
|
||||
// 收集所有工具类别,用于名称匹配
|
||||
const allToolCategories = await prisma.category.findMany({
|
||||
where: { parentId: null },
|
||||
select: { id: true, name: true, slug: true },
|
||||
});
|
||||
|
||||
const forumCategoryNames = new Set();
|
||||
for (const ft of forumTopics) {
|
||||
if (ft.category) {
|
||||
forumCategoryNames.add(ft.category.name.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
// 简单的关键词匹配:论坛板块名包含工具类别名,或反之
|
||||
for (const tc of allToolCategories) {
|
||||
const tcLower = tc.name.toLowerCase();
|
||||
for (const fcn of forumCategoryNames) {
|
||||
if (fcn.includes(tcLower) || tcLower.includes(fcn)) {
|
||||
addScore(tc.id, tc.name, tc.slug, 2);
|
||||
categoryScores[tc.id].sources.add("forum");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按分数降序排列
|
||||
const sorted = Object.values(categoryScores)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ categoryId, categoryName, categorySlug, score, sources }) => ({
|
||||
categoryId,
|
||||
categoryName,
|
||||
categorySlug,
|
||||
score: Math.round(score),
|
||||
sources: [...sources],
|
||||
}));
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
// ─── 推荐引擎 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Section 1: "基于你的兴趣推荐"
|
||||
* 从用户感兴趣的前 3 个类别中,挑选他们尚未收藏/浏览过的工具(最多 3 个)
|
||||
*/
|
||||
async function recommendFromInterests(userId, topCategories, excludeToolIds, limit = 3) {
|
||||
if (topCategories.length === 0) return [];
|
||||
|
||||
const categoryIds = topCategories.slice(0, 3).map((c) => c.categoryId);
|
||||
const tools = await prisma.tool.findMany({
|
||||
where: {
|
||||
categoryId: { in: categoryIds },
|
||||
status: "published",
|
||||
id: { notIn: excludeToolIds },
|
||||
},
|
||||
include: {
|
||||
category: { select: { name: true, slug: true } },
|
||||
},
|
||||
orderBy: { viewCount: "desc" },
|
||||
take: limit * 3, // fetch more to deduplicate across categories
|
||||
});
|
||||
|
||||
// Deduplicate and pick distinct tools
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const t of tools) {
|
||||
if (seen.has(t.id)) continue;
|
||||
seen.add(t.id);
|
||||
result.push(t);
|
||||
if (result.length >= limit) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section 2: "本周热门工具"
|
||||
* 过去 30 天内发布的最热门工具(按 viewCount),排除用户已看过的
|
||||
*/
|
||||
async function recommendTrendingTools(excludeToolIds, limit = 3) {
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const tools = await prisma.tool.findMany({
|
||||
where: {
|
||||
status: "published",
|
||||
createdAt: { gte: thirtyDaysAgo },
|
||||
id: { notIn: excludeToolIds },
|
||||
},
|
||||
include: {
|
||||
category: { select: { name: true, slug: true } },
|
||||
},
|
||||
orderBy: { viewCount: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section 3: "你可能错过的新工具"
|
||||
* 用户感兴趣类别中最近 30 天新发布的工具(最多 2 个)
|
||||
*/
|
||||
async function recommendNewTools(userId, topCategories, excludeToolIds, limit = 2) {
|
||||
if (topCategories.length === 0) return [];
|
||||
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const categoryIds = topCategories.slice(0, 3).map((c) => c.categoryId);
|
||||
|
||||
const tools = await prisma.tool.findMany({
|
||||
where: {
|
||||
categoryId: { in: categoryIds },
|
||||
status: "published",
|
||||
createdAt: { gte: thirtyDaysAgo },
|
||||
id: { notIn: excludeToolIds },
|
||||
},
|
||||
include: {
|
||||
category: { select: { name: true, slug: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
return tools;
|
||||
}
|
||||
|
||||
// ─── 获取用户已交互的工具 ID 集合 ────────────────────────────
|
||||
async function getUserInteractedToolIds(userId) {
|
||||
const [favs, history] = await Promise.all([
|
||||
prisma.favorite.findMany({
|
||||
where: { userId, toolId: { not: null } },
|
||||
select: { toolId: true },
|
||||
}),
|
||||
prisma.browsingHistory.findMany({
|
||||
where: { userId, toolId: { not: null } },
|
||||
select: { toolId: true },
|
||||
}),
|
||||
]);
|
||||
return new Set([
|
||||
...favs.map((f) => f.toolId).filter(Boolean),
|
||||
...history.map((h) => h.toolId).filter(Boolean),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── HTML 邮件模板 ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 生成工具卡片的 HTML 片段
|
||||
*/
|
||||
function renderToolCard(tool, baseUrl) {
|
||||
const logoSrc = tool.logoUrl || `${baseUrl}/default-tool-logo.png`;
|
||||
const detailUrl = `${baseUrl}/tools/${tool.slug}`;
|
||||
const desc = truncate(tool.description, 80);
|
||||
|
||||
return `
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background-color:#ffffff;">
|
||||
<tr>
|
||||
<td style="padding:16px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td width="48" style="vertical-align:top;padding-right:12px;">
|
||||
<img src="${logoSrc}" width="48" height="48" alt="${tool.name}" style="border-radius:8px;display:block;object-fit:cover;background:#f3f4f6;" />
|
||||
</td>
|
||||
<td style="vertical-align:top;">
|
||||
<p style="margin:0 0 4px 0;font-size:16px;font-weight:600;color:#111827;">
|
||||
<a href="${detailUrl}" style="color:#111827;text-decoration:none;">${tool.name}</a>
|
||||
</p>
|
||||
<p style="margin:0 0 6px 0;font-size:13px;color:#6b7280;">${tool.category?.name || ""}</p>
|
||||
<p style="margin:0 0 10px 0;font-size:14px;color:#374151;line-height:1.5;">${desc}</p>
|
||||
<a href="${detailUrl}" style="display:inline-block;padding:6px 16px;font-size:13px;color:#ffffff;background-color:#3b82f6;border-radius:6px;text-decoration:none;">查看详情</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成完整 HTML 邮件
|
||||
*/
|
||||
function renderEmailHtml({ userName, weekNumber, dateStr, interestCategories, interestTools, trendingTools, newTools, unsubscribeUrl, baseUrl }) {
|
||||
const displayName = userName || "AI 探索者";
|
||||
const sortedCategories = (interestCategories || []).slice(0, 5);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>追光AI 周报 | 为你精选的 AI 工具</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans SC',sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f3f4f6;">
|
||||
<tr>
|
||||
<td align="center" style="padding:24px 16px;">
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;background-color:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background:linear-gradient(135deg,#3b82f6,#8b5cf6);padding:32px 24px;text-align:center;">
|
||||
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.8);">追光AI · 第 ${weekNumber} 周</p>
|
||||
<h1 style="margin:8px 0 0 0;font-size:24px;color:#ffffff;font-weight:700;">为你精选的 AI 工具</h1>
|
||||
<p style="margin:8px 0 0 0;font-size:14px;color:rgba(255,255,255,0.9);">${dateStr}</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Greeting -->
|
||||
<tr>
|
||||
<td style="padding:24px;">
|
||||
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;">
|
||||
${displayName},你好!
|
||||
</p>
|
||||
<p style="margin:8px 0 0 0;font-size:14px;color:#6b7280;line-height:1.6;">
|
||||
根据你的收藏和浏览历史,我们为你精心挑选了以下 AI 工具。以下是本周的个性化推荐:
|
||||
</p>
|
||||
|
||||
<!-- Interest Summary -->
|
||||
${sortedCategories.length > 0 ? `
|
||||
<p style="margin:16px 0 0 0;font-size:13px;color:#9ca3af;">
|
||||
你的兴趣偏好:${sortedCategories.map((c) => c.categoryName).join("、")}
|
||||
</p>
|
||||
` : ""}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Section 1: 基于你的兴趣推荐 -->
|
||||
<tr>
|
||||
<td style="padding:0 24px 8px 24px;">
|
||||
<h2 style="margin:0;font-size:18px;color:#111827;border-left:4px solid #3b82f6;padding-left:12px;">
|
||||
基于你的兴趣推荐
|
||||
</h2>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 24px 16px 24px;">
|
||||
${interestTools.length > 0
|
||||
? interestTools.map((t) => renderToolCard(t, baseUrl)).join("")
|
||||
: `<p style="margin:0;font-size:14px;color:#9ca3af;">暂无匹配的推荐工具</p>`}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Section 2: 本周热门工具 -->
|
||||
<tr>
|
||||
<td style="padding:0 24px 8px 24px;">
|
||||
<h2 style="margin:0;font-size:18px;color:#111827;border-left:4px solid #f59e0b;padding-left:12px;">
|
||||
本周热门工具
|
||||
</h2>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 24px 16px 24px;">
|
||||
${trendingTools.length > 0
|
||||
? trendingTools.map((t) => renderToolCard(t, baseUrl)).join("")
|
||||
: `<p style="margin:0;font-size:14px;color:#9ca3af;">暂无热门工具</p>`}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Section 3: 你可能错过的新工具 -->
|
||||
<tr>
|
||||
<td style="padding:0 24px 8px 24px;">
|
||||
<h2 style="margin:0;font-size:18px;color:#111827;border-left:4px solid #10b981;padding-left:12px;">
|
||||
你可能错过的新工具
|
||||
</h2>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 24px 16px 24px;">
|
||||
${newTools.length > 0
|
||||
? newTools.map((t) => renderToolCard(t, baseUrl)).join("")
|
||||
: `<p style="margin:0;font-size:14px;color:#9ca3af;">暂无新工具</p>`}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- CTA -->
|
||||
<tr>
|
||||
<td style="padding:16px 24px;text-align:center;">
|
||||
<a href="${baseUrl}/tools" style="display:inline-block;padding:12px 32px;font-size:15px;color:#ffffff;background-color:#3b82f6;border-radius:8px;text-decoration:none;font-weight:600;">
|
||||
探索更多 AI 工具
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Divider -->
|
||||
<tr>
|
||||
<td style="padding:0 24px;">
|
||||
<div style="border-top:1px solid #e5e7eb;"></div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding:20px 24px 32px 24px;text-align:center;">
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.8;">
|
||||
此邮件由追光AI自动生成<br>
|
||||
如有疑问,请访问 <a href="${baseUrl}" style="color:#3b82f6;">${baseUrl.replace("https://", "")}</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成纯文本邮件内容
|
||||
*/
|
||||
function renderTextContent({ userName, interestTools, trendingTools, newTools, baseUrl }) {
|
||||
const displayName = userName || "AI 探索者";
|
||||
let text = [
|
||||
`追光AI 周报 | 为你精选的 AI 工具`,
|
||||
``,
|
||||
`${displayName},你好!`,
|
||||
``,
|
||||
`根据你的收藏和浏览历史,我们为你精心挑选了以下 AI 工具:`,
|
||||
``,
|
||||
];
|
||||
|
||||
if (interestTools.length > 0) {
|
||||
text.push(`【基于你的兴趣推荐】`);
|
||||
interestTools.forEach((t, i) => {
|
||||
text.push(` ${i + 1}. ${t.name} - ${truncate(t.description, 60)}`);
|
||||
text.push(` 查看: ${baseUrl}/tools/${t.slug}`);
|
||||
});
|
||||
text.push(``);
|
||||
}
|
||||
|
||||
if (trendingTools.length > 0) {
|
||||
text.push(`【本周热门工具】`);
|
||||
trendingTools.forEach((t, i) => {
|
||||
text.push(` ${i + 1}. ${t.name} - ${truncate(t.description, 60)}`);
|
||||
text.push(` 查看: ${baseUrl}/tools/${t.slug}`);
|
||||
});
|
||||
text.push(``);
|
||||
}
|
||||
|
||||
if (newTools.length > 0) {
|
||||
text.push(`【你可能错过的新工具】`);
|
||||
newTools.forEach((t, i) => {
|
||||
text.push(` ${i + 1}. ${t.name} - ${truncate(t.description, 60)}`);
|
||||
text.push(` 查看: ${baseUrl}/tools/${t.slug}`);
|
||||
});
|
||||
text.push(``);
|
||||
}
|
||||
|
||||
text.push(`探索更多 AI 工具: ${baseUrl}/tools`);
|
||||
text.push(``);
|
||||
text.push(`此邮件由追光AI自动生成`);
|
||||
|
||||
return text.join("\n");
|
||||
}
|
||||
|
||||
// ─── 主流程 ───────────────────────────────────────────────────
|
||||
|
||||
async function generateRecommendationsForUser(user) {
|
||||
const userId = user.id;
|
||||
|
||||
// 1. 分析兴趣
|
||||
let interestCategories = [];
|
||||
try {
|
||||
interestCategories = await analyzeUserInterests(userId);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ 用户 ${user.email} 兴趣分析失败: ${err.message}`);
|
||||
interestCategories = [];
|
||||
}
|
||||
|
||||
// 2. 获取已交互的工具 ID(用于排除)
|
||||
const excludeIds = await getUserInteractedToolIds(userId);
|
||||
const excludeIdArray = [...excludeIds].filter(Boolean);
|
||||
|
||||
// 3. Section 1: 基于兴趣推荐
|
||||
let interestTools = [];
|
||||
if (interestCategories.length > 0) {
|
||||
interestTools = await recommendFromInterests(userId, interestCategories, excludeIdArray, 3);
|
||||
}
|
||||
|
||||
// 如果兴趣推荐不够 3 个,用热门工具补充
|
||||
if (interestTools.length < 3) {
|
||||
const existingIds = new Set([...excludeIdArray, ...interestTools.map((t) => t.id)]);
|
||||
const fillTools = await prisma.tool.findMany({
|
||||
where: {
|
||||
status: "published",
|
||||
id: { notIn: [...existingIds] },
|
||||
},
|
||||
include: { category: { select: { name: true, slug: true } } },
|
||||
orderBy: { viewCount: "desc" },
|
||||
take: 3 - interestTools.length,
|
||||
});
|
||||
interestTools = [...interestTools, ...fillTools];
|
||||
}
|
||||
|
||||
// 4. Section 2: 本周热门
|
||||
const trendingIds = new Set([...excludeIdArray, ...interestTools.map((t) => t.id)]);
|
||||
const trendingTools = await recommendTrendingTools([...trendingIds], 3);
|
||||
|
||||
// 5. Section 3: 新工具
|
||||
const newIds = new Set([...trendingIds, ...trendingTools.map((t) => t.id)]);
|
||||
const newTools = await recommendNewTools(userId, interestCategories, [...newIds], 2);
|
||||
|
||||
return {
|
||||
interestCategories: interestCategories.slice(0, 5),
|
||||
interestTools,
|
||||
trendingTools,
|
||||
newTools,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!process.env.DATABASE_URL) throw new Error("缺少环境变量: DATABASE_URL");
|
||||
|
||||
const startTime = new Date();
|
||||
const now = new Date();
|
||||
const weekNumber = getWeekNumber(now);
|
||||
const dateStr = now.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: "Asia/Shanghai",
|
||||
});
|
||||
const dateISO = now.toISOString().split("T")[0];
|
||||
const baseUrl = getBaseUrl();
|
||||
const unsubscribeUrl = `${baseUrl}/settings/notifications`;
|
||||
|
||||
console.log(`📧 开始生成第 ${weekNumber} 周个性化推荐邮件 (${dateStr})...`);
|
||||
console.log();
|
||||
|
||||
// 1. 查询目标用户:非 Bot、有邮箱、有活动(收藏/浏览/社区帖子)
|
||||
console.log("👥 第一步:查询目标用户...");
|
||||
|
||||
const usersWithActivity = await prisma.user.findMany({
|
||||
where: {
|
||||
isBot: false,
|
||||
email: { not: null, not: "" },
|
||||
OR: [
|
||||
{ favorites: { some: {} } },
|
||||
{ browsingHistory: { some: {} } },
|
||||
{ forumTopics: { some: {} } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(` 找到 ${usersWithActivity.length} 位活跃用户`);
|
||||
console.log();
|
||||
|
||||
if (usersWithActivity.length === 0) {
|
||||
console.log("⚠️ 没有符合条件的用户,跳过推荐生成");
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "weekly-recommend-email",
|
||||
taskName: "每周推荐邮件",
|
||||
status: "completed",
|
||||
startedAt: startTime,
|
||||
finishedAt: new Date(),
|
||||
duration: new Date().getTime() - startTime.getTime(),
|
||||
result: { userCount: 0, emailCount: 0 },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 为每个用户生成推荐
|
||||
console.log("🔍 第二步:为每个用户生成个性化推荐...");
|
||||
|
||||
const emailEntries = [];
|
||||
let successCount = 0;
|
||||
let skipCount = 0;
|
||||
|
||||
for (let i = 0; i < usersWithActivity.length; i++) {
|
||||
const user = usersWithActivity[i];
|
||||
const progress = `[${i + 1}/${usersWithActivity.length}]`;
|
||||
|
||||
console.log(` ${progress} 处理用户 ${user.email}...`);
|
||||
|
||||
try {
|
||||
const recs = await generateRecommendationsForUser(user);
|
||||
|
||||
// 跳过没有任何推荐内容的用户
|
||||
if (
|
||||
recs.interestTools.length === 0 &&
|
||||
recs.trendingTools.length === 0 &&
|
||||
recs.newTools.length === 0
|
||||
) {
|
||||
console.log(` ⚠ 无可用推荐,跳过`);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const htmlContent = renderEmailHtml({
|
||||
userName: user.name,
|
||||
weekNumber,
|
||||
dateStr,
|
||||
interestCategories: recs.interestCategories,
|
||||
interestTools: recs.interestTools,
|
||||
trendingTools: recs.trendingTools,
|
||||
newTools: recs.newTools,
|
||||
unsubscribeUrl,
|
||||
baseUrl,
|
||||
});
|
||||
|
||||
const textContent = renderTextContent({
|
||||
userName: user.name,
|
||||
interestTools: recs.interestTools,
|
||||
trendingTools: recs.trendingTools,
|
||||
newTools: recs.newTools,
|
||||
baseUrl,
|
||||
});
|
||||
|
||||
emailEntries.push({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
subject: `追光AI 周报 | 为你精选的 AI 工具`,
|
||||
htmlContent,
|
||||
textContent,
|
||||
interestCategories: recs.interestCategories.map((c) => ({
|
||||
name: c.categoryName,
|
||||
score: c.score,
|
||||
sources: c.sources,
|
||||
})),
|
||||
sections: {
|
||||
interest: recs.interestTools.map((t) => ({ id: t.id, name: t.name, slug: t.slug })),
|
||||
trending: recs.trendingTools.map((t) => ({ id: t.id, name: t.name, slug: t.slug })),
|
||||
new: recs.newTools.map((t) => ({ id: t.id, name: t.name, slug: t.slug })),
|
||||
},
|
||||
});
|
||||
|
||||
const sectionCounts = [
|
||||
recs.interestTools.length > 0 ? `兴趣${recs.interestTools.length}` : "",
|
||||
recs.trendingTools.length > 0 ? `热门${recs.trendingTools.length}` : "",
|
||||
recs.newTools.length > 0 ? `新工具${recs.newTools.length}` : "",
|
||||
].filter(Boolean).join("+");
|
||||
|
||||
console.log(` ✅ 推荐: ${sectionCounts} | 兴趣: ${recs.interestCategories.slice(0, 3).map((c) => c.categoryName).join(", ") || "无"}`);
|
||||
successCount++;
|
||||
} catch (err) {
|
||||
console.error(` ❌ 处理失败: ${err.message}`);
|
||||
skipCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(` ✅ 成功生成 ${successCount} 封推荐邮件 (跳过 ${skipCount} 人)`);
|
||||
|
||||
// 3. 输出到 JSON 文件
|
||||
console.log();
|
||||
console.log("💾 第三步:写入 JSON 文件...");
|
||||
|
||||
const outputDir = path.resolve(__dirname, "..", "data", "emails");
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
const outputFile = path.join(outputDir, `weekly-recommend-${dateISO}.json`);
|
||||
const outputData = {
|
||||
generatedAt: startTime.toISOString(),
|
||||
weekNumber,
|
||||
dateStr,
|
||||
dateISO,
|
||||
totalUsers: usersWithActivity.length,
|
||||
successCount,
|
||||
skipCount,
|
||||
entries: emailEntries,
|
||||
};
|
||||
|
||||
fs.writeFileSync(outputFile, JSON.stringify(outputData, null, 2), "utf-8");
|
||||
console.log(` 文件: ${outputFile}`);
|
||||
console.log(` 大小: ${(fs.statSync(outputFile).size / 1024).toFixed(1)} KB`);
|
||||
|
||||
// 4. 记录 TaskLog
|
||||
const endTime = new Date();
|
||||
const duration = endTime.getTime() - startTime.getTime();
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "weekly-recommend-email",
|
||||
taskName: "每周推荐邮件",
|
||||
status: "completed",
|
||||
startedAt: startTime,
|
||||
finishedAt: endTime,
|
||||
duration,
|
||||
result: {
|
||||
userCount: usersWithActivity.length,
|
||||
emailCount: successCount,
|
||||
skipCount,
|
||||
outputFile: `data/emails/weekly-recommend-${dateISO}.json`,
|
||||
weekNumber,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log();
|
||||
console.log(`🎉 每周推荐邮件生成完成!`);
|
||||
console.log(` 周数: 第 ${weekNumber} 周`);
|
||||
console.log(` 用户数: ${usersWithActivity.length}`);
|
||||
console.log(` 成功: ${successCount}`);
|
||||
console.log(` 跳过: ${skipCount}`);
|
||||
console.log(` 耗时: ${(duration / 1000).toFixed(1)}s`);
|
||||
|
||||
return { userCount: usersWithActivity.length, successCount, skipCount };
|
||||
}
|
||||
|
||||
// ─── Entry ─────────────────────────────────────────────────────
|
||||
main()
|
||||
.then(() => {
|
||||
console.log("Done");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(async (error) => {
|
||||
console.error("❌ 每周推荐邮件生成失败:", error.message);
|
||||
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "weekly-recommend-email",
|
||||
taskName: "每周推荐邮件",
|
||||
status: "failed",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
duration: 0,
|
||||
error: error.message,
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
await disconnectRedis();
|
||||
});
|
||||
Reference in New Issue
Block a user