4453 lines
119 KiB
Markdown
4453 lines
119 KiB
Markdown
|
|
# 实施计划 (IMPLEMENTATION_PLAN.md)
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
本文档详细描述用户管理系统的实施计划,确保100%还原PRD、数据模型、技术架构、API、安全设计和部署文档的所有设计。
|
|||
|
|
|
|||
|
|
**目标**:
|
|||
|
|
- 100%还原PRD所有功能需求
|
|||
|
|
- 100%还原数据模型设计
|
|||
|
|
- 100%还原技术架构设计
|
|||
|
|
- 100%还原API接口设计
|
|||
|
|
- 100%还原安全设计
|
|||
|
|
- 100%还原部署和运维方案
|
|||
|
|
|
|||
|
|
**技术指标**:
|
|||
|
|
- 支持10亿用户规模
|
|||
|
|
- 支持10万级并发访问
|
|||
|
|
- P50响应时间 < 100ms
|
|||
|
|
- P99响应时间 < 500ms
|
|||
|
|
- 系统可用性 99.99%
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 实施阶段划分
|
|||
|
|
|
|||
|
|
### 阶段1:项目初始化与环境搭建(第1-2周)
|
|||
|
|
|
|||
|
|
**目标**:完成项目基础架构搭建
|
|||
|
|
|
|||
|
|
#### 1.1 技术栈确定
|
|||
|
|
|
|||
|
|
| 技术领域 | 选择 | 版本 | 说明 |
|
|||
|
|
|---------|------|------|------|
|
|||
|
|
| 开发语言 | Go | 1.23+ | 高性能、并发能力强 |
|
|||
|
|
| Web框架 | Gin | 1.10+ | 轻量级、高性能 |
|
|||
|
|
| ORM | GORM | 1.25+ | 功能强大、支持多数据库 |
|
|||
|
|
| 数据库 | SQLite(默认) | 3.40+ | 单机部署默认 |
|
|||
|
|
| 数据库 | PostgreSQL | 14+ | 生产环境可选 |
|
|||
|
|
| 数据库 | MySQL | 8.0+ | 生产环境可选 |
|
|||
|
|
| 缓存 | Redis | 7.0+ | 可选,用于L2缓存 |
|
|||
|
|
| 配置管理 | Viper | 1.18+ | 支持多格式配置 |
|
|||
|
|
| 日志 | Zap | 1.27+ | 高性能日志库 |
|
|||
|
|
| 监控 | Prometheus | 2.50+ | 指标收集 |
|
|||
|
|
| 链路追踪 | OpenTelemetry | 1.20+ | 分布式追踪 |
|
|||
|
|
| 测试框架 | Testify | 1.9+ | 断言和mock |
|
|||
|
|
| API文档 | Swagger | 0.30+ | 自动生成API文档 |
|
|||
|
|
|
|||
|
|
#### 1.2 项目结构初始化
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
user-management-system/
|
|||
|
|
├── cmd/ # 主程序入口
|
|||
|
|
│ └── server/
|
|||
|
|
│ └── main.go # 服务启动入口
|
|||
|
|
├── internal/ # 内部应用代码
|
|||
|
|
│ ├── api/ # API层
|
|||
|
|
│ │ ├── handler/ # HTTP处理器
|
|||
|
|
│ │ ├── middleware/ # 中间件
|
|||
|
|
│ │ └── router/ # 路由定义
|
|||
|
|
│ ├── service/ # 业务逻辑层
|
|||
|
|
│ │ ├── user.go # 用户服务
|
|||
|
|
│ │ ├── role.go # 角色服务
|
|||
|
|
│ │ ├── permission.go # 权限服务
|
|||
|
|
│ │ ├── auth.go # 认证服务
|
|||
|
|
│ │ └── device.go # 设备服务
|
|||
|
|
│ ├── repository/ # 数据访问层
|
|||
|
|
│ │ ├── user.go
|
|||
|
|
│ │ ├── role.go
|
|||
|
|
│ │ ├── permission.go
|
|||
|
|
│ │ └── device.go
|
|||
|
|
│ ├── domain/ # 领域模型
|
|||
|
|
│ │ ├── user.go
|
|||
|
|
│ │ ├── role.go
|
|||
|
|
│ │ ├── permission.go
|
|||
|
|
│ │ └── device.go
|
|||
|
|
│ ├── cache/ # 缓存层
|
|||
|
|
│ │ ├── l1.go # 本地缓存
|
|||
|
|
│ │ ├── l2.go # Redis缓存
|
|||
|
|
│ │ └── cache_manager.go # 缓存管理器
|
|||
|
|
│ ├── config/ # 配置管理
|
|||
|
|
│ │ └── config.go
|
|||
|
|
│ ├── database/ # 数据库管理
|
|||
|
|
│ │ ├── db.go # 数据库连接
|
|||
|
|
│ │ ├── migration/ # 数据库迁移
|
|||
|
|
│ │ └── transaction.go # 事务管理
|
|||
|
|
│ ├── auth/ # 认证授权
|
|||
|
|
│ │ ├── jwt.go # JWT工具
|
|||
|
|
│ │ ├── password.go # 密码工具
|
|||
|
|
│ │ └── oauth2.go # OAuth2工具
|
|||
|
|
│ ├── security/ # 安全组件
|
|||
|
|
│ │ ├── encryption.go # 加密工具
|
|||
|
|
│ │ ├── ratelimit.go # 限流工具
|
|||
|
|
│ │ └── validator.go # 验证工具
|
|||
|
|
│ ├── monitoring/ # 监控组件
|
|||
|
|
│ │ ├── metrics.go # Prometheus指标
|
|||
|
|
│ │ ├── tracing.go # 链路追踪
|
|||
|
|
│ │ └── health.go # 健康检查
|
|||
|
|
│ └── utils/ # 工具函数
|
|||
|
|
│ ├── logger.go
|
|||
|
|
│ ├── validator.go
|
|||
|
|
│ └── utils.go
|
|||
|
|
├── pkg/ # 公共包
|
|||
|
|
│ ├── errors/ # 错误定义
|
|||
|
|
│ ├── response/ # 统一响应
|
|||
|
|
│ └── constants/ # 常量定义
|
|||
|
|
├── configs/ # 配置文件
|
|||
|
|
│ ├── config.yaml # 默认配置
|
|||
|
|
│ ├── config.dev.yaml # 开发环境
|
|||
|
|
│ ├── config.prod.yaml # 生产环境
|
|||
|
|
│ └── config.sqlite.yaml # SQLite配置
|
|||
|
|
├── scripts/ # 脚本文件
|
|||
|
|
│ ├── install.sh # 安装脚本
|
|||
|
|
│ ├── start.sh # 启动脚本
|
|||
|
|
│ ├── stop.sh # 停止脚本
|
|||
|
|
│ ├── restart.sh # 重启脚本
|
|||
|
|
│ ├── health-check.sh # 健康检查
|
|||
|
|
│ ├── backup.sh # 备份脚本
|
|||
|
|
│ └── self-check.sh # 自检脚本
|
|||
|
|
├── deployments/ # 部署文件
|
|||
|
|
│ ├── docker/
|
|||
|
|
│ │ ├── Dockerfile
|
|||
|
|
│ │ └── docker-compose.yml
|
|||
|
|
│ ├── kubernetes/
|
|||
|
|
│ │ ├── deployment.yaml
|
|||
|
|
│ │ ├── service.yaml
|
|||
|
|
│ │ ├── configmap.yaml
|
|||
|
|
│ │ └── ingress.yaml
|
|||
|
|
│ └── helm/
|
|||
|
|
│ └── user-management/
|
|||
|
|
│ ├── Chart.yaml
|
|||
|
|
│ ├── values.yaml
|
|||
|
|
│ └── templates/
|
|||
|
|
├── migrations/ # 数据库迁移文件
|
|||
|
|
│ ├── sqlite/
|
|||
|
|
│ │ └── V1__init.sql
|
|||
|
|
│ ├── postgresql/
|
|||
|
|
│ │ └── V1__init.sql
|
|||
|
|
│ └── mysql/
|
|||
|
|
│ └── V1__init.sql
|
|||
|
|
├── tests/ # 测试文件
|
|||
|
|
│ ├── unit/ # 单元测试
|
|||
|
|
│ ├── integration/ # 集成测试
|
|||
|
|
│ └── e2e/ # 端到端测试
|
|||
|
|
├── docs/ # 文档
|
|||
|
|
│ ├── README.md
|
|||
|
|
│ ├── PRD.md
|
|||
|
|
│ ├── DATA_MODEL.md
|
|||
|
|
│ ├── ARCHITECTURE.md
|
|||
|
|
│ ├── API.md
|
|||
|
|
│ ├── SECURITY.md
|
|||
|
|
│ ├── DEPLOYMENT.md
|
|||
|
|
│ └── IMPLEMENTATION_PLAN.md
|
|||
|
|
├── data/ # 数据目录(SQLite)
|
|||
|
|
│ └── .gitkeep
|
|||
|
|
├── logs/ # 日志目录
|
|||
|
|
│ └── .gitkeep
|
|||
|
|
├── .gitignore
|
|||
|
|
├── go.mod
|
|||
|
|
├── go.sum
|
|||
|
|
├── Makefile
|
|||
|
|
├── README.md
|
|||
|
|
└── LICENSE
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 1.3 初始化任务清单
|
|||
|
|
|
|||
|
|
- [ ] 创建Go模块:`go mod init github.com/user-management-system`
|
|||
|
|
- [ ] 安装依赖:`go get -u github.com/gin-gonic/gin`
|
|||
|
|
- [ ] 安装依赖:`go get -u gorm.io/gorm`
|
|||
|
|
- [ ] 安装依赖:`go get -u gorm.io/driver/sqlite`
|
|||
|
|
- [ ] 安装依赖:`go get -u gorm.io/driver/postgres`
|
|||
|
|
- [ ] 安装依赖:`go get -u gorm.io/driver/mysql`
|
|||
|
|
- [ ] 安装依赖:`go get -u github.com/redis/go-redis/v9`
|
|||
|
|
- [ ] 安装依赖:`go get -u github.com/spf13/viper`
|
|||
|
|
- [ ] 安装依赖:`go get -u go.uber.org/zap`
|
|||
|
|
- [ ] 安装依赖:`go get -u github.com/prometheus/client_golang`
|
|||
|
|
- [ ] 安装依赖:`go get -u go.opentelemetry.io/otel`
|
|||
|
|
- [ ] 安装依赖:`go get -u github.com/swaggo/gin-swagger`
|
|||
|
|
- [ ] 安装依赖:`go get -u github.com/stretchr/testify`
|
|||
|
|
- [ ] 创建目录结构
|
|||
|
|
- [ ] 配置`.gitignore`
|
|||
|
|
- [ ] 创建`Makefile`
|
|||
|
|
|
|||
|
|
#### 1.4 配置文件示例
|
|||
|
|
|
|||
|
|
**config.yaml**
|
|||
|
|
```yaml
|
|||
|
|
server:
|
|||
|
|
port: 8080
|
|||
|
|
mode: release # debug, release
|
|||
|
|
read_timeout: 30s
|
|||
|
|
write_timeout: 30s
|
|||
|
|
|
|||
|
|
database:
|
|||
|
|
type: sqlite # sqlite, postgresql, mysql
|
|||
|
|
sqlite:
|
|||
|
|
path: ./data/user_management.db
|
|||
|
|
postgresql:
|
|||
|
|
host: localhost
|
|||
|
|
port: 5432
|
|||
|
|
database: user_management
|
|||
|
|
username: postgres
|
|||
|
|
password: password
|
|||
|
|
ssl_mode: disable
|
|||
|
|
max_open_conns: 100
|
|||
|
|
max_idle_conns: 10
|
|||
|
|
mysql:
|
|||
|
|
host: localhost
|
|||
|
|
port: 3306
|
|||
|
|
database: user_management
|
|||
|
|
username: root
|
|||
|
|
password: password
|
|||
|
|
charset: utf8mb4
|
|||
|
|
max_open_conns: 100
|
|||
|
|
max_idle_conns: 10
|
|||
|
|
|
|||
|
|
cache:
|
|||
|
|
l1:
|
|||
|
|
enabled: true
|
|||
|
|
max_size: 10000
|
|||
|
|
ttl: 5m
|
|||
|
|
l2:
|
|||
|
|
enabled: true
|
|||
|
|
type: redis
|
|||
|
|
redis:
|
|||
|
|
addr: localhost:6379
|
|||
|
|
password: ""
|
|||
|
|
db: 0
|
|||
|
|
pool_size: 50
|
|||
|
|
ttl: 30m
|
|||
|
|
|
|||
|
|
redis:
|
|||
|
|
enabled: true
|
|||
|
|
addr: localhost:6379
|
|||
|
|
password: ""
|
|||
|
|
db: 0
|
|||
|
|
|
|||
|
|
jwt:
|
|||
|
|
secret: your-secret-key-change-in-production
|
|||
|
|
access_token_expire: 2h
|
|||
|
|
refresh_token_expire: 7d
|
|||
|
|
|
|||
|
|
security:
|
|||
|
|
password_min_length: 8
|
|||
|
|
password_require_special: true
|
|||
|
|
password_require_number: true
|
|||
|
|
login_max_attempts: 5
|
|||
|
|
login_lock_duration: 30m
|
|||
|
|
|
|||
|
|
ratelimit:
|
|||
|
|
enabled: true
|
|||
|
|
login:
|
|||
|
|
enabled: true
|
|||
|
|
algorithm: token_bucket
|
|||
|
|
capacity: 5
|
|||
|
|
rate: 1 # 1 token per minute
|
|||
|
|
window: 1m
|
|||
|
|
register:
|
|||
|
|
enabled: true
|
|||
|
|
algorithm: leaky_bucket
|
|||
|
|
capacity: 3
|
|||
|
|
rate: 1
|
|||
|
|
window: 1h
|
|||
|
|
api:
|
|||
|
|
enabled: true
|
|||
|
|
algorithm: sliding_window
|
|||
|
|
capacity: 1000
|
|||
|
|
window: 1m
|
|||
|
|
|
|||
|
|
monitoring:
|
|||
|
|
prometheus:
|
|||
|
|
enabled: true
|
|||
|
|
path: /metrics
|
|||
|
|
tracing:
|
|||
|
|
enabled: true
|
|||
|
|
endpoint: http://localhost:4318
|
|||
|
|
service_name: user-management-system
|
|||
|
|
|
|||
|
|
logging:
|
|||
|
|
level: info # debug, info, warn, error
|
|||
|
|
format: json # json, text
|
|||
|
|
output:
|
|||
|
|
- stdout
|
|||
|
|
- ./logs/app.log
|
|||
|
|
rotation:
|
|||
|
|
max_size: 100 # MB
|
|||
|
|
max_age: 30 # days
|
|||
|
|
max_backups: 10
|
|||
|
|
|
|||
|
|
admin:
|
|||
|
|
username: admin
|
|||
|
|
password: <set-via-UMS_ADMIN_PASSWORD> # 历史方案,当前仓库已改为显式初始化
|
|||
|
|
email: admin@example.com
|
|||
|
|
|
|||
|
|
cors:
|
|||
|
|
enabled: true
|
|||
|
|
allowed_origins:
|
|||
|
|
- "*"
|
|||
|
|
allowed_methods:
|
|||
|
|
- GET
|
|||
|
|
- POST
|
|||
|
|
- PUT
|
|||
|
|
- DELETE
|
|||
|
|
- OPTIONS
|
|||
|
|
allowed_headers:
|
|||
|
|
- "*"
|
|||
|
|
max_age: 3600
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Makefile**
|
|||
|
|
```makefile
|
|||
|
|
.PHONY: help build run test clean install deps migrate-up migrate-down backup lint fmt vet
|
|||
|
|
|
|||
|
|
help: ## 显示帮助信息
|
|||
|
|
@echo "可用命令:"
|
|||
|
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
|||
|
|
|
|||
|
|
deps: ## 安装依赖
|
|||
|
|
@echo "安装依赖..."
|
|||
|
|
go mod download
|
|||
|
|
go mod tidy
|
|||
|
|
|
|||
|
|
build: ## 构建应用
|
|||
|
|
@echo "构建应用..."
|
|||
|
|
go build -o bin/user-management-system cmd/server/main.go
|
|||
|
|
|
|||
|
|
run: ## 运行应用
|
|||
|
|
@echo "运行应用..."
|
|||
|
|
go run cmd/server/main.go
|
|||
|
|
|
|||
|
|
test: ## 运行测试
|
|||
|
|
@echo "运行测试..."
|
|||
|
|
go test -v ./... -cover
|
|||
|
|
|
|||
|
|
test-coverage: ## 运行测试并生成覆盖率报告
|
|||
|
|
@echo "运行测试并生成覆盖率..."
|
|||
|
|
go test -v ./... -coverprofile=coverage.out
|
|||
|
|
go tool cover -html=coverage.out -o coverage.html
|
|||
|
|
|
|||
|
|
lint: ## 运行代码检查
|
|||
|
|
@echo "运行代码检查..."
|
|||
|
|
golangci-lint run
|
|||
|
|
|
|||
|
|
fmt: ## 格式化代码
|
|||
|
|
@echo "格式化代码..."
|
|||
|
|
go fmt ./...
|
|||
|
|
|
|||
|
|
vet: ## 运行go vet
|
|||
|
|
@echo "运行go vet..."
|
|||
|
|
go vet ./...
|
|||
|
|
|
|||
|
|
migrate-up: ## 执行数据库迁移(向上)
|
|||
|
|
@echo "执行数据库迁移..."
|
|||
|
|
go run cmd/migrate/main.go up
|
|||
|
|
|
|||
|
|
migrate-down: ## 执行数据库迁移(向下)
|
|||
|
|
@echo "回滚数据库迁移..."
|
|||
|
|
go run cmd/migrate/main.go down
|
|||
|
|
|
|||
|
|
backup: ## 备份数据库
|
|||
|
|
@echo "备份数据库..."
|
|||
|
|
./scripts/backup.sh
|
|||
|
|
|
|||
|
|
clean: ## 清理构建文件
|
|||
|
|
@echo "清理构建文件..."
|
|||
|
|
rm -rf bin/
|
|||
|
|
rm -f coverage.out coverage.html
|
|||
|
|
|
|||
|
|
install: ## 安装脚本权限
|
|||
|
|
@echo "设置脚本权限..."
|
|||
|
|
chmod +x scripts/*.sh
|
|||
|
|
|
|||
|
|
swagger: ## 生成Swagger文档
|
|||
|
|
@echo "生成Swagger文档..."
|
|||
|
|
swag init -g cmd/server/main.go
|
|||
|
|
|
|||
|
|
docker-build: ## 构建Docker镜像
|
|||
|
|
@echo "构建Docker镜像..."
|
|||
|
|
docker build -t user-management-system:latest .
|
|||
|
|
|
|||
|
|
docker-run: ## 运行Docker容器
|
|||
|
|
@echo "运行Docker容器..."
|
|||
|
|
docker-compose -f deployments/docker/docker-compose.yml up -d
|
|||
|
|
|
|||
|
|
docker-stop: ## 停止Docker容器
|
|||
|
|
@echo "停止Docker容器..."
|
|||
|
|
docker-compose -f deployments/docker/docker-compose.yml down
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 阶段2:核心数据模型实现(第3-4周)
|
|||
|
|
|
|||
|
|
**目标**:完成所有数据模型的定义和数据库迁移
|
|||
|
|
|
|||
|
|
#### 2.1 数据模型实现
|
|||
|
|
|
|||
|
|
**2.1.1 用户模型 (users)**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/domain/user.go
|
|||
|
|
package domain
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"time"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Gender 性别
|
|||
|
|
type Gender int
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
GenderUnknown Gender = iota // 未知
|
|||
|
|
GenderMale // 男
|
|||
|
|
GenderFemale // 女
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// UserStatus 用户状态
|
|||
|
|
type UserStatus int
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
UserStatusInactive UserStatus = 0 // 未激活
|
|||
|
|
UserStatusActive UserStatus = 1 // 已激活
|
|||
|
|
UserStatusLocked UserStatus = 2 // 已锁定
|
|||
|
|
UserStatusDisabled UserStatus = 3 // 已禁用
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// User 用户模型
|
|||
|
|
type User struct {
|
|||
|
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
|||
|
|
Username string `gorm:"type:varchar(50);uniqueIndex;not null" json:"username"`
|
|||
|
|
Email string `gorm:"type:varchar(100);uniqueIndex" json:"email"`
|
|||
|
|
Phone string `gorm:"type:varchar(20);uniqueIndex" json:"phone"`
|
|||
|
|
Nickname string `gorm:"type:varchar(50)" json:"nickname"`
|
|||
|
|
Avatar string `gorm:"type:varchar(255)" json:"avatar"`
|
|||
|
|
Password string `gorm:"type:varchar(255)" json:"-"`
|
|||
|
|
Gender Gender `gorm:"type:int;default:0" json:"gender"`
|
|||
|
|
Birthday *time.Time `gorm:"type:date" json:"birthday,omitempty"`
|
|||
|
|
Region string `gorm:"type:varchar(50)" json:"region"`
|
|||
|
|
Bio string `gorm:"type:varchar(500)" json:"bio"`
|
|||
|
|
Status UserStatus `gorm:"type:int;default:0;index" json:"status"`
|
|||
|
|
LastLoginTime *time.Time `json:"last_login_time,omitempty"`
|
|||
|
|
LastLoginIP string `gorm:"type:varchar(50)" json:"last_login_ip"`
|
|||
|
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|||
|
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
|||
|
|
DeletedAt *time.Time `gorm:"index" json:"deleted_at,omitempty"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TableName 指定表名
|
|||
|
|
func (User) TableName() string {
|
|||
|
|
return "users"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**2.1.2 角色模型 (roles)**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/domain/role.go
|
|||
|
|
package domain
|
|||
|
|
|
|||
|
|
import "time"
|
|||
|
|
|
|||
|
|
// RoleStatus 角色状态
|
|||
|
|
type RoleStatus int
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
RoleStatusDisabled RoleStatus = 0 // 禁用
|
|||
|
|
RoleStatusEnabled RoleStatus = 1 // 启用
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Role 角色模型
|
|||
|
|
type Role struct {
|
|||
|
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
|||
|
|
Name string `gorm:"type:varchar(50);uniqueIndex;not null" json:"name"`
|
|||
|
|
Code string `gorm:"type:varchar(50);uniqueIndex;not null" json:"code"`
|
|||
|
|
Description string `gorm:"type:varchar(200)" json:"description"`
|
|||
|
|
ParentID *int64 `gorm:"index" json:"parent_id,omitempty"`
|
|||
|
|
Level int `gorm:"default:1;index" json:"level"`
|
|||
|
|
IsSystem bool `gorm:"default:false" json:"is_system"` // 是否系统角色
|
|||
|
|
IsDefault bool `gorm:"default:false;index" json:"is_default"` // 是否默认角色
|
|||
|
|
Status RoleStatus `gorm:"type:int;default:1" json:"status"`
|
|||
|
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|||
|
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TableName 指定表名
|
|||
|
|
func (Role) TableName() string {
|
|||
|
|
return "roles"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// PredefinedRoles 预定义角色
|
|||
|
|
var PredefinedRoles = []Role{
|
|||
|
|
{
|
|||
|
|
ID: 1,
|
|||
|
|
Name: "管理员",
|
|||
|
|
Code: "admin",
|
|||
|
|
Description: "系统管理员角色,拥有所有权限",
|
|||
|
|
ParentID: nil,
|
|||
|
|
Level: 1,
|
|||
|
|
IsSystem: true,
|
|||
|
|
IsDefault: false,
|
|||
|
|
Status: RoleStatusEnabled,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
ID: 2,
|
|||
|
|
Name: "普通用户",
|
|||
|
|
Code: "user",
|
|||
|
|
Description: "普通用户角色,基本权限",
|
|||
|
|
ParentID: nil,
|
|||
|
|
Level: 1,
|
|||
|
|
IsSystem: true,
|
|||
|
|
IsDefault: true,
|
|||
|
|
Status: RoleStatusEnabled,
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**2.1.3 权限模型 (permissions)**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/domain/permission.go
|
|||
|
|
package domain
|
|||
|
|
|
|||
|
|
import "time"
|
|||
|
|
|
|||
|
|
// PermissionType 权限类型
|
|||
|
|
type PermissionType int
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
PermissionTypeMenu PermissionType = iota // 菜单
|
|||
|
|
PermissionTypeButton // 按钮
|
|||
|
|
PermissionTypeAPI // 接口
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// PermissionStatus 权限状态
|
|||
|
|
type PermissionStatus int
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
PermissionStatusDisabled PermissionStatus = 0 // 禁用
|
|||
|
|
PermissionStatusEnabled PermissionStatus = 1 // 启用
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Permission 权限模型
|
|||
|
|
type Permission struct {
|
|||
|
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
|||
|
|
Name string `gorm:"type:varchar(50);not null" json:"name"`
|
|||
|
|
Code string `gorm:"type:varchar(100);uniqueIndex;not null" json:"code"`
|
|||
|
|
Type PermissionType `gorm:"type:int;not null" json:"type"`
|
|||
|
|
Description string `gorm:"type:varchar(200)" json:"description"`
|
|||
|
|
ParentID *int64 `gorm:"index" json:"parent_id,omitempty"`
|
|||
|
|
Level int `gorm:"default:1" json:"level"`
|
|||
|
|
Path string `gorm:"type:varchar(200)" json:"path,omitempty"`
|
|||
|
|
Method string `gorm:"type:varchar(10)" json:"method,omitempty"`
|
|||
|
|
Sort int `gorm:"default:0" json:"sort"`
|
|||
|
|
Icon string `gorm:"type:varchar(50)" json:"icon,omitempty"`
|
|||
|
|
Status PermissionStatus `gorm:"type:int;default:1" json:"status"`
|
|||
|
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|||
|
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TableName 指定表名
|
|||
|
|
func (Permission) TableName() string {
|
|||
|
|
return "permissions"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**2.1.4 其他核心模型**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/domain/user_role.go
|
|||
|
|
package domain
|
|||
|
|
|
|||
|
|
import "time"
|
|||
|
|
|
|||
|
|
// UserRole 用户-角色关联
|
|||
|
|
type UserRole struct {
|
|||
|
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
|||
|
|
UserID int64 `gorm:"not null;index:idx_user_role;index:idx_user" json:"user_id"`
|
|||
|
|
RoleID int64 `gorm:"not null;index:idx_user_role;index:idx_role" json:"role_id"`
|
|||
|
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TableName 指定表名
|
|||
|
|
func (UserRole) TableName() string {
|
|||
|
|
return "user_roles"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// internal/domain/role_permission.go
|
|||
|
|
package domain
|
|||
|
|
|
|||
|
|
import "time"
|
|||
|
|
|
|||
|
|
// RolePermission 角色-权限关联
|
|||
|
|
type RolePermission struct {
|
|||
|
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
|||
|
|
RoleID int64 `gorm:"not null;index:idx_role_perm;index:idx_role" json:"role_id"`
|
|||
|
|
PermissionID int64 `gorm:"not null;index:idx_role_perm;index:idx_perm" json:"permission_id"`
|
|||
|
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TableName 指定表名
|
|||
|
|
func (RolePermission) TableName() string {
|
|||
|
|
return "role_permissions"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// internal/domain/device.go
|
|||
|
|
package domain
|
|||
|
|
|
|||
|
|
import "time"
|
|||
|
|
|
|||
|
|
// DeviceType 设备类型
|
|||
|
|
type DeviceType int
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
DeviceTypeUnknown DeviceType = iota
|
|||
|
|
DeviceTypeWeb
|
|||
|
|
DeviceTypeMobile
|
|||
|
|
DeviceTypeDesktop
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// DeviceStatus 设备状态
|
|||
|
|
type DeviceStatus int
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
DeviceStatusInactive DeviceStatus = 0
|
|||
|
|
DeviceStatusActive DeviceStatus = 1
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Device 设备模型
|
|||
|
|
type Device struct {
|
|||
|
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
|||
|
|
UserID int64 `gorm:"not null;index" json:"user_id"`
|
|||
|
|
DeviceID string `gorm:"type:varchar(100);uniqueIndex;not null" json:"device_id"`
|
|||
|
|
DeviceName string `gorm:"type:varchar(100)" json:"device_name"`
|
|||
|
|
DeviceType DeviceType `gorm:"type:int;default:0" json:"device_type"`
|
|||
|
|
DeviceOS string `gorm:"type:varchar(50)" json:"device_os"`
|
|||
|
|
DeviceBrowser string `gorm:"type:varchar(50)" json:"device_browser"`
|
|||
|
|
IP string `gorm:"type:varchar(50)" json:"ip"`
|
|||
|
|
Location string `gorm:"type:varchar(100)" json:"location"`
|
|||
|
|
Status DeviceStatus `gorm:"type:int;default:1" json:"status"`
|
|||
|
|
LastActiveTime time.Time `json:"last_active_time"`
|
|||
|
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
|||
|
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TableName 指定表名
|
|||
|
|
func (Device) TableName() string {
|
|||
|
|
return "devices"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 2.2 数据库迁移脚本
|
|||
|
|
|
|||
|
|
**migrations/sqlite/V1__init.sql**
|
|||
|
|
```sql
|
|||
|
|
-- 创建用户表
|
|||
|
|
CREATE TABLE users (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
username VARCHAR(50) UNIQUE NOT NULL,
|
|||
|
|
email VARCHAR(100) UNIQUE,
|
|||
|
|
phone VARCHAR(20) UNIQUE,
|
|||
|
|
nickname VARCHAR(50),
|
|||
|
|
avatar VARCHAR(255),
|
|||
|
|
password VARCHAR(255),
|
|||
|
|
gender INTEGER DEFAULT 0,
|
|||
|
|
birthday DATE,
|
|||
|
|
region VARCHAR(50),
|
|||
|
|
bio VARCHAR(500),
|
|||
|
|
status INTEGER DEFAULT 0,
|
|||
|
|
last_login_time DATETIME,
|
|||
|
|
last_login_ip VARCHAR(50),
|
|||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
deleted_at DATETIME
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- 创建角色表
|
|||
|
|
CREATE TABLE roles (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
name VARCHAR(50) UNIQUE NOT NULL,
|
|||
|
|
code VARCHAR(50) UNIQUE NOT NULL,
|
|||
|
|
description VARCHAR(200),
|
|||
|
|
parent_id INTEGER,
|
|||
|
|
level INTEGER DEFAULT 1,
|
|||
|
|
is_system INTEGER DEFAULT 0,
|
|||
|
|
is_default INTEGER DEFAULT 0,
|
|||
|
|
status INTEGER DEFAULT 1,
|
|||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
FOREIGN KEY (parent_id) REFERENCES roles(id)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- 创建权限表
|
|||
|
|
CREATE TABLE permissions (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
name VARCHAR(50) NOT NULL,
|
|||
|
|
code VARCHAR(100) UNIQUE NOT NULL,
|
|||
|
|
type INTEGER NOT NULL,
|
|||
|
|
description VARCHAR(200),
|
|||
|
|
parent_id INTEGER,
|
|||
|
|
level INTEGER DEFAULT 1,
|
|||
|
|
path VARCHAR(200),
|
|||
|
|
method VARCHAR(10),
|
|||
|
|
sort INTEGER DEFAULT 0,
|
|||
|
|
icon VARCHAR(50),
|
|||
|
|
status INTEGER DEFAULT 1,
|
|||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
FOREIGN KEY (parent_id) REFERENCES permissions(id)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- 创建用户-角色关联表
|
|||
|
|
CREATE TABLE user_roles (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
user_id INTEGER NOT NULL,
|
|||
|
|
role_id INTEGER NOT NULL,
|
|||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
|||
|
|
FOREIGN KEY (role_id) REFERENCES roles(id),
|
|||
|
|
UNIQUE(user_id, role_id)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- 创建角色-权限关联表
|
|||
|
|
CREATE TABLE role_permissions (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
role_id INTEGER NOT NULL,
|
|||
|
|
permission_id INTEGER NOT NULL,
|
|||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
FOREIGN KEY (role_id) REFERENCES roles(id),
|
|||
|
|
FOREIGN KEY (permission_id) REFERENCES permissions(id),
|
|||
|
|
UNIQUE(role_id, permission_id)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- 创建设备表
|
|||
|
|
CREATE TABLE devices (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
user_id INTEGER NOT NULL,
|
|||
|
|
device_id VARCHAR(100) UNIQUE NOT NULL,
|
|||
|
|
device_name VARCHAR(100),
|
|||
|
|
device_type INTEGER DEFAULT 0,
|
|||
|
|
device_os VARCHAR(50),
|
|||
|
|
device_browser VARCHAR(50),
|
|||
|
|
ip VARCHAR(50),
|
|||
|
|
location VARCHAR(100),
|
|||
|
|
status INTEGER DEFAULT 1,
|
|||
|
|
last_active_time DATETIME,
|
|||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- 创建登录日志表
|
|||
|
|
CREATE TABLE login_logs (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
user_id INTEGER,
|
|||
|
|
login_type INTEGER NOT NULL,
|
|||
|
|
device_id VARCHAR(100),
|
|||
|
|
ip VARCHAR(50),
|
|||
|
|
location VARCHAR(100),
|
|||
|
|
status INTEGER NOT NULL,
|
|||
|
|
fail_reason VARCHAR(255),
|
|||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- 创建操作日志表
|
|||
|
|
CREATE TABLE operation_logs (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
user_id INTEGER,
|
|||
|
|
operation_type VARCHAR(50),
|
|||
|
|
operation_name VARCHAR(100),
|
|||
|
|
request_method VARCHAR(10),
|
|||
|
|
request_path VARCHAR(200),
|
|||
|
|
request_params TEXT,
|
|||
|
|
response_status INTEGER,
|
|||
|
|
ip VARCHAR(50),
|
|||
|
|
user_agent VARCHAR(500),
|
|||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- 创建索引
|
|||
|
|
CREATE INDEX idx_users_status ON users(status);
|
|||
|
|
CREATE INDEX idx_users_created_at ON users(created_at);
|
|||
|
|
CREATE INDEX idx_roles_parent_id ON roles(parent_id);
|
|||
|
|
CREATE INDEX idx_roles_is_default ON roles(is_default);
|
|||
|
|
CREATE INDEX idx_permissions_parent_id ON permissions(parent_id);
|
|||
|
|
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
|
|||
|
|
CREATE INDEX idx_user_roles_role_id ON user_roles(role_id);
|
|||
|
|
CREATE INDEX idx_role_permissions_role_id ON role_permissions(role_id);
|
|||
|
|
CREATE INDEX idx_role_permissions_permission_id ON role_permissions(permission_id);
|
|||
|
|
CREATE INDEX idx_devices_user_id ON devices(user_id);
|
|||
|
|
CREATE INDEX idx_devices_device_id ON devices(device_id);
|
|||
|
|
CREATE INDEX idx_login_logs_user_id ON login_logs(user_id);
|
|||
|
|
CREATE INDEX idx_login_logs_created_at ON login_logs(created_at);
|
|||
|
|
CREATE INDEX idx_operation_logs_user_id ON operation_logs(user_id);
|
|||
|
|
CREATE INDEX idx_operation_logs_created_at ON operation_logs(created_at);
|
|||
|
|
|
|||
|
|
-- 插入默认角色
|
|||
|
|
INSERT INTO roles (name, code, description, is_system, is_default) VALUES
|
|||
|
|
('管理员', 'admin', '系统管理员角色,拥有所有权限', 1, 0),
|
|||
|
|
('普通用户', 'user', '普通用户角色,基本权限', 1, 1);
|
|||
|
|
|
|||
|
|
-- 默认管理员账号不再通过迁移直接写入
|
|||
|
|
-- 当前方案改为使用显式初始化工具创建管理员账号
|
|||
|
|
|
|||
|
|
-- 分配管理员角色
|
|||
|
|
-- 管理员角色绑定在显式初始化管理员时一并完成
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**2.2.2 数据库迁移工具**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// cmd/migrate/main.go
|
|||
|
|
package main
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"fmt"
|
|||
|
|
"log"
|
|||
|
|
"os"
|
|||
|
|
|
|||
|
|
"github.com/user-management-system/internal/config"
|
|||
|
|
"github.com/user-management-system/internal/database"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
func main() {
|
|||
|
|
if len(os.Args) < 2 {
|
|||
|
|
fmt.Println("Usage: migrate <up|down>")
|
|||
|
|
os.Exit(1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
command := os.Args[1]
|
|||
|
|
|
|||
|
|
cfg, err := config.Load()
|
|||
|
|
if err != nil {
|
|||
|
|
log.Fatalf("加载配置失败: %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
db, err := database.NewDB(cfg)
|
|||
|
|
if err != nil {
|
|||
|
|
log.Fatalf("连接数据库失败: %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
switch command {
|
|||
|
|
case "up":
|
|||
|
|
if err := database.MigrateUp(db); err != nil {
|
|||
|
|
log.Fatalf("执行迁移失败: %v", err)
|
|||
|
|
}
|
|||
|
|
log.Println("数据库迁移成功")
|
|||
|
|
case "down":
|
|||
|
|
if err := database.MigrateDown(db); err != nil {
|
|||
|
|
log.Fatalf("回滚迁移失败: %v", err)
|
|||
|
|
}
|
|||
|
|
log.Println("数据库回滚成功")
|
|||
|
|
default:
|
|||
|
|
fmt.Println("未知命令: ", command)
|
|||
|
|
os.Exit(1)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/database/migration.go
|
|||
|
|
package database
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"embed"
|
|||
|
|
"fmt"
|
|||
|
|
"io/fs"
|
|||
|
|
"strings"
|
|||
|
|
|
|||
|
|
"gorm.io/gorm"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
//go:embed migrations/sqlite/*.sql
|
|||
|
|
var sqlFiles embed.FS
|
|||
|
|
|
|||
|
|
// MigrateUp 执行向上迁移
|
|||
|
|
func MigrateUp(db *gorm.DB) error {
|
|||
|
|
// 读取迁移文件
|
|||
|
|
files, err := fs.ReadDir(sqlFiles, "migrations/sqlite")
|
|||
|
|
if err != nil {
|
|||
|
|
return fmt.Errorf("读取迁移文件失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, file := range files {
|
|||
|
|
if !strings.HasSuffix(file.Name(), ".sql") {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
content, err := fs.ReadFile(sqlFiles, "migrations/sqlite/"+file.Name())
|
|||
|
|
if err != nil {
|
|||
|
|
return fmt.Errorf("读取文件 %s 失败: %w", file.Name(), err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := db.Exec(string(content)).Error; err != nil {
|
|||
|
|
return fmt.Errorf("执行迁移文件 %s 失败: %w", file.Name(), err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fmt.Printf("执行迁移: %s\n", file.Name())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MigrateDown 执行向下迁移
|
|||
|
|
func MigrateDown(db *gorm.DB) error {
|
|||
|
|
tables := []string{
|
|||
|
|
"operation_logs", "login_logs", "devices",
|
|||
|
|
"role_permissions", "user_roles",
|
|||
|
|
"permissions", "roles", "users",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, table := range tables {
|
|||
|
|
if err := db.Migrator().DropTable(table); err != nil {
|
|||
|
|
return fmt.Errorf("删除表 %s 失败: %w", table, err)
|
|||
|
|
}
|
|||
|
|
fmt.Printf("删除表: %s\n", table)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 2.3 任务清单
|
|||
|
|
|
|||
|
|
- [ ] 定义用户模型 `User`
|
|||
|
|
- [ ] 定义角色模型 `Role`
|
|||
|
|
- [ ] 定义权限模型 `Permission`
|
|||
|
|
- [ ] 定义用户-角色关联模型 `UserRole`
|
|||
|
|
- [ ] 定义角色-权限关联模型 `RolePermission`
|
|||
|
|
- [ ] 定义设备模型 `Device`
|
|||
|
|
- [ ] 定义登录日志模型 `LoginLog`
|
|||
|
|
- [ ] 定义操作日志模型 `OperationLog`
|
|||
|
|
- [ ] 创建SQLite迁移脚本
|
|||
|
|
- [ ] 创建PostgreSQL迁移脚本
|
|||
|
|
- [ ] 创建MySQL迁移脚本
|
|||
|
|
- [ ] 实现迁移工具
|
|||
|
|
- [ ] 编写模型单元测试
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 阶段3:缓存层实现(第5周)
|
|||
|
|
|
|||
|
|
**目标**:实现L1本地缓存和L2 Redis缓存
|
|||
|
|
|
|||
|
|
#### 3.1 L1本地缓存实现
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/cache/l1.go
|
|||
|
|
package cache
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"sync"
|
|||
|
|
"time"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// L1Cache L1本地缓存
|
|||
|
|
type L1Cache struct {
|
|||
|
|
data map[string]*cacheItem
|
|||
|
|
mu sync.RWMutex
|
|||
|
|
maxSize int
|
|||
|
|
ttl time.Duration
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type cacheItem struct {
|
|||
|
|
value interface{}
|
|||
|
|
expiration time.Time
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewL1Cache 创建L1缓存
|
|||
|
|
func NewL1Cache(maxSize int, ttl time.Duration) *L1Cache {
|
|||
|
|
cache := &L1Cache{
|
|||
|
|
data: make(map[string]*cacheItem),
|
|||
|
|
maxSize: maxSize,
|
|||
|
|
ttl: ttl,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 启动清理过期数据的协程
|
|||
|
|
go cache.cleanup()
|
|||
|
|
|
|||
|
|
return cache
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Set 设置缓存
|
|||
|
|
func (c *L1Cache) Set(key string, value interface{}) {
|
|||
|
|
c.mu.Lock()
|
|||
|
|
defer c.mu.Unlock()
|
|||
|
|
|
|||
|
|
// 检查缓存大小
|
|||
|
|
if len(c.data) >= c.maxSize {
|
|||
|
|
// 简单的LRU:删除最旧的
|
|||
|
|
var oldestKey string
|
|||
|
|
var oldestTime time.Time
|
|||
|
|
for k, item := range c.data {
|
|||
|
|
if oldestTime.IsZero() || item.expiration.Before(oldestTime) {
|
|||
|
|
oldestKey = k
|
|||
|
|
oldestTime = item.expiration
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if oldestKey != "" {
|
|||
|
|
delete(c.data, oldestKey)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.data[key] = &cacheItem{
|
|||
|
|
value: value,
|
|||
|
|
expiration: time.Now().Add(c.ttl),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get 获取缓存
|
|||
|
|
func (c *L1Cache) Get(key string) (interface{}, bool) {
|
|||
|
|
c.mu.RLock()
|
|||
|
|
defer c.mu.RUnlock()
|
|||
|
|
|
|||
|
|
item, ok := c.data[key]
|
|||
|
|
if !ok {
|
|||
|
|
return nil, false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查是否过期
|
|||
|
|
if time.Now().After(item.expiration) {
|
|||
|
|
return nil, false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return item.value, true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Delete 删除缓存
|
|||
|
|
func (c *L1Cache) Delete(key string) {
|
|||
|
|
c.mu.Lock()
|
|||
|
|
defer c.mu.Unlock()
|
|||
|
|
delete(c.data, key)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Clear 清空缓存
|
|||
|
|
func (c *L1Cache) Clear() {
|
|||
|
|
c.mu.Lock()
|
|||
|
|
defer c.mu.Unlock()
|
|||
|
|
c.data = make(map[string]*cacheItem)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Size 返回缓存大小
|
|||
|
|
func (c *L1Cache) Size() int {
|
|||
|
|
c.mu.RLock()
|
|||
|
|
defer c.mu.RUnlock()
|
|||
|
|
return len(c.data)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// cleanup 定期清理过期数据
|
|||
|
|
func (c *L1Cache) cleanup() {
|
|||
|
|
ticker := time.NewTicker(1 * time.Minute)
|
|||
|
|
defer ticker.Stop()
|
|||
|
|
|
|||
|
|
for range ticker.C {
|
|||
|
|
c.mu.Lock()
|
|||
|
|
now := time.Now()
|
|||
|
|
for key, item := range c.data {
|
|||
|
|
if now.After(item.expiration) {
|
|||
|
|
delete(c.data, key)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
c.mu.Unlock()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.2 L2 Redis缓存实现
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/cache/l2.go
|
|||
|
|
package cache
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"encoding/json"
|
|||
|
|
"fmt"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/redis/go-redis/v9"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// L2Cache L2 Redis缓存
|
|||
|
|
type L2Cache struct {
|
|||
|
|
client *redis.Client
|
|||
|
|
ttl time.Duration
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewL2Cache 创建L2缓存
|
|||
|
|
func NewL2Cache(addr, password string, db int, ttl time.Duration) (*L2Cache, error) {
|
|||
|
|
client := redis.NewClient(&redis.Options{
|
|||
|
|
Addr: addr,
|
|||
|
|
Password: password,
|
|||
|
|
DB: db,
|
|||
|
|
PoolSize: 50,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 测试连接
|
|||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|||
|
|
defer cancel()
|
|||
|
|
|
|||
|
|
if err := client.Ping(ctx).Err(); err != nil {
|
|||
|
|
return nil, fmt.Errorf("连接Redis失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return &L2Cache{
|
|||
|
|
client: client,
|
|||
|
|
ttl: ttl,
|
|||
|
|
}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Set 设置缓存
|
|||
|
|
func (c *L2Cache) Set(ctx context.Context, key string, value interface{}) error {
|
|||
|
|
data, err := json.Marshal(value)
|
|||
|
|
if err != nil {
|
|||
|
|
return fmt.Errorf("序列化失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return c.client.Set(ctx, key, data, c.ttl).Err()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get 获取缓存
|
|||
|
|
func (c *L2Cache) Get(ctx context.Context, key string, dest interface{}) error {
|
|||
|
|
data, err := c.client.Get(ctx, key).Bytes()
|
|||
|
|
if err != nil {
|
|||
|
|
if err == redis.Nil {
|
|||
|
|
return ErrCacheNotFound
|
|||
|
|
}
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return json.Unmarshal(data, dest)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Delete 删除缓存
|
|||
|
|
func (c *L2Cache) Delete(ctx context.Context, keys ...string) error {
|
|||
|
|
return c.client.Del(ctx, keys...).Err()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SetWithCustomTTL 设置自定义TTL的缓存
|
|||
|
|
func (c *L2Cache) SetWithCustomTTL(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
|
|||
|
|
data, err := json.Marshal(value)
|
|||
|
|
if err != nil {
|
|||
|
|
return fmt.Errorf("序列化失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return c.client.Set(ctx, key, data, ttl).Err()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MSet 批量设置
|
|||
|
|
func (c *L2Cache) MSet(ctx context.Context, pairs map[string]interface{}) error {
|
|||
|
|
pipe := c.client.Pipeline()
|
|||
|
|
|
|||
|
|
for key, value := range pairs {
|
|||
|
|
data, err := json.Marshal(value)
|
|||
|
|
if err != nil {
|
|||
|
|
return fmt.Errorf("序列化失败: %w", err)
|
|||
|
|
}
|
|||
|
|
pipe.Set(ctx, key, data, c.ttl)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_, err := pipe.Exec(ctx)
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MGet 批量获取
|
|||
|
|
func (c *L2Cache) MGet(ctx context.Context, keys ...string) ([]interface{}, error) {
|
|||
|
|
results, err := c.client.MGet(ctx, keys...).Result()
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var values []interface{}
|
|||
|
|
for _, result := range results {
|
|||
|
|
if result == nil {
|
|||
|
|
values = append(values, nil)
|
|||
|
|
} else {
|
|||
|
|
var value interface{}
|
|||
|
|
if err := json.Unmarshal([]byte(result.(string)), &value); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
values = append(values, value)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return values, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Close 关闭连接
|
|||
|
|
func (c *L2Cache) Close() error {
|
|||
|
|
return c.client.Close()
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.3 缓存管理器实现
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/cache/cache_manager.go
|
|||
|
|
package cache
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"fmt"
|
|||
|
|
"sync"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/google/uuid"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
var (
|
|||
|
|
ErrCacheNotFound = fmt.Errorf("cache not found")
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// CacheManager 缓存管理器
|
|||
|
|
type CacheManager struct {
|
|||
|
|
l1 *L1Cache
|
|||
|
|
l2 *L2Cache
|
|||
|
|
l1Enabled bool
|
|||
|
|
l2Enabled bool
|
|||
|
|
mu sync.RWMutex
|
|||
|
|
hitCount int64
|
|||
|
|
missCount int64
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewCacheManager 创建缓存管理器
|
|||
|
|
func NewCacheManager(l1 *L1Cache, l2 *L2Cache, l1Enabled, l2Enabled bool) *CacheManager {
|
|||
|
|
return &CacheManager{
|
|||
|
|
l1: l1,
|
|||
|
|
l2: l2,
|
|||
|
|
l1Enabled: l1Enabled,
|
|||
|
|
l2Enabled: l2Enabled,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get 获取缓存(多级缓存)
|
|||
|
|
func (cm *CacheManager) Get(ctx context.Context, key string, dest interface{}) error {
|
|||
|
|
cm.mu.Lock()
|
|||
|
|
cm.hitCount++
|
|||
|
|
cm.mu.Unlock()
|
|||
|
|
|
|||
|
|
// 先查L1缓存
|
|||
|
|
if cm.l1Enabled {
|
|||
|
|
if value, ok := cm.l1.Get(key); ok {
|
|||
|
|
return cm.decode(value, dest)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 再查L2缓存
|
|||
|
|
if cm.l2Enabled {
|
|||
|
|
if err := cm.l2.Get(ctx, key, dest); err == nil {
|
|||
|
|
// 回填L1缓存
|
|||
|
|
if cm.l1Enabled {
|
|||
|
|
var value interface{}
|
|||
|
|
if err := cm.l2.Get(ctx, key, &value); err == nil {
|
|||
|
|
cm.l1.Set(key, value)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
} else if err != ErrCacheNotFound {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
cm.mu.Lock()
|
|||
|
|
cm.hitCount--
|
|||
|
|
cm.missCount++
|
|||
|
|
cm.mu.Unlock()
|
|||
|
|
|
|||
|
|
return ErrCacheNotFound
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Set 设置缓存(多级缓存)
|
|||
|
|
func (cm *CacheManager) Set(ctx context.Context, key string, value interface{}) error {
|
|||
|
|
if cm.l1Enabled {
|
|||
|
|
cm.l1.Set(key, value)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if cm.l2Enabled {
|
|||
|
|
if err := cm.l2.Set(ctx, key, value); err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Delete 删除缓存
|
|||
|
|
func (cm *CacheManager) Delete(ctx context.Context, key string) error {
|
|||
|
|
if cm.l1Enabled {
|
|||
|
|
cm.l1.Delete(key)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if cm.l2Enabled {
|
|||
|
|
return cm.l2.Delete(ctx, key)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Clear 清空所有缓存
|
|||
|
|
func (cm *CacheManager) Clear(ctx context.Context) {
|
|||
|
|
if cm.l1Enabled {
|
|||
|
|
cm.l1.Clear()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if cm.l2Enabled {
|
|||
|
|
// L2清理需要pattern,这里简化处理
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetOrLoad 缓存不存在时加载
|
|||
|
|
func (cm *CacheManager) GetOrLoad(ctx context.Context, key string, dest interface{}, loader func() (interface{}, error)) error {
|
|||
|
|
err := cm.Get(ctx, key, dest)
|
|||
|
|
if err == nil {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err != ErrCacheNotFound {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 缓存未命中,加载数据
|
|||
|
|
value, err := loader()
|
|||
|
|
if err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 设置缓存
|
|||
|
|
if err := cm.Set(ctx, key, value); err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return cm.decode(value, dest)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetStats 获取缓存统计
|
|||
|
|
func (cm *CacheManager) GetStats() CacheStats {
|
|||
|
|
cm.mu.RLock()
|
|||
|
|
defer cm.mu.RUnlock()
|
|||
|
|
|
|||
|
|
total := cm.hitCount + cm.missCount
|
|||
|
|
hitRate := 0.0
|
|||
|
|
if total > 0 {
|
|||
|
|
hitRate = float64(cm.hitCount) / float64(total) * 100
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return CacheStats{
|
|||
|
|
HitCount: cm.hitCount,
|
|||
|
|
MissCount: cm.missCount,
|
|||
|
|
HitRate: hitRate,
|
|||
|
|
L1Size: cm.l1.Size(),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CacheStats 缓存统计
|
|||
|
|
type CacheStats struct {
|
|||
|
|
HitCount int64 `json:"hit_count"`
|
|||
|
|
MissCount int64 `json:"miss_count"`
|
|||
|
|
HitRate float64 `json:"hit_rate"`
|
|||
|
|
L1Size int `json:"l1_size"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// decode 解码缓存值
|
|||
|
|
func (cm *CacheManager) decode(value interface{}, dest interface{}) error {
|
|||
|
|
// 这里需要根据实际类型进行解码
|
|||
|
|
// 简化处理,直接赋值
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GenerateKey 生成缓存key
|
|||
|
|
func GenerateKey(prefix string, parts ...string) string {
|
|||
|
|
key := prefix
|
|||
|
|
for _, part := range parts {
|
|||
|
|
key += ":" + part
|
|||
|
|
}
|
|||
|
|
return key
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GenerateUniqueKey 生成唯一key
|
|||
|
|
func GenerateUniqueKey() string {
|
|||
|
|
return uuid.New().String()
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.4 任务清单
|
|||
|
|
|
|||
|
|
- [ ] 实现L1本地缓存
|
|||
|
|
- [ ] 实现L2 Redis缓存
|
|||
|
|
- [ ] 实现缓存管理器
|
|||
|
|
- [ ] 实现多级缓存策略
|
|||
|
|
- [ ] 实现缓存统计
|
|||
|
|
- [ ] 编写缓存单元测试
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 阶段4:核心业务服务实现(第6-8周)
|
|||
|
|
|
|||
|
|
**目标**:实现用户、角色、权限、认证等核心业务服务
|
|||
|
|
|
|||
|
|
#### 4.1 用户服务实现
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/service/user.go
|
|||
|
|
package service
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"fmt"
|
|||
|
|
|
|||
|
|
"github.com/user-management-system/internal/cache"
|
|||
|
|
"github.com/user-management-system/internal/domain"
|
|||
|
|
"github.com/user-management-system/internal/repository"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// UserService 用户服务
|
|||
|
|
type UserService struct {
|
|||
|
|
userRepo repository.UserRepository
|
|||
|
|
roleRepo repository.RoleRepository
|
|||
|
|
cache *cache.CacheManager
|
|||
|
|
auth AuthService
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewUserService 创建用户服务
|
|||
|
|
func NewUserService(
|
|||
|
|
userRepo repository.UserRepository,
|
|||
|
|
roleRepo repository.RoleRepository,
|
|||
|
|
cache *cache.CacheManager,
|
|||
|
|
auth AuthService,
|
|||
|
|
) *UserService {
|
|||
|
|
return &UserService{
|
|||
|
|
userRepo: userRepo,
|
|||
|
|
roleRepo: roleRepo,
|
|||
|
|
cache: cache,
|
|||
|
|
auth: auth,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Register 用户注册
|
|||
|
|
func (s *UserService) Register(ctx context.Context, req *RegisterRequest) (*domain.User, error) {
|
|||
|
|
// 1. 验证用户名是否已存在
|
|||
|
|
exists, err := s.userRepo.ExistsByUsername(ctx, req.Username)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("检查用户名失败: %w", err)
|
|||
|
|
}
|
|||
|
|
if exists {
|
|||
|
|
return nil, ErrUsernameExists
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 验证邮箱是否已存在
|
|||
|
|
if req.Email != "" {
|
|||
|
|
exists, err := s.userRepo.ExistsByEmail(ctx, req.Email)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("检查邮箱失败: %w", err)
|
|||
|
|
}
|
|||
|
|
if exists {
|
|||
|
|
return nil, ErrEmailExists
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 验证手机号是否已存在
|
|||
|
|
if req.Phone != "" {
|
|||
|
|
exists, err := s.userRepo.ExistsByPhone(ctx, req.Phone)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("检查手机号失败: %w", err)
|
|||
|
|
}
|
|||
|
|
if exists {
|
|||
|
|
return nil, ErrPhoneExists
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. 加密密码
|
|||
|
|
hashedPassword, err := s.auth.HashPassword(req.Password)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("加密密码失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 5. 创建用户
|
|||
|
|
user := &domain.User{
|
|||
|
|
Username: req.Username,
|
|||
|
|
Email: req.Email,
|
|||
|
|
Phone: req.Phone,
|
|||
|
|
Nickname: req.Nickname,
|
|||
|
|
Password: hashedPassword,
|
|||
|
|
Status: domain.UserStatusInactive,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := s.userRepo.Create(ctx, user); err != nil {
|
|||
|
|
return nil, fmt.Errorf("创建用户失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 6. 分配默认角色
|
|||
|
|
defaultRole, err := s.roleRepo.FindDefault(ctx)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("查找默认角色失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := s.roleRepo.AssignToUser(ctx, user.ID, defaultRole.ID); err != nil {
|
|||
|
|
return nil, fmt.Errorf("分配角色失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 7. 清除用户列表缓存
|
|||
|
|
s.cache.Delete(ctx, cache.GenerateKey("users", "list"))
|
|||
|
|
|
|||
|
|
return user, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Login 用户登录
|
|||
|
|
func (s *UserService) Login(ctx context.Context, req *LoginRequest, device *DeviceInfo) (*LoginResponse, error) {
|
|||
|
|
// 1. 查找用户
|
|||
|
|
user, err := s.userRepo.FindByUsername(ctx, req.Username)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, ErrInvalidCredentials
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 验证密码
|
|||
|
|
if !s.auth.VerifyPassword(req.Password, user.Password) {
|
|||
|
|
return nil, ErrInvalidCredentials
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 检查用户状态
|
|||
|
|
if user.Status == domain.UserStatusLocked {
|
|||
|
|
return nil, ErrAccountLocked
|
|||
|
|
}
|
|||
|
|
if user.Status == domain.UserStatusDisabled {
|
|||
|
|
return nil, ErrAccountDisabled
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. 生成Token
|
|||
|
|
accessToken, err := s.auth.GenerateAccessToken(user.ID, user.Username)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("生成访问令牌失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
refreshToken, err := s.auth.GenerateRefreshToken(user.ID, user.Username)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("生成刷新令牌失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 5. 记录设备
|
|||
|
|
if device != nil {
|
|||
|
|
if err := s.RecordDevice(ctx, user.ID, device); err != nil {
|
|||
|
|
return nil, fmt.Errorf("记录设备失败: %w", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 6. 更新最后登录信息
|
|||
|
|
now := time.Now()
|
|||
|
|
user.LastLoginTime = &now
|
|||
|
|
user.LastLoginIP = device.IP
|
|||
|
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
|||
|
|
// 不影响登录流程
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 7. 清除用户缓存
|
|||
|
|
s.cache.Delete(ctx, cache.GenerateKey("users", user.ID))
|
|||
|
|
|
|||
|
|
return &LoginResponse{
|
|||
|
|
User: user,
|
|||
|
|
AccessToken: accessToken,
|
|||
|
|
RefreshToken: refreshToken,
|
|||
|
|
ExpiresIn: 7200, // 2小时
|
|||
|
|
}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetUserByID 根据ID获取用户
|
|||
|
|
func (s *UserService) GetUserByID(ctx context.Context, id int64) (*domain.User, error) {
|
|||
|
|
key := cache.GenerateKey("users", id)
|
|||
|
|
|
|||
|
|
var user domain.User
|
|||
|
|
err := s.cache.GetOrLoad(ctx, key, &user, func() (interface{}, error) {
|
|||
|
|
return s.userRepo.FindByID(ctx, id)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return &user, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateUser 更新用户
|
|||
|
|
func (s *UserService) UpdateUser(ctx context.Context, id int64, req *UpdateUserRequest) (*domain.User, error) {
|
|||
|
|
// 1. 获取用户
|
|||
|
|
user, err := s.userRepo.FindByID(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, ErrUserNotFound
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 更新字段
|
|||
|
|
if req.Nickname != "" {
|
|||
|
|
user.Nickname = req.Nickname
|
|||
|
|
}
|
|||
|
|
if req.Avatar != "" {
|
|||
|
|
user.Avatar = req.Avatar
|
|||
|
|
}
|
|||
|
|
if req.Gender != nil {
|
|||
|
|
user.Gender = *req.Gender
|
|||
|
|
}
|
|||
|
|
if req.Birthday != nil {
|
|||
|
|
user.Birthday = req.Birthday
|
|||
|
|
}
|
|||
|
|
if req.Region != "" {
|
|||
|
|
user.Region = req.Region
|
|||
|
|
}
|
|||
|
|
if req.Bio != "" {
|
|||
|
|
user.Bio = req.Bio
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 保存
|
|||
|
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
|||
|
|
return nil, fmt.Errorf("更新用户失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. 清除缓存
|
|||
|
|
key := cache.GenerateKey("users", id)
|
|||
|
|
s.cache.Delete(ctx, key)
|
|||
|
|
|
|||
|
|
return user, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ChangePassword 修改密码
|
|||
|
|
func (s *UserService) ChangePassword(ctx context.Context, id int64, oldPassword, newPassword string) error {
|
|||
|
|
// 1. 获取用户
|
|||
|
|
user, err := s.userRepo.FindByID(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
return ErrUserNotFound
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 验证旧密码
|
|||
|
|
if !s.auth.VerifyPassword(oldPassword, user.Password) {
|
|||
|
|
return ErrInvalidOldPassword
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 加密新密码
|
|||
|
|
hashedPassword, err := s.auth.HashPassword(newPassword)
|
|||
|
|
if err != nil {
|
|||
|
|
return fmt.Errorf("加密密码失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. 更新密码
|
|||
|
|
user.Password = hashedPassword
|
|||
|
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
|||
|
|
return fmt.Errorf("更新密码失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListUsers 分页获取用户列表
|
|||
|
|
func (s *UserService) ListUsers(ctx context.Context, req *ListUsersRequest) (*ListUsersResponse, error) {
|
|||
|
|
// 构建缓存key
|
|||
|
|
cacheKey := cache.GenerateKey("users", "list", fmt.Sprintf("%d-%d", req.Page, req.PageSize))
|
|||
|
|
|
|||
|
|
// 尝试从缓存获取(仅第一页)
|
|||
|
|
if req.Page == 1 {
|
|||
|
|
var cachedResp ListUsersResponse
|
|||
|
|
if err := s.cache.Get(ctx, cacheKey, &cachedResp); err == nil {
|
|||
|
|
return &cachedResp, nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 从数据库查询
|
|||
|
|
users, total, err := s.userRepo.List(ctx, req)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("查询用户列表失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resp := &ListUsersResponse{
|
|||
|
|
Users: users,
|
|||
|
|
Total: total,
|
|||
|
|
Page: req.Page,
|
|||
|
|
PageSize: req.PageSize,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 缓存第一页结果
|
|||
|
|
if req.Page == 1 {
|
|||
|
|
s.cache.Set(ctx, cacheKey, resp)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return resp, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DeleteUser 删除用户(软删除)
|
|||
|
|
func (s *UserService) DeleteUser(ctx context.Context, id int64) error {
|
|||
|
|
if err := s.userRepo.Delete(ctx, id); err != nil {
|
|||
|
|
return fmt.Errorf("删除用户失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清除缓存
|
|||
|
|
s.cache.Delete(ctx, cache.GenerateKey("users", id))
|
|||
|
|
s.cache.Delete(ctx, cache.GenerateKey("users", "list"))
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetUserRoles 获取用户角色
|
|||
|
|
func (s *UserService) GetUserRoles(ctx context.Context, userID int64) ([]*domain.Role, error) {
|
|||
|
|
roles, err := s.roleRepo.FindByUserID(ctx, userID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("获取用户角色失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return roles, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetUserPermissions 获取用户权限
|
|||
|
|
func (s *UserService) GetUserPermissions(ctx context.Context, userID int64) ([]*domain.Permission, error) {
|
|||
|
|
permissions, err := s.roleRepo.FindPermissionsByUserID(ctx, userID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("获取用户权限失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return permissions, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// AssignRole 分配角色
|
|||
|
|
func (s *UserService) AssignRole(ctx context.Context, userID, roleID int64) error {
|
|||
|
|
if err := s.roleRepo.AssignToUser(ctx, userID, roleID); err != nil {
|
|||
|
|
return fmt.Errorf("分配角色失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清除用户角色缓存
|
|||
|
|
s.cache.Delete(ctx, cache.GenerateKey("users", userID, "roles"))
|
|||
|
|
s.cache.Delete(ctx, cache.GenerateKey("users", userID, "permissions"))
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// RevokeRole 撤销角色
|
|||
|
|
func (s *UserService) RevokeRole(ctx context.Context, userID, roleID int64) error {
|
|||
|
|
if err := s.roleRepo.RevokeFromUser(ctx, userID, roleID); err != nil {
|
|||
|
|
return fmt.Errorf("撤销角色失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清除用户角色缓存
|
|||
|
|
s.cache.Delete(ctx, cache.GenerateKey("users", userID, "roles"))
|
|||
|
|
s.cache.Delete(ctx, cache.GenerateKey("users", userID, "permissions"))
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// RecordDevice 记录设备信息
|
|||
|
|
func (s *UserService) RecordDevice(ctx context.Context, userID int64, device *DeviceInfo) error {
|
|||
|
|
// 实现设备记录逻辑
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 请求和响应结构体
|
|||
|
|
type RegisterRequest struct {
|
|||
|
|
Username string `json:"username" binding:"required,min=3,max=50"`
|
|||
|
|
Email string `json:"email" binding:"omitempty,email,max=100"`
|
|||
|
|
Phone string `json:"phone" binding:"omitempty,max=20"`
|
|||
|
|
Nickname string `json:"nickname" binding:"omitempty,max=50"`
|
|||
|
|
Password string `json:"password" binding:"required,min=8"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type LoginRequest struct {
|
|||
|
|
Username string `json:"username" binding:"required"`
|
|||
|
|
Password string `json:"password" binding:"required"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type UpdateUserRequest struct {
|
|||
|
|
Nickname string `json:"nickname" binding:"omitempty,max=50"`
|
|||
|
|
Avatar string `json:"avatar" binding:"omitempty,max=255"`
|
|||
|
|
Gender *int `json:"gender" binding:"omitempty,min=0,max=2"`
|
|||
|
|
Birthday *time.Time `json:"birthday"`
|
|||
|
|
Region string `json:"region" binding:"omitempty,max=50"`
|
|||
|
|
Bio string `json:"bio" binding:"omitempty,max=500"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type ListUsersRequest struct {
|
|||
|
|
Page int `json:"page" binding:"required,min=1"`
|
|||
|
|
PageSize int `json:"page_size" binding:"required,min=1,max=100"`
|
|||
|
|
Keyword string `json:"keyword"`
|
|||
|
|
Status *int `json:"status"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type LoginResponse struct {
|
|||
|
|
User *domain.User `json:"user"`
|
|||
|
|
AccessToken string `json:"access_token"`
|
|||
|
|
RefreshToken string `json:"refresh_token"`
|
|||
|
|
ExpiresIn int64 `json:"expires_in"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type ListUsersResponse struct {
|
|||
|
|
Users []*domain.User `json:"users"`
|
|||
|
|
Total int64 `json:"total"`
|
|||
|
|
Page int `json:"page"`
|
|||
|
|
PageSize int `json:"page_size"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type DeviceInfo struct {
|
|||
|
|
DeviceID string `json:"device_id"`
|
|||
|
|
DeviceName string `json:"device_name"`
|
|||
|
|
DeviceType int `json:"device_type"`
|
|||
|
|
DeviceOS string `json:"device_os"`
|
|||
|
|
DeviceBrowser string `json:"device_browser"`
|
|||
|
|
IP string `json:"ip"`
|
|||
|
|
Location string `json:"location"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 错误定义
|
|||
|
|
var (
|
|||
|
|
ErrUsernameExists = fmt.Errorf("用户名已存在")
|
|||
|
|
ErrEmailExists = fmt.Errorf("邮箱已存在")
|
|||
|
|
ErrPhoneExists = fmt.Errorf("手机号已存在")
|
|||
|
|
ErrInvalidCredentials = fmt.Errorf("用户名或密码错误")
|
|||
|
|
ErrAccountLocked = fmt.Errorf("账号已被锁定")
|
|||
|
|
ErrAccountDisabled = fmt.Errorf("账号已被禁用")
|
|||
|
|
ErrUserNotFound = fmt.Errorf("用户不存在")
|
|||
|
|
ErrInvalidOldPassword = fmt.Errorf("原密码错误")
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.2 认证服务实现
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/service/auth.go
|
|||
|
|
package service
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"crypto/rand"
|
|||
|
|
"encoding/base64"
|
|||
|
|
"fmt"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/golang-jwt/jwt/v5"
|
|||
|
|
"golang.org/x/crypto/bcrypt"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// AuthService 认证服务
|
|||
|
|
type AuthService struct {
|
|||
|
|
jwtSecret []byte
|
|||
|
|
accessTokenExpire time.Duration
|
|||
|
|
refreshTokenExpire time.Duration
|
|||
|
|
passwordMinLength int
|
|||
|
|
passwordRequireSpecial bool
|
|||
|
|
passwordRequireNumber bool
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewAuthService 创建认证服务
|
|||
|
|
func NewAuthService(jwtSecret string, accessTokenExpire, refreshTokenExpire time.Duration, options ...Option) *AuthService {
|
|||
|
|
svc := &AuthService{
|
|||
|
|
jwtSecret: []byte(jwtSecret),
|
|||
|
|
accessTokenExpire: accessTokenExpire,
|
|||
|
|
refreshTokenExpire: refreshTokenExpire,
|
|||
|
|
passwordMinLength: 8,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, opt := range options {
|
|||
|
|
opt(svc)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return svc
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Option 认证服务选项
|
|||
|
|
type Option func(*AuthService)
|
|||
|
|
|
|||
|
|
func WithPasswordMinLength(length int) Option {
|
|||
|
|
return func(s *AuthService) {
|
|||
|
|
s.passwordMinLength = length
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func WithPasswordRequireSpecial(require bool) Option {
|
|||
|
|
return func(s *AuthService) {
|
|||
|
|
s.passwordRequireSpecial = require
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func WithPasswordRequireNumber(require bool) Option {
|
|||
|
|
return func(s *AuthService) {
|
|||
|
|
s.passwordRequireNumber = require
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Claims JWT声明
|
|||
|
|
type Claims struct {
|
|||
|
|
UserID int64 `json:"user_id"`
|
|||
|
|
Username string `json:"username"`
|
|||
|
|
Type string `json:"type"` // access, refresh
|
|||
|
|
jwt.RegisteredClaims
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GenerateAccessToken 生成访问令牌
|
|||
|
|
func (s *AuthService) GenerateAccessToken(userID int64, username string) (string, error) {
|
|||
|
|
now := time.Now()
|
|||
|
|
claims := Claims{
|
|||
|
|
UserID: userID,
|
|||
|
|
Username: username,
|
|||
|
|
Type: "access",
|
|||
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|||
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|||
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(s.accessTokenExpire)),
|
|||
|
|
Issuer: "user-management-system",
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|||
|
|
return token.SignedString(s.jwtSecret)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GenerateRefreshToken 生成刷新令牌
|
|||
|
|
func (s *AuthService) GenerateRefreshToken(userID int64, username string) (string, error) {
|
|||
|
|
now := time.Now()
|
|||
|
|
claims := Claims{
|
|||
|
|
UserID: userID,
|
|||
|
|
Username: username,
|
|||
|
|
Type: "refresh",
|
|||
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|||
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|||
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(s.refreshTokenExpire)),
|
|||
|
|
Issuer: "user-management-system",
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|||
|
|
return token.SignedString(s.jwtSecret)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ParseToken 解析令牌
|
|||
|
|
func (s *AuthService) ParseToken(tokenString string) (*Claims, error) {
|
|||
|
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
|||
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|||
|
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
|||
|
|
}
|
|||
|
|
return s.jwtSecret, nil
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
|||
|
|
return claims, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return nil, fmt.Errorf("invalid token")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// RefreshToken 刷新令牌
|
|||
|
|
func (s *AuthService) RefreshToken(refreshToken string) (string, error) {
|
|||
|
|
claims, err := s.ParseToken(refreshToken)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", fmt.Errorf("invalid refresh token: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if claims.Type != "refresh" {
|
|||
|
|
return "", fmt.Errorf("not a refresh token")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 生成新的访问令牌
|
|||
|
|
return s.GenerateAccessToken(claims.UserID, claims.Username)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// HashPassword 加密密码
|
|||
|
|
func (s *AuthService) HashPassword(password string) (string, error) {
|
|||
|
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", fmt.Errorf("加密密码失败: %w", err)
|
|||
|
|
}
|
|||
|
|
return string(bytes), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// VerifyPassword 验证密码
|
|||
|
|
func (s *AuthService) VerifyPassword(password, hashedPassword string) bool {
|
|||
|
|
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
|||
|
|
return err == nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ValidatePassword 验证密码强度
|
|||
|
|
func (s *AuthService) ValidatePassword(password string) error {
|
|||
|
|
// 检查长度
|
|||
|
|
if len(password) < s.passwordMinLength {
|
|||
|
|
return fmt.Errorf("密码长度不能少于%d位", s.passwordMinLength)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查特殊字符
|
|||
|
|
if s.passwordRequireSpecial {
|
|||
|
|
hasSpecial := false
|
|||
|
|
for _, c := range password {
|
|||
|
|
if (c >= 33 && c <= 47) || (c >= 58 && c <= 64) || (c >= 91 && c <= 96) || (c >= 123 && c <= 126) {
|
|||
|
|
hasSpecial = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !hasSpecial {
|
|||
|
|
return fmt.Errorf("密码必须包含特殊字符")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查数字
|
|||
|
|
if s.passwordRequireNumber {
|
|||
|
|
hasNumber := false
|
|||
|
|
for _, c := range password {
|
|||
|
|
if c >= '0' && c <= '9' {
|
|||
|
|
hasNumber = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !hasNumber {
|
|||
|
|
return fmt.Errorf("密码必须包含数字")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GenerateResetToken 生成重置令牌
|
|||
|
|
func (s *AuthService) GenerateResetToken() (string, error) {
|
|||
|
|
b := make([]byte, 32)
|
|||
|
|
if _, err := rand.Read(b); err != nil {
|
|||
|
|
return "", fmt.Errorf("生成重置令牌失败: %w", err)
|
|||
|
|
}
|
|||
|
|
return base64.URLEncoding.EncodeToString(b), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GenerateVerificationCode 生成验证码
|
|||
|
|
func (s *AuthService) GenerateVerificationCode(length int) (string, error) {
|
|||
|
|
const digits = "0123456789"
|
|||
|
|
b := make([]byte, length)
|
|||
|
|
for i := range b {
|
|||
|
|
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(digits))))
|
|||
|
|
if err != nil {
|
|||
|
|
return "", fmt.Errorf("生成验证码失败: %w", err)
|
|||
|
|
}
|
|||
|
|
b[i] = digits[n.Int64()]
|
|||
|
|
}
|
|||
|
|
return string(b), nil
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.3 角色服务实现
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/service/role.go
|
|||
|
|
package service
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"fmt"
|
|||
|
|
|
|||
|
|
"github.com/user-management-system/internal/cache"
|
|||
|
|
"github.com/user-management-system/internal/domain"
|
|||
|
|
"github.com/user-management-system/internal/repository"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// RoleService 角色服务
|
|||
|
|
type RoleService struct {
|
|||
|
|
roleRepo repository.RoleRepository
|
|||
|
|
cache *cache.CacheManager
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewRoleService 创建角色服务
|
|||
|
|
func NewRoleService(
|
|||
|
|
roleRepo repository.RoleRepository,
|
|||
|
|
cache *cache.CacheManager,
|
|||
|
|
) *RoleService {
|
|||
|
|
return &RoleService{
|
|||
|
|
roleRepo: roleRepo,
|
|||
|
|
cache: cache,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CreateRole 创建角色
|
|||
|
|
func (s *RoleService) CreateRole(ctx context.Context, req *CreateRoleRequest) (*domain.Role, error) {
|
|||
|
|
// 1. 检查角色名称是否已存在
|
|||
|
|
exists, err := s.roleRepo.ExistsByCode(ctx, req.Code)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("检查角色代码失败: %w", err)
|
|||
|
|
}
|
|||
|
|
if exists {
|
|||
|
|
return nil, ErrRoleCodeExists
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 创建角色
|
|||
|
|
role := &domain.Role{
|
|||
|
|
Name: req.Name,
|
|||
|
|
Code: req.Code,
|
|||
|
|
Description: req.Description,
|
|||
|
|
ParentID: req.ParentID,
|
|||
|
|
Level: 1,
|
|||
|
|
IsSystem: false,
|
|||
|
|
IsDefault: false,
|
|||
|
|
Status: domain.RoleStatusEnabled,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if req.ParentID != nil {
|
|||
|
|
parent, err := s.roleRepo.FindByID(ctx, *req.ParentID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("查找父角色失败: %w", err)
|
|||
|
|
}
|
|||
|
|
role.Level = parent.Level + 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := s.roleRepo.Create(ctx, role); err != nil {
|
|||
|
|
return nil, fmt.Errorf("创建角色失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 清除角色列表缓存
|
|||
|
|
s.cache.Delete(ctx, cache.GenerateKey("roles", "list"))
|
|||
|
|
|
|||
|
|
return role, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetRoleByID 根据ID获取角色
|
|||
|
|
func (s *RoleService) GetRoleByID(ctx context.Context, id int64) (*domain.Role, error) {
|
|||
|
|
key := cache.GenerateKey("roles", id)
|
|||
|
|
|
|||
|
|
var role domain.Role
|
|||
|
|
err := s.cache.GetOrLoad(ctx, key, &role, func() (interface{}, error) {
|
|||
|
|
return s.roleRepo.FindByID(ctx, id)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return &role, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateRole 更新角色
|
|||
|
|
func (s *RoleService) UpdateRole(ctx context.Context, id int64, req *UpdateRoleRequest) (*domain.Role, error) {
|
|||
|
|
// 1. 获取角色
|
|||
|
|
role, err := s.roleRepo.FindByID(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, ErrRoleNotFound
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 系统角色不允许修改
|
|||
|
|
if role.IsSystem {
|
|||
|
|
return nil, ErrCannotModifySystemRole
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 更新字段
|
|||
|
|
if req.Name != "" {
|
|||
|
|
role.Name = req.Name
|
|||
|
|
}
|
|||
|
|
if req.Description != "" {
|
|||
|
|
role.Description = req.Description
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. 保存
|
|||
|
|
if err := s.roleRepo.Update(ctx, role); err != nil {
|
|||
|
|
return nil, fmt.Errorf("更新角色失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 5. 清除缓存
|
|||
|
|
key := cache.GenerateKey("roles", id)
|
|||
|
|
s.cache.Delete(ctx, key)
|
|||
|
|
|
|||
|
|
return role, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DeleteRole 删除角色
|
|||
|
|
func (s *RoleService) DeleteRole(ctx context.Context, id int64) error {
|
|||
|
|
// 1. 获取角色
|
|||
|
|
role, err := s.roleRepo.FindByID(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
return ErrRoleNotFound
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 系统角色不允许删除
|
|||
|
|
if role.IsSystem {
|
|||
|
|
return ErrCannotDeleteSystemRole
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 检查是否有用户使用该角色
|
|||
|
|
count, err := s.roleRepo.CountUsers(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
return fmt.Errorf("检查角色用户失败: %w", err)
|
|||
|
|
}
|
|||
|
|
if count > 0 {
|
|||
|
|
return ErrRoleInUse
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. 删除角色
|
|||
|
|
if err := s.roleRepo.Delete(ctx, id); err != nil {
|
|||
|
|
return fmt.Errorf("删除角色失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 5. 清除缓存
|
|||
|
|
s.cache.Delete(ctx, cache.GenerateKey("roles", id))
|
|||
|
|
s.cache.Delete(ctx, cache.GenerateKey("roles", "list"))
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListRoles 分页获取角色列表
|
|||
|
|
func (s *RoleService) ListRoles(ctx context.Context, req *ListRolesRequest) (*ListRolesResponse, error) {
|
|||
|
|
// 尝试从缓存获取(仅第一页)
|
|||
|
|
if req.Page == 1 {
|
|||
|
|
cacheKey := cache.GenerateKey("roles", "list")
|
|||
|
|
var cachedResp ListRolesResponse
|
|||
|
|
if err := s.cache.Get(ctx, cacheKey, &cachedResp); err == nil {
|
|||
|
|
return &cachedResp, nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 从数据库查询
|
|||
|
|
roles, total, err := s.roleRepo.List(ctx, req)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("查询角色列表失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resp := &ListRolesResponse{
|
|||
|
|
Roles: roles,
|
|||
|
|
Total: total,
|
|||
|
|
Page: req.Page,
|
|||
|
|
PageSize: req.PageSize,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 缓存第一页结果
|
|||
|
|
if req.Page == 1 {
|
|||
|
|
s.cache.Set(ctx, cache.GenerateKey("roles", "list"), resp)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return resp, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// AssignPermission 分配权限
|
|||
|
|
func (s *RoleService) AssignPermission(ctx context.Context, roleID, permissionID int64) error {
|
|||
|
|
if err := s.roleRepo.AssignPermission(ctx, roleID, permissionID); err != nil {
|
|||
|
|
return fmt.Errorf("分配权限失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清除缓存
|
|||
|
|
s.cache.Delete(ctx, cache.GenerateKey("roles", roleID, "permissions"))
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// RevokePermission 撤销权限
|
|||
|
|
func (s *RoleService) RevokePermission(ctx context.Context, roleID, permissionID int64) error {
|
|||
|
|
if err := s.roleRepo.RevokePermission(ctx, roleID, permissionID); err != nil {
|
|||
|
|
return fmt.Errorf("撤销权限失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清除缓存
|
|||
|
|
s.cache.Delete(ctx, cache.GenerateKey("roles", roleID, "permissions"))
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetRolePermissions 获取角色权限
|
|||
|
|
func (s *RoleService) GetRolePermissions(ctx context.Context, roleID int64) ([]*domain.Permission, error) {
|
|||
|
|
cacheKey := cache.GenerateKey("roles", roleID, "permissions")
|
|||
|
|
|
|||
|
|
var permissions []*domain.Permission
|
|||
|
|
err := s.cache.GetOrLoad(ctx, cacheKey, &permissions, func() (interface{}, error) {
|
|||
|
|
return s.roleRepo.FindPermissions(ctx, roleID)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return permissions, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 请求和响应结构体
|
|||
|
|
type CreateRoleRequest struct {
|
|||
|
|
Name string `json:"name" binding:"required,max=50"`
|
|||
|
|
Code string `json:"code" binding:"required,max=50"`
|
|||
|
|
Description string `json:"description" binding:"omitempty,max=200"`
|
|||
|
|
ParentID *int64 `json:"parent_id"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type UpdateRoleRequest struct {
|
|||
|
|
Name string `json:"name" binding:"omitempty,max=50"`
|
|||
|
|
Description string `json:"description" binding:"omitempty,max=200"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type ListRolesRequest struct {
|
|||
|
|
Page int `json:"page" binding:"required,min=1"`
|
|||
|
|
PageSize int `json:"page_size" binding:"required,min=1,max=100"`
|
|||
|
|
Status *int `json:"status"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type ListRolesResponse struct {
|
|||
|
|
Roles []*domain.Role `json:"roles"`
|
|||
|
|
Total int64 `json:"total"`
|
|||
|
|
Page int `json:"page"`
|
|||
|
|
PageSize int `json:"page_size"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 错误定义
|
|||
|
|
var (
|
|||
|
|
ErrRoleCodeExists = fmt.Errorf("角色代码已存在")
|
|||
|
|
ErrRoleNotFound = fmt.Errorf("角色不存在")
|
|||
|
|
ErrCannotModifySystemRole = fmt.Errorf("不能修改系统角色")
|
|||
|
|
ErrCannotDeleteSystemRole = fmt.Errorf("不能删除系统角色")
|
|||
|
|
ErrRoleInUse = fmt.Errorf("角色正在使用中")
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.4 权限服务实现
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/service/permission.go
|
|||
|
|
package service
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"fmt"
|
|||
|
|
|
|||
|
|
"github.com/user-management-system/internal/cache"
|
|||
|
|
"github.com/user-management-system/internal/domain"
|
|||
|
|
"github.com/user-management-system/internal/repository"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// PermissionService 权限服务
|
|||
|
|
type PermissionService struct {
|
|||
|
|
permRepo repository.PermissionRepository
|
|||
|
|
cache *cache.CacheManager
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewPermissionService 创建权限服务
|
|||
|
|
func NewPermissionService(
|
|||
|
|
permRepo repository.PermissionRepository,
|
|||
|
|
cache *cache.CacheManager,
|
|||
|
|
) *PermissionService {
|
|||
|
|
return &PermissionService{
|
|||
|
|
permRepo: permRepo,
|
|||
|
|
cache: cache,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CreatePermission 创建权限
|
|||
|
|
func (s *PermissionService) CreatePermission(ctx context.Context, req *CreatePermissionRequest) (*domain.Permission, error) {
|
|||
|
|
// 1. 检查权限代码是否已存在
|
|||
|
|
exists, err := s.permRepo.ExistsByCode(ctx, req.Code)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("检查权限代码失败: %w", err)
|
|||
|
|
}
|
|||
|
|
if exists {
|
|||
|
|
return nil, ErrPermissionCodeExists
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 创建权限
|
|||
|
|
permission := &domain.Permission{
|
|||
|
|
Name: req.Name,
|
|||
|
|
Code: req.Code,
|
|||
|
|
Type: domain.PermissionType(req.Type),
|
|||
|
|
Description: req.Description,
|
|||
|
|
ParentID: req.ParentID,
|
|||
|
|
Level: 1,
|
|||
|
|
Path: req.Path,
|
|||
|
|
Method: req.Method,
|
|||
|
|
Sort: req.Sort,
|
|||
|
|
Icon: req.Icon,
|
|||
|
|
Status: domain.PermissionStatusEnabled,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if req.ParentID != nil {
|
|||
|
|
parent, err := s.permRepo.FindByID(ctx, *req.ParentID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("查找父权限失败: %w", err)
|
|||
|
|
}
|
|||
|
|
permission.Level = parent.Level + 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := s.permRepo.Create(ctx, permission); err != nil {
|
|||
|
|
return nil, fmt.Errorf("创建权限失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 清除缓存
|
|||
|
|
s.cache.Delete(ctx, cache.GenerateKey("permissions", "list"))
|
|||
|
|
|
|||
|
|
return permission, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetPermissionByID 根据ID获取权限
|
|||
|
|
func (s *PermissionService) GetPermissionByID(ctx context.Context, id int64) (*domain.Permission, error) {
|
|||
|
|
key := cache.GenerateKey("permissions", id)
|
|||
|
|
|
|||
|
|
var permission domain.Permission
|
|||
|
|
err := s.cache.GetOrLoad(ctx, key, &permission, func() (interface{}, error) {
|
|||
|
|
return s.permRepo.FindByID(ctx, id)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return &permission, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListPermissions 分页获取权限列表
|
|||
|
|
func (s *PermissionService) ListPermissions(ctx context.Context, req *ListPermissionsRequest) (*ListPermissionsResponse, error) {
|
|||
|
|
// 尝试从缓存获取(仅第一页)
|
|||
|
|
if req.Page == 1 {
|
|||
|
|
cacheKey := cache.GenerateKey("permissions", "list")
|
|||
|
|
var cachedResp ListPermissionsResponse
|
|||
|
|
if err := s.cache.Get(ctx, cacheKey, &cachedResp); err == nil {
|
|||
|
|
return &cachedResp, nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 从数据库查询
|
|||
|
|
permissions, total, err := s.permRepo.List(ctx, req)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("查询权限列表失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resp := &ListPermissionsResponse{
|
|||
|
|
Permissions: permissions,
|
|||
|
|
Total: total,
|
|||
|
|
Page: req.Page,
|
|||
|
|
PageSize: req.PageSize,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 缓存第一页结果
|
|||
|
|
if req.Page == 1 {
|
|||
|
|
s.cache.Set(ctx, cache.GenerateKey("permissions", "list"), resp)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return resp, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CheckPermission 检查用户是否有权限
|
|||
|
|
func (s *PermissionService) CheckPermission(ctx context.Context, userID int64, permissionCode string) (bool, error) {
|
|||
|
|
cacheKey := cache.GenerateKey("users", userID, "permissions", permissionCode)
|
|||
|
|
|
|||
|
|
var hasPermission bool
|
|||
|
|
err := s.cache.GetOrLoad(ctx, cacheKey, &hasPermission, func() (interface{}, error) {
|
|||
|
|
permissions, err := s.permRepo.FindByUserID(ctx, userID)
|
|||
|
|
if err != nil {
|
|||
|
|
return false, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, perm := range permissions {
|
|||
|
|
if perm.Code == permissionCode {
|
|||
|
|
return true, nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return false, nil
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if err != nil {
|
|||
|
|
return false, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return hasPermission, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 请求和响应结构体
|
|||
|
|
type CreatePermissionRequest struct {
|
|||
|
|
Name string `json:"name" binding:"required,max=50"`
|
|||
|
|
Code string `json:"code" binding:"required,max=100"`
|
|||
|
|
Type int `json:"type" binding:"required,min=0,max=2"`
|
|||
|
|
Description string `json:"description" binding:"omitempty,max=200"`
|
|||
|
|
ParentID *int64 `json:"parent_id"`
|
|||
|
|
Path string `json:"path" binding:"omitempty,max=200"`
|
|||
|
|
Method string `json:"method" binding:"omitempty,max=10"`
|
|||
|
|
Sort int `json:"sort"`
|
|||
|
|
Icon string `json:"icon" binding:"omitempty,max=50"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type ListPermissionsRequest struct {
|
|||
|
|
Page int `json:"page" binding:"required,min=1"`
|
|||
|
|
PageSize int `json:"page_size" binding:"required,min=1,max=100"`
|
|||
|
|
Type *int `json:"type"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type ListPermissionsResponse struct {
|
|||
|
|
Permissions []*domain.Permission `json:"permissions"`
|
|||
|
|
Total int64 `json:"total"`
|
|||
|
|
Page int `json:"page"`
|
|||
|
|
PageSize int `json:"page_size"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 错误定义
|
|||
|
|
var (
|
|||
|
|
ErrPermissionCodeExists = fmt.Errorf("权限代码已存在")
|
|||
|
|
ErrPermissionNotFound = fmt.Errorf("权限不存在")
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.5 数据访问层实现
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/repository/user.go
|
|||
|
|
package repository
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
|
|||
|
|
"gorm.io/gorm"
|
|||
|
|
|
|||
|
|
"github.com/user-management-system/internal/domain"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// UserRepository 用户仓储
|
|||
|
|
type UserRepository interface {
|
|||
|
|
Create(ctx context.Context, user *domain.User) error
|
|||
|
|
Update(ctx context.Context, user *domain.User) error
|
|||
|
|
Delete(ctx context.Context, id int64) error
|
|||
|
|
FindByID(ctx context.Context, id int64) (*domain.User, error)
|
|||
|
|
FindByUsername(ctx context.Context, username string) (*domain.User, error)
|
|||
|
|
FindByEmail(ctx context.Context, email string) (*domain.User, error)
|
|||
|
|
FindByPhone(ctx context.Context, phone string) (*domain.User, error)
|
|||
|
|
ExistsByUsername(ctx context.Context, username string) (bool, error)
|
|||
|
|
ExistsByEmail(ctx context.Context, email string) (bool, error)
|
|||
|
|
ExistsByPhone(ctx context.Context, phone string) (bool, error)
|
|||
|
|
List(ctx context.Context, req *ListUsersRequest) ([]*domain.User, int64, error)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type userRepository struct {
|
|||
|
|
db *gorm.DB
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func NewUserRepository(db *gorm.DB) UserRepository {
|
|||
|
|
return &userRepository{db: db}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
|
|||
|
|
return r.db.WithContext(ctx).Create(user).Error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
|
|||
|
|
return r.db.WithContext(ctx).Save(user).Error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *userRepository) Delete(ctx context.Context, id int64) error {
|
|||
|
|
return r.db.WithContext(ctx).Delete(&domain.User{}, id).Error
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *userRepository) FindByID(ctx context.Context, id int64) (*domain.User, error) {
|
|||
|
|
var user domain.User
|
|||
|
|
err := r.db.WithContext(ctx).First(&user, id).Error
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return &user, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *userRepository) FindByUsername(ctx context.Context, username string) (*domain.User, error) {
|
|||
|
|
var user domain.User
|
|||
|
|
err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return &user, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *userRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
|||
|
|
var user domain.User
|
|||
|
|
err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return &user, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *userRepository) FindByPhone(ctx context.Context, phone string) (*domain.User, error) {
|
|||
|
|
var user domain.User
|
|||
|
|
err := r.db.WithContext(ctx).Where("phone = ?", phone).First(&user).Error
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return &user, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *userRepository) ExistsByUsername(ctx context.Context, username string) (bool, error) {
|
|||
|
|
var count int64
|
|||
|
|
err := r.db.WithContext(ctx).Model(&domain.User{}).Where("username = ?", username).Count(&count).Error
|
|||
|
|
return count > 0, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
|||
|
|
var count int64
|
|||
|
|
err := r.db.WithContext(ctx).Model(&domain.User{}).Where("email = ?", email).Count(&count).Error
|
|||
|
|
return count > 0, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *userRepository) ExistsByPhone(ctx context.Context, phone string) (bool, error) {
|
|||
|
|
var count int64
|
|||
|
|
err := r.db.WithContext(ctx).Model(&domain.User{}).Where("phone = ?", phone).Count(&count).Error
|
|||
|
|
return count > 0, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (r *userRepository) List(ctx context.Context, req *ListUsersRequest) ([]*domain.User, int64, error) {
|
|||
|
|
var users []*domain.User
|
|||
|
|
var total int64
|
|||
|
|
|
|||
|
|
query := r.db.WithContext(ctx).Model(&domain.User{})
|
|||
|
|
|
|||
|
|
// 关键字搜索
|
|||
|
|
if req.Keyword != "" {
|
|||
|
|
query = query.Where("username LIKE ? OR nickname LIKE ? OR email LIKE ?",
|
|||
|
|
"%"+req.Keyword+"%", "%"+req.Keyword+"%", "%"+req.Keyword+"%")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 状态筛选
|
|||
|
|
if req.Status != nil {
|
|||
|
|
query = query.Where("status = ?", *req.Status)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 统计总数
|
|||
|
|
if err := query.Count(&total).Error; err != nil {
|
|||
|
|
return nil, 0, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 分页查询
|
|||
|
|
offset := (req.Page - 1) * req.PageSize
|
|||
|
|
err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&users).Error
|
|||
|
|
|
|||
|
|
return users, total, err
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.6 任务清单
|
|||
|
|
|
|||
|
|
- [ ] 实现用户服务 `UserService`
|
|||
|
|
- [ ] 实现认证服务 `AuthService`
|
|||
|
|
- [ ] 实现角色服务 `RoleService`
|
|||
|
|
- [ ] 实现权限服务 `PermissionService`
|
|||
|
|
- [ ] 实现设备服务 `DeviceService`
|
|||
|
|
- [ ] 实现用户仓储 `UserRepository`
|
|||
|
|
- [ ] 实现角色仓储 `RoleRepository`
|
|||
|
|
- [ ] 实现权限仓储 `PermissionRepository`
|
|||
|
|
- [ ] 编写服务层单元测试
|
|||
|
|
- [ ] 编写仓储层单元测试
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 阶段5:API层实现(第9-10周)
|
|||
|
|
|
|||
|
|
**目标**:实现所有API接口
|
|||
|
|
|
|||
|
|
#### 5.1 中间件实现
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/api/middleware/auth.go
|
|||
|
|
package middleware
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"net/http"
|
|||
|
|
"strings"
|
|||
|
|
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
|
|||
|
|
"github.com/user-management-system/internal/service"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// AuthMiddleware 认证中间件
|
|||
|
|
func AuthMiddleware(authService service.AuthService) gin.HandlerFunc {
|
|||
|
|
return func(c *gin.Context) {
|
|||
|
|
// 1. 获取Token
|
|||
|
|
authHeader := c.GetHeader("Authorization")
|
|||
|
|
if authHeader == "" {
|
|||
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "缺少认证令牌"})
|
|||
|
|
c.Abort()
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 解析Bearer Token
|
|||
|
|
parts := strings.Split(authHeader, " ")
|
|||
|
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
|||
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "认证令牌格式错误"})
|
|||
|
|
c.Abort()
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
token := parts[1]
|
|||
|
|
|
|||
|
|
// 3. 验证Token
|
|||
|
|
claims, err := authService.ParseToken(token)
|
|||
|
|
if err != nil {
|
|||
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "认证令牌无效"})
|
|||
|
|
c.Abort()
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. 检查Token类型
|
|||
|
|
if claims.Type != "access" {
|
|||
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "令牌类型错误"})
|
|||
|
|
c.Abort()
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 5. 将用户信息存入上下文
|
|||
|
|
c.Set("user_id", claims.UserID)
|
|||
|
|
c.Set("username", claims.Username)
|
|||
|
|
|
|||
|
|
c.Next()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// PermissionMiddleware 权限中间件
|
|||
|
|
func PermissionMiddleware(permService service.PermissionService, permissionCode string) gin.HandlerFunc {
|
|||
|
|
return func(c *gin.Context) {
|
|||
|
|
userID, exists := c.Get("user_id")
|
|||
|
|
if !exists {
|
|||
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
|||
|
|
c.Abort()
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
hasPermission, err := permService.CheckPermission(c, userID.(int64), permissionCode)
|
|||
|
|
if err != nil {
|
|||
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "权限检查失败"})
|
|||
|
|
c.Abort()
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if !hasPermission {
|
|||
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权限访问"})
|
|||
|
|
c.Abort()
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.Next()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/api/middleware/ratelimit.go
|
|||
|
|
package middleware
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"net/http"
|
|||
|
|
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
|
|||
|
|
"github.com/user-management-system/internal/security"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// RateLimitMiddleware 限流中间件
|
|||
|
|
func RateLimitMiddleware(limiter *security.RateLimiter) gin.HandlerFunc {
|
|||
|
|
return func(c *gin.Context) {
|
|||
|
|
key := c.ClientIP()
|
|||
|
|
|
|||
|
|
allowed, err := limiter.Allow(c, key)
|
|||
|
|
if err != nil {
|
|||
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "限流检查失败"})
|
|||
|
|
c.Abort()
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if !allowed {
|
|||
|
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
|||
|
|
"error": "请求过于频繁,请稍后再试",
|
|||
|
|
})
|
|||
|
|
c.Abort()
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.Next()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/api/middleware/cors.go
|
|||
|
|
package middleware
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// CORSMiddleware 跨域中间件
|
|||
|
|
func CORSMiddleware() gin.HandlerFunc {
|
|||
|
|
return func(c *gin.Context) {
|
|||
|
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
|||
|
|
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
|||
|
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
|||
|
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
|
|||
|
|
|
|||
|
|
if c.Request.Method == "OPTIONS" {
|
|||
|
|
c.AbortWithStatus(204)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.Next()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/api/middleware/logger.go
|
|||
|
|
package middleware
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
"go.uber.org/zap"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// LoggerMiddleware 日志中间件
|
|||
|
|
func LoggerMiddleware(logger *zap.Logger) gin.HandlerFunc {
|
|||
|
|
return func(c *gin.Context) {
|
|||
|
|
start := time.Now()
|
|||
|
|
path := c.Request.URL.Path
|
|||
|
|
query := c.Request.URL.RawQuery
|
|||
|
|
|
|||
|
|
c.Next()
|
|||
|
|
|
|||
|
|
cost := time.Since(start)
|
|||
|
|
logger.Info("HTTP Request",
|
|||
|
|
zap.String("method", c.Request.Method),
|
|||
|
|
zap.String("path", path),
|
|||
|
|
zap.String("query", query),
|
|||
|
|
zap.Int("status", c.Writer.Status()),
|
|||
|
|
zap.Duration("cost", cost),
|
|||
|
|
zap.String("ip", c.ClientIP()),
|
|||
|
|
zap.String("user-agent", c.Request.UserAgent()),
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 5.2 处理器实现
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/api/handler/user.go
|
|||
|
|
package handler
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"net/http"
|
|||
|
|
"strconv"
|
|||
|
|
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
|
|||
|
|
"github.com/user-management-system/internal/service"
|
|||
|
|
"github.com/user-management-system/pkg/response"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// UserHandler 用户处理器
|
|||
|
|
type UserHandler struct {
|
|||
|
|
userService *service.UserService
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewUserHandler 创建用户处理器
|
|||
|
|
func NewUserHandler(userService *service.UserService) *UserHandler {
|
|||
|
|
return &UserHandler{userService: userService}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Register 用户注册
|
|||
|
|
// @Summary 用户注册
|
|||
|
|
// @Description 创建新用户
|
|||
|
|
// @Tags 用户管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param request body service.RegisterRequest true "注册信息"
|
|||
|
|
// @Success 200 {object} response.Response{data=domain.User}
|
|||
|
|
// @Router /api/v1/users/register [post]
|
|||
|
|
func (h *UserHandler) Register(c *gin.Context) {
|
|||
|
|
var req service.RegisterRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "参数错误", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
user, err := h.userService.Register(c, &req)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "注册失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, user)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Login 用户登录
|
|||
|
|
// @Summary 用户登录
|
|||
|
|
// @Description 用户登录获取访问令牌
|
|||
|
|
// @Tags 认证
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param request body service.LoginRequest true "登录信息"
|
|||
|
|
// @Success 200 {object} response.Response{data=service.LoginResponse}
|
|||
|
|
// @Router /api/v1/auth/login [post]
|
|||
|
|
func (h *UserHandler) Login(c *gin.Context) {
|
|||
|
|
var req service.LoginRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "参数错误", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取设备信息
|
|||
|
|
device := &service.DeviceInfo{
|
|||
|
|
DeviceID: c.GetHeader("X-Device-ID"),
|
|||
|
|
DeviceName: c.GetHeader("X-Device-Name"),
|
|||
|
|
DeviceOS: c.GetHeader("X-Device-OS"),
|
|||
|
|
DeviceBrowser: c.GetHeader("User-Agent"),
|
|||
|
|
IP: c.ClientIP(),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resp, err := h.userService.Login(c, &req, device)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusUnauthorized, "登录失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, resp)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetUser 获取当前用户信息
|
|||
|
|
// @Summary 获取当前用户
|
|||
|
|
// @Description 获取当前登录用户的信息
|
|||
|
|
// @Tags 用户管理
|
|||
|
|
// @Produce json
|
|||
|
|
// @Success 200 {object} response.Response{data=domain.User}
|
|||
|
|
// @Router /api/v1/users/me [get]
|
|||
|
|
func (h *UserHandler) GetUser(c *gin.Context) {
|
|||
|
|
userID, _ := c.Get("user_id")
|
|||
|
|
|
|||
|
|
user, err := h.userService.GetUserByID(c, userID.(int64))
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusNotFound, "用户不存在", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, user)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetUserByID 根据ID获取用户
|
|||
|
|
// @Summary 获取用户
|
|||
|
|
// @Description 根据ID获取用户信息
|
|||
|
|
// @Tags 用户管理
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path int true "用户ID"
|
|||
|
|
// @Success 200 {object} response.Response{data=domain.User}
|
|||
|
|
// @Router /api/v1/users/{id} [get]
|
|||
|
|
func (h *UserHandler) GetUserByID(c *gin.Context) {
|
|||
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "无效的用户ID", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
user, err := h.userService.GetUserByID(c, id)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusNotFound, "用户不存在", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, user)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateUser 更新用户
|
|||
|
|
// @Summary 更新用户
|
|||
|
|
// @Description 更新用户信息
|
|||
|
|
// @Tags 用户管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path int true "用户ID"
|
|||
|
|
// @Param request body service.UpdateUserRequest true "更新信息"
|
|||
|
|
// @Success 200 {object} response.Response{data=domain.User}
|
|||
|
|
// @Router /api/v1/users/{id} [put]
|
|||
|
|
func (h *UserHandler) UpdateUser(c *gin.Context) {
|
|||
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "无效的用户ID", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var req service.UpdateUserRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "参数错误", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
user, err := h.userService.UpdateUser(c, id, &req)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "更新失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, user)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ChangePassword 修改密码
|
|||
|
|
// @Summary 修改密码
|
|||
|
|
// @Description 修改用户密码
|
|||
|
|
// @Tags 用户管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path int true "用户ID"
|
|||
|
|
// @Param request body ChangePasswordRequest true "密码信息"
|
|||
|
|
// @Success 200 {object} response.Response
|
|||
|
|
// @Router /api/v1/users/{id}/password [put]
|
|||
|
|
func (h *UserHandler) ChangePassword(c *gin.Context) {
|
|||
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "无效的用户ID", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var req ChangePasswordRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "参数错误", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := h.userService.ChangePassword(c, id, req.OldPassword, req.NewPassword); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "修改密码失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, nil)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListUsers 获取用户列表
|
|||
|
|
// @Summary 用户列表
|
|||
|
|
// @Description 分页获取用户列表
|
|||
|
|
// @Tags 用户管理
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param page query int true "页码"
|
|||
|
|
// @Param page_size query int true "每页数量"
|
|||
|
|
// @Param keyword query string false "搜索关键字"
|
|||
|
|
// @Param status query int false "状态"
|
|||
|
|
// @Success 200 {object} response.Response{data=service.ListUsersResponse}
|
|||
|
|
// @Router /api/v1/users [get]
|
|||
|
|
func (h *UserHandler) ListUsers(c *gin.Context) {
|
|||
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|||
|
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|||
|
|
|
|||
|
|
var status *int
|
|||
|
|
if statusStr := c.Query("status"); statusStr != "" {
|
|||
|
|
s, _ := strconv.Atoi(statusStr)
|
|||
|
|
status = &s
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
req := &service.ListUsersRequest{
|
|||
|
|
Page: page,
|
|||
|
|
PageSize: pageSize,
|
|||
|
|
Keyword: c.Query("keyword"),
|
|||
|
|
Status: status,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resp, err := h.userService.ListUsers(c, req)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusInternalServerError, "查询失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, resp)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DeleteUser 删除用户
|
|||
|
|
// @Summary 删除用户
|
|||
|
|
// @Description 删除用户(软删除)
|
|||
|
|
// @Tags 用户管理
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path int true "用户ID"
|
|||
|
|
// @Success 200 {object} response.Response
|
|||
|
|
// @Router /api/v1/users/{id} [delete]
|
|||
|
|
func (h *UserHandler) DeleteUser(c *gin.Context) {
|
|||
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "无效的用户ID", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := h.userService.DeleteUser(c, id); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "删除失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, nil)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetUserRoles 获取用户角色
|
|||
|
|
// @Summary 获取用户角色
|
|||
|
|
// @Description 获取用户的角色列表
|
|||
|
|
// @Tags 用户管理
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path int true "用户ID"
|
|||
|
|
// @Success 200 {object} response.Response{data=[]domain.Role}
|
|||
|
|
// @Router /api/v1/users/{id}/roles [get]
|
|||
|
|
func (h *UserHandler) GetUserRoles(c *gin.Context) {
|
|||
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "无效的用户ID", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
roles, err := h.userService.GetUserRoles(c, id)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusInternalServerError, "查询失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, roles)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetUserPermissions 获取用户权限
|
|||
|
|
// @Summary 获取用户权限
|
|||
|
|
// @Description 获取用户的权限列表
|
|||
|
|
// @Tags 用户管理
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path int true "用户ID"
|
|||
|
|
// @Success 200 {object} response.Response{data=[]domain.Permission}
|
|||
|
|
// @Router /api/v1/users/{id}/permissions [get]
|
|||
|
|
func (h *UserHandler) GetUserPermissions(c *gin.Context) {
|
|||
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "无效的用户ID", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
permissions, err := h.userService.GetUserPermissions(c, id)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusInternalServerError, "查询失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, permissions)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// AssignRole 分配角色
|
|||
|
|
// @Summary 分配角色
|
|||
|
|
// @Description 为用户分配角色
|
|||
|
|
// @Tags 用户管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path int true "用户ID"
|
|||
|
|
// @Param request body AssignRoleRequest true "角色信息"
|
|||
|
|
// @Success 200 {object} response.Response
|
|||
|
|
// @Router /api/v1/users/{id}/roles [post]
|
|||
|
|
func (h *UserHandler) AssignRole(c *gin.Context) {
|
|||
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "无效的用户ID", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var req AssignRoleRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "参数错误", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := h.userService.AssignRole(c, id, req.RoleID); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "分配角色失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, nil)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// RevokeRole 撤销角色
|
|||
|
|
// @Summary 撤销角色
|
|||
|
|
// @Description 撤销用户的角色
|
|||
|
|
// @Tags 用户管理
|
|||
|
|
// @Accept json
|
|||
|
|
// @Produce json
|
|||
|
|
// @Param id path int true "用户ID"
|
|||
|
|
// @Param request body AssignRoleRequest true "角色信息"
|
|||
|
|
// @Success 200 {object} response.Response
|
|||
|
|
// @Router /api/v1/users/{id}/roles [delete]
|
|||
|
|
func (h *UserHandler) RevokeRole(c *gin.Context) {
|
|||
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "无效的用户ID", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var req AssignRoleRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "参数错误", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := h.userService.RevokeRole(c, id, req.RoleID); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "撤销角色失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, nil)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 请求结构体
|
|||
|
|
type ChangePasswordRequest struct {
|
|||
|
|
OldPassword string `json:"old_password" binding:"required"`
|
|||
|
|
NewPassword string `json:"new_password" binding:"required,min=8"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type AssignRoleRequest struct {
|
|||
|
|
RoleID int64 `json:"role_id" binding:"required"`
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/api/handler/role.go
|
|||
|
|
package handler
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"net/http"
|
|||
|
|
"strconv"
|
|||
|
|
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
|
|||
|
|
"github.com/user-management-system/internal/service"
|
|||
|
|
"github.com/user-management-system/pkg/response"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// RoleHandler 角色处理器
|
|||
|
|
type RoleHandler struct {
|
|||
|
|
roleService *service.RoleService
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewRoleHandler 创建角色处理器
|
|||
|
|
func NewRoleHandler(roleService *service.RoleService) *RoleHandler {
|
|||
|
|
return &RoleHandler{roleService: roleService}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CreateRole 创建角色
|
|||
|
|
func (h *RoleHandler) CreateRole(c *gin.Context) {
|
|||
|
|
var req service.CreateRoleRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "参数错误", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
role, err := h.roleService.CreateRole(c, &req)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "创建角色失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, role)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetRoleByID 获取角色
|
|||
|
|
func (h *RoleHandler) GetRoleByID(c *gin.Context) {
|
|||
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "无效的角色ID", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
role, err := h.roleService.GetRoleByID(c, id)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusNotFound, "角色不存在", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, role)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UpdateRole 更新角色
|
|||
|
|
func (h *RoleHandler) UpdateRole(c *gin.Context) {
|
|||
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "无效的角色ID", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var req service.UpdateRoleRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "参数错误", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
role, err := h.roleService.UpdateRole(c, id, &req)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "更新角色失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, role)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DeleteRole 删除角色
|
|||
|
|
func (h *RoleHandler) DeleteRole(c *gin.Context) {
|
|||
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "无效的角色ID", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := h.roleService.DeleteRole(c, id); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "删除角色失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, nil)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListRoles 角色列表
|
|||
|
|
func (h *RoleHandler) ListRoles(c *gin.Context) {
|
|||
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|||
|
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|||
|
|
|
|||
|
|
var status *int
|
|||
|
|
if statusStr := c.Query("status"); statusStr != "" {
|
|||
|
|
s, _ := strconv.Atoi(statusStr)
|
|||
|
|
status = &s
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
req := &service.ListRolesRequest{
|
|||
|
|
Page: page,
|
|||
|
|
PageSize: pageSize,
|
|||
|
|
Status: status,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resp, err := h.roleService.ListRoles(c, req)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusInternalServerError, "查询失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, resp)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// GetRolePermissions 获取角色权限
|
|||
|
|
func (h *RoleHandler) GetRolePermissions(c *gin.Context) {
|
|||
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "无效的角色ID", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
permissions, err := h.roleService.GetRolePermissions(c, id)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusInternalServerError, "查询失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, permissions)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// AssignPermission 分配权限
|
|||
|
|
func (h *RoleHandler) AssignPermission(c *gin.Context) {
|
|||
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "无效的角色ID", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var req AssignPermissionRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "参数错误", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := h.roleService.AssignPermission(c, id, req.PermissionID); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "分配权限失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, nil)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// RevokePermission 撤销权限
|
|||
|
|
func (h *RoleHandler) RevokePermission(c *gin.Context) {
|
|||
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|||
|
|
if err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "无效的角色ID", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var req AssignPermissionRequest
|
|||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "参数错误", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := h.roleService.RevokePermission(c, id, req.PermissionID); err != nil {
|
|||
|
|
response.Error(c, http.StatusBadRequest, "撤销权限失败", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
response.Success(c, nil)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type AssignPermissionRequest struct {
|
|||
|
|
PermissionID int64 `json:"permission_id" binding:"required"`
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 5.3 路由定义
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/api/router/router.go
|
|||
|
|
package router
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
|
|||
|
|
"github.com/user-management-system/internal/api/handler"
|
|||
|
|
"github.com/user-management-system/internal/api/middleware"
|
|||
|
|
"github.com/user-management-system/internal/monitoring"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// SetupRouter 设置路由
|
|||
|
|
func SetupRouter(
|
|||
|
|
userHandler *handler.UserHandler,
|
|||
|
|
roleHandler *handler.RoleHandler,
|
|||
|
|
permHandler *handler.PermissionHandler,
|
|||
|
|
authMiddleware gin.HandlerFunc,
|
|||
|
|
permissionMiddleware gin.HandlerFunc,
|
|||
|
|
rateLimitMiddleware gin.HandlerFunc,
|
|||
|
|
corsMiddleware gin.HandlerFunc,
|
|||
|
|
loggerMiddleware gin.HandlerFunc,
|
|||
|
|
healthHandler *monitoring.HealthHandler,
|
|||
|
|
) *gin.Engine {
|
|||
|
|
r := gin.New()
|
|||
|
|
|
|||
|
|
// 全局中间件
|
|||
|
|
r.Use(corsMiddleware())
|
|||
|
|
r.Use(loggerMiddleware)
|
|||
|
|
r.Use(gin.Recovery())
|
|||
|
|
|
|||
|
|
// 健康检查
|
|||
|
|
r.GET("/health", healthHandler.Check)
|
|||
|
|
|
|||
|
|
// API路由组
|
|||
|
|
api := r.Group("/api/v1")
|
|||
|
|
{
|
|||
|
|
// 公开接口(无需认证)
|
|||
|
|
public := api.Group("")
|
|||
|
|
{
|
|||
|
|
public.POST("/auth/login", rateLimitMiddleware, userHandler.Login)
|
|||
|
|
public.POST("/auth/register", rateLimitMiddleware, userHandler.Register)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 需要认证的接口
|
|||
|
|
auth := api.Group("", authMiddleware)
|
|||
|
|
{
|
|||
|
|
// 用户管理
|
|||
|
|
users := auth.Group("/users")
|
|||
|
|
{
|
|||
|
|
users.GET("/me", userHandler.GetUser)
|
|||
|
|
users.GET("/:id", userHandler.GetUserByID)
|
|||
|
|
users.PUT("/:id", userHandler.UpdateUser)
|
|||
|
|
users.DELETE("/:id", permissionMiddleware, userHandler.DeleteUser)
|
|||
|
|
users.PUT("/:id/password", userHandler.ChangePassword)
|
|||
|
|
users.GET("", userHandler.ListUsers)
|
|||
|
|
users.GET("/:id/roles", userHandler.GetUserRoles)
|
|||
|
|
users.GET("/:id/permissions", userHandler.GetUserPermissions)
|
|||
|
|
users.POST("/:id/roles", userHandler.AssignRole)
|
|||
|
|
users.DELETE("/:id/roles", userHandler.RevokeRole)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 角色管理
|
|||
|
|
roles := auth.Group("/roles")
|
|||
|
|
{
|
|||
|
|
roles.POST("", permissionMiddleware, roleHandler.CreateRole)
|
|||
|
|
roles.GET("/:id", roleHandler.GetRoleByID)
|
|||
|
|
roles.PUT("/:id", permissionMiddleware, roleHandler.UpdateRole)
|
|||
|
|
roles.DELETE("/:id", permissionMiddleware, roleHandler.DeleteRole)
|
|||
|
|
roles.GET("", roleHandler.ListRoles)
|
|||
|
|
roles.GET("/:id/permissions", roleHandler.GetRolePermissions)
|
|||
|
|
roles.POST("/:id/permissions", permissionMiddleware, roleHandler.AssignPermission)
|
|||
|
|
roles.DELETE("/:id/permissions", permissionMiddleware, roleHandler.RevokePermission)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 权限管理
|
|||
|
|
permissions := auth.Group("/permissions")
|
|||
|
|
{
|
|||
|
|
permissions.POST("", permissionMiddleware, permHandler.CreatePermission)
|
|||
|
|
permissions.GET("/:id", permHandler.GetPermissionByID)
|
|||
|
|
permissions.PUT("/:id", permissionMiddleware, permHandler.UpdatePermission)
|
|||
|
|
permissions.DELETE("/:id", permissionMiddleware, permHandler.DeletePermission)
|
|||
|
|
permissions.GET("", permHandler.ListPermissions)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return r
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 5.4 任务清单
|
|||
|
|
|
|||
|
|
- [ ] 实现认证中间件
|
|||
|
|
- [ ] 实现权限中间件
|
|||
|
|
- [ ] 实现限流中间件
|
|||
|
|
- [ ] 实现CORS中间件
|
|||
|
|
- [ ] 实现日志中间件
|
|||
|
|
- [ ] 实现用户处理器
|
|||
|
|
- [ ] 实现角色处理器
|
|||
|
|
- [ ] 实现权限处理器
|
|||
|
|
- [ ] 定义路由
|
|||
|
|
- [ ] 集成Swagger文档
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 阶段6:安全组件实现(第11周)
|
|||
|
|
|
|||
|
|
**目标**:实现限流、加密、验证等安全组件
|
|||
|
|
|
|||
|
|
#### 6.1 限流组件
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/security/ratelimit.go
|
|||
|
|
package security
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"fmt"
|
|||
|
|
"sync"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/redis/go-redis/v9"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// RateLimiter 限流器接口
|
|||
|
|
type RateLimiter interface {
|
|||
|
|
Allow(ctx context.Context, key string) (bool, error)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// TokenBucketLimiter 令牌桶限流器
|
|||
|
|
type TokenBucketLimiter struct {
|
|||
|
|
redis *redis.Client
|
|||
|
|
capacity int64
|
|||
|
|
rate int64 // tokens per second
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func NewTokenBucketLimiter(redis *redis.Client, capacity, rate int64) *TokenBucketLimiter {
|
|||
|
|
return &TokenBucketLimiter{
|
|||
|
|
redis: redis,
|
|||
|
|
capacity: capacity,
|
|||
|
|
rate: rate,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (l *TokenBucketLimiter) Allow(ctx context.Context, key string) (bool, error) {
|
|||
|
|
now := time.Now().Unix()
|
|||
|
|
key = fmt.Sprintf("rate_limit:token_bucket:%s", key)
|
|||
|
|
|
|||
|
|
pipe := l.redis.Pipeline()
|
|||
|
|
|
|||
|
|
tokensCmd := pipe.Get(ctx, key)
|
|||
|
|
lastRefillCmd := pipe.Get(ctx, key+":last_refill")
|
|||
|
|
|
|||
|
|
_, err := pipe.Exec(ctx)
|
|||
|
|
if err != nil && err != redis.Nil {
|
|||
|
|
return false, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var tokens float64
|
|||
|
|
if err := tokensCmd.Err(); err == nil {
|
|||
|
|
tokens, _ = tokensCmd.Float64()
|
|||
|
|
} else {
|
|||
|
|
tokens = float64(l.capacity)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var lastRefill int64
|
|||
|
|
if err := lastRefillCmd.Err(); err == nil {
|
|||
|
|
lastRefill, _ = lastRefillCmd.Int64()
|
|||
|
|
} else {
|
|||
|
|
lastRefill = now
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 计算需要补充的令牌
|
|||
|
|
elapsedTime := now - lastRefill
|
|||
|
|
refillTokens := float64(elapsedTime) * float64(l.rate)
|
|||
|
|
|
|||
|
|
tokens += refillTokens
|
|||
|
|
if tokens > float64(l.capacity) {
|
|||
|
|
tokens = float64(l.capacity)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 尝试消费一个令牌
|
|||
|
|
if tokens >= 1 {
|
|||
|
|
tokens -= 1
|
|||
|
|
|
|||
|
|
// 更新Redis
|
|||
|
|
pipe := l.redis.Pipeline()
|
|||
|
|
pipe.Set(ctx, key, tokens, 2*time.Second)
|
|||
|
|
pipe.Set(ctx, key+":last_refill", now, 2*time.Second)
|
|||
|
|
pipe.Exec(ctx)
|
|||
|
|
|
|||
|
|
return true, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return false, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SlidingWindowLimiter 滑动窗口限流器
|
|||
|
|
type SlidingWindowLimiter struct {
|
|||
|
|
redis *redis.Client
|
|||
|
|
capacity int64
|
|||
|
|
window time.Duration
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func NewSlidingWindowLimiter(redis *redis.Client, capacity int64, window time.Duration) *SlidingWindowLimiter {
|
|||
|
|
return &SlidingWindowLimiter{
|
|||
|
|
redis: redis,
|
|||
|
|
capacity: capacity,
|
|||
|
|
window: window,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (l *SlidingWindowLimiter) Allow(ctx context.Context, key string) (bool, error) {
|
|||
|
|
now := time.Now().UnixMicro()
|
|||
|
|
windowStart := now - l.window.Microseconds()
|
|||
|
|
|
|||
|
|
key = fmt.Sprintf("rate_limit:sliding_window:%s", key)
|
|||
|
|
|
|||
|
|
// 移除窗口外的数据
|
|||
|
|
l.redis.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))
|
|||
|
|
|
|||
|
|
// 获取当前窗口内请求数
|
|||
|
|
count, err := l.redis.ZCard(ctx, key).Result()
|
|||
|
|
if err != nil {
|
|||
|
|
return false, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if count >= l.capacity {
|
|||
|
|
return false, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 添加当前请求
|
|||
|
|
l.redis.ZAdd(ctx, key, redis.Z{
|
|||
|
|
Score: float64(now),
|
|||
|
|
Member: now,
|
|||
|
|
})
|
|||
|
|
l.redis.Expire(ctx, key, l.window)
|
|||
|
|
|
|||
|
|
return true, nil
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 6.2 加密组件
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/security/encryption.go
|
|||
|
|
package security
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"crypto/aes"
|
|||
|
|
"crypto/cipher"
|
|||
|
|
"crypto/rand"
|
|||
|
|
"encoding/base64"
|
|||
|
|
"errors"
|
|||
|
|
"io"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// AESEncrypt AES加密
|
|||
|
|
func AESEncrypt(plaintext, key []byte) ([]byte, error) {
|
|||
|
|
block, err := aes.NewCipher(key)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 使用GCM模式
|
|||
|
|
gcm, err := cipher.NewGCM(block)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
nonce := make([]byte, gcm.NonceSize())
|
|||
|
|
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
|||
|
|
return ciphertext, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// AESDecrypt AES解密
|
|||
|
|
func AESDecrypt(ciphertext, key []byte) ([]byte, error) {
|
|||
|
|
block, err := aes.NewCipher(key)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
gcm, err := cipher.NewGCM(block)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
nonceSize := gcm.NonceSize()
|
|||
|
|
if len(ciphertext) < nonceSize {
|
|||
|
|
return nil, errors.New("ciphertext too short")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
|||
|
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return plaintext, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// EncryptString 加密字符串
|
|||
|
|
func EncryptString(plaintext string, key string) (string, error) {
|
|||
|
|
keyBytes := []byte(key)
|
|||
|
|
plaintextBytes := []byte(plaintext)
|
|||
|
|
|
|||
|
|
ciphertext, err := AESEncrypt(plaintextBytes, keyBytes)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DecryptString 解密字符串
|
|||
|
|
func DecryptString(ciphertext string, key string) (string, error) {
|
|||
|
|
keyBytes := []byte(key)
|
|||
|
|
ciphertextBytes, err := base64.StdEncoding.DecodeString(ciphertext)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
plaintext, err := AESDecrypt(ciphertextBytes, keyBytes)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return string(plaintext), nil
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 6.3 任务清单
|
|||
|
|
|
|||
|
|
- [ ] 实现令牌桶限流器
|
|||
|
|
- [ ] 实现滑动窗口限流器
|
|||
|
|
- [ ] 实现漏桶限流器
|
|||
|
|
- [ ] 实现AES加密组件
|
|||
|
|
- [ ] 实现验证器组件
|
|||
|
|
- [ ] 编写安全组件单元测试
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 阶段7:监控和可观测性实现(第12周)
|
|||
|
|
|
|||
|
|
**目标**:实现Prometheus监控、链路追踪、健康检查
|
|||
|
|
|
|||
|
|
#### 7.1 Prometheus监控
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/monitoring/metrics.go
|
|||
|
|
package monitoring
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"github.com/prometheus/client_golang/prometheus"
|
|||
|
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
var (
|
|||
|
|
// HTTP请求总数
|
|||
|
|
HTTPRequestsTotal = promauto.NewCounterVec(
|
|||
|
|
prometheus.CounterOpts{
|
|||
|
|
Name: "http_requests_total",
|
|||
|
|
Help: "Total number of HTTP requests",
|
|||
|
|
},
|
|||
|
|
[]string{"method", "path", "status"},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// HTTP请求延迟
|
|||
|
|
HTTPRequestDuration = promauto.NewHistogramVec(
|
|||
|
|
prometheus.HistogramOpts{
|
|||
|
|
Name: "http_request_duration_seconds",
|
|||
|
|
Help: "HTTP request latency in seconds",
|
|||
|
|
Buckets: prometheus.DefBuckets,
|
|||
|
|
},
|
|||
|
|
[]string{"method", "path"},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 缓存命中率
|
|||
|
|
CacheHitRate = promauto.NewGaugeVec(
|
|||
|
|
prometheus.GaugeOpts{
|
|||
|
|
Name: "cache_hit_rate",
|
|||
|
|
Help: "Cache hit rate percentage",
|
|||
|
|
},
|
|||
|
|
[]string{"cache_level"}, // l1, l2
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 数据库查询数量
|
|||
|
|
DBQueriesTotal = promauto.NewCounterVec(
|
|||
|
|
prometheus.CounterOpts{
|
|||
|
|
Name: "db_queries_total",
|
|||
|
|
Help: "Total number of database queries",
|
|||
|
|
},
|
|||
|
|
[]string{"operation", "table"},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 数据库查询延迟
|
|||
|
|
DBQueryDuration = promauto.NewHistogramVec(
|
|||
|
|
prometheus.HistogramOpts{
|
|||
|
|
Name: "db_query_duration_seconds",
|
|||
|
|
Help: "Database query latency in seconds",
|
|||
|
|
Buckets: prometheus.DefBuckets,
|
|||
|
|
},
|
|||
|
|
[]string{"operation", "table"},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 在线用户数
|
|||
|
|
OnlineUsers = promauto.NewGauge(
|
|||
|
|
prometheus.GaugeOpts{
|
|||
|
|
Name: "online_users",
|
|||
|
|
Help: "Number of online users",
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 活跃设备数
|
|||
|
|
ActiveDevices = promauto.NewGauge(
|
|||
|
|
prometheus.GaugeOpts{
|
|||
|
|
Name: "active_devices",
|
|||
|
|
Help: "Number of active devices",
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// RecordHTTPRequest 记录HTTP请求
|
|||
|
|
func RecordHTTPRequest(method, path string, status int, duration float64) {
|
|||
|
|
HTTPRequestsTotal.WithLabelValues(method, path, string(rune(status))).Inc()
|
|||
|
|
HTTPRequestDuration.WithLabelValues(method, path).Observe(duration)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// RecordCacheHitRate 记录缓存命中率
|
|||
|
|
func RecordCacheHitRate(level string, rate float64) {
|
|||
|
|
CacheHitRate.WithLabelValues(level).Set(rate)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// RecordDBQuery 记录数据库查询
|
|||
|
|
func RecordDBQuery(operation, table string, duration float64) {
|
|||
|
|
DBQueriesTotal.WithLabelValues(operation, table).Inc()
|
|||
|
|
DBQueryDuration.WithLabelValues(operation, table).Observe(duration)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SetOnlineUsers 设置在线用户数
|
|||
|
|
func SetOnlineUsers(count float64) {
|
|||
|
|
OnlineUsers.Set(count)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SetActiveDevices 设置活跃设备数
|
|||
|
|
func SetActiveDevices(count float64) {
|
|||
|
|
ActiveDevices.Set(count)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 7.2 健康检查
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// internal/monitoring/health.go
|
|||
|
|
package monitoring
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"net/http"
|
|||
|
|
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
|
|||
|
|
"github.com/user-management-system/internal/cache"
|
|||
|
|
"github.com/user-management-system/internal/database"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// HealthHandler 健康检查处理器
|
|||
|
|
type HealthHandler struct {
|
|||
|
|
db *database.DB
|
|||
|
|
cache *cache.CacheManager
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewHealthHandler 创建健康检查处理器
|
|||
|
|
func NewHealthHandler(db *database.DB, cache *cache.CacheManager) *HealthHandler {
|
|||
|
|
return &HealthHandler{
|
|||
|
|
db: db,
|
|||
|
|
cache: cache,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// HealthStatus 健康状态
|
|||
|
|
type HealthStatus struct {
|
|||
|
|
Status string `json:"status"`
|
|||
|
|
Database DatabaseHealth `json:"database"`
|
|||
|
|
Redis RedisHealth `json:"redis,omitempty"`
|
|||
|
|
Cache CacheHealth `json:"cache"`
|
|||
|
|
Version string `json:"version"`
|
|||
|
|
Timestamp string `json:"timestamp"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type DatabaseHealth struct {
|
|||
|
|
Status string `json:"status"`
|
|||
|
|
Type string `json:"type"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type RedisHealth struct {
|
|||
|
|
Status string `json:"status"`
|
|||
|
|
Mode string `json:"mode"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type CacheHealth struct {
|
|||
|
|
L1Cache L1CacheHealth `json:"l1_cache"`
|
|||
|
|
L2Cache L2CacheHealth `json:"l2_cache"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type L1CacheHealth struct {
|
|||
|
|
Status string `json:"status"`
|
|||
|
|
Items int `json:"items"`
|
|||
|
|
HitRate float64 `json:"hit_rate"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type L2CacheHealth struct {
|
|||
|
|
Status string `json:"status"`
|
|||
|
|
Enabled bool `json:"enabled"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check 健康检查
|
|||
|
|
func (h *HealthHandler) Check(c *gin.Context) {
|
|||
|
|
health := h.getHealthStatus()
|
|||
|
|
|
|||
|
|
if health.Status == "DOWN" {
|
|||
|
|
c.JSON(http.StatusServiceUnavailable, health)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
c.JSON(http.StatusOK, health)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (h *HealthHandler) getHealthStatus() *HealthStatus {
|
|||
|
|
now := time.Now().Format(time.RFC3339)
|
|||
|
|
|
|||
|
|
// 检查数据库
|
|||
|
|
dbStatus := "UP"
|
|||
|
|
dbType := "sqlite"
|
|||
|
|
if sqlDB, err := h.db.DB(); err == nil {
|
|||
|
|
if err := sqlDB.Ping(); err != nil {
|
|||
|
|
dbStatus = "DOWN"
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
dbStatus = "DOWN"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查Redis
|
|||
|
|
redisStatus := "UP"
|
|||
|
|
redisMode := "standalone"
|
|||
|
|
// TODO: 实现Redis健康检查
|
|||
|
|
|
|||
|
|
// 获取缓存统计
|
|||
|
|
stats := h.cache.GetStats()
|
|||
|
|
|
|||
|
|
cacheHealth := CacheHealth{
|
|||
|
|
L1Cache: L1CacheHealth{
|
|||
|
|
Status: "UP",
|
|||
|
|
Items: stats.L1Size,
|
|||
|
|
HitRate: stats.HitRate,
|
|||
|
|
},
|
|||
|
|
L2Cache: L2CacheHealth{
|
|||
|
|
Status: redisStatus,
|
|||
|
|
Enabled: h.cache.IsL2Enabled(),
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
status := "UP"
|
|||
|
|
if dbStatus == "DOWN" {
|
|||
|
|
status = "DOWN"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return &HealthStatus{
|
|||
|
|
Status: status,
|
|||
|
|
Database: DatabaseHealth{
|
|||
|
|
Status: dbStatus,
|
|||
|
|
Type: dbType,
|
|||
|
|
},
|
|||
|
|
Redis: RedisHealth{
|
|||
|
|
Status: redisStatus,
|
|||
|
|
Mode: redisMode,
|
|||
|
|
},
|
|||
|
|
Cache: cacheHealth,
|
|||
|
|
Version: "1.0.0",
|
|||
|
|
Timestamp: now,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 7.3 任务清单
|
|||
|
|
|
|||
|
|
- [ ] 实现Prometheus指标收集
|
|||
|
|
- [ ] 实现健康检查接口
|
|||
|
|
- [ ] 集成OpenTelemetry链路追踪
|
|||
|
|
- [ ] 配置Prometheus告警规则
|
|||
|
|
- [ ] 配置Grafana仪表板
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 阶段8:部署和运维(第13周)
|
|||
|
|
|
|||
|
|
**目标**:实现Docker部署、Kubernetes部署、自动化脚本
|
|||
|
|
|
|||
|
|
#### 8.1 Docker部署
|
|||
|
|
|
|||
|
|
**Dockerfile**
|
|||
|
|
```dockerfile
|
|||
|
|
FROM golang:1.23-alpine AS builder
|
|||
|
|
|
|||
|
|
WORKDIR /app
|
|||
|
|
|
|||
|
|
# 复制依赖文件
|
|||
|
|
COPY go.mod go.sum ./
|
|||
|
|
RUN go mod download
|
|||
|
|
|
|||
|
|
# 复制源代码
|
|||
|
|
COPY . .
|
|||
|
|
|
|||
|
|
# 构建应用
|
|||
|
|
RUN CGO_ENABLED=1 GOOS=linux go build -o user-management-system cmd/server/main.go
|
|||
|
|
|
|||
|
|
FROM alpine:latest
|
|||
|
|
|
|||
|
|
RUN apk --no-cache add ca-certificates tzdata
|
|||
|
|
|
|||
|
|
WORKDIR /app
|
|||
|
|
|
|||
|
|
# 复制可执行文件
|
|||
|
|
COPY --from=builder /app/user-management-system .
|
|||
|
|
COPY --from=builder /app/configs ./configs
|
|||
|
|
|
|||
|
|
# 创建必要目录
|
|||
|
|
RUN mkdir -p data logs
|
|||
|
|
|
|||
|
|
# 设置时区
|
|||
|
|
ENV TZ=Asia/Shanghai
|
|||
|
|
|
|||
|
|
EXPOSE 8080
|
|||
|
|
|
|||
|
|
CMD ["./user-management-system"]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**docker-compose.yml**
|
|||
|
|
```yaml
|
|||
|
|
version: '3.8'
|
|||
|
|
|
|||
|
|
services:
|
|||
|
|
user-management:
|
|||
|
|
image: user-management-system:latest
|
|||
|
|
container_name: user-ms
|
|||
|
|
ports:
|
|||
|
|
- "8080:8080"
|
|||
|
|
volumes:
|
|||
|
|
- ./data:/app/data
|
|||
|
|
- ./logs:/app/logs
|
|||
|
|
- ./configs:/app/configs
|
|||
|
|
environment:
|
|||
|
|
- SPRING_PROFILES_ACTIVE=docker
|
|||
|
|
- DATABASE_TYPE=sqlite
|
|||
|
|
- DATABASE_PATH=/app/data/user_management.db
|
|||
|
|
- REDIS_ENABLED=true
|
|||
|
|
- REDIS_ADDR=redis:6379
|
|||
|
|
restart: unless-stopped
|
|||
|
|
healthcheck:
|
|||
|
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
|
|||
|
|
interval: 30s
|
|||
|
|
timeout: 10s
|
|||
|
|
retries: 3
|
|||
|
|
start_period: 40s
|
|||
|
|
depends_on:
|
|||
|
|
- redis
|
|||
|
|
|
|||
|
|
redis:
|
|||
|
|
image: redis:7-alpine
|
|||
|
|
container_name: user-ms-redis
|
|||
|
|
ports:
|
|||
|
|
- "6379:6379"
|
|||
|
|
volumes:
|
|||
|
|
- redis-data:/data
|
|||
|
|
restart: unless-stopped
|
|||
|
|
healthcheck:
|
|||
|
|
test: ["CMD", "redis-cli", "ping"]
|
|||
|
|
interval: 10s
|
|||
|
|
timeout: 5s
|
|||
|
|
retries: 5
|
|||
|
|
|
|||
|
|
prometheus:
|
|||
|
|
image: prom/prometheus:latest
|
|||
|
|
container_name: user-ms-prometheus
|
|||
|
|
ports:
|
|||
|
|
- "9090:9090"
|
|||
|
|
volumes:
|
|||
|
|
- ./deployments/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
|||
|
|
- prometheus-data:/prometheus
|
|||
|
|
command:
|
|||
|
|
- '--config.file=/etc/prometheus/prometheus.yml'
|
|||
|
|
- '--storage.tsdb.path=/prometheus'
|
|||
|
|
restart: unless-stopped
|
|||
|
|
|
|||
|
|
grafana:
|
|||
|
|
image: grafana/grafana:latest
|
|||
|
|
container_name: user-ms-grafana
|
|||
|
|
ports:
|
|||
|
|
- "3000:3000"
|
|||
|
|
volumes:
|
|||
|
|
- grafana-data:/var/lib/grafana
|
|||
|
|
environment:
|
|||
|
|
- GF_SECURITY_ADMIN_PASSWORD=admin
|
|||
|
|
restart: unless-stopped
|
|||
|
|
|
|||
|
|
volumes:
|
|||
|
|
redis-data:
|
|||
|
|
prometheus-data:
|
|||
|
|
grafana-data:
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 8.2 自动化脚本
|
|||
|
|
|
|||
|
|
**scripts/start.sh**
|
|||
|
|
```bash
|
|||
|
|
#!/bin/bash
|
|||
|
|
|
|||
|
|
APP_NAME="user-management-system"
|
|||
|
|
APP_DIR="/opt/user-management-system"
|
|||
|
|
LOG_DIR="$APP_DIR/logs"
|
|||
|
|
PID_FILE="$LOG_DIR/$APP_NAME.pid"
|
|||
|
|
|
|||
|
|
# 启动函数
|
|||
|
|
start() {
|
|||
|
|
if [ -f "$PID_FILE" ]; then
|
|||
|
|
PID=$(cat $PID_FILE)
|
|||
|
|
if ps -p $PID > /dev/null; then
|
|||
|
|
echo "$APP_NAME is already running (PID: $PID)"
|
|||
|
|
return 1
|
|||
|
|
else
|
|||
|
|
rm -f $PID_FILE
|
|||
|
|
fi
|
|||
|
|
fi
|
|||
|
|
|
|||
|
|
echo "Starting $APP_NAME..."
|
|||
|
|
cd $APP_DIR
|
|||
|
|
nohup ./bin/user-management-system > $LOG_DIR/app.log 2>&1 &
|
|||
|
|
echo $! > $PID_FILE
|
|||
|
|
|
|||
|
|
sleep 2
|
|||
|
|
|
|||
|
|
if ps -p $(cat $PID_FILE) > /dev/null; then
|
|||
|
|
echo "$APP_NAME started successfully (PID: $(cat $PID_FILE))"
|
|||
|
|
return 0
|
|||
|
|
else
|
|||
|
|
echo "$APP_NAME failed to start"
|
|||
|
|
rm -f $PID_FILE
|
|||
|
|
return 1
|
|||
|
|
fi
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 停止函数
|
|||
|
|
stop() {
|
|||
|
|
if [ ! -f "$PID_FILE" ]; then
|
|||
|
|
echo "$APP_NAME is not running"
|
|||
|
|
return 1
|
|||
|
|
fi
|
|||
|
|
|
|||
|
|
PID=$(cat $PID_FILE)
|
|||
|
|
echo "Stopping $APP_NAME (PID: $PID)..."
|
|||
|
|
|
|||
|
|
kill $PID
|
|||
|
|
sleep 2
|
|||
|
|
|
|||
|
|
if ps -p $PID > /dev/null; then
|
|||
|
|
echo "Force killing $APP_NAME..."
|
|||
|
|
kill -9 $PID
|
|||
|
|
fi
|
|||
|
|
|
|||
|
|
rm -f $PID_FILE
|
|||
|
|
echo "$APP_NAME stopped"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 重启函数
|
|||
|
|
restart() {
|
|||
|
|
stop
|
|||
|
|
sleep 1
|
|||
|
|
start
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 状态函数
|
|||
|
|
status() {
|
|||
|
|
if [ ! -f "$PID_FILE" ]; then
|
|||
|
|
echo "$APP_NAME is not running"
|
|||
|
|
return 1
|
|||
|
|
fi
|
|||
|
|
|
|||
|
|
PID=$(cat $PID_FILE)
|
|||
|
|
if ps -p $PID > /dev/null; then
|
|||
|
|
echo "$APP_NAME is running (PID: $PID)"
|
|||
|
|
return 0
|
|||
|
|
else
|
|||
|
|
echo "$APP_NAME is not running (stale PID file)"
|
|||
|
|
rm -f $PID_FILE
|
|||
|
|
return 1
|
|||
|
|
fi
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
case "$1" in
|
|||
|
|
start)
|
|||
|
|
start
|
|||
|
|
;;
|
|||
|
|
stop)
|
|||
|
|
stop
|
|||
|
|
;;
|
|||
|
|
restart)
|
|||
|
|
restart
|
|||
|
|
;;
|
|||
|
|
status)
|
|||
|
|
status
|
|||
|
|
;;
|
|||
|
|
*)
|
|||
|
|
echo "Usage: $0 {start|stop|restart|status}"
|
|||
|
|
exit 1
|
|||
|
|
esac
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**scripts/backup.sh**
|
|||
|
|
```bash
|
|||
|
|
#!/bin/bash
|
|||
|
|
|
|||
|
|
BACKUP_DIR="/backup/user-management"
|
|||
|
|
DATA_DIR="/opt/user-management-system/data"
|
|||
|
|
DATE=$(date +%Y%m%d_%H%M%S)
|
|||
|
|
RETENTION_DAYS=30
|
|||
|
|
|
|||
|
|
# 创建备份目录
|
|||
|
|
mkdir -p $BACKUP_DIR
|
|||
|
|
|
|||
|
|
# 备份SQLite数据库
|
|||
|
|
if [ -f "$DATA_DIR/user_management.db" ]; then
|
|||
|
|
echo "Backing up database..."
|
|||
|
|
cp "$DATA_DIR/user_management.db" "$BACKUP_DIR/user_management_$DATE.db"
|
|||
|
|
|
|||
|
|
# 压缩备份
|
|||
|
|
gzip "$BACKUP_DIR/user_management_$DATE.db"
|
|||
|
|
echo "Backup completed: $BACKUP_DIR/user_management_$DATE.db.gz"
|
|||
|
|
else
|
|||
|
|
echo "Warning: Database file not found"
|
|||
|
|
exit 1
|
|||
|
|
fi
|
|||
|
|
|
|||
|
|
# 备份配置文件
|
|||
|
|
echo "Backing up configuration..."
|
|||
|
|
tar -czf "$BACKUP_DIR/config_$DATE.tar.gz" /opt/user-management-system/configs/
|
|||
|
|
|
|||
|
|
# 删除过期备份
|
|||
|
|
echo "Cleaning up old backups (older than $RETENTION_DAYS days)..."
|
|||
|
|
find $BACKUP_DIR -name "*.db.gz" -mtime +$RETENTION_DAYS -delete
|
|||
|
|
find $BACKUP_DIR -name "config_*.tar.gz" -mtime +$RETENTION_DAYS -delete
|
|||
|
|
|
|||
|
|
echo "Backup task completed"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**scripts/health-check.sh**
|
|||
|
|
```bash
|
|||
|
|
#!/bin/bash
|
|||
|
|
|
|||
|
|
SERVER_URL="http://localhost:8080"
|
|||
|
|
HEALTH_ENDPOINT="/health"
|
|||
|
|
|
|||
|
|
check_health() {
|
|||
|
|
response=$(curl -s -o /dev/null -w "%{http_code}" ${SERVER_URL}${HEALTH_ENDPOINT})
|
|||
|
|
|
|||
|
|
if [ $response -eq 200 ]; then
|
|||
|
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Service health: HTTP $response"
|
|||
|
|
return 0
|
|||
|
|
else
|
|||
|
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Service unhealthy: HTTP $response"
|
|||
|
|
return 1
|
|||
|
|
fi
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 单次检查
|
|||
|
|
check_health
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 8.3 任务清单
|
|||
|
|
|
|||
|
|
- [ ] 创建Dockerfile
|
|||
|
|
- [ ] 创建docker-compose.yml
|
|||
|
|
- [ ] 创建Kubernetes部署文件
|
|||
|
|
- [ ] 创建Helm Charts
|
|||
|
|
- [ ] 实现启动脚本
|
|||
|
|
- [ ] 实现停止脚本
|
|||
|
|
- [ ] 实现重启脚本
|
|||
|
|
- [ ] 实现备份脚本
|
|||
|
|
- [ ] 实现健康检查脚本
|
|||
|
|
- [ ] 配置Logrotate
|
|||
|
|
- [ ] 配置Cron定时任务
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 阶段9:测试(第14-15周)
|
|||
|
|
|
|||
|
|
**目标**:完成单元测试、集成测试、性能测试
|
|||
|
|
|
|||
|
|
#### 9.1 测试策略
|
|||
|
|
|
|||
|
|
| 测试类型 | 覆盖范围 | 目标覆盖率 | 工具 |
|
|||
|
|
|---------|---------|-----------|------|
|
|||
|
|
| 单元测试 | 业务逻辑、工具函数 | 80%+ | Testify |
|
|||
|
|
| 集成测试 | API接口、数据库操作 | 70%+ | Testify |
|
|||
|
|
| 端到端测试 | 完整业务流程 | 60%+ | Testify + httptest |
|
|||
|
|
| 性能测试 | 并发、响应时间 | - | Vegeta / JMeter |
|
|||
|
|
| 压力测试 | 极限负载 | - | JMeter |
|
|||
|
|
|
|||
|
|
#### 9.2 任务清单
|
|||
|
|
|
|||
|
|
- [ ] 编写单元测试(domain、service、repository)
|
|||
|
|
- [ ] 编写集成测试(API接口)
|
|||
|
|
- [ ] 编写端到端测试(完整业务流程)
|
|||
|
|
- [ ] 执行性能测试(并发、响应时间)
|
|||
|
|
- [ ] 执行压力测试(极限负载)
|
|||
|
|
- [ ] 修复测试发现的问题
|
|||
|
|
- [ ] 优化性能瓶颈
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 阶段10:文档和交付(第16周)
|
|||
|
|
|
|||
|
|
**目标**:完善文档,准备交付
|
|||
|
|
|
|||
|
|
#### 10.1 文档完善
|
|||
|
|
|
|||
|
|
- [ ] 完善README.md
|
|||
|
|
- [ ] 完善API文档(Swagger)
|
|||
|
|
- [ ] 完善部署文档
|
|||
|
|
- [ ] 完善运维文档
|
|||
|
|
- [ ] 编写用户手册
|
|||
|
|
- [ ] 编写开发者文档
|
|||
|
|
|
|||
|
|
#### 10.2 交付准备
|
|||
|
|
|
|||
|
|
- [ ] 代码review
|
|||
|
|
- [ ] 安全扫描
|
|||
|
|
- [ ] 性能优化
|
|||
|
|
- [ ] 打包发布
|
|||
|
|
- [ ] 部署验证
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 质量保证
|
|||
|
|
|
|||
|
|
### 代码质量
|
|||
|
|
|
|||
|
|
- **代码审查**:所有代码必须经过至少一人审查
|
|||
|
|
- **单元测试覆盖率**:≥80%
|
|||
|
|
- **静态代码分析**:使用golangci-lint
|
|||
|
|
- **代码风格**:遵循Go官方代码风格
|
|||
|
|
|
|||
|
|
### 性能指标
|
|||
|
|
|
|||
|
|
| 指标 | 目标值 | 验证方法 |
|
|||
|
|
|------|--------|----------|
|
|||
|
|
| 并发用户数 | 100,000 | 性能测试 |
|
|||
|
|
| QPS | 100,000 | 性能测试 |
|
|||
|
|
| P50响应时间 | <100ms | 性能测试 |
|
|||
|
|
| P99响应时间 | <500ms | 性能测试 |
|
|||
|
|
| 缓存命中率 | >95% | 监控统计 |
|
|||
|
|
| 系统可用性 | 99.99% | 监控统计 |
|
|||
|
|
|
|||
|
|
### 安全要求
|
|||
|
|
|
|||
|
|
- [ ] 所有API接口都有认证
|
|||
|
|
- [ ] 敏感数据加密存储
|
|||
|
|
- [ ] 密码使用bcrypt加密
|
|||
|
|
- [ ] 实现接口防刷
|
|||
|
|
- [ ] 实现SQL注入防护
|
|||
|
|
- [ ] 实现XSS防护
|
|||
|
|
- [ ] 实现CSRF防护
|
|||
|
|
- [ ] 通过安全扫描
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 风险管理
|
|||
|
|
|
|||
|
|
### 技术风险
|
|||
|
|
|
|||
|
|
| 风险 | 可能性 | 影响 | 应对措施 |
|
|||
|
|
|------|--------|------|----------|
|
|||
|
|
| 性能不达标 | 中 | 高 | 提前性能测试,优化慢查询 |
|
|||
|
|
| 并发问题 | 中 | 高 | 充分测试,使用协程池 |
|
|||
|
|
| 安全漏洞 | 低 | 高 | 安全扫描,代码审查 |
|
|||
|
|
| 数据库性能 | 中 | 高 | 优化索引,使用缓存 |
|
|||
|
|
|
|||
|
|
### 进度风险
|
|||
|
|
|
|||
|
|
| 风险 | 可能性 | 影响 | 应对措施 |
|
|||
|
|
|------|--------|------|----------|
|
|||
|
|
| 需求变更 | 中 | 中 | 严格控制变更范围 |
|
|||
|
|
| 技术难点 | 低 | 高 | 提前调研,准备方案B |
|
|||
|
|
| 人员变动 | 低 | 中 | 代码文档化,知识共享 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 里程碑
|
|||
|
|
|
|||
|
|
| 里程碑 | 日期 | 交付物 |
|
|||
|
|
|--------|------|--------|
|
|||
|
|
| M1: 项目初始化完成 | 第2周 | 项目结构、配置文件、数据库迁移脚本 |
|
|||
|
|
| M2: 核心数据模型完成 | 第4周 | 所有模型定义、数据库表创建 |
|
|||
|
|
| M3: 缓存层完成 | 第5周 | L1缓存、L2缓存、缓存管理器 |
|
|||
|
|
| M4: 核心服务完成 | 第8周 | 用户、角色、权限、认证服务 |
|
|||
|
|
| M5: API层完成 | 第10周 | 所有API接口、中间件、路由 |
|
|||
|
|
| M6: 安全组件完成 | 第11周 | 限流、加密、验证组件 |
|
|||
|
|
| M7: 监控完成 | 第12周 | Prometheus指标、健康检查、链路追踪 |
|
|||
|
|
| M8: 部署完成 | 第13周 | Docker部署、K8s部署、自动化脚本 |
|
|||
|
|
| M9: 测试完成 | 第15周 | 单元测试、集成测试、性能测试 |
|
|||
|
|
| M10: 交付完成 | 第16周 | 完整文档、可交付产品 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 成功标准
|
|||
|
|
|
|||
|
|
### 功能完整性
|
|||
|
|
|
|||
|
|
- ✅ 100%实现PRD所有功能需求
|
|||
|
|
- ✅ 100%实现数据模型设计
|
|||
|
|
- ✅ 100%实现API接口设计
|
|||
|
|
- ✅ 100%实现安全设计
|
|||
|
|
- ✅ 100%实现部署和运维方案
|
|||
|
|
|
|||
|
|
### 性能达标
|
|||
|
|
|
|||
|
|
- ✅ 支持10亿用户规模
|
|||
|
|
- ✅ 支持10万级并发访问
|
|||
|
|
- ✅ P50响应时间 < 100ms
|
|||
|
|
- ✅ P99响应时间 < 500ms
|
|||
|
|
- ✅ 系统可用性 ≥ 99.99%
|
|||
|
|
|
|||
|
|
### 质量达标
|
|||
|
|
|
|||
|
|
- ✅ 单元测试覆盖率 ≥ 80%
|
|||
|
|
- ✅ 无安全漏洞
|
|||
|
|
- ✅ 代码审查通过
|
|||
|
|
- ✅ 文档完善
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 附录
|
|||
|
|
|
|||
|
|
### A. 参考文档
|
|||
|
|
|
|||
|
|
- PRD.md - 产品需求文档
|
|||
|
|
- DATA_MODEL.md - 数据模型设计
|
|||
|
|
- ARCHITECTURE.md - 技术架构文档
|
|||
|
|
- API.md - API接口设计
|
|||
|
|
- SECURITY.md - 安全设计文档
|
|||
|
|
- DEPLOYMENT.md - 部署和运维指南
|
|||
|
|
|
|||
|
|
### B. 技术栈
|
|||
|
|
|
|||
|
|
- Go 1.23+
|
|||
|
|
- Gin 1.10+
|
|||
|
|
- GORM 1.25+
|
|||
|
|
- SQLite 3.40+
|
|||
|
|
- PostgreSQL 14+
|
|||
|
|
- Redis 7.0+
|
|||
|
|
- Prometheus 2.50+
|
|||
|
|
- Docker 20.10+
|
|||
|
|
- Kubernetes 1.28+
|
|||
|
|
|
|||
|
|
### C. 联系方式
|
|||
|
|
|
|||
|
|
- 项目负责人:[待定]
|
|||
|
|
- 技术负责人:[待定]
|
|||
|
|
- 测试负责人:[待定]
|