Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb1f68b423 | ||
|
|
c0a6ee3cb0 | ||
|
|
1ceff0ffa3 | ||
|
|
f16bd5567b | ||
|
|
60f49b3b27 | ||
|
|
2f9a401381 | ||
|
|
7355edf999 | ||
|
|
f8173ace88 | ||
|
|
a4198743a4 | ||
|
|
224d36dcf1 | ||
|
|
1fb4940625 | ||
|
|
182f57480a | ||
|
|
9680da7762 | ||
|
|
13e33777bd | ||
|
|
4dab5563c5 | ||
|
|
1dba45d417 | ||
|
|
42a5803c06 | ||
|
|
ed00a63434 | ||
|
|
c2204adf0c | ||
|
|
ce410666d9 | ||
|
|
bc5d654a59 | ||
|
|
8410a2cac4 | ||
|
|
b0539c0014 | ||
|
|
ccbe6cf37b | ||
|
|
a0ff21ad3f | ||
|
|
10030f2fc7 | ||
|
|
9eb237513e | ||
|
|
4e619c30be | ||
|
|
09be964d53 | ||
|
|
aa3ae38968 | ||
|
|
9fd545797f | ||
|
|
e84ae246b4 | ||
|
|
d48961ecf4 | ||
|
|
b5eda1d6d1 | ||
|
|
b903af8402 | ||
|
|
5fdbe4b023 | ||
|
|
7dd00dc074 | ||
|
|
d56dfc2bad | ||
|
|
4de1f4d2e7 | ||
|
|
a95322b6c5 | ||
|
|
a1f411639d | ||
|
|
560adafe27 | ||
|
|
b65b4ac801 | ||
|
|
c3d0f4aa2a | ||
|
|
dec473a62a | ||
|
|
c9ced7dc2a | ||
|
|
27a2e41c93 | ||
|
|
a91b4e0deb | ||
|
|
91fe674dea | ||
|
|
5f944dd88a | ||
|
|
8a8630416e | ||
|
|
b4e7d954b0 | ||
|
|
fa715abbc2 | ||
|
|
3570167a83 | ||
|
|
7224504ee0 | ||
|
|
fd19bc4070 | ||
|
|
f02c00f9fe | ||
|
|
d4dad1537f | ||
|
|
679d94abd0 | ||
|
|
5a5e9a7171 | ||
|
|
cdf00189aa | ||
|
|
07fbbdd2a5 | ||
|
|
ab983e5dd6 | ||
|
|
5908e3e9fd | ||
|
|
c716b2bea2 | ||
|
|
67099d21a1 | ||
|
|
2934364013 | ||
|
|
98c49e91f7 | ||
|
|
7b1122e125 | ||
|
|
c3d5b891b6 | ||
|
|
ea37b2ebb1 | ||
|
|
89cb1d92c8 | ||
|
|
601658c992 | ||
|
|
0659c7624d | ||
|
|
2555b07abe | ||
|
|
01b27fdde6 | ||
|
|
33881795c5 | ||
|
|
26dd1b9615 | ||
|
|
eba7baaa5f | ||
|
|
97bda15450 | ||
|
|
ce5bd6d3ba | ||
|
|
41e00fcb90 | ||
|
|
7642e0d1ea | ||
|
|
e6899d26bc | ||
|
|
8b758cc40c | ||
|
|
f2bb2ef952 | ||
|
|
3edbaef170 | ||
|
|
251fcba655 | ||
|
|
b8c21c24cd | ||
|
|
d6890462a3 | ||
|
|
2032950bb7 | ||
|
|
fa056ae272 | ||
|
|
f6376fbf2b | ||
|
|
d548f07c9f | ||
|
|
7f4e2592b7 | ||
|
|
8f18d1e442 | ||
|
|
41b499cde7 | ||
|
|
33d348c692 | ||
|
|
f14f2a536f | ||
|
|
a4a5870925 | ||
|
|
b93d51b4ad | ||
|
|
b80f5cfdd9 | ||
|
|
af1b7d49a2 | ||
|
|
969b36f1d1 | ||
|
|
3192b79aa4 | ||
|
|
afa9e8204f | ||
|
|
874820fabd | ||
|
|
4c48344fc1 | ||
|
|
78a35f71e1 | ||
|
|
39957295af | ||
|
|
9aa4688ac8 | ||
|
|
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
@@ -0,0 +1,228 @@
|
||||
# 技术契约与架构设计书
|
||||
## 功能:授权趋势图表自适应溢出修复
|
||||
|
||||
**版本**:v1.0
|
||||
**日期**:2026-03-13
|
||||
**设计者**:System Architect
|
||||
**状态**:待实现
|
||||
|
||||
---
|
||||
|
||||
## 一、问题定位(Root Cause Analysis)
|
||||
|
||||
通过审计截图与源码,确认存在 **三条独立的溢出根因**,相互叠加导致图表在不同视口宽度下随机超出卡片边界。
|
||||
|
||||
### 根因 A — CSS 高度链断裂(最高优先级)
|
||||
|
||||
**文件**:`frontend/src/views/Dashboard.vue`
|
||||
|
||||
```
|
||||
.content-container { min-height: calc(100vh - 80px); } ← ①
|
||||
└─ .dashboard { height: 100%; } ← ② ❌ 100% of min-height = 0!
|
||||
└─ .content-section { flex: 1; min-height: 0; } ← ③
|
||||
└─ .chart-section { flex: 0.5; min-height: 300px; }
|
||||
└─ .license-trend-chart { height: 100%; } ← ④ 同样失效
|
||||
```
|
||||
|
||||
CSS 规范:`height: 100%` 只解析相对于父元素的 **`height`** 属性,而非 `min-height`。步骤②处 `.dashboard` 获取的 `height: 100%` 实际解析为 `auto`,导致整条 flex 高度链全部退化为 `auto`/内容高度,图表实际渲染高度由内容撑开,触发溢出。
|
||||
|
||||
**现象**:桌面端图表卡片高度不稳定,ECharts canvas 随视口/数据量变化而溢出卡片底部。
|
||||
|
||||
### 根因 B — ECharts Grid 使用绝对像素 + `containLabel: false`
|
||||
|
||||
**文件**:`frontend/src/components/charts/LicenseTrendChart.vue`
|
||||
|
||||
```js
|
||||
grid: {
|
||||
left: 60, // 固定像素,容器小时 Y 轴标签溢出左侧
|
||||
right: 50, // 固定像素,容器小时溢出右侧
|
||||
top: 30,
|
||||
bottom: 60,
|
||||
containLabel: false // ❌ 不包含坐标轴标签在 grid 范围内
|
||||
}
|
||||
```
|
||||
|
||||
当容器宽度较小时,ECharts 按绝对像素保留边距并绘制轴标签于 grid 外部,标签实际渲染于 canvas 边缘外,出现截断/溢出。
|
||||
|
||||
**现象**:窄屏或侧边栏展开时,X 轴日期标签被裁切或与右侧内容重叠。
|
||||
|
||||
### 根因 C — 父容器 `overflow: hidden` 裁切 ECharts tooltip
|
||||
|
||||
**文件**:`frontend/src/components/charts/LicenseTrendChart.vue`
|
||||
|
||||
```scss
|
||||
.license-trend-chart {
|
||||
overflow: hidden; // ❌ 裁切了 ECharts tooltip 的绝对定位弹层
|
||||
}
|
||||
```
|
||||
|
||||
ECharts 的 tooltip 默认渲染为 `position: absolute` 的 `div` 元素,若其最近的 `overflow: hidden` 祖先正好是卡片容器,tooltip 悬浮层会被硬裁。移动端尤其明显(截图二:tooltip "授权数量: 8" 被截断于卡片底部)。
|
||||
|
||||
---
|
||||
|
||||
## 二、数据模型层(Schema)
|
||||
|
||||
本次修复为纯前端 CSS/布局层变更,**不涉及任何数据模型、API 接口或后端逻辑修改**。API 契约保持不变。
|
||||
|
||||
---
|
||||
|
||||
## 三、后端服务层(API)
|
||||
|
||||
**无变更**。后端 `GET /api/v1/dashboard/authorization-trend` 接口及响应格式 `AuthorizationTrendResponse` 不受影响。
|
||||
|
||||
---
|
||||
|
||||
## 四、前端交互层(View)变更契约
|
||||
|
||||
### 4.1 变更文件清单
|
||||
|
||||
| 操作 | 文件路径 | 变更范围 |
|
||||
|------|----------|----------|
|
||||
| 修改 | `frontend/src/views/Dashboard.vue` | `.content-container` 的高度策略 |
|
||||
| 修改 | `frontend/src/components/charts/LicenseTrendChart.vue` | Grid 配置 + overflow + 高度策略 |
|
||||
|
||||
---
|
||||
|
||||
### 4.2 `frontend/src/views/Dashboard.vue` — 修改意图
|
||||
|
||||
**目标**:修复高度链断裂,使 flex 容器的高度能正确传递给子组件。
|
||||
|
||||
#### 改动 1:`.content-container` 改用 `height` 替代 `min-height`
|
||||
|
||||
```scss
|
||||
// ❌ 修改前
|
||||
.content-container {
|
||||
min-height: calc(100vh - 80px);
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ 修改后
|
||||
.content-container {
|
||||
height: calc(100vh - 80px); // 使用确定的 height,让 flex 子项 height: 100% 可以解析
|
||||
min-height: 600px; // 保留最小高度防止内容压扁,但用 min-height 做限底
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
> **风险评估**:`Dashboard.vue` 的 `height` 被 `Layout` 组件 `overflow: auto` 的滚动容器包裹,切换为固定 `height` 不会影响滚动行为,但需验证 Layout 组件是否已设置 `overflow-y: auto`。
|
||||
|
||||
---
|
||||
|
||||
### 4.3 `frontend/src/components/charts/LicenseTrendChart.vue` — 修改意图
|
||||
|
||||
**目标 A**:修复 ECharts Grid overflow(根因 B)。
|
||||
|
||||
#### 改动 1:`chartOption.grid` 启用 `containLabel: true` 并将边距改为百分比
|
||||
|
||||
```js
|
||||
// ❌ 修改前
|
||||
grid: {
|
||||
left: 60,
|
||||
right: 50,
|
||||
top: 30,
|
||||
bottom: 60,
|
||||
containLabel: false
|
||||
}
|
||||
|
||||
// ✅ 修改后
|
||||
grid: {
|
||||
left: '3%', // 改为百分比,随容器缩放
|
||||
right: '3%',
|
||||
top: 30,
|
||||
bottom: 30, // containLabel: true 后 ECharts 自动为标签留空间,无需预留 60px
|
||||
containLabel: true // 轴标签始终包含在 grid 区域内,不会超出 canvas
|
||||
}
|
||||
```
|
||||
|
||||
**目标 B**:解决 tooltip 被 `overflow: hidden` 裁切(根因 C)。
|
||||
|
||||
#### 改动 2:`v-chart` 的 tooltip 改为挂载到 body
|
||||
|
||||
在 `chartOption.tooltip` 中追加:
|
||||
|
||||
```js
|
||||
tooltip: {
|
||||
// ...现有配置不变...
|
||||
appendToBody: true, // tooltip DOM 挂载到 <body>,完全逃离 overflow: hidden 容器
|
||||
}
|
||||
```
|
||||
|
||||
> 此方案比改 `overflow: visible` 更稳健,不破坏卡片的 `border-radius` 裁切效果。`overflow: hidden` 保留,仅 tooltip 逃逸。
|
||||
|
||||
**目标 C**:稳定图表 canvas 在 flex 链中的高度(根因 A 的组件侧保障)。
|
||||
|
||||
#### 改动 3:`.chart-container` 添加 `position: relative` + `.trend-chart` 使用位置绝对铺满
|
||||
|
||||
为防止在高度链无法修复的回退场景下图表仍能铺满容器:
|
||||
|
||||
```scss
|
||||
// ✅ 修改后
|
||||
.chart-container {
|
||||
padding: 1.35vw 1.25vw 1.25vw;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
position: relative; // 新增:建立定位上下文
|
||||
|
||||
.trend-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 12.8vw; // 保持现有移动端 fallback
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端覆盖(已有,维持原样)
|
||||
@media (max-width: 1024px) {
|
||||
.chart-container .trend-chart {
|
||||
height: 200px;
|
||||
min-height: unset;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、全栈影响面分析(Full-Stack Impact Analysis)
|
||||
|
||||
| 层级 | 受影响组件 | 影响类型 | 处理方式 |
|
||||
|------|-----------|----------|----------|
|
||||
| 数据层 | 无 | — | 无需处理 |
|
||||
| API 层 | 无 | — | 无需处理 |
|
||||
| 状态层 | 无 | — | 无需处理 |
|
||||
| 视图组件 | `Dashboard.vue` | 高度策略调整 | 必须修改 |
|
||||
| 子组件 | `LicenseTrendChart.vue` | Grid 配置 + tooltip + overflow | 必须修改 |
|
||||
| 其他页面 | 其他使用 `Layout` 组件的页面 | 可能受 `.content-container` 修改影响 | 修改前审查;若 `.content-container` 属全局 class 则需回归测试 |
|
||||
|
||||
> ⚠️ **注意**:如果 `.content-container` 是全局共用 class(定义在全局 CSS 而非 scoped),则修改会影响所有使用该 class 的页面。需先确认作用域:当前 Dashboard.vue 的 `<style lang="scss" scoped>` 限定了 `.content-container` 只作用于该页面,因此是安全的局部修改。
|
||||
|
||||
---
|
||||
|
||||
## 六、待验证事项(Acceptance Criteria)
|
||||
|
||||
- [ ] 1920×1080 桌面端:图表无溢出,X 轴日期标签完整显示
|
||||
- [ ] 1440×900:图表无溢出,轴标签不被截断
|
||||
- [ ] 1280×800:图表卡片高度稳定,边距正常
|
||||
- [ ] 768px 平板:`.chart-section min-height: 300px` 生效,图表可读
|
||||
- [ ] 375px 手机:固定 200px 高度正常,tooltip 不被卡片边界截断
|
||||
- [ ] 暗模式:tooltip 背景色与轴标签颜色不变
|
||||
- [ ] 快捷选择切换 7 天 / 30 天:图表 resize 正常,无残影溢出
|
||||
|
||||
---
|
||||
|
||||
## 七、文件清单汇总
|
||||
|
||||
### 必须修改
|
||||
1. `frontend/src/views/Dashboard.vue`
|
||||
- SCSS `.content-container`:`min-height` → `height` + 添加 `min-height` 保底
|
||||
|
||||
2. `frontend/src/components/charts/LicenseTrendChart.vue`
|
||||
- JS `chartOption.grid`:`containLabel: false` + 固定 px → `containLabel: true` + `%` 边距
|
||||
- JS `chartOption.tooltip`:新增 `appendToBody: true`
|
||||
- SCSS `.chart-container`:新增 `position: relative`
|
||||
|
||||
### 无需新建
|
||||
本次修复不需要创建任何新文件。
|
||||
|
||||
---
|
||||
|
||||
*本文档由 System Architect 技能生成,作为 task-coder 的实现输入。实现时严格遵循上述契约,禁止超出本文档定义的修改范围。*
|
||||
@@ -18,24 +18,38 @@ jobs:
|
||||
steps:
|
||||
# 通过SSH连接服务器进行部署
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@v1.2.0
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.HOST_PI }}
|
||||
username: pi
|
||||
password: ${{ secrets.PASS_PI }}
|
||||
host: ${{ secrets.HOST43 }}
|
||||
username: root
|
||||
password: ${{ secrets.PASS43 }}
|
||||
port: 22
|
||||
command_timeout: 10m
|
||||
script_stop: true
|
||||
envs: USER
|
||||
script: |
|
||||
# 确保使用正确的用户环境
|
||||
export HOME=/home/pi
|
||||
export USER=pi
|
||||
# 确保使用正确的用户环境(云服务器 root)
|
||||
export HOME=/root
|
||||
export USER=root
|
||||
|
||||
# 加载pi用户的环境配置(包括代理设置)
|
||||
source /home/pi/.bashrc || true
|
||||
source /home/pi/.profile || true
|
||||
# 加载 root 用户的环境配置(包括代理设置)
|
||||
source /root/.bashrc || true
|
||||
source /root/.profile || true
|
||||
|
||||
cd /home/pi/license-manager
|
||||
git pull origin main
|
||||
cd /root/license-manager
|
||||
|
||||
echo "Starting git pull with retry (max 5 times)..."
|
||||
max_retries=5
|
||||
count=0
|
||||
until git pull origin main; do
|
||||
count=$((count + 1))
|
||||
if [ "$count" -ge "$max_retries" ]; then
|
||||
echo "❌ git pull failed after $max_retries attempts."
|
||||
exit 1
|
||||
fi
|
||||
echo "⚠️ git pull failed (attempt $count/$max_retries), retrying in 10 seconds..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "✅ git pull succeeded."
|
||||
pm2 restart license_manager
|
||||
@@ -1,108 +0,0 @@
|
||||
name: Deploy Backend
|
||||
|
||||
on:
|
||||
# push:
|
||||
# branches: [ main ]
|
||||
# paths:
|
||||
# - 'backend/**'
|
||||
# - 'Dockerfile'
|
||||
# - '.github/workflows/deploy_backend.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: license-manager-backend
|
||||
CONTAINER_NAME: license-manager
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- name: Build and test backend
|
||||
run: |
|
||||
cd backend
|
||||
go mod tidy
|
||||
go mod verify
|
||||
go test ./...
|
||||
go build -ldflags="-s -w" -o license-manager cmd/main.go
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build -f Dockerfile.backend -t ${{ env.IMAGE_NAME }}:latest .
|
||||
docker save ${{ env.IMAGE_NAME }}:latest > backend-image.tar
|
||||
|
||||
- name: Copy files to server
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.HOST_PI }}
|
||||
username: pi
|
||||
password: ${{ secrets.PASS_PI }}
|
||||
port: 22
|
||||
source: "backend-image.tar"
|
||||
target: "/home/pi/license-manager/"
|
||||
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.HOST_PI }}
|
||||
username: pi
|
||||
password: ${{ secrets.PASS_PI }}
|
||||
port: 22
|
||||
script: |
|
||||
cd /home/pi/license-manager
|
||||
|
||||
# Stop and remove existing container
|
||||
docker stop ${{ env.CONTAINER_NAME }} || true
|
||||
docker rm ${{ env.CONTAINER_NAME }} || true
|
||||
|
||||
# Load new image
|
||||
docker load < backend-image.tar
|
||||
|
||||
# Remove old image
|
||||
docker rmi ${{ env.IMAGE_NAME }}:old || true
|
||||
docker tag ${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:old || true
|
||||
|
||||
# Run new container with host network
|
||||
docker run -d \
|
||||
--name ${{ env.CONTAINER_NAME }} \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
-v /home/pi/license-manager/logs:/app/logs \
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
# Clean up
|
||||
rm -f backend-image.tar
|
||||
docker image prune -f
|
||||
|
||||
# Check deployment status
|
||||
sleep 5
|
||||
if docker ps | grep -q ${{ env.CONTAINER_NAME }}; then
|
||||
echo "✅ Deployment successful! Container is running."
|
||||
docker logs --tail 10 ${{ env.CONTAINER_NAME }}
|
||||
else
|
||||
echo "❌ Deployment failed! Container is not running."
|
||||
docker logs ${{ env.CONTAINER_NAME }}
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
sleep 10
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" http://${{ secrets.HOST_PI }}:18888/health || echo "000")
|
||||
if [ "$response" = "200" ]; then
|
||||
echo "✅ Health check passed! Service is responding."
|
||||
else
|
||||
echo "⚠️ Health check returned status: $response"
|
||||
echo "Service may still be starting up..."
|
||||
fi
|
||||
@@ -25,8 +25,13 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
# 设置pnpm
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
# 缓存node_modules(不缓存构建输出,确保每次都是最新构建)
|
||||
- name: Cache dependencies
|
||||
@@ -34,7 +39,8 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
frontend/node_modules
|
||||
key: ${{ runner.os }}-frontend-deps-${{ hashFiles('frontend/package-lock.json') }}
|
||||
~/.pnpm-store
|
||||
key: ${{ runner.os }}-frontend-deps-${{ hashFiles('frontend/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-frontend-deps-
|
||||
|
||||
@@ -42,7 +48,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# 清理旧的构建目录(确保使用最新代码构建)
|
||||
- name: Clean old build artifacts
|
||||
@@ -59,9 +65,9 @@ jobs:
|
||||
export VITE_OUTDIR=production
|
||||
export VITE_ADDRESS_BASE_URL=/production/
|
||||
# 先执行类型检查
|
||||
npm run type-check
|
||||
pnpm run type-check
|
||||
# 然后执行构建
|
||||
npm run build:prod
|
||||
pnpm run build:prod
|
||||
# 检查构建结果
|
||||
echo "Build completed, checking production directory:"
|
||||
ls -la production/
|
||||
@@ -76,14 +82,14 @@ jobs:
|
||||
- name: Prepare temp directory
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.HOST_PI }}
|
||||
username: pi
|
||||
password: ${{ secrets.PASS_PI }}
|
||||
host: ${{ secrets.HOST43 }}
|
||||
username: root
|
||||
password: ${{ secrets.PASS43 }}
|
||||
port: 22
|
||||
script: |
|
||||
sudo rm -rf /home/pi/lm-www/frontend_tmp
|
||||
sudo mkdir -p /home/pi/lm-www/frontend_tmp/production
|
||||
sudo chown -R pi:pi /home/pi/lm-www/frontend_tmp
|
||||
sudo chown -R root:root /home/pi/lm-www/frontend_tmp
|
||||
|
||||
# 安装 rsync(用于带进度显示的文件传输)
|
||||
- name: Install rsync
|
||||
@@ -105,10 +111,10 @@ jobs:
|
||||
echo "Starting file transfer with rsync..."
|
||||
|
||||
# 启动后台传输(使用 --info=progress2 只显示总体进度,减少输出)
|
||||
sshpass -p "${{ secrets.PASS_PI }}" rsync -avz --info=progress2 \
|
||||
sshpass -p "${{ secrets.PASS43 }}" rsync -avz --info=progress2 \
|
||||
-e "ssh -o StrictHostKeyChecking=no" \
|
||||
frontend/production/ \
|
||||
pi@${{ secrets.HOST_PI }}:/home/pi/lm-www/frontend_tmp/production/ > /tmp/rsync.log 2>&1 &
|
||||
root@${{ secrets.HOST43 }}:/home/pi/lm-www/frontend_tmp/production/ > /tmp/rsync.log 2>&1 &
|
||||
RSYNC_PID=$!
|
||||
|
||||
# 每10秒显示一次进度
|
||||
@@ -118,8 +124,8 @@ jobs:
|
||||
while kill -0 $RSYNC_PID 2>/dev/null; do
|
||||
sleep 10
|
||||
# 通过SSH检查目标目录大小
|
||||
TRANSFERRED=$(sshpass -p "${{ secrets.PASS_PI }}" ssh -o StrictHostKeyChecking=no \
|
||||
pi@${{ secrets.HOST_PI }} \
|
||||
TRANSFERRED=$(sshpass -p "${{ secrets.PASS43 }}" ssh -o StrictHostKeyChecking=no \
|
||||
root@${{ secrets.HOST43 }} \
|
||||
"du -sb /home/pi/lm-www/frontend_tmp/production 2>/dev/null | cut -f1" || echo "0")
|
||||
|
||||
if [ "$TRANSFERRED" != "0" ] && [ "$TOTAL_SIZE" != "0" ]; then
|
||||
@@ -153,9 +159,9 @@ jobs:
|
||||
- name: Deploy to production
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.HOST_PI }}
|
||||
username: pi
|
||||
password: ${{ secrets.PASS_PI }}
|
||||
host: ${{ secrets.HOST43 }}
|
||||
username: root
|
||||
password: ${{ secrets.PASS43 }}
|
||||
port: 22
|
||||
script: |
|
||||
set -e
|
||||
@@ -171,7 +177,8 @@ jobs:
|
||||
sudo rm -rf /home/pi/lm-www/frontend_tmp
|
||||
|
||||
echo "Setting permissions..."
|
||||
sudo chown -R www-data:www-data /home/pi/lm-www/frontend/
|
||||
# 将前端目录权限授予 nginx 运行用户(OpenCloudOS 上通常为 nginx)
|
||||
sudo chown -R nginx:nginx /home/pi/lm-www/frontend/
|
||||
sudo chmod -R 644 /home/pi/lm-www/frontend/
|
||||
sudo find /home/pi/lm-www/frontend/ -type d -exec chmod 755 {} \;
|
||||
|
||||
@@ -180,6 +187,14 @@ jobs:
|
||||
ls -la /home/pi/lm-www/frontend/production/ || echo "Warning: production directory not found"
|
||||
|
||||
echo "Reloading nginx..."
|
||||
sudo systemctl reload nginx
|
||||
# 与服务器手动操作保持一致:先测试配置,再重载
|
||||
if command -v nginx >/dev/null 2>&1; then
|
||||
echo "Testing nginx config (nginx -t)..."
|
||||
sudo nginx -t
|
||||
echo "Reloading nginx (nginx -s reload)..."
|
||||
sudo nginx -s reload
|
||||
else
|
||||
echo "⚠️ 未找到 nginx 命令,请确认 nginx 是否已安装并在 PATH 中"
|
||||
fi
|
||||
|
||||
echo "✅ Deployment completed!"
|
||||
@@ -1,96 +0,0 @@
|
||||
name: Deploy Frontend
|
||||
|
||||
on:
|
||||
# push:
|
||||
# branches: [ main ]
|
||||
# paths:
|
||||
# - 'frontend/**'
|
||||
# - 'Dockerfile.frontend'
|
||||
# - 'nginx.conf'
|
||||
# - '.github/workflows/deploy_frontend.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: license-manager-frontend
|
||||
CONTAINER_NAME: license-manager-frontend
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build -f Dockerfile.frontend --build-arg NGINX_CONF=nginx-ci.conf -t ${{ env.IMAGE_NAME }}:latest .
|
||||
docker save ${{ env.IMAGE_NAME }}:latest > frontend-image.tar
|
||||
|
||||
- name: Copy files to server
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.HOST_PI }}
|
||||
username: pi
|
||||
password: ${{ secrets.PASS_PI }}
|
||||
port: 22
|
||||
source: "frontend-image.tar"
|
||||
target: "/home/pi/license-manager/"
|
||||
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.HOST_PI }}
|
||||
username: pi
|
||||
password: ${{ secrets.PASS_PI }}
|
||||
port: 22
|
||||
script: |
|
||||
cd /home/pi/license-manager
|
||||
|
||||
# Stop and remove existing container
|
||||
docker stop ${{ env.CONTAINER_NAME }} || true
|
||||
docker rm ${{ env.CONTAINER_NAME }} || true
|
||||
|
||||
# Load new image
|
||||
docker load < frontend-image.tar
|
||||
|
||||
# Remove old image
|
||||
docker rmi ${{ env.IMAGE_NAME }}:old || true
|
||||
docker tag ${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:old || true
|
||||
|
||||
# Run new container
|
||||
docker run -d \
|
||||
--name ${{ env.CONTAINER_NAME }} \
|
||||
--restart unless-stopped \
|
||||
-p 18080:80 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
# Clean up
|
||||
rm -f frontend-image.tar
|
||||
docker image prune -f
|
||||
|
||||
# Check deployment status
|
||||
sleep 5
|
||||
if docker ps | grep -q ${{ env.CONTAINER_NAME }}; then
|
||||
echo "✅ Frontend deployment successful! Container is running."
|
||||
docker logs --tail 10 ${{ env.CONTAINER_NAME }}
|
||||
else
|
||||
echo "❌ Frontend deployment failed! Container is not running."
|
||||
docker logs ${{ env.CONTAINER_NAME }}
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
sleep 10
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" http://${{ secrets.HOST_PI }}:18080/ || echo "000")
|
||||
if [ "$response" = "200" ]; then
|
||||
echo "✅ Frontend health check passed! Service is responding."
|
||||
else
|
||||
echo "⚠️ Frontend health check returned status: $response"
|
||||
echo "Service may still be starting up..."
|
||||
fi
|
||||
@@ -38,12 +38,13 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Run tests
|
||||
- name: Build backend
|
||||
run: |
|
||||
cd backend
|
||||
go mod tidy
|
||||
go mod verify
|
||||
go test -v ./...
|
||||
# Skip tests in CI/CD to avoid external API dependencies
|
||||
echo "Skipping tests in CI/CD environment..."
|
||||
go build -ldflags="-s -w" -o license-manager cmd/main.go
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
+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.
|
||||
+3
-2
@@ -1,12 +1,13 @@
|
||||
# Backend only Dockerfile
|
||||
FROM golang:1.23-alpine AS builder
|
||||
WORKDIR /app/backend
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
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
|
||||
@@ -14,4 +15,4 @@ COPY backend/configs/ ../configs/
|
||||
# Use production config by default
|
||||
RUN ln -sf ../configs/config.prod.yaml config.yaml
|
||||
EXPOSE 18888
|
||||
CMD ["./license-manager"]
|
||||
CMD ["./license-manager"]
|
||||
@@ -1,4 +1,4 @@
|
||||
# 软件授权管理平台
|
||||
# 软件授权管理系统
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/cedar-v/license-manager/releases">
|
||||
@@ -24,24 +24,25 @@
|
||||
|
||||
## 项目概述
|
||||
|
||||
软件授权管理平台是一个独立的软件授权管理系统,为软件系统提供授权码生成、分发、验证和管理服务。支持在线和离线两种授权模式,基于硬件绑定的授权机制确保安全性。
|
||||
软件授权管理系统是一个完整的软件商业化解决方案,集成了软件授权管理、用户自助服务和商业化运营能力。支持B2B企业客户和B2C个人用户的双轨运营模式,提供从注册购买到授权激活的全流程数字化服务。采用在线/离线混合授权模式,结合硬件绑定和RSA数字签名确保安全性。
|
||||
|
||||
✨同时支持**任何开发语言**、**任何类型的软件产品**通过标准化 **HTTP API** 与**跨平台客户端 SDK** **快速集成授权能力**。
|
||||
|
||||
## Demo环境
|
||||
<a href="https://lm.cedar-v.com:18080">
|
||||
<a href="http://lm.cedar-v.com">
|
||||
<img src="https://img.shields.io/badge/Demo-Online-success?logo=googlechrome" alt="Live Demo">
|
||||
</a>
|
||||
|
||||
访问地址:[lm.cedar-v.com:18080](http://lm.cedar-v.com:18080)
|
||||
访问地址:[lm.cedar-v.com](http://lm.cedar-v.com)
|
||||
**登录凭证:**
|
||||
- 用户名:`admin`
|
||||
- 密码:`admin@123`
|
||||
|
||||
> 💡 这是一个当前功能的演示环境,您可以体验已开发的核心特性。
|
||||
|
||||
## 官方文档
|
||||
## 官方
|
||||
|
||||
官方网站请访问:[https://www.cedar-v.com](https://www.cedar-v.com)
|
||||
官方文档请访问:[https://docs.cedar-v.com](https://docs.lm.cedar-v.com)
|
||||
|
||||
## Star收藏与关注
|
||||
@@ -79,6 +80,28 @@
|
||||
|
||||
客户管理模块提供完整的客户信息管理功能,包括客户信息的增删改查、状态管理、授权关联等核心功能。
|
||||
|
||||
## 用户端自助服务
|
||||
|
||||
平台提供完整的用户端自助服务,支持 **B2B+B2C双轨运营**:
|
||||
|
||||
### B2B企业客户服务
|
||||
- **企业用户注册**:支持手机号注册,企业管理员可管理成员账户
|
||||
- **产品套餐购买**:提供试用版、基础版、专业版三种套餐,支持批量许可购买
|
||||
- **授权码管理**:在线购买授权码,支持离线激活和授权码分享
|
||||
- **设备管理**:企业用户可查看和管理所有激活设备,支持设备解绑
|
||||
- **订单管理**:完整的订单历史查询和状态跟踪
|
||||
|
||||
### B2C个人客户服务
|
||||
- **个人用户注册**:手机号快速注册,简化的个人账户管理
|
||||
- **灵活购买**:支持单个或少量许可购买,满足个人用户需求
|
||||
- **便捷激活**:支持在线激活和离线激活两种模式
|
||||
- **设备监控**:个人用户可实时查看设备状态和使用情况
|
||||
|
||||
### 统一支付体验
|
||||
- **支付宝集成**:支持支付宝扫码支付,安全可靠的支付体验
|
||||
- **支付状态跟踪**:实时支付状态查询和回调通知
|
||||
- **多环境支持**:沙箱环境测试和生产环境切换
|
||||
|
||||
## 核心功能
|
||||
|
||||
- 🔧 **客户管理**:完整的客户信息管理和状态控制
|
||||
@@ -88,6 +111,7 @@
|
||||
- 🌐 **API服务**:提供验证、激活、心跳监控等RESTful API
|
||||
- ⚙️ **系统管理**:管理员认证和监控仪表盘
|
||||
- 🛠️ **跨平台工具**:多平台硬件信息获取工具
|
||||
- 👥 **用户端自助服务**:B2B+B2C双轨运营,支持企业客户和个人用户自助操作
|
||||
|
||||
## 技术栈
|
||||
|
||||
|
||||
+24
-1
@@ -24,7 +24,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
License Manager is an independent software licensing platform that provides license code generation, distribution, validation, and management services for IoT platforms and other software systems. It offers both online and offline licensing modes with hardware-based binding for enhanced security.
|
||||
License Manager is a comprehensive software commercialization solution that integrates software licensing management, self-service user portals, and commercial operation capabilities. It supports dual-track operations for both B2B enterprise customers and B2C individual users, providing full-process digital services from registration and purchase to license activation. It employs a hybrid online/offline licensing model combined with hardware binding and RSA digital signatures to ensure security.
|
||||
|
||||
✨It enables **any programming language** and **any type of software product** to **quickly integrate licensing capabilities** via standardized **HTTP APIs** and **cross-platform client SDKs**.
|
||||
|
||||
@@ -71,6 +71,28 @@ Client SDK repository: [https://github.com/cedar-v/license-manager-sdk](https://
|
||||
|
||||
The customer management module provides comprehensive customer information management features, including CRUD operations for customer data, status management, and authorization associations.
|
||||
|
||||
## Self-Service User Portal
|
||||
|
||||
The platform provides comprehensive self-service user portals supporting **dual-track operations for B2B+B2C**:
|
||||
|
||||
### B2B Enterprise Customer Services
|
||||
- **Enterprise User Registration**: Mobile phone registration with enterprise admin managing member accounts
|
||||
- **Product Package Purchase**: Three package types (Trial, Basic, Professional) with bulk license purchasing
|
||||
- **License Code Management**: Online license purchase with offline activation and license sharing
|
||||
- **Device Management**: Enterprise users can view and manage all activated devices with device unbinding
|
||||
- **Order Management**: Complete order history queries and status tracking
|
||||
|
||||
### B2C Individual Customer Services
|
||||
- **Individual User Registration**: Quick mobile phone registration with simplified personal account management
|
||||
- **Flexible Purchasing**: Support for single or small quantity license purchases meeting individual user needs
|
||||
- **Convenient Activation**: Support for both online activation and offline activation modes
|
||||
- **Device Monitoring**: Individual users can monitor device status and usage in real-time
|
||||
|
||||
### Unified Payment Experience
|
||||
- **Alipay Integration**: Support for Alipay QR code payment with secure and reliable payment experience
|
||||
- **Payment Status Tracking**: Real-time payment status queries and callback notifications
|
||||
- **Multi-environment Support**: Sandbox environment testing and production environment switching
|
||||
|
||||
## Key Features
|
||||
|
||||
- 🔧 **Customer Management**: Complete customer information management with status control
|
||||
@@ -80,6 +102,7 @@ The customer management module provides comprehensive customer information manag
|
||||
- 🌐 **API Services**: RESTful APIs for validation, activation, and heartbeat monitoring
|
||||
- ⚙️ **System Management**: Admin authentication and monitoring dashboard
|
||||
- 🛠️ **Cross-platform Tools**: Hardware information extraction tools for multiple platforms
|
||||
- 👥 **Self-Service User Portal**: Dual-track B2B+B2C operations supporting enterprise customers and individual users with self-service operations
|
||||
|
||||
## Technical Stack
|
||||
|
||||
|
||||
+14
-4
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
|
||||
// @title License Manager API
|
||||
// @version 1.0
|
||||
// @description 软件授权管理平台API文档
|
||||
// @description 软件授权管理系统API文档
|
||||
// @termsOfService http://swagger.io/terms/
|
||||
|
||||
// @contact.name API Support
|
||||
@@ -36,10 +37,19 @@ import (
|
||||
// @description Bearer token for authentication
|
||||
|
||||
func main() {
|
||||
// 定义命令行参数
|
||||
var customPath string
|
||||
flag.StringVar(&customPath, "config", "", "path to configuration file")
|
||||
flag.StringVar(&customPath, "c", "", "path to configuration file (shorthand)")
|
||||
flag.Parse()
|
||||
|
||||
// 加载配置
|
||||
configPath := "config.yaml" // Docker 环境默认路径
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
configPath = "../configs/config.yaml" // 本地开发环境
|
||||
configPath := customPath
|
||||
if configPath == "" {
|
||||
configPath = "config.yaml" // Docker 环境默认路径
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
configPath = "../configs/config.yaml" // 本地开发环境
|
||||
}
|
||||
}
|
||||
|
||||
if err := config.Load(configPath); err != nil {
|
||||
|
||||
@@ -5,11 +5,11 @@ server:
|
||||
|
||||
database:
|
||||
driver: "mysql"
|
||||
host: "mysql"
|
||||
host: "203.2.164.31"
|
||||
port: 3306
|
||||
username: "license_user"
|
||||
password: "license@123"
|
||||
database: "license_manager"
|
||||
username: "license"
|
||||
password: "8X3Yd8hQ4WMPw227"
|
||||
database: "license"
|
||||
charset: "utf8mb4"
|
||||
parse_time: true
|
||||
loc: "Local"
|
||||
@@ -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,43 @@ 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"
|
||||
"500018": "Account locked, please try again later"
|
||||
|
||||
# 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"
|
||||
"601006": "Order has been paid and cannot be cancelled"
|
||||
"601007": "Order status does not allow continue payment"
|
||||
|
||||
# System general (90xxxx)
|
||||
system:
|
||||
"900001": "Invalid request parameters"
|
||||
@@ -53,6 +97,14 @@ errors:
|
||||
"900003": "Resource conflict"
|
||||
"900004": "Internal server error"
|
||||
|
||||
# Invoice module (70xxxx)
|
||||
invoice:
|
||||
"700001": "Invoice not found"
|
||||
"700002": "Order not found or access denied"
|
||||
"700003": "Invoice already exists for this order"
|
||||
"700004": "Invoice status does not allow this operation"
|
||||
"700005": "Invoice file not found"
|
||||
|
||||
# Common text
|
||||
common:
|
||||
success: "Success"
|
||||
@@ -113,12 +165,20 @@ 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"
|
||||
|
||||
invoice_status:
|
||||
"pending": "Pending"
|
||||
"issued": "Issued"
|
||||
"rejected": "Rejected"
|
||||
|
||||
invoice_type:
|
||||
"personal": "Personal"
|
||||
"enterprise": "Enterprise"
|
||||
"vat_special": "VAT Special Invoice"
|
||||
|
||||
# Default error message
|
||||
default_error: "Unknown error"
|
||||
default_error: "Unknown error"
|
||||
@@ -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,43 @@ 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送信に失敗しました"
|
||||
"500018": "アカウントがロックされています。しばらくしてからお試しください"
|
||||
|
||||
# セット料金モジュール (60xxxx)
|
||||
cu_package:
|
||||
"600001": "セット料金が存在しません"
|
||||
"600002": "セット料金は販売終了しました"
|
||||
"600003": "試用版購入時間制限"
|
||||
"600004": "試用版は一括購入をサポートしていません"
|
||||
"600005": "今月試用版を購入済み"
|
||||
|
||||
# 注文モジュール (61xxxx)
|
||||
cu_order:
|
||||
"601001": "注文が存在しません"
|
||||
"601005": "ライセンス数量が制限を超えています"
|
||||
"601006": "支払い済みの注文はキャンセルできません"
|
||||
"601007": "注文の状態により、支払いを続けることができません"
|
||||
|
||||
# システム共通 (90xxxx)
|
||||
system:
|
||||
"900001": "無効なリクエストパラメータ"
|
||||
@@ -113,12 +157,10 @@ enums:
|
||||
|
||||
authorization_change_type:
|
||||
"renewal": "更新"
|
||||
"upgrade": "アップグレード"
|
||||
"limit_change": "制限変更"
|
||||
"feature_toggle": "機能切り替え"
|
||||
"feature_limit_change": "機能制限変更"
|
||||
"lock": "ロック"
|
||||
"unlock": "アンロック"
|
||||
"other": "その他"
|
||||
|
||||
# デフォルトエラーメッセージ
|
||||
default_error: "不明なエラー"
|
||||
default_error: "不明なエラー"
|
||||
@@ -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,54 @@ errors:
|
||||
"400005": "开始日期不能晚于结束日期"
|
||||
"400006": "数量限制参数错误"
|
||||
|
||||
# 客户用户模块 (50xxxx)
|
||||
cu_user:
|
||||
"500001": "手机号格式错误"
|
||||
"500002": "手机号已被注册"
|
||||
"500003": "手机号或密码错误"
|
||||
"500004": "忘记密码失败"
|
||||
"500005": "重置密码失败"
|
||||
"500006": "个人资料更新失败"
|
||||
"500007": "手机号更新失败"
|
||||
"500008": "密码修改失败"
|
||||
"500009": "用户不存在"
|
||||
"500010": "认证令牌缺失"
|
||||
"500011": "认证令牌无效"
|
||||
"500012": "认证令牌已过期"
|
||||
"500013": "令牌生成失败"
|
||||
"500014": "验证码错误或已过期"
|
||||
"500015": "验证码不存在"
|
||||
"500016": "短信发送过于频繁,请稍后再试"
|
||||
"500017": "短信发送失败"
|
||||
"500018": "账号已被锁定,请稍后再试"
|
||||
|
||||
# 套餐订单模块 (60xxxx)
|
||||
cu_package:
|
||||
"600001": "套餐不存在"
|
||||
"600002": "套餐已下架"
|
||||
"600003": "试用版购买时间限制"
|
||||
"600004": "试用版不支持批量购买"
|
||||
"600005": "当月已购买试用版"
|
||||
|
||||
# 订单模块 (61xxxx)
|
||||
cu_order:
|
||||
"601001": "订单不存在"
|
||||
"601005": "许可数量超出限制"
|
||||
"601006": "订单已支付,无法取消"
|
||||
"601007": "订单状态不允许继续支付"
|
||||
|
||||
# 设备管理模块 (62xxxx)
|
||||
cu_device:
|
||||
"620001": "设备不存在或无权限访问"
|
||||
|
||||
# 发票模块 (70xxxx)
|
||||
invoice:
|
||||
"700001": "发票不存在"
|
||||
"700002": "订单不存在或无权限访问"
|
||||
"700003": "该订单已存在发票申请"
|
||||
"700004": "发票状态不允许操作"
|
||||
"700005": "发票文件不存在"
|
||||
|
||||
# 系统通用 (90xxxx)
|
||||
system:
|
||||
"900001": "请求参数无效"
|
||||
@@ -112,13 +168,21 @@ enums:
|
||||
"offline": "离线"
|
||||
|
||||
authorization_change_type:
|
||||
"renewal": "续期"
|
||||
"upgrade": "升级"
|
||||
"limit_change": "限制变更"
|
||||
"feature_toggle": "功能切换"
|
||||
"renewal": "日期变更"
|
||||
"feature_limit_change": "功能限制变更"
|
||||
"lock": "锁定"
|
||||
"unlock": "解锁"
|
||||
"other": "其他"
|
||||
|
||||
invoice_status:
|
||||
"pending": "待处理"
|
||||
"issued": "已开票"
|
||||
"rejected": "已驳回"
|
||||
|
||||
invoice_type:
|
||||
"personal": "个人"
|
||||
"enterprise": "企业普票"
|
||||
"vat_special": "增值税专用发票"
|
||||
|
||||
# 默认错误信息
|
||||
default_error: "未知错误"
|
||||
default_error: "未知错误"
|
||||
@@ -5,10 +5,14 @@
|
||||
Web框架: Gin
|
||||
ORM: GORM,开发环境使用Auto Migration
|
||||
配置: Viper
|
||||
日志: logrus
|
||||
认证: JWT
|
||||
缓存: Redis (可选)
|
||||
数据库: PostgreSQL/MySQL
|
||||
日志: logrus (封装为logger包)
|
||||
认证: JWT (支持管理员和C端用户双JWT体系)
|
||||
缓存: Redis/内存缓存 (支持切换)
|
||||
数据库: MySQL (推荐)/PostgreSQL
|
||||
支付集成: 支付宝
|
||||
短信服务: 阿里云SMS
|
||||
加密算法: RSA
|
||||
国际化: 支持多语言错误信息和枚举显示
|
||||
|
||||
|
||||
## 目录结构
|
||||
@@ -20,16 +24,33 @@
|
||||
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_invoice_handler.go # 客户端发票处理器
|
||||
│ │ │ ├── cu_order_handler.go
|
||||
│ │ │ ├── cu_profile_handler.go
|
||||
│ │ │ └── invoice_handler.go # 管理端发票处理器
|
||||
│ │ ├── middleware/ # 中间件
|
||||
│ │ │ ├── auth.go
|
||||
│ │ │ ├── cors.go
|
||||
@@ -41,22 +62,46 @@ 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
|
||||
│ │ ├── admin_invoice_service.go # 管理端发票服务
|
||||
│ │ ├── cu_device_service.go
|
||||
│ │ ├── cu_invoice_service.go # 客户端发票服务
|
||||
│ │ ├── cu_order_filters.go # 订单过滤器
|
||||
│ │ ├── cu_order_filters_test.go # 订单过滤器测试
|
||||
│ │ ├── cu_order_service.go
|
||||
│ │ ├── cu_user_service.go
|
||||
│ │ └── interfaces.go # 接口定义
|
||||
│ ├── repository/ # 数据访问层
|
||||
│ │ ├── admin_invoice_repository.go # 管理端发票仓储
|
||||
│ │ ├── authorization_code_repository.go
|
||||
│ │ ├── cu_invoice_repository.go # 客户端发票仓储
|
||||
│ │ ├── cu_order_repository.go
|
||||
│ │ ├── cu_user_repository.go
|
||||
│ │ ├── customer_repository.go
|
||||
│ │ ├── dashboard_repository.go
|
||||
│ │ ├── license_repository.go
|
||||
│ │ ├── payment_repository.go
|
||||
│ │ ├── user_repository.go
|
||||
│ │ ├── gorm/ # GORM相关扩展
|
||||
│ │ ├── errors.go
|
||||
│ │ └── interfaces.go
|
||||
│ ├── models/ # 数据模型
|
||||
│ │ ├── admin_invoice_models.go # 管理端发票模型
|
||||
│ │ ├── auth.go
|
||||
│ │ ├── common.go
|
||||
│ │ ├── cu_authorization.go
|
||||
│ │ ├── cu_invoice_models.go # 客户端发票模型
|
||||
│ │ ├── cu_order.go
|
||||
│ │ ├── cu_user.go
|
||||
│ │ ├── customer.go
|
||||
│ │ ├── dashboard.go
|
||||
│ │ ├── license.go
|
||||
│ │ ├── payment.go
|
||||
│ │ ├── shared_invoice_models.go # 共享发票模型
|
||||
│ │ ├── system.go
|
||||
│ │ └── user.go
|
||||
│ ├── config/ # 配置管理
|
||||
@@ -65,14 +110,25 @@ backend/
|
||||
│ ├── connection.go
|
||||
│ └── migration.go
|
||||
├── pkg/
|
||||
│ ├── cache/ # 缓存系统
|
||||
│ │ ├── cache.go
|
||||
│ │ ├── factory.go
|
||||
│ │ ├── memory.go
|
||||
│ │ ├── redis.go
|
||||
│ │ └── key_builder.go
|
||||
│ ├── context/ # 上下文封装
|
||||
│ │ └── context.go
|
||||
│ ├── i18n/ # 国际化
|
||||
│ │ ├── errors.go
|
||||
│ │ └── manager.go
|
||||
│ │ ├── manager.go
|
||||
│ │ └── messages/
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── crypto.go
|
||||
│ │ └── jwt.go
|
||||
│ │ ├── alipay.go # 支付宝支付集成
|
||||
│ │ ├── authorization_code.go # 授权码生成
|
||||
│ │ ├── crypto.go # 加密工具
|
||||
│ │ ├── cu_jwt.go # C端用户JWT工具
|
||||
│ │ ├── jwt.go # JWT工具
|
||||
│ │ └── sms.go # 短信服务
|
||||
│ └── logger/ # 日志封装
|
||||
│ └── logger.go
|
||||
├── configs/
|
||||
@@ -113,6 +169,12 @@ 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
|
||||
│ ├── 012_create_invoices_table.sql
|
||||
│ └── README.md
|
||||
├── go.mod
|
||||
└── go.sum
|
||||
@@ -122,5 +184,11 @@ backend/
|
||||
- **分层架构**:Handler → Service → Repository
|
||||
- **依赖注入**:通过接口解耦各层
|
||||
- **配置驱动**:Viper管理所有配置
|
||||
- **缓存抽象**:支持内存/Redis切换
|
||||
- **缓存抽象**:支持内存/Redis切换,带过期时间和键构建器
|
||||
- **双JWT体系**:支持管理员和C端用户独立认证体系
|
||||
- **客户端/管理端分离**:采用cu_*前缀严格分离不同用户角色的业务逻辑
|
||||
- **模块化设计**:支持客户管理、订单管理、支付集成等独立模块
|
||||
- **国际化支持**:完整的多语言错误信息和枚举显示系统
|
||||
- **软删除支持**:所有数据表支持gorm.DeletedAt软删除
|
||||
- **SQL迁移**:复杂的索引和约束通过SQL文件管理,保证生产环境一致性
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# 令牌刷新
|
||||
|
||||
目前系统有一个超管端和一个用户端,需要对两个端进行令牌刷新机制的改造。
|
||||
|
||||
Token过期与刷新机制
|
||||
Token刷新是为了在保证安全性的同时提升用户体验,避免频繁登录。核心是使用短期访问令牌(Access Token) + 长期刷新令牌(Refresh Token) 的双令牌策略。
|
||||
基本流程
|
||||
1. 用户登录
|
||||
用户提交账号密码 → 后端验证 → 返回两个token:
|
||||
- Access Token (短期,如15分钟-2小时)
|
||||
- Refresh Token (长期,如7-30天)
|
||||
2. 日常请求
|
||||
前端携带Access Token → 后端验证 → 返回数据
|
||||
3. Token过期处理
|
||||
Access Token过期 → 请求失败(401) → 前端自动用Refresh Token换新的Access Token → 重试原请求
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# 管理端企业线索汇总接口设计
|
||||
|
||||
## 接口路径
|
||||
`GET /api/leads/summary`
|
||||
|
||||
## 请求
|
||||
不需要任何参数。
|
||||
|
||||
## 鉴权
|
||||
需要管理端权限(与线索列表/详情接口一致)。
|
||||
|
||||
## 响应示例
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "success",
|
||||
"data": {
|
||||
"total_count": 1200,
|
||||
"pending_count": 300,
|
||||
"following_count": 520,
|
||||
"converted_count": 260,
|
||||
"lost_count": 120
|
||||
},
|
||||
"timestamp": 1738454400
|
||||
}
|
||||
```
|
||||
|
||||
## 字段说明
|
||||
- total_count:线索总数
|
||||
- pending_count:待跟进数量
|
||||
- following_count:已跟进数量
|
||||
- converted_count:已成交数量
|
||||
- lost_count:已流失数量
|
||||
@@ -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'` 的记录数
|
||||
@@ -0,0 +1,45 @@
|
||||
# 订单号查询增加筛选
|
||||
|
||||
改造接口:`GET /api/cu/orders`
|
||||
|
||||
## 背景
|
||||
当前订单列表仅支持分页查询,需要增加筛选能力以提升定位订单效率。
|
||||
|
||||
## 目标
|
||||
在现有订单列表接口基础上,增加 3 个可选筛选字段:`search`、`status`、`time`,并保持向后兼容(不传筛选参数时行为不变)。
|
||||
|
||||
## 接口定义
|
||||
### 请求
|
||||
`GET /api/cu/orders`
|
||||
|
||||
#### Query 参数
|
||||
- `page`:页码,默认 `1`
|
||||
- `page_size`:每页数量,默认 `10`,最大 `100`
|
||||
- `search`:模糊匹配关键字(订单号 `order_no` 或订单授权码 `authorization_code`)
|
||||
- `status`:订单状态筛选(等值匹配)
|
||||
- 目前系统内常见值:`pending` / `paid` / `cancelled`
|
||||
- `time`:创建时间筛选(按订单 `created_at`)
|
||||
- 支持值(推荐):`today` / `week` / `month` / `three_months`
|
||||
- 支持中文别名:`今天` / `本周` / `本月` / `近三个月`
|
||||
|
||||
#### 过滤规则
|
||||
- `search`:`order_no LIKE %search% OR authorization_code LIKE %search%`
|
||||
- `status`:`status = ?`
|
||||
- `time`:对 `created_at` 增加范围过滤(左闭右开:`>= start AND < end`)
|
||||
- `today/今天`:当天 00:00:00 ~ 次日 00:00:00
|
||||
- `week/本周`:以周一为一周起始
|
||||
- `month/本月`:当月 1 日 00:00:00 ~ 下月 1 日 00:00:00
|
||||
- `three_months/近三个月`:从当前时间往前推 3 个月(按日期对齐到 00:00:00)~ 次日 00:00:00
|
||||
|
||||
### 响应
|
||||
保持现有返回结构不变:`models.CuOrderListResponse`
|
||||
|
||||
## 示例
|
||||
- 订单号/授权码模糊搜索:
|
||||
- `GET /api/cu/orders?page=1&page_size=10&search=ORD20260118`
|
||||
- 按状态筛选:
|
||||
- `GET /api/cu/orders?status=pending`
|
||||
- 按时间筛选(近三个月):
|
||||
- `GET /api/cu/orders?time=three_months`
|
||||
- 组合筛选:
|
||||
- `GET /api/cu/orders?search=ORD&status=paid&time=month`
|
||||
@@ -0,0 +1,95 @@
|
||||
# 取消订单接口
|
||||
|
||||
## 背景
|
||||
当前客户侧订单支持创建、查询与列表查看(`/api/cu/orders`),但缺少“取消未支付订单”的能力;用户误下单/重复下单时只能等待支付超时,体验较差。
|
||||
|
||||
## 目标
|
||||
- 客户用户可取消“未支付(`pending`)”订单。
|
||||
- 已支付(`paid`)订单不允许取消,返回明确的业务错误码与错误信息。
|
||||
- 返回结构与现有接口保持一致:成功返回 `models.APIResponse`,失败返回 `models.ErrorResponse`。
|
||||
|
||||
## 参考接口(已实现)
|
||||
为保持风格一致,本接口沿用以下已实现接口的路径风格、鉴权方式与返回结构:
|
||||
- `POST /api/cu/orders`:创建订单
|
||||
- `GET /api/cu/orders/{order_id}`:订单详情
|
||||
- `GET /api/cu/orders`:订单列表(含 `status` 筛选)
|
||||
|
||||
## 业务规则(与当前项目对齐)
|
||||
> 说明:当前 `cu_orders` 表包含 `deleted_at`(GORM 软删除),但订单列表接口按 `status` 做筛选;为了支持“已取消订单可追溯/可筛选”,本设计采用**状态取消**(`status=cancelled`)而不是直接删除记录。
|
||||
|
||||
- 允许取消:订单属于当前登录客户用户,且订单状态为 `pending`。
|
||||
- 不允许取消:订单状态为 `paid`(包含试用/免费单,当前实现中免费单直接为 `paid`)。
|
||||
- 幂等性:重复取消同一订单(已为 `cancelled`)应返回成功(不重复变更),避免前端重复点击导致报错。
|
||||
|
||||
## 接口定义
|
||||
|
||||
### 请求
|
||||
```http
|
||||
PUT /api/cu/orders/{order_id}/cancel
|
||||
```
|
||||
|
||||
#### Headers
|
||||
- `Authorization: Bearer <token>`(必填)
|
||||
- `Accept-Language`(可选)或 query `?lang=zh-CN`(可选):与现有多语言中间件一致
|
||||
|
||||
#### Path 参数
|
||||
- `order_id`:订单ID(必填,字符串,UUID)
|
||||
|
||||
#### Body
|
||||
无
|
||||
|
||||
### 响应
|
||||
|
||||
#### 成功(200)
|
||||
返回取消后的订单信息(与 `GET /api/cu/orders/{order_id}` 一致):
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"id": "uuid-string",
|
||||
"order_no": "ORD202601210001",
|
||||
"customer_id": "customer-uuid",
|
||||
"cu_user_id": "cu-user-uuid",
|
||||
"package_id": "basic",
|
||||
"package_name": "基础版",
|
||||
"license_count": 10,
|
||||
"unit_price": 300,
|
||||
"discount_rate": 1,
|
||||
"total_amount": 3000,
|
||||
"status": "cancelled",
|
||||
"authorization_code": null,
|
||||
"expired_at": null,
|
||||
"created_at": "2026-01-21T10:00:00Z",
|
||||
"updated_at": "2026-01-21T10:05:00Z"
|
||||
},
|
||||
"timestamp": "2026-01-21T10:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### 失败
|
||||
失败时返回 `models.ErrorResponse`:
|
||||
```json
|
||||
{
|
||||
"code": "601006",
|
||||
"message": "订单已支付,无法取消",
|
||||
"timestamp": "2026-01-21T10:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 状态约定
|
||||
- `pending`:待支付(允许取消)
|
||||
- `paid`:已支付(不允许取消)
|
||||
- `cancelled`:已取消(重复取消返回成功)
|
||||
|
||||
## 错误码(建议新增/复用)
|
||||
- `100004`:缺少认证信息(未登录)
|
||||
- `100005`:权限不足(只能取消自己的订单)
|
||||
- `601001`:订单不存在
|
||||
- `601006`:订单已支付,无法取消(建议新增到 `backend/configs/i18n/errors/*` 的 `cu_order` 下)
|
||||
- `900001`:请求参数无效(如 `order_id` 为空)
|
||||
- `900004`:服务器内部错误
|
||||
|
||||
## 示例
|
||||
- 取消订单:
|
||||
- `PUT /api/cu/orders/7b8c2c2e-9f2f-4f0b-9f31-0c8d3f6a9c11/cancel`
|
||||
@@ -0,0 +1,123 @@
|
||||
# 用户授权管理相关接口
|
||||
|
||||
> 分组建议:放到“用户端授权码管理”(Customer User / CU)分组,统一前缀:`/api/cu`
|
||||
|
||||
## 通用约定
|
||||
- 鉴权:`Authorization: Bearer <token>`(必填)
|
||||
- 多语言:`Accept-Language` header 或 `?lang=zh-CN`(可选)
|
||||
- 成功返回:`models.APIResponse`
|
||||
- 失败返回:`models.ErrorResponse`
|
||||
|
||||
## 参考接口(已实现)
|
||||
为保持接口风格一致(路径、鉴权、返回结构),本设计参考并沿用以下已实现接口:
|
||||
- `POST /api/cu/authorization-codes/{codeId}/share`:用户分享授权码
|
||||
- `POST /api/cu/authorization-codes/product-activation-code`:获取产品激活码
|
||||
- `GET /api/v1/authorization-codes`:授权码列表(管理端,分页结构可复用)
|
||||
|
||||
## 状态约定
|
||||
授权码状态沿用系统枚举:
|
||||
- `normal`:有效(当前时间未超过 `end_date` 且未锁定)
|
||||
- `expired`:已过期(当前时间超过 `end_date`)
|
||||
- `locked`:已锁定(`is_locked=true`;是否过期由 `end_date` 决定)
|
||||
|
||||
> 前端展示“有效/已过期”时,可将 `locked` 作为“有效但锁定”或单独状态展示(以 UI 为准)。
|
||||
|
||||
## 接口定义
|
||||
|
||||
### 1) 获取用户授权码列表(新增)
|
||||
```http
|
||||
GET /api/cu/authorization-codes
|
||||
```
|
||||
|
||||
#### Query 参数
|
||||
- `page`:页码,默认 `1`
|
||||
- `page_size`:每页数量,默认 `10`,最大 `100`
|
||||
- `status`:状态筛选(可选)`normal` / `expired` / `locked`
|
||||
- `search`:授权码模糊匹配(可选,匹配 `code`)
|
||||
|
||||
#### 响应(200)
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"id": "uuid-string",
|
||||
"code": "LIC-COMP001-A7B9X2-C8F4",
|
||||
"status": "normal",
|
||||
"status_display": "正常",
|
||||
"max_activations": 10,
|
||||
"current_activations": 7,
|
||||
"remaining_activations": 3,
|
||||
"created_at": "2026-01-21T10:00:00Z",
|
||||
"end_date": "2026-12-31T23:59:59Z"
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"page_size": 10,
|
||||
"total_pages": 1
|
||||
},
|
||||
"timestamp": "2026-01-21T10:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### 字段说明
|
||||
- `max_activations`:设备总数(可绑定/可激活上限)
|
||||
- `current_activations`:已绑定设备数(已激活许可证数量)
|
||||
- `remaining_activations`:剩余可绑定设备数(建议字段;也可由前端计算 `max_activations - current_activations`)
|
||||
- `end_date`:到期时间(授权结束时间)
|
||||
|
||||
#### 错误码
|
||||
- `100004`:缺少认证信息
|
||||
- `900001`:请求参数无效
|
||||
- `900004`:服务器内部错误
|
||||
|
||||
---
|
||||
|
||||
### 2) 用户授权信息统计(新增)
|
||||
```http
|
||||
GET /api/cu/authorization-codes/summary
|
||||
```
|
||||
|
||||
#### 响应(200)
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"total_count": 12,
|
||||
"expired_count": 3,
|
||||
"valid_count": 9,
|
||||
"valid_max_activations_sum": 150
|
||||
},
|
||||
"timestamp": "2026-01-21T10:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### 字段说明
|
||||
- `total_count`:总授权码数量
|
||||
- `expired_count`:已过期授权码数量
|
||||
- `valid_count`:有效授权码数量(建议口径:`status=normal`,是否包含 `locked` 由产品定义;若包含请在实现中统一)
|
||||
- `valid_max_activations_sum`:有效授权码的 `max_activations` 之和
|
||||
|
||||
#### 错误码
|
||||
- `100004`:缺少认证信息
|
||||
- `900004`:服务器内部错误
|
||||
|
||||
---
|
||||
|
||||
### 3) 用户分享授权码(已实现)
|
||||
```http
|
||||
POST /api/cu/authorization-codes/{codeId}/share
|
||||
```
|
||||
> 说明:接口实现与 Swagger 注释已存在于 `backend/internal/api/handlers/cu_authorization_handler.go`。
|
||||
|
||||
---
|
||||
|
||||
### 4) 获取产品激活码(已实现)
|
||||
```http
|
||||
POST /api/cu/authorization-codes/product-activation-code
|
||||
```
|
||||
> 说明:接口实现与 Swagger 注释已存在于 `backend/internal/api/handlers/cu_authorization_handler.go`。
|
||||
@@ -0,0 +1,12 @@
|
||||
# 管理端用户管理相关接口设计
|
||||
|
||||
这两接口都属于管理端接口 不属于用户端接口
|
||||
|
||||
## 用户列表分页查询
|
||||
|
||||
系统管理员可以查看用户列表,并进行分页查询。
|
||||
|
||||
## 进入用户端
|
||||
|
||||
系统管理员可以以直接登录的方式进入用户端
|
||||
接口和用户登录接口一样可以返回token
|
||||
@@ -0,0 +1,145 @@
|
||||
# 套餐管理需求
|
||||
|
||||
管理员端可以配置套餐,用户端从数据库查询套餐列表。
|
||||
|
||||
## 表设计
|
||||
|
||||
```sql
|
||||
-- 套餐表
|
||||
CREATE TABLE packages (
|
||||
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||||
name VARCHAR(100) NOT NULL COMMENT '套餐名称',
|
||||
type VARCHAR(20) NOT NULL COMMENT '套餐类型: trial-试用版, basic-基础版, professional-专业版, custom-定制版',
|
||||
price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
|
||||
price_description VARCHAR(100) DEFAULT '' COMMENT '价格描述,如"定制报价"',
|
||||
duration_description VARCHAR(200) DEFAULT '' COMMENT '期限描述,如"永久有效"、"当月25日到期"',
|
||||
description VARCHAR(500) DEFAULT '' COMMENT '套餐说明',
|
||||
features TEXT DEFAULT '' COMMENT '功能项,JSON格式: ["功能1", "功能2"]',
|
||||
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态: 1-启用, 0-禁用',
|
||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序,数字越大越靠前',
|
||||
remark VARCHAR(500) DEFAULT '' COMMENT '备注',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
deleted_at DATETIME NULL,
|
||||
|
||||
INDEX idx_packages_type (type),
|
||||
INDEX idx_packages_status (status),
|
||||
INDEX idx_packages_sort_order (sort_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品套餐表';
|
||||
```
|
||||
|
||||
## 接口设计
|
||||
|
||||
### 管理端接口
|
||||
|
||||
#### 1. 获取套餐列表
|
||||
```
|
||||
GET /api/packages
|
||||
```
|
||||
**请求参数:**
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| type | string | 否 | 套餐类型筛选 |
|
||||
| status | int | 否 | 状态筛选,1-启用,0-禁用 |
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "试用版",
|
||||
"type": "trial",
|
||||
"price": 0,
|
||||
"price_description": "免费",
|
||||
"duration_description": "当月25日到期",
|
||||
"description": "免费体验全部功能",
|
||||
"features": ["全部功能", "1个许可"],
|
||||
"status": 1,
|
||||
"sort_order": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 创建套餐
|
||||
```
|
||||
POST /api/packages
|
||||
```
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"name": "试用版",
|
||||
"type": "trial",
|
||||
"price": 0,
|
||||
"price_description": "免费",
|
||||
"duration_description": "当月25日到期",
|
||||
"description": "免费体验全部功能",
|
||||
"features": ["全部功能", "1个许可"],
|
||||
"status": 1,
|
||||
"sort_order": 1,
|
||||
"remark": ""
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 更新套餐
|
||||
```
|
||||
PUT /api/packages/{id}
|
||||
```
|
||||
**请求体:** 同创建接口
|
||||
|
||||
#### 4. 删除套餐
|
||||
```
|
||||
DELETE /api/packages/{id}
|
||||
```
|
||||
|
||||
### 用户端接口
|
||||
|
||||
#### 获取套餐列表
|
||||
```
|
||||
GET /api/cu/packages
|
||||
```
|
||||
**说明:** 只返回启用状态的套餐,按 sort_order 排序,保持原有返回结构不变
|
||||
|
||||
**响应:** 与原有接口一致
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"data": [
|
||||
{
|
||||
"id": "trial",
|
||||
"name": "试用版",
|
||||
"type": "trial",
|
||||
"price": 0,
|
||||
"max_devices": 1,
|
||||
"description": "免费体验全部功能",
|
||||
"features": ["全部功能", "1个许可"],
|
||||
"details": "包含全部功能|试用期限制|1个许可等"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 套餐类型
|
||||
|
||||
| 类型 | 名称 | 说明 |
|
||||
|------|------|------|
|
||||
| trial | 试用版 | 免费,当月25日到期 |
|
||||
| basic | 基础版 | 付费,永久有效 |
|
||||
| professional | 专业版 | 付费,永久有效,全部功能 |
|
||||
| custom | 定制版 | 定制报价,按需配置 |
|
||||
|
||||
## 实现文件
|
||||
|
||||
### 新建文件
|
||||
- `backend/internal/models/package.go` - 套餐模型
|
||||
- `backend/internal/repository/package_repository.go` - 套餐仓储
|
||||
- `backend/internal/service/package_service.go` - 套餐服务
|
||||
- `backend/internal/api/handlers/package_handler.go` - 管理端套餐处理器
|
||||
- `backend/migrations/013_create_packages_table.sql` - 数据库迁移
|
||||
|
||||
### 修改文件
|
||||
- `backend/internal/api/routes/router.go` - 添加路由
|
||||
- `backend/internal/api/handlers/cu_order_handler.go` - 修改 GetPackages 从数据库查询
|
||||
- `backend/internal/service/cu_order_service.go` - 修改 CalculatePrice 支持数据库套餐
|
||||
@@ -0,0 +1,210 @@
|
||||
# 企业线索收集
|
||||
|
||||
从各个渠道收集用户线索,并进行跟进和转化。
|
||||
|
||||
## 表设计
|
||||
|
||||
```sql
|
||||
-- 企业线索表
|
||||
CREATE TABLE leads (
|
||||
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
|
||||
lead_no VARCHAR(50) NOT NULL COMMENT '线索编号,系统生成',
|
||||
company_name VARCHAR(200) NOT NULL COMMENT '企业/机构名称',
|
||||
contact_name VARCHAR(100) NOT NULL COMMENT '联系人',
|
||||
contact_phone VARCHAR(20) NOT NULL COMMENT '联系电话',
|
||||
contact_email VARCHAR(100) COMMENT '邮箱地址',
|
||||
requirement TEXT NOT NULL COMMENT '需求描述',
|
||||
extra_info TEXT COMMENT '补充信息',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending-待联系, contacted-已联系, converted-已成交, invalid-已失效',
|
||||
follow_up_date DATETIME COMMENT '跟进日期',
|
||||
follow_up_record TEXT COMMENT '跟进记录',
|
||||
internal_note TEXT COMMENT '内部备注',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
|
||||
INDEX idx_leads_status (status),
|
||||
INDEX idx_leads_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企业线索表';
|
||||
```
|
||||
|
||||
## 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | VARCHAR(36) | 是 | 主键,UUID |
|
||||
| lead_no | VARCHAR(50) | 是 | 线索编号,系统生成,格式:LEAD + 时间戳 + 6位随机数 |
|
||||
| company_name | VARCHAR(200) | 是 | 企业/机构名称 |
|
||||
| contact_name | VARCHAR(100) | 是 | 联系人姓名 |
|
||||
| contact_phone | VARCHAR(20) | 是 | 联系电话 |
|
||||
| contact_email | VARCHAR(100) | 否 | 邮箱地址 |
|
||||
| requirement | TEXT | 是 | 需求描述 |
|
||||
| extra_info | TEXT | 否 | 补充信息 |
|
||||
| status | VARCHAR(20) | 是 | 状态:pending-待联系,contacted-已联系,converted-已成交,invalid-已失效 |
|
||||
| follow_up_date | DATETIME | 否 | 跟进日期 |
|
||||
| follow_up_record | TEXT | 否 | 跟进记录 |
|
||||
| internal_note | TEXT | 否 | 内部备注 |
|
||||
| created_at | DATETIME | 是 | 创建时间 |
|
||||
| updated_at | DATETIME | 是 | 更新时间 |
|
||||
|
||||
## 状态流转
|
||||
|
||||
```
|
||||
pending (待联系) → contacted (已联系) → converted (已成交)
|
||||
↓
|
||||
invalid (已失效)
|
||||
```
|
||||
|
||||
## 接口设计
|
||||
|
||||
### 1. 新增线索(公开接口)
|
||||
```
|
||||
POST /api/leads
|
||||
```
|
||||
**无需鉴权**
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"company_name": "北京科技有限公司",
|
||||
"contact_name": "张三",
|
||||
"contact_phone": "13800138000",
|
||||
"contact_email": "zhangsan@example.com",
|
||||
"requirement": "需要采购50套软件授权",
|
||||
"extra_info": "希望了解批量购买优惠"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"lead_no": "LEAD20260201123456ABC123",
|
||||
"company_name": "北京科技有限公司",
|
||||
"contact_name": "张三",
|
||||
"contact_phone": "13800138000",
|
||||
"contact_email": "zhangsan@example.com",
|
||||
"requirement": "需要采购50套软件授权",
|
||||
"status": "pending",
|
||||
"created_at": "2026-02-01T12:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 线索列表(管理端)
|
||||
```
|
||||
GET /api/leads
|
||||
```
|
||||
**需要管理员鉴权**
|
||||
|
||||
**请求参数:**
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| page | int | 否 | 页码,默认1 |
|
||||
| page_size | int | 否 | 每页数量,默认10,最大100 |
|
||||
| search | string | 否 | 关键词检索(企业名称/联系人/联系电话) |
|
||||
| status | string | 否 | 状态筛选:pending/contacted/converted/invalid |
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"data": {
|
||||
"leads": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"lead_no": "LEAD20260201123456ABC123",
|
||||
"company_name": "北京科技有限公司",
|
||||
"contact_name": "张三",
|
||||
"contact_phone": "13800138000",
|
||||
"status": "pending",
|
||||
"created_at": "2026-02-01T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"total_count": 100,
|
||||
"page": 1,
|
||||
"page_size": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 线索详情(管理端)
|
||||
```
|
||||
GET /api/leads/{id}
|
||||
```
|
||||
**需要管理员鉴权**
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"lead_no": "LEAD20260201123456ABC123",
|
||||
"company_name": "北京科技有限公司",
|
||||
"contact_name": "张三",
|
||||
"contact_phone": "13800138000",
|
||||
"contact_email": "zhangsan@example.com",
|
||||
"requirement": "需要采购50套软件授权",
|
||||
"extra_info": "希望了解批量购买优惠",
|
||||
"status": "contacted",
|
||||
"follow_up_date": "2026-02-05T10:00:00Z",
|
||||
"follow_up_record": "已电话沟通,客户表示下周决策",
|
||||
"internal_note": "重点跟进客户",
|
||||
"created_at": "2026-02-01T12:00:00Z",
|
||||
"updated_at": "2026-02-05T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 线索跟进(管理端)
|
||||
```
|
||||
PUT /api/leads/{id}
|
||||
```
|
||||
**需要管理员鉴权**
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"status": "contacted",
|
||||
"follow_up_date": "2026-02-05T10:00:00Z",
|
||||
"follow_up_record": "已电话沟通,客户表示下周决策",
|
||||
"internal_note": "重点跟进客户"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 删除线索(管理端)
|
||||
```
|
||||
DELETE /api/leads/{id}
|
||||
```
|
||||
**需要管理员鉴权**
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功"
|
||||
}
|
||||
```
|
||||
|
||||
## 实现文件
|
||||
|
||||
### 新建文件
|
||||
- `backend/internal/models/lead.go` - 线索模型
|
||||
- `backend/internal/repository/lead_repository.go` - 线索仓储
|
||||
- `backend/internal/service/lead_service.go` - 线索服务
|
||||
- `backend/internal/api/handlers/lead_handler.go` - 线索处理器
|
||||
- `backend/migrations/014_create_leads_table.sql` - 数据库迁移
|
||||
|
||||
### 修改文件
|
||||
- `backend/internal/api/routes/router.go` - 添加路由
|
||||
- `backend/internal/database/migration.go` - 添加自动迁移和初始化
|
||||
@@ -0,0 +1,26 @@
|
||||
# 订单列表查询接口修改
|
||||
|
||||
## 接口说明
|
||||
|
||||
- **接口路径**:`GET /api/cu/orders`
|
||||
- **修改内容**:在订单列表的每条订单数据中新增一个字段,表示该订单是否已经申请过发票。
|
||||
|
||||
## 新增字段
|
||||
|
||||
- **字段名**:`has_invoice_applied`
|
||||
- **类型**:`boolean`
|
||||
- **含义**:
|
||||
- `true`:该订单在发票表中已经有发票申请记录
|
||||
- `false`:该订单还没有发票申请记录
|
||||
|
||||
## 判断规则
|
||||
|
||||
- 相关表:`invoices`
|
||||
- 判断条件:
|
||||
- 在 `invoices` 表中存在 `order_id = cu_orders.id` 且 `deleted_at IS NULL` 的记录,则 `has_invoice_applied = true`
|
||||
- 否则 `has_invoice_applied = false`
|
||||
|
||||
## 前端使用建议
|
||||
|
||||
- 当 `has_invoice_applied = true` 时,前端可以隐藏或禁用“申请开票”按钮。
|
||||
- 当 `has_invoice_applied = false` 时,前端正常展示“申请开票”按钮。
|
||||
@@ -0,0 +1,80 @@
|
||||
# 2026.2.24-用户端发票申请修改
|
||||
|
||||
## 1. 需求背景
|
||||
目前用户申请发票后,如果被管理员驳回(状态为 `rejected`),用户无法修改该申请信息并重新提交。由于每个订单只能关联一个发票记录,用户也无法为该订单重新发起新的申请。因此需要增加一个发票修改接口,允许用户在驳回状态下编辑发票信息并重新进入待处理状态。
|
||||
|
||||
## 2. 业务流程
|
||||
1. **用户侧**:查看发票详情,发现状态为“已驳回”,查看“驳回原因”和“修改建议”。
|
||||
2. **用户侧**:点击修改,编辑发票抬头、税号、邮箱等信息。
|
||||
3. **提交修改**:调用修改接口。
|
||||
- 系统校验发票当前状态必须为 `rejected`。
|
||||
- 系统校验发票所属权。
|
||||
- 系统更新发票信息,并将状态重置为 `pending`。
|
||||
- 系统清空原有的驳回信息(可选,或保留历史但在界面不再作为当前驳回提示)。
|
||||
4. **管理侧**:发票重新出现在待处理列表中。
|
||||
|
||||
## 3. 接口设计
|
||||
|
||||
### 3.1 修改发票申请 (客户端)
|
||||
|
||||
- **接口地址**:`PUT /api/cu/invoices/{id}`
|
||||
- **鉴权**:需要客户端登录态 (`CustomerAuth`)
|
||||
- **请求参数 (JSON Body)**:
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| invoice_type | string | 是 | 发票类型: personal/enterprise/vat_special |
|
||||
| receiver_email| string | 是 | 收票邮箱 |
|
||||
| title | string | 是 | 发票抬头 |
|
||||
| taxpayer_id | string | 否 | 纳税人识别号 (企业类型必填) |
|
||||
| content | string | 否 | 开票内容 |
|
||||
| remark | string | 否 | 备注 |
|
||||
|
||||
> [!NOTE]
|
||||
> `order_id` 不允许修改,因为发票记录与订单是一一绑定的。
|
||||
|
||||
- **逻辑校验**:
|
||||
1. **存在性校验**:发票 ID 必须存在。
|
||||
2. **权限校验**:发票必须属于当前登录的客户用户 (`cu_user_id`)。
|
||||
3. **状态校验**:只有 `status = 'rejected'` 的发票允许修改。若状态为 `pending` 或 `issued`,则返回错误。
|
||||
4. **字段校验**:与创建发票时的校验逻辑一致(如企业类型必须有税号)。
|
||||
|
||||
- **数据更新**:
|
||||
- 更新传入的业务字段。
|
||||
- `status` 更新为 `pending`。
|
||||
- `updated_at` 更新为当前时间。
|
||||
- `reject_reason`、`suggestion`、`rejected_at`、`rejected_by` 建议清空,以表示这是一次全新的提交。
|
||||
|
||||
### 3.2 响应示例
|
||||
|
||||
**成功响应**:
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"id": "invoice-uuid",
|
||||
"invoice_no": "INV20260224001",
|
||||
"status": "pending"
|
||||
},
|
||||
"timestamp": "2026-02-24T18:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应 (状态错误)**:
|
||||
```json
|
||||
{
|
||||
"code": "700004",
|
||||
"message": "发票当前状态不允许该操作",
|
||||
"timestamp": "2026-02-24T18:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 影响范围
|
||||
- **后端模型**:需新增 `InvoiceUpdateRequest` 结构体。
|
||||
- **后端服务**:`CuInvoiceService` 新增 `UpdateInvoice` 方法。
|
||||
- **后端接口**:`router.go` 注册新路由。
|
||||
- **前端**:发票详情页增加修改按钮(仅在驳回时显示),跳转至编辑页。
|
||||
|
||||
## 5. 错误码补充
|
||||
- `700006`: 发票当前状态不允许修改 (如果 700004 语义不够具体可以新增,或沿用 700004)。
|
||||
@@ -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,11 @@
|
||||
## 本地开发环境
|
||||
|
||||
```bash
|
||||
# 使用默认配置
|
||||
cd cmd
|
||||
go run .
|
||||
|
||||
# 指定配置文件
|
||||
cd cmd
|
||||
go run . -config configs/config.dev.yaml
|
||||
```
|
||||
@@ -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,414 @@
|
||||
# 发票模块设计
|
||||
|
||||
## 1. 背景与目标
|
||||
客户购买套餐后(订单已支付),需要在用户端发起开票申请;管理员在管理端处理开票、上传发票 PDF,或驳回并给出修改建议;用户可在列表/详情查看处理进度并下载发票文件。
|
||||
|
||||
## 2. 实体关系
|
||||
- `cu_orders`(订单) : `invoices`(发票) = 1 : 1(一个订单最多一张发票)
|
||||
|
||||
约束:
|
||||
- `invoices.order_id` 唯一,确保 1:1
|
||||
|
||||
## 3. 状态与枚举
|
||||
|
||||
### 3.1 开票状态(invoice_status)
|
||||
- `pending`:待处理
|
||||
- `issued`:已开票
|
||||
- `rejected`:已驳回
|
||||
|
||||
状态流转:
|
||||
- `pending -> issued`
|
||||
- `pending -> rejected`
|
||||
- (可选)`rejected -> issued`(管理员确认无误后直接开票)
|
||||
|
||||
### 3.2 发票类型(invoice_type)
|
||||
- `personal`:个人
|
||||
- `enterprise`:企业普票
|
||||
- `vat_special`:增值税专用发票
|
||||
|
||||
字段约束建议:
|
||||
- `invoice_type=personal`:`taxpayer_id` 可为空
|
||||
- `invoice_type=enterprise/vat_special`:`taxpayer_id` 必填
|
||||
|
||||
## 4. 编号与金额口径
|
||||
|
||||
### 4.1 发票申请号(invoice_no)
|
||||
建议生成规则与订单号风格一致:
|
||||
- `INV{YYYYMMDD}{9位序列}`(序列使用纳秒时间戳后 9 位,确保全局唯一)
|
||||
|
||||
### 4.2 发票金额(amount)
|
||||
强制以订单 `cu_orders.total_amount` 为准(下单时已确定),避免用户端传入金额造成口径不一致。
|
||||
|
||||
## 5. 表结构设计(新增 invoices 表)
|
||||
|
||||
> 说明:当前项目大量使用软删除(`deleted_at`),发票也建议沿用该模式。
|
||||
|
||||
### 5.1 invoices 表(建议)
|
||||
字段设计(按业务分组):
|
||||
- 主键:`id`
|
||||
- 关联:`order_id`(唯一)、`order_no`(冗余快照,便于搜索)、`customer_id`、`cu_user_id`
|
||||
- 金额:`amount`
|
||||
- 业务:`status`、`invoice_type`、`title`、`taxpayer_id`、`content`、`receiver_email`、`remark`
|
||||
- 开票处理:`issued_at`、`invoice_file_url`、`uploaded_at`、`uploaded_by`
|
||||
- 驳回处理:`rejected_at`、`rejected_by`、`reject_reason`、`suggestion`
|
||||
- 下载(可选):`download_token`(用于邮件链接下载的随机 token)
|
||||
- 时间:`created_at`、`updated_at`、`deleted_at`
|
||||
|
||||
推荐 SQL(MySQL):
|
||||
```sql
|
||||
CREATE TABLE `invoices` (
|
||||
`id` varchar(36) NOT NULL,
|
||||
`invoice_no` varchar(50) NOT NULL COMMENT '发票申请号',
|
||||
`order_id` varchar(36) NOT NULL COMMENT '订单ID(唯一)',
|
||||
`order_no` varchar(50) NOT NULL COMMENT '订单号(冗余快照,便于检索)',
|
||||
`customer_id` varchar(36) NOT NULL COMMENT '客户ID',
|
||||
`cu_user_id` varchar(36) NOT NULL COMMENT '申请人(客户用户)ID',
|
||||
|
||||
`amount` decimal(10,2) NOT NULL COMMENT '发票金额(取订单total_amount)',
|
||||
|
||||
`status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/issued/rejected',
|
||||
`invoice_type` varchar(20) NOT NULL COMMENT 'personal/enterprise/vat_special',
|
||||
`title` varchar(200) NOT NULL COMMENT '发票抬头',
|
||||
`taxpayer_id` varchar(50) DEFAULT NULL COMMENT '纳税人识别号',
|
||||
`content` varchar(200) NOT NULL COMMENT '开票内容',
|
||||
`receiver_email` varchar(255) NOT NULL COMMENT '收票邮箱',
|
||||
`remark` varchar(1000) DEFAULT NULL COMMENT '备注',
|
||||
|
||||
`invoice_file_url` varchar(500) DEFAULT NULL COMMENT '发票文件URL(PDF)',
|
||||
`uploaded_at` datetime(3) DEFAULT NULL COMMENT '上传时间',
|
||||
`uploaded_by` varchar(36) DEFAULT NULL COMMENT '上传人(管理员)ID',
|
||||
`issued_at` datetime(3) DEFAULT NULL COMMENT '开票完成时间',
|
||||
|
||||
`reject_reason` varchar(500) DEFAULT NULL COMMENT '驳回原因',
|
||||
`suggestion` varchar(500) DEFAULT NULL COMMENT '建议修改内容',
|
||||
`rejected_at` datetime(3) DEFAULT NULL COMMENT '驳回时间',
|
||||
`rejected_by` varchar(36) DEFAULT NULL COMMENT '驳回人(管理员)ID',
|
||||
|
||||
`download_token` varchar(64) DEFAULT NULL COMMENT '下载token(可选)',
|
||||
|
||||
`created_at` datetime(3) NOT NULL,
|
||||
`updated_at` datetime(3) NOT NULL,
|
||||
`deleted_at` datetime(3) DEFAULT NULL,
|
||||
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_invoices_invoice_no` (`invoice_no`),
|
||||
UNIQUE KEY `idx_invoices_order_id` (`order_id`),
|
||||
KEY `idx_invoices_customer_id` (`customer_id`),
|
||||
KEY `idx_invoices_cu_user_id` (`cu_user_id`),
|
||||
KEY `idx_invoices_status` (`status`),
|
||||
KEY `idx_invoices_created_at` (`created_at`),
|
||||
KEY `idx_invoices_deleted_at` (`deleted_at`),
|
||||
KEY `idx_invoices_order_no` (`order_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
```
|
||||
|
||||
## 6. 接口设计
|
||||
|
||||
### 6.1 通用约定(与项目一致)
|
||||
- 鉴权:客户端 `CustomerAuth()`;管理端 `AuthMiddleware()`(是否叠加 `AdminOnlyMiddleware()` 视权限策略)
|
||||
- 返回:成功 `models.APIResponse`,失败 `models.ErrorResponse`
|
||||
- 多语言:`Accept-Language` 或 `?lang=...`
|
||||
|
||||
### 6.2 客户端(/api/cu)
|
||||
|
||||
#### 6.2.1 客户申请发票
|
||||
```http
|
||||
POST /api/cu/invoices
|
||||
```
|
||||
请求 Body:
|
||||
```json
|
||||
{
|
||||
"order_id": "order-uuid",
|
||||
"invoice_type": "enterprise",
|
||||
"receiver_email": "finance@example.com",
|
||||
"title": "某某公司",
|
||||
"taxpayer_id": "9133xxxxxxxxxxxxxx",
|
||||
"content": "软件服务费",
|
||||
"remark": "请开电子发票"
|
||||
}
|
||||
```
|
||||
规则(建议):
|
||||
- `order_id` 必填,且订单必须属于当前用户(`cu_orders.cu_user_id`)
|
||||
- 仅允许已支付订单开票:`cu_orders.status=paid`
|
||||
- 一个订单仅允许一张发票:若已存在发票则返回冲突错误
|
||||
- `invoice_type` 影响 `taxpayer_id` 是否必填(见 3.2)
|
||||
|
||||
成功响应:
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "成功",
|
||||
"data": { "id": "invoice-uuid", "invoice_no": "INV202601210123456789", "status": "pending" },
|
||||
"timestamp": "2026-01-21T10:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### 6.2.2 客户发票列表
|
||||
```http
|
||||
GET /api/cu/invoices
|
||||
```
|
||||
Query:
|
||||
- `page`(默认 1)
|
||||
- `page_size`(默认 10,最大 100)
|
||||
- `status`(可选):`pending/issued/rejected`
|
||||
- `search`(可选):`invoice_no` 或 `order_no` 模糊匹配
|
||||
- `apply_date`(可选):申请日期(`YYYY-MM-DD`,按 `created_at` 过滤)
|
||||
|
||||
#### 6.2.3 客户发票详情
|
||||
```http
|
||||
GET /api/cu/invoices/{id}
|
||||
```
|
||||
返回:发票表全部字段 + 关联订单号/套餐类型(可 join `cu_orders` 返回 `order_no/package_name`)。
|
||||
|
||||
#### 6.2.4 客户发票汇总信息
|
||||
```http
|
||||
GET /api/cu/invoices/summary
|
||||
```
|
||||
返回:
|
||||
- `total_count`
|
||||
- `pending_count`
|
||||
- `issued_count`
|
||||
- `rejected_count`
|
||||
|
||||
#### 6.2.5 发票下载(推荐:鉴权下载)
|
||||
```http
|
||||
GET /api/cu/invoices/{id}/download
|
||||
```
|
||||
仅允许下载自己的发票;返回 PDF 文件流。
|
||||
|
||||
### 6.3 管理端(/api/v1)
|
||||
|
||||
#### 6.3.1 管理员发票列表
|
||||
```http
|
||||
GET /api/v1/invoices
|
||||
```
|
||||
Query 与客户端一致,额外可支持 `customer_id` 筛选。
|
||||
|
||||
#### 6.3.2 管理员发票详情
|
||||
```http
|
||||
GET /api/v1/invoices/{id}
|
||||
```
|
||||
返回:
|
||||
- 发票表所有字段
|
||||
- 关联订单号、套餐类型(`cu_orders.package_name`)
|
||||
- 申请人姓名/电话(`cu_users.real_name/phone`)
|
||||
- 开票人 `full_name`(通过 `users.full_name`,关联 `uploaded_by`)
|
||||
|
||||
#### 6.3.3 管理员发票汇总信息
|
||||
```http
|
||||
GET /api/v1/invoices/summary
|
||||
```
|
||||
|
||||
#### 6.3.4 管理员发票驳回
|
||||
```http
|
||||
POST /api/v1/invoices/{id}/reject
|
||||
```
|
||||
Body:
|
||||
```json
|
||||
{ "reject_reason": "抬头信息不完整", "suggestion": "请补充纳税人识别号" }
|
||||
```
|
||||
规则:仅允许对 `pending` 发票操作,写入驳回字段并更新 `status=rejected`。
|
||||
|
||||
#### 6.3.5 管理员发票开票(支持覆盖重开)
|
||||
```http
|
||||
POST /api/v1/invoices/{id}/issue
|
||||
```
|
||||
Body:
|
||||
```json
|
||||
{ "invoice_file_url": "/files/invoices/INV202601210123456789_20260121103000.pdf", "issued_at": "2026-01-21T10:30:00Z" }
|
||||
```
|
||||
规则:
|
||||
- 允许从 `pending/rejected` 置为 `issued`
|
||||
- 允许在 `issued` 状态下覆盖更新 `invoice_file_url/issued_at`(“重开覆盖”)
|
||||
|
||||
#### 6.3.6 管理端发票文件上传
|
||||
```http
|
||||
POST /api/v1/invoices/upload
|
||||
```
|
||||
说明:
|
||||
- 存储目录:程序运行目录相对路径 `../files/invoices/`
|
||||
- 文件名:`{invoice_no}_{yyyyMMddHHmmss}.pdf`
|
||||
- 返回相对 URL:`/files/invoices/...pdf`
|
||||
|
||||
FormData:
|
||||
- `invoice_no`
|
||||
- `file`(pdf)
|
||||
|
||||
### 6.4 公共下载接口(可选)
|
||||
若需要邮件直达下载(不登录),建议采用 token 方式(不要直接暴露任意 `file_url`):
|
||||
```http
|
||||
GET /api/public/invoices/download?token=xxxxx
|
||||
```
|
||||
规则:
|
||||
- `token` 对应 `invoices.download_token`(随机生成)
|
||||
- 仅当 `status=issued` 且存在 `invoice_file_url` 才允许下载
|
||||
|
||||
## 7. 错误码规划(建议)
|
||||
建议新增发票模块错误码段:`70xxxx`
|
||||
- `700001` 发票不存在
|
||||
- `700002` 订单不允许开票(非 `paid`)
|
||||
- `700003` 该订单已存在发票
|
||||
- `700004` 发票状态不允许操作(如非 `pending` 不能驳回)
|
||||
- `700005` 发票文件缺失/不可下载
|
||||
|
||||
## 8. 相关表结构(现有)
|
||||
|
||||
### 附录:现有表结构(参考)
|
||||
|
||||
-- license_manager.users definition
|
||||
|
||||
CREATE TABLE `users` (
|
||||
`id` varchar(36) NOT NULL,
|
||||
`username` varchar(50) NOT NULL,
|
||||
`email` varchar(255) NOT NULL,
|
||||
`password_hash` varchar(255) NOT NULL,
|
||||
`full_name` varchar(100) NOT NULL,
|
||||
`phone` varchar(20) DEFAULT NULL,
|
||||
`role` varchar(20) NOT NULL DEFAULT 'viewer',
|
||||
`status` varchar(20) NOT NULL DEFAULT 'active',
|
||||
`last_login_at` datetime(3) DEFAULT NULL,
|
||||
`last_login_ip` varchar(45) DEFAULT NULL,
|
||||
`login_attempts` bigint NOT NULL DEFAULT '0',
|
||||
`locked_until` datetime(3) DEFAULT NULL,
|
||||
`created_at` datetime(3) NOT NULL,
|
||||
`updated_at` datetime(3) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_users_username` (`username`),
|
||||
UNIQUE KEY `idx_users_email` (`email`),
|
||||
KEY `idx_users_role` (`role`),
|
||||
KEY `idx_users_status` (`status`),
|
||||
KEY `idx_users_last_login_at` (`last_login_at`),
|
||||
KEY `idx_users_locked_until` (`locked_until`),
|
||||
KEY `idx_users_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
-- license_manager.cu_users definition
|
||||
|
||||
CREATE TABLE `cu_users` (
|
||||
`id` varchar(36) NOT NULL,
|
||||
`customer_id` varchar(36) NOT NULL,
|
||||
`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,
|
||||
`login_attempts` bigint NOT NULL DEFAULT '0',
|
||||
`locked_until` datetime(3) DEFAULT NULL,
|
||||
`avatar_url` varchar(500) DEFAULT NULL,
|
||||
`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;
|
||||
|
||||
-- license_manager.cu_orders definition
|
||||
|
||||
CREATE TABLE `cu_orders` (
|
||||
`id` varchar(36) NOT NULL,
|
||||
`order_no` varchar(50) NOT NULL,
|
||||
`customer_id` varchar(36) NOT NULL,
|
||||
`cu_user_id` varchar(36) NOT NULL,
|
||||
`package_id` varchar(50) NOT NULL,
|
||||
`package_name` varchar(100) NOT NULL,
|
||||
`license_count` bigint NOT NULL,
|
||||
`unit_price` decimal(10,2) NOT NULL,
|
||||
`discount_rate` decimal(3,2) NOT NULL DEFAULT '1.00',
|
||||
`total_amount` decimal(10,2) NOT NULL,
|
||||
`status` varchar(20) NOT NULL DEFAULT 'paid',
|
||||
`authorization_code` varchar(500) DEFAULT NULL,
|
||||
`expired_at` datetime(3) 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_cu_orders_order_no` (`order_no`),
|
||||
KEY `idx_cu_orders_customer_id` (`customer_id`),
|
||||
KEY `idx_cu_orders_cu_user_id` (`cu_user_id`),
|
||||
KEY `idx_cu_orders_package_id` (`package_id`),
|
||||
KEY `idx_cu_orders_status` (`status`),
|
||||
KEY `idx_cu_orders_expired_at` (`expired_at`),
|
||||
KEY `idx_cu_orders_created_at` (`created_at`),
|
||||
KEY `idx_cu_orders_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
-- 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;
|
||||
|
||||
|
||||
-- license_manager.authorization_codes definition
|
||||
|
||||
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;
|
||||
@@ -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,82 @@
|
||||
# 开发提示词
|
||||
|
||||
> 用于指导 AI 助手在项目中的开发行为和决策方式
|
||||
|
||||
---
|
||||
|
||||
## 一、核心定位
|
||||
|
||||
作为一名经验丰富的开发工程师和架构师,你需要:
|
||||
|
||||
1. **主动审查** - 发现架构设计不合理或存在潜在问题时,必须及时与请求者讨论,说明具体问题和建议
|
||||
2. **充分调研** - 不要凭空设计,而要基于现有实践和项目规范
|
||||
3. **主动沟通** - 遇到无法决策的问题时,主动询问请求者而不是随便决策
|
||||
|
||||
---
|
||||
|
||||
## 二、开发原则
|
||||
|
||||
### 2.1 代码规范
|
||||
|
||||
| 规范项 | 要求 |
|
||||
|:------|:----|
|
||||
| 日志库 | 使用 logrus,不要使用其他日志库 |
|
||||
| 注释 | 接口的请求和响应结构体需要加上**中文注释** |
|
||||
| 接口参数 | 可选参数(非必输)必须使用指针类型(如 `*string`),以便正确区分“未传”和“明确传空”的区别 |
|
||||
| 参数校验 | 返回无效请求错误(如 `900001`)时,必须在提示信息后追加具体的原因,例如:`Message: msg + ": " + err.Error()` |
|
||||
| 设计原则 | 避免过度设计,保持代码的简洁和可读性 |
|
||||
| 错误处理 | 不可为了处理错误而处理错误,让错误在源头尖叫,而不是让它在下游腐烂。|
|
||||
|
||||
### 2.2 接口设计
|
||||
|
||||
1. **参考学习** - 设计新接口前,先查看项目中至少 3 个已实现的类似接口
|
||||
2. **保持一致** - 遵循项目现有的接口设计模式和命名规范
|
||||
3. **文档完善** - 所有请求和响应结构体字段必须添加中文注释,说明字段用途、数据类型、是否必填、取值范围等
|
||||
|
||||
### 2.3 开发实施
|
||||
|
||||
- 如果当前项目已在开发中,按照项目现有代码规范和最佳实践开发,不要独辟蹊径
|
||||
- 如果当前项目尚未开发,按照项目规范和最佳实践开发,不要独辟蹊径
|
||||
- 代码风格、目录结构、命名方式应与项目保持一致
|
||||
- 实现新功能前,先研究项目中类似功能的实现方式
|
||||
|
||||
---
|
||||
|
||||
## 三、沟通协作
|
||||
|
||||
### 必须主动询问的情况
|
||||
|
||||
| 情况 | 说明 |
|
||||
|:----|:----|
|
||||
| 需求不明确 | 需求存在歧义或不够清晰 |
|
||||
| 技术方案选择 | 有多种技术方案可选择 |
|
||||
| 重要决策 | 涉及架构或技术的重要决策 |
|
||||
| 规范冲突 | 与现有规范冲突需要权衡 |
|
||||
|
||||
### 禁止的行为
|
||||
|
||||
- 不要在不确定的情况下自行决策
|
||||
- 不要臆断需求,避免返工
|
||||
|
||||
---
|
||||
|
||||
## 四、行为准则
|
||||
|
||||
### 应该做的
|
||||
|
||||
- ✅ 主动反馈架构设计中的问题
|
||||
- ✅ 设计接口前充分调研现有实现
|
||||
- ✅ 保持代码风格与项目一致
|
||||
- ✅ 为所有接口字段添加中文注释
|
||||
- ✅ 遇到不确定的问题时主动询问
|
||||
- ✅ 让错误在源头尖叫,而不是让它在下游腐烂
|
||||
- ✅ 可选参数使用指针类型
|
||||
- ✅ 参数校验报错追加具体原因 (`err.Error()`)
|
||||
|
||||
### 不应该做的
|
||||
|
||||
- ❌ 不要凭空设计,要基于现有实践
|
||||
- ❌ 不要独辟蹊径,要遵循项目规范
|
||||
- ❌ 不要在不确定的情况下自行决策
|
||||
- ❌ 不要使用 logrus 以外的日志库
|
||||
- ❌ 不要为了处理错误而处理错误
|
||||
+5702
-4
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
+18
-2
@@ -3,10 +3,17 @@ module license-manager
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13
|
||||
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0
|
||||
github.com/alibabacloud-go/tea v1.4.0
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.9
|
||||
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/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
@@ -22,10 +29,16 @@ 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/debug v1.0.1 // 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/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
|
||||
@@ -57,13 +70,16 @@ require (
|
||||
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 +89,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
|
||||
|
||||
+210
-1
@@ -1,24 +1,90 @@
|
||||
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 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
|
||||
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 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
|
||||
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
|
||||
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
|
||||
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
|
||||
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
|
||||
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 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
|
||||
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
|
||||
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
|
||||
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 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
|
||||
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
|
||||
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 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
|
||||
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 +123,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 +154,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 +186,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 +199,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 +212,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 +238,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 +260,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 +271,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 +284,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 +366,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 +451,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
|
||||
|
||||
@@ -102,6 +102,7 @@ func (h *AuthorizationCodeHandler) CreateAuthorizationCode(c *gin.Context) {
|
||||
// @Param page query int false "页码,默认1" minimum(1)
|
||||
// @Param page_size query int false "每页条数,默认20,最大100" minimum(1) maximum(100)
|
||||
// @Param customer_id query string false "客户ID筛选"
|
||||
// @Param code query string false "授权码(模糊匹配)"
|
||||
// @Param status query string false "状态筛选" Enums(normal, locked, expired)
|
||||
// @Param start_date query string false "创建开始时间"
|
||||
// @Param end_date query string false "创建结束时间"
|
||||
|
||||
@@ -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,278 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCuAuthorizationCodes 获取用户授权码列表
|
||||
// @Summary 获取用户授权码列表
|
||||
// @Description 获取当前登录用户的授权码列表,支持分页、状态筛选与授权码模糊搜索
|
||||
// @Tags 用户端授权码管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页数量" default(10)
|
||||
// @Param status query string false "状态筛选" Enums(normal, locked, expired)
|
||||
// @Param search query string false "授权码模糊匹配"
|
||||
// @Success 200 {object} models.APIResponse{data=models.CuAuthorizationCodeListResponse} "成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/cu/authorization-codes [get]
|
||||
func (h *CuAuthorizationHandler) GetCuAuthorizationCodes(c *gin.Context) {
|
||||
claims := c.MustGet("cu_user").(*utils.CuClaims)
|
||||
|
||||
var req models.CuAuthorizationCodeListRequest
|
||||
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
|
||||
}
|
||||
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
data, err := h.authCodeService.GetCuAuthorizationCodeList(ctx, claims.CustomerID, &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: data,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetCuAuthorizationCodeSummary 获取用户授权信息统计
|
||||
// @Summary 获取用户授权信息统计
|
||||
// @Description 获取当前登录用户的授权码统计信息
|
||||
// @Tags 用户端授权码管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} models.APIResponse{data=models.CuAuthorizationCodeSummaryResponse} "成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/cu/authorization-codes/summary [get]
|
||||
func (h *CuAuthorizationHandler) GetCuAuthorizationCodeSummary(c *gin.Context) {
|
||||
claims := c.MustGet("cu_user").(*utils.CuClaims)
|
||||
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
data, err := h.authCodeService.GetCuAuthorizationCodeSummary(ctx, claims.CustomerID)
|
||||
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: data,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
@@ -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,253 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"license-manager/internal/api/middleware"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/service"
|
||||
"license-manager/pkg/i18n"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type CuInvoiceHandler struct {
|
||||
invoiceService service.CuInvoiceService
|
||||
}
|
||||
|
||||
func NewCuInvoiceHandler(invoiceService service.CuInvoiceService) *CuInvoiceHandler {
|
||||
return &CuInvoiceHandler{
|
||||
invoiceService: invoiceService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateInvoice 客户申请发票
|
||||
// @Summary 客户申请发票
|
||||
// @Description 客户为已支付的订单申请发票
|
||||
// @Tags 发票管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body models.InvoiceCreateRequest true "发票申请请求"
|
||||
// @Success 200 {object} models.APIResponse{data=object{id=string,invoice_no=string,status=string}} "成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/cu/invoices [post]
|
||||
func (h *CuInvoiceHandler) CreateInvoice(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
|
||||
var req models.InvoiceCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
invoice, err := h.invoiceService.CreateInvoice(c.Request.Context(), getCuUserID(c), &req)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
response := invoice.ToResponse()
|
||||
// 只返回关键信息
|
||||
result := map[string]interface{}{
|
||||
"id": response.ID,
|
||||
"invoice_no": response.InvoiceNo,
|
||||
"status": response.Status,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: result,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserInvoices 客户发票列表
|
||||
// @Summary 客户发票列表
|
||||
// @Description 获取当前客户的发票列表
|
||||
// @Tags 发票管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param page query int false "页码,默认1" minimum(1)
|
||||
// @Param page_size query int false "每页条数,默认10,最大100" minimum(1) maximum(100)
|
||||
// @Param status query string false "状态筛选,pending-待处理/issued-已开票/rejected-已驳回"
|
||||
// @Param search query string false "搜索关键词,支持发票号或订单号模糊匹配"
|
||||
// @Param apply_date query string false "申请日期筛选,格式YYYY-MM-DD 或 RFC3339范围(start,end)"
|
||||
// @Success 200 {object} models.APIResponse{data=models.InvoiceListResponse} "成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/cu/invoices [get]
|
||||
func (h *CuInvoiceHandler) GetUserInvoices(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
|
||||
var req models.InvoiceListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
invoices, err := h.invoiceService.GetUserInvoices(c.Request.Context(), getCuUserID(c), &req)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: invoices,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserInvoiceDetail 客户发票详情
|
||||
// @Summary 客户发票详情
|
||||
// @Description 获取指定发票的详细信息
|
||||
// @Tags 发票管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "发票ID"
|
||||
// @Success 200 {object} models.APIResponse{data=models.InvoiceResponse} "成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 404 {object} models.ErrorResponse "发票不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/cu/invoices/{id} [get]
|
||||
func (h *CuInvoiceHandler) GetUserInvoiceDetail(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
invoiceID := c.Param("id")
|
||||
|
||||
invoice, err := h.invoiceService.GetInvoice(c.Request.Context(), invoiceID, getCuUserID(c))
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: invoice.ToResponse(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserInvoiceSummary 客户发票汇总信息
|
||||
// @Summary 客户发票汇总信息
|
||||
// @Description 获取当前客户的发票统计汇总信息
|
||||
// @Tags 发票管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} models.APIResponse{data=models.InvoiceSummaryResponse} "成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/cu/invoices/summary [get]
|
||||
func (h *CuInvoiceHandler) GetUserInvoiceSummary(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
|
||||
summary, err := h.invoiceService.GetInvoiceSummary(c.Request.Context(), getCuUserID(c))
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: summary,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadInvoice 客户发票下载
|
||||
// @Summary 客户发票下载
|
||||
// @Description 下载指定发票的文件
|
||||
// @Tags 发票管理
|
||||
// @Accept json
|
||||
// @Produce application/pdf
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "发票ID"
|
||||
// @Success 200 {file} binary "发票文件"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 404 {object} models.ErrorResponse "发票不存在或文件不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/cu/invoices/{id}/download [get]
|
||||
func (h *CuInvoiceHandler) DownloadInvoice(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
invoiceID := c.Param("id")
|
||||
|
||||
fileURL, err := h.invoiceService.DownloadInvoice(c.Request.Context(), invoiceID, getCuUserID(c))
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
// 重定向到文件URL
|
||||
c.Redirect(http.StatusFound, fileURL)
|
||||
}
|
||||
|
||||
// UpdateInvoice 客户修改发票申请
|
||||
// @Summary 客户修改发票申请
|
||||
// @Description 客户修改已被驳回的发票申请并重新提交
|
||||
// @Tags 发票管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "发票ID"
|
||||
// @Param request body models.InvoiceUpdateRequest true "发票修改请求"
|
||||
// @Success 200 {object} models.APIResponse{data=object{id=string,invoice_no=string,status=string}} "成功"
|
||||
// @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/invoices/{id} [put]
|
||||
func (h *CuInvoiceHandler) UpdateInvoice(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
invoiceID := c.Param("id")
|
||||
|
||||
var req models.InvoiceUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
invoice, err := h.invoiceService.UpdateInvoice(c.Request.Context(), invoiceID, getCuUserID(c), &req)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
response := invoice.ToResponse()
|
||||
result := map[string]interface{}{
|
||||
"id": response.ID,
|
||||
"invoice_no": response.InvoiceNo,
|
||||
"status": response.Status,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: result,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
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 CuOrderHandler struct {
|
||||
cuOrderService service.CuOrderService
|
||||
paymentService service.PaymentService
|
||||
packageService service.PackageService
|
||||
}
|
||||
|
||||
func NewCuOrderHandler(cuOrderService service.CuOrderService, paymentService service.PaymentService, packageService service.PackageService) *CuOrderHandler {
|
||||
return &CuOrderHandler{
|
||||
cuOrderService: cuOrderService,
|
||||
paymentService: paymentService,
|
||||
packageService: packageService,
|
||||
}
|
||||
}
|
||||
|
||||
// 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, err := h.packageService.GetCuPackageList(c.Request.Context())
|
||||
if err != nil {
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("800005", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为map数组格式(兼容原有结构)
|
||||
result := make([]map[string]interface{}, len(packages))
|
||||
for i, pkg := range packages {
|
||||
result[i] = map[string]interface{}{
|
||||
"id": pkg.ID,
|
||||
"name": pkg.Name,
|
||||
"type": pkg.Type,
|
||||
"price": pkg.Price,
|
||||
"display_price": pkg.DisplayPrice,
|
||||
"price_description": pkg.PriceDescription,
|
||||
"max_devices": pkg.MaxDevices,
|
||||
"description": pkg.Description,
|
||||
"features": pkg.Features,
|
||||
"details": pkg.Details,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: result,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 先计算价格,根据金额决定处理逻辑
|
||||
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
|
||||
}
|
||||
|
||||
// 如果金额为0(免费/试用版),直接生成授权码,无论是否传了payment_method
|
||||
if priceResult.TotalAmount == 0 {
|
||||
h.createFreeOrder(c, cuUserID.(string), customerID.(string), &req)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果金额大于0,根据是否传了payment_method决定处理逻辑
|
||||
if req.PaymentMethod == "" {
|
||||
// 金额大于0但没有传支付方式,走免费订单流程(直接生成授权码)
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
// CancelOrder 删除订单
|
||||
// @Summary 删除订单
|
||||
// @Description 删除当前用户的订单记录
|
||||
// @Tags 客户订单管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param order_id path string true "订单ID"
|
||||
// @Success 200 {object} models.APIResponse{} "成功"
|
||||
// @Failure 400
|
||||
// @Failure 401
|
||||
// @Failure 403
|
||||
// @Failure 404
|
||||
// @Failure 409
|
||||
// @Router /api/cu/orders/{order_id}/cancel [put]
|
||||
func (h *CuOrderHandler) CancelOrder(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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
err := h.cuOrderService.DeleteOrder(ctx, 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: nil,
|
||||
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)
|
||||
// @Param search query string false "订单号或授权码模糊匹配"
|
||||
// @Param status query string false "订单状态筛选" Enums(pending, paid, cancelled)
|
||||
// @Param time query string false "时间筛选" Enums(today, week, month, three_months)
|
||||
// @Success 200 {object} models.APIResponse{data=models.CuOrderListResponse} "成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Router /api/cu/orders [get]
|
||||
func (h *CuOrderHandler) GetUserOrders(c *gin.Context) {
|
||||
var req models.CuOrderListRequest
|
||||
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
|
||||
}
|
||||
|
||||
// 获取用户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
|
||||
}
|
||||
|
||||
// 获取订单列表
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
orders, total, err := h.cuOrderService.GetUserOrders(ctx, cuUserID.(string), &req)
|
||||
if err != nil {
|
||||
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
|
||||
Code: err.(*i18n.I18nError).Code,
|
||||
Message: err.(*i18n.I18nError).Message, // 直接使用错误对象中的消息
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
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: req.Page,
|
||||
PageSize: req.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,
|
||||
})
|
||||
}
|
||||
|
||||
// ContinuePay 继续支付
|
||||
// @Summary 继续支付
|
||||
// @Description 对指定订单继续支付,如果支付单已过期则创建新的支付单
|
||||
// @Tags 客户订单管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param order_id path string true "订单ID"
|
||||
// @Param payment_method query string false "支付方式" Enums(alipay, wechat)
|
||||
// @Success 200 {object} models.APIResponse{data=object{order_id=string,order_no=string,payment_no=string,payment_url=string,total_amount=number,expire_time=string}} "成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 404 {object} models.ErrorResponse "订单不存在"
|
||||
// @Failure 400 {object} models.ErrorResponse "订单状态不允许继续支付"
|
||||
// @Router /api/cu/orders/{order_id}/pay [post]
|
||||
func (h *CuOrderHandler) ContinuePay(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和客户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
|
||||
}
|
||||
|
||||
// 获取支付方式(可选,默认使用配置的默认支付方式)
|
||||
paymentMethod := c.DefaultQuery("payment_method", "")
|
||||
|
||||
// 调用服务层
|
||||
ctx := middleware.WithLanguage(c.Request.Context(), c)
|
||||
result, err := h.cuOrderService.ContinuePay(ctx, orderID, cuUserID.(string), customerID.(string), paymentMethod)
|
||||
if err != nil {
|
||||
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
|
||||
Code: err.(*i18n.I18nError).Code,
|
||||
Message: err.(*i18n.I18nError).Message,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果支付单号为空,说明需要创建新的支付单
|
||||
if result.PaymentNo == "" {
|
||||
// 获取订单信息以获取金额
|
||||
order, err := h.cuOrderService.GetOrder(ctx, 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
|
||||
}
|
||||
|
||||
// 创建新的支付单
|
||||
paymentReq := &models.PaymentCreateRequest{
|
||||
BusinessType: models.BusinessTypePackageOrder,
|
||||
BusinessID: &order.ID,
|
||||
CustomerID: customerID.(string),
|
||||
CuUserID: cuUserID.(string),
|
||||
Amount: order.TotalAmount,
|
||||
Currency: "CNY",
|
||||
PaymentMethod: paymentMethod,
|
||||
}
|
||||
|
||||
payment, err := h.paymentService.CreatePayment(ctx, paymentReq)
|
||||
if err != nil {
|
||||
lang := middleware.GetLanguage(c)
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("602001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新返回结果
|
||||
result.PaymentNo = payment.PaymentNo
|
||||
result.PaymentURL = payment.PaymentURL
|
||||
result.ExpireTime = payment.ExpireTime
|
||||
}
|
||||
|
||||
lang := middleware.GetLanguage(c)
|
||||
successMsg := i18n.GetI18nErrorMessage("000000", lang)
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: successMsg,
|
||||
Data: gin.H{
|
||||
"order_id": result.OrderID,
|
||||
"order_no": result.OrderNo,
|
||||
"payment_no": result.PaymentNo,
|
||||
"payment_url": result.PaymentURL,
|
||||
"total_amount": result.TotalAmount,
|
||||
"expire_time": result.ExpireTime.Format("2006-01-02T15:04:05Z"),
|
||||
},
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
@@ -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,378 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"license-manager/internal/api/middleware"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/service"
|
||||
"license-manager/pkg/i18n"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AdminInvoiceHandler struct {
|
||||
invoiceService service.AdminInvoiceService
|
||||
}
|
||||
|
||||
func NewAdminInvoiceHandler(invoiceService service.AdminInvoiceService) *AdminInvoiceHandler {
|
||||
return &AdminInvoiceHandler{
|
||||
invoiceService: invoiceService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAdminInvoices 管理员发票列表
|
||||
// @Summary 管理员发票列表
|
||||
// @Description 管理员查看所有发票列表,支持按客户筛选
|
||||
// @Tags 发票管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param page query int false "页码,默认1" minimum(1)
|
||||
// @Param page_size query int false "每页条数,默认10,最大100" minimum(1) maximum(100)
|
||||
// @Param status query string false "状态筛选,pending-待处理/issued-已开票/rejected-已驳回"
|
||||
// @Param search query string false "搜索关键词,支持发票号或订单号模糊匹配"
|
||||
// @Param apply_date query string false "申请日期筛选,格式YYYY-MM-DD 或 RFC3339范围(start,end)"
|
||||
// @Param customer_id query string false "客户ID筛选"
|
||||
// @Success 200 {object} models.APIResponse{data=models.InvoiceListResponse} "成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/invoices [get]
|
||||
func (h *AdminInvoiceHandler) GetAdminInvoices(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
|
||||
var req models.InvoiceListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: i18n.GetI18nErrorMessage("900001", lang),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
invoices, err := h.invoiceService.GetAdminInvoices(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: invoices,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetAdminInvoiceDetail 管理员发票详情
|
||||
// @Summary 管理员发票详情
|
||||
// @Description 管理员查看发票详细信息,包含关联信息
|
||||
// @Tags 发票管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "发票ID"
|
||||
// @Success 200 {object} models.APIResponse{data=models.InvoiceDetailResponse} "成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 404 {object} models.ErrorResponse "发票不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/invoices/{id} [get]
|
||||
func (h *AdminInvoiceHandler) GetAdminInvoiceDetail(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
invoiceID := c.Param("id")
|
||||
adminID := getUserID(c)
|
||||
|
||||
detail, err := h.invoiceService.GetInvoiceDetail(c.Request.Context(), invoiceID, true, adminID)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: detail,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetAdminInvoiceSummary 管理员发票汇总信息
|
||||
// @Summary 管理员发票汇总信息
|
||||
// @Description 管理员查看全平台的发票统计汇总信息
|
||||
// @Tags 发票管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} models.APIResponse{data=models.InvoiceSummaryResponse} "成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/invoices/summary [get]
|
||||
func (h *AdminInvoiceHandler) GetAdminInvoiceSummary(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
|
||||
summary, err := h.invoiceService.GetAdminInvoiceSummary(c.Request.Context())
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: summary,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// RejectInvoice 管理员发票驳回
|
||||
// @Summary 管理员发票驳回
|
||||
// @Description 管理员驳回发票申请,需要填写驳回原因和修改建议
|
||||
// @Tags 发票管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "发票ID"
|
||||
// @Param request body models.InvoiceRejectRequest true "驳回请求"
|
||||
// @Success 200 {object} models.APIResponse{data=models.InvoiceResponse} "成功"
|
||||
// @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/v1/invoices/{id}/reject [post]
|
||||
func (h *AdminInvoiceHandler) RejectInvoice(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
invoiceID := c.Param("id")
|
||||
adminID := getUserID(c)
|
||||
|
||||
var req models.InvoiceRejectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
invoice, err := h.invoiceService.RejectInvoice(c.Request.Context(), invoiceID, adminID, &req)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: invoice.ToResponse(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// IssueInvoice 管理员发票开票
|
||||
// @Summary 管理员发票开票
|
||||
// @Description 管理员为发票开票,上传发票文件并设置开票时间
|
||||
// @Tags 发票管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "发票ID"
|
||||
// @Param request body models.InvoiceIssueRequest true "开票请求"
|
||||
// @Success 200 {object} models.APIResponse{data=models.InvoiceResponse} "成功"
|
||||
// @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/v1/invoices/{id}/issue [post]
|
||||
func (h *AdminInvoiceHandler) IssueInvoice(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
invoiceID := c.Param("id")
|
||||
adminID := getUserID(c)
|
||||
|
||||
var req models.InvoiceIssueRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
invoice, err := h.invoiceService.IssueInvoice(c.Request.Context(), invoiceID, adminID, &req)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: invoice.ToResponse(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// UploadInvoiceFile 管理端发票文件上传
|
||||
// @Summary 管理端发票文件上传
|
||||
// @Description 管理员上传发票PDF文件
|
||||
// @Tags 发票管理
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param invoice_no formData string true "发票申请号"
|
||||
// @Param file formData file true "发票PDF文件"
|
||||
// @Success 200 {object} models.APIResponse{data=models.InvoiceUploadResponse} "成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/v1/invoices/upload [post]
|
||||
func (h *AdminInvoiceHandler) UploadInvoiceFile(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
|
||||
// 获取表单参数
|
||||
invoiceNo := c.PostForm("invoice_no")
|
||||
if invoiceNo == "" {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: i18n.GetI18nErrorMessage("900001", lang) + ": invoice_no is required",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取上传的文件
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 验证文件类型
|
||||
if filepath.Ext(header.Filename) != ".pdf" {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: i18n.GetI18nErrorMessage("900001", lang) + ": file type must be pdf",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建文件保存路径
|
||||
uploadDir := filepath.Join("..", "files", "invoices")
|
||||
filename := invoiceNo + "_" + strconv.FormatInt(time.Now().Unix(), 10) + ".pdf"
|
||||
filePath := filepath.Join(uploadDir, filename)
|
||||
|
||||
// 保存文件
|
||||
if err := c.SaveUploadedFile(header, filePath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Code: "900004",
|
||||
Message: i18n.GetI18nErrorMessage("900004", lang) + ": " + err.Error(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回相对URL
|
||||
fileURL := "/files/invoices/" + filename
|
||||
response := models.InvoiceUploadResponse{
|
||||
FileURL: fileURL,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: response,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadByToken 公共发票下载(通过token)
|
||||
// @Summary 公共发票下载
|
||||
// @Description 通过下载token下载发票文件,无需登录验证
|
||||
// @Tags 发票管理
|
||||
// @Accept json
|
||||
// @Produce application/pdf
|
||||
// @Param token query string true "下载token"
|
||||
// @Success 200 {file} binary "发票文件"
|
||||
// @Failure 400 {object} models.ErrorResponse "token无效"
|
||||
// @Failure 404 {object} models.ErrorResponse "文件不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/public/invoices/download [get]
|
||||
func (h *AdminInvoiceHandler) DownloadByToken(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
token := c.Query("token")
|
||||
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: i18n.GetI18nErrorMessage("900001", lang) + ": token is required",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
fileURL, err := h.invoiceService.DownloadByToken(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
// 重定向到文件URL
|
||||
c.Redirect(http.StatusFound, fileURL)
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
|
||||
// getCuUserID 获取客户用户ID
|
||||
func getCuUserID(c *gin.Context) string {
|
||||
if userID, exists := c.Get("cu_user_id"); exists {
|
||||
if id, ok := userID.(string); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getUserID 获取管理员用户ID
|
||||
func getUserID(c *gin.Context) string {
|
||||
if userID, exists := c.Get("user_id"); exists {
|
||||
if id, ok := userID.(string); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// handleI18nError 处理国际化错误
|
||||
func handleI18nError(c *gin.Context, err error, lang string) {
|
||||
if i18nErr, ok := err.(*i18n.I18nError); ok {
|
||||
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
|
||||
Code: i18nErr.Code,
|
||||
Message: i18nErr.Message,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 默认错误处理
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Code: "900004",
|
||||
Message: i18n.GetI18nErrorMessage("900004", lang),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"license-manager/internal/api/middleware"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/service"
|
||||
"license-manager/pkg/i18n"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type LeadHandler struct {
|
||||
leadService service.LeadService
|
||||
}
|
||||
|
||||
func NewLeadHandler(leadService service.LeadService) *LeadHandler {
|
||||
return &LeadHandler{
|
||||
leadService: leadService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateLead 创建线索
|
||||
// @Summary 创建线索
|
||||
// @Description 用户提交企业线索,无需鉴权
|
||||
// @Tags 企业线索
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.LeadCreateRequest true "线索创建请求"
|
||||
// @Success 200 {object} models.APIResponse{data=models.LeadResponse} "成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/leads [post]
|
||||
func (h *LeadHandler) CreateLead(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
|
||||
var req models.LeadCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
lead, err := h.leadService.CreateLead(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: lead.ToResponse(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetLeads 获取线索列表
|
||||
// @Summary 获取线索列表
|
||||
// @Description 管理员获取线索列表,支持分页和筛选
|
||||
// @Tags 企业线索
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param page query int false "页码,默认1" minimum(1)
|
||||
// @Param page_size query int false "每页条数,默认10,最大100" minimum(1) maximum(100)
|
||||
// @Param search query string false "关键词检索"
|
||||
// @Param status query string false "状态筛选"
|
||||
// @Success 200 {object} models.APIResponse{data=models.LeadListResponse} "成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/leads [get]
|
||||
func (h *LeadHandler) GetLeads(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
|
||||
var req models.LeadListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.leadService.GetLeadList(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: result,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetLeadSummary 获取线索汇总
|
||||
// @Summary 获取线索汇总
|
||||
// @Description 管理员获取企业线索汇总统计信息
|
||||
// @Tags 企业线索
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} models.APIResponse{data=models.LeadSummaryResponse} "成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/leads/summary [get]
|
||||
func (h *LeadHandler) GetLeadSummary(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
|
||||
summary, err := h.leadService.GetLeadSummary(c.Request.Context())
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: summary,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetLead 获取线索详情
|
||||
// @Summary 获取线索详情
|
||||
// @Description 根据ID获取线索详细信息
|
||||
// @Tags 企业线索
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "线索ID"
|
||||
// @Success 200 {object} models.APIResponse{data=models.LeadResponse} "成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 404 {object} models.ErrorResponse "线索不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/leads/{id} [get]
|
||||
func (h *LeadHandler) GetLead(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
id := c.Param("id")
|
||||
|
||||
lead, err := h.leadService.GetLead(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: lead.ToResponse(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateLead 更新线索
|
||||
// @Summary 更新线索
|
||||
// @Description 更新线索信息,用于线索跟进
|
||||
// @Tags 企业线索
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "线索ID"
|
||||
// @Param request body models.LeadUpdateRequest true "线索更新请求"
|
||||
// @Success 200 {object} models.APIResponse{data=models.LeadResponse} "成功"
|
||||
// @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/leads/{id} [put]
|
||||
func (h *LeadHandler) UpdateLead(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
id := c.Param("id")
|
||||
|
||||
var req models.LeadUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
lead, err := h.leadService.UpdateLead(c.Request.Context(), id, &req)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: lead.ToResponse(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteLead 删除线索
|
||||
// @Summary 删除线索
|
||||
// @Description 删除指定线索
|
||||
// @Tags 企业线索
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "线索ID"
|
||||
// @Success 200 {object} models.APIResponse{} "成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 404 {object} models.ErrorResponse "线索不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/leads/{id} [delete]
|
||||
func (h *LeadHandler) DeleteLead(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
id := c.Param("id")
|
||||
|
||||
if err := h.leadService.DeleteLead(c.Request.Context(), id); err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: nil,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateLeadStatus 更新线索状态
|
||||
// @Summary 更新线索状态
|
||||
// @Description 快速更新线索状态
|
||||
// @Tags 企业线索
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "线索ID"
|
||||
// @Param status query string true "状态: pending/contacted/converted/invalid"
|
||||
// @Success 200 {object} models.APIResponse{data=models.LeadResponse} "成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 404 {object} models.ErrorResponse "线索不存在"
|
||||
// @Router /api/leads/{id}/status [put]
|
||||
func (h *LeadHandler) UpdateLeadStatus(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
id := c.Param("id")
|
||||
|
||||
status := c.Query("status")
|
||||
if status == "" {
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证状态值
|
||||
validStatuses := map[string]bool{
|
||||
string(models.LeadStatusPending): true,
|
||||
string(models.LeadStatusContacted): true,
|
||||
string(models.LeadStatusConverted): true,
|
||||
string(models.LeadStatusInvalid): true,
|
||||
}
|
||||
if !validStatuses[status] {
|
||||
statusCode, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(statusCode, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
req := &models.LeadUpdateRequest{
|
||||
Status: status,
|
||||
}
|
||||
|
||||
lead, err := h.leadService.UpdateLead(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: lead.ToResponse(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"license-manager/internal/api/middleware"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/service"
|
||||
"license-manager/pkg/i18n"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type PackageHandler struct {
|
||||
packageService service.PackageService
|
||||
}
|
||||
|
||||
func NewPackageHandler(packageService service.PackageService) *PackageHandler {
|
||||
return &PackageHandler{
|
||||
packageService: packageService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPackages 获取套餐列表
|
||||
// @Summary 获取套餐列表
|
||||
// @Description 管理员获取套餐列表,支持类型和状态筛选
|
||||
// @Tags 套餐管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param type query string false "套餐类型筛选"
|
||||
// @Param status query int false "状态筛选,1-启用,0-禁用"
|
||||
// @Success 200 {object} models.APIResponse{data=models.PackageListResponse} "成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/packages [get]
|
||||
func (h *PackageHandler) GetPackages(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
|
||||
var req models.PackageListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.packageService.GetPackageList(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: result,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetPackageDetail 获取套餐详情
|
||||
// @Summary 获取套餐详情
|
||||
// @Description 根据ID获取套餐详细信息
|
||||
// @Tags 套餐管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "套餐ID"
|
||||
// @Success 200 {object} models.APIResponse{data=models.PackageResponse} "成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 404 {object} models.ErrorResponse "套餐不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/packages/{id} [get]
|
||||
func (h *PackageHandler) GetPackageDetail(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
id := c.Param("id")
|
||||
|
||||
pkg, err := h.packageService.GetPackageDetail(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: pkg.ToResponse(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// CreatePackage 创建套餐
|
||||
// @Summary 创建套餐
|
||||
// @Description 创建新的套餐
|
||||
// @Tags 套餐管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body models.PackageCreateRequest true "套餐创建请求"
|
||||
// @Success 200 {object} models.APIResponse{data=models.PackageResponse} "成功"
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/packages [post]
|
||||
func (h *PackageHandler) CreatePackage(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
|
||||
var req models.PackageCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
pkg, err := h.packageService.CreatePackage(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: pkg.ToResponse(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePackage 更新套餐
|
||||
// @Summary 更新套餐
|
||||
// @Description 更新指定套餐的信息
|
||||
// @Tags 套餐管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "套餐ID"
|
||||
// @Param request body models.PackageUpdateRequest true "套餐更新请求"
|
||||
// @Success 200 {object} models.APIResponse{data=models.PackageResponse} "成功"
|
||||
// @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/packages/{id} [put]
|
||||
func (h *PackageHandler) UpdatePackage(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
id := c.Param("id")
|
||||
|
||||
var req models.PackageUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message + ": " + err.Error(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
pkg, err := h.packageService.UpdatePackage(c.Request.Context(), id, &req)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: pkg.ToResponse(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// DeletePackage 删除套餐
|
||||
// @Summary 删除套餐
|
||||
// @Description 删除指定套餐(软删除)
|
||||
// @Tags 套餐管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "套餐ID"
|
||||
// @Success 200 {object} models.APIResponse{} "成功"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 403 {object} models.ErrorResponse "权限不足"
|
||||
// @Failure 404 {object} models.ErrorResponse "套餐不存在"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/packages/{id} [delete]
|
||||
func (h *PackageHandler) DeletePackage(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
id := c.Param("id")
|
||||
|
||||
if err := h.packageService.DeletePackage(c.Request.Context(), id); err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: nil,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePackageStatus 更新套餐状态
|
||||
// @Summary 更新套餐状态
|
||||
// @Description 启用或禁用套餐
|
||||
// @Tags 套餐管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path string true "套餐ID"
|
||||
// @Param status query int true "状态,1-启用,0-禁用"
|
||||
// @Success 200 {object} models.APIResponse{data=models.PackageResponse} "成功"
|
||||
// @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/packages/{id}/status [put]
|
||||
func (h *PackageHandler) UpdatePackageStatus(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
id := c.Param("id")
|
||||
|
||||
statusStr := c.Query("status")
|
||||
if statusStr == "" {
|
||||
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(status, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status, err := strconv.Atoi(statusStr)
|
||||
if err != nil || (status != 0 && status != 1) {
|
||||
statusCode, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
|
||||
c.JSON(statusCode, models.ErrorResponse{
|
||||
Code: errCode,
|
||||
Message: message,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
req := &models.PackageUpdateRequest{
|
||||
Status: &status,
|
||||
}
|
||||
|
||||
pkg, err := h.packageService.UpdatePackage(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
handleI18nError(c, err, lang)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: i18n.GetI18nErrorMessage("000000", lang),
|
||||
Data: pkg.ToResponse(),
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/repository"
|
||||
"license-manager/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PublicRenewalHandler struct {
|
||||
licenseRepo repository.LicenseRepository
|
||||
customerRepo repository.CustomerRepository
|
||||
cuUserRepo repository.CuUserRepository
|
||||
packageService service.PackageService
|
||||
cuOrderService service.CuOrderService
|
||||
paymentService service.PaymentService
|
||||
}
|
||||
|
||||
func NewPublicRenewalHandler(
|
||||
licenseRepo repository.LicenseRepository,
|
||||
customerRepo repository.CustomerRepository,
|
||||
cuUserRepo repository.CuUserRepository,
|
||||
packageService service.PackageService,
|
||||
cuOrderService service.CuOrderService,
|
||||
paymentService service.PaymentService,
|
||||
) *PublicRenewalHandler {
|
||||
return &PublicRenewalHandler{
|
||||
licenseRepo: licenseRepo,
|
||||
customerRepo: customerRepo,
|
||||
cuUserRepo: cuUserRepo,
|
||||
packageService: packageService,
|
||||
cuOrderService: cuOrderService,
|
||||
paymentService: paymentService,
|
||||
}
|
||||
}
|
||||
|
||||
type publicAuthCodeQueryRequest struct {
|
||||
AuthorizationCode string `json:"authorization_code" binding:"required"`
|
||||
}
|
||||
|
||||
type publicRenewalOrderRequest struct {
|
||||
AuthorizationCode string `json:"authorization_code" binding:"required"`
|
||||
PackageID string `json:"package_id" binding:"required"`
|
||||
LicenseCount int `json:"license_count" binding:"required,min=1,max=1000"`
|
||||
RenewDays int `json:"renew_days" binding:"required,min=1,max=3650"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
ContactName string `json:"contact_name" binding:"required,max=100"`
|
||||
ContactPhone string `json:"contact_phone" binding:"required,min=7,max=20"`
|
||||
ContactEmail string `json:"contact_email" binding:"omitempty,email,max=255"`
|
||||
CompanyName string `json:"company_name" binding:"omitempty,max=200"`
|
||||
}
|
||||
|
||||
type publicPurchaseOrderRequest struct {
|
||||
PackageID string `json:"package_id" binding:"required"`
|
||||
LicenseCount int `json:"license_count" binding:"required,min=1,max=1000"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
ContactName string `json:"contact_name" binding:"required,max=100"`
|
||||
ContactPhone string `json:"contact_phone" binding:"required,min=7,max=20"`
|
||||
ContactEmail string `json:"contact_email" binding:"omitempty,email,max=255"`
|
||||
CompanyName string `json:"company_name" binding:"omitempty,max=200"`
|
||||
CustomerType string `json:"customer_type" binding:"omitempty,oneof=individual enterprise government education"`
|
||||
Description string `json:"description" binding:"omitempty,max=500"`
|
||||
}
|
||||
|
||||
// QueryAuthorizationCode 公开查询授权码信息
|
||||
func (h *PublicRenewalHandler) QueryAuthorizationCode(c *gin.Context) {
|
||||
var req publicAuthCodeQueryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: "请求参数错误",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
code := strings.TrimSpace(req.AuthorizationCode)
|
||||
authCode, err := h.licenseRepo.GetAuthorizationCodeByCode(c.Request.Context(), code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Code: "300001",
|
||||
Message: "授权码不存在",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
customerName := ""
|
||||
contactPerson := ""
|
||||
contactPhone := ""
|
||||
contactEmail := ""
|
||||
|
||||
customer, err := h.customerRepo.GetCustomerByID(c.Request.Context(), authCode.CustomerID)
|
||||
if err == nil {
|
||||
customerName = customer.CustomerName
|
||||
contactPerson = customer.ContactPerson
|
||||
if customer.Phone != nil {
|
||||
contactPhone = *customer.Phone
|
||||
}
|
||||
if customer.Email != nil {
|
||||
contactEmail = *customer.Email
|
||||
}
|
||||
}
|
||||
|
||||
activeCount, _ := h.licenseRepo.GetActiveLicenseCount(c.Request.Context(), authCode.ID)
|
||||
|
||||
status := "normal"
|
||||
now := time.Now()
|
||||
if authCode.IsLocked {
|
||||
status = "locked"
|
||||
} else if now.After(authCode.EndDate) {
|
||||
status = "expired"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: "success",
|
||||
Data: gin.H{
|
||||
"authorization_code_id": authCode.ID,
|
||||
"authorization_code": authCode.Code,
|
||||
"customer_id": authCode.CustomerID,
|
||||
"customer_name": customerName,
|
||||
"contact_person": contactPerson,
|
||||
"contact_phone": contactPhone,
|
||||
"contact_email": contactEmail,
|
||||
"status": status,
|
||||
"is_locked": authCode.IsLocked,
|
||||
"start_date": authCode.StartDate.Format(time.RFC3339),
|
||||
"end_date": authCode.EndDate.Format(time.RFC3339),
|
||||
"max_activations": authCode.MaxActivations,
|
||||
"active_licenses": activeCount,
|
||||
},
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateRenewalOrder 公开创建续期支付单
|
||||
func (h *PublicRenewalHandler) CreateRenewalOrder(c *gin.Context) {
|
||||
var req publicRenewalOrderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: "请求参数错误",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
authCode, err := h.licenseRepo.GetAuthorizationCodeByCode(c.Request.Context(), strings.TrimSpace(req.AuthorizationCode))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Code: "300001",
|
||||
Message: "授权码不存在",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if authCode.IsLocked {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "300003",
|
||||
Message: "授权码已锁定,无法续期",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.packageService.GetPackageDetail(c.Request.Context(), req.PackageID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "800002",
|
||||
Message: "套餐不存在",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
priceResult, err := h.cuOrderService.CalculatePrice(c.Request.Context(), req.PackageID, req.LicenseCount)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "601004",
|
||||
Message: "价格计算失败",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
customer, err := h.customerRepo.GetCustomerByID(c.Request.Context(), authCode.CustomerID)
|
||||
if err == nil {
|
||||
if strings.TrimSpace(req.CompanyName) != "" {
|
||||
customer.CustomerName = strings.TrimSpace(req.CompanyName)
|
||||
}
|
||||
customer.ContactPerson = strings.TrimSpace(req.ContactName)
|
||||
phone := strings.TrimSpace(req.ContactPhone)
|
||||
if phone != "" {
|
||||
customer.Phone = &phone
|
||||
}
|
||||
email := strings.TrimSpace(req.ContactEmail)
|
||||
if email != "" {
|
||||
customer.Email = &email
|
||||
}
|
||||
_ = h.customerRepo.UpdateCustomer(c.Request.Context(), customer)
|
||||
}
|
||||
|
||||
cuUserID, err := h.ensureCuUser(c.Request.Context(), authCode.CustomerID, req.ContactPhone, req.ContactName, req.ContactEmail)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Code: "900004",
|
||||
Message: "创建客户用户失败",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
renewalExtra := map[string]interface{}{
|
||||
"authorization_code_id": authCode.ID,
|
||||
"renew_days": req.RenewDays,
|
||||
}
|
||||
extraDataBytes, _ := json.Marshal(renewalExtra)
|
||||
extraData := string(extraDataBytes)
|
||||
|
||||
paymentMethod := strings.TrimSpace(req.PaymentMethod)
|
||||
if paymentMethod == "" {
|
||||
paymentMethod = models.PaymentMethodAlipay
|
||||
}
|
||||
|
||||
businessID := authCode.ID
|
||||
payment, err := h.paymentService.CreatePayment(c.Request.Context(), &models.PaymentCreateRequest{
|
||||
BusinessType: models.BusinessTypeLicenseExtend,
|
||||
BusinessID: &businessID,
|
||||
CustomerID: authCode.CustomerID,
|
||||
CuUserID: cuUserID,
|
||||
Amount: priceResult.TotalAmount,
|
||||
Currency: "CNY",
|
||||
PaymentMethod: paymentMethod,
|
||||
ExtraData: &extraData,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Code: "602001",
|
||||
Message: "创建支付单失败",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: "success",
|
||||
Data: gin.H{
|
||||
"payment_no": payment.PaymentNo,
|
||||
"payment_url": payment.PaymentURL,
|
||||
"total_amount": payment.Amount,
|
||||
"expire_time": payment.ExpireTime.Format(time.RFC3339),
|
||||
"renew_days": req.RenewDays,
|
||||
},
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// CreatePurchaseOrder 公开创建新购支付单
|
||||
func (h *PublicRenewalHandler) CreatePurchaseOrder(c *gin.Context) {
|
||||
var req publicPurchaseOrderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: "请求参数错误",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
pkg, err := h.packageService.GetPackageDetail(c.Request.Context(), req.PackageID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "800002",
|
||||
Message: "套餐不存在",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
priceResult, err := h.cuOrderService.CalculatePrice(c.Request.Context(), req.PackageID, req.LicenseCount)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "601004",
|
||||
Message: "价格计算失败",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
customerID, cuUserID, err := h.ensureCustomerAndUser(
|
||||
c.Request.Context(),
|
||||
strings.TrimSpace(req.CompanyName),
|
||||
strings.TrimSpace(req.ContactName),
|
||||
strings.TrimSpace(req.ContactPhone),
|
||||
strings.TrimSpace(req.ContactEmail),
|
||||
strings.TrimSpace(req.CustomerType),
|
||||
strings.TrimSpace(req.Description),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Code: "900004",
|
||||
Message: "客户信息写入失败",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
orderReq := &models.CuOrderCreateRequest{
|
||||
PackageID: req.PackageID,
|
||||
LicenseCount: req.LicenseCount,
|
||||
PaymentMethod: strings.TrimSpace(req.PaymentMethod),
|
||||
}
|
||||
if orderReq.PaymentMethod == "" {
|
||||
orderReq.PaymentMethod = models.PaymentMethodAlipay
|
||||
}
|
||||
|
||||
if priceResult.TotalAmount == 0 {
|
||||
order, createErr := h.cuOrderService.CreateOrder(c.Request.Context(), cuUserID, customerID, orderReq)
|
||||
if createErr != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "601001",
|
||||
Message: "订单创建失败",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: "success",
|
||||
Data: gin.H{
|
||||
"order_id": order.ID,
|
||||
"order_no": order.OrderNo,
|
||||
"package_name": pkg.Name,
|
||||
"total_amount": 0,
|
||||
"license_count": req.LicenseCount,
|
||||
"authorization_code": order.AuthorizationCode,
|
||||
},
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
order, err := h.cuOrderService.CreatePendingOrder(c.Request.Context(), cuUserID, customerID, orderReq)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "601001",
|
||||
Message: "订单创建失败",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
payment, err := h.paymentService.CreatePayment(c.Request.Context(), &models.PaymentCreateRequest{
|
||||
BusinessType: models.BusinessTypePackageOrder,
|
||||
BusinessID: &order.ID,
|
||||
CustomerID: customerID,
|
||||
CuUserID: cuUserID,
|
||||
Amount: priceResult.TotalAmount,
|
||||
Currency: "CNY",
|
||||
PaymentMethod: orderReq.PaymentMethod,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
|
||||
Code: "602001",
|
||||
Message: "支付单创建失败",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: "success",
|
||||
Data: gin.H{
|
||||
"order_id": order.ID,
|
||||
"order_no": order.OrderNo,
|
||||
"package_name": pkg.Name,
|
||||
"payment_no": payment.PaymentNo,
|
||||
"payment_url": payment.PaymentURL,
|
||||
"total_amount": payment.Amount,
|
||||
"expire_time": payment.ExpireTime.Format(time.RFC3339),
|
||||
"license_count": req.LicenseCount,
|
||||
},
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetPublicPaymentStatus 公开支付状态查询
|
||||
func (h *PublicRenewalHandler) GetPublicPaymentStatus(c *gin.Context) {
|
||||
paymentNo := strings.TrimSpace(c.Param("payment_no"))
|
||||
if paymentNo == "" {
|
||||
c.JSON(http.StatusBadRequest, models.ErrorResponse{
|
||||
Code: "900001",
|
||||
Message: "支付单号不能为空",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
statusResp, err := h.paymentService.GetPaymentStatus(c.Request.Context(), paymentNo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Code: "602002",
|
||||
Message: "支付单不存在",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
payment, err := h.paymentService.GetPayment(c.Request.Context(), paymentNo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, models.ErrorResponse{
|
||||
Code: "602002",
|
||||
Message: "支付单不存在",
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
authCodeID := ""
|
||||
endDate := ""
|
||||
if payment.ExtraData != nil && strings.TrimSpace(*payment.ExtraData) != "" {
|
||||
var renewalData struct {
|
||||
AuthorizationCodeID string `json:"authorization_code_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(*payment.ExtraData), &renewalData); err == nil {
|
||||
authCodeID = renewalData.AuthorizationCodeID
|
||||
if strings.TrimSpace(authCodeID) != "" {
|
||||
authCode, err := h.licenseRepo.GetAuthorizationCodeByID(c.Request.Context(), authCodeID)
|
||||
if err == nil {
|
||||
endDate = authCode.EndDate.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.APIResponse{
|
||||
Code: "000000",
|
||||
Message: "success",
|
||||
Data: gin.H{
|
||||
"payment_no": statusResp.PaymentNo,
|
||||
"status": statusResp.Status,
|
||||
"amount": statusResp.Amount,
|
||||
"payment_time": statusResp.PaymentTime,
|
||||
"trade_no": statusResp.TradeNo,
|
||||
"authorization_code_id": authCodeID,
|
||||
"authorization_end_date": endDate,
|
||||
"business_order": statusResp.BusinessOrder,
|
||||
},
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PublicRenewalHandler) ensureCuUser(ctx context.Context, customerID, phone, contactName, contactEmail string) (string, error) {
|
||||
countryCode := "+86"
|
||||
trimmedPhone := strings.TrimSpace(phone)
|
||||
user, err := h.cuUserRepo.GetByPhone(trimmedPhone, countryCode)
|
||||
if err == nil {
|
||||
return user.ID, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var realName *string
|
||||
name := strings.TrimSpace(contactName)
|
||||
if name != "" {
|
||||
realName = &name
|
||||
}
|
||||
var email *string
|
||||
trimmedEmail := strings.TrimSpace(contactEmail)
|
||||
if trimmedEmail != "" {
|
||||
email = &trimmedEmail
|
||||
}
|
||||
|
||||
newUser := &models.CuUser{
|
||||
CustomerID: customerID,
|
||||
Phone: trimmedPhone,
|
||||
PhoneCountryCode: countryCode,
|
||||
UserRole: "member",
|
||||
RealName: realName,
|
||||
Email: email,
|
||||
Status: "active",
|
||||
PhoneVerified: true,
|
||||
EmailVerified: false,
|
||||
Language: "zh-CN",
|
||||
Timezone: "Asia/Shanghai",
|
||||
}
|
||||
if err := h.cuUserRepo.Create(newUser); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return newUser.ID, nil
|
||||
}
|
||||
|
||||
func (h *PublicRenewalHandler) ensureCustomerAndUser(
|
||||
ctx context.Context,
|
||||
companyName, contactName, phone, email, customerType, description string,
|
||||
) (string, string, error) {
|
||||
countryCode := "+86"
|
||||
trimmedPhone := strings.TrimSpace(phone)
|
||||
existingUser, err := h.cuUserRepo.GetByPhone(trimmedPhone, countryCode)
|
||||
if err == nil {
|
||||
customer, customerErr := h.customerRepo.GetCustomerByID(ctx, existingUser.CustomerID)
|
||||
if customerErr == nil {
|
||||
if companyName != "" {
|
||||
customer.CustomerName = companyName
|
||||
}
|
||||
if contactName != "" {
|
||||
customer.ContactPerson = contactName
|
||||
}
|
||||
if trimmedPhone != "" {
|
||||
customer.Phone = &trimmedPhone
|
||||
}
|
||||
if email != "" {
|
||||
customer.Email = &email
|
||||
}
|
||||
if description != "" {
|
||||
customer.Description = &description
|
||||
}
|
||||
_ = h.customerRepo.UpdateCustomer(ctx, customer)
|
||||
}
|
||||
return existingUser.CustomerID, existingUser.ID, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
ct := customerType
|
||||
if ct == "" {
|
||||
ct = "enterprise"
|
||||
}
|
||||
customerName := companyName
|
||||
if customerName == "" {
|
||||
customerName = contactName
|
||||
}
|
||||
if customerName == "" {
|
||||
customerName = trimmedPhone
|
||||
}
|
||||
contactPerson := contactName
|
||||
if contactPerson == "" {
|
||||
contactPerson = trimmedPhone
|
||||
}
|
||||
|
||||
customer := &models.Customer{
|
||||
CustomerName: customerName,
|
||||
CustomerType: ct,
|
||||
ContactPerson: contactPerson,
|
||||
CustomerLevel: "basic",
|
||||
Status: "active",
|
||||
CreatedBy: "public-order",
|
||||
}
|
||||
if trimmedPhone != "" {
|
||||
customer.Phone = &trimmedPhone
|
||||
}
|
||||
if email != "" {
|
||||
customer.Email = &email
|
||||
}
|
||||
if description != "" {
|
||||
customer.Description = &description
|
||||
}
|
||||
if err := h.customerRepo.CreateCustomer(ctx, customer); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
cuUserID, err := h.ensureCuUser(ctx, customer.ID, trimmedPhone, contactName, email)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return customer.ID, cuUserID, nil
|
||||
}
|
||||
@@ -4,17 +4,19 @@ 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 认证中间件
|
||||
// AuthMiddleware 认证中间件(支持自动延长)
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authorizationHeader := c.GetHeader(AuthorizationHeaderKey)
|
||||
@@ -79,6 +81,17 @@ func AuthMiddleware() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否需要自动延长token(单令牌延长机制)
|
||||
if newToken, err := utils.RefreshTokenIfNeeded(accessToken); err == nil && newToken != accessToken {
|
||||
// 自动延长token有效期,后端直接使用新token,客户端无需感知
|
||||
accessToken = newToken
|
||||
// 重新解析claims
|
||||
newClaims, parseErr := utils.ParseToken(newToken)
|
||||
if parseErr == nil {
|
||||
claims = newClaims
|
||||
}
|
||||
}
|
||||
|
||||
// 将用户信息存储到上下文
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
@@ -154,7 +167,90 @@ 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
|
||||
}
|
||||
|
||||
// 检查是否需要自动延长token(单令牌延长机制)
|
||||
if newToken, err := utils.RefreshCuTokenIfNeeded(accessToken); err == nil && newToken != accessToken {
|
||||
// 自动延长token有效期,后端直接使用新token,客户端无需感知
|
||||
accessToken = newToken
|
||||
// 重新解析claims
|
||||
newClaims, parseErr := utils.ParseCuToken(newToken)
|
||||
if parseErr == nil {
|
||||
claims = newClaims
|
||||
}
|
||||
}
|
||||
|
||||
// 将客户用户信息存储到上下文
|
||||
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,62 @@ 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)
|
||||
cuInvoiceRepo := repository.NewCuInvoiceRepository(db)
|
||||
adminInvoiceRepo := repository.NewAdminInvoiceRepository(db)
|
||||
packageRepo := repository.NewPackageRepository(db)
|
||||
leadRepo := repository.NewLeadRepository(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)
|
||||
packageService := service.NewPackageService(packageRepo, db)
|
||||
cuOrderService := service.NewCuOrderService(cuOrderRepo, cuUserRepo, authCodeRepo, packageRepo, paymentRepo, cuInvoiceRepo, 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,14 +104,33 @@ 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, packageService)
|
||||
cuDeviceHandler := handlers.NewCuDeviceHandler(cuDeviceService)
|
||||
paymentHandler := handlers.NewPaymentHandler(paymentService)
|
||||
cuAuthorizationHandler := handlers.NewCuAuthorizationHandler(authCodeService)
|
||||
cuInvoiceService := service.NewCuInvoiceService(cuInvoiceRepo, cuOrderRepo, db)
|
||||
cuInvoiceHandler := handlers.NewCuInvoiceHandler(cuInvoiceService)
|
||||
enumHandler := handlers.NewEnumHandler(enumService)
|
||||
authCodeHandler := handlers.NewAuthorizationCodeHandler(authCodeService)
|
||||
licenseHandler := handlers.NewLicenseHandler(licenseService)
|
||||
dashboardHandler := handlers.NewDashboardHandler(dashboardService)
|
||||
adminInvoiceService := service.NewAdminInvoiceService(adminInvoiceRepo, cuOrderRepo, userRepo, cuUserRepo, db)
|
||||
adminInvoiceHandler := handlers.NewAdminInvoiceHandler(adminInvoiceService)
|
||||
packageHandler := handlers.NewPackageHandler(packageService)
|
||||
leadService := service.NewLeadService(leadRepo, db)
|
||||
leadHandler := handlers.NewLeadHandler(leadService)
|
||||
publicRenewalHandler := handlers.NewPublicRenewalHandler(licenseRepo, customerRepo, cuUserRepo, packageService, cuOrderService, paymentService)
|
||||
|
||||
// 健康检测接口(无需认证)
|
||||
router.GET("/health", systemHandler.HealthCheck)
|
||||
|
||||
// 静态文件服务 - 发票文件等
|
||||
// 发票PDF上传保存路径为 "../files/invoices"
|
||||
// 这里将URL前缀 /files 映射到 "../files" 目录,保证 /files/invoices 下的文件可被访问
|
||||
router.Static("/files", "../files")
|
||||
|
||||
// Swagger文档路由
|
||||
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.URL("/swagger/doc.json")))
|
||||
|
||||
@@ -80,6 +145,20 @@ 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)
|
||||
|
||||
// 公共发票下载接口
|
||||
public.GET("/public/invoices/download", adminInvoiceHandler.DownloadByToken)
|
||||
public.POST("/public/authorization-codes/query", publicRenewalHandler.QueryAuthorizationCode)
|
||||
public.POST("/public/orders", publicRenewalHandler.CreatePurchaseOrder)
|
||||
public.POST("/public/renewals/order", publicRenewalHandler.CreateRenewalOrder)
|
||||
public.GET("/public/payment/:payment_no/status", publicRenewalHandler.GetPublicPaymentStatus)
|
||||
|
||||
// 线索收集(无需鉴权)
|
||||
public.POST("/leads", leadHandler.CreateLead)
|
||||
}
|
||||
|
||||
// 需要认证的接口
|
||||
@@ -125,6 +204,30 @@ func SetupRouter() *gin.Engine {
|
||||
// 仪表盘接口
|
||||
auth.GET("/v1/dashboard/authorization-trend", dashboardHandler.GetAuthorizationTrend)
|
||||
auth.GET("/v1/dashboard/recent-authorizations", dashboardHandler.GetRecentAuthorizations)
|
||||
|
||||
// 发票管理(管理员)
|
||||
auth.GET("/v1/invoices", adminInvoiceHandler.GetAdminInvoices)
|
||||
auth.GET("/v1/invoices/:id", adminInvoiceHandler.GetAdminInvoiceDetail)
|
||||
auth.GET("/v1/invoices/summary", adminInvoiceHandler.GetAdminInvoiceSummary)
|
||||
auth.POST("/v1/invoices/:id/reject", adminInvoiceHandler.RejectInvoice)
|
||||
auth.POST("/v1/invoices/:id/issue", adminInvoiceHandler.IssueInvoice)
|
||||
auth.POST("/v1/invoices/upload", adminInvoiceHandler.UploadInvoiceFile)
|
||||
|
||||
// 套餐管理(管理员)
|
||||
auth.GET("/packages", packageHandler.GetPackages)
|
||||
auth.GET("/packages/:id", packageHandler.GetPackageDetail)
|
||||
auth.POST("/packages", packageHandler.CreatePackage)
|
||||
auth.PUT("/packages/:id", packageHandler.UpdatePackage)
|
||||
auth.DELETE("/packages/:id", packageHandler.DeletePackage)
|
||||
auth.PUT("/packages/:id/status", packageHandler.UpdatePackageStatus)
|
||||
|
||||
// 线索管理(管理员)
|
||||
auth.GET("/leads", leadHandler.GetLeads)
|
||||
auth.GET("/leads/summary", leadHandler.GetLeadSummary)
|
||||
auth.GET("/leads/:id", leadHandler.GetLead)
|
||||
auth.PUT("/leads/:id", leadHandler.UpdateLead)
|
||||
auth.PUT("/leads/:id/status", leadHandler.UpdateLeadStatus)
|
||||
auth.DELETE("/leads/:id", leadHandler.DeleteLead)
|
||||
}
|
||||
|
||||
// 管理员接口
|
||||
@@ -135,5 +238,109 @@ 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.PUT("/orders/:order_id/cancel", cuOrderHandler.CancelOrder)
|
||||
cuAuth.POST("/orders/:order_id/pay", cuOrderHandler.ContinuePay)
|
||||
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("/authorization-codes", cuAuthorizationHandler.GetCuAuthorizationCodes)
|
||||
cuAuth.GET("/authorization-codes/summary", cuAuthorizationHandler.GetCuAuthorizationCodeSummary)
|
||||
|
||||
// 设备管理
|
||||
cuAuth.GET("/devices", cuDeviceHandler.GetDevices)
|
||||
cuAuth.GET("/devices/summary", cuDeviceHandler.GetDeviceSummary)
|
||||
cuAuth.DELETE("/devices/:id", cuDeviceHandler.UnbindDevice)
|
||||
|
||||
// 发票管理
|
||||
cuAuth.POST("/invoices", cuInvoiceHandler.CreateInvoice)
|
||||
cuAuth.GET("/invoices", cuInvoiceHandler.GetUserInvoices)
|
||||
cuAuth.GET("/invoices/:id", cuInvoiceHandler.GetUserInvoiceDetail)
|
||||
cuAuth.PUT("/invoices/:id", cuInvoiceHandler.UpdateInvoice)
|
||||
cuAuth.GET("/invoices/summary", cuInvoiceHandler.GetUserInvoiceSummary)
|
||||
cuAuth.GET("/invoices/:id/download", cuInvoiceHandler.DownloadInvoice)
|
||||
}
|
||||
}
|
||||
|
||||
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,14 +23,25 @@ func AutoMigrate() error {
|
||||
&models.Customer{},
|
||||
&models.CustomerCodeSequence{},
|
||||
&models.User{},
|
||||
&models.CuUser{},
|
||||
&models.CuOrder{},
|
||||
&models.Payment{},
|
||||
&models.AuthorizationCode{},
|
||||
&models.License{},
|
||||
&models.AuthorizationChange{},
|
||||
&models.Invoice{}, // 发票表
|
||||
&models.Package{}, // 套餐表
|
||||
&models.Lead{}, // 线索表
|
||||
)
|
||||
if err != nil {
|
||||
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)
|
||||
@@ -41,6 +52,11 @@ func AutoMigrate() error {
|
||||
return fmt.Errorf("failed to initialize default admin user: %w", err)
|
||||
}
|
||||
|
||||
// 初始化默认套餐数据
|
||||
if err := initDefaultPackages(); err != nil {
|
||||
return fmt.Errorf("failed to initialize default packages: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Database auto migration completed successfully")
|
||||
return nil
|
||||
}
|
||||
@@ -48,10 +64,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 +79,7 @@ func initCustomerCodeSequence() error {
|
||||
}
|
||||
log.Printf("Initialized customer code sequence for year %d", currentYear)
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -82,18 +98,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 +135,86 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
// initDefaultPackages 初始化默认套餐数据
|
||||
func initDefaultPackages() error {
|
||||
var count int64
|
||||
if err := DB.Model(&models.Package{}).Where("deleted_at IS NULL").Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("failed to count packages: %w", err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
log.Println("Packages already initialized, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 默认套餐数据
|
||||
packages := []models.Package{
|
||||
{
|
||||
Name: "试用版",
|
||||
Type: "trial",
|
||||
Price: 0,
|
||||
PriceDescription: "免费",
|
||||
DurationDescription: "当月25日到期",
|
||||
Description: "免费体验全部功能",
|
||||
Features: `["全部功能", "1个许可"]`,
|
||||
Status: 1,
|
||||
SortOrder: 1,
|
||||
},
|
||||
{
|
||||
Name: "基础版",
|
||||
Type: "basic",
|
||||
Price: 300,
|
||||
PriceDescription: "300元/许可",
|
||||
DurationDescription: "永久有效",
|
||||
Description: "小型企业最佳选择",
|
||||
Features: `["基础功能", "批量许可购买", "折扣优惠"]`,
|
||||
Status: 1,
|
||||
SortOrder: 2,
|
||||
},
|
||||
{
|
||||
Name: "专业版",
|
||||
Type: "professional",
|
||||
Price: 2000,
|
||||
PriceDescription: "2000元/许可",
|
||||
DurationDescription: "永久有效",
|
||||
Description: "企业级完整解决方案",
|
||||
Features: `["全部功能", "技术支持", "数据分析"]`,
|
||||
Status: 1,
|
||||
SortOrder: 3,
|
||||
},
|
||||
{
|
||||
Name: "企业定制版",
|
||||
Type: "custom",
|
||||
Price: 0,
|
||||
PriceDescription: "定制报价",
|
||||
DurationDescription: "按需定制",
|
||||
Description: "按需定制解决方案",
|
||||
Features: `["按需定制", "专属服务"]`,
|
||||
Status: 1,
|
||||
SortOrder: 4,
|
||||
},
|
||||
}
|
||||
|
||||
for _, pkg := range packages {
|
||||
if err := DB.Create(&pkg).Error; err != nil {
|
||||
return fmt.Errorf("failed to create package %s: %w", pkg.Name, err)
|
||||
}
|
||||
log.Printf("Created default package: %s", pkg.Name)
|
||||
}
|
||||
|
||||
log.Println("Default packages initialized successfully")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package models
|
||||
|
||||
// ========== 管理端发票请求/响应结构体 ==========
|
||||
|
||||
// InvoiceRejectRequest 发票驳回请求结构(管理端)
|
||||
type InvoiceRejectRequest struct {
|
||||
RejectReason string `json:"reject_reason" binding:"required,max=500"` // 驳回原因,必填,最大500字符
|
||||
Suggestion *string `json:"suggestion" binding:"omitempty,max=500"` // 修改建议,非必填,最大500字符
|
||||
}
|
||||
|
||||
// InvoiceIssueRequest 发票开票请求结构(管理端)
|
||||
type InvoiceIssueRequest struct {
|
||||
InvoiceFileURL string `json:"invoice_file_url" binding:"required,max=500"` // 发票文件URL,必填,最大500字符
|
||||
IssuedAt string `json:"issued_at" binding:"required"` // 开票时间,必填,ISO 8601格式时间字符串
|
||||
}
|
||||
|
||||
// InvoiceUploadRequest 发票文件上传请求结构(管理端)
|
||||
type InvoiceUploadRequest struct {
|
||||
InvoiceNo string `form:"invoice_no" binding:"required"` // 发票申请号,必填
|
||||
}
|
||||
|
||||
// InvoiceUploadResponse 发票文件上传响应结构(管理端)
|
||||
type InvoiceUploadResponse struct {
|
||||
FileURL string `json:"file_url"` // 上传后的文件URL
|
||||
}
|
||||
|
||||
// InvoiceDetailResponse 发票详情响应结构(包含关联信息,管理端使用)
|
||||
type InvoiceDetailResponse struct {
|
||||
*InvoiceResponse
|
||||
OrderPackageName string `json:"order_package_name,omitempty"` // 关联订单的套餐名称
|
||||
ApplicantName string `json:"applicant_name,omitempty"` // 发票申请人真实姓名
|
||||
ApplicantPhone string `json:"applicant_phone,omitempty"` // 发票申请人电话号码
|
||||
UploaderName string `json:"uploader_name,omitempty"` // 发票文件上传人姓名(管理员)
|
||||
RejecterName string `json:"rejecter_name,omitempty"` // 发票驳回人姓名(管理员)
|
||||
}
|
||||
@@ -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"` // 客户名称显示(多语言)
|
||||
@@ -132,6 +132,7 @@ type AuthorizationCodeListRequest struct {
|
||||
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
|
||||
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认20,最大100
|
||||
CustomerID string `form:"customer_id" binding:"omitempty"` // 客户ID筛选
|
||||
Code string `form:"code" binding:"omitempty"` // 授权码模糊匹配
|
||||
Status string `form:"status" binding:"omitempty,oneof=normal locked expired"` // 状态筛选
|
||||
StartDate string `form:"start_date" binding:"omitempty"` // 创建开始时间
|
||||
EndDate string `form:"end_date" binding:"omitempty"` // 创建结束时间
|
||||
@@ -170,18 +171,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 +261,33 @@ type AuthorizationChangeListResponse struct {
|
||||
PageSize int `json:"page_size"` // 每页条数
|
||||
TotalPages int `json:"total_pages"` // 总页数
|
||||
}
|
||||
|
||||
// AuthorizationCodeShareRequest 授权码分享请求结构
|
||||
type AuthorizationCodeShareRequest struct {
|
||||
TargetContact string `json:"target_contact" binding:"required"` // 受赠用户联系方式(手机号或邮箱)
|
||||
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,39 @@
|
||||
package models
|
||||
|
||||
// CuAuthorizationCodeListRequest 用户端授权码列表查询请求
|
||||
type CuAuthorizationCodeListRequest struct {
|
||||
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
|
||||
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页数量,默认10,最大100
|
||||
Status string `form:"status" binding:"omitempty,oneof=normal locked expired"`
|
||||
Search string `form:"search" binding:"omitempty"` // 授权码模糊匹配(code LIKE %search%)
|
||||
}
|
||||
|
||||
// CuAuthorizationCodeListItem 用户端授权码列表项
|
||||
type CuAuthorizationCodeListItem struct {
|
||||
ID string `json:"id"` // 授权码ID
|
||||
Code string `json:"code"` // 授权码
|
||||
Status string `json:"status"` // 状态
|
||||
StatusDisplay string `json:"status_display,omitempty"` // 状态显示
|
||||
MaxActivations int `json:"max_activations"` // 最大激活数量
|
||||
CurrentActivations int `json:"current_activations"` // 当前激活数量
|
||||
RemainingActivations int `json:"remaining_activations"` // 剩余激活数量
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
EndDate string `json:"end_date"` // 到期时间
|
||||
}
|
||||
|
||||
// CuAuthorizationCodeListResponse 用户端授权码列表响应
|
||||
type CuAuthorizationCodeListResponse struct {
|
||||
List []CuAuthorizationCodeListItem `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// CuAuthorizationCodeSummaryResponse 用户端授权信息统计响应
|
||||
type CuAuthorizationCodeSummaryResponse struct {
|
||||
TotalCount int64 `json:"total_count"` // 总授权码数量
|
||||
ExpiredCount int64 `json:"expired_count"` // 已过期授权码数量
|
||||
ValidCount int64 `json:"valid_count"` // 有效授权码数量
|
||||
ValidMaxActivationsSum int64 `json:"valid_max_activations_sum"` // 有效授权码的 `max_activations` 之和
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package models
|
||||
|
||||
// ========== 客户端发票请求/响应结构体 ==========
|
||||
|
||||
// InvoiceCreateRequest 客户端申请发票请求结构
|
||||
// Content 非必填。
|
||||
type InvoiceCreateRequest struct {
|
||||
OrderID string `json:"order_id" binding:"required"` // 订单ID,必填
|
||||
InvoiceType string `json:"invoice_type" binding:"required,oneof=personal enterprise vat_special"` // 发票类型,必填,personal-个人/enterprise-企业普票/vat_special-增值税专用发票
|
||||
ReceiverEmail string `json:"receiver_email" binding:"required,email"` // 收票邮箱,必填,格式为邮箱地址
|
||||
Title string `json:"title" binding:"required,max=200"` // 发票抬头,必填,最多200字符
|
||||
TaxpayerID *string `json:"taxpayer_id,omitempty"` // 纳税人识别号,企业发票必填,个人发票可为空
|
||||
Content string `json:"content" binding:"omitempty,max=200"` // 开票内容,非必填,最多200字符
|
||||
Remark *string `json:"remark,omitempty"` // 备注,可选
|
||||
}
|
||||
|
||||
// InvoiceUpdateRequest 客户端修改发票请求结构
|
||||
type InvoiceUpdateRequest struct {
|
||||
InvoiceType *string `json:"invoice_type" binding:"omitempty,oneof=personal enterprise vat_special"` // 发票类型,非必填,personal-个人/enterprise-企业普票/vat_special-增值税专用发票
|
||||
ReceiverEmail *string `json:"receiver_email" binding:"omitempty,email"` // 收票邮箱,非必填,格式为邮箱地址
|
||||
Title *string `json:"title" binding:"omitempty,max=200"` // 发票抬头,非必填,最多200字符
|
||||
TaxpayerID *string `json:"taxpayer_id,omitempty"` // 纳税人识别号,企业发票必填,个人发票可为空
|
||||
Content *string `json:"content" binding:"omitempty,max=200"` // 开票内容,非必填,最多200字符
|
||||
Remark *string `json:"remark,omitempty"` // 备注,可选
|
||||
}
|
||||
|
||||
// InvoiceListRequest 发票列表查询请求结构
|
||||
// 注:apply_date 支持 YYYY-MM-DD 或 RFC3339 时间范围 "start,end"。
|
||||
type InvoiceListRequest struct {
|
||||
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
|
||||
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认10,最大100
|
||||
Status string `form:"status" binding:"omitempty"` // 状态筛选,pending-待处理/issued-已开票/rejected-已驳回
|
||||
Search string `form:"search" binding:"omitempty"` // 搜索关键字,支持发票号或订单号模糊匹配
|
||||
ApplyDate string `form:"apply_date" binding:"omitempty"` // 申请日期筛选,格式YYYY-MM-DD 或 RFC3339范围start,end
|
||||
CustomerID string `form:"customer_id" binding:"omitempty"` // 客户ID筛选(管理端)
|
||||
}
|
||||
|
||||
// InvoiceListResponse 发票列表响应结构
|
||||
type InvoiceListResponse struct {
|
||||
Invoices []*InvoiceResponse `json:"invoices"` // 发票列表
|
||||
TotalCount int64 `json:"total_count"` // 总记录数
|
||||
Page int `json:"page"` // 当前页码
|
||||
PageSize int `json:"page_size"` // 每页条数
|
||||
}
|
||||
|
||||
// InvoiceSummaryResponse 发票汇总统计响应结构
|
||||
type InvoiceSummaryResponse struct {
|
||||
TotalCount int64 `json:"total_count"` // 发票总数
|
||||
PendingCount int64 `json:"pending_count"` // 待处理发票数量
|
||||
IssuedCount int64 `json:"issued_count"` // 已开票发票数量
|
||||
RejectedCount int64 `json:"rejected_count"` // 已驳回发票数量
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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"`
|
||||
|
||||
// 非持久化字段:是否已经申请发票(由服务层填充)
|
||||
HasInvoiceApplied bool `gorm:"-" json:"-"`
|
||||
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
|
||||
}
|
||||
|
||||
// CuOrderListRequest 订单列表查询请求结构
|
||||
type CuOrderListRequest struct {
|
||||
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
|
||||
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认10,最大100
|
||||
Search string `form:"search" binding:"omitempty"` // 订单号或授权码模糊匹配
|
||||
Status string `form:"status" binding:"omitempty"` // 订单状态筛选
|
||||
Time string `form:"time" binding:"omitempty"` // 时间筛选:today/week/month/three_months 或 今天/本周/本月/近三个月
|
||||
}
|
||||
|
||||
// CuOrderResponse 订单响应结构
|
||||
type CuOrderResponse struct {
|
||||
ID string `json:"id"` // 订单ID
|
||||
OrderNo string `json:"order_no"` // 订单号,格式如ORD202601210123456789
|
||||
CustomerID string `json:"customer_id"` // 客户ID
|
||||
CuUserID string `json:"cu_user_id"` // 客户用户ID
|
||||
PackageID string `json:"package_id"` // 套餐ID
|
||||
PackageName string `json:"package_name"` // 套餐名称
|
||||
LicenseCount int `json:"license_count"` // 许可数量
|
||||
UnitPrice float64 `json:"unit_price"` // 单价,单位元
|
||||
DiscountRate float64 `json:"discount_rate"` // 折扣率,0.0-1.0之间
|
||||
TotalAmount float64 `json:"total_amount"` // 订单总金额,单位元
|
||||
Status string `json:"status"` // 订单状态,pending-待支付/paid-已支付
|
||||
AuthorizationCode *string `json:"authorization_code"` // 授权码,已支付订单生成
|
||||
ExpiredAt *time.Time `json:"expired_at"` // 订单过期时间
|
||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||
|
||||
// 是否已申请发票(根据 invoices 表判断)
|
||||
HasInvoiceApplied bool `json:"has_invoice_applied"`
|
||||
}
|
||||
|
||||
// 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,
|
||||
HasInvoiceApplied: o.HasInvoiceApplied,
|
||||
}
|
||||
}
|
||||
|
||||
// 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,216 @@
|
||||
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 客户用户注册请求结构
|
||||
// CuUserRegisterRequest 客户用户注册请求结构
|
||||
type CuUserRegisterRequest struct {
|
||||
Phone string `json:"phone" binding:"required,len=11"` // 手机号,必填,11位手机号
|
||||
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
|
||||
SmsCode string `json:"sms_code" binding:"required,len=6"` // 短信验证码,必填,6位数字
|
||||
CustomerID string `json:"customer_id" binding:"omitempty"` // 客户ID,可选,不提供则自动创建客户
|
||||
Password string `json:"password" binding:"required,min=8,max=50"` // 密码,必填,8-50位
|
||||
RealName *string `json:"real_name" binding:"omitempty,max=100"` // 真实姓名,可选,最大100字符
|
||||
Email *string `json:"email" binding:"omitempty,email,max=255"` // 邮箱,可选,格式为邮箱地址
|
||||
}
|
||||
|
||||
// CuUserLoginRequest 客户用户登录请求结构
|
||||
type CuUserLoginRequest struct {
|
||||
Phone string `json:"phone" binding:"required,min=7,max=20"` // 手机号,必填,7-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"` // 真实姓名,可选,最大100字符
|
||||
Email *string `json:"email" binding:"omitempty,email,max=255"` // 邮箱,可选,格式为邮箱地址
|
||||
AvatarURL *string `json:"avatar_url" binding:"omitempty,max=500"` // 头像URL,可选,最大500字符
|
||||
Language *string `json:"language" binding:"omitempty,oneof=zh-CN en-US ja-JP"` // 语言偏好,可选,zh-CN-中文/en-US-英文/ja-JP-日文
|
||||
Timezone *string `json:"timezone" binding:"omitempty,max=50"` // 时区,可选,最大50字符
|
||||
AdditionalInfo *string `json:"additional_info" binding:"omitempty"` // 附加信息,可选,JSON格式
|
||||
Remark *string `json:"remark" binding:"omitempty,max=1000"` // 备注,可选,最大1000字符
|
||||
}
|
||||
|
||||
// CuUserPhoneUpdateRequest 客户用户手机号更新请求结构
|
||||
type CuUserPhoneUpdateRequest struct {
|
||||
NewPhone string `json:"new_phone" binding:"required,len=11"` // 新手机号,必填,11位手机号
|
||||
NewPhoneCountryCode string `json:"new_phone_country_code" binding:"required"` // 新手机号国家区号,必填
|
||||
CurrentSmsCode string `json:"current_sms_code" binding:"required,len=6"` // 当前手机号验证码,必填,6位数字
|
||||
NewSmsCode string `json:"new_sms_code" binding:"required,len=6"` // 新手机号验证码,必填,6位数字
|
||||
}
|
||||
|
||||
// CuUserSendRegisterSmsRequest 注册发送验证码请求结构
|
||||
type CuUserSendRegisterSmsRequest struct {
|
||||
Phone string `json:"phone" binding:"required,min=7,max=20"` // 手机号,必填,7-20位
|
||||
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
|
||||
}
|
||||
|
||||
// CuUserSendLoginSmsRequest 发送登录验证码请求结构
|
||||
type CuUserSendLoginSmsRequest struct {
|
||||
Phone string `json:"phone" binding:"required,min=7,max=20"` // 手机号,必填,7-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"` // 新手机号,必填,7-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"` // 手机号,必填,7-20位
|
||||
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
|
||||
}
|
||||
|
||||
// CuUserResetPasswordRequest 重置密码请求结构
|
||||
type CuUserResetPasswordRequest struct {
|
||||
Phone string `json:"phone" binding:"required,min=7,max=20"` // 手机号,必填,7-20位
|
||||
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
|
||||
SmsCode string `json:"sms_code" binding:"required,len=6"` // 短信验证码,必填,6位数字
|
||||
NewPassword string `json:"new_password" binding:"required,min=8,max=50"` // 新密码,必填,8-50位
|
||||
}
|
||||
|
||||
// CuUserChangePasswordRequest 修改密码请求结构
|
||||
type CuUserChangePasswordRequest struct {
|
||||
OldPassword string `json:"old_password" binding:"required"` // 旧密码,必填
|
||||
NewPassword string `json:"new_password" binding:"required,min=8,max=50"` // 新密码,必填,8-50位
|
||||
}
|
||||
|
||||
// CuUserResponse 用户信息响应结构(用于API,避免暴露敏感字段)
|
||||
type CuUserResponse struct {
|
||||
ID string `json:"id"` // 用户ID
|
||||
CustomerID string `json:"customer_id"` // 客户ID
|
||||
Phone string `json:"phone"` // 手机号
|
||||
PhoneCountryCode string `json:"phone_country_code"` // 国家区号
|
||||
UserRole string `json:"user_role"` // 用户角色,admin-管理员/member-成员
|
||||
RealName *string `json:"real_name"` // 真实姓名
|
||||
Email *string `json:"email"` // 邮箱
|
||||
Status string `json:"status"` // 账户状态,active-激活/disabled-禁用
|
||||
PhoneVerified bool `json:"phone_verified"` // 手机号是否已验证
|
||||
EmailVerified bool `json:"email_verified"` // 邮箱是否已验证
|
||||
LastLoginAt *string `json:"last_login_at,omitempty"` // 最后登录时间
|
||||
LastLoginIP *string `json:"last_login_ip,omitempty"` // 最后登录IP
|
||||
AvatarURL *string `json:"avatar_url"` // 头像URL
|
||||
Language string `json:"language"` // 语言偏好
|
||||
Timezone string `json:"timezone"` // 时区
|
||||
AdditionalInfo *string `json:"additional_info"` // 附加信息,JSON格式
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LeadStatus 线索状态
|
||||
type LeadStatus string
|
||||
|
||||
const (
|
||||
LeadStatusPending LeadStatus = "pending" // 待联系
|
||||
LeadStatusContacted LeadStatus = "contacted" // 已联系
|
||||
LeadStatusConverted LeadStatus = "converted" // 已成交
|
||||
LeadStatusInvalid LeadStatus = "invalid" // 已失效
|
||||
)
|
||||
|
||||
// Lead 企业线索
|
||||
type Lead struct {
|
||||
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
|
||||
LeadNo string `gorm:"type:varchar(50);not null;uniqueIndex" json:"lead_no"`
|
||||
CompanyName string `gorm:"type:varchar(200);not null" json:"company_name"`
|
||||
ContactName string `gorm:"type:varchar(100);not null" json:"contact_name"`
|
||||
ContactPhone string `gorm:"type:varchar(20);not null" json:"contact_phone"`
|
||||
ContactEmail string `gorm:"type:varchar(100)" json:"contact_email"`
|
||||
Requirement string `gorm:"type:text;not null" json:"requirement"`
|
||||
ExtraInfo string `gorm:"type:text" json:"extra_info"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:'pending'" json:"status"`
|
||||
FollowUpDate *time.Time `gorm:"" json:"follow_up_date"`
|
||||
FollowUpRecord string `gorm:"type:text" json:"follow_up_record"`
|
||||
InternalNote string `gorm:"type:text" json:"internal_note"`
|
||||
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Lead) TableName() string {
|
||||
return "leads"
|
||||
}
|
||||
|
||||
// BeforeCreate 创建前自动设置ID和时间戳
|
||||
func (l *Lead) BeforeCreate(tx *gorm.DB) error {
|
||||
if l.ID == "" {
|
||||
l.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now()
|
||||
if l.CreatedAt.IsZero() {
|
||||
l.CreatedAt = now
|
||||
}
|
||||
if l.UpdatedAt.IsZero() {
|
||||
l.UpdatedAt = now
|
||||
}
|
||||
// 生成线索编号
|
||||
if l.LeadNo == "" {
|
||||
l.LeadNo = generateLeadNo()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateLeadNo 生成线索编号
|
||||
func generateLeadNo() string {
|
||||
return "LEAD" + time.Now().Format("20060102150405") + randomString(6)
|
||||
}
|
||||
|
||||
// randomString 生成随机字符串
|
||||
func randomString(length int) string {
|
||||
const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
result := make([]byte, length)
|
||||
now := time.Now().UnixNano()
|
||||
for i := range result {
|
||||
result[i] = charset[(now+int64(i)*17)%int64(len(charset))]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// ToResponse 转换为响应结构
|
||||
func (l *Lead) ToResponse() *LeadResponse {
|
||||
return &LeadResponse{
|
||||
ID: l.ID,
|
||||
LeadNo: l.LeadNo,
|
||||
CompanyName: l.CompanyName,
|
||||
ContactName: l.ContactName,
|
||||
ContactPhone: l.ContactPhone,
|
||||
ContactEmail: l.ContactEmail,
|
||||
Requirement: l.Requirement,
|
||||
ExtraInfo: l.ExtraInfo,
|
||||
Status: l.Status,
|
||||
FollowUpDate: l.FollowUpDate,
|
||||
FollowUpRecord: l.FollowUpRecord,
|
||||
InternalNote: l.InternalNote,
|
||||
CreatedAt: l.CreatedAt,
|
||||
UpdatedAt: l.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// LeadResponse 线索响应结构
|
||||
type LeadResponse struct {
|
||||
ID string `json:"id"` // 线索ID
|
||||
LeadNo string `json:"lead_no"` // 线索编号
|
||||
CompanyName string `json:"company_name"` // 公司名称
|
||||
ContactName string `json:"contact_name"` // 联系人
|
||||
ContactPhone string `json:"contact_phone"` // 联系电话
|
||||
ContactEmail string `json:"contact_email"` // 邮箱
|
||||
Requirement string `json:"requirement"` // 需求描述
|
||||
ExtraInfo string `json:"extra_info"` // 补充信息
|
||||
Status string `json:"status"` // 状态
|
||||
FollowUpDate *time.Time `json:"follow_up_date"` // 跟进日期(RFC3339,如 2026-02-05T10:00:00Z)
|
||||
FollowUpRecord string `json:"follow_up_record"` // 跟进记录
|
||||
InternalNote string `json:"internal_note"` // 内部备注
|
||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// LeadCreateRequest 创建线索请求
|
||||
type LeadCreateRequest struct {
|
||||
CompanyName string `json:"company_name" binding:"required,min=1,max=200"` // 公司名称
|
||||
ContactName string `json:"contact_name" binding:"required,min=1,max=100"` // 联系人
|
||||
ContactPhone string `json:"contact_phone" binding:"required,min=1,max=20"` // 联系电话
|
||||
ContactEmail string `json:"contact_email" binding:"omitempty,email,max=100"` // 邮箱
|
||||
Requirement string `json:"requirement" binding:"omitempty"` // 需求描述
|
||||
ExtraInfo string `json:"extra_info"` // 补充信息
|
||||
}
|
||||
|
||||
// LeadUpdateRequest 更新线索请求
|
||||
type LeadUpdateRequest struct {
|
||||
CompanyName string `json:"company_name" binding:"omitempty,min=1,max=200"` // 公司名称
|
||||
ContactName string `json:"contact_name" binding:"omitempty,min=1,max=100"` // 联系人
|
||||
ContactPhone string `json:"contact_phone" binding:"omitempty,min=1,max=20"` // 联系电话
|
||||
ContactEmail string `json:"contact_email" binding:"omitempty,email,max=100"` // 邮箱
|
||||
Requirement string `json:"requirement" binding:"omitempty,min=1"` // 需求描述
|
||||
ExtraInfo string `json:"extra_info"` // 补充信息
|
||||
Status string `json:"status" binding:"omitempty,oneof=pending contacted converted invalid"` // 状态
|
||||
FollowUpDate *time.Time `json:"follow_up_date"` // 跟进日期(RFC3339,如 2026-02-05T10:00:00Z)
|
||||
FollowUpRecord string `json:"follow_up_record"` // 跟进记录
|
||||
InternalNote string `json:"internal_note"` // 内部备注
|
||||
}
|
||||
|
||||
// LeadListRequest 线索列表请求
|
||||
type LeadListRequest struct {
|
||||
Page int `form:"page" binding:"omitempty,min=1"`
|
||||
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"`
|
||||
Search string `form:"search"` // 关键词检索
|
||||
Status string `form:"status"` // 状态筛选
|
||||
}
|
||||
|
||||
// LeadListResponse 线索列表响应
|
||||
type LeadListResponse struct {
|
||||
Leads []*LeadResponse `json:"leads"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// LeadSummaryResponse 线索汇总响应
|
||||
type LeadSummaryResponse struct {
|
||||
TotalCount int64 `json:"total_count"` // 线索总数
|
||||
PendingCount int64 `json:"pending_count"` // 待跟进数量
|
||||
ContactedCount int64 `json:"contacted_count"` // 已跟进数量
|
||||
ConvertedCount int64 `json:"converted_count"` // 已成交数量
|
||||
InvalidCount int64 `json:"invalid_count"` // 已流失数量
|
||||
}
|
||||
@@ -63,14 +63,14 @@ func (l *License) BeforeUpdate(tx *gorm.DB) error {
|
||||
|
||||
// LicenseListRequest 许可证列表查询请求结构
|
||||
type LicenseListRequest struct {
|
||||
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
|
||||
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认20,最大100
|
||||
AuthorizationCodeID string `form:"authorization_code_id" binding:"omitempty"` // 授权码ID筛选
|
||||
CustomerID string `form:"customer_id" binding:"omitempty"` // 客户ID筛选
|
||||
Status string `form:"status" binding:"omitempty,oneof=active inactive revoked"` // 状态筛选
|
||||
IsOnline *bool `form:"is_online" binding:"omitempty"` // 在线状态筛选
|
||||
Sort string `form:"sort" binding:"omitempty,oneof=created_at updated_at activated_at last_heartbeat"` // 排序字段,默认created_at
|
||||
Order string `form:"order" binding:"omitempty,oneof=asc desc"` // 排序方向,默认desc
|
||||
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
|
||||
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认20,最大100
|
||||
AuthorizationCodeID string `form:"authorization_code_id" binding:"omitempty"` // 授权码ID筛选
|
||||
CustomerID string `form:"customer_id" binding:"omitempty"` // 客户ID筛选
|
||||
Status string `form:"status" binding:"omitempty,oneof=active inactive revoked"` // 状态筛选
|
||||
IsOnline *string `form:"is_online" binding:"omitempty"` // 在线状态筛选
|
||||
Sort string `form:"sort" binding:"omitempty,oneof=created_at updated_at activated_at last_heartbeat"` // 排序字段,默认created_at
|
||||
Order string `form:"order" binding:"omitempty,oneof=asc desc"` // 排序方向,默认desc
|
||||
}
|
||||
|
||||
// LicenseListItem 许可证列表项结构
|
||||
@@ -169,17 +169,71 @@ type HeartbeatResponse struct {
|
||||
HeartbeatInterval int `json:"heartbeat_interval"` // 下次心跳间隔(秒)
|
||||
}
|
||||
|
||||
// StatsOverviewResponse 授权概览统计响应结构
|
||||
// StatsOverviewResponse stats overview API response
|
||||
type StatsOverviewResponse struct {
|
||||
TotalAuthCodes int64 `json:"total_auth_codes"` // 总授权码数量(所有授权码的累计总数)
|
||||
ActiveLicenses int64 `json:"active_licenses"` // 活跃许可证数量(状态为active的许可证总数)
|
||||
ExpiringSoon int64 `json:"expiring_soon"` // 即将过期数量(30天内即将过期的授权码,且未被锁定)
|
||||
AbnormalAlerts int64 `json:"abnormal_alerts"` // 异常告警数量(活跃许可证中心跳超时的数量,超过配置的心跳超时时间未收到心跳)
|
||||
GrowthRate GrowthRate `json:"growth_rate"` // 增长率(同比上月)
|
||||
// Stock metrics
|
||||
TotalAuthCodes int64 `json:"total_auth_codes"` // total authorization codes ever created
|
||||
ActiveLicenses int64 `json:"active_licenses"` // licenses with status=active
|
||||
|
||||
// Flow metrics
|
||||
TodayNewLicenses int64 `json:"today_new_licenses"` // licenses created today
|
||||
YesterdayNewLicenses int64 `json:"yesterday_new_licenses"` // licenses created yesterday (for comparison)
|
||||
MonthNewAuthCodes int64 `json:"month_new_auth_codes"` // auth codes created this calendar month
|
||||
|
||||
// Risk metrics
|
||||
ExpiringIn7Days int64 `json:"expiring_in_7days"` // auth codes expiring within 7 days, not locked
|
||||
ExpiringIn30Days int64 `json:"expiring_in_30days"` // auth codes expiring within 30 days, not locked
|
||||
AbnormalAlerts int64 `json:"abnormal_alerts"` // active licenses with heartbeat timeout
|
||||
|
||||
// Growth rates shown as sub-text, not standalone cards
|
||||
GrowthRate GrowthRate `json:"growth_rate"`
|
||||
}
|
||||
|
||||
// GrowthRate 增长率结构(同比上月:当前值相比一个月前同一时刻值的增长率)
|
||||
// GrowthRate month-over-month growth rates (shown as sub-text on dashboard cards)
|
||||
type GrowthRate struct {
|
||||
AuthCodes float64 `json:"auth_codes"` // 授权码增长率(%)(当前总授权码数相比一个月前总授权码数的增长率)
|
||||
Licenses float64 `json:"licenses"` // 许可证增长率(%)(当前活跃许可证数相比一个月前活跃许可证数的增长率)
|
||||
AuthCodesMoM float64 `json:"auth_codes_mom"` // auth codes MoM growth rate (%)
|
||||
LicensesMoM float64 `json:"licenses_mom"` // active licenses MoM growth rate (%)
|
||||
}
|
||||
|
||||
// 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 *string `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,152 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PackageType 套餐类型
|
||||
type PackageType string
|
||||
|
||||
const (
|
||||
PackageTypeTrial PackageType = "trial" // 试用版
|
||||
PackageTypeBasic PackageType = "basic" // 基础版
|
||||
PackageTypeProfessional PackageType = "professional" // 专业版
|
||||
PackageTypeCustom PackageType = "custom" // 定制版
|
||||
)
|
||||
|
||||
// Package 套餐模型
|
||||
type Package struct {
|
||||
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
|
||||
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
||||
Type string `gorm:"type:varchar(20);not null" json:"type"`
|
||||
Price float64 `gorm:"type:decimal(10,2);not null;default:0" json:"price"`
|
||||
PriceDescription string `gorm:"type:varchar(100);default:''" json:"price_description"`
|
||||
DurationDescription string `gorm:"type:varchar(200);default:''" json:"duration_description"`
|
||||
Description string `gorm:"type:varchar(500);default:''" json:"description"`
|
||||
Features string `gorm:"type:text" json:"features"` // JSON数组格式
|
||||
Status int `gorm:"type:tinyint(1);not null;default:1" json:"status"`
|
||||
SortOrder int `gorm:"type:int;not null;default:0" json:"sort_order"`
|
||||
Remark string `gorm:"type:varchar(500);default:''" json:"remark"`
|
||||
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Package) TableName() string {
|
||||
return "packages"
|
||||
}
|
||||
|
||||
// BeforeCreate 创建前自动设置ID和时间戳
|
||||
func (p *Package) BeforeCreate(tx *gorm.DB) error {
|
||||
if p.ID == "" {
|
||||
p.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now()
|
||||
if p.CreatedAt.IsZero() {
|
||||
p.CreatedAt = now
|
||||
}
|
||||
if p.UpdatedAt.IsZero() {
|
||||
p.UpdatedAt = now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate 更新前自动设置时间戳
|
||||
func (p *Package) BeforeUpdate(tx *gorm.DB) error {
|
||||
p.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToResponse 转换为响应结构
|
||||
func (p *Package) ToResponse() *PackageResponse {
|
||||
return &PackageResponse{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Type: p.Type,
|
||||
Price: p.Price,
|
||||
PriceDescription: p.PriceDescription,
|
||||
DurationDescription: p.DurationDescription,
|
||||
Description: p.Description,
|
||||
Features: p.Features,
|
||||
Status: p.Status,
|
||||
SortOrder: p.SortOrder,
|
||||
Remark: p.Remark,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// PackageResponse 套餐响应结构
|
||||
type PackageResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Price float64 `json:"price"`
|
||||
PriceDescription string `json:"price_description"`
|
||||
DurationDescription string `json:"duration_description"`
|
||||
Description string `json:"description"`
|
||||
Features string `json:"features"`
|
||||
Status int `json:"status"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PackageCreateRequest 创建套餐请求
|
||||
type PackageCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=100"`
|
||||
Type string `json:"type" binding:"required,oneof=trial basic professional custom"`
|
||||
Price float64 `json:"price" binding:"gte=0"`
|
||||
PriceDescription string `json:"price_description" binding:"max=100"`
|
||||
DurationDescription string `json:"duration_description" binding:"max=200"`
|
||||
Description string `json:"description" binding:"max=500"`
|
||||
Features string `json:"features"` // JSON格式
|
||||
Status int `json:"status" binding:"oneof=0 1"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Remark string `json:"remark" binding:"max=500"`
|
||||
}
|
||||
|
||||
// PackageUpdateRequest 更新套餐请求
|
||||
type PackageUpdateRequest struct {
|
||||
Name string `json:"name" binding:"omitempty,min=1,max=100"`
|
||||
Type string `json:"type" binding:"omitempty,oneof=trial basic professional custom"`
|
||||
Price float64 `json:"price" binding:"omitempty,gte=0"`
|
||||
PriceDescription string `json:"price_description" binding:"omitempty,max=100"`
|
||||
DurationDescription string `json:"duration_description" binding:"omitempty,max=200"`
|
||||
Description string `json:"description" binding:"omitempty,max=500"`
|
||||
Features string `json:"features"` // JSON格式
|
||||
Status *int `json:"status" binding:"omitempty,oneof=0 1"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
Remark string `json:"remark" binding:"omitempty,max=500"`
|
||||
}
|
||||
|
||||
// PackageListRequest 套餐列表请求
|
||||
type PackageListRequest struct {
|
||||
Type string `form:"type"` // 套餐类型筛选
|
||||
Status *int `form:"status"` // 状态筛选
|
||||
}
|
||||
|
||||
// PackageListResponse 套餐列表响应
|
||||
type PackageListResponse struct {
|
||||
Packages []*PackageResponse `json:"packages"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
}
|
||||
|
||||
// CuPackageResponse 用户端套餐响应(兼容原有结构)
|
||||
type CuPackageResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Price float64 `json:"price"`
|
||||
DisplayPrice string `json:"display_price"`
|
||||
PriceDescription string `json:"price_description"`
|
||||
MaxDevices int `json:"max_devices"` // 从features中解析
|
||||
Description string `json:"description"`
|
||||
Features string `json:"features"`
|
||||
Details string `json:"details"`
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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"`
|
||||
ExtraData *string `json:"extra_data,omitempty"`
|
||||
}
|
||||
|
||||
// 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"` // 支付状态,pending-待支付/paid-已支付/cancelled-已取消/expired-已过期/failed-支付失败
|
||||
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" // 银行卡
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ========== 共享发票数据模型 ==========
|
||||
|
||||
// Invoice 发票模型
|
||||
type Invoice struct {
|
||||
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
|
||||
InvoiceNo string `gorm:"type:varchar(50);not null;uniqueIndex" json:"invoice_no"`
|
||||
OrderID string `gorm:"type:varchar(36);not null" json:"order_id"`
|
||||
OrderNo string `gorm:"type:varchar(50);not null;index" 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"`
|
||||
Amount float64 `gorm:"type:decimal(10,2);not null" json:"amount"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:'pending';index" json:"status"`
|
||||
InvoiceType string `gorm:"type:varchar(20);not null" json:"invoice_type"`
|
||||
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||
TaxpayerID *string `gorm:"type:varchar(50)" json:"taxpayer_id"`
|
||||
Content string `gorm:"type:varchar(200);not null" json:"content"`
|
||||
ReceiverEmail string `gorm:"type:varchar(255);not null" json:"receiver_email"`
|
||||
Remark *string `gorm:"type:varchar(1000)" json:"remark"`
|
||||
InvoiceFileURL *string `gorm:"type:varchar(500)" json:"invoice_file_url"`
|
||||
UploadedAt *time.Time `gorm:"" json:"uploaded_at"`
|
||||
UploadedBy *string `gorm:"type:varchar(36)" json:"uploaded_by"`
|
||||
IssuedAt *time.Time `gorm:"" json:"issued_at"`
|
||||
RejectReason *string `gorm:"type:varchar(500)" json:"reject_reason"`
|
||||
Suggestion *string `gorm:"type:varchar(500)" json:"suggestion"`
|
||||
RejectedAt *time.Time `gorm:"" json:"rejected_at"`
|
||||
RejectedBy *string `gorm:"type:varchar(36)" json:"rejected_by"`
|
||||
DownloadToken *string `gorm:"type:varchar(64)" json:"download_token"`
|
||||
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 (Invoice) TableName() string {
|
||||
return "invoices"
|
||||
}
|
||||
|
||||
// BeforeCreate 创建前自动设置时间戳和ID
|
||||
func (i *Invoice) BeforeCreate(tx *gorm.DB) error {
|
||||
// 生成UUID作为主键ID
|
||||
if i.ID == "" {
|
||||
i.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
// 设置时间戳
|
||||
now := time.Now()
|
||||
if i.CreatedAt.IsZero() {
|
||||
i.CreatedAt = now
|
||||
}
|
||||
if i.UpdatedAt.IsZero() {
|
||||
i.UpdatedAt = now
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate 更新前自动设置时间戳
|
||||
func (i *Invoice) BeforeUpdate(tx *gorm.DB) error {
|
||||
i.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========== 发票枚举常量 ==========
|
||||
|
||||
// 发票状态枚举
|
||||
const (
|
||||
InvoiceStatusPending = "pending" // 待处理
|
||||
InvoiceStatusIssued = "issued" // 已开票
|
||||
InvoiceStatusRejected = "rejected" // 已驳回
|
||||
)
|
||||
|
||||
// 发票类型枚举
|
||||
const (
|
||||
InvoiceTypePersonal = "personal" // 个人
|
||||
InvoiceTypeEnterprise = "enterprise" // 企业普票
|
||||
InvoiceTypeVATSpecial = "vat_special" // 增值税专用发票
|
||||
)
|
||||
|
||||
// ========== 共享响应结构体 ==========
|
||||
|
||||
// InvoiceResponse 发票响应结构
|
||||
type InvoiceResponse struct {
|
||||
ID string `json:"id"` // 发票ID
|
||||
InvoiceNo string `json:"invoice_no"` // 发票申请号,格式如INV202601210123456789
|
||||
OrderID string `json:"order_id"` // 关联的订单ID
|
||||
OrderNo string `json:"order_no"` // 关联的订单号
|
||||
CustomerID string `json:"customer_id"` // 客户ID
|
||||
CuUserID string `json:"cu_user_id"` // 申请人客户用户ID
|
||||
CuUserPhone string `json:"cu_user_phone"` // 申请人客户用户手机号
|
||||
Amount float64 `json:"amount"` // 发票金额,单位元,保留2位小数
|
||||
Status string `json:"status"` // 发票状态,pending-待处理/issued-已开票/rejected-已驳回
|
||||
StatusDisplay string `json:"status_display"` // 发票状态显示文本,根据当前语言显示
|
||||
InvoiceType string `json:"invoice_type"` // 发票类型,personal-个人/enterprise-企业普票/vat_special-增值税专用发票
|
||||
InvoiceTypeDisplay string `json:"invoice_type_display"` // 发票类型显示文本,根据当前语言显示
|
||||
Title string `json:"title"` // 发票抬头
|
||||
TaxpayerID *string `json:"taxpayer_id"` // 纳税人识别号,企业发票填写
|
||||
Content string `json:"content"` // 开票内容
|
||||
ReceiverEmail string `json:"receiver_email"` // 收票邮箱
|
||||
Remark *string `json:"remark"` // 备注信息
|
||||
InvoiceFileURL *string `json:"invoice_file_url"` // 发票文件下载URL,已开票后填写
|
||||
UploadedAt *time.Time `json:"uploaded_at"` // 发票文件上传时间
|
||||
UploadedBy *string `json:"uploaded_by"` // 发票文件上传人ID(管理员)
|
||||
IssuedAt *time.Time `json:"issued_at"` // 发票开票完成时间
|
||||
RejectReason *string `json:"reject_reason"` // 驳回原因,已驳回时填写
|
||||
Suggestion *string `json:"suggestion"` // 修改建议,已驳回时填写
|
||||
RejectedAt *time.Time `json:"rejected_at"` // 驳回时间
|
||||
RejectedBy *string `json:"rejected_by"` // 驳回人ID(管理员)
|
||||
DownloadToken *string `json:"download_token"` // 下载令牌,用于邮件链接下载
|
||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// ToResponse 转换为响应结构(不包含多语言显示字段)
|
||||
func (i *Invoice) ToResponse() *InvoiceResponse {
|
||||
return &InvoiceResponse{
|
||||
ID: i.ID,
|
||||
InvoiceNo: i.InvoiceNo,
|
||||
OrderID: i.OrderID,
|
||||
OrderNo: i.OrderNo,
|
||||
CustomerID: i.CustomerID,
|
||||
CuUserID: i.CuUserID,
|
||||
Amount: i.Amount,
|
||||
Status: i.Status,
|
||||
InvoiceType: i.InvoiceType,
|
||||
Title: i.Title,
|
||||
TaxpayerID: i.TaxpayerID,
|
||||
Content: i.Content,
|
||||
ReceiverEmail: i.ReceiverEmail,
|
||||
Remark: i.Remark,
|
||||
InvoiceFileURL: i.InvoiceFileURL,
|
||||
UploadedAt: i.UploadedAt,
|
||||
UploadedBy: i.UploadedBy,
|
||||
IssuedAt: i.IssuedAt,
|
||||
RejectReason: i.RejectReason,
|
||||
Suggestion: i.Suggestion,
|
||||
RejectedAt: i.RejectedAt,
|
||||
RejectedBy: i.RejectedBy,
|
||||
DownloadToken: i.DownloadToken,
|
||||
CreatedAt: i.CreatedAt,
|
||||
UpdatedAt: i.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"license-manager/internal/models"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminInvoiceRepository 管理端发票仓储接口
|
||||
type AdminInvoiceRepository interface {
|
||||
// ========== 管理端专用方法 ==========
|
||||
GetByCustomerID(customerID string, offset, limit int) ([]*models.Invoice, int64, error)
|
||||
GetAdminList(ctx context.Context, req *models.InvoiceListRequest, createdAtStart, createdAtEnd *time.Time) ([]*models.Invoice, int64, error)
|
||||
GetAdminInvoiceSummary(ctx context.Context) (*models.InvoiceSummaryResponse, error)
|
||||
|
||||
// ========== 通用工具方法 ==========
|
||||
GetByID(id string) (*models.Invoice, error)
|
||||
GetByInvoiceNo(invoiceNo string) (*models.Invoice, error)
|
||||
GetByOrderID(orderID string) (*models.Invoice, error)
|
||||
Update(invoice *models.Invoice) error
|
||||
Delete(id string) error
|
||||
GetByDownloadToken(token string) (*models.Invoice, error)
|
||||
UpdateDownloadToken(id string, token *string) error
|
||||
}
|
||||
|
||||
type adminInvoiceRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAdminInvoiceRepository(db *gorm.DB) AdminInvoiceRepository {
|
||||
return &adminInvoiceRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *adminInvoiceRepository) GetByID(id string) (*models.Invoice, error) {
|
||||
var invoice models.Invoice
|
||||
err := r.db.Where("id = ? AND deleted_at IS NULL", id).First(&invoice).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &invoice, nil
|
||||
}
|
||||
|
||||
func (r *adminInvoiceRepository) GetByInvoiceNo(invoiceNo string) (*models.Invoice, error) {
|
||||
var invoice models.Invoice
|
||||
err := r.db.Where("invoice_no = ? AND deleted_at IS NULL", invoiceNo).First(&invoice).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &invoice, nil
|
||||
}
|
||||
|
||||
func (r *adminInvoiceRepository) GetByOrderID(orderID string) (*models.Invoice, error) {
|
||||
var invoice models.Invoice
|
||||
err := r.db.Where("order_id = ? AND deleted_at IS NULL", orderID).First(&invoice).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &invoice, nil
|
||||
}
|
||||
|
||||
func (r *adminInvoiceRepository) GetByCustomerID(customerID string, offset, limit int) ([]*models.Invoice, int64, error) {
|
||||
var invoices []*models.Invoice
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&models.Invoice{}).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(&invoices).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return invoices, total, nil
|
||||
}
|
||||
|
||||
func (r *adminInvoiceRepository) GetAdminList(ctx context.Context, req *models.InvoiceListRequest, createdAtStart, createdAtEnd *time.Time) ([]*models.Invoice, int64, error) {
|
||||
var invoices []*models.Invoice
|
||||
var total int64
|
||||
|
||||
// 默认值
|
||||
page := 1
|
||||
pageSize := 10
|
||||
search := ""
|
||||
status := ""
|
||||
customerID := ""
|
||||
if req != nil {
|
||||
if req.Page > 0 {
|
||||
page = req.Page
|
||||
}
|
||||
if req.PageSize > 0 {
|
||||
pageSize = req.PageSize
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
search = strings.TrimSpace(req.Search)
|
||||
status = strings.TrimSpace(req.Status)
|
||||
customerID = strings.TrimSpace(req.CustomerID)
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&models.Invoice{}).Where("deleted_at IS NULL")
|
||||
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
if customerID != "" {
|
||||
query = query.Where("customer_id = ?", customerID)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
query = query.Where("(invoice_no LIKE ? OR order_no LIKE ?)", like, like)
|
||||
}
|
||||
|
||||
if createdAtStart != nil && createdAtEnd != nil {
|
||||
query = query.Where("created_at >= ? AND created_at < ?", *createdAtStart, *createdAtEnd)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 获取分页数据
|
||||
err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&invoices).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return invoices, total, nil
|
||||
}
|
||||
|
||||
func (r *adminInvoiceRepository) Update(invoice *models.Invoice) error {
|
||||
return r.db.Save(invoice).Error
|
||||
}
|
||||
|
||||
func (r *adminInvoiceRepository) Delete(id string) error {
|
||||
return r.db.Where("id = ?", id).Delete(&models.Invoice{}).Error
|
||||
}
|
||||
|
||||
func (r *adminInvoiceRepository) GetAdminInvoiceSummary(ctx context.Context) (*models.InvoiceSummaryResponse, error) {
|
||||
// 查询发票总数
|
||||
var totalCount int64
|
||||
err := r.db.Model(&models.Invoice{}).
|
||||
Where("deleted_at IS NULL").
|
||||
Count(&totalCount).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 查询待处理发票数
|
||||
var pendingCount int64
|
||||
err = r.db.Model(&models.Invoice{}).
|
||||
Where("status = ? AND deleted_at IS NULL", models.InvoiceStatusPending).
|
||||
Count(&pendingCount).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 查询已开票数
|
||||
var issuedCount int64
|
||||
err = r.db.Model(&models.Invoice{}).
|
||||
Where("status = ? AND deleted_at IS NULL", models.InvoiceStatusIssued).
|
||||
Count(&issuedCount).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 查询已驳回数
|
||||
var rejectedCount int64
|
||||
err = r.db.Model(&models.Invoice{}).
|
||||
Where("status = ? AND deleted_at IS NULL", models.InvoiceStatusRejected).
|
||||
Count(&rejectedCount).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.InvoiceSummaryResponse{
|
||||
TotalCount: totalCount,
|
||||
PendingCount: pendingCount,
|
||||
IssuedCount: issuedCount,
|
||||
RejectedCount: rejectedCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *adminInvoiceRepository) GetByDownloadToken(token string) (*models.Invoice, error) {
|
||||
var invoice models.Invoice
|
||||
err := r.db.Where("download_token = ? AND deleted_at IS NULL", token).First(&invoice).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &invoice, nil
|
||||
}
|
||||
|
||||
func (r *adminInvoiceRepository) UpdateDownloadToken(id string, token *string) error {
|
||||
return r.db.Model(&models.Invoice{}).Where("id = ?", id).Update("download_token", token).Error
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"license-manager/internal/models"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -64,10 +65,10 @@ func (r *authorizationCodeRepository) GetAuthorizationCodeList(ctx context.Conte
|
||||
ac.start_date, ac.end_date, ac.max_activations,
|
||||
COALESCE(l.active_count, 0) AS current_activations,
|
||||
ac.deployment_type, ac.is_locked, ac.description, ac.created_at,
|
||||
CASE
|
||||
CASE
|
||||
WHEN ac.is_locked = true THEN 'locked'
|
||||
WHEN ac.end_date < NOW() THEN 'expired'
|
||||
WHEN ac.start_date <= NOW() AND ac.end_date >= NOW() THEN 'normal'
|
||||
WHEN ac.end_date >= NOW() THEN 'normal'
|
||||
ELSE 'expired'
|
||||
END AS status`).
|
||||
Joins("LEFT JOIN customers c ON ac.customer_id = c.id AND c.deleted_at IS NULL").
|
||||
@@ -91,7 +92,7 @@ func (r *authorizationCodeRepository) GetAuthorizationCodeList(ctx context.Conte
|
||||
case "expired":
|
||||
query = query.Where("ac.is_locked = false AND ac.end_date < NOW()")
|
||||
case "normal":
|
||||
query = query.Where("ac.is_locked = false AND ac.start_date <= NOW() AND ac.end_date >= NOW()")
|
||||
query = query.Where("ac.is_locked = false AND ac.end_date >= NOW()")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +102,10 @@ func (r *authorizationCodeRepository) GetAuthorizationCodeList(ctx context.Conte
|
||||
if req.EndDate != "" {
|
||||
query = query.Where("ac.created_at <= ?", req.EndDate)
|
||||
}
|
||||
if strings.TrimSpace(req.Code) != "" {
|
||||
like := "%" + strings.TrimSpace(req.Code) + "%"
|
||||
query = query.Where("ac.code LIKE ?", like)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
var total int64
|
||||
@@ -167,6 +172,140 @@ func (r *authorizationCodeRepository) GetAuthorizationCodeList(ctx context.Conte
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCuAuthorizationCodeList 用户端:查询用户授权码列表
|
||||
func (r *authorizationCodeRepository) GetCuAuthorizationCodeList(ctx context.Context, customerID string, req *models.CuAuthorizationCodeListRequest) (*models.CuAuthorizationCodeListResponse, error) {
|
||||
page := 1
|
||||
pageSize := 10
|
||||
status := ""
|
||||
search := ""
|
||||
if req != nil {
|
||||
if req.Page > 0 {
|
||||
page = req.Page
|
||||
}
|
||||
if req.PageSize > 0 {
|
||||
pageSize = req.PageSize
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
status = req.Status
|
||||
search = req.Search
|
||||
}
|
||||
|
||||
query := r.db.WithContext(ctx).Table("authorization_codes ac").
|
||||
Select(`ac.id, ac.code, ac.created_at, ac.end_date, ac.max_activations,
|
||||
COALESCE(l.active_count, 0) AS current_activations,
|
||||
CASE
|
||||
WHEN ac.is_locked = true THEN 'locked'
|
||||
WHEN ac.end_date < NOW() THEN 'expired'
|
||||
WHEN ac.end_date >= NOW() THEN 'normal'
|
||||
ELSE 'expired'
|
||||
END AS status`).
|
||||
Joins(`LEFT JOIN (
|
||||
SELECT authorization_code_id, COUNT(*) AS active_count
|
||||
FROM licenses
|
||||
WHERE status = 'active' AND deleted_at IS NULL
|
||||
GROUP BY authorization_code_id
|
||||
) l ON ac.id = l.authorization_code_id`).
|
||||
Where("ac.customer_id = ?", customerID)
|
||||
|
||||
if status != "" {
|
||||
switch status {
|
||||
case "locked":
|
||||
query = query.Where("ac.end_date >= NOW() AND ac.is_locked = true")
|
||||
case "expired":
|
||||
query = query.Where("ac.end_date < NOW()")
|
||||
case "normal":
|
||||
query = query.Where("ac.end_date >= NOW() AND ac.is_locked = false")
|
||||
}
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
query = query.Where("ac.code LIKE ?", like)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
var results []struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
EndDate time.Time `json:"end_date"`
|
||||
MaxActivations int `json:"max_activations"`
|
||||
CurrentActivations int `json:"current_activations" gorm:"column:current_activations"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
if err := query.Order("ac.created_at DESC").Limit(pageSize).Offset(offset).Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := make([]models.CuAuthorizationCodeListItem, len(results))
|
||||
for i, item := range results {
|
||||
remaining := item.MaxActivations - item.CurrentActivations
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
list[i] = models.CuAuthorizationCodeListItem{
|
||||
ID: item.ID,
|
||||
Code: item.Code,
|
||||
Status: item.Status,
|
||||
MaxActivations: item.MaxActivations,
|
||||
CurrentActivations: item.CurrentActivations,
|
||||
RemainingActivations: remaining,
|
||||
CreatedAt: item.CreatedAt.Format(time.RFC3339),
|
||||
EndDate: item.EndDate.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
|
||||
return &models.CuAuthorizationCodeListResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCuAuthorizationCodeSummary 用户端:授权信息统计
|
||||
func (r *authorizationCodeRepository) GetCuAuthorizationCodeSummary(ctx context.Context, customerID string) (*models.CuAuthorizationCodeSummaryResponse, error) {
|
||||
var result struct {
|
||||
TotalCount int64 `gorm:"column:total_count"`
|
||||
ExpiredCount int64 `gorm:"column:expired_count"`
|
||||
ValidCount int64 `gorm:"column:valid_count"`
|
||||
ValidMaxActivationsSum int64 `gorm:"column:valid_max_activations_sum"`
|
||||
}
|
||||
|
||||
// 计算授权码统计信息
|
||||
err := r.db.WithContext(ctx).Table("authorization_codes ac").
|
||||
Select(`
|
||||
COUNT(*) AS total_count,
|
||||
SUM(CASE WHEN (ac.end_date < NOW() OR ac.is_locked = true) THEN 1 ELSE 0 END) AS expired_count,
|
||||
SUM(CASE WHEN (ac.end_date >= NOW() AND ac.is_locked = false) THEN 1 ELSE 0 END) AS valid_count,
|
||||
SUM(CASE WHEN (ac.end_date >= NOW() AND ac.is_locked = false)
|
||||
THEN ac.max_activations
|
||||
ELSE 0 END) AS valid_max_activations_sum
|
||||
`).
|
||||
Where("ac.customer_id = ?", customerID).
|
||||
Scan(&result).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.CuAuthorizationCodeSummaryResponse{
|
||||
TotalCount: result.TotalCount,
|
||||
ExpiredCount: result.ExpiredCount,
|
||||
ValidCount: result.ValidCount,
|
||||
ValidMaxActivationsSum: result.ValidMaxActivationsSum,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateAuthorizationCode 更新授权码
|
||||
func (r *authorizationCodeRepository) UpdateAuthorizationCode(ctx context.Context, authCode *models.AuthorizationCode) error {
|
||||
return r.db.WithContext(ctx).Save(authCode).Error
|
||||
@@ -297,3 +436,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,156 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"license-manager/internal/models"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CuInvoiceRepository 客户端发票仓储接口
|
||||
type CuInvoiceRepository interface {
|
||||
// ========== 客户端专用方法 ==========
|
||||
GetByCuUserID(ctx context.Context, cuUserID string, req *models.InvoiceListRequest, createdAtStart, createdAtEnd *time.Time) ([]*models.Invoice, int64, error)
|
||||
GetCuUserInvoiceSummary(ctx context.Context, cuUserID string) (*models.InvoiceSummaryResponse, error)
|
||||
|
||||
// ========== 通用工具方法 ==========
|
||||
Create(invoice *models.Invoice) error
|
||||
Update(invoice *models.Invoice) error
|
||||
GetByID(id string) (*models.Invoice, error)
|
||||
CheckOrderInvoiceExists(orderID string) (bool, error)
|
||||
}
|
||||
|
||||
type cuInvoiceRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCuInvoiceRepository(db *gorm.DB) CuInvoiceRepository {
|
||||
return &cuInvoiceRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *cuInvoiceRepository) Create(invoice *models.Invoice) error {
|
||||
return r.db.Create(invoice).Error
|
||||
}
|
||||
|
||||
func (r *cuInvoiceRepository) Update(invoice *models.Invoice) error {
|
||||
// 使用 Select 指定更新字段,确保可以清空某些字段(虽然此处目前主要是业务更新和状态重置)
|
||||
// 或者直接使用 Save 更新全量字段
|
||||
return r.db.Save(invoice).Error
|
||||
}
|
||||
|
||||
func (r *cuInvoiceRepository) GetByID(id string) (*models.Invoice, error) {
|
||||
var invoice models.Invoice
|
||||
err := r.db.Where("id = ? AND deleted_at IS NULL", id).First(&invoice).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &invoice, nil
|
||||
}
|
||||
|
||||
func (r *cuInvoiceRepository) GetByCuUserID(ctx context.Context, cuUserID string, req *models.InvoiceListRequest, createdAtStart, createdAtEnd *time.Time) ([]*models.Invoice, int64, error) {
|
||||
var invoices []*models.Invoice
|
||||
var total int64
|
||||
|
||||
// 默认值
|
||||
page := 1
|
||||
pageSize := 10
|
||||
search := ""
|
||||
status := ""
|
||||
if req != nil {
|
||||
if req.Page > 0 {
|
||||
page = req.Page
|
||||
}
|
||||
if req.PageSize > 0 {
|
||||
pageSize = req.PageSize
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
search = strings.TrimSpace(req.Search)
|
||||
status = strings.TrimSpace(req.Status)
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&models.Invoice{}).Where("cu_user_id = ? AND deleted_at IS NULL", cuUserID)
|
||||
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
query = query.Where("(invoice_no LIKE ? OR order_no LIKE ?)", like, like)
|
||||
}
|
||||
|
||||
if createdAtStart != nil && createdAtEnd != nil {
|
||||
query = query.Where("created_at >= ? AND created_at < ?", *createdAtStart, *createdAtEnd)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 获取分页数据
|
||||
err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&invoices).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return invoices, total, nil
|
||||
}
|
||||
|
||||
func (r *cuInvoiceRepository) GetCuUserInvoiceSummary(ctx context.Context, cuUserID string) (*models.InvoiceSummaryResponse, error) {
|
||||
// 查询发票总数
|
||||
var totalCount int64
|
||||
err := r.db.Model(&models.Invoice{}).
|
||||
Where("cu_user_id = ? AND deleted_at IS NULL", cuUserID).
|
||||
Count(&totalCount).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 查询待处理发票数
|
||||
var pendingCount int64
|
||||
err = r.db.Model(&models.Invoice{}).
|
||||
Where("cu_user_id = ? AND status = ? AND deleted_at IS NULL", cuUserID, models.InvoiceStatusPending).
|
||||
Count(&pendingCount).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 查询已开票数
|
||||
var issuedCount int64
|
||||
err = r.db.Model(&models.Invoice{}).
|
||||
Where("cu_user_id = ? AND status = ? AND deleted_at IS NULL", cuUserID, models.InvoiceStatusIssued).
|
||||
Count(&issuedCount).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 查询已驳回数
|
||||
var rejectedCount int64
|
||||
err = r.db.Model(&models.Invoice{}).
|
||||
Where("cu_user_id = ? AND status = ? AND deleted_at IS NULL", cuUserID, models.InvoiceStatusRejected).
|
||||
Count(&rejectedCount).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.InvoiceSummaryResponse{
|
||||
TotalCount: totalCount,
|
||||
PendingCount: pendingCount,
|
||||
IssuedCount: issuedCount,
|
||||
RejectedCount: rejectedCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *cuInvoiceRepository) CheckOrderInvoiceExists(orderID string) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.Invoice{}).
|
||||
Where("order_id = ? AND deleted_at IS NULL", orderID).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"license-manager/internal/models"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CuOrderRepository interface {
|
||||
Create(order *models.CuOrder) error
|
||||
GetByID(id string) (*models.CuOrder, error)
|
||||
GetByOrderNo(orderNo string) (*models.CuOrder, error)
|
||||
GetByCuUserID(ctx context.Context, cuUserID string, req *models.CuOrderListRequest, createdAtStart, createdAtEnd *time.Time) ([]*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(ctx context.Context, cuUserID string, req *models.CuOrderListRequest, createdAtStart, createdAtEnd *time.Time) ([]*models.CuOrder, int64, error) {
|
||||
var orders []*models.CuOrder
|
||||
var total int64
|
||||
|
||||
// 默认值
|
||||
page := 1
|
||||
pageSize := 10
|
||||
search := ""
|
||||
status := ""
|
||||
if req != nil {
|
||||
if req.Page > 0 {
|
||||
page = req.Page
|
||||
}
|
||||
if req.PageSize > 0 {
|
||||
pageSize = req.PageSize
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
search = strings.TrimSpace(req.Search)
|
||||
status = strings.TrimSpace(req.Status)
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&models.CuOrder{}).Where("cu_user_id = ? AND deleted_at IS NULL", cuUserID)
|
||||
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
query = query.Where("(order_no LIKE ? OR authorization_code LIKE ?)", like, like)
|
||||
}
|
||||
|
||||
if createdAtStart != nil && createdAtEnd != nil {
|
||||
query = query.Where("created_at >= ? AND created_at < ?", *createdAtStart, *createdAtEnd)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 获取分页数据
|
||||
err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).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 {
|
||||
// 使用 Unscoped() 进行物理删除而不是软删除
|
||||
return r.db.Unscoped().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 00:00:00"
|
||||
endOfMonth := currentMonth + "-31 23:59:59"
|
||||
|
||||
err := r.db.Model(&models.CuOrder{}).
|
||||
Where("cu_orders.cu_user_id = ? AND (cu_orders.status = 'paid' OR cu_orders.status = 'pending') AND cu_orders.created_at >= ? AND cu_orders.created_at <= ? AND cu_orders.deleted_at IS NULL",
|
||||
cuUserID, startOfMonth, endOfMonth).
|
||||
// We need to join with package to check package type because package_id might be a UUID, not 'trial'.
|
||||
// But if the order already recorded the type somewhere or if we just assume 'trial' is the package ID?
|
||||
// Actually, let's just use a subquery or join to verify package type is trial.
|
||||
Joins("JOIN packages ON packages.id = cu_orders.package_id").
|
||||
Where("packages.type = ?", string(models.PackageTypeTrial)).
|
||||
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,22 @@ 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
|
||||
|
||||
// 用户端:查询用户授权码列表
|
||||
GetCuAuthorizationCodeList(ctx context.Context, customerID string, req *models.CuAuthorizationCodeListRequest) (*models.CuAuthorizationCodeListResponse, error)
|
||||
|
||||
// 用户端:授权信息统计
|
||||
GetCuAuthorizationCodeSummary(ctx context.Context, customerID string) (*models.CuAuthorizationCodeSummaryResponse, error)
|
||||
}
|
||||
|
||||
// LicenseRepository 许可证数据访问接口
|
||||
@@ -112,6 +128,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 +150,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)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"license-manager/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LeadRepository 线索仓储接口
|
||||
type LeadRepository interface {
|
||||
Create(lead *models.Lead) error
|
||||
Update(lead *models.Lead) error
|
||||
Delete(id string) error
|
||||
GetByID(id string) (*models.Lead, error)
|
||||
GetList(ctx context.Context, req *models.LeadListRequest) ([]*models.Lead, int64, error)
|
||||
GetSummary(ctx context.Context) (*models.LeadSummaryResponse, error)
|
||||
}
|
||||
|
||||
type leadRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewLeadRepository 创建线索仓储
|
||||
func NewLeadRepository(db *gorm.DB) LeadRepository {
|
||||
return &leadRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *leadRepository) Create(lead *models.Lead) error {
|
||||
return r.db.Create(lead).Error
|
||||
}
|
||||
|
||||
func (r *leadRepository) Update(lead *models.Lead) error {
|
||||
return r.db.Save(lead).Error
|
||||
}
|
||||
|
||||
func (r *leadRepository) Delete(id string) error {
|
||||
return r.db.Unscoped().Where("id = ?", id).Delete(&models.Lead{}).Error
|
||||
}
|
||||
|
||||
func (r *leadRepository) GetByID(id string) (*models.Lead, error) {
|
||||
var lead models.Lead
|
||||
err := r.db.Where("id = ?", id).First(&lead).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &lead, nil
|
||||
}
|
||||
|
||||
func (r *leadRepository) GetList(ctx context.Context, req *models.LeadListRequest) ([]*models.Lead, int64, error) {
|
||||
var leads []*models.Lead
|
||||
var total int64
|
||||
|
||||
// 默认值
|
||||
page := 1
|
||||
pageSize := 10
|
||||
search := ""
|
||||
status := ""
|
||||
|
||||
if req != nil {
|
||||
if req.Page > 0 {
|
||||
page = req.Page
|
||||
}
|
||||
if req.PageSize > 0 {
|
||||
pageSize = req.PageSize
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
search = req.Search
|
||||
status = req.Status
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&models.Lead{})
|
||||
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
query = query.Where("(company_name LIKE ? OR contact_name LIKE ? OR contact_phone LIKE ?)", like, like, like)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 获取列表,按创建时间降序
|
||||
err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&leads).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return leads, total, nil
|
||||
}
|
||||
|
||||
func (r *leadRepository) GetSummary(ctx context.Context) (*models.LeadSummaryResponse, error) {
|
||||
var row struct {
|
||||
TotalCount int64 `gorm:"column:total_count"`
|
||||
PendingCount int64 `gorm:"column:pending_count"`
|
||||
ContactedCount int64 `gorm:"column:contacted_count"`
|
||||
ConvertedCount int64 `gorm:"column:converted_count"`
|
||||
InvalidCount int64 `gorm:"column:invalid_count"`
|
||||
}
|
||||
|
||||
err := r.db.WithContext(ctx).Model(&models.Lead{}).Select(
|
||||
"COUNT(*) as total_count, " +
|
||||
"SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending_count, " +
|
||||
"SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as contacted_count, " +
|
||||
"SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as converted_count, " +
|
||||
"SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as invalid_count",
|
||||
string(models.LeadStatusPending),
|
||||
string(models.LeadStatusContacted),
|
||||
string(models.LeadStatusConverted),
|
||||
string(models.LeadStatusInvalid),
|
||||
).Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.LeadSummaryResponse{
|
||||
TotalCount: row.TotalCount,
|
||||
PendingCount: row.PendingCount,
|
||||
ContactedCount: row.ContactedCount,
|
||||
ConvertedCount: row.ConvertedCount,
|
||||
InvalidCount: row.InvalidCount,
|
||||
}, nil
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"license-manager/internal/config"
|
||||
"license-manager/internal/models"
|
||||
)
|
||||
|
||||
@@ -65,13 +66,18 @@ func (r *licenseRepository) GetLicenseList(ctx context.Context, req *models.Lice
|
||||
}
|
||||
|
||||
// 在线状态筛选
|
||||
if req.IsOnline != nil {
|
||||
// 在线状态:最后心跳时间在5分钟内
|
||||
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
|
||||
if *req.IsOnline {
|
||||
query = query.Where("licenses.last_heartbeat >= ?", fiveMinutesAgo)
|
||||
} else {
|
||||
query = query.Where("licenses.last_heartbeat < ? OR licenses.last_heartbeat IS NULL", fiveMinutesAgo)
|
||||
if req.IsOnline != nil && *req.IsOnline != "" {
|
||||
cfg := config.GetConfig()
|
||||
heartbeatTimeoutSeconds := cfg.License.HeartbeatTimeout
|
||||
if heartbeatTimeoutSeconds <= 0 {
|
||||
heartbeatTimeoutSeconds = 300 // 默认5分钟
|
||||
}
|
||||
onlineThreshold := time.Now().Add(-time.Duration(heartbeatTimeoutSeconds) * time.Second)
|
||||
|
||||
if *req.IsOnline == "true" {
|
||||
query = query.Where("licenses.last_heartbeat >= ?", onlineThreshold)
|
||||
} else if *req.IsOnline == "false" {
|
||||
query = query.Where("licenses.last_heartbeat < ? OR licenses.last_heartbeat IS NULL", onlineThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,12 +111,19 @@ func (r *licenseRepository) GetLicenseList(ctx context.Context, req *models.Lice
|
||||
|
||||
// 转换为响应格式
|
||||
list := make([]models.LicenseListItem, len(licenses))
|
||||
|
||||
cfg := config.GetConfig()
|
||||
heartbeatTimeoutSeconds := cfg.License.HeartbeatTimeout
|
||||
if heartbeatTimeoutSeconds <= 0 {
|
||||
heartbeatTimeoutSeconds = 300 // 默认5分钟
|
||||
}
|
||||
onlineThreshold := time.Now().Add(-time.Duration(heartbeatTimeoutSeconds) * time.Second)
|
||||
|
||||
for i, license := range licenses {
|
||||
// 计算在线状态
|
||||
isOnline := false
|
||||
if license.LastHeartbeat != nil {
|
||||
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
|
||||
isOnline = license.LastHeartbeat.After(fiveMinutesAgo)
|
||||
isOnline = license.LastHeartbeat.After(onlineThreshold)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
@@ -155,10 +168,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
|
||||
@@ -168,8 +181,13 @@ func (r *licenseRepository) GetLicenseByID(ctx context.Context, id string) (*mod
|
||||
|
||||
// 计算在线状态
|
||||
if license.LastHeartbeat != nil {
|
||||
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
|
||||
license.IsOnline = license.LastHeartbeat.After(fiveMinutesAgo)
|
||||
cfg := config.GetConfig()
|
||||
heartbeatTimeoutSeconds := cfg.License.HeartbeatTimeout
|
||||
if heartbeatTimeoutSeconds <= 0 {
|
||||
heartbeatTimeoutSeconds = 300 // 默认5分钟
|
||||
}
|
||||
onlineThreshold := time.Now().Add(-time.Duration(heartbeatTimeoutSeconds) * time.Second)
|
||||
license.IsOnline = license.LastHeartbeat.After(onlineThreshold)
|
||||
}
|
||||
|
||||
return &license, nil
|
||||
@@ -224,10 +242,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
|
||||
@@ -237,8 +255,13 @@ func (r *licenseRepository) GetLicenseByKey(ctx context.Context, licenseKey stri
|
||||
|
||||
// 计算在线状态
|
||||
if license.LastHeartbeat != nil {
|
||||
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
|
||||
license.IsOnline = license.LastHeartbeat.After(fiveMinutesAgo)
|
||||
cfg := config.GetConfig()
|
||||
heartbeatTimeoutSeconds := cfg.License.HeartbeatTimeout
|
||||
if heartbeatTimeoutSeconds <= 0 {
|
||||
heartbeatTimeoutSeconds = 300 // 默认5分钟
|
||||
}
|
||||
onlineThreshold := time.Now().Add(-time.Duration(heartbeatTimeoutSeconds) * time.Second)
|
||||
license.IsOnline = license.LastHeartbeat.After(onlineThreshold)
|
||||
}
|
||||
|
||||
return &license, nil
|
||||
@@ -251,4 +274,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 && *req.IsOnline != "" {
|
||||
if *req.IsOnline == "true" {
|
||||
query = query.Where("licenses.last_heartbeat > ?", onlineThreshold)
|
||||
} else if *req.IsOnline == "false" {
|
||||
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(time.RFC3339)
|
||||
activatedAtStr = &str
|
||||
}
|
||||
if result.LastHeartbeat != nil {
|
||||
str := result.LastHeartbeat.Format(time.RFC3339)
|
||||
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(time.RFC3339),
|
||||
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,101 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"license-manager/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PackageRepository 套餐仓储接口
|
||||
type PackageRepository interface {
|
||||
Create(pkg *models.Package) error
|
||||
Update(pkg *models.Package) error
|
||||
Delete(id string) error
|
||||
GetByID(id string) (*models.Package, error)
|
||||
GetList(ctx context.Context, req *models.PackageListRequest) ([]*models.Package, int64, error)
|
||||
GetEnabledList(ctx context.Context) ([]*models.Package, error)
|
||||
GetByType(ctx context.Context, pkgType string) ([]*models.Package, error)
|
||||
}
|
||||
|
||||
type packageRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewPackageRepository 创建套餐仓储
|
||||
func NewPackageRepository(db *gorm.DB) PackageRepository {
|
||||
return &packageRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *packageRepository) Create(pkg *models.Package) error {
|
||||
return r.db.Create(pkg).Error
|
||||
}
|
||||
|
||||
func (r *packageRepository) Update(pkg *models.Package) error {
|
||||
return r.db.Save(pkg).Error
|
||||
}
|
||||
|
||||
func (r *packageRepository) Delete(id string) error {
|
||||
return r.db.Model(&models.Package{}).Where("id = ?", id).Update("deleted_at", gorm.Expr("NOW()")).Error
|
||||
}
|
||||
|
||||
func (r *packageRepository) GetByID(id string) (*models.Package, error) {
|
||||
var pkg models.Package
|
||||
err := r.db.Where("id = ? AND deleted_at IS NULL", id).First(&pkg).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pkg, nil
|
||||
}
|
||||
|
||||
func (r *packageRepository) GetList(ctx context.Context, req *models.PackageListRequest) ([]*models.Package, int64, error) {
|
||||
var packages []*models.Package
|
||||
var total int64
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&models.Package{}).Where("deleted_at IS NULL")
|
||||
|
||||
if req.Type != "" {
|
||||
query = query.Where("type = ?", req.Type)
|
||||
}
|
||||
|
||||
if req.Status != nil {
|
||||
query = query.Where("status = ?", *req.Status)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 获取列表,按sort_order降序排序
|
||||
err := query.Order("sort_order DESC, created_at DESC").Find(&packages).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return packages, total, nil
|
||||
}
|
||||
|
||||
func (r *packageRepository) GetEnabledList(ctx context.Context) ([]*models.Package, error) {
|
||||
var packages []*models.Package
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("status = ? AND deleted_at IS NULL", 1).
|
||||
Order("sort_order ASC, created_at ASC").
|
||||
Find(&packages).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
func (r *packageRepository) GetByType(ctx context.Context, pkgType string) ([]*models.Package, error) {
|
||||
var packages []*models.Package
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("type = ? AND status = ? AND deleted_at IS NULL", pkgType, 1).
|
||||
Order("sort_order DESC").
|
||||
Find(&packages).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
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).
|
||||
Order("created_at DESC").
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/repository"
|
||||
pkgcontext "license-manager/pkg/context"
|
||||
"license-manager/pkg/i18n"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminInvoiceService 管理端发票服务接口
|
||||
type AdminInvoiceService interface {
|
||||
// ========== 管理端发票操作 ==========
|
||||
RejectInvoice(ctx context.Context, invoiceID string, adminID string, req *models.InvoiceRejectRequest) (*models.Invoice, error)
|
||||
IssueInvoice(ctx context.Context, invoiceID string, adminID string, req *models.InvoiceIssueRequest) (*models.Invoice, error)
|
||||
GetAdminInvoices(ctx context.Context, req *models.InvoiceListRequest) (*models.InvoiceListResponse, error)
|
||||
GetAdminInvoiceSummary(ctx context.Context) (*models.InvoiceSummaryResponse, error)
|
||||
GetInvoiceDetail(ctx context.Context, invoiceID string, isAdmin bool, userID string) (*models.InvoiceDetailResponse, error)
|
||||
GenerateDownloadToken(ctx context.Context, invoiceID string) (string, error)
|
||||
|
||||
// ========== 公共发票操作 ==========
|
||||
DownloadByToken(ctx context.Context, token string) (string, error)
|
||||
}
|
||||
|
||||
type adminInvoiceService struct {
|
||||
repo repository.AdminInvoiceRepository
|
||||
orderRepo repository.CuOrderRepository
|
||||
userRepo repository.UserRepository
|
||||
cuUserRepo repository.CuUserRepository
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAdminInvoiceService(repo repository.AdminInvoiceRepository, orderRepo repository.CuOrderRepository, userRepo repository.UserRepository, cuUserRepo repository.CuUserRepository, db *gorm.DB) AdminInvoiceService {
|
||||
return &adminInvoiceService{
|
||||
repo: repo,
|
||||
orderRepo: orderRepo,
|
||||
userRepo: userRepo,
|
||||
cuUserRepo: cuUserRepo,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 管理端发票操作实现 ==========
|
||||
|
||||
func (s *adminInvoiceService) RejectInvoice(ctx context.Context, invoiceID string, adminID string, req *models.InvoiceRejectRequest) (*models.Invoice, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
invoice, err := s.repo.GetByID(invoiceID)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("700001", lang)
|
||||
}
|
||||
|
||||
// 验证状态
|
||||
if invoice.Status != models.InvoiceStatusPending {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
invoice.Status = models.InvoiceStatusRejected
|
||||
invoice.RejectReason = &req.RejectReason
|
||||
invoice.Suggestion = req.Suggestion
|
||||
invoice.RejectedAt = &now
|
||||
invoice.RejectedBy = &adminID
|
||||
invoice.UpdatedAt = now
|
||||
|
||||
err = s.repo.Update(invoice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return invoice, nil
|
||||
}
|
||||
|
||||
func (s *adminInvoiceService) IssueInvoice(ctx context.Context, invoiceID string, adminID string, req *models.InvoiceIssueRequest) (*models.Invoice, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
invoice, err := s.repo.GetByID(invoiceID)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("700001", lang)
|
||||
}
|
||||
|
||||
prevStatus := invoice.Status
|
||||
|
||||
issuedAt, err := time.Parse(time.RFC3339, req.IssuedAt)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
invoice.Status = models.InvoiceStatusIssued
|
||||
invoice.InvoiceFileURL = &req.InvoiceFileURL
|
||||
invoice.IssuedAt = &issuedAt
|
||||
invoice.UploadedAt = &now
|
||||
invoice.UploadedBy = &adminID
|
||||
invoice.UpdatedAt = now
|
||||
|
||||
// Clear reject info when issuing from rejected status.
|
||||
if prevStatus == models.InvoiceStatusRejected {
|
||||
invoice.RejectReason = nil
|
||||
invoice.Suggestion = nil
|
||||
invoice.RejectedAt = nil
|
||||
invoice.RejectedBy = nil
|
||||
}
|
||||
|
||||
err = s.repo.Update(invoice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return invoice, nil
|
||||
}
|
||||
|
||||
func (s *adminInvoiceService) GetAdminInvoices(ctx context.Context, req *models.InvoiceListRequest) (*models.InvoiceListResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 解析日期筛选
|
||||
var createdAtStart, createdAtEnd *time.Time
|
||||
if req.ApplyDate != "" {
|
||||
start, end, err := s.parseDateRange(req.ApplyDate)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
createdAtStart = start
|
||||
createdAtEnd = end
|
||||
}
|
||||
|
||||
invoices, total, err := s.repo.GetAdminList(ctx, req, createdAtStart, createdAtEnd)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 转换为响应结构并填充多语言显示字段和用户手机号
|
||||
invoiceResponses := make([]*models.InvoiceResponse, len(invoices))
|
||||
for i, invoice := range invoices {
|
||||
invoiceResponses[i] = invoice.ToResponse()
|
||||
s.fillDisplayFields(invoiceResponses[i], lang)
|
||||
|
||||
// 填充申请人手机号
|
||||
cuUser, err := s.cuUserRepo.GetByID(invoice.CuUserID)
|
||||
if err == nil {
|
||||
invoiceResponses[i].CuUserPhone = cuUser.Phone
|
||||
}
|
||||
}
|
||||
|
||||
return &models.InvoiceListResponse{
|
||||
Invoices: invoiceResponses,
|
||||
TotalCount: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *adminInvoiceService) GetAdminInvoiceSummary(ctx context.Context) (*models.InvoiceSummaryResponse, error) {
|
||||
return s.repo.GetAdminInvoiceSummary(ctx)
|
||||
}
|
||||
|
||||
func (s *adminInvoiceService) GetInvoiceDetail(ctx context.Context, invoiceID string, isAdmin bool, userID string) (*models.InvoiceDetailResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
invoice, err := s.repo.GetByID(invoiceID)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("700001", lang)
|
||||
}
|
||||
|
||||
// 权限验证
|
||||
if !isAdmin && invoice.CuUserID != userID {
|
||||
return nil, i18n.NewI18nError("700001", lang)
|
||||
}
|
||||
|
||||
response := &models.InvoiceDetailResponse{
|
||||
InvoiceResponse: invoice.ToResponse(),
|
||||
}
|
||||
|
||||
// 填充多语言显示字段
|
||||
s.fillDetailDisplayFields(response, lang)
|
||||
|
||||
// 获取关联信息
|
||||
if isAdmin {
|
||||
// 管理端需要更多关联信息
|
||||
order, err := s.orderRepo.GetByID(invoice.OrderID)
|
||||
if err == nil {
|
||||
response.OrderPackageName = order.PackageName
|
||||
}
|
||||
|
||||
cuUser, err := s.cuUserRepo.GetByID(invoice.CuUserID)
|
||||
if err == nil && cuUser.RealName != nil {
|
||||
response.ApplicantName = *cuUser.RealName
|
||||
response.ApplicantPhone = cuUser.Phone
|
||||
}
|
||||
|
||||
if invoice.UploadedBy != nil {
|
||||
user, err := s.userRepo.GetUserByID(ctx, *invoice.UploadedBy)
|
||||
if err == nil {
|
||||
response.UploaderName = user.FullName
|
||||
}
|
||||
}
|
||||
|
||||
if invoice.RejectedBy != nil {
|
||||
user, err := s.userRepo.GetUserByID(ctx, *invoice.RejectedBy)
|
||||
if err == nil {
|
||||
response.RejecterName = user.FullName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *adminInvoiceService) GenerateDownloadToken(ctx context.Context, invoiceID string) (string, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
invoice, err := s.repo.GetByID(invoiceID)
|
||||
if err != nil {
|
||||
return "", i18n.NewI18nError("700001", lang)
|
||||
}
|
||||
|
||||
if invoice.Status != models.InvoiceStatusIssued {
|
||||
return "", i18n.NewI18nError("700005", lang)
|
||||
}
|
||||
|
||||
if invoice.InvoiceFileURL == nil || *invoice.InvoiceFileURL == "" {
|
||||
return "", i18n.NewI18nError("700005", lang)
|
||||
}
|
||||
|
||||
// 生成随机token
|
||||
tokenBytes := make([]byte, 32)
|
||||
_, err = rand.Read(tokenBytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := hex.EncodeToString(tokenBytes)
|
||||
|
||||
// 更新token
|
||||
err = s.repo.UpdateDownloadToken(invoiceID, &token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ========== 公共发票操作实现 ==========
|
||||
|
||||
func (s *adminInvoiceService) DownloadByToken(ctx context.Context, token string) (string, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
invoice, err := s.repo.GetByDownloadToken(token)
|
||||
if err != nil {
|
||||
return "", i18n.NewI18nError("700001", lang)
|
||||
}
|
||||
|
||||
if invoice.Status != models.InvoiceStatusIssued {
|
||||
return "", i18n.NewI18nError("700005", lang)
|
||||
}
|
||||
|
||||
if invoice.InvoiceFileURL == nil || *invoice.InvoiceFileURL == "" {
|
||||
return "", i18n.NewI18nError("700005", lang)
|
||||
}
|
||||
|
||||
return *invoice.InvoiceFileURL, nil
|
||||
}
|
||||
|
||||
// ========== 私有辅助方法 ==========
|
||||
|
||||
// fillDisplayFields 填充响应结构的多语言显示字段
|
||||
func (s *adminInvoiceService) fillDisplayFields(response *models.InvoiceResponse, lang string) {
|
||||
response.StatusDisplay = i18n.GetEnumMessage("invoice_status", response.Status, lang)
|
||||
response.InvoiceTypeDisplay = i18n.GetEnumMessage("invoice_type", response.InvoiceType, lang)
|
||||
}
|
||||
|
||||
// fillDetailDisplayFields 填充详情响应结构的多语言显示字段
|
||||
func (s *adminInvoiceService) fillDetailDisplayFields(response *models.InvoiceDetailResponse, lang string) {
|
||||
response.StatusDisplay = i18n.GetEnumMessage("invoice_status", response.Status, lang)
|
||||
response.InvoiceTypeDisplay = i18n.GetEnumMessage("invoice_type", response.InvoiceType, lang)
|
||||
}
|
||||
|
||||
// parseDateRange 解析日期范围
|
||||
func (s *adminInvoiceService) parseDateRange(dateStr string) (*time.Time, *time.Time, error) {
|
||||
parts := strings.Split(dateStr, ",")
|
||||
if len(parts) == 1 {
|
||||
start, _, err := s.parseFlexibleTime(strings.TrimSpace(dateStr))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
end := start.AddDate(0, 0, 1)
|
||||
return &start, &end, nil
|
||||
}
|
||||
if len(parts) != 2 {
|
||||
return nil, nil, fmt.Errorf("invalid date range")
|
||||
}
|
||||
|
||||
startText := strings.TrimSpace(parts[0])
|
||||
endText := strings.TrimSpace(parts[1])
|
||||
if startText == "" || endText == "" {
|
||||
return nil, nil, fmt.Errorf("invalid date range")
|
||||
}
|
||||
|
||||
start, _, err := s.parseFlexibleTime(startText)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
end, endDateOnly, err := s.parseFlexibleTime(endText)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if endDateOnly {
|
||||
end = end.AddDate(0, 0, 1)
|
||||
}
|
||||
if end.Before(start) {
|
||||
start, end = end, start
|
||||
}
|
||||
|
||||
return &start, &end, nil
|
||||
}
|
||||
|
||||
func (s *adminInvoiceService) parseFlexibleTime(value string) (time.Time, bool, error) {
|
||||
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return t, false, nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return t, false, nil
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", value)
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
return t, true, 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,219 @@ 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)
|
||||
}
|
||||
|
||||
// 根据联系方式查找目标用户
|
||||
targetUser, err := s.findUserByContact(req.TargetContact)
|
||||
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.ID == userID {
|
||||
return nil, i18n.NewI18nError("300105", lang) // 不能分享给自己
|
||||
}
|
||||
|
||||
// 验证目标用户状态
|
||||
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: targetUser.ID, // 记录为目标用户创建的
|
||||
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
|
||||
}
|
||||
|
||||
// findUserByContact 根据联系方式查找用户(手机号或邮箱)
|
||||
func (s *authorizationCodeService) findUserByContact(contact string) (*models.CuUser, error) {
|
||||
// 判断是手机号还是邮箱
|
||||
if strings.Contains(contact, "@") {
|
||||
// 邮箱
|
||||
return s.cuUserRepo.GetByEmail(contact)
|
||||
} else {
|
||||
// 手机号:如果没有国家代码,默认使用 +86
|
||||
phone := contact
|
||||
countryCode := "+86" // 默认国家代码
|
||||
|
||||
// 检查是否已经包含国家代码
|
||||
if strings.HasPrefix(contact, "+") {
|
||||
// 如果以 + 开头,分离国家代码和手机号
|
||||
parts := strings.SplitN(contact, " ", 2)
|
||||
if len(parts) == 2 {
|
||||
countryCode = parts[0]
|
||||
phone = parts[1]
|
||||
} else {
|
||||
// 可能是直接的格式如 +8613800000000,需要分离
|
||||
// 简单处理:假设国家代码是 + 开头的1-4位数字
|
||||
for i := 1; i <= 4 && i < len(contact); i++ {
|
||||
if contact[i] >= '0' && contact[i] <= '9' {
|
||||
continue
|
||||
}
|
||||
countryCode = contact[:i]
|
||||
phone = contact[i:]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s.cuUserRepo.GetByPhone(phone, countryCode)
|
||||
}
|
||||
}
|
||||
|
||||
// GetCuAuthorizationCodeList 用户端:获取当前用户授权码列表
|
||||
func (s *authorizationCodeService) GetCuAuthorizationCodeList(ctx context.Context, customerID string, req *models.CuAuthorizationCodeListRequest) (*models.CuAuthorizationCodeListResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
if customerID == "" {
|
||||
return nil, i18n.NewI18nError("100004", lang)
|
||||
}
|
||||
|
||||
result, err := s.authCodeRepo.GetCuAuthorizationCodeList(ctx, customerID, req)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
for i := range result.List {
|
||||
result.List[i].StatusDisplay = i18n.GetEnumMessage("authorization_code_status", result.List[i].Status, lang)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetCuAuthorizationCodeSummary 用户端:授权信息统计
|
||||
func (s *authorizationCodeService) GetCuAuthorizationCodeSummary(ctx context.Context, customerID string) (*models.CuAuthorizationCodeSummaryResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
if customerID == "" {
|
||||
return nil, i18n.NewI18nError("100004", lang)
|
||||
}
|
||||
|
||||
result, err := s.authCodeRepo.GetCuAuthorizationCodeSummary(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
return result, 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,317 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/repository"
|
||||
pkgcontext "license-manager/pkg/context"
|
||||
"license-manager/pkg/i18n"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CuInvoiceService 客户端发票服务接口
|
||||
type CuInvoiceService interface {
|
||||
// ========== 客户端发票操作 ==========
|
||||
CreateInvoice(ctx context.Context, cuUserID string, req *models.InvoiceCreateRequest) (*models.Invoice, error)
|
||||
GetInvoice(ctx context.Context, invoiceID, cuUserID string) (*models.Invoice, error)
|
||||
GetUserInvoices(ctx context.Context, cuUserID string, req *models.InvoiceListRequest) (*models.InvoiceListResponse, error)
|
||||
GetInvoiceSummary(ctx context.Context, cuUserID string) (*models.InvoiceSummaryResponse, error)
|
||||
DownloadInvoice(ctx context.Context, invoiceID, cuUserID string) (string, error)
|
||||
UpdateInvoice(ctx context.Context, invoiceID, cuUserID string, req *models.InvoiceUpdateRequest) (*models.Invoice, error)
|
||||
}
|
||||
|
||||
type cuInvoiceService struct {
|
||||
repo repository.CuInvoiceRepository
|
||||
orderRepo repository.CuOrderRepository
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCuInvoiceService(repo repository.CuInvoiceRepository, orderRepo repository.CuOrderRepository, db *gorm.DB) CuInvoiceService {
|
||||
return &cuInvoiceService{
|
||||
repo: repo,
|
||||
orderRepo: orderRepo,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 客户端发票操作实现 ==========
|
||||
|
||||
func (s *cuInvoiceService) CreateInvoice(ctx context.Context, cuUserID string, req *models.InvoiceCreateRequest) (*models.Invoice, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 验证订单是否存在且属于当前用户
|
||||
order, err := s.orderRepo.GetByID(req.OrderID)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("700002", lang)
|
||||
}
|
||||
|
||||
if order.CuUserID != cuUserID {
|
||||
return nil, i18n.NewI18nError("700002", lang)
|
||||
}
|
||||
|
||||
// 验证订单状态必须为已支付
|
||||
if order.Status != "paid" {
|
||||
return nil, i18n.NewI18nError("700002", lang)
|
||||
}
|
||||
|
||||
// 检查是否已存在发票
|
||||
exists, err := s.repo.CheckOrderInvoiceExists(req.OrderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
return nil, i18n.NewI18nError("700003", lang)
|
||||
}
|
||||
|
||||
// 验证发票类型和纳税人识别号
|
||||
if req.InvoiceType != models.InvoiceTypePersonal && (req.TaxpayerID == nil || *req.TaxpayerID == "") {
|
||||
return nil, i18n.NewI18nError("700004", lang)
|
||||
}
|
||||
|
||||
// 生成发票申请号
|
||||
invoiceNo := s.generateInvoiceNo()
|
||||
|
||||
// 创建发票记录
|
||||
now := time.Now()
|
||||
invoice := &models.Invoice{
|
||||
InvoiceNo: invoiceNo,
|
||||
OrderID: req.OrderID,
|
||||
OrderNo: order.OrderNo,
|
||||
CustomerID: order.CustomerID,
|
||||
CuUserID: cuUserID,
|
||||
Amount: order.TotalAmount,
|
||||
Status: models.InvoiceStatusPending,
|
||||
InvoiceType: req.InvoiceType,
|
||||
Title: req.Title,
|
||||
TaxpayerID: req.TaxpayerID,
|
||||
Content: req.Content,
|
||||
ReceiverEmail: req.ReceiverEmail,
|
||||
Remark: req.Remark,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
err = s.repo.Create(invoice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return invoice, nil
|
||||
}
|
||||
|
||||
func (s *cuInvoiceService) GetInvoice(ctx context.Context, invoiceID, cuUserID string) (*models.Invoice, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
invoice, err := s.repo.GetByID(invoiceID)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("700001", lang)
|
||||
}
|
||||
|
||||
// 验证权限:只能查看自己的发票
|
||||
if invoice.CuUserID != cuUserID {
|
||||
return nil, i18n.NewI18nError("700001", lang)
|
||||
}
|
||||
|
||||
return invoice, nil
|
||||
}
|
||||
|
||||
func (s *cuInvoiceService) GetUserInvoices(ctx context.Context, cuUserID string, req *models.InvoiceListRequest) (*models.InvoiceListResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 解析日期筛选
|
||||
var createdAtStart, createdAtEnd *time.Time
|
||||
if req.ApplyDate != "" {
|
||||
start, end, err := s.parseDateRange(req.ApplyDate)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
createdAtStart = start
|
||||
createdAtEnd = end
|
||||
}
|
||||
|
||||
invoices, total, err := s.repo.GetByCuUserID(ctx, cuUserID, req, createdAtStart, createdAtEnd)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 转换为响应结构并填充多语言显示字段
|
||||
invoiceResponses := make([]*models.InvoiceResponse, len(invoices))
|
||||
for i, invoice := range invoices {
|
||||
invoiceResponses[i] = invoice.ToResponse()
|
||||
s.fillDisplayFields(invoiceResponses[i], lang)
|
||||
}
|
||||
|
||||
return &models.InvoiceListResponse{
|
||||
Invoices: invoiceResponses,
|
||||
TotalCount: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *cuInvoiceService) GetInvoiceSummary(ctx context.Context, cuUserID string) (*models.InvoiceSummaryResponse, error) {
|
||||
return s.repo.GetCuUserInvoiceSummary(ctx, cuUserID)
|
||||
}
|
||||
|
||||
func (s *cuInvoiceService) DownloadInvoice(ctx context.Context, invoiceID, cuUserID string) (string, error) {
|
||||
invoice, err := s.GetInvoice(ctx, invoiceID, cuUserID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
if invoice.Status != models.InvoiceStatusIssued {
|
||||
return "", i18n.NewI18nError("700005", lang)
|
||||
}
|
||||
|
||||
if invoice.InvoiceFileURL == nil || *invoice.InvoiceFileURL == "" {
|
||||
return "", i18n.NewI18nError("700005", lang)
|
||||
}
|
||||
|
||||
return *invoice.InvoiceFileURL, nil
|
||||
}
|
||||
|
||||
func (s *cuInvoiceService) UpdateInvoice(ctx context.Context, invoiceID, cuUserID string, req *models.InvoiceUpdateRequest) (*models.Invoice, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 获取原发票
|
||||
invoice, err := s.repo.GetByID(invoiceID)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("700001", lang)
|
||||
}
|
||||
|
||||
// 权限校验
|
||||
if invoice.CuUserID != cuUserID {
|
||||
return nil, i18n.NewI18nError("700001", lang)
|
||||
}
|
||||
|
||||
// 状态校验:只有被驳回的发票可以修改
|
||||
if invoice.Status != models.InvoiceStatusRejected {
|
||||
return nil, i18n.NewI18nError("700004", lang)
|
||||
}
|
||||
|
||||
// 业务逻辑校验:发票类型和纳税人识别号
|
||||
// 如果传入了新的 InvoiceType 或者 TaxpayerID,我们要校验最终的结果
|
||||
finalInvoiceType := invoice.InvoiceType
|
||||
if req.InvoiceType != nil {
|
||||
finalInvoiceType = *req.InvoiceType
|
||||
}
|
||||
|
||||
finalTaxpayerID := invoice.TaxpayerID
|
||||
if req.TaxpayerID != nil {
|
||||
finalTaxpayerID = req.TaxpayerID // This handles explicitly setting it to nil or "" as well if needed? Wait, if they set *req.TaxpayerID == "" it will just replace it.
|
||||
}
|
||||
|
||||
if finalInvoiceType != models.InvoiceTypePersonal && (finalTaxpayerID == nil || *finalTaxpayerID == "") {
|
||||
return nil, i18n.NewI18nError("700004", lang)
|
||||
}
|
||||
|
||||
// 更新字段(仅更新非空字段)
|
||||
if req.InvoiceType != nil {
|
||||
invoice.InvoiceType = *req.InvoiceType
|
||||
}
|
||||
if req.Title != nil {
|
||||
invoice.Title = *req.Title
|
||||
}
|
||||
if req.TaxpayerID != nil {
|
||||
invoice.TaxpayerID = req.TaxpayerID
|
||||
}
|
||||
if req.Content != nil {
|
||||
invoice.Content = *req.Content
|
||||
}
|
||||
if req.ReceiverEmail != nil {
|
||||
invoice.ReceiverEmail = *req.ReceiverEmail
|
||||
}
|
||||
if req.Remark != nil {
|
||||
invoice.Remark = req.Remark
|
||||
}
|
||||
|
||||
// 重置状态和驳回信息
|
||||
invoice.Status = models.InvoiceStatusPending
|
||||
invoice.RejectReason = nil
|
||||
invoice.Suggestion = nil
|
||||
invoice.RejectedAt = nil
|
||||
invoice.RejectedBy = nil
|
||||
|
||||
// 更新数据库
|
||||
err = s.repo.Update(invoice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return invoice, nil
|
||||
}
|
||||
|
||||
// ========== 私有辅助方法 ==========
|
||||
|
||||
// fillDisplayFields 填充响应结构的多语言显示字段
|
||||
func (s *cuInvoiceService) fillDisplayFields(response *models.InvoiceResponse, lang string) {
|
||||
response.StatusDisplay = i18n.GetEnumMessage("invoice_status", response.Status, lang)
|
||||
response.InvoiceTypeDisplay = i18n.GetEnumMessage("invoice_type", response.InvoiceType, lang)
|
||||
}
|
||||
|
||||
// generateInvoiceNo 生成发票申请号
|
||||
func (s *cuInvoiceService) generateInvoiceNo() string {
|
||||
now := time.Now()
|
||||
timestamp := now.UnixNano() / int64(time.Millisecond) % 1000000000 // 取纳秒后9位
|
||||
return fmt.Sprintf("INV%04d%02d%02d%09d",
|
||||
now.Year(), now.Month(), now.Day(), timestamp)
|
||||
}
|
||||
|
||||
// parseDateRange 解析日期范围
|
||||
func (s *cuInvoiceService) parseDateRange(dateStr string) (*time.Time, *time.Time, error) {
|
||||
parts := strings.Split(dateStr, ",")
|
||||
if len(parts) == 1 {
|
||||
start, _, err := s.parseFlexibleTime(strings.TrimSpace(dateStr))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
end := start.AddDate(0, 0, 1)
|
||||
return &start, &end, nil
|
||||
}
|
||||
if len(parts) != 2 {
|
||||
return nil, nil, fmt.Errorf("invalid date range")
|
||||
}
|
||||
|
||||
startText := strings.TrimSpace(parts[0])
|
||||
endText := strings.TrimSpace(parts[1])
|
||||
if startText == "" || endText == "" {
|
||||
return nil, nil, fmt.Errorf("invalid date range")
|
||||
}
|
||||
|
||||
start, _, err := s.parseFlexibleTime(startText)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
end, endDateOnly, err := s.parseFlexibleTime(endText)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if endDateOnly {
|
||||
end = end.AddDate(0, 0, 1)
|
||||
}
|
||||
if end.Before(start) {
|
||||
start, end = end, start
|
||||
}
|
||||
|
||||
return &start, &end, nil
|
||||
}
|
||||
|
||||
func (s *cuInvoiceService) parseFlexibleTime(value string) (time.Time, bool, error) {
|
||||
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return t, false, nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return t, false, nil
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", value)
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
return t, true, nil
|
||||
}
|
||||
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user