26 Commits
Author SHA1 Message Date
wood911010 33767d4e71 feat: 发布 v1.1.0,新增客户用户管理、产品套餐体系、授权码增强、设备管理、支付宝支付集成、短信验证码服务及缓存模块重构,修复授权码格式和支付流程问题 2026-01-17 22:48:44 +08:00
wood911010 b5fa708c28 refactor: 优化支付宝回调处理逻辑,简化空值检查和JSON序列化过程 2026-01-17 00:28:33 +08:00
wood911010 8eca9f92bb feat: 优化支付宝回调处理逻辑,支持多种套餐类型的使用限制映射,简化自定义参数处理 2026-01-17 00:18:06 +08:00
wood911010 612bf9090e feat: 更新支付宝支付回调处理逻辑,支持新旧接口兼容,优化回调数据处理和订单状态更新 2026-01-17 00:00:23 +08:00
wood911010 973b549298 feat: 添加设备和订单汇总接口,支持获取当前用户的设备和订单统计信息,优化授权码管理和设备筛选功能 2026-01-16 22:42:27 +08:00
wood911010 e91775ea76 feat: 添加产品激活码接口,支持生成包含授权码和配置签名的产品激活码,优化授权码管理流程 2026-01-16 21:21:28 +08:00
wood911010 04af5eca00 fix: 更新企业定制版套餐价格字段,将其改为定制报价以更好地反映服务内容 2026-01-15 09:18:59 +08:00
wood911010 3f6cca7364 feat: 添加企业定制版套餐到订单处理逻辑,支持按需定制的服务选项 2026-01-14 18:49:18 +08:00
wood911010 6bfde74e21 feat: 添加用户订单和设备汇总接口设计文档,支持获取当前登录用户的订单和设备统计信息 2026-01-13 23:47:42 +08:00
wood911010 194accefd8 fix: 更新短信服务配置,将最大发送次数从5增加到25,以支持更高的短信发送需求 2026-01-13 09:41:44 +08:00
wood911010 19a80887b3 fix: 更新短信发送错误处理,返回详细错误信息以便调试,改善用户体验 2026-01-13 01:05:59 +08:00
wood911010 7062b9f1e8 feat: 增强短信服务功能,添加验证码相关错误处理和国际化文本支持,更新错误码以改善用户体验 2026-01-12 19:54:00 +08:00
wood911010 d2423f3705 feat: 添加品牌与商标使用条款文档,明确品牌材料的使用规则和限制 2026-01-10 18:28:22 +08:00
wood911010 d9cb5361b1 feat: 添加缓存模块,支持内存和Redis缓存,优化短信服务功能,更新相关配置和文档 2026-01-10 18:18:15 +08:00
wood911010 19e0c6706a feat: 添加短信服务功能,支持发送当前手机号和新手机号验证码,更新相关配置和文档 2026-01-10 16:54:44 +08:00
wood911010 9c2b503d2b feat: 添加注册发送验证码功能,支持手机号验证及频率限制,更新相关文档和国际化文本 2026-01-10 15:44:49 +08:00
wood911010 af76eaf1b1 feat: 添加支付模块,支持支付宝支付功能,包括订单创建、支付状态查询及支付历史记录管理 2026-01-09 19:21:40 +08:00
wood911010 879aca449f feat: 添加授权码分享功能,支持用户将授权码分享给其他用户并生成新授权码 2026-01-07 00:55:12 +08:00
wood911010 2228beed51 feat: 添加设备管理模块,支持设备列表获取和设备解绑功能 2026-01-04 00:09:31 +08:00
wood911010 d7dc1e2dbe feat: 添加客户订单管理模块,支持套餐列表获取、订单创建及用户订单查询功能 2026-01-02 01:48:36 +08:00
wood911010 d76bff2142 feat: 添加客户用户管理模块,支持注册、登录、密码重置及个人资料管理功能 2025-12-31 00:40:37 +08:00
wood911010 18a1193618 chore: 更新后端和前端服务的镜像版本至 v1.0.5 2025-12-21 15:08:16 +08:00
wood911010 02fc6727f3 chore: 更新后端和前端服务的镜像版本至 v1.0.4 2025-12-21 14:59:07 +08:00
wood911010 146eb2c44d feat: 更新授权变更类型,添加起止时间支持,优化许可证更新表单和国际化文本 2025-12-21 13:31:00 +08:00
wood911010 53e3449849 chore: 修改开发环境的 API 基础 URL,恢复为本地地址 2025-12-09 20:39:22 +08:00
wood911010 b56766b123 chore: 更新后端服务的基础镜像版本至 alpine:3.18 2025-12-09 20:30:56 +08:00
99 changed files with 20208 additions and 292 deletions

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
# Branding & Trademark Policy
# 品牌与商标使用条款
本文件规定了本项目 Logo、图标、名称及其他品牌材料的使用规则。
The following terms govern the use of the project's logo, icons, name, and other branding materials.
源代码遵循 GNU General Public License v3(GPLv3)授权,但品牌材料不属于 GPLv3 授权范围。
The source code is licensed under the GNU General Public License v3 (GPLv3), but branding materials are **not** covered by the GPLv3 license.
---
## 1. Branding Materials Covered
## 1. 受保护的品牌材料
以下内容均属于“品牌材料”:
The following items are considered “Branding Materials”:
- 项目名称 / Project name
- Logo
- 图标 / Icons
- 品牌识别元素 / Brand identifiers
- 其他视觉或文字形式的品牌材料
Other visual or textual branding assets
---
## 2. No Modification
## 2. 禁止修改
未经版权所有者书面授权,禁止对品牌材料进行任何形式的修改:
You may not modify the Branding Materials in any way without explicit written permission:
- 更改颜色、比例、形状
Changing colors, proportions, or shapes
- 添加或删除元素
Adding or removing elements
- 制作衍生 Logo
Creating derivative logos
- 将 Logo 融入其他图形
Embedding the logo into other graphics
- 删除或替换软件中的原始 Logo
Removing or replacing the original logo in the software
---
## 3. No Replacement with Your Own Logo
## 3. 禁止替换为自有 Logo
未经书面授权,不得将软件中的 Logo 或品牌材料替换为任何个人或组织的 Logo、图标或品牌标识。
You may not replace the logo or branding materials within the software with your own or any third-party branding without written authorization.
---
## 4. No Commercial Use
## 4. 禁止商业使用
未经商业授权,禁止将品牌材料用于任何商业目的:
Commercial use of the Branding Materials is prohibited unless a commercial license is granted:
- 商业产品或服务
Commercial products or services
- 付费发行
Paid distribution
- 广告、营销、宣传
Advertising, marketing, or promotional use
- 商业网站或应用展示
Display on commercial websites or applications
---
## 5. No Misleading Use
## 5. 禁止误导性使用
不得以任何方式使用品牌材料暗示:
You may not use the Branding Materials in a way that implies:
- 官方认可 / Official endorsement
- 官方合作 / Official partnership
- 获得商业授权 / Commercial authorization
- 与版权所有者存在合作或从属关系
Any affiliation with the copyright holder
除非获得明确的书面许可。
Unless explicit written permission is provided.
---
## 6. Trademark Rights Reserved
## 6. 商标权保留
版权所有者保留对品牌材料的全部商标权。
All trademark rights to the Branding Materials are reserved by the copyright holder.
GPLv3 对源代码的授权 **不构成** 对商标或品牌材料的授权。
The GPLv3 license for the source code **does not** grant any rights to use the trademarks or branding materials.
---
## Contact for Permissions
## 授权联系
如需商业授权或品牌使用许可,请联系版权所有者。
For commercial licensing or branding permissions, please contact the copyright holder.
+1 -1
View File
@@ -6,7 +6,7 @@ RUN go mod download
COPY backend/ ./
RUN go build -ldflags="-s -w" -o license-manager cmd/main.go
FROM alpine:latest
FROM alpine:3.18
RUN apk --no-cache add ca-certificates tzdata curl
WORKDIR /app/backend/cmd
COPY --from=builder /app/backend/license-manager ./license-manager
+67 -1
View File
@@ -23,6 +23,10 @@ auth:
secret: "license-manager-dev-secret-key-32-bytes"
expire_hours: 1
refresh_threshold_minutes: 30
cu_jwt:
secret: "license-manager-cu-dev-secret-key-32-bytes"
expire_hours: 24
refresh_threshold_minutes: 60
security:
max_login_attempts: 5
lockout_duration_minutes: 15
@@ -43,9 +47,71 @@ license:
private_key_path: "../configs/rsa_private_key.pem" # RSA私钥文件路径(服务器端使用,必须保密)
public_key_path: "../configs/rsa_public_key.pem" # RSA公钥文件路径(客户端使用,可以公开)
key_size: 2048 # 密钥大小:2048或4096,默认2048
heartbeat_timeout: 300 # 心跳超时时间(秒) - 5分钟内为在线
offline_timeout: 1440 # 离线超时时间(分钟) - 24小时后为异常
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
payment:
# 默认支付方式
default_method: alipay
# 支付提供商配置 - 支付宝沙箱环境
providers:
alipay:
enabled: true
app_id: "9021000159611786"
private_key: "-----BEGIN PRIVATE KEY-----\nMIIEogIBAAKCAQEAhxBHcY3BLmA0glH1v1e9VnLFEc72ZyI5xNF77SMA5hGutz30\nSmzczjYvGJNYTeyWfIjIqtP0IA49XA61kpUPiTyBy2sxjDfu6ew+Yu2waynpI+Nbk\npIz8TWT1g7rqgZMsUexufmvFEpvn1AmboVKN8iZVflwc4lq5pQ/GS7uTlDK3fY32j\nEk6jxEHy8yZvkf467X0WK9AdrJ0IxUTOLBROdZySqg7mc2Iyo61xdDOuVCJjZSWFH\nt+s4UWx2VPFSbL2z4m82abGicAgj696J2PU9iLhQG2HA/EwrH8zsVyJPHTMgPFwng\nN9sxmT4U3OH8ugCnw/amEsD8VZR3PG3ypQIDAQABAoIBAFSs2Q2Q4H7xo5ncRmQ6Xq\nKW+4Ppq8MGyw7ggoAuBL84cL/C4KfUTi4d/DK9FYxs+f+DRcMLdK6wHrnbIuyN9Rq\nFy527S2nsGyWejZI6D/NvaBGu1Vczv8rGWzPFUOvcXTJ7jynYJnJjJE3N0vpRKDGG\n2tLyuhRFH7cFU/W23YJM7TflTUUh1SG0Ec+jUa+ek5nK/vsqGE7A5p/D+mW4WLfS4\nsGhfe1XzNKjuCznTYajG79YXHXBKljqq4SKoEdh0SQHoEy7H0BXqArjSbh4KdHYoW/\nKkvA/c5QpbaTuWdK3iuvq7bZ1I0SEckL/rzufj+OcgoKcnwxZG2/Eq1Nb8tUCgYEAw\ncuyQW1AZEVl4dbk9Y4JXFeWh82LZ9pmyvr9tmqwQP4Ywlb9zUlXy+Zbv1wa9acfRWu9\ndYGm3SByFk8c3UGb/s3+eY4MribqkNVkjdotC/+wfymyf3gdLypGEXjBGwNJregGFeF\nmUhWnS+YWOiCiH401uDTU1pRtW37DZgdD5ZMCgYEAsmqIRL4U+3bkIb8u26lgS3k2W\nImen8+i91jVc8wWD7/aWGNmjodimyOv9AJRt34y5apzzfcWuzr9qdNQrzGubtKjaVM6\ns6NNVGzUJkLjF4WpyFDmX/ZLZkTW0gQLMYAjmiEJkPlbpHQ81vSMCT7W76RB31vL8p\ndRJMGBz/dN6ecCgYATzaXsi6Z92J2do2EIOrh4KDcor3dkaGwCp/1PK73RFmYfTdZnq\nOwA2QxdFKNooOhQJr9Yh6Quioc+Al2XuoGfWxt6/moLTczAeh3pZPcfd8RoajFkVzjc\nPfylAmrxQLfBvqkIiVz0XGQZHGz/CaCnir2HklaBiu9V3zFGds2YYwKBgFuaNryUm1F\nAiYCKGBTPsZEOesiaX8bNZG56PZ1mey0642/eGRm0iPv+JqixSCNdt+QSz8I/ynv2naZ\nDyHkqmNRtcVjyUoFIprth5MqALPg2uCUQ5DcAzVWEoobRbK3NjHnl3HOCk3qr81Cp3f\nwhN1lyjB+RkfTLm+2zLGMKJgahAoGAGUigAVlCnUo6zj8PVOeUPJWV3tZf8ZkTtCXT\njdp9tPwZqf0ZlmVZCoQB3nclF/6PgxgH7W596DlUt8TIrdmgg28HM8C2rpY/AoCw1H3\ncMkha8gCUN308cx0I2duea3tvDUb8thfvR34d7bxCI2hTOsplfE/MVv+sjzprGwtH/84=\n-----END PRIVATE KEY-----"
public_key: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjkjXfe1fXjelyi6HIzFp\n5l9PGPxDBTi/TyIsRrF5NaYRCGqqSSJj1sc440eJ7InKRb4q/7cpdjlroC6DDO1O\nHKipld1pWSqoezc2lLVxvRWGt7Tl9mDfrul1/4ywx9OnIPBx3iJvzY15XDVWlZFY\nWVJFQv5EriLn+3xy+3gaazzJYCp/PumDqMTUgp0Kjk+HuHJ5/PKtFt3AtN2GB8gl\nQEybl2Wf0qNZU9SzmEm+9I7CNvB/zUC3csRxJHTvbOtkjhCC/TrS20Xm8IFjygu9\nlBf9qzacXyfHjG6fLMU8aDn4kPAXL+wbqE2UzFmVvMNCLpEaIS3r7I8S2TGQLPDD\n6QIDAQAB\n-----END PUBLIC KEY-----"
gateway_url: "https://openapi-sandbox.dl.alipaydev.com/gateway.do"
notify_url: "http://localhost:18888/api/payment/alipay/callback"
return_url: "http://localhost:18888/payment/success"
sign_type: "RSA2"
charset: "utf-8"
format: "json"
# 通用支付配置
expire_minutes: 30 # 支付过期时间(分钟)
retry_times: 3 # 支付重试次数
enable_log: true # 启用支付日志
sms:
enabled: false # 开发环境默认禁用,需要配置阿里云AccessKey
access_key_id: "" # 请配置阿里云AccessKey ID
access_key_secret: "" # 请配置阿里云AccessKey Secret
sign_name: "xxxx科技"
region_id: "cn-hangzhou"
endpoint: "dysmsapi.aliyuncs.com"
# 模板配置(可选,不配置则使用代码中的默认值)
templates:
register: "SMS_330275014" # 注册验证码模板
reset_pwd: "SMS_330275014" # 重置密码验证码模板
login: "SMS_330275014" # 登录验证码模板
current_phone: "SMS_330275014" # 当前手机号验证码模板
new_phone: "SMS_330275014" # 新手机号验证码模板
cache:
enabled: true # 是否启用缓存
type: memory # 缓存类型: memory, redis
ttl: 30m # 默认TTL
# Redis缓存配置(当type为redis时使用)
redis:
host: localhost
port: 6379
password: ""
db: 0
pool_size: 10
min_idle_conns: 5
conn_max_idle_time: 30m
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
# 内存缓存配置(当type为memory时使用)
memory:
max_size: 10000 # 最大缓存条目数
cleanup_interval: 10m # 清理间隔
+77 -2
View File
@@ -24,6 +24,10 @@ auth:
secret: "license-manager-dev-secret-key-32-bytes" # 32字节的随机字符串
expire_hours: 1 # 1小时后过期
refresh_threshold_minutes: 30 # 30分钟后可刷新token
cu_jwt:
secret: "license-manager-cu-dev-secret-key-32-bytes" # 客户用户JWT密钥
expire_hours: 24 # 24小时后过期
refresh_threshold_minutes: 60 # 60分钟后可刷新token
security:
max_login_attempts: 5 # 5次失败后锁定15分钟
lockout_duration_minutes: 15 # 15分钟后解锁
@@ -44,7 +48,78 @@ license:
private_key_path: "../configs/rsa_private_key.pem" # RSA私钥文件路径(服务器端使用,必须保密)
public_key_path: "../configs/rsa_public_key.pem" # RSA公钥文件路径(客户端使用,可以公开)
key_size: 2048 # 密钥大小:2048或4096,默认2048
heartbeat_timeout: 300 # 心跳超时时间(秒) - 5分钟内为在线
offline_timeout: 1440 # 离线超时时间(分钟) - 24小时后为异常
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
payment:
# 默认支付方式
default_method: alipay
# 支付提供商配置
providers:
alipay:
enabled: true
app_id: "your_alipay_app_id"
private_key: "your_alipay_private_key"
public_key: "alipay_public_key"
gateway_url: "https://openapi.alipay.com/gateway.do"
notify_url: "https://your-domain.com/api/payment/alipay/callback"
return_url: "https://your-domain.com/payment/success"
sign_type: "RSA2"
charset: "utf-8"
format: "json"
wechat:
enabled: false # 可动态开启
app_id: "your_wechat_app_id"
mch_id: "your_wechat_mch_id"
private_key: "your_wechat_private_key"
gateway_url: "https://api.mch.weixin.qq.com/pay/unifiedorder"
notify_url: "https://your-domain.com/api/payment/wechat/callback"
# ... 其他微信支付配置
# 通用支付配置
expire_minutes: 30 # 支付过期时间(分钟)
retry_times: 3 # 支付重试次数
enable_log: true # 启用支付日志
sms:
enabled: false # 是否启用短信服务
access_key_id: "your-access-key-id" # 阿里云AccessKey ID
access_key_secret: "your-access-key-secret" # 阿里云AccessKey Secret
sign_name: "xxxx科技" # 短信签名
region_id: "cn-hangzhou" # 地域ID
endpoint: "dysmsapi.aliyuncs.com" # 服务端点
# 模板配置(可选,不配置则使用代码中的默认值)
templates:
register: "SMS_330275014" # 注册验证码模板
reset_pwd: "SMS_330275014" # 重置密码验证码模板
login: "SMS_330275014" # 登录验证码模板
current_phone: "SMS_330275014" # 当前手机号验证码模板
new_phone: "SMS_330275014" # 新手机号验证码模板
cache:
enabled: true # 是否启用缓存
type: memory # 缓存类型: memory, redis
ttl: 30m # 默认TTL
# Redis缓存配置(当type为redis时使用)
redis:
host: localhost
port: 6379
password: ""
db: 0
pool_size: 10
min_idle_conns: 5
conn_max_idle_time: 30m
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
# 内存缓存配置(当type为memory时使用)
memory:
max_size: 10000 # 最大缓存条目数
cleanup_interval: 10m # 清理间隔
+64
View File
@@ -23,6 +23,10 @@ auth:
secret: "please-change-in-prod"
expire_hours: 1
refresh_threshold_minutes: 30
cu_jwt:
secret: "please-change-cu-jwt-secret-in-prod"
expire_hours: 24
refresh_threshold_minutes: 60
security:
max_login_attempts: 5
lockout_duration_minutes: 15
@@ -48,4 +52,64 @@ license:
offline_timeout: 1440 # 离线超时时间(分钟) - 24小时后为异常
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
payment:
# 默认支付方式
default_method: alipay
# 支付提供商配置 - 生产环境支付宝配置
providers:
alipay:
enabled: true
app_id: "your-prod-app-id"
private_key: "your-prod-private-key"
public_key: "your-prod-public-key"
gateway_url: "https://openapi.alipay.com/gateway.do"
notify_url: "https://your-domain.com/api/payment/alipay/callback"
return_url: "https://your-domain.com/payment/success"
sign_type: "RSA2"
charset: "utf-8"
format: "json"
# 通用支付配置
expire_minutes: 30 # 支付过期时间(分钟)
retry_times: 3 # 支付重试次数
enable_log: true # 启用支付日志
sms:
enabled: false # 生产环境默认禁用,需要配置阿里云AccessKey
access_key_id: "" # 请配置生产环境阿里云AccessKey ID
access_key_secret: "" # 请配置生产环境阿里云AccessKey Secret
sign_name: "xxxx科技"
region_id: "cn-hangzhou"
endpoint: "dysmsapi.aliyuncs.com"
# 模板配置(可选,不配置则使用代码中的默认值)
templates:
register: "SMS_330275014" # 注册验证码模板
reset_pwd: "SMS_330275014" # 重置密码验证码模板
login: "SMS_330275014" # 登录验证码模板
current_phone: "SMS_330275014" # 当前手机号验证码模板
new_phone: "SMS_330275014" # 新手机号验证码模板
cache:
enabled: true # 是否启用缓存
type: memory # 缓存类型: memory, redis
ttl: 30m # 默认TTL
# Redis缓存配置(当type为redis时使用)
redis:
host: localhost
port: 6379
password: ""
db: 0
pool_size: 10
min_idle_conns: 5
conn_max_idle_time: 30m
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
# 内存缓存配置(当type为memory时使用)
memory:
max_size: 10000 # 最大缓存条目数
cleanup_interval: 10m # 清理间隔
+69 -3
View File
@@ -23,6 +23,10 @@ auth:
secret: "license-manager-dev-secret-key-32-bytes" # 32字节的随机字符串
expire_hours: 1 # 1小时后过期
refresh_threshold_minutes: 30 # 30分钟后可刷新token
cu_jwt:
secret: "license-manager-cu-dev-secret-key-32-bytes" # 客户用户JWT密钥
expire_hours: 24 # 24小时后过期
refresh_threshold_minutes: 60 # 60分钟后可刷新token
security:
max_login_attempts: 5 # 5次失败后锁定15分钟
lockout_duration_minutes: 15 # 15分钟后解锁
@@ -43,7 +47,69 @@ license:
private_key_path: "../configs/rsa_private_key.pem" # RSA私钥文件路径(服务器端使用,必须保密)
public_key_path: "../configs/rsa_public_key.pem" # RSA公钥文件路径(客户端使用,可以公开)
key_size: 2048 # 密钥大小:2048或4096,默认2048
heartbeat_timeout: 300 # 心跳超时时间(秒) - 5分钟内为在线
heartbeat_timeout: 300 # 心跳超时时间(秒) - 5分钟内未上报心跳视为离线
offline_timeout: 1440 # 离线超时时间(分钟) - 24小时后为异常
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
payment:
# 默认支付方式
default_method: alipay
# 支付提供商配置 - 支付宝沙箱环境
providers:
alipay:
enabled: true
app_id: "9021000159611786"
private_key: "-----BEGIN PRIVATE KEY-----\nMIIEogIBAAKCAQEAhxBHcY3BLmA0glH1v1e9VnLFEc72ZyI5xNF77SMA5hGutz30\nSmzczjYvGJNYTeyWfIjIqtP0IA49XA61kpUPiTyBy2sxjDfu6ew+Yu2waynpI+Nbk\npIz8TWT1g7rqgZMsUexufmvFEpvn1AmboVKN8iZVflwc4lq5pQ/GS7uTlDK3fY32j\nEk6jxEHy8yZvkf467X0WK9AdrJ0IxUTOLBROdZySqg7mc2Iyo61xdDOuVCJjZSWFH\nt+s4UWx2VPFSbL2z4m82abGicAgj696J2PU9iLhQG2HA/EwrH8zsVyJPHTMgPFwng\nN9sxmT4U3OH8ugCnw/amEsD8VZR3PG3ypQIDAQABAoIBAFSs2Q2Q4H7xo5ncRmQ6Xq\nKW+4Ppq8MGyw7ggoAuBL84cL/C4KfUTi4d/DK9FYxs+f+DRcMLdK6wHrnbIuyN9Rq\nFy527S2nsGyWejZI6D/NvaBGu1Vczv8rGWzPFUOvcXTJ7jynYJnJjJE3N0vpRKDGG\n2tLyuhRFH7cFU/W23YJM7TflTUUh1SG0Ec+jUa+ek5nK/vsqGE7A5p/D+mW4WLfS4\nsGhfe1XzNKjuCznTYajG79YXHXBKljqq4SKoEdh0SQHoEy7H0BXqArjSbh4KdHYoW/\nKkvA/c5QpbaTuWdK3iuvq7bZ1I0SEckL/rzufj+OcgoKcnwxZG2/Eq1Nb8tUCgYEAw\ncuyQW1AZEVl4dbk9Y4JXFeWh82LZ9pmyvr9tmqwQP4Ywlb9zUlXy+Zbv1wa9acfRWu9\ndYGm3SByFk8c3UGb/s3+eY4MribqkNVkjdotC/+wfymyf3gdLypGEXjBGwNJregGFeF\nmUhWnS+YWOiCiH401uDTU1pRtW37DZgdD5ZMCgYEAsmqIRL4U+3bkIb8u26lgS3k2W\nImen8+i91jVc8wWD7/aWGNmjodimyOv9AJRt34y5apzzfcWuzr9qdNQrzGubtKjaVM6\ns6NNVGzUJkLjF4WpyFDmX/ZLZkTW0gQLMYAjmiEJkPlbpHQ81vSMCT7W76RB31vL8p\ndRJMGBz/dN6ecCgYATzaXsi6Z92J2do2EIOrh4KDcor3dkaGwCp/1PK73RFmYfTdZnq\nOwA2QxdFKNooOhQJr9Yh6Quioc+Al2XuoGfWxt6/moLTczAeh3pZPcfd8RoajFkVzjc\nPfylAmrxQLfBvqkIiVz0XGQZHGz/CaCnir2HklaBiu9V3zFGds2YYwKBgFuaNryUm1F\nAiYCKGBTPsZEOesiaX8bNZG56PZ1mey0642/eGRm0iPv+JqixSCNdt+QSz8I/ynv2naZ\nDyHkqmNRtcVjyUoFIprth5MqALPg2uCUQ5DcAzVWEoobRbK3NjHnl3HOCk3qr81Cp3f\nwhN1lyjB+RkfTLm+2zLGMKJgahAoGAGUigAVlCnUo6zj8PVOeUPJWV3tZf8ZkTtCXT\njdp9tPwZqf0ZlmVZCoQB3nclF/6PgxgH7W596DlUt8TIrdmgg28HM8C2rpY/AoCw1H3\ncMkha8gCUN308cx0I2duea3tvDUb8thfvR34d7bxCI2hTOsplfE/MVv+sjzprGwtH/84=\n-----END PRIVATE KEY-----"
public_key: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjkjXfe1fXjelyi6HIzFp\n5l9PGPxDBTi/TyIsRrF5NaYRCGqqSSJj1sc440eJ7InKRb4q/7cpdjlroC6DDO1O\nHKipld1pWSqoezc2lLVxvRWGt7Tl9mDfrul1/4ywx9OnIPBx3iJvzY15XDVWlZFY\nWVJFQv5EriLn+3xy+3gaazzJYCp/PumDqMTUgp0Kjk+HuHJ5/PKtFt3AtN2GB8gl\nQEybl2Wf0qNZU9SzmEm+9I7CNvB/zUC3csRxJHTvbOtkjhCC/TrS20Xm8IFjygu9\nlBf9qzacXyfHjG6fLMU8aDn4kPAXL+wbqE2UzFmVvMNCLpEaIS3r7I8S2TGQLPDD\n6QIDAQAB\n-----END PUBLIC KEY-----"
gateway_url: "https://openapi-sandbox.dl.alipaydev.com/gateway.do"
notify_url: "http://localhost:18888/api/payment/alipay/callback"
return_url: "http://localhost:18888/payment/success"
sign_type: "RSA2"
charset: "utf-8"
format: "json"
# 通用支付配置
expire_minutes: 30 # 支付过期时间(分钟)
retry_times: 3 # 支付重试次数
enable_log: true # 启用支付日志
sms:
enabled: true # 是否启用短信服务(需要配置阿里云AccessKey)
access_key_id: "xxxxxxxxxxx" # 阿里云AccessKey ID
access_key_secret: "xxxxxxxxxxxxxxxxxxx" # 阿里云AccessKey Secret
sign_name: "xxxx科技" # 短信签名
region_id: "cn-hangzhou" # 地域ID
endpoint: "dysmsapi.aliyuncs.com" # 服务端点
# 模板配置(可选,不配置则使用代码中的默认值)
templates:
register: "SMS_330275014" # 注册验证码模板
reset_pwd: "SMS_330275014" # 重置密码验证码模板
login: "SMS_330275014" # 登录验证码模板
current_phone: "SMS_330275014" # 当前手机号验证码模板
new_phone: "SMS_330275014" # 新手机号验证码模板
cache:
enabled: true # 是否启用缓存
type: memory # 缓存类型: memory, redis
ttl: 30m # 默认TTL
# Redis缓存配置(当type为redis时使用)
redis:
host: localhost
port: 6379
password: ""
db: 0
pool_size: 10
min_idle_conns: 5
conn_max_idle_time: 30m
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
# 内存缓存配置(当type为memory时使用)
memory:
max_size: 10000 # 最大缓存条目数
cleanup_interval: 10m # 清理间隔
+48 -9
View File
@@ -18,11 +18,13 @@ errors:
customer:
"200001": "Customer not found"
"200002": "Customer already exists"
"200003": "Customer name is required"
"200004": "Invalid customer information format"
"200005": "Failed to query customer list"
"200006": "Customer has authorization codes, cannot delete. Please delete authorizations first"
"200007": "Customer is disabled, cannot create authorization"
"200003": "Request too frequent, please try again later"
"200004": "SMS sending failed"
"200005": "Customer name is required"
"200006": "Invalid customer information format"
"200007": "Failed to query customer list"
"200008": "Customer has authorization codes, cannot delete. Please delete authorizations first"
"200009": "Customer is disabled, cannot create authorization"
# Authorization module (30xxxx)
authorization:
@@ -36,6 +38,12 @@ errors:
"300008": "Hardware fingerprint mismatch"
"300009": "Failed to generate license file"
"300010": "Invalid configuration parameters"
"300101": "Authorization code not found"
"300102": "Authorization code is locked"
"300103": "Share count exceeds available activations"
"300104": "Target user not found"
"300105": "Cannot share with yourself"
"300106": "Database transaction failed"
# Dashboard module (40xxxx)
dashboard:
@@ -45,7 +53,40 @@ errors:
"400004": "Time range exceeds limit (over one year)"
"400005": "Start date cannot be later than end date"
"400006": "Invalid limit parameter"
# Customer User module (50xxxx)
cu_user:
"500001": "Invalid phone number format"
"500002": "Phone number already registered"
"500003": "Invalid phone or password"
"500004": "Forgot password failed"
"500005": "Reset password failed"
"500006": "Profile update failed"
"500007": "Phone update failed"
"500008": "Password change failed"
"500009": "User not found"
"500010": "Authentication token required"
"500011": "Invalid authentication token"
"500012": "Authentication token expired"
"500013": "Failed to generate token"
"500014": "Verification code incorrect or expired"
"500015": "Verification code does not exist"
"500016": "SMS sending too frequent, please try again later"
"500017": "SMS sending failed"
# 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"
@@ -113,9 +154,7 @@ enums:
authorization_change_type:
"renewal": "Renewal"
"upgrade": "Upgrade"
"limit_change": "Limit Change"
"feature_toggle": "Feature Toggle"
"feature_limit_change": "Feature Limit Change"
"lock": "Lock"
"unlock": "Unlock"
"other": "Other"
+48 -9
View File
@@ -18,11 +18,13 @@ errors:
customer:
"200001": "顧客が見つかりません"
"200002": "顧客が既に存在します"
"200003": "顧客名は必須です"
"200004": "無効な顧客情報形式"
"200005": "顧客リストの照会に失敗しました"
"200006": "顧客にまだ認可コードが存在します。削除する前に認可を削除してください"
"200007": "顧客が無効になっています。認可を作成できません"
"200003": "リクエストが頻繁すぎます。しばらくしてからもう一度お試しください"
"200004": "SMS送信に失敗しました"
"200005": "顧客名は必須です"
"200006": "無効な顧客情報形式"
"200007": "顧客リストの照会に失敗しました"
"200008": "顧客にまだ認可コードが存在します。削除する前に認可を削除してください"
"200009": "顧客が無効になっています。認可を作成できません"
# 認可モジュール (30xxxx)
authorization:
@@ -36,6 +38,12 @@ errors:
"300008": "ハードウェア指紋が一致しません"
"300009": "ライセンスファイルの生成に失敗しました"
"300010": "無効な設定パラメータ"
"300101": "認可コードが見つかりません"
"300102": "認可コードがロックされています"
"300103": "共有数が利用可能なアクティベーション数を超えています"
"300104": "対象ユーザーが見つかりません"
"300105": "自分自身に共有できません"
"300106": "データベーストランザクションが失敗しました"
# ダッシュボードモジュール (40xxxx)
dashboard:
@@ -45,7 +53,40 @@ errors:
"400004": "時間範囲が制限を超えています(1年以上)"
"400005": "開始日は終了日より後にできません"
"400006": "無効な制限パラメータ"
# 顧客ユーザーモジュール (50xxxx)
cu_user:
"500001": "電話番号形式が無効です"
"500002": "電話番号が既に登録されています"
"500003": "電話番号またはパスワードが無効です"
"500004": "パスワードリセットに失敗しました"
"500005": "パスワードリセットに失敗しました"
"500006": "プロフィール更新に失敗しました"
"500007": "電話番号更新に失敗しました"
"500008": "パスワード変更に失敗しました"
"500009": "ユーザーが見つかりません"
"500010": "認証トークンが必要です"
"500011": "認証トークンが無効です"
"500012": "認証トークンが期限切れです"
"500013": "トークン生成に失敗しました"
"500014": "認証コードが正しくないか期限切れです"
"500015": "認証コードが存在しません"
"500016": "SMS送信が頻繁すぎます。しばらくしてからお試しください"
"500017": "SMS送信に失敗しました"
# セット料金モジュール (60xxxx)
cu_package:
"600001": "セット料金が存在しません"
"600002": "セット料金は販売終了しました"
"600003": "試用版購入時間制限"
"600004": "試用版は一括購入をサポートしていません"
"600005": "今月試用版を購入済み"
# 注文モジュール (61xxxx)
cu_order:
"601001": "注文が存在しません"
"601005": "ライセンス数量が制限を超えています"
# システム共通 (90xxxx)
system:
"900001": "無効なリクエストパラメータ"
@@ -113,9 +154,7 @@ enums:
authorization_change_type:
"renewal": "更新"
"upgrade": "アップグレード"
"limit_change": "制限変更"
"feature_toggle": "機能切り替え"
"feature_limit_change": "機能制限変更"
"lock": "ロック"
"unlock": "アンロック"
"other": "その他"
+52 -9
View File
@@ -18,11 +18,13 @@ errors:
customer:
"200001": "客户不存在"
"200002": "客户已存在"
"200003": "客户名称不能为空"
"200004": "客户信息格式无效"
"200005": "客户列表查询失败"
"200006": "客户仍有授权,无法删除,请先删除授权"
"200007": "客户已停用,无法创建授权"
"200003": "请求过于频繁,请稍后再试"
"200004": "短信发送失败"
"200005": "客户名称不能为空"
"200006": "客户信息格式无效"
"200007": "客户列表查询失败"
"200008": "客户仍有授权,无法删除,请先删除授权"
"200009": "客户已停用,无法创建授权"
# 授权模块 (30xxxx)
authorization:
@@ -36,6 +38,12 @@ errors:
"300008": "硬件指纹不匹配"
"300009": "许可证文件生成失败"
"300010": "配置参数错误"
"300101": "授权码不存在"
"300102": "授权码已被锁定"
"300103": "分享数量超过可用激活数"
"300104": "目标用户不存在"
"300105": "不能分享给自己"
"300106": "数据库事务失败"
# 仪表盘模块 (40xxxx)
dashboard:
@@ -46,6 +54,43 @@ errors:
"400005": "开始日期不能晚于结束日期"
"400006": "数量限制参数错误"
# 客户用户模块 (50xxxx)
cu_user:
"500001": "手机号格式错误"
"500002": "手机号已被注册"
"500003": "手机号或密码错误"
"500004": "忘记密码失败"
"500005": "重置密码失败"
"500006": "个人资料更新失败"
"500007": "手机号更新失败"
"500008": "密码修改失败"
"500009": "用户不存在"
"500010": "认证令牌缺失"
"500011": "认证令牌无效"
"500012": "认证令牌已过期"
"500013": "令牌生成失败"
"500014": "验证码错误或已过期"
"500015": "验证码不存在"
"500016": "短信发送过于频繁,请稍后再试"
"500017": "短信发送失败"
# 套餐订单模块 (60xxxx)
cu_package:
"600001": "套餐不存在"
"600002": "套餐已下架"
"600003": "试用版购买时间限制"
"600004": "试用版不支持批量购买"
"600005": "当月已购买试用版"
# 订单模块 (61xxxx)
cu_order:
"601001": "订单不存在"
"601005": "许可数量超出限制"
# 设备管理模块 (62xxxx)
cu_device:
"620001": "设备不存在或无权限访问"
# 系统通用 (90xxxx)
system:
"900001": "请求参数无效"
@@ -112,10 +157,8 @@ enums:
"offline": "离线"
authorization_change_type:
"renewal": "续期"
"upgrade": "升级"
"limit_change": "限制变更"
"feature_toggle": "功能切换"
"renewal": "日期变更"
"feature_limit_change": "功能限制变更"
"lock": "锁定"
"unlock": "解锁"
"other": "其他"
+48 -5
View File
@@ -6,9 +6,11 @@
ORM: GORM,开发环境使用Auto Migration
配置: Viper
日志: logrus
认证: JWT
认证: JWT (支持管理员和C端用户双JWT体系)
缓存: Redis (可选)
数据库: PostgreSQL/MySQL
支付集成: 支付宝
加密算法: RSA
## 目录结构
@@ -20,16 +22,32 @@
backend/
├── cmd/
│ ├── main.go # 程序入口
│ └── license_manager.sh # 启动脚本
│ ├── license_manager.sh # 启动脚本
│ ├── client-demo/ # C端客户端演示程序
│ │ ├── main.go
│ │ ├── rsa.go
│ │ ├── client_config.json
│ │ ├── README.md
│ │ └── license_code/
│ └── gen-rsa-keys/ # RSA密钥生成工具
│ └── main.go
├── internal/
│ ├── api/
│ │ ├── handlers/ # HTTP处理器
│ │ │ ├── auth_handler.go
│ │ │ ├── authorization_code_handler.go
│ │ │ ├── customer_handler.go
│ │ │ ├── dashboard_handler.go
│ │ │ ├── enum_handler.go
│ │ │ ├── license_handler.go
│ │ │ └── system_handler.go
│ │ │ ├── payment_handler.go
│ │ │ ├── system_handler.go
│ │ │ ├── cu_auth_handler.go
│ │ │ ├── cu_authorization_handler.go
│ │ │ ├── cu_device_handler.go
│ │ │ ├── cu_order_handler.go
│ │ │ ├── cu_order_handler_test.go
│ │ │ └── cu_profile_handler.go
│ │ ├── middleware/ # 中间件
│ │ │ ├── auth.go
│ │ │ ├── cors.go
@@ -41,22 +59,36 @@ backend/
│ │ ├── auth_service.go
│ │ ├── authorization_code_service.go
│ │ ├── customer_service.go
│ │ ├── dashboard_service.go
│ │ ├── enum_service.go
│ │ ├── license_service.go
│ │ ├── payment_service.go
│ │ ├── system_service.go
│ │ ├── cu_device_service.go
│ │ ├── cu_order_service.go
│ │ ├── cu_user_service.go
│ │ └── interfaces.go # 接口定义
│ ├── repository/ # 数据访问层
│ │ ├── authorization_code_repository.go
│ │ ├── customer_repository.go
│ │ ├── dashboard_repository.go
│ │ ├── license_repository.go
│ │ ├── payment_repository.go
│ │ ├── user_repository.go
│ │ ├── cu_order_repository.go
│ │ ├── cu_user_repository.go
│ │ ├── gorm/ # GORM相关扩展
│ │ ├── errors.go
│ │ └── interfaces.go
│ ├── models/ # 数据模型
│ │ ├── auth.go
│ │ ├── common.go
│ │ ├── customer.go
│ │ ├── cu_order.go
│ │ ├── cu_user.go
│ │ ├── dashboard.go
│ │ ├── license.go
│ │ ├── payment.go
│ │ ├── system.go
│ │ └── user.go
│ ├── config/ # 配置管理
@@ -71,8 +103,11 @@ backend/
│ │ ├── errors.go
│ │ └── manager.go
│ ├── utils/ # 工具函数
│ │ ├── crypto.go
│ │ └── jwt.go
│ │ ├── alipay.go # 支付宝支付集成
│ │ ├── crypto.go # 加密工具
│ │ ├── cu_jwt.go # C端用户JWT工具
│ │ ├── jwt.go # JWT工具
│ │ └── authorization_code.go # 授权码生成
│ └── logger/ # 日志封装
│ └── logger.go
├── configs/
@@ -113,6 +148,11 @@ backend/
│ ├── 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
│ ├── 011_create_payments_table.sql
│ └── README.md
├── go.mod
└── go.sum
@@ -123,4 +163,7 @@ backend/
- **依赖注入**:通过接口解耦各层
- **配置驱动**:Viper管理所有配置
- **缓存抽象**:支持内存/Redis切换
- **双JWT体系**:支持管理员和C端用户独立认证体系
- **模块化设计**:支持客户管理、订单管理、支付集成等独立模块
- **国际化支持**:完整的多语言错误信息系统
@@ -0,0 +1,179 @@
# 用户端设备汇总接口设计文档
## 需求概述
提供用户端设备汇总统计接口,返回当前登录客户的设备总数、在线设备数、离线设备数。
## 接口路径
```
GET /api/cu/devices/summary
```
## 认证要求
- 需要 Bearer Token 认证(C端用户JWT)
- 基于当前登录用户的客户ID进行统计
## 请求参数
无查询参数
## 响应数据结构
### 成功响应
```json
{
"code": "000000",
"message": "成功",
"data": {
"total_devices": 100,
"online_devices": 85,
"offline_devices": 15
}
}
```
### 响应字段说明
| 字段 | 类型 | 必填 | 描述 |
|------|------|------|------|
| total_devices | int64 | 是 | 设备总数(活跃许可证数) |
| online_devices | int64 | 是 | 在线设备数 |
| offline_devices | int64 | 是 | 离线设备数 |
### 在线状态判断逻辑
参考现有代码实现:
- 通过 `last_heartbeat` 时间戳判断设备是否在线
- 心跳超时时间从配置获取,默认5分钟(300秒)
- 如果 `last_heartbeat + heartbeat_timeout > 当前时间`,则设备在线
### 业务规则
- 统计范围:当前登录用户的客户ID下的所有活跃设备(`status = 'active'` 且 `deleted_at IS NULL`)
- 权限控制:只能查看自己客户ID下的设备数据
- 数据一致性:`total_devices = online_devices + offline_devices`
## 错误响应
| 错误码 | HTTP状态码 | 描述 |
|--------|------------|------|
| 500011 | 401 | 认证令牌无效 |
| 500012 | 401 | 认证令牌已过期 |
| 500010 | 401 | 认证令牌缺失 |
| 900004 | 500 | 服务器内部错误 |
## Swagger 文档
```go
// GetDeviceSummary 获取设备汇总统计
// @Summary 获取设备汇总统计
// @Description 获取当前登录用户的设备汇总统计信息(总数、在线数、离线数)
// @Tags 客户设备管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.DeviceSummaryResponse} "获取成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/devices/summary [get]
```
## 技术实现方案
### 1. 新增响应模型(models/license.go)
```go
// DeviceSummaryResponse 设备汇总响应结构
type DeviceSummaryResponse struct {
TotalDevices int64 `json:"total_devices"` // 设备总数
OnlineDevices int64 `json:"online_devices"` // 在线设备数
OfflineDevices int64 `json:"offline_devices"` // 离线设备数
}
```
### 2. 服务层接口扩展(service/interfaces.go)
在 `CuDeviceService` 接口中添加:
```go
// 获取设备汇总统计
GetDeviceSummary(ctx context.Context, customerID string) (*models.DeviceSummaryResponse, error)
```
### 3. Repository层接口扩展(repository/interfaces.go)
在 `LicenseRepository` 接口中添加:
```go
// GetCustomerDeviceSummary 获取客户设备汇总统计
GetCustomerDeviceSummary(ctx context.Context, customerID string) (*models.DeviceSummaryResponse, error)
```
### 4. 处理器扩展(handlers/cu_device_handler.go)
添加新的处理器方法:
```go
func (h *CuDeviceHandler) GetDeviceSummary(c *gin.Context) {
// 从JWT Token中获取用户ID和客户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
// 调用服务层
result, err := h.cuDeviceService.GetDeviceSummary(c.Request.Context(), claims.CustomerID)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: result,
})
}
```
### 5. 路由配置(routes/router.go)
在 cuAuth 路由组中添加:
```go
cuAuth.GET("/devices/summary", cuDeviceHandler.GetDeviceSummary)
```
## 数据库查询逻辑
基于现有 `licenses` 表结构,统计逻辑:
1. **设备总数**:查询 `customer_id = ? AND status = 'active' AND deleted_at IS NULL` 的记录数
2. **在线设备数**:在总数基础上,筛选 `last_heartbeat > (NOW() - INTERVAL heartbeat_timeout SECOND)` 的记录数
3. **离线设备数**:总数减去在线设备数
## 性能优化建议
- 建议对该接口使用缓存,避免频繁的数据库查询
- 可以使用 Redis 缓存,缓存时间建议为 1-5 分钟
- 缓存键设计:`device_summary:{customer_id}`
@@ -0,0 +1,167 @@
# 用户订单汇总接口设计文档
## 需求概述
提供用户订单汇总统计接口,返回当前登录客户的订单总数、待支付订单数、已支付订单数。
## 接口路径
```
GET /api/cu/orders/summary
```
## 认证要求
- 需要 Bearer Token 认证(C端用户JWT)
- 基于当前登录用户的客户ID进行统计
## 请求参数
无查询参数
## 响应数据结构
### 成功响应
```json
{
"code": "000000",
"message": "成功",
"data": {
"total_orders": 100,
"pending_orders": 15,
"paid_orders": 85
}
}
```
### 响应字段说明
| 字段 | 类型 | 必填 | 描述 |
|------|------|------|------|
| total_orders | int64 | 是 | 订单总数 |
| pending_orders | int64 | 是 | 待支付订单数 |
| paid_orders | int64 | 是 | 已支付订单数 |
## 业务规则
- 统计范围:当前登录用户的客户ID下的所有订单(包含已删除订单)
- 订单状态:
- `pending`: 待支付订单
- `paid`: 已支付订单
- 数据一致性:`total_orders = pending_orders + paid_orders`
## 错误响应
| 错误码 | HTTP状态码 | 描述 |
|--------|------------|------|
| 500011 | 401 | 认证令牌无效 |
| 500012 | 401 | 认证令牌已过期 |
| 500010 | 401 | 认证令牌缺失 |
| 900004 | 500 | 服务器内部错误 |
## Swagger 文档
```go
// GetOrderSummary 获取订单汇总统计
// @Summary 获取订单汇总统计
// @Description 获取当前登录用户的订单汇总统计信息(总数、待支付数、已支付数)
// @Tags 客户订单管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.OrderSummaryResponse} "获取成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/orders/summary [get]
```
## 技术实现方案
### 1. 新增响应模型(models/cu_order.go)
```go
// OrderSummaryResponse 订单汇总响应结构
type OrderSummaryResponse struct {
TotalOrders int64 `json:"total_orders"` // 订单总数
PendingOrders int64 `json:"pending_orders"` // 待支付订单数
PaidOrders int64 `json:"paid_orders"` // 已支付订单数
}
```
### 2. 服务层接口扩展(service/interfaces.go)
在 `CuOrderService` 接口中添加:
```go
// 获取订单汇总统计
GetOrderSummary(ctx context.Context, customerID string) (*models.OrderSummaryResponse, error)
```
### 3. Repository层接口扩展(repository/interfaces.go)
在 `CuOrderRepository` 接口中添加:
```go
// GetCustomerOrderSummary 获取客户订单汇总统计
GetCustomerOrderSummary(ctx context.Context, customerID string) (*models.OrderSummaryResponse, error)
```
### 4. 处理器扩展(handlers/cu_order_handler.go)
添加新的处理器方法:
```go
func (h *CuOrderHandler) GetOrderSummary(c *gin.Context) {
// 从JWT Token中获取用户ID和客户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
// 调用服务层
result, err := h.cuOrderService.GetOrderSummary(c.Request.Context(), claims.CustomerID)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: result,
})
}
```
### 5. 路由配置(routes/router.go)
在 cuAuth 路由组中添加:
```go
cuAuth.GET("/orders/summary", cuOrderHandler.GetOrderSummary)
```
## 数据库查询逻辑
基于现有 `cu_orders` 表结构,统计逻辑:
1. **订单总数**:查询 `customer_id = ?` 的记录数(包含所有状态)
2. **待支付订单数**:查询 `customer_id = ? AND status = 'pending'` 的记录数
3. **已支付订单数**:查询 `customer_id = ? AND status = 'paid'` 的记录数
+50 -1
View File
@@ -59,10 +59,12 @@ errors:
"100004": "缺少认证信息"
"100005": "权限不足"
# 客户模块 (20xxxx) - 预留
# 客户模块 (20xxxx)
customer:
"200001": "客户不存在"
"200002": "客户已存在"
"200003": "请求过于频繁,请稍后再试"
"200004": "短信发送失败"
# 授权模块 (30xxxx) - 预留
license:
@@ -99,6 +101,8 @@ errors:
customer:
"200001": "Customer not found"
"200002": "Customer already exists"
"200003": "Request too frequent, please try again later"
"200004": "SMS sending failed"
license:
"300001": "Invalid license key"
@@ -113,6 +117,51 @@ errors:
default_error: "Unknown error"
```
## 核心设计原则
### 1. 错误消息规范
- **错误消息**: 使用业务错误码,通过 `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(), // 本地化消息 + 具体错误信息
Timestamp: getCurrentTimestamp(),
})
// 成功响应
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: result,
})
```
## 核心组件设计
### 1. 多语言管理器
+309
View File
@@ -0,0 +1,309 @@
# 日志系统设计方案
## 设计理念
基于系统设计理念,构建轻量级、高性能、可观测的日志系统:
- **结构化日志**:使用 Logrus 提供结构化日志输出,便于日志分析和监控
- **分层记录**:HTTP 请求日志、业务逻辑日志、错误日志分层记录
- **配置驱动**:通过配置文件控制日志级别和格式
- **性能友好**:日志输出异步化,不影响业务处理性能
- **可观测性**:支持日志聚合和监控集成
## 整体架构设计
### 1. 目录结构
```
backend/
├── configs/ # 配置目录
│ └── config.yaml # 日志配置
├── pkg/logger/ # 日志封装包
│ └── logger.go # 日志管理器和自定义格式器
├── internal/
│ ├── api/
│ │ └── middleware/
│ │ └── logging.go # HTTP请求日志中间件
│ └── service/ # 业务服务层(日志输出点)
└── docs/
└── log_design.md # 日志设计文档
```
### 2. 核心设计原则
- **单一职责**:日志包专门处理日志格式化和输出逻辑
- **配置解耦**:日志配置独立管理,支持运行时调整
- **性能优先**:日志输出异步,不阻塞业务逻辑
- **扩展性**:支持多种输出目标(文件、Elasticsearch等)
## 核心组件设计
### 1. 日志管理器
```go
// pkg/logger/logger.go
type CustomFormatter struct {
*logrus.TextFormatter
}
// Init() 初始化日志系统
// GetLogger() 获取全局日志实例
```
**核心特性:**
- 基于 Logrus 的封装
- 自定义格式器,支持调用者信息显示
- 配置文件驱动的日志级别设置
### 2. HTTP 请求日志中间件
```go
// internal/api/middleware/logging.go
func CustomLoggerMiddleware() gin.HandlerFunc
func LoggingMiddleware(logger *logrus.Logger) gin.HandlerFunc
```
**记录内容:**
- 客户端IP
- 请求方法和路径
- 响应状态码
- 请求延迟时间
- User-Agent信息
### 3. 业务日志输出
```go
// 业务服务中的日志输出示例
s.logger.Infof("[GenerateLicenseFile] 开始生成许可证文件,license_id: %s", id)
s.logger.Errorf("[GenerateLicenseFile] 查询许可证失败,license_id: %s, error: %v", id, err)
```
**日志分类:**
- **Info级别**:正常业务流程记录
- **Warn级别**:异常情况但不影响业务
- **Error级别**:业务错误和系统异常
## 日志格式和输出
### 1. 日志格式
**标准格式:**
```
2024-12-25 14:30:15 INFO API Request status=200 method=GET path=/api/customers ip=127.0.0.1 latency=15.2ms
```
**带调用者信息:**
```
2024-12-25 14:30:15 INFO [GenerateLicenseFile] 开始生成许可证文件,license_id: 12345 ./backend/internal/service/license_service.go:290
```
### 2. 日志级别
| 级别 | 描述 | 使用场景 |
|------|------|----------|
| DEBUG | 调试信息 | 开发环境,详细的执行流程 |
| INFO | 一般信息 | 正常业务流程,重要状态变更 |
| WARN | 警告信息 | 异常情况但不影响业务继续 |
| ERROR | 错误信息 | 业务错误,系统异常 |
### 3. 结构化字段
**HTTP请求日志字段:**
- `status`: HTTP状态码
- `method`: 请求方法
- `path`: 请求路径
- `ip`: 客户端IP
- `latency`: 请求处理时间
- `user_agent`: 用户代理
**业务日志字段:**
- `[ModuleName]`: 模块标识
- 业务相关参数(如 license_id, customer_id 等)
## 配置系统集成
### 1. 日志配置结构
```yaml
# configs/config.yaml
log:
level: "info" # 日志级别: debug/info/warn/error
format: "text" # 日志格式: text/json
```
### 2. 配置加载逻辑
```go
// internal/config/config.go
type LogConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
}
// 默认配置
viper.SetDefault("log.level", "info")
viper.SetDefault("log.format", "json")
```
## 集成实现方案
### 1. 应用启动集成
```go
// cmd/main.go
func main() {
// 初始化日志系统
logger.Init()
log := logger.GetLogger()
log.Info("启动 License Manager 服务器...")
}
```
### 2. Gin 路由集成
```go
// internal/api/routes/router.go
func SetupRouter() *gin.Engine {
router := gin.New()
// 添加日志中间件
router.Use(middleware.CustomLoggerMiddleware())
return router
}
```
### 3. 服务层集成
```go
// internal/service/license_service.go
type LicenseService struct {
logger *logrus.Logger
}
func NewLicenseService(repo repository.LicenseRepository, logger *logrus.Logger) LicenseService {
return LicenseService{
logger: logger,
}
}
```
## 日志输出示例
### 1. HTTP 请求日志
```
2024-12-25 14:30:15 INFO API Request status=200 method=GET path=/api/customers ip=127.0.0.1 latency=15.2ms user_agent=Mozilla/5.0...
```
### 2. 业务操作日志
```
2024-12-25 14:30:16 INFO [GenerateLicenseFile] 开始生成许可证文件,license_id: 12345 ./backend/internal/service/license_service.go:290
2024-12-25 14:30:16 INFO [GenerateLicenseFile] 许可证信息查询成功,license_key: LK-12345, status: active ./backend/internal/service/license_service.go:309
2024-12-25 14:30:16 WARN [GenerateLicenseFile] 许可证已被撤销,license_id: 12345 ./backend/internal/service/license_service.go:313
```
### 3. 错误日志
```
2024-12-25 14:30:17 ERROR [GenerateLicenseFile] 查询许可证失败,license_id: 12345, error: license not found ./backend/internal/service/license_service.go:302
```
## 性能优化策略
### 1. 异步日志输出
- 日志输出默认异步,不阻塞业务逻辑
- 使用缓冲区批量写入,提升I/O性能
### 2. 日志级别控制
- 生产环境使用 INFO 级别及以上
- 开发环境可启用 DEBUG 级别
- 根据需要动态调整日志级别
### 3. 结构化日志优化
- 使用预定义的字段名称
- 避免大对象序列化
- 敏感信息脱敏处理
## 前端日志处理
### 1. 开发环境日志
```typescript
// 前端调试日志
console.log('[Licenses] openChangeValidityDialog called')
console.log('[Licenses] changeDialogVisible ->', val)
```
### 2. 生产环境优化
- 开发环境的 console.log 在生产环境自动移除
- 错误日志通过统一的错误处理中间件记录
- 用户行为日志通过专门的埋点系统处理
## 监控和告警集成
### 1. 日志聚合
- 使用 ELK Stack 或 Loki 进行日志聚合
- 支持日志查询和可视化分析
### 2. 告警规则
- ERROR 级别日志自动告警
- 关键业务异常监控
- 性能指标监控(如请求延迟)
### 3. 审计日志
- 敏感操作记录(如用户登录、授权变更)
- 支持审计追溯和合规要求
## 实施策略
### 第一阶段:基础架构
1. 实现日志管理器核心功能
2. 配置基础日志格式和级别
3. 集成 HTTP 请求日志中间件
4. 在关键业务节点添加日志输出
### 第二阶段:功能完善
1. 优化日志格式和字段
2. 添加业务日志分类
3. 实现日志轮转和归档
4. 集成日志监控系统
### 第三阶段:运维优化
1. 实现分布式日志追踪
2. 添加性能监控指标
3. 建立日志分析和告警机制
4. 完善日志安全和隐私保护
## 配置示例
### 开发环境配置
```yaml
log:
level: "debug" # 开发环境启用debug级别
format: "text" # 文本格式便于阅读
```
### 生产环境配置
```yaml
log:
level: "info" # 生产环境使用info级别
format: "json" # JSON格式便于日志聚合
```
这个日志设计方案遵循系统的设计理念,提供了结构化、可观测、性能优异的日志系统,为系统的监控、调试和运维提供了有力支持。
@@ -0,0 +1,407 @@
# 客户管理结构改造 - 客户用户管理模块
## 需求概述
为支持客户自主注册和使用客户端,需要构建独立的客户用户管理体系。
**核心要求:**
- 只支持手机号注册,通过短信验证码验证
- 用户体系完全独立,与原有系统管理员用户体系分离
- 鉴权和路由模块与原有系统完全分开,实现安全隔离和独立演进
## 原customers表结构
-- license_manager.customers definition
CREATE TABLE `customers` (
`id` varchar(36) NOT NULL DEFAULT (uuid()),
`customer_code` varchar(20) NOT NULL,
`customer_name` varchar(200) NOT NULL,
`customer_type` varchar(20) NOT NULL DEFAULT 'enterprise',
`contact_person` varchar(100) NOT NULL,
`contact_title` varchar(100) DEFAULT NULL,
`email` varchar(255) DEFAULT NULL,
`phone` varchar(20) DEFAULT NULL,
`address` text,
`company_size` varchar(20) DEFAULT NULL,
`customer_level` varchar(20) NOT NULL DEFAULT 'basic',
`status` varchar(20) NOT NULL DEFAULT 'active',
`description` text,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`created_by` varchar(36) NOT NULL,
`updated_by` varchar(36) DEFAULT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_customers_customer_code` (`customer_code`),
KEY `idx_customers_customer_name` (`customer_name`),
KEY `idx_customers_customer_type` (`customer_type`),
KEY `idx_customers_customer_level` (`customer_level`),
KEY `idx_customers_status` (`status`),
KEY `idx_customers_created_at` (`created_at`),
KEY `idx_customers_created_by` (`created_by`),
KEY `idx_customers_deleted_at` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
## 需求背景
当与客户签约后,原授权管理平台的客户是系统管理员通过后管手动添加的。现要支持客户自动注册,给客户专门开发了一个客户端,客户可以自己注册账号并登录使用。
**核心变更:**
- 用户体系完全独立:客户用户与系统管理员用户完全分离
- 注册方式:仅支持手机号注册,通过短信验证码验证
- 安全隔离:鉴权和路由模块与原有系统完全分开,独立演进
## 新增表结构
-- mysql客户用户表(新增)
CREATE TABLE `cu_users` (
`id` VARCHAR(36) NOT NULL DEFAULT (uuid()),
`customer_id` VARCHAR(36) NOT NULL COMMENT '所属客户ID',
`phone` VARCHAR(20) NOT NULL COMMENT '手机号(作为登录账号)',
`phone_country_code` VARCHAR(10) DEFAULT '+86' COMMENT '国家代码',
`password` VARCHAR(255) NULL COMMENT '密码(可选,用于增强安全)',
`salt` VARCHAR(32) NULL COMMENT '密码盐值',
`user_role` VARCHAR(20) NOT NULL DEFAULT 'member' COMMENT '角色: admin-企业管理员, member-普通成员',
`real_name` VARCHAR(100) NULL COMMENT '真实姓名',
`email` VARCHAR(255) NULL COMMENT '邮箱(可选)',
`status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active-正常, disabled-禁用, pending-待激活',
`phone_verified` TINYINT(1) DEFAULT 1 COMMENT '手机是否验证(注册时已验证)',
`email_verified` TINYINT(1) DEFAULT 0 COMMENT '邮箱是否验证',
`last_login_at` DATETIME(3) DEFAULT NULL COMMENT '最后登录时间',
`last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP',
`login_fail_count` INT DEFAULT 0 COMMENT '登录失败次数',
`locked_until` DATETIME(3) DEFAULT NULL COMMENT '锁定截止时间',
`created_at` DATETIME(3) NOT NULL,
`updated_at` DATETIME(3) NOT NULL,
`updated_by` VARCHAR(36) DEFAULT NULL,
`deleted_at` DATETIME(3) DEFAULT NULL,
-- ========== 用户偏好设置 ==========
`avatar_url` VARCHAR(500) DEFAULT NULL COMMENT '头像URL',
`language` VARCHAR(10) DEFAULT 'zh-CN',
`timezone` VARCHAR(50) DEFAULT 'Asia/Shanghai',
`additional_info` JSON DEFAULT NULL COMMENT '附加信息',
`remark` TEXT DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_cu_users_phone` (`phone`, `phone_country_code`),
UNIQUE KEY `idx_cu_users_email` (`email`),
KEY `idx_cu_users_customer_id` (`customer_id`),
KEY `idx_cu_users_status` (`status`),
KEY `idx_cu_users_created_at` (`created_at`),
KEY `idx_cu_users_deleted_at` (`deleted_at`),
CONSTRAINT `fk_cu_users_customer_id` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
## 字段说明
### 核心身份字段
- `customer_id`: 关联客户ID,建立用户与客户的关系
- `phone`: 手机号,作为主要登录账号(手机号+国家代码唯一)
- `phone_country_code`: 国家代码,默认+86
### 安全字段
- `password`: 密码(可选,用于增强安全)
- `salt`: 密码盐值
- `user_role`: 用户角色
- `admin`: 企业管理员(可管理企业成员)
- `member`: 普通成员
### 基础信息字段
- `real_name`: 用户真实姓名
- `email`: 邮箱地址(可选,全局唯一)
### 状态管理字段
- `status`: 用户状态
- `active`: 正常状态
- `disabled`: 禁用状态
- `pending`: 待激活状态
- `phone_verified`: 手机验证状态(注册时已验证)
- `email_verified`: 邮箱验证状态
### 安全监控字段
- `last_login_at`: 最后登录时间
- `last_login_ip`: 最后登录IP
- `login_fail_count`: 登录失败次数
- `locked_until`: 账户锁定截止时间
### 偏好设置字段
- `avatar_url`: 用户头像URL
- `language`: 界面语言,默认zh-CN
- `timezone`: 时区设置,默认Asia/Shanghai
- `additional_info`: 附加信息,JSON格式存储
- `remark`: 备注信息
## 核心功能
### 手机号注册
- 仅支持手机号注册方式
- 通过短信验证码验证手机号真实性
- 手机号+国家代码全局唯一
- 注册成功后自动创建用户记录
### 短信验证码机制
- 发送短信验证码API
- 验证码存储在Redis中,有效期控制(通常5分钟)
- 验证码使用后立即删除,防止重复使用
- 验证码错误次数限制
- 防短信轰炸机制(频率限制)
### 用户登录
- 手机号+密码登录(主要方式)
- 手机号+短信验证码登录(备用方式)
- 登录失败次数限制和账户锁定
- 登录历史记录和IP追踪
### 用户信息管理
- 用户信息查看和编辑
- 邮箱绑定和验证(可选)
- 密码设置和管理
- 用户偏好设置
- 手机号修改(验证新旧手机号)
- 角色权限管理(管理员功能)
### 密码管理
- 忘记密码流程(短信验证码重置)
- 修改密码(验证当前密码)
- 密码强度要求和安全策略
### 安全特性
- 手机号实名验证
- 短信验证码二次验证
- 登录失败自动锁定
- 敏感操作日志记录
- IP访问记录追踪
## 认证和路由隔离设计
**采用共享架构,分组隔离的设计方案**
### 为什么需要隔离?
1. **用户体系不同**:SAAS用户是客户员工,原有用户是系统管理员
2. **安全隔离要求**:防止SAAS用户访问系统管理功能
3. **独立演进**:允许SAAS用户系统独立升级和维护
### 共享架构优势
- 避免代码重复,保持架构一致性
- 通过路由组实现逻辑隔离
- 共享核心组件,降低维护成本
- 符合现有Clean Architecture设计原则
### 认证隔离策略
#### 双认证体系
- **管理员认证**:原有JWT认证体系,针对系统管理员
- **客户认证**:新增的客户用户认证体系,针对客户用户
#### 认证中间件扩展
```go
// internal/api/middleware/auth.go
// AdminAuth 原有管理员认证中间件
func AdminAuth() gin.HandlerFunc {
// 使用管理员JWT密钥验证
// 检查管理员权限
}
// CustomerAuth 新增客户用户认证中间件
func CustomerAuth() gin.HandlerFunc {
// 使用客户JWT密钥验证
// 检查客户用户身份和状态
// 从cu_users表验证用户
}
```
### 路由分组隔离
#### 路由前缀分离
- **管理员API**:`/api/*` - 系统管理功能
- **客户API**:`/api/cu/*` - 客户用户功能
#### 功能权限隔离
- 管理员可以访问所有系统功能
- 客户用户只能访问自己的数据和授权信息
- 企业管理员可以管理本企业的普通成员(不实现)
- 严格的数据隔离和权限控制
### Token机制分离
#### 独立的JWT配置
```yaml
# configs/config.yaml
jwt:
admin:
secret: "admin-jwt-secret-key"
expire: 24h
cu: # 客户用户
secret: "cu-jwt-secret-key"
expire: 24h
```
#### Token格式差异
- 管理员Token:包含用户ID和角色信息
- 客户Token:包含用户ID和客户ID,确保数据隔离
## 数据库迁移
需要创建新的数据库迁移文件:
```sql
-- migrations/007_create_cu_users_table.sql
-- 创建customer_users表及相关索引和外键约束
-- 同时需要考虑短信验证码相关的配置表(如有需要)
-- migrations/008_add_sms_config.sql (可选)
```
## 架构设计方案
基于现有Clean Architecture,采用**共享架构,分组隔离**的设计方案:
### 总体原则
- 保持现有架构不变,避免过度分离
- 通过路由组实现逻辑隔离
- 共享核心组件,减少重复代码
- 认证逻辑独立,Token机制分离
### 目录结构规划
```
backend/
├── internal/
│ ├── api/
│ │ ├── handlers/
│ │ │ ├── auth_handler.go # 保持(管理员登录)
│ │ │ ├── cu_auth_handler.go # 新增(客户用户注册/登录)
│ │ │ ├── cu_profile_handler.go # 新增(客户用户个人信息)
│ │ │ ├── customer_handler.go # 保持
│ │ │ ├── license_handler.go # 保持
│ │ │ └── ...
│ │ ├── middleware/
│ │ │ ├── auth.go # 扩展:添加CustomerAuth函数
│ │ │ ├── cors.go
│ │ │ └── ...
│ │ └── routes/
│ │ └── router.go # 扩展:区分admin和customer路由组
│ ├── service/
│ │ ├── cu_user_service.go # 新增(客户用户业务逻辑)
│ │ └── ...
│ ├── repository/
│ │ ├── cu_user_repository.go # 新增(客户用户数据访问)
│ │ └── ...
│ └── models/
│ ├── cu_user.go # 新增(客户用户数据模型)
│ └── ...
├── pkg/
│ └── sms/ # 新增(短信服务工具包)
└── configs/
└── config.yaml # 扩展:添加客户用户相关配置项
```
### 路由分组设计
```go
// internal/api/routes/router.go
func SetupRouter() *gin.Engine {
router := gin.New()
// 管理员API组 - /api/*
adminGroup := router.Group("/api")
adminGroup.Use(middleware.AdminAuth()) // 原有管理员认证
{
adminGroup.POST("/auth/login", handlers.AdminLogin)
adminGroup.GET("/customers", handlers.GetCustomers)
// ... 其他管理员API
}
// 客户用户API组 - /api/cu/*
customerGroup := router.Group("/api/cu")
customerGroup.Use(middleware.CustomerAuth()) // 新增客户认证
{
customerGroup.POST("/auth/register", handlers.CuUserRegister)
customerGroup.POST("/auth/login", handlers.CuUserLogin)
customerGroup.POST("/auth/forgot-password", handlers.CuUserForgotPassword)
customerGroup.POST("/auth/reset-password", handlers.CuUserResetPassword)
customerGroup.GET("/profile", handlers.GetCuUserProfile)
customerGroup.PUT("/profile", handlers.UpdateCuUserProfile)
customerGroup.PUT("/profile/phone", handlers.UpdateCuUserPhone)
// ... 其他客户用户API
}
return router
}
```
## 实现计划
### 第一阶段:基础设施搭建
1. 创建cu_前缀的文件结构
2. 实现短信验证码服务
3. 创建cu_users数据模型和迁移
4. 配置独立的JWT密钥和Token机制
### 第二阶段:核心认证功能
1. 实现手机号注册API(短信验证码验证)
2. 实现手机号登录API(短信验证码登录)
3. 构建独立的认证中间件
4. 实现客户用户路由隔离机制
### 第三阶段:用户管理功能
1. 实现用户信息管理API
2. 添加用户偏好设置功能
3. 实现密码管理功能(忘记密码、重置密码)
4. 实现手机号修改功能(新旧手机号验证)
5. 完善安全监控机制
6. 集成登录历史和IP追踪
## 技术要点
### 短信服务集成
- 支持主流短信服务商(阿里云、腾讯云等)
- 验证码存储在Redis中,确保高性能和自动过期
- 验证码生成和验证逻辑
- 频率限制和防刷机制
### Redis缓存策略
- 短信验证码:`sms:{type}:{phone}`格式存储,5分钟过期
- 使用后立即删除,防止重复使用
- 支持分布式部署的一致性
### 安全考虑
- 手机号数据加密存储
- 独立的Token密钥管理
- 请求频率限制
- 异常登录检测和告警
- 密码安全策略(强度要求、定期更新)
### 配置管理
- 客户用户模块配置文件
- 环境区分(开发/测试/生产)
- 敏感信息加密存储
### 文件命名规范
- 客户用户相关文件统一使用`cu_`前缀
- 清晰区分客户用户逻辑与管理员逻辑
- 便于代码维护和功能扩展
## 验收标准
### 功能验收
- [ ] 客户自主注册流程完整(短信验证码)
- [ ] 手机号+密码登录功能正常
- [ ] 忘记密码重置流程完整
- [ ] 用户角色权限控制正确(admin/member)
- [ ] 手机号修改流程安全(新旧手机号验证)
- [ ] Redis验证码存储和验证正常
- [ ] 路由隔离正确(/api/ vs /api/cu/)
### 性能验收
- [ ] API响应时间符合要求
- [ ] Redis操作性能良好
- [ ] 并发访问稳定
### 安全验收
- [ ] 密码加密存储安全
- [ ] JWT Token隔离正确
- [ ] 短信验证码防刷机制有效
- [ ] 数据隔离完整
Whitespace-only changes.
@@ -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,其他版等于购买数量)
- [ ] 一个订单对应一个授权码
@@ -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**: 自定义业务参数
@@ -0,0 +1,135 @@
# 注册发送验证码接口
## 概述
为注册流程提供手机号验证功能,用户在注册前需要先获取短信验证码进行手机号验证。
## 接口信息
- **接口路径**: `POST /api/cu/send-register-sms`
- **认证方式**: 无需认证(公开接口)
- **请求格式**: `application/json`
- **响应格式**: `application/json`
## 请求参数
```json
{
"phone": "13800138000", // 必填,手机号
"phone_country_code": "+86" // 可选,国家代码,默认+86
}
```
### 参数说明
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| phone | string | 是 | 手机号,长度7-20位 |
| phone_country_code | string | 否 | 国家代码,默认"+86" |
## 响应格式
### 成功响应 (200)
```json
{
"code": 200,
"message": "验证码发送成功",
"data": null
}
```
### 错误响应
#### 手机号已存在 (200002)
```json
{
"code": "200002",
"message": "客户已存在",
"data": null
}
```
#### 请求过于频繁 (200003)
```json
{
"code": "200003",
"message": "请求过于频繁,请稍后再试",
"data": null
}
```
#### 参数无效 (900001)
```json
{
"code": "900001",
"message": "请求参数无效",
"data": null
}
```
#### 短信发送失败 (200004)
```json
{
"code": "200004",
"message": "短信发送失败",
"data": null
}
```
## 业务逻辑
1. **参数验证**
- 验证手机号格式
- 验证国家代码格式(如提供)
- 参数不符合要求时,返回错误码 `900001`
2. **用户检查**
- 检查手机号是否已被注册
- 如已注册,返回错误码 `200002`
3. **频率限制**
- 限制同一手机号发送频率(建议:1分钟内最多1次,1小时内最多5次)
- 使用Redis缓存记录发送时间和次数
- 超过频率限制时,返回错误码 `200003`
4. **验证码生成**
- 生成6位数字验证码
- 将验证码存储到Redis(设置过期时间,如5分钟)
5. **短信发送**
- 调用短信服务发送验证码
- 发送失败时,返回错误码 `200004`
- 记录发送日志
## 与忘记密码接口对比
| 对比项 | 注册验证码接口 | 忘记密码验证码接口 |
|--------|----------------|-------------------|
| 接口路径 | `/api/cu/send-register-sms` | `/api/cu/forgot-password` |
| 用户存在检查 | 检查用户**不存在** | 检查用户**存在** |
| 验证码用途 | 手机号验证 | 密码重置 |
| 频率限制 | 较宽松 | 较严格 |
| 缓存键前缀 | `register_sms:` | `forgot_sms:` |
## 缓存设计
- **验证码存储**: `register_sms:{phone_country_code}:{phone}` (过期时间: 5分钟)
- **频率限制**: `register_sms_limit:{phone_country_code}:{phone}` (过期时间: 1小时)
## 安全考虑
1. 验证码只能使用一次,验证后立即删除
2. 限制发送频率,防止短信轰炸
3. 记录发送日志,便于审计
4. 支持图形验证码作为二次验证(可选)
## 后续流程
1. 用户收到验证码后,填写注册表单
2. 调用注册接口 `/api/cu/register`,提交手机号、验证码、密码等信息
3. 后端验证验证码正确性,完成注册流程
@@ -0,0 +1,350 @@
# 短信发送模块设计
## 概述
基于阿里云短信服务,实现验证码发送功能,支持注册、登录、密码重置等场景。
## 技术栈
- **短信服务商**: 阿里云短信服务
- **SDK**: github.com/alibabacloud-go/dysmsapi-20170525/v5
- **缓存**: Redis (存储验证码)
- **频率限制**: Redis (限制发送频率)
## 核心功能
1. **验证码发送**: 支持手机号验证
2. **频率控制**: 防止短信轰炸
3. **验证码缓存**: Redis存储和管理
4. **错误处理**: 完善的错误响应
## 架构设计
### 1. 配置结构
```go
// internal/config/config.go
type SMSConfig struct {
Enabled bool `mapstructure:"enabled"` // 是否启用短信服务
AccessKeyID string `mapstructure:"access_key_id"` // 阿里云AccessKey ID
AccessKeySecret string `mapstructure:"access_key_secret"`// 阿里云AccessKey Secret
SignName string `mapstructure:"sign_name"` // 短信签名
RegionID string `mapstructure:"region_id"` // 地域ID,默认cn-hangzhou
Endpoint string `mapstructure:"endpoint"` // 服务端点
}
// 添加到主配置
type Config struct {
// ... 其他配置
SMS SMSConfig `mapstructure:"sms"`
}
```
### 2. 服务接口
```go
// pkg/utils/sms.go
type SMSService interface {
SendVerificationCode(phone, phoneCountryCode, templateCode string) error
VerifyCode(phone, phoneCountryCode, code string) (bool, error)
IsRateLimited(phone, phoneCountryCode string) bool
}
type smsService struct {
client *dysmsapi20170525.Client
redis *redis.Client // 验证码缓存和频率限制
config *SMSConfig
logger *log.Logger
}
```
### 3. 配置与常量设计
#### 为什么不完全使用配置文件?
**不推荐完全配置化的原因:**
1. **模板代码相对稳定**:阿里云模板一旦创建,不会频繁变化
2. **类型安全**:代码常量有编译时检查,避免运行时错误
3. **重构友好**:IDE可以自动重构所有引用
4. **性能优化**:常量在编译时确定,无运行时查找开销
#### 推荐的混合方案
```go
// pkg/utils/sms.go
// 默认短信模板常量(生产环境使用)
const (
DefaultTemplateRegister = "SMS_330275014"
DefaultTemplateResetPwd = "SMS_330275015"
DefaultTemplateLogin = "SMS_330275016"
)
// 缓存键常量
const (
SMSCodeKeyPrefix = "sms:code"
SMSLimitKeyPrefix = "sms:limit"
SMSCodeExpireTime = 5 * time.Minute
SMSLimitTimeWindow = time.Hour
SMSLimitMaxCount = 5
)
// SMS模板配置(支持运行时覆盖)
type SMSTemplates struct {
Register string `mapstructure:"register"`
ResetPwd string `mapstructure:"reset_pwd"`
Login string `mapstructure:"login"`
}
// 使用方法:优先使用配置,否则使用默认常量
func (s *smsService) getTemplateCode(templateType string) string {
var templateCode string
switch templateType {
case "register":
templateCode = s.templates.Register
if templateCode == "" {
templateCode = DefaultTemplateRegister
}
case "reset_pwd":
templateCode = s.templates.ResetPwd
if templateCode == "" {
templateCode = DefaultTemplateResetPwd
}
case "login":
templateCode = s.templates.Login
if templateCode == "" {
templateCode = DefaultTemplateLogin
}
}
return templateCode
}
```
#### 配置文件示例
```yaml
# configs/config.yaml
sms:
enabled: true
access_key_id: "your-access-key-id"
access_key_secret: "your-access-key-secret"
sign_name: "惠州顺视智能科技"
region_id: "cn-hangzhou"
endpoint: "dysmsapi.aliyuncs.com"
# 模板配置(可选,不配置则使用代码中的默认值)
templates:
register: "SMS_330275014" # 注册模板
reset_pwd: "SMS_330275015" # 重置密码模板
login: "SMS_330275016" # 登录模板
current_phone: "SMS_330275017" # 当前手机号验证模板
new_phone: "SMS_330275018" # 新手机号验证模板
注意:所有模板ID都必须在阿里云SMS控制台中存在且审核通过,否则会报错"isv.SMS_TEMPLATE_ILLEGAL"
```
### 4. 缓存设计
#### 验证码存储
- **Key**: `sms:code:{country_code}:{phone}`
- **Value**: 6位数字验证码
- **TTL**: 5分钟
#### 频率限制
- **Key**: `sms:limit:{country_code}:{phone}`
- **Value**: 发送次数计数器
- **TTL**: 1小时
- **限制**: 每小时最多5次
### 5. 阿里云SMS API集成
#### 已实现功能 ✅
- ✅ AccessKey认证方式
- ✅ 短信发送API调用
- ✅ 错误处理和响应解析
- ✅ 缓存集成(验证码存储和频率限制)
- ✅ 多环境配置支持
#### API调用流程
1. **客户端创建**: 使用AccessKeyID和AccessKeySecret创建阿里云SMS客户端
2. **请求构建**: 构建SendSmsRequest,包含签名、模板、手机号和参数
3. **API调用**: 调用阿里云SMS API发送短信
4. **响应处理**: 解析响应,检查发送结果
5. **缓存存储**: 成功发送后将验证码存储到缓存
#### 错误处理
- **认证错误**: InvalidAccessKeyId.NotFound 等
- **参数错误**: 手机号格式、模板不存在等
- **网络错误**: 超时、重试等
- **配额错误**: 发送频率限制等
#### 测试验证
```bash
# 运行SMS服务测试
go test ./pkg/utils/ -v -run TestSMS
# 验证阿里云API调用
# 使用有效AccessKey时会成功发送短信
# 使用无效AccessKey时会返回认证错误
```
## 接口设计
### 发送验证码
```go
// 发送注册验证码
func (s *smsService) SendRegisterCode(phone, phoneCountryCode string) error {
return s.SendVerificationCode(phone, phoneCountryCode, TemplateRegister)
}
// 发送重置密码验证码
func (s *smsService) SendResetPwdCode(phone, phoneCountryCode string) error {
return s.SendVerificationCode(phone, phoneCountryCode, TemplateResetPwd)
}
```
### 验证码验证
```go
// 验证验证码
func (s *smsService) VerifyCode(phone, phoneCountryCode, code string) (bool, error) {
key := fmt.Sprintf("sms:code:%s:%s", phoneCountryCode, phone)
storedCode, err := s.redis.Get(key).Result()
if err == redis.Nil {
return false, errors.New("验证码不存在或已过期")
}
if err != nil {
return false, err
}
// 验证成功后删除验证码(一次性使用)
if storedCode == code {
s.redis.Del(key)
return true, nil
}
return false, nil
}
```
### 频率限制
```go
// 检查频率限制
func (s *smsService) IsRateLimited(phone, phoneCountryCode string) bool {
key := fmt.Sprintf("sms:limit:%s:%s", phoneCountryCode, phone)
count, err := s.redis.Incr(key).Result()
if err != nil {
return true // Redis错误时限制发送
}
// 首次发送,设置过期时间
if count == 1 {
s.redis.Expire(key, time.Hour)
}
// 每小时最多5次
return count > 5
}
```
## 集成方式
### 1. Service层集成
```go
// internal/service/cu_user_service.go
func (s *cuUserService) SendRegisterSms(ctx context.Context, req *models.CuUserSendRegisterSmsRequest) error {
// 1. 参数验证
if req == nil {
return i18n.NewI18nError("900001", lang)
}
// 2. 检查手机号是否已注册
exists, err := s.repo.CheckPhoneExists(req.Phone, req.PhoneCountryCode, "")
if err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
if exists {
return i18n.NewI18nError("200002", lang)
}
// 3. 调用短信服务
err = s.smsService.SendRegisterCode(req.Phone, req.PhoneCountryCode)
if err != nil {
return i18n.NewI18nError("200004", lang) // 短信发送失败
}
return nil
}
```
### 2. 依赖注入
```go
// internal/api/routes/router.go
func SetupRouter() *gin.Engine {
// ... 其他代码
// 初始化短信服务
smsService := utils.NewSMSService(&cfg.SMS, redisClient, logger)
// 初始化用户服务时注入短信服务
cuUserService := service.NewCuUserService(cuUserRepo, customerRepo, db, smsService)
// ... 其他代码
}
```
## 错误码扩展
在语言包中添加短信相关错误码:
```yaml
# customer模块扩展
customer:
"200003": "请求过于频繁,请稍后再试"
"200004": "短信发送失败"
```
## 安全考虑
1. **验证码保护**
- 验证码一次性使用
- 过期时间控制(5分钟)
- Redis存储,防止内存泄露
2. **频率控制**
- 基于手机号的发送频率限制
- 防止短信轰炸攻击
- 支持国际号码
3. **日志记录**
- 发送成功/失败日志
- 敏感信息脱敏
- 便于问题排查
## 实施计划
### 第一阶段:基础功能
1. 实现SMS服务基础类
2. 集成阿里云短信SDK
3. 实现验证码发送和验证
4. 添加频率限制
### 第二阶段:完善功能
1. 添加多种验证码模板
2. 完善错误处理和日志
3. 添加监控指标
4. 性能优化
### 第三阶段:高级功能
1. 支持国际短信
2. 模板动态配置
3. 批量发送功能
4. 失败重试机制
@@ -0,0 +1,470 @@
# 缓存模块设计
基于Clean Architecture设计,支持内存缓存和Redis缓存两种实现方式,提供统一的缓存接口,便于在不同环境下切换。
## 设计理念
- **抽象接口**:通过接口抽象不同缓存实现
- **配置驱动**:支持运行时配置缓存类型和参数
- **优雅降级**:缓存异常时不影响业务逻辑
- **性能监控**:提供缓存命中率等指标
- **序列化支持**:自动处理常见数据类型的序列化
## 核心架构
### 1. 缓存接口设计
```go
// Cache 缓存接口
type Cache interface {
// 基础操作
Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key string, value string, ttl time.Duration) error
Del(ctx context.Context, keys ...string) error
Exists(ctx context.Context, key string) (bool, error)
// TTL操作
TTL(ctx context.Context, key string) (time.Duration, error)
Expire(ctx context.Context, key string, ttl time.Duration) error
// 数值操作
Incr(ctx context.Context, key string) (int64, error)
Decr(ctx context.Context, key string) (int64, error)
// 批量操作
MGet(ctx context.Context, keys ...string) ([]string, error)
MSet(ctx context.Context, pairs map[string]string, ttl time.Duration) error
// 工具方法
Ping(ctx context.Context) error
Close() error
}
// CacheWithTTL 带TTL的缓存接口
type CacheWithTTL interface {
Cache
GetWithTTL(ctx context.Context, key string) (string, time.Duration, error)
}
// Serializer 序列化接口
type Serializer interface {
Marshal(v interface{}) ([]byte, error)
Unmarshal(data []byte, v interface{}) error
}
```
### 2. 缓存配置
```go
// CacheConfig 缓存配置
type CacheConfig struct {
Type string `mapstructure:"type"` // 缓存类型: memory, redis
TTL time.Duration `mapstructure:"ttl"` // 默认TTL
Enabled bool `mapstructure:"enabled"` // 是否启用缓存
Redis RedisConfig `mapstructure:"redis"` // Redis配置
Memory MemoryConfig `mapstructure:"memory"` // 内存配置
}
// RedisConfig Redis配置
type RedisConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
PoolSize int `mapstructure:"pool_size"`
MinIdleConns int `mapstructure:"min_idle_conns"`
ConnMaxIdleTime time.Duration `mapstructure:"conn_max_idle_time"`
DialTimeout time.Duration `mapstructure:"dial_timeout"`
ReadTimeout time.Duration `mapstructure:"read_timeout"`
WriteTimeout time.Duration `mapstructure:"write_timeout"`
}
// MemoryConfig 内存配置
type MemoryConfig struct {
MaxSize int `mapstructure:"max_size"` // 最大缓存条目数
CleanupInterval time.Duration `mapstructure:"cleanup_interval"` // 清理间隔
}
```
## 实现方案
### 1. 内存缓存实现
```go
// memoryCache 内存缓存实现
type memoryCache struct {
data sync.Map
ttl sync.Map
maxSize int
mu sync.RWMutex
}
// NewMemoryCache 创建内存缓存
func NewMemoryCache(config MemoryConfig) Cache {
cache := &memoryCache{
maxSize: config.MaxSize,
data: sync.Map{},
ttl: sync.Map{},
}
// 启动清理协程
if config.CleanupInterval > 0 {
go cache.cleanup(config.CleanupInterval)
}
return cache
}
func (m *memoryCache) Get(ctx context.Context, key string) (string, error) {
value, ok := m.data.Load(key)
if !ok {
return "", ErrCacheMiss
}
// 检查TTL
if ttl, exists := m.ttl.Load(key); exists {
if time.Now().After(ttl.(time.Time)) {
m.data.Delete(key)
m.ttl.Delete(key)
return "", ErrCacheMiss
}
}
return value.(string), nil
}
func (m *memoryCache) Set(ctx context.Context, key string, value string, ttl time.Duration) error {
// 简单的容量检查(生产环境建议使用更完善的LRU缓存库)
if m.maxSize > 0 {
// 这里可以实现简单的容量控制逻辑
// 生产环境建议使用 github.com/hashicorp/golang-lru 等专业库
}
m.data.Store(key, value)
if ttl > 0 {
m.ttl.Store(key, time.Now().Add(ttl))
}
return nil
}
```
### 2. Redis缓存实现
```go
// redisCache Redis缓存实现
type redisCache struct {
client *redis.Client
ttl time.Duration
}
// NewRedisCache 创建Redis缓存
func NewRedisCache(config RedisConfig, defaultTTL time.Duration) (Cache, error) {
client := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", config.Host, config.Port),
Password: config.Password,
DB: config.DB,
PoolSize: config.PoolSize,
})
// 测试连接
if err := client.Ping(context.Background()).Err(); err != nil {
return nil, fmt.Errorf("failed to connect to Redis: %w", err)
}
return &redisCache{
client: client,
ttl: defaultTTL,
}, nil
}
func (r *redisCache) Get(ctx context.Context, key string) (string, error) {
value, err := r.client.Get(ctx, key).Result()
if err == redis.Nil {
return "", ErrCacheMiss
}
if err != nil {
return "", fmt.Errorf("cache get error: %w", err)
}
return value, nil
}
func (r *redisCache) Set(ctx context.Context, key string, value string, ttl time.Duration) error {
if ttl == 0 {
ttl = r.ttl
}
return r.client.Set(ctx, key, value, ttl).Err()
}
```
### 3. 缓存工厂
```go
// NewCache 创建缓存实例
func NewCache(config CacheConfig) (Cache, error) {
if !config.Enabled {
return &noOpCache{}, nil
}
switch config.Type {
case "memory":
return NewMemoryCache(config.Memory)
case "redis":
return NewRedisCache(config.Redis, config.TTL)
default:
return nil, fmt.Errorf("unsupported cache type: %s", config.Type)
}
}
// noOpCache 空操作缓存(当缓存被禁用时使用)
type noOpCache struct{}
func (n *noOpCache) Get(ctx context.Context, key string) (string, error) {
return "", ErrCacheMiss
}
func (n *noOpCache) Set(ctx context.Context, key string, value string, ttl time.Duration) error {
return nil
}
// ... 其他方法都返回nil或默认值
```
## 高级功能
### 1. 序列化支持
```go
// JSONSerializer JSON序列化器
type JSONSerializer struct{}
func (j *JSONSerializer) Marshal(v interface{}) ([]byte, error) {
return json.Marshal(v)
}
func (j *JSONSerializer) Unmarshal(data []byte, v interface{}) error {
return json.Unmarshal(data, v)
}
// Cached 带序列化的缓存包装器
type Cached struct {
Cache
Serializer Serializer
}
func (c *Cached) SetObject(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
data, err := c.Serializer.Marshal(value)
if err != nil {
return err
}
return c.Cache.Set(ctx, key, string(data), ttl)
}
func (c *Cached) GetObject(ctx context.Context, key string, dest interface{}) error {
value, err := c.Cache.Get(ctx, key)
if err != nil {
return err
}
return c.Serializer.Unmarshal([]byte(value), dest)
}
```
### 2. 缓存装饰器
```go
// CacheDecorator 缓存装饰器
type CacheDecorator struct {
Cache
prefix string // 键前缀
}
func (c *CacheDecorator) buildKey(key string) string {
if c.prefix != "" {
return c.prefix + ":" + key
}
return key
}
func (c *CacheDecorator) Get(ctx context.Context, key string) (string, error) {
return c.Cache.Get(ctx, c.buildKey(key))
}
func (c *CacheDecorator) Set(ctx context.Context, key string, value string, ttl time.Duration) error {
return c.Cache.Set(ctx, c.buildKey(key), value, ttl)
}
```
### 3. 缓存键管理
```go
// KeyBuilder 缓存键构建器
type KeyBuilder struct {
prefix string
separator string
}
func NewKeyBuilder(prefix string) *KeyBuilder {
return &KeyBuilder{
prefix: prefix,
separator: ":",
}
}
func (k *KeyBuilder) Build(parts ...string) string {
if k.prefix != "" {
parts = append([]string{k.prefix}, parts...)
}
return strings.Join(parts, k.separator)
}
// 预定义键模式
const (
UserKeyPrefix = "user"
SessionKeyPrefix = "session"
SMSCodePrefix = "sms:code"
SMSLimitPrefix = "sms:limit"
)
// 使用示例
keyBuilder := NewKeyBuilder("app")
userKey := keyBuilder.Build(UserKeyPrefix, "123") // "app:user:123"
```
## 配置示例
### 内存缓存配置
```yaml
cache:
enabled: true
type: memory
ttl: 30m
memory:
max_size: 10000
cleanup_interval: 10m
```
### Redis缓存配置
```yaml
cache:
enabled: true
type: redis
ttl: 30m
redis:
host: localhost
port: 6379
password: ""
db: 0
pool_size: 10
min_idle_conns: 5
conn_max_idle_time: 30m
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
```
## 错误处理
```go
var (
ErrCacheMiss = errors.New("cache miss")
ErrCacheClosed = errors.New("cache closed")
)
// 优雅降级:缓存错误不影响业务
func safeCacheOperation(cache Cache, operation func() error) error {
defer func() {
if r := recover(); r != nil {
// 记录错误日志,但不影响业务
}
}()
if cache == nil {
return nil // 缓存未启用,直接跳过
}
return operation()
}
```
## 监控指标
```go
// CacheStats 缓存统计信息
type CacheStats struct {
Hits int64 // 命中次数
Misses int64 // 未命中次数
Errors int64 // 错误次数
HitRate float64 // 命中率
TotalOps int64 // 总操作数
AvgRespTime int64 // 平均响应时间(纳秒)
}
func (s *CacheStats) RecordHit() {
s.Hits++
s.updateHitRate()
}
func (s *CacheStats) RecordMiss() {
s.Misses++
s.updateHitRate()
}
func (s *CacheStats) RecordError() {
s.Errors++
}
func (s *CacheStats) updateHitRate() {
total := s.Hits + s.Misses
if total > 0 {
s.HitRate = float64(s.Hits) / float64(total)
}
}
```
## 使用示例
```go
// 初始化缓存
cache, err := NewCache(config.Cache)
if err != nil {
log.Fatal(err)
}
// 基础使用
cache.Set(ctx, "user:123", "user_data", time.Hour)
data, err := cache.Get(ctx, "user:123")
// 带序列化的使用
cached := &Cached{
Cache: cache,
Serializer: &JSONSerializer{},
}
user := User{ID: 123, Name: "John"}
cached.SetObject(ctx, "user:123", user, time.Hour)
var userData User
cached.GetObject(ctx, "user:123", &userData)
```
## 实施计划
### 第一阶段:核心功能 (P0)
1. 实现缓存接口和基础实现类
2. 支持内存缓存 (sync.Map)
3. 支持Redis缓存 (go-redis/v9)
4. 实现缓存工厂和配置加载
5. 集成到现有项目中替换现有缓存逻辑
### 第二阶段:增强功能 (P1)
1. 添加序列化支持 (JSONSerializer)
2. 实现缓存键构建器 (KeyBuilder)
3. 添加TTL查询和过期时间管理
4. 完善批量操作支持
### 第三阶段:监控运维 (P2)
1. 实现缓存统计和监控指标
2. 添加性能监控和告警
3. 优化连接池和错误重试
4. 支持缓存预热和配置热更新
@@ -0,0 +1,191 @@
# 授权码生成规范再变更(2026-01-16)
## 1. 背景与目标
授权码(Authorization Code)当前用于“在线激活”:客户端携带授权码请求服务端,服务端签发许可证文件。
当客户环境无网络时,在线激活不可用,因此需要一段可由客户端内置 RSA 公钥解析/验签的字符串,携带授权的配置(到期时间、功能配置、使用限制等),客户端在离线场景可据此完成自校验与功能开关。
本次“再变更”的核心原则:
- 不改动现有授权码本体格式与核心激活流程
- 仅在“复制/导出”时提供“产品激活码(Product Activation Code)”扩展串
## 2. 当前代码实现(现状梳理)
### 2.1 授权码基础格式(旧规则,核心流程依赖)
当前核心流程依赖的授权码格式为 4 段:
```
LIC-{客户ID前4位}-{12位随机}-{4位校验码}
```
示例:`LIC-F44D-GvBzMfEGbxMP-LVAA`
对应实现:
- 生成:`backend/pkg/utils/authorization_code.go` 的 `GenerateLegacyAuthorizationCode()`(由 `backend/internal/service/authorization_code_service.go` 的 `generateAuthorizationCode()` 调用)
- 使用:核心流程以数据库匹配为准(`licenseRepo.GetAuthorizationCodeByCode`),不做额外格式校验
### 2.2 历史“自包含授权码”方案(已移除)
历史上曾存在一套“自包含授权码”的生成/解析方案(已删除,不再使用):
- 曾实现:`backend/pkg/utils/license_codec.go`(已删除,原 `EncodeLicenseData()/DecodeLicenseData()`)
- 格式:`LIC-{2位随机}-{Base64数据}`(3 段示例:`LIC-B5-xxxx...`)
- 特征:将到期时间/配置等编码进 `{Base64数据}`,并做签名校验
该格式与 2.1 的旧规则(4 段)以及现有激活流程不一致,已按本次“再变更”回退/统一到 2.1。
受影响的核心流程(已改回旧规则生成):
- `POST /api/v1/authorization-codes`(管理端创建授权码)
- `POST /api/cu/orders`(C 端下单创建授权码)
### 2.3 授权码下载包(保持不变)
现有下载接口会导出一个 zip,包含:
- `authorization_code.txt`:仅包含授权码字符串(`authCode.Code`)
- `rsa_public_key.pem`:RSA 公钥(从配置 `cfg.License.RSA.PublicKeyPath` 读取)
对应实现:
- 路由:`GET /api/v1/authorization-codes/:id/download`
- Service:`backend/internal/service/authorization_code_service.go` 的 `GenerateAuthorizationFile()`
- Handler:`backend/internal/api/handlers/authorization_code_handler.go` 的 `DownloadAuthorizationFile()`
### 2.4 “公钥可解析”的签名封装(可复用)
代码中已存在一套「RSA 私钥签名 + Base64 封装」实现,客户端可用公钥验签后得到原始 JSON:
- 服务端签名封装:`backend/internal/service/license_service.go` 的 `signLicenseFile()`
- 签名算法:`RSA-PSS-SHA256`(`backend/pkg/utils/crypto.go` 的 `RSAPrivateKey.SignData()` / `RSAPublicKey.VerifySignature()`)
- 客户端验签示例:`backend/cmd/client-demo/main.go` 的 `decryptLicenseFile()`
说明:这里的“加密字符串”在现有体系中更贴近“签名封装字符串”(提供防篡改与来源可信验证,不提供保密性)。
## 3. 本次再变更:产品激活码(Product Activation Code)
### 3.1 输出格式
在需要离线激活/离线解析配置的场景,对外展示/复制的字符串为“产品激活码”:
```
{授权码}&{配置签名封装串}
```
示例:`LIC-F44D-GvBzMfEGbxMP-LVAA&{payload}`
其中:
- `{授权码}`:保持现有 4 段格式不变(用于在线激活、数据库查找、订单绑定等核心流程)
- `{payload}`:可由客户端内置 RSA 公钥验签并解析出 JSON 的字符串
### 3.2 payload(配置签名封装串)业务 JSON
payload 解析后的业务 JSON(即“配置 JSON”)建议至少包含:
```json
{
"authorization_code": "LIC-F44D-GvBzMfEGbxMP-LVAA",
"start_date": "2026-01-16T00:00:00+08:00",
"end_date": "2026-12-31T23:59:59+08:00",
"deployment_type": "standalone",
"max_activations": 100,
"feature_config": {},
"usage_limits": {},
"custom_parameters": {},
"generated_at": "2026-01-16T12:00:00+08:00",
"ver": 1
}
```
字段来源:
- `authorization_code/start_date/end_date/deployment_type/max_activations/feature_config/usage_limits/custom_parameters`:来自 `authorization_codes` 表记录
- `generated_at`:服务端生成时间
### 3.3 payload 生成规则(服务端)
复用许可证文件的签名封装方式(`license_service.signLicenseFile()` 的数据结构与算法):
1. 构造“配置 JSON”(见 3.2),序列化为 `data`(JSON 字符串)
2. 使用 RSA 私钥对 `data` 进行 PSS+SHA256 签名得到 `signature`(Base64)
3. 组装签名封装对象:
```json
{
"data": "{...配置JSON字符串...}",
"signature": "{base64_signature}",
"algorithm": "RSA-PSS-SHA256"
}
```
4. 将封装对象再序列化为 JSON,并 Base64 编码得到 `{payload}`
说明:
- 私钥路径来自 `cfg.License.RSA.PrivateKeyPath`
- 公钥路径来自 `cfg.License.RSA.PublicKeyPath`(客户端内置同一对公钥)
- Base64 编码沿用 `base64.StdEncoding`(与现有 license file 一致)
### 3.4 客户端解析规则(产品端)
1. 读取输入字符串,按 `&` 分割:
- 只有 1 段:视为仅“在线激活授权码”
- 至少 2 段:`base_code` 与 `payload`(取第 1 段为授权码本体)
2. 对 `payload` Base64 解码得到封装 JSON
3. 解析封装 JSON,校验 `algorithm == "RSA-PSS-SHA256"`
4. 用内置 RSA 公钥对 `data` 与 `signature` 验签
5. 验签通过后解析 `data` 得到配置 JSON,并自行校验:
- 到期时间、功能配置、使用限制等
补充(在线激活):
- `/api/v1/activate` 的 `authorization_code` 参数只使用 `{授权码}` 本体,不依赖 `{payload}`
- 客户端若传入完整 `product_activation_code`(`{授权码}&{payload}`),服务端会自动截取 `&` 前的 `{授权码}` 完成激活
## 4. 接口变更清单(需求说明)
### 4.1 现有接口需要调整的内容
#### 4.1.1 管理端:创建授权码
- 接口:`POST /api/v1/authorization-codes`
- 变更点:
- `code` 生成规则统一为 2.1 的 4 段旧规则:`LIC-{客户ID前4位}-{12位随机}-{4位校验码}`
- `feature_config/usage_limits/custom_parameters/start_date/end_date/...` 等仍按现有结构字段写入数据库(供 license file 与 payload 复用)
- 返回:响应中的 `code` 仅返回“旧规则授权码本体”(不包含 `&payload`)
#### 4.1.2 C 端:下单创建授权码
- 接口:`POST /api/cu/orders`
- 变更点:
- 订单关联的授权码字符串(`order.authorization_code`)生成规则统一为 2.1 的旧规则
- 同步把订单对应配置写入 `authorization_codes` 表的结构化字段(不再依赖“授权码本体自包含”)
- 返回:仍返回 `authorization_code`(旧规则授权码本体,不包含 `&payload`)
- 备注:`POST /api/cu/authorization-codes/:codeId/share` 仍可沿用旧规则生成(如后续需要统一,可复用同一生成器)
#### 4.1.3 客户端:激活
- 接口:`POST /api/v1/activate`
- 变更点:
- 激活不需要配置 payload;仅以 `{授权码}` 做数据库匹配,不做额外格式校验
- 兼容传入完整 `product_activation_code`:服务端自动截取 `&` 前的 `{授权码}`
### 4.2 新增接口(仅客户端使用):获取产品激活码
为客户端提供一个“获取产品激活码”的接口,返回 `{授权码}&{payload}`。管理端不提供该接口(当前仅 C 端“复制/导出”场景需要)。
#### 4.2.1 建议接口形态(C 端)
```
POST /api/cu/authorization-codes/product-activation-code
```
请求:
```json
{
"authorization_code": "LIC-F44D-GvBzMfEGbxMP-LVAA"
}
```
返回:
```json
{
"code": "000000",
"message": "success",
"data": {
"product_activation_code": "LIC-F44D-GvBzMfEGbxMP-LVAA&{payload}"
}
}
```
约束与校验(建议):
- 授权码存在,且归属当前登录客户(CustomerAuth)
- 授权码未锁定,且在有效期内
## 5. 兼容性与验收标准
### 5.1 兼容性
- 原授权码本体格式不变,在线激活核心流程不受影响
- 旧授权码仍可用;仅新增“产品激活码”扩展串用于离线解析
### 5.2 验收标准
- `product_activation_code` 中的 `{payload}` 能被客户端仅凭公钥验签并解析出配置 JSON
- 客户端可在离线场景下依据配置 JSON 完成自校验与功能开关
- 不影响 `/api/v1/activate`、授权码管理、订单等核心流程
@@ -0,0 +1,165 @@
# 设备管理
## 概述
设备管理是为客户用户提供的功能,用户可以在客户端查看和管理自己激活授权码的设备信息。
**核心业务逻辑:**
- 用户激活授权码后,设备会在平台生成许可证记录
- 一个客户可以有多个设备(受每个授权码的`max_activations`限制)
- 设备支持解绑操作,解绑后可以用授权码在其他设备重新激活
## 相关表结构
### licenses 表(许可证表)
```sql
CREATE TABLE `licenses` (
`id` varchar(36) NOT NULL,
`license_key` varchar(200) NOT NULL,
`authorization_code_id` varchar(36) NOT NULL,
`customer_id` varchar(36) NOT NULL,
`hardware_fingerprint` varchar(200) NOT NULL,
`device_info` json DEFAULT NULL,
`activation_ip` varchar(45) DEFAULT NULL,
`status` varchar(20) NOT NULL DEFAULT 'inactive',
`activated_at` datetime(3) DEFAULT NULL,
`last_heartbeat` datetime(3) DEFAULT NULL,
`last_online_ip` varchar(45) DEFAULT NULL,
`config_updated_at` datetime(3) DEFAULT NULL,
`usage_data` json DEFAULT NULL,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_licenses_license_key` (`license_key`),
KEY `idx_licenses_authorization_code_id` (`authorization_code_id`),
KEY `idx_licenses_customer_id` (`customer_id`),
KEY `idx_licenses_hardware_fingerprint` (`hardware_fingerprint`),
KEY `idx_licenses_status` (`status`),
KEY `idx_licenses_activated_at` (`activated_at`),
KEY `idx_licenses_last_heartbeat` (`last_heartbeat`),
KEY `idx_licenses_config_updated_at` (`config_updated_at`),
KEY `idx_licenses_created_at` (`created_at`),
KEY `idx_licenses_deleted_at` (`deleted_at`),
CONSTRAINT `fk_licenses_authorization_code` FOREIGN KEY (`authorization_code_id`) REFERENCES `authorization_codes` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT,
CONSTRAINT `fk_licenses_customer` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
### authorization_codes 表(授权码表)
```sql
CREATE TABLE `authorization_codes` (
`id` varchar(36) NOT NULL,
`code` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`customer_id` varchar(36) NOT NULL,
`created_by` varchar(36) NOT NULL,
`software_id` varchar(50) DEFAULT NULL,
`description` text,
`start_date` datetime(3) NOT NULL,
`end_date` datetime(3) NOT NULL,
`deployment_type` varchar(20) NOT NULL DEFAULT 'standalone',
`encryption_type` varchar(20) DEFAULT 'standard',
`software_version` varchar(50) DEFAULT NULL,
`max_activations` bigint NOT NULL DEFAULT '1',
`is_locked` tinyint(1) NOT NULL DEFAULT '0',
`lock_reason` text,
`locked_at` datetime(3) DEFAULT NULL,
`locked_by` varchar(36) DEFAULT NULL,
`feature_config` json DEFAULT NULL,
`usage_limits` json DEFAULT NULL,
`custom_parameters` json DEFAULT NULL,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_authorization_codes_customer_id` (`customer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
## 配置要求
使用现有的心跳超时配置来判断设备在线状态:
```yaml
license:
heartbeat_timeout: 300 # 心跳超时时间(秒),超过此时间未上报心跳视为离线
```
## API 接口
### 1. 获取设备列表
**接口路径:** `GET /api/cu/devices`
**功能:** 根据用户token获取该客户的所有设备列表
**查询参数:**
- `page` (integer, optional): 页码,默认1
- `page_size` (integer, optional): 每页数量,默认20,最大100
- `device_name` (string, optional): 设备名称模糊搜索(匹配device_info中的name字段)
- `authorization_code_id` (string, optional): 按授权码ID筛选设备
**返回数据:**
```json
{
"code": "200",
"message": "success",
"data": {
"total": 25,
"page": 1,
"page_size": 20,
"list": [
{
"id": "license-uuid",
"device_info": {
"name": "我的电脑",
"os": "Windows 11",
"cpu": "Intel i7",
"memory": "16GB"
},
"is_online": true,
"last_online_ip": "192.168.1.100",
"last_heartbeat": "2026-01-03T10:30:00Z",
"activated_at": "2026-01-01T09:00:00Z",
"authorization_info": {
"authorization_code": "LIC-ABC123-XXXX...",
"authorization_code_id": "auth-code-uuid",
"end_date": "2026-12-31T23:59:59Z",
"description": "基础版许可"
}
}
]
}
}
```
**业务逻辑:**
1. 从用户token解析得到`customer_id`
2. 查询该客户的所有有效许可证(status='active'且未删除)
3. 支持按授权码ID筛选(可选)
4. 支持分页和设备名称模糊搜索
5. 在线状态计算:当前时间 - last_heartbeat < heartbeat_timeout(配置为300秒)
6. 授权信息通过关联`authorization_codes`表获取
### 2. 解绑设备
**接口路径:** `DELETE /api/cu/devices/{id}`
**功能:** 解绑指定设备,物理删除许可证记录
**路径参数:**
- `id` (string, required): 许可证ID
**返回数据:**
```json
{
"code": "200",
"message": "设备解绑成功"
}
```
**业务逻辑:**
1. 验证许可证ID存在且属于当前客户
2. 物理删除许可证记录(DELETE操作)
3. 删除后用户可以用该授权码在其他设备重新激活
4. `max_activations`限制检查在激活时处理,不在此接口处理
@@ -0,0 +1,229 @@
# 用户端授权码分享
## 📋 功能概述
用户可以将自己的授权码分享给其他用户,受赠用户可以激活使用该授权码。
## 🎯 核心需求
- **分享操作**:A用户可以将其授权码的部分激活次数分享给B用户
- **新码生成**:系统为B用户生成新的授权码,时间范围从分享时刻到原授权码结束时间
- **事务保证**:整个分享过程必须支持数据库事务,确保数据一致性
## 🔄 业务流程
### 分享操作流程:
1. A用户在后台选择要分享的授权码
2. 输入B用户的用户ID
3. 选择分享数量(最大分享数量 = 授权码最大激活数 - 已激活数)
4. 确认分享操作
### 系统处理逻辑:
1. **参数校验**:验证分享数量不超过可用激活数,验证目标用户存在
2. **扣减操作**:A用户的授权码 `max_activations` 减少相应数量
3. **生成新码**:为B用户重新生成授权码
- 时间范围:从当前时间到原授权码结束时间
- 激活数量:分享的数量
- 其他参数:与原授权码完全相同
4. **事务保证**:整个过程在数据库事务中执行,失败时完全回滚
## 🛠️ API接口设计
### 分享授权码
**接口**: `POST /api/v1/user/authorization-codes/{codeId}/share`
**请求参数**:
```json
{
"target_user_id": "string", // 受赠用户ID
"share_count": 5 // 分享激活次数
}
```
**响应格式**:
```json
{
"code": "000000",
"message": "分享成功",
"data": {
"new_authorization_code": {
"id": "string",
"code": "string",
"start_date": "2024-01-01T00:00:00Z",
"end_date": "2024-12-31T23:59:59Z",
"max_activations": 5
}
}
}
```
## ⚠️ 错误处理
| 错误码 | 错误场景 | HTTP状态码 |
|--------|----------|------------|
| 300101 | 授权码不存在 | 404 |
| 300102 | 授权码已被锁定 | 403 |
| 300103 | 分享数量超过可用激活数 | 400 |
| 300104 | 目标用户不存在 | 404 |
| 300105 | 不能分享给自己 | 400 |
| 300106 | 数据库事务失败 | 500 |
## 🔒 安全考虑
- **权限验证**:用户只能分享自己拥有的授权码
- **参数校验**:分享数量必须为正整数,不能超过可用激活数
- **用户验证**:目标用户必须存在且状态正常
- **防止自赠**:不能分享给自己
## 🏗️ 实现要点
### 技术架构要求:
- **分层架构**:Handler → Service → Repository
- **事务支持**:必须使用数据库事务保证数据一致性
- **多语言支持**:错误信息支持国际化
- **日志记录**:记录所有分享操作的详细日志
### 数据库事务:
```sql
BEGIN TRANSACTION;
-- 1. 减少原授权码的max_activations
UPDATE authorization_codes SET max_activations = max_activations - ? WHERE id = ?;
-- 2. 为目标用户创建新的授权码
INSERT INTO authorization_codes (...) VALUES (...);
COMMIT;
```
## 📊 数据表结构
### authorization_codes - 授权码表
```sql
CREATE TABLE `authorization_codes` (
`id` varchar(36) NOT NULL,
`code` varchar(1000) NOT NULL, -- 加密后的授权码
`customer_id` varchar(36) NOT NULL, -- 客户ID
`created_by` varchar(36) NOT NULL, -- 创建者用户ID
`software_id` varchar(50) DEFAULT NULL, -- 软件ID
`description` text, -- 描述
`start_date` datetime(3) NOT NULL, -- 开始时间
`end_date` datetime(3) NOT NULL, -- 结束时间
`deployment_type` varchar(20) NOT NULL DEFAULT 'standalone', -- 部署类型
`encryption_type` varchar(20) DEFAULT 'standard', -- 加密类型
`software_version` varchar(50) DEFAULT NULL, -- 软件版本
`max_activations` bigint NOT NULL DEFAULT '1', -- 最大激活次数
`is_locked` tinyint(1) NOT NULL DEFAULT '0', -- 是否锁定
`lock_reason` text, -- 锁定原因
`locked_at` datetime(3) DEFAULT NULL, -- 锁定时间
`locked_by` varchar(36) DEFAULT NULL, -- 锁定者
`feature_config` json DEFAULT NULL, -- 功能配置
`usage_limits` json DEFAULT NULL, -- 使用限制
`custom_parameters` json DEFAULT NULL, -- 自定义参数
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_authorization_codes_customer_id` (`customer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
### licenses - 许可证表
```sql
CREATE TABLE `licenses` (
`id` varchar(36) NOT NULL,
`license_key` varchar(200) NOT NULL, -- 许可证密钥
`authorization_code_id` varchar(36) NOT NULL, -- 授权码ID
`customer_id` varchar(36) NOT NULL, -- 客户ID
`hardware_fingerprint` varchar(200) NOT NULL, -- 硬件指纹
`device_info` json DEFAULT NULL, -- 设备信息
`activation_ip` varchar(45) DEFAULT NULL, -- 激活IP
`status` varchar(20) NOT NULL DEFAULT 'inactive', -- 状态
`activated_at` datetime(3) DEFAULT NULL, -- 激活时间
`last_heartbeat` datetime(3) DEFAULT NULL,-- 最后心跳
`last_online_ip` varchar(45) DEFAULT NULL, -- 最后在线IP
`config_updated_at` datetime(3) DEFAULT NULL, -- 配置更新时间
`usage_data` json DEFAULT NULL, -- 使用数据
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_licenses_license_key` (`license_key`),
KEY `idx_licenses_authorization_code_id` (`authorization_code_id`),
KEY `idx_licenses_customer_id` (`customer_id`),
KEY `idx_licenses_hardware_fingerprint` (`hardware_fingerprint`),
KEY `idx_licenses_status` (`status`),
KEY `idx_licenses_activated_at` (`activated_at`),
KEY `idx_licenses_last_heartbeat` (`last_heartbeat`),
KEY `idx_licenses_config_updated_at` (`config_updated_at`),
KEY `idx_licenses_created_at` (`created_at`),
KEY `idx_licenses_deleted_at` (`deleted_at`),
CONSTRAINT `fk_licenses_authorization_code` FOREIGN KEY (`authorization_code_id`) REFERENCES `authorization_codes` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT,
CONSTRAINT `fk_licenses_customer` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
### cu_users - 客户用户表
```sql
CREATE TABLE `cu_users` (
`id` varchar(36) NOT NULL,
`customer_id` varchar(36) NOT NULL, -- 客户ID
`phone` varchar(20) NOT NULL, -- 手机号
`phone_country_code` varchar(10) NOT NULL DEFAULT '+86', -- 国家代码
`password` varchar(255) DEFAULT NULL, -- 密码
`salt` varchar(32) DEFAULT NULL, -- 密码盐
`user_role` varchar(20) NOT NULL DEFAULT 'member', -- 用户角色
`real_name` varchar(100) DEFAULT NULL, -- 真实姓名
`email` varchar(255) DEFAULT NULL, -- 邮箱
`status` varchar(20) NOT NULL DEFAULT 'active', -- 状态
`phone_verified` tinyint(1) NOT NULL DEFAULT '1', -- 手机号验证
`email_verified` tinyint(1) NOT NULL DEFAULT '0', -- 邮箱验证
`last_login_at` datetime(3) DEFAULT NULL, -- 最后登录时间
`last_login_ip` varchar(50) DEFAULT NULL, -- 最后登录IP
`login_attempts` bigint NOT NULL DEFAULT '0', -- 登录尝试次数
`locked_until` datetime(3) DEFAULT NULL, -- 锁定到期时间
`avatar_url` varchar(500) DEFAULT NULL, -- 头像URL
`language` varchar(10) NOT NULL DEFAULT 'zh-CN', -- 语言
`timezone` varchar(50) NOT NULL DEFAULT 'Asia/Shanghai', -- 时区
`additional_info` json DEFAULT NULL, -- 附加信息
`remark` text, -- 备注
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_cu_users_customer_id` (`customer_id`),
KEY `idx_cu_users_email` (`email`),
KEY `idx_cu_users_status` (`status`),
KEY `idx_cu_users_last_login_at` (`last_login_at`),
KEY `idx_cu_users_locked_until` (`locked_until`),
KEY `idx_cu_users_created_at` (`created_at`),
KEY `idx_cu_users_deleted_at` (`deleted_at`),
KEY `idx_cu_users_phone` (`phone`,`phone_country_code`),
KEY `idx_cu_users_customer_status` (`customer_id`,`status`),
KEY `idx_cu_users_status_locked` (`status`,`locked_until`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
## 📋 开发规范
### 遵循原则:
- **分层架构**:Handler → Service → Repository
- **依赖注入**:通过接口解耦各层依赖
- **多语言支持**:错误信息支持国际化
- **统一响应**:遵循项目API响应格式规范
### 代码结构:
```
internal/
├── api/handlers/
│ └── user_authorization_handler.go # 用户授权处理器
├── service/
│ └── user_authorization_service.go # 用户授权业务逻辑
└── repository/
└── user_authorization_repository.go # 用户授权数据访问
```
### 质量检查:
- [ ] 分层职责是否清晰
- [ ] 多语言字段是否完整
- [ ] 错误处理是否统一
- [ ] 事务边界是否正确
- [ ] 参数校验是否完整
- [ ] 日志记录是否完善
@@ -0,0 +1,492 @@
# 支付宝支付对接设计文档
## 概述
本系统需要对接支付宝支付功能,主要用于用户购买产品套餐的支付场景。系统采用模块化设计,支持后续扩展其他支付方式(如微信支付等)。
## 业务需求
- 用户购买产品套餐时支持支付宝支付
- 支持支付状态查询和回调处理
- 支付成功后自动激活用户套餐
- 支持支付记录查询和管理
## 技术架构
基于现有的Clean Architecture设计,使用**支付宝官方Go SDK**实现支付功能:
### 核心组件
- **支付SDK**: `github.com/smartwalle/alipay/v3` - 官方支付宝Go SDK
- **支付服务**: 封装支付业务逻辑,集成官方SDK
- **支付仓储**: 支付数据持久化
- **支付处理器**: RESTful API接口
### 架构优势
- **官方维护**: 使用官方SDK,确保兼容性和安全性
- **简化开发**: 无需手动实现签名、验证等复杂逻辑
- **标准化**: 遵循支付宝官方API规范
- **易扩展**: 支持支付宝、微信等多支付方式
## 数据模型设计
### 通用支付设计:支付抽象与业务分离
采用最佳实践的支付抽象设计,支持多种支付方式和业务场景:
#### 设计原则
- **支付抽象**:payments表只包含支付相关的通用字段
- **业务关联**:通过`business_type`和`business_id`关联具体业务
- **扩展性**:支持支付宝、微信等多种支付方式
- **业务隔离**:支付失败不影响业务数据
#### 支付订单表 (payments)
```sql
CREATE TABLE payments (
id SERIAL PRIMARY KEY,
payment_no VARCHAR(64) UNIQUE NOT NULL COMMENT '支付单号',
business_type VARCHAR(50) NOT NULL COMMENT '业务类型 (package_order-套餐购买)',
business_id VARCHAR(36) NULL COMMENT '业务ID (关联cu_orders.id)',
user_id INTEGER NOT NULL COMMENT '用户ID',
amount DECIMAL(10,2) NOT NULL COMMENT '支付金额',
currency VARCHAR(3) DEFAULT 'CNY' COMMENT '货币类型',
payment_method VARCHAR(20) DEFAULT 'alipay' COMMENT '支付方式',
payment_provider VARCHAR(20) DEFAULT 'alipay' COMMENT '支付提供商',
status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '支付状态',
trade_no VARCHAR(64) NULL COMMENT '第三方交易号',
payment_time TIMESTAMP NULL COMMENT '支付完成时间',
expire_time TIMESTAMP NOT NULL COMMENT '支付过期时间',
payment_url TEXT NULL COMMENT '支付链接',
notify_data JSONB NULL COMMENT '回调数据',
extra_data JSONB NULL COMMENT '扩展数据',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 索引
CREATE INDEX idx_payments_payment_no ON payments(payment_no);
CREATE INDEX idx_payments_business_type_id ON payments(business_type, business_id);
CREATE INDEX idx_payments_user_id ON payments(user_id);
CREATE INDEX idx_payments_status ON payments(status);
CREATE INDEX idx_payments_created_at ON payments(created_at);
```
#### 业务类型枚举
- `package_order`: 套餐购买订单
- `license_extend`: 许可延期(预留)
- `service_upgrade`: 服务升级(预留)
#### 支付状态枚举
- `pending`: 待支付
- `paid`: 已支付
- `cancelled`: 已取消
- `expired`: 已过期
- `failed`: 支付失败
- `refunded`: 已退款
#### 支付方式枚举
- `alipay`: 支付宝
- `wechat`: 微信支付(预留)
- `bank`: 银行卡(预留)
## 官方SDK集成
### SDK选择
使用 `github.com/smartwalle/alipay/v3` 官方Go SDK,原因:
- **官方维护**: 支付宝官方团队维护,确保最新API支持
- **功能完整**: 支持支付、退款、查询等所有功能
- **签名处理**: 自动处理RSA签名和验证
- **类型安全**: 强类型接口,避免手动构造参数
- **社区活跃**: 有完整的文档和示例
### 核心用法
```go
// 1. 创建客户端
client, err := alipay.New(appID, privateKey, false)
// 2. 创建支付请求
payRequest := alipay.TradePagePay{
Trade: alipay.Trade{
OutTradeNo: "PAY20240101001",
TotalAmount: "100.00",
Subject: "产品购买",
ProductCode: "FAST_INSTANT_TRADE_PAY",
},
}
// 3. 生成支付URL
payURL, err := client.TradePagePay(payRequest)
// 4. 处理回调
notification, err := client.GetTradeNotification(request)
```
### 集成优势
- **零配置**: SDK自动处理签名和验证
- **错误处理**: 完善的错误类型和处理机制
- **性能优化**: 内部实现连接池和缓存
- **安全可靠**: 官方审核,确保安全合规
## API接口设计
### 1. 创建订单并支付
**方案选择:兼容性修改现有接口**
基于现有 `POST /api/cu/orders` 接口进行兼容性修改:
**接口路径**: `POST /api/cu/orders`(保持不变)
**新增请求参数**:
```json
{
"package_id": "basic",
"license_count": 100,
"payment_method": "alipay" // 新增:支付方式,不传则为免费订单
}
```
**响应格式变化**:
**免费订单(试用版)响应** - 保持原有格式:
```json
{
"code": "000000",
"message": "成功",
"data": {
"order_id": "order_id_123",
"order_no": "ORD202401010001",
"total_amount": 0.00,
"status": "paid",
"authorization_code": "AC-240101-A1B2C3D4",
"expired_at": "2024-01-25T23:59:59Z"
}
}
```
**付费订单响应** - 新增支付信息:
```json
{
"code": "000000",
"message": "成功",
"data": {
"order_id": "order_id_456",
"order_no": "ORD202401010002",
"payment_no": "PAY202401010002",
"total_amount": 24000.00,
"status": "pending",
"payment_url": "https://openapi.alipay.com/gateway.do?...",
"expire_time": "2024-01-01T13:00:00Z"
}
}
```
**业务逻辑**:
- 根据套餐类型和支付方式决定处理流程
- 免费订单:直接创建paid状态订单并生成授权码
- 付费订单:创建pending状态订单和支付单,返回支付链接
### 2. 查询订单状态
**接口**: `GET /api/cu/orders/{order_no}/status`(保持与现有API风格一致)
**响应**:
**免费订单状态**:
```json
{
"code": "000000",
"data": {
"order_no": "ORD202401010001",
"status": "paid",
"authorization_code": "AC-240101-A1B2C3D4",
"expired_at": "2024-01-25T23:59:59Z"
}
}
```
**付费订单状态**:
```json
{
"code": "000000",
"data": {
"order_no": "ORD202401010002",
"status": "paid",
"payment_status": "paid",
"authorization_code": "AC-240102-B2C3D4E5",
"payment_time": "2024-01-01T12:35:00Z"
}
}
```
**业务逻辑**:
- 兼容现有查询接口
- 对于付费订单,额外返回支付相关信息
### 3. 支付回调处理
**接口**: `POST /api/payment/alipay/callback`(兼容:`POST /api/v1/payment/alipay/callback`)
支付宝服务器回调,系统验证签名后更新订单状态。
### 4. 获取支付历史
**接口**: `GET /api/v1/payment/history`
支持分页查询用户的支付记录。
## 支付流程设计
### 1. 支付流程图
```
用户选择套餐 → 创建业务订单(cu_orders, status=pending) → 创建支付单(payments, pending) → 跳转支付 → 用户支付 → 支付回调 → 更新payments为paid → 更新cu_orders为paid → 生成授权码
```
### 2. 详细流程
1. **创建业务订单**
- 前端调用创建订单接口,传入套餐ID和许可数量
- 后端验证套餐配置,计算价格和折扣
- 在`cu_orders`表创建记录,状态为`pending`,生成业务订单
- **创建对应的支付单**,关联业务订单,状态为`pending`
- 调用支付接口生成支付链接
- 返回支付信息给前端
2. **支付处理**
- 用户跳转到支付页面完成支付
- 第三方支付平台处理支付并异步回调系统
3. **回调处理**
- 验证支付回调签名和数据
- 确认支付金额一致
- 在事务中执行:
- 更新`payments`表状态为`paid`
- 更新对应的`cu_orders`表状态为`paid`
- 生成授权码
- 返回成功响应给支付平台
4. **状态查询**
- 前端可查询支付状态
- 返回支付状态和业务订单信息
## 安全设计
### 1. 支付验证
- 使用支付平台公钥验证回调签名
- 防止伪造回调请求
- 支持多种支付方式的签名验证
### 2. 金额防篡改
- 支付单金额在创建时计算并存储
- 支付回调时二次验证金额
- 业务数据与支付数据分离,确保一致性
### 3. 防重复支付
- 支付单状态机严格控制
- 第三方交易号唯一性验证
- 支付过期时间控制
### 4. 业务隔离
- 支付失败:只更新payments状态,业务订单保持pending
- 支付成功:事务中同时更新payments和业务订单状态
- 支持多种业务类型的支付处理
## 配置管理
在`configs/config.yaml`中添加统一的支付配置:
```yaml
payment:
# 默认支付方式
default_method: alipay
# 支付提供商配置
providers:
alipay:
enabled: true
app_id: "your_app_id"
private_key: "your_private_key"
public_key: "alipay_public_key"
gateway_url: "https://openapi.alipay.com/gateway.do"
notify_url: "https://your-domain.com/api/payment/alipay/callback"
return_url: "https://your-domain.com/payment/success"
sign_type: "RSA2"
charset: "utf-8"
format: "json"
# 通用支付配置
expire_minutes: 30 # 支付过期时间(分钟)
retry_times: 3 # 支付重试次数
enable_log: true # 启用支付日志
```
**配置说明**:
- `default_method`: 默认支付方式,可设置为 `alipay` 或 `wechat`
- `providers`: 各支付提供商的详细配置,支持动态开启/关闭
- 通用配置:支付过期时间、重试次数、日志开关等
## 错误处理
### 支付相关错误码
- `PAYMENT_ORDER_NOT_FOUND`: 订单不存在
- `PAYMENT_ALREADY_PAID`: 订单已支付
- `PAYMENT_EXPIRED`: 订单已过期
- `PAYMENT_AMOUNT_MISMATCH`: 支付金额不匹配
- `PAYMENT_SIGNATURE_VERIFY_FAILED`: 签名验证失败
## 扩展性设计
### 1. 多支付方式支持
通过策略模式和工厂模式支持多种支付方式:
```go
// 支付策略接口
type PaymentStrategy interface {
CreatePayment(payment *Payment) (*PaymentResponse, error)
QueryPayment(paymentNo string) (*PaymentStatus, error)
HandleCallback(data map[string]interface{}) error
ValidateSignature(data map[string]interface{}) bool
}
// 支付工厂
type PaymentFactory struct {
strategies map[string]PaymentStrategy
}
func (f *PaymentFactory) GetStrategy(method string) PaymentStrategy {
return f.strategies[method]
}
```
### 2. 支持的支付方式
- **支付宝**:当前实现
- **微信支付**:预留扩展
- **银行卡**:预留扩展
### 3. 多业务场景支持
通过`business_type`支持不同业务场景:
```go
const (
BusinessTypePackageOrder = "package_order" // 套餐购买
BusinessTypeLicenseExtend = "license_extend" // 许可延期
BusinessTypeServiceUpgrade = "service_upgrade" // 服务升级
)
```
### 4. 配置说明
详见[配置管理](#配置管理)章节,支持支付宝、微信等多支付方式统一配置。
## 测试策略
### 1. 单元测试
- 支付服务逻辑测试
- 支付宝接口mock测试
- 回调处理测试
### 2. 集成测试
- 完整支付流程测试
- 支付宝沙箱环境测试
### 3. 沙箱环境
使用支付宝提供的沙箱环境进行测试,避免真实资金操作。
## 数据迁移和兼容性
### 新增payments表
创建通用的支付表,支持多种支付方式和业务场景:
```sql
-- 创建payments表(见数据模型设计部分)
-- business_type + business_id 支持关联不同业务
```
### API兼容性处理
#### 现有接口修改策略
**POST /api/cu/orders 接口变更**:
1. **向后兼容**:不传`payment_method`参数时,保持原有免费订单逻辑
2. **扩展功能**:传入`payment_method`参数时,启用支付流程
3. **响应差异**:免费订单返回授权码,付费订单返回支付链接
#### 迁移步骤
1. **代码层面**:
```go
// 修改现有创建订单逻辑
func CreateOrder(req CreateOrderRequest) (*OrderResponse, error) {
if req.PaymentMethod == "" {
// 免费订单:直接创建并返回授权码
return createFreeOrder(req)
} else {
// 付费订单:创建订单+支付单,返回支付信息
return createPaidOrder(req)
}
}
```
2. **数据库层面**:
- 新增payments表
- cu_orders表保持不变
- 通过关联关系连接
3. **前端适配**:
- 免费订单:无需修改,正常显示授权码
- 付费订单:跳转支付页面,支付完成后查询状态
#### 错误处理兼容
保持现有的错误码体系,新增支付相关错误码:
```yaml
cu_payment:
"602001": "支付失败"
"602002": "支付金额不匹配"
"602003": "重复支付"
"602004": "支付已过期"
```
## 部署注意事项
1. **网络要求**: 支付宝回调URL必须是公网可访问的HTTPS地址
2. **密钥安全**: 配置支付宝RSA私钥文件,严格控制访问权限
3. **数据库**: 确保payments表已创建,数据库连接池充足
4. **日志监控**: 支付相关日志级别设为INFO,配置告警监控支付成功率
5. **SDK依赖**: 确保 `github.com/smartwalle/alipay/v3` 已正确安装
6. **配置验证**: 部署前验证支付宝配置参数的正确性
7. **业务隔离**: 支付失败不影响业务订单,确保数据一致性
## 对接资料
### 支付宝官方文档
- [电脑网站支付API](https://opendocs.alipay.com/open/59da99d0_alipay.trade.page.pay)
- [SDK下载与文档](https://opendocs.alipay.com/open/54/103419)
- [签名验证指南](https://opendocs.alipay.com/open/291/105974)
### Go SDK资源
- [smartwalle/alipay GitHub](https://github.com/smartwalle/alipay)
- [SDK使用示例](https://github.com/smartwalle/alipay/tree/master/examples)
- [GoDoc文档](https://pkg.go.dev/github.com/smartwalle/alipay/v3)
### 沙箱环境
- [支付宝沙箱](https://open.alipay.com/develop/sandbox/app) - 用于开发测试
- [测试账号申请](https://opendocs.alipay.com/open/200/105311) - 获取测试环境账号
@@ -0,0 +1,38 @@
沙箱账号
商家信息
商户账号enjyeu8747@sandbox.com
登录密码111111
商户PID2088721092516360
账户余额1000000.00
买家信息
买家账号mgwlmn3858@sandbox.com
登录密码111111
支付密码111111
用户UID2088722092516378
用户名称mgwlmn3858
证件类型IDENTITY_CARD
证件账号471743193305122691
账户余额1000000.00
支付宝网关地址
https://openapi-sandbox.dl.alipaydev.com/gateway.do
websocket服务地址
openchannel-sandbox.dl.alipaydev.com
基本信息
APPID
9021000159611786
应用名称
sandbox 默认应用:2088721092516360
绑定的商家账号(PID)
2088721092516360
应用公钥:复制公钥
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxBHcY3BLmA0glH1v1e9VnLFEc72ZyI5xNF77SMA5hGutz30SmzczjYvGJNYTeyWfIjIqtP0IA49XA61kpUPiTyBy2sxjDfu6ew+Yu2waynpI+NbkpIz8TWT1g7rqgZMsUexufmvFEpvn1AmboVKN8iZVflwc4lq5pQ/GS7uTlDK3fY32jEk6jxEHy8yZvkf467X0WK9AdrJ0IxUTOLBROdZySqg7mc2Iyo61xdDOuVCJjZSWFHt+s4UWx2VPFSbL2z4m82abGicAgj696J2PU9iLhQG2HA/EwrH8zsVyJPHTMgPFwngN9sxmT4U3OH8ugCnw/amEsD8VZR3PG3ypQIDAQAB
应用私钥:
复制私钥
MIIEogIBAAKCAQEAhxBHcY3BLmA0glH1v1e9VnLFEc72ZyI5xNF77SMA5hGutz30SmzczjYvGJNYTeyWfIjIqtP0IA49XA61kpUPiTyBy2sxjDfu6ew+Yu2waynpI+NbkpIz8TWT1g7rqgZMsUexufmvFEpvn1AmboVKN8iZVflwc4lq5pQ/GS7uTlDK3fY32jEk6jxEHy8yZvkf467X0WK9AdrJ0IxUTOLBROdZySqg7mc2Iyo61xdDOuVCJjZSWFHt+s4UWx2VPFSbL2z4m82abGicAgj696J2PU9iLhQG2HA/EwrH8zsVyJPHTMgPFwngN9sxmT4U3OH8ugCnw/amEsD8VZR3PG3ypQIDAQABAoIBAFSs2Q2Q4H7xo5ncRmQ6XqKW+4Ppq8MGyw7ggoAuBL84cL/C4KfUTi4d/DK9FYxs+f+DRcMLdK6wHrnbIuyN9RqFy527S2nsGyWejZI6D/NvaBGu1Vczv8rGWzPFUOvcXTJ7jynYJnJjJE3N0vpRKDGG2tLyuhRFH7cFU/W23YJM7TflTUUh1SG0Ec+jUa+ek5nK/vsqGE7A5p/D+mW4WLfS4sGhfe1XzNKjuCznTYajG79YXHXBKljqq4SKoEdh0SQHoEy7H0BXqArjSbh4KdHYoW/KkvA/c5QpbaTuWdK3iuvq7bZ1I0SEckL/rzufj+OcgoKcnwxZG2/Eq1Nb8tUCgYEAwcuyQW1AZEVl4dbk9Y4JXFeWh82LZ9pmyvr9tmqwQP4Ywlb9zUlXy+Zbv1wa9acfRWu9dYGm3SByFk8c3UGb/s3+eY4MribqkNVkjdotC/+wfymyf3gdLypGEXjBGwNJregGFeFmUhWnS+YWOiCiH401uDTU1pRtW37DZgdD5ZMCgYEAsmqIRL4U+3bkIb8u26lgS3k2WImen8+i91jVc8wWD7/aWGNmjodimyOv9AJRt34y5apzzfcWuzr9qdNQrzGubtKjaVM6s6NNVGzUJkLjF4WpyFDmX/ZLZkTW0gQLMYAjmiEJkPlbpHQ81vSMCT7W76RB31vL8pdRJMGBz/dN6ecCgYATzaXsi6Z92J2do2EIOrh4KDcor3dkaGwCp/1PK73RFmYfTdZnqOwA2QxdFKNooOhQJr9Yh6Quioc+Al2XuoGfWxt6/moLTczAeh3pZPcfd8RoajFkVzjcPfylAmrxQLfBvqkIiVz0XGQZHGz/CaCnir2HklaBiu9V3zFGds2YYwKBgFuaNryUm1FAiYCKGBTPsZEOesiaX8bNZG56PZ1mey0642/eGRm0iPv+JqixSCNdt+QSz8I/ynv2naZDyHkqmNRtcVjyUoFIprth5MqALPg2uCUQ5DcAzVWEoobRbK3NjHnl3HOCk3qr81Cp3fwhN1lyjB+RkfTLm+2zLGMKJgahAoGAGUigAVlCnUo6zj8PVOeUPJWV3tZf8ZkTtCXTjdp9tPwZqf0ZlmVZCoQB3nclF/6PgxgH7W596DlUt8TIrdmgg28HM8C2rpY/AoCw1H3cMkha8gCUN308cx0I2duea3tvDUb8thfvR34d7bxCI2hTOsplfE/MVv+sjzprGwtH/84=
支付宝公钥:复制公钥
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjkjXfe1fXjelyi6HIzFp5l9PGPxDBTi/TyIsRrF5NaYRCGqqSSJj1sc440eJ7InKRb4q/7cpdjlroC6DDO1OHKipld1pWSqoezc2lLVxvRWGt7Tl9mDfrul1/4ywx9OnIPBx3iJvzY15XDVWlZFYWVJFQv5EriLn+3xy+3gaazzJYCp/PumDqMTUgp0Kjk+HuHJ5/PKtFt3AtN2GB8glQEybl2Wf0qNZU9SzmEm+9I7CNvB/zUC3csRxJHTvbOtkjhCC/TrS20Xm8IFjygu9lBf9qzacXyfHjG6fLMU8aDn4kPAXL+wbqE2UzFmVvMNCLpEaIS3r7I8S2TGQLPDD6QIDAQAB
@@ -0,0 +1,182 @@
# 支付宝沙箱测试指南
## 🔧 配置准备
### 1. 启动服务
```bash
# 启动数据库和后端服务
cd backend
go run cmd/main.go
```
### 2. 验证配置
启动后检查日志,确认支付宝SDK初始化成功:
```
[INFO] Alipay client initialized successfully
```
## 🧪 测试步骤
### 步骤1: 创建订单并支付
#### API请求
```bash
# 1. 获取套餐列表
curl -X GET "http://localhost:18888/api/cu/packages" \
-H "Content-Type: application/json"
# 2. 计算价格
curl -X POST "http://localhost:18888/api/cu/orders/calculate" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"package_id": "basic",
"license_count": 1
}'
# 3. 创建付费订单
curl -X POST "http://localhost:18888/api/cu/orders" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"package_id": "basic",
"license_count": 1,
"payment_method": "alipay"
}'
```
#### 预期响应
```json
{
"code": "000000",
"message": "成功",
"data": {
"id": "order_xxx",
"order_no": "ORD20240101001",
"payment_no": "PAY20240101001",
"total_amount": 300.00,
"status": "pending",
"payment_url": "https://openapi-sandbox.dl.alipaydev.com/gateway.do?...",
"expire_time": "2024-01-01T13:00:00Z"
}
}
```
### 步骤2: 模拟支付
#### 方法1: 浏览器访问支付链接
1. 复制API响应中的 `payment_url`
2. 在浏览器中打开该链接
3. 使用沙箱买家账号登录:
- 账号:`mgwlmn3858@sandbox.com`
- 密码:`111111`
- 支付密码:`111111`
#### 方法2: 使用支付宝App扫码
如果支付链接是二维码,可以用支付宝App扫描支付
### 步骤3: 验证支付结果
#### 查询支付状态
```bash
curl -X GET "http://localhost:18888/api/payment/PAY20240101001/status" \
-H "Authorization: Bearer YOUR_TOKEN"
```
#### 预期成功响应
```json
{
"code": "000000",
"data": {
"payment_no": "PAY20240101001",
"status": "paid",
"amount": 300.00,
"payment_time": "2024-01-01T12:35:00Z",
"trade_no": "20240101220014xxxxxx",
"business_order": {
"order_no": "ORD20240101001",
"authorization_code": "AC-240101-XXXXXX",
"status": "paid"
}
}
}
```
## 🔍 常见问题排查
### 1. 支付链接无法访问
- 检查 `gateway_url` 是否正确
- 确认网络能访问支付宝沙箱环境
### 2. 回调失败
- 检查 `notify_url` 是否可公网访问
- 查看服务日志中的回调处理信息
### 3. 签名验证失败
- 确认私钥格式正确(PKCS8)
- 检查公钥是否匹配
### 4. 订单状态未更新
- 检查回调URL配置
- 查看支付宝回调日志
## 📊 测试数据
### 沙箱账号信息
- **买家账号**: mgwlmn3858@sandbox.com / 111111
- **支付密码**: 111111
- **余额**: 1,000,000.00
### 测试金额建议
- 小金额测试:0.01元
- 正常金额测试:1.00-100.00元
- 大金额测试:1000.00元
## 🐛 调试技巧
### 1. 查看详细日志
```bash
# 设置日志级别为debug
log:
level: "debug"
```
### 2. 手动触发回调测试
```bash
# 模拟支付宝回调(需构造正确的回调参数)
curl -X POST "http://localhost:18888/api/payment/alipay/callback" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "回调参数..."
```
### 3. 数据库检查
```sql
-- 查看支付订单
SELECT * FROM payments WHERE payment_no = 'PAY20240101001';
-- 查看业务订单
SELECT * FROM cu_orders WHERE order_no = 'ORD20240101001';
```
## ✅ 测试 checklist
- [ ] 服务启动成功,无错误日志
- [ ] 创建付费订单API返回支付链接
- [ ] 支付链接可正常访问支付宝沙箱
- [ ] 沙箱账号能正常登录和支付
- [ ] 支付完成后订单状态更新为paid
- [ ] 授权码正确生成
- [ ] 回调日志正常,无错误
## 🚀 生产环境部署
测试通过后,修改配置为生产环境:
1. 更换为正式APPID
2. 使用生产RSA密钥对
3. 修改网关地址为正式环境
4. 更新回调URL为生产域名
5. 配置生产数据库
---
**🎉 按照此指南测试,你的支付宝支付功能就能正常工作了!**
+1 -1
View File
@@ -484,7 +484,7 @@ GET /api/v1/authorization-codes/{id}/changes
**查询参数**
- `page` - 页码(默认1)
- `page_size` - 每页数量(默认20,最大100)
- `change_type` - 变更类型筛选 (renewal/upgrade/limit_change/feature_toggle/lock/unlock/other)
- `change_type` - 变更类型筛选 (renewal/feature_limit_change/lock/unlock/other)
- `operator_id` - 操作人筛选
- `start_date` - 开始时间 (YYYY-MM-DD格式)
- `end_date` - 结束时间 (YYYY-MM-DD格式)
@@ -0,0 +1,193 @@
# 阿里云短信发送SDK集成
## 概述
基于阿里云短信服务,实现验证码发送功能。已集成到项目中,支持注册、登录、密码重置等场景。
## 已实现功能 ✅
- ✅ AccessKey认证方式(推荐)
- ✅ 短信发送API调用
- ✅ 错误处理和响应解析
- ✅ 缓存集成
- ✅ 频率限制
- ✅ 多环境配置支持
## 技术栈
```go
package main
import (
"context"
"fmt"
dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20170525/v5/client"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
util "github.com/alibabacloud-go/tea-utils/v2/service"
"github.com/alibabacloud-go/tea/tea"
)
// createSMSClient 创建阿里云SMS客户端 (AccessKey方式)
func (s *smsService) createSMSClient() (*dysmsapi20170525.Client, error) {
config := &openapi.Config{
AccessKeyId: tea.String(s.config.AccessKeyID),
AccessKeySecret: tea.String(s.config.AccessKeySecret),
}
// 设置地域和端点
if s.config.RegionID != "" {
config.RegionId = tea.String(s.config.RegionID)
} else {
config.RegionId = tea.String("cn-hangzhou")
}
if s.config.Endpoint != "" {
config.Endpoint = tea.String(s.config.Endpoint)
} else {
config.Endpoint = tea.String("dysmsapi.aliyuncs.com")
}
client, err := dysmsapi20170525.NewClient(config)
if err != nil {
return nil, err
}
return client, nil
}
// CreateClient 使用凭据方式初始化账号Client (阿里云示例)
// 工程代码建议使用更安全的无AK方式,凭据配置方式请参见:https://help.aliyun.com/document_detail/378661.html。
func CreateClientWithCredentials() (_result *dysmsapi20170525.Client, _err error) {
// 此方法仅供参考,项目中使用AccessKey方式
return _result, fmt.Errorf("credentials方式未实现,请使用AccessKey方式")
}
// sendSMS 调用阿里云API发送短信 (项目中的实现)
func (s *smsService) sendSMS(phone, templateCode, code string) error {
// 创建阿里云SMS客户端
client, err := s.createSMSClient()
if err != nil {
return fmt.Errorf("failed to create SMS client: %w", err)
}
// 构建发送短信请求
sendSmsRequest := &dysmsapi20170525.SendSmsRequest{
SignName: tea.String(s.config.SignName),
TemplateCode: tea.String(templateCode),
PhoneNumbers: tea.String(phone),
TemplateParam: tea.String(fmt.Sprintf(`{"code":"%s"}`, code)),
}
// 创建运行时选项
runtime := &util.RuntimeOptions{}
// 发送短信
resp, err := client.SendSmsWithOptions(sendSmsRequest, runtime)
if err != nil {
return fmt.Errorf("failed to send SMS: %w", err)
}
// 检查响应结果
if resp.Body == nil {
return fmt.Errorf("SMS response body is nil")
}
// 检查返回码
if tea.StringValue(resp.Body.Code) != "OK" {
return fmt.Errorf("SMS send failed, code: %s, message: %s",
tea.StringValue(resp.Body.Code),
tea.StringValue(resp.Body.Message))
}
// 记录成功发送的日志
fmt.Printf("SMS sent successfully, BizId: %s, Code: %s\n",
tea.StringValue(resp.Body.BizId),
tea.StringValue(resp.Body.Code))
return nil
}
// _main 阿里云官方示例代码 (仅供参考)
func _main(args []*string) (_err error) {
// 项目中不使用此方法,改用AccessKey方式
return fmt.Errorf("此示例方法已废弃,请查看sendSMS方法")
}
## 配置和使用
### 1. 配置文件
```yaml
# configs/config.yaml
sms:
enabled: true # 启用短信服务
access_key_id: "your-access-key-id" # 阿里云AccessKey ID
access_key_secret: "your-access-key-secret" # 阿里云AccessKey Secret
sign_name: "惠州顺视智能科技" # 短信签名
region_id: "cn-hangzhou" # 地域ID
endpoint: "dysmsapi.aliyuncs.com" # 服务端点
templates:
register: "SMS_330275014" # 注册验证码模板
reset_pwd: "SMS_330275014" # 重置密码验证码模板
login: "SMS_330275014" # 登录验证码模板
current_phone: "SMS_330275014" # 当前手机号验证码模板
new_phone: "SMS_330275014" # 新手机号验证码模板
```
### 2. 使用示例
```go
// 发送注册验证码
err := smsService.SendVerificationCode(ctx, "+8613800012345", "+86", "register")
if err != nil {
log.Printf("Failed to send SMS: %v", err)
}
// 验证验证码
valid, err := smsService.VerifyCode(ctx, "+8613800012345", "+86", "123456")
if err != nil {
log.Printf("Failed to verify code: %v", err)
}
if !valid {
log.Println("Invalid verification code")
}
```
### 3. 错误处理
项目中的SMS服务包含完善的错误处理:
- **认证错误**: AccessKey无效等
- **参数错误**: 手机号格式、模板不存在等
- **频率限制**: 自动频率控制和缓存
- **网络错误**: 重试和超时处理
### 4. 测试验证
```bash
# 运行SMS测试
go test ./pkg/utils/ -v -run TestSMS
# 查看测试输出,确认API调用正常
```
## 注意事项
1. **AccessKey安全**: 生产环境使用RAM用户AccessKey,不要使用主账号
2. **费用控制**: 阿里云SMS按条收费,注意测试用量
3. **频率限制**: 已实现客户端频率限制,避免被阿里云限制
4. **错误日志**: 重要错误已记录,便于排查问题
## 技术栈版本
- **阿里云SMS SDK**: `github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0`
- **OpenAPI SDK**: `github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13`
- **Tea Utils**: `github.com/alibabacloud-go/tea-utils/v2 v2.0.9`
- **Tea Core**: `github.com/alibabacloud-go/tea v1.4.0`
---
*此文档基于阿里云SMS服务官方SDK示例,项目中已完整实现并集成。*
+5
View File
@@ -0,0 +1,5 @@
# 提示词
## 接口设计提示词
请参考至少三个当前已经实现的接口,熟悉接口设计规范,然后设计新的接口。
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+21 -2
View File
@@ -6,8 +6,12 @@ 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/redis/go-redis/v9 v9.17.2
github.com/shirou/gopsutil/v3 v3.24.5
github.com/sirupsen/logrus v1.9.3
github.com/smartwalle/alipay/v3 v3.2.28
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
@@ -22,10 +26,21 @@ require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0 // indirect
github.com/alibabacloud-go/tea v1.4.0 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
github.com/aliyun/credentials-go v1.4.5 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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,16 +69,20 @@ 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/smartwalle/ncrypto v1.0.4 // indirect
github.com/smartwalle/ngx v1.0.12 // indirect
github.com/smartwalle/nsign v1.0.9 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@@ -73,7 +92,7 @@ require (
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.20.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
+201 -1
View File
@@ -1,24 +1,81 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 h1:Q00FU3H94Ts0ZIHDmY+fYGgB7dV9D/YX6FGsgorQPgw=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0 h1:rYyjylSQ0xdA2AaYIFky9CSyKDOZpEHdAsIMAcbSIjw=
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0/go.mod h1:6ax2auNlAXYhdIqYOTpXWm7vS9AwaWigZFzHKkDm9k8=
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
github.com/alibabacloud-go/tea v1.3.13/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea v1.4.0 h1:MSKhu/kWLPX7mplWMngki8nNt+CyUZ+kfkzaR5VpMhA=
github.com/alibabacloud-go/tea v1.4.0/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk=
github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@@ -57,12 +114,29 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -71,8 +145,10 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
@@ -101,6 +177,8 @@ github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
@@ -112,6 +190,9 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
@@ -122,8 +203,21 @@ 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/smartwalle/alipay/v3 v3.2.28 h1:pANvguTjmFtfTw3le44qjQHYDrRcjCy8r8YUonFeblE=
github.com/smartwalle/alipay/v3 v3.2.28/go.mod h1:dwPyjY5y17qUsDrsVYCQqmDBtQ/qpvY8JmQMvuJYGLQ=
github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8=
github.com/smartwalle/ncrypto v1.0.4/go.mod h1:Dwlp6sfeNaPMnOxMNayMTacvC5JGEVln3CVdiVDgbBk=
github.com/smartwalle/ngx v1.0.12 h1:jcoCyu/0HtQ1y/gbiSLzqOUZcHnVLlKOmm0awRF7Mcg=
github.com/smartwalle/ngx v1.0.12/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0=
github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E=
github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
@@ -135,10 +229,12 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -155,6 +251,9 @@ github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+z
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg=
github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk=
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
@@ -163,6 +262,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
@@ -174,27 +275,77 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUu
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -206,34 +357,81 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -244,5 +442,7 @@ gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
@@ -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
@@ -0,0 +1,443 @@
package handlers
import (
"net/http"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"license-manager/pkg/utils"
"github.com/gin-gonic/gin"
)
type CuAuthHandler struct {
cuUserService service.CuUserService
}
func NewCuAuthHandler(cuUserService service.CuUserService) *CuAuthHandler {
return &CuAuthHandler{
cuUserService: cuUserService,
}
}
// CuUserRegister 客户用户注册
// @Summary 客户用户注册
// @Description 通过手机号注册客户用户账号
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Param user body models.CuUserRegisterRequest true "注册信息"
// @Success 200 {object} models.APIResponse{data=object{user=object{id=string,customer_id=string,phone=string,real_name=string,email=string,user_role=string,status=string},token=string}} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 409 {object} models.ErrorResponse "手机号已被注册"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/register [post]
func (h *CuAuthHandler) CuUserRegister(c *gin.Context) {
var req models.CuUserRegisterRequest
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
}
// 验证短信验证码
phoneCountryCode := req.PhoneCountryCode
if phoneCountryCode == "" {
phoneCountryCode = "+86"
}
valid, err := h.cuUserService.VerifyRegisterSmsCode(c.Request.Context(), req.Phone, phoneCountryCode, req.SmsCode)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
if !valid {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("500012", lang) // 验证码错误或已过期
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
user, err := h.cuUserService.Register(c.Request.Context(), &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 生成JWT Token
token, err := utils.GenerateCuToken(user.ID, user.CustomerID, user.UserRole, user.Phone)
if err != nil {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("500001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: gin.H{
"user": gin.H{
"id": user.ID,
"customer_id": user.CustomerID,
"phone": user.Phone,
"real_name": user.RealName,
"email": user.Email,
"user_role": user.UserRole,
"status": user.Status,
},
"token": token,
},
})
}
// CuUserLogin 客户用户登录
// @Summary 客户用户登录
// @Description 通过手机号和密码或短信验证码登录客户用户账号
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Param user body models.CuUserLoginRequest true "登录信息"
// @Success 200 {object} models.APIResponse{data=object{user=object{id=string,customer_id=string,phone=string,real_name=string,email=string,user_role=string,status=string},token=string}} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "手机号、密码或验证码错误"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/login [post]
func (h *CuAuthHandler) CuUserLogin(c *gin.Context) {
var req models.CuUserLoginRequest
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
}
// 获取客户端IP
ip := c.ClientIP()
user, token, err := h.cuUserService.Login(c.Request.Context(), &req, ip)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: gin.H{
"user": gin.H{
"id": user.ID,
"customer_id": user.CustomerID,
"phone": user.Phone,
"real_name": user.RealName,
"email": user.Email,
"user_role": user.UserRole,
"status": user.Status,
},
"token": token,
},
})
}
// CuUserForgotPassword 忘记密码 - 发送验证码
// @Summary 忘记密码 - 发送验证码
// @Description 提交手机号,发送密码重置验证码到手机
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Param request body models.CuUserForgotPasswordRequest true "忘记密码请求"
// @Success 200 {object} models.APIResponse "验证码发送成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 404 {object} models.ErrorResponse "用户不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/forgot-password [post]
func (h *CuAuthHandler) CuUserForgotPassword(c *gin.Context) {
var req models.CuUserForgotPasswordRequest
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
}
err := h.cuUserService.ForgotPassword(c.Request.Context(), &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// CuUserSendLoginSms 登录发送验证码
// @Summary 登录发送验证码
// @Description 登录前发送短信验证码到手机
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Param request body models.CuUserSendLoginSmsRequest true "登录发送验证码请求"
// @Success 200 {object} models.APIResponse "验证码发送成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 404 {object} models.ErrorResponse "用户不存在"
// @Failure 429 {object} models.ErrorResponse "请求过于频繁"
// @Failure 500 {object} models.ErrorResponse "短信发送失败"
// @Router /api/cu/send-login-sms [post]
func (h *CuAuthHandler) CuUserSendLoginSms(c *gin.Context) {
var req models.CuUserSendLoginSmsRequest
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
}
err := h.cuUserService.SendLoginSms(c.Request.Context(), &req)
if err != nil {
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// CuUserSendRegisterSms 注册发送验证码
// @Summary 注册发送验证码
// @Description 注册前发送短信验证码到手机
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Param request body models.CuUserSendRegisterSmsRequest true "注册发送验证码请求"
// @Success 200 {object} models.APIResponse "验证码发送成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 409 {object} models.ErrorResponse "手机号已被注册"
// @Failure 429 {object} models.ErrorResponse "请求过于频繁"
// @Failure 500 {object} models.ErrorResponse "短信发送失败"
// @Router /api/cu/send-register-sms [post]
func (h *CuAuthHandler) CuUserSendRegisterSms(c *gin.Context) {
var req models.CuUserSendRegisterSmsRequest
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
}
err := h.cuUserService.SendRegisterSms(c.Request.Context(), &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// CuUserResetPassword 重置密码
// @Summary 重置密码
// @Description 通过验证码重置密码
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Param request body models.CuUserResetPasswordRequest true "重置密码请求"
// @Success 200 {object} models.APIResponse "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 409 {object} models.ErrorResponse "重置密码失败"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/reset-password [post]
func (h *CuAuthHandler) CuUserResetPassword(c *gin.Context) {
var req models.CuUserResetPasswordRequest
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
}
err := h.cuUserService.ResetPassword(c.Request.Context(), &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
@@ -0,0 +1,167 @@
package handlers
import (
"net/http"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"license-manager/pkg/utils"
"github.com/gin-gonic/gin"
)
type CuAuthorizationHandler struct {
authCodeService service.AuthorizationCodeService
}
func NewCuAuthorizationHandler(authCodeService service.AuthorizationCodeService) *CuAuthorizationHandler {
return &CuAuthorizationHandler{
authCodeService: authCodeService,
}
}
// ShareAuthorizationCode 用户分享授权码
// @Summary 用户分享授权码
// @Description 用户可以将自己的授权码分享给其他用户
// @Tags 用户端授权码管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param codeId path string true "授权码ID"
// @Param request body models.AuthorizationCodeShareRequest true "分享请求"
// @Success 200 {object} models.APIResponse{data=models.AuthorizationCodeShareResponse} "分享成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "授权码已被锁定"
// @Failure 404 {object} models.ErrorResponse "授权码不存在或目标用户不存在"
// @Failure 409 {object} models.ErrorResponse "不能分享给自己或分享数量超过可用激活数"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/authorization-codes/{codeId}/share [post]
func (h *CuAuthorizationHandler) ShareAuthorizationCode(c *gin.Context) {
// 从JWT Token中获取用户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
userID := claims.UserID
// 获取路径参数
codeID := c.Param("codeId")
if codeID == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
var req models.AuthorizationCodeShareRequest
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
}
// 调用服务层处理分享逻辑
data, err := h.authCodeService.ShareAuthorizationCode(c.Request.Context(), codeID, userID, &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// GetProductActivationCode 获取产品激活码
// @Summary 获取产品激活码
// @Description 获取产品激活码:{授权码}&{payload}(payload 为 RSA-PSS-SHA256 签名封装串)
// @Tags 用户端授权码管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body models.ProductActivationCodeRequest true "获取产品激活码请求"
// @Success 200 {object} models.APIResponse{data=models.ProductActivationCodeResponse} "获取成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "授权码已被锁定"
// @Failure 404 {object} models.ErrorResponse "授权码不存在或已过期"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/authorization-codes/product-activation-code [post]
func (h *CuAuthorizationHandler) GetProductActivationCode(c *gin.Context) {
claims := c.MustGet("cu_user").(*utils.CuClaims)
customerID := claims.CustomerID
var req models.ProductActivationCodeRequest
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
}
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.authCodeService.GetProductActivationCode(ctx, customerID, &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
@@ -0,0 +1,203 @@
package handlers
import (
"net/http"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"license-manager/pkg/utils"
"github.com/gin-gonic/gin"
)
type CuDeviceHandler struct {
cuDeviceService service.CuDeviceService
}
func NewCuDeviceHandler(cuDeviceService service.CuDeviceService) *CuDeviceHandler {
return &CuDeviceHandler{
cuDeviceService: cuDeviceService,
}
}
// GetDevices 获取设备列表
// @Summary 获取设备列表
// @Description 获取当前登录用户的设备列表,支持分页和筛选
// @Tags 客户设备管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码,默认1"
// @Param page_size query int false "每页数量,默认20,最大100"
// @Param device_name query string false "设备名称模糊搜索"
// @Param authorization_code_id query string false "按授权码ID筛选设备"
// @Param is_online query bool false "是否在线筛选:true在线,false离线"
// @Success 200 {object} models.APIResponse{data=models.DeviceListResponse} "获取成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/devices [get]
func (h *CuDeviceHandler) GetDevices(c *gin.Context) {
// 从JWT Token中获取用户ID和客户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
// 解析查询参数
var req models.DeviceListRequest
if err := c.ShouldBindQuery(&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
}
// 调用服务层
result, err := h.cuDeviceService.GetDeviceList(c.Request.Context(), claims.CustomerID, &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: result,
})
}
// UnbindDevice 解绑设备
// @Summary 解绑设备
// @Description 解绑指定的设备,物理删除许可证记录
// @Tags 客户设备管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "许可证ID"
// @Success 200 {object} models.APIResponse "解绑成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "无权限操作此设备"
// @Failure 404 {object} models.ErrorResponse "设备不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/devices/{id} [delete]
func (h *CuDeviceHandler) UnbindDevice(c *gin.Context) {
// 从JWT Token中获取用户ID和客户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
// 获取路径参数
licenseID := c.Param("id")
if licenseID == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": license id is required",
Timestamp: getCurrentTimestamp(),
})
return
}
// 调用服务层
err := h.cuDeviceService.UnbindDevice(c.Request.Context(), claims.CustomerID, licenseID)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// GetDeviceSummary 获取设备汇总统计
// @Summary 获取设备汇总统计
// @Description 获取当前登录用户的设备汇总统计信息(总数、在线数、离线数)
// @Tags 客户设备管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.DeviceSummaryResponse} "获取成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/devices/summary [get]
func (h *CuDeviceHandler) GetDeviceSummary(c *gin.Context) {
// 从JWT Token中获取用户ID和客户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
// 调用服务层
result, err := h.cuDeviceService.GetDeviceSummary(c.Request.Context(), claims.CustomerID)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: result,
})
}
@@ -0,0 +1,489 @@
package handlers
import (
"net/http"
"strconv"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"license-manager/pkg/utils"
"github.com/gin-gonic/gin"
)
type CuOrderHandler struct {
cuOrderService service.CuOrderService
paymentService service.PaymentService
}
func NewCuOrderHandler(cuOrderService service.CuOrderService, paymentService service.PaymentService) *CuOrderHandler {
return &CuOrderHandler{
cuOrderService: cuOrderService,
paymentService: paymentService,
}
}
// 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": "免费体验全部功能",
"details": "包含全部功能|试用期限制|1个许可等",
},
{
"id": "basic",
"name": "基础版",
"type": "basic",
"price": 300,
"max_devices": 1000,
"description": "小型企业最佳选择",
"details": "包含基础功能|批量许可购买|折扣优惠等",
},
{
"id": "professional",
"name": "专业版",
"type": "professional",
"price": 2000,
"max_devices": 1000,
"description": "企业级完整解决方案",
"details": "包含高级功能|技术支持|数据分析等",
}, {
"id": "custom",
"name": "企业定制版",
"type": "custom",
"price": "定制报价",
"max_devices": 0,
"description": "按需定制",
"details": "包含高级功能|技术支持|数据分析等",
},
}
}
// 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}} "免费订单成功"
// @Success 200 {object} models.APIResponse{data=object{id=string,order_no=string,payment_no=string,total_amount=number,payment_url=string,expire_time=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
}
// 根据是否有支付方式决定处理逻辑
if req.PaymentMethod == "" {
// 免费订单:直接创建订单
h.createFreeOrder(c, cuUserID.(string), customerID.(string), &req)
} else {
// 付费订单:创建订单和支付单
h.createPaidOrder(c, cuUserID.(string), customerID.(string), &req)
}
}
// createFreeOrder 创建免费订单
func (h *CuOrderHandler) createFreeOrder(c *gin.Context, cuUserID, customerID string, req *models.CuOrderCreateRequest) {
order, err := h.cuOrderService.CreateOrder(c.Request.Context(), cuUserID, customerID, 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(),
})
}
// createPaidOrder 创建付费订单
func (h *CuOrderHandler) createPaidOrder(c *gin.Context, cuUserID, customerID string, req *models.CuOrderCreateRequest) {
// 客户用户创建订单,不需要额外的管理员权限检查
// cuUserID 和 customerID 已经在上层方法中验证过了
// 计算价格
priceResult, 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
}
// 创建业务订单(pending状态)
order, err := h.cuOrderService.CreatePendingOrder(c.Request.Context(), cuUserID, customerID, 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
}
// 创建支付单
paymentReq := &models.PaymentCreateRequest{
BusinessType: models.BusinessTypePackageOrder,
BusinessID: &order.ID,
CustomerID: customerID,
CuUserID: cuUserID,
Amount: priceResult.TotalAmount,
Currency: "CNY",
PaymentMethod: req.PaymentMethod,
}
payment, err := h.paymentService.CreatePayment(c.Request.Context(), paymentReq)
if err != nil {
// 如果支付单创建失败,需要回滚业务订单
// 这里应该有事务处理,暂时先返回错误
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
Code: "602001",
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,
"payment_no": payment.PaymentNo,
"total_amount": priceResult.TotalAmount,
"payment_url": payment.PaymentURL,
"expire_time": payment.ExpireTime.Format("2006-01-02T15:04:05Z"),
},
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(),
})
}
// GetOrderSummary 获取订单汇总统计
// @Summary 获取订单汇总统计
// @Description 获取当前登录用户的订单汇总统计信息(总数、待支付数、已支付数)
// @Tags 客户订单管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.OrderSummaryResponse} "获取成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/orders/summary [get]
func (h *CuOrderHandler) GetOrderSummary(c *gin.Context) {
// 从JWT Token中获取用户ID和客户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
// 调用服务层
result, err := h.cuOrderService.GetOrderSummary(c.Request.Context(), claims.CustomerID)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: result,
})
}
@@ -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"])
}
@@ -0,0 +1,464 @@
package handlers
import (
"net/http"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"license-manager/pkg/utils"
"github.com/gin-gonic/gin"
)
type CuProfileHandler struct {
cuUserService service.CuUserService
}
func NewCuProfileHandler(cuUserService service.CuUserService) *CuProfileHandler {
return &CuProfileHandler{
cuUserService: cuUserService,
}
}
// GetCuUserProfile 获取用户个人资料
// @Summary 获取用户个人资料
// @Description 获取当前登录用户的个人资料信息
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.CuUserResponse} "获取成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "用户不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/profile [get]
func (h *CuProfileHandler) GetCuUserProfile(c *gin.Context) {
// 从JWT Token中获取用户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
user, err := h.cuUserService.GetProfile(c.Request.Context(), claims.UserID)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: user.ToResponse(),
})
}
// UpdateCuUserProfile 更新用户个人资料
// @Summary 更新用户个人资料
// @Description 更新当前登录用户的个人资料信息
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param profile body models.CuUserProfileUpdateRequest true "个人资料信息"
// @Success 200 {object} models.APIResponse "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 409 {object} models.ErrorResponse "个人资料更新失败"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/profile [put]
func (h *CuProfileHandler) UpdateCuUserProfile(c *gin.Context) {
// 从JWT Token中获取用户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
var req models.CuUserProfileUpdateRequest
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
}
err := h.cuUserService.UpdateProfile(c.Request.Context(), claims.UserID, &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// CuUserSendCurrentPhoneSms 发送当前手机号验证码
// @Summary 发送当前手机号验证码
// @Description 发送当前手机号验证码,用于手机号更新时的身份验证
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse "验证码发送成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未授权"
// @Failure 404 {object} models.ErrorResponse "用户不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/profile/send-current-phone-sms [post]
func (h *CuProfileHandler) CuUserSendCurrentPhoneSms(c *gin.Context) {
claims := c.MustGet("cu_user").(*utils.CuClaims)
err := h.cuUserService.SendCurrentPhoneSms(c.Request.Context(), claims.UserID)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// CuUserSendNewPhoneSms 发送新手机号验证码
// @Summary 发送新手机号验证码
// @Description 发送新手机号验证码,用于手机号更新时的可用性验证
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body models.CuUserSendNewPhoneSmsRequest true "新手机号信息"
// @Success 200 {object} models.APIResponse "验证码发送成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未授权"
// @Failure 409 {object} models.ErrorResponse "手机号已被注册"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/profile/send-new-phone-sms [post]
func (h *CuProfileHandler) CuUserSendNewPhoneSms(c *gin.Context) {
var req models.CuUserSendNewPhoneSmsRequest
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
}
err := h.cuUserService.SendNewPhoneSms(c.Request.Context(), &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// UpdateCuUserPhone 更新用户手机号
// @Summary 更新用户手机号
// @Description 更新当前登录用户的手机号,需要验证新旧手机号
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param phone body models.CuUserPhoneUpdateRequest true "手机号更新信息"
// @Success 200 {object} models.APIResponse "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 409 {object} models.ErrorResponse "手机号更新失败"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/profile/phone [put]
func (h *CuProfileHandler) UpdateCuUserPhone(c *gin.Context) {
// 从JWT Token中获取用户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
var req models.CuUserPhoneUpdateRequest
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
}
// 获取用户的当前手机号信息(从数据库)
user, err := h.cuUserService.GetProfile(c.Request.Context(), claims.UserID)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 验证当前手机号验证码
valid, err := h.cuUserService.VerifyPhoneCode(c.Request.Context(), user.Phone, user.PhoneCountryCode, req.CurrentSmsCode, "current_phone")
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
if !valid {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("500012", lang) // 验证码错误或已过期
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 验证新手机号验证码
newPhoneCountryCode := req.NewPhoneCountryCode
if newPhoneCountryCode == "" {
newPhoneCountryCode = "+86"
}
valid, err = h.cuUserService.VerifyPhoneCode(c.Request.Context(), req.NewPhone, newPhoneCountryCode, req.NewSmsCode, "new_phone")
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
if !valid {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("500012", lang) // 验证码错误或已过期
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
err = h.cuUserService.UpdatePhone(c.Request.Context(), claims.UserID, &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// ChangeCuUserPassword 修改用户密码
// @Summary 修改用户密码
// @Description 修改当前登录用户的密码,需要提供旧密码
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param password body models.CuUserChangePasswordRequest true "密码修改信息"
// @Success 200 {object} models.APIResponse "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 409 {object} models.ErrorResponse "密码修改失败"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/profile/password [put]
func (h *CuProfileHandler) ChangeCuUserPassword(c *gin.Context) {
// 从JWT Token中获取用户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
var req models.CuUserChangePasswordRequest
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
}
err := h.cuUserService.ChangePassword(c.Request.Context(), claims.UserID, req.OldPassword, req.NewPassword)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
@@ -258,7 +258,7 @@ func (h *CustomerHandler) UpdateCustomer(c *gin.Context) {
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.customerService.UpdateCustomer(ctx, id, &req)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
@@ -321,7 +321,7 @@ func (h *CustomerHandler) DeleteCustomer(c *gin.Context) {
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
err := h.customerService.DeleteCustomer(ctx, id)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
@@ -396,7 +396,7 @@ func (h *CustomerHandler) UpdateCustomerStatus(c *gin.Context) {
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.customerService.UpdateCustomerStatus(ctx, id, &req)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
@@ -0,0 +1,173 @@
package handlers
import (
"net/http"
"strconv"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"license-manager/pkg/utils"
"github.com/gin-gonic/gin"
)
type PaymentHandler struct {
paymentService service.PaymentService
}
func NewPaymentHandler(paymentService service.PaymentService) *PaymentHandler {
return &PaymentHandler{
paymentService: paymentService,
}
}
// GetPaymentStatus 获取支付状态
// @Summary 获取支付状态
// @Description 根据支付单号获取支付状态
// @Tags 支付管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param payment_no path string true "支付单号"
// @Success 200 {object} models.APIResponse{data=models.PaymentStatusResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "支付单不存在"
// @Router /api/payment/{payment_no}/status [get]
func (h *PaymentHandler) GetPaymentStatus(c *gin.Context) {
paymentNo := c.Param("payment_no")
if paymentNo == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
status, err := h.paymentService.GetPaymentStatus(c.Request.Context(), paymentNo)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
Code: "602001",
Message: "获取支付状态失败",
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMsg := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMsg,
Data: status,
Timestamp: getCurrentTimestamp(),
})
}
// GetUserPayments 获取用户支付历史
// @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.PaymentListResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Router /api/payment/history [get]
func (h *PaymentHandler) GetUserPayments(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
}
// 获取客户用户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
}
payments, err := h.paymentService.GetUserPayments(c.Request.Context(), customerID.(string), cuUserID.(string), page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
Code: "602001",
Message: "获取支付历史失败",
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMsg := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMsg,
Data: payments,
Timestamp: getCurrentTimestamp(),
})
}
// AlipayCallback 支付宝回调处理
// @Summary 支付宝回调
// @Description 处理支付宝支付回调
// @Tags 支付管理
// @Accept application/x-www-form-urlencoded
// @Produce text/plain
// @Param notification body string false "支付宝回调参数"
// @Success 200 {string} string "success"
// @Failure 400 {string} string "failure"
// @Router /api/payment/alipay/callback [post]
func (h *PaymentHandler) AlipayCallback(c *gin.Context) {
// 这里我们需要一个alipay client实例来解析回调
// 由于我们在service层已经有了client,这里暂时简化处理
// 实际应该从service中获取client
// 暂时使用简化的处理方式
// 实际项目中应该使用service中的client来验证和处理
if err := c.Request.ParseForm(); err != nil {
utils.SendNotificationResponse(c.Writer, false)
return
}
values := c.Request.PostForm
if len(values) == 0 {
values = c.Request.Form
}
if err := h.paymentService.ProcessAlipayCallback(c.Request.Context(), values); err != nil {
utils.SendNotificationResponse(c.Writer, false)
return
}
utils.SendNotificationResponse(c.Writer, true)
}
+76 -2
View File
@@ -4,14 +4,16 @@ import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"license-manager/pkg/utils"
"github.com/gin-gonic/gin"
)
const (
AuthorizationHeaderKey = "authorization"
AuthorizationTypeBearer = "bearer"
AuthorizationPayloadKey = "authorization_payload"
CuUserPayloadKey = "cu_user"
)
// AuthMiddleware 认证中间件
@@ -154,7 +156,79 @@ func AdminOnlyMiddleware() gin.HandlerFunc {
}
}
// CustomerAuth 客户用户认证中间件
func CustomerAuth() gin.HandlerFunc {
return func(c *gin.Context) {
authorizationHeader := c.GetHeader(AuthorizationHeaderKey)
if len(authorizationHeader) == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"code": "400010",
"message": "缺少认证令牌",
"data": nil,
})
c.Abort()
return
}
fields := strings.Fields(authorizationHeader)
if len(fields) < 2 {
c.JSON(http.StatusUnauthorized, gin.H{
"code": "400011",
"message": "认证令牌格式无效",
"data": nil,
})
c.Abort()
return
}
authorizationType := strings.ToLower(fields[0])
if authorizationType != AuthorizationTypeBearer {
c.JSON(http.StatusUnauthorized, gin.H{
"code": "400011",
"message": "不支持的认证类型",
"data": nil,
})
c.Abort()
return
}
accessToken := fields[1]
claims, err := utils.ValidateCuToken(accessToken)
if err != nil {
var errorCode, message string
switch err {
case utils.ErrCuTokenExpired:
errorCode = "400012"
message = "令牌已过期"
case utils.ErrCuTokenMalformed:
errorCode = "400011"
message = "令牌格式错误"
default:
errorCode = "400011"
message = "令牌无效"
}
c.JSON(http.StatusUnauthorized, gin.H{
"code": errorCode,
"message": message,
"data": nil,
})
c.Abort()
return
}
// 将客户用户信息存储到上下文
c.Set("cu_user_id", claims.UserID)
c.Set("cu_customer_id", claims.CustomerID)
c.Set("cu_user_role", claims.UserRole)
c.Set("cu_phone", claims.Phone)
c.Set(CuUserPayloadKey, claims)
c.Next()
}
}
// getCurrentTimestamp 获取当前时间戳
func getCurrentTimestamp() string {
return "2024-07-30T12:00:00Z" // 简化实现,实际应该使用time.Now().Format(time.RFC3339)
}
}
+144 -1
View File
@@ -1,6 +1,7 @@
package routes
import (
"fmt"
_ "license-manager/docs/swagger" // swagger docs
"license-manager/internal/api/handlers"
"license-manager/internal/api/middleware"
@@ -8,7 +9,9 @@ import (
"license-manager/internal/database"
"license-manager/internal/repository"
"license-manager/internal/service"
"license-manager/pkg/cache"
"license-manager/pkg/logger"
"license-manager/pkg/utils"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
@@ -38,19 +41,57 @@ func SetupRouter() *gin.Engine {
db := database.GetDB()
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)
paymentRepo := repository.NewPaymentRepository(db)
// 获取logger实例
log := logger.GetLogger()
// 初始化缓存
cacheInstance, err := cache.NewCache(cache.CacheConfig{
Type: cfg.Cache.Type,
TTL: cfg.Cache.TTL,
Enabled: cfg.Cache.Enabled,
Redis: cache.RedisConfig{
Host: cfg.Cache.Redis.Host,
Port: cfg.Cache.Redis.Port,
Password: cfg.Cache.Redis.Password,
DB: cfg.Cache.Redis.DB,
PoolSize: cfg.Cache.Redis.PoolSize,
MinIdleConns: cfg.Cache.Redis.MinIdleConns,
ConnMaxIdleTime: cfg.Cache.Redis.ConnMaxIdleTime,
DialTimeout: cfg.Cache.Redis.DialTimeout,
ReadTimeout: cfg.Cache.Redis.ReadTimeout,
WriteTimeout: cfg.Cache.Redis.WriteTimeout,
},
Memory: cache.MemoryConfig{
MaxSize: cfg.Cache.Memory.MaxSize,
},
})
if err != nil {
log.Fatalf("Failed to initialize cache: %v", err)
}
// 初始化SMS服务
smsService, err := utils.NewSMSService(&cfg.SMS, cacheInstance, log)
if err != nil {
log.Fatalf("Failed to initialize SMS service: %v", err)
}
// 初始化服务层
authService := service.NewAuthService(userRepo)
systemService := service.NewSystemService()
customerService := service.NewCustomerService(customerRepo)
cuUserService := service.NewCuUserService(cuUserRepo, customerRepo, smsService, db)
authCodeService := service.NewAuthorizationCodeService(authCodeRepo, customerRepo, cuUserRepo, licenseRepo)
cuOrderService := service.NewCuOrderService(cuOrderRepo, cuUserRepo, authCodeRepo, db)
cuDeviceService := service.NewCuDeviceService(licenseRepo)
paymentService := service.NewPaymentService(paymentRepo, cuOrderRepo, convertPaymentConfig(cfg.Payment), db)
enumService := service.NewEnumService()
authCodeService := service.NewAuthorizationCodeService(authCodeRepo, customerRepo, licenseRepo)
licenseService := service.NewLicenseService(licenseRepo, db, log)
dashboardService := service.NewDashboardService(dashboardRepo)
@@ -58,6 +99,12 @@ func SetupRouter() *gin.Engine {
authHandler := handlers.NewAuthHandler(authService)
systemHandler := handlers.NewSystemHandler(systemService)
customerHandler := handlers.NewCustomerHandler(customerService)
cuAuthHandler := handlers.NewCuAuthHandler(cuUserService)
cuProfileHandler := handlers.NewCuProfileHandler(cuUserService)
cuOrderHandler := handlers.NewCuOrderHandler(cuOrderService, paymentService)
cuDeviceHandler := handlers.NewCuDeviceHandler(cuDeviceService)
paymentHandler := handlers.NewPaymentHandler(paymentService)
cuAuthorizationHandler := handlers.NewCuAuthorizationHandler(authCodeService)
enumHandler := handlers.NewEnumHandler(enumService)
authCodeHandler := handlers.NewAuthorizationCodeHandler(authCodeService)
licenseHandler := handlers.NewLicenseHandler(licenseService)
@@ -80,6 +127,10 @@ func SetupRouter() *gin.Engine {
// 许可证激活接口(客户端软件使用)
public.POST("/v1/activate", licenseHandler.ActivateLicense)
public.POST("/v1/heartbeat", licenseHandler.Heartbeat)
// 支付回调接口
public.POST("/payment/alipay/callback", paymentHandler.AlipayCallback)
public.POST("/v1/payment/alipay/callback", paymentHandler.AlipayCallback)
}
// 需要认证的接口
@@ -135,5 +186,97 @@ func SetupRouter() *gin.Engine {
}
}
// 客户用户API路由组
cuGroup := router.Group("/api/cu")
{
// 公开接口(无需认证)
cuPublic := cuGroup.Group("")
{
cuPublic.POST("/register", cuAuthHandler.CuUserRegister)
cuPublic.POST("/login", cuAuthHandler.CuUserLogin)
cuPublic.POST("/send-login-sms", cuAuthHandler.CuUserSendLoginSms)
cuPublic.POST("/send-register-sms", cuAuthHandler.CuUserSendRegisterSms)
cuPublic.POST("/forgot-password", cuAuthHandler.CuUserForgotPassword)
cuPublic.POST("/reset-password", cuAuthHandler.CuUserResetPassword)
cuPublic.GET("/packages", cuOrderHandler.GetPackages)
}
// 需要认证的接口
cuAuth := cuGroup.Group("")
cuAuth.Use(middleware.CustomerAuth())
{
// 用户个人资料
cuAuth.GET("/profile", cuProfileHandler.GetCuUserProfile)
cuAuth.PUT("/profile", cuProfileHandler.UpdateCuUserProfile)
cuAuth.POST("/profile/send-current-phone-sms", cuProfileHandler.CuUserSendCurrentPhoneSms)
cuAuth.POST("/profile/send-new-phone-sms", cuProfileHandler.CuUserSendNewPhoneSms)
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)
cuAuth.GET("/orders/summary", cuOrderHandler.GetOrderSummary)
// 支付管理
cuAuth.GET("/payment/:payment_no/status", paymentHandler.GetPaymentStatus)
cuAuth.GET("/payment/history", paymentHandler.GetUserPayments)
// 授权码管理
cuAuth.POST("/authorization-codes/:codeId/share", cuAuthorizationHandler.ShareAuthorizationCode)
cuAuth.POST("/authorization-codes/product-activation-code", cuAuthorizationHandler.GetProductActivationCode)
// 设备管理
cuAuth.GET("/devices", cuDeviceHandler.GetDevices)
cuAuth.GET("/devices/summary", cuDeviceHandler.GetDeviceSummary)
cuAuth.DELETE("/devices/:id", cuDeviceHandler.UnbindDevice)
}
}
return router
}
// convertPaymentConfig 转换支付配置
func convertPaymentConfig(cfg config.PaymentConfig) *service.PaymentConfig {
fmt.Printf("convertPaymentConfig: cfg.DefaultMethod=%s, cfg.Providers is nil: %v\n", cfg.DefaultMethod, cfg.Providers == nil)
if cfg.Providers != nil {
fmt.Printf("convertPaymentConfig: Found %d providers\n", len(cfg.Providers))
for name, provider := range cfg.Providers {
fmt.Printf("convertPaymentConfig: Provider %s, Enabled=%v\n", name, provider.Enabled)
}
}
config := &service.PaymentConfig{
DefaultMethod: cfg.DefaultMethod,
ExpireMinutes: cfg.ExpireMinutes,
}
if cfg.Providers != nil {
config.Providers = make(map[string]*service.AlipayProviderConfig)
for name, provider := range cfg.Providers {
if provider.Enabled {
config.Providers[name] = &service.AlipayProviderConfig{
Enabled: provider.Enabled,
AppID: provider.AppID,
PrivateKey: provider.PrivateKey,
PublicKey: provider.PublicKey,
GatewayURL: provider.GatewayURL,
NotifyURL: provider.NotifyURL,
ReturnURL: provider.ReturnURL,
SignType: provider.SignType,
Charset: provider.Charset,
Format: provider.Format,
}
}
}
}
fmt.Printf("convertPaymentConfig: Final config.Providers is nil: %v\n", config.Providers == nil)
if config.Providers != nil {
fmt.Printf("convertPaymentConfig: Final config has %d providers\n", len(config.Providers))
}
return config
}
+115 -2
View File
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"log"
"time"
"github.com/spf13/viper"
)
@@ -14,6 +15,9 @@ type Config struct {
Log LogConfig `mapstructure:"log"`
I18n I18nConfig `mapstructure:"i18n"`
License LicenseConfig `mapstructure:"license"`
Payment PaymentConfig `mapstructure:"payment"`
SMS SMSConfig `mapstructure:"sms"`
Cache CacheConfig `mapstructure:"cache"`
}
type ServerConfig struct {
@@ -40,6 +44,7 @@ type DatabaseConfig struct {
type AuthConfig struct {
JWT JWTConfig `mapstructure:"jwt"`
CuJWT CuJWTConfig `mapstructure:"cu_jwt"` // 客户用户JWT配置
Security SecurityConfig `mapstructure:"security"`
}
@@ -49,6 +54,12 @@ type JWTConfig struct {
RefreshThresholdMinutes int `mapstructure:"refresh_threshold_minutes"`
}
type CuJWTConfig struct {
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"`
RefreshThresholdMinutes int `mapstructure:"refresh_threshold_minutes"`
}
type SecurityConfig struct {
MaxLoginAttempts int `mapstructure:"max_login_attempts"`
LockoutDurationMinutes int `mapstructure:"lockout_duration_minutes"`
@@ -69,7 +80,7 @@ type I18nConfig struct {
type LicenseConfig struct {
// RSA非对称加密配置
RSA RSAConfig `mapstructure:"rsa"`
HeartbeatTimeout int `mapstructure:"heartbeat_timeout"` // 心跳超时时间(秒)
OfflineTimeout int `mapstructure:"offline_timeout"` // 离线超时时间(分钟)
ExpiringDays int `mapstructure:"expiring_days"` // 即将过期天数
@@ -78,7 +89,72 @@ type LicenseConfig struct {
type RSAConfig struct {
PrivateKeyPath string `mapstructure:"private_key_path"` // RSA私钥文件路径
PublicKeyPath string `mapstructure:"public_key_path"` // RSA公钥文件路径
KeySize int `mapstructure:"key_size"` // 密钥大小(2048或4096),默认2048
KeySize int `mapstructure:"key_size"` // 密钥大小(2048或4096),默认2048
}
type PaymentConfig struct {
DefaultMethod string `mapstructure:"default_method"` // 默认支付方式
Providers map[string]*PaymentProvider `mapstructure:"providers"` // 支付提供商配置
ExpireMinutes int `mapstructure:"expire_minutes"` // 支付过期时间(分钟)
RetryTimes int `mapstructure:"retry_times"` // 支付重试次数
EnableLog bool `mapstructure:"enable_log"` // 启用支付日志
}
type PaymentProvider struct {
AppID string `mapstructure:"app_id"`
PrivateKey string `mapstructure:"private_key"`
PublicKey string `mapstructure:"public_key"`
GatewayURL string `mapstructure:"gateway_url"`
NotifyURL string `mapstructure:"notify_url"`
ReturnURL string `mapstructure:"return_url"`
SignType string `mapstructure:"sign_type"`
Charset string `mapstructure:"charset"`
Format string `mapstructure:"format"`
Enabled bool `mapstructure:"enabled"`
}
type CacheConfig struct {
Type string `mapstructure:"type"` // 缓存类型: memory, redis
TTL time.Duration `mapstructure:"ttl"` // 默认TTL
Enabled bool `mapstructure:"enabled"` // 是否启用缓存
Redis RedisConfig `mapstructure:"redis"` // Redis配置
Memory MemoryConfig `mapstructure:"memory"` // 内存配置
}
type RedisConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
PoolSize int `mapstructure:"pool_size"`
MinIdleConns int `mapstructure:"min_idle_conns"`
ConnMaxIdleTime time.Duration `mapstructure:"conn_max_idle_time"`
DialTimeout time.Duration `mapstructure:"dial_timeout"`
ReadTimeout time.Duration `mapstructure:"read_timeout"`
WriteTimeout time.Duration `mapstructure:"write_timeout"`
}
type MemoryConfig struct {
MaxSize int `mapstructure:"max_size"` // 最大缓存条目数
CleanupInterval time.Duration `mapstructure:"cleanup_interval"` // 清理间隔
}
type SMSConfig struct {
Enabled bool `mapstructure:"enabled"` // 是否启用短信服务
AccessKeyID string `mapstructure:"access_key_id"` // 阿里云AccessKey ID
AccessKeySecret string `mapstructure:"access_key_secret"` // 阿里云AccessKey Secret
SignName string `mapstructure:"sign_name"` // 短信签名
RegionID string `mapstructure:"region_id"` // 地域ID,默认cn-hangzhou
Endpoint string `mapstructure:"endpoint"` // 服务端点
Templates SMSTemplates `mapstructure:"templates"` // 模板配置
}
type SMSTemplates struct {
Register string `mapstructure:"register"` // 注册模板
ResetPwd string `mapstructure:"reset_pwd"` // 重置密码模板
Login string `mapstructure:"login"` // 登录模板
CurrentPhone string `mapstructure:"current_phone"` // 当前手机号模板
NewPhone string `mapstructure:"new_phone"` // 新手机号模板
}
var AppConfig *Config
@@ -139,6 +215,11 @@ func setDefaults() {
viper.SetDefault("auth.jwt.expire_hours", 1)
viper.SetDefault("auth.jwt.refresh_threshold_minutes", 30)
// Cu Auth defaults (客户用户)
viper.SetDefault("auth.cu_jwt.secret", "license-manager-cu-default-secret-key")
viper.SetDefault("auth.cu_jwt.expire_hours", 24)
viper.SetDefault("auth.cu_jwt.refresh_threshold_minutes", 30)
viper.SetDefault("auth.security.max_login_attempts", 5)
viper.SetDefault("auth.security.lockout_duration_minutes", 15)
@@ -160,6 +241,38 @@ func setDefaults() {
viper.SetDefault("license.heartbeat_timeout", 300)
viper.SetDefault("license.offline_timeout", 1440)
viper.SetDefault("license.expiring_days", 30)
// Payment defaults
viper.SetDefault("payment.default_method", "alipay")
viper.SetDefault("payment.expire_minutes", 30)
viper.SetDefault("payment.retry_times", 3)
viper.SetDefault("payment.enable_log", true)
// SMS defaults
viper.SetDefault("sms.enabled", false)
viper.SetDefault("sms.sign_name", "惠州顺视智能科技")
viper.SetDefault("sms.region_id", "cn-hangzhou")
viper.SetDefault("sms.endpoint", "dysmsapi.aliyuncs.com")
// Cache defaults
viper.SetDefault("cache.enabled", true)
viper.SetDefault("cache.type", "memory")
viper.SetDefault("cache.ttl", "30m")
// Redis cache defaults
viper.SetDefault("cache.redis.host", "localhost")
viper.SetDefault("cache.redis.port", 6379)
viper.SetDefault("cache.redis.db", 0)
viper.SetDefault("cache.redis.pool_size", 10)
viper.SetDefault("cache.redis.min_idle_conns", 5)
viper.SetDefault("cache.redis.conn_max_idle_time", "30m")
viper.SetDefault("cache.redis.dial_timeout", "5s")
viper.SetDefault("cache.redis.read_timeout", "3s")
viper.SetDefault("cache.redis.write_timeout", "3s")
// Memory cache defaults
viper.SetDefault("cache.memory.max_size", 10000)
viper.SetDefault("cache.memory.cleanup_interval", "10m")
}
func GetConfig() *Config {
+34 -9
View File
@@ -23,6 +23,9 @@ func AutoMigrate() error {
&models.Customer{},
&models.CustomerCodeSequence{},
&models.User{},
&models.CuUser{},
&models.CuOrder{},
&models.Payment{},
&models.AuthorizationCode{},
&models.License{},
&models.AuthorizationChange{},
@@ -31,6 +34,11 @@ func AutoMigrate() error {
return fmt.Errorf("failed to migrate database: %w", err)
}
// 创建客户用户表的复合索引(GORM不支持复合索引,需要手动创建)
if err := createCuUserIndexes(); err != nil {
return fmt.Errorf("failed to create cu_user indexes: %w", err)
}
// 初始化客户编码序列
if err := initCustomerCodeSequence(); err != nil {
return fmt.Errorf("failed to initialize customer code sequence: %w", err)
@@ -48,10 +56,10 @@ func AutoMigrate() error {
// initCustomerCodeSequence 初始化客户编码序列
func initCustomerCodeSequence() error {
currentYear := time.Now().Year()
var sequence models.CustomerCodeSequence
result := DB.Where("year = ?", currentYear).First(&sequence)
if result.Error != nil {
// 记录不存在,创建新记录
sequence = models.CustomerCodeSequence{
@@ -63,7 +71,7 @@ func initCustomerCodeSequence() error {
}
log.Printf("Initialized customer code sequence for year %d", currentYear)
}
return nil
}
@@ -82,18 +90,35 @@ func RunMigrationIfEnabled() error {
return nil
}
// createCuUserIndexes 创建客户用户表的复合索引
func createCuUserIndexes() error {
// 检查是否支持复合索引(MySQL等)
dbType := DB.Dialector.Name()
switch dbType {
case "mysql":
// 注意:所有必要的索引都已在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)
}
return nil
}
// initDefaultAdminUser 初始化默认管理员用户
func initDefaultAdminUser() error {
var user models.User
result := DB.Where("username = ?", "admin").First(&user)
if result.Error != nil {
// 用户不存在,创建默认管理员
passwordHash, err := utils.HashPassword("admin@123")
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
user = models.User{
Username: "admin",
Email: "admin@example.com",
@@ -102,15 +127,15 @@ func initDefaultAdminUser() error {
Role: "admin",
Status: "active",
}
if err := DB.Create(&user).Error; err != nil {
return fmt.Errorf("failed to create default admin user: %w", err)
}
log.Println("Created default admin user: admin / admin@123")
} else {
log.Println("Default admin user already exists")
}
return nil
}
}
+46 -13
View File
@@ -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"` // 客户名称显示(多语言)
@@ -170,18 +170,21 @@ type AuthorizationCodeListResponse struct {
// AuthorizationCodeUpdateRequest 更新授权码请求结构
type AuthorizationCodeUpdateRequest struct {
SoftwareID *string `json:"software_id" binding:"omitempty"` // 软件ID
Description *string `json:"description" binding:"omitempty,max=1000"` // 描述
ValidityDays *int `json:"validity_days" binding:"omitempty,min=1,max=365000"` // 有效天数(1-365000天,365000代表永久有效)
DeploymentType *string `json:"deployment_type" binding:"omitempty,oneof=standalone cloud hybrid"` // 部署类型:standalone/cloud/hybrid
EncryptionType *string `json:"encryption_type" binding:"omitempty,oneof=standard advanced"` // 加密类型:standard/advanced
SoftwareVersion *string `json:"software_version" binding:"omitempty"` // 软件版本
MaxActivations *int `json:"max_activations" binding:"omitempty,min=1"` // 最大激活次数
FeatureConfig interface{} `json:"feature_config" binding:"omitempty"` // 功能配置
UsageLimits interface{} `json:"usage_limits" binding:"omitempty"` // 使用限制
CustomParameters interface{} `json:"custom_parameters" binding:"omitempty"` // 自定义参数
ChangeType string `json:"change_type" binding:"required,oneof=renewal upgrade limit_change feature_toggle lock unlock other"` // 变更类型:renewal/upgrade/limit_change/feature_toggle/lock/unlock/other
Reason *string `json:"reason" binding:"omitempty,max=500"` // 变更原因
SoftwareID *string `json:"software_id" binding:"omitempty"` // 软件ID
Description *string `json:"description" binding:"omitempty,max=1000"` // 描述
ValidityDays *int `json:"validity_days" binding:"omitempty,min=1,max=365000"` // 有效天数(1-365000天,365000代表永久有效)
DeploymentType *string `json:"deployment_type" binding:"omitempty,oneof=standalone cloud hybrid"` // 部署类型:standalone/cloud/hybrid
EncryptionType *string `json:"encryption_type" binding:"omitempty,oneof=standard advanced"` // 加密类型:standard/advanced
SoftwareVersion *string `json:"software_version" binding:"omitempty"` // 软件版本
MaxActivations *int `json:"max_activations" binding:"omitempty,min=1"` // 最大激活次数
FeatureConfig interface{} `json:"feature_config" binding:"omitempty"` // 功能配置
UsageLimits interface{} `json:"usage_limits" binding:"omitempty"` // 使用限制
CustomParameters interface{} `json:"custom_parameters" binding:"omitempty"` // 自定义参数
// 可选的起止时间(优先于 validity_days),格式:YYYY-MM-DD
StartDate *string `json:"start_date" binding:"omitempty"` // 生效日期(YYYY-MM-DD)
EndDate *string `json:"end_date" binding:"omitempty"` // 失效日期(YYYY-MM-DD)
ChangeType string `json:"change_type" binding:"required,oneof=renewal feature_limit_change lock unlock other"` // 变更类型:renewal/upgrade/limit_change/feature_toggle/lock/unlock/other
Reason *string `json:"reason" binding:"omitempty,max=500"` // 变更原因
}
// AuthorizationCodeLockRequest 锁定/解锁授权码请求结构
@@ -257,3 +260,33 @@ type AuthorizationChangeListResponse struct {
PageSize int `json:"page_size"` // 每页条数
TotalPages int `json:"total_pages"` // 总页数
}
// AuthorizationCodeShareRequest 授权码分享请求结构
type AuthorizationCodeShareRequest struct {
TargetUserID string `json:"target_user_id" binding:"required"` // 受赠用户ID
ShareCount int `json:"share_count" binding:"required,min=1"` // 分享激活次数
}
// AuthorizationCodeShareResponse 授权码分享响应结构
type AuthorizationCodeShareResponse struct {
NewAuthorizationCode AuthorizationCodeShareResponseItem `json:"new_authorization_code"` // 新生成的授权码信息
}
// AuthorizationCodeShareResponseItem 分享响应中的授权码信息
type AuthorizationCodeShareResponseItem struct {
ID string `json:"id"` // 授权码ID
Code string `json:"code"` // 授权码
StartDate string `json:"start_date"` // 开始时间
EndDate string `json:"end_date"` // 结束时间
MaxActivations int `json:"max_activations"` // 最大激活次数
}
// ProductActivationCodeRequest 获取产品激活码请求结构
type ProductActivationCodeRequest struct {
AuthorizationCode string `json:"authorization_code" binding:"required"` // 授权码(旧规则格式)
}
// ProductActivationCodeResponse 获取产品激活码响应结构
type ProductActivationCodeResponse struct {
ProductActivationCode string `json:"product_activation_code"` // 产品激活码:{授权码}&{payload}
}
+120
View File
@@ -0,0 +1,120 @@
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"`
PaymentMethod string `json:"payment_method,omitempty"` // 可选:支付方式,不传则为免费订单 支持:alipay,wechat
}
// 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"`
}
// OrderSummaryResponse 订单汇总响应结构
type OrderSummaryResponse struct {
TotalOrders int64 `json:"total_orders"` // 订单总数
PendingOrders int64 `json:"pending_orders"` // 待支付订单数
PaidOrders int64 `json:"paid_orders"` // 已支付订单数
}
+215
View File
@@ -0,0 +1,215 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// CuUser 客户用户模型
type CuUser struct {
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
CustomerID string `gorm:"type:varchar(36);not null;index" json:"customer_id"`
Phone string `gorm:"type:varchar(20);not null" json:"phone"`
PhoneCountryCode string `gorm:"type:varchar(10);not null;default:'+86'" json:"phone_country_code"`
Password *string `gorm:"type:varchar(255)" json:"-"` // 不在JSON中显示密码
Salt *string `gorm:"type:varchar(32)" json:"-"`
UserRole string `gorm:"type:varchar(20);not null;default:'member'" json:"user_role"`
RealName *string `gorm:"type:varchar(100)" json:"real_name"`
Email *string `gorm:"type:varchar(255);index" json:"email"`
Status string `gorm:"type:varchar(20);not null;default:'active';index" json:"status"`
PhoneVerified bool `gorm:"not null;default:true" json:"phone_verified"`
EmailVerified bool `gorm:"not null;default:false" json:"email_verified"`
LastLoginAt *time.Time `gorm:"index" json:"last_login_at"`
LastLoginIP *string `gorm:"type:varchar(50)" json:"last_login_ip"`
LoginAttempts int `gorm:"not null;default:0" json:"login_attempts"`
LockedUntil *time.Time `gorm:"index" json:"locked_until"`
AvatarURL *string `gorm:"type:varchar(500)" json:"avatar_url"`
Language string `gorm:"type:varchar(10);not null;default:'zh-CN'" json:"language"`
Timezone string `gorm:"type:varchar(50);not null;default:'Asia/Shanghai'" json:"timezone"`
AdditionalInfo *string `gorm:"type:json" json:"additional_info"`
Remark *string `gorm:"type:text" json:"remark"`
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 (CuUser) TableName() string {
return "cu_users"
}
// BeforeCreate 创建前自动设置时间戳和ID
func (u *CuUser) BeforeCreate(tx *gorm.DB) error {
// 生成UUID作为主键ID
if u.ID == "" {
u.ID = uuid.New().String()
}
// 设置时间戳
now := time.Now()
if u.CreatedAt.IsZero() {
u.CreatedAt = now
}
if u.UpdatedAt.IsZero() {
u.UpdatedAt = now
}
return nil
}
// BeforeUpdate 更新前自动刷新更新时间
func (u *CuUser) BeforeUpdate(tx *gorm.DB) error {
u.UpdatedAt = time.Now()
return nil
}
// IsAccountLocked 检查账号是否被锁定
func (u *CuUser) IsAccountLocked() bool {
if u.Status == "locked" {
return true
}
if u.LockedUntil != nil && time.Now().Before(*u.LockedUntil) {
return true
}
return false
}
// CuUserRegisterRequest 客户用户注册请求结构
type CuUserRegisterRequest struct {
Phone string `json:"phone" binding:"required,len=11"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
SmsCode string `json:"sms_code" binding:"required,len=6"`
CustomerID string `json:"customer_id" binding:"omitempty"` // 可选,不提供则自动创建客户
Password string `json:"password" binding:"required,min=8,max=50"`
RealName *string `json:"real_name" binding:"omitempty,max=100"`
Email *string `json:"email" binding:"omitempty,email,max=255"`
}
// CuUserLoginRequest 客户用户登录请求结构
type CuUserLoginRequest struct {
Phone string `json:"phone" binding:"required,min=7,max=20"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
Password string `json:"password"` // 密码登录时必填
SmsCode string `json:"sms_code"` // 验证码登录时必填
LoginType string `json:"login_type" binding:"required,oneof=password sms"` // 登录类型:password 或 sms
}
// CuUserProfileUpdateRequest 客户用户资料更新请求结构
type CuUserProfileUpdateRequest struct {
RealName *string `json:"real_name" binding:"omitempty,max=100"`
Email *string `json:"email" binding:"omitempty,email,max=255"`
AvatarURL *string `json:"avatar_url" binding:"omitempty,max=500"`
Language *string `json:"language" binding:"omitempty,oneof=zh-CN en-US ja-JP"`
Timezone *string `json:"timezone" binding:"omitempty,max=50"`
AdditionalInfo *string `json:"additional_info" binding:"omitempty"`
Remark *string `json:"remark" binding:"omitempty,max=1000"`
}
// CuUserPhoneUpdateRequest 客户用户手机号更新请求结构
type CuUserPhoneUpdateRequest struct {
NewPhone string `json:"new_phone" binding:"required,len=11"`
NewPhoneCountryCode string `json:"new_phone_country_code" binding:"required"`
CurrentSmsCode string `json:"current_sms_code" binding:"required,len=6"` // 当前手机号验证码
NewSmsCode string `json:"new_sms_code" binding:"required,len=6"` // 新手机号验证码
}
// CuUserSendRegisterSmsRequest 注册发送验证码请求结构
type CuUserSendRegisterSmsRequest struct {
Phone string `json:"phone" binding:"required,min=7,max=20"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
}
// CuUserSendLoginSmsRequest 发送登录验证码请求结构
type CuUserSendLoginSmsRequest struct {
Phone string `json:"phone" binding:"required,min=7,max=20"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
}
// CuUserSendCurrentPhoneSmsRequest 发送当前手机号验证码请求结构
type CuUserSendCurrentPhoneSmsRequest struct {
// 无需额外参数,从JWT token中获取当前用户手机号
}
// CuUserSendNewPhoneSmsRequest 发送新手机号验证码请求结构
type CuUserSendNewPhoneSmsRequest struct {
NewPhone string `json:"new_phone" binding:"required,min=7,max=20"`
NewPhoneCountryCode string `json:"new_phone_country_code" binding:"omitempty"` // 可选,默认+86
}
// CuUserForgotPasswordRequest 忘记密码请求结构(发送重置验证码)
type CuUserForgotPasswordRequest struct {
Phone string `json:"phone" binding:"required,min=7,max=20"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
}
// CuUserResetPasswordRequest 重置密码请求结构
type CuUserResetPasswordRequest struct {
Phone string `json:"phone" binding:"required,min=7,max=20"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
SmsCode string `json:"sms_code" binding:"required,len=6"`
NewPassword string `json:"new_password" binding:"required,min=8,max=50"`
}
// CuUserChangePasswordRequest 修改密码请求结构
type CuUserChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8,max=50"`
}
// CuUserResponse 用户信息响应结构(用于API,避免暴露敏感字段)
type CuUserResponse struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
Phone string `json:"phone"`
PhoneCountryCode string `json:"phone_country_code"`
UserRole string `json:"user_role"`
RealName *string `json:"real_name"`
Email *string `json:"email"`
Status string `json:"status"`
PhoneVerified bool `json:"phone_verified"`
EmailVerified bool `json:"email_verified"`
LastLoginAt *string `json:"last_login_at,omitempty"`
LastLoginIP *string `json:"last_login_ip,omitempty"`
AvatarURL *string `json:"avatar_url"`
Language string `json:"language"`
Timezone string `json:"timezone"`
AdditionalInfo *string `json:"additional_info"`
Remark *string `json:"remark"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ToResponse 转换为响应结构
func (u *CuUser) ToResponse() *CuUserResponse {
response := &CuUserResponse{
ID: u.ID,
CustomerID: u.CustomerID,
Phone: u.Phone,
PhoneCountryCode: u.PhoneCountryCode,
UserRole: u.UserRole,
RealName: u.RealName,
Email: u.Email,
Status: u.Status,
PhoneVerified: u.PhoneVerified,
EmailVerified: u.EmailVerified,
AvatarURL: u.AvatarURL,
Language: u.Language,
Timezone: u.Timezone,
AdditionalInfo: u.AdditionalInfo,
Remark: u.Remark,
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: u.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
// 格式化时间字段
if u.LastLoginAt != nil {
timeStr := u.LastLoginAt.Format("2006-01-02T15:04:05Z07:00")
response.LastLoginAt = &timeStr
}
if u.LastLoginIP != nil {
response.LastLoginIP = u.LastLoginIP
}
return response
}
+43
View File
@@ -183,3 +183,46 @@ type GrowthRate struct {
AuthCodes float64 `json:"auth_codes"` // 授权码增长率(%)(当前总授权码数相比一个月前总授权码数的增长率)
Licenses float64 `json:"licenses"` // 许可证增长率(%)(当前活跃许可证数相比一个月前活跃许可证数的增长率)
}
// DeviceListRequest 设备列表查询请求结构(客户用户接口)
type DeviceListRequest struct {
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页数量,默认20,最大100
DeviceName string `form:"device_name" binding:"omitempty,max=100"` // 设备名称模糊搜索(匹配device_info.name)
AuthorizationCodeID string `form:"authorization_code_id" binding:"omitempty,len=36"` // 按授权码ID筛选设备
IsOnline *bool `form:"is_online" binding:"omitempty"` // 是否在线筛选(true在线,false离线;不传不过滤)
}
// DeviceListItem 设备列表项结构(客户用户接口)
type DeviceListItem struct {
ID string `json:"id"` // 许可证ID
DeviceInfo map[string]interface{} `json:"device_info"` // 设备信息
IsOnline bool `json:"is_online"` // 是否在线
LastOnlineIP *string `json:"last_online_ip"` // 最后在线IP
LastHeartbeat *string `json:"last_heartbeat"` // 最后心跳时间
ActivatedAt *string `json:"activated_at"` // 激活时间
AuthorizationInfo AuthorizationInfo `json:"authorization_info"` // 授权信息
}
// AuthorizationInfo 授权信息结构
type AuthorizationInfo struct {
AuthorizationCode string `json:"authorization_code"` // 授权码
AuthorizationCodeID string `json:"authorization_code_id"` // 授权码ID
EndDate string `json:"end_date"` // 授权到期时间
Description string `json:"description"` // 授权描述
}
// DeviceListResponse 设备列表响应结构(客户用户接口)
type DeviceListResponse struct {
List []DeviceListItem `json:"list"` // 设备列表
Total int64 `json:"total"` // 总数量
Page int `json:"page"` // 当前页码
PageSize int `json:"page_size"` // 每页数量
}
// DeviceSummaryResponse 设备汇总响应结构
type DeviceSummaryResponse struct {
TotalDevices int64 `json:"total_devices"` // 设备总数
OnlineDevices int64 `json:"online_devices"` // 在线设备数
OfflineDevices int64 `json:"offline_devices"` // 离线设备数
}
+157
View File
@@ -0,0 +1,157 @@
package models
import (
"time"
"gorm.io/gorm"
)
// Payment 支付订单模型
type Payment struct {
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
PaymentNo string `gorm:"type:varchar(64);uniqueIndex;not null" json:"payment_no"`
BusinessType string `gorm:"type:varchar(50);not null;index:business_type_id" json:"business_type"`
BusinessID *string `gorm:"type:varchar(36);index:business_type_id" json:"business_id"`
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"`
Amount float64 `gorm:"type:decimal(10,2);not null" json:"amount"`
Currency string `gorm:"type:varchar(3);default:'CNY'" json:"currency"`
PaymentMethod string `gorm:"type:varchar(20);default:'alipay'" json:"payment_method"`
PaymentProvider string `gorm:"type:varchar(20);default:'alipay'" json:"payment_provider"`
Status string `gorm:"type:varchar(20);not null;default:'pending'" json:"status"`
TradeNo *string `gorm:"type:varchar(64)" json:"trade_no"`
PaymentTime *time.Time `gorm:"type:datetime(3)" json:"payment_time"`
ExpireTime time.Time `gorm:"type:datetime(3);not null" json:"expire_time"`
PaymentURL *string `gorm:"type:text" json:"payment_url"`
NotifyData *string `gorm:"type:json" json:"notify_data"`
ExtraData *string `gorm:"type:json" json:"extra_data"`
CreatedAt time.Time `gorm:"type:datetime(3);not null;index" json:"created_at"`
UpdatedAt time.Time `gorm:"type:datetime(3);not null" json:"updated_at"`
}
// TableName 指定表名
func (Payment) TableName() string {
return "payments"
}
// BeforeCreate 创建前自动设置时间戳
func (p *Payment) BeforeCreate(tx *gorm.DB) error {
now := time.Now()
if p.CreatedAt.IsZero() {
p.CreatedAt = now
}
if p.UpdatedAt.IsZero() {
p.UpdatedAt = now
}
return nil
}
// BeforeUpdate 更新前自动设置时间戳
func (p *Payment) BeforeUpdate(tx *gorm.DB) error {
p.UpdatedAt = time.Now()
return nil
}
// PaymentCreateRequest 创建支付请求结构
type PaymentCreateRequest struct {
BusinessType string `json:"business_type" binding:"required"`
BusinessID *string `json:"business_id"`
CustomerID string `json:"customer_id" binding:"required"`
CuUserID string `json:"cu_user_id" binding:"required"`
Amount float64 `json:"amount" binding:"required,min=0.01"`
Currency string `json:"currency"`
PaymentMethod string `json:"payment_method"`
}
// PaymentResponse 支付响应结构
type PaymentResponse struct {
ID int `json:"id"`
PaymentNo string `json:"payment_no"`
BusinessType string `json:"business_type"`
BusinessID *string `json:"business_id"`
CustomerID string `json:"customer_id"`
CuUserID string `json:"cu_user_id"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
PaymentMethod string `json:"payment_method"`
PaymentProvider string `json:"payment_provider"`
Status string `json:"status"`
TradeNo *string `json:"trade_no"`
PaymentTime *time.Time `json:"payment_time"`
ExpireTime time.Time `json:"expire_time"`
PaymentURL *string `json:"payment_url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ToResponse 转换为响应结构
func (p *Payment) ToResponse() *PaymentResponse {
return &PaymentResponse{
ID: p.ID,
PaymentNo: p.PaymentNo,
BusinessType: p.BusinessType,
BusinessID: p.BusinessID,
CustomerID: p.CustomerID,
CuUserID: p.CuUserID,
Amount: p.Amount,
Currency: p.Currency,
PaymentMethod: p.PaymentMethod,
PaymentProvider: p.PaymentProvider,
Status: p.Status,
TradeNo: p.TradeNo,
PaymentTime: p.PaymentTime,
ExpireTime: p.ExpireTime,
PaymentURL: p.PaymentURL,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
}
}
// PaymentStatusResponse 支付状态响应结构
type PaymentStatusResponse struct {
PaymentNo string `json:"payment_no"`
Status string `json:"status"`
Amount float64 `json:"amount"`
PaymentTime *time.Time `json:"payment_time"`
TradeNo *string `json:"trade_no"`
BusinessOrder *PaymentBusinessOrder `json:"business_order,omitempty"`
}
// PaymentBusinessOrder 支付关联的业务订单信息
type PaymentBusinessOrder struct {
OrderNo string `json:"order_no"`
AuthorizationCode *string `json:"authorization_code"`
Status string `json:"status"`
}
// PaymentListResponse 支付列表响应结构
type PaymentListResponse struct {
Payments []*PaymentResponse `json:"payments"`
TotalCount int64 `json:"total_count"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// 业务类型常量
const (
BusinessTypePackageOrder = "package_order" // 套餐购买订单
BusinessTypeLicenseExtend = "license_extend" // 许可延期
BusinessTypeServiceUpgrade = "service_upgrade" // 服务升级
)
// 支付状态常量
const (
PaymentStatusPending = "pending" // 待支付
PaymentStatusPaid = "paid" // 已支付
PaymentStatusCancelled = "cancelled" // 已取消
PaymentStatusExpired = "expired" // 已过期
PaymentStatusFailed = "failed" // 支付失败
PaymentStatusRefunded = "refunded" // 已退款
)
// 支付方式常量
const (
PaymentMethodAlipay = "alipay" // 支付宝
PaymentMethodWechat = "wechat" // 微信支付
PaymentMethodBank = "bank" // 银行卡
)
@@ -297,3 +297,24 @@ func (r *authorizationCodeRepository) GetAuthorizationChangeList(ctx context.Con
func (r *authorizationCodeRepository) RecordAuthorizationChange(ctx context.Context, change *models.AuthorizationChange) error {
return r.db.WithContext(ctx).Create(change).Error
}
// BeginTransaction 开始事务
func (r *authorizationCodeRepository) BeginTransaction(ctx context.Context) interface{} {
return r.db.WithContext(ctx).Begin()
}
// CreateAuthorizationCodeWithTx 在事务中创建授权码
func (r *authorizationCodeRepository) CreateAuthorizationCodeWithTx(ctx context.Context, tx interface{}, authCode *models.AuthorizationCode) error {
if gormTx, ok := tx.(*gorm.DB); ok {
return gormTx.WithContext(ctx).Create(authCode).Error
}
return ErrInvalidTransaction
}
// UpdateMaxActivationsWithTx 在事务中更新授权码的最大激活次数
func (r *authorizationCodeRepository) UpdateMaxActivationsWithTx(ctx context.Context, tx interface{}, authCodeID string, newMaxActivations int) error {
if gormTx, ok := tx.(*gorm.DB); ok {
return gormTx.WithContext(ctx).Model(&models.AuthorizationCode{}).Where("id = ?", authCodeID).Update("max_activations", newMaxActivations).Error
}
return ErrInvalidTransaction
}
@@ -0,0 +1,151 @@
package repository
import (
"context"
"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)
GetCustomerOrderSummary(ctx context.Context, customerID string) (*models.OrderSummaryResponse, 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
}
// GetCustomerOrderSummary 获取客户订单汇总统计
func (r *cuOrderRepository) GetCustomerOrderSummary(ctx context.Context, customerID string) (*models.OrderSummaryResponse, error) {
// 查询订单总数
var totalOrders int64
err := r.db.Model(&models.CuOrder{}).
Where("customer_id = ?", customerID).
Count(&totalOrders).Error
if err != nil {
return nil, err
}
// 查询待支付订单数
var pendingOrders int64
err = r.db.Model(&models.CuOrder{}).
Where("customer_id = ? AND status = ?", customerID, "pending").
Count(&pendingOrders).Error
if err != nil {
return nil, err
}
// 查询已支付订单数
var paidOrders int64
err = r.db.Model(&models.CuOrder{}).
Where("customer_id = ? AND status = ?", customerID, "paid").
Count(&paidOrders).Error
if err != nil {
return nil, err
}
return &models.OrderSummaryResponse{
TotalOrders: totalOrders,
PendingOrders: pendingOrders,
PaidOrders: paidOrders,
}, nil
}
@@ -0,0 +1,139 @@
package repository
import (
"errors"
"license-manager/internal/models"
"gorm.io/gorm"
)
type CuUserRepository interface {
Create(user *models.CuUser) error
GetByID(id string) (*models.CuUser, error)
GetByPhone(phone, countryCode string) (*models.CuUser, error)
GetByEmail(email string) (*models.CuUser, error)
GetByCustomerID(customerID string, offset, limit int) ([]*models.CuUser, int64, error)
Update(user *models.CuUser) error
Delete(id string) error
UpdateLoginInfo(id, ip string) error
IncrementLoginAttempts(id string) error
ResetLoginAttempts(id string) error
LockAccount(id string, until interface{}) error
CheckPhoneExists(phone, countryCode string, excludeID string) (bool, error)
CheckEmailExists(email string, excludeID string) (bool, error)
}
type cuUserRepository struct {
db *gorm.DB
}
func NewCuUserRepository(db *gorm.DB) CuUserRepository {
return &cuUserRepository{db: db}
}
func (r *cuUserRepository) Create(user *models.CuUser) error {
return r.db.Create(user).Error
}
func (r *cuUserRepository) GetByID(id string) (*models.CuUser, error) {
var user models.CuUser
err := r.db.Where("id = ? AND deleted_at IS NULL", id).First(&user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, gorm.ErrRecordNotFound
}
return nil, err
}
return &user, nil
}
func (r *cuUserRepository) GetByPhone(phone, countryCode string) (*models.CuUser, error) {
var user models.CuUser
err := r.db.Where("phone = ? AND phone_country_code = ? AND deleted_at IS NULL", phone, countryCode).First(&user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, gorm.ErrRecordNotFound
}
return nil, err
}
return &user, nil
}
func (r *cuUserRepository) GetByEmail(email string) (*models.CuUser, error) {
var user models.CuUser
err := r.db.Where("email = ? AND deleted_at IS NULL", email).First(&user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, gorm.ErrRecordNotFound
}
return nil, err
}
return &user, nil
}
func (r *cuUserRepository) GetByCustomerID(customerID string, offset, limit int) ([]*models.CuUser, int64, error) {
var users []*models.CuUser
var total int64
query := r.db.Model(&models.CuUser{}).Where("customer_id = ? AND deleted_at IS NULL", customerID)
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据
if err := query.Offset(offset).Limit(limit).Order("created_at DESC").Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
func (r *cuUserRepository) Update(user *models.CuUser) error {
return r.db.Save(user).Error
}
func (r *cuUserRepository) Delete(id string) error {
return r.db.Where("id = ?", id).Delete(&models.CuUser{}).Error
}
func (r *cuUserRepository) UpdateLoginInfo(id, ip string) error {
return r.db.Model(&models.CuUser{}).Where("id = ?", id).Updates(map[string]interface{}{
"last_login_at": gorm.Expr("NOW()"),
"last_login_ip": ip,
}).Error
}
func (r *cuUserRepository) IncrementLoginAttempts(id string) error {
return r.db.Model(&models.CuUser{}).Where("id = ?", id).UpdateColumn("login_attempts", gorm.Expr("login_attempts + 1")).Error
}
func (r *cuUserRepository) ResetLoginAttempts(id string) error {
return r.db.Model(&models.CuUser{}).Where("id = ?", id).Update("login_attempts", 0).Error
}
func (r *cuUserRepository) LockAccount(id string, until interface{}) error {
return r.db.Model(&models.CuUser{}).Where("id = ?", id).Update("locked_until", until).Error
}
func (r *cuUserRepository) CheckPhoneExists(phone, countryCode string, excludeID string) (bool, error) {
var count int64
query := r.db.Model(&models.CuUser{}).Where("phone = ? AND phone_country_code = ? AND deleted_at IS NULL", phone, countryCode)
if excludeID != "" {
query = query.Where("id != ?", excludeID)
}
err := query.Count(&count).Error
return count > 0, err
}
func (r *cuUserRepository) CheckEmailExists(email string, excludeID string) (bool, error) {
var count int64
query := r.db.Model(&models.CuUser{}).Where("email = ? AND deleted_at IS NULL", email)
if excludeID != "" {
query = query.Where("id != ?", excludeID)
}
err := query.Count(&count).Error
return count > 0, err
}
+1
View File
@@ -30,6 +30,7 @@ var (
ErrDatabaseCreate = errors.New("database create failed")
ErrDatabaseUpdate = errors.New("database update failed")
ErrDatabaseDelete = errors.New("database delete failed")
ErrInvalidTransaction = errors.New("invalid transaction object")
)
// 辅助函数:判断错误类型
+52
View File
@@ -82,6 +82,16 @@ type AuthorizationCodeRepository interface {
// RecordAuthorizationChange 记录授权变更历史
RecordAuthorizationChange(ctx context.Context, change *models.AuthorizationChange) error
// 事务相关方法
// BeginTransaction 开始事务
BeginTransaction(ctx context.Context) interface{}
// CreateAuthorizationCodeWithTx 在事务中创建授权码
CreateAuthorizationCodeWithTx(ctx context.Context, tx interface{}, authCode *models.AuthorizationCode) error
// UpdateMaxActivationsWithTx 在事务中更新授权码的最大激活次数
UpdateMaxActivationsWithTx(ctx context.Context, tx interface{}, authCodeID string, newMaxActivations int) error
}
// LicenseRepository 许可证数据访问接口
@@ -112,6 +122,18 @@ type LicenseRepository interface {
// GetActiveLicenseCount 获取指定授权码的激活许可证数量
GetActiveLicenseCount(ctx context.Context, authCodeID string) (int64, error)
// GetCustomerDeviceList 查询客户设备列表(关联授权码信息)
GetCustomerDeviceList(ctx context.Context, customerID string, req *models.DeviceListRequest) (*models.DeviceListResponse, error)
// GetCustomerDeviceSummary 获取客户设备汇总统计
GetCustomerDeviceSummary(ctx context.Context, customerID string) (*models.DeviceSummaryResponse, error)
// DeleteLicenseByID 根据ID删除许可证(物理删除,用于设备解绑)
DeleteLicenseByID(ctx context.Context, id string) error
// CheckLicenseBelongsToCustomer 检查许可证是否属于指定客户
CheckLicenseBelongsToCustomer(ctx context.Context, licenseID, customerID string) (bool, error)
}
// DashboardRepository 仪表盘数据访问接口
@@ -122,3 +144,33 @@ type DashboardRepository interface {
// GetRecentAuthorizations 获取最近授权列表
GetRecentAuthorizations(ctx context.Context, req *models.DashboardRecentAuthorizationsRequest) (*models.DashboardRecentAuthorizationsResponse, error)
}
// PaymentRepository 支付数据访问接口
type PaymentRepository interface {
// Create 创建支付订单
Create(ctx context.Context, payment *models.Payment) error
// GetByID 根据ID获取支付订单
GetByID(ctx context.Context, id int) (*models.Payment, error)
// GetByPaymentNo 根据支付单号获取支付订单
GetByPaymentNo(ctx context.Context, paymentNo string) (*models.Payment, error)
// GetByBusinessID 根据业务ID获取支付订单
GetByBusinessID(ctx context.Context, businessType, businessID string) (*models.Payment, error)
// GetByCustomerAndCuUserID 根据客户ID和客户用户ID获取支付订单列表
GetByCustomerAndCuUserID(ctx context.Context, customerID, cuUserID string, offset, limit int) ([]*models.Payment, int64, error)
// Update 更新支付订单
Update(ctx context.Context, payment *models.Payment) error
// UpdateStatus 更新支付状态
UpdateStatus(ctx context.Context, paymentNo, status string, tradeNo *string, paymentTime *string) error
// Delete 删除支付订单
Delete(ctx context.Context, id int) error
// GetExpiredPayments 获取过期的支付订单
GetExpiredPayments(ctx context.Context) ([]*models.Payment, error)
}
@@ -7,6 +7,7 @@ import (
"gorm.io/gorm"
"license-manager/internal/config"
"license-manager/internal/models"
)
@@ -155,10 +156,10 @@ func (r *licenseRepository) GetLicenseList(ctx context.Context, req *models.Lice
// GetLicenseByID 根据ID获取许可证信息
func (r *licenseRepository) GetLicenseByID(ctx context.Context, id string) (*models.License, error) {
var license models.License
err := r.db.Preload("AuthorizationCode").Preload("Customer").
Where("id = ?", id).First(&license).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrLicenseNotFound
@@ -224,10 +225,10 @@ func (r *licenseRepository) GetAuthorizationCodeByCode(ctx context.Context, code
// GetLicenseByKey 根据许可证密钥获取许可证信息
func (r *licenseRepository) GetLicenseByKey(ctx context.Context, licenseKey string) (*models.License, error) {
var license models.License
err := r.db.Preload("AuthorizationCode").Preload("Customer").
Where("license_key = ?", licenseKey).First(&license).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrLicenseNotFound
@@ -251,4 +252,195 @@ func (r *licenseRepository) GetActiveLicenseCount(ctx context.Context, authCodeI
Where("authorization_code_id = ? AND status = ?", authCodeID, "active").
Count(&count).Error
return count, err
}
}
// GetCustomerDeviceList 查询客户设备列表(关联授权码信息)
func (r *licenseRepository) GetCustomerDeviceList(ctx context.Context, customerID string, req *models.DeviceListRequest) (*models.DeviceListResponse, error) {
// 设置默认值
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > 100 {
req.PageSize = 100
}
// 构建查询
query := r.db.Model(&models.License{}).
Select(`licenses.id, licenses.device_info, licenses.last_online_ip,
licenses.activated_at, licenses.last_heartbeat,
authorization_codes.code as authorization_code,
authorization_codes.id as authorization_code_id,
authorization_codes.end_date, authorization_codes.description`).
Joins("LEFT JOIN authorization_codes ON licenses.authorization_code_id = authorization_codes.id").
Where("licenses.customer_id = ? AND licenses.status = ? AND licenses.deleted_at IS NULL",
customerID, "active")
// 获取心跳超时配置(秒)
cfg := config.GetConfig()
heartbeatTimeoutSeconds := cfg.License.HeartbeatTimeout
if heartbeatTimeoutSeconds <= 0 {
heartbeatTimeoutSeconds = 300 // 默认5分钟
}
now := time.Now()
onlineThreshold := now.Add(-time.Duration(heartbeatTimeoutSeconds) * time.Second)
// 按授权码ID筛选
if req.AuthorizationCodeID != "" {
query = query.Where("licenses.authorization_code_id = ?", req.AuthorizationCodeID)
}
// 设备名称模糊搜索
if req.DeviceName != "" {
query = query.Where("JSON_EXTRACT(licenses.device_info, '$.name') LIKE ?", "%"+req.DeviceName+"%")
}
// 在线状态筛选
if req.IsOnline != nil {
if *req.IsOnline {
query = query.Where("licenses.last_heartbeat > ?", onlineThreshold)
} else {
query = query.Where("(licenses.last_heartbeat <= ? OR licenses.last_heartbeat IS NULL)", onlineThreshold)
}
}
// 获取总数
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
// 定义扫描结构体
type deviceScanResult struct {
ID string `json:"id"`
DeviceInfo string `json:"device_info"`
LastOnlineIP *string `json:"last_online_ip"`
ActivatedAt *time.Time `json:"activated_at"`
LastHeartbeat *time.Time `json:"last_heartbeat"`
AuthorizationCode string `json:"authorization_code"`
AuthorizationCodeID string `json:"authorization_code_id"`
EndDate time.Time `json:"end_date"`
Description *string `json:"description"`
}
// 分页查询
offset := (req.Page - 1) * req.PageSize
var scanResults []deviceScanResult
err := query.Order("licenses.activated_at DESC").
Offset(offset).Limit(req.PageSize).
Scan(&scanResults).Error
if err != nil {
return nil, err
}
devices := make([]models.DeviceListItem, len(scanResults))
for i, result := range scanResults {
// 解析device_info JSON
var deviceInfo map[string]interface{}
if result.DeviceInfo != "" {
// 这里需要JSON解析,暂时简化处理
deviceInfo = map[string]interface{}{"raw": result.DeviceInfo}
}
// 计算在线状态(使用心跳超时配置)
isOnline := false
if result.LastHeartbeat != nil && result.LastHeartbeat.After(onlineThreshold) {
isOnline = true
}
// 格式化时间字段
var activatedAtStr, lastHeartbeatStr *string
if result.ActivatedAt != nil {
str := result.ActivatedAt.Format("2006-01-02T15:04:05Z")
activatedAtStr = &str
}
if result.LastHeartbeat != nil {
str := result.LastHeartbeat.Format("2006-01-02T15:04:05Z")
lastHeartbeatStr = &str
}
// 构建授权信息
description := ""
if result.Description != nil {
description = *result.Description
}
devices[i] = models.DeviceListItem{
ID: result.ID,
DeviceInfo: deviceInfo,
IsOnline: isOnline,
LastOnlineIP: result.LastOnlineIP,
LastHeartbeat: lastHeartbeatStr,
ActivatedAt: activatedAtStr,
AuthorizationInfo: models.AuthorizationInfo{
AuthorizationCode: result.AuthorizationCode,
AuthorizationCodeID: result.AuthorizationCodeID,
EndDate: result.EndDate.Format("2006-01-02T15:04:05Z"),
Description: description,
},
}
}
return &models.DeviceListResponse{
List: devices,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}, nil
}
// DeleteLicenseByID 根据ID删除许可证(物理删除,用于设备解绑)
func (r *licenseRepository) DeleteLicenseByID(ctx context.Context, id string) error {
return r.db.Unscoped().Delete(&models.License{}, "id = ?", id).Error
}
// GetCustomerDeviceSummary 获取客户设备汇总统计
func (r *licenseRepository) GetCustomerDeviceSummary(ctx context.Context, customerID string) (*models.DeviceSummaryResponse, error) {
// 获取心跳超时配置(秒)
cfg := config.GetConfig()
heartbeatTimeoutSeconds := cfg.License.HeartbeatTimeout
if heartbeatTimeoutSeconds <= 0 {
heartbeatTimeoutSeconds = 300 // 默认5分钟
}
// 查询设备总数
var totalDevices int64
err := r.db.Model(&models.License{}).
Where("customer_id = ? AND status = ? AND deleted_at IS NULL", customerID, "active").
Count(&totalDevices).Error
if err != nil {
return nil, err
}
// 查询在线设备数
var onlineDevices int64
err = r.db.Model(&models.License{}).
Where("customer_id = ? AND status = ? AND deleted_at IS NULL AND last_heartbeat > ?",
customerID, "active", time.Now().Add(-time.Duration(heartbeatTimeoutSeconds)*time.Second)).
Count(&onlineDevices).Error
if err != nil {
return nil, err
}
// 计算离线设备数
offlineDevices := totalDevices - onlineDevices
return &models.DeviceSummaryResponse{
TotalDevices: totalDevices,
OnlineDevices: onlineDevices,
OfflineDevices: offlineDevices,
}, nil
}
// CheckLicenseBelongsToCustomer 检查许可证是否属于指定客户
func (r *licenseRepository) CheckLicenseBelongsToCustomer(ctx context.Context, licenseID, customerID string) (bool, error) {
var count int64
err := r.db.Model(&models.License{}).
Where("id = ? AND customer_id = ? AND deleted_at IS NULL", licenseID, customerID).
Count(&count).Error
return count > 0, err
}
@@ -0,0 +1,102 @@
package repository
import (
"context"
"license-manager/internal/models"
"gorm.io/gorm"
)
type paymentRepository struct {
db *gorm.DB
}
func NewPaymentRepository(db *gorm.DB) PaymentRepository {
return &paymentRepository{db: db}
}
func (r *paymentRepository) Create(ctx context.Context, payment *models.Payment) error {
return r.db.WithContext(ctx).Create(payment).Error
}
func (r *paymentRepository) GetByID(ctx context.Context, id int) (*models.Payment, error) {
var payment models.Payment
err := r.db.WithContext(ctx).Where("id = ?", id).First(&payment).Error
if err != nil {
return nil, err
}
return &payment, nil
}
func (r *paymentRepository) GetByPaymentNo(ctx context.Context, paymentNo string) (*models.Payment, error) {
var payment models.Payment
err := r.db.WithContext(ctx).Where("payment_no = ?", paymentNo).First(&payment).Error
if err != nil {
return nil, err
}
return &payment, nil
}
func (r *paymentRepository) GetByBusinessID(ctx context.Context, businessType, businessID string) (*models.Payment, error) {
var payment models.Payment
err := r.db.WithContext(ctx).Where("business_type = ? AND business_id = ?", businessType, businessID).First(&payment).Error
if err != nil {
return nil, err
}
return &payment, nil
}
func (r *paymentRepository) GetByCustomerAndCuUserID(ctx context.Context, customerID, cuUserID string, offset, limit int) ([]*models.Payment, int64, error) {
var payments []*models.Payment
var total int64
query := r.db.WithContext(ctx).Model(&models.Payment{}).Where("customer_id = ? AND cu_user_id = ?", customerID, cuUserID)
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据
err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&payments).Error
if err != nil {
return nil, 0, err
}
return payments, total, nil
}
func (r *paymentRepository) Update(ctx context.Context, payment *models.Payment) error {
return r.db.WithContext(ctx).Save(payment).Error
}
func (r *paymentRepository) UpdateStatus(ctx context.Context, paymentNo, status string, tradeNo *string, paymentTime *string) error {
updateData := map[string]interface{}{
"status": status,
}
if tradeNo != nil {
updateData["trade_no"] = *tradeNo
}
if paymentTime != nil {
updateData["payment_time"] = *paymentTime
}
return r.db.WithContext(ctx).Model(&models.Payment{}).Where("payment_no = ?", paymentNo).Updates(updateData).Error
}
func (r *paymentRepository) Delete(ctx context.Context, id int) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&models.Payment{}).Error
}
func (r *paymentRepository) GetExpiredPayments(ctx context.Context) ([]*models.Payment, error) {
var payments []*models.Payment
err := r.db.WithContext(ctx).Where("status = ? AND expire_time < NOW()", models.PaymentStatusPending).Find(&payments).Error
if err != nil {
return nil, err
}
return payments, nil
}
@@ -2,7 +2,7 @@ package service
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -10,15 +10,21 @@ 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"
"gorm.io/gorm"
)
type authorizationCodeService struct {
authCodeRepo repository.AuthorizationCodeRepository
customerRepo repository.CustomerRepository
cuUserRepo repository.CuUserRepository
licenseRepo repository.LicenseRepository
}
@@ -26,11 +32,13 @@ type authorizationCodeService struct {
func NewAuthorizationCodeService(
authCodeRepo repository.AuthorizationCodeRepository,
customerRepo repository.CustomerRepository,
cuUserRepo repository.CuUserRepository,
licenseRepo repository.LicenseRepository,
) AuthorizationCodeService {
return &authorizationCodeService{
authCodeRepo: authCodeRepo,
customerRepo: customerRepo,
cuUserRepo: cuUserRepo,
licenseRepo: licenseRepo,
}
}
@@ -69,13 +77,13 @@ func (s *authorizationCodeService) CreateAuthorizationCode(ctx context.Context,
return nil, i18n.NewI18nError("100004", lang) // 缺少认证信息
}
// 生成授权码
// 授权码生成规则回退/统一为旧规则(不再自包含配置)
authCode, err := s.generateAuthorizationCode(req.CustomerID)
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 +116,7 @@ func (s *authorizationCodeService) CreateAuthorizationCode(ctx context.Context,
// 构建授权码实体
authCodeEntity := &models.AuthorizationCode{
ID: uuid.New().String(), // 生成新的UUID作为主键
Code: authCode,
CustomerID: req.CustomerID,
CreatedBy: currentUserID,
@@ -256,6 +265,102 @@ func (s *authorizationCodeService) GenerateAuthorizationFile(ctx context.Context
return []byte(authCode.Code), fileName, authCode.Code, nil
}
// GetProductActivationCode 获取产品激活码:{授权码}&{payload}
func (s *authorizationCodeService) GetProductActivationCode(ctx context.Context, customerID string, req *models.ProductActivationCodeRequest) (*models.ProductActivationCodeResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
if customerID == "" || req == nil || strings.TrimSpace(req.AuthorizationCode) == "" {
return nil, i18n.NewI18nError("900001", lang)
}
code := strings.TrimSpace(req.AuthorizationCode)
if idx := strings.Index(code, "&"); idx > 0 {
code = strings.TrimSpace(code[:idx])
}
if code == "" {
return nil, i18n.NewI18nError("900001", lang)
}
authCode, err := s.licenseRepo.GetAuthorizationCodeByCode(ctx, code)
if err != nil {
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
return nil, i18n.NewI18nError("300001", lang) // 授权码不存在
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 校验归属
if authCode.CustomerID != customerID {
return nil, i18n.NewI18nError("100005", lang) // 权限不足
}
// 校验状态与有效期
now := time.Now()
if authCode.IsLocked {
return nil, i18n.NewI18nError("300003", lang) // 授权码已被锁定
}
if now.Before(authCode.StartDate) || now.After(authCode.EndDate) {
return nil, i18n.NewI18nError("300001", lang) // 授权码未生效或已过期
}
// 构造配置 JSON(客户端离线解析/自校验)
payloadData := map[string]interface{}{
"ver": 1,
"authorization_code": authCode.Code,
"start_date": authCode.StartDate,
"end_date": authCode.EndDate,
"deployment_type": authCode.DeploymentType,
"max_activations": authCode.MaxActivations,
"generated_at": time.Now().Format(time.RFC3339),
}
if featureConfig := parseJSONField(authCode.FeatureConfig); len(featureConfig) > 0 {
payloadData["feature_config"] = featureConfig
}
if usageLimits := parseJSONField(authCode.UsageLimits); len(usageLimits) > 0 {
payloadData["usage_limits"] = usageLimits
}
if customParameters := parseJSONField(authCode.CustomParameters); len(customParameters) > 0 {
payloadData["custom_parameters"] = customParameters
}
payloadDataBytes, err := json.Marshal(payloadData)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// RSA 私钥签名封装(与 license_service.signLicenseFile 相同结构)
cfg := config.GetConfig()
if cfg == nil || cfg.License.RSA.PrivateKeyPath == "" {
return nil, i18n.NewI18nError("900004", lang, "RSA private key path not configured")
}
privateKey, err := utils.LoadRSAPrivateKeyFromFile(cfg.License.RSA.PrivateKeyPath)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
signature, err := privateKey.SignData(payloadDataBytes)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
signedPayload := map[string]interface{}{
"data": string(payloadDataBytes),
"signature": signature,
"algorithm": "RSA-PSS-SHA256",
}
signedPayloadBytes, err := json.Marshal(signedPayload)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
payload := base64.StdEncoding.EncodeToString(signedPayloadBytes)
productActivationCode := fmt.Sprintf("%s&%s", authCode.Code, payload)
return &models.ProductActivationCodeResponse{ProductActivationCode: productActivationCode}, nil
}
// fillAuthorizationCodeDisplayFields 填充完整授权码模型的多语言显示字段
func (s *authorizationCodeService) fillAuthorizationCodeDisplayFields(authCode *models.AuthorizationCode, lang string) {
// 计算状态
@@ -319,8 +424,26 @@ func (s *authorizationCodeService) UpdateAuthorizationCode(ctx context.Context,
if req.Description != nil {
existingAuthCode.Description = req.Description
}
if req.ValidityDays != nil {
// 重新计算开始时间和结束时间
// 支持通过起止时间直接设置(优先),格式 YYYY-MM-DD;若未提供,则继续支持 validity_days(向后兼容)
if req.StartDate != nil || req.EndDate != nil {
if req.StartDate == nil || req.EndDate == nil {
return nil, i18n.NewI18nError("900001", lang)
}
startParsed, err := time.Parse("2006-01-02", *req.StartDate)
if err != nil {
return nil, i18n.NewI18nError("900001", lang)
}
endParsed, err := time.Parse("2006-01-02", *req.EndDate)
if err != nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 规范化到本地时区的 00:00:00 / 23:59:59
startDate := time.Date(startParsed.Year(), startParsed.Month(), startParsed.Day(), 0, 0, 0, 0, time.Local)
endDate := time.Date(endParsed.Year(), endParsed.Month(), endParsed.Day(), 23, 59, 59, 0, time.Local)
existingAuthCode.StartDate = startDate
existingAuthCode.EndDate = endDate
} else if req.ValidityDays != nil {
// 重新计算开始时间和结束时间(兼容旧的 validity_days 字段)
now := time.Now()
startDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
endDate := startDate.AddDate(0, 0, *req.ValidityDays-1).Add(23*time.Hour + 59*time.Minute + 59*time.Second)
@@ -584,112 +707,7 @@ func (s *authorizationCodeService) fillAuthCodeDisplayFields(item *models.Author
// 格式: LIC-{4位客户代码}-{12位随机}-{4位校验码}
// 安全性:12位随机字符(62字符集)提供约 3.23 × 10^21 种可能组合
func (s *authorizationCodeService) generateAuthorizationCode(customerID string) (string, error) {
// 获取客户编码的前4位作为前缀
customerCode := "COMP" // 默认前缀
if len(customerID) >= 4 {
customerCode = strings.ToUpper(customerID[:4])
}
// 生成12位随机字符串(从8位增加到12位以提升安全性)
randomStr, err := s.generateRandomString(12)
if err != nil {
return "", err
}
// 生成4位校验码
checksum := s.generateChecksum(customerCode + randomStr)
// 格式: LIC-{customer_code}-{random}-{checksum}
return fmt.Sprintf("LIC-%s-%s-%s", customerCode, randomStr, checksum), nil
}
// generateRandomString 生成随机字符串
// 使用62字符集(A-Z, a-z, 0-9)提供更高的熵值
func (s *authorizationCodeService) generateRandomString(length int) (string, error) {
// 扩展字符集到62字符(A-Z, a-z, 0-9)以提升安全性
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 生成校验码
// 基于输入字符串的ASCII值计算,用于格式验证
func (s *authorizationCodeService) generateChecksum(input string) string {
sum := 0
for _, char := range input {
sum += int(char)
}
// 使用与随机字符串相同的62字符集以保持一致性
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
checksum := ""
for i := 0; i < 4; i++ {
checksum += string(chars[sum%len(chars)])
sum = sum / len(chars)
}
return checksum
}
// ValidateAuthorizationCodeFormat 验证授权码格式和校验和
// 格式: LIC-{4位客户代码}-{8或12位随机}-{4位校验码}
// 兼容旧格式(8位随机,36字符集)和新格式(12位随机,62字符集)
func (s *authorizationCodeService) ValidateAuthorizationCodeFormat(code string) bool {
// 解析格式
parts := strings.Split(code, "-")
if len(parts) != 4 || parts[0] != "LIC" {
return false
}
// 验证各部分长度(兼容旧格式8位和新格式12位)
customerCodeLen := len(parts[1])
randomLen := len(parts[2])
checksumLen := len(parts[3])
// 客户代码必须为4位,校验和必须为4位
if customerCodeLen != 4 || checksumLen != 4 {
return false
}
// 随机部分:兼容8位(旧格式)和12位(新格式)
if randomLen != 8 && randomLen != 12 {
return false
}
// 根据随机部分长度选择字符集验证校验和
// 8位:使用36字符集(旧格式)
// 12位:使用62字符集(新格式)
expectedChecksum := s.generateChecksumWithCharset(parts[1]+parts[2], randomLen == 12)
return expectedChecksum == parts[3]
}
// generateChecksumWithCharset 生成校验码(支持指定字符集)
// useExtendedCharset: true使用62字符集(新格式),false使用36字符集(旧格式)
func (s *authorizationCodeService) generateChecksumWithCharset(input string, useExtendedCharset bool) string {
sum := 0
for _, char := range input {
sum += int(char)
}
var chars string
if useExtendedCharset {
// 使用62字符集(A-Z, a-z, 0-9)用于新格式
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
} else {
// 使用36字符集(A-Z, 0-9)用于旧格式
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
}
checksum := ""
for i := 0; i < 4; i++ {
checksum += string(chars[sum%len(chars)])
sum = sum / len(chars)
}
return checksum
return utils.GenerateLegacyAuthorizationCode(customerID)
}
// GetAuthorizationChangeList 查询授权变更历史列表
@@ -732,3 +750,147 @@ func (s *authorizationCodeService) fillChangeDisplayFields(item *models.Authoriz
// 填充变更类型显示字段
item.ChangeTypeDisplay = i18n.GetEnumMessage("authorization_change_type", item.ChangeType, lang)
}
// ShareAuthorizationCode 用户分享授权码
func (s *authorizationCodeService) ShareAuthorizationCode(ctx context.Context, authCodeID, userID string, req *models.AuthorizationCodeShareRequest) (*models.AuthorizationCodeShareResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if authCodeID == "" || userID == "" || req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 验证不能分享给自己
if req.TargetUserID == userID {
return nil, i18n.NewI18nError("300105", lang) // 不能分享给自己
}
// 验证目标用户是否存在
targetUser, err := s.cuUserRepo.GetByID(req.TargetUserID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, i18n.NewI18nError("300104", lang) // 目标用户不存在
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 验证目标用户状态
if targetUser.Status != "active" {
return nil, i18n.NewI18nError("300104", lang) // 目标用户不存在或状态异常
}
// 获取原授权码
authCode, err := s.authCodeRepo.GetAuthorizationCodeByID(ctx, authCodeID)
if err != nil {
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
return nil, i18n.NewI18nError("300101", lang) // 授权码不存在
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 验证授权码所有权(只能分享自己的授权码)
if authCode.CreatedBy != userID {
return nil, i18n.NewI18nError("300101", lang) // 授权码不存在(无权限访问)
}
// 验证授权码状态
if authCode.IsLocked {
return nil, i18n.NewI18nError("300102", lang) // 授权码已被锁定
}
// 计算当前已激活次数
currentActivations, err := s.licenseRepo.GetActiveLicenseCount(ctx, authCodeID)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 计算可分享次数
availableActivations := authCode.MaxActivations - int(currentActivations)
if req.ShareCount > availableActivations {
return nil, i18n.NewI18nError("300103", lang) // 分享数量超过可用激活数
}
var newAuthCode *models.AuthorizationCode
// 开启事务
tx := s.authCodeRepo.BeginTransaction(ctx)
if tx == nil {
return nil, i18n.NewI18nError("300106", lang) // 数据库事务失败
}
defer func() {
if r := recover(); r != nil {
if gormTx, ok := tx.(*gorm.DB); ok {
gormTx.Rollback()
}
panic(r)
}
}()
// 在事务中执行操作
txErr := func() error {
// 1. 减少原授权码的max_activations
newMaxActivations := authCode.MaxActivations - req.ShareCount
err := s.authCodeRepo.UpdateMaxActivationsWithTx(ctx, tx, authCodeID, newMaxActivations)
if err != nil {
return err
}
// 2. 为目标用户创建新的授权码
now := time.Now()
code, err := s.generateAuthorizationCode(targetUser.CustomerID)
if err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
newAuthCode = &models.AuthorizationCode{
Code: code,
CustomerID: targetUser.CustomerID, // 使用目标用户的客户ID
CreatedBy: req.TargetUserID, // 记录为目标用户创建的
SoftwareID: authCode.SoftwareID,
Description: authCode.Description,
StartDate: now, // 从分享时刻开始
EndDate: authCode.EndDate, // 到原授权码结束时间
DeploymentType: authCode.DeploymentType,
EncryptionType: authCode.EncryptionType,
SoftwareVersion: authCode.SoftwareVersion,
MaxActivations: req.ShareCount,
IsLocked: false,
FeatureConfig: authCode.FeatureConfig,
UsageLimits: authCode.UsageLimits,
CustomParameters: authCode.CustomParameters,
}
err = s.authCodeRepo.CreateAuthorizationCodeWithTx(ctx, tx, newAuthCode)
if err != nil {
return err
}
return nil
}()
if txErr != nil {
if gormTx, ok := tx.(*gorm.DB); ok {
gormTx.Rollback()
}
return nil, i18n.NewI18nError("300106", lang, txErr.Error()) // 数据库事务失败
}
// 提交事务
if gormTx, ok := tx.(*gorm.DB); ok {
if err := gormTx.Commit().Error; err != nil {
return nil, i18n.NewI18nError("300106", lang, err.Error()) // 数据库事务失败
}
}
// 返回新创建的授权码信息
response := &models.AuthorizationCodeShareResponse{
NewAuthorizationCode: models.AuthorizationCodeShareResponseItem{
ID: newAuthCode.ID,
Code: newAuthCode.Code,
StartDate: newAuthCode.StartDate.Format(time.RFC3339),
EndDate: newAuthCode.EndDate.Format(time.RFC3339),
MaxActivations: newAuthCode.MaxActivations,
},
}
return response, nil
}
@@ -0,0 +1,77 @@
package service
import (
"context"
"license-manager/internal/models"
"license-manager/internal/repository"
pkgcontext "license-manager/pkg/context"
"license-manager/pkg/i18n"
)
type cuDeviceService struct {
licenseRepo repository.LicenseRepository
}
// NewCuDeviceService 创建客户设备服务实例
func NewCuDeviceService(licenseRepo repository.LicenseRepository) CuDeviceService {
return &cuDeviceService{
licenseRepo: licenseRepo,
}
}
// GetDeviceList 获取设备列表
func (s *cuDeviceService) GetDeviceList(ctx context.Context, customerID string, req *models.DeviceListRequest) (*models.DeviceListResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 参数验证
if customerID == "" {
return nil, i18n.NewI18nError("900001", lang) // 业务错误,不覆盖多语言message
}
if req == nil {
req = &models.DeviceListRequest{}
}
// 委托给Repository层进行数据访问
return s.licenseRepo.GetCustomerDeviceList(ctx, customerID, req)
}
// GetDeviceSummary 获取设备汇总统计
func (s *cuDeviceService) GetDeviceSummary(ctx context.Context, customerID string) (*models.DeviceSummaryResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 参数验证
if customerID == "" {
return nil, i18n.NewI18nError("900001", lang) // 业务错误,不覆盖多语言message
}
// 委托给Repository层进行数据访问
return s.licenseRepo.GetCustomerDeviceSummary(ctx, customerID)
}
// UnbindDevice 解绑设备
func (s *cuDeviceService) UnbindDevice(ctx context.Context, customerID, licenseID string) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 参数验证
if customerID == "" || licenseID == "" {
return i18n.NewI18nError("900001", lang) // 业务错误,不覆盖多语言message
}
// 检查许可证是否存在且属于当前客户
belongs, err := s.licenseRepo.CheckLicenseBelongsToCustomer(ctx, licenseID, customerID)
if err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
if !belongs {
return i18n.NewI18nError("620001", lang) // 设备不存在或无权限访问
}
// 物理删除许可证记录
err = s.licenseRepo.DeleteLicenseByID(ctx, licenseID)
if err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
return nil
}
@@ -0,0 +1,448 @@
package service
import (
"context"
"encoding/json"
"fmt"
"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)
CreatePendingOrder(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)
GetOrderSummary(ctx context.Context, customerID string) (*models.OrderSummaryResponse, error)
CalculatePrice(ctx context.Context, packageID string, licenseCount int) (*PriceCalculationResult, error)
UpdateOrderStatus(ctx context.Context, orderID string, status string) 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) CreatePendingOrder(ctx context.Context, cuUserID, customerID string, req *models.CuOrderCreateRequest) (*models.CuOrder, error) {
// 计算价格
priceResult, err := s.CalculatePrice(ctx, req.PackageID, req.LicenseCount)
if err != nil {
return nil, err
}
// 生成订单号
orderNo := s.generateOrderNo()
// 获取套餐配置
packageConfig, exists := packageConfigs[req.PackageID]
if !exists {
return nil, i18n.NewI18nError("600001", "套餐不存在")
}
// 创建订单(pending状态)
order := &models.CuOrder{
OrderNo: orderNo,
CustomerID: customerID,
CuUserID: cuUserID,
PackageID: req.PackageID,
PackageName: packageConfig.Name,
LicenseCount: req.LicenseCount,
UnitPrice: priceResult.UnitPrice,
DiscountRate: priceResult.DiscountRate,
TotalAmount: priceResult.TotalAmount,
Status: "pending", // 待支付状态
}
// 对于试用版,设置过期时间
if req.PackageID == "trial" {
expiredAt := s.getTrialExpiryTime()
order.ExpiredAt = &expiredAt
}
// 保存订单
if err := s.repo.Create(order); err != nil {
return nil, i18n.NewI18nError("601001", "订单创建失败: "+err.Error())
}
return order, nil
}
func (s *cuOrderService) UpdateOrderStatus(ctx context.Context, orderID string, status string) error {
// 这里需要实现更新订单状态的逻辑
// 暂时先返回nil,后面再实现
return nil
}
// getTrialExpiryTime 获取试用版过期时间(当月25日23:59:59)
func (s *cuOrderService) getTrialExpiryTime() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), 25, 23, 59, 59, 0, now.Location())
}
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
}
// 构建授权配置(用于后续生成产品激活码 payload)
var featureConfigMap, usageLimitsMap map[string]interface{}
// 根据套餐类型设置配置信息
if req.PackageID == "basic" {
usageLimitsMap = map[string]interface{}{
"type": "standard",
}
}
customParametersMap := map[string]interface{}{
"package_id": req.PackageID,
"package_name": pkg.Name,
"license_count": req.LicenseCount,
}
// 授权码生成规则回退/统一为旧规则(不再自包含配置)
authCode, err := utils.GenerateLegacyAuthorizationCode(customerID)
if err != nil {
tx.Rollback()
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 构建订单描述
description := fmt.Sprintf("%s - %d个许可", pkg.Name, req.LicenseCount)
// 处理JSON字段(用于数据库存储与后续生成产品激活码 payload)
var featureConfig, usageLimits, customParameters models.JSON
b, err := json.Marshal(featureConfigMap)
if err != nil {
tx.Rollback()
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
featureConfig = models.JSON(b)
if usageLimitsMap != nil {
d, err := json.Marshal(usageLimitsMap)
if err != nil {
tx.Rollback()
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
usageLimits = models.JSON(d)
}
c, err := json.Marshal(customParametersMap)
if err != nil {
tx.Rollback()
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
customParameters = models.JSON(c)
// 构建授权码实体(使用生成的授权码)
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: featureConfig,
UsageLimits: usageLimits,
CustomParameters: customParameters,
}
// 创建授权码
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
}
// GetOrderSummary 获取订单汇总统计
func (s *cuOrderService) GetOrderSummary(ctx context.Context, customerID string) (*models.OrderSummaryResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 参数验证
if customerID == "" {
return nil, i18n.NewI18nError("900001", lang) // 业务错误,不覆盖多语言message
}
// 委托给Repository层进行数据访问
return s.repo.GetCustomerOrderSummary(ctx, customerID)
}
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)
}
+725
View File
@@ -0,0 +1,725 @@
package service
import (
"context"
"errors"
"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"
"gorm.io/gorm"
)
type CuUserService interface {
Register(ctx context.Context, req *models.CuUserRegisterRequest) (*models.CuUser, error)
Login(ctx context.Context, req *models.CuUserLoginRequest, ip string) (*models.CuUser, string, error)
GetProfile(ctx context.Context, userID string) (*models.CuUser, error)
UpdateProfile(ctx context.Context, userID string, req *models.CuUserProfileUpdateRequest) error
UpdatePhone(ctx context.Context, userID string, req *models.CuUserPhoneUpdateRequest) error
ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error
SendRegisterSms(ctx context.Context, req *models.CuUserSendRegisterSmsRequest) error
SendLoginSms(ctx context.Context, req *models.CuUserSendLoginSmsRequest) error
SendCurrentPhoneSms(ctx context.Context, userID string) error
SendNewPhoneSms(ctx context.Context, req *models.CuUserSendNewPhoneSmsRequest) error
VerifyRegisterSmsCode(ctx context.Context, phone, phoneCountryCode, code string) (bool, error)
VerifyPhoneCode(ctx context.Context, phone, phoneCountryCode, code, templateType string) (bool, error)
ForgotPassword(ctx context.Context, req *models.CuUserForgotPasswordRequest) error
ResetPassword(ctx context.Context, req *models.CuUserResetPasswordRequest) error
GetUsersByCustomer(ctx context.Context, customerID string, offset, limit int) ([]*models.CuUser, int64, error)
LockAccount(ctx context.Context, userID string, duration time.Duration) error
UnlockAccount(ctx context.Context, userID string) error
}
type cuUserService struct {
repo repository.CuUserRepository
customerRepo repository.CustomerRepository
smsService utils.SMSService
db *gorm.DB
}
func NewCuUserService(repo repository.CuUserRepository, customerRepo repository.CustomerRepository, smsService utils.SMSService, db *gorm.DB) CuUserService {
return &cuUserService{
repo: repo,
customerRepo: customerRepo,
smsService: smsService,
db: db,
}
}
func (s *cuUserService) Register(ctx context.Context, req *models.CuUserRegisterRequest) (*models.CuUser, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 设置默认值
phoneCountryCode := req.PhoneCountryCode
if phoneCountryCode == "" {
phoneCountryCode = "+86" // 默认中国
}
// 检查手机号是否已存在
exists, err := s.repo.CheckPhoneExists(req.Phone, phoneCountryCode, "")
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
if exists {
return nil, i18n.NewI18nError("500002", lang) // 手机号已被注册
}
// 检查邮箱是否已存在(如果提供)
if req.Email != nil {
exists, err := s.repo.CheckEmailExists(*req.Email, "")
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
if exists {
return nil, i18n.NewI18nError("500002", lang) // 邮箱已被注册
}
}
// 处理客户ID:如果未提供,则自动创建客户
customerID := req.CustomerID
if customerID == "" {
// 创建新客户
customer, err := s.createDefaultCustomer(ctx, req.Phone, phoneCountryCode)
if err != nil {
return nil, err
}
customerID = customer.ID
}
// 生成密码哈希
var passwordHash, salt string
if req.Password != "" {
salt = utils.GenerateSalt()
passwordHash, err = utils.HashPasswordWithSalt(req.Password, salt)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
}
// 创建用户
user := &models.CuUser{
CustomerID: customerID,
Phone: req.Phone,
PhoneCountryCode: phoneCountryCode,
Password: &passwordHash,
Salt: &salt,
UserRole: "member", // 默认普通成员
RealName: req.RealName,
Email: req.Email,
Status: "active",
PhoneVerified: true, // 注册时已验证
EmailVerified: false,
Language: "zh-CN",
Timezone: "Asia/Shanghai",
}
// 委托给Repository层进行数据创建
if err := s.repo.Create(user); err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
return user, nil
}
func (s *cuUserService) Login(ctx context.Context, req *models.CuUserLoginRequest, ip string) (*models.CuUser, string, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if req == nil {
return nil, "", i18n.NewI18nError("900001", lang)
}
// 设置默认国家代码
phoneCountryCode := req.PhoneCountryCode
if phoneCountryCode == "" {
phoneCountryCode = "+86" // 默认中国
}
// 根据手机号查找用户
user, err := s.repo.GetByPhone(req.Phone, phoneCountryCode)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, "", i18n.NewI18nError("500003", lang) // 手机号或密码错误
}
return nil, "", i18n.NewI18nError("900004", lang, err.Error())
}
// 检查账号状态
if user.Status != "active" {
return nil, "", i18n.NewI18nError("500003", lang) // 账号已被禁用
}
// 根据登录类型进行不同的验证
if req.LoginType == "sms" {
// 短信验证码登录
if req.SmsCode == "" {
return nil, "", i18n.NewI18nError("900001", lang) // 短信验证码不能为空
}
// 验证短信验证码
valid, err := s.smsService.VerifyCode(ctx, req.Phone, phoneCountryCode, req.SmsCode)
if err != nil {
switch err {
case utils.ErrSMSCodeNotExist:
return nil, "", i18n.NewI18nError("500015", lang) // 验证码不存在
case utils.ErrSMSCodeIncorrect:
return nil, "", i18n.NewI18nError("500014", lang) // 验证码错误或已过期
default:
return nil, "", i18n.NewI18nError("900004", lang, err.Error())
}
}
if !valid {
return nil, "", i18n.NewI18nError("500014", lang) // 验证码错误或已过期
}
} else {
// 密码登录(默认行为)
// 检查是否被锁定
if user.IsAccountLocked() {
return nil, "", i18n.NewI18nError("500003", lang) // 账号已被锁定
}
// 验证密码
if req.Password == "" {
return nil, "", i18n.NewI18nError("900001", lang) // 密码不能为空
}
if user.Password == nil {
return nil, "", i18n.NewI18nError("500003", lang) // 请先设置密码
}
if !utils.CheckPassword(req.Password, *user.Password, *user.Salt) {
// 增加登录失败次数
if err := s.repo.IncrementLoginAttempts(user.ID); err != nil {
// 记录错误但不影响登录流程
}
// 检查是否需要锁定账号
cfg := config.GetConfig()
if cfg != nil && user.LoginAttempts+1 >= cfg.Auth.Security.MaxLoginAttempts {
lockUntil := time.Now().Add(time.Duration(cfg.Auth.Security.LockoutDurationMinutes) * time.Minute)
if err := s.repo.LockAccount(user.ID, lockUntil); err != nil {
// 记录错误但不影响登录流程
}
return nil, "", i18n.NewI18nError("500003", lang) // 账号已被锁定
}
return nil, "", i18n.NewI18nError("500003", lang) // 手机号或密码错误
}
}
// 登录成功,重置登录失败次数并更新登录信息
// 注意:短信登录也需要重置失败次数,避免被锁定
if err := s.repo.ResetLoginAttempts(user.ID); err != nil {
// 记录错误但不影响登录流程
}
if err := s.repo.UpdateLoginInfo(user.ID, ip); err != nil {
// 记录错误但不影响登录流程
}
// 生成JWT Token
token, err := utils.GenerateCuToken(user.ID, user.CustomerID, user.UserRole, user.Phone)
if err != nil {
return nil, "", i18n.NewI18nError("500013", lang, err.Error())
}
return user, token, nil
}
func (s *cuUserService) GetProfile(ctx context.Context, userID string) (*models.CuUser, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if userID == "" {
return nil, i18n.NewI18nError("900001", lang)
}
user, err := s.repo.GetByID(userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, i18n.NewI18nError("500009", lang) // 用户不存在
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
return user, nil
}
func (s *cuUserService) UpdateProfile(ctx context.Context, userID string, req *models.CuUserProfileUpdateRequest) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if userID == "" || req == nil {
return i18n.NewI18nError("900001", lang)
}
user, err := s.repo.GetByID(userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return i18n.NewI18nError("500009", lang) // 用户不存在
}
return i18n.NewI18nError("900004", lang, err.Error())
}
// 检查邮箱是否已被其他用户使用
if req.Email != nil && *req.Email != "" {
exists, err := s.repo.CheckEmailExists(*req.Email, userID)
if err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
if exists {
return i18n.NewI18nError("500006", lang) // 邮箱已被其他用户使用
}
user.Email = req.Email
user.EmailVerified = false // 修改邮箱后需要重新验证
}
// 更新其他字段
if req.RealName != nil {
user.RealName = req.RealName
}
if req.AvatarURL != nil {
user.AvatarURL = req.AvatarURL
}
if req.Language != nil {
user.Language = *req.Language
}
if req.Timezone != nil {
user.Timezone = *req.Timezone
}
if req.AdditionalInfo != nil {
user.AdditionalInfo = req.AdditionalInfo
}
if req.Remark != nil {
user.Remark = req.Remark
}
if err := s.repo.Update(user); err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
return nil
}
func (s *cuUserService) UpdatePhone(ctx context.Context, userID string, req *models.CuUserPhoneUpdateRequest) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if userID == "" || req == nil {
return i18n.NewI18nError("900001", lang)
}
// 检查新手机号是否已被使用
exists, err := s.repo.CheckPhoneExists(req.NewPhone, req.NewPhoneCountryCode, userID)
if err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
if exists {
return i18n.NewI18nError("500007", lang) // 新手机号已被其他用户使用
}
// 验证当前手机号和新手机号的验证码
// 更新手机号
user, err := s.repo.GetByID(userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return i18n.NewI18nError("500009", lang) // 用户不存在
}
return i18n.NewI18nError("900004", lang, err.Error())
}
user.Phone = req.NewPhone
user.PhoneCountryCode = req.NewPhoneCountryCode
user.PhoneVerified = true // 手机号已验证
if err := s.repo.Update(user); err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
return nil
}
func (s *cuUserService) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if userID == "" || oldPassword == "" || newPassword == "" {
return i18n.NewI18nError("900001", lang)
}
user, err := s.repo.GetByID(userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return i18n.NewI18nError("500009", lang) // 用户不存在
}
return i18n.NewI18nError("900004", lang, err.Error())
}
// 验证旧密码
if user.Password == nil {
return i18n.NewI18nError("500008", lang) // 请先设置密码
}
if !utils.CheckPassword(oldPassword, *user.Password, *user.Salt) {
return i18n.NewI18nError("500008", lang) // 旧密码错误
}
// 生成新密码哈希
salt := utils.GenerateSalt()
passwordHash, err := utils.HashPasswordWithSalt(newPassword, salt)
if err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
user.Password = &passwordHash
user.Salt = &salt
if err := s.repo.Update(user); err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
return nil
}
// sendSmsCode 通用短信验证码发送逻辑
func (s *cuUserService) sendSmsCode(ctx context.Context, phone, phoneCountryCode, templateType string, checkUserExists bool) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 设置默认国家代码
if phoneCountryCode == "" {
phoneCountryCode = "+86"
}
// 检查用户状态
exists, err := s.repo.CheckPhoneExists(phone, phoneCountryCode, "")
if err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
if checkUserExists && !exists {
// 忘记密码:用户必须存在
return i18n.NewI18nError("500009", lang) // 用户不存在
} else if !checkUserExists && exists {
// 注册:用户不能存在
return i18n.NewI18nError("200002", lang) // 客户已存在
}
// 如果用户存在,检查账号状态(仅对忘记密码场景)
if checkUserExists && exists {
user, err := s.repo.GetByPhone(phone, phoneCountryCode)
if err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
if user.Status != "active" {
return i18n.NewI18nError("500004", lang) // 账号已被禁用
}
}
// 发送验证码
var sendErr error
switch templateType {
case "register":
sendErr = s.smsService.SendRegisterCode(ctx, phone, phoneCountryCode)
case "reset_pwd":
sendErr = s.smsService.SendResetPwdCode(ctx, phone, phoneCountryCode)
case "login":
sendErr = s.smsService.SendLoginCode(ctx, phone, phoneCountryCode)
default:
return i18n.NewI18nError("900001", lang, "无效的模板类型")
}
if sendErr != nil {
switch sendErr {
case utils.ErrSMSRateLimited:
return i18n.NewI18nError("500016", lang) // 短信发送过于频繁,请稍后再试
default:
// 对于短信发送失败,结合本地化消息和具体错误信息,便于调试
return i18n.NewI18nError("500017", lang, sendErr.Error())
}
}
return nil
}
func (s *cuUserService) SendRegisterSms(ctx context.Context, req *models.CuUserSendRegisterSmsRequest) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 参数验证
if req == nil {
return i18n.NewI18nError("900001", lang)
}
return s.sendSmsCode(ctx, req.Phone, req.PhoneCountryCode, "register", false)
}
// SendLoginSms 发送登录验证码
func (s *cuUserService) SendLoginSms(ctx context.Context, req *models.CuUserSendLoginSmsRequest) error {
return s.sendSmsCode(ctx, req.Phone, req.PhoneCountryCode, "login", true)
}
func (s *cuUserService) SendCurrentPhoneSms(ctx context.Context, userID string) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 参数验证
if userID == "" {
return i18n.NewI18nError("900001", lang)
}
// 获取用户信息
user, err := s.repo.GetByID(userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return i18n.NewI18nError("500009", lang) // 用户不存在
}
return i18n.NewI18nError("900004", lang, err.Error())
}
// 检查账号状态
if user.Status != "active" {
return i18n.NewI18nError("500004", lang) // 账号已被禁用
}
// 发送当前手机号验证码
err = s.smsService.SendCurrentPhoneCode(ctx, user.Phone, user.PhoneCountryCode)
if err != nil {
switch err {
case utils.ErrSMSRateLimited:
return i18n.NewI18nError("500016", lang) // 短信发送过于频繁,请稍后再试
default:
// 对于短信发送失败,结合本地化消息和具体错误信息,便于调试
return i18n.NewI18nError("500017", lang, err.Error())
}
}
return nil
}
func (s *cuUserService) SendNewPhoneSms(ctx context.Context, req *models.CuUserSendNewPhoneSmsRequest) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 参数验证
if req == nil {
return i18n.NewI18nError("900001", lang)
}
// 设置默认国家代码
phoneCountryCode := req.NewPhoneCountryCode
if phoneCountryCode == "" {
phoneCountryCode = "+86"
}
// 检查新手机号是否已被注册
exists, err := s.repo.CheckPhoneExists(req.NewPhone, phoneCountryCode, "")
if err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
if exists {
return i18n.NewI18nError("200002", lang) // 客户已存在
}
// 发送新手机号验证码
err = s.smsService.SendNewPhoneCode(ctx, req.NewPhone, phoneCountryCode)
if err != nil {
switch err {
case utils.ErrSMSRateLimited:
return i18n.NewI18nError("500016", lang) // 短信发送过于频繁,请稍后再试
default:
// 对于短信发送失败,结合本地化消息和具体错误信息,便于调试
return i18n.NewI18nError("500017", lang, err.Error())
}
}
return nil
}
func (s *cuUserService) VerifyRegisterSmsCode(ctx context.Context, phone, phoneCountryCode, code string) (bool, error) {
valid, err := s.smsService.VerifyCode(ctx, phone, phoneCountryCode, code)
if err != nil {
lang := pkgcontext.GetLanguageFromContext(ctx)
switch err {
case utils.ErrSMSCodeNotExist:
return false, i18n.NewI18nError("500015", lang) // 验证码不存在
case utils.ErrSMSCodeIncorrect:
return false, i18n.NewI18nError("500014", lang) // 验证码错误或已过期
default:
return false, i18n.NewI18nError("900004", lang, err.Error())
}
}
return valid, nil
}
func (s *cuUserService) VerifyPhoneCode(ctx context.Context, phone, phoneCountryCode, code, templateType string) (bool, error) {
valid, err := s.smsService.VerifyCode(ctx, phone, phoneCountryCode, code)
if err != nil {
lang := pkgcontext.GetLanguageFromContext(ctx)
switch err {
case utils.ErrSMSCodeNotExist:
return false, i18n.NewI18nError("500015", lang) // 验证码不存在
case utils.ErrSMSCodeIncorrect:
return false, i18n.NewI18nError("500014", lang) // 验证码错误或已过期
default:
return false, i18n.NewI18nError("900004", lang, err.Error())
}
}
return valid, nil
}
func (s *cuUserService) ForgotPassword(ctx context.Context, req *models.CuUserForgotPasswordRequest) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 参数验证
if req == nil {
return i18n.NewI18nError("900001", lang)
}
return s.sendSmsCode(ctx, req.Phone, req.PhoneCountryCode, "reset_pwd", true)
}
func (s *cuUserService) ResetPassword(ctx context.Context, req *models.CuUserResetPasswordRequest) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if req == nil {
return i18n.NewI18nError("900001", lang)
}
// 设置默认国家代码
phoneCountryCode := req.PhoneCountryCode
if phoneCountryCode == "" {
phoneCountryCode = "+86" // 默认中国
}
// 查找用户
user, err := s.repo.GetByPhone(req.Phone, phoneCountryCode)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return i18n.NewI18nError("500009", lang) // 用户不存在
}
return i18n.NewI18nError("900004", lang, err.Error())
}
// 检查账号状态
if user.Status != "active" {
return i18n.NewI18nError("500005", lang) // 账号已被禁用
}
// 验证短信验证码
valid, err := s.smsService.VerifyCode(ctx, req.Phone, phoneCountryCode, req.SmsCode)
if err != nil {
switch err {
case utils.ErrSMSCodeNotExist:
return i18n.NewI18nError("500015", lang) // 验证码不存在
case utils.ErrSMSCodeIncorrect:
return i18n.NewI18nError("500014", lang) // 验证码错误或已过期
default:
return i18n.NewI18nError("900004", lang, err.Error())
}
}
if !valid {
return i18n.NewI18nError("500014", lang) // 验证码错误或已过期
}
// 生成新密码哈希
salt := utils.GenerateSalt()
passwordHash, err := utils.HashPasswordWithSalt(req.NewPassword, salt)
if err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
user.Password = &passwordHash
user.Salt = &salt
user.LockedUntil = nil // 解锁账号
user.LoginAttempts = 0 // 重置登录失败次数
if err := s.repo.Update(user); err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
return nil
}
func (s *cuUserService) GetUsersByCustomer(ctx context.Context, customerID string, offset, limit int) ([]*models.CuUser, int64, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if customerID == "" {
return nil, 0, i18n.NewI18nError("900001", lang)
}
users, total, err := s.repo.GetByCustomerID(customerID, offset, limit)
if err != nil {
return nil, 0, i18n.NewI18nError("900004", lang, err.Error())
}
return users, total, nil
}
func (s *cuUserService) LockAccount(ctx context.Context, userID string, duration time.Duration) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if userID == "" {
return i18n.NewI18nError("900001", lang)
}
lockUntil := time.Now().Add(duration)
if err := s.repo.LockAccount(userID, lockUntil); err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
return nil
}
func (s *cuUserService) UnlockAccount(ctx context.Context, userID string) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if userID == "" {
return i18n.NewI18nError("900001", lang)
}
if err := s.repo.LockAccount(userID, nil); err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
return nil
}
// createDefaultCustomer 创建默认客户(用于自动注册场景)
func (s *cuUserService) createDefaultCustomer(ctx context.Context, phone, phoneCountryCode string) (*models.Customer, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 使用手机号作为客户名称
customerName := phoneCountryCode + phone
companySize := "small"
contactPhone := phoneCountryCode + phone
// 创建默认客户
customer := &models.Customer{
CustomerName: customerName, // 客户名称默认为手机号
CustomerType: "individual", // 客户类型默认为个人客户
ContactPerson: contactPhone, // 联系人默认为手机号
CustomerLevel: "basic", // 等级默认为基础客户
Status: "active", // 状态为激活
CompanySize: &companySize, // 公司规模为小型企业
CreatedBy: "system", // 系统自动创建
Phone: &contactPhone, // 设置联系电话
}
// 委托给Repository层进行数据创建
if err := s.customerRepo.CreateCustomer(ctx, customer); err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
return customer, nil
}
+18
View File
@@ -44,6 +44,12 @@ type AuthorizationCodeService interface {
DeleteAuthorizationCode(ctx context.Context, id string) error
GetAuthorizationChangeList(ctx context.Context, authCodeID string, req *models.AuthorizationChangeListRequest) (*models.AuthorizationChangeListResponse, error)
GenerateAuthorizationFile(ctx context.Context, id string) ([]byte, string, string, error)
// 用户端分享功能
ShareAuthorizationCode(ctx context.Context, authCodeID, userID string, req *models.AuthorizationCodeShareRequest) (*models.AuthorizationCodeShareResponse, error)
// 用户端获取产品激活码
GetProductActivationCode(ctx context.Context, customerID string, req *models.ProductActivationCodeRequest) (*models.ProductActivationCodeResponse, error)
}
// LicenseService 许可证服务接口
@@ -70,3 +76,15 @@ type DashboardService interface {
// 获取最近授权列表
GetRecentAuthorizations(ctx context.Context, req *models.DashboardRecentAuthorizationsRequest) (*models.DashboardRecentAuthorizationsResponse, error)
}
// CuDeviceService 客户设备服务接口
type CuDeviceService interface {
// 获取设备列表
GetDeviceList(ctx context.Context, customerID string, req *models.DeviceListRequest) (*models.DeviceListResponse, error)
// 获取设备汇总统计
GetDeviceSummary(ctx context.Context, customerID string) (*models.DeviceSummaryResponse, error)
// 解绑设备
UnbindDevice(ctx context.Context, customerID, licenseID string) error
}
+9 -61
View File
@@ -378,64 +378,6 @@ func (s *licenseService) generateLicenseKey() (string, error) {
return fmt.Sprintf("LIC-DEVICE-%s", hex), nil
}
// validateAuthorizationCodeFormat 验证授权码格式和校验和
// 格式: LIC-{4位客户代码}-{8或12位随机}-{4位校验码}
// 兼容旧格式(8位随机,36字符集)和新格式(12位随机,62字符集)
func validateAuthorizationCodeFormat(code string) bool {
// 解析格式
parts := strings.Split(code, "-")
if len(parts) != 4 || parts[0] != "LIC" {
return false
}
// 验证各部分长度(兼容旧格式8位和新格式12位)
customerCodeLen := len(parts[1])
randomLen := len(parts[2])
checksumLen := len(parts[3])
// 客户代码必须为4位,校验和必须为4位
if customerCodeLen != 4 || checksumLen != 4 {
return false
}
// 随机部分:兼容8位(旧格式)和12位(新格式)
if randomLen != 8 && randomLen != 12 {
return false
}
// 根据随机部分长度选择字符集验证校验和
// 8位:使用36字符集(旧格式)
// 12位:使用62字符集(新格式)
expectedChecksum := generateChecksumForCode(parts[1]+parts[2], randomLen == 12)
return expectedChecksum == parts[3]
}
// generateChecksumForCode 生成校验码(用于验证)
// 基于输入字符串的ASCII值计算
// useExtendedCharset: true使用62字符集(新格式),false使用36字符集(旧格式)
func generateChecksumForCode(input string, useExtendedCharset bool) string {
sum := 0
for _, char := range input {
sum += int(char)
}
var chars string
if useExtendedCharset {
// 使用62字符集(A-Z, a-z, 0-9)用于新格式
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
} else {
// 使用36字符集(A-Z, 0-9)用于旧格式
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
}
checksum := ""
for i := 0; i < 4; i++ {
checksum += string(chars[sum%len(chars)])
sum = sum / len(chars)
}
return checksum
}
// ActivateLicense 激活许可证
func (s *licenseService) ActivateLicense(ctx context.Context, req *models.ActivateRequest, clientIP string) (*models.ActivateResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
@@ -445,9 +387,15 @@ func (s *licenseService) ActivateLicense(ctx context.Context, req *models.Activa
return nil, i18n.NewI18nError("900001", lang)
}
// 验证授权码格式(在查询数据库前先验证格式,避免无效查询)
if !validateAuthorizationCodeFormat(req.AuthorizationCode) {
return nil, i18n.NewI18nError("300001", lang) // 授权码格式无效
// 兼容“产品激活码”形式:{授权码}&{payload}
// 激活流程只需要授权码本体,不依赖payload。
req.AuthorizationCode = strings.TrimSpace(req.AuthorizationCode)
if idx := strings.Index(req.AuthorizationCode, "&"); idx > 0 {
req.AuthorizationCode = strings.TrimSpace(req.AuthorizationCode[:idx])
}
if req.AuthorizationCode == "" {
return nil, i18n.NewI18nError("900001", lang)
}
// 获取授权码信息
+419
View File
@@ -0,0 +1,419 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
"license-manager/internal/models"
"license-manager/internal/repository"
"license-manager/pkg/utils"
"github.com/smartwalle/alipay/v3"
"gorm.io/gorm"
)
type PaymentService interface {
CreatePayment(ctx context.Context, req *models.PaymentCreateRequest) (*models.Payment, error)
GetPayment(ctx context.Context, paymentNo string) (*models.Payment, error)
GetPaymentStatus(ctx context.Context, paymentNo string) (*models.PaymentStatusResponse, error)
ProcessAlipayCallback(ctx context.Context, values url.Values) error
GetUserPayments(ctx context.Context, customerID, cuUserID string, page, pageSize int) (*models.PaymentListResponse, error)
CancelExpiredPayments(ctx context.Context) error
}
type paymentService struct {
paymentRepo repository.PaymentRepository
cuOrderRepo repository.CuOrderRepository
alipayClient *alipay.Client
config *PaymentConfig
db *gorm.DB
}
type PaymentConfig struct {
DefaultMethod string `json:"default_method"`
Providers map[string]*AlipayProviderConfig `json:"providers"`
ExpireMinutes int `json:"expire_minutes"`
}
type AlipayProviderConfig struct {
Enabled bool `json:"enabled"`
AppID string `json:"app_id"`
PrivateKey string `json:"private_key"`
PublicKey string `json:"public_key"`
GatewayURL string `json:"gateway_url"`
NotifyURL string `json:"notify_url"`
ReturnURL string `json:"return_url"`
SignType string `json:"sign_type"`
Charset string `json:"charset"`
Format string `json:"format"`
}
func NewPaymentService(
paymentRepo repository.PaymentRepository,
cuOrderRepo repository.CuOrderRepository,
config *PaymentConfig,
db *gorm.DB,
) PaymentService {
var alipayClient *alipay.Client
if config != nil && config.Providers != nil {
if alipayConfig, exists := config.Providers["alipay"]; exists && alipayConfig.Enabled {
isProduction := strings.Contains(strings.ToLower(alipayConfig.GatewayURL), "openapi.alipay.com") &&
!strings.Contains(strings.ToLower(alipayConfig.GatewayURL), "sandbox")
client, err := alipay.New(
alipayConfig.AppID,
alipayConfig.PrivateKey,
isProduction,
alipay.WithSandboxGateway(alipayConfig.GatewayURL),
alipay.WithProductionGateway(alipayConfig.GatewayURL),
)
if err != nil {
fmt.Printf("Failed to create Alipay client: %v\n", err)
} else {
if alipayConfig.PublicKey != "" {
if err := client.LoadAliPayPublicKey(alipayConfig.PublicKey); err != nil {
fmt.Printf("Failed to load Alipay public key: %v\n", err)
}
}
alipayClient = client
}
}
}
return &paymentService{
paymentRepo: paymentRepo,
cuOrderRepo: cuOrderRepo,
alipayClient: alipayClient,
config: config,
db: db,
}
}
func (s *paymentService) CreatePayment(ctx context.Context, req *models.PaymentCreateRequest) (*models.Payment, error) {
// 设置默认值
if req.Currency == "" {
req.Currency = "CNY"
}
if s.config != nil && req.PaymentMethod == "" {
req.PaymentMethod = s.config.DefaultMethod
}
// 生成支付单号
paymentNo := utils.GeneratePaymentNo()
// 计算过期时间
expireMinutes := 30
if s.config != nil && s.config.ExpireMinutes > 0 {
expireMinutes = s.config.ExpireMinutes
}
expireTime := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
// 创建支付单
payment := &models.Payment{
PaymentNo: paymentNo,
BusinessType: req.BusinessType,
BusinessID: req.BusinessID,
CustomerID: req.CustomerID,
CuUserID: req.CuUserID,
Amount: req.Amount,
Currency: req.Currency,
PaymentMethod: req.PaymentMethod,
PaymentProvider: req.PaymentMethod,
Status: models.PaymentStatusPending,
ExpireTime: expireTime,
}
// 如果是支付宝,生成支付URL
if req.PaymentMethod == models.PaymentMethodAlipay {
if s.alipayClient == nil {
return nil, fmt.Errorf("alipay client not initialized")
}
var notifyURL, returnURL string
if s.config != nil && s.config.Providers != nil {
if provider, ok := s.config.Providers["alipay"]; ok && provider != nil {
notifyURL = strings.TrimSpace(provider.NotifyURL)
returnURL = strings.TrimSpace(provider.ReturnURL)
}
}
payRequest := alipay.TradePagePay{
Trade: alipay.Trade{
OutTradeNo: paymentNo,
TotalAmount: fmt.Sprintf("%.2f", req.Amount),
Subject: "产品套餐购买",
ProductCode: "FAST_INSTANT_TRADE_PAY",
NotifyURL: notifyURL,
ReturnURL: returnURL,
},
}
payURL, err := s.alipayClient.TradePagePay(payRequest)
if err != nil {
return nil, fmt.Errorf("generate payment URL failed: %w", err)
}
payURLStr := payURL.String()
payment.PaymentURL = &payURLStr
}
// 保存到数据库
if err := s.paymentRepo.Create(ctx, payment); err != nil {
return nil, fmt.Errorf("create payment failed: %w", err)
}
return payment, nil
}
func (s *paymentService) GetPayment(ctx context.Context, paymentNo string) (*models.Payment, error) {
return s.paymentRepo.GetByPaymentNo(ctx, paymentNo)
}
func (s *paymentService) GetPaymentStatus(ctx context.Context, paymentNo string) (*models.PaymentStatusResponse, error) {
payment, err := s.paymentRepo.GetByPaymentNo(ctx, paymentNo)
if err != nil {
return nil, err
}
response := &models.PaymentStatusResponse{
PaymentNo: payment.PaymentNo,
Status: payment.Status,
Amount: payment.Amount,
PaymentTime: payment.PaymentTime,
TradeNo: payment.TradeNo,
}
// 如果支付成功,获取业务订单信息
if payment.Status == models.PaymentStatusPaid && payment.BusinessID != nil {
if payment.BusinessType == models.BusinessTypePackageOrder {
order, err := s.cuOrderRepo.GetByID(*payment.BusinessID)
if err == nil && order.AuthorizationCode != nil {
response.BusinessOrder = &models.PaymentBusinessOrder{
OrderNo: order.OrderNo,
AuthorizationCode: order.AuthorizationCode,
Status: order.Status,
}
}
}
}
return response, nil
}
func (s *paymentService) ProcessAlipayCallback(ctx context.Context, values url.Values) error {
if s.alipayClient == nil {
return errors.New("alipay client not initialized")
}
if s.db == nil {
return errors.New("db not initialized")
}
if len(values) == 0 {
return errors.New("empty callback values")
}
notification, err := s.alipayClient.DecodeNotification(values)
if err != nil {
return err
}
paymentNo := strings.TrimSpace(notification.OutTradeNo)
if paymentNo == "" {
return errors.New("missing out_trade_no")
}
notifyDataBytes, err := json.Marshal(values)
if err != nil {
return err
}
notifyDataStr := string(notifyDataBytes)
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var payment models.Payment
if err := tx.Where("payment_no = ?", paymentNo).First(&payment).Error; err != nil {
return err
}
// 金额二次校验
expectedAmount := fmt.Sprintf("%.2f", payment.Amount)
if notification.TotalAmount != "" && expectedAmount != notification.TotalAmount {
return fmt.Errorf("payment amount mismatch: expected %s, got %s", expectedAmount, notification.TotalAmount)
}
updates := map[string]interface{}{
"notify_data": notifyDataStr,
}
if strings.TrimSpace(notification.TradeNo) != "" {
updates["trade_no"] = strings.TrimSpace(notification.TradeNo)
}
isPaid := false
switch notification.TradeStatus {
case alipay.TradeStatusSuccess, alipay.TradeStatusFinished:
isPaid = true
updates["status"] = models.PaymentStatusPaid
if notification.GmtPayment != "" {
if t, err := time.ParseInLocation("2006-01-02 15:04:05", notification.GmtPayment, time.Local); err == nil {
updates["payment_time"] = t
}
}
case alipay.TradeStatusClosed:
// 已支付的订单不允许被回调覆盖为取消
if payment.Status != models.PaymentStatusPaid {
updates["status"] = models.PaymentStatusCancelled
}
default:
// WAIT_BUYER_PAY / 未知状态:只记录回调数据,不改变业务状态
}
if err := tx.Model(&models.Payment{}).Where("payment_no = ?", paymentNo).Updates(updates).Error; err != nil {
return err
}
if !isPaid {
return nil
}
// 支付成功:根据业务类型处理
if payment.BusinessType != models.BusinessTypePackageOrder || payment.BusinessID == nil || strings.TrimSpace(*payment.BusinessID) == "" {
return nil
}
var order models.CuOrder
if err := tx.Where("id = ? AND deleted_at IS NULL", *payment.BusinessID).First(&order).Error; err != nil {
return err
}
// 幂等:订单已生成授权码则不重复创建
if order.AuthorizationCode != nil && strings.TrimSpace(*order.AuthorizationCode) != "" && order.Status == "paid" {
return nil
}
// 生成授权码并写入授权码表 & 订单(沿用当前 free order 的规则)
authCode, err := utils.GenerateLegacyAuthorizationCode(order.CustomerID)
if err != nil {
return err
}
now := time.Now()
var startDate, endDate time.Time
var expiredAt *time.Time
if order.PackageID == "trial" {
trialExpiry := time.Date(now.Year(), now.Month(), 25, 23, 59, 59, 0, now.Location())
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)
expiredAt = nil
}
var featureConfigMap, usageLimitsMap map[string]interface{}
switch order.PackageID {
case "basic":
usageLimitsMap = map[string]interface{}{
"type": "basic",
}
case "professional":
usageLimitsMap = map[string]interface{}{
"type": "professional",
}
case "trial":
usageLimitsMap = map[string]interface{}{
"type": "trial",
}
default:
usageLimitsMap = map[string]interface{}{
"type": "default",
}
}
var featureConfig, usageLimits models.JSON
b, err := json.Marshal(featureConfigMap)
if err != nil {
return err
}
featureConfig = models.JSON(b)
if usageLimitsMap != nil {
c, err := json.Marshal(usageLimitsMap)
if err != nil {
return err
}
usageLimits = models.JSON(c)
}
description := fmt.Sprintf("%s - %d个授权", order.PackageName, order.LicenseCount)
encryptionType := "standard"
authCodeEntity := &models.AuthorizationCode{
Code: authCode,
CustomerID: order.CustomerID,
CreatedBy: order.CuUserID,
Description: &description,
StartDate: startDate,
EndDate: endDate,
DeploymentType: "cloud",
EncryptionType: &encryptionType,
MaxActivations: order.LicenseCount,
IsLocked: false,
FeatureConfig: featureConfig,
UsageLimits: usageLimits,
}
if err := tx.Create(authCodeEntity).Error; err != nil {
return err
}
orderUpdates := map[string]interface{}{
"status": "paid",
"authorization_code": authCode,
"expired_at": expiredAt,
"updated_at": now,
}
if err := tx.Model(&models.CuOrder{}).Where("id = ?", order.ID).Updates(orderUpdates).Error; err != nil {
return err
}
return nil
})
}
func (s *paymentService) GetUserPayments(ctx context.Context, customerID, cuUserID string, page, pageSize int) (*models.PaymentListResponse, error) {
offset := (page - 1) * pageSize
payments, total, err := s.paymentRepo.GetByCustomerAndCuUserID(ctx, customerID, cuUserID, offset, pageSize)
if err != nil {
return nil, err
}
paymentResponses := make([]*models.PaymentResponse, len(payments))
for i, payment := range payments {
paymentResponses[i] = payment.ToResponse()
}
return &models.PaymentListResponse{
Payments: paymentResponses,
TotalCount: total,
Page: page,
PageSize: pageSize,
}, nil
}
func (s *paymentService) CancelExpiredPayments(ctx context.Context) error {
expiredPayments, err := s.paymentRepo.GetExpiredPayments(ctx)
if err != nil {
return err
}
for _, payment := range expiredPayments {
err = s.paymentRepo.UpdateStatus(ctx, payment.PaymentNo, models.PaymentStatusExpired, nil, nil)
if err != nil {
fmt.Printf("Failed to cancel expired payment %s: %v\n", payment.PaymentNo, err)
}
}
return nil
}
@@ -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',
@@ -0,0 +1,68 @@
-- 客户用户表结构
-- 基于客户用户管理模块设计创建
-- MySQL版本
-- 创建客户用户表
CREATE TABLE cu_users (
-- 基础信息属性
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
customer_id VARCHAR(36) NOT NULL COMMENT '所属客户ID',
phone VARCHAR(20) NOT NULL COMMENT '手机号,作为登录账号',
phone_country_code VARCHAR(10) NOT NULL DEFAULT '+86' COMMENT '国家代码',
-- 安全信息
password VARCHAR(255) NULL COMMENT '密码(可选,用于增强安全)',
salt VARCHAR(32) NULL COMMENT '密码盐值',
user_role VARCHAR(20) NOT NULL DEFAULT 'member' COMMENT '角色: admin-企业管理员, member-普通成员',
-- 个人信息
real_name VARCHAR(100) NULL COMMENT '真实姓名',
email VARCHAR(255) NULL COMMENT '邮箱(可选)',
-- 状态管理
status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active-正常, disabled-禁用, pending-待激活',
phone_verified TINYINT(1) NOT NULL DEFAULT 1 COMMENT '手机是否验证(注册时已验证)',
email_verified TINYINT(1) NOT NULL DEFAULT 0 COMMENT '邮箱是否验证',
-- 登录安全
last_login_at DATETIME(3) NULL COMMENT '最后登录时间',
last_login_ip VARCHAR(50) NULL COMMENT '最后登录IP',
login_attempts INT NOT NULL DEFAULT 0 COMMENT '登录失败次数',
locked_until DATETIME(3) NULL COMMENT '账号锁定到期时间',
-- 用户偏好设置
avatar_url VARCHAR(500) NULL COMMENT '头像URL',
language VARCHAR(10) NOT NULL DEFAULT 'zh-CN' COMMENT '界面语言',
timezone VARCHAR(50) NOT NULL DEFAULT 'Asia/Shanghai' COMMENT '时区设置',
additional_info JSON NULL COMMENT '附加信息',
remark TEXT NULL COMMENT '备注',
-- 时间字段 (使用DATETIME(3)匹配Go的time.Time和GORM的精度要求)
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
deleted_at DATETIME(3) NULL,
-- 外键约束
CONSTRAINT fk_cu_users_customer_id FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户用户表 - 客户用户管理';
-- 创建索引
CREATE INDEX idx_cu_users_phone ON cu_users(phone, phone_country_code);
CREATE INDEX idx_cu_users_email ON cu_users(email);
CREATE INDEX idx_cu_users_customer_id ON cu_users(customer_id);
CREATE INDEX idx_cu_users_status ON cu_users(status);
CREATE INDEX idx_cu_users_created_at ON cu_users(created_at);
CREATE INDEX idx_cu_users_deleted_at ON cu_users(deleted_at);
CREATE INDEX idx_cu_users_locked_until ON cu_users(locked_until);
-- 复合索引用于常见查询
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);
-- 注意:
-- 1. 时间戳由Go应用程序管理,不依赖数据库默认值
-- 2. password字段可选,用于手机号+密码登录方式
-- 3. phone + phone_country_code 作为复合唯一键,确保手机号唯一性
-- 4. user_role字段保留但暂不用于业务逻辑检查
-- 5. phone_verified在注册时设为true,后续手机号修改需要重新验证
-- 6. 使用软删除,通过deleted_at字段实现
@@ -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字段实现
@@ -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字符的碰撞概率极低
@@ -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. 如果将来需要更长,仍然可以进一步扩展
@@ -0,0 +1,57 @@
-- 支付订单表
-- 支持多种支付方式和业务场景的通用支付表
-- MySQL版本
-- 创建支付订单表
CREATE TABLE payments (
-- 基础信息
id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
payment_no VARCHAR(64) UNIQUE NOT NULL COMMENT '支付单号',
business_type VARCHAR(50) NOT NULL COMMENT '业务类型 (package_order)',
business_id VARCHAR(36) NULL COMMENT '业务ID (关联cu_orders.id)',
-- 用户信息
customer_id VARCHAR(36) NOT NULL COMMENT '客户ID',
cu_user_id VARCHAR(36) NOT NULL COMMENT '客户用户ID',
-- 支付信息
amount DECIMAL(10,2) NOT NULL COMMENT '支付金额',
currency VARCHAR(3) DEFAULT 'CNY' COMMENT '货币类型',
payment_method VARCHAR(20) DEFAULT 'alipay' COMMENT '支付方式',
payment_provider VARCHAR(20) DEFAULT 'alipay' COMMENT '支付提供商',
-- 状态信息
status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '支付状态: pending-待支付, paid-已支付, cancelled-已取消, expired-已过期, failed-支付失败',
trade_no VARCHAR(64) NULL COMMENT '第三方交易号',
payment_time DATETIME(3) NULL COMMENT '支付完成时间',
expire_time DATETIME(3) NOT NULL COMMENT '支付过期时间',
-- 支付链接和回调
payment_url TEXT NULL COMMENT '支付链接',
notify_data JSON NULL COMMENT '回调数据',
extra_data JSON NULL COMMENT '扩展数据',
-- 时间信息
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表 - 支持多种支付方式和业务场景';
-- 创建索引
CREATE INDEX idx_payments_payment_no ON payments(payment_no);
CREATE INDEX idx_payments_business_type_id ON payments(business_type, business_id);
CREATE INDEX idx_payments_customer_id ON payments(customer_id);
CREATE INDEX idx_payments_cu_user_id ON payments(cu_user_id);
CREATE INDEX idx_payments_status ON payments(status);
CREATE INDEX idx_payments_created_at ON payments(created_at);
-- 外键约束
ALTER TABLE payments ADD CONSTRAINT fk_payments_customer_id FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE;
ALTER TABLE payments ADD CONSTRAINT fk_payments_cu_user_id FOREIGN KEY (cu_user_id) REFERENCES cu_users(id) ON DELETE CASCADE;
-- 注意:
-- 1. 时间戳由Go应用程序管理,不依赖数据库默认值
-- 2. payment_no全局唯一,作为支付单的主要标识
-- 3. business_type + business_id 支持关联不同业务场景
-- 4. extra_data字段用于存储支付方式特定的扩展信息
-- 5. 支持软删除(如果需要可以通过deleted_at字段扩展)
+44 -2
View File
@@ -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签名,显著缩短授权码长度
**执行注意事项**:
- 扩展字段长度以适应新的授权码格式
- 不会影响现有数据
- 支持更高效的离线验证
## 执行顺序
按文件名的数字前缀顺序执行:
+265
View File
@@ -0,0 +1,265 @@
# Cache Package
基于Clean Architecture设计的缓存模块,支持内存缓存和Redis缓存两种实现方式。
## 特性
- ✅ 统一的缓存接口抽象
- ✅ 支持内存和Redis缓存
- ✅ 配置驱动,支持运行时切换
- ✅ 优雅降级,缓存异常不影响业务
- ✅ 序列化支持(JSON)
- ✅ 缓存键构建器
- ✅ 性能监控和统计
- ✅ 连接池管理和超时控制
## 快速开始
### 1. 初始化缓存
```go
import "license-manager/pkg/cache"
// 内存缓存
memCache, _ := cache.NewCache(cache.CacheConfig{
Type: "memory",
TTL: time.Hour,
Enabled: true,
Memory: cache.MemoryConfig{
MaxSize: 10000,
},
})
// Redis缓存
redisCache, _ := cache.NewCache(cache.CacheConfig{
Type: "redis",
TTL: time.Hour,
Enabled: true,
Redis: cache.RedisConfig{
Host: "localhost",
Port: 6379,
PoolSize: 10,
},
})
```
### 2. 基本操作
```go
ctx := context.Background()
// 设置缓存
cache.Set(ctx, "key", "value", time.Hour)
// 获取缓存
value, err := cache.Get(ctx, "key")
if err == cache.ErrCacheMiss {
// 缓存未命中
}
// 删除缓存
cache.Del(ctx, "key1", "key2")
// 检查存在
exists, _ := cache.Exists(ctx, "key")
```
### 3. 高级功能
```go
// TTL操作
ttl, _ := cache.TTL(ctx, "key")
cache.Expire(ctx, "key", time.Hour)
// 数值操作
count, _ := cache.Incr(ctx, "counter")
// 批量操作
cache.MSet(ctx, map[string]string{
"k1": "v1",
"k2": "v2",
}, time.Hour)
values, _ := cache.MGet(ctx, "k1", "k2")
```
### 4. 序列化支持
```go
// 使用序列化包装器
cached := &cache.Cached{
Cache: cache,
Serializer: &cache.JSONSerializer{},
}
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
// 存储对象
user := User{ID: 1, Name: "Alice"}
cached.SetObject(ctx, "user:1", user, time.Hour)
// 获取对象
var retrieved User
cached.GetObject(ctx, "user:1", &retrieved)
```
### 5. 键构建器
```go
keyBuilder := cache.NewKeyBuilder("myapp")
userKey := keyBuilder.User("123") // "myapp:user:123"
sessionKey := keyBuilder.Session("abc") // "myapp:session:abc"
smsKey := keyBuilder.SMSCode("13800138000") // "myapp:sms:code:13800138000"
```
## 配置
### 内存缓存配置
```yaml
cache:
enabled: true
type: memory
ttl: 30m
memory:
max_size: 10000 # 最大缓存条目数
cleanup_interval: 10m # 清理间隔
```
### Redis缓存配置
```yaml
cache:
enabled: true
type: redis
ttl: 30m
redis:
host: localhost
port: 6379
password: ""
db: 0
pool_size: 10
min_idle_conns: 5
conn_max_idle_time: 30m
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
```
## 接口定义
```go
type Cache interface {
// 基础操作
Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key string, value string, ttl time.Duration) error
Del(ctx context.Context, keys ...string) error
Exists(ctx context.Context, key string) (bool, error)
// TTL操作
TTL(ctx context.Context, key string) (time.Duration, error)
Expire(ctx context.Context, key string, ttl time.Duration) error
// 数值操作
Incr(ctx context.Context, key string) (int64, error)
Decr(ctx context.Context, key string) (int64, error)
// 批量操作
MGet(ctx context.Context, keys ...string) ([]string, error)
MSet(ctx context.Context, pairs map[string]string, ttl time.Duration) error
// 工具方法
Ping(ctx context.Context) error
Close() error
}
```
## 错误处理
```go
value, err := cache.Get(ctx, "key")
if err != nil {
if err == cache.ErrCacheMiss {
// 缓存未命中,执行数据库查询
return loadFromDatabase()
}
// 其他错误,记录日志但不影响业务
log.Printf("Cache error: %v", err)
}
```
## Redis支持说明
### 当前状态 ✅
Redis缓存支持现已完全可用!支持内存缓存和Redis缓存无缝切换。
### 启用Redis缓存
1. **修改配置**:
```yaml
cache:
enabled: true
type: redis # 切换到redis
ttl: 30m
redis:
host: localhost
port: 6379
password: ""
db: 0
pool_size: 10
min_idle_conns: 5
conn_max_idle_time: 30m
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
```
2. **重启应用**:
应用启动时会自动连接Redis并测试连接。
### 切换回内存缓存
```yaml
cache:
type: memory # 切换回内存缓存
```
### Redis优势
- **分布式缓存**:支持多实例共享缓存
- **持久化**:数据不会因重启丢失
- **丰富的数据结构**:支持List、Set、Hash等
- **原子操作**:Incr/Decr等操作原子性
- **高性能**:连接池和管道操作
- **监控完善**:详细的性能统计
### 依赖版本说明
- Redis客户端:`github.com/redis/go-redis/v9 v9.17.2`
- 阿里云SMS:`github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0`
## 最佳实践
1. **合理设置TTL**:根据业务场景设置合适的过期时间
2. **使用键前缀**:通过KeyBuilder避免键冲突
3. **优雅降级**:使用`safeCacheOperation`确保业务连续性
4. **监控指标**:定期检查缓存命中率和性能指标
5. **序列化优化**:对复杂对象使用JSON序列化
## 性能考虑
- **内存缓存**:适合单机环境,响应速度快
- **Redis缓存**:适合分布式环境,支持持久化
- **连接池**:Redis使用连接池提高并发性能
- **批量操作**:优先使用MGet/MSet减少网络往返
## 扩展
模块设计支持轻松扩展:
1. **新增缓存类型**:实现Cache接口即可
2. **自定义序列化器**:实现Serializer接口
3. **缓存策略**:添加LRU、LFU等淘汰策略
4. **分布式锁**:基于Redis实现分布式锁
+95
View File
@@ -0,0 +1,95 @@
package cache
import (
"context"
"time"
)
// Cache 缓存接口
type Cache interface {
// 基础操作
Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key string, value string, ttl time.Duration) error
Del(ctx context.Context, keys ...string) error
Exists(ctx context.Context, key string) (bool, error)
// TTL操作
TTL(ctx context.Context, key string) (time.Duration, error)
Expire(ctx context.Context, key string, ttl time.Duration) error
// 数值操作
Incr(ctx context.Context, key string) (int64, error)
Decr(ctx context.Context, key string) (int64, error)
// 批量操作
MGet(ctx context.Context, keys ...string) ([]string, error)
MSet(ctx context.Context, pairs map[string]string, ttl time.Duration) error
// 工具方法
Ping(ctx context.Context) error
Close() error
}
// CacheWithTTL 带TTL查询的缓存接口
type CacheWithTTL interface {
Cache
GetWithTTL(ctx context.Context, key string) (string, time.Duration, error)
}
// Serializer 序列化接口
type Serializer interface {
Marshal(v interface{}) ([]byte, error)
Unmarshal(data []byte, v interface{}) error
}
// 错误定义
var (
ErrCacheMiss = NewCacheError("cache miss")
)
// CacheError 缓存错误
type CacheError struct {
Message string
}
func (e *CacheError) Error() string {
return e.Message
}
func NewCacheError(message string) *CacheError {
return &CacheError{Message: message}
}
// CacheStats 缓存统计信息
type CacheStats struct {
Hits int64 // 命中次数
Misses int64 // 未命中次数
Errors int64 // 错误次数
HitRate float64 // 命中率
TotalOps int64 // 总操作数
AvgRespTime int64 // 平均响应时间(纳秒)
}
func (s *CacheStats) RecordHit() {
s.Hits++
s.updateHitRate()
s.TotalOps++
}
func (s *CacheStats) RecordMiss() {
s.Misses++
s.updateHitRate()
s.TotalOps++
}
func (s *CacheStats) RecordError() {
s.Errors++
s.TotalOps++
}
func (s *CacheStats) updateHitRate() {
total := s.Hits + s.Misses
if total > 0 {
s.HitRate = float64(s.Hits) / float64(total)
}
}
+180
View File
@@ -0,0 +1,180 @@
package cache
import (
"context"
"testing"
"time"
)
func TestMemoryCache(t *testing.T) {
cache := NewMemoryCache(100)
ctx := context.Background()
// Test Set and Get
err := cache.Set(ctx, "test:key", "test_value", time.Minute)
if err != nil {
t.Fatalf("Set failed: %v", err)
}
value, err := cache.Get(ctx, "test:key")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if value != "test_value" {
t.Errorf("Expected 'test_value', got '%s'", value)
}
// Test cache miss
_, err = cache.Get(ctx, "nonexistent:key")
if err != ErrCacheMiss {
t.Errorf("Expected ErrCacheMiss, got %v", err)
}
// Test Exists
exists, err := cache.Exists(ctx, "test:key")
if err != nil || !exists {
t.Errorf("Exists failed or returned false")
}
exists, err = cache.Exists(ctx, "nonexistent:key")
if err != nil || exists {
t.Errorf("Exists should return false for nonexistent key")
}
// Test TTL
ttl, err := cache.TTL(ctx, "test:key")
if err != nil {
t.Fatalf("TTL failed: %v", err)
}
if ttl <= 0 {
t.Errorf("TTL should be positive")
}
// Test Del
err = cache.Del(ctx, "test:key")
if err != nil {
t.Fatalf("Del failed: %v", err)
}
// Verify deletion
_, err = cache.Get(ctx, "test:key")
if err != ErrCacheMiss {
t.Errorf("Key should be deleted")
}
}
func TestKeyBuilder(t *testing.T) {
kb := NewKeyBuilder("myapp")
userKey := kb.User("123")
expected := "myapp:user:123"
if userKey != expected {
t.Errorf("Expected '%s', got '%s'", expected, userKey)
}
sessionKey := kb.Session("abc")
expected = "myapp:session:abc"
if sessionKey != expected {
t.Errorf("Expected '%s', got '%s'", expected, sessionKey)
}
smsKey := kb.SMSCode("13800138000")
expected = "myapp:sms:code:13800138000"
if smsKey != expected {
t.Errorf("Expected '%s', got '%s'", expected, smsKey)
}
}
func TestCachedWrapper(t *testing.T) {
cache := NewMemoryCache(100)
cached := &Cached{
Cache: cache,
Serializer: &JSONSerializer{},
}
ctx := context.Background()
type TestUser struct {
ID int `json:"id"`
Name string `json:"name"`
}
user := TestUser{ID: 1, Name: "Alice"}
// Test SetObject
err := cached.SetObject(ctx, "user:1", user, time.Minute)
if err != nil {
t.Fatalf("SetObject failed: %v", err)
}
// Test GetObject
var retrieved TestUser
err = cached.GetObject(ctx, "user:1", &retrieved)
if err != nil {
t.Fatalf("GetObject failed: %v", err)
}
if retrieved.ID != user.ID || retrieved.Name != user.Name {
t.Errorf("Retrieved object doesn't match original")
}
}
func TestCacheFactory(t *testing.T) {
// Test memory cache
config := CacheConfig{
Type: "memory",
TTL: time.Hour,
Enabled: true,
Memory: MemoryConfig{
MaxSize: 100,
},
}
cache, err := NewCache(config)
if err != nil {
t.Fatalf("NewCache failed: %v", err)
}
if cache == nil {
t.Fatal("Cache should not be nil")
}
// Test disabled cache
config.Enabled = false
cache, err = NewCache(config)
if err != nil {
t.Fatalf("NewCache with disabled should not fail: %v", err)
}
// Should be noOpCache
ctx := context.Background()
err = cache.Set(ctx, "test", "value", time.Minute)
if err != nil {
t.Errorf("noOpCache Set should not fail: %v", err)
}
_, err = cache.Get(ctx, "test")
if err != ErrCacheMiss {
t.Errorf("noOpCache Get should return ErrCacheMiss")
}
}
func TestRedisCacheUnavailable(t *testing.T) {
config := CacheConfig{
Type: "redis",
TTL: time.Hour,
Enabled: true,
Redis: RedisConfig{
Host: "localhost",
Port: 6379,
},
}
_, err := NewCache(config)
if err == nil {
t.Error("Redis cache should not be available due to dependency issues")
}
}
+165
View File
@@ -0,0 +1,165 @@
// Package cache 缓存模块使用示例
package cache
import (
"context"
"fmt"
"log"
"time"
)
// ExampleUsage 缓存使用示例
func ExampleUsage() {
ctx := context.Background()
// 1. 初始化内存缓存
memCache, _ := NewCache(CacheConfig{
Type: "memory",
TTL: time.Hour,
Enabled: true,
Memory: MemoryConfig{
MaxSize: 1000,
},
})
// 2. 基本操作
memCache.Set(ctx, "user:123", "John Doe", time.Hour)
user, _ := memCache.Get(ctx, "user:123")
fmt.Printf("User: %s\n", user)
// 3. 批量操作
memCache.MSet(ctx, map[string]string{
"config:app": "v1.0.0",
"config:env": "production",
"config:host": "api.example.com",
}, time.Hour)
configs, _ := memCache.MGet(ctx, "config:app", "config:env", "config:host")
fmt.Printf("Configs: %v\n", configs)
// 4. 使用键构建器
keyBuilder := NewKeyBuilder("myapp")
userKey := keyBuilder.User("123")
sessionKey := keyBuilder.Session("abc123")
smsKey := keyBuilder.SMSCode("+8613800012345")
memCache.Set(ctx, userKey, "user_data", time.Hour)
memCache.Set(ctx, sessionKey, "session_data", 30*time.Minute)
memCache.Set(ctx, smsKey, "123456", 5*time.Minute)
// 5. 使用序列化包装器
cached := &Cached{
Cache: memCache,
Serializer: &JSONSerializer{},
}
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
}
userObj := User{ID: 1, Name: "Alice", Age: 30}
cached.SetObject(ctx, "user:object:1", userObj, time.Hour)
var retrievedUser User
cached.GetObject(ctx, "user:object:1", &retrievedUser)
fmt.Printf("Retrieved user: %+v\n", retrievedUser)
// 6. 数值操作
count, _ := memCache.Incr(ctx, "counter:visits")
fmt.Printf("Visit count: %d\n", count)
// 7. TTL操作
memCache.Set(ctx, "temp:key", "temp_value", 10*time.Second)
ttl, _ := memCache.TTL(ctx, "temp:key")
fmt.Printf("TTL: %v\n", ttl)
// 8. 统计信息
if memCacheWithStats, ok := memCache.(interface{ GetStats() CacheStats }); ok {
stats := memCacheWithStats.GetStats()
fmt.Printf("Cache stats - Hits: %d, Misses: %d, HitRate: %.2f%%\n",
stats.Hits, stats.Misses, stats.HitRate*100)
}
// 9. 优雅关闭
memCache.Close()
}
// ExampleRedisUsage Redis缓存使用示例
func ExampleRedisUsage() {
// 初始化Redis缓存
redisCache, err := NewCache(CacheConfig{
Type: "redis",
TTL: time.Hour,
Enabled: true,
Redis: RedisConfig{
Host: "localhost",
Port: 6379,
Password: "",
DB: 0,
PoolSize: 10,
MinIdleConns: 5,
ConnMaxIdleTime: 30 * time.Minute,
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
},
})
if err != nil {
log.Printf("Failed to create Redis cache: %v", err)
return
}
ctx := context.Background()
// 基础操作
err = redisCache.Set(ctx, "user:redis:123", "Redis User", time.Hour)
if err != nil {
log.Printf("Redis set error: %v", err)
return
}
user, err := redisCache.Get(ctx, "user:redis:123")
if err != nil {
log.Printf("Redis get error: %v", err)
} else {
fmt.Printf("Redis user: %s\n", user)
}
// Redis特有的批量操作会更高效
data := make(map[string]string)
for i := 0; i < 10; i++ {
data[fmt.Sprintf("batch:key:%d", i)] = fmt.Sprintf("redis_value_%d", i)
}
redisCache.MSet(ctx, data, time.Hour)
// 数值操作在Redis中非常高效
counter, _ := redisCache.Incr(ctx, "redis:global:counter")
fmt.Printf("Redis global counter: %d\n", counter)
// TTL查询
ttl, _ := redisCache.TTL(ctx, "user:redis:123")
fmt.Printf("TTL remaining: %v\n", ttl)
redisCache.Close()
}
// ExampleSafeCacheOperation 优雅降级的缓存操作示例
func ExampleSafeCacheOperation(cache Cache) error {
return safeCacheOperation(cache, func() error {
ctx := context.Background()
// 正常的缓存操作
cache.Set(ctx, "safe:key", "safe_value", time.Hour)
value, err := cache.Get(ctx, "safe:key")
if err != nil {
return err
}
fmt.Printf("Safe cache value: %s\n", value)
return nil
})
}
+141
View File
@@ -0,0 +1,141 @@
package cache
import (
"context"
"encoding/json"
"fmt"
"time"
)
// CacheConfig 缓存配置
type CacheConfig struct {
Type string `mapstructure:"type"` // 缓存类型: memory, redis
TTL time.Duration `mapstructure:"ttl"` // 默认TTL
Enabled bool `mapstructure:"enabled"` // 是否启用缓存
Redis RedisConfig `mapstructure:"redis"` // Redis配置
Memory MemoryConfig `mapstructure:"memory"` // 内存配置
}
// MemoryConfig 内存配置
type MemoryConfig struct {
MaxSize int `mapstructure:"max_size"` // 最大缓存条目数
CleanupInterval time.Duration `mapstructure:"cleanup_interval"` // 清理间隔
}
// NewCache 创建缓存实例
func NewCache(config CacheConfig) (Cache, error) {
if !config.Enabled {
return &noOpCache{}, nil
}
switch config.Type {
case "memory":
cache := NewMemoryCache(config.Memory.MaxSize)
return cache, nil
case "redis":
// TODO: Redis支持暂时不可用,需要解决依赖版本冲突
return nil, fmt.Errorf("Redis cache not available: dependency issue, use memory cache instead")
default:
return nil, fmt.Errorf("unsupported cache type: %s", config.Type)
}
}
// noOpCache 空操作缓存(当缓存被禁用时使用)
type noOpCache struct{}
func (n *noOpCache) Get(ctx context.Context, key string) (string, error) {
return "", ErrCacheMiss
}
func (n *noOpCache) Set(ctx context.Context, key string, value string, ttl time.Duration) error {
return nil
}
func (n *noOpCache) Del(ctx context.Context, keys ...string) error {
return nil
}
func (n *noOpCache) Exists(ctx context.Context, key string) (bool, error) {
return false, nil
}
func (n *noOpCache) TTL(ctx context.Context, key string) (time.Duration, error) {
return -1, nil
}
func (n *noOpCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
return nil
}
func (n *noOpCache) Incr(ctx context.Context, key string) (int64, error) {
return 0, nil
}
func (n *noOpCache) Decr(ctx context.Context, key string) (int64, error) {
return 0, nil
}
func (n *noOpCache) MGet(ctx context.Context, keys ...string) ([]string, error) {
results := make([]string, len(keys))
return results, nil
}
func (n *noOpCache) MSet(ctx context.Context, pairs map[string]string, ttl time.Duration) error {
return nil
}
func (n *noOpCache) Ping(ctx context.Context) error {
return nil
}
func (n *noOpCache) Close() error {
return nil
}
// JSONSerializer JSON序列化器
type JSONSerializer struct{}
func (j *JSONSerializer) Marshal(v interface{}) ([]byte, error) {
return json.Marshal(v)
}
func (j *JSONSerializer) Unmarshal(data []byte, v interface{}) error {
return json.Unmarshal(data, v)
}
// Cached 带序列化的缓存包装器
type Cached struct {
Cache
Serializer Serializer
}
func (c *Cached) SetObject(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
data, err := c.Serializer.Marshal(value)
if err != nil {
return err
}
return c.Cache.Set(ctx, key, string(data), ttl)
}
func (c *Cached) GetObject(ctx context.Context, key string, dest interface{}) error {
value, err := c.Cache.Get(ctx, key)
if err != nil {
return err
}
return c.Serializer.Unmarshal([]byte(value), dest)
}
// safeCacheOperation 优雅降级的缓存操作
func safeCacheOperation(cache Cache, operation func() error) error {
defer func() {
if r := recover(); r != nil {
// 记录错误日志,但不影响业务
}
}()
if cache == nil {
return nil // 缓存未启用,直接跳过
}
return operation()
}
+73
View File
@@ -0,0 +1,73 @@
package cache
import (
"strings"
)
// KeyBuilder 缓存键构建器
type KeyBuilder struct {
prefix string
separator string
}
// NewKeyBuilder 创建键构建器
func NewKeyBuilder(prefix string) *KeyBuilder {
return &KeyBuilder{
prefix: prefix,
separator: ":",
}
}
// Build 构建缓存键
func (k *KeyBuilder) Build(parts ...string) string {
if k.prefix != "" {
parts = append([]string{k.prefix}, parts...)
}
return strings.Join(parts, k.separator)
}
// User 构建用户相关键
func (k *KeyBuilder) User(userID string) string {
return k.Build("user", userID)
}
// Session 构建会话相关键
func (k *KeyBuilder) Session(sessionID string) string {
return k.Build("session", sessionID)
}
// SMSCode 构建短信验证码键
func (k *KeyBuilder) SMSCode(phone string) string {
return k.Build("sms", "code", phone)
}
// SMSLimit 构建短信频率限制键
func (k *KeyBuilder) SMSLimit(phone string) string {
return k.Build("sms", "limit", phone)
}
// Config 构建配置相关键
func (k *KeyBuilder) Config(key string) string {
return k.Build("config", key)
}
// Lock 构建分布式锁键
func (k *KeyBuilder) Lock(resource string) string {
return k.Build("lock", resource)
}
// Counter 构建计数器键
func (k *KeyBuilder) Counter(name string) string {
return k.Build("counter", name)
}
// 预定义键模式常量
const (
UserPrefix = "user"
SessionPrefix = "session"
SMSCodePrefix = "sms:code"
SMSLimitPrefix = "sms:limit"
ConfigPrefix = "config"
LockPrefix = "lock"
CounterPrefix = "counter"
)
+245
View File
@@ -0,0 +1,245 @@
package cache
import (
"context"
"sync"
"time"
)
// memoryCache 内存缓存实现
type memoryCache struct {
data sync.Map
ttlMap sync.Map
maxSize int
stats CacheStats
}
// NewMemoryCache 创建内存缓存
func NewMemoryCache(maxSize int) Cache {
cache := &memoryCache{
data: sync.Map{},
ttlMap: sync.Map{},
maxSize: maxSize,
}
// 启动清理协程
go cache.cleanupRoutine()
return cache
}
// cleanupRoutine 定期清理过期数据
func (m *memoryCache) cleanupRoutine() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
m.cleanupExpired()
}
}
// cleanupExpired 清理过期数据
func (m *memoryCache) cleanupExpired() {
now := time.Now()
m.ttlMap.Range(func(key, value interface{}) bool {
if expireTime, ok := value.(time.Time); ok && now.After(expireTime) {
m.data.Delete(key)
m.ttlMap.Delete(key)
}
return true
})
}
func (m *memoryCache) Get(ctx context.Context, key string) (string, error) {
defer func(start time.Time) {
m.stats.AvgRespTime = (m.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
value, ok := m.data.Load(key)
if !ok {
m.stats.RecordMiss()
return "", ErrCacheMiss
}
// 检查是否过期
if ttl, exists := m.ttlMap.Load(key); exists {
if expireTime, ok := ttl.(time.Time); ok && time.Now().After(expireTime) {
m.data.Delete(key)
m.ttlMap.Delete(key)
m.stats.RecordMiss()
return "", ErrCacheMiss
}
}
m.stats.RecordHit()
if str, ok := value.(string); ok {
return str, nil
}
m.stats.RecordError()
return "", NewCacheError("invalid cache value type")
}
func (m *memoryCache) Set(ctx context.Context, key string, value string, ttl time.Duration) error {
defer func(start time.Time) {
m.stats.AvgRespTime = (m.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
// 简单的容量控制(生产环境建议使用LRU库)
if m.maxSize > 0 {
// 简单的清理策略:当存储量接近最大值时,清理一些过期数据
size := 0
m.data.Range(func(key, value interface{}) bool {
size++
return size < m.maxSize
})
if size >= m.maxSize {
m.cleanupExpired()
}
}
m.data.Store(key, value)
if ttl > 0 {
m.ttlMap.Store(key, time.Now().Add(ttl))
} else {
m.ttlMap.Delete(key) // 永不过期
}
return nil
}
func (m *memoryCache) Del(ctx context.Context, keys ...string) error {
defer func(start time.Time) {
m.stats.AvgRespTime = (m.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
for _, key := range keys {
m.data.Delete(key)
m.ttlMap.Delete(key)
}
return nil
}
func (m *memoryCache) Exists(ctx context.Context, key string) (bool, error) {
defer func(start time.Time) {
m.stats.AvgRespTime = (m.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
_, exists := m.data.Load(key)
if !exists {
return false, nil
}
// 检查是否过期
if ttl, exists := m.ttlMap.Load(key); exists {
if expireTime, ok := ttl.(time.Time); ok && time.Now().After(expireTime) {
m.data.Delete(key)
m.ttlMap.Delete(key)
return false, nil
}
}
return true, nil
}
func (m *memoryCache) TTL(ctx context.Context, key string) (time.Duration, error) {
defer func(start time.Time) {
m.stats.AvgRespTime = (m.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
ttl, exists := m.ttlMap.Load(key)
if !exists {
return -1, nil // 没有设置过期时间
}
if expireTime, ok := ttl.(time.Time); ok {
remaining := time.Until(expireTime)
if remaining <= 0 {
m.data.Delete(key)
m.ttlMap.Delete(key)
return -2, nil // 已过期
}
return remaining, nil
}
return -1, nil
}
func (m *memoryCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
defer func(start time.Time) {
m.stats.AvgRespTime = (m.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
if ttl <= 0 {
m.ttlMap.Delete(key)
return nil
}
// 检查key是否存在
if _, exists := m.data.Load(key); !exists {
return NewCacheError("key not found")
}
m.ttlMap.Store(key, time.Now().Add(ttl))
return nil
}
func (m *memoryCache) Incr(ctx context.Context, key string) (int64, error) {
defer func(start time.Time) {
m.stats.AvgRespTime = (m.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
// 内存缓存的数值操作实现比较简单
return 0, NewCacheError("Incr not implemented for memory cache")
}
func (m *memoryCache) Decr(ctx context.Context, key string) (int64, error) {
defer func(start time.Time) {
m.stats.AvgRespTime = (m.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
return 0, NewCacheError("Decr not implemented for memory cache")
}
func (m *memoryCache) MGet(ctx context.Context, keys ...string) ([]string, error) {
defer func(start time.Time) {
m.stats.AvgRespTime = (m.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
results := make([]string, len(keys))
for i, key := range keys {
if value, err := m.Get(ctx, key); err == nil {
results[i] = value
} else {
results[i] = ""
}
}
return results, nil
}
func (m *memoryCache) MSet(ctx context.Context, pairs map[string]string, ttl time.Duration) error {
defer func(start time.Time) {
m.stats.AvgRespTime = (m.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
for key, value := range pairs {
if err := m.Set(ctx, key, value, ttl); err != nil {
return err
}
}
return nil
}
func (m *memoryCache) Ping(ctx context.Context) error {
return nil
}
func (m *memoryCache) Close() error {
return nil
}
// GetStats 获取缓存统计信息
func (m *memoryCache) GetStats() CacheStats {
return m.stats
}
+238
View File
@@ -0,0 +1,238 @@
package cache
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// RedisConfig Redis配置
type RedisConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
PoolSize int `mapstructure:"pool_size"`
MinIdleConns int `mapstructure:"min_idle_conns"`
ConnMaxIdleTime time.Duration `mapstructure:"conn_max_idle_time"`
DialTimeout time.Duration `mapstructure:"dial_timeout"`
ReadTimeout time.Duration `mapstructure:"read_timeout"`
WriteTimeout time.Duration `mapstructure:"write_timeout"`
}
// redisCache Redis缓存实现
type redisCache struct {
client *redis.Client
ttl time.Duration
stats CacheStats
}
// NewRedisCache 创建Redis缓存
func NewRedisCache(config RedisConfig, defaultTTL time.Duration) (Cache, error) {
client := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", config.Host, config.Port),
Password: config.Password,
DB: config.DB,
PoolSize: config.PoolSize,
MinIdleConns: config.MinIdleConns,
ConnMaxIdleTime: config.ConnMaxIdleTime,
DialTimeout: config.DialTimeout,
ReadTimeout: config.ReadTimeout,
WriteTimeout: config.WriteTimeout,
})
// 测试连接
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("failed to connect to Redis: %w", err)
}
return &redisCache{
client: client,
ttl: defaultTTL,
}, nil
}
func (r *redisCache) Get(ctx context.Context, key string) (string, error) {
defer func(start time.Time) {
r.stats.AvgRespTime = (r.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
value, err := r.client.Get(ctx, key).Result()
if err == redis.Nil {
r.stats.RecordMiss()
return "", ErrCacheMiss
}
if err != nil {
r.stats.RecordError()
return "", NewCacheError(fmt.Sprintf("redis get error: %v", err))
}
r.stats.RecordHit()
return value, nil
}
func (r *redisCache) Set(ctx context.Context, key string, value string, ttl time.Duration) error {
defer func(start time.Time) {
r.stats.AvgRespTime = (r.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
if ttl == 0 {
ttl = r.ttl
}
err := r.client.Set(ctx, key, value, ttl).Err()
if err != nil {
r.stats.RecordError()
return NewCacheError(fmt.Sprintf("redis set error: %v", err))
}
return nil
}
func (r *redisCache) Del(ctx context.Context, keys ...string) error {
defer func(start time.Time) {
r.stats.AvgRespTime = (r.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
err := r.client.Del(ctx, keys...).Err()
if err != nil {
r.stats.RecordError()
return NewCacheError(fmt.Sprintf("redis del error: %v", err))
}
return nil
}
func (r *redisCache) Exists(ctx context.Context, key string) (bool, error) {
defer func(start time.Time) {
r.stats.AvgRespTime = (r.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
count, err := r.client.Exists(ctx, key).Result()
if err != nil {
r.stats.RecordError()
return false, NewCacheError(fmt.Sprintf("redis exists error: %v", err))
}
return count > 0, nil
}
func (r *redisCache) TTL(ctx context.Context, key string) (time.Duration, error) {
defer func(start time.Time) {
r.stats.AvgRespTime = (r.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
ttl, err := r.client.TTL(ctx, key).Result()
if err != nil {
r.stats.RecordError()
return 0, NewCacheError(fmt.Sprintf("redis ttl error: %v", err))
}
return ttl, nil
}
func (r *redisCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
defer func(start time.Time) {
r.stats.AvgRespTime = (r.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
success, err := r.client.Expire(ctx, key, ttl).Result()
if err != nil {
r.stats.RecordError()
return NewCacheError(fmt.Sprintf("redis expire error: %v", err))
}
if !success {
return NewCacheError("key not found")
}
return nil
}
func (r *redisCache) Incr(ctx context.Context, key string) (int64, error) {
defer func(start time.Time) {
r.stats.AvgRespTime = (r.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
value, err := r.client.Incr(ctx, key).Result()
if err != nil {
r.stats.RecordError()
return 0, NewCacheError(fmt.Sprintf("redis incr error: %v", err))
}
return value, nil
}
func (r *redisCache) Decr(ctx context.Context, key string) (int64, error) {
defer func(start time.Time) {
r.stats.AvgRespTime = (r.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
value, err := r.client.Decr(ctx, key).Result()
if err != nil {
r.stats.RecordError()
return 0, NewCacheError(fmt.Sprintf("redis decr error: %v", err))
}
return value, nil
}
func (r *redisCache) MGet(ctx context.Context, keys ...string) ([]string, error) {
defer func(start time.Time) {
r.stats.AvgRespTime = (r.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
values, err := r.client.MGet(ctx, keys...).Result()
if err != nil {
r.stats.RecordError()
return nil, NewCacheError(fmt.Sprintf("redis mget error: %v", err))
}
results := make([]string, len(values))
for i, v := range values {
if v != nil {
results[i] = fmt.Sprintf("%v", v)
} else {
results[i] = ""
}
}
return results, nil
}
func (r *redisCache) MSet(ctx context.Context, pairs map[string]string, ttl time.Duration) error {
defer func(start time.Time) {
r.stats.AvgRespTime = (r.stats.AvgRespTime + time.Since(start).Nanoseconds()) / 2
}(time.Now())
if ttl == 0 {
ttl = r.ttl
}
// Redis MSet 不支持每个key单独设置TTL,所以这里使用管道
pipe := r.client.Pipeline()
for key, value := range pairs {
pipe.Set(ctx, key, value, ttl)
}
_, err := pipe.Exec(ctx)
if err != nil {
r.stats.RecordError()
return NewCacheError(fmt.Sprintf("redis mset error: %v", err))
}
return nil
}
func (r *redisCache) Ping(ctx context.Context) error {
return r.client.Ping(ctx).Err()
}
func (r *redisCache) Close() error {
return r.client.Close()
}
+13 -12
View File
@@ -22,15 +22,16 @@ func NewErrorI18n(manager I18nManager) *ErrorI18n {
func NewI18nErrorResponse(code, lang string, customMessage ...string) (int, string, string) {
// 获取本地化错误信息
message := GetI18nErrorMessage(code, lang)
// 支持自定义消息覆盖
// 如果提供了自定义消息,则使用自定义消息
// 这样可以让调用方根据需要显示具体错误信息或本地化消息
if len(customMessage) > 0 && customMessage[0] != "" {
message = customMessage[0]
}
// 获取对应的HTTP状态码
httpStatus := getHTTPStatusByCode(code)
return httpStatus, code, message
}
@@ -40,7 +41,7 @@ func GetI18nErrorMessage(code, lang string) string {
// 如果多语言系统未初始化,降级到默认错误处理
return getDefaultErrorMessage(code)
}
return globalManager.GetErrorMessage(code, lang)
}
@@ -49,7 +50,7 @@ func GetI18nErrorMessageWithManager(manager I18nManager, code, lang string) stri
if manager == nil {
return getDefaultErrorMessage(code)
}
return manager.GetErrorMessage(code, lang)
}
@@ -77,7 +78,7 @@ func getDefaultErrorMessage(code string) string {
"900003": "Resource conflict",
"900004": "Internal server error",
}
if msg, ok := defaultMessages[code]; ok {
return msg
}
@@ -96,7 +97,7 @@ func getHTTPStatusByCode(code string) int {
StatusConflict = 409
StatusInternalServerError = 500
)
switch code {
case "000000": // 成功
return StatusOK
@@ -149,7 +150,7 @@ func (e *I18nError) Error() string {
// NewI18nError 创建多语言错误
func NewI18nError(code, lang string, customMessage ...string) *I18nError {
httpCode, _, message := NewI18nErrorResponse(code, lang, customMessage...)
return &I18nError{
Code: code,
Lang: lang,
@@ -196,12 +197,12 @@ func ValidateI18nSupport() error {
if globalManager == nil {
return fmt.Errorf("i18n manager not initialized")
}
supportedLangs := globalManager.SupportedLanguages()
if len(supportedLangs) == 0 {
return fmt.Errorf("no languages loaded")
}
return nil
}
@@ -219,4 +220,4 @@ func IsLanguageSupported(lang string) bool {
return false
}
return globalManager.IsLanguageSupported(lang)
}
}
+35
View File
@@ -0,0 +1,35 @@
package utils
import (
"fmt"
"net/http"
"time"
)
// GeneratePaymentNo 生成支付单号
func GeneratePaymentNo() string {
timestamp := time.Now().Format("20060102150405")
random := GenerateRandomString(6)
return fmt.Sprintf("PAY%s%s", timestamp, random)
}
// GenerateRandomString 生成随机字符串
func GenerateRandomString(length int) string {
const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
result := make([]byte, length)
for i := range result {
result[i] = charset[time.Now().UnixNano()%int64(len(charset))]
time.Sleep(1 * time.Nanosecond) // 简单的随机性保证
}
return string(result)
}
// SendNotificationResponse 发送通知响应
func SendNotificationResponse(w http.ResponseWriter, success bool) {
response := "success"
if !success {
response = "failure"
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(response))
}
+51
View File
@@ -0,0 +1,51 @@
package utils
import (
"crypto/rand"
"fmt"
"strings"
)
// GenerateLegacyAuthorizationCode 生成旧规则授权码:
// LIC-{客户ID前4位}-{12位随机}-{4位校验码}
func GenerateLegacyAuthorizationCode(customerID string) (string, error) {
customerCode := "COMP"
if len(customerID) >= 4 {
customerCode = strings.ToUpper(customerID[:4])
}
randomStr, err := generateSecureRandomString(12)
if err != nil {
return "", err
}
checksum := generateChecksum62(customerCode + randomStr)
return fmt.Sprintf("LIC-%s-%s-%s", customerCode, randomStr, checksum), nil
}
func generateSecureRandomString(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
}
func generateChecksum62(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
}
+25
View File
@@ -7,6 +7,7 @@ import (
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
@@ -21,12 +22,36 @@ func HashPassword(password string) (string, error) {
return string(bytes), err
}
// HashPasswordWithSalt 使用盐值哈希密码
func HashPasswordWithSalt(password, salt string) (string, error) {
combined := password + salt
bytes, err := bcrypt.GenerateFromPassword([]byte(combined), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPasswordHash 验证密码
func CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
// GenerateSalt 生成随机盐值
func GenerateSalt() string {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
// 如果随机数生成失败,使用固定值(不推荐用于生产环境)
return "defaultsalt123"
}
return hex.EncodeToString(bytes)
}
// CheckPassword 使用盐值验证密码
func CheckPassword(password, hash, salt string) bool {
combined := password + salt
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(combined))
return err == nil
}
// RSAPrivateKey RSA私钥结构
type RSAPrivateKey struct {
*rsa.PrivateKey
+115
View File
@@ -0,0 +1,115 @@
package utils
import (
"errors"
"time"
"license-manager/internal/config"
"github.com/golang-jwt/jwt/v5"
)
type CuClaims struct {
UserID string `json:"user_id"`
CustomerID string `json:"customer_id"`
UserRole string `json:"user_role"`
Phone string `json:"phone"`
jwt.RegisteredClaims
}
var (
ErrCuTokenExpired = errors.New("cu token has expired")
ErrCuTokenNotValid = errors.New("cu token is not valid")
ErrCuTokenMalformed = errors.New("cu token is malformed")
ErrCuTokenNotFound = errors.New("cu token not found")
)
// GenerateCuToken 生成客户用户JWT Token
func GenerateCuToken(userID, customerID, userRole, phone string) (string, error) {
cfg := config.GetConfig()
if cfg == nil {
return "", errors.New("config not initialized")
}
expireTime := time.Now().Add(time.Duration(cfg.Auth.CuJWT.ExpireHours) * time.Hour)
claims := &CuClaims{
UserID: userID,
CustomerID: customerID,
UserRole: userRole,
Phone: phone,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expireTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "license-manager-cu",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(cfg.Auth.CuJWT.Secret))
if err != nil {
return "", err
}
return tokenString, nil
}
// ParseCuToken 解析客户用户JWT Token
func ParseCuToken(tokenString string) (*CuClaims, error) {
cfg := config.GetConfig()
if cfg == nil {
return nil, errors.New("config not initialized")
}
token, err := jwt.ParseWithClaims(tokenString, &CuClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return []byte(cfg.Auth.CuJWT.Secret), nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrCuTokenExpired
}
if errors.Is(err, jwt.ErrTokenMalformed) {
return nil, ErrCuTokenMalformed
}
return nil, ErrCuTokenNotValid
}
if claims, ok := token.Claims.(*CuClaims); ok && token.Valid {
return claims, nil
}
return nil, ErrCuTokenNotValid
}
// ValidateCuToken 验证客户用户Token是否有效
func ValidateCuToken(tokenString string) (*CuClaims, error) {
return ParseCuToken(tokenString)
}
// RefreshCuTokenIfNeeded 检查客户用户Token是否需要刷新,如果需要则返回新Token
func RefreshCuTokenIfNeeded(tokenString string) (string, error) {
claims, err := ParseCuToken(tokenString)
if err != nil {
return "", err
}
cfg := config.GetConfig()
if cfg == nil {
return "", errors.New("config not initialized")
}
// 检查是否需要刷新(剩余时间少于阈值)
thresholdDuration := time.Duration(cfg.Auth.CuJWT.RefreshThresholdMinutes) * time.Minute
if time.Until(claims.ExpiresAt.Time) < thresholdDuration {
// 生成新Token
return GenerateCuToken(claims.UserID, claims.CustomerID, claims.UserRole, claims.Phone)
}
// 不需要刷新,返回原Token
return tokenString, nil
}
+354
View File
@@ -0,0 +1,354 @@
package utils
import (
"context"
"errors"
"fmt"
"math/rand"
"time"
"license-manager/internal/config"
"license-manager/pkg/cache"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20170525/v5/client"
util "github.com/alibabacloud-go/tea-utils/v2/service"
"github.com/alibabacloud-go/tea/tea"
)
// 短信相关错误定义
var (
ErrSMSCodeNotExist = errors.New("sms_code_not_exist")
ErrSMSCodeExpired = errors.New("sms_code_expired")
ErrSMSRateLimited = errors.New("sms_rate_limited")
ErrSMSSendFailed = errors.New("sms_send_failed")
ErrSMSCodeIncorrect = errors.New("sms_code_incorrect")
)
// 默认短信模板常量
const (
DefaultTemplateRegister = "SMS_330275014"
DefaultTemplateResetPwd = "SMS_330275015"
DefaultTemplateLogin = "SMS_330275016"
DefaultTemplateCurrentPhone = "SMS_330275017" // 当前手机号验证码
DefaultTemplateNewPhone = "SMS_330275018" // 新手机号验证码
)
// 缓存键常量
const (
SMSCodeKeyPrefix = "sms:code"
SMSLimitKeyPrefix = "sms:limit"
SMSCodeExpireTime = 5 * time.Minute
SMSLimitTimeWindow = time.Hour
SMSLimitMaxCount = 5
)
// SMSService 短信服务接口
type SMSService interface {
SendVerificationCode(ctx context.Context, phone, phoneCountryCode, templateCode string) error
VerifyCode(ctx context.Context, phone, phoneCountryCode, code string) (bool, error)
IsRateLimited(ctx context.Context, phone, phoneCountryCode string) bool
SendRegisterCode(ctx context.Context, phone, phoneCountryCode string) error
SendResetPwdCode(ctx context.Context, phone, phoneCountryCode string) error
SendLoginCode(ctx context.Context, phone, phoneCountryCode string) error
SendCurrentPhoneCode(ctx context.Context, phone, phoneCountryCode string) error
SendNewPhoneCode(ctx context.Context, phone, phoneCountryCode string) error
}
// smsService 短信服务实现
type smsService struct {
config *config.SMSConfig
templates *config.SMSTemplates
cache cache.Cache
logger interface{} // 支持不同类型的logger
}
// NewSMSService 创建SMS服务实例
func NewSMSService(cfg *config.SMSConfig, cache cache.Cache, logger interface{}) (SMSService, error) {
if !cfg.Enabled {
return &disabledSMSService{}, nil
}
// 验证配置有效性
if cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" {
return nil, fmt.Errorf("SMS service configuration is invalid: AccessKeyID and AccessKeySecret are required")
}
if cfg.SignName == "" {
return nil, fmt.Errorf("SMS service configuration is invalid: SignName is required")
}
return &smsService{
config: cfg,
templates: &cfg.Templates,
cache: cache,
logger: logger,
}, nil
}
// disabledSMSService 禁用的SMS服务
type disabledSMSService struct{}
func (s *disabledSMSService) SendVerificationCode(ctx context.Context, phone, phoneCountryCode, templateCode string) error {
return fmt.Errorf("SMS service is disabled")
}
func (s *disabledSMSService) VerifyCode(ctx context.Context, phone, phoneCountryCode, code string) (bool, error) {
return false, fmt.Errorf("SMS service is disabled")
}
func (s *disabledSMSService) IsRateLimited(ctx context.Context, phone, phoneCountryCode string) bool {
return true
}
func (s *disabledSMSService) SendRegisterCode(ctx context.Context, phone, phoneCountryCode string) error {
return fmt.Errorf("SMS service is disabled")
}
func (s *disabledSMSService) SendResetPwdCode(ctx context.Context, phone, phoneCountryCode string) error {
return fmt.Errorf("SMS service is disabled")
}
func (s *disabledSMSService) SendLoginCode(ctx context.Context, phone, phoneCountryCode string) error {
return fmt.Errorf("SMS service is disabled")
}
func (s *disabledSMSService) SendCurrentPhoneCode(ctx context.Context, phone, phoneCountryCode string) error {
return fmt.Errorf("SMS service is disabled")
}
func (s *disabledSMSService) SendNewPhoneCode(ctx context.Context, phone, phoneCountryCode string) error {
return fmt.Errorf("SMS service is disabled")
}
// createSMSClient 创建阿里云短信客户端
// TODO: 实现阿里云SMS客户端创建,需要添加依赖:
// github.com/alibabacloud-go/dysmsapi-20170525/v5/client
// github.com/alibabacloud-go/darabonba-openapi/v2/client
// github.com/alibabacloud-go/tea-utils/v2/service
// github.com/alibabacloud-go/tea/tea
func createSMSClient(accessKeyID, accessKeySecret, regionID, endpoint string) (interface{}, error) {
// 暂时返回nil,等待依赖添加
return nil, fmt.Errorf("阿里云SMS客户端未实现,请添加相关依赖")
}
// SendVerificationCode 发送验证码
func (s *smsService) SendVerificationCode(ctx context.Context, phone, phoneCountryCode, templateCode string) error {
// 检查频率限制
if s.IsRateLimited(ctx, phone, phoneCountryCode) {
return ErrSMSRateLimited
}
// 生成6位随机验证码
code := generateVerificationCode()
// 构造完整手机号
fullPhone := phoneCountryCode + phone
if phoneCountryCode == "" {
fullPhone = "+86" + phone
}
// 送短信 - 需要阿里云SMS SDK
err := s.sendSMS(fullPhone, templateCode, code)
if err != nil {
fmt.Printf("Failed to send SMS to %s: %v", fullPhone, err)
// 返回包含详细错误信息的错误,这样上层可以获取具体错误原因
return fmt.Errorf("SMS send failed: %w", err)
}
// 缓存验证码
cacheKey := fmt.Sprintf("%s:%s:%s", SMSCodeKeyPrefix, phoneCountryCode, phone)
err = s.cache.Set(ctx, cacheKey, code, SMSCodeExpireTime)
if err != nil {
fmt.Printf("Failed to cache verification code: %v", err)
}
// 记录发送日志
fmt.Printf("SMS sent successfully to %s with template %s, code: %s", fullPhone, templateCode, code)
return nil
}
// sendSMS 调用阿里云API发送短信
func (s *smsService) sendSMS(phone, templateCode, code string) error {
// 创建阿里云SMS客户端
client, err := s.createSMSClient()
if err != nil {
return fmt.Errorf("failed to create SMS client: %w", err)
}
// 构建发送短信请求
sendSmsRequest := &dysmsapi20170525.SendSmsRequest{
SignName: tea.String(s.config.SignName),
TemplateCode: tea.String(templateCode),
PhoneNumbers: tea.String(phone),
TemplateParam: tea.String(fmt.Sprintf(`{"code":"%s"}`, code)),
}
// 创建运行时选项
runtime := &util.RuntimeOptions{}
// 发送短信
resp, err := client.SendSmsWithOptions(sendSmsRequest, runtime)
if err != nil {
return fmt.Errorf("failed to send SMS: %w", err)
}
// 检查响应结果
if resp.Body == nil {
return fmt.Errorf("SMS response body is nil")
}
// 检查返回码
if tea.StringValue(resp.Body.Code) != "OK" {
return fmt.Errorf("SMS send failed, code: %s, message: %s",
tea.StringValue(resp.Body.Code),
tea.StringValue(resp.Body.Message))
}
// 记录成功发送的日志
fmt.Printf("SMS sent successfully, BizId: %s, Code: %s\n",
tea.StringValue(resp.Body.BizId),
tea.StringValue(resp.Body.Code))
return nil
}
// createSMSClient 创建阿里云SMS客户端
func (s *smsService) createSMSClient() (*dysmsapi20170525.Client, error) {
config := &openapi.Config{
AccessKeyId: tea.String(s.config.AccessKeyID),
AccessKeySecret: tea.String(s.config.AccessKeySecret),
}
// 设置地域和端点
if s.config.RegionID != "" {
config.RegionId = tea.String(s.config.RegionID)
} else {
config.RegionId = tea.String("cn-hangzhou")
}
if s.config.Endpoint != "" {
config.Endpoint = tea.String(s.config.Endpoint)
} else {
config.Endpoint = tea.String("dysmsapi.aliyuncs.com")
}
client, err := dysmsapi20170525.NewClient(config)
if err != nil {
return nil, err
}
return client, nil
}
// VerifyCode 验证验证码
func (s *smsService) VerifyCode(ctx context.Context, phone, phoneCountryCode, code string) (bool, error) {
cacheKey := fmt.Sprintf("%s:%s:%s", SMSCodeKeyPrefix, phoneCountryCode, phone)
storedCode, err := s.cache.Get(ctx, cacheKey)
if err != nil {
if err == cache.ErrCacheMiss {
return false, ErrSMSCodeNotExist
}
return false, err
}
// 验证成功后删除验证码(一次性使用)
if storedCode == code {
s.cache.Del(ctx, cacheKey)
return true, nil
}
return false, ErrSMSCodeIncorrect
}
// IsRateLimited 检查是否被频率限制
func (s *smsService) IsRateLimited(ctx context.Context, phone, phoneCountryCode string) bool {
limitKey := fmt.Sprintf("%s:%s:%s", SMSLimitKeyPrefix, phoneCountryCode, phone)
// 使用Incr实现简单的频率限制
count, err := s.cache.Incr(ctx, limitKey)
if err != nil {
// 缓存错误时允许请求通过(降级策略)
return false
}
// 首次发送,设置过期时间
if count == 1 {
s.cache.Expire(ctx, limitKey, SMSLimitTimeWindow)
}
// 每小时最多5次
return count > SMSLimitMaxCount
}
// generateVerificationCode 生成6位随机验证码
func generateVerificationCode() string {
rand.Seed(time.Now().UnixNano())
code := fmt.Sprintf("%06d", rand.Intn(1000000))
return code
}
// GetTemplateCode 获取模板代码:优先使用配置,否则使用默认值
func (s *smsService) GetTemplateCode(templateType string) string {
switch templateType {
case "register":
if s.templates.Register != "" {
return s.templates.Register
}
return DefaultTemplateRegister
case "reset_pwd":
if s.templates.ResetPwd != "" {
return s.templates.ResetPwd
}
return DefaultTemplateResetPwd
case "login":
if s.templates.Login != "" {
return s.templates.Login
}
return DefaultTemplateLogin
case "current_phone":
if s.templates.CurrentPhone != "" {
return s.templates.CurrentPhone
}
return DefaultTemplateCurrentPhone
case "new_phone":
if s.templates.NewPhone != "" {
return s.templates.NewPhone
}
return DefaultTemplateNewPhone
default:
return ""
}
}
// SendRegisterCode 发送注册验证码
func (s *smsService) SendRegisterCode(ctx context.Context, phone, phoneCountryCode string) error {
templateCode := s.GetTemplateCode("register")
return s.SendVerificationCode(ctx, phone, phoneCountryCode, templateCode)
}
// SendResetPwdCode 发送重置密码验证码
func (s *smsService) SendResetPwdCode(ctx context.Context, phone, phoneCountryCode string) error {
templateCode := s.GetTemplateCode("reset_pwd")
return s.SendVerificationCode(ctx, phone, phoneCountryCode, templateCode)
}
// SendLoginCode 发送登录验证码
func (s *smsService) SendLoginCode(ctx context.Context, phone, phoneCountryCode string) error {
templateCode := s.GetTemplateCode("login")
return s.SendVerificationCode(ctx, phone, phoneCountryCode, templateCode)
}
// SendCurrentPhoneCode 发送当前手机号验证码
func (s *smsService) SendCurrentPhoneCode(ctx context.Context, phone, phoneCountryCode string) error {
templateCode := s.GetTemplateCode("current_phone")
return s.SendVerificationCode(ctx, phone, phoneCountryCode, templateCode)
}
// SendNewPhoneCode 发送新手机号验证码
func (s *smsService) SendNewPhoneCode(ctx context.Context, phone, phoneCountryCode string) error {
templateCode := s.GetTemplateCode("new_phone")
return s.SendVerificationCode(ctx, phone, phoneCountryCode, templateCode)
}
+105
View File
@@ -0,0 +1,105 @@
package utils
import (
"context"
"testing"
"license-manager/internal/config"
"license-manager/pkg/cache"
)
func TestSMSServiceInitialization(t *testing.T) {
// 创建内存缓存用于测试
memCache := cache.NewMemoryCache(100)
// 创建SMS配置
smsConfig := &config.SMSConfig{
Enabled: false, // 测试禁用状态
AccessKeyID: "test-key-id",
AccessKeySecret: "test-key-secret",
SignName: "Test Sign",
RegionID: "cn-hangzhou",
Endpoint: "dysmsapi.aliyuncs.com",
}
// 测试创建SMS服务
smsService, err := NewSMSService(smsConfig, memCache, nil)
if err != nil {
t.Fatalf("Failed to create SMS service: %v", err)
}
if smsService == nil {
t.Fatal("SMS service should not be nil")
}
// 测试禁用状态下的方法调用
ctx := context.Background()
// 这些方法在禁用状态下应该返回错误
err = smsService.SendVerificationCode(ctx, "+8613800012345", "+86", "test-template")
if err == nil {
t.Error("SendVerificationCode should return error when SMS is disabled")
}
_, err = smsService.VerifyCode(ctx, "+8613800012345", "+86", "123456")
if err == nil {
t.Error("VerifyCode should return error when SMS is disabled")
}
exists := smsService.IsRateLimited(ctx, "+8613800012345", "+86")
if !exists { // 禁用状态下应该总是限流
t.Error("IsRateLimited should return true when SMS is disabled")
}
}
func TestSMSEnabledService(t *testing.T) {
// 创建内存缓存用于测试
memCache := cache.NewMemoryCache(100)
// 创建SMS配置(启用状态)
smsConfig := &config.SMSConfig{
Enabled: true,
AccessKeyID: "test-key-id",
AccessKeySecret: "test-key-secret",
SignName: "Test Sign",
RegionID: "cn-hangzhou",
Endpoint: "dysmsapi.aliyuncs.com",
Templates: config.SMSTemplates{
Register: "SMS_111111111",
ResetPwd: "SMS_222222222",
Login: "SMS_333333333",
CurrentPhone: "SMS_444444444",
NewPhone: "SMS_555555555",
},
}
// 测试创建SMS服务
smsService, err := NewSMSService(smsConfig, memCache, nil)
if err != nil {
t.Fatalf("Failed to create SMS service: %v", err)
}
if smsService == nil {
t.Fatal("SMS service should not be nil")
}
// 测试模板配置是否正确应用
// 注意:由于smsService是内部类型,我们通过接口方法间接验证
// 在实际应用中,SendCurrentPhoneCode会使用配置中的current_phone模板
ctx := context.Background()
// 测试频率限制(应该允许第一次发送)
isLimited := smsService.IsRateLimited(ctx, "+8613800012345", "+86")
if isLimited {
t.Error("First SMS should not be rate limited")
}
// 测试发送登录验证码(会因为无效的AccessKey而失败,但不应该panic)
err = smsService.SendLoginCode(ctx, "+8613800012345", "+86")
if err == nil {
t.Log("SMS send succeeded (unexpected with test credentials)")
} else {
t.Logf("SMS send failed as expected: %v", err)
}
}
Binary file not shown.
+2 -2
View File
@@ -32,7 +32,7 @@ services:
# Backend service - using GitHub registry image
backend:
image: ghcr.io/cedar-v/license-manager-backend:v1.0.3
image: ghcr.io/cedar-v/license-manager-backend:v1.0.4
container_name: license-manager-backend-git
restart: unless-stopped
environment:
@@ -69,7 +69,7 @@ services:
# Frontend service - using GitHub registry image
frontend:
image: ghcr.io/cedar-v/license-manager-frontend:v1.0.3
image: ghcr.io/cedar-v/license-manager-frontend:v1.0.4
container_name: license-manager-frontend-git
restart: unless-stopped
environment:
+14
View File
@@ -0,0 +1,14 @@
# Release v1.0.4
**发布日期**:2025-12-21
---
## ✨ 新增功能
- **授权期限变更**:支持授权期限变更,在线授权可以自动更新授权期限,离线授权可以手动更新授权期限
- **授权变更**:支持功能配置与使用限制变更,支持许可证数量变更,在线授权可以自动更新授权变更,离线授权可以手动更新授权变更
## 🐛 修复问题
- **授权期限变更接口**:修复授权期限变更接口问题
+55
View File
@@ -0,0 +1,55 @@
# Release v1.1.0
**发布日期**:2026-01-17
---
## ✨ 新增功能
### 🔐 客户用户管理系统
- **手机号注册登录**:支持手机号注册和登录,完全独立于管理员用户体系
- **用户角色管理**:支持企业管理员和普通成员角色
- **用户资料管理**:支持头像、语言偏好设置等个性化配置
- **安全认证隔离**:采用路由分组隔离,管理员和客户用户完全分离
### 💳 产品套餐体系
- **三种套餐类型**:试用版、基础版、专业版,支持不同的功能限制和许可数量
- **阶梯定价折扣**:根据许可数量提供折扣
- **自动授权码生成**:购买后自动生成对应的授权码,支持批量许可
- **订单管理系统**:完整的订单创建、查询和管理流程
### 🔑 授权码增强功能
- **离线激活支持**:授权码自包含配置信息,支持无网络环境激活
- **产品激活码**:提供可离线解析的加密授权码格式
- **授权码分享**:用户可将其授权码的部分激活次数分享给其他用户
- **RSA数字签名**:确保授权码的安全性和防篡改
### 📱 设备管理
- **设备列表查看**:客户用户可查看所有激活授权码的设备信息
- **设备解绑**:支持解绑设备,释放授权码许可供其他设备使用
- **在线状态监控**:实时显示设备在线状态和最后心跳时间
- **设备信息展示**:显示设备名称、操作系统、硬件信息等详情
### 💰 支付宝支付集成
- **在线支付支持**:集成支付宝官方SDK,支持产品套餐购买
- **支付状态管理**:完整的支付流程和状态跟踪
- **安全支付验证**:支持支付回调验证和金额校验
- **多环境配置**:支持沙箱和生产环境切换
### 📨 短信验证码服务
- **阿里云短信集成**:基于阿里云短信服务,支持验证码发送
- **多种验证码场景**:注册验证、登录验证、密码重置等
- **频率控制**:防止短信轰炸,限制发送频率
- **验证码缓存**:Redis缓存验证码,支持过期和验证
### 🗄️ 缓存模块重构
- **多缓存实现**:支持内存缓存和Redis缓存两种方式
- **Clean Architecture**:基于Clean Architecture设计,提供统一缓存接口
- **序列化支持**:自动处理JSON序列化和反序列化
- **监控统计**:提供缓存命中率等性能监控指标
## 🐛 修复问题
- **授权码格式统一**:修复授权码生成格式不一致问题
- **支付流程优化**:优化支付流程的用户体验和错误处理
- **缓存稳定性**:提升缓存模块的稳定性和错误处理能力
+2 -2
View File
@@ -7,8 +7,8 @@
# @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
###
# 开发环境接口地址
# VITE_API_BASE_URL = 'http://localhost:18888'
VITE_API_BASE_URL = 'http://111.113.214.109:18888'
VITE_API_BASE_URL = 'http://localhost:18888'
# VITE_API_BASE_URL = 'http://111.113.214.109:18888'
# VITE_API_BASE_URL = 'http://lm.cedar-v.com:18080'
+2
View File
@@ -93,6 +93,8 @@ export interface LicenseUpdateRequest {
software_id?: string;
description?: string;
validity_days?: number;
start_date?: string;
end_date?: string;
deployment_type?: string;
encryption_type?: string;
software_version?: string;
+39 -1
View File
@@ -88,6 +88,10 @@
"unknown": "Unknown Page"
}
},
"common": {
"cancel": "Cancel",
"confirm": "Confirm"
},
"chart": {
"licenseTrend": {
"title": "License Trend",
@@ -407,16 +411,50 @@
"actions": {
"copyCode": "Copy License Code",
"updateLicense": "Update License",
"renewLicense": "Renew License",
"changeValidity": "Change Validity Period",
"revokeLicense": "Revoke License",
"downloadCertificate": "Download Authorization File"
},
"updateDialog": {
"title": "Update Authorization",
"fields": {
"changeType": "Change Type",
"maxActivations": "Max Activations",
"featureConfig": "Feature Config (JSON)",
"usageLimits": "Usage Limits (JSON)",
"customParameters": "Custom Parameters (JSON)",
"reason": "Change Reason"
},
"validation": {
"changeTypeRequired": "Please select change type",
"reasonRequired": "Please enter change reason",
"featureConfigInvalid": "Feature config JSON is invalid",
"usageLimitsInvalid": "Usage limits JSON is invalid",
"customParametersInvalid": "Custom parameters JSON is invalid"
},
"messages": {
"updateSuccess": "Update submitted",
"updateError": "Failed to submit update"
}
},
"dialogs": {
"changeValidityTitle": "Change Validity Period",
"validityType": "Validity Type",
"limited": "Limited",
"permanent": "Permanent",
"dateRange": "Date Range",
"reason": "Change Reason",
"reasonPlaceholder": "Please enter change reason (max 500 chars)"
},
"messages": {
"copySuccess": "License code copied to clipboard",
"copyError": "Copy failed, please copy manually",
"codeEmpty": "License code is empty",
"updateComingSoon": "Update license feature is under development...",
"renewComingSoon": "Renew license feature is under development...",
"changeValiditySuccess": "Validity period changed successfully",
"changeValidityError": "Failed to change validity period",
"changeValidityReasonRequired": "Please provide a reason for the change",
"revokeComingSoon": "Revoke license feature is under development...",
"downloadSuccess": "Authorization file download started",
"downloadError": "Failed to download authorization file, please try again later",
+39 -1
View File
@@ -88,6 +88,10 @@
"unknown": "未知ページ"
}
},
"common": {
"cancel": "キャンセル",
"confirm": "確認"
},
"chart": {
"licenseTrend": {
"title": "ライセンストレンド",
@@ -407,16 +411,50 @@
"actions": {
"copyCode": "ライセンスコードをコピー",
"updateLicense": "ライセンス更新",
"renewLicense": "ライセンス延長",
"changeValidity": "有効期限を変更",
"revokeLicense": "ライセンス取り消し",
"downloadCertificate": "認証ファイルをダウンロード"
},
"updateDialog": {
"title": "ライセンスを変更",
"fields": {
"changeType": "変更タイプ",
"maxActivations": "最大アクティベーション数",
"featureConfig": "機能設定 (JSON)",
"usageLimits": "利用制限 (JSON)",
"customParameters": "カスタムパラメータ (JSON)",
"reason": "変更理由"
},
"validation": {
"changeTypeRequired": "変更タイプを選択してください",
"reasonRequired": "変更理由を入力してください",
"featureConfigInvalid": "機能設定のJSONが不正です",
"usageLimitsInvalid": "利用制限のJSONが不正です",
"customParametersInvalid": "カスタムパラメータのJSONが不正です"
},
"messages": {
"updateSuccess": "変更が送信されました",
"updateError": "変更の送信に失敗しました"
}
},
"dialogs": {
"changeValidityTitle": "有効期限の変更",
"validityType": "期限タイプ",
"limited": "限定",
"permanent": "永久",
"dateRange": "期間",
"reason": "変更理由",
"reasonPlaceholder": "変更理由を入力してください(最大500文字)"
},
"messages": {
"copySuccess": "ライセンスコードがクリップボードにコピーされました",
"copyError": "コピーに失敗しました。手動でコピーしてください",
"codeEmpty": "ライセンスコードが空です",
"updateComingSoon": "ライセンス更新機能は開発中です...",
"renewComingSoon": "ライセンス延長機能は開発中です...",
"changeValiditySuccess": "有効期限の変更が完了しました",
"changeValidityError": "有効期限の変更に失敗しました",
"changeValidityReasonRequired": "変更理由を入力してください",
"revokeComingSoon": "ライセンス取り消し機能は開発中です...",
"downloadSuccess": "認証ファイルのダウンロードを開始しました",
"downloadError": "認証ファイルのダウンロードに失敗しました。後でもう一度お試しください",
+39 -1
View File
@@ -88,6 +88,10 @@
"unknown": "未知页面"
}
},
"common": {
"cancel": "取消",
"confirm": "确定"
},
"chart": {
"licenseTrend": {
"title": "授权趋势",
@@ -407,16 +411,50 @@
"actions": {
"copyCode": "复制授权码",
"updateLicense": "变更授权",
"renewLicense": "延期授权",
"changeValidity": "变更授权期限",
"revokeLicense": "撤销许可证",
"downloadCertificate": "授权文件下载"
},
"updateDialog": {
"title": "变更授权",
"fields": {
"changeType": "变更类型",
"maxActivations": "最大激活设备数",
"featureConfig": "功能配置 (JSON)",
"usageLimits": "使用限制 (JSON)",
"customParameters": "自定义参数 (JSON)",
"reason": "变更原因"
},
"validation": {
"changeTypeRequired": "请选择变更类型",
"reasonRequired": "请输入变更原因",
"featureConfigInvalid": "功能配置 JSON 格式错误",
"usageLimitsInvalid": "使用限制 JSON 格式错误",
"customParametersInvalid": "自定义参数 JSON 格式错误"
},
"messages": {
"updateSuccess": "变更已提交",
"updateError": "变更提交失败"
}
},
"dialogs": {
"changeValidityTitle": "变更授权期限",
"validityType": "期限类型",
"limited": "有限",
"permanent": "永久",
"dateRange": "起止时间",
"reason": "变更原因",
"reasonPlaceholder": "请输入变更原因(最多500字符)"
},
"messages": {
"copySuccess": "授权码已复制到剪贴板",
"copyError": "复制失败,请手动复制",
"codeEmpty": "授权码为空",
"updateComingSoon": "变更授权功能开发中...",
"renewComingSoon": "延期授权功能开发中...",
"changeValiditySuccess": "授权期限变更成功",
"changeValidityError": "变更授权期限失败",
"changeValidityReasonRequired": "请填写变更原因",
"revokeComingSoon": "撤销许可证功能开发中...",
"downloadSuccess": "授权文件已开始下载",
"downloadError": "下载授权文件失败,请稍后重试",
@@ -56,18 +56,146 @@
<el-button class="action-btn-inline copy-btn" @click="handleCopyCode">
{{ $t('pages.licenses.detail.actions.copyCode') }}
</el-button>
<el-button class="action-btn-inline update-btn" @click="handleUpdateLicense">
<el-button class="action-btn-inline update-btn" @click="openUpdateDialog">
{{ $t('pages.licenses.detail.actions.updateLicense') }}
</el-button>
<el-button class="action-btn-inline renew-btn" @click="handleRenewLicense">
{{ $t('pages.licenses.detail.actions.renewLicense') }}
<el-button class="action-btn-inline renew-btn" @click="openChangeValidityDialog">
{{ $t('pages.licenses.detail.actions.changeValidity') }}
</el-button>
<!-- update button moved -->
<el-button class="action-btn-inline download-btn" @click="handleDownloadCertificate">
{{ $t('pages.licenses.detail.actions.downloadCertificate') }}
</el-button>
</div>
</div>
<!-- 变更授权期限弹窗 -->
<el-dialog
:title="t('pages.licenses.detail.dialogs.changeValidityTitle')"
v-model="changeDialogVisible"
width="520px"
>
<div>
<el-form label-width="100px" :model="{ validity_type: changeValidityType, date_range: changeDateRange, reason: changeReason }">
<el-form-item :label="t('pages.licenses.detail.dialogs.validityType')">
<el-radio-group v-model="changeValidityType">
<el-radio label="limited">{{ t('pages.licenses.detail.dialogs.limited') }}</el-radio>
<el-radio label="permanent">{{ t('pages.licenses.detail.dialogs.permanent') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="changeValidityType === 'limited'" :label="t('pages.licenses.detail.dialogs.dateRange')">
<el-date-picker
v-model="changeDateRange"
type="daterange"
range-separator=" - "
start-placeholder="Start date"
end-placeholder="End date"
:disabled-date="disabledDate"
value-format="YYYY-MM-DD"
@change="handleChangeDateRange"
/>
</el-form-item>
<el-form-item :label="t('pages.licenses.detail.dialogs.reason')">
<el-input
type="textarea"
v-model="changeReason"
:placeholder="t('pages.licenses.detail.dialogs.reasonPlaceholder')"
:rows="3"
maxlength="500"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="changeDialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="changeSubmitting" @click="handleConfirmChangeValidity">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>
<!-- 变更授权弹窗(编辑激活数/配置等) -->
<el-dialog
:title="t('pages.licenses.detail.updateDialog.title')"
v-model="updateDialogVisible"
width="720px"
>
<div>
<el-form label-width="140px" class="update-dialog-form">
<el-form-item :label="t('pages.licenses.detail.updateDialog.fields.changeType')">
<el-select v-model="updateChangeType" placeholder="Select" style="width:100%">
<el-option
v-for="opt in changeTypeOptions"
:key="opt.key"
:label="opt.display"
:value="opt.key"
/>
</el-select>
</el-form-item>
<el-form-item :label="t('pages.licenses.detail.updateDialog.fields.maxActivations')">
<el-input-number v-model="updateMaxActivations" :min="1" :controls="true" />
</el-form-item>
<el-form-item :label="t('pages.licenses.detail.updateDialog.fields.featureConfig')">
<div class="kv-entries">
<div v-for="(entry, idx) in featureConfigEntries" :key="entry.id" class="kv-row">
<el-input v-model="entry.key" placeholder="key" style="width:40%" />
<el-select v-model="entry.type" placeholder="type" style="width:14%;margin:0 8px">
<el-option label="string" value="string" />
<el-option label="number" value="number" />
<el-option label="bool" value="bool" />
</el-select>
<el-input v-model="entry.value" placeholder="value" style="width:40%" />
<el-button type="text" icon="el-icon-delete" @click="removeFeatureConfigEntry(idx)" />
</div>
<el-button size="small" @click="addFeatureConfigEntry">{{ t('pages.licenses.form.keyValue.addItem') }}</el-button>
</div>
</el-form-item>
<el-form-item :label="t('pages.licenses.detail.updateDialog.fields.usageLimits')">
<div class="kv-entries">
<div v-for="(entry, idx) in usageLimitsEntries" :key="entry.id" class="kv-row">
<el-input v-model="entry.key" placeholder="key" style="width:40%" />
<el-select v-model="entry.type" placeholder="type" style="width:14%;margin:0 8px">
<el-option label="string" value="string" />
<el-option label="number" value="number" />
<el-option label="bool" value="bool" />
</el-select>
<el-input v-model="entry.value" placeholder="value" style="width:40%" />
<el-button type="text" icon="el-icon-delete" @click="removeUsageLimitEntry(idx)" />
</div>
<el-button size="small" @click="addUsageLimitEntry">{{ t('pages.licenses.form.keyValue.addItem') }}</el-button>
</div>
</el-form-item>
<el-form-item :label="t('pages.licenses.detail.updateDialog.fields.customParameters')">
<div class="kv-entries">
<div v-for="(entry, idx) in customParametersEntries" :key="entry.id" class="kv-row">
<el-input v-model="entry.key" placeholder="key" style="width:40%" />
<el-select v-model="entry.type" placeholder="type" style="width:14%;margin:0 8px">
<el-option label="string" value="string" />
<el-option label="number" value="number" />
<el-option label="bool" value="bool" />
</el-select>
<el-input v-model="entry.value" placeholder="value" style="width:40%" />
<el-button type="text" icon="el-icon-delete" @click="removeCustomParamEntry(idx)" />
</div>
<el-button size="small" @click="addCustomParamEntry">{{ t('pages.licenses.form.keyValue.addItem') }}</el-button>
</div>
</el-form-item>
<el-form-item :label="t('pages.licenses.detail.updateDialog.fields.reason')">
<el-input type="textarea" v-model="updateReason" :rows="3" maxlength="500" />
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="updateDialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="updateSubmitting" @click="handleConfirmUpdate">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>
<!-- Tab 内容区域 -->
<div class="tab-content-area">
<BasicInfo v-if="activeTab === 'basic'" :license-data="licenseData" />
@@ -85,7 +213,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
@@ -134,12 +262,360 @@ const handleCopyCode = () => {
})
}
const handleUpdateLicense = () => {
ElMessage.info(t('pages.licenses.detail.messages.updateComingSoon'))
import { updateLicense } from '@/api/license'
import { getEnumOptions } from '@/api/enum'
// --- change validity dialog state & handlers ---
const changeDialogVisible = ref(false)
const changeValidityType = ref<'permanent' | 'limited'>('limited')
const changeDateRange = ref<[string, string] | null>(null)
const changeValidityDays = ref<number>(365)
const changeReason = ref<string>('')
const changeSubmitting = ref(false)
const disabledDate = (time: Date) => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return time.getTime() < today.getTime()
}
const handleRenewLicense = () => {
ElMessage.info(t('pages.licenses.detail.messages.renewComingSoon'))
const openChangeValidityDialog = () => {
// initialize with current license data
console.log('[Licenses] openChangeValidityDialog called')
if (licenseData.value && licenseData.value.start_date && licenseData.value.end_date) {
changeDateRange.value = [licenseData.value.start_date, licenseData.value.end_date]
const startDate = new Date(changeDateRange.value[0])
const endDate = new Date(changeDateRange.value[1])
const diffTime = endDate.getTime() - startDate.getTime()
changeValidityDays.value = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
changeValidityType.value = changeValidityDays.value >= 365000 ? 'permanent' : 'limited'
} else {
changeDateRange.value = null
changeValidityDays.value = 365
changeValidityType.value = 'limited'
}
changeReason.value = ''
changeDialogVisible.value = true
}
// watch for dialog visibility changes and verify DOM mount
watch(changeDialogVisible, async (val) => {
console.log('[Licenses] changeDialogVisible ->', val)
if (val) {
await nextTick()
const wrapper = document.querySelector('.el-dialog__wrapper') || document.querySelector('.el-dialog')
console.log('[Licenses] dialog DOM element:', wrapper)
if (!wrapper) {
ElMessage.error(t('pages.licenses.detail.messages.changeValidityError') + ' (dialog not mounted)')
}
}
})
const handleChangeDateRange = (dates: [string, string] | null) => {
if (dates && dates.length === 2) {
const startDate = new Date(dates[0])
const endDate = new Date(dates[1])
const diffTime = endDate.getTime() - startDate.getTime()
changeValidityDays.value = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
}
}
const handleConfirmChangeValidity = async () => {
if (!licenseData.value?.id) {
ElMessage.error(t('pages.licenses.detail.messages.missingId'))
return
}
if (!changeReason.value || !changeReason.value.trim()) {
ElMessage.warning(t('pages.licenses.detail.messages.changeValidityReasonRequired'))
return
}
changeSubmitting.value = true
try {
// build start_date / end_date payload (preferred). Format YYYY-MM-DD.
const formatDateYMD = (d: Date) => {
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
return `${y}-${m}-${day}`
}
let startDateStr: string | null = null
let endDateStr: string | null = null
if (changeValidityType.value === 'permanent') {
const today = new Date()
startDateStr = formatDateYMD(today)
const endDate = new Date(today)
// permanent maps to 365000 days (back-end convention)
endDate.setDate(endDate.getDate() + 365000 - 1)
endDateStr = formatDateYMD(endDate)
} else {
if (!changeDateRange.value || changeDateRange.value.length !== 2 || !changeDateRange.value[0] || !changeDateRange.value[1]) {
ElMessage.warning(t('pages.licenses.detail.form.validation.dateRangeRequired'))
changeSubmitting.value = false
return
}
// date picker returns strings when value-format is set; handle both and avoid timezone drift by parsing Y-M-D explicitly
const rawStart = changeDateRange.value[0]
const rawEnd = changeDateRange.value[1]
const parseYMD = (s: string) => {
const parts = s.split('-')
return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]))
}
const start = typeof rawStart === 'string' ? parseYMD(rawStart) : (rawStart as any as Date)
const end = typeof rawEnd === 'string' ? parseYMD(rawEnd) : (rawEnd as any as Date)
startDateStr = formatDateYMD(start)
endDateStr = formatDateYMD(end)
}
console.log('[Licenses] submitting changeValidity', { changeValidityType: changeValidityType.value, changeDateRange: changeDateRange.value, startDateStr, endDateStr })
const payload: any = {
change_type: 'renewal',
reason: changeReason.value
}
if (startDateStr && endDateStr) {
payload.start_date = startDateStr
payload.end_date = endDateStr
}
// include update dialog fields if dialog visible
if (updateDialogVisible.value) {
if (updateMaxActivations.value !== null && updateMaxActivations.value !== undefined) {
payload.max_activations = updateMaxActivations.value
}
const fc = buildJsonRecord(featureConfigEntries.value)
if (fc) payload.feature_config = fc
const ul = buildJsonRecord(usageLimitsEntries.value)
if (ul) payload.usage_limits = ul
const cp = buildJsonRecord(customParametersEntries.value)
if (cp) payload.custom_parameters = cp
payload.change_type = updateChangeType.value || payload.change_type
// if reason in update dialog provided, override
if (updateReason.value && updateReason.value.trim()) {
payload.reason = updateReason.value
}
}
const res = await updateLicense(licenseData.value.id, payload)
if (res.code === '000000') {
ElMessage.success(res.message || t('pages.licenses.detail.messages.changeValiditySuccess'))
changeDialogVisible.value = false
await loadLicenseData()
} else {
throw new Error(res.message || t('pages.licenses.detail.messages.changeValidityError'))
}
} catch (err: any) {
console.error('change validity error', err)
const backendMsg = err?.response?.data?.message
ElMessage.error(backendMsg || err.message || t('pages.licenses.detail.messages.changeValidityError'))
} finally {
changeSubmitting.value = false
}
}
// --- update authorization dialog ---
import type { RawEnumItem } from '@/api/enum'
type KeyValueType = 'string' | 'number' | 'bool'
interface KeyValueItem {
id: string
key: string
value: string
type: KeyValueType
}
const updateDialogVisible = ref(false)
const changeTypeOptions = ref<RawEnumItem[]>([])
const updateChangeType = ref<string>('other')
const updateMaxActivations = ref<number | null>(null)
const featureConfigEntries = ref<KeyValueItem[]>([])
const usageLimitsEntries = ref<KeyValueItem[]>([])
const customParametersEntries = ref<KeyValueItem[]>([])
const updateReason = ref<string>('')
const updateSubmitting = ref(false)
const createEntryId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const createEmptyEntry = (): KeyValueItem => ({
id: createEntryId(),
key: '',
value: '',
type: 'string'
})
const addFeatureConfigEntry = () => featureConfigEntries.value.push(createEmptyEntry())
const addUsageLimitEntry = () => usageLimitsEntries.value.push(createEmptyEntry())
const addCustomParamEntry = () => customParametersEntries.value.push(createEmptyEntry())
const removeFeatureConfigEntry = (i: number) => featureConfigEntries.value.splice(i, 1)
const removeUsageLimitEntry = (i: number) => usageLimitsEntries.value.splice(i, 1)
const removeCustomParamEntry = (i: number) => customParametersEntries.value.splice(i, 1)
const convertValueByType = (value: string, type: KeyValueType) => {
if (type === 'number') {
const num = Number(value)
if (Number.isNaN(num)) throw new Error('invalid number')
return num
}
if (type === 'bool') return value === 'true'
return value
}
const buildJsonRecord = (entries: KeyValueItem[]) => {
if (!entries.length) return null
const result: Record<string, any> = {}
entries.forEach(item => {
const k = item.key.trim()
if (!k) return
try {
result[k] = convertValueByType(item.value, item.type)
} catch {
// ignore here; validation will surface
}
})
return Object.keys(result).length ? result : null
}
// Validate key-value entries: ensure key present and value matches selected type
const validateKeyValueEntries = (entries: KeyValueItem[]): { ok: boolean; msg?: string } => {
for (const item of entries) {
const key = item.key?.trim()
const val = item.value
if (!key) {
return { ok: false, msg: t('pages.licenses.form.keyValue.keyRequired') }
}
if (item.type === 'number') {
if (val === '' || val === null || val === undefined || Number.isNaN(Number(val))) {
return { ok: false, msg: t('pages.licenses.form.keyValue.numberRequired') }
}
}
if (item.type === 'bool') {
const lower = String(val).toLowerCase()
if (lower !== 'true' && lower !== 'false') {
return { ok: false, msg: t('pages.licenses.form.keyValue.boolRequired') }
}
}
}
return { ok: true }
}
const openUpdateDialog = async () => {
updateChangeType.value = 'other'
updateReason.value = ''
updateMaxActivations.value = licenseData.value?.max_activations || null
// populate entries from existing JSON if present
featureConfigEntries.value = []
usageLimitsEntries.value = []
customParametersEntries.value = []
try {
if (licenseData.value?.feature_config) {
const obj = typeof licenseData.value.feature_config === 'string' ? JSON.parse(licenseData.value.feature_config) : licenseData.value.feature_config
for (const [k, v] of Object.entries(obj || {})) {
featureConfigEntries.value.push({ id: createEntryId(), key: k, value: String(v), type: typeof v === 'number' ? 'number' : typeof v === 'boolean' ? 'bool' : 'string' })
}
}
} catch {
// ignore parse errors
}
try {
if (licenseData.value?.usage_limits) {
const obj = typeof licenseData.value.usage_limits === 'string' ? JSON.parse(licenseData.value.usage_limits) : licenseData.value.usage_limits
for (const [k, v] of Object.entries(obj || {})) {
usageLimitsEntries.value.push({ id: createEntryId(), key: k, value: String(v), type: typeof v === 'number' ? 'number' : typeof v === 'boolean' ? 'bool' : 'string' })
}
}
} catch {}
try {
if (licenseData.value?.custom_parameters) {
const obj = typeof licenseData.value.custom_parameters === 'string' ? JSON.parse(licenseData.value.custom_parameters) : licenseData.value.custom_parameters
for (const [k, v] of Object.entries(obj || {})) {
customParametersEntries.value.push({ id: createEntryId(), key: k, value: String(v), type: typeof v === 'number' ? 'number' : typeof v === 'boolean' ? 'bool' : 'string' })
}
}
} catch {}
// load change type options from backend enum
try {
const res = await getEnumOptions('authorization_change_type')
if (res.code === '000000' && res.data?.items) {
changeTypeOptions.value = res.data.items
if (!changeTypeOptions.value.find(i => i.key === updateChangeType.value) && changeTypeOptions.value.length) {
updateChangeType.value = changeTypeOptions.value[0].key
}
}
} catch (err) {
console.error('load change type enums failed', err)
}
updateDialogVisible.value = true
}
const handleConfirmUpdate = async () => {
if (!licenseData.value?.id) {
ElMessage.error(t('pages.licenses.detail.messages.missingId'))
return
}
if (!updateChangeType.value || !updateChangeType.value.trim()) {
ElMessage.warning(t('pages.licenses.detail.updateDialog.validation.changeTypeRequired'))
return
}
if (!updateReason.value || !updateReason.value.trim()) {
ElMessage.warning(t('pages.licenses.detail.updateDialog.validation.reasonRequired'))
return
}
// build payload
const payload: any = {
change_type: updateChangeType.value,
reason: updateReason.value
}
if (updateMaxActivations.value !== null && updateMaxActivations.value !== undefined) {
payload.max_activations = updateMaxActivations.value
}
// build JSON object from key-value entries
const fc = buildJsonRecord(featureConfigEntries.value)
if (fc) payload.feature_config = fc
const ul = buildJsonRecord(usageLimitsEntries.value)
if (ul) payload.usage_limits = ul
const cp = buildJsonRecord(customParametersEntries.value)
if (cp) payload.custom_parameters = cp
// validate KV entries before sending
const v1 = validateKeyValueEntries(featureConfigEntries.value)
if (!v1.ok) {
ElMessage.error(v1.msg || t('pages.licenses.detail.updateDialog.validation.featureConfigInvalid'))
changeSubmitting.value = false
return
}
const v2 = validateKeyValueEntries(usageLimitsEntries.value)
if (!v2.ok) {
ElMessage.error(v2.msg || t('pages.licenses.detail.updateDialog.validation.usageLimitsInvalid'))
changeSubmitting.value = false
return
}
const v3 = validateKeyValueEntries(customParametersEntries.value)
if (!v3.ok) {
ElMessage.error(v3.msg || t('pages.licenses.detail.updateDialog.validation.customParametersInvalid'))
changeSubmitting.value = false
return
}
updateSubmitting.value = true
try {
const res = await updateLicense(licenseData.value.id, payload)
if (res.code === '000000') {
ElMessage.success(res.message || t('pages.licenses.detail.updateDialog.messages.updateSuccess'))
updateDialogVisible.value = false
await loadLicenseData()
} else {
throw new Error(res.message || t('pages.licenses.detail.updateDialog.messages.updateError'))
}
} catch (err: any) {
console.error('update authorization error', err)
const backendMsg = err?.response?.data?.message
ElMessage.error(backendMsg || err.message || t('pages.licenses.detail.updateDialog.messages.updateError'))
} finally {
updateSubmitting.value = false
}
}
const getFileNameFromDisposition = (disposition?: string | null) => {
@@ -229,6 +705,20 @@ const loadLicenseData = async () => {
onMounted(() => {
loadLicenseData()
})
// load change type enums on mount for dropdown
const loadChangeTypeEnums = async () => {
try {
const res = await getEnumOptions('authorization_change_type')
if (res.code === '000000' && res.data?.items) {
changeTypeOptions.value = res.data.items
}
} catch (err) {
console.error('load change type enums failed', err)
}
}
onMounted(() => {
loadChangeTypeEnums()
})
</script>
<style lang="scss" scoped>
@@ -505,3 +995,4 @@ onMounted(() => {
}
}
</style>