Files
opsx-licence/backend/internal/service/setting/setting_service.go
T
Logan.LiandCursor 9b79eabd34 fix: 修复收入统计 404,并隔离支付测试/生产配置
Dashboard 收入接口路径对齐;登录页去掉「授权管理平台」;Waffo 测试/生产密钥与 Product 映射分环境保存。

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

592 lines
16 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)
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
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: cp.ProductID,
ProductIDYearly: cp.ProductIDYearly,
ProductIDPerpetual: cp.ProductIDPerpetual,
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 = meta.Currency
meta.CurrencyPerpetual = 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)
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
}