fix: 按评审报告止血支付漏洞并加固授权链路
废除浏览器回跳自助结算,通用 webhook 强制密钥鉴权,Waffo 支持验签与未知事件忽略;重构订单状态机/激活占座/SQLite WAL,并补备份、readyz、限流与前端导出/登录页安全修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
8cc2c83f9d
commit
bac72e6945
24 files changed
+595
-304
No files matched your search
@@ -77,6 +77,11 @@ SUCCESS_URL=https://l123.91ai.io/api/v1/purchase/success
|
|||||||
# Dashboard → Settings → Webhooks:
|
# Dashboard → Settings → Webhooks:
|
||||||
# URL: https://你的域名/api/v1/webhooks/waffo (尽量 HTTPS;Raw JSON;事件 order.completed)
|
# URL: https://你的域名/api/v1/webhooks/waffo (尽量 HTTPS;Raw JSON;事件 order.completed)
|
||||||
# 当前公网: https://l123.91ai.io/api/v1/webhooks/waffo
|
# 当前公网: 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
|
# Docker Compose
|
||||||
|
|||||||
+2
-2
@@ -18,7 +18,7 @@ COPY frontend/ ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ==================== Stage 2: Backend ====================
|
# ==================== Stage 2: Backend ====================
|
||||||
FROM golang:1.24-bookworm AS backend-builder
|
FROM golang:1.25-bookworm AS backend-builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
ENV GOPROXY=https://goproxy.cn,direct
|
ENV GOPROXY=https://goproxy.cn,direct
|
||||||
@@ -61,6 +61,6 @@ VOLUME ["/data"]
|
|||||||
EXPOSE 9001
|
EXPOSE 9001
|
||||||
|
|
||||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=5 \
|
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"]
|
ENTRYPOINT ["/app/opsx-licence"]
|
||||||
@@ -4,24 +4,43 @@ import (
|
|||||||
"backend/config"
|
"backend/config"
|
||||||
"backend/internal/model"
|
"backend/internal/model"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Init 初始化数据库连接并执行迁移 // Logan.Li
|
// Init 初始化数据库连接并执行迁移(当前交付形态以 SQLite 为准) // Logan.Li
|
||||||
func Init(cfg *config.Config) (*gorm.DB, error) {
|
func Init(cfg *config.Config) (*gorm.DB, error) {
|
||||||
logLevel := getLogLevel(cfg.Mode)
|
logLevel := getLogLevel(cfg.Mode)
|
||||||
|
|
||||||
fmt.Printf("[数据库] 使用 SQLite: %s\n", cfg.DBName)
|
driver := strings.ToLower(strings.TrimSpace(cfg.DBDriver))
|
||||||
db, err := gorm.Open(sqlite.Open(cfg.DBName), &gorm.Config{
|
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),
|
Logger: logger.Default.LogMode(logLevel),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SQLite 连接失败: %v", err)
|
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)
|
return migrateDatabase(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -239,7 +239,9 @@ func ensureOpsXShellPlanPricing(db *gorm.DB) error {
|
|||||||
for _, u := range updates {
|
for _, u := range updates {
|
||||||
var plan model.Plan
|
var plan model.Plan
|
||||||
err := db.Where("product_id = ? AND plan_type = ?", product.ID, u.planType).First(&plan).Error
|
err := db.Where("product_id = ? AND plan_type = ?", product.ID, u.planType).First(&plan).Error
|
||||||
if err != nil {
|
if err == nil {
|
||||||
|
continue // 已存在:尊重管理员运行时修改,不再覆盖 // Logan.Li
|
||||||
|
}
|
||||||
plan = model.Plan{
|
plan = model.Plan{
|
||||||
ProductID: product.ID,
|
ProductID: product.ID,
|
||||||
Name: u.name,
|
Name: u.name,
|
||||||
@@ -255,21 +257,8 @@ func ensureOpsXShellPlanPricing(db *gorm.DB) error {
|
|||||||
if err := db.Create(&plan).Error; err != nil {
|
if err := db.Create(&plan).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
plan.Name = u.name
|
log.Printf("[初始化] OpsXShell 套餐已确保存在(仅新建,不覆盖已有价格)")
|
||||||
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 {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Printf("[初始化] OpsXShell 套餐已同步: 包年¥99/2台, 永久¥199/5台, 体验180天/1台")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,21 +277,13 @@ func ensureWaffoPaymentGateway(db *gorm.DB) error {
|
|||||||
if err := db.Create(&gw).Error; err != nil {
|
if err := db.Create(&gw).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
log.Println("[初始化] 已创建 Waffo 支付网关(默认沙箱,可在后台改 live)")
|
||||||
existing.Enabled = true
|
|
||||||
existing.SandboxMode = true
|
|
||||||
existing.Currency = "CNY"
|
|
||||||
existing.Priority = 0
|
|
||||||
if err := db.Save(&existing).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
}
|
// 已存在则不覆盖 Enabled/SandboxMode,尊重管理员配置 // Logan.Li
|
||||||
// Lower priority for other enabled gateways (ASC pick prefers Waffo).
|
|
||||||
if err := db.Model(&model.PaymentGatewayConfig{}).
|
if err := db.Model(&model.PaymentGatewayConfig{}).
|
||||||
Where("gateway <> ? AND enabled = ?", "waffo", true).
|
Where("gateway <> ? AND enabled = ?", "waffo", true).
|
||||||
Update("priority", 100).Error; err != nil {
|
Update("priority", 100).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Println("[初始化] 支付网关已优先 Waffo(包年/永久均为 onetime)")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
module backend
|
module backend
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-contrib/cors v1.5.0
|
github.com/gin-contrib/cors v1.5.0
|
||||||
@@ -9,6 +9,7 @@ require (
|
|||||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
golang.org/x/crypto v0.43.0
|
golang.org/x/crypto v0.43.0
|
||||||
|
golang.org/x/time v0.15.0
|
||||||
gorm.io/driver/sqlite v1.5.4
|
gorm.io/driver/sqlite v1.5.4
|
||||||
gorm.io/gorm v1.25.5
|
gorm.io/gorm v1.25.5
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
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 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import (
|
|||||||
"backend/internal/dto"
|
"backend/internal/dto"
|
||||||
"backend/internal/model"
|
"backend/internal/model"
|
||||||
orderservice "backend/internal/service/order"
|
orderservice "backend/internal/service/order"
|
||||||
|
"backend/internal/service/payment"
|
||||||
"backend/utils"
|
"backend/utils"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -102,8 +105,32 @@ func (ctrl *OrderController) ClaimPurchase(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp})
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaymentWebhook 支付回调(公开接口,供支付渠道调用) // Logan.Li
|
// PaymentWebhook 通用支付回调:必须携带与网关配置一致的共享密钥,禁止匿名结算 // Logan.Li
|
||||||
func (ctrl *OrderController) PaymentWebhook(c *gin.Context) {
|
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
|
var req dto.PaymentWebhookRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "Invalid payload"})
|
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) {
|
func (ctrl *OrderController) PurchaseSuccess(c *gin.Context) {
|
||||||
orderNo := firstNonEmpty(c.Query("order_no"), c.Query("orderNo"))
|
orderNo := firstNonEmpty(c.Query("order_no"), c.Query("orderNo"))
|
||||||
email := firstNonEmpty(c.Query("email"), c.Query("customer_email"), c.Query("customerEmail"))
|
email := firstNonEmpty(c.Query("email"), c.Query("customer_email"), c.Query("customerEmail"))
|
||||||
planType := firstNonEmpty(c.Query("planType"), c.Query("plan_type"))
|
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 := "支付结果"
|
title := "支付结果"
|
||||||
headline := "正在确认支付…"
|
headline := "正在确认支付…"
|
||||||
body := "若你已完成付款,请稍等数秒后刷新本页;或打开 OpsXShell,在「账户」粘贴许可证激活。"
|
body := "若你已完成付款,请稍等数秒后刷新本页;或打开 OpsXShell,在「账户」粘贴许可证激活。"
|
||||||
@@ -172,13 +187,8 @@ func (ctrl *OrderController) PurchaseSuccess(c *gin.Context) {
|
|||||||
switch order.Status {
|
switch order.Status {
|
||||||
case "paid":
|
case "paid":
|
||||||
headline = "支付成功"
|
headline = "支付成功"
|
||||||
if order.LicenseID > 0 {
|
// 仅当邮箱匹配订单客户时展示 License,防止仅凭订单号泄露密钥 // Logan.Li
|
||||||
var lic model.License
|
if email != "" {
|
||||||
if err := ctrl.db.First(&lic, order.LicenseID).Error; err == nil {
|
|
||||||
licenseKey = lic.LicenseKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if licenseKey == "" {
|
|
||||||
claimed, err := ctrl.svc.ClaimPurchase(dto.ClaimPurchaseRequest{
|
claimed, err := ctrl.svc.ClaimPurchase(dto.ClaimPurchaseRequest{
|
||||||
OrderNo: orderNo,
|
OrderNo: orderNo,
|
||||||
CustomerEmail: email,
|
CustomerEmail: email,
|
||||||
@@ -187,10 +197,14 @@ func (ctrl *OrderController) PurchaseSuccess(c *gin.Context) {
|
|||||||
if err == nil && claimed != nil {
|
if err == nil && claimed != nil {
|
||||||
licenseKey = claimed.LicenseKey
|
licenseKey = claimed.LicenseKey
|
||||||
}
|
}
|
||||||
|
} else if order.LicenseID > 0 {
|
||||||
|
body = "订单已支付。请在 OpsXShell 内用下单邮箱领取许可证,或刷新本页并带上 email 参数。"
|
||||||
}
|
}
|
||||||
if licenseKey != "" {
|
if licenseKey != "" {
|
||||||
body = "请复制下方许可证,回到 OpsXShell → 设置 / 升级页 → 粘贴激活。也可点击「打开应用」自动回跳。"
|
body = "请复制下方许可证,回到 OpsXShell → 设置 / 升级页 → 粘贴激活。也可点击「打开应用」自动回跳。"
|
||||||
} else {
|
} else if order.LicenseID > 0 && email == "" {
|
||||||
|
body = "订单已支付。为保护密钥,请使用下单邮箱在客户端领取,或访问本页并附带 email 参数。"
|
||||||
|
} else if licenseKey == "" {
|
||||||
body = "订单已支付,许可证生成中,请稍后刷新本页。"
|
body = "订单已支付,许可证生成中,请稍后刷新本页。"
|
||||||
}
|
}
|
||||||
case "pending":
|
case "pending":
|
||||||
@@ -205,17 +219,8 @@ func (ctrl *OrderController) PurchaseSuccess(c *gin.Context) {
|
|||||||
body = "请确认是否从 OpsXShell 内发起购买。订单号:" + html.EscapeString(orderNo)
|
body = "请确认是否从 OpsXShell 内发起购买。订单号:" + html.EscapeString(orderNo)
|
||||||
}
|
}
|
||||||
} else if email != "" {
|
} else if email != "" {
|
||||||
claimed, err := ctrl.svc.ClaimPurchase(dto.ClaimPurchaseRequest{
|
headline = "请提供订单号"
|
||||||
CustomerEmail: email,
|
body = "为保护授权安全,领取许可证需同时提供订单号与下单邮箱。"
|
||||||
ProductCode: "opsxshell",
|
|
||||||
})
|
|
||||||
if err == nil && claimed != nil && claimed.LicenseKey != "" {
|
|
||||||
headline = "支付成功"
|
|
||||||
licenseKey = claimed.LicenseKey
|
|
||||||
orderNo = claimed.OrderNo
|
|
||||||
orderStatus = claimed.Status
|
|
||||||
body = "已根据邮箱找到可用许可证,请复制后在 OpsXShell 中激活。"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deepLink := "opsxshell://pro/activated"
|
deepLink := "opsxshell://pro/activated"
|
||||||
@@ -318,8 +323,18 @@ func (ctrl *OrderController) WaffoWebhook(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "无法读取请求体"})
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "无法读取请求体"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sigHeader := c.GetHeader("X-Waffo-Signature")
|
sigHeader := firstNonEmpty(
|
||||||
_ = sigHeader // optional RSA verify when WAFFO_WEBHOOK_PUBLIC_KEY is set later // Logan.Li
|
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{}
|
var payload map[string]interface{}
|
||||||
if len(body) > 0 {
|
if len(body) > 0 {
|
||||||
@@ -377,7 +392,8 @@ func (ctrl *OrderController) WaffoWebhook(c *gin.Context) {
|
|||||||
asString(payload["paymentStatus"]),
|
asString(payload["paymentStatus"]),
|
||||||
))
|
))
|
||||||
|
|
||||||
mappedStatus := "paid"
|
// 未知事件默认忽略,禁止把任意回调当成 paid // Logan.Li
|
||||||
|
mappedStatus := ""
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(strings.ToLower(eventType), "refund"), strings.Contains(statusRaw, "refund"):
|
case strings.Contains(strings.ToLower(eventType), "refund"), strings.Contains(statusRaw, "refund"):
|
||||||
mappedStatus = "refunded"
|
mappedStatus = "refunded"
|
||||||
@@ -386,11 +402,15 @@ func (ctrl *OrderController) WaffoWebhook(c *gin.Context) {
|
|||||||
mappedStatus = "failed"
|
mappedStatus = "failed"
|
||||||
case strings.Contains(strings.ToLower(eventType), "order.completed"),
|
case strings.Contains(strings.ToLower(eventType), "order.completed"),
|
||||||
strings.Contains(strings.ToLower(eventType), "payment.succeeded"),
|
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"):
|
strings.Contains(statusRaw, "success") || strings.Contains(statusRaw, "succeed"):
|
||||||
mappedStatus = "paid"
|
mappedStatus = "paid"
|
||||||
default:
|
case statusRaw == "" && (strings.Contains(strings.ToLower(eventType), "completed") ||
|
||||||
|
strings.Contains(strings.ToLower(eventType), "succeeded")):
|
||||||
mappedStatus = "paid"
|
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
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if merchantExternalID == "" && gatewayOrderNo == "" && buyerEmail == "" {
|
if merchantExternalID == "" && gatewayOrderNo == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "missing orderMerchantExternalId / orderId"})
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "missing orderMerchantExternalId / orderId"})
|
||||||
return
|
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{
|
req := dto.PaymentWebhookRequest{
|
||||||
EventType: eventType,
|
EventType: eventType,
|
||||||
OrderNo: merchantExternalID,
|
OrderNo: merchantExternalID,
|
||||||
GatewayOrderNo: firstNonEmpty(gatewayOrderNo, paymentID),
|
GatewayOrderNo: firstNonEmpty(gatewayOrderNo, paymentID),
|
||||||
Status: mappedStatus,
|
Status: mappedStatus,
|
||||||
|
Amount: amount,
|
||||||
|
Currency: currency,
|
||||||
CustomerEmail: buyerEmail,
|
CustomerEmail: buyerEmail,
|
||||||
Gateway: "waffo",
|
Gateway: "waffo",
|
||||||
Payload: string(body),
|
Payload: string(body),
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ type ProductWithPlansResponse struct {
|
|||||||
|
|
||||||
// PaymentWebhookRequest 支付回调请求 // Logan.Li
|
// PaymentWebhookRequest 支付回调请求 // Logan.Li
|
||||||
type PaymentWebhookRequest struct {
|
type PaymentWebhookRequest struct {
|
||||||
EventType string `json:"event_type" binding:"required"`
|
EventType string `json:"event_type"`
|
||||||
OrderNo string `json:"order_no" binding:"required"`
|
OrderNo string `json:"order_no"`
|
||||||
GatewayOrderNo string `json:"gateway_order_no"`
|
GatewayOrderNo string `json:"gateway_order_no"`
|
||||||
Status string `json:"status" binding:"required"` // paid / failed / refunded
|
Status string `json:"status" binding:"required"` // paid / failed / refunded
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type Customer struct {
|
|||||||
ID uint `json:"id" gorm:"primaryKey"`
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
Name string `json:"name" gorm:"size:100;not null"`
|
Name string `json:"name" gorm:"size:100;not null"`
|
||||||
Company string `json:"company" gorm:"size:200"`
|
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"`
|
Phone string `json:"phone" gorm:"size:20"`
|
||||||
Address string `json:"address" gorm:"size:500"`
|
Address string `json:"address" gorm:"size:500"`
|
||||||
Notes string `json:"notes" gorm:"type:text"`
|
Notes string `json:"notes" gorm:"type:text"`
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import "time"
|
|||||||
// CustomerEntitlement 客户权益记录(防重复购买的核心表) // Logan.Li
|
// CustomerEntitlement 客户权益记录(防重复购买的核心表) // Logan.Li
|
||||||
type CustomerEntitlement struct {
|
type CustomerEntitlement struct {
|
||||||
ID uint `json:"id" gorm:"primaryKey"`
|
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"`
|
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"`
|
Product Product `json:"product,omitempty" gorm:"foreignKey:ProductID"`
|
||||||
PlanID uint `json:"plan_id"`
|
PlanID uint `json:"plan_id"`
|
||||||
Plan Plan `json:"plan,omitempty" gorm:"foreignKey:PlanID"`
|
Plan Plan `json:"plan,omitempty" gorm:"foreignKey:PlanID"`
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ func (License) TableName() string {
|
|||||||
// Activation 激活记录模型(机器绑定) // Logan.Li
|
// Activation 激活记录模型(机器绑定) // Logan.Li
|
||||||
type Activation struct {
|
type Activation struct {
|
||||||
ID uint `json:"id" gorm:"primaryKey"`
|
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"`
|
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"`
|
DeviceName string `json:"device_name" gorm:"size:200"`
|
||||||
DeviceInfo string `json:"device_info" gorm:"type:text"` // JSON 格式存储设备信息
|
DeviceInfo string `json:"device_info" gorm:"type:text"` // JSON 格式存储设备信息
|
||||||
IP string `json:"ip" gorm:"size:50"`
|
IP string `json:"ip" gorm:"size:50"`
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type Order struct {
|
|||||||
Status string `json:"status" gorm:"size:20;default:'pending'"` // pending / paid / failed / refunded / cancelled
|
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
|
PaymentMethod string `json:"payment_method" gorm:"size:50"` // paddle / stripe / creem / waffo / alipay / wechat
|
||||||
PaymentGateway string `json:"payment_gateway" gorm:"size:50"`
|
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"`
|
PaidAt *time.Time `json:"paid_at,omitempty"`
|
||||||
RefundAt *time.Time `json:"refund_at,omitempty"`
|
RefundAt *time.Time `json:"refund_at,omitempty"`
|
||||||
ClientIP string `json:"client_ip" gorm:"size:50"`
|
ClientIP string `json:"client_ip" gorm:"size:50"`
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"backend/internal/model"
|
"backend/internal/model"
|
||||||
"backend/utils"
|
"backend/utils"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -134,7 +135,7 @@ func (s *EntitlementService) Renew(id, planID uint) (*dto.EntitlementResponse, e
|
|||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByEmail 按邮箱查询客户权益(客户端接口) // Logan.Li
|
// GetByEmail 按邮箱查询客户权益(客户端接口;不回传完整 License Key) // Logan.Li
|
||||||
func (s *EntitlementService) GetByEmail(email string) ([]dto.EntitlementResponse, error) {
|
func (s *EntitlementService) GetByEmail(email string) ([]dto.EntitlementResponse, error) {
|
||||||
var customer model.Customer
|
var customer model.Customer
|
||||||
if err := s.db.Where("email = ?", email).First(&customer).Error; err != nil {
|
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))
|
results := make([]dto.EntitlementResponse, 0, len(entitlements))
|
||||||
for _, e := range 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
|
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
|
// toResponse 模型转响应 DTO // Logan.Li
|
||||||
func (s *EntitlementService) toResponse(e model.CustomerEntitlement) dto.EntitlementResponse {
|
func (s *EntitlementService) toResponse(e model.CustomerEntitlement) dto.EntitlementResponse {
|
||||||
resp := dto.EntitlementResponse{
|
resp := dto.EntitlementResponse{
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"backend/utils"
|
"backend/utils"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func inferPlanType(lic model.License) string {
|
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 {
|
if lic.Duration <= 0 || lic.EndDate.Equal(model.PermanentEndDate) || lic.EndDate.Year() >= 2099 {
|
||||||
return "perpetual"
|
return "perpetual"
|
||||||
}
|
}
|
||||||
if lic.Duration <= 180 {
|
// 先判月度,再判体验/包年,避免 monthly 成为死代码 // Logan.Li
|
||||||
return "trial"
|
|
||||||
}
|
|
||||||
if lic.Duration <= 31 {
|
if lic.Duration <= 31 {
|
||||||
return "monthly"
|
return "monthly"
|
||||||
}
|
}
|
||||||
|
if lic.Duration <= 180 {
|
||||||
|
return "trial"
|
||||||
|
}
|
||||||
return "yearly"
|
return "yearly"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,36 +78,68 @@ func (s *LicenseService) Verify(req dto.LicenseVerifyRequest) (*dto.LicenseVerif
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate 客户端激活 License(机器绑定) // Logan.Li
|
// Activate 客户端激活 License(事务 + 条件占座,消除 TOCTOU) // Logan.Li
|
||||||
func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP string) (map[string]interface{}, error) {
|
func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP string) (map[string]interface{}, error) {
|
||||||
|
var payload map[string]interface{}
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var lic model.License
|
var lic model.License
|
||||||
if err := s.db.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
|
if err := tx.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
|
||||||
return nil, errors.New("无效的 License")
|
return errors.New("无效的 License")
|
||||||
}
|
}
|
||||||
|
if lic.Status == model.LicenseStatusRevoked || lic.Status == model.LicenseStatusSuspended {
|
||||||
if lic.Status == model.LicenseStatusRevoked {
|
return errors.New("License 已被吊销或暂停")
|
||||||
return nil, errors.New("License 已被吊销")
|
|
||||||
}
|
}
|
||||||
if lic.Status == model.LicenseStatusExpired || utils.IsLicenseExpired(lic.EndDate) {
|
if lic.Status == model.LicenseStatusExpired || utils.IsLicenseExpired(lic.EndDate) {
|
||||||
return nil, errors.New("License 已过期")
|
return errors.New("License 已过期")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
var existing model.Activation
|
var existing model.Activation
|
||||||
if err := s.db.Where("license_id = ? AND device_id = ?", lic.ID, req.DeviceID).First(&existing).Error; err == nil {
|
if err := tx.Where("license_id = ? AND device_id = ?", lic.ID, req.DeviceID).
|
||||||
existing.LastSeenAt = time.Now()
|
First(&existing).Error; err == nil {
|
||||||
existing.IsOnline = true
|
updates := map[string]interface{}{
|
||||||
existing.DeviceName = req.DeviceName
|
"last_seen_at": now,
|
||||||
existing.DeviceInfo = req.DeviceInfo
|
"is_online": true,
|
||||||
existing.IP = clientIP
|
"device_name": req.DeviceName,
|
||||||
s.db.Save(&existing)
|
"device_info": req.DeviceInfo,
|
||||||
return licenseClientPayload(lic), nil
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
if lic.CurrentActivations >= lic.MaxActivations {
|
res := tx.Model(&model.License{}).
|
||||||
return nil, errors.New("已达到最大激活数量限制")
|
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("已达到最大激活数量限制")
|
||||||
}
|
}
|
||||||
|
|
||||||
activation := model.Activation{
|
if err := tx.Create(&model.Activation{
|
||||||
LicenseID: lic.ID,
|
LicenseID: lic.ID,
|
||||||
DeviceID: req.DeviceID,
|
DeviceID: req.DeviceID,
|
||||||
DeviceName: req.DeviceName,
|
DeviceName: req.DeviceName,
|
||||||
@@ -112,38 +147,43 @@ func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP strin
|
|||||||
IP: clientIP,
|
IP: clientIP,
|
||||||
Status: "active",
|
Status: "active",
|
||||||
IsOnline: true,
|
IsOnline: true,
|
||||||
ActivatedAt: time.Now(),
|
ActivatedAt: now,
|
||||||
LastSeenAt: time.Now(),
|
LastSeenAt: now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return errors.New("激活失败")
|
||||||
}
|
}
|
||||||
if err := s.db.Create(&activation).Error; err != nil {
|
if err := tx.First(&lic, lic.ID).Error; err != nil {
|
||||||
return nil, errors.New("激活失败")
|
return err
|
||||||
}
|
}
|
||||||
|
payload = licenseClientPayload(lic)
|
||||||
lic.CurrentActivations++
|
return nil
|
||||||
if lic.Status == model.LicenseStatusInactive {
|
})
|
||||||
lic.Status = model.LicenseStatusActive
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
s.db.Save(&lic)
|
return payload, nil
|
||||||
|
|
||||||
return licenseClientPayload(lic), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deactivate 客户端停用 // Logan.Li
|
// Deactivate 客户端停用(仅 active→inactive 才减席位) // Logan.Li
|
||||||
func (s *LicenseService) Deactivate(req dto.LicenseDeactivateRequest) error {
|
func (s *LicenseService) Deactivate(req dto.LicenseDeactivateRequest) error {
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var lic model.License
|
var lic model.License
|
||||||
if err := s.db.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
|
if err := tx.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
|
||||||
return errors.New("无效的 License")
|
return errors.New("无效的 License")
|
||||||
}
|
}
|
||||||
|
res := tx.Model(&model.Activation{}).
|
||||||
result := s.db.Model(&model.Activation{}).
|
Where("license_id = ? AND device_id = ? AND status = ?", lic.ID, req.DeviceID, "active").
|
||||||
Where("license_id = ? AND device_id = ?", lic.ID, req.DeviceID).
|
Updates(map[string]interface{}{"status": "inactive", "is_online": false})
|
||||||
Update("status", "inactive")
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
if result.RowsAffected > 0 && lic.CurrentActivations > 0 {
|
}
|
||||||
lic.CurrentActivations--
|
if res.RowsAffected > 0 {
|
||||||
s.db.Save(&lic)
|
return tx.Model(&model.License{}).
|
||||||
|
Where("id = ? AND current_activations > 0", lic.ID).
|
||||||
|
Update("current_activations", gorm.Expr("current_activations - 1")).Error
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heartbeat 客户端心跳 // Logan.Li
|
// Heartbeat 客户端心跳 // Logan.Li
|
||||||
@@ -153,8 +193,8 @@ func (s *LicenseService) Heartbeat(req dto.LicenseHeartbeatRequest) (map[string]
|
|||||||
return nil, errors.New("无效的 License")
|
return nil, errors.New("无效的 License")
|
||||||
}
|
}
|
||||||
|
|
||||||
if lic.Status == model.LicenseStatusRevoked {
|
if lic.Status == model.LicenseStatusRevoked || lic.Status == model.LicenseStatusSuspended {
|
||||||
return nil, errors.New("License 已被吊销")
|
return nil, errors.New("License 已被吊销或暂停")
|
||||||
}
|
}
|
||||||
if lic.Status == model.LicenseStatusExpired || utils.IsLicenseExpired(lic.EndDate) {
|
if lic.Status == model.LicenseStatusExpired || utils.IsLicenseExpired(lic.EndDate) {
|
||||||
return nil, errors.New("License 已过期")
|
return nil, errors.New("License 已过期")
|
||||||
@@ -183,4 +223,3 @@ func (s *LicenseService) ListDevicesByKey(licenseKey string) ([]dto.ActivationRe
|
|||||||
}
|
}
|
||||||
return results, lic.MaxActivations, nil
|
return results, lic.MaxActivations, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"backend/utils"
|
"backend/utils"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -193,20 +194,27 @@ func (s *OrderService) Checkout(req dto.CheckoutRequest, clientIP, userAgent str
|
|||||||
}, clientIP, userAgent)
|
}, 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) {
|
func (s *OrderService) ClaimPurchase(req dto.ClaimPurchaseRequest) (*dto.ClaimPurchaseResponse, error) {
|
||||||
orderNo := strings.TrimSpace(req.OrderNo)
|
orderNo := strings.TrimSpace(req.OrderNo)
|
||||||
email := strings.TrimSpace(req.CustomerEmail)
|
email := strings.TrimSpace(req.CustomerEmail)
|
||||||
productCode := strings.TrimSpace(req.ProductCode)
|
if orderNo == "" || email == "" {
|
||||||
if productCode == "" {
|
return nil, errors.New("请同时提供 order_no 与 customer_email")
|
||||||
productCode = "opsxshell"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if orderNo != "" {
|
|
||||||
var order model.Order
|
var order model.Order
|
||||||
if err := s.db.Where("order_no = ?", orderNo).First(&order).Error; err != nil {
|
if err := s.db.Preload("Customer").Where("order_no = ?", orderNo).First(&order).Error; err != nil {
|
||||||
return nil, errors.New("订单不存在")
|
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 !strings.EqualFold(strings.TrimSpace(order.Customer.Email), email) {
|
||||||
|
return nil, errors.New("订单与邮箱不匹配")
|
||||||
|
}
|
||||||
|
|
||||||
switch order.Status {
|
switch order.Status {
|
||||||
case "pending":
|
case "pending":
|
||||||
return &dto.ClaimPurchaseResponse{
|
return &dto.ClaimPurchaseResponse{
|
||||||
@@ -249,69 +257,9 @@ func (s *OrderService) ClaimPurchase(req dto.ClaimPurchaseRequest) (*dto.ClaimPu
|
|||||||
Message: fmt.Sprintf("订单状态为 %s,无法领取授权", order.Status),
|
Message: fmt.Sprintf("订单状态为 %s,无法领取授权", order.Status),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if email == "" {
|
|
||||||
return nil, errors.New("请提供 order_no 或 customer_email")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
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,
|
|
||||||
Status: "pending",
|
|
||||||
Message: "订单尚未支付完成,请稍后再试",
|
|
||||||
}, 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) {
|
func (s *OrderService) resolveWebhookOrder(req dto.PaymentWebhookRequest) (model.Order, error) {
|
||||||
var order model.Order
|
var order model.Order
|
||||||
if no := strings.TrimSpace(req.OrderNo); no != "" {
|
if no := strings.TrimSpace(req.OrderNo); no != "" {
|
||||||
@@ -328,46 +276,72 @@ func (s *OrderService) resolveWebhookOrder(req dto.PaymentWebhookRequest) (model
|
|||||||
return order, nil
|
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("订单不存在")
|
return order, errors.New("订单不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaymentWebhook 支付回调(幂等 + 事务) // Logan.Li
|
// PaymentWebhook 支付回调:原子状态流转 + 金额校验 + 退款可达 // Logan.Li
|
||||||
func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP string) error {
|
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)
|
order, err := s.resolveWebhookOrder(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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()
|
now := time.Now()
|
||||||
switch req.Status {
|
updates := map[string]interface{}{
|
||||||
case "paid", "completed":
|
"payment_gateway": req.Gateway,
|
||||||
// 更新订单 // Logan.Li
|
"payment_method": req.Gateway,
|
||||||
order.Status = "paid"
|
}
|
||||||
order.GatewayOrderNo = req.GatewayOrderNo
|
if g := strings.TrimSpace(req.GatewayOrderNo); g != "" {
|
||||||
order.PaymentGateway = req.Gateway
|
updates["gateway_order_no"] = g
|
||||||
order.PaymentMethod = req.Gateway
|
}
|
||||||
order.PaidAt = &now
|
switch status {
|
||||||
if err := tx.Save(&order).Error; err != nil {
|
case "paid":
|
||||||
return err
|
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
|
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 validUntil *time.Time
|
||||||
var endDate 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 {
|
if err := tx.Create(&lic).Error; err != nil {
|
||||||
return err
|
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
|
renewable := plan.PlanType != "perpetual" && plan.PlanType != "lifetime"
|
||||||
tx.Save(&order)
|
|
||||||
|
|
||||||
// 更新权益记录(防重复购买核心) // Logan.Li
|
|
||||||
var ent model.CustomerEntitlement
|
var ent model.CustomerEntitlement
|
||||||
entResult := tx.Where("customer_id = ? AND product_id = ?", order.CustomerID, order.ProductID).First(&ent)
|
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 {
|
if entResult.Error != nil {
|
||||||
ent = model.CustomerEntitlement{
|
ent = model.CustomerEntitlement{
|
||||||
CustomerID: order.CustomerID,
|
CustomerID: order.CustomerID,
|
||||||
@@ -412,7 +386,9 @@ func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP st
|
|||||||
ValidUntil: validUntil,
|
ValidUntil: validUntil,
|
||||||
Renewable: renewable,
|
Renewable: renewable,
|
||||||
}
|
}
|
||||||
tx.Create(&ent)
|
if err := tx.Create(&ent).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ent.PlanID = order.PlanID
|
ent.PlanID = order.PlanID
|
||||||
ent.LicenseID = lic.ID
|
ent.LicenseID = lic.ID
|
||||||
@@ -422,32 +398,31 @@ func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP st
|
|||||||
ent.ValidFrom = now
|
ent.ValidFrom = now
|
||||||
ent.ValidUntil = validUntil
|
ent.ValidUntil = validUntil
|
||||||
ent.Renewable = renewable
|
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,
|
utils.LogSystemAction(tx, "payment_completed", "order", order.ID, order.OrderNo,
|
||||||
fmt.Sprintf("订单支付成功,已发放 License: %s", lic.LicenseKey), clientIP)
|
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":
|
case "refunded":
|
||||||
order.Status = "refunded"
|
if order.LicenseID > 0 {
|
||||||
order.RefundAt = &now
|
if err := tx.Model(&model.License{}).Where("id = ?", order.LicenseID).
|
||||||
if err := tx.Save(&order).Error; err != nil {
|
Updates(map[string]interface{}{
|
||||||
|
"status": model.LicenseStatusRevoked,
|
||||||
|
"revoke_reason": "订单退款",
|
||||||
|
}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// 吊销关联 License // Logan.Li
|
if err := tx.Model(&model.CustomerEntitlement{}).Where("license_id = ?", order.LicenseID).
|
||||||
if order.LicenseID > 0 {
|
Update("status", "revoked").Error; err != nil {
|
||||||
tx.Model(&model.License{}).Where("id = ?", order.LicenseID).
|
return err
|
||||||
Updates(map[string]interface{}{"status": model.LicenseStatusRevoked, "revoke_reason": "订单退款"})
|
|
||||||
tx.Model(&model.CustomerEntitlement{}).Where("license_id = ?", order.LicenseID).
|
|
||||||
Update("status", "revoked")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
utils.LogSystemAction(tx, "payment_refunded", "order", order.ID, order.OrderNo,
|
||||||
|
"订单退款,已吊销关联授权", clientIP)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -630,7 +605,7 @@ func (s *OrderService) CheckPurchaseStatus(customerEmail string, productID uint)
|
|||||||
return &dto.CheckPurchaseStatusResponse{
|
return &dto.CheckPurchaseStatusResponse{
|
||||||
Purchased: true,
|
Purchased: true,
|
||||||
HasActiveLicense: isActive,
|
HasActiveLicense: isActive,
|
||||||
LicenseKey: ent.LicenseKey,
|
LicenseKey: "", // 公开接口不回传完整密钥,请走 claim(order+email) // Logan.Li
|
||||||
LicenseStatus: licenseStatus,
|
LicenseStatus: licenseStatus,
|
||||||
PlanType: ent.PlanType,
|
PlanType: ent.PlanType,
|
||||||
ValidUntil: validUntilStr,
|
ValidUntil: validUntilStr,
|
||||||
|
|||||||
@@ -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("不支持的公钥格式")
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-15
@@ -66,10 +66,22 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
|||||||
// CORS 中间件 // Logan.Li
|
// CORS 中间件 // Logan.Li
|
||||||
r.Use(middleware.CORSMiddleware(cfg))
|
r.Use(middleware.CORSMiddleware(cfg))
|
||||||
|
|
||||||
// 健康检查 // Logan.Li
|
// 存活探针(不查库) // Logan.Li
|
||||||
r.GET("/health", func(c *gin.Context) {
|
r.GET("/health", func(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{"code": 0, "message": "ok", "data": nil})
|
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
|
// 初始化控制器 // Logan.Li
|
||||||
authController := auth.NewAuthController(db, cfg)
|
authController := auth.NewAuthController(db, cfg)
|
||||||
@@ -87,36 +99,40 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
|||||||
// ============ 认证路由(无需 JWT) ============
|
// ============ 认证路由(无需 JWT) ============
|
||||||
authGroup := api.Group("/auth")
|
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")
|
clientAPI := api.Group("/v1")
|
||||||
{
|
{
|
||||||
|
licLimit := middleware.RateLimit(2, 30)
|
||||||
|
checkoutLimit := middleware.RateLimit(0.5, 10)
|
||||||
|
webhookLimit := middleware.RateLimit(1, 20)
|
||||||
|
|
||||||
// License 验证 / 激活 / 停用 / 心跳
|
// License 验证 / 激活 / 停用 / 心跳
|
||||||
register(clientAPI, "POST", "/license/verify", licenseController.Verify)
|
register(clientAPI, "POST", "/license/verify", licLimit, licenseController.Verify)
|
||||||
register(clientAPI, "POST", "/license/activate", licenseController.ActivateDevice)
|
register(clientAPI, "POST", "/license/activate", licLimit, licenseController.ActivateDevice)
|
||||||
register(clientAPI, "POST", "/license/deactivate", licenseController.Deactivate)
|
register(clientAPI, "POST", "/license/deactivate", licLimit, licenseController.Deactivate)
|
||||||
register(clientAPI, "POST", "/license/heartbeat", licenseController.Heartbeat)
|
register(clientAPI, "POST", "/license/heartbeat", licLimit, licenseController.Heartbeat)
|
||||||
register(clientAPI, "POST", "/license/devices", licenseController.ListDevices)
|
register(clientAPI, "POST", "/license/devices", licLimit, licenseController.ListDevices)
|
||||||
|
|
||||||
// 产品定价查询(展示用)
|
// 产品定价查询(展示用)
|
||||||
register(clientAPI, "GET", "/products/code/:code", productController.GetByCode)
|
register(clientAPI, "GET", "/products/code/:code", productController.GetByCode)
|
||||||
clientAPI.GET("/products/:product_id/plans", planController.GetByProduct)
|
clientAPI.GET("/products/:product_id/plans", planController.GetByProduct)
|
||||||
|
|
||||||
// 订单与支付(客户端购买入口 + 支付回调)
|
// 订单与支付(客户端购买入口 + 支付回调)
|
||||||
register(clientAPI, "POST", "/orders", orderController.CreateOrder)
|
register(clientAPI, "POST", "/orders", checkoutLimit, orderController.CreateOrder)
|
||||||
register(clientAPI, "POST", "/checkout", orderController.Checkout)
|
register(clientAPI, "POST", "/checkout", checkoutLimit, orderController.Checkout)
|
||||||
register(clientAPI, "POST", "/purchase/claim", orderController.ClaimPurchase)
|
register(clientAPI, "POST", "/purchase/claim", checkoutLimit, orderController.ClaimPurchase)
|
||||||
register(clientAPI, "POST", "/webhooks/payment", orderController.PaymentWebhook)
|
register(clientAPI, "POST", "/webhooks/payment", webhookLimit, orderController.PaymentWebhook)
|
||||||
register(clientAPI, "GET", "/webhooks/waffo", orderController.WaffoWebhookReady)
|
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, "GET", "/purchase/success", orderController.PurchaseSuccess)
|
||||||
register(clientAPI, "POST", "/purchase/check", orderController.CheckDuplicatePurchase)
|
register(clientAPI, "POST", "/purchase/check", checkoutLimit, orderController.CheckDuplicatePurchase)
|
||||||
register(clientAPI, "POST", "/purchase/status", orderController.CheckPurchaseStatus)
|
register(clientAPI, "POST", "/purchase/status", checkoutLimit, orderController.CheckPurchaseStatus)
|
||||||
|
|
||||||
// 客户权益查询(通过邮箱)
|
// 客户权益查询(通过邮箱)
|
||||||
register(clientAPI, "GET", "/entitlements/my", entitlementController.GetByEmail)
|
register(clientAPI, "GET", "/entitlements/my", checkoutLimit, entitlementController.GetByEmail)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 需要认证的管理路由 ============
|
// ============ 需要认证的管理路由 ============
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -38,20 +36,15 @@ type OfflineKeyPair struct {
|
|||||||
Public ed25519.PublicKey
|
Public ed25519.PublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeriveOfflineKeyPair 从 32 字节 seed(hex)派生 Ed25519 密钥对 // Logan.Li
|
// DeriveOfflineKeyPair 从 32 字节 seed(必须 64 位 hex)派生 Ed25519 密钥对 // Logan.Li
|
||||||
func DeriveOfflineKeyPair(seedHex string) (*OfflineKeyPair, error) {
|
func DeriveOfflineKeyPair(seedHex string) (*OfflineKeyPair, error) {
|
||||||
seedHex = strings.TrimSpace(seedHex)
|
seedHex = strings.TrimSpace(seedHex)
|
||||||
if seedHex == "" {
|
if seedHex == "" {
|
||||||
return nil, errors.New("离线签发 seed 为空")
|
return nil, errors.New("离线签发 seed 为空")
|
||||||
}
|
}
|
||||||
raw, err := hex.DecodeString(seedHex)
|
raw, err := hex.DecodeString(seedHex)
|
||||||
if err != nil {
|
if err != nil || len(raw) != ed25519.SeedSize {
|
||||||
return nil, fmt.Errorf("离线签发 seed 不是合法 hex: %w", err)
|
return nil, errors.New("OFFLINE_LICENSE_SEED 必须是 64 位 hex(32 字节),请用 openssl rand -hex 32 生成")
|
||||||
}
|
|
||||||
if len(raw) != ed25519.SeedSize {
|
|
||||||
// 允许任意长度输入,统一 hash 成 32 字节 // Logan.Li
|
|
||||||
sum := sha256.Sum256(raw)
|
|
||||||
raw = sum[:]
|
|
||||||
}
|
}
|
||||||
priv := ed25519.NewKeyFromSeed(raw)
|
priv := ed25519.NewKeyFromSeed(raw)
|
||||||
pub := priv.Public().(ed25519.PublicKey)
|
pub := priv.Public().(ed25519.PublicKey)
|
||||||
|
|||||||
+27
-1
@@ -26,11 +26,37 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- licence_data:/data
|
- licence_data:/data
|
||||||
healthcheck:
|
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
|
interval: 15s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 15s
|
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:
|
volumes:
|
||||||
licence_data:
|
licence_data:
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"element-plus": "^2.5.0",
|
"element-plus": "^2.5.0",
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.7.9",
|
||||||
"echarts": "^5.4.3",
|
"echarts": "^5.4.3",
|
||||||
"vue-echarts": "^6.6.8",
|
"vue-echarts": "^6.6.8",
|
||||||
"dayjs": "^1.11.10"
|
"dayjs": "^1.11.10"
|
||||||
|
|||||||
+37
-25
@@ -1,5 +1,6 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import router from '@/router'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
// 创建 axios 实例 // Logan.Li
|
// 创建 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
|
// 请求拦截器:注入 Bearer token // Logan.Li
|
||||||
request.interceptors.request.use(
|
request.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
@@ -26,6 +47,10 @@ request.interceptors.request.use(
|
|||||||
// 响应拦截器:统一处理 code 与 HTTP 错误 // Logan.Li
|
// 响应拦截器:统一处理 code 与 HTTP 错误 // Logan.Li
|
||||||
request.interceptors.response.use(
|
request.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
|
// 文件下载(blob)直接放行,避免被业务码拦截 // Logan.Li
|
||||||
|
if (['blob', 'arraybuffer'].includes(response.config.responseType)) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
const res = response.data
|
const res = response.data
|
||||||
|
|
||||||
// code 为 0 或 200 视为成功 // Logan.Li
|
// code 为 0 或 200 视为成功 // Logan.Li
|
||||||
@@ -33,53 +58,40 @@ request.interceptors.response.use(
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
// 401:登录态失效,引导重新登录 // Logan.Li
|
// 401:登录态失效 // Logan.Li
|
||||||
if (res.code === 401) {
|
if (res.code === 401) {
|
||||||
ElMessageBox.confirm('登录状态已过期,请重新登录', '系统提示', {
|
return forceReLogin()
|
||||||
confirmButtonText: '重新登录',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning',
|
|
||||||
}).then(() => {
|
|
||||||
const userStore = useUserStore()
|
|
||||||
userStore.logout()
|
|
||||||
window.location.href = '/login'
|
|
||||||
})
|
|
||||||
return Promise.reject(new Error(res.message || '未授权'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其他业务错误:仅错误用 ElMessage 提示(常规操作不打扰) // Logan.Li
|
// 其他业务错误:仅错误用 ElMessage 提示(常规操作不打扰) // Logan.Li
|
||||||
ElMessage.error(res.message || '请求失败')
|
ElMessage.error(res.message || '请求失败')
|
||||||
return Promise.reject(new Error(res.message || 'Error'))
|
return Promise.reject(new Error(res.message || 'Error'))
|
||||||
},
|
},
|
||||||
(error) => {
|
async (error) => {
|
||||||
let message = '网络错误'
|
if (error.response?.status === 401) {
|
||||||
|
return forceReLogin()
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = '网络错误'
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
switch (error.response.status) {
|
switch (error.response.status) {
|
||||||
case 400:
|
case 400:
|
||||||
message = error.response.data?.message || '请求参数错误'
|
message = error.response.data?.message || '请求参数错误'
|
||||||
break
|
break
|
||||||
case 401:
|
|
||||||
message = '未授权,请重新登录'
|
|
||||||
{
|
|
||||||
const userStore = useUserStore()
|
|
||||||
userStore.logout()
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = '/login'
|
|
||||||
}, 1500)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 403:
|
case 403:
|
||||||
message = '拒绝访问'
|
message = '拒绝访问'
|
||||||
break
|
break
|
||||||
case 404:
|
case 404:
|
||||||
message = '请求的资源不存在'
|
message = '请求的资源不存在'
|
||||||
break
|
break
|
||||||
|
case 429:
|
||||||
|
message = '请求过于频繁,请稍后再试'
|
||||||
|
break
|
||||||
case 500:
|
case 500:
|
||||||
message = '服务器内部错误'
|
message = '服务器内部错误'
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
message = `连接错误 ${error.response.status}`
|
message = error.response.data?.message || `连接错误 ${error.response.status}`
|
||||||
}
|
}
|
||||||
} else if (error.request) {
|
} else if (error.request) {
|
||||||
message = '网络连接失败,请检查网络'
|
message = '网络连接失败,请检查网络'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
|||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(ElementPlus, { locale: undefined }) // 默认中文
|
app.use(ElementPlus, { locale: zhCn })
|
||||||
|
|
||||||
// 从 localStorage 恢复登录状态 // Logan.Li
|
// 从 localStorage 恢复登录状态 // Logan.Li
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|||||||
@@ -41,8 +41,6 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div class="login-tip">默认账号:admin / admin</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -60,8 +58,8 @@ const userStore = useUserStore()
|
|||||||
const loginFormRef = ref(null)
|
const loginFormRef = ref(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loginForm = reactive({
|
const loginForm = reactive({
|
||||||
username: 'admin',
|
username: '',
|
||||||
password: 'admin',
|
password: '',
|
||||||
})
|
})
|
||||||
const loginRules = {
|
const loginRules = {
|
||||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||||
@@ -117,10 +115,4 @@ async function handleLogin() {
|
|||||||
color: #909399;
|
color: #909399;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.login-tip {
|
|
||||||
text-align: center;
|
|
||||||
color: #c0c4cc;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
Reference in new issue
Block a user