嵌入与邮箱

This commit is contained in:
ryan
2026-06-08 14:10:56 +08:00
parent 62bd5d09d4
commit db68a130ce
26 changed files with 6089 additions and 129 deletions
+85 -5
View File
@@ -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)
}
}
+28 -3
View File
@@ -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"
}
// -----------------------------------------------------------------------------
+19 -9
View File
@@ -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