diff --git a/.env.example b/.env.example index b6de962..30f9b7e 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,11 @@ GIN_MODE=release # JWT 密钥(生产环境必须修改!) JWT_SECRET=your-super-secret-key-change-this-in-production +# 离线许可证签发 seed(32 字节 hex)。生产必须固定;轮换需同步桌面公钥。 +# 生成示例: openssl rand -hex 32 +# 当前生产公钥(桌面已嵌入): 108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d +# OFFLINE_LICENSE_SEED= + # 数据库配置 # 可选: sqlite / mysql / postgres DB_DRIVER=sqlite @@ -52,13 +57,27 @@ WAFFO_PRODUCT_ID_PERPETUAL=PROD_0pcnzzT4nalEkkcdyIUoD5 WAFFO_CURRENCY=CNY WAFFO_CURRENCY_YEARLY=CNY WAFFO_CURRENCY_PERPETUAL=CNY -# test | live(test 时自动附加 test=true) +# test | live(test 时自动附加 test=true;API Key 会话由 Dashboard 环境决定) WAFFO_ENVIRONMENT=test # 支付成功回跳(桌面端 deep link;CreateOrder/Checkout 的 return_url 可覆盖) -SUCCESS_URL=http://l123.91ai.io/api/v1/purchase/success -# PEM 私钥仅保存在服务端,切勿下发到客户端或写入仓库 +SUCCESS_URL=https://l123.91ai.io/api/v1/purchase/success + +# --- 官方打通必填(API Key 模式,见 https://docs.waffo.ai/zh/integrate/ai-integration)--- +# Dashboard → Settings → API Keys:Merchant ID + RSA Private Key +# 有这两项时走 POST /v1/actions/checkout/create-session,并把本系统 ORD-* 写入 orderMerchantExternalId +# 没有则回退到 hosted store URL(无法可靠对账,仅调试用) +# WAFFO_MERCHANT_ID=mch_xxx +# 私钥 PEM(可用真实换行,或把换行写成 \n);勿提交仓库 +# WAFFO_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY----- +# 或 Base64 整段 PEM: +# WAFFO_PRIVATE_KEY_BASE64= +# 兼容旧名: # WAFFO_PRIVATE_KEY_PEM= +# Dashboard → Settings → Webhooks: +# URL: https://你的域名/api/v1/webhooks/waffo (尽量 HTTPS;Raw JSON;事件 order.completed) +# 当前公网: https://l123.91ai.io/api/v1/webhooks/waffo + # ============================================ # Docker Compose # ============================================ diff --git a/README.md b/README.md index fbb0be8..aa64d7a 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,91 @@ # opsx-licence -许可证(License)管理服务器 Web 版 —— 基于 Go (Gin) + Vue 3 的单体全栈应用。 +通用许可证(License)管理服务器 —— Go (Gin) + Vue 3 单体全栈,面向 Web / PC / 移动等多端产品。 -## 项目简介 +线上示例:https://l123.91ai.io/ -opsx-licence 是一个统一的授权验证与管理服务,提供许可证的生成、验证、激活、机器绑定、心跳等核心能力,并支持产品定价、订单购买、防重复购买与支付回调。 +## 能力概览 -技术栈与工程结构参考 [opsx-web-lite](../opsx-web-lite),业务领域参考 Licence 服务器 Web版。 +| 模块 | 说明 | +|------|------| +| 产品 / 计划 / 客户 | 多产品 SKU;计划显示「体验 / 包年 / 永久」并带所属产品 | +| 在线 License | `LIC-…`,座位激活、心跳、吊销 | +| 离线 License | `LICOFF-…`,绑 device_id,最长 365 天,**不可续期(到期重签)** | +| 订单 / Waffo | create-session + webhook 对账;管理端「支付」页配置与调试 | +| 仪表盘 | 总数 / 过期 / 在线设备 / 增长 / 收入 / 产品与地理·平台分布 | + +详细模型见 [docs/licensing-model.md](docs/licensing-model.md);Waffo 闭环见 [docs/waffo-integration.md](docs/waffo-integration.md)。 ## 技术栈 -### 后端 -- **语言/框架**:Go 1.24 + Gin -- **ORM**:GORM(默认 SQLite,支持 MySQL / PostgreSQL) -- **认证**:JWT(HS256)+ bcrypt -- **架构**:4 层分层(controller → service → dto → model),按业务域分包 - -### 前端 -- **框架**:Vue 3.4 + Vite -- **UI**:Element Plus -- **状态/路由**:Pinia + Vue Router 4 -- **图表**:ECharts - -### 部署 -- 单二进制:前端通过 `//go:embed` 打包进 Go 二进制,一个文件即可部署 +- **后端**:Go 1.24 + Gin + GORM(默认 SQLite,可 MySQL / PostgreSQL) +- **前端**:Vue 3.4 + Vite + Element Plus + ECharts(`//go:embed` 打进单二进制) +- **认证**:JWT + bcrypt ## 快速开始 -### 环境要求 -- Go >= 1.24 -- Node.js >= 20 -- CGO(SQLite 驱动需要,macOS 自带 gcc) - -### 一键启动(开发模式) - ```bash cp .env.example .env -./run.sh start +./run.sh start # 开发:前端 :5181,后端 :9001 +# 默认账号 admin / admin(生产请改密码) ``` -- 前端:http://localhost:5181 -- 后端:http://localhost:9001 -- 默认账号:`admin` / `admin` - -### Docker 部署(生产推荐) +### Docker(生产推荐) ```bash cp .env.example .env -# 修改 JWT_SECRET、Waffo 等 -./run.sh docker up # 等价于 docker compose up -d --build +# 配置 JWT_SECRET、Waffo、OFFLINE_LICENSE_SEED 等 +./run.sh docker up # 或 ./run.sh docker rebuild ./run.sh docker logs -./run.sh docker down ``` - 访问:http://localhost:9001(管理后台 + `/api`) -- 数据卷:`licence_data` → 容器内 `/data/db.sqlite3` +- 数据卷:`licence_data` → `/data/db.sqlite3` -### 构建单二进制 +### 单二进制 ```bash -./run.sh build -./run.sh binary start +./run.sh build && ./run.sh binary start ``` +## 管理端菜单 + +- **授权管理**:产品、客户、在线 License、离线 License +- **定价与订单**:定价计划、订单 +- **支付**:Waffo 集成(商户 / Store / 可扩展 Product ID 映射 / 测试连接) +- **审计与设置**:操作日志、系统设置 + +## 客户端契约(公开 API) + +前缀 `/api/v1`(无需管理 JWT): + +- `POST /license/activate|verify|deactivate|heartbeat` +- `POST /checkout`、`POST /orders`、`POST /purchase/claim` +- `POST /webhooks/waffo`、`GET /purchase/success` + +桌面端默认 API Base:`https://l123.91ai.io/api`(可用 `OPSX_LICENSE_API_BASE` 覆盖)。 + ## 常用命令 | 命令 | 说明 | -|---|---| -| `make dev` / `./run.sh start` | 启动前后端开发服务 | -| `make build` / `./run.sh build` | 构建嵌入前端的单二进制 | -| `./run.sh stop` | 停止服务 | -| `./run.sh status` | 查看服务状态 | -| `make clean` | 清理构建产物 | +|------|------| +| `./run.sh start` / `make dev` | 开发模式 | +| `./run.sh build` / `make build` | 构建嵌入前端的二进制 | +| `./run.sh docker rebuild` | 重建并滚动升级容器 | +| `./run.sh stop` / `status` | 停止 / 查看状态 | ## 项目结构 ``` opsx-licence/ -├── backend/ # Go 后端 -│ ├── main.go # 入口 -│ ├── embed.go # 前端嵌入 -│ ├── config/ # 配置 -│ ├── database/ # 数据库初始化 + 种子数据 -│ ├── middleware/ # 中间件(auth / cors) -│ ├── router/ # 路由 -│ ├── utils/ # 工具(jwt / password) -│ └── internal/ # 业务分层 -│ ├── controller/ # HTTP 处理层 -│ ├── service/ # 业务逻辑层 -│ ├── dto/ # 请求/响应结构 -│ └── model/ # 数据模型 -├── frontend/ # Vue 前端 -│ └── src/ -│ ├── api/ # 接口封装 -│ ├── stores/ # Pinia 状态 -│ ├── router/ # 路由 -│ ├── layouts/ # 布局 -│ └── views/ # 页面 -├── Makefile -├── run.sh # 启动脚本 +├── backend/ # Go API + embed 前端 +├── frontend/ # Vue 管理后台 +├── docs/ # 授权模型 / Waffo 集成说明 +├── docker-compose.yml +├── run.sh └── .env.example ``` -## 业务域(规划) - -| 模块 | 说明 | 状态 | -|---|---|---| -| 认证 (Auth) | 管理员登录、JWT | ✅ | -| 核心授权 | 产品 / 客户 / License / 激活 | 🚧 规划中 | -| 定价订单 | Plan / Order / 支付网关 | 🚧 规划中 | -| 防重复购买 | 客户权益 / 幂等回调 | 🚧 规划中 | -| 分析审计 | Dashboard / Analytics / AuditLog | 🚧 规划中 | - ## 作者 Logan.Li diff --git a/backend/config/config.go b/backend/config/config.go index 52fad4c..c310841 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -27,6 +27,9 @@ type Config struct { // JWT 配置 JWTSecret string + // 离线许可证 Ed25519 seed(32 字节 hex;生产必须固定,勿轮换除非同步桌面公钥) // Logan.Li + OfflineLicenseSeed string + // CORS 配置 CORSOrigins []string } @@ -47,8 +50,9 @@ func Load() *Config { DBUser: getEnv("DB_USER", "root"), DBPassword: getEnv("DB_PASSWORD", ""), DBName: getEnv("DB_NAME", "db.sqlite3"), - JWTSecret: getEnv("JWT_SECRET", ""), - CORSOrigins: defaultCORSOrigins(), + JWTSecret: getEnv("JWT_SECRET", ""), + OfflineLicenseSeed: getEnv("OFFLINE_LICENSE_SEED", ""), + CORSOrigins: defaultCORSOrigins(), } if raw := strings.TrimSpace(os.Getenv("CORS_ORIGINS")); raw != "" { diff --git a/backend/database/seed.go b/backend/database/seed.go index c1268b8..60609ee 100644 --- a/backend/database/seed.go +++ b/backend/database/seed.go @@ -114,7 +114,19 @@ func seedSampleData(db *gorm.DB) error { plans := []model.Plan{ { ProductID: product.ID, - Name: "Yearly", + Name: "体验", + PlanType: "trial", + Price: 0, + Currency: "CNY", + Duration: 180, + MaxDevices: 1, + Features: `["专业功能","体验","1 设备"]`, + SortOrder: 0, + Status: 1, + }, + { + ProductID: product.ID, + Name: "包年", PlanType: "yearly", Price: 99, Currency: "CNY", @@ -126,7 +138,7 @@ func seedSampleData(db *gorm.DB) error { }, { ProductID: product.ID, - Name: "Perpetual", + Name: "永久", PlanType: "perpetual", Price: 199, Currency: "CNY", @@ -136,18 +148,6 @@ func seedSampleData(db *gorm.DB) error { SortOrder: 2, Status: 1, }, - { - ProductID: product.ID, - Name: "Trial 180d", - PlanType: "trial", - Price: 0, - Currency: "USD", - Duration: 180, - MaxDevices: 1, - Features: `["专业功能","半年体验","1 设备"]`, - SortOrder: 0, - Status: 1, - }, } for i := range plans { if err := db.Create(&plans[i]).Error; err != nil { @@ -232,9 +232,9 @@ func ensureOpsXShellPlanPricing(db *gorm.DB) error { sortOrder int } updates := []planSpec{ - {"yearly", "Yearly", 99, "CNY", 365, 2, "[\"专业功能\",\"包年\",\"2 设备\"]", 1}, - {"perpetual", "Perpetual", 199, "CNY", 0, 5, "[\"专业功能\",\"永久授权\",\"5 设备\"]", 2}, - {"trial", "Trial 180d", 0, "CNY", 180, 1, "[\"专业功能\",\"半年体验\",\"1 设备\"]", 0}, + {"trial", "体验", 0, "CNY", 180, 1, "[\"专业功能\",\"体验\",\"1 设备\"]", 0}, + {"yearly", "包年", 99, "CNY", 365, 2, "[\"专业功能\",\"包年\",\"2 设备\"]", 1}, + {"perpetual", "永久", 199, "CNY", 0, 5, "[\"专业功能\",\"永久授权\",\"5 设备\"]", 2}, } for _, u := range updates { var plan model.Plan diff --git a/backend/internal/controller/analytics/analytics_controller.go b/backend/internal/controller/analytics/analytics_controller.go index e375ec3..2166958 100644 --- a/backend/internal/controller/analytics/analytics_controller.go +++ b/backend/internal/controller/analytics/analytics_controller.go @@ -90,3 +90,33 @@ func (ctrl *AnalyticsController) GetProductDistribution(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"code": 0, "data": stats}) } + +// GetGeoDistribution 地理分布 // Logan.Li +func (ctrl *AnalyticsController) GetGeoDistribution(c *gin.Context) { + stats, err := ctrl.svc.GetGeoDistribution() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": stats}) +} + +// GetPlatformDistribution 客户端形态分布 // Logan.Li +func (ctrl *AnalyticsController) GetPlatformDistribution(c *gin.Context) { + stats, err := ctrl.svc.GetPlatformDistribution() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": stats}) +} + +// GetLicenseGrowthDetail 增长明细(累计+新增) // Logan.Li +func (ctrl *AnalyticsController) GetLicenseGrowthDetail(c *gin.Context) { + stats, err := ctrl.svc.GetLicenseGrowthDetail() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": stats}) +} diff --git a/backend/internal/controller/license/license_controller.go b/backend/internal/controller/license/license_controller.go index 056a23a..b2932fb 100644 --- a/backend/internal/controller/license/license_controller.go +++ b/backend/internal/controller/license/license_controller.go @@ -1,6 +1,8 @@ package license import ( + "backend/config" + "backend/internal/dto" licenseservice "backend/internal/service/license" "backend/utils" "net/http" @@ -12,13 +14,18 @@ import ( // LicenseController License 控制器 // Logan.Li type LicenseController struct { - svc *licenseservice.LicenseService - db *gorm.DB + svc *licenseservice.LicenseService + offline *licenseservice.OfflineIssuer + db *gorm.DB } // NewLicenseController 创建控制器实例 // Logan.Li -func NewLicenseController(db *gorm.DB) *LicenseController { - return &LicenseController{svc: licenseservice.NewLicenseService(db), db: db} +func NewLicenseController(db *gorm.DB, cfg *config.Config) *LicenseController { + return &LicenseController{ + svc: licenseservice.NewLicenseService(db), + offline: licenseservice.NewOfflineIssuer(db, cfg), + db: db, + } } // parseListQuery 从请求解析筛选参数 // Logan.Li @@ -26,6 +33,7 @@ func parseListQuery(c *gin.Context) licenseservice.ListQuery { q := licenseservice.ListQuery{ Keyword: c.Query("keyword"), Status: c.Query("status"), + Channel: c.Query("channel"), } if v, err := strconv.ParseUint(c.Query("customer_id"), 10, 32); err == nil { q.CustomerID = uint(v) @@ -65,16 +73,7 @@ func (ctrl *LicenseController) Get(c *gin.Context) { // Create 创建 License // Logan.Li func (ctrl *LicenseController) Create(c *gin.Context) { - var req struct { - CustomerID uint `json:"customer_id" binding:"required"` - ProductID uint `json:"product_id" binding:"required"` - Duration int `json:"duration" binding:"required"` - MaxActivations int `json:"max_activations"` - IsTrial bool `json:"is_trial"` - TrialDays int `json:"trial_days"` - Notes string `json:"notes"` - Count int `json:"count"` - } + var req dto.LicenseCreate if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()}) return @@ -89,6 +88,36 @@ func (ctrl *LicenseController) Create(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "message": "创建成功", "data": licenses}) } +// IssueOffline 签发离线许可证序列号(最长 365 天,绑定 device_id) // Logan.Li +func (ctrl *LicenseController) IssueOffline(c *gin.Context) { + var req dto.OfflineLicenseCreate + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()}) + return + } + result, err := ctrl.offline.Issue(req) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()}) + return + } + utils.LogAction(ctrl.db, c, "create_offline", "license", result.LicenseID, result.Serial, "", "", + "签发离线证 device="+result.DeviceID) + c.JSON(http.StatusOK, gin.H{"code": 0, "message": "离线许可证已生成", "data": result}) +} + +// OfflinePublicKey 返回当前离线验签公钥(供运维核对桌面嵌入值) // Logan.Li +func (ctrl *LicenseController) OfflinePublicKey(c *gin.Context) { + pub := "" + if ctrl.offline != nil { + pub = ctrl.offline.PublicKeyHex() + } + if pub == "" { + c.JSON(http.StatusOK, gin.H{"code": 1, "message": "未配置 OFFLINE_LICENSE_SEED"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"public_key_hex": pub}}) +} + // Update 更新 License // Logan.Li func (ctrl *LicenseController) Update(c *gin.Context) { id, _ := strconv.ParseUint(c.Param("id"), 10, 32) diff --git a/backend/internal/controller/order/order_controller.go b/backend/internal/controller/order/order_controller.go index 4c9de81..22588dd 100644 --- a/backend/internal/controller/order/order_controller.go +++ b/backend/internal/controller/order/order_controller.go @@ -310,15 +310,16 @@ func renderSimplePage(title, headline, body, extraHTML, deepLink string) string ` } -// WaffoWebhook accepts loosely-shaped Waffo payment events and maps to PaymentWebhook. // Logan.Li +// WaffoWebhook maps official Waffo Raw JSON webhooks (order.completed etc.) to PaymentWebhook. +// Prefer data.orderMerchantExternalId (= our ORD-*) for matching; also accept legacy shapes. // Logan.Li func (ctrl *OrderController) WaffoWebhook(c *gin.Context) { body, err := io.ReadAll(c.Request.Body) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "无法读取请求体"}) return } - // Optional: accept X-Waffo-Signature if present (no strict verification yet). // Logan.Li - _ = c.GetHeader("X-Waffo-Signature") + sigHeader := c.GetHeader("X-Waffo-Signature") + _ = sigHeader // optional RSA verify when WAFFO_WEBHOOK_PUBLIC_KEY is set later // Logan.Li var payload map[string]interface{} if len(body) > 0 { @@ -330,79 +331,93 @@ func (ctrl *OrderController) WaffoWebhook(c *gin.Context) { payload = map[string]interface{}{} } - orderNo := firstNonEmpty( + data, _ := payload["data"].(map[string]interface{}) + metaTop, _ := payload["metadata"].(map[string]interface{}) + metaOrder, _ := data["orderMetadata"].(map[string]interface{}) + metaLegacy, _ := data["metadata"].(map[string]interface{}) + + // Official: orderMerchantExternalId is our ORD-*; orderId is Waffo ORD_*. // Logan.Li + merchantExternalID := firstNonEmpty( + asString(data["orderMerchantExternalId"]), + asString(payload["orderMerchantExternalId"]), + asString(metaOrder["orderNo"]), + asString(metaOrder["order_no"]), + asString(metaLegacy["orderNo"]), + asString(metaLegacy["order_no"]), + asString(metaTop["orderNo"]), + asString(metaTop["order_no"]), asString(payload["order_no"]), asString(payload["orderNo"]), - asString(payload["order_id"]), c.Query("order_no"), c.Query("orderNo"), ) - if orderNo == "" { - if meta, ok := payload["metadata"].(map[string]interface{}); ok { - orderNo = firstNonEmpty(asString(meta["orderNo"]), asString(meta["order_no"])) - } - } - if orderNo == "" { - if data, ok := payload["data"].(map[string]interface{}); ok { - orderNo = firstNonEmpty(asString(data["order_no"]), asString(data["orderNo"])) - if orderNo == "" { - if meta, ok := data["metadata"].(map[string]interface{}); ok { - orderNo = firstNonEmpty(asString(meta["orderNo"]), asString(meta["order_no"])) - } - } - } - } - if orderNo == "" { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "missing order_no"}) - return - } + gatewayOrderNo := firstNonEmpty( + asString(data["orderId"]), + asString(payload["orderId"]), + asString(payload["gateway_order_no"]), + asString(payload["gatewayOrderNo"]), + asString(payload["id"]), + ) + paymentID := firstNonEmpty(asString(data["paymentId"]), asString(payload["paymentId"])) + buyerEmail := firstNonEmpty(asString(data["buyerEmail"]), asString(payload["buyerEmail"]), asString(payload["customer_email"])) eventType := firstNonEmpty( - asString(payload["event_type"]), + c.GetHeader("X-Waffo-Event"), asString(payload["eventType"]), + asString(payload["event_type"]), asString(payload["type"]), asString(payload["event"]), "payment", ) statusRaw := strings.ToLower(firstNonEmpty( + asString(data["orderStatus"]), + asString(data["paymentStatus"]), asString(payload["status"]), asString(payload["payment_status"]), asString(payload["paymentStatus"]), )) - gatewayOrderNo := firstNonEmpty( - asString(payload["gateway_order_no"]), - asString(payload["gatewayOrderNo"]), - asString(payload["transaction_id"]), - asString(payload["transactionId"]), - asString(payload["id"]), - ) mappedStatus := "paid" switch { - case strings.Contains(statusRaw, "fail"), strings.Contains(statusRaw, "cancel"), - strings.Contains(eventType, "fail"), strings.Contains(eventType, "cancel"): - mappedStatus = "failed" - case strings.Contains(statusRaw, "refund"), strings.Contains(eventType, "refund"): + case strings.Contains(strings.ToLower(eventType), "refund"), strings.Contains(statusRaw, "refund"): mappedStatus = "refunded" - case statusRaw == "" || strings.Contains(statusRaw, "paid") || strings.Contains(statusRaw, "complete") || - strings.Contains(statusRaw, "success") || strings.Contains(eventType, "complete") || - strings.Contains(eventType, "paid") || strings.Contains(eventType, "success"): + case strings.Contains(strings.ToLower(eventType), "fail"), strings.Contains(strings.ToLower(eventType), "cancel"), + strings.Contains(statusRaw, "fail"), strings.Contains(statusRaw, "cancel"): + 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, "success") || strings.Contains(statusRaw, "succeed"): mappedStatus = "paid" default: - // Treat unknown completed-like events as paid for now. // Logan.Li mappedStatus = "paid" } + // Ignore non-terminal noise if event is clearly not a completion (e.g. order.created). // Logan.Li + et := strings.ToLower(eventType) + if strings.Contains(et, "order.created") || strings.Contains(et, "order.pending") || + strings.Contains(et, "payment.pending") || strings.Contains(et, "checkout.session") { + c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ignored event"}) + return + } + + if merchantExternalID == "" && gatewayOrderNo == "" && buyerEmail == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "missing orderMerchantExternalId / orderId"}) + return + } + req := dto.PaymentWebhookRequest{ EventType: eventType, - OrderNo: orderNo, - GatewayOrderNo: gatewayOrderNo, + OrderNo: merchantExternalID, + GatewayOrderNo: firstNonEmpty(gatewayOrderNo, paymentID), Status: mappedStatus, + CustomerEmail: buyerEmail, Gateway: "waffo", Payload: string(body), - Signature: c.GetHeader("X-Waffo-Signature"), + Signature: sigHeader, } if err := ctrl.svc.PaymentWebhook(req, utils.GetClientIP(c)); err != nil { + // Still 200 so Waffo does not retry forever on business errors; log via message. // Logan.Li c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()}) return } diff --git a/backend/internal/controller/setting/setting_controller.go b/backend/internal/controller/setting/setting_controller.go index 49ee005..cc083c2 100644 --- a/backend/internal/controller/setting/setting_controller.go +++ b/backend/internal/controller/setting/setting_controller.go @@ -1,6 +1,7 @@ package setting import ( + "backend/internal/dto" "backend/internal/model" settingservice "backend/internal/service/setting" "net/http" @@ -32,14 +33,7 @@ func (ctrl *SettingController) Get(c *gin.Context) { // Update 更新系统设置 // Logan.Li func (ctrl *SettingController) Update(c *gin.Context) { - var req struct { - SystemName *string `json:"system_name"` - DefaultDuration *int `json:"default_duration"` - DefaultMaxActivations *int `json:"default_max_activations"` - ExpireReminderDays *int `json:"expire_reminder_days"` - APIRateLimit *int `json:"api_rate_limit"` - LogRetentionDays *int `json:"log_retention_days"` - } + var req dto.SettingUpdate if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return @@ -52,6 +46,53 @@ func (ctrl *SettingController) Update(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "message": "更新成功", "data": setting}) } +// GetWaffo 获取 Waffo 支付集成配置 // Logan.Li +func (ctrl *SettingController) GetWaffo(c *gin.Context) { + data, err := ctrl.svc.GetWaffo() + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": data}) +} + +// UpdateWaffo 更新 Waffo 支付集成配置 // Logan.Li +func (ctrl *SettingController) UpdateWaffo(c *gin.Context) { + var req dto.WaffoSettingsUpdate + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + data, err := ctrl.svc.UpdateWaffo(req) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "message": "Waffo 配置已保存", "data": data}) +} + +// DebugWaffo 用当前配置探测 Waffo create-session(不回显私钥) // Logan.Li +func (ctrl *SettingController) DebugWaffo(c *gin.Context) { + var req dto.WaffoDebugRequest + _ = c.ShouldBindJSON(&req) + data := ctrl.svc.DebugWaffo(req) + msg := "调试完成" + code := 0 + if data == nil { + c.JSON(http.StatusOK, gin.H{"code": 1, "message": "调试失败"}) + return + } + if !data.OK { + code = 1 + if data.ErrorSummary != "" { + msg = data.ErrorSummary + } else { + msg = "调试失败" + } + } + c.JSON(http.StatusOK, gin.H{"code": code, "message": msg, "data": data}) +} + // ListAuditLogs 审计日志列表 // Logan.Li func (ctrl *SettingController) ListAuditLogs(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) diff --git a/backend/internal/dto/analytics.go b/backend/internal/dto/analytics.go index f55be8a..1dfdfad 100644 --- a/backend/internal/dto/analytics.go +++ b/backend/internal/dto/analytics.go @@ -2,14 +2,30 @@ package dto // DashboardStats 仪表板统计响应 // Logan.Li type DashboardStats struct { - TotalLicenses int64 `json:"total_licenses"` - ActiveLicenses int64 `json:"active_licenses"` - NewThisMonth int64 `json:"new_this_month"` - ExpiringSoon int64 `json:"expiring_soon"` - TotalCustomers int64 `json:"total_customers"` - TotalProducts int64 `json:"total_products"` - OnlineDevices int64 `json:"online_devices"` - MonthlyRevenue float64 `json:"monthly_revenue"` + TotalLicenses int64 `json:"total_licenses"` + ActiveLicenses int64 `json:"active_licenses"` + ExpiredLicenses int64 `json:"expired_licenses"` + OfflineLicenses int64 `json:"offline_licenses"` + OnlineLicenses int64 `json:"online_licenses"` + NewThisMonth int64 `json:"new_this_month"` + ExpiringSoon int64 `json:"expiring_soon"` + TotalCustomers int64 `json:"total_customers"` + TotalProducts int64 `json:"total_products"` + OnlineDevices int64 `json:"online_devices"` // 当前在线用户/设备(5 分钟心跳) + MonthlyRevenue float64 `json:"monthly_revenue"` + TotalRevenue float64 `json:"total_revenue"` +} + +// GeoDistribution 地理/网络分布(基于激活 IP / device_info) // Logan.Li +type GeoDistribution struct { + Region string `json:"region"` + Count int64 `json:"count"` +} + +// PlatformDistribution 客户端形态分布(web / pc / mobile / unknown) // Logan.Li +type PlatformDistribution struct { + Platform string `json:"platform"` + Count int64 `json:"count"` } // ExpiringLicenseItem 即将到期 License // Logan.Li @@ -51,6 +67,13 @@ type ProductDistribution struct { Count int64 `json:"count"` } +// LicenseGrowthDetailPoint License 增长明细(累计 + 当月新增) // Logan.Li +type LicenseGrowthDetailPoint struct { + Date string `json:"date"` + Cumulative int64 `json:"cumulative"` + Created int64 `json:"created"` +} + // SettingUpdate 更新系统设置请求 // Logan.Li type SettingUpdate struct { SystemName *string `json:"system_name"` @@ -61,6 +84,74 @@ type SettingUpdate struct { LogRetentionDays *int `json:"log_retention_days"` } +// WaffoProductMapping 计划 key → Waffo Product ID(前端 Payments 可扩展表格) // Logan.Li +type WaffoProductMapping struct { + PlanKey string `json:"plan_key"` + Label string `json:"label,omitempty"` + ProductID string `json:"product_id"` +} + +// WaffoSettingsResponse 管理端 Waffo 配置(私钥不回显) // Logan.Li +type WaffoSettingsResponse struct { + Enabled bool `json:"enabled"` + MerchantID string `json:"merchant_id"` + StoreID string `json:"store_id"` + StoreSlug string `json:"store_slug"` + Environment string `json:"environment"` // test | live + SandboxMode bool `json:"sandbox_mode"` + Currency string `json:"currency"` + ProductID string `json:"product_id"` + ProductIDYearly string `json:"product_id_yearly"` + ProductIDPerpetual string `json:"product_id_perpetual"` + ProductIDs map[string]string `json:"product_ids"` // 可扩展 plan_type → PROD_xxx // Logan.Li + ProductMappings []WaffoProductMapping `json:"product_mappings"` // 与前端表格对齐 // Logan.Li + SuccessURL string `json:"success_url"` + WebhookURL string `json:"webhook_url"` + PrivateKeyConfigured bool `json:"private_key_configured"` + SourceHint string `json:"source_hint"` // db | env | mixed +} + +// WaffoSettingsUpdate 管理端更新 Waffo 配置;private_key 空=保留原值 // Logan.Li +type WaffoSettingsUpdate struct { + Enabled *bool `json:"enabled"` + MerchantID string `json:"merchant_id"` + StoreID string `json:"store_id"` + StoreSlug string `json:"store_slug"` + Environment string `json:"environment"` // test | live | prod + Currency string `json:"currency"` + ProductID string `json:"product_id"` + ProductIDYearly string `json:"product_id_yearly"` + ProductIDPerpetual string `json:"product_id_perpetual"` + ProductIDs map[string]string `json:"product_ids"` // 可扩展映射;与 yearly/perpetual 合并 // Logan.Li + ProductMappings []WaffoProductMapping `json:"product_mappings"` // 前端表格主数据源 // Logan.Li + SuccessURL string `json:"success_url"` + PrivateKey string `json:"private_key"` // 仅写入;GET 永不返回 + ClearPrivateKey bool `json:"clear_private_key"` +} + +// WaffoDebugRequest 用当前配置探测 create-session(可覆盖 plan/product) // Logan.Li +type WaffoDebugRequest struct { + PlanType string `json:"plan_type"` // yearly | perpetual | …,默认 yearly + ProductID string `json:"product_id"` // 可选覆盖 + BuyerEmail string `json:"buyer_email"` // 可选 +} + +// WaffoDebugResponse 调试结果摘要(绝不含私钥) // Logan.Li +type WaffoDebugResponse struct { + OK bool `json:"ok"` + AuthMode string `json:"auth_mode"` // api_key | store_slug | none + Environment string `json:"environment"` + PlanType string `json:"plan_type"` + ProductIDUsed string `json:"product_id_used"` + HTTPStatus int `json:"http_status,omitempty"` + SessionID string `json:"session_id,omitempty"` + CheckoutURLHost string `json:"checkout_url_host,omitempty"` // 仅 host,避免整链噪音 + ErrorSummary string `json:"error_summary,omitempty"` + PrivateKeyPresent bool `json:"private_key_present"` + MerchantIDPresent bool `json:"merchant_id_present"` + StoreSlugPresent bool `json:"store_slug_present"` +} + // AuditLogResponse 操作日志响应 // Logan.Li type AuditLogResponse struct { ID uint `json:"id"` diff --git a/backend/internal/dto/commerce.go b/backend/internal/dto/commerce.go index 07095ed..c683d70 100644 --- a/backend/internal/dto/commerce.go +++ b/backend/internal/dto/commerce.go @@ -28,17 +28,18 @@ type PlanUpdate struct { // PlanResponse 定价计划响应 // Logan.Li type PlanResponse struct { - ID uint `json:"id"` - ProductID uint `json:"product_id"` - Name string `json:"name"` - PlanType string `json:"plan_type"` - Price float64 `json:"price"` - Currency string `json:"currency"` - Duration int `json:"duration"` - MaxDevices int `json:"max_devices"` - Features []string `json:"features"` - SortOrder int `json:"sort_order"` - Status int `json:"status"` + ID uint `json:"id"` + ProductID uint `json:"product_id"` + ProductName string `json:"product_name"` + Name string `json:"name"` + PlanType string `json:"plan_type"` + Price float64 `json:"price"` + Currency string `json:"currency"` + Duration int `json:"duration"` + MaxDevices int `json:"max_devices"` + Features []string `json:"features"` + SortOrder int `json:"sort_order"` + Status int `json:"status"` } // OrderResponse 订单响应 // Logan.Li diff --git a/backend/internal/dto/license.go b/backend/internal/dto/license.go index 696caf4..a1f0188 100644 --- a/backend/internal/dto/license.go +++ b/backend/internal/dto/license.go @@ -4,7 +4,7 @@ package dto type LicenseCreate struct { CustomerID uint `json:"customer_id" binding:"required"` ProductID uint `json:"product_id" binding:"required"` - Duration int `json:"duration" binding:"required"` // 30/90/180/365/730/1095/1825/0(永久) + Duration int `json:"duration"` // 30/90/180/365/…/0(永久);0 合法,勿用 binding:required MaxActivations int `json:"max_activations"` IsTrial bool `json:"is_trial"` TrialDays int `json:"trial_days"` @@ -26,6 +26,10 @@ type LicenseRevoke struct { type LicenseResponse struct { ID uint `json:"id"` LicenseKey string `json:"license_key"` + LicenseKeyShort string `json:"license_key_short"` // 列表展示用短码,避免 LICOFF 撑爆表格 // Logan.Li + Channel string `json:"channel"` // online | offline + IsOffline bool `json:"is_offline"` + DeviceID string `json:"device_id,omitempty"` // 离线证绑定设备 CustomerID uint `json:"customer_id"` CustomerName string `json:"customer_name"` ProductID uint `json:"product_id"` @@ -102,3 +106,31 @@ type ActivationResponse struct { ActivatedAt string `json:"activated_at"` LastSeenAt string `json:"last_seen_at"` } + +// OfflineLicenseCreate 管理端签发离线许可证请求 // Logan.Li +type OfflineLicenseCreate struct { + DeviceID string `json:"device_id" binding:"required"` + Email string `json:"email"` + CustomerID uint `json:"customer_id"` + CustomerName string `json:"customer_name"` + ProductCode string `json:"product_code"` + PlanType string `json:"plan_type"` // trial | yearly(禁止 perpetual) + DurationDays int `json:"duration_days"` // 1–365,默认 365;trial 上限 180 + Notes string `json:"notes"` +} + +// OfflineLicenseResponse 离线许可证签发结果 // Logan.Li +type OfflineLicenseResponse struct { + Serial string `json:"serial"` + DeviceID string `json:"device_id"` + Email string `json:"email,omitempty"` + PlanType string `json:"plan_type"` + ProductCode string `json:"product_code"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at"` + Duration int `json:"duration_days"` + MaxDevices int `json:"max_devices"` + LicenseID uint `json:"license_id"` + PublicKey string `json:"public_key_hex"` +} + diff --git a/backend/internal/model/license.go b/backend/internal/model/license.go index 5c25c79..61804bc 100644 --- a/backend/internal/model/license.go +++ b/backend/internal/model/license.go @@ -33,7 +33,7 @@ var PermanentEndDate = time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC) // License 许可证模型 // Logan.Li type License struct { ID uint `json:"id" gorm:"primaryKey"` - LicenseKey string `json:"license_key" gorm:"size:100;uniqueIndex;not null"` + LicenseKey string `json:"license_key" gorm:"size:512;uniqueIndex;not null"` // 在线 LIC-… / 离线 LICOFF-… CustomerID uint `json:"customer_id"` Customer Customer `json:"customer,omitempty" gorm:"foreignKey:CustomerID"` ProductID uint `json:"product_id"` diff --git a/backend/internal/service/analytics/analytics_service.go b/backend/internal/service/analytics/analytics_service.go index 17710bb..53a61c0 100644 --- a/backend/internal/service/analytics/analytics_service.go +++ b/backend/internal/service/analytics/analytics_service.go @@ -3,6 +3,9 @@ package analytics import ( "backend/internal/dto" "backend/internal/model" + "encoding/json" + "net" + "strings" "time" "gorm.io/gorm" @@ -21,16 +24,26 @@ func NewAnalyticsService(db *gorm.DB) *AnalyticsService { // GetDashboardStats 仪表板统计 // Logan.Li func (s *AnalyticsService) GetDashboardStats() (*dto.DashboardStats, error) { var stats dto.DashboardStats + now := time.Now() s.db.Model(&model.License{}).Count(&stats.TotalLicenses) s.db.Model(&model.License{}).Where("status = ?", model.LicenseStatusActive).Count(&stats.ActiveLicenses) - // 本月新增 // Logan.Li - now := time.Now() + // 已过期:显式 expired,或到期日已过且非永久(end < 2090) // Logan.Li + s.db.Model(&model.License{}).Where( + "status = ? OR (end_date < ? AND end_date < ? AND status NOT IN ?)", + model.LicenseStatusExpired, + now, + time.Date(2090, 1, 1, 0, 0, 0, 0, time.UTC), + []string{model.LicenseStatusRevoked}, + ).Count(&stats.ExpiredLicenses) + + s.db.Model(&model.License{}).Where("license_key LIKE ?", "LICOFF-%").Count(&stats.OfflineLicenses) + s.db.Model(&model.License{}).Where("license_key NOT LIKE ?", "LICOFF-%").Count(&stats.OnlineLicenses) + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) s.db.Model(&model.License{}).Where("created_at >= ?", monthStart).Count(&stats.NewThisMonth) - // 即将到期(30 天内) // Logan.Li thirtyDaysLater := now.AddDate(0, 0, 30) s.db.Model(&model.License{}). Where("status = ? AND end_date > ? AND end_date < ?", model.LicenseStatusActive, now, thirtyDaysLater). @@ -39,15 +52,17 @@ func (s *AnalyticsService) GetDashboardStats() (*dto.DashboardStats, error) { s.db.Model(&model.Customer{}).Count(&stats.TotalCustomers) s.db.Model(&model.Product{}).Count(&stats.TotalProducts) - // 在线设备(5 分钟内有心跳) // Logan.Li fiveMinAgo := now.Add(-5 * time.Minute) s.db.Model(&model.Activation{}).Where("is_online = ? AND last_seen_at >= ?", true, fiveMinAgo).Count(&stats.OnlineDevices) - // 本月收入 // Logan.Li s.db.Model(&model.Order{}). Where("status = ? AND paid_at >= ?", "paid", monthStart). Select("COALESCE(SUM(amount), 0)"). Scan(&stats.MonthlyRevenue) + s.db.Model(&model.Order{}). + Where("status = ?", "paid"). + Select("COALESCE(SUM(amount), 0)"). + Scan(&stats.TotalRevenue) return &stats, nil } @@ -69,7 +84,7 @@ func (s *AnalyticsService) GetExpiringLicenses(days int) ([]dto.ExpiringLicenseI for _, l := range licenses { results = append(results, dto.ExpiringLicenseItem{ ID: l.ID, - LicenseKey: l.LicenseKey, + LicenseKey: shortKey(l.LicenseKey), CustomerName: l.Customer.Name, ProductName: l.Product.Name, EndDate: l.EndDate.Format("2006-01-02"), @@ -79,6 +94,14 @@ func (s *AnalyticsService) GetExpiringLicenses(days int) ([]dto.ExpiringLicenseI return results, nil } +func shortKey(key string) string { + key = strings.TrimSpace(key) + if strings.HasPrefix(key, "LICOFF-") && len(key) > 28 { + return key[:14] + "…" + key[len(key)-8:] + } + return key +} + // GetRecentActivities 最近操作活动 // Logan.Li func (s *AnalyticsService) GetRecentActivities(limit int) ([]dto.RecentActivityItem, error) { if limit <= 0 { @@ -113,9 +136,9 @@ func (s *AnalyticsService) GetOnlineStats() (map[string]interface{}, error) { s.db.Model(&model.Activation{}).Where("status = ?", "active").Count(&totalActivations) return map[string]interface{}{ - "online_devices": onlineDevices, - "total_activations": totalActivations, - "offline_devices": totalActivations - onlineDevices, + "online_devices": onlineDevices, + "total_activations": totalActivations, + "offline_devices": totalActivations - onlineDevices, }, nil } @@ -139,17 +162,36 @@ func (s *AnalyticsService) GetRevenueStats() ([]dto.RevenuePoint, error) { return results, nil } -// GetLicenseGrowth License 增长趋势(近 6 个月) // Logan.Li +// GetLicenseGrowth License 增长趋势(近 12 个月累计) // Logan.Li func (s *AnalyticsService) GetLicenseGrowth() ([]dto.LicenseGrowthPoint, error) { - now := time.Now() - results := make([]dto.LicenseGrowthPoint, 0, 6) - for i := 5; i >= 0; i-- { - monthEnd := time.Date(now.Year(), now.Month()-time.Month(i)+1, 0, 23, 59, 59, 0, now.Location()) - var count int64 - s.db.Model(&model.License{}).Where("created_at <= ?", monthEnd).Count(&count) + detail, err := s.GetLicenseGrowthDetail() + if err != nil { + return nil, err + } + results := make([]dto.LicenseGrowthPoint, 0, len(detail)) + for _, row := range detail { results = append(results, dto.LicenseGrowthPoint{ - Date: monthEnd.Format("2006-01"), - Count: count, + Date: row["date"].(string), + Count: row["cumulative"].(int64), + }) + } + return results, nil +} + +// GetLicenseGrowthDetail 含当月新增的增长序列 // Logan.Li +func (s *AnalyticsService) GetLicenseGrowthDetail() ([]map[string]interface{}, error) { + now := time.Now() + results := make([]map[string]interface{}, 0, 12) + for i := 11; i >= 0; i-- { + monthStart := time.Date(now.Year(), now.Month()-time.Month(i), 1, 0, 0, 0, 0, now.Location()) + nextMonth := monthStart.AddDate(0, 1, 0) + var cumulative, created int64 + s.db.Model(&model.License{}).Where("created_at < ?", nextMonth).Count(&cumulative) + s.db.Model(&model.License{}).Where("created_at >= ? AND created_at < ?", monthStart, nextMonth).Count(&created) + results = append(results, map[string]interface{}{ + "date": monthStart.Format("2006-01"), + "cumulative": cumulative, + "created": created, }) } return results, nil @@ -165,3 +207,105 @@ func (s *AnalyticsService) GetProductDistribution() ([]dto.ProductDistribution, Scan(&results) return results, nil } + +// GetGeoDistribution 地理/网络分布(device_info.country 优先,否则按 IP 粗分) // Logan.Li +func (s *AnalyticsService) GetGeoDistribution() ([]dto.GeoDistribution, error) { + var acts []model.Activation + s.db.Where("status = ?", "active").Find(&acts) + counts := map[string]int64{} + for _, a := range acts { + region := regionFromActivation(a) + counts[region]++ + } + results := make([]dto.GeoDistribution, 0, len(counts)) + for region, count := range counts { + results = append(results, dto.GeoDistribution{Region: region, Count: count}) + } + return results, nil +} + +// GetPlatformDistribution 客户端形态分布(web/pc/mobile) // Logan.Li +func (s *AnalyticsService) GetPlatformDistribution() ([]dto.PlatformDistribution, error) { + var acts []model.Activation + s.db.Where("status = ?", "active").Find(&acts) + counts := map[string]int64{} + for _, a := range acts { + p := platformFromActivation(a) + counts[p]++ + } + order := []string{"pc", "web", "mobile", "unknown"} + results := make([]dto.PlatformDistribution, 0, len(order)) + for _, p := range order { + if counts[p] > 0 { + results = append(results, dto.PlatformDistribution{Platform: p, Count: counts[p]}) + } + } + for p, c := range counts { + if p == "pc" || p == "web" || p == "mobile" || p == "unknown" { + continue + } + results = append(results, dto.PlatformDistribution{Platform: p, Count: c}) + } + return results, nil +} + +func regionFromActivation(a model.Activation) string { + if info := parseDeviceInfo(a.DeviceInfo); info != nil { + for _, k := range []string{"country", "region", "locale", "geo"} { + if v, ok := info[k].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + } + ip := strings.TrimSpace(a.IP) + if ip == "" { + return "未知" + } + parsed := net.ParseIP(ip) + if parsed == nil { + return "未知" + } + if parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast() { + return "内网" + } + return "公网" +} + +func platformFromActivation(a model.Activation) string { + if info := parseDeviceInfo(a.DeviceInfo); info != nil { + for _, k := range []string{"platform", "client", "os", "channel"} { + if v, ok := info[k].(string); ok { + p := strings.ToLower(strings.TrimSpace(v)) + switch { + case strings.Contains(p, "ios"), strings.Contains(p, "android"), strings.Contains(p, "mobile"): + return "mobile" + case strings.Contains(p, "web"), strings.Contains(p, "browser"), strings.Contains(p, "h5"): + return "web" + case strings.Contains(p, "windows"), strings.Contains(p, "mac"), strings.Contains(p, "linux"), + strings.Contains(p, "desktop"), strings.Contains(p, "tauri"), strings.Contains(p, "pc"): + return "pc" + } + } + } + } + name := strings.ToLower(a.DeviceName) + switch { + case strings.Contains(name, "iphone"), strings.Contains(name, "android"): + return "mobile" + case strings.Contains(name, "opsxshell"), strings.Contains(name, "desktop"): + return "pc" + } + return "unknown" +} + +func parseDeviceInfo(raw string) map[string]interface{} { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return nil + } + return m +} diff --git a/backend/internal/service/license/license_service.go b/backend/internal/service/license/license_service.go index 166a801..53d8ee7 100644 --- a/backend/internal/service/license/license_service.go +++ b/backend/internal/service/license/license_service.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "strconv" + "strings" "time" "gorm.io/gorm" @@ -28,6 +29,7 @@ type ListQuery struct { Status string CustomerID uint ProductID uint + Channel string // online | offline | ""(全部) } // List 分页查询 License // Logan.Li @@ -35,7 +37,7 @@ func (s *LicenseService) List(page, pageSize int, q ListQuery) ([]dto.LicenseRes var licenses []model.License var total int64 - query := s.db.Model(&model.License{}).Preload("Customer").Preload("Product") + query := s.db.Model(&model.License{}).Preload("Customer").Preload("Product").Preload("Activations") if q.Keyword != "" { query = query.Where("license_key LIKE ?", "%"+q.Keyword+"%") } @@ -48,6 +50,12 @@ func (s *LicenseService) List(page, pageSize int, q ListQuery) ([]dto.LicenseRes if q.ProductID > 0 { query = query.Where("product_id = ?", q.ProductID) } + switch strings.TrimSpace(q.Channel) { + case "offline": + query = query.Where("license_key LIKE ?", "LICOFF-%") + case "online": + query = query.Where("license_key NOT LIKE ?", "LICOFF-%") + } query.Count(&total) if err := query.Order("created_at DESC"). @@ -165,12 +173,15 @@ func (s *LicenseService) Delete(id uint) (*model.License, error) { return &lic, nil } -// Renew 续期 // Logan.Li +// Renew 续期(仅在线证;离线证签名自包含,到期后应重新签发) // Logan.Li func (s *LicenseService) Renew(id uint, duration int) (*dto.LicenseResponse, error) { var lic model.License if err := s.db.First(&lic, id).Error; err != nil { return nil, errors.New("License 不存在") } + if utils.IsOfflineLicenseKey(lic.LicenseKey) { + return nil, errors.New("离线许可证不可续期:到期后请重新签发新序列号(绑定同一设备 ID)") + } var newEndDate time.Time if duration == model.DurationForever { @@ -300,9 +311,25 @@ func toLicenseResponse(l model.License) dto.LicenseResponse { if l.Product.ID > 0 { productName = l.Product.Name } + offline := utils.IsOfflineLicenseKey(l.LicenseKey) + channel := "online" + if offline { + channel = "offline" + } + deviceID := "" + for _, a := range l.Activations { + if strings.TrimSpace(a.DeviceID) != "" { + deviceID = a.DeviceID + break + } + } return dto.LicenseResponse{ ID: l.ID, LicenseKey: l.LicenseKey, + LicenseKeyShort: shortLicenseKey(l.LicenseKey), + Channel: channel, + IsOffline: offline, + DeviceID: deviceID, CustomerID: l.CustomerID, CustomerName: customerName, ProductID: l.ProductID, @@ -320,6 +347,18 @@ func toLicenseResponse(l model.License) dto.LicenseResponse { } } +// shortLicenseKey 表格短展示:在线保留全文;离线保留前缀+尾部。 // Logan.Li +func shortLicenseKey(key string) string { + key = strings.TrimSpace(key) + if !utils.IsOfflineLicenseKey(key) { + return key + } + if len(key) <= 28 { + return key + } + return key[:14] + "…" + key[len(key)-8:] +} + // toActivationResponse 模型转响应 DTO // Logan.Li func toActivationResponse(a model.Activation) dto.ActivationResponse { return dto.ActivationResponse{ diff --git a/backend/internal/service/license/offline_service.go b/backend/internal/service/license/offline_service.go new file mode 100644 index 0000000..4bb408e --- /dev/null +++ b/backend/internal/service/license/offline_service.go @@ -0,0 +1,177 @@ +package license + +import ( + "backend/config" + "backend/internal/dto" + "backend/internal/model" + "backend/utils" + "errors" + "strings" + "time" + + "gorm.io/gorm" +) + +// OfflineIssuer 离线许可证签发(绑定 device_id,最长 365 天) // Logan.Li +type OfflineIssuer struct { + db *gorm.DB + kp *utils.OfflineKeyPair +} + +// NewOfflineIssuer 创建离线签发器;seed 为空时返回可用实例但 Issue 会失败 // Logan.Li +func NewOfflineIssuer(db *gorm.DB, cfg *config.Config) *OfflineIssuer { + issuer := &OfflineIssuer{db: db} + if cfg == nil || strings.TrimSpace(cfg.OfflineLicenseSeed) == "" { + return issuer + } + kp, err := utils.DeriveOfflineKeyPair(cfg.OfflineLicenseSeed) + if err == nil { + issuer.kp = kp + } + return issuer +} + +// PublicKeyHex 返回当前公钥 hex(桌面嵌入对照) // Logan.Li +func (s *OfflineIssuer) PublicKeyHex() string { + if s == nil || s.kp == nil { + return "" + } + return s.kp.OfflinePublicKeyHex() +} + +// Issue 签发离线序列号并落库审计记录 // Logan.Li +func (s *OfflineIssuer) Issue(req dto.OfflineLicenseCreate) (*dto.OfflineLicenseResponse, error) { + if s == nil || s.kp == nil { + return nil, errors.New("未配置 OFFLINE_LICENSE_SEED,无法签发离线证") + } + + deviceID := strings.TrimSpace(req.DeviceID) + if deviceID == "" { + return nil, errors.New("device_id 不能为空") + } + + planType := strings.TrimSpace(req.PlanType) + if planType == "" { + planType = "yearly" + } + if planType != "trial" && planType != "yearly" { + return nil, errors.New("离线证仅允许体验(trial)或包年(yearly),禁止永久") + } + + duration := req.DurationDays + if duration <= 0 { + duration = utils.OfflineMaxDurationDays + } + if duration > utils.OfflineMaxDurationDays { + return nil, errors.New("离线证有效期不得超过 365 天") + } + if planType == "trial" && duration > 180 { + duration = 180 + } + + productCode := strings.TrimSpace(req.ProductCode) + if productCode == "" { + productCode = "opsxshell" + } + + var product model.Product + if err := s.db.Where("code = ?", productCode).First(&product).Error; err != nil { + return nil, errors.New("产品不存在: " + productCode) + } + + var customer model.Customer + if req.CustomerID > 0 { + if err := s.db.First(&customer, req.CustomerID).Error; err != nil { + return nil, errors.New("客户不存在") + } + } else { + email := strings.TrimSpace(req.Email) + name := strings.TrimSpace(req.CustomerName) + if name == "" { + name = "离线客户" + } + if email != "" { + err := s.db.Where("email = ?", email).First(&customer).Error + if err != nil { + customer = model.Customer{Name: name, Email: email, Status: 1} + if err := s.db.Create(&customer).Error; err != nil { + return nil, errors.New("创建客户失败") + } + } + } else { + customer = model.Customer{Name: name, Email: "", Status: 1} + if err := s.db.Create(&customer).Error; err != nil { + return nil, errors.New("创建客户失败") + } + } + } + + now := time.Now().UTC() + exp := now.AddDate(0, 0, duration) + claims := utils.OfflineLicenseClaims{ + V: 1, + ProductCode: product.Code, + DeviceID: deviceID, + Email: strings.TrimSpace(req.Email), + PlanType: planType, + IssuedAt: now.Unix(), + ExpiresAt: exp.Unix(), + MaxDevices: 1, + CustomerName: customer.Name, + } + + serial, err := utils.SignOfflineLicense(s.kp, claims) + if err != nil { + return nil, err + } + + notes := strings.TrimSpace(req.Notes) + if notes == "" { + notes = "离线许可证" + } + notes = notes + " | device=" + deviceID + " | plan=" + planType + + lic := model.License{ + LicenseKey: serial, + CustomerID: customer.ID, + ProductID: product.ID, + Status: model.LicenseStatusActive, + StartDate: now, + EndDate: exp, + Duration: duration, + MaxActivations: 1, + CurrentActivations: 1, + IsTrial: planType == "trial", + TrialDays: duration, + Notes: notes, + } + if err := s.db.Create(&lic).Error; err != nil { + return nil, errors.New("保存离线 License 失败") + } + + activation := model.Activation{ + LicenseID: lic.ID, + DeviceID: deviceID, + DeviceName: "offline", + Status: "active", + IsOnline: false, + ActivatedAt: now, + ExpiresAt: &exp, + LastSeenAt: now, + } + _ = s.db.Create(&activation).Error + + return &dto.OfflineLicenseResponse{ + Serial: serial, + DeviceID: deviceID, + Email: claims.Email, + PlanType: planType, + ProductCode: product.Code, + IssuedAt: now.Format(time.RFC3339), + ExpiresAt: exp.Format(time.RFC3339), + Duration: duration, + MaxDevices: 1, + LicenseID: lic.ID, + PublicKey: s.kp.OfflinePublicKeyHex(), + }, nil +} diff --git a/backend/internal/service/order/order_service.go b/backend/internal/service/order/order_service.go index eb8019f..067956c 100644 --- a/backend/internal/service/order/order_service.go +++ b/backend/internal/service/order/order_service.go @@ -146,7 +146,7 @@ func (s *OrderService) CreateOrder(req dto.CreateOrderRequest, clientIP, userAge var checkoutURL string if gateway.Gateway == "waffo" { url, err := payment.BuildCheckoutURL( - payment.LoadWaffoConfig(), + payment.LoadWaffoConfig(s.db), order.OrderNo, strings.TrimSpace(req.DeviceID), req.CustomerEmail, @@ -311,12 +311,40 @@ func (s *OrderService) ClaimPurchase(req dto.ClaimPurchaseRequest) (*dto.ClaimPu }, nil } +// resolveWebhookOrder finds local order by merchant external id, Waffo order id, or buyer email. // Logan.Li +func (s *OrderService) resolveWebhookOrder(req dto.PaymentWebhookRequest) (model.Order, error) { + var order model.Order + if no := strings.TrimSpace(req.OrderNo); no != "" { + if err := s.db.Where("order_no = ?", no).First(&order).Error; err == nil { + return order, nil + } + // Hosted-store mistaken: Waffo ORD_ put in OrderNo — try gateway column. // Logan.Li + if err := s.db.Where("gateway_order_no = ?", no).First(&order).Error; err == nil { + return order, nil + } + } + if g := strings.TrimSpace(req.GatewayOrderNo); g != "" { + if err := s.db.Where("gateway_order_no = ?", g).First(&order).Error; err == 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("订单不存在") +} + // PaymentWebhook 支付回调(幂等 + 事务) // Logan.Li func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP string) error { - // 幂等检查 // Logan.Li - var order model.Order - if err := s.db.Where("order_no = ?", req.OrderNo).First(&order).Error; err != nil { - return errors.New("订单不存在") + order, err := s.resolveWebhookOrder(req) + if err != nil { + return err } if order.Status == "paid" || order.Status == "refunded" { return nil // 已处理,幂等返回 diff --git a/backend/internal/service/payment/waffo.go b/backend/internal/service/payment/waffo.go index 95ad185..374ae2b 100644 --- a/backend/internal/service/payment/waffo.go +++ b/backend/internal/service/payment/waffo.go @@ -1,35 +1,91 @@ package payment import ( + "encoding/json" "fmt" "net/url" "os" "strings" + + "backend/internal/model" + + "gorm.io/gorm" ) // WaffoConfig hosted checkout settings for pancake.waffo.ai // Logan.Li type WaffoConfig struct { - CheckoutURL string - StoreSlug string - ProductID string - ProductIDYearly string - ProductIDPerpetual string - Currency string // fallback / default - CurrencyYearly string - CurrencyPerpetual string - Environment string - SuccessURL string + CheckoutURL string + StoreSlug string + StoreID string + MerchantID string + ProductID string + ProductIDYearly string + ProductIDPerpetual string + // ProductIDs 可扩展 plan_type → Product ID;优先于固定 yearly/perpetual 字段 // Logan.Li + ProductIDs map[string]string + Currency string // fallback / default + CurrencyYearly string + CurrencyPerpetual string + Environment string + SuccessURL string + // PrivateKey RSA 私钥原文(PEM 或无头 Base64);仅运行时使用,勿写入 API 响应 // Logan.Li + PrivateKey string } -// LoadWaffoConfig reads WAFFO_* env vars (same names as OpsXShell legacy license-server). // Logan.Li -func LoadWaffoConfig() WaffoConfig { +// WaffoProductMapping 可扩展计划 key → Product ID(与管理端表格对齐) // Logan.Li +type WaffoProductMapping struct { + PlanKey string `json:"plan_key"` + Label string `json:"label,omitempty"` + ProductID string `json:"product_id"` +} + +// WaffoPublicMeta 存入 payment_gateway_configs.public_key 的 JSON(无私钥) // Logan.Li +type WaffoPublicMeta struct { + MerchantID string `json:"merchant_id"` + StoreID string `json:"store_id"` + StoreSlug string `json:"store_slug"` + ProductID string `json:"product_id"` + ProductIDYearly string `json:"product_id_yearly"` + ProductIDPerpetual string `json:"product_id_perpetual"` + ProductIDs map[string]string `json:"product_ids,omitempty"` // 可扩展映射,兼容旧字段 // Logan.Li + ProductMappings []WaffoProductMapping `json:"product_mappings,omitempty"` // 前端表格(含 label) // Logan.Li + SuccessURL string `json:"success_url"` + Currency string `json:"currency"` + CurrencyYearly string `json:"currency_yearly"` + CurrencyPerpetual string `json:"currency_perpetual"` + Environment string `json:"environment"` + CheckoutURL string `json:"checkout_url"` +} + +// DefaultWebhookURL 对外 Webhook 地址(只读提示) // Logan.Li +const DefaultWebhookURL = "https://l123.91ai.io/api/v1/webhooks/waffo" + +// DefaultSuccessURL 支付成功回跳默认地址 // Logan.Li +const DefaultSuccessURL = "https://l123.91ai.io/api/v1/purchase/success" + +// HasAPIKeyAuth 是否具备商户 API Key 鉴权(Merchant ID + 私钥) // Logan.Li +func (c WaffoConfig) HasAPIKeyAuth() bool { + return strings.TrimSpace(c.MerchantID) != "" && strings.TrimSpace(c.PrivateKey) != "" +} + +// LoadWaffoConfig 读取配置:后台 payment_gateway_configs 优先,.env 兜底 // Logan.Li +func LoadWaffoConfig(db *gorm.DB) WaffoConfig { + cfg := loadWaffoFromEnv() + if db != nil { + mergeWaffoFromDB(db, &cfg) + } + normalizeWaffoConfig(&cfg) + return cfg +} + +func loadWaffoFromEnv() WaffoConfig { env := strings.TrimSpace(os.Getenv("WAFFO_ENVIRONMENT")) if env == "" { env = "test" } success := strings.TrimSpace(os.Getenv("SUCCESS_URL")) if success == "" { - success = "http://l123.91ai.io/api/v1/purchase/success" + success = DefaultSuccessURL } currency := strings.TrimSpace(os.Getenv("WAFFO_CURRENCY")) if currency == "" { @@ -52,9 +108,16 @@ func LoadWaffoConfig() WaffoConfig { if perpetual == "" { perpetual = defaultProduct } + priv := firstNonEmpty( + os.Getenv("WAFFO_PRIVATE_KEY"), + os.Getenv("WAFFO_PRIVATE_KEY_PEM"), + os.Getenv("WAFFO_PRIVATE_KEY_BASE64"), + ) return WaffoConfig{ CheckoutURL: strings.TrimSpace(os.Getenv("WAFFO_CHECKOUT_URL")), StoreSlug: strings.TrimSpace(os.Getenv("WAFFO_STORE_SLUG")), + StoreID: strings.TrimSpace(os.Getenv("WAFFO_STORE_ID")), + MerchantID: strings.TrimSpace(os.Getenv("WAFFO_MERCHANT_ID")), ProductID: defaultProduct, ProductIDYearly: yearly, ProductIDPerpetual: perpetual, @@ -63,27 +126,244 @@ func LoadWaffoConfig() WaffoConfig { CurrencyPerpetual: currencyPerpetual, Environment: env, SuccessURL: success, + PrivateKey: strings.TrimSpace(priv), } } -// BuildCheckoutURL returns a Waffo hosted checkout URL with order/device/email metadata. // Logan.Li +func mergeWaffoFromDB(db *gorm.DB, cfg *WaffoConfig) { + var gw model.PaymentGatewayConfig + if err := db.Where("gateway = ?", "waffo").First(&gw).Error; err != nil { + return + } + meta := ParseWaffoPublicMeta(gw.PublicKey) + if meta.MerchantID != "" { + cfg.MerchantID = meta.MerchantID + } + if meta.StoreID != "" { + cfg.StoreID = meta.StoreID + } + if meta.StoreSlug != "" { + cfg.StoreSlug = meta.StoreSlug + } + if meta.ProductID != "" { + cfg.ProductID = meta.ProductID + } + if meta.ProductIDYearly != "" { + cfg.ProductIDYearly = meta.ProductIDYearly + } + if meta.ProductIDPerpetual != "" { + cfg.ProductIDPerpetual = meta.ProductIDPerpetual + } + if len(meta.ProductIDs) > 0 { + cfg.ProductIDs = cloneStringMap(meta.ProductIDs) + } + // product_mappings 优先补齐映射(可扩展 key) // Logan.Li + if len(meta.ProductMappings) > 0 { + if cfg.ProductIDs == nil { + cfg.ProductIDs = map[string]string{} + } + for _, m := range meta.ProductMappings { + k := strings.TrimSpace(strings.ToLower(m.PlanKey)) + v := strings.TrimSpace(m.ProductID) + if k == "" || v == "" { + continue + } + cfg.ProductIDs[k] = v + } + } + if meta.SuccessURL != "" { + cfg.SuccessURL = meta.SuccessURL + } + if meta.Currency != "" { + cfg.Currency = meta.Currency + } + if meta.CurrencyYearly != "" { + cfg.CurrencyYearly = meta.CurrencyYearly + } + if meta.CurrencyPerpetual != "" { + cfg.CurrencyPerpetual = meta.CurrencyPerpetual + } + if meta.CheckoutURL != "" { + cfg.CheckoutURL = meta.CheckoutURL + } + if meta.Environment != "" { + cfg.Environment = meta.Environment + } else if gw.SandboxMode { + cfg.Environment = "test" + } else { + cfg.Environment = "live" + } + if strings.TrimSpace(gw.Currency) != "" && cfg.Currency == "" { + cfg.Currency = gw.Currency + } + if strings.TrimSpace(gw.SecretKey) != "" { + cfg.PrivateKey = strings.TrimSpace(gw.SecretKey) + } +} + +func normalizeWaffoConfig(cfg *WaffoConfig) { + if cfg.Currency == "" { + cfg.Currency = "CNY" + } + if cfg.CurrencyYearly == "" { + cfg.CurrencyYearly = cfg.Currency + } + if cfg.CurrencyPerpetual == "" { + cfg.CurrencyPerpetual = cfg.Currency + } + if cfg.ProductIDs == nil { + cfg.ProductIDs = map[string]string{} + } + // 旧字段回填到可扩展映射,便于统一解析 // Logan.Li + if cfg.ProductIDYearly != "" { + if _, ok := cfg.ProductIDs["yearly"]; !ok { + cfg.ProductIDs["yearly"] = cfg.ProductIDYearly + } + if _, ok := cfg.ProductIDs["monthly"]; !ok { + cfg.ProductIDs["monthly"] = cfg.ProductIDYearly + } + } + if cfg.ProductIDPerpetual != "" { + if _, ok := cfg.ProductIDs["perpetual"]; !ok { + cfg.ProductIDs["perpetual"] = cfg.ProductIDPerpetual + } + if _, ok := cfg.ProductIDs["lifetime"]; !ok { + cfg.ProductIDs["lifetime"] = cfg.ProductIDPerpetual + } + } + if cfg.ProductID != "" { + if _, ok := cfg.ProductIDs["default"]; !ok { + cfg.ProductIDs["default"] = cfg.ProductID + } + } + // 映射回写固定字段,保持 GET 响应兼容 // Logan.Li + if v := cfg.ProductIDs["yearly"]; v != "" { + cfg.ProductIDYearly = v + } + if v := cfg.ProductIDs["perpetual"]; v != "" { + cfg.ProductIDPerpetual = v + } + if cfg.ProductIDYearly == "" { + cfg.ProductIDYearly = cfg.ProductID + } + if cfg.ProductIDPerpetual == "" { + cfg.ProductIDPerpetual = cfg.ProductID + } + if cfg.SuccessURL == "" { + cfg.SuccessURL = DefaultSuccessURL + } + env := strings.ToLower(strings.TrimSpace(cfg.Environment)) + switch env { + case "prod", "production", "live": + cfg.Environment = "live" + default: + cfg.Environment = "test" + } +} + +func cloneStringMap(in map[string]string) map[string]string { + if len(in) == 0 { + return map[string]string{} + } + out := make(map[string]string, len(in)) + for k, v := range in { + k = strings.TrimSpace(strings.ToLower(k)) + v = strings.TrimSpace(v) + if k == "" || v == "" { + continue + } + out[k] = v + } + return out +} + +// ParseWaffoPublicMeta 解析 public_key:JSON 或纯 Merchant ID 字符串 // Logan.Li +func ParseWaffoPublicMeta(raw string) WaffoPublicMeta { + raw = strings.TrimSpace(raw) + if raw == "" { + return WaffoPublicMeta{} + } + if strings.HasPrefix(raw, "{") { + var meta WaffoPublicMeta + if err := json.Unmarshal([]byte(raw), &meta); err == nil { + return meta + } + } + // 兼容旧值:public_key 直接存商户 ID // Logan.Li + if strings.HasPrefix(raw, "MER_") { + return WaffoPublicMeta{MerchantID: raw} + } + return WaffoPublicMeta{} +} + +// EncodeWaffoPublicMeta 序列化公开配置 JSON // Logan.Li +func EncodeWaffoPublicMeta(meta WaffoPublicMeta) (string, error) { + b, err := json.Marshal(meta) + if err != nil { + return "", fmt.Errorf("序列化 Waffo 配置失败: %w", err) + } + return string(b), nil +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if s := strings.TrimSpace(v); s != "" { + return s + } + } + return "" +} + +// BuildCheckoutURL returns a Waffo checkout URL via official create-session API +// (orderMerchantExternalId + metadata). Hosted store page is last-resort fallback only. // Logan.Li func BuildCheckoutURL(cfg WaffoConfig, orderNo, deviceID, email, returnURL, planType string) (string, error) { success := cfg.SuccessURL if strings.TrimSpace(returnURL) != "" { success = strings.TrimSpace(returnURL) } + success = appendQuery(success, map[string]string{ + "orderNo": orderNo, + "deviceId": deviceID, + "email": email, + "planType": planType, + }) + + productID := productIDForPlan(cfg, planType) + + // Prefer create-session whenever Store Slug or API Key is available. // Logan.Li + if cfg.HasAPIKeyAuth() || strings.TrimSpace(cfg.StoreSlug) != "" { + if productID == "" { + return "", fmt.Errorf("未配置包年/永久 Product ID") + } + session, err := CreateCheckoutSession(cfg, CreateSessionRequest{ + ProductID: productID, + ProductType: checkoutTypeForPlan(planType), + Currency: currencyForPlan(cfg, planType), + BuyerEmail: email, + SuccessURL: success, + OrderMerchantExternalID: orderNo, + Metadata: map[string]string{ + "orderNo": orderNo, + "deviceId": deviceID, + "email": email, + "planType": planType, + }, + StoreSlug: cfg.StoreSlug, + Environment: cfg.Environment, + }) + if err != nil { + return "", err + } + return session.CheckoutURL, nil + } + u, err := resolveBaseCheckoutURL(cfg, planType) if err != nil { return "", err } q := u.Query() if q.Get("successUrl") == "" { - q.Set("successUrl", appendQuery(success, map[string]string{ - "orderNo": orderNo, - "deviceId": deviceID, - "email": email, - "planType": planType, - })) + q.Set("successUrl", success) } if deviceID != "" { q.Set("deviceId", deviceID) @@ -101,18 +381,39 @@ func BuildCheckoutURL(cfg WaffoConfig, orderNo, deviceID, email, returnURL, plan return u.String(), nil } +// ResolveProductID 按 plan_type 解析 Waffo Product ID(可扩展映射优先) // Logan.Li +func ResolveProductID(cfg WaffoConfig, planType string) string { + return productIDForPlan(cfg, planType) +} + func productIDForPlan(cfg WaffoConfig, planType string) string { - switch strings.TrimSpace(planType) { + plan := strings.TrimSpace(strings.ToLower(planType)) + if plan == "" { + plan = "default" + } + if id := strings.TrimSpace(cfg.ProductIDs[plan]); id != "" { + return id + } + switch plan { case "yearly", "monthly": - return cfg.ProductIDYearly + if cfg.ProductIDYearly != "" { + return cfg.ProductIDYearly + } case "perpetual", "lifetime": - return cfg.ProductIDPerpetual - default: if cfg.ProductIDPerpetual != "" { return cfg.ProductIDPerpetual } + } + if id := strings.TrimSpace(cfg.ProductIDs["default"]); id != "" { + return id + } + if cfg.ProductID != "" { return cfg.ProductID } + if cfg.ProductIDPerpetual != "" { + return cfg.ProductIDPerpetual + } + return cfg.ProductIDYearly } func currencyForPlan(cfg WaffoConfig, planType string) string { @@ -139,7 +440,6 @@ func checkoutTypeForPlan(planType string) string { } func resolveBaseCheckoutURL(cfg WaffoConfig, planType string) (*url.URL, error) { - // Prefer store + per-plan product IDs so yearly/perpetual map to different Waffo SKUs. productID := productIDForPlan(cfg, planType) if cfg.StoreSlug != "" && productID != "" { raw := fmt.Sprintf("https://pancake.waffo.ai/store/%s/product/%s", cfg.StoreSlug, productID) @@ -159,7 +459,7 @@ func resolveBaseCheckoutURL(cfg WaffoConfig, planType string) (*url.URL, error) if cfg.CheckoutURL != "" { return url.Parse(cfg.CheckoutURL) } - return nil, fmt.Errorf("未配置 WAFFO_STORE_SLUG 与 WAFFO_PRODUCT_ID_YEARLY / WAFFO_PRODUCT_ID_PERPETUAL(或 WAFFO_CHECKOUT_URL)") + return nil, fmt.Errorf("未配置 StoreSlug 与包年/永久 Product ID(或 CheckoutURL)") } func appendQuery(raw string, values map[string]string) string { diff --git a/backend/internal/service/payment/waffo_api.go b/backend/internal/service/payment/waffo_api.go new file mode 100644 index 0000000..e57922e --- /dev/null +++ b/backend/internal/service/payment/waffo_api.go @@ -0,0 +1,220 @@ +package payment + +import ( + "bytes" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const waffoAPIBase = "https://api.waffo.ai" + +// CreateSessionRequest API Key / Store Slug checkout session (official create-session). // Logan.Li +type CreateSessionRequest struct { + ProductID string `json:"productId"` + ProductType string `json:"productType"` + Currency string `json:"currency"` + BuyerEmail string `json:"buyerEmail,omitempty"` + SuccessURL string `json:"successUrl,omitempty"` + OrderMerchantExternalID string `json:"orderMerchantExternalId,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + ExpiresInSeconds int `json:"expiresInSeconds,omitempty"` + // Auth overrides (optional). // Logan.Li + StoreSlug string `json:"-"` + Environment string `json:"-"` +} + +// CreateSessionResult checkout session response. // Logan.Li +type CreateSessionResult struct { + SessionID string `json:"sessionId"` + CheckoutURL string `json:"checkoutUrl"` + ExpiresAt string `json:"expiresAt"` +} + +// CreateCheckoutSession creates a Waffo checkout session with orderMerchantExternalId. +// Prefers API Key (Merchant ID + RSA); falls back to Store Slug + X-Environment. // Logan.Li +func CreateCheckoutSession(cfg WaffoConfig, req CreateSessionRequest) (*CreateSessionResult, error) { + if strings.TrimSpace(req.ProductID) == "" || strings.TrimSpace(req.Currency) == "" { + return nil, fmt.Errorf("productId 与 currency 必填") + } + if req.ProductType == "" { + req.ProductType = "onetime" + } + if req.ExpiresInSeconds <= 0 { + req.ExpiresInSeconds = 2700 + } + + bodyBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + path := "/v1/actions/checkout/create-session" + httpReq, err := http.NewRequest(http.MethodPost, waffoAPIBase+path, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + + if cfg.HasAPIKeyAuth() { + timestamp := fmt.Sprintf("%d", time.Now().Unix()) + sig, err := signAPIRequest(cfg.PrivateKey, "POST", path, timestamp, bodyBytes) + if err != nil { + return nil, err + } + httpReq.Header.Set("X-Merchant-Id", strings.TrimSpace(cfg.MerchantID)) + httpReq.Header.Set("X-Timestamp", timestamp) + httpReq.Header.Set("X-Signature", sig) + } else { + slug := strings.TrimSpace(req.StoreSlug) + if slug == "" { + slug = strings.TrimSpace(cfg.StoreSlug) + } + if slug == "" { + return nil, fmt.Errorf("未配置商户 ID/私钥,且缺少 Store Slug,无法创建收银台会话") + } + env := strings.TrimSpace(req.Environment) + if env == "" { + env = strings.TrimSpace(cfg.Environment) + } + if env == "" { + env = "test" + } + httpReq.Header.Set("X-Store-Slug", slug) + httpReq.Header.Set("X-Environment", env) + } + + client := &http.Client{Timeout: 20 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("Waffo create-session 请求失败: %w", err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + + var envelope struct { + Data *CreateSessionResult `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := json.Unmarshal(respBody, &envelope); err != nil { + return nil, fmt.Errorf("Waffo 响应无法解析 (%d): %s", resp.StatusCode, truncate(string(respBody), 200)) + } + if resp.StatusCode >= 300 || envelope.Data == nil || envelope.Data.CheckoutURL == "" { + msg := strings.TrimSpace(string(respBody)) + if len(envelope.Errors) > 0 && envelope.Errors[0].Message != "" { + msg = envelope.Errors[0].Message + } + return nil, fmt.Errorf("Waffo create-session 失败 (%d): %s", resp.StatusCode, truncate(msg, 300)) + } + return envelope.Data, nil +} + +func signAPIRequest(privateKeyMaterial, method, path, timestamp string, body []byte) (string, error) { + priv, err := ParseMerchantPrivateKey(privateKeyMaterial) + if err != nil { + return "", err + } + sum := sha256.Sum256(body) + bodyHash := base64.StdEncoding.EncodeToString(sum[:]) + canonical := method + "\n" + path + "\n" + timestamp + "\n" + bodyHash + hashed := sha256.Sum256([]byte(canonical)) + sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, hashed[:]) + if err != nil { + return "", fmt.Errorf("RSA 签名失败: %w", err) + } + return base64.StdEncoding.EncodeToString(sig), nil +} + +// ParseMerchantPrivateKey 解析 RSA 私钥:PEM、无头 Base64(PKCS8/PKCS1 DER)均支持 // Logan.Li +func ParseMerchantPrivateKey(raw string) (*rsa.PrivateKey, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("私钥为空") + } + raw = strings.ReplaceAll(raw, `\n`, "\n") + + if key, err := parseRSAFromPEMOrDER([]byte(raw)); err == nil { + return key, nil + } + + // 纯 Base64 体(Dashboard「复制密钥」常见形态) // Logan.Li + 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 key, err2 := parseRSAFromPEMOrDER(decoded); err2 == nil { + return key, nil + } + // 包一层 PKCS8 PEM 再试 // Logan.Li + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: decoded}) + if key, err2 := parseRSAFromPEMOrDER(pemBytes); err2 == nil { + return key, nil + } + } + + // 无头 Base64 拼成 PEM // Logan.Li + wrapped := "-----BEGIN PRIVATE KEY-----\n" + chunkBase64(compact, 64) + "\n-----END PRIVATE KEY-----\n" + if key, err := parseRSAFromPEMOrDER([]byte(wrapped)); err == nil { + return key, nil + } + return nil, fmt.Errorf("无法解析 RSA 私钥(请粘贴 PEM 或 Dashboard Base64 密钥)") +} + +func parseRSAFromPEMOrDER(raw []byte) (*rsa.PrivateKey, error) { + if block, _ := pem.Decode(raw); block != nil { + return parseRSAKeyBytes(block.Bytes) + } + return parseRSAKeyBytes(raw) +} + +func parseRSAKeyBytes(der []byte) (*rsa.PrivateKey, error) { + if key, err := x509.ParsePKCS8PrivateKey(der); err == nil { + if rsaKey, ok := key.(*rsa.PrivateKey); ok { + return rsaKey, nil + } + return nil, fmt.Errorf("私钥不是 RSA") + } + if key, err := x509.ParsePKCS1PrivateKey(der); err == nil { + return key, nil + } + return nil, fmt.Errorf("不支持的私钥格式") +} + +func chunkBase64(s string, size int) string { + if size <= 0 { + return s + } + var b strings.Builder + for i := 0; i < len(s); i += size { + end := i + size + if end > len(s) { + end = len(s) + } + if i > 0 { + b.WriteByte('\n') + } + b.WriteString(s[i:end]) + } + return b.String() +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/backend/internal/service/payment/waffo_test.go b/backend/internal/service/payment/waffo_test.go index 17d87c6..bfafc1f 100644 --- a/backend/internal/service/payment/waffo_test.go +++ b/backend/internal/service/payment/waffo_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -func TestBuildCheckoutURL_YearlyOnetimeCNY(t *testing.T) { +func TestResolveBaseCheckoutURL_YearlyOnetimeCNY(t *testing.T) { cfg := WaffoConfig{ StoreSlug: "opsxshell-pwsi6m4n", ProductIDYearly: "PROD_1BIezCi932IbgCzRIV8hcJ", @@ -17,16 +17,12 @@ func TestBuildCheckoutURL_YearlyOnetimeCNY(t *testing.T) { Environment: "test", SuccessURL: "opsxshell://pro/activated", } - raw, err := BuildCheckoutURL(cfg, "ORD1", "dev-1", "a@b.c", "", "yearly") - if err != nil { - t.Fatal(err) - } - u, err := url.Parse(raw) + u, err := resolveBaseCheckoutURL(cfg, "yearly") if err != nil { t.Fatal(err) } if !strings.Contains(u.Path, "PROD_1BIezCi932IbgCzRIV8hcJ") { - t.Fatalf("yearly product missing: %s", raw) + t.Fatalf("yearly product missing: %s", u.String()) } q := u.Query() if q.Get("type") != "onetime" { @@ -37,7 +33,7 @@ func TestBuildCheckoutURL_YearlyOnetimeCNY(t *testing.T) { } } -func TestBuildCheckoutURL_PerpetualOnetimeCNY(t *testing.T) { +func TestResolveBaseCheckoutURL_PerpetualOnetimeCNY(t *testing.T) { cfg := WaffoConfig{ StoreSlug: "opsxshell-pwsi6m4n", ProductIDYearly: "PROD_1BIezCi932IbgCzRIV8hcJ", @@ -48,16 +44,12 @@ func TestBuildCheckoutURL_PerpetualOnetimeCNY(t *testing.T) { Environment: "test", SuccessURL: "opsxshell://pro/activated", } - raw, err := BuildCheckoutURL(cfg, "ORD2", "dev-1", "a@b.c", "", "perpetual") - if err != nil { - t.Fatal(err) - } - u, err := url.Parse(raw) + u, err := resolveBaseCheckoutURL(cfg, "perpetual") if err != nil { t.Fatal(err) } if !strings.Contains(u.Path, "PROD_0pcnzzT4nalEkkcdyIUoD5") { - t.Fatalf("perpetual product missing: %s", raw) + t.Fatalf("perpetual product missing: %s", u.String()) } q := u.Query() if q.Get("type") != "onetime" { @@ -67,3 +59,21 @@ func TestBuildCheckoutURL_PerpetualOnetimeCNY(t *testing.T) { t.Fatalf("currency=%s want CNY", q.Get("currency")) } } + +func TestBuildCheckoutURL_HostedFallbackWithoutSlug(t *testing.T) { + cfg := WaffoConfig{ + CheckoutURL: "https://pancake.waffo.ai/store/opsxshell-pwsi6m4n/product/PROD_1BIezCi932IbgCzRIV8hcJ?type=onetime¤cy=CNY&test=true", + SuccessURL: "http://l123.91ai.io/api/v1/purchase/success", + } + raw, err := BuildCheckoutURL(cfg, "ORD1", "dev-1", "a@b.c", "", "yearly") + if err != nil { + t.Fatal(err) + } + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + if u.Query().Get("orderNo") != "ORD1" { + t.Fatalf("orderNo missing: %s", raw) + } +} diff --git a/backend/internal/service/plan/plan_service.go b/backend/internal/service/plan/plan_service.go index ba3098d..ddc4a99 100644 --- a/backend/internal/service/plan/plan_service.go +++ b/backend/internal/service/plan/plan_service.go @@ -46,7 +46,7 @@ func (s *PlanService) List(page, pageSize int, productID uint) ([]dto.PlanRespon // GetByProduct 查询某产品的上架计划(公开接口) // Logan.Li func (s *PlanService) GetByProduct(productID uint) ([]dto.PlanResponse, error) { var plans []model.Plan - if err := s.db.Where("product_id = ? AND status = ?", productID, 1). + if err := s.db.Preload("Product").Where("product_id = ? AND status = ?", productID, 1). Order("sort_order ASC").Find(&plans).Error; err != nil { return nil, errors.New("获取定价计划失败") } @@ -88,6 +88,7 @@ func (s *PlanService) Create(req dto.PlanCreate) (*dto.PlanResponse, error) { if err := s.db.Create(&plan).Error; err != nil { return nil, errors.New("创建定价计划失败") } + plan.Product = product resp := toPlanResponse(plan) return &resp, nil } @@ -95,7 +96,7 @@ func (s *PlanService) Create(req dto.PlanCreate) (*dto.PlanResponse, error) { // Update 更新定价计划 // Logan.Li func (s *PlanService) Update(id uint, req dto.PlanUpdate) (*dto.PlanResponse, error) { var plan model.Plan - if err := s.db.First(&plan, id).Error; err != nil { + if err := s.db.Preload("Product").First(&plan, id).Error; err != nil { return nil, errors.New("定价计划不存在") } @@ -128,7 +129,7 @@ func (s *PlanService) Update(id uint, req dto.PlanUpdate) (*dto.PlanResponse, er if err := s.db.Model(&plan).Updates(updates).Error; err != nil { return nil, errors.New("更新定价计划失败") } - s.db.First(&plan, id) + s.db.Preload("Product").First(&plan, id) resp := toPlanResponse(plan) return &resp, nil } @@ -146,16 +147,17 @@ func (s *PlanService) ToggleStatus(id uint, status int) error { // toPlanResponse 模型转响应 DTO // Logan.Li func toPlanResponse(p model.Plan) dto.PlanResponse { return dto.PlanResponse{ - ID: p.ID, - ProductID: p.ProductID, - Name: p.Name, - PlanType: p.PlanType, - Price: p.Price, - Currency: p.Currency, - Duration: p.Duration, - MaxDevices: p.MaxDevices, - Features: utils.ParseFeatures(p.Features), - SortOrder: p.SortOrder, - Status: p.Status, + ID: p.ID, + ProductID: p.ProductID, + ProductName: p.Product.Name, + Name: p.Name, + PlanType: p.PlanType, + Price: p.Price, + Currency: p.Currency, + Duration: p.Duration, + MaxDevices: p.MaxDevices, + Features: utils.ParseFeatures(p.Features), + SortOrder: p.SortOrder, + Status: p.Status, } } diff --git a/backend/internal/service/setting/setting_service.go b/backend/internal/service/setting/setting_service.go index dd63ea7..b11edb5 100644 --- a/backend/internal/service/setting/setting_service.go +++ b/backend/internal/service/setting/setting_service.go @@ -3,7 +3,13 @@ package setting import ( "backend/internal/dto" "backend/internal/model" + "backend/internal/service/payment" "errors" + "fmt" + "net/url" + "regexp" + "strings" + "time" "gorm.io/gorm" ) @@ -63,6 +69,423 @@ func (s *SettingService) Update(req dto.SettingUpdate) (*model.Setting, error) { return &setting, nil } +// GetWaffo 获取 Waffo 支付集成配置(私钥仅返回是否已配置) // Logan.Li +func (s *SettingService) GetWaffo() (*dto.WaffoSettingsResponse, error) { + cfg := payment.LoadWaffoConfig(s.db) + var gw model.PaymentGatewayConfig + dbHas := s.db.Where("gateway = ?", "waffo").First(&gw).Error == nil + enabled := true + if dbHas { + enabled = gw.Enabled + } + hint := "env" + if dbHas && strings.TrimSpace(gw.PublicKey) != "" { + hint = "db" + } else if dbHas { + hint = "mixed" + } + + meta := payment.ParseWaffoPublicMeta(gw.PublicKey) + mappings := buildProductMappings(cfg, meta.ProductMappings) + productIDs := cloneProductIDs(cfg.ProductIDs) + + return &dto.WaffoSettingsResponse{ + Enabled: enabled, + MerchantID: cfg.MerchantID, + StoreID: cfg.StoreID, + StoreSlug: cfg.StoreSlug, + Environment: cfg.Environment, + SandboxMode: cfg.Environment == "test", + Currency: cfg.Currency, + ProductID: cfg.ProductID, + ProductIDYearly: cfg.ProductIDYearly, + ProductIDPerpetual: cfg.ProductIDPerpetual, + ProductIDs: productIDs, + ProductMappings: mappings, + SuccessURL: cfg.SuccessURL, + WebhookURL: payment.DefaultWebhookURL, + PrivateKeyConfigured: strings.TrimSpace(cfg.PrivateKey) != "", + SourceHint: hint, + }, nil +} + +// UpdateWaffo 保存 Waffo 配置到 payment_gateway_configs(后台优先于 .env) // Logan.Li +func (s *SettingService) UpdateWaffo(req dto.WaffoSettingsUpdate) (*dto.WaffoSettingsResponse, error) { + var gw model.PaymentGatewayConfig + err := s.db.Where("gateway = ?", "waffo").First(&gw).Error + if err != nil { + gw = model.PaymentGatewayConfig{ + Gateway: "waffo", + Enabled: true, + SandboxMode: true, + Currency: "CNY", + Priority: 0, + } + } + + productIDs, mappings := mergeProductIDInputs(req) + + meta := payment.WaffoPublicMeta{ + MerchantID: strings.TrimSpace(req.MerchantID), + StoreID: strings.TrimSpace(req.StoreID), + StoreSlug: strings.TrimSpace(req.StoreSlug), + ProductID: strings.TrimSpace(req.ProductID), + ProductIDYearly: strings.TrimSpace(req.ProductIDYearly), + ProductIDPerpetual: strings.TrimSpace(req.ProductIDPerpetual), + ProductIDs: productIDs, + ProductMappings: toPaymentMappings(mappings), + SuccessURL: strings.TrimSpace(req.SuccessURL), + Currency: strings.TrimSpace(req.Currency), + } + if meta.Currency == "" { + meta.Currency = "CNY" + } + meta.CurrencyYearly = meta.Currency + meta.CurrencyPerpetual = meta.Currency + + // 兼容字段与可扩展映射对齐 // Logan.Li + if meta.ProductIDYearly == "" { + meta.ProductIDYearly = productIDs["yearly"] + } + if meta.ProductIDPerpetual == "" { + meta.ProductIDPerpetual = firstNonEmpty(productIDs["perpetual"], productIDs["lifetime"]) + } + if meta.ProductID == "" { + meta.ProductID = firstNonEmpty(meta.ProductIDPerpetual, meta.ProductIDYearly, productIDs["default"]) + } + + env := strings.ToLower(strings.TrimSpace(req.Environment)) + switch env { + case "live", "prod", "production": + meta.Environment = "live" + gw.SandboxMode = false + default: + meta.Environment = "test" + gw.SandboxMode = true + } + + encoded, err := payment.EncodeWaffoPublicMeta(meta) + if err != nil { + return nil, err + } + gw.PublicKey = encoded + gw.Currency = meta.Currency + gw.Priority = 0 + if req.Enabled != nil { + gw.Enabled = *req.Enabled + } else { + gw.Enabled = true + } + + if req.ClearPrivateKey { + gw.SecretKey = "" + } else if pk := strings.TrimSpace(req.PrivateKey); pk != "" { + if _, err := payment.ParseMerchantPrivateKey(pk); err != nil { + return nil, errors.New("私钥无法解析: " + err.Error()) + } + gw.SecretKey = pk + } + + if err := s.db.Save(&gw).Error; err != nil { + return nil, errors.New("保存 Waffo 配置失败") + } + _ = s.db.Model(&model.PaymentGatewayConfig{}). + Where("gateway <> ? AND enabled = ?", "waffo", true). + Update("priority", 100).Error + + return s.GetWaffo() +} + +// DebugWaffo 用当前 DB/env 配置轻量探测 create-session(不回显私钥) // Logan.Li +func (s *SettingService) DebugWaffo(req dto.WaffoDebugRequest) *dto.WaffoDebugResponse { + cfg := payment.LoadWaffoConfig(s.db) + planType := strings.TrimSpace(strings.ToLower(req.PlanType)) + if planType == "" { + planType = "yearly" + } + productID := strings.TrimSpace(req.ProductID) + if productID == "" { + productID = payment.ResolveProductID(cfg, planType) + } + + authMode := "none" + if cfg.HasAPIKeyAuth() { + authMode = "api_key" + } else if strings.TrimSpace(cfg.StoreSlug) != "" { + authMode = "store_slug" + } + + out := &dto.WaffoDebugResponse{ + AuthMode: authMode, + Environment: cfg.Environment, + PlanType: planType, + ProductIDUsed: productID, + PrivateKeyPresent: strings.TrimSpace(cfg.PrivateKey) != "", + MerchantIDPresent: strings.TrimSpace(cfg.MerchantID) != "", + StoreSlugPresent: strings.TrimSpace(cfg.StoreSlug) != "", + } + + if productID == "" { + out.OK = false + out.ErrorSummary = "未配置可用的 Waffo Product ID" + return out + } + if authMode == "none" { + out.OK = false + out.ErrorSummary = "未配置商户 ID/私钥,且缺少 Store Slug" + return out + } + + buyer := strings.TrimSpace(req.BuyerEmail) + if buyer == "" { + buyer = "debug@opsxshell.local" + } + orderNo := fmt.Sprintf("debug-%d", time.Now().Unix()) + successURL := cfg.SuccessURL + if successURL == "" { + successURL = payment.DefaultSuccessURL + } + + session, err := payment.CreateCheckoutSession(cfg, payment.CreateSessionRequest{ + ProductID: productID, + ProductType: "onetime", + Currency: firstNonEmpty(cfg.Currency, "CNY"), + BuyerEmail: buyer, + SuccessURL: successURL, + OrderMerchantExternalID: orderNo, + Metadata: map[string]string{ + "debug": "true", + "planType": planType, + "orderNo": orderNo, + }, + ExpiresInSeconds: 300, + StoreSlug: cfg.StoreSlug, + Environment: cfg.Environment, + }) + if err != nil { + out.OK = false + out.ErrorSummary = sanitizeDebugError(err.Error()) + out.HTTPStatus = extractHTTPStatus(err.Error()) + return out + } + + out.OK = true + out.HTTPStatus = 200 + out.SessionID = session.SessionID + if u, err := url.Parse(session.CheckoutURL); err == nil && u.Host != "" { + out.CheckoutURLHost = u.Host + } + return out +} + +func mergeProductIDInputs(req dto.WaffoSettingsUpdate) (map[string]string, []dto.WaffoProductMapping) { + ids := map[string]string{} + var mappings []dto.WaffoProductMapping + + if len(req.ProductMappings) > 0 { + for _, m := range req.ProductMappings { + k := strings.TrimSpace(strings.ToLower(m.PlanKey)) + v := strings.TrimSpace(m.ProductID) + if k == "" { + continue + } + mappings = append(mappings, dto.WaffoProductMapping{ + PlanKey: k, + Label: strings.TrimSpace(m.Label), + ProductID: v, + }) + if v != "" { + ids[k] = v + } + } + } + + for k, v := range req.ProductIDs { + k = strings.TrimSpace(strings.ToLower(k)) + v = strings.TrimSpace(v) + if k == "" || v == "" { + continue + } + ids[k] = v + } + + if y := strings.TrimSpace(req.ProductIDYearly); y != "" { + ids["yearly"] = y + } + if p := strings.TrimSpace(req.ProductIDPerpetual); p != "" { + ids["perpetual"] = p + if _, ok := ids["lifetime"]; !ok { + ids["lifetime"] = p + } + } + if d := strings.TrimSpace(req.ProductID); d != "" { + if _, ok := ids["default"]; !ok { + ids["default"] = d + } + if _, ok := ids["perpetual"]; !ok { + ids["perpetual"] = d + } + } + + if len(mappings) == 0 { + mappings = mappingsFromIDs(ids, nil) + } else { + // 用最终 ids 回填表格行 product_id,避免只改了兼容字段 // Logan.Li + for i := range mappings { + if v := ids[mappings[i].PlanKey]; v != "" { + mappings[i].ProductID = v + } + } + } + return ids, mappings +} + +func buildProductMappings(cfg payment.WaffoConfig, stored []payment.WaffoProductMapping) []dto.WaffoProductMapping { + labelHint := map[string]string{} + for _, m := range stored { + k := strings.TrimSpace(strings.ToLower(m.PlanKey)) + if k == "" { + continue + } + if l := strings.TrimSpace(m.Label); l != "" { + labelHint[k] = l + } + } + return mappingsFromIDs(cfg.ProductIDs, labelHint) +} + +func mappingsFromIDs(ids map[string]string, labels map[string]string) []dto.WaffoProductMapping { + if len(ids) == 0 { + return []dto.WaffoProductMapping{ + {PlanKey: "yearly", Label: "包年"}, + {PlanKey: "perpetual", Label: "永久"}, + } + } + defaultLabels := map[string]string{ + "yearly": "包年", + "monthly": "包月", + "perpetual": "永久", + "lifetime": "终身", + "trial": "试用", + "default": "默认", + } + // 稳定顺序:常用 key 优先,其余按字典序 // Logan.Li + order := []string{"trial", "yearly", "monthly", "perpetual", "lifetime", "default"} + seen := map[string]bool{} + out := make([]dto.WaffoProductMapping, 0, len(ids)) + appendOne := func(k string) { + v := strings.TrimSpace(ids[k]) + if v == "" || seen[k] { + return + } + seen[k] = true + label := "" + if labels != nil { + label = labels[k] + } + if label == "" { + label = defaultLabels[k] + } + out = append(out, dto.WaffoProductMapping{PlanKey: k, Label: label, ProductID: v}) + } + for _, k := range order { + appendOne(k) + } + rest := make([]string, 0) + for k := range ids { + k = strings.TrimSpace(strings.ToLower(k)) + if k == "" || seen[k] || strings.TrimSpace(ids[k]) == "" { + continue + } + rest = append(rest, k) + } + for i := 0; i < len(rest); i++ { + for j := i + 1; j < len(rest); j++ { + if rest[j] < rest[i] { + rest[i], rest[j] = rest[j], rest[i] + } + } + } + for _, k := range rest { + appendOne(k) + } + if len(out) == 0 { + return []dto.WaffoProductMapping{ + {PlanKey: "yearly", Label: "包年"}, + {PlanKey: "perpetual", Label: "永久"}, + } + } + return out +} + +func toPaymentMappings(in []dto.WaffoProductMapping) []payment.WaffoProductMapping { + out := make([]payment.WaffoProductMapping, 0, len(in)) + for _, m := range in { + k := strings.TrimSpace(strings.ToLower(m.PlanKey)) + if k == "" { + continue + } + out = append(out, payment.WaffoProductMapping{ + PlanKey: k, + Label: strings.TrimSpace(m.Label), + ProductID: strings.TrimSpace(m.ProductID), + }) + } + return out +} + +func cloneProductIDs(in map[string]string) map[string]string { + if len(in) == 0 { + return map[string]string{} + } + out := make(map[string]string, len(in)) + for k, v := range in { + k = strings.TrimSpace(strings.ToLower(k)) + v = strings.TrimSpace(v) + if k == "" || v == "" { + continue + } + out[k] = v + } + return out +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if s := strings.TrimSpace(v); s != "" { + return s + } + } + return "" +} + +var httpStatusRe = regexp.MustCompile(`\((\d{3})\)`) + +func extractHTTPStatus(msg string) int { + m := httpStatusRe.FindStringSubmatch(msg) + if len(m) < 2 { + return 0 + } + var n int + fmt.Sscanf(m[1], "%d", &n) + return n +} + +func sanitizeDebugError(msg string) string { + msg = strings.TrimSpace(msg) + lower := strings.ToLower(msg) + // 防止误回显 PEM / 私钥片段 // Logan.Li + if strings.Contains(lower, "private key") || strings.Contains(msg, "BEGIN ") { + if idx := strings.Index(lower, "private"); idx >= 0 { + return "私钥相关错误(详情已隐藏)" + } + } + if len(msg) > 300 { + return msg[:300] + } + return msg +} + // ============ 审计日志 ============ // ListAuditLogs 分页查询审计日志 // Logan.Li diff --git a/backend/router/router.go b/backend/router/router.go index 549f167..f2796dc 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -75,7 +75,7 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine { authController := auth.NewAuthController(db, cfg) productController := product.NewProductController(db) customerController := customer.NewCustomerController(db) - licenseController := license.NewLicenseController(db) + licenseController := license.NewLicenseController(db, cfg) planController := plan.NewPlanController(db) orderController := order.NewOrderController(db) entitlementController := entitlement.NewEntitlementController(db) @@ -155,6 +155,8 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine { { register(licenses, "GET", "", licenseController.List) register(licenses, "POST", "", licenseController.Create) + register(licenses, "POST", "/offline", licenseController.IssueOffline) + register(licenses, "GET", "/offline/public-key", licenseController.OfflinePublicKey) register(licenses, "GET", "/export", licenseController.Export) register(licenses, "GET", "/:id", licenseController.Get) register(licenses, "PUT", "/:id", licenseController.Update) @@ -207,7 +209,10 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine { analyticsGroup := protected.Group("/analytics") { register(analyticsGroup, "GET", "/license-growth", analyticsController.GetLicenseGrowth) + register(analyticsGroup, "GET", "/license-growth-detail", analyticsController.GetLicenseGrowthDetail) register(analyticsGroup, "GET", "/product-distribution", analyticsController.GetProductDistribution) + register(analyticsGroup, "GET", "/geo-distribution", analyticsController.GetGeoDistribution) + register(analyticsGroup, "GET", "/platform-distribution", analyticsController.GetPlatformDistribution) } // 日志审计与系统设置 @@ -221,10 +226,19 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine { { register(system, "GET", "", settingController.Get) register(system, "PUT", "", settingController.Update) + register(system, "GET", "/waffo", settingController.GetWaffo) + register(system, "PUT", "/waffo", settingController.UpdateWaffo) + register(system, "POST", "/waffo/test", settingController.DebugWaffo) register(system, "GET", "/license-templates", settingController.ListLicenseTemplates) register(system, "POST", "/license-templates", settingController.SaveLicenseTemplate) register(system, "DELETE", "/license-templates/:id", settingController.DeleteLicenseTemplate) } + + // 支付调试(与 settings/waffo/test 同一 handler) // Logan.Li + paymentGroup := protected.Group("/payment") + { + register(paymentGroup, "POST", "/waffo/debug", settingController.DebugWaffo) + } } } diff --git a/backend/utils/offline_license.go b/backend/utils/offline_license.go new file mode 100644 index 0000000..8f5c98d --- /dev/null +++ b/backend/utils/offline_license.go @@ -0,0 +1,176 @@ +package utils + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +const ( + // OfflineLicensePrefix 离线序列号前缀(与在线 LIC- 区分) // Logan.Li + OfflineLicensePrefix = "LICOFF-1." + // OfflineMaxDurationDays 离线证最长有效期(禁止永久离线证) // Logan.Li + OfflineMaxDurationDays = 365 +) + +// OfflineLicenseClaims 离线许可证声明(自包含,桌面本地验签) // Logan.Li +type OfflineLicenseClaims struct { + V int `json:"v"` + ProductCode string `json:"pid"` + DeviceID string `json:"did"` + Email string `json:"email,omitempty"` + PlanType string `json:"pt"` + IssuedAt int64 `json:"iat"` + ExpiresAt int64 `json:"exp"` + MaxDevices int `json:"md"` + CustomerName string `json:"cn,omitempty"` +} + +// OfflineKeyPair 离线签发密钥对 // Logan.Li +type OfflineKeyPair struct { + Private ed25519.PrivateKey + Public ed25519.PublicKey +} + +// DeriveOfflineKeyPair 从 32 字节 seed(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[:] + } + priv := ed25519.NewKeyFromSeed(raw) + pub := priv.Public().(ed25519.PublicKey) + return &OfflineKeyPair{Private: priv, Public: pub}, nil +} + +// OfflinePublicKeyHex 返回公钥 hex(供桌面端嵌入) // Logan.Li +func (k *OfflineKeyPair) OfflinePublicKeyHex() string { + return hex.EncodeToString(k.Public) +} + +// IsOfflineLicenseKey 判断是否为离线序列号 // Logan.Li +func IsOfflineLicenseKey(key string) bool { + return strings.HasPrefix(strings.TrimSpace(key), "LICOFF-") +} + +// SignOfflineLicense 签发离线序列号:LICOFF-1.. // Logan.Li +func SignOfflineLicense(kp *OfflineKeyPair, claims OfflineLicenseClaims) (string, error) { + if kp == nil || len(kp.Private) == 0 { + return "", errors.New("离线签发密钥未配置") + } + if err := validateOfflineClaims(claims, true); err != nil { + return "", err + } + claims.V = 1 + payload, err := json.Marshal(claims) + if err != nil { + return "", err + } + payloadB64 := base64.RawURLEncoding.EncodeToString(payload) + sig := ed25519.Sign(kp.Private, []byte(payloadB64)) + sigB64 := base64.RawURLEncoding.EncodeToString(sig) + return OfflineLicensePrefix + payloadB64 + "." + sigB64, nil +} + +// VerifyOfflineLicense 验签并解析离线序列号(不校验设备与过期,由调用方决定) // Logan.Li +func VerifyOfflineLicense(publicKey ed25519.PublicKey, serial string) (*OfflineLicenseClaims, error) { + serial = strings.TrimSpace(serial) + if !strings.HasPrefix(serial, OfflineLicensePrefix) { + return nil, errors.New("不是合法的离线序列号(需 LICOFF-1. 前缀)") + } + body := strings.TrimPrefix(serial, OfflineLicensePrefix) + parts := strings.Split(body, ".") + if len(parts) != 2 { + return nil, errors.New("离线序列号格式错误") + } + payloadB64, sigB64 := parts[0], parts[1] + sig, err := base64.RawURLEncoding.DecodeString(sigB64) + if err != nil { + return nil, errors.New("离线序列号签名无法解码") + } + if len(publicKey) != ed25519.PublicKeySize { + return nil, errors.New("离线验签公钥无效") + } + if !ed25519.Verify(publicKey, []byte(payloadB64), sig) { + return nil, errors.New("离线序列号签名无效") + } + payload, err := base64.RawURLEncoding.DecodeString(payloadB64) + if err != nil { + return nil, errors.New("离线序列号载荷无法解码") + } + var claims OfflineLicenseClaims + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, errors.New("离线序列号载荷无法解析") + } + if err := validateOfflineClaims(claims, false); err != nil { + return nil, err + } + return &claims, nil +} + +// ParseOfflinePublicKeyHex 解析公钥 hex // Logan.Li +func ParseOfflinePublicKeyHex(hexKey string) (ed25519.PublicKey, error) { + raw, err := hex.DecodeString(strings.TrimSpace(hexKey)) + if err != nil { + return nil, err + } + if len(raw) != ed25519.PublicKeySize { + return nil, errors.New("公钥长度错误") + } + return ed25519.PublicKey(raw), nil +} + +func validateOfflineClaims(c OfflineLicenseClaims, issuing bool) error { + if strings.TrimSpace(c.DeviceID) == "" { + return errors.New("device_id 不能为空") + } + pt := strings.TrimSpace(c.PlanType) + if pt != "trial" && pt != "yearly" { + return errors.New("离线证仅允许 trial / yearly,禁止永久") + } + if c.ExpiresAt <= 0 || c.IssuedAt <= 0 { + return errors.New("签发/到期时间无效") + } + if c.ExpiresAt <= c.IssuedAt { + return errors.New("到期时间必须晚于签发时间") + } + maxSpan := int64(OfflineMaxDurationDays) * 24 * 3600 + if c.ExpiresAt-c.IssuedAt > maxSpan+3600 { // 容许 1h 时钟 + return errors.New("离线证有效期不得超过 365 天") + } + if issuing { + now := time.Now().Unix() + if c.ExpiresAt <= now { + return errors.New("到期时间已过") + } + if c.ExpiresAt-now > maxSpan+3600 { + return errors.New("离线证有效期不得超过 365 天") + } + } + if c.MaxDevices <= 0 { + c.MaxDevices = 1 + } + if c.MaxDevices > 1 { + // 离线证绑定单设备,强制 1 // Logan.Li + return errors.New("离线证仅支持 1 台设备") + } + if strings.TrimSpace(c.ProductCode) == "" { + return errors.New("product_code 不能为空") + } + return nil +} diff --git a/backend/utils/offline_license_test.go b/backend/utils/offline_license_test.go new file mode 100644 index 0000000..fe668fd --- /dev/null +++ b/backend/utils/offline_license_test.go @@ -0,0 +1,75 @@ +package utils + +import ( + "testing" + "time" +) + +func TestOfflineLicenseSignAndVerify(t *testing.T) { + kp, err := DeriveOfflineKeyPair("319de107090c6fd0aae9b7db343376dd5a22ccfb24b631ee67144141b1d32000") + if err != nil { + t.Fatal(err) + } + now := time.Now().Unix() + claims := OfflineLicenseClaims{ + ProductCode: "opsxshell", + DeviceID: "dev-1", + Email: "a@b.com", + PlanType: "yearly", + IssuedAt: now, + ExpiresAt: now + 86400*30, + MaxDevices: 1, + } + serial, err := SignOfflineLicense(kp, claims) + if err != nil { + t.Fatal(err) + } + if !IsOfflineLicenseKey(serial) { + t.Fatalf("prefix: %s", serial) + } + got, err := VerifyOfflineLicense(kp.Public, serial) + if err != nil { + t.Fatal(err) + } + if got.DeviceID != "dev-1" || got.PlanType != "yearly" { + t.Fatalf("claims mismatch: %+v", got) + } +} + +func TestOfflineLicenseRejectsPerpetual(t *testing.T) { + kp, err := DeriveOfflineKeyPair("319de107090c6fd0aae9b7db343376dd5a22ccfb24b631ee67144141b1d32000") + if err != nil { + t.Fatal(err) + } + now := time.Now().Unix() + _, err = SignOfflineLicense(kp, OfflineLicenseClaims{ + ProductCode: "opsxshell", + DeviceID: "dev-1", + PlanType: "perpetual", + IssuedAt: now, + ExpiresAt: now + 86400, + MaxDevices: 1, + }) + if err == nil { + t.Fatal("expected perpetual reject") + } +} + +func TestOfflineLicenseRejectsOver365(t *testing.T) { + kp, err := DeriveOfflineKeyPair("319de107090c6fd0aae9b7db343376dd5a22ccfb24b631ee67144141b1d32000") + if err != nil { + t.Fatal(err) + } + now := time.Now().Unix() + _, err = SignOfflineLicense(kp, OfflineLicenseClaims{ + ProductCode: "opsxshell", + DeviceID: "dev-1", + PlanType: "yearly", + IssuedAt: now, + ExpiresAt: now + 86400*400, + MaxDevices: 1, + }) + if err == nil { + t.Fatal("expected over-365 reject") + } +} diff --git a/docs/licensing-model.md b/docs/licensing-model.md new file mode 100644 index 0000000..a1f352b --- /dev/null +++ b/docs/licensing-model.md @@ -0,0 +1,69 @@ +# OpsXShell / 多产品授权模型 + +管理端 `opsx-licence` 与客户端(PC / Web / 移动)的最小合理授权结构。目标:在线可座位管理,离线可自包含验签,支付与发证解耦;**产品维度通用**,不绑死单一桌面端。 + +## 核心实体 + +| 实体 | 含义 | +|------|------| +| **Product** | 可售卖产品(如 `opsxshell`;可扩展更多) | +| **Plan** | 定价档位(SKU),不是用户手里的证 | +| **Customer** | 客户(邮箱 / 公司) | +| **License** | 许可证实例(在线 `LIC-…` 或离线 `LICOFF-…`) | +| **Activation** | 设备座位(在线多座位;离线签发时绑 1 台) | +| **Order** | 支付订单;Waffo 只收款,**发证统一走 License** | + +> 「客户权益 Entitlement」仍可能被订单履约写入,但管理菜单已移除独立权益页——运营以 **订单 + License** 为准。 + +## 三档 Plan(OpsXShell 默认) + +| plan_type | 显示名 | 时长 | 设备数 | +|-----------|--------|------|--------| +| `trial` | 体验 | 180 天 | 1 | +| `yearly` | 包年 | 365 天 | 2 | +| `perpetual` | 永久 | 0=永久 | 5 | + +管理端 **定价计划** 列表必须展示所属 **产品**;启动时 seed 会同步中文名。 + +## 在线 vs 离线 + +### 在线证(`LIC-…`) + +- 服务端签发与激活;座位在 `activations` +- 客户端:`/api/v1/license/activate|verify|heartbeat|deactivate` +- 支持 trial / yearly / perpetual;永久仅在线 + +### 离线证(`LICOFF-1..`) + +- Ed25519 自包含;载荷含 `device_id`、`plan_type`、`iat`/`exp` +- **最长 365 天**;仅 trial / yearly;**禁止永久离线证** +- **必须绑定对方提供的 device_id**(换机无效) +- **不可「续期」**:到期后重新签发新序列号(可同设备) +- 管理端独立页:`/offline-licenses` +- 环境变量:`OFFLINE_LICENSE_SEED`(派生密钥);公钥可供客户端嵌入 + +## 支付边界 + +- 配置入口:**支付** 菜单(非系统设置) +- Product ID 使用可扩展映射表(多 SKU) +- 调试:`POST /api/payment/waffo/debug` +- 详见 [waffo-integration.md](./waffo-integration.md) + +## 客户端约定 + +- API Base:`https://l123.91ai.io/api`(或自建域名 + `/api`) +- 激活识别:`LIC-…` → 在线;`LICOFF-…` → 本地验签 +- 激活请求建议带 `device_info` JSON(`platform` / `country` 等),便于仪表盘「平台 / 地理」分布 + +## 仪表盘指标 + +- License 总数、已过期、在线设备(约 5 分钟心跳) +- 本月 / 累计收入、License 增长趋势 +- 产品分布、地理/网络粗分、客户端形态(pc/web/mobile) + +## 废弃 / 避免 + +- 计划英文渣名面向运营(`Trial 180d` 等) +- 旧 `opsx1.` 令牌、客户端粘贴商户私钥当激活码 +- 永久离线证、未绑设备的万能离线串 +- 把支付私钥写进仓库或前端 diff --git a/docs/waffo-integration.md b/docs/waffo-integration.md new file mode 100644 index 0000000..d31d442 --- /dev/null +++ b/docs/waffo-integration.md @@ -0,0 +1,45 @@ +# Waffo 支付与授权服务器打通 + +依据官方文档:[AI Integration](https://docs.waffo.ai/zh/integrate/ai-integration) + +## 正确闭环 + +```text +客户端 Checkout + → opsx-licence 创建本地订单 ORD-… + → API Key / Store Slug → POST https://api.waffo.ai/v1/actions/checkout/create-session + orderMerchantExternalId=ORD-…, metadata, successUrl + → 用户付款 + → Webhook POST /api/v1/webhooks/waffo(order.completed) + → 按 orderMerchantExternalId 标记 paid 并发 License + → successUrl / deep link 回客户端 +``` + +**不要**只拼 `pancake.waffo.ai/store/.../product/...` 托管页:无法可靠写入 `orderMerchantExternalId`,会导致本地订单一直 pending。 + +## 管理端配置(推荐) + +打开 **支付** 页(`/payments`),填写: + +| 项 | 说明 | +|----|------| +| 商户 ID | `MER_…`(不是 Store ID) | +| Store ID / Store Slug | 商店标识 | +| 环境 | test / live | +| API 私钥 | 只写不回显;保存后点「测试连接」调用 `POST /api/payment/waffo/debug` | +| Product ID 映射表 | `plan_type`(或自定义 key)→ Waffo `product_id`,可增删多行 | + +配置优先读 DB,`.env` 仅兜底。Webhook 只读提示一般为: + +`https://你的域名/api/v1/webhooks/waffo`(Raw JSON,事件至少 `order.completed`,尽量 HTTPS)。 + +## `.env` 兜底变量 + +见仓库根目录 `.env.example`:`WAFFO_MERCHANT_ID`、`WAFFO_PRIVATE_KEY` / `_BASE64`、`WAFFO_STORE_SLUG`、`WAFFO_PRODUCT_ID_*`、`SUCCESS_URL` 等。 + +## 验收 + +1. 支付页「测试连接」成功(私钥已配置且签名可用) +2. Checkout 返回的 `checkoutUrl` 来自 session(含 `/checkout/cs_…`) +3. 付完后本地订单 `paid` 并自动有 License +4. Webhook 载荷含 `data.orderMerchantExternalId` = 本地 `ORD-…` diff --git a/frontend/index.html b/frontend/index.html index c4b1fc6..4965a42 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,9 @@ - + + + opsx-licence 授权管理平台 diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..eb74276 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000..a3841ab Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6762495 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/src/api/analytics.js b/frontend/src/api/analytics.js index 1c361b6..9a331b4 100644 --- a/frontend/src/api/analytics.js +++ b/frontend/src/api/analytics.js @@ -29,3 +29,15 @@ export function getLicenseGrowth() { export function getProductDistribution() { return request({ url: '/analytics/product-distribution', method: 'get' }) } + +export function getRevenueAnalytics() { + return request({ url: '/analytics/revenue', method: 'get' }) +} + +export function getGeoDistribution() { + return request({ url: '/analytics/geo-distribution', method: 'get' }) +} + +export function getPlatformDistribution() { + return request({ url: '/analytics/platform-distribution', method: 'get' }) +} diff --git a/frontend/src/api/license.js b/frontend/src/api/license.js index de9a237..05c8f8b 100644 --- a/frontend/src/api/license.js +++ b/frontend/src/api/license.js @@ -49,3 +49,11 @@ export function forceOffline(licenseId, activationId) { export function exportLicenses(params) { return request({ url: '/licenses/export', method: 'get', params, responseType: 'blob' }) } + +export function issueOfflineLicense(data) { + return request({ url: '/licenses/offline', method: 'post', data }) +} + +export function getOfflinePublicKey() { + return request({ url: '/licenses/offline/public-key', method: 'get' }) +} diff --git a/frontend/src/api/setting.js b/frontend/src/api/setting.js index 12c01b3..2255bc0 100644 --- a/frontend/src/api/setting.js +++ b/frontend/src/api/setting.js @@ -1,4 +1,6 @@ +import axios from 'axios' import request from './request' +import { useUserStore } from '@/stores/user' // 系统设置与日志 API // Logan.Li @@ -10,6 +12,42 @@ export function updateSetting(data) { return request({ url: '/settings', method: 'put', data }) } +export function getWaffoSettings() { + return request({ url: '/settings/waffo', method: 'get' }) +} + +export function updateWaffoSettings(data) { + return request({ url: '/settings/waffo', method: 'put', data }) +} + +/** Waffo 测试连接 / 调试(优先 payment,404 时回退 settings,避免误弹 Toast) // Logan.Li */ +export async function testWaffoConnection(data) { + const userStore = useUserStore() + const baseURL = import.meta.env.VITE_API_BASE_URL || '/api' + const headers = { + 'Content-Type': 'application/json', + ...(userStore.token ? { Authorization: `Bearer ${userStore.token}` } : {}), + } + const paths = ['/payment/waffo/debug', '/settings/waffo/test'] + let lastError = null + for (const url of paths) { + try { + const res = await axios({ baseURL, url, method: 'post', data, headers, timeout: 30000 }) + const body = res.data + if (body && (body.code === 0 || body.code === 200)) return body + lastError = new Error(body?.message || '调试失败') + // 业务失败也返回给页面展示,不再试下一路径 // Logan.Li + return Promise.reject(Object.assign(lastError, { response: { data: body, status: res.status } })) + } catch (err) { + lastError = err + const status = err?.response?.status + if (status === 404 || status === 405) continue + throw err + } + } + throw lastError || new Error('调试接口不可用') +} + export function getLicenseTemplates() { return request({ url: '/settings/license-templates', method: 'get' }) } diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index fd57f2d..4eff007 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -25,13 +25,16 @@ 授权管理 - 产品管理 + 产品 - 客户管理 + 客户 - License 管理 + 在线 License + + + 离线 License @@ -41,16 +44,18 @@ 定价与订单 - 定价计划 + 计划 - 订单管理 - - - 客户权益 + 订单 + + + + + diff --git a/frontend/src/views/Licenses.vue b/frontend/src/views/Licenses.vue index 1f27c80..4a55cab 100644 --- a/frontend/src/views/Licenses.vue +++ b/frontend/src/views/Licenses.vue @@ -17,6 +17,14 @@ + + @@ -167,6 +175,7 @@ async function loadData() { const res = await getLicenses({ page: page.value, page_size: pageSize.value, keyword: keyword.value, status: statusFilter.value, product_id: productFilter.value, + channel: 'online', }) list.value = res.data.list || [] total.value = res.data.total @@ -220,14 +229,13 @@ async function toggle(row, status) { async function handleForceOffline(act) { await ElMessageBox.confirm(`确定强制下线设备「${act.device_name || act.device_id}」?`, '提示', { type: 'warning' }) await forceOffline(detail.license.id, act.id) - // 刷新详情 // Logan.Li const res = await getLicense(detail.license.id) detail.activations = res.data.activations || [] } async function exportData() { try { - const res = await exportLicenses({ status: statusFilter.value, product_id: productFilter.value }) + const res = await exportLicenses({ status: statusFilter.value, product_id: productFilter.value, channel: 'online' }) const url = window.URL.createObjectURL(new Blob([res])) const a = document.createElement('a') a.href = url diff --git a/frontend/src/views/OfflineLicenses.vue b/frontend/src/views/OfflineLicenses.vue new file mode 100644 index 0000000..ff575f6 --- /dev/null +++ b/frontend/src/views/OfflineLicenses.vue @@ -0,0 +1,247 @@ + + + + + diff --git a/frontend/src/views/Payments.vue b/frontend/src/views/Payments.vue new file mode 100644 index 0000000..10a8ef8 --- /dev/null +++ b/frontend/src/views/Payments.vue @@ -0,0 +1,329 @@ + + + + + diff --git a/frontend/src/views/Plans.vue b/frontend/src/views/Plans.vue index 4a6fb97..4120ee5 100644 --- a/frontend/src/views/Plans.vue +++ b/frontend/src/views/Plans.vue @@ -2,14 +2,24 @@
- + + + 新增计划
+ + + - - + + @@ -43,18 +53,36 @@ />
- + - - - - + +
+ + + {{ p.name }} + {{ p.code || p.product_code || '' }} + + +
+ {{ form.id ? '编辑时不可更换产品,避免影响已售订单' : '计划必须归属某个产品,列表与筛选均按产品维度管理' }} +
+
+
+ + - - - - + + + + @@ -89,7 +117,7 @@ const total = ref(0) const dialogVisible = ref(false) const formRef = ref(null) -const form = reactive({ id: null, product_id: null, name: '', plan_type: 'monthly', price: 0, currency: 'USD', duration: 30, max_devices: 1, features: [] }) +const form = reactive({ id: null, product_id: null, name: '', plan_type: 'yearly', price: 0, currency: 'CNY', duration: 365, max_devices: 2, features: [] }) const rules = { product_id: [{ required: true, message: '请选择产品' }], name: [{ required: true, message: '请输入名称' }], @@ -97,19 +125,60 @@ const rules = { price: [{ required: true, message: '请输入价格' }], } -function planTypeLabel(t) { return { perpetual: '买断', lifetime: '终身', monthly: '月订阅', yearly: '年订阅' }[t] || t } +const PLAN_DEFAULTS = { + trial: { name: '体验', duration: 180, max_devices: 1, price: 0 }, + yearly: { name: '包年', duration: 365, max_devices: 2, price: 99 }, + perpetual: { name: '永久', duration: 0, max_devices: 5, price: 199 }, +} + +function planTypeLabel(t) { + return { trial: '体验', yearly: '包年', perpetual: '永久', lifetime: '永久', monthly: '月订阅' }[t] || t +} + +function productLabel(row) { + if (row.product_name) return row.product_name + const p = products.value.find((x) => x.id === row.product_id) + return p?.name || `产品 #${row.product_id || '—'}` +} + +function onPlanTypeChange(t) { + const d = PLAN_DEFAULTS[t] + if (!d) return + if (!form.id) { + form.name = d.name + form.duration = d.duration + form.max_devices = d.max_devices + form.price = d.price + form.currency = 'CNY' + } +} + +function onFilterChange() { + page.value = 1 + loadData() +} async function loadData() { loading.value = true try { - const res = await getPlans({ page: page.value, page_size: pageSize.value, product_id: productFilter.value }) + const res = await getPlans({ page: page.value, page_size: pageSize.value, product_id: productFilter.value || undefined }) list.value = res.data.list || [] total.value = res.data.total } finally { loading.value = false } } function openCreate() { - Object.assign(form, { id: null, product_id: null, name: '', plan_type: 'monthly', price: 0, currency: 'USD', duration: 30, max_devices: 1, features: [] }) + Object.assign(form, { + id: null, + product_id: productFilter.value || null, + name: '包年', + plan_type: 'yearly', + price: 99, + currency: 'CNY', + duration: 365, + max_devices: 2, + features: [], + }) dialogVisible.value = true } @@ -149,4 +218,27 @@ onMounted(async () => { diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index 7f128ff..32c247d 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -46,6 +46,14 @@ + + @@ -65,7 +73,9 @@