Files
opsx-licence/backend/internal/service/setting/setting_service.go
T
Logan.LiandCursor 76bba729f1 chore: 清理设置页多余文案并移除未使用的 License 模板
支付已迁至独立菜单;模板未接入发证流程,前后端入口一并下线。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 22:01:02 +08:00

533 lines
14 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 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)
mappings := buildProductMappings(cfg, meta.ProductMappings)
productIDs := cloneProductIDs(cfg.ProductIDs)
return &dto.WaffoSettingsResponse{
Enabled: enabled,
MerchantID: cfg.MerchantID,
StoreID: cfg.StoreID,
StoreSlug: cfg.StoreSlug,
Environment: cfg.Environment,
SandboxMode: cfg.Environment == "test",
Currency: cfg.Currency,
ProductID: cfg.ProductID,
ProductIDYearly: cfg.ProductIDYearly,
ProductIDPerpetual: cfg.ProductIDPerpetual,
ProductIDs: productIDs,
ProductMappings: mappings,
SuccessURL: cfg.SuccessURL,
WebhookURL: payment.DefaultWebhookURL,
PrivateKeyConfigured: strings.TrimSpace(cfg.PrivateKey) != "",
SourceHint: hint,
}, nil
}
// 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,
}
}
productIDs, mappings := mergeProductIDInputs(req)
meta := payment.WaffoPublicMeta{
MerchantID: strings.TrimSpace(req.MerchantID),
StoreID: strings.TrimSpace(req.StoreID),
StoreSlug: strings.TrimSpace(req.StoreSlug),
ProductID: strings.TrimSpace(req.ProductID),
ProductIDYearly: strings.TrimSpace(req.ProductIDYearly),
ProductIDPerpetual: strings.TrimSpace(req.ProductIDPerpetual),
ProductIDs: productIDs,
ProductMappings: toPaymentMappings(mappings),
SuccessURL: strings.TrimSpace(req.SuccessURL),
Currency: strings.TrimSpace(req.Currency),
}
if meta.Currency == "" {
meta.Currency = "CNY"
}
meta.CurrencyYearly = meta.Currency
meta.CurrencyPerpetual = meta.Currency
// 兼容字段与可扩展映射对齐 // 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"])
}
env := strings.ToLower(strings.TrimSpace(req.Environment))
switch env {
case "live", "prod", "production":
meta.Environment = "live"
gw.SandboxMode = false
default:
meta.Environment = "test"
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
}
if req.ClearPrivateKey {
gw.SecretKey = ""
} else if pk := strings.TrimSpace(req.PrivateKey); pk != "" {
if _, err := payment.ParseMerchantPrivateKey(pk); err != nil {
return nil, errors.New("私钥无法解析: " + err.Error())
}
gw.SecretKey = pk
}
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)
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"
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
}
session, err := payment.CreateCheckoutSession(cfg, payment.CreateSessionRequest{
ProductID: productID,
ProductType: "onetime",
Currency: firstNonEmpty(cfg.Currency, "CNY"),
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())
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
}
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,
})
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)
} 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{}
for _, m := range stored {
k := strings.TrimSpace(strings.ToLower(m.PlanKey))
if k == "" {
continue
}
if l := strings.TrimSpace(m.Label); l != "" {
labelHint[k] = l
}
}
return mappingsFromIDs(cfg.ProductIDs, labelHint)
}
func mappingsFromIDs(ids map[string]string, labels map[string]string) []dto.WaffoProductMapping {
if len(ids) == 0 {
return []dto.WaffoProductMapping{
{PlanKey: "yearly", Label: "包年"},
{PlanKey: "perpetual", Label: "永久"},
}
}
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]
}
out = append(out, dto.WaffoProductMapping{PlanKey: k, Label: label, ProductID: v})
}
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: "包年"},
{PlanKey: "perpetual", Label: "永久"},
}
}
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),
})
}
return out
}
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
}