Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -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/[email protected]
|
||||
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/[email protected]
|
||||
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
|
||||
@@ -76,14 +76,14 @@ jobs:
|
||||
- name: Prepare temp directory
|
||||
uses: appleboy/[email protected]
|
||||
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 +105,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 +118,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 +153,9 @@ jobs:
|
||||
- name: Deploy to production
|
||||
uses: appleboy/[email protected]
|
||||
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 +171,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 +181,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/[email protected]
|
||||
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/[email protected]
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 软件授权管理平台
|
||||
# 软件授权管理系统
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/cedar-v/license-manager/releases">
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
## 项目概述
|
||||
|
||||
软件授权管理平台是一个独立的软件授权管理系统,为软件系统提供授权码生成、分发、验证和管理服务。支持在线和离线两种授权模式,基于硬件绑定的授权机制确保安全性。
|
||||
软件授权管理系统是一个完整的软件商业化解决方案,集成了软件授权管理、用户自助服务和商业化运营能力。支持B2B企业客户和B2C个人用户的双轨运营模式,提供从注册购买到授权激活的全流程数字化服务。采用在线/离线混合授权模式,结合硬件绑定和RSA数字签名确保安全性。
|
||||
|
||||
✨同时支持**任何开发语言**、**任何类型的软件产品**通过标准化 **HTTP API** 与**跨平台客户端 SDK** **快速集成授权能力**。
|
||||
|
||||
@@ -40,8 +40,9 @@
|
||||
|
||||
> 💡 这是一个当前功能的演示环境,您可以体验已开发的核心特性。
|
||||
|
||||
## 官方文档
|
||||
## 官方
|
||||
|
||||
官方网站请访问:[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 {
|
||||
|
||||
@@ -73,6 +73,7 @@ errors:
|
||||
"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:
|
||||
@@ -86,6 +87,8 @@ errors:
|
||||
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:
|
||||
@@ -94,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"
|
||||
@@ -159,5 +170,15 @@ enums:
|
||||
"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"
|
||||
@@ -73,6 +73,7 @@ errors:
|
||||
"500015": "認証コードが存在しません"
|
||||
"500016": "SMS送信が頻繁すぎます。しばらくしてからお試しください"
|
||||
"500017": "SMS送信に失敗しました"
|
||||
"500018": "アカウントがロックされています。しばらくしてからお試しください"
|
||||
|
||||
# セット料金モジュール (60xxxx)
|
||||
cu_package:
|
||||
@@ -86,6 +87,8 @@ errors:
|
||||
cu_order:
|
||||
"601001": "注文が存在しません"
|
||||
"601005": "ライセンス数量が制限を超えています"
|
||||
"601006": "支払い済みの注文はキャンセルできません"
|
||||
"601007": "注文の状態により、支払いを続けることができません"
|
||||
|
||||
# システム共通 (90xxxx)
|
||||
system:
|
||||
@@ -160,4 +163,4 @@ enums:
|
||||
"other": "その他"
|
||||
|
||||
# デフォルトエラーメッセージ
|
||||
default_error: "不明なエラー"
|
||||
default_error: "不明なエラー"
|
||||
@@ -73,6 +73,7 @@ errors:
|
||||
"500015": "验证码不存在"
|
||||
"500016": "短信发送过于频繁,请稍后再试"
|
||||
"500017": "短信发送失败"
|
||||
"500018": "账号已被锁定,请稍后再试"
|
||||
|
||||
# 套餐订单模块 (60xxxx)
|
||||
cu_package:
|
||||
@@ -86,11 +87,21 @@ errors:
|
||||
cu_order:
|
||||
"601001": "订单不存在"
|
||||
"601005": "许可数量超出限制"
|
||||
"601006": "订单已支付,无法取消"
|
||||
"601007": "订单状态不允许继续支付"
|
||||
|
||||
# 设备管理模块 (62xxxx)
|
||||
cu_device:
|
||||
"620001": "设备不存在或无权限访问"
|
||||
|
||||
# 发票模块 (70xxxx)
|
||||
invoice:
|
||||
"700001": "发票不存在"
|
||||
"700002": "订单不存在或无权限访问"
|
||||
"700003": "该订单已存在发票申请"
|
||||
"700004": "发票状态不允许操作"
|
||||
"700005": "发票文件不存在"
|
||||
|
||||
# 系统通用 (90xxxx)
|
||||
system:
|
||||
"900001": "请求参数无效"
|
||||
@@ -163,5 +174,15 @@ enums:
|
||||
"unlock": "解锁"
|
||||
"other": "其他"
|
||||
|
||||
invoice_status:
|
||||
"pending": "待处理"
|
||||
"issued": "已开票"
|
||||
"rejected": "已驳回"
|
||||
|
||||
invoice_type:
|
||||
"personal": "个人"
|
||||
"enterprise": "企业普票"
|
||||
"vat_special": "增值税专用发票"
|
||||
|
||||
# 默认错误信息
|
||||
default_error: "未知错误"
|
||||
default_error: "未知错误"
|
||||
@@ -5,12 +5,14 @@
|
||||
Web框架: Gin
|
||||
ORM: GORM,开发环境使用Auto Migration
|
||||
配置: Viper
|
||||
日志: logrus
|
||||
日志: logrus (封装为logger包)
|
||||
认证: JWT (支持管理员和C端用户双JWT体系)
|
||||
缓存: Redis (可选)
|
||||
数据库: PostgreSQL/MySQL
|
||||
缓存: Redis/内存缓存 (支持切换)
|
||||
数据库: MySQL (推荐)/PostgreSQL
|
||||
支付集成: 支付宝
|
||||
短信服务: 阿里云SMS
|
||||
加密算法: RSA
|
||||
国际化: 支持多语言错误信息和枚举显示
|
||||
|
||||
|
||||
## 目录结构
|
||||
@@ -45,9 +47,10 @@ backend/
|
||||
│ │ │ ├── cu_auth_handler.go
|
||||
│ │ │ ├── cu_authorization_handler.go
|
||||
│ │ │ ├── cu_device_handler.go
|
||||
│ │ │ ├── cu_invoice_handler.go # 客户端发票处理器
|
||||
│ │ │ ├── cu_order_handler.go
|
||||
│ │ │ ├── cu_order_handler_test.go
|
||||
│ │ │ └── cu_profile_handler.go
|
||||
│ │ │ ├── cu_profile_handler.go
|
||||
│ │ │ └── invoice_handler.go # 管理端发票处理器
|
||||
│ │ ├── middleware/ # 中间件
|
||||
│ │ │ ├── auth.go
|
||||
│ │ │ ├── cors.go
|
||||
@@ -64,31 +67,41 @@ backend/
|
||||
│ │ ├── 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
|
||||
│ │ ├── cu_order_repository.go
|
||||
│ │ ├── cu_user_repository.go
|
||||
│ │ ├── gorm/ # GORM相关扩展
|
||||
│ │ ├── errors.go
|
||||
│ │ └── interfaces.go
|
||||
│ ├── models/ # 数据模型
|
||||
│ │ ├── admin_invoice_models.go # 管理端发票模型
|
||||
│ │ ├── auth.go
|
||||
│ │ ├── common.go
|
||||
│ │ ├── customer.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/ # 配置管理
|
||||
@@ -97,17 +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/ # 工具函数
|
||||
│ │ ├── alipay.go # 支付宝支付集成
|
||||
│ │ ├── authorization_code.go # 授权码生成
|
||||
│ │ ├── crypto.go # 加密工具
|
||||
│ │ ├── cu_jwt.go # C端用户JWT工具
|
||||
│ │ ├── jwt.go # JWT工具
|
||||
│ │ └── authorization_code.go # 授权码生成
|
||||
│ │ └── sms.go # 短信服务
|
||||
│ └── logger/ # 日志封装
|
||||
│ └── logger.go
|
||||
├── configs/
|
||||
@@ -153,6 +174,7 @@ backend/
|
||||
│ ├── 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
|
||||
@@ -162,8 +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,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": "[email protected]",
|
||||
"requirement": "需要采购50套软件授权",
|
||||
"extra_info": "希望了解批量购买优惠"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"lead_no": "LEAD20260201123456ABC123",
|
||||
"company_name": "北京科技有限公司",
|
||||
"contact_name": "张三",
|
||||
"contact_phone": "13800138000",
|
||||
"contact_email": "[email protected]",
|
||||
"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": "[email protected]",
|
||||
"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)。
|
||||
@@ -0,0 +1,11 @@
|
||||
## 本地开发环境
|
||||
|
||||
```bash
|
||||
# 使用默认配置
|
||||
cd cmd
|
||||
go run .
|
||||
|
||||
# 指定配置文件
|
||||
cd cmd
|
||||
go run . -config configs/config.dev.yaml
|
||||
```
|
||||
@@ -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": "[email protected]",
|
||||
"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;
|
||||
+80
-3
@@ -1,5 +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 以外的日志库
|
||||
- ❌ 不要为了处理错误而处理错误
|
||||
+3268
-19
File diff suppressed because it is too large.
Load diff
+3268
-19
File diff suppressed because it is too large.
Load diff
+2151
-19
File diff suppressed because it is too large.
Load diff
+4
-7
@@ -3,6 +3,10 @@ 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
|
||||
@@ -11,7 +15,6 @@ require (
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/smartwalle/alipay/v3 v3.2.28
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
github.com/swaggo/swag v1.16.3
|
||||
@@ -27,11 +30,7 @@ require (
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 // indirect
|
||||
github.com/alibabacloud-go/debug v1.0.1 // indirect
|
||||
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0 // indirect
|
||||
github.com/alibabacloud-go/tea v1.4.0 // indirect
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
|
||||
github.com/aliyun/credentials-go v1.4.5 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
@@ -39,7 +38,6 @@ require (
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
@@ -69,7 +67,6 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
|
||||
@@ -8,16 +8,22 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN
|
||||
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=
|
||||
@@ -25,7 +31,9 @@ github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA
|
||||
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=
|
||||
@@ -37,6 +45,7 @@ github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZL
|
||||
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=
|
||||
|
||||
@@ -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 "创建结束时间"
|
||||
|
||||
@@ -24,7 +24,7 @@ func NewCuAuthorizationHandler(authCodeService service.AuthorizationCodeService)
|
||||
|
||||
// ShareAuthorizationCode 用户分享授权码
|
||||
// @Summary 用户分享授权码
|
||||
// @Description 用户可以将自己的授权码分享给其他用户
|
||||
// @Description 用户可以将自己的授权码分享给其他用户(通过手机号或邮箱)
|
||||
// @Tags 用户端授权码管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -165,3 +165,114 @@ func (h *CuAuthorizationHandler) GetProductActivationCode(c *gin.Context) {
|
||||
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,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(),
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"license-manager/internal/api/middleware"
|
||||
"license-manager/internal/models"
|
||||
@@ -16,12 +15,14 @@ import (
|
||||
type CuOrderHandler struct {
|
||||
cuOrderService service.CuOrderService
|
||||
paymentService service.PaymentService
|
||||
packageService service.PackageService
|
||||
}
|
||||
|
||||
func NewCuOrderHandler(cuOrderService service.CuOrderService, paymentService service.PaymentService) *CuOrderHandler {
|
||||
func NewCuOrderHandler(cuOrderService service.CuOrderService, paymentService service.PaymentService, packageService service.PackageService) *CuOrderHandler {
|
||||
return &CuOrderHandler{
|
||||
cuOrderService: cuOrderService,
|
||||
paymentService: paymentService,
|
||||
packageService: packageService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,58 +37,43 @@ func NewCuOrderHandler(cuOrderService service.CuOrderService, paymentService ser
|
||||
func (h *CuOrderHandler) GetPackages(c *gin.Context) {
|
||||
lang := middleware.GetLanguage(c)
|
||||
|
||||
// 获取套餐列表
|
||||
packages := h.getPackageList()
|
||||
// 从数据库获取套餐列表
|
||||
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: packages,
|
||||
Data: result,
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
// getPackageList 获取套餐列表(暂时写死数据)
|
||||
func (h *CuOrderHandler) getPackageList() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{
|
||||
"id": "trial",
|
||||
"name": "试用版",
|
||||
"type": "trial",
|
||||
"price": 0,
|
||||
"max_devices": 1,
|
||||
"description": "免费体验全部功能",
|
||||
"details": "包含全部功能|试用期限制|1个许可等",
|
||||
},
|
||||
{
|
||||
"id": "basic",
|
||||
"name": "基础版",
|
||||
"type": "basic",
|
||||
"price": 300,
|
||||
"max_devices": 1000,
|
||||
"description": "小型企业最佳选择",
|
||||
"details": "包含基础功能|批量许可购买|折扣优惠等",
|
||||
},
|
||||
{
|
||||
"id": "professional",
|
||||
"name": "专业版",
|
||||
"type": "professional",
|
||||
"price": 2000,
|
||||
"max_devices": 1000,
|
||||
"description": "企业级完整解决方案",
|
||||
"details": "包含高级功能|技术支持|数据分析等",
|
||||
}, {
|
||||
"id": "custom",
|
||||
"name": "企业定制版",
|
||||
"type": "custom",
|
||||
"price": "定制报价",
|
||||
"max_devices": 0,
|
||||
"description": "按需定制",
|
||||
"details": "包含高级功能|技术支持|数据分析等",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CalculatePrice 计算订单价格
|
||||
// @Summary 计算订单价格
|
||||
// @Description 根据套餐和许可数量计算价格和折扣
|
||||
@@ -205,9 +191,26 @@ func (h *CuOrderHandler) CreateOrder(c *gin.Context) {
|
||||
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 {
|
||||
// 付费订单:创建订单和支付单
|
||||
@@ -369,6 +372,67 @@ func (h *CuOrderHandler) GetOrder(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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 获取当前用户的订单列表,支持分页
|
||||
@@ -378,22 +442,25 @@ func (h *CuOrderHandler) GetOrder(c *gin.Context) {
|
||||
// @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) {
|
||||
// 解析分页参数
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
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
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// 获取用户ID
|
||||
cuUserID, exists := c.Get("cu_user_id")
|
||||
@@ -409,7 +476,8 @@ func (h *CuOrderHandler) GetUserOrders(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 获取订单列表
|
||||
orders, total, err := h.cuOrderService.GetUserOrders(c.Request.Context(), cuUserID.(string), offset, pageSize)
|
||||
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,
|
||||
@@ -433,8 +501,8 @@ func (h *CuOrderHandler) GetUserOrders(c *gin.Context) {
|
||||
Data: &models.CuOrderListResponse{
|
||||
Orders: orderResponses,
|
||||
TotalCount: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
},
|
||||
Timestamp: getCurrentTimestamp(),
|
||||
})
|
||||
@@ -487,3 +555,130 @@ func (h *CuOrderHandler) GetOrderSummary(c *gin.Context) {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCuOrderHandler_getPackageList(t *testing.T) {
|
||||
handler := &CuOrderHandler{}
|
||||
|
||||
packages := handler.getPackageList()
|
||||
|
||||
// 验证返回了3种套餐
|
||||
assert.Equal(t, 3, len(packages))
|
||||
|
||||
// 验证试用版
|
||||
trial := packages[0]
|
||||
assert.Equal(t, "trial", trial["id"])
|
||||
assert.Equal(t, "试用版", trial["name"])
|
||||
assert.Equal(t, 0, trial["price"])
|
||||
assert.Equal(t, 1, trial["max_devices"])
|
||||
|
||||
// 验证基础版
|
||||
basic := packages[1]
|
||||
assert.Equal(t, "basic", basic["id"])
|
||||
assert.Equal(t, "基础版", basic["name"])
|
||||
assert.Equal(t, 300, basic["price"])
|
||||
|
||||
// 验证专业版
|
||||
professional := packages[2]
|
||||
assert.Equal(t, "professional", professional["id"])
|
||||
assert.Equal(t, "专业版", professional["name"])
|
||||
assert.Equal(t, 2000, professional["price"])
|
||||
}
|
||||
@@ -0,0 +1,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(),
|
||||
})
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const (
|
||||
CuUserPayloadKey = "cu_user"
|
||||
)
|
||||
|
||||
// AuthMiddleware 认证中间件
|
||||
// AuthMiddleware 认证中间件(支持自动延长)
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authorizationHeader := c.GetHeader(AuthorizationHeaderKey)
|
||||
@@ -81,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)
|
||||
@@ -156,7 +167,7 @@ func AdminOnlyMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// CustomerAuth 客户用户认证中间件
|
||||
// CustomerAuth 客户用户认证中间件(支持自动延长)
|
||||
func CustomerAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authorizationHeader := c.GetHeader(AuthorizationHeaderKey)
|
||||
@@ -217,6 +228,17 @@ func CustomerAuth() gin.HandlerFunc {
|
||||
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)
|
||||
|
||||
@@ -47,6 +47,10 @@ func SetupRouter() *gin.Engine {
|
||||
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()
|
||||
@@ -88,7 +92,8 @@ func SetupRouter() *gin.Engine {
|
||||
customerService := service.NewCustomerService(customerRepo)
|
||||
cuUserService := service.NewCuUserService(cuUserRepo, customerRepo, smsService, db)
|
||||
authCodeService := service.NewAuthorizationCodeService(authCodeRepo, customerRepo, cuUserRepo, licenseRepo)
|
||||
cuOrderService := service.NewCuOrderService(cuOrderRepo, cuUserRepo, authCodeRepo, db)
|
||||
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()
|
||||
@@ -101,18 +106,30 @@ func SetupRouter() *gin.Engine {
|
||||
customerHandler := handlers.NewCustomerHandler(customerService)
|
||||
cuAuthHandler := handlers.NewCuAuthHandler(cuUserService)
|
||||
cuProfileHandler := handlers.NewCuProfileHandler(cuUserService)
|
||||
cuOrderHandler := handlers.NewCuOrderHandler(cuOrderService, paymentService)
|
||||
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)
|
||||
|
||||
// 健康检测接口(无需认证)
|
||||
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")))
|
||||
|
||||
@@ -131,6 +148,12 @@ func SetupRouter() *gin.Engine {
|
||||
// 支付回调接口
|
||||
public.POST("/payment/alipay/callback", paymentHandler.AlipayCallback)
|
||||
public.POST("/v1/payment/alipay/callback", paymentHandler.AlipayCallback)
|
||||
|
||||
// 公共发票下载接口
|
||||
public.GET("/public/invoices/download", adminInvoiceHandler.DownloadByToken)
|
||||
|
||||
// 线索收集(无需鉴权)
|
||||
public.POST("/leads", leadHandler.CreateLead)
|
||||
}
|
||||
|
||||
// 需要认证的接口
|
||||
@@ -176,6 +199,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)
|
||||
}
|
||||
|
||||
// 管理员接口
|
||||
@@ -217,6 +264,8 @@ func SetupRouter() *gin.Engine {
|
||||
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)
|
||||
|
||||
@@ -227,11 +276,21 @@ func SetupRouter() *gin.Engine {
|
||||
// 授权码管理
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ func AutoMigrate() error {
|
||||
&models.AuthorizationCode{},
|
||||
&models.License{},
|
||||
&models.AuthorizationChange{},
|
||||
&models.Invoice{}, // 发票表
|
||||
&models.Package{}, // 套餐表
|
||||
&models.Lead{}, // 线索表
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to migrate database: %w", err)
|
||||
@@ -49,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
|
||||
}
|
||||
@@ -139,3 +147,74 @@ func initDefaultAdminUser() error {
|
||||
|
||||
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"` // 发票驳回人姓名(管理员)
|
||||
}
|
||||
@@ -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"` // 创建结束时间
|
||||
@@ -263,8 +264,8 @@ type AuthorizationChangeListResponse struct {
|
||||
|
||||
// AuthorizationCodeShareRequest 授权码分享请求结构
|
||||
type AuthorizationCodeShareRequest struct {
|
||||
TargetUserID string `json:"target_user_id" binding:"required"` // 受赠用户ID
|
||||
ShareCount int `json:"share_count" binding:"required,min=1"` // 分享激活次数
|
||||
TargetContact string `json:"target_contact" binding:"required"` // 受赠用户联系方式(手机号或邮箱)
|
||||
ShareCount int `json:"share_count" binding:"required,min=1"` // 分享激活次数
|
||||
}
|
||||
|
||||
// AuthorizationCodeShareResponse 授权码分享响应结构
|
||||
|
||||
@@ -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"` // 已驳回发票数量
|
||||
}
|
||||
@@ -24,6 +24,9 @@ type CuOrder struct {
|
||||
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:"-"`
|
||||
}
|
||||
|
||||
@@ -64,23 +67,35 @@ type CuOrderCreateRequest struct {
|
||||
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"`
|
||||
OrderNo string `json:"order_no"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
CuUserID string `json:"cu_user_id"`
|
||||
PackageID string `json:"package_id"`
|
||||
PackageName string `json:"package_name"`
|
||||
LicenseCount int `json:"license_count"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
DiscountRate float64 `json:"discount_rate"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
Status string `json:"status"`
|
||||
AuthorizationCode *string `json:"authorization_code"`
|
||||
ExpiredAt *time.Time `json:"expired_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
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 转换为响应结构
|
||||
@@ -101,6 +116,7 @@ func (o *CuOrder) ToResponse() *CuOrderResponse {
|
||||
ExpiredAt: o.ExpiredAt,
|
||||
CreatedAt: o.CreatedAt,
|
||||
UpdatedAt: o.UpdatedAt,
|
||||
HasInvoiceApplied: o.HasInvoiceApplied,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +130,7 @@ type CuOrderListResponse struct {
|
||||
|
||||
// OrderSummaryResponse 订单汇总响应结构
|
||||
type OrderSummaryResponse struct {
|
||||
TotalOrders int64 `json:"total_orders"` // 订单总数
|
||||
PendingOrders int64 `json:"pending_orders"` // 待支付订单数
|
||||
PaidOrders int64 `json:"paid_orders"` // 已支付订单数
|
||||
TotalOrders int64 `json:"total_orders"` // 订单总数
|
||||
PendingOrders int64 `json:"pending_orders"` // 待支付订单数
|
||||
PaidOrders int64 `json:"paid_orders"` // 已支付订单数
|
||||
}
|
||||
@@ -75,55 +75,56 @@ func (u *CuUser) IsAccountLocked() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// CuUserRegisterRequest 客户用户注册请求结构
|
||||
// CuUserRegisterRequest 客户用户注册请求结构
|
||||
type CuUserRegisterRequest struct {
|
||||
Phone string `json:"phone" binding:"required,len=11"`
|
||||
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
|
||||
SmsCode string `json:"sms_code" binding:"required,len=6"`
|
||||
CustomerID string `json:"customer_id" binding:"omitempty"` // 可选,不提供则自动创建客户
|
||||
Password string `json:"password" binding:"required,min=8,max=50"`
|
||||
RealName *string `json:"real_name" binding:"omitempty,max=100"`
|
||||
Email *string `json:"email" binding:"omitempty,email,max=255"`
|
||||
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"`
|
||||
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
|
||||
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"`
|
||||
Email *string `json:"email" binding:"omitempty,email,max=255"`
|
||||
AvatarURL *string `json:"avatar_url" binding:"omitempty,max=500"`
|
||||
Language *string `json:"language" binding:"omitempty,oneof=zh-CN en-US ja-JP"`
|
||||
Timezone *string `json:"timezone" binding:"omitempty,max=50"`
|
||||
AdditionalInfo *string `json:"additional_info" binding:"omitempty"`
|
||||
Remark *string `json:"remark" binding:"omitempty,max=1000"`
|
||||
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"`
|
||||
NewPhoneCountryCode string `json:"new_phone_country_code" binding:"required"`
|
||||
CurrentSmsCode string `json:"current_sms_code" binding:"required,len=6"` // 当前手机号验证码
|
||||
NewSmsCode string `json:"new_sms_code" binding:"required,len=6"` // 新手机号验证码
|
||||
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"`
|
||||
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
|
||||
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"`
|
||||
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
|
||||
Phone string `json:"phone" binding:"required,min=7,max=20"` // 手机号,必填,7-20位
|
||||
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
|
||||
}
|
||||
|
||||
// CuUserSendCurrentPhoneSmsRequest 发送当前手机号验证码请求结构
|
||||
@@ -133,51 +134,51 @@ type CuUserSendCurrentPhoneSmsRequest struct {
|
||||
|
||||
// CuUserSendNewPhoneSmsRequest 发送新手机号验证码请求结构
|
||||
type CuUserSendNewPhoneSmsRequest struct {
|
||||
NewPhone string `json:"new_phone" binding:"required,min=7,max=20"`
|
||||
NewPhoneCountryCode string `json:"new_phone_country_code" binding:"omitempty"` // 可选,默认+86
|
||||
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"`
|
||||
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
|
||||
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"`
|
||||
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
|
||||
SmsCode string `json:"sms_code" binding:"required,len=6"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=8,max=50"`
|
||||
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"`
|
||||
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"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
Phone string `json:"phone"`
|
||||
PhoneCountryCode string `json:"phone_country_code"`
|
||||
UserRole string `json:"user_role"`
|
||||
RealName *string `json:"real_name"`
|
||||
Email *string `json:"email"`
|
||||
Status string `json:"status"`
|
||||
PhoneVerified bool `json:"phone_verified"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
LastLoginAt *string `json:"last_login_at,omitempty"`
|
||||
LastLoginIP *string `json:"last_login_ip,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
Language string `json:"language"`
|
||||
Timezone string `json:"timezone"`
|
||||
AdditionalInfo *string `json:"additional_info"`
|
||||
Remark *string `json:"remark"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
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 转换为响应结构
|
||||
|
||||
@@ -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 许可证列表项结构
|
||||
@@ -186,11 +186,11 @@ type GrowthRate struct {
|
||||
|
||||
// DeviceListRequest 设备列表查询请求结构(客户用户接口)
|
||||
type DeviceListRequest struct {
|
||||
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
|
||||
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页数量,默认20,最大100
|
||||
DeviceName string `form:"device_name" binding:"omitempty,max=100"` // 设备名称模糊搜索(匹配device_info.name)
|
||||
AuthorizationCodeID string `form:"authorization_code_id" binding:"omitempty,len=36"` // 按授权码ID筛选设备
|
||||
IsOnline *bool `form:"is_online" binding:"omitempty"` // 是否在线筛选(true在线,false离线;不传不过滤)
|
||||
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 设备列表项结构(客户用户接口)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -109,27 +109,27 @@ func (p *Payment) ToResponse() *PaymentResponse {
|
||||
|
||||
// PaymentStatusResponse 支付状态响应结构
|
||||
type PaymentStatusResponse struct {
|
||||
PaymentNo string `json:"payment_no"`
|
||||
Status string `json:"status"`
|
||||
Amount float64 `json:"amount"`
|
||||
PaymentTime *time.Time `json:"payment_time"`
|
||||
TradeNo *string `json:"trade_no"`
|
||||
BusinessOrder *PaymentBusinessOrder `json:"business_order,omitempty"`
|
||||
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"`
|
||||
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"`
|
||||
Payments []*PaymentResponse `json:"payments"` // 支付记录列表
|
||||
TotalCount int64 `json:"total_count"` // 总记录数
|
||||
Page int `json:"page"` // 当前页码
|
||||
PageSize int `json:"page_size"` // 每页条数
|
||||
}
|
||||
|
||||
// 业务类型常量
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"license-manager/internal/models"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -11,7 +13,7 @@ type CuOrderRepository interface {
|
||||
Create(order *models.CuOrder) error
|
||||
GetByID(id string) (*models.CuOrder, error)
|
||||
GetByOrderNo(orderNo string) (*models.CuOrder, error)
|
||||
GetByCuUserID(cuUserID string, offset, limit int) ([]*models.CuOrder, int64, error)
|
||||
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
|
||||
@@ -49,11 +51,44 @@ func (r *cuOrderRepository) GetByOrderNo(orderNo string) (*models.CuOrder, error
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (r *cuOrderRepository) GetByCuUserID(cuUserID string, offset, limit int) ([]*models.CuOrder, int64, error) {
|
||||
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
|
||||
|
||||
query := r.db.Model(&models.CuOrder{}).Where("cu_user_id = ? AND deleted_at IS NULL", cuUserID)
|
||||
// 默认值
|
||||
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 {
|
||||
@@ -61,7 +96,7 @@ func (r *cuOrderRepository) GetByCuUserID(cuUserID string, offset, limit int) ([
|
||||
}
|
||||
|
||||
// 获取分页数据
|
||||
err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&orders).Error
|
||||
err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&orders).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -94,7 +129,8 @@ func (r *cuOrderRepository) Update(order *models.CuOrder) error {
|
||||
}
|
||||
|
||||
func (r *cuOrderRepository) Delete(id string) error {
|
||||
return r.db.Where("id = ?", id).Delete(&models.CuOrder{}).Error
|
||||
// 使用 Unscoped() 进行物理删除而不是软删除
|
||||
return r.db.Unscoped().Where("id = ?", id).Delete(&models.CuOrder{}).Error
|
||||
}
|
||||
|
||||
// GetPendingOrders 移除:不再有pending状态的订单
|
||||
@@ -103,12 +139,17 @@ func (r *cuOrderRepository) Delete(id string) error {
|
||||
|
||||
func (r *cuOrderRepository) CheckTrialOrderExists(cuUserID string, currentMonth string) (bool, error) {
|
||||
var count int64
|
||||
startOfMonth := currentMonth + "-01"
|
||||
endOfMonth := currentMonth + "-31"
|
||||
startOfMonth := currentMonth + "-01 00:00:00"
|
||||
endOfMonth := currentMonth + "-31 23:59:59"
|
||||
|
||||
err := r.db.Model(&models.CuOrder{}).
|
||||
Where("cu_user_id = ? AND package_id = ? AND status = ? AND created_at >= ? AND created_at <= ? AND deleted_at IS NULL",
|
||||
cuUserID, "trial", "paid", startOfMonth, endOfMonth).
|
||||
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
|
||||
|
||||
@@ -92,6 +92,12 @@ type AuthorizationCodeRepository interface {
|
||||
|
||||
// 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 许可证数据访问接口
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -66,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,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)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
@@ -169,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
|
||||
@@ -238,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
|
||||
@@ -299,10 +321,10 @@ func (r *licenseRepository) GetCustomerDeviceList(ctx context.Context, customerI
|
||||
}
|
||||
|
||||
// 在线状态筛选
|
||||
if req.IsOnline != nil {
|
||||
if *req.IsOnline {
|
||||
if req.IsOnline != nil && *req.IsOnline != "" {
|
||||
if *req.IsOnline == "true" {
|
||||
query = query.Where("licenses.last_heartbeat > ?", onlineThreshold)
|
||||
} else {
|
||||
} else if *req.IsOnline == "false" {
|
||||
query = query.Where("(licenses.last_heartbeat <= ? OR licenses.last_heartbeat IS NULL)", onlineThreshold)
|
||||
}
|
||||
}
|
||||
@@ -355,11 +377,11 @@ func (r *licenseRepository) GetCustomerDeviceList(ctx context.Context, customerI
|
||||
// 格式化时间字段
|
||||
var activatedAtStr, lastHeartbeatStr *string
|
||||
if result.ActivatedAt != nil {
|
||||
str := result.ActivatedAt.Format("2006-01-02T15:04:05Z")
|
||||
str := result.ActivatedAt.Format(time.RFC3339)
|
||||
activatedAtStr = &str
|
||||
}
|
||||
if result.LastHeartbeat != nil {
|
||||
str := result.LastHeartbeat.Format("2006-01-02T15:04:05Z")
|
||||
str := result.LastHeartbeat.Format(time.RFC3339)
|
||||
lastHeartbeatStr = &str
|
||||
}
|
||||
|
||||
@@ -379,7 +401,7 @@ func (r *licenseRepository) GetCustomerDeviceList(ctx context.Context, customerI
|
||||
AuthorizationInfo: models.AuthorizationInfo{
|
||||
AuthorizationCode: result.AuthorizationCode,
|
||||
AuthorizationCodeID: result.AuthorizationCodeID,
|
||||
EndDate: result.EndDate.Format("2006-01-02T15:04:05Z"),
|
||||
EndDate: result.EndDate.Format(time.RFC3339),
|
||||
Description: description,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -39,7 +39,11 @@ func (r *paymentRepository) GetByPaymentNo(ctx context.Context, paymentNo string
|
||||
|
||||
func (r *paymentRepository) GetByBusinessID(ctx context.Context, businessType, businessID string) (*models.Payment, error) {
|
||||
var payment models.Payment
|
||||
err := r.db.WithContext(ctx).Where("business_type = ? AND business_id = ?", businessType, businessID).First(&payment).Error
|
||||
// 按创建时间倒序,获取最新的支付单
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -760,13 +760,8 @@ func (s *authorizationCodeService) ShareAuthorizationCode(ctx context.Context, a
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 验证不能分享给自己
|
||||
if req.TargetUserID == userID {
|
||||
return nil, i18n.NewI18nError("300105", lang) // 不能分享给自己
|
||||
}
|
||||
|
||||
// 验证目标用户是否存在
|
||||
targetUser, err := s.cuUserRepo.GetByID(req.TargetUserID)
|
||||
// 根据联系方式查找目标用户
|
||||
targetUser, err := s.findUserByContact(req.TargetContact)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, i18n.NewI18nError("300104", lang) // 目标用户不存在
|
||||
@@ -774,6 +769,11 @@ func (s *authorizationCodeService) ShareAuthorizationCode(ctx context.Context, a
|
||||
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) // 目标用户不存在或状态异常
|
||||
@@ -844,7 +844,7 @@ func (s *authorizationCodeService) ShareAuthorizationCode(ctx context.Context, a
|
||||
newAuthCode = &models.AuthorizationCode{
|
||||
Code: code,
|
||||
CustomerID: targetUser.CustomerID, // 使用目标用户的客户ID
|
||||
CreatedBy: req.TargetUserID, // 记录为目标用户创建的
|
||||
CreatedBy: targetUser.ID, // 记录为目标用户创建的
|
||||
SoftwareID: authCode.SoftwareID,
|
||||
Description: authCode.Description,
|
||||
StartDate: now, // 从分享时刻开始
|
||||
@@ -894,3 +894,75 @@ func (s *authorizationCodeService) ShareAuthorizationCode(ctx context.Context, a
|
||||
|
||||
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,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
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func parseCuOrderTimeRange(value string, now time.Time) (*time.Time, *time.Time, error) {
|
||||
raw := strings.TrimSpace(value)
|
||||
if raw == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
loc := now.Location()
|
||||
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
startOfTomorrow := startOfToday.AddDate(0, 0, 1)
|
||||
|
||||
switch strings.ToLower(raw) {
|
||||
case "today", "今天":
|
||||
return &startOfToday, &startOfTomorrow, nil
|
||||
case "week", "this_week", "本周":
|
||||
// 默认按周一作为一周开始
|
||||
daysSinceMonday := (int(now.Weekday()) + 6) % 7
|
||||
start := startOfToday.AddDate(0, 0, -daysSinceMonday)
|
||||
end := start.AddDate(0, 0, 7)
|
||||
return &start, &end, nil
|
||||
case "month", "this_month", "本月":
|
||||
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
|
||||
end := start.AddDate(0, 1, 0)
|
||||
return &start, &end, nil
|
||||
case "three_months", "3months", "last_3_months", "近三个月":
|
||||
threeMonthsAgo := now.AddDate(0, -3, 0)
|
||||
start := time.Date(threeMonthsAgo.Year(), threeMonthsAgo.Month(), threeMonthsAgo.Day(), 0, 0, 0, 0, loc)
|
||||
return &start, &startOfTomorrow, nil
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("invalid time filter: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseCuOrderTimeRange(t *testing.T) {
|
||||
loc := time.FixedZone("CST", 8*3600)
|
||||
now := time.Date(2026, 1, 14, 15, 4, 5, 0, loc) // Wednesday
|
||||
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
start, end, err := parseCuOrderTimeRange("", now)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
if start != nil || end != nil {
|
||||
t.Fatalf("expected nil start/end, got %v %v", start, end)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("today", func(t *testing.T) {
|
||||
start, end, err := parseCuOrderTimeRange("today", now)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
wantStart := time.Date(2026, 1, 14, 0, 0, 0, 0, loc)
|
||||
wantEnd := time.Date(2026, 1, 15, 0, 0, 0, 0, loc)
|
||||
if start == nil || end == nil || !start.Equal(wantStart) || !end.Equal(wantEnd) {
|
||||
t.Fatalf("unexpected range: start=%v end=%v", start, end)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("this week", func(t *testing.T) {
|
||||
start, end, err := parseCuOrderTimeRange("week", now)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
wantStart := time.Date(2026, 1, 12, 0, 0, 0, 0, loc) // Monday
|
||||
wantEnd := time.Date(2026, 1, 19, 0, 0, 0, 0, loc)
|
||||
if start == nil || end == nil || !start.Equal(wantStart) || !end.Equal(wantEnd) {
|
||||
t.Fatalf("unexpected range: start=%v end=%v", start, end)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("this month", func(t *testing.T) {
|
||||
start, end, err := parseCuOrderTimeRange("month", now)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
wantStart := time.Date(2026, 1, 1, 0, 0, 0, 0, loc)
|
||||
wantEnd := time.Date(2026, 2, 1, 0, 0, 0, 0, loc)
|
||||
if start == nil || end == nil || !start.Equal(wantStart) || !end.Equal(wantEnd) {
|
||||
t.Fatalf("unexpected range: start=%v end=%v", start, end)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("last three months", func(t *testing.T) {
|
||||
start, end, err := parseCuOrderTimeRange("three_months", now)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
wantStart := time.Date(2025, 10, 14, 0, 0, 0, 0, loc)
|
||||
wantEnd := time.Date(2026, 1, 15, 0, 0, 0, 0, loc)
|
||||
if start == nil || end == nil || !start.Equal(wantStart) || !end.Equal(wantEnd) {
|
||||
t.Fatalf("unexpected range: start=%v end=%v", start, end)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
_, _, err := parseCuOrderTimeRange("nope", now)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/repository"
|
||||
@@ -19,10 +20,23 @@ type CuOrderService interface {
|
||||
CreateOrder(ctx context.Context, cuUserID, customerID string, req *models.CuOrderCreateRequest) (*models.CuOrder, error)
|
||||
CreatePendingOrder(ctx context.Context, cuUserID, customerID string, req *models.CuOrderCreateRequest) (*models.CuOrder, error)
|
||||
GetOrder(ctx context.Context, orderID, cuUserID string) (*models.CuOrder, error)
|
||||
GetUserOrders(ctx context.Context, cuUserID string, offset, limit int) ([]*models.CuOrder, int64, error)
|
||||
GetUserOrders(ctx context.Context, cuUserID string, req *models.CuOrderListRequest) ([]*models.CuOrder, int64, error)
|
||||
GetOrderSummary(ctx context.Context, customerID string) (*models.OrderSummaryResponse, error)
|
||||
CalculatePrice(ctx context.Context, packageID string, licenseCount int) (*PriceCalculationResult, error)
|
||||
CancelOrder(ctx context.Context, orderID, cuUserID string) (*models.CuOrder, error)
|
||||
DeleteOrder(ctx context.Context, orderID, cuUserID string) error
|
||||
UpdateOrderStatus(ctx context.Context, orderID string, status string) error
|
||||
ContinuePay(ctx context.Context, orderID, cuUserID, customerID, paymentMethod string) (*ContinuePayResponse, error)
|
||||
}
|
||||
|
||||
// ContinuePayResponse 继续支付响应
|
||||
type ContinuePayResponse struct {
|
||||
OrderID string `json:"order_id"` // 订单ID
|
||||
OrderNo string `json:"order_no"` // 订单号
|
||||
PaymentNo string `json:"payment_no"` // 支付单号
|
||||
PaymentURL *string `json:"payment_url"` // 支付链接
|
||||
TotalAmount float64 `json:"total_amount"` // 订单总金额
|
||||
ExpireTime time.Time `json:"expire_time"` // 支付过期时间
|
||||
}
|
||||
|
||||
type PriceCalculationResult struct {
|
||||
@@ -33,31 +47,6 @@ type PriceCalculationResult struct {
|
||||
DiscountDesc string `json:"discount_description"`
|
||||
}
|
||||
|
||||
// 套餐配置(临时写死,后续可改为配置化)
|
||||
var packageConfigs = map[string]PackageConfig{
|
||||
"trial": {
|
||||
Name: "试用版",
|
||||
Price: 0,
|
||||
MaxDevices: 1, // 试用版限制1个许可
|
||||
},
|
||||
"basic": {
|
||||
Name: "基础版",
|
||||
Price: 300,
|
||||
MaxDevices: 1000, // 基础版最多1000个许可
|
||||
},
|
||||
"professional": {
|
||||
Name: "专业版",
|
||||
Price: 2000,
|
||||
MaxDevices: 1000, // 专业版最多1000个许可
|
||||
},
|
||||
}
|
||||
|
||||
type PackageConfig struct {
|
||||
Name string
|
||||
Price float64
|
||||
MaxDevices int
|
||||
}
|
||||
|
||||
// 折扣规则配置
|
||||
var discountRules = []DiscountRule{
|
||||
{MinQuantity: 50, MaxQuantity: 99, Rate: 0.9, Description: "50-99许可9折优惠"},
|
||||
@@ -76,41 +65,86 @@ type cuOrderService struct {
|
||||
repo repository.CuOrderRepository
|
||||
cuUserRepo repository.CuUserRepository
|
||||
authCodeRepo repository.AuthorizationCodeRepository
|
||||
packageRepo repository.PackageRepository
|
||||
paymentRepo repository.PaymentRepository
|
||||
invoiceRepo repository.CuInvoiceRepository
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCuOrderService(repo repository.CuOrderRepository, cuUserRepo repository.CuUserRepository, authCodeRepo repository.AuthorizationCodeRepository, db *gorm.DB) CuOrderService {
|
||||
func NewCuOrderService(
|
||||
repo repository.CuOrderRepository,
|
||||
cuUserRepo repository.CuUserRepository,
|
||||
authCodeRepo repository.AuthorizationCodeRepository,
|
||||
packageRepo repository.PackageRepository,
|
||||
paymentRepo repository.PaymentRepository,
|
||||
invoiceRepo repository.CuInvoiceRepository,
|
||||
db *gorm.DB,
|
||||
) CuOrderService {
|
||||
return &cuOrderService{
|
||||
repo: repo,
|
||||
cuUserRepo: cuUserRepo,
|
||||
authCodeRepo: authCodeRepo,
|
||||
packageRepo: packageRepo,
|
||||
paymentRepo: paymentRepo,
|
||||
invoiceRepo: invoiceRepo,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cuOrderService) CreatePendingOrder(ctx context.Context, cuUserID, customerID string, req *models.CuOrderCreateRequest) (*models.CuOrder, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 计算价格
|
||||
priceResult, err := s.CalculatePrice(ctx, req.PackageID, req.LicenseCount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 从数据库获取套餐配置
|
||||
pkgEntity, maxDevices, err := s.getPackageForOrder(ctx, req.PackageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 对于试用版,进行特殊检查
|
||||
if pkgEntity.Type == string(models.PackageTypeTrial) {
|
||||
// 试用版不支持批量购买
|
||||
if req.LicenseCount != 1 {
|
||||
return nil, i18n.NewI18nError("600004", lang) // 试用版不支持批量购买
|
||||
}
|
||||
|
||||
// 检查购买时间限制
|
||||
now := time.Now()
|
||||
if now.Day() > 25 {
|
||||
return nil, i18n.NewI18nError("600003", lang) // 试用版购买时间限制
|
||||
}
|
||||
|
||||
// 每个用户每月只能有一个试用版(包括pending的)
|
||||
currentMonth := now.Format("2006-01")
|
||||
exists, err := s.repo.CheckTrialOrderExists(cuUserID, currentMonth)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
if exists {
|
||||
return nil, i18n.NewI18nError("600005", lang) // 当月已购买试用版
|
||||
}
|
||||
}
|
||||
|
||||
// 再次校验数量(防御性校验)
|
||||
if req.LicenseCount > maxDevices {
|
||||
return nil, i18n.NewI18nError("601005", lang)
|
||||
}
|
||||
|
||||
// 生成订单号
|
||||
orderNo := s.generateOrderNo()
|
||||
|
||||
// 获取套餐配置
|
||||
packageConfig, exists := packageConfigs[req.PackageID]
|
||||
if !exists {
|
||||
return nil, i18n.NewI18nError("600001", "套餐不存在")
|
||||
}
|
||||
|
||||
// 创建订单(pending状态)
|
||||
order := &models.CuOrder{
|
||||
OrderNo: orderNo,
|
||||
CustomerID: customerID,
|
||||
CuUserID: cuUserID,
|
||||
PackageID: req.PackageID,
|
||||
PackageName: packageConfig.Name,
|
||||
PackageName: pkgEntity.Name,
|
||||
LicenseCount: req.LicenseCount,
|
||||
UnitPrice: priceResult.UnitPrice,
|
||||
DiscountRate: priceResult.DiscountRate,
|
||||
@@ -118,8 +152,8 @@ func (s *cuOrderService) CreatePendingOrder(ctx context.Context, cuUserID, custo
|
||||
Status: "pending", // 待支付状态
|
||||
}
|
||||
|
||||
// 对于试用版,设置过期时间
|
||||
if req.PackageID == "trial" {
|
||||
// 对于试用版,设置过期时间(根据套餐类型判断)
|
||||
if pkgEntity.Type == string(models.PackageTypeTrial) {
|
||||
expiredAt := s.getTrialExpiryTime()
|
||||
order.ExpiredAt = &expiredAt
|
||||
}
|
||||
@@ -152,19 +186,19 @@ func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID s
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 检查套餐是否存在
|
||||
pkg, exists := packageConfigs[req.PackageID]
|
||||
if !exists {
|
||||
return nil, i18n.NewI18nError("600001", lang) // 套餐不存在
|
||||
// 检查套餐是否存在(从数据库)
|
||||
pkgEntity, maxDevices, err := s.getPackageForOrder(ctx, req.PackageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 验证许可数量
|
||||
if req.LicenseCount > pkg.MaxDevices {
|
||||
if req.LicenseCount > maxDevices {
|
||||
return nil, i18n.NewI18nError("601005", lang) // 许可数量超出限制
|
||||
}
|
||||
|
||||
// 试用版特殊检查
|
||||
if req.PackageID == "trial" {
|
||||
// 试用版特殊检查(根据套餐类型判断)
|
||||
if pkgEntity.Type == string(models.PackageTypeTrial) {
|
||||
// 试用版不支持批量购买
|
||||
if req.LicenseCount != 1 {
|
||||
return nil, i18n.NewI18nError("600004", lang) // 试用版不支持批量购买
|
||||
@@ -202,7 +236,7 @@ func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID s
|
||||
CustomerID: customerID,
|
||||
CuUserID: cuUserID,
|
||||
PackageID: req.PackageID,
|
||||
PackageName: pkg.Name,
|
||||
PackageName: pkgEntity.Name,
|
||||
LicenseCount: req.LicenseCount,
|
||||
UnitPrice: priceResult.UnitPrice,
|
||||
DiscountRate: priceResult.DiscountRate,
|
||||
@@ -232,7 +266,7 @@ func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID s
|
||||
// 计算授权时间
|
||||
var startDate, endDate time.Time
|
||||
var expiredAt *time.Time
|
||||
if req.PackageID == "trial" {
|
||||
if pkgEntity.Type == string(models.PackageTypeTrial) {
|
||||
// 试用版:从今天到当月25日
|
||||
trialExpiry := time.Date(now.Year(), now.Month(), 25, 23, 59, 59, 0, now.Location())
|
||||
if now.Day() > 25 {
|
||||
@@ -254,7 +288,7 @@ func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID s
|
||||
var featureConfigMap, usageLimitsMap map[string]interface{}
|
||||
|
||||
// 根据套餐类型设置配置信息
|
||||
if req.PackageID == "basic" {
|
||||
if pkgEntity.Type == string(models.PackageTypeBasic) {
|
||||
usageLimitsMap = map[string]interface{}{
|
||||
"type": "standard",
|
||||
}
|
||||
@@ -262,7 +296,7 @@ func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID s
|
||||
|
||||
customParametersMap := map[string]interface{}{
|
||||
"package_id": req.PackageID,
|
||||
"package_name": pkg.Name,
|
||||
"package_name": pkgEntity.Name,
|
||||
"license_count": req.LicenseCount,
|
||||
}
|
||||
|
||||
@@ -274,7 +308,7 @@ func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID s
|
||||
}
|
||||
|
||||
// 构建订单描述
|
||||
description := fmt.Sprintf("%s - %d个许可", pkg.Name, req.LicenseCount)
|
||||
description := fmt.Sprintf("%s - %d个许可", pkgEntity.Name, req.LicenseCount)
|
||||
|
||||
// 处理JSON字段(用于数据库存储与后续生成产品激活码 payload)
|
||||
var featureConfig, usageLimits, customParameters models.JSON
|
||||
@@ -363,7 +397,93 @@ func (s *cuOrderService) GetOrder(ctx context.Context, orderID, cuUserID string)
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (s *cuOrderService) GetUserOrders(ctx context.Context, cuUserID string, offset, limit int) ([]*models.CuOrder, int64, error) {
|
||||
func (s *cuOrderService) CancelOrder(ctx context.Context, orderID, cuUserID string) (*models.CuOrder, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
if orderID == "" || cuUserID == "" {
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
order, err := s.repo.GetByID(orderID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, i18n.NewI18nError("601001", lang) // 订单不存在
|
||||
}
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 检查权限:只能取消自己的订单
|
||||
if order.CuUserID != cuUserID {
|
||||
return nil, i18n.NewI18nError("100005", lang) // 权限不足
|
||||
}
|
||||
|
||||
switch order.Status {
|
||||
case "cancelled":
|
||||
// 幂等:已取消直接返回成功
|
||||
return order, nil
|
||||
case "paid":
|
||||
return nil, i18n.NewI18nError("601006", lang) // 订单已支付,无法取消
|
||||
case "pending":
|
||||
order.Status = "cancelled"
|
||||
if err := s.repo.Update(order); err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
return order, nil
|
||||
default:
|
||||
// 兜底:未知状态按不可取消处理
|
||||
return nil, i18n.NewI18nError("601006", lang)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cuOrderService) DeleteOrder(ctx context.Context, orderID, cuUserID string) error {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
if orderID == "" || cuUserID == "" {
|
||||
return i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
order, err := s.repo.GetByID(orderID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return i18n.NewI18nError("601001", lang) // 订单不存在
|
||||
}
|
||||
return i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 检查权限:只能删除自己的订单
|
||||
if order.CuUserID != cuUserID {
|
||||
return i18n.NewI18nError("100005", lang) // 权限不足
|
||||
}
|
||||
|
||||
// 开启事务,确保订单和相关支付记录一起删除
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// 删除相关的支付记录
|
||||
if err := tx.Unscoped().Where("business_type = ? AND business_id = ?", models.BusinessTypePackageOrder, orderID).Delete(&models.Payment{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 删除订单记录(物理删除)
|
||||
if err := tx.Unscoped().Where("id = ?", orderID).Delete(&models.CuOrder{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *cuOrderService) GetUserOrders(ctx context.Context, cuUserID string, req *models.CuOrderListRequest) ([]*models.CuOrder, int64, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 参数验证
|
||||
@@ -371,11 +491,40 @@ func (s *cuOrderService) GetUserOrders(ctx context.Context, cuUserID string, off
|
||||
return nil, 0, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
orders, total, err := s.repo.GetByCuUserID(cuUserID, offset, limit)
|
||||
if req == nil {
|
||||
req = &models.CuOrderListRequest{}
|
||||
}
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize <= 0 {
|
||||
req.PageSize = 10
|
||||
}
|
||||
if req.PageSize > 100 {
|
||||
req.PageSize = 100
|
||||
}
|
||||
|
||||
createdAtStart, createdAtEnd, err := parseCuOrderTimeRange(req.Time, time.Now())
|
||||
if err != nil {
|
||||
return nil, 0, i18n.NewI18nError("900001", lang, err.Error())
|
||||
}
|
||||
|
||||
orders, total, err := s.repo.GetByCuUserID(ctx, cuUserID, req, createdAtStart, createdAtEnd)
|
||||
if err != nil {
|
||||
return nil, 0, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 为订单填充“是否已申请发票”标记
|
||||
if s.invoiceRepo != nil && len(orders) > 0 {
|
||||
for _, order := range orders {
|
||||
exists, err := s.invoiceRepo.CheckOrderInvoiceExists(order.ID)
|
||||
if err != nil {
|
||||
return nil, 0, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
order.HasInvoiceApplied = exists
|
||||
}
|
||||
}
|
||||
|
||||
return orders, total, nil
|
||||
}
|
||||
|
||||
@@ -395,20 +544,14 @@ func (s *cuOrderService) GetOrderSummary(ctx context.Context, customerID string)
|
||||
func (s *cuOrderService) CalculatePrice(ctx context.Context, packageID string, licenseCount int) (*PriceCalculationResult, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 检查套餐是否存在
|
||||
pkg, exists := packageConfigs[packageID]
|
||||
if !exists {
|
||||
return nil, i18n.NewI18nError("600001", lang) // 套餐不存在
|
||||
// 从数据库获取套餐配置
|
||||
pkgEntity, maxDevices, err := s.getPackageForOrder(ctx, packageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查套餐是否已下架(虽然当前是写死配置,但保留这个检查)
|
||||
// TODO: 如果将来添加status字段,可以在这里检查
|
||||
// if pkg.Status != "active" {
|
||||
// return nil, i18n.NewI18nError("600002", lang) // 套餐已下架
|
||||
// }
|
||||
|
||||
// 验证许可数量
|
||||
if licenseCount > pkg.MaxDevices {
|
||||
if licenseCount > maxDevices {
|
||||
return nil, i18n.NewI18nError("601005", lang) // 许可数量超出限制
|
||||
}
|
||||
|
||||
@@ -424,10 +567,10 @@ func (s *cuOrderService) CalculatePrice(ctx context.Context, packageID string, l
|
||||
}
|
||||
|
||||
// 计算总价
|
||||
totalAmount := pkg.Price * float64(licenseCount) * discountRate
|
||||
totalAmount := pkgEntity.Price * float64(licenseCount) * discountRate
|
||||
|
||||
return &PriceCalculationResult{
|
||||
UnitPrice: pkg.Price,
|
||||
UnitPrice: pkgEntity.Price,
|
||||
LicenseCount: licenseCount,
|
||||
DiscountRate: discountRate,
|
||||
TotalAmount: totalAmount,
|
||||
@@ -446,3 +589,108 @@ func (s *cuOrderService) generateOrderNo() string {
|
||||
|
||||
return fmt.Sprintf("ORD%s%09d", dateStr, nano)
|
||||
}
|
||||
|
||||
// getPackageForOrder 从数据库获取套餐配置,并解析最大许可数量
|
||||
func (s *cuOrderService) getPackageForOrder(ctx context.Context, packageID string) (*models.Package, int, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
if packageID == "" {
|
||||
return nil, 0, i18n.NewI18nError("600001", lang)
|
||||
}
|
||||
|
||||
pkgEntity, err := s.packageRepo.GetByID(packageID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, 0, i18n.NewI18nError("600001", lang) // 套餐不存在
|
||||
}
|
||||
return nil, 0, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 检查套餐是否启用
|
||||
if pkgEntity.Status != 1 {
|
||||
return nil, 0, i18n.NewI18nError("600002", lang) // 套餐已下架
|
||||
}
|
||||
|
||||
maxDevices := parseMaxDevicesFromFeatures(pkgEntity.Features)
|
||||
return pkgEntity, maxDevices, nil
|
||||
}
|
||||
|
||||
// parseMaxDevicesFromFeatures 从 features JSON 中解析 "X个许可"
|
||||
func parseMaxDevicesFromFeatures(features string) int {
|
||||
// 默认最大许可数,与原始写死配置保持一致
|
||||
maxDevices := 1000
|
||||
if features == "" {
|
||||
return maxDevices
|
||||
}
|
||||
|
||||
var featureList []string
|
||||
if err := json.Unmarshal([]byte(features), &featureList); err != nil {
|
||||
return maxDevices
|
||||
}
|
||||
|
||||
for _, f := range featureList {
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(f, "%d个许可", &n); err == nil && n > 0 {
|
||||
maxDevices = n
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return maxDevices
|
||||
}
|
||||
|
||||
// ContinuePay 继续支付:获取订单的支付信息,如果支付单已过期则返回订单信息供重新创建支付单
|
||||
func (s *cuOrderService) ContinuePay(ctx context.Context, orderID, cuUserID, customerID, paymentMethod string) (*ContinuePayResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 1. 验证订单
|
||||
order, err := s.repo.GetByID(orderID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, i18n.NewI18nError("601001", lang) // 订单不存在
|
||||
}
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 2. 检查权限:只能支付自己的订单
|
||||
if order.CuUserID != cuUserID {
|
||||
return nil, i18n.NewI18nError("100005", lang) // 权限不足
|
||||
}
|
||||
|
||||
// 3. 检查订单状态:只有待支付订单才能继续支付
|
||||
if order.Status != "pending" {
|
||||
return nil, i18n.NewI18nError("601007", lang) // 订单状态不允许继续支付
|
||||
}
|
||||
|
||||
// 4. 查询订单的最新支付单
|
||||
payment, err := s.paymentRepo.GetByBusinessID(ctx, models.BusinessTypePackageOrder, orderID)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 5. 如果支付单存在且可用(pending 且未过期),直接返回
|
||||
if payment != nil && err == nil {
|
||||
now := time.Now()
|
||||
if payment.Status == models.PaymentStatusPending && payment.ExpireTime.After(now) {
|
||||
return &ContinuePayResponse{
|
||||
OrderID: order.ID,
|
||||
OrderNo: order.OrderNo,
|
||||
PaymentNo: payment.PaymentNo,
|
||||
PaymentURL: payment.PaymentURL,
|
||||
TotalAmount: order.TotalAmount,
|
||||
ExpireTime: payment.ExpireTime,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 支付单不存在或已过期,返回订单信息(让 handler 层创建新的支付单)
|
||||
// 这里返回一个特殊标记,表示需要创建新的支付单
|
||||
return &ContinuePayResponse{
|
||||
OrderID: order.ID,
|
||||
OrderNo: order.OrderNo,
|
||||
PaymentNo: "", // 空字符串表示需要创建新的支付单
|
||||
PaymentURL: nil,
|
||||
TotalAmount: order.TotalAmount,
|
||||
ExpireTime: time.Time{}, // 零值表示需要创建新的支付单
|
||||
}, nil
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func (s *cuUserService) Login(ctx context.Context, req *models.CuUserLoginReques
|
||||
// 密码登录(默认行为)
|
||||
// 检查是否被锁定
|
||||
if user.IsAccountLocked() {
|
||||
return nil, "", i18n.NewI18nError("500003", lang) // 账号已被锁定
|
||||
return nil, "", i18n.NewI18nError("500018", lang) // 账号已被锁定
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
@@ -210,7 +210,7 @@ func (s *cuUserService) Login(ctx context.Context, req *models.CuUserLoginReques
|
||||
if err := s.repo.LockAccount(user.ID, lockUntil); err != nil {
|
||||
// 记录错误但不影响登录流程
|
||||
}
|
||||
return nil, "", i18n.NewI18nError("500003", lang) // 账号已被锁定
|
||||
return nil, "", i18n.NewI18nError("500018", lang) // 账号已被锁定
|
||||
}
|
||||
|
||||
return nil, "", i18n.NewI18nError("500003", lang) // 手机号或密码错误
|
||||
|
||||
@@ -240,7 +240,7 @@ func (s *customerService) DeleteCustomer(ctx context.Context, id string) error {
|
||||
return i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
if hasAuthCodes {
|
||||
return i18n.NewI18nError("200006", lang)
|
||||
return i18n.NewI18nError("200008", lang)
|
||||
}
|
||||
|
||||
// 检查是否有关联的许可证
|
||||
@@ -249,7 +249,7 @@ func (s *customerService) DeleteCustomer(ctx context.Context, id string) error {
|
||||
return i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
if hasLicenses {
|
||||
return i18n.NewI18nError("200006", lang)
|
||||
return i18n.NewI18nError("200008", lang)
|
||||
}
|
||||
|
||||
// 委托给Repository层进行物理删除
|
||||
|
||||
@@ -50,6 +50,10 @@ type AuthorizationCodeService interface {
|
||||
|
||||
// 用户端获取产品激活码
|
||||
GetProductActivationCode(ctx context.Context, customerID string, req *models.ProductActivationCodeRequest) (*models.ProductActivationCodeResponse, error)
|
||||
|
||||
// 用户端授权码列表与统计
|
||||
GetCuAuthorizationCodeList(ctx context.Context, customerID string, req *models.CuAuthorizationCodeListRequest) (*models.CuAuthorizationCodeListResponse, error)
|
||||
GetCuAuthorizationCodeSummary(ctx context.Context, customerID string) (*models.CuAuthorizationCodeSummaryResponse, error)
|
||||
}
|
||||
|
||||
// LicenseService 许可证服务接口
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/repository"
|
||||
pkgcontext "license-manager/pkg/context"
|
||||
"license-manager/pkg/i18n"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LeadService 线索服务接口
|
||||
type LeadService interface {
|
||||
CreateLead(ctx context.Context, req *models.LeadCreateRequest) (*models.Lead, error)
|
||||
UpdateLead(ctx context.Context, id string, req *models.LeadUpdateRequest) (*models.Lead, error)
|
||||
DeleteLead(ctx context.Context, id string) error
|
||||
GetLead(ctx context.Context, id string) (*models.Lead, error)
|
||||
GetLeadList(ctx context.Context, req *models.LeadListRequest) (*models.LeadListResponse, error)
|
||||
GetLeadSummary(ctx context.Context) (*models.LeadSummaryResponse, error)
|
||||
}
|
||||
|
||||
type leadService struct {
|
||||
repo repository.LeadRepository
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewLeadService 创建线索服务
|
||||
func NewLeadService(repo repository.LeadRepository, db *gorm.DB) LeadService {
|
||||
return &leadService{
|
||||
repo: repo,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *leadService) CreateLead(ctx context.Context, req *models.LeadCreateRequest) (*models.Lead, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 如果 requirement 为空,设置默认值 "Empty"
|
||||
requirement := req.Requirement
|
||||
if requirement == "" {
|
||||
requirement = "Empty"
|
||||
}
|
||||
|
||||
lead := &models.Lead{
|
||||
CompanyName: req.CompanyName,
|
||||
ContactName: req.ContactName,
|
||||
ContactPhone: req.ContactPhone,
|
||||
ContactEmail: req.ContactEmail,
|
||||
Requirement: requirement,
|
||||
ExtraInfo: req.ExtraInfo,
|
||||
Status: string(models.LeadStatusPending),
|
||||
}
|
||||
|
||||
if err := s.repo.Create(lead); err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
return lead, nil
|
||||
}
|
||||
|
||||
func (s *leadService) UpdateLead(ctx context.Context, id string, req *models.LeadUpdateRequest) (*models.Lead, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
lead, err := s.repo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900002", lang)
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if req.CompanyName != "" {
|
||||
lead.CompanyName = req.CompanyName
|
||||
}
|
||||
if req.ContactName != "" {
|
||||
lead.ContactName = req.ContactName
|
||||
}
|
||||
if req.ContactPhone != "" {
|
||||
lead.ContactPhone = req.ContactPhone
|
||||
}
|
||||
if req.ContactEmail != "" {
|
||||
lead.ContactEmail = req.ContactEmail
|
||||
}
|
||||
if req.Requirement != "" {
|
||||
lead.Requirement = req.Requirement
|
||||
}
|
||||
if req.ExtraInfo != "" {
|
||||
lead.ExtraInfo = req.ExtraInfo
|
||||
}
|
||||
if req.Status != "" {
|
||||
lead.Status = req.Status
|
||||
}
|
||||
lead.FollowUpDate = req.FollowUpDate
|
||||
if req.FollowUpRecord != "" {
|
||||
lead.FollowUpRecord = req.FollowUpRecord
|
||||
}
|
||||
if req.InternalNote != "" {
|
||||
lead.InternalNote = req.InternalNote
|
||||
}
|
||||
lead.UpdatedAt = time.Now()
|
||||
|
||||
if err := s.repo.Update(lead); err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
return lead, nil
|
||||
}
|
||||
|
||||
func (s *leadService) DeleteLead(ctx context.Context, id string) error {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 检查是否存在
|
||||
_, err := s.repo.GetByID(id)
|
||||
if err != nil {
|
||||
return i18n.NewI18nError("900002", lang)
|
||||
}
|
||||
|
||||
if err := s.repo.Delete(id); err != nil {
|
||||
return i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *leadService) GetLead(ctx context.Context, id string) (*models.Lead, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
lead, err := s.repo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900002", lang)
|
||||
}
|
||||
|
||||
return lead, nil
|
||||
}
|
||||
|
||||
func (s *leadService) GetLeadList(ctx context.Context, req *models.LeadListRequest) (*models.LeadListResponse, error) {
|
||||
leads, total, err := s.repo.GetList(ctx, req)
|
||||
if err != nil {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 转换为响应
|
||||
leadResponses := make([]*models.LeadResponse, len(leads))
|
||||
for i, lead := range leads {
|
||||
leadResponses[i] = lead.ToResponse()
|
||||
}
|
||||
|
||||
page := 1
|
||||
pageSize := 10
|
||||
if req != nil {
|
||||
if req.Page > 0 {
|
||||
page = req.Page
|
||||
}
|
||||
if req.PageSize > 0 {
|
||||
pageSize = req.PageSize
|
||||
}
|
||||
}
|
||||
|
||||
return &models.LeadListResponse{
|
||||
Leads: leadResponses,
|
||||
TotalCount: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *leadService) GetLeadSummary(ctx context.Context) (*models.LeadSummaryResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
summary, err := s.repo.GetSummary(ctx)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"license-manager/internal/models"
|
||||
"license-manager/internal/repository"
|
||||
pkgcontext "license-manager/pkg/context"
|
||||
"license-manager/pkg/i18n"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PackageService 套餐服务接口
|
||||
type PackageService interface {
|
||||
// 管理端接口
|
||||
CreatePackage(ctx context.Context, req *models.PackageCreateRequest) (*models.Package, error)
|
||||
UpdatePackage(ctx context.Context, id string, req *models.PackageUpdateRequest) (*models.Package, error)
|
||||
DeletePackage(ctx context.Context, id string) error
|
||||
GetPackageList(ctx context.Context, req *models.PackageListRequest) (*models.PackageListResponse, error)
|
||||
GetPackageDetail(ctx context.Context, id string) (*models.Package, error)
|
||||
|
||||
// 用户端接口
|
||||
GetCuPackageList(ctx context.Context) ([]*models.CuPackageResponse, error)
|
||||
}
|
||||
|
||||
type packageService struct {
|
||||
repo repository.PackageRepository
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewPackageService 创建套餐服务
|
||||
func NewPackageService(repo repository.PackageRepository, db *gorm.DB) PackageService {
|
||||
return &packageService{
|
||||
repo: repo,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 管理端接口实现 ==========
|
||||
|
||||
func (s *packageService) CreatePackage(ctx context.Context, req *models.PackageCreateRequest) (*models.Package, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
pkg := &models.Package{
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Price: req.Price,
|
||||
PriceDescription: req.PriceDescription,
|
||||
DurationDescription: req.DurationDescription,
|
||||
Description: req.Description,
|
||||
Features: req.Features,
|
||||
Status: req.Status,
|
||||
SortOrder: req.SortOrder,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(pkg); err != nil {
|
||||
return nil, i18n.NewI18nError("800001", lang)
|
||||
}
|
||||
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func (s *packageService) UpdatePackage(ctx context.Context, id string, req *models.PackageUpdateRequest) (*models.Package, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
pkg, err := s.repo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("800002", lang)
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if req.Name != "" {
|
||||
pkg.Name = req.Name
|
||||
}
|
||||
if req.Type != "" {
|
||||
pkg.Type = req.Type
|
||||
}
|
||||
if req.Price >= 0 {
|
||||
pkg.Price = req.Price
|
||||
}
|
||||
if req.PriceDescription != "" {
|
||||
pkg.PriceDescription = req.PriceDescription
|
||||
}
|
||||
if req.DurationDescription != "" {
|
||||
pkg.DurationDescription = req.DurationDescription
|
||||
}
|
||||
if req.Description != "" {
|
||||
pkg.Description = req.Description
|
||||
}
|
||||
if req.Features != "" {
|
||||
pkg.Features = req.Features
|
||||
}
|
||||
if req.Status != nil {
|
||||
pkg.Status = *req.Status
|
||||
}
|
||||
if req.SortOrder != nil {
|
||||
pkg.SortOrder = *req.SortOrder
|
||||
}
|
||||
if req.Remark != "" {
|
||||
pkg.Remark = req.Remark
|
||||
}
|
||||
|
||||
pkg.UpdatedAt = time.Now()
|
||||
|
||||
if err := s.repo.Update(pkg); err != nil {
|
||||
return nil, i18n.NewI18nError("800003", lang)
|
||||
}
|
||||
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func (s *packageService) DeletePackage(ctx context.Context, id string) error {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
// 检查是否存在
|
||||
_, err := s.repo.GetByID(id)
|
||||
if err != nil {
|
||||
return i18n.NewI18nError("800002", lang)
|
||||
}
|
||||
|
||||
if err := s.repo.Delete(id); err != nil {
|
||||
return i18n.NewI18nError("800004", lang)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *packageService) GetPackageList(ctx context.Context, req *models.PackageListRequest) (*models.PackageListResponse, error) {
|
||||
packages, total, err := s.repo.GetList(ctx, req)
|
||||
if err != nil {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
return nil, i18n.NewI18nError("800005", lang)
|
||||
}
|
||||
|
||||
responses := make([]*models.PackageResponse, len(packages))
|
||||
for i, pkg := range packages {
|
||||
responses[i] = pkg.ToResponse()
|
||||
}
|
||||
|
||||
return &models.PackageListResponse{
|
||||
Packages: responses,
|
||||
TotalCount: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *packageService) GetPackageDetail(ctx context.Context, id string) (*models.Package, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
pkg, err := s.repo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, i18n.NewI18nError("800002", lang)
|
||||
}
|
||||
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
// ========== 用户端接口实现 ==========
|
||||
|
||||
func (s *packageService) GetCuPackageList(ctx context.Context) ([]*models.CuPackageResponse, error) {
|
||||
packages, err := s.repo.GetEnabledList(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responses := make([]*models.CuPackageResponse, len(packages))
|
||||
for i, pkg := range packages {
|
||||
responses[i] = s.toCuPackageResponse(pkg)
|
||||
}
|
||||
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
// toCuPackageResponse 转换为用户端套餐响应(兼容原有结构)
|
||||
func (s *packageService) toCuPackageResponse(pkg *models.Package) *models.CuPackageResponse {
|
||||
// 从features中解析max_devices
|
||||
maxDevices := 1000 // 默认值
|
||||
if pkg.Features != "" {
|
||||
var features []string
|
||||
if err := json.Unmarshal([]byte(pkg.Features), &features); err == nil {
|
||||
for _, f := range features {
|
||||
// 尝试解析"X个许可"格式
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(f, "%d个许可", &n); err == nil {
|
||||
maxDevices = n
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建details字符串
|
||||
details := pkg.Description
|
||||
if pkg.DurationDescription != "" {
|
||||
if details != "" {
|
||||
details += "|"
|
||||
}
|
||||
details += pkg.DurationDescription
|
||||
}
|
||||
if maxDevices > 0 {
|
||||
if details != "" {
|
||||
details += "|"
|
||||
}
|
||||
details += fmt.Sprintf("%d个许可", maxDevices)
|
||||
}
|
||||
|
||||
// 计算 display_price
|
||||
displayPrice := ""
|
||||
if pkg.Price != 0 {
|
||||
displayPrice = fmt.Sprintf("¥%g", pkg.Price)
|
||||
} else {
|
||||
displayPrice = pkg.PriceDescription
|
||||
}
|
||||
|
||||
return &models.CuPackageResponse{
|
||||
ID: pkg.ID,
|
||||
Name: pkg.Name,
|
||||
Type: pkg.Type,
|
||||
Price: pkg.Price,
|
||||
DisplayPrice: displayPrice,
|
||||
PriceDescription: pkg.PriceDescription,
|
||||
MaxDevices: maxDevices,
|
||||
Description: pkg.Description,
|
||||
Features: pkg.Features,
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
-- 发票管理表
|
||||
-- 支持订单开票申请、管理员处理、文件上传等完整发票管理流程
|
||||
-- MySQL版本
|
||||
|
||||
-- 创建发票表
|
||||
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 COMMENT='发票管理表 - 支持订单开票申请和处理流程';
|
||||
|
||||
-- 外键约束
|
||||
ALTER TABLE `invoices` ADD CONSTRAINT `fk_invoices_order_id` FOREIGN KEY (`order_id`) REFERENCES `cu_orders` (`id`) ON DELETE CASCADE;
|
||||
ALTER TABLE `invoices` ADD CONSTRAINT `fk_invoices_customer_id` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`) ON DELETE CASCADE;
|
||||
ALTER TABLE `invoices` ADD CONSTRAINT `fk_invoices_cu_user_id` FOREIGN KEY (`cu_user_id`) REFERENCES `cu_users` (`id`) ON DELETE CASCADE;
|
||||
ALTER TABLE `invoices` ADD CONSTRAINT `fk_invoices_uploaded_by` FOREIGN KEY (`uploaded_by`) REFERENCES `users` (`id`) ON DELETE SET NULL;
|
||||
ALTER TABLE `invoices` ADD CONSTRAINT `fk_invoices_rejected_by` FOREIGN KEY (`rejected_by`) REFERENCES `users` (`id`) ON DELETE SET NULL;
|
||||
|
||||
-- 注意:
|
||||
-- 1. 时间戳由Go应用程序管理,不依赖数据库默认值
|
||||
-- 2. invoice_no全局唯一,作为发票申请的主要标识
|
||||
-- 3. order_id唯一,确保一个订单最多一张发票
|
||||
-- 4. 使用软删除,通过deleted_at字段实现
|
||||
-- 5. 外键约束确保数据一致性,管理员字段允许为NULL
|
||||
@@ -0,0 +1,36 @@
|
||||
-- 套餐表
|
||||
-- 用于管理员配置产品套餐,用户端从数据库查询
|
||||
|
||||
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='产品套餐表';
|
||||
|
||||
-- 插入初始数据
|
||||
INSERT INTO packages (id, name, type, price, price_description, duration_description, description, features, status, sort_order, created_at, updated_at) VALUES
|
||||
("DEMO-TRIAL", '试用版', 'trial', 0, '¥0', '当月25日到期', '免费体验全部功能', '["全部功能", "1个许可"]', 1, 1, NOW(), NOW()),
|
||||
("DEMO-BASIC", '基础版', 'basic', 300, '300元/许可', '永久有效', '小型企业最佳选择', '["基础功能", "批量许可购买"]', 1, 2, NOW(), NOW()),
|
||||
("DEMO-PRO", '专业版', 'professional', 2000, '2000元/许可', '永久有效', '企业级完整解决方案', '["全部功能", "技术支持", "数据分析"]', 1, 3, NOW(), NOW()),
|
||||
("DEMO-CUSTOM", '企业定制版', 'custom', 0, '定制报价', '按需定制', '按需定制解决方案', '["按需定制", "专属服务"]', 1, 4, NOW(), NOW());
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 企业线索表
|
||||
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='企业线索表';
|
||||
@@ -126,6 +126,18 @@ func getHTTPStatusByCode(code string) int {
|
||||
return StatusOK
|
||||
case "30": // 授权模块错误,默认200(业务错误通过响应体区分)
|
||||
return StatusOK
|
||||
case "40": // 仪表盘模块错误,默认200(业务错误通过响应体区分)
|
||||
return StatusOK
|
||||
case "50": // 客户用户模块错误,默认200(业务错误通过响应体区分)
|
||||
return StatusOK
|
||||
case "60": // 套餐订单模块错误,默认200(业务错误通过响应体区分)
|
||||
return StatusOK
|
||||
case "61": // 订单模块错误,默认200(业务错误通过响应体区分)
|
||||
return StatusOK
|
||||
case "62": // 设备模块错误,默认200(业务错误通过响应体区分)
|
||||
return StatusOK
|
||||
case "70": // 发票模块错误,默认200(业务错误通过响应体区分)
|
||||
return StatusOK
|
||||
case "90": // 系统错误,默认500
|
||||
return StatusInternalServerError
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"license-manager/internal/config"
|
||||
"license-manager/pkg/cache"
|
||||
)
|
||||
|
||||
func TestSMSServiceInitialization(t *testing.T) {
|
||||
// 创建内存缓存用于测试
|
||||
memCache := cache.NewMemoryCache(100)
|
||||
|
||||
// 创建SMS配置
|
||||
smsConfig := &config.SMSConfig{
|
||||
Enabled: false, // 测试禁用状态
|
||||
AccessKeyID: "test-key-id",
|
||||
AccessKeySecret: "test-key-secret",
|
||||
SignName: "Test Sign",
|
||||
RegionID: "cn-hangzhou",
|
||||
Endpoint: "dysmsapi.aliyuncs.com",
|
||||
}
|
||||
|
||||
// 测试创建SMS服务
|
||||
smsService, err := NewSMSService(smsConfig, memCache, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SMS service: %v", err)
|
||||
}
|
||||
|
||||
if smsService == nil {
|
||||
t.Fatal("SMS service should not be nil")
|
||||
}
|
||||
|
||||
// 测试禁用状态下的方法调用
|
||||
ctx := context.Background()
|
||||
|
||||
// 这些方法在禁用状态下应该返回错误
|
||||
err = smsService.SendVerificationCode(ctx, "+8613800012345", "+86", "test-template")
|
||||
if err == nil {
|
||||
t.Error("SendVerificationCode should return error when SMS is disabled")
|
||||
}
|
||||
|
||||
_, err = smsService.VerifyCode(ctx, "+8613800012345", "+86", "123456")
|
||||
if err == nil {
|
||||
t.Error("VerifyCode should return error when SMS is disabled")
|
||||
}
|
||||
|
||||
exists := smsService.IsRateLimited(ctx, "+8613800012345", "+86")
|
||||
if !exists { // 禁用状态下应该总是限流
|
||||
t.Error("IsRateLimited should return true when SMS is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSEnabledService(t *testing.T) {
|
||||
// 创建内存缓存用于测试
|
||||
memCache := cache.NewMemoryCache(100)
|
||||
|
||||
// 创建SMS配置(启用状态)
|
||||
smsConfig := &config.SMSConfig{
|
||||
Enabled: true,
|
||||
AccessKeyID: "test-key-id",
|
||||
AccessKeySecret: "test-key-secret",
|
||||
SignName: "Test Sign",
|
||||
RegionID: "cn-hangzhou",
|
||||
Endpoint: "dysmsapi.aliyuncs.com",
|
||||
Templates: config.SMSTemplates{
|
||||
Register: "SMS_111111111",
|
||||
ResetPwd: "SMS_222222222",
|
||||
Login: "SMS_333333333",
|
||||
CurrentPhone: "SMS_444444444",
|
||||
NewPhone: "SMS_555555555",
|
||||
},
|
||||
}
|
||||
|
||||
// 测试创建SMS服务
|
||||
smsService, err := NewSMSService(smsConfig, memCache, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SMS service: %v", err)
|
||||
}
|
||||
|
||||
if smsService == nil {
|
||||
t.Fatal("SMS service should not be nil")
|
||||
}
|
||||
|
||||
// 测试模板配置是否正确应用
|
||||
// 注意:由于smsService是内部类型,我们通过接口方法间接验证
|
||||
// 在实际应用中,SendCurrentPhoneCode会使用配置中的current_phone模板
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 测试频率限制(应该允许第一次发送)
|
||||
isLimited := smsService.IsRateLimited(ctx, "+8613800012345", "+86")
|
||||
if isLimited {
|
||||
t.Error("First SMS should not be rate limited")
|
||||
}
|
||||
|
||||
// 测试发送登录验证码(会因为无效的AccessKey而失败,但不应该panic)
|
||||
err = smsService.SendLoginCode(ctx, "+8613800012345", "+86")
|
||||
if err == nil {
|
||||
t.Log("SMS send succeeded (unexpected with test credentials)")
|
||||
} else {
|
||||
t.Logf("SMS send failed as expected: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ services:
|
||||
|
||||
# Backend service - using GitHub registry image
|
||||
backend:
|
||||
image: ghcr.io/cedar-v/license-manager-backend:v1.0.4
|
||||
image: ghcr.io/cedar-v/license-manager-backend:v1.1.0
|
||||
container_name: license-manager-backend-git
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -69,7 +69,7 @@ services:
|
||||
|
||||
# Frontend service - using GitHub registry image
|
||||
frontend:
|
||||
image: ghcr.io/cedar-v/license-manager-frontend:v1.0.4
|
||||
image: ghcr.io/cedar-v/license-manager-frontend:v1.1.0
|
||||
container_name: license-manager-frontend-git
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
# 客户属性设计与Web显示方案
|
||||
|
||||
## 1. 核心客户属性
|
||||
|
||||
### 基础标识
|
||||
- `id` - 唯一标识符
|
||||
- **作用**: 数据库主键,系统内部引用
|
||||
- **生成**: UUID自动生成
|
||||
- **必填**: 是
|
||||
|
||||
- `customer_code` - 客户编码 (如: CUS-2025-001)
|
||||
- **作用**: 业务识别码,便于客服沟通和查找
|
||||
- **生成**: CUS-YYYY-NNNN格式,系统自动递增
|
||||
- **必填**: 是
|
||||
- **唯一**: 是
|
||||
- **意义**: 比数据库ID更有业务意义,支持按年度统计
|
||||
|
||||
- `customer_name` - 客户名称/公司名
|
||||
- **作用**: 主要识别信息,合同签署主体
|
||||
- **示例**: "北京科技有限公司"、"张三"
|
||||
- **必填**: 是
|
||||
- **搜索**: 支持模糊搜索
|
||||
|
||||
- `customer_type` - 客户类型 (个人/企业/政府/教育)
|
||||
- **作用**: 区分客户类别,影响服务策略和定价
|
||||
- **枚举**: individual(个人) | enterprise(企业) | government(政府) | education(教育)
|
||||
- **必填**: 是
|
||||
- **默认**: enterprise
|
||||
|
||||
### 联系信息
|
||||
- `contact_person` - 联系人姓名
|
||||
- **作用**: 授权通知和技术支持的对接人
|
||||
- **必填**: 是
|
||||
- **示例**: "张三"
|
||||
|
||||
- `contact_title` - 联系人职务
|
||||
- **作用**: 了解联系人在组织中的角色,便于沟通
|
||||
- **必填**: 否
|
||||
- **示例**: "技术总监"、"IT经理"
|
||||
|
||||
- `email` - 主邮箱
|
||||
- **作用**: 授权激活通知、过期提醒、技术支持的主要渠道
|
||||
- **必填**: 是
|
||||
- **唯一**: 建议唯一
|
||||
- **验证**: 邮箱格式验证
|
||||
|
||||
- `phone` - 电话号码
|
||||
- **作用**: 紧急联系、电话技术支持
|
||||
- **必填**: 否
|
||||
- **格式**: 支持手机和固话
|
||||
|
||||
### 地址信息
|
||||
- `address` - 地址
|
||||
- **作用**: 联系地址,邮寄材料或现场服务时使用
|
||||
- **必填**: 否
|
||||
- **示例**: "北京市朝阳区科技园区100号"
|
||||
- **格式**: 自由文本,用户可详细可简略
|
||||
|
||||
### 商业属性
|
||||
- `company_size` - 企业规模 (小型/中型/大型/集团)
|
||||
- **作用**: 影响定价策略和服务等级
|
||||
- **枚举**: small(小型) | medium(中型) | large(大型) | enterprise(集团)
|
||||
- **必填**: 否
|
||||
- **用途**: 制定差异化授权策略
|
||||
|
||||
### 授权配置
|
||||
- `preferred_license_type` - 首选授权类型 (在线/离线/混合)
|
||||
- **作用**: 影响授权码生成策略和验证方式
|
||||
- **枚举**: online(在线) | offline(离线) | hybrid(混合)
|
||||
- **必填**: 否
|
||||
- **默认**: online
|
||||
- **意义**: 在线需要联网验证,离线可本地验证
|
||||
|
||||
### 客户分级
|
||||
- `customer_level` - 客户等级 (普通/VIP/企业/战略)
|
||||
- **作用**: 影响服务优先级、授权数量限制、技术支持等级
|
||||
- **枚举**: normal(普通) | vip(VIP) | enterprise(企业) | strategic(战略)
|
||||
- **必填**: 否
|
||||
- **默认**: normal
|
||||
- **影响**: 高等级客户可获得更多授权、更快响应
|
||||
|
||||
### 状态管理
|
||||
- `status` - 客户状态 (正常/禁用)
|
||||
- **作用**: 控制是否允许为该客户生成新授权
|
||||
- **枚举**: active(正常) | disabled(禁用)
|
||||
- **必填**: 是
|
||||
- **默认**: active
|
||||
- **影响**: 禁用状态下不能生成新授权,但现有授权仍可使用
|
||||
|
||||
- `description` - 客户描述/备注
|
||||
- **作用**: 记录客户特殊情况、业务背景、注意事项
|
||||
- **必填**: 否
|
||||
- **示例**: "重要客户,IoT平台采购方,需要优先技术支持"
|
||||
|
||||
### 时间字段
|
||||
- `created_at` - 创建时间
|
||||
- **作用**: 记录客户注册时间,用于统计分析
|
||||
- **自动**: 系统自动记录
|
||||
- **不可修改**: 是
|
||||
|
||||
- `updated_at` - 更新时间
|
||||
- **作用**: 记录最后修改时间,追踪数据变更
|
||||
- **自动**: 系统自动更新
|
||||
- **触发**: 任何字段修改都会更新
|
||||
|
||||
- `created_by` - 创建人
|
||||
- **作用**: 记录是哪个管理员创建的客户
|
||||
- **必填**: 是
|
||||
- **关联**: 管理员用户ID
|
||||
|
||||
- `updated_by` - 更新人
|
||||
- **作用**: 记录最后修改人,便于问题追溯
|
||||
- **自动**: 系统自动记录当前操作员
|
||||
|
||||
---
|
||||
|
||||
## 2. Web列表页显示信息
|
||||
|
||||
### 主要显示列
|
||||
```
|
||||
| 客户编码 | 客户名称 | 客户类型 | 联系人 | 邮箱 | 客户等级 | 状态 | 创建时间 | 操作 |
|
||||
```
|
||||
|
||||
### 具体显示内容与作用
|
||||
|
||||
**客户编码** (`customer_code`)
|
||||
- 显示: CUS-2025-001
|
||||
- 作用: 快速识别客户,支持精确搜索,客服沟通时的标准引用
|
||||
- 交互: 点击可复制到剪贴板
|
||||
|
||||
**客户名称** (`customer_name`)
|
||||
- 显示: 北京科技有限公司
|
||||
- 作用: 主要识别信息,最重要的显示内容
|
||||
- 交互: 点击进入客户详情页
|
||||
- 样式: 可截断显示,悬停显示完整名称
|
||||
|
||||
**客户类型** (`customer_type`)
|
||||
- 显示: 🏢企业 | 👤个人 | 🏛️政府 | 🎓教育
|
||||
- 作用: 快速区分客户类别,影响服务策略
|
||||
- 样式: 用图标+文字+颜色区分
|
||||
- 筛选: 支持按类型筛选
|
||||
|
||||
**联系人** (`contact_person`)
|
||||
- 显示: 张三
|
||||
- 作用: 知道具体联系谁,便于沟通对接
|
||||
- 交互: 悬停显示联系人职务信息
|
||||
|
||||
**邮箱** (`email`)
|
||||
- 显示: zhangsan@example.com
|
||||
- 作用: 重要联系方式,授权通知的主要渠道
|
||||
- 交互: 点击可发送邮件
|
||||
- 验证: 显示邮箱验证状态(已验证/未验证)
|
||||
|
||||
**客户等级** (`customer_level`)
|
||||
- 显示: 🏢企业 | 👤普通 | ⭐VIP | 💼战略
|
||||
- 作用: 显示客户重要程度,影响服务优先级
|
||||
- 样式: 不同等级用不同颜色和图标
|
||||
- 排序: 支持按等级排序
|
||||
|
||||
**状态** (`status`)
|
||||
- 显示: ✅正常 | ❌禁用
|
||||
- 作用: 显示客户是否可以生成新授权
|
||||
- 交互: 可直接点击切换状态(需要确认)
|
||||
- 样式: 正常为绿色,禁用为红色
|
||||
|
||||
**创建时间** (`created_at`)
|
||||
- 显示: 2025-01-15
|
||||
- 作用: 了解客户注册时间,便于分析客户生命周期
|
||||
- 格式: YYYY-MM-DD
|
||||
- 排序: 默认按创建时间倒序
|
||||
|
||||
**操作**
|
||||
- 查看详情: 进入客户详情页
|
||||
- 编辑: 修改客户信息
|
||||
- 授权管理: 查看该客户的所有授权
|
||||
- 生成授权: 快速为该客户生成新授权
|
||||
- 更多操作: 禁用/启用、删除等
|
||||
|
||||
---
|
||||
|
||||
## 3. Web详情页显示信息
|
||||
|
||||
### 基本信息卡片
|
||||
```
|
||||
客户编码: CUS-2025-001 客户类型: 🏢企业
|
||||
客户名称: 北京科技有限公司 客户等级: ⭐VIP客户
|
||||
联系人: 张三 (技术总监) 当前状态: ✅正常
|
||||
邮箱: zhangsan@example.com 电话: 13800138000
|
||||
```
|
||||
**用途**: 核心信息一览,便于快速了解客户基本情况
|
||||
|
||||
### 地址信息卡片
|
||||
```
|
||||
地址: 北京市朝阳区科技园区100号
|
||||
```
|
||||
**用途**: 联系地址,邮寄材料或现场服务参考
|
||||
|
||||
### 商业信息卡片
|
||||
```
|
||||
企业规模: 中型企业 首选授权: 在线授权
|
||||
备注描述: 重要客户,IoT平台采购方,需要优先技术支持
|
||||
```
|
||||
**用途**: 业务背景了解,制定针对性服务策略
|
||||
|
||||
### 状态信息卡片
|
||||
```
|
||||
当前状态: ✅正常 创建时间: 2025-01-15 10:30:00
|
||||
创建人: 李四(管理员) 更新时间: 2025-01-20 15:45:00
|
||||
更新人: 王五(客服)
|
||||
```
|
||||
**用途**: 跟踪客户生命周期,了解操作历史
|
||||
|
||||
### 授权统计卡片
|
||||
```
|
||||
总授权数: 3个 活跃授权: 2个
|
||||
过期授权: 1个 最近授权: 2025-01-18
|
||||
即将过期: 1个(7天内) 授权设备总数: 85台
|
||||
```
|
||||
**用途**: 快速了解客户授权使用情况,识别续费机会
|
||||
|
||||
---
|
||||
|
||||
## 4. 列表页功能特性
|
||||
|
||||
### 搜索筛选
|
||||
- **全文搜索框**: 支持客户编码、名称、联系人、邮箱的模糊搜索
|
||||
- **作用**: 快速定位目标客户
|
||||
- **实现**: 实时搜索,输入即显示结果
|
||||
|
||||
- **客户类型筛选**: 全部 | 企业 | 个人 | 政府 | 教育
|
||||
- **作用**: 按业务类型分类查看
|
||||
- **场景**: 针对不同类型客户制定营销策略
|
||||
|
||||
- **客户等级筛选**: 全部 | 普通 | VIP | 企业 | 战略
|
||||
- **作用**: 重点关注高价值客户
|
||||
- **场景**: 优先处理VIP和战略客户的需求
|
||||
|
||||
- **状态筛选**: 全部 | 正常 | 禁用
|
||||
- **作用**: 识别问题客户,及时处理
|
||||
- **场景**: 定期清理禁用客户,分析禁用原因
|
||||
|
||||
### 排序功能
|
||||
- **按创建时间排序** (默认)
|
||||
- **作用**: 了解客户注册趋势,关注新客户
|
||||
|
||||
- **按客户名称排序**
|
||||
- **作用**: 字母序查找,便于浏览
|
||||
|
||||
- **按客户等级排序**
|
||||
- **作用**: 优先显示重要客户
|
||||
|
||||
- **按更新时间排序**
|
||||
- **作用**: 查看最近活跃的客户
|
||||
|
||||
### 批量操作
|
||||
- **批量启用/禁用**
|
||||
- **作用**: 批量处理客户状态变更
|
||||
- **场景**: 批量处理欠费客户、恢复客户服务
|
||||
|
||||
- **批量导出**
|
||||
- **作用**: 导出客户数据进行分析
|
||||
- **格式**: Excel、CSV格式
|
||||
|
||||
- **批量删除**
|
||||
- **作用**: 清理无效客户数据
|
||||
- **安全**: 需要二次确认,软删除机制
|
||||
|
||||
### 分页显示
|
||||
- **每页条数**: 10/20/50/100条可选
|
||||
- **默认**: 20条
|
||||
- **作用**: 平衡加载速度和浏览效率
|
||||
|
||||
- **总数统计**: 显示"共1,234个客户"
|
||||
- **作用**: 了解客户总体规模
|
||||
|
||||
- **页码导航**: 支持跳转到指定页
|
||||
- **作用**: 快速浏览大量数据
|
||||
|
||||
---
|
||||
|
||||
## 5. 显示优化建议
|
||||
|
||||
### 响应式设计
|
||||
- **桌面端(>1200px)**: 显示所有列
|
||||
- **作用**: 完整信息展示,提高工作效率
|
||||
|
||||
- **平板端(768-1200px)**: 隐藏地址、电话等次要信息
|
||||
- **作用**: 保持核心信息,适应中等屏幕
|
||||
|
||||
- **手机端(<768px)**: 卡片式布局,只显示客户名称、等级、状态
|
||||
- **作用**: 移动端快速浏览,支持现场客户查询
|
||||
|
||||
### 数据可视化(仪表盘)
|
||||
- **客户等级分布饼图**
|
||||
- **作用**: 了解客户结构,制定等级提升策略
|
||||
|
||||
- **客户类型统计柱图**
|
||||
- **作用**: 分析目标市场,调整营销重点
|
||||
|
||||
- **月度新增客户趋势图**
|
||||
- **作用**: 监控业务增长,评估营销效果
|
||||
|
||||
### 交互优化
|
||||
- **悬停显示完整信息**
|
||||
- **场景**: 客户名称过长时显示完整名称
|
||||
- **内容**: 联系人悬停显示职务和电话
|
||||
|
||||
- **点击客户名称进入详情页**
|
||||
- **作用**: 快速访问详细信息
|
||||
|
||||
- **状态切换快捷操作**
|
||||
- **实现**: 点击状态图标直接切换,需要确认
|
||||
- **作用**: 快速处理客户状态变更
|
||||
|
||||
- **右键菜单快速操作**
|
||||
- **功能**: 编辑、查看授权、生成授权、禁用等
|
||||
- **作用**: 提高操作效率,减少页面跳转
|
||||
|
||||
### 性能优化
|
||||
- **虚拟滚动**: 大数据量时采用虚拟滚动
|
||||
- **触发**: 客户数>1000时
|
||||
- **作用**: 保证页面流畅性
|
||||
|
||||
- **延迟加载**: 图片和非关键信息延迟加载
|
||||
- **作用**: 提高首屏加载速度
|
||||
|
||||
- **缓存策略**: 客户列表数据缓存5分钟
|
||||
- **作用**: 减少不必要的服务器请求
|
||||
|
||||
## 6. 业务价值总结
|
||||
|
||||
### 提高工作效率
|
||||
- **快速识别**: 通过客户编码和等级快速识别重要客户
|
||||
- **精准搜索**: 多维度搜索快速定位目标客户
|
||||
- **批量操作**: 减少重复性操作,提高处理效率
|
||||
|
||||
### 优化客户服务
|
||||
- **服务分级**: 根据客户等级提供差异化服务
|
||||
- **及时响应**: 通过状态管理及时识别问题客户
|
||||
- **个性化**: 根据客户偏好(授权类型)提供合适的解决方案
|
||||
|
||||
### 支撑业务决策
|
||||
- **客户分析**: 通过等级、类型分布了解客户结构
|
||||
- **趋势监控**: 通过创建时间分析客户增长趋势
|
||||
- **风险控制**: 通过状态管理防范业务风险
|
||||
@@ -1,127 +0,0 @@
|
||||
# 软件授权管理平台 - 目录结构说明
|
||||
|
||||
## 项目整体结构
|
||||
|
||||
```
|
||||
license-manager/
|
||||
├── README.md # 项目说明文档
|
||||
├── LICENSE # 开源许可证
|
||||
├── .gitignore # Git忽略文件配置
|
||||
├── Dockerfile # Docker构建配置
|
||||
├── docker-compose.yml # Docker编排配置
|
||||
├── Makefile # 构建自动化脚本
|
||||
│
|
||||
├── backend/ # 后端服务目录
|
||||
├── frontend/ # 前端应用目录
|
||||
├── docs/ # 项目文档目录
|
||||
├── scripts/ # 项目级脚本目录
|
||||
├── tests/ # 端到端测试目录
|
||||
└── bin/ # 编译产物目录
|
||||
```
|
||||
|
||||
## 前端目录 (frontend/)
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── public/ # 静态资源目录
|
||||
│ ├── index.html # HTML模板
|
||||
│ └── favicon.ico # 网站图标
|
||||
├── src/ # 前端源代码
|
||||
│ ├── components/ # Vue组件
|
||||
│ ├── views/ # 页面视图
|
||||
│ ├── router/ # 路由配置
|
||||
│ ├── store/ # 状态管理(Pinia)
|
||||
│ ├── api/ # API请求封装
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── assets/ # 资源文件
|
||||
│ └── main.js # 应用入口
|
||||
├── package.json # NPM依赖配置
|
||||
├── vite.config.js # Vite构建配置
|
||||
└── .env.example # 环境变量模板
|
||||
```
|
||||
|
||||
## 文档目录 (docs/)
|
||||
|
||||
```
|
||||
docs/
|
||||
├── api/ # API接口文档
|
||||
├── architecture/ # 架构设计文档
|
||||
│ ├── 需求说明书.md # 项目需求规格
|
||||
│ └── 目录结构说明.md # 本文档
|
||||
└── deployment/ # 部署相关文档
|
||||
```
|
||||
|
||||
## 脚本目录 (scripts/)
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── build.sh # 项目构建脚本
|
||||
├── deploy.sh # 部署脚本
|
||||
└── dev.sh # 开发环境启动脚本
|
||||
```
|
||||
|
||||
## 测试目录 (tests/)
|
||||
|
||||
```
|
||||
tests/
|
||||
├── integration/ # 集成测试
|
||||
└── e2e/ # 端到端测试
|
||||
```
|
||||
|
||||
## 编译产物目录 (bin/)
|
||||
|
||||
```
|
||||
bin/
|
||||
└── license-manager # 编译后的可执行文件
|
||||
```
|
||||
|
||||
## 目录设计原则
|
||||
|
||||
### 1. 前后端分离
|
||||
- backend/ 和 frontend/ 完全独立,可以分别开发和部署
|
||||
- 各自拥有独立的依赖管理和构建配置
|
||||
|
||||
### 2. 职责清晰
|
||||
- internal/ 存放内部业务逻辑,不对外暴露
|
||||
- pkg/ 存放可重用的公共代码
|
||||
- cmd/ 只包含应用启动代码
|
||||
|
||||
### 3. 标准化
|
||||
- 遵循Go项目标准布局
|
||||
- 遵循Vue.js项目最佳实践
|
||||
- 符合开源项目规范
|
||||
|
||||
### 4. 易于维护
|
||||
- 配置文件集中管理
|
||||
- 脚本统一放置
|
||||
- 文档结构清晰
|
||||
|
||||
## 开发流程
|
||||
|
||||
### 开发环境启动
|
||||
```bash
|
||||
# 启动开发环境(前后端同时启动)
|
||||
./scripts/dev.sh
|
||||
```
|
||||
|
||||
### 项目构建
|
||||
```bash
|
||||
# 构建整个项目
|
||||
./scripts/build.sh
|
||||
```
|
||||
|
||||
### Docker部署
|
||||
```bash
|
||||
# 构建Docker镜像
|
||||
docker build -t license-manager .
|
||||
|
||||
# 运行容器
|
||||
docker run -p 8080:8080 license-manager
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 权限管理:确保脚本文件具有执行权限
|
||||
2. 配置文件:不要提交包含敏感信息的配置文件
|
||||
3. 依赖管理:定期更新依赖版本,注意安全漏洞
|
||||
4. 代码规范:前后端代码都要遵循各自的编码规范
|
||||
@@ -1,126 +0,0 @@
|
||||
# 软件授权管理平台需求规格说明书
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
### 1.1 项目背景
|
||||
为了保护软件知识产权,规范软件使用权限,需要开发一个独立的软件授权管理平台,为IoT平台等软件系统提供授权码生成、分发、验证和管理服务。
|
||||
|
||||
### 1.2 项目目标
|
||||
- 建立统一的软件授权管理系统
|
||||
- 实现基于硬件绑定的授权机制
|
||||
- 提供在线和离线两种授权模式
|
||||
- 支持授权状态实时监控和管理
|
||||
- 提供完整的客户管理功能
|
||||
|
||||
### 1.3 系统定位
|
||||
本系统作为独立的授权管理平台,通过Web管理界面和RESTful API为其他软件系统提供授权服务。
|
||||
|
||||
## 2. 系统功能需求
|
||||
|
||||
### 2.1 客户管理模块
|
||||
- 支持客户基本信息的增删改查(姓名、公司、联系人、邮箱、电话、地址、状态、描述)
|
||||
- 支持客户状态管理(激活/停用)
|
||||
- 提供客户列表查看、详情查看和JSON API接口
|
||||
|
||||
### 2.2 授权生成模块
|
||||
|
||||
#### 2.2.1 授权类型支持
|
||||
- **在线授权码模式**:生成授权码,客户端激活时绑定硬件信息
|
||||
- **离线许可证模式**:直接生成绑定硬件信息的软件许可证
|
||||
|
||||
#### 2.2.2 授权生成功能
|
||||
- 支持为指定客户生成授权码或软件许可证
|
||||
- 硬件指纹基于机器序列号、MAC地址、CPU ID生成
|
||||
- 支持自定义有效期(1-36500天,预设选项:30天、90天、365天、永久)
|
||||
- 支持功能列表配置和部署模式选择(在线/离线)
|
||||
- 使用HMAC-SHA256签名和Base64编码保证安全性
|
||||
- 可扩展额外功能,软件系统可以从生成的license里解析出来,比如{"device_number":20}
|
||||
|
||||
### 2.3 授权管理模块
|
||||
- 显示所有授权记录列表,支持按客户筛选
|
||||
- 授权状态监控(激活、过期、吊销),支持手动吊销和删除
|
||||
- 提供授权详细信息查看(客户信息、硬件信息、过期时间、最后心跳时间)
|
||||
- 实时显示授权过期和激活状态
|
||||
|
||||
### 2.4 部署包生成模块
|
||||
- 为每个授权自动生成ZIP格式部署包
|
||||
- 包含配置文件(config.yaml)、安装脚本(install.sh)、部署文档等
|
||||
- 根据客户和授权信息自动命名
|
||||
|
||||
### 2.5 在线验证模块
|
||||
- **授权验证API**:验证授权码和软件许可证,支持硬件指纹验证
|
||||
- **授权激活API**:支持授权码激活为软件许可证,防止重复激活
|
||||
- **心跳监控API**:记录客户端活跃时间,提供在线状态统计
|
||||
- **授权信息查询API**:返回脱敏的授权信息,支持状态和过期检查
|
||||
|
||||
### 2.6 系统管理模块
|
||||
- **用户认证**:管理员登录、状态保持、安全退出
|
||||
- **权限控制**:Web界面需要认证,API接口独立于管理界面
|
||||
- **系统监控**:运行状态仪表盘、授权统计信息、资源监控
|
||||
|
||||
### 2.7 工具支持模块
|
||||
- 提供Windows和Linux平台的硬件信息获取工具
|
||||
- 支持获取机器序列号、MAC地址、CPU ID
|
||||
- 提供工具下载接口
|
||||
|
||||
## 3. 系统非功能性需求
|
||||
|
||||
### 3.1 性能需求
|
||||
- 支持并发用户数不少于100个
|
||||
- API响应时间不超过2秒
|
||||
- 数据库连接池管理和查询性能优化
|
||||
|
||||
### 3.2 安全需求
|
||||
- 授权码和软件许可证使用HMAC-SHA256签名
|
||||
- 硬件指纹绑定防止盗用
|
||||
- 敏感信息保护和HTTPS传输加密
|
||||
|
||||
### 3.3 可靠性需求
|
||||
- 系统可用性达到99.9%
|
||||
- 数据备份恢复、异常优雅降级
|
||||
- 完善的错误处理和日志记录
|
||||
|
||||
### 3.4 可扩展性需求
|
||||
- 支持水平扩展和模块化设计
|
||||
- 支持多种数据库(当前使用PostgreSQL)
|
||||
- RESTful API设计便于集成
|
||||
|
||||
### 3.5 兼容性需求
|
||||
- 支持Windows、Linux、macOS操作系统
|
||||
- 支持主流浏览器和跨平台客户端工具
|
||||
|
||||
## 4. 系统架构需求
|
||||
|
||||
### 4.1 技术架构
|
||||
- **架构模式**:B/S架构
|
||||
- **后端技术**:Go 1.23+,Gorilla Mux路由,Logrus日志
|
||||
- **数据库**:PostgreSQL 12+,mysql12+
|
||||
- **前端**:Vue.js 3+ 配合Vite,响应式UI组件
|
||||
- **配置**:YAML格式配置文件
|
||||
|
||||
### 4.2 部署要求
|
||||
- 支持单机部署、Docker容器化部署
|
||||
- 支持系统服务形式运行
|
||||
- 提供启动脚本和配置模板
|
||||
|
||||
## 5. 数据需求
|
||||
|
||||
### 5.1 数据模型
|
||||
- **客户数据表**:基本信息、状态、创建时间,支持软删除和审计
|
||||
- **授权数据表**:授权码/许可证信息、客户关联、硬件信息、状态时间、功能列表
|
||||
|
||||
### 5.2 数据管理
|
||||
- 支持定期数据备份和导入导出功能
|
||||
- 提供数据迁移工具
|
||||
|
||||
## 6. 接口需求
|
||||
|
||||
### 6.1 Web管理界面
|
||||
- 客户管理、授权生成、授权列表和详情页面
|
||||
- 系统仪表盘、用户登录页面
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
## 一次性设置(首次推送)
|
||||
|
||||
### 第一步:克隆开源项目
|
||||
```bash
|
||||
# 克隆开源项目到本地
|
||||
git clone https://github.com/cedar-v/license-manager.git
|
||||
|
||||
# 进入项目目录
|
||||
cd license-manager
|
||||
```
|
||||
|
||||
### 第二步:添加私有仓库作为新的远程仓库
|
||||
```bash
|
||||
# 查看当前远程仓库(应该只有 origin 指向开源项目)
|
||||
git remote -v
|
||||
|
||||
# 添加你的私有仓库(命名为 private)
|
||||
git remote add private https://github.com/cedar-v/license-manager-sszn.git
|
||||
|
||||
# 验证(现在应该有两个远程仓库)
|
||||
git remote -v
|
||||
```
|
||||
|
||||
应该显示:
|
||||
```
|
||||
origin https://github.com/cedar-v/license-manager.git (fetch)
|
||||
origin https://github.com/cedar-v/license-manager.git (push)
|
||||
private https://github.com/cedar-v/license-manager-sszn.git (fetch)
|
||||
private https://github.com/cedar-v/license-manager-sszn.git (push)
|
||||
```
|
||||
|
||||
### 第三步:创建并切换到 license-manager 分支
|
||||
```bash
|
||||
# 创建 license-manager 分支
|
||||
git checkout -b license-manager
|
||||
|
||||
# 推送到私有仓库
|
||||
git push -u private license-manager
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后续更新流程
|
||||
|
||||
当开源项目有更新时,你可以这样同步:
|
||||
|
||||
```bash
|
||||
# 1. 切换到 license-manager 分支
|
||||
git checkout license-manager
|
||||
|
||||
# 2. 从开源项目拉取最新代码(origin 是开源项目)
|
||||
git pull origin main
|
||||
|
||||
# 或者如果开源项目的默认分支是 master
|
||||
# git pull origin master
|
||||
|
||||
# 3. 推送到你的私有仓库
|
||||
git push private license-manager
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 一键同步脚本(可选)
|
||||
|
||||
你可以创建一个脚本方便以后使用:
|
||||
|
||||
**sync.sh** (Mac/Linux) 或 **sync.bat** (Windows)
|
||||
```bash
|
||||
#!/bin/bash
|
||||
git checkout license-manager
|
||||
git pull origin main
|
||||
git push private license-manager
|
||||
echo "同步完成!"
|
||||
```
|
||||
@@ -0,0 +1,48 @@
|
||||
# 🔥License Manager v1.1.0 发布,三天完成产品货币化
|
||||
|
||||
**三天快速对接,完成产品货币化** 🚀
|
||||
|
||||
---
|
||||
|
||||
## 💡 产品定位
|
||||
|
||||
软件商业化是一场系统性工程。从产品定价到用户获取,从试用到转化,从续费到升级,每一个环节都至关重要。License Manager作为您的商业化基石,提供完整的货币化技术栈。
|
||||
License Manager支持企业级管理后台和用户端自助服务,实现B2B+B2C双轨运营,企业客户和个人用户均可享受便捷的授权体验。
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
### 👥 用户管理系统
|
||||
- 手机号注册登录,完全独立于管理员体系
|
||||
- 支持企业管理员和普通成员角色管理
|
||||
- 安全的认证隔离设计
|
||||
|
||||
### 💰 产品套餐与支付
|
||||
- 三种套餐类型:试用版、基础版、专业版
|
||||
- 支付宝在线支付集成,支持沙箱和生产环境
|
||||
- 阶梯定价折扣,按许可数量自动计算优惠
|
||||
|
||||
### 🔑 授权码系统
|
||||
- 离线激活支持,无网络环境也可使用
|
||||
- 授权码分享功能,支持部分激活次数转让
|
||||
- RSA数字签名,确保授权码安全防篡改
|
||||
|
||||
### 📱 设备管理
|
||||
- 设备绑定与解绑,实时在线状态监控
|
||||
- 设备信息展示,支持批量设备管理
|
||||
|
||||
## 🎯 商业化能力
|
||||
|
||||
- **📊 定价策略数字化**:多维度定价,按用户数、功能模块、使用时长灵活定价
|
||||
- **🔄 订阅经济支撑**:月付/年付/多年付,自动续费提醒
|
||||
- **🏢 企业批量采购**:批量许可折扣,集中化企业管理
|
||||
- **🎯 试用转化机制**:试用期管理,分阶段功能解锁引导升级
|
||||
|
||||
## 🐛 优化改进
|
||||
|
||||
- 授权码格式统一与生成优化
|
||||
- 支付流程用户体验提升
|
||||
- 缓存模块稳定性增强
|
||||
|
||||
---
|
||||
|
||||
**立即体验**:https://docs.lm.cedar-v.com/
|
||||
@@ -0,0 +1,859 @@
|
||||
# 测试用例
|
||||
|
||||
## 目录
|
||||
1. [认证授权模块](#1-认证授权模块)
|
||||
2. [客户管理模块](#2-客户管理模块)
|
||||
3. [授权码管理模块](#3-授权码管理模块)
|
||||
4. [许可证管理模块](#4-许可证管理模块)
|
||||
5. [套餐管理模块](#5-套餐管理模块)
|
||||
6. [订单管理模块](#6-订单管理模块)
|
||||
7. [支付模块](#7-支付模块)
|
||||
8. [发票管理模块](#8-发票管理模块)
|
||||
9. [设备管理模块](#9-设备管理模块)
|
||||
10. [线索管理模块](#10-线索管理模块)
|
||||
11. [仪表盘/统计模块](#11-仪表盘统计模块)
|
||||
|
||||
---
|
||||
|
||||
## 1. 认证授权模块
|
||||
|
||||
### 1.1 管理员登录
|
||||
|
||||
| 用例编号 | TC-AUTH-001 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 管理员正常登录 |
|
||||
| 测试目的 | 验证管理员使用正确用户名密码登录成功 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 系统存在管理员账号,用户名: admin,密码: admin123 |
|
||||
| 测试步骤 | 1. 打开登录页面<br>2. 输入用户名 admin<br>3. 输入密码 admin123<br>4. 点击登录按钮 |
|
||||
| 预期结果 | 1. 登录成功,跳转至首页<br>2. 返回 token<br>3. 页面显示管理员用户名 |
|
||||
|
||||
| 用例编号 | TC-AUTH-002 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 管理员密码错误登录 |
|
||||
| 测试目的 | 验证密码错误时登录失败 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 系统存在管理员账号 |
|
||||
| 测试步骤 | 1. 输入正确的用户名<br>2. 输入错误的密码<br>3. 点击登录按钮 |
|
||||
| 预期结果 | 1. 登录失败<br>2. 显示错误提示"用户名或密码错误" |
|
||||
|
||||
| 用例编号 | TC-AUTH-003 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 管理员用户名不存在 |
|
||||
| 测试目的 | 验证用户名不存在时登录失败 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 无 |
|
||||
| 测试步骤 | 1. 输入不存在的用户名<br>2. 输入任意密码<br>3. 点击登录按钮 |
|
||||
| 预期结果 | 1. 登录失败<br>2. 显示错误提示"用户名或密码错误" |
|
||||
|
||||
| 用例编号 | TC-AUTH-004 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 管理员用户名密码为空 |
|
||||
| 测试目的 | 验证输入为空时登录失败 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 无 |
|
||||
| 测试步骤 | 1. 不输入用户名<br>2. 不输入密码<br>3. 点击登录按钮 |
|
||||
| 预期结果 | 1. 登录失败<br>2. 显示错误提示"用户名不能为空" |
|
||||
|
||||
### 1.2 Token刷新
|
||||
|
||||
| 用例编号 | TC-AUTH-005 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 正常刷新Token |
|
||||
| 测试目的 | 验证Token过期前可以正常刷新 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 用户已登录,获取有效Token |
|
||||
| 测试步骤 | 1. 调用刷新Token接口 /api/v1/auth/refresh |
|
||||
| 预期结果 | 1. 返回新的Token<br>2. 新Token有效时间重置 |
|
||||
|
||||
### 1.3 客户用户注册
|
||||
|
||||
| 用例编号 | TC-AUTH-006 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户用户正常注册 |
|
||||
| 测试目的 | 验证新用户可以成功注册 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 无 |
|
||||
| 测试步骤 | 1. 打开注册页面<br>2. 输入手机号<br>3. 获取并输入短信验证码<br>4. 设置密码<br>5. 确认密码<br>6. 点击注册按钮 |
|
||||
| 预期结果 | 1. 注册成功<br>2. 自动登录并跳转至首页<br>3. 返回用户信息 |
|
||||
|
||||
| 用例编号 | TC-AUTH-007 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户用户注册-手机号已存在 |
|
||||
| 测试目的 | 验证已注册手机号无法重复注册 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 手机号 13800138000 已注册 |
|
||||
| 测试步骤 | 1. 输入已注册手机号 13800138000<br>2. 获取短信验证码<br>3. 填写其他必填信息<br>4. 点击注册按钮 |
|
||||
| 预期结果 | 1. 注册失败<br>2. 显示错误提示"该手机号已注册" |
|
||||
|
||||
| 用例编号 | TC-AUTH-008 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户用户注册-无效验证码 |
|
||||
| 测试目的 | 验证无效验证码无法完成注册 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 无 |
|
||||
| 测试步骤 | 1. 输入手机号<br>2. 输入错误的短信验证码<br>3. 填写密码<br>4. 点击注册按钮 |
|
||||
| 预期结果 | 1. 注册失败<br>2. 显示错误提示"验证码错误" |
|
||||
|
||||
### 1.4 客户用户登录
|
||||
|
||||
| 用例编号 | TC-AUTH-009 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户用户密码登录 |
|
||||
| 测试目的 | 验证客户用户使用密码登录 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已存在客户用户账号 |
|
||||
| 测试步骤 | 1. 输入手机号<br>2. 输入密码<br>3. 点击登录按钮 |
|
||||
| 预期结果 | 1. 登录成功<br>2. 返回Token |
|
||||
|
||||
| 用例编号 | TC-AUTH-010 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户用户短信登录 |
|
||||
| 测庺目的 | 验证客户用户使用短信验证码登录 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已存在客户用户账号 |
|
||||
| 测试步骤 | 1. 输入手机号<br>2. 点击发送验证码<br>3. 输入收到的短信验证码<br>4. 点击登录按钮 |
|
||||
| 预期结果 | 1. 登录成功<br>2. 返回Token |
|
||||
|
||||
---
|
||||
|
||||
## 2. 客户管理模块
|
||||
|
||||
### 2.1 创建客户
|
||||
|
||||
| 用例编号 | TC-CUS-001 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 创建客户-必填字段完整 |
|
||||
| 测试目的 | 验证必填字段填写完整时可成功创建客户 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员 |
|
||||
| 测试步骤 | 1. 进入客户管理页面<br>2. 点击"新增客户"按钮<br>3. 填写客户名称:测试公司<br>4. 选择客户类型:企业<br>5. 填写联系人:张三<br>6. 选择客户等级:VIP<br>7. 选择状态:启用<br>8. 点击保存按钮 |
|
||||
| 预期结果 | 1. 创建成功<br>2. 客户列表显示新客户<br>3. 自动生成客户编码 |
|
||||
|
||||
| 用例编号 | TC-CUS-002 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 创建客户-缺少必填字段 |
|
||||
| 测试目的 | 验证缺少必填字段时创建失败 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员 |
|
||||
| 测试步骤 | 1. 进入客户管理页面<br>2. 点击"新增客户"按钮<br>3. 填写部分字段<br>4. 点击保存按钮 |
|
||||
| 预期结果 | 1. 创建失败<br>2. 显示错误提示,提示必填字段 |
|
||||
|
||||
| 用例编号 | TC-CUS-003 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 创建客户-邮箱格式错误 |
|
||||
| 测试目的 | 验证邮箱格式错误时创建失败 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员 |
|
||||
| 测试步骤 | 1. 填写客户信息<br>2. 输入格式错误的邮箱 abc@<br>3. 点击保存按钮 |
|
||||
| 预期结果 | 1. 创建失败<br>2. 显示错误提示"邮箱格式不正确" |
|
||||
|
||||
### 2.2 查询客户
|
||||
|
||||
| 用例编号 | TC-CUS-004 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 查询客户-列表展示 |
|
||||
| 测试目的 | 验证客户列表正确展示 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员,系统存在多个客户 |
|
||||
| 测试步骤 | 1. 进入客户管理页面 |
|
||||
| 预期结果 | 1. 显示客户列表<br>2. 每页显示默认20条<br>3. 显示客户名称、编码、联系人、状态等信息 |
|
||||
|
||||
| 用例编号 | TC-CUS-005 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 查询客户-搜索功能 |
|
||||
| 测试目的 | 验证搜索功能可正确过滤客户 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员,系统存在客户"测试公司" |
|
||||
| 测试步骤 | 1. 在搜索框输入"测试公司"<br>2. 点击搜索按钮 |
|
||||
| 预期结果 | 1. 列表只显示匹配的客户的 |
|
||||
|
||||
| 用例编号 | TC-CUS-006 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 查询客户-分页功能 |
|
||||
| 测试目的 | 验证分页功能正常 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员,客户数量超过20 |
|
||||
| 测试步骤 | 1. 进入客户列表<br>2. 点击第2页 |
|
||||
| 预期结果 | 1. 显示第2页数据<br>2. 分页信息正确 |
|
||||
|
||||
### 2.3 更新客户
|
||||
|
||||
| 用例编号 | TC-CUS-007 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 更新客户信息 |
|
||||
| 测试目的 | 验证客户信息可以正常更新 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员,存在客户"测试公司" |
|
||||
| 测试步骤 | 1. 在客户列表找到"测试公司"<br>2. 点击编辑按钮<br>3. 修改客户名称为"测试公司2"<br>4. 点击保存按钮 |
|
||||
| 预期结果 | 1. 更新成功<br>2. 列表显示新名称 |
|
||||
|
||||
| 用例编号 | TC-CUS-008 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 更新客户状态 |
|
||||
| 测试目的 | 验证客户状态可以正常切换 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员,存在客户 |
|
||||
| 测试步骤 | 1. 找到目标客户<br>2. 点击启用/禁用按钮或编辑页面修改状态 |
|
||||
| 预期结果 | 1. 状态更新成功<br>2. 列表显示新状态 |
|
||||
|
||||
### 2.4 删除客户
|
||||
|
||||
| 用例编号 | TC-CUS-009 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 删除客户 |
|
||||
| 测试目的 | 验证客户可以被删除 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员,存在客户 |
|
||||
| 测试步骤 | 1. 找到目标客户<br>2. 点击删除按钮<br>3. 在确认弹窗中点击确认 |
|
||||
| 预期结果 | 1. 删除成功<br>2. 列表不再显示该客户 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 授权码管理模块
|
||||
|
||||
### 3.1 创建授权码
|
||||
|
||||
| 用例编号 | TC-AUTHCODE-001 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 创建授权码-必填字段完整 |
|
||||
| 测试目的 | 验证必填字段填写完整时可成功创建授权码 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员,系统存在客户 |
|
||||
| 测试步骤 | 1. 进入授权码管理页面<br>2. 点击"新建授权码"按钮<br>3. 选择客户<br>4. 填写有效天数:365<br>5. 选择部署类型:独立部署<br>6. 填写最大激活次数:5<br>7. 点击保存按钮 |
|
||||
| 预期结果 | 1. 创建成功<br>2. 生成授权码<br>3. 显示授权码详情 |
|
||||
|
||||
| 用例编号 | TC-AUTHCODE-002 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 创建授权码-缺少必填字段 |
|
||||
| 测试目的 | 验证缺少必填字段时创建失败 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员 |
|
||||
| 测试步骤 | 1. 进入授权码管理页面<br>2. 点击"新建授权码"按钮<br>3. 部分填写字段<br>4. 点击保存按钮 |
|
||||
| 预期结果 | 1. 创建失败<br>2. 显示错误提示 |
|
||||
|
||||
| 用例编号 | TC-AUTHCODE-003 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 创建授权码-永久有效 |
|
||||
| 测试目的 | 验证可以创建永久有效的授权码 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员 |
|
||||
| 测试步骤 | 1. 填写有效天数为365000(永久)<br>2. 其他必填字段正常填写<br>3. 点击保存 |
|
||||
| 预期结果 | 1. 创建成功<br>2. 显示"永久有效"而非具体过期日期 |
|
||||
|
||||
### 3.2 查询授权码
|
||||
|
||||
| 用例编号 | TC-AUTHCODE-004 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 查询授权码列表 |
|
||||
| 测试目的 | 验证授权码列表正确展示 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员,系统存在多个授权码 |
|
||||
| 测试步骤 | 1. 进入授权码管理页面 |
|
||||
| 预期结果 | 1. 显示授权码列表<br>2. 显示授权码、客户名称、状态、生效日期、过期日期、激活次数 |
|
||||
|
||||
| 用例编号 | TC-AUTHCODE-005 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 查询授权码-状态筛选 |
|
||||
| 测试目的 | 验证按状态筛选功能正常 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员 |
|
||||
| 测试步骤 | 1. 选择状态筛选:已锁定<br>2. 点击筛选 |
|
||||
| 预期结果 | 1. 只显示锁定的授权码 |
|
||||
|
||||
| 用例编号 | TC-AUTHCODE-006 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 查询授权码详情 |
|
||||
| 测试目的 | 验证授权码详情页展示完整信息 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员,存在授权码 |
|
||||
| 测试步骤 | 1. 点击任意授权码条目 |
|
||||
| 预期结果 | 1. 显示授权码详情<br>2. 显示关联客户信息、授权码信息、功能配置等 |
|
||||
|
||||
### 3.3 更新授权码
|
||||
|
||||
| 用例编号 | TC-AUTHCODE-007 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 更新授权码-延长时间 |
|
||||
| 测试目的 | 验证可以延长授权码有效期 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员,存在授权码 |
|
||||
| 测试步骤 | 1. 找到目标授权码<br>2. 点击编辑按钮<br>3. 修改有效天数为730<br>4. 选择变更类型:续期<br>5. 填写原因<br>6. 点击保存 |
|
||||
| 预期结果 | 1. 更新成功<br>2. 过期日期延后<br>3. 生成变更历史记录 |
|
||||
|
||||
| 用例编号 | TC-AUTHCODE-008 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 锁定授权码 |
|
||||
| 测试目的 | 验证可以锁定授权码 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员,存在授权码 |
|
||||
| 测试步骤 | 1. 找到目标授权码<br>2. 点击锁定按钮<br>3. 填写锁定原因<br>4. 点击确认 |
|
||||
| 预期结果 | 1. 授权码被锁定<br>2. 状态显示为锁定<br>3. 无法进行激活 |
|
||||
|
||||
| 用例编号 | TC-AUTHCODE-009 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 解锁授权码 |
|
||||
| 测试目的 | 验证可以解锁授权码 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员,存在锁定的授权码 |
|
||||
| 测试步骤 | 1. 找到锁定的授权码<br>2. 点击解锁按钮<br>3. 确认解锁 |
|
||||
| 预期结果 | 1. 授权码被解锁<br>2. 状态恢复正常 |
|
||||
|
||||
### 3.4 删除授权码
|
||||
|
||||
| 用例编号 | TC-AUTHCODE-010 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 删除授权码 |
|
||||
| 测试目的 | 验证授权码可以被删除 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员,存在未使用的授权码 |
|
||||
| 测试步骤 | 1. 找到目标授权码<br>2. 点击删除按钮<br>3. 确认删除 |
|
||||
| 预期结果 | 1. 删除成功<br>2. 列表不再显示该授权码 |
|
||||
|
||||
### 3.5 授权码变更历史
|
||||
|
||||
| 用例编号 | TC-AUTHCODE-011 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 查看授权码变更历史 |
|
||||
| 测试目的 | 验证变更历史记录完整 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员,授权码有变更记录 |
|
||||
| 测试步骤 | 1. 进入授权码详情<br>2. 点击"变更历史"标签页 |
|
||||
| 预期结果 | 1. 显示所有变更记录<br>2. 包含变更类型、操作人、时间、原因 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 许可证管理模块
|
||||
|
||||
### 4.1 激活许可证
|
||||
|
||||
| 用例编号 | TC-LICENSE-001 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户端激活许可证-成功 |
|
||||
| 测试目的 | 验证客户端使用授权码可以成功激活 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 存在有效的授权码,客户端未激活过 |
|
||||
| 测试步骤 | 1. 打开客户端软件<br>2. 输入授权码<br>3. 输入硬件指纹<br>4. 点击激活按钮 |
|
||||
| 预期结果 | 1. 激活成功<br>2. 返回许可证密钥<br>3. 生成许可证文件 |
|
||||
|
||||
| 用例编号 | TC-LICENSE-002 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户端激活许可证-授权码无效 |
|
||||
| 测试目的 | 验证无效授权码无法激活 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 无 |
|
||||
| 测试步骤 | 1. 打开客户端软件<br>2. 输入无效授权码<br>3. 输入硬件指纹<br>4. 点击激活按钮 |
|
||||
| 预期结果 | 1. 激活失败<br>2. 显示错误提示"授权码无效" |
|
||||
|
||||
| 用例编号 | TC-LICENSE-003 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户端激活许可证-授权码已过期 |
|
||||
| 测试目的 | 验证过期授权码无法激活 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 存在已过期的授权码 |
|
||||
| 测试步骤 | 1. 打开客户端软件<br>2. 输入过期授权码<br>3. 输入硬件指纹<br>4. 点击激活按钮 |
|
||||
| 预期结果 | 1. 激活失败<br>2. 显示错误提示"授权码已过期" |
|
||||
|
||||
| 用例编号 | TC-LICENSE-004 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户端激活许可证-授权码已锁定 |
|
||||
| 测试目的 | 验证锁定授权码无法激活 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 存在已锁定的授权码 |
|
||||
| 测试步骤 | 1. 打开客户端软件<br>2. 输入锁定授权码<br>3. 输入硬件指纹<br>4. 点击激活按钮 |
|
||||
| 预期结果 | 1. 激活失败<br>2. 显示错误提示"授权码已锁定" |
|
||||
|
||||
| 用例编号 | TC-LICENSE-005 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户端激活许可证-激活次数超限 |
|
||||
| 测试目的 | 验证超过最大激活次数无法激活 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 授权码已达到最大激活次数 |
|
||||
| 测试步骤 | 1. 打开客户端软件<br>2. 输入授权码<br>3. 输入硬件指纹<br>4. 点击激活按钮 |
|
||||
| 预期结果 | 1. 激活失败<br>2. 显示错误提示"激活次数已达上限" |
|
||||
|
||||
### 4.2 心跳检测
|
||||
|
||||
| 用例编号 | TC-LICENSE-006 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户端心跳检测-在线 |
|
||||
| 测试目的 | 验证客户端心跳正常,状态在线 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 许可证已激活,客户端在线 |
|
||||
| 测试步骤 | 1. 客户端发送心跳请求<br>2. 携带许可证密钥和硬件指纹 |
|
||||
| 预期结果 | 1. 返回成功<br>2. 许可证状态为在线<br>3. 更新最后心跳时间 |
|
||||
|
||||
| 用例编号 | TC-LICENSE-007 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户端心跳检测-许可证被撤销 |
|
||||
| 测试目的 | 验证许可证被撤销后心跳失败 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 许可证已被管理员撤销 |
|
||||
| 测试步骤 | 1. 客户端发送心跳请求 |
|
||||
| 预期结果 | 1. 返回失败<br>2. 错误提示"许可证已撤销" |
|
||||
|
||||
| 用例编号 | TC-LICENSE-008 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户端心跳检测-许可证过期 |
|
||||
| 测试目的 | 验证许可证过期后心跳失败 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 授权码已过期 |
|
||||
| 测试步骤 | 1. 客户端发送心跳请求 |
|
||||
| 预期结果 | 1. 返回失败<br>2. 错误提示"授权码已过期" |
|
||||
|
||||
### 4.3 管理员手动创建许可证
|
||||
|
||||
| 用例编号 | TC-LICENSE-009 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 管理员手动创建许可证 |
|
||||
| 测试目的 | 验证管理员可以手动创建许可证 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员,存在授权码和客户 |
|
||||
| 测试步骤 | 1. 进入许可证管理页面<br>2. 点击"新建许可证"<br>3. 选择授权码<br>4. 填写硬件指纹<br>5. 点击保存 |
|
||||
| 预期结果 | 1. 创建成功<br>2. 生成许可证密钥 |
|
||||
|
||||
### 4.4 撤销许可证
|
||||
|
||||
| 用例编号 | TC-LICENSE-010 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 管理员撤销许可证 |
|
||||
| 测试目的 | 验证管理员可以撤销许可证 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员,存在激活的许可证 |
|
||||
| 测试步骤 | 1. 进入许可证管理页面<br>2. 找到目标许可证<br>3. 点击撤销按钮<br>4. 填写撤销原因<br>5. 确认撤销 |
|
||||
| 预期结果 | 1. 撤销成功<br>2. 许可证状态变为已撤销<br>3. 客户端无法使用 |
|
||||
|
||||
### 4.5 许可证列表查询
|
||||
|
||||
| 用例编号 | TC-LICENSE-011 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 查询许可证列表 |
|
||||
| 测试目的 | 验证许可证列表正确展示 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员,系统存在多个许可证 |
|
||||
| 测试步骤 | 1. 进入许可证管理页面 |
|
||||
| 预期结果 | 1. 显示许可证列表<br>2. 显示许可证密钥、客户名称、状态、激活时间、在线状态等 |
|
||||
|
||||
| 用例编号 | TC-LICENSE-012 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 查询许可证-状态筛选 |
|
||||
| 测试目的 | 验证按状态筛选功能正常 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员 |
|
||||
| 测试步骤 | 1. 选择状态筛选:已激活<br>2. 点击筛选 |
|
||||
| 预期结果 | 1. 只显示激活的许可证 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 套餐管理模块
|
||||
|
||||
### 5.1 创建套餐
|
||||
|
||||
| 用例编号 | TC-PACKAGE-001 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 创建套餐 |
|
||||
| 测试目的 | 验证可以创建新套餐 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员 |
|
||||
| 测试步骤 | 1. 进入套餐管理页面<br>2. 点击"新建套餐"<br>3. 填写套餐名称:基础版<br>4. 填写价格:99<br>5. 填写有效天数:365<br>6. 填写最大激活次数:10<br>7. 填写描述<br>8. 点击保存 |
|
||||
| 预期结果 | 1. 创建成功<br>2. 列表显示新套餐 |
|
||||
|
||||
### 5.2 查询套餐
|
||||
|
||||
| 用例编号 | TC-PACKAGE-002 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户查看可用套餐 |
|
||||
| 测试目的 | 验证客户可查看可选套餐 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 系统存在启用的套餐,客户已登录 |
|
||||
| 测试步骤 | 1. 客户登录后进入套餐选择页面 |
|
||||
| 预期结果 | 1. 显示所有启用的套餐<br>2. 显示套餐名称、价格、权益等 |
|
||||
|
||||
### 5.3 更新套餐
|
||||
|
||||
| 用例编号 | TC-PACKAGE-003 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 更新套餐信息 |
|
||||
| 测试目的 | 验证套餐信息可以更新 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员,存在套餐 |
|
||||
| 测试步骤 | 1. 找到目标套餐<br>2. 点击编辑<br>3. 修改价格或描述<br>4. 保存 |
|
||||
| 预期结果 | 1. 更新成功<br>2. 列表显示新信息 |
|
||||
|
||||
### 5.4 套餐上下架
|
||||
|
||||
| 用例编号 | TC-PACKAGE-004 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 套餐下架 |
|
||||
| 测试目的 | 验证可以下架套餐 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员,存在启用的套餐 |
|
||||
| 测试步骤 | 1. 找到目标套餐<br>2. 点击下架按钮 |
|
||||
| 预期结果 | 1. 下架成功<br>2. 套餐状态变为禁用<br>3. 客户不可见 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 订单管理模块
|
||||
|
||||
### 6.1 创建订单
|
||||
|
||||
| 用例编号 | TC-ORDER-001 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户创建订单-选择套餐 |
|
||||
| 测试目的 | 验证客户可以选择套餐创建订单 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 客户已登录,系统存在启用的套餐 |
|
||||
| 测试步骤 | 1. 进入套餐页面<br>2. 选择目标套餐<br>3. 点击立即购买 |
|
||||
| 预期结果 | 1. 进入确认订单页面<br>2. 显示套餐信息、价格 |
|
||||
|
||||
| 用例编号 | TC-ORDER-002 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户创建订单-自定义授权码 |
|
||||
| 测试目的 | 验证客户可以输入自定义授权码 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 客户已登录 |
|
||||
| 测试步骤 | 1. 选择"使用已有授权码"<br>2. 输入授权码<br>3. 点击下一步 |
|
||||
| 预期结果 | 1. 验证授权码有效<br>2. 进入确认订单页面 |
|
||||
|
||||
| 用例编号 | TC-ORDER-003 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 订单价格计算 |
|
||||
| 测试目的 | 验证订单价格计算正确 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 客户已登录 |
|
||||
| 测试步骤 | 1. 选择套餐<br>2. 查看价格明细 |
|
||||
| 预期结果 | 1. 显示单价、总价、优惠等 |
|
||||
|
||||
### 6.2 订单支付
|
||||
|
||||
| 用例编号 | TC-ORDER-004 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 订单支付-支付宝 |
|
||||
| 测试目的 | 验证可以使用支付宝支付订单 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 客户已登录,存在待支付订单 |
|
||||
| 测试步骤 | 1. 进入订单详情<br>2. 选择支付宝支付<br>3. 点击去支付<br>4. 完成支付宝支付流程 |
|
||||
| 预期结果 | 1. 跳转至支付宝页面<br>2. 支付成功后订单状态变为已支付 |
|
||||
|
||||
| 用例编号 | TC-ORDER-005 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 订单支付-支付超时 |
|
||||
| 测试目的 | 验证订单支付超时后状态正确 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 客户发起支付但未完成 |
|
||||
| 测试步骤 | 1. 发起支付<br>2. 等待超过支付超时时间(15分钟)<br>3. 查看订单状态 |
|
||||
| 预期结果 | 1. 订单状态变为已取消<br>2. 客户可重新发起支付 |
|
||||
|
||||
### 6.3 取消订单
|
||||
|
||||
| 用例编号 | TC-ORDER-006 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 取消待支付订单 |
|
||||
| 测试目的 | 验证客户可以取消未支付的订单 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 客户存在待支付订单 |
|
||||
| 测试步骤 | 1. 进入我的订单<br>2. 找到待支付订单<br>3. 点击取消订单<br>4. 确认取消 |
|
||||
| 预期结果 | 1. 订单取消成功<br>2. 订单状态变为已取消 |
|
||||
|
||||
### 6.4 订单列表查询
|
||||
|
||||
| 用例编号 | TC-ORDER-007 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 查询客户订单列表 |
|
||||
| 测试目的 | 验证客户可查看自己的订单 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 客户已登录,存在订单 |
|
||||
| 测试步骤 | 1. 进入我的订单页面 |
|
||||
| 预期结果 | 1. 显示订单列表<br>2. 显示订单号、套餐名称、金额、状态、时间等 |
|
||||
|
||||
| 用例编号 | TC-ORDER-008 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 订单状态筛选 |
|
||||
| 测试目的 | 验证按状态筛选订单功能正常 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 客户已登录,存在多种状态订单 |
|
||||
| 测试步骤 | 1. 选择状态筛选:已完成<br>2. 点击筛选 |
|
||||
| 预期结果 | 1. 只显示已完成的订单 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 支付模块
|
||||
|
||||
### 7.1 支付回调
|
||||
|
||||
| 用例编号 | TC-PAY-001 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 支付宝支付回调-成功 |
|
||||
| 测试目的 | 验证支付成功后回调处理正确 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 客户发起支付宝支付 |
|
||||
| 测试步骤 | 1. 客户完成支付宝支付<br>2. 支付宝发送异步回调 |
|
||||
| 预期结果 | 1. 回调处理成功<br>2. 订单状态更新为已支付<br>3. 授权码生成/激活成功 |
|
||||
|
||||
| 用例编号 | TC-PAY-002 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 支付宝支付回调-签名验证 |
|
||||
| 测试目的 | 验证回调签名验证正确,防止伪造回调 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 无 |
|
||||
| 测试步骤 | 1. 发送伪造的支付回调请求 |
|
||||
| 预期结果 | 1. 签名验证失败<br>2. 不处理回调 |
|
||||
|
||||
### 7.2 支付状态查询
|
||||
|
||||
| 用例编号 | TC-PAY-003 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 查询支付状态 |
|
||||
| 测试目的 | 验证可以查询支付结果 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 存在支付记录 |
|
||||
| 测试步骤 | 1. 进入支付记录<br>2. 查看支付详情 |
|
||||
| 预期结果 | 1. 显示支付状态:待支付/支付中/已支付/已取消 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 发票管理模块
|
||||
|
||||
### 8.1 客户申请发票
|
||||
|
||||
| 用例编号 | TC-INVOICE-001 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户申请发票 |
|
||||
| 测试目的 | 验证客户可以申请开具发票 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 客户存在已完成的订单 |
|
||||
| 测试步骤 | 1. 进入发票申请页面<br>2. 选择订单<br>3. 填写发票抬头<br>4. 选择发票类型:普通/增值税专用<br>5. 填写税号等信息<br>6. 提交申请 |
|
||||
| 预期结果 | 1. 申请成功<br>2. 发票状态变为申请中 |
|
||||
|
||||
| 用例编号 | TC-INVOICE-002 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户查看发票列表 |
|
||||
| 测试目的 | 验证客户可查看自己的发票 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 客户已登录 |
|
||||
| 测试步骤 | 1. 进入我的发票页面 |
|
||||
| 预期结果 | 1. 显示发票列表<br>2. 显示发票状态、抬头、金额等 |
|
||||
|
||||
### 8.2 管理员处理发票
|
||||
|
||||
| 用例编号 | TC-INVOICE-003 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 管理员开具发票 |
|
||||
| 测试目的 | 验证管理员可以开具发票 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员,存在申请中的发票 |
|
||||
| 测试步骤 | 1. 进入发票管理页面<br>2. 找到待处理发票<br>3. 上传发票文件<br>4. 点击开具 |
|
||||
| 预期结果 | 1. 发票状态变为已开具<br>2. 生成发票文件 |
|
||||
|
||||
| 用例编号 | TC-INVOICE-004 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 管理员拒绝发票 |
|
||||
| 测试目的 | 验证管理员可以拒绝发票申请 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员,存在申请中的发票 |
|
||||
| 测试步骤 | 1. 找到目标发票<br>2. 点击拒绝<br>3. 填写拒绝原因<br>4. 确认 |
|
||||
| 预期结果 | 1. 发票状态变为已拒绝<br>2. 显示拒绝原因 |
|
||||
|
||||
### 8.3 发票下载
|
||||
|
||||
| 用例编号 | TC-INVOICE-005 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户下载发票 |
|
||||
| 测试目的 | 验证客户可以下载已开具的发票 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 发票已开具 |
|
||||
| 测试步骤 | 1. 进入我的发票<br>2. 找到已开具的发票<br>3. 点击下载 |
|
||||
| 预期结果 | 1. 发票PDF文件开始下载 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 设备管理模块
|
||||
|
||||
### 9.1 设备列表
|
||||
|
||||
| 用例编号 | TC-DEVICE-001 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户查看设备列表 |
|
||||
| 测试目的 | 验证客户可以查看自己的设备列表 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 客户存在激活的许可证设备 |
|
||||
| 测试步骤 | 1. 客户登录后进入设备管理页面 |
|
||||
| 预期结果 | 1. 显示设备列表<br>2. 显示设备名称、状态、最后在线时间等 |
|
||||
|
||||
| 用例编号 | TC-DEVICE-002 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 设备在线状态显示 |
|
||||
| 测试目的 | 验证设备在线状态正确显示 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 存在在线和离线的设备 |
|
||||
| 测试步骤 | 1. 查看设备列表 |
|
||||
| 预期结果 | 1. 在线设备显示在线状态<br>2. 离线设备显示离线状态 |
|
||||
|
||||
### 9.2 设备解绑
|
||||
|
||||
| 用例编号 | TC-DEVICE-003 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 客户解绑设备 |
|
||||
| 测试目的 | 验证客户可以解绑自己的设备 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 客户存在激活的设备 |
|
||||
| 测试步骤 | 1. 找到目标设备<br>2. 点击解绑按钮<br>3. 确认解绑 |
|
||||
| 预期结果 | 1. 解绑成功<br>2. 设备从列表移除<br>3. 许可证释放 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 线索管理模块
|
||||
|
||||
### 10.1 提交线索
|
||||
|
||||
| 用例编号 | TC-LEAD-001 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 访客提交线索-完整信息 |
|
||||
| 测试目的 | 验证访客可以成功提交线索 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 无 |
|
||||
| 测试步骤 | 1. 打开线索提交页面<br>2. 填写公司名称:测试公司<br>3. 填写联系人:张三<br>4. 填写联系电话:13800138000<br>5. 填写需求描述<br>6. 点击提交 |
|
||||
| 预期结果 | 1. 提交成功<br>2. 返回线索编号<br>3. 显示提交成功提示 |
|
||||
|
||||
| 用例编号 | TC-LEAD-002 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 访客提交线索-缺少必填字段 |
|
||||
| 测试目的 | 验证缺少必填字段时提交失败 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 无 |
|
||||
| 测试步骤 | 1. 部分填写线索信息<br>2. 缺少公司名称<br>3. 点击提交 |
|
||||
| 预期结果 | 1. 提交失败<br>2. 提示必填字段不能为空 |
|
||||
|
||||
| 用例编号 | TC-LEAD-003 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 访客提交线索-需求为空 |
|
||||
| 测试目的 | 验证需求描述可为空(修改后) |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 无 |
|
||||
| 测试步骤 | 1. 填写必填字段(公司名、联系人、电话)<br>2. 需求描述留空<br>3. 点击提交 |
|
||||
| 预期结果 | 1. 提交成功<br>2. 需求描述自动填充为"Empty" |
|
||||
|
||||
| 用例编号 | TC-LEAD-004 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 访客提交线索-邮箱格式错误 |
|
||||
| 测试目的 | 验证邮箱格式错误时有提示 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 无 |
|
||||
| 测试步骤 | 1. 填写必填字段<br>2. 填写格式错误的邮箱 abc@<br>3. 点击提交 |
|
||||
| 预期结果 | 1. 提交失败<br>2. 提示邮箱格式不正确 |
|
||||
|
||||
### 10.2 管理员处理线索
|
||||
|
||||
| 用例编号 | TC-LEAD-005 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 管理员查看线索列表 |
|
||||
| 测试目的 | 验证管理员可以查看所有线索 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员,系统存在线索 |
|
||||
| 测试步骤 | 1. 进入线索管理页面 |
|
||||
| 预期结果 | 1. 显示线索列表<br>2. 显示公司名称、联系人、电话、状态、提交时间等 |
|
||||
|
||||
| 用例编号 | TC-LEAD-006 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 管理员更新线索状态 |
|
||||
| 测试目的 | 验证管理员可以更新线索状态 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员,存在线索 |
|
||||
| 测试步骤 | 1. 找到目标线索<br>2. 修改状态为已联系<br>3. 填写跟进记录<br>4. 保存 |
|
||||
| 预期结果 | 1. 更新成功<br>2. 线索状态变更 |
|
||||
|
||||
| 用例编号 | TC-LEAD-007 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 管理员删除线索 |
|
||||
| 测试目的 | 验证管理员可以删除线索 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员,存在线索 |
|
||||
| 测试步骤 | 1. 找到目标线索<br>2. 点击删除<br>3. 确认删除 |
|
||||
| 预期结果 | 1. 删除成功<br>2. 线索从列表移除 |
|
||||
|
||||
### 10.3 线索统计
|
||||
|
||||
| 用例编号 | TC-LEAD-008 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 查看线索汇总统计 |
|
||||
| 测试目的 | 验证线索统计功能正常 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员 |
|
||||
| 测试步骤 | 1. 进入线索汇总页面 |
|
||||
| 预期结果 | 1. 显示线索总数<br>2. 显示各状态数量:待联系、已联系、已成交、已失效 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 仪表盘/统计模块
|
||||
|
||||
### 11.1 授权概览统计
|
||||
|
||||
| 用例编号 | TC-STATS-001 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 查看授权概览统计 |
|
||||
| 测试目的 | 验证概览统计数据正确 |
|
||||
| 优先级 | P0 |
|
||||
| 前置条件 | 已登录为管理员 |
|
||||
| 测试步骤 | 1. 进入仪表盘首页 |
|
||||
| 预期结果 | 1. 显示总授权码数量<br>2. 显示活跃许可证数量<br>3. 显示即将过期数量<br>4. 显示异常告警数量<br>5. 显示增长率 |
|
||||
|
||||
### 11.2 授权趋势图
|
||||
|
||||
| 用例编号 | TC-STATS-002 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 查看授权趋势图 |
|
||||
| 测试目的 | 验证授权趋势图表展示正确 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员 |
|
||||
| 测试步骤 | 1. 进入授权趋势页面 |
|
||||
| 预期结果 | 1. 显示折线图/柱状图<br>2. 显示时间范围内授权码创建数量 |
|
||||
|
||||
| 用例编号 | TC-STATS-003 |
|
||||
|---------|-------------|
|
||||
| 用例名称 | 筛选授权趋势时间范围 |
|
||||
| 测试目的 | 验证时间范围筛选功能正常 |
|
||||
| 优先级 | P1 |
|
||||
| 前置条件 | 已登录为管理员 |
|
||||
| 测试步骤 | 1. 选择时间范围:最近7天<br>2. 查看趋势图 |
|
||||
| 预期结果 | 1. 显示最近7天的数据 |
|
||||
|
||||
---
|
||||
|
||||
## 附录:测试优先级说明
|
||||
|
||||
| 优先级 | 说明 |
|
||||
|--------|------|
|
||||
| P0 | 核心功能,必须测试,阻塞发布 |
|
||||
| P1 | 重要功能,建议测试,影响体验 |
|
||||
| P2 | 辅助功能,可以测试 |
|
||||
|
||||
---
|
||||
|
||||
## 附录:测试环境说明
|
||||
|
||||
| 环境 | 说明 |
|
||||
|------|------|
|
||||
| 开发环境 | 本地开发调试使用 |
|
||||
| 测试环境 | 功能测试、回归测试使用 |
|
||||
| 预发布环境 | 验收测试使用 |
|
||||
| 生产环境 | 正式环境 |
|
||||
|
||||
---
|
||||
|
||||
## 附录:测试数据准备
|
||||
|
||||
1. **管理员账号**
|
||||
- 用户名: admin
|
||||
- 密码: admin123
|
||||
|
||||
2. **客户用户账号**
|
||||
- 手机号: 17398467065
|
||||
- 密码: 12345678
|
||||
|
||||
3. **授权码**
|
||||
- 状态: 正常/锁定/过期
|
||||
- 激活次数: 未使用/已用完
|
||||
|
||||
4. **套餐**
|
||||
- 状态: 启用/禁用
|
||||
- 价格: 99/299/999
|
||||
@@ -7,9 +7,9 @@
|
||||
# @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
||||
###
|
||||
# 开发环境接口地址
|
||||
VITE_API_BASE_URL = 'http://localhost:18888'
|
||||
# VITE_API_BASE_URL = 'http://localhost:18888'
|
||||
# VITE_API_BASE_URL = 'http://111.113.214.109:18888'
|
||||
# VITE_API_BASE_URL = 'http://lm.cedar-v.com:18080'
|
||||
VITE_API_BASE_URL = 'http://43.136.177.43:18888/'
|
||||
|
||||
|
||||
# # 开发环境打包名称
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# 字体版权审计报告
|
||||
|
||||
生成日期: 2026-02-08
|
||||
|
||||
## 🔴 紧急处理项
|
||||
|
||||
### 1. Swis721 BlkCn BT - **必须移除**
|
||||
- **风险等级**: 🔴 高危
|
||||
- **当前状态**: 未授权商用
|
||||
- **文件位置**: `src/assets/fonts/Swis721-BlkCn-BT-Black.ttf`
|
||||
- **使用位置**: `src/views/Login.vue`
|
||||
- **版权方**: Bitstream Inc.
|
||||
- **商用费用**: ~$35-99 USD/字重
|
||||
|
||||
#### 替代方案:
|
||||
```css
|
||||
/* 推荐替换为开源字体 */
|
||||
font-family: 'Roboto Condensed', 'Noto Sans SC', sans-serif;
|
||||
/* 或 */
|
||||
font-family: 'Barlow Condensed', 'Noto Sans SC', sans-serif;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. PangMenZhengDao (庞门正道标题体) - **需确认授权**
|
||||
- **风险等级**: 🟡 中危
|
||||
- **当前状态**: 免费版(疑似仅限个人使用)
|
||||
- **文件位置**: `src/assets/fonts/PangMenZhengDao.ttf`
|
||||
- **使用位置**: `src/views/Licenses/LicenseSearch.vue`
|
||||
- **版权方**: 庞门正道字体工作室
|
||||
- **免费版限制**: 通常仅限个人非商业使用
|
||||
|
||||
#### 处理建议:
|
||||
1. **联系作者确认商用授权**
|
||||
- 官网: https://www.zcool.com.cn/u/14777796
|
||||
- 或购买商用授权(通常 ¥199-999 RMB)
|
||||
|
||||
2. **或替换为开源字体**:
|
||||
```css
|
||||
/* 推荐中文标题字体 */
|
||||
font-family: 'Noto Serif SC', 'Source Han Serif CN', serif;
|
||||
/* 或使用粗体 Noto Sans */
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
font-weight: 700;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 安全字体
|
||||
|
||||
### Noto Sans SC - 完全合规
|
||||
- **协议**: SIL Open Font License 1.1
|
||||
- **版权方**: Google Fonts
|
||||
- **商用**: ✅ 完全免费
|
||||
- **当前状态**: ✅ 已正确引入
|
||||
- **文件**: `src/assets/fonts/noto-sans-sc-*.woff2`
|
||||
|
||||
---
|
||||
|
||||
## 📋 建议行动计划
|
||||
|
||||
### 立即执行(24小时内)
|
||||
1. ✅ 移除 `Swis721-BlkCn-BT-Black.ttf`
|
||||
2. ✅ 移除 fonts.css 中的 Swis721 声明
|
||||
3. ✅ 更新 Login.vue 使用开源字体
|
||||
|
||||
### 短期执行(1周内)
|
||||
1. 🔍 联系庞门正道确认 PangMenZhengDao 授权
|
||||
2. 📝 如无法获得授权,替换为开源字体
|
||||
3. 📄 添加字体授权文档到项目
|
||||
|
||||
### 长期优化
|
||||
1. 📋 建立字体使用规范文档
|
||||
2. 🔍 定期审计第三方资源版权
|
||||
3. ✅ 优先使用 Google Fonts 等开源字体
|
||||
|
||||
---
|
||||
|
||||
## 🔧 技术实施清单
|
||||
|
||||
### 移除商业字体
|
||||
```bash
|
||||
# 删除文件
|
||||
rm frontend/src/assets/fonts/Swis721-BlkCn-BT-Black.ttf
|
||||
rm frontend/src/assets/fonts/PangMenZhengDao.ttf # 如未获授权
|
||||
|
||||
# 更新 fonts.css(移除相关 @font-face 声明)
|
||||
# 更新组件样式(替换 font-family)
|
||||
```
|
||||
|
||||
### 推荐开源替代字体
|
||||
|
||||
#### 英文/数字字体:
|
||||
- **Roboto Condensed** - 类似 Swis721 风格
|
||||
- **Barlow Condensed** - 现代无衬线浓缩字体
|
||||
- **Oswald** - 窄体标题字体
|
||||
|
||||
#### 中文字体:
|
||||
- **Noto Sans SC** (已使用) - 黑体,适合正文
|
||||
- **Noto Serif SC** - 宋体,适合标题
|
||||
- **Source Han Sans CN** - 思源黑体(Adobe)
|
||||
|
||||
#### 获取方式:
|
||||
```bash
|
||||
# 通过 Google Fonts CDN
|
||||
https://fonts.google.com/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关法律条款
|
||||
|
||||
### GPL-3.0 项目字体使用
|
||||
- ✅ 可使用 SIL OFL 协议字体
|
||||
- ✅ 可使用 Apache License 字体
|
||||
- ⚠️ 商业字体需额外购买授权
|
||||
- ❌ 不可使用未授权商业字体
|
||||
|
||||
### 字体授权检查清单
|
||||
- [ ] 确认字体协议类型
|
||||
- [ ] 验证商用授权范围
|
||||
- [ ] 保存授权证明文件
|
||||
- [ ] 添加版权声明到项目
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关资源
|
||||
|
||||
- [Google Fonts](https://fonts.google.com/) - 免费开源字体库
|
||||
- [SIL Open Font License](https://scripts.sil.org/OFL)
|
||||
- [Adobe Fonts](https://fonts.adobe.com/) - 部分免费字体
|
||||
- [中文字体版权指南](https://www.zcool.com.cn/article/ZMTQzNjM2.html)
|
||||
|
||||
---
|
||||
|
||||
**审计人**: GitHub Copilot
|
||||
**项目**: License Manager Frontend
|
||||
**版本**: 1.0.0
|
||||
**协议**: GPL-3.0
|
||||
@@ -1,8 +1,8 @@
|
||||
# 软件授权管理平台多语言框架设计方案
|
||||
# 软件授权管理系统多语言框架设计方案
|
||||
|
||||
## 1. 需求分析
|
||||
|
||||
根据需求说明书,软件授权管理平台需要支持国际化,主要体现在:
|
||||
根据需求说明书,软件授权管理系统需要支持国际化,主要体现在:
|
||||
|
||||
- **用户界面多语言**:Web管理界面需要支持中文、英文、日文等多种语言
|
||||
- **错误信息多语言**:API接口返回的错误信息需要本地化
|
||||
|
||||
+15
-24
@@ -1,26 +1,17 @@
|
||||
<!--
|
||||
* @Author: 13895237362 [email protected]
|
||||
* @Date: 2025-09-24 10:31:33
|
||||
* @LastEditors: 13895237362 [email protected]
|
||||
* @LastEditTime: 2025-09-24 10:32:56
|
||||
* @FilePath: \frontend\index.html
|
||||
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes, minimum-scale=0.5, maximum-scale=3.0" />
|
||||
<title>授权管理系统</title>
|
||||
<!-- Noto Sans SC 本地字体预加载(优化加载速度) -->
|
||||
<!-- 注意: 字体文件需要下载到 src/assets/fonts/ 目录,参考 FONT_OPTIMIZATION.md -->
|
||||
<link rel="preload" href="/src/assets/fonts/noto-sans-sc-regular.woff2" as="font" type="font/woff2" crossorigin="anonymous">
|
||||
<link rel="preload" href="/src/assets/fonts/noto-sans-sc-medium.woff2" as="font" type="font/woff2" crossorigin="anonymous">
|
||||
<link rel="preload" href="/src/assets/fonts/noto-sans-sc-bold.woff2" as="font" type="font/woff2" crossorigin="anonymous">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, user-scalable=yes, minimum-scale=0.5, maximum-scale=3.0" />
|
||||
<title>授权管理系统</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Generated
+10
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@fontsource/noto-sans-sc": "^5.2.9",
|
||||
"axios": "^1.7.0",
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.10.5",
|
||||
@@ -603,6 +604,15 @@
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource/noto-sans-sc": {
|
||||
"version": "5.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/noto-sans-sc/-/noto-sans-sc-5.2.9.tgz",
|
||||
"integrity": "sha512-bTUIWGBgJDpwi5qAr+x0/lcgv80IHTB9vl6s2f6EymZEa7qYV99yNRBZuKFT+SYDKVunZrjCEhWtpxqmbXWl5Q==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@fontsource/noto-sans-sc": "^5.2.9",
|
||||
"axios": "^1.7.0",
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.10.5",
|
||||
|
||||
Generated
+3597
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,7 @@ import { errorCodeType } from './errorCodeType'
|
||||
import { generateAcceptLanguageHeader } from '@/utils/language'
|
||||
// 路由
|
||||
import router from '@/router'
|
||||
export const envUrl1 = import.meta.env.VITE_API_BASE_URL
|
||||
|
||||
const envUrl = import.meta.env.VITE_API_BASE_URL
|
||||
console.log('API Base URL:', envUrl)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import Axios from './https'
|
||||
|
||||
// 发票详情接口类型定义
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
invoice_no: string;
|
||||
applicant_name: string;
|
||||
applicant_phone: string;
|
||||
receiver_email: string;
|
||||
order_no: string;
|
||||
order_id: string;
|
||||
order_package_name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
status: 'pending' | 'success' | 'rejected';
|
||||
status_display: string;
|
||||
amount: number;
|
||||
title: string;
|
||||
taxpayer_id?: string;
|
||||
content?: string;
|
||||
remark?: string;
|
||||
invoice_type: string;
|
||||
invoice_type_display: string;
|
||||
// 审核/上传相关
|
||||
reject_reason?: string;
|
||||
rejected_at?: string;
|
||||
rejected_by?: string;
|
||||
uploaded_at?: string;
|
||||
uploaded_by?: string;
|
||||
uploader_name?: string;
|
||||
invoice_file_url?: string;
|
||||
}
|
||||
|
||||
// 发票查询请求参数
|
||||
export interface InvoiceQueryRequest {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
// 通用响应接口
|
||||
export interface ApiResponse<T = any> {
|
||||
code: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
// 分页数据响应接口
|
||||
export interface InvoiceQueryResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
data: {
|
||||
invoices: Invoice[];
|
||||
total_count: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages?: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// 发票统计数据接口响应
|
||||
export interface InvoiceSummaryResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
data: {
|
||||
total_count: number;
|
||||
pending_count: number;
|
||||
completed_count: number;
|
||||
rejected_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 查询发票列表
|
||||
*/
|
||||
export function getInvoices(params?: InvoiceQueryRequest): Promise<InvoiceQueryResponse> {
|
||||
return Axios.get('/api/v1/invoices', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取发票统计数据
|
||||
*/
|
||||
export function getInvoiceSummary(): Promise<InvoiceSummaryResponse> {
|
||||
return Axios.get('/api/v1/invoices/summary')
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取发票详情
|
||||
*/
|
||||
export function getInvoiceDetail(id: string): Promise<ApiResponse<Invoice>> {
|
||||
return Axios.get(`/api/v1/invoices/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传发票文件
|
||||
*/
|
||||
export function uploadInvoice(data: FormData): Promise<ApiResponse> {
|
||||
return Axios.post('/api/v1/invoices/upload', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 驳回发票申请
|
||||
*/
|
||||
export function rejectInvoice(id: string, data: any): Promise<ApiResponse> {
|
||||
return Axios.post(`/api/v1/invoices/${id}/reject`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员发票开票
|
||||
*/
|
||||
export function issueInvoice(id: string, data: { invoice_file_url: string, issued_at: string }): Promise<ApiResponse> {
|
||||
return Axios.post(`/api/v1/invoices/${id}/issue`, data)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import Axios from './https'
|
||||
|
||||
// 企业线索接口类型定义
|
||||
export interface Lead {
|
||||
id: string;
|
||||
lead_no: string;
|
||||
company_name: string;
|
||||
contact_name: string;
|
||||
contact_phone: string;
|
||||
contact_email: string;
|
||||
requirement: string;
|
||||
extra_info: string;
|
||||
status: 'pending' | 'contacting' | 'completed' | 'rejected';
|
||||
follow_up_date: string | null;
|
||||
follow_up_record: string;
|
||||
internal_note: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 查询请求参数
|
||||
export interface LeadQueryRequest {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// 通用响应接口
|
||||
export interface ApiResponse<T = any> {
|
||||
code: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
// 分页数据响应接口
|
||||
export interface LeadQueryResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
data: {
|
||||
leads: Lead[];
|
||||
total_count: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取企业线索列表
|
||||
*/
|
||||
export function getLeads(params?: LeadQueryRequest): Promise<LeadQueryResponse> {
|
||||
return Axios.get('/api/leads', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取企业线索统计数据 (假设有这个接口,如果没有则前端计算或者等后续补充)
|
||||
*/
|
||||
export function getLeadSummary(): Promise<ApiResponse<any>> {
|
||||
return Axios.get('/api/leads/summary')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取企业线索详情
|
||||
*/
|
||||
export function getLeadDetail(id: string | number): Promise<ApiResponse<Lead>> {
|
||||
return Axios.get(`/api/leads/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新企业线索
|
||||
*/
|
||||
export function updateLead(id: string | number, data: Partial<Lead>): Promise<ApiResponse<Lead>> {
|
||||
return Axios.put(`/api/leads/${id}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除企业线索
|
||||
*/
|
||||
export function deleteLead(id: string | number): Promise<ApiResponse<any>> {
|
||||
return Axios.delete(`/api/leads/${id}`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import Axios from './https'
|
||||
|
||||
// 套餐接口类型定义
|
||||
export interface Package {
|
||||
id: string | number;
|
||||
name: string;
|
||||
price: number;
|
||||
price_description?: string;
|
||||
duration_description: string;
|
||||
status: number; // 1: enabled, 2: disabled
|
||||
type: string;
|
||||
sort_order: number;
|
||||
description?: string;
|
||||
features?: any;
|
||||
remark?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 套餐列表响应定义
|
||||
export interface PackageListResponse {
|
||||
packages: Package[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
// 通用响应接口
|
||||
export interface ApiResponse<T = any> {
|
||||
code: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取套餐列表
|
||||
*/
|
||||
export function getPackages(): Promise<ApiResponse<PackageListResponse>> {
|
||||
return Axios.get('/api/packages')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取套餐详情
|
||||
*/
|
||||
export function getPackageDetail(id: string | number): Promise<ApiResponse<Package>> {
|
||||
return Axios.get(`/api/packages/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新套餐
|
||||
*/
|
||||
export function updatePackage(id: string | number, data: Partial<Package>): Promise<ApiResponse<Package>> {
|
||||
return Axios.put(`/api/packages/${id}`, data)
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user