feat: 通用授权后台、离线证、Waffo 支付页与仪表盘加强
支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
0966375076
commit
8cc2c83f9d
41 files changed
+3177
-332
No files matched your search
@@ -3,7 +3,13 @@ package setting
|
||||
import (
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
"backend/internal/service/payment"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -63,6 +69,423 @@ func (s *SettingService) Update(req dto.SettingUpdate) (*model.Setting, error) {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user