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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-12 20:25:46 +08:00
1 parent 0966375076
commit 8cc2c83f9d
41 files changed
+3177 -332

No files matched your search

+22 -3
View File
@@ -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
# ============================================
+50 -76
View File
@@ -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
+6 -2
View File
@@ -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 != "" {
+17 -17
View File
@@ -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
@@ -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})
}
@@ -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)
@@ -310,15 +310,16 @@ func renderSimplePage(title, headline, body, extraHTML, deepLink string) string
</html>`
}
// 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
}
@@ -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"))
+99 -8
View File
@@ -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"`
+12 -11
View File
@@ -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
+33 -1
View File
@@ -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"`
}
+1 -1
View File
@@ -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"`
@@ -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
}
@@ -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{
@@ -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
}
@@ -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 // 已处理,幂等返回
+326 -26
View File
@@ -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 {
@@ -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]
}
+24 -14
View File
@@ -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&currency=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)
}
}
+16 -14
View File
@@ -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,
}
}
@@ -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
+15 -1
View File
@@ -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)
}
}
}
+176
View File
@@ -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.<payload_b64url>.<sig_b64url> // 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
}
+75
View File
@@ -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")
}
}
+69
View File
@@ -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.<payload>.<sig>`)
- 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.` 令牌、客户端粘贴商户私钥当激活码
- 永久离线证、未绑设备的万能离线串
- 把支付私钥写进仓库或前端
+45
View File
@@ -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-…`
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>opsx-licence 授权管理平台</title>
</head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0b1f3a"/>
<circle cx="11" cy="11" r="6" fill="#409eff"/>
<circle cx="11" cy="11" r="2.5" fill="#0b1f3a"/>
<rect x="16" y="9.5" width="11" height="3.2" rx="1" fill="#409eff"/>
<rect x="22" y="12.5" width="2.2" height="4.5" rx="0.6" fill="#409eff"/>
<rect x="25.5" y="12.5" width="2.2" height="6" rx="0.6" fill="#409eff"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

+12
View File
@@ -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' })
}
+8
View File
@@ -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' })
}
+38
View File
@@ -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' })
}
+18 -10
View File
@@ -25,13 +25,16 @@
<span>授权管理</span>
</template>
<el-menu-item index="/products">
<el-icon><Box /></el-icon>产品管理
<el-icon><Box /></el-icon>产品
</el-menu-item>
<el-menu-item index="/customers">
<el-icon><User /></el-icon>客户管理
<el-icon><User /></el-icon>客户
</el-menu-item>
<el-menu-item index="/licenses">
<el-icon><Key /></el-icon>License 管理
<el-icon><Key /></el-icon>在线 License
</el-menu-item>
<el-menu-item index="/offline-licenses">
<el-icon><Lock /></el-icon>离线 License
</el-menu-item>
</el-sub-menu>
@@ -41,16 +44,18 @@
<span>定价与订单</span>
</template>
<el-menu-item index="/plans">
<el-icon><PriceTag /></el-icon>定价计划
<el-icon><PriceTag /></el-icon>计划
</el-menu-item>
<el-menu-item index="/orders">
<el-icon><ShoppingCart /></el-icon>订单管理
</el-menu-item>
<el-menu-item index="/entitlements">
<el-icon><Medal /></el-icon>客户权益
<el-icon><ShoppingCart /></el-icon>订单
</el-menu-item>
</el-sub-menu>
<el-menu-item index="/payments">
<el-icon><Wallet /></el-icon>
<template #title>支付</template>
</el-menu-item>
<el-sub-menu index="system">
<template #title>
<el-icon><TrendCharts /></el-icon>
@@ -141,7 +146,10 @@ if (userStore.token && !userInfo.value) {
})
}
const activeMenu = computed(() => route.path)
const activeMenu = computed(() => {
if (route.path.startsWith('/payments')) return '/payments'
return route.path
})
// 退出登录 / 修改密码 // Logan.Li
async function handleCommand(command) {
@@ -167,7 +175,7 @@ const pwdRules = {
{ min: 6, message: '密码长度至少 6 位', trigger: 'blur' },
],
confirm_password: [
{ required: true, message: '请确认新密码', trigger: 'blur' },
{ required: true, message: '请确认密码', trigger: 'blur' },
{
validator: (_rule, value, callback) => {
if (value !== pwdForm.new_password) callback(new Error('两次输入的密码不一致'))
+15 -5
View File
@@ -35,7 +35,13 @@ const routes = [
path: 'licenses',
name: 'Licenses',
component: () => import('@/views/Licenses.vue'),
meta: { title: 'License 管理', icon: 'Key' },
meta: { title: '在线 License', icon: 'Key' },
},
{
path: 'offline-licenses',
name: 'OfflineLicenses',
component: () => import('@/views/OfflineLicenses.vue'),
meta: { title: '离线 License', icon: 'Lock' },
},
{
path: 'plans',
@@ -50,10 +56,14 @@ const routes = [
meta: { title: '订单管理', icon: 'ShoppingCart' },
},
{
path: 'entitlements',
name: 'Entitlements',
component: () => import('@/views/Entitlements.vue'),
meta: { title: '客户权益', icon: 'Medal' },
path: 'payments',
name: 'Payments',
component: () => import('@/views/Payments.vue'),
meta: { title: '支付配置', icon: 'Wallet' },
},
{
path: 'payments/waffo',
redirect: '/payments',
},
{
path: 'logs',
+148 -33
View File
@@ -2,10 +2,10 @@
<div class="dashboard">
<!-- 统计卡片 -->
<el-row :gutter="16">
<el-col :span="6" v-for="card in statCards" :key="card.title">
<el-col :xs="12" :sm="8" :md="6" :lg="4" v-for="card in statCards" :key="card.title" class="stat-col">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<el-icon :size="36" :color="card.color">
<el-icon :size="32" :color="card.color">
<component :is="card.icon" />
</el-icon>
<div class="stat-info">
@@ -18,24 +18,42 @@
</el-row>
<el-row :gutter="16" style="margin-top: 16px">
<!-- 收入趋势 -->
<el-col :span="14">
<el-card shadow="never">
<template #header>近 6 个月收入趋势</template>
<v-chart class="chart" :option="revenueOption" autoresize />
<template #header>License 增长趋势</template>
<v-chart class="chart" :option="growthOption" autoresize />
</el-card>
</el-col>
<!-- 产品分布 -->
<el-col :span="10">
<el-card shadow="never">
<template #header>License 产品分布</template>
<template #header>收入趋势</template>
<v-chart class="chart" :option="revenueOption" autoresize />
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="8">
<el-card shadow="never">
<template #header>产品分布</template>
<v-chart class="chart" :option="productOption" autoresize />
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never">
<template #header>地理分布</template>
<v-chart class="chart" :option="geoOption" autoresize />
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never">
<template #header>平台分布</template>
<v-chart class="chart" :option="platformOption" autoresize />
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top: 16px">
<!-- 即将到期 -->
<el-col :span="14">
<el-card shadow="never">
<template #header>即将到期 License(30 天内)</template>
@@ -54,7 +72,6 @@
</el-table>
</el-card>
</el-col>
<!-- 最近活动 -->
<el-col :span="10">
<el-card shadow="never">
<template #header>最近操作</template>
@@ -70,6 +87,7 @@
<div style="color: #606266; font-size: 13px">{{ act.detail }}</div>
</el-timeline-item>
</el-timeline>
<el-empty v-if="!activities.length" description="暂无操作" :image-size="64" />
</el-card>
</el-col>
</el-row>
@@ -77,7 +95,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ref, onMounted, computed } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
@@ -85,7 +103,8 @@ import { BarChart, PieChart, LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components'
import {
getDashboardStats, getExpiringLicenses, getRecentActivities,
getRevenueStats, getProductDistribution,
getRevenueStats, getProductDistribution, getLicenseGrowth,
getRevenueAnalytics, getGeoDistribution, getPlatformDistribution,
} from '@/api/analytics'
use([CanvasRenderer, BarChart, PieChart, LineChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent])
@@ -95,52 +114,148 @@ const expiring = ref([])
const activities = ref([])
const revenueData = ref([])
const productData = ref([])
const growthData = ref([])
const geoData = ref([])
const platformData = ref([])
function formatMoney(v) {
if (v == null || v === '') return '—'
const n = Number(v)
if (Number.isNaN(n)) return String(v)
return n.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
}
const statCards = computed(() => [
{ title: 'License 总数', value: stats.value.total_licenses ?? '—', icon: 'Key', color: '#409eff' },
{ title: '活跃授权', value: stats.value.active_licenses ?? '—', icon: 'CircleCheck', color: '#67c23a' },
{ title: '客户数', value: stats.value.total_customers ?? '—', icon: 'User', color: '#e6a23c' },
{ title: '在线设备', value: stats.value.online_devices ?? '—', icon: 'Monitor', color: '#f56c6c' },
{ title: '已过期', value: stats.value.expired_licenses ?? stats.value.expired ?? '—', icon: 'WarningFilled', color: '#e6a23c' },
{ title: '当前在线', value: stats.value.online_devices ?? stats.value.current_online ?? '—', icon: 'Monitor', color: '#67c23a' },
{ title: '本月收入', value: formatMoney(stats.value.month_revenue ?? stats.value.revenue_month), icon: 'Coin', color: '#f56c6c' },
{ title: '活跃授权', value: stats.value.active_licenses ?? '—', icon: 'CircleCheck', color: '#409eff' },
{ title: '客户数', value: stats.value.total_customers ?? '—', icon: 'User', color: '#909399' },
])
function emptyPie() {
return [{ name: '暂无数据', value: 0 }]
}
const growthOption = computed(() => ({
tooltip: { trigger: 'axis' },
legend: { data: ['新增', '累计'], bottom: 0 },
grid: { left: 40, right: 20, top: 24, bottom: 40 },
xAxis: { type: 'category', data: growthData.value.map((d) => d.period || d.date || d.month) },
yAxis: { type: 'value', minInterval: 1 },
series: [
{
name: '新增',
type: 'bar',
data: growthData.value.map((d) => d.new_count ?? d.count ?? d.value ?? 0),
color: '#409eff',
},
{
name: '累计',
type: 'line',
smooth: true,
data: growthData.value.map((d) => d.total_count ?? d.total ?? null),
color: '#67c23a',
},
],
}))
const revenueOption = computed(() => ({
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: revenueData.value.map((d) => d.period) },
grid: { left: 48, right: 16, top: 24, bottom: 28 },
xAxis: { type: 'category', data: revenueData.value.map((d) => d.period || d.date || d.month) },
yAxis: { type: 'value' },
series: [{ data: revenueData.value.map((d) => d.amount), type: 'line', smooth: true, areaStyle: {}, color: '#409eff' }],
series: [{
data: revenueData.value.map((d) => d.amount ?? d.revenue ?? d.value ?? 0),
type: 'line',
smooth: true,
areaStyle: { opacity: 0.15 },
color: '#f56c6c',
}],
}))
const productOption = computed(() => ({
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [{
type: 'pie', radius: ['40%', '70%'],
data: productData.value.map((d) => ({ name: d.product_name || '未知', value: d.count })),
type: 'pie',
radius: ['40%', '68%'],
data: (productData.value.length ? productData.value : emptyPie()).map((d) => ({
name: d.product_name || d.name || '未知',
value: d.count ?? d.value ?? 0,
})),
}],
}))
onMounted(async () => {
const geoOption = computed(() => ({
tooltip: { trigger: 'item' },
legend: { bottom: 0, type: 'scroll' },
series: [{
type: 'pie',
radius: ['35%', '65%'],
data: (geoData.value.length ? geoData.value : emptyPie()).map((d) => ({
name: d.country || d.region || d.name || '未知',
value: d.count ?? d.value ?? 0,
})),
}],
}))
const platformOption = computed(() => ({
tooltip: { trigger: 'axis' },
grid: { left: 48, right: 16, top: 24, bottom: 28 },
xAxis: {
type: 'category',
data: (platformData.value.length ? platformData.value : [{ name: '暂无', count: 0 }]).map(
(d) => d.platform || d.name || '未知',
),
},
yAxis: { type: 'value', minInterval: 1 },
series: [{
type: 'bar',
data: (platformData.value.length ? platformData.value : [{ count: 0 }]).map((d) => d.count ?? d.value ?? 0),
color: '#909399',
}],
}))
async function safe(fn) {
try {
const [s, e, a, r, p] = await Promise.all([
getDashboardStats(), getExpiringLicenses(30), getRecentActivities(8),
getRevenueStats(), getProductDistribution(),
])
stats.value = s.data
expiring.value = e.data
activities.value = a.data
revenueData.value = r.data
productData.value = p.data
return await fn()
} catch {
// 拦截器已处理
return null
}
}
onMounted(async () => {
const [s, e, a, r, p, g, geo, plat, ra] = await Promise.all([
safe(() => getDashboardStats()),
safe(() => getExpiringLicenses(30)),
safe(() => getRecentActivities(8)),
safe(() => getRevenueStats()),
safe(() => getProductDistribution()),
safe(() => getLicenseGrowth()),
safe(() => getGeoDistribution()),
safe(() => getPlatformDistribution()),
safe(() => getRevenueAnalytics()),
])
stats.value = s?.data || {}
expiring.value = e?.data || []
activities.value = a?.data || []
const rev = ra?.data || r?.data || []
revenueData.value = Array.isArray(rev) ? rev : (rev.list || rev.series || [])
productData.value = p?.data || []
growthData.value = g?.data || []
geoData.value = geo?.data || []
platformData.value = plat?.data || []
})
</script>
<style scoped>
.stat-card { margin-bottom: 0; }
.stat-content { display: flex; align-items: center; gap: 16px; }
.stat-info { display: flex; flex-direction: column; }
.stat-col { margin-bottom: 16px; }
.stat-card { height: 100%; }
.stat-content { display: flex; align-items: center; gap: 14px; }
.stat-info { display: flex; flex-direction: column; min-width: 0; }
.stat-title { font-size: 13px; color: #909399; }
.stat-value { font-size: 26px; font-weight: 600; color: #303133; }
.stat-value { font-size: 24px; font-weight: 600; color: #303133; line-height: 1.2; word-break: break-all; }
.chart { height: 280px; }
</style>
+10 -2
View File
@@ -17,6 +17,14 @@
</div>
</div>
<el-alert
type="info"
:closable="false"
show-icon
title="本页默认仅展示在线 License。离线序列号请前往「授权管理 → 离线 License」。"
style="margin-bottom: 12px"
/>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="license_key" label="License Key" min-width="220" />
<el-table-column prop="customer_name" label="客户" min-width="100" />
@@ -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
+247
View File
@@ -0,0 +1,247 @@
<template>
<div>
<el-card shadow="never">
<div class="toolbar">
<div class="filters">
<el-input v-model="keyword" placeholder="搜索短码 / 设备 ID / 客户" clearable style="width: 240px" @keyup.enter="loadData" @clear="loadData" />
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 120px" @change="loadData">
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</div>
<div>
<el-button type="primary" @click="openIssue">签发离线序列号</el-button>
</div>
</div>
<el-alert
type="warning"
:closable="false"
show-icon
title="离线证不可续期:绑定固定设备、最长 365 天;到期请「到期重签」签发新序列号,旧证不会自动延长。"
style="margin-bottom: 12px"
/>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column label="序列号" min-width="200">
<template #default="{ row }">
<div class="serial-cell">
<code class="serial-short">{{ row.license_key_short || row.license_key }}</code>
<el-button size="small" link type="primary" @click="copyKey(row.license_key)">复制全文</el-button>
<el-button size="small" link @click="showFull(row)">查看</el-button>
</div>
</template>
</el-table-column>
<el-table-column prop="device_id" label="绑定设备" min-width="180" show-overflow-tooltip />
<el-table-column prop="customer_name" label="客户" min-width="100" />
<el-table-column prop="product_name" label="产品" min-width="100" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="end_date" label="到期" width="110" />
<el-table-column label="剩余" width="70">
<template #default="{ row }">
<span :style="{ color: row.days_left <= 7 ? '#f56c6c' : '' }">{{ row.days_left }}天</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="reissue(row)">到期重签</el-button>
<el-button size="small" type="danger" @click="revoke(row)">吊销</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page" v-model:page-size="pageSize"
:total="total" layout="total, prev, pager, next" style="margin-top: 16px"
@current-change="loadData"
/>
</el-card>
<el-dialog v-model="issueVisible" title="签发离线序列号" width="560px">
<el-alert
type="info"
:closable="false"
show-icon
title="需用户提供 device_id。适用于 Web / PC / 移动端通用离线激活(客户端本地验签)。"
style="margin-bottom: 16px"
/>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="设备 ID" prop="device_id">
<el-input v-model="form.device_id" placeholder="客户端复制的 device_id" />
</el-form-item>
<el-form-item label="邮箱"><el-input v-model="form.email" placeholder="可选" /></el-form-item>
<el-form-item label="客户名"><el-input v-model="form.customer_name" placeholder="可选" /></el-form-item>
<el-form-item label="产品编码"><el-input v-model="form.product_code" placeholder="默认 opsxshell,可改多产品" /></el-form-item>
<el-form-item label="计划" prop="plan_type">
<el-select v-model="form.plan_type" style="width: 100%" @change="onPlanChange">
<el-option value="yearly" label="包年 ≤365 天" />
<el-option value="trial" label="体验 ≤180 天" />
</el-select>
</el-form-item>
<el-form-item label="有效天数" prop="duration_days">
<el-input-number v-model="form.duration_days" :min="1" :max="maxDays" />
<span style="margin-left: 8px; color: #909399">最长 {{ maxDays }} 天 · 禁止永久</span>
</el-form-item>
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
<div v-if="result" class="result-box">
<div class="result-label">完整序列号(仅此时完整展示,请立即复制给用户)</div>
<el-input type="textarea" :rows="3" :model-value="result.serial" readonly class="serial-full" />
<div class="result-meta">到期:{{ result.expires_at }} · 设备:{{ result.device_id }}</div>
<el-button size="small" type="primary" style="margin-top: 8px" @click="copyKey(result.serial)">复制序列号</el-button>
</div>
<template #footer>
<el-button @click="issueVisible = false">关闭</el-button>
<el-button type="primary" :loading="issuing" @click="submitIssue">签发</el-button>
</template>
</el-dialog>
<el-dialog v-model="fullVisible" title="完整序列号" width="640px">
<el-input type="textarea" :rows="5" :model-value="fullSerial" readonly class="serial-full" />
<template #footer>
<el-button type="primary" @click="copyKey(fullSerial)">复制</el-button>
<el-button @click="fullVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getLicenses, issueOfflineLicense, revokeLicense } from '@/api/license'
const list = ref([])
const loading = ref(false)
const keyword = ref('')
const statusFilter = ref('')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const statusOptions = [
{ label: '未激活', value: 'inactive' }, { label: '已激活', value: 'active' },
{ label: '已过期', value: 'expired' }, { label: '已吊销', value: 'revoked' }, { label: '已暂停', value: 'suspended' },
]
function statusLabel(s) { return statusOptions.find((o) => o.value === s)?.label || s }
function statusTag(s) {
return { active: 'success', inactive: 'info', expired: 'warning', revoked: 'danger', suspended: 'warning' }[s] || ''
}
const issueVisible = ref(false)
const issuing = ref(false)
const formRef = ref(null)
const form = reactive({
device_id: '', email: '', customer_name: '', product_code: 'opsxshell',
plan_type: 'yearly', duration_days: 365, notes: '',
})
const result = ref(null)
const maxDays = computed(() => (form.plan_type === 'trial' ? 180 : 365))
const rules = {
device_id: [{ required: true, message: '请输入 device_id' }],
plan_type: [{ required: true, message: '请选择计划' }],
duration_days: [{ required: true, message: '请输入天数' }],
}
const fullVisible = ref(false)
const fullSerial = ref('')
function onPlanChange() {
if (form.duration_days > maxDays.value) form.duration_days = maxDays.value
}
async function loadData() {
loading.value = true
try {
const res = await getLicenses({
page: page.value, page_size: pageSize.value,
keyword: keyword.value, status: statusFilter.value, channel: 'offline',
})
list.value = res.data.list || []
total.value = res.data.total
} finally { loading.value = false }
}
function openIssue() {
Object.assign(form, {
device_id: '', email: '', customer_name: '', product_code: 'opsxshell',
plan_type: 'yearly', duration_days: 365, notes: '',
})
result.value = null
issueVisible.value = true
}
function reissue(row) {
Object.assign(form, {
device_id: row.device_id || '',
email: '',
customer_name: row.customer_name || '',
product_code: 'opsxshell',
plan_type: row.is_trial ? 'trial' : 'yearly',
duration_days: row.is_trial ? 180 : 365,
notes: `重签自 #${row.id}`,
})
result.value = null
issueVisible.value = true
}
async function submitIssue() {
await formRef.value.validate(async (valid) => {
if (!valid) return
issuing.value = true
try {
const res = await issueOfflineLicense({
device_id: form.device_id.trim(),
email: form.email.trim() || undefined,
customer_name: form.customer_name.trim() || undefined,
plan_type: form.plan_type,
duration_days: form.duration_days,
notes: form.notes,
product_code: form.product_code.trim() || 'opsxshell',
})
result.value = res.data
loadData()
ElMessage.success('已签发(请复制序列号;列表仅显示短码)')
} catch { /* */ } finally {
issuing.value = false
}
})
}
function showFull(row) {
fullSerial.value = row.license_key
fullVisible.value = true
}
async function copyKey(text) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.error('复制失败,请手动选择')
}
}
async function revoke(row) {
const { value } = await ElMessageBox.prompt('吊销原因', '吊销离线证', { inputType: 'textarea' })
await revokeLicense(row.id, value || '')
loadData()
}
onMounted(loadData)
</script>
<style scoped>
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
.filters { display: flex; gap: 10px; }
.serial-cell { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.serial-short { font-size: 12px; background: #f5f7fa; padding: 2px 6px; border-radius: 4px; }
.serial-full :deep(textarea) { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; word-break: break-all; }
.result-box { margin-top: 12px; padding-top: 12px; border-top: 1px solid #ebeef5; }
.result-label { font-size: 13px; margin-bottom: 6px; color: #606266; }
.result-meta { margin-top: 6px; font-size: 12px; color: #909399; }
</style>
+329
View File
@@ -0,0 +1,329 @@
<template>
<div v-loading="loading">
<el-card shadow="never">
<template #header>
<div class="card-header">
<div>
<span class="title">Waffo 支付</span>
<el-tag size="small" type="info" style="margin-left: 8px">后台配置优先于 .env</el-tag>
</div>
<div class="header-actions">
<el-button :loading="debugging" @click="testConnection">测试连接 / 调试</el-button>
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
</div>
</div>
</template>
<el-alert
type="info"
:closable="false"
show-icon
title="商户 ID / Store / 私钥来自 Waffo Dashboard「集成」页。复制密钥后粘贴保存即可,无需手改服务器 .env。"
style="margin-bottom: 16px"
/>
<el-form :model="waffo" label-width="140px" style="max-width: 860px">
<el-form-item label="启用">
<el-switch v-model="waffo.enabled" />
</el-form-item>
<el-form-item label="商户 ID">
<el-input v-model="waffo.merchant_id" placeholder="MER_…" />
</el-form-item>
<el-form-item label="Store ID">
<el-input v-model="waffo.store_id" placeholder="STO_…(备用)" />
</el-form-item>
<el-form-item label="Store Slug">
<el-input v-model="waffo.store_slug" placeholder="如 opsxshell-pwsi6m4n" />
</el-form-item>
<el-form-item label="环境">
<el-radio-group v-model="waffo.environment">
<el-radio value="test">测试 (test)</el-radio>
<el-radio value="live">生产 (live)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="API 私钥">
<div class="secret-row">
<el-tag :type="waffo.private_key_configured ? 'success' : 'warning'" size="small">
{{ waffo.private_key_configured ? '已配置' : '未配置' }}
</el-tag>
<el-button size="small" @click="showPrivateKey = !showPrivateKey">
{{ showPrivateKey ? '取消更换' : (waffo.private_key_configured ? '更换' : '填写') }}
</el-button>
<el-button v-if="waffo.private_key_configured" size="small" type="danger" link @click="clearPrivateKey">清除</el-button>
</div>
<el-input
v-if="showPrivateKey"
v-model="privateKeyInput"
type="textarea"
:rows="4"
placeholder="粘贴 PEM 或 Dashboard Base64 密钥(保存后不再回显)"
style="margin-top: 8px"
/>
</el-form-item>
<el-form-item label="货币">
<el-input v-model="waffo.currency" style="width: 120px" />
</el-form-item>
<el-form-item label="SUCCESS_URL">
<el-input v-model="waffo.success_url" placeholder="支付成功回跳" />
</el-form-item>
<el-form-item label="Webhook URL">
<el-input :model-value="waffo.webhook_url" readonly>
<template #append>
<el-button @click="copyWebhook">复制</el-button>
</template>
</el-input>
<div class="hint">在 Waffo Dashboard → Webhooks 配置此地址(POST,Raw JSON,order.completed)</div>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" style="margin-top: 16px">
<template #header>
<div class="card-header">
<span class="title">Product ID 映射</span>
<el-button size="small" type="primary" @click="addMapping">新增行</el-button>
</div>
</template>
<el-alert
type="warning"
:closable="false"
show-icon
title="将计划类型 / 业务 key 映射到 Waffo Product ID(如 yearly → PROD_xxx)。保存时会同步兼容字段 product_id_yearly / product_id_perpetual。"
style="margin-bottom: 12px"
/>
<el-table :data="productMappings" border stripe>
<el-table-column label="计划类型 / Key" min-width="180">
<template #default="{ row }">
<el-select
v-model="row.plan_key"
filterable
allow-create
default-first-option
placeholder="如 yearly / perpetual / trial"
style="width: 100%"
>
<el-option v-for="opt in planKeyOptions" :key="opt" :label="opt" :value="opt" />
</el-select>
</template>
</el-table-column>
<el-table-column label="说明" min-width="140">
<template #default="{ row }">
<el-input v-model="row.label" placeholder="可选说明" />
</template>
</el-table-column>
<el-table-column label="Waffo Product ID" min-width="220">
<template #default="{ row }">
<el-input v-model="row.product_id" placeholder="PROD_…" />
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ $index }">
<el-button size="small" type="danger" link @click="removeMapping($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card v-if="debugResult" shadow="never" style="margin-top: 16px">
<template #header>调试结果</template>
<pre class="debug-box">{{ debugResult }}</pre>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getWaffoSettings, updateWaffoSettings, testWaffoConnection } from '@/api/setting'
const planKeyOptions = ['trial', 'yearly', 'perpetual', 'monthly', 'lifetime']
const loading = ref(false)
const saving = ref(false)
const debugging = ref(false)
const showPrivateKey = ref(false)
const privateKeyInput = ref('')
const clearKeyFlag = ref(false)
const debugResult = ref('')
const productMappings = ref([])
const waffo = reactive({
enabled: true,
merchant_id: '',
store_id: '',
store_slug: '',
environment: 'test',
currency: 'CNY',
product_id: '',
product_id_yearly: '',
product_id_perpetual: '',
product_mappings: [],
success_url: '',
webhook_url: 'https://l123.91ai.io/api/v1/webhooks/waffo',
private_key_configured: false,
})
function normalizeMappings(data = {}) {
if (Array.isArray(data.product_mappings) && data.product_mappings.length) {
return data.product_mappings.map((m) => ({
plan_key: m.plan_key || m.key || m.plan_type || '',
label: m.label || '',
product_id: m.product_id || '',
}))
}
const rows = []
if (data.product_id_yearly) {
rows.push({ plan_key: 'yearly', label: '包年', product_id: data.product_id_yearly })
}
if (data.product_id_perpetual || data.product_id) {
rows.push({
plan_key: 'perpetual',
label: '永久',
product_id: data.product_id_perpetual || data.product_id,
})
}
if (!rows.length) {
rows.push(
{ plan_key: 'yearly', label: '包年', product_id: '' },
{ plan_key: 'perpetual', label: '永久', product_id: '' },
)
}
return rows
}
function mappingsToCompatFields(rows) {
const map = {}
for (const row of rows) {
const key = (row.plan_key || '').trim()
if (!key) continue
map[key] = (row.product_id || '').trim()
}
return {
product_mappings: rows
.filter((r) => (r.plan_key || '').trim())
.map((r) => ({
plan_key: r.plan_key.trim(),
label: r.label || '',
product_id: (r.product_id || '').trim(),
})),
product_id_yearly: map.yearly || '',
product_id_perpetual: map.perpetual || map.lifetime || '',
product_id: map.perpetual || map.lifetime || map.yearly || '',
}
}
function addMapping() {
productMappings.value.push({ plan_key: '', label: '', product_id: '' })
}
function removeMapping(index) {
productMappings.value.splice(index, 1)
if (!productMappings.value.length) addMapping()
}
async function load() {
loading.value = true
try {
const res = await getWaffoSettings()
Object.assign(waffo, res.data || {})
if (!waffo.environment) waffo.environment = waffo.sandbox_mode === false ? 'live' : 'test'
productMappings.value = normalizeMappings(res.data || {})
showPrivateKey.value = false
privateKeyInput.value = ''
clearKeyFlag.value = false
debugResult.value = ''
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
const compat = mappingsToCompatFields(productMappings.value)
const payload = {
enabled: waffo.enabled,
merchant_id: waffo.merchant_id,
store_id: waffo.store_id,
store_slug: waffo.store_slug,
environment: waffo.environment,
currency: waffo.currency,
success_url: waffo.success_url,
clear_private_key: clearKeyFlag.value,
...compat,
}
if (showPrivateKey.value && privateKeyInput.value.trim()) {
payload.private_key = privateKeyInput.value.trim()
}
const res = await updateWaffoSettings(payload)
Object.assign(waffo, res.data || {})
productMappings.value = normalizeMappings(res.data || { ...compat })
showPrivateKey.value = false
privateKeyInput.value = ''
clearKeyFlag.value = false
ElMessage.success('支付配置已保存')
} catch { /* */ } finally {
saving.value = false
}
}
function clearPrivateKey() {
clearKeyFlag.value = true
waffo.private_key_configured = false
privateKeyInput.value = ''
showPrivateKey.value = false
ElMessage.info('将在保存后清除私钥')
}
async function copyWebhook() {
try {
await navigator.clipboard.writeText(waffo.webhook_url || '')
ElMessage.success('已复制 Webhook URL')
} catch {
ElMessage.error('复制失败')
}
}
async function testConnection() {
debugging.value = true
debugResult.value = ''
try {
const compat = mappingsToCompatFields(productMappings.value)
const res = await testWaffoConnection({
merchant_id: waffo.merchant_id,
store_id: waffo.store_id,
store_slug: waffo.store_slug,
environment: waffo.environment,
...compat,
})
debugResult.value = JSON.stringify(res.data ?? res, null, 2)
ElMessage.success('调试请求已完成')
} catch (err) {
debugResult.value = JSON.stringify({
error: err?.response?.data || err?.message || String(err),
}, null, 2)
} finally {
debugging.value = false
}
}
onMounted(load)
</script>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.title { font-weight: 600; }
.header-actions { display: flex; gap: 8px; }
.secret-row { display: flex; align-items: center; gap: 10px; }
.hint { margin-top: 6px; font-size: 12px; color: #909399; }
.debug-box {
margin: 0;
padding: 12px;
background: #0f172a;
color: #e2e8f0;
border-radius: 8px;
font-size: 12px;
overflow: auto;
max-height: 360px;
}
</style>
+108 -16
View File
@@ -2,14 +2,24 @@
<div>
<el-card shadow="never">
<div class="toolbar">
<el-select v-model="productFilter" placeholder="筛选产品" clearable filterable style="width: 200px" @change="loadData" />
<el-select v-model="productFilter" placeholder="筛选产品" clearable filterable style="width: 220px" @change="onFilterChange">
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
<el-button type="primary" @click="openCreate">新增计划</el-button>
</div>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="product_name" label="产品" min-width="140">
<template #default="{ row }">
<el-tag type="primary" effect="plain">{{ productLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="计划名称" min-width="130" />
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ planTypeLabel(row.plan_type) }}</template>
<el-table-column label="类型" width="110">
<template #default="{ row }">
{{ planTypeLabel(row.plan_type) }}
<span class="type-code">{{ row.plan_type }}</span>
</template>
</el-table-column>
<el-table-column label="价格" width="100">
<template #default="{ row }">{{ row.price }} {{ row.currency }}</template>
@@ -43,18 +53,36 @@
/>
</el-card>
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑计划' : '新增计划'" width="540px">
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑计划' : '新增计划'" width="560px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="产品" prop="product_id">
<el-select v-model="form.product_id" filterable :disabled="!!form.id" style="width: 100%">
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
<el-form-item label="产品" prop="product_id" class="product-field">
<div class="product-picker">
<el-select
v-model="form.product_id"
filterable
:disabled="!!form.id"
placeholder="请选择所属产品"
size="large"
style="width: 100%"
>
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id">
<span>{{ p.name }}</span>
<span class="opt-code">{{ p.code || p.product_code || '' }}</span>
</el-option>
</el-select>
<div class="product-hint">
{{ form.id ? '编辑时不可更换产品,避免影响已售订单' : '计划必须归属某个产品,列表与筛选均按产品维度管理' }}
</div>
</div>
</el-form-item>
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="如:体验 / 包年 / 永久" />
</el-form-item>
<el-form-item label="名称" prop="name"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="类型" prop="plan_type">
<el-select v-model="form.plan_type" style="width: 100%">
<el-option value="perpetual" label="买断" /><el-option value="lifetime" label="终身" />
<el-option value="monthly" label="月订阅" /><el-option value="yearly" label="年订阅" />
<el-select v-model="form.plan_type" style="width: 100%" @change="onPlanTypeChange">
<el-option value="trial" label="体验 (Trial)" />
<el-option value="yearly" label="包年 (Yearly)" />
<el-option value="perpetual" label="永久 (Perpetual)" />
</el-select>
</el-form-item>
<el-form-item label="价格" prop="price"><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item>
@@ -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 () => {
<style scoped>
.toolbar { display: flex; justify-content: space-between; margin-bottom: 16px; }
.type-code { display: block; font-size: 11px; color: #909399; }
.product-field :deep(.el-form-item__label) {
font-weight: 700;
color: #303133;
}
.product-picker {
width: 100%;
padding: 12px;
border: 1px solid #d9ecff;
border-radius: 8px;
background: linear-gradient(180deg, #f0f7ff 0%, #fff 100%);
}
.product-hint {
margin-top: 8px;
font-size: 12px;
color: #909399;
line-height: 1.4;
}
.opt-code {
float: right;
color: #909399;
font-size: 12px;
}
</style>
+11 -1
View File
@@ -46,6 +46,14 @@
</el-col>
</el-row>
<el-alert
type="info"
:closable="false"
show-icon
title="Waffo 支付配置已迁至「支付」菜单,可在侧边栏直接进入。"
style="margin-top: 16px"
/>
<!-- 模板对话框 -->
<el-dialog v-model="tmplVisible" :title="tmplForm.id ? '编辑模板' : '新增模板'" width="440px">
<el-form :model="tmplForm" label-width="90px">
@@ -65,7 +73,9 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getSetting, updateSetting, getLicenseTemplates, saveLicenseTemplate, deleteLicenseTemplate } from '@/api/setting'
import {
getSetting, updateSetting, getLicenseTemplates, saveLicenseTemplate, deleteLicenseTemplate,
} from '@/api/setting'
const setting = reactive({})
const loadingSetting = ref(false)