From 2b01a16bf189d5c615a37db03b4903af19bda675 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Thu, 12 Feb 2026 09:42:22 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E4=B8=80=E8=A8=80=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=BB=8E=E4=B8=80=E8=A8=80=20API=20=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=E5=90=8D=E8=A8=80=E5=B9=B6=E6=8F=92=E5=85=A5?= =?UTF-8?q?=E9=82=AE=E4=BB=B6=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/comment/comment.email.default.ts | 3 + .../src/modules/comment/comment.service.ts | 5 + .../src/modules/configs/configs.default.ts | 4 + .../src/modules/configs/configs.schema.ts | 9 ++ .../subscribe/subscribe.email.default.ts | 3 + .../modules/subscribe/subscribe.service.ts | 4 + .../processors/helper/helper.email.service.ts | 12 ++ .../helper/helper.hitokoto.service.ts | 69 ++++++++ .../src/processors/helper/helper.module.ts | 2 + apps/core/test/mock/processors/email.mock.ts | 9 ++ .../test/mock/processors/hitokoto.mock.ts | 15 ++ docs/email-hitokoto.md | 152 ++++++++++++++++++ 12 files changed, 287 insertions(+) create mode 100644 apps/core/src/processors/helper/helper.hitokoto.service.ts create mode 100644 apps/core/test/mock/processors/hitokoto.mock.ts create mode 100644 docs/email-hitokoto.md diff --git a/apps/core/src/modules/comment/comment.email.default.ts b/apps/core/src/modules/comment/comment.email.default.ts index 9525a80f338..d679fae21d8 100644 --- a/apps/core/src/modules/comment/comment.email.default.ts +++ b/apps/core/src/modules/comment/comment.email.default.ts @@ -1,3 +1,4 @@ +import type { HitokotoData } from '~/processors/helper/helper.hitokoto.service' import dayjs from 'dayjs' import type { OwnerModel, OwnerModelSecurityKeys } from '../owner/owner.model' import type { CommentModel } from './comment.model' @@ -58,6 +59,8 @@ export const baseRenderProps = Object.freeze({ parent: null as CommentModel | null, owner: {} as Omit, }, + + hitokoto: null as HitokotoData | null, }) type Writeable = { -readonly [P in keyof T]: T[P] } diff --git a/apps/core/src/modules/comment/comment.service.ts b/apps/core/src/modules/comment/comment.service.ts index c349b262726..ae276cff7e8 100644 --- a/apps/core/src/modules/comment/comment.service.ts +++ b/apps/core/src/modules/comment/comment.service.ts @@ -632,6 +632,11 @@ export class CommentService implements OnModuleInit { : `[${seo.title || 'Mx Space'}] 有新回复了耶~` source.ip ??= '' + + // 获取一言数据 + const { hitokoto } = await this.mailService.getHitokotoForTemplate() + source.hitokoto = hitokoto + const options = { from: sendfrom, subject, diff --git a/apps/core/src/modules/configs/configs.default.ts b/apps/core/src/modules/configs/configs.default.ts index d7db2cb591d..fb20d476696 100644 --- a/apps/core/src/modules/configs/configs.default.ts +++ b/apps/core/src/modules/configs/configs.default.ts @@ -28,6 +28,10 @@ export const generateDefaultConfig: () => IConfig = () => ({ resend: { apiKey: '', }, + hitokoto: { + enable: false, + api: 'https://v1.hitokoto.cn', + }, }, commentOptions: { antiSpam: false, diff --git a/apps/core/src/modules/configs/configs.schema.ts b/apps/core/src/modules/configs/configs.schema.ts index 768a951c6e6..c40c0a1bbbe 100644 --- a/apps/core/src/modules/configs/configs.schema.ts +++ b/apps/core/src/modules/configs/configs.schema.ts @@ -70,6 +70,15 @@ export const MailOptionsSchema = section('邮件通知设置', { }), smtp: SmtpConfigSchema, resend: ResendConfigSchema, + hitokoto: withMeta( + z + .object({ + enable: field.toggle(z.boolean().optional(), '启用一言'), + api: field.plain(z.string().url().optional(), '一言 API 地址'), + }) + .optional(), + { title: '一言设置' }, + ), }) export class MailOptionsDto extends createZodDto(MailOptionsSchema) {} export type MailOptionsConfig = z.infer diff --git a/apps/core/src/modules/subscribe/subscribe.email.default.ts b/apps/core/src/modules/subscribe/subscribe.email.default.ts index b5ae2c55ca3..092345ccaf0 100644 --- a/apps/core/src/modules/subscribe/subscribe.email.default.ts +++ b/apps/core/src/modules/subscribe/subscribe.email.default.ts @@ -1,3 +1,4 @@ +import type { HitokotoData } from '~/processors/helper/helper.hitokoto.service' import type { OwnerModel, OwnerModelSecurityKeys } from '../owner/owner.model' import { SubscribeAllBit } from './subscribe.constant' @@ -26,6 +27,8 @@ export const defaultSubscribeForRenderProps = { created: '2023-06-04T15:02:09.179Z', }, }, + + hitokoto: null as HitokotoData | null, } export type SubscribeTemplateRenderProps = typeof defaultSubscribeForRenderProps diff --git a/apps/core/src/modules/subscribe/subscribe.service.ts b/apps/core/src/modules/subscribe/subscribe.service.ts index 31e801e6228..356c611ff58 100644 --- a/apps/core/src/modules/subscribe/subscribe.service.ts +++ b/apps/core/src/modules/subscribe/subscribe.service.ts @@ -282,6 +282,10 @@ export class SubscribeService implements OnModuleInit, OnModuleDestroy { this.lruCache.set(cacheKey, finalTemplate) } + // 获取一言数据 + const { hitokoto } = await this.emailService.getHitokotoForTemplate() + source.hitokoto = hitokoto + const options: Mail.Options = { from: sendfrom, subject: `[${seo.title || 'Mx Space'}] 发布了新内容~`, diff --git a/apps/core/src/processors/helper/helper.email.service.ts b/apps/core/src/processors/helper/helper.email.service.ts index 6b2915220d2..18abf7013c7 100644 --- a/apps/core/src/processors/helper/helper.email.service.ts +++ b/apps/core/src/processors/helper/helper.email.service.ts @@ -12,6 +12,8 @@ import type Mail from 'nodemailer/lib/mailer' import { Resend } from 'resend' import { SubPubBridgeService } from '../redis/subpub.service' import { AssetService } from './helper.asset.service' +import type { HitokotoData } from './helper.hitokoto.service' +import { HitokotoService } from './helper.hitokoto.service' type MailProvider = 'smtp' | 'resend' type MailClient = { @@ -30,6 +32,7 @@ export class EmailService implements OnModuleInit, OnModuleDestroy { private readonly assetService: AssetService, private readonly subpub: SubPubBridgeService, private readonly ownerService: OwnerService, + private readonly hitokotoService: HitokotoService, ) { this.logger = new Logger(EmailService.name) } @@ -285,4 +288,13 @@ export class EmailService implements OnModuleInit, OnModuleDestroy { } return undefined } + + /** + * 获取一言数据,用于邮件模板渲染 + * @returns 一言数据对象,如果未启用或获取失败则返回默认值 + */ + async getHitokotoForTemplate(): Promise<{ hitokoto: HitokotoData | null }> { + const hitokoto = await this.hitokotoService.getHitokoto() + return { hitokoto } + } } diff --git a/apps/core/src/processors/helper/helper.hitokoto.service.ts b/apps/core/src/processors/helper/helper.hitokoto.service.ts new file mode 100644 index 00000000000..1459bab193b --- /dev/null +++ b/apps/core/src/processors/helper/helper.hitokoto.service.ts @@ -0,0 +1,69 @@ +import { Injectable, Logger } from '@nestjs/common' +import { ConfigsService } from '~/modules/configs/configs.service' +import { HttpService } from './helper.http.service' + +export interface HitokotoResponse { + id: number + uuid: string + hitokoto: string + type: string + from: string + from_who: string | null + creator: string + creator_uid: number + reviewer: number + commit_from: string + created_at: string + length: number +} + +export interface HitokotoData { + text: string + from: string + author?: string +} + +@Injectable() +export class HitokotoService { + private readonly logger = new Logger(HitokotoService.name) + + constructor( + private readonly configsService: ConfigsService, + private readonly httpService: HttpService, + ) {} + + /** + * 获取随机一言 + * @returns 一言数据,如果获取失败或未启用则返回 null + */ + async getHitokoto(): Promise { + try { + const mailOptions = await this.configsService.get('mailOptions') + const hitokotoConfig = mailOptions?.hitokoto + + if (!hitokotoConfig?.enable) { + return null + } + + const apiUrl = hitokotoConfig.api || 'https://v1.hitokoto.cn' + + const response = await this.httpService.axiosRef.get( + apiUrl, + { + timeout: 5000, // 5秒超时 + }, + ) + + const data = response.data + + return { + text: data.hitokoto, + from: data.from, + author: data.from_who || undefined, + } + } catch (error) { + this.logger.warn(`获取一言失败: ${error.message}`) + return null + } + } +} diff --git a/apps/core/src/processors/helper/helper.module.ts b/apps/core/src/processors/helper/helper.module.ts index 23cd549efea..8d83f449f2b 100644 --- a/apps/core/src/processors/helper/helper.module.ts +++ b/apps/core/src/processors/helper/helper.module.ts @@ -14,6 +14,7 @@ import { BarkPushService } from './helper.bark.service' import { CountingService } from './helper.counting.service' import { EmailService } from './helper.email.service' import { EventManagerService } from './helper.event.service' +import { HitokotoService } from './helper.hitokoto.service' import { HttpService } from './helper.http.service' import { ImageMigrationService } from './helper.image-migration.service' import { ImageService } from './helper.image.service' @@ -30,6 +31,7 @@ const providers: Provider[] = [ CountingService, EmailService, EventManagerService, + HitokotoService, HttpService, ImageMigrationService, ImageService, diff --git a/apps/core/test/mock/processors/email.mock.ts b/apps/core/test/mock/processors/email.mock.ts index 4bc2338e8af..f1f0d6280a5 100644 --- a/apps/core/test/mock/processors/email.mock.ts +++ b/apps/core/test/mock/processors/email.mock.ts @@ -11,5 +11,14 @@ export const emailProvider = defineProvider({ sendTestEmail() { return Promise.resolve() }, + async getHitokotoForTemplate() { + return { + hitokoto: { + text: '测试一言', + from: '测试出处', + author: '测试作者', + }, + } + }, }, }) diff --git a/apps/core/test/mock/processors/hitokoto.mock.ts b/apps/core/test/mock/processors/hitokoto.mock.ts new file mode 100644 index 00000000000..e64786ed02e --- /dev/null +++ b/apps/core/test/mock/processors/hitokoto.mock.ts @@ -0,0 +1,15 @@ +import { HitokotoService } from '~/processors/helper/helper.hitokoto.service' +import { defineProvider } from 'test/helper/defineProvider' + +export const hitokotoProvider = defineProvider({ + provide: HitokotoService, + useValue: { + async getHitokoto() { + return { + text: '那些看似不起波澜的日复一日,会突然在某一天让你看到坚持的意义。', + from: '测试出处', + author: '测试作者', + } + }, + }, +}) diff --git a/docs/email-hitokoto.md b/docs/email-hitokoto.md new file mode 100644 index 00000000000..8404dc876a9 --- /dev/null +++ b/docs/email-hitokoto.md @@ -0,0 +1,152 @@ +# 邮件一言功能 + +## 概述 + +MX Space 现在支持在发送邮件时自动获取并插入随机一言(Hitokoto)。一言是一个提供随机名言警句的服务,可以为你的邮件增添文化气息。 + +## 配置 + +### 1. 启用一言功能 + +在管理后台的 **设置** → **邮件通知设置** → **一言设置** 中: + +- **启用一言**: 开启/关闭一言功能 +- **一言 API 地址**: 配置一言 API 的地址,默认为 `https://v1.hitokoto.cn` + +### 2. 配置示例 + +```json +{ + "mailOptions": { + "enable": true, + "hitokoto": { + "enable": true, + "api": "https://v1.hitokoto.cn" + } + } +} +``` + +## 在邮件模板中使用 + +一言数据会自动注入到所有邮件模板的渲染上下文中,你可以在自定义邮件模板中使用以下变量: + +### 可用变量 + +- `hitokoto`: 一言对象,包含以下属性: + - `text`: 一言内容(字符串) + - `from`: 一言出处(字符串) + - `author`: 一言作者(字符串,可选) + +### 模板示例 + +在 EJS 邮件模板中使用一言: + +```html + + + + + 邮件通知 + + + +
+

你好,<%= owner %>

+

这是一封来自 MX Space 的通知邮件。

+
+ + + <% if (hitokoto) { %> +
+

+ 「<%= hitokoto.text %>」 +

+

+ —— + <% if (hitokoto.author) { %> + <%= hitokoto.author %>《<%= hitokoto.from %>》 + <% } else { %> + 《<%= hitokoto.from %>》 + <% } %> +

+
+ <% } %> + + +
+

此邮件由系统自动发送,请勿直接回复。

+
+ + +``` + +### 简单示例 + +如果只想在邮件底部添加一言: + +```html + + +<% if (hitokoto) { %> +
+

+ <%= hitokoto.text %> + <% if (hitokoto.author) { %> + —— <%= hitokoto.author %> + <% } %> +

+<% } %> +``` + +## 注意事项 + +1. **网络请求**: 一言数据通过网络请求获取,设置了 5 秒超时时间 +2. **失败处理**: 如果获取一言失败(网络问题、超时等),`hitokoto` 变量会是 `null`,不会影响邮件发送 +3. **性能考虑**: 一言 API 调用是异步的,不会阻塞邮件发送流程 +4. **自定义 API**: 你可以使用自己搭建的一言 API 服务,只需在配置中修改 API 地址 + +## API 返回格式 + +一言 API 返回的数据格式示例: + +```json +{ + "id": 1, + "uuid": "abc123", + "hitokoto": "那些看似不起波澜的日复一日,会突然在某一天让你看到坚持的意义。", + "type": "a", + "from": "你的名字", + "from_who": "新海诚", + "creator": "hitokoto", + "creator_uid": 1, + "reviewer": 0, + "commit_from": "web", + "created_at": "2023-01-01", + "length": 30 +} +``` + +实际在模板中可用的数据经过简化处理: + +```javascript +{ + text: "那些看似不起波澜的日复一日,会突然在某一天让你看到坚持的意义。", + from: "你的名字", + author: "新海诚" // 可选 +} +``` + +## 自定义一言源 + +你可以使用其他兼容的一言 API,只要返回格式包含以下字段: + +- `hitokoto`: 一言内容 +- `from`: 出处 +- `from_who`: 作者(可选) + +推荐的一言 API: + +- [官方 API](https://v1.hitokoto.cn) +- [Hitokoto 国际版](https://international.v1.hitokoto.cn) +- 自建服务: 参考 [Hitokoto API 文档](https://hitokoto.cn/api) From 8c5260a635497ca298ff6503106f5778f0c2a272 Mon Sep 17 00:00:00 2001 From: Teror Fox Date: Thu, 12 Feb 2026 09:44:11 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E5=9C=A8=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E5=92=8C=E8=AE=A2=E9=98=85=E6=9C=8D=E5=8A=A1=E4=B8=AD=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20hitokoto=20=E5=AD=97=E6=AE=B5=E4=BB=A5=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E9=9A=8F=E6=9C=BA=E5=90=8D=E8=A8=80=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/core/src/modules/comment/comment.service.ts | 2 ++ apps/core/src/modules/subscribe/subscribe.service.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/apps/core/src/modules/comment/comment.service.ts b/apps/core/src/modules/comment/comment.service.ts index ae276cff7e8..6109082d25e 100644 --- a/apps/core/src/modules/comment/comment.service.ts +++ b/apps/core/src/modules/comment/comment.service.ts @@ -485,6 +485,8 @@ export class CommentService implements OnModuleInit { CommentReplyMailType.Owner === type ? comment.mail : ownerInfo.mail, ip: comment.ip || '', + hitokoto: null, + aggregate: { owner: ownerInfo, commentor: { diff --git a/apps/core/src/modules/subscribe/subscribe.service.ts b/apps/core/src/modules/subscribe/subscribe.service.ts index 356c611ff58..bc71327fc80 100644 --- a/apps/core/src/modules/subscribe/subscribe.service.ts +++ b/apps/core/src/modules/subscribe/subscribe.service.ts @@ -128,6 +128,8 @@ export class SubscribeService implements OnModuleInit, OnModuleDestroy { unsubscribe_link: unsubscribeLink, owner: owner.name, + hitokoto: null, + aggregate: { owner, subscriber: {