feat: 通用授权后台、离线证、Waffo 支付页与仪表盘加强

支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-12 20:25:46 +08:00
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