Browse Source

feat: add email configuration management for admin

- Introduced new API endpoints for managing SMTP email configuration (GET, PUT, POST).
- Implemented email sending functionality using Nodemailer.
- Added middleware to require admin permissions for accessing admin routes.
- Updated admin sidebar navigation to conditionally display items based on admin role.
- Created a new email configuration page in the admin panel with form validation and test functionality.
- Masked sensitive information in API responses.
- Enhanced scheduler execution and task management APIs to require admin permissions.
- Added a new JSON file for desktop topic titles.
acas
npmrun 3 months ago
parent
commit
1460246ab0
  1. 3
      .reasonix/desktop-topic-title-sources.json
  2. 3
      .reasonix/desktop-topic-titles.json
  3. 1
      app/components/admin/AdminSidebarNav.vue
  4. 12
      app/middleware/auth.global.ts
  5. 12
      app/pages/admin.vue
  6. 24
      app/pages/admin/dashboard.vue
  7. 396
      app/pages/admin/email/index.vue
  8. 6
      bun.lock
  9. 2
      package.json
  10. BIN
      packages/drizzle-pkg/db.sqlite
  11. 12
      server/api/admin/email/config.get.ts
  12. 47
      server/api/admin/email/config.put.ts
  13. 22
      server/api/admin/email/send.post.ts
  14. 12
      server/api/admin/email/test.post.ts
  15. 2
      server/api/config/global.get.ts
  16. 2
      server/api/config/global.put.ts
  17. 2
      server/api/scheduler/executions.get.ts
  18. 2
      server/api/scheduler/executions/[id].delete.ts
  19. 2
      server/api/scheduler/executions/delete-all.post.ts
  20. 2
      server/api/scheduler/stats.get.ts
  21. 2
      server/api/scheduler/tasks/[id].delete.ts
  22. 2
      server/api/scheduler/tasks/[id].get.ts
  23. 2
      server/api/scheduler/tasks/[id].put.ts
  24. 2
      server/api/scheduler/tasks/[id]/toggle.post.ts
  25. 2
      server/api/scheduler/tasks/[id]/trigger.post.ts
  26. 2
      server/api/scheduler/tasks/index.get.ts
  27. 2
      server/api/scheduler/tasks/index.post.ts
  28. 2
      server/api/users/[id].delete.ts
  29. 2
      server/api/users/[id].put.ts
  30. 2
      server/api/users/index.get.ts
  31. 2
      server/api/users/index.post.ts
  32. 8
      server/service/config/normalization.ts
  33. 47
      server/service/config/registry.ts
  34. 89
      server/service/email/index.ts

3
.reasonix/desktop-topic-title-sources.json

@ -0,0 +1,3 @@
{
"topic_20260626-113733_9874ba9ab889b7ed": "manual"
}

3
.reasonix/desktop-topic-titles.json

@ -0,0 +1,3 @@
{
"topic_20260626-113733_9874ba9ab889b7ed": "新的会话"
}

1
app/components/admin/AdminSidebarNav.vue

