Files
team-api/internal/logic/admin/settings.go
T

394 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package admin
import (
"bytes"
"context"
"fmt"
"image"
"image/color"
"image/png"
"io"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/google/uuid"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/dispatchadapter"
"github.com/qianfree/team-api/internal/logic/common"
)
// emailTestTemplateCode 测试邮件在 ntf_send_log 中的模板编码,便于在「邮件发送记录」里筛出。
const emailTestTemplateCode = "_test"
// GetSettingsCategories returns all available setting categories.
func (s *sAdmin) GetSettingsCategories(ctx context.Context, _ *v1.AdminSettingsCategoriesReq) (*v1.AdminSettingsCategoriesRes, error) {
categories := common.Categories
list := make([]v1.SettingCategoryItem, len(categories))
for i, c := range categories {
list[i] = v1.SettingCategoryItem{
Key: c.Key,
Label: c.Label,
Icon: c.Icon,
Order: c.Order,
}
}
return &v1.AdminSettingsCategoriesRes{List: list}, nil
}
// GetSettings retrieves settings with schema for a given category.
func (s *sAdmin) GetSettings(ctx context.Context, req *v1.AdminSettingsGetReq) (*v1.AdminSettingsGetRes, error) {
if !isValidCategory(req.Category) {
return nil, common.NewBadRequestError("无效的设置分类")
}
items := common.Config().GetCategoryWithValues(ctx, req.Category)
list := make([]v1.AdminSettingItem, len(items))
for i, item := range items {
list[i] = v1.AdminSettingItem{
Key: item.Key,
Value: common.TypedValue(item.Type, item.Value, item.Default),
Type: string(item.Type),
Label: item.Label,
Description: item.Description,
Sensitive: item.Sensitive,
Validation: item.Validation,
Default: common.TypedValue(item.Type, "", item.Default),
}
}
return &v1.AdminSettingsGetRes{List: list}, nil
}
// UpdateSettings batch-updates settings for a given category.
func (s *sAdmin) UpdateSettings(ctx context.Context, req *v1.AdminSettingsUpdateReq) (*v1.AdminSettingsUpdateRes, error) {
if !isValidCategory(req.Category) {
return nil, common.NewBadRequestError("无效的设置分类")
}
// Normalize interface{} values to strings for storage
strValues := make(map[string]string, len(req.Settings))
for key, val := range req.Settings {
strValues[key] = common.NormalizeSettingValue(val)
}
if err := s.validateCrossFieldSettings(ctx, req.Category, strValues); err != nil {
return nil, err
}
if err := common.Config().UpdateCategory(ctx, req.Category, strValues); err != nil {
return nil, err
}
return nil, nil
}
// validateCrossFieldSettings validates interdependent settings within a category.
func (s *sAdmin) validateCrossFieldSettings(ctx context.Context, category string, values map[string]string) error {
switch category {
case "general":
return s.validateGeneralSettings(ctx, values)
case "security":
return s.validateSecuritySettings(ctx, values)
case "channel":
return s.validateChannelSettings(ctx, values)
default:
return nil
}
}
// validateGeneralSettings 校验基础配置项:维护时长必须为 Go 时长格式(如 2h、30m、1h30m)。
// API 维护中间件按该格式解析 Retry-After,格式非法会静默回退 300s,故在保存入口拦截。
func (s *sAdmin) validateGeneralSettings(_ context.Context, values map[string]string) error {
durationStr, ok := values["maintenance_duration"]
if !ok || strings.TrimSpace(durationStr) == "" {
return nil
}
d, err := time.ParseDuration(strings.TrimSpace(durationStr))
if err != nil || d <= 0 {
return common.NewBadRequestError("维护时长格式不正确,请使用如 2h、30m、1h30m 的时长格式")
}
return nil
}
// validateSecuritySettings 校验安全配置项之间的依赖:启用 Turnstile 前必须已配置密钥;
// IP 黑名单每个条目必须是合法 IP / CIDR(坏条目入库后中间件会静默跳过,必须保存时拦下)。
func (s *sAdmin) validateSecuritySettings(ctx context.Context, values map[string]string) error {
if raw, ok := values["ip_blacklist_list"]; ok && strings.TrimSpace(raw) != "" {
if err := common.ValidateIpBlacklistRaw(raw); err != nil {
return common.NewBadRequestError(err.Error())
}
}
enabled, ok := values["turnstile_enabled"]
if !ok || (enabled != "true" && enabled != "1") {
return nil
}
siteKey := values["turnstile_site_key"]
secretKey := values["turnstile_secret_key"]
// If keys not in the current request, read existing values from DB
if siteKey == "" {
siteKey = common.Config().GetString(ctx, "turnstile_site_key")
}
if secretKey == "" || secretKey == "******" {
secretKey = common.Config().GetString(ctx, "turnstile_secret_key")
}
if strings.TrimSpace(siteKey) == "" || strings.TrimSpace(secretKey) == "" {
return common.NewBadRequestError("启用 Turnstile 前必须先配置 Site Key 和 Secret Key")
}
return nil
}
// validateChannelSettings 校验渠道配置项之间的依赖:同步图片厂商异步化依赖对象存储保存生成
// 结果,开启前必须已在「存储配置」中配置对象存储(OSS/S3/COS),否则生成的图片无处保存。
func (s *sAdmin) validateChannelSettings(ctx context.Context, values map[string]string) error {
// 路由策略覆盖(JSON):保存前做 Schema 校验,拦截非法配置入库
//(调度侧加载时仍会二次校验并沿用上一份,此处是给运营的即时反馈)。
if raw, ok := values["channel_routing_policy"]; ok && raw != "" {
if err := dispatchadapter.ValidateRoutingPolicyJSON(raw); err != nil {
return common.NewBadRequestError(fmt.Sprintf("路由策略配置非法:%v", err))
}
}
enabled, ok := values["sync_image_async_enabled"]
if !ok || (enabled != "true" && enabled != "1") {
return nil
}
// 存储配置属于 storage 分类、独立持久化,此处直接读已保存的存储配置判断是否可用。
if !common.IsStorageConfigured(ctx) {
return common.NewBadRequestError("开启同步图片厂商异步化前,必须先在「存储配置」中配置对象存储(OSS/S3/COS)")
}
return nil
}
func isValidCategory(category string) bool {
for _, c := range common.Categories {
if c.Key == category {
return true
}
}
return false
}
// TestStorageConfig 测试对象存储配置连通性:用生成的测试图片走一次「上传 → 下载校验 → 删除」
// 完整往返,验证凭证 / 桶 / 端点 / 读写删权限是否正常。
//
// access_key_id / secret 为空或掩码("******")时回落到已保存的配置值——与 GET 接口对敏感字段
// 的掩码逻辑(config.GetCategoryWithValues)以及 Turnstile 校验的 "******" 处理保持一致,
// 从而支持管理员在「未点保存」的状态下直接测试表单中新填/改动的配置。
func (s *sAdmin) TestStorageConfig(ctx context.Context, req *v1.AdminStorageTestReq) (*v1.AdminStorageTestRes, error) {
cfg := &common.StorageConfig{
Provider: strings.TrimSpace(req.Provider),
Endpoint: strings.TrimSpace(req.Endpoint),
Region: strings.TrimSpace(req.Region),
Bucket: strings.TrimSpace(req.Bucket),
AccessKeyID: strings.TrimSpace(req.AccessKeyID),
SecretKey: req.SecretKey,
UseSSL: req.UseSSL,
PathPrefix: strings.TrimSpace(req.PathPrefix),
}
// 敏感字段掩码 / 留空 → 回落已保存值
if cfg.AccessKeyID == "" || cfg.AccessKeyID == "******" {
cfg.AccessKeyID = common.Config().GetString(ctx, "storage_access_key_id")
}
if cfg.SecretKey == "" || cfg.SecretKey == "******" {
cfg.SecretKey = common.Config().GetString(ctx, "storage_access_key_secret")
}
if cfg.PathPrefix == "" {
cfg.PathPrefix = "team-api" // 与 GetStorageConfig 的默认前缀保持一致
}
if cfg.Provider == "" || cfg.Bucket == "" {
return nil, common.NewBadRequestError("请先填写存储供应商和存储桶名称")
}
provider, err := common.NewStorageProvider(cfg)
if err != nil {
return nil, common.NewBadRequestError("创建存储客户端失败:" + err.Error())
}
// 生成一张 1x1 PNG 作为测试图片(真实图片字节,验证二进制内容往返无损)
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
img.Set(0, 0, color.RGBA{R: 0x33, G: 0x99, B: 0xff, A: 0xff})
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, common.NewBadRequestError("生成测试图片失败:" + err.Error())
}
payload := buf.Bytes()
// 独立 _healthcheck 目录 + uuid,避免与业务对象冲突;provider 内部会自动加路径前缀
key := fmt.Sprintf("_healthcheck/%s.png", uuid.New().String())
// 整个往返设 20s 超时,端点不可达时不至于长时间挂起
testCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
res := &v1.AdminStorageTestRes{}
start := time.Now()
// 1. 上传
if _, err := provider.Upload(testCtx, bytes.NewReader(payload), key, "image/png"); err != nil {
return nil, common.NewBadRequestError("上传测试图片失败:" + err.Error())
}
res.Uploaded = true
// 兜底清理:覆盖下载失败等提前返回路径,避免遗留测试对象(Delete 幂等,成功路径重复删除无副作用)
defer func() {
if delErr := provider.Delete(context.WithoutCancel(testCtx), key); delErr != nil {
g.Log().Warningf(ctx, "storage test: cleanup test object %s failed: %v", key, delErr)
}
}()
// 2. 下载并校验内容一致
rc, err := provider.Download(testCtx, key)
if err != nil {
return nil, common.NewBadRequestError("下载测试图片失败:" + err.Error())
}
got, readErr := io.ReadAll(rc)
_ = rc.Close()
if readErr != nil {
return nil, common.NewBadRequestError("读取测试图片失败:" + readErr.Error())
}
if !bytes.Equal(got, payload) {
return nil, common.NewBadRequestError("下载内容与上传不一致,存储未正确保存对象")
}
res.Downloaded = true
// 3. 删除(校验删除权限;失败不致命,仅告警,兜底 defer 会再尝试一次)
if err := provider.Delete(testCtx, key); err != nil {
g.Log().Warningf(ctx, "storage test: delete test object %s failed: %v", key, err)
} else {
res.Deleted = true
}
res.ElapsedMs = time.Since(start).Milliseconds()
res.Message = "对象存储配置正常:测试图片上传、下载校验通过"
return res, nil
}
// TestEmailConfig 发送一封测试邮件,验证 SMTP 配置是否可用。
//
// 与 TestStorageConfig 同构:请求体携带表单中的邮件配置(可能尚未保存),字段为空或为掩码
// "******" 时回落到已保存的值,从而支持管理员在「未点保存」的状态下直接测试改动。
// 收件人留空时发送给当前登录管理员的邮箱。
//
// 使用 SendOnce 而非 Send:连通性测试要快速拿到真实 SMTP 错误(认证失败、端口不通重试
// 三次也不会变好),不该让管理员多等十几秒的退避。
func (s *sAdmin) TestEmailConfig(ctx context.Context, req *v1.AdminEmailTestReq) (*v1.AdminEmailTestRes, error) {
cfg := &common.EmailConfig{
Host: strings.TrimSpace(req.Host),
Port: req.Port,
Username: strings.TrimSpace(req.Username),
Password: req.Password,
From: strings.TrimSpace(req.From),
FromName: strings.TrimSpace(req.FromName),
}
// 空 / 掩码 → 回落已保存值(邮件配置的唯一来源是 sys_options,不读配置文件)
if cfg.Host == "" {
cfg.Host = common.Config().GetString(ctx, "email_smtp_host")
}
if cfg.Port == 0 {
cfg.Port = common.Config().GetInt(ctx, "email_smtp_port")
}
if cfg.Username == "" {
cfg.Username = common.Config().GetString(ctx, "email_smtp_username")
}
if cfg.Password == "" || cfg.Password == "******" {
cfg.Password = common.Config().GetString(ctx, "email_smtp_password")
}
if cfg.From == "" {
cfg.From = common.Config().GetString(ctx, "email_smtp_from")
}
if cfg.FromName == "" {
cfg.FromName = common.Config().GetString(ctx, "email_smtp_from_name")
}
// 未传 TLS 开关时沿用已保存值(*bool 区分「没传」和「显式关闭」)
if req.UseTLS != nil {
cfg.UseTLS = *req.UseTLS
} else {
cfg.UseTLS = common.Config().GetBool(ctx, "email_smtp_tls")
}
if cfg.Host == "" || cfg.From == "" {
return nil, common.NewBadRequestError("请先填写 SMTP 服务器和发件人地址")
}
if cfg.Port == 0 {
cfg.Port = 587
}
// 与 EmailConfigFromOptions 保持一致:未配置发件人名称时回落站点名称
if cfg.FromName == "" {
cfg.FromName = common.Config().GetString(ctx, "site_name")
}
recipient, err := s.resolveTestEmailRecipient(ctx, req.To)
if err != nil {
return nil, err
}
siteName := common.Config().GetString(ctx, "site_name")
now := time.Now().Format("2006-01-02 15:04:05")
body := fmt.Sprintf(`
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>邮件配置测试</h2>
<p>这是一封来自 <strong>%s</strong> 的测试邮件,收到即表示 SMTP 配置可用。</p>
<p style="color: #666;">发件人:%s<br/>发送时间:%s</p>
</div>
`, siteName, cfg.From, now)
// 单次投递 + 20s 上限:Send/SendOnce 内的等待可被 ctx 打断,超时能真正生效
testCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
start := time.Now()
sendErr := common.NewEmailSender(cfg).SendOnce(testCtx, &common.EmailMessage{
To: recipient,
Subject: fmt.Sprintf("[%s] 邮件配置测试", siteName),
BodyHTML: body,
TemplateCode: emailTestTemplateCode,
})
elapsed := time.Since(start).Milliseconds()
if sendErr != nil {
g.Log().Warningf(ctx, "email config test: send to %s failed: %v", recipient, sendErr)
return nil, common.NewBadRequestError("测试邮件发送失败:" + sendErr.Error())
}
return &v1.AdminEmailTestRes{
Sent: true,
Recipient: recipient,
ElapsedMs: elapsed,
Message: "测试邮件已发送至 " + recipient + ",请查收(含垃圾邮件箱)",
}, nil
}
// resolveTestEmailRecipient 解析测试邮件收件人:显式传入优先,留空则取当前登录管理员的邮箱。
func (s *sAdmin) resolveTestEmailRecipient(ctx context.Context, to string) (string, error) {
recipient := strings.TrimSpace(to)
if recipient == "" {
var admin *struct {
Email string `json:"email"`
}
if err := dao.SysAdminUsers.Ctx(ctx).
Where("id", common.GetCtxUserID(ctx)).
Fields("email").
Scan(&admin); common.IgnoreScanNoRows(err) != nil {
return "", err
}
if admin != nil {
recipient = strings.TrimSpace(admin.Email)
}
}
if recipient == "" {
return "", common.NewBadRequestError("请填写收件人,或先为当前管理员账号配置邮箱")
}
if !common.IsValidEmail(recipient) {
return "", common.NewBadRequestError("收件人邮箱格式不正确")
}
return recipient, nil
}