Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b63aabaa79 | ||
|
|
9b0e290d5d | ||
|
|
c161d42b06 |
@@ -68,6 +68,9 @@ server/web/dist/
|
||||
/web/
|
||||
/wishing-tree-pages/
|
||||
|
||||
# Git worktrees(本地并行开发目录,不属于本仓库源码;锚定根目录,避免误伤同名子目录)
|
||||
/worktrees/
|
||||
|
||||
# 数据库
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
@@ -14,3 +14,7 @@
|
||||
- 商业前端的本地 `file:` 依赖可能缓存旧版 `@lunar/core`;生产构建必须先构建 core,并通过显式别名引用最新 `dist`。
|
||||
- 神煞规则不能统一套用“年干与日干”两个锚点;必须按每个神煞的标准查法指定锚点,并使用 `pp.test` 中的真实命例做回归测试。
|
||||
- 大运切换必须同时切换该步大运对应的十年流年,不能让当前大运标题与首步大运流年混用。
|
||||
|
||||
## 2026-09-25:邮件服务方案必须先确认代码可用
|
||||
|
||||
- 推荐邮箱 SMTP 服务前,先核对后端是否已实现对应协议;不能把“服务商支持 SMTP”说成“当前应用已经可以发信”。必须先完成实现和端到端验证,再指导用户配置凭证。
|
||||
|
||||
@@ -9,6 +9,16 @@ DB_NAME=lunar.db
|
||||
JWT_SECRET=replace-with-a-long-random-secret
|
||||
ADMIN_PASSWORD=replace-with-a-strong-admin-password
|
||||
|
||||
SMTP_HOST=smtp.qq.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USERNAME=your-qq-email@qq.com
|
||||
SMTP_PASSWORD=your-qq-smtp-authorization-code
|
||||
# 可填写已在 QQ 域名邮箱绑定到该账号的域名地址,例如 z@kepo.net;域名需配置 SPF
|
||||
SMTP_FROM_EMAIL=z@kepo.net
|
||||
SMTP_TLS_MODE=implicit
|
||||
SMTP_TLS_CA_FILE=
|
||||
|
||||
# 可选备用通道;未配置 SMTP 时才会使用
|
||||
POSTMARK_SERVER_TOKEN=
|
||||
POSTMARK_FROM_EMAIL=login@your-domain.com
|
||||
POSTMARK_API_BASE_URL=https://api.postmarkapp.com
|
||||
|
||||
+13
-5
@@ -5,7 +5,7 @@
|
||||
## 功能模块
|
||||
|
||||
- 微信小程序登录与生辰档案。
|
||||
- Postmark 邮箱验证码登录。
|
||||
- QQ 邮箱 SMTP 验证码登录,支持 465 隐式 TLS 或 587 STARTTLS;Postmark 可选备用。
|
||||
- Stripe `4.99 USD` 报告支付。
|
||||
- Binance Pay `5 USDT` 报告支付。
|
||||
- 签名 Webhook、幂等事件、退款权益撤销。
|
||||
@@ -35,7 +35,7 @@ go run ./cmd
|
||||
go test ./...
|
||||
```
|
||||
|
||||
测试环境文件为本地 `server/.env.test`,不提交任何真实密钥。
|
||||
本地测试配置使用 `server/.env.test`。如果文件包含 SMTP 凭证,必须保持 Git 忽略并限制文件权限,绝不提交到仓库。真实发信测试需显式运行 `RUN_QQ_SMTP_LIVE_TEST=1 SMTP_TEST_TO=收件地址 go test ./internal/handler -run '^TestRequestEmailLoginCodeLiveQQSMTP$' -count=1`;该命令会实际发送邮件。
|
||||
|
||||
## 商业支付 API
|
||||
|
||||
@@ -88,14 +88,22 @@ mysql < migrations/002_commerce.sql
|
||||
|
||||
## 环境变量
|
||||
|
||||
完整模板见 `.env.example`。生产环境至少需要:
|
||||
完整模板见 `.env.example`。邮箱登录配置 QQ SMTP:
|
||||
|
||||
- `SMTP_HOST=smtp.qq.com`
|
||||
- `SMTP_PORT=465`(或 587)
|
||||
- `SMTP_USERNAME`:完整 QQ 邮箱地址
|
||||
- `SMTP_PASSWORD`:QQ 邮箱生成的 SMTP 授权码,不是邮箱登录密码
|
||||
- `SMTP_FROM_EMAIL`:发件地址。生产使用 `z@kepo.net`(`kepo.net` 已通过 QQ 域名邮箱绑定,MX 为 `mxdomain.qq.com`);需在 DNS 添加 SPF 记录 `v=spf1 include:spf.mail.qq.com ~all`,否则 Gmail 等可能拒收或判为垃圾邮件
|
||||
- `SMTP_TLS_MODE=implicit`(465)或 `starttls`(587)
|
||||
- `SMTP_TLS_CA_FILE`:可选的自定义 CA 文件路径
|
||||
|
||||
QQ 邮箱需先在账户设置中开启 SMTP 服务。SMTP 配置有效时优先使用 SMTP;未配置时才尝试 Postmark。生产环境还需要:
|
||||
|
||||
- `JWT_SECRET`
|
||||
- `ADMIN_PASSWORD`
|
||||
- `PUBLIC_BASE_URL`
|
||||
- `WEB_APP_URL`
|
||||
- `POSTMARK_SERVER_TOKEN`
|
||||
- `POSTMARK_FROM_EMAIL`
|
||||
- `STRIPE_SECRET_KEY`
|
||||
- `STRIPE_WEBHOOK_SECRET`
|
||||
- `BINANCE_PAY_API_KEY`
|
||||
|
||||
@@ -14,6 +14,7 @@ type Config struct {
|
||||
Stripe StripeConfig
|
||||
BinancePay BinancePayConfig
|
||||
Postmark PostmarkConfig
|
||||
SMTP SMTPConfig
|
||||
Commerce CommerceConfig
|
||||
Admin AdminConfig
|
||||
Wiki WikiConfig
|
||||
@@ -71,6 +72,20 @@ type PostmarkConfig struct {
|
||||
APIBaseURL string
|
||||
}
|
||||
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
Username string
|
||||
Password string
|
||||
FromEmail string
|
||||
TLSMode string
|
||||
TLSCAFile string
|
||||
}
|
||||
|
||||
func (c SMTPConfig) IsConfigured() bool {
|
||||
return c.Host != "" || c.Username != "" || c.Password != "" || c.FromEmail != ""
|
||||
}
|
||||
|
||||
type CommerceConfig struct {
|
||||
PublicBaseURL string
|
||||
WebAppURL string
|
||||
@@ -136,6 +151,15 @@ func Load() *Config {
|
||||
FromEmail: getEnv("POSTMARK_FROM_EMAIL", ""),
|
||||
APIBaseURL: getEnv("POSTMARK_API_BASE_URL", "https://api.postmarkapp.com"),
|
||||
},
|
||||
SMTP: SMTPConfig{
|
||||
Host: getEnv("SMTP_HOST", ""),
|
||||
Port: getEnv("SMTP_PORT", "465"),
|
||||
Username: getEnv("SMTP_USERNAME", ""),
|
||||
Password: getEnv("SMTP_PASSWORD", ""),
|
||||
FromEmail: getEnv("SMTP_FROM_EMAIL", ""),
|
||||
TLSMode: getEnv("SMTP_TLS_MODE", "implicit"),
|
||||
TLSCAFile: getEnv("SMTP_TLS_CA_FILE", ""),
|
||||
},
|
||||
Commerce: CommerceConfig{
|
||||
PublicBaseURL: getEnv("PUBLIC_BASE_URL", "http://localhost:8080"),
|
||||
WebAppURL: getEnv("WEB_APP_URL", "http://localhost:4260"),
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -26,6 +27,8 @@ func RequestEmailLoginCode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// 保留 service 返回的 provider 错误(含服务商名称与上游状态码),便于生产排障关联
|
||||
log.Printf("[EMAIL-AUTH] 验证码发送失败: email=%s err=%v", request.Email, err)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "验证码暂时无法发送"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gouki/lunar-server/internal/config"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type smtpTestResult struct {
|
||||
message string
|
||||
authenticated bool
|
||||
err error
|
||||
}
|
||||
|
||||
type smtpTestServer struct {
|
||||
port string
|
||||
caFile string
|
||||
tlsMode string
|
||||
results chan smtpTestResult
|
||||
}
|
||||
|
||||
func startSMTPTestServer(t *testing.T, tlsMode string, rejectAuthentication bool) smtpTestServer {
|
||||
t.Helper()
|
||||
certificate, caFile := createSMTPTestCertificate(t)
|
||||
listener, err := net.Listen("tcp", "localhost:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { listener.Close() })
|
||||
results := make(chan smtpTestResult, 1)
|
||||
port := fmt.Sprint(listener.Addr().(*net.TCPAddr).Port)
|
||||
go serveSMTPTestConnection(listener, certificate, tlsMode, rejectAuthentication, results)
|
||||
return smtpTestServer{port: port, caFile: caFile, tlsMode: tlsMode, results: results}
|
||||
}
|
||||
|
||||
func createSMTPTestCertificate(t *testing.T) (tls.Certificate, string) {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rootTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
NotBefore: now.Add(-time.Minute),
|
||||
NotAfter: now.Add(time.Hour),
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
rootDER, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, &rootKey.PublicKey, rootKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rootCertificate, err := x509.ParseCertificate(rootDER)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serverKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serverTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
NotBefore: now.Add(-time.Minute),
|
||||
NotAfter: now.Add(time.Hour),
|
||||
DNSNames: []string{"localhost"},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
serverDER, err := x509.CreateCertificate(rand.Reader, serverTemplate, rootCertificate, &serverKey.PublicKey, rootKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
privateKeyDER, err := x509.MarshalECPrivateKey(serverKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificate, err := tls.X509KeyPair(
|
||||
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverDER}),
|
||||
pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privateKeyDER}),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caFile := filepath.Join(t.TempDir(), "smtp-test-ca.pem")
|
||||
if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER}), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return certificate, caFile
|
||||
}
|
||||
|
||||
func serveSMTPTestConnection(listener net.Listener, certificate tls.Certificate, tlsMode string, rejectAuthentication bool, results chan<- smtpTestResult) {
|
||||
connection, err := listener.Accept()
|
||||
if err != nil {
|
||||
results <- smtpTestResult{err: err}
|
||||
return
|
||||
}
|
||||
defer connection.Close()
|
||||
serverTLSConfig := &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12}
|
||||
tlsActive := false
|
||||
if tlsMode == "implicit" {
|
||||
tlsConnection := tls.Server(connection, serverTLSConfig)
|
||||
if err := tlsConnection.Handshake(); err != nil {
|
||||
results <- smtpTestResult{err: err}
|
||||
return
|
||||
}
|
||||
connection = tlsConnection
|
||||
tlsActive = true
|
||||
}
|
||||
reader := bufio.NewReader(connection)
|
||||
writer := bufio.NewWriter(connection)
|
||||
writeResponse := func(response string) error {
|
||||
if _, err := writer.WriteString(response + "\r\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
return writer.Flush()
|
||||
}
|
||||
if err := writeResponse("220 localhost ESMTP"); err != nil {
|
||||
results <- smtpTestResult{err: err}
|
||||
return
|
||||
}
|
||||
var message strings.Builder
|
||||
for {
|
||||
command, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
results <- smtpTestResult{err: err}
|
||||
return
|
||||
}
|
||||
trimmed := strings.TrimRight(command, "\r\n")
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "EHLO "):
|
||||
capabilities := "250-localhost\r\n250-AUTH PLAIN\r\n250 OK\r\n"
|
||||
if tlsMode == "starttls" && !tlsActive {
|
||||
capabilities = "250-localhost\r\n250-STARTTLS\r\n250 OK\r\n"
|
||||
}
|
||||
if _, err := writer.WriteString(capabilities); err != nil {
|
||||
results <- smtpTestResult{err: err}
|
||||
return
|
||||
}
|
||||
if err := writer.Flush(); err != nil {
|
||||
results <- smtpTestResult{err: err}
|
||||
return
|
||||
}
|
||||
case trimmed == "STARTTLS":
|
||||
if err := writeResponse("220 2.0.0 ready to start TLS"); err != nil {
|
||||
results <- smtpTestResult{err: err}
|
||||
return
|
||||
}
|
||||
tlsConnection := tls.Server(connection, serverTLSConfig)
|
||||
if err := tlsConnection.Handshake(); err != nil {
|
||||
results <- smtpTestResult{err: err}
|
||||
return
|
||||
}
|
||||
connection = tlsConnection
|
||||
reader = bufio.NewReader(connection)
|
||||
writer = bufio.NewWriter(connection)
|
||||
tlsActive = true
|
||||
case strings.HasPrefix(trimmed, "AUTH PLAIN"):
|
||||
encoded := strings.TrimSpace(strings.TrimPrefix(trimmed, "AUTH PLAIN"))
|
||||
if encoded == "" {
|
||||
if err := writeResponse("334 "); err != nil {
|
||||
results <- smtpTestResult{err: err}
|
||||
return
|
||||
}
|
||||
encoded, err = reader.ReadString('\n')
|
||||
if err != nil {
|
||||
results <- smtpTestResult{err: err}
|
||||
return
|
||||
}
|
||||
encoded = strings.TrimSpace(encoded)
|
||||
}
|
||||
credentials, decodeErr := base64.StdEncoding.DecodeString(encoded)
|
||||
parts := strings.Split(string(credentials), "\x00")
|
||||
authenticated := decodeErr == nil && len(parts) == 3 && parts[1] == "smtp-user@example.test" && parts[2] == "smtp-test-password"
|
||||
if rejectAuthentication || !authenticated {
|
||||
writeResponse("535 5.7.8 Authentication credentials invalid")
|
||||
results <- smtpTestResult{authenticated: false}
|
||||
return
|
||||
}
|
||||
if err := writeResponse("235 2.7.0 Authentication successful"); err != nil {
|
||||
results <- smtpTestResult{err: err}
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(trimmed, "MAIL FROM:") || strings.HasPrefix(trimmed, "RCPT TO:"):
|
||||
if err := writeResponse("250 2.1.0 accepted"); err != nil {
|
||||
results <- smtpTestResult{err: err}
|
||||
return
|
||||
}
|
||||
case trimmed == "DATA":
|
||||
if err := writeResponse("354 end with <CRLF>.<CRLF>"); err != nil {
|
||||
results <- smtpTestResult{err: err}
|
||||
return
|
||||
}
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
results <- smtpTestResult{err: err}
|
||||
return
|
||||
}
|
||||
if line == ".\r\n" {
|
||||
break
|
||||
}
|
||||
message.WriteString(strings.TrimPrefix(line, "."))
|
||||
}
|
||||
if err := writeResponse("250 2.0.0 queued"); err != nil {
|
||||
results <- smtpTestResult{err: err}
|
||||
return
|
||||
}
|
||||
results <- smtpTestResult{message: message.String(), authenticated: true}
|
||||
case trimmed == "QUIT":
|
||||
writeResponse("221 2.0.0 bye")
|
||||
return
|
||||
default:
|
||||
if err := writeResponse("250 OK"); err != nil {
|
||||
results <- smtpTestResult{err: err}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func configureEmailCodeAPI(t *testing.T, server smtpTestServer) *gin.Engine {
|
||||
t.Helper()
|
||||
t.Setenv("SERVER_ENV", "test")
|
||||
t.Setenv("DB_TYPE", "sqlite")
|
||||
t.Setenv("DB_NAME", fmt.Sprintf("file:email-auth-smtp-http-test-%s?mode=memory&cache=shared", server.port))
|
||||
t.Setenv("JWT_SECRET", "email-auth-test-secret")
|
||||
t.Setenv("SMTP_HOST", "localhost")
|
||||
t.Setenv("SMTP_PORT", server.port)
|
||||
t.Setenv("SMTP_USERNAME", "smtp-user@example.test")
|
||||
t.Setenv("SMTP_PASSWORD", "smtp-test-password")
|
||||
t.Setenv("SMTP_FROM_EMAIL", "login@example.test")
|
||||
t.Setenv("SMTP_TLS_MODE", server.tlsMode)
|
||||
t.Setenv("SMTP_TLS_CA_FILE", server.caFile)
|
||||
t.Setenv("POSTMARK_SERVER_TOKEN", "")
|
||||
t.Setenv("POSTMARK_FROM_EMAIL", "")
|
||||
if err := config.InitDB(config.Load()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := gin.New()
|
||||
router.POST("/api/auth/email/request", RequestEmailLoginCode)
|
||||
return router
|
||||
}
|
||||
|
||||
func TestRequestEmailLoginCodeUsesSMTPViaAPI(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
server := startSMTPTestServer(t, "implicit", false)
|
||||
router := configureEmailCodeAPI(t, server)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/auth/email/request", strings.NewReader(`{"email":"recipient@example.com"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"code":0`) {
|
||||
t.Fatalf("expected HTTP 200 code:0, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
result := <-server.results
|
||||
if result.err != nil {
|
||||
t.Fatalf("SMTP test server failed: %v", result.err)
|
||||
}
|
||||
if !result.authenticated || !strings.Contains(result.message, "To: <recipient@example.com>") || !strings.Contains(result.message, "10 分钟内有效") {
|
||||
t.Fatalf("unexpected SMTP message or authentication result: authenticated=%t", result.authenticated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestEmailLoginCodeReturnsServiceUnavailableWhenSMTPAuthFails(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
server := startSMTPTestServer(t, "starttls", true)
|
||||
router := configureEmailCodeAPI(t, server)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/auth/email/request", strings.NewReader(`{"email":"recipient@example.com"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusServiceUnavailable || strings.Contains(response.Body.String(), `"code":0`) {
|
||||
t.Fatalf("expected HTTP 503 with nonzero code, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
result := <-server.results
|
||||
if result.err != nil {
|
||||
t.Fatalf("SMTP test server failed: %v", result.err)
|
||||
}
|
||||
if result.authenticated {
|
||||
t.Fatal("expected SMTP authentication to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestEmailLoginCodeLiveQQSMTP(t *testing.T) {
|
||||
if os.Getenv("RUN_QQ_SMTP_LIVE_TEST") != "1" {
|
||||
t.Skip("set RUN_QQ_SMTP_LIVE_TEST=1 to send a real email through QQ SMTP")
|
||||
}
|
||||
recipient := os.Getenv("SMTP_TEST_TO")
|
||||
if recipient == "" {
|
||||
t.Fatal("SMTP_TEST_TO must be set to the test recipient")
|
||||
}
|
||||
testEnvironment, err := godotenv.Read("../../.env.test")
|
||||
if err != nil {
|
||||
t.Fatalf("load local SMTP test environment: %v", err)
|
||||
}
|
||||
for name, value := range testEnvironment {
|
||||
t.Setenv(name, value)
|
||||
}
|
||||
if err := config.InitDB(config.Load()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := gin.New()
|
||||
router.POST("/api/auth/email/request", RequestEmailLoginCode)
|
||||
requestBody := fmt.Sprintf(`{"email":%q}`, recipient)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/auth/email/request", strings.NewReader(requestBody))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"code":0`) {
|
||||
t.Fatalf("expected HTTP 200 code:0, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,21 @@ import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -123,14 +130,121 @@ func (s *EmailAuthService) hashCode(email, code string) string {
|
||||
}
|
||||
|
||||
func (s *EmailAuthService) sendCode(email, code string) error {
|
||||
if s.config.Postmark.ServerToken == "" || s.config.Postmark.FromEmail == "" {
|
||||
// 开发模式:Postmark 未配置时,验证码打印到服务端控制台,方便本地联调
|
||||
if s.config.Server.Env != "production" {
|
||||
log.Printf("[DEV-EMAIL] 邮箱登录验证码: email=%s code=%s(生产环境将自动通过 Postmark 发送)", email, code)
|
||||
return nil
|
||||
}
|
||||
return errors.New("email service is not configured")
|
||||
if s.config.SMTP.IsConfigured() {
|
||||
return s.sendCodeWithSMTP(email, code)
|
||||
}
|
||||
if s.config.Postmark.ServerToken != "" && s.config.Postmark.FromEmail != "" {
|
||||
return s.sendCodeWithPostmark(email, code)
|
||||
}
|
||||
if s.config.Server.Env != "production" {
|
||||
log.Printf("[DEV-EMAIL] 邮箱登录验证码: email=%s code=%s(生产环境需配置 SMTP 或 Postmark)", email, code)
|
||||
return nil
|
||||
}
|
||||
return errors.New("email service is not configured")
|
||||
}
|
||||
|
||||
func (s *EmailAuthService) sendCodeWithSMTP(email, code string) error {
|
||||
settings := s.config.SMTP
|
||||
port, err := strconv.Atoi(settings.Port)
|
||||
if settings.Host == "" || settings.Username == "" || settings.Password == "" || settings.FromEmail == "" || err != nil || port < 1 || port > 65535 {
|
||||
return errors.New("SMTP configuration is incomplete or invalid")
|
||||
}
|
||||
if settings.TLSMode != "implicit" && settings.TLSMode != "starttls" {
|
||||
return errors.New("SMTP TLS mode must be implicit or starttls")
|
||||
}
|
||||
from, err := mail.ParseAddress(settings.FromEmail)
|
||||
if err != nil || from.Address != settings.FromEmail {
|
||||
return errors.New("SMTP sender address is invalid")
|
||||
}
|
||||
tlsConfig, err := s.smtpTLSConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
address := net.JoinHostPort(settings.Host, settings.Port)
|
||||
var connection net.Conn
|
||||
if settings.TLSMode == "implicit" {
|
||||
connection, err = tls.DialWithDialer(dialer, "tcp", address, tlsConfig)
|
||||
} else {
|
||||
connection, err = dialer.Dial("tcp", address)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP connection failed: %w", err)
|
||||
}
|
||||
defer connection.Close()
|
||||
if err := connection.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := smtp.NewClient(connection, settings.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP session setup failed: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if settings.TLSMode == "starttls" {
|
||||
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||||
return errors.New("SMTP server does not support STARTTLS")
|
||||
}
|
||||
if err := client.StartTLS(tlsConfig); err != nil {
|
||||
return fmt.Errorf("SMTP TLS negotiation failed: %w", err)
|
||||
}
|
||||
}
|
||||
if err := client.Auth(smtp.PlainAuth("", settings.Username, settings.Password, settings.Host)); err != nil {
|
||||
return fmt.Errorf("SMTP authentication failed: %w", err)
|
||||
}
|
||||
if err := client.Mail(from.Address); err != nil {
|
||||
return fmt.Errorf("SMTP sender rejected: %w", err)
|
||||
}
|
||||
if err := client.Rcpt(email); err != nil {
|
||||
return fmt.Errorf("SMTP recipient rejected: %w", err)
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP message rejected: %w", err)
|
||||
}
|
||||
fromHeader := (&mail.Address{Name: "知命网", Address: from.Address}).String()
|
||||
toHeader := (&mail.Address{Address: email}).String()
|
||||
message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n你的登录验证码是 %s,10 分钟内有效。若非本人操作,请忽略此邮件。\r\n",
|
||||
fromHeader,
|
||||
toHeader,
|
||||
mime.QEncoding.Encode("UTF-8", "知命登录验证码"),
|
||||
time.Now().Format(time.RFC1123Z),
|
||||
code,
|
||||
)
|
||||
if _, err := io.WriteString(writer, message); err != nil {
|
||||
closeErr := writer.Close()
|
||||
if closeErr != nil {
|
||||
return errors.Join(err, closeErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return fmt.Errorf("SMTP server did not accept the message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EmailAuthService) smtpTLSConfig() (*tls.Config, error) {
|
||||
settings := s.config.SMTP
|
||||
config := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: settings.Host}
|
||||
if settings.TLSCAFile == "" {
|
||||
return config, nil
|
||||
}
|
||||
certificatePEM, err := os.ReadFile(settings.TLSCAFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read SMTP CA file: %w", err)
|
||||
}
|
||||
roots, err := x509.SystemCertPool()
|
||||
if err != nil || roots == nil {
|
||||
roots = x509.NewCertPool()
|
||||
}
|
||||
if !roots.AppendCertsFromPEM(certificatePEM) {
|
||||
return nil, errors.New("SMTP CA file contains no valid certificates")
|
||||
}
|
||||
config.RootCAs = roots
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *EmailAuthService) sendCodeWithPostmark(email, code string) error {
|
||||
payload := map[string]string{
|
||||
"From": s.config.Postmark.FromEmail,
|
||||
"To": email,
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## [未发布]
|
||||
|
||||
### 邮箱验证码
|
||||
|
||||
- 增加 QQ 邮箱 SMTP 登录验证码发送,支持隐式 TLS 与 STARTTLS;SMTP 凭证只从环境变量读取。
|
||||
- SMTP 配置可用时优先发送,未配置时保留 Postmark 备用通道。
|
||||
|
||||
### 排盘记录与免费层
|
||||
|
||||
- 每次排盘保存服务端历史记录,后台新增排盘记录页面。
|
||||
|
||||
@@ -12,7 +12,7 @@ flowchart TD
|
||||
B --> C[免费查看四柱、五行、大运、流年、刑冲合害、神煞]
|
||||
C --> D{是否解锁深度解读}
|
||||
D -- 否 --> E[继续查看免费排盘]
|
||||
D -- 是 --> F[Postmark 邮箱验证码登录]
|
||||
D -- 是 --> F[QQ 邮箱 SMTP 验证码登录]
|
||||
F --> G{选择支付渠道}
|
||||
G --> H[Stripe: 4.99 USD]
|
||||
G --> I[Binance Pay: 5 USDT]
|
||||
@@ -77,7 +77,7 @@ ZM-ABCD-EFGH-JKLM
|
||||
|
||||
### 邮箱登录
|
||||
|
||||
- `POST /api/auth/email/request`:通过 Postmark 发送 6 位验证码。
|
||||
- `POST /api/auth/email/request`:优先通过 QQ SMTP(TLS)发送 6 位验证码;未配置 SMTP 时可使用 Postmark 备用通道。
|
||||
- `POST /api/auth/email/verify`:验证验证码并签发用户 JWT。
|
||||
- 验证码 10 分钟有效、最多尝试 5 次、60 秒内不能重复发送。
|
||||
|
||||
@@ -133,9 +133,10 @@ Stripe 校验 `Stripe-Signature` 与 5 分钟时间窗口;Binance Pay 使用
|
||||
|
||||
前端复制 `.env.example` 为 `.env.local`。只有明确设置 `VITE_PAYMENT_MODE=demo` 才进入演示支付;未设置时默认使用生产支付,避免线上误开模拟解锁。
|
||||
|
||||
后端配置见 `server/.env.example`,必须准备:
|
||||
后端配置见 `server/.env.example`,邮箱登录可配置:
|
||||
|
||||
- Postmark Server Token 与已验证发件域名。
|
||||
- QQ SMTP 主机、端口、完整邮箱地址与 SMTP 授权码;授权码通过环境变量或本地忽略的密钥文件注入,不提交到 Git。
|
||||
- Postmark Server Token 与已验证发件域名可作为备用通道。
|
||||
- Stripe Secret Key 与 Webhook Secret。
|
||||
- Binance Pay 商户 API Key、Secret Key 与 Webhook 公钥。
|
||||
- 公开 HTTPS API 域名和 Web 地址。
|
||||
|
||||
Reference in New Issue
Block a user