Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33767d4e71 | ||
|
|
b5fa708c28 | ||
|
|
8eca9f92bb | ||
|
|
612bf9090e | ||
|
|
973b549298 | ||
|
|
e91775ea76 | ||
|
|
04af5eca00 | ||
|
|
3f6cca7364 | ||
|
|
6bfde74e21 | ||
|
|
194accefd8 | ||
|
|
19a80887b3 | ||
|
|
7062b9f1e8 | ||
|
|
d2423f3705 | ||
|
|
d9cb5361b1 | ||
|
|
19e0c6706a | ||
|
|
9c2b503d2b | ||
|
|
af76eaf1b1 | ||
|
|
879aca449f | ||
|
|
2228beed51 | ||
|
|
d7dc1e2dbe | ||
|
|
d76bff2142 | ||
|
|
18a1193618 | ||
|
|
02fc6727f3 | ||
|
|
146eb2c44d | ||
|
|
53e3449849 | ||
|
|
b56766b123 |
No files matched your search
+103
@@ -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
@@ -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
|
||||
|
||||
@@ -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 # 清理间隔
|
||||
|
||||
|
||||
@@ -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 # 清理间隔
|
||||
@@ -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 # 清理间隔
|
||||
@@ -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 # 清理间隔
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "その他"
|
||||
|
||||
@@ -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": "其他"
|
||||
|
||||
@@ -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'` 的记录数
|
||||
@@ -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. 多语言管理器
|
||||
|
||||
@@ -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. 配置生产数据库
|
||||
|
||||
---
|
||||
|
||||
**🎉 按照此指南测试,你的支付宝支付功能就能正常工作了!**
|
||||
@@ -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示例,项目中已完整实现并集成。*
|
||||
@@ -0,0 +1,5 @@
|
||||
# 提示词
|
||||
|
||||
## 接口设计提示词
|
||||
|
||||
请参考至少三个当前已经实现的接口,熟悉接口设计规范,然后设计新的接口。
|
||||
+2452
-3
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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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"` // 已支付订单数
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"` // 离线设备数
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
// 辅助函数:判断错误类型
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 获取授权码信息
|
||||
|
||||
@@ -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字段扩展)
|
||||
@@ -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签名,显著缩短授权码长度
|
||||
|
||||
**执行注意事项**:
|
||||
- 扩展字段长度以适应新的授权码格式
|
||||
- 不会影响现有数据
|
||||
- 支持更高效的离线验证
|
||||
|
||||
## 执行顺序
|
||||
|
||||
按文件名的数字前缀顺序执行:
|
||||
|
||||
Vendored
+265
@@ -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实现分布式锁
|
||||
Vendored
+95
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+180
@@ -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")
|
||||
}
|
||||
}
|
||||
Vendored
+165
@@ -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
|
||||
})
|
||||
}
|
||||
Vendored
+141
@@ -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()
|
||||
}
|
||||
Vendored
+73
@@ -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"
|
||||
)
|
||||
Vendored
+245
@@ -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
|
||||
}
|
||||
Vendored
+238
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
@@ -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:
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Release v1.0.4
|
||||
|
||||
**发布日期**:2025-12-21
|
||||
|
||||
---
|
||||
|
||||
## ✨ 新增功能
|
||||
|
||||
- **授权期限变更**:支持授权期限变更,在线授权可以自动更新授权期限,离线授权可以手动更新授权期限
|
||||
- **授权变更**:支持功能配置与使用限制变更,支持许可证数量变更,在线授权可以自动更新授权变更,离线授权可以手动更新授权变更
|
||||
|
||||
## 🐛 修复问题
|
||||
|
||||
- **授权期限变更接口**:修复授权期限变更接口问题
|
||||
@@ -0,0 +1,55 @@
|
||||
# Release v1.1.0
|
||||
|
||||
**发布日期**:2026-01-17
|
||||
|
||||
---
|
||||
|
||||
## ✨ 新增功能
|
||||
|
||||
### 🔐 客户用户管理系统
|
||||
- **手机号注册登录**:支持手机号注册和登录,完全独立于管理员用户体系
|
||||
- **用户角色管理**:支持企业管理员和普通成员角色
|
||||
- **用户资料管理**:支持头像、语言偏好设置等个性化配置
|
||||
- **安全认证隔离**:采用路由分组隔离,管理员和客户用户完全分离
|
||||
|
||||
### 💳 产品套餐体系
|
||||
- **三种套餐类型**:试用版、基础版、专业版,支持不同的功能限制和许可数量
|
||||
- **阶梯定价折扣**:根据许可数量提供折扣
|
||||
- **自动授权码生成**:购买后自动生成对应的授权码,支持批量许可
|
||||
- **订单管理系统**:完整的订单创建、查询和管理流程
|
||||
|
||||
### 🔑 授权码增强功能
|
||||
- **离线激活支持**:授权码自包含配置信息,支持无网络环境激活
|
||||
- **产品激活码**:提供可离线解析的加密授权码格式
|
||||
- **授权码分享**:用户可将其授权码的部分激活次数分享给其他用户
|
||||
- **RSA数字签名**:确保授权码的安全性和防篡改
|
||||
|
||||
### 📱 设备管理
|
||||
- **设备列表查看**:客户用户可查看所有激活授权码的设备信息
|
||||
- **设备解绑**:支持解绑设备,释放授权码许可供其他设备使用
|
||||
- **在线状态监控**:实时显示设备在线状态和最后心跳时间
|
||||
- **设备信息展示**:显示设备名称、操作系统、硬件信息等详情
|
||||
|
||||
### 💰 支付宝支付集成
|
||||
- **在线支付支持**:集成支付宝官方SDK,支持产品套餐购买
|
||||
- **支付状态管理**:完整的支付流程和状态跟踪
|
||||
- **安全支付验证**:支持支付回调验证和金额校验
|
||||
- **多环境配置**:支持沙箱和生产环境切换
|
||||
|
||||
### 📨 短信验证码服务
|
||||
- **阿里云短信集成**:基于阿里云短信服务,支持验证码发送
|
||||
- **多种验证码场景**:注册验证、登录验证、密码重置等
|
||||
- **频率控制**:防止短信轰炸,限制发送频率
|
||||
- **验证码缓存**:Redis缓存验证码,支持过期和验证
|
||||
|
||||
### 🗄️ 缓存模块重构
|
||||
- **多缓存实现**:支持内存缓存和Redis缓存两种方式
|
||||
- **Clean Architecture**:基于Clean Architecture设计,提供统一缓存接口
|
||||
- **序列化支持**:自动处理JSON序列化和反序列化
|
||||
- **监控统计**:提供缓存命中率等性能监控指标
|
||||
|
||||
## 🐛 修复问题
|
||||
|
||||
- **授权码格式统一**:修复授权码生成格式不一致问题
|
||||
- **支付流程优化**:优化支付流程的用户体验和错误处理
|
||||
- **缓存稳定性**:提升缓存模块的稳定性和错误处理能力
|
||||
@@ -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'
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "認証ファイルのダウンロードに失敗しました。後でもう一度お試しください",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user