Author SHA1 Message Date
goukiandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c161d42b06 feat(server): 接入 QQ 邮箱 SMTP 发送登录验证码
Build and Publish Server / build (push) Successful in 2m6s
- 支持 465 隐式 TLS 与 587 STARTTLS,SMTP 配置优先、Postmark 备用
- 发件人支持 QQ 域名邮箱地址(z@kepo.net),凭证仅走环境变量
- HTTP 测试覆盖发送成功与认证失败;真实发信测试需显式开启 RUN_QQ_SMTP_LIVE_TEST

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-27 23:24:46 +00:00
8 changed files with 515 additions and 16 deletions
+4
View File
@@ -14,3 +14,7 @@
- 商业前端的本地 `file:` 依赖可能缓存旧版 `@lunar/core`;生产构建必须先构建 core,并通过显式别名引用最新 `dist`。
- 神煞规则不能统一套用“年干与日干”两个锚点;必须按每个神煞的标准查法指定锚点,并使用 `pp.test` 中的真实命例做回归测试。
- 大运切换必须同时切换该步大运对应的十年流年,不能让当前大运标题与首步大运流年混用。
## 2026-09-25:邮件服务方案必须先确认代码可用
- 推荐邮箱 SMTP 服务前,先核对后端是否已实现对应协议;不能把“服务商支持 SMTP”说成“当前应用已经可以发信”。必须先完成实现和端到端验证,再指导用户配置凭证。
+10
View File
@@ -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
View File
@@ -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`
+24
View File
@@ -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"),
+333
View File
@@ -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())
}
}
+121 -7
View File
@@ -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,
+5
View File
@@ -2,6 +2,11 @@
## [未发布]
### 邮箱验证码
- 增加 QQ 邮箱 SMTP 登录验证码发送,支持隐式 TLS 与 STARTTLS;SMTP 凭证只从环境变量读取。
- SMTP 配置可用时优先发送,未配置时保留 Postmark 备用通道。
### 排盘记录与免费层
- 每次排盘保存服务端历史记录,后台新增排盘记录页面。
+5 -4
View File
@@ -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 地址。