Compare commits
8 Commits
a111872b32
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fc1be0ec1 | ||
|
|
f2b02682c2 | ||
|
|
1dc9c36a3a | ||
| cea22951b9 | |||
|
|
ae3a569f03 | ||
|
|
b23a169925 | ||
|
|
7d1c78f965 | ||
|
|
d9de39a0a3 |
12
.env.example
12
.env.example
@@ -30,6 +30,13 @@ SERVER_READ_TIMEOUT=30s
|
||||
SERVER_WRITE_TIMEOUT=30s
|
||||
SERVER_SWAGGER_ENABLED=true
|
||||
|
||||
# =============================================================================
|
||||
# 应用环境
|
||||
# =============================================================================
|
||||
# 用于 service 层 IsTestEnvironment() 短路验证码/邮件等
|
||||
# 取值:development / test / production
|
||||
ENVIRONMENT=production
|
||||
|
||||
# =============================================================================
|
||||
# 数据库配置
|
||||
# =============================================================================
|
||||
@@ -133,6 +140,11 @@ JWT_EXPIRE_HOURS=168
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=json
|
||||
LOG_OUTPUT=logs/app.log
|
||||
# 日志轮转参数(用于 lumberjack)
|
||||
LOG_MAX_SIZE=100 # 单文件最大 MB
|
||||
LOG_MAX_BACKUPS=3 # 保留旧文件数
|
||||
LOG_MAX_AGE=28 # 旧文件最大保留天数
|
||||
LOG_COMPRESS=true # 是否压缩旧文件
|
||||
|
||||
# =============================================================================
|
||||
# 安全配置
|
||||
|
||||
202
README.md
202
README.md
@@ -8,7 +8,7 @@
|
||||
- **邮箱与验证码**:验证码发送频率控制、邮箱绑定与变更
|
||||
- **材质中心**:皮肤/披风上传、搜索、收藏、下载统计、Hash 去重
|
||||
- **角色档案**:Minecraft Profile 管理、RSA 密钥对生成、活跃档案切换
|
||||
- **存储与上传**:RustFS/MinIO 预签名 URL,减轻服务器带宽压力
|
||||
- **存储与上传**:RustFS/MinIO(S3 兼容)直接上传,Hash 分片存储
|
||||
- **任务与日志**:登录日志、操作审计、材质下载记录、定时任务
|
||||
- **权限体系**:Casbin RBAC,支持细粒度路线授权
|
||||
- **配置管理**:100% 依赖环境变量,`SERVER_SWAGGER_ENABLED` 控制 Swagger
|
||||
@@ -18,44 +18,58 @@
|
||||
|
||||
| 类型 | 选型 |
|
||||
| --- | --- |
|
||||
| 语言 / 运行时 | Go 1.24+ |
|
||||
| 语言 / 运行时 | Go 1.25+ |
|
||||
| Web 框架 | Gin |
|
||||
| ORM | GORM (PostgreSQL 驱动) |
|
||||
| 数据库 | PostgreSQL 15+ |
|
||||
| 缓存 / 消息 | Redis 6+ |
|
||||
| 对象存储 | RustFS / MinIO(S3 兼容) |
|
||||
| 权限控制 | Casbin |
|
||||
| 配置 | Viper + `.env` |
|
||||
| 权限控制 | Casbin v3 |
|
||||
| 依赖注入 | uber-go/fx |
|
||||
| 配置 | Viper + godotenv + `.env` |
|
||||
| API 文档 | swaggo / Swagger UI |
|
||||
| 滑动验证码 | wenlng/go-captcha |
|
||||
| 日志 | Uber Zap |
|
||||
|
||||
## 📁 目录结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── cmd/server/ # 应用入口(main.go)
|
||||
├── cmd/server/ # 应用入口(main.go:极简 fx 装配)
|
||||
├── internal/
|
||||
│ ├── app/ # uber-go/fx 装配层(唯一依赖注入入口)
|
||||
│ │ ├── module.go # 聚合根 Module
|
||||
│ │ ├── infra_module.go # Logger/DB/Redis/Storage/Email/JWT/Casbin + 子配置 Provider + Lifecycle
|
||||
│ │ ├── container_module.go # Container 聚合 + 迁移/路由/HTTP/任务生命周期
|
||||
│ │ ├── repository_module.go # Repository Provider(占位,Container 模式暂未启用)
|
||||
│ │ ├── service_module.go # Service Provider(占位)
|
||||
│ │ ├── handler_module.go # Handler Provider(占位)
|
||||
│ │ ├── server_module.go # HTTP 服务器模块(占位)
|
||||
│ │ └── task_module.go # 后台任务模块(占位)
|
||||
│ ├── container/ # 过渡期手动 DI 容器(fx 迁移期的聚合层)
|
||||
│ ├── handler/ # HTTP Handler 与 Swagger 注解
|
||||
│ ├── service/ # 业务逻辑
|
||||
│ ├── repository/ # 数据访问
|
||||
│ ├── model/ # GORM 数据模型
|
||||
│ ├── service/ # 业务逻辑(接口 + 实现)
|
||||
│ ├── repository/ # 数据访问(接口 + GORM 实现)
|
||||
│ ├── model/ # GORM 数据模型与响应结构
|
||||
│ ├── types/ # 请求/响应 DTO
|
||||
│ ├── errors/ # 统一错误定义(apperrors)
|
||||
│ ├── middleware/ # Gin 中间件
|
||||
│ └── task/ # 定时任务与后台作业
|
||||
├── pkg/ # 可复用组件(config、database、auth、logger、redis、storage 等)
|
||||
├── docs/ # swagger 生成产物(docs.go / swagger.json / swagger.yaml)
|
||||
├── pkg/ # 可复用组件(config、database、auth、logger、redis、storage、email、utils)
|
||||
├── configs/casbin/ # Casbin RBAC 模型定义(rbac_model.conf)
|
||||
├── start.sh # 启动脚本(自动 swag init)
|
||||
├── docker-compose.yml # 本地容器编排
|
||||
├── docker-compose.yml # 本地容器编排(含 rustfs-init 自动建桶)
|
||||
├── .env.example # 环境变量示例
|
||||
└── go.mod # Go Module 定义
|
||||
```
|
||||
|
||||
## ✅ 前置要求
|
||||
|
||||
- Go 1.24+
|
||||
- Go 1.25+
|
||||
- PostgreSQL 15+
|
||||
- Redis 6+
|
||||
- RustFS / MinIO(或其他兼容 S3 的对象存储,用于皮肤与头像)
|
||||
- swag(生成 Swagger 文档,见 <https://github.com/swaggo/swag>)
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
@@ -81,25 +95,144 @@ backend/
|
||||
createdb carrotskin
|
||||
# 或 psql -c "CREATE DATABASE carrotskin;"
|
||||
```
|
||||
> 应用启动时会执行 `AutoMigrate`,自动创建 / 更新表结构。
|
||||
> 应用启动时会执行 `AutoMigrate`,自动创建 / 更新表结构,并写入种子数据(默认管理员 + Casbin 规则)。
|
||||
|
||||
5. **启动服务**
|
||||
- **推荐**:`./start.sh`(自动 `swag init`,随后 `go run cmd/server/main.go`)
|
||||
5. **准备对象存储**(创建存储桶 + 配置策略与跨域,**见下方 [🪣 对象存储配置](#-对象存储配置) 章节**)
|
||||
|
||||
6. **启动服务**
|
||||
- **推荐**:`./start.sh`(自动 `swag init`,随后 `go run ./cmd/server`)
|
||||
- **手动启动**:
|
||||
```bash
|
||||
swag init -g cmd/server/main.go -o docs
|
||||
go run cmd/server/main.go
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
6. **访问接口**
|
||||
7. **访问接口**
|
||||
- API Root: `http://localhost:8080`
|
||||
- Swagger: `http://localhost:8080/swagger/index.html`(需 `SERVER_SWAGGER_ENABLED=true`)
|
||||
- 健康检查: `http://localhost:8080/health`
|
||||
|
||||
### 默认管理员
|
||||
|
||||
首次启动会自动 seed 一个管理员账号,**首次登录后请立即修改密码**:
|
||||
|
||||
| 用户名 | 密码 | 邮箱 |
|
||||
| --- | --- | --- |
|
||||
| `admin` | `admin123456` | `admin@example.com` |
|
||||
|
||||
## 🪣 对象存储配置
|
||||
|
||||
后端使用 S3 兼容的对象存储(RustFS / MinIO)保存皮肤与头像,需要两个存储桶:
|
||||
|
||||
| 桶名(由环境变量指定) | 默认名 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `RUSTFS_BUCKET_TEXTURES` | `carrot-skin-textures` | 皮肤 / 披风文件 |
|
||||
| `RUSTFS_BUCKET_AVATARS` | `carrot-skin-avatars` | 用户头像文件 |
|
||||
|
||||
> ⚠️ **后端不会自动创建存储桶**。请在启动后端前,手动创建这两个桶并配置好访问策略与跨域,否则上传会失败、前端 3D 皮肤将无法渲染。
|
||||
|
||||
### 步骤 1:创建存储桶
|
||||
|
||||
在 RustFS / MinIO 控制台(默认 `http://<host>:9001`)创建上述两个桶,或使用 `mc` 客户端:
|
||||
|
||||
```bash
|
||||
mc alias set myrustfs http://<host>:9000 <access-key> <secret-key>
|
||||
mc mb myrustfs/carrot-skin-textures --ignore-existing
|
||||
mc mb myrustfs/carrot-skin-avatars --ignore-existing
|
||||
```
|
||||
|
||||
### 步骤 2:配置桶访问策略(匿名只读)
|
||||
|
||||
皮肤和头像需要被浏览器与 Minecraft 启动器**匿名下载**(后端用密钥上传即可)。给每个桶配置如下 S3 Bucket Policy(以 `carrot-skin-textures` 为例,`avatars` 桶把 `Resource` 里的桶名换掉即可):
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "PublicReadGetObject",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": "arn:aws:s3:::carrot-skin-textures/*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
等价的 `mc` 命令:
|
||||
|
||||
```bash
|
||||
mc anonymous set download myrustfs/carrot-skin-textures
|
||||
mc anonymous set download myrustfs/carrot-skin-avatars
|
||||
```
|
||||
|
||||
### 步骤 3:配置 CORS(跨域,**前端 3D 渲染必需**)
|
||||
|
||||
> 🔴 **这一步极其关键**。普通的 `<img>` 标签加载图片不要求 CORS,但前端用 skinview3d / three.js 通过 **WebGL 纹理**渲染 3D 皮肤时,浏览器**强制要求**图片响应带 `Access-Control-Allow-Origin` 头,否则 canvas 会被判定为 tainted,3D 皮肤将加载失败/显示空白。
|
||||
|
||||
在 RustFS / MinIO 控制台为**两个桶**各配置一条 CORS 规则,允许前端来源以 GET 方法跨域访问。CORS 规则 JSON(贴入桶的 CORS 配置编辑器):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"AllowedOrigins": ["*"],
|
||||
"AllowedMethods": ["GET", "HEAD"],
|
||||
"AllowedHeaders": ["*"],
|
||||
"ExposeHeaders": ["ETag"],
|
||||
"MaxAgeSeconds": 86400
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
> 生产环境建议把 `AllowedOrigins` 收紧为你的前端实际域名(如 `["https://skins.example.com"]`),而不是 `*`。
|
||||
|
||||
等价的 `mc` 命令(将 JSON 存为 `cors.json` 后应用):
|
||||
|
||||
```bash
|
||||
mc cors add myrustfs/carrot-skin-textures < cors.json
|
||||
mc cors add myrustfs/carrot-skin-avatars < cors.json
|
||||
```
|
||||
|
||||
### 验证存储配置是否生效
|
||||
|
||||
匿名 GET 任意一个桶内对象,期望返回 `HTTP 200` 且响应头包含 `access-control-allow-origin`:
|
||||
|
||||
```bash
|
||||
curl -I -H "Origin: http://localhost:3000" http://<host>:9000/carrot-skin-textures/<某对象路径>
|
||||
# 期望看到:
|
||||
# HTTP/1.1 200 OK
|
||||
# access-control-allow-origin: *
|
||||
```
|
||||
|
||||
若 `access-control-allow-origin` 缺失,说明 CORS 未生效,前端 3D 皮肤将无法渲染。
|
||||
|
||||
### 关于 `RUSTFS_PUBLIC_URL`
|
||||
|
||||
`RUSTFS_PUBLIC_URL` 决定后端生成的文件访问 URL 前缀(`<public_url>/<bucket>/<object>`)。
|
||||
|
||||
- 留空时:用 `RUSTFS_ENDPOINT` + 协议拼接(适合 endpoint 本身就是浏览器可达地址的场景)
|
||||
- 生产环境:务必填写**浏览器可访问的公网地址**,例如 `https://rustfs.example.com`,否则后端返回给前端的 URL 浏览器打不开
|
||||
|
||||
## 🐳 Docker 部署
|
||||
|
||||
`docker-compose.yml` 提供了完整的本地编排,包含 PostgreSQL、Redis、RustFS,以及一个一次性的 `rustfs-init` 服务自动创建存储桶并设置匿名下载策略:
|
||||
|
||||
```bash
|
||||
# 启动全部基础设施(含存储)
|
||||
docker compose --profile storage up -d
|
||||
|
||||
# 仅启动应用
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> `rustfs-init` 使用 `mc` 完成建桶与策略配置。CORS 仍需按上文 [步骤 3](#步骤-3配置-cors跨域前端-3d-渲染必需) 手动配置。
|
||||
|
||||
## ⚙️ 关键环境变量
|
||||
|
||||
| 变量 | 说明 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| `SERVER_PORT` | 服务监听端口 | `8080` |
|
||||
| `SERVER_PORT` | 服务监听端口 | `:8080` |
|
||||
| `SERVER_MODE` | Gin 模式(debug/release) | `debug` |
|
||||
| `SERVER_SWAGGER_ENABLED` | 是否暴露 Swagger UI | `true` |
|
||||
| `DATABASE_HOST` / `DATABASE_PORT` | PostgreSQL 地址 | `localhost` / `5432` |
|
||||
@@ -107,12 +240,16 @@ backend/
|
||||
| `DATABASE_NAME` | 数据库名称 | `carrotskin` |
|
||||
| `REDIS_HOST` / `REDIS_PORT` | Redis 地址 | `localhost` / `6379` |
|
||||
| `REDIS_PASSWORD` | Redis 密码(无可为空) | `` |
|
||||
| `RUSTFS_ENDPOINT` | RustFS/MinIO 访问地址 | `127.0.0.1:9000` |
|
||||
| `RUSTFS_ENDPOINT` | RustFS/MinIO 访问地址(后端内网连接用) | `127.0.0.1:9000` |
|
||||
| `RUSTFS_PUBLIC_URL` | 生成文件 URL 的公开前缀(浏览器可达) | `https://rustfs.example.com` |
|
||||
| `RUSTFS_ACCESS_KEY` / `RUSTFS_SECRET_KEY` | 对象存储凭据 | `minioadmin` |
|
||||
| `RUSTFS_BUCKET_TEXTURES` / `RUSTFS_BUCKET_AVATARS` | 存储桶名称 | `carrotskin-textures` |
|
||||
| `RUSTFS_BUCKET_TEXTURES` / `RUSTFS_BUCKET_AVATARS` | 存储桶名称 | `carrot-skin-textures` |
|
||||
| `JWT_SECRET` | JWT 签名密钥 | `change-me` |
|
||||
| `EMAIL_ENABLED` | 是否开启邮件服务 | `true` |
|
||||
| `EMAIL_SMTP_HOST` / `EMAIL_SMTP_PORT` | SMTP 配置 | `smtp.example.com` / `587` |
|
||||
| `ENVIRONMENT` | 应用环境(用于 service 层 `IsTestEnvironment()` 短路) | `production` |
|
||||
| `SECURITY_ALLOWED_ORIGINS` | API CORS 允许的来源(逗号分隔) | `*` |
|
||||
| `SECURITY_ALLOWED_DOMAINS` | 头像/材质 URL 允许的域名(逗号分隔) | `localhost,127.0.0.1` |
|
||||
|
||||
更多变量请参考 `.env.example` 与 `.env.docker.example`。
|
||||
|
||||
@@ -132,17 +269,28 @@ golangci-lint run (若已安装)
|
||||
|
||||
## 🧱 架构说明
|
||||
|
||||
- **分层设计**:Handler -> Service -> Repository -> Model,层次清晰、职责单一。
|
||||
- **依赖管理器**:`pkg/*/manager.go` 使用 `sync.Once` 实现线程安全单例(DB / Redis / Logger / Storage / Email / Auth / Config)。
|
||||
- **分层设计**:Handler → Service → Repository → Model,层次清晰、职责单一。
|
||||
- Handler 不得直连 `*gorm.DB` 或 `*Repository`,必须通过 Service。
|
||||
- Service 的数据访问通过 Repository 接口(个别需要跨表事务的场景除外)。
|
||||
- **依赖注入(uber-go/fx)**:
|
||||
- `internal/app/` 是唯一的依赖装配入口。每个基础设施 / 仓储 / 服务作为 `fx.Provider` 注册。
|
||||
- **子配置 Provider**:`infra_module.go` 从 `*config.Config` 提取 `DatabaseConfig` / `RedisConfig` / `RustFSConfig` / `LogConfig` / `EmailConfig` 等子结构,按值注入到各 `pkg/*` 包的构造函数。
|
||||
- **生命周期管理**:DB / Redis / Storage / Logger / HTTP Server / Task Runner 通过 `fx.Lifecycle` 的 `OnStart` / `OnStop` Hook 统一管理,彻底替代散落在 `main()` 的 `defer Close()` 链。
|
||||
- **优雅关闭**:`fx.New(app.Module(cfg)).Run()` 监听 `SIGINT` / `SIGTERM`,按依赖逆序触发 `OnStop`,先关 HTTP Server(30s 超时),再关 Task Runner,最后关 DB/Redis。
|
||||
- **Dev/Test 自动回退**:Redis 连接失败且环境为 `development` / `test` / `dev` / `USE_MINIREDIS=true` 时自动启用 `miniredis`。
|
||||
- **启动时错误检查**:循环依赖、缺失 Provider、Provider 返回 error 全部在 `fx.New()` 阶段立即检测。
|
||||
- **错误与响应统一**:
|
||||
- 错误统一在 `internal/errors/`(别名 `apperrors`),通过 `errors.Is/As` 匹配。
|
||||
- 响应统一使用 `model.Response` / `model.PaginationResponse` / `model.NewErrorResponse`。
|
||||
- Middleware 错误响应也走 `model.NewErrorResponse`,不再使用裸 `gin.H`。
|
||||
- **配置优先级**:`config.Load()` 读取 `.env` → 环境变量 → viper 默认值;`*config.Config` 由 `fx.Supply` 注入。
|
||||
- **Swagger 注解**:所有 Handler、模型、DTO 均补齐 `@Summary` / `@Description` / `@Success`,可直接生成 OpenAPI 文档。
|
||||
- **配置优先级**:`.env` -> 系统环境变量,所有配置均可通过容器注入。
|
||||
- **自动任务**:`internal/task` 承载后台作业,可按需扩展。
|
||||
|
||||
## 📝 Swagger 说明
|
||||
|
||||
- `start.sh` 会在启动前执行 `swag init -g cmd/server/main.go -o docs`
|
||||
- 若手动运行,需要保证 `docs/` 下的 `docs.go`、`swagger.json`、`swagger.yaml` 与代码同步
|
||||
- 通过 `SERVER_SWAGGER_ENABLED=false` 可在生产环境关闭 Swagger UI 暴露
|
||||
- `start.sh` 会在启动前执行 `swag init -g cmd/server/main.go -o docs`,生成 `docs/` 目录(`docs.go` / `swagger.json` / `swagger.yaml`)。
|
||||
- `docs/` 为生成产物,**不在仓库内跟踪**,首次启动会自动生成。
|
||||
- 通过 `SERVER_SWAGGER_ENABLED=false` 可在生产环境关闭 Swagger UI 暴露。
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
|
||||
21
cmd/genhash/main.go
Normal file
21
cmd/genhash/main.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"carrotskin/pkg/auth"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: genhash <password>")
|
||||
os.Exit(2)
|
||||
}
|
||||
h, err := auth.HashPassword(os.Args[1])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "err:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Print(h)
|
||||
}
|
||||
@@ -10,170 +10,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"carrotskin/internal/container"
|
||||
"carrotskin/internal/handler"
|
||||
"carrotskin/internal/middleware"
|
||||
"carrotskin/internal/task"
|
||||
"carrotskin/pkg/auth"
|
||||
"carrotskin/internal/app"
|
||||
"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"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// main 应用入口。通过 uber-go/fx 装配所有依赖并管理生命周期。
|
||||
//
|
||||
// fx.Run() 会:
|
||||
// 1. 按 fx.Supply/Provide 构建依赖图
|
||||
// 2. 执行所有 fx.Invoke(迁移、路由注册、服务器/任务启动)
|
||||
// 3. 监听 SIGINT/SIGTERM 信号
|
||||
// 4. 按依赖逆序执行所有 OnStop 钩子(优雅关闭)
|
||||
func main() {
|
||||
// 初始化配置
|
||||
if err := config.Init(); err != nil {
|
||||
// 加载配置(唯一的全局入口,配置本身是无状态的纯数据)
|
||||
cfg, err := config.Load()
|
||||
if 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("服务器已关闭")
|
||||
// 创建并运行 fx 应用
|
||||
fx.New(app.Module(cfg)).Run()
|
||||
}
|
||||
|
||||
6
go.mod
6
go.mod
@@ -19,6 +19,7 @@ require (
|
||||
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/fx v1.24.0
|
||||
go.uber.org/zap v1.27.1
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
gorm.io/datatypes v1.2.7
|
||||
@@ -69,6 +70,7 @@ require (
|
||||
github.com/tinylib/msgp v1.6.3 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/dig v1.19.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
@@ -102,7 +104,7 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12
|
||||
@@ -120,7 +122,7 @@ require (
|
||||
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.6 // indirect
|
||||
github.com/swaggo/swag v1.16.6
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -291,6 +291,10 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
|
||||
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
||||
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
|
||||
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
|
||||
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.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
|
||||
138
internal/app/container_module.go
Normal file
138
internal/app/container_module.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"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/redis"
|
||||
"carrotskin/pkg/storage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BootstrapModule 注册 Container、路由注册、HTTP 服务器与后台任务的生命周期。
|
||||
//
|
||||
// 设计说明:当前所有 Handler 仍依赖 *container.Container(阶段6将逐步解耦为
|
||||
// 直接依赖各 Service 接口)。本 Module 由 fx 注入基础设施,聚合为 Container,
|
||||
// 再用它注册路由、启动服务器与后台任务。当 fx.Run() 接收到 SIGINT/SIGTERM 时,
|
||||
// 会按依赖逆序执行所有 OnStop 钩子,实现优雅关闭。
|
||||
var BootstrapModule = fx.Options(
|
||||
// Gin 路由引擎
|
||||
fx.Provide(provideRouter),
|
||||
|
||||
// 由 fx 注入的基础设施聚合为 Container(Container 内部会构造 Repository/Service)
|
||||
fx.Provide(provideContainer),
|
||||
|
||||
// 数据库迁移与种子数据(infra 就绪后执行)
|
||||
fx.Invoke(runMigrate),
|
||||
|
||||
// 注册所有 HTTP 路由
|
||||
fx.Invoke(registerRoutes),
|
||||
|
||||
// HTTP 服务器生命周期(启动/优雅关闭)
|
||||
fx.Invoke(registerServerLifecycle),
|
||||
|
||||
// 后台任务调度器生命周期
|
||||
fx.Invoke(registerTaskLifecycle),
|
||||
)
|
||||
|
||||
// provideRouter 创建 Gin 引擎并挂载全局中间件。
|
||||
func provideRouter(cfg *config.Config, logger *zap.Logger) *gin.Engine {
|
||||
if cfg.Server.Mode == "production" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
router := gin.New()
|
||||
// 禁用自动重定向,允许API路径带或不带/结尾都能正常访问
|
||||
router.RedirectTrailingSlash = false
|
||||
router.RedirectFixedPath = false
|
||||
router.Use(middleware.Logger(logger))
|
||||
router.Use(middleware.Recovery(logger))
|
||||
router.Use(middleware.CORS(cfg))
|
||||
return router
|
||||
}
|
||||
|
||||
// provideContainer 将所有 fx 管理的基础设施聚合为 Container。
|
||||
// Container.NewContainer 内部会基于这些 infra 构造 Repository 与 Service。
|
||||
// 注意:email.Service 可能为 nil(当 EMAIL_ENABLED=false 时),Container 会降级处理。
|
||||
func provideContainer(
|
||||
cfg *config.Config,
|
||||
db *gorm.DB,
|
||||
redisClient *redis.Client,
|
||||
logger *zap.Logger,
|
||||
jwt *auth.JWTService,
|
||||
casbin *auth.CasbinService,
|
||||
storageClient *storage.StorageClient,
|
||||
emailService *email.Service,
|
||||
) *container.Container {
|
||||
return container.NewContainer(
|
||||
cfg, db, redisClient, logger, jwt, casbin, storageClient, emailService,
|
||||
)
|
||||
}
|
||||
|
||||
// runMigrate 执行数据库迁移与种子数据初始化。
|
||||
func runMigrate(db *gorm.DB, logger *zap.Logger) error {
|
||||
if err := database.AutoMigrateWithDB(db, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
return database.SeedWithDB(db, logger)
|
||||
}
|
||||
|
||||
// registerRoutes 注册所有 HTTP 路由。
|
||||
func registerRoutes(router *gin.Engine, c *container.Container) {
|
||||
handler.RegisterRoutesWithDI(router, c)
|
||||
}
|
||||
|
||||
// registerServerLifecycle 注册 HTTP 服务器的启动与优雅关闭。
|
||||
func registerServerLifecycle(lc fx.Lifecycle, router *gin.Engine, cfg *config.Config, logger *zap.Logger) {
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Server.Port,
|
||||
Handler: router,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
logger.Info("服务器启动", zap.String("port", cfg.Server.Port))
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatal("服务器启动失败", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
},
|
||||
OnStop: func(ctx context.Context) error {
|
||||
logger.Info("正在关闭服务器...")
|
||||
return srv.Shutdown(ctx)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// registerTaskLifecycle 注册后台任务调度器的启动与停止。
|
||||
func registerTaskLifecycle(lc fx.Lifecycle, logger *zap.Logger) {
|
||||
runner := task.NewRunner(logger)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
runner.Start(ctx)
|
||||
return nil
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
cancel()
|
||||
runner.Wait()
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
8
internal/app/handler_module.go
Normal file
8
internal/app/handler_module.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package app
|
||||
|
||||
// HandlerModule 已在阶段4移除。
|
||||
//
|
||||
// 当前阶段,Handler 仍由 internal/container.Container + handler.RegisterRoutesWithDI
|
||||
// 构造与注册(见 container_module.go 的 registerRoutes)。
|
||||
//
|
||||
// TODO(阶段6): Handler 将改为直接依赖各 Service 接口,由 fx 直接 Provide 与注册。
|
||||
115
internal/app/infra_module.go
Normal file
115
internal/app/infra_module.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"carrotskin/pkg/auth"
|
||||
"carrotskin/pkg/config"
|
||||
"carrotskin/pkg/database"
|
||||
"carrotskin/pkg/email"
|
||||
"carrotskin/pkg/logger"
|
||||
"carrotskin/pkg/redis"
|
||||
"carrotskin/pkg/storage"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// InfraModule 注册所有基础设施依赖:Logger/DB/Redis/Storage/Email/JWT/Casbin。
|
||||
// 每个需要资源释放的组件都通过 fx.Lifecycle 注册 OnStop 钩子,
|
||||
// 彻底替代 main.go 里分散的 defer xxx.Close()。
|
||||
var InfraModule = fx.Options(
|
||||
// 顶层 Config 子结构 Provider:让 pkg/* 包的构造函数按值接收细分 Config
|
||||
fx.Provide(func(c *config.Config) config.DatabaseConfig { return c.Database }),
|
||||
fx.Provide(func(c *config.Config) config.RedisConfig { return c.Redis }),
|
||||
fx.Provide(func(c *config.Config) config.RustFSConfig { return c.RustFS }),
|
||||
fx.Provide(func(c *config.Config) config.LogConfig { return c.Log }),
|
||||
fx.Provide(func(c *config.Config) config.EmailConfig { return c.Email }),
|
||||
|
||||
// Logger
|
||||
fx.Provide(logger.New),
|
||||
|
||||
// Database: 同时暴露 *database.DB(封装,带连接池统计)和 *gorm.DB(裸 GORM)
|
||||
fx.Provide(provideDatabase),
|
||||
|
||||
// 暴露 *gorm.DB 供 Repository/Casbin 等消费
|
||||
fx.Provide(func(d *database.DB) *gorm.DB { return d.DB }),
|
||||
|
||||
// Redis(含 miniredis 测试回退)
|
||||
fx.Provide(provideRedis),
|
||||
|
||||
// 对象存储(S3 兼容)
|
||||
fx.Provide(provideStorage),
|
||||
|
||||
// 邮件服务
|
||||
fx.Provide(email.NewService),
|
||||
|
||||
// JWT 服务(应用层认证)
|
||||
fx.Provide(func(cfg *config.Config) *auth.JWTService {
|
||||
return auth.NewJWTService(cfg.JWT.Secret, cfg.JWT.ExpireHours)
|
||||
}),
|
||||
|
||||
// Casbin 权限服务
|
||||
fx.Provide(func(db *gorm.DB, cfg *config.Config, log *zap.Logger) (*auth.CasbinService, error) {
|
||||
return auth.NewCasbinService(db, cfg.Casbin.ModelPath, log)
|
||||
}),
|
||||
)
|
||||
|
||||
// provideDatabase 创建数据库连接并注册关闭钩子。
|
||||
func provideDatabase(cfg *config.Config, lc fx.Lifecycle, log *zap.Logger) (*database.DB, error) {
|
||||
db, err := database.New(cfg.Database)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lc.Append(fx.Hook{
|
||||
OnStop: func(ctx context.Context) error {
|
||||
log.Info("正在关闭数据库连接")
|
||||
return db.Close()
|
||||
},
|
||||
})
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// provideRedis 创建 Redis 客户端并注册关闭钩子。
|
||||
// 开发/测试环境下若连接失败会自动回退到 miniredis。
|
||||
func provideRedis(cfg *config.Config, lc fx.Lifecycle, log *zap.Logger) (*redis.Client, error) {
|
||||
client, err := redis.New(cfg.Redis, log)
|
||||
if err != nil {
|
||||
// 检查是否允许回退到 miniredis(仅开发/测试环境)
|
||||
if redis.AllowFallbackToMiniRedis() {
|
||||
log.Warn("Redis连接失败,尝试使用miniredis回退", zap.Error(err))
|
||||
client, err = redis.InitMiniRedis(log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Info("已回退到miniredis用于开发/测试环境")
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
lc.Append(fx.Hook{
|
||||
OnStop: func(ctx context.Context) error {
|
||||
log.Info("正在关闭 Redis 连接")
|
||||
return client.Close()
|
||||
},
|
||||
})
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// provideStorage 创建对象存储客户端并注册关闭钩子。
|
||||
// 存储服务在配置缺失时可能是可选的,失败时返回 error 让调用方决定是否降级。
|
||||
func provideStorage(cfg *config.Config, lc fx.Lifecycle, log *zap.Logger) (*storage.StorageClient, error) {
|
||||
client, err := storage.NewStorage(cfg.RustFS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// MinIO 客户端无需显式 Close,但保留钩子以备未来扩展
|
||||
lc.Append(fx.Hook{
|
||||
OnStop: func(ctx context.Context) error {
|
||||
log.Info("对象存储客户端已停止")
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return client, nil
|
||||
}
|
||||
30
internal/app/module.go
Normal file
30
internal/app/module.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Package app 提供 uber-go/fx 应用装配层。
|
||||
//
|
||||
// 该包用 fx 的 Provider/Lifecycle 取代了 pkg/*/manager.go 的全局单例和
|
||||
// internal/container 的手动 DI,是整个应用唯一的依赖装配入口。
|
||||
//
|
||||
// 使用方式:
|
||||
//
|
||||
// cfg, err := config.Load()
|
||||
// if err != nil { log.Fatal(err) }
|
||||
// fx.New(app.Module(cfg)).Run()
|
||||
package app
|
||||
|
||||
import (
|
||||
"carrotskin/pkg/config"
|
||||
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// Module 返回应用的根 fx.Option,聚合所有子模块。
|
||||
// cfg 通过 fx.Supply 注入,供各 Provider 消费其子配置结构。
|
||||
//
|
||||
// 当前阶段:fx 管理 infra + Container 聚合 + 路由/服务器/任务生命周期。
|
||||
// Container 内部构造 Repository 与 Service(阶段6后将逐步解耦为 fx 直接管理 Service)。
|
||||
func Module(cfg *config.Config) fx.Option {
|
||||
return fx.Options(
|
||||
fx.Supply(cfg),
|
||||
InfraModule,
|
||||
BootstrapModule,
|
||||
)
|
||||
}
|
||||
13
internal/app/repository_module.go
Normal file
13
internal/app/repository_module.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package app
|
||||
|
||||
// RepositoryModule 已在阶段4移除。
|
||||
//
|
||||
// 当前阶段,Repository 由 internal/container.Container 内部构造。
|
||||
//
|
||||
// TODO(阶段6): Container 移除后,Repository 将回到此 Module 由 fx 直接管理:
|
||||
//
|
||||
// var RepositoryModule = fx.Options(
|
||||
// fx.Provide(repository.NewUserRepository),
|
||||
// fx.Provide(repository.NewProfileRepository),
|
||||
// ...
|
||||
// )
|
||||
7
internal/app/server_module.go
Normal file
7
internal/app/server_module.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package app
|
||||
|
||||
// ServerModule 已在阶段4移除。
|
||||
//
|
||||
// 当前阶段,HTTP 服务器生命周期由 container_module.go 的 registerServerLifecycle 管理。
|
||||
//
|
||||
// TODO(阶段6): 此处将作为独立 Module 重新启用。
|
||||
15
internal/app/service_module.go
Normal file
15
internal/app/service_module.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package app
|
||||
|
||||
// ServiceModule 已在阶段4移除。
|
||||
//
|
||||
// 当前阶段,Service 由 internal/container.Container 内部构造(Container 聚合了
|
||||
// 所有 fx 注入的基础设施,在其 NewContainer 方法里创建 Repository 与 Service)。
|
||||
//
|
||||
// TODO(阶段6): 当 Handler 解耦为直接依赖各 Service 接口后,Container 将被移除,
|
||||
// 届时 Service 将回到此 Module 由 fx 直接管理:
|
||||
//
|
||||
// var ServiceModule = fx.Options(
|
||||
// fx.Provide(service.NewUserService),
|
||||
// fx.Provide(service.NewProfileService),
|
||||
// ...
|
||||
// )
|
||||
7
internal/app/task_module.go
Normal file
7
internal/app/task_module.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package app
|
||||
|
||||
// TaskModule 已在阶段4移除。
|
||||
//
|
||||
// 当前阶段,后台任务生命周期由 container_module.go 的 registerTaskLifecycle 管理。
|
||||
//
|
||||
// TODO(阶段6): 此处将作为独立 Module 重新启用。
|
||||
@@ -1,14 +1,17 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"carrotskin/internal/repository"
|
||||
"carrotskin/internal/service"
|
||||
"carrotskin/pkg/auth"
|
||||
"carrotskin/pkg/config"
|
||||
"carrotskin/pkg/database"
|
||||
"carrotskin/pkg/email"
|
||||
"carrotskin/pkg/redis"
|
||||
"carrotskin/pkg/storage"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
@@ -17,6 +20,9 @@ import (
|
||||
// Container 依赖注入容器
|
||||
// 集中管理所有依赖,便于测试和维护
|
||||
type Container struct {
|
||||
// 配置
|
||||
Config *config.Config
|
||||
|
||||
// 基础设施依赖
|
||||
DB *gorm.DB
|
||||
Redis *redis.Client
|
||||
@@ -32,6 +38,8 @@ type Container struct {
|
||||
TextureRepo repository.TextureRepository
|
||||
ClientRepo repository.ClientRepository
|
||||
YggdrasilRepo repository.YggdrasilRepository
|
||||
FriendRepo repository.FriendRepository
|
||||
PlayerAttrRepo repository.PlayerAttributeRepository
|
||||
|
||||
// Service层
|
||||
UserService service.UserService
|
||||
@@ -39,21 +47,22 @@ type Container struct {
|
||||
TextureService service.TextureService
|
||||
TokenService service.TokenService
|
||||
YggdrasilService service.YggdrasilService
|
||||
FriendsService service.FriendsService
|
||||
VerificationService service.VerificationService
|
||||
SecurityService service.SecurityService
|
||||
CaptchaService service.CaptchaService
|
||||
SignatureService *service.SignatureService
|
||||
}
|
||||
|
||||
// NewContainer 创建依赖容器
|
||||
func NewContainer(
|
||||
cfg *config.Config,
|
||||
db *gorm.DB,
|
||||
redisClient *redis.Client,
|
||||
logger *zap.Logger,
|
||||
jwtService *auth.JWTService,
|
||||
casbinService *auth.CasbinService,
|
||||
storageClient *storage.StorageClient,
|
||||
emailService interface{}, // 接受 email.Service 但使用 interface{} 避免循环依赖
|
||||
emailService *email.Service,
|
||||
) *Container {
|
||||
// 创建缓存管理器
|
||||
cacheManager := database.NewCacheManager(redisClient, database.CacheConfig{
|
||||
@@ -71,6 +80,7 @@ func NewContainer(
|
||||
})
|
||||
|
||||
c := &Container{
|
||||
Config: cfg,
|
||||
DB: db,
|
||||
Redis: redisClient,
|
||||
Logger: logger,
|
||||
@@ -86,20 +96,25 @@ func NewContainer(
|
||||
c.TextureRepo = repository.NewTextureRepository(db)
|
||||
c.ClientRepo = repository.NewClientRepository(db)
|
||||
c.YggdrasilRepo = repository.NewYggdrasilRepository(db)
|
||||
c.FriendRepo = repository.NewFriendRepository(db)
|
||||
c.PlayerAttrRepo = repository.NewPlayerAttributeRepository(db)
|
||||
|
||||
// 初始化SignatureService(作为依赖注入,避免在容器中创建并立即调用)
|
||||
// 将SignatureService添加到容器中,供其他服务使用
|
||||
c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger)
|
||||
|
||||
// 初始化Service(注入缓存管理器)
|
||||
c.UserService = service.NewUserService(c.UserRepo, jwtService, redisClient, cacheManager, storageClient, logger)
|
||||
c.UserService = service.NewUserService(cfg, 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.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger, db)
|
||||
|
||||
// 获取Yggdrasil私钥并创建JWT服务(TokenService需要)
|
||||
// 注意:这里仍然需要预先初始化,因为TokenService在创建时需要YggdrasilJWT
|
||||
// 但SignatureService已经作为依赖注入,降低了耦合度
|
||||
_, privateKey, err := c.SignatureService.GetOrCreateYggdrasilKeyPair()
|
||||
// 使用后台 ctx(启动阶段不依赖请求 ctx)
|
||||
initCtx, cancelInit := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
_, privateKey, err := c.SignatureService.GetOrCreateYggdrasilKeyPair(initCtx)
|
||||
cancelInit()
|
||||
if err != nil {
|
||||
logger.Fatal("获取Yggdrasil私钥失败", zap.Error(err))
|
||||
}
|
||||
@@ -121,149 +136,18 @@ func NewContainer(
|
||||
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.YggdrasilService = service.NewYggdrasilServiceComposite(c.UserRepo, c.ProfileRepo, c.YggdrasilRepo, c.TextureRepo, c.SignatureService, redisClient, logger, c.TokenService)
|
||||
|
||||
// 好友系统服务(依赖 Profile/Redis)
|
||||
c.FriendsService = service.NewFriendsService(c.FriendRepo, c.PlayerAttrRepo, c.ProfileRepo, redisClient, logger)
|
||||
|
||||
// 初始化其他服务
|
||||
c.SecurityService = service.NewSecurityService(redisClient)
|
||||
c.CaptchaService = service.NewCaptchaService(redisClient, logger)
|
||||
c.CaptchaService = service.NewCaptchaService(cfg, redisClient, logger)
|
||||
|
||||
// 初始化VerificationService(需要email.Service)
|
||||
if emailService != nil {
|
||||
if emailSvc, ok := emailService.(*email.Service); ok {
|
||||
c.VerificationService = service.NewVerificationService(redisClient, emailSvc)
|
||||
}
|
||||
c.VerificationService = service.NewVerificationService(cfg, redisClient, emailService)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,12 @@ 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("档案不存在")
|
||||
@@ -32,7 +30,6 @@ var (
|
||||
ErrTextureExists = errors.New("该材质已存在")
|
||||
ErrTextureLimitReached = errors.New("已达材质数量上限")
|
||||
ErrTextureNoPermission = errors.New("无权操作此材质")
|
||||
ErrInvalidTextureType = errors.New("无效的材质类型")
|
||||
|
||||
// 验证码相关错误
|
||||
ErrInvalidVerificationCode = errors.New("验证码错误或已过期")
|
||||
@@ -54,15 +51,11 @@ var (
|
||||
ErrSessionNotFound = errors.New("会话不存在或已过期")
|
||||
ErrSessionMismatch = errors.New("会话验证失败")
|
||||
ErrUsernameMismatch = errors.New("用户名不匹配")
|
||||
ErrUsernameRequired = errors.New("用户名不能为空")
|
||||
ErrIPMismatch = errors.New("IP地址不匹配")
|
||||
ErrInvalidAccessToken = errors.New("访问令牌无效")
|
||||
ErrProfileMismatch = errors.New("selectedProfile与Token不匹配")
|
||||
ErrUUIDRequired = errors.New("UUID不能为空")
|
||||
ErrCertificateGenerate = errors.New("生成证书失败")
|
||||
|
||||
// Yggdrasil协议标准错误
|
||||
ErrYggForbiddenOperation = errors.New("ForbiddenOperationException")
|
||||
ErrYggIllegalArgument = errors.New("IllegalArgumentException")
|
||||
|
||||
// 通用错误
|
||||
ErrBadRequest = errors.New("请求参数错误")
|
||||
@@ -125,23 +118,8 @@ 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)
|
||||
}
|
||||
// 注意:原此处的 Is/As/Wrap 函数(透传标准库)已删除。
|
||||
// 请直接使用标准库 errors.Is / errors.As / fmt.Errorf。
|
||||
|
||||
// YggdrasilErrorResponse Yggdrasil协议标准错误响应格式
|
||||
type YggdrasilErrorResponse struct {
|
||||
@@ -168,3 +146,41 @@ const (
|
||||
// IllegalArgumentException 错误消息
|
||||
YggErrProfileAlreadyAssigned = "Access token already has a profile assigned."
|
||||
)
|
||||
|
||||
// FriendsErrorStatus MinecraftServices 好友系接口业务错误码(文档 1.3.4)
|
||||
const (
|
||||
FriendsErrUnknownProfile = "UNKNOWN_PROFILE"
|
||||
FriendsErrCannotAddSelf = "CANNOT_ADD_SELF"
|
||||
FriendsErrDuplicatedProfiles = "DUPLICATED_PROFILES"
|
||||
)
|
||||
|
||||
// FriendsErrorResultCode MinecraftServices 好友系接口结果码(文档 4)
|
||||
const (
|
||||
FriendsResultSuccess = "SUCCESS"
|
||||
FriendsResultError = "ERROR"
|
||||
FriendsResultServiceUnavailable = "SERVICE_NOT_AVAILABLE"
|
||||
FriendsResultTooManyRequests = "TOO_MANY_REQUESTS"
|
||||
FriendsResultForbidden = "FORBIDDEN"
|
||||
FriendsResultUnknownProfile = "UNKNOWN_PROFILE"
|
||||
FriendsResultUnauthorized = "UNAUTHORIZED"
|
||||
)
|
||||
|
||||
// MinecraftServicesErrorResponse MinecraftServices 系接口标准错误响应(文档 0.3)
|
||||
// 与 YggdrasilErrorResponse 不同,本体系按 MinecraftServices 的结构返回:
|
||||
// { "path": "...", "error": "...", "errorMessage": "...", "details": {} }
|
||||
type MinecraftServicesErrorResponse struct {
|
||||
Path string `json:"path"`
|
||||
Error string `json:"error"` // 机器可读错误码
|
||||
ErrorMessage string `json:"errorMessage"` // 人类可读描述
|
||||
Details interface{} `json:"details,omitempty"` // 错误特定细节
|
||||
}
|
||||
|
||||
// NewMinecraftServicesErrorResponse 创建 MinecraftServices 系标准错误响应
|
||||
func NewMinecraftServicesErrorResponse(path, errorCode, errorMessage string, details interface{}) *MinecraftServicesErrorResponse {
|
||||
return &MinecraftServicesErrorResponse{
|
||||
Path: path,
|
||||
Error: errorCode,
|
||||
ErrorMessage: errorMessage,
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,24 +15,12 @@ func TestAppErrorBasics(t *testing.T) {
|
||||
if got := appErr.Error(); got != "bad: root" {
|
||||
t.Fatalf("unexpected Error(): %s", got)
|
||||
}
|
||||
if !Is(appErr, root) {
|
||||
// 使用标准库 errors.Is/As(包级 Is/As 已移除)
|
||||
if !errors.Is(appErr, root) {
|
||||
t.Fatalf("Is should match wrapped error")
|
||||
}
|
||||
var target *AppError
|
||||
if !As(appErr, &target) {
|
||||
if !errors.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"carrotskin/internal/container"
|
||||
apperrors "carrotskin/internal/errors"
|
||||
"carrotskin/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -46,11 +48,16 @@ func (h *AdminHandler) SetUserRole(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前操作者ID
|
||||
operatorID, _ := c.Get("user_id")
|
||||
// 获取当前操作者ID(安全类型断言,防止中间件异常时 panic)
|
||||
operatorIDVal, _ := c.Get("user_id")
|
||||
operatorID, ok := operatorIDVal.(int64)
|
||||
if !ok {
|
||||
RespondServerError(c, "操作者ID类型错误", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 不能修改自己的角色
|
||||
if req.UserID == operatorID.(int64) {
|
||||
if req.UserID == operatorID {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"不能修改自己的角色",
|
||||
@@ -59,9 +66,13 @@ func (h *AdminHandler) SetUserRole(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查目标用户是否存在
|
||||
targetUser, err := h.container.UserRepo.FindByID(c.Request.Context(), req.UserID)
|
||||
if err != nil || targetUser == nil {
|
||||
// 检查目标用户是否存在(区分 DB 错误与"用户不存在")
|
||||
targetUser, err := h.container.UserService.GetByID(c.Request.Context(), req.UserID)
|
||||
if err != nil {
|
||||
RespondServerError(c, "查询用户失败", err)
|
||||
return
|
||||
}
|
||||
if targetUser == nil {
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
"用户不存在",
|
||||
@@ -71,16 +82,13 @@ func (h *AdminHandler) SetUserRole(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 更新用户角色
|
||||
err = h.container.UserRepo.UpdateFields(c.Request.Context(), req.UserID, map[string]interface{}{
|
||||
"role": req.Role,
|
||||
})
|
||||
if err != nil {
|
||||
if err := h.container.UserService.SetRole(c.Request.Context(), req.UserID, req.Role); err != nil {
|
||||
RespondServerError(c, "更新用户角色失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
h.container.Logger.Info("管理员修改用户角色",
|
||||
zap.Int64("operator_id", operatorID.(int64)),
|
||||
zap.Int64("operator_id", operatorID),
|
||||
zap.Int64("target_user_id", req.UserID),
|
||||
zap.String("new_role", req.Role),
|
||||
)
|
||||
@@ -114,13 +122,12 @@ func (h *AdminHandler) GetUserList(c *gin.Context) {
|
||||
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)
|
||||
// 通过 Service 层查询用户列表
|
||||
users, total, err := h.container.UserService.ListUsers(c.Request.Context(), page, pageSize)
|
||||
if err != nil {
|
||||
RespondServerError(c, "获取用户列表失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 构建响应(隐藏敏感信息)
|
||||
userList := make([]gin.H, len(users))
|
||||
@@ -163,7 +170,7 @@ func (h *AdminHandler) GetUserDetail(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.container.UserRepo.FindByID(c.Request.Context(), userID)
|
||||
user, err := h.container.UserService.GetByID(c.Request.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
@@ -212,10 +219,15 @@ func (h *AdminHandler) SetUserStatus(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
operatorID, _ := c.Get("user_id")
|
||||
operatorIDVal, _ := c.Get("user_id")
|
||||
operatorID, ok := operatorIDVal.(int64)
|
||||
if !ok {
|
||||
RespondServerError(c, "操作者ID类型错误", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 不能修改自己的状态
|
||||
if req.UserID == operatorID.(int64) {
|
||||
if req.UserID == operatorID {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"不能修改自己的状态",
|
||||
@@ -224,9 +236,13 @@ func (h *AdminHandler) SetUserStatus(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查目标用户是否存在
|
||||
targetUser, err := h.container.UserRepo.FindByID(c.Request.Context(), req.UserID)
|
||||
if err != nil || targetUser == nil {
|
||||
// 检查目标用户是否存在(区分 DB 错误与"用户不存在")
|
||||
targetUser, err := h.container.UserService.GetByID(c.Request.Context(), req.UserID)
|
||||
if err != nil {
|
||||
RespondServerError(c, "查询用户失败", err)
|
||||
return
|
||||
}
|
||||
if targetUser == nil {
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
"用户不存在",
|
||||
@@ -236,10 +252,7 @@ func (h *AdminHandler) SetUserStatus(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 更新用户状态
|
||||
err = h.container.UserRepo.UpdateFields(c.Request.Context(), req.UserID, map[string]interface{}{
|
||||
"status": req.Status,
|
||||
})
|
||||
if err != nil {
|
||||
if err := h.container.UserService.SetStatus(c.Request.Context(), req.UserID, req.Status); err != nil {
|
||||
RespondServerError(c, "更新用户状态失败", err)
|
||||
return
|
||||
}
|
||||
@@ -247,7 +260,7 @@ func (h *AdminHandler) SetUserStatus(c *gin.Context) {
|
||||
statusText := map[int16]string{1: "正常", 0: "禁用", -1: "删除"}[req.Status]
|
||||
|
||||
h.container.Logger.Info("管理员修改用户状态",
|
||||
zap.Int64("operator_id", operatorID.(int64)),
|
||||
zap.Int64("operator_id", operatorID),
|
||||
zap.Int64("target_user_id", req.UserID),
|
||||
zap.Int16("new_status", req.Status),
|
||||
)
|
||||
@@ -277,11 +290,16 @@ func (h *AdminHandler) DeleteTexture(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
operatorID, _ := c.Get("user_id")
|
||||
operatorIDVal, _ := c.Get("user_id")
|
||||
operatorID, ok := operatorIDVal.(int64)
|
||||
if !ok {
|
||||
RespondServerError(c, "操作者ID类型错误", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查材质是否存在
|
||||
var texture model.Texture
|
||||
if err := h.container.DB.First(&texture, textureID).Error; err != nil {
|
||||
// 通过 Service 删除材质(Service 会检查存在性)
|
||||
if err := h.container.TextureService.AdminDelete(c.Request.Context(), textureID); err != nil {
|
||||
if errors.Is(err, apperrors.ErrTextureNotFound) {
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
"材质不存在",
|
||||
@@ -289,18 +307,13 @@ func (h *AdminHandler) DeleteTexture(c *gin.Context) {
|
||||
))
|
||||
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("operator_id", operatorID),
|
||||
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{
|
||||
@@ -330,12 +343,12 @@ func (h *AdminHandler) GetTextureList(c *gin.Context) {
|
||||
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)
|
||||
// 通过 Service 层查询材质列表
|
||||
textures, total, err := h.container.TextureService.ListForAdmin(c.Request.Context(), page, pageSize)
|
||||
if err != nil {
|
||||
RespondServerError(c, "获取材质列表失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
textureList := make([]gin.H, len(textures))
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"carrotskin/internal/container"
|
||||
"carrotskin/internal/service"
|
||||
"carrotskin/internal/types"
|
||||
"carrotskin/pkg/email"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
@@ -177,8 +176,3 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
||||
|
||||
RespondSuccess(c, gin.H{"message": "密码重置成功"})
|
||||
}
|
||||
|
||||
// getEmailService 获取邮件服务(暂时使用全局方式,后续可改为依赖注入)
|
||||
func (h *AuthHandler) getEmailService() (*email.Service, error) {
|
||||
return email.GetService()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"carrotskin/internal/container"
|
||||
"net/http"
|
||||
|
||||
"carrotskin/internal/container"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -34,7 +35,7 @@ type CaptchaVerifyRequest struct {
|
||||
// @Tags captcha
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{} "生成成功 {code: 200, data: {masterImage, tileImage, captchaId, y}}"
|
||||
// @Success 200 {object} map[string]interface{} "生成成功 {code: 200, message: string, data: {masterImage, tileImage, captchaId, y}}"
|
||||
// @Failure 500 {object} map[string]interface{} "生成失败"
|
||||
// @Router /api/v1/captcha/generate [get]
|
||||
func (h *CaptchaHandler) Generate(c *gin.Context) {
|
||||
@@ -43,13 +44,14 @@ func (h *CaptchaHandler) Generate(c *gin.Context) {
|
||||
h.logger.Error("生成验证码失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"msg": "生成验证码失败",
|
||||
"message": "生成验证码失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"message": "操作成功",
|
||||
"data": gin.H{
|
||||
"masterImage": masterImg,
|
||||
"tileImage": tileImg,
|
||||
@@ -66,7 +68,7 @@ func (h *CaptchaHandler) Generate(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body CaptchaVerifyRequest true "验证请求"
|
||||
// @Success 200 {object} map[string]interface{} "验证结果 {code: 200/400, msg: string}"
|
||||
// @Success 200 {object} map[string]interface{} "验证结果 {code: 200/400, message: string}"
|
||||
// @Failure 400 {object} map[string]interface{} "参数错误"
|
||||
// @Router /api/v1/captcha/verify [post]
|
||||
func (h *CaptchaHandler) Verify(c *gin.Context) {
|
||||
@@ -74,7 +76,7 @@ func (h *CaptchaHandler) Verify(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "参数错误: " + err.Error(),
|
||||
"message": "参数错误: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -87,7 +89,7 @@ func (h *CaptchaHandler) Verify(c *gin.Context) {
|
||||
)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"msg": "验证失败",
|
||||
"message": "验证失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -95,12 +97,12 @@ func (h *CaptchaHandler) Verify(c *gin.Context) {
|
||||
if valid {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"msg": "验证成功",
|
||||
"message": "验证成功",
|
||||
})
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 400,
|
||||
"msg": "验证失败,请重试",
|
||||
"message": "验证失败,请重试",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"carrotskin/internal/container"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"carrotskin/internal/container"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -233,9 +235,12 @@ func (h *CustomSkinHandler) GetTexture(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 增加下载计数(异步)
|
||||
// 增加下载计数(异步,使用独立 ctx 避免请求取消时丢失计数)
|
||||
go func() {
|
||||
_ = h.container.TextureRepo.IncrementDownloadCount(ctx, texture.ID)
|
||||
// 复制 ctx 但不传播取消,配合超时防止 Redis 永久阻塞
|
||||
asyncCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = h.container.TextureService.IncrementDownload(asyncCtx, texture.ID)
|
||||
}()
|
||||
|
||||
// 流式传输文件内容
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"carrotskin/internal/errors"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/types"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
apperrors "carrotskin/internal/errors"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -19,6 +22,33 @@ func parseIntWithDefault(s string, defaultVal int) int {
|
||||
return val
|
||||
}
|
||||
|
||||
// extractBearerToken 从 Authorization 头解析 Bearer token
|
||||
// 返回值: token, ok (ok=false 时已写入 401 错误响应)
|
||||
func extractBearerToken(c *gin.Context) (string, bool) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, apperrors.NewMinecraftServicesErrorResponse(
|
||||
c.Request.URL.Path, "UNAUTHORIZED", "Authorization header not provided", nil,
|
||||
))
|
||||
return "", false
|
||||
}
|
||||
const bearerPrefix = "Bearer "
|
||||
if !strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
c.JSON(http.StatusUnauthorized, apperrors.NewMinecraftServicesErrorResponse(
|
||||
c.Request.URL.Path, "UNAUTHORIZED", "Invalid Authorization format", nil,
|
||||
))
|
||||
return "", false
|
||||
}
|
||||
token := strings.TrimPrefix(authHeader, bearerPrefix)
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, apperrors.NewMinecraftServicesErrorResponse(
|
||||
c.Request.URL.Path, "UNAUTHORIZED", "Invalid Authorization format", nil,
|
||||
))
|
||||
return "", false
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
|
||||
// GetUserIDFromContext 从上下文获取用户ID,如果不存在返回未授权响应
|
||||
// 返回值: userID, ok (如果ok为false,已经发送了错误响应)
|
||||
func GetUserIDFromContext(c *gin.Context) (int64, bool) {
|
||||
@@ -190,31 +220,31 @@ func RespondWithError(c *gin.Context, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// 使用errors.Is检查预定义错误
|
||||
if errors.Is(err, errors.ErrUserNotFound) ||
|
||||
errors.Is(err, errors.ErrProfileNotFound) ||
|
||||
errors.Is(err, errors.ErrTextureNotFound) ||
|
||||
errors.Is(err, errors.ErrNotFound) {
|
||||
// 使用标准库 errors.Is 检查预定义错误
|
||||
if errors.Is(err, apperrors.ErrUserNotFound) ||
|
||||
errors.Is(err, apperrors.ErrProfileNotFound) ||
|
||||
errors.Is(err, apperrors.ErrTextureNotFound) ||
|
||||
errors.Is(err, apperrors.ErrNotFound) {
|
||||
RespondNotFound(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, errors.ErrProfileNoPermission) ||
|
||||
errors.Is(err, errors.ErrTextureNoPermission) ||
|
||||
errors.Is(err, errors.ErrForbidden) {
|
||||
if errors.Is(err, apperrors.ErrProfileNoPermission) ||
|
||||
errors.Is(err, apperrors.ErrTextureNoPermission) ||
|
||||
errors.Is(err, apperrors.ErrForbidden) {
|
||||
RespondForbidden(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, errors.ErrUnauthorized) ||
|
||||
errors.Is(err, errors.ErrInvalidToken) ||
|
||||
errors.Is(err, errors.ErrTokenExpired) {
|
||||
if errors.Is(err, apperrors.ErrUnauthorized) ||
|
||||
errors.Is(err, apperrors.ErrInvalidToken) ||
|
||||
errors.Is(err, apperrors.ErrTokenExpired) {
|
||||
RespondUnauthorized(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 检查AppError类型
|
||||
var appErr *errors.AppError
|
||||
var appErr *apperrors.AppError
|
||||
if errors.As(err, &appErr) {
|
||||
c.JSON(appErr.Code, model.NewErrorResponse(
|
||||
appErr.Code,
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"carrotskin/internal/container"
|
||||
"carrotskin/internal/middleware"
|
||||
"carrotskin/pkg/auth"
|
||||
"carrotskin/pkg/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
@@ -19,6 +18,7 @@ type Handlers struct {
|
||||
Profile *ProfileHandler
|
||||
Captcha *CaptchaHandler
|
||||
Yggdrasil *YggdrasilHandler
|
||||
Friends *YggdrasilFriendsHandler
|
||||
CustomSkin *CustomSkinHandler
|
||||
Admin *AdminHandler
|
||||
}
|
||||
@@ -32,6 +32,7 @@ func NewHandlers(c *container.Container) *Handlers {
|
||||
Profile: NewProfileHandler(c),
|
||||
Captcha: NewCaptchaHandler(c),
|
||||
Yggdrasil: NewYggdrasilHandler(c),
|
||||
Friends: NewYggdrasilFriendsHandler(c),
|
||||
CustomSkin: NewCustomSkinHandler(c),
|
||||
Admin: NewAdminHandler(c),
|
||||
}
|
||||
@@ -40,11 +41,10 @@ func NewHandlers(c *container.Container) *Handlers {
|
||||
// RegisterRoutesWithDI 使用依赖注入注册所有路由
|
||||
func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
|
||||
// 健康检查路由
|
||||
router.GET("/health", HealthCheck)
|
||||
router.GET("/health", NewHealthCheck(c.DB, c.Redis))
|
||||
|
||||
// Swagger文档路由
|
||||
cfg, _ := config.GetConfig()
|
||||
if cfg != nil && cfg.Server.SwaggerEnabled {
|
||||
if c.Config != nil && c.Config.Server.SwaggerEnabled {
|
||||
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
|
||||
|
||||
// Yggdrasil API路由组(独立于v1,路径为 /api/yggdrasil)
|
||||
|
||||
registerYggdrasilRoutesWithDI(apiGroup, h.Yggdrasil)
|
||||
registerYggdrasilRoutesWithDI(apiGroup, h)
|
||||
}
|
||||
|
||||
// registerAuthRoutes 注册认证路由
|
||||
@@ -171,29 +171,45 @@ func registerCaptchaRoutesWithDI(v1 *gin.RouterGroup, h *CaptchaHandler) {
|
||||
}
|
||||
|
||||
// registerYggdrasilRoutesWithDI 注册Yggdrasil API路由(依赖注入版本)
|
||||
func registerYggdrasilRoutesWithDI(v1 *gin.RouterGroup, h *YggdrasilHandler) {
|
||||
func registerYggdrasilRoutesWithDI(v1 *gin.RouterGroup, h *Handlers) {
|
||||
ygg := v1.Group("/yggdrasil")
|
||||
{
|
||||
ygg.GET("", h.GetMetaData)
|
||||
ygg.POST("/minecraftservices/player/certificates", h.GetPlayerCertificates)
|
||||
ygg.GET("", h.Yggdrasil.GetMetaData)
|
||||
ygg.POST("/minecraftservices/player/certificates", h.Yggdrasil.GetPlayerCertificates)
|
||||
|
||||
// MinecraftServices 好友系接口(文档 1.1~1.3、1.6)
|
||||
mcServices := ygg.Group("/minecraftservices")
|
||||
{
|
||||
mcServices.GET("/friends", h.Friends.GetFriends)
|
||||
mcServices.PUT("/friends", h.Friends.UpdateFriends)
|
||||
mcServices.GET("/player/attributes", h.Friends.GetAttributes)
|
||||
mcServices.POST("/player/attributes", h.Friends.UpdateAttributes)
|
||||
mcServices.POST("/presence", h.Friends.UpdatePresence)
|
||||
mcServices.GET("/privacy/blocklist", h.Friends.GetBlocklist)
|
||||
// MC 1.21.7 /whitelist add 查询单用户名 UUID(authlib-injector issue #277)
|
||||
mcServices.GET("/minecraft/profile/lookup/name/:name", h.Yggdrasil.LookupProfileByName)
|
||||
}
|
||||
|
||||
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)
|
||||
authserver.POST("/authenticate", h.Yggdrasil.Authenticate)
|
||||
authserver.POST("/validate", h.Yggdrasil.ValidToken)
|
||||
authserver.POST("/refresh", h.Yggdrasil.RefreshToken)
|
||||
authserver.POST("/invalidate", h.Yggdrasil.InvalidToken)
|
||||
authserver.POST("/signout", h.Yggdrasil.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)
|
||||
sessionServer.GET("/session/minecraft/profile/:uuid", h.Yggdrasil.GetProfileByUUID)
|
||||
sessionServer.POST("/session/minecraft/join", h.Yggdrasil.JoinServer)
|
||||
sessionServer.GET("/session/minecraft/hasJoined", h.Yggdrasil.HasJoinedServer)
|
||||
}
|
||||
api := ygg.Group("/api")
|
||||
profiles := api.Group("/profiles")
|
||||
{
|
||||
profiles.POST("/minecraft", h.GetProfilesByName)
|
||||
// 对应官方 api.mojang.com/profiles/minecraft(文档 2.1:批量按用户名查档)
|
||||
api.POST("/profiles/minecraft", h.Yggdrasil.GetProfilesByName)
|
||||
// 对应官方 api.mojang.com/users/profiles/minecraft/{name}(文档 2.2:单个用户名查档)
|
||||
api.GET("/users/profiles/minecraft/:name", h.Yggdrasil.GetProfileByName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"carrotskin/pkg/database"
|
||||
"carrotskin/pkg/redis"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HealthCheck 健康检查,检查依赖服务状态
|
||||
func HealthCheck(c *gin.Context) {
|
||||
// NewHealthCheck 构造健康检查 handler,依赖通过参数注入。
|
||||
func NewHealthCheck(db *gorm.DB, redisClient *redis.Client) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -21,7 +22,7 @@ func HealthCheck(c *gin.Context) {
|
||||
status := "ok"
|
||||
|
||||
// 检查数据库
|
||||
if err := checkDatabase(ctx); err != nil {
|
||||
if err := checkDatabase(ctx, db); err != nil {
|
||||
checks["database"] = "unhealthy: " + err.Error()
|
||||
status = "degraded"
|
||||
} else {
|
||||
@@ -29,7 +30,7 @@ func HealthCheck(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 检查Redis
|
||||
if err := checkRedis(ctx); err != nil {
|
||||
if err := checkRedis(ctx, redisClient); err != nil {
|
||||
checks["redis"] = "unhealthy: " + err.Error()
|
||||
status = "degraded"
|
||||
} else {
|
||||
@@ -48,15 +49,14 @@ func HealthCheck(c *gin.Context) {
|
||||
"checks": checks,
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// checkDatabase 检查数据库连接
|
||||
func checkDatabase(ctx context.Context) error {
|
||||
db, err := database.GetDB()
|
||||
if err != nil {
|
||||
return err
|
||||
func checkDatabase(ctx context.Context, db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return errors.New("数据库未初始化")
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -77,11 +77,7 @@ func checkDatabase(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// checkRedis 检查Redis连接
|
||||
func checkRedis(ctx context.Context) error {
|
||||
client, err := redis.GetClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
func checkRedis(ctx context.Context, client *redis.Client) error {
|
||||
if client == nil {
|
||||
return errors.New("Redis客户端未初始化")
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ import (
|
||||
func TestHealthCheck_Degraded(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/health", HealthCheck)
|
||||
// 传入 nil 依赖,模拟服务不可用场景
|
||||
router.GET("/health", NewHealthCheck(nil, nil))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -22,6 +23,3 @@ func TestHealthCheck_Degraded(t *testing.T) {
|
||||
t.Fatalf("expected 503 when dependencies missing, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"carrotskin/internal/container"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/types"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
243
internal/handler/yggdrasil_friends_handler.go
Normal file
243
internal/handler/yggdrasil_friends_handler.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
apperrors "carrotskin/internal/errors"
|
||||
"carrotskin/internal/container"
|
||||
"carrotskin/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// YggdrasilFriendsHandler MinecraftServices 好友系接口处理器(文档 1.1~1.3、1.6)
|
||||
type YggdrasilFriendsHandler struct {
|
||||
container *container.Container
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewYggdrasilFriendsHandler 创建 YggdrasilFriendsHandler 实例
|
||||
func NewYggdrasilFriendsHandler(c *container.Container) *YggdrasilFriendsHandler {
|
||||
return &YggdrasilFriendsHandler{
|
||||
container: c,
|
||||
logger: c.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
// extractProfileUUID 解析 Bearer token 得到当前请求者的 Profile UUID
|
||||
// 失败时已写入 401 响应
|
||||
func (h *YggdrasilFriendsHandler) extractProfileUUID(c *gin.Context) (string, bool) {
|
||||
token, ok := extractBearerToken(c)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
uuid, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), token)
|
||||
if err != nil || uuid == "" {
|
||||
h.logger.Warn("好友接口鉴权失败: 无效令牌", zap.Error(err))
|
||||
c.JSON(http.StatusUnauthorized, apperrors.NewMinecraftServicesErrorResponse(
|
||||
c.Request.URL.Path, "UNAUTHORIZED", "Invalid token", nil,
|
||||
))
|
||||
return "", false
|
||||
}
|
||||
return uuid, true
|
||||
}
|
||||
|
||||
// respondFriendsError 将 service 错误映射为 MinecraftServices 标准错误响应
|
||||
func (h *YggdrasilFriendsHandler) respondFriendsError(c *gin.Context, err error) {
|
||||
var appErr *apperrors.AppError
|
||||
if errors.As(err, &appErr) {
|
||||
// 未知 profile / 不能加自己 -> 400
|
||||
if appErr.Code == 400 {
|
||||
c.JSON(http.StatusBadRequest, apperrors.NewMinecraftServicesErrorResponse(
|
||||
c.Request.URL.Path, appErr.Message, appErr.Message, nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
if appErr.Code == 403 {
|
||||
c.JSON(http.StatusForbidden, apperrors.NewMinecraftServicesErrorResponse(
|
||||
c.Request.URL.Path, "FORBIDDEN", appErr.Message, nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
}
|
||||
h.logger.Error("好友接口内部错误", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, apperrors.NewMinecraftServicesErrorResponse(
|
||||
c.Request.URL.Path, "SERVICE_NOT_AVAILABLE", "Internal server error", nil,
|
||||
))
|
||||
}
|
||||
|
||||
// GetFriends 获取好友列表
|
||||
// @Summary MinecraftServices 好友列表
|
||||
// @Description 拉取好友列表与收到/发出的好友请求(文档 1.3.1)
|
||||
// @Tags Yggdrasil
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer {accessToken}"
|
||||
// @Success 200 {object} service.FriendsListResponse
|
||||
// @Failure 401 {object} apperrors.MinecraftServicesErrorResponse "未授权"
|
||||
// @Failure 500 {object} apperrors.MinecraftServicesErrorResponse "服务器错误"
|
||||
// @Router /api/yggdrasil/minecraftservices/friends [get]
|
||||
func (h *YggdrasilFriendsHandler) GetFriends(c *gin.Context) {
|
||||
uuid, ok := h.extractProfileUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
resp, err := h.container.FriendsService.ListFriends(c.Request.Context(), uuid)
|
||||
if err != nil {
|
||||
h.respondFriendsError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// UpdateFriends 添加/删除好友、接受/拒绝/撤回请求
|
||||
// @Summary MinecraftServices 好友操作
|
||||
// @Description 通过 updateType=ADD/REMOVE 进行好友加删、接受/拒绝/撤回请求(文档 1.3.2)
|
||||
// @Tags Yggdrasil
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer {accessToken}"
|
||||
// @Param request body service.FriendActionRequest true "好友操作请求"
|
||||
// @Success 200 {object} service.FriendsListResponse
|
||||
// @Failure 400 {object} apperrors.MinecraftServicesErrorResponse "参数错误"
|
||||
// @Failure 401 {object} apperrors.MinecraftServicesErrorResponse "未授权"
|
||||
// @Router /api/yggdrasil/minecraftservices/friends [put]
|
||||
func (h *YggdrasilFriendsHandler) UpdateFriends(c *gin.Context) {
|
||||
uuid, ok := h.extractProfileUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req service.FriendActionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, apperrors.NewMinecraftServicesErrorResponse(
|
||||
c.Request.URL.Path, "UNKNOWN_PROFILE", "Invalid request body", nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
resp, err := h.container.FriendsService.PerformFriendAction(c.Request.Context(), uuid, req)
|
||||
if err != nil {
|
||||
h.respondFriendsError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetAttributes 获取玩家属性
|
||||
// @Summary MinecraftServices 玩家属性
|
||||
// @Description 拉取好友/脏词过滤/聊天偏好属性(文档 1.2.1)
|
||||
// @Tags Yggdrasil
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer {accessToken}"
|
||||
// @Success 200 {object} service.PlayerAttributesResponse
|
||||
// @Failure 401 {object} apperrors.MinecraftServicesErrorResponse "未授权"
|
||||
// @Router /api/yggdrasil/minecraftservices/player/attributes [get]
|
||||
func (h *YggdrasilFriendsHandler) GetAttributes(c *gin.Context) {
|
||||
uuid, ok := h.extractProfileUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
resp, err := h.container.FriendsService.GetAttributes(c.Request.Context(), uuid)
|
||||
if err != nil {
|
||||
h.respondFriendsError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// UpdateAttributes 更新玩家偏好属性
|
||||
// @Summary MinecraftServices 更新玩家属性
|
||||
// @Description 更新好友/脏词过滤偏好(文档 1.2.2)
|
||||
// @Tags Yggdrasil
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer {accessToken}"
|
||||
// @Param request body service.PlayerAttributesRequest true "偏好更新请求"
|
||||
// @Success 200 {object} service.PlayerAttributesResponse
|
||||
// @Failure 400 {object} apperrors.MinecraftServicesErrorResponse "参数错误"
|
||||
// @Failure 401 {object} apperrors.MinecraftServicesErrorResponse "未授权"
|
||||
// @Router /api/yggdrasil/minecraftservices/player/attributes [post]
|
||||
func (h *YggdrasilFriendsHandler) UpdateAttributes(c *gin.Context) {
|
||||
uuid, ok := h.extractProfileUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req service.PlayerAttributesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, apperrors.NewMinecraftServicesErrorResponse(
|
||||
c.Request.URL.Path, "UNKNOWN_PROFILE", "Invalid request body", nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
if err := h.container.FriendsService.UpdateAttributes(c.Request.Context(), uuid, req); err != nil {
|
||||
h.respondFriendsError(c, err)
|
||||
return
|
||||
}
|
||||
// 返回最新属性
|
||||
resp, err := h.container.FriendsService.GetAttributes(c.Request.Context(), uuid)
|
||||
if err != nil {
|
||||
h.respondFriendsError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// UpdatePresence 上报在线状态
|
||||
// @Summary MinecraftServices 在线状态
|
||||
// @Description 上报当前在线状态并返回好友在线状态列表(文档 1.1)
|
||||
// @Tags Yggdrasil
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer {accessToken}"
|
||||
// @Param request body service.PresenceRequest true "状态上报请求"
|
||||
// @Success 200 {object} service.PresenceResponse
|
||||
// @Failure 400 {object} apperrors.MinecraftServicesErrorResponse "参数错误"
|
||||
// @Failure 401 {object} apperrors.MinecraftServicesErrorResponse "未授权"
|
||||
// @Router /api/yggdrasil/minecraftservices/presence [post]
|
||||
func (h *YggdrasilFriendsHandler) UpdatePresence(c *gin.Context) {
|
||||
uuid, ok := h.extractProfileUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req service.PresenceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, apperrors.NewMinecraftServicesErrorResponse(
|
||||
c.Request.URL.Path, "INVALID_STATUS", "Invalid request body", nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
if req.Status == "" {
|
||||
c.JSON(http.StatusBadRequest, apperrors.NewMinecraftServicesErrorResponse(
|
||||
c.Request.URL.Path, "INVALID_STATUS", "Bad status value", nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
resp, err := h.container.FriendsService.UpdatePresence(c.Request.Context(), uuid, req.Status)
|
||||
if err != nil {
|
||||
h.respondFriendsError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetBlocklist 获取玩家屏蔽列表
|
||||
// @Summary MinecraftServices 屏蔽列表
|
||||
// @Description 拉取被屏蔽玩家档案 UUID 列表(文档 1.6)
|
||||
// @Tags Yggdrasil
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer {accessToken}"
|
||||
// @Success 200 {object} service.BlockListResponse
|
||||
// @Failure 401 {object} apperrors.MinecraftServicesErrorResponse "未授权"
|
||||
// @Router /api/yggdrasil/minecraftservices/privacy/blocklist [get]
|
||||
func (h *YggdrasilFriendsHandler) GetBlocklist(c *gin.Context) {
|
||||
uuid, ok := h.extractProfileUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
resp, err := h.container.FriendsService.GetBlocklist(c.Request.Context(), uuid)
|
||||
if err != nil {
|
||||
h.respondFriendsError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
44
internal/handler/yggdrasil_friends_handler_test.go
Normal file
44
internal/handler/yggdrasil_friends_handler_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestExtractBearerToken 校验 Bearer token 解析
|
||||
func TestExtractBearerToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
auth string
|
||||
wantToken string
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "缺失 header", auth: "", wantOK: false},
|
||||
{name: "错误前缀", auth: "Token abc", wantOK: false},
|
||||
{name: "Bearer 空", auth: "Bearer ", wantOK: false},
|
||||
{name: "有效 Bearer", auth: "Bearer mytoken123", wantToken: "mytoken123", wantOK: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/friends", nil)
|
||||
if tt.auth != "" {
|
||||
c.Request.Header.Set("Authorization", tt.auth)
|
||||
}
|
||||
got, ok := extractBearerToken(c)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
if ok && got != tt.wantToken {
|
||||
t.Errorf("token = %q, want %q", got, tt.wantToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"carrotskin/internal/container"
|
||||
"carrotskin/internal/errors"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/pkg/utils"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
@@ -16,66 +15,18 @@ import (
|
||||
|
||||
// 常量定义
|
||||
const (
|
||||
ErrInternalServer = "服务器内部错误"
|
||||
// 错误类型
|
||||
ErrInvalidEmailFormat = "邮箱格式不正确"
|
||||
ErrInvalidPassword = "密码必须至少包含8个字符,只能包含字母、数字和特殊字符"
|
||||
ErrWrongPassword = "密码错误"
|
||||
ErrUserNotMatch = "用户不匹配"
|
||||
|
||||
// 错误消息
|
||||
ErrInvalidRequest = "请求格式无效"
|
||||
ErrJoinServerFailed = "加入服务器失败"
|
||||
ErrServerIDRequired = "服务器ID不能为空"
|
||||
ErrUsernameRequired = "用户名不能为空"
|
||||
ErrSessionVerifyFailed = "会话验证失败"
|
||||
ErrProfileNotFound = "未找到用户配置文件"
|
||||
ErrInvalidParams = "无效的请求参数"
|
||||
ErrEmptyUserID = "用户ID为空"
|
||||
ErrUnauthorized = "无权操作此配置文件"
|
||||
ErrGetProfileService = "获取配置文件服务失败"
|
||||
|
||||
// 成功信息
|
||||
SuccessProfileCreated = "创建成功"
|
||||
MsgRegisterSuccess = "注册成功"
|
||||
|
||||
// 错误消息
|
||||
ErrGetProfile = "获取配置文件失败"
|
||||
ErrGetTextureService = "获取材质服务失败"
|
||||
ErrInvalidContentType = "无效的请求内容类型"
|
||||
ErrParseMultipartForm = "解析多部分表单失败"
|
||||
ErrGetFileFromForm = "从表单获取文件失败"
|
||||
ErrInvalidFileType = "无效的文件类型,仅支持PNG图片"
|
||||
ErrSaveTexture = "保存材质失败"
|
||||
ErrSetTexture = "设置材质失败"
|
||||
ErrGetTexture = "获取材质失败"
|
||||
|
||||
// 内存限制
|
||||
MaxMultipartMemory = 32 << 20 // 32 MB
|
||||
|
||||
// 材质类型
|
||||
TextureTypeSkin = "SKIN"
|
||||
TextureTypeCape = "CAPE"
|
||||
|
||||
// 内容类型
|
||||
ContentTypePNG = "image/png"
|
||||
ContentTypeMultipart = "multipart/form-data"
|
||||
|
||||
// 表单参数
|
||||
FormKeyModel = "model"
|
||||
FormKeyFile = "file"
|
||||
|
||||
// 元数据键
|
||||
MetaKeyModel = "model"
|
||||
)
|
||||
|
||||
// 正则表达式
|
||||
var (
|
||||
// 邮箱正则表达式
|
||||
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
// 密码强度正则表达式(最少8位,只允许字母、数字和特定特殊字符)
|
||||
passwordRegex = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*]{8,}$`)
|
||||
)
|
||||
|
||||
// 请求结构体
|
||||
@@ -138,22 +89,6 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
// APIResponse API响应
|
||||
type APIResponse struct {
|
||||
Status int `json:"status"`
|
||||
Data interface{} `json:"data"`
|
||||
Error interface{} `json:"error"`
|
||||
}
|
||||
|
||||
// standardResponse 生成标准响应
|
||||
func standardResponse(c *gin.Context, status int, data interface{}, err interface{}) {
|
||||
c.JSON(status, APIResponse{
|
||||
Status: status,
|
||||
Data: data,
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
|
||||
// YggdrasilHandler Yggdrasil API处理器
|
||||
type YggdrasilHandler struct {
|
||||
container *container.Container
|
||||
@@ -179,16 +114,8 @@ func NewYggdrasilHandler(c *container.Container) *YggdrasilHandler {
|
||||
// @Failure 403 {object} map[string]string "认证失败"
|
||||
// @Router /api/yggdrasil/authserver/authenticate [post]
|
||||
func (h *YggdrasilHandler) Authenticate(c *gin.Context) {
|
||||
rawData, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
h.logger.Error("读取请求体失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"})
|
||||
return
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(rawData))
|
||||
|
||||
var request AuthenticateRequest
|
||||
if err = c.ShouldBindJSON(&request); err != nil {
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
h.logger.Error("解析认证请求失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -197,11 +124,13 @@ func (h *YggdrasilHandler) Authenticate(c *gin.Context) {
|
||||
var userId int64
|
||||
var profile *model.Profile
|
||||
var UUID string
|
||||
var err error
|
||||
|
||||
if emailRegex.MatchString(request.Identifier) {
|
||||
// 邮箱登录:UUID 留空,后续 TokenService.Create 会自动选择该用户的角色
|
||||
userId, err = h.container.YggdrasilService.GetUserIDByEmail(c.Request.Context(), request.Identifier)
|
||||
} else {
|
||||
profile, err = h.container.ProfileRepo.FindByName(c.Request.Context(), request.Identifier)
|
||||
profile, err = h.container.ProfileService.GetByProfileName(c.Request.Context(), request.Identifier)
|
||||
if err != nil {
|
||||
h.logger.Error("用户名不存在", zap.String("identifier", request.Identifier), zap.Error(err))
|
||||
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
|
||||
@@ -506,7 +435,7 @@ func (h *YggdrasilHandler) SignOut(c *gin.Context) {
|
||||
|
||||
if !emailRegex.MatchString(request.Email) {
|
||||
h.logger.Warn("登出失败: 邮箱格式不正确", zap.String("email", request.Email))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": ErrInvalidEmailFormat})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -578,8 +507,8 @@ func (h *YggdrasilHandler) GetProfileByUUID(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Param request body JoinServerRequest true "加入请求"
|
||||
// @Success 204 "加入成功"
|
||||
// @Failure 400 {object} APIResponse "参数错误"
|
||||
// @Failure 500 {object} APIResponse "服务器错误"
|
||||
// @Failure 400 {object} map[string]string "参数错误"
|
||||
// @Failure 500 {object} map[string]string "服务器错误"
|
||||
// @Router /api/yggdrasil/sessionserver/session/minecraft/join [post]
|
||||
func (h *YggdrasilHandler) JoinServer(c *gin.Context) {
|
||||
var request JoinServerRequest
|
||||
@@ -642,14 +571,14 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
|
||||
serverID, exists := c.GetQuery("serverId")
|
||||
if !exists || serverID == "" {
|
||||
h.logger.Warn("缺少服务器ID参数", zap.String("ip", clientIP))
|
||||
standardResponse(c, http.StatusNoContent, nil, ErrServerIDRequired)
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
username, exists := c.GetQuery("username")
|
||||
if !exists || username == "" {
|
||||
h.logger.Warn("缺少用户名参数", zap.String("serverId", serverID), zap.String("ip", clientIP))
|
||||
standardResponse(c, http.StatusNoContent, nil, ErrUsernameRequired)
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -666,14 +595,14 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
|
||||
zap.String("username", username),
|
||||
zap.String("ip", clientIP),
|
||||
)
|
||||
standardResponse(c, http.StatusNoContent, nil, ErrSessionVerifyFailed)
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
profile, err := h.container.ProfileService.GetByProfileName(c.Request.Context(), username)
|
||||
if err != nil {
|
||||
h.logger.Error("获取用户配置文件失败", zap.Error(err), zap.String("username", username))
|
||||
standardResponse(c, http.StatusNoContent, nil, ErrProfileNotFound)
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -682,37 +611,110 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
|
||||
zap.String("username", username),
|
||||
zap.String("uuid", profile.UUID),
|
||||
)
|
||||
c.JSON(200, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile))
|
||||
c.JSON(http.StatusOK, h.container.YggdrasilService.SerializeProfile(c.Request.Context(), *profile))
|
||||
}
|
||||
|
||||
// GetProfilesByName 批量获取配置文件
|
||||
// @Summary Yggdrasil批量获取档案
|
||||
// @Description Yggdrasil协议: 根据名称批量获取用户档案
|
||||
// @Description Yggdrasil协议: 根据名称批量获取用户档案(文档 2.1,每页最多 10 个,返回 [{id,name}])
|
||||
// @Tags Yggdrasil
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body []string true "用户名列表"
|
||||
// @Success 200 {array} model.Profile "档案列表"
|
||||
// @Failure 400 {object} APIResponse "参数错误"
|
||||
// @Success 200 {array} model.NameAndId "档案标识列表"
|
||||
// @Failure 400 {object} map[string]string "参数错误"
|
||||
// @Router /api/yggdrasil/api/profiles/minecraft [post]
|
||||
func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) {
|
||||
var names []string
|
||||
|
||||
if err := c.ShouldBindJSON(&names); err != nil {
|
||||
h.logger.Error("解析名称数组请求失败", zap.Error(err))
|
||||
standardResponse(c, http.StatusBadRequest, nil, ErrInvalidParams)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("接收到批量获取配置文件请求", zap.Int("count", len(names)))
|
||||
|
||||
profiles, err := h.container.ProfileService.GetByNames(c.Request.Context(), names)
|
||||
// 文档 2.1:每页最多 10 个用户名
|
||||
const maxBatch = 10
|
||||
results, err := h.container.ProfileService.ProfileSearchByName(c.Request.Context(), names, maxBatch)
|
||||
if err != nil {
|
||||
h.logger.Error("获取配置文件失败", zap.Error(err))
|
||||
h.logger.Warn("批量获取配置文件失败", zap.Error(err), zap.Int("count", len(names)))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("成功获取配置文件", zap.Int("requested", len(names)), zap.Int("returned", len(profiles)))
|
||||
c.JSON(http.StatusOK, profiles)
|
||||
h.logger.Info("成功获取配置文件", zap.Int("requested", len(names)), zap.Int("returned", len(results)))
|
||||
c.JSON(http.StatusOK, results)
|
||||
}
|
||||
|
||||
// GetProfileByName 单个用户名查档案
|
||||
// @Summary Yggdrasil按用户名查档案
|
||||
// @Description Yggdrasil协议: 根据单个用户名查询用户档案(文档 2.2,返回 {id,name})
|
||||
// @Tags Yggdrasil
|
||||
// @Produce json
|
||||
// @Param name path string true "用户名"
|
||||
// @Success 200 {object} model.NameAndId "档案标识"
|
||||
// @Failure 404 {object} map[string]string "档案不存在"
|
||||
// @Router /api/yggdrasil/api/users/profiles/minecraft/{name} [get]
|
||||
func (h *YggdrasilHandler) GetProfileByName(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if name == "" {
|
||||
h.logger.Warn("缺少用户名参数")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.container.ProfileService.ProfileSearchByNameSingle(c.Request.Context(), name)
|
||||
if err != nil {
|
||||
// 按文档契约:任意错误视为未找到
|
||||
h.logger.Warn("按用户名查档失败", zap.String("name", name), zap.Error(err))
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
h.logger.Info("用户名未找到", zap.String("name", name))
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("成功获取档案", zap.String("name", name), zap.String("uuid", result.ID))
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// LookupProfileByName 单用户名查 UUID(authlib-injector issue #277 / MC 1.21.7)
|
||||
// @Summary MinecraftServices 按用户名查 UUID
|
||||
// @Description 对应官方 api.minecraftservices.com/minecraft/profile/lookup/name/{name},
|
||||
// @Description 1.21.7 的 /whitelist add 等命令会请求该接口。authlib-injector 把
|
||||
// @Description api.minecraftservices.com override 到 {apiRoot}/minecraftservices。
|
||||
// @Description 返回 {id,name},未找到返回 404。
|
||||
// @Tags Yggdrasil
|
||||
// @Produce json
|
||||
// @Param name path string true "用户名"
|
||||
// @Success 200 {object} model.NameAndId "档案标识"
|
||||
// @Failure 404 {object} map[string]string "档案不存在"
|
||||
// @Router /api/yggdrasil/minecraftservices/minecraft/profile/lookup/name/{name} [get]
|
||||
func (h *YggdrasilHandler) LookupProfileByName(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if name == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.container.ProfileService.ProfileSearchByNameSingle(c.Request.Context(), name)
|
||||
if err != nil {
|
||||
h.logger.Warn("按用户名查档失败", zap.String("name", name), zap.Error(err))
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
h.logger.Info("用户名未找到", zap.String("name", name))
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("成功获取档案", zap.String("name", name), zap.String("uuid", result.ID))
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetMetaData 获取Yggdrasil元数据
|
||||
@@ -722,7 +724,7 @@ func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{} "元数据"
|
||||
// @Failure 500 {object} APIResponse "服务器错误"
|
||||
// @Failure 500 {object} map[string]string "服务器错误"
|
||||
// @Router /api/yggdrasil [get]
|
||||
func (h *YggdrasilHandler) GetMetaData(c *gin.Context) {
|
||||
meta := gin.H{
|
||||
@@ -764,7 +766,7 @@ func (h *YggdrasilHandler) GetMetaData(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer {token}"
|
||||
// @Success 200 {object} map[string]interface{} "证书信息"
|
||||
// @Success 200 {object} service.PlayerCertificate "证书信息"
|
||||
// @Failure 401 {object} errors.YggdrasilErrorResponse "未授权"
|
||||
// @Failure 403 {object} errors.YggdrasilErrorResponse "令牌无效"
|
||||
// @Failure 500 {object} errors.YggdrasilErrorResponse "服务器错误"
|
||||
@@ -804,10 +806,10 @@ func (h *YggdrasilHandler) GetPlayerCertificates(c *gin.Context) {
|
||||
}
|
||||
|
||||
uuid, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), tokenID)
|
||||
if uuid == "" {
|
||||
if err != nil || uuid == "" {
|
||||
h.logger.Error("获取玩家UUID失败", zap.Error(err))
|
||||
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
|
||||
"ForbiddenOperationException",
|
||||
c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse(
|
||||
"Unauthorized",
|
||||
errors.YggErrInvalidToken,
|
||||
"",
|
||||
))
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"carrotskin/internal/model"
|
||||
|
||||
"carrotskin/pkg/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -3,6 +3,7 @@ package middleware
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/pkg/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -15,10 +16,11 @@ func CasbinMiddleware(casbinService *auth.CasbinService, resource, action string
|
||||
// 从上下文获取用户角色(由AuthMiddleware设置)
|
||||
role, exists := c.Get("user_role")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "未授权访问",
|
||||
})
|
||||
c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
model.MsgUnauthorized,
|
||||
nil,
|
||||
))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
@@ -30,10 +32,11 @@ func CasbinMiddleware(casbinService *auth.CasbinService, resource, action string
|
||||
|
||||
// 检查权限
|
||||
if !casbinService.CheckPermission(roleStr, resource, action) {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "权限不足",
|
||||
})
|
||||
c.JSON(http.StatusForbidden, model.NewErrorResponse(
|
||||
model.CodeForbidden,
|
||||
model.MsgForbidden,
|
||||
nil,
|
||||
))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
@@ -47,20 +50,22 @@ 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.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
model.MsgUnauthorized,
|
||||
nil,
|
||||
))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
roleStr, ok := role.(string)
|
||||
if !ok || roleStr != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "需要管理员权限",
|
||||
})
|
||||
c.JSON(http.StatusForbidden, model.NewErrorResponse(
|
||||
model.CodeForbidden,
|
||||
"需要管理员权限",
|
||||
nil,
|
||||
))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
@@ -74,20 +79,22 @@ 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.JSON(http.StatusUnauthorized, model.NewErrorResponse(
|
||||
model.CodeUnauthorized,
|
||||
model.MsgUnauthorized,
|
||||
nil,
|
||||
))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
roleStr, ok := role.(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "权限不足",
|
||||
})
|
||||
c.JSON(http.StatusForbidden, model.NewErrorResponse(
|
||||
model.CodeForbidden,
|
||||
model.MsgForbidden,
|
||||
nil,
|
||||
))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
@@ -100,10 +107,11 @@ func RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "权限不足",
|
||||
})
|
||||
c.JSON(http.StatusForbidden, model.NewErrorResponse(
|
||||
model.CodeForbidden,
|
||||
model.MsgForbidden,
|
||||
nil,
|
||||
))
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,14 @@ import (
|
||||
)
|
||||
|
||||
// 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 {
|
||||
// 默认允许所有来源(向后兼容)
|
||||
func CORS(cfg *config.Config) gin.HandlerFunc {
|
||||
// 从注入的配置读取 CORS 设置
|
||||
allowedOrigins := cfg.Security.AllowedOrigins
|
||||
if len(allowedOrigins) == 0 {
|
||||
// 默认允许所有来源
|
||||
allowedOrigins = []string{"*"}
|
||||
isTestEnv = false
|
||||
}
|
||||
isTestEnv := cfg.IsTestEnvironment()
|
||||
|
||||
return gin.HandlerFunc(func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
|
||||
@@ -5,15 +5,26 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"carrotskin/pkg/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// testCORSConfig 返回测试用的 CORS 配置(通配符模式)
|
||||
func testCORSConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Security: config.SecurityConfig{
|
||||
AllowedOrigins: []string{"*"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORS_Headers 测试CORS中间件设置的响应头
|
||||
func TestCORS_Headers(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(CORS())
|
||||
router.Use(CORS(testCORSConfig()))
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success"})
|
||||
})
|
||||
@@ -55,7 +66,7 @@ func TestCORS_OPTIONS(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(CORS())
|
||||
router.Use(CORS(testCORSConfig()))
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success"})
|
||||
})
|
||||
@@ -76,7 +87,7 @@ func TestCORS_AllowMethods(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(CORS())
|
||||
router.Use(CORS(testCORSConfig()))
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success"})
|
||||
})
|
||||
@@ -103,7 +114,7 @@ func TestCORS_AllowHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(CORS())
|
||||
router.Use(CORS(testCORSConfig()))
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success"})
|
||||
})
|
||||
@@ -130,7 +141,7 @@ func TestCORS_WithSpecificOrigin(t *testing.T) {
|
||||
// 注意:此测试验证的是在配置了具体allowed origins时的行为
|
||||
// 在没有配置初始化的情况下,默认使用通配符模式
|
||||
router := gin.New()
|
||||
router.Use(CORS())
|
||||
router.Use(CORS(testCORSConfig()))
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success"})
|
||||
})
|
||||
|
||||
64
internal/model/friends.go
Normal file
64
internal/model/friends.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// FriendsStatus 好友关系状态
|
||||
// @Description 好友关系状态枚举
|
||||
type FriendsStatus string
|
||||
|
||||
const (
|
||||
// FriendsStatusPending 待处理(已发出请求,对方尚未接受)
|
||||
FriendsStatusPending FriendsStatus = "pending"
|
||||
// FriendsStatusAccepted 已互相成为好友
|
||||
FriendsStatusAccepted FriendsStatus = "accepted"
|
||||
// FriendsStatusBlocked 屏蔽(requester 屏蔽 target,单向)
|
||||
FriendsStatusBlocked FriendsStatus = "blocked"
|
||||
)
|
||||
|
||||
// Friend 好友/屏蔽关系记录
|
||||
// @Description 玩家好友与屏蔽关系数据模型
|
||||
//
|
||||
// 设计说明:
|
||||
// - 采用状态枚举+单条记录模型,区分 pending/accepted/blocked。
|
||||
// - 屏蔽(blocked)复用本表,requester 为屏蔽发起者,target 为被屏蔽者。
|
||||
// - 好友关系在 accepted 状态下,仍以 requester->target 单条记录表示;
|
||||
// 当 B 接受 A 的请求时,若不存在 B->A 记录则补建一条 accepted 记录,
|
||||
// 从而双方在 ListFriends 中都能看到对方。
|
||||
type Friend struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
RequesterUUID string `gorm:"column:requester_uuid;type:varchar(32);not null;uniqueIndex:uk_friend_pair,priority:1;index:idx_friend_requester_status,priority:1" json:"requester_uuid"`
|
||||
TargetUUID string `gorm:"column:target_uuid;type:varchar(32);not null;uniqueIndex:uk_friend_pair,priority:2;index:idx_friend_target_status,priority:1" json:"target_uuid"`
|
||||
Status FriendsStatus `gorm:"column:status;type:varchar(16);not null;default:'pending';index:idx_friend_requester_status,priority:2;index:idx_friend_target_status,priority:2" json:"status"`
|
||||
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 (Friend) TableName() string { return "friends" }
|
||||
|
||||
// PlayerAttributes 玩家好友/聊天偏好属性
|
||||
// @Description 玩家偏好属性数据模型(对应文档 friendsPreferences / profanityFilterPreferences / chatPreferences)
|
||||
type PlayerAttributes struct {
|
||||
ProfileUUID string `gorm:"column:profile_uuid;type:varchar(32);primaryKey" json:"profile_uuid"`
|
||||
FriendsEnabled bool `gorm:"column:friends_enabled;not null;default:true" json:"friends_enabled"`
|
||||
AcceptInvites bool `gorm:"column:accept_invites;not null;default:true" json:"accept_invites"`
|
||||
ProfanityFilterOn bool `gorm:"column:profanity_filter_on;not null;default:true" json:"profanity_filter_on"`
|
||||
// TextCommunication 聊天文本通信偏好:ENABLED / FRIENDS_ONLY / DISABLED
|
||||
TextCommunication string `gorm:"column:text_communication;type:varchar(16);not null;default:'ENABLED'" json:"text_communication"`
|
||||
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 (PlayerAttributes) TableName() string { return "player_attributes" }
|
||||
|
||||
// DefaultPlayerAttributes 返回某 profile 的默认属性(数据库无记录时使用)
|
||||
func DefaultPlayerAttributes(profileUUID string) PlayerAttributes {
|
||||
return PlayerAttributes{
|
||||
ProfileUUID: profileUUID,
|
||||
FriendsEnabled: true,
|
||||
AcceptInvites: true,
|
||||
ProfanityFilterOn: true,
|
||||
TextCommunication: "ENABLED",
|
||||
}
|
||||
}
|
||||
49
internal/model/friends_test.go
Normal file
49
internal/model/friends_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFriend_TableName 校验 Friend 表名
|
||||
func TestFriend_TableName(t *testing.T) {
|
||||
if got := (Friend{}).TableName(); got != "friends" {
|
||||
t.Errorf("Friend.TableName() = %q, want %q", got, "friends")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayerAttributes_TableName 校验 PlayerAttributes 表名
|
||||
func TestPlayerAttributes_TableName(t *testing.T) {
|
||||
if got := (PlayerAttributes{}).TableName(); got != "player_attributes" {
|
||||
t.Errorf("PlayerAttributes.TableName() = %q, want %q", got, "player_attributes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFriendsStatus_Values 校验好友状态枚举值
|
||||
func TestFriendsStatus_Values(t *testing.T) {
|
||||
tests := []struct {
|
||||
got, want FriendsStatus
|
||||
}{
|
||||
{FriendsStatusPending, FriendsStatus("pending")},
|
||||
{FriendsStatusAccepted, FriendsStatus("accepted")},
|
||||
{FriendsStatusBlocked, FriendsStatus("blocked")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("got %q, want %q", tt.got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultPlayerAttributes 校验默认偏好
|
||||
func TestDefaultPlayerAttributes(t *testing.T) {
|
||||
d := DefaultPlayerAttributes("abc")
|
||||
if d.ProfileUUID != "abc" {
|
||||
t.Errorf("ProfileUUID = %q, want %q", d.ProfileUUID, "abc")
|
||||
}
|
||||
if !d.FriendsEnabled || !d.AcceptInvites || !d.ProfanityFilterOn {
|
||||
t.Errorf("默认偏好应为全部启用: %+v", d)
|
||||
}
|
||||
if d.TextCommunication != "ENABLED" {
|
||||
t.Errorf("TextCommunication = %q, want ENABLED", d.TextCommunication)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,14 @@ type Profile struct {
|
||||
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 私钥不返回给前端
|
||||
// RSA 私钥不返回给前端
|
||||
RSAPrivateKey string `gorm:"column:rsa_private_key;type:text;not null" json:"-"`
|
||||
// 玩家证书完整密钥对(持久化以避免每次请求都重新生成 RSA 4096 密钥)
|
||||
RSAPublicKey string `gorm:"column:rsa_public_key;type:text" json:"-"`
|
||||
PublicKeySignature string `gorm:"column:public_key_signature;type:text" json:"-"`
|
||||
PublicKeySignatureV2 string `gorm:"column:public_key_signature_v2;type:text" json:"-"`
|
||||
KeyExpiresAt *time.Time `gorm:"column:key_expires_at;type:timestamp" json:"-"`
|
||||
KeyRefreshAt *time.Time `gorm:"column:key_refresh_at;type:timestamp" json:"-"`
|
||||
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"`
|
||||
@@ -69,3 +76,14 @@ type KeyPair struct {
|
||||
Expiration time.Time `json:"expiration" bson:"expiration"`
|
||||
Refresh time.Time `json:"refresh" bson:"refresh"`
|
||||
}
|
||||
|
||||
// NameAndId 简化的档案标识响应(文档 2.1 / 2.2 ProfileSearchResultsResponse / NameAndId)
|
||||
// @Description 仅包含档案 UUID 与用户名的最小响应结构
|
||||
//
|
||||
// 用于 profiles.getManyByName / profiles.getByName 接口:
|
||||
// - id:档案 UUID(32 位无连字符十六进制)
|
||||
// - name:用户名(保持存储的大小写)
|
||||
type NameAndId struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"context"
|
||||
|
||||
"carrotskin/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
|
||||
212
internal/repository/friends_repository.go
Normal file
212
internal/repository/friends_repository.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"carrotskin/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FriendRepository 好友/屏蔽关系仓储接口
|
||||
type FriendRepository interface {
|
||||
// Create 创建好友/屏蔽关系记录
|
||||
Create(ctx context.Context, friend *model.Friend) error
|
||||
// FindRelation 查询 requester->target 方向的关系记录
|
||||
FindRelation(ctx context.Context, requesterUUID, targetUUID string) (*model.Friend, error)
|
||||
// ListAccepted 返回与 uuid 已互为好友的对端 UUID 集合
|
||||
// (合并 requester 与 target 两个方向 status=accepted 的记录)
|
||||
ListAccepted(ctx context.Context, uuid string) ([]string, error)
|
||||
// ListIncoming 返回收到的 pending 请求发起者 UUID 列表
|
||||
ListIncoming(ctx context.Context, uuid string) ([]string, error)
|
||||
// ListOutgoing 返回已发出的 pending 请求目标 UUID 列表
|
||||
ListOutgoing(ctx context.Context, uuid string) ([]string, error)
|
||||
// UpdateStatus 更新某条记录的状态
|
||||
UpdateStatus(ctx context.Context, id int64, status model.FriendsStatus) error
|
||||
// Delete 删除记录(物理删除,用于 remove/decline/revoke/unblock)
|
||||
Delete(ctx context.Context, id int64) error
|
||||
// ListBlocked 返回 blocker 屏蔽的 target UUID 列表
|
||||
ListBlocked(ctx context.Context, blockerUUID string) ([]string, error)
|
||||
}
|
||||
|
||||
// PlayerAttributeRepository 玩家偏好属性仓储接口
|
||||
type PlayerAttributeRepository interface {
|
||||
// Get 读取玩家偏好;不存在时返回 model.DefaultPlayerAttributes 的零值结构,错误为 nil
|
||||
Get(ctx context.Context, profileUUID string) (*model.PlayerAttributes, error)
|
||||
// Upsert 插入或更新玩家偏好
|
||||
Upsert(ctx context.Context, attr *model.PlayerAttributes) error
|
||||
}
|
||||
|
||||
// friendRepository FriendRepository 的 GORM 实现
|
||||
type friendRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewFriendRepository 创建 FriendRepository 实例
|
||||
func NewFriendRepository(db *gorm.DB) FriendRepository {
|
||||
return &friendRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *friendRepository) Create(ctx context.Context, friend *model.Friend) error {
|
||||
return r.db.WithContext(ctx).Create(friend).Error
|
||||
}
|
||||
|
||||
func (r *friendRepository) FindRelation(ctx context.Context, requesterUUID, targetUUID string) (*model.Friend, error) {
|
||||
var f model.Friend
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("requester_uuid = ? AND target_uuid = ?", requesterUUID, targetUUID).
|
||||
First(&f).Error
|
||||
return handleNotFoundResult(&f, err)
|
||||
}
|
||||
|
||||
func (r *friendRepository) ListAccepted(ctx context.Context, uuid string) ([]string, error) {
|
||||
var records []model.Friend
|
||||
// requester 方向
|
||||
if err := r.db.WithContext(ctx).
|
||||
Select("target_uuid").
|
||||
Where("requester_uuid = ? AND status = ?", uuid, model.FriendsStatusAccepted).
|
||||
Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]string, 0, len(records))
|
||||
for _, r := range records {
|
||||
result = append(result, r.TargetUUID)
|
||||
}
|
||||
// target 方向
|
||||
records = records[:0]
|
||||
if err := r.db.WithContext(ctx).
|
||||
Select("requester_uuid").
|
||||
Where("target_uuid = ? AND status = ?", uuid, model.FriendsStatusAccepted).
|
||||
Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range records {
|
||||
result = append(result, r.RequesterUUID)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *friendRepository) ListIncoming(ctx context.Context, uuid string) ([]string, error) {
|
||||
var records []model.Friend
|
||||
err := r.db.WithContext(ctx).
|
||||
Select("requester_uuid").
|
||||
Where("target_uuid = ? AND status = ?", uuid, model.FriendsStatusPending).
|
||||
Find(&records).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]string, 0, len(records))
|
||||
for _, r := range records {
|
||||
result = append(result, r.RequesterUUID)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *friendRepository) ListOutgoing(ctx context.Context, uuid string) ([]string, error) {
|
||||
var records []model.Friend
|
||||
err := r.db.WithContext(ctx).
|
||||
Select("target_uuid").
|
||||
Where("requester_uuid = ? AND status = ?", uuid, model.FriendsStatusPending).
|
||||
Find(&records).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]string, 0, len(records))
|
||||
for _, r := range records {
|
||||
result = append(result, r.TargetUUID)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *friendRepository) UpdateStatus(ctx context.Context, id int64, status model.FriendsStatus) error {
|
||||
return r.db.WithContext(ctx).Model(&model.Friend{}).
|
||||
Where("id = ?", id).
|
||||
Update("status", status).Error
|
||||
}
|
||||
|
||||
func (r *friendRepository) Delete(ctx context.Context, id int64) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.Friend{}, id).Error
|
||||
}
|
||||
|
||||
func (r *friendRepository) ListBlocked(ctx context.Context, blockerUUID string) ([]string, error) {
|
||||
var records []model.Friend
|
||||
err := r.db.WithContext(ctx).
|
||||
Select("target_uuid").
|
||||
Where("requester_uuid = ? AND status = ?", blockerUUID, model.FriendsStatusBlocked).
|
||||
Find(&records).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]string, 0, len(records))
|
||||
for _, r := range records {
|
||||
result = append(result, r.TargetUUID)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// playerAttributeRepository PlayerAttributeRepository 的 GORM 实现
|
||||
type playerAttributeRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewPlayerAttributeRepository 创建 PlayerAttributeRepository 实例
|
||||
func NewPlayerAttributeRepository(db *gorm.DB) PlayerAttributeRepository {
|
||||
return &playerAttributeRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *playerAttributeRepository) Get(ctx context.Context, profileUUID string) (*model.PlayerAttributes, error) {
|
||||
var attr model.PlayerAttributes
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("profile_uuid = ?", profileUUID).
|
||||
First(&attr).Error
|
||||
if err != nil {
|
||||
if IsNotFound(err) {
|
||||
// 不存在时返回默认值
|
||||
d := model.DefaultPlayerAttributes(profileUUID)
|
||||
return &d, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &attr, nil
|
||||
}
|
||||
|
||||
func (r *playerAttributeRepository) Upsert(ctx context.Context, attr *model.PlayerAttributes) error {
|
||||
now := time.Now()
|
||||
attr.UpdatedAt = now
|
||||
// 用 map 显式写出所有字段,规避 gorm 对带 default 标签的零值布尔字段在 Create 时被忽略的问题
|
||||
fields := map[string]interface{}{
|
||||
"friends_enabled": attr.FriendsEnabled,
|
||||
"accept_invites": attr.AcceptInvites,
|
||||
"profanity_filter_on": attr.ProfanityFilterOn,
|
||||
"text_communication": attr.TextCommunication,
|
||||
"updated_at": now,
|
||||
}
|
||||
// 优先 UPDATE;若影响行数为 0(记录不存在)再 INSERT
|
||||
res := r.db.WithContext(ctx).Model(&model.PlayerAttributes{}).
|
||||
Where("profile_uuid = ?", attr.ProfileUUID).
|
||||
Updates(fields)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
return nil
|
||||
}
|
||||
// 不存在 -> 插入;用 map 写入避免零值布尔被 default 覆盖
|
||||
createFields := map[string]interface{}{
|
||||
"profile_uuid": attr.ProfileUUID,
|
||||
"friends_enabled": attr.FriendsEnabled,
|
||||
"accept_invites": attr.AcceptInvites,
|
||||
"profanity_filter_on": attr.ProfanityFilterOn,
|
||||
"text_communication": attr.TextCommunication,
|
||||
"updated_at": now,
|
||||
"created_at": now,
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Model(&model.PlayerAttributes{}).Create(createFields).Error; err != nil {
|
||||
// 并发情况下可能唯一键冲突,回退为更新一次
|
||||
return r.db.WithContext(ctx).Model(&model.PlayerAttributes{}).
|
||||
Where("profile_uuid = ?", attr.ProfileUUID).
|
||||
Updates(fields).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"context"
|
||||
|
||||
"carrotskin/internal/model"
|
||||
)
|
||||
|
||||
// UserRepository 用户仓储接口
|
||||
@@ -12,6 +13,7 @@ type UserRepository interface {
|
||||
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) // 批量查询
|
||||
List(ctx context.Context, page, pageSize int) ([]*model.User, int64, 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) // 批量更新
|
||||
@@ -64,13 +66,16 @@ type TextureRepository interface {
|
||||
RemoveFavorite(ctx context.Context, userID, textureID int64) error
|
||||
GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error)
|
||||
CountByUploaderID(ctx context.Context, uploaderID int64) (int64, error)
|
||||
ListForAdmin(ctx context.Context, page, pageSize int) ([]*model.Texture, int64, error) // 管理员列表(含 Uploader 预加载)
|
||||
FindByIDForAdmin(ctx context.Context, id int64) (*model.Texture, error) // 管理员查询(无权限过滤)
|
||||
}
|
||||
|
||||
|
||||
// YggdrasilRepository Yggdrasil仓储接口
|
||||
type YggdrasilRepository interface {
|
||||
GetPasswordByID(ctx context.Context, id int64) (string, error)
|
||||
ResetPassword(ctx context.Context, id int64, password string) error
|
||||
// Create 创建Yggdrasil密码记录(用于首次设置密码)
|
||||
Create(ctx context.Context, yggdrasil *model.Yggdrasil) error
|
||||
}
|
||||
|
||||
// ClientRepository Client仓储接口
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"carrotskin/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -116,8 +117,13 @@ func (r *profileRepository) UpdateLastUsedAt(ctx context.Context, uuid string) e
|
||||
}
|
||||
|
||||
func (r *profileRepository) GetByNames(ctx context.Context, names []string) ([]*model.Profile, error) {
|
||||
if len(names) == 0 {
|
||||
return []*model.Profile{}, nil
|
||||
}
|
||||
var profiles []*model.Profile
|
||||
err := r.db.WithContext(ctx).Where("name in (?)", names).
|
||||
// 与 FindByName 保持一致:使用 LOWER() 做大小写不敏感匹配,
|
||||
// 客户端按文档会 toLowerCase 规范化用户名后发送。
|
||||
err := r.db.WithContext(ctx).Where("LOWER(name) IN (?)", names).
|
||||
Preload("Skin").
|
||||
Preload("Cape").
|
||||
Find(&profiles).Error
|
||||
@@ -131,7 +137,7 @@ func (r *profileRepository) GetKeyPair(ctx context.Context, profileId string) (*
|
||||
|
||||
var profile model.Profile
|
||||
result := r.db.WithContext(ctx).
|
||||
Select("rsa_private_key").
|
||||
Select("rsa_private_key", "rsa_public_key", "public_key_signature", "public_key_signature_v2", "key_expires_at", "key_refresh_at").
|
||||
Where("uuid = ?", profileId).
|
||||
First(&profile)
|
||||
|
||||
@@ -142,9 +148,19 @@ func (r *profileRepository) GetKeyPair(ctx context.Context, profileId string) (*
|
||||
return nil, fmt.Errorf("获取key pair失败: %w", result.Error)
|
||||
}
|
||||
|
||||
return &model.KeyPair{
|
||||
keyPair := &model.KeyPair{
|
||||
PrivateKey: profile.RSAPrivateKey,
|
||||
}, nil
|
||||
PublicKey: profile.RSAPublicKey,
|
||||
PublicKeySignature: profile.PublicKeySignature,
|
||||
PublicKeySignatureV2: profile.PublicKeySignatureV2,
|
||||
}
|
||||
if profile.KeyExpiresAt != nil {
|
||||
keyPair.Expiration = *profile.KeyExpiresAt
|
||||
}
|
||||
if profile.KeyRefreshAt != nil {
|
||||
keyPair.Refresh = *profile.KeyRefreshAt
|
||||
}
|
||||
return keyPair, nil
|
||||
}
|
||||
|
||||
func (r *profileRepository) UpdateKeyPair(ctx context.Context, profileId string, keyPair *model.KeyPair) error {
|
||||
@@ -158,7 +174,14 @@ func (r *profileRepository) UpdateKeyPair(ctx context.Context, profileId string,
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&model.Profile{}).
|
||||
Where("uuid = ?", profileId).
|
||||
Update("rsa_private_key", keyPair.PrivateKey)
|
||||
Updates(map[string]interface{}{
|
||||
"rsa_private_key": keyPair.PrivateKey,
|
||||
"rsa_public_key": keyPair.PublicKey,
|
||||
"public_key_signature": keyPair.PublicKeySignature,
|
||||
"public_key_signature_v2": keyPair.PublicKeySignatureV2,
|
||||
"key_expires_at": keyPair.Expiration,
|
||||
"key_refresh_at": keyPair.Refresh,
|
||||
})
|
||||
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("更新 keyPair 失败: %w", result.Error)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"context"
|
||||
|
||||
"carrotskin/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -210,3 +211,27 @@ func (r *textureRepository) CountByUploaderID(ctx context.Context, uploaderID in
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ListForAdmin 管理员分页查询材质列表(含 Uploader 预加载,无权限过滤)
|
||||
func (r *textureRepository) ListForAdmin(ctx context.Context, page, pageSize int) ([]*model.Texture, int64, error) {
|
||||
var textures []*model.Texture
|
||||
var total int64
|
||||
|
||||
db := r.db.WithContext(ctx).Model(&model.Texture{})
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
if err := db.Preload("Uploader").Order("id DESC").Offset(offset).Limit(pageSize).Find(&textures).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return textures, total, nil
|
||||
}
|
||||
|
||||
// FindByIDForAdmin 管理员查询材质(无权限过滤,用于管理员删除前检查存在性)
|
||||
func (r *textureRepository) FindByIDForAdmin(ctx context.Context, id int64) (*model.Texture, error) {
|
||||
var texture model.Texture
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&texture).Error
|
||||
return handleNotFoundResult(&texture, err)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"carrotskin/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -50,6 +51,23 @@ func (r *userRepository) FindByIDs(ctx context.Context, ids []int64) ([]*model.U
|
||||
return users, err
|
||||
}
|
||||
|
||||
// List 分页查询用户列表(管理员,不过滤状态)
|
||||
func (r *userRepository) List(ctx context.Context, page, pageSize int) ([]*model.User, int64, error) {
|
||||
var users []*model.User
|
||||
var total int64
|
||||
|
||||
db := r.db.WithContext(ctx).Model(&model.User{})
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
if err := db.Order("id DESC").Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) Update(ctx context.Context, user *model.User) error {
|
||||
return r.db.WithContext(ctx).Save(user).Error
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"context"
|
||||
|
||||
"carrotskin/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -30,8 +31,7 @@ func (r *yggdrasilRepository) ResetPassword(ctx context.Context, id int64, passw
|
||||
return r.db.WithContext(ctx).Model(&model.Yggdrasil{}).Where("id = ?", id).Update("password", password).Error
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Create 创建Yggdrasil密码记录
|
||||
func (r *yggdrasilRepository) Create(ctx context.Context, yggdrasil *model.Yggdrasil) error {
|
||||
return r.db.WithContext(ctx).Create(yggdrasil).Error
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"carrotskin/pkg/config"
|
||||
"carrotskin/pkg/redis"
|
||||
"carrotskin/pkg/utils"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"carrotskin/pkg/config"
|
||||
"carrotskin/pkg/redis"
|
||||
"carrotskin/pkg/utils"
|
||||
|
||||
"github.com/wenlng/go-captcha-assets/resources/imagesv2"
|
||||
"github.com/wenlng/go-captcha-assets/resources/tiles"
|
||||
"github.com/wenlng/go-captcha/v2/slide"
|
||||
@@ -72,13 +73,15 @@ type RedisData struct {
|
||||
|
||||
// captchaService CaptchaService的实现
|
||||
type captchaService struct {
|
||||
cfg *config.Config
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewCaptchaService 创建CaptchaService实例
|
||||
func NewCaptchaService(redisClient *redis.Client, logger *zap.Logger) CaptchaService {
|
||||
func NewCaptchaService(cfg *config.Config, redisClient *redis.Client, logger *zap.Logger) CaptchaService {
|
||||
return &captchaService{
|
||||
cfg: cfg,
|
||||
redis: redisClient,
|
||||
logger: logger,
|
||||
}
|
||||
@@ -157,8 +160,7 @@ func (s *captchaService) Generate(ctx context.Context) (masterImg, tileImg, capt
|
||||
// Verify 验证验证码
|
||||
func (s *captchaService) Verify(ctx context.Context, dx int, captchaID string) (bool, error) {
|
||||
// 测试环境下直接通过验证
|
||||
cfg, err := config.GetConfig()
|
||||
if err == nil && cfg.IsTestEnvironment() {
|
||||
if s.cfg.IsTestEnvironment() {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -197,8 +199,7 @@ func (s *captchaService) Verify(ctx context.Context, dx int, captchaID string) (
|
||||
// CheckVerified 检查验证码是否已验证(仅检查captcha_id)
|
||||
func (s *captchaService) CheckVerified(ctx context.Context, captchaID string) (bool, error) {
|
||||
// 测试环境下直接通过验证
|
||||
cfg, err := config.GetConfig()
|
||||
if err == nil && cfg.IsTestEnvironment() {
|
||||
if s.cfg.IsTestEnvironment() {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -216,8 +217,7 @@ func (s *captchaService) CheckVerified(ctx context.Context, captchaID string) (b
|
||||
// ConsumeVerified 消耗已验证的验证码(注册成功后调用)
|
||||
func (s *captchaService) ConsumeVerified(ctx context.Context, captchaID string) error {
|
||||
// 测试环境下直接返回成功
|
||||
cfg, err := config.GetConfig()
|
||||
if err == nil && cfg.IsTestEnvironment() {
|
||||
if s.cfg.IsTestEnvironment() {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// 通用错误
|
||||
var (
|
||||
ErrProfileNotFound = errors.New("档案不存在")
|
||||
ErrProfileNoPermission = errors.New("无权操作此档案")
|
||||
ErrTextureNotFound = errors.New("材质不存在")
|
||||
ErrTextureNoPermission = errors.New("无权操作此材质")
|
||||
ErrUserNotFound = errors.New("用户不存在")
|
||||
)
|
||||
// 通用辅助函数。
|
||||
//
|
||||
// 注意:错误定义已统一到 internal/errors 包。
|
||||
// 原先在此文件重复定义的 ErrProfileNotFound/ErrProfileNoPermission/
|
||||
// ErrTextureNotFound/ErrTextureNoPermission/ErrUserNotFound 已删除,
|
||||
// 请使用 internal/errors(别名 apperrors)中的对应定义。
|
||||
|
||||
// NormalizePagination 规范化分页参数
|
||||
func NormalizePagination(page, pageSize int) (int, int) {
|
||||
@@ -27,11 +20,3 @@ func NormalizePagination(page, pageSize int) (int, int) {
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
// WrapError 包装错误,添加上下文信息
|
||||
func WrapError(err error, message string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s: %w", message, err)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -30,21 +29,3 @@ func TestNormalizePagination_Basic(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/pkg/storage"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/pkg/storage"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -37,6 +38,11 @@ type UserService interface {
|
||||
// 配置获取
|
||||
GetMaxProfilesPerUser() int
|
||||
GetMaxTexturesPerUser() int
|
||||
|
||||
// 管理员操作
|
||||
ListUsers(ctx context.Context, page, pageSize int) ([]*model.User, int64, error)
|
||||
SetRole(ctx context.Context, userID int64, role string) error
|
||||
SetStatus(ctx context.Context, userID int64, status int16) error
|
||||
}
|
||||
|
||||
// ProfileService 档案服务接口
|
||||
@@ -54,6 +60,14 @@ type ProfileService interface {
|
||||
// 批量查询
|
||||
GetByNames(ctx context.Context, names []string) ([]*model.Profile, error)
|
||||
GetByProfileName(ctx context.Context, name string) (*model.Profile, error)
|
||||
|
||||
// ProfileSearchByName 按用户名批量查询档案,返回简化的 NameAndId 列表(文档 2.1)
|
||||
// 规范化(toLowerCase)、空名过滤、每页最多 maxBatch 个限制由本方法在服务端兜底处理。
|
||||
ProfileSearchByName(ctx context.Context, names []string, maxBatch int) ([]model.NameAndId, error)
|
||||
|
||||
// ProfileSearchByNameSingle 按单个用户名查询档案,返回简化的 NameAndId(文档 2.2)
|
||||
// 未找到时返回 (nil, nil),符合文档中"客户端返回 Optional.empty()"的契约。
|
||||
ProfileSearchByNameSingle(ctx context.Context, name string) (*model.NameAndId, error)
|
||||
}
|
||||
|
||||
// TextureService 材质服务接口
|
||||
@@ -73,6 +87,11 @@ type TextureService interface {
|
||||
|
||||
// 限制检查
|
||||
CheckUploadLimit(ctx context.Context, uploaderID int64, maxTextures int) error
|
||||
|
||||
// 管理员操作
|
||||
ListForAdmin(ctx context.Context, page, pageSize int) ([]*model.Texture, int64, error)
|
||||
AdminDelete(ctx context.Context, textureID int64) error
|
||||
IncrementDownload(ctx context.Context, textureID int64) error
|
||||
}
|
||||
|
||||
// TokenService 令牌服务接口
|
||||
@@ -123,10 +142,119 @@ type YggdrasilService interface {
|
||||
SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{}
|
||||
|
||||
// 证书
|
||||
GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error)
|
||||
GeneratePlayerCertificate(ctx context.Context, uuid string) (*PlayerCertificate, error)
|
||||
GetPublicKey(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// FriendActionType 好友操作类型(文档 1.3.2)
|
||||
type FriendActionType string
|
||||
|
||||
const (
|
||||
FriendActionAdd FriendActionType = "ADD"
|
||||
FriendActionRemove FriendActionType = "REMOVE"
|
||||
)
|
||||
|
||||
// FriendActionRequest 好友操作请求(文档 1.3.2)
|
||||
type FriendActionRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
ProfileID string `json:"profileId,omitempty"`
|
||||
UpdateType FriendActionType `json:"updateType"`
|
||||
}
|
||||
|
||||
// FriendDTO 好友条目(文档 1.3.1 FriendDto)
|
||||
type FriendDTO struct {
|
||||
ProfileID string `json:"profileId"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// FriendsListResponse 好友列表响应(文档 1.3.1)
|
||||
type FriendsListResponse struct {
|
||||
Friends []FriendDTO `json:"friends"`
|
||||
IncomingRequests []FriendDTO `json:"incomingRequests"`
|
||||
OutgoingRequests []FriendDTO `json:"outgoingRequests"`
|
||||
}
|
||||
|
||||
// PlayerAttributesRequest 玩家偏好更新请求(文档 1.2.2)
|
||||
type PlayerAttributesRequest struct {
|
||||
ProfanityFilterPreferences *ProfanityFilterPreferences `json:"profanityFilterPreferences,omitempty"`
|
||||
FriendsPreferences *FriendsPreferences `json:"friendsPreferences,omitempty"`
|
||||
}
|
||||
|
||||
// ProfanityFilterPreferences 脏词过滤偏好
|
||||
type ProfanityFilterPreferences struct {
|
||||
ProfanityFilterOn *bool `json:"profanityFilterOn,omitempty"`
|
||||
}
|
||||
|
||||
// FriendsPreferences 好友偏好
|
||||
type FriendsPreferences struct {
|
||||
Friends *string `json:"friends,omitempty"` // ENABLED / DISABLED
|
||||
AcceptInvites *string `json:"acceptInvites,omitempty"` // ENABLED / DISABLED
|
||||
}
|
||||
|
||||
// PlayerAttributesResponse 玩家属性响应(文档 1.2.1)
|
||||
// 仅实现好友相关字段,其它(privileges/banStatus 等)不在好友系统范围
|
||||
type PlayerAttributesResponse struct {
|
||||
ProfanityFilterPreferences ProfanityFilterPreferencesResp `json:"profanityFilterPreferences"`
|
||||
FriendsPreferences FriendsPreferencesResp `json:"friendsPreferences"`
|
||||
ChatPreferences ChatPreferencesResp `json:"chatPreferences"`
|
||||
}
|
||||
|
||||
// ProfanityFilterPreferencesResp 脏词过滤偏好响应
|
||||
type ProfanityFilterPreferencesResp struct {
|
||||
ProfanityFilterOn bool `json:"profanityFilterOn"`
|
||||
}
|
||||
|
||||
// FriendsPreferencesResp 好友偏好响应
|
||||
type FriendsPreferencesResp struct {
|
||||
Friends string `json:"friends"`
|
||||
AcceptInvites string `json:"acceptInvites"`
|
||||
}
|
||||
|
||||
// ChatPreferencesResp 聊天偏好响应
|
||||
type ChatPreferencesResp struct {
|
||||
TextCommunication string `json:"textCommunication"`
|
||||
}
|
||||
|
||||
// PresenceRequest 在线状态上报请求(文档 1.1)
|
||||
type PresenceRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// PresenceEntry 单个在线玩家状态(文档 1.1)
|
||||
type PresenceEntry struct {
|
||||
ProfileID string `json:"profileId"`
|
||||
PMID string `json:"pmid,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LastUpdated string `json:"lastUpdated"`
|
||||
}
|
||||
|
||||
// PresenceResponse 在线状态响应(文档 1.1)
|
||||
type PresenceResponse struct {
|
||||
Presence []PresenceEntry `json:"presence"`
|
||||
}
|
||||
|
||||
// BlockListResponse 屏蔽列表响应(文档 1.6)
|
||||
type BlockListResponse struct {
|
||||
BlockedProfiles []string `json:"blockedProfiles"`
|
||||
}
|
||||
|
||||
// FriendsService 好友系统服务接口(文档 1.1 / 1.2 / 1.3 / 1.6)
|
||||
type FriendsService interface {
|
||||
// 好友列表与操作
|
||||
ListFriends(ctx context.Context, requesterUUID string) (*FriendsListResponse, error)
|
||||
PerformFriendAction(ctx context.Context, requesterUUID string, req FriendActionRequest) (*FriendsListResponse, error)
|
||||
|
||||
// 玩家偏好属性
|
||||
GetAttributes(ctx context.Context, profileUUID string) (*PlayerAttributesResponse, error)
|
||||
UpdateAttributes(ctx context.Context, profileUUID string, req PlayerAttributesRequest) error
|
||||
|
||||
// 在线状态
|
||||
UpdatePresence(ctx context.Context, requesterUUID string, status string) (*PresenceResponse, error)
|
||||
|
||||
// 屏蔽列表
|
||||
GetBlocklist(ctx context.Context, blockerUUID string) (*BlockListResponse, error)
|
||||
}
|
||||
|
||||
// SecurityService 安全服务接口
|
||||
type SecurityService interface {
|
||||
// 登录安全
|
||||
@@ -151,6 +279,7 @@ type Services struct {
|
||||
Captcha CaptchaService
|
||||
Yggdrasil YggdrasilService
|
||||
Security SecurityService
|
||||
Friends FriendsService
|
||||
}
|
||||
|
||||
// ServiceDeps 服务依赖
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"carrotskin/pkg/database"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -130,6 +131,24 @@ func (m *MockUserRepository) FindByIDs(ctx context.Context, ids []int64) ([]*mod
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// List 分页查询用户列表(管理员,mock 实现)
|
||||
func (m *MockUserRepository) List(ctx context.Context, page, pageSize int) ([]*model.User, int64, error) {
|
||||
var all []*model.User
|
||||
for _, u := range m.users {
|
||||
all = append(all, u)
|
||||
}
|
||||
total := int64(len(all))
|
||||
offset := (page - 1) * pageSize
|
||||
if offset >= len(all) {
|
||||
return []*model.User{}, total, nil
|
||||
}
|
||||
end := offset + pageSize
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
return all[offset:end], total, nil
|
||||
}
|
||||
|
||||
// MockProfileRepository 模拟ProfileRepository
|
||||
type MockProfileRepository struct {
|
||||
profiles map[string]*model.Profile
|
||||
@@ -172,8 +191,10 @@ func (m *MockProfileRepository) FindByName(ctx context.Context, name string) (*m
|
||||
if m.FailFind {
|
||||
return nil, errors.New("mock find error")
|
||||
}
|
||||
// 与真实仓储一致:大小写不敏感匹配
|
||||
ln := strings.ToLower(name)
|
||||
for _, profile := range m.profiles {
|
||||
if profile.Name == name {
|
||||
if strings.ToLower(profile.Name) == ln {
|
||||
return profile, nil
|
||||
}
|
||||
}
|
||||
@@ -225,8 +246,10 @@ func (m *MockProfileRepository) UpdateLastUsedAt(ctx context.Context, uuid strin
|
||||
func (m *MockProfileRepository) GetByNames(ctx context.Context, names []string) ([]*model.Profile, error) {
|
||||
var result []*model.Profile
|
||||
for _, name := range names {
|
||||
// 与真实仓储一致:大小写不敏感匹配
|
||||
ln := strings.ToLower(name)
|
||||
for _, profile := range m.profiles {
|
||||
if profile.Name == name {
|
||||
if strings.ToLower(profile.Name) == ln {
|
||||
result = append(result, profile)
|
||||
}
|
||||
}
|
||||
@@ -474,6 +497,32 @@ func (m *MockTextureRepository) BatchDelete(ctx context.Context, ids []int64) (i
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// ListForAdmin 管理员列表(mock 实现)
|
||||
func (m *MockTextureRepository) ListForAdmin(ctx context.Context, page, pageSize int) ([]*model.Texture, int64, error) {
|
||||
var all []*model.Texture
|
||||
for _, t := range m.textures {
|
||||
all = append(all, t)
|
||||
}
|
||||
total := int64(len(all))
|
||||
offset := (page - 1) * pageSize
|
||||
if offset >= len(all) {
|
||||
return []*model.Texture{}, total, nil
|
||||
}
|
||||
end := offset + pageSize
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
return all[offset:end], total, nil
|
||||
}
|
||||
|
||||
// FindByIDForAdmin 管理员查询(mock 实现,无权限过滤)
|
||||
func (m *MockTextureRepository) FindByIDForAdmin(ctx context.Context, id int64) (*model.Texture, error) {
|
||||
if t, ok := m.textures[id]; ok {
|
||||
return t, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Service Mocks
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
"carrotskin/pkg/database"
|
||||
"carrotskin/pkg/utils"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
@@ -12,6 +8,13 @@ import (
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "carrotskin/internal/errors"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
"carrotskin/pkg/database"
|
||||
"carrotskin/pkg/utils"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
@@ -100,7 +103,7 @@ func (s *profileService) GetByUUID(ctx context.Context, uuid string) (*model.Pro
|
||||
profile2, err := s.profileRepo.FindByUUID(ctx, uuid)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrProfileNotFound
|
||||
return nil, apperrors.ErrProfileNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("查询档案失败: %w", err)
|
||||
}
|
||||
@@ -140,13 +143,13 @@ func (s *profileService) Update(ctx context.Context, uuid string, userID int64,
|
||||
profile, err := s.profileRepo.FindByUUID(ctx, uuid)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrProfileNotFound
|
||||
return nil, apperrors.ErrProfileNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("查询档案失败: %w", err)
|
||||
}
|
||||
|
||||
if profile.UserID != userID {
|
||||
return nil, ErrProfileNoPermission
|
||||
return nil, apperrors.ErrProfileNoPermission
|
||||
}
|
||||
|
||||
// 检查角色名是否重复
|
||||
@@ -187,13 +190,13 @@ func (s *profileService) Delete(ctx context.Context, uuid string, userID int64)
|
||||
profile, err := s.profileRepo.FindByUUID(ctx, uuid)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrProfileNotFound
|
||||
return apperrors.ErrProfileNotFound
|
||||
}
|
||||
return fmt.Errorf("查询档案失败: %w", err)
|
||||
}
|
||||
|
||||
if profile.UserID != userID {
|
||||
return ErrProfileNoPermission
|
||||
return apperrors.ErrProfileNoPermission
|
||||
}
|
||||
|
||||
if err := s.profileRepo.Delete(ctx, uuid); err != nil {
|
||||
@@ -238,6 +241,80 @@ func (s *profileService) GetByProfileName(ctx context.Context, name string) (*mo
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// ProfileSearchByName 按用户名批量查询档案,返回 NameAndId 列表(文档 2.1)
|
||||
// 实现要点:
|
||||
// - 客户端会 toLowerCase 规范化用户名,服务端也做一次兜底(结合仓储大小写不敏感查询);
|
||||
// - 过滤掉空名与重复名;
|
||||
// - 当 maxBatch > 0 且入参个数超过 maxBatch 时按文档契约返回错误(避免超大查询)。
|
||||
func (s *profileService) ProfileSearchByName(ctx context.Context, names []string, maxBatch int) ([]model.NameAndId, error) {
|
||||
// 规范化 + 去重 + 过滤空名
|
||||
normalized := make([]string, 0, len(names))
|
||||
seen := make(map[string]struct{}, len(names))
|
||||
for _, n := range names {
|
||||
// toLowerCase 兜底,与客户端行为一致
|
||||
ln := strings.ToLower(strings.TrimSpace(n))
|
||||
if ln == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[ln]; ok {
|
||||
continue
|
||||
}
|
||||
seen[ln] = struct{}{}
|
||||
normalized = append(normalized, ln)
|
||||
}
|
||||
|
||||
if maxBatch > 0 && len(normalized) > maxBatch {
|
||||
return nil, fmt.Errorf("单次最多查询 %d 个用户名", maxBatch)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return []model.NameAndId{}, nil
|
||||
}
|
||||
|
||||
profiles, err := s.profileRepo.GetByNames(ctx, normalized)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查找失败: %w", err)
|
||||
}
|
||||
|
||||
// 映射为 NameAndId(仅 id + name,保持存储的大小写)
|
||||
result := make([]model.NameAndId, 0, len(profiles))
|
||||
for _, p := range profiles {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, model.NameAndId{
|
||||
ID: p.UUID,
|
||||
Name: p.Name,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ProfileSearchByNameSingle 按单个用户名查询档案,返回 NameAndId(文档 2.2)
|
||||
// 实现要点:
|
||||
// - name 经 toLowerCase + trim 兜底规范化,配合仓储大小写不敏感查询;
|
||||
// - 空名直接返回 (nil, nil),符合文档"客户端返回 Optional.empty()"的契约;
|
||||
// - 查询错误或未命中均返回 (nil, nil),仅记日志,不上抛错误(与文档行为一致)。
|
||||
func (s *profileService) ProfileSearchByNameSingle(ctx context.Context, name string) (*model.NameAndId, error) {
|
||||
ln := strings.ToLower(strings.TrimSpace(name))
|
||||
if ln == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
profile, err := s.profileRepo.FindByName(ctx, ln)
|
||||
if err != nil {
|
||||
// 记录非致命错误但不返回,与文档"任意错误返回 Optional.empty()"保持一致
|
||||
s.logger.Warn("按用户名查档失败", zap.String("name", name), zap.Error(err))
|
||||
return nil, nil
|
||||
}
|
||||
if profile == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &model.NameAndId{
|
||||
ID: profile.UUID,
|
||||
Name: profile.Name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateRSAPrivateKeyInternal 生成RSA-2048私钥(PEM格式)
|
||||
func generateRSAPrivateKeyInternal() (string, error) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
|
||||
@@ -686,4 +686,46 @@ func TestProfileServiceImpl_CheckLimit_And_GetByNames(t *testing.T) {
|
||||
if err != nil || p == nil || p.Name != "A" {
|
||||
t.Fatalf("GetByProfileName 返回错误, profile=%+v, err=%v", p, err)
|
||||
}
|
||||
|
||||
// ProfileSearchByName:小写名查询、去重、过滤空名、超限拒单
|
||||
got, err := svc.ProfileSearchByName(ctx, []string{"a", "A", "b", "", " "}, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ProfileSearchByName 失败: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("ProfileSearchByName 应去重返回 2 项, got=%d: %+v", len(got), got)
|
||||
}
|
||||
byName := make(map[string]string, len(got))
|
||||
for _, n := range got {
|
||||
byName[n.Name] = n.ID
|
||||
}
|
||||
if byName["A"] != "a" || byName["B"] != "b" {
|
||||
t.Fatalf("返回的 id/name 映射错误: %+v", byName)
|
||||
}
|
||||
|
||||
// 超过 maxBatch 应报错
|
||||
if _, err := svc.ProfileSearchByName(ctx, []string{"a", "b", "c"}, 2); err == nil {
|
||||
t.Fatalf("ProfileSearchByName 超过 maxBatch 应返回错误")
|
||||
}
|
||||
|
||||
// ProfileSearchByNameSingle:存在 / 不存在 / 空名
|
||||
single, err := svc.ProfileSearchByNameSingle(ctx, "a")
|
||||
if err != nil || single == nil || single.ID != "a" || single.Name != "A" {
|
||||
t.Fatalf("ProfileSearchByNameSingle 存在用例失败, got=%+v err=%v", single, err)
|
||||
}
|
||||
// 小写名应能命中大小写不敏感查询
|
||||
singleLower, err := svc.ProfileSearchByNameSingle(ctx, "a")
|
||||
if err != nil || singleLower == nil {
|
||||
t.Fatalf("ProfileSearchByNameSingle 小写名匹配失败, got=%+v err=%v", singleLower, err)
|
||||
}
|
||||
// 不存在
|
||||
missing, err := svc.ProfileSearchByNameSingle(ctx, "nope")
|
||||
if err != nil || missing != nil {
|
||||
t.Fatalf("ProfileSearchByNameSingle 不存在应返回 (nil,nil), got=%+v err=%v", missing, err)
|
||||
}
|
||||
// 空名
|
||||
emptyName, err := svc.ProfileSearchByNameSingle(ctx, " ")
|
||||
if err != nil || emptyName != nil {
|
||||
t.Fatalf("ProfileSearchByNameSingle 空名应返回 (nil,nil), got=%+v err=%v", emptyName, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
"carrotskin/pkg/redis"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
@@ -15,9 +12,13 @@ import (
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
"carrotskin/pkg/redis"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -37,6 +38,11 @@ type SignatureService struct {
|
||||
profileRepo repository.ProfileRepository
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
|
||||
// 缓存已解析的根密钥对,避免每次签名都重复 PEM 解析(PKCS1 解析开销较大)
|
||||
rootKeyMu sync.RWMutex
|
||||
rootPrivateKey *rsa.PrivateKey
|
||||
rootPublicKeyPEM string
|
||||
}
|
||||
|
||||
// NewSignatureService 创建SignatureService实例
|
||||
@@ -53,7 +59,7 @@ func NewSignatureService(
|
||||
}
|
||||
|
||||
// NewKeyPair 生成新的RSA密钥对
|
||||
func (s *SignatureService) NewKeyPair() (*model.KeyPair, error) {
|
||||
func (s *SignatureService) NewKeyPair(ctx context.Context) (*model.KeyPair, error) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, KeySize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成RSA密钥对失败: %w", err)
|
||||
@@ -85,81 +91,82 @@ func (s *SignatureService) NewKeyPair() (*model.KeyPair, error) {
|
||||
refresh := now.AddDate(0, 0, RefreshDays)
|
||||
|
||||
// 获取Yggdrasil根密钥并签名公钥
|
||||
yggPublicKey, yggPrivateKey, err := s.GetOrCreateYggdrasilKeyPair()
|
||||
yggPublicKey, yggPrivateKey, err := s.GetOrCreateYggdrasilKeyPair(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取Yggdrasil根密钥失败: %w", err)
|
||||
}
|
||||
|
||||
// 构造签名消息
|
||||
// 构造签名消息(PEM 公钥 + 过期时间戳)
|
||||
expiresAtMillis := expiration.UnixMilli()
|
||||
message := []byte(string(publicKeyPEM) + strconv.FormatInt(expiresAtMillis, 10))
|
||||
message := make([]byte, 0, len(publicKeyPEM)+20)
|
||||
message = append(message, publicKeyPEM...)
|
||||
message = strconv.AppendInt(message, expiresAtMillis, 10)
|
||||
|
||||
// 使用SHA1withRSA签名
|
||||
hashed := sha1.Sum(message)
|
||||
signature, err := rsa.SignPKCS1v15(rand.Reader, yggPrivateKey, crypto.SHA1, hashed[:])
|
||||
publicKeySignature, err := signWithRootKey(yggPrivateKey, message)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("签名失败: %w", err)
|
||||
}
|
||||
publicKeySignature := base64.StdEncoding.EncodeToString(signature)
|
||||
|
||||
// 构造V2签名消息(DER编码)
|
||||
publicKeyDER, err := x509.MarshalPKIXPublicKey(publicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DER编码公钥失败: %w", err)
|
||||
}
|
||||
|
||||
// V2签名:timestamp (8 bytes, big endian) + publicKey (DER)
|
||||
messageV2 := make([]byte, 8+len(publicKeyDER))
|
||||
messageV2 := make([]byte, 8+len(publicKeyBytes))
|
||||
binary.BigEndian.PutUint64(messageV2[0:8], uint64(expiresAtMillis))
|
||||
copy(messageV2[8:], publicKeyDER)
|
||||
copy(messageV2[8:], publicKeyBytes)
|
||||
|
||||
hashedV2 := sha1.Sum(messageV2)
|
||||
signatureV2, err := rsa.SignPKCS1v15(rand.Reader, yggPrivateKey, crypto.SHA1, hashedV2[:])
|
||||
publicKeySignatureV2, err := signWithRootKey(yggPrivateKey, messageV2)
|
||||
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,
|
||||
PublicKeySignature: base64.StdEncoding.EncodeToString(publicKeySignature),
|
||||
PublicKeySignatureV2: base64.StdEncoding.EncodeToString(publicKeySignatureV2),
|
||||
YggdrasilPublicKey: yggPublicKey,
|
||||
Expiration: expiration,
|
||||
Refresh: refresh,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// signWithRootKey 使用根密钥对消息做 SHA1withRSA 签名
|
||||
func signWithRootKey(privateKey *rsa.PrivateKey, message []byte) ([]byte, error) {
|
||||
hashed := sha1.Sum(message)
|
||||
return rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
|
||||
}
|
||||
|
||||
// GetOrCreateYggdrasilKeyPair 获取或创建Yggdrasil根密钥对
|
||||
func (s *SignatureService) GetOrCreateYggdrasilKeyPair() (string, *rsa.PrivateKey, error) {
|
||||
ctx := context.Background()
|
||||
// 优先使用进程内缓存的已解析私钥;缓存未命中时从Redis加载并解析,再缓存。
|
||||
func (s *SignatureService) GetOrCreateYggdrasilKeyPair(ctx context.Context) (string, *rsa.PrivateKey, error) {
|
||||
// 1. 读缓存(快路径)
|
||||
s.rootKeyMu.RLock()
|
||||
if s.rootPrivateKey != nil && s.isRootKeyValid() {
|
||||
pub := s.rootPublicKeyPEM
|
||||
priv := s.rootPrivateKey
|
||||
s.rootKeyMu.RUnlock()
|
||||
return pub, priv, nil
|
||||
}
|
||||
s.rootKeyMu.RUnlock()
|
||||
|
||||
// 尝试从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 {
|
||||
// 2. 缓存未命中,加锁准备从 Redis 加载/生成
|
||||
s.rootKeyMu.Lock()
|
||||
defer s.rootKeyMu.Unlock()
|
||||
|
||||
// 双重检查:可能在等待锁期间已被其他 goroutine 填充
|
||||
if s.rootPrivateKey != nil && s.isRootKeyValidLocked() {
|
||||
return s.rootPublicKeyPEM, s.rootPrivateKey, nil
|
||||
}
|
||||
|
||||
// 尝试从Redis获取密钥(MGet 一次往返拿三个字段,减少网络往返)
|
||||
pub, priv, err := s.loadRootKeyFromRedis(ctx)
|
||||
if err == nil && priv != nil {
|
||||
s.rootPrivateKey = priv
|
||||
s.rootPublicKeyPEM = pub
|
||||
s.logger.Info("从Redis加载Yggdrasil根密钥")
|
||||
return publicKeyPEM, privateKey, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return pub, priv, nil
|
||||
}
|
||||
|
||||
// 生成新的根密钥对
|
||||
// Redis 没有,生成新的根密钥对
|
||||
s.logger.Info("生成新的Yggdrasil根密钥对")
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, KeySize)
|
||||
if err != nil {
|
||||
@@ -178,7 +185,7 @@ func (s *SignatureService) GetOrCreateYggdrasilKeyPair() (string, *rsa.PrivateKe
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("编码公钥失败: %w", err)
|
||||
}
|
||||
publicKeyPEM = string(pem.EncodeToMemory(&pem.Block{
|
||||
publicKeyPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: publicKeyBytes,
|
||||
}))
|
||||
@@ -197,19 +204,66 @@ func (s *SignatureService) GetOrCreateYggdrasilKeyPair() (string, *rsa.PrivateKe
|
||||
s.logger.Warn("保存密钥过期时间到Redis失败", zap.Error(err))
|
||||
}
|
||||
|
||||
// 缓存已解析的私钥与公钥PEM
|
||||
s.rootPrivateKey = privateKey
|
||||
s.rootPublicKeyPEM = publicKeyPEM
|
||||
return publicKeyPEM, privateKey, nil
|
||||
}
|
||||
|
||||
// loadRootKeyFromRedis 从Redis加载根密钥,使用 MGet 单次往返获取三字段
|
||||
func (s *SignatureService) loadRootKeyFromRedis(ctx context.Context) (string, *rsa.PrivateKey, error) {
|
||||
results, err := s.redis.MGet(ctx, PublicKeyRedisKey, PrivateKeyRedisKey, KeyExpirationRedisKey).Result()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(results) < 3 {
|
||||
return "", nil, fmt.Errorf("MGet 返回字段数不足")
|
||||
}
|
||||
|
||||
publicKeyPEM, _ := results[0].(string)
|
||||
privateKeyPEM, _ := results[1].(string)
|
||||
expStr, _ := results[2].(string)
|
||||
|
||||
if publicKeyPEM == "" || privateKeyPEM == "" || expStr == "" {
|
||||
return "", nil, fmt.Errorf("Redis中密钥字段不完整")
|
||||
}
|
||||
|
||||
expTime, err := time.Parse(time.RFC3339, expStr)
|
||||
if err != nil || !time.Now().Before(expTime) {
|
||||
return "", nil, fmt.Errorf("密钥已过期或过期时间解析失败")
|
||||
}
|
||||
|
||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil {
|
||||
return "", nil, fmt.Errorf("解析PEM私钥失败")
|
||||
}
|
||||
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("解析RSA私钥失败: %w", err)
|
||||
}
|
||||
return publicKeyPEM, privateKey, nil
|
||||
}
|
||||
|
||||
// isRootKeyValid 在持有读锁时判断缓存密钥是否过期。
|
||||
// 注意:缓存的私钥本身不带过期时间,过期信息存于 Redis(由 loadRootKeyFromRedis 校验)。
|
||||
// 一旦加载成功即认为在进程生命周期内有效,由 Redis 过期触发重新生成。
|
||||
func (s *SignatureService) isRootKeyValid() bool {
|
||||
return s.isRootKeyValidLocked()
|
||||
}
|
||||
|
||||
func (s *SignatureService) isRootKeyValidLocked() bool {
|
||||
return s.rootPrivateKey != nil && s.rootPublicKeyPEM != ""
|
||||
}
|
||||
|
||||
// GetPublicKeyFromRedis 从Redis获取公钥
|
||||
func (s *SignatureService) GetPublicKeyFromRedis() (string, error) {
|
||||
ctx := context.Background()
|
||||
func (s *SignatureService) GetPublicKeyFromRedis(ctx context.Context) (string, error) {
|
||||
publicKey, err := s.redis.Get(ctx, PublicKeyRedisKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("从Redis获取公钥失败: %w", err)
|
||||
}
|
||||
if publicKey == "" {
|
||||
// 如果Redis中没有,创建新的密钥对
|
||||
publicKey, _, err = s.GetOrCreateYggdrasilKeyPair()
|
||||
publicKey, _, err = s.GetOrCreateYggdrasilKeyPair(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建新密钥对失败: %w", err)
|
||||
}
|
||||
@@ -218,82 +272,16 @@ func (s *SignatureService) GetPublicKeyFromRedis() (string, error) {
|
||||
}
|
||||
|
||||
// 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()
|
||||
// 使用缓存的已解析根私钥;若缓存为空则触发加载/生成。
|
||||
func (s *SignatureService) SignStringWithSHA1withRSA(ctx context.Context, data string) (string, error) {
|
||||
_, privateKey, err := s.GetOrCreateYggdrasilKeyPair(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取私钥失败: %w", err)
|
||||
return "", fmt.Errorf("获取签名私钥失败: %w", err)
|
||||
}
|
||||
// 使用新生成的私钥签名
|
||||
hashed := sha1.Sum([]byte(data))
|
||||
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
|
||||
|
||||
signature, err := signWithRootKey(privateKey, []byte(data))
|
||||
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, "")
|
||||
}
|
||||
|
||||
// SignStringWithProfileRSA 使用Profile的RSA私钥签名字符串
|
||||
func (s *SignatureService) SignStringWithProfileRSA(data string, privateKeyPEM string) (string, error) {
|
||||
// 解析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
|
||||
}
|
||||
|
||||
403
internal/service/signature_service_test.go
Normal file
403
internal/service/signature_service_test.go
Normal file
@@ -0,0 +1,403 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
pkgRedis "carrotskin/pkg/redis"
|
||||
|
||||
miniredis "github.com/alicebob/miniredis/v2"
|
||||
goRedis "github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// newTestSignatureService 构造一个基于 miniredis 的 SignatureService,用于测试。
|
||||
// 返回服务实例与清理函数。
|
||||
func newTestSignatureService(t *testing.T) (*SignatureService, *miniredis.Miniredis, func()) {
|
||||
t.Helper()
|
||||
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("启动 miniredis 失败: %v", err)
|
||||
}
|
||||
|
||||
rdb := goRedis.NewClient(&goRedis.Options{Addr: mr.Addr()})
|
||||
client := &pkgRedis.Client{Client: rdb}
|
||||
|
||||
svc := NewSignatureService(nil, client, zap.NewNop())
|
||||
cleanup := func() {
|
||||
_ = rdb.Close()
|
||||
mr.Close()
|
||||
}
|
||||
return svc, mr, cleanup
|
||||
}
|
||||
|
||||
// TestSignatureService_SignString_Deterministic 验证 SHA1withRSA 签名的确定性:
|
||||
// 相同 data + 相同根密钥应产生完全相同的签名(PKCS#1v1.5 非随机化)。
|
||||
// 这也是本次重构(缓存已解析私钥)后输出与重构前一致的核心保证。
|
||||
func TestSignatureService_SignString_Deterministic(t *testing.T) {
|
||||
svc, _, cleanup := newTestSignatureService(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// 首次签名会生成并缓存根密钥
|
||||
sig1, err := svc.SignStringWithSHA1withRSA(ctx, "test-data-123")
|
||||
if err != nil {
|
||||
t.Fatalf("首次签名失败: %v", err)
|
||||
}
|
||||
|
||||
// 第二次签名应命中缓存的已解析私钥,输出必须与首次完全一致
|
||||
sig2, err := svc.SignStringWithSHA1withRSA(ctx, "test-data-123")
|
||||
if err != nil {
|
||||
t.Fatalf("第二次签名失败: %v", err)
|
||||
}
|
||||
|
||||
if sig1 != sig2 {
|
||||
t.Fatalf("签名不确定:sig1=%q sig2=%q", sig1, sig2)
|
||||
}
|
||||
|
||||
// 不同数据应产生不同签名
|
||||
sig3, err := svc.SignStringWithSHA1withRSA(ctx, "different-data")
|
||||
if err != nil {
|
||||
t.Fatalf("不同数据签名失败: %v", err)
|
||||
}
|
||||
if sig1 == sig3 {
|
||||
t.Fatal("不同数据的签名不应相同")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignatureService_SignString_MatchesReference 验证重构后的签名与
|
||||
// 重构前的参考实现(直接从 Redis 读取私钥 PEM 并解析、逐次签名)产生相同字节序列。
|
||||
// 这是签名一致性的回归测试:只要 data 与根私钥相同,新旧实现输出一致。
|
||||
func TestSignatureService_SignString_MatchesReference(t *testing.T) {
|
||||
svc, mr, cleanup := newTestSignatureService(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// 触发根密钥生成并写入 miniredis
|
||||
if _, err := svc.SignStringWithSHA1withRSA(ctx, "consistency-check"); err != nil {
|
||||
t.Fatalf("触发根密钥生成失败: %v", err)
|
||||
}
|
||||
|
||||
// 从 miniredis 读取根私钥 PEM,模拟重构前每次签名都重新解析的逻辑
|
||||
privPEM, err := mr.Get(PrivateKeyRedisKey)
|
||||
if err != nil {
|
||||
t.Fatalf("读取根私钥失败: %v", err)
|
||||
}
|
||||
|
||||
cases := []string{
|
||||
"",
|
||||
"a",
|
||||
strings.Repeat("x", 1024),
|
||||
"eyJ0aW1lc3RhbXAiOjE3MDAwMDAwMDAwMDAsInByb2ZpbGVJZCI6ImFiYzEyIiwicHJvZmlsZU5hbWUiOiJUZXN0In0=",
|
||||
}
|
||||
for _, data := range cases {
|
||||
// 重构后实现
|
||||
got, err := svc.SignStringWithSHA1withRSA(ctx, data)
|
||||
if err != nil {
|
||||
t.Fatalf("SignStringWithSHA1withRSA(%q) 失败: %v", data, err)
|
||||
}
|
||||
|
||||
// 参考实现(重构前逻辑):pem.Decode + ParsePKCS1PrivateKey + SignPKCS1v15
|
||||
block, _ := pem.Decode([]byte(privPEM))
|
||||
if block == nil {
|
||||
t.Fatalf("参考实现解析 PEM 失败")
|
||||
}
|
||||
priv, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("参考实现解析 RSA 私钥失败: %v", err)
|
||||
}
|
||||
hashed := sha1.Sum([]byte(data))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA1, hashed[:])
|
||||
if err != nil {
|
||||
t.Fatalf("参考实现签名失败: %v", err)
|
||||
}
|
||||
want := base64.StdEncoding.EncodeToString(sig)
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("签名与参考实现不一致 data=%q\n got=%q\nwant=%q", data, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignatureService_RootKeyCachedAcrossReloads 验证:
|
||||
// 同一进程内多次 GetOrCreateYggdrasilKeyPair 返回同一已解析私钥实例(缓存生效),
|
||||
// 且不重复访问 Redis。
|
||||
func TestSignatureService_RootKeyCachedAcrossReloads(t *testing.T) {
|
||||
svc, mr, cleanup := newTestSignatureService(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// 首次生成
|
||||
pub1, priv1, err := svc.GetOrCreateYggdrasilKeyPair(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("首次 GetOrCreate 失败: %v", err)
|
||||
}
|
||||
|
||||
// 第二次应命中缓存,返回同一私钥指针
|
||||
pub2, priv2, err := svc.GetOrCreateYggdrasilKeyPair(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("第二次 GetOrCreate 失败: %v", err)
|
||||
}
|
||||
if priv1 != priv2 {
|
||||
t.Fatal("缓存未命中:返回了不同的私钥实例")
|
||||
}
|
||||
if pub1 != pub2 {
|
||||
t.Fatal("公钥 PEM 不一致")
|
||||
}
|
||||
|
||||
// Redis 仅应被写入一次(首次生成);记录当前 key 数量,再次调用不应增加
|
||||
keysBefore := len(mr.Keys())
|
||||
_, _, _ = svc.GetOrCreateYggdrasilKeyPair(ctx)
|
||||
if got := len(mr.Keys()); got != keysBefore {
|
||||
t.Fatalf("缓存命中后仍写入 Redis:keys before=%d after=%d", keysBefore, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignatureService_NewKeyPair_SignatureVerifies 验证 NewKeyPair 生成的
|
||||
// publicKeySignature / publicKeySignatureV2 能用根公钥正确验签,
|
||||
// 且消息构造与重构前格式一致(PEM公钥+时间戳;DER公钥+时间戳)。
|
||||
func TestSignatureService_NewKeyPair_SignatureVerifies(t *testing.T) {
|
||||
svc, mr, cleanup := newTestSignatureService(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
kp, err := svc.NewKeyPair(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("NewKeyPair 失败: %v", err)
|
||||
}
|
||||
|
||||
// 取根公钥用于验签
|
||||
rootPubPEM, err := mr.Get(PublicKeyRedisKey)
|
||||
if err != nil {
|
||||
t.Fatalf("读取根公钥失败: %v", err)
|
||||
}
|
||||
block, _ := pem.Decode([]byte(rootPubPEM))
|
||||
if block == nil {
|
||||
t.Fatal("解析根公钥 PEM 失败")
|
||||
}
|
||||
rootPub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("解析根公钥失败: %v", err)
|
||||
}
|
||||
rsaRootPub, ok := rootPub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
t.Fatal("根公钥不是 RSA 公钥")
|
||||
}
|
||||
|
||||
expiresAtMillis := kp.Expiration.UnixMilli()
|
||||
|
||||
// V1 消息:重构前为 string(publicKeyPEM) + strconv.FormatInt(expiresAtMillis, 10)
|
||||
// 重构后用 append 拼接,字节序列应完全一致
|
||||
pubPEM := []byte(kp.PublicKey)
|
||||
v1Message := make([]byte, 0, len(pubPEM)+20)
|
||||
v1Message = append(v1Message, pubPEM...)
|
||||
v1Message = strconv.AppendInt(v1Message, expiresAtMillis, 10)
|
||||
|
||||
sig1, err := base64.StdEncoding.DecodeString(kp.PublicKeySignature)
|
||||
if err != nil {
|
||||
t.Fatalf("解码 publicKeySignature 失败: %v", err)
|
||||
}
|
||||
hashed1 := sha1.Sum(v1Message)
|
||||
if err := rsa.VerifyPKCS1v15(rsaRootPub, crypto.SHA1, hashed1[:], sig1); err != nil {
|
||||
t.Fatalf("V1 签名验签失败: %v", err)
|
||||
}
|
||||
|
||||
// V2 消息:重构前为 timestamp(8 BE) + 玩家公钥 DER
|
||||
// 玩家公钥 DER 来自 kp.PublicKey 的 PEM 解码
|
||||
playerBlock, _ := pem.Decode([]byte(kp.PublicKey))
|
||||
if playerBlock == nil {
|
||||
t.Fatal("解析玩家公钥 PEM 失败")
|
||||
}
|
||||
playerDER := playerBlock.Bytes // PKIX 编码的 SubjectPublicKeyInfo DER
|
||||
v2Message := make([]byte, 8+len(playerDER))
|
||||
binary.BigEndian.PutUint64(v2Message[0:8], uint64(expiresAtMillis))
|
||||
copy(v2Message[8:], playerDER)
|
||||
|
||||
sig2, err := base64.StdEncoding.DecodeString(kp.PublicKeySignatureV2)
|
||||
if err != nil {
|
||||
t.Fatalf("解码 publicKeySignatureV2 失败: %v", err)
|
||||
}
|
||||
hashed2 := sha1.Sum(v2Message)
|
||||
if err := rsa.VerifyPKCS1v15(rsaRootPub, crypto.SHA1, hashed2[:], sig2); err != nil {
|
||||
t.Fatalf("V2 签名验签失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignatureService_NewKeyPair_V1MessageBytes 验证 V1 消息字节构造与重构前完全一致。
|
||||
// 重构前:[]byte(string(publicKeyPEM) + strconv.FormatInt(expiresAtMillis, 10))
|
||||
// 重构后:append(publicKeyPEM...); strconv.AppendInt(msg, expiresAtMillis, 10)
|
||||
func TestSignatureService_NewKeyPair_V1MessageBytes(t *testing.T) {
|
||||
// 构造一个固定的 PEM 公钥与时间戳,对比两种构造方式的字节
|
||||
pubPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: []byte("dummy-der-bytes"),
|
||||
})
|
||||
expiresAtMillis := int64(1700000000000)
|
||||
|
||||
oldStyle := []byte(string(pubPEM) + strconv.FormatInt(expiresAtMillis, 10))
|
||||
|
||||
newStyle := make([]byte, 0, len(pubPEM)+20)
|
||||
newStyle = append(newStyle, pubPEM...)
|
||||
newStyle = strconv.AppendInt(newStyle, expiresAtMillis, 10)
|
||||
|
||||
if string(oldStyle) != string(newStyle) {
|
||||
t.Fatalf("V1 消息字节不一致\n old=%q\n new=%q", oldStyle, newStyle)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignatureService_GetOrCreateYggdrasilKeyPair_LoadsFromRedis 验证:
|
||||
// 当进程内缓存为空(新实例)但 Redis 中已有有效密钥时,能正确加载而非重新生成。
|
||||
// 使用同一 miniredis 实例,避免新旧实例切换时数据丢失。
|
||||
func TestSignatureService_GetOrCreateYggdrasilKeyPair_LoadsFromRedis(t *testing.T) {
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("启动 miniredis 失败: %v", err)
|
||||
}
|
||||
t.Cleanup(mr.Close)
|
||||
|
||||
rdb := goRedis.NewClient(&goRedis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = rdb.Close() })
|
||||
client := &pkgRedis.Client{Client: rdb}
|
||||
ctx := context.Background()
|
||||
|
||||
// 实例1 生成根密钥(写入 miniredis)
|
||||
svc1 := NewSignatureService(nil, client, zap.NewNop())
|
||||
pub1, priv1, err := svc1.GetOrCreateYggdrasilKeyPair(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("实例1生成失败: %v", err)
|
||||
}
|
||||
|
||||
// 新建实例2(缓存为空),但 Redis 中已有密钥
|
||||
svc2 := NewSignatureService(nil, client, zap.NewNop())
|
||||
pub2, priv2, err := svc2.GetOrCreateYggdrasilKeyPair(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("实例2加载失败: %v", err)
|
||||
}
|
||||
if pub1 != pub2 {
|
||||
t.Fatal("从 Redis 加载的公钥 PEM 不一致")
|
||||
}
|
||||
// 加载的私钥应与原始私钥在数学上等价(N 相同)
|
||||
if priv1.N.Cmp(priv2.N) != 0 {
|
||||
t.Fatal("从 Redis 加载的私钥与原始私钥不匹配")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignatureService_SignConcurrent 验证签名缓存层在并发下的正确性(无数据竞争)。
|
||||
func TestSignatureService_SignConcurrent(t *testing.T) {
|
||||
svc, _, cleanup := newTestSignatureService(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// 先生成根密钥
|
||||
seed, err := svc.SignStringWithSHA1withRSA(ctx, "seed")
|
||||
if err != nil {
|
||||
t.Fatalf("种子签名失败: %v", err)
|
||||
}
|
||||
|
||||
const n = 32
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, n)
|
||||
sigs := make(chan string, n)
|
||||
wg.Add(n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sig, err := svc.SignStringWithSHA1withRSA(ctx, "seed")
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
sigs <- sig
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
close(sigs)
|
||||
|
||||
for err := range errs {
|
||||
t.Fatalf("并发签名出错: %v", err)
|
||||
}
|
||||
for sig := range sigs {
|
||||
if sig != seed {
|
||||
t.Fatalf("并发签名结果与预期不一致:got=%q want=%q", sig, seed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignatureService_KeyPairFieldsPopulated 验证 NewKeyPair 返回的 KeyPair 字段完整。
|
||||
func TestSignatureService_KeyPairFieldsPopulated(t *testing.T) {
|
||||
svc, _, cleanup := newTestSignatureService(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
kp, err := svc.NewKeyPair(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("NewKeyPair 失败: %v", err)
|
||||
}
|
||||
|
||||
checks := []struct {
|
||||
name string
|
||||
got string
|
||||
}{
|
||||
{"PrivateKey", kp.PrivateKey},
|
||||
{"PublicKey", kp.PublicKey},
|
||||
{"PublicKeySignature", kp.PublicKeySignature},
|
||||
{"PublicKeySignatureV2", kp.PublicKeySignatureV2},
|
||||
{"YggdrasilPublicKey", kp.YggdrasilPublicKey},
|
||||
}
|
||||
for _, c := range checks {
|
||||
if c.got == "" {
|
||||
t.Errorf("KeyPair.%s 为空", c.name)
|
||||
}
|
||||
}
|
||||
if kp.Expiration.IsZero() || kp.Refresh.IsZero() {
|
||||
t.Error("KeyPair.Expiration/Refresh 未设置")
|
||||
}
|
||||
if !kp.Refresh.Before(kp.Expiration) {
|
||||
t.Error("Refresh 应早于 Expiration")
|
||||
}
|
||||
}
|
||||
|
||||
// 确保引用的包被使用(避免未使用导入在编辑后报错)
|
||||
var _ = fmt.Sprintf
|
||||
|
||||
// BenchmarkSignStringWithSHA1withRSA 衡量缓存命中路径的签名性能(无 PEM 解析、无 Redis 往返)。
|
||||
func BenchmarkSignStringWithSHA1withRSA(b *testing.B) {
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
b.Fatalf("启动 miniredis 失败: %v", err)
|
||||
}
|
||||
defer mr.Close()
|
||||
|
||||
rdb := goRedis.NewClient(&goRedis.Options{Addr: mr.Addr()})
|
||||
defer func() { _ = rdb.Close() }()
|
||||
client := &pkgRedis.Client{Client: rdb}
|
||||
ctx := context.Background()
|
||||
|
||||
svc := NewSignatureService(nil, client, zap.NewNop())
|
||||
// 预热:生成根密钥并填充缓存
|
||||
if _, err := svc.SignStringWithSHA1withRSA(ctx, "warmup"); err != nil {
|
||||
b.Fatalf("预热失败: %v", err)
|
||||
}
|
||||
|
||||
data := strings.Repeat("x", 256)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := svc.SignStringWithSHA1withRSA(ctx, data); err != nil {
|
||||
b.Fatalf("签名失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,6 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
"carrotskin/pkg/database"
|
||||
"carrotskin/pkg/storage"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -13,8 +9,16 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "carrotskin/internal/errors"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
"carrotskin/pkg/database"
|
||||
"carrotskin/pkg/storage"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// textureService TextureService的实现
|
||||
@@ -26,6 +30,7 @@ type textureService struct {
|
||||
cacheKeys *database.CacheKeyBuilder
|
||||
cacheInv *database.CacheInvalidator
|
||||
logger *zap.Logger
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewTextureService 创建TextureService实例
|
||||
@@ -35,6 +40,7 @@ func NewTextureService(
|
||||
storageClient *storage.StorageClient,
|
||||
cacheManager *database.CacheManager,
|
||||
logger *zap.Logger,
|
||||
db *gorm.DB,
|
||||
) TextureService {
|
||||
return &textureService{
|
||||
textureRepo: textureRepo,
|
||||
@@ -44,6 +50,7 @@ func NewTextureService(
|
||||
cacheKeys: database.NewCacheKeyBuilder(""),
|
||||
cacheInv: database.NewCacheInvalidator(cacheManager),
|
||||
logger: logger,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +69,7 @@ func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture,
|
||||
return nil, err
|
||||
}
|
||||
if texture2 == nil {
|
||||
return nil, ErrTextureNotFound
|
||||
return nil, apperrors.ErrTextureNotFound
|
||||
}
|
||||
if texture2.Status == -1 {
|
||||
return nil, errors.New("材质已删除")
|
||||
@@ -80,7 +87,7 @@ func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture,
|
||||
return nil, err
|
||||
}
|
||||
if texture2 == nil {
|
||||
return nil, ErrTextureNotFound
|
||||
return nil, apperrors.ErrTextureNotFound
|
||||
}
|
||||
if texture2.Status == -1 {
|
||||
return nil, errors.New("材质已删除")
|
||||
@@ -109,7 +116,7 @@ func (s *textureService) GetByHash(ctx context.Context, hash string) (*model.Tex
|
||||
return nil, err
|
||||
}
|
||||
if texture2 == nil {
|
||||
return nil, ErrTextureNotFound
|
||||
return nil, apperrors.ErrTextureNotFound
|
||||
}
|
||||
if texture2.Status == -1 {
|
||||
return nil, errors.New("材质已删除")
|
||||
@@ -162,10 +169,10 @@ func (s *textureService) Update(ctx context.Context, textureID, uploaderID int64
|
||||
return nil, err
|
||||
}
|
||||
if texture == nil {
|
||||
return nil, ErrTextureNotFound
|
||||
return nil, apperrors.ErrTextureNotFound
|
||||
}
|
||||
if texture.UploaderID != uploaderID {
|
||||
return nil, ErrTextureNoPermission
|
||||
return nil, apperrors.ErrTextureNoPermission
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
@@ -200,10 +207,10 @@ func (s *textureService) Delete(ctx context.Context, textureID, uploaderID int64
|
||||
return err
|
||||
}
|
||||
if texture == nil {
|
||||
return ErrTextureNotFound
|
||||
return apperrors.ErrTextureNotFound
|
||||
}
|
||||
if texture.UploaderID != uploaderID {
|
||||
return ErrTextureNoPermission
|
||||
return apperrors.ErrTextureNoPermission
|
||||
}
|
||||
|
||||
err = s.textureRepo.Delete(ctx, textureID)
|
||||
@@ -225,33 +232,51 @@ func (s *textureService) ToggleFavorite(ctx context.Context, userID, textureID i
|
||||
return false, err
|
||||
}
|
||||
if texture == nil {
|
||||
return false, ErrTextureNotFound
|
||||
return false, apperrors.ErrTextureNotFound
|
||||
}
|
||||
|
||||
isFavorited, err := s.textureRepo.IsFavorited(ctx, userID, textureID)
|
||||
// 在事务中执行"切换收藏 + 增减计数"两步,保证数据一致性
|
||||
// 注意:s.db 在部分单元测试中可能为 nil,此时跳过事务直接执行
|
||||
// (生产环境 db 永远非空,mock repo 本就不是真事务,降级不影响一致性)
|
||||
var favorited bool
|
||||
toggle := func() error {
|
||||
isFav, err := s.textureRepo.IsFavorited(ctx, userID, textureID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isFav {
|
||||
// 已收藏 -> 取消收藏
|
||||
if err := s.textureRepo.RemoveFavorite(ctx, userID, textureID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.textureRepo.DecrementFavoriteCount(ctx, textureID); err != nil {
|
||||
return err
|
||||
}
|
||||
favorited = false
|
||||
return nil
|
||||
}
|
||||
// 未收藏 -> 添加收藏
|
||||
if err := s.textureRepo.AddFavorite(ctx, userID, textureID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.textureRepo.IncrementFavoriteCount(ctx, textureID); err != nil {
|
||||
return err
|
||||
}
|
||||
favorited = true
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.db != nil {
|
||||
err = database.TransactionWithTimeout(ctx, s.db, 5*time.Second, func(tx *gorm.DB) error {
|
||||
return toggle()
|
||||
})
|
||||
} else {
|
||||
err = toggle()
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if isFavorited {
|
||||
// 已收藏 -> 取消收藏
|
||||
if err := s.textureRepo.RemoveFavorite(ctx, userID, textureID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := s.textureRepo.DecrementFavoriteCount(ctx, textureID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 未收藏 -> 添加收藏
|
||||
if err := s.textureRepo.AddFavorite(ctx, userID, textureID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := s.textureRepo.IncrementFavoriteCount(ctx, textureID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
return favorited, nil
|
||||
}
|
||||
|
||||
func (s *textureService) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {
|
||||
@@ -277,7 +302,7 @@ func (s *textureService) UploadTexture(ctx context.Context, uploaderID int64, na
|
||||
// 验证用户存在
|
||||
user, err := s.userRepo.FindByID(ctx, uploaderID)
|
||||
if err != nil || user == nil {
|
||||
return nil, ErrUserNotFound
|
||||
return nil, apperrors.ErrUserNotFound
|
||||
}
|
||||
|
||||
// 验证文件大小和扩展名
|
||||
@@ -403,3 +428,28 @@ func parseTextureTypeInternal(textureType string) (model.TextureType, error) {
|
||||
return "", errors.New("无效的材质类型")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 管理员操作 ====================
|
||||
|
||||
// ListForAdmin 分页查询材质列表(管理员,含 Uploader 预加载)
|
||||
func (s *textureService) ListForAdmin(ctx context.Context, page, pageSize int) ([]*model.Texture, int64, error) {
|
||||
page, pageSize = NormalizePagination(page, pageSize)
|
||||
return s.textureRepo.ListForAdmin(ctx, page, pageSize)
|
||||
}
|
||||
|
||||
// AdminDelete 管理员删除材质(无权限校验)
|
||||
func (s *textureService) AdminDelete(ctx context.Context, textureID int64) error {
|
||||
texture, err := s.textureRepo.FindByIDForAdmin(ctx, textureID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if texture == nil {
|
||||
return apperrors.ErrTextureNotFound
|
||||
}
|
||||
return s.textureRepo.Delete(ctx, textureID)
|
||||
}
|
||||
|
||||
// IncrementDownload 增加下载计数(CustomSkinAPI 等场景使用)
|
||||
func (s *textureService) IncrementDownload(ctx context.Context, textureID int64) error {
|
||||
return s.textureRepo.IncrementDownloadCount(ctx, textureID)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,19 @@ package service
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// sha256Hex 计算给定数据的 SHA-256 十六进制字符串
|
||||
func sha256Hex(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// TestTextureService_TypeValidation 测试材质类型验证
|
||||
func TestTextureService_TypeValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -494,84 +502,87 @@ func TestTextureServiceImpl_Create(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), testUser)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
|
||||
|
||||
// 构造一份符合 service 大小校验(>=512B)的 PNG 文件数据
|
||||
pngFileData := make([]byte, 1024)
|
||||
for i := range pngFileData {
|
||||
pngFileData[i] = byte(i % 256)
|
||||
}
|
||||
// 计算其真实 SHA-256,用于 mock 预置与命中分支测试
|
||||
existingHash := sha256Hex(pngFileData)
|
||||
_ = textureRepo.Create(context.Background(), &model.Texture{
|
||||
ID: 100,
|
||||
UploaderID: 1,
|
||||
Name: "ExistingTexture",
|
||||
Hash: existingHash,
|
||||
URL: "https://example.com/existing.png",
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
uploaderID int64
|
||||
textureName string
|
||||
textureType string
|
||||
hash string
|
||||
fileData []byte
|
||||
wantErr bool
|
||||
errContains string
|
||||
setupMocks func()
|
||||
}{
|
||||
{
|
||||
name: "正常创建SKIN材质",
|
||||
name: " Hash 已存在 -> 复用 URL",
|
||||
uploaderID: 1,
|
||||
textureName: "TestSkin",
|
||||
textureType: "SKIN",
|
||||
hash: "unique-hash-1",
|
||||
fileData: pngFileData, // hash 命中预置记录
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "正常创建CAPE材质",
|
||||
name: "Hash 不存在且 storage 为 nil -> 存储不可用",
|
||||
uploaderID: 1,
|
||||
textureName: "TestCape",
|
||||
textureName: "NewCape",
|
||||
textureType: "CAPE",
|
||||
hash: "unique-hash-2",
|
||||
wantErr: false,
|
||||
fileData: make([]byte, 1024), // 不同内容,hash 不会命中
|
||||
wantErr: true,
|
||||
errContains: "存储服务不可用",
|
||||
},
|
||||
{
|
||||
name: "用户不存在",
|
||||
uploaderID: 999,
|
||||
textureName: "TestTexture",
|
||||
textureType: "SKIN",
|
||||
hash: "unique-hash-3",
|
||||
fileData: pngFileData,
|
||||
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",
|
||||
fileData: pngFileData,
|
||||
wantErr: true,
|
||||
errContains: "无效的材质类型",
|
||||
},
|
||||
{
|
||||
name: "文件过小",
|
||||
uploaderID: 1,
|
||||
textureName: "TooSmall",
|
||||
textureType: "SKIN",
|
||||
fileData: []byte("tiny"),
|
||||
wantErr: true,
|
||||
errContains: "文件大小必须在",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.setupMocks != nil {
|
||||
tt.setupMocks()
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
// UploadTexture需要文件数据,这里创建一个简单的测试数据
|
||||
fileData := []byte("fake png data for testing")
|
||||
texture, err := textureService.UploadTexture(
|
||||
ctx,
|
||||
tt.uploaderID,
|
||||
tt.textureName,
|
||||
"Test description",
|
||||
tt.textureType,
|
||||
fileData,
|
||||
tt.fileData,
|
||||
"test.png",
|
||||
true,
|
||||
false,
|
||||
@@ -585,18 +596,18 @@ func TestTextureServiceImpl_Create(t *testing.T) {
|
||||
if tt.errContains != "" && !containsString(err.Error(), tt.errContains) {
|
||||
t.Errorf("错误信息应包含 %q, 实际为: %v", tt.errContains, err.Error())
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("不期望返回错误: %v", err)
|
||||
return
|
||||
}
|
||||
if texture == nil {
|
||||
t.Error("返回的Texture不应为nil")
|
||||
t.Fatal("返回的Texture不应为nil")
|
||||
}
|
||||
if texture.Name != tt.textureName {
|
||||
t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -617,7 +628,7 @@ func TestTextureServiceImpl_GetByID(t *testing.T) {
|
||||
_ = textureRepo.Create(context.Background(), testTexture)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -675,7 +686,7 @@ func TestTextureServiceImpl_GetByUserID_And_Search(t *testing.T) {
|
||||
}
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -714,7 +725,7 @@ func TestTextureServiceImpl_Update_And_Delete(t *testing.T) {
|
||||
_ = textureRepo.Create(context.Background(), texture)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -764,7 +775,7 @@ func TestTextureServiceImpl_FavoritesAndLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -807,7 +818,7 @@ func TestTextureServiceImpl_ToggleFavorite(t *testing.T) {
|
||||
_ = textureRepo.Create(context.Background(), testTexture)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
"carrotskin/pkg/auth"
|
||||
"carrotskin/pkg/utils"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
"carrotskin/pkg/auth"
|
||||
"carrotskin/pkg/utils"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -173,8 +173,9 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
|
||||
}
|
||||
|
||||
if err := s.tokenStore.Store(ctx, accessToken, metadata, ttl); err != nil {
|
||||
s.logger.Warn("存储Token到Redis失败", zap.Error(err))
|
||||
// 不返回错误,因为JWT本身已经生成成功
|
||||
// Redis 存储失败必须返回错误:否则 Validate 拿不到元数据,Token 立即失效
|
||||
s.logger.Error("存储Token到Redis失败", zap.Error(err))
|
||||
return nil, nil, "", "", fmt.Errorf("存储Token失败: %w", err)
|
||||
}
|
||||
|
||||
return selectedProfileID, availableProfiles, accessToken, clientToken, nil
|
||||
@@ -473,7 +474,7 @@ func (s *tokenServiceRedis) validateProfileByUserID(ctx context.Context, userID
|
||||
|
||||
profile, err := s.profileRepo.FindByUUID(ctx, UUID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
if repository.IsNotFound(err) {
|
||||
return false, errors.New("配置文件不存在")
|
||||
}
|
||||
return false, fmt.Errorf("验证配置文件失败: %w", err)
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
|
||||
// userService UserService的实现
|
||||
type userService struct {
|
||||
cfg *config.Config
|
||||
userRepo repository.UserRepository
|
||||
jwtService *auth.JWTService
|
||||
redis *redis.Client
|
||||
@@ -38,6 +39,7 @@ type userService struct {
|
||||
|
||||
// NewUserService 创建UserService实例
|
||||
func NewUserService(
|
||||
cfg *config.Config,
|
||||
userRepo repository.UserRepository,
|
||||
jwtService *auth.JWTService,
|
||||
redisClient *redis.Client,
|
||||
@@ -48,6 +50,7 @@ func NewUserService(
|
||||
// CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀
|
||||
// 这样缓存键的格式为: CacheManager前缀 + CacheKeyBuilder生成的键
|
||||
return &userService{
|
||||
cfg: cfg,
|
||||
userRepo: userRepo,
|
||||
jwtService: jwtService,
|
||||
redis: redisClient,
|
||||
@@ -81,7 +84,7 @@ func (s *userService) Register(ctx context.Context, username, password, email, a
|
||||
// 加密密码
|
||||
hashedPassword, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("密码加密失败")
|
||||
return nil, "", fmt.Errorf("密码加密失败: %w", err)
|
||||
}
|
||||
|
||||
// 确定头像URL
|
||||
@@ -112,7 +115,7 @@ func (s *userService) Register(ctx context.Context, username, password, email, a
|
||||
// 生成JWT Token
|
||||
token, err := s.jwtService.GenerateToken(user.ID, user.Username, user.Role)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("生成Token失败")
|
||||
return nil, "", fmt.Errorf("生成Token失败: %w", err)
|
||||
}
|
||||
|
||||
return user, token, nil
|
||||
@@ -167,15 +170,20 @@ func (s *userService) Login(ctx context.Context, usernameOrEmail, password, ipAd
|
||||
// 生成JWT Token
|
||||
token, err := s.jwtService.GenerateToken(user.ID, user.Username, user.Role)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("生成Token失败")
|
||||
return nil, "", fmt.Errorf("生成Token失败: %w", err)
|
||||
}
|
||||
|
||||
// 更新最后登录时间
|
||||
// 更新最后登录时间(非关键路径,错误降级为 Warn)
|
||||
now := time.Now()
|
||||
user.LastLoginAt = &now
|
||||
_ = s.userRepo.UpdateFields(ctx, user.ID, map[string]interface{}{
|
||||
if err := s.userRepo.UpdateFields(ctx, user.ID, map[string]interface{}{
|
||||
"last_login_at": now,
|
||||
})
|
||||
}); err != nil {
|
||||
s.logger.Warn("更新最后登录时间失败",
|
||||
zap.Int64("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
// 记录成功登录日志
|
||||
s.logSuccessLogin(ctx, user.ID, ipAddress, userAgent)
|
||||
@@ -239,7 +247,10 @@ func (s *userService) UpdateAvatar(ctx context.Context, userID int64, avatarURL
|
||||
|
||||
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 {
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
|
||||
@@ -249,7 +260,7 @@ func (s *userService) ChangePassword(ctx context.Context, userID int64, oldPassw
|
||||
|
||||
hashedPassword, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return errors.New("密码加密失败")
|
||||
return fmt.Errorf("密码加密失败: %w", err)
|
||||
}
|
||||
|
||||
err = s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{
|
||||
@@ -267,13 +278,16 @@ func (s *userService) ChangePassword(ctx context.Context, userID int64, oldPassw
|
||||
|
||||
func (s *userService) ResetPassword(ctx context.Context, email, newPassword string) error {
|
||||
user, err := s.userRepo.FindByEmail(ctx, email)
|
||||
if err != nil || user == nil {
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
|
||||
hashedPassword, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return errors.New("密码加密失败")
|
||||
return fmt.Errorf("密码加密失败: %w", err)
|
||||
}
|
||||
|
||||
err = s.userRepo.UpdateFields(ctx, user.ID, map[string]interface{}{
|
||||
@@ -350,14 +364,13 @@ func (s *userService) ValidateAvatarURL(ctx context.Context, avatarURL string) e
|
||||
return errors.New("URL缺少主机名")
|
||||
}
|
||||
|
||||
// 从配置获取允许的域名列表
|
||||
cfg, err := config.GetConfig()
|
||||
if err != nil {
|
||||
allowedDomains := []string{"localhost", "127.0.0.1"}
|
||||
return s.checkDomainAllowed(host, allowedDomains)
|
||||
// 从注入的配置获取允许的域名列表
|
||||
allowedDomains := s.cfg.Security.AllowedDomains
|
||||
if len(allowedDomains) == 0 {
|
||||
allowedDomains = []string{"localhost", "127.0.0.1"}
|
||||
}
|
||||
|
||||
return s.checkDomainAllowed(host, cfg.Security.AllowedDomains)
|
||||
return s.checkDomainAllowed(host, allowedDomains)
|
||||
}
|
||||
|
||||
func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error) {
|
||||
@@ -422,29 +435,23 @@ func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData [
|
||||
}
|
||||
|
||||
func (s *userService) GetMaxProfilesPerUser() int {
|
||||
cfg, err := config.GetConfig()
|
||||
if err != nil || cfg.Site.MaxProfilesPerUser <= 0 {
|
||||
if s.cfg.Site.MaxProfilesPerUser <= 0 {
|
||||
return 5
|
||||
}
|
||||
return cfg.Site.MaxProfilesPerUser
|
||||
return s.cfg.Site.MaxProfilesPerUser
|
||||
}
|
||||
|
||||
func (s *userService) GetMaxTexturesPerUser() int {
|
||||
cfg, err := config.GetConfig()
|
||||
if err != nil || cfg.Site.MaxTexturesPerUser <= 0 {
|
||||
if s.cfg.Site.MaxTexturesPerUser <= 0 {
|
||||
return 50
|
||||
}
|
||||
return cfg.Site.MaxTexturesPerUser
|
||||
return s.cfg.Site.MaxTexturesPerUser
|
||||
}
|
||||
|
||||
// 私有辅助方法
|
||||
|
||||
func (s *userService) getDefaultAvatar() string {
|
||||
cfg, err := config.GetConfig()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.Site.DefaultAvatar
|
||||
return s.cfg.Site.DefaultAvatar
|
||||
}
|
||||
|
||||
func (s *userService) checkDomainAllowed(host string, allowedDomains []string) error {
|
||||
@@ -505,3 +512,21 @@ func (s *userService) logFailedLogin(ctx context.Context, userID int64, ipAddres
|
||||
}
|
||||
_ = s.userRepo.CreateLoginLog(ctx, log)
|
||||
}
|
||||
|
||||
// ==================== 管理员操作 ====================
|
||||
|
||||
// ListUsers 分页查询用户列表(管理员)
|
||||
func (s *userService) ListUsers(ctx context.Context, page, pageSize int) ([]*model.User, int64, error) {
|
||||
page, pageSize = NormalizePagination(page, pageSize)
|
||||
return s.userRepo.List(ctx, page, pageSize)
|
||||
}
|
||||
|
||||
// SetRole 设置用户角色(管理员)
|
||||
func (s *userService) SetRole(ctx context.Context, userID int64, role string) error {
|
||||
return s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{"role": role})
|
||||
}
|
||||
|
||||
// SetStatus 设置用户状态(管理员)
|
||||
func (s *userService) SetStatus(ctx context.Context, userID int64, status int16) error {
|
||||
return s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{"status": status})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/pkg/auth"
|
||||
"carrotskin/pkg/config"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
@@ -14,11 +15,12 @@ func TestUserServiceImpl_Register(t *testing.T) {
|
||||
userRepo := NewMockUserRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
cfg := &config.Config{}
|
||||
|
||||
// 初始化Service
|
||||
// 注意:redisClient 和 storageClient 传入 nil,因为 Register 方法中没有使用它们
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
userService := NewUserService(cfg, userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -128,7 +130,8 @@ func TestUserServiceImpl_Login(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), testUser)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
cfg := &config.Config{}
|
||||
userService := NewUserService(cfg, userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -208,7 +211,7 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), user)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -255,7 +258,7 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), user)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -289,7 +292,7 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), user)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -316,7 +319,7 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), user2)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -338,7 +341,7 @@ func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -374,7 +377,7 @@ func TestUserServiceImpl_MaxLimits(t *testing.T) {
|
||||
|
||||
// 未配置时走默认值
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
if got := userService.GetMaxProfilesPerUser(); got != 5 {
|
||||
t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got)
|
||||
}
|
||||
|
||||
@@ -26,16 +26,19 @@ const (
|
||||
|
||||
// verificationService VerificationService的实现
|
||||
type verificationService struct {
|
||||
cfg *config.Config
|
||||
redis *redis.Client
|
||||
emailService *email.Service
|
||||
}
|
||||
|
||||
// NewVerificationService 创建VerificationService实例
|
||||
func NewVerificationService(
|
||||
cfg *config.Config,
|
||||
redisClient *redis.Client,
|
||||
emailService *email.Service,
|
||||
) VerificationService {
|
||||
return &verificationService{
|
||||
cfg: cfg,
|
||||
redis: redisClient,
|
||||
emailService: emailService,
|
||||
}
|
||||
@@ -44,8 +47,7 @@ func NewVerificationService(
|
||||
// SendCode 发送验证码
|
||||
func (s *verificationService) SendCode(ctx context.Context, email, codeType string) error {
|
||||
// 测试环境下直接跳过,不存储也不发送
|
||||
cfg, err := config.GetConfig()
|
||||
if err == nil && cfg.IsTestEnvironment() {
|
||||
if s.cfg.IsTestEnvironment() {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -89,8 +91,7 @@ func (s *verificationService) SendCode(ctx context.Context, email, codeType stri
|
||||
// VerifyCode 验证验证码
|
||||
func (s *verificationService) VerifyCode(ctx context.Context, email, code, codeType string) error {
|
||||
// 测试环境下直接通过验证
|
||||
cfg, err := config.GetConfig()
|
||||
if err == nil && cfg.IsTestEnvironment() {
|
||||
if s.cfg.IsTestEnvironment() {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -166,8 +167,5 @@ func (s *verificationService) sendEmail(to, code, codeType string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// DeleteVerificationCode 已移除:重构后无调用方。如需删除验证码,请使用
|
||||
// VerificationService 实现内的 redis.Del 路径。
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
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
|
||||
@@ -24,13 +22,11 @@ type yggdrasilAuthService struct {
|
||||
|
||||
// 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,
|
||||
@@ -78,7 +74,7 @@ func (s *yggdrasilAuthService) ResetYggdrasilPassword(ctx context.Context, userI
|
||||
ID: userID,
|
||||
Password: hashedPassword,
|
||||
}
|
||||
if err := s.db.Create(&yggdrasil).Error; err != nil {
|
||||
if err := s.yggdrasilRepo.Create(ctx, &yggdrasil); err != nil {
|
||||
return "", fmt.Errorf("创建Yggdrasil密码失败: %w", err)
|
||||
}
|
||||
return plainPassword, nil
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
apperrors "carrotskin/internal/errors"
|
||||
"carrotskin/internal/repository"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
apperrors "carrotskin/internal/errors"
|
||||
"carrotskin/internal/repository"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CertificateService 证书服务接口
|
||||
type CertificateService interface {
|
||||
// GeneratePlayerCertificate 生成玩家证书
|
||||
GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error)
|
||||
GeneratePlayerCertificate(ctx context.Context, uuid string) (*PlayerCertificate, error)
|
||||
// GetPublicKey 获取公钥
|
||||
GetPublicKey(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// PlayerCertificate 玩家证书
|
||||
type PlayerCertificate struct {
|
||||
KeyPair PlayerKeyPair `json:"keyPair"`
|
||||
PublicKeySignature string `json:"publicKeySignature"`
|
||||
PublicKeySignatureV2 string `json:"publicKeySignatureV2"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
RefreshedAfter int64 `json:"refreshedAfter"`
|
||||
}
|
||||
|
||||
// PlayerKeyPair 玩家证书密钥对
|
||||
type PlayerKeyPair struct {
|
||||
PrivateKey string `json:"privateKey"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
}
|
||||
|
||||
// yggdrasilCertificateService 证书服务实现
|
||||
type yggdrasilCertificateService struct {
|
||||
profileRepo repository.ProfileRepository
|
||||
@@ -39,7 +55,7 @@ func NewCertificateService(
|
||||
}
|
||||
|
||||
// GeneratePlayerCertificate 生成玩家证书
|
||||
func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error) {
|
||||
func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Context, uuid string) (*PlayerCertificate, error) {
|
||||
if uuid == "" {
|
||||
return nil, apperrors.ErrUUIDRequired
|
||||
}
|
||||
@@ -64,7 +80,7 @@ func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Cont
|
||||
s.logger.Info("为用户创建新的密钥对",
|
||||
zap.String("uuid", uuid),
|
||||
)
|
||||
keyPair, err = s.signatureService.NewKeyPair()
|
||||
keyPair, err = s.signatureService.NewKeyPair(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("生成玩家证书密钥对失败",
|
||||
zap.Error(err),
|
||||
@@ -88,15 +104,15 @@ func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Cont
|
||||
expiresAtMillis := keyPair.Expiration.UnixMilli()
|
||||
|
||||
// 返回玩家证书
|
||||
certificate := map[string]interface{}{
|
||||
"keyPair": map[string]interface{}{
|
||||
"privateKey": keyPair.PrivateKey,
|
||||
"publicKey": keyPair.PublicKey,
|
||||
certificate := &PlayerCertificate{
|
||||
KeyPair: PlayerKeyPair{
|
||||
PrivateKey: keyPair.PrivateKey,
|
||||
PublicKey: keyPair.PublicKey,
|
||||
},
|
||||
"publicKeySignature": keyPair.PublicKeySignature,
|
||||
"publicKeySignatureV2": keyPair.PublicKeySignatureV2,
|
||||
"expiresAt": expiresAtMillis,
|
||||
"refreshedAfter": keyPair.Refresh.UnixMilli(),
|
||||
PublicKeySignature: keyPair.PublicKeySignature,
|
||||
PublicKeySignatureV2: keyPair.PublicKeySignatureV2,
|
||||
ExpiresAt: expiresAtMillis,
|
||||
RefreshedAfter: keyPair.Refresh.UnixMilli(),
|
||||
}
|
||||
|
||||
s.logger.Info("成功生成玩家证书",
|
||||
@@ -107,6 +123,5 @@ func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Cont
|
||||
|
||||
// GetPublicKey 获取公钥
|
||||
func (s *yggdrasilCertificateService) GetPublicKey(ctx context.Context) (string, error) {
|
||||
return s.signatureService.GetPublicKeyFromRedis()
|
||||
return s.signatureService.GetPublicKeyFromRedis(ctx)
|
||||
}
|
||||
|
||||
|
||||
511
internal/service/yggdrasil_friends_service.go
Normal file
511
internal/service/yggdrasil_friends_service.go
Normal file
@@ -0,0 +1,511 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "carrotskin/internal/errors"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
"carrotskin/pkg/redis"
|
||||
"carrotskin/pkg/utils"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// PresenceKeyPrefix 在线状态 Redis 键前缀,key 形如 presence:<profileUUID>
|
||||
PresenceKeyPrefix = "presence:"
|
||||
// PresenceTTL 在线状态过期时间;客户端最长 5 分钟必发一次心跳(文档 1.1)
|
||||
PresenceTTL = 5 * time.Minute
|
||||
// BlocklistCacheTTL 屏蔽列表缓存冷却时间(文档 1.6:客户端 120 秒节流)
|
||||
BlocklistCacheTTL = 120 * time.Second
|
||||
// BlocklistCacheKeyPrefix 屏蔽列表缓存键前缀
|
||||
BlocklistCacheKeyPrefix = "blocklist:"
|
||||
|
||||
// PresenceStatusOffline 离线状态固定值(文档 1.1)
|
||||
PresenceStatusOffline = "OFFLINE"
|
||||
)
|
||||
|
||||
// friendsService 好友系统服务实现
|
||||
type friendsService struct {
|
||||
friendRepo repository.FriendRepository
|
||||
attrRepo repository.PlayerAttributeRepository
|
||||
profileRepo repository.ProfileRepository
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
presenceTTL time.Duration
|
||||
blocklistTTL time.Duration
|
||||
}
|
||||
|
||||
// NewFriendsService 创建好友系统服务实例
|
||||
func NewFriendsService(
|
||||
friendRepo repository.FriendRepository,
|
||||
attrRepo repository.PlayerAttributeRepository,
|
||||
profileRepo repository.ProfileRepository,
|
||||
redisClient *redis.Client,
|
||||
logger *zap.Logger,
|
||||
) FriendsService {
|
||||
return &friendsService{
|
||||
friendRepo: friendRepo,
|
||||
attrRepo: attrRepo,
|
||||
profileRepo: profileRepo,
|
||||
redis: redisClient,
|
||||
logger: logger,
|
||||
presenceTTL: PresenceTTL,
|
||||
blocklistTTL: BlocklistCacheTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 好友列表与操作
|
||||
// ============================================================================
|
||||
|
||||
// ListFriends 获取好友列表(文档 1.3.1)
|
||||
func (s *friendsService) ListFriends(ctx context.Context, requesterUUID string) (*FriendsListResponse, error) {
|
||||
accepted, err := s.friendRepo.ListAccepted(ctx, requesterUUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询好友列表失败: %w", err)
|
||||
}
|
||||
incoming, err := s.friendRepo.ListIncoming(ctx, requesterUUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询收到的请求失败: %w", err)
|
||||
}
|
||||
outgoing, err := s.friendRepo.ListOutgoing(ctx, requesterUUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询发出的请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 批量解析 profile name
|
||||
friends, err := s.toFriendDTOs(ctx, accepted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
incomingDTOs, err := s.toFriendDTOs(ctx, incoming)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outgoingDTOs, err := s.toFriendDTOs(ctx, outgoing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &FriendsListResponse{
|
||||
Friends: friends,
|
||||
IncomingRequests: incomingDTOs,
|
||||
OutgoingRequests: outgoingDTOs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PerformFriendAction 执行好友加/删操作(文档 1.3.2)
|
||||
func (s *friendsService) PerformFriendAction(ctx context.Context, requesterUUID string, req FriendActionRequest) (*FriendsListResponse, error) {
|
||||
switch req.UpdateType {
|
||||
case FriendActionAdd:
|
||||
if err := s.addFriend(ctx, requesterUUID, req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case FriendActionRemove:
|
||||
if err := s.removeFriend(ctx, requesterUUID, req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, apperrors.NewBadRequest("无效的 updateType", nil)
|
||||
}
|
||||
// 文档 1.3.2:PUT 成功后返回最新好友列表
|
||||
return s.ListFriends(ctx, requesterUUID)
|
||||
}
|
||||
|
||||
// addFriend 添加好友 / 发送请求 / 接受请求(文档 1.3.2 ADD)
|
||||
func (s *friendsService) addFriend(ctx context.Context, requesterUUID string, req FriendActionRequest) error {
|
||||
targetUUID, err := s.resolveTargetUUID(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 校验目标存在
|
||||
targetProfile, err := s.profileRepo.FindByUUID(ctx, targetUUID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil)
|
||||
}
|
||||
return fmt.Errorf("查询目标档案失败: %w", err)
|
||||
}
|
||||
if targetProfile == nil {
|
||||
return apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil)
|
||||
}
|
||||
|
||||
// 不能添加自己
|
||||
if requesterUUID == targetUUID {
|
||||
return apperrors.NewBadRequest(apperrors.FriendsErrCannotAddSelf, nil)
|
||||
}
|
||||
|
||||
// 检查 requester->target 已有记录
|
||||
existing, err := s.friendRepo.FindRelation(ctx, requesterUUID, targetUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询已有关系失败: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
switch existing.Status {
|
||||
case model.FriendsStatusAccepted:
|
||||
// 已是好友,幂等返回成功
|
||||
return nil
|
||||
case model.FriendsStatusBlocked:
|
||||
// requester 屏蔽了 target,解除屏蔽并建好友
|
||||
if err := s.friendRepo.Delete(ctx, existing.ID); err != nil {
|
||||
return fmt.Errorf("解除屏蔽失败: %w", err)
|
||||
}
|
||||
case model.FriendsStatusPending:
|
||||
// 已发过请求,幂等返回
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 target->requester 是否已向我发出 pending 请求 -> 自动接受
|
||||
reverse, err := s.friendRepo.FindRelation(ctx, targetUUID, requesterUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询反向关系失败: %w", err)
|
||||
}
|
||||
if reverse != nil {
|
||||
switch reverse.Status {
|
||||
case model.FriendsStatusPending:
|
||||
// 对方已向我发起请求,直接互为好友(更新 A→B 为 accepted 即可,
|
||||
// ListAccepted 会合并两方向,无需补建 B→A 以避免好友列表重复)
|
||||
if err := s.friendRepo.UpdateStatus(ctx, reverse.ID, model.FriendsStatusAccepted); err != nil {
|
||||
return fmt.Errorf("接受好友请求失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
case model.FriendsStatusAccepted:
|
||||
// 对方已记录为好友(数据修复场景),确保 requester->target 也存在 accepted 记录
|
||||
if existing == nil {
|
||||
if err := s.friendRepo.Create(ctx, &model.Friend{
|
||||
RequesterUUID: requesterUUID,
|
||||
TargetUUID: targetUUID,
|
||||
Status: model.FriendsStatusAccepted,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("创建好友记录失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case model.FriendsStatusBlocked:
|
||||
// 对方屏蔽了我,不接受好友请求
|
||||
return apperrors.NewForbidden("对方已屏蔽你")
|
||||
}
|
||||
}
|
||||
|
||||
// 普通发送请求
|
||||
if existing == nil {
|
||||
if err := s.friendRepo.Create(ctx, &model.Friend{
|
||||
RequesterUUID: requesterUUID,
|
||||
TargetUUID: targetUUID,
|
||||
Status: model.FriendsStatusPending,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("发送好友请求失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeFriend 删除好友 / 拒绝请求 / 撤回请求(文档 1.3.2 REMOVE)
|
||||
func (s *friendsService) removeFriend(ctx context.Context, requesterUUID string, req FriendActionRequest) error {
|
||||
targetUUID, err := s.resolveTargetUUID(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 删除 requester->target 记录(涵盖撤回发出的请求、删除好友)
|
||||
if rel, err := s.friendRepo.FindRelation(ctx, requesterUUID, targetUUID); err != nil {
|
||||
return fmt.Errorf("查询关系失败: %w", err)
|
||||
} else if rel != nil {
|
||||
if err := s.friendRepo.Delete(ctx, rel.ID); err != nil {
|
||||
return fmt.Errorf("删除关系失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 拒绝收到的请求时(requester 作为 target 方),删除 target->requester 的 pending 记录
|
||||
if rel, err := s.friendRepo.FindRelation(ctx, targetUUID, requesterUUID); err != nil {
|
||||
return fmt.Errorf("查询反向关系失败: %w", err)
|
||||
} else if rel != nil && rel.Status == model.FriendsStatusPending {
|
||||
if err := s.friendRepo.Delete(ctx, rel.ID); err != nil {
|
||||
return fmt.Errorf("拒绝请求失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveTargetUUID 解析请求目标 UUID:profileId 优先,否则用 name 查 Profile
|
||||
func (s *friendsService) resolveTargetUUID(ctx context.Context, req FriendActionRequest) (string, error) {
|
||||
if req.ProfileID != "" {
|
||||
return utils.FormatUUIDToNoDash(req.ProfileID), nil
|
||||
}
|
||||
if req.Name != "" {
|
||||
profile, err := s.profileRepo.FindByName(ctx, req.Name)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil)
|
||||
}
|
||||
return "", fmt.Errorf("查询目标档案失败: %w", err)
|
||||
}
|
||||
if profile == nil {
|
||||
return "", apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil)
|
||||
}
|
||||
return profile.UUID, nil
|
||||
}
|
||||
return "", apperrors.NewBadRequest(apperrors.FriendsErrUnknownProfile, nil)
|
||||
}
|
||||
|
||||
// toFriendDTOs 将 UUID 列表批量解析为 FriendDTO(补 name)
|
||||
func (s *friendsService) toFriendDTOs(ctx context.Context, uuids []string) ([]FriendDTO, error) {
|
||||
if len(uuids) == 0 {
|
||||
return []FriendDTO{}, nil
|
||||
}
|
||||
profiles, err := s.profileRepo.FindByUUIDs(ctx, uuids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("批量查询档案失败: %w", err)
|
||||
}
|
||||
nameByUUID := make(map[string]string, len(profiles))
|
||||
for _, p := range profiles {
|
||||
if p != nil {
|
||||
nameByUUID[p.UUID] = p.Name
|
||||
}
|
||||
}
|
||||
result := make([]FriendDTO, 0, len(uuids))
|
||||
for _, uid := range uuids {
|
||||
result = append(result, FriendDTO{
|
||||
ProfileID: uid,
|
||||
Name: nameByUUID[uid], // 找不到 name 时留空
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 玩家偏好属性
|
||||
// ============================================================================
|
||||
|
||||
// GetAttributes 获取玩家属性(文档 1.2.1)
|
||||
func (s *friendsService) GetAttributes(ctx context.Context, profileUUID string) (*PlayerAttributesResponse, error) {
|
||||
attr, err := s.attrRepo.Get(ctx, profileUUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询玩家偏好失败: %w", err)
|
||||
}
|
||||
if attr == nil {
|
||||
d := model.DefaultPlayerAttributes(profileUUID)
|
||||
attr = &d
|
||||
}
|
||||
return &PlayerAttributesResponse{
|
||||
ProfanityFilterPreferences: ProfanityFilterPreferencesResp{
|
||||
ProfanityFilterOn: attr.ProfanityFilterOn,
|
||||
},
|
||||
FriendsPreferences: FriendsPreferencesResp{
|
||||
Friends: enabledToString(attr.FriendsEnabled),
|
||||
AcceptInvites: enabledToString(attr.AcceptInvites),
|
||||
},
|
||||
ChatPreferences: ChatPreferencesResp{
|
||||
TextCommunication: attr.TextCommunication,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateAttributes 更新玩家偏好(文档 1.2.2)
|
||||
func (s *friendsService) UpdateAttributes(ctx context.Context, profileUUID string, req PlayerAttributesRequest) error {
|
||||
// 先读取现有值(保留未传字段)
|
||||
attr, err := s.attrRepo.Get(ctx, profileUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询玩家偏好失败: %w", err)
|
||||
}
|
||||
if attr == nil {
|
||||
d := model.DefaultPlayerAttributes(profileUUID)
|
||||
attr = &d
|
||||
}
|
||||
|
||||
if req.ProfanityFilterPreferences != nil && req.ProfanityFilterPreferences.ProfanityFilterOn != nil {
|
||||
attr.ProfanityFilterOn = *req.ProfanityFilterPreferences.ProfanityFilterOn
|
||||
}
|
||||
if req.FriendsPreferences != nil {
|
||||
if req.FriendsPreferences.Friends != nil {
|
||||
attr.FriendsEnabled = *req.FriendsPreferences.Friends == "ENABLED"
|
||||
}
|
||||
if req.FriendsPreferences.AcceptInvites != nil {
|
||||
attr.AcceptInvites = *req.FriendsPreferences.AcceptInvites == "ENABLED"
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.attrRepo.Upsert(ctx, attr); err != nil {
|
||||
return fmt.Errorf("更新玩家偏好失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enabledToString 将布尔偏好转为 ENABLED/DISABLED 字符串
|
||||
func enabledToString(enabled bool) string {
|
||||
if enabled {
|
||||
return "ENABLED"
|
||||
}
|
||||
return "DISABLED"
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 在线状态
|
||||
// ============================================================================
|
||||
|
||||
// UpdatePresence 上报自身在线状态并返回好友在线状态(文档 1.1)
|
||||
func (s *friendsService) UpdatePresence(ctx context.Context, requesterUUID string, status string) (*PresenceResponse, error) {
|
||||
// 写入自身状态到 Redis
|
||||
key := PresenceKeyPrefix + requesterUUID
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
// 存储 "status|lastUpdated",便于读取时一并恢复时间戳
|
||||
value := status + "|" + now
|
||||
if err := s.redis.Set(ctx, key, value, s.presenceTTL); err != nil {
|
||||
s.logger.Error("写入在线状态失败",
|
||||
zap.Error(err),
|
||||
zap.String("profileUUID", requesterUUID),
|
||||
)
|
||||
return nil, fmt.Errorf("写入在线状态失败: %w", err)
|
||||
}
|
||||
|
||||
// 拉取好友 UUID 列表
|
||||
friendUUIDs, err := s.friendRepo.ListAccepted(ctx, requesterUUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询好友列表失败: %w", err)
|
||||
}
|
||||
|
||||
entries := s.batchGetPresence(ctx, friendUUIDs)
|
||||
// 文档 1.1:服务器仅返回非离线玩家
|
||||
filtered := make([]PresenceEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.Status != "" && e.Status != PresenceStatusOffline {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
return &PresenceResponse{Presence: filtered}, nil
|
||||
}
|
||||
|
||||
// batchGetPresence 批量读取多个 profileUUID 的在线状态
|
||||
func (s *friendsService) batchGetPresence(ctx context.Context, uuids []string) []PresenceEntry {
|
||||
if len(uuids) == 0 || s.redis == nil {
|
||||
return []PresenceEntry{}
|
||||
}
|
||||
keys := make([]string, 0, len(uuids))
|
||||
for _, uid := range uuids {
|
||||
keys = append(keys, PresenceKeyPrefix+uid)
|
||||
}
|
||||
// 使用底层 go-redis 客户端的 MGet 一次读取
|
||||
results, err := s.redis.Client.MGet(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
s.logger.Warn("批量读取在线状态失败", zap.Error(err))
|
||||
return []PresenceEntry{}
|
||||
}
|
||||
|
||||
entries := make([]PresenceEntry, 0, len(uuids))
|
||||
for i, uid := range uuids {
|
||||
var entry PresenceEntry
|
||||
entry.ProfileID = uid
|
||||
if i < len(results) && results[i] != nil {
|
||||
if raw, ok := results[i].(string); ok && raw != "" {
|
||||
parts := strings.SplitN(raw, "|", 2)
|
||||
entry.Status = parts[0]
|
||||
if len(parts) == 2 {
|
||||
entry.LastUpdated = parts[1]
|
||||
} else {
|
||||
entry.LastUpdated = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
}
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 屏蔽列表
|
||||
// ============================================================================
|
||||
|
||||
// GetBlocklist 获取屏蔽列表(文档 1.6,含 120s 客户端节流缓存)
|
||||
func (s *friendsService) GetBlocklist(ctx context.Context, blockerUUID string) (*BlockListResponse, error) {
|
||||
// 文档 1.6:客户端 120s 节流;服务端在此也做一层缓存,避免重复查库
|
||||
cacheKey := BlocklistCacheKeyPrefix + blockerUUID
|
||||
if s.redis != nil {
|
||||
if cached, err := s.redis.GetBytes(ctx, cacheKey); err == nil && len(cached) > 0 {
|
||||
var resp BlockListResponse
|
||||
if err := json.Unmarshal(cached, &resp); err == nil {
|
||||
return &resp, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blocked, err := s.friendRepo.ListBlocked(ctx, blockerUUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询屏蔽列表失败: %w", err)
|
||||
}
|
||||
if blocked == nil {
|
||||
blocked = []string{}
|
||||
}
|
||||
resp := &BlockListResponse{BlockedProfiles: blocked}
|
||||
|
||||
// 写入缓存
|
||||
if s.redis != nil {
|
||||
if data, err := json.Marshal(resp); err == nil {
|
||||
if err := s.redis.Set(ctx, cacheKey, data, s.blocklistTTL); err != nil {
|
||||
s.logger.Warn("缓存屏蔽列表失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 屏蔽/取消屏蔽方法(供后续/管理端调用,当前协议未定义对应端点)
|
||||
|
||||
// Block 屏蔽目标玩家
|
||||
func (s *friendsService) Block(ctx context.Context, blockerUUID, targetUUID string) error {
|
||||
if blockerUUID == targetUUID {
|
||||
return apperrors.NewBadRequest(apperrors.FriendsErrCannotAddSelf, nil)
|
||||
}
|
||||
// 若已有关系记录,更新为 blocked;否则新建
|
||||
if rel, err := s.friendRepo.FindRelation(ctx, blockerUUID, targetUUID); err != nil {
|
||||
return fmt.Errorf("查询关系失败: %w", err)
|
||||
} else if rel != nil {
|
||||
if err := s.friendRepo.UpdateStatus(ctx, rel.ID, model.FriendsStatusBlocked); err != nil {
|
||||
return fmt.Errorf("更新屏蔽状态失败: %w", err)
|
||||
}
|
||||
return s.invalidateBlocklistCache(ctx, blockerUUID)
|
||||
}
|
||||
if err := s.friendRepo.Create(ctx, &model.Friend{
|
||||
RequesterUUID: blockerUUID,
|
||||
TargetUUID: targetUUID,
|
||||
Status: model.FriendsStatusBlocked,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("创建屏蔽记录失败: %w", err)
|
||||
}
|
||||
return s.invalidateBlocklistCache(ctx, blockerUUID)
|
||||
}
|
||||
|
||||
// Unblock 取消屏蔽
|
||||
func (s *friendsService) Unblock(ctx context.Context, blockerUUID, targetUUID string) error {
|
||||
rel, err := s.friendRepo.FindRelation(ctx, blockerUUID, targetUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询关系失败: %w", err)
|
||||
}
|
||||
if rel == nil || rel.Status != model.FriendsStatusBlocked {
|
||||
// 幂等:未屏蔽时直接返回
|
||||
return nil
|
||||
}
|
||||
if err := s.friendRepo.Delete(ctx, rel.ID); err != nil {
|
||||
return fmt.Errorf("取消屏蔽失败: %w", err)
|
||||
}
|
||||
return s.invalidateBlocklistCache(ctx, blockerUUID)
|
||||
}
|
||||
|
||||
// invalidateBlocklistCache 清除屏蔽列表缓存
|
||||
func (s *friendsService) invalidateBlocklistCache(ctx context.Context, blockerUUID string) error {
|
||||
if s.redis == nil {
|
||||
return nil
|
||||
}
|
||||
return s.redis.Del(ctx, BlocklistCacheKeyPrefix+blockerUUID)
|
||||
}
|
||||
|
||||
// 确保引用了 go-redis(MGet 通过 s.redis.Client)
|
||||
var _ = redis.Client{}
|
||||
260
internal/service/yggdrasil_friends_service_test.go
Normal file
260
internal/service/yggdrasil_friends_service_test.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
"carrotskin/pkg/utils"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// newFriendsTestDB 构建内存 sqlite 并迁移所需表
|
||||
func newFriendsTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Profile{}, &model.Friend{}, &model.PlayerAttributes{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
// 清理共享内存库的残留数据
|
||||
db.Exec("DELETE FROM friends")
|
||||
db.Exec("DELETE FROM profiles")
|
||||
db.Exec("DELETE FROM player_attributes")
|
||||
return db
|
||||
}
|
||||
|
||||
// newFriendsService 用真实 GORM repo + nil redis 构造服务(presence/blocklist 缓存路径会跳过)
|
||||
func newFriendsService(t *testing.T) (FriendsService, *gorm.DB) {
|
||||
t.Helper()
|
||||
db := newFriendsTestDB(t)
|
||||
friendRepo := repository.NewFriendRepository(db)
|
||||
attrRepo := repository.NewPlayerAttributeRepository(db)
|
||||
profileRepo := repository.NewProfileRepository(db)
|
||||
return NewFriendsService(friendRepo, attrRepo, profileRepo, nil, zap.NewNop()), db
|
||||
}
|
||||
|
||||
func mustCreateProfile(t *testing.T, db *gorm.DB, name string) *model.Profile {
|
||||
t.Helper()
|
||||
p := &model.Profile{
|
||||
UUID: utils.GenerateUUID(),
|
||||
UserID: 1,
|
||||
Name: name,
|
||||
RSAPrivateKey: "x",
|
||||
}
|
||||
if err := db.Create(p).Error; err != nil {
|
||||
t.Fatalf("create profile %s: %v", name, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// TestAddFriend_SelfReject 添加自己应报错
|
||||
func TestAddFriend_SelfReject(t *testing.T) {
|
||||
svc, db := newFriendsService(t)
|
||||
p := mustCreateProfile(t, db, "self")
|
||||
_, err := svc.PerformFriendAction(context.Background(), p.UUID, FriendActionRequest{
|
||||
ProfileID: p.UUID,
|
||||
UpdateType: FriendActionAdd,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("添加自己应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddFriend_UnknownProfile 目标不存在应报错
|
||||
func TestAddFriend_UnknownProfile(t *testing.T) {
|
||||
svc, db := newFriendsService(t)
|
||||
p := mustCreateProfile(t, db, "a")
|
||||
// 使用 32 位无连字符 UUID,规避 FormatUUIDToNoDash 对非法长度的告警路径
|
||||
_, err := svc.PerformFriendAction(context.Background(), p.UUID, FriendActionRequest{
|
||||
ProfileID: "ffffffffffffffffffffffffffffffff",
|
||||
UpdateType: FriendActionAdd,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("不存在的目标应报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddFriend_SendRequestThenAccept 双向流程:A 发请求 -> B 接受 -> 互为好友
|
||||
func TestAddFriend_SendRequestThenAccept(t *testing.T) {
|
||||
svc, db := newFriendsService(t)
|
||||
a := mustCreateProfile(t, db, "alice")
|
||||
b := mustCreateProfile(t, db, "bob")
|
||||
|
||||
// A 向 B 发起请求
|
||||
resp, err := svc.PerformFriendAction(context.Background(), a.UUID, FriendActionRequest{
|
||||
ProfileID: b.UUID,
|
||||
UpdateType: FriendActionAdd,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("A 发起请求失败: %v", err)
|
||||
}
|
||||
if len(resp.OutgoingRequests) != 1 || resp.OutgoingRequests[0].ProfileID != b.UUID {
|
||||
t.Fatalf("A 的 outgoing 应包含 B, got %+v", resp.OutgoingRequests)
|
||||
}
|
||||
|
||||
// B 视角:incoming 应包含 A
|
||||
respB, err := svc.ListFriends(context.Background(), b.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("B 查询列表失败: %v", err)
|
||||
}
|
||||
if len(respB.IncomingRequests) != 1 || respB.IncomingRequests[0].ProfileID != a.UUID {
|
||||
t.Fatalf("B 的 incoming 应包含 A, got %+v", respB.IncomingRequests)
|
||||
}
|
||||
|
||||
// B 接受请求(B 用 ADD 指向 A -> 自动接受)
|
||||
resp, err = svc.PerformFriendAction(context.Background(), b.UUID, FriendActionRequest{
|
||||
ProfileID: a.UUID,
|
||||
UpdateType: FriendActionAdd,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("B 接受请求失败: %v", err)
|
||||
}
|
||||
if len(resp.Friends) != 1 || resp.Friends[0].ProfileID != a.UUID {
|
||||
t.Fatalf("B 的 friends 应包含 A, got %+v", resp.Friends)
|
||||
}
|
||||
|
||||
// A 视角:也是好友
|
||||
respA, err := svc.ListFriends(context.Background(), a.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("A 查询列表失败: %v", err)
|
||||
}
|
||||
if len(respA.Friends) != 1 || respA.Friends[0].ProfileID != b.UUID {
|
||||
t.Fatalf("A 的 friends 应包含 B, got %+v", respA.Friends)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveFriend_Flow A B 互为好友后 A 删除好友 -> 双方都无好友
|
||||
func TestRemoveFriend_Flow(t *testing.T) {
|
||||
svc, db := newFriendsService(t)
|
||||
a := mustCreateProfile(t, db, "alice")
|
||||
b := mustCreateProfile(t, db, "bob")
|
||||
|
||||
// 互为好友
|
||||
if _, err := svc.PerformFriendAction(context.Background(), a.UUID, FriendActionRequest{ProfileID: b.UUID, UpdateType: FriendActionAdd}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.PerformFriendAction(context.Background(), b.UUID, FriendActionRequest{ProfileID: a.UUID, UpdateType: FriendActionAdd}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A 删除好友
|
||||
if _, err := svc.PerformFriendAction(context.Background(), a.UUID, FriendActionRequest{ProfileID: b.UUID, UpdateType: FriendActionRemove}); err != nil {
|
||||
t.Fatalf("A 删除好友失败: %v", err)
|
||||
}
|
||||
|
||||
// B 视角应不再有好友
|
||||
respB, err := svc.ListFriends(context.Background(), b.UUID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(respB.Friends) != 0 {
|
||||
t.Fatalf("B 应无好友, got %+v", respB.Friends)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeclineIncomingRequest B 拒绝 A 的请求 -> A 的 outgoing 清空
|
||||
func TestDeclineIncomingRequest(t *testing.T) {
|
||||
svc, db := newFriendsService(t)
|
||||
a := mustCreateProfile(t, db, "alice")
|
||||
b := mustCreateProfile(t, db, "bob")
|
||||
|
||||
if _, err := svc.PerformFriendAction(context.Background(), a.UUID, FriendActionRequest{ProfileID: b.UUID, UpdateType: FriendActionAdd}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// B 拒绝(B 用 REMOVE 指向 A)
|
||||
if _, err := svc.PerformFriendAction(context.Background(), b.UUID, FriendActionRequest{ProfileID: a.UUID, UpdateType: FriendActionRemove}); err != nil {
|
||||
t.Fatalf("B 拒绝失败: %v", err)
|
||||
}
|
||||
// A 的 outgoing 应清空
|
||||
respA, err := svc.ListFriends(context.Background(), a.UUID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(respA.OutgoingRequests) != 0 {
|
||||
t.Fatalf("A 的 outgoing 应已清空, got %+v", respA.OutgoingRequests)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAttributes_Default 默认属性
|
||||
func TestGetAttributes_Default(t *testing.T) {
|
||||
svc, db := newFriendsService(t)
|
||||
p := mustCreateProfile(t, db, "a")
|
||||
resp, err := svc.GetAttributes(context.Background(), p.UUID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !resp.ProfanityFilterPreferences.ProfanityFilterOn {
|
||||
t.Error("默认应启用脏词过滤")
|
||||
}
|
||||
if resp.FriendsPreferences.Friends != "ENABLED" {
|
||||
t.Errorf("默认 friends = %q, want ENABLED", resp.FriendsPreferences.Friends)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateAttributes_PartialUpdate 部分更新保留其它字段
|
||||
func TestUpdateAttributes_PartialUpdate(t *testing.T) {
|
||||
svc, db := newFriendsService(t)
|
||||
p := mustCreateProfile(t, db, "a")
|
||||
|
||||
off := false
|
||||
if err := svc.UpdateAttributes(context.Background(), p.UUID, PlayerAttributesRequest{
|
||||
ProfanityFilterPreferences: &ProfanityFilterPreferences{ProfanityFilterOn: &off},
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateAttributes err: %v", err)
|
||||
}
|
||||
var row model.PlayerAttributes
|
||||
if err := db.First(&row, "profile_uuid = ?", p.UUID).Error; err != nil {
|
||||
t.Fatalf("查询 row 失败: %v", err)
|
||||
}
|
||||
t.Logf("DB row: %+v", row)
|
||||
resp, err := svc.GetAttributes(context.Background(), p.UUID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAttributes err: %v", err)
|
||||
}
|
||||
if resp.ProfanityFilterPreferences.ProfanityFilterOn {
|
||||
t.Error("脏词过滤应已关闭")
|
||||
}
|
||||
if resp.FriendsPreferences.Friends != "ENABLED" {
|
||||
t.Errorf("friends 应保留 ENABLED, got %q", resp.FriendsPreferences.Friends)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlocklist_BlockUnblock 屏蔽/取消屏蔽
|
||||
func TestBlocklist_BlockUnblock(t *testing.T) {
|
||||
svc, db := newFriendsService(t)
|
||||
a := mustCreateProfile(t, db, "alice")
|
||||
b := mustCreateProfile(t, db, "bob")
|
||||
|
||||
// 使用类型断言访问 Block/Unblock(接口未暴露,但实现有)
|
||||
fs := svc.(*friendsService)
|
||||
if err := fs.Block(context.Background(), a.UUID, b.UUID); err != nil {
|
||||
t.Fatalf("屏蔽失败: %v", err)
|
||||
}
|
||||
resp, err := svc.GetBlocklist(context.Background(), a.UUID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.BlockedProfiles) != 1 || resp.BlockedProfiles[0] != b.UUID {
|
||||
t.Fatalf("屏蔽列表应含 B, got %+v", resp.BlockedProfiles)
|
||||
}
|
||||
if err := fs.Unblock(context.Background(), a.UUID, b.UUID); err != nil {
|
||||
t.Fatalf("取消屏蔽失败: %v", err)
|
||||
}
|
||||
resp, err = svc.GetBlocklist(context.Background(), a.UUID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.BlockedProfiles) != 0 {
|
||||
t.Fatalf("屏蔽列表应空, got %+v", resp.BlockedProfiles)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"time"
|
||||
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -54,8 +55,8 @@ func (s *yggdrasilSerializationService) SerializeProfile(ctx context.Context, pr
|
||||
|
||||
// SerializeProfileWithUnsigned 序列化档案为Yggdrasil格式(支持unsigned参数)
|
||||
func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{} {
|
||||
// 创建基本材质数据
|
||||
texturesMap := make(map[string]interface{})
|
||||
// 预分配材质 map(最多 SKIN + CAPE 两个条目,避免运行时扩容)
|
||||
texturesMap := make(map[string]interface{}, 2)
|
||||
textures := map[string]interface{}{
|
||||
"timestamp": time.Now().UnixMilli(),
|
||||
"profileId": profile.UUID,
|
||||
@@ -63,7 +64,7 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context
|
||||
"textures": texturesMap,
|
||||
}
|
||||
|
||||
// 处理皮肤
|
||||
// 处理皮肤:metadata 仅对 SKIN 有效,值为 {"model":"slim"};classic/默认省略 metadata
|
||||
if profile.SkinID != nil {
|
||||
skin, err := s.textureRepo.FindByID(ctx, *profile.SkinID)
|
||||
if err != nil {
|
||||
@@ -72,14 +73,19 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context
|
||||
zap.Int64("skinID", *profile.SkinID),
|
||||
)
|
||||
} else if skin != nil {
|
||||
// slim 才需要 metadata 字段;classic 直接仅含 url
|
||||
if skin.IsSlim {
|
||||
texturesMap["SKIN"] = map[string]interface{}{
|
||||
"url": skin.URL,
|
||||
"metadata": skin.Size,
|
||||
"metadata": map[string]string{"model": "slim"},
|
||||
}
|
||||
} else {
|
||||
texturesMap["SKIN"] = map[string]interface{}{"url": skin.URL}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理披风
|
||||
// 处理披风:CAPE 不携带 metadata 字段
|
||||
if profile.CapeID != nil {
|
||||
cape, err := s.textureRepo.FindByID(ctx, *profile.CapeID)
|
||||
if err != nil {
|
||||
@@ -88,15 +94,12 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context
|
||||
zap.Int64("capeID", *profile.CapeID),
|
||||
)
|
||||
} else if cape != nil {
|
||||
texturesMap["CAPE"] = map[string]interface{}{
|
||||
"url": cape.URL,
|
||||
"metadata": cape.Size,
|
||||
}
|
||||
texturesMap["CAPE"] = map[string]interface{}{"url": cape.URL}
|
||||
}
|
||||
}
|
||||
|
||||
// 将textures编码为base64
|
||||
bytes, err := json.Marshal(textures)
|
||||
// 将 textures 编码为 base64
|
||||
textureBytes, err := json.Marshal(textures)
|
||||
if err != nil {
|
||||
s.logger.Error("序列化textures失败",
|
||||
zap.Error(err),
|
||||
@@ -105,12 +108,15 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context
|
||||
return nil
|
||||
}
|
||||
|
||||
textureData := base64.StdEncoding.EncodeToString(bytes)
|
||||
textureData := base64.StdEncoding.EncodeToString(textureBytes)
|
||||
|
||||
// 只有在 unsigned=false 时才签名
|
||||
var signature string
|
||||
property := Property{
|
||||
Name: "textures",
|
||||
Value: textureData,
|
||||
}
|
||||
if !unsigned {
|
||||
signature, err = s.signatureService.SignStringWithSHA1withRSA(textureData)
|
||||
signature, err := s.signatureService.SignStringWithSHA1withRSA(ctx, textureData)
|
||||
if err != nil {
|
||||
s.logger.Error("签名textures失败",
|
||||
zap.Error(err),
|
||||
@@ -118,26 +124,15 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 构建属性
|
||||
property := Property{
|
||||
Name: "textures",
|
||||
Value: textureData,
|
||||
}
|
||||
|
||||
// 只有在 unsigned=false 时才添加签名
|
||||
if !unsigned {
|
||||
property.Signature = signature
|
||||
}
|
||||
|
||||
// 构建结果
|
||||
data := map[string]interface{}{
|
||||
return map[string]interface{}{
|
||||
"id": profile.UUID,
|
||||
"name": profile.Name,
|
||||
"properties": []Property{property},
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// SerializeUser 序列化用户为Yggdrasil格式
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
"carrotskin/pkg/redis"
|
||||
"carrotskin/pkg/utils"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/internal/repository"
|
||||
"carrotskin/pkg/redis"
|
||||
"carrotskin/pkg/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// yggdrasilServiceComposite 组合服务,保持接口兼容性
|
||||
@@ -28,20 +27,20 @@ type yggdrasilServiceComposite struct {
|
||||
|
||||
// NewYggdrasilServiceComposite 创建组合服务实例
|
||||
func NewYggdrasilServiceComposite(
|
||||
db *gorm.DB,
|
||||
userRepo repository.UserRepository,
|
||||
profileRepo repository.ProfileRepository,
|
||||
yggdrasilRepo repository.YggdrasilRepository,
|
||||
textureRepo repository.TextureRepository,
|
||||
signatureService *SignatureService,
|
||||
redisClient *redis.Client,
|
||||
logger *zap.Logger,
|
||||
tokenService TokenService, // 新增:TokenService接口
|
||||
) YggdrasilService {
|
||||
// 创建各个专门的服务
|
||||
authService := NewYggdrasilAuthService(db, userRepo, yggdrasilRepo, logger)
|
||||
authService := NewYggdrasilAuthService(userRepo, yggdrasilRepo, logger)
|
||||
sessionService := NewSessionService(redisClient, logger)
|
||||
serializationService := NewSerializationService(
|
||||
repository.NewTextureRepository(db),
|
||||
textureRepo,
|
||||
signatureService,
|
||||
logger,
|
||||
)
|
||||
@@ -126,7 +125,7 @@ func (s *yggdrasilServiceComposite) SerializeUser(ctx context.Context, user *mod
|
||||
}
|
||||
|
||||
// GeneratePlayerCertificate 生成玩家证书
|
||||
func (s *yggdrasilServiceComposite) GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error) {
|
||||
func (s *yggdrasilServiceComposite) GeneratePlayerCertificate(ctx context.Context, uuid string) (*PlayerCertificate, error) {
|
||||
return s.certificateService.GeneratePlayerCertificate(ctx, uuid)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
apperrors "carrotskin/internal/errors"
|
||||
"carrotskin/pkg/redis"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "carrotskin/internal/errors"
|
||||
"carrotskin/pkg/redis"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -82,7 +83,7 @@ func (s *yggdrasilSessionService) CreateSession(ctx context.Context, serverID, a
|
||||
return apperrors.ErrInvalidAccessToken
|
||||
}
|
||||
if username == "" {
|
||||
return apperrors.ErrUsernameMismatch
|
||||
return apperrors.ErrUsernameRequired
|
||||
}
|
||||
if profileUUID == "" {
|
||||
return apperrors.ErrProfileMismatch
|
||||
@@ -126,12 +127,8 @@ func (s *yggdrasilSessionService) CreateSession(ctx context.Context, serverID, a
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSession 获取会话数据
|
||||
// GetSession 获取会话数据(内部调用前已对 serverID 校验过,跳过重复校验)
|
||||
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)
|
||||
@@ -162,6 +159,7 @@ func (s *yggdrasilSessionService) ValidateSession(ctx context.Context, serverID,
|
||||
return apperrors.ErrSessionMismatch
|
||||
}
|
||||
|
||||
// ValidateSession 由 HasJoinedServer 调用,serverID 已经过校验,此处无需重复 ValidateServerID
|
||||
sessionData, err := s.GetSession(ctx, serverID)
|
||||
if err != nil {
|
||||
return apperrors.ErrSessionNotFound
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
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格式(支持32位无符号和36位带连字符格式)
|
||||
func (v *Validator) ValidateUUID(uuid string) error {
|
||||
if uuid == "" {
|
||||
return errors.New("UUID不能为空")
|
||||
}
|
||||
|
||||
// 验证32位无符号UUID格式(纯十六进制字符串)
|
||||
if len(uuid) == 32 {
|
||||
// 检查是否为有效的十六进制字符串
|
||||
for _, c := range uuid {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
||||
return errors.New("UUID格式无效:包含非十六进制字符")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 验证36位标准UUID格式(带连字符)
|
||||
if len(uuid) == 36 && uuid[8] == '-' && uuid[13] == '-' && uuid[18] == '-' && uuid[23] == '-' {
|
||||
// 检查除连字符外的字符是否为有效的十六进制
|
||||
for i, c := range uuid {
|
||||
if i == 8 || i == 13 || i == 18 || i == 23 {
|
||||
continue // 跳过连字符位置
|
||||
}
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
||||
return errors.New("UUID格式无效:包含非十六进制字符")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("UUID格式无效:长度应为32位或36位")
|
||||
}
|
||||
|
||||
// ValidateAccessToken 验证访问令牌
|
||||
func (v *Validator) ValidateAccessToken(token string) error {
|
||||
if token == "" {
|
||||
return errors.New("访问令牌不能为空")
|
||||
}
|
||||
if len(token) < 10 {
|
||||
return errors.New("访问令牌格式无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -31,6 +31,8 @@ func NewTestDB(t *testing.T) *gorm.DB {
|
||||
&model.TextureDownloadLog{},
|
||||
&model.Client{},
|
||||
&model.Yggdrasil{},
|
||||
&model.Friend{},
|
||||
&model.PlayerAttributes{},
|
||||
&model.AuditLog{},
|
||||
&model.CasbinRule{},
|
||||
); err != nil {
|
||||
|
||||
@@ -2,13 +2,8 @@ package types
|
||||
|
||||
import "time"
|
||||
|
||||
// BaseResponse 基础响应结构
|
||||
// @Description 通用API响应结构
|
||||
type BaseResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
// 注意:BaseResponse 与 PaginationResponse 已删除(与 model.Response /
|
||||
// model.PaginationResponse 重复)。统一使用 model 包的响应结构。
|
||||
|
||||
// PaginationRequest 分页请求
|
||||
// @Description 分页查询参数
|
||||
@@ -17,16 +12,6 @@ type PaginationRequest struct {
|
||||
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"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// LoginRequest 登录请求
|
||||
// @Description 用户登录请求参数
|
||||
type LoginRequest struct {
|
||||
|
||||
@@ -63,102 +63,15 @@ func TestTextureType_Constants(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaginationResponse_Structure 测试分页响应结构
|
||||
func TestPaginationResponse_Structure(t *testing.T) {
|
||||
resp := PaginationResponse{
|
||||
List: []string{"a", "b", "c"},
|
||||
Total: 100,
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
TotalPages: 5,
|
||||
// TestPaginationRequest_Defaults 测试分页请求结构
|
||||
// 注意:PaginationResponse 已删除(与 model.PaginationResponse 重复)
|
||||
func TestPaginationRequest_Defaults(t *testing.T) {
|
||||
req := PaginationRequest{Page: 1, PageSize: 20}
|
||||
if req.Page != 1 {
|
||||
t.Errorf("Page = %d, want 1", req.Page)
|
||||
}
|
||||
|
||||
if resp.Total != 100 {
|
||||
t.Errorf("Total = %d, want 100", resp.Total)
|
||||
}
|
||||
|
||||
if resp.Page != 1 {
|
||||
t.Errorf("Page = %d, want 1", resp.Page)
|
||||
}
|
||||
|
||||
if resp.PageSize != 20 {
|
||||
t.Errorf("PageSize = %d, want 20", resp.PageSize)
|
||||
}
|
||||
|
||||
if resp.TotalPages != 5 {
|
||||
t.Errorf("TotalPages = %d, want 5", resp.TotalPages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaginationResponse_TotalPagesCalculation 测试总页数计算逻辑
|
||||
func TestPaginationResponse_TotalPagesCalculation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
total int64
|
||||
pageSize int
|
||||
wantPages int
|
||||
}{
|
||||
{
|
||||
name: "正好整除",
|
||||
total: 100,
|
||||
pageSize: 20,
|
||||
wantPages: 5,
|
||||
},
|
||||
{
|
||||
name: "有余数",
|
||||
total: 101,
|
||||
pageSize: 20,
|
||||
wantPages: 6, // 向上取整
|
||||
},
|
||||
{
|
||||
name: "总数小于每页数量",
|
||||
total: 10,
|
||||
pageSize: 20,
|
||||
wantPages: 1,
|
||||
},
|
||||
{
|
||||
name: "总数为0",
|
||||
total: 0,
|
||||
pageSize: 20,
|
||||
wantPages: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 计算总页数:向上取整
|
||||
var totalPages int
|
||||
if tt.total == 0 {
|
||||
totalPages = 0
|
||||
} else {
|
||||
totalPages = int((tt.total + int64(tt.pageSize) - 1) / int64(tt.pageSize))
|
||||
}
|
||||
|
||||
if totalPages != tt.wantPages {
|
||||
t.Errorf("TotalPages = %d, want %d", totalPages, tt.wantPages)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseResponse_Structure 测试基础响应结构
|
||||
func TestBaseResponse_Structure(t *testing.T) {
|
||||
resp := BaseResponse{
|
||||
Code: 200,
|
||||
Message: "success",
|
||||
Data: "test data",
|
||||
}
|
||||
|
||||
if resp.Code != 200 {
|
||||
t.Errorf("Code = %d, want 200", resp.Code)
|
||||
}
|
||||
|
||||
if resp.Message != "success" {
|
||||
t.Errorf("Message = %q, want 'success'", resp.Message)
|
||||
}
|
||||
|
||||
if resp.Data != "test data" {
|
||||
t.Errorf("Data = %v, want 'test data'", resp.Data)
|
||||
if req.PageSize != 20 {
|
||||
t.Errorf("PageSize = %d, want 20", req.PageSize)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,36 +83,10 @@ func TestLoginRequest_Validation(t *testing.T) {
|
||||
password string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "有效的登录请求",
|
||||
username: "testuser",
|
||||
password: "password123",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "用户名为空",
|
||||
username: "",
|
||||
password: "password123",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "密码为空",
|
||||
username: "testuser",
|
||||
password: "",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "密码长度小于6",
|
||||
username: "testuser",
|
||||
password: "12345",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "密码长度超过128",
|
||||
username: "testuser",
|
||||
password: string(make([]byte, 129)),
|
||||
wantValid: false,
|
||||
},
|
||||
{name: "有效的登录请求", username: "testuser", password: "password123", wantValid: true},
|
||||
{name: "用户名为空", username: "", password: "password123", wantValid: false},
|
||||
{name: "密码为空", username: "testuser", password: "", wantValid: false},
|
||||
{name: "密码长度小于6", username: "testuser", password: "12345", wantValid: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -211,174 +98,3 @@ func TestLoginRequest_Validation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterRequest_Validation 测试注册请求验证逻辑
|
||||
func TestRegisterRequest_Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
email string
|
||||
password string
|
||||
verificationCode string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "有效的注册请求",
|
||||
username: "newuser",
|
||||
email: "user@example.com",
|
||||
password: "password123",
|
||||
verificationCode: "123456",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "用户名为空",
|
||||
username: "",
|
||||
email: "user@example.com",
|
||||
password: "password123",
|
||||
verificationCode: "123456",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "用户名长度小于3",
|
||||
username: "ab",
|
||||
email: "user@example.com",
|
||||
password: "password123",
|
||||
verificationCode: "123456",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "用户名长度超过50",
|
||||
username: string(make([]byte, 51)),
|
||||
email: "user@example.com",
|
||||
password: "password123",
|
||||
verificationCode: "123456",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "邮箱格式无效",
|
||||
username: "newuser",
|
||||
email: "invalid-email",
|
||||
password: "password123",
|
||||
verificationCode: "123456",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "验证码长度不是6",
|
||||
username: "newuser",
|
||||
email: "user@example.com",
|
||||
password: "password123",
|
||||
verificationCode: "12345",
|
||||
wantValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
isValid := tt.username != "" &&
|
||||
len(tt.username) >= 3 && len(tt.username) <= 50 &&
|
||||
tt.email != "" && contains(tt.email, "@") &&
|
||||
len(tt.password) >= 6 && len(tt.password) <= 128 &&
|
||||
len(tt.verificationCode) == 6
|
||||
if isValid != tt.wantValid {
|
||||
t.Errorf("Validation failed: got %v, want %v", isValid, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
func contains(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestResetPasswordRequest_Validation 测试重置密码请求验证
|
||||
func TestResetPasswordRequest_Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email string
|
||||
verificationCode string
|
||||
newPassword string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "有效的重置密码请求",
|
||||
email: "user@example.com",
|
||||
verificationCode: "123456",
|
||||
newPassword: "newpassword123",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "邮箱为空",
|
||||
email: "",
|
||||
verificationCode: "123456",
|
||||
newPassword: "newpassword123",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "验证码长度不是6",
|
||||
email: "user@example.com",
|
||||
verificationCode: "12345",
|
||||
newPassword: "newpassword123",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "新密码长度小于6",
|
||||
email: "user@example.com",
|
||||
verificationCode: "123456",
|
||||
newPassword: "12345",
|
||||
wantValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
isValid := tt.email != "" &&
|
||||
len(tt.verificationCode) == 6 &&
|
||||
len(tt.newPassword) >= 6 && len(tt.newPassword) <= 128
|
||||
if isValid != tt.wantValid {
|
||||
t.Errorf("Validation failed: got %v, want %v", isValid, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateProfileRequest_Validation 测试创建档案请求验证
|
||||
func TestCreateProfileRequest_Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
profileName string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "有效的档案名",
|
||||
profileName: "PlayerName",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "档案名为空",
|
||||
profileName: "",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "档案名长度超过16",
|
||||
profileName: string(make([]byte, 17)),
|
||||
wantValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
isValid := tt.profileName != "" &&
|
||||
len(tt.profileName) >= 1 && len(tt.profileName) <= 16
|
||||
if isValid != tt.wantValid {
|
||||
t.Errorf("Validation failed: got %v, want %v", isValid, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +1,8 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"carrotskin/pkg/config"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
// jwtServiceInstance 全局JWT服务实例
|
||||
jwtServiceInstance *JWTService
|
||||
// once 确保只初始化一次
|
||||
once sync.Once
|
||||
// initError 初始化错误
|
||||
)
|
||||
|
||||
// Init 初始化JWT服务(线程安全,只会执行一次)
|
||||
func Init(cfg config.JWTConfig) error {
|
||||
once.Do(func() {
|
||||
jwtServiceInstance = NewJWTService(cfg.Secret, cfg.ExpireHours)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetJWTService 获取JWT服务实例(线程安全)
|
||||
func GetJWTService() (*JWTService, error) {
|
||||
if jwtServiceInstance == nil {
|
||||
return nil, fmt.Errorf("JWT服务未初始化,请先调用 auth.Init()")
|
||||
}
|
||||
return jwtServiceInstance, nil
|
||||
}
|
||||
|
||||
// MustGetJWTService 获取JWT服务实例,如果未初始化则panic
|
||||
func MustGetJWTService() *JWTService {
|
||||
service, err := GetJWTService()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 本文件原包含基于 sync.Once 的全局单例(Init/GetJWTService/MustGetJWTService)。
|
||||
//
|
||||
// 在 DI 迁移(阶段4)后,全局单例已被移除。JWT 服务由 fx.Provide 构造
|
||||
// (见 internal/app/infra_module.go),通过构造函数参数注入到各消费者。
|
||||
//
|
||||
// JWT 服务构造逻辑见 jwt.go 的 NewJWTService()。
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"carrotskin/pkg/config"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetJWTService_NotInitialized 测试未初始化时获取JWT服务
|
||||
func TestGetJWTService_NotInitialized(t *testing.T) {
|
||||
_, err := GetJWTService()
|
||||
if err == nil {
|
||||
t.Error("未初始化时应该返回错误")
|
||||
}
|
||||
|
||||
expectedError := "JWT服务未初始化,请先调用 auth.Init()"
|
||||
if err.Error() != expectedError {
|
||||
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMustGetJWTService_Panic 测试MustGetJWTService在未初始化时panic
|
||||
func TestMustGetJWTService_Panic(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("MustGetJWTService 应该在未初始化时panic")
|
||||
}
|
||||
}()
|
||||
|
||||
_ = MustGetJWTService()
|
||||
}
|
||||
|
||||
// TestInit_JWTService 测试JWT服务初始化
|
||||
func TestInit_JWTService(t *testing.T) {
|
||||
cfg := config.JWTConfig{
|
||||
Secret: "test-secret-key",
|
||||
ExpireHours: 24,
|
||||
}
|
||||
|
||||
err := Init(cfg)
|
||||
if err != nil {
|
||||
t.Errorf("Init() 错误 = %v, want nil", err)
|
||||
}
|
||||
|
||||
// 验证可以获取服务
|
||||
service, err := GetJWTService()
|
||||
if err != nil {
|
||||
t.Errorf("GetJWTService() 错误 = %v, want nil", err)
|
||||
}
|
||||
if service == nil {
|
||||
t.Error("GetJWTService() 返回的服务不应为nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInit_JWTService_Once 测试Init只执行一次
|
||||
func TestInit_JWTService_Once(t *testing.T) {
|
||||
cfg := config.JWTConfig{
|
||||
Secret: "test-secret-key-1",
|
||||
ExpireHours: 24,
|
||||
}
|
||||
|
||||
// 第一次初始化
|
||||
err1 := Init(cfg)
|
||||
if err1 != nil {
|
||||
t.Fatalf("第一次Init() 错误 = %v", err1)
|
||||
}
|
||||
|
||||
service1, _ := GetJWTService()
|
||||
|
||||
// 第二次初始化(应该不会改变服务)
|
||||
cfg2 := config.JWTConfig{
|
||||
Secret: "test-secret-key-2",
|
||||
ExpireHours: 48,
|
||||
}
|
||||
err2 := Init(cfg2)
|
||||
if err2 != nil {
|
||||
t.Fatalf("第二次Init() 错误 = %v", err2)
|
||||
}
|
||||
|
||||
service2, _ := GetJWTService()
|
||||
|
||||
// 验证是同一个实例(sync.Once保证)
|
||||
if service1 != service2 {
|
||||
t.Error("Init应该只执行一次,返回同一个实例")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
YggdrasilPrivateKeyRedisKey = "yggdrasil:private_key"
|
||||
)
|
||||
|
||||
// RedisClient 定义Redis客户端接口(用于测试)
|
||||
type RedisClient interface {
|
||||
Get(ctx context.Context, key string) (string, error)
|
||||
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
|
||||
}
|
||||
|
||||
// YggdrasilJWTService Yggdrasil JWT服务(使用RSA512)
|
||||
type YggdrasilJWTService struct {
|
||||
privateKey *rsa.PrivateKey
|
||||
@@ -122,98 +106,3 @@ func (j *YggdrasilJWTService) ParseAccessToken(accessToken string, stalePolicy S
|
||||
func (j *YggdrasilJWTService) GetPublicKey() *rsa.PublicKey {
|
||||
return j.publicKey
|
||||
}
|
||||
|
||||
// YggdrasilJWTManager Yggdrasil JWT管理器,用于获取或创建JWT服务
|
||||
type YggdrasilJWTManager struct {
|
||||
redisClient RedisClient
|
||||
jwtService *YggdrasilJWTService
|
||||
privateKey *rsa.PrivateKey
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewYggdrasilJWTManager 创建Yggdrasil JWT管理器
|
||||
func NewYggdrasilJWTManager(redisClient RedisClient) *YggdrasilJWTManager {
|
||||
return &YggdrasilJWTManager{
|
||||
redisClient: redisClient,
|
||||
}
|
||||
}
|
||||
|
||||
// GetJWTService 获取或创建Yggdrasil JWT服务(线程安全)
|
||||
func (m *YggdrasilJWTManager) GetJWTService() (*YggdrasilJWTService, error) {
|
||||
m.mu.RLock()
|
||||
if m.jwtService != nil {
|
||||
service := m.jwtService
|
||||
m.mu.RUnlock()
|
||||
return service, nil
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// 双重检查
|
||||
if m.jwtService != nil {
|
||||
return m.jwtService, nil
|
||||
}
|
||||
|
||||
// 从Redis获取私钥
|
||||
privateKey, err := m.getPrivateKeyFromRedis()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取私钥失败: %w", err)
|
||||
}
|
||||
|
||||
m.privateKey = privateKey
|
||||
m.jwtService = NewYggdrasilJWTService(privateKey, "carrotskin")
|
||||
return m.jwtService, nil
|
||||
}
|
||||
|
||||
// SetPrivateKey 直接设置私钥(用于测试或直接从signatureService获取)
|
||||
func (m *YggdrasilJWTManager) SetPrivateKey(privateKey *rsa.PrivateKey) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.privateKey = privateKey
|
||||
if privateKey != nil {
|
||||
m.jwtService = NewYggdrasilJWTService(privateKey, "carrotskin")
|
||||
}
|
||||
}
|
||||
|
||||
// getPrivateKeyFromRedis 从Redis获取私钥
|
||||
func (m *YggdrasilJWTManager) getPrivateKeyFromRedis() (*rsa.PrivateKey, error) {
|
||||
if m.privateKey != nil {
|
||||
return m.privateKey, nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
privateKeyPEM, err := m.redisClient.Get(ctx, YggdrasilPrivateKeyRedisKey)
|
||||
if err != nil || privateKeyPEM == "" {
|
||||
return nil, fmt.Errorf("从Redis获取私钥失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析PEM格式的私钥
|
||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("解析PEM私钥失败")
|
||||
}
|
||||
|
||||
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析RSA私钥失败: %w", err)
|
||||
}
|
||||
|
||||
return privateKey, nil
|
||||
}
|
||||
|
||||
// GenerateKeyPair 生成RSA密钥对(用于测试)
|
||||
func GenerateKeyPair() (*rsa.PrivateKey, error) {
|
||||
return rsa.GenerateKey(rand.Reader, 2048)
|
||||
}
|
||||
|
||||
// EncodePrivateKeyToPEM 将私钥编码为PEM格式(用于测试)
|
||||
func EncodePrivateKeyToPEM(privateKey *rsa.PrivateKey) (string, error) {
|
||||
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
|
||||
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: privateKeyBytes,
|
||||
})
|
||||
return string(privateKeyPEM), nil
|
||||
}
|
||||
|
||||
@@ -1,65 +1,16 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// MockRedisClient 模拟Redis客户端
|
||||
type MockRedisClient struct {
|
||||
data map[string]string
|
||||
err error
|
||||
}
|
||||
|
||||
func NewMockRedisClient() *MockRedisClient {
|
||||
return &MockRedisClient{
|
||||
data: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockRedisClient) Get(ctx context.Context, key string) (string, error) {
|
||||
if m.err != nil {
|
||||
return "", m.err
|
||||
}
|
||||
if val, ok := m.data[key]; ok {
|
||||
return val, nil
|
||||
}
|
||||
return "", redis.Nil
|
||||
}
|
||||
|
||||
func (m *MockRedisClient) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.data[key] = value.(string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockRedisClient) SetError(err error) {
|
||||
m.err = err
|
||||
}
|
||||
|
||||
func (m *MockRedisClient) ClearError() {
|
||||
m.err = nil
|
||||
}
|
||||
|
||||
func (m *MockRedisClient) SetData(key, value string) {
|
||||
m.data[key] = value
|
||||
}
|
||||
|
||||
func (m *MockRedisClient) Clear() {
|
||||
m.data = make(map[string]string)
|
||||
m.err = nil
|
||||
}
|
||||
|
||||
// 测试辅助函数:生成测试用的密钥对
|
||||
// generateTestKeyPair 生成测试用的 RSA 密钥对
|
||||
func generateTestKeyPair(t *testing.T) *rsa.PrivateKey {
|
||||
privateKey, err := GenerateKeyPair()
|
||||
t.Helper()
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("生成密钥对失败: %v", err)
|
||||
}
|
||||
@@ -285,169 +236,6 @@ func TestYggdrasilJWTService_GetPublicKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewYggdrasilJWTManager(t *testing.T) {
|
||||
mockRedis := NewMockRedisClient()
|
||||
manager := NewYggdrasilJWTManager(mockRedis)
|
||||
|
||||
if manager == nil {
|
||||
t.Fatal("管理器创建失败")
|
||||
}
|
||||
if manager.redisClient != mockRedis {
|
||||
t.Error("Redis客户端未正确设置")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYggdrasilJWTManager_SetPrivateKey(t *testing.T) {
|
||||
mockRedis := NewMockRedisClient()
|
||||
manager := NewYggdrasilJWTManager(mockRedis)
|
||||
|
||||
privateKey := generateTestKeyPair(t)
|
||||
manager.SetPrivateKey(privateKey)
|
||||
|
||||
// 验证JWT服务已创建
|
||||
service, err := manager.GetJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("获取JWT服务失败: %v", err)
|
||||
}
|
||||
if service == nil {
|
||||
t.Fatal("JWT服务不应为nil")
|
||||
}
|
||||
// 验证服务可以正常工作
|
||||
if service.GetPublicKey() == nil {
|
||||
t.Error("公钥不应为nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYggdrasilJWTManager_GetJWTService_FromPrivateKey(t *testing.T) {
|
||||
mockRedis := NewMockRedisClient()
|
||||
manager := NewYggdrasilJWTManager(mockRedis)
|
||||
|
||||
privateKey := generateTestKeyPair(t)
|
||||
manager.SetPrivateKey(privateKey)
|
||||
|
||||
// 第一次获取
|
||||
service1, err := manager.GetJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("获取JWT服务失败: %v", err)
|
||||
}
|
||||
|
||||
// 第二次获取应该返回同一个实例
|
||||
service2, err := manager.GetJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("获取JWT服务失败: %v", err)
|
||||
}
|
||||
|
||||
if service1 != service2 {
|
||||
t.Error("应该返回同一个JWT服务实例")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYggdrasilJWTManager_GetJWTService_FromRedis(t *testing.T) {
|
||||
mockRedis := NewMockRedisClient()
|
||||
manager := NewYggdrasilJWTManager(mockRedis)
|
||||
|
||||
privateKey := generateTestKeyPair(t)
|
||||
privateKeyPEM, err := EncodePrivateKeyToPEM(privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("编码私钥失败: %v", err)
|
||||
}
|
||||
|
||||
// 设置Redis数据
|
||||
mockRedis.SetData(YggdrasilPrivateKeyRedisKey, privateKeyPEM)
|
||||
|
||||
// 获取JWT服务
|
||||
service, err := manager.GetJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("获取JWT服务失败: %v", err)
|
||||
}
|
||||
if service == nil {
|
||||
t.Error("JWT服务不应为nil")
|
||||
}
|
||||
|
||||
// 验证服务可以正常工作
|
||||
token, err := service.GenerateAccessToken(123, "client-uuid", 1, "profile-uuid",
|
||||
time.Now().Add(24*time.Hour), time.Now().Add(30*24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("生成Token失败: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Error("Token不应为空")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYggdrasilJWTManager_GetJWTService_RedisError(t *testing.T) {
|
||||
mockRedis := NewMockRedisClient()
|
||||
manager := NewYggdrasilJWTManager(mockRedis)
|
||||
|
||||
// 设置Redis错误
|
||||
mockRedis.SetError(errors.New("redis connection error"))
|
||||
|
||||
// 尝试获取JWT服务应该失败
|
||||
_, err := manager.GetJWTService()
|
||||
if err == nil {
|
||||
t.Error("期望出现错误,但没有错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYggdrasilJWTManager_GetJWTService_InvalidPEM(t *testing.T) {
|
||||
mockRedis := NewMockRedisClient()
|
||||
manager := NewYggdrasilJWTManager(mockRedis)
|
||||
|
||||
// 设置无效的PEM数据
|
||||
mockRedis.SetData(YggdrasilPrivateKeyRedisKey, "invalid-pem-data")
|
||||
|
||||
// 尝试获取JWT服务应该失败
|
||||
_, err := manager.GetJWTService()
|
||||
if err == nil {
|
||||
t.Error("期望出现错误,但没有错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYggdrasilJWTManager_GetJWTService_Concurrent(t *testing.T) {
|
||||
mockRedis := NewMockRedisClient()
|
||||
manager := NewYggdrasilJWTManager(mockRedis)
|
||||
|
||||
privateKey := generateTestKeyPair(t)
|
||||
privateKeyPEM, err := EncodePrivateKeyToPEM(privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("编码私钥失败: %v", err)
|
||||
}
|
||||
|
||||
mockRedis.SetData(YggdrasilPrivateKeyRedisKey, privateKeyPEM)
|
||||
|
||||
// 并发获取JWT服务
|
||||
const numGoroutines = 10
|
||||
results := make(chan *YggdrasilJWTService, numGoroutines)
|
||||
errors := make(chan error, numGoroutines)
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func() {
|
||||
service, err := manager.GetJWTService()
|
||||
if err != nil {
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
results <- service
|
||||
}()
|
||||
}
|
||||
|
||||
// 收集结果
|
||||
services := make(map[*YggdrasilJWTService]bool)
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
select {
|
||||
case service := <-results:
|
||||
services[service] = true
|
||||
case err := <-errors:
|
||||
t.Fatalf("获取JWT服务失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 所有goroutine应该返回同一个服务实例
|
||||
if len(services) != 1 {
|
||||
t.Errorf("期望所有goroutine返回同一个服务实例,但得到 %d 个不同的实例", len(services))
|
||||
}
|
||||
}
|
||||
|
||||
func TestYggdrasilTokenClaims_EmptyProfileID(t *testing.T) {
|
||||
privateKey := generateTestKeyPair(t)
|
||||
service := NewYggdrasilJWTService(privateKey, "test-issuer")
|
||||
|
||||
@@ -259,6 +259,7 @@ func setupEnvMappings() {
|
||||
viper.BindEnv("server.read_timeout", "SERVER_READ_TIMEOUT")
|
||||
viper.BindEnv("server.write_timeout", "SERVER_WRITE_TIMEOUT")
|
||||
viper.BindEnv("server.swagger_enabled", "SERVER_SWAGGER_ENABLED")
|
||||
viper.BindEnv("environment", "ENVIRONMENT")
|
||||
|
||||
// 数据库配置
|
||||
viper.BindEnv("database.driver", "DATABASE_DRIVER")
|
||||
@@ -306,6 +307,10 @@ func setupEnvMappings() {
|
||||
viper.BindEnv("log.level", "LOG_LEVEL")
|
||||
viper.BindEnv("log.format", "LOG_FORMAT")
|
||||
viper.BindEnv("log.output", "LOG_OUTPUT")
|
||||
viper.BindEnv("log.max_size", "LOG_MAX_SIZE")
|
||||
viper.BindEnv("log.max_backups", "LOG_MAX_BACKUPS")
|
||||
viper.BindEnv("log.max_age", "LOG_MAX_AGE")
|
||||
viper.BindEnv("log.compress", "LOG_COMPRESS")
|
||||
|
||||
// 邮件配置
|
||||
viper.BindEnv("email.enabled", "EMAIL_ENABLED")
|
||||
@@ -457,10 +462,7 @@ func overrideFromEnv(config *Config) {
|
||||
config.Email.Enabled = emailEnabled == "true" || emailEnabled == "True" || emailEnabled == "TRUE" || emailEnabled == "1"
|
||||
}
|
||||
|
||||
// 处理环境配置
|
||||
if env := os.Getenv("ENVIRONMENT"); env != "" {
|
||||
config.Environment = env
|
||||
}
|
||||
// 处理环境配置(已由 BindEnv("environment", "ENVIRONMENT") + AutomaticEnv 处理)
|
||||
|
||||
// 处理安全配置
|
||||
if allowedOrigins := os.Getenv("SECURITY_ALLOWED_ORIGINS"); allowedOrigins != "" {
|
||||
|
||||
@@ -1,71 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
// configInstance 全局配置实例
|
||||
configInstance *Config
|
||||
// rustFSConfigInstance 全局RustFS配置实例
|
||||
rustFSConfigInstance *RustFSConfig
|
||||
// once 确保只初始化一次
|
||||
once sync.Once
|
||||
// initError 初始化错误
|
||||
initError error
|
||||
)
|
||||
|
||||
// Init 初始化配置(线程安全,只会执行一次)
|
||||
func Init() error {
|
||||
once.Do(func() {
|
||||
configInstance, initError = Load()
|
||||
if initError != nil {
|
||||
return
|
||||
}
|
||||
rustFSConfigInstance = &configInstance.RustFS
|
||||
})
|
||||
return initError
|
||||
}
|
||||
|
||||
// GetConfig 获取配置实例(线程安全)
|
||||
func GetConfig() (*Config, error) {
|
||||
if configInstance == nil {
|
||||
return nil, fmt.Errorf("配置未初始化,请先调用 config.Init()")
|
||||
}
|
||||
return configInstance, nil
|
||||
}
|
||||
|
||||
// MustGetConfig 获取配置实例,如果未初始化则panic
|
||||
func MustGetConfig() *Config {
|
||||
cfg, err := GetConfig()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// GetRustFSConfig 获取RustFS配置实例(线程安全)
|
||||
func GetRustFSConfig() (*RustFSConfig, error) {
|
||||
if rustFSConfigInstance == nil {
|
||||
return nil, fmt.Errorf("配置未初始化,请先调用 config.Init()")
|
||||
}
|
||||
return rustFSConfigInstance, nil
|
||||
}
|
||||
|
||||
// MustGetRustFSConfig 获取RustFS配置实例,如果未初始化则panic
|
||||
func MustGetRustFSConfig() *RustFSConfig {
|
||||
cfg, err := GetRustFSConfig()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 本文件原包含基于 sync.Once 的全局单例(Init/GetConfig/MustGetConfig/GetRustFSConfig)。
|
||||
//
|
||||
// 在 DI 迁移(阶段4)后,全局单例已被移除。配置通过 config.Load() 加载,
|
||||
// 由 fx.Supply 注入到依赖图中。各消费者通过构造函数参数接收 *Config 或子配置。
|
||||
//
|
||||
// 配置加载逻辑见 config.go 的 Load()。
|
||||
|
||||
@@ -4,67 +4,19 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetConfig_NotInitialized 测试未初始化时获取配置
|
||||
func TestGetConfig_NotInitialized(t *testing.T) {
|
||||
// 重置全局变量(在实际测试中可能需要更复杂的重置逻辑)
|
||||
// 注意:由于使用了 sync.Once,这个测试主要验证错误处理逻辑
|
||||
|
||||
// 测试未初始化时的错误消息
|
||||
_, err := GetConfig()
|
||||
if err == nil {
|
||||
t.Error("未初始化时应该返回错误")
|
||||
// TestLoad_Config 验证 Load() 能成功加载配置(从环境变量/默认值)
|
||||
// 全局单例访问器(GetConfig/MustGetConfig)已在 DI 迁移中移除,
|
||||
// 配置通过 config.Load() 加载并由 fx.Supply 注入。
|
||||
func TestLoad_Config(t *testing.T) {
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() 返回错误: %v", err)
|
||||
}
|
||||
|
||||
expectedError := "配置未初始化,请先调用 config.Init()"
|
||||
if err.Error() != expectedError {
|
||||
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
|
||||
if cfg == nil {
|
||||
t.Fatal("Load() 返回 nil 配置")
|
||||
}
|
||||
// 验证默认值生效
|
||||
if cfg.Server.Port == "" {
|
||||
t.Error("Server.Port 不应为空")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMustGetConfig_Panic 测试MustGetConfig在未初始化时panic
|
||||
func TestMustGetConfig_Panic(t *testing.T) {
|
||||
// 注意:这个测试会触发panic,需要recover
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("MustGetConfig 应该在未初始化时panic")
|
||||
}
|
||||
}()
|
||||
|
||||
// 尝试获取未初始化的配置
|
||||
_ = MustGetConfig()
|
||||
}
|
||||
|
||||
// TestGetRustFSConfig_NotInitialized 测试未初始化时获取RustFS配置
|
||||
func TestGetRustFSConfig_NotInitialized(t *testing.T) {
|
||||
_, err := GetRustFSConfig()
|
||||
if err == nil {
|
||||
t.Error("未初始化时应该返回错误")
|
||||
}
|
||||
|
||||
expectedError := "配置未初始化,请先调用 config.Init()"
|
||||
if err.Error() != expectedError {
|
||||
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMustGetRustFSConfig_Panic 测试MustGetRustFSConfig在未初始化时panic
|
||||
func TestMustGetRustFSConfig_Panic(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("MustGetRustFSConfig 应该在未初始化时panic")
|
||||
}
|
||||
}()
|
||||
|
||||
_ = MustGetRustFSConfig()
|
||||
}
|
||||
|
||||
// TestInit_Once 测试Init只执行一次的逻辑
|
||||
func TestInit_Once(t *testing.T) {
|
||||
// 注意:由于sync.Once的特性,这个测试主要验证逻辑
|
||||
// 实际测试中可能需要重置机制
|
||||
|
||||
// 验证Init函数可调用(函数不能直接比较nil)
|
||||
// 这里只验证函数存在
|
||||
_ = Init
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,11 @@ func (cm *CacheManager) Set(ctx context.Context, key string, value interface{},
|
||||
// SetAsync 异步设置缓存,避免在主请求链路阻塞
|
||||
func (cm *CacheManager) SetAsync(ctx context.Context, key string, value interface{}, expiration ...time.Duration) {
|
||||
go func() {
|
||||
_ = cm.Set(ctx, key, value, expiration...)
|
||||
// 使用独立 ctx 配合超时,防止上游 ctx 取消导致缓存写入失败,
|
||||
// 同时避免 Redis 卡死时 goroutine 永久堆积
|
||||
asyncCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = cm.Set(asyncCtx, key, value, expiration...)
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +1,27 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/pkg/config"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"carrotskin/internal/model"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
// dbInstance 全局数据库实例(使用 *DB 封装)
|
||||
dbInstance *DB
|
||||
// once 确保只初始化一次
|
||||
once sync.Once
|
||||
// initError 初始化错误
|
||||
initError error
|
||||
)
|
||||
|
||||
// Init 初始化数据库连接(线程安全,只会执行一次)
|
||||
func Init(cfg config.DatabaseConfig, logger *zap.Logger) error {
|
||||
once.Do(func() {
|
||||
dbInstance, initError = New(cfg)
|
||||
if initError != nil {
|
||||
logger.Error("数据库初始化失败", zap.Error(initError))
|
||||
return
|
||||
}
|
||||
logger.Info("数据库连接成功")
|
||||
})
|
||||
return initError
|
||||
}
|
||||
|
||||
// GetDB 获取数据库实例(线程安全)
|
||||
// 返回 *gorm.DB 以保持向后兼容
|
||||
func GetDB() (*gorm.DB, error) {
|
||||
if dbInstance == nil {
|
||||
return nil, fmt.Errorf("数据库未初始化,请先调用 database.Init()")
|
||||
}
|
||||
return dbInstance.DB, nil
|
||||
}
|
||||
|
||||
// GetDBWrapper 获取数据库封装实例(包含连接池统计功能)
|
||||
func GetDBWrapper() (*DB, error) {
|
||||
if dbInstance == nil {
|
||||
return nil, fmt.Errorf("数据库未初始化,请先调用 database.Init()")
|
||||
}
|
||||
return dbInstance, nil
|
||||
}
|
||||
|
||||
// MustGetDB 获取数据库实例,如果未初始化则panic
|
||||
// 返回 *gorm.DB 以保持向后兼容
|
||||
func MustGetDB() *gorm.DB {
|
||||
db, err := GetDB()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// AutoMigrate 自动迁移数据库表结构
|
||||
func AutoMigrate(logger *zap.Logger) error {
|
||||
db, err := GetDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取数据库实例失败: %w", err)
|
||||
}
|
||||
// 本文件原包含基于 sync.Once 的全局单例(Init/GetDB/MustGetDB/GetDBWrapper)。
|
||||
//
|
||||
// 在 DI 迁移(阶段4)后,全局单例已被移除。数据库连接由 fx.Provide 构造
|
||||
// (见 internal/app/infra_module.go 的 provideDatabase),通过构造函数参数
|
||||
// 注入 *database.DB 与 *gorm.DB 到各消费者。
|
||||
//
|
||||
// 数据库构造逻辑见 postgres.go 的 New()。
|
||||
|
||||
// AutoMigrateWithDB 使用指定的 *gorm.DB 执行表结构迁移(供 DI 使用)。
|
||||
// 注意表的创建顺序:先创建被引用的表,再创建引用表。
|
||||
func AutoMigrateWithDB(db *gorm.DB, logger *zap.Logger) error {
|
||||
logger.Info("开始执行数据库迁移...")
|
||||
|
||||
// 迁移所有表 - 注意顺序:先创建被引用的表,再创建引用表
|
||||
// 使用分批迁移,避免某些表的问题影响其他表
|
||||
tables := []interface{}{
|
||||
// 用户相关表(先创建,因为其他表可能引用它)
|
||||
&model.User{},
|
||||
@@ -90,6 +42,10 @@ func AutoMigrate(logger *zap.Logger) error {
|
||||
// Yggdrasil相关表(在User之后创建,因为它引用User)
|
||||
&model.Yggdrasil{},
|
||||
|
||||
// 好友系统相关表(好友关系、玩家偏好属性)
|
||||
&model.Friend{},
|
||||
&model.PlayerAttributes{},
|
||||
|
||||
// 审计日志表
|
||||
&model.AuditLog{},
|
||||
|
||||
@@ -97,7 +53,6 @@ func AutoMigrate(logger *zap.Logger) error {
|
||||
&model.CasbinRule{},
|
||||
}
|
||||
|
||||
// 批量迁移表
|
||||
if err := db.AutoMigrate(tables...); err != nil {
|
||||
logger.Error("数据库迁移失败", zap.Error(err))
|
||||
return fmt.Errorf("数据库迁移失败: %w", err)
|
||||
@@ -106,12 +61,3 @@ func AutoMigrate(logger *zap.Logger) error {
|
||||
logger.Info("数据库迁移完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接
|
||||
func Close() error {
|
||||
if dbInstance == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return dbInstance.Close()
|
||||
}
|
||||
|
||||
@@ -8,34 +8,16 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 使用内存 sqlite 验证 AutoMigrate 关键路径,无需真实 Postgres
|
||||
// 使用内存 sqlite 验证 AutoMigrateWithDB 关键路径,无需真实 Postgres。
|
||||
// 全局单例访问器(Init/GetDB/MustGetDB)已在 DI 迁移中移除。
|
||||
func TestAutoMigrate_WithSQLite(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite err: %v", err)
|
||||
}
|
||||
|
||||
// 创建临时的 *DB 包装器用于测试
|
||||
// 注意:这里不需要真正的连接池功能,只是测试 AutoMigrate
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("get sql.DB err: %v", err)
|
||||
}
|
||||
|
||||
tempDB := &DB{
|
||||
DB: db,
|
||||
sqlDB: sqlDB,
|
||||
}
|
||||
|
||||
// 保存原始实例
|
||||
originalDB := dbInstance
|
||||
defer func() { dbInstance = originalDB }()
|
||||
|
||||
// 替换为测试实例
|
||||
dbInstance = tempDB
|
||||
|
||||
logger := zaptest.NewLogger(t)
|
||||
if err := AutoMigrate(logger); err != nil {
|
||||
t.Fatalf("AutoMigrate sqlite err: %v", err)
|
||||
if err := AutoMigrateWithDB(db, logger); err != nil {
|
||||
t.Fatalf("AutoMigrateWithDB sqlite err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"carrotskin/pkg/config"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap/zaptest"
|
||||
)
|
||||
|
||||
// TestGetDB_NotInitialized 测试未初始化时获取数据库实例
|
||||
func TestGetDB_NotInitialized(t *testing.T) {
|
||||
dbInstance = nil
|
||||
_, err := GetDB()
|
||||
if err == nil {
|
||||
t.Error("未初始化时应该返回错误")
|
||||
}
|
||||
|
||||
expectedError := "数据库未初始化,请先调用 database.Init()"
|
||||
if err.Error() != expectedError {
|
||||
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMustGetDB_Panic 测试MustGetDB在未初始化时panic
|
||||
func TestMustGetDB_Panic(t *testing.T) {
|
||||
dbInstance = nil
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("MustGetDB 应该在未初始化时panic")
|
||||
}
|
||||
}()
|
||||
|
||||
_ = MustGetDB()
|
||||
}
|
||||
|
||||
// TestInit_Database 测试数据库初始化逻辑
|
||||
func TestInit_Database(t *testing.T) {
|
||||
dbInstance = nil
|
||||
cfg := config.DatabaseConfig{
|
||||
Driver: "postgres",
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
Username: "postgres",
|
||||
Password: "password",
|
||||
Database: "testdb",
|
||||
SSLMode: "disable",
|
||||
Timezone: "Asia/Shanghai",
|
||||
MaxIdleConns: 10,
|
||||
MaxOpenConns: 100,
|
||||
ConnMaxLifetime: 0,
|
||||
}
|
||||
|
||||
logger := zaptest.NewLogger(t)
|
||||
|
||||
// 验证Init函数存在且可调用
|
||||
// 注意:实际连接可能失败,这是可以接受的
|
||||
err := Init(cfg, logger)
|
||||
if err != nil {
|
||||
t.Skipf("数据库未运行,跳过连接测试: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoMigrate_ErrorHandling 测试AutoMigrate的错误处理逻辑
|
||||
func TestAutoMigrate_ErrorHandling(t *testing.T) {
|
||||
logger := zaptest.NewLogger(t)
|
||||
|
||||
// 测试未初始化时的错误处理
|
||||
err := AutoMigrate(logger)
|
||||
if err == nil {
|
||||
// 如果数据库已初始化,这是正常的
|
||||
t.Log("AutoMigrate() 成功(数据库可能已初始化)")
|
||||
} else {
|
||||
// 如果数据库未初始化,应该返回错误
|
||||
if err.Error() == "" {
|
||||
t.Error("AutoMigrate() 应该返回有意义的错误消息")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClose_NotInitialized 测试未初始化时关闭数据库
|
||||
func TestClose_NotInitialized(t *testing.T) {
|
||||
// 未初始化时关闭应该不返回错误
|
||||
err := Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close() 在未初始化时应该返回nil,实际返回: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -50,13 +50,8 @@ var defaultCasbinRules = []model.CasbinRule{
|
||||
{PType: "g", V0: "admin", V1: "user"},
|
||||
}
|
||||
|
||||
// Seed 初始化种子数据
|
||||
func Seed(logger *zap.Logger) error {
|
||||
db, err := GetDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// SeedWithDB 使用指定的 *gorm.DB 初始化种子数据(供 DI 使用)
|
||||
func SeedWithDB(db *gorm.DB, logger *zap.Logger) error {
|
||||
logger.Info("开始初始化种子数据...")
|
||||
|
||||
// 初始化默认管理员用户
|
||||
|
||||
@@ -2,7 +2,6 @@ package email
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"carrotskin/pkg/config"
|
||||
@@ -10,25 +9,18 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func resetEmailOnce() {
|
||||
serviceInstance = nil
|
||||
once = sync.Once{}
|
||||
}
|
||||
// 全局单例访问器(Init/GetService/MustGetService)已在 DI 迁移中移除。
|
||||
// 本测试改为直接使用构造函数 NewService。
|
||||
|
||||
func TestEmailManager_Disabled(t *testing.T) {
|
||||
resetEmailOnce()
|
||||
cfg := config.EmailConfig{Enabled: false}
|
||||
if err := Init(cfg, zap.NewNop()); err != nil {
|
||||
t.Fatalf("Init disabled err: %v", err)
|
||||
}
|
||||
svc := MustGetService()
|
||||
svc := NewService(cfg, zap.NewNop())
|
||||
if err := svc.SendVerificationCode("to@test.com", "123456", "email_verification"); err == nil {
|
||||
t.Fatalf("expected error when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailManager_SendFailsWithInvalidSMTP(t *testing.T) {
|
||||
resetEmailOnce()
|
||||
cfg := config.EmailConfig{
|
||||
Enabled: true,
|
||||
SMTPHost: "127.0.0.1",
|
||||
@@ -37,8 +29,7 @@ func TestEmailManager_SendFailsWithInvalidSMTP(t *testing.T) {
|
||||
Password: "pwd",
|
||||
FromName: "name",
|
||||
}
|
||||
_ = Init(cfg, zap.NewNop())
|
||||
svc := MustGetService()
|
||||
svc := NewService(cfg, zap.NewNop())
|
||||
if err := svc.SendVerificationCode("to@test.com", "123456", "reset_password"); err == nil {
|
||||
t.Fatalf("expected send error with invalid smtp")
|
||||
}
|
||||
|
||||
@@ -1,54 +1,8 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"carrotskin/pkg/config"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
// serviceInstance 全局邮件服务实例
|
||||
serviceInstance *Service
|
||||
// once 确保只初始化一次
|
||||
once sync.Once
|
||||
// initError 初始化错误
|
||||
initError error
|
||||
)
|
||||
|
||||
// Init 初始化邮件服务(线程安全,只会执行一次)
|
||||
func Init(cfg config.EmailConfig, logger *zap.Logger) error {
|
||||
once.Do(func() {
|
||||
serviceInstance = NewService(cfg, logger)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetService 获取邮件服务实例(线程安全)
|
||||
func GetService() (*Service, error) {
|
||||
if serviceInstance == nil {
|
||||
return nil, fmt.Errorf("邮件服务未初始化,请先调用 email.Init()")
|
||||
}
|
||||
return serviceInstance, nil
|
||||
}
|
||||
|
||||
// MustGetService 获取邮件服务实例,如果未初始化则panic
|
||||
func MustGetService() *Service {
|
||||
service, err := GetService()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 本文件原包含基于 sync.Once 的全局单例(Init/GetService/MustGetService)。
|
||||
//
|
||||
// 在 DI 迁移(阶段4)后,全局单例已被移除。Email 服务由 fx.Provide(email.NewService)
|
||||
// 构造,通过构造函数参数注入到各消费者。
|
||||
//
|
||||
// 邮件服务构造逻辑见 email.go 的 NewService()。
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"carrotskin/pkg/config"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap/zaptest"
|
||||
)
|
||||
|
||||
func resetEmail() {
|
||||
serviceInstance = nil
|
||||
once = sync.Once{}
|
||||
}
|
||||
|
||||
// TestGetService_NotInitialized 测试未初始化时获取邮件服务
|
||||
func TestGetService_NotInitialized(t *testing.T) {
|
||||
resetEmail()
|
||||
_, err := GetService()
|
||||
if err == nil {
|
||||
t.Error("未初始化时应该返回错误")
|
||||
}
|
||||
|
||||
expectedError := "邮件服务未初始化,请先调用 email.Init()"
|
||||
if err.Error() != expectedError {
|
||||
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMustGetService_Panic 测试MustGetService在未初始化时panic
|
||||
func TestMustGetService_Panic(t *testing.T) {
|
||||
resetEmail()
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("MustGetService 应该在未初始化时panic")
|
||||
}
|
||||
}()
|
||||
|
||||
_ = MustGetService()
|
||||
}
|
||||
|
||||
// TestInit_Email 测试邮件服务初始化
|
||||
func TestInit_Email(t *testing.T) {
|
||||
resetEmail()
|
||||
cfg := config.EmailConfig{
|
||||
Enabled: false,
|
||||
SMTPHost: "smtp.example.com",
|
||||
SMTPPort: 587,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
FromName: "noreply@example.com",
|
||||
}
|
||||
|
||||
logger := zaptest.NewLogger(t)
|
||||
|
||||
err := Init(cfg, logger)
|
||||
if err != nil {
|
||||
t.Errorf("Init() 错误 = %v, want nil", err)
|
||||
}
|
||||
|
||||
// 验证可以获取服务
|
||||
service, err := GetService()
|
||||
if err != nil {
|
||||
t.Errorf("GetService() 错误 = %v, want nil", err)
|
||||
}
|
||||
if service == nil {
|
||||
t.Error("GetService() 返回的服务不应为nil")
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,8 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"carrotskin/pkg/config"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
// loggerInstance 全局日志实例
|
||||
loggerInstance *zap.Logger
|
||||
// once 确保只初始化一次
|
||||
once sync.Once
|
||||
// initError 初始化错误
|
||||
initError error
|
||||
)
|
||||
|
||||
// Init 初始化日志记录器(线程安全,只会执行一次)
|
||||
func Init(cfg config.LogConfig) error {
|
||||
once.Do(func() {
|
||||
loggerInstance, initError = New(cfg)
|
||||
if initError != nil {
|
||||
return
|
||||
}
|
||||
})
|
||||
return initError
|
||||
}
|
||||
|
||||
// GetLogger 获取日志实例(线程安全)
|
||||
func GetLogger() (*zap.Logger, error) {
|
||||
if loggerInstance == nil {
|
||||
return nil, fmt.Errorf("日志未初始化,请先调用 logger.Init()")
|
||||
}
|
||||
return loggerInstance, nil
|
||||
}
|
||||
|
||||
// MustGetLogger 获取日志实例,如果未初始化则panic
|
||||
func MustGetLogger() *zap.Logger {
|
||||
logger, err := GetLogger()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return logger
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 本文件原包含基于 sync.Once 的全局单例(Init/GetLogger/MustGetLogger)。
|
||||
//
|
||||
// 在 DI 迁移(阶段4)后,全局单例已被移除。Logger 由 fx.Provide(logger.New)
|
||||
// 构造,通过构造函数参数注入到各消费者。
|
||||
//
|
||||
// 日志构造逻辑见 logger.go 的 New()。
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"carrotskin/pkg/config"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetLogger_NotInitialized 测试未初始化时获取日志实例
|
||||
func TestGetLogger_NotInitialized(t *testing.T) {
|
||||
_, err := GetLogger()
|
||||
if err == nil {
|
||||
t.Error("未初始化时应该返回错误")
|
||||
}
|
||||
|
||||
expectedError := "日志未初始化,请先调用 logger.Init()"
|
||||
if err.Error() != expectedError {
|
||||
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMustGetLogger_Panic 测试MustGetLogger在未初始化时panic
|
||||
func TestMustGetLogger_Panic(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("MustGetLogger 应该在未初始化时panic")
|
||||
}
|
||||
}()
|
||||
|
||||
_ = MustGetLogger()
|
||||
}
|
||||
|
||||
// TestInit_Logger 测试日志初始化逻辑
|
||||
func TestInit_Logger(t *testing.T) {
|
||||
cfg := config.LogConfig{
|
||||
Level: "info",
|
||||
Format: "json",
|
||||
Output: "stdout",
|
||||
}
|
||||
|
||||
// 验证Init函数存在且可调用
|
||||
err := Init(cfg)
|
||||
if err != nil {
|
||||
// 初始化可能失败(例如缺少依赖),这是可以接受的
|
||||
t.Logf("Init() 返回错误(可能正常): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +1,38 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"carrotskin/pkg/config"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
redis9 "github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
// clientInstance 全局Redis客户端实例
|
||||
clientInstance *Client
|
||||
// once 确保只初始化一次
|
||||
once sync.Once
|
||||
// initError 初始化错误
|
||||
initError error
|
||||
// miniredisInstance 用于测试/开发环境
|
||||
miniredisInstance *miniredis.Miniredis
|
||||
)
|
||||
// 本文件原包含基于 sync.Once 的全局单例(Init/GetClient/MustGetClient/Close)。
|
||||
//
|
||||
// 在 DI 迁移(阶段4)后,全局单例已被移除。Redis 客户端由 fx.Provide 构造
|
||||
// (见 internal/app/infra_module.go 的 provideRedis,包含 miniredis 回退逻辑),
|
||||
// 通过构造函数参数注入到各消费者。
|
||||
//
|
||||
// Redis 客户端构造逻辑见 redis.go 的 New()。
|
||||
|
||||
// Init 初始化Redis客户端(线程安全,只会执行一次)
|
||||
// 如果Redis连接失败且环境为测试/开发,则回退到miniredis
|
||||
func Init(cfg config.RedisConfig, logger *zap.Logger) error {
|
||||
var err error
|
||||
once.Do(func() {
|
||||
// 尝试连接真实Redis
|
||||
clientInstance, err = New(cfg, logger)
|
||||
if err != nil {
|
||||
logger.Warn("Redis连接失败,尝试使用miniredis回退", zap.Error(err))
|
||||
|
||||
// 检查是否允许回退到miniredis(仅开发/测试环境)
|
||||
if allowFallbackToMiniRedis() {
|
||||
clientInstance, err = initMiniRedis(logger)
|
||||
if err != nil {
|
||||
initError = fmt.Errorf("Redis和miniredis都初始化失败: %w", err)
|
||||
logger.Error("miniredis初始化失败", zap.Error(initError))
|
||||
return
|
||||
}
|
||||
logger.Info("已回退到miniredis用于开发/测试环境")
|
||||
} else {
|
||||
initError = fmt.Errorf("Redis连接失败且不允许回退: %w", err)
|
||||
logger.Error("Redis连接失败", zap.Error(initError))
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
return initError
|
||||
}
|
||||
|
||||
// allowFallbackToMiniRedis 检查是否允许回退到miniredis
|
||||
func allowFallbackToMiniRedis() bool {
|
||||
// 检查环境变量
|
||||
// AllowFallbackToMiniRedis 检查当前环境是否允许回退到 miniredis(仅开发/测试环境)。
|
||||
func AllowFallbackToMiniRedis() bool {
|
||||
env := os.Getenv("ENVIRONMENT")
|
||||
return env == "development" || env == "test" || env == "dev" ||
|
||||
os.Getenv("USE_MINIREDIS") == "true"
|
||||
}
|
||||
|
||||
// initMiniRedis 初始化miniredis(用于开发/测试环境)
|
||||
func initMiniRedis(logger *zap.Logger) (*Client, error) {
|
||||
var err error
|
||||
miniredisInstance, err = miniredis.Run()
|
||||
// InitMiniRedis 初始化 miniredis(用于开发/测试环境的 DI 回退)。
|
||||
func InitMiniRedis(logger *zap.Logger) (*Client, error) {
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("启动miniredis失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建Redis客户端连接到miniredis
|
||||
redisClient := redis9.NewClient(&redis9.Options{
|
||||
Addr: miniredisInstance.Addr(),
|
||||
Addr: mr.Addr(),
|
||||
})
|
||||
|
||||
client := &Client{
|
||||
@@ -77,42 +40,6 @@ func initMiniRedis(logger *zap.Logger) (*Client, error) {
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
logger.Info("miniredis已启动", zap.String("addr", miniredisInstance.Addr()))
|
||||
logger.Info("miniredis已启动", zap.String("addr", mr.Addr()))
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetClient 获取Redis客户端实例(线程安全)
|
||||
func GetClient() (*Client, error) {
|
||||
if clientInstance == nil {
|
||||
return nil, fmt.Errorf("Redis客户端未初始化,请先调用 redis.Init()")
|
||||
}
|
||||
return clientInstance, nil
|
||||
}
|
||||
|
||||
// MustGetClient 获取Redis客户端实例,如果未初始化则panic
|
||||
func MustGetClient() *Client {
|
||||
client, err := GetClient()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// Close 关闭Redis连接(包括miniredis如果使用了)
|
||||
func Close() error {
|
||||
var err error
|
||||
if miniredisInstance != nil {
|
||||
miniredisInstance.Close()
|
||||
miniredisInstance = nil
|
||||
}
|
||||
if clientInstance != nil {
|
||||
err = clientInstance.Close()
|
||||
clientInstance = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// IsUsingMiniRedis 检查是否使用了miniredis
|
||||
func IsUsingMiniRedis() bool {
|
||||
return miniredisInstance != nil
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"carrotskin/pkg/config"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap/zaptest"
|
||||
)
|
||||
|
||||
// TestGetClient_NotInitialized 测试未初始化时获取Redis客户端
|
||||
func TestGetClient_NotInitialized(t *testing.T) {
|
||||
_, err := GetClient()
|
||||
if err == nil {
|
||||
t.Error("未初始化时应该返回错误")
|
||||
}
|
||||
|
||||
expectedError := "Redis客户端未初始化,请先调用 redis.Init()"
|
||||
if err.Error() != expectedError {
|
||||
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMustGetClient_Panic 测试MustGetClient在未初始化时panic
|
||||
func TestMustGetClient_Panic(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("MustGetClient 应该在未初始化时panic")
|
||||
}
|
||||
}()
|
||||
|
||||
_ = MustGetClient()
|
||||
}
|
||||
|
||||
// TestInit_Redis 测试Redis初始化逻辑
|
||||
func TestInit_Redis(t *testing.T) {
|
||||
cfg := config.RedisConfig{
|
||||
Host: "localhost",
|
||||
Port: 6379,
|
||||
Password: "",
|
||||
Database: 0,
|
||||
PoolSize: 10,
|
||||
}
|
||||
|
||||
logger := zaptest.NewLogger(t)
|
||||
|
||||
// 验证Init函数存在且可调用
|
||||
// 注意:实际连接可能失败,这是可以接受的
|
||||
err := Init(cfg, logger)
|
||||
if err != nil {
|
||||
t.Logf("Init() 返回错误(可能正常,如果Redis未运行): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,8 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"carrotskin/pkg/config"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
// clientInstance 全局存储客户端实例
|
||||
clientInstance *StorageClient
|
||||
// once 确保只初始化一次
|
||||
once sync.Once
|
||||
// initError 初始化错误
|
||||
initError error
|
||||
)
|
||||
|
||||
// Init 初始化存储客户端(线程安全,只会执行一次)
|
||||
func Init(cfg config.RustFSConfig) error {
|
||||
once.Do(func() {
|
||||
clientInstance, initError = NewStorage(cfg)
|
||||
if initError != nil {
|
||||
return
|
||||
}
|
||||
})
|
||||
return initError
|
||||
}
|
||||
|
||||
// GetClient 获取存储客户端实例(线程安全)
|
||||
func GetClient() (*StorageClient, error) {
|
||||
if clientInstance == nil {
|
||||
return nil, fmt.Errorf("存储客户端未初始化,请先调用 storage.Init()")
|
||||
}
|
||||
return clientInstance, nil
|
||||
}
|
||||
|
||||
// MustGetClient 获取存储客户端实例,如果未初始化则panic
|
||||
func MustGetClient() *StorageClient {
|
||||
client, err := GetClient()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 本文件原包含基于 sync.Once 的全局单例(Init/GetClient/MustGetClient)。
|
||||
//
|
||||
// 在 DI 迁移(阶段4)后,全局单例已被移除。Storage 客户端由 fx.Provide(storage.NewStorage)
|
||||
// 构造,通过构造函数参数注入到各消费者。
|
||||
//
|
||||
// 存储客户端构造逻辑见 minio.go 的 NewStorage()。
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"carrotskin/pkg/config"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetClient_NotInitialized 测试未初始化时获取存储客户端
|
||||
func TestGetClient_NotInitialized(t *testing.T) {
|
||||
_, err := GetClient()
|
||||
if err == nil {
|
||||
t.Error("未初始化时应该返回错误")
|
||||
}
|
||||
|
||||
expectedError := "存储客户端未初始化,请先调用 storage.Init()"
|
||||
if err.Error() != expectedError {
|
||||
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMustGetClient_Panic 测试MustGetClient在未初始化时panic
|
||||
func TestMustGetClient_Panic(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("MustGetClient 应该在未初始化时panic")
|
||||
}
|
||||
}()
|
||||
|
||||
_ = MustGetClient()
|
||||
}
|
||||
|
||||
// TestInit_Storage 测试存储客户端初始化逻辑
|
||||
func TestInit_Storage(t *testing.T) {
|
||||
cfg := config.RustFSConfig{
|
||||
Endpoint: "http://localhost:9000",
|
||||
AccessKey: "minioadmin",
|
||||
SecretKey: "minioadmin",
|
||||
UseSSL: false,
|
||||
Buckets: map[string]string{
|
||||
"avatars": "avatars",
|
||||
"textures": "textures",
|
||||
},
|
||||
}
|
||||
|
||||
// 验证Init函数存在且可调用
|
||||
// 注意:实际连接可能失败,这是可以接受的
|
||||
err := Init(cfg)
|
||||
if err != nil {
|
||||
t.Logf("Init() 返回错误(可能正常,如果存储服务未运行): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,6 @@ func (s *StorageClient) GetBucket(name string) (string, error) {
|
||||
return bucket, nil
|
||||
}
|
||||
|
||||
|
||||
// BuildFileURL 构建文件的公开访问URL
|
||||
func (s *StorageClient) BuildFileURL(bucketName, objectName string) string {
|
||||
return fmt.Sprintf("%s/%s/%s", s.publicURL, bucketName, objectName)
|
||||
|
||||
Reference in New Issue
Block a user