@ -4,6 +4,7 @@ export interface NavItem {
to?: string
icon?: string
children?: NavItem[]
requireAdmin?: boolean
}
const props = defineProps<{

12
app/middleware/auth.global.ts

@ -26,6 +26,18 @@ export default defineNuxtRouteMiddleware(async (to) => {
const currentFullPath = to.fullPath;
const isLoggedIn = loggedIn.value;
// 管理员路由守卫:除仪表板外 /admin/* 需要管理员权限
const isAdminRoute = currentPath.startsWith("/admin");
const isDashboardOnly = currentPath === "/admin" || currentPath === "/admin/dashboard";
if (isLoggedIn && isAdminRoute && !isDashboardOnly) {
const { user } = useAuthSession();
if (user.value?.role !== "admin") {
const { $toast } = useNuxtApp();
$toast?.warning("需要管理员权限");
return navigateTo("/");
}
}
if (!isLoggedIn && !isPublicRoute(currentPath)) {
return navigateTo({
path: FRONTEND_LOGIN_PATH,

12
app/pages/admin.vue

@ -15,17 +15,25 @@ const adminNav: NavItem[] = [
{
label: '定时任务',
to: '/admin/scheduler',
icon: 'lucide:clock'
icon: 'lucide:clock',
requireAdmin: true,
},
{
label: '系统管理',
icon: 'lucide:settings',
requireAdmin: true,
children: [
{ label: '用户管理', to: '/admin/users' },
{ label: '邮件配置', to: '/admin/email' },
]
},
]
const visibleNav = computed(() => {
const isAdmin = user.value?.role === 'admin'
return adminNav.filter(item => !item.requireAdmin || isAdmin)
})
const logout = async () => {
await clear()
navigateTo("/")
@ -45,7 +53,7 @@ const logout = async () => {
</NuxtLink>
</div>
<AdminSidebarNav :nav="adminNav" />
<AdminSidebarNav :nav="visibleNav" />
<div class="sidebar-footer">
<NuxtLink to="/" class="home-link">

24
app/pages/admin/dashboard.vue

@ -1,17 +1,21 @@
<script setup lang="ts">
const { user } = useAuthSession()
const isAdmin = computed(() => user.value?.role === 'admin')
const stats = await useHttpFetch("/api/scheduler/stats")
const statsData = computed(() => (stats.data.value ?? {}) as {
const stats = isAdmin.value ? await useHttpFetch("/api/scheduler/stats") : null
const statsData = computed(() => (stats?.data?.value ?? {}) as {
totalTasks: number
enabledTasks: number
activeJobs: number
last24hExecutions: number
})
const quickActions = [
{ label: '查看定时任务', to: '/admin/scheduler', icon: 'schedule', desc: '管理系统定时任务' },
]
const quickActions = computed(() => {
if (!isAdmin.value) return []
return [
{ label: '查看定时任务', to: '/admin/scheduler', icon: 'schedule', desc: '管理系统定时任务' },
]
})
</script>
<template>
@ -19,12 +23,12 @@ const quickActions = [
<header class="page-header">
<div class="welcome-section">
<h1 class="page-title">欢迎回来,{{ user?.username || '管理员' }}</h1>
<p class="page-subtitle">以下是今日任务概况</p>
<p v-if="isAdmin" class="page-subtitle">以下是今日任务概况</p>
</div>
</header>
<!-- Quick stats -->
<div class="stats-grid">
<!-- Quick stats (admin only) -->
<div v-if="isAdmin" class="stats-grid">
<div class="stat-card">
<div class="stat-icon stat-icon-total">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@ -74,8 +78,8 @@ const quickActions = [
</div>
</div>
<!-- Quick actions -->
<section class="quick-actions-section">
<!-- Quick actions (admin only) -->
<section v-if="isAdmin && quickActions.length" class="quick-actions-section">
<h2 class="section-title">快捷操作</h2>
<div class="actions-grid">
<NuxtLink v-for="action in quickActions" :key="action.to" :to="action.to" class="action-card">

396
app/pages/admin/email/index.vue

@ -0,0 +1,396 @@
<script setup lang="ts">
definePageMeta({
layout: false,
})
const { $toast } = useNuxtApp()
const form = reactive({
host: "",
port: 587,
user: "",
pass: "",
from: "",
secure: false,
})
const testTo = ref("")
const loading = ref(false)
const testing = ref(false)
const sending = ref(false)
const testResult = ref<{ success: boolean; message: string } | null>(null)
async function loadConfig() {
try {
const res = await $fetch<{ code: number; data: { host: string; port: number; user: string; pass: string; from: string; secure: boolean } }>("/api/admin/email/config")
if (res.code === 0 && res.data) {
form.host = res.data.host || ""
form.port = res.data.port || 587
form.user = res.data.user || ""
form.pass = res.data.pass || ""
form.from = res.data.from || ""
form.secure = res.data.secure || false
}
} catch (_e) {
// silently fail on load
}
}
async function handleSave() {
loading.value = true
try {
await $fetch("/api/admin/email/config", {
method: "PUT",
body: {
host: form.host,
port: form.port,
user: form.user,
pass: form.pass,
from: form.from,
secure: form.secure,
},
})
$toast.success("邮件配置已保存")
} catch (e: any) {
$toast.error(e?.data?.message || e?.message || "保存失败")
} finally {
loading.value = false
}
}
async function handleTestConnection() {
testing.value = true
testResult.value = null
try {
const res = await $fetch<{ code: number; data: { success: boolean; message: string }; message?: string }>("/api/admin/email/test", { method: "POST" })
testResult.value = res.code === 0 ? res.data : { success: false, message: res.message || "测试失败" }
if (testResult.value?.success) {
$toast.success("SMTP 连接测试成功")
} else {
$toast.error(testResult.value?.message || "连接测试失败")
}
} catch (e: any) {
const msg = e?.data?.message || e?.message || "测试请求失败"
testResult.value = { success: false, message: msg }
$toast.error(msg)
} finally {
testing.value = false
}
}
async function handleSendTest() {
if (!testTo.value) {
$toast.warning("请输入测试收件地址")
return
}
sending.value = true
try {
const res = await $fetch<{ code: number; data: { success: boolean; message: string }; message?: string }>("/api/admin/email/send", {
method: "POST",
body: { to: testTo.value },
})
if (res.code === 0 && res.data?.success) {
$toast.success("测试邮件已发送")
} else {
$toast.error(res?.data?.message || res?.message || "发送失败")
}
} catch (e: any) {
$toast.error(e?.data?.message || e?.message || "发送失败")
} finally {
sending.value = false
}
}
onMounted(() => {
loadConfig()
})
</script>
<template>
<div class="email-page">
<header class="page-header">
<h1 class="page-title">邮件配置</h1>
<p class="page-subtitle">配置 SMTP 服务器,启用系统邮件发送功能</p>
</header>
<div class="email-card">
<h2 class="section-title">SMTP 服务器设置</h2>
<form class="email-form" @submit.prevent="handleSave">
<div class="form-row">
<div class="form-group">
<label class="form-label">SMTP 服务器地址</label>
<input
v-model="form.host"
type="text"
class="form-input"
placeholder="例如 smtp.example.com"
/>
</div>
<div class="form-group">
<label class="form-label">端口</label>
<input
v-model.number="form.port"
type="number"
class="form-input"
placeholder="587"
min="1"
max="65535"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">用户名</label>
<input
v-model="form.user"
type="text"
class="form-input"
placeholder="SMTP 用户名"
/>
</div>
<div class="form-group">
<label class="form-label">密码</label>
<input
v-model="form.pass"
type="password"
class="form-input"
placeholder="SMTP 密码"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">发件人地址</label>
<input
v-model="form.from"
type="email"
class="form-input"
placeholder="例如 noreply@example.com"
/>
</div>
<div class="form-group form-group-check">
<label class="form-label">&nbsp;</label>
<label class="form-check-label">
<input v-model="form.secure" type="checkbox" class="form-checkbox" />
<span>使用 SSL/TLS 加密连接</span>
</label>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" :disabled="loading">
{{ loading ? "保存中..." : "保存配置" }}
</button>
<button type="button" class="btn-outline" :disabled="testing" @click="handleTestConnection">
{{ testing ? "测试中..." : "测试连接" }}
</button>
</div>
</form>
</div>
<div class="email-card">
<h2 class="section-title">发送测试邮件</h2>
<p class="section-desc">使用当前 SMTP 配置发送一封测试邮件,验证邮件发送功能是否正常。</p>
<div class="test-send-row">
<input
v-model="testTo"
type="email"
class="form-input test-input"
placeholder="输入收件人邮箱地址"
/>
<button class="btn-primary" :disabled="sending" @click="handleSendTest">
{{ sending ? "发送中..." : "发送测试邮件" }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.email-page {
padding: 40px;
max-width: 800px;
}
.page-header {
margin-bottom: 32px;
}
.page-title {
font-family: var(--font-display);
font-size: 32px;
font-weight: 400;
color: var(--color-ink);
letter-spacing: -0.3px;
margin: 0 0 8px 0;
}
.page-subtitle {
font-size: 15px;
color: var(--color-muted);
margin: 0;
}
.email-card {
background: var(--color-surface-card);
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
}
.section-title {
font-size: 12px;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 20px 0;
}
.section-desc {
font-size: 14px;
color: var(--color-muted);
margin: 0 0 16px 0;
}
.email-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-label {
font-size: 14px;
font-weight: 500;
color: var(--color-body-strong);
}
.form-input {
width: 100%;
padding: 12px 16px;
font-size: 14px;
color: var(--color-ink);
background: var(--color-canvas);
border: 1px solid var(--color-hairline);
border-radius: 8px;
transition: all 0.15s ease;
}
.form-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.15);
}
.form-group-check {
justify-content: flex-end;
}
.form-check-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--color-ink);
cursor: pointer;
}
.form-checkbox {
width: 18px;
height: 18px;
accent-color: var(--color-primary);
cursor: pointer;
}
.form-actions {
display: flex;
gap: 12px;
padding-top: 8px;
}
.btn-primary {
padding: 12px 24px;
font-size: 14px;
font-weight: 500;
color: var(--color-on-primary);
background: var(--color-primary);
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease;
}
.btn-primary:hover {
background: var(--color-primary-active);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-outline {
padding: 12px 24px;
font-size: 14px;
font-weight: 500;
color: var(--color-ink);
background: transparent;
border: 1px solid var(--color-hairline);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-outline:hover {
background: var(--color-surface-soft);
}
.btn-outline:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.test-send-row {
display: flex;
gap: 12px;
align-items: center;
}
.test-input {
flex: 1;
}
@media (max-width: 768px) {
.email-page {
padding: 24px;
}
.form-row {
grid-template-columns: 1fr;
}
.test-send-row {
flex-direction: column;
align-items: stretch;
}
}
</style>

6
bun.lock

@ -28,6 +28,7 @@
"marked": "12.0.2",
"mime": "4.1.0",
"multer": "2.1.1",
"nodemailer": "6.9.16",
"nuxt": "4.4.6",
"svg-captcha": "1.4.0",
"tailwindcss": "4.3.0",
@ -41,6 +42,7 @@
"@tailwindcss/vite": "4.3.0",
"@types/multer": "2.1.0",
"@types/node": "25.8.0",
"@types/nodemailer": "^8.0.1",
"drizzle-kit": "0.31.10",
"sass-embedded": "1.100.0",
"tsconfig": "workspace:*",
@ -628,6 +630,8 @@
"@types/node": ["@types/node@25.8.0", "https://registry.npmmirror.com/@types/node/-/node-25.8.0.tgz", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
"@types/nodemailer": ["@types/nodemailer@8.0.1", "https://registry.npmmirror.com/@types/nodemailer/-/nodemailer-8.0.1.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw=="],
"@types/parse5": ["@types/parse5@6.0.3", "https://registry.npmmirror.com/@types/parse5/-/parse5-6.0.3.tgz", {}, "sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g=="],
"@types/pg": ["@types/pg@8.20.0", "https://registry.npmmirror.com/@types/pg/-/pg-8.20.0.tgz", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
@ -1416,6 +1420,8 @@
"node-schedule": ["node-schedule@2.1.1", "https://registry.npmmirror.com/node-schedule/-/node-schedule-2.1.1.tgz", { "dependencies": { "cron-parser": "^4.2.0", "long-timeout": "0.1.1", "sorted-array-functions": "^1.3.0" } }, "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ=="],
"nodemailer": ["nodemailer@6.9.16", "https://registry.npmmirror.com/nodemailer/-/nodemailer-6.9.16.tgz", {}, "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ=="],
"nopt": ["nopt@8.1.0", "https://registry.npmmirror.com/nopt/-/nopt-8.1.0.tgz", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
"normalize-path": ["normalize-path@3.0.0", "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],

2
package.json

@ -41,6 +41,7 @@
"logger": "workspace:*",
"marked": "12.0.2",
"mime": "4.1.0",
"nodemailer": "6.9.16",
"multer": "2.1.1",
"nuxt": "4.4.6",
"svg-captcha": "1.4.0",
@ -55,6 +56,7 @@
"@tailwindcss/vite": "4.3.0",
"@types/multer": "2.1.0",
"@types/node": "25.8.0",
"@types/nodemailer": "^8.0.1",
"drizzle-kit": "0.31.10",
"sass-embedded": "1.100.0",
"tsconfig": "workspace:*",

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

12
server/api/admin/email/config.get.ts

@ -0,0 +1,12 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { getSmtpConfig } from "#server/service/email";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const config = await getSmtpConfig();
// Mask password
return R.success({
...config,
pass: config.pass ? "" : "",
});
});

47
server/api/admin/email/config.put.ts

@ -0,0 +1,47 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { setGlobalConfigValue } from "#server/service/config";
import { delCache } from "#server/utils/context";
interface EmailConfigBody {
host?: string;
port?: number;
user?: string;
pass?: string;
from?: string;
secure?: boolean;
}
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const body = await readBody<EmailConfigBody>(event);
const updates: Promise<void>[] = [];
if (body.host !== undefined) {
updates.push(setGlobalConfigValue("smtpHost", body.host));
}
if (body.port !== undefined) {
updates.push(setGlobalConfigValue("smtpPort", body.port));
}
if (body.user !== undefined) {
updates.push(setGlobalConfigValue("smtpUser", body.user));
}
if (body.pass !== undefined && body.pass !== "") {
// Only update password if a new non-empty value is provided
updates.push(setGlobalConfigValue("smtpPass", body.pass));
}
if (body.from !== undefined) {
updates.push(setGlobalConfigValue("smtpFrom", body.from));
}
if (body.secure !== undefined) {
updates.push(setGlobalConfigValue("smtpSecure", body.secure));
}
await Promise.all(updates);
// Invalidate config caches
await delCache("config:global:public");
await delCache("config:global:admin");
return R.success({ message: "邮件配置已保存" });
});

22
server/api/admin/email/send.post.ts

@ -0,0 +1,22 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { sendMail } from "#server/service/email";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const body = await readBody<{ to: string; subject?: string; text?: string }>(event);
if (!body.to) {
throw createError({ statusCode: 400, statusMessage: "收件人地址不能为空" });
}
const result = await sendMail({
to: body.to,
subject: body.subject || "测试邮件 - Person Panel",
text: body.text || "这是一封来自 Person Panel 的测试邮件。如果你收到此邮件,说明邮件配置正确。",
});
if (result.success) {
return R.success(result);
}
throw createError({ statusCode: 400, statusMessage: result.message });
});

12
server/api/admin/email/test.post.ts

@ -0,0 +1,12 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { getSmtpConfig, testSmtpConnection } from "#server/service/email";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const config = await getSmtpConfig();
const result = await testSmtpConnection(config);
if (result.success) {
return R.success(result);
}
throw createError({ statusCode: 400, statusMessage: result.message });
});

2
server/api/config/global.get.ts

@ -6,7 +6,7 @@ const PUBLIC_GLOBAL_CONFIG_KEYS = [
"siteName",
"allowRegister",
] as const satisfies readonly KnownConfigKey[];
const SECRET_MASKED_GLOBAL_CONFIG_KEYS = new Set<KnownConfigKey>([]);
const SECRET_MASKED_GLOBAL_CONFIG_KEYS = new Set<KnownConfigKey>(["smtpPass"]);
export default defineWrappedResponseHandler(async (event) => {
const user = await getCurrentUser(event);

2
server/api/config/global.put.ts

@ -14,7 +14,7 @@ type UpdateGlobalConfigBody = {
};
function toSafeResponseValue(key: string, value: unknown) {
if (key === "commentSmtpPass") {
if (key === "smtpPass") {
return "";
}
return value;

2
server/api/scheduler/executions.get.ts

@ -1,7 +1,9 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { getCache, setCache } from "#server/utils/context";
import { listExecutions } from "../../service/scheduler";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const query = getQuery(event);
const page = query.page ? Number(query.page) : 1;
const pageSize = query.pageSize ? Number(query.pageSize) : 20;

2
server/api/scheduler/executions/[id].delete.ts

@ -1,7 +1,9 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { delCache } from "#server/utils/context";
import { deleteExecution } from "#server/service/scheduler";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const id = getRouterParam(event, "id");
if (!id) return R.throwError(400, "Missing id", null);

2
server/api/scheduler/executions/delete-all.post.ts

@ -1,7 +1,9 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { delCache } from "#server/utils/context";
import { deleteAllExecutions } from "#server/service/scheduler";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const query = getQuery(event);
const taskId = query.taskId as string | undefined;

2
server/api/scheduler/stats.get.ts

@ -1,8 +1,10 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { getCache, setCache } from "#server/utils/context";
import { getStats } from "../../service/scheduler";
import { getJobCount } from "../../scheduler/engine";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const cacheKey = 'scheduler:stats'
const cached = await getCache<{ totalTasks: number, enabledTasks: number, last24hExecutions: number, activeJobs: number }>(cacheKey)
if (cached) return R.success({ ...cached, activeJobs: getJobCount() })

2
server/api/scheduler/tasks/[id].delete.ts

@ -1,8 +1,10 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { delCache } from "#server/utils/context";
import { deleteTask } from "../../../service/scheduler";
import { removeTask } from "../../../scheduler/engine";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const id = getRouterParam(event, "id");
if (!id) return R.throwError(400, "Missing id", null);

2
server/api/scheduler/tasks/[id].get.ts

@ -1,7 +1,9 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { getCache, setCache } from "#server/utils/context";
import { getTaskById, getRecentExecutions } from "../../../service/scheduler";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const id = getRouterParam(event, "id");
if (!id) return R.throwError(400, "Missing id", null);

2
server/api/scheduler/tasks/[id].put.ts

@ -3,6 +3,7 @@ import { z } from "zod";
import { updateTask, getTaskById } from "../../../service/scheduler";
import { reloadTask } from "../../../scheduler/engine";
import { Cron } from "croner";
import { requireAdmin } from "#server/utils/admin-guard";
const updateTaskSchema = z.object({
name: z.string().min(1).optional(),
@ -22,6 +23,7 @@ const updateTaskSchema = z.object({
});
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const id = getRouterParam(event, "id");
if (!id) return R.throwError(400, "Missing id", null);

2
server/api/scheduler/tasks/[id]/toggle.post.ts

@ -1,8 +1,10 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { delCache } from "#server/utils/context";
import { toggleTask } from "../../../../service/scheduler";
import { reloadTask, removeTask } from "../../../../scheduler/engine";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const id = getRouterParam(event, "id");
if (!id) return R.throwError(400, "Missing id", null);

2
server/api/scheduler/tasks/[id]/trigger.post.ts

@ -1,6 +1,8 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { triggerTask } from "../../../../scheduler/engine";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const id = getRouterParam(event, "id");
if (!id) return R.throwError(400, "Missing id", null);

2
server/api/scheduler/tasks/index.get.ts

@ -1,8 +1,10 @@
import { requireAdmin } from "#server/utils/admin-guard";
import { getCache, setCache } from "#server/utils/context";
import { listTasks } from "../../../service/scheduler";
import { listRegisteredTasks } from "../../../scheduler/registry";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const query = getQuery(event);
const page = query.page ? Number(query.page) : 1;
const pageSize = query.pageSize ? Number(query.pageSize) : 20;

2
server/api/scheduler/tasks/index.post.ts

@ -3,6 +3,7 @@ import { z } from "zod";
import { createTask } from "../../../service/scheduler";
import { addTask } from "../../../scheduler/engine";
import { Cron } from "croner";
import { requireAdmin } from "#server/utils/admin-guard";
const createTaskSchema = z.object({
name: z.string().min(1),
@ -22,6 +23,7 @@ const createTaskSchema = z.object({
});
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const body = await readBody(event);
const parsed = createTaskSchema.safeParse(body);

2
server/api/users/[id].delete.ts

@ -2,10 +2,12 @@ import { dbGlobal } from "drizzle-pkg/lib/db";
import { users, sessions } from "drizzle-pkg/lib/schema/auth";
import { eq } from "drizzle-orm";
import log4js from "logger";
import { requireAdmin } from "#server/utils/admin-guard";
const logger = log4js.getLogger("USERS");
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const id = Number(event.context.params?.id);
if (!id || isNaN(id)) {

2
server/api/users/[id].put.ts

@ -9,7 +9,7 @@ const logger = log4js.getLogger("USERS");
export default defineWrappedResponseHandler(async (event) => {
const id = Number(event.context.params?.id);
const body = await readBody(event);
requireAdmin(event);
await requireAdmin(event);
if (!id || isNaN(id)) {
throw createError({

2
server/api/users/index.get.ts

@ -1,8 +1,10 @@
import { dbGlobal } from "drizzle-pkg/lib/db";
import { users } from "drizzle-pkg/lib/schema/auth";
import { and, count, desc, eq, like, or, asc } from "drizzle-orm";
import { requireAdmin } from "#server/utils/admin-guard";
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const query = getQuery(event);
const page = query.page ? Number(query.page) : 1;
const pageSize = query.pageSize ? Number(query.pageSize) : 10;

2
server/api/users/index.post.ts

@ -2,11 +2,13 @@ import { hash } from "bcryptjs";
import { dbGlobal } from "drizzle-pkg/lib/db";
import { users } from "drizzle-pkg/lib/schema/auth";
import { isUniqueConflictOnField } from "#server/utils/db-unique-constraint";
import { requireAdmin } from "#server/utils/admin-guard";
import log4js from "logger";
const logger = log4js.getLogger("USERS");
export default defineWrappedResponseHandler(async (event) => {
await requireAdmin(event);
const body = await readBody(event);
if (!body?.username || !body?.password) {

8
server/service/config/normalization.ts

@ -2,10 +2,10 @@ import type { KnownConfigKey } from "./registry";
const TRIMMABLE_GLOBAL_CONFIG_KEYS = [
"siteName",
"commentMailFromEmail",
"commentSmtpHost",
"commentSmtpUser",
"commentSmtpPass",
"smtpHost",
"smtpUser",
"smtpPass",
"smtpFrom",
] as const;
export function normalizeGlobalConfigValue<K extends KnownConfigKey>(key: K, value: unknown): unknown {

47
server/service/config/registry.ts

@ -47,6 +47,53 @@ const CONFIG_REGISTRY = {
userOverridable: true,
validate: (value: string) => ["light", "dark", "system"].includes(value),
}),
smtpHost: defineConfig<string>({
key: "smtpHost",
scope: "global",
valueType: "string",
defaultValue: "",
userOverridable: false,
validate: (value: string) => value.length <= 256,
}),
smtpPort: defineConfig<number>({
key: "smtpPort",
scope: "global",
valueType: "number",
defaultValue: 587,
userOverridable: false,
validate: (value: number) => Number.isInteger(value) && value >= 1 && value <= 65535,
}),
smtpUser: defineConfig<string>({
key: "smtpUser",
scope: "global",
valueType: "string",
defaultValue: "",
userOverridable: false,
validate: (value: string) => value.length <= 256,
}),
smtpPass: defineConfig<string>({
key: "smtpPass",
scope: "global",
valueType: "string",
defaultValue: "",
userOverridable: false,
validate: (value: string) => value.length <= 256,
}),
smtpFrom: defineConfig<string>({
key: "smtpFrom",
scope: "global",
valueType: "string",
defaultValue: "",
userOverridable: false,
validate: (value: string) => value.length <= 256,
}),
smtpSecure: defineConfig<boolean>({
key: "smtpSecure",
scope: "global",
valueType: "boolean",
defaultValue: false,
userOverridable: false,
}),
} as const;
export type KnownConfigKey = keyof typeof CONFIG_REGISTRY;

89
server/service/email/index.ts

@ -0,0 +1,89 @@
import nodemailer from "nodemailer";
import type { Transporter } from "nodemailer";
import { getGlobalConfigValue } from "#server/service/config";
import log4js from "logger";
const logger = log4js.getLogger("email");
export interface SmtpConfig {
host: string;
port: number;
user: string;
pass: string;
from: string;
secure: boolean;
}
export async function getSmtpConfig(): Promise<SmtpConfig> {
const [host, port, user, pass, from, secure] = await Promise.all([
getGlobalConfigValue("smtpHost"),
getGlobalConfigValue("smtpPort"),
getGlobalConfigValue("smtpUser"),
getGlobalConfigValue("smtpPass"),
getGlobalConfigValue("smtpFrom"),
getGlobalConfigValue("smtpSecure"),
]);
return { host, port, user, pass, from, secure };
}
export function createTransporter(config: SmtpConfig): Transporter {
return nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.user
? { user: config.user, pass: config.pass }
: undefined,
});
}
export async function testSmtpConnection(config: SmtpConfig): Promise<{ success: boolean; message: string }> {
if (!config.host || !config.port) {
return { success: false, message: "SMTP 服务器地址和端口未配置" };
}
try {
const transporter = createTransporter(config);
await transporter.verify();
return { success: true, message: "SMTP 连接测试成功" };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "未知错误";
logger.error("SMTP connection test failed: %s", message);
return { success: false, message: `SMTP 连接失败: ${message}` };
}
}
export interface SendMailOptions {
to: string | string[];
subject: string;
text?: string;
html?: string;
}
export async function sendMail(options: SendMailOptions): Promise<{ success: boolean; message: string }> {
const config = await getSmtpConfig();
if (!config.host || !config.port) {
return { success: false, message: "SMTP 服务器未配置,请先在系统设置中配置邮件服务" };
}
if (!config.from) {
return { success: false, message: "发件人地址未配置" };
}
try {
const transporter = createTransporter(config);
const info = await transporter.sendMail({
from: config.from,
to: Array.isArray(options.to) ? options.to.join(", ") : options.to,
subject: options.subject,
text: options.text,
html: options.html,
});
logger.info("Email sent to %s, messageId: %s", options.to, info.messageId);
return { success: true, message: "邮件发送成功" };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "未知错误";
logger.error("Failed to send email to %s: %s", options.to, message);
return { success: false, message: `邮件发送失败: ${message}` };
}
}
Loading…
Cancel
Save