mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
嵌入与邮箱
This commit is contained in:
@@ -45,3 +45,5 @@ uploads/*
|
||||
s3_cache
|
||||
/frontend/.next/
|
||||
/data/
|
||||
/internal/router/dist/
|
||||
/frontend/out/
|
||||
|
||||
@@ -44,6 +44,7 @@ Refreshing/ # 项目根目录(模块名: github.com/lin
|
||||
├── go.mod / go.sum # Go 模块依赖
|
||||
├── config.yaml # 运行时配置(不提交到 Git)
|
||||
├── config.example.yaml # 配置模板(需提交)
|
||||
├── DEPLOYMENT_zh.md # 部署说明文档(中文版)
|
||||
├── Makefile # 常用命令(swagger/tidy/license)
|
||||
├── Dockerfile # 后端容器镜像构建
|
||||
├── docker-compose.yml # 本地依赖服务(PostgreSQL / Redis / ClickHouse)
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
swagger:
|
||||
scripts/swagger.sh
|
||||
|
||||
build-embedded:
|
||||
cd frontend && pnpm build:embed
|
||||
rm -rf internal/router/dist
|
||||
cp -R frontend/out internal/router/dist
|
||||
go build -tags embed_frontend -o bin/credit main.go
|
||||
|
||||
@@ -16,7 +16,6 @@ app:
|
||||
session_secure: false
|
||||
session_http_only: false
|
||||
api_prefix: "/api"
|
||||
frontend_url: "http://localhost:3000"
|
||||
|
||||
# ─── PostgreSQL ─────────────────────────────────────────────────────────────────
|
||||
# Supports Standalone and Primary-Replica (read/write split) modes.
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
# Refreshing 部署指南
|
||||
|
||||
本文档详细介绍了 **Refreshing** 脚手架系统在不同业务阶段的部署方案,涵盖从**最小化单机部署**到**最大化高可用分布式部署**的全生命周期架构。
|
||||
|
||||
---
|
||||
|
||||
## 一、 系统组件概览
|
||||
|
||||
在部署系统前,请了解各运行组件及其角色:
|
||||
|
||||
| 组件名称 | 运行命令/形式 | 职责说明 | 必选/可选 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **HTTP API 服务** | `bin/credit api` | 接收并处理前端及第三方的 RESTful API 请求 | **必选** |
|
||||
| **异步任务工作进程** | `bin/credit worker` | 消费并处理异步队列任务(如邮件发送、清理上传文件等) | **必选** |
|
||||
| **定时任务调度器** | `bin/credit scheduler` | 定时向 Redis 队列下发 Cron 任务(仅负责触发,不负责执行) | **必选** |
|
||||
| **前端服务 (Node.js)** | `pnpm start` | 提供 React/Next.js 页面服务(在分离部署时使用) | 分离模式必选 |
|
||||
| **PostgreSQL** | 关系型主数据库 | 存储用户、系统配置、认证源、任务执行记录等核心数据 | **必选** |
|
||||
| **Redis** | 缓存与消息队列中间件 | 存储 Session 会话、临时缓存以及 Asynq 异步任务队列数据 | **必选** |
|
||||
| **ClickHouse** | 分析型数据库 | 存储历史数据同步或进行高性能分析 | 可选 |
|
||||
| **对象存储 (S3)** | 兼容 S3 的云存储/私有云 | 存放用户上传的静态文件、图片等 | 可选 |
|
||||
|
||||
---
|
||||
|
||||
## 二、 部署配置准备
|
||||
|
||||
系统在启动前会从当前目录加载 `config.yaml` 配置文件。
|
||||
生产环境部署前,请复制 `config.example.yaml` 为 `config.yaml`,并至少确认以下关键参数的配置:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
env: "production" # 生产环境标识
|
||||
addr: ":8000" # API 服务监听端口
|
||||
session_secret: "prod-random-secret" # 极其重要的加密密钥,首发启动后不可更改
|
||||
session_domain: ".yourdomain.com" # 跨域共享 Session 时需配置
|
||||
|
||||
database:
|
||||
host: "db.yourdomain.com"
|
||||
port: 5432
|
||||
username: "postgres"
|
||||
password: "YOUR_DB_PASSWORD"
|
||||
database: "refreshing"
|
||||
|
||||
redis:
|
||||
addrs:
|
||||
- "redis.yourdomain.com:6379"
|
||||
password: "YOUR_REDIS_PASSWORD"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、 方案一:最小部署 — 单机嵌入式极简版 (推荐)
|
||||
|
||||
此部署方案将**前端静态网页全部直接打入 Go 后端二进制文件中**,极大地简化了部署运维,是中小型应用、内部系统、SaaS 早期阶段的首选。
|
||||
|
||||
### 📊 架构设计
|
||||
- **服务载体**:单台云服务器 (1核2G 即可)。
|
||||
- **依赖服务**:在一台机器上启动轻量级 PostgreSQL 与 Redis(可采用 Docker 部署)。
|
||||
- **进程管理**:在一台机器上直接拉起打包好的 Go 单文件,并分别运行 `api`、`worker`、`scheduler` 进程。
|
||||
- **前端托管**:Go 服务直接在 8000 端口承载前端的所有页面,不需要额外配置 Node.js 生产服务器。
|
||||
|
||||
### 🛠️ 步骤说明
|
||||
|
||||
#### 1. 单机依赖服务初始化 (使用 Docker Compose)
|
||||
在机器上准备以下 `docker-compose.yml` 快速启动 PostgreSQL 和 Redis:
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: refreshing-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: YOUR_DB_PASSWORD
|
||||
POSTGRES_DB: refreshing
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- ./data/pg:/var/lib/postgresql/data
|
||||
restart: always
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: refreshing-redis
|
||||
command: redis-server --requirepass YOUR_REDIS_PASSWORD
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
restart: always
|
||||
```
|
||||
执行命令启动:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### 2. 前后端一键嵌入式打包
|
||||
在开发或编译机上,运行编译指令:
|
||||
```bash
|
||||
make build-embedded
|
||||
```
|
||||
该命令会自动完成前端的静态编译导出 (`frontend/out`)、复制到 Go 后端目录,最后使用 `-tags embed_frontend` 生成后端单文件:
|
||||
- 产物路径:`bin/credit`
|
||||
|
||||
#### 3. 进程管理 (使用 Systemd)
|
||||
将 `bin/credit` 拷贝到生产服务器 `/usr/local/bin/credit`,并为 `api`、`worker` 和 `scheduler` 配置 Systemd 管理服务。
|
||||
|
||||
新建 API 进程服务文件 `/etc/systemd/system/refreshing-api.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Refreshing API Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/app
|
||||
ExecStart=/usr/local/bin/credit api
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
同理,新建 Worker 服务 `/etc/systemd/system/refreshing-worker.service`(将命令改为 `credit worker`),以及 Scheduler 服务 `/etc/systemd/system/refreshing-scheduler.service`(将命令改为 `credit scheduler`)。
|
||||
|
||||
启动并启用所有服务:
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now refreshing-api refreshing-worker refreshing-scheduler
|
||||
```
|
||||
|
||||
#### 4. 配置 Nginx 证书
|
||||
配置 Nginx 作为反向代理并启用 HTTPS 证书:
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/cert.crt;
|
||||
ssl_certificate_key /path/to/cert.key;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、 方案二:标准部署 — 前后端物理分离架构
|
||||
|
||||
此方案中前端与后端彻底解耦。前端采用 SSR/ISR (Next.js Node 服务) 运行,后端采用独立的 API 服务运行。
|
||||
|
||||
### 📊 架构设计
|
||||
- **前端部署**:单独部署到 Node.js 托管环境(如多台前端机器或 Vercel/Cloudflare Pages)。
|
||||
- **后端部署**:多台后端云服务器,统一指向云数据库 RDS 与云缓存 Redis。
|
||||
- **通信方式**:前后端通过 Nginx 规则路由或独立域名(如 `app.yourdomain.com` 访问前端,`api.yourdomain.com` 访问后端)进行跨域通信。
|
||||
|
||||
### 🛠️ 步骤说明
|
||||
|
||||
#### 1. 部署后端 Go 服务
|
||||
1. 编译后端:
|
||||
```bash
|
||||
go build -o bin/credit main.go
|
||||
```
|
||||
2. 在后端服务器上,同样使用 Systemd 或 Docker 守护启动 `credit api`、`credit worker` 和 `credit scheduler`。
|
||||
3. 配置后端 Nginx 将客户端 API 请求(如 `/api/...`)反向代理至后端绑定的端口(如 `:8000`)。
|
||||
|
||||
#### 2. 部署前端 Next.js 服务
|
||||
1. 前端服务器环境确保已安装 Node.js 和 pnpm。
|
||||
2. 安装依赖并编译生产版本:
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
3. 使用 PM2 守护前端 Node.js 服务运行。新建 `ecosystem.config.js`:
|
||||
```javascript
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'refreshing-frontend',
|
||||
script: 'node_modules/next/dist/bin/next',
|
||||
args: 'start -p 3000',
|
||||
instances: 'max',
|
||||
exec_mode: 'cluster',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
LINUX_DO_CREDIT_BACKEND_URL: 'https://api.yourdomain.com'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
启动前端服务:
|
||||
```bash
|
||||
pm2 start ecosystem.config.js
|
||||
```
|
||||
|
||||
#### 3. 跨域与 Cookie 说明
|
||||
- 若前后端使用**不同子域名**部署(例如 `app.yourdomain.com` 和 `api.yourdomain.com`),必须在 `config.yaml` 中将 `app.session_domain` 显式设置为顶级域名(`.yourdomain.com`),以确保 Session Cookie 可以在子域间顺利透传。
|
||||
- 在跨域状态下,前端请求必须配置 `withCredentials: true`,API 端的跨域中间件(`corsMiddleware`)会自动将该域添加至允许源中。
|
||||
|
||||
---
|
||||
|
||||
## 五、 方案三:最大部署 — 企业级高可用分布式架构 (Max)
|
||||
|
||||
当系统面临高并发流量、海量后台任务或极高的可用性要求时,需要将所有组件拆分为无状态水平扩容,并引入高可用的云基础设施。
|
||||
|
||||
### 📊 架构设计图
|
||||
```
|
||||
┌────────────────────────┐
|
||||
│ 域名 / 负载均衡器 │
|
||||
│ (SLB / Cloudflare) │
|
||||
└──────────┬─────────────┘
|
||||
│
|
||||
┌──────────────────┴──────────────────┐
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ 前端集群 │ │ 后端 API 集群 │
|
||||
│ (Next.js Node) │ │ (Go 无状态实例) │
|
||||
│ [弹性扩容 / 8台+] │ │ [弹性扩容 / 8台+] │
|
||||
└─────────────────────┘ └──────────┬──────────┘
|
||||
│
|
||||
┌────────────────────────────────────────┼────────────────────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
|
||||
│ 异步 Worker 集群 │ │ 定时 Scheduler │ │ S3 对象存储集群 │
|
||||
│ (多节点并发处理) │ │ (主备模式,限单节点)│ │(R2/MinIO/AWS S3) │
|
||||
└─────────┬─────────┘ └─────────┬─────────┘ └───────────────────┘
|
||||
│ │
|
||||
└───────────────────┬────────────────────┘
|
||||
│
|
||||
┌───────────────────┴────────────────────┐
|
||||
▼ ▼
|
||||
┌───────────────────────────────────┐ ┌───────────────────────────────────┐
|
||||
│ Redis 哨兵/集群 │ │ PG 主从读写分离集群 │
|
||||
│ (高可用缓存/Asynq 队列) │ │ (RDS Primary-Replica) │
|
||||
└───────────────────────────────────┘ └───────────────────────────────────┘
|
||||
```
|
||||
|
||||
### ⚙️ 最大部署配置要点
|
||||
|
||||
#### 1. 数据库高可用 (主从读写分离)
|
||||
在 `config.yaml` 中配置 `database` 的主库写与从库读:
|
||||
```yaml
|
||||
database:
|
||||
enabled: true
|
||||
host: "pg-primary.yourdomain.com" # 主库地址(写)
|
||||
port: 5432
|
||||
username: "postgres"
|
||||
password: "YOUR_DB_PASSWORD"
|
||||
database: "refreshing"
|
||||
# 配置读写分离只读副本(GORM 自动轮询读,支持配置多个从库)
|
||||
replicas:
|
||||
- host: "pg-replica-1.yourdomain.com"
|
||||
port: 5432
|
||||
username: "postgres"
|
||||
password: "YOUR_DB_PASSWORD"
|
||||
- host: "pg-replica-2.yourdomain.com"
|
||||
port: 5432
|
||||
username: "postgres"
|
||||
password: "YOUR_DB_PASSWORD"
|
||||
```
|
||||
|
||||
#### 2. Redis 高可用 (哨兵/Sentinel 或集群)
|
||||
- **Sentinel 哨兵模式**:通过配置 `redis.master_name` 启用,SDK 会自动监视 Master 的主备切换。
|
||||
- **Cluster 集群模式**:将 `redis.cluster_mode` 设为 `true`,并提供所有集群节点的 `addrs`。
|
||||
```yaml
|
||||
redis:
|
||||
addrs:
|
||||
- "redis-node-1.yourdomain.com:6379"
|
||||
- "redis-node-2.yourdomain.com:6379"
|
||||
- "redis-node-3.yourdomain.com:6379"
|
||||
cluster_mode: true
|
||||
```
|
||||
|
||||
#### 3. 对象存储与缓存分离 (S3 + Local Cache)
|
||||
高可用集群下,本地文件系统不再可共享。文件存储必须启用 S3 兼容服务,并在多节点间开启本地高速磁盘缓存加速读取:
|
||||
```yaml
|
||||
s3:
|
||||
enabled: true
|
||||
endpoint: "https://your-r2-or-s3-id.r2.cloudflarestorage.com"
|
||||
region: "auto"
|
||||
bucket: "refreshing-assets"
|
||||
access_key_id: "YOUR_S3_KEY"
|
||||
secret_access_key: "YOUR_S3_SECRET"
|
||||
local_cache:
|
||||
enabled: true # 开启本地磁盘缓存
|
||||
cache_dir: "/data/s3_cache" # 本地高性能 SSD 挂载点
|
||||
```
|
||||
|
||||
#### 4. 后端进程横向拆分部署
|
||||
- **API 集群**:启动数十个甚至上百个 `credit api` 无状态容器。它们可以通过负载均衡器直接挂载,支持随时弹性缩容扩容。
|
||||
- **Worker 集群**:启动多个 `credit worker` 容器。因为 `Asynq` 基于 Redis 分布式处理,多个 Worker 进程可以安全地同时运行并竞抢同一队列的异步任务,自动保障任务的并发吞吐能力。
|
||||
- **Scheduler 独占**:**【注意】** 为避免重复触发定时 Cron 任务,`credit scheduler` 定时调度器进程**同一时间应仅运行单个活跃实例**(主备高可用可以通过容器平台的单实例保障或 K8s Job 机制来限制实例数为 1)。
|
||||
|
||||
#### 5. ClickHouse 高并发同步
|
||||
在大数据量、高频支付结算场景下,开启 ClickHouse 以接收系统的历史数据同步(如订单流水和任务大宽表),通过定时器把 PostgreSQL 的压力转移到 ClickHouse 列式存储中。
|
||||
```yaml
|
||||
clickhouse:
|
||||
enabled: true
|
||||
hosts:
|
||||
- "ch-node-1.yourdomain.com:9000"
|
||||
- "ch-node-2.yourdomain.com:9000"
|
||||
```
|
||||
|
||||
#### 6. OpenTelemetry 分布式链路追踪
|
||||
最大部署架构必须引入链路追踪(Jaeger 或 OTel Collector)以便排查节点间请求延迟或网络问题。
|
||||
在生产环境,通过配置 OTel 将 Span 发送至公共日志分析平台。
|
||||
```yaml
|
||||
otel:
|
||||
sampling_rate: 0.05 # 开启 5% 的流量追踪采样率以减少开销
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、 部署方案对比与选择建议
|
||||
|
||||
| 指标维度 | 方案一:最小单机嵌入版 | 方案二:标准前后端分离版 | 方案三:最大高可用分布式版 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **支持流量/并发** | 1,000 ~ 5,000 QPS (视机器性能) | 5,000 ~ 20,000 QPS | 20,000 ~ 100,000+ QPS (无限扩展) |
|
||||
| **服务器数量** | 1 台 | 3 ~ 5 台 | 10 台以上集群 |
|
||||
| **运维复杂度** | 极简 (只需部署一个程序) | 中等 (需维护 Node 和 Go 两套环境) | 较高 (K8s/多组件集群维护) |
|
||||
| **适合场景** | 个人项目、内部系统、SaaS 早期起步 | 正常线上运营项目、有中等规模团队 | 大型企业级应用、高并发核心交易系统 |
|
||||
@@ -240,7 +240,7 @@ export const apiSections: PolicySection[] = [
|
||||
code={`{
|
||||
"error_msg": "",
|
||||
"data": {
|
||||
"site_name": "Antigravity Project",
|
||||
"site_name": "Wavelet",
|
||||
"registration_enabled": true,
|
||||
"password_login_enabled": true,
|
||||
"password_register_enabled": true,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Info,
|
||||
Loader2,
|
||||
Lock,
|
||||
Mail,
|
||||
Pencil,
|
||||
Plus,
|
||||
Server,
|
||||
@@ -26,6 +27,7 @@ import {Switch} from "@/components/ui/switch"
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {useAuth} from "@/components/providers/auth-provider"
|
||||
import {AuthSourceModal} from "@/components/common/settings/auth-source-modal"
|
||||
import {AdminService, apiConfig} from "@/lib/services"
|
||||
@@ -94,6 +96,15 @@ export function SecurityMain() {
|
||||
const [capTokenTTL, setCapTokenTTL] = useState("")
|
||||
const [capAutoSolve, setCapAutoSolve] = useState(true)
|
||||
const [serverAddress, setServerAddress] = useState("")
|
||||
const [smtpHost, setSmtpHost] = useState("")
|
||||
const [smtpPort, setSmtpPort] = useState("")
|
||||
const [smtpUsername, setSmtpUsername] = useState("")
|
||||
const [smtpPassword, setSmtpPassword] = useState("")
|
||||
const [smtpTestOpen, setSmtpTestOpen] = useState(false)
|
||||
const [smtpTestTo, setSmtpTestTo] = useState("")
|
||||
const [smtpTestLog, setSmtpTestLog] = useState("")
|
||||
const [smtpTestSuccess, setSmtpTestSuccess] = useState<boolean | null>(null)
|
||||
const [smtpTestError, setSmtpTestError] = useState("")
|
||||
|
||||
const systemConfigsQuery = useQuery({
|
||||
queryKey: ["admin", "system-configs"],
|
||||
@@ -128,6 +139,10 @@ export function SecurityMain() {
|
||||
setCapTokenTTL(cfgMap["cap_token_ttl_seconds"]?.value || "1200")
|
||||
setCapAutoSolve(cfgMap["cap_auto_solve"]?.value !== "false")
|
||||
setServerAddress(cfgMap["server_address"]?.value || "")
|
||||
setSmtpHost(cfgMap["smtp_host"]?.value || "")
|
||||
setSmtpPort(cfgMap["smtp_port"]?.value || "587")
|
||||
setSmtpUsername(cfgMap["smtp_username"]?.value || "")
|
||||
setSmtpPassword(cfgMap["smtp_password"]?.value || "")
|
||||
}
|
||||
}, [systemConfigsQuery.data])
|
||||
|
||||
@@ -238,6 +253,86 @@ export function SecurityMain() {
|
||||
saveSystemMutation.mutate()
|
||||
}
|
||||
|
||||
const saveSmtpMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const updates = [
|
||||
{ key: "smtp_host", value: smtpHost },
|
||||
{ key: "smtp_port", value: smtpPort },
|
||||
{ key: "smtp_username", value: smtpUsername },
|
||||
{ key: "smtp_password", value: smtpPassword },
|
||||
]
|
||||
|
||||
for (const update of updates) {
|
||||
const currentCfg = configs[update.key]
|
||||
if (update.key === "smtp_password" && (update.value === "" || update.value === "******")) {
|
||||
// If already configured and sent empty or mask, skip updating it (keep existing)
|
||||
if (currentCfg && currentCfg.value === "******") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
await AdminService.updateSystemConfig(update.key, {
|
||||
value: update.value,
|
||||
description: currentCfg?.description || "",
|
||||
})
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
toast.success("SMTP 邮件配置已成功保存")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "保存配置失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleSmtpSave = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
saveSmtpMutation.mutate()
|
||||
}
|
||||
|
||||
const testSmtpMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
setSmtpTestLog("正在发起连接测试...\n")
|
||||
setSmtpTestSuccess(null)
|
||||
setSmtpTestError("")
|
||||
|
||||
const res = await AdminService.testSMTP({
|
||||
smtp_host: smtpHost,
|
||||
smtp_port: parseInt(smtpPort, 10) || 587,
|
||||
smtp_username: smtpUsername,
|
||||
smtp_password: smtpPassword,
|
||||
to: smtpTestTo,
|
||||
})
|
||||
return res
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setSmtpTestLog(data.log)
|
||||
if (data.success) {
|
||||
setSmtpTestSuccess(true)
|
||||
toast.success("测试邮件发送成功")
|
||||
} else {
|
||||
setSmtpTestSuccess(false)
|
||||
setSmtpTestError(data.error || "发送失败,请检查配置和日志。")
|
||||
toast.error("测试邮件发送失败")
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setSmtpTestSuccess(false)
|
||||
setSmtpTestError(error.message || "请求发送失败")
|
||||
setSmtpTestLog((prev) => prev + `\n[请求错误] ${error.message}\n`)
|
||||
toast.error(error.message || "测试请求发送失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleSmtpTestSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!smtpTestTo) {
|
||||
toast.error("请输入目标邮箱地址")
|
||||
return
|
||||
}
|
||||
testSmtpMutation.mutate()
|
||||
}
|
||||
|
||||
if (loading || !user || !user.is_admin) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -567,6 +662,7 @@ export function SecurityMain() {
|
||||
<TabsContent value="operation" />
|
||||
<TabsContent value="system" className="pt-4">
|
||||
<div className="space-y-6">
|
||||
{/* 通用设置 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -614,6 +710,106 @@ export function SecurityMain() {
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SMTP 邮件设置 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">SMTP 邮件设置</CardTitle>
|
||||
<CardDescription className="text-xs">配置系统的邮件发送服务 (SMTP)</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSmtpSave} className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_host" className="text-xs font-semibold">SMTP 服务器地址</Label>
|
||||
<Input
|
||||
id="smtp_host"
|
||||
type="text"
|
||||
value={smtpHost}
|
||||
onChange={(e) => setSmtpHost(e.target.value)}
|
||||
placeholder="例如: smtp.example.com"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_port" className="text-xs font-semibold">SMTP 端口</Label>
|
||||
<Input
|
||||
id="smtp_port"
|
||||
type="number"
|
||||
value={smtpPort}
|
||||
onChange={(e) => setSmtpPort(e.target.value)}
|
||||
placeholder="例如: 587 或 465"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_username" className="text-xs font-semibold">SMTP 账户</Label>
|
||||
<Input
|
||||
id="smtp_username"
|
||||
type="text"
|
||||
value={smtpUsername}
|
||||
onChange={(e) => setSmtpUsername(e.target.value)}
|
||||
placeholder="例如: sender@example.com"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_password" className="text-xs font-semibold">SMTP 访问凭证</Label>
|
||||
<Input
|
||||
id="smtp_password"
|
||||
type="password"
|
||||
value={smtpPassword}
|
||||
onChange={(e) => setSmtpPassword(e.target.value)}
|
||||
placeholder={configs["smtp_password"]?.value === "******" ? "•••••• (已配置,留空或输入新值)" : "输入凭证密码"}
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-dashed">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSmtpTestOpen(true)
|
||||
setSmtpTestTo("")
|
||||
setSmtpTestLog("")
|
||||
setSmtpTestSuccess(null)
|
||||
setSmtpTestError("")
|
||||
}}
|
||||
disabled={saveSmtpMutation.isPending}
|
||||
>
|
||||
测试发件
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={saveSmtpMutation.isPending}
|
||||
>
|
||||
{saveSmtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存配置"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="status" className="pt-4">
|
||||
@@ -677,6 +873,80 @@ export function SecurityMain() {
|
||||
await authSourcesQuery.refetch()
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={smtpTestOpen} onOpenChange={setSmtpTestOpen}>
|
||||
<DialogContent className="max-w-lg border border-dashed">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold">SMTP 发件测试</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
输入接收测试邮件的邮箱地址。系统将使用您在表单中当前填写的 SMTP 配置进行发件测试。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSmtpTestSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_test_to" className="text-xs font-semibold">目标邮箱地址</Label>
|
||||
<Input
|
||||
id="smtp_test_to"
|
||||
type="email"
|
||||
required
|
||||
value={smtpTestTo}
|
||||
onChange={(e) => setSmtpTestTo(e.target.value)}
|
||||
placeholder="例如: receiver@example.com"
|
||||
className="bg-card border-dashed text-xs"
|
||||
disabled={testSmtpMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{smtpTestLog && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">连接与传输日志</Label>
|
||||
<pre className="bg-zinc-950 text-zinc-50 font-mono p-4 rounded-lg text-[10px] h-60 overflow-y-auto whitespace-pre-wrap border border-dashed border-zinc-800 leading-relaxed">
|
||||
{smtpTestLog}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{smtpTestSuccess === true && (
|
||||
<div className="p-3 rounded-lg border border-dashed border-emerald-500/30 bg-emerald-500/5 text-emerald-500 text-xs">
|
||||
测试成功!邮件已顺利发出。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{smtpTestSuccess === false && (
|
||||
<div className="p-3 rounded-lg border border-dashed border-rose-500/30 bg-rose-500/5 text-rose-500 text-xs break-all">
|
||||
测试失败:{smtpTestError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0 border-t border-dashed pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSmtpTestOpen(false)}
|
||||
disabled={testSmtpMutation.isPending}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={testSmtpMutation.isPending}
|
||||
>
|
||||
{testSmtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
测试中...
|
||||
</>
|
||||
) : (
|
||||
"开始测试"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,6 +117,16 @@ export class AdminService extends BaseService {
|
||||
return this.put<void>(`/system-configs/${ key }`, request);
|
||||
}
|
||||
|
||||
static async testSMTP(request: {
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_username: string;
|
||||
smtp_password: string;
|
||||
to: string;
|
||||
}): Promise<{ success: boolean; log: string; error: string }> {
|
||||
return this.post<{ success: boolean; log: string; error: string }>('/system-configs/smtp/test', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除系统配置
|
||||
* @param key - 配置键
|
||||
|
||||
@@ -1,79 +1,11 @@
|
||||
"use server"
|
||||
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import type { Theme } from "./types"
|
||||
import { THEME_CONFIG } from "./config"
|
||||
|
||||
/**
|
||||
* 从 CSS 片段中解析 CSS 变量
|
||||
* @param cssSection - CSS 代码片段
|
||||
* @returns CSS 变量键值对映射
|
||||
*/
|
||||
function parseCSSVariables(cssSection: string): Record<string, string> {
|
||||
const variables: Record<string, string> = {}
|
||||
const regex = /--([a-z0-9-]+):\s*([^;]+);/g
|
||||
|
||||
let match
|
||||
while ((match = regex.exec(cssSection)) !== null) {
|
||||
variables[match[1]] = match[2].trim()
|
||||
}
|
||||
|
||||
return variables
|
||||
}
|
||||
import type {Theme} from "./types"
|
||||
import themesData from "./themes.json"
|
||||
|
||||
/**
|
||||
* 获取所有可用主题
|
||||
* 通过解析 CSS 文件获取主题列表和颜色配置
|
||||
* @returns 主题列表,按默认主题优先,其余按名称排序
|
||||
* 从预生成的 themes.json 中直接读取主题列表和颜色配置
|
||||
* @returns 主题列表,已按默认主题优先且其余按名称排序
|
||||
*/
|
||||
export async function getAvailableThemes(): Promise<Theme[]> {
|
||||
try {
|
||||
const styleDir = path.join(process.cwd(), THEME_CONFIG.styleDir)
|
||||
const files = await fs.readdir(styleDir)
|
||||
|
||||
const themes = await Promise.all(
|
||||
files
|
||||
.filter((file) => file.endsWith(".css"))
|
||||
.map(async (file) => {
|
||||
/* 生成主题名称 */
|
||||
const name = file === "default.css"
|
||||
? "Default"
|
||||
: file
|
||||
.replace(".css", "")
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
|
||||
const content = await fs.readFile(path.join(styleDir, file), "utf-8")
|
||||
|
||||
/* 从 :root 解析明亮模式颜色 */
|
||||
const rootSection = content.match(/:root\s*\{([^}]+)\}/s)?.[1] || ""
|
||||
const lightColors = parseCSSVariables(rootSection)
|
||||
|
||||
/* 从 .dark 解析暗色模式颜色 */
|
||||
const darkSection = content.match(/\.dark\s*\{([^}]+)\}/s)?.[1] || ""
|
||||
const darkColors = parseCSSVariables(darkSection)
|
||||
|
||||
return {
|
||||
id: file,
|
||||
name,
|
||||
colors: {
|
||||
light: lightColors,
|
||||
dark: darkColors,
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
/* 默认主题排在最前面,其余按名称排序 */
|
||||
return themes.sort((a, b) => {
|
||||
if (a.id === "default.css") return -1
|
||||
if (b.id === "default.css") return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to parse themes:", error)
|
||||
return []
|
||||
}
|
||||
return themesData as Theme[];
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+35
-31
@@ -1,39 +1,43 @@
|
||||
import type { NextConfig } from "next";
|
||||
import type {NextConfig} from "next";
|
||||
|
||||
const isExport = process.env.NEXT_STANDALONE_EXPORT === 'true';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactCompiler: true,
|
||||
experimental: {
|
||||
},
|
||||
async rewrites() {
|
||||
const backendUrl = process.env.LINUX_DO_CREDIT_BACKEND_URL || 'http://localhost:8000';
|
||||
return [
|
||||
// 易支付兼容接口 - 创建订单
|
||||
{
|
||||
source: '/epay/pay/:path*',
|
||||
destination: `${ backendUrl }/pay/:path*`,
|
||||
},
|
||||
// 易支付兼容接口 - 查询订单和退款
|
||||
{
|
||||
source: '/epay/api.php',
|
||||
destination: `${ backendUrl }/api.php`,
|
||||
},
|
||||
// Credit 协议接口 - 商户分发
|
||||
{
|
||||
source: '/lpay/distribute',
|
||||
destination: `${ backendUrl }/pay/distribute`,
|
||||
},
|
||||
// 上传文件静态资源
|
||||
{
|
||||
source: '/f/:id',
|
||||
destination: `${ backendUrl }/f/:id`,
|
||||
},
|
||||
// 标准 RESTful API 接口
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${ backendUrl }/api/:path*`,
|
||||
}
|
||||
];
|
||||
},
|
||||
...(isExport ? { output: 'export' } : {
|
||||
async rewrites() {
|
||||
const backendUrl = process.env.LINUX_DO_CREDIT_BACKEND_URL || 'http://localhost:8000';
|
||||
return [
|
||||
// 易支付兼容接口 - 创建订单
|
||||
{
|
||||
source: '/epay/pay/:path*',
|
||||
destination: `${ backendUrl }/pay/:path*`,
|
||||
},
|
||||
// 易支付兼容接口 - 查询订单和退款
|
||||
{
|
||||
source: '/epay/api.php',
|
||||
destination: `${ backendUrl }/api.php`,
|
||||
},
|
||||
// Credit 协议接口 - 商户分发
|
||||
{
|
||||
source: '/lpay/distribute',
|
||||
destination: `${ backendUrl }/pay/distribute`,
|
||||
},
|
||||
// 上传文件静态资源
|
||||
{
|
||||
source: '/f/:id',
|
||||
destination: `${ backendUrl }/f/:id`,
|
||||
},
|
||||
// 标准 RESTful API 接口
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${ backendUrl }/api/:path*`,
|
||||
}
|
||||
];
|
||||
},
|
||||
})
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
"buildDate": "2026-04-22 16:00:00",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "node scripts/generate-themes.js",
|
||||
"prebuild": "node scripts/generate-themes.js",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"build:embed": "NEXT_STANDALONE_EXPORT=true next build",
|
||||
"start": "next start -p 3010",
|
||||
"lint": "eslint"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const STYLE_DIR = path.join(__dirname, '../public/style');
|
||||
const OUTPUT_FILE = path.join(__dirname, '../lib/theme/themes.json');
|
||||
|
||||
function parseCSSVariables(cssSection) {
|
||||
const variables = {};
|
||||
const regex = /--([a-z0-9-]+):\s*([^;]+);/g;
|
||||
let match;
|
||||
while ((match = regex.exec(cssSection)) !== null) {
|
||||
variables[match[1]] = match[2].trim();
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(STYLE_DIR)) {
|
||||
console.error(`Style directory not found: ${STYLE_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(STYLE_DIR);
|
||||
const themes = files
|
||||
.filter((file) => file.endsWith('.css'))
|
||||
.map((file) => {
|
||||
/* 生成主题名称 */
|
||||
const name = file === 'default.css'
|
||||
? 'Default'
|
||||
: file
|
||||
.replace('.css', '')
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
const content = fs.readFileSync(path.join(STYLE_DIR, file), 'utf-8');
|
||||
|
||||
/* 从 :root 解析明亮模式颜色 */
|
||||
const rootSection = content.match(/:root\s*\{([^}]+)\}/s)?.[1] || "";
|
||||
const lightColors = parseCSSVariables(rootSection);
|
||||
|
||||
/* 从 .dark 解析暗色模式颜色 */
|
||||
const darkSection = content.match(/\.dark\s*\{([^}]+)\}/s)?.[1] || "";
|
||||
const darkColors = parseCSSVariables(darkSection);
|
||||
|
||||
return {
|
||||
id: file,
|
||||
name,
|
||||
colors: {
|
||||
light: lightColors,
|
||||
dark: darkColors,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/* 默认主题排在最前面,其余按名称排序 */
|
||||
themes.sort((a, b) => {
|
||||
if (a.id === 'default.css') return -1;
|
||||
if (b.id === 'default.css') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// 确保输出文件的父目录存在
|
||||
const outputDir = path.dirname(OUTPUT_FILE);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(themes, null, 2), 'utf-8');
|
||||
console.log(`Successfully generated themes.json at ${OUTPUT_FILE}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate themes.json:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/linux-do/credit/internal/db"
|
||||
"github.com/linux-do/credit/internal/model"
|
||||
"github.com/linux-do/credit/internal/util"
|
||||
mail "github.com/linux-do/credit/internal/util/mail"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -123,6 +124,12 @@ func ListSystemConfigs(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range configs {
|
||||
if configs[i].Key == model.ConfigKeySMTPPassword && configs[i].Value != "" {
|
||||
configs[i].Value = "******"
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(configs))
|
||||
}
|
||||
|
||||
@@ -150,6 +157,10 @@ func GetSystemConfig(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if config.Key == model.ConfigKeySMTPPassword && config.Value != "" {
|
||||
config.Value = "******"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(config))
|
||||
}
|
||||
|
||||
@@ -191,11 +202,14 @@ func UpdateSystemConfig(c *gin.Context) {
|
||||
|
||||
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
// 更新配置
|
||||
if err := tx.Model(&config).
|
||||
Updates(map[string]interface{}{
|
||||
"value": req.Value,
|
||||
"description": req.Description,
|
||||
}).Error; err != nil {
|
||||
updates := map[string]interface{}{
|
||||
"description": req.Description,
|
||||
}
|
||||
if !(key == model.ConfigKeySMTPPassword && req.Value == "******") {
|
||||
updates["value"] = req.Value
|
||||
config.Value = req.Value
|
||||
}
|
||||
if err := tx.Model(&config).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -257,3 +271,69 @@ func DeleteSystemConfig(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, util.OKNil())
|
||||
}
|
||||
|
||||
// TestSMTPRequest 测试 SMTP 配置请求
|
||||
type TestSMTPRequest struct {
|
||||
SMTPHost string `json:"smtp_host" binding:"required,max=255"`
|
||||
SMTPPort int `json:"smtp_port" binding:"required"`
|
||||
SMTPUsername string `json:"smtp_username" binding:"required,max=255"`
|
||||
SMTPPassword string `json:"smtp_password" binding:"required,max=255"`
|
||||
To string `json:"to" binding:"required,email"`
|
||||
}
|
||||
|
||||
// TestSMTPResponse 测试 SMTP 配置响应
|
||||
type TestSMTPResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Log string `json:"log"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// TestSMTP 测试 SMTP 邮件发送
|
||||
// @Summary 测试 SMTP 邮件发送
|
||||
// @Description 使用传入的配置进行 SMTP 邮件发送测试,支持使用 ****** 占位符使用保存的数据库密码
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param request body system_config.TestSMTPRequest true "测试请求参数"
|
||||
// @Success 200 {object} util.ResponseAny{data=system_config.TestSMTPResponse} "测试执行完毕"
|
||||
// @Failure 400 {object} util.ResponseAny "参数错误"
|
||||
// @Router /api/v1/admin/system-configs/smtp/test [post]
|
||||
func TestSMTP(c *gin.Context) {
|
||||
var req TestSMTPRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
password := req.SMTPPassword
|
||||
if password == "******" {
|
||||
var sc model.SystemConfig
|
||||
if err := sc.GetByKey(c.Request.Context(), model.ConfigKeySMTPPassword); err == nil {
|
||||
password = sc.Value
|
||||
}
|
||||
}
|
||||
|
||||
cfg := mail.Config{
|
||||
Host: req.SMTPHost,
|
||||
Port: req.SMTPPort,
|
||||
Username: req.SMTPUsername,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
subject := "Wavelet SMTP Test Mail"
|
||||
body := `<h3>SMTP Mail Connection Test</h3>
|
||||
<p>If you received this message, your SMTP configuration is correct and mail sending is working properly.</p>
|
||||
<p>Sent from Wavelet.</p>`
|
||||
|
||||
logs, err := mail.SendMailWithLog(cfg, req.To, subject, body)
|
||||
resp := TestSMTPResponse{
|
||||
Success: err == nil,
|
||||
Log: logs,
|
||||
}
|
||||
if err != nil {
|
||||
resp.Error = err.Error()
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(resp))
|
||||
}
|
||||
|
||||
@@ -17,11 +17,14 @@ limitations under the License.
|
||||
package system_config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/textproto"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -143,9 +146,9 @@ func TestListSystemConfigs(t *testing.T) {
|
||||
var configs []model.SystemConfig
|
||||
json.Unmarshal(dataBytes, &configs)
|
||||
|
||||
// Defaults seed 15 configurations
|
||||
if len(configs) != 15 {
|
||||
t.Errorf("expected 15 default configs, got %d", len(configs))
|
||||
// Defaults seed 19 configurations
|
||||
if len(configs) != 19 {
|
||||
t.Errorf("expected 19 default configs, got %d", len(configs))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -300,3 +303,106 @@ func TestDeleteSystemConfig(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTestSMTP(t *testing.T) {
|
||||
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
|
||||
r := setupTestRouter(adminUser)
|
||||
r.POST("/api/v1/admin/system-configs/smtp/test", TestSMTP)
|
||||
|
||||
// Start a mock SMTP server
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start mock smtp server: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
port := l.Addr().(*net.TCPAddr).Port
|
||||
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
writer := bufio.NewWriter(conn)
|
||||
reader := bufio.NewReader(conn)
|
||||
tp := textproto.NewReader(reader)
|
||||
|
||||
// 220 Ready
|
||||
writer.WriteString("220 mock.smtp.com SMTP Ready\r\n")
|
||||
writer.Flush()
|
||||
|
||||
// Read HELO/EHLO
|
||||
tp.ReadLine()
|
||||
writer.WriteString("250-mock.smtp.com\r\n250 AUTH PLAIN\r\n")
|
||||
writer.Flush()
|
||||
|
||||
// Read AUTH PLAIN
|
||||
tp.ReadLine()
|
||||
writer.WriteString("235 Authentication successful\r\n")
|
||||
writer.Flush()
|
||||
|
||||
// Read MAIL FROM
|
||||
tp.ReadLine()
|
||||
writer.WriteString("250 OK\r\n")
|
||||
writer.Flush()
|
||||
|
||||
// Read RCPT TO
|
||||
tp.ReadLine()
|
||||
writer.WriteString("250 OK\r\n")
|
||||
writer.Flush()
|
||||
|
||||
// Read DATA
|
||||
tp.ReadLine()
|
||||
writer.WriteString("354 Start mail input\r\n")
|
||||
writer.Flush()
|
||||
|
||||
// Read body lines until dot
|
||||
for {
|
||||
line, err := tp.ReadLine()
|
||||
if err != nil || line == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
writer.WriteString("250 OK\r\n")
|
||||
writer.Flush()
|
||||
|
||||
// Read QUIT
|
||||
tp.ReadLine()
|
||||
writer.WriteString("221 Bye\r\n")
|
||||
writer.Flush()
|
||||
}()
|
||||
|
||||
payload := TestSMTPRequest{
|
||||
SMTPHost: "127.0.0.1",
|
||||
SMTPPort: port,
|
||||
SMTPUsername: "sender@example.com",
|
||||
SMTPPassword: "password",
|
||||
To: "recipient@example.com",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/admin/system-configs/smtp/test", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp util.ResponseAny
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
|
||||
dataBytes, _ := json.Marshal(resp.Data)
|
||||
var testResp TestSMTPResponse
|
||||
json.Unmarshal(dataBytes, &testResp)
|
||||
|
||||
if !testResp.Success {
|
||||
t.Errorf("expected test success, got failed: %s. Log: %s", testResp.Error, testResp.Log)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,16 @@ func newMockRedisClient() *mockRedisClient {
|
||||
|
||||
func (m *mockRedisClient) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd {
|
||||
cmd := redis.NewStatusCmd(ctx)
|
||||
m.store[key] = fmt.Sprintf("%v", value)
|
||||
var val string
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
val = string(v)
|
||||
case string:
|
||||
val = v
|
||||
default:
|
||||
val = fmt.Sprintf("%v", v)
|
||||
}
|
||||
m.store[key] = val
|
||||
cmd.SetVal("OK")
|
||||
return cmd
|
||||
}
|
||||
@@ -99,7 +108,15 @@ func (m *mockRedisClient) HSet(ctx context.Context, key string, values ...interf
|
||||
cmd := redis.NewIntCmd(ctx)
|
||||
if len(values) >= 2 {
|
||||
field := fmt.Sprintf("%v", values[0])
|
||||
val := fmt.Sprintf("%v", values[1])
|
||||
var val string
|
||||
switch v := values[1].(type) {
|
||||
case []byte:
|
||||
val = string(v)
|
||||
case string:
|
||||
val = v
|
||||
default:
|
||||
val = fmt.Sprintf("%v", v)
|
||||
}
|
||||
compositeKey := key + ":" + field
|
||||
m.store[compositeKey] = val
|
||||
cmd.SetVal(1)
|
||||
@@ -265,6 +282,15 @@ func setupTestDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to migrate schema: %v", err)
|
||||
}
|
||||
|
||||
// 注入测试所需的服务器地址配置
|
||||
if err := dbConn.Create(&model.SystemConfig{
|
||||
Key: model.ConfigKeyServerAddress,
|
||||
Value: "http://localhost:3000",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("failed to seed server_address config: %v", err)
|
||||
}
|
||||
|
||||
return dbConn
|
||||
}
|
||||
|
||||
@@ -332,7 +358,6 @@ func initializeTestConfig() {
|
||||
config.Config.App.SessionCookieName = "test_session_id"
|
||||
config.Config.App.SessionSecret = "test_session_secret"
|
||||
config.Config.App.APIPrefix = "/api"
|
||||
config.Config.App.FrontendURL = "http://localhost:3000"
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/linux-do/credit/internal/common"
|
||||
"github.com/linux-do/credit/internal/config"
|
||||
"github.com/linux-do/credit/internal/db"
|
||||
"github.com/linux-do/credit/internal/model"
|
||||
"github.com/linux-do/credit/internal/util"
|
||||
@@ -104,11 +103,12 @@ func activeLoginSources() []AuthSourceView {
|
||||
return sources
|
||||
}
|
||||
|
||||
func frontendLoginRedirectURL() string {
|
||||
if config.Config.App.FrontendURL != "" {
|
||||
return strings.TrimRight(config.Config.App.FrontendURL, "/") + "/login"
|
||||
func getFrontendLoginRedirectURL(ctx context.Context) (string, error) {
|
||||
var sc model.SystemConfig
|
||||
if err := sc.GetByKey(ctx, model.ConfigKeyServerAddress); err != nil || strings.TrimSpace(sc.Value) == "" {
|
||||
return "", errors.New("服务器地址 (server_address) 未配置或配置为空,请在后台系统设置中配置后再试")
|
||||
}
|
||||
return "/login"
|
||||
return strings.TrimRight(sc.Value, "/") + "/login", nil
|
||||
}
|
||||
|
||||
func buildOAuthConfig(ctx context.Context, source *model.AuthSource, redirectURL string) (*oauth2.Config, *oidc.IDTokenVerifier, error) {
|
||||
@@ -304,14 +304,18 @@ func GetLoginURL(c *gin.Context) {
|
||||
|
||||
authorizeURL, err := buildAuthorizeURL(c.Request.Context(), source, state)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, util.OK(OAuthAuthorizeResponse{AuthorizeURL: authorizeURL}))
|
||||
}
|
||||
|
||||
func buildAuthorizeURL(ctx context.Context, source *model.AuthSource, state string) (string, error) {
|
||||
authConfig, verifier, err := buildOAuthConfig(ctx, source, frontendLoginRedirectURL())
|
||||
redirectURL, err := getFrontendLoginRedirectURL(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
authConfig, verifier, err := buildOAuthConfig(ctx, source, redirectURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -361,7 +365,7 @@ func Authorize(c *gin.Context) {
|
||||
}
|
||||
authorizeURL, err := buildAuthorizeURL(c.Request.Context(), source, state)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, util.OK(OAuthAuthorizeResponse{AuthorizeURL: authorizeURL}))
|
||||
@@ -407,7 +411,13 @@ func Callback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userInfo, err := buildOAuthUserInfo(ctx, source, req.Code, req.State, frontendLoginRedirectURL())
|
||||
redirectURL, err := getFrontendLoginRedirectURL(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
userInfo, err := buildOAuthUserInfo(ctx, source, req.Code, req.State, redirectURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
|
||||
@@ -38,7 +38,6 @@ type appConfig struct {
|
||||
NodeID int64 `mapstructure:"node_id"`
|
||||
APIPrefix string `mapstructure:"api_prefix"`
|
||||
GracefulShutdownTimeout int `mapstructure:"graceful_shutdown_timeout"`
|
||||
FrontendURL string `mapstructure:"frontend_url"`
|
||||
SessionCookieName string `mapstructure:"session_cookie_name"`
|
||||
SessionSecret string `mapstructure:"session_secret"`
|
||||
SessionDomain string `mapstructure:"session_domain"`
|
||||
|
||||
@@ -90,6 +90,10 @@ func initSystemConfigs() {
|
||||
ensureConfigKeyExists(model.ConfigKeyCapChallengeTTL, "600", "system", "人机验证难题有效时间(秒)")
|
||||
ensureConfigKeyExists(model.ConfigKeyCapTokenTTL, "1200", "system", "人机验证兑换凭证有效时间(秒)")
|
||||
ensureConfigKeyExists(model.ConfigKeyServerAddress, "", "system", "服务器地址(用于跨域源控制,不设定则允许任意源)")
|
||||
ensureConfigKeyExists(model.ConfigKeySMTPHost, "", "system", "SMTP 服务器地址(例如 smtp.example.com)")
|
||||
ensureConfigKeyExists(model.ConfigKeySMTPPort, "587", "system", "SMTP 端口(例如 587 或 465)")
|
||||
ensureConfigKeyExists(model.ConfigKeySMTPUsername, "", "system", "SMTP 账户(如 sender@example.com)")
|
||||
ensureConfigKeyExists(model.ConfigKeySMTPPassword, "", "system", "SMTP 访问凭证(授权码/密码)")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -142,6 +146,30 @@ func initSystemConfigs() {
|
||||
Type: "system",
|
||||
Description: "服务器地址(用于跨域源控制,不设定则允许任意源)",
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeySMTPHost,
|
||||
Value: "",
|
||||
Type: "system",
|
||||
Description: "SMTP 服务器地址(例如 smtp.example.com)",
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeySMTPPort,
|
||||
Value: "587",
|
||||
Type: "system",
|
||||
Description: "SMTP 端口(例如 587 或 465)",
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeySMTPUsername,
|
||||
Value: "",
|
||||
Type: "system",
|
||||
Description: "SMTP 账户(如 sender@example.com)",
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeySMTPPassword,
|
||||
Value: "",
|
||||
Type: "system",
|
||||
Description: "SMTP 访问凭证(授权码/密码)",
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeyUploadAllowedExtensions,
|
||||
Value: "jpg,png,webp",
|
||||
@@ -150,7 +178,7 @@ func initSystemConfigs() {
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeySiteName,
|
||||
Value: "Antigravity Project",
|
||||
Value: "Wavelet",
|
||||
Type: "system",
|
||||
Description: "系统平台的展示名称",
|
||||
},
|
||||
|
||||
@@ -46,6 +46,10 @@ const (
|
||||
ConfigKeyCapChallengeTTL = "cap_challenge_ttl_seconds" // 人机验证难题有效时间(秒)
|
||||
ConfigKeyCapTokenTTL = "cap_token_ttl_seconds" // 人机验证兑换凭证有效时间(秒)
|
||||
ConfigKeyServerAddress = "server_address" // 服务器地址
|
||||
ConfigKeySMTPHost = "smtp_host" // SMTP 服务器地址
|
||||
ConfigKeySMTPPort = "smtp_port" // SMTP 端口
|
||||
ConfigKeySMTPUsername = "smtp_username" // SMTP 账户
|
||||
ConfigKeySMTPPassword = "smtp_password" // SMTP 访问凭证
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//go:build !embed_frontend
|
||||
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func registerFrontend(r *gin.Engine) {
|
||||
// No-op when not embedding frontend
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//go:build embed_frontend
|
||||
|
||||
/*
|
||||
Copyright 2025 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package router
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var frontendFS embed.FS
|
||||
|
||||
func serveFileDirect(c *gin.Context, subFS fs.FS, filePath string) bool {
|
||||
file, err := subFS.Open(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if stat.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
seeker, ok := file.(io.ReadSeeker)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// 使用 http.ServeContent 直接输出文件内容,不进行路径规范化重定向
|
||||
http.ServeContent(c.Writer, c.Request, filePath, stat.ModTime(), seeker)
|
||||
return true
|
||||
}
|
||||
|
||||
func registerFrontend(r *gin.Engine) {
|
||||
subFS, err := fs.Sub(frontendFS, "dist")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// API 接口路由或文件服务路由 -> 直接返回,由 Gin 处理标准 404
|
||||
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/f/") {
|
||||
return
|
||||
}
|
||||
|
||||
// 只处理 GET 和 HEAD 请求
|
||||
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
|
||||
c.JSON(http.StatusMethodNotAllowed, gin.H{"error_msg": "Method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
// 移除开头的斜杠以在嵌入文件系统中查找
|
||||
cleanPath := strings.TrimPrefix(path, "/")
|
||||
|
||||
// 1. 根路径 -> 直接输出 index.html
|
||||
if cleanPath == "" {
|
||||
if serveFileDirect(c, subFS, "index.html") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 精确匹配(如果对应的文件存在,直接输出)
|
||||
if serveFileDirect(c, subFS, cleanPath) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是个目录(例如请求了 "/login",同时 dist 目录下存在一个叫 "login" 的文件夹目录),
|
||||
// 则查找是否有对应的 ".html" 文件(例如 "login.html")并进行输出。
|
||||
if cleanPath != "" {
|
||||
htmlPath := cleanPath + ".html"
|
||||
if serveFileDirect(c, subFS, htmlPath) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Next.js Clean URLs 兜底逻辑(例如访问 /settings/security -> 实际映射输出 settings/security.html)
|
||||
if !strings.Contains(cleanPath, ".") {
|
||||
htmlPath := cleanPath + ".html"
|
||||
if serveFileDirect(c, subFS, htmlPath) {
|
||||
return
|
||||
}
|
||||
indexPath := cleanPath + "/index.html"
|
||||
if serveFileDirect(c, subFS, indexPath) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 单页应用(SPA)前端路由兜底:返回 index.html
|
||||
if serveFileDirect(c, subFS, "index.html") {
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -200,6 +200,7 @@ func Serve() {
|
||||
// System Config
|
||||
adminRouter.POST("/system-configs", system_config.CreateSystemConfig)
|
||||
adminRouter.GET("/system-configs", system_config.ListSystemConfigs)
|
||||
adminRouter.POST("/system-configs/smtp/test", system_config.TestSMTP)
|
||||
|
||||
systemConfigRouter := adminRouter.Group("/system-configs/:key")
|
||||
{
|
||||
@@ -218,6 +219,9 @@ func Serve() {
|
||||
}
|
||||
}
|
||||
|
||||
// 注册前端静态路由(当启用 embed_frontend 编译标签时)
|
||||
registerFrontend(r)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: config.Config.App.Addr,
|
||||
Handler: r,
|
||||
|
||||
@@ -100,7 +100,7 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) {
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeySiteName,
|
||||
Value: "Antigravity Project",
|
||||
Value: "Wavelet",
|
||||
Type: "system",
|
||||
Description: "系统平台的展示名称",
|
||||
},
|
||||
@@ -182,6 +182,30 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) {
|
||||
Type: "system",
|
||||
Description: "服务器地址(用于跨域源控制,不设定则允许任意源)",
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeySMTPHost,
|
||||
Value: "",
|
||||
Type: "system",
|
||||
Description: "SMTP 服务器地址(例如 smtp.example.com)",
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeySMTPPort,
|
||||
Value: "587",
|
||||
Type: "system",
|
||||
Description: "SMTP 端口(例如 587 或 465)",
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeySMTPUsername,
|
||||
Value: "",
|
||||
Type: "system",
|
||||
Description: "SMTP 账户(如 sender@example.com)",
|
||||
},
|
||||
{
|
||||
Key: model.ConfigKeySMTPPassword,
|
||||
Value: "",
|
||||
Type: "system",
|
||||
Description: "SMTP 访问凭证(授权码/密码)",
|
||||
},
|
||||
}
|
||||
|
||||
if err := tx.Create(&defaultConfigs).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
Copyright 2026 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config represents SMTP mail configuration
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// SendMail sends an HTML email using the provided config and message details
|
||||
func SendMail(cfg Config, to string, subject, body string) error {
|
||||
return SendMailHTML(cfg, to, subject, body)
|
||||
}
|
||||
|
||||
// SendMailHTML sends an HTML format email
|
||||
func SendMailHTML(cfg Config, to string, subject, body string) error {
|
||||
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
|
||||
|
||||
// Header & MIME settings for HTML email
|
||||
header := make(map[string]string)
|
||||
header["From"] = cfg.Username
|
||||
header["To"] = to
|
||||
header["Subject"] = subject
|
||||
header["MIME-Version"] = "1.0"
|
||||
header["Content-Type"] = "text/html; charset=UTF-8"
|
||||
|
||||
message := ""
|
||||
for k, v := range header {
|
||||
message += fmt.Sprintf("%s: %s\r\n", k, v)
|
||||
}
|
||||
message += "\r\n" + body
|
||||
|
||||
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
|
||||
|
||||
// If using SSL port 465, we connection via TLS dial
|
||||
if cfg.Port == 465 {
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: cfg.Host,
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial tls failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
client, err := smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp client creation failed: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth failed: %w", err)
|
||||
}
|
||||
|
||||
if err = client.Mail(cfg.Username); err != nil {
|
||||
return fmt.Errorf("smtp mail command failed: %w", err)
|
||||
}
|
||||
|
||||
if err = client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("smtp rcpt command failed: %w", err)
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp data command failed: %w", err)
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
_, err = w.Write([]byte(message))
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp writing body failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// For standard port (587 / 25), use smtp.SendMail directly (handles STARTTLS automatically if server supports it)
|
||||
err := smtp.SendMail(addr, auth, cfg.Username, []string{to}, []byte(message))
|
||||
if err != nil {
|
||||
return fmt.Errorf("send mail failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendMailWithLog sends a test email and records a detailed SMTP connection log
|
||||
func SendMailWithLog(cfg Config, to string, subject, body string) (string, error) {
|
||||
var logBuf bytes.Buffer
|
||||
logLine := func(dir string, format string, args ...interface{}) {
|
||||
logBuf.WriteString(fmt.Sprintf("[%s] %s\n", dir, fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
|
||||
logLine("System", "Connecting to %s...", addr)
|
||||
|
||||
var conn net.Conn
|
||||
var err error
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
if cfg.Port == 465 {
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: cfg.Host,
|
||||
}
|
||||
conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
|
||||
} else {
|
||||
conn, err = dialer.Dial("tcp", addr)
|
||||
}
|
||||
if err != nil {
|
||||
logLine("Error", "Connection failed: %v", err)
|
||||
return logBuf.String(), err
|
||||
}
|
||||
defer conn.Close()
|
||||
logLine("System", "Connected successfully.")
|
||||
|
||||
// Set a 10-second session deadline for read/write operations
|
||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
client, err := smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
logLine("Error", "SMTP client handshake failed: %v", err)
|
||||
return logBuf.String(), err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// If not 465, support STARTTLS if available
|
||||
if cfg.Port != 465 {
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
logLine("C", "STARTTLS")
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: cfg.Host,
|
||||
}
|
||||
if err = client.StartTLS(tlsConfig); err != nil {
|
||||
logLine("Error", "STARTTLS failed: %v", err)
|
||||
return logBuf.String(), err
|
||||
}
|
||||
logLine("S", "220 Ready to start TLS")
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication
|
||||
if cfg.Username != "" && cfg.Password != "" {
|
||||
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
|
||||
logLine("C", "AUTH PLAIN **********")
|
||||
if err = client.Auth(auth); err != nil {
|
||||
logLine("Error", "Authentication failed: %v", err)
|
||||
return logBuf.String(), err
|
||||
}
|
||||
logLine("S", "235 Authentication successful")
|
||||
}
|
||||
|
||||
// Mail command
|
||||
logLine("C", "MAIL FROM:<%s>", cfg.Username)
|
||||
if err = client.Mail(cfg.Username); err != nil {
|
||||
logLine("Error", "MAIL FROM command failed: %v", err)
|
||||
return logBuf.String(), err
|
||||
}
|
||||
logLine("S", "250 OK")
|
||||
|
||||
// Rcpt command
|
||||
logLine("C", "RCPT TO:<%s>", to)
|
||||
if err = client.Rcpt(to); err != nil {
|
||||
logLine("Error", "RCPT TO command failed: %v", err)
|
||||
return logBuf.String(), err
|
||||
}
|
||||
logLine("S", "250 OK")
|
||||
|
||||
// Data command
|
||||
logLine("C", "DATA")
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
logLine("Error", "DATA command failed: %v", err)
|
||||
return logBuf.String(), err
|
||||
}
|
||||
logLine("S", "354 Start mail input")
|
||||
|
||||
// Header & MIME settings for HTML email
|
||||
header := make(map[string]string)
|
||||
header["From"] = cfg.Username
|
||||
header["To"] = to
|
||||
header["Subject"] = subject
|
||||
header["MIME-Version"] = "1.0"
|
||||
header["Content-Type"] = "text/html; charset=UTF-8"
|
||||
|
||||
message := ""
|
||||
for k, v := range header {
|
||||
message += fmt.Sprintf("%s: %s\r\n", k, v)
|
||||
}
|
||||
message += "\r\n" + body
|
||||
|
||||
logLine("System", "Sending message body...")
|
||||
if _, err = w.Write([]byte(message)); err != nil {
|
||||
w.Close()
|
||||
logLine("Error", "Writing message body failed: %v", err)
|
||||
return logBuf.String(), err
|
||||
}
|
||||
w.Close()
|
||||
logLine("S", "250 OK")
|
||||
|
||||
logLine("C", "QUIT")
|
||||
_ = client.Quit()
|
||||
logLine("System", "Mail sent successfully!")
|
||||
|
||||
return logBuf.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
Copyright 2026 linux.do
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSendMailMock(t *testing.T) {
|
||||
// Start a mock SMTP server
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start mock smtp server: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
port := l.Addr().(*net.TCPAddr).Port
|
||||
|
||||
go func() {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
writer := bufio.NewWriter(conn)
|
||||
reader := bufio.NewReader(conn)
|
||||
tp := textproto.NewReader(reader)
|
||||
|
||||
// 220 Ready
|
||||
_, _ = writer.WriteString("220 mock.smtp.com SMTP Ready\r\n")
|
||||
_ = writer.Flush()
|
||||
|
||||
// Read HELO/EHLO
|
||||
_, _ = tp.ReadLine()
|
||||
_, _ = writer.WriteString("250-mock.smtp.com\r\n250 AUTH PLAIN\r\n")
|
||||
_ = writer.Flush()
|
||||
|
||||
// Read AUTH PLAIN
|
||||
_, _ = tp.ReadLine()
|
||||
_, _ = writer.WriteString("235 Authentication successful\r\n")
|
||||
_ = writer.Flush()
|
||||
|
||||
// Read MAIL FROM
|
||||
_, _ = tp.ReadLine()
|
||||
_, _ = writer.WriteString("250 OK\r\n")
|
||||
_ = writer.Flush()
|
||||
|
||||
// Read RCPT TO
|
||||
_, _ = tp.ReadLine()
|
||||
_, _ = writer.WriteString("250 OK\r\n")
|
||||
_ = writer.Flush()
|
||||
|
||||
// Read DATA
|
||||
_, _ = tp.ReadLine()
|
||||
_, _ = writer.WriteString("354 Start mail input\r\n")
|
||||
_ = writer.Flush()
|
||||
|
||||
// Read body lines until dot
|
||||
for {
|
||||
line, err := tp.ReadLine()
|
||||
if err != nil || line == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
_, _ = writer.WriteString("250 OK\r\n")
|
||||
_ = writer.Flush()
|
||||
|
||||
// Read QUIT
|
||||
_, _ = tp.ReadLine()
|
||||
_, _ = writer.WriteString("221 Bye\r\n")
|
||||
_ = writer.Flush()
|
||||
}()
|
||||
|
||||
cfg := Config{
|
||||
Host: "127.0.0.1",
|
||||
Port: port,
|
||||
Username: "test@example.com",
|
||||
Password: "password",
|
||||
}
|
||||
|
||||
err = SendMail(cfg, "recipient@example.com", "Test Subject", "<h1>Test Body</h1>")
|
||||
if err != nil {
|
||||
t.Errorf("failed to send mail: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user