fix: 修复收入统计 404,并隔离支付测试/生产配置
Dashboard 收入接口路径对齐;登录页去掉「授权管理平台」;Waffo 测试/生产密钥与 Product 映射分环境保存。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
76bba729f1
commit
9b79eabd34
9 files changed
+391
-80
No files matched your search
@@ -108,9 +108,20 @@ type WaffoSettingsResponse struct {
|
|||||||
SuccessURL string `json:"success_url"`
|
SuccessURL string `json:"success_url"`
|
||||||
WebhookURL string `json:"webhook_url"`
|
WebhookURL string `json:"webhook_url"`
|
||||||
PrivateKeyConfigured bool `json:"private_key_configured"`
|
PrivateKeyConfigured bool `json:"private_key_configured"`
|
||||||
|
PrivateKeyByEnv map[string]bool `json:"private_key_configured_by_env"`
|
||||||
|
Profiles map[string]WaffoEnvUI `json:"profiles"` // test/live 隔离的映射预览 // Logan.Li
|
||||||
SourceHint string `json:"source_hint"` // db | env | mixed
|
SourceHint string `json:"source_hint"` // db | env | mixed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WaffoEnvUI 单环境前端预览(无私钥) // Logan.Li
|
||||||
|
type WaffoEnvUI struct {
|
||||||
|
ProductMappings []WaffoProductMapping `json:"product_mappings"`
|
||||||
|
ProductID string `json:"product_id"`
|
||||||
|
ProductIDYearly string `json:"product_id_yearly"`
|
||||||
|
ProductIDPerpetual string `json:"product_id_perpetual"`
|
||||||
|
PrivateKeyConfigured bool `json:"private_key_configured"`
|
||||||
|
}
|
||||||
|
|
||||||
// WaffoSettingsUpdate 管理端更新 Waffo 配置;private_key 空=保留原值 // Logan.Li
|
// WaffoSettingsUpdate 管理端更新 Waffo 配置;private_key 空=保留原值 // Logan.Li
|
||||||
type WaffoSettingsUpdate struct {
|
type WaffoSettingsUpdate struct {
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type WaffoPublicMeta struct {
|
|||||||
ProductIDPerpetual string `json:"product_id_perpetual"`
|
ProductIDPerpetual string `json:"product_id_perpetual"`
|
||||||
ProductIDs map[string]string `json:"product_ids,omitempty"` // 可扩展映射,兼容旧字段 // Logan.Li
|
ProductIDs map[string]string `json:"product_ids,omitempty"` // 可扩展映射,兼容旧字段 // Logan.Li
|
||||||
ProductMappings []WaffoProductMapping `json:"product_mappings,omitempty"` // 前端表格(含 label) // Logan.Li
|
ProductMappings []WaffoProductMapping `json:"product_mappings,omitempty"` // 前端表格(含 label) // Logan.Li
|
||||||
|
EnvProfiles map[string]WaffoEnvProfile `json:"env_profiles,omitempty"` // test/live 隔离映射 // Logan.Li
|
||||||
SuccessURL string `json:"success_url"`
|
SuccessURL string `json:"success_url"`
|
||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
CurrencyYearly string `json:"currency_yearly"`
|
CurrencyYearly string `json:"currency_yearly"`
|
||||||
@@ -193,11 +194,40 @@ func mergeWaffoFromDB(db *gorm.DB, cfg *WaffoConfig) {
|
|||||||
} else {
|
} else {
|
||||||
cfg.Environment = "live"
|
cfg.Environment = "live"
|
||||||
}
|
}
|
||||||
|
cfg.Environment = NormalizeWaffoEnvironment(cfg.Environment)
|
||||||
|
meta.ApplyEnvProfile(cfg.Environment)
|
||||||
|
// ApplyEnvProfile 后同步产品字段到 cfg // Logan.Li
|
||||||
|
if meta.ProductID != "" {
|
||||||
|
cfg.ProductID = meta.ProductID
|
||||||
|
}
|
||||||
|
if meta.ProductIDYearly != "" {
|
||||||
|
cfg.ProductIDYearly = meta.ProductIDYearly
|
||||||
|
}
|
||||||
|
if meta.ProductIDPerpetual != "" {
|
||||||
|
cfg.ProductIDPerpetual = meta.ProductIDPerpetual
|
||||||
|
}
|
||||||
|
if len(meta.ProductIDs) > 0 {
|
||||||
|
cfg.ProductIDs = cloneStringMap(meta.ProductIDs)
|
||||||
|
}
|
||||||
|
if len(meta.ProductMappings) > 0 {
|
||||||
|
if cfg.ProductIDs == nil {
|
||||||
|
cfg.ProductIDs = map[string]string{}
|
||||||
|
}
|
||||||
|
for _, m := range meta.ProductMappings {
|
||||||
|
k := strings.TrimSpace(strings.ToLower(m.PlanKey))
|
||||||
|
v := strings.TrimSpace(m.ProductID)
|
||||||
|
if k == "" || v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cfg.ProductIDs[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
if strings.TrimSpace(gw.Currency) != "" && cfg.Currency == "" {
|
if strings.TrimSpace(gw.Currency) != "" && cfg.Currency == "" {
|
||||||
cfg.Currency = gw.Currency
|
cfg.Currency = gw.Currency
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(gw.SecretKey) != "" {
|
bag := ParseWaffoSecretBag(gw.SecretKey, cfg.Environment)
|
||||||
cfg.PrivateKey = strings.TrimSpace(gw.SecretKey)
|
if pk := bag.SecretForEnv(cfg.Environment); pk != "" {
|
||||||
|
cfg.PrivateKey = pk
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WaffoEnvProfile 单环境下的 Product 映射(测试/生产隔离) // Logan.Li
|
||||||
|
type WaffoEnvProfile struct {
|
||||||
|
ProductID string `json:"product_id,omitempty"`
|
||||||
|
ProductIDYearly string `json:"product_id_yearly,omitempty"`
|
||||||
|
ProductIDPerpetual string `json:"product_id_perpetual,omitempty"`
|
||||||
|
ProductIDs map[string]string `json:"product_ids,omitempty"`
|
||||||
|
ProductMappings []WaffoProductMapping `json:"product_mappings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaffoSecretBag 测试/生产私钥分存(写入 SecretKey 字段的 JSON) // Logan.Li
|
||||||
|
type WaffoSecretBag struct {
|
||||||
|
Test string `json:"test,omitempty"`
|
||||||
|
Live string `json:"live,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeWaffoEnvironment 归一化环境名 // Logan.Li
|
||||||
|
func NormalizeWaffoEnvironment(env string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(env)) {
|
||||||
|
case "live", "prod", "production":
|
||||||
|
return "live"
|
||||||
|
default:
|
||||||
|
return "test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseWaffoSecretBag 解析 SecretKey:JSON 双钥或旧版单 PEM // Logan.Li
|
||||||
|
func ParseWaffoSecretBag(raw, fallbackEnv string) WaffoSecretBag {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return WaffoSecretBag{}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(raw, "{") {
|
||||||
|
var bag WaffoSecretBag
|
||||||
|
if err := json.Unmarshal([]byte(raw), &bag); err == nil {
|
||||||
|
return bag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
env := NormalizeWaffoEnvironment(fallbackEnv)
|
||||||
|
if env == "live" {
|
||||||
|
return WaffoSecretBag{Live: raw}
|
||||||
|
}
|
||||||
|
return WaffoSecretBag{Test: raw}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeWaffoSecretBag 序列化双环境私钥 // Logan.Li
|
||||||
|
func EncodeWaffoSecretBag(bag WaffoSecretBag) (string, error) {
|
||||||
|
bag.Test = strings.TrimSpace(bag.Test)
|
||||||
|
bag.Live = strings.TrimSpace(bag.Live)
|
||||||
|
b, err := json.Marshal(bag)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretForEnv 取指定环境私钥 // Logan.Li
|
||||||
|
func (b WaffoSecretBag) SecretForEnv(env string) string {
|
||||||
|
if NormalizeWaffoEnvironment(env) == "live" {
|
||||||
|
return strings.TrimSpace(b.Live)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(b.Test)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSecretForEnv 写入指定环境私钥 // Logan.Li
|
||||||
|
func (b *WaffoSecretBag) SetSecretForEnv(env, secret string) {
|
||||||
|
secret = strings.TrimSpace(secret)
|
||||||
|
if NormalizeWaffoEnvironment(env) == "live" {
|
||||||
|
b.Live = secret
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.Test = secret
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSecretForEnv 清除指定环境私钥 // Logan.Li
|
||||||
|
func (b *WaffoSecretBag) ClearSecretForEnv(env string) {
|
||||||
|
b.SetSecretForEnv(env, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyEnvProfile 将当前环境的 Profile 叠到顶层字段供运行时使用 // Logan.Li
|
||||||
|
func (m *WaffoPublicMeta) ApplyEnvProfile(env string) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
env = NormalizeWaffoEnvironment(env)
|
||||||
|
if m.EnvProfiles == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, ok := m.EnvProfiles[env]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(p.ProductMappings) > 0 {
|
||||||
|
m.ProductMappings = p.ProductMappings
|
||||||
|
}
|
||||||
|
if len(p.ProductIDs) > 0 {
|
||||||
|
m.ProductIDs = p.ProductIDs
|
||||||
|
}
|
||||||
|
if p.ProductID != "" {
|
||||||
|
m.ProductID = p.ProductID
|
||||||
|
}
|
||||||
|
if p.ProductIDYearly != "" {
|
||||||
|
m.ProductIDYearly = p.ProductIDYearly
|
||||||
|
}
|
||||||
|
if p.ProductIDPerpetual != "" {
|
||||||
|
m.ProductIDPerpetual = p.ProductIDPerpetual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureEnvProfile 把顶层映射写入指定环境 Profile // Logan.Li
|
||||||
|
func (m *WaffoPublicMeta) CaptureEnvProfile(env string) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
env = NormalizeWaffoEnvironment(env)
|
||||||
|
if m.EnvProfiles == nil {
|
||||||
|
m.EnvProfiles = map[string]WaffoEnvProfile{}
|
||||||
|
}
|
||||||
|
m.EnvProfiles[env] = WaffoEnvProfile{
|
||||||
|
ProductID: m.ProductID,
|
||||||
|
ProductIDYearly: m.ProductIDYearly,
|
||||||
|
ProductIDPerpetual: m.ProductIDPerpetual,
|
||||||
|
ProductIDs: m.ProductIDs,
|
||||||
|
ProductMappings: m.ProductMappings,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,29 +86,73 @@ func (s *SettingService) GetWaffo() (*dto.WaffoSettingsResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
meta := payment.ParseWaffoPublicMeta(gw.PublicKey)
|
meta := payment.ParseWaffoPublicMeta(gw.PublicKey)
|
||||||
|
env := payment.NormalizeWaffoEnvironment(cfg.Environment)
|
||||||
|
meta.ApplyEnvProfile(env)
|
||||||
mappings := buildProductMappings(cfg, meta.ProductMappings)
|
mappings := buildProductMappings(cfg, meta.ProductMappings)
|
||||||
productIDs := cloneProductIDs(cfg.ProductIDs)
|
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{
|
return &dto.WaffoSettingsResponse{
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
MerchantID: cfg.MerchantID,
|
MerchantID: cfg.MerchantID,
|
||||||
StoreID: cfg.StoreID,
|
StoreID: cfg.StoreID,
|
||||||
StoreSlug: cfg.StoreSlug,
|
StoreSlug: cfg.StoreSlug,
|
||||||
Environment: cfg.Environment,
|
Environment: env,
|
||||||
SandboxMode: cfg.Environment == "test",
|
SandboxMode: env == "test",
|
||||||
Currency: cfg.Currency,
|
Currency: cfg.Currency,
|
||||||
ProductID: cfg.ProductID,
|
ProductID: firstNonEmpty(meta.ProductID, cfg.ProductID),
|
||||||
ProductIDYearly: cfg.ProductIDYearly,
|
ProductIDYearly: firstNonEmpty(meta.ProductIDYearly, cfg.ProductIDYearly),
|
||||||
ProductIDPerpetual: cfg.ProductIDPerpetual,
|
ProductIDPerpetual: firstNonEmpty(meta.ProductIDPerpetual, cfg.ProductIDPerpetual),
|
||||||
ProductIDs: productIDs,
|
ProductIDs: productIDs,
|
||||||
ProductMappings: mappings,
|
ProductMappings: mappings,
|
||||||
SuccessURL: cfg.SuccessURL,
|
SuccessURL: cfg.SuccessURL,
|
||||||
WebhookURL: payment.DefaultWebhookURL,
|
WebhookURL: payment.DefaultWebhookURL,
|
||||||
PrivateKeyConfigured: strings.TrimSpace(cfg.PrivateKey) != "",
|
PrivateKeyConfigured: bag.SecretForEnv(env) != "",
|
||||||
SourceHint: hint,
|
PrivateKeyByEnv: map[string]bool{
|
||||||
|
"test": bag.SecretForEnv("test") != "",
|
||||||
|
"live": bag.SecretForEnv("live") != "",
|
||||||
|
},
|
||||||
|
Profiles: profiles,
|
||||||
|
SourceHint: hint,
|
||||||
}, nil
|
}, 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
|
// UpdateWaffo 保存 Waffo 配置到 payment_gateway_configs(后台优先于 .env) // Logan.Li
|
||||||
func (s *SettingService) UpdateWaffo(req dto.WaffoSettingsUpdate) (*dto.WaffoSettingsResponse, error) {
|
func (s *SettingService) UpdateWaffo(req dto.WaffoSettingsUpdate) (*dto.WaffoSettingsResponse, error) {
|
||||||
var gw model.PaymentGatewayConfig
|
var gw model.PaymentGatewayConfig
|
||||||
@@ -123,25 +167,35 @@ func (s *SettingService) UpdateWaffo(req dto.WaffoSettingsUpdate) (*dto.WaffoSet
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
productIDs, mappings := mergeProductIDInputs(req)
|
oldMeta := payment.ParseWaffoPublicMeta(gw.PublicKey)
|
||||||
|
oldEnv := payment.NormalizeWaffoEnvironment(oldMeta.Environment)
|
||||||
meta := payment.WaffoPublicMeta{
|
if oldEnv == "test" && !gw.SandboxMode {
|
||||||
MerchantID: strings.TrimSpace(req.MerchantID),
|
oldEnv = "live"
|
||||||
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),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 == "" {
|
if meta.Currency == "" {
|
||||||
meta.Currency = "CNY"
|
meta.Currency = "CNY"
|
||||||
}
|
}
|
||||||
meta.CurrencyYearly = meta.Currency
|
meta.CurrencyYearly = meta.Currency
|
||||||
meta.CurrencyPerpetual = 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
|
// 兼容字段与可扩展映射对齐 // Logan.Li
|
||||||
if meta.ProductIDYearly == "" {
|
if meta.ProductIDYearly == "" {
|
||||||
@@ -154,13 +208,12 @@ func (s *SettingService) UpdateWaffo(req dto.WaffoSettingsUpdate) (*dto.WaffoSet
|
|||||||
meta.ProductID = firstNonEmpty(meta.ProductIDPerpetual, meta.ProductIDYearly, productIDs["default"])
|
meta.ProductID = firstNonEmpty(meta.ProductIDPerpetual, meta.ProductIDYearly, productIDs["default"])
|
||||||
}
|
}
|
||||||
|
|
||||||
env := strings.ToLower(strings.TrimSpace(req.Environment))
|
// 当前环境映射写入 Profile,保留另一环境配置 // Logan.Li
|
||||||
switch env {
|
meta.CaptureEnvProfile(newEnv)
|
||||||
case "live", "prod", "production":
|
|
||||||
meta.Environment = "live"
|
if newEnv == "live" {
|
||||||
gw.SandboxMode = false
|
gw.SandboxMode = false
|
||||||
default:
|
} else {
|
||||||
meta.Environment = "test"
|
|
||||||
gw.SandboxMode = true
|
gw.SandboxMode = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,14 +230,20 @@ func (s *SettingService) UpdateWaffo(req dto.WaffoSettingsUpdate) (*dto.WaffoSet
|
|||||||
gw.Enabled = true
|
gw.Enabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bag := payment.ParseWaffoSecretBag(gw.SecretKey, oldEnv)
|
||||||
if req.ClearPrivateKey {
|
if req.ClearPrivateKey {
|
||||||
gw.SecretKey = ""
|
bag.ClearSecretForEnv(newEnv)
|
||||||
} else if pk := strings.TrimSpace(req.PrivateKey); pk != "" {
|
} else if pk := strings.TrimSpace(req.PrivateKey); pk != "" {
|
||||||
if _, err := payment.ParseMerchantPrivateKey(pk); err != nil {
|
if _, err := payment.ParseMerchantPrivateKey(pk); err != nil {
|
||||||
return nil, errors.New("私钥无法解析: " + err.Error())
|
return nil, errors.New("私钥无法解析: " + err.Error())
|
||||||
}
|
}
|
||||||
gw.SecretKey = pk
|
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 {
|
if err := s.db.Save(&gw).Error; err != nil {
|
||||||
return nil, errors.New("保存 Waffo 配置失败")
|
return nil, errors.New("保存 Waffo 配置失败")
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
|||||||
register(analyticsGroup, "GET", "/license-growth", analyticsController.GetLicenseGrowth)
|
register(analyticsGroup, "GET", "/license-growth", analyticsController.GetLicenseGrowth)
|
||||||
register(analyticsGroup, "GET", "/license-growth-detail", analyticsController.GetLicenseGrowthDetail)
|
register(analyticsGroup, "GET", "/license-growth-detail", analyticsController.GetLicenseGrowthDetail)
|
||||||
register(analyticsGroup, "GET", "/product-distribution", analyticsController.GetProductDistribution)
|
register(analyticsGroup, "GET", "/product-distribution", analyticsController.GetProductDistribution)
|
||||||
|
register(analyticsGroup, "GET", "/revenue", analyticsController.GetRevenueStats)
|
||||||
register(analyticsGroup, "GET", "/geo-distribution", analyticsController.GetGeoDistribution)
|
register(analyticsGroup, "GET", "/geo-distribution", analyticsController.GetGeoDistribution)
|
||||||
register(analyticsGroup, "GET", "/platform-distribution", analyticsController.GetPlatformDistribution)
|
register(analyticsGroup, "GET", "/platform-distribution", analyticsController.GetPlatformDistribution)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
<link rel="apple-touch-icon" href="/favicon.png" />
|
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>opsx-licence 授权管理平台</title>
|
<title>opsx-licence</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function getProductDistribution() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getRevenueAnalytics() {
|
export function getRevenueAnalytics() {
|
||||||
return request({ url: '/analytics/revenue', method: 'get' })
|
return request({ url: '/dashboard/revenue', method: 'get' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGeoDistribution() {
|
export function getGeoDistribution() {
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="login-header">
|
<div class="login-header">
|
||||||
<el-icon size="40" color="#409eff"><Key /></el-icon>
|
|
||||||
<h2 class="login-title">opsx-licence</h2>
|
<h2 class="login-title">opsx-licence</h2>
|
||||||
<p class="login-subtitle">授权管理平台</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-form
|
<el-form
|
||||||
@@ -74,7 +72,6 @@ async function handleLogin() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await userStore.login(loginForm)
|
await userStore.login(loginForm)
|
||||||
// 登录成功后跳转到 redirect 或首页 // Logan.Li
|
|
||||||
const redirect = route.query.redirect || '/'
|
const redirect = route.query.redirect || '/'
|
||||||
router.push(redirect)
|
router.push(redirect)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -92,27 +89,25 @@ async function handleLogin() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: #f3f5f7;
|
||||||
}
|
}
|
||||||
.login-card {
|
.login-card {
|
||||||
width: 400px;
|
width: 380px;
|
||||||
padding: 40px;
|
padding: 36px 32px 28px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border: 1px solid #ebeef5;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||||
}
|
}
|
||||||
.login-header {
|
.login-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 28px;
|
||||||
}
|
}
|
||||||
.login-title {
|
.login-title {
|
||||||
margin: 12px 0 4px;
|
margin: 0;
|
||||||
font-size: 24px;
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
color: #303133;
|
color: #303133;
|
||||||
}
|
}
|
||||||
.login-subtitle {
|
|
||||||
margin: 0;
|
|
||||||
color: #909399;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
+115
-32
@@ -18,7 +18,7 @@
|
|||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
title="商户 ID / Store / 私钥来自 Waffo Dashboard「集成」页。复制密钥后粘贴保存即可,无需手改服务器 .env。"
|
title="测试 / 生产环境的私钥与 Product ID 映射相互隔离。切换到生产后需单独填写生产密钥;保存后才会生效。"
|
||||||
style="margin-bottom: 16px"
|
style="margin-bottom: 16px"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -26,6 +26,24 @@
|
|||||||
<el-form-item label="启用">
|
<el-form-item label="启用">
|
||||||
<el-switch v-model="waffo.enabled" />
|
<el-switch v-model="waffo.enabled" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="环境">
|
||||||
|
<div class="env-row">
|
||||||
|
<span :class="{ muted: isLive }">测试</span>
|
||||||
|
<el-switch
|
||||||
|
v-model="isLive"
|
||||||
|
inline-prompt
|
||||||
|
active-text="生产"
|
||||||
|
inactive-text="测试"
|
||||||
|
style="margin: 0 10px"
|
||||||
|
@change="onEnvSwitch"
|
||||||
|
/>
|
||||||
|
<span :class="{ muted: !isLive }">生产</span>
|
||||||
|
<el-tag size="small" :type="isLive ? 'danger' : 'success'" style="margin-left: 12px">
|
||||||
|
{{ isLive ? 'live' : 'test' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="hint">切换环境会载入该环境的 Product 映射;若该环境尚未配置私钥,需重新粘贴保存。</div>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="商户 ID">
|
<el-form-item label="商户 ID">
|
||||||
<el-input v-model="waffo.merchant_id" placeholder="MER_…" />
|
<el-input v-model="waffo.merchant_id" placeholder="MER_…" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -35,16 +53,10 @@
|
|||||||
<el-form-item label="Store Slug">
|
<el-form-item label="Store Slug">
|
||||||
<el-input v-model="waffo.store_slug" placeholder="如 opsxshell-pwsi6m4n" />
|
<el-input v-model="waffo.store_slug" placeholder="如 opsxshell-pwsi6m4n" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="环境">
|
|
||||||
<el-radio-group v-model="waffo.environment">
|
|
||||||
<el-radio value="test">测试 (test)</el-radio>
|
|
||||||
<el-radio value="live">生产 (live)</el-radio>
|
|
||||||
</el-radio-group>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="API 私钥">
|
<el-form-item label="API 私钥">
|
||||||
<div class="secret-row">
|
<div class="secret-row">
|
||||||
<el-tag :type="waffo.private_key_configured ? 'success' : 'warning'" size="small">
|
<el-tag :type="waffo.private_key_configured ? 'success' : 'warning'" size="small">
|
||||||
{{ waffo.private_key_configured ? '已配置' : '未配置' }}
|
{{ waffo.private_key_configured ? `已配置(${waffo.environment})` : `未配置(${waffo.environment})` }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<el-button size="small" @click="showPrivateKey = !showPrivateKey">
|
<el-button size="small" @click="showPrivateKey = !showPrivateKey">
|
||||||
{{ showPrivateKey ? '取消更换' : (waffo.private_key_configured ? '更换' : '填写') }}
|
{{ showPrivateKey ? '取消更换' : (waffo.private_key_configured ? '更换' : '填写') }}
|
||||||
@@ -52,11 +64,11 @@
|
|||||||
<el-button v-if="waffo.private_key_configured" size="small" type="danger" link @click="clearPrivateKey">清除</el-button>
|
<el-button v-if="waffo.private_key_configured" size="small" type="danger" link @click="clearPrivateKey">清除</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-input
|
<el-input
|
||||||
v-if="showPrivateKey"
|
v-if="showPrivateKey || !waffo.private_key_configured"
|
||||||
v-model="privateKeyInput"
|
v-model="privateKeyInput"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:rows="4"
|
:rows="4"
|
||||||
placeholder="粘贴 PEM 或 Dashboard Base64 密钥(保存后不再回显)"
|
:placeholder="`粘贴 ${waffo.environment === 'live' ? '生产' : '测试'} 环境 PEM / Base64 密钥(保存后不再回显)`"
|
||||||
style="margin-top: 8px"
|
style="margin-top: 8px"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -80,7 +92,7 @@
|
|||||||
<el-card shadow="never" style="margin-top: 16px">
|
<el-card shadow="never" style="margin-top: 16px">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="title">Product ID 映射</span>
|
<span class="title">Product ID 映射({{ isLive ? '生产' : '测试' }})</span>
|
||||||
<el-button size="small" type="primary" @click="addMapping">新增行</el-button>
|
<el-button size="small" type="primary" @click="addMapping">新增行</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -88,7 +100,7 @@
|
|||||||
type="warning"
|
type="warning"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
title="将计划类型 / 业务 key 映射到 Waffo Product ID(如 yearly → PROD_xxx)。保存时会同步兼容字段 product_id_yearly / product_id_perpetual。"
|
title="测试与生产的 Product ID 通常不同,已按环境分别保存。切换开关即可编辑对应环境映射。"
|
||||||
style="margin-bottom: 12px"
|
style="margin-bottom: 12px"
|
||||||
/>
|
/>
|
||||||
<el-table :data="productMappings" border stripe>
|
<el-table :data="productMappings" border stripe>
|
||||||
@@ -132,7 +144,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { getWaffoSettings, updateWaffoSettings, testWaffoConnection } from '@/api/setting'
|
import { getWaffoSettings, updateWaffoSettings, testWaffoConnection } from '@/api/setting'
|
||||||
|
|
||||||
@@ -146,6 +158,8 @@ const privateKeyInput = ref('')
|
|||||||
const clearKeyFlag = ref(false)
|
const clearKeyFlag = ref(false)
|
||||||
const debugResult = ref('')
|
const debugResult = ref('')
|
||||||
const productMappings = ref([])
|
const productMappings = ref([])
|
||||||
|
const profiles = ref({ test: null, live: null })
|
||||||
|
const keyByEnv = ref({ test: false, live: false })
|
||||||
|
|
||||||
const waffo = reactive({
|
const waffo = reactive({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -163,6 +177,20 @@ const waffo = reactive({
|
|||||||
private_key_configured: false,
|
private_key_configured: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isLive = computed({
|
||||||
|
get: () => waffo.environment === 'live',
|
||||||
|
set: (v) => {
|
||||||
|
waffo.environment = v ? 'live' : 'test'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function defaultMappings() {
|
||||||
|
return [
|
||||||
|
{ plan_key: 'yearly', label: '包年', product_id: '' },
|
||||||
|
{ plan_key: 'perpetual', label: '永久', product_id: '' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeMappings(data = {}) {
|
function normalizeMappings(data = {}) {
|
||||||
if (Array.isArray(data.product_mappings) && data.product_mappings.length) {
|
if (Array.isArray(data.product_mappings) && data.product_mappings.length) {
|
||||||
return data.product_mappings.map((m) => ({
|
return data.product_mappings.map((m) => ({
|
||||||
@@ -182,13 +210,7 @@ function normalizeMappings(data = {}) {
|
|||||||
product_id: data.product_id_perpetual || data.product_id,
|
product_id: data.product_id_perpetual || data.product_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!rows.length) {
|
return rows.length ? rows : defaultMappings()
|
||||||
rows.push(
|
|
||||||
{ plan_key: 'yearly', label: '包年', product_id: '' },
|
|
||||||
{ plan_key: 'perpetual', label: '永久', product_id: '' },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mappingsToCompatFields(rows) {
|
function mappingsToCompatFields(rows) {
|
||||||
@@ -212,6 +234,33 @@ function mappingsToCompatFields(rows) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stashCurrentEnv() {
|
||||||
|
const env = waffo.environment === 'live' ? 'live' : 'test'
|
||||||
|
profiles.value[env] = {
|
||||||
|
product_mappings: productMappings.value.map((r) => ({ ...r })),
|
||||||
|
private_key_configured: waffo.private_key_configured,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEnv(env) {
|
||||||
|
const target = env === 'live' ? 'live' : 'test'
|
||||||
|
waffo.environment = target
|
||||||
|
const profile = profiles.value[target]
|
||||||
|
productMappings.value = normalizeMappings(profile || {})
|
||||||
|
const configured = !!(keyByEnv.value[target] || profile?.private_key_configured)
|
||||||
|
waffo.private_key_configured = configured
|
||||||
|
privateKeyInput.value = ''
|
||||||
|
clearKeyFlag.value = false
|
||||||
|
// 未配置私钥时强制展开输入框,避免仍显示另一环境密钥状态 // Logan.Li
|
||||||
|
showPrivateKey.value = !configured
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnvSwitch(live) {
|
||||||
|
stashCurrentEnv()
|
||||||
|
applyEnv(live ? 'live' : 'test')
|
||||||
|
ElMessage.info(live ? '已切换到生产环境视图(保存后生效)' : '已切换到测试环境视图(保存后生效)')
|
||||||
|
}
|
||||||
|
|
||||||
function addMapping() {
|
function addMapping() {
|
||||||
productMappings.value.push({ plan_key: '', label: '', product_id: '' })
|
productMappings.value.push({ plan_key: '', label: '', product_id: '' })
|
||||||
}
|
}
|
||||||
@@ -225,12 +274,28 @@ async function load() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getWaffoSettings()
|
const res = await getWaffoSettings()
|
||||||
Object.assign(waffo, res.data || {})
|
const data = res.data || {}
|
||||||
|
Object.assign(waffo, data)
|
||||||
if (!waffo.environment) waffo.environment = waffo.sandbox_mode === false ? 'live' : 'test'
|
if (!waffo.environment) waffo.environment = waffo.sandbox_mode === false ? 'live' : 'test'
|
||||||
productMappings.value = normalizeMappings(res.data || {})
|
waffo.environment = waffo.environment === 'live' ? 'live' : 'test'
|
||||||
showPrivateKey.value = false
|
|
||||||
privateKeyInput.value = ''
|
keyByEnv.value = {
|
||||||
clearKeyFlag.value = false
|
test: !!(data.private_key_configured_by_env?.test),
|
||||||
|
live: !!(data.private_key_configured_by_env?.live),
|
||||||
|
}
|
||||||
|
profiles.value = {
|
||||||
|
test: data.profiles?.test || { product_mappings: [] },
|
||||||
|
live: data.profiles?.live || { product_mappings: [] },
|
||||||
|
}
|
||||||
|
// 当前环境优先用顶层字段(已 ApplyEnvProfile) // Logan.Li
|
||||||
|
profiles.value[waffo.environment] = {
|
||||||
|
product_mappings: data.product_mappings || [],
|
||||||
|
product_id: data.product_id,
|
||||||
|
product_id_yearly: data.product_id_yearly,
|
||||||
|
product_id_perpetual: data.product_id_perpetual,
|
||||||
|
private_key_configured: data.private_key_configured,
|
||||||
|
}
|
||||||
|
applyEnv(waffo.environment)
|
||||||
debugResult.value = ''
|
debugResult.value = ''
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -240,6 +305,7 @@ async function load() {
|
|||||||
async function save() {
|
async function save() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
|
stashCurrentEnv()
|
||||||
const compat = mappingsToCompatFields(productMappings.value)
|
const compat = mappingsToCompatFields(productMappings.value)
|
||||||
const payload = {
|
const payload = {
|
||||||
enabled: waffo.enabled,
|
enabled: waffo.enabled,
|
||||||
@@ -252,15 +318,29 @@ async function save() {
|
|||||||
clear_private_key: clearKeyFlag.value,
|
clear_private_key: clearKeyFlag.value,
|
||||||
...compat,
|
...compat,
|
||||||
}
|
}
|
||||||
if (showPrivateKey.value && privateKeyInput.value.trim()) {
|
if (privateKeyInput.value.trim()) {
|
||||||
payload.private_key = privateKeyInput.value.trim()
|
payload.private_key = privateKeyInput.value.trim()
|
||||||
|
payload.clear_private_key = false
|
||||||
|
} else if (!waffo.private_key_configured) {
|
||||||
|
// 当前环境无密钥且未填写:明确清除该环境密钥位 // Logan.Li
|
||||||
|
payload.clear_private_key = true
|
||||||
}
|
}
|
||||||
const res = await updateWaffoSettings(payload)
|
const res = await updateWaffoSettings(payload)
|
||||||
Object.assign(waffo, res.data || {})
|
Object.assign(waffo, res.data || {})
|
||||||
productMappings.value = normalizeMappings(res.data || { ...compat })
|
const data = res.data || {}
|
||||||
showPrivateKey.value = false
|
keyByEnv.value = {
|
||||||
privateKeyInput.value = ''
|
test: !!(data.private_key_configured_by_env?.test),
|
||||||
clearKeyFlag.value = false
|
live: !!(data.private_key_configured_by_env?.live),
|
||||||
|
}
|
||||||
|
profiles.value = {
|
||||||
|
test: data.profiles?.test || profiles.value.test,
|
||||||
|
live: data.profiles?.live || profiles.value.live,
|
||||||
|
}
|
||||||
|
profiles.value[waffo.environment] = {
|
||||||
|
product_mappings: data.product_mappings || [],
|
||||||
|
private_key_configured: data.private_key_configured,
|
||||||
|
}
|
||||||
|
applyEnv(waffo.environment)
|
||||||
ElMessage.success('支付配置已保存')
|
ElMessage.success('支付配置已保存')
|
||||||
} catch { /* */ } finally {
|
} catch { /* */ } finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
@@ -270,9 +350,10 @@ async function save() {
|
|||||||
function clearPrivateKey() {
|
function clearPrivateKey() {
|
||||||
clearKeyFlag.value = true
|
clearKeyFlag.value = true
|
||||||
waffo.private_key_configured = false
|
waffo.private_key_configured = false
|
||||||
|
keyByEnv.value[waffo.environment] = false
|
||||||
privateKeyInput.value = ''
|
privateKeyInput.value = ''
|
||||||
showPrivateKey.value = false
|
showPrivateKey.value = true
|
||||||
ElMessage.info('将在保存后清除私钥')
|
ElMessage.info('将在保存后清除当前环境私钥')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyWebhook() {
|
async function copyWebhook() {
|
||||||
@@ -315,6 +396,8 @@ onMounted(load)
|
|||||||
.title { font-weight: 600; }
|
.title { font-weight: 600; }
|
||||||
.header-actions { display: flex; gap: 8px; }
|
.header-actions { display: flex; gap: 8px; }
|
||||||
.secret-row { display: flex; align-items: center; gap: 10px; }
|
.secret-row { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.env-row { display: flex; align-items: center; }
|
||||||
|
.muted { color: #c0c4cc; }
|
||||||
.hint { margin-top: 6px; font-size: 12px; color: #909399; }
|
.hint { margin-top: 6px; font-size: 12px; color: #909399; }
|
||||||
.debug-box {
|
.debug-box {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
Reference in new issue
Block a user