From d7dc1e2dbef2c80f84259da9c85b3df1290784f9 Mon Sep 17 00:00:00 2001 From: wood911010 Date: Fri, 2 Jan 2026 01:48:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A5=97=E9=A4=90=E5=88=97=E8=A1=A8=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E3=80=81=E8=AE=A2=E5=8D=95=E5=88=9B=E5=BB=BA=E5=8F=8A?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=AE=A2=E5=8D=95=E6=9F=A5=E8=AF=A2=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/configs/i18n/errors/en-US.yaml | 13 + backend/configs/i18n/errors/ja-JP.yaml | 13 + backend/configs/i18n/errors/zh-CN.yaml | 13 + backend/docs/error_i18n_design.md | 21 +- .../modules_design/2026.1.1-产品套餐体系.md | 541 ++++++++++++++++++ .../2026.1.1-授权码生成规范变更.md | 232 ++++++++ backend/docs/swagger/docs.go | 458 +++++++++++++++ backend/docs/swagger/swagger.json | 458 +++++++++++++++ backend/docs/swagger/swagger.yaml | 283 +++++++++ backend/go.mod | 5 +- backend/go.sum | 2 + backend/internal/api/handlers/auth_handler.go | 5 +- .../internal/api/handlers/cu_order_handler.go | 348 +++++++++++ .../api/handlers/cu_order_handler_test.go | 35 ++ backend/internal/api/middleware/auth.go | 5 +- backend/internal/api/routes/router.go | 12 +- backend/internal/database/migration.go | 25 +- backend/internal/models/auth.go | 2 +- backend/internal/models/cu_order.go | 112 ++++ .../repository/cu_order_repository.go | 113 ++++ .../service/authorization_code_service.go | 38 +- backend/internal/service/cu_order_service.go | 354 ++++++++++++ .../003_create_authorization_codes_table.sql | 2 +- .../migrations/008_create_cu_orders_table.sql | 48 ++ ...update_authorization_codes_code_length.sql | 19 + ...te_cu_orders_authorization_code_length.sql | 11 + backend/migrations/README.md | 46 +- backend/pkg/i18n/errors.go | 3 +- backend/pkg/utils/license_codec.go | 409 +++++++++++++ 29 files changed, 3589 insertions(+), 37 deletions(-) create mode 100644 backend/docs/modules_design/2026.1.1-产品套餐体系.md create mode 100644 backend/docs/modules_design/2026.1.1-授权码生成规范变更.md create mode 100644 backend/internal/api/handlers/cu_order_handler.go create mode 100644 backend/internal/api/handlers/cu_order_handler_test.go create mode 100644 backend/internal/models/cu_order.go create mode 100644 backend/internal/repository/cu_order_repository.go create mode 100644 backend/internal/service/cu_order_service.go create mode 100644 backend/migrations/008_create_cu_orders_table.sql create mode 100644 backend/migrations/009_update_authorization_codes_code_length.sql create mode 100644 backend/migrations/010_update_cu_orders_authorization_code_length.sql create mode 100644 backend/pkg/utils/license_codec.go diff --git a/backend/configs/i18n/errors/en-US.yaml b/backend/configs/i18n/errors/en-US.yaml index 8ae7601..98c6f4c 100644 --- a/backend/configs/i18n/errors/en-US.yaml +++ b/backend/configs/i18n/errors/en-US.yaml @@ -62,6 +62,19 @@ errors: "500012": "Authentication token expired" "500013": "Failed to generate token" + # Package order module (60xxxx) + cu_package: + "600001": "Package does not exist" + "600002": "Package is discontinued" + "600003": "Trial version purchase time limit" + "600004": "Trial version does not support bulk purchase" + "600005": "Trial version already purchased this month" + + # Order module (61xxxx) + cu_order: + "601001": "Order does not exist" + "601005": "License quantity exceeds limit" + # System general (90xxxx) system: "900001": "Invalid request parameters" diff --git a/backend/configs/i18n/errors/ja-JP.yaml b/backend/configs/i18n/errors/ja-JP.yaml index dc70795..171ab57 100644 --- a/backend/configs/i18n/errors/ja-JP.yaml +++ b/backend/configs/i18n/errors/ja-JP.yaml @@ -62,6 +62,19 @@ errors: "500012": "認証トークンが期限切れです" "500013": "トークン生成に失敗しました" + # セット料金モジュール (60xxxx) + cu_package: + "600001": "セット料金が存在しません" + "600002": "セット料金は販売終了しました" + "600003": "試用版購入時間制限" + "600004": "試用版は一括購入をサポートしていません" + "600005": "今月試用版を購入済み" + + # 注文モジュール (61xxxx) + cu_order: + "601001": "注文が存在しません" + "601005": "ライセンス数量が制限を超えています" + # システム共通 (90xxxx) system: "900001": "無効なリクエストパラメータ" diff --git a/backend/configs/i18n/errors/zh-CN.yaml b/backend/configs/i18n/errors/zh-CN.yaml index b591bc4..7c7ddcb 100644 --- a/backend/configs/i18n/errors/zh-CN.yaml +++ b/backend/configs/i18n/errors/zh-CN.yaml @@ -62,6 +62,19 @@ errors: "500012": "认证令牌已过期" "500013": "令牌生成失败" + # 套餐订单模块 (60xxxx) + cu_package: + "600001": "套餐不存在" + "600002": "套餐已下架" + "600003": "试用版购买时间限制" + "600004": "试用版不支持批量购买" + "600005": "当月已购买试用版" + + # 订单模块 (61xxxx) + cu_order: + "601001": "订单不存在" + "601005": "许可数量超出限制" + # 系统通用 (90xxxx) system: "900001": "请求参数无效" diff --git a/backend/docs/error_i18n_design.md b/backend/docs/error_i18n_design.md index f87058c..e6bce37 100644 --- a/backend/docs/error_i18n_design.md +++ b/backend/docs/error_i18n_design.md @@ -119,15 +119,32 @@ default_error: "Unknown error" - **错误消息**: 使用业务错误码,通过 `i18n.NewI18nErrorResponse()` 获取本地化错误信息 - **成功消息**: 统一使用 `i18n.GetErrorMessage("000000", lang)` 获取本地化的"成功"消息 +- **自定义消息**: 支持通过第三个参数传入自定义错误消息,优先级高于本地化消息 ### 2. 响应格式统一 ```go -// 错误响应 +// 错误响应 - 使用本地化消息 +status, errCode, message := i18n.NewI18nErrorResponse("200001", lang) +c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, // 显示本地化的错误消息 + Timestamp: getCurrentTimestamp(), +}) + +// 错误响应 - 使用自定义消息(显示具体错误信息) +status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error()) +c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, // 显示具体的错误信息,如"RSA私钥文件不存在..." + Timestamp: getCurrentTimestamp(), +}) + +// 错误响应 - 结合本地化消息和具体错误 status, errCode, message := i18n.NewI18nErrorResponse("900001", lang) c.JSON(status, models.ErrorResponse{ Code: errCode, - Message: message + ": " + err.Error(), + Message: message + ": " + err.Error(), // 本地化消息 + 具体错误信息 Timestamp: getCurrentTimestamp(), }) diff --git a/backend/docs/modules_design/2026.1.1-产品套餐体系.md b/backend/docs/modules_design/2026.1.1-产品套餐体系.md new file mode 100644 index 0000000..8154bb8 --- /dev/null +++ b/backend/docs/modules_design/2026.1.1-产品套餐体系.md @@ -0,0 +1,541 @@ +# 产品套餐体系设计 + +## 需求概述 + +构建套餐购买和授权管理系统,支持客户自主选择套餐、生成授权码的业务流程。 + +**核心要求:** +- 支持三种套餐类型:试用版、基础版、专业版 +- 实现阶梯定价折扣机制(基于许可数量,通过配置化JSON实现) +- 自动生成授权码(一个订单对应一个授权码,激活次数由许可数量决定) +- 支付流程暂时用伪代码实现 +- 保证授权码的安全性和唯一性 + +**重要概念说明:** +- **许可数量**:等于设备数量,决定授权码可以激活多少次 +- **试用版**:1个授权码,许可数量为1,用户每月限购1个 +- **基础版/专业版**:1个授权码,许可数量等于购买数量,支持50-99、100-499、500+许可的阶梯折扣 + +## 业务流程 + +### 整体购买流程 +1. 用户登录客户用户系统 +2. 浏览套餐列表和价格信息 +3. 选择套餐类型和许可数量 +4. 系统计算折扣和总价 +5. 用户确认并支付(支付成功后直接创建订单) +6. 系统生成授权码并返回给用户 +7. 支付失败或取消:不创建订单,用户可重新下单 + +### 简化流程说明 +- **试用版**:直接生成授权码,无需支付 +- **基础版/专业版**:使用伪代码模拟支付成功,然后生成授权码 +- **一个订单**:对应一个授权码,数量字段仅用于价格计算 + +## 套餐类型详细说明 + +| 套餐类型 | 授权期限 | 单价(元)/许可 | 许可数量 | 功能限制 | 适用场景 | +|---------|---------|----------|---------|---------|---------| +| 试用版 | 当天至本月25日 | 0 | 1 | 全部功能 | 新用户试用体验 | +| 基础版 | 永久授权 | 300 | 按购买数量 | 部分功能限制 | 小型企业入门 | +| 专业版 | 永久授权 | 2000 | 按购买数量 | 全部功能 | 中大型企业使用 | + +### 功能限制说明 + +**试用版:** +- 全部功能开放,无任何限制 +- 时效性:每月1-25日可购买,当月25日到期 +- 不可批量购买,数量固定为1 + +**基础版:** +- 限制同时在线设备数量(具体数量待定) +- 限制某些高级功能的使用 +- 支持批量购买,享受阶梯折扣 + +**专业版:** +- 全部功能开放,无任何限制 +- 支持批量购买,企业级折扣 +- 包含优先技术支持服务 + +## 购买规则和限制 + +### 折扣规则 +- **默认价格**:单个许可不享受折扣 +- **9折优惠**:购买50-99个许可 +- **8折优惠**:购买100-499个许可 +- **7折优惠**:购买500个及以上许可 + +### 试用版特殊规则 +- **购买时间限制**:每月1-25日可购买,26-31日不可购买 +- **授权期限**:从购买当天到当月25日23:59:59 +- **许可数量**:固定为1(只能激活1次) +- **用户限制**:每个用户每月只能购买1个试用版授权码 +- **重复购买**:次月可重新购买,不限制购买次数 + +### 基础版/专业版规则 +- **许可数量选择**:支持选择1-1000个许可数量 +- **激活次数限制**:授权码的激活次数等于购买的许可数量 +- **折扣计算**:基于许可总数量计算折扣,按单价折扣 +- **授权期限**:购买后永久有效,无到期时间限制 + +### 通用限制 +- **用户限制**:每个客户用户账号可独立购买,不受其他账号影响 +- **支付限制**:必须完成支付后才能生成授权码 +- **退款政策**:购买后不支持退款,建议谨慎选择 + +## 套餐配置设计 + +### 写死JSON配置结构 + +套餐信息通过代码中的JSON配置实现,便于后续修改: + +```go +// pkg/config/packages.go +type PackageConfig struct { + Packages []Package `json:"packages"` + Discounts []DiscountRule `json:"discounts"` +} + +type Package struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // trial, basic, professional + Price float64 `json:"price"` + DurationDays *int `json:"duration_days"` // null表示永久 + MaxDevices *int `json:"max_devices"` // null表示无限制 + Features map[string]interface{} `json:"features"` + Status string `json:"status"` // active, disabled + SortOrder int `json:"sort_order"` +} + +type DiscountRule struct { + MinQuantity int `json:"min_quantity"` + MaxQuantity *int `json:"max_quantity"` // null表示无上限 + Rate float64 `json:"rate"` + Description string `json:"description"` +} + +// 写死配置数据 +var DefaultPackageConfig = PackageConfig{ + Packages: []Package{ + { + ID: "trial", + Name: "试用版", + Type: "trial", + Price: 0, // 免费 + DurationDays: nil, // 动态计算到当月25日 + MaxDevices: nil, // 无设备限制 + Features: map[string]interface{}{"full_access": true}, + Status: "active", + SortOrder: 1, + }, + { + ID: "basic", + Name: "基础版", + Type: "basic", + Price: 300, // 每许可300元 + DurationDays: nil, // 永久 + MaxDevices: nil, // 激活次数由购买数量决定 + Features: map[string]interface{}{"basic_features": true}, + Status: "active", + SortOrder: 2, + }, + { + ID: "professional", + Name: "专业版", + Type: "professional", + Price: 2000, // 每许可2000元 + DurationDays: nil, // 永久 + MaxDevices: nil, // 激活次数由购买数量决定 + Features: map[string]interface{}{"full_access": true, "priority_support": true}, + Status: "active", + SortOrder: 3, + }, + }, + // 折扣规则:基于许可数量 + Discounts: []DiscountRule{ + {MinQuantity: 50, MaxQuantity: &[]int{99}[0], Rate: 0.9, Description: "50-99许可9折优惠"}, + {MinQuantity: 100, MaxQuantity: &[]int{499}[0], Rate: 0.8, Description: "100-499许可8折优惠"}, + {MinQuantity: 500, MaxQuantity: nil, Rate: 0.7, Description: "500+许可7折优惠"}, + }, +} +``` + +## 数据模型设计 + +### 订单表 (orders) +```sql +CREATE TABLE orders ( + id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()), + order_no VARCHAR(50) NOT NULL COMMENT '订单号', + customer_id VARCHAR(36) NOT NULL COMMENT '客户ID', + cu_user_id VARCHAR(36) NOT NULL COMMENT '客户用户ID', + package_id VARCHAR(50) NOT NULL COMMENT '套餐ID (对应配置中的ID)', + package_name VARCHAR(100) NOT NULL COMMENT '套餐名称(快照)', + license_count INT NOT NULL COMMENT '许可数量(可激活次数)', + unit_price DECIMAL(10,2) NOT NULL COMMENT '单价(每许可价格,快照)', + discount_rate DECIMAL(3,2) NOT NULL DEFAULT 1.0 COMMENT '折扣率', + total_amount DECIMAL(10,2) NOT NULL COMMENT '订单总金额', + status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '订单状态: pending-待支付, paid-已支付, cancelled-已取消, expired-已过期', + authorization_code VARCHAR(50) NULL COMMENT '生成的授权码', + expired_at DATETIME(3) NULL COMMENT '订单过期时间', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + deleted_at DATETIME(3) NULL, + + FOREIGN KEY (customer_id) REFERENCES customers(id), + FOREIGN KEY (cu_user_id) REFERENCES cu_users(id) +); +``` + +**说明:** +- 一个订单对应一个授权码,`license_count`字段表示许可数量 +- `authorization_code`字段存储生成的授权码 +- 授权码创建时会设置许可数量限制 + +## API接口设计 + +### 1. 获取套餐列表 +``` +GET /api/cu/packages +``` +**响应:** +```json +{ + "code": "000000", + "message": "成功", + "data": [ + { + "id": "trial", + "name": "试用版", + "type": "trial", + "price": 0, + "max_devices": 1, + "description": "免费试用,有效期至本月25日" + }, + { + "id": "basic", + "name": "基础版", + "type": "basic", + "price": 300, + "max_devices": 1000, + "description": "基础功能,支持批量许可购买" + }, + { + "id": "professional", + "name": "专业版", + "type": "professional", + "price": 2000, + "max_devices": 1000, + "description": "全部功能,支持批量许可购买" + } + ] +} +``` + +### 2. 计算订单价格 +``` +POST /api/cu/orders/calculate +``` +**请求:** +```json +{ + "package_id": "basic", + "license_count": 100 +} +``` +**响应:** +```json +{ + "code": "000000", + "message": "成功", + "data": { + "unit_price": 300.00, + "license_count": 100, + "discount_rate": 0.8, + "total_amount": 24000.00, + "discount_description": "100-499许可8折优惠" + } +} +``` + +### 3. 创建订单 +``` +POST /api/cu/orders +``` +**请求:** +```json +{ + "package_id": "basic", + "license_count": 100 +} +``` +**响应:** +```json +{ + "code": "000000", + "message": "成功", + "data": { + "order_id": "order_id_123", + "order_no": "ORD202401010001", + "total_amount": 24000.00, + "status": "paid", + "authorization_code": "AC-240101-A1B2C3D4", + "expired_at": null + } +} +``` + +### 4. 获取订单详情 +``` +GET /api/cu/orders/{order_id} +``` +返回订单状态和授权码信息。 + +### 5. 获取用户订单历史 +``` +GET /api/cu/orders +``` +返回用户的订单历史记录。 + +## 前端交互需求 + +### 套餐选择页面 +- 展示三种套餐类型的详细信息 +- 支持数量选择(试用版除外) +- 实时计算折扣和总价 +- 显示购买限制和规则说明 + +### 订单确认页面 +- 显示订单详情和价格明细 +- 提供支付方式选择 +- 显示订单过期时间提醒 + +### 支付结果页面 +- 显示支付状态 +- 支付成功后展示生成的授权码 +- 提供授权码下载/复制功能 + +### 订单历史页面 +- 显示用户的所有订单记录 +- 支持按状态筛选 +- 显示授权码详情和使用情况 + +## 支付集成 (暂时用伪代码实现) + +### 支付流程说明 +由于支付功能暂时不实现,使用伪代码直接模拟支付成功: + +```go +// 伪代码:模拟支付流程 +func simulatePayment(order *Order) error { + // TODO: 替换为真实的支付接口 + // 1. 调用第三方支付接口 + // 2. 返回支付链接或二维码 + // 3. 等待支付回调 + + // 暂时直接标记为支付成功 + order.Status = "paid" + order.AuthorizationCode = generateAuthorizationCode() + + return nil +} +``` + +### 支付状态流转 +1. **所有订单**:创建时即为已支付状态 +2. **试用版**:免费,无需支付流程 +3. **基础版/专业版**:支付成功后创建订单,失败则不创建 +4. **无中间状态**:要么创建成功(已支付),要么失败不创建 + +### 未来扩展点 +- 集成支付宝/微信支付SDK +- 实现支付回调接口 +- 添加支付超时和失败处理 +- 实现退款功能 + +## 授权码生成逻辑 + +### 授权码生成流程 +1. 根据套餐类型从配置中获取授权参数 +2. 生成唯一的授权码(格式:AC-年月日-8位随机数) +3. 设置授权期限和许可数量限制 +4. 创建订单记录和授权码记录 + +### 授权码格式规范 +- **前缀**:AC (Authorization Code) +- **日期**:YYMMDD格式(如240101) +- **随机数**:8位数字字母组合(排除容易混淆的字符如0、O、I、1) +- **示例**:AC-240101-A1B2C3D4 + +### 试用版特殊处理 +- **购买限制**:每个用户每月只能有一个试用版授权码 +- **时间限制**:每月1-25日可购买,26-31日不可购买 +- **到期时间**:购买当月25日23:59:59 +- **许可数量**:固定为1(只能激活1次) +- **重复购买**:次月可重新购买,不限制购买次数 + + + +**说明:** 由于一个订单对应一个授权码,不再需要批量生成逻辑。 + +## 数据库设计 + +### 新增表 +1. **orders** - 订单表 + +### 索引设计 +```sql +-- orders表索引 +CREATE INDEX idx_orders_customer_id ON orders(customer_id); +CREATE INDEX idx_orders_cu_user_id ON orders(cu_user_id); +CREATE INDEX idx_orders_status ON orders(status); +CREATE INDEX idx_orders_created_at ON orders(created_at); +CREATE INDEX idx_orders_package_id ON orders(package_id); +``` + +## 错误处理 + +### 业务错误码 (60xxxx) +```yaml +# 套餐相关错误 +cu_package: + "600001": "套餐不存在" + "600002": "套餐已下架" + "600003": "试用版购买时间限制" + "600004": "试用版不支持批量购买" + "600005": "当月已购买试用版" + +# 订单相关错误 +cu_order: + "601001": "订单不存在" + "601005": "许可数量超出限制" + +# 支付相关错误 +cu_payment: + "602001": "支付失败" + "602002": "支付金额不匹配" + "602003": "重复支付" +``` + +## 多语言支持 + +### 套餐名称国际化 +```yaml +packages: + trial: "试用版" + basic: "基础版" + professional: "专业版" +``` + +### 折扣说明国际化 +```yaml +discounts: + no_discount: "不享受折扣" + discount_9: "9折优惠 (50-99个)" + discount_8: "8折优惠 (100-499个)" + discount_7: "7折优惠 (500个以上)" +``` + +## 技术实现要点 + +### 订单号生成 +- 格式:ORD + 年月日 + 6位序列号 +- 保证全局唯一性 +- 支持高并发生成 + +### 折扣计算逻辑 +基于许可数量的配置化折扣规则: + +```go +func calculateDiscount(config PackageConfig, licenseCount int) (rate float64, description string) { + for _, rule := range config.Discounts { + if licenseCount >= rule.MinQuantity { + if rule.MaxQuantity == nil || licenseCount <= *rule.MaxQuantity { + return rule.Rate, rule.Description + } + } + } + return 1.0, "不享受折扣" +} +``` + +### 试用版时间限制检查 +```go +func canPurchaseTrial() bool { + now := time.Now() + // 每月1-25日可购买 + return now.Day >= 1 && now.Day <= 25 +} + +func getTrialExpiryTime() time.Time { + now := time.Now() + // 当月25日23:59:59 + return time.Date(now.Year(), now.Month(), 25, 23, 59, 59, 0, now.Location()) +} +``` + +### 授权码生成 +```go +func generateAuthorizationCode() string { + now := time.Now() + dateStr := now.Format("060102") // YYMMDD格式 + + // 生成8位随机字符串(数字+字母,排除易混淆字符) + const charset = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" + code := make([]byte, 8) + for i := range code { + code[i] = charset[rand.Intn(len(charset))] + } + + return fmt.Sprintf("AC-%s-%s", dateStr, string(code)) +} +``` + +### 订单号生成 +```go +func generateOrderNo() string { + now := time.Now() + dateStr := now.Format("20060102") // YYYYMMDD格式 + + // 使用原子计数器生成6位序列号 + sequence := atomic.AddUint64(&orderSequence, 1) % 1000000 + + return fmt.Sprintf("ORD%s%06d", dateStr, sequence) +} +``` + +## 验收标准 + +### 功能验收 +- [x] 套餐列表接口正确返回三种套餐信息(公开接口,无需认证) +- [x] 价格计算接口正确处理折扣逻辑 +- [x] 订单创建接口完整实现业务流程 +- [x] 获取订单详情接口支持权限验证 +- [x] 获取用户订单历史接口支持分页 +- [ ] 试用版购买时间限制生效 +- [ ] 试用版每月限购一个的检查逻辑正确 +- [ ] 授权码生成正确(包含激活次数限制) +- [x] 订单创建即为已支付状态,无需状态更新 +- [ ] 授权码格式规范统一 + +### 性能验收 +- [ ] 订单创建响应时间 < 500ms +- [ ] 授权码生成 < 100ms +- [ ] 高并发订单处理稳定 +- [ ] 数据库查询性能良好 + +### 安全验收 +- [ ] 订单金额验证严格 +- [ ] 防止重复生成授权码 +- [ ] 授权码唯一性保证 +- [ ] 敏感信息处理安全 + +### 业务验收 +- [ ] 折扣规则计算准确(基于许可数量和配置JSON) +- [ ] 试用版期限设置正确(当月25日) +- [ ] 试用版用户每月限购一个 +- [ ] 永久版无到期时间限制 +- [ ] 许可数量设置正确(试用版1,其他版等于购买数量) +- [ ] 一个订单对应一个授权码 + diff --git a/backend/docs/modules_design/2026.1.1-授权码生成规范变更.md b/backend/docs/modules_design/2026.1.1-授权码生成规范变更.md new file mode 100644 index 0000000..68cbcf3 --- /dev/null +++ b/backend/docs/modules_design/2026.1.1-授权码生成规范变更.md @@ -0,0 +1,232 @@ +# 授权码生成规范变更 + +## 📋 需求背景 + +### 现状分析 +- 当前授权码格式:`AC-260101-PQRSTUVW` (示例格式) +- 用户购买授权码后,需要在软件端激活使用 +- 软件使用环境可能不联网,无法实时验证授权码有效性 +- 当前授权码仅包含基本标识信息,缺少配置详情 + +### 业务痛点 +1. **离线激活困难**:无网络环境无法验证授权码 +2. **配置信息缺失**:授权码不包含功能配置、使用限制等详细信息 +3. **用户体验不佳**:激活流程依赖网络连接 +4. **格式不够友好**:授权码可读性差,不易识别 + +## 🎯 需求目标 + +### 核心目标 +实现**离线激活授权码**,支持无网络环境下的软件激活和配置获取。 + +### 功能目标 +1. **完整配置嵌入**:授权码包含所有必要配置信息 +2. **加密签名保护**:通过数字签名确保授权码安全性 +3. **离线验证支持**:软件端可独立验证授权码有效性 +4. **友好格式设计**:提高授权码的可读性和识别性 + +## 🏗️ 技术方案设计 + +### 1. 授权码结构设计 + +#### 自包含式授权码 +授权码本身包含完整的配置信息,通过编码和加密实现: + +**授权码格式**: +``` +LIC-{压缩编码的配置数据}-{校验码} +``` + +**生成步骤**: +1. 构建核心配置JSON +2. 进行数据压缩和编码 +3. 添加RSA签名 +4. 生成校验码 +5. 格式化为用户友好的字符串 + +### 2. 数据编码方案 + +#### 核心配置数据 +```json +{ + "ver": "1.0", + "exp": "2026-12-31T23:59:59Z", + "features": { + "max_users": 100, + "storage": "100GB" + }, + "limits": { + "type": "standard" + }, + "params": { + "company": "示例公司" + } +} +``` + +#### 编码过程 +1. **JSON序列化**:将配置转换为紧凑JSON格式 +2. **数据压缩**:使用高效压缩算法减小体积 +3. **Base64编码**:转换为安全字符集 +4. **RSA签名**:对编码数据进行签名 +5. **格式封装**:添加前缀和校验码 + +#### 解码过程 +1. **格式解析**:提取编码数据部分 +2. **签名验证**:验证RSA签名有效性 +3. **数据解码**:Base64解码并解压缩 +4. **配置提取**:解析JSON获取配置信息 + +### 3. 加密与签名 + +#### 密钥管理 +- **私钥**: 与生成许可证使用相同的RSA私钥 +- **公钥**: 软件客户端内置对应的RSA公钥 +- **自动生成**: 如果密钥文件不存在,系统会自动生成RSA密钥对 +- **数据签名**: 对编码后的配置数据进行RSA签名 + +#### 签名算法 +- **算法**: RS256 (RSA + SHA256) +- **签名位置**: 签名数据嵌入到授权码中 +- **验证方式**: 软件端提取并验证签名确保数据完整性 + +### 4. 激活流程设计 + +#### 完全离线激活流程 +```mermaid +sequenceDiagram + participant U as 用户 + participant S as 软件客户端 + + U->>S: 输入授权码 + S->>S: 解析授权码格式 + S->>S: 验证RSA签名 + S->>S: 解码配置数据 + S->>S: 验证到期时间 + S->>S: 激活软件功能 + + Note over S: 完全离线,授权码自包含所有信息 +``` + +## 🔒 安全性考虑 + +### 加密保护 +- **RSA签名**: 使用与许可证相同的私钥进行签名 +- **数据完整性**: 防止配置信息被篡改 +- **到期验证**: 确保授权码在有效期内使用 + +### 验证机制 +- **格式校验**: 验证授权码格式规范 +- **签名验证**: 强制验证RSA签名有效性 +- **时间检查**: 验证到期时间未过期 + +## 📊 验收标准 + +### 功能验收 +- [ ] 授权码自包含配置信息(到期时间、功能配置、使用限制、自定义参数) +- [ ] 授权码通过RSA签名确保数据完整性 +- [ ] 软件端可完全离线解析和验证授权码 +- [ ] 激活流程无需网络连接 + +### 安全性验收 +- [ ] RSA签名验证通过率 100% +- [ ] 配置数据防篡改保护 +- [ ] 到期时间验证准确 +- [ ] 编码/解码过程可靠 + +## 🔧 简化实施计划 + +### 📋 当前实现分析 + +#### 现有架构保持不变 +- **数据库结构**: 完全保持现有设计 +- **业务逻辑**: 不改变现有流程 +- **API接口**: 现有接口继续可用 + +#### 核心变化 +- **授权码生成规则**: 从简单字符串改为自包含配置的编码字符串 +- **新增工具包**: `pkg/license/codec.go` 提供编解码功能 +- **字段长度调整**: `code` 字段长度从 100 调整到 1000 以容纳编码数据 + +### 🎯 简化的修改计划 + +#### 第一阶段:开发核心算法包 +1. **扩展 `pkg/utils/license_codec.go`** + - `EncodeLicenseData()`: 配置数据编码为授权码 + - `DecodeLicenseData()`: 从授权码解析配置数据 + - `SignLicenseData()`: RSA签名生成 + - `VerifyLicenseSignature()`: RSA签名验证 + +#### 第二阶段:修改授权码生成 +1. **更新 `generateAuthorizationCode()` 方法** + - 集成配置数据编码逻辑 + - 生成自包含的授权码字符串 + - 保持相同的调用接口 + +2. **调整数据库字段长度** + - `code VARCHAR(100)` → `code VARCHAR(1000)` + - 确保向后兼容现有短授权码 + +#### 第三阶段:完成授权码生成规则 +1. **集成到现有流程** + - 修改授权码生成服务 + - 保持现有API接口不变 + - 授权码自包含配置信息 + +### 📊 验收标准 + +#### 功能验收 +- [ ] 授权码包含完整配置信息(到期时间、功能配置、使用限制、自定义参数) +- [ ] 授权码通过RSA签名确保数据完整性 +- [ ] 授权码可被软件端离线解析和验证 +- [ ] 保持与现有系统的完全兼容性 + +#### 性能验收 +- [ ] 授权码生成时间 < 200ms (增加约50ms用于编码) +- [ ] 离线验证时间 < 100ms + +#### 安全性验收 +- [ ] RSA签名验证功能正常 +- [ ] 配置数据防篡改保护 + +### 🔄 兼容性策略 + +#### 渐进式启用 +- 默认保持原有生成方式(可配置开关) +- 新授权码采用自包含格式 +- 现有授权码继续有效 + +### 📝 具体代码修改点 + +#### 1. 新增文件:`pkg/utils/license_codec.go` +```go +package utils + +// 核心编解码和签名验证功能 +func EncodeLicenseData(config interface{}) (string, error) +func DecodeLicenseData(code string) (interface{}, error) +func SignLicenseData(data []byte) (string, error) +func VerifyLicenseSignature(data []byte, signature string) error +``` + +#### 2. 修改文件:`authorization_code_service.go` +- 修改 `CreateAuthorizationCode()` 方法支持预生成授权码 +- 添加 `PreGeneratedCode` 参数到请求结构 +- 保持向后兼容性 + +#### 3. 修改文件:`cu_order_service.go` +- 修改订单创建流程生成自包含授权码 +- 使用 `utils.EncodeLicenseData()` 生成授权码 +- 传递预生成的授权码给授权码服务 + +#### 4. 修改文件:`003_create_authorization_codes_table.sql` +- 调整 `code VARCHAR(100)` → `code VARCHAR(1000)` + + +## 📝 附录 + +### 配置参数说明 +- **end_date**: 到期时间 +- **feature_config**: 功能特性配置 +- **usage_limits**: 使用限制配置 +- **custom_parameters**: 自定义业务参数 \ No newline at end of file diff --git a/backend/docs/swagger/docs.go b/backend/docs/swagger/docs.go index 2ce37f1..280bf18 100644 --- a/backend/docs/swagger/docs.go +++ b/backend/docs/swagger/docs.go @@ -172,6 +172,371 @@ const docTemplate = `{ } } }, + "/api/cu/orders": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "获取当前用户的订单列表,支持分页", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "客户订单管理" + ], + "summary": "获取用户订单列表", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 10, + "description": "每页数量", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/models.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.CuOrderListResponse" + } + } + } + ] + } + }, + "401": { + "description": "未认证", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "创建新的产品套餐订单", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "客户订单管理" + ], + "summary": "创建订单", + "parameters": [ + { + "description": "订单创建信息", + "name": "order", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.CuOrderCreateRequest" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/models.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "authorization_code": { + "type": "string" + }, + "id": { + "type": "string" + }, + "order_no": { + "type": "string" + }, + "total_amount": { + "type": "number" + } + } + } + } + } + ] + } + }, + "400": { + "description": "请求参数无效", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "未认证", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "套餐不存在", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "409": { + "description": "当月已购买试用版", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/api/cu/orders/calculate": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "根据套餐和许可数量计算价格和折扣", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "客户订单管理" + ], + "summary": "计算订单价格", + "parameters": [ + { + "description": "价格计算请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "license_count": { + "type": "integer" + }, + "package_id": { + "type": "string" + } + } + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/models.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "discount_description": { + "type": "string" + }, + "discount_rate": { + "type": "number" + }, + "license_count": { + "type": "integer" + }, + "total_amount": { + "type": "number" + }, + "unit_price": { + "type": "number" + } + } + } + } + } + ] + } + }, + "400": { + "description": "请求参数无效", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "未认证", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "套餐不存在", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/api/cu/orders/{order_id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "根据订单ID获取订单详细信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "客户订单管理" + ], + "summary": "获取订单详情", + "parameters": [ + { + "type": "string", + "description": "订单ID", + "name": "order_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/models.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.CuOrderResponse" + } + } + } + ] + } + }, + "401": { + "description": "未认证", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "403": { + "description": "权限不足", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "订单不存在", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/api/cu/packages": { + "get": { + "description": "获取所有可用的产品套餐信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "客户订单管理" + ], + "summary": "获取套餐列表", + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/models.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "max_devices": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "price": { + "type": "number" + }, + "type": { + "type": "string" + } + } + } + } + } + } + ] + } + } + } + } + }, "/api/cu/profile": { "get": { "security": [ @@ -3454,6 +3819,93 @@ const docTemplate = `{ } } }, + "models.CuOrderCreateRequest": { + "type": "object", + "required": [ + "license_count", + "package_id" + ], + "properties": { + "license_count": { + "type": "integer", + "maximum": 1000, + "minimum": 1 + }, + "package_id": { + "type": "string" + } + } + }, + "models.CuOrderListResponse": { + "type": "object", + "properties": { + "orders": { + "type": "array", + "items": { + "$ref": "#/definitions/models.CuOrderResponse" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_count": { + "type": "integer" + } + } + }, + "models.CuOrderResponse": { + "type": "object", + "properties": { + "authorization_code": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "cu_user_id": { + "type": "string" + }, + "customer_id": { + "type": "string" + }, + "discount_rate": { + "type": "number" + }, + "expired_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "license_count": { + "type": "integer" + }, + "order_no": { + "type": "string" + }, + "package_id": { + "type": "string" + }, + "package_name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "total_amount": { + "type": "number" + }, + "unit_price": { + "type": "number" + }, + "updated_at": { + "type": "string" + } + } + }, "models.CuUserChangePasswordRequest": { "type": "object", "required": [ @@ -3499,6 +3951,12 @@ const docTemplate = `{ "type": "string" }, "phone": { + "type": "string", + "maxLength": 20, + "minLength": 7 + }, + "phone_country_code": { + "description": "可选,默认+86", "type": "string" } } diff --git a/backend/docs/swagger/swagger.json b/backend/docs/swagger/swagger.json index 122c7de..8b6e550 100644 --- a/backend/docs/swagger/swagger.json +++ b/backend/docs/swagger/swagger.json @@ -168,6 +168,371 @@ } } }, + "/api/cu/orders": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "获取当前用户的订单列表,支持分页", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "客户订单管理" + ], + "summary": "获取用户订单列表", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 10, + "description": "每页数量", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/models.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.CuOrderListResponse" + } + } + } + ] + } + }, + "401": { + "description": "未认证", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "创建新的产品套餐订单", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "客户订单管理" + ], + "summary": "创建订单", + "parameters": [ + { + "description": "订单创建信息", + "name": "order", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.CuOrderCreateRequest" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/models.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "authorization_code": { + "type": "string" + }, + "id": { + "type": "string" + }, + "order_no": { + "type": "string" + }, + "total_amount": { + "type": "number" + } + } + } + } + } + ] + } + }, + "400": { + "description": "请求参数无效", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "未认证", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "套餐不存在", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "409": { + "description": "当月已购买试用版", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/api/cu/orders/calculate": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "根据套餐和许可数量计算价格和折扣", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "客户订单管理" + ], + "summary": "计算订单价格", + "parameters": [ + { + "description": "价格计算请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "license_count": { + "type": "integer" + }, + "package_id": { + "type": "string" + } + } + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/models.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "discount_description": { + "type": "string" + }, + "discount_rate": { + "type": "number" + }, + "license_count": { + "type": "integer" + }, + "total_amount": { + "type": "number" + }, + "unit_price": { + "type": "number" + } + } + } + } + } + ] + } + }, + "400": { + "description": "请求参数无效", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "未认证", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "套餐不存在", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/api/cu/orders/{order_id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "根据订单ID获取订单详细信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "客户订单管理" + ], + "summary": "获取订单详情", + "parameters": [ + { + "type": "string", + "description": "订单ID", + "name": "order_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/models.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.CuOrderResponse" + } + } + } + ] + } + }, + "401": { + "description": "未认证", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "403": { + "description": "权限不足", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "订单不存在", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, + "/api/cu/packages": { + "get": { + "description": "获取所有可用的产品套餐信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "客户订单管理" + ], + "summary": "获取套餐列表", + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/models.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "max_devices": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "price": { + "type": "number" + }, + "type": { + "type": "string" + } + } + } + } + } + } + ] + } + } + } + } + }, "/api/cu/profile": { "get": { "security": [ @@ -3450,6 +3815,93 @@ } } }, + "models.CuOrderCreateRequest": { + "type": "object", + "required": [ + "license_count", + "package_id" + ], + "properties": { + "license_count": { + "type": "integer", + "maximum": 1000, + "minimum": 1 + }, + "package_id": { + "type": "string" + } + } + }, + "models.CuOrderListResponse": { + "type": "object", + "properties": { + "orders": { + "type": "array", + "items": { + "$ref": "#/definitions/models.CuOrderResponse" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_count": { + "type": "integer" + } + } + }, + "models.CuOrderResponse": { + "type": "object", + "properties": { + "authorization_code": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "cu_user_id": { + "type": "string" + }, + "customer_id": { + "type": "string" + }, + "discount_rate": { + "type": "number" + }, + "expired_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "license_count": { + "type": "integer" + }, + "order_no": { + "type": "string" + }, + "package_id": { + "type": "string" + }, + "package_name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "total_amount": { + "type": "number" + }, + "unit_price": { + "type": "number" + }, + "updated_at": { + "type": "string" + } + } + }, "models.CuUserChangePasswordRequest": { "type": "object", "required": [ @@ -3495,6 +3947,12 @@ "type": "string" }, "phone": { + "type": "string", + "maxLength": 20, + "minLength": 7 + }, + "phone_country_code": { + "description": "可选,默认+86", "type": "string" } } diff --git a/backend/docs/swagger/swagger.yaml b/backend/docs/swagger/swagger.yaml index 20e8946..8327714 100644 --- a/backend/docs/swagger/swagger.yaml +++ b/backend/docs/swagger/swagger.yaml @@ -408,6 +408,64 @@ definitions: description: 总许可证数量 type: integer type: object + models.CuOrderCreateRequest: + properties: + license_count: + maximum: 1000 + minimum: 1 + type: integer + package_id: + type: string + required: + - license_count + - package_id + type: object + models.CuOrderListResponse: + properties: + orders: + items: + $ref: '#/definitions/models.CuOrderResponse' + type: array + page: + type: integer + page_size: + type: integer + total_count: + type: integer + type: object + models.CuOrderResponse: + properties: + authorization_code: + type: string + created_at: + type: string + cu_user_id: + type: string + customer_id: + type: string + discount_rate: + type: number + expired_at: + type: string + id: + type: string + license_count: + type: integer + order_no: + type: string + package_id: + type: string + package_name: + type: string + status: + type: string + total_amount: + type: number + unit_price: + type: number + updated_at: + type: string + type: object models.CuUserChangePasswordRequest: properties: new_password: @@ -437,6 +495,11 @@ definitions: password: type: string phone: + maxLength: 20 + minLength: 7 + type: string + phone_country_code: + description: 可选,默认+86 type: string required: - password @@ -1385,6 +1448,226 @@ paths: summary: 客户用户登录 tags: - 客户用户管理 + /api/cu/orders: + get: + consumes: + - application/json + description: 获取当前用户的订单列表,支持分页 + parameters: + - default: 1 + description: 页码 + in: query + name: page + type: integer + - default: 10 + description: 每页数量 + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/models.APIResponse' + - properties: + data: + $ref: '#/definitions/models.CuOrderListResponse' + type: object + "401": + description: 未认证 + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: 获取用户订单列表 + tags: + - 客户订单管理 + post: + consumes: + - application/json + description: 创建新的产品套餐订单 + parameters: + - description: 订单创建信息 + in: body + name: order + required: true + schema: + $ref: '#/definitions/models.CuOrderCreateRequest' + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/models.APIResponse' + - properties: + data: + properties: + authorization_code: + type: string + id: + type: string + order_no: + type: string + total_amount: + type: number + type: object + type: object + "400": + description: 请求参数无效 + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: 未认证 + schema: + $ref: '#/definitions/models.ErrorResponse' + "404": + description: 套餐不存在 + schema: + $ref: '#/definitions/models.ErrorResponse' + "409": + description: 当月已购买试用版 + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: 创建订单 + tags: + - 客户订单管理 + /api/cu/orders/{order_id}: + get: + consumes: + - application/json + description: 根据订单ID获取订单详细信息 + parameters: + - description: 订单ID + in: path + name: order_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/models.APIResponse' + - properties: + data: + $ref: '#/definitions/models.CuOrderResponse' + type: object + "401": + description: 未认证 + schema: + $ref: '#/definitions/models.ErrorResponse' + "403": + description: 权限不足 + schema: + $ref: '#/definitions/models.ErrorResponse' + "404": + description: 订单不存在 + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: 获取订单详情 + tags: + - 客户订单管理 + /api/cu/orders/calculate: + post: + consumes: + - application/json + description: 根据套餐和许可数量计算价格和折扣 + parameters: + - description: 价格计算请求 + in: body + name: request + required: true + schema: + properties: + license_count: + type: integer + package_id: + type: string + type: object + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/models.APIResponse' + - properties: + data: + properties: + discount_description: + type: string + discount_rate: + type: number + license_count: + type: integer + total_amount: + type: number + unit_price: + type: number + type: object + type: object + "400": + description: 请求参数无效 + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: 未认证 + schema: + $ref: '#/definitions/models.ErrorResponse' + "404": + description: 套餐不存在 + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: 计算订单价格 + tags: + - 客户订单管理 + /api/cu/packages: + get: + consumes: + - application/json + description: 获取所有可用的产品套餐信息 + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/models.APIResponse' + - properties: + data: + items: + properties: + id: + type: string + max_devices: + type: integer + name: + type: string + price: + type: number + type: + type: string + type: object + type: array + type: object + summary: 获取套餐列表 + tags: + - 客户订单管理 /api/cu/profile: get: consumes: diff --git a/backend/go.mod b/backend/go.mod index 3b88b3c..d1532a6 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,8 +6,10 @@ require ( github.com/gin-gonic/gin v1.10.0 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.4.0 + github.com/shirou/gopsutil/v3 v3.24.5 github.com/sirupsen/logrus v1.9.3 github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.9.0 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.0 github.com/swaggo/swag v1.16.3 @@ -26,6 +28,7 @@ require ( github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect @@ -54,10 +57,10 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index b2c6324..dd61558 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -122,6 +122,8 @@ github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/i github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go index 9965a5f..fb92395 100644 --- a/backend/internal/api/handlers/auth_handler.go +++ b/backend/internal/api/handlers/auth_handler.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "time" "license-manager/internal/api/middleware" "license-manager/internal/models" @@ -49,7 +50,7 @@ func (h *AuthHandler) Login(c *gin.Context) { // 获取客户端IP clientIP := c.ClientIP() - + data, err := h.authService.Login(c.Request.Context(), &req, clientIP) if err != nil { errorCode := "100003" // 登录失败 @@ -154,7 +155,7 @@ func (h *AuthHandler) RefreshToken(c *gin.Context) { // getCurrentTimestamp 获取当前时间戳 func getCurrentTimestamp() string { - return "2024-07-30T12:00:00Z" // 简化实现,实际应该使用time.Now().Format(time.RFC3339) + return time.Now().Format(time.RFC3339) } // extractTokenFromHeader 从请求头提取Token diff --git a/backend/internal/api/handlers/cu_order_handler.go b/backend/internal/api/handlers/cu_order_handler.go new file mode 100644 index 0000000..2403126 --- /dev/null +++ b/backend/internal/api/handlers/cu_order_handler.go @@ -0,0 +1,348 @@ +package handlers + +import ( + "net/http" + "strconv" + + "license-manager/internal/api/middleware" + "license-manager/internal/models" + "license-manager/internal/service" + "license-manager/pkg/i18n" + + "github.com/gin-gonic/gin" +) + +type CuOrderHandler struct { + cuOrderService service.CuOrderService +} + +func NewCuOrderHandler(cuOrderService service.CuOrderService) *CuOrderHandler { + return &CuOrderHandler{ + cuOrderService: cuOrderService, + } +} + +// GetPackages 获取套餐列表 +// @Summary 获取套餐列表 +// @Description 获取所有可用的产品套餐信息 +// @Tags 客户订单管理 +// @Accept json +// @Produce json +// @Success 200 {object} models.APIResponse{data=[]object{id=string,name=string,type=string,price=number,max_devices=integer}} "成功" +// @Router /api/cu/packages [get] +func (h *CuOrderHandler) GetPackages(c *gin.Context) { + lang := middleware.GetLanguage(c) + + // 获取套餐列表 + packages := h.getPackageList() + + c.JSON(http.StatusOK, models.APIResponse{ + Code: "000000", + Message: i18n.GetI18nErrorMessage("000000", lang), + Data: packages, + Timestamp: getCurrentTimestamp(), + }) +} + +// getPackageList 获取套餐列表(暂时写死数据) +func (h *CuOrderHandler) getPackageList() []map[string]interface{} { + return []map[string]interface{}{ + { + "id": "trial", + "name": "试用版", + "type": "trial", + "price": 0, + "max_devices": 1, + "description": "免费试用,有效期至本月25日", + }, + { + "id": "basic", + "name": "基础版", + "type": "basic", + "price": 300, + "max_devices": 1000, + "description": "基础功能,支持批量许可购买", + }, + { + "id": "professional", + "name": "专业版", + "type": "professional", + "price": 2000, + "max_devices": 1000, + "description": "全部功能,支持批量许可购买", + }, + } +} + +// CalculatePrice 计算订单价格 +// @Summary 计算订单价格 +// @Description 根据套餐和许可数量计算价格和折扣 +// @Tags 客户订单管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body object{package_id=string,license_count=integer} true "价格计算请求" +// @Success 200 {object} models.APIResponse{data=object{unit_price=number,license_count=integer,discount_rate=number,total_amount=number,discount_description=string}} "成功" +// @Failure 401 {object} models.ErrorResponse "未认证" +// @Failure 400 {object} models.ErrorResponse "请求参数无效" +// @Failure 404 {object} models.ErrorResponse "套餐不存在" +// @Router /api/cu/orders/calculate [post] +func (h *CuOrderHandler) CalculatePrice(c *gin.Context) { + var req struct { + PackageID string `json:"package_id" binding:"required"` + LicenseCount int `json:"license_count" binding:"required,min=1,max=1000"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + lang := middleware.GetLanguage(c) + status, errCode, message := i18n.NewI18nErrorResponse("900001", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message + ": " + err.Error(), + Timestamp: getCurrentTimestamp(), + }) + return + } + + // 获取用户ID(用于后续业务逻辑扩展) + _, exists := c.Get("cu_user_id") + if !exists { + lang := middleware.GetLanguage(c) + status, errCode, message := i18n.NewI18nErrorResponse("100004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: getCurrentTimestamp(), + }) + return + } + + // 计算价格 + result, err := h.cuOrderService.CalculatePrice(c.Request.Context(), req.PackageID, req.LicenseCount) + if err != nil { + c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{ + Code: err.(*i18n.I18nError).Code, + Message: err.(*i18n.I18nError).Message, // 直接使用错误对象中的消息 + Timestamp: getCurrentTimestamp(), + }) + return + } + + lang := middleware.GetLanguage(c) + successMsg := i18n.GetI18nErrorMessage("000000", lang) + c.JSON(http.StatusOK, models.APIResponse{ + Code: "000000", + Message: successMsg, + Data: result, + Timestamp: getCurrentTimestamp(), + }) +} + +// CreateOrder 创建订单 +// @Summary 创建订单 +// @Description 创建新的产品套餐订单 +// @Tags 客户订单管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param order body models.CuOrderCreateRequest true "订单创建信息" +// @Success 200 {object} models.APIResponse{data=object{id=string,order_no=string,total_amount=number,authorization_code=string}} "成功" +// @Failure 401 {object} models.ErrorResponse "未认证" +// @Failure 400 {object} models.ErrorResponse "请求参数无效" +// @Failure 404 {object} models.ErrorResponse "套餐不存在" +// @Failure 409 {object} models.ErrorResponse "当月已购买试用版" +// @Router /api/cu/orders [post] +func (h *CuOrderHandler) CreateOrder(c *gin.Context) { + var req models.CuOrderCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + lang := middleware.GetLanguage(c) + status, errCode, message := i18n.NewI18nErrorResponse("900001", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message + ": " + err.Error(), + Timestamp: getCurrentTimestamp(), + }) + return + } + + // 获取用户ID和客户ID + cuUserID, exists := c.Get("cu_user_id") + if !exists { + lang := middleware.GetLanguage(c) + status, errCode, message := i18n.NewI18nErrorResponse("100004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: getCurrentTimestamp(), + }) + return + } + + customerID, exists := c.Get("cu_customer_id") + if !exists { + lang := middleware.GetLanguage(c) + status, errCode, message := i18n.NewI18nErrorResponse("100004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: getCurrentTimestamp(), + }) + return + } + + // 创建订单 + order, err := h.cuOrderService.CreateOrder(c.Request.Context(), cuUserID.(string), customerID.(string), &req) + if err != nil { + c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{ + Code: err.(*i18n.I18nError).Code, + Message: err.(*i18n.I18nError).Message, // 直接使用错误对象中的消息 + Timestamp: getCurrentTimestamp(), + }) + return + } + + lang := middleware.GetLanguage(c) + successMsg := i18n.GetI18nErrorMessage("000000", lang) + c.JSON(http.StatusOK, models.APIResponse{ + Code: "000000", + Message: successMsg, + Data: gin.H{ + "id": order.ID, + "order_no": order.OrderNo, + "total_amount": order.TotalAmount, + "authorization_code": order.AuthorizationCode, + }, + Timestamp: getCurrentTimestamp(), + }) +} + +// GetOrder 获取订单详情 +// @Summary 获取订单详情 +// @Description 根据订单ID获取订单详细信息 +// @Tags 客户订单管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param order_id path string true "订单ID" +// @Success 200 {object} models.APIResponse{data=models.CuOrderResponse} "成功" +// @Failure 401 {object} models.ErrorResponse "未认证" +// @Failure 403 {object} models.ErrorResponse "权限不足" +// @Failure 404 {object} models.ErrorResponse "订单不存在" +// @Router /api/cu/orders/{order_id} [get] +func (h *CuOrderHandler) GetOrder(c *gin.Context) { + orderID := c.Param("order_id") + if orderID == "" { + lang := middleware.GetLanguage(c) + status, errCode, message := i18n.NewI18nErrorResponse("900001", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: getCurrentTimestamp(), + }) + return + } + + // 获取用户ID + cuUserID, exists := c.Get("cu_user_id") + if !exists { + lang := middleware.GetLanguage(c) + status, errCode, message := i18n.NewI18nErrorResponse("100004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: getCurrentTimestamp(), + }) + return + } + + // 获取订单详情 + order, err := h.cuOrderService.GetOrder(c.Request.Context(), orderID, cuUserID.(string)) + if err != nil { + c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{ + Code: err.(*i18n.I18nError).Code, + Message: err.(*i18n.I18nError).Message, // 直接使用错误对象中的消息 + Timestamp: getCurrentTimestamp(), + }) + return + } + + lang := middleware.GetLanguage(c) + successMsg := i18n.GetI18nErrorMessage("000000", lang) + c.JSON(http.StatusOK, models.APIResponse{ + Code: "000000", + Message: successMsg, + Data: order.ToResponse(), + Timestamp: getCurrentTimestamp(), + }) +} + +// GetUserOrders 获取用户订单列表 +// @Summary 获取用户订单列表 +// @Description 获取当前用户的订单列表,支持分页 +// @Tags 客户订单管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param page query integer false "页码" default(1) +// @Param page_size query integer false "每页数量" default(10) +// @Success 200 {object} models.APIResponse{data=models.CuOrderListResponse} "成功" +// @Failure 401 {object} models.ErrorResponse "未认证" +// @Router /api/cu/orders [get] +func (h *CuOrderHandler) GetUserOrders(c *gin.Context) { + // 解析分页参数 + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 10 + } + + offset := (page - 1) * pageSize + + // 获取用户ID + cuUserID, exists := c.Get("cu_user_id") + if !exists { + lang := middleware.GetLanguage(c) + status, errCode, message := i18n.NewI18nErrorResponse("100004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: getCurrentTimestamp(), + }) + return + } + + // 获取订单列表 + orders, total, err := h.cuOrderService.GetUserOrders(c.Request.Context(), cuUserID.(string), offset, pageSize) + if err != nil { + c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{ + Code: err.(*i18n.I18nError).Code, + Message: err.(*i18n.I18nError).Message, // 直接使用错误对象中的消息 + Timestamp: getCurrentTimestamp(), + }) + return + } + + // 转换为响应格式 + orderResponses := make([]*models.CuOrderResponse, len(orders)) + for i, order := range orders { + orderResponses[i] = order.ToResponse() + } + + lang := middleware.GetLanguage(c) + successMsg := i18n.GetI18nErrorMessage("000000", lang) + c.JSON(http.StatusOK, models.APIResponse{ + Code: "000000", + Message: successMsg, + Data: &models.CuOrderListResponse{ + Orders: orderResponses, + TotalCount: total, + Page: page, + PageSize: pageSize, + }, + Timestamp: getCurrentTimestamp(), + }) +} diff --git a/backend/internal/api/handlers/cu_order_handler_test.go b/backend/internal/api/handlers/cu_order_handler_test.go new file mode 100644 index 0000000..b9f6ff5 --- /dev/null +++ b/backend/internal/api/handlers/cu_order_handler_test.go @@ -0,0 +1,35 @@ +package handlers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCuOrderHandler_getPackageList(t *testing.T) { + handler := &CuOrderHandler{} + + packages := handler.getPackageList() + + // 验证返回了3种套餐 + assert.Equal(t, 3, len(packages)) + + // 验证试用版 + trial := packages[0] + assert.Equal(t, "trial", trial["id"]) + assert.Equal(t, "试用版", trial["name"]) + assert.Equal(t, 0, trial["price"]) + assert.Equal(t, 1, trial["max_devices"]) + + // 验证基础版 + basic := packages[1] + assert.Equal(t, "basic", basic["id"]) + assert.Equal(t, "基础版", basic["name"]) + assert.Equal(t, 300, basic["price"]) + + // 验证专业版 + professional := packages[2] + assert.Equal(t, "professional", professional["id"]) + assert.Equal(t, "专业版", professional["name"]) + assert.Equal(t, 2000, professional["price"]) +} diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go index aa80231..e0d26ca 100644 --- a/backend/internal/api/middleware/auth.go +++ b/backend/internal/api/middleware/auth.go @@ -4,8 +4,9 @@ import ( "net/http" "strings" - "github.com/gin-gonic/gin" "license-manager/pkg/utils" + + "github.com/gin-gonic/gin" ) const ( @@ -230,4 +231,4 @@ func CustomerAuth() gin.HandlerFunc { // getCurrentTimestamp 获取当前时间戳 func getCurrentTimestamp() string { return "2024-07-30T12:00:00Z" // 简化实现,实际应该使用time.Now().Format(time.RFC3339) -} \ No newline at end of file +} diff --git a/backend/internal/api/routes/router.go b/backend/internal/api/routes/router.go index e8cc998..d5159e0 100644 --- a/backend/internal/api/routes/router.go +++ b/backend/internal/api/routes/router.go @@ -39,6 +39,7 @@ func SetupRouter() *gin.Engine { customerRepo := repository.NewCustomerRepository(db) userRepo := repository.NewUserRepository(db) cuUserRepo := repository.NewCuUserRepository(db) + cuOrderRepo := repository.NewCuOrderRepository(db) authCodeRepo := repository.NewAuthorizationCodeRepository(db) licenseRepo := repository.NewLicenseRepository(db) dashboardRepo := repository.NewDashboardRepository(db) @@ -51,8 +52,9 @@ func SetupRouter() *gin.Engine { systemService := service.NewSystemService() customerService := service.NewCustomerService(customerRepo) cuUserService := service.NewCuUserService(cuUserRepo, customerRepo, db) - enumService := service.NewEnumService() authCodeService := service.NewAuthorizationCodeService(authCodeRepo, customerRepo, licenseRepo) + cuOrderService := service.NewCuOrderService(cuOrderRepo, cuUserRepo, authCodeRepo, db) + enumService := service.NewEnumService() licenseService := service.NewLicenseService(licenseRepo, db, log) dashboardService := service.NewDashboardService(dashboardRepo) @@ -62,6 +64,7 @@ func SetupRouter() *gin.Engine { customerHandler := handlers.NewCustomerHandler(customerService) cuAuthHandler := handlers.NewCuAuthHandler(cuUserService) cuProfileHandler := handlers.NewCuProfileHandler(cuUserService) + cuOrderHandler := handlers.NewCuOrderHandler(cuOrderService) enumHandler := handlers.NewEnumHandler(enumService) authCodeHandler := handlers.NewAuthorizationCodeHandler(authCodeService) licenseHandler := handlers.NewLicenseHandler(licenseService) @@ -149,6 +152,7 @@ func SetupRouter() *gin.Engine { cuPublic.POST("/login", cuAuthHandler.CuUserLogin) cuPublic.POST("/forgot-password", cuAuthHandler.CuUserForgotPassword) cuPublic.POST("/reset-password", cuAuthHandler.CuUserResetPassword) + cuPublic.GET("/packages", cuOrderHandler.GetPackages) } // 需要认证的接口 @@ -160,6 +164,12 @@ func SetupRouter() *gin.Engine { cuAuth.PUT("/profile", cuProfileHandler.UpdateCuUserProfile) cuAuth.PUT("/profile/phone", cuProfileHandler.UpdateCuUserPhone) cuAuth.PUT("/profile/password", cuProfileHandler.ChangeCuUserPassword) + + // 套餐和订单管理 + cuAuth.POST("/orders/calculate", cuOrderHandler.CalculatePrice) + cuAuth.POST("/orders", cuOrderHandler.CreateOrder) + cuAuth.GET("/orders/:order_id", cuOrderHandler.GetOrder) + cuAuth.GET("/orders", cuOrderHandler.GetUserOrders) } } diff --git a/backend/internal/database/migration.go b/backend/internal/database/migration.go index b4931e3..f7dce6e 100644 --- a/backend/internal/database/migration.go +++ b/backend/internal/database/migration.go @@ -3,7 +3,6 @@ package database import ( "fmt" "log" - "strings" "time" "license-manager/internal/config" @@ -25,6 +24,7 @@ func AutoMigrate() error { &models.CustomerCodeSequence{}, &models.User{}, &models.CuUser{}, + &models.CuOrder{}, &models.AuthorizationCode{}, &models.License{}, &models.AuthorizationChange{}, @@ -96,26 +96,9 @@ func createCuUserIndexes() error { switch dbType { case "mysql": - // 创建复合索引用于优化查询性能 - // 使用兼容所有MySQL版本的语法(不支持IF NOT EXISTS的版本会忽略重复错误) - indexes := []string{ - "CREATE INDEX idx_cu_users_phone ON cu_users(phone, phone_country_code)", - "CREATE INDEX idx_cu_users_customer_status ON cu_users(customer_id, status)", - "CREATE INDEX idx_cu_users_status_locked ON cu_users(status, locked_until)", - } - - for _, indexSQL := range indexes { - if err := DB.Exec(indexSQL).Error; err != nil { - // 检查是否是索引已存在的错误(MySQL错误码1061),如果是则忽略 - if strings.Contains(err.Error(), "Duplicate key name") || strings.Contains(err.Error(), "already exists") { - // 索引已存在,跳过 - continue - } - log.Printf("Warning: failed to create index: %v", err) - // 不返回错误,允许索引创建失败(可能已存在) - } - } - log.Println("Created cu_users composite indexes") + // 注意:所有必要的索引都已在SQL迁移文件中创建 + // 这里不再重复创建,避免"Duplicate key name"错误 + log.Println("All cu_users indexes already created in SQL migration file") default: log.Printf("Skipping composite index creation for database type: %s", dbType) } diff --git a/backend/internal/models/auth.go b/backend/internal/models/auth.go index c47a09a..5833a27 100644 --- a/backend/internal/models/auth.go +++ b/backend/internal/models/auth.go @@ -36,7 +36,7 @@ type UserInfo struct { // AuthorizationCode 授权码模型 type AuthorizationCode struct { ID string `gorm:"type:varchar(36);primaryKey" json:"id"` // 授权码ID - Code string `gorm:"type:varchar(100);uniqueIndex;not null" json:"code"` // 授权码 + Code string `gorm:"type:varchar(1000);not null" json:"code"` // 授权码 CustomerID string `gorm:"type:varchar(36);not null;index" json:"customer_id"` // 客户ID CustomerName string `gorm:"-" json:"customer_name,omitempty"` // 客户名称 CustomerNameDisplay string `gorm:"-" json:"customer_name_display,omitempty"` // 客户名称显示(多语言) diff --git a/backend/internal/models/cu_order.go b/backend/internal/models/cu_order.go new file mode 100644 index 0000000..dd45d8d --- /dev/null +++ b/backend/internal/models/cu_order.go @@ -0,0 +1,112 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// CuOrder 客户用户订单模型 +type CuOrder struct { + ID string `gorm:"type:varchar(36);primaryKey" json:"id"` + OrderNo string `gorm:"type:varchar(50);not null;uniqueIndex" json:"order_no"` + CustomerID string `gorm:"type:varchar(36);not null;index" json:"customer_id"` + CuUserID string `gorm:"type:varchar(36);not null;index" json:"cu_user_id"` + PackageID string `gorm:"type:varchar(50);not null;index" json:"package_id"` + PackageName string `gorm:"type:varchar(100);not null" json:"package_name"` + LicenseCount int `gorm:"not null" json:"license_count"` + UnitPrice float64 `gorm:"type:decimal(10,2);not null" json:"unit_price"` + DiscountRate float64 `gorm:"type:decimal(3,2);not null;default:1.0" json:"discount_rate"` + TotalAmount float64 `gorm:"type:decimal(10,2);not null" json:"total_amount"` + Status string `gorm:"type:varchar(20);not null;default:'paid'" json:"status"` + AuthorizationCode *string `gorm:"type:varchar(500)" json:"authorization_code"` + ExpiredAt *time.Time `gorm:"index" json:"expired_at"` + CreatedAt time.Time `gorm:"not null;index" json:"created_at"` + UpdatedAt time.Time `gorm:"not null" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// TableName 指定表名 +func (CuOrder) TableName() string { + return "cu_orders" +} + +// BeforeCreate 创建前自动设置时间戳和ID +func (o *CuOrder) BeforeCreate(tx *gorm.DB) error { + // 生成UUID作为主键ID + if o.ID == "" { + o.ID = uuid.New().String() + } + + // 设置时间戳 + now := time.Now() + if o.CreatedAt.IsZero() { + o.CreatedAt = now + } + if o.UpdatedAt.IsZero() { + o.UpdatedAt = now + } + + return nil +} + +// BeforeUpdate 更新前自动设置时间戳 +func (o *CuOrder) BeforeUpdate(tx *gorm.DB) error { + o.UpdatedAt = time.Now() + return nil +} + +// CuOrderCreateRequest 创建订单请求结构 +type CuOrderCreateRequest struct { + PackageID string `json:"package_id" binding:"required"` + LicenseCount int `json:"license_count" binding:"required,min=1,max=1000"` +} + +// CuOrderResponse 订单响应结构 +type CuOrderResponse struct { + ID string `json:"id"` + OrderNo string `json:"order_no"` + CustomerID string `json:"customer_id"` + CuUserID string `json:"cu_user_id"` + PackageID string `json:"package_id"` + PackageName string `json:"package_name"` + LicenseCount int `json:"license_count"` + UnitPrice float64 `json:"unit_price"` + DiscountRate float64 `json:"discount_rate"` + TotalAmount float64 `json:"total_amount"` + Status string `json:"status"` + AuthorizationCode *string `json:"authorization_code"` + ExpiredAt *time.Time `json:"expired_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ToResponse 转换为响应结构 +func (o *CuOrder) ToResponse() *CuOrderResponse { + return &CuOrderResponse{ + ID: o.ID, + OrderNo: o.OrderNo, + CustomerID: o.CustomerID, + CuUserID: o.CuUserID, + PackageID: o.PackageID, + PackageName: o.PackageName, + LicenseCount: o.LicenseCount, + UnitPrice: o.UnitPrice, + DiscountRate: o.DiscountRate, + TotalAmount: o.TotalAmount, + Status: o.Status, + AuthorizationCode: o.AuthorizationCode, + ExpiredAt: o.ExpiredAt, + CreatedAt: o.CreatedAt, + UpdatedAt: o.UpdatedAt, + } +} + +// CuOrderListResponse 订单列表响应结构 +type CuOrderListResponse struct { + Orders []*CuOrderResponse `json:"orders"` + TotalCount int64 `json:"total_count"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} diff --git a/backend/internal/repository/cu_order_repository.go b/backend/internal/repository/cu_order_repository.go new file mode 100644 index 0000000..1bdbfc1 --- /dev/null +++ b/backend/internal/repository/cu_order_repository.go @@ -0,0 +1,113 @@ +package repository + +import ( + "license-manager/internal/models" + + "gorm.io/gorm" +) + +type CuOrderRepository interface { + Create(order *models.CuOrder) error + GetByID(id string) (*models.CuOrder, error) + GetByOrderNo(orderNo string) (*models.CuOrder, error) + GetByCuUserID(cuUserID string, offset, limit int) ([]*models.CuOrder, int64, error) + GetByCustomerID(customerID string, offset, limit int) ([]*models.CuOrder, int64, error) + Update(order *models.CuOrder) error + Delete(id string) error + CheckTrialOrderExists(cuUserID string, currentMonth string) (bool, error) +} + +type cuOrderRepository struct { + db *gorm.DB +} + +func NewCuOrderRepository(db *gorm.DB) CuOrderRepository { + return &cuOrderRepository{db: db} +} + +func (r *cuOrderRepository) Create(order *models.CuOrder) error { + return r.db.Create(order).Error +} + +func (r *cuOrderRepository) GetByID(id string) (*models.CuOrder, error) { + var order models.CuOrder + err := r.db.Where("id = ? AND deleted_at IS NULL", id).First(&order).Error + if err != nil { + return nil, err + } + return &order, nil +} + +func (r *cuOrderRepository) GetByOrderNo(orderNo string) (*models.CuOrder, error) { + var order models.CuOrder + err := r.db.Where("order_no = ? AND deleted_at IS NULL", orderNo).First(&order).Error + if err != nil { + return nil, err + } + return &order, nil +} + +func (r *cuOrderRepository) GetByCuUserID(cuUserID string, offset, limit int) ([]*models.CuOrder, int64, error) { + var orders []*models.CuOrder + var total int64 + + query := r.db.Model(&models.CuOrder{}).Where("cu_user_id = ? AND deleted_at IS NULL", cuUserID) + + // 获取总数 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 获取分页数据 + err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&orders).Error + if err != nil { + return nil, 0, err + } + + return orders, total, nil +} + +func (r *cuOrderRepository) GetByCustomerID(customerID string, offset, limit int) ([]*models.CuOrder, int64, error) { + var orders []*models.CuOrder + var total int64 + + query := r.db.Model(&models.CuOrder{}).Where("customer_id = ? AND deleted_at IS NULL", customerID) + + // 获取总数 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 获取分页数据 + err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&orders).Error + if err != nil { + return nil, 0, err + } + + return orders, total, nil +} + +func (r *cuOrderRepository) Update(order *models.CuOrder) error { + return r.db.Save(order).Error +} + +func (r *cuOrderRepository) Delete(id string) error { + return r.db.Where("id = ?", id).Delete(&models.CuOrder{}).Error +} + +// GetPendingOrders 移除:不再有pending状态的订单 + +// UpdateStatus 移除:不再需要更新订单状态 + +func (r *cuOrderRepository) CheckTrialOrderExists(cuUserID string, currentMonth string) (bool, error) { + var count int64 + startOfMonth := currentMonth + "-01" + endOfMonth := currentMonth + "-31" + + err := r.db.Model(&models.CuOrder{}). + Where("cu_user_id = ? AND package_id = ? AND status = ? AND created_at >= ? AND created_at <= ? AND deleted_at IS NULL", + cuUserID, "trial", "paid", startOfMonth, endOfMonth). + Count(&count).Error + + return count > 0, err +} diff --git a/backend/internal/service/authorization_code_service.go b/backend/internal/service/authorization_code_service.go index 1cdf9b5..60dba27 100644 --- a/backend/internal/service/authorization_code_service.go +++ b/backend/internal/service/authorization_code_service.go @@ -10,10 +10,14 @@ import ( "strings" "time" + "license-manager/internal/config" "license-manager/internal/models" "license-manager/internal/repository" pkgcontext "license-manager/pkg/context" "license-manager/pkg/i18n" + "license-manager/pkg/utils" + + "github.com/google/uuid" ) type authorizationCodeService struct { @@ -69,13 +73,40 @@ func (s *authorizationCodeService) CreateAuthorizationCode(ctx context.Context, return nil, i18n.NewI18nError("100004", lang) // 缺少认证信息 } - // 生成授权码 - authCode, err := s.generateAuthorizationCode(req.CustomerID) + // 生成自包含授权码 + var featureConfigMap, usageLimitsMap, customParametersMap map[string]interface{} + + if req.FeatureConfig != nil { + if fc, ok := req.FeatureConfig.(map[string]interface{}); ok { + featureConfigMap = fc + } + } + if req.UsageLimits != nil { + if ul, ok := req.UsageLimits.(map[string]interface{}); ok { + usageLimitsMap = ul + } + } + if req.CustomParameters != nil { + if cp, ok := req.CustomParameters.(map[string]interface{}); ok { + customParametersMap = cp + } + } + + licenseConfig := &utils.LicenseConfig{ + EndDate: endDate, + FeatureConfig: featureConfigMap, + UsageLimits: usageLimitsMap, + CustomParameters: customParametersMap, + } + + // 从配置获取RSA私钥路径 + privateKeyPath := config.GetConfig().License.RSA.PrivateKeyPath + authCode, err := utils.EncodeLicenseData(licenseConfig, privateKeyPath) if err != nil { return nil, i18n.NewI18nError("900004", lang, err.Error()) } - // 处理JSON字段 + // 处理JSON字段(用于数据库存储) var featureConfig, usageLimits, customParameters models.JSON if req.FeatureConfig != nil { featureConfigBytes, err := json.Marshal(req.FeatureConfig) @@ -108,6 +139,7 @@ func (s *authorizationCodeService) CreateAuthorizationCode(ctx context.Context, // 构建授权码实体 authCodeEntity := &models.AuthorizationCode{ + ID: uuid.New().String(), // 生成新的UUID作为主键 Code: authCode, CustomerID: req.CustomerID, CreatedBy: currentUserID, diff --git a/backend/internal/service/cu_order_service.go b/backend/internal/service/cu_order_service.go new file mode 100644 index 0000000..1e63b0a --- /dev/null +++ b/backend/internal/service/cu_order_service.go @@ -0,0 +1,354 @@ +package service + +import ( + "context" + "fmt" + "license-manager/internal/config" + "license-manager/internal/models" + "license-manager/internal/repository" + pkgcontext "license-manager/pkg/context" + "license-manager/pkg/i18n" + "license-manager/pkg/utils" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type CuOrderService interface { + CreateOrder(ctx context.Context, cuUserID, customerID string, req *models.CuOrderCreateRequest) (*models.CuOrder, error) + GetOrder(ctx context.Context, orderID, cuUserID string) (*models.CuOrder, error) + GetUserOrders(ctx context.Context, cuUserID string, offset, limit int) ([]*models.CuOrder, int64, error) + CalculatePrice(ctx context.Context, packageID string, licenseCount int) (*PriceCalculationResult, error) +} + +type PriceCalculationResult struct { + UnitPrice float64 `json:"unit_price"` + LicenseCount int `json:"license_count"` + DiscountRate float64 `json:"discount_rate"` + TotalAmount float64 `json:"total_amount"` + DiscountDesc string `json:"discount_description"` +} + +// 套餐配置(临时写死,后续可改为配置化) +var packageConfigs = map[string]PackageConfig{ + "trial": { + Name: "试用版", + Price: 0, + MaxDevices: 1, // 试用版限制1个许可 + }, + "basic": { + Name: "基础版", + Price: 300, + MaxDevices: 1000, // 基础版最多1000个许可 + }, + "professional": { + Name: "专业版", + Price: 2000, + MaxDevices: 1000, // 专业版最多1000个许可 + }, +} + +type PackageConfig struct { + Name string + Price float64 + MaxDevices int +} + +// 折扣规则配置 +var discountRules = []DiscountRule{ + {MinQuantity: 50, MaxQuantity: 99, Rate: 0.9, Description: "50-99许可9折优惠"}, + {MinQuantity: 100, MaxQuantity: 499, Rate: 0.8, Description: "100-499许可8折优惠"}, + {MinQuantity: 500, MaxQuantity: 0, Rate: 0.7, Description: "500+许可7折优惠"}, +} + +type DiscountRule struct { + MinQuantity int + MaxQuantity int // 0表示无上限 + Rate float64 + Description string +} + +type cuOrderService struct { + repo repository.CuOrderRepository + cuUserRepo repository.CuUserRepository + authCodeRepo repository.AuthorizationCodeRepository + db *gorm.DB +} + +func NewCuOrderService(repo repository.CuOrderRepository, cuUserRepo repository.CuUserRepository, authCodeRepo repository.AuthorizationCodeRepository, db *gorm.DB) CuOrderService { + return &cuOrderService{ + repo: repo, + cuUserRepo: cuUserRepo, + authCodeRepo: authCodeRepo, + db: db, + } +} + +func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID string, req *models.CuOrderCreateRequest) (*models.CuOrder, error) { + lang := pkgcontext.GetLanguageFromContext(ctx) + + // 参数验证 + if req == nil || req.PackageID == "" || req.LicenseCount <= 0 { + return nil, i18n.NewI18nError("900001", lang) + } + + // 检查套餐是否存在 + pkg, exists := packageConfigs[req.PackageID] + if !exists { + return nil, i18n.NewI18nError("600001", lang) // 套餐不存在 + } + + // 验证许可数量 + if req.LicenseCount > pkg.MaxDevices { + return nil, i18n.NewI18nError("601005", lang) // 许可数量超出限制 + } + + // 试用版特殊检查 + if req.PackageID == "trial" { + // 试用版不支持批量购买 + if req.LicenseCount != 1 { + return nil, i18n.NewI18nError("600004", lang) // 试用版不支持批量购买 + } + + // 检查购买时间限制 + now := time.Now() + if now.Day() > 25 { + return nil, i18n.NewI18nError("600003", lang) // 试用版购买时间限制 + } + + // 每个用户每月只能有一个试用版 + currentMonth := now.Format("2006-01") + exists, err := s.repo.CheckTrialOrderExists(cuUserID, currentMonth) + if err != nil { + return nil, i18n.NewI18nError("900004", lang, err.Error()) + } + if exists { + return nil, i18n.NewI18nError("600005", lang) // 当月已购买试用版 + } + } + + // 计算价格 + priceResult, err := s.CalculatePrice(ctx, req.PackageID, req.LicenseCount) + if err != nil { + return nil, err + } + + // 生成订单号 + orderNo := s.generateOrderNo() + + // 创建订单 + order := &models.CuOrder{ + OrderNo: orderNo, + CustomerID: customerID, + CuUserID: cuUserID, + PackageID: req.PackageID, + PackageName: pkg.Name, + LicenseCount: req.LicenseCount, + UnitPrice: priceResult.UnitPrice, + DiscountRate: priceResult.DiscountRate, + TotalAmount: priceResult.TotalAmount, + Status: "paid", // 直接设置为已支付状态 + } + + // 开启事务 + tx := s.db.Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 创建订单 + if err := tx.Create(order).Error; err != nil { + tx.Rollback() + return nil, i18n.NewI18nError("900004", lang, err.Error()) + } + + // 支付成功,创建授权码 + // 实际项目中这里应该先调用支付接口,支付成功后再执行后续逻辑 + now := time.Now() + order.UpdatedAt = now + + // 计算授权时间 + var startDate, endDate time.Time + var expiredAt *time.Time + if req.PackageID == "trial" { + // 试用版:从今天到当月25日 + trialExpiry := time.Date(now.Year(), now.Month(), 25, 23, 59, 59, 0, now.Location()) + if now.Day() > 25 { + // 如果今天已经过了25日,则无法购买 + tx.Rollback() + return nil, i18n.NewI18nError("600003", lang) // 试用版购买时间限制 + } + startDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + endDate = trialExpiry + expiredAt = &trialExpiry + } else { + // 永久版:从今天开始,结束时间设为很远的未来 + startDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + endDate = startDate.AddDate(1000, 0, 0) // 1000年后 + expiredAt = nil + } + + // 生成自包含授权码 + var featureConfigMap, usageLimitsMap map[string]interface{} + + // 根据套餐类型设置配置信息 + if req.PackageID == "basic" { + usageLimitsMap = map[string]interface{}{ + "type": "standard", + } + } + + licenseConfig := &utils.LicenseConfig{ + EndDate: endDate, + FeatureConfig: featureConfigMap, + UsageLimits: usageLimitsMap, + CustomParameters: map[string]interface{}{ + "package_id": req.PackageID, + "package_name": pkg.Name, + "license_count": req.LicenseCount, + }, + } + + // 从配置获取RSA私钥路径 + privateKeyPath := config.GetConfig().License.RSA.PrivateKeyPath + authCode, err := utils.EncodeLicenseData(licenseConfig, privateKeyPath) + if err != nil { + tx.Rollback() + return nil, i18n.NewI18nError("900004", lang, err.Error()) + } + + // 构建订单描述 + description := fmt.Sprintf("%s - %d个许可", pkg.Name, req.LicenseCount) + + // 构建授权码实体(使用生成的授权码) + authCodeEntity := &models.AuthorizationCode{ + ID: uuid.New().String(), // 生成新的UUID作为主键 + Code: authCode, + CustomerID: customerID, + CreatedBy: cuUserID, + Description: &description, + StartDate: startDate, + EndDate: endDate, + DeploymentType: "cloud", + EncryptionType: &[]string{"standard"}[0], + MaxActivations: req.LicenseCount, + IsLocked: false, + // FeatureConfig 和 UsageLimits 已在授权码字符串中,无需重复存储 + } + + // 创建授权码 + if err := s.authCodeRepo.CreateAuthorizationCode(ctx, authCodeEntity); err != nil { + tx.Rollback() + return nil, i18n.NewI18nError("900004", lang, err.Error()) + } + + // 更新订单的授权码信息 + order.AuthorizationCode = &authCode + order.ExpiredAt = expiredAt + + // 更新订单 + if err := tx.Save(order).Error; err != nil { + tx.Rollback() + return nil, i18n.NewI18nError("900004", lang, err.Error()) + } + + // 提交事务 + if err := tx.Commit().Error; err != nil { + return nil, i18n.NewI18nError("900004", lang, err.Error()) + } + + return order, nil +} + +func (s *cuOrderService) GetOrder(ctx context.Context, orderID, cuUserID string) (*models.CuOrder, error) { + lang := pkgcontext.GetLanguageFromContext(ctx) + + order, err := s.repo.GetByID(orderID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, i18n.NewI18nError("601001", lang) // 订单不存在 + } + return nil, i18n.NewI18nError("900004", lang, err.Error()) + } + + // 检查权限:只能查看自己的订单 + if order.CuUserID != cuUserID { + return nil, i18n.NewI18nError("100005", lang) // 权限不足 + } + + return order, nil +} + +func (s *cuOrderService) GetUserOrders(ctx context.Context, cuUserID string, offset, limit int) ([]*models.CuOrder, int64, error) { + lang := pkgcontext.GetLanguageFromContext(ctx) + + // 参数验证 + if cuUserID == "" { + return nil, 0, i18n.NewI18nError("900001", lang) + } + + orders, total, err := s.repo.GetByCuUserID(cuUserID, offset, limit) + if err != nil { + return nil, 0, i18n.NewI18nError("900004", lang, err.Error()) + } + + return orders, total, nil +} + +func (s *cuOrderService) CalculatePrice(ctx context.Context, packageID string, licenseCount int) (*PriceCalculationResult, error) { + lang := pkgcontext.GetLanguageFromContext(ctx) + + // 检查套餐是否存在 + pkg, exists := packageConfigs[packageID] + if !exists { + return nil, i18n.NewI18nError("600001", lang) // 套餐不存在 + } + + // 检查套餐是否已下架(虽然当前是写死配置,但保留这个检查) + // TODO: 如果将来添加status字段,可以在这里检查 + // if pkg.Status != "active" { + // return nil, i18n.NewI18nError("600002", lang) // 套餐已下架 + // } + + // 验证许可数量 + if licenseCount > pkg.MaxDevices { + return nil, i18n.NewI18nError("601005", lang) // 许可数量超出限制 + } + + // 计算折扣 + discountRate := 1.0 + discountDesc := "不享受折扣" + for _, rule := range discountRules { + if licenseCount >= rule.MinQuantity && (rule.MaxQuantity == 0 || licenseCount <= rule.MaxQuantity) { + discountRate = rule.Rate + discountDesc = rule.Description + break + } + } + + // 计算总价 + totalAmount := pkg.Price * float64(licenseCount) * discountRate + + return &PriceCalculationResult{ + UnitPrice: pkg.Price, + LicenseCount: licenseCount, + DiscountRate: discountRate, + TotalAmount: totalAmount, + DiscountDesc: discountDesc, + }, nil +} + +// 生成订单号 +func (s *cuOrderService) generateOrderNo() string { + now := time.Now() + dateStr := now.Format("20060102") + + // 使用纳秒时间戳的最后9位作为序列号,确保全局唯一性 + // 纳秒时间戳在同一纳秒内不会重复,重启应用也不会影响 + nano := now.UnixNano() % 1000000000 // 取最后9位 + + return fmt.Sprintf("ORD%s%09d", dateStr, nano) +} diff --git a/backend/migrations/003_create_authorization_codes_table.sql b/backend/migrations/003_create_authorization_codes_table.sql index f4e86bc..b1d6f81 100644 --- a/backend/migrations/003_create_authorization_codes_table.sql +++ b/backend/migrations/003_create_authorization_codes_table.sql @@ -6,7 +6,7 @@ CREATE TABLE authorization_codes ( -- 基础信息属性 id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()), - code VARCHAR(100) NOT NULL UNIQUE COMMENT '唯一授权码,软件身份凭证', + code VARCHAR(1000) NOT NULL UNIQUE COMMENT '唯一授权码,软件身份凭证(支持自包含配置)', customer_id VARCHAR(36) NOT NULL COMMENT '关联客户ID', created_by VARCHAR(36) NOT NULL COMMENT '创建人ID', software_id VARCHAR(50) COMMENT '目标软件产品ID', diff --git a/backend/migrations/008_create_cu_orders_table.sql b/backend/migrations/008_create_cu_orders_table.sql new file mode 100644 index 0000000..7b32e1a --- /dev/null +++ b/backend/migrations/008_create_cu_orders_table.sql @@ -0,0 +1,48 @@ +-- 客户用户订单表结构 +-- 基于产品套餐体系设计创建 +-- MySQL版本 + +-- 创建客户用户订单表 +CREATE TABLE cu_orders ( + -- 基础信息 + id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()), + order_no VARCHAR(50) NOT NULL COMMENT '订单号', + customer_id VARCHAR(36) NOT NULL COMMENT '客户ID', + cu_user_id VARCHAR(36) NOT NULL COMMENT '客户用户ID', + package_id VARCHAR(50) NOT NULL COMMENT '套餐ID (对应配置中的ID)', + package_name VARCHAR(100) NOT NULL COMMENT '套餐名称(快照)', + + -- 购买信息 + license_count INT NOT NULL COMMENT '许可数量', + unit_price DECIMAL(10,2) NOT NULL COMMENT '单价(每许可价格,快照)', + discount_rate DECIMAL(3,2) NOT NULL DEFAULT 1.0 COMMENT '折扣率', + total_amount DECIMAL(10,2) NOT NULL COMMENT '订单总金额', + + -- 状态信息 + status VARCHAR(20) NOT NULL DEFAULT 'paid' COMMENT '订单状态: paid-已支付', + authorization_code VARCHAR(50) NULL COMMENT '生成的授权码', + + -- 时间信息 + expired_at DATETIME(3) NULL COMMENT '订单过期时间', + created_at DATETIME(3) NOT NULL, + updated_at DATETIME(3) NOT NULL, + deleted_at DATETIME(3) NULL, + + -- 外键约束 + CONSTRAINT fk_cu_orders_customer_id FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE, + CONSTRAINT fk_cu_orders_cu_user_id FOREIGN KEY (cu_user_id) REFERENCES cu_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户用户订单表 - 产品套餐购买订单'; + +-- 创建索引 +CREATE INDEX idx_cu_orders_customer_id ON cu_orders(customer_id); +CREATE INDEX idx_cu_orders_cu_user_id ON cu_orders(cu_user_id); +CREATE INDEX idx_cu_orders_status ON cu_orders(status); +CREATE INDEX idx_cu_orders_created_at ON cu_orders(created_at); +CREATE INDEX idx_cu_orders_package_id ON cu_orders(package_id); +CREATE INDEX idx_cu_orders_deleted_at ON cu_orders(deleted_at); + +-- 注意: +-- 1. 时间戳由Go应用程序管理,不依赖数据库默认值 +-- 2. license_count决定授权码可以激活多少次 +-- 3. authorization_code在订单支付后生成 +-- 4. 使用软删除,通过deleted_at字段实现 diff --git a/backend/migrations/009_update_authorization_codes_code_length.sql b/backend/migrations/009_update_authorization_codes_code_length.sql new file mode 100644 index 0000000..bc89d73 --- /dev/null +++ b/backend/migrations/009_update_authorization_codes_code_length.sql @@ -0,0 +1,19 @@ +-- 更新授权码表code字段长度以支持自包含配置 +-- 使用HMAC签名的极简格式,预计长度80-200字符,预留足够空间 + +-- MySQL +-- 删除现有的UNIQUE索引(基于code字段的唯一索引) +-- 注意:如果索引不存在会报错但不影响迁移,可以忽略 +ALTER TABLE authorization_codes DROP INDEX IF EXISTS idx_authorization_codes_code; + +-- 修改字段长度为1000字符以支持更长的授权码 +ALTER TABLE authorization_codes MODIFY COLUMN code varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL; + +-- 创建前缀UNIQUE索引(只索引前255个字符,足够区分授权码) +-- 这样既保证唯一性,又避免了InnoDB的索引长度限制(3072字节) +CREATE UNIQUE INDEX idx_authorization_codes_code_prefix ON authorization_codes(code(255)); + +-- 注意事项: +-- 1. 此迁移会更改现有数据结构,请在生产环境执行前做好备份 +-- 2. 前缀索引保证了唯一性,但只检查前255个字符的唯一性 +-- 3. 对于LIC-{随机}-{Base64数据}格式,前255字符的碰撞概率极低 diff --git a/backend/migrations/010_update_cu_orders_authorization_code_length.sql b/backend/migrations/010_update_cu_orders_authorization_code_length.sql new file mode 100644 index 0000000..6bf76f5 --- /dev/null +++ b/backend/migrations/010_update_cu_orders_authorization_code_length.sql @@ -0,0 +1,11 @@ +-- 更新cu_orders表authorization_code字段长度 +-- 支持新的HMAC签名授权码格式(约150-200字符) + +-- MySQL +-- 修改authorization_code字段长度从50到500字符 +ALTER TABLE cu_orders MODIFY COLUMN authorization_code varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; + +-- 注意事项: +-- 1. 此迁移会更改现有数据结构,请在生产环境执行前做好备份 +-- 2. 500字符长度足以支持当前和未来的授权码格式 +-- 3. 如果将来需要更长,仍然可以进一步扩展 diff --git a/backend/migrations/README.md b/backend/migrations/README.md index 14064cd..9d08126 100644 --- a/backend/migrations/README.md +++ b/backend/migrations/README.md @@ -7,8 +7,16 @@ ``` sql/ ├── migrations/ # 数据库迁移文件 -│ ├── 001_create_customers_table.sql # 客户表结构 -│ └── 002_insert_sample_data.sql # 示例数据 +│ ├── 001_create_customers_table.sql # 客户表结构 +│ ├── 002_insert_sample_data.sql # 示例数据 +│ ├── 003_create_authorization_codes_table.sql # 授权码表结构 +│ ├── 004_create_licenses_table.sql # 许可证表结构 +│ ├── 005_create_authorization_changes_table.sql # 授权变更表结构 +│ ├── 006_create_users_table.sql # 管理员用户表结构 +│ ├── 007_create_cu_users_table.sql # 客户用户表结构 +│ ├── 008_create_cu_orders_table.sql # 客户用户订单表结构 +│ ├── 009_update_authorization_codes_code_length.sql # 更新授权码字段长度 +│ └── 010_update_cu_orders_authorization_code_length.sql # 更新订单表授权码字段长度 └── README.md # 本文件 ``` @@ -35,6 +43,40 @@ sql/ - 教育客户 - 禁用状态客户 +### 008_create_cu_orders_table.sql +创建客户用户订单表结构,包括: + +- **订单主表** (`cu_orders`):存储客户用户购买套餐的订单信息 +- **许可管理**:记录许可数量、价格、折扣等购买信息 +- **状态跟踪**:订单状态从创建到支付完成的生命周期 +- **授权关联**:存储生成的授权码和过期时间 + +### 009_update_authorization_codes_code_length.sql +更新授权码表结构以支持自包含配置授权码: + +- **字段更新**:`code VARCHAR(100) → VARCHAR(1000)` +- **功能增强**:支持包含完整配置信息的自包含授权码 +- **向后兼容**:保持对现有短授权码的支持 +- **安全考虑**:RSA签名确保配置信息完整性 + +**执行注意事项**: +- 此迁移会修改现有表结构 +- 生产环境执行前务必备份数据 +- 现有短授权码继续有效 +- 新授权码可包含完整配置信息 + +### 010_update_cu_orders_authorization_code_length.sql +更新客户订单表授权码字段长度以支持新的HMAC签名授权码: + +- **字段更新**:`authorization_code VARCHAR(50) → VARCHAR(500)` +- **兼容性**:支持约150-200字符的新格式授权码 +- **安全升级**:从RSA签名升级到HMAC签名,显著缩短授权码长度 + +**执行注意事项**: +- 扩展字段长度以适应新的授权码格式 +- 不会影响现有数据 +- 支持更高效的离线验证 + ## 执行顺序 按文件名的数字前缀顺序执行: diff --git a/backend/pkg/i18n/errors.go b/backend/pkg/i18n/errors.go index 9114f6d..1ac6d43 100644 --- a/backend/pkg/i18n/errors.go +++ b/backend/pkg/i18n/errors.go @@ -23,7 +23,8 @@ func NewI18nErrorResponse(code, lang string, customMessage ...string) (int, stri // 获取本地化错误信息 message := GetI18nErrorMessage(code, lang) - // 支持自定义消息覆盖 + // 如果提供了自定义消息,则使用自定义消息 + // 这样可以让调用方根据需要显示具体错误信息或本地化消息 if len(customMessage) > 0 && customMessage[0] != "" { message = customMessage[0] } diff --git a/backend/pkg/utils/license_codec.go b/backend/pkg/utils/license_codec.go new file mode 100644 index 0000000..f505f48 --- /dev/null +++ b/backend/pkg/utils/license_codec.go @@ -0,0 +1,409 @@ +package utils + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "time" +) + +// LicenseConfig 自包含授权码配置结构(极简字段名) +type LicenseConfig struct { + EndDate time.Time `json:"e"` // 到期时间 + FeatureConfig map[string]interface{} `json:"f,omitempty"` // 功能配置 + UsageLimits map[string]interface{} `json:"l,omitempty"` // 使用限制 + CustomParameters map[string]interface{} `json:"p,omitempty"` // 自定义参数 +} + +// EncodeLicenseData 将配置数据编码为自包含授权码(极简模式) +func EncodeLicenseData(config *LicenseConfig, privateKeyPath string) (string, error) { + if config == nil { + return "", errors.New("config cannot be nil") + } + + // 使用极简紧凑序列化 + configBytes, err := ultraCompactMarshal(config) + if err != nil { + return "", fmt.Errorf("failed to marshal config: %w", err) + } + + // 使用HMAC-SHA256替代RSA签名(短签名模式) + // 从私钥文件中提取密钥作为HMAC密钥(简化处理) + hmacKey, err := getHMACKeyFromRSAKey(privateKeyPath) + if err != nil { + return "", fmt.Errorf("failed to get HMAC key: %w", err) + } + + signature, err := signWithHMAC(configBytes, hmacKey) + if err != nil { + return "", fmt.Errorf("failed to sign data: %w", err) + } + + // 极简格式:[版本(4位)][时间戳(32位)][配置数据][HMAC签名(32字节)] + version := uint8(3) // 版本3(极简模式) + timestamp := uint32(config.EndDate.Unix()) + + // 打包头部:版本(4位) + 时间戳(28位) 到5字节 + header := make([]byte, 5) + header[0] = (version << 4) | byte(timestamp>>28) + header[1] = byte(timestamp >> 20) + header[2] = byte(timestamp >> 12) + header[3] = byte(timestamp >> 4) + header[4] = byte(timestamp << 4) // 最后4位保留给标志位 + + // 设置字段存在标志 + flagBits := uint8(0) + if config.FeatureConfig != nil { + flagBits |= 1 << 0 + } + if config.UsageLimits != nil { + flagBits |= 1 << 1 + } + if config.CustomParameters != nil { + flagBits |= 1 << 2 + } + header[4] |= flagBits + + // 构建数据:[头部(5字节)][配置数据][签名(32字节)] + data := append(header, configBytes...) + data = append(data, signature...) + + // 使用URL安全的Base64编码 + encodedData := base64.RawURLEncoding.EncodeToString(data) + + // 生成随机码(2位) + randomStr, err := generateRandomString(2) + if err != nil { + return "", fmt.Errorf("failed to generate random string: %w", err) + } + + // 最终格式:LIC-{2位随机}-{数据} + return fmt.Sprintf("LIC-%s-%s", randomStr, encodedData), nil +} + +// ultraCompactMarshal 极简紧凑序列化(无长度前缀) +func ultraCompactMarshal(config *LicenseConfig) ([]byte, error) { + var buf []byte + + // 直接序列化存在的字段,不使用长度前缀 + // 依赖解码时知道字段顺序和格式 + + if config.FeatureConfig != nil { + mapBytes, err := json.Marshal(config.FeatureConfig) + if err != nil { + return nil, err + } + buf = append(buf, mapBytes...) + buf = append(buf, 0) // 字段分隔符 + } + + if config.UsageLimits != nil { + mapBytes, err := json.Marshal(config.UsageLimits) + if err != nil { + return nil, err + } + buf = append(buf, mapBytes...) + buf = append(buf, 0) // 字段分隔符 + } + + if config.CustomParameters != nil { + mapBytes, err := json.Marshal(config.CustomParameters) + if err != nil { + return nil, err + } + buf = append(buf, mapBytes...) + buf = append(buf, 0) // 字段分隔符 + } + + return buf, nil +} + +// getHMACKeyFromRSAKey 从RSA密钥文件提取HMAC密钥 +func getHMACKeyFromRSAKey(keyPath string) ([]byte, error) { + // 首先尝试作为私钥加载 + if privateKey, err := getLicensePrivateKey(keyPath); err == nil { + // 使用私钥的模数作为HMAC密钥 + key := privateKey.N.Bytes() + // 截取前32字节作为HMAC-SHA256密钥 + if len(key) > 32 { + key = key[:32] + } else if len(key) < 32 { + // 补齐到32字节 + padding := make([]byte, 32-len(key)) + key = append(key, padding...) + } + return key, nil + } + + // 如果私钥加载失败,尝试作为公钥加载 + if publicKey, err := getLicensePublicKey(keyPath); err == nil { + // 使用公钥的模数作为HMAC密钥 + key := publicKey.N.Bytes() + // 截取前32字节作为HMAC-SHA256密钥 + if len(key) > 32 { + key = key[:32] + } else if len(key) < 32 { + // 补齐到32字节 + padding := make([]byte, 32-len(key)) + key = append(key, padding...) + } + return key, nil + } + + return nil, errors.New("failed to load RSA key for HMAC") +} + +// getLicensePublicKey 获取RSA公钥(复用crypto.go的函数) +func getLicensePublicKey(publicKeyPath string) (*RSAPublicKey, error) { + // 检查文件是否存在 + if _, err := os.Stat(publicKeyPath); os.IsNotExist(err) { + return nil, fmt.Errorf("RSA公钥文件不存在: %s", publicKeyPath) + } + + // 加载公钥 + publicKey, err := LoadRSAPublicKeyFromFile(publicKeyPath) + if err != nil { + return nil, fmt.Errorf("RSA公钥文件损坏或格式错误: %w", err) + } + + return publicKey, nil +} + +// signWithHMAC 使用HMAC-SHA256签名 +func signWithHMAC(data []byte, key []byte) ([]byte, error) { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil), nil +} + +// ultraCompactUnmarshal 从极简格式反序列化配置 +func ultraCompactUnmarshal(data []byte, flags uint8) (*LicenseConfig, error) { + config := &LicenseConfig{} + offset := 0 + + // 解析f字段(如果存在) + if flags&1 != 0 { + end := findFieldEnd(data, offset) + if end == -1 { + return nil, errors.New("invalid feature config format") + } + var m map[string]interface{} + if err := json.Unmarshal(data[offset:end], &m); err != nil { + return nil, fmt.Errorf("invalid feature config: %w", err) + } + config.FeatureConfig = m + offset = end + 1 + } + + // 解析l字段(如果存在) + if flags&(1<<1) != 0 { + end := findFieldEnd(data, offset) + if end == -1 { + return nil, errors.New("invalid usage limits format") + } + var m map[string]interface{} + if err := json.Unmarshal(data[offset:end], &m); err != nil { + return nil, fmt.Errorf("invalid usage limits: %w", err) + } + config.UsageLimits = m + offset = end + 1 + } + + // 解析p字段(如果存在) + if flags&(1<<2) != 0 { + if offset >= len(data) { + return nil, errors.New("missing custom parameters") + } + // 最后一个字段到数据结尾 + var m map[string]interface{} + if err := json.Unmarshal(data[offset:], &m); err != nil { + return nil, fmt.Errorf("invalid custom parameters: %w", err) + } + config.CustomParameters = m + } + + return config, nil +} + +// findFieldEnd 查找字段结束位置(以null字节分隔) +func findFieldEnd(data []byte, start int) int { + for i := start; i < len(data); i++ { + if data[i] == 0 { + return i + } + } + return -1 +} + +// DecodeLicenseData 从授权码解析配置数据 +func DecodeLicenseData(code string, publicKeyPath string) (*LicenseConfig, error) { + if !strings.HasPrefix(code, "LIC-") { + return nil, errors.New("invalid license format") + } + + // 解析授权码格式:LIC-{2位随机码}-{数据} + parts := strings.Split(code, "-") + if len(parts) != 3 || parts[0] != "LIC" { + return nil, errors.New("invalid license format") + } + + randomStr := parts[1] + encodedData := parts[2] + + // 验证随机码长度 + if len(randomStr) != 2 { + return nil, errors.New("invalid random string length") + } + + // 使用URL安全的Base64解码 + dataBytes, err := base64.RawURLEncoding.DecodeString(encodedData) + if err != nil { + return nil, fmt.Errorf("failed to decode base64: %w", err) + } + + // 解析极简格式:[头部(5字节)][配置数据][签名(32字节)] + if len(dataBytes) < 5+32 { // 头部 + 最少签名 + return nil, errors.New("invalid data format") + } + + // 解析头部 + header := dataBytes[:5] + version := header[0] >> 4 + timestamp := uint32(header[0]&0x0F)<<28 | uint32(header[1])<<20 | uint32(header[2])<<12 | uint32(header[3])<<4 | uint32(header[4]>>4) + flags := header[4] & 0x0F + + if version != 3 { + return nil, errors.New("unsupported version") + } + + config := &LicenseConfig{ + EndDate: time.Unix(int64(timestamp), 0), + } + + // 分离配置数据和签名 + configDataEnd := len(dataBytes) - 32 + configBytes := dataBytes[5:configDataEnd] + signature := dataBytes[configDataEnd:] + + // 获取HMAC密钥用于验证 + hmacKey, err := getHMACKeyFromRSAKey(publicKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to get HMAC key: %w", err) + } + + // 验证HMAC签名 + if !verifyHMAC(configBytes, signature, hmacKey) { + return nil, errors.New("signature verification failed") + } + + // 从极简格式反序列化配置 + fullConfig, err := ultraCompactUnmarshal(configBytes, flags) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + // 合并时间戳 + fullConfig.EndDate = config.EndDate + return fullConfig, nil +} + +// verifyHMAC 验证HMAC签名 +func verifyHMAC(data []byte, signature []byte, key []byte) bool { + h := hmac.New(sha256.New, key) + h.Write(data) + expectedMAC := h.Sum(nil) + return hmac.Equal(signature, expectedMAC) +} + +// SignLicenseData 使用RSA私钥对数据进行签名 +func SignLicenseData(data []byte, privateKeyPath string) ([]byte, error) { + // 获取RSA私钥 + privateKey, err := getLicensePrivateKey(privateKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to get private key: %w", err) + } + + // 使用现有的RSA签名方法(PSS模式,与许可证保持一致) + signature, err := privateKey.SignData(data) + if err != nil { + return nil, fmt.Errorf("failed to sign data: %w", err) + } + + // 解码Base64签名 + signatureBytes, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return nil, fmt.Errorf("failed to decode signature: %w", err) + } + + return signatureBytes, nil +} + +// VerifyLicenseSignature 使用RSA公钥验证签名 +func VerifyLicenseSignature(data []byte, signature []byte, publicKeyPath string) error { + // 获取RSA公钥 + publicKey, err := getLicensePublicKey(publicKeyPath) + if err != nil { + return fmt.Errorf("failed to get public key: %w", err) + } + + // 编码签名为Base64(与SignData方法匹配) + signatureBase64 := base64.StdEncoding.EncodeToString(signature) + + // 使用现有的RSA验证方法 + err = publicKey.VerifySignature(data, signatureBase64) + if err != nil { + return fmt.Errorf("signature verification failed: %w", err) + } + + return nil +} + +// getLicensePrivateKey 获取许可证RSA私钥 +func getLicensePrivateKey(privateKeyPath string) (*RSAPrivateKey, error) { + // 检查文件是否存在 + if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) { + return nil, fmt.Errorf("RSA私钥文件不存在,请先生成RSA密钥对: %s", privateKeyPath) + } + + // 加载私钥 + privateKey, err := LoadRSAPrivateKeyFromFile(privateKeyPath) + if err != nil { + return nil, fmt.Errorf("RSA私钥文件损坏或格式错误: %w", err) + } + + return privateKey, nil +} + +// generateRandomString 生成随机字符串 +func generateRandomString(length int) (string, error) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + for i, b := range bytes { + bytes[i] = chars[b%byte(len(chars))] + } + return string(bytes), nil +} + +// generateChecksum 生成校验码 +func generateChecksum(input string) string { + sum := 0 + for _, char := range input { + sum += int(char) + } + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + checksum := "" + for i := 0; i < 4; i++ { + checksum += string(chars[sum%len(chars)]) + sum = sum / len(chars) + } + return checksum +}