Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ TopicEye continuously crawls 25+ sources (RSS, Reddit, YouTube, podcasts, newsle
| **Webnovel radar** | Fanqie / Qimao / Zhihu Yanxuan trending charts, gated behind a runtime feature flag. |
| **Mother topics** | Multi-tenant topic templates — admins maintain the system library, users fork their own and customize keywords, weights, and target readers. Fork edits take effect immediately in the scoring queue. |
| **AI model evaluation** | Admin UI to compare LLM models side-by-side on the same prompts, track quality and cost, and pick the right routing group per task. |
| **Email verification** | Transactional email via Brevo API or any SMTP provider (QQ Enterprise / Gmail / etc.), configured from the admin settings page. |
| **Email verification** | Transactional email via any standard SMTP provider (QQ Enterprise / Gmail / self-hosted etc.), configured from the admin settings page. |
| **Article reader** | In-app reader for source URLs — fetches public HTML only, no auth/cookies/captcha bypass, with SSRF guard and snapshot caching. |
| **Favorites** | Save items across sessions (per-user). |
| **My topics** | Personalized topic configuration with mother-topic fork, keyword filters, and scoring overrides. |
Expand All @@ -114,7 +114,7 @@ api/v1/ ──► services/ ──► repositories/ ──► models/ ──►
| `models/` | Pure ORM declarations, field definitions, `__table_args__` | Business methods, side effects, IO |
| `schemas/` | Pydantic request/response models, serialization | ORM imports, DB access |

Cross-cutting: `core/` (config, DB, logging, retry), `middleware/` (rate limit, request metrics), `services/email/` (Brevo + SMTP), `services/llm/` (failover, circuit breaker, response cache), `services/scrapers/` + `services/trending_scrapers/` (per-source fetchers).
Cross-cutting: `core/` (config, DB, logging, retry), `middleware/` (rate limit, request metrics), `services/email/` (SMTP), `services/llm/` (failover, circuit breaker, response cache), `services/scrapers/` + `services/trending_scrapers/` (per-source fetchers).

### Scoring engine

Expand Down Expand Up @@ -164,7 +164,7 @@ Full config in [backend/app/services/scoring_engine.py](backend/app/services/sco
- **Frontend:** Next.js 16 · React 19 · TypeScript · Tailwind CSS v4
- **Database:** PostgreSQL 16 · DuckDB as a read-only analytics layer over OLTP
- **Auth:** Opaque bearer tokens (DB-hashed) + OAuth via Authlib
- **Email:** Brevo API or SMTP (user-configurable per deployment)
- **Email:** any standard SMTP server (user-configurable per deployment)
- **Infra:** Docker / docker-compose (dev + prod) · APScheduler

## Quick start
Expand Down Expand Up @@ -248,9 +248,9 @@ All configuration is environment-driven. See [`backend/.env.example`](backend/.e
>
> **Third-party login** (Google / GitHub) is configured from the admin settings page (**Admin → Settings → Third-party Login**): client id / secret and the enable toggle are stored in the database (secret encrypted), effective immediately without a restart.
>
> **Email verification** is configured from the admin settings page. Two providers are supported:
> - **Brevo API** — free tier 300 emails/day, no credit card required but account approval needed.
> - **SMTP** — bring your own provider (QQ Enterprises / Gmail / etc.), no approval needed.
> **Email verification** is configured from the admin settings page: bring your own SMTP server
> (QQ Enterprises / Gmail / self-hosted Postfix etc.). Some providers (QQ / 163) require an
> app-specific authorization code instead of the login password.

## Development

Expand Down
11 changes: 5 additions & 6 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ AI 驱动的内容发现与选题分析平台。TopicEye 持续抓取 25+ 信源
| 网文雷达 | 番茄 / 七猫 / 知乎盐选热榜,受运行时功能开关控制,默认关闭 |
| 母题模板 | 多租户模型——管理员维护系统模板库(只读),用户首次访问时自动 fork 一份到自己名下,可自定义关键词、权重、目标读者。修改立即影响打分队列。 |
| AI 模型评测 | 后台 UI 对比多个 LLM 在同一 prompt 下的表现,追踪质量与成本,为不同任务选合适的路由组。 |
| 邮件验证 | 事务邮件支持 Brevo API 或任意 SMTP(QQ 企业邮 / Gmail 等),在管理后台设置页配置。 |
| 邮件验证 | 事务邮件走任意标准 SMTP(QQ 企业邮 / Gmail / 自建等),在管理后台设置页配置。 |
| 站内阅读 | 对原文链接的站内阅读器——仅抓取公开 HTML,不使用登录态 / Cookie / 验证码 / 反爬绕过,带 SSRF 防护与快照缓存。 |
| 收藏夹 | 跨会话保存感兴趣的内容(按用户隔离) |
| 我的选题 | 个性化选题配置,支持母题 fork、关键词过滤、评分覆盖 |
Expand All @@ -112,7 +112,7 @@ api/v1/ ──► services/ ──► repositories/ ──► models/ ──►
| `models/` | 纯 ORM 声明、字段定义、`__table_args__` | 业务方法、副作用、IO |
| `schemas/` | Pydantic 请求/响应模型、序列化 | ORM import、DB 访问 |

跨层支撑:`core/`(配置、DB、日志、retry)、`middleware/`(限流、请求指标)、`services/email/`(Brevo + SMTP)、`services/llm/`(failover / 熔断 / 响应缓存)、`services/scrapers/` + `services/trending_scrapers/`(按信源的抓取器)。
跨层支撑:`core/`(配置、DB、日志、retry)、`middleware/`(限流、请求指标)、`services/email/`(SMTP)、`services/llm/`(failover / 熔断 / 响应缓存)、`services/scrapers/` + `services/trending_scrapers/`(按信源的抓取器)。

### 评分引擎

Expand Down Expand Up @@ -162,7 +162,7 @@ api/v1/ ──► services/ ──► repositories/ ──► models/ ──►
- **前端:** Next.js 16 · React 19 · TypeScript · Tailwind CSS v4
- **数据库:** PostgreSQL 16 · DuckDB 作为 OLTP 的只读分析层
- **认证:** 不透明 bearer token(DB hash)+ Authlib OAuth
- **邮件:** Brevo API 或 SMTP(按部署自行配置)
- **邮件:** 任意标准 SMTP 服务器(按部署自行配置)
- **基础设施:** Docker / docker-compose(dev + prod)· APScheduler

## 快速开始
Expand Down Expand Up @@ -246,9 +246,8 @@ npm run dev
>
> **第三方登录**(Google / GitHub)在后台配置(**管理后台 → 系统设置 → 第三方登录**):client id / secret 与启用开关存数据库(secret 加密存储),保存后即时生效,无需重启。
>
> **邮件验证** 在管理后台设置页配置,两种 provider:
> - **Brevo API** — 免费版 300 邮件/天,无需信用卡,但需账号审核。
> - **SMTP** — 自带任意 provider(QQ 企业邮 / Gmail 等),无需审核。
> **邮件验证** 在管理后台设置页配置:自带任意标准 SMTP 服务器(QQ 企业邮 / Gmail /
> 自建 Postfix 等)。部分邮箱(QQ / 163)需使用授权码而非登录密码。

## 开发

Expand Down
59 changes: 26 additions & 33 deletions backend/app/api/v1/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,41 +167,36 @@ async def update_feature_flags(
# ── Email provider (邮件服务配置) ─────────────────────────────────────


# 支持的邮件 Provider 列表,前端据此渲染下拉选项
SUPPORTED_EMAIL_PROVIDERS = ["brevo", "smtp"]
# 支持的邮件 Provider 列表(#108 起仅剩自定义 SMTP,Brevo 已移除)
SUPPORTED_EMAIL_PROVIDERS = ["smtp"]

# API Key / SMTP 密码脱敏前缀长度
# SMTP 密码脱敏前缀长度
_SECRET_MASK_PREFIX = 6


class EmailProviderConfigResponse(BaseModel):
"""邮件 Provider 配置响应。敏感字段脱敏返回。"""

provider: str = "brevo"
provider: str = "smtp"
from_email: str = ""
from_name: str = "TopicEye"
api_key_configured: bool = False
api_key_preview: str = ""
smtp_host: str = ""
smtp_port: int = 587
smtp_username: str = ""
smtp_password_configured: bool = False
smtp_password_preview: str = ""
smtp_use_ssl: bool = False
supported_providers: list[str] = []


class EmailProviderConfigUpdateRequest(BaseModel):
"""邮件 Provider 配置更新请求。

api_key / smtp_password 为空字符串时保留原值(不修改),非空时覆盖。
SMTP 字段仅在 provider == 'smtp' 时生效。
smtp_password 为空字符串时保留原值(不修改),非空时覆盖。
"""

provider: str = "brevo"
provider: str = "smtp"
from_email: str = ""
from_name: str = "TopicEye"
api_key: str = ""
smtp_host: str = ""
smtp_port: int = 587
smtp_username: str = ""
Expand All @@ -211,17 +206,17 @@ class EmailProviderConfigUpdateRequest(BaseModel):

@router.get("/email-provider", response_model=EmailProviderConfigResponse)
async def get_email_provider_config(db: AsyncSession = Depends(get_db)):
"""获取当前邮件 Provider 配置。敏感字段(api_key / smtp_password)不返回明文,仅返回脱敏预览。"""
"""获取当前邮件 Provider 配置。敏感字段(smtp_password)不返回明文,仅返回脱敏预览。"""
row = await AppSettingRepository(db).get_by_key("email_provider_config")

if not row or not row.value:
return EmailProviderConfigResponse(supported_providers=SUPPORTED_EMAIL_PROVIDERS)
return EmailProviderConfigResponse()

try:
config = json.loads(row.value)
except json.JSONDecodeError:
logger.warning("email_provider_config JSON 损坏,返回默认配置")
return EmailProviderConfigResponse(supported_providers=SUPPORTED_EMAIL_PROVIDERS)
return EmailProviderConfigResponse()

from app.services.secret_store import decrypt_secret

Expand All @@ -231,22 +226,25 @@ def _mask(plain: str) -> str:
mask_len = min(_SECRET_MASK_PREFIX, len(plain))
return plain[:mask_len] + "****"

api_key_plain = decrypt_secret(config.get("api_key", "")) or ""
stored_provider = config.get("provider", "smtp")
if stored_provider not in SUPPORTED_EMAIL_PROVIDERS:
# #108 移除 Brevo 后的存量配置:归一展示为 smtp(字段自然为空),
# 邮件发送侧同样显式降级——管理员重配 SMTP 后恢复
logger.warning("email_provider_config 存量 provider=%r 已不受支持,按未配置处理", stored_provider)
return EmailProviderConfigResponse()

smtp_password_plain = decrypt_secret(config.get("smtp_password", "")) or ""

return EmailProviderConfigResponse(
provider=config.get("provider", "brevo"),
provider=stored_provider,
from_email=config.get("from_email", ""),
from_name=config.get("from_name", "TopicEye"),
api_key_configured=bool(api_key_plain),
api_key_preview=_mask(api_key_plain),
smtp_host=config.get("smtp_host", ""),
smtp_port=int(config.get("smtp_port", 587)),
smtp_username=config.get("smtp_username", ""),
smtp_password_configured=bool(smtp_password_plain),
smtp_password_preview=_mask(smtp_password_plain),
smtp_use_ssl=bool(config.get("smtp_use_ssl", False)),
supported_providers=SUPPORTED_EMAIL_PROVIDERS,
)


Expand All @@ -257,21 +255,20 @@ async def update_email_provider_config(
):
"""更新邮件 Provider 配置。

敏感字段(api_key / smtp_password)为空时保留原值,非空时加密覆盖。
SMTP 专属字段仅在 provider == 'smtp' 时校验必填。
敏感字段(smtp_password)为空时保留原值,非空时加密覆盖。
"""
if payload.provider not in SUPPORTED_EMAIL_PROVIDERS:
raise HTTPException(status_code=400, detail=f"不支持的邮件 Provider: {payload.provider}")
raise HTTPException(
status_code=400,
detail=f"不支持的邮件 Provider: {payload.provider}(Brevo 已于 #108 移除,请使用自定义 SMTP)",
)

if not payload.from_email.strip():
raise HTTPException(status_code=400, detail="发件人邮箱不能为空")

# SMTP 必填校验
if payload.provider == "smtp":
if not payload.smtp_host.strip():
raise HTTPException(status_code=400, detail="SMTP 服务器不能为空")
if not payload.smtp_username.strip():
raise HTTPException(status_code=400, detail="SMTP 用户名不能为空")
if not payload.smtp_host.strip():
raise HTTPException(status_code=400, detail="SMTP 服务器不能为空")
if not payload.smtp_username.strip():
raise HTTPException(status_code=400, detail="SMTP 用户名不能为空")

from app.services.secret_store import encrypt_secret

Expand All @@ -286,9 +283,6 @@ async def update_email_provider_config(
existing_config = {}

# 敏感字段处理:空值保留原值,非空值加密覆盖
api_key_stored = (
encrypt_secret(payload.api_key.strip()) if payload.api_key.strip() else existing_config.get("api_key", "")
)
smtp_password_stored = (
encrypt_secret(payload.smtp_password.strip())
if payload.smtp_password.strip()
Expand All @@ -299,7 +293,6 @@ async def update_email_provider_config(
"provider": payload.provider,
"from_email": payload.from_email.strip(),
"from_name": payload.from_name.strip() or "TopicEye",
"api_key": api_key_stored,
"smtp_host": payload.smtp_host.strip(),
"smtp_port": int(payload.smtp_port) if payload.smtp_port else 587,
"smtp_username": payload.smtp_username.strip(),
Expand Down
3 changes: 2 additions & 1 deletion backend/app/services/email/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""邮件发送服务模块。

提供可配置的邮件 Provider 抽象,当前默认实现为 Brevo。
提供可配置的邮件 Provider 抽象,当前唯一实现为自定义 SMTP
(#108 起仅剩 SMTP,Brevo 已移除)。
新增 Provider 只需继承 EmailProvider 并在 factory 中注册。
"""
# author: fxbin
Expand Down
114 changes: 0 additions & 114 deletions backend/app/services/email/brevo_provider.py

This file was deleted.

Loading
Loading