53 Commits

Author SHA1 Message Date
52b61be822 删除误上传的.env 2026-01-21 22:07:24 +08:00
17a2792ac4 初步完成举报功能 2026-01-21 22:04:12 +08:00
68d7318285 初步完成举报功能 2026-01-21 21:34:11 +08:00
432b875ba4 皮肤部分拿apifox测过了 2026-01-20 11:50:24 +08:00
116612ffec 修改了皮肤收藏部分 2026-01-13 18:34:21 +08:00
lafay
3e8b7d150d chore: Refactor Dockerfile and build workflow for improved efficiency
All checks were successful
Build / build (push) Successful in 4m4s
Build / build-docker (push) Successful in 1m16s
- Removed the build stage from the Dockerfile, simplifying the image creation process.
- Updated the Dockerfile to directly copy the pre-built binary instead of using a multi-stage build.
- Modified the build workflow to eliminate unnecessary build arguments, streamlining the configuration.
2026-01-10 05:21:45 +08:00
lafay
fd5a0e8405 chore: Update Docker image tags in build workflow
Some checks failed
Build / build (push) Successful in 3m57s
Build / build-docker (push) Has been cancelled
- Changed Docker image tags in the build workflow to reflect the new repository owner.
- Updated the image references to use 'carrotskin' instead of the previous owner for consistency.
2026-01-10 05:12:51 +08:00
lafay
573c10ed1d chore: Remove Swagger documentation generation from build workflow
Some checks failed
Build / build (push) Successful in 4m0s
Build / build-docker (push) Failing after 41s
- Eliminated the Swagger documentation generation step from the build process.
- Updated the main server file to remove the Swagger documentation import, streamlining the codebase.
2026-01-10 05:00:09 +08:00
lafay
3b8d8bd7a7 chore: Add Swagger documentation generation to build workflow
Some checks failed
Build / build (push) Failing after 55s
Build / build-docker (push) Has been skipped
- Included a step to generate Swagger documentation during the build process.
- This addition enhances API documentation and ensures it is up-to-date with the codebase.
2026-01-10 04:57:56 +08:00
lafay
6338592d27 chore: Remove Go proxy setup from build workflow
Some checks failed
Build / build (push) Failing after 3m8s
Build / build-docker (push) Has been skipped
- Eliminated the Go proxy configuration from the build workflow to streamline the setup process.
- This change simplifies the environment setup for dependency management.
2026-01-10 04:47:14 +08:00
lafay
ef460ec891 chore: Disable caching in Go setup for build workflow
Some checks failed
Build / build (push) Failing after 1m31s
Build / build-docker (push) Has been skipped
- Updated the build workflow to disable caching for the Go setup action.
- This change aims to ensure a clean build environment for better consistency.
2026-01-10 04:42:22 +08:00
lafay
62d9432a2d chore: Add Go proxy setup to build workflow
Some checks failed
Build / build-docker (push) Has been cancelled
Build / build (push) Has been cancelled
- Configured Go proxy settings for improved dependency management.
- Disabled Go checksum database for local development environments.
2026-01-10 04:39:11 +08:00
lafay
e1d79ed445 chore: Update build workflow to include 'dev' branch
Some checks failed
Build / build (push) Failing after 21m16s
Build / build-docker (push) Has been skipped
- Added 'dev' branch to the push and pull_request triggers in the build workflow configuration.
- Ensured that CI/CD processes are aligned for both master and dev branches.
2026-01-10 04:01:20 +08:00
lafay
c5d7e317a4 refactor: Streamline user information retrieval and validation
- Refactored the user information retrieval process to improve efficiency.
- Enhanced validation logic for input parameters in the user handler.
- Updated UserService interface to support new retrieval methods.
- Improved error handling for user status checks before responding.
2026-01-10 03:58:22 +08:00
lafay
06539dc086 feat: Add public user information retrieval endpoint
- Introduced a new endpoint to fetch public user information without authentication.
- Implemented UserToPublicUserInfo function to format user data for the response.
- Updated UserService interface and user service implementation to support fetching users by username.
- Enhanced user handler to validate input parameters and check user status before responding.
2026-01-10 03:52:35 +08:00
lafay
22142db782 fix: Improve texture upload handling and caching logic
- Simplified caching logic by removing unnecessary nil check before setting cache.
- Enhanced error handling in texture upload process to return the original texture object if fetching the uploader information fails or returns nil.
2026-01-10 03:23:26 +08:00
lafay
2c9c6ecfc0 Merge branch 'dev' of https://code.littlelan.cn/CarrotSkin/backend into dev 2026-01-10 03:17:39 +08:00
lafay
c5db489d72 refactor: Enhance texture handling and configuration
- Removed Swagger documentation import from the main server file.
- Updated TextureInfo struct to include UploaderUsername for better texture metadata.
- Modified texture repository methods to preload Uploader information when fetching textures by hash.
- Improved texture service to handle cases where Uploader information is missing, ensuring proper caching and retrieval.
- Added Redis configuration options in the environment variable setup for better flexibility.
2026-01-10 03:15:27 +08:00
d952ddd4ea 增加环境变量是否成功读取的检查,并在控制台中打印日志 2026-01-10 01:58:46 +08:00
e761ff5be5 移除预签名url相关实现 2026-01-04 16:08:23 +08:00
9e83ae16af 更新readme 2025-12-26 21:32:32 +08:00
85a9463913 解决合并后出现的问题,为swagger提供禁用选项,暂时移除wiki 2025-12-26 01:15:17 +08:00
44f007936e Merge remote-tracking branch 'origin/feature/redis-auth-integration' into dev
# Conflicts:
#	go.mod
#	go.sum
#	internal/container/container.go
#	internal/repository/interfaces.go
#	internal/service/mocks_test.go
#	internal/service/texture_service_test.go
#	internal/service/token_service_test.go
#	pkg/redis/manager.go
2025-12-25 22:45:58 +08:00
lan
6ddcf92ce3 refactor: Remove Token management and integrate Redis for authentication
- Deleted the Token model and its repository, transitioning to a Redis-based token management system.
- Updated the service layer to utilize Redis for token storage, enhancing performance and scalability.
- Refactored the container to remove TokenRepository and integrate the new token service.
- Cleaned up the Dockerfile and other files by removing unnecessary whitespace and comments.
- Enhanced error handling and logging for Redis initialization and usage.
2025-12-24 16:03:46 +08:00
9b0a60033e 删除服务端材质渲染功能及system_config表,转为环境变量配置,初步配置管理员功能 2025-12-08 19:12:30 +08:00
399e6f096f 暂存服务端渲染功能,材质渲染计划迁移至前端 2025-12-08 17:40:28 +08:00
63ca7eff0d 统一文件上传方式为直接上传,更新环境变量示例 2025-12-08 15:40:28 +08:00
aa75691c49 完善服务端材质渲染(未测试),删除profile表中不必要的isActive字段及相关接口 2025-12-07 20:51:20 +08:00
lan
a51535a465 feat: Add texture rendering endpoints and service methods
- Introduced new API endpoints for rendering textures, avatars, capes, and previews, enhancing the texture handling capabilities.
- Implemented corresponding service methods in the TextureHandler to process rendering requests and return appropriate responses.
- Updated the TextureRenderService interface to include methods for rendering textures, avatars, and capes, along with their respective parameters.
- Enhanced error handling for invalid texture IDs and added support for different rendering types and formats.
- Updated go.mod to include the webp library for image processing.
2025-12-07 10:10:28 +08:00
lan
432c47d969 chore: Update database configuration and enhance error handling
- Changed database credentials in start.sh for testing purposes.
- Added environment variable for testing and allowed origins in start.sh.
- Improved error handling in yggdrasil_auth_service.go by checking for nil user before returning an error.
2025-12-04 22:35:03 +08:00
lan
8858fd1ede feat: Enhance texture upload functionality and API response format
- Introduced a new upload endpoint for direct texture file uploads, allowing users to upload textures with validation for size and format.
- Updated existing texture-related API responses to a standardized format, improving consistency across the application.
- Refactored texture service methods to handle file uploads and reuse existing texture URLs based on hash checks.
- Cleaned up Dockerfile and other files by removing unnecessary whitespace.
2025-12-04 20:07:30 +08:00
lan
0bcd9336c4 refactor: Update service and repository methods to use context
- Refactored multiple service and repository methods to accept context as a parameter, enhancing consistency and enabling better control over request lifecycles.
- Updated handlers to utilize context in method calls, improving error handling and performance.
- Cleaned up Dockerfile by removing unnecessary whitespace.
2025-12-03 15:27:12 +08:00
lan
4824a997dd feat: 增强令牌管理与客户端仓库集成
新增 ClientRepository 接口,用于管理客户端相关操作。
更新 Token 模型,加入版本号和过期时间字段,以提升令牌管理能力。
将 ClientRepo 集成到容器中,支持依赖注入。
重构 TokenService,采用 JWT 以增强安全性。
更新 Docker 配置,并清理多个文件中的空白字符。
2025-12-03 14:43:38 +08:00
lan
e873c58af9 refactor: 重构服务层和仓库层 2025-12-03 10:58:39 +08:00
lan
034e02e93a feat: Enhance dependency injection and service integration
- Updated main.go to initialize email service and include it in the dependency injection container.
- Refactored handlers to utilize context in service method calls, improving consistency and error handling.
- Introduced new service options for upload, security, and captcha services, enhancing modularity and testability.
- Removed unused repository implementations to streamline the codebase.

This commit continues the effort to improve the architecture by ensuring all services are properly injected and utilized across the application.
2025-12-02 22:52:33 +08:00
兰一民
792e96b238 Merge pull request 'feature/dependency-injection' (#1) from feature/dependency-injection into dev
Reviewed-on: #1
2025-12-02 19:49:44 +08:00
lafay
801f1b1397 refactor: Implement dependency injection for handlers and services
- Refactored AuthHandler, UserHandler, TextureHandler, ProfileHandler, CaptchaHandler, and YggdrasilHandler to use dependency injection.
- Removed direct instantiation of services and repositories within handlers, replacing them with constructor injection.
- Updated the container to initialize service instances and provide them to handlers.
- Enhanced code structure for better testability and adherence to Go best practices.
2025-12-02 19:47:04 +08:00
lan
188a05caa7 chore: Clean up code by removing trailing whitespace in multiple files 2025-12-02 18:41:34 +08:00
lan
e05ba3b041 feat: Service层接口化
新增Service接口定义(internal/service/interfaces.go):
- UserService: 用户认证、查询、更新等接口
- ProfileService: 档案CRUD、状态管理接口
- TextureService: 材质管理、收藏功能接口
- TokenService: 令牌生命周期管理接口
- VerificationService: 验证码服务接口
- CaptchaService: 滑动验证码接口
- UploadService: 上传服务接口
- YggdrasilService: Yggdrasil API接口

新增Service实现:
- user_service_impl.go: 用户服务实现
- profile_service_impl.go: 档案服务实现
- texture_service_impl.go: 材质服务实现
- token_service_impl.go: 令牌服务实现

更新Container:
- 添加Service层字段
- 初始化Service实例
- 添加With*Service选项函数

遵循Go最佳实践:
- 接口定义与实现分离
- 依赖通过构造函数注入
- 便于单元测试mock
2025-12-02 17:50:52 +08:00
lan
ffdc3e3e6b feat: 完善依赖注入改造
完成所有Handler的依赖注入改造:
- AuthHandler: 认证相关功能
- UserHandler: 用户管理功能
- TextureHandler: 材质管理功能
- ProfileHandler: 档案管理功能
- CaptchaHandler: 验证码功能
- YggdrasilHandler: Yggdrasil API功能

新增错误类型定义:
- internal/errors/errors.go: 统一的错误类型和工厂函数

更新main.go:
- 使用container.NewContainer创建依赖容器
- 使用handler.RegisterRoutesWithDI注册路由

代码遵循Go最佳实践:
- 依赖通过构造函数注入
- Handler通过结构体方法实现
- 统一的错误处理模式
- 清晰的分层架构
2025-12-02 17:46:00 +08:00
lan
f7589ebbb8 feat: 引入依赖注入模式
- 创建Repository接口定义(UserRepository、ProfileRepository、TextureRepository等)
- 创建Repository接口实现
- 创建依赖注入容器(container.Container)
- 改造Handler层使用依赖注入(AuthHandler、UserHandler、TextureHandler)
- 创建新的路由注册方式(RegisterRoutesWithDI)
- 提供main.go示例文件展示如何使用依赖注入

同时包含之前的安全修复:
- CORS配置安全加固
- 头像URL验证安全修复
- JWT algorithm confusion漏洞修复
- Recovery中间件增强
- 敏感错误信息泄露修复
- 类型断言安全修复
2025-12-02 17:40:39 +08:00
lan
373c61f625 add docker workflow
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m28s
2025-12-02 11:53:08 +08:00
lan
653acebe47 refactor: 更新Docker工作流,切换到Node基础镜像并优化依赖安装和构建输出
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m15s
2025-12-02 11:49:39 +08:00
lan
d45ca9afe2 refactor: 更新Docker工作流,切换到Alpine基础镜像并添加依赖安装步骤
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 10s
2025-12-02 11:47:51 +08:00
lan
71c8e1b9d2 refactor: 移除旧的Docker工作流,整合Kaniko构建流程并优化标签生成
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 9s
2025-12-02 11:46:32 +08:00
lan
79afaddeb3 feat: 添加Docker服务支持和等待机制,优化镜像构建流程 2025-12-02 11:42:01 +08:00
lan
394ae7c953 refactor: 优化Docker工作流,简化标签生成和镜像构建步骤 2025-12-02 11:38:38 +08:00
lan
23be1c563d refactor: 移除不必要的配置依赖,简化上传URL生成逻辑并添加公开访问URL支持 2025-12-02 11:22:14 +08:00
lan
13bab28926 feat: 增加登录和验证码验证失败次数限制,添加账号锁定机制
Some checks failed
SonarQube Analysis / sonarqube (push) Has been cancelled
2025-12-02 10:38:25 +08:00
lan
10fdcd916b feat: 添加种子数据初始化功能,重构多个处理程序以简化错误响应和用户验证 2025-12-02 10:33:19 +08:00
lafay
bdd2be5dc5 refactor: update user serialization in Yggdrasil handler to use SerializeUser for improved properties handling
Some checks failed
SonarQube Analysis / sonarqube (push) Has been cancelled
2025-11-30 19:00:59 +08:00
lafay
4188ee1555 feat: 添加Yggdrasil密码重置功能,更新依赖和配置 2025-11-30 18:56:56 +08:00
lan
a4b6c5011e chore(git): 更新.gitignore以忽略新的本地文件
Some checks failed
SonarQube Analysis / sonarqube (push) Has been cancelled
2025-11-30 08:33:17 +08:00
133 changed files with 14555 additions and 11634 deletions

75
.dockerignore Normal file
View File

@@ -0,0 +1,75 @@
# Git
.git
.gitignore
.gitea
# IDE
.vscode
.idea
*.swp
*.swo
# 构建产物
bin/
dist/
build/
server
*.exe
# 测试和覆盖率
*.test
coverage.out
coverage.html
coverage.txt
test_results/
test_coverage/
# 日志
*.log
logs/
log/
# 临时文件
tmp/
temp/
.tmp/
# 本地配置
.env
.env.local
.env.development
.env.test
.env.production
configs/config.yaml
# 文档 (可选保留)
# docs/
# 数据库文件
*.db
*.sqlite
*.sqlite3
# 备份
*.bak
*.backup
# OS 文件
.DS_Store
Thumbs.db
# Docker
docker-compose*.yml
Dockerfile*
!Dockerfile
# README 和脚本
README.md
*.sh
*.bat
scripts/
# 本地开发
local/
dev/
minio-data/

64
.env.docker.example Normal file
View File

@@ -0,0 +1,64 @@
# ==================== CarrotSkin Docker 环境配置示例 ====================
# 复制此文件为 .env 后修改配置值
# 此文件用于 docker-compose 部署,变量名与 docker-compose.yml 中的引用一致
# ==================== 站点配置 ====================
SITE_NAME=CarrotSkin
SITE_DESCRIPTION=一个优秀的Minecraft皮肤站
REGISTRATION_ENABLED=true
DEFAULT_AVATAR=
# ==================== 用户限制配置 ====================
MAX_TEXTURES_PER_USER=50
MAX_PROFILES_PER_USER=5
# ==================== 积分配置 ====================
CHECKIN_REWARD=10
TEXTURE_DOWNLOAD_REWARD=1
# ==================== 服务配置 ====================
# 应用对外端口
APP_PORT=8080
# 运行模式: debug, release, test
SERVER_MODE=release
# 是否启用 Swagger 文档: true, false
SERVER_SWAGGER_ENABLED=true
# ==================== 数据库配置 ====================
# 数据库密码,生产环境务必修改
DATABASE_PASSWORD=carrotskin123
# ==================== Redis 配置 ====================
# 留空表示不设置密码
REDIS_PASSWORD=
# ==================== JWT 配置 ====================
# 生产环境务必修改此密钥!
JWT_SECRET=your-super-secret-jwt-key-change-in-production
# ==================== 存储配置 (RustFS S3兼容) ====================
# 内部访问地址 (容器间通信)
RUSTFS_ENDPOINT=rustfs:9000
# 公开访问地址 (用于生成文件URL供外部浏览器访问)
# 示例: 直接访问 http://localhost:9000 或反向代理 https://example.com/storage
RUSTFS_PUBLIC_URL=http://localhost:9000
RUSTFS_ACCESS_KEY=rustfsadmin
RUSTFS_SECRET_KEY=rustfsadmin123
RUSTFS_USE_SSL=false
# 存储桶配置
RUSTFS_BUCKET_TEXTURES=carrot-skin-textures
RUSTFS_BUCKET_AVATARS=carrot-skin-avatars
# ==================== 安全配置 ====================
# CORS 允许的来源,多个用逗号分隔
SECURITY_ALLOWED_ORIGINS=*
# 允许的头像/材质URL域名多个用逗号分隔
SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1
# ==================== 邮件配置 ====================
EMAIL_ENABLED=false
EMAIL_SMTP_HOST=
EMAIL_SMTP_PORT=587
EMAIL_USERNAME=
EMAIL_PASSWORD=
EMAIL_FROM_NAME=CarrotSkin

View File

@@ -1,6 +1,26 @@
# CarrotSkin 环境配置文件示例
# 复制此文件为 .env 并修改相应的配置值
# =============================================================================
# 站点配置
# =============================================================================
SITE_NAME=CarrotSkin
SITE_DESCRIPTION=一个优秀的Minecraft皮肤站
REGISTRATION_ENABLED=true
DEFAULT_AVATAR=
# =============================================================================
# 用户限制配置
# =============================================================================
MAX_TEXTURES_PER_USER=50
MAX_PROFILES_PER_USER=5
# =============================================================================
# 积分配置
# =============================================================================
CHECKIN_REWARD=10
TEXTURE_DOWNLOAD_REWARD=1
# =============================================================================
# 服务器配置
# =============================================================================
@@ -8,6 +28,7 @@ SERVER_PORT=:8080
SERVER_MODE=debug
SERVER_READ_TIMEOUT=30s
SERVER_WRITE_TIMEOUT=30s
SERVER_SWAGGER_ENABLED=true
# =============================================================================
# 数据库配置
@@ -23,6 +44,7 @@ DATABASE_TIMEZONE=Asia/Shanghai
DATABASE_MAX_IDLE_CONNS=10
DATABASE_MAX_OPEN_CONNS=100
DATABASE_CONN_MAX_LIFETIME=1h
DATABASE_CONN_MAX_IDLE_TIME=10m
# =============================================================================
# Redis配置
@@ -37,6 +59,7 @@ REDIS_POOL_SIZE=10
# RustFS对象存储配置 (S3兼容)
# =============================================================================
RUSTFS_ENDPOINT=127.0.0.1:9000
RUSTFS_PUBLIC_URL=http://127.0.0.1:9000
RUSTFS_ACCESS_KEY=your_access_key
RUSTFS_SECRET_KEY=your_secret_key
RUSTFS_USE_SSL=false
@@ -55,26 +78,17 @@ JWT_EXPIRE_HOURS=168
LOG_LEVEL=info
LOG_FORMAT=json
LOG_OUTPUT=logs/app.log
LOG_MAX_SIZE=100
LOG_MAX_BACKUPS=3
LOG_MAX_AGE=28
LOG_COMPRESS=true
# =============================================================================
# 文件上传配置
# =============================================================================
UPLOAD_MAX_SIZE=10485760
UPLOAD_TEXTURE_MAX_SIZE=2097152
UPLOAD_AVATAR_MAX_SIZE=1048576
# =============================================================================
# 安全配置
# =============================================================================
MAX_LOGIN_ATTEMPTS=5
LOGIN_LOCK_DURATION=30m
# CORS 允许的来源,多个用逗号分隔
SECURITY_ALLOWED_ORIGINS=*
# 允许的头像/材质URL域名多个用逗号分隔
SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1
# =============================================================================
# 邮件配置(可选)
# 邮件配置
# 腾讯企业邮箱SSL配置示例smtp.exmail.qq.com, 端口465
# =============================================================================
EMAIL_ENABLED=false

View File

@@ -0,0 +1,73 @@
name: Build
on:
push:
branches:
- master
- dev
pull_request:
branches:
- master
- dev
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: false
- name: Download dependencies
run: go mod download
- name: Build
env:
GOOS: linux
GOARCH: amd64
CGO_ENABLED: 0
run: go build -v -o mcauth-linux-amd64 ./cmd/server
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: mcauth-linux-amd64
path: mcauth-linux-amd64
build-docker:
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: code.littlelan.cn
username: ${{ secrets.GIT_USERNAME }}
password: ${{ secrets.GIT_TOKEN }}
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: mcauth-linux-amd64
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
code.littlelan.cn/carrotskin/mcauth:latest
code.littlelan.cn/carrotskin/mcauth:${{ github.sha }}
platforms: linux/amd64

View File

@@ -1,43 +0,0 @@
name: SonarQube Analysis
on:
push:
pull_request:
jobs:
sonarqube:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Shallow clones should be disabled for better analysis
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Download and extract SonarQube Scanner
run: |
export SONAR_SCANNER_VERSION=7.2.0.5079
export SONAR_SCANNER_HOME=$HOME/.sonar/sonar-scanner-$SONAR_SCANNER_VERSION-linux-x64
curl --create-dirs -sSLo $HOME/.sonar/sonar-scanner.zip https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-$SONAR_SCANNER_VERSION-linux-x64.zip
unzip -o $HOME/.sonar/sonar-scanner.zip -d $HOME/.sonar/
export PATH=$SONAR_SCANNER_HOME/bin:$PATH
echo "SONAR_SCANNER_HOME=$SONAR_SCANNER_HOME" >> $GITHUB_ENV
echo "$SONAR_SCANNER_HOME/bin" >> $GITHUB_PATH
- name: Run SonarQube Scanner
env:
SONAR_TOKEN: sqp_b8a64837bd9e967b6876166e9ba27f0bc88626ed
run: |
export SONAR_SCANNER_VERSION=7.2.0.5079
export SONAR_SCANNER_HOME=$HOME/.sonar/sonar-scanner-$SONAR_SCANNER_VERSION-linux-x64
export PATH=$SONAR_SCANNER_HOME/bin:$PATH
sonar-scanner \
-Dsonar.projectKey=CarrotSkin \
-Dsonar.sources=. \
-Dsonar.host.url=https://sonar.littlelan.cn

View File

@@ -1,104 +0,0 @@
name: Test
on:
push:
branches:
- main
- master
- develop
- 'feature/**'
pull_request:
branches:
- main
- master
- develop
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache-dependency-path: go.sum
- name: Download dependencies
run: go mod download
- name: Run tests
run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
- name: Generate coverage report
run: |
go tool cover -html=coverage.out -o coverage.html
go tool cover -func=coverage.out -o coverage.txt
- name: Upload coverage reports
uses: actions/upload-artifact@v3
with:
name: coverage-reports
path: |
coverage.out
coverage.html
coverage.txt
- name: Display coverage summary
run: |
echo "## Test Coverage Summary" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
cat coverage.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache-dependency-path: go.sum
- name: Download dependencies
run: go mod download
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: latest
args: --timeout=5m
build:
runs-on: ubuntu-latest
needs: [test, lint]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache-dependency-path: go.sum
- name: Download dependencies
run: go mod download
- name: Build
run: go build -v -o server ./cmd/server
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: build-artifacts
path: server

9
.gitignore vendored
View File

@@ -23,7 +23,8 @@ dist/
build/
# Compiled binaries
server
server.exe
main.exe
# IDE files
.vscode/
@@ -59,7 +60,7 @@ configs/config.yaml
.env.production
# Keep example files
!.env.example
!.env
# Database files
*.db
@@ -105,3 +106,7 @@ minio-data/
# Local development files
local/
dev/
service_coverage
.gitignore
docs/
blessing skin材质渲染示例/

35
Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# 运行阶段
FROM alpine:latest
# 安装必要的运行时依赖
RUN apk add --no-cache ca-certificates tzdata wget
# 创建非 root 用户
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser
# 设置工作目录
WORKDIR /app
# 复制已经编译好的二进制文件
ARG BINARY_NAME=mcauth-linux-amd64
COPY ${BINARY_NAME} /app/server
# 复制配置文件(如果需要)
COPY configs/ /app/configs/
# 设置权限
RUN chown -R appuser:appuser /app
# 切换到非 root 用户
USER appuser
# 暴露端口
EXPOSE 8080
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
# 启动应用
ENTRYPOINT ["/app/server"]

646
README.md
View File

@@ -1,564 +1,160 @@
# CarrotSkin Backend
一个功能完善的Minecraft皮肤站后端系统,采用单体架构设计基于Go语言和Gin框架开发
一个功能完善的 Minecraft 皮肤站后端,基于 Go + Gin 构建,覆盖用户认证、材质管理、角色档案、审计日志等核心能力,并提供完整的 Swagger 文档与容器友好的环境变量配置
## ✨ 核心功能
## ✨ 功能亮点
- **用户认证系统** - 注册、登录、JWT认证、积分系统
- **邮箱验证系统** - 注册验证、找回密码、更换邮箱基于Redis的验证码
- **材质管理系统** - 皮肤/披风上传、搜索、收藏、下载统计
- **角色档案系统** - Minecraft角色创建、管理、RSA密钥生成
- **文件存储** - MinIO/RustFS对象存储集成、预签名URL上传
- **缓存系统** - Redis缓存、验证码存储、频率限制
- **权限管理** - Casbin RBAC权限控制
- **数据审计** - 登录日志、操作审计、下载记录
- **账号体系**:注册 / 登录 / JWT 鉴权 / Yggdrasil 密码同步 / 用户积分
- **邮箱验证码**:验证码发送频率控制、邮箱绑定与变更
- **材质中心**皮肤/披风上传、搜索、收藏、下载统计、Hash 去重
- **角色档案**Minecraft Profile 管理、RSA 密钥生成、活跃档案切换
- **存储与上传**RustFS/MinIO 预签名 URL,减轻服务器带宽压力
- **任务与日志**:登录日志、操作审计、材质下载记录、定时任务
- **权限体系**Casbin RBAC,支持细粒度路线授
- **配置管理**100% 依赖环境变量,`SERVER_SWAGGER_ENABLED` 控制 Swagger
- **可观测性**Zap 结构化日志、统一 API 响应模型
## 项目结构
## 🛠 技术栈
| 类型 | 选型 |
| --- | --- |
| 语言 / 运行时 | Go 1.24+ |
| Web 框架 | Gin |
| ORM | GORM (PostgreSQL 驱动) |
| 数据库 | PostgreSQL 15+ |
| 缓存 / 消息 | Redis 6+ |
| 对象存储 | RustFS / MinIOS3 兼容) |
| 权限控制 | Casbin |
| 配置 | Viper + `.env` |
| API 文档 | swaggo / Swagger UI |
| 日志 | Uber Zap |
## 📁 目录结构
```
backend/
├── cmd/ # 应用程序入口
│ └── server/ # 主服务器入口
└── main.go # 服务初始化、路由注册
├── internal/ # 私有应用代码
│ ├── handler/ # HTTP处理器函数式
│ ├── routes.go # 路由注册
│ ├── auth_handler.go
│ ├── user_handler.go
│ └── ...
│ ├── service/ # 业务逻辑服务(函数式
├── common.go # 公共声明jsoniter等
├── user_service.go
└── ...
│ ├── repository/ # 数据访问层(函数式)
├── user_repository.go
│ │ └── ...
│ ├── model/ # 数据模型GORM
│ ├── middleware/ # 中间件
│ └── types/ # 类型定义
├── pkg/ # 公共库代码
│ ├── auth/ # 认证授权
│ │ └── manager.go # JWT服务管理器
│ ├── config/ # 配置管理
│ │ └── manager.go # 配置管理器
│ ├── database/ # 数据库连接
│ │ ├── manager.go # 数据库管理器AutoMigrate
│ │ └── postgres.go # PostgreSQL连接
│ ├── email/ # 邮件服务
│ │ └── manager.go # 邮件服务管理器
│ ├── logger/ # 日志系统
│ │ └── manager.go # 日志管理器
│ ├── redis/ # Redis客户端
│ │ └── manager.go # Redis管理器
│ ├── storage/ # 文件存储(RustFS/MinIO)
│ │ └── manager.go # 存储管理器
│ ├── utils/ # 工具函数
│ └── validator/ # 数据验证
├── docs/ # API定义和文档Swagger
├── configs/ # 配置文件
│ └── casbin/ # Casbin权限配置
├── go.mod # Go模块依赖
├── go.sum # Go模块校验
├── start.sh # Linux/Mac启动脚本
├── .env # 环境变量配置
└── README.md # 项目说明
├── cmd/server/ # 应用入口main.go
├── internal/
├── handler/ # HTTP Handler 与 Swagger 注解
│ ├── service/ # 业务逻辑
│ ├── repository/ # 数据访问
│ ├── model/ # GORM 数据模型
│ ├── types/ # 请求/响应 DTO
│ ├── middleware/ # Gin 中间件
└── task/ # 定时任务与后台作业
├── pkg/ # 可复用组件config、database、auth、logger、redis、storage 等
├── docs/ # swagger 生成产物docs.go / swagger.json / swagger.yaml
├── start.sh # 启动脚本(自动 swag init
├── docker-compose.yml # 本地容器编排
├── .env.example # 环境变量示例
└── go.mod # Go Module 定义
```
## 技术栈
## ✅ 前置要求
- **语言**: Go 1.23+
- **框架**: Gin Web Framework
- **数据库**: PostgreSQL 15+ (GORM ORM)
- **缓存**: Redis 6.0+
- **存储**: RustFS/MinIO (S3兼容对象存储)
- **权限**: Casbin RBAC
- **日志**: Zap (结构化日志)
- **配置**: 环境变量 (.env) + Viper
- **JSON**: jsoniter (高性能JSON序列化)
- **文档**: Swagger/OpenAPI 3.0
- Go 1.24+
- PostgreSQL 15+
- Redis 6+
- RustFS / MinIO或其他兼容 S3 的对象存储,用于皮肤与头像)
## 快速开始
## 🚀 快速开始
### 环境要求
- Go 1.21或更高版本
- PostgreSQL 15或更高版本
- Redis 6.0或更高版本
- RustFS 或其他 S3 兼容对象存储服务
### 安装和运行
1. **克隆项目**
```bash
git clone <repository-url>
cd CarrotSkin/backend
```
1. **克隆仓库**
```bash
git clone <repo>
cd backend
```
2. **安装依赖**
```bash
go mod download
```
```bash
go mod download
```
3. **配置环境**
```bash
# 复制环境变量文件
cp .env.example .env
# 编辑 .env 文件配置数据库、RustFS等服务连接信息
```
**注意**:项目完全依赖 `.env` 文件进行配置,不再使用 YAML 配置文件,便于 Docker 容器化部署。
3. **配置环境变量**
```bash
cp .env .env
# 根据实际环境填写数据库、Redis、对象存储、邮件等信息
```
4. **初始化数据库**
```bash
# 创建数据库
createdb carrotskin
# 或者使用PostgreSQL客户端
psql -h localhost -U postgres -c "CREATE DATABASE carrotskin;"
```
```bash
createdb carrotskin
# 或 psql -c "CREATE DATABASE carrotskin;"
```
> 应用启动时会执行 `AutoMigrate`,自动创建 / 更新表结构。
> 💡 **提示**: 项目使用 GORM 的 `AutoMigrate` 功能自动创建和更新数据库表结构无需手动执行SQL脚本。首次启动时会自动创建所有表。
5. **启动服务**
- **推荐**`./start.sh`(自动 `swag init`,随后 `go run cmd/server/main.go`
- **手动启动**
```bash
swag init -g cmd/server/main.go -o docs
go run cmd/server/main.go
```
5. **运行服务**
6. **访问接口**
- API Root: `http://localhost:8080`
- Swagger: `http://localhost:8080/swagger/index.html`(需 `SERVER_SWAGGER_ENABLED=true`
方式一:使用启动脚本(推荐)
```bash
# Linux/Mac
chmod +x start.sh
./start.sh
## ⚙️ 关键环境变量
# Windows
start.bat
```
| 变量 | 说明 | 示例 |
| --- | --- | --- |
| `SERVER_PORT` | 服务监听端口 | `8080` |
| `SERVER_MODE` | Gin 模式debug/release | `debug` |
| `SERVER_SWAGGER_ENABLED` | 是否暴露 Swagger UI | `true` |
| `DATABASE_HOST` / `DATABASE_PORT` | PostgreSQL 地址 | `localhost` / `5432` |
| `DATABASE_USERNAME` / `DATABASE_PASSWORD` | 数据库凭据 | `postgres` |
| `DATABASE_NAME` | 数据库名称 | `carrotskin` |
| `REDIS_HOST` / `REDIS_PORT` | Redis 地址 | `localhost` / `6379` |
| `REDIS_PASSWORD` | Redis 密码(无可为空) | `` |
| `RUSTFS_ENDPOINT` | RustFS/MinIO 访问地址 | `127.0.0.1:9000` |
| `RUSTFS_ACCESS_KEY` / `RUSTFS_SECRET_KEY` | 对象存储凭据 | `minioadmin` |
| `RUSTFS_BUCKET_TEXTURES` / `RUSTFS_BUCKET_AVATARS` | 存储桶名称 | `carrotskin-textures` |
| `JWT_SECRET` | JWT 签名密钥 | `change-me` |
| `EMAIL_ENABLED` | 是否开启邮件服务 | `true` |
| `EMAIL_SMTP_HOST` / `EMAIL_SMTP_PORT` | SMTP 配置 | `smtp.example.com` / `587` |
方式二:直接运行
```bash
# 设置环境变量(或使用.env文件
export DATABASE_HOST=localhost
export DATABASE_PORT=5432
# ... 其他环境变量
更多变量请参考 `.env.example` 与 `.env.docker.example`。
# 运行服务
go run cmd/server/main.go
```
> 💡 **提示**:
> - 启动脚本会自动加载 `.env` 文件中的环境变量
> - 首次启动时会自动执行数据库迁移AutoMigrate
> - 如果对象存储未配置,服务仍可启动(相关功能不可用)
服务启动后:
- **服务地址**: http://localhost:8080
- **Swagger文档**: http://localhost:8080/swagger/index.html
- **健康检查**: http://localhost:8080/health
## API接口
### 认证相关
- `POST /api/v1/auth/register` - 用户注册(需邮箱验证码)
- `POST /api/v1/auth/login` - 用户登录(支持用户名/邮箱)
- `POST /api/v1/auth/send-code` - 发送验证码(注册/重置密码/更换邮箱)
- `POST /api/v1/auth/reset-password` - 重置密码(需验证码)
### 用户相关(需认证)
- `GET /api/v1/user/profile` - 获取用户信息
- `PUT /api/v1/user/profile` - 更新用户信息(头像、密码)
- `POST /api/v1/user/avatar/upload-url` - 生成头像上传URL
- `PUT /api/v1/user/avatar` - 更新头像
- `POST /api/v1/user/change-email` - 更换邮箱(需验证码)
### 材质管理
公开接口:
- `GET /api/v1/texture` - 搜索材质
- `GET /api/v1/texture/:id` - 获取材质详情
认证接口:
- `POST /api/v1/texture/upload-url` - 生成材质上传URL
- `POST /api/v1/texture` - 创建材质记录
- `PUT /api/v1/texture/:id` - 更新材质
- `DELETE /api/v1/texture/:id` - 删除材质
- `POST /api/v1/texture/:id/favorite` - 切换收藏状态
- `GET /api/v1/texture/my` - 我的材质列表
- `GET /api/v1/texture/favorites` - 我的收藏列表
### 角色档案
公开接口:
- `GET /api/v1/profile/:uuid` - 获取档案详情
认证接口:
- `POST /api/v1/profile` - 创建角色档案UUID由后端生成
- `GET /api/v1/profile` - 我的档案列表
- `PUT /api/v1/profile/:uuid` - 更新档案
- `DELETE /api/v1/profile/:uuid` - 删除档案
- `POST /api/v1/profile/:uuid/activate` - 设置活跃档案
### 系统配置
- `GET /api/v1/system/config` - 获取系统配置
## 配置管理
### 环境变量配置
项目**完全依赖环境变量**进行配置,不使用 YAML 配置文件,便于容器化部署:
1. **配置来源**: 环境变量 或 `.env` 文件
2. **环境变量格式**: 使用下划线分隔,全大写,如 `DATABASE_HOST`
3. **容器部署**: 直接在容器运行时设置环境变量即可
**主要环境变量**:
```bash
# 数据库配置
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=your_password
DATABASE_NAME=carrotskin
# Redis配置
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=your_redis_password
REDIS_DATABASE=0
REDIS_POOL_SIZE=10
# RustFS对象存储配置 (S3兼容)
RUSTFS_ENDPOINT=127.0.0.1:9000
RUSTFS_ACCESS_KEY=your_access_key
RUSTFS_SECRET_KEY=your_secret_key
RUSTFS_USE_SSL=false
RUSTFS_BUCKET_TEXTURES=carrot-skin-textures
RUSTFS_BUCKET_AVATARS=carrot-skin-avatars
# JWT配置
JWT_SECRET=your-jwt-secret-key
JWT_EXPIRE_HOURS=168
# 邮件配置
EMAIL_ENABLED=true
EMAIL_SMTP_HOST=smtp.example.com
EMAIL_SMTP_PORT=587
EMAIL_USERNAME=noreply@example.com
EMAIL_PASSWORD=your_email_password
EMAIL_FROM_NAME=CarrotSkin
```
**动态配置(存储在数据库中)**:
- 积分系统配置(注册奖励、签到积分等)
- 用户限制配置(最大材质数、最大角色数等)
- 网站设置(站点名称、公告、维护模式等)
完整的环境变量列表请参考 `.env.example` 文件。
### 数据库自动迁移
项目使用 GORM 的 `AutoMigrate` 功能自动管理数据库表结构:
- **首次启动**: 自动创建所有表结构
- **模型更新**: 自动添加新字段、索引等
- **类型转换**: 自动处理字段类型变更如枚举类型转为varchar
- **外键管理**: 自动管理外键关系
**注意事项**:
- 生产环境建议先备份数据库再执行迁移
- 某些复杂变更(如删除字段)可能需要手动处理
- 枚举类型在PostgreSQL中存储为varchar避免类型兼容问题
## 架构设计
### 面向过程的函数式架构
项目采用**面向过程的函数式架构**,摒弃不必要的面向对象抽象,使用独立函数和单例管理器模式,代码更简洁、可维护性更强:
```
┌─────────────────────────────────────┐
│ Handler 层 (函数) │ ← 路由处理、参数验证、响应格式化
├─────────────────────────────────────┤
│ Service 层 (函数) │ ← 业务逻辑、权限检查、数据验证
├─────────────────────────────────────┤
│ Repository 层 (函数) │ ← 数据库操作、关联查询
├─────────────────────────────────────┤
│ Manager 层 (单例模式) │ ← 核心依赖管理(线程安全)
│ - database.MustGetDB() │
│ - logger.MustGetLogger() │
│ - auth.MustGetJWTService() │
│ - redis.MustGetClient() │
│ - email.MustGetService() │
│ - storage.MustGetClient() │
│ - config.MustGetConfig() │
├──────────────┬──────────────────────┤
│ PostgreSQL │ Redis │ RustFS │ ← 数据存储层
└──────────────┴──────────────────────┘
```
### 架构特点
1. **函数式设计**: 所有业务逻辑以独立函数形式实现,无结构体方法,降低耦合度
2. **管理器模式**: 使用 `sync.Once` 实现线程安全的单例管理器,统一管理核心依赖
3. **按需获取**: 通过管理器函数按需获取依赖,避免链式传递,代码更清晰
4. **自动迁移**: 使用 GORM AutoMigrate 自动管理数据库表结构
5. **高性能**: 使用 jsoniter 替代标准库 json提升序列化性能
### 核心模块
1. **认证模块** (`internal/handler/auth_handler.go`)
- JWT令牌生成和验证通过 `auth.MustGetJWTService()` 获取)
- bcrypt密码加密
- 邮箱验证码注册
- 密码重置功能
- 登录日志记录(支持用户名/邮箱登录)
2. **用户模块** (`internal/handler/user_handler.go`)
- 用户信息管理
- 头像上传预签名URL通过 `storage.MustGetClient()` 获取)
- 密码修改(需原密码验证)
- 邮箱更换(需验证码)
- 积分系统
3. **邮箱验证模块** (`internal/service/verification_service.go`)
- 验证码生成6位数字
- 验证码存储Redis10分钟有效期通过 `redis.MustGetClient()` 获取)
- 发送频率限制1分钟
- 邮件发送HTML格式通过 `email.MustGetService()` 获取)
4. **材质模块** (`internal/handler/texture_handler.go`)
- 材质上传预签名URL
- 材质搜索和收藏
- Hash去重
- 下载统计
5. **档案模块** (`internal/handler/profile_handler.go`)
- Minecraft角色管理
- RSA密钥生成RSA-2048
- 活跃状态管理
- 档案数量限制
6. **管理器模块** (`pkg/*/manager.go`)
- 数据库管理器:`database.MustGetDB()` - 线程安全的数据库连接
- 日志管理器:`logger.MustGetLogger()` - 结构化日志实例
- JWT管理器`auth.MustGetJWTService()` - JWT服务实例
- Redis管理器`redis.MustGetClient()` - Redis客户端
- 邮件管理器:`email.MustGetService()` - 邮件服务
- 存储管理器:`storage.MustGetClient()` - 对象存储客户端
- 配置管理器:`config.MustGetConfig()` - 应用配置
### 技术特性
- **架构优势**:
- 面向过程的函数式设计,代码简洁清晰
- 单例管理器模式,线程安全的依赖管理
- 按需获取依赖,避免链式传递
- 自动数据库迁移AutoMigrate
- **安全性**:
- bcrypt密码加密、JWT令牌认证
- 邮箱验证码(注册/重置密码/更换邮箱)
- Casbin RBAC权限控制
- 频率限制(防暴力破解)
- **性能**:
- jsoniter 高性能JSON序列化替代标准库
- PostgreSQL索引优化
- Redis缓存验证码、会话等
- 预签名URL减轻服务器压力
- 连接池管理
- **可靠性**:
- 事务保证数据一致性
- 完整的错误处理和日志记录
- 优雅关闭和资源清理
- 对象存储连接失败时服务仍可启动
- **可扩展**:
- 清晰的函数式架构
- 管理器模式统一管理依赖
- 环境变量配置(便于容器化)
- **审计**:
- 登录日志(成功/失败)
- 操作审计
- 下载记录
## 开发指南
### 代码结构
- `cmd/server/` - 应用入口,初始化服务
- `internal/handler/` - HTTP请求处理
- `internal/service/` - 业务逻辑实现
- `internal/repository/` - 数据库操作
- `internal/model/` - 数据模型定义
- `internal/types/` - 请求/响应类型定义
- `internal/middleware/` - 中间件JWT、CORS、日志等
- `pkg/` - 可复用的公共库
### 开发规范
1. **代码风格**: 遵循Go官方代码规范使用 `gofmt` 格式化
2. **架构模式**: 使用函数式设计,避免不必要的结构体和方法
3. **依赖管理**: 通过管理器函数获取依赖(如 `database.MustGetDB()`),避免链式传递
4. **错误处理**: 使用统一的错误响应格式 (`model.NewErrorResponse`)
5. **日志记录**: 使用 Zap 结构化日志,通过 `logger.MustGetLogger()` 获取实例
6. **JSON序列化**: 使用 jsoniter 替代标准库 json提升性能
7. **RESTful API**: 遵循 REST 设计原则合理使用HTTP方法
### 添加新功能
1.`internal/model/` 定义数据模型GORM会自动迁移
2.`internal/repository/` 实现数据访问函数(使用 `database.MustGetDB()` 获取数据库)
3.`internal/service/` 实现业务逻辑函数(按需使用管理器获取依赖)
4.`internal/handler/` 实现HTTP处理函数使用管理器获取logger、jwtService等
5.`internal/handler/routes.go` 注册路由
**示例**:
```go
// Repository层
func FindUserByID(id uint) (*model.User, error) {
db := database.MustGetDB()
var user model.User
err := db.First(&user, id).Error
return &user, err
}
// Service层
func GetUserProfile(userID uint) (*model.User, error) {
logger := logger.MustGetLogger()
user, err := repository.FindUserByID(userID)
if err != nil {
logger.Error("获取用户失败", zap.Error(err))
return nil, err
}
return user, nil
}
// Handler层
func GetUserProfile(c *gin.Context) {
logger := logger.MustGetLogger()
jwtService := auth.MustGetJWTService()
// ... 处理逻辑
}
```
## 部署
### 本地开发
## 🧪 常用命令
```bash
# 安装依赖
go mod download
# 运行单元测试
go test ./...
# 配置环境变量(创建.env文件或直接export
cp .env.example .env
# 编辑 .env 文件
# 重新生成 swagger
swag init -g cmd/server/main.go -o docs
# 启动服务
# 方式1: 使用启动脚本
./start.sh # Linux/Mac
start.bat # Windows
# 方式2: 直接运行
go run cmd/server/main.go
# 代码格式化 / 静态检查
gofmt -w .
golangci-lint run (若已安装)
```
**首次启动**:
- 会自动执行数据库迁移AutoMigrate创建所有表结构
- 如果对象存储未配置,会记录警告但服务仍可启动
- 检查日志确认所有服务初始化成功
## 🧱 架构说明
### 生产部署
- **分层设计**Handler -> Service -> Repository -> Model层次清晰、职责单一。
- **依赖管理器**`pkg/*/manager.go` 使用 `sync.Once` 实现线程安全单例DB / Redis / Logger / Storage / Email / Auth / Config
- **Swagger 注解**:所有 Handler、模型、DTO 均补齐 `@Summary` / `@Description` / `@Success`,可直接生成 OpenAPI 文档。
- **配置优先级**`.env` -> 系统环境变量,所有配置均可通过容器注入。
- **自动任务**`internal/task` 承载后台作业,可按需扩展。
```bash
# 构建二进制文件
go build -o carrotskin-server cmd/server/main.go
## 📝 Swagger 说明
# 运行服务
./carrotskin-server
```
- `start.sh` 会在启动前执行 `swag init -g cmd/server/main.go -o docs`
- 若手动运行,需要保证 `docs/` 下的 `docs.go`、`swagger.json`、`swagger.yaml` 与代码同步
- 通过 `SERVER_SWAGGER_ENABLED=false` 可在生产环境关闭 Swagger UI 暴露
### Docker部署
## 🤝 贡献指南
```bash
# 构建镜像
docker build -t carrotskin-backend:latest .
1. Fork & Clone
2. 创建特性分支:`git checkout -b feature/xxx`
3. 编写代码并补全测试 / Swagger 注释
4. 提交时附上变更说明
# 启动服务
docker-compose up -d
```
## 📄 许可证
## 故障排查
该项目未附带开源许可证,默认保留所有权利。若需对外使用,请先与作者确认协议。
### 常见问题
---
1. **数据库连接失败**
- 检查 `.env` 中的数据库配置(`DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_USERNAME`, `DATABASE_PASSWORD`, `DATABASE_NAME`
- 确认PostgreSQL服务已启动
- 验证数据库用户权限
- 确认数据库已创建:`createdb carrotskin``psql -c "CREATE DATABASE carrotskin;"`
- 检查数据库迁移日志,确认表结构创建成功
2. **Redis连接失败**
- 检查Redis服务是否运行`redis-cli ping`
- 验证 `.env` 中的Redis配置
- 确认Redis密码是否正确
- 检查防火墙规则
3. **RustFS/MinIO连接失败**
- 检查存储服务是否运行
- 验证访问密钥是否正确(`RUSTFS_ACCESS_KEY`, `RUSTFS_SECRET_KEY`
- 确认存储桶是否已创建(`RUSTFS_BUCKET_TEXTURES`, `RUSTFS_BUCKET_AVATARS`
- 检查网络连接和端口(`RUSTFS_ENDPOINT`
- **注意**: 如果对象存储连接失败,服务仍可启动,但上传功能不可用
4. **邮件发送失败**
- 检查 `EMAIL_ENABLED=true`
- 验证SMTP服务器地址和端口
- 确认邮箱用户名和密码正确
- 检查邮件服务商是否需要开启SMTP
- 查看日志获取详细错误信息
5. **验证码相关问题**
- 验证码过期10分钟有效期
- 发送过于频繁1分钟限制
- Redis存储失败检查Redis连接
- 邮件未收到(检查垃圾邮件)
6. **JWT验证失败**
- 检查 `JWT_SECRET` 是否配置
- 验证令牌是否过期默认168小时
- 确认请求头中包含 `Authorization: Bearer <token>`
- Token格式是否正确
### 调试技巧
1. **查看日志**
```bash
# 实时查看日志
tail -f logs/app.log
# 搜索错误日志
grep "ERROR" logs/app.log
```
2. **测试Redis连接**
```bash
redis-cli -h localhost -p 6379 -a your_password
> PING
> KEYS *
```
3. **测试数据库连接**
```bash
psql -h localhost -U postgres -d carrotskin
\dt # 查看所有表
```
4. **测试邮件配置**
- 使用Swagger文档测试 `/api/v1/auth/send-code` 接口
- 检查邮件服务商是否限制发送频率
### 开发调试
启用详细日志:
```bash
# 在 .env 中设置
LOG_LEVEL=debug
SERVER_MODE=debug
```
如需了解业务细节或 API 调用示例,请参考 `docs/swagger.yaml` 或运行服务后访问 Swagger UI。祝编码愉快🍀

180
cmd/server/main.go Normal file
View File

@@ -0,0 +1,180 @@
// @title CarrotSkin API
// @version 1.0
// @description Minecraft皮肤站后端API
// @host localhost:8080
// @BasePath /api/v1
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
_ "time/tzdata"
"carrotskin/internal/container"
"carrotskin/internal/handler"
"carrotskin/internal/middleware"
"carrotskin/internal/task"
"carrotskin/pkg/auth"
"carrotskin/pkg/config"
"carrotskin/pkg/database"
"carrotskin/pkg/email"
"carrotskin/pkg/logger"
"carrotskin/pkg/redis"
"carrotskin/pkg/storage"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func main() {
// 初始化配置
if err := config.Init(); err != nil {
log.Fatalf("配置加载失败: %v", err)
}
cfg := config.MustGetConfig()
// 初始化日志
if err := logger.Init(cfg.Log); err != nil {
log.Fatalf("日志初始化失败: %v", err)
}
loggerInstance := logger.MustGetLogger()
defer loggerInstance.Sync()
// 初始化数据库
if err := database.Init(cfg.Database, loggerInstance); err != nil {
loggerInstance.Fatal("数据库初始化失败", zap.Error(err))
}
defer database.Close()
// 执行数据库迁移
if err := database.AutoMigrate(loggerInstance); err != nil {
loggerInstance.Fatal("数据库迁移失败", zap.Error(err))
}
// 初始化种子数据
if err := database.Seed(loggerInstance); err != nil {
loggerInstance.Fatal("种子数据初始化失败", zap.Error(err))
}
// 初始化JWT服务
if err := auth.Init(cfg.JWT); err != nil {
loggerInstance.Fatal("JWT服务初始化失败", zap.Error(err))
}
// 初始化Redis开发/测试环境失败时会自动回退到miniredis
if err := redis.Init(cfg.Redis, loggerInstance); err != nil {
loggerInstance.Fatal("Redis初始化失败", zap.Error(err))
}
defer redis.Close()
// 记录Redis模式
if redis.IsUsingMiniRedis() {
loggerInstance.Info("使用miniredis进行开发/测试")
} else {
loggerInstance.Info("使用生产Redis")
}
// 初始化对象存储 (RustFS - S3兼容)
var storageClient *storage.StorageClient
if err := storage.Init(cfg.RustFS); err != nil {
loggerInstance.Warn("对象存储连接失败,某些功能可能不可用", zap.Error(err))
} else {
storageClient = storage.MustGetClient()
loggerInstance.Info("对象存储连接成功")
}
// 初始化邮件服务
if err := email.Init(cfg.Email, loggerInstance); err != nil {
loggerInstance.Fatal("邮件服务初始化失败", zap.Error(err))
}
emailServiceInstance := email.MustGetService()
// 初始化Casbin权限服务
casbinService, err := auth.NewCasbinService(database.MustGetDB(), cfg.Casbin.ModelPath, loggerInstance)
if err != nil {
loggerInstance.Fatal("Casbin服务初始化失败", zap.Error(err))
}
// 创建依赖注入容器
c := container.NewContainer(
database.MustGetDB(),
redis.MustGetClient(),
loggerInstance,
auth.MustGetJWTService(),
casbinService,
storageClient,
emailServiceInstance,
)
// 设置Gin模式
if cfg.Server.Mode == "production" {
gin.SetMode(gin.ReleaseMode)
}
// 创建路由
router := gin.New()
// 禁用自动重定向允许API路径带或不带/结尾都能正常访问
router.RedirectTrailingSlash = false
router.RedirectFixedPath = false
// 添加中间件
router.Use(middleware.Logger(loggerInstance))
router.Use(middleware.Recovery(loggerInstance))
router.Use(middleware.CORS())
// 使用依赖注入方式注册路由
handler.RegisterRoutesWithDI(router, c)
// 启动后台任务Token已迁移到Redis不再需要清理任务
// 如需使用数据库Token存储可以恢复TokenCleanupTask
taskRunner := task.NewRunner(loggerInstance)
taskCtx, taskCancel := context.WithCancel(context.Background())
defer taskCancel()
taskRunner.Start(taskCtx)
// 创建HTTP服务器
srv := &http.Server{
Addr: cfg.Server.Port,
Handler: router,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
}
// 启动服务器
go func() {
loggerInstance.Info("服务器启动", zap.String("port", cfg.Server.Port))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
loggerInstance.Fatal("服务器启动失败", zap.Error(err))
}
}()
// 等待中断信号优雅关闭
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
loggerInstance.Info("正在关闭服务器...")
// 停止后台任务
taskCancel()
taskRunner.Wait()
// 设置关闭超时
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
loggerInstance.Fatal("服务器强制关闭", zap.Error(err))
}
loggerInstance.Info("服务器已关闭")
}

View File

@@ -11,4 +11,4 @@ g = _, _
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
m = g(r.sub, p.sub) && (p.obj == "*" || r.obj == p.obj) && (p.act == "*" || r.act == p.act)

197
docker-compose.yml Normal file
View File

@@ -0,0 +1,197 @@
version: '3.8'
services:
# ==================== 应用服务 ====================
app:
build:
context: .
dockerfile: Dockerfile
image: carrotskin/backend:latest
container_name: carrotskin-backend
restart: unless-stopped
ports:
- "${APP_PORT:-8080}:8080"
environment:
# 站点配置
- SITE_NAME=${SITE_NAME:-CarrotSkin}
- SITE_DESCRIPTION=${SITE_DESCRIPTION:-一个优秀的Minecraft皮肤站}
- REGISTRATION_ENABLED=${REGISTRATION_ENABLED:-true}
- DEFAULT_AVATAR=${DEFAULT_AVATAR:-}
# 用户限制配置
- MAX_TEXTURES_PER_USER=${MAX_TEXTURES_PER_USER:-50}
- MAX_PROFILES_PER_USER=${MAX_PROFILES_PER_USER:-5}
# 积分配置
- CHECKIN_REWARD=${CHECKIN_REWARD:-10}
- TEXTURE_DOWNLOAD_REWARD=${TEXTURE_DOWNLOAD_REWARD:-1}
# 服务器配置
- SERVER_PORT=:8080
- SERVER_MODE=${SERVER_MODE:-release}
- SERVER_SWAGGER_ENABLED=${SERVER_SWAGGER_ENABLED:-true}
# 数据库配置
- DATABASE_DRIVER=postgres
- DATABASE_HOST=postgres
- DATABASE_PORT=5432
- DATABASE_USERNAME=carrotskin
- DATABASE_PASSWORD=${DATABASE_PASSWORD:-carrotskin123}
- DATABASE_NAME=carrotskin
- DATABASE_SSL_MODE=disable
- DATABASE_TIMEZONE=Asia/Shanghai
# Redis 配置
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
- REDIS_DATABASE=0
# JWT 配置
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
- JWT_EXPIRE_HOURS=168
# 存储配置 (RustFS S3兼容)
- RUSTFS_ENDPOINT=${RUSTFS_ENDPOINT:-rustfs:9000}
- RUSTFS_PUBLIC_URL=${RUSTFS_PUBLIC_URL:-http://localhost:9000}
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123}
- RUSTFS_USE_SSL=${RUSTFS_USE_SSL:-false}
- RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrot-skin-textures}
- RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrot-skin-avatars}
# 安全配置
- SECURITY_ALLOWED_ORIGINS=${SECURITY_ALLOWED_ORIGINS:-*}
- SECURITY_ALLOWED_DOMAINS=${SECURITY_ALLOWED_DOMAINS:-localhost,127.0.0.1}
# 邮件配置
- EMAIL_ENABLED=${EMAIL_ENABLED:-false}
- EMAIL_SMTP_HOST=${EMAIL_SMTP_HOST:-}
- EMAIL_SMTP_PORT=${EMAIL_SMTP_PORT:-587}
- EMAIL_USERNAME=${EMAIL_USERNAME:-}
- EMAIL_PASSWORD=${EMAIL_PASSWORD:-}
- EMAIL_FROM_NAME=${EMAIL_FROM_NAME:-CarrotSkin}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- carrotskin-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# ==================== PostgreSQL 数据库 ====================
postgres:
image: postgres:16-alpine
container_name: carrotskin-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=carrotskin
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:-carrotskin123}
- POSTGRES_DB=carrotskin
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- carrotskin-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U carrotskin -d carrotskin"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# ==================== Redis 缓存 ====================
redis:
image: redis:7-alpine
container_name: carrotskin-redis
restart: unless-stopped
command: >
redis-server
--appendonly yes
--maxmemory 256mb
--maxmemory-policy allkeys-lru
${REDIS_PASSWORD:+--requirepass ${REDIS_PASSWORD}}
volumes:
- redis-data:/data
ports:
- "6379:6379"
networks:
- carrotskin-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
# ==================== RustFS 对象存储====================
rustfs:
image: ghcr.io/rustfs/rustfs:latest
container_name: carrotskin-rustfs
restart: unless-stopped
command: >
server
--address 0.0.0.0:9000
--console-address 0.0.0.0:9001
--access-key ${RUSTFS_ACCESS_KEY:-rustfsadmin}
--secret-key ${RUSTFS_SECRET_KEY:-rustfsadmin123}
--data /data
volumes:
- rustfs-data:/data
ports:
- "9000:9000" # S3 API 端口
- "9001:9001" # 控制台端口
networks:
- carrotskin-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
profiles:
- storage # 使用 --profile storage 启动
# RustFS 初始化服务 - 自动创建存储桶
rustfs-init:
image: minio/mc:latest
container_name: carrotskin-rustfs-init
depends_on:
rustfs:
condition: service_healthy
entrypoint: >
/bin/sh -c "
echo '等待 RustFS 启动...';
sleep 5;
mc alias set myrustfs http://rustfs:9000 $${RUSTFS_ACCESS_KEY} $${RUSTFS_SECRET_KEY};
echo '创建材质存储桶...';
mc mb myrustfs/$${RUSTFS_BUCKET_TEXTURES} --ignore-existing;
mc anonymous set download myrustfs/$${RUSTFS_BUCKET_TEXTURES};
echo '创建头像存储桶...';
mc mb myrustfs/$${RUSTFS_BUCKET_AVATARS} --ignore-existing;
mc anonymous set download myrustfs/$${RUSTFS_BUCKET_AVATARS};
echo '存储桶创建完成: $${RUSTFS_BUCKET_TEXTURES}, $${RUSTFS_BUCKET_AVATARS}';
"
environment:
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123}
- RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrot-skin-textures}
- RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrot-skin-avatars}
networks:
- carrotskin-network
profiles:
- storage
# ==================== 数据卷 ====================
volumes:
postgres-data:
driver: local
redis-data:
driver: local
rustfs-data:
driver: local
# ==================== 网络 ====================
networks:
carrotskin-network:
driver: bridge

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

137
go.mod
View File

@@ -1,91 +1,128 @@
module carrotskin
go 1.23.0
go 1.24.0
toolchain go1.24.2
require (
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/alicebob/miniredis/v2 v2.31.1
github.com/casbin/casbin/v2 v2.123.0
github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/joho/godotenv v1.5.1
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
github.com/minio/minio-go/v7 v7.0.66
github.com/redis/go-redis/v9 v9.0.5
github.com/minio/minio-go/v7 v7.0.97
github.com/redis/go-redis/v9 v9.17.2
github.com/spf13/viper v1.21.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/gin-swagger v1.6.1
github.com/wenlng/go-captcha-assets v1.0.7
github.com/wenlng/go-captcha/v2 v2.0.4
go.uber.org/zap v1.26.0
gorm.io/driver/postgres v1.5.4
gorm.io/gorm v1.25.5
)
require (
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
golang.org/x/image v0.16.0 // indirect
go.uber.org/zap v1.27.1
gorm.io/datatypes v1.2.7
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/casbin/govaluate v1.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/glebarez/go-sqlite v1.20.3 // indirect
github.com/glebarez/sqlite v1.7.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/microsoft/go-mssqldb v1.7.2 // indirect
github.com/minio/crc64nvme v1.1.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
github.com/yuin/gopher-lua v1.1.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/image v0.33.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/sync v0.18.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gorm.io/driver/mysql v1.6.0 // indirect
gorm.io/driver/sqlserver v1.6.0 // indirect
gorm.io/plugin/dbresolver v1.6.0 // indirect
modernc.org/libc v1.22.2 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.20.3 // indirect
)
require (
github.com/bytedance/sonic v1.14.2 // indirect
github.com/casbin/gorm-adapter/v3 v3.39.0
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.15.1 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.5.0
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.6.0
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.4.3
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.6
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12
github.com/klauspost/compress v1.17.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggo/swag v1.16.2
github.com/swaggo/swag v1.16.6
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
go.uber.org/multierr v1.10.0 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.40.0
golang.org/x/net v0.42.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.35.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.45.0
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

374
go.sum
View File

@@ -1,41 +1,86 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.2/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1 h1:lGlwhPtrX6EVml1hO0ivjkUxsSyl4dsiw9qcA1k/3IQ=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1/go.mod h1:RKUqNu35KJYcVG/fqTRqmuXJZYNhYkBrnC/hX7yGbTA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0/go.mod h1:bhXu1AjYL+wutSL/kpSq6s7733q2Rb0yuot9Zgfqa/0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1 h1:sO0/P7g68FrryJzljemN+6GTssUXdANk6aJ7T1ZxnsQ=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1 h1:6oNBlSdi1QqM1PNW7FPA6xOGA5UNsXnkaYZz9vdPGhA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1/go.mod h1:s4kgfzA0covAXNicZHDMN58jExvcng2mC/DepXiF1EI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1 h1:DzHpqpoJVaCgOUdVHxE8QB52S6NiVdDQvGlny1qvPqA=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao=
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y=
github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.31.1 h1:7XAt0uUg3DtwEKW5ZAGa+K7FZV2DdKQo5K/6TTnfX8Y=
github.com/alicebob/miniredis/v2 v2.31.1/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg=
github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/casbin/casbin/v2 v2.123.0 h1:UkiMllBgn3MrwHGiZTDFVTV9up+W2CRLufZwKiuAmpA=
github.com/casbin/casbin/v2 v2.123.0/go.mod h1:Ee33aqGrmES+GNL17L0h9X28wXuo829wnNUnS0edAco=
github.com/casbin/gorm-adapter/v3 v3.39.0 h1:k15txH6vE4796MuA+LFcU8I1vMjutklyzMXfjDz7lzo=
github.com/casbin/gorm-adapter/v3 v3.39.0/go.mod h1:kjXoK8MqA3E/CcqEF2l3SCkhJj1YiHVR6SF0LMvJoH4=
github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/glebarez/go-sqlite v1.20.3 h1:89BkqGOXR9oRmG58ZrzgoY/Fhy5x0M+/WV48U5zVrZ4=
github.com/glebarez/go-sqlite v1.20.3/go.mod h1:u3N6D/wftiAzIOJtZl6BmedqxmmkDfH3q+ihjqxC9u0=
github.com/glebarez/sqlite v1.7.0 h1:A7Xj/KN2Lvie4Z4rrgQHY8MsbebX3NyWsL3n2i82MVI=
github.com/glebarez/sqlite v1.7.0/go.mod h1:PkeevrRlF/1BhQBCnzcMWzgrIk7IOop+qS2jUYLfHhk=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@@ -52,29 +97,58 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.15.1 h1:BSe8uhN+xQ4r5guV/ywQI4gO59C2raYcGffYWZEjZzM=
github.com/go-playground/validator/v10 v10.15.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -87,12 +161,13 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -100,42 +175,64 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v0.19.0/go.mod h1:ukJCBnnzLzpVF0qYRT+eg1e+eSwjeQ7IvenUv8QPook=
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q=
github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw=
github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o=
github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578 h1:VstopitMQi3hZP0fzvnsLmzXZdQGc4bEcgu24cp+d4M=
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -147,115 +244,188 @@ github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjb
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
github.com/swaggo/swag v1.16.2 h1:28Pp+8DkQoV+HLzLx8RGJZXNGKbFqnuvSbAAtoxiY04=
github.com/swaggo/swag v1.16.2/go.mod h1:6YzXnDcpr0767iOejs318CwYkCQqyGer6BizOg03f+E=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wenlng/go-captcha-assets v1.0.7 h1:tfF84A4un/i4p+TbRVHDqDPeQeatvddOfB2xbKvLVq8=
github.com/wenlng/go-captcha-assets v1.0.7/go.mod h1:zinRACsdYcL/S6pHgI9Iv7FKTU41d00+43pNX+b9+MM=
github.com/wenlng/go-captcha/v2 v2.0.4 h1:5cSUF36ZyA03qeDMjKmeXGpbYJMXEexZIYK3Vga3ME0=
github.com/wenlng/go-captcha/v2 v2.0.4/go.mod h1:5hac1em3uXoyC5ipZ0xFv9umNM/waQvYAQdr0cx/h34=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
github.com/yuin/gopher-lua v1.1.0 h1:BojcDhfyDWgU2f2TOzYK/g5p2gxMrku8oupLDqlnSqE=
github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw=
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220224120231-95c6836cb0e7/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc=
gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/plugin/dbresolver v1.6.0 h1:XvKDeOtTn1EIX6s4SrKpEH82q0gXVemhYjbYZFGFVcw=
gorm.io/plugin/dbresolver v1.6.0/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0=
modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.20.3 h1:SqGJMMxjj1PHusLxdYxeQSodg7Jxn9WWkaAQjKrntZs=
modernc.org/sqlite v1.20.3/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=

View File

@@ -0,0 +1,273 @@
package container
import (
"carrotskin/internal/repository"
"carrotskin/internal/service"
"carrotskin/pkg/auth"
"carrotskin/pkg/database"
"carrotskin/pkg/email"
"carrotskin/pkg/redis"
"carrotskin/pkg/storage"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
)
// Container 依赖注入容器
// 集中管理所有依赖,便于测试和维护
type Container struct {
// 基础设施依赖
DB *gorm.DB
Redis *redis.Client
Logger *zap.Logger
JWT *auth.JWTService
Casbin *auth.CasbinService
Storage *storage.StorageClient
CacheManager *database.CacheManager
// Repository层
UserRepo repository.UserRepository
ProfileRepo repository.ProfileRepository
TextureRepo repository.TextureRepository
ClientRepo repository.ClientRepository
YggdrasilRepo repository.YggdrasilRepository
ReportRepo repository.ReportRepository
// Service层
UserService service.UserService
ProfileService service.ProfileService
TextureService service.TextureService
TokenService service.TokenService
YggdrasilService service.YggdrasilService
VerificationService service.VerificationService
SecurityService service.SecurityService
CaptchaService service.CaptchaService
SignatureService *service.SignatureService
ReportService service.ReportService
}
// NewContainer 创建依赖容器
func NewContainer(
db *gorm.DB,
redisClient *redis.Client,
logger *zap.Logger,
jwtService *auth.JWTService,
casbinService *auth.CasbinService,
storageClient *storage.StorageClient,
emailService interface{}, // 接受 email.Service 但使用 interface{} 避免循环依赖
) *Container {
// 创建缓存管理器
cacheManager := database.NewCacheManager(redisClient, database.CacheConfig{
Prefix: "carrotskin:",
Expiration: 5 * time.Minute,
Enabled: true,
Policy: database.CachePolicy{
UserTTL: 5 * time.Minute,
UserEmailTTL: 5 * time.Minute,
ProfileTTL: 5 * time.Minute,
ProfileListTTL: 3 * time.Minute,
TextureTTL: 5 * time.Minute,
TextureListTTL: 2 * time.Minute,
},
})
c := &Container{
DB: db,
Redis: redisClient,
Logger: logger,
JWT: jwtService,
Casbin: casbinService,
Storage: storageClient,
CacheManager: cacheManager,
}
// 初始化Repository
c.UserRepo = repository.NewUserRepository(db)
c.ProfileRepo = repository.NewProfileRepository(db)
c.TextureRepo = repository.NewTextureRepository(db)
c.ClientRepo = repository.NewClientRepository(db)
c.YggdrasilRepo = repository.NewYggdrasilRepository(db)
c.ReportRepo = repository.NewReportRepository(db)
// 初始化SignatureService作为依赖注入避免在容器中创建并立即调用
// 将SignatureService添加到容器中供其他服务使用
c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger)
// 初始化Service注入缓存管理器
c.UserService = service.NewUserService(c.UserRepo, jwtService, redisClient, cacheManager, storageClient, logger)
c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger)
c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger)
c.ReportService = service.NewReportService(c.ReportRepo, c.UserRepo, logger)
// 获取Yggdrasil私钥并创建JWT服务TokenService需要
// 注意这里仍然需要预先初始化因为TokenService在创建时需要YggdrasilJWT
// 但SignatureService已经作为依赖注入降低了耦合度
_, privateKey, err := c.SignatureService.GetOrCreateYggdrasilKeyPair()
if err != nil {
logger.Fatal("获取Yggdrasil私钥失败", zap.Error(err))
}
yggdrasilJWT := auth.NewYggdrasilJWTService(privateKey, "carrotskin")
// 创建Redis Token存储必须使用Redis包括miniredis回退
if redisClient == nil {
logger.Fatal("Redis客户端未初始化无法创建Token服务")
}
tokenStore := auth.NewTokenStoreRedis(
redisClient,
logger,
auth.WithKeyPrefix("token:"),
auth.WithDefaultTTL(24*time.Hour),
auth.WithStaleTTL(30*24*time.Hour),
auth.WithMaxTokensPerUser(10),
)
c.TokenService = service.NewTokenServiceRedis(tokenStore, c.ClientRepo, c.ProfileRepo, yggdrasilJWT, logger)
// 使用组合服务(内部包含认证、会话、序列化、证书服务)
c.YggdrasilService = service.NewYggdrasilServiceComposite(db, c.UserRepo, c.ProfileRepo, c.YggdrasilRepo, c.SignatureService, redisClient, logger, c.TokenService)
// 初始化其他服务
c.SecurityService = service.NewSecurityService(redisClient)
c.CaptchaService = service.NewCaptchaService(redisClient, logger)
// 初始化VerificationService需要email.Service
if emailService != nil {
if emailSvc, ok := emailService.(*email.Service); ok {
c.VerificationService = service.NewVerificationService(redisClient, emailSvc)
}
}
return c
}
// NewTestContainer 创建测试用容器可注入mock依赖
func NewTestContainer(opts ...Option) *Container {
c := &Container{}
for _, opt := range opts {
opt(c)
}
return c
}
// Option 容器配置选项
type Option func(*Container)
// WithDB 设置数据库连接
func WithDB(db *gorm.DB) Option {
return func(c *Container) {
c.DB = db
}
}
// WithRedis 设置Redis客户端
func WithRedis(redis *redis.Client) Option {
return func(c *Container) {
c.Redis = redis
}
}
// WithLogger 设置日志
func WithLogger(logger *zap.Logger) Option {
return func(c *Container) {
c.Logger = logger
}
}
// WithJWT 设置JWT服务
func WithJWT(jwt *auth.JWTService) Option {
return func(c *Container) {
c.JWT = jwt
}
}
// WithStorage 设置存储客户端
func WithStorage(storage *storage.StorageClient) Option {
return func(c *Container) {
c.Storage = storage
}
}
// WithUserRepo 设置用户仓储
func WithUserRepo(repo repository.UserRepository) Option {
return func(c *Container) {
c.UserRepo = repo
}
}
// WithProfileRepo 设置档案仓储
func WithProfileRepo(repo repository.ProfileRepository) Option {
return func(c *Container) {
c.ProfileRepo = repo
}
}
// WithTextureRepo 设置材质仓储
func WithTextureRepo(repo repository.TextureRepository) Option {
return func(c *Container) {
c.TextureRepo = repo
}
}
// WithUserService 设置用户服务
func WithUserService(svc service.UserService) Option {
return func(c *Container) {
c.UserService = svc
}
}
// WithProfileService 设置档案服务
func WithProfileService(svc service.ProfileService) Option {
return func(c *Container) {
c.ProfileService = svc
}
}
// WithTextureService 设置材质服务
func WithTextureService(svc service.TextureService) Option {
return func(c *Container) {
c.TextureService = svc
}
}
// WithTokenService 设置令牌服务
func WithTokenService(svc service.TokenService) Option {
return func(c *Container) {
c.TokenService = svc
}
}
// WithYggdrasilRepo 设置Yggdrasil仓储
func WithYggdrasilRepo(repo repository.YggdrasilRepository) Option {
return func(c *Container) {
c.YggdrasilRepo = repo
}
}
// WithYggdrasilService 设置Yggdrasil服务
func WithYggdrasilService(svc service.YggdrasilService) Option {
return func(c *Container) {
c.YggdrasilService = svc
}
}
// WithVerificationService 设置验证码服务
func WithVerificationService(svc service.VerificationService) Option {
return func(c *Container) {
c.VerificationService = svc
}
}
// WithSecurityService 设置安全服务
func WithSecurityService(svc service.SecurityService) Option {
return func(c *Container) {
c.SecurityService = svc
}
}
// WithCaptchaService 设置验证码服务
func WithCaptchaService(svc service.CaptchaService) Option {
return func(c *Container) {
c.CaptchaService = svc
}
}

140
internal/errors/errors.go Normal file
View File

@@ -0,0 +1,140 @@
// Package errors 定义应用程序的错误类型
package errors
import (
"errors"
"fmt"
)
// 预定义错误
var (
// 用户相关错误
ErrUserNotFound = errors.New("用户不存在")
ErrUserAlreadyExists = errors.New("用户已存在")
ErrEmailAlreadyExists = errors.New("邮箱已被注册")
ErrInvalidPassword = errors.New("密码错误")
ErrAccountDisabled = errors.New("账号已被禁用")
// 认证相关错误
ErrUnauthorized = errors.New("未授权")
ErrInvalidToken = errors.New("无效的令牌")
ErrTokenExpired = errors.New("令牌已过期")
ErrInvalidSignature = errors.New("签名验证失败")
// 档案相关错误
ErrProfileNotFound = errors.New("档案不存在")
ErrProfileNameExists = errors.New("角色名已被使用")
ErrProfileLimitReached = errors.New("已达档案数量上限")
ErrProfileNoPermission = errors.New("无权操作此档案")
// 材质相关错误
ErrTextureNotFound = errors.New("材质不存在")
ErrTextureExists = errors.New("该材质已存在")
ErrTextureLimitReached = errors.New("已达材质数量上限")
ErrTextureNoPermission = errors.New("无权操作此材质")
ErrInvalidTextureType = errors.New("无效的材质类型")
// 验证码相关错误
ErrInvalidVerificationCode = errors.New("验证码错误或已过期")
ErrTooManyAttempts = errors.New("尝试次数过多")
ErrSendTooFrequent = errors.New("发送过于频繁")
// URL验证相关错误
ErrInvalidURL = errors.New("无效的URL格式")
ErrDomainNotAllowed = errors.New("URL域名不在允许的列表中")
// 存储相关错误
ErrStorageUnavailable = errors.New("存储服务不可用")
ErrUploadFailed = errors.New("上传失败")
// Yggdrasil相关错误
ErrPasswordMismatch = errors.New("密码错误")
ErrPasswordNotSet = errors.New("未生成密码")
ErrInvalidServerID = errors.New("服务器ID格式无效")
ErrSessionNotFound = errors.New("会话不存在或已过期")
ErrSessionMismatch = errors.New("会话验证失败")
ErrUsernameMismatch = errors.New("用户名不匹配")
ErrIPMismatch = errors.New("IP地址不匹配")
ErrInvalidAccessToken = errors.New("访问令牌无效")
ErrProfileMismatch = errors.New("selectedProfile与Token不匹配")
ErrUUIDRequired = errors.New("UUID不能为空")
ErrCertificateGenerate = errors.New("生成证书失败")
// 通用错误
ErrBadRequest = errors.New("请求参数错误")
ErrInternalServer = errors.New("服务器内部错误")
ErrNotFound = errors.New("资源不存在")
ErrForbidden = errors.New("权限不足")
)
// AppError 应用错误类型,包含错误码和消息
type AppError struct {
Code int // HTTP状态码
Message string // 用户可见的错误消息
Err error // 原始错误(用于日志)
}
// Error 实现error接口
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
// Unwrap 支持errors.Is和errors.As
func (e *AppError) Unwrap() error {
return e.Err
}
// NewAppError 创建新的应用错误
func NewAppError(code int, message string, err error) *AppError {
return &AppError{
Code: code,
Message: message,
Err: err,
}
}
// NewBadRequest 创建400错误
func NewBadRequest(message string, err error) *AppError {
return NewAppError(400, message, err)
}
// NewUnauthorized 创建401错误
func NewUnauthorized(message string) *AppError {
return NewAppError(401, message, nil)
}
// NewForbidden 创建403错误
func NewForbidden(message string) *AppError {
return NewAppError(403, message, nil)
}
// NewNotFound 创建404错误
func NewNotFound(message string) *AppError {
return NewAppError(404, message, nil)
}
// NewInternalError 创建500错误
func NewInternalError(message string, err error) *AppError {
return NewAppError(500, message, err)
}
// Is 检查错误是否匹配
func Is(err, target error) bool {
return errors.Is(err, target)
}
// As 尝试将错误转换为指定类型
func As(err error, target interface{}) bool {
return errors.As(err, target)
}
// Wrap 包装错误
func Wrap(err error, message string) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", message, err)
}

View File

@@ -0,0 +1,38 @@
package errors
import (
"errors"
"testing"
)
func TestAppErrorBasics(t *testing.T) {
root := errors.New("root")
appErr := NewBadRequest("bad", root)
if appErr.Code != 400 || appErr.Message != "bad" {
t.Fatalf("unexpected appErr fields: %+v", appErr)
}
if got := appErr.Error(); got != "bad: root" {
t.Fatalf("unexpected Error(): %s", got)
}
if !Is(appErr, root) {
t.Fatalf("Is should match wrapped error")
}
var target *AppError
if !As(appErr, &target) {
t.Fatalf("As should succeed")
}
}
func TestWrap(t *testing.T) {
if Wrap(nil, "msg") != nil {
t.Fatalf("Wrap nil should return nil")
}
err := errors.New("base")
wrapped := Wrap(err, "ctx")
if wrapped.Error() != "ctx: base" {
t.Fatalf("wrap message mismatch: %v", wrapped)
}
}

View File

@@ -0,0 +1,382 @@
package handler
import (
"net/http"
"strconv"
"carrotskin/internal/container"
"carrotskin/internal/model"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// AdminHandler 管理员处理器
type AdminHandler struct {
container *container.Container
}
// NewAdminHandler 创建管理员处理器
func NewAdminHandler(c *container.Container) *AdminHandler {
return &AdminHandler{container: c}
}
// SetUserRoleRequest 设置用户角色请求
type SetUserRoleRequest struct {
UserID int64 `json:"user_id" binding:"required"`
Role string `json:"role" binding:"required,oneof=user admin"`
}
// SetUserRole 设置用户角色
// @Summary 设置用户角色
// @Description 管理员设置指定用户的角色
// @Tags Admin
// @Accept json
// @Produce json
// @Param request body SetUserRoleRequest true "设置角色请求"
// @Success 200 {object} model.Response{data=map[string]interface{}} "更新成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 403 {object} model.ErrorResponse "无权操作"
// @Security BearerAuth
// @Router /api/v1/admin/users/role [put]
func (h *AdminHandler) SetUserRole(c *gin.Context) {
var req SetUserRoleRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
// 获取当前操作者ID
operatorID, _ := c.Get("user_id")
// 不能修改自己的角色
if req.UserID == operatorID.(int64) {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"不能修改自己的角色",
nil,
))
return
}
// 检查目标用户是否存在
targetUser, err := h.container.UserRepo.FindByID(c.Request.Context(), req.UserID)
if err != nil || targetUser == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
nil,
))
return
}
// 更新用户角色
err = h.container.UserRepo.UpdateFields(c.Request.Context(), req.UserID, map[string]interface{}{
"role": req.Role,
})
if err != nil {
RespondServerError(c, "更新用户角色失败", err)
return
}
h.container.Logger.Info("管理员修改用户角色",
zap.Int64("operator_id", operatorID.(int64)),
zap.Int64("target_user_id", req.UserID),
zap.String("new_role", req.Role),
)
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "用户角色更新成功",
"user_id": req.UserID,
"role": req.Role,
}))
}
// GetUserList 获取用户列表
// @Summary 获取用户列表
// @Description 管理员获取所有用户列表
// @Tags Admin
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Failure 403 {object} model.ErrorResponse "无权操作"
// @Security BearerAuth
// @Router /api/v1/admin/users [get]
func (h *AdminHandler) GetUserList(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
// 使用数据库直接查询用户列表
var users []model.User
var total int64
db := h.container.DB
db.Model(&model.User{}).Count(&total)
db.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&users)
// 构建响应(隐藏敏感信息)
userList := make([]gin.H, len(users))
for i, u := range users {
userList[i] = gin.H{
"id": u.ID,
"username": u.Username,
"email": u.Email,
"avatar": u.Avatar,
"role": u.Role,
"status": u.Status,
"points": u.Points,
"last_login_at": u.LastLoginAt,
"created_at": u.CreatedAt,
}
}
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"users": userList,
"total": total,
"page": page,
"page_size": pageSize,
}))
}
// GetUserDetail 获取用户详情
// @Summary 获取用户详情
// @Description 管理员获取指定用户的详细信息
// @Tags Admin
// @Produce json
// @Param id path int true "用户ID"
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Failure 404 {object} model.ErrorResponse "用户不存在"
// @Security BearerAuth
// @Router /api/v1/admin/users/{id} [get]
func (h *AdminHandler) GetUserDetail(c *gin.Context) {
userID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的用户ID", err)
return
}
user, err := h.container.UserRepo.FindByID(c.Request.Context(), userID)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
nil,
))
return
}
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"avatar": user.Avatar,
"role": user.Role,
"status": user.Status,
"points": user.Points,
"properties": user.Properties,
"last_login_at": user.LastLoginAt,
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
}))
}
// SetUserStatusRequest 设置用户状态请求
type SetUserStatusRequest struct {
UserID int64 `json:"user_id" binding:"required"`
Status int16 `json:"status" binding:"required,oneof=1 0 -1"` // 1:正常, 0:禁用, -1:删除
}
// SetUserStatus 设置用户状态
// @Summary 设置用户状态
// @Description 管理员设置用户状态(启用/禁用)
// @Tags Admin
// @Accept json
// @Produce json
// @Param request body SetUserStatusRequest true "设置状态请求"
// @Success 200 {object} model.Response{data=map[string]interface{}} "更新成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Security BearerAuth
// @Router /api/v1/admin/users/status [put]
func (h *AdminHandler) SetUserStatus(c *gin.Context) {
var req SetUserStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
operatorID, _ := c.Get("user_id")
// 不能修改自己的状态
if req.UserID == operatorID.(int64) {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"不能修改自己的状态",
nil,
))
return
}
// 检查目标用户是否存在
targetUser, err := h.container.UserRepo.FindByID(c.Request.Context(), req.UserID)
if err != nil || targetUser == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
nil,
))
return
}
// 更新用户状态
err = h.container.UserRepo.UpdateFields(c.Request.Context(), req.UserID, map[string]interface{}{
"status": req.Status,
})
if err != nil {
RespondServerError(c, "更新用户状态失败", err)
return
}
statusText := map[int16]string{1: "正常", 0: "禁用", -1: "删除"}[req.Status]
h.container.Logger.Info("管理员修改用户状态",
zap.Int64("operator_id", operatorID.(int64)),
zap.Int64("target_user_id", req.UserID),
zap.Int16("new_status", req.Status),
)
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "用户状态更新成功",
"user_id": req.UserID,
"status": req.Status,
"status_text": statusText,
}))
}
// DeleteTexture 管理员删除材质
// @Summary 管理员删除材质
// @Description 管理员可以删除任意材质(用于审核不当内容)
// @Tags Admin
// @Produce json
// @Param id path int true "材质ID"
// @Success 200 {object} model.Response{data=map[string]interface{}} "删除成功"
// @Failure 404 {object} model.ErrorResponse "材质不存在"
// @Security BearerAuth
// @Router /api/v1/admin/textures/{id} [delete]
func (h *AdminHandler) DeleteTexture(c *gin.Context) {
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的材质ID", err)
return
}
operatorID, _ := c.Get("user_id")
// 检查材质是否存在
var texture model.Texture
if err := h.container.DB.First(&texture, textureID).Error; err != nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"材质不存在",
nil,
))
return
}
// 删除材质
if err := h.container.DB.Delete(&texture).Error; err != nil {
RespondServerError(c, "删除材质失败", err)
return
}
h.container.Logger.Info("管理员删除材质",
zap.Int64("operator_id", operatorID.(int64)),
zap.Int64("texture_id", textureID),
zap.Int64("uploader_id", texture.UploaderID),
zap.String("texture_name", texture.Name),
)
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "材质删除成功",
"texture_id": textureID,
}))
}
// GetTextureList 管理员获取材质列表
// @Summary 管理员获取材质列表
// @Description 管理员获取所有材质列表(用于审核)
// @Tags Admin
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Security BearerAuth
// @Router /api/v1/admin/textures [get]
func (h *AdminHandler) GetTextureList(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
var textures []model.Texture
var total int64
db := h.container.DB
db.Model(&model.Texture{}).Count(&total)
db.Preload("Uploader").Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&textures)
// 构建响应
textureList := make([]gin.H, len(textures))
for i, t := range textures {
uploaderName := ""
if t.Uploader != nil {
uploaderName = t.Uploader.Username
}
textureList[i] = gin.H{
"id": t.ID,
"name": t.Name,
"type": t.Type,
"hash": t.Hash,
"uploader_id": t.UploaderID,
"uploader_name": uploaderName,
"is_public": t.IsPublic,
"download_count": t.DownloadCount,
"created_at": t.CreatedAt,
}
}
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"textures": textureList,
"total": total,
"page": page,
"page_size": pageSize,
}))
}
// GetPermissions 获取权限列表
// @Summary 获取权限列表
// @Description 管理员获取所有Casbin权限规则
// @Tags Admin
// @Produce json
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Security BearerAuth
// @Router /api/v1/admin/permissions [get]
func (h *AdminHandler) GetPermissions(c *gin.Context) {
// 获取所有权限规则
policies, _ := h.container.Casbin.GetEnforcer().GetPolicy()
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"policies": policies,
}))
}

View File

@@ -1,19 +1,29 @@
package handler
import (
"carrotskin/internal/model"
"carrotskin/internal/container"
"carrotskin/internal/service"
"carrotskin/internal/types"
"carrotskin/pkg/auth"
"carrotskin/pkg/email"
"carrotskin/pkg/logger"
"carrotskin/pkg/redis"
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// AuthHandler 认证处理器(依赖注入版本)
type AuthHandler struct {
container *container.Container
logger *zap.Logger
}
// NewAuthHandler 创建AuthHandler实例
func NewAuthHandler(c *container.Container) *AuthHandler {
return &AuthHandler{
container: c,
logger: c.Logger,
}
}
// Register 用户注册
// @Summary 用户注册
// @Description 注册新用户账号
@@ -21,66 +31,35 @@ import (
// @Accept json
// @Produce json
// @Param request body types.RegisterRequest true "注册信息"
// @Success 200 {object} model.Response "注册成功"
// @Success 200 {object} model.Response{data=types.LoginResponse} "注册成功"
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
// @Router /api/v1/auth/register [post]
func Register(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
jwtService := auth.MustGetJWTService()
redisClient := redis.MustGetClient()
func (h *AuthHandler) Register(c *gin.Context) {
var req types.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
RespondBadRequest(c, "请求参数错误", err)
return
}
// 验证邮箱验证码
if err := service.VerifyCode(c.Request.Context(), redisClient, req.Email, req.VerificationCode, service.VerificationTypeRegister); err != nil {
loggerInstance.Warn("验证码验证失败",
zap.String("email", req.Email),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
if err := h.container.VerificationService.VerifyCode(c.Request.Context(), req.Email, req.VerificationCode, service.VerificationTypeRegister); err != nil {
h.logger.Warn("验证码验证失败", zap.String("email", req.Email), zap.Error(err))
RespondBadRequest(c, err.Error(), nil)
return
}
// 调用service层注册用户传递可选的头像URL
user, token, err := service.RegisterUser(jwtService, req.Username, req.Password, req.Email, req.Avatar)
// 注册用户
user, token, err := h.container.UserService.Register(c.Request.Context(), req.Username, req.Password, req.Email, req.Avatar)
if err != nil {
loggerInstance.Error("用户注册失败", zap.Error(err))
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
h.logger.Error("用户注册失败", zap.Error(err))
RespondBadRequest(c, err.Error(), nil)
return
}
// 返回响应
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.LoginResponse{
Token: token,
UserInfo: &types.UserInfo{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Avatar: user.Avatar,
Points: user.Points,
Role: user.Role,
Status: user.Status,
LastLoginAt: user.LastLoginAt,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
},
}))
RespondSuccess(c, &types.LoginResponse{
Token: token,
UserInfo: UserToUserInfo(user),
})
}
// Login 用户登录
@@ -94,56 +73,31 @@ func Register(c *gin.Context) {
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
// @Failure 401 {object} model.ErrorResponse "登录失败"
// @Router /api/v1/auth/login [post]
func Login(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
jwtService := auth.MustGetJWTService()
func (h *AuthHandler) Login(c *gin.Context) {
var req types.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
RespondBadRequest(c, "请求参数错误", err)
return
}
// 获取IP和UserAgent
ipAddress := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
// 调用service层登录
user, token, err := service.LoginUser(jwtService, req.Username, req.Password, ipAddress, userAgent)
user, token, err := h.container.UserService.Login(c.Request.Context(), req.Username, req.Password, ipAddress, userAgent)
if err != nil {
loggerInstance.Warn("用户登录失败",
h.logger.Warn("用户登录失败",
zap.String("username_or_email", req.Username),
zap.String("ip", ipAddress),
zap.Error(err),
)
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
err.Error(),
nil,
))
RespondUnauthorized(c, err.Error())
return
}
// 返回响应
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.LoginResponse{
Token: token,
UserInfo: &types.UserInfo{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Avatar: user.Avatar,
Points: user.Points,
Role: user.Role,
Status: user.Status,
LastLoginAt: user.LastLoginAt,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
},
}))
RespondSuccess(c, &types.LoginResponse{
Token: token,
UserInfo: UserToUserInfo(user),
})
}
// SendVerificationCode 发送验证码
@@ -153,42 +107,27 @@ func Login(c *gin.Context) {
// @Accept json
// @Produce json
// @Param request body types.SendVerificationCodeRequest true "发送验证码请求"
// @Success 200 {object} model.Response "发送成功"
// @Success 200 {object} model.Response{data=map[string]string} "发送成功"
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
// @Router /api/v1/auth/send-code [post]
func SendVerificationCode(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
redisClient := redis.MustGetClient()
emailService := email.MustGetService()
func (h *AuthHandler) SendVerificationCode(c *gin.Context) {
var req types.SendVerificationCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
RespondBadRequest(c, "请求参数错误", err)
return
}
// 发送验证码
if err := service.SendVerificationCode(c.Request.Context(), redisClient, emailService, req.Email, req.Type); err != nil {
loggerInstance.Error("发送验证码失败",
if err := h.container.VerificationService.SendCode(c.Request.Context(), req.Email, req.Type); err != nil {
h.logger.Error("发送验证码失败",
zap.String("email", req.Email),
zap.String("type", req.Type),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
RespondBadRequest(c, err.Error(), nil)
return
}
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "验证码已发送,请查收邮件",
}))
RespondSuccess(c, gin.H{"message": "验证码已发送,请查收邮件"})
}
// ResetPassword 重置密码
@@ -198,52 +137,34 @@ func SendVerificationCode(c *gin.Context) {
// @Accept json
// @Produce json
// @Param request body types.ResetPasswordRequest true "重置密码请求"
// @Success 200 {object} model.Response "重置成功"
// @Success 200 {object} model.Response{data=map[string]string} "重置成功"
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
// @Router /api/v1/auth/reset-password [post]
func ResetPassword(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
redisClient := redis.MustGetClient()
func (h *AuthHandler) ResetPassword(c *gin.Context) {
var req types.ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
RespondBadRequest(c, "请求参数错误", err)
return
}
// 验证验证码
if err := service.VerifyCode(c.Request.Context(), redisClient, req.Email, req.VerificationCode, service.VerificationTypeResetPassword); err != nil {
loggerInstance.Warn("验证码验证失败",
zap.String("email", req.Email),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
if err := h.container.VerificationService.VerifyCode(c.Request.Context(), req.Email, req.VerificationCode, service.VerificationTypeResetPassword); err != nil {
h.logger.Warn("验证码验证失败", zap.String("email", req.Email), zap.Error(err))
RespondBadRequest(c, err.Error(), nil)
return
}
// 重置密码
if err := service.ResetUserPassword(req.Email, req.NewPassword); err != nil {
loggerInstance.Error("重置密码失败",
zap.String("email", req.Email),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
model.CodeServerError,
err.Error(),
nil,
))
if err := h.container.UserService.ResetPassword(c.Request.Context(), req.Email, req.NewPassword); err != nil {
h.logger.Error("重置密码失败", zap.String("email", req.Email), zap.Error(err))
RespondServerError(c, err.Error(), nil)
return
}
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "密码重置成功",
}))
RespondSuccess(c, gin.H{"message": "密码重置成功"})
}
// getEmailService 获取邮件服务(暂时使用全局方式,后续可改为依赖注入)
func (h *AuthHandler) getEmailService() (*email.Service, error) {
return email.GetService()
}

View File

@@ -1,47 +1,76 @@
package handler
import (
"carrotskin/internal/service"
"carrotskin/pkg/redis"
"carrotskin/internal/container"
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// CaptchaHandler 验证码处理器
type CaptchaHandler struct {
container *container.Container
logger *zap.Logger
}
// NewCaptchaHandler 创建CaptchaHandler实例
func NewCaptchaHandler(c *container.Container) *CaptchaHandler {
return &CaptchaHandler{
container: c,
logger: c.Logger,
}
}
// CaptchaVerifyRequest 验证码验证请求
type CaptchaVerifyRequest struct {
CaptchaID string `json:"captchaId" binding:"required"`
Dx int `json:"dx" binding:"required"`
}
// Generate 生成验证码
func Generate(c *gin.Context) {
// 调用验证码服务生成验证码数据
redisClient := redis.MustGetClient()
masterImg, tileImg, captchaID, y, err := service.GenerateCaptchaData(c.Request.Context(), redisClient)
// @Summary 生成滑动验证码
// @Description 生成滑动验证码图片
// @Tags captcha
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "生成成功 {code: 200, data: {masterImage, tileImage, captchaId, y}}"
// @Failure 500 {object} map[string]interface{} "生成失败"
// @Router /api/v1/captcha/generate [get]
func (h *CaptchaHandler) Generate(c *gin.Context) {
masterImg, tileImg, captchaID, y, err := h.container.CaptchaService.Generate(c.Request.Context())
if err != nil {
h.logger.Error("生成验证码失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"msg": "生成验证码失败: " + err.Error(),
"msg": "生成验证码失败",
})
return
}
// 返回验证码数据给前端
c.JSON(http.StatusOK, gin.H{
"code": 200,
"data": gin.H{
"masterImage": masterImg, // 主图base64格式
"tileImage": tileImg, // 滑块图base64格式
"captchaId": captchaID, // 验证码唯一标识(用于后续验证)
"y": y, // 滑块Y坐标前端可用于定位滑块初始位置
"masterImage": masterImg,
"tileImage": tileImg,
"captchaId": captchaID,
"y": y,
},
})
}
// Verify 验证验证码
func Verify(c *gin.Context) {
// 定义请求参数结构体
var req struct {
CaptchaID string `json:"captchaId" binding:"required"` // 验证码唯一标识
Dx int `json:"dx" binding:"required"` // 用户滑动的X轴偏移量
}
// 解析并校验请求参数
// @Summary 验证滑动验证码
// @Description 验证用户滑动的偏移量是否正确
// @Tags captcha
// @Accept json
// @Produce json
// @Param request body CaptchaVerifyRequest true "验证请求"
// @Success 200 {object} map[string]interface{} "验证结果 {code: 200/400, msg: string}"
// @Failure 400 {object} map[string]interface{} "参数错误"
// @Router /api/v1/captcha/verify [post]
func (h *CaptchaHandler) Verify(c *gin.Context) {
var req CaptchaVerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
@@ -50,18 +79,19 @@ func Verify(c *gin.Context) {
return
}
// 调用验证码服务验证偏移量
redisClient := redis.MustGetClient()
valid, err := service.VerifyCaptchaData(c.Request.Context(), redisClient, req.Dx, req.CaptchaID)
valid, err := h.container.CaptchaService.Verify(c.Request.Context(), req.Dx, req.CaptchaID)
if err != nil {
h.logger.Error("验证码验证失败",
zap.String("captcha_id", req.CaptchaID),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"msg": "验证失败: " + err.Error(),
"msg": "验证失败",
})
return
}
// 根据验证结果返回响应
if valid {
c.JSON(http.StatusOK, gin.H{
"code": 200,

View File

@@ -0,0 +1,243 @@
package handler
import (
"carrotskin/internal/container"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// CustomSkinHandler CustomSkinAPI处理器
type CustomSkinHandler struct {
container *container.Container
logger *zap.Logger
}
// NewCustomSkinHandler 创建CustomSkinHandler实例
func NewCustomSkinHandler(c *container.Container) *CustomSkinHandler {
return &CustomSkinHandler{
container: c,
logger: c.Logger,
}
}
// CustomSkinAPIResponse CustomSkinAPI响应格式
type CustomSkinAPIResponse struct {
Username string `json:"username"`
Textures map[string]string `json:"textures,omitempty"`
Skin string `json:"skin,omitempty"`
Cape string `json:"cape,omitempty"`
Elytra string `json:"elytra,omitempty"`
}
// GetPlayerInfo 获取玩家信息
// @Summary 获取玩家信息
// @Description CustomSkinAPI: 获取玩家皮肤配置信息
// @Tags CustomSkinAPI
// @Accept json
// @Produce json
// @Param username path string true "玩家用户名"
// @Success 200 {object} CustomSkinAPIResponse
// @Failure 400 {object} map[string]string "用户名不能为空"
// @Failure 404 {object} map[string]string "玩家未找到"
// @Router /api/v1/csl/{username} [get]
func (h *CustomSkinHandler) GetPlayerInfo(c *gin.Context) {
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名不能为空"})
return
}
// 移除 .json 后缀(如果存在)
username = strings.TrimSuffix(username, ".json")
// 查找Profile不区分大小写
profile, err := h.container.ProfileService.GetByProfileName(c.Request.Context(), username)
if err != nil {
h.logger.Debug("未找到玩家",
zap.String("username", username),
zap.Error(err),
)
c.JSON(http.StatusNotFound, gin.H{"error": "玩家未找到"})
return
}
// 构建响应
response := CustomSkinAPIResponse{
Username: profile.Name,
}
// Profile 已经通过 GetByProfileName 预加载了 Skin 和 Cape
// 构建材质字典
textures := make(map[string]string)
hasSkin := false
hasCape := false
hasElytra := false
// 处理皮肤
if profile.SkinID != nil && profile.Skin != nil {
skinHash := profile.Skin.Hash
hasSkin = true
if profile.Skin.IsSlim {
// 如果是slim模型优先添加到slim然后添加default
textures["slim"] = skinHash
textures["default"] = skinHash
} else {
// 如果是default模型优先添加到default然后添加slim
textures["default"] = skinHash
textures["slim"] = skinHash
}
}
// 处理披风
if profile.CapeID != nil && profile.Cape != nil {
textures["cape"] = profile.Cape.Hash
hasCape = true
}
// 处理鞘翅使用cape的hash如果存在cape
if hasCape && profile.Cape != nil {
textures["elytra"] = profile.Cape.Hash
hasElytra = true
}
// 根据材质字典决定返回格式
// 根据协议如果只有皮肤使用default模型可以使用缩略格式
// 但如果有多个不同的材质或需要指定模型,使用完整格式
if hasSkin && !hasCape && !hasElytra {
// 如果只有皮肤使用缩略格式使用default模型的hash
if defaultHash, exists := textures["default"]; exists {
response.Skin = defaultHash
} else if slimHash, exists := textures["slim"]; exists {
// 如果只有slim也使用缩略格式但协议说这会导致手臂渲染错误
response.Skin = slimHash
}
} else if len(textures) > 0 {
// 如果有多个材质或需要指定模型,使用完整格式
response.Textures = textures
}
// 如果没有材质,不设置 textures 和 skin 字段(留空)
// 设置缓存头
c.Header("Cache-Control", "public, max-age=300") // 5分钟缓存
c.Header("Content-Type", "application/json; charset=utf-8")
// 响应If-Modified-Since
if modifiedSince := c.GetHeader("If-Modified-Since"); modifiedSince != "" {
if t, err := time.Parse(http.TimeFormat, modifiedSince); err == nil {
// 如果资源未修改返回304
if profile.UpdatedAt.Before(t.Add(time.Second)) {
c.Status(http.StatusNotModified)
return
}
}
}
// 设置Last-Modified
c.Header("Last-Modified", profile.UpdatedAt.UTC().Format(http.TimeFormat))
c.JSON(http.StatusOK, response)
}
// GetTexture 获取资源文件
// @Summary 获取资源文件
// @Description CustomSkinAPI: 获取材质图片文件
// @Tags CustomSkinAPI
// @Param hash path string true "材质Hash"
// @Success 200 {file} binary
// @Failure 400 {object} map[string]string "资源标识符不能为空"
// @Failure 404 {object} map[string]string "资源未找到或不可用"
// @Router /api/v1/csl/textures/{hash} [get]
func (h *CustomSkinHandler) GetTexture(c *gin.Context) {
hash := c.Param("hash")
if hash == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "资源标识符不能为空"})
return
}
// 查找Texture
texture, err := h.container.TextureService.GetByHash(c.Request.Context(), hash)
if err != nil {
h.logger.Debug("未找到材质",
zap.String("hash", hash),
zap.Error(err),
)
c.JSON(http.StatusNotFound, gin.H{"error": "资源未找到"})
return
}
// 检查材质状态
if texture.Status != 1 {
c.JSON(http.StatusNotFound, gin.H{"error": "资源不可用"})
return
}
// 解析文件URL获取bucket和objectName
if h.container.Storage == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "存储服务不可用"})
return
}
bucket, objectName, err := h.container.Storage.ParseFileURL(texture.URL)
if err != nil {
h.logger.Error("解析文件URL失败",
zap.String("url", texture.URL),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, gin.H{"error": "解析文件URL失败"})
return
}
// 获取文件对象
ctx := c.Request.Context()
reader, objInfo, err := h.container.Storage.GetObject(ctx, bucket, objectName)
if err != nil {
h.logger.Error("获取文件失败",
zap.String("bucket", bucket),
zap.String("objectName", objectName),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取文件失败"})
return
}
defer reader.Close()
// 设置HTTP头
c.Header("Content-Type", objInfo.ContentType)
c.Header("Content-Length", fmt.Sprintf("%d", objInfo.Size))
c.Header("Last-Modified", objInfo.LastModified.UTC().Format(http.TimeFormat))
c.Header("ETag", objInfo.ETag)
c.Header("Cache-Control", "public, max-age=86400") // 24小时缓存
// 响应If-Modified-Since
if modifiedSince := c.GetHeader("If-Modified-Since"); modifiedSince != "" {
if t, err := time.Parse(http.TimeFormat, modifiedSince); err == nil {
// 如果资源未修改返回304
if objInfo.LastModified.Before(t.Add(time.Second)) {
c.Status(http.StatusNotModified)
return
}
}
}
// 响应If-None-Match (ETag)
if noneMatch := c.GetHeader("If-None-Match"); noneMatch != "" {
if noneMatch == objInfo.ETag || noneMatch == fmt.Sprintf(`"%s"`, objInfo.ETag) {
c.Status(http.StatusNotModified)
return
}
}
// 增加下载计数(异步)
go func() {
_ = h.container.TextureRepo.IncrementDownloadCount(ctx, texture.ID)
}()
// 流式传输文件内容
c.DataFromReader(http.StatusOK, objInfo.Size, objInfo.ContentType, reader, nil)
}

229
internal/handler/helpers.go Normal file
View File

@@ -0,0 +1,229 @@
package handler
import (
"carrotskin/internal/errors"
"carrotskin/internal/model"
"carrotskin/internal/types"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// parseIntWithDefault 将字符串解析为整数,解析失败返回默认值
func parseIntWithDefault(s string, defaultVal int) int {
val, err := strconv.Atoi(s)
if err != nil {
return defaultVal
}
return val
}
// GetUserIDFromContext 从上下文获取用户ID如果不存在返回未授权响应
// 返回值: userID, ok (如果ok为false已经发送了错误响应)
func GetUserIDFromContext(c *gin.Context) (int64, bool) {
userIDValue, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
model.MsgUnauthorized,
nil,
))
return 0, false
}
// 安全的类型断言
userID, ok := userIDValue.(int64)
if !ok {
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
model.CodeServerError,
"用户ID类型错误",
nil,
))
return 0, false
}
return userID, true
}
// UserToUserInfo 将 User 模型转换为 UserInfo 响应
func UserToUserInfo(user *model.User) *types.UserInfo {
return &types.UserInfo{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Avatar: user.Avatar,
Points: user.Points,
Role: user.Role,
Status: user.Status,
LastLoginAt: user.LastLoginAt,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
}
// UserToPublicUserInfo 将 User 模型转换为 PublicUserInfo 响应
func UserToPublicUserInfo(user *model.User) *types.PublicUserInfo {
return &types.PublicUserInfo{
ID: user.ID,
Username: user.Username,
Avatar: user.Avatar,
Points: user.Points,
Role: user.Role,
Status: user.Status,
CreatedAt: user.CreatedAt,
}
}
// ProfileToProfileInfo 将 Profile 模型转换为 ProfileInfo 响应
func ProfileToProfileInfo(profile *model.Profile) *types.ProfileInfo {
return &types.ProfileInfo{
UUID: profile.UUID,
UserID: profile.UserID,
Name: profile.Name,
SkinID: profile.SkinID,
CapeID: profile.CapeID,
LastUsedAt: profile.LastUsedAt,
CreatedAt: profile.CreatedAt,
UpdatedAt: profile.UpdatedAt,
}
}
// ProfilesToProfileInfos 批量转换 Profile 模型为 ProfileInfo 响应
func ProfilesToProfileInfos(profiles []*model.Profile) []*types.ProfileInfo {
result := make([]*types.ProfileInfo, 0, len(profiles))
for _, profile := range profiles {
result = append(result, ProfileToProfileInfo(profile))
}
return result
}
// TextureToTextureInfo 将 Texture 模型转换为 TextureInfo 响应
func TextureToTextureInfo(texture *model.Texture) *types.TextureInfo {
uploaderUsername := ""
if texture.Uploader != nil {
uploaderUsername = texture.Uploader.Username
}
return &types.TextureInfo{
ID: texture.ID,
UploaderID: texture.UploaderID,
UploaderUsername: uploaderUsername,
Name: texture.Name,
Description: texture.Description,
Type: types.TextureType(texture.Type),
URL: texture.URL,
Hash: texture.Hash,
Size: texture.Size,
IsPublic: texture.IsPublic,
DownloadCount: texture.DownloadCount,
FavoriteCount: texture.FavoriteCount,
IsSlim: texture.IsSlim,
Status: texture.Status,
CreatedAt: texture.CreatedAt,
UpdatedAt: texture.UpdatedAt,
}
}
// TexturesToTextureInfos 批量转换 Texture 模型为 TextureInfo 响应
func TexturesToTextureInfos(textures []*model.Texture) []*types.TextureInfo {
result := make([]*types.TextureInfo, len(textures))
for i, texture := range textures {
result[i] = TextureToTextureInfo(texture)
}
return result
}
// RespondBadRequest 返回400错误响应
func RespondBadRequest(c *gin.Context, message string, err error) {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
message,
err,
))
}
// RespondUnauthorized 返回401错误响应
func RespondUnauthorized(c *gin.Context, message string) {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
message,
nil,
))
}
// RespondForbidden 返回403错误响应
func RespondForbidden(c *gin.Context, message string) {
c.JSON(http.StatusForbidden, model.NewErrorResponse(
model.CodeForbidden,
message,
nil,
))
}
// RespondNotFound 返回404错误响应
func RespondNotFound(c *gin.Context, message string) {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
message,
nil,
))
}
// RespondServerError 返回500错误响应
func RespondServerError(c *gin.Context, message string, err error) {
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
model.CodeServerError,
message,
err,
))
}
// RespondSuccess 返回成功响应
func RespondSuccess(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, model.NewSuccessResponse(data))
}
// RespondWithError 根据错误类型自动选择状态码
func RespondWithError(c *gin.Context, err error) {
if err == nil {
return
}
// 使用errors.Is检查预定义错误
if errors.Is(err, errors.ErrUserNotFound) ||
errors.Is(err, errors.ErrProfileNotFound) ||
errors.Is(err, errors.ErrTextureNotFound) ||
errors.Is(err, errors.ErrNotFound) {
RespondNotFound(c, err.Error())
return
}
if errors.Is(err, errors.ErrProfileNoPermission) ||
errors.Is(err, errors.ErrTextureNoPermission) ||
errors.Is(err, errors.ErrForbidden) {
RespondForbidden(c, err.Error())
return
}
if errors.Is(err, errors.ErrUnauthorized) ||
errors.Is(err, errors.ErrInvalidToken) ||
errors.Is(err, errors.ErrTokenExpired) {
RespondUnauthorized(c, err.Error())
return
}
// 检查AppError类型
var appErr *errors.AppError
if errors.As(err, &appErr) {
c.JSON(appErr.Code, model.NewErrorResponse(
appErr.Code,
appErr.Message,
appErr.Err,
))
return
}
// 默认返回500错误
RespondServerError(c, err.Error(), err)
}

View File

@@ -1,18 +1,28 @@
package handler
import (
"carrotskin/internal/model"
"carrotskin/internal/service"
"carrotskin/internal/container"
"carrotskin/internal/types"
"carrotskin/pkg/database"
"carrotskin/pkg/logger"
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// CreateProfile 创建档案
// ProfileHandler 档案处理器
type ProfileHandler struct {
container *container.Container
logger *zap.Logger
}
// NewProfileHandler 创建ProfileHandler实例
func NewProfileHandler(c *container.Container) *ProfileHandler {
return &ProfileHandler{
container: c,
logger: c.Logger,
}
}
// Create 创建档案
// @Summary 创建Minecraft档案
// @Description 创建新的Minecraft角色档案UUID由后端自动生成
// @Tags profile
@@ -20,181 +30,101 @@ import (
// @Produce json
// @Security BearerAuth
// @Param request body types.CreateProfileRequest true "档案信息(仅需提供角色名)"
// @Success 200 {object} model.Response{data=types.ProfileInfo} "创建成功返回完整档案信息含自动生成的UUID"
// @Failure 400 {object} model.ErrorResponse "请求参数错误或已达档案数量上限"
// @Failure 401 {object} model.ErrorResponse "未授权"
// @Failure 500 {object} model.ErrorResponse "服务器错误"
// @Success 200 {object} model.Response{data=types.ProfileInfo} "创建成功"
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
// @Router /api/v1/profile [post]
func CreateProfile(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
// 获取用户ID
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
"未授权",
nil,
))
func (h *ProfileHandler) Create(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
// 解析请求
var req types.CreateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误: "+err.Error(),
nil,
))
RespondBadRequest(c, "请求参数错误: "+err.Error(), nil)
return
}
// TODO: 从配置或数据库读取限制
maxProfiles := 5
db := database.MustGetDB()
// 检查档案数量限制
if err := service.CheckProfileLimit(db, userID.(int64), maxProfiles); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
maxProfiles := h.container.UserService.GetMaxProfilesPerUser()
if err := h.container.ProfileService.CheckLimit(c.Request.Context(), userID, maxProfiles); err != nil {
RespondBadRequest(c, err.Error(), nil)
return
}
// 创建档案
profile, err := service.CreateProfile(db, userID.(int64), req.Name)
profile, err := h.container.ProfileService.Create(c.Request.Context(), userID, req.Name)
if err != nil {
loggerInstance.Error("创建档案失败",
zap.Int64("user_id", userID.(int64)),
h.logger.Error("创建档案失败",
zap.Int64("user_id", userID),
zap.String("name", req.Name),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
model.CodeServerError,
err.Error(),
nil,
))
RespondServerError(c, err.Error(), nil)
return
}
// 返回成功响应
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.ProfileInfo{
UUID: profile.UUID,
UserID: profile.UserID,
Name: profile.Name,
SkinID: profile.SkinID,
CapeID: profile.CapeID,
IsActive: profile.IsActive,
LastUsedAt: profile.LastUsedAt,
CreatedAt: profile.CreatedAt,
UpdatedAt: profile.UpdatedAt,
}))
RespondSuccess(c, ProfileToProfileInfo(profile))
}
// GetProfiles 获取档案列表
// List 获取档案列表
// @Summary 获取档案列表
// @Description 获取当前用户的所有档案
// @Tags profile
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} model.Response "获取成功"
// @Failure 401 {object} model.ErrorResponse "未授权"
// @Success 200 {object} model.Response{data=[]types.ProfileInfo} "获取成功"
// @Failure 500 {object} model.ErrorResponse "服务器错误"
// @Router /api/v1/profile [get]
func GetProfiles(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
// 获取用户ID
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
"未授权",
nil,
))
func (h *ProfileHandler) List(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
// 查询档案列表
profiles, err := service.GetUserProfiles(database.MustGetDB(), userID.(int64))
profiles, err := h.container.ProfileService.GetByUserID(c.Request.Context(), userID)
if err != nil {
loggerInstance.Error("获取档案列表失败",
zap.Int64("user_id", userID.(int64)),
h.logger.Error("获取档案列表失败",
zap.Int64("user_id", userID),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
model.CodeServerError,
err.Error(),
nil,
))
RespondServerError(c, err.Error(), nil)
return
}
// 转换为响应格式
result := make([]*types.ProfileInfo, 0, len(profiles))
for _, profile := range profiles {
result = append(result, &types.ProfileInfo{
UUID: profile.UUID,
UserID: profile.UserID,
Name: profile.Name,
SkinID: profile.SkinID,
CapeID: profile.CapeID,
IsActive: profile.IsActive,
LastUsedAt: profile.LastUsedAt,
CreatedAt: profile.CreatedAt,
UpdatedAt: profile.UpdatedAt,
})
}
c.JSON(http.StatusOK, model.NewSuccessResponse(result))
RespondSuccess(c, ProfilesToProfileInfos(profiles))
}
// GetProfile 获取档案详情
// Get 获取档案详情
// @Summary 获取档案详情
// @Description 根据UUID获取档案详细信息
// @Tags profile
// @Accept json
// @Produce json
// @Param uuid path string true "档案UUID"
// @Success 200 {object} model.Response "获取成功"
// @Success 200 {object} model.Response{data=types.ProfileInfo} "获取成功"
// @Failure 404 {object} model.ErrorResponse "档案不存在"
// @Failure 500 {object} model.ErrorResponse "服务器错误"
// @Router /api/v1/profile/{uuid} [get]
func GetProfile(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
func (h *ProfileHandler) Get(c *gin.Context) {
uuid := c.Param("uuid")
// 查询档案
profile, err := service.GetProfileByUUID(database.MustGetDB(), uuid)
if err != nil {
loggerInstance.Error("获取档案失败",
zap.String("uuid", uuid),
zap.Error(err),
)
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
err.Error(),
nil,
))
if uuid == "" {
RespondBadRequest(c, "UUID不能为空", nil)
return
}
// 返回成功响应
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.ProfileInfo{
UUID: profile.UUID,
UserID: profile.UserID,
Name: profile.Name,
SkinID: profile.SkinID,
CapeID: profile.CapeID,
IsActive: profile.IsActive,
LastUsedAt: profile.LastUsedAt,
CreatedAt: profile.CreatedAt,
UpdatedAt: profile.UpdatedAt,
}))
profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), uuid)
if err != nil {
h.logger.Error("获取档案失败",
zap.String("uuid", uuid),
zap.Error(err),
)
RespondNotFound(c, err.Error())
return
}
RespondSuccess(c, ProfileToProfileInfo(profile))
}
// UpdateProfile 更新档案
// Update 更新档案
// @Summary 更新档案
// @Description 更新档案信息
// @Tags profile
@@ -203,83 +133,47 @@ func GetProfile(c *gin.Context) {
// @Security BearerAuth
// @Param uuid path string true "档案UUID"
// @Param request body types.UpdateProfileRequest true "更新信息"
// @Success 200 {object} model.Response "更新成功"
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
// @Failure 401 {object} model.ErrorResponse "未授权"
// @Success 200 {object} model.Response{data=types.ProfileInfo} "更新成功"
// @Failure 403 {object} model.ErrorResponse "无权操作"
// @Failure 404 {object} model.ErrorResponse "档案不存在"
// @Failure 500 {object} model.ErrorResponse "服务器错误"
// @Router /api/v1/profile/{uuid} [put]
func UpdateProfile(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
uuid := c.Param("uuid")
// 获取用户ID
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
"未授权",
nil,
))
func (h *ProfileHandler) Update(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
uuid := c.Param("uuid")
if uuid == "" {
RespondBadRequest(c, "UUID不能为空", nil)
return
}
// 解析请求
var req types.UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误: "+err.Error(),
nil,
))
RespondBadRequest(c, "请求参数错误: "+err.Error(), nil)
return
}
// 更新档案
var namePtr *string
if req.Name != "" {
namePtr = &req.Name
}
profile, err := service.UpdateProfile(database.MustGetDB(), uuid, userID.(int64), namePtr, req.SkinID, req.CapeID)
profile, err := h.container.ProfileService.Update(c.Request.Context(), uuid, userID, namePtr, req.SkinID, req.CapeID)
if err != nil {
loggerInstance.Error("更新档案失败",
h.logger.Error("更新档案失败",
zap.String("uuid", uuid),
zap.Int64("user_id", userID.(int64)),
zap.Int64("user_id", userID),
zap.Error(err),
)
statusCode := http.StatusInternalServerError
if err.Error() == "档案不存在" {
statusCode = http.StatusNotFound
} else if err.Error() == "无权操作此档案" {
statusCode = http.StatusForbidden
}
c.JSON(statusCode, model.NewErrorResponse(
model.CodeServerError,
err.Error(),
nil,
))
RespondWithError(c, err)
return
}
// 返回成功响应
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.ProfileInfo{
UUID: profile.UUID,
UserID: profile.UserID,
Name: profile.Name,
SkinID: profile.SkinID,
CapeID: profile.CapeID,
IsActive: profile.IsActive,
LastUsedAt: profile.LastUsedAt,
CreatedAt: profile.CreatedAt,
UpdatedAt: profile.UpdatedAt,
}))
RespondSuccess(c, ProfileToProfileInfo(profile))
}
// DeleteProfile 删除档案
// Delete 删除档案
// @Summary 删除档案
// @Description 删除指定的Minecraft档案
// @Tags profile
@@ -287,112 +181,30 @@ func UpdateProfile(c *gin.Context) {
// @Produce json
// @Security BearerAuth
// @Param uuid path string true "档案UUID"
// @Success 200 {object} model.Response "删除成功"
// @Failure 401 {object} model.ErrorResponse "未授权"
// @Success 200 {object} model.Response{data=map[string]string} "删除成功"
// @Failure 403 {object} model.ErrorResponse "无权操作"
// @Failure 404 {object} model.ErrorResponse "档案不存在"
// @Failure 500 {object} model.ErrorResponse "服务器错误"
// @Router /api/v1/profile/{uuid} [delete]
func DeleteProfile(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
uuid := c.Param("uuid")
// 获取用户ID
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
"未授权",
nil,
))
func (h *ProfileHandler) Delete(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
// 删除档案
err := service.DeleteProfile(database.MustGetDB(), uuid, userID.(int64))
if err != nil {
loggerInstance.Error("删除档案失败",
uuid := c.Param("uuid")
if uuid == "" {
RespondBadRequest(c, "UUID不能为空", nil)
return
}
if err := h.container.ProfileService.Delete(c.Request.Context(), uuid, userID); err != nil {
h.logger.Error("删除档案失败",
zap.String("uuid", uuid),
zap.Int64("user_id", userID.(int64)),
zap.Int64("user_id", userID),
zap.Error(err),
)
statusCode := http.StatusInternalServerError
if err.Error() == "档案不存在" {
statusCode = http.StatusNotFound
} else if err.Error() == "无权操作此档案" {
statusCode = http.StatusForbidden
}
c.JSON(statusCode, model.NewErrorResponse(
model.CodeServerError,
err.Error(),
nil,
))
RespondWithError(c, err)
return
}
// 返回成功响应
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "删除成功",
}))
}
// SetActiveProfile 设置活跃档案
// @Summary 设置活跃档案
// @Description 将指定档案设置为活跃状态
// @Tags profile
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param uuid path string true "档案UUID"
// @Success 200 {object} model.Response "设置成功"
// @Failure 401 {object} model.ErrorResponse "未授权"
// @Failure 403 {object} model.ErrorResponse "无权操作"
// @Failure 404 {object} model.ErrorResponse "档案不存在"
// @Failure 500 {object} model.ErrorResponse "服务器错误"
// @Router /api/v1/profile/{uuid}/activate [post]
func SetActiveProfile(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
uuid := c.Param("uuid")
// 获取用户ID
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
"未授权",
nil,
))
return
}
// 设置活跃状态
err := service.SetActiveProfile(database.MustGetDB(), uuid, userID.(int64))
if err != nil {
loggerInstance.Error("设置活跃档案失败",
zap.String("uuid", uuid),
zap.Int64("user_id", userID.(int64)),
zap.Error(err),
)
statusCode := http.StatusInternalServerError
if err.Error() == "档案不存在" {
statusCode = http.StatusNotFound
} else if err.Error() == "无权操作此档案" {
statusCode = http.StatusForbidden
}
c.JSON(statusCode, model.NewErrorResponse(
model.CodeServerError,
err.Error(),
nil,
))
return
}
// 返回成功响应
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "设置成功",
}))
RespondSuccess(c, gin.H{"message": "删除成功"})
}

View File

@@ -0,0 +1,495 @@
package handler
import (
"carrotskin/internal/container"
"carrotskin/internal/model"
"strconv"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// ReportHandler 举报处理器
type ReportHandler struct {
container *container.Container
logger *zap.Logger
}
// NewReportHandler 创建ReportHandler实例
func NewReportHandler(c *container.Container) *ReportHandler {
return &ReportHandler{
container: c,
logger: c.Logger,
}
}
// CreateReportRequest 创建举报请求
type CreateReportRequest struct {
TargetType string `json:"target_type" binding:"required"` // "texture" 或 "user"
TargetID int64 `json:"target_id" binding:"required"`
Reason string `json:"reason" binding:"required"`
}
// CreateReport 创建举报
// @Summary 创建举报
// @Description 用户举报皮肤或其他用户
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body CreateReportRequest true "举报信息"
// @Success 200 {object} model.Response{data=model.Report} "创建成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 401 {object} model.ErrorResponse "未授权"
// @Router /api/v1/report [post]
func (h *ReportHandler) CreateReport(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req CreateReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
// 转换目标类型
var targetType model.ReportType
switch req.TargetType {
case "texture":
targetType = model.ReportTypeTexture
case "user":
targetType = model.ReportTypeUser
default:
RespondBadRequest(c, "无效的举报类型", nil)
return
}
report, err := h.container.ReportService.CreateReport(c.Request.Context(), userID, targetType, req.TargetID, req.Reason)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, report)
}
// GetByID 获取举报详情
// @Summary 获取举报详情
// @Description 获取指定ID的举报详细信息
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path int true "举报ID"
// @Success 200 {object} model.Response{data=model.Report} "获取成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 404 {object} model.ErrorResponse "举报不存在"
// @Router /api/v1/report/{id} [get]
func (h *ReportHandler) GetByID(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的举报ID", err)
return
}
report, err := h.container.ReportService.GetByID(c.Request.Context(), id)
if err != nil {
RespondNotFound(c, err.Error())
return
}
RespondSuccess(c, report)
}
// GetByReporterID 获取举报人的举报记录
// @Summary 获取举报人的举报记录
// @Description 获取指定用户的举报记录列表
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param reporter_id path int true "举报人ID"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Router /api/v1/report/reporter/{reporter_id} [get]
func (h *ReportHandler) GetByReporterID(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
reporterID, err := strconv.ParseInt(c.Param("reporter_id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的举报人ID", err)
return
}
page := parseIntWithDefault(c.DefaultQuery("page", "1"), 1)
pageSize := parseIntWithDefault(c.DefaultQuery("page_size", "20"), 20)
reports, total, err := h.container.ReportService.GetByReporterID(c.Request.Context(), reporterID, userID, page, pageSize)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, gin.H{
"list": reports,
"total": total,
"page": page,
"per_page": pageSize,
})
}
// GetByTarget 获取目标对象的举报记录
// @Summary 获取目标对象的举报记录
// @Description 获取指定目标对象的举报记录列表(仅管理员)
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param target_type path string true "目标类型 (texture/user)"
// @Param target_id path int true "目标ID"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 403 {object} model.ErrorResponse "无权访问"
// @Router /api/v1/report/target/{target_type}/{target_id} [get]
func (h *ReportHandler) GetByTarget(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
targetTypeStr := c.Param("target_type")
targetID, err := strconv.ParseInt(c.Param("target_id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的目标ID", err)
return
}
var targetType model.ReportType
switch targetTypeStr {
case "texture":
targetType = model.ReportTypeTexture
case "user":
targetType = model.ReportTypeUser
default:
RespondBadRequest(c, "无效的目标类型", nil)
return
}
page := parseIntWithDefault(c.DefaultQuery("page", "1"), 1)
pageSize := parseIntWithDefault(c.DefaultQuery("page_size", "20"), 20)
reports, total, err := h.container.ReportService.GetByTarget(c.Request.Context(), targetType, targetID, userID, page, pageSize)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, gin.H{
"list": reports,
"total": total,
"page": page,
"per_page": pageSize,
})
}
// GetByStatus 根据状态查询举报记录
// @Summary 根据状态查询举报记录
// @Description 根据状态查询举报记录列表(仅管理员)
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param status path string true "状态 (pending/approved/rejected)"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Router /api/v1/report/status/{status} [get]
func (h *ReportHandler) GetByStatus(c *gin.Context) {
statusStr := c.Param("status")
var status model.ReportStatus
switch statusStr {
case "pending":
status = model.ReportStatusPending
case "approved":
status = model.ReportStatusApproved
case "rejected":
status = model.ReportStatusRejected
default:
RespondBadRequest(c, "无效的状态", nil)
return
}
page := parseIntWithDefault(c.DefaultQuery("page", "1"), 1)
pageSize := parseIntWithDefault(c.DefaultQuery("page_size", "20"), 20)
reports, total, err := h.container.ReportService.GetByStatus(c.Request.Context(), status, page, pageSize)
if err != nil {
RespondServerError(c, err.Error(), err)
return
}
RespondSuccess(c, gin.H{
"list": reports,
"total": total,
"page": page,
"per_page": pageSize,
})
}
// Search 搜索举报记录
// @Summary 搜索举报记录
// @Description 搜索举报记录(仅管理员)
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param keyword query int false "关键词举报人ID或目标ID"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 403 {object} model.ErrorResponse "无权访问"
// @Router /api/v1/report/search [get]
func (h *ReportHandler) Search(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
keywordStr := c.Query("keyword")
keyword, err := strconv.ParseInt(keywordStr, 10, 64)
if err != nil {
RespondBadRequest(c, "无效的关键词", err)
return
}
page := parseIntWithDefault(c.DefaultQuery("page", "1"), 1)
pageSize := parseIntWithDefault(c.DefaultQuery("page_size", "20"), 20)
reports, total, err := h.container.ReportService.Search(c.Request.Context(), keyword, userID, page, pageSize)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, gin.H{
"list": reports,
"total": total,
"page": page,
"per_page": pageSize,
})
}
// ReviewRequest 处理举报请求
type ReviewRequest struct {
Status string `json:"status" binding:"required"` // "approved" 或 "rejected"
ReviewNote string `json:"review_note"`
}
// Review 处理举报记录
// @Summary 处理举报记录
// @Description 管理员处理举报记录
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path int true "举报ID"
// @Param request body ReviewRequest true "处理信息"
// @Success 200 {object} model.Response{data=model.Report} "处理成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 403 {object} model.ErrorResponse "无权访问"
// @Router /api/v1/report/{id}/review [put]
func (h *ReportHandler) Review(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的举报ID", err)
return
}
var req ReviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
// 转换状态
var status model.ReportStatus
switch req.Status {
case "approved":
status = model.ReportStatusApproved
case "rejected":
status = model.ReportStatusRejected
default:
RespondBadRequest(c, "无效的状态", nil)
return
}
report, err := h.container.ReportService.Review(c.Request.Context(), id, userID, status, req.ReviewNote)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, report)
}
// BatchReviewRequest 批量处理举报请求
type BatchReviewRequest struct {
IDs []int64 `json:"ids" binding:"required"`
Status string `json:"status" binding:"required"` // "approved" 或 "rejected"
ReviewNote string `json:"review_note"`
}
// BatchReview 批量处理举报记录
// @Summary 批量处理举报记录
// @Description 管理员批量处理举报记录
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body BatchReviewRequest true "处理信息"
// @Success 200 {object} model.Response{data=map[string]interface{}} "处理成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 403 {object} model.ErrorResponse "无权访问"
// @Router /api/v1/report/batch-review [put]
func (h *ReportHandler) BatchReview(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req BatchReviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
// 转换状态
var status model.ReportStatus
switch req.Status {
case "approved":
status = model.ReportStatusApproved
case "rejected":
status = model.ReportStatusRejected
default:
RespondBadRequest(c, "无效的状态", nil)
return
}
affected, err := h.container.ReportService.BatchReview(c.Request.Context(), req.IDs, userID, status, req.ReviewNote)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, gin.H{
"affected": affected,
})
}
// Delete 删除举报记录
// @Summary 删除举报记录
// @Description 删除指定的举报记录
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path int true "举报ID"
// @Success 200 {object} model.Response "删除成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 403 {object} model.ErrorResponse "无权访问"
// @Router /api/v1/report/{id} [delete]
func (h *ReportHandler) Delete(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的举报ID", err)
return
}
if err := h.container.ReportService.Delete(c.Request.Context(), id, userID); err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, nil)
}
// BatchDeleteRequest 批量删除请求
type BatchDeleteRequest struct {
IDs []int64 `json:"ids" binding:"required"`
}
// BatchDelete 批量删除举报记录
// @Summary 批量删除举报记录
// @Description 管理员批量删除举报记录
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body BatchDeleteRequest true "删除信息"
// @Success 200 {object} model.Response{data=map[string]interface{}} "删除成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 403 {object} model.ErrorResponse "无权访问"
// @Router /api/v1/report/batch-delete [delete]
func (h *ReportHandler) BatchDelete(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req BatchDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
affected, err := h.container.ReportService.BatchDelete(c.Request.Context(), req.IDs, userID)
if err != nil {
RespondBadRequest(c, err.Error(), err)
return
}
RespondSuccess(c, gin.H{
"affected": affected,
})
}
// GetStats 获取举报统计信息
// @Summary 获取举报统计信息
// @Description 获取举报统计信息(仅管理员)
// @Tags report
// @Accept json
// @Produce json
// @Security Bearer
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Router /api/v1/report/stats [get]
func (h *ReportHandler) GetStats(c *gin.Context) {
stats, err := h.container.ReportService.GetStats(c.Request.Context())
if err != nil {
RespondServerError(c, err.Error(), err)
return
}
RespondSuccess(c, stats)
}

View File

@@ -1,139 +1,268 @@
package handler
import (
"carrotskin/internal/container"
"carrotskin/internal/middleware"
"carrotskin/internal/model"
"carrotskin/pkg/auth"
"carrotskin/pkg/config"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
// RegisterRoutes 注册所有路由
func RegisterRoutes(router *gin.Engine) {
// 设置Swagger文档
SetupSwagger(router)
// Handlers 集中管理所有Handler
type Handlers struct {
Auth *AuthHandler
User *UserHandler
Texture *TextureHandler
Profile *ProfileHandler
Captcha *CaptchaHandler
Yggdrasil *YggdrasilHandler
CustomSkin *CustomSkinHandler
Admin *AdminHandler
Report *ReportHandler
}
// NewHandlers 创建所有Handler实例
func NewHandlers(c *container.Container) *Handlers {
return &Handlers{
Auth: NewAuthHandler(c),
User: NewUserHandler(c),
Texture: NewTextureHandler(c),
Profile: NewProfileHandler(c),
Captcha: NewCaptchaHandler(c),
Yggdrasil: NewYggdrasilHandler(c),
CustomSkin: NewCustomSkinHandler(c),
Admin: NewAdminHandler(c),
Report: NewReportHandler(c),
}
}
// RegisterRoutesWithDI 使用依赖注入注册所有路由
func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
// 健康检查路由
router.GET("/health", HealthCheck)
// Swagger文档路由
cfg, _ := config.GetConfig()
if cfg != nil && cfg.Server.SwaggerEnabled {
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
}
// 创建Handler实例
h := NewHandlers(c)
// API路由组
v1 := router.Group("/api/v1")
{
// 认证路由无需JWT
authGroup := v1.Group("/auth")
{
authGroup.POST("/register", Register)
authGroup.POST("/login", Login)
authGroup.POST("/send-code", SendVerificationCode)
authGroup.POST("/reset-password", ResetPassword)
}
registerAuthRoutes(v1, h.Auth)
// 用户路由需要JWT认证
userGroup := v1.Group("/user")
userGroup.Use(middleware.AuthMiddleware())
{
userGroup.GET("/profile", GetUserProfile)
userGroup.PUT("/profile", UpdateUserProfile)
// 头像相关
userGroup.POST("/avatar/upload-url", GenerateAvatarUploadURL)
userGroup.PUT("/avatar", UpdateAvatar)
// 更换邮箱
userGroup.POST("/change-email", ChangeEmail)
}
registerUserRoutes(v1, h.User, c.JWT)
// 材质路由
textureGroup := v1.Group("/texture")
{
// 公开路由(无需认证)
textureGroup.GET("", SearchTextures) // 搜索材质
textureGroup.GET("/:id", GetTexture) // 获取材质详情
// 需要认证的路由
textureAuth := textureGroup.Group("")
textureAuth.Use(middleware.AuthMiddleware())
{
textureAuth.POST("/upload-url", GenerateTextureUploadURL) // 生成上传URL
textureAuth.POST("", CreateTexture) // 创建材质记录
textureAuth.PUT("/:id", UpdateTexture) // 更新材质
textureAuth.DELETE("/:id", DeleteTexture) // 删除材质
textureAuth.POST("/:id/favorite", ToggleFavorite) // 切换收藏
textureAuth.GET("/my", GetUserTextures) // 我的材质
textureAuth.GET("/favorites", GetUserFavorites) // 我的收藏
}
}
registerTextureRoutes(v1, h.Texture, c.JWT)
// 档案路由
profileGroup := v1.Group("/profile")
{
// 公开路由(无需认证)
profileGroup.GET("/:uuid", GetProfile) // 获取档案详情
registerProfileRoutesWithDI(v1, h.Profile, c.JWT)
// 需要认证的路由
profileAuth := profileGroup.Group("")
profileAuth.Use(middleware.AuthMiddleware())
{
profileAuth.POST("/", CreateProfile) // 创建档案
profileAuth.GET("/", GetProfiles) // 获取我的档案列表
profileAuth.PUT("/:uuid", UpdateProfile) // 更新档案
profileAuth.DELETE("/:uuid", DeleteProfile) // 删除档案
profileAuth.POST("/:uuid/activate", SetActiveProfile) // 设置活跃档案
}
}
// 验证码路由
captchaGroup := v1.Group("/captcha")
{
captchaGroup.GET("/generate", Generate) //生成验证码
captchaGroup.POST("/verify", Verify) //验证验证码
}
registerCaptchaRoutesWithDI(v1, h.Captcha)
// Yggdrasil API路由组
ygg := v1.Group("/yggdrasil")
registerYggdrasilRoutesWithDI(v1, h.Yggdrasil)
// CustomSkinAPI 路由
registerCustomSkinRoutes(v1, h.CustomSkin)
// 管理员路由(需要管理员权限)
registerAdminRoutes(v1, c, h.Admin)
// 举报路由
registerReportRoutes(v1, h.Report, c.JWT)
}
}
// registerAuthRoutes 注册认证路由
func registerAuthRoutes(v1 *gin.RouterGroup, h *AuthHandler) {
authGroup := v1.Group("/auth")
{
authGroup.POST("/register", h.Register)
authGroup.POST("/login", h.Login)
authGroup.POST("/send-code", h.SendVerificationCode)
authGroup.POST("/reset-password", h.ResetPassword)
}
}
// registerUserRoutes 注册用户路由
func registerUserRoutes(v1 *gin.RouterGroup, h *UserHandler, jwtService *auth.JWTService) {
// 公开用户信息路由(无需认证)
v1.GET("/users/public", h.GetPublicInfo)
// 需要认证的用户路由
userGroup := v1.Group("/user")
userGroup.Use(middleware.AuthMiddleware(jwtService))
{
userGroup.GET("/profile", h.GetProfile)
userGroup.PUT("/profile", h.UpdateProfile)
// 头像相关
userGroup.POST("/avatar/upload", h.UploadAvatar) // 直接上传头像文件
userGroup.PUT("/avatar", h.UpdateAvatar) // 更新头像URL外部URL
// 更换邮箱
userGroup.POST("/change-email", h.ChangeEmail)
// Yggdrasil密码相关
userGroup.POST("/yggdrasil-password/reset", h.ResetYggdrasilPassword)
}
}
// registerTextureRoutes 注册材质路由
func registerTextureRoutes(v1 *gin.RouterGroup, h *TextureHandler, jwtService *auth.JWTService) {
textureGroup := v1.Group("/texture")
{
// 公开路由(无需认证)
textureGroup.GET("", h.Search)
textureGroup.GET("/:id", h.Get)
// 需要认证的路由
textureAuth := textureGroup.Group("")
textureAuth.Use(middleware.AuthMiddleware(jwtService))
{
ygg.GET("", GetMetaData)
ygg.POST("/minecraftservices/player/certificates", GetPlayerCertificates)
authserver := ygg.Group("/authserver")
{
authserver.POST("/authenticate", Authenticate)
authserver.POST("/validate", ValidToken)
authserver.POST("/refresh", RefreshToken)
authserver.POST("/invalidate", InvalidToken)
authserver.POST("/signout", SignOut)
}
sessionServer := ygg.Group("/sessionserver")
{
sessionServer.GET("/session/minecraft/profile/:uuid", GetProfileByUUID)
sessionServer.POST("/session/minecraft/join", JoinServer)
sessionServer.GET("/session/minecraft/hasJoined", HasJoinedServer)
}
api := ygg.Group("/api")
profiles := api.Group("/profiles")
{
profiles.POST("/minecraft", GetProfilesByName)
}
}
// 系统路由
system := v1.Group("/system")
{
system.GET("/config", GetSystemConfig)
textureAuth.POST("/upload", h.Upload) // 直接上传文件
textureAuth.PUT("/:id", h.Update)
textureAuth.DELETE("/:id", h.Delete)
textureAuth.POST("/:id/favorite", h.ToggleFavorite)
textureAuth.GET("/my", h.GetUserTextures)
textureAuth.GET("/favorites", h.GetUserFavorites)
}
}
}
// 以下是系统配置相关的占位符函数,待后续实现
// registerProfileRoutesWithDI 注册档案路由(依赖注入版本)
func registerProfileRoutesWithDI(v1 *gin.RouterGroup, h *ProfileHandler, jwtService *auth.JWTService) {
profileGroup := v1.Group("/profile")
{
// 公开路由(无需认证)
profileGroup.GET("/:uuid", h.Get)
// GetSystemConfig 获取系统配置
// @Summary 获取系统配置
// @Description 获取公开的系统配置信息
// @Tags system
// @Accept json
// @Produce json
// @Success 200 {object} model.Response "获取成功"
// @Router /api/v1/system/config [get]
func GetSystemConfig(c *gin.Context) {
// TODO: 实现从数据库读取系统配置
c.JSON(200, model.NewSuccessResponse(gin.H{
"site_name": "CarrotSkin",
"site_description": "A Minecraft Skin Station",
"registration_enabled": true,
"max_textures_per_user": 100,
"max_profiles_per_user": 5,
}))
// 需要认证的路由
profileAuth := profileGroup.Group("")
profileAuth.Use(middleware.AuthMiddleware(jwtService))
{
// 同时支持 /api/v1/profile 和 /api/v1/profile/ 两种形式返回列表与创建
profileAuth.GET("", h.List)
profileAuth.POST("", h.Create)
profileAuth.POST("/", h.Create)
profileAuth.GET("/", h.List)
profileAuth.PUT("/:uuid", h.Update)
profileAuth.DELETE("/:uuid", h.Delete)
}
}
}
// registerCaptchaRoutesWithDI 注册验证码路由(依赖注入版本)
func registerCaptchaRoutesWithDI(v1 *gin.RouterGroup, h *CaptchaHandler) {
captchaGroup := v1.Group("/captcha")
{
captchaGroup.GET("/generate", h.Generate)
captchaGroup.POST("/verify", h.Verify)
}
}
// registerYggdrasilRoutesWithDI 注册Yggdrasil API路由依赖注入版本
func registerYggdrasilRoutesWithDI(v1 *gin.RouterGroup, h *YggdrasilHandler) {
ygg := v1.Group("/yggdrasil")
{
ygg.GET("", h.GetMetaData)
ygg.POST("/minecraftservices/player/certificates", h.GetPlayerCertificates)
authserver := ygg.Group("/authserver")
{
authserver.POST("/authenticate", h.Authenticate)
authserver.POST("/validate", h.ValidToken)
authserver.POST("/refresh", h.RefreshToken)
authserver.POST("/invalidate", h.InvalidToken)
authserver.POST("/signout", h.SignOut)
}
sessionServer := ygg.Group("/sessionserver")
{
sessionServer.GET("/session/minecraft/profile/:uuid", h.GetProfileByUUID)
sessionServer.POST("/session/minecraft/join", h.JoinServer)
sessionServer.GET("/session/minecraft/hasJoined", h.HasJoinedServer)
}
api := ygg.Group("/api")
profiles := api.Group("/profiles")
{
profiles.POST("/minecraft", h.GetProfilesByName)
}
}
}
// registerAdminRoutes 注册管理员路由
func registerAdminRoutes(v1 *gin.RouterGroup, c *container.Container, h *AdminHandler) {
admin := v1.Group("/admin")
admin.Use(middleware.AuthMiddleware(c.JWT))
admin.Use(middleware.RequireAdmin())
{
// 用户管理
admin.GET("/users", h.GetUserList)
admin.GET("/users/:id", h.GetUserDetail)
admin.PUT("/users/role", h.SetUserRole)
admin.PUT("/users/status", h.SetUserStatus)
// 材质管理(审核)
admin.GET("/textures", h.GetTextureList)
admin.DELETE("/textures/:id", h.DeleteTexture)
// 权限管理
admin.GET("/permissions", h.GetPermissions)
}
}
// registerCustomSkinRoutes 注册CustomSkinAPI路由
// CustomSkinAPI 协议要求根地址必须以 / 结尾
// 路由格式:
// - {ROOT}/{USERNAME}.json - 获取玩家信息
// - {ROOT}/textures/{hash} - 获取资源文件
//
// 根路径为 /api/v1/csl/
func registerCustomSkinRoutes(v1 *gin.RouterGroup, h *CustomSkinHandler) {
// CustomSkinAPI 路由组
csl := v1.Group("/csl")
{
// 获取玩家信息: {ROOT}/{USERNAME}.json
csl.GET("/:username", h.GetPlayerInfo)
// 获取资源文件: {ROOT}/textures/{hash}
csl.GET("/textures/:hash", h.GetTexture)
}
}
// registerReportRoutes 注册举报路由
func registerReportRoutes(v1 *gin.RouterGroup, h *ReportHandler, jwtService *auth.JWTService) {
reportGroup := v1.Group("/report")
{
// 公开路由(无需认证)
reportGroup.GET("/stats", h.GetStats)
// 需要认证的路由
reportAuth := reportGroup.Group("")
reportAuth.Use(middleware.AuthMiddleware(jwtService))
{
reportAuth.POST("", h.CreateReport)
reportAuth.GET("/:id", h.GetByID)
reportAuth.GET("/reporter_id", h.GetByReporterID)
reportAuth.GET("/target", h.GetByTarget)
reportAuth.GET("/status", h.GetByStatus)
reportAuth.GET("/search", h.Search)
reportAuth.PUT("/:id/review", h.Review)
reportAuth.POST("/batch-review", h.BatchReview)
reportAuth.DELETE("/:id", h.Delete)
reportAuth.POST("/batch-delete", h.BatchDelete)
}
}
}

View File

@@ -1,62 +1,95 @@
package handler
import (
"context"
"errors"
"net/http"
"time"
"carrotskin/pkg/database"
"carrotskin/pkg/redis"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
// @title CarrotSkin API
// @version 1.0
// @description CarrotSkin 是一个优秀的 Minecraft 皮肤站 API 服务
// @description
// @description ## 功能特性
// @description - 用户注册/登录/管理
// @description - 材质上传/下载/管理
// @description - Minecraft 档案管理
// @description - 权限控制系统
// @description - 积分系统
// @description
// @description ## 认证方式
// @description 使用 JWT Token 进行身份认证,需要在请求头中包含:
// @description ```
// @description Authorization: Bearer <your-jwt-token>
// @description ```
// @contact.name CarrotSkin Team
// @contact.email support@carrotskin.com
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
// @host localhost:8080
// @BasePath /api/v1
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description Type "Bearer" followed by a space and JWT token.
func SetupSwagger(router *gin.Engine) {
// Swagger文档路由
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// 健康检查接口
router.GET("/health", HealthCheck)
}
// HealthCheck 健康检查
// @Summary 健康检查
// @Description 检查服务是否正常运行
// @Tags system
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "成功"
// @Router /health [get]
// HealthCheck 健康检查,检查依赖服务状态
func HealthCheck(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"message": "CarrotSkin API is running",
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
checks := make(map[string]string)
status := "ok"
// 检查数据库
if err := checkDatabase(ctx); err != nil {
checks["database"] = "unhealthy: " + err.Error()
status = "degraded"
} else {
checks["database"] = "healthy"
}
// 检查Redis
if err := checkRedis(ctx); err != nil {
checks["redis"] = "unhealthy: " + err.Error()
status = "degraded"
} else {
checks["redis"] = "healthy"
}
// 根据状态返回相应的HTTP状态码
httpStatus := http.StatusOK
if status == "degraded" {
httpStatus = http.StatusServiceUnavailable
}
c.JSON(httpStatus, gin.H{
"status": status,
"message": "CarrotSkin API health check",
"checks": checks,
"timestamp": time.Now().Unix(),
})
}
// checkDatabase 检查数据库连接
func checkDatabase(ctx context.Context) error {
db, err := database.GetDB()
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
// 使用Ping检查连接
if err := sqlDB.PingContext(ctx); err != nil {
return err
}
// 执行简单查询验证
var result int
if err := db.WithContext(ctx).Raw("SELECT 1").Scan(&result).Error; err != nil {
return err
}
return nil
}
// checkRedis 检查Redis连接
func checkRedis(ctx context.Context) error {
client, err := redis.GetClient()
if err != nil {
return err
}
if client == nil {
return errors.New("Redis客户端未初始化")
}
// 使用Ping检查连接
if err := client.Ping(ctx).Err(); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,27 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// 仅验证降级路径(未初始化依赖时的响应)
func TestHealthCheck_Degraded(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/health", HealthCheck)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 when dependencies missing, got %d", w.Code)
}
}

View File

@@ -1,247 +1,77 @@
package handler
import (
"carrotskin/internal/container"
"carrotskin/internal/model"
"carrotskin/internal/service"
"carrotskin/internal/types"
"carrotskin/pkg/config"
"carrotskin/pkg/database"
"carrotskin/pkg/logger"
"carrotskin/pkg/storage"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// GenerateTextureUploadURL 生成材质上传URL
// @Summary 生成材质上传URL
// @Description 生成预签名URL用于上传材质文件
// @Tags texture
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body types.GenerateTextureUploadURLRequest true "上传URL请求"
// @Success 200 {object} model.Response "生成成功"
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
// @Router /api/v1/texture/upload-url [post]
func GenerateTextureUploadURL(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
model.MsgUnauthorized,
nil,
))
return
}
var req types.GenerateTextureUploadURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
return
}
// 调用UploadService生成预签名URL
storageClient := storage.MustGetClient()
cfg := *config.MustGetRustFSConfig()
result, err := service.GenerateTextureUploadURL(
c.Request.Context(),
storageClient,
cfg,
userID.(int64),
req.FileName,
string(req.TextureType),
)
if err != nil {
logger.MustGetLogger().Error("生成材质上传URL失败",
zap.Int64("user_id", userID.(int64)),
zap.String("file_name", req.FileName),
zap.String("texture_type", string(req.TextureType)),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
return
}
// 返回响应
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.GenerateTextureUploadURLResponse{
PostURL: result.PostURL,
FormData: result.FormData,
TextureURL: result.FileURL,
ExpiresIn: 900, // 15分钟 = 900秒
}))
// TextureHandler 材质处理器(依赖注入版本)
type TextureHandler struct {
container *container.Container
logger *zap.Logger
}
// CreateTexture 创建材质记录
// @Summary 创建材质记录
// @Description 文件上传完成后,创建材质记录到数据库
// @Tags texture
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body types.CreateTextureRequest true "创建材质请求"
// @Success 200 {object} model.Response "创建成功"
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
// @Router /api/v1/texture [post]
func CreateTexture(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
model.MsgUnauthorized,
nil,
))
return
// NewTextureHandler 创建TextureHandler实例
func NewTextureHandler(c *container.Container) *TextureHandler {
return &TextureHandler{
container: c,
logger: c.Logger,
}
var req types.CreateTextureRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
return
}
// TODO: 从配置或数据库读取限制
maxTextures := 100
if err := service.CheckTextureUploadLimit(database.MustGetDB(), userID.(int64), maxTextures); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
return
}
// 创建材质
texture, err := service.CreateTexture(database.MustGetDB(),
userID.(int64),
req.Name,
req.Description,
string(req.Type),
req.URL,
req.Hash,
req.Size,
req.IsPublic,
req.IsSlim,
)
if err != nil {
logger.MustGetLogger().Error("创建材质失败",
zap.Int64("user_id", userID.(int64)),
zap.String("name", req.Name),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
return
}
// 返回响应
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.TextureInfo{
ID: texture.ID,
UploaderID: texture.UploaderID,
Name: texture.Name,
Description: texture.Description,
Type: types.TextureType(texture.Type),
URL: texture.URL,
Hash: texture.Hash,
Size: texture.Size,
IsPublic: texture.IsPublic,
DownloadCount: texture.DownloadCount,
FavoriteCount: texture.FavoriteCount,
IsSlim: texture.IsSlim,
Status: texture.Status,
CreatedAt: texture.CreatedAt,
UpdatedAt: texture.UpdatedAt,
}))
}
// GetTexture 获取材质详情
// Get 获取材质详情
// @Summary 获取材质详情
// @Description 根据ID获取材质详细信息
// @Description 获取指定ID的材质详细信息
// @Tags texture
// @Accept json
// @Produce json
// @Param id path int true "材质ID"
// @Success 200 {object} model.Response "获取成功"
// @Success 200 {object} model.Response{data=types.TextureInfo} "获取成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 404 {object} model.ErrorResponse "材质不存在"
// @Router /api/v1/texture/{id} [get]
func GetTexture(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
func (h *TextureHandler) Get(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"无效的材质ID",
err,
))
RespondBadRequest(c, "无效的材质ID", err)
return
}
texture, err := service.GetTextureByID(database.MustGetDB(), id)
texture, err := h.container.TextureService.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
err.Error(),
nil,
))
RespondNotFound(c, err.Error())
return
}
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.TextureInfo{
ID: texture.ID,
UploaderID: texture.UploaderID,
Name: texture.Name,
Description: texture.Description,
Type: types.TextureType(texture.Type),
URL: texture.URL,
Hash: texture.Hash,
Size: texture.Size,
IsPublic: texture.IsPublic,
DownloadCount: texture.DownloadCount,
FavoriteCount: texture.FavoriteCount,
IsSlim: texture.IsSlim,
Status: texture.Status,
CreatedAt: texture.CreatedAt,
UpdatedAt: texture.UpdatedAt,
}))
RespondSuccess(c, TextureToTextureInfo(texture))
}
// SearchTextures 搜索材质
// Search 搜索材质
// @Summary 搜索材质
// @Description 根据关键词类型搜索材质
// @Description 搜索材质列表,支持关键词类型、公开性筛选和分页
// @Tags texture
// @Accept json
// @Produce json
// @Param keyword query string false "关键词"
// @Param type query string false "材质类型(SKIN/CAPE)"
// @Param public_only query bool false "只看公开材质"
// @Param type query string false "材质类型 (SKIN/CAPE)"
// @Param public_only query boolean false "仅显示公开材质"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} model.PaginationResponse "搜索成功"
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Failure 500 {object} model.ErrorResponse "服务器错误"
// @Router /api/v1/texture [get]
func SearchTextures(c *gin.Context) {
func (h *TextureHandler) Search(c *gin.Context) {
keyword := c.Query("keyword")
textureTypeStr := c.Query("type")
publicOnly := c.Query("public_only") == "true"
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
page := parseIntWithDefault(c.DefaultQuery("page", "1"), 1)
pageSize := parseIntWithDefault(c.DefaultQuery("page_size", "20"), 20)
var textureType model.TextureType
switch textureTypeStr {
@@ -251,349 +81,316 @@ func SearchTextures(c *gin.Context) {
textureType = model.TextureTypeCape
}
textures, total, err := service.SearchTextures(database.MustGetDB(), keyword, textureType, publicOnly, page, pageSize)
textures, total, err := h.container.TextureService.Search(c.Request.Context(), keyword, textureType, publicOnly, page, pageSize)
if err != nil {
logger.MustGetLogger().Error("搜索材质失败",
zap.String("keyword", keyword),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
model.CodeServerError,
"搜索材质失败",
err,
))
h.logger.Error("搜索材质失败", zap.String("keyword", keyword), zap.Error(err))
RespondServerError(c, "搜索材质失败", err)
return
}
// 转换为TextureInfo
textureInfos := make([]*types.TextureInfo, len(textures))
for i, texture := range textures {
textureInfos[i] = &types.TextureInfo{
ID: texture.ID,
UploaderID: texture.UploaderID,
Name: texture.Name,
Description: texture.Description,
Type: types.TextureType(texture.Type),
URL: texture.URL,
Hash: texture.Hash,
Size: texture.Size,
IsPublic: texture.IsPublic,
DownloadCount: texture.DownloadCount,
FavoriteCount: texture.FavoriteCount,
IsSlim: texture.IsSlim,
Status: texture.Status,
CreatedAt: texture.CreatedAt,
UpdatedAt: texture.UpdatedAt,
}
}
c.JSON(http.StatusOK, model.NewPaginationResponse(textureInfos, total, page, pageSize))
// 返回格式:
// {
// "code": 200,
// "message": "操作成功",
// "data": {
// "list": [...],
// "total": 1,
// "page": 1,
// "per_page": 5
// }
// }
RespondSuccess(c, gin.H{
"list": TexturesToTextureInfos(textures),
"total": total,
"page": page,
"per_page": pageSize,
})
}
// UpdateTexture 更新材质
// Update 更新材质
// @Summary 更新材质
// @Description 更新材质信息(仅上传者可操作
// @Description 更新材质信息(名称、描述、公开性
// @Tags texture
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "材质ID"
// @Param request body types.UpdateTextureRequest true "更新材质请求"
// @Success 200 {object} model.Response "更新成功"
// @Param request body types.UpdateTextureRequest true "更新信息"
// @Success 200 {object} model.Response{data=types.TextureInfo} "更新成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 403 {object} model.ErrorResponse "无权操作"
// @Router /api/v1/texture/{id} [put]
func UpdateTexture(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
model.MsgUnauthorized,
nil,
))
func (h *TextureHandler) Update(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
idStr := c.Param("id")
textureID, err := strconv.ParseInt(idStr, 10, 64)
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"无效的材质ID",
err,
))
RespondBadRequest(c, "无效的材质ID", err)
return
}
var req types.UpdateTextureRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
RespondBadRequest(c, "请求参数错误", err)
return
}
texture, err := service.UpdateTexture(database.MustGetDB(), textureID, userID.(int64), req.Name, req.Description, req.IsPublic)
texture, err := h.container.TextureService.Update(c.Request.Context(), textureID, userID, req.Name, req.Description, req.IsPublic)
if err != nil {
logger.MustGetLogger().Error("更新材质失败",
zap.Int64("user_id", userID.(int64)),
h.logger.Error("更新材质失败",
zap.Int64("user_id", userID),
zap.Int64("texture_id", textureID),
zap.Error(err),
)
c.JSON(http.StatusForbidden, model.NewErrorResponse(
model.CodeForbidden,
err.Error(),
nil,
))
RespondForbidden(c, err.Error())
return
}
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.TextureInfo{
ID: texture.ID,
UploaderID: texture.UploaderID,
Name: texture.Name,
Description: texture.Description,
Type: types.TextureType(texture.Type),
URL: texture.URL,
Hash: texture.Hash,
Size: texture.Size,
IsPublic: texture.IsPublic,
DownloadCount: texture.DownloadCount,
FavoriteCount: texture.FavoriteCount,
IsSlim: texture.IsSlim,
Status: texture.Status,
CreatedAt: texture.CreatedAt,
UpdatedAt: texture.UpdatedAt,
}))
RespondSuccess(c, TextureToTextureInfo(texture))
}
// DeleteTexture 删除材质
// Delete 删除材质
// @Summary 删除材质
// @Description 删除材质(软删除,仅上传者可操作)
// @Description 删除指定ID的材质
// @Tags texture
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "材质ID"
// @Success 200 {object} model.Response "删除成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 403 {object} model.ErrorResponse "无权操作"
// @Router /api/v1/texture/{id} [delete]
func DeleteTexture(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
model.MsgUnauthorized,
nil,
))
func (h *TextureHandler) Delete(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
idStr := c.Param("id")
textureID, err := strconv.ParseInt(idStr, 10, 64)
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"无效的材质ID",
err,
))
RespondBadRequest(c, "无效的材质ID", err)
return
}
if err := service.DeleteTexture(database.MustGetDB(), textureID, userID.(int64)); err != nil {
logger.MustGetLogger().Error("删除材质失败",
zap.Int64("user_id", userID.(int64)),
if err := h.container.TextureService.Delete(c.Request.Context(), textureID, userID); err != nil {
h.logger.Error("删除材质失败",
zap.Int64("user_id", userID),
zap.Int64("texture_id", textureID),
zap.Error(err),
)
c.JSON(http.StatusForbidden, model.NewErrorResponse(
model.CodeForbidden,
err.Error(),
nil,
))
RespondForbidden(c, err.Error())
return
}
c.JSON(http.StatusOK, model.NewSuccessResponse(nil))
RespondSuccess(c, nil)
}
// ToggleFavorite 切换收藏状态
// @Summary 切换收藏状态
// @Description 收藏或取消收藏材质
// @Description 收藏或取消收藏指定材质
// @Tags texture
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "材质ID"
// @Success 200 {object} model.Response "切换成功"
// @Router /api/v1/texture/{id}/favorite [post]
func ToggleFavorite(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
model.MsgUnauthorized,
nil,
))
// @Success 200 {object} model.Response{data=map[string]bool} "操作成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Router /api/v1/texture/{id} [post]
func (h *TextureHandler) ToggleFavorite(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
idStr := c.Param("id")
textureID, err := strconv.ParseInt(idStr, 10, 64)
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"无效的材质ID",
err,
))
RespondBadRequest(c, "无效的材质ID", err)
return
}
isFavorited, err := service.ToggleTextureFavorite(database.MustGetDB(), userID.(int64), textureID)
isFavorited, err := h.container.TextureService.ToggleFavorite(c.Request.Context(), userID, textureID)
if err != nil {
logger.MustGetLogger().Error("切换收藏状态失败",
zap.Int64("user_id", userID.(int64)),
h.logger.Error("切换收藏状态失败",
zap.Int64("user_id", userID),
zap.Int64("texture_id", textureID),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
RespondBadRequest(c, err.Error(), nil)
return
}
c.JSON(http.StatusOK, model.NewSuccessResponse(map[string]bool{
"is_favorited": isFavorited,
}))
RespondSuccess(c, map[string]bool{"is_favorited": isFavorited})
}
// GetUserTextures 获取用户上传的材质列表
// @Summary 获取用户上传的材质列表
// @Description 获取当前用户上传的所有材质
// @Summary 获取的材质
// @Description 获取当前登录用户上传的材质列表
// @Tags texture
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} model.PaginationResponse "获取成功"
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Failure 500 {object} model.ErrorResponse "服务器错误"
// @Router /api/v1/texture/my [get]
func GetUserTextures(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
model.MsgUnauthorized,
nil,
))
func (h *TextureHandler) GetUserTextures(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
page := parseIntWithDefault(c.DefaultQuery("page", "1"), 1)
pageSize := parseIntWithDefault(c.DefaultQuery("page_size", "20"), 20)
textures, total, err := service.GetUserTextures(database.MustGetDB(), userID.(int64), page, pageSize)
textures, total, err := h.container.TextureService.GetByUserID(c.Request.Context(), userID, page, pageSize)
if err != nil {
logger.MustGetLogger().Error("获取用户材质列表失败",
zap.Int64("user_id", userID.(int64)),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
model.CodeServerError,
"获取材质列表失败",
err,
))
h.logger.Error("获取用户材质列表失败", zap.Int64("user_id", userID), zap.Error(err))
RespondServerError(c, "获取材质列表失败", err)
return
}
// 转换为TextureInfo
textureInfos := make([]*types.TextureInfo, len(textures))
for i, texture := range textures {
textureInfos[i] = &types.TextureInfo{
ID: texture.ID,
UploaderID: texture.UploaderID,
Name: texture.Name,
Description: texture.Description,
Type: types.TextureType(texture.Type),
URL: texture.URL,
Hash: texture.Hash,
Size: texture.Size,
IsPublic: texture.IsPublic,
DownloadCount: texture.DownloadCount,
FavoriteCount: texture.FavoriteCount,
IsSlim: texture.IsSlim,
Status: texture.Status,
CreatedAt: texture.CreatedAt,
UpdatedAt: texture.UpdatedAt,
}
}
c.JSON(http.StatusOK, model.NewPaginationResponse(textureInfos, total, page, pageSize))
RespondSuccess(c, gin.H{
"list": TexturesToTextureInfos(textures),
"total": total,
"page": page,
"per_page": pageSize,
})
}
// GetUserFavorites 获取用户收藏的材质列表
// @Summary 获取用户收藏的材质列表
// @Description 获取当前用户收藏的所有材质
// @Summary 获取我的收藏
// @Description 获取当前登录用户收藏的材质列表
// @Tags texture
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} model.PaginationResponse "获取成功"
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Failure 500 {object} model.ErrorResponse "服务器错误"
// @Router /api/v1/texture/favorites [get]
func GetUserFavorites(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
model.MsgUnauthorized,
nil,
))
func (h *TextureHandler) GetUserFavorites(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
page := parseIntWithDefault(c.DefaultQuery("page", "1"), 1)
pageSize := parseIntWithDefault(c.DefaultQuery("page_size", "20"), 20)
textures, total, err := service.GetUserTextureFavorites(database.MustGetDB(), userID.(int64), page, pageSize)
textures, total, err := h.container.TextureService.GetUserFavorites(c.Request.Context(), userID, page, pageSize)
if err != nil {
logger.MustGetLogger().Error("获取用户收藏列表失败",
zap.Int64("user_id", userID.(int64)),
h.logger.Error("获取用户收藏列表失败", zap.Int64("user_id", userID), zap.Error(err))
RespondServerError(c, "获取收藏列表失败", err)
return
}
RespondSuccess(c, gin.H{
"list": TexturesToTextureInfos(textures),
"total": total,
"page": page,
"per_page": pageSize,
})
}
// Upload 直接上传材质文件
// @Summary 上传材质
// @Description 上传图片文件创建新材质
// @Tags texture
// @Accept multipart/form-data
// @Produce json
// @Security BearerAuth
// @Param file formData file true "材质文件 (PNG)"
// @Param name formData string true "材质名称"
// @Param description formData string false "材质描述"
// @Param type formData string false "材质类型 (SKIN/CAPE)" default(SKIN)
// @Param is_public formData boolean false "是否公开" default(false)
// @Param is_slim formData boolean false "是否为纤细模型 (仅SKIN有效)" default(false)
// @Success 200 {object} model.Response{data=types.TextureInfo} "上传成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Router /api/v1/texture/upload [post]
func (h *TextureHandler) Upload(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
// 解析multipart表单
if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB
RespondBadRequest(c, "解析表单失败", err)
return
}
// 获取文件
file, err := c.FormFile("file")
if err != nil {
RespondBadRequest(c, "获取文件失败", err)
return
}
// 读取文件内容
src, err := file.Open()
if err != nil {
RespondBadRequest(c, "打开文件失败", err)
return
}
defer src.Close()
fileData := make([]byte, file.Size)
if _, err := src.Read(fileData); err != nil {
RespondBadRequest(c, "读取文件失败", err)
return
}
// 获取表单字段
name := c.PostForm("name")
if name == "" {
RespondBadRequest(c, "名称不能为空", nil)
return
}
description := c.PostForm("description")
textureType := c.PostForm("type")
if textureType == "" {
textureType = "SKIN" // 默认值
}
isPublic := c.PostForm("is_public") == "true"
isSlim := c.PostForm("is_slim") == "true"
// 检查上传限制
maxTextures := h.container.UserService.GetMaxTexturesPerUser()
if err := h.container.TextureService.CheckUploadLimit(c.Request.Context(), userID, maxTextures); err != nil {
RespondBadRequest(c, err.Error(), nil)
return
}
// 调用服务上传
texture, err := h.container.TextureService.UploadTexture(
c.Request.Context(),
userID,
name,
description,
textureType,
fileData,
file.Filename,
isPublic,
isSlim,
)
if err != nil {
h.logger.Error("上传材质失败",
zap.Int64("user_id", userID),
zap.String("file_name", file.Filename),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
model.CodeServerError,
"获取收藏列表失败",
err,
))
RespondBadRequest(c, err.Error(), nil)
return
}
// 转换为TextureInfo
textureInfos := make([]*types.TextureInfo, len(textures))
for i, texture := range textures {
textureInfos[i] = &types.TextureInfo{
ID: texture.ID,
UploaderID: texture.UploaderID,
Name: texture.Name,
Description: texture.Description,
Type: types.TextureType(texture.Type),
URL: texture.URL,
Hash: texture.Hash,
Size: texture.Size,
IsPublic: texture.IsPublic,
DownloadCount: texture.DownloadCount,
FavoriteCount: texture.FavoriteCount,
IsSlim: texture.IsSlim,
Status: texture.Status,
CreatedAt: texture.CreatedAt,
UpdatedAt: texture.UpdatedAt,
}
}
c.JSON(http.StatusOK, model.NewPaginationResponse(textureInfos, total, page, pageSize))
RespondSuccess(c, TextureToTextureInfo(texture))
}

View File

@@ -1,415 +1,370 @@
package handler
import (
"carrotskin/internal/container"
"carrotskin/internal/model"
"carrotskin/internal/service"
"carrotskin/internal/types"
"carrotskin/pkg/config"
"carrotskin/pkg/logger"
"carrotskin/pkg/redis"
"carrotskin/pkg/storage"
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// GetUserProfile 获取用户信息
// UserHandler 用户处理器(依赖注入版本)
type UserHandler struct {
container *container.Container
logger *zap.Logger
}
// NewUserHandler 创建UserHandler实例
func NewUserHandler(c *container.Container) *UserHandler {
return &UserHandler{
container: c,
logger: c.Logger,
}
}
// GetProfile 获取用户信息
// @Summary 获取用户信息
// @Description 获取当前登录用户的详细信息
// @Tags user
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} model.Response "获取成功"
// @Failure 401 {object} model.ErrorResponse "未授权"
// @Success 200 {object} model.Response{data=types.UserInfo} "获取成功"
// @Failure 404 {object} model.ErrorResponse "用户不存在"
// @Router /api/v1/user/profile [get]
func GetUserProfile(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
// 从上下文获取用户ID (由JWT中间件设置)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
model.MsgUnauthorized,
nil,
))
func (h *UserHandler) GetProfile(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
// 获取用户信息
user, err := service.GetUserByID(userID.(int64))
user, err := h.container.UserService.GetByID(c.Request.Context(), userID)
if err != nil || user == nil {
loggerInstance.Error("获取用户信息失败",
zap.Int64("user_id", userID.(int64)),
h.logger.Error("获取用户信息失败",
zap.Int64("user_id", userID),
zap.Error(err),
)
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
err,
))
RespondNotFound(c, "用户不存在")
return
}
// 返回用户信息
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.UserInfo{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Avatar: user.Avatar,
Points: user.Points,
Role: user.Role,
Status: user.Status,
LastLoginAt: user.LastLoginAt,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}))
RespondSuccess(c, UserToUserInfo(user))
}
// UpdateUserProfile 更新用户信息
// UpdateProfile 更新用户信息
// @Summary 更新用户信息
// @Description 更新当前登录用户的头像和密码(修改邮箱请使用 /change-email 接口
// @Description 更新用户资料密码、头像URL如需上传头像请使用上传接口
// @Tags user
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body types.UpdateUserRequest true "更新信息修改密码时需同时提供old_password和new_password"
// @Param request body types.UpdateUserRequest true "更新信息"
// @Success 200 {object} model.Response{data=types.UserInfo} "更新成功"
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
// @Failure 401 {object} model.ErrorResponse "未授权"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 404 {object} model.ErrorResponse "用户不存在"
// @Failure 500 {object} model.ErrorResponse "服务器错误"
// @Router /api/v1/user/profile [put]
func UpdateUserProfile(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
model.MsgUnauthorized,
nil,
))
func (h *UserHandler) UpdateProfile(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req types.UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
RespondBadRequest(c, "请求参数错误", err)
return
}
// 获取用户
user, err := service.GetUserByID(userID.(int64))
user, err := h.container.UserService.GetByID(c.Request.Context(), userID)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
err,
))
RespondNotFound(c, "用户不存在")
return
}
// 处理密码修改
if req.NewPassword != "" {
// 如果提供了新密码,必须同时提供旧密码
if req.OldPassword == "" {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"修改密码需要提供原密码",
nil,
))
RespondBadRequest(c, "修改密码需要提供原密码", nil)
return
}
// 调用修改密码服务
if err := service.ChangeUserPassword(userID.(int64), req.OldPassword, req.NewPassword); err != nil {
loggerInstance.Error("修改密码失败",
zap.Int64("user_id", userID.(int64)),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
if err := h.container.UserService.ChangePassword(c.Request.Context(), userID, req.OldPassword, req.NewPassword); err != nil {
h.logger.Error("修改密码失败", zap.Int64("user_id", userID), zap.Error(err))
RespondBadRequest(c, err.Error(), nil)
return
}
loggerInstance.Info("用户修改密码成功",
zap.Int64("user_id", userID.(int64)),
)
h.logger.Info("用户修改密码成功", zap.Int64("user_id", userID))
}
// 更新头像
if req.Avatar != "" {
if err := h.container.UserService.ValidateAvatarURL(c.Request.Context(), req.Avatar); err != nil {
RespondBadRequest(c, err.Error(), nil)
return
}
user.Avatar = req.Avatar
}
// 保存更新(仅当有头像修改时)
if req.Avatar != "" {
if err := service.UpdateUserInfo(user); err != nil {
loggerInstance.Error("更新用户信息失败",
zap.Int64("user_id", user.ID),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
model.CodeServerError,
"更新失败",
err,
))
if err := h.container.UserService.UpdateInfo(c.Request.Context(), user); err != nil {
h.logger.Error("更新用户信息失败", zap.Int64("user_id", user.ID), zap.Error(err))
RespondServerError(c, "更新失败", err)
return
}
}
// 重新获取更新后的用户信息
updatedUser, err := service.GetUserByID(userID.(int64))
updatedUser, err := h.container.UserService.GetByID(c.Request.Context(), userID)
if err != nil || updatedUser == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
err,
))
RespondNotFound(c, "用户不存在")
return
}
// 返回更新后的用户信息
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.UserInfo{
ID: updatedUser.ID,
Username: updatedUser.Username,
Email: updatedUser.Email,
Avatar: updatedUser.Avatar,
Points: updatedUser.Points,
Role: updatedUser.Role,
Status: updatedUser.Status,
LastLoginAt: updatedUser.LastLoginAt,
CreatedAt: updatedUser.CreatedAt,
UpdatedAt: updatedUser.UpdatedAt,
}))
RespondSuccess(c, UserToUserInfo(updatedUser))
}
// GenerateAvatarUploadURL 生成头像上传URL
// @Summary 生成头像上传URL
// @Description 生成预签名URL用于上传用户头像
// UploadAvatar 直接上传头像文件
// @Summary 上传头像
// @Description 上传图片文件作为用户头像
// @Tags user
// @Accept json
// @Accept multipart/form-data
// @Produce json
// @Security BearerAuth
// @Param request body types.GenerateAvatarUploadURLRequest true "文件"
// @Success 200 {object} model.Response "生成成功"
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
// @Router /api/v1/user/avatar/upload-url [post]
func GenerateAvatarUploadURL(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
model.MsgUnauthorized,
nil,
))
// @Param file formData file true "头像文件"
// @Success 200 {object} model.Response{data=map[string]interface{}} "上传成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 404 {object} model.ErrorResponse "用户不存在"
// @Router /api/v1/user/avatar/upload [post]
func (h *UserHandler) UploadAvatar(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req types.GenerateAvatarUploadURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
// 解析multipart表单
if err := c.Request.ParseMultipartForm(10 << 20); err != nil { // 10MB
RespondBadRequest(c, "解析表单失败", err)
return
}
// 调用UploadService生成预签名URL
storageClient := storage.MustGetClient()
cfg := *config.MustGetRustFSConfig()
result, err := service.GenerateAvatarUploadURL(c.Request.Context(), storageClient, cfg, userID.(int64), req.FileName)
// 获取文件
file, err := c.FormFile("file")
if err != nil {
loggerInstance.Error("生成头像上传URL失败",
zap.Int64("user_id", userID.(int64)),
zap.String("file_name", req.FileName),
RespondBadRequest(c, "获取文件失败", err)
return
}
// 读取文件内容
src, err := file.Open()
if err != nil {
RespondBadRequest(c, "打开文件失败", err)
return
}
defer src.Close()
fileData := make([]byte, file.Size)
if _, err := src.Read(fileData); err != nil {
RespondBadRequest(c, "读取文件失败", err)
return
}
// 调用服务上传头像
avatarURL, err := h.container.UserService.UploadAvatar(c.Request.Context(), userID, fileData, file.Filename)
if err != nil {
h.logger.Error("上传头像失败",
zap.Int64("user_id", userID),
zap.String("file_name", file.Filename),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
RespondBadRequest(c, err.Error(), nil)
return
}
// 返回响应
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.GenerateAvatarUploadURLResponse{
PostURL: result.PostURL,
FormData: result.FormData,
AvatarURL: result.FileURL,
ExpiresIn: 900, // 15分钟 = 900秒
}))
// 获取更新后的用户信息
user, err := h.container.UserService.GetByID(c.Request.Context(), userID)
if err != nil || user == nil {
RespondNotFound(c, "用户不存在")
return
}
RespondSuccess(c, gin.H{
"avatar_url": avatarURL,
"user": UserToUserInfo(user),
})
}
// UpdateAvatar 更新头像URL
// UpdateAvatar 更新头像URL保留用于外部URL
// @Summary 更新头像URL
// @Description 上传完成后更新用户头像URL到数据库
// @Description 更新用户头像为外部URL
// @Tags user
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param avatar_url query string true "头像URL"
// @Success 200 {object} model.Response "更新成功"
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
// @Success 200 {object} model.Response{data=types.UserInfo} "更新成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 404 {object} model.ErrorResponse "用户不存在"
// @Router /api/v1/user/avatar [put]
func UpdateAvatar(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
model.MsgUnauthorized,
nil,
))
func (h *UserHandler) UpdateAvatar(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
avatarURL := c.Query("avatar_url")
if avatarURL == "" {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"头像URL不能为空",
nil,
))
RespondBadRequest(c, "头像URL不能为空", nil)
return
}
// 更新头像
if err := service.UpdateUserAvatar(userID.(int64), avatarURL); err != nil {
loggerInstance.Error("更新头像失败",
zap.Int64("user_id", userID.(int64)),
if err := h.container.UserService.ValidateAvatarURL(c.Request.Context(), avatarURL); err != nil {
RespondBadRequest(c, err.Error(), nil)
return
}
if err := h.container.UserService.UpdateAvatar(c.Request.Context(), userID, avatarURL); err != nil {
h.logger.Error("更新头像失败",
zap.Int64("user_id", userID),
zap.String("avatar_url", avatarURL),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, model.NewErrorResponse(
model.CodeServerError,
"更新头像失败",
err,
))
RespondServerError(c, "更新头像失败", err)
return
}
// 获取更新后的用户信息
user, err := service.GetUserByID(userID.(int64))
user, err := h.container.UserService.GetByID(c.Request.Context(), userID)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
err,
))
RespondNotFound(c, "用户不存在")
return
}
// 返回更新后的用户信息
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.UserInfo{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Avatar: user.Avatar,
Points: user.Points,
Role: user.Role,
Status: user.Status,
LastLoginAt: user.LastLoginAt,
CreatedAt: user.CreatedAt,
}))
RespondSuccess(c, UserToUserInfo(user))
}
// ChangeEmail 更换邮箱
// @Summary 更换邮箱
// @Description 通过验证码更换用户邮箱
// @Description 更换用户绑定的邮箱,需要验证码
// @Tags user
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body types.ChangeEmailRequest true "更换邮箱请求"
// @Success 200 {object} model.Response{data=types.UserInfo} "更换成功"
// @Failure 400 {object} model.ErrorResponse "请求参数错误"
// @Failure 401 {object} model.ErrorResponse "未授权"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 404 {object} model.ErrorResponse "用户不存在"
// @Router /api/v1/user/change-email [post]
func ChangeEmail(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
model.MsgUnauthorized,
nil,
))
func (h *UserHandler) ChangeEmail(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
var req types.ChangeEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"请求参数错误",
err,
))
RespondBadRequest(c, "请求参数错误", err)
return
}
// 验证验证码
redisClient := redis.MustGetClient()
if err := service.VerifyCode(c.Request.Context(), redisClient, req.NewEmail, req.VerificationCode, service.VerificationTypeChangeEmail); err != nil {
loggerInstance.Warn("验证码验证失败",
if err := h.container.VerificationService.VerifyCode(c.Request.Context(), req.NewEmail, req.VerificationCode, service.VerificationTypeChangeEmail); err != nil {
h.logger.Warn("验证码验证失败", zap.String("new_email", req.NewEmail), zap.Error(err))
RespondBadRequest(c, err.Error(), nil)
return
}
if err := h.container.UserService.ChangeEmail(c.Request.Context(), userID, req.NewEmail); err != nil {
h.logger.Error("更换邮箱失败",
zap.Int64("user_id", userID),
zap.String("new_email", req.NewEmail),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
RespondBadRequest(c, err.Error(), nil)
return
}
// 更换邮箱
if err := service.ChangeUserEmail(userID.(int64), req.NewEmail); err != nil {
loggerInstance.Error("更换邮箱失败",
zap.Int64("user_id", userID.(int64)),
zap.String("new_email", req.NewEmail),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
err.Error(),
nil,
))
return
}
// 获取更新后的用户信息
user, err := service.GetUserByID(userID.(int64))
user, err := h.container.UserService.GetByID(c.Request.Context(), userID)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
err,
))
RespondNotFound(c, "用户不存在")
return
}
c.JSON(http.StatusOK, model.NewSuccessResponse(&types.UserInfo{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Avatar: user.Avatar,
Points: user.Points,
Role: user.Role,
Status: user.Status,
LastLoginAt: user.LastLoginAt,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}))
RespondSuccess(c, UserToUserInfo(user))
}
// ResetYggdrasilPassword 重置Yggdrasil密码
// @Summary 重置Yggdrasil密码
// @Description 重置用户的Yggdrasil API认证密码
// @Tags user
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} model.Response{data=map[string]string} "重置成功"
// @Failure 500 {object} model.ErrorResponse "服务器错误"
// @Router /api/v1/user/yggdrasil-password/reset [post]
func (h *UserHandler) ResetYggdrasilPassword(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
newPassword, err := h.container.YggdrasilService.ResetYggdrasilPassword(c.Request.Context(), userID)
if err != nil {
h.logger.Error("重置Yggdrasil密码失败", zap.Error(err), zap.Int64("userId", userID))
RespondServerError(c, "重置Yggdrasil密码失败", nil)
return
}
h.logger.Info("Yggdrasil密码重置成功", zap.Int64("userId", userID))
RespondSuccess(c, gin.H{"password": newPassword})
}
// GetPublicInfo 获取用户公开信息
// @Summary 获取用户公开信息
// @Description 根据用户名或用户ID获取用户的公开信息不包含敏感信息如邮箱
// @Tags user
// @Accept json
// @Produce json
// @Param username query string false "用户名"
// @Param id query int false "用户ID"
// @Success 200 {object} model.Response{data=types.PublicUserInfo} "获取成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 404 {object} model.ErrorResponse "用户不存在"
// @Router /api/v1/users/public [get]
func (h *UserHandler) GetPublicInfo(c *gin.Context) {
username := c.Query("username")
idStr := c.Query("id")
// 至少需要提供一个参数
if username == "" && idStr == "" {
RespondBadRequest(c, "必须提供用户名或用户ID", nil)
return
}
var user *model.User
var err error
// 优先使用用户名查询
if username != "" {
user, err = h.container.UserService.GetByUsername(c.Request.Context(), username)
} else {
// 使用用户ID查询
id := parseIntWithDefault(idStr, 0)
if id == 0 {
RespondBadRequest(c, "无效的用户ID", nil)
return
}
user, err = h.container.UserService.GetByID(c.Request.Context(), int64(id))
}
if err != nil || user == nil {
RespondNotFound(c, "用户不存在")
return
}
// 检查用户状态
if user.Status != 1 {
RespondNotFound(c, "用户不可用")
return
}
RespondSuccess(c, UserToPublicUserInfo(user))
}

View File

@@ -2,11 +2,8 @@ package handler
import (
"bytes"
"carrotskin/internal/container"
"carrotskin/internal/model"
"carrotskin/internal/service"
"carrotskin/pkg/database"
"carrotskin/pkg/logger"
"carrotskin/pkg/redis"
"carrotskin/pkg/utils"
"io"
"net/http"
@@ -111,6 +108,7 @@ type (
Password string `json:"password" binding:"required"`
}
// JoinServerRequest 加入服务器请求
JoinServerRequest struct {
ServerID string `json:"serverId" binding:"required"`
AccessToken string `json:"accessToken" binding:"required"`
@@ -138,6 +136,7 @@ type (
}
)
// APIResponse API响应
type APIResponse struct {
Status int `json:"status"`
Data interface{} `json:"data"`
@@ -153,38 +152,56 @@ func standardResponse(c *gin.Context, status int, data interface{}, err interfac
})
}
// Authenticate 用户认证
func Authenticate(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
db := database.MustGetDB()
// YggdrasilHandler Yggdrasil API处理器
type YggdrasilHandler struct {
container *container.Container
logger *zap.Logger
}
// 读取并保存原始请求体,以便多次读取
// NewYggdrasilHandler 创建YggdrasilHandler实例
func NewYggdrasilHandler(c *container.Container) *YggdrasilHandler {
return &YggdrasilHandler{
container: c,
logger: c.Logger,
}
}
// Authenticate 用户认证
// @Summary Yggdrasil认证
// @Description Yggdrasil协议: 用户登录认证
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param request body AuthenticateRequest true "认证请求"
// @Success 200 {object} AuthenticateResponse
// @Failure 403 {object} map[string]string "认证失败"
// @Router /api/v1/yggdrasil/authserver/authenticate [post]
func (h *YggdrasilHandler) Authenticate(c *gin.Context) {
rawData, err := io.ReadAll(c.Request.Body)
if err != nil {
loggerInstance.Error("[ERROR] 读取请求体失败: ", zap.Error(err))
h.logger.Error("读取请求体失败", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"})
return
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(rawData))
// 绑定JSON数据到请求结构体
var request AuthenticateRequest
if err = c.ShouldBindJSON(&request); err != nil {
loggerInstance.Error("[ERROR] 解析认证请求失败: ", zap.Error(err))
h.logger.Error("解析认证请求失败", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 根据标识符类型(邮箱或用户名)获取用户
var userId int64
var profile *model.Profile
var UUID string
if emailRegex.MatchString(request.Identifier) {
userId, err = service.GetUserIDByEmail(db, request.Identifier)
userId, err = h.container.YggdrasilService.GetUserIDByEmail(c.Request.Context(), request.Identifier)
} else {
profile, err = service.GetProfileByProfileName(db, request.Identifier)
profile, err = h.container.ProfileRepo.FindByName(c.Request.Context(), request.Identifier)
if err != nil {
loggerInstance.Error("[ERROR] 用户名不存在: ", zap.String("标识符", request.Identifier), zap.Error(err))
h.logger.Error("用户名不存在", zap.String("identifier", request.Identifier), zap.Error(err))
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
@@ -193,165 +210,164 @@ func Authenticate(c *gin.Context) {
}
if err != nil {
loggerInstance.Warn("[WARN] 认证失败: 用户不存在",
zap.String("标识符:", request.Identifier),
zap.Error(err))
h.logger.Warn("认证失败: 用户不存在", zap.String("identifier", request.Identifier), zap.Error(err))
c.JSON(http.StatusForbidden, gin.H{"error": "用户不存在"})
return
}
// 验证密码
err = service.VerifyPassword(db, request.Password, userId)
if err != nil {
loggerInstance.Warn("[WARN] 认证失败:", zap.Error(err))
if err := h.container.YggdrasilService.VerifyPassword(c.Request.Context(), request.Password, userId); err != nil {
h.logger.Warn("认证失败: 密码错误", zap.Error(err))
c.JSON(http.StatusForbidden, gin.H{"error": ErrWrongPassword})
return
}
// 生成新令牌
selectedProfile, availableProfiles, accessToken, clientToken, err := service.NewToken(db, loggerInstance, userId, UUID, request.ClientToken)
selectedProfile, availableProfiles, accessToken, clientToken, err := h.container.TokenService.Create(c.Request.Context(), userId, UUID, request.ClientToken)
if err != nil {
loggerInstance.Error("[ERROR] 生成令牌失败:", zap.Error(err), zap.Any("用户ID:", userId))
h.logger.Error("生成令牌失败", zap.Error(err), zap.Int64("userId", userId))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
user, err := service.GetUserByID(userId)
user, err := h.container.UserService.GetByID(c.Request.Context(), userId)
if err != nil {
loggerInstance.Error("[ERROR] id查找错误:", zap.Error(err), zap.Any("ID:", userId))
h.logger.Error("获取用户信息失败", zap.Error(err), zap.Int64("userId", userId))
}
// 处理可用的配置文件
redisClient := redis.MustGetClient()
availableProfilesData := make([]map[string]interface{}, 0, len(availableProfiles))
for _, profile := range availableProfiles {
availableProfilesData = append(availableProfilesData, service.SerializeProfile(db, loggerInstance, redisClient, *profile))
for _, p := range availableProfiles {
availableProfilesData = append(availableProfilesData, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *p))
}
response := AuthenticateResponse{
AccessToken: accessToken,
ClientToken: clientToken,
AvailableProfiles: availableProfilesData,
}
if selectedProfile != nil {
response.SelectedProfile = service.SerializeProfile(db, loggerInstance, redisClient, *selectedProfile)
}
if request.RequestUser {
response.User = map[string]interface{}{
"id": userId,
"properties": user.Properties,
}
response.SelectedProfile = h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *selectedProfile)
}
// 返回认证响应
loggerInstance.Info("[INFO] 用户认证成功", zap.Any("用户ID:", userId))
if request.RequestUser && user != nil {
response.User = h.container.YggdrasilService.SerializeUser(c.Request.Context(), user, UUID)
}
h.logger.Info("用户认证成功", zap.Int64("userId", userId))
c.JSON(http.StatusOK, response)
}
// ValidToken 验证令牌
func ValidToken(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
db := database.MustGetDB()
// @Summary Yggdrasil验证令牌
// @Description Yggdrasil协议: 验证AccessToken是否有效
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param request body ValidTokenRequest true "验证请求"
// @Success 204 "令牌有效"
// @Failure 403 {object} map[string]bool "令牌无效"
// @Router /api/v1/yggdrasil/authserver/validate [post]
func (h *YggdrasilHandler) ValidToken(c *gin.Context) {
var request ValidTokenRequest
if err := c.ShouldBindJSON(&request); err != nil {
loggerInstance.Error("[ERROR] 解析验证令牌请求失败: ", zap.Error(err))
h.logger.Error("解析验证令牌请求失败", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 验证令牌
if service.ValidToken(db, request.AccessToken, request.ClientToken) {
loggerInstance.Info("[INFO] 令牌验证成功", zap.Any("访问令牌:", request.AccessToken))
if h.container.TokenService.Validate(c.Request.Context(), request.AccessToken, request.ClientToken) {
h.logger.Info("令牌验证成功", zap.String("accessToken", request.AccessToken))
c.JSON(http.StatusNoContent, gin.H{"valid": true})
} else {
loggerInstance.Warn("[WARN] 令牌验证失败", zap.Any("访问令牌:", request.AccessToken))
h.logger.Warn("令牌验证失败", zap.String("accessToken", request.AccessToken))
c.JSON(http.StatusForbidden, gin.H{"valid": false})
}
}
// RefreshToken 刷新令牌
func RefreshToken(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
db := database.MustGetDB()
// @Summary Yggdrasil刷新令牌
// @Description Yggdrasil协议: 刷新AccessToken
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param request body RefreshRequest true "刷新请求"
// @Success 200 {object} RefreshResponse
// @Failure 400 {object} map[string]string "刷新失败"
// @Router /api/v1/yggdrasil/authserver/refresh [post]
func (h *YggdrasilHandler) RefreshToken(c *gin.Context) {
var request RefreshRequest
if err := c.ShouldBindJSON(&request); err != nil {
loggerInstance.Error("[ERROR] 解析刷新令牌请求失败: ", zap.Error(err))
h.logger.Error("解析刷新令牌请求失败", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 获取用户ID和用户信息
UUID, err := service.GetUUIDByAccessToken(db, request.AccessToken)
UUID, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), request.AccessToken)
if err != nil {
loggerInstance.Warn("[WARN] 刷新令牌失败: 无效的访问令牌", zap.Any("令牌:", request.AccessToken), zap.Error(err))
h.logger.Warn("刷新令牌失败: 无效的访问令牌", zap.String("token", request.AccessToken), zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, _ := service.GetUserIDByAccessToken(db, request.AccessToken)
// 格式化UUID 这里是因为HMCL的传入参数是HEX格式为了兼容HMCL在此做处理
userID, _ := h.container.TokenService.GetUserIDByAccessToken(c.Request.Context(), request.AccessToken)
UUID = utils.FormatUUID(UUID)
profile, err := service.GetProfileByUUID(db, UUID)
profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), UUID)
if err != nil {
loggerInstance.Error("[ERROR] 刷新令牌失败: 无法获取用户信息 错误: ", zap.Error(err))
h.logger.Error("刷新令牌失败: 无法获取用户信息", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 准备响应数据
var profileData map[string]interface{}
var userData map[string]interface{}
var profileID string
// 处理选定的配置文件
if request.SelectedProfile != nil {
// 验证profileID是否存在
profileIDValue, ok := request.SelectedProfile["id"]
if !ok {
loggerInstance.Error("[ERROR] 刷新令牌失败: 缺少配置文件ID", zap.Any("ID:", userID))
h.logger.Error("刷新令牌失败: 缺少配置文件ID", zap.Int64("userId", userID))
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少配置文件ID"})
return
}
// 类型断言
profileID, ok = profileIDValue.(string)
if !ok {
loggerInstance.Error("[ERROR] 刷新令牌失败: 配置文件ID类型错误 ", zap.Any("用户ID:", userID))
h.logger.Error("刷新令牌失败: 配置文件ID类型错误", zap.Int64("userId", userID))
c.JSON(http.StatusBadRequest, gin.H{"error": "配置文件ID必须是字符串"})
return
}
// 格式化profileID
profileID = utils.FormatUUID(profileID)
// 验证配置文件所属用户
if profile.UserID != userID {
loggerInstance.Warn("[WARN] 刷新令牌失败: 用户不匹配 ", zap.Any("用户ID:", userID), zap.Any("配置文件用户ID:", profile.UserID))
h.logger.Warn("刷新令牌失败: 用户不匹配",
zap.Int64("userId", userID),
zap.Int64("profileUserId", profile.UserID),
)
c.JSON(http.StatusBadRequest, gin.H{"error": ErrUserNotMatch})
return
}
profileData = service.SerializeProfile(db, loggerInstance, redis.MustGetClient(), *profile)
}
user, _ := service.GetUserByID(userID)
// 添加用户信息(如果请求了)
if request.RequestUser {
userData = service.SerializeUser(loggerInstance, user, UUID)
profileData = h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile)
}
// 刷新令牌
newAccessToken, newClientToken, err := service.RefreshToken(db, loggerInstance,
user, _ := h.container.UserService.GetByID(c.Request.Context(), userID)
if request.RequestUser && user != nil {
userData = h.container.YggdrasilService.SerializeUser(c.Request.Context(), user, UUID)
}
newAccessToken, newClientToken, err := h.container.TokenService.Refresh(c.Request.Context(),
request.AccessToken,
request.ClientToken,
profileID,
)
if err != nil {
loggerInstance := logger.MustGetLogger()
loggerInstance.Error("[ERROR] 刷新令牌失败: ", zap.Error(err), zap.Any("用户ID: ", userID))
h.logger.Error("刷新令牌失败", zap.Error(err), zap.Int64("userId", userID))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 返回响应
loggerInstance.Info("[INFO] 刷新令牌成功", zap.Any("用户ID:", userID))
h.logger.Info("刷新令牌成功", zap.Int64("userId", userID))
c.JSON(http.StatusOK, RefreshResponse{
AccessToken: newAccessToken,
ClientToken: newClientToken,
@@ -361,235 +377,241 @@ func RefreshToken(c *gin.Context) {
}
// InvalidToken 使令牌失效
func InvalidToken(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
db := database.MustGetDB()
// @Summary Yggdrasil注销令牌
// @Description Yggdrasil协议: 使AccessToken失效
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param request body ValidTokenRequest true "失效请求"
// @Success 204 "操作成功"
// @Router /api/v1/yggdrasil/authserver/invalidate [post]
func (h *YggdrasilHandler) InvalidToken(c *gin.Context) {
var request ValidTokenRequest
if err := c.ShouldBindJSON(&request); err != nil {
loggerInstance.Error("[ERROR] 解析使令牌失效请求失败: ", zap.Error(err))
h.logger.Error("解析使令牌失效请求失败", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 使令牌失效
service.InvalidToken(db, loggerInstance, request.AccessToken)
loggerInstance.Info("[INFO] 令牌已使失效", zap.Any("访问令牌:", request.AccessToken))
h.container.TokenService.Invalidate(c.Request.Context(), request.AccessToken)
h.logger.Info("令牌已失效", zap.String("token", request.AccessToken))
c.JSON(http.StatusNoContent, gin.H{})
}
// SignOut 用户登出
func SignOut(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
db := database.MustGetDB()
// @Summary Yggdrasil登出
// @Description Yggdrasil协议: 用户登出,使所有令牌失效
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param request body SignOutRequest true "登出请求"
// @Success 204 "操作成功"
// @Failure 400 {object} map[string]string "参数错误"
// @Router /api/v1/yggdrasil/authserver/signout [post]
func (h *YggdrasilHandler) SignOut(c *gin.Context) {
var request SignOutRequest
if err := c.ShouldBindJSON(&request); err != nil {
loggerInstance.Error("[ERROR] 解析登出请求失败: %v", zap.Error(err))
h.logger.Error("解析登出请求失败", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 验证邮箱格式
if !emailRegex.MatchString(request.Email) {
loggerInstance.Warn("[WARN] 登出失败: 邮箱格式不正确 ", zap.Any(" ", request.Email))
h.logger.Warn("登出失败: 邮箱格式不正确", zap.String("email", request.Email))
c.JSON(http.StatusBadRequest, gin.H{"error": ErrInvalidEmailFormat})
return
}
// 通过邮箱获取用户
user, err := service.GetUserByEmail(request.Email)
if err != nil {
loggerInstance.Warn(
"登出失败: 用户不存在",
zap.String("邮箱", request.Email),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
user, err := h.container.UserService.GetByEmail(c.Request.Context(), request.Email)
if err != nil || user == nil {
h.logger.Warn("登出失败: 用户不存在", zap.String("email", request.Email), zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": "用户不存在"})
return
}
password, err := service.GetPasswordByUserId(db, user.ID)
if err != nil {
loggerInstance.Error("[ERROR] 邮箱查找失败", zap.Any("UserId:", user.ID), zap.Error(err))
}
// 验证密码
if password != request.Password {
loggerInstance.Warn("[WARN] 登出失败: 密码错误", zap.Any("用户ID:", user.ID))
if err := h.container.YggdrasilService.VerifyPassword(c.Request.Context(), request.Password, user.ID); err != nil {
h.logger.Warn("登出失败: 密码错误", zap.Int64("userId", user.ID))
c.JSON(http.StatusBadRequest, gin.H{"error": ErrWrongPassword})
return
}
// 使该用户的所有令牌失效
service.InvalidUserTokens(db, loggerInstance, user.ID)
loggerInstance.Info("[INFO] 用户登出成功", zap.Any("用户ID:", user.ID))
h.container.TokenService.InvalidateUserTokens(c.Request.Context(), user.ID)
h.logger.Info("用户登出成功", zap.Int64("userId", user.ID))
c.JSON(http.StatusNoContent, gin.H{"valid": true})
}
func GetProfileByUUID(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
db := database.MustGetDB()
redisClient := redis.MustGetClient()
// 获取并格式化UUID
// GetProfileByUUID 根据UUID获取档案
// @Summary Yggdrasil获取档案
// @Description Yggdrasil协议: 根据UUID获取用户档案信息
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param uuid path string true "用户UUID"
// @Success 200 {object} map[string]interface{} "档案信息"
// @Failure 500 {object} APIResponse "服务器错误"
// @Router /api/v1/yggdrasil/sessionserver/session/minecraft/profile/{uuid} [get]
func (h *YggdrasilHandler) GetProfileByUUID(c *gin.Context) {
uuid := utils.FormatUUID(c.Param("uuid"))
loggerInstance.Info("[INFO] 接收到获取配置文件请求", zap.Any("UUID:", uuid))
h.logger.Info("获取配置文件请求", zap.String("uuid", uuid))
// 获取配置文件
profile, err := service.GetProfileByUUID(db, uuid)
profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), uuid)
if err != nil {
loggerInstance.Error("[ERROR] 获取配置文件失败:", zap.Error(err), zap.String("UUID:", uuid))
h.logger.Error("获取配置文件失败", zap.Error(err), zap.String("uuid", uuid))
standardResponse(c, http.StatusInternalServerError, nil, err.Error())
return
}
// 返回配置文件信息
loggerInstance.Info("[INFO] 成功获取配置文件", zap.String("UUID:", uuid), zap.String("名称:", profile.Name))
c.JSON(http.StatusOK, service.SerializeProfile(db, loggerInstance, redisClient, *profile))
h.logger.Info("成功获取配置文件", zap.String("uuid", uuid), zap.String("name", profile.Name))
c.JSON(http.StatusOK, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile))
}
func JoinServer(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
db := database.MustGetDB()
redisClient := redis.MustGetClient()
// JoinServer 加入服务器
// @Summary Yggdrasil加入服务器
// @Description Yggdrasil协议: 客户端加入服务器
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param request body JoinServerRequest true "加入请求"
// @Success 204 "加入成功"
// @Failure 400 {object} APIResponse "参数错误"
// @Failure 500 {object} APIResponse "服务器错误"
// @Router /api/v1/yggdrasil/sessionserver/session/minecraft/join [post]
func (h *YggdrasilHandler) JoinServer(c *gin.Context) {
var request JoinServerRequest
clientIP := c.ClientIP()
// 解析请求参数
if err := c.ShouldBindJSON(&request); err != nil {
loggerInstance.Error(
"解析加入服务器请求失败",
zap.Error(err),
zap.String("IP", clientIP),
)
h.logger.Error("解析加入服务器请求失败", zap.Error(err), zap.String("ip", clientIP))
standardResponse(c, http.StatusBadRequest, nil, ErrInvalidRequest)
return
}
loggerInstance.Info(
"收到加入服务器请求",
zap.String("服务器ID", request.ServerID),
zap.String("用户UUID", request.SelectedProfile),
zap.String("IP", clientIP),
h.logger.Info("收到加入服务器请求",
zap.String("serverId", request.ServerID),
zap.String("userUUID", request.SelectedProfile),
zap.String("ip", clientIP),
)
// 处理加入服务器请求
if err := service.JoinServer(db, loggerInstance, redisClient, request.ServerID, request.AccessToken, request.SelectedProfile, clientIP); err != nil {
loggerInstance.Error(
"加入服务器失败",
if err := h.container.YggdrasilService.JoinServer(c.Request.Context(), request.ServerID, request.AccessToken, request.SelectedProfile, clientIP); err != nil {
h.logger.Error("加入服务器失败",
zap.Error(err),
zap.String("服务器ID", request.ServerID),
zap.String("用户UUID", request.SelectedProfile),
zap.String("IP", clientIP),
zap.String("serverId", request.ServerID),
zap.String("userUUID", request.SelectedProfile),
zap.String("ip", clientIP),
)
standardResponse(c, http.StatusInternalServerError, nil, ErrJoinServerFailed)
return
}
// 加入成功返回204状态码
loggerInstance.Info(
"加入服务器成功",
zap.String("服务器ID", request.ServerID),
zap.String("用户UUID", request.SelectedProfile),
zap.String("IP", clientIP),
h.logger.Info("加入服务器成功",
zap.String("serverId", request.ServerID),
zap.String("userUUID", request.SelectedProfile),
zap.String("ip", clientIP),
)
c.Status(http.StatusNoContent)
}
func HasJoinedServer(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
db := database.MustGetDB()
redisClient := redis.MustGetClient()
// HasJoinedServer 验证玩家是否已加入服务器
// @Summary Yggdrasil验证加入
// @Description Yggdrasil协议: 服务端验证客户端是否已加入
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param username query string true "用户名"
// @Param serverId query string true "服务器ID"
// @Param ip query string false "客户端IP"
// @Success 200 {object} map[string]interface{} "验证成功,返回档案"
// @Failure 204 "验证失败"
// @Router /api/v1/yggdrasil/sessionserver/session/minecraft/hasJoined [get]
func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
clientIP, _ := c.GetQuery("ip")
// 获取并验证服务器ID参数
serverID, exists := c.GetQuery("serverId")
if !exists || serverID == "" {
loggerInstance.Warn("[WARN] 缺少服务器ID参数", zap.Any("IP:", clientIP))
h.logger.Warn("缺少服务器ID参数", zap.String("ip", clientIP))
standardResponse(c, http.StatusNoContent, nil, ErrServerIDRequired)
return
}
// 获取并验证用户名参数
username, exists := c.GetQuery("username")
if !exists || username == "" {
loggerInstance.Warn("[WARN] 缺少用户名参数", zap.Any("服务器ID:", serverID), zap.Any("IP:", clientIP))
h.logger.Warn("缺少用户名参数", zap.String("serverId", serverID), zap.String("ip", clientIP))
standardResponse(c, http.StatusNoContent, nil, ErrUsernameRequired)
return
}
loggerInstance.Info("[INFO] 收到会话验证请求", zap.Any("服务器ID:", serverID), zap.Any("用户名: ", username), zap.Any("IP: ", clientIP))
h.logger.Info("收到会话验证请求",
zap.String("serverId", serverID),
zap.String("username", username),
zap.String("ip", clientIP),
)
// 验证玩家是否已加入服务器
if err := service.HasJoinedServer(loggerInstance, redisClient, serverID, username, clientIP); err != nil {
loggerInstance.Warn("[WARN] 会话验证失败",
if err := h.container.YggdrasilService.HasJoinedServer(c.Request.Context(), serverID, username, clientIP); err != nil {
h.logger.Warn("会话验证失败",
zap.Error(err),
zap.String("serverID", serverID),
zap.String("serverId", serverID),
zap.String("username", username),
zap.String("clientIP", clientIP),
zap.String("ip", clientIP),
)
standardResponse(c, http.StatusNoContent, nil, ErrSessionVerifyFailed)
return
}
profile, err := service.GetProfileByUUID(db, username)
profile, err := h.container.ProfileService.GetByUUID(c.Request.Context(), username)
if err != nil {
loggerInstance.Error("[ERROR] 获取用户配置文件失败: %v - 用户名: %s",
zap.Error(err), // 错误详情zap 原生支持,保留错误链)
zap.String("username", username), // 结构化存储用户名(便于检索)
)
h.logger.Error("获取用户配置文件失败", zap.Error(err), zap.String("username", username))
standardResponse(c, http.StatusNoContent, nil, ErrProfileNotFound)
return
}
// 返回玩家配置文件
loggerInstance.Info("[INFO] 会话验证成功 - 服务器ID: %s, 用户名: %s, UUID: %s",
zap.String("serverID", serverID), // 结构化存储服务器ID
zap.String("username", username), // 结构化存储用户名
zap.String("UUID", profile.UUID), // 结构化存储UUID
h.logger.Info("会话验证成功",
zap.String("serverId", serverID),
zap.String("username", username),
zap.String("uuid", profile.UUID),
)
c.JSON(200, service.SerializeProfile(db, loggerInstance, redisClient, *profile))
c.JSON(200, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile))
}
func GetProfilesByName(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
db := database.MustGetDB()
// GetProfilesByName 批量获取配置文件
// @Summary Yggdrasil批量获取档案
// @Description Yggdrasil协议: 根据名称批量获取用户档案
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param request body []string true "用户名列表"
// @Success 200 {array} model.Profile "档案列表"
// @Failure 400 {object} APIResponse "参数错误"
// @Router /api/v1/yggdrasil/api/profiles/minecraft [post]
func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) {
var names []string
// 解析请求参数
if err := c.ShouldBindJSON(&names); err != nil {
loggerInstance.Error("[ERROR] 解析名称数组请求失败: ",
zap.Error(err),
)
h.logger.Error("解析名称数组请求失败", zap.Error(err))
standardResponse(c, http.StatusBadRequest, nil, ErrInvalidParams)
return
}
loggerInstance.Info("[INFO] 接收到批量获取配置文件请求",
zap.Int("名称数量:", len(names)), // 结构化存储名称数量
)
// 批量获取配置文件
profiles, err := service.GetProfilesDataByNames(db, names)
h.logger.Info("接收到批量获取配置文件请求", zap.Int("count", len(names)))
profiles, err := h.container.ProfileService.GetByNames(c.Request.Context(), names)
if err != nil {
loggerInstance.Error("[ERROR] 获取配置文件失败: ",
zap.Error(err),
)
h.logger.Error("获取配置文件失败", zap.Error(err))
}
// 改造zap 兼容原有 INFO 日志格式
loggerInstance.Info("[INFO] 成功获取配置文件",
zap.Int("请求名称数:", len(names)),
zap.Int("返回结果数: ", len(profiles)),
)
h.logger.Info("成功获取配置文件", zap.Int("requested", len(names)), zap.Int("returned", len(profiles)))
c.JSON(http.StatusOK, profiles)
}
func GetMetaData(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
redisClient := redis.MustGetClient()
// GetMetaData 获取Yggdrasil元数据
// @Summary Yggdrasil元数据
// @Description Yggdrasil协议: 获取服务器元数据
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "元数据"
// @Failure 500 {object} APIResponse "服务器错误"
// @Router /api/v1/yggdrasil [get]
func (h *YggdrasilHandler) GetMetaData(c *gin.Context) {
meta := gin.H{
"implementationName": "CellAuth",
"implementationVersion": "0.0.1",
@@ -601,26 +623,35 @@ func GetMetaData(c *gin.Context) {
"feature.non_email_login": true,
"feature.enable_profile_key": true,
}
skinDomains := []string{".hitwh.games", ".littlelan.cn"}
signature, err := service.GetPublicKeyFromRedisFunc(loggerInstance, redisClient)
signature, err := h.container.YggdrasilService.GetPublicKey(c.Request.Context())
if err != nil {
loggerInstance.Error("[ERROR] 获取公钥失败: ", zap.Error(err))
h.logger.Error("获取公钥失败", zap.Error(err))
standardResponse(c, http.StatusInternalServerError, nil, ErrInternalServer)
return
}
loggerInstance.Info("[INFO] 提供元数据")
c.JSON(http.StatusOK, gin.H{"meta": meta,
h.logger.Info("提供元数据")
c.JSON(http.StatusOK, gin.H{
"meta": meta,
"skinDomains": skinDomains,
"signaturePublickey": signature})
"signaturePublickey": signature,
})
}
func GetPlayerCertificates(c *gin.Context) {
loggerInstance := logger.MustGetLogger()
db := database.MustGetDB()
redisClient := redis.MustGetClient()
var uuid string
// GetPlayerCertificates 获取玩家证书
// @Summary Yggdrasil获取证书
// @Description Yggdrasil协议: 获取玩家证书
// @Tags Yggdrasil
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer {token}"
// @Success 200 {object} map[string]interface{} "证书信息"
// @Failure 401 {object} map[string]string "未授权"
// @Failure 500 {object} APIResponse "服务器错误"
// @Router /api/v1/yggdrasil/minecraftservices/player/certificates [post]
func (h *YggdrasilHandler) GetPlayerCertificates(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header not provided"})
@@ -628,39 +659,36 @@ func GetPlayerCertificates(c *gin.Context) {
return
}
// 检查是否以 Bearer 开头并提取 sessionID
bearerPrefix := "Bearer "
if len(authHeader) < len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"})
c.Abort()
return
}
tokenID := authHeader[len(bearerPrefix):]
if tokenID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"})
c.Abort()
return
}
var err error
uuid, err = service.GetUUIDByAccessToken(db, tokenID)
uuid, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), tokenID)
if uuid == "" {
loggerInstance.Error("[ERROR] 获取玩家UUID失败: ", zap.Error(err))
h.logger.Error("获取玩家UUID失败", zap.Error(err))
standardResponse(c, http.StatusInternalServerError, nil, ErrInternalServer)
return
}
// 格式化UUID
uuid = utils.FormatUUID(uuid)
// 生成玩家证书
certificate, err := service.GeneratePlayerCertificate(db, loggerInstance, redisClient, uuid)
certificate, err := h.container.YggdrasilService.GeneratePlayerCertificate(c.Request.Context(), uuid)
if err != nil {
loggerInstance.Error("[ERROR] 生成玩家证书失败: ", zap.Error(err))
h.logger.Error("生成玩家证书失败", zap.Error(err))
standardResponse(c, http.StatusInternalServerError, nil, ErrInternalServer)
return
}
loggerInstance.Info("[INFO] 成功生成玩家证书")
h.logger.Info("成功生成玩家证书")
c.JSON(http.StatusOK, certificate)
}

View File

@@ -1,25 +1,25 @@
package middleware
import (
"carrotskin/internal/model"
"net/http"
"strings"
"carrotskin/pkg/auth"
"github.com/gin-gonic/gin"
)
// AuthMiddleware JWT认证中间件
func AuthMiddleware() gin.HandlerFunc {
// AuthMiddleware JWT认证中间件注入JWT服务版本
func AuthMiddleware(jwtService *auth.JWTService) gin.HandlerFunc {
return gin.HandlerFunc(func(c *gin.Context) {
jwtService := auth.MustGetJWTService()
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "缺少Authorization头",
})
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
"缺少Authorization头",
nil,
))
c.Abort()
return
}
@@ -27,10 +27,11 @@ func AuthMiddleware() gin.HandlerFunc {
// Bearer token格式
tokenParts := strings.SplitN(authHeader, " ", 2)
if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "无效的Authorization头格式",
})
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
"无效的Authorization头格式",
nil,
))
c.Abort()
return
}
@@ -38,10 +39,11 @@ func AuthMiddleware() gin.HandlerFunc {
token := tokenParts[1]
claims, err := jwtService.ValidateToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 401,
"message": "无效的token",
})
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
model.CodeUnauthorized,
"无效的token",
err,
))
c.Abort()
return
}
@@ -49,17 +51,15 @@ func AuthMiddleware() gin.HandlerFunc {
// 将用户信息存储到上下文中
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Set("user_role", claims.Role)
c.Next()
})
}
// OptionalAuthMiddleware 可选的JWT认证中间件
func OptionalAuthMiddleware() gin.HandlerFunc {
// OptionalAuthMiddleware 可选的JWT认证中间件注入JWT服务版本
func OptionalAuthMiddleware(jwtService *auth.JWTService) gin.HandlerFunc {
return gin.HandlerFunc(func(c *gin.Context) {
jwtService := auth.MustGetJWTService()
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
tokenParts := strings.SplitN(authHeader, " ", 2)
@@ -69,7 +69,7 @@ func OptionalAuthMiddleware() gin.HandlerFunc {
if err == nil {
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Set("user_role", claims.Role)
}
}
}

View File

@@ -0,0 +1,109 @@
package middleware
import (
"net/http"
"carrotskin/pkg/auth"
"github.com/gin-gonic/gin"
)
// CasbinMiddleware Casbin权限中间件
// 需要先经过AuthMiddleware获取用户信息
func CasbinMiddleware(casbinService *auth.CasbinService, resource, action string) gin.HandlerFunc {
return func(c *gin.Context) {
// 从上下文获取用户角色由AuthMiddleware设置
role, exists := c.Get("user_role")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未授权访问",
})
c.Abort()
return
}
roleStr, ok := role.(string)
if !ok || roleStr == "" {
roleStr = "user" // 默认角色
}
// 检查权限
if !casbinService.CheckPermission(roleStr, resource, action) {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "权限不足",
})
c.Abort()
return
}
c.Next()
}
}
// RequireAdmin 要求管理员权限的中间件
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("user_role")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未授权访问",
})
c.Abort()
return
}
roleStr, ok := role.(string)
if !ok || roleStr != "admin" {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "需要管理员权限",
})
c.Abort()
return
}
c.Next()
}
}
// RequireRole 要求指定角色的中间件
func RequireRole(allowedRoles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("user_role")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未授权访问",
})
c.Abort()
return
}
roleStr, ok := role.(string)
if !ok {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "权限不足",
})
c.Abort()
return
}
// 检查是否在允许的角色列表中
for _, allowed := range allowedRoles {
if roleStr == allowed {
c.Next()
return
}
}
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "权限不足",
})
c.Abort()
}
}

View File

@@ -1,16 +1,52 @@
package middleware
import (
"carrotskin/pkg/config"
"github.com/gin-gonic/gin"
)
// CORS 跨域中间件
func CORS() gin.HandlerFunc {
// 获取配置,如果配置未初始化则使用默认值
var allowedOrigins []string
var isTestEnv bool
if cfg, err := config.GetConfig(); err == nil {
allowedOrigins = cfg.Security.AllowedOrigins
isTestEnv = cfg.IsTestEnvironment()
} else {
// 默认允许所有来源(向后兼容)
allowedOrigins = []string{"*"}
isTestEnv = false
}
return gin.HandlerFunc(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Credentials", "true")
origin := c.GetHeader("Origin")
// 检查是否允许该来源
allowOrigin := "*"
// 测试环境下强制使用 *,否则按配置处理
if !isTestEnv && len(allowedOrigins) > 0 && allowedOrigins[0] != "*" {
allowOrigin = ""
for _, allowed := range allowedOrigins {
if allowed == origin || allowed == "*" {
allowOrigin = origin
break
}
}
}
if allowOrigin != "" {
c.Header("Access-Control-Allow-Origin", allowOrigin)
// 只有在非通配符模式下才允许credentials
if allowOrigin != "*" {
c.Header("Access-Control-Allow-Credentials", "true")
}
}
c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
c.Header("Access-Control-Max-Age", "86400") // 缓存预检请求结果24小时
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)

View File

@@ -24,10 +24,11 @@ func TestCORS_Headers(t *testing.T) {
router.ServeHTTP(w, req)
// 验证CORS响应头
// 注意:当 Access-Control-Allow-Origin 为 "*" 时根据CORS规范
// 不应该设置 Access-Control-Allow-Credentials 为 "true"
expectedHeaders := map[string]string{
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "POST, OPTIONS, GET, PUT, DELETE",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS, GET, PUT, DELETE",
}
for header, expectedValue := range expectedHeaders {
@@ -37,6 +38,11 @@ func TestCORS_Headers(t *testing.T) {
}
}
// 验证在通配符模式下不设置Credentials这是正确的安全行为
if credentials := w.Header().Get("Access-Control-Allow-Credentials"); credentials != "" {
t.Errorf("通配符origin模式下不应设置 Access-Control-Allow-Credentials, got %q", credentials)
}
// 验证Access-Control-Allow-Headers包含必要字段
allowHeaders := w.Header().Get("Access-Control-Allow-Headers")
if allowHeaders == "" {
@@ -117,6 +123,30 @@ func TestCORS_AllowHeaders(t *testing.T) {
}
}
// TestCORS_WithSpecificOrigin 测试配置了具体origin时的CORS行为
func TestCORS_WithSpecificOrigin(t *testing.T) {
gin.SetMode(gin.TestMode)
// 注意此测试验证的是在配置了具体allowed origins时的行为
// 在没有配置初始化的情况下,默认使用通配符模式
router := gin.New()
router.Use(CORS())
router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success"})
})
req, _ := http.NewRequest("GET", "/test", nil)
req.Header.Set("Origin", "http://example.com")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// 默认配置下使用通配符所以不应该设置credentials
if credentials := w.Header().Get("Access-Control-Allow-Credentials"); credentials != "" {
t.Logf("当前模式下 Access-Control-Allow-Credentials = %q (通配符模式不设置)", credentials)
}
}
// 辅助函数:检查字符串是否包含子字符串(简单实现)
func contains(s, substr string) bool {
if len(substr) == 0 {

View File

@@ -1,6 +1,7 @@
package middleware
import (
"fmt"
"net/http"
"runtime/debug"
@@ -11,16 +12,26 @@ import (
// Recovery 恢复中间件
func Recovery(logger *zap.Logger) gin.HandlerFunc {
return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
if err, ok := recovered.(string); ok {
logger.Error("服务器恐慌",
zap.String("error", err),
zap.String("path", c.Request.URL.Path),
zap.String("method", c.Request.Method),
zap.String("ip", c.ClientIP()),
zap.String("stack", string(debug.Stack())),
)
// 将任意类型的panic转换为字符串
var errMsg string
switch v := recovered.(type) {
case string:
errMsg = v
case error:
errMsg = v.Error()
default:
errMsg = fmt.Sprintf("%v", v)
}
logger.Error("服务器恐慌",
zap.String("error", errMsg),
zap.String("path", c.Request.URL.Path),
zap.String("method", c.Request.Method),
zap.String("ip", c.ClientIP()),
zap.String("user_agent", c.GetHeader("User-Agent")),
zap.String("stack", string(debug.Stack())),
)
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": "服务器内部错误",

View File

@@ -5,20 +5,21 @@ import (
)
// AuditLog 审计日志模型
// @Description 系统操作审计日志记录
type AuditLog struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserID *int64 `gorm:"column:user_id;type:bigint;index" json:"user_id,omitempty"`
Action string `gorm:"column:action;type:varchar(100);not null;index" json:"action"`
ResourceType string `gorm:"column:resource_type;type:varchar(50);not null;index:idx_audit_logs_resource" json:"resource_type"`
ResourceID string `gorm:"column:resource_id;type:varchar(50);index:idx_audit_logs_resource" json:"resource_id,omitempty"`
UserID *int64 `gorm:"column:user_id;type:bigint;index:idx_audit_logs_user_created,priority:1" json:"user_id,omitempty"`
Action string `gorm:"column:action;type:varchar(100);not null;index:idx_audit_logs_action" json:"action"`
ResourceType string `gorm:"column:resource_type;type:varchar(50);not null;index:idx_audit_logs_resource,priority:1" json:"resource_type"`
ResourceID string `gorm:"column:resource_id;type:varchar(50);index:idx_audit_logs_resource,priority:2" json:"resource_id,omitempty"`
OldValues string `gorm:"column:old_values;type:jsonb" json:"old_values,omitempty"` // JSONB 格式
NewValues string `gorm:"column:new_values;type:jsonb" json:"new_values,omitempty"` // JSONB 格式
IPAddress string `gorm:"column:ip_address;type:inet;not null" json:"ip_address"`
IPAddress string `gorm:"column:ip_address;type:inet;not null;index:idx_audit_logs_ip" json:"ip_address"`
UserAgent string `gorm:"column:user_agent;type:text" json:"user_agent,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_audit_logs_created_at,sort:desc" json:"created_at"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_audit_logs_user_created,priority:2,sort:desc;index:idx_audit_logs_created_at,sort:desc" json:"created_at"`
// 关联
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:SET NULL" json:"user,omitempty"`
}
// TableName 指定表名
@@ -27,15 +28,16 @@ func (AuditLog) TableName() string {
}
// CasbinRule Casbin 权限规则模型
// @Description Casbin权限控制规则数据
type CasbinRule struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
PType string `gorm:"column:ptype;type:varchar(100);not null;index;uniqueIndex:uk_casbin_rule" json:"ptype"`
V0 string `gorm:"column:v0;type:varchar(100);not null;default:'';index;uniqueIndex:uk_casbin_rule" json:"v0"`
V1 string `gorm:"column:v1;type:varchar(100);not null;default:'';index;uniqueIndex:uk_casbin_rule" json:"v1"`
V2 string `gorm:"column:v2;type:varchar(100);not null;default:'';uniqueIndex:uk_casbin_rule" json:"v2"`
V3 string `gorm:"column:v3;type:varchar(100);not null;default:'';uniqueIndex:uk_casbin_rule" json:"v3"`
V4 string `gorm:"column:v4;type:varchar(100);not null;default:'';uniqueIndex:uk_casbin_rule" json:"v4"`
V5 string `gorm:"column:v5;type:varchar(100);not null;default:'';uniqueIndex:uk_casbin_rule" json:"v5"`
PType string `gorm:"column:ptype;type:varchar(100);not null;index:idx_casbin_ptype;uniqueIndex:uk_casbin_rule,priority:1" json:"ptype"`
V0 string `gorm:"column:v0;type:varchar(100);not null;default:'';index:idx_casbin_v0;uniqueIndex:uk_casbin_rule,priority:2" json:"v0"`
V1 string `gorm:"column:v1;type:varchar(100);not null;default:'';index:idx_casbin_v1;uniqueIndex:uk_casbin_rule,priority:3" json:"v1"`
V2 string `gorm:"column:v2;type:varchar(100);not null;default:'';uniqueIndex:uk_casbin_rule,priority:4" json:"v2"`
V3 string `gorm:"column:v3;type:varchar(100);not null;default:'';uniqueIndex:uk_casbin_rule,priority:5" json:"v3"`
V4 string `gorm:"column:v4;type:varchar(100);not null;default:'';uniqueIndex:uk_casbin_rule,priority:6" json:"v4"`
V5 string `gorm:"column:v5;type:varchar(100);not null;default:'';uniqueIndex:uk_casbin_rule,priority:7" json:"v5"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
}

24
internal/model/base.go Normal file
View File

@@ -0,0 +1,24 @@
package model
import (
"time"
"gorm.io/gorm"
)
// BaseModel 基础模型
// @Description 通用基础模型包含ID和时间戳字段
// 包含 uint 类型的 ID 和标准时间字段,但时间字段不通过 JSON 返回给前端
type BaseModel struct {
// ID 主键
ID uint `gorm:"primarykey" json:"id"`
// CreatedAt 创建时间 (不返回给前端)
CreatedAt time.Time `gorm:"column:created_at" json:"-"`
// UpdatedAt 更新时间 (不返回给前端)
UpdatedAt time.Time `gorm:"column:updated_at" json:"-"`
// DeletedAt 删除时间 (软删除,不返回给前端)
DeletedAt gorm.DeletedAt `gorm:"index;column:deleted_at" json:"-"`
}

24
internal/model/client.go Normal file
View File

@@ -0,0 +1,24 @@
package model
import "time"
// Client 客户端实体用于管理Token版本
// @Description Yggdrasil客户端Token管理数据
type Client struct {
UUID string `gorm:"column:uuid;type:varchar(36);primaryKey" json:"uuid"` // Client UUID
ClientToken string `gorm:"column:client_token;type:varchar(64);not null;uniqueIndex" json:"client_token"` // 客户端Token
UserID int64 `gorm:"column:user_id;not null;index:idx_clients_user_id" json:"user_id"` // 用户ID
ProfileID string `gorm:"column:profile_id;type:varchar(36);index:idx_clients_profile_id" json:"profile_id,omitempty"` // 选中的Profile
Version int `gorm:"column:version;not null;default:0;index:idx_clients_version" json:"version"` // 版本号
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
// 关联
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"user,omitempty"`
Profile *Profile `gorm:"foreignKey:ProfileID;references:UUID;constraint:OnDelete:CASCADE" json:"profile,omitempty"`
}
// TableName 指定表名
func (Client) TableName() string {
return "clients"
}

View File

@@ -5,22 +5,22 @@ import (
)
// Profile Minecraft 档案模型
// @Description Minecraft角色档案数据模型
type Profile struct {
UUID string `gorm:"column:uuid;type:varchar(36);primaryKey" json:"uuid"`
UserID int64 `gorm:"column:user_id;not null;index" json:"user_id"`
Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex" json:"name"` // Minecraft 角色名
SkinID *int64 `gorm:"column:skin_id;type:bigint" json:"skin_id,omitempty"`
CapeID *int64 `gorm:"column:cape_id;type:bigint" json:"cape_id,omitempty"`
UserID int64 `gorm:"column:user_id;not null;index:idx_profiles_user_created,priority:1" json:"user_id"`
Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex:idx_profiles_name" json:"name"` // Minecraft 角色名
SkinID *int64 `gorm:"column:skin_id;type:bigint;index:idx_profiles_skin_id" json:"skin_id,omitempty"`
CapeID *int64 `gorm:"column:cape_id;type:bigint;index:idx_profiles_cape_id" json:"cape_id,omitempty"`
RSAPrivateKey string `gorm:"column:rsa_private_key;type:text;not null" json:"-"` // RSA 私钥不返回给前端
IsActive bool `gorm:"column:is_active;not null;default:true;index" json:"is_active"`
LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp" json:"last_used_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;index:idx_profiles_last_used,sort:desc" json:"last_used_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_profiles_user_created,priority:2,sort:desc" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
// 关联
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Skin *Texture `gorm:"foreignKey:SkinID" json:"skin,omitempty"`
Cape *Texture `gorm:"foreignKey:CapeID" json:"cape,omitempty"`
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"user,omitempty"`
Skin *Texture `gorm:"foreignKey:SkinID;constraint:OnDelete:SET NULL" json:"skin,omitempty"`
Cape *Texture `gorm:"foreignKey:CapeID;constraint:OnDelete:SET NULL" json:"cape,omitempty"`
}
// TableName 指定表名
@@ -29,35 +29,43 @@ func (Profile) TableName() string {
}
// ProfileResponse 档案响应(包含完整的皮肤/披风信息)
// @Description Minecraft档案完整响应数据
type ProfileResponse struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Textures ProfileTexturesData `json:"textures"`
IsActive bool `json:"is_active"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ProfileTexturesData Minecraft 材质数据结构
// @Description Minecraft档案材质数据
type ProfileTexturesData struct {
Skin *ProfileTexture `json:"SKIN,omitempty"`
Cape *ProfileTexture `json:"CAPE,omitempty"`
}
// ProfileTexture 单个材质信息
// @Description 单个材质的详细信息
type ProfileTexture struct {
URL string `json:"url"`
Metadata *ProfileTextureMetadata `json:"metadata,omitempty"`
}
// ProfileTextureMetadata 材质元数据
// @Description 材质的元数据信息
type ProfileTextureMetadata struct {
Model string `json:"model,omitempty"` // "slim" or "classic"
}
// KeyPair RSA密钥对
// @Description 用于Yggdrasil认证的RSA密钥对
type KeyPair struct {
PrivateKey string `json:"private_key" bson:"private_key"`
PublicKey string `json:"public_key" bson:"public_key"`
Expiration time.Time `json:"expiration" bson:"expiration"`
Refresh time.Time `json:"refresh" bson:"refresh"`
PrivateKey string `json:"private_key" bson:"private_key"`
PublicKey string `json:"public_key" bson:"public_key"`
PublicKeySignature string `json:"public_key_signature" bson:"public_key_signature"`
PublicKeySignatureV2 string `json:"public_key_signature_v2" bson:"public_key_signature_v2"`
YggdrasilPublicKey string `json:"yggdrasil_public_key" bson:"yggdrasil_public_key"`
Expiration time.Time `json:"expiration" bson:"expiration"`
Refresh time.Time `json:"refresh" bson:"refresh"`
}

49
internal/model/report.go Normal file
View File

@@ -0,0 +1,49 @@
package model
import (
"time"
)
// ReportType 举报类型
// @Description 举报类型枚举TEXTURE(皮肤)或USER(用户)
type ReportType string
const (
ReportTypeTexture ReportType = "TEXTURE"
ReportTypeUser ReportType = "USER"
)
// ReportStatus 举报状态
// @Description 举报状态枚举PENDING(待处理)、APPROVED(已通过)、REJECTED(已驳回)
type ReportStatus string
const (
ReportStatusPending ReportStatus = "PENDING"
ReportStatusApproved ReportStatus = "APPROVED"
ReportStatusRejected ReportStatus = "REJECTED"
)
// Report 举报模型
// @Description 用户举报记录模型,用于举报皮肤或用户
type Report struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
ReporterID int64 `gorm:"column:reporter_id;not null;index:idx_reports_reporter_created,priority:1" json:"reporter_id"` // 举报人ID
TargetType ReportType `gorm:"column:target_type;type:varchar(50);not null;index:idx_reports_target_status,priority:1" json:"target_type"` // TEXTURE 或 USER
TargetID int64 `gorm:"column:target_id;not null;index:idx_reports_target_status,priority:2" json:"target_id"` // 被举报对象ID皮肤ID或用户ID
Reason string `gorm:"column:reason;type:text;not null" json:"reason"` // 举报原因
Status ReportStatus `gorm:"column:status;type:varchar(50);not null;default:'PENDING';index:idx_reports_status_created,priority:1;index:idx_reports_target_status,priority:3" json:"status"` // PENDING, APPROVED, REJECTED
ReviewerID *int64 `gorm:"column:reviewer_id;type:bigint" json:"reviewer_id,omitempty"` // 处理人ID管理员
ReviewNote string `gorm:"column:review_note;type:text" json:"review_note,omitempty"` // 处理备注
ReviewedAt *time.Time `gorm:"column:reviewed_at;type:timestamp" json:"reviewed_at,omitempty"` // 处理时间
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_reports_reporter_created,priority:2,sort:desc;index:idx_reports_status_created,priority:2,sort:desc" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
// 关联
Reporter *User `gorm:"foreignKey:ReporterID;constraint:OnDelete:CASCADE" json:"reporter,omitempty"`
Reviewer *User `gorm:"foreignKey:ReviewerID;constraint:OnDelete:SET NULL" json:"reviewer,omitempty"`
}
// TableName 指定表名
func (Report) TableName() string {
return "reports"
}

View File

@@ -1,23 +1,28 @@
package model
import "os"
// Response 通用API响应结构
// @Description 标准API响应格式
type Response struct {
Code int `json:"code"` // 业务状态码
Message string `json:"message"` // 响应消息
Data interface{} `json:"data,omitempty"` // 响应数据
Code int `json:"code"` // 业务状态码
Message string `json:"message"` // 响应消息
Data interface{} `json:"data,omitempty"` // 响应数据
}
// PaginationResponse 分页响应结构
// @Description 分页数据响应格式
type PaginationResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
Total int64 `json:"total"` // 总记录数
Page int `json:"page"` // 当前页码
PerPage int `json:"per_page"` // 每页数量
Total int64 `json:"total"` // 总记录数
Page int `json:"page"` // 当前页码
PerPage int `json:"per_page"` // 每页数量
}
// ErrorResponse 错误响应
// @Description API错误响应格式
type ErrorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
@@ -26,14 +31,14 @@ type ErrorResponse struct {
// 常用状态码
const (
CodeSuccess = 200 // 成功
CodeCreated = 201 // 创建成功
CodeBadRequest = 400 // 请求参数错误
CodeUnauthorized = 401 // 未授权
CodeForbidden = 403 // 禁止访问
CodeNotFound = 404 // 资源不存在
CodeConflict = 409 // 资源冲突
CodeServerError = 500 // 服务器错误
CodeSuccess = 200 // 成功
CodeCreated = 201 // 创建成功
CodeBadRequest = 400 // 请求参数错误
CodeUnauthorized = 401 // 未授权
CodeForbidden = 403 // 禁止访问
CodeNotFound = 404 // 资源不存在
CodeConflict = 409 // 资源冲突
CodeServerError = 500 // 服务器错误
)
// 常用响应消息
@@ -61,17 +66,26 @@ func NewSuccessResponse(data interface{}) *Response {
}
// NewErrorResponse 创建错误响应
// 注意err参数仅在开发环境下显示生产环境不应暴露详细错误信息
func NewErrorResponse(code int, message string, err error) *ErrorResponse {
resp := &ErrorResponse{
Code: code,
Message: message,
}
if err != nil {
// 仅在非生产环境下返回详细错误信息
// 可以通过环境变量 ENVIRONMENT 控制
if err != nil && !isProductionEnvironment() {
resp.Error = err.Error()
}
return resp
}
// isProductionEnvironment 检查是否为生产环境
func isProductionEnvironment() bool {
env := os.Getenv("ENVIRONMENT")
return env == "production" || env == "prod"
}
// NewPaginationResponse 创建分页响应
func NewPaginationResponse(data interface{}, total int64, page, perPage int) *PaginationResponse {
return &PaginationResponse{

View File

@@ -1,41 +0,0 @@
package model
import (
"time"
)
// ConfigType 配置类型
type ConfigType string
const (
ConfigTypeString ConfigType = "STRING"
ConfigTypeInteger ConfigType = "INTEGER"
ConfigTypeBoolean ConfigType = "BOOLEAN"
ConfigTypeJSON ConfigType = "JSON"
)
// SystemConfig 系统配置模型
type SystemConfig struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Key string `gorm:"column:key;type:varchar(100);not null;uniqueIndex" json:"key"`
Value string `gorm:"column:value;type:text;not null" json:"value"`
Description string `gorm:"column:description;type:varchar(255);not null;default:''" json:"description"`
Type ConfigType `gorm:"column:type;type:varchar(50);not null;default:'STRING'" json:"type"` // STRING, INTEGER, BOOLEAN, JSON
IsPublic bool `gorm:"column:is_public;not null;default:false;index" json:"is_public"` // 是否可被前端获取
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
}
// TableName 指定表名
func (SystemConfig) TableName() string {
return "system_config"
}
// SystemConfigPublicResponse 公开配置响应
type SystemConfigPublicResponse struct {
SiteName string `json:"site_name"`
SiteDescription string `json:"site_description"`
RegistrationEnabled bool `json:"registration_enabled"`
MaintenanceMode bool `json:"maintenance_mode"`
Announcement string `json:"announcement"`
}

View File

@@ -5,6 +5,7 @@ import (
)
// TextureType 材质类型
// @Description 材质类型枚举SKIN(皮肤)或CAPE(披风)
type TextureType string
const (
@@ -13,25 +14,26 @@ const (
)
// Texture 材质模型
// @Description Minecraft材质数据模型
type Texture struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UploaderID int64 `gorm:"column:uploader_id;not null;index" json:"uploader_id"`
UploaderID int64 `gorm:"column:uploader_id;not null;index:idx_textures_uploader_status,priority:1;index:idx_textures_uploader_created,priority:1" json:"uploader_id"`
Name string `gorm:"column:name;type:varchar(100);not null;default:''" json:"name"`
Description string `gorm:"column:description;type:text" json:"description,omitempty"`
Type TextureType `gorm:"column:type;type:varchar(50);not null" json:"type"` // SKIN, CAPE
Type TextureType `gorm:"column:type;type:varchar(50);not null;index:idx_textures_public_type_status,priority:2" json:"type"` // SKIN, CAPE
URL string `gorm:"column:url;type:varchar(255);not null" json:"url"`
Hash string `gorm:"column:hash;type:varchar(64);not null;uniqueIndex" json:"hash"` // SHA-256
Hash string `gorm:"column:hash;type:varchar(64);not null;index:idx_textures_hash" json:"hash"` // SHA-256
Size int `gorm:"column:size;type:integer;not null;default:0" json:"size"`
IsPublic bool `gorm:"column:is_public;not null;default:false;index:idx_textures_public_type_status" json:"is_public"`
IsPublic bool `gorm:"column:is_public;not null;default:false;index:idx_textures_public_type_status,priority:1" json:"is_public"`
DownloadCount int `gorm:"column:download_count;type:integer;not null;default:0;index:idx_textures_download_count,sort:desc" json:"download_count"`
FavoriteCount int `gorm:"column:favorite_count;type:integer;not null;default:0;index:idx_textures_favorite_count,sort:desc" json:"favorite_count"`
IsSlim bool `gorm:"column:is_slim;not null;default:false" json:"is_slim"` // Alex(细) or Steve(粗)
Status int16 `gorm:"column:status;type:smallint;not null;default:1;index:idx_textures_public_type_status" json:"status"` // 1:正常, 0:审核中, -1:已删除
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
IsSlim bool `gorm:"column:is_slim;not null;default:false" json:"is_slim"` // Alex(细) or Steve(粗)
Status int16 `gorm:"column:status;type:smallint;not null;default:1;index:idx_textures_public_type_status,priority:3;index:idx_textures_uploader_status,priority:2" json:"status"` // 1:正常, 0:审核中, -1:已删除
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_textures_uploader_created,priority:2,sort:desc;index:idx_textures_created_at,sort:desc" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
// 关联
Uploader *User `gorm:"foreignKey:UploaderID" json:"uploader,omitempty"`
Uploader *User `gorm:"foreignKey:UploaderID;constraint:OnDelete:CASCADE" json:"uploader,omitempty"`
}
// TableName 指定表名
@@ -40,15 +42,16 @@ func (Texture) TableName() string {
}
// UserTextureFavorite 用户材质收藏
// @Description 用户收藏材质关联表
type UserTextureFavorite struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserID int64 `gorm:"column:user_id;not null;index;uniqueIndex:uk_user_texture" json:"user_id"`
TextureID int64 `gorm:"column:texture_id;not null;index;uniqueIndex:uk_user_texture" json:"texture_id"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index" json:"created_at"`
UserID int64 `gorm:"column:user_id;not null;uniqueIndex:uk_user_texture,priority:1;index:idx_favorites_user_created,priority:1" json:"user_id"`
TextureID int64 `gorm:"column:texture_id;not null;uniqueIndex:uk_user_texture,priority:2;index:idx_favorites_texture_id" json:"texture_id"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_favorites_user_created,priority:2,sort:desc;index:idx_favorites_created_at,sort:desc" json:"created_at"`
// 关联
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Texture *Texture `gorm:"foreignKey:TextureID" json:"texture,omitempty"`
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"user,omitempty"`
Texture *Texture `gorm:"foreignKey:TextureID;constraint:OnDelete:CASCADE" json:"texture,omitempty"`
}
// TableName 指定表名
@@ -57,17 +60,18 @@ func (UserTextureFavorite) TableName() string {
}
// TextureDownloadLog 材质下载记录
// @Description 材质下载日志记录
type TextureDownloadLog struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
TextureID int64 `gorm:"column:texture_id;not null;index" json:"texture_id"`
UserID *int64 `gorm:"column:user_id;type:bigint;index" json:"user_id,omitempty"`
IPAddress string `gorm:"column:ip_address;type:inet;not null;index" json:"ip_address"`
TextureID int64 `gorm:"column:texture_id;not null;index:idx_download_logs_texture_created,priority:1" json:"texture_id"`
UserID *int64 `gorm:"column:user_id;type:bigint;index:idx_download_logs_user_id" json:"user_id,omitempty"`
IPAddress string `gorm:"column:ip_address;type:inet;not null;index:idx_download_logs_ip" json:"ip_address"`
UserAgent string `gorm:"column:user_agent;type:text" json:"user_agent,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_download_logs_created_at,sort:desc" json:"created_at"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_download_logs_texture_created,priority:2,sort:desc;index:idx_download_logs_created_at,sort:desc" json:"created_at"`
// 关联
Texture *Texture `gorm:"foreignKey:TextureID" json:"texture,omitempty"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Texture *Texture `gorm:"foreignKey:TextureID;constraint:OnDelete:CASCADE" json:"texture,omitempty"`
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:SET NULL" json:"user,omitempty"`
}
// TableName 指定表名

View File

@@ -1,14 +0,0 @@
package model
import "time"
type Token struct {
AccessToken string `json:"_id"`
UserID int64 `json:"user_id"`
ClientToken string `json:"client_token"`
ProfileId string `json:"profile_id"`
Usable bool `json:"usable"`
IssueDate time.Time `json:"issue_date"`
}
func (Token) TableName() string { return "token" }

View File

@@ -2,22 +2,25 @@ package model
import (
"time"
"gorm.io/datatypes"
)
// User 用户模型
// @Description 用户账户数据模型
type User struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Username string `gorm:"column:username;type:varchar(255);not null;uniqueIndex" json:"username"`
Password string `gorm:"column:password;type:varchar(255);not null" json:"-"` // 密码不返回给前端
Email string `gorm:"column:email;type:varchar(255);not null;uniqueIndex" json:"email"`
Avatar string `gorm:"column:avatar;type:varchar(255);not null;default:''" json:"avatar"`
Points int `gorm:"column:points;type:integer;not null;default:0" json:"points"`
Role string `gorm:"column:role;type:varchar(50);not null;default:'user'" json:"role"`
Status int16 `gorm:"column:status;type:smallint;not null;default:1" json:"status"` // 1:正常, 0:禁用, -1:删除
Properties string `gorm:"column:properties;type:jsonb" json:"properties"` // JSON字符串存储为PostgreSQL的JSONB类型
LastLoginAt *time.Time `gorm:"column:last_login_at;type:timestamp" json:"last_login_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Username string `gorm:"column:username;type:varchar(255);not null;uniqueIndex:idx_user_username_status,priority:1" json:"username"`
Password string `gorm:"column:password;type:varchar(255);not null" json:"-"` // 密码不返回给前端
Email string `gorm:"column:email;type:varchar(255);not null;uniqueIndex:idx_user_email_status,priority:1" json:"email"`
Avatar string `gorm:"column:avatar;type:varchar(255);not null;default:''" json:"avatar"`
Points int `gorm:"column:points;type:integer;not null;default:0;index:idx_user_points,sort:desc" json:"points"`
Role string `gorm:"column:role;type:varchar(50);not null;default:'user';index:idx_user_role_status,priority:1" json:"role"`
Status int16 `gorm:"column:status;type:smallint;not null;default:1;index:idx_user_username_status,priority:2;index:idx_user_email_status,priority:2;index:idx_user_role_status,priority:2" json:"status"` // 1:正常, 0:禁用, -1:删除
Properties *datatypes.JSON `gorm:"column:properties;type:jsonb" json:"properties,omitempty" swaggertype:"string"` // JSON数据存储为PostgreSQL的JSONB类型
LastLoginAt *time.Time `gorm:"column:last_login_at;type:timestamp;index:idx_user_last_login,sort:desc" json:"last_login_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_user_created_at,sort:desc" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
}
// TableName 指定表名
@@ -26,22 +29,23 @@ func (User) TableName() string {
}
// UserPointLog 用户积分变更记录
// @Description 用户积分变动日志记录
type UserPointLog struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserID int64 `gorm:"column:user_id;not null;index" json:"user_id"`
ChangeType string `gorm:"column:change_type;type:varchar(50);not null" json:"change_type"` // EARN, SPEND, ADMIN_ADJUST
UserID int64 `gorm:"column:user_id;not null;index:idx_point_logs_user_created,priority:1" json:"user_id"`
ChangeType string `gorm:"column:change_type;type:varchar(50);not null;index:idx_point_logs_change_type" json:"change_type"` // EARN, SPEND, ADMIN_ADJUST
Amount int `gorm:"column:amount;type:integer;not null" json:"amount"`
BalanceBefore int `gorm:"column:balance_before;type:integer;not null" json:"balance_before"`
BalanceAfter int `gorm:"column:balance_after;type:integer;not null" json:"balance_after"`
Reason string `gorm:"column:reason;type:varchar(255);not null" json:"reason"`
ReferenceType string `gorm:"column:reference_type;type:varchar(50)" json:"reference_type,omitempty"`
ReferenceID *int64 `gorm:"column:reference_id;type:bigint" json:"reference_id,omitempty"`
OperatorID *int64 `gorm:"column:operator_id;type:bigint" json:"operator_id,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_point_logs_created_at,sort:desc" json:"created_at"`
OperatorID *int64 `gorm:"column:operator_id;type:bigint;index" json:"operator_id,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_point_logs_user_created,priority:2,sort:desc;index:idx_point_logs_created_at,sort:desc" json:"created_at"`
// 关联
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Operator *User `gorm:"foreignKey:OperatorID" json:"operator,omitempty"`
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"user,omitempty"`
Operator *User `gorm:"foreignKey:OperatorID;constraint:OnDelete:SET NULL" json:"operator,omitempty"`
}
// TableName 指定表名
@@ -50,18 +54,19 @@ func (UserPointLog) TableName() string {
}
// UserLoginLog 用户登录日志
// @Description 用户登录历史记录
type UserLoginLog struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserID int64 `gorm:"column:user_id;not null;index" json:"user_id"`
IPAddress string `gorm:"column:ip_address;type:inet;not null;index" json:"ip_address"`
UserID int64 `gorm:"column:user_id;not null;index:idx_login_logs_user_created,priority:1" json:"user_id"`
IPAddress string `gorm:"column:ip_address;type:inet;not null;index:idx_login_logs_ip" json:"ip_address"`
UserAgent string `gorm:"column:user_agent;type:text" json:"user_agent,omitempty"`
LoginMethod string `gorm:"column:login_method;type:varchar(50);not null;default:'PASSWORD'" json:"login_method"`
IsSuccess bool `gorm:"column:is_success;not null;index" json:"is_success"`
IsSuccess bool `gorm:"column:is_success;not null;index:idx_login_logs_success" json:"is_success"`
FailureReason string `gorm:"column:failure_reason;type:varchar(255)" json:"failure_reason,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_login_logs_created_at,sort:desc" json:"created_at"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_login_logs_user_created,priority:2,sort:desc;index:idx_login_logs_created_at,sort:desc" json:"created_at"`
// 关联
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"user,omitempty"`
}
// TableName 指定表名

View File

@@ -1,48 +1,62 @@
package model
import (
"crypto/rand"
"fmt"
"math/big"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"math/rand"
"time"
)
// 定义随机字符集
const passwordChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
// Yggdrasil ygg密码与用户id绑定
// @Description Yggdrasil认证密码数据模型
type Yggdrasil struct {
ID int64 `gorm:"column:id;primaryKey;not null" json:"id"`
Password string `gorm:"column:password;not null" json:"password"`
Password string `gorm:"column:password;type:varchar(255);not null" json:"-"` // 加密后的密码,不返回给前端
// 关联 - Yggdrasil的ID引用User的ID但不自动创建外键约束避免循环依赖
User *User `gorm:"foreignKey:ID;references:ID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE" json:"user,omitempty"`
}
func (Yggdrasil) TableName() string { return "Yggdrasil" }
func (Yggdrasil) TableName() string { return "yggdrasil" }
// AfterCreate User创建后自动同步生成GeneratePassword记录
// AfterCreate User创建后自动同步生成Yggdrasil密码记录
func (u *User) AfterCreate(tx *gorm.DB) error {
randomPwd := GenerateRandomPassword(16)
// 生成随机明文密码
plainPassword := GenerateRandomPassword(16)
// 创建GeneratePassword记录
gp := Yggdrasil{
ID: u.ID, // 关联User的ID
Password: randomPwd, // 16位随机密码
// 使用 bcrypt 加密密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("密码加密失败: %w", err)
}
if err := tx.Create(&gp).Error; err != nil {
// 若同步失败,可记录日志或回滚事务(根据业务需求处理)
return fmt.Errorf("同步生成密码失败: %w", err)
// 创建Yggdrasil记录存储加密后的密码
ygg := Yggdrasil{
ID: u.ID,
Password: string(hashedPassword),
}
if err := tx.Create(&ygg).Error; err != nil {
return fmt.Errorf("同步生成Yggdrasil密码失败: %w", err)
}
return nil
}
// GenerateRandomPassword 生成指定长度的随机字符串
// GenerateRandomPassword 生成指定长度的安全随机字符串
func GenerateRandomPassword(length int) string {
rand.Seed(time.Now().UnixNano()) // 初始化随机数种子
b := make([]byte, length)
for i := range b {
b[i] = passwordChars[rand.Intn(len(passwordChars))]
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(passwordChars))))
if err != nil {
// 如果安全随机数生成失败,使用固定值(极端情况下的降级处理)
b[i] = passwordChars[0]
continue
}
b[i] = passwordChars[num.Int64()]
}
return string(b)
}

View File

@@ -0,0 +1,18 @@
package model
import (
"strings"
"testing"
)
func TestGenerateRandomPassword(t *testing.T) {
pwd := GenerateRandomPassword(16)
if len(pwd) != 16 {
t.Fatalf("length mismatch: %d", len(pwd))
}
for _, ch := range pwd {
if !strings.ContainsRune(passwordChars, ch) {
t.Fatalf("unexpected char: %c", ch)
}
}
}

View File

@@ -0,0 +1,64 @@
package repository
import (
"carrotskin/internal/model"
"context"
"gorm.io/gorm"
)
// clientRepository ClientRepository的实现
type clientRepository struct {
db *gorm.DB
}
// NewClientRepository 创建ClientRepository实例
func NewClientRepository(db *gorm.DB) ClientRepository {
return &clientRepository{db: db}
}
func (r *clientRepository) Create(ctx context.Context, client *model.Client) error {
return r.db.WithContext(ctx).Create(client).Error
}
func (r *clientRepository) FindByClientToken(ctx context.Context, clientToken string) (*model.Client, error) {
var client model.Client
err := r.db.WithContext(ctx).Where("client_token = ?", clientToken).First(&client).Error
if err != nil {
return nil, err
}
return &client, nil
}
func (r *clientRepository) FindByUUID(ctx context.Context, uuid string) (*model.Client, error) {
var client model.Client
err := r.db.WithContext(ctx).Where("uuid = ?", uuid).First(&client).Error
if err != nil {
return nil, err
}
return &client, nil
}
func (r *clientRepository) FindByUserID(ctx context.Context, userID int64) ([]*model.Client, error) {
var clients []*model.Client
err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&clients).Error
return clients, err
}
func (r *clientRepository) Update(ctx context.Context, client *model.Client) error {
return r.db.WithContext(ctx).Save(client).Error
}
func (r *clientRepository) IncrementVersion(ctx context.Context, clientUUID string) error {
return r.db.WithContext(ctx).Model(&model.Client{}).
Where("uuid = ?", clientUUID).
Update("version", gorm.Expr("version + 1")).Error
}
func (r *clientRepository) DeleteByClientToken(ctx context.Context, clientToken string) error {
return r.db.WithContext(ctx).Where("client_token = ?", clientToken).Delete(&model.Client{}).Error
}
func (r *clientRepository) DeleteByUserID(ctx context.Context, userID int64) error {
return r.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&model.Client{}).Error
}

View File

@@ -0,0 +1,75 @@
package repository
import (
"errors"
"gorm.io/gorm"
)
// IsNotFound 检查是否为记录未找到错误
func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
// HandleNotFound 处理记录未找到的情况,未找到时返回 nil, nil
func HandleNotFound[T any](result *T, err error) (*T, error) {
if err != nil {
if IsNotFound(err) {
return nil, nil
}
return nil, err
}
return result, nil
}
// Paginate 创建分页查询
func Paginate(page, pageSize int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
offset := (page - 1) * pageSize
return db.Offset(offset).Limit(pageSize)
}
}
// PaginatedQuery 执行分页查询,返回列表和总数
func PaginatedQuery[T any](
baseQuery *gorm.DB,
page, pageSize int,
orderBy string,
preloads ...string,
) ([]T, int64, error) {
var items []T
var total int64
// 获取总数
if err := baseQuery.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页查询
query := baseQuery.Scopes(Paginate(page, pageSize))
// 添加排序
if orderBy != "" {
query = query.Order(orderBy)
}
// 添加预加载
for _, preload := range preloads {
query = query.Preload(preload)
}
if err := query.Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}

View File

@@ -0,0 +1,99 @@
package repository
import (
"carrotskin/internal/model"
"context"
)
// UserRepository 用户仓储接口
type UserRepository interface {
Create(ctx context.Context, user *model.User) error
FindByID(ctx context.Context, id int64) (*model.User, error)
FindByUsername(ctx context.Context, username string) (*model.User, error)
FindByEmail(ctx context.Context, email string) (*model.User, error)
FindByIDs(ctx context.Context, ids []int64) ([]*model.User, error) // 批量查询
Update(ctx context.Context, user *model.User) error
UpdateFields(ctx context.Context, id int64, fields map[string]interface{}) error
BatchUpdate(ctx context.Context, ids []int64, fields map[string]interface{}) (int64, error) // 批量更新
Delete(ctx context.Context, id int64) error
BatchDelete(ctx context.Context, ids []int64) (int64, error) // 批量删除
CreateLoginLog(ctx context.Context, log *model.UserLoginLog) error
CreatePointLog(ctx context.Context, log *model.UserPointLog) error
UpdatePoints(ctx context.Context, userID int64, amount int, changeType, reason string) error
}
// ProfileRepository 档案仓储接口
type ProfileRepository interface {
Create(ctx context.Context, profile *model.Profile) error
FindByUUID(ctx context.Context, uuid string) (*model.Profile, error)
FindByName(ctx context.Context, name string) (*model.Profile, error)
FindByUserID(ctx context.Context, userID int64) ([]*model.Profile, error)
FindByUUIDs(ctx context.Context, uuids []string) ([]*model.Profile, error) // 批量查询
Update(ctx context.Context, profile *model.Profile) error
UpdateFields(ctx context.Context, uuid string, updates map[string]interface{}) error
BatchUpdate(ctx context.Context, uuids []string, updates map[string]interface{}) (int64, error) // 批量更新
Delete(ctx context.Context, uuid string) error
BatchDelete(ctx context.Context, uuids []string) (int64, error) // 批量删除
CountByUserID(ctx context.Context, userID int64) (int64, error)
UpdateLastUsedAt(ctx context.Context, uuid string) error
GetByNames(ctx context.Context, names []string) ([]*model.Profile, error)
GetKeyPair(ctx context.Context, profileId string) (*model.KeyPair, error)
UpdateKeyPair(ctx context.Context, profileId string, keyPair *model.KeyPair) error
}
// TextureRepository 材质仓储接口
type TextureRepository interface {
Create(ctx context.Context, texture *model.Texture) error
FindByID(ctx context.Context, id int64) (*model.Texture, error)
FindByHash(ctx context.Context, hash string) (*model.Texture, error)
FindByHashAndUploaderID(ctx context.Context, hash string, uploaderID int64) (*model.Texture, error) // 根据Hash和上传者ID查找
FindByIDs(ctx context.Context, ids []int64) ([]*model.Texture, error) // 批量查询
FindByUploaderID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error)
Search(ctx context.Context, keyword string, textureType model.TextureType, publicOnly bool, page, pageSize int) ([]*model.Texture, int64, error)
Update(ctx context.Context, texture *model.Texture) error
UpdateFields(ctx context.Context, id int64, fields map[string]interface{}) error
BatchUpdate(ctx context.Context, ids []int64, fields map[string]interface{}) (int64, error) // 批量更新
Delete(ctx context.Context, id int64) error
BatchDelete(ctx context.Context, ids []int64) (int64, error) // 批量删除
IncrementDownloadCount(ctx context.Context, id int64) error
CreateDownloadLog(ctx context.Context, log *model.TextureDownloadLog) error
ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, error)
GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error)
CountByUploaderID(ctx context.Context, uploaderID int64) (int64, error)
}
// YggdrasilRepository Yggdrasil仓储接口
type YggdrasilRepository interface {
GetPasswordByID(ctx context.Context, id int64) (string, error)
ResetPassword(ctx context.Context, id int64, password string) error
}
// ClientRepository Client仓储接口
type ClientRepository interface {
Create(ctx context.Context, client *model.Client) error
FindByClientToken(ctx context.Context, clientToken string) (*model.Client, error)
FindByUUID(ctx context.Context, uuid string) (*model.Client, error)
FindByUserID(ctx context.Context, userID int64) ([]*model.Client, error)
Update(ctx context.Context, client *model.Client) error
IncrementVersion(ctx context.Context, clientUUID string) error
DeleteByClientToken(ctx context.Context, clientToken string) error
DeleteByUserID(ctx context.Context, userID int64) error
}
// ReportRepository 举报仓储接口
type ReportRepository interface {
Create(ctx context.Context, report *model.Report) error
FindByID(ctx context.Context, id int64) (*model.Report, error)
FindByReporterID(ctx context.Context, reporterID int64, page, pageSize int) ([]*model.Report, int64, error)
FindByTarget(ctx context.Context, targetType model.ReportType, targetID int64, page, pageSize int) ([]*model.Report, int64, error)
FindByStatus(ctx context.Context, status model.ReportStatus, page, pageSize int) ([]*model.Report, int64, error)
Search(ctx context.Context, keyword string, page, pageSize int) ([]*model.Report, int64, error)
Update(ctx context.Context, report *model.Report) error
UpdateFields(ctx context.Context, id int64, fields map[string]interface{}) error
Review(ctx context.Context, id int64, status model.ReportStatus, reviewerID int64, reviewNote string) error
BatchReview(ctx context.Context, ids []int64, status model.ReportStatus, reviewerID int64, reviewNote string) (int64, error)
Delete(ctx context.Context, id int64) error
BatchDelete(ctx context.Context, ids []int64) (int64, error)
CountByStatus(ctx context.Context, status model.ReportStatus) (int64, error)
CheckDuplicate(ctx context.Context, reporterID int64, targetType model.ReportType, targetID int64) (bool, error)
}

View File

@@ -2,7 +2,6 @@ package repository
import (
"carrotskin/internal/model"
"carrotskin/pkg/database"
"context"
"errors"
"fmt"
@@ -10,17 +9,23 @@ import (
"gorm.io/gorm"
)
// CreateProfile 创建档案
func CreateProfile(profile *model.Profile) error {
db := database.MustGetDB()
return db.Create(profile).Error
// profileRepository ProfileRepository的实现
type profileRepository struct {
db *gorm.DB
}
// FindProfileByUUID 根据UUID查找档案
func FindProfileByUUID(uuid string) (*model.Profile, error) {
db := database.MustGetDB()
// NewProfileRepository 创建ProfileRepository实例
func NewProfileRepository(db *gorm.DB) ProfileRepository {
return &profileRepository{db: db}
}
func (r *profileRepository) Create(ctx context.Context, profile *model.Profile) error {
return r.db.WithContext(ctx).Create(profile).Error
}
func (r *profileRepository) FindByUUID(ctx context.Context, uuid string) (*model.Profile, error) {
var profile model.Profile
err := db.Where("uuid = ?", uuid).
err := r.db.WithContext(ctx).Where("uuid = ?", uuid).
Preload("Skin").
Preload("Cape").
First(&profile).Error
@@ -30,145 +35,117 @@ func FindProfileByUUID(uuid string) (*model.Profile, error) {
return &profile, nil
}
// FindProfileByName 根据角色名查找档案
func FindProfileByName(name string) (*model.Profile, error) {
db := database.MustGetDB()
func (r *profileRepository) FindByName(ctx context.Context, name string) (*model.Profile, error) {
var profile model.Profile
err := db.Where("name = ?", name).First(&profile).Error
// 使用 LOWER 函数进行不区分大小写的查询,并预加载 Skin 和 Cape
err := r.db.WithContext(ctx).Where("LOWER(name) = LOWER(?)", name).
Preload("Skin").
Preload("Cape").
First(&profile).Error
if err != nil {
return nil, err
}
return &profile, nil
}
// FindProfilesByUserID 获取用户的所有档案
func FindProfilesByUserID(userID int64) ([]*model.Profile, error) {
db := database.MustGetDB()
func (r *profileRepository) FindByUserID(ctx context.Context, userID int64) ([]*model.Profile, error) {
var profiles []*model.Profile
err := db.Where("user_id = ?", userID).
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Preload("Skin").
Preload("Cape").
Order("created_at DESC").
Find(&profiles).Error
if err != nil {
return nil, err
return profiles, err
}
func (r *profileRepository) FindByUUIDs(ctx context.Context, uuids []string) ([]*model.Profile, error) {
if len(uuids) == 0 {
return []*model.Profile{}, nil
}
return profiles, nil
var profiles []*model.Profile
// 使用 IN 查询优化批量查询,并预加载关联
err := r.db.WithContext(ctx).Where("uuid IN ?", uuids).
Preload("Skin").
Preload("Cape").
Find(&profiles).Error
return profiles, err
}
// UpdateProfile 更新档案
func UpdateProfile(profile *model.Profile) error {
db := database.MustGetDB()
return db.Save(profile).Error
func (r *profileRepository) Update(ctx context.Context, profile *model.Profile) error {
return r.db.WithContext(ctx).Save(profile).Error
}
// UpdateProfileFields 更新指定字段
func UpdateProfileFields(uuid string, updates map[string]interface{}) error {
db := database.MustGetDB()
return db.Model(&model.Profile{}).
func (r *profileRepository) UpdateFields(ctx context.Context, uuid string, updates map[string]interface{}) error {
return r.db.WithContext(ctx).Model(&model.Profile{}).
Where("uuid = ?", uuid).
Updates(updates).Error
}
// DeleteProfile 删除档案
func DeleteProfile(uuid string) error {
db := database.MustGetDB()
return db.Where("uuid = ?", uuid).Delete(&model.Profile{}).Error
func (r *profileRepository) Delete(ctx context.Context, uuid string) error {
return r.db.WithContext(ctx).Where("uuid = ?", uuid).Delete(&model.Profile{}).Error
}
// CountProfilesByUserID 统计用户的档案数量
func CountProfilesByUserID(userID int64) (int64, error) {
db := database.MustGetDB()
func (r *profileRepository) BatchUpdate(ctx context.Context, uuids []string, updates map[string]interface{}) (int64, error) {
if len(uuids) == 0 {
return 0, nil
}
result := r.db.WithContext(ctx).Model(&model.Profile{}).Where("uuid IN ?", uuids).Updates(updates)
return result.RowsAffected, result.Error
}
func (r *profileRepository) BatchDelete(ctx context.Context, uuids []string) (int64, error) {
if len(uuids) == 0 {
return 0, nil
}
result := r.db.WithContext(ctx).Where("uuid IN ?", uuids).Delete(&model.Profile{})
return result.RowsAffected, result.Error
}
func (r *profileRepository) CountByUserID(ctx context.Context, userID int64) (int64, error) {
var count int64
err := db.Model(&model.Profile{}).
err := r.db.WithContext(ctx).Model(&model.Profile{}).
Where("user_id = ?", userID).
Count(&count).Error
return count, err
}
// SetActiveProfile 设置档案为活跃状态(同时将用户的其他档案设置为非活跃)
func SetActiveProfile(uuid string, userID int64) error {
db := database.MustGetDB()
return db.Transaction(func(tx *gorm.DB) error {
// 将用户的所有档案设置为非活跃
if err := tx.Model(&model.Profile{}).
Where("user_id = ?", userID).
Update("is_active", false).Error; err != nil {
return err
}
// 将指定档案设置为活跃
if err := tx.Model(&model.Profile{}).
Where("uuid = ? AND user_id = ?", uuid, userID).
Update("is_active", true).Error; err != nil {
return err
}
return nil
})
}
// UpdateProfileLastUsedAt 更新最后使用时间
func UpdateProfileLastUsedAt(uuid string) error {
db := database.MustGetDB()
return db.Model(&model.Profile{}).
func (r *profileRepository) UpdateLastUsedAt(ctx context.Context, uuid string) error {
return r.db.WithContext(ctx).Model(&model.Profile{}).
Where("uuid = ?", uuid).
Update("last_used_at", gorm.Expr("CURRENT_TIMESTAMP")).Error
}
// FindOneProfileByUserID 根据id找一个角色
func FindOneProfileByUserID(userID int64) (*model.Profile, error) {
profiles, err := FindProfilesByUserID(userID)
if err != nil {
return nil, err
}
profile := profiles[0]
return profile, nil
}
func GetProfilesByNames(names []string) ([]*model.Profile, error) {
db := database.MustGetDB()
func (r *profileRepository) GetByNames(ctx context.Context, names []string) ([]*model.Profile, error) {
var profiles []*model.Profile
err := db.Where("name in (?)", names).Find(&profiles).Error
if err != nil {
return nil, err
}
return profiles, nil
err := r.db.WithContext(ctx).Where("name in (?)", names).
Preload("Skin").
Preload("Cape").
Find(&profiles).Error
return profiles, err
}
func GetProfileKeyPair(profileId string) (*model.KeyPair, error) {
db := database.MustGetDB()
// 1. 参数校验(保持原逻辑)
func (r *profileRepository) GetKeyPair(ctx context.Context, profileId string) (*model.KeyPair, error) {
if profileId == "" {
return nil, errors.New("参数不能为空")
}
// 2. GORM 查询:只查询 key_pair 字段(对应原 mongo 投影)
var profile *model.Profile
// 条件id = profileIdPostgreSQL 主键),只选择 key_pair 字段
result := db.WithContext(context.Background()).
Select("key_pair"). // 只查询需要的字段(投影)
Where("id = ?", profileId). // 查询条件GORM 自动处理占位符,避免 SQL 注入)
First(&profile) // 查单条记录
var profile model.Profile
result := r.db.WithContext(ctx).
Select("key_pair").
Where("id = ?", profileId).
First(&profile)
// 3. 错误处理(适配 GORM 错误类型)
if result.Error != nil {
// 空结果判断(对应原 mongo.ErrNoDocuments / pgx.ErrNoRows
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, errors.New("key pair未找到")
}
// 保持原错误封装格式
return nil, fmt.Errorf("获取key pair失败: %w", result.Error)
}
// 4. JSONB 反序列化为 model.KeyPair
keyPair := &model.KeyPair{}
return keyPair, nil
return &model.KeyPair{}, nil
}
func UpdateProfileKeyPair(profileId string, keyPair *model.KeyPair) error {
db := database.MustGetDB()
// 仅保留最必要的入参校验(避免无效数据库请求)
func (r *profileRepository) UpdateKeyPair(ctx context.Context, profileId string, keyPair *model.KeyPair) error {
if profileId == "" {
return errors.New("profileId 不能为空")
}
@@ -176,24 +153,17 @@ func UpdateProfileKeyPair(profileId string, keyPair *model.KeyPair) error {
return errors.New("keyPair 不能为 nil")
}
// 事务内执行核心更新(保证原子性,出错自动回滚)
return db.Transaction(func(tx *gorm.DB) error {
// 核心更新逻辑:按 profileId 匹配,直接更新 key_pair 相关字段
result := tx.WithContext(context.Background()).
Table("profiles"). // 目标表名(与 PostgreSQL 表一致)
Where("id = ?", profileId). // 更新条件profileId 匹配
// 直接映射字段(无需序列化,依赖 GORM 自动字段匹配)
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Table("profiles").
Where("id = ?", profileId).
UpdateColumns(map[string]interface{}{
"private_key": keyPair.PrivateKey, // 数据库 private_key 字段
"public_key": keyPair.PublicKey, // 数据库 public_key 字段
// 若 key_pair 是单个字段(非拆分),替换为:"key_pair": keyPair
"private_key": keyPair.PrivateKey,
"public_key": keyPair.PublicKey,
})
// 仅处理数据库层面的致命错误
if result.Error != nil {
return fmt.Errorf("更新 keyPair 失败: %w", result.Error)
}
return nil
})
}

View File

@@ -42,41 +42,6 @@ func TestProfileRepository_QueryConditions(t *testing.T) {
}
}
// TestProfileRepository_SetActiveLogic 测试设置活跃档案的逻辑
func TestProfileRepository_SetActiveLogic(t *testing.T) {
tests := []struct {
name string
uuid string
userID int64
otherProfiles int
wantAllInactive bool
}{
{
name: "设置一个档案为活跃,其他应该变为非活跃",
uuid: "profile-1",
userID: 1,
otherProfiles: 2,
wantAllInactive: true,
},
{
name: "只有一个档案时",
uuid: "profile-1",
userID: 1,
otherProfiles: 0,
wantAllInactive: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 验证逻辑:设置一个档案为活跃时,应该先将所有档案设为非活跃
if !tt.wantAllInactive {
t.Error("Setting active profile should first set all profiles to inactive")
}
})
}
}
// TestProfileRepository_CountLogic 测试统计逻辑
func TestProfileRepository_CountLogic(t *testing.T) {
tests := []struct {
@@ -109,30 +74,30 @@ func TestProfileRepository_CountLogic(t *testing.T) {
// TestProfileRepository_UpdateFieldsLogic 测试更新字段逻辑
func TestProfileRepository_UpdateFieldsLogic(t *testing.T) {
tests := []struct {
name string
uuid string
updates map[string]interface{}
name string
uuid string
updates map[string]interface{}
wantValid bool
}{
{
name: "有效的更新",
uuid: "123e4567-e89b-12d3-a456-426614174000",
updates: map[string]interface{}{
"name": "NewName",
"name": "NewName",
"skin_id": int64(1),
},
wantValid: true,
},
{
name: "UUID为空",
uuid: "",
updates: map[string]interface{}{"name": "NewName"},
name: "UUID为空",
uuid: "",
updates: map[string]interface{}{"name": "NewName"},
wantValid: false,
},
{
name: "更新字段为空",
uuid: "123e4567-e89b-12d3-a456-426614174000",
updates: map[string]interface{}{},
name: "更新字段为空",
uuid: "123e4567-e89b-12d3-a456-426614174000",
updates: map[string]interface{}{},
wantValid: true, // 空更新也是有效的,只是不会更新任何字段
},
}
@@ -150,24 +115,24 @@ func TestProfileRepository_UpdateFieldsLogic(t *testing.T) {
// TestProfileRepository_FindOneProfileLogic 测试查找单个档案的逻辑
func TestProfileRepository_FindOneProfileLogic(t *testing.T) {
tests := []struct {
name string
name string
profileCount int
wantError bool
wantError bool
}{
{
name: "有档案时返回第一个",
name: "有档案时返回第一个",
profileCount: 1,
wantError: false,
wantError: false,
},
{
name: "多个档案时返回第一个",
name: "多个档案时返回第一个",
profileCount: 3,
wantError: false,
wantError: false,
},
{
name: "没有档案时应该错误",
name: "没有档案时应该错误",
profileCount: 0,
wantError: true,
wantError: true,
},
}
@@ -181,4 +146,3 @@ func TestProfileRepository_FindOneProfileLogic(t *testing.T) {
})
}
}

View File

@@ -0,0 +1,225 @@
package repository
import (
"carrotskin/internal/model"
"context"
"errors"
"time"
"gorm.io/gorm"
)
// reportRepository 举报仓储实现
type reportRepository struct {
db *gorm.DB
}
// NewReportRepository 创建举报仓储实例
func NewReportRepository(db *gorm.DB) ReportRepository {
return &reportRepository{db: db}
}
// Create 创建举报记录
func (r *reportRepository) Create(ctx context.Context, report *model.Report) error {
return r.db.WithContext(ctx).Create(report).Error
}
// FindByID 根据ID查找举报记录
func (r *reportRepository) FindByID(ctx context.Context, id int64) (*model.Report, error) {
var report model.Report
err := r.db.WithContext(ctx).Preload("Reporter").Preload("Reviewer").First(&report, id).Error
if err != nil {
return nil, err
}
return &report, nil
}
// FindByReporterID 根据举报人ID查找举报记录
func (r *reportRepository) FindByReporterID(ctx context.Context, reporterID int64, page, pageSize int) ([]*model.Report, int64, error) {
var reports []*model.Report
var total int64
offset := (page - 1) * pageSize
// 查询总数
if err := r.db.WithContext(ctx).Model(&model.Report{}).Where("reporter_id = ?", reporterID).Count(&total).Error; err != nil {
return nil, 0, err
}
// 查询数据
err := r.db.WithContext(ctx).
Preload("Reporter").
Preload("Reviewer").
Where("reporter_id = ?", reporterID).
Order("created_at DESC").
Limit(pageSize).
Offset(offset).
Find(&reports).Error
return reports, total, err
}
// FindByTarget 根据目标对象查找举报记录
func (r *reportRepository) FindByTarget(ctx context.Context, targetType model.ReportType, targetID int64, page, pageSize int) ([]*model.Report, int64, error) {
var reports []*model.Report
var total int64
offset := (page - 1) * pageSize
// 查询总数
if err := r.db.WithContext(ctx).Model(&model.Report{}).Where("target_type = ? AND target_id = ?", targetType, targetID).Count(&total).Error; err != nil {
return nil, 0, err
}
// 查询数据
err := r.db.WithContext(ctx).
Preload("Reporter").
Preload("Reviewer").
Where("target_type = ? AND target_id = ?", targetType, targetID).
Order("created_at DESC").
Limit(pageSize).
Offset(offset).
Find(&reports).Error
return reports, total, err
}
// FindByStatus 根据状态查找举报记录
func (r *reportRepository) FindByStatus(ctx context.Context, status model.ReportStatus, page, pageSize int) ([]*model.Report, int64, error) {
var reports []*model.Report
var total int64
offset := (page - 1) * pageSize
// 查询总数
if err := r.db.WithContext(ctx).Model(&model.Report{}).Where("status = ?", status).Count(&total).Error; err != nil {
return nil, 0, err
}
// 查询数据
err := r.db.WithContext(ctx).
Preload("Reporter").
Preload("Reviewer").
Where("status = ?", status).
Order("created_at DESC").
Limit(pageSize).
Offset(offset).
Find(&reports).Error
return reports, total, err
}
// Search 搜索举报记录
func (r *reportRepository) Search(ctx context.Context, keyword string, page, pageSize int) ([]*model.Report, int64, error) {
var reports []*model.Report
var total int64
offset := (page - 1) * pageSize
query := r.db.WithContext(ctx).Model(&model.Report{}).Where("reason LIKE ?", "%"+keyword+"%")
// 查询总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 查询数据
err := query.
Preload("Reporter").
Preload("Reviewer").
Order("created_at DESC").
Limit(pageSize).
Offset(offset).
Find(&reports).Error
return reports, total, err
}
// Update 更新举报记录
func (r *reportRepository) Update(ctx context.Context, report *model.Report) error {
return r.db.WithContext(ctx).Save(report).Error
}
// UpdateFields 更新举报记录的指定字段
func (r *reportRepository) UpdateFields(ctx context.Context, id int64, fields map[string]interface{}) error {
return r.db.WithContext(ctx).Model(&model.Report{}).Where("id = ?", id).Updates(fields).Error
}
// Review 处理举报记录
func (r *reportRepository) Review(ctx context.Context, id int64, status model.ReportStatus, reviewerID int64, reviewNote string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var report model.Report
if err := tx.First(&report, id).Error; err != nil {
return err
}
// 检查状态是否已被处理
if report.Status != model.ReportStatusPending {
return errors.New("report has already been reviewed")
}
// 更新举报状态
now := time.Now()
updates := map[string]interface{}{
"status": status,
"reviewer_id": reviewerID,
"review_note": reviewNote,
"reviewed_at": &now,
}
return tx.Model(&report).Updates(updates).Error
})
}
// BatchReview 批量处理举报记录
func (r *reportRepository) BatchReview(ctx context.Context, ids []int64, status model.ReportStatus, reviewerID int64, reviewNote string) (int64, error) {
var affected int64
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
result := tx.Model(&model.Report{}).
Where("id IN ? AND status = ?", ids, model.ReportStatusPending).
Updates(map[string]interface{}{
"status": status,
"reviewer_id": reviewerID,
"review_note": reviewNote,
"reviewed_at": &now,
})
if result.Error != nil {
return result.Error
}
affected = result.RowsAffected
return nil
})
return affected, err
}
// Delete 删除举报记录
func (r *reportRepository) Delete(ctx context.Context, id int64) error {
return r.db.WithContext(ctx).Delete(&model.Report{}, id).Error
}
// BatchDelete 批量删除举报记录
func (r *reportRepository) BatchDelete(ctx context.Context, ids []int64) (int64, error) {
result := r.db.WithContext(ctx).Delete(&model.Report{}, ids)
return result.RowsAffected, result.Error
}
// CountByStatus 根据状态统计举报数量
func (r *reportRepository) CountByStatus(ctx context.Context, status model.ReportStatus) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.Report{}).Where("status = ?", status).Count(&count).Error
return count, err
}
// CheckDuplicate 检查是否重复举报
func (r *reportRepository) CheckDuplicate(ctx context.Context, reporterID int64, targetType model.ReportType, targetID int64) (bool, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.Report{}).
Where("reporter_id = ? AND target_type = ? AND target_id = ? AND status = ?",
reporterID, targetType, targetID, model.ReportStatusPending).
Count(&count).Error
return count > 0, err
}

View File

@@ -0,0 +1,249 @@
package repository
import (
"context"
"testing"
"carrotskin/internal/model"
"carrotskin/internal/testutil"
)
func TestUserRepository_BasicAndPoints(t *testing.T) {
db := testutil.NewTestDB(t)
repo := NewUserRepository(db)
ctx := context.Background()
user := &model.User{Username: "u1", Email: "e1@test.com", Password: "pwd", Status: 1}
if err := repo.Create(ctx, user); err != nil {
t.Fatalf("create user err: %v", err)
}
if u, err := repo.FindByID(ctx, user.ID); err != nil || u.Username != "u1" {
t.Fatalf("FindByID mismatch: %v %+v", err, u)
}
if u, err := repo.FindByUsername(ctx, "u1"); err != nil || u.Email != "e1@test.com" {
t.Fatalf("FindByUsername mismatch")
}
if u, err := repo.FindByEmail(ctx, "e1@test.com"); err != nil || u.ID != user.ID {
t.Fatalf("FindByEmail mismatch")
}
if err := repo.UpdateFields(ctx, user.ID, map[string]interface{}{"avatar": "a.png"}); err != nil {
t.Fatalf("UpdateFields err: %v", err)
}
if _, err := repo.BatchUpdate(ctx, []int64{user.ID}, map[string]interface{}{"status": 2}); err != nil {
t.Fatalf("BatchUpdate err: %v", err)
}
// 积分增加
if err := repo.UpdatePoints(ctx, user.ID, 10, "add", "bonus"); err != nil {
t.Fatalf("UpdatePoints add err: %v", err)
}
// 积分不足场景
if err := repo.UpdatePoints(ctx, user.ID, -100, "sub", "penalty"); err == nil {
t.Fatalf("expected insufficient points error")
}
if list, err := repo.FindByIDs(ctx, []int64{user.ID}); err != nil || len(list) != 1 {
t.Fatalf("FindByIDs mismatch: %v %d", err, len(list))
}
if list, err := repo.FindByIDs(ctx, []int64{}); err != nil || len(list) != 0 {
t.Fatalf("FindByIDs empty mismatch: %v %d", err, len(list))
}
// 软删除
if err := repo.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete err: %v", err)
}
deleted, _ := repo.FindByID(ctx, user.ID)
if deleted != nil {
t.Fatalf("expected deleted user filtered out")
}
// 批量操作边界
if _, err := repo.BatchUpdate(ctx, []int64{}, map[string]interface{}{"status": 1}); err != nil {
t.Fatalf("BatchUpdate empty should not error: %v", err)
}
if _, err := repo.BatchDelete(ctx, []int64{}); err != nil {
t.Fatalf("BatchDelete empty should not error: %v", err)
}
// 日志写入
_ = repo.CreateLoginLog(ctx, &model.UserLoginLog{UserID: user.ID, IPAddress: "127.0.0.1"})
_ = repo.CreatePointLog(ctx, &model.UserPointLog{UserID: user.ID, Amount: 1, ChangeType: "add"})
}
func TestProfileRepository_Basic(t *testing.T) {
db := testutil.NewTestDB(t)
userRepo := NewUserRepository(db)
profileRepo := NewProfileRepository(db)
ctx := context.Background()
u := &model.User{Username: "u2", Email: "u2@test.com", Password: "pwd", Status: 1}
_ = userRepo.Create(ctx, u)
p := &model.Profile{UUID: "p-uuid", UserID: u.ID, Name: "hero"}
if err := profileRepo.Create(ctx, p); err != nil {
t.Fatalf("create profile err: %v", err)
}
if got, err := profileRepo.FindByUUID(ctx, "p-uuid"); err != nil || got.Name != "hero" {
t.Fatalf("FindByUUID mismatch: %v %+v", err, got)
}
if list, err := profileRepo.FindByUserID(ctx, u.ID); err != nil || len(list) != 1 {
t.Fatalf("FindByUserID mismatch")
}
if count, err := profileRepo.CountByUserID(ctx, u.ID); err != nil || count != 1 {
t.Fatalf("CountByUserID mismatch: %d err=%v", count, err)
}
if err := profileRepo.UpdateLastUsedAt(ctx, "p-uuid"); err != nil {
t.Fatalf("UpdateLastUsedAt err: %v", err)
}
if got, err := profileRepo.FindByName(ctx, "hero"); err != nil || got == nil {
t.Fatalf("FindByName mismatch")
}
if list, err := profileRepo.FindByUUIDs(ctx, []string{"p-uuid"}); err != nil || len(list) != 1 {
t.Fatalf("FindByUUIDs mismatch")
}
if _, err := profileRepo.BatchUpdate(ctx, []string{"p-uuid"}, map[string]interface{}{"name": "hero2"}); err != nil {
t.Fatalf("BatchUpdate profile err: %v", err)
}
if err := profileRepo.Delete(ctx, "p-uuid"); err != nil {
t.Fatalf("Delete err: %v", err)
}
if _, err := profileRepo.BatchDelete(ctx, []string{}); err != nil {
t.Fatalf("BatchDelete empty err: %v", err)
}
}
func TestTextureRepository_Basic(t *testing.T) {
db := testutil.NewTestDB(t)
userRepo := NewUserRepository(db)
textureRepo := NewTextureRepository(db)
ctx := context.Background()
u := &model.User{Username: "u3", Email: "u3@test.com", Password: "pwd", Status: 1}
_ = userRepo.Create(ctx, u)
tex := &model.Texture{
UploaderID: u.ID,
Name: "tex",
Hash: "hash1",
URL: "url1",
Type: model.TextureTypeSkin,
IsPublic: true,
Status: 1,
}
if err := textureRepo.Create(ctx, tex); err != nil {
t.Fatalf("create texture err: %v", err)
}
if got, _ := textureRepo.FindByHash(ctx, "hash1"); got == nil || got.ID != tex.ID {
t.Fatalf("FindByHash mismatch")
}
if got, _ := textureRepo.FindByHashAndUploaderID(ctx, "hash1", u.ID); got == nil {
t.Fatalf("FindByHashAndUploaderID mismatch")
}
_, _ = textureRepo.ToggleFavorite(ctx, u.ID, tex.ID)
favList, _, _ := textureRepo.GetUserFavorites(ctx, u.ID, 1, 10)
if len(favList) == 0 {
t.Fatalf("GetUserFavorites expected at least 1 favorite")
}
_, _ = textureRepo.ToggleFavorite(ctx, u.ID, tex.ID)
favList, _, _ = textureRepo.GetUserFavorites(ctx, u.ID, 1, 10)
if len(favList) != 0 {
t.Fatalf("GetUserFavorites expected 0 favorites after toggle off")
}
_ = textureRepo.IncrementDownloadCount(ctx, tex.ID)
_ = textureRepo.CreateDownloadLog(ctx, &model.TextureDownloadLog{TextureID: tex.ID, UserID: &u.ID, IPAddress: "127.0.0.1"})
// 批量更新与删除
if affected, err := textureRepo.BatchUpdate(ctx, []int64{tex.ID}, map[string]interface{}{"name": "tex-new"}); err != nil || affected != 1 {
t.Fatalf("BatchUpdate mismatch, affected=%d err=%v", affected, err)
}
if affected, err := textureRepo.BatchDelete(ctx, []int64{tex.ID}); err != nil || affected != 1 {
t.Fatalf("BatchDelete mismatch, affected=%d err=%v", affected, err)
}
// 搜索与收藏列表
_ = textureRepo.Create(ctx, &model.Texture{
UploaderID: u.ID,
Name: "search-me",
Hash: "hash2",
URL: "url2",
Type: model.TextureTypeCape,
IsPublic: true,
Status: 1,
})
if list, total, err := textureRepo.Search(ctx, "search", model.TextureTypeCape, true, 1, 10); err != nil || total == 0 || len(list) == 0 {
t.Fatalf("Search mismatch, total=%d len=%d err=%v", total, len(list), err)
}
_, _ = textureRepo.ToggleFavorite(ctx, u.ID, tex.ID+1)
if favList, total, err := textureRepo.GetUserFavorites(ctx, u.ID, 1, 10); err != nil || total == 0 || len(favList) == 0 {
t.Fatalf("GetUserFavorites mismatch, total=%d len=%d err=%v", total, len(favList), err)
}
if _, total, err := textureRepo.Search(ctx, "", model.TextureTypeSkin, true, 1, 10); err != nil || total < 2 {
t.Fatalf("Search fallback mismatch")
}
// 列表与计数
if _, total, err := textureRepo.FindByUploaderID(ctx, u.ID, 1, 10); err != nil || total != 1 {
t.Fatalf("FindByUploaderID mismatch")
}
if cnt, err := textureRepo.CountByUploaderID(ctx, u.ID); err != nil || cnt != 1 {
t.Fatalf("CountByUploaderID mismatch")
}
_ = textureRepo.Delete(ctx, tex.ID)
}
func TestClientRepository_Basic(t *testing.T) {
db := testutil.NewTestDB(t)
repo := NewClientRepository(db)
ctx := context.Background()
client := &model.Client{UUID: "c-uuid", ClientToken: "ct-1", UserID: 9, Version: 1}
if err := repo.Create(ctx, client); err != nil {
t.Fatalf("Create client err: %v", err)
}
if got, _ := repo.FindByClientToken(ctx, "ct-1"); got == nil || got.UUID != "c-uuid" {
t.Fatalf("FindByClientToken mismatch")
}
if got, _ := repo.FindByUUID(ctx, "c-uuid"); got == nil || got.ClientToken != "ct-1" {
t.Fatalf("FindByUUID mismatch")
}
if list, _ := repo.FindByUserID(ctx, 9); len(list) != 1 {
t.Fatalf("FindByUserID mismatch")
}
_ = repo.IncrementVersion(ctx, "c-uuid")
updated, _ := repo.FindByUUID(ctx, "c-uuid")
if updated.Version != 2 {
t.Fatalf("IncrementVersion not applied, got %d", updated.Version)
}
_ = repo.DeleteByClientToken(ctx, "ct-1")
_ = repo.DeleteByUserID(ctx, 9)
}
func TestYggdrasilRepository_Basic(t *testing.T) {
db := testutil.NewTestDB(t)
userRepo := NewUserRepository(db)
yggRepo := NewYggdrasilRepository(db)
ctx := context.Background()
user := &model.User{Username: "u-ygg", Email: "ygg@test.com", Password: "pwd", Status: 1}
_ = userRepo.Create(ctx, user) // AfterCreate 会生成 yggdrasil 记录
pwd, err := yggRepo.GetPasswordByID(ctx, user.ID)
if err != nil || pwd == "" {
t.Fatalf("GetPasswordByID err=%v pwd=%s", err, pwd)
}
if err := yggRepo.ResetPassword(ctx, user.ID, "newpwd"); err != nil {
t.Fatalf("ResetPassword err: %v", err)
}
}

View File

@@ -1,57 +0,0 @@
package repository
import (
"carrotskin/internal/model"
"carrotskin/pkg/database"
"errors"
"gorm.io/gorm"
)
// GetSystemConfigByKey 根据键获取配置
func GetSystemConfigByKey(key string) (*model.SystemConfig, error) {
db := database.MustGetDB()
var config model.SystemConfig
err := db.Where("key = ?", key).First(&config).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &config, nil
}
// GetPublicSystemConfigs 获取所有公开配置
func GetPublicSystemConfigs() ([]model.SystemConfig, error) {
db := database.MustGetDB()
var configs []model.SystemConfig
err := db.Where("is_public = ?", true).Find(&configs).Error
if err != nil {
return nil, err
}
return configs, nil
}
// GetAllSystemConfigs 获取所有配置(管理员用)
func GetAllSystemConfigs() ([]model.SystemConfig, error) {
db := database.MustGetDB()
var configs []model.SystemConfig
err := db.Find(&configs).Error
if err != nil {
return nil, err
}
return configs, nil
}
// UpdateSystemConfig 更新配置
func UpdateSystemConfig(config *model.SystemConfig) error {
db := database.MustGetDB()
return db.Save(config).Error
}
// UpdateSystemConfigValue 更新配置值
func UpdateSystemConfigValue(key, value string) error {
db := database.MustGetDB()
return db.Model(&model.SystemConfig{}).Where("key = ?", key).Update("value", value).Error
}

View File

@@ -1,146 +0,0 @@
package repository
import (
"testing"
)
// TestSystemConfigRepository_QueryConditions 测试系统配置查询条件逻辑
func TestSystemConfigRepository_QueryConditions(t *testing.T) {
tests := []struct {
name string
key string
isPublic bool
wantValid bool
}{
{
name: "有效的配置键",
key: "site_name",
isPublic: true,
wantValid: true,
},
{
name: "配置键为空",
key: "",
isPublic: true,
wantValid: false,
},
{
name: "公开配置查询",
key: "site_name",
isPublic: true,
wantValid: true,
},
{
name: "私有配置查询",
key: "secret_key",
isPublic: false,
wantValid: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.key != ""
if isValid != tt.wantValid {
t.Errorf("Query condition validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestSystemConfigRepository_PublicConfigLogic 测试公开配置逻辑
func TestSystemConfigRepository_PublicConfigLogic(t *testing.T) {
tests := []struct {
name string
isPublic bool
wantInclude bool
}{
{
name: "只获取公开配置",
isPublic: true,
wantInclude: true,
},
{
name: "私有配置不应包含",
isPublic: false,
wantInclude: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 验证逻辑GetPublicSystemConfigs应该只返回is_public=true的配置
if tt.isPublic != tt.wantInclude {
t.Errorf("Public config logic failed: isPublic=%v, wantInclude=%v", tt.isPublic, tt.wantInclude)
}
})
}
}
// TestSystemConfigRepository_UpdateValueLogic 测试更新配置值逻辑
func TestSystemConfigRepository_UpdateValueLogic(t *testing.T) {
tests := []struct {
name string
key string
value string
wantValid bool
}{
{
name: "有效的键值对",
key: "site_name",
value: "CarrotSkin",
wantValid: true,
},
{
name: "键为空",
key: "",
value: "CarrotSkin",
wantValid: false,
},
{
name: "值为空(可能有效)",
key: "site_name",
value: "",
wantValid: true, // 空值也可能是有效的
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.key != ""
if isValid != tt.wantValid {
t.Errorf("Update value validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestSystemConfigRepository_ErrorHandling 测试错误处理逻辑
func TestSystemConfigRepository_ErrorHandling(t *testing.T) {
tests := []struct {
name string
isNotFound bool
wantNilConfig bool
}{
{
name: "记录未找到应该返回nil配置",
isNotFound: true,
wantNilConfig: true,
},
{
name: "找到记录应该返回配置",
isNotFound: false,
wantNilConfig: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 验证错误处理逻辑如果是RecordNotFound返回nil配置
if tt.isNotFound != tt.wantNilConfig {
t.Errorf("Error handling logic failed: isNotFound=%v, wantNilConfig=%v", tt.isNotFound, tt.wantNilConfig)
}
})
}
}

View File

@@ -2,63 +2,68 @@ package repository
import (
"carrotskin/internal/model"
"carrotskin/pkg/database"
"context"
"gorm.io/gorm"
)
// CreateTexture 创建材质
func CreateTexture(texture *model.Texture) error {
db := database.MustGetDB()
return db.Create(texture).Error
// textureRepository TextureRepository的实现
type textureRepository struct {
db *gorm.DB
}
// FindTextureByID 根据ID查找材质
func FindTextureByID(id int64) (*model.Texture, error) {
db := database.MustGetDB()
// NewTextureRepository 创建TextureRepository实例
func NewTextureRepository(db *gorm.DB) TextureRepository {
return &textureRepository{db: db}
}
func (r *textureRepository) Create(ctx context.Context, texture *model.Texture) error {
return r.db.WithContext(ctx).Create(texture).Error
}
func (r *textureRepository) FindByID(ctx context.Context, id int64) (*model.Texture, error) {
var texture model.Texture
err := db.Preload("Uploader").First(&texture, id).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &texture, nil
err := r.db.WithContext(ctx).Preload("Uploader").First(&texture, id).Error
return handleNotFoundResult(&texture, err)
}
// FindTextureByHash 根据Hash查找材质
func FindTextureByHash(hash string) (*model.Texture, error) {
db := database.MustGetDB()
func (r *textureRepository) FindByHash(ctx context.Context, hash string) (*model.Texture, error) {
var texture model.Texture
err := db.Where("hash = ?", hash).First(&texture).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &texture, nil
err := r.db.WithContext(ctx).Preload("Uploader").Where("hash = ?", hash).First(&texture).Error
return handleNotFoundResult(&texture, err)
}
// FindTexturesByUploaderID 根据上传者ID查找材质列表
func FindTexturesByUploaderID(uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) {
db := database.MustGetDB()
func (r *textureRepository) FindByHashAndUploaderID(ctx context.Context, hash string, uploaderID int64) (*model.Texture, error) {
var texture model.Texture
err := r.db.WithContext(ctx).Preload("Uploader").Where("hash = ? AND uploader_id = ?", hash, uploaderID).First(&texture).Error
return handleNotFoundResult(&texture, err)
}
func (r *textureRepository) FindByIDs(ctx context.Context, ids []int64) ([]*model.Texture, error) {
if len(ids) == 0 {
return []*model.Texture{}, nil
}
var textures []*model.Texture
// 使用 IN 查询优化批量查询,并预加载关联
err := r.db.WithContext(ctx).Where("id IN ?", ids).
Preload("Uploader").
Find(&textures).Error
return textures, err
}
func (r *textureRepository) FindByUploaderID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) {
var textures []*model.Texture
var total int64
query := db.Model(&model.Texture{}).Where("uploader_id = ? AND status != -1", uploaderID)
query := r.db.WithContext(ctx).Model(&model.Texture{}).Where("uploader_id = ? AND status != -1", uploaderID)
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页查询
offset := (page - 1) * pageSize
err := query.Preload("Uploader").
err := query.Scopes(Paginate(page, pageSize)).
Preload("Uploader").
Order("created_at DESC").
Offset(offset).
Limit(pageSize).
Find(&textures).Error
if err != nil {
@@ -68,40 +73,29 @@ func FindTexturesByUploaderID(uploaderID int64, page, pageSize int) ([]*model.Te
return textures, total, nil
}
// SearchTextures 搜索材质
func SearchTextures(keyword string, textureType model.TextureType, publicOnly bool, page, pageSize int) ([]*model.Texture, int64, error) {
db := database.MustGetDB()
func (r *textureRepository) Search(ctx context.Context, keyword string, textureType model.TextureType, publicOnly bool, page, pageSize int) ([]*model.Texture, int64, error) {
var textures []*model.Texture
var total int64
query := db.Model(&model.Texture{}).Where("status = 1")
query := r.db.WithContext(ctx).Model(&model.Texture{}).Where("status = 1")
// 公开筛选
if publicOnly {
query = query.Where("is_public = ?", true)
}
// 类型筛选
if textureType != "" {
query = query.Where("type = ?", textureType)
}
// 关键词搜索
if keyword != "" {
query = query.Where("name LIKE ? OR description LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页查询
offset := (page - 1) * pageSize
err := query.Preload("Uploader").
err := query.Scopes(Paginate(page, pageSize)).
Preload("Uploader").
Order("created_at DESC").
Offset(offset).
Limit(pageSize).
Find(&textures).Error
if err != nil {
@@ -111,106 +105,105 @@ func SearchTextures(keyword string, textureType model.TextureType, publicOnly bo
return textures, total, nil
}
// UpdateTexture 更新材质
func UpdateTexture(texture *model.Texture) error {
db := database.MustGetDB()
return db.Save(texture).Error
func (r *textureRepository) Update(ctx context.Context, texture *model.Texture) error {
return r.db.WithContext(ctx).Save(texture).Error
}
// UpdateTextureFields 更新材质指定字段
func UpdateTextureFields(id int64, fields map[string]interface{}) error {
db := database.MustGetDB()
return db.Model(&model.Texture{}).Where("id = ?", id).Updates(fields).Error
func (r *textureRepository) UpdateFields(ctx context.Context, id int64, fields map[string]interface{}) error {
return r.db.WithContext(ctx).Model(&model.Texture{}).Where("id = ?", id).Updates(fields).Error
}
// DeleteTexture 删除材质(软删除)
func DeleteTexture(id int64) error {
db := database.MustGetDB()
return db.Model(&model.Texture{}).Where("id = ?", id).Update("status", -1).Error
func (r *textureRepository) Delete(ctx context.Context, id int64) error {
return r.db.WithContext(ctx).Model(&model.Texture{}).Where("id = ?", id).Update("status", -1).Error
}
// IncrementTextureDownloadCount 增加下载次数
func IncrementTextureDownloadCount(id int64) error {
db := database.MustGetDB()
return db.Model(&model.Texture{}).Where("id = ?", id).
func (r *textureRepository) BatchUpdate(ctx context.Context, ids []int64, fields map[string]interface{}) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
result := r.db.WithContext(ctx).Model(&model.Texture{}).Where("id IN ?", ids).Updates(fields)
return result.RowsAffected, result.Error
}
func (r *textureRepository) BatchDelete(ctx context.Context, ids []int64) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
result := r.db.WithContext(ctx).Model(&model.Texture{}).Where("id IN ?", ids).Update("status", -1)
return result.RowsAffected, result.Error
}
func (r *textureRepository) IncrementDownloadCount(ctx context.Context, id int64) error {
return r.db.WithContext(ctx).Model(&model.Texture{}).Where("id = ?", id).
UpdateColumn("download_count", gorm.Expr("download_count + ?", 1)).Error
}
// IncrementTextureFavoriteCount 增加收藏次数
func IncrementTextureFavoriteCount(id int64) error {
db := database.MustGetDB()
return db.Model(&model.Texture{}).Where("id = ?", id).
UpdateColumn("favorite_count", gorm.Expr("favorite_count + ?", 1)).Error
func (r *textureRepository) CreateDownloadLog(ctx context.Context, log *model.TextureDownloadLog) error {
return r.db.WithContext(ctx).Create(log).Error
}
// DecrementTextureFavoriteCount 减少收藏次数
func DecrementTextureFavoriteCount(id int64) error {
db := database.MustGetDB()
return db.Model(&model.Texture{}).Where("id = ?", id).
UpdateColumn("favorite_count", gorm.Expr("favorite_count - ?", 1)).Error
func (r *textureRepository) ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, error) {
var isAdded bool
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var count int64
err := tx.Model(&model.UserTextureFavorite{}).
Where("user_id = ? AND texture_id = ?", userID, textureID).
Count(&count).Error
if err != nil {
return err
}
if count > 0 {
result := tx.Where("user_id = ? AND texture_id = ?", userID, textureID).
Delete(&model.UserTextureFavorite{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
if err := tx.Model(&model.Texture{}).Where("id = ?", textureID).
UpdateColumn("favorite_count", gorm.Expr("GREATEST(favorite_count - 1, 0)")).Error; err != nil {
return err
}
}
isAdded = false
return nil
}
favorite := &model.UserTextureFavorite{
UserID: userID,
TextureID: textureID,
}
if err := tx.Create(favorite).Error; err != nil {
return err
}
if err := tx.Model(&model.Texture{}).Where("id = ?", textureID).
UpdateColumn("favorite_count", gorm.Expr("favorite_count + 1")).Error; err != nil {
return err
}
isAdded = true
return nil
})
return isAdded, err
}
// CreateTextureDownloadLog 创建下载日志
func CreateTextureDownloadLog(log *model.TextureDownloadLog) error {
db := database.MustGetDB()
return db.Create(log).Error
}
// IsTextureFavorited 检查是否已收藏
func IsTextureFavorited(userID, textureID int64) (bool, error) {
db := database.MustGetDB()
var count int64
err := db.Model(&model.UserTextureFavorite{}).
Where("user_id = ? AND texture_id = ?", userID, textureID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
// AddTextureFavorite 添加收藏
func AddTextureFavorite(userID, textureID int64) error {
db := database.MustGetDB()
favorite := &model.UserTextureFavorite{
UserID: userID,
TextureID: textureID,
}
return db.Create(favorite).Error
}
// RemoveTextureFavorite 取消收藏
func RemoveTextureFavorite(userID, textureID int64) error {
db := database.MustGetDB()
return db.Where("user_id = ? AND texture_id = ?", userID, textureID).
Delete(&model.UserTextureFavorite{}).Error
}
// GetUserTextureFavorites 获取用户收藏的材质列表
func GetUserTextureFavorites(userID int64, page, pageSize int) ([]*model.Texture, int64, error) {
db := database.MustGetDB()
func (r *textureRepository) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {
var textures []*model.Texture
var total int64
// 子查询获取收藏的材质ID
subQuery := db.Model(&model.UserTextureFavorite{}).
subQuery := r.db.WithContext(ctx).Model(&model.UserTextureFavorite{}).
Select("texture_id").
Where("user_id = ?", userID)
query := db.Model(&model.Texture{}).
query := r.db.WithContext(ctx).Model(&model.Texture{}).
Where("id IN (?) AND status = 1", subQuery)
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页查询
offset := (page - 1) * pageSize
err := query.Preload("Uploader").
err := query.Scopes(Paginate(page, pageSize)).
Preload("Uploader").
Order("created_at DESC").
Offset(offset).
Limit(pageSize).
Find(&textures).Error
if err != nil {
@@ -220,11 +213,9 @@ func GetUserTextureFavorites(userID int64, page, pageSize int) ([]*model.Texture
return textures, total, nil
}
// CountTexturesByUploaderID 统计用户上传的材质数量
func CountTexturesByUploaderID(uploaderID int64) (int64, error) {
db := database.MustGetDB()
func (r *textureRepository) CountByUploaderID(ctx context.Context, uploaderID int64) (int64, error) {
var count int64
err := db.Model(&model.Texture{}).
err := r.db.WithContext(ctx).Model(&model.Texture{}).
Where("uploader_id = ? AND status != -1", uploaderID).
Count(&count).Error
return count, err

View File

@@ -1,89 +0,0 @@
package repository
import (
"carrotskin/internal/model"
"carrotskin/pkg/database"
)
func CreateToken(token *model.Token) error {
db := database.MustGetDB()
return db.Create(token).Error
}
func GetTokensByUserId(userId int64) ([]*model.Token, error) {
db := database.MustGetDB()
tokens := make([]*model.Token, 0)
err := db.Where("user_id = ?", userId).Find(&tokens).Error
if err != nil {
return nil, err
}
return tokens, nil
}
func BatchDeleteTokens(tokensToDelete []string) (int64, error) {
db := database.MustGetDB()
if len(tokensToDelete) == 0 {
return 0, nil // 无需要删除的令牌,直接返回
}
result := db.Where("access_token IN ?", tokensToDelete).Delete(&model.Token{})
return result.RowsAffected, result.Error
}
func FindTokenByID(accessToken string) (*model.Token, error) {
db := database.MustGetDB()
var tokens []*model.Token
err := db.Where("_id = ?", accessToken).Find(&tokens).Error
if err != nil {
return nil, err
}
return tokens[0], nil
}
func GetUUIDByAccessToken(accessToken string) (string, error) {
db := database.MustGetDB()
var token model.Token
err := db.Where("access_token = ?", accessToken).First(&token).Error
if err != nil {
return "", err
}
return token.ProfileId, nil
}
func GetUserIDByAccessToken(accessToken string) (int64, error) {
db := database.MustGetDB()
var token model.Token
err := db.Where("access_token = ?", accessToken).First(&token).Error
if err != nil {
return 0, err
}
return token.UserID, nil
}
func GetTokenByAccessToken(accessToken string) (*model.Token, error) {
db := database.MustGetDB()
var token model.Token
err := db.Where("access_token = ?", accessToken).First(&token).Error
if err != nil {
return nil, err
}
return &token, nil
}
func DeleteTokenByAccessToken(accessToken string) error {
db := database.MustGetDB()
err := db.Where("access_token = ?", accessToken).Delete(&model.Token{}).Error
if err != nil {
return err
}
return nil
}
func DeleteTokenByUserId(userId int64) error {
db := database.MustGetDB()
err := db.Where("user_id = ?", userId).Delete(&model.Token{}).Error
if err != nil {
return err
}
return nil
}

View File

@@ -1,123 +0,0 @@
package repository
import (
"testing"
)
// TestTokenRepository_BatchDeleteLogic 测试批量删除逻辑
func TestTokenRepository_BatchDeleteLogic(t *testing.T) {
tests := []struct {
name string
tokensToDelete []string
wantCount int64
wantError bool
}{
{
name: "有效的token列表",
tokensToDelete: []string{"token1", "token2", "token3"},
wantCount: 3,
wantError: false,
},
{
name: "空列表应该返回0",
tokensToDelete: []string{},
wantCount: 0,
wantError: false,
},
{
name: "单个token",
tokensToDelete: []string{"token1"},
wantCount: 1,
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 验证批量删除逻辑空列表应该直接返回0
if len(tt.tokensToDelete) == 0 {
if tt.wantCount != 0 {
t.Errorf("Empty list should return count 0, got %d", tt.wantCount)
}
}
})
}
}
// TestTokenRepository_QueryConditions 测试token查询条件逻辑
func TestTokenRepository_QueryConditions(t *testing.T) {
tests := []struct {
name string
accessToken string
userID int64
wantValid bool
}{
{
name: "有效的access token",
accessToken: "valid-token-123",
userID: 1,
wantValid: true,
},
{
name: "access token为空",
accessToken: "",
userID: 1,
wantValid: false,
},
{
name: "用户ID为0",
accessToken: "valid-token-123",
userID: 0,
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.accessToken != "" && tt.userID > 0
if isValid != tt.wantValid {
t.Errorf("Query condition validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestTokenRepository_FindTokenByIDLogic 测试根据ID查找token的逻辑
func TestTokenRepository_FindTokenByIDLogic(t *testing.T) {
tests := []struct {
name string
accessToken string
resultCount int
wantError bool
}{
{
name: "找到token",
accessToken: "token-123",
resultCount: 1,
wantError: false,
},
{
name: "未找到token",
accessToken: "token-123",
resultCount: 0,
wantError: true, // 访问索引0会panic
},
{
name: "找到多个token异常情况",
accessToken: "token-123",
resultCount: 2,
wantError: false, // 返回第一个
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 验证逻辑如果结果为空访问索引0会出错
hasError := tt.resultCount == 0
if hasError != tt.wantError {
t.Errorf("FindTokenByID logic failed: got error=%v, want error=%v", hasError, tt.wantError)
}
})
}
}

View File

@@ -2,95 +2,92 @@ package repository
import (
"carrotskin/internal/model"
"carrotskin/pkg/database"
"context"
"errors"
"gorm.io/gorm"
)
// CreateUser 创建用户
func CreateUser(user *model.User) error {
db := database.MustGetDB()
return db.Create(user).Error
// userRepository UserRepository的实现
type userRepository struct {
db *gorm.DB
}
// FindUserByID 根据ID查找用户
func FindUserByID(id int64) (*model.User, error) {
db := database.MustGetDB()
// NewUserRepository 创建UserRepository实例
func NewUserRepository(db *gorm.DB) UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) Create(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Create(user).Error
}
func (r *userRepository) FindByID(ctx context.Context, id int64) (*model.User, error) {
var user model.User
err := db.Where("id = ? AND status != -1", id).First(&user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
err := r.db.WithContext(ctx).Where("id = ? AND status != -1", id).First(&user).Error
return handleNotFoundResult(&user, err)
}
// FindUserByUsername 根据用户名查找用户
func FindUserByUsername(username string) (*model.User, error) {
db := database.MustGetDB()
func (r *userRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) {
var user model.User
err := db.Where("username = ? AND status != -1", username).First(&user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &user, nil
err := r.db.WithContext(ctx).Where("username = ? AND status != -1", username).First(&user).Error
return handleNotFoundResult(&user, err)
}
// FindUserByEmail 根据邮箱查找用户
func FindUserByEmail(email string) (*model.User, error) {
db := database.MustGetDB()
func (r *userRepository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
var user model.User
err := db.Where("email = ? AND status != -1", email).First(&user).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
err := r.db.WithContext(ctx).Where("email = ? AND status != -1", email).First(&user).Error
return handleNotFoundResult(&user, err)
}
func (r *userRepository) FindByIDs(ctx context.Context, ids []int64) ([]*model.User, error) {
if len(ids) == 0 {
return []*model.User{}, nil
}
return &user, nil
var users []*model.User
// 使用 IN 查询优化批量查询
err := r.db.WithContext(ctx).Where("id IN ? AND status != -1", ids).Find(&users).Error
return users, err
}
// UpdateUser 更新用户
func UpdateUser(user *model.User) error {
db := database.MustGetDB()
return db.Save(user).Error
func (r *userRepository) Update(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Save(user).Error
}
// UpdateUserFields 更新指定字段
func UpdateUserFields(id int64, fields map[string]interface{}) error {
db := database.MustGetDB()
return db.Model(&model.User{}).Where("id = ?", id).Updates(fields).Error
func (r *userRepository) UpdateFields(ctx context.Context, id int64, fields map[string]interface{}) error {
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(fields).Error
}
// DeleteUser 软删除用户
func DeleteUser(id int64) error {
db := database.MustGetDB()
return db.Model(&model.User{}).Where("id = ?", id).Update("status", -1).Error
func (r *userRepository) Delete(ctx context.Context, id int64) error {
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("status", -1).Error
}
// CreateLoginLog 创建登录日志
func CreateLoginLog(log *model.UserLoginLog) error {
db := database.MustGetDB()
return db.Create(log).Error
func (r *userRepository) BatchUpdate(ctx context.Context, ids []int64, fields map[string]interface{}) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
result := r.db.WithContext(ctx).Model(&model.User{}).Where("id IN ?", ids).Updates(fields)
return result.RowsAffected, result.Error
}
// CreatePointLog 创建积分日志
func CreatePointLog(log *model.UserPointLog) error {
db := database.MustGetDB()
return db.Create(log).Error
func (r *userRepository) BatchDelete(ctx context.Context, ids []int64) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
result := r.db.WithContext(ctx).Model(&model.User{}).Where("id IN ?", ids).Update("status", -1)
return result.RowsAffected, result.Error
}
// UpdateUserPoints 更新用户积分(事务)
func UpdateUserPoints(userID int64, amount int, changeType, reason string) error {
db := database.MustGetDB()
return db.Transaction(func(tx *gorm.DB) error {
// 获取当前用户积分
func (r *userRepository) CreateLoginLog(ctx context.Context, log *model.UserLoginLog) error {
return r.db.WithContext(ctx).Create(log).Error
}
func (r *userRepository) CreatePointLog(ctx context.Context, log *model.UserPointLog) error {
return r.db.WithContext(ctx).Create(log).Error
}
func (r *userRepository) UpdatePoints(ctx context.Context, userID int64, amount int, changeType, reason string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Where("id = ?", userID).First(&user).Error; err != nil {
return err
@@ -99,17 +96,14 @@ func UpdateUserPoints(userID int64, amount int, changeType, reason string) error
balanceBefore := user.Points
balanceAfter := balanceBefore + amount
// 检查积分是否足够
if balanceAfter < 0 {
return errors.New("积分不足")
}
// 更新用户积分
if err := tx.Model(&user).Update("points", balanceAfter).Error; err != nil {
return err
}
// 创建积分日志
log := &model.UserPointLog{
UserID: userID,
ChangeType: changeType,
@@ -123,14 +117,13 @@ func UpdateUserPoints(userID int64, amount int, changeType, reason string) error
})
}
// UpdateUserAvatar 更新用户头像
func UpdateUserAvatar(userID int64, avatarURL string) error {
db := database.MustGetDB()
return db.Model(&model.User{}).Where("id = ?", userID).Update("avatar", avatarURL).Error
}
// UpdateUserEmail 更新用户邮箱
func UpdateUserEmail(userID int64, email string) error {
db := database.MustGetDB()
return db.Model(&model.User{}).Where("id = ?", userID).Update("email", email).Error
// handleNotFoundResult 处理记录未找到的情况
func handleNotFoundResult[T any](result *T, err error) (*T, error) {
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return result, nil
}

View File

@@ -2,15 +2,36 @@ package repository
import (
"carrotskin/internal/model"
"carrotskin/pkg/database"
"context"
"gorm.io/gorm"
)
func GetYggdrasilPasswordById(Id int64) (string, error) {
db := database.MustGetDB()
// yggdrasilRepository YggdrasilRepository的实现
type yggdrasilRepository struct {
db *gorm.DB
}
// NewYggdrasilRepository 创建YggdrasilRepository实例
func NewYggdrasilRepository(db *gorm.DB) YggdrasilRepository {
return &yggdrasilRepository{db: db}
}
func (r *yggdrasilRepository) GetPasswordByID(ctx context.Context, id int64) (string, error) {
var yggdrasil model.Yggdrasil
err := db.Where("id = ?", Id).First(&yggdrasil).Error
err := r.db.WithContext(ctx).Select("password").Where("id = ?", id).First(&yggdrasil).Error
if err != nil {
return "", err
}
return yggdrasil.Password, nil
}
func (r *yggdrasilRepository) ResetPassword(ctx context.Context, id int64, password string) error {
return r.db.WithContext(ctx).Model(&model.Yggdrasil{}).Where("id = ?", id).Update("password", password).Error
}

View File

@@ -13,11 +13,11 @@ import (
"github.com/wenlng/go-captcha-assets/resources/imagesv2"
"github.com/wenlng/go-captcha-assets/resources/tiles"
"github.com/wenlng/go-captcha/v2/slide"
"go.uber.org/zap"
)
var (
slideTileCapt slide.Captcha
cfg *config.Config
)
// 常量定义业务相关配置与Redis连接配置分离
@@ -28,8 +28,6 @@ const (
// Init 验证码图初始化
func init() {
cfg, _ = config.Load()
// 从默认仓库中获取主图
builder := slide.NewBuilder()
bgImage, err := imagesv2.GetImages()
if err != nil {
@@ -72,48 +70,71 @@ type RedisData struct {
Ty int `json:"ty"` // 滑块目标Y坐标
}
// GenerateCaptchaData 提取生成验证码的相关信息
func GenerateCaptchaData(ctx context.Context, redisClient *redis.Client) (string, string, string, int, error) {
// captchaService CaptchaService的实现
type captchaService struct {
redis *redis.Client
logger *zap.Logger
}
// NewCaptchaService 创建CaptchaService实例
func NewCaptchaService(redisClient *redis.Client, logger *zap.Logger) CaptchaService {
return &captchaService{
redis: redisClient,
logger: logger,
}
}
// Generate 生成验证码
func (s *captchaService) Generate(ctx context.Context) (masterImg, tileImg, captchaID string, y int, err error) {
// 生成uuid作为验证码进程唯一标识
captchaID := uuid.NewString()
captchaID = uuid.NewString()
if captchaID == "" {
return "", "", "", 0, errors.New("生成验证码唯一标识失败")
err = errors.New("生成验证码唯一标识失败")
return
}
captData, err := slideTileCapt.Generate()
if err != nil {
return "", "", "", 0, fmt.Errorf("生成验证码失败: %w", err)
err = fmt.Errorf("生成验证码失败: %w", err)
return
}
blockData := captData.GetData()
if blockData == nil {
return "", "", "", 0, errors.New("获取验证码数据失败")
err = errors.New("获取验证码数据失败")
return
}
block, _ := json.Marshal(blockData)
var blockMap map[string]interface{}
if err := json.Unmarshal(block, &blockMap); err != nil {
return "", "", "", 0, fmt.Errorf("反序列化为map失败: %w", err)
if err = json.Unmarshal(block, &blockMap); err != nil {
err = fmt.Errorf("反序列化为map失败: %w", err)
return
}
// 提取x和y并转换为int类型
tx, ok := blockMap["x"].(float64)
if !ok {
return "", "", "", 0, errors.New("无法将x转换为float64")
err = errors.New("无法将x转换为float64")
return
}
var x = int(tx)
ty, ok := blockMap["y"].(float64)
if !ok {
return "", "", "", 0, errors.New("无法将y转换为float64")
err = errors.New("无法将y转换为float64")
return
}
var y = int(ty)
var mBase64, tBase64 string
mBase64, err = captData.GetMasterImage().ToBase64()
y = int(ty)
masterImg, err = captData.GetMasterImage().ToBase64()
if err != nil {
return "", "", "", 0, fmt.Errorf("主图转换为base64失败: %w", err)
err = fmt.Errorf("主图转换为base64失败: %w", err)
return
}
tBase64, err = captData.GetTileImage().ToBase64()
tileImg, err = captData.GetTileImage().ToBase64()
if err != nil {
return "", "", "", 0, fmt.Errorf("滑块图转换为base64失败: %w", err)
err = fmt.Errorf("滑块图转换为base64失败: %w", err)
return
}
redisData := RedisData{
Tx: x,
Ty: y,
@@ -123,32 +144,37 @@ func GenerateCaptchaData(ctx context.Context, redisClient *redis.Client) (string
expireTime := 300 * time.Second
// 使用注入的Redis客户端
if err := redisClient.Set(
ctx,
redisKey,
redisDataJSON,
expireTime,
); err != nil {
return "", "", "", 0, fmt.Errorf("存储验证码到Redis失败: %w", err)
if err = s.redis.Set(ctx, redisKey, redisDataJSON, expireTime); err != nil {
err = fmt.Errorf("存储验证码到redis失败: %w", err)
return
}
return mBase64, tBase64, captchaID, y - 10, nil
// 返回时 y 需要减10
y = y - 10
return
}
// VerifyCaptchaData 验证用户验证码
func VerifyCaptchaData(ctx context.Context, redisClient *redis.Client, dx int, id string) (bool, error) {
redisKey := redisKeyPrefix + id
// Verify 验证验证码
func (s *captchaService) Verify(ctx context.Context, dx int, captchaID string) (bool, error) {
// 测试环境下直接通过验证
cfg, err := config.GetConfig()
if err == nil && cfg.IsTestEnvironment() {
return true, nil
}
redisKey := redisKeyPrefix + captchaID
// 从Redis获取验证信息使用注入的客户端
dataJSON, err := redisClient.Get(ctx, redisKey)
dataJSON, err := s.redis.Get(ctx, redisKey)
if err != nil {
if redisClient.Nil(err) { // 使用封装客户端的Nil错误
if s.redis.Nil(err) { // 使用封装客户端的Nil错误
return false, errors.New("验证码已过期或无效")
}
return false, fmt.Errorf("Redis查询失败: %w", err)
return false, fmt.Errorf("redis查询失败: %w", err)
}
var redisData RedisData
if err := json.Unmarshal([]byte(dataJSON), &redisData); err != nil {
return false, fmt.Errorf("解析Redis数据失败: %w", err)
return false, fmt.Errorf("解析redis数据失败: %w", err)
}
tx := redisData.Tx
ty := redisData.Ty
@@ -156,9 +182,9 @@ func VerifyCaptchaData(ctx context.Context, redisClient *redis.Client, dx int, i
// 验证后立即删除Redis记录防止重复使用
if ok {
if err := redisClient.Del(ctx, redisKey); err != nil {
if err := s.redis.Del(ctx, redisKey); err != nil {
// 记录警告但不影响验证结果
log.Printf("删除验证码Redis记录失败: %v", err)
s.logger.Warn("删除验证码Redis记录失败", zap.Error(err))
}
}
return ok, nil

View File

@@ -0,0 +1,37 @@
package service
import (
"errors"
"fmt"
)
// 通用错误
var (
ErrProfileNotFound = errors.New("档案不存在")
ErrProfileNoPermission = errors.New("无权操作此档案")
ErrTextureNotFound = errors.New("材质不存在")
ErrTextureNoPermission = errors.New("无权操作此材质")
ErrUserNotFound = errors.New("用户不存在")
)
// NormalizePagination 规范化分页参数
func NormalizePagination(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize
}
// WrapError 包装错误,添加上下文信息
func WrapError(err error, message string) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", message, err)
}

View File

@@ -0,0 +1,50 @@
package service
import (
"errors"
"testing"
)
// TestNormalizePagination_Basic 覆盖 NormalizePagination 的边界分支
func TestNormalizePagination_Basic(t *testing.T) {
tests := []struct {
name string
page int
size int
wantPage int
wantPageSize int
}{
{"page 小于 1", 0, 10, 1, 10},
{"pageSize 小于 1", 1, 0, 1, 20},
{"pageSize 大于 100", 2, 200, 2, 100},
{"正常范围", 3, 30, 3, 30},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotPage, gotSize := NormalizePagination(tt.page, tt.size)
if gotPage != tt.wantPage || gotSize != tt.wantPageSize {
t.Fatalf("NormalizePagination(%d,%d) = (%d,%d), want (%d,%d)",
tt.page, tt.size, gotPage, gotSize, tt.wantPage, tt.wantPageSize)
}
})
}
}
// TestWrapError 覆盖 WrapError 的 nil 与非 nil 分支
func TestWrapError(t *testing.T) {
if err := WrapError(nil, "msg"); err != nil {
t.Fatalf("WrapError(nil, ...) 应返回 nil, got=%v", err)
}
orig := errors.New("orig")
wrapped := WrapError(orig, "context")
if wrapped == nil {
t.Fatalf("WrapError 应返回非 nil 错误")
}
if wrapped.Error() == orig.Error() {
t.Fatalf("WrapError 应添加上下文信息, got=%v", wrapped)
}
}

View File

@@ -0,0 +1,181 @@
// Package service 定义业务逻辑层接口
package service
import (
"carrotskin/internal/model"
"carrotskin/pkg/storage"
"context"
"time"
"go.uber.org/zap"
)
// UserService 用户服务接口
type UserService interface {
// 用户认证
Register(ctx context.Context, username, password, email, avatar string) (*model.User, string, error)
Login(ctx context.Context, usernameOrEmail, password, ipAddress, userAgent string) (*model.User, string, error)
// 用户查询
GetByID(ctx context.Context, id int64) (*model.User, error)
GetByEmail(ctx context.Context, email string) (*model.User, error)
GetByUsername(ctx context.Context, username string) (*model.User, error)
// 用户更新
UpdateInfo(ctx context.Context, user *model.User) error
UpdateAvatar(ctx context.Context, userID int64, avatarURL string) error
ChangePassword(ctx context.Context, userID int64, oldPassword, newPassword string) error
ResetPassword(ctx context.Context, email, newPassword string) error
ChangeEmail(ctx context.Context, userID int64, newEmail string) error
// 头像上传
UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error)
// URL验证
ValidateAvatarURL(ctx context.Context, avatarURL string) error
// 配置获取
GetMaxProfilesPerUser() int
GetMaxTexturesPerUser() int
}
// ProfileService 档案服务接口
type ProfileService interface {
// 档案CRUD
Create(ctx context.Context, userID int64, name string) (*model.Profile, error)
GetByUUID(ctx context.Context, uuid string) (*model.Profile, error)
GetByUserID(ctx context.Context, userID int64) ([]*model.Profile, error)
Update(ctx context.Context, uuid string, userID int64, name *string, skinID, capeID *int64) (*model.Profile, error)
Delete(ctx context.Context, uuid string, userID int64) error
// 档案状态
CheckLimit(ctx context.Context, userID int64, maxProfiles int) error
// 批量查询
GetByNames(ctx context.Context, names []string) ([]*model.Profile, error)
GetByProfileName(ctx context.Context, name string) (*model.Profile, error)
}
// TextureService 材质服务接口
type TextureService interface {
// 材质CRUD
UploadTexture(ctx context.Context, uploaderID int64, name, description, textureType string, fileData []byte, fileName string, isPublic, isSlim bool) (*model.Texture, error)
GetByID(ctx context.Context, id int64) (*model.Texture, error)
GetByHash(ctx context.Context, hash string) (*model.Texture, error)
GetByUserID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error)
Search(ctx context.Context, keyword string, textureType model.TextureType, publicOnly bool, page, pageSize int) ([]*model.Texture, int64, error)
Update(ctx context.Context, textureID, uploaderID int64, name, description string, isPublic *bool) (*model.Texture, error)
Delete(ctx context.Context, textureID, uploaderID int64) error
// 收藏
ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, error)
GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error)
// 限制检查
CheckUploadLimit(ctx context.Context, uploaderID int64, maxTextures int) error
}
// TokenService 令牌服务接口
type TokenService interface {
// 令牌管理
Create(ctx context.Context, userID int64, uuid, clientToken string) (*model.Profile, []*model.Profile, string, string, error)
Validate(ctx context.Context, accessToken, clientToken string) bool
Refresh(ctx context.Context, accessToken, clientToken, selectedProfileID string) (string, string, error)
Invalidate(ctx context.Context, accessToken string)
InvalidateUserTokens(ctx context.Context, userID int64)
// 令牌查询
GetUUIDByAccessToken(ctx context.Context, accessToken string) (string, error)
GetUserIDByAccessToken(ctx context.Context, accessToken string) (int64, error)
}
// VerificationService 验证码服务接口
type VerificationService interface {
SendCode(ctx context.Context, email, codeType string) error
VerifyCode(ctx context.Context, email, code, codeType string) error
}
// CaptchaService 滑动验证码服务接口
type CaptchaService interface {
Generate(ctx context.Context) (masterImg, tileImg, captchaID string, y int, err error)
Verify(ctx context.Context, dx int, captchaID string) (bool, error)
}
// YggdrasilService Yggdrasil服务接口
type YggdrasilService interface {
// 用户认证
GetUserIDByEmail(ctx context.Context, email string) (int64, error)
VerifyPassword(ctx context.Context, password string, userID int64) error
// 会话管理
JoinServer(ctx context.Context, serverID, accessToken, selectedProfile, ip string) error
HasJoinedServer(ctx context.Context, serverID, username, ip string) error
// 密码管理
ResetYggdrasilPassword(ctx context.Context, userID int64) (string, error)
// 序列化
SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{}
SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{}
// 证书
GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error)
GetPublicKey(ctx context.Context) (string, error)
}
// SecurityService 安全服务接口
type SecurityService interface {
// 登录安全
CheckLoginLocked(ctx context.Context, identifier string) (bool, time.Duration, error)
RecordLoginFailure(ctx context.Context, identifier string) (int, error)
ClearLoginAttempts(ctx context.Context, identifier string) error
GetRemainingLoginAttempts(ctx context.Context, identifier string) (int, error)
// 验证码安全
CheckVerifyLocked(ctx context.Context, email, codeType string) (bool, time.Duration, error)
RecordVerifyFailure(ctx context.Context, email, codeType string) (int, error)
ClearVerifyAttempts(ctx context.Context, email, codeType string) error
}
// ReportService 举报服务接口
type ReportService interface {
// 创建举报
CreateReport(ctx context.Context, reporterID int64, targetType model.ReportType, targetID int64, reason string) (*model.Report, error)
// 查询举报
GetByID(ctx context.Context, id int64) (*model.Report, error)
GetByReporterID(ctx context.Context, reporterID, userID int64, page, pageSize int) ([]*model.Report, int64, error)
GetByTarget(ctx context.Context, targetType model.ReportType, targetID, userID int64, page, pageSize int) ([]*model.Report, int64, error)
GetByStatus(ctx context.Context, status model.ReportStatus, page, pageSize int) ([]*model.Report, int64, error)
Search(ctx context.Context, keyword, userID int64, page, pageSize int) ([]*model.Report, int64, error)
// 处理举报
Review(ctx context.Context, reportID, reviewerID int64, status model.ReportStatus, reviewNote string) (*model.Report, error)
BatchReview(ctx context.Context, ids []int64, reviewerID int64, status model.ReportStatus, reviewNote string) (int64, error)
// 删除举报
Delete(ctx context.Context, reportID, userID int64) error
BatchDelete(ctx context.Context, ids []int64, userID int64) (int64, error)
// 统计
GetStats(ctx context.Context) (map[string]int64, error)
}
// Services 服务集合
type Services struct {
User UserService
Profile ProfileService
Texture TextureService
Token TokenService
Verification VerificationService
Captcha CaptchaService
Yggdrasil YggdrasilService
Security SecurityService
Report ReportService
}
// ServiceDeps 服务依赖
type ServiceDeps struct {
Logger *zap.Logger
Storage *storage.StorageClient
}

View File

@@ -0,0 +1,827 @@
package service
import (
"carrotskin/internal/model"
"carrotskin/pkg/database"
"context"
"errors"
"time"
)
// ============================================================================
// Repository Mocks
// ============================================================================
// MockUserRepository 模拟UserRepository
type MockUserRepository struct {
users map[int64]*model.User
// 用于模拟错误的标志
FailCreate bool
FailFindByID bool
FailFindByUsername bool
FailFindByEmail bool
FailUpdate bool
}
func NewMockUserRepository() *MockUserRepository {
return &MockUserRepository{
users: make(map[int64]*model.User),
}
}
func (m *MockUserRepository) Create(ctx context.Context, user *model.User) error {
if m.FailCreate {
return errors.New("mock create error")
}
if user.ID == 0 {
user.ID = int64(len(m.users) + 1)
}
m.users[user.ID] = user
return nil
}
func (m *MockUserRepository) FindByID(ctx context.Context, id int64) (*model.User, error) {
if m.FailFindByID {
return nil, errors.New("mock find error")
}
if user, ok := m.users[id]; ok {
return user, nil
}
return nil, nil
}
func (m *MockUserRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) {
if m.FailFindByUsername {
return nil, errors.New("mock find by username error")
}
for _, user := range m.users {
if user.Username == username {
return user, nil
}
}
return nil, nil
}
func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
if m.FailFindByEmail {
return nil, errors.New("mock find by email error")
}
for _, user := range m.users {
if user.Email == email {
return user, nil
}
}
return nil, nil
}
func (m *MockUserRepository) Update(ctx context.Context, user *model.User) error {
if m.FailUpdate {
return errors.New("mock update error")
}
m.users[user.ID] = user
return nil
}
func (m *MockUserRepository) UpdateFields(ctx context.Context, id int64, fields map[string]interface{}) error {
if m.FailUpdate {
return errors.New("mock update fields error")
}
_, ok := m.users[id]
if !ok {
return errors.New("user not found")
}
return nil
}
func (m *MockUserRepository) Delete(ctx context.Context, id int64) error {
delete(m.users, id)
return nil
}
func (m *MockUserRepository) CreateLoginLog(ctx context.Context, log *model.UserLoginLog) error {
return nil
}
func (m *MockUserRepository) CreatePointLog(ctx context.Context, log *model.UserPointLog) error {
return nil
}
func (m *MockUserRepository) UpdatePoints(ctx context.Context, userID int64, amount int, changeType, reason string) error {
return nil
}
// BatchUpdate 和 BatchDelete 仅用于满足接口,在测试中不做具体操作
func (m *MockUserRepository) BatchUpdate(ctx context.Context, ids []int64, fields map[string]interface{}) (int64, error) {
return 0, nil
}
func (m *MockUserRepository) BatchDelete(ctx context.Context, ids []int64) (int64, error) {
return 0, nil
}
// FindByIDs 批量查询用户
func (m *MockUserRepository) FindByIDs(ctx context.Context, ids []int64) ([]*model.User, error) {
var result []*model.User
for _, id := range ids {
if u, ok := m.users[id]; ok {
result = append(result, u)
}
}
return result, nil
}
// MockProfileRepository 模拟ProfileRepository
type MockProfileRepository struct {
profiles map[string]*model.Profile
userProfiles map[int64][]*model.Profile
nextID int64
FailCreate bool
FailFind bool
FailUpdate bool
FailDelete bool
}
func NewMockProfileRepository() *MockProfileRepository {
return &MockProfileRepository{
profiles: make(map[string]*model.Profile),
userProfiles: make(map[int64][]*model.Profile),
nextID: 1,
}
}
func (m *MockProfileRepository) Create(ctx context.Context, profile *model.Profile) error {
if m.FailCreate {
return errors.New("mock create error")
}
m.profiles[profile.UUID] = profile
m.userProfiles[profile.UserID] = append(m.userProfiles[profile.UserID], profile)
return nil
}
func (m *MockProfileRepository) FindByUUID(ctx context.Context, uuid string) (*model.Profile, error) {
if m.FailFind {
return nil, errors.New("mock find error")
}
if profile, ok := m.profiles[uuid]; ok {
return profile, nil
}
return nil, errors.New("profile not found")
}
func (m *MockProfileRepository) FindByName(ctx context.Context, name string) (*model.Profile, error) {
if m.FailFind {
return nil, errors.New("mock find error")
}
for _, profile := range m.profiles {
if profile.Name == name {
return profile, nil
}
}
return nil, nil
}
func (m *MockProfileRepository) FindByUserID(ctx context.Context, userID int64) ([]*model.Profile, error) {
if m.FailFind {
return nil, errors.New("mock find error")
}
return m.userProfiles[userID], nil
}
func (m *MockProfileRepository) Update(ctx context.Context, profile *model.Profile) error {
if m.FailUpdate {
return errors.New("mock update error")
}
m.profiles[profile.UUID] = profile
return nil
}
func (m *MockProfileRepository) UpdateFields(ctx context.Context, uuid string, updates map[string]interface{}) error {
if m.FailUpdate {
return errors.New("mock update error")
}
return nil
}
func (m *MockProfileRepository) Delete(ctx context.Context, uuid string) error {
if m.FailDelete {
return errors.New("mock delete error")
}
delete(m.profiles, uuid)
return nil
}
func (m *MockProfileRepository) CountByUserID(ctx context.Context, userID int64) (int64, error) {
return int64(len(m.userProfiles[userID])), nil
}
func (m *MockProfileRepository) SetActive(ctx context.Context, uuid string, userID int64) error {
return nil
}
func (m *MockProfileRepository) UpdateLastUsedAt(ctx context.Context, uuid string) error {
return nil
}
func (m *MockProfileRepository) GetByNames(ctx context.Context, names []string) ([]*model.Profile, error) {
var result []*model.Profile
for _, name := range names {
for _, profile := range m.profiles {
if profile.Name == name {
result = append(result, profile)
}
}
}
return result, nil
}
func (m *MockProfileRepository) GetKeyPair(ctx context.Context, profileId string) (*model.KeyPair, error) {
return nil, nil
}
func (m *MockProfileRepository) UpdateKeyPair(ctx context.Context, profileId string, keyPair *model.KeyPair) error {
return nil
}
// BatchUpdate / BatchDelete 仅用于满足接口
func (m *MockProfileRepository) BatchUpdate(ctx context.Context, uuids []string, updates map[string]interface{}) (int64, error) {
return 0, nil
}
func (m *MockProfileRepository) BatchDelete(ctx context.Context, uuids []string) (int64, error) {
return 0, nil
}
// FindByUUIDs 批量查询 Profile
func (m *MockProfileRepository) FindByUUIDs(ctx context.Context, uuids []string) ([]*model.Profile, error) {
var result []*model.Profile
for _, id := range uuids {
if p, ok := m.profiles[id]; ok {
result = append(result, p)
}
}
return result, nil
}
// MockTextureRepository 模拟TextureRepository
type MockTextureRepository struct {
textures map[int64]*model.Texture
favorites map[int64]map[int64]bool // userID -> textureID -> favorited
nextID int64
FailCreate bool
FailFind bool
FailUpdate bool
FailDelete bool
}
func NewMockTextureRepository() *MockTextureRepository {
return &MockTextureRepository{
textures: make(map[int64]*model.Texture),
favorites: make(map[int64]map[int64]bool),
nextID: 1,
}
}
func (m *MockTextureRepository) Create(ctx context.Context, texture *model.Texture) error {
if m.FailCreate {
return errors.New("mock create error")
}
if texture.ID == 0 {
texture.ID = m.nextID
m.nextID++
}
m.textures[texture.ID] = texture
return nil
}
func (m *MockTextureRepository) FindByID(ctx context.Context, id int64) (*model.Texture, error) {
if m.FailFind {
return nil, errors.New("mock find error")
}
if texture, ok := m.textures[id]; ok {
return texture, nil
}
return nil, errors.New("texture not found")
}
func (m *MockTextureRepository) FindByHash(ctx context.Context, hash string) (*model.Texture, error) {
if m.FailFind {
return nil, errors.New("mock find error")
}
for _, texture := range m.textures {
if texture.Hash == hash {
return texture, nil
}
}
return nil, nil
}
func (m *MockTextureRepository) FindByHashAndUploaderID(ctx context.Context, hash string, uploaderID int64) (*model.Texture, error) {
if m.FailFind {
return nil, errors.New("mock find error")
}
for _, texture := range m.textures {
if texture.Hash == hash && texture.UploaderID == uploaderID {
return texture, nil
}
}
return nil, nil
}
func (m *MockTextureRepository) FindByUploaderID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) {
if m.FailFind {
return nil, 0, errors.New("mock find error")
}
var result []*model.Texture
for _, texture := range m.textures {
if texture.UploaderID == uploaderID {
result = append(result, texture)
}
}
return result, int64(len(result)), nil
}
func (m *MockTextureRepository) Search(ctx context.Context, keyword string, textureType model.TextureType, publicOnly bool, page, pageSize int) ([]*model.Texture, int64, error) {
if m.FailFind {
return nil, 0, errors.New("mock find error")
}
var result []*model.Texture
for _, texture := range m.textures {
if publicOnly && !texture.IsPublic {
continue
}
result = append(result, texture)
}
return result, int64(len(result)), nil
}
func (m *MockTextureRepository) Update(ctx context.Context, texture *model.Texture) error {
if m.FailUpdate {
return errors.New("mock update error")
}
m.textures[texture.ID] = texture
return nil
}
func (m *MockTextureRepository) UpdateFields(ctx context.Context, id int64, fields map[string]interface{}) error {
if m.FailUpdate {
return errors.New("mock update error")
}
return nil
}
func (m *MockTextureRepository) Delete(ctx context.Context, id int64) error {
if m.FailDelete {
return errors.New("mock delete error")
}
delete(m.textures, id)
return nil
}
func (m *MockTextureRepository) IncrementDownloadCount(ctx context.Context, id int64) error {
if texture, ok := m.textures[id]; ok {
texture.DownloadCount++
}
return nil
}
func (m *MockTextureRepository) IncrementFavoriteCount(ctx context.Context, id int64) error {
if texture, ok := m.textures[id]; ok {
texture.FavoriteCount++
}
return nil
}
func (m *MockTextureRepository) CreateDownloadLog(ctx context.Context, log *model.TextureDownloadLog) error {
return nil
}
func (m *MockTextureRepository) ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, error) {
if m.favorites[userID] == nil {
m.favorites[userID] = make(map[int64]bool)
}
isFavorited := m.favorites[userID][textureID]
m.favorites[userID][textureID] = !isFavorited
if texture, ok := m.textures[textureID]; ok {
if !isFavorited {
texture.FavoriteCount++
} else if texture.FavoriteCount > 0 {
texture.FavoriteCount--
}
}
return !isFavorited, nil
}
func (m *MockTextureRepository) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {
var result []*model.Texture
if userFavs, ok := m.favorites[userID]; ok {
for textureID := range userFavs {
if texture, exists := m.textures[textureID]; exists {
result = append(result, texture)
}
}
}
return result, int64(len(result)), nil
}
func (m *MockTextureRepository) CountByUploaderID(ctx context.Context, uploaderID int64) (int64, error) {
var count int64
for _, texture := range m.textures {
if texture.UploaderID == uploaderID {
count++
}
}
return count, nil
}
// FindByIDs 批量查询 Texture
func (m *MockTextureRepository) FindByIDs(ctx context.Context, ids []int64) ([]*model.Texture, error) {
var result []*model.Texture
for _, id := range ids {
if tex, ok := m.textures[id]; ok {
result = append(result, tex)
}
}
return result, nil
}
// BatchUpdate 仅用于满足接口
func (m *MockTextureRepository) BatchUpdate(ctx context.Context, ids []int64, fields map[string]interface{}) (int64, error) {
return 0, nil
}
// BatchDelete 仅用于满足接口
func (m *MockTextureRepository) BatchDelete(ctx context.Context, ids []int64) (int64, error) {
var deleted int64
for _, id := range ids {
if _, ok := m.textures[id]; ok {
delete(m.textures, id)
deleted++
}
}
return deleted, nil
}
// ============================================================================
// Service Mocks
// ============================================================================
// MockUserService 模拟UserService
type MockUserService struct {
users map[int64]*model.User
maxProfilesPerUser int
maxTexturesPerUser int
FailRegister bool
FailLogin bool
FailGetByID bool
FailUpdate bool
}
func NewMockUserService() *MockUserService {
return &MockUserService{
users: make(map[int64]*model.User),
maxProfilesPerUser: 5,
maxTexturesPerUser: 50,
}
}
func (m *MockUserService) Register(username, password, email, avatar string) (*model.User, string, error) {
if m.FailRegister {
return nil, "", errors.New("mock register error")
}
user := &model.User{
ID: int64(len(m.users) + 1),
Username: username,
Email: email,
Avatar: avatar,
Status: 1,
}
m.users[user.ID] = user
return user, "mock-token", nil
}
func (m *MockUserService) Login(usernameOrEmail, password, ipAddress, userAgent string) (*model.User, string, error) {
if m.FailLogin {
return nil, "", errors.New("mock login error")
}
for _, user := range m.users {
if user.Username == usernameOrEmail || user.Email == usernameOrEmail {
return user, "mock-token", nil
}
}
return nil, "", errors.New("user not found")
}
func (m *MockUserService) GetByID(id int64) (*model.User, error) {
if m.FailGetByID {
return nil, errors.New("mock get by id error")
}
if user, ok := m.users[id]; ok {
return user, nil
}
return nil, nil
}
func (m *MockUserService) GetByEmail(email string) (*model.User, error) {
for _, user := range m.users {
if user.Email == email {
return user, nil
}
}
return nil, nil
}
func (m *MockUserService) UpdateInfo(user *model.User) error {
if m.FailUpdate {
return errors.New("mock update error")
}
m.users[user.ID] = user
return nil
}
func (m *MockUserService) UpdateAvatar(userID int64, avatarURL string) error {
if m.FailUpdate {
return errors.New("mock update error")
}
if user, ok := m.users[userID]; ok {
user.Avatar = avatarURL
}
return nil
}
func (m *MockUserService) ChangePassword(userID int64, oldPassword, newPassword string) error {
return nil
}
func (m *MockUserService) ResetPassword(email, newPassword string) error {
return nil
}
func (m *MockUserService) ChangeEmail(userID int64, newEmail string) error {
if user, ok := m.users[userID]; ok {
user.Email = newEmail
}
return nil
}
func (m *MockUserService) ValidateAvatarURL(avatarURL string) error {
return nil
}
func (m *MockUserService) GetMaxProfilesPerUser() int {
return m.maxProfilesPerUser
}
func (m *MockUserService) GetMaxTexturesPerUser() int {
return m.maxTexturesPerUser
}
// MockProfileService 模拟ProfileService
type MockProfileService struct {
profiles map[string]*model.Profile
FailCreate bool
FailGet bool
FailUpdate bool
FailDelete bool
}
func NewMockProfileService() *MockProfileService {
return &MockProfileService{
profiles: make(map[string]*model.Profile),
}
}
func (m *MockProfileService) Create(userID int64, name string) (*model.Profile, error) {
if m.FailCreate {
return nil, errors.New("mock create error")
}
profile := &model.Profile{
UUID: "mock-uuid-" + name,
UserID: userID,
Name: name,
}
m.profiles[profile.UUID] = profile
return profile, nil
}
func (m *MockProfileService) GetByUUID(uuid string) (*model.Profile, error) {
if m.FailGet {
return nil, errors.New("mock get error")
}
if profile, ok := m.profiles[uuid]; ok {
return profile, nil
}
return nil, errors.New("profile not found")
}
func (m *MockProfileService) GetByUserID(userID int64) ([]*model.Profile, error) {
if m.FailGet {
return nil, errors.New("mock get error")
}
var result []*model.Profile
for _, profile := range m.profiles {
if profile.UserID == userID {
result = append(result, profile)
}
}
return result, nil
}
func (m *MockProfileService) Update(uuid string, userID int64, name *string, skinID, capeID *int64) (*model.Profile, error) {
if m.FailUpdate {
return nil, errors.New("mock update error")
}
if profile, ok := m.profiles[uuid]; ok {
if name != nil {
profile.Name = *name
}
if skinID != nil {
profile.SkinID = skinID
}
if capeID != nil {
profile.CapeID = capeID
}
return profile, nil
}
return nil, errors.New("profile not found")
}
func (m *MockProfileService) Delete(uuid string, userID int64) error {
if m.FailDelete {
return errors.New("mock delete error")
}
delete(m.profiles, uuid)
return nil
}
func (m *MockProfileService) SetActive(uuid string, userID int64) error {
return nil
}
func (m *MockProfileService) CheckLimit(userID int64, maxProfiles int) error {
count := 0
for _, profile := range m.profiles {
if profile.UserID == userID {
count++
}
}
if count >= maxProfiles {
return errors.New("达到档案数量上限")
}
return nil
}
func (m *MockProfileService) GetByNames(names []string) ([]*model.Profile, error) {
var result []*model.Profile
for _, name := range names {
for _, profile := range m.profiles {
if profile.Name == name {
result = append(result, profile)
}
}
}
return result, nil
}
func (m *MockProfileService) GetByProfileName(name string) (*model.Profile, error) {
for _, profile := range m.profiles {
if profile.Name == name {
return profile, nil
}
}
return nil, errors.New("profile not found")
}
// MockTextureService 模拟TextureService
type MockTextureService struct {
textures map[int64]*model.Texture
nextID int64
FailCreate bool
FailGet bool
FailUpdate bool
FailDelete bool
}
func NewMockTextureService() *MockTextureService {
return &MockTextureService{
textures: make(map[int64]*model.Texture),
nextID: 1,
}
}
func (m *MockTextureService) Create(uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error) {
if m.FailCreate {
return nil, errors.New("mock create error")
}
texture := &model.Texture{
ID: m.nextID,
UploaderID: uploaderID,
Name: name,
Description: description,
URL: url,
Hash: hash,
Size: size,
IsPublic: isPublic,
IsSlim: isSlim,
}
m.textures[texture.ID] = texture
m.nextID++
return texture, nil
}
func (m *MockTextureService) GetByID(id int64) (*model.Texture, error) {
if m.FailGet {
return nil, errors.New("mock get error")
}
if texture, ok := m.textures[id]; ok {
return texture, nil
}
return nil, errors.New("texture not found")
}
func (m *MockTextureService) GetByUserID(uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) {
if m.FailGet {
return nil, 0, errors.New("mock get error")
}
var result []*model.Texture
for _, texture := range m.textures {
if texture.UploaderID == uploaderID {
result = append(result, texture)
}
}
return result, int64(len(result)), nil
}
func (m *MockTextureService) Search(keyword string, textureType model.TextureType, publicOnly bool, page, pageSize int) ([]*model.Texture, int64, error) {
if m.FailGet {
return nil, 0, errors.New("mock get error")
}
var result []*model.Texture
for _, texture := range m.textures {
if publicOnly && !texture.IsPublic {
continue
}
result = append(result, texture)
}
return result, int64(len(result)), nil
}
func (m *MockTextureService) Update(textureID, uploaderID int64, name, description string, isPublic *bool) (*model.Texture, error) {
if m.FailUpdate {
return nil, errors.New("mock update error")
}
if texture, ok := m.textures[textureID]; ok {
if name != "" {
texture.Name = name
}
if description != "" {
texture.Description = description
}
if isPublic != nil {
texture.IsPublic = *isPublic
}
return texture, nil
}
return nil, errors.New("texture not found")
}
func (m *MockTextureService) Delete(textureID, uploaderID int64) error {
if m.FailDelete {
return errors.New("mock delete error")
}
delete(m.textures, textureID)
return nil
}
func (m *MockTextureService) ToggleFavorite(userID, textureID int64) (bool, error) {
return true, nil
}
func (m *MockTextureService) GetUserFavorites(userID int64, page, pageSize int) ([]*model.Texture, int64, error) {
return nil, 0, nil
}
func (m *MockTextureService) CheckUploadLimit(uploaderID int64, maxTextures int) error {
count := 0
for _, texture := range m.textures {
if texture.UploaderID == uploaderID {
count++
}
}
if count >= maxTextures {
return errors.New("达到材质数量上限")
}
return nil
}
// ============================================================================
// CacheManager Mock - 使用 database.CacheManager 的内存版本
// ============================================================================
// NewMockCacheManager 创建一个内存 CacheManager 用于测试
func NewMockCacheManager() *database.CacheManager {
return database.NewCacheManager(nil, database.CacheConfig{
Prefix: "test:",
Expiration: 5 * time.Minute,
Enabled: false, // 禁用缓存,测试不依赖 Redis
})
}

View File

@@ -3,121 +3,165 @@ package service
import (
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/database"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
"gorm.io/gorm"
)
// CreateProfile 创建档案
func CreateProfile(db *gorm.DB, userID int64, name string) (*model.Profile, error) {
// 1. 验证用户存在
user, err := repository.FindUserByID(userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("用户不存在")
}
return nil, fmt.Errorf("查询用户失败: %w", err)
}
// profileService ProfileService的实现
type profileService struct {
profileRepo repository.ProfileRepository
userRepo repository.UserRepository
cache *database.CacheManager
cacheKeys *database.CacheKeyBuilder
cacheInv *database.CacheInvalidator
logger *zap.Logger
}
// NewProfileService 创建ProfileService实例
func NewProfileService(
profileRepo repository.ProfileRepository,
userRepo repository.UserRepository,
cacheManager *database.CacheManager,
logger *zap.Logger,
) ProfileService {
return &profileService{
profileRepo: profileRepo,
userRepo: userRepo,
cache: cacheManager,
cacheKeys: database.NewCacheKeyBuilder(""),
cacheInv: database.NewCacheInvalidator(cacheManager),
logger: logger,
}
}
func (s *profileService) Create(ctx context.Context, userID int64, name string) (*model.Profile, error) {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil || user == nil {
return nil, errors.New("用户不存在")
}
if user.Status != 1 {
return nil, fmt.Errorf("用户状态异常")
return nil, errors.New("用户状态异常")
}
// 2. 检查角色名是否已存在
existingName, err := repository.FindProfileByName(name)
// 检查角色名是否已存在
existingName, err := s.profileRepo.FindByName(ctx, name)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("查询角色名失败: %w", err)
}
if existingName != nil {
return nil, fmt.Errorf("角色名已被使用")
return nil, errors.New("角色名已被使用")
}
// 3. 生成UUID
// 生成UUID和RSA密钥
profileUUID := uuid.New().String()
// 4. 生成RSA密钥对
privateKey, err := generateRSAPrivateKey()
privateKey, err := generateRSAPrivateKeyInternal()
if err != nil {
return nil, fmt.Errorf("生成RSA密钥失败: %w", err)
}
// 5. 创建档案
// 创建档案
profile := &model.Profile{
UUID: profileUUID,
UserID: userID,
Name: name,
RSAPrivateKey: privateKey,
IsActive: true, // 新创建的档案默认为活跃状态
}
if err := repository.CreateProfile(profile); err != nil {
if err := s.profileRepo.Create(ctx, profile); err != nil {
return nil, fmt.Errorf("创建档案失败: %w", err)
}
// 6. 将用户的其他档案设置为非活跃
if err := repository.SetActiveProfile(profileUUID, userID); err != nil {
return nil, fmt.Errorf("设置活跃状态失败: %w", err)
}
// 清除用户的 profile 列表缓存
s.cacheInv.OnCreate(ctx, s.cacheKeys.ProfileList(userID))
return profile, nil
}
// GetProfileByUUID 获取档案详情
func GetProfileByUUID(db *gorm.DB, uuid string) (*model.Profile, error) {
profile, err := repository.FindProfileByUUID(uuid)
func (s *profileService) GetByUUID(ctx context.Context, uuid string) (*model.Profile, error) {
// 尝试从缓存获取
cacheKey := s.cacheKeys.Profile(uuid)
var profile model.Profile
if ok, _ := s.cache.TryGet(ctx, cacheKey, &profile); ok {
return &profile, nil
}
// 缓存未命中,从数据库查询
profile2, err := s.profileRepo.FindByUUID(ctx, uuid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("档案不存在")
return nil, ErrProfileNotFound
}
return nil, fmt.Errorf("查询档案失败: %w", err)
}
return profile, nil
// 存入缓存(异步)
if profile2 != nil {
s.cache.SetAsync(context.Background(), cacheKey, profile2, s.cache.Policy.ProfileTTL)
}
return profile2, nil
}
// GetUserProfiles 获取用户的所有档案
func GetUserProfiles(db *gorm.DB, userID int64) ([]*model.Profile, error) {
profiles, err := repository.FindProfilesByUserID(userID)
func (s *profileService) GetByUserID(ctx context.Context, userID int64) ([]*model.Profile, error) {
// 尝试从缓存获取
cacheKey := s.cacheKeys.ProfileList(userID)
var profiles []*model.Profile
if ok, _ := s.cache.TryGet(ctx, cacheKey, &profiles); ok {
return profiles, nil
}
// 缓存未命中,从数据库查询
profiles, err := s.profileRepo.FindByUserID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("查询档案列表失败: %w", err)
}
// 存入缓存(异步)
if profiles != nil {
s.cache.SetAsync(context.Background(), cacheKey, profiles, s.cache.Policy.ProfileListTTL)
}
return profiles, nil
}
// UpdateProfile 更新档案
func UpdateProfile(db *gorm.DB, uuid string, userID int64, name *string, skinID, capeID *int64) (*model.Profile, error) {
// 1. 查询档案
profile, err := repository.FindProfileByUUID(uuid)
func (s *profileService) Update(ctx context.Context, uuid string, userID int64, name *string, skinID, capeID *int64) (*model.Profile, error) {
// 获取档案并验证权限
profile, err := s.profileRepo.FindByUUID(ctx, uuid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("档案不存在")
return nil, ErrProfileNotFound
}
return nil, fmt.Errorf("查询档案失败: %w", err)
}
// 2. 验证权限
if profile.UserID != userID {
return nil, fmt.Errorf("无权操作此档案")
return nil, ErrProfileNoPermission
}
// 3. 检查角色名是否重复
// 检查角色名是否重复
if name != nil && *name != profile.Name {
existingName, err := repository.FindProfileByName(*name)
existingName, err := s.profileRepo.FindByName(ctx, *name)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("查询角色名失败: %w", err)
}
if existingName != nil {
return nil, fmt.Errorf("角色名已被使用")
return nil, errors.New("角色名已被使用")
}
profile.Name = *name
}
// 4. 更新皮肤和披风
// 更新皮肤和披风
if skinID != nil {
profile.SkinID = skinID
}
@@ -125,71 +169,48 @@ func UpdateProfile(db *gorm.DB, uuid string, userID int64, name *string, skinID,
profile.CapeID = capeID
}
// 5. 保存更新
if err := repository.UpdateProfile(profile); err != nil {
if err := s.profileRepo.Update(ctx, profile); err != nil {
return nil, fmt.Errorf("更新档案失败: %w", err)
}
// 6. 重新加载关联数据
return repository.FindProfileByUUID(uuid)
// 清除该 profile 和用户列表的缓存
s.cacheInv.OnUpdate(ctx,
s.cacheKeys.Profile(uuid),
s.cacheKeys.ProfileList(userID),
)
return s.profileRepo.FindByUUID(ctx, uuid)
}
// DeleteProfile 删除档案
func DeleteProfile(db *gorm.DB, uuid string, userID int64) error {
// 1. 查询档案
profile, err := repository.FindProfileByUUID(uuid)
func (s *profileService) Delete(ctx context.Context, uuid string, userID int64) error {
// 获取档案并验证权限
profile, err := s.profileRepo.FindByUUID(ctx, uuid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("档案不存在")
return ErrProfileNotFound
}
return fmt.Errorf("查询档案失败: %w", err)
}
// 2. 验证权限
if profile.UserID != userID {
return fmt.Errorf("无权操作此档案")
return ErrProfileNoPermission
}
// 3. 删除档案
if err := repository.DeleteProfile(uuid); err != nil {
if err := s.profileRepo.Delete(ctx, uuid); err != nil {
return fmt.Errorf("删除档案失败: %w", err)
}
return nil
}
// SetActiveProfile 设置活跃档案
func SetActiveProfile(db *gorm.DB, uuid string, userID int64) error {
// 1. 查询档案
profile, err := repository.FindProfileByUUID(uuid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("档案不存在")
}
return fmt.Errorf("查询档案失败: %w", err)
}
// 2. 验证权限
if profile.UserID != userID {
return fmt.Errorf("无权操作此档案")
}
// 3. 设置活跃状态
if err := repository.SetActiveProfile(uuid, userID); err != nil {
return fmt.Errorf("设置活跃状态失败: %w", err)
}
// 4. 更新最后使用时间
if err := repository.UpdateProfileLastUsedAt(uuid); err != nil {
return fmt.Errorf("更新使用时间失败: %w", err)
}
// 清除该 profile 和用户列表的缓存
s.cacheInv.OnDelete(ctx,
s.cacheKeys.Profile(uuid),
s.cacheKeys.ProfileList(userID),
)
return nil
}
// CheckProfileLimit 检查用户档案数量限制
func CheckProfileLimit(db *gorm.DB, userID int64, maxProfiles int) error {
count, err := repository.CountProfilesByUserID(userID)
func (s *profileService) CheckLimit(ctx context.Context, userID int64, maxProfiles int) error {
count, err := s.profileRepo.CountByUserID(ctx, userID)
if err != nil {
return fmt.Errorf("查询档案数量失败: %w", err)
}
@@ -197,19 +218,33 @@ func CheckProfileLimit(db *gorm.DB, userID int64, maxProfiles int) error {
if int(count) >= maxProfiles {
return fmt.Errorf("已达到档案数量上限(%d个", maxProfiles)
}
return nil
}
// generateRSAPrivateKey 生成RSA-2048私钥PEM格式
func generateRSAPrivateKey() (string, error) {
// 生成2048位RSA密钥对
func (s *profileService) GetByNames(ctx context.Context, names []string) ([]*model.Profile, error) {
profiles, err := s.profileRepo.GetByNames(ctx, names)
if err != nil {
return nil, fmt.Errorf("查找失败: %w", err)
}
return profiles, nil
}
func (s *profileService) GetByProfileName(ctx context.Context, name string) (*model.Profile, error) {
// Profile name 查询通常不会频繁缓存,但为了一致性也添加
profile, err := s.profileRepo.FindByName(ctx, name)
if err != nil {
return nil, errors.New("用户角色未创建")
}
return profile, nil
}
// generateRSAPrivateKeyInternal 生成RSA-2048私钥PEM格式
func generateRSAPrivateKeyInternal() (string, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return "", err
}
// 将私钥编码为PEM格式
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
@@ -218,35 +253,3 @@ func generateRSAPrivateKey() (string, error) {
return string(privateKeyPEM), nil
}
func ValidateProfileByUserID(db *gorm.DB, userId int64, UUID string) (bool, error) {
if userId == 0 || UUID == "" {
return false, errors.New("用户ID或配置文件ID不能为空")
}
profile, err := repository.FindProfileByUUID(UUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, errors.New("配置文件不存在")
}
return false, fmt.Errorf("验证配置文件失败: %w", err)
}
return profile.UserID == userId, nil
}
func GetProfilesDataByNames(db *gorm.DB, names []string) ([]*model.Profile, error) {
profiles, err := repository.GetProfilesByNames(names)
if err != nil {
return nil, fmt.Errorf("查找失败: %w", err)
}
return profiles, nil
}
// GetProfileKeyPair 从 PostgreSQL 获取密钥对GORM 实现,无手动 SQL
func GetProfileKeyPair(db *gorm.DB, profileId string) (*model.KeyPair, error) {
keyPair, err := repository.GetProfileKeyPair(profileId)
if err != nil {
return nil, fmt.Errorf("查找失败: %w", err)
}
return keyPair, nil
}

View File

@@ -1,7 +1,11 @@
package service
import (
"carrotskin/internal/model"
"context"
"testing"
"go.uber.org/zap"
)
// TestProfileService_Validation 测试Profile服务验证逻辑
@@ -76,15 +80,6 @@ func TestProfileService_StatusValidation(t *testing.T) {
}
}
// TestProfileService_IsActiveDefault 测试Profile默认活跃状态
func TestProfileService_IsActiveDefault(t *testing.T) {
// 新创建的档案默认为活跃状态
isActive := true
if !isActive {
t.Error("新创建的Profile应该默认为活跃状态")
}
}
// TestUpdateProfile_PermissionCheck 测试更新Profile的权限检查逻辑
func TestUpdateProfile_PermissionCheck(t *testing.T) {
tests := []struct {
@@ -187,38 +182,6 @@ func TestDeleteProfile_PermissionCheck(t *testing.T) {
}
}
// TestSetActiveProfile_PermissionCheck 测试设置活跃Profile的权限检查
func TestSetActiveProfile_PermissionCheck(t *testing.T) {
tests := []struct {
name string
profileUserID int64
requestUserID int64
wantErr bool
}{
{
name: "用户ID匹配允许设置",
profileUserID: 1,
requestUserID: 1,
wantErr: false,
},
{
name: "用户ID不匹配拒绝设置",
profileUserID: 1,
requestUserID: 2,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hasError := tt.profileUserID != tt.requestUserID
if hasError != tt.wantErr {
t.Errorf("Permission check failed: got %v, want %v", hasError, tt.wantErr)
}
})
}
}
// TestCheckProfileLimit_Logic 测试Profile数量限制检查逻辑
func TestCheckProfileLimit_Logic(t *testing.T) {
tests := []struct {
@@ -347,22 +310,22 @@ func TestGenerateRSAPrivateKey(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
privateKey, err := generateRSAPrivateKey()
privateKey, err := generateRSAPrivateKeyInternal()
if (err != nil) != tt.wantError {
t.Errorf("generateRSAPrivateKey() error = %v, wantError %v", err, tt.wantError)
t.Errorf("generateRSAPrivateKeyInternal() error = %v, wantError %v", err, tt.wantError)
return
}
if !tt.wantError {
if privateKey == "" {
t.Error("generateRSAPrivateKey() 返回的私钥不应为空")
t.Error("generateRSAPrivateKeyInternal() 返回的私钥不应为空")
}
// 验证PEM格式
if len(privateKey) < 100 {
t.Errorf("generateRSAPrivateKey() 返回的私钥长度异常: %d", len(privateKey))
t.Errorf("generateRSAPrivateKeyInternal() 返回的私钥长度异常: %d", len(privateKey))
}
// 验证包含PEM头部
if !contains(privateKey, "BEGIN RSA PRIVATE KEY") {
t.Error("generateRSAPrivateKey() 返回的私钥应包含PEM头部")
t.Error("generateRSAPrivateKeyInternal() 返回的私钥应包含PEM头部")
}
}
})
@@ -373,9 +336,9 @@ func TestGenerateRSAPrivateKey(t *testing.T) {
func TestGenerateRSAPrivateKey_Uniqueness(t *testing.T) {
keys := make(map[string]bool)
for i := 0; i < 10; i++ {
key, err := generateRSAPrivateKey()
key, err := generateRSAPrivateKeyInternal()
if err != nil {
t.Fatalf("generateRSAPrivateKey() 失败: %v", err)
t.Fatalf("generateRSAPrivateKeyInternal() 失败: %v", err)
}
if keys[key] {
t.Errorf("第%d次生成的密钥与之前重复", i+1)
@@ -404,3 +367,323 @@ func containsMiddle(s, substr string) bool {
}
return false
}
// ============================================================================
// 使用 Mock 的集成测试
// ============================================================================
// TestProfileServiceImpl_Create 测试创建Profile
func TestProfileServiceImpl_Create(t *testing.T) {
profileRepo := NewMockProfileRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置用户
testUser := &model.User{
ID: 1,
Username: "testuser",
Email: "test@example.com",
Status: 1,
}
_ = userRepo.Create(context.Background(), testUser)
cacheManager := NewMockCacheManager()
profileService := NewProfileService(profileRepo, userRepo, cacheManager, logger)
tests := []struct {
name string
userID int64
profileName string
wantErr bool
errMsg string
setupMocks func()
}{
{
name: "正常创建Profile",
userID: 1,
profileName: "TestProfile",
wantErr: false,
},
{
name: "用户不存在",
userID: 999,
profileName: "TestProfile2",
wantErr: true,
errMsg: "用户不存在",
},
{
name: "角色名已存在",
userID: 1,
profileName: "ExistingProfile",
wantErr: true,
errMsg: "角色名已被使用",
setupMocks: func() {
_ = profileRepo.Create(context.Background(), &model.Profile{
UUID: "existing-uuid",
UserID: 2,
Name: "ExistingProfile",
})
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setupMocks != nil {
tt.setupMocks()
}
ctx := context.Background()
profile, err := profileService.Create(ctx, tt.userID, tt.profileName)
if tt.wantErr {
if err == nil {
t.Error("期望返回错误,但实际没有错误")
return
}
if tt.errMsg != "" && err.Error() != tt.errMsg {
t.Errorf("错误信息不匹配: got %v, want %v", err.Error(), tt.errMsg)
}
} else {
if err != nil {
t.Errorf("不期望返回错误: %v", err)
return
}
if profile == nil {
t.Error("返回的Profile不应为nil")
}
if profile.Name != tt.profileName {
t.Errorf("Profile名称不匹配: got %v, want %v", profile.Name, tt.profileName)
}
if profile.UUID == "" {
t.Error("Profile UUID不应为空")
}
}
})
}
}
// TestProfileServiceImpl_GetByUUID 测试获取Profile
func TestProfileServiceImpl_GetByUUID(t *testing.T) {
profileRepo := NewMockProfileRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置Profile
testProfile := &model.Profile{
UUID: "test-uuid-123",
UserID: 1,
Name: "TestProfile",
}
_ = profileRepo.Create(context.Background(), testProfile)
cacheManager := NewMockCacheManager()
profileService := NewProfileService(profileRepo, userRepo, cacheManager, logger)
tests := []struct {
name string
uuid string
wantErr bool
}{
{
name: "获取存在的Profile",
uuid: "test-uuid-123",
wantErr: false,
},
{
name: "获取不存在的Profile",
uuid: "non-existent-uuid",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
profile, err := profileService.GetByUUID(ctx, tt.uuid)
if tt.wantErr {
if err == nil {
t.Error("期望返回错误,但实际没有错误")
}
} else {
if err != nil {
t.Errorf("不期望返回错误: %v", err)
return
}
if profile == nil {
t.Error("返回的Profile不应为nil")
}
if profile.UUID != tt.uuid {
t.Errorf("Profile UUID不匹配: got %v, want %v", profile.UUID, tt.uuid)
}
}
})
}
}
// TestProfileServiceImpl_Delete 测试删除Profile
func TestProfileServiceImpl_Delete(t *testing.T) {
profileRepo := NewMockProfileRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置Profile
testProfile := &model.Profile{
UUID: "delete-test-uuid",
UserID: 1,
Name: "DeleteTestProfile",
}
_ = profileRepo.Create(context.Background(), testProfile)
cacheManager := NewMockCacheManager()
profileService := NewProfileService(profileRepo, userRepo, cacheManager, logger)
tests := []struct {
name string
uuid string
userID int64
wantErr bool
}{
{
name: "正常删除",
uuid: "delete-test-uuid",
userID: 1,
wantErr: false,
},
{
name: "用户ID不匹配",
uuid: "delete-test-uuid",
userID: 2,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
err := profileService.Delete(ctx, tt.uuid, tt.userID)
if tt.wantErr {
if err == nil {
t.Error("期望返回错误,但实际没有错误")
}
} else {
if err != nil {
t.Errorf("不期望返回错误: %v", err)
}
}
})
}
}
// TestProfileServiceImpl_GetByUserID 测试按用户获取档案列表
func TestProfileServiceImpl_GetByUserID(t *testing.T) {
profileRepo := NewMockProfileRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 为用户 1 和 2 预置不同档案
_ = profileRepo.Create(context.Background(), &model.Profile{UUID: "p1", UserID: 1, Name: "P1"})
_ = profileRepo.Create(context.Background(), &model.Profile{UUID: "p2", UserID: 1, Name: "P2"})
_ = profileRepo.Create(context.Background(), &model.Profile{UUID: "p3", UserID: 2, Name: "P3"})
cacheManager := NewMockCacheManager()
svc := NewProfileService(profileRepo, userRepo, cacheManager, logger)
ctx := context.Background()
list, err := svc.GetByUserID(ctx, 1)
if err != nil {
t.Fatalf("GetByUserID 失败: %v", err)
}
if len(list) != 2 {
t.Fatalf("GetByUserID 返回数量错误, got=%d, want=2", len(list))
}
}
// TestProfileServiceImpl_Update 测试 Update
func TestProfileServiceImpl_Update(t *testing.T) {
profileRepo := NewMockProfileRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
profile := &model.Profile{
UUID: "u1",
UserID: 1,
Name: "OldName",
}
_ = profileRepo.Create(context.Background(), profile)
cacheManager := NewMockCacheManager()
svc := NewProfileService(profileRepo, userRepo, cacheManager, logger)
ctx := context.Background()
// 正常更新名称与皮肤/披风
newName := "NewName"
var skinID int64 = 10
var capeID int64 = 20
updated, err := svc.Update(ctx, "u1", 1, &newName, &skinID, &capeID)
if err != nil {
t.Fatalf("Update 正常情况失败: %v", err)
}
if updated == nil || updated.Name != newName {
t.Fatalf("Update 未更新名称, got=%+v", updated)
}
// 用户无权限
if _, err := svc.Update(ctx, "u1", 2, &newName, nil, nil); err == nil {
t.Fatalf("Update 在无权限时应返回错误")
}
// 名称重复
_ = profileRepo.Create(context.Background(), &model.Profile{
UUID: "u2",
UserID: 2,
Name: "Duplicate",
})
if _, err := svc.Update(ctx, "u1", 1, stringPtr("Duplicate"), nil, nil); err == nil {
t.Fatalf("Update 在名称重复时应返回错误")
}
}
// TestProfileServiceImpl_CheckLimit_And_GetByNames 测试 CheckLimit / GetByNames / GetByProfileName
func TestProfileServiceImpl_CheckLimit_And_GetByNames(t *testing.T) {
profileRepo := NewMockProfileRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 为用户 1 预置 2 个档案
_ = profileRepo.Create(context.Background(), &model.Profile{UUID: "a", UserID: 1, Name: "A"})
_ = profileRepo.Create(context.Background(), &model.Profile{UUID: "b", UserID: 1, Name: "B"})
cacheManager := NewMockCacheManager()
svc := NewProfileService(profileRepo, userRepo, cacheManager, logger)
ctx := context.Background()
// CheckLimit 未达上限
if err := svc.CheckLimit(ctx, 1, 3); err != nil {
t.Fatalf("CheckLimit 未达到上限时不应报错: %v", err)
}
// CheckLimit 达到上限
if err := svc.CheckLimit(ctx, 1, 2); err == nil {
t.Fatalf("CheckLimit 达到上限时应报错")
}
// GetByNames
list, err := svc.GetByNames(ctx, []string{"A", "B"})
if err != nil {
t.Fatalf("GetByNames 失败: %v", err)
}
if len(list) != 2 {
t.Fatalf("GetByNames 返回数量错误, got=%d, want=2", len(list))
}
// GetByProfileName 存在
p, err := svc.GetByProfileName(ctx, "A")
if err != nil || p == nil || p.Name != "A" {
t.Fatalf("GetByProfileName 返回错误, profile=%+v, err=%v", p, err)
}
}

View File

@@ -0,0 +1,335 @@
package service
import (
"context"
"errors"
"strconv"
"time"
apperrors "carrotskin/internal/errors"
"carrotskin/internal/model"
"carrotskin/internal/repository"
"go.uber.org/zap"
)
// reportService ReportService的实现
type reportService struct {
reportRepo repository.ReportRepository
userRepo repository.UserRepository
logger *zap.Logger
}
// NewReportService 创建ReportService实例
func NewReportService(
reportRepo repository.ReportRepository,
userRepo repository.UserRepository,
logger *zap.Logger,
) ReportService {
return &reportService{
reportRepo: reportRepo,
userRepo: userRepo,
logger: logger,
}
}
// CreateReport 创建举报
func (s *reportService) CreateReport(ctx context.Context, reporterID int64, targetType model.ReportType, targetID int64, reason string) (*model.Report, error) {
// 验证举报人存在
reporter, err := s.userRepo.FindByID(ctx, reporterID)
if err != nil {
s.logger.Error("举报人不存在", zap.Int64("reporter_id", reporterID), zap.Error(err))
return nil, apperrors.ErrUserNotFound
}
if reporter == nil {
return nil, apperrors.ErrUserNotFound
}
// 验证举报原因
if reason == "" {
return nil, errors.New("举报原因不能为空")
}
if len(reason) > 500 {
return nil, errors.New("举报原因不能超过500字符")
}
// 验证目标类型
if targetType != model.ReportTypeTexture && targetType != model.ReportTypeUser {
return nil, errors.New("无效的举报类型")
}
// 检查是否重复举报
isDuplicate, err := s.reportRepo.CheckDuplicate(ctx, reporterID, targetType, targetID)
if err != nil {
s.logger.Error("检查重复举报失败", zap.Error(err))
return nil, err
}
if isDuplicate {
return nil, errors.New("您已经举报过该对象,请勿重复举报")
}
// 创建举报记录
report := &model.Report{
ReporterID: reporterID,
TargetType: targetType,
TargetID: targetID,
Reason: reason,
Status: model.ReportStatusPending,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.reportRepo.Create(ctx, report); err != nil {
s.logger.Error("创建举报失败", zap.Error(err))
return nil, err
}
s.logger.Info("创建举报成功", zap.Int64("report_id", report.ID), zap.Int64("reporter_id", reporterID))
return report, nil
}
// GetByID 根据ID查询举报
func (s *reportService) GetByID(ctx context.Context, id int64) (*model.Report, error) {
report, err := s.reportRepo.FindByID(ctx, id)
if err != nil {
s.logger.Error("查询举报失败", zap.Int64("report_id", id), zap.Error(err))
return nil, err
}
return report, nil
}
// GetByReporterID 根据举报人ID查询举报记录
func (s *reportService) GetByReporterID(ctx context.Context, reporterID, userID int64, page, pageSize int) ([]*model.Report, int64, error) {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return nil, 0, err
}
if user == nil {
return nil, 0, apperrors.ErrUserNotFound
}
// 只有本人或管理员可以查看自己的举报记录
if reporterID != userID && !(user.Role == "admin") {
return nil, 0, errors.New("无权查看其他用户的举报记录")
}
reports, total, err := s.reportRepo.FindByReporterID(ctx, reporterID, page, pageSize)
if err != nil {
s.logger.Error("查询举报记录失败", zap.Error(err))
return nil, 0, err
}
return reports, total, nil
}
// GetByTarget 根据目标对象查询举报记录
func (s *reportService) GetByTarget(ctx context.Context, targetType model.ReportType, targetID, userID int64, page, pageSize int) ([]*model.Report, int64, error) {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return nil, 0, err
}
if user == nil {
return nil, 0, apperrors.ErrUserNotFound
}
// 只有管理员可以查看目标对象的举报记录
if !(user.Role == "admin") {
return nil, 0, errors.New("无权查看举报记录")
}
reports, total, err := s.reportRepo.FindByTarget(ctx, targetType, targetID, page, pageSize)
if err != nil {
s.logger.Error("查询举报记录失败", zap.Error(err))
return nil, 0, err
}
return reports, total, nil
}
// GetByStatus 根据状态查询举报记录
func (s *reportService) GetByStatus(ctx context.Context, status model.ReportStatus, page, pageSize int) ([]*model.Report, int64, error) {
reports, total, err := s.reportRepo.FindByStatus(ctx, status, page, pageSize)
if err != nil {
s.logger.Error("查询举报记录失败", zap.Error(err))
return nil, 0, err
}
return reports, total, nil
}
// Search 搜索举报记录
func (s *reportService) Search(ctx context.Context, keyword, userID int64, page, pageSize int) ([]*model.Report, int64, error) {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return nil, 0, err
}
if user == nil {
return nil, 0, apperrors.ErrUserNotFound
}
// 只有管理员可以搜索举报记录
if !(user.Role == "admin") {
return nil, 0, errors.New("无权搜索举报记录")
}
reports, total, err := s.reportRepo.Search(ctx, strconv.FormatInt(keyword, 10), page, pageSize)
if err != nil {
s.logger.Error("搜索举报记录失败", zap.Error(err))
return nil, 0, err
}
return reports, total, nil
}
// Review 处理举报记录
func (s *reportService) Review(ctx context.Context, reportID, reviewerID int64, status model.ReportStatus, reviewNote string) (*model.Report, error) {
// 验证处理人存在且是管理员
reviewer, err := s.userRepo.FindByID(ctx, reviewerID)
if err != nil {
s.logger.Error("处理人不存在", zap.Int64("reviewer_id", reviewerID), zap.Error(err))
return nil, apperrors.ErrUserNotFound
}
if reviewer == nil || !(reviewer.Role == "admin") {
return nil, errors.New("只有管理员可以处理举报")
}
// 验证状态
if status != model.ReportStatusApproved && status != model.ReportStatusRejected {
return nil, errors.New("无效的举报处理状态")
}
// 处理举报
if err := s.reportRepo.Review(ctx, reportID, status, reviewerID, reviewNote); err != nil {
s.logger.Error("处理举报失败", zap.Int64("report_id", reportID), zap.Error(err))
return nil, err
}
// 返回更新后的举报记录
report, err := s.reportRepo.FindByID(ctx, reportID)
if err != nil {
s.logger.Error("查询举报记录失败", zap.Int64("report_id", reportID), zap.Error(err))
return nil, err
}
s.logger.Info("处理举报成功", zap.Int64("report_id", reportID), zap.Int64("reviewer_id", reviewerID), zap.String("status", string(status)))
return report, nil
}
// BatchReview 批量处理举报记录
func (s *reportService) BatchReview(ctx context.Context, ids []int64, reviewerID int64, status model.ReportStatus, reviewNote string) (int64, error) {
// 验证处理人存在且是管理员
reviewer, err := s.userRepo.FindByID(ctx, reviewerID)
if err != nil {
s.logger.Error("处理人不存在", zap.Int64("reviewer_id", reviewerID), zap.Error(err))
return 0, apperrors.ErrUserNotFound
}
if reviewer == nil || !(reviewer.Role == "admin") {
return 0, errors.New("只有管理员可以处理举报")
}
// 验证状态
if status != model.ReportStatusApproved && status != model.ReportStatusRejected {
return 0, errors.New("无效的举报处理状态")
}
// 批量处理举报
affected, err := s.reportRepo.BatchReview(ctx, ids, status, reviewerID, reviewNote)
if err != nil {
s.logger.Error("批量处理举报失败", zap.Error(err))
return 0, err
}
s.logger.Info("批量处理举报成功", zap.Int("count", int(affected)), zap.Int64("reviewer_id", reviewerID), zap.String("status", string(status)))
return affected, nil
}
// Delete 删除举报记录
func (s *reportService) Delete(ctx context.Context, reportID, userID int64) error {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return err
}
if user == nil {
return apperrors.ErrUserNotFound
}
// 查询举报记录
report, err := s.reportRepo.FindByID(ctx, reportID)
if err != nil {
return err
}
if report == nil {
return errors.New("举报记录不存在")
}
// 只有举报人、管理员或处理人可以删除举报记录
if report.ReporterID != userID && !(user.Role == "admin") && (report.ReviewerID == nil || *report.ReviewerID != userID) {
return errors.New("无权删除此举报记录")
}
if err := s.reportRepo.Delete(ctx, reportID); err != nil {
s.logger.Error("删除举报记录失败", zap.Int64("report_id", reportID), zap.Error(err))
return err
}
s.logger.Info("删除举报记录成功", zap.Int64("report_id", reportID))
return nil
}
// BatchDelete 批量删除举报记录
func (s *reportService) BatchDelete(ctx context.Context, ids []int64, userID int64) (int64, error) {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return 0, err
}
if user == nil {
return 0, apperrors.ErrUserNotFound
}
// 只有管理员可以批量删除
if !(user.Role == "admin") {
return 0, errors.New("无权批量删除举报记录")
}
affected, err := s.reportRepo.BatchDelete(ctx, ids)
if err != nil {
s.logger.Error("批量删除举报记录失败", zap.Error(err))
return 0, err
}
s.logger.Info("批量删除举报记录成功", zap.Int("count", int(affected)))
return affected, nil
}
// GetStats 获取举报统计信息
func (s *reportService) GetStats(ctx context.Context) (map[string]int64, error) {
stats := make(map[string]int64)
// 统计各状态的举报数量
pendingCount, err := s.reportRepo.CountByStatus(ctx, model.ReportStatusPending)
if err != nil {
return nil, err
}
stats["pending"] = pendingCount
approvedCount, err := s.reportRepo.CountByStatus(ctx, model.ReportStatusApproved)
if err != nil {
return nil, err
}
stats["approved"] = approvedCount
rejectedCount, err := s.reportRepo.CountByStatus(ctx, model.ReportStatusRejected)
if err != nil {
return nil, err
}
stats["rejected"] = rejectedCount
stats["total"] = pendingCount + approvedCount + rejectedCount
return stats, nil
}

View File

@@ -0,0 +1,184 @@
package service
import (
"context"
"fmt"
"time"
"carrotskin/pkg/redis"
)
const (
// 登录失败限制配置
MaxLoginAttempts = 5 // 最大登录失败次数
LoginLockDuration = 15 * time.Minute // 账号锁定时间
LoginAttemptWindow = 10 * time.Minute // 失败次数统计窗口
// 验证码错误限制配置
MaxVerifyAttempts = 5 // 最大验证码错误次数
VerifyLockDuration = 30 * time.Minute // 验证码锁定时间
// Redis Key 前缀
LoginAttemptKeyPrefix = "security:login_attempt:"
LoginLockedKeyPrefix = "security:login_locked:"
VerifyAttemptKeyPrefix = "security:verify_attempt:"
VerifyLockedKeyPrefix = "security:verify_locked:"
)
// securityService SecurityService的实现
type securityService struct {
redis *redis.Client
}
// NewSecurityService 创建SecurityService实例
func NewSecurityService(redisClient *redis.Client) SecurityService {
return &securityService{
redis: redisClient,
}
}
// CheckLoginLocked 检查账号是否被锁定
func (s *securityService) CheckLoginLocked(ctx context.Context, identifier string) (bool, time.Duration, error) {
key := LoginLockedKeyPrefix + identifier
ttl, err := s.redis.TTL(ctx, key)
if err != nil {
return false, 0, err
}
if ttl > 0 {
return true, ttl, nil
}
return false, 0, nil
}
// RecordLoginFailure 记录登录失败
func (s *securityService) RecordLoginFailure(ctx context.Context, identifier string) (int, error) {
attemptKey := LoginAttemptKeyPrefix + identifier
// 增加失败次数
count, err := s.redis.Incr(ctx, attemptKey)
if err != nil {
return 0, fmt.Errorf("记录登录失败次数失败: %w", err)
}
// 设置过期时间(仅在第一次设置)
if count == 1 {
if err := s.redis.Expire(ctx, attemptKey, LoginAttemptWindow); err != nil {
return int(count), fmt.Errorf("设置过期时间失败: %w", err)
}
}
// 如果超过最大次数,锁定账号
if count >= MaxLoginAttempts {
lockedKey := LoginLockedKeyPrefix + identifier
if err := s.redis.Set(ctx, lockedKey, "1", LoginLockDuration); err != nil {
return int(count), fmt.Errorf("锁定账号失败: %w", err)
}
// 清除失败计数
_ = s.redis.Del(ctx, attemptKey)
}
return int(count), nil
}
// ClearLoginAttempts 清除登录失败记录(登录成功后调用)
func (s *securityService) ClearLoginAttempts(ctx context.Context, identifier string) error {
attemptKey := LoginAttemptKeyPrefix + identifier
return s.redis.Del(ctx, attemptKey)
}
// GetRemainingLoginAttempts 获取剩余登录尝试次数
func (s *securityService) GetRemainingLoginAttempts(ctx context.Context, identifier string) (int, error) {
attemptKey := LoginAttemptKeyPrefix + identifier
countStr, err := s.redis.Get(ctx, attemptKey)
if err != nil {
// key 不存在,返回最大次数
return MaxLoginAttempts, nil
}
var count int
fmt.Sscanf(countStr, "%d", &count)
remaining := MaxLoginAttempts - count
if remaining < 0 {
remaining = 0
}
return remaining, nil
}
// CheckVerifyLocked 检查验证码是否被锁定
func (s *securityService) CheckVerifyLocked(ctx context.Context, email, codeType string) (bool, time.Duration, error) {
key := VerifyLockedKeyPrefix + codeType + ":" + email
ttl, err := s.redis.TTL(ctx, key)
if err != nil {
return false, 0, err
}
if ttl > 0 {
return true, ttl, nil
}
return false, 0, nil
}
// RecordVerifyFailure 记录验证码验证失败
func (s *securityService) RecordVerifyFailure(ctx context.Context, email, codeType string) (int, error) {
attemptKey := VerifyAttemptKeyPrefix + codeType + ":" + email
// 增加失败次数
count, err := s.redis.Incr(ctx, attemptKey)
if err != nil {
return 0, fmt.Errorf("记录验证码失败次数失败: %w", err)
}
// 设置过期时间
if count == 1 {
if err := s.redis.Expire(ctx, attemptKey, VerifyLockDuration); err != nil {
return int(count), err
}
}
// 如果超过最大次数,锁定验证
if count >= MaxVerifyAttempts {
lockedKey := VerifyLockedKeyPrefix + codeType + ":" + email
if err := s.redis.Set(ctx, lockedKey, "1", VerifyLockDuration); err != nil {
return int(count), err
}
_ = s.redis.Del(ctx, attemptKey)
}
return int(count), nil
}
// ClearVerifyAttempts 清除验证码失败记录(验证成功后调用)
func (s *securityService) ClearVerifyAttempts(ctx context.Context, email, codeType string) error {
attemptKey := VerifyAttemptKeyPrefix + codeType + ":" + email
return s.redis.Del(ctx, attemptKey)
}
// 全局函数,保持向后兼容,用于已存在的代码
func CheckLoginLocked(ctx context.Context, redisClient *redis.Client, identifier string) (bool, time.Duration, error) {
svc := NewSecurityService(redisClient)
return svc.CheckLoginLocked(ctx, identifier)
}
func RecordLoginFailure(ctx context.Context, redisClient *redis.Client, identifier string) (int, error) {
svc := NewSecurityService(redisClient)
return svc.RecordLoginFailure(ctx, identifier)
}
func ClearLoginAttempts(ctx context.Context, redisClient *redis.Client, identifier string) error {
svc := NewSecurityService(redisClient)
return svc.ClearLoginAttempts(ctx, identifier)
}
func CheckVerifyLocked(ctx context.Context, redisClient *redis.Client, email, codeType string) (bool, time.Duration, error) {
svc := NewSecurityService(redisClient)
return svc.CheckVerifyLocked(ctx, email, codeType)
}
func RecordVerifyFailure(ctx context.Context, redisClient *redis.Client, email, codeType string) (int, error) {
svc := NewSecurityService(redisClient)
return svc.RecordVerifyFailure(ctx, email, codeType)
}
func ClearVerifyAttempts(ctx context.Context, redisClient *redis.Client, email, codeType string) error {
svc := NewSecurityService(redisClient)
return svc.ClearVerifyAttempts(ctx, email, codeType)
}

View File

@@ -1,97 +0,0 @@
package service
import (
"carrotskin/internal/model"
"carrotskin/pkg/redis"
"encoding/base64"
"go.uber.org/zap"
"time"
"gorm.io/gorm"
)
type Property struct {
Name string `json:"name"`
Value string `json:"value"`
Signature string `json:"signature,omitempty"`
}
func SerializeProfile(db *gorm.DB, logger *zap.Logger, redisClient *redis.Client, p model.Profile) map[string]interface{} {
var err error
// 创建基本材质数据
texturesMap := make(map[string]interface{})
textures := map[string]interface{}{
"timestamp": time.Now().UnixMilli(),
"profileId": p.UUID,
"profileName": p.Name,
"textures": texturesMap,
}
// 处理皮肤
if p.SkinID != nil {
skin, err := GetTextureByID(db, *p.SkinID)
if err != nil {
logger.Error("[ERROR] 获取皮肤失败:", zap.Error(err), zap.Any("SkinID:", *p.SkinID))
} else {
texturesMap["SKIN"] = map[string]interface{}{
"url": skin.URL,
"metadata": skin.Size,
}
}
}
// 处理披风
if p.CapeID != nil {
cape, err := GetTextureByID(db, *p.CapeID)
if err != nil {
logger.Error("[ERROR] 获取披风失败:", zap.Error(err), zap.Any("capeID:", *p.CapeID))
} else {
texturesMap["CAPE"] = map[string]interface{}{
"url": cape.URL,
"metadata": cape.Size,
}
}
}
// 将textures编码为base64
bytes, err := json.Marshal(textures)
if err != nil {
logger.Error("[ERROR] 序列化textures失败: ", zap.Error(err))
return nil
}
textureData := base64.StdEncoding.EncodeToString(bytes)
signature, err := SignStringWithSHA1withRSA(logger, redisClient, textureData)
if err != nil {
logger.Error("[ERROR] 签名textures失败: ", zap.Error(err))
return nil
}
// 构建结果
data := map[string]interface{}{
"id": p.UUID,
"name": p.Name,
"properties": []Property{
{
Name: "textures",
Value: textureData,
Signature: signature,
},
},
}
return data
}
func SerializeUser(logger *zap.Logger, u *model.User, UUID string) map[string]interface{} {
if u == nil {
logger.Error("[ERROR] 尝试序列化空用户")
return nil
}
data := map[string]interface{}{
"id": UUID,
"properties": u.Properties,
}
return data
}

View File

@@ -1,172 +0,0 @@
package service
import (
"carrotskin/internal/model"
"testing"
"go.uber.org/zap/zaptest"
)
// TestSerializeUser_NilUser 实际调用SerializeUser函数测试nil用户
func TestSerializeUser_NilUser(t *testing.T) {
logger := zaptest.NewLogger(t)
result := SerializeUser(logger, nil, "test-uuid")
if result != nil {
t.Error("SerializeUser() 对于nil用户应返回nil")
}
}
// TestSerializeUser_ActualCall 实际调用SerializeUser函数
func TestSerializeUser_ActualCall(t *testing.T) {
logger := zaptest.NewLogger(t)
user := &model.User{
ID: 1,
Username: "testuser",
Email: "test@example.com",
Properties: "{}",
}
result := SerializeUser(logger, user, "test-uuid-123")
if result == nil {
t.Fatal("SerializeUser() 返回的结果不应为nil")
}
if result["id"] != "test-uuid-123" {
t.Errorf("id = %v, want 'test-uuid-123'", result["id"])
}
if result["properties"] == nil {
t.Error("properties 不应为nil")
}
}
// TestProperty_Structure 测试Property结构
func TestProperty_Structure(t *testing.T) {
prop := Property{
Name: "textures",
Value: "base64value",
Signature: "signature",
}
if prop.Name == "" {
t.Error("Property name should not be empty")
}
if prop.Value == "" {
t.Error("Property value should not be empty")
}
// Signature是可选的
if prop.Signature == "" {
t.Log("Property signature is optional")
}
}
// TestSerializeService_PropertyFields 测试Property字段
func TestSerializeService_PropertyFields(t *testing.T) {
tests := []struct {
name string
property Property
wantValid bool
}{
{
name: "有效的Property",
property: Property{
Name: "textures",
Value: "base64value",
Signature: "signature",
},
wantValid: true,
},
{
name: "缺少Name的Property",
property: Property{
Name: "",
Value: "base64value",
Signature: "signature",
},
wantValid: false,
},
{
name: "缺少Value的Property",
property: Property{
Name: "textures",
Value: "",
Signature: "signature",
},
wantValid: false,
},
{
name: "没有Signature的Property有效",
property: Property{
Name: "textures",
Value: "base64value",
Signature: "",
},
wantValid: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.property.Name != "" && tt.property.Value != ""
if isValid != tt.wantValid {
t.Errorf("Property validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestSerializeUser_InputValidation 测试SerializeUser输入验证
func TestSerializeUser_InputValidation(t *testing.T) {
tests := []struct {
name string
user *struct{}
wantValid bool
}{
{
name: "用户不为nil",
user: &struct{}{},
wantValid: true,
},
{
name: "用户为nil",
user: nil,
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.user != nil
if isValid != tt.wantValid {
t.Errorf("Input validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestSerializeProfile_Structure 测试SerializeProfile返回结构
func TestSerializeProfile_Structure(t *testing.T) {
// 测试返回的数据结构应该包含的字段
expectedFields := []string{"id", "name", "properties"}
// 验证字段名称
for _, field := range expectedFields {
if field == "" {
t.Error("Field name should not be empty")
}
}
// 验证properties应该是数组
// 注意:这里只测试逻辑,不测试实际序列化
}
// TestSerializeProfile_PropertyName 测试Property名称
func TestSerializeProfile_PropertyName(t *testing.T) {
// textures是固定的属性名
propertyName := "textures"
if propertyName != "textures" {
t.Errorf("Property name = %s, want 'textures'", propertyName)
}
}

View File

@@ -14,592 +14,263 @@ import (
"encoding/binary"
"encoding/pem"
"fmt"
"go.uber.org/zap"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"go.uber.org/zap"
)
// 常量定义
const (
// RSA密钥长度
RSAKeySize = 4096
// Redis密钥名称
PrivateKeyRedisKey = "private_key"
PublicKeyRedisKey = "public_key"
// 密钥过期时间
KeyExpirationTime = time.Hour * 24 * 7
// 证书相关
CertificateRefreshInterval = time.Hour * 24 // 证书刷新时间间隔
CertificateExpirationPeriod = time.Hour * 24 * 7 // 证书过期时间
KeySize = 4096
ExpirationDays = 90
RefreshDays = 60
PublicKeyRedisKey = "yggdrasil:public_key"
PrivateKeyRedisKey = "yggdrasil:private_key"
KeyExpirationRedisKey = "yggdrasil:key_expiration"
RedisTTL = 0 // 永不过期,由应用程序管理过期时间
)
// PlayerCertificate 表示玩家证书信息
type PlayerCertificate struct {
ExpiresAt string `json:"expiresAt"`
RefreshedAfter string `json:"refreshedAfter"`
PublicKeySignature string `json:"publicKeySignature,omitempty"`
PublicKeySignatureV2 string `json:"publicKeySignatureV2,omitempty"`
KeyPair struct {
PrivateKey string `json:"privateKey"`
PublicKey string `json:"publicKey"`
} `json:"keyPair"`
}
// SignatureService 保留结构体以保持向后兼容,但推荐使用函数式版本
// SignatureService 签名服务(导出以便依赖注入)
type SignatureService struct {
profileRepo repository.ProfileRepository
redis *redis.Client
logger *zap.Logger
redisClient *redis.Client
}
func NewSignatureService(logger *zap.Logger, redisClient *redis.Client) *SignatureService {
// NewSignatureService 创建SignatureService实例
func NewSignatureService(
profileRepo repository.ProfileRepository,
redisClient *redis.Client,
logger *zap.Logger,
) *SignatureService {
return &SignatureService{
profileRepo: profileRepo,
redis: redisClient,
logger: logger,
redisClient: redisClient,
}
}
// SignStringWithSHA1withRSA 使用SHA1withRSA签名字符串并返回Base64编码的签名函数式版本
func SignStringWithSHA1withRSA(logger *zap.Logger, redisClient *redis.Client, data string) (string, error) {
if data == "" {
return "", fmt.Errorf("签名数据不能为空")
}
// 获取私钥
privateKey, err := DecodePrivateKeyFromPEM(logger, redisClient)
// NewKeyPair 生成新的RSA密钥对
func (s *SignatureService) NewKeyPair() (*model.KeyPair, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, KeySize)
if err != nil {
logger.Error("[ERROR] 解码私钥失败: ", zap.Error(err))
return "", fmt.Errorf("解码私钥失败: %w", err)
return nil, fmt.Errorf("生成RSA密钥对失败: %w", err)
}
// 计算SHA1哈希
hashed := sha1.Sum([]byte(data))
// 获取公钥
publicKey := &privateKey.PublicKey
// 使用RSA-PKCS1v15算法签名
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
if err != nil {
logger.Error("[ERROR] RSA签名失败: ", zap.Error(err))
return "", fmt.Errorf("RSA签名失败: %w", err)
}
// Base64编码签名
encodedSignature := base64.StdEncoding.EncodeToString(signature)
logger.Info("[INFO] 成功使用SHA1withRSA生成签名,", zap.Any("数据长度:", len(data)))
return encodedSignature, nil
}
// SignStringWithSHA1withRSAService 使用SHA1withRSA签名字符串并返回Base64编码的签名结构体方法版本保持向后兼容
func (s *SignatureService) SignStringWithSHA1withRSA(data string) (string, error) {
return SignStringWithSHA1withRSA(s.logger, s.redisClient, data)
}
// DecodePrivateKeyFromPEM 从Redis获取并解码PEM格式的私钥函数式版本
func DecodePrivateKeyFromPEM(logger *zap.Logger, redisClient *redis.Client) (*rsa.PrivateKey, error) {
// 从Redis获取私钥
privateKeyString, err := GetPrivateKeyFromRedis(logger, redisClient)
if err != nil {
return nil, fmt.Errorf("从Redis获取私钥失败: %w", err)
}
// 解码PEM格式
privateKeyBlock, rest := pem.Decode([]byte(privateKeyString))
if privateKeyBlock == nil || len(rest) > 0 {
logger.Error("[ERROR] 无效的PEM格式私钥")
return nil, fmt.Errorf("无效的PEM格式私钥")
}
// 解析PKCS1格式的私钥
privateKey, err := x509.ParsePKCS1PrivateKey(privateKeyBlock.Bytes)
if err != nil {
logger.Error("[ERROR] 解析私钥失败: ", zap.Error(err))
return nil, fmt.Errorf("解析私钥失败: %w", err)
}
return privateKey, nil
}
// GetPrivateKeyFromRedis 从Redis获取私钥PEM格式函数式版本
func GetPrivateKeyFromRedis(logger *zap.Logger, redisClient *redis.Client) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancel()
pemBytes, err := redisClient.GetBytes(ctx, PrivateKeyRedisKey)
if err != nil {
logger.Info("[INFO] 从Redis获取私钥失败尝试生成新的密钥对: ", zap.Error(err))
// 生成新的密钥对
err = GenerateRSAKeyPair(logger, redisClient)
if err != nil {
logger.Error("[ERROR] 生成RSA密钥对失败: ", zap.Error(err))
return "", fmt.Errorf("生成RSA密钥对失败: %w", err)
}
// 递归获取生成的密钥
return GetPrivateKeyFromRedis(logger, redisClient)
}
return string(pemBytes), nil
}
// DecodePrivateKeyFromPEMService 从Redis获取并解码PEM格式的私钥结构体方法版本保持向后兼容
func (s *SignatureService) DecodePrivateKeyFromPEM() (*rsa.PrivateKey, error) {
return DecodePrivateKeyFromPEM(s.logger, s.redisClient)
}
// GetPrivateKeyFromRedisService 从Redis获取私钥PEM格式结构体方法版本保持向后兼容
func (s *SignatureService) GetPrivateKeyFromRedis() (string, error) {
return GetPrivateKeyFromRedis(s.logger, s.redisClient)
}
// GenerateRSAKeyPair 生成新的RSA密钥对函数式版本
func GenerateRSAKeyPair(logger *zap.Logger, redisClient *redis.Client) error {
logger.Info("[INFO] 开始生成RSA密钥对", zap.Int("keySize", RSAKeySize))
// 生成私钥
privateKey, err := rsa.GenerateKey(rand.Reader, RSAKeySize)
if err != nil {
logger.Error("[ERROR] 生成RSA私钥失败: ", zap.Error(err))
return fmt.Errorf("生成RSA私钥失败: %w", err)
}
// 编码私钥为PEM格式
pemPrivateKey, err := EncodePrivateKeyToPEM(privateKey)
if err != nil {
logger.Error("[ERROR] 编码RSA私钥失败: ", zap.Error(err))
return fmt.Errorf("编码RSA私钥失败: %w", err)
}
// 获取公钥并编码为PEM格式
pubKey := privateKey.PublicKey
pemPublicKey, err := EncodePublicKeyToPEM(logger, &pubKey)
if err != nil {
logger.Error("[ERROR] 编码RSA公钥失败: ", zap.Error(err))
return fmt.Errorf("编码RSA公钥失败: %w", err)
}
// 保存密钥对到Redis
return SaveKeyPairToRedis(logger, redisClient, string(pemPrivateKey), string(pemPublicKey))
}
// GenerateRSAKeyPairService 生成新的RSA密钥对结构体方法版本保持向后兼容
func (s *SignatureService) GenerateRSAKeyPair() error {
return GenerateRSAKeyPair(s.logger, s.redisClient)
}
// EncodePrivateKeyToPEM 将私钥编码为PEM格式函数式版本
func EncodePrivateKeyToPEM(privateKey *rsa.PrivateKey, keyType ...string) ([]byte, error) {
if privateKey == nil {
return nil, fmt.Errorf("私钥不能为空")
}
// 默认使用 "PRIVATE KEY" 类型
pemType := "PRIVATE KEY"
// 如果指定了类型参数且为 "RSA",则使用 "RSA PRIVATE KEY"
if len(keyType) > 0 && keyType[0] == "RSA" {
pemType = "RSA PRIVATE KEY"
}
// 将私钥转换为PKCS1格式
// PEM编码私钥
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
// 编码为PEM格式
pemBlock := &pem.Block{
Type: pemType,
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privateKeyBytes,
})
// PEM编码公钥
publicKeyBytes, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
return nil, fmt.Errorf("编码公钥失败: %w", err)
}
return pem.EncodeToMemory(pemBlock), nil
}
// EncodePublicKeyToPEM 将公钥编码为PEM格式函数式版本
func EncodePublicKeyToPEM(logger *zap.Logger, publicKey *rsa.PublicKey, keyType ...string) ([]byte, error) {
if publicKey == nil {
return nil, fmt.Errorf("公钥不能为空")
}
// 默认使用 "PUBLIC KEY" 类型
pemType := "PUBLIC KEY"
var publicKeyBytes []byte
var err error
// 如果指定了类型参数且为 "RSA",则使用 "RSA PUBLIC KEY"
if len(keyType) > 0 && keyType[0] == "RSA" {
pemType = "RSA PUBLIC KEY"
publicKeyBytes = x509.MarshalPKCS1PublicKey(publicKey)
} else {
// 默认将公钥转换为PKIX格式
publicKeyBytes, err = x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
logger.Error("[ERROR] 序列化公钥失败: ", zap.Error(err))
return nil, fmt.Errorf("序列化公钥失败: %w", err)
}
}
// 编码为PEM格式
pemBlock := &pem.Block{
Type: pemType,
publicKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: publicKeyBytes,
}
})
return pem.EncodeToMemory(pemBlock), nil
}
// SaveKeyPairToRedis 将RSA密钥对保存到Redis函数式版本
func SaveKeyPairToRedis(logger *zap.Logger, redisClient *redis.Client, privateKey, publicKey string) error {
// 创建上下文并设置超时
ctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancel()
// 使用事务确保两个操作的原子性
tx := redisClient.TxPipeline()
tx.Set(ctx, PrivateKeyRedisKey, privateKey, KeyExpirationTime)
tx.Set(ctx, PublicKeyRedisKey, publicKey, KeyExpirationTime)
// 执行事务
_, err := tx.Exec(ctx)
if err != nil {
logger.Error("[ERROR] 保存RSA密钥对到Redis失败: ", zap.Error(err))
return fmt.Errorf("保存RSA密钥对到Redis失败: %w", err)
}
logger.Info("[INFO] 成功保存RSA密钥对到Redis")
return nil
}
// EncodePrivateKeyToPEMService 将私钥编码为PEM格式结构体方法版本保持向后兼容
func (s *SignatureService) EncodePrivateKeyToPEM(privateKey *rsa.PrivateKey, keyType ...string) ([]byte, error) {
return EncodePrivateKeyToPEM(privateKey, keyType...)
}
// EncodePublicKeyToPEMService 将公钥编码为PEM格式结构体方法版本保持向后兼容
func (s *SignatureService) EncodePublicKeyToPEM(publicKey *rsa.PublicKey, keyType ...string) ([]byte, error) {
return EncodePublicKeyToPEM(s.logger, publicKey, keyType...)
}
// SaveKeyPairToRedisService 将RSA密钥对保存到Redis结构体方法版本保持向后兼容
func (s *SignatureService) SaveKeyPairToRedis(privateKey, publicKey string) error {
return SaveKeyPairToRedis(s.logger, s.redisClient, privateKey, publicKey)
}
// GetPublicKeyFromRedisFunc 从Redis获取公钥PEM格式函数式版本
func GetPublicKeyFromRedisFunc(logger *zap.Logger, redisClient *redis.Client) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancel()
pemBytes, err := redisClient.GetBytes(ctx, PublicKeyRedisKey)
if err != nil {
logger.Info("[INFO] 从Redis获取公钥失败尝试生成新的密钥对: ", zap.Error(err))
// 生成新的密钥对
err = GenerateRSAKeyPair(logger, redisClient)
if err != nil {
logger.Error("[ERROR] 生成RSA密钥对失败: ", zap.Error(err))
return "", fmt.Errorf("生成RSA密钥对失败: %w", err)
}
// 递归获取生成的密钥
return GetPublicKeyFromRedisFunc(logger, redisClient)
}
// 检查获取到的公钥是否为空key不存在时GetBytes返回nil, nil
if len(pemBytes) == 0 {
logger.Info("[INFO] Redis中公钥为空尝试生成新的密钥对")
// 生成新的密钥对
err = GenerateRSAKeyPair(logger, redisClient)
if err != nil {
logger.Error("[ERROR] 生成RSA密钥对失败: ", zap.Error(err))
return "", fmt.Errorf("生成RSA密钥对失败: %w", err)
}
// 递归获取生成的密钥
return GetPublicKeyFromRedisFunc(logger, redisClient)
}
return string(pemBytes), nil
}
// GetPublicKeyFromRedis 从Redis获取公钥PEM格式结构体方法版本
func (s *SignatureService) GetPublicKeyFromRedis() (string, error) {
return GetPublicKeyFromRedisFunc(s.logger, s.redisClient)
}
// GeneratePlayerCertificate 生成玩家证书(函数式版本)
func GeneratePlayerCertificate(db *gorm.DB, logger *zap.Logger, redisClient *redis.Client, uuid string) (*PlayerCertificate, error) {
if uuid == "" {
return nil, fmt.Errorf("UUID不能为空")
}
logger.Info("[INFO] 开始生成玩家证书用户UUID: %s",
zap.String("uuid", uuid),
)
keyPair, err := repository.GetProfileKeyPair(uuid)
if err != nil {
logger.Info("[INFO] 获取用户密钥对失败,将创建新密钥对: %v",
zap.Error(err),
zap.String("uuid", uuid),
)
keyPair = nil
}
// 如果没有找到密钥对或密钥对已过期,创建一个新的
// 计算时间
now := time.Now().UTC()
if keyPair == nil || keyPair.Refresh.Before(now) || keyPair.PrivateKey == "" || keyPair.PublicKey == "" {
logger.Info("[INFO] 为用户创建新的密钥对: %s",
zap.String("uuid", uuid),
)
keyPair, err = NewKeyPair(logger)
if err != nil {
logger.Error("[ERROR] 生成玩家证书密钥对失败: %v",
zap.Error(err),
zap.String("uuid", uuid),
)
return nil, fmt.Errorf("生成玩家证书密钥对失败: %w", err)
}
// 保存密钥对到数据库
err = repository.UpdateProfileKeyPair(uuid, keyPair)
if err != nil {
// 日志修改logger → s.loggerzap结构化字段
logger.Warn("[WARN] 更新用户密钥对失败: %v",
zap.Error(err),
zap.String("uuid", uuid),
)
// 继续执行,即使保存失败
}
}
expiration := now.AddDate(0, 0, ExpirationDays)
refresh := now.AddDate(0, 0, RefreshDays)
// 计算expiresAt的毫秒时间戳
expiresAtMillis := keyPair.Expiration.UnixMilli()
// 准备签名
publicKeySignature := ""
publicKeySignatureV2 := ""
// 获取服务器私钥用于签名
serverPrivateKey, err := DecodePrivateKeyFromPEM(logger, redisClient)
// 获取Yggdrasil根密钥并签名公钥
yggPublicKey, yggPrivateKey, err := s.GetOrCreateYggdrasilKeyPair()
if err != nil {
// 日志修改logger → s.loggerzap结构化字段
logger.Error("[ERROR] 获取服务器私钥失败: %v",
zap.Error(err),
zap.String("uuid", uuid),
)
return nil, fmt.Errorf("获取服务器私钥失败: %w", err)
return nil, fmt.Errorf("获取Yggdrasil根密钥失败: %w", err)
}
// 提取公钥DER编码
pubPEMBlock, _ := pem.Decode([]byte(keyPair.PublicKey))
if pubPEMBlock == nil {
// 日志修改logger → s.loggerzap结构化字段
logger.Error("[ERROR] 解码公钥PEM失败",
zap.String("uuid", uuid),
zap.String("publicKey", keyPair.PublicKey),
)
return nil, fmt.Errorf("解码公钥PEM失败")
}
pubDER := pubPEMBlock.Bytes
// 构造签名消息
expiresAtMillis := expiration.UnixMilli()
message := []byte(string(publicKeyPEM) + strconv.FormatInt(expiresAtMillis, 10))
// 准备publicKeySignature用于MC 1.19
// Base64编码公钥不包含换行
pubBase64 := strings.ReplaceAll(base64.StdEncoding.EncodeToString(pubDER), "\n", "")
// 按76字符一行进行包装
pubBase64Wrapped := WrapString(pubBase64, 76)
// 放入PEM格式
pubMojangPEM := "-----BEGIN RSA PUBLIC KEY-----\n" +
pubBase64Wrapped +
"\n-----END RSA PUBLIC KEY-----\n"
// 签名数据: expiresAt毫秒时间戳 + 公钥PEM格式
signedData := []byte(fmt.Sprintf("%d%s", expiresAtMillis, pubMojangPEM))
// 计算SHA1哈希并签名
hash1 := sha1.Sum(signedData)
signature, err := rsa.SignPKCS1v15(rand.Reader, serverPrivateKey, crypto.SHA1, hash1[:])
// 使用SHA1withRSA签名
hashed := sha1.Sum(message)
signature, err := rsa.SignPKCS1v15(rand.Reader, yggPrivateKey, crypto.SHA1, hashed[:])
if err != nil {
logger.Error("[ERROR] 签名失败: %v",
zap.Error(err),
zap.String("uuid", uuid),
zap.Int64("expiresAtMillis", expiresAtMillis),
)
return nil, fmt.Errorf("签名失败: %w", err)
}
publicKeySignature = base64.StdEncoding.EncodeToString(signature)
publicKeySignature := base64.StdEncoding.EncodeToString(signature)
// 准备publicKeySignatureV2用于MC 1.19.1+
var uuidBytes []byte
// 如果提供了UUID则使用它
// 移除UUID中的连字符
uuidStr := strings.ReplaceAll(uuid, "-", "")
// 将UUID转换为字节数组16字节
if len(uuidStr) < 32 {
logger.Warn("[WARN] UUID长度不足32字符使用空UUID: %s",
zap.String("uuid", uuid),
zap.String("processedUuidStr", uuidStr),
)
uuidBytes = make([]byte, 16)
} else {
// 解析UUID字符串为字节
uuidBytes = make([]byte, 16)
parseErr := error(nil)
for i := 0; i < 16; i++ {
// 每两个字符转换为一个字节
byteStr := uuidStr[i*2 : i*2+2]
byteVal, err := strconv.ParseUint(byteStr, 16, 8)
if err != nil {
parseErr = err
logger.Error("[ERROR] 解析UUID字节失败: %v, byteStr: %s",
zap.Error(err),
zap.String("uuid", uuid),
zap.String("byteStr", byteStr),
zap.Int("index", i),
)
uuidBytes = make([]byte, 16) // 出错时使用空UUID
break
}
uuidBytes[i] = byte(byteVal)
}
if parseErr != nil {
return nil, fmt.Errorf("解析UUID字节失败: %w", parseErr)
}
}
// 准备签名数据UUID + expiresAt时间戳 + DER编码的公钥
signedDataV2 := make([]byte, 0, 24+len(pubDER)) // 预分配缓冲区
// 添加UUID16字节
signedDataV2 = append(signedDataV2, uuidBytes...)
// 添加expiresAt毫秒时间戳8字节大端序
expiresAtBytes := make([]byte, 8)
binary.BigEndian.PutUint64(expiresAtBytes, uint64(expiresAtMillis))
signedDataV2 = append(signedDataV2, expiresAtBytes...)
// 添加DER编码的公钥
signedDataV2 = append(signedDataV2, pubDER...)
// 计算SHA1哈希并签名
hash2 := sha1.Sum(signedDataV2)
signatureV2, err := rsa.SignPKCS1v15(rand.Reader, serverPrivateKey, crypto.SHA1, hash2[:])
// 构造V2签名消息DER编码
publicKeyDER, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
logger.Error("[ERROR] 签名V2失败: %v",
zap.Error(err),
zap.String("uuid", uuid),
zap.Int64("expiresAtMillis", expiresAtMillis),
)
return nil, fmt.Errorf("签名V2失败: %w", err)
return nil, fmt.Errorf("DER编码公钥失败: %w", err)
}
publicKeySignatureV2 = base64.StdEncoding.EncodeToString(signatureV2)
// 创建玩家证书结构
certificate := &PlayerCertificate{
KeyPair: struct {
PrivateKey string `json:"privateKey"`
PublicKey string `json:"publicKey"`
}{
PrivateKey: keyPair.PrivateKey,
PublicKey: keyPair.PublicKey,
},
// V2签名timestamp (8 bytes, big endian) + publicKey (DER)
messageV2 := make([]byte, 8+len(publicKeyDER))
binary.BigEndian.PutUint64(messageV2[0:8], uint64(expiresAtMillis))
copy(messageV2[8:], publicKeyDER)
hashedV2 := sha1.Sum(messageV2)
signatureV2, err := rsa.SignPKCS1v15(rand.Reader, yggPrivateKey, crypto.SHA1, hashedV2[:])
if err != nil {
return nil, fmt.Errorf("V2签名失败: %w", err)
}
publicKeySignatureV2 := base64.StdEncoding.EncodeToString(signatureV2)
return &model.KeyPair{
PrivateKey: string(privateKeyPEM),
PublicKey: string(publicKeyPEM),
PublicKeySignature: publicKeySignature,
PublicKeySignatureV2: publicKeySignatureV2,
ExpiresAt: keyPair.Expiration.Format(time.RFC3339Nano),
RefreshedAfter: keyPair.Refresh.Format(time.RFC3339Nano),
}
logger.Info("[INFO] 成功生成玩家证书,过期时间: %s",
zap.String("uuid", uuid),
zap.String("expiresAt", certificate.ExpiresAt),
zap.String("refreshedAfter", certificate.RefreshedAfter),
)
return certificate, nil
YggdrasilPublicKey: yggPublicKey,
Expiration: expiration,
Refresh: refresh,
}, nil
}
// GeneratePlayerCertificateService 生成玩家证书(结构体方法版本,保持向后兼容)
func (s *SignatureService) GeneratePlayerCertificate(uuid string) (*PlayerCertificate, error) {
return GeneratePlayerCertificate(nil, s.logger, s.redisClient, uuid) // TODO: 需要传入db参数
}
// GetOrCreateYggdrasilKeyPair 获取或创建Yggdrasil根密钥对
func (s *SignatureService) GetOrCreateYggdrasilKeyPair() (string, *rsa.PrivateKey, error) {
ctx := context.Background()
// NewKeyPair 生成新的密钥对(函数式版本)
func NewKeyPair(logger *zap.Logger) (*model.KeyPair, error) {
// 生成新的RSA密钥对用于玩家证书
privateKey, err := rsa.GenerateKey(rand.Reader, 2048) // 对玩家证书使用更小的密钥以提高性能
if err != nil {
logger.Error("[ERROR] 生成玩家证书私钥失败: %v",
zap.Error(err),
)
return nil, fmt.Errorf("生成玩家证书私钥失败: %w", err)
// 尝试从Redis获取密钥
publicKeyPEM, err := s.redis.Get(ctx, PublicKeyRedisKey)
if err == nil && publicKeyPEM != "" {
privateKeyPEM, err := s.redis.Get(ctx, PrivateKeyRedisKey)
if err == nil && privateKeyPEM != "" {
// 检查密钥是否过期
expStr, err := s.redis.Get(ctx, KeyExpirationRedisKey)
if err == nil && expStr != "" {
expTime, err := time.Parse(time.RFC3339, expStr)
if err == nil && time.Now().Before(expTime) {
// 密钥有效,解析私钥
block, _ := pem.Decode([]byte(privateKeyPEM))
if block != nil {
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err == nil {
s.logger.Info("从Redis加载Yggdrasil根密钥")
return publicKeyPEM, privateKey, nil
}
}
}
}
}
}
// 获取DER编码的密钥
keyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
// 生成新的根密钥
s.logger.Info("生成新的Yggdrasil根密钥对")
privateKey, err := rsa.GenerateKey(rand.Reader, KeySize)
if err != nil {
logger.Error("[ERROR] 编码私钥为PKCS8格式失败: %v",
zap.Error(err),
)
return nil, fmt.Errorf("编码私钥为PKCS8格式失败: %w", err)
return "", nil, fmt.Errorf("生成RSA密钥失败: %w", err)
}
pubDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
if err != nil {
logger.Error("[ERROR] 编码公钥为PKIX格式失败: %v",
zap.Error(err),
)
return nil, fmt.Errorf("编码公钥为PKIX格式失败: %w", err)
}
// 将密钥编码为PEM格式
keyPEM := pem.EncodeToMemory(&pem.Block{
// PEM编码私钥
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
privateKeyPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: keyDER,
})
Bytes: privateKeyBytes,
}))
pubPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: pubDER,
})
// 创建证书过期和刷新时间
now := time.Now().UTC()
expiresAtTime := now.Add(CertificateExpirationPeriod)
refreshedAfter := now.Add(CertificateRefreshInterval)
keyPair := &model.KeyPair{
Expiration: expiresAtTime,
PrivateKey: string(keyPEM),
PublicKey: string(pubPEM),
Refresh: refreshedAfter,
// PEM编码公钥
publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
if err != nil {
return "", nil, fmt.Errorf("编码公钥失败: %w", err)
}
return keyPair, nil
publicKeyPEM = string(pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: publicKeyBytes,
}))
// 计算过期时间90天
expiration := time.Now().AddDate(0, 0, ExpirationDays)
// 保存到Redis
if err := s.redis.Set(ctx, PublicKeyRedisKey, publicKeyPEM, RedisTTL); err != nil {
s.logger.Warn("保存公钥到Redis失败", zap.Error(err))
}
if err := s.redis.Set(ctx, PrivateKeyRedisKey, privateKeyPEM, RedisTTL); err != nil {
s.logger.Warn("保存私钥到Redis失败", zap.Error(err))
}
if err := s.redis.Set(ctx, KeyExpirationRedisKey, expiration.Format(time.RFC3339), RedisTTL); err != nil {
s.logger.Warn("保存密钥过期时间到Redis失败", zap.Error(err))
}
return publicKeyPEM, privateKey, nil
}
// WrapString 将字符串按指定宽度进行换行(函数式版本)
func WrapString(str string, width int) string {
if width <= 0 {
return str
// GetPublicKeyFromRedis 从Redis获取公钥
func (s *SignatureService) GetPublicKeyFromRedis() (string, error) {
ctx := context.Background()
publicKey, err := s.redis.Get(ctx, PublicKeyRedisKey)
if err != nil {
return "", fmt.Errorf("从Redis获取公钥失败: %w", err)
}
var b strings.Builder
for i := 0; i < len(str); i += width {
end := i + width
if end > len(str) {
end = len(str)
}
b.WriteString(str[i:end])
if end < len(str) {
b.WriteString("\n")
if publicKey == "" {
// 如果Redis中没有创建新的密钥对
publicKey, _, err = s.GetOrCreateYggdrasilKeyPair()
if err != nil {
return "", fmt.Errorf("创建新密钥对失败: %w", err)
}
}
return b.String()
return publicKey, nil
}
// NewKeyPairService 生成新的密钥对(结构体方法版本,保持向后兼容)
func (s *SignatureService) NewKeyPair() (*model.KeyPair, error) {
return NewKeyPair(s.logger)
// SignStringWithSHA1withRSA 使用SHA1withRSA签名字符串
func (s *SignatureService) SignStringWithSHA1withRSA(data string) (string, error) {
ctx := context.Background()
// 从Redis获取私钥
privateKeyPEM, err := s.redis.Get(ctx, PrivateKeyRedisKey)
if err != nil || privateKeyPEM == "" {
// 如果没有私钥,创建新的密钥对
_, privateKey, err := s.GetOrCreateYggdrasilKeyPair()
if err != nil {
return "", fmt.Errorf("获取私钥失败: %w", err)
}
// 使用新生成的私钥签名
hashed := sha1.Sum([]byte(data))
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
if err != nil {
return "", fmt.Errorf("签名失败: %w", err)
}
return base64.StdEncoding.EncodeToString(signature), nil
}
// 解析PEM格式的私钥
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return "", fmt.Errorf("解析PEM私钥失败")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("解析RSA私钥失败: %w", err)
}
// 签名
hashed := sha1.Sum([]byte(data))
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
if err != nil {
return "", fmt.Errorf("签名失败: %w", err)
}
return base64.StdEncoding.EncodeToString(signature), nil
}
// FormatPublicKey 格式化公钥为单行格式去除PEM头尾和换行符
func FormatPublicKey(publicKeyPEM string) string {
// 移除PEM格式的头尾
lines := strings.Split(publicKeyPEM, "\n")
var keyLines []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" &&
!strings.HasPrefix(trimmed, "-----BEGIN") &&
!strings.HasPrefix(trimmed, "-----END") {
keyLines = append(keyLines, trimmed)
}
}
return strings.Join(keyLines, "")
}

View File

@@ -1,358 +0,0 @@
package service
import (
"crypto/rand"
"crypto/rsa"
"strings"
"testing"
"time"
"go.uber.org/zap/zaptest"
)
// TestSignatureService_Constants 测试签名服务相关常量
func TestSignatureService_Constants(t *testing.T) {
if RSAKeySize != 4096 {
t.Errorf("RSAKeySize = %d, want 4096", RSAKeySize)
}
if PrivateKeyRedisKey == "" {
t.Error("PrivateKeyRedisKey should not be empty")
}
if PublicKeyRedisKey == "" {
t.Error("PublicKeyRedisKey should not be empty")
}
if KeyExpirationTime != 24*7*time.Hour {
t.Errorf("KeyExpirationTime = %v, want 7 days", KeyExpirationTime)
}
if CertificateRefreshInterval != 24*time.Hour {
t.Errorf("CertificateRefreshInterval = %v, want 24 hours", CertificateRefreshInterval)
}
if CertificateExpirationPeriod != 24*7*time.Hour {
t.Errorf("CertificateExpirationPeriod = %v, want 7 days", CertificateExpirationPeriod)
}
}
// TestSignatureService_DataValidation 测试签名数据验证逻辑
func TestSignatureService_DataValidation(t *testing.T) {
tests := []struct {
name string
data string
wantValid bool
}{
{
name: "非空数据有效",
data: "test data",
wantValid: true,
},
{
name: "空数据无效",
data: "",
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.data != ""
if isValid != tt.wantValid {
t.Errorf("Data validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestPlayerCertificate_Structure 测试PlayerCertificate结构
func TestPlayerCertificate_Structure(t *testing.T) {
cert := PlayerCertificate{
ExpiresAt: "2025-01-01T00:00:00Z",
RefreshedAfter: "2025-01-01T00:00:00Z",
PublicKeySignature: "signature",
PublicKeySignatureV2: "signaturev2",
}
// 验证结构体字段
if cert.ExpiresAt == "" {
t.Error("ExpiresAt should not be empty")
}
if cert.RefreshedAfter == "" {
t.Error("RefreshedAfter should not be empty")
}
// PublicKeySignature是可选的
if cert.PublicKeySignature == "" {
t.Log("PublicKeySignature is optional")
}
}
// TestWrapString 测试字符串换行函数
func TestWrapString(t *testing.T) {
tests := []struct {
name string
str string
width int
expected string
}{
{
name: "正常换行",
str: "1234567890",
width: 5,
expected: "12345\n67890",
},
{
name: "字符串长度等于width",
str: "12345",
width: 5,
expected: "12345",
},
{
name: "字符串长度小于width",
str: "123",
width: 5,
expected: "123",
},
{
name: "width为0返回原字符串",
str: "1234567890",
width: 0,
expected: "1234567890",
},
{
name: "width为负数返回原字符串",
str: "1234567890",
width: -1,
expected: "1234567890",
},
{
name: "空字符串",
str: "",
width: 5,
expected: "",
},
{
name: "width为1",
str: "12345",
width: 1,
expected: "1\n2\n3\n4\n5",
},
{
name: "长字符串多次换行",
str: "123456789012345",
width: 5,
expected: "12345\n67890\n12345",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := WrapString(tt.str, tt.width)
if result != tt.expected {
t.Errorf("WrapString(%q, %d) = %q, want %q", tt.str, tt.width, result, tt.expected)
}
})
}
}
// TestWrapString_LineCount 测试换行后的行数
func TestWrapString_LineCount(t *testing.T) {
tests := []struct {
name string
str string
width int
wantLines int
}{
{
name: "10个字符width=5应该2行",
str: "1234567890",
width: 5,
wantLines: 2,
},
{
name: "15个字符width=5应该3行",
str: "123456789012345",
width: 5,
wantLines: 3,
},
{
name: "5个字符width=5应该1行",
str: "12345",
width: 5,
wantLines: 1,
},
{
name: "width为0应该1行",
str: "1234567890",
width: 0,
wantLines: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := WrapString(tt.str, tt.width)
lines := strings.Count(result, "\n") + 1
if lines != tt.wantLines {
t.Errorf("Line count = %d, want %d (result: %q)", lines, tt.wantLines, result)
}
})
}
}
// TestWrapString_NoTrailingNewline 测试末尾不换行
func TestWrapString_NoTrailingNewline(t *testing.T) {
str := "1234567890"
result := WrapString(str, 5)
// 验证末尾没有换行符
if strings.HasSuffix(result, "\n") {
t.Error("Result should not end with newline")
}
// 验证包含换行符(除了最后一行)
if !strings.Contains(result, "\n") {
t.Error("Result should contain newline for multi-line output")
}
}
// TestEncodePrivateKeyToPEM_ActualCall 实际调用EncodePrivateKeyToPEM函数
func TestEncodePrivateKeyToPEM_ActualCall(t *testing.T) {
// 生成测试用的RSA私钥
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("生成RSA私钥失败: %v", err)
}
tests := []struct {
name string
keyType []string
wantError bool
}{
{
name: "默认类型",
keyType: []string{},
wantError: false,
},
{
name: "RSA类型",
keyType: []string{"RSA"},
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pemBytes, err := EncodePrivateKeyToPEM(privateKey, tt.keyType...)
if (err != nil) != tt.wantError {
t.Errorf("EncodePrivateKeyToPEM() error = %v, wantError %v", err, tt.wantError)
return
}
if !tt.wantError {
if len(pemBytes) == 0 {
t.Error("EncodePrivateKeyToPEM() 返回的PEM字节不应为空")
}
pemStr := string(pemBytes)
// 验证PEM格式
if !strings.Contains(pemStr, "BEGIN") || !strings.Contains(pemStr, "END") {
t.Error("EncodePrivateKeyToPEM() 返回的PEM格式不正确")
}
// 验证类型
if len(tt.keyType) > 0 && tt.keyType[0] == "RSA" {
if !strings.Contains(pemStr, "RSA PRIVATE KEY") {
t.Error("EncodePrivateKeyToPEM() 应包含 'RSA PRIVATE KEY'")
}
} else {
if !strings.Contains(pemStr, "PRIVATE KEY") {
t.Error("EncodePrivateKeyToPEM() 应包含 'PRIVATE KEY'")
}
}
}
})
}
}
// TestEncodePublicKeyToPEM_ActualCall 实际调用EncodePublicKeyToPEM函数
func TestEncodePublicKeyToPEM_ActualCall(t *testing.T) {
logger := zaptest.NewLogger(t)
// 生成测试用的RSA密钥对
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("生成RSA密钥对失败: %v", err)
}
publicKey := &privateKey.PublicKey
tests := []struct {
name string
keyType []string
wantError bool
}{
{
name: "默认类型",
keyType: []string{},
wantError: false,
},
{
name: "RSA类型",
keyType: []string{"RSA"},
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pemBytes, err := EncodePublicKeyToPEM(logger, publicKey, tt.keyType...)
if (err != nil) != tt.wantError {
t.Errorf("EncodePublicKeyToPEM() error = %v, wantError %v", err, tt.wantError)
return
}
if !tt.wantError {
if len(pemBytes) == 0 {
t.Error("EncodePublicKeyToPEM() 返回的PEM字节不应为空")
}
pemStr := string(pemBytes)
// 验证PEM格式
if !strings.Contains(pemStr, "BEGIN") || !strings.Contains(pemStr, "END") {
t.Error("EncodePublicKeyToPEM() 返回的PEM格式不正确")
}
// 验证类型
if len(tt.keyType) > 0 && tt.keyType[0] == "RSA" {
if !strings.Contains(pemStr, "RSA PUBLIC KEY") {
t.Error("EncodePublicKeyToPEM() 应包含 'RSA PUBLIC KEY'")
}
} else {
if !strings.Contains(pemStr, "PUBLIC KEY") {
t.Error("EncodePublicKeyToPEM() 应包含 'PUBLIC KEY'")
}
}
}
})
}
}
// TestEncodePublicKeyToPEM_NilKey 测试nil公钥
func TestEncodePublicKeyToPEM_NilKey(t *testing.T) {
logger := zaptest.NewLogger(t)
_, err := EncodePublicKeyToPEM(logger, nil)
if err == nil {
t.Error("EncodePublicKeyToPEM() 对于nil公钥应返回错误")
}
}
// TestNewSignatureService 测试创建SignatureService
func TestNewSignatureService(t *testing.T) {
logger := zaptest.NewLogger(t)
// 注意这里需要实际的redis client但我们只测试结构体创建
// 在实际测试中可以使用mock redis client
service := NewSignatureService(logger, nil)
if service == nil {
t.Error("NewSignatureService() 不应返回nil")
}
if service.logger != logger {
t.Error("NewSignatureService() logger 设置不正确")
}
}

View File

@@ -1,121 +1,171 @@
package service
import (
"bytes"
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/database"
"carrotskin/pkg/storage"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"path/filepath"
"strings"
"gorm.io/gorm"
"go.uber.org/zap"
)
// CreateTexture 创建材质
func CreateTexture(db *gorm.DB, uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error) {
// 验证用户存在
user, err := repository.FindUserByID(uploaderID)
if err != nil {
return nil, err
}
if user == nil {
return nil, errors.New("用户不存在")
}
// 检查Hash是否已存在
existingTexture, err := repository.FindTextureByHash(hash)
if err != nil {
return nil, err
}
if existingTexture != nil {
return nil, errors.New("该材质已存在")
}
// 转换材质类型
var textureTypeEnum model.TextureType
switch textureType {
case "SKIN":
textureTypeEnum = model.TextureTypeSkin
case "CAPE":
textureTypeEnum = model.TextureTypeCape
default:
return nil, errors.New("无效的材质类型")
}
// 创建材质
texture := &model.Texture{
UploaderID: uploaderID,
Name: name,
Description: description,
Type: textureTypeEnum,
URL: url,
Hash: hash,
Size: size,
IsPublic: isPublic,
IsSlim: isSlim,
Status: 1,
DownloadCount: 0,
FavoriteCount: 0,
}
if err := repository.CreateTexture(texture); err != nil {
return nil, err
}
return texture, nil
// textureService TextureService的实现
type textureService struct {
textureRepo repository.TextureRepository
userRepo repository.UserRepository
storage *storage.StorageClient
cache *database.CacheManager
cacheKeys *database.CacheKeyBuilder
cacheInv *database.CacheInvalidator
logger *zap.Logger
}
// GetTextureByID 根据ID获取材质
func GetTextureByID(db *gorm.DB, id int64) (*model.Texture, error) {
texture, err := repository.FindTextureByID(id)
// NewTextureService 创建TextureService实例
func NewTextureService(
textureRepo repository.TextureRepository,
userRepo repository.UserRepository,
storageClient *storage.StorageClient,
cacheManager *database.CacheManager,
logger *zap.Logger,
) TextureService {
return &textureService{
textureRepo: textureRepo,
userRepo: userRepo,
storage: storageClient,
cache: cacheManager,
cacheKeys: database.NewCacheKeyBuilder(""),
cacheInv: database.NewCacheInvalidator(cacheManager),
logger: logger,
}
}
func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture, error) {
// 尝试从缓存获取
cacheKey := s.cacheKeys.Texture(id)
var texture model.Texture
if ok, _ := s.cache.TryGet(ctx, cacheKey, &texture); ok {
if texture.Status == -1 {
return nil, errors.New("材质已删除")
}
// 如果缓存中没有 Uploader 信息,重新查询数据库
if texture.Uploader == nil {
texture2, err := s.textureRepo.FindByID(ctx, id)
if err != nil {
return nil, err
}
if texture2 == nil {
return nil, ErrTextureNotFound
}
if texture2.Status == -1 {
return nil, errors.New("材质已删除")
}
// 更新缓存
s.cache.SetAsync(context.Background(), cacheKey, texture2, s.cache.Policy.TextureTTL)
return texture2, nil
}
return &texture, nil
}
// 缓存未命中,从数据库查询
texture2, err := s.textureRepo.FindByID(ctx, id)
if err != nil {
return nil, err
}
if texture == nil {
return nil, errors.New("材质不存在")
if texture2 == nil {
return nil, ErrTextureNotFound
}
if texture.Status == -1 {
if texture2.Status == -1 {
return nil, errors.New("材质已删除")
}
return texture, nil
// 存入缓存(异步)
s.cache.SetAsync(context.Background(), cacheKey, texture2, s.cache.Policy.TextureTTL)
return texture2, nil
}
// GetUserTextures 获取用户上传的材质列表
func GetUserTextures(db *gorm.DB, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
func (s *textureService) GetByHash(ctx context.Context, hash string) (*model.Texture, error) {
// 尝试从缓存获取
cacheKey := s.cacheKeys.TextureByHash(hash)
var texture model.Texture
if ok, _ := s.cache.TryGet(ctx, cacheKey, &texture); ok {
if texture.Status == -1 {
return nil, errors.New("材质已删除")
}
return &texture, nil
}
return repository.FindTexturesByUploaderID(uploaderID, page, pageSize)
// 缓存未命中,从数据库查询
texture2, err := s.textureRepo.FindByHash(ctx, hash)
if err != nil {
return nil, err
}
if texture2 == nil {
return nil, ErrTextureNotFound
}
if texture2.Status == -1 {
return nil, errors.New("材质已删除")
}
// 存入缓存(异步)
s.cache.SetAsync(context.Background(), cacheKey, texture2, s.cache.Policy.TextureTTL)
return texture2, nil
}
// SearchTextures 搜索材质
func SearchTextures(db *gorm.DB, keyword string, textureType model.TextureType, publicOnly bool, page, pageSize int) ([]*model.Texture, int64, error) {
if page < 1 {
page = 1
func (s *textureService) GetByUserID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) {
page, pageSize = NormalizePagination(page, pageSize)
// 尝试从缓存获取(包含分页参数)
cacheKey := s.cacheKeys.TextureList(uploaderID, page)
var cachedResult struct {
Textures []*model.Texture
Total int64
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
if ok, _ := s.cache.TryGet(ctx, cacheKey, &cachedResult); ok {
return cachedResult.Textures, cachedResult.Total, nil
}
return repository.SearchTextures(keyword, textureType, publicOnly, page, pageSize)
// 缓存未命中,从数据库查询
textures, total, err := s.textureRepo.FindByUploaderID(ctx, uploaderID, page, pageSize)
if err != nil {
return nil, 0, err
}
// 存入缓存(异步)
result := struct {
Textures []*model.Texture
Total int64
}{Textures: textures, Total: total}
s.cache.SetAsync(context.Background(), cacheKey, result, s.cache.Policy.TextureListTTL)
return textures, total, nil
}
// UpdateTexture 更新材质
func UpdateTexture(db *gorm.DB, textureID, uploaderID int64, name, description string, isPublic *bool) (*model.Texture, error) {
// 获取材质
texture, err := repository.FindTextureByID(textureID)
func (s *textureService) Search(ctx context.Context, keyword string, textureType model.TextureType, publicOnly bool, page, pageSize int) ([]*model.Texture, int64, error) {
page, pageSize = NormalizePagination(page, pageSize)
return s.textureRepo.Search(ctx, keyword, textureType, publicOnly, page, pageSize)
}
func (s *textureService) Update(ctx context.Context, textureID, uploaderID int64, name, description string, isPublic *bool) (*model.Texture, error) {
// 获取材质并验证权限
texture, err := s.textureRepo.FindByID(ctx, textureID)
if err != nil {
return nil, err
}
if texture == nil {
return nil, errors.New("材质不存在")
return nil, ErrTextureNotFound
}
// 检查权限:只有上传者可以修改
if texture.UploaderID != uploaderID {
return nil, errors.New("无权修改此材质")
return nil, ErrTextureNoPermission
}
// 更新字段
@@ -131,114 +181,69 @@ func UpdateTexture(db *gorm.DB, textureID, uploaderID int64, name, description s
}
if len(updates) > 0 {
if err := repository.UpdateTextureFields(textureID, updates); err != nil {
if err := s.textureRepo.UpdateFields(ctx, textureID, updates); err != nil {
return nil, err
}
}
// 返回更新后的材质
return repository.FindTextureByID(textureID)
// 清除 texture 缓存和用户列表缓存
s.cacheInv.OnUpdate(ctx, s.cacheKeys.Texture(textureID))
s.cacheInv.BatchInvalidate(ctx, s.cacheKeys.TextureListPattern(uploaderID))
return s.textureRepo.FindByID(ctx, textureID)
}
// DeleteTexture 删除材质
func DeleteTexture(db *gorm.DB, textureID, uploaderID int64) error {
// 获取材质
texture, err := repository.FindTextureByID(textureID)
func (s *textureService) Delete(ctx context.Context, textureID, uploaderID int64) error {
// 获取材质并验证权限
texture, err := s.textureRepo.FindByID(ctx, textureID)
if err != nil {
return err
}
if texture == nil {
return errors.New("材质不存在")
return ErrTextureNotFound
}
// 检查权限:只有上传者可以删除
if texture.UploaderID != uploaderID {
return errors.New("无权删除此材质")
return ErrTextureNoPermission
}
return repository.DeleteTexture(textureID)
}
// RecordTextureDownload 记录下载
func RecordTextureDownload(db *gorm.DB, textureID int64, userID *int64, ipAddress, userAgent string) error {
// 检查材质是否存在
texture, err := repository.FindTextureByID(textureID)
err = s.textureRepo.Delete(ctx, textureID)
if err != nil {
return err
}
if texture == nil {
return errors.New("材质不存在")
}
// 增加下载次数
if err := repository.IncrementTextureDownloadCount(textureID); err != nil {
return err
}
// 清除 texture 缓存和用户列表缓存
s.cacheInv.OnDelete(ctx, s.cacheKeys.Texture(textureID))
s.cacheInv.BatchInvalidate(ctx, s.cacheKeys.TextureListPattern(uploaderID))
// 创建下载日志
log := &model.TextureDownloadLog{
TextureID: textureID,
UserID: userID,
IPAddress: ipAddress,
UserAgent: userAgent,
}
return repository.CreateTextureDownloadLog(log)
return nil
}
// ToggleTextureFavorite 切换收藏状态
func ToggleTextureFavorite(db *gorm.DB, userID, textureID int64) (bool, error) {
// 检查材质是否存在
texture, err := repository.FindTextureByID(textureID)
func (s *textureService) ToggleFavorite(ctx context.Context, userID, textureID int64) (bool, error) {
texture, err := s.textureRepo.FindByID(ctx, textureID)
if err != nil {
return false, err
}
if texture == nil {
return false, errors.New("材质不存在")
if texture == nil || texture.Status != 1 || !texture.IsPublic {
return false, ErrTextureNotFound
}
// 检查是否已收藏
isFavorited, err := repository.IsTextureFavorited(userID, textureID)
isAdded, err := s.textureRepo.ToggleFavorite(ctx, userID, textureID)
if err != nil {
return false, err
}
if isFavorited {
// 取消收藏
if err := repository.RemoveTextureFavorite(userID, textureID); err != nil {
return false, err
}
if err := repository.DecrementTextureFavoriteCount(textureID); err != nil {
return false, err
}
return false, nil
} else {
// 添加收藏
if err := repository.AddTextureFavorite(userID, textureID); err != nil {
return false, err
}
if err := repository.IncrementTextureFavoriteCount(textureID); err != nil {
return false, err
}
return true, nil
}
s.cacheInv.BatchInvalidate(ctx, s.cacheKeys.UserFavoritesPattern(userID))
return isAdded, nil
}
// GetUserTextureFavorites 获取用户收藏的材质列表
func GetUserTextureFavorites(db *gorm.DB, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
return repository.GetUserTextureFavorites(userID, page, pageSize)
func (s *textureService) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {
page, pageSize = NormalizePagination(page, pageSize)
return s.textureRepo.GetUserFavorites(ctx, userID, page, pageSize)
}
// CheckTextureUploadLimit 检查用户上传材质数量限制
func CheckTextureUploadLimit(db *gorm.DB, uploaderID int64, maxTextures int) error {
count, err := repository.CountTexturesByUploaderID(uploaderID)
func (s *textureService) CheckUploadLimit(ctx context.Context, uploaderID int64, maxTextures int) error {
count, err := s.textureRepo.CountByUploaderID(ctx, uploaderID)
if err != nil {
return err
}
@@ -249,3 +254,135 @@ func CheckTextureUploadLimit(db *gorm.DB, uploaderID int64, maxTextures int) err
return nil
}
// UploadTexture 直接上传材质文件
func (s *textureService) UploadTexture(ctx context.Context, uploaderID int64, name, description, textureType string, fileData []byte, fileName string, isPublic, isSlim bool) (*model.Texture, error) {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, uploaderID)
if err != nil || user == nil {
return nil, ErrUserNotFound
}
// 验证文件大小和扩展名
fileSize := len(fileData)
const minSize = 512 // 512B
const maxSize = 10 * 1024 * 1024 // 10MB
if int64(fileSize) < minSize || int64(fileSize) > maxSize {
return nil, fmt.Errorf("文件大小必须在 %d 到 %d 字节之间", minSize, maxSize)
}
// 验证文件扩展名只支持PNG
ext := strings.ToLower(filepath.Ext(fileName))
if ext != ".png" {
return nil, fmt.Errorf("不支持的文件格式: %s仅支持PNG格式", ext)
}
// 验证材质类型
if textureType != "SKIN" && textureType != "CAPE" {
return nil, errors.New("无效的材质类型")
}
// 计算文件SHA256哈希
hashBytes := sha256.Sum256(fileData)
hash := hex.EncodeToString(hashBytes[:])
// 检查是否有任何用户上传过相同Hash的皮肤复用URL不重复保存文件
existingTexture, err := s.textureRepo.FindByHash(ctx, hash)
if err != nil {
return nil, err
}
var finalURL string
if existingTexture != nil {
// 如果已存在相同Hash的皮肤复用已存在的URL不重复上传
finalURL = existingTexture.URL
s.logger.Info("复用已存在的材质文件",
zap.String("hash", hash),
zap.String("url", finalURL),
)
} else {
// 如果不存在,上传到对象存储
if s.storage == nil {
return nil, errors.New("存储服务不可用")
}
// 获取存储桶名称
bucketName, err := s.storage.GetBucket("textures")
if err != nil {
return nil, fmt.Errorf("获取存储桶失败: %w", err)
}
// 生成对象名称(路径)
// 格式: hash/{hash[:2]}/{hash[2:4]}/{hash}.png
// 使用哈希值作为路径,避免重复存储相同文件
textureTypeFolder := strings.ToLower(textureType)
objectName := fmt.Sprintf("%s/%s/%s/%s/%s%s", textureTypeFolder, hash[:2], hash[2:4], hash, hash, ext)
// 上传文件
reader := bytes.NewReader(fileData)
contentType := "image/png"
if err := s.storage.UploadObject(ctx, bucketName, objectName, reader, int64(fileSize), contentType); err != nil {
return nil, fmt.Errorf("上传文件失败: %w", err)
}
// 构建文件URL
finalURL = s.storage.BuildFileURL(bucketName, objectName)
s.logger.Info("上传新的材质文件",
zap.String("hash", hash),
zap.String("url", finalURL),
)
}
// 转换材质类型
textureTypeEnum, err := parseTextureTypeInternal(textureType)
if err != nil {
return nil, err
}
// 创建材质记录即使Hash相同也创建新的数据库记录
texture := &model.Texture{
UploaderID: uploaderID,
Name: name,
Description: description,
Type: textureTypeEnum,
URL: finalURL,
Hash: hash,
Size: fileSize,
IsPublic: isPublic,
IsSlim: isSlim,
Status: 1,
DownloadCount: 0,
FavoriteCount: 0,
}
if err := s.textureRepo.Create(ctx, texture); err != nil {
return nil, err
}
// 清除用户的 texture 列表缓存(所有分页)
s.cacheInv.BatchInvalidate(ctx, fmt.Sprintf("texture:user:%d:*", uploaderID))
// 重新查询以预加载 Uploader 关联
textureWithUploader, err := s.textureRepo.FindByID(ctx, texture.ID)
if err != nil {
// 如果查询失败,返回原始创建的 texture 对象(虽然可能没有 Uploader 信息)
return texture, nil
}
if textureWithUploader == nil {
// 如果查询返回 nil极端情况如数据库复制延迟返回原始创建的 texture 对象
return texture, nil
}
return textureWithUploader, nil
}
// parseTextureTypeInternal 解析材质类型
func parseTextureTypeInternal(textureType string) (model.TextureType, error) {
switch textureType {
case "SKIN":
return model.TextureTypeSkin, nil
case "CAPE":
return model.TextureTypeCape, nil
default:
return "", errors.New("无效的材质类型")
}
}

View File

@@ -1,7 +1,12 @@
package service
import (
"carrotskin/internal/model"
"context"
"strings"
"testing"
"go.uber.org/zap"
)
// TestTextureService_TypeValidation 测试材质类型验证
@@ -469,3 +474,374 @@ func TestCheckTextureUploadLimit_Logic(t *testing.T) {
func boolPtr(b bool) *bool {
return &b
}
// ============================================================================
// 使用 Mock 的集成测试
// ============================================================================
// TestTextureServiceImpl_Create 测试创建Texture
func TestTextureServiceImpl_Create(t *testing.T) {
textureRepo := NewMockTextureRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置用户
testUser := &model.User{
ID: 1,
Username: "testuser",
Email: "test@example.com",
Status: 1,
}
_ = userRepo.Create(context.Background(), testUser)
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
tests := []struct {
name string
uploaderID int64
textureName string
textureType string
hash string
wantErr bool
errContains string
setupMocks func()
}{
{
name: "正常创建SKIN材质",
uploaderID: 1,
textureName: "TestSkin",
textureType: "SKIN",
hash: "unique-hash-1",
wantErr: false,
},
{
name: "正常创建CAPE材质",
uploaderID: 1,
textureName: "TestCape",
textureType: "CAPE",
hash: "unique-hash-2",
wantErr: false,
},
{
name: "用户不存在",
uploaderID: 999,
textureName: "TestTexture",
textureType: "SKIN",
hash: "unique-hash-3",
wantErr: true,
},
{
name: "材质Hash已存在",
uploaderID: 1,
textureName: "DuplicateTexture",
textureType: "SKIN",
hash: "existing-hash",
wantErr: false,
setupMocks: func() {
_ = textureRepo.Create(context.Background(), &model.Texture{
ID: 100,
UploaderID: 1,
Name: "ExistingTexture",
Hash: "existing-hash",
})
},
},
{
name: "无效的材质类型",
uploaderID: 1,
textureName: "InvalidTypeTexture",
textureType: "INVALID",
hash: "unique-hash-4",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setupMocks != nil {
tt.setupMocks()
}
ctx := context.Background()
// UploadTexture需要文件数据这里创建一个简单的测试数据
fileData := []byte(strings.Repeat("x", 512))
texture, err := textureService.UploadTexture(
ctx,
tt.uploaderID,
tt.textureName,
"Test description",
tt.textureType,
fileData,
"test.png",
true,
false,
)
if tt.wantErr {
if err == nil {
t.Error("期望返回错误,但实际没有错误")
return
}
if tt.errContains != "" && !containsString(err.Error(), tt.errContains) {
t.Errorf("错误信息应包含 %q, 实际为: %v", tt.errContains, err.Error())
}
} else {
if err != nil {
t.Errorf("不期望返回错误: %v", err)
return
}
if texture == nil {
t.Error("返回的Texture不应为nil")
}
if texture.Name != tt.textureName {
t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName)
}
}
})
}
}
// TestTextureServiceImpl_GetByID 测试获取Texture
func TestTextureServiceImpl_GetByID(t *testing.T) {
textureRepo := NewMockTextureRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置Texture
testTexture := &model.Texture{
ID: 1,
UploaderID: 1,
Name: "TestTexture",
Hash: "test-hash",
}
_ = textureRepo.Create(context.Background(), testTexture)
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
tests := []struct {
name string
id int64
wantErr bool
}{
{
name: "获取存在的Texture",
id: 1,
wantErr: false,
},
{
name: "获取不存在的Texture",
id: 999,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
texture, err := textureService.GetByID(ctx, tt.id)
if tt.wantErr {
if err == nil {
t.Error("期望返回错误,但实际没有错误")
}
} else {
if err != nil {
t.Errorf("不期望返回错误: %v", err)
return
}
if texture == nil {
t.Error("返回的Texture不应为nil")
}
}
})
}
}
// TestTextureServiceImpl_GetByUserID_And_Search 测试 GetByUserID 与 Search 分页封装
func TestTextureServiceImpl_GetByUserID_And_Search(t *testing.T) {
textureRepo := NewMockTextureRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置多条 Texture
for i := int64(1); i <= 5; i++ {
_ = textureRepo.Create(context.Background(), &model.Texture{
ID: i,
UploaderID: 1,
Name: "T",
IsPublic: i%2 == 0,
})
}
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
ctx := context.Background()
// GetByUserID 应按上传者过滤并调用 NormalizePagination
textures, total, err := textureService.GetByUserID(ctx, 1, 0, 0)
if err != nil {
t.Fatalf("GetByUserID 失败: %v", err)
}
if total != int64(len(textures)) {
t.Fatalf("GetByUserID 返回数量与总数不一致, total=%d, len=%d", total, len(textures))
}
// Search 仅验证能够正常调用并返回结果
searchResult, searchTotal, err := textureService.Search(ctx, "", model.TextureTypeSkin, true, -1, 200)
if err != nil {
t.Fatalf("Search 失败: %v", err)
}
if searchTotal != int64(len(searchResult)) {
t.Fatalf("Search 返回数量与总数不一致, total=%d, len=%d", searchTotal, len(searchResult))
}
}
// TestTextureServiceImpl_Update_And_Delete 测试 Update / Delete 权限与字段更新
func TestTextureServiceImpl_Update_And_Delete(t *testing.T) {
textureRepo := NewMockTextureRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
texture := &model.Texture{
ID: 1,
UploaderID: 1,
Name: "Old",
Description: "OldDesc",
IsPublic: false,
}
_ = textureRepo.Create(context.Background(), texture)
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
ctx := context.Background()
// 更新成功
newName := "NewName"
newDesc := "NewDesc"
public := boolPtr(true)
updated, err := textureService.Update(ctx, 1, 1, newName, newDesc, public)
if err != nil {
t.Fatalf("Update 正常情况失败: %v", err)
}
// 由于 MockTextureRepository.UpdateFields 不会真正修改结构体字段,这里只验证不会返回 nil 即可
if updated == nil {
t.Fatalf("Update 返回结果不应为 nil")
}
// 无权限更新
if _, err := textureService.Update(ctx, 1, 2, "X", "Y", nil); err == nil {
t.Fatalf("Update 在无权限时应返回错误")
}
// 删除成功
if err := textureService.Delete(ctx, 1, 1); err != nil {
t.Fatalf("Delete 正常情况失败: %v", err)
}
// 无权限删除
if err := textureService.Delete(ctx, 1, 2); err == nil {
t.Fatalf("Delete 在无权限时应返回错误")
}
}
// TestTextureServiceImpl_FavoritesAndLimit 测试 GetUserFavorites 与 CheckUploadLimit
func TestTextureServiceImpl_FavoritesAndLimit(t *testing.T) {
textureRepo := NewMockTextureRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置若干 Texture 与收藏关系
for i := int64(1); i <= 3; i++ {
_ = textureRepo.Create(context.Background(), &model.Texture{
ID: i,
UploaderID: 1,
Name: "T",
})
_, _ = textureRepo.ToggleFavorite(context.Background(), 1, i)
}
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
ctx := context.Background()
// GetUserFavorites
favs, total, err := textureService.GetUserFavorites(ctx, 1, -1, -1)
if err != nil {
t.Fatalf("GetUserFavorites 失败: %v", err)
}
if int64(len(favs)) != total || total != 3 {
t.Fatalf("GetUserFavorites 数量不正确, total=%d, len=%d", total, len(favs))
}
// CheckUploadLimit 未超过上限
if err := textureService.CheckUploadLimit(ctx, 1, 10); err != nil {
t.Fatalf("CheckUploadLimit 在未达到上限时不应报错: %v", err)
}
// CheckUploadLimit 超过上限
if err := textureService.CheckUploadLimit(ctx, 1, 2); err == nil {
t.Fatalf("CheckUploadLimit 在超过上限时应返回错误")
}
}
// TestTextureServiceImpl_ToggleFavorite 测试收藏功能
func TestTextureServiceImpl_ToggleFavorite(t *testing.T) {
textureRepo := NewMockTextureRepository()
userRepo := NewMockUserRepository()
logger := zap.NewNop()
// 预置用户和Texture
testUser := &model.User{ID: 1, Username: "testuser", Status: 1}
_ = userRepo.Create(context.Background(), testUser)
testTexture := &model.Texture{
ID: 1,
UploaderID: 1,
Name: "TestTexture",
Hash: "test-hash",
}
_ = textureRepo.Create(context.Background(), testTexture)
cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
ctx := context.Background()
// 第一次收藏
isFavorited, err := textureService.ToggleFavorite(ctx, 1, 1)
if err != nil {
t.Errorf("第一次收藏失败: %v", err)
}
if !isFavorited {
t.Error("第一次操作应该是添加收藏")
}
// 第二次取消收藏
isFavorited, err = textureService.ToggleFavorite(ctx, 1, 1)
if err != nil {
t.Errorf("取消收藏失败: %v", err)
}
if isFavorited {
t.Error("第二次操作应该是取消收藏")
}
}
// 辅助函数
func containsString(s, substr string) bool {
return len(s) >= len(substr) && (s == substr ||
(len(s) > len(substr) && (findSubstring(s, substr) != -1)))
}
func findSubstring(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}

View File

@@ -1,277 +0,0 @@
package service
import (
"carrotskin/internal/model"
"carrotskin/internal/repository"
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
"strconv"
"time"
"gorm.io/gorm"
)
// 常量定义
const (
ExtendedTimeout = 10 * time.Second
TokensMaxCount = 10 // 用户最多保留的token数量
)
// NewToken 创建新令牌
func NewToken(db *gorm.DB, logger *zap.Logger, userId int64, UUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error) {
var (
selectedProfileID *model.Profile
availableProfiles []*model.Profile
)
// 设置超时上下文
_, cancel := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancel()
// 验证用户存在
_, err := repository.FindProfileByUUID(UUID)
if err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("获取用户信息失败: %w", err)
}
// 生成令牌
if clientToken == "" {
clientToken = uuid.New().String()
}
accessToken := uuid.New().String()
token := model.Token{
AccessToken: accessToken,
ClientToken: clientToken,
UserID: userId,
Usable: true,
IssueDate: time.Now(),
}
// 获取用户配置文件
profiles, err := repository.FindProfilesByUserID(userId)
if err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("获取用户配置文件失败: %w", err)
}
// 如果用户只有一个配置文件,自动选择
if len(profiles) == 1 {
selectedProfileID = profiles[0]
token.ProfileId = selectedProfileID.UUID
}
availableProfiles = profiles
// 插入令牌到tokens集合
_, insertCancel := context.WithTimeout(context.Background(), DefaultTimeout)
defer insertCancel()
err = repository.CreateToken(&token)
if err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("创建Token失败: %w", err)
}
// 清理多余的令牌
go CheckAndCleanupExcessTokens(db, logger, userId)
return selectedProfileID, availableProfiles, accessToken, clientToken, nil
}
// CheckAndCleanupExcessTokens 检查并清理用户多余的令牌只保留最新的10个
func CheckAndCleanupExcessTokens(db *gorm.DB, logger *zap.Logger, userId int64) {
if userId == 0 {
return
}
// 获取用户所有令牌,按发行日期降序排序
tokens, err := repository.GetTokensByUserId(userId)
if err != nil {
logger.Error("[ERROR] 获取用户Token失败: ", zap.Error(err), zap.String("userId", strconv.FormatInt(userId, 10)))
return
}
// 如果令牌数量不超过上限,无需清理
if len(tokens) <= TokensMaxCount {
return
}
// 获取需要删除的令牌ID列表
tokensToDelete := make([]string, 0, len(tokens)-TokensMaxCount)
for i := TokensMaxCount; i < len(tokens); i++ {
tokensToDelete = append(tokensToDelete, tokens[i].AccessToken)
}
// 执行批量删除,传入上下文和待删除的令牌列表(作为切片参数)
DeletedCount, err := repository.BatchDeleteTokens(tokensToDelete)
if err != nil {
logger.Error("[ERROR] 清理用户多余Token失败: ", zap.Error(err), zap.String("userId", strconv.FormatInt(userId, 10)))
return
}
if DeletedCount > 0 {
logger.Info("[INFO] 成功清理用户多余Token", zap.Any("userId:", userId), zap.Any("count:", DeletedCount))
}
}
// ValidToken 验证令牌有效性
func ValidToken(db *gorm.DB, accessToken string, clientToken string) bool {
if accessToken == "" {
return false
}
// 使用投影只获取需要的字段
var token *model.Token
token, err := repository.FindTokenByID(accessToken)
if err != nil {
return false
}
if !token.Usable {
return false
}
// 如果客户端令牌为空,只验证访问令牌
if clientToken == "" {
return true
}
// 否则验证客户端令牌是否匹配
return token.ClientToken == clientToken
}
func GetUUIDByAccessToken(db *gorm.DB, accessToken string) (string, error) {
return repository.GetUUIDByAccessToken(accessToken)
}
func GetUserIDByAccessToken(db *gorm.DB, accessToken string) (int64, error) {
return repository.GetUserIDByAccessToken(accessToken)
}
// RefreshToken 刷新令牌
func RefreshToken(db *gorm.DB, logger *zap.Logger, accessToken, clientToken string, selectedProfileID string) (string, string, error) {
if accessToken == "" {
return "", "", errors.New("accessToken不能为空")
}
// 查找旧令牌
oldToken, err := repository.GetTokenByAccessToken(accessToken)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", "", errors.New("accessToken无效")
}
logger.Error("[ERROR] 查询Token失败: ", zap.Error(err), zap.Any("accessToken:", accessToken))
return "", "", fmt.Errorf("查询令牌失败: %w", err)
}
// 验证profile
if selectedProfileID != "" {
valid, validErr := ValidateProfileByUserID(db, oldToken.UserID, selectedProfileID)
if validErr != nil {
logger.Error(
"验证Profile失败",
zap.Error(err),
zap.Any("userId", oldToken.UserID),
zap.String("profileId", selectedProfileID),
)
return "", "", fmt.Errorf("验证角色失败: %w", err)
}
if !valid {
return "", "", errors.New("角色与用户不匹配")
}
}
// 检查 clientToken 是否有效
if clientToken != "" && clientToken != oldToken.ClientToken {
return "", "", errors.New("clientToken无效")
}
// 检查 selectedProfileID 的逻辑
if selectedProfileID != "" {
if oldToken.ProfileId != "" && oldToken.ProfileId != selectedProfileID {
return "", "", errors.New("原令牌已绑定角色,无法选择新角色")
}
} else {
selectedProfileID = oldToken.ProfileId // 如果未指定,则保持原角色
}
// 生成新令牌
newAccessToken := uuid.New().String()
newToken := model.Token{
AccessToken: newAccessToken,
ClientToken: oldToken.ClientToken, // 新令牌的 clientToken 与原令牌相同
UserID: oldToken.UserID,
Usable: true,
ProfileId: selectedProfileID, // 绑定到指定角色或保持原角色
IssueDate: time.Now(),
}
// 使用双重写入模式替代事务,先插入新令牌,再删除旧令牌
err = repository.CreateToken(&newToken)
if err != nil {
logger.Error(
"创建新Token失败",
zap.Error(err),
zap.String("accessToken", accessToken),
)
return "", "", fmt.Errorf("创建新Token失败: %w", err)
}
err = repository.DeleteTokenByAccessToken(accessToken)
if err != nil {
// 删除旧令牌失败,记录日志但不阻止操作,因为新令牌已成功创建
logger.Warn(
"删除旧Token失败但新Token已创建",
zap.Error(err),
zap.String("oldToken", oldToken.AccessToken),
zap.String("newToken", newAccessToken),
)
}
logger.Info(
"成功刷新Token",
zap.Any("userId", oldToken.UserID),
zap.String("accessToken", newAccessToken),
)
return newAccessToken, oldToken.ClientToken, nil
}
// InvalidToken 使令牌失效
func InvalidToken(db *gorm.DB, logger *zap.Logger, accessToken string) {
if accessToken == "" {
return
}
err := repository.DeleteTokenByAccessToken(accessToken)
if err != nil {
logger.Error(
"删除Token失败",
zap.Error(err),
zap.String("accessToken", accessToken),
)
return
}
logger.Info("[INFO] 成功删除", zap.Any("Token:", accessToken))
}
// InvalidUserTokens 使用户所有令牌失效
func InvalidUserTokens(db *gorm.DB, logger *zap.Logger, userId int64) {
if userId == 0 {
return
}
err := repository.DeleteTokenByUserId(userId)
if err != nil {
logger.Error(
"[ERROR]删除用户Token失败",
zap.Error(err),
zap.Any("userId", userId),
)
return
}
logger.Info("[INFO] 成功删除用户Token", zap.Any("userId:", userId))
}

View File

@@ -0,0 +1,470 @@
package service
import (
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/auth"
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
)
// tokenServiceRedis TokenService的Redis实现
type tokenServiceRedis struct {
tokenStore *auth.TokenStoreRedis
clientRepo repository.ClientRepository
profileRepo repository.ProfileRepository
yggdrasilJWT *auth.YggdrasilJWTService
logger *zap.Logger
tokenExpireSec int64 // Token过期时间0表示永不过期
tokenStaleSec int64 // Token过期但可用时间0表示永不过期
}
// NewTokenServiceRedis 创建使用Redis的TokenService实例
func NewTokenServiceRedis(
tokenStore *auth.TokenStoreRedis,
clientRepo repository.ClientRepository,
profileRepo repository.ProfileRepository,
yggdrasilJWT *auth.YggdrasilJWTService,
logger *zap.Logger,
) TokenService {
return &tokenServiceRedis{
tokenStore: tokenStore,
clientRepo: clientRepo,
profileRepo: profileRepo,
yggdrasilJWT: yggdrasilJWT,
logger: logger,
tokenExpireSec: 24 * 3600, // 默认24小时
tokenStaleSec: 30 * 24 * 3600, // 默认30天
}
}
// Create 创建Token使用JWT + Redis存储
func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error) {
var (
selectedProfileID *model.Profile
availableProfiles []*model.Profile
)
// 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
// 验证用户存在
if UUID != "" {
_, err := s.profileRepo.FindByUUID(ctx, UUID)
if err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("获取用户信息失败: %w", err)
}
}
// 生成ClientToken
if clientToken == "" {
clientToken = uuid.New().String()
}
// 获取或创建Client
var client *model.Client
existingClient, err := s.clientRepo.FindByClientToken(ctx, clientToken)
if err != nil {
// Client不存在创建新的
clientUUID := uuid.New().String()
client = &model.Client{
UUID: clientUUID,
ClientToken: clientToken,
UserID: userID,
Version: 0,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if UUID != "" {
client.ProfileID = UUID
}
if err := s.clientRepo.Create(ctx, client); err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("创建Client失败: %w", err)
}
} else {
// Client已存在验证UserID是否匹配
if existingClient.UserID != userID {
return selectedProfileID, availableProfiles, "", "", errors.New("clientToken已属于其他用户")
}
client = existingClient
// 不增加Version只有在刷新时才增加只更新ProfileID和UpdatedAt
client.UpdatedAt = time.Now()
if UUID != "" {
client.ProfileID = UUID
if err := s.clientRepo.Update(ctx, client); err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("更新Client失败: %w", err)
}
}
}
// 获取用户配置文件
profiles, err := s.profileRepo.FindByUserID(ctx, userID)
if err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("获取用户配置文件失败: %w", err)
}
// 如果用户只有一个配置文件,自动选择
profileID := client.ProfileID
if len(profiles) == 1 {
selectedProfileID = profiles[0]
if profileID == "" {
profileID = selectedProfileID.UUID
client.ProfileID = profileID
_ = s.clientRepo.Update(ctx, client)
}
}
availableProfiles = profiles
// 生成Token过期时间
now := time.Now()
var expiresAt, staleAt time.Time
if s.tokenExpireSec > 0 {
expiresAt = now.Add(time.Duration(s.tokenExpireSec) * time.Second)
} else {
// 使用遥远的未来时间
expiresAt = time.Date(2038, 1, 1, 0, 0, 0, 0, time.UTC)
}
if s.tokenStaleSec > 0 {
staleAt = now.Add(time.Duration(s.tokenStaleSec) * time.Second)
} else {
staleAt = time.Date(2038, 1, 1, 0, 0, 0, 0, time.UTC)
}
// 生成JWT AccessToken
accessToken, err := s.yggdrasilJWT.GenerateAccessToken(
userID,
client.UUID,
client.Version,
profileID,
expiresAt,
staleAt,
)
if err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("生成AccessToken失败: %w", err)
}
// 存储Token到Redis
ttl := expiresAt.Sub(now)
metadata := &auth.TokenMetadata{
UserID: userID,
ProfileID: profileID,
ClientUUID: client.UUID,
ClientToken: client.ClientToken,
Version: client.Version,
CreatedAt: now.Unix(),
}
if err := s.tokenStore.Store(ctx, accessToken, metadata, ttl); err != nil {
s.logger.Warn("存储Token到Redis失败", zap.Error(err))
// 不返回错误因为JWT本身已经生成成功
}
return selectedProfileID, availableProfiles, accessToken, clientToken, nil
}
// Validate 验证Token使用JWT验证 + Redis存储验证
func (s *tokenServiceRedis) Validate(ctx context.Context, accessToken, clientToken string) bool {
// 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
if accessToken == "" {
return false
}
// 解析JWT
claims, err := s.yggdrasilJWT.ParseAccessToken(accessToken, auth.StalePolicyDeny)
if err != nil {
return false
}
// 从Redis获取Token元数据
metadata, err := s.tokenStore.Retrieve(ctx, accessToken)
if err != nil {
// Token可能已过期或不存在
return false
}
// 查找Client
client, err := s.clientRepo.FindByUUID(ctx, claims.Subject)
if err != nil {
return false
}
// 验证Version是否匹配
if claims.Version != client.Version {
return false
}
// 验证ClientToken如果提供
if clientToken != "" && metadata.ClientToken != clientToken {
return false
}
return true
}
// Refresh 刷新Token使用Version机制Redis存储
func (s *tokenServiceRedis) Refresh(ctx context.Context, accessToken, clientToken, selectedProfileID string) (string, string, error) {
// 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
if accessToken == "" {
return "", "", errors.New("accessToken不能为空")
}
// 解析JWT获取Client信息
claims, err := s.yggdrasilJWT.ParseAccessToken(accessToken, auth.StalePolicyAllow)
if err != nil {
return "", "", errors.New("accessToken无效")
}
// 查找Client
client, err := s.clientRepo.FindByUUID(ctx, claims.Subject)
if err != nil {
return "", "", errors.New("无法找到对应的Client")
}
// 验证ClientToken
if clientToken != "" && client.ClientToken != clientToken {
return "", "", errors.New("clientToken无效")
}
// 验证Version必须匹配
if claims.Version != client.Version {
return "", "", errors.New("token版本不匹配请重新登录")
}
// 验证Profile
if selectedProfileID != "" {
valid, validErr := s.validateProfileByUserID(ctx, client.UserID, selectedProfileID)
if validErr != nil {
s.logger.Error("验证Profile失败",
zap.Error(validErr),
zap.Int64("userId", client.UserID),
zap.String("profileId", selectedProfileID),
)
return "", "", fmt.Errorf("验证角色失败: %w", validErr)
}
if !valid {
return "", "", errors.New("角色与用户不匹配")
}
// 检查是否已绑定Profile
if client.ProfileID != "" && client.ProfileID != selectedProfileID {
return "", "", errors.New("原令牌已绑定角色,无法选择新角色")
}
client.ProfileID = selectedProfileID
} else {
selectedProfileID = client.ProfileID
}
// 增加Version这是关键通过Version失效所有旧Token
client.Version++
client.UpdatedAt = time.Now()
if err := s.clientRepo.Update(ctx, client); err != nil {
return "", "", fmt.Errorf("更新Client版本失败: %w", err)
}
// 删除旧Token从Redis
if err := s.tokenStore.Delete(ctx, accessToken); err != nil {
s.logger.Warn("删除旧Token失败", zap.Error(err))
}
// 生成Token过期时间
now := time.Now()
var expiresAt, staleAt time.Time
if s.tokenExpireSec > 0 {
expiresAt = now.Add(time.Duration(s.tokenExpireSec) * time.Second)
} else {
expiresAt = time.Date(2038, 1, 1, 0, 0, 0, 0, time.UTC)
}
if s.tokenStaleSec > 0 {
staleAt = now.Add(time.Duration(s.tokenStaleSec) * time.Second)
} else {
staleAt = time.Date(2038, 1, 1, 0, 0, 0, 0, time.UTC)
}
// 生成新的JWT AccessToken使用新的Version
newAccessToken, err := s.yggdrasilJWT.GenerateAccessToken(
client.UserID,
client.UUID,
client.Version,
selectedProfileID,
expiresAt,
staleAt,
)
if err != nil {
return "", "", fmt.Errorf("生成新AccessToken失败: %w", err)
}
// 存储新Token到Redis
ttl := expiresAt.Sub(now)
metadata := &auth.TokenMetadata{
UserID: client.UserID,
ProfileID: selectedProfileID,
ClientUUID: client.UUID,
ClientToken: client.ClientToken,
Version: client.Version,
CreatedAt: now.Unix(),
}
if err := s.tokenStore.Store(ctx, newAccessToken, metadata, ttl); err != nil {
s.logger.Warn("存储新Token到Redis失败", zap.Error(err))
}
s.logger.Info("成功刷新Token", zap.Int64("userId", client.UserID), zap.Int("version", client.Version))
return newAccessToken, client.ClientToken, nil
}
// Invalidate 使Token失效从Redis删除
func (s *tokenServiceRedis) Invalidate(ctx context.Context, accessToken string) {
// 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
if accessToken == "" {
return
}
// 解析JWT获取Client信息
claims, err := s.yggdrasilJWT.ParseAccessToken(accessToken, auth.StalePolicyAllow)
if err != nil {
s.logger.Warn("解析Token失败", zap.Error(err))
return
}
// 查找Client并增加Version失效所有旧Token
client, err := s.clientRepo.FindByUUID(ctx, claims.Subject)
if err != nil {
s.logger.Warn("无法找到对应的Client", zap.Error(err))
return
}
// 增加Version以失效所有旧Token
client.Version++
client.UpdatedAt = time.Now()
if err := s.clientRepo.Update(ctx, client); err != nil {
s.logger.Error("失效Token失败", zap.Error(err))
return
}
// 从Redis删除Token
if err := s.tokenStore.Delete(ctx, accessToken); err != nil {
s.logger.Warn("从Redis删除Token失败", zap.Error(err))
return
}
s.logger.Info("成功失效Token", zap.String("clientUUID", client.UUID), zap.Int("version", client.Version))
}
// InvalidateUserTokens 使用户所有Token失效从Redis删除
func (s *tokenServiceRedis) InvalidateUserTokens(ctx context.Context, userID int64) {
// 设置超时上下文
ctx, cancel := context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
if userID == 0 {
return
}
// 获取用户所有Client
clients, err := s.clientRepo.FindByUserID(ctx, userID)
if err != nil {
s.logger.Error("获取用户Client失败", zap.Error(err), zap.Int64("userId", userID))
return
}
// 增加每个Client的Version
for _, client := range clients {
client.Version++
client.UpdatedAt = time.Now()
if err := s.clientRepo.Update(ctx, client); err != nil {
s.logger.Error("失效用户Token失败", zap.Error(err), zap.Int64("userId", userID))
}
}
// 从Redis删除用户所有Token
if err := s.tokenStore.DeleteByUserID(ctx, userID); err != nil {
s.logger.Error("从Redis删除用户Token失败", zap.Error(err), zap.Int64("userId", userID))
return
}
s.logger.Info("成功失效用户所有Token", zap.Int64("userId", userID), zap.Int("clientCount", len(clients)))
}
// GetUUIDByAccessToken 从AccessToken获取UUID通过JWT解析
func (s *tokenServiceRedis) GetUUIDByAccessToken(ctx context.Context, accessToken string) (string, error) {
claims, err := s.yggdrasilJWT.ParseAccessToken(accessToken, auth.StalePolicyAllow)
if err != nil {
return "", errors.New("accessToken无效")
}
if claims.ProfileID != "" {
return claims.ProfileID, nil
}
// 如果没有ProfileID从Client获取
client, err := s.clientRepo.FindByUUID(ctx, claims.Subject)
if err != nil {
return "", fmt.Errorf("无法找到对应的Client: %w", err)
}
if client.ProfileID != "" {
return client.ProfileID, nil
}
return "", errors.New("无法从Token中获取UUID")
}
// GetUserIDByAccessToken 从AccessToken获取UserID通过JWT解析
func (s *tokenServiceRedis) GetUserIDByAccessToken(ctx context.Context, accessToken string) (int64, error) {
claims, err := s.yggdrasilJWT.ParseAccessToken(accessToken, auth.StalePolicyAllow)
if err != nil {
return 0, errors.New("accessToken无效")
}
// 从Client获取UserID
client, err := s.clientRepo.FindByUUID(ctx, claims.Subject)
if err != nil {
return 0, fmt.Errorf("无法找到对应的Client: %w", err)
}
// 验证Version
if claims.Version != client.Version {
return 0, errors.New("token版本不匹配")
}
return client.UserID, nil
}
// validateProfileByUserID 验证Profile是否属于用户
func (s *tokenServiceRedis) validateProfileByUserID(ctx context.Context, userID int64, UUID string) (bool, error) {
if userID == 0 || UUID == "" {
return false, errors.New("用户ID或配置文件ID不能为空")
}
profile, err := s.profileRepo.FindByUUID(ctx, UUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, errors.New("配置文件不存在")
}
return false, fmt.Errorf("验证配置文件失败: %w", err)
}
return profile.UserID == userID, nil
}

View File

@@ -1,204 +0,0 @@
package service
import (
"testing"
"time"
)
// TestTokenService_Constants 测试Token服务相关常量
func TestTokenService_Constants(t *testing.T) {
if ExtendedTimeout != 10*time.Second {
t.Errorf("ExtendedTimeout = %v, want 10 seconds", ExtendedTimeout)
}
if TokensMaxCount != 10 {
t.Errorf("TokensMaxCount = %d, want 10", TokensMaxCount)
}
}
// TestTokenService_Timeout 测试超时常量
func TestTokenService_Timeout(t *testing.T) {
if DefaultTimeout != 5*time.Second {
t.Errorf("DefaultTimeout = %v, want 5 seconds", DefaultTimeout)
}
if ExtendedTimeout <= DefaultTimeout {
t.Errorf("ExtendedTimeout (%v) should be greater than DefaultTimeout (%v)", ExtendedTimeout, DefaultTimeout)
}
}
// TestTokenService_Validation 测试Token验证逻辑
func TestTokenService_Validation(t *testing.T) {
tests := []struct {
name string
accessToken string
wantValid bool
}{
{
name: "空token无效",
accessToken: "",
wantValid: false,
},
{
name: "非空token可能有效",
accessToken: "valid-token-string",
wantValid: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 测试空token检查逻辑
isValid := tt.accessToken != ""
if isValid != tt.wantValid {
t.Errorf("Token validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestTokenService_ClientTokenLogic 测试ClientToken逻辑
func TestTokenService_ClientTokenLogic(t *testing.T) {
tests := []struct {
name string
clientToken string
shouldGenerate bool
}{
{
name: "空的clientToken应该生成新的",
clientToken: "",
shouldGenerate: true,
},
{
name: "非空的clientToken应该使用提供的",
clientToken: "existing-client-token",
shouldGenerate: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
shouldGenerate := tt.clientToken == ""
if shouldGenerate != tt.shouldGenerate {
t.Errorf("ClientToken logic failed: got %v, want %v", shouldGenerate, tt.shouldGenerate)
}
})
}
}
// TestTokenService_ProfileSelection 测试Profile选择逻辑
func TestTokenService_ProfileSelection(t *testing.T) {
tests := []struct {
name string
profileCount int
shouldAutoSelect bool
}{
{
name: "只有一个profile时自动选择",
profileCount: 1,
shouldAutoSelect: true,
},
{
name: "多个profile时不自动选择",
profileCount: 2,
shouldAutoSelect: false,
},
{
name: "没有profile时不自动选择",
profileCount: 0,
shouldAutoSelect: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
shouldAutoSelect := tt.profileCount == 1
if shouldAutoSelect != tt.shouldAutoSelect {
t.Errorf("Profile selection logic failed: got %v, want %v", shouldAutoSelect, tt.shouldAutoSelect)
}
})
}
}
// TestTokenService_CleanupLogic 测试清理逻辑
func TestTokenService_CleanupLogic(t *testing.T) {
tests := []struct {
name string
tokenCount int
maxCount int
shouldCleanup bool
cleanupCount int
}{
{
name: "token数量未超过上限不需要清理",
tokenCount: 5,
maxCount: 10,
shouldCleanup: false,
cleanupCount: 0,
},
{
name: "token数量超过上限需要清理",
tokenCount: 15,
maxCount: 10,
shouldCleanup: true,
cleanupCount: 5,
},
{
name: "token数量等于上限不需要清理",
tokenCount: 10,
maxCount: 10,
shouldCleanup: false,
cleanupCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
shouldCleanup := tt.tokenCount > tt.maxCount
if shouldCleanup != tt.shouldCleanup {
t.Errorf("Cleanup decision failed: got %v, want %v", shouldCleanup, tt.shouldCleanup)
}
if shouldCleanup {
expectedCleanupCount := tt.tokenCount - tt.maxCount
if expectedCleanupCount != tt.cleanupCount {
t.Errorf("Cleanup count failed: got %d, want %d", expectedCleanupCount, tt.cleanupCount)
}
}
})
}
}
// TestTokenService_UserIDValidation 测试UserID验证
func TestTokenService_UserIDValidation(t *testing.T) {
tests := []struct {
name string
userID int64
isValid bool
}{
{
name: "有效的UserID",
userID: 1,
isValid: true,
},
{
name: "UserID为0时无效",
userID: 0,
isValid: false,
},
{
name: "负数UserID无效",
userID: -1,
isValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.userID > 0
if isValid != tt.isValid {
t.Errorf("UserID validation failed: got %v, want %v", isValid, tt.isValid)
}
})
}
}

View File

@@ -1,160 +0,0 @@
package service
import (
"carrotskin/pkg/config"
"carrotskin/pkg/storage"
"context"
"fmt"
"path/filepath"
"strings"
"time"
)
// FileType 文件类型枚举
type FileType string
const (
FileTypeAvatar FileType = "avatar"
FileTypeTexture FileType = "texture"
)
// UploadConfig 上传配置
type UploadConfig struct {
AllowedExts map[string]bool // 允许的文件扩展名
MinSize int64 // 最小文件大小(字节)
MaxSize int64 // 最大文件大小(字节)
Expires time.Duration // URL过期时间
}
// GetUploadConfig 根据文件类型获取上传配置
func GetUploadConfig(fileType FileType) *UploadConfig {
switch fileType {
case FileTypeAvatar:
return &UploadConfig{
AllowedExts: map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".webp": true,
},
MinSize: 1024, // 1KB
MaxSize: 5 * 1024 * 1024, // 5MB
Expires: 15 * time.Minute,
}
case FileTypeTexture:
return &UploadConfig{
AllowedExts: map[string]bool{
".png": true,
},
MinSize: 1024, // 1KB
MaxSize: 10 * 1024 * 1024, // 10MB
Expires: 15 * time.Minute,
}
default:
return nil
}
}
// ValidateFileName 验证文件名
func ValidateFileName(fileName string, fileType FileType) error {
if fileName == "" {
return fmt.Errorf("文件名不能为空")
}
uploadConfig := GetUploadConfig(fileType)
if uploadConfig == nil {
return fmt.Errorf("不支持的文件类型")
}
ext := strings.ToLower(filepath.Ext(fileName))
if !uploadConfig.AllowedExts[ext] {
return fmt.Errorf("不支持的文件格式: %s", ext)
}
return nil
}
// GenerateAvatarUploadURL 生成头像上传URL
func GenerateAvatarUploadURL(ctx context.Context, storageClient *storage.StorageClient, cfg config.RustFSConfig, userID int64, fileName string) (*storage.PresignedPostPolicyResult, error) {
// 1. 验证文件名
if err := ValidateFileName(fileName, FileTypeAvatar); err != nil {
return nil, err
}
// 2. 获取上传配置
uploadConfig := GetUploadConfig(FileTypeAvatar)
// 3. 获取存储桶名称
bucketName, err := storageClient.GetBucket("avatars")
if err != nil {
return nil, fmt.Errorf("获取存储桶失败: %w", err)
}
// 4. 生成对象名称(路径)
// 格式: user_{userId}/timestamp_{originalFileName}
timestamp := time.Now().Format("20060102150405")
objectName := fmt.Sprintf("user_%d/%s_%s", userID, timestamp, fileName)
// 5. 生成预签名POST URL
result, err := storageClient.GeneratePresignedPostURL(
ctx,
bucketName,
objectName,
uploadConfig.MinSize,
uploadConfig.MaxSize,
uploadConfig.Expires,
cfg.UseSSL,
cfg.Endpoint,
)
if err != nil {
return nil, fmt.Errorf("生成上传URL失败: %w", err)
}
return result, nil
}
// GenerateTextureUploadURL 生成材质上传URL
func GenerateTextureUploadURL(ctx context.Context, storageClient *storage.StorageClient, cfg config.RustFSConfig, userID int64, fileName, textureType string) (*storage.PresignedPostPolicyResult, error) {
// 1. 验证文件名
if err := ValidateFileName(fileName, FileTypeTexture); err != nil {
return nil, err
}
// 2. 验证材质类型
if textureType != "SKIN" && textureType != "CAPE" {
return nil, fmt.Errorf("无效的材质类型: %s", textureType)
}
// 3. 获取上传配置
uploadConfig := GetUploadConfig(FileTypeTexture)
// 4. 获取存储桶名称
bucketName, err := storageClient.GetBucket("textures")
if err != nil {
return nil, fmt.Errorf("获取存储桶失败: %w", err)
}
// 5. 生成对象名称(路径)
// 格式: user_{userId}/{textureType}/timestamp_{originalFileName}
timestamp := time.Now().Format("20060102150405")
textureTypeFolder := strings.ToLower(textureType)
objectName := fmt.Sprintf("user_%d/%s/%s_%s", userID, textureTypeFolder, timestamp, fileName)
// 6. 生成预签名POST URL
result, err := storageClient.GeneratePresignedPostURL(
ctx,
bucketName,
objectName,
uploadConfig.MinSize,
uploadConfig.MaxSize,
uploadConfig.Expires,
cfg.UseSSL,
cfg.Endpoint,
)
if err != nil {
return nil, fmt.Errorf("生成上传URL失败: %w", err)
}
return result, nil
}

View File

@@ -1,279 +0,0 @@
package service
import (
"strings"
"testing"
"time"
)
// TestUploadService_FileTypes 测试文件类型常量
func TestUploadService_FileTypes(t *testing.T) {
if FileTypeAvatar == "" {
t.Error("FileTypeAvatar should not be empty")
}
if FileTypeTexture == "" {
t.Error("FileTypeTexture should not be empty")
}
if FileTypeAvatar == FileTypeTexture {
t.Error("FileTypeAvatar and FileTypeTexture should be different")
}
}
// TestGetUploadConfig 测试获取上传配置
func TestGetUploadConfig(t *testing.T) {
tests := []struct {
name string
fileType FileType
wantConfig bool
}{
{
name: "头像类型返回配置",
fileType: FileTypeAvatar,
wantConfig: true,
},
{
name: "材质类型返回配置",
fileType: FileTypeTexture,
wantConfig: true,
},
{
name: "无效类型返回nil",
fileType: FileType("invalid"),
wantConfig: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := GetUploadConfig(tt.fileType)
hasConfig := config != nil
if hasConfig != tt.wantConfig {
t.Errorf("GetUploadConfig() = %v, want %v", hasConfig, tt.wantConfig)
}
if config != nil {
// 验证配置字段
if config.MinSize <= 0 {
t.Error("MinSize should be greater than 0")
}
if config.MaxSize <= 0 {
t.Error("MaxSize should be greater than 0")
}
if config.MaxSize < config.MinSize {
t.Error("MaxSize should be greater than or equal to MinSize")
}
if config.Expires <= 0 {
t.Error("Expires should be greater than 0")
}
if len(config.AllowedExts) == 0 {
t.Error("AllowedExts should not be empty")
}
}
})
}
}
// TestGetUploadConfig_AvatarConfig 测试头像配置详情
func TestGetUploadConfig_AvatarConfig(t *testing.T) {
config := GetUploadConfig(FileTypeAvatar)
if config == nil {
t.Fatal("Avatar config should not be nil")
}
// 验证允许的扩展名
expectedExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
for _, ext := range expectedExts {
if !config.AllowedExts[ext] {
t.Errorf("Avatar config should allow %s extension", ext)
}
}
// 验证文件大小限制
if config.MinSize != 1024 {
t.Errorf("Avatar MinSize = %d, want 1024", config.MinSize)
}
if config.MaxSize != 5*1024*1024 {
t.Errorf("Avatar MaxSize = %d, want 5MB", config.MaxSize)
}
// 验证过期时间
if config.Expires != 15*time.Minute {
t.Errorf("Avatar Expires = %v, want 15 minutes", config.Expires)
}
}
// TestGetUploadConfig_TextureConfig 测试材质配置详情
func TestGetUploadConfig_TextureConfig(t *testing.T) {
config := GetUploadConfig(FileTypeTexture)
if config == nil {
t.Fatal("Texture config should not be nil")
}
// 验证允许的扩展名材质只允许PNG
if !config.AllowedExts[".png"] {
t.Error("Texture config should allow .png extension")
}
// 验证文件大小限制
if config.MinSize != 1024 {
t.Errorf("Texture MinSize = %d, want 1024", config.MinSize)
}
if config.MaxSize != 10*1024*1024 {
t.Errorf("Texture MaxSize = %d, want 10MB", config.MaxSize)
}
// 验证过期时间
if config.Expires != 15*time.Minute {
t.Errorf("Texture Expires = %v, want 15 minutes", config.Expires)
}
}
// TestValidateFileName 测试文件名验证
func TestValidateFileName(t *testing.T) {
tests := []struct {
name string
fileName string
fileType FileType
wantErr bool
errContains string
}{
{
name: "有效的头像文件名",
fileName: "avatar.png",
fileType: FileTypeAvatar,
wantErr: false,
},
{
name: "有效的材质文件名",
fileName: "texture.png",
fileType: FileTypeTexture,
wantErr: false,
},
{
name: "文件名为空",
fileName: "",
fileType: FileTypeAvatar,
wantErr: true,
errContains: "文件名不能为空",
},
{
name: "不支持的文件扩展名",
fileName: "file.txt",
fileType: FileTypeAvatar,
wantErr: true,
errContains: "不支持的文件格式",
},
{
name: "无效的文件类型",
fileName: "file.png",
fileType: FileType("invalid"),
wantErr: true,
errContains: "不支持的文件类型",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateFileName(tt.fileName, tt.fileType)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateFileName() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr && tt.errContains != "" {
if err == nil || !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("ValidateFileName() error = %v, should contain %s", err, tt.errContains)
}
}
})
}
}
// TestValidateFileName_Extensions 测试各种扩展名
func TestValidateFileName_Extensions(t *testing.T) {
avatarExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
for _, ext := range avatarExts {
fileName := "test" + ext
err := ValidateFileName(fileName, FileTypeAvatar)
if err != nil {
t.Errorf("Avatar file with %s extension should be valid, got error: %v", ext, err)
}
}
// 材质只支持PNG
textureExts := []string{".png"}
for _, ext := range textureExts {
fileName := "test" + ext
err := ValidateFileName(fileName, FileTypeTexture)
if err != nil {
t.Errorf("Texture file with %s extension should be valid, got error: %v", ext, err)
}
}
// 测试不支持的扩展名
invalidExts := []string{".txt", ".pdf", ".doc"}
for _, ext := range invalidExts {
fileName := "test" + ext
err := ValidateFileName(fileName, FileTypeAvatar)
if err == nil {
t.Errorf("Avatar file with %s extension should be invalid", ext)
}
}
}
// TestValidateFileName_CaseInsensitive 测试扩展名大小写不敏感
func TestValidateFileName_CaseInsensitive(t *testing.T) {
testCases := []struct {
fileName string
fileType FileType
wantErr bool
}{
{"test.PNG", FileTypeAvatar, false},
{"test.JPG", FileTypeAvatar, false},
{"test.JPEG", FileTypeAvatar, false},
{"test.GIF", FileTypeAvatar, false},
{"test.WEBP", FileTypeAvatar, false},
{"test.PnG", FileTypeTexture, false},
}
for _, tc := range testCases {
t.Run(tc.fileName, func(t *testing.T) {
err := ValidateFileName(tc.fileName, tc.fileType)
if (err != nil) != tc.wantErr {
t.Errorf("ValidateFileName(%s, %s) error = %v, wantErr %v", tc.fileName, tc.fileType, err, tc.wantErr)
}
})
}
}
// TestUploadConfig_Structure 测试UploadConfig结构
func TestUploadConfig_Structure(t *testing.T) {
config := &UploadConfig{
AllowedExts: map[string]bool{
".png": true,
},
MinSize: 1024,
MaxSize: 5 * 1024 * 1024,
Expires: 15 * time.Minute,
}
if config.AllowedExts == nil {
t.Error("AllowedExts should not be nil")
}
if config.MinSize <= 0 {
t.Error("MinSize should be greater than 0")
}
if config.MaxSize <= config.MinSize {
t.Error("MaxSize should be greater than MinSize")
}
if config.Expires <= 0 {
t.Error("Expires should be greater than 0")
}
}

View File

@@ -1,32 +1,81 @@
package service
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"path/filepath"
"strings"
"time"
apperrors "carrotskin/internal/errors"
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/auth"
"errors"
"strings"
"time"
"carrotskin/pkg/config"
"carrotskin/pkg/database"
"carrotskin/pkg/redis"
"carrotskin/pkg/storage"
"go.uber.org/zap"
)
// RegisterUser 用户注册
func RegisterUser(jwtService *auth.JWTService, username, password, email, avatar string) (*model.User, string, error) {
// userService UserService的实现
type userService struct {
userRepo repository.UserRepository
jwtService *auth.JWTService
redis *redis.Client
cache *database.CacheManager
cacheKeys *database.CacheKeyBuilder
cacheInv *database.CacheInvalidator
storage *storage.StorageClient
logger *zap.Logger
}
// NewUserService 创建UserService实例
func NewUserService(
userRepo repository.UserRepository,
jwtService *auth.JWTService,
redisClient *redis.Client,
cacheManager *database.CacheManager,
storageClient *storage.StorageClient,
logger *zap.Logger,
) UserService {
// CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀
// 这样缓存键的格式为: CacheManager前缀 + CacheKeyBuilder生成的键
return &userService{
userRepo: userRepo,
jwtService: jwtService,
redis: redisClient,
cache: cacheManager,
cacheKeys: database.NewCacheKeyBuilder(""),
cacheInv: database.NewCacheInvalidator(cacheManager),
storage: storageClient,
logger: logger,
}
}
func (s *userService) Register(ctx context.Context, username, password, email, avatar string) (*model.User, string, error) {
// 检查用户名是否已存在
existingUser, err := repository.FindUserByUsername(username)
existingUser, err := s.userRepo.FindByUsername(ctx, username)
if err != nil {
return nil, "", err
}
if existingUser != nil {
return nil, "", errors.New("用户名已存在")
return nil, "", apperrors.ErrUserAlreadyExists
}
// 检查邮箱是否已存在
existingEmail, err := repository.FindUserByEmail(email)
existingEmail, err := s.userRepo.FindByEmail(ctx, email)
if err != nil {
return nil, "", err
}
if existingEmail != nil {
return nil, "", errors.New("邮箱已被注册")
return nil, "", apperrors.ErrEmailAlreadyExists
}
// 加密密码
@@ -35,10 +84,14 @@ func RegisterUser(jwtService *auth.JWTService, username, password, email, avatar
return nil, "", errors.New("密码加密失败")
}
// 确定头像URL:优先使用用户提供的头像,否则使用默认头像
// 确定头像URL
avatarURL := avatar
if avatarURL == "" {
avatarURL = getDefaultAvatar()
if avatarURL != "" {
if err := s.ValidateAvatarURL(ctx, avatarURL); err != nil {
return nil, "", err
}
} else {
avatarURL = s.getDefaultAvatar()
}
// 创建用户
@@ -49,61 +102,70 @@ func RegisterUser(jwtService *auth.JWTService, username, password, email, avatar
Avatar: avatarURL,
Role: "user",
Status: 1,
Points: 0, // 初始积分可以从配置读取
Points: 0,
}
if err := repository.CreateUser(user); err != nil {
if err := s.userRepo.Create(ctx, user); err != nil {
return nil, "", err
}
// 生成JWT Token
token, err := jwtService.GenerateToken(user.ID, user.Username, user.Role)
token, err := s.jwtService.GenerateToken(user.ID, user.Username, user.Role)
if err != nil {
return nil, "", errors.New("生成Token失败")
}
// TODO: 添加注册奖励积分
return user, token, nil
}
// LoginUser 用户登录(支持用户名或邮箱登录)
func LoginUser(jwtService *auth.JWTService, usernameOrEmail, password, ipAddress, userAgent string) (*model.User, string, error) {
// 查找用户:判断是用户名还是邮箱
func (s *userService) Login(ctx context.Context, usernameOrEmail, password, ipAddress, userAgent string) (*model.User, string, error) {
// 检查账号是否被锁定
if s.redis != nil {
identifier := usernameOrEmail + ":" + ipAddress
locked, ttl, err := CheckLoginLocked(ctx, s.redis, identifier)
if err == nil && locked {
return nil, "", fmt.Errorf("登录尝试次数过多,请在 %d 分钟后重试", int(ttl.Minutes())+1)
}
}
// 查找用户
var user *model.User
var err error
if strings.Contains(usernameOrEmail, "@") {
// 包含@符号,认为是邮箱
user, err = repository.FindUserByEmail(usernameOrEmail)
user, err = s.userRepo.FindByEmail(ctx, usernameOrEmail)
} else {
// 否则认为是用户名
user, err = repository.FindUserByUsername(usernameOrEmail)
user, err = s.userRepo.FindByUsername(ctx, usernameOrEmail)
}
if err != nil {
return nil, "", err
}
if user == nil {
// 记录失败日志
logFailedLogin(0, ipAddress, userAgent, "用户不存在")
s.recordLoginFailure(ctx, usernameOrEmail, ipAddress, userAgent, 0, "用户不存在")
return nil, "", errors.New("用户名/邮箱或密码错误")
}
// 检查用户状态
if user.Status != 1 {
logFailedLogin(user.ID, ipAddress, userAgent, "账号已被禁用")
s.recordLoginFailure(ctx, usernameOrEmail, ipAddress, userAgent, user.ID, "账号已被禁用")
return nil, "", errors.New("账号已被禁用")
}
// 验证密码
if !auth.CheckPassword(user.Password, password) {
logFailedLogin(user.ID, ipAddress, userAgent, "密码错误")
s.recordLoginFailure(ctx, usernameOrEmail, ipAddress, userAgent, user.ID, "密码错误")
return nil, "", errors.New("用户名/邮箱或密码错误")
}
// 登录成功,清除失败计数
if s.redis != nil {
identifier := usernameOrEmail + ":" + ipAddress
_ = ClearLoginAttempts(ctx, s.redis, identifier)
}
// 生成JWT Token
token, err := jwtService.GenerateToken(user.ID, user.Username, user.Role)
token, err := s.jwtService.GenerateToken(user.ID, user.Username, user.Role)
if err != nil {
return nil, "", errors.New("生成Token失败")
}
@@ -111,97 +173,317 @@ func LoginUser(jwtService *auth.JWTService, usernameOrEmail, password, ipAddress
// 更新最后登录时间
now := time.Now()
user.LastLoginAt = &now
_ = repository.UpdateUserFields(user.ID, map[string]interface{}{
_ = s.userRepo.UpdateFields(ctx, user.ID, map[string]interface{}{
"last_login_at": now,
})
// 记录成功登录日志
logSuccessLogin(user.ID, ipAddress, userAgent)
s.logSuccessLogin(ctx, user.ID, ipAddress, userAgent)
return user, token, nil
}
// GetUserByID 根据ID获取用户
func GetUserByID(id int64) (*model.User, error) {
return repository.FindUserByID(id)
func (s *userService) GetByID(ctx context.Context, id int64) (*model.User, error) {
// 使用 Cached 装饰器自动处理缓存
cacheKey := s.cacheKeys.User(id)
return database.Cached(ctx, s.cache, cacheKey, func() (*model.User, error) {
return s.userRepo.FindByID(ctx, id)
}, s.cache.Policy.UserTTL)
}
// UpdateUserInfo 更新用户信息
func UpdateUserInfo(user *model.User) error {
return repository.UpdateUser(user)
func (s *userService) GetByEmail(ctx context.Context, email string) (*model.User, error) {
// 使用 Cached 装饰器自动处理缓存
cacheKey := s.cacheKeys.UserByEmail(email)
return database.Cached(ctx, s.cache, cacheKey, func() (*model.User, error) {
return s.userRepo.FindByEmail(ctx, email)
}, s.cache.Policy.UserEmailTTL)
}
// UpdateUserAvatar 更新用户头像
func UpdateUserAvatar(userID int64, avatarURL string) error {
return repository.UpdateUserFields(userID, map[string]interface{}{
func (s *userService) GetByUsername(ctx context.Context, username string) (*model.User, error) {
// 使用 Cached 装饰器自动处理缓存
cacheKey := s.cacheKeys.UserByUsername(username)
return database.Cached(ctx, s.cache, cacheKey, func() (*model.User, error) {
return s.userRepo.FindByUsername(ctx, username)
}, s.cache.Policy.UserTTL)
}
func (s *userService) UpdateInfo(ctx context.Context, user *model.User) error {
err := s.userRepo.Update(ctx, user)
if err != nil {
return err
}
// 清除缓存
s.cacheInv.OnUpdate(ctx,
s.cacheKeys.User(user.ID),
s.cacheKeys.UserByEmail(user.Email),
s.cacheKeys.UserByUsername(user.Username),
)
return nil
}
func (s *userService) UpdateAvatar(ctx context.Context, userID int64, avatarURL string) error {
err := s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{
"avatar": avatarURL,
})
if err != nil {
return err
}
// 清除用户缓存
s.cacheInv.OnUpdate(ctx, s.cacheKeys.User(userID))
return nil
}
// ChangeUserPassword 修改密码
func ChangeUserPassword(userID int64, oldPassword, newPassword string) error {
// 获取用户
user, err := repository.FindUserByID(userID)
if err != nil {
func (s *userService) ChangePassword(ctx context.Context, userID int64, oldPassword, newPassword string) error {
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil || user == nil {
return errors.New("用户不存在")
}
// 验证旧密码
if !auth.CheckPassword(user.Password, oldPassword) {
return errors.New("原密码错误")
}
// 加密新密码
hashedPassword, err := auth.HashPassword(newPassword)
if err != nil {
return errors.New("密码加密失败")
}
// 更新密码
return repository.UpdateUserFields(userID, map[string]interface{}{
err = s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{
"password": hashedPassword,
})
if err != nil {
return err
}
// 清除用户缓存
s.cacheInv.OnUpdate(ctx, s.cacheKeys.User(userID))
return nil
}
// ResetUserPassword 重置密码(通过邮箱)
func ResetUserPassword(email, newPassword string) error {
// 查找用户
user, err := repository.FindUserByEmail(email)
if err != nil {
func (s *userService) ResetPassword(ctx context.Context, email, newPassword string) error {
user, err := s.userRepo.FindByEmail(ctx, email)
if err != nil || user == nil {
return errors.New("用户不存在")
}
// 加密新密码
hashedPassword, err := auth.HashPassword(newPassword)
if err != nil {
return errors.New("密码加密失败")
}
// 更新密码
return repository.UpdateUserFields(user.ID, map[string]interface{}{
err = s.userRepo.UpdateFields(ctx, user.ID, map[string]interface{}{
"password": hashedPassword,
})
if err != nil {
return err
}
// 清除用户缓存
s.cacheInv.OnUpdate(ctx,
s.cacheKeys.User(user.ID),
s.cacheKeys.UserByEmail(email),
)
return nil
}
// ChangeUserEmail 更换邮箱
func ChangeUserEmail(userID int64, newEmail string) error {
// 检查新邮箱是否已被使用
existingUser, err := repository.FindUserByEmail(newEmail)
func (s *userService) ChangeEmail(ctx context.Context, userID int64, newEmail string) error {
// 获取旧邮箱
oldUser, _ := s.userRepo.FindByID(ctx, userID)
existingUser, err := s.userRepo.FindByEmail(ctx, newEmail)
if err != nil {
return err
}
if existingUser != nil && existingUser.ID != userID {
return errors.New("邮箱已被其他用户使用")
return apperrors.ErrEmailAlreadyExists
}
// 更新邮箱
return repository.UpdateUserFields(userID, map[string]interface{}{
err = s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{
"email": newEmail,
})
if err != nil {
return err
}
// 清除旧邮箱和用户ID的缓存
keysToInvalidate := []string{
s.cacheKeys.User(userID),
s.cacheKeys.UserByEmail(newEmail),
}
if oldUser != nil {
keysToInvalidate = append(keysToInvalidate, s.cacheKeys.UserByEmail(oldUser.Email))
}
s.cacheInv.OnUpdate(ctx, keysToInvalidate...)
return nil
}
// logSuccessLogin 记录成功登录
func logSuccessLogin(userID int64, ipAddress, userAgent string) {
func (s *userService) ValidateAvatarURL(ctx context.Context, avatarURL string) error {
if avatarURL == "" {
return nil
}
// 允许相对路径
if strings.HasPrefix(avatarURL, "/") {
return nil
}
// 解析URL
parsedURL, err := url.Parse(avatarURL)
if err != nil {
return errors.New("无效的URL格式")
}
// 必须是HTTP或HTTPS协议
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return errors.New("URL必须使用http或https协议")
}
host := parsedURL.Hostname()
if host == "" {
return errors.New("URL缺少主机名")
}
// 从配置获取允许的域名列表
cfg, err := config.GetConfig()
if err != nil {
allowedDomains := []string{"localhost", "127.0.0.1"}
return s.checkDomainAllowed(host, allowedDomains)
}
return s.checkDomainAllowed(host, cfg.Security.AllowedDomains)
}
func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error) {
// 验证文件大小
fileSize := len(fileData)
const minSize = 512 // 512B
const maxSize = 5 * 1024 * 1024 // 5MB
if int64(fileSize) < minSize || int64(fileSize) > maxSize {
return "", fmt.Errorf("文件大小必须在 %d 到 %d 字节之间", minSize, maxSize)
}
// 验证文件扩展名
ext := strings.ToLower(filepath.Ext(fileName))
allowedExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
if !allowedExts[ext] {
return "", fmt.Errorf("不支持的文件格式: %s仅支持 jpg/jpeg/png/gif/webp", ext)
}
// 检查存储服务
if s.storage == nil {
return "", errors.New("存储服务不可用")
}
// 计算文件哈希
hashBytes := sha256.Sum256(fileData)
hash := hex.EncodeToString(hashBytes[:])
// 获取存储桶
bucketName, err := s.storage.GetBucket("avatars")
if err != nil {
return "", fmt.Errorf("获取存储桶失败: %w", err)
}
// 生成对象路径: avatars/{hash[:2]}/{hash[2:4]}/{hash}{ext}
objectName := fmt.Sprintf("%s/%s/%s%s", hash[:2], hash[2:4], hash, ext)
// 上传文件
reader := bytes.NewReader(fileData)
contentType := "image/" + strings.TrimPrefix(ext, ".")
if ext == ".jpg" {
contentType = "image/jpeg"
}
if err := s.storage.UploadObject(ctx, bucketName, objectName, reader, int64(fileSize), contentType); err != nil {
return "", fmt.Errorf("上传文件失败: %w", err)
}
// 构建文件URL
avatarURL := s.storage.BuildFileURL(bucketName, objectName)
// 更新用户头像
if err := s.UpdateAvatar(ctx, userID, avatarURL); err != nil {
return "", fmt.Errorf("更新用户头像失败: %w", err)
}
s.logger.Info("上传头像成功",
zap.Int64("user_id", userID),
zap.String("hash", hash),
zap.String("url", avatarURL),
)
return avatarURL, nil
}
func (s *userService) GetMaxProfilesPerUser() int {
cfg, err := config.GetConfig()
if err != nil || cfg.Site.MaxProfilesPerUser <= 0 {
return 5
}
return cfg.Site.MaxProfilesPerUser
}
func (s *userService) GetMaxTexturesPerUser() int {
cfg, err := config.GetConfig()
if err != nil || cfg.Site.MaxTexturesPerUser <= 0 {
return 50
}
return cfg.Site.MaxTexturesPerUser
}
// 私有辅助方法
func (s *userService) getDefaultAvatar() string {
cfg, err := config.GetConfig()
if err != nil {
return ""
}
return cfg.Site.DefaultAvatar
}
func (s *userService) checkDomainAllowed(host string, allowedDomains []string) error {
host = strings.ToLower(host)
for _, allowed := range allowedDomains {
allowed = strings.ToLower(strings.TrimSpace(allowed))
if allowed == "" {
continue
}
if host == allowed {
return nil
}
if strings.HasPrefix(allowed, "*.") {
suffix := allowed[1:]
if strings.HasSuffix(host, suffix) {
return nil
}
}
}
return errors.New("URL域名不在允许的列表中")
}
func (s *userService) recordLoginFailure(ctx context.Context, usernameOrEmail, ipAddress, userAgent string, userID int64, reason string) {
if s.redis != nil {
identifier := usernameOrEmail + ":" + ipAddress
count, _ := RecordLoginFailure(ctx, s.redis, identifier)
if count >= MaxLoginAttempts {
s.logFailedLogin(ctx, userID, ipAddress, userAgent, reason+"-账号已锁定")
return
}
}
s.logFailedLogin(ctx, userID, ipAddress, userAgent, reason)
}
func (s *userService) logSuccessLogin(ctx context.Context, userID int64, ipAddress, userAgent string) {
log := &model.UserLoginLog{
UserID: userID,
IPAddress: ipAddress,
@@ -209,11 +491,10 @@ func logSuccessLogin(userID int64, ipAddress, userAgent string) {
LoginMethod: "PASSWORD",
IsSuccess: true,
}
_ = repository.CreateLoginLog(log)
_ = s.userRepo.CreateLoginLog(ctx, log)
}
// logFailedLogin 记录失败登录
func logFailedLogin(userID int64, ipAddress, userAgent, reason string) {
func (s *userService) logFailedLogin(ctx context.Context, userID int64, ipAddress, userAgent, reason string) {
log := &model.UserLoginLog{
UserID: userID,
IPAddress: ipAddress,
@@ -222,27 +503,5 @@ func logFailedLogin(userID int64, ipAddress, userAgent, reason string) {
IsSuccess: false,
FailureReason: reason,
}
_ = repository.CreateLoginLog(log)
}
// getDefaultAvatar 获取默认头像URL
func getDefaultAvatar() string {
// 如果数据库中不存在默认头像配置,返回错误信息
const log = "数据库中不存在默认头像配置"
// 尝试从数据库读取配置
config, err := repository.GetSystemConfigByKey("default_avatar")
if err != nil || config == nil {
return log
}
return config.Value
}
func GetUserByEmail(email string) (*model.User, error) {
user, err := repository.FindUserByEmail(email)
if err != nil {
return nil, errors.New("邮箱查找失败")
}
return user, nil
_ = s.userRepo.CreateLoginLog(ctx, log)
}

View File

@@ -1,199 +1,384 @@
package service
import (
"strings"
"carrotskin/internal/model"
"carrotskin/pkg/auth"
"context"
"testing"
"go.uber.org/zap"
)
// TestGetDefaultAvatar 测试获取默认头像的逻辑
// 注意这个测试需要mock repository但由于repository是函数式的
// 我们只测试逻辑部分
func TestGetDefaultAvatar_Logic(t *testing.T) {
func TestUserServiceImpl_Register(t *testing.T) {
// 准备依赖
userRepo := NewMockUserRepository()
jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop()
// 初始化Service
// 注意redisClient 和 storageClient 传入 nil因为 Register 方法中没有使用它们
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
// 测试用例
tests := []struct {
name string
configExists bool
configValue string
expectedResult string
name string
username string
password string
email string
avatar string
wantErr bool
errMsg string
setupMocks func()
}{
{
name: "配置存在时返回配置值",
configExists: true,
configValue: "https://example.com/avatar.png",
expectedResult: "https://example.com/avatar.png",
name: "正常注册",
username: "testuser",
password: "password123",
email: "test@example.com",
avatar: "",
wantErr: false,
},
{
name: "配置不存在时返回错误信息",
configExists: false,
configValue: "",
expectedResult: "数据库中不存在默认头像配置",
name: "用户名已存在",
username: "existinguser",
password: "password123",
email: "new@example.com",
avatar: "",
wantErr: true,
// 服务实现现已统一使用 apperrors.ErrUserAlreadyExists错误信息为“用户已存在”
errMsg: "用户已存在",
setupMocks: func() {
_ = userRepo.Create(context.Background(), &model.User{
Username: "existinguser",
Email: "old@example.com",
})
},
},
{
name: "邮箱已存在",
username: "newuser",
password: "password123",
email: "existing@example.com",
avatar: "",
wantErr: true,
errMsg: "邮箱已被注册",
setupMocks: func() {
_ = userRepo.Create(context.Background(), &model.User{
Username: "otheruser",
Email: "existing@example.com",
})
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 这个测试只验证逻辑不实际调用repository
// 实际的repository调用测试需要集成测试或mock
if tt.configExists {
if tt.expectedResult != tt.configValue {
t.Errorf("当配置存在时,应该返回配置值")
// 重置mock状态
if tt.setupMocks != nil {
tt.setupMocks()
}
user, token, err := userService.Register(ctx, tt.username, tt.password, tt.email, tt.avatar)
if tt.wantErr {
if err == nil {
t.Error("期望返回错误,但实际没有错误")
return
}
if tt.errMsg != "" && err.Error() != tt.errMsg {
t.Errorf("错误信息不匹配: got %v, want %v", err.Error(), tt.errMsg)
}
} else {
if !strings.Contains(tt.expectedResult, "数据库中不存在默认头像配置") {
t.Errorf("当配置不存在时,应该返回错误信息")
if err != nil {
t.Errorf("不期望返回错误: %v", err)
return
}
if user == nil {
t.Error("返回的用户不应为nil")
}
if token == "" {
t.Error("返回的Token不应为空")
}
if user.Username != tt.username {
t.Errorf("用户名不匹配: got %v, want %v", user.Username, tt.username)
}
}
})
}
}
// TestLoginUser_EmailDetection 测试登录时邮箱检测逻辑
func TestLoginUser_EmailDetection(t *testing.T) {
func TestUserServiceImpl_Login(t *testing.T) {
// 准备依赖
userRepo := NewMockUserRepository()
jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop()
// 预置用户
password := "password123"
hashedPassword, _ := auth.HashPassword(password)
testUser := &model.User{
Username: "testlogin",
Email: "login@example.com",
Password: hashedPassword,
Status: 1,
}
_ = userRepo.Create(context.Background(), testUser)
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
tests := []struct {
name string
usernameOrEmail string
isEmail bool
password string
wantErr bool
errMsg string
}{
{
name: "包含@符号,识别为邮箱",
usernameOrEmail: "user@example.com",
isEmail: true,
name: "用户名登录成功",
usernameOrEmail: "testlogin",
password: "password123",
wantErr: false,
},
{
name: "不包含@符号,识别为用户名",
usernameOrEmail: "username",
isEmail: false,
name: "邮箱登录成功",
usernameOrEmail: "login@example.com",
password: "password123",
wantErr: false,
},
{
name: "空字符串",
usernameOrEmail: "",
isEmail: false,
name: "密码错误",
usernameOrEmail: "testlogin",
password: "wrongpassword",
wantErr: true,
errMsg: "用户名/邮箱或密码错误",
},
{
name: "只有@符号",
usernameOrEmail: "@",
isEmail: true,
name: "用户不存在",
usernameOrEmail: "nonexistent",
password: "password123",
wantErr: true,
errMsg: "用户名/邮箱或密码错误",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isEmail := strings.Contains(tt.usernameOrEmail, "@")
if isEmail != tt.isEmail {
t.Errorf("Email detection failed: got %v, want %v", isEmail, tt.isEmail)
user, token, err := userService.Login(ctx, tt.usernameOrEmail, tt.password, "127.0.0.1", "test-agent")
if tt.wantErr {
if err == nil {
t.Error("期望返回错误,但实际没有错误")
} else if tt.errMsg != "" && err.Error() != tt.errMsg {
t.Errorf("错误信息不匹配: got %v, want %v", err.Error(), tt.errMsg)
}
} else {
if err != nil {
t.Errorf("不期望返回错误: %v", err)
}
if user == nil {
t.Error("用户不应为nil")
}
if token == "" {
t.Error("Token不应为空")
}
}
})
}
}
// TestUserService_Constants 测试用户服务相关常量
func TestUserService_Constants(t *testing.T) {
// 测试默认用户角色
defaultRole := "user"
if defaultRole == "" {
t.Error("默认用户角色不能为空")
// TestUserServiceImpl_BasicGetters 测试 GetByID / GetByEmail / UpdateInfo / UpdateAvatar
func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
userRepo := NewMockUserRepository()
jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop()
// 预置用户
user := &model.User{
ID: 1,
Username: "basic",
Email: "basic@example.com",
Avatar: "",
}
_ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
// GetByID
gotByID, err := userService.GetByID(ctx, 1)
if err != nil || gotByID == nil || gotByID.ID != 1 {
t.Fatalf("GetByID 返回不正确: user=%+v, err=%v", gotByID, err)
}
// 测试默认用户状态
defaultStatus := int16(1)
if defaultStatus != 1 {
t.Errorf("默认用户状态应为1正常实际为%d", defaultStatus)
// GetByEmail
gotByEmail, err := userService.GetByEmail(ctx, "basic@example.com")
if err != nil || gotByEmail == nil || gotByEmail.Email != "basic@example.com" {
t.Fatalf("GetByEmail 返回不正确: user=%+v, err=%v", gotByEmail, err)
}
// 测试初始积分
initialPoints := 0
if initialPoints < 0 {
t.Errorf("初始积分不应为负数,实际为%d", initialPoints)
// UpdateInfo
user.Username = "updated"
if err := userService.UpdateInfo(ctx, user); err != nil {
t.Fatalf("UpdateInfo 失败: %v", err)
}
updated, _ := userRepo.FindByID(context.Background(), 1)
if updated.Username != "updated" {
t.Fatalf("UpdateInfo 未更新用户名, got=%s", updated.Username)
}
// UpdateAvatar 只需确认不会返回错误(具体字段更新由仓库层保证)
if err := userService.UpdateAvatar(ctx, 1, "http://example.com/avatar.png"); err != nil {
t.Fatalf("UpdateAvatar 失败: %v", err)
}
}
// TestUserService_Validation 测试用户数据验证逻辑
func TestUserService_Validation(t *testing.T) {
// TestUserServiceImpl_ChangePassword 测试 ChangePassword
func TestUserServiceImpl_ChangePassword(t *testing.T) {
userRepo := NewMockUserRepository()
jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop()
hashed, _ := auth.HashPassword("oldpass")
user := &model.User{
ID: 1,
Username: "changepw",
Password: hashed,
}
_ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
// 原密码正确
if err := userService.ChangePassword(ctx, 1, "oldpass", "newpass"); err != nil {
t.Fatalf("ChangePassword 正常情况失败: %v", err)
}
// 用户不存在
if err := userService.ChangePassword(ctx, 999, "oldpass", "newpass"); err == nil {
t.Fatalf("ChangePassword 应在用户不存在时返回错误")
}
// 原密码错误
if err := userService.ChangePassword(ctx, 1, "wrong", "another"); err == nil {
t.Fatalf("ChangePassword 应在原密码错误时返回错误")
}
}
// TestUserServiceImpl_ResetPassword 测试 ResetPassword
func TestUserServiceImpl_ResetPassword(t *testing.T) {
userRepo := NewMockUserRepository()
jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop()
user := &model.User{
ID: 1,
Username: "resetpw",
Email: "reset@example.com",
}
_ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
// 正常重置
if err := userService.ResetPassword(ctx, "reset@example.com", "newpass"); err != nil {
t.Fatalf("ResetPassword 正常情况失败: %v", err)
}
// 用户不存在
if err := userService.ResetPassword(ctx, "notfound@example.com", "newpass"); err == nil {
t.Fatalf("ResetPassword 应在用户不存在时返回错误")
}
}
// TestUserServiceImpl_ChangeEmail 测试 ChangeEmail
func TestUserServiceImpl_ChangeEmail(t *testing.T) {
userRepo := NewMockUserRepository()
jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop()
user1 := &model.User{ID: 1, Email: "user1@example.com"}
user2 := &model.User{ID: 2, Email: "user2@example.com"}
_ = userRepo.Create(context.Background(), user1)
_ = userRepo.Create(context.Background(), user2)
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
// 正常修改
if err := userService.ChangeEmail(ctx, 1, "new@example.com"); err != nil {
t.Fatalf("ChangeEmail 正常情况失败: %v", err)
}
// 邮箱被其他用户占用
if err := userService.ChangeEmail(ctx, 1, "user2@example.com"); err == nil {
t.Fatalf("ChangeEmail 应在邮箱被占用时返回错误")
}
}
// TestUserServiceImpl_ValidateAvatarURL 测试 ValidateAvatarURL
func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
userRepo := NewMockUserRepository()
jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop()
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background()
tests := []struct {
name string
username string
email string
password string
wantValid bool
name string
url string
wantErr bool
}{
{
name: "有效的用户名和邮箱",
username: "testuser",
email: "test@example.com",
password: "password123",
wantValid: true,
},
{
name: "用户名为空",
username: "",
email: "test@example.com",
password: "password123",
wantValid: false,
},
{
name: "邮箱为空",
username: "testuser",
email: "",
password: "password123",
wantValid: false,
},
{
name: "密码为空",
username: "testuser",
email: "test@example.com",
password: "",
wantValid: false,
},
{
name: "邮箱格式无效(缺少@",
username: "testuser",
email: "invalid-email",
password: "password123",
wantValid: false,
},
{"空字符串通过", "", false},
{"相对路径通过", "/images/avatar.png", false},
{"非法URL格式", "://bad-url", true},
{"非法协议", "ftp://example.com/avatar.png", true},
{"缺少主机名", "http:///avatar.png", true},
{"本地域名通过", "http://localhost/avatar.png", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 简单的验证逻辑测试
isValid := tt.username != "" && tt.email != "" && tt.password != "" && strings.Contains(tt.email, "@")
if isValid != tt.wantValid {
t.Errorf("Validation failed: got %v, want %v", isValid, tt.wantValid)
err := userService.ValidateAvatarURL(ctx, tt.url)
if (err != nil) != tt.wantErr {
t.Fatalf("ValidateAvatarURL(%q) error = %v, wantErr=%v", tt.url, err, tt.wantErr)
}
})
}
}
// TestUserService_AvatarLogic 测试头像逻辑
func TestUserService_AvatarLogic(t *testing.T) {
tests := []struct {
name string
providedAvatar string
defaultAvatar string
expectedAvatar string
}{
{
name: "提供头像时使用提供的头像",
providedAvatar: "https://example.com/custom.png",
defaultAvatar: "https://example.com/default.png",
expectedAvatar: "https://example.com/custom.png",
},
{
name: "未提供头像时使用默认头像",
providedAvatar: "",
defaultAvatar: "https://example.com/default.png",
expectedAvatar: "https://example.com/default.png",
},
}
// TestUserServiceImpl_MaxLimits 测试 GetMaxProfilesPerUser / GetMaxTexturesPerUser
// 现在配置从环境变量读取,测试默认值
func TestUserServiceImpl_MaxLimits(t *testing.T) {
userRepo := NewMockUserRepository()
jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
avatarURL := tt.providedAvatar
if avatarURL == "" {
avatarURL = tt.defaultAvatar
}
if avatarURL != tt.expectedAvatar {
t.Errorf("Avatar logic failed: got %s, want %s", avatarURL, tt.expectedAvatar)
}
})
// 未配置时走默认值
cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
if got := userService.GetMaxProfilesPerUser(); got != 5 {
t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got)
}
if got := userService.GetMaxTexturesPerUser(); got != 50 {
t.Fatalf("GetMaxTexturesPerUser 默认值错误, got=%d", got)
}
}

View File

@@ -7,24 +7,139 @@ import (
"math/big"
"time"
"carrotskin/pkg/config"
"carrotskin/pkg/email"
"carrotskin/pkg/redis"
)
const (
// 验证码类型
VerificationTypeRegister = "register"
VerificationTypeRegister = "register"
VerificationTypeResetPassword = "reset_password"
VerificationTypeChangeEmail = "change_email"
// 验证码配置
CodeLength = 6 // 验证码长度
CodeLength = 6 // 验证码长度
CodeExpiration = 10 * time.Minute // 验证码有效期
CodeRateLimit = 1 * time.Minute // 发送频率限制
)
// GenerateVerificationCode 生成6位数字验证码
func GenerateVerificationCode() (string, error) {
// verificationService VerificationService的实现
type verificationService struct {
redis *redis.Client
emailService *email.Service
}
// NewVerificationService 创建VerificationService实例
func NewVerificationService(
redisClient *redis.Client,
emailService *email.Service,
) VerificationService {
return &verificationService{
redis: redisClient,
emailService: emailService,
}
}
// SendCode 发送验证码
func (s *verificationService) SendCode(ctx context.Context, email, codeType string) error {
// 测试环境下直接跳过,不存储也不发送
cfg, err := config.GetConfig()
if err == nil && cfg.IsTestEnvironment() {
return nil
}
// 检查发送频率限制
rateLimitKey := fmt.Sprintf("verification:rate_limit:%s:%s", codeType, email)
exists, err := s.redis.Exists(ctx, rateLimitKey)
if err != nil {
return fmt.Errorf("检查发送频率失败: %w", err)
}
if exists > 0 {
return fmt.Errorf("发送过于频繁,请稍后再试")
}
// 生成验证码
code, err := s.generateCode()
if err != nil {
return fmt.Errorf("生成验证码失败: %w", err)
}
// 存储验证码到Redis
codeKey := fmt.Sprintf("verification:code:%s:%s", codeType, email)
if err := s.redis.Set(ctx, codeKey, code, CodeExpiration); err != nil {
return fmt.Errorf("存储验证码失败: %w", err)
}
// 设置发送频率限制
if err := s.redis.Set(ctx, rateLimitKey, "1", CodeRateLimit); err != nil {
return fmt.Errorf("设置发送频率限制失败: %w", err)
}
// 发送邮件
if err := s.sendEmail(email, code, codeType); err != nil {
// 发送失败,删除验证码
_ = s.redis.Del(ctx, codeKey)
return fmt.Errorf("发送邮件失败: %w", err)
}
return nil
}
// VerifyCode 验证验证码
func (s *verificationService) VerifyCode(ctx context.Context, email, code, codeType string) error {
// 测试环境下直接通过验证
cfg, err := config.GetConfig()
if err == nil && cfg.IsTestEnvironment() {
return nil
}
// 检查是否被锁定
locked, ttl, err := CheckVerifyLocked(ctx, s.redis, email, codeType)
if err == nil && locked {
return fmt.Errorf("验证码错误次数过多,请在 %d 分钟后重试", int(ttl.Minutes())+1)
}
codeKey := fmt.Sprintf("verification:code:%s:%s", codeType, email)
// 从Redis获取验证码
storedCode, err := s.redis.Get(ctx, codeKey)
if err != nil {
// 记录失败尝试并检查是否触发锁定
count, _ := RecordVerifyFailure(ctx, s.redis, email, codeType)
if count >= MaxVerifyAttempts {
return fmt.Errorf("验证码错误次数过多,账号已被锁定 %d 分钟", int(VerifyLockDuration.Minutes()))
}
remaining := MaxVerifyAttempts - count
if remaining > 0 {
return fmt.Errorf("验证码已过期或不存在,还剩 %d 次尝试机会", remaining)
}
return fmt.Errorf("验证码已过期或不存在")
}
// 验证验证码
if storedCode != code {
// 记录失败尝试并检查是否触发锁定
count, _ := RecordVerifyFailure(ctx, s.redis, email, codeType)
if count >= MaxVerifyAttempts {
return fmt.Errorf("验证码错误次数过多,账号已被锁定 %d 分钟", int(VerifyLockDuration.Minutes()))
}
remaining := MaxVerifyAttempts - count
if remaining > 0 {
return fmt.Errorf("验证码错误,还剩 %d 次尝试机会", remaining)
}
return fmt.Errorf("验证码错误")
}
// 验证成功,删除验证码和失败计数
_ = s.redis.Del(ctx, codeKey)
_ = ClearVerifyAttempts(ctx, s.redis, email, codeType)
return nil
}
// generateCode 生成6位数字验证码
func (s *verificationService) generateCode() (string, error) {
const digits = "0123456789"
code := make([]byte, CodeLength)
for i := range code {
@@ -37,82 +152,22 @@ func GenerateVerificationCode() (string, error) {
return string(code), nil
}
// SendVerificationCode 发送验证码
func SendVerificationCode(ctx context.Context, redisClient *redis.Client, emailService *email.Service, email, codeType string) error {
// 检查发送频率限制
rateLimitKey := fmt.Sprintf("verification:rate_limit:%s:%s", codeType, email)
exists, err := redisClient.Exists(ctx, rateLimitKey)
if err != nil {
return fmt.Errorf("检查发送频率失败: %w", err)
// sendEmail 根据类型发送邮件
func (s *verificationService) sendEmail(to, code, codeType string) error {
switch codeType {
case VerificationTypeRegister:
return s.emailService.SendEmailVerification(to, code)
case VerificationTypeResetPassword:
return s.emailService.SendResetPassword(to, code)
case VerificationTypeChangeEmail:
return s.emailService.SendChangeEmail(to, code)
default:
return s.emailService.SendVerificationCode(to, code, codeType)
}
if exists > 0 {
return fmt.Errorf("发送过于频繁,请稍后再试")
}
// 生成验证码
code, err := GenerateVerificationCode()
if err != nil {
return fmt.Errorf("生成验证码失败: %w", err)
}
// 存储验证码到Redis
codeKey := fmt.Sprintf("verification:code:%s:%s", codeType, email)
if err := redisClient.Set(ctx, codeKey, code, CodeExpiration); err != nil {
return fmt.Errorf("存储验证码失败: %w", err)
}
// 设置发送频率限制
if err := redisClient.Set(ctx, rateLimitKey, "1", CodeRateLimit); err != nil {
return fmt.Errorf("设置发送频率限制失败: %w", err)
}
// 发送邮件
if err := sendVerificationEmail(emailService, email, code, codeType); err != nil {
// 发送失败,删除验证码
_ = redisClient.Del(ctx, codeKey)
return fmt.Errorf("发送邮件失败: %w", err)
}
return nil
}
// VerifyCode 验证验证码
func VerifyCode(ctx context.Context, redisClient *redis.Client, email, code, codeType string) error {
codeKey := fmt.Sprintf("verification:code:%s:%s", codeType, email)
// 从Redis获取验证码
storedCode, err := redisClient.Get(ctx, codeKey)
if err != nil {
return fmt.Errorf("验证码已过期或不存在")
}
// 验证验证码
if storedCode != code {
return fmt.Errorf("验证码错误")
}
// 验证成功,删除验证码
_ = redisClient.Del(ctx, codeKey)
return nil
}
// DeleteVerificationCode 删除验证码
// DeleteVerificationCode 删除验证码(工具函数,保持向后兼容)
func DeleteVerificationCode(ctx context.Context, redisClient *redis.Client, email, codeType string) error {
codeKey := fmt.Sprintf("verification:code:%s:%s", codeType, email)
return redisClient.Del(ctx, codeKey)
}
// sendVerificationEmail 根据类型发送邮件
func sendVerificationEmail(emailService *email.Service, to, code, codeType string) error {
switch codeType {
case VerificationTypeRegister:
return emailService.SendEmailVerification(to, code)
case VerificationTypeResetPassword:
return emailService.SendResetPassword(to, code)
case VerificationTypeChangeEmail:
return emailService.SendChangeEmail(to, code)
default:
return emailService.SendVerificationCode(to, code, codeType)
}
}

View File

@@ -7,6 +7,9 @@ import (
// TestGenerateVerificationCode 测试生成验证码函数
func TestGenerateVerificationCode(t *testing.T) {
// 创建服务实例(使用 nil因为这个测试不需要依赖
svc := &verificationService{}
tests := []struct {
name string
wantLen int
@@ -21,18 +24,18 @@ func TestGenerateVerificationCode(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, err := GenerateVerificationCode()
code, err := svc.generateCode()
if (err != nil) != tt.wantErr {
t.Errorf("GenerateVerificationCode() error = %v, wantErr %v", err, tt.wantErr)
t.Errorf("generateCode() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(code) != tt.wantLen {
t.Errorf("GenerateVerificationCode() code length = %v, want %v", len(code), tt.wantLen)
t.Errorf("generateCode() code length = %v, want %v", len(code), tt.wantLen)
}
// 验证验证码只包含数字
for _, c := range code {
if c < '0' || c > '9' {
t.Errorf("GenerateVerificationCode() code contains non-digit: %c", c)
t.Errorf("generateCode() code contains non-digit: %c", c)
}
}
})
@@ -41,9 +44,9 @@ func TestGenerateVerificationCode(t *testing.T) {
// 测试多次生成,验证码应该不同(概率上)
codes := make(map[string]bool)
for i := 0; i < 100; i++ {
code, err := GenerateVerificationCode()
code, err := svc.generateCode()
if err != nil {
t.Fatalf("GenerateVerificationCode() failed: %v", err)
t.Fatalf("generateCode() failed: %v", err)
}
if codes[code] {
t.Logf("发现重复验证码这是正常的因为只有6位数字: %s", code)
@@ -82,9 +85,10 @@ func TestVerificationConstants(t *testing.T) {
// TestVerificationCodeFormat 测试验证码格式
func TestVerificationCodeFormat(t *testing.T) {
code, err := GenerateVerificationCode()
svc := &verificationService{}
code, err := svc.generateCode()
if err != nil {
t.Fatalf("GenerateVerificationCode() failed: %v", err)
t.Fatalf("generateCode() failed: %v", err)
}
// 验证长度

View File

@@ -0,0 +1,94 @@
package service
import (
apperrors "carrotskin/internal/errors"
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/auth"
"context"
"fmt"
"go.uber.org/zap"
"gorm.io/gorm"
)
// yggdrasilAuthService Yggdrasil认证服务实现
// 负责认证和密码管理
type yggdrasilAuthService struct {
db *gorm.DB
userRepo repository.UserRepository
yggdrasilRepo repository.YggdrasilRepository
logger *zap.Logger
}
// NewYggdrasilAuthService 创建Yggdrasil认证服务实例内部使用
func NewYggdrasilAuthService(
db *gorm.DB,
userRepo repository.UserRepository,
yggdrasilRepo repository.YggdrasilRepository,
logger *zap.Logger,
) *yggdrasilAuthService {
return &yggdrasilAuthService{
db: db,
userRepo: userRepo,
yggdrasilRepo: yggdrasilRepo,
logger: logger,
}
}
func (s *yggdrasilAuthService) GetUserIDByEmail(ctx context.Context, email string) (int64, error) {
user, err := s.userRepo.FindByEmail(ctx, email)
if err != nil {
return 0, apperrors.ErrUserNotFound
}
if user == nil {
return 0, apperrors.ErrUserNotFound
}
return user.ID, nil
}
func (s *yggdrasilAuthService) VerifyPassword(ctx context.Context, password string, userID int64) error {
passwordStore, err := s.yggdrasilRepo.GetPasswordByID(ctx, userID)
if err != nil {
return apperrors.ErrPasswordNotSet
}
// 使用 bcrypt 验证密码
if !auth.CheckPassword(passwordStore, password) {
return apperrors.ErrPasswordMismatch
}
return nil
}
func (s *yggdrasilAuthService) ResetYggdrasilPassword(ctx context.Context, userID int64) (string, error) {
// 生成新的16位随机密码明文返回给用户
plainPassword := model.GenerateRandomPassword(16)
// 使用 bcrypt 加密密码后存储
hashedPassword, err := auth.HashPassword(plainPassword)
if err != nil {
return "", fmt.Errorf("密码加密失败: %w", err)
}
// 检查Yggdrasil记录是否存在
_, err = s.yggdrasilRepo.GetPasswordByID(ctx, userID)
if err != nil {
// 如果不存在,创建新记录
yggdrasil := model.Yggdrasil{
ID: userID,
Password: hashedPassword,
}
if err := s.db.Create(&yggdrasil).Error; err != nil {
return "", fmt.Errorf("创建Yggdrasil密码失败: %w", err)
}
return plainPassword, nil
}
// 如果存在,更新密码(存储加密后的密码)
if err := s.yggdrasilRepo.ResetPassword(ctx, userID, hashedPassword); err != nil {
return "", fmt.Errorf("重置Yggdrasil密码失败: %w", err)
}
// 返回明文密码给用户
return plainPassword, nil
}

View File

@@ -0,0 +1,112 @@
package service
import (
apperrors "carrotskin/internal/errors"
"carrotskin/internal/repository"
"context"
"fmt"
"time"
"go.uber.org/zap"
)
// CertificateService 证书服务接口
type CertificateService interface {
// GeneratePlayerCertificate 生成玩家证书
GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error)
// GetPublicKey 获取公钥
GetPublicKey(ctx context.Context) (string, error)
}
// yggdrasilCertificateService 证书服务实现
type yggdrasilCertificateService struct {
profileRepo repository.ProfileRepository
signatureService *SignatureService
logger *zap.Logger
}
// NewCertificateService 创建证书服务实例
func NewCertificateService(
profileRepo repository.ProfileRepository,
signatureService *SignatureService,
logger *zap.Logger,
) CertificateService {
return &yggdrasilCertificateService{
profileRepo: profileRepo,
signatureService: signatureService,
logger: logger,
}
}
// GeneratePlayerCertificate 生成玩家证书
func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error) {
if uuid == "" {
return nil, apperrors.ErrUUIDRequired
}
s.logger.Info("开始生成玩家证书",
zap.String("uuid", uuid),
)
// 获取密钥对
keyPair, err := s.profileRepo.GetKeyPair(ctx, uuid)
if err != nil {
s.logger.Info("获取用户密钥对失败,将创建新密钥对",
zap.Error(err),
zap.String("uuid", uuid),
)
keyPair = nil
}
// 如果没有找到密钥对或密钥对已过期,创建一个新的
now := time.Now().UTC()
if keyPair == nil || keyPair.Refresh.Before(now) || keyPair.PrivateKey == "" || keyPair.PublicKey == "" {
s.logger.Info("为用户创建新的密钥对",
zap.String("uuid", uuid),
)
keyPair, err = s.signatureService.NewKeyPair()
if err != nil {
s.logger.Error("生成玩家证书密钥对失败",
zap.Error(err),
zap.String("uuid", uuid),
)
return nil, fmt.Errorf("生成玩家证书密钥对失败: %w", err)
}
// 保存密钥对到数据库
err = s.profileRepo.UpdateKeyPair(ctx, uuid, keyPair)
if err != nil {
s.logger.Warn("更新用户密钥对失败",
zap.Error(err),
zap.String("uuid", uuid),
)
// 继续执行,即使保存失败
}
}
// 计算expiresAt的毫秒时间戳
expiresAtMillis := keyPair.Expiration.UnixMilli()
// 返回玩家证书
certificate := map[string]interface{}{
"keyPair": map[string]interface{}{
"privateKey": keyPair.PrivateKey,
"publicKey": keyPair.PublicKey,
},
"publicKeySignature": keyPair.PublicKeySignature,
"publicKeySignatureV2": keyPair.PublicKeySignatureV2,
"expiresAt": expiresAtMillis,
"refreshedAfter": keyPair.Refresh.UnixMilli(),
}
s.logger.Info("成功生成玩家证书",
zap.String("uuid", uuid),
)
return certificate, nil
}
// GetPublicKey 获取公钥
func (s *yggdrasilCertificateService) GetPublicKey(ctx context.Context) (string, error) {
return s.signatureService.GetPublicKeyFromRedis()
}

View File

@@ -0,0 +1,156 @@
package service
import (
"carrotskin/internal/model"
"carrotskin/internal/repository"
"context"
"encoding/base64"
"time"
"go.uber.org/zap"
)
// SerializationService 序列化服务接口
type SerializationService interface {
// SerializeProfile 序列化档案为Yggdrasil格式
SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{}
// SerializeUser 序列化用户为Yggdrasil格式
SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{}
}
// Property Yggdrasil属性
type Property struct {
Name string `json:"name"`
Value string `json:"value"`
Signature string `json:"signature,omitempty"`
}
// yggdrasilSerializationService 序列化服务实现
type yggdrasilSerializationService struct {
textureRepo repository.TextureRepository
signatureService *SignatureService
logger *zap.Logger
}
// NewSerializationService 创建序列化服务实例
func NewSerializationService(
textureRepo repository.TextureRepository,
signatureService *SignatureService,
logger *zap.Logger,
) SerializationService {
return &yggdrasilSerializationService{
textureRepo: textureRepo,
signatureService: signatureService,
logger: logger,
}
}
// SerializeProfile 序列化档案为Yggdrasil格式
func (s *yggdrasilSerializationService) SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{} {
// 创建基本材质数据
texturesMap := make(map[string]interface{})
textures := map[string]interface{}{
"timestamp": time.Now().UnixMilli(),
"profileId": profile.UUID,
"profileName": profile.Name,
"textures": texturesMap,
}
// 处理皮肤
if profile.SkinID != nil {
skin, err := s.textureRepo.FindByID(ctx, *profile.SkinID)
if err != nil {
s.logger.Error("获取皮肤失败",
zap.Error(err),
zap.Int64("skinID", *profile.SkinID),
)
} else if skin != nil {
texturesMap["SKIN"] = map[string]interface{}{
"url": skin.URL,
"metadata": skin.Size,
}
}
}
// 处理披风
if profile.CapeID != nil {
cape, err := s.textureRepo.FindByID(ctx, *profile.CapeID)
if err != nil {
s.logger.Error("获取披风失败",
zap.Error(err),
zap.Int64("capeID", *profile.CapeID),
)
} else if cape != nil {
texturesMap["CAPE"] = map[string]interface{}{
"url": cape.URL,
"metadata": cape.Size,
}
}
}
// 将textures编码为base64
bytes, err := json.Marshal(textures)
if err != nil {
s.logger.Error("序列化textures失败",
zap.Error(err),
zap.String("profileUUID", profile.UUID),
)
return nil
}
textureData := base64.StdEncoding.EncodeToString(bytes)
signature, err := s.signatureService.SignStringWithSHA1withRSA(textureData)
if err != nil {
s.logger.Error("签名textures失败",
zap.Error(err),
zap.String("profileUUID", profile.UUID),
)
return nil
}
// 构建结果
data := map[string]interface{}{
"id": profile.UUID,
"name": profile.Name,
"properties": []Property{
{
Name: "textures",
Value: textureData,
Signature: signature,
},
},
}
return data
}
// SerializeUser 序列化用户为Yggdrasil格式
func (s *yggdrasilSerializationService) SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{} {
if user == nil {
s.logger.Error("尝试序列化空用户")
return nil
}
data := map[string]interface{}{
"id": uuid,
}
// 正确处理 *datatypes.JSON 指针类型
// 如果 Properties 为 nil则设置为 nil否则解引用并解析为 JSON 值
if user.Properties == nil {
data["properties"] = nil
} else {
// datatypes.JSON 是 []byte 类型,需要解析为实际的 JSON 值
var propertiesValue interface{}
if err := json.Unmarshal(*user.Properties, &propertiesValue); err != nil {
s.logger.Warn("解析用户Properties失败使用空值",
zap.Error(err),
zap.Int64("userID", user.ID),
)
data["properties"] = nil
} else {
data["properties"] = propertiesValue
}
}
return data
}

View File

@@ -1,201 +0,0 @@
package service
import (
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/redis"
"carrotskin/pkg/utils"
"context"
"errors"
"fmt"
"go.uber.org/zap"
"net"
"strings"
"time"
"gorm.io/gorm"
)
// SessionKeyPrefix Redis会话键前缀
const SessionKeyPrefix = "Join_"
// SessionTTL 会话超时时间 - 增加到15分钟
const SessionTTL = 15 * time.Minute
type SessionData struct {
AccessToken string `json:"accessToken"`
UserName string `json:"userName"`
SelectedProfile string `json:"selectedProfile"`
IP string `json:"ip"`
}
// GetUserIDByEmail 根据邮箱返回用户id
func GetUserIDByEmail(db *gorm.DB, Identifier string) (int64, error) {
user, err := repository.FindUserByEmail(Identifier)
if err != nil {
return 0, errors.New("用户不存在")
}
return user.ID, nil
}
// GetProfileByProfileName 根据用户名返回用户id
func GetProfileByProfileName(db *gorm.DB, Identifier string) (*model.Profile, error) {
profile, err := repository.FindProfileByName(Identifier)
if err != nil {
return nil, errors.New("用户角色未创建")
}
return profile, nil
}
// VerifyPassword 验证密码是否一致
func VerifyPassword(db *gorm.DB, password string, Id int64) error {
passwordStore, err := repository.GetYggdrasilPasswordById(Id)
if err != nil {
return errors.New("未生成密码")
}
if passwordStore != password {
return errors.New("密码错误")
}
return nil
}
func GetProfileByUserId(db *gorm.DB, userId int64) (*model.Profile, error) {
profiles, err := repository.FindProfilesByUserID(userId)
if err != nil {
return nil, errors.New("角色查找失败")
}
if len(profiles) == 0 {
return nil, errors.New("角色查找失败")
}
return profiles[0], nil
}
func GetPasswordByUserId(db *gorm.DB, userId int64) (string, error) {
passwordStore, err := repository.GetYggdrasilPasswordById(userId)
if err != nil {
return "", errors.New("yggdrasil密码查找失败")
}
return passwordStore, nil
}
// JoinServer 记录玩家加入服务器的会话信息
func JoinServer(db *gorm.DB, logger *zap.Logger, redisClient *redis.Client, serverId, accessToken, selectedProfile, ip string) error {
// 输入验证
if serverId == "" || accessToken == "" || selectedProfile == "" {
return errors.New("参数不能为空")
}
// 验证serverId格式防止注入攻击
if len(serverId) > 100 || strings.ContainsAny(serverId, "<>\"'&") {
return errors.New("服务器ID格式无效")
}
// 验证IP格式
if ip != "" {
if net.ParseIP(ip) == nil {
return errors.New("IP地址格式无效")
}
}
// 获取和验证Token
token, err := repository.GetTokenByAccessToken(accessToken)
if err != nil {
logger.Error(
"验证Token失败",
zap.Error(err),
zap.String("accessToken", accessToken),
)
return fmt.Errorf("验证Token失败: %w", err)
}
// 格式化UUID并验证与Token关联的配置文件
formattedProfile := utils.FormatUUID(selectedProfile)
if token.ProfileId != formattedProfile {
return errors.New("selectedProfile与Token不匹配")
}
profile, err := repository.FindProfileByUUID(formattedProfile)
if err != nil {
logger.Error(
"获取Profile失败",
zap.Error(err),
zap.String("uuid", formattedProfile),
)
return fmt.Errorf("获取Profile失败: %w", err)
}
// 创建会话数据
data := SessionData{
AccessToken: accessToken,
UserName: profile.Name,
SelectedProfile: formattedProfile,
IP: ip,
}
// 序列化会话数据
marshaledData, err := json.Marshal(data)
if err != nil {
logger.Error(
"[ERROR]序列化会话数据失败",
zap.Error(err),
)
return fmt.Errorf("序列化会话数据失败: %w", err)
}
// 存储会话数据到Redis
sessionKey := SessionKeyPrefix + serverId
ctx := context.Background()
if err = redisClient.Set(ctx, sessionKey, marshaledData, SessionTTL); err != nil {
logger.Error(
"保存会话数据失败",
zap.Error(err),
zap.String("serverId", serverId),
)
return fmt.Errorf("保存会话数据失败: %w", err)
}
logger.Info(
"玩家成功加入服务器",
zap.String("username", profile.Name),
zap.String("serverId", serverId),
)
return nil
}
// HasJoinedServer 验证玩家是否已经加入了服务器
func HasJoinedServer(logger *zap.Logger, redisClient *redis.Client, serverId, username, ip string) error {
if serverId == "" || username == "" {
return errors.New("服务器ID和用户名不能为空")
}
// 设置超时上下文
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// 从Redis获取会话数据
sessionKey := SessionKeyPrefix + serverId
data, err := redisClient.GetBytes(ctx, sessionKey)
if err != nil {
logger.Error("[ERROR] 获取会话数据失败:", zap.Error(err), zap.Any("serverId:", serverId))
return fmt.Errorf("获取会话数据失败: %w", err)
}
// 反序列化会话数据
var sessionData SessionData
if err = json.Unmarshal(data, &sessionData); err != nil {
logger.Error("[ERROR] 解析会话数据失败: ", zap.Error(err))
return fmt.Errorf("解析会话数据失败: %w", err)
}
// 验证用户名
if sessionData.UserName != username {
return errors.New("用户名不匹配")
}
// 验证IP(如果提供)
if ip != "" && sessionData.IP != ip {
return errors.New("IP地址不匹配")
}
return nil
}

View File

@@ -0,0 +1,131 @@
package service
import (
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/redis"
"carrotskin/pkg/utils"
"context"
"errors"
"fmt"
"go.uber.org/zap"
"gorm.io/gorm"
)
// yggdrasilServiceComposite 组合服务,保持接口兼容性
// 将认证、会话、序列化、证书服务组合在一起
type yggdrasilServiceComposite struct {
authService *yggdrasilAuthService
sessionService SessionService
serializationService SerializationService
certificateService CertificateService
profileRepo repository.ProfileRepository
tokenService TokenService // 使用TokenService接口不直接依赖TokenRepository
logger *zap.Logger
}
// NewYggdrasilServiceComposite 创建组合服务实例
func NewYggdrasilServiceComposite(
db *gorm.DB,
userRepo repository.UserRepository,
profileRepo repository.ProfileRepository,
yggdrasilRepo repository.YggdrasilRepository,
signatureService *SignatureService,
redisClient *redis.Client,
logger *zap.Logger,
tokenService TokenService, // 新增TokenService接口
) YggdrasilService {
// 创建各个专门的服务
authService := NewYggdrasilAuthService(db, userRepo, yggdrasilRepo, logger)
sessionService := NewSessionService(redisClient, logger)
serializationService := NewSerializationService(
repository.NewTextureRepository(db),
signatureService,
logger,
)
certificateService := NewCertificateService(profileRepo, signatureService, logger)
return &yggdrasilServiceComposite{
authService: authService,
sessionService: sessionService,
serializationService: serializationService,
certificateService: certificateService,
profileRepo: profileRepo,
tokenService: tokenService,
logger: logger,
}
}
// GetUserIDByEmail 获取用户ID通过邮箱
func (s *yggdrasilServiceComposite) GetUserIDByEmail(ctx context.Context, email string) (int64, error) {
return s.authService.GetUserIDByEmail(ctx, email)
}
// VerifyPassword 验证密码
func (s *yggdrasilServiceComposite) VerifyPassword(ctx context.Context, password string, userID int64) error {
return s.authService.VerifyPassword(ctx, password, userID)
}
// ResetYggdrasilPassword 重置Yggdrasil密码
func (s *yggdrasilServiceComposite) ResetYggdrasilPassword(ctx context.Context, userID int64) (string, error) {
return s.authService.ResetYggdrasilPassword(ctx, userID)
}
// JoinServer 加入服务器
func (s *yggdrasilServiceComposite) JoinServer(ctx context.Context, serverID, accessToken, selectedProfile, ip string) error {
// 通过TokenService验证Token并获取UUID
uuid, err := s.tokenService.GetUUIDByAccessToken(ctx, accessToken)
if err != nil {
s.logger.Error("验证Token失败",
zap.Error(err),
zap.String("accessToken", accessToken),
)
return fmt.Errorf("验证Token失败: %w", err)
}
// 格式化UUID并验证与Token关联的配置文件
formattedProfile := utils.FormatUUID(selectedProfile)
if uuid != formattedProfile {
return errors.New("selectedProfile与Token不匹配")
}
// 获取Profile以获取用户名
profile, err := s.profileRepo.FindByUUID(ctx, formattedProfile)
if err != nil {
s.logger.Error("获取Profile失败",
zap.Error(err),
zap.String("uuid", formattedProfile),
)
return fmt.Errorf("获取Profile失败: %w", err)
}
// 使用会话服务创建会话
return s.sessionService.CreateSession(ctx, serverID, accessToken, profile.Name, formattedProfile, ip)
}
// HasJoinedServer 验证玩家是否已加入服务器
func (s *yggdrasilServiceComposite) HasJoinedServer(ctx context.Context, serverID, username, ip string) error {
return s.sessionService.ValidateSession(ctx, serverID, username, ip)
}
// SerializeProfile 序列化档案
func (s *yggdrasilServiceComposite) SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{} {
return s.serializationService.SerializeProfile(ctx, profile)
}
// SerializeUser 序列化用户
func (s *yggdrasilServiceComposite) SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{} {
return s.serializationService.SerializeUser(ctx, user, uuid)
}
// GeneratePlayerCertificate 生成玩家证书
func (s *yggdrasilServiceComposite) GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error) {
return s.certificateService.GeneratePlayerCertificate(ctx, uuid)
}
// GetPublicKey 获取公钥
func (s *yggdrasilServiceComposite) GetPublicKey(ctx context.Context) (string, error) {
return s.certificateService.GetPublicKey(ctx)
}

View File

@@ -0,0 +1,181 @@
package service
import (
apperrors "carrotskin/internal/errors"
"carrotskin/pkg/redis"
"context"
"fmt"
"net"
"strings"
"time"
"go.uber.org/zap"
)
// SessionKeyPrefix Redis会话键前缀
const SessionKeyPrefix = "Join_"
// SessionTTL 会话超时时间 - 增加到15分钟
const SessionTTL = 15 * time.Minute
// SessionData 会话数据
type SessionData struct {
AccessToken string `json:"accessToken"`
UserName string `json:"userName"`
SelectedProfile string `json:"selectedProfile"`
IP string `json:"ip"`
}
// SessionService 会话管理服务接口
type SessionService interface {
// CreateSession 创建服务器会话
CreateSession(ctx context.Context, serverID, accessToken, username, profileUUID, ip string) error
// GetSession 获取会话数据
GetSession(ctx context.Context, serverID string) (*SessionData, error)
// ValidateSession 验证会话用户名和IP
ValidateSession(ctx context.Context, serverID, username, ip string) error
}
// yggdrasilSessionService 会话服务实现
type yggdrasilSessionService struct {
redis *redis.Client
logger *zap.Logger
}
// NewSessionService 创建会话服务实例
func NewSessionService(redisClient *redis.Client, logger *zap.Logger) SessionService {
return &yggdrasilSessionService{
redis: redisClient,
logger: logger,
}
}
// ValidateServerID 验证服务器ID格式
func ValidateServerID(serverID string) error {
if serverID == "" {
return apperrors.ErrInvalidServerID
}
if len(serverID) > 100 || strings.ContainsAny(serverID, "<>\"'&") {
return apperrors.ErrInvalidServerID
}
return nil
}
// ValidateIP 验证IP地址格式
func ValidateIP(ip string) error {
if ip == "" {
return nil // IP是可选的
}
if net.ParseIP(ip) == nil {
return apperrors.ErrIPMismatch
}
return nil
}
// CreateSession 创建服务器会话
func (s *yggdrasilSessionService) CreateSession(ctx context.Context, serverID, accessToken, username, profileUUID, ip string) error {
// 输入验证
if err := ValidateServerID(serverID); err != nil {
return err
}
if accessToken == "" {
return apperrors.ErrInvalidAccessToken
}
if username == "" {
return apperrors.ErrUsernameMismatch
}
if profileUUID == "" {
return apperrors.ErrProfileMismatch
}
if err := ValidateIP(ip); err != nil {
return err
}
// 创建会话数据
data := SessionData{
AccessToken: accessToken,
UserName: username,
SelectedProfile: profileUUID,
IP: ip,
}
// 序列化会话数据
marshaledData, err := json.Marshal(data)
if err != nil {
s.logger.Error("序列化会话数据失败",
zap.Error(err),
zap.String("serverID", serverID),
)
return fmt.Errorf("序列化会话数据失败: %w", err)
}
// 存储会话数据到Redis
sessionKey := SessionKeyPrefix + serverID
if err = s.redis.Set(ctx, sessionKey, marshaledData, SessionTTL); err != nil {
s.logger.Error("保存会话数据失败",
zap.Error(err),
zap.String("serverID", serverID),
)
return fmt.Errorf("保存会话数据失败: %w", err)
}
s.logger.Info("会话创建成功",
zap.String("username", username),
zap.String("serverID", serverID),
)
return nil
}
// GetSession 获取会话数据
func (s *yggdrasilSessionService) GetSession(ctx context.Context, serverID string) (*SessionData, error) {
if err := ValidateServerID(serverID); err != nil {
return nil, err
}
// 从Redis获取会话数据
sessionKey := SessionKeyPrefix + serverID
data, err := s.redis.GetBytes(ctx, sessionKey)
if err != nil {
s.logger.Error("获取会话数据失败",
zap.Error(err),
zap.String("serverID", serverID),
)
return nil, fmt.Errorf("获取会话数据失败: %w", err)
}
// 反序列化会话数据
var sessionData SessionData
if err = json.Unmarshal(data, &sessionData); err != nil {
s.logger.Error("解析会话数据失败",
zap.Error(err),
zap.String("serverID", serverID),
)
return nil, fmt.Errorf("解析会话数据失败: %w", err)
}
return &sessionData, nil
}
// ValidateSession 验证会话用户名和IP
func (s *yggdrasilSessionService) ValidateSession(ctx context.Context, serverID, username, ip string) error {
if serverID == "" || username == "" {
return apperrors.ErrSessionMismatch
}
sessionData, err := s.GetSession(ctx, serverID)
if err != nil {
return apperrors.ErrSessionNotFound
}
// 验证用户名
if sessionData.UserName != username {
return apperrors.ErrUsernameMismatch
}
// 验证IP如果提供
if ip != "" && sessionData.IP != ip {
return apperrors.ErrIPMismatch
}
return nil
}

View File

@@ -0,0 +1,81 @@
package service
import (
"errors"
"net"
"regexp"
"strings"
)
// Validator Yggdrasil验证器
type Validator struct{}
// NewValidator 创建验证器实例
func NewValidator() *Validator {
return &Validator{}
}
var (
// emailRegex 邮箱正则表达式
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
)
// ValidateServerID 验证服务器ID格式
func (v *Validator) ValidateServerID(serverID string) error {
if serverID == "" {
return errors.New("服务器ID不能为空")
}
if len(serverID) > 100 {
return errors.New("服务器ID长度超过限制最大100字符")
}
// 防止注入攻击:检查危险字符
if strings.ContainsAny(serverID, "<>\"'&") {
return errors.New("服务器ID包含非法字符")
}
return nil
}
// ValidateIP 验证IP地址格式
func (v *Validator) ValidateIP(ip string) error {
if ip == "" {
return nil // IP是可选的
}
if net.ParseIP(ip) == nil {
return errors.New("IP地址格式无效")
}
return nil
}
// ValidateEmail 验证邮箱格式
func (v *Validator) ValidateEmail(email string) error {
if email == "" {
return errors.New("邮箱不能为空")
}
if !emailRegex.MatchString(email) {
return errors.New("邮箱格式不正确")
}
return nil
}
// ValidateUUID 验证UUID格式简单验证
func (v *Validator) ValidateUUID(uuid string) error {
if uuid == "" {
return errors.New("UUID不能为空")
}
// UUID格式xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (32个十六进制字符 + 4个连字符)
if len(uuid) < 32 || len(uuid) > 36 {
return errors.New("UUID格式无效")
}
return nil
}
// ValidateAccessToken 验证访问令牌
func (v *Validator) ValidateAccessToken(token string) error {
if token == "" {
return errors.New("访问令牌不能为空")
}
if len(token) < 10 {
return errors.New("访问令牌格式无效")
}
return nil
}

168
internal/task/runner.go Normal file
View File

@@ -0,0 +1,168 @@
package task
import (
"context"
"math/rand"
"runtime/debug"
"sync"
"time"
"go.uber.org/zap"
)
// Task 定义可调度任务
type Task interface {
Name() string
Interval() time.Duration
Run(ctx context.Context) error
}
// Runner 简单的周期任务调度器
type Runner struct {
tasks []Task
logger *zap.Logger
wg sync.WaitGroup
startImmediately bool
jitterPercent float64
}
// NewRunner 创建任务调度器
func NewRunner(logger *zap.Logger, tasks ...Task) *Runner {
return NewRunnerWithOptions(logger, tasks)
}
// RunnerOption 运行器配置项
type RunnerOption func(r *Runner)
// WithStartImmediately 是否启动后立即执行一次(默认 true
func WithStartImmediately(start bool) RunnerOption {
return func(r *Runner) {
r.startImmediately = start
}
}
// WithJitter 为执行间隔增加 0~percent 之间的随机抖动percent=0 关闭默认0
// 可降低多个任务同时触发的概率
func WithJitter(percent float64) RunnerOption {
return func(r *Runner) {
if percent < 0 {
percent = 0
}
r.jitterPercent = percent
}
}
// NewRunnerWithOptions 支持可选配置的创建函数
func NewRunnerWithOptions(logger *zap.Logger, tasks []Task, opts ...RunnerOption) *Runner {
r := &Runner{
tasks: tasks,
logger: logger,
startImmediately: true,
jitterPercent: 0,
}
for _, opt := range opts {
opt(r)
}
return r
}
// Start 启动所有任务(异步)
func (r *Runner) Start(ctx context.Context) {
for _, t := range r.tasks {
task := t
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer r.recoverPanic(task)
interval := r.normalizeInterval(task.Interval())
// 可选:立即执行一次
if r.startImmediately {
r.runOnce(ctx, task)
}
// 周期执行
for {
wait := r.applyJitter(interval)
if !r.wait(ctx, wait) {
return
}
// 每轮读取最新的 interval允许任务动态调整间隔
interval = r.normalizeInterval(task.Interval())
select {
case <-ctx.Done():
return
default:
r.runOnce(ctx, task)
}
}
}()
}
}
// Wait 等待所有任务退出
func (r *Runner) Wait() {
r.wg.Wait()
}
func (r *Runner) runOnce(ctx context.Context, task Task) {
if err := task.Run(ctx); err != nil && r.logger != nil {
r.logger.Warn("任务执行失败", zap.String("task", task.Name()), zap.Error(err))
}
}
// normalizeInterval 确保间隔为正值
func (r *Runner) normalizeInterval(d time.Duration) time.Duration {
if d <= 0 {
return time.Minute
}
return d
}
// applyJitter 在基础间隔上添加最多 jitterPercent 的随机抖动
func (r *Runner) applyJitter(base time.Duration) time.Duration {
if r.jitterPercent <= 0 {
return base
}
maxJitter := time.Duration(float64(base) * r.jitterPercent)
if maxJitter <= 0 {
return base
}
return base + time.Duration(rand.Int63n(int64(maxJitter)))
}
// wait 封装带 context 的 sleep
func (r *Runner) wait(ctx context.Context, d time.Duration) bool {
if d <= 0 {
select {
case <-ctx.Done():
return false
default:
return true
}
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
// recoverPanic 防止任务 panic 导致 goroutine 退出
func (r *Runner) recoverPanic(task Task) {
if rec := recover(); rec != nil && r.logger != nil {
r.logger.Error("任务发生panic",
zap.String("task", task.Name()),
zap.Any("panic", rec),
zap.ByteString("stack", debug.Stack()),
)
}
}

View File

@@ -0,0 +1,65 @@
package task
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
)
type mockTask struct {
name string
interval time.Duration
err error
runCount *atomic.Int32
}
func (m *mockTask) Name() string { return m.name }
func (m *mockTask) Interval() time.Duration { return m.interval }
func (m *mockTask) Run(ctx context.Context) error {
if m.runCount != nil {
m.runCount.Add(1)
}
return m.err
}
func TestRunner_StartAndWait(t *testing.T) {
runCount := &atomic.Int32{}
task := &mockTask{name: "ok", interval: 20 * time.Millisecond, runCount: runCount}
runner := NewRunner(zap.NewNop(), task)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runner.Start(ctx)
time.Sleep(60 * time.Millisecond)
cancel()
runner.Wait()
if runCount.Load() == 0 {
t.Fatalf("expected task to run at least once")
}
}
func TestRunner_RunErrorLogged(t *testing.T) {
runCount := &atomic.Int32{}
task := &mockTask{name: "err", interval: 10 * time.Millisecond, err: errors.New("boom"), runCount: runCount}
runner := NewRunner(zap.NewNop(), task)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runner.Start(ctx)
time.Sleep(25 * time.Millisecond)
cancel()
runner.Wait()
if runCount.Load() == 0 {
t.Fatalf("expected task to be attempted")
}
}

View File

@@ -0,0 +1,55 @@
package testutil
import (
"testing"
"time"
"carrotskin/internal/model"
"carrotskin/pkg/database"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// NewTestDB 返回基于内存的 sqlite 数据库并完成模型迁移
func NewTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open sqlite memory db: %v", err)
}
if err := db.AutoMigrate(
&model.User{},
&model.UserPointLog{},
&model.UserLoginLog{},
&model.Profile{},
&model.Texture{},
&model.UserTextureFavorite{},
&model.TextureDownloadLog{},
&model.Client{},
&model.Yggdrasil{},
&model.AuditLog{},
&model.CasbinRule{},
); err != nil {
t.Fatalf("failed to migrate models: %v", err)
}
return db
}
// NewNoopLogger 返回无输出 logger
func NewNoopLogger() *zap.Logger {
return zap.NewNop()
}
// NewTestCache 返回禁用 redis 的缓存管理器(用于单元测试)
func NewTestCache() *database.CacheManager {
return database.NewCacheManager(nil, database.CacheConfig{
Prefix: "test:",
Expiration: 1 * time.Minute,
Enabled: false,
})
}

View File

@@ -0,0 +1,27 @@
package testutil
import "testing"
func TestNewTestDB(t *testing.T) {
db := NewTestDB(t)
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("DB() err: %v", err)
}
if err := sqlDB.Ping(); err != nil {
t.Fatalf("ping err: %v", err)
}
}
func TestNewTestCache(t *testing.T) {
cache := NewTestCache()
if cache.Policy.UserTTL == 0 {
t.Fatalf("expected defaults filled")
}
// disabled cache should not error on Set
if err := cache.Set(nil, "k", "v"); err != nil {
t.Fatalf("Set on disabled cache should be nil err, got %v", err)
}
}

View File

@@ -3,6 +3,7 @@ package types
import "time"
// BaseResponse 基础响应结构
// @Description 通用API响应结构
type BaseResponse struct {
Code int `json:"code"`
Message string `json:"message"`
@@ -10,12 +11,14 @@ type BaseResponse struct {
}
// PaginationRequest 分页请求
// @Description 分页查询参数
type PaginationRequest struct {
Page int `json:"page" form:"page" binding:"omitempty,min=1"`
PageSize int `json:"page_size" form:"page_size" binding:"omitempty,min=1,max=100"`
}
// PaginationResponse 分页响应
// @Description 分页查询结果
type PaginationResponse struct {
List interface{} `json:"list"`
Total int64 `json:"total"`
@@ -25,21 +28,24 @@ type PaginationResponse struct {
}
// LoginRequest 登录请求
// @Description 用户登录请求参数
type LoginRequest struct {
Username string `json:"username" binding:"required" example:"testuser"` // 支持用户名或邮箱
Password string `json:"password" binding:"required,min=6,max=128" example:"password123"`
}
// RegisterRequest 注册请求
// @Description 用户注册请求参数
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=50" example:"newuser"`
Email string `json:"email" binding:"required,email" example:"user@example.com"`
Password string `json:"password" binding:"required,min=6,max=128" example:"password123"`
VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"` // 邮箱验证码
VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"` // 邮箱验证码
Avatar string `json:"avatar" binding:"omitempty,url" example:"https://rustfs.example.com/avatars/user_1/avatar.png"` // 可选,用户自定义头像
}
// UpdateUserRequest 更新用户请求
// @Description 更新用户信息请求参数
type UpdateUserRequest struct {
Avatar string `json:"avatar" binding:"omitempty,url" example:"https://example.com/new-avatar.png"`
OldPassword string `json:"old_password" binding:"omitempty,min=6,max=128" example:"oldpassword123"` // 修改密码时必需
@@ -47,12 +53,14 @@ type UpdateUserRequest struct {
}
// SendVerificationCodeRequest 发送验证码请求
// @Description 发送邮箱验证码请求参数
type SendVerificationCodeRequest struct {
Email string `json:"email" binding:"required,email" example:"user@example.com"`
Type string `json:"type" binding:"required,oneof=register reset_password change_email" example:"register"` // 类型: register/reset_password/change_email
}
// ResetPasswordRequest 重置密码请求
// @Description 重置密码请求参数
type ResetPasswordRequest struct {
Email string `json:"email" binding:"required,email" example:"user@example.com"`
VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"`
@@ -60,57 +68,35 @@ type ResetPasswordRequest struct {
}
// ChangeEmailRequest 更换邮箱请求
// @Description 更换邮箱请求参数
type ChangeEmailRequest struct {
NewEmail string `json:"new_email" binding:"required,email" example:"newemail@example.com"`
VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"`
}
// GenerateAvatarUploadURLRequest 生成头像上传URL请求
type GenerateAvatarUploadURLRequest struct {
FileName string `json:"file_name" binding:"required" example:"avatar.png"`
}
// GenerateAvatarUploadURLResponse 生成头像上传URL响应
type GenerateAvatarUploadURLResponse struct {
PostURL string `json:"post_url" example:"https://rustfs.example.com/avatars"`
FormData map[string]string `json:"form_data"`
AvatarURL string `json:"avatar_url" example:"https://rustfs.example.com/avatars/user_1/xxx.png"`
ExpiresIn int `json:"expires_in" example:"900"` // 秒
}
// CreateProfileRequest 创建档案请求
// @Description 创建Minecraft档案请求参数
type CreateProfileRequest struct {
Name string `json:"name" binding:"required,min=1,max=16" example:"PlayerName"`
}
// UpdateTextureRequest 更新材质请求
// @Description 更新材质信息请求参数
type UpdateTextureRequest struct {
Name string `json:"name" binding:"omitempty,min=1,max=100" example:"My Skin"`
Description string `json:"description" binding:"omitempty,max=500" example:"A cool skin"`
IsPublic *bool `json:"is_public" example:"true"`
}
// GenerateTextureUploadURLRequest 生成材质上传URL请求
type GenerateTextureUploadURLRequest struct {
FileName string `json:"file_name" binding:"required" example:"skin.png"`
TextureType TextureType `json:"texture_type" binding:"required,oneof=SKIN CAPE" example:"SKIN"`
}
// GenerateTextureUploadURLResponse 生成材质上传URL响应
type GenerateTextureUploadURLResponse struct {
PostURL string `json:"post_url" example:"https://rustfs.example.com/textures"`
FormData map[string]string `json:"form_data"`
TextureURL string `json:"texture_url" example:"https://rustfs.example.com/textures/user_1/skin/xxx.png"`
ExpiresIn int `json:"expires_in" example:"900"` // 秒
}
// LoginResponse 登录响应
// @Description 登录成功响应数据
type LoginResponse struct {
Token string `json:"token"`
UserInfo *UserInfo `json:"user_info"`
}
// UserInfo 用户信息
// @Description 用户详细信息
type UserInfo struct {
ID int64 `json:"id" example:"1"`
Username string `json:"username" example:"testuser"`
@@ -124,6 +110,18 @@ type UserInfo struct {
UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"`
}
// PublicUserInfo 用户公开信息
// @Description 用户公开信息(不包含敏感信息如邮箱)
type PublicUserInfo struct {
ID int64 `json:"id" example:"1"`
Username string `json:"username" example:"testuser"`
Avatar string `json:"avatar" example:"https://example.com/avatar.png"`
Points int `json:"points" example:"100"`
Role string `json:"role" example:"user"`
Status int16 `json:"status" example:"1"`
CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"`
}
// TextureType 材质类型
type TextureType string
@@ -133,44 +131,48 @@ const (
)
// TextureInfo 材质信息
// @Description 材质详细信息
type TextureInfo struct {
ID int64 `json:"id" example:"1"`
UploaderID int64 `json:"uploader_id" example:"1"`
Name string `json:"name" example:"My Skin"`
Description string `json:"description,omitempty" example:"A cool skin"`
Type TextureType `json:"type" example:"SKIN"`
URL string `json:"url" example:"https://rustfs.example.com/textures/xxx.png"`
Hash string `json:"hash" example:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`
Size int `json:"size" example:"2048"`
IsPublic bool `json:"is_public" example:"true"`
DownloadCount int `json:"download_count" example:"100"`
FavoriteCount int `json:"favorite_count" example:"50"`
IsSlim bool `json:"is_slim" example:"false"`
Status int16 `json:"status" example:"1"`
CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"`
UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"`
ID int64 `json:"id" example:"1"`
UploaderID int64 `json:"uploader_id" example:"1"`
UploaderUsername string `json:"uploader_username" example:"testuser"`
Name string `json:"name" example:"My Skin"`
Description string `json:"description,omitempty" example:"A cool skin"`
Type TextureType `json:"type" example:"SKIN"`
URL string `json:"url" example:"https://rustfs.example.com/textures/xxx.png"`
Hash string `json:"hash" example:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`
Size int `json:"size" example:"2048"`
IsPublic bool `json:"is_public" example:"true"`
DownloadCount int `json:"download_count" example:"100"`
FavoriteCount int `json:"favorite_count" example:"50"`
IsSlim bool `json:"is_slim" example:"false"`
Status int16 `json:"status" example:"1"`
CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"`
UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"`
}
// ProfileInfo 角色信息
// @Description Minecraft档案信息
type ProfileInfo struct {
UUID string `json:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"`
UserID int64 `json:"user_id" example:"1"`
Name string `json:"name" example:"PlayerName"`
SkinID *int64 `json:"skin_id,omitempty" example:"1"`
CapeID *int64 `json:"cape_id,omitempty" example:"2"`
IsActive bool `json:"is_active" example:"true"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" example:"2025-10-01T12:00:00Z"`
CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"`
UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"`
}
// UploadURLRequest 上传URL请求
// @Description 获取材质上传URL请求参数
type UploadURLRequest struct {
Type TextureType `json:"type" binding:"required,oneof=SKIN CAPE"`
Filename string `json:"filename" binding:"required"`
}
// UploadURLResponse 上传URL响应
// @Description 材质上传URL响应数据
type UploadURLResponse struct {
PostURL string `json:"post_url"`
FormData map[string]string `json:"form_data"`
@@ -178,19 +180,8 @@ type UploadURLResponse struct {
ExpiresIn int `json:"expires_in"`
}
// CreateTextureRequest 创建材质请求
type CreateTextureRequest struct {
Name string `json:"name" binding:"required,min=1,max=100" example:"My Cool Skin"`
Description string `json:"description" binding:"max=500" example:"A very cool skin"`
Type TextureType `json:"type" binding:"required,oneof=SKIN CAPE" example:"SKIN"`
URL string `json:"url" binding:"required,url" example:"https://rustfs.example.com/textures/user_1/skin/xxx.png"`
Hash string `json:"hash" binding:"required,len=64" example:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`
Size int `json:"size" binding:"required,min=1" example:"2048"`
IsPublic bool `json:"is_public" example:"true"`
IsSlim bool `json:"is_slim" example:"false"` // Alex模型(细臂)为trueSteve模型(粗臂)为false
}
// SearchTextureRequest 搜索材质请求
// @Description 搜索材质请求参数
type SearchTextureRequest struct {
PaginationRequest
Keyword string `json:"keyword" form:"keyword"`
@@ -199,6 +190,7 @@ type SearchTextureRequest struct {
}
// UpdateProfileRequest 更新角色请求
// @Description 更新Minecraft档案请求参数
type UpdateProfileRequest struct {
Name string `json:"name" binding:"omitempty,min=1,max=16" example:"NewPlayerName"`
SkinID *int64 `json:"skin_id,omitempty" example:"1"`
@@ -206,10 +198,11 @@ type UpdateProfileRequest struct {
}
// SystemConfigResponse 基础系统配置响应
// @Description 系统配置信息
type SystemConfigResponse struct {
SiteName string `json:"site_name" example:"CarrotSkin"`
SiteDescription string `json:"site_description" example:"A Minecraft Skin Station"`
RegistrationEnabled bool `json:"registration_enabled" example:"true"`
MaxTexturesPerUser int `json:"max_textures_per_user" example:"100"`
MaxProfilesPerUser int `json:"max_profiles_per_user" example:"5"`
}
}

Some files were not shown because too many files have changed in this diff Show More