From bac72e69454c3ccf111adb0b1a49780ba5b298e5 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Wed, 12 Aug 2026 21:26:58 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8C=89=E8=AF=84=E5=AE=A1=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E6=AD=A2=E8=A1=80=E6=94=AF=E4=BB=98=E6=BC=8F=E6=B4=9E?= =?UTF-8?q?=E5=B9=B6=E5=8A=A0=E5=9B=BA=E6=8E=88=E6=9D=83=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 废除浏览器回跳自助结算,通用 webhook 强制密钥鉴权,Waffo 支持验签与未知事件忽略;重构订单状态机/激活占座/SQLite WAL,并补备份、readyz、限流与前端导出/登录页安全修复。 Co-authored-by: Cursor --- .env.example | 5 + Dockerfile | 4 +- backend/database/database.go | 49 ++- backend/database/seed.go | 55 ++-- backend/go.mod | 3 +- backend/go.sum | 2 + .../controller/order/order_controller.go | 108 ++++--- backend/internal/dto/commerce.go | 4 +- backend/internal/model/customer.go | 2 +- backend/internal/model/entitlement.go | 8 +- backend/internal/model/license.go | 4 +- backend/internal/model/order.go | 2 +- .../entitlement/entitlement_service.go | 19 +- .../service/license/client_service.go | 173 +++++++---- .../internal/service/order/order_service.go | 285 ++++++++---------- .../internal/service/payment/waffo_webhook.go | 127 ++++++++ backend/middleware/ratelimit.go | 57 ++++ backend/router/router.go | 46 ++- backend/utils/offline_license.go | 13 +- docker-compose.yml | 28 +- frontend/package.json | 2 +- frontend/src/api/request.js | 62 ++-- frontend/src/main.js | 3 +- frontend/src/views/Login.vue | 12 +- 24 files changed, 682 insertions(+), 391 deletions(-) create mode 100644 backend/internal/service/payment/waffo_webhook.go create mode 100644 backend/middleware/ratelimit.go diff --git a/.env.example b/.env.example index 30f9b7e..bdf082b 100644 --- a/.env.example +++ b/.env.example @@ -77,6 +77,11 @@ SUCCESS_URL=https://l123.91ai.io/api/v1/purchase/success # Dashboard → Settings → Webhooks: # URL: https://你的域名/api/v1/webhooks/waffo (尽量 HTTPS;Raw JSON;事件 order.completed) # 当前公网: https://l123.91ai.io/api/v1/webhooks/waffo +# 验签公钥(RSA PEM)或共享密钥;配置后强制校验 X-Waffo-Signature +# WAFFO_WEBHOOK_PUBLIC_KEY= +# WAFFO_WEBHOOK_SECRET= +# 通用回调 /api/v1/webhooks/payment 在 release 下必须配置,并通过 X-Webhook-Secret 传入 +# INTERNAL_WEBHOOK_SECRET= # ============================================ # Docker Compose diff --git a/Dockerfile b/Dockerfile index 4f03955..03acafb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ COPY frontend/ ./ RUN npm run build # ==================== Stage 2: Backend ==================== -FROM golang:1.24-bookworm AS backend-builder +FROM golang:1.25-bookworm AS backend-builder WORKDIR /src ENV GOPROXY=https://goproxy.cn,direct @@ -61,6 +61,6 @@ VOLUME ["/data"] EXPOSE 9001 HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=5 \ - CMD curl -fsS "http://127.0.0.1:${PORT}/health" || exit 1 + CMD curl -fsS "http://127.0.0.1:${PORT}/readyz" || exit 1 ENTRYPOINT ["/app/opsx-licence"] diff --git a/backend/database/database.go b/backend/database/database.go index 786464b..4cf1b0d 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -4,24 +4,43 @@ import ( "backend/config" "backend/internal/model" "fmt" + "strings" + "time" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) -// Init 初始化数据库连接并执行迁移 // Logan.Li +// Init 初始化数据库连接并执行迁移(当前交付形态以 SQLite 为准) // Logan.Li func Init(cfg *config.Config) (*gorm.DB, error) { logLevel := getLogLevel(cfg.Mode) - fmt.Printf("[数据库] 使用 SQLite: %s\n", cfg.DBName) - db, err := gorm.Open(sqlite.Open(cfg.DBName), &gorm.Config{ + driver := strings.ToLower(strings.TrimSpace(cfg.DBDriver)) + if driver != "" && driver != "sqlite" && driver != "sqlite3" { + return nil, fmt.Errorf("当前仅支持 SQLite(收到 DB_DRIVER=%s);MySQL/PostgreSQL 尚未接入", cfg.DBDriver) + } + + dsn := cfg.DBName + if !strings.Contains(dsn, "?") { + dsn += "?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL&_foreign_keys=on" + } + fmt.Printf("[数据库] 使用 SQLite(WAL): %s\n", cfg.DBName) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logLevel), }) if err != nil { return nil, fmt.Errorf("SQLite 连接失败: %v", err) } + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + sqlDB.SetMaxOpenConns(1) // SQLite 写串行化,WAL 下读不阻塞 // Logan.Li + sqlDB.SetMaxIdleConns(1) + sqlDB.SetConnMaxLifetime(time.Hour) + return migrateDatabase(db) } @@ -37,21 +56,21 @@ func getLogLevel(mode string) logger.LogLevel { func migrateDatabase(db *gorm.DB) (*gorm.DB, error) { err := db.AutoMigrate( // 认证与系统 - &model.User{}, // 管理员用户 // Logan.Li - &model.Setting{}, // 系统设置 // Logan.Li - &model.AuditLog{}, // 操作日志 // Logan.Li - &model.LicenseTemplate{}, // License 模板 // Logan.Li - &model.PaymentGatewayConfig{}, // 支付网关配置 // Logan.Li + &model.User{}, // 管理员用户 // Logan.Li + &model.Setting{}, // 系统设置 // Logan.Li + &model.AuditLog{}, // 操作日志 // Logan.Li + &model.LicenseTemplate{}, // License 模板 // Logan.Li + &model.PaymentGatewayConfig{}, // 支付网关配置 // Logan.Li // 核心授权域 - &model.Product{}, // 产品 // Logan.Li - &model.Customer{}, // 客户 // Logan.Li - &model.License{}, // License // Logan.Li - &model.Activation{}, // 激活记录 // Logan.Li + &model.Product{}, // 产品 // Logan.Li + &model.Customer{}, // 客户 // Logan.Li + &model.License{}, // License // Logan.Li + &model.Activation{}, // 激活记录 // Logan.Li // 定价与订单 - &model.Plan{}, // 定价计划 // Logan.Li - &model.Order{}, // 订单 // Logan.Li + &model.Plan{}, // 定价计划 // Logan.Li + &model.Order{}, // 订单 // Logan.Li // 防重复购买 - &model.CustomerEntitlement{}, // 客户权益 // Logan.Li + &model.CustomerEntitlement{}, // 客户权益 // Logan.Li ) if err != nil { return nil, fmt.Errorf("数据库迁移失败: %v", err) diff --git a/backend/database/seed.go b/backend/database/seed.go index 60609ee..847f4f8 100644 --- a/backend/database/seed.go +++ b/backend/database/seed.go @@ -239,37 +239,26 @@ func ensureOpsXShellPlanPricing(db *gorm.DB) error { for _, u := range updates { var plan model.Plan err := db.Where("product_id = ? AND plan_type = ?", product.ID, u.planType).First(&plan).Error - if err != nil { - plan = model.Plan{ - ProductID: product.ID, - Name: u.name, - PlanType: u.planType, - Price: u.price, - Currency: u.currency, - Duration: u.duration, - MaxDevices: u.maxDevices, - Features: u.features, - SortOrder: u.sortOrder, - Status: 1, - } - if err := db.Create(&plan).Error; err != nil { - return err - } - continue + if err == nil { + continue // 已存在:尊重管理员运行时修改,不再覆盖 // Logan.Li } - plan.Name = u.name - plan.Price = u.price - plan.Currency = u.currency - plan.Duration = u.duration - plan.MaxDevices = u.maxDevices - plan.Features = u.features - plan.SortOrder = u.sortOrder - plan.Status = 1 - if err := db.Save(&plan).Error; err != nil { + plan = model.Plan{ + ProductID: product.ID, + Name: u.name, + PlanType: u.planType, + Price: u.price, + Currency: u.currency, + Duration: u.duration, + MaxDevices: u.maxDevices, + Features: u.features, + SortOrder: u.sortOrder, + Status: 1, + } + if err := db.Create(&plan).Error; err != nil { return err } } - log.Printf("[初始化] OpsXShell 套餐已同步: 包年¥99/2台, 永久¥199/5台, 体验180天/1台") + log.Printf("[初始化] OpsXShell 套餐已确保存在(仅新建,不覆盖已有价格)") return nil } @@ -288,21 +277,13 @@ func ensureWaffoPaymentGateway(db *gorm.DB) error { if err := db.Create(&gw).Error; err != nil { return err } - } else { - existing.Enabled = true - existing.SandboxMode = true - existing.Currency = "CNY" - existing.Priority = 0 - if err := db.Save(&existing).Error; err != nil { - return err - } + log.Println("[初始化] 已创建 Waffo 支付网关(默认沙箱,可在后台改 live)") } - // Lower priority for other enabled gateways (ASC pick prefers Waffo). + // 已存在则不覆盖 Enabled/SandboxMode,尊重管理员配置 // Logan.Li if err := db.Model(&model.PaymentGatewayConfig{}). Where("gateway <> ? AND enabled = ?", "waffo", true). Update("priority", 100).Error; err != nil { return err } - log.Println("[初始化] 支付网关已优先 Waffo(包年/永久均为 onetime)") return nil } diff --git a/backend/go.mod b/backend/go.mod index 47fbe40..3d50543 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module backend -go 1.24.0 +go 1.25.0 require ( github.com/gin-contrib/cors v1.5.0 @@ -9,6 +9,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.0 github.com/joho/godotenv v1.5.1 golang.org/x/crypto v0.43.0 + golang.org/x/time v0.15.0 gorm.io/driver/sqlite v1.5.4 gorm.io/gorm v1.25.5 ) diff --git a/backend/go.sum b/backend/go.sum index 75ae538..67fc39c 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -95,6 +95,8 @@ golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= diff --git a/backend/internal/controller/order/order_controller.go b/backend/internal/controller/order/order_controller.go index 22588dd..d9f61e9 100644 --- a/backend/internal/controller/order/order_controller.go +++ b/backend/internal/controller/order/order_controller.go @@ -4,12 +4,15 @@ import ( "backend/internal/dto" "backend/internal/model" orderservice "backend/internal/service/order" + "backend/internal/service/payment" "backend/utils" "encoding/json" "fmt" "html" "io" + "log" "net/http" + "os" "strconv" "strings" @@ -102,8 +105,32 @@ func (ctrl *OrderController) ClaimPurchase(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp}) } -// PaymentWebhook 支付回调(公开接口,供支付渠道调用) // Logan.Li +// PaymentWebhook 通用支付回调:必须携带与网关配置一致的共享密钥,禁止匿名结算 // Logan.Li func (ctrl *OrderController) PaymentWebhook(c *gin.Context) { + secret := strings.TrimSpace(os.Getenv("INTERNAL_WEBHOOK_SECRET")) + if secret == "" { + var gw model.PaymentGatewayConfig + if err := ctrl.db.Where("enabled = ?", true).Order("priority ASC").First(&gw).Error; err == nil { + secret = strings.TrimSpace(gw.WebhookSecret) + } + } + provided := strings.TrimSpace(firstNonEmpty( + c.GetHeader("X-Webhook-Secret"), + c.GetHeader("X-Internal-Webhook-Secret"), + strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer "), + )) + release := strings.EqualFold(os.Getenv("GIN_MODE"), "release") + if secret == "" { + if release { + c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "未配置 webhook 密钥,已拒绝"}) + return + } + log.Println("[安全警告] INTERNAL_WEBHOOK_SECRET / WebhookSecret 未配置,开发模式放行通用回调") + } else if provided == "" || provided != secret { + c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid webhook secret"}) + return + } + var req dto.PaymentWebhookRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "Invalid payload"}) @@ -141,24 +168,12 @@ func (ctrl *OrderController) WaffoWebhookReady(c *gin.Context) { ))) } -// PurchaseSuccess browser success / claim page after Waffo checkout. // Logan.Li +// PurchaseSuccess 浏览器回跳仅展示 DB 已确认的支付结果,绝不信任 query 自助结算 // Logan.Li func (ctrl *OrderController) PurchaseSuccess(c *gin.Context) { orderNo := firstNonEmpty(c.Query("order_no"), c.Query("orderNo")) email := firstNonEmpty(c.Query("email"), c.Query("customer_email"), c.Query("customerEmail")) planType := firstNonEmpty(c.Query("planType"), c.Query("plan_type")) - // If Waffo bounced here with paid-like query, try to settle once. // Logan.Li - statusHint := strings.ToLower(firstNonEmpty(c.Query("status"), c.Query("payment_status"), c.Query("paymentStatus"))) - if orderNo != "" && (strings.Contains(statusHint, "paid") || strings.Contains(statusHint, "success") || strings.Contains(statusHint, "complete")) { - _ = ctrl.svc.PaymentWebhook(dto.PaymentWebhookRequest{ - EventType: "browser_return", - OrderNo: orderNo, - Status: "paid", - Gateway: "waffo", - Payload: c.Request.URL.RawQuery, - }, utils.GetClientIP(c)) - } - title := "支付结果" headline := "正在确认支付…" body := "若你已完成付款,请稍等数秒后刷新本页;或打开 OpsXShell,在「账户」粘贴许可证激活。" @@ -172,13 +187,8 @@ func (ctrl *OrderController) PurchaseSuccess(c *gin.Context) { switch order.Status { case "paid": headline = "支付成功" - if order.LicenseID > 0 { - var lic model.License - if err := ctrl.db.First(&lic, order.LicenseID).Error; err == nil { - licenseKey = lic.LicenseKey - } - } - if licenseKey == "" { + // 仅当邮箱匹配订单客户时展示 License,防止仅凭订单号泄露密钥 // Logan.Li + if email != "" { claimed, err := ctrl.svc.ClaimPurchase(dto.ClaimPurchaseRequest{ OrderNo: orderNo, CustomerEmail: email, @@ -187,10 +197,14 @@ func (ctrl *OrderController) PurchaseSuccess(c *gin.Context) { if err == nil && claimed != nil { licenseKey = claimed.LicenseKey } + } else if order.LicenseID > 0 { + body = "订单已支付。请在 OpsXShell 内用下单邮箱领取许可证,或刷新本页并带上 email 参数。" } if licenseKey != "" { body = "请复制下方许可证,回到 OpsXShell → 设置 / 升级页 → 粘贴激活。也可点击「打开应用」自动回跳。" - } else { + } else if order.LicenseID > 0 && email == "" { + body = "订单已支付。为保护密钥,请使用下单邮箱在客户端领取,或访问本页并附带 email 参数。" + } else if licenseKey == "" { body = "订单已支付,许可证生成中,请稍后刷新本页。" } case "pending": @@ -205,17 +219,8 @@ func (ctrl *OrderController) PurchaseSuccess(c *gin.Context) { body = "请确认是否从 OpsXShell 内发起购买。订单号:" + html.EscapeString(orderNo) } } else if email != "" { - claimed, err := ctrl.svc.ClaimPurchase(dto.ClaimPurchaseRequest{ - CustomerEmail: email, - ProductCode: "opsxshell", - }) - if err == nil && claimed != nil && claimed.LicenseKey != "" { - headline = "支付成功" - licenseKey = claimed.LicenseKey - orderNo = claimed.OrderNo - orderStatus = claimed.Status - body = "已根据邮箱找到可用许可证,请复制后在 OpsXShell 中激活。" - } + headline = "请提供订单号" + body = "为保护授权安全,领取许可证需同时提供订单号与下单邮箱。" } deepLink := "opsxshell://pro/activated" @@ -318,8 +323,18 @@ func (ctrl *OrderController) WaffoWebhook(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "无法读取请求体"}) return } - sigHeader := c.GetHeader("X-Waffo-Signature") - _ = sigHeader // optional RSA verify when WAFFO_WEBHOOK_PUBLIC_KEY is set later // Logan.Li + sigHeader := firstNonEmpty( + c.GetHeader("X-Waffo-Signature"), + c.GetHeader("X-Signature"), + c.GetHeader("X-Webhook-Secret"), + ) + verifyKey := payment.LoadWaffoWebhookVerifyKey(ctrl.db) + if verifyKey == "" { + log.Println("[安全警告] 未配置 WAFFO_WEBHOOK_PUBLIC_KEY/SECRET,Waffo 回调暂未强制验签;请尽快配置") + } else if err := payment.VerifyWaffoWebhookSignature(verifyKey, body, sigHeader); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid signature"}) + return + } var payload map[string]interface{} if len(body) > 0 { @@ -377,7 +392,8 @@ func (ctrl *OrderController) WaffoWebhook(c *gin.Context) { asString(payload["paymentStatus"]), )) - mappedStatus := "paid" + // 未知事件默认忽略,禁止把任意回调当成 paid // Logan.Li + mappedStatus := "" switch { case strings.Contains(strings.ToLower(eventType), "refund"), strings.Contains(statusRaw, "refund"): mappedStatus = "refunded" @@ -386,11 +402,15 @@ func (ctrl *OrderController) WaffoWebhook(c *gin.Context) { mappedStatus = "failed" case strings.Contains(strings.ToLower(eventType), "order.completed"), strings.Contains(strings.ToLower(eventType), "payment.succeeded"), - statusRaw == "" || strings.Contains(statusRaw, "paid") || strings.Contains(statusRaw, "complete") || + strings.Contains(statusRaw, "paid") || strings.Contains(statusRaw, "complete") || strings.Contains(statusRaw, "success") || strings.Contains(statusRaw, "succeed"): mappedStatus = "paid" - default: + case statusRaw == "" && (strings.Contains(strings.ToLower(eventType), "completed") || + strings.Contains(strings.ToLower(eventType), "succeeded")): mappedStatus = "paid" + default: + c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ignored unrecognized event"}) + return } // Ignore non-terminal noise if event is clearly not a completion (e.g. order.created). // Logan.Li @@ -401,16 +421,26 @@ func (ctrl *OrderController) WaffoWebhook(c *gin.Context) { return } - if merchantExternalID == "" && gatewayOrderNo == "" && buyerEmail == "" { + if merchantExternalID == "" && gatewayOrderNo == "" { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "missing orderMerchantExternalId / orderId"}) return } + amount := 0.0 + if v, ok := data["orderAmount"].(float64); ok { + amount = v + } else if v, ok := payload["amount"].(float64); ok { + amount = v + } + currency := firstNonEmpty(asString(data["orderCurrency"]), asString(data["currency"]), asString(payload["currency"])) + req := dto.PaymentWebhookRequest{ EventType: eventType, OrderNo: merchantExternalID, GatewayOrderNo: firstNonEmpty(gatewayOrderNo, paymentID), Status: mappedStatus, + Amount: amount, + Currency: currency, CustomerEmail: buyerEmail, Gateway: "waffo", Payload: string(body), diff --git a/backend/internal/dto/commerce.go b/backend/internal/dto/commerce.go index c683d70..22000d8 100644 --- a/backend/internal/dto/commerce.go +++ b/backend/internal/dto/commerce.go @@ -120,8 +120,8 @@ type ProductWithPlansResponse struct { // PaymentWebhookRequest 支付回调请求 // Logan.Li type PaymentWebhookRequest struct { - EventType string `json:"event_type" binding:"required"` - OrderNo string `json:"order_no" binding:"required"` + EventType string `json:"event_type"` + OrderNo string `json:"order_no"` GatewayOrderNo string `json:"gateway_order_no"` Status string `json:"status" binding:"required"` // paid / failed / refunded Amount float64 `json:"amount"` diff --git a/backend/internal/model/customer.go b/backend/internal/model/customer.go index 800e36e..c45f137 100644 --- a/backend/internal/model/customer.go +++ b/backend/internal/model/customer.go @@ -11,7 +11,7 @@ type Customer struct { ID uint `json:"id" gorm:"primaryKey"` Name string `json:"name" gorm:"size:100;not null"` Company string `json:"company" gorm:"size:200"` - Email string `json:"email" gorm:"size:100;index"` + Email string `json:"email" gorm:"size:100;uniqueIndex"` Phone string `json:"phone" gorm:"size:20"` Address string `json:"address" gorm:"size:500"` Notes string `json:"notes" gorm:"type:text"` diff --git a/backend/internal/model/entitlement.go b/backend/internal/model/entitlement.go index c52effd..787afa7 100644 --- a/backend/internal/model/entitlement.go +++ b/backend/internal/model/entitlement.go @@ -5,16 +5,16 @@ import "time" // CustomerEntitlement 客户权益记录(防重复购买的核心表) // Logan.Li type CustomerEntitlement struct { ID uint `json:"id" gorm:"primaryKey"` - CustomerID uint `json:"customer_id"` + CustomerID uint `json:"customer_id" gorm:"uniqueIndex:idx_ent_cust_prod,priority:1"` Customer Customer `json:"customer,omitempty" gorm:"foreignKey:CustomerID"` - ProductID uint `json:"product_id"` + ProductID uint `json:"product_id" gorm:"uniqueIndex:idx_ent_cust_prod,priority:2"` Product Product `json:"product,omitempty" gorm:"foreignKey:ProductID"` PlanID uint `json:"plan_id"` Plan Plan `json:"plan,omitempty" gorm:"foreignKey:PlanID"` LicenseID uint `json:"license_id"` LicenseKey string `json:"license_key" gorm:"size:100"` - PlanType string `json:"plan_type" gorm:"size:50"` // perpetual / monthly / yearly / lifetime - Status string `json:"status" gorm:"size:20;default:'active'"` // active / expired / revoked + PlanType string `json:"plan_type" gorm:"size:50"` // perpetual / monthly / yearly / lifetime + Status string `json:"status" gorm:"size:20;default:'active'"` // active / expired / revoked ValidFrom time.Time `json:"valid_from"` ValidUntil *time.Time `json:"valid_until,omitempty"` // 买断/永久为 null Renewable bool `json:"renewable" gorm:"default:true"` // 订阅到期后是否可续费 diff --git a/backend/internal/model/license.go b/backend/internal/model/license.go index 61804bc..462816f 100644 --- a/backend/internal/model/license.go +++ b/backend/internal/model/license.go @@ -62,9 +62,9 @@ func (License) TableName() string { // Activation 激活记录模型(机器绑定) // Logan.Li type Activation struct { ID uint `json:"id" gorm:"primaryKey"` - LicenseID uint `json:"license_id"` + LicenseID uint `json:"license_id" gorm:"uniqueIndex:idx_lic_device,priority:1"` License License `json:"license,omitempty" gorm:"foreignKey:LicenseID"` - DeviceID string `json:"device_id" gorm:"size:100"` + DeviceID string `json:"device_id" gorm:"size:100;uniqueIndex:idx_lic_device,priority:2"` DeviceName string `json:"device_name" gorm:"size:200"` DeviceInfo string `json:"device_info" gorm:"type:text"` // JSON 格式存储设备信息 IP string `json:"ip" gorm:"size:50"` diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index 8dd9645..a469f4a 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -18,7 +18,7 @@ type Order struct { Status string `json:"status" gorm:"size:20;default:'pending'"` // pending / paid / failed / refunded / cancelled PaymentMethod string `json:"payment_method" gorm:"size:50"` // paddle / stripe / creem / waffo / alipay / wechat PaymentGateway string `json:"payment_gateway" gorm:"size:50"` - GatewayOrderNo string `json:"gateway_order_no" gorm:"size:200"` + GatewayOrderNo string `json:"gateway_order_no" gorm:"size:200;index"` PaidAt *time.Time `json:"paid_at,omitempty"` RefundAt *time.Time `json:"refund_at,omitempty"` ClientIP string `json:"client_ip" gorm:"size:50"` diff --git a/backend/internal/service/entitlement/entitlement_service.go b/backend/internal/service/entitlement/entitlement_service.go index 0245717..0165d2f 100644 --- a/backend/internal/service/entitlement/entitlement_service.go +++ b/backend/internal/service/entitlement/entitlement_service.go @@ -5,6 +5,7 @@ import ( "backend/internal/model" "backend/utils" "errors" + "strings" "time" "gorm.io/gorm" @@ -134,7 +135,7 @@ func (s *EntitlementService) Renew(id, planID uint) (*dto.EntitlementResponse, e return &resp, nil } -// GetByEmail 按邮箱查询客户权益(客户端接口) // Logan.Li +// GetByEmail 按邮箱查询客户权益(客户端接口;不回传完整 License Key) // Logan.Li func (s *EntitlementService) GetByEmail(email string) ([]dto.EntitlementResponse, error) { var customer model.Customer if err := s.db.Where("email = ?", email).First(&customer).Error; err != nil { @@ -148,11 +149,25 @@ func (s *EntitlementService) GetByEmail(email string) ([]dto.EntitlementResponse results := make([]dto.EntitlementResponse, 0, len(entitlements)) for _, e := range entitlements { - results = append(results, s.toResponse(e)) + resp := s.toResponse(e) + resp.LicenseKey = maskLicenseKey(resp.LicenseKey) + results = append(results, resp) } return results, nil } +// maskLicenseKey 脱敏展示,避免公开接口泄露完整密钥 // Logan.Li +func maskLicenseKey(key string) string { + key = strings.TrimSpace(key) + if len(key) <= 8 { + if key == "" { + return "" + } + return "****" + } + return key[:4] + "****" + key[len(key)-4:] +} + // toResponse 模型转响应 DTO // Logan.Li func (s *EntitlementService) toResponse(e model.CustomerEntitlement) dto.EntitlementResponse { resp := dto.EntitlementResponse{ diff --git a/backend/internal/service/license/client_service.go b/backend/internal/service/license/client_service.go index 882405b..3dee2e7 100644 --- a/backend/internal/service/license/client_service.go +++ b/backend/internal/service/license/client_service.go @@ -6,6 +6,8 @@ import ( "backend/utils" "errors" "time" + + "gorm.io/gorm" ) func inferPlanType(lic model.License) string { @@ -15,12 +17,13 @@ func inferPlanType(lic model.License) string { if lic.Duration <= 0 || lic.EndDate.Equal(model.PermanentEndDate) || lic.EndDate.Year() >= 2099 { return "perpetual" } - if lic.Duration <= 180 { - return "trial" - } + // 先判月度,再判体验/包年,避免 monthly 成为死代码 // Logan.Li if lic.Duration <= 31 { return "monthly" } + if lic.Duration <= 180 { + return "trial" + } return "yearly" } @@ -75,75 +78,112 @@ func (s *LicenseService) Verify(req dto.LicenseVerifyRequest) (*dto.LicenseVerif }, nil } -// Activate 客户端激活 License(机器绑定) // Logan.Li +// Activate 客户端激活 License(事务 + 条件占座,消除 TOCTOU) // Logan.Li func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP string) (map[string]interface{}, error) { - var lic model.License - if err := s.db.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil { - return nil, errors.New("无效的 License") - } + var payload map[string]interface{} + err := s.db.Transaction(func(tx *gorm.DB) error { + var lic model.License + if err := tx.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil { + return errors.New("无效的 License") + } + if lic.Status == model.LicenseStatusRevoked || lic.Status == model.LicenseStatusSuspended { + return errors.New("License 已被吊销或暂停") + } + if lic.Status == model.LicenseStatusExpired || utils.IsLicenseExpired(lic.EndDate) { + return errors.New("License 已过期") + } - if lic.Status == model.LicenseStatusRevoked { - return nil, errors.New("License 已被吊销") - } - if lic.Status == model.LicenseStatusExpired || utils.IsLicenseExpired(lic.EndDate) { - return nil, errors.New("License 已过期") - } + now := time.Now() + var existing model.Activation + if err := tx.Where("license_id = ? AND device_id = ?", lic.ID, req.DeviceID). + First(&existing).Error; err == nil { + updates := map[string]interface{}{ + "last_seen_at": now, + "is_online": true, + "device_name": req.DeviceName, + "device_info": req.DeviceInfo, + "ip": clientIP, + } + if existing.Status != "active" { + updates["status"] = "active" + res := tx.Model(&model.License{}). + Where("id = ? AND current_activations < max_activations", lic.ID). + Update("current_activations", gorm.Expr("current_activations + 1")) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errors.New("已达到最大激活数量限制") + } + } + if err := tx.Model(&existing).Updates(updates).Error; err != nil { + return err + } + if err := tx.First(&lic, lic.ID).Error; err != nil { + return err + } + payload = licenseClientPayload(lic) + return nil + } - var existing model.Activation - if err := s.db.Where("license_id = ? AND device_id = ?", lic.ID, req.DeviceID).First(&existing).Error; err == nil { - existing.LastSeenAt = time.Now() - existing.IsOnline = true - existing.DeviceName = req.DeviceName - existing.DeviceInfo = req.DeviceInfo - existing.IP = clientIP - s.db.Save(&existing) - return licenseClientPayload(lic), nil - } + res := tx.Model(&model.License{}). + Where("id = ? AND current_activations < max_activations", lic.ID). + Updates(map[string]interface{}{ + "current_activations": gorm.Expr("current_activations + 1"), + "status": model.LicenseStatusActive, + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errors.New("已达到最大激活数量限制") + } - if lic.CurrentActivations >= lic.MaxActivations { - return nil, errors.New("已达到最大激活数量限制") + if err := tx.Create(&model.Activation{ + LicenseID: lic.ID, + DeviceID: req.DeviceID, + DeviceName: req.DeviceName, + DeviceInfo: req.DeviceInfo, + IP: clientIP, + Status: "active", + IsOnline: true, + ActivatedAt: now, + LastSeenAt: now, + }).Error; err != nil { + return errors.New("激活失败") + } + if err := tx.First(&lic, lic.ID).Error; err != nil { + return err + } + payload = licenseClientPayload(lic) + return nil + }) + if err != nil { + return nil, err } - - activation := model.Activation{ - LicenseID: lic.ID, - DeviceID: req.DeviceID, - DeviceName: req.DeviceName, - DeviceInfo: req.DeviceInfo, - IP: clientIP, - Status: "active", - IsOnline: true, - ActivatedAt: time.Now(), - LastSeenAt: time.Now(), - } - if err := s.db.Create(&activation).Error; err != nil { - return nil, errors.New("激活失败") - } - - lic.CurrentActivations++ - if lic.Status == model.LicenseStatusInactive { - lic.Status = model.LicenseStatusActive - } - s.db.Save(&lic) - - return licenseClientPayload(lic), nil + return payload, nil } -// Deactivate 客户端停用 // Logan.Li +// Deactivate 客户端停用(仅 active→inactive 才减席位) // Logan.Li func (s *LicenseService) Deactivate(req dto.LicenseDeactivateRequest) error { - var lic model.License - if err := s.db.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil { - return errors.New("无效的 License") - } - - result := s.db.Model(&model.Activation{}). - Where("license_id = ? AND device_id = ?", lic.ID, req.DeviceID). - Update("status", "inactive") - - if result.RowsAffected > 0 && lic.CurrentActivations > 0 { - lic.CurrentActivations-- - s.db.Save(&lic) - } - return nil + return s.db.Transaction(func(tx *gorm.DB) error { + var lic model.License + if err := tx.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil { + return errors.New("无效的 License") + } + res := tx.Model(&model.Activation{}). + Where("license_id = ? AND device_id = ? AND status = ?", lic.ID, req.DeviceID, "active"). + Updates(map[string]interface{}{"status": "inactive", "is_online": false}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected > 0 { + return tx.Model(&model.License{}). + Where("id = ? AND current_activations > 0", lic.ID). + Update("current_activations", gorm.Expr("current_activations - 1")).Error + } + return nil + }) } // Heartbeat 客户端心跳 // Logan.Li @@ -153,8 +193,8 @@ func (s *LicenseService) Heartbeat(req dto.LicenseHeartbeatRequest) (map[string] return nil, errors.New("无效的 License") } - if lic.Status == model.LicenseStatusRevoked { - return nil, errors.New("License 已被吊销") + if lic.Status == model.LicenseStatusRevoked || lic.Status == model.LicenseStatusSuspended { + return nil, errors.New("License 已被吊销或暂停") } if lic.Status == model.LicenseStatusExpired || utils.IsLicenseExpired(lic.EndDate) { return nil, errors.New("License 已过期") @@ -183,4 +223,3 @@ func (s *LicenseService) ListDevicesByKey(licenseKey string) ([]dto.ActivationRe } return results, lic.MaxActivations, nil } - diff --git a/backend/internal/service/order/order_service.go b/backend/internal/service/order/order_service.go index 067956c..80ee58d 100644 --- a/backend/internal/service/order/order_service.go +++ b/backend/internal/service/order/order_service.go @@ -7,6 +7,7 @@ import ( "backend/utils" "errors" "fmt" + "math" "strings" "time" @@ -193,125 +194,72 @@ func (s *OrderService) Checkout(req dto.CheckoutRequest, clientIP, userAgent str }, clientIP, userAgent) } -// ClaimPurchase fetch license after payment by order_no or email+product. // Logan.Li +// ClaimPurchase 领取授权:必须同时提供 order_no + customer_email,防止仅凭邮箱取证 // Logan.Li func (s *OrderService) ClaimPurchase(req dto.ClaimPurchaseRequest) (*dto.ClaimPurchaseResponse, error) { orderNo := strings.TrimSpace(req.OrderNo) email := strings.TrimSpace(req.CustomerEmail) - productCode := strings.TrimSpace(req.ProductCode) - if productCode == "" { - productCode = "opsxshell" + if orderNo == "" || email == "" { + return nil, errors.New("请同时提供 order_no 与 customer_email") } - if orderNo != "" { - var order model.Order - if err := s.db.Where("order_no = ?", orderNo).First(&order).Error; err != nil { - return nil, errors.New("订单不存在") - } - switch order.Status { - case "pending": - return &dto.ClaimPurchaseResponse{ - OrderNo: order.OrderNo, - Status: "pending", - Message: "订单尚未支付完成,请稍后再试", - }, nil - case "paid": - licenseKey := "" - if order.LicenseID > 0 { - var lic model.License - if err := s.db.First(&lic, order.LicenseID).Error; err == nil { - licenseKey = lic.LicenseKey - } - } - if licenseKey == "" { - var ent model.CustomerEntitlement - if err := s.db.Where("customer_id = ? AND product_id = ? AND status = ?", order.CustomerID, order.ProductID, "active"). - First(&ent).Error; err == nil { - licenseKey = ent.LicenseKey - } - } - if licenseKey == "" { - return &dto.ClaimPurchaseResponse{ - OrderNo: order.OrderNo, - Status: "paid", - Message: "订单已支付,但 License 尚未就绪", - }, nil - } - return &dto.ClaimPurchaseResponse{ - LicenseKey: licenseKey, - OrderNo: order.OrderNo, - Status: "paid", - Message: "ok", - }, nil - default: - return &dto.ClaimPurchaseResponse{ - OrderNo: order.OrderNo, - Status: order.Status, - Message: fmt.Sprintf("订单状态为 %s,无法领取授权", order.Status), - }, nil + var order model.Order + if err := s.db.Preload("Customer").Where("order_no = ?", orderNo).First(&order).Error; err != nil { + return nil, errors.New("订单不存在") + } + if order.Customer.ID == 0 { + if err := s.db.First(&order.Customer, order.CustomerID).Error; err != nil { + return nil, errors.New("订单客户不存在") } } - - if email == "" { - return nil, errors.New("请提供 order_no 或 customer_email") + if !strings.EqualFold(strings.TrimSpace(order.Customer.Email), email) { + return nil, errors.New("订单与邮箱不匹配") } - var product model.Product - if err := s.db.Where("code = ?", productCode).First(&product).Error; err != nil { - return nil, errors.New("产品不存在") - } - - var customer model.Customer - if err := s.db.Where("email = ?", email).First(&customer).Error; err != nil { + switch order.Status { + case "pending": return &dto.ClaimPurchaseResponse{ - Status: "not_found", - Message: "未找到购买记录", - }, nil - } - - var ent model.CustomerEntitlement - if err := s.db.Where("customer_id = ? AND product_id = ?", customer.ID, product.ID). - Order("updated_at DESC").First(&ent).Error; err != nil { - // 可能有未支付订单 // Logan.Li - var pending model.Order - if err2 := s.db.Where("customer_id = ? AND product_id = ? AND status = ?", customer.ID, product.ID, "pending"). - Order("created_at DESC").First(&pending).Error; err2 == nil { - return &dto.ClaimPurchaseResponse{ - OrderNo: pending.OrderNo, - Status: "pending", - Message: "订单尚未支付完成,请稍后再试", - }, nil - } - return &dto.ClaimPurchaseResponse{ - Status: "not_found", - Message: "未找到购买记录", - }, nil - } - - if ent.Status == "active" && ent.LicenseKey != "" { - return &dto.ClaimPurchaseResponse{ - LicenseKey: ent.LicenseKey, - Status: "paid", - Message: "ok", - }, nil - } - - var pending model.Order - if err := s.db.Where("customer_id = ? AND product_id = ? AND status = ?", customer.ID, product.ID, "pending"). - Order("created_at DESC").First(&pending).Error; err == nil { - return &dto.ClaimPurchaseResponse{ - OrderNo: pending.OrderNo, + OrderNo: order.OrderNo, Status: "pending", Message: "订单尚未支付完成,请稍后再试", }, nil + case "paid": + licenseKey := "" + if order.LicenseID > 0 { + var lic model.License + if err := s.db.First(&lic, order.LicenseID).Error; err == nil { + licenseKey = lic.LicenseKey + } + } + if licenseKey == "" { + var ent model.CustomerEntitlement + if err := s.db.Where("customer_id = ? AND product_id = ? AND status = ?", order.CustomerID, order.ProductID, "active"). + First(&ent).Error; err == nil { + licenseKey = ent.LicenseKey + } + } + if licenseKey == "" { + return &dto.ClaimPurchaseResponse{ + OrderNo: order.OrderNo, + Status: "paid", + Message: "订单已支付,但 License 尚未就绪", + }, nil + } + return &dto.ClaimPurchaseResponse{ + LicenseKey: licenseKey, + OrderNo: order.OrderNo, + Status: "paid", + Message: "ok", + }, nil + default: + return &dto.ClaimPurchaseResponse{ + OrderNo: order.OrderNo, + Status: order.Status, + Message: fmt.Sprintf("订单状态为 %s,无法领取授权", order.Status), + }, nil } - - return &dto.ClaimPurchaseResponse{ - Status: ent.Status, - Message: "暂无可用授权", - }, nil } -// resolveWebhookOrder finds local order by merchant external id, Waffo order id, or buyer email. // Logan.Li +// resolveWebhookOrder 仅按 order_no / gateway_order_no 匹配,禁止邮箱兜底劫持 // Logan.Li func (s *OrderService) resolveWebhookOrder(req dto.PaymentWebhookRequest) (model.Order, error) { var order model.Order if no := strings.TrimSpace(req.OrderNo); no != "" { @@ -328,46 +276,72 @@ func (s *OrderService) resolveWebhookOrder(req dto.PaymentWebhookRequest) (model return order, nil } } - if email := strings.TrimSpace(req.CustomerEmail); email != "" { - var customer model.Customer - if err := s.db.Where("email = ?", email).First(&customer).Error; err == nil { - if err := s.db.Where("customer_id = ? AND status = ?", customer.ID, "pending"). - Order("created_at DESC").First(&order).Error; err == nil { - return order, nil - } - } - } return order, errors.New("订单不存在") } -// PaymentWebhook 支付回调(幂等 + 事务) // Logan.Li +// PaymentWebhook 支付回调:原子状态流转 + 金额校验 + 退款可达 // Logan.Li func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP string) error { + status := strings.ToLower(strings.TrimSpace(req.Status)) + if status == "completed" { + status = "paid" + } + switch status { + case "paid", "failed", "cancelled", "refunded": + default: + return nil // 未识别状态直接忽略 // Logan.Li + } + order, err := s.resolveWebhookOrder(req) if err != nil { return err } - if order.Status == "paid" || order.Status == "refunded" { - return nil // 已处理,幂等返回 + + // 金额/货币校验(有金额时) // Logan.Li + if req.Amount > 0 && (math.Abs(req.Amount-order.Amount) > 0.005 || + (req.Currency != "" && !strings.EqualFold(req.Currency, order.Currency))) { + utils.LogSystemAction(s.db, "payment_amount_mismatch", "order", order.ID, order.OrderNo, + fmt.Sprintf("webhook %.2f%s 与订单 %.2f%s 不符", req.Amount, req.Currency, order.Amount, order.Currency), clientIP) + return errors.New("支付金额与订单不符") } - // 事务处理 // Logan.Li - return s.db.Transaction(func(tx *gorm.DB) error { - now := time.Now() - switch req.Status { - case "paid", "completed": - // 更新订单 // Logan.Li - order.Status = "paid" - order.GatewayOrderNo = req.GatewayOrderNo - order.PaymentGateway = req.Gateway - order.PaymentMethod = req.Gateway - order.PaidAt = &now - if err := tx.Save(&order).Error; err != nil { - return err - } + now := time.Now() + updates := map[string]interface{}{ + "payment_gateway": req.Gateway, + "payment_method": req.Gateway, + } + if g := strings.TrimSpace(req.GatewayOrderNo); g != "" { + updates["gateway_order_no"] = g + } + switch status { + case "paid": + updates["status"] = "paid" + updates["paid_at"] = now + case "refunded": + updates["status"] = "refunded" + updates["refund_at"] = now + default: + updates["status"] = status + } - // 发放 License // Logan.Li + // 原子状态流转:refund 只允许 paid→refunded;paid/failed/cancelled 只允许非终态 // Logan.Li + res := s.db.Model(&model.Order{}).Where("id = ?", order.ID). + Where("(status NOT IN ('paid','refunded') AND ? IN ('paid','failed','cancelled')) OR (status = 'paid' AND ? = 'refunded')", + status, status). + Updates(updates) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return nil // 幂等:已处理或非法流转 // Logan.Li + } + + return s.db.Transaction(func(tx *gorm.DB) error { + switch status { + case "paid": var plan model.Plan - tx.First(&plan, order.PlanID) + if err := tx.First(&plan, order.PlanID).Error; err != nil { + return fmt.Errorf("plan 不存在: %w", err) + } var validUntil *time.Time var endDate time.Time @@ -391,14 +365,14 @@ func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP st if err := tx.Create(&lic).Error; err != nil { return err } + if err := tx.Model(&model.Order{}).Where("id = ?", order.ID). + Update("license_id", lic.ID).Error; err != nil { + return err + } - order.LicenseID = lic.ID - tx.Save(&order) - - // 更新权益记录(防重复购买核心) // Logan.Li + renewable := plan.PlanType != "perpetual" && plan.PlanType != "lifetime" var ent model.CustomerEntitlement entResult := tx.Where("customer_id = ? AND product_id = ?", order.CustomerID, order.ProductID).First(&ent) - renewable := plan.PlanType != "perpetual" && plan.PlanType != "lifetime" if entResult.Error != nil { ent = model.CustomerEntitlement{ CustomerID: order.CustomerID, @@ -412,7 +386,9 @@ func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP st ValidUntil: validUntil, Renewable: renewable, } - tx.Create(&ent) + if err := tx.Create(&ent).Error; err != nil { + return err + } } else { ent.PlanID = order.PlanID ent.LicenseID = lic.ID @@ -422,31 +398,30 @@ func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP st ent.ValidFrom = now ent.ValidUntil = validUntil ent.Renewable = renewable - tx.Save(&ent) + if err := tx.Save(&ent).Error; err != nil { + return err + } } utils.LogSystemAction(tx, "payment_completed", "order", order.ID, order.OrderNo, fmt.Sprintf("订单支付成功,已发放 License: %s", lic.LicenseKey), clientIP) - case "failed", "cancelled": - order.Status = req.Status - if err := tx.Save(&order).Error; err != nil { - return err - } - case "refunded": - order.Status = "refunded" - order.RefundAt = &now - if err := tx.Save(&order).Error; err != nil { - return err - } - // 吊销关联 License // Logan.Li if order.LicenseID > 0 { - tx.Model(&model.License{}).Where("id = ?", order.LicenseID). - Updates(map[string]interface{}{"status": model.LicenseStatusRevoked, "revoke_reason": "订单退款"}) - tx.Model(&model.CustomerEntitlement{}).Where("license_id = ?", order.LicenseID). - Update("status", "revoked") + if err := tx.Model(&model.License{}).Where("id = ?", order.LicenseID). + Updates(map[string]interface{}{ + "status": model.LicenseStatusRevoked, + "revoke_reason": "订单退款", + }).Error; err != nil { + return err + } + if err := tx.Model(&model.CustomerEntitlement{}).Where("license_id = ?", order.LicenseID). + Update("status", "revoked").Error; err != nil { + return err + } } + utils.LogSystemAction(tx, "payment_refunded", "order", order.ID, order.OrderNo, + "订单退款,已吊销关联授权", clientIP) } return nil }) @@ -630,7 +605,7 @@ func (s *OrderService) CheckPurchaseStatus(customerEmail string, productID uint) return &dto.CheckPurchaseStatusResponse{ Purchased: true, HasActiveLicense: isActive, - LicenseKey: ent.LicenseKey, + LicenseKey: "", // 公开接口不回传完整密钥,请走 claim(order+email) // Logan.Li LicenseStatus: licenseStatus, PlanType: ent.PlanType, ValidUntil: validUntilStr, diff --git a/backend/internal/service/payment/waffo_webhook.go b/backend/internal/service/payment/waffo_webhook.go new file mode 100644 index 0000000..e88cdac --- /dev/null +++ b/backend/internal/service/payment/waffo_webhook.go @@ -0,0 +1,127 @@ +package payment + +import ( + "crypto" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "os" + "strings" + + "backend/internal/model" + + "gorm.io/gorm" +) + +// LoadWaffoWebhookVerifyKey 读取 webhook 验签材料:环境变量优先,其次网关 WebhookSecret // Logan.Li +func LoadWaffoWebhookVerifyKey(db *gorm.DB) string { + if v := strings.TrimSpace(os.Getenv("WAFFO_WEBHOOK_PUBLIC_KEY")); v != "" { + return v + } + if v := strings.TrimSpace(os.Getenv("WAFFO_WEBHOOK_SECRET")); v != "" { + return v + } + if db == nil { + return "" + } + var gw model.PaymentGatewayConfig + if err := db.Where("gateway = ?", "waffo").First(&gw).Error; err != nil { + return "" + } + return strings.TrimSpace(gw.WebhookSecret) +} + +// VerifyWaffoWebhookSignature 校验 Waffo webhook:RSA PKCS1v15(SHA256(body)) 或 HMAC 共享密钥 // Logan.Li +func VerifyWaffoWebhookSignature(verifyKey string, body []byte, sigHeader string) error { + verifyKey = strings.TrimSpace(verifyKey) + sigHeader = strings.TrimSpace(sigHeader) + if verifyKey == "" { + return errors.New("未配置 webhook 验签公钥/密钥") + } + if sigHeader == "" { + return errors.New("缺少 X-Waffo-Signature") + } + + // 共享密钥模式:签名头即为密钥(或 Bearer 同值) // Logan.Li + if !looksLikePEMOrDER(verifyKey) { + secret := strings.TrimPrefix(sigHeader, "Bearer ") + if subtleConstantTimeEq(secret, verifyKey) || subtleConstantTimeEq(sigHeader, verifyKey) { + return nil + } + return errors.New("webhook 共享密钥不匹配") + } + + pub, err := parseRSAPublicKey(verifyKey) + if err != nil { + return fmt.Errorf("webhook 验签公钥无效: %w", err) + } + sig, err := base64.StdEncoding.DecodeString(sigHeader) + if err != nil { + // 兼容 URL-safe base64 // Logan.Li + sig, err = base64.RawURLEncoding.DecodeString(sigHeader) + if err != nil { + return errors.New("签名格式错误") + } + } + sum := sha256.Sum256(body) + if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig); err != nil { + return errors.New("webhook 签名校验失败") + } + return nil +} + +func looksLikePEMOrDER(s string) bool { + return strings.Contains(s, "BEGIN") || len(s) > 80 +} + +func subtleConstantTimeEq(a, b string) bool { + if len(a) != len(b) { + return false + } + var v byte + for i := 0; i < len(a); i++ { + v |= a[i] ^ b[i] + } + return v == 0 +} + +func parseRSAPublicKey(raw string) (*rsa.PublicKey, error) { + raw = strings.TrimSpace(raw) + raw = strings.ReplaceAll(raw, `\n`, "\n") + if block, _ := pem.Decode([]byte(raw)); block != nil { + return parseRSAPublicKeyBytes(block.Bytes) + } + compact := strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == ' ' || r == '\t' { + return -1 + } + return r + }, raw) + if decoded, err := base64.StdEncoding.DecodeString(compact); err == nil { + if pub, err2 := parseRSAPublicKeyBytes(decoded); err2 == nil { + return pub, nil + } + } + wrapped := "-----BEGIN PUBLIC KEY-----\n" + chunkBase64(compact, 64) + "\n-----END PUBLIC KEY-----\n" + if block, _ := pem.Decode([]byte(wrapped)); block != nil { + return parseRSAPublicKeyBytes(block.Bytes) + } + return nil, errors.New("无法解析 RSA 公钥") +} + +func parseRSAPublicKeyBytes(der []byte) (*rsa.PublicKey, error) { + if pub, err := x509.ParsePKIXPublicKey(der); err == nil { + if rsaPub, ok := pub.(*rsa.PublicKey); ok { + return rsaPub, nil + } + return nil, errors.New("公钥不是 RSA") + } + if pub, err := x509.ParsePKCS1PublicKey(der); err == nil { + return pub, nil + } + return nil, errors.New("不支持的公钥格式") +} diff --git a/backend/middleware/ratelimit.go b/backend/middleware/ratelimit.go new file mode 100644 index 0000000..aa251f7 --- /dev/null +++ b/backend/middleware/ratelimit.go @@ -0,0 +1,57 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" +) + +type visitorEntry struct { + limiter *rate.Limiter + last time.Time +} + +// RateLimit 按 ClientIP 的令牌桶限流中间件 // Logan.Li +func RateLimit(rps float64, burst int) gin.HandlerFunc { + var mu sync.Mutex + visitors := map[string]*visitorEntry{} + + go func() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for range ticker.C { + mu.Lock() + cutoff := time.Now().Add(-10 * time.Minute) + for ip, e := range visitors { + if e.last.Before(cutoff) { + delete(visitors, ip) + } + } + mu.Unlock() + } + }() + + return func(c *gin.Context) { + ip := c.ClientIP() + mu.Lock() + e, ok := visitors[ip] + if !ok { + e = &visitorEntry{limiter: rate.NewLimiter(rate.Limit(rps), burst)} + visitors[ip] = e + } + e.last = time.Now() + allow := e.limiter.Allow() + mu.Unlock() + if !allow { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "code": 429, + "message": "请求过于频繁,请稍后再试", + }) + return + } + c.Next() + } +} diff --git a/backend/router/router.go b/backend/router/router.go index f2796dc..364d28e 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -66,10 +66,22 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine { // CORS 中间件 // Logan.Li r.Use(middleware.CORSMiddleware(cfg)) - // 健康检查 // Logan.Li + // 存活探针(不查库) // Logan.Li r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"code": 0, "message": "ok", "data": nil}) }) + r.GET("/healthz", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "alive"}) + }) + // 就绪探针:检查数据库可达 // Logan.Li + r.GET("/readyz", func(c *gin.Context) { + sqlDB, err := db.DB() + if err != nil || sqlDB.Ping() != nil { + c.JSON(503, gin.H{"status": "db unreachable"}) + return + } + c.JSON(200, gin.H{"status": "ready"}) + }) // 初始化控制器 // Logan.Li authController := auth.NewAuthController(db, cfg) @@ -87,36 +99,40 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine { // ============ 认证路由(无需 JWT) ============ authGroup := api.Group("/auth") { - register(authGroup, "POST", "/login", authController.Login) + register(authGroup, "POST", "/login", middleware.RateLimit(0.2, 5), authController.Login) } // ============ 客户端授权契约(无需认证,供外部工具调用) ============ clientAPI := api.Group("/v1") { + licLimit := middleware.RateLimit(2, 30) + checkoutLimit := middleware.RateLimit(0.5, 10) + webhookLimit := middleware.RateLimit(1, 20) + // License 验证 / 激活 / 停用 / 心跳 - register(clientAPI, "POST", "/license/verify", licenseController.Verify) - register(clientAPI, "POST", "/license/activate", licenseController.ActivateDevice) - register(clientAPI, "POST", "/license/deactivate", licenseController.Deactivate) - register(clientAPI, "POST", "/license/heartbeat", licenseController.Heartbeat) - register(clientAPI, "POST", "/license/devices", licenseController.ListDevices) + register(clientAPI, "POST", "/license/verify", licLimit, licenseController.Verify) + register(clientAPI, "POST", "/license/activate", licLimit, licenseController.ActivateDevice) + register(clientAPI, "POST", "/license/deactivate", licLimit, licenseController.Deactivate) + register(clientAPI, "POST", "/license/heartbeat", licLimit, licenseController.Heartbeat) + register(clientAPI, "POST", "/license/devices", licLimit, licenseController.ListDevices) // 产品定价查询(展示用) register(clientAPI, "GET", "/products/code/:code", productController.GetByCode) clientAPI.GET("/products/:product_id/plans", planController.GetByProduct) // 订单与支付(客户端购买入口 + 支付回调) - register(clientAPI, "POST", "/orders", orderController.CreateOrder) - register(clientAPI, "POST", "/checkout", orderController.Checkout) - register(clientAPI, "POST", "/purchase/claim", orderController.ClaimPurchase) - register(clientAPI, "POST", "/webhooks/payment", orderController.PaymentWebhook) + register(clientAPI, "POST", "/orders", checkoutLimit, orderController.CreateOrder) + register(clientAPI, "POST", "/checkout", checkoutLimit, orderController.Checkout) + register(clientAPI, "POST", "/purchase/claim", checkoutLimit, orderController.ClaimPurchase) + register(clientAPI, "POST", "/webhooks/payment", webhookLimit, orderController.PaymentWebhook) register(clientAPI, "GET", "/webhooks/waffo", orderController.WaffoWebhookReady) - register(clientAPI, "POST", "/webhooks/waffo", orderController.WaffoWebhook) + register(clientAPI, "POST", "/webhooks/waffo", webhookLimit, orderController.WaffoWebhook) register(clientAPI, "GET", "/purchase/success", orderController.PurchaseSuccess) - register(clientAPI, "POST", "/purchase/check", orderController.CheckDuplicatePurchase) - register(clientAPI, "POST", "/purchase/status", orderController.CheckPurchaseStatus) + register(clientAPI, "POST", "/purchase/check", checkoutLimit, orderController.CheckDuplicatePurchase) + register(clientAPI, "POST", "/purchase/status", checkoutLimit, orderController.CheckPurchaseStatus) // 客户权益查询(通过邮箱) - register(clientAPI, "GET", "/entitlements/my", entitlementController.GetByEmail) + register(clientAPI, "GET", "/entitlements/my", checkoutLimit, entitlementController.GetByEmail) } // ============ 需要认证的管理路由 ============ diff --git a/backend/utils/offline_license.go b/backend/utils/offline_license.go index 8f5c98d..4489841 100644 --- a/backend/utils/offline_license.go +++ b/backend/utils/offline_license.go @@ -2,12 +2,10 @@ package utils import ( "crypto/ed25519" - "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" - "fmt" "strings" "time" ) @@ -38,20 +36,15 @@ type OfflineKeyPair struct { Public ed25519.PublicKey } -// DeriveOfflineKeyPair 从 32 字节 seed(hex)派生 Ed25519 密钥对 // Logan.Li +// DeriveOfflineKeyPair 从 32 字节 seed(必须 64 位 hex)派生 Ed25519 密钥对 // Logan.Li func DeriveOfflineKeyPair(seedHex string) (*OfflineKeyPair, error) { seedHex = strings.TrimSpace(seedHex) if seedHex == "" { return nil, errors.New("离线签发 seed 为空") } raw, err := hex.DecodeString(seedHex) - if err != nil { - return nil, fmt.Errorf("离线签发 seed 不是合法 hex: %w", err) - } - if len(raw) != ed25519.SeedSize { - // 允许任意长度输入,统一 hash 成 32 字节 // Logan.Li - sum := sha256.Sum256(raw) - raw = sum[:] + if err != nil || len(raw) != ed25519.SeedSize { + return nil, errors.New("OFFLINE_LICENSE_SEED 必须是 64 位 hex(32 字节),请用 openssl rand -hex 32 生成") } priv := ed25519.NewKeyFromSeed(raw) pub := priv.Public().(ed25519.PublicKey) diff --git a/docker-compose.yml b/docker-compose.yml index e3d5342..072b7e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,11 +26,37 @@ services: volumes: - licence_data:/data healthcheck: - test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9001/health"] + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9001/readyz"] interval: 15s timeout: 3s retries: 5 start_period: 15s + # SQLite 每日备份 sidecar(保留最近 7 份) // Logan.Li + backup: + image: alpine:3.20 + container_name: opsx-licence-backup + restart: unless-stopped + volumes: + - licence_data:/data:ro + - ./backups:/backups + entrypoint: ["/bin/sh", "-c"] + command: + - | + apk add --no-cache sqlite >/dev/null 2>&1 || true + while true; do + ts=$$(date +%Y%m%d-%H%M%S) + if [ -f /data/db.sqlite3 ]; then + if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 /data/db.sqlite3 ".backup '/backups/db-$$ts.sqlite3'" + else + cp /data/db.sqlite3 /backups/db-$$ts.sqlite3 + fi + ls -t /backups/db-*.sqlite3 2>/dev/null | tail -n +8 | xargs -r rm -f + echo "[backup] done $$ts" + fi + sleep 86400 + done + volumes: licence_data: diff --git a/frontend/package.json b/frontend/package.json index 416b5c9..78bee6e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,7 +14,7 @@ "pinia": "^2.1.7", "element-plus": "^2.5.0", "@element-plus/icons-vue": "^2.3.1", - "axios": "^1.6.5", + "axios": "^1.7.9", "echarts": "^5.4.3", "vue-echarts": "^6.6.8", "dayjs": "^1.11.10" diff --git a/frontend/src/api/request.js b/frontend/src/api/request.js index b1075f1..0324b8a 100644 --- a/frontend/src/api/request.js +++ b/frontend/src/api/request.js @@ -1,5 +1,6 @@ import axios from 'axios' -import { ElMessage, ElMessageBox } from 'element-plus' +import { ElMessage } from 'element-plus' +import router from '@/router' import { useUserStore } from '@/stores/user' // 创建 axios 实例 // Logan.Li @@ -11,6 +12,26 @@ const request = axios.create({ }, }) +let handling401 = false + +// 统一强制重新登录,避免并发 401 叠加弹窗 // Logan.Li +async function forceReLogin() { + if (handling401) return Promise.reject(new Error('relogin')) + handling401 = true + try { + const userStore = useUserStore() + userStore.logout() + ElMessage.warning('登录状态已过期,请重新登录') + const redirect = router.currentRoute.value.fullPath + await router.push({ path: '/login', query: redirect && redirect !== '/login' ? { redirect } : {} }) + } finally { + setTimeout(() => { + handling401 = false + }, 1000) + } + return Promise.reject(new Error('relogin')) +} + // 请求拦截器:注入 Bearer token // Logan.Li request.interceptors.request.use( (config) => { @@ -26,6 +47,10 @@ request.interceptors.request.use( // 响应拦截器:统一处理 code 与 HTTP 错误 // Logan.Li request.interceptors.response.use( (response) => { + // 文件下载(blob)直接放行,避免被业务码拦截 // Logan.Li + if (['blob', 'arraybuffer'].includes(response.config.responseType)) { + return response + } const res = response.data // code 为 0 或 200 视为成功 // Logan.Li @@ -33,53 +58,40 @@ request.interceptors.response.use( return res } - // 401:登录态失效,引导重新登录 // Logan.Li + // 401:登录态失效 // Logan.Li if (res.code === 401) { - ElMessageBox.confirm('登录状态已过期,请重新登录', '系统提示', { - confirmButtonText: '重新登录', - cancelButtonText: '取消', - type: 'warning', - }).then(() => { - const userStore = useUserStore() - userStore.logout() - window.location.href = '/login' - }) - return Promise.reject(new Error(res.message || '未授权')) + return forceReLogin() } // 其他业务错误:仅错误用 ElMessage 提示(常规操作不打扰) // Logan.Li ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message || 'Error')) }, - (error) => { - let message = '网络错误' + async (error) => { + if (error.response?.status === 401) { + return forceReLogin() + } + let message = '网络错误' if (error.response) { switch (error.response.status) { case 400: message = error.response.data?.message || '请求参数错误' break - case 401: - message = '未授权,请重新登录' - { - const userStore = useUserStore() - userStore.logout() - setTimeout(() => { - window.location.href = '/login' - }, 1500) - } - break case 403: message = '拒绝访问' break case 404: message = '请求的资源不存在' break + case 429: + message = '请求过于频繁,请稍后再试' + break case 500: message = '服务器内部错误' break default: - message = `连接错误 ${error.response.status}` + message = error.response.data?.message || `连接错误 ${error.response.status}` } } else if (error.request) { message = '网络连接失败,请检查网络' diff --git a/frontend/src/main.js b/frontend/src/main.js index b44f288..dd99b6d 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,6 +1,7 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import ElementPlus from 'element-plus' +import zhCn from 'element-plus/es/locale/lang/zh-cn' import * as ElementPlusIconsVue from '@element-plus/icons-vue' import 'element-plus/dist/index.css' @@ -17,7 +18,7 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.use(createPinia()) app.use(router) -app.use(ElementPlus, { locale: undefined }) // 默认中文 +app.use(ElementPlus, { locale: zhCn }) // 从 localStorage 恢复登录状态 // Logan.Li const userStore = useUserStore() diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 7c3f239..4f22cf8 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -41,8 +41,6 @@ - - @@ -60,8 +58,8 @@ const userStore = useUserStore() const loginFormRef = ref(null) const loading = ref(false) const loginForm = reactive({ - username: 'admin', - password: 'admin', + username: '', + password: '', }) const loginRules = { username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], @@ -117,10 +115,4 @@ async function handleLogin() { color: #909399; font-size: 14px; } -.login-tip { - text-align: center; - color: #c0c4cc; - font-size: 12px; - margin-top: 16px; -}