feat: 首次推送到Gitea - 完整项目代码 + 安全加固 + 知识库

This commit is contained in:
ZhuiGuangAI Dev
2026-10-02 18:52:49 +08:00
parent b44601bb66
commit 8205ae709c
1185 changed files with 49841 additions and 12802 deletions
+762
View File
@@ -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();
});