740 lines
21 KiB
Go
740 lines
21 KiB
Go
package setting
|
||
|
||
import (
|
||
"backend/internal/dto"
|
||
"backend/internal/model"
|
||
"backend/internal/service/payment"
|
||
"errors"
|
||
"fmt"
|
||
"net/url"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// SettingService 系统设置业务逻辑层 // Logan.Li
|
||
type SettingService struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
// NewSettingService 创建 service 实例 // Logan.Li
|
||
func NewSettingService(db *gorm.DB) *SettingService {
|
||
return &SettingService{db: db}
|
||
}
|
||
|
||
// Get 获取系统设置(单行) // Logan.Li
|
||
func (s *SettingService) Get() (*model.Setting, error) {
|
||
var setting model.Setting
|
||
if err := s.db.First(&setting).Error; err != nil {
|
||
return nil, errors.New("系统设置不存在")
|
||
}
|
||
return &setting, nil
|
||
}
|
||
|
||
// Update 更新系统设置 // Logan.Li
|
||
func (s *SettingService) Update(req dto.SettingUpdate) (*model.Setting, error) {
|
||
var setting model.Setting
|
||
if err := s.db.First(&setting).Error; err != nil {
|
||
return nil, errors.New("系统设置不存在")
|
||
}
|
||
|
||
updates := map[string]interface{}{}
|
||
if req.SystemName != nil {
|
||
updates["system_name"] = *req.SystemName
|
||
}
|
||
if req.DefaultDuration != nil {
|
||
updates["default_duration"] = *req.DefaultDuration
|
||
}
|
||
if req.DefaultMaxActivations != nil {
|
||
updates["default_max_activations"] = *req.DefaultMaxActivations
|
||
}
|
||
if req.ExpireReminderDays != nil {
|
||
updates["expire_reminder_days"] = *req.ExpireReminderDays
|
||
}
|
||
if req.APIRateLimit != nil {
|
||
updates["api_rate_limit"] = *req.APIRateLimit
|
||
}
|
||
if req.LogRetentionDays != nil {
|
||
updates["log_retention_days"] = *req.LogRetentionDays
|
||
}
|
||
|
||
if len(updates) > 0 {
|
||
if err := s.db.Model(&setting).Updates(updates).Error; err != nil {
|
||
return nil, errors.New("更新系统设置失败")
|
||
}
|
||
}
|
||
s.db.First(&setting)
|
||
return &setting, nil
|
||
}
|
||
|
||
// GetWaffo 获取 Waffo 支付集成配置(私钥仅返回是否已配置) // Logan.Li
|
||
func (s *SettingService) GetWaffo() (*dto.WaffoSettingsResponse, error) {
|
||
cfg := payment.LoadWaffoConfig(s.db)
|
||
var gw model.PaymentGatewayConfig
|
||
dbHas := s.db.Where("gateway = ?", "waffo").First(&gw).Error == nil
|
||
enabled := true
|
||
if dbHas {
|
||
enabled = gw.Enabled
|
||
}
|
||
hint := "env"
|
||
if dbHas && strings.TrimSpace(gw.PublicKey) != "" {
|
||
hint = "db"
|
||
} else if dbHas {
|
||
hint = "mixed"
|
||
}
|
||
|
||
meta := payment.ParseWaffoPublicMeta(gw.PublicKey)
|
||
env := payment.NormalizeWaffoEnvironment(cfg.Environment)
|
||
meta.ApplyEnvProfile(env)
|
||
mappings := buildProductMappings(cfg, meta.ProductMappings)
|
||
productIDs := cloneProductIDs(cfg.ProductIDs)
|
||
bag := payment.ParseWaffoSecretBag(gw.SecretKey, env)
|
||
|
||
profiles := map[string]dto.WaffoEnvUI{
|
||
"test": buildEnvUI(meta, bag, "test"),
|
||
"live": buildEnvUI(meta, bag, "live"),
|
||
}
|
||
|
||
return &dto.WaffoSettingsResponse{
|
||
Enabled: enabled,
|
||
MerchantID: cfg.MerchantID,
|
||
StoreID: cfg.StoreID,
|
||
StoreSlug: cfg.StoreSlug,
|
||
Environment: env,
|
||
SandboxMode: env == "test",
|
||
Currency: cfg.Currency,
|
||
ProductID: firstNonEmpty(meta.ProductID, cfg.ProductID),
|
||
ProductIDYearly: firstNonEmpty(meta.ProductIDYearly, cfg.ProductIDYearly),
|
||
ProductIDPerpetual: firstNonEmpty(meta.ProductIDPerpetual, cfg.ProductIDPerpetual),
|
||
ProductIDs: productIDs,
|
||
ProductMappings: mappings,
|
||
SuccessURL: cfg.SuccessURL,
|
||
WebhookURL: payment.DefaultWebhookURL,
|
||
PrivateKeyConfigured: bag.SecretForEnv(env) != "",
|
||
PrivateKeyByEnv: map[string]bool{
|
||
"test": bag.SecretForEnv("test") != "",
|
||
"live": bag.SecretForEnv("live") != "",
|
||
},
|
||
Profiles: profiles,
|
||
SourceHint: hint,
|
||
}, nil
|
||
}
|
||
|
||
// buildEnvUI 组装单环境预览(不得串用另一环境顶层映射) // Logan.Li
|
||
func buildEnvUI(meta payment.WaffoPublicMeta, bag payment.WaffoSecretBag, env string) dto.WaffoEnvUI {
|
||
cp := meta
|
||
// 先清空商品字段,再叠该环境 Profile,避免未配置环境显示成另一侧数据 // Logan.Li
|
||
cp.ProductID = ""
|
||
cp.ProductIDYearly = ""
|
||
cp.ProductIDPerpetual = ""
|
||
cp.ProductIDs = nil
|
||
cp.ProductMappings = nil
|
||
cp.ApplyEnvProfile(env)
|
||
|
||
cfgLike := payment.WaffoConfig{
|
||
ProductID: cp.ProductID,
|
||
ProductIDYearly: cp.ProductIDYearly,
|
||
ProductIDPerpetual: cp.ProductIDPerpetual,
|
||
ProductIDs: cloneProductIDs(cp.ProductIDs),
|
||
}
|
||
if len(cp.ProductMappings) > 0 {
|
||
if cfgLike.ProductIDs == nil {
|
||
cfgLike.ProductIDs = map[string]string{}
|
||
}
|
||
for _, m := range cp.ProductMappings {
|
||
k := strings.TrimSpace(strings.ToLower(m.PlanKey))
|
||
v := strings.TrimSpace(m.ProductID)
|
||
if k != "" && v != "" {
|
||
cfgLike.ProductIDs[k] = v
|
||
}
|
||
}
|
||
}
|
||
return dto.WaffoEnvUI{
|
||
ProductMappings: buildProductMappings(cfgLike, cp.ProductMappings),
|
||
ProductID: firstNonEmpty(cp.ProductID, cfgLike.ProductIDs["perpetual"], cfgLike.ProductIDs["lifetime"], cfgLike.ProductIDs["yearly"]),
|
||
ProductIDYearly: firstNonEmpty(cp.ProductIDYearly, cfgLike.ProductIDs["yearly"]),
|
||
ProductIDPerpetual: firstNonEmpty(cp.ProductIDPerpetual, cfgLike.ProductIDs["perpetual"], cfgLike.ProductIDs["lifetime"]),
|
||
PrivateKeyConfigured: bag.SecretForEnv(env) != "",
|
||
}
|
||
}
|
||
|
||
// UpdateWaffo 保存 Waffo 配置到 payment_gateway_configs(后台优先于 .env) // Logan.Li
|
||
func (s *SettingService) UpdateWaffo(req dto.WaffoSettingsUpdate) (*dto.WaffoSettingsResponse, error) {
|
||
var gw model.PaymentGatewayConfig
|
||
err := s.db.Where("gateway = ?", "waffo").First(&gw).Error
|
||
if err != nil {
|
||
gw = model.PaymentGatewayConfig{
|
||
Gateway: "waffo",
|
||
Enabled: true,
|
||
SandboxMode: true,
|
||
Currency: "CNY",
|
||
Priority: 0,
|
||
}
|
||
}
|
||
|
||
oldMeta := payment.ParseWaffoPublicMeta(gw.PublicKey)
|
||
oldEnv := payment.NormalizeWaffoEnvironment(oldMeta.Environment)
|
||
if oldEnv == "test" && !gw.SandboxMode {
|
||
oldEnv = "live"
|
||
}
|
||
|
||
productIDs, mappings := mergeProductIDInputs(req)
|
||
newEnv := payment.NormalizeWaffoEnvironment(req.Environment)
|
||
|
||
meta := oldMeta
|
||
if meta.EnvProfiles == nil {
|
||
meta.EnvProfiles = map[string]payment.WaffoEnvProfile{}
|
||
}
|
||
meta.MerchantID = strings.TrimSpace(req.MerchantID)
|
||
meta.StoreID = strings.TrimSpace(req.StoreID)
|
||
meta.StoreSlug = strings.TrimSpace(req.StoreSlug)
|
||
meta.SuccessURL = strings.TrimSpace(req.SuccessURL)
|
||
meta.Currency = strings.TrimSpace(req.Currency)
|
||
if meta.Currency == "" {
|
||
meta.Currency = "CNY"
|
||
}
|
||
meta.CurrencyYearly = firstNonEmpty(strings.TrimSpace(req.CurrencyYearly), currencyFromMappings(mappings, "yearly"), meta.Currency)
|
||
meta.CurrencyPerpetual = firstNonEmpty(strings.TrimSpace(req.CurrencyPerpetual), currencyFromMappings(mappings, "perpetual"), currencyFromMappings(mappings, "lifetime"), meta.Currency)
|
||
meta.Environment = newEnv
|
||
meta.ProductID = strings.TrimSpace(req.ProductID)
|
||
meta.ProductIDYearly = strings.TrimSpace(req.ProductIDYearly)
|
||
meta.ProductIDPerpetual = strings.TrimSpace(req.ProductIDPerpetual)
|
||
meta.ProductIDs = productIDs
|
||
meta.ProductMappings = toPaymentMappings(mappings)
|
||
|
||
// 兼容字段与可扩展映射对齐 // Logan.Li
|
||
if meta.ProductIDYearly == "" {
|
||
meta.ProductIDYearly = productIDs["yearly"]
|
||
}
|
||
if meta.ProductIDPerpetual == "" {
|
||
meta.ProductIDPerpetual = firstNonEmpty(productIDs["perpetual"], productIDs["lifetime"])
|
||
}
|
||
if meta.ProductID == "" {
|
||
meta.ProductID = firstNonEmpty(meta.ProductIDPerpetual, meta.ProductIDYearly, productIDs["default"])
|
||
}
|
||
|
||
// 当前环境映射写入 Profile,保留另一环境配置 // Logan.Li
|
||
meta.CaptureEnvProfile(newEnv)
|
||
|
||
if newEnv == "live" {
|
||
gw.SandboxMode = false
|
||
} else {
|
||
gw.SandboxMode = true
|
||
}
|
||
|
||
encoded, err := payment.EncodeWaffoPublicMeta(meta)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
gw.PublicKey = encoded
|
||
gw.Currency = meta.Currency
|
||
gw.Priority = 0
|
||
if req.Enabled != nil {
|
||
gw.Enabled = *req.Enabled
|
||
} else {
|
||
gw.Enabled = true
|
||
}
|
||
|
||
bag := payment.ParseWaffoSecretBag(gw.SecretKey, oldEnv)
|
||
if req.ClearPrivateKey {
|
||
bag.ClearSecretForEnv(newEnv)
|
||
} else if pk := strings.TrimSpace(req.PrivateKey); pk != "" {
|
||
if _, err := payment.ParseMerchantPrivateKey(pk); err != nil {
|
||
return nil, errors.New("私钥无法解析: " + err.Error())
|
||
}
|
||
bag.SetSecretForEnv(newEnv, pk)
|
||
}
|
||
encodedSecret, err := payment.EncodeWaffoSecretBag(bag)
|
||
if err != nil {
|
||
return nil, errors.New("保存私钥失败")
|
||
}
|
||
gw.SecretKey = encodedSecret
|
||
|
||
if err := s.db.Save(&gw).Error; err != nil {
|
||
return nil, errors.New("保存 Waffo 配置失败")
|
||
}
|
||
_ = s.db.Model(&model.PaymentGatewayConfig{}).
|
||
Where("gateway <> ? AND enabled = ?", "waffo", true).
|
||
Update("priority", 100).Error
|
||
|
||
return s.GetWaffo()
|
||
}
|
||
|
||
// DebugWaffo 用当前 DB/env 配置轻量探测 create-session(不回显私钥) // Logan.Li
|
||
func (s *SettingService) DebugWaffo(req dto.WaffoDebugRequest) *dto.WaffoDebugResponse {
|
||
cfg := payment.LoadWaffoConfig(s.db)
|
||
// 页面未保存的环境 / 映射优先覆盖,避免「填了映射却仍用旧 Product」 // Logan.Li
|
||
if strings.TrimSpace(req.Environment) != "" {
|
||
env := payment.NormalizeWaffoEnvironment(req.Environment)
|
||
cfg.Environment = env
|
||
var gw model.PaymentGatewayConfig
|
||
if err := s.db.Where("gateway = ?", "waffo").First(&gw).Error; err == nil {
|
||
bag := payment.ParseWaffoSecretBag(gw.SecretKey, env)
|
||
cfg.PrivateKey = bag.SecretForEnv(env)
|
||
}
|
||
}
|
||
applyDebugProductOverrides(&cfg, req)
|
||
|
||
planType := strings.TrimSpace(strings.ToLower(req.PlanType))
|
||
if planType == "" {
|
||
planType = "yearly"
|
||
}
|
||
productID := strings.TrimSpace(req.ProductID)
|
||
if productID == "" {
|
||
productID = payment.ResolveProductID(cfg, planType)
|
||
}
|
||
|
||
authMode := "none"
|
||
if cfg.HasAPIKeyAuth() {
|
||
authMode = "api_key"
|
||
} else if strings.TrimSpace(cfg.StoreSlug) != "" {
|
||
authMode = "store_slug"
|
||
}
|
||
|
||
out := &dto.WaffoDebugResponse{
|
||
AuthMode: authMode,
|
||
Environment: cfg.Environment,
|
||
PlanType: planType,
|
||
ProductIDUsed: productID,
|
||
PrivateKeyPresent: strings.TrimSpace(cfg.PrivateKey) != "",
|
||
MerchantIDPresent: strings.TrimSpace(cfg.MerchantID) != "",
|
||
StoreSlugPresent: strings.TrimSpace(cfg.StoreSlug) != "",
|
||
}
|
||
|
||
if productID == "" {
|
||
out.OK = false
|
||
out.ErrorSummary = "未配置可用的 Waffo Product ID(请在当前环境映射表填写后先保存,或点「填充 OpsXshell 商品」)"
|
||
return out
|
||
}
|
||
if authMode == "none" {
|
||
out.OK = false
|
||
out.ErrorSummary = "未配置商户 ID/私钥,且缺少 Store Slug"
|
||
return out
|
||
}
|
||
|
||
buyer := strings.TrimSpace(req.BuyerEmail)
|
||
if buyer == "" {
|
||
buyer = "debug@opsxshell.local"
|
||
}
|
||
orderNo := fmt.Sprintf("debug-%d", time.Now().Unix())
|
||
successURL := cfg.SuccessURL
|
||
if successURL == "" {
|
||
successURL = payment.DefaultSuccessURL
|
||
}
|
||
currency := payment.ResolveCurrency(cfg, planType)
|
||
if c := strings.TrimSpace(req.Currency); c != "" && planType != "yearly" && planType != "perpetual" && planType != "lifetime" {
|
||
currency = c
|
||
}
|
||
|
||
session, err := payment.CreateCheckoutSession(cfg, payment.CreateSessionRequest{
|
||
ProductID: productID,
|
||
ProductType: "onetime",
|
||
Currency: currency,
|
||
BuyerEmail: buyer,
|
||
SuccessURL: successURL,
|
||
OrderMerchantExternalID: orderNo,
|
||
Metadata: map[string]string{
|
||
"debug": "true",
|
||
"planType": planType,
|
||
"orderNo": orderNo,
|
||
},
|
||
ExpiresInSeconds: 300,
|
||
StoreSlug: cfg.StoreSlug,
|
||
Environment: cfg.Environment,
|
||
})
|
||
if err != nil {
|
||
out.OK = false
|
||
out.ErrorSummary = sanitizeDebugError(err.Error())
|
||
out.HTTPStatus = extractHTTPStatus(err.Error())
|
||
if strings.Contains(out.ErrorSummary, "not approved for production") {
|
||
out.ErrorSummary += "(Waffo 门店尚未开通生产收款:请先在测试环境验证;生产需在 Waffo Dashboard 完成 Go-Live)"
|
||
}
|
||
return out
|
||
}
|
||
|
||
out.OK = true
|
||
out.HTTPStatus = 200
|
||
out.SessionID = session.SessionID
|
||
if u, err := url.Parse(session.CheckoutURL); err == nil && u.Host != "" {
|
||
out.CheckoutURLHost = u.Host
|
||
}
|
||
return out
|
||
}
|
||
|
||
// applyDebugProductOverrides 把调试请求中的映射叠到运行时配置 // Logan.Li
|
||
func applyDebugProductOverrides(cfg *payment.WaffoConfig, req dto.WaffoDebugRequest) {
|
||
if cfg == nil {
|
||
return
|
||
}
|
||
if cfg.ProductIDs == nil {
|
||
cfg.ProductIDs = map[string]string{}
|
||
}
|
||
for _, m := range req.ProductMappings {
|
||
k := strings.TrimSpace(strings.ToLower(m.PlanKey))
|
||
v := strings.TrimSpace(m.ProductID)
|
||
if k == "" || v == "" {
|
||
continue
|
||
}
|
||
cfg.ProductIDs[k] = v
|
||
switch k {
|
||
case "yearly", "monthly":
|
||
cfg.ProductIDYearly = v
|
||
if c := strings.TrimSpace(m.Currency); c != "" {
|
||
cfg.CurrencyYearly = c
|
||
}
|
||
case "perpetual", "lifetime":
|
||
cfg.ProductIDPerpetual = v
|
||
cfg.ProductID = v
|
||
if c := strings.TrimSpace(m.Currency); c != "" {
|
||
cfg.CurrencyPerpetual = c
|
||
}
|
||
}
|
||
}
|
||
if y := strings.TrimSpace(req.ProductIDYearly); y != "" {
|
||
cfg.ProductIDYearly = y
|
||
cfg.ProductIDs["yearly"] = y
|
||
}
|
||
if p := strings.TrimSpace(req.ProductIDPerpetual); p != "" {
|
||
cfg.ProductIDPerpetual = p
|
||
cfg.ProductID = p
|
||
cfg.ProductIDs["perpetual"] = p
|
||
}
|
||
if c := strings.TrimSpace(req.Currency); c != "" {
|
||
cfg.Currency = c
|
||
}
|
||
if c := strings.TrimSpace(req.CurrencyYearly); c != "" {
|
||
cfg.CurrencyYearly = c
|
||
}
|
||
if c := strings.TrimSpace(req.CurrencyPerpetual); c != "" {
|
||
cfg.CurrencyPerpetual = c
|
||
}
|
||
}
|
||
|
||
func mergeProductIDInputs(req dto.WaffoSettingsUpdate) (map[string]string, []dto.WaffoProductMapping) {
|
||
ids := map[string]string{}
|
||
var mappings []dto.WaffoProductMapping
|
||
|
||
if len(req.ProductMappings) > 0 {
|
||
for _, m := range req.ProductMappings {
|
||
k := strings.TrimSpace(strings.ToLower(m.PlanKey))
|
||
v := strings.TrimSpace(m.ProductID)
|
||
if k == "" {
|
||
continue
|
||
}
|
||
mappings = append(mappings, dto.WaffoProductMapping{
|
||
PlanKey: k,
|
||
Label: strings.TrimSpace(m.Label),
|
||
ProductID: v,
|
||
Currency: strings.ToUpper(strings.TrimSpace(m.Currency)),
|
||
})
|
||
if v != "" {
|
||
ids[k] = v
|
||
}
|
||
}
|
||
}
|
||
|
||
for k, v := range req.ProductIDs {
|
||
k = strings.TrimSpace(strings.ToLower(k))
|
||
v = strings.TrimSpace(v)
|
||
if k == "" || v == "" {
|
||
continue
|
||
}
|
||
ids[k] = v
|
||
}
|
||
|
||
if y := strings.TrimSpace(req.ProductIDYearly); y != "" {
|
||
ids["yearly"] = y
|
||
}
|
||
if p := strings.TrimSpace(req.ProductIDPerpetual); p != "" {
|
||
ids["perpetual"] = p
|
||
if _, ok := ids["lifetime"]; !ok {
|
||
ids["lifetime"] = p
|
||
}
|
||
}
|
||
if d := strings.TrimSpace(req.ProductID); d != "" {
|
||
if _, ok := ids["default"]; !ok {
|
||
ids["default"] = d
|
||
}
|
||
if _, ok := ids["perpetual"]; !ok {
|
||
ids["perpetual"] = d
|
||
}
|
||
}
|
||
|
||
if len(mappings) == 0 {
|
||
mappings = mappingsFromIDs(ids, nil, nil, payment.WaffoConfig{})
|
||
} else {
|
||
// 用最终 ids 回填表格行 product_id,避免只改了兼容字段 // Logan.Li
|
||
for i := range mappings {
|
||
if v := ids[mappings[i].PlanKey]; v != "" {
|
||
mappings[i].ProductID = v
|
||
}
|
||
}
|
||
}
|
||
return ids, mappings
|
||
}
|
||
|
||
func buildProductMappings(cfg payment.WaffoConfig, stored []payment.WaffoProductMapping) []dto.WaffoProductMapping {
|
||
labelHint := map[string]string{}
|
||
currencyHint := map[string]string{}
|
||
for _, m := range stored {
|
||
k := strings.TrimSpace(strings.ToLower(m.PlanKey))
|
||
if k == "" {
|
||
continue
|
||
}
|
||
if l := strings.TrimSpace(m.Label); l != "" {
|
||
labelHint[k] = l
|
||
}
|
||
if c := strings.TrimSpace(m.Currency); c != "" {
|
||
currencyHint[k] = strings.ToUpper(c)
|
||
}
|
||
}
|
||
// 若 stored 有完整行(含空 product 也应展示),优先按 stored 顺序回显 // Logan.Li
|
||
if len(stored) > 0 {
|
||
out := make([]dto.WaffoProductMapping, 0, len(stored))
|
||
seen := map[string]bool{}
|
||
for _, m := range stored {
|
||
k := strings.TrimSpace(strings.ToLower(m.PlanKey))
|
||
if k == "" || seen[k] {
|
||
continue
|
||
}
|
||
seen[k] = true
|
||
pid := strings.TrimSpace(m.ProductID)
|
||
if pid == "" {
|
||
pid = strings.TrimSpace(cfg.ProductIDs[k])
|
||
}
|
||
cur := strings.TrimSpace(m.Currency)
|
||
if cur == "" {
|
||
cur = currencyHint[k]
|
||
}
|
||
if cur == "" {
|
||
switch k {
|
||
case "yearly", "monthly":
|
||
cur = cfg.CurrencyYearly
|
||
case "perpetual", "lifetime":
|
||
cur = cfg.CurrencyPerpetual
|
||
}
|
||
}
|
||
out = append(out, dto.WaffoProductMapping{
|
||
PlanKey: k,
|
||
Label: firstNonEmpty(strings.TrimSpace(m.Label), labelHint[k]),
|
||
ProductID: pid,
|
||
Currency: strings.ToUpper(strings.TrimSpace(cur)),
|
||
})
|
||
}
|
||
if len(out) > 0 {
|
||
return out
|
||
}
|
||
}
|
||
return mappingsFromIDs(cfg.ProductIDs, labelHint, currencyHint, cfg)
|
||
}
|
||
|
||
func mappingsFromIDs(ids map[string]string, labels, currencies map[string]string, cfg payment.WaffoConfig) []dto.WaffoProductMapping {
|
||
if len(ids) == 0 {
|
||
return []dto.WaffoProductMapping{
|
||
{PlanKey: "yearly", Label: "包年", Currency: "CNY"},
|
||
{PlanKey: "perpetual", Label: "永久", Currency: "USD"},
|
||
}
|
||
}
|
||
defaultLabels := map[string]string{
|
||
"yearly": "包年",
|
||
"monthly": "包月",
|
||
"perpetual": "永久",
|
||
"lifetime": "终身",
|
||
"trial": "试用",
|
||
"default": "默认",
|
||
}
|
||
// 稳定顺序:常用 key 优先,其余按字典序 // Logan.Li
|
||
order := []string{"trial", "yearly", "monthly", "perpetual", "lifetime", "default"}
|
||
seen := map[string]bool{}
|
||
out := make([]dto.WaffoProductMapping, 0, len(ids))
|
||
appendOne := func(k string) {
|
||
v := strings.TrimSpace(ids[k])
|
||
if v == "" || seen[k] {
|
||
return
|
||
}
|
||
seen[k] = true
|
||
label := ""
|
||
if labels != nil {
|
||
label = labels[k]
|
||
}
|
||
if label == "" {
|
||
label = defaultLabels[k]
|
||
}
|
||
cur := ""
|
||
if currencies != nil {
|
||
cur = currencies[k]
|
||
}
|
||
if cur == "" {
|
||
switch k {
|
||
case "yearly", "monthly":
|
||
cur = cfg.CurrencyYearly
|
||
case "perpetual", "lifetime":
|
||
cur = cfg.CurrencyPerpetual
|
||
default:
|
||
cur = cfg.Currency
|
||
}
|
||
}
|
||
out = append(out, dto.WaffoProductMapping{
|
||
PlanKey: k,
|
||
Label: label,
|
||
ProductID: v,
|
||
Currency: strings.ToUpper(strings.TrimSpace(cur)),
|
||
})
|
||
}
|
||
for _, k := range order {
|
||
appendOne(k)
|
||
}
|
||
rest := make([]string, 0)
|
||
for k := range ids {
|
||
k = strings.TrimSpace(strings.ToLower(k))
|
||
if k == "" || seen[k] || strings.TrimSpace(ids[k]) == "" {
|
||
continue
|
||
}
|
||
rest = append(rest, k)
|
||
}
|
||
for i := 0; i < len(rest); i++ {
|
||
for j := i + 1; j < len(rest); j++ {
|
||
if rest[j] < rest[i] {
|
||
rest[i], rest[j] = rest[j], rest[i]
|
||
}
|
||
}
|
||
}
|
||
for _, k := range rest {
|
||
appendOne(k)
|
||
}
|
||
if len(out) == 0 {
|
||
return []dto.WaffoProductMapping{
|
||
{PlanKey: "yearly", Label: "包年", Currency: "CNY"},
|
||
{PlanKey: "perpetual", Label: "永久", Currency: "USD"},
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func toPaymentMappings(in []dto.WaffoProductMapping) []payment.WaffoProductMapping {
|
||
out := make([]payment.WaffoProductMapping, 0, len(in))
|
||
for _, m := range in {
|
||
k := strings.TrimSpace(strings.ToLower(m.PlanKey))
|
||
if k == "" {
|
||
continue
|
||
}
|
||
out = append(out, payment.WaffoProductMapping{
|
||
PlanKey: k,
|
||
Label: strings.TrimSpace(m.Label),
|
||
ProductID: strings.TrimSpace(m.ProductID),
|
||
Currency: strings.ToUpper(strings.TrimSpace(m.Currency)),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// currencyFromMappings 从映射表取指定 plan 的货币 // Logan.Li
|
||
func currencyFromMappings(mappings []dto.WaffoProductMapping, plan string) string {
|
||
plan = strings.TrimSpace(strings.ToLower(plan))
|
||
for _, m := range mappings {
|
||
if strings.TrimSpace(strings.ToLower(m.PlanKey)) == plan {
|
||
return strings.ToUpper(strings.TrimSpace(m.Currency))
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func cloneProductIDs(in map[string]string) map[string]string {
|
||
if len(in) == 0 {
|
||
return map[string]string{}
|
||
}
|
||
out := make(map[string]string, len(in))
|
||
for k, v := range in {
|
||
k = strings.TrimSpace(strings.ToLower(k))
|
||
v = strings.TrimSpace(v)
|
||
if k == "" || v == "" {
|
||
continue
|
||
}
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, v := range values {
|
||
if s := strings.TrimSpace(v); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
var httpStatusRe = regexp.MustCompile(`\((\d{3})\)`)
|
||
|
||
func extractHTTPStatus(msg string) int {
|
||
m := httpStatusRe.FindStringSubmatch(msg)
|
||
if len(m) < 2 {
|
||
return 0
|
||
}
|
||
var n int
|
||
fmt.Sscanf(m[1], "%d", &n)
|
||
return n
|
||
}
|
||
|
||
func sanitizeDebugError(msg string) string {
|
||
msg = strings.TrimSpace(msg)
|
||
lower := strings.ToLower(msg)
|
||
// 防止误回显 PEM / 私钥片段 // Logan.Li
|
||
if strings.Contains(lower, "private key") || strings.Contains(msg, "BEGIN ") {
|
||
if idx := strings.Index(lower, "private"); idx >= 0 {
|
||
return "私钥相关错误(详情已隐藏)"
|
||
}
|
||
}
|
||
if len(msg) > 300 {
|
||
return msg[:300]
|
||
}
|
||
return msg
|
||
}
|
||
|
||
// ============ 审计日志 ============
|
||
|
||
// ListAuditLogs 分页查询审计日志 // Logan.Li
|
||
func (s *SettingService) ListAuditLogs(page, pageSize int, action, targetType string) ([]dto.AuditLogResponse, int64, error) {
|
||
var logs []model.AuditLog
|
||
var total int64
|
||
|
||
query := s.db.Model(&model.AuditLog{})
|
||
if action != "" {
|
||
query = query.Where("action = ?", action)
|
||
}
|
||
if targetType != "" {
|
||
query = query.Where("target_type = ?", targetType)
|
||
}
|
||
|
||
query.Count(&total)
|
||
if err := query.Order("created_at DESC").
|
||
Offset((page - 1) * pageSize).Limit(pageSize).
|
||
Find(&logs).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
results := make([]dto.AuditLogResponse, 0, len(logs))
|
||
for _, l := range logs {
|
||
results = append(results, dto.AuditLogResponse{
|
||
ID: l.ID,
|
||
Username: l.Username,
|
||
Action: l.Action,
|
||
TargetType: l.TargetType,
|
||
TargetID: l.TargetID,
|
||
TargetName: l.TargetName,
|
||
Detail: l.Detail,
|
||
Result: l.Result,
|
||
IP: l.IP,
|
||
CreatedAt: l.CreatedAt.Format("2006-01-02 15:04:05"),
|
||
})
|
||
}
|
||
return results, total, nil
|
||
}
|
||
|
||
// ClearAuditLogs 清空审计日志 // Logan.Li
|
||
func (s *SettingService) ClearAuditLogs() error {
|
||
return s.db.Exec("DELETE FROM audit_logs").Error
|
||
}
|