Compare commits
6 Commits
7d1c78f965
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fc1be0ec1 | ||
|
|
f2b02682c2 | ||
|
|
1dc9c36a3a | ||
| cea22951b9 | |||
|
|
ae3a569f03 | ||
|
|
b23a169925 |
12
.env.example
12
.env.example
@@ -30,6 +30,13 @@ SERVER_READ_TIMEOUT=30s
|
|||||||
SERVER_WRITE_TIMEOUT=30s
|
SERVER_WRITE_TIMEOUT=30s
|
||||||
SERVER_SWAGGER_ENABLED=true
|
SERVER_SWAGGER_ENABLED=true
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 应用环境
|
||||||
|
# =============================================================================
|
||||||
|
# 用于 service 层 IsTestEnvironment() 短路验证码/邮件等
|
||||||
|
# 取值:development / test / production
|
||||||
|
ENVIRONMENT=production
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -133,6 +140,11 @@ JWT_EXPIRE_HOURS=168
|
|||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
LOG_FORMAT=json
|
LOG_FORMAT=json
|
||||||
LOG_OUTPUT=logs/app.log
|
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 去重
|
- **材质中心**:皮肤/披风上传、搜索、收藏、下载统计、Hash 去重
|
||||||
- **角色档案**:Minecraft Profile 管理、RSA 密钥对生成、活跃档案切换
|
- **角色档案**:Minecraft Profile 管理、RSA 密钥对生成、活跃档案切换
|
||||||
- **存储与上传**:RustFS/MinIO 预签名 URL,减轻服务器带宽压力
|
- **存储与上传**:RustFS/MinIO(S3 兼容)直接上传,Hash 分片存储
|
||||||
- **任务与日志**:登录日志、操作审计、材质下载记录、定时任务
|
- **任务与日志**:登录日志、操作审计、材质下载记录、定时任务
|
||||||
- **权限体系**:Casbin RBAC,支持细粒度路线授权
|
- **权限体系**:Casbin RBAC,支持细粒度路线授权
|
||||||
- **配置管理**:100% 依赖环境变量,`SERVER_SWAGGER_ENABLED` 控制 Swagger
|
- **配置管理**:100% 依赖环境变量,`SERVER_SWAGGER_ENABLED` 控制 Swagger
|
||||||
@@ -18,44 +18,58 @@
|
|||||||
|
|
||||||
| 类型 | 选型 |
|
| 类型 | 选型 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 语言 / 运行时 | Go 1.24+ |
|
| 语言 / 运行时 | Go 1.25+ |
|
||||||
| Web 框架 | Gin |
|
| Web 框架 | Gin |
|
||||||
| ORM | GORM (PostgreSQL 驱动) |
|
| ORM | GORM (PostgreSQL 驱动) |
|
||||||
| 数据库 | PostgreSQL 15+ |
|
| 数据库 | PostgreSQL 15+ |
|
||||||
| 缓存 / 消息 | Redis 6+ |
|
| 缓存 / 消息 | Redis 6+ |
|
||||||
| 对象存储 | RustFS / MinIO(S3 兼容) |
|
| 对象存储 | RustFS / MinIO(S3 兼容) |
|
||||||
| 权限控制 | Casbin |
|
| 权限控制 | Casbin v3 |
|
||||||
| 配置 | Viper + `.env` |
|
| 依赖注入 | uber-go/fx |
|
||||||
|
| 配置 | Viper + godotenv + `.env` |
|
||||||
| API 文档 | swaggo / Swagger UI |
|
| API 文档 | swaggo / Swagger UI |
|
||||||
|
| 滑动验证码 | wenlng/go-captcha |
|
||||||
| 日志 | Uber Zap |
|
| 日志 | Uber Zap |
|
||||||
|
|
||||||
## 📁 目录结构
|
## 📁 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
├── cmd/server/ # 应用入口(main.go)
|
├── cmd/server/ # 应用入口(main.go:极简 fx 装配)
|
||||||
├── internal/
|
├── 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 注解
|
│ ├── handler/ # HTTP Handler 与 Swagger 注解
|
||||||
│ ├── service/ # 业务逻辑
|
│ ├── service/ # 业务逻辑(接口 + 实现)
|
||||||
│ ├── repository/ # 数据访问
|
│ ├── repository/ # 数据访问(接口 + GORM 实现)
|
||||||
│ ├── model/ # GORM 数据模型
|
│ ├── model/ # GORM 数据模型与响应结构
|
||||||
│ ├── types/ # 请求/响应 DTO
|
│ ├── types/ # 请求/响应 DTO
|
||||||
|
│ ├── errors/ # 统一错误定义(apperrors)
|
||||||
│ ├── middleware/ # Gin 中间件
|
│ ├── middleware/ # Gin 中间件
|
||||||
│ └── task/ # 定时任务与后台作业
|
│ └── task/ # 定时任务与后台作业
|
||||||
├── pkg/ # 可复用组件(config、database、auth、logger、redis、storage 等)
|
├── pkg/ # 可复用组件(config、database、auth、logger、redis、storage、email、utils)
|
||||||
├── docs/ # swagger 生成产物(docs.go / swagger.json / swagger.yaml)
|
├── configs/casbin/ # Casbin RBAC 模型定义(rbac_model.conf)
|
||||||
├── start.sh # 启动脚本(自动 swag init)
|
├── start.sh # 启动脚本(自动 swag init)
|
||||||
├── docker-compose.yml # 本地容器编排
|
├── docker-compose.yml # 本地容器编排(含 rustfs-init 自动建桶)
|
||||||
├── .env.example # 环境变量示例
|
├── .env.example # 环境变量示例
|
||||||
└── go.mod # Go Module 定义
|
└── go.mod # Go Module 定义
|
||||||
```
|
```
|
||||||
|
|
||||||
## ✅ 前置要求
|
## ✅ 前置要求
|
||||||
|
|
||||||
- Go 1.24+
|
- Go 1.25+
|
||||||
- PostgreSQL 15+
|
- PostgreSQL 15+
|
||||||
- Redis 6+
|
- Redis 6+
|
||||||
- RustFS / MinIO(或其他兼容 S3 的对象存储,用于皮肤与头像)
|
- RustFS / MinIO(或其他兼容 S3 的对象存储,用于皮肤与头像)
|
||||||
|
- swag(生成 Swagger 文档,见 <https://github.com/swaggo/swag>)
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
@@ -81,25 +95,144 @@ backend/
|
|||||||
createdb carrotskin
|
createdb carrotskin
|
||||||
# 或 psql -c "CREATE DATABASE carrotskin;"
|
# 或 psql -c "CREATE DATABASE carrotskin;"
|
||||||
```
|
```
|
||||||
> 应用启动时会执行 `AutoMigrate`,自动创建 / 更新表结构。
|
> 应用启动时会执行 `AutoMigrate`,自动创建 / 更新表结构,并写入种子数据(默认管理员 + Casbin 规则)。
|
||||||
|
|
||||||
5. **启动服务**
|
5. **准备对象存储**(创建存储桶 + 配置策略与跨域,**见下方 [🪣 对象存储配置](#-对象存储配置) 章节**)
|
||||||
- **推荐**:`./start.sh`(自动 `swag init`,随后 `go run cmd/server/main.go`)
|
|
||||||
|
6. **启动服务**
|
||||||
|
- **推荐**:`./start.sh`(自动 `swag init`,随后 `go run ./cmd/server`)
|
||||||
- **手动启动**:
|
- **手动启动**:
|
||||||
```bash
|
```bash
|
||||||
swag init -g cmd/server/main.go -o docs
|
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`
|
- API Root: `http://localhost:8080`
|
||||||
- Swagger: `http://localhost:8080/swagger/index.html`(需 `SERVER_SWAGGER_ENABLED=true`)
|
- 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_MODE` | Gin 模式(debug/release) | `debug` |
|
||||||
| `SERVER_SWAGGER_ENABLED` | 是否暴露 Swagger UI | `true` |
|
| `SERVER_SWAGGER_ENABLED` | 是否暴露 Swagger UI | `true` |
|
||||||
| `DATABASE_HOST` / `DATABASE_PORT` | PostgreSQL 地址 | `localhost` / `5432` |
|
| `DATABASE_HOST` / `DATABASE_PORT` | PostgreSQL 地址 | `localhost` / `5432` |
|
||||||
@@ -107,12 +240,16 @@ backend/
|
|||||||
| `DATABASE_NAME` | 数据库名称 | `carrotskin` |
|
| `DATABASE_NAME` | 数据库名称 | `carrotskin` |
|
||||||
| `REDIS_HOST` / `REDIS_PORT` | Redis 地址 | `localhost` / `6379` |
|
| `REDIS_HOST` / `REDIS_PORT` | Redis 地址 | `localhost` / `6379` |
|
||||||
| `REDIS_PASSWORD` | Redis 密码(无可为空) | `` |
|
| `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_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` |
|
| `JWT_SECRET` | JWT 签名密钥 | `change-me` |
|
||||||
| `EMAIL_ENABLED` | 是否开启邮件服务 | `true` |
|
| `EMAIL_ENABLED` | 是否开启邮件服务 | `true` |
|
||||||
| `EMAIL_SMTP_HOST` / `EMAIL_SMTP_PORT` | SMTP 配置 | `smtp.example.com` / `587` |
|
| `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`。
|
更多变量请参考 `.env.example` 与 `.env.docker.example`。
|
||||||
|
|
||||||
@@ -132,17 +269,28 @@ golangci-lint run (若已安装)
|
|||||||
|
|
||||||
## 🧱 架构说明
|
## 🧱 架构说明
|
||||||
|
|
||||||
- **分层设计**:Handler -> Service -> Repository -> Model,层次清晰、职责单一。
|
- **分层设计**:Handler → Service → Repository → Model,层次清晰、职责单一。
|
||||||
- **依赖管理器**:`pkg/*/manager.go` 使用 `sync.Once` 实现线程安全单例(DB / Redis / Logger / Storage / Email / Auth / Config)。
|
- 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 文档。
|
- **Swagger 注解**:所有 Handler、模型、DTO 均补齐 `@Summary` / `@Description` / `@Success`,可直接生成 OpenAPI 文档。
|
||||||
- **配置优先级**:`.env` -> 系统环境变量,所有配置均可通过容器注入。
|
|
||||||
- **自动任务**:`internal/task` 承载后台作业,可按需扩展。
|
|
||||||
|
|
||||||
## 📝 Swagger 说明
|
## 📝 Swagger 说明
|
||||||
|
|
||||||
- `start.sh` 会在启动前执行 `swag init -g cmd/server/main.go -o docs`
|
- `start.sh` 会在启动前执行 `swag init -g cmd/server/main.go -o docs`,生成 `docs/` 目录(`docs.go` / `swagger.json` / `swagger.yaml`)。
|
||||||
- 若手动运行,需要保证 `docs/` 下的 `docs.go`、`swagger.json`、`swagger.yaml` 与代码同步
|
- `docs/` 为生成产物,**不在仓库内跟踪**,首次启动会自动生成。
|
||||||
- 通过 `SERVER_SWAGGER_ENABLED=false` 可在生产环境关闭 Swagger UI 暴露
|
- 通过 `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)
|
||||||
|
}
|
||||||
@@ -20,6 +20,13 @@ import (
|
|||||||
// 每个需要资源释放的组件都通过 fx.Lifecycle 注册 OnStop 钩子,
|
// 每个需要资源释放的组件都通过 fx.Lifecycle 注册 OnStop 钩子,
|
||||||
// 彻底替代 main.go 里分散的 defer xxx.Close()。
|
// 彻底替代 main.go 里分散的 defer xxx.Close()。
|
||||||
var InfraModule = fx.Options(
|
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
|
// Logger
|
||||||
fx.Provide(logger.New),
|
fx.Provide(logger.New),
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"carrotskin/internal/repository"
|
"carrotskin/internal/repository"
|
||||||
"carrotskin/internal/service"
|
"carrotskin/internal/service"
|
||||||
"carrotskin/pkg/auth"
|
"carrotskin/pkg/auth"
|
||||||
@@ -9,8 +12,6 @@ import (
|
|||||||
"carrotskin/pkg/email"
|
"carrotskin/pkg/email"
|
||||||
"carrotskin/pkg/redis"
|
"carrotskin/pkg/redis"
|
||||||
"carrotskin/pkg/storage"
|
"carrotskin/pkg/storage"
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -37,6 +38,8 @@ type Container struct {
|
|||||||
TextureRepo repository.TextureRepository
|
TextureRepo repository.TextureRepository
|
||||||
ClientRepo repository.ClientRepository
|
ClientRepo repository.ClientRepository
|
||||||
YggdrasilRepo repository.YggdrasilRepository
|
YggdrasilRepo repository.YggdrasilRepository
|
||||||
|
FriendRepo repository.FriendRepository
|
||||||
|
PlayerAttrRepo repository.PlayerAttributeRepository
|
||||||
|
|
||||||
// Service层
|
// Service层
|
||||||
UserService service.UserService
|
UserService service.UserService
|
||||||
@@ -44,6 +47,7 @@ type Container struct {
|
|||||||
TextureService service.TextureService
|
TextureService service.TextureService
|
||||||
TokenService service.TokenService
|
TokenService service.TokenService
|
||||||
YggdrasilService service.YggdrasilService
|
YggdrasilService service.YggdrasilService
|
||||||
|
FriendsService service.FriendsService
|
||||||
VerificationService service.VerificationService
|
VerificationService service.VerificationService
|
||||||
CaptchaService service.CaptchaService
|
CaptchaService service.CaptchaService
|
||||||
SignatureService *service.SignatureService
|
SignatureService *service.SignatureService
|
||||||
@@ -92,6 +96,8 @@ func NewContainer(
|
|||||||
c.TextureRepo = repository.NewTextureRepository(db)
|
c.TextureRepo = repository.NewTextureRepository(db)
|
||||||
c.ClientRepo = repository.NewClientRepository(db)
|
c.ClientRepo = repository.NewClientRepository(db)
|
||||||
c.YggdrasilRepo = repository.NewYggdrasilRepository(db)
|
c.YggdrasilRepo = repository.NewYggdrasilRepository(db)
|
||||||
|
c.FriendRepo = repository.NewFriendRepository(db)
|
||||||
|
c.PlayerAttrRepo = repository.NewPlayerAttributeRepository(db)
|
||||||
|
|
||||||
// 初始化SignatureService(作为依赖注入,避免在容器中创建并立即调用)
|
// 初始化SignatureService(作为依赖注入,避免在容器中创建并立即调用)
|
||||||
// 将SignatureService添加到容器中,供其他服务使用
|
// 将SignatureService添加到容器中,供其他服务使用
|
||||||
@@ -132,6 +138,9 @@ func NewContainer(
|
|||||||
// 使用组合服务(内部包含认证、会话、序列化、证书服务)
|
// 使用组合服务(内部包含认证、会话、序列化、证书服务)
|
||||||
c.YggdrasilService = service.NewYggdrasilServiceComposite(c.UserRepo, c.ProfileRepo, c.YggdrasilRepo, c.TextureRepo, 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.CaptchaService = service.NewCaptchaService(cfg, redisClient, logger)
|
c.CaptchaService = service.NewCaptchaService(cfg, redisClient, logger)
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,12 @@ var (
|
|||||||
ErrUserNotFound = errors.New("用户不存在")
|
ErrUserNotFound = errors.New("用户不存在")
|
||||||
ErrUserAlreadyExists = errors.New("用户已存在")
|
ErrUserAlreadyExists = errors.New("用户已存在")
|
||||||
ErrEmailAlreadyExists = errors.New("邮箱已被注册")
|
ErrEmailAlreadyExists = errors.New("邮箱已被注册")
|
||||||
ErrInvalidPassword = errors.New("密码错误")
|
|
||||||
ErrAccountDisabled = errors.New("账号已被禁用")
|
ErrAccountDisabled = errors.New("账号已被禁用")
|
||||||
|
|
||||||
// 认证相关错误
|
// 认证相关错误
|
||||||
ErrUnauthorized = errors.New("未授权")
|
ErrUnauthorized = errors.New("未授权")
|
||||||
ErrInvalidToken = errors.New("无效的令牌")
|
ErrInvalidToken = errors.New("无效的令牌")
|
||||||
ErrTokenExpired = errors.New("令牌已过期")
|
ErrTokenExpired = errors.New("令牌已过期")
|
||||||
ErrInvalidSignature = errors.New("签名验证失败")
|
|
||||||
|
|
||||||
// 档案相关错误
|
// 档案相关错误
|
||||||
ErrProfileNotFound = errors.New("档案不存在")
|
ErrProfileNotFound = errors.New("档案不存在")
|
||||||
@@ -32,7 +30,6 @@ var (
|
|||||||
ErrTextureExists = errors.New("该材质已存在")
|
ErrTextureExists = errors.New("该材质已存在")
|
||||||
ErrTextureLimitReached = errors.New("已达材质数量上限")
|
ErrTextureLimitReached = errors.New("已达材质数量上限")
|
||||||
ErrTextureNoPermission = errors.New("无权操作此材质")
|
ErrTextureNoPermission = errors.New("无权操作此材质")
|
||||||
ErrInvalidTextureType = errors.New("无效的材质类型")
|
|
||||||
|
|
||||||
// 验证码相关错误
|
// 验证码相关错误
|
||||||
ErrInvalidVerificationCode = errors.New("验证码错误或已过期")
|
ErrInvalidVerificationCode = errors.New("验证码错误或已过期")
|
||||||
@@ -54,15 +51,11 @@ var (
|
|||||||
ErrSessionNotFound = errors.New("会话不存在或已过期")
|
ErrSessionNotFound = errors.New("会话不存在或已过期")
|
||||||
ErrSessionMismatch = errors.New("会话验证失败")
|
ErrSessionMismatch = errors.New("会话验证失败")
|
||||||
ErrUsernameMismatch = errors.New("用户名不匹配")
|
ErrUsernameMismatch = errors.New("用户名不匹配")
|
||||||
|
ErrUsernameRequired = errors.New("用户名不能为空")
|
||||||
ErrIPMismatch = errors.New("IP地址不匹配")
|
ErrIPMismatch = errors.New("IP地址不匹配")
|
||||||
ErrInvalidAccessToken = errors.New("访问令牌无效")
|
ErrInvalidAccessToken = errors.New("访问令牌无效")
|
||||||
ErrProfileMismatch = errors.New("selectedProfile与Token不匹配")
|
ErrProfileMismatch = errors.New("selectedProfile与Token不匹配")
|
||||||
ErrUUIDRequired = errors.New("UUID不能为空")
|
ErrUUIDRequired = errors.New("UUID不能为空")
|
||||||
ErrCertificateGenerate = errors.New("生成证书失败")
|
|
||||||
|
|
||||||
// Yggdrasil协议标准错误
|
|
||||||
ErrYggForbiddenOperation = errors.New("ForbiddenOperationException")
|
|
||||||
ErrYggIllegalArgument = errors.New("IllegalArgumentException")
|
|
||||||
|
|
||||||
// 通用错误
|
// 通用错误
|
||||||
ErrBadRequest = errors.New("请求参数错误")
|
ErrBadRequest = errors.New("请求参数错误")
|
||||||
@@ -128,7 +121,6 @@ func NewInternalError(message string, err error) *AppError {
|
|||||||
// 注意:原此处的 Is/As/Wrap 函数(透传标准库)已删除。
|
// 注意:原此处的 Is/As/Wrap 函数(透传标准库)已删除。
|
||||||
// 请直接使用标准库 errors.Is / errors.As / fmt.Errorf。
|
// 请直接使用标准库 errors.Is / errors.As / fmt.Errorf。
|
||||||
|
|
||||||
|
|
||||||
// YggdrasilErrorResponse Yggdrasil协议标准错误响应格式
|
// YggdrasilErrorResponse Yggdrasil协议标准错误响应格式
|
||||||
type YggdrasilErrorResponse struct {
|
type YggdrasilErrorResponse struct {
|
||||||
Error string `json:"error"` // 错误的简要描述(机器可读)
|
Error string `json:"error"` // 错误的简要描述(机器可读)
|
||||||
@@ -154,3 +146,41 @@ const (
|
|||||||
// IllegalArgumentException 错误消息
|
// IllegalArgumentException 错误消息
|
||||||
YggErrProfileAlreadyAssigned = "Access token already has a profile assigned."
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
apperrors "carrotskin/internal/errors"
|
|
||||||
"carrotskin/internal/container"
|
"carrotskin/internal/container"
|
||||||
|
apperrors "carrotskin/internal/errors"
|
||||||
"carrotskin/internal/model"
|
"carrotskin/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrotskin/internal/container"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"carrotskin/internal/container"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -34,7 +35,7 @@ type CaptchaVerifyRequest struct {
|
|||||||
// @Tags captcha
|
// @Tags captcha
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce 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{} "生成失败"
|
// @Failure 500 {object} map[string]interface{} "生成失败"
|
||||||
// @Router /api/v1/captcha/generate [get]
|
// @Router /api/v1/captcha/generate [get]
|
||||||
func (h *CaptchaHandler) Generate(c *gin.Context) {
|
func (h *CaptchaHandler) Generate(c *gin.Context) {
|
||||||
@@ -43,13 +44,14 @@ func (h *CaptchaHandler) Generate(c *gin.Context) {
|
|||||||
h.logger.Error("生成验证码失败", zap.Error(err))
|
h.logger.Error("生成验证码失败", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"code": 500,
|
"code": 500,
|
||||||
"msg": "生成验证码失败",
|
"message": "生成验证码失败",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"code": 200,
|
"code": 200,
|
||||||
|
"message": "操作成功",
|
||||||
"data": gin.H{
|
"data": gin.H{
|
||||||
"masterImage": masterImg,
|
"masterImage": masterImg,
|
||||||
"tileImage": tileImg,
|
"tileImage": tileImg,
|
||||||
@@ -66,7 +68,7 @@ func (h *CaptchaHandler) Generate(c *gin.Context) {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body CaptchaVerifyRequest true "验证请求"
|
// @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{} "参数错误"
|
// @Failure 400 {object} map[string]interface{} "参数错误"
|
||||||
// @Router /api/v1/captcha/verify [post]
|
// @Router /api/v1/captcha/verify [post]
|
||||||
func (h *CaptchaHandler) Verify(c *gin.Context) {
|
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 {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
"code": 400,
|
"code": 400,
|
||||||
"msg": "参数错误: " + err.Error(),
|
"message": "参数错误: " + err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -87,7 +89,7 @@ func (h *CaptchaHandler) Verify(c *gin.Context) {
|
|||||||
)
|
)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"code": 500,
|
"code": 500,
|
||||||
"msg": "验证失败",
|
"message": "验证失败",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -95,12 +97,12 @@ func (h *CaptchaHandler) Verify(c *gin.Context) {
|
|||||||
if valid {
|
if valid {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"msg": "验证成功",
|
"message": "验证成功",
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"code": 400,
|
"code": 400,
|
||||||
"msg": "验证失败,请重试",
|
"message": "验证失败,请重试",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrotskin/internal/container"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"carrotskin/internal/container"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
apperrors "carrotskin/internal/errors"
|
|
||||||
"carrotskin/internal/model"
|
|
||||||
"carrotskin/internal/types"
|
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
apperrors "carrotskin/internal/errors"
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
"carrotskin/internal/types"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -20,6 +22,33 @@ func parseIntWithDefault(s string, defaultVal int) int {
|
|||||||
return val
|
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,如果不存在返回未授权响应
|
// GetUserIDFromContext 从上下文获取用户ID,如果不存在返回未授权响应
|
||||||
// 返回值: userID, ok (如果ok为false,已经发送了错误响应)
|
// 返回值: userID, ok (如果ok为false,已经发送了错误响应)
|
||||||
func GetUserIDFromContext(c *gin.Context) (int64, bool) {
|
func GetUserIDFromContext(c *gin.Context) (int64, bool) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type Handlers struct {
|
|||||||
Profile *ProfileHandler
|
Profile *ProfileHandler
|
||||||
Captcha *CaptchaHandler
|
Captcha *CaptchaHandler
|
||||||
Yggdrasil *YggdrasilHandler
|
Yggdrasil *YggdrasilHandler
|
||||||
|
Friends *YggdrasilFriendsHandler
|
||||||
CustomSkin *CustomSkinHandler
|
CustomSkin *CustomSkinHandler
|
||||||
Admin *AdminHandler
|
Admin *AdminHandler
|
||||||
}
|
}
|
||||||
@@ -31,6 +32,7 @@ func NewHandlers(c *container.Container) *Handlers {
|
|||||||
Profile: NewProfileHandler(c),
|
Profile: NewProfileHandler(c),
|
||||||
Captcha: NewCaptchaHandler(c),
|
Captcha: NewCaptchaHandler(c),
|
||||||
Yggdrasil: NewYggdrasilHandler(c),
|
Yggdrasil: NewYggdrasilHandler(c),
|
||||||
|
Friends: NewYggdrasilFriendsHandler(c),
|
||||||
CustomSkin: NewCustomSkinHandler(c),
|
CustomSkin: NewCustomSkinHandler(c),
|
||||||
Admin: NewAdminHandler(c),
|
Admin: NewAdminHandler(c),
|
||||||
}
|
}
|
||||||
@@ -77,7 +79,7 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
|
|||||||
|
|
||||||
// Yggdrasil API路由组(独立于v1,路径为 /api/yggdrasil)
|
// Yggdrasil API路由组(独立于v1,路径为 /api/yggdrasil)
|
||||||
|
|
||||||
registerYggdrasilRoutesWithDI(apiGroup, h.Yggdrasil)
|
registerYggdrasilRoutesWithDI(apiGroup, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerAuthRoutes 注册认证路由
|
// registerAuthRoutes 注册认证路由
|
||||||
@@ -169,29 +171,45 @@ func registerCaptchaRoutesWithDI(v1 *gin.RouterGroup, h *CaptchaHandler) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// registerYggdrasilRoutesWithDI 注册Yggdrasil API路由(依赖注入版本)
|
// registerYggdrasilRoutesWithDI 注册Yggdrasil API路由(依赖注入版本)
|
||||||
func registerYggdrasilRoutesWithDI(v1 *gin.RouterGroup, h *YggdrasilHandler) {
|
func registerYggdrasilRoutesWithDI(v1 *gin.RouterGroup, h *Handlers) {
|
||||||
ygg := v1.Group("/yggdrasil")
|
ygg := v1.Group("/yggdrasil")
|
||||||
{
|
{
|
||||||
ygg.GET("", h.GetMetaData)
|
ygg.GET("", h.Yggdrasil.GetMetaData)
|
||||||
ygg.POST("/minecraftservices/player/certificates", h.GetPlayerCertificates)
|
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 := ygg.Group("/authserver")
|
||||||
{
|
{
|
||||||
authserver.POST("/authenticate", h.Authenticate)
|
authserver.POST("/authenticate", h.Yggdrasil.Authenticate)
|
||||||
authserver.POST("/validate", h.ValidToken)
|
authserver.POST("/validate", h.Yggdrasil.ValidToken)
|
||||||
authserver.POST("/refresh", h.RefreshToken)
|
authserver.POST("/refresh", h.Yggdrasil.RefreshToken)
|
||||||
authserver.POST("/invalidate", h.InvalidToken)
|
authserver.POST("/invalidate", h.Yggdrasil.InvalidToken)
|
||||||
authserver.POST("/signout", h.SignOut)
|
authserver.POST("/signout", h.Yggdrasil.SignOut)
|
||||||
}
|
}
|
||||||
sessionServer := ygg.Group("/sessionserver")
|
sessionServer := ygg.Group("/sessionserver")
|
||||||
{
|
{
|
||||||
sessionServer.GET("/session/minecraft/profile/:uuid", h.GetProfileByUUID)
|
sessionServer.GET("/session/minecraft/profile/:uuid", h.Yggdrasil.GetProfileByUUID)
|
||||||
sessionServer.POST("/session/minecraft/join", h.JoinServer)
|
sessionServer.POST("/session/minecraft/join", h.Yggdrasil.JoinServer)
|
||||||
sessionServer.GET("/session/minecraft/hasJoined", h.HasJoinedServer)
|
sessionServer.GET("/session/minecraft/hasJoined", h.Yggdrasil.HasJoinedServer)
|
||||||
}
|
}
|
||||||
api := ygg.Group("/api")
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"carrotskin/internal/container"
|
"carrotskin/internal/container"
|
||||||
"carrotskin/internal/model"
|
"carrotskin/internal/model"
|
||||||
"carrotskin/internal/types"
|
"carrotskin/internal/types"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"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
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"carrotskin/internal/container"
|
"carrotskin/internal/container"
|
||||||
"carrotskin/internal/errors"
|
"carrotskin/internal/errors"
|
||||||
"carrotskin/internal/model"
|
"carrotskin/internal/model"
|
||||||
"carrotskin/pkg/utils"
|
"carrotskin/pkg/utils"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -16,66 +15,18 @@ import (
|
|||||||
|
|
||||||
// 常量定义
|
// 常量定义
|
||||||
const (
|
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
|
MaxMultipartMemory = 32 << 20 // 32 MB
|
||||||
|
|
||||||
// 材质类型
|
// 材质类型
|
||||||
TextureTypeSkin = "SKIN"
|
TextureTypeSkin = "SKIN"
|
||||||
TextureTypeCape = "CAPE"
|
TextureTypeCape = "CAPE"
|
||||||
|
|
||||||
// 内容类型
|
|
||||||
ContentTypePNG = "image/png"
|
|
||||||
ContentTypeMultipart = "multipart/form-data"
|
|
||||||
|
|
||||||
// 表单参数
|
|
||||||
FormKeyModel = "model"
|
|
||||||
FormKeyFile = "file"
|
|
||||||
|
|
||||||
// 元数据键
|
|
||||||
MetaKeyModel = "model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 正则表达式
|
// 正则表达式
|
||||||
var (
|
var (
|
||||||
// 邮箱正则表达式
|
// 邮箱正则表达式
|
||||||
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
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处理器
|
// YggdrasilHandler Yggdrasil API处理器
|
||||||
type YggdrasilHandler struct {
|
type YggdrasilHandler struct {
|
||||||
container *container.Container
|
container *container.Container
|
||||||
@@ -179,16 +114,8 @@ func NewYggdrasilHandler(c *container.Container) *YggdrasilHandler {
|
|||||||
// @Failure 403 {object} map[string]string "认证失败"
|
// @Failure 403 {object} map[string]string "认证失败"
|
||||||
// @Router /api/yggdrasil/authserver/authenticate [post]
|
// @Router /api/yggdrasil/authserver/authenticate [post]
|
||||||
func (h *YggdrasilHandler) Authenticate(c *gin.Context) {
|
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
|
var request AuthenticateRequest
|
||||||
if err = c.ShouldBindJSON(&request); err != nil {
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
h.logger.Error("解析认证请求失败", zap.Error(err))
|
h.logger.Error("解析认证请求失败", zap.Error(err))
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -197,8 +124,10 @@ func (h *YggdrasilHandler) Authenticate(c *gin.Context) {
|
|||||||
var userId int64
|
var userId int64
|
||||||
var profile *model.Profile
|
var profile *model.Profile
|
||||||
var UUID string
|
var UUID string
|
||||||
|
var err error
|
||||||
|
|
||||||
if emailRegex.MatchString(request.Identifier) {
|
if emailRegex.MatchString(request.Identifier) {
|
||||||
|
// 邮箱登录:UUID 留空,后续 TokenService.Create 会自动选择该用户的角色
|
||||||
userId, err = h.container.YggdrasilService.GetUserIDByEmail(c.Request.Context(), request.Identifier)
|
userId, err = h.container.YggdrasilService.GetUserIDByEmail(c.Request.Context(), request.Identifier)
|
||||||
} else {
|
} else {
|
||||||
profile, err = h.container.ProfileService.GetByProfileName(c.Request.Context(), request.Identifier)
|
profile, err = h.container.ProfileService.GetByProfileName(c.Request.Context(), request.Identifier)
|
||||||
@@ -506,7 +435,7 @@ func (h *YggdrasilHandler) SignOut(c *gin.Context) {
|
|||||||
|
|
||||||
if !emailRegex.MatchString(request.Email) {
|
if !emailRegex.MatchString(request.Email) {
|
||||||
h.logger.Warn("登出失败: 邮箱格式不正确", zap.String("email", 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,8 +507,8 @@ func (h *YggdrasilHandler) GetProfileByUUID(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body JoinServerRequest true "加入请求"
|
// @Param request body JoinServerRequest true "加入请求"
|
||||||
// @Success 204 "加入成功"
|
// @Success 204 "加入成功"
|
||||||
// @Failure 400 {object} APIResponse "参数错误"
|
// @Failure 400 {object} map[string]string "参数错误"
|
||||||
// @Failure 500 {object} APIResponse "服务器错误"
|
// @Failure 500 {object} map[string]string "服务器错误"
|
||||||
// @Router /api/yggdrasil/sessionserver/session/minecraft/join [post]
|
// @Router /api/yggdrasil/sessionserver/session/minecraft/join [post]
|
||||||
func (h *YggdrasilHandler) JoinServer(c *gin.Context) {
|
func (h *YggdrasilHandler) JoinServer(c *gin.Context) {
|
||||||
var request JoinServerRequest
|
var request JoinServerRequest
|
||||||
@@ -642,14 +571,14 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
|
|||||||
serverID, exists := c.GetQuery("serverId")
|
serverID, exists := c.GetQuery("serverId")
|
||||||
if !exists || serverID == "" {
|
if !exists || serverID == "" {
|
||||||
h.logger.Warn("缺少服务器ID参数", zap.String("ip", clientIP))
|
h.logger.Warn("缺少服务器ID参数", zap.String("ip", clientIP))
|
||||||
standardResponse(c, http.StatusNoContent, nil, ErrServerIDRequired)
|
c.Status(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
username, exists := c.GetQuery("username")
|
username, exists := c.GetQuery("username")
|
||||||
if !exists || username == "" {
|
if !exists || username == "" {
|
||||||
h.logger.Warn("缺少用户名参数", zap.String("serverId", serverID), zap.String("ip", clientIP))
|
h.logger.Warn("缺少用户名参数", zap.String("serverId", serverID), zap.String("ip", clientIP))
|
||||||
standardResponse(c, http.StatusNoContent, nil, ErrUsernameRequired)
|
c.Status(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,14 +595,14 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
|
|||||||
zap.String("username", username),
|
zap.String("username", username),
|
||||||
zap.String("ip", clientIP),
|
zap.String("ip", clientIP),
|
||||||
)
|
)
|
||||||
standardResponse(c, http.StatusNoContent, nil, ErrSessionVerifyFailed)
|
c.Status(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
profile, err := h.container.ProfileService.GetByProfileName(c.Request.Context(), username)
|
profile, err := h.container.ProfileService.GetByProfileName(c.Request.Context(), username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("获取用户配置文件失败", zap.Error(err), zap.String("username", username))
|
h.logger.Error("获取用户配置文件失败", zap.Error(err), zap.String("username", username))
|
||||||
standardResponse(c, http.StatusNoContent, nil, ErrProfileNotFound)
|
c.Status(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,37 +611,110 @@ func (h *YggdrasilHandler) HasJoinedServer(c *gin.Context) {
|
|||||||
zap.String("username", username),
|
zap.String("username", username),
|
||||||
zap.String("uuid", profile.UUID),
|
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 批量获取配置文件
|
// GetProfilesByName 批量获取配置文件
|
||||||
// @Summary Yggdrasil批量获取档案
|
// @Summary Yggdrasil批量获取档案
|
||||||
// @Description Yggdrasil协议: 根据名称批量获取用户档案
|
// @Description Yggdrasil协议: 根据名称批量获取用户档案(文档 2.1,每页最多 10 个,返回 [{id,name}])
|
||||||
// @Tags Yggdrasil
|
// @Tags Yggdrasil
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body []string true "用户名列表"
|
// @Param request body []string true "用户名列表"
|
||||||
// @Success 200 {array} model.Profile "档案列表"
|
// @Success 200 {array} model.NameAndId "档案标识列表"
|
||||||
// @Failure 400 {object} APIResponse "参数错误"
|
// @Failure 400 {object} map[string]string "参数错误"
|
||||||
// @Router /api/yggdrasil/api/profiles/minecraft [post]
|
// @Router /api/yggdrasil/api/profiles/minecraft [post]
|
||||||
func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) {
|
func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) {
|
||||||
var names []string
|
var names []string
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&names); err != nil {
|
if err := c.ShouldBindJSON(&names); err != nil {
|
||||||
h.logger.Error("解析名称数组请求失败", zap.Error(err))
|
h.logger.Error("解析名称数组请求失败", zap.Error(err))
|
||||||
standardResponse(c, http.StatusBadRequest, nil, ErrInvalidParams)
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("接收到批量获取配置文件请求", zap.Int("count", len(names)))
|
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 {
|
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)))
|
h.logger.Info("成功获取配置文件", zap.Int("requested", len(names)), zap.Int("returned", len(results)))
|
||||||
c.JSON(http.StatusOK, profiles)
|
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元数据
|
// GetMetaData 获取Yggdrasil元数据
|
||||||
@@ -722,7 +724,7 @@ func (h *YggdrasilHandler) GetProfilesByName(c *gin.Context) {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {object} map[string]interface{} "元数据"
|
// @Success 200 {object} map[string]interface{} "元数据"
|
||||||
// @Failure 500 {object} APIResponse "服务器错误"
|
// @Failure 500 {object} map[string]string "服务器错误"
|
||||||
// @Router /api/yggdrasil [get]
|
// @Router /api/yggdrasil [get]
|
||||||
func (h *YggdrasilHandler) GetMetaData(c *gin.Context) {
|
func (h *YggdrasilHandler) GetMetaData(c *gin.Context) {
|
||||||
meta := gin.H{
|
meta := gin.H{
|
||||||
@@ -764,7 +766,7 @@ func (h *YggdrasilHandler) GetMetaData(c *gin.Context) {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param Authorization header string true "Bearer {token}"
|
// @Param Authorization header string true "Bearer {token}"
|
||||||
// @Success 200 {object} map[string]interface{} "证书信息"
|
// @Success 200 {object} service.PlayerCertificate "证书信息"
|
||||||
// @Failure 401 {object} errors.YggdrasilErrorResponse "未授权"
|
// @Failure 401 {object} errors.YggdrasilErrorResponse "未授权"
|
||||||
// @Failure 403 {object} errors.YggdrasilErrorResponse "令牌无效"
|
// @Failure 403 {object} errors.YggdrasilErrorResponse "令牌无效"
|
||||||
// @Failure 500 {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)
|
uuid, err := h.container.TokenService.GetUUIDByAccessToken(c.Request.Context(), tokenID)
|
||||||
if uuid == "" {
|
if err != nil || uuid == "" {
|
||||||
h.logger.Error("获取玩家UUID失败", zap.Error(err))
|
h.logger.Error("获取玩家UUID失败", zap.Error(err))
|
||||||
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
|
c.JSON(http.StatusUnauthorized, errors.NewYggdrasilErrorResponse(
|
||||||
"ForbiddenOperationException",
|
"Unauthorized",
|
||||||
errors.YggErrInvalidToken,
|
errors.YggErrInvalidToken,
|
||||||
"",
|
"",
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrotskin/internal/model"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
|
||||||
"carrotskin/pkg/auth"
|
"carrotskin/pkg/auth"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|||||||
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 角色名
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
Expiration time.Time `json:"expiration" bson:"expiration"`
|
||||||
Refresh time.Time `json:"refresh" bson:"refresh"`
|
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
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrotskin/internal/model"
|
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"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
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrotskin/internal/model"
|
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"carrotskin/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserRepository 用户仓储接口
|
// UserRepository 用户仓储接口
|
||||||
@@ -69,7 +70,6 @@ type TextureRepository interface {
|
|||||||
FindByIDForAdmin(ctx context.Context, id int64) (*model.Texture, error) // 管理员查询(无权限过滤)
|
FindByIDForAdmin(ctx context.Context, id int64) (*model.Texture, error) // 管理员查询(无权限过滤)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// YggdrasilRepository Yggdrasil仓储接口
|
// YggdrasilRepository Yggdrasil仓储接口
|
||||||
type YggdrasilRepository interface {
|
type YggdrasilRepository interface {
|
||||||
GetPasswordByID(ctx context.Context, id int64) (string, error)
|
GetPasswordByID(ctx context.Context, id int64) (string, error)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrotskin/internal/model"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"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) {
|
func (r *profileRepository) GetByNames(ctx context.Context, names []string) ([]*model.Profile, error) {
|
||||||
|
if len(names) == 0 {
|
||||||
|
return []*model.Profile{}, nil
|
||||||
|
}
|
||||||
var profiles []*model.Profile
|
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("Skin").
|
||||||
Preload("Cape").
|
Preload("Cape").
|
||||||
Find(&profiles).Error
|
Find(&profiles).Error
|
||||||
@@ -131,7 +137,7 @@ func (r *profileRepository) GetKeyPair(ctx context.Context, profileId string) (*
|
|||||||
|
|
||||||
var profile model.Profile
|
var profile model.Profile
|
||||||
result := r.db.WithContext(ctx).
|
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).
|
Where("uuid = ?", profileId).
|
||||||
First(&profile)
|
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 nil, fmt.Errorf("获取key pair失败: %w", result.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &model.KeyPair{
|
keyPair := &model.KeyPair{
|
||||||
PrivateKey: profile.RSAPrivateKey,
|
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 {
|
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 {
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
result := tx.Model(&model.Profile{}).
|
result := tx.Model(&model.Profile{}).
|
||||||
Where("uuid = ?", profileId).
|
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 {
|
if result.Error != nil {
|
||||||
return fmt.Errorf("更新 keyPair 失败: %w", result.Error)
|
return fmt.Errorf("更新 keyPair 失败: %w", result.Error)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrotskin/internal/model"
|
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrotskin/internal/model"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrotskin/internal/model"
|
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,9 +35,3 @@ func (r *yggdrasilRepository) ResetPassword(ctx context.Context, id int64, passw
|
|||||||
func (r *yggdrasilRepository) Create(ctx context.Context, yggdrasil *model.Yggdrasil) error {
|
func (r *yggdrasilRepository) Create(ctx context.Context, yggdrasil *model.Yggdrasil) error {
|
||||||
return r.db.WithContext(ctx).Create(yggdrasil).Error
|
return r.db.WithContext(ctx).Create(yggdrasil).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrotskin/pkg/config"
|
|
||||||
"carrotskin/pkg/redis"
|
|
||||||
"carrotskin/pkg/utils"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"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/imagesv2"
|
||||||
"github.com/wenlng/go-captcha-assets/resources/tiles"
|
"github.com/wenlng/go-captcha-assets/resources/tiles"
|
||||||
"github.com/wenlng/go-captcha/v2/slide"
|
"github.com/wenlng/go-captcha/v2/slide"
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrotskin/internal/model"
|
|
||||||
"carrotskin/pkg/storage"
|
|
||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
"carrotskin/pkg/storage"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,6 +60,14 @@ type ProfileService interface {
|
|||||||
// 批量查询
|
// 批量查询
|
||||||
GetByNames(ctx context.Context, names []string) ([]*model.Profile, error)
|
GetByNames(ctx context.Context, names []string) ([]*model.Profile, error)
|
||||||
GetByProfileName(ctx context.Context, name 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 材质服务接口
|
// TextureService 材质服务接口
|
||||||
@@ -133,10 +142,119 @@ type YggdrasilService interface {
|
|||||||
SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{}
|
SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{}
|
||||||
|
|
||||||
// 证书
|
// 证书
|
||||||
GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error)
|
GeneratePlayerCertificate(ctx context.Context, uuid string) (*PlayerCertificate, error)
|
||||||
GetPublicKey(ctx context.Context) (string, 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 安全服务接口
|
// SecurityService 安全服务接口
|
||||||
type SecurityService interface {
|
type SecurityService interface {
|
||||||
// 登录安全
|
// 登录安全
|
||||||
@@ -161,6 +279,7 @@ type Services struct {
|
|||||||
Captcha CaptchaService
|
Captcha CaptchaService
|
||||||
Yggdrasil YggdrasilService
|
Yggdrasil YggdrasilService
|
||||||
Security SecurityService
|
Security SecurityService
|
||||||
|
Friends FriendsService
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceDeps 服务依赖
|
// ServiceDeps 服务依赖
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"carrotskin/pkg/database"
|
"carrotskin/pkg/database"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -190,8 +191,10 @@ func (m *MockProfileRepository) FindByName(ctx context.Context, name string) (*m
|
|||||||
if m.FailFind {
|
if m.FailFind {
|
||||||
return nil, errors.New("mock find error")
|
return nil, errors.New("mock find error")
|
||||||
}
|
}
|
||||||
|
// 与真实仓储一致:大小写不敏感匹配
|
||||||
|
ln := strings.ToLower(name)
|
||||||
for _, profile := range m.profiles {
|
for _, profile := range m.profiles {
|
||||||
if profile.Name == name {
|
if strings.ToLower(profile.Name) == ln {
|
||||||
return profile, nil
|
return profile, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,8 +246,10 @@ func (m *MockProfileRepository) UpdateLastUsedAt(ctx context.Context, uuid strin
|
|||||||
func (m *MockProfileRepository) GetByNames(ctx context.Context, names []string) ([]*model.Profile, error) {
|
func (m *MockProfileRepository) GetByNames(ctx context.Context, names []string) ([]*model.Profile, error) {
|
||||||
var result []*model.Profile
|
var result []*model.Profile
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
|
// 与真实仓储一致:大小写不敏感匹配
|
||||||
|
ln := strings.ToLower(name)
|
||||||
for _, profile := range m.profiles {
|
for _, profile := range m.profiles {
|
||||||
if profile.Name == name {
|
if strings.ToLower(profile.Name) == ln {
|
||||||
result = append(result, profile)
|
result = append(result, profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
apperrors "carrotskin/internal/errors"
|
|
||||||
"carrotskin/internal/model"
|
|
||||||
"carrotskin/internal/repository"
|
|
||||||
"carrotskin/pkg/database"
|
|
||||||
"carrotskin/pkg/utils"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
@@ -13,6 +8,13 @@ import (
|
|||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
apperrors "carrotskin/internal/errors"
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
"carrotskin/internal/repository"
|
||||||
|
"carrotskin/pkg/database"
|
||||||
|
"carrotskin/pkg/utils"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -239,6 +241,80 @@ func (s *profileService) GetByProfileName(ctx context.Context, name string) (*mo
|
|||||||
return profile, nil
|
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格式)
|
// generateRSAPrivateKeyInternal 生成RSA-2048私钥(PEM格式)
|
||||||
func generateRSAPrivateKeyInternal() (string, error) {
|
func generateRSAPrivateKeyInternal() (string, error) {
|
||||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
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" {
|
if err != nil || p == nil || p.Name != "A" {
|
||||||
t.Fatalf("GetByProfileName 返回错误, profile=%+v, err=%v", p, err)
|
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
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrotskin/internal/model"
|
|
||||||
"carrotskin/internal/repository"
|
|
||||||
"carrotskin/pkg/redis"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto"
|
"crypto"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -15,9 +12,13 @@ import (
|
|||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
"carrotskin/internal/repository"
|
||||||
|
"carrotskin/pkg/redis"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,6 +38,11 @@ type SignatureService struct {
|
|||||||
profileRepo repository.ProfileRepository
|
profileRepo repository.ProfileRepository
|
||||||
redis *redis.Client
|
redis *redis.Client
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
|
|
||||||
|
// 缓存已解析的根密钥对,避免每次签名都重复 PEM 解析(PKCS1 解析开销较大)
|
||||||
|
rootKeyMu sync.RWMutex
|
||||||
|
rootPrivateKey *rsa.PrivateKey
|
||||||
|
rootPublicKeyPEM string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSignatureService 创建SignatureService实例
|
// NewSignatureService 创建SignatureService实例
|
||||||
@@ -90,74 +96,77 @@ func (s *SignatureService) NewKeyPair(ctx context.Context) (*model.KeyPair, erro
|
|||||||
return nil, fmt.Errorf("获取Yggdrasil根密钥失败: %w", err)
|
return nil, fmt.Errorf("获取Yggdrasil根密钥失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构造签名消息
|
// 构造签名消息(PEM 公钥 + 过期时间戳)
|
||||||
expiresAtMillis := expiration.UnixMilli()
|
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签名
|
// 使用SHA1withRSA签名
|
||||||
hashed := sha1.Sum(message)
|
publicKeySignature, err := signWithRootKey(yggPrivateKey, message)
|
||||||
signature, err := rsa.SignPKCS1v15(rand.Reader, yggPrivateKey, crypto.SHA1, hashed[:])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("签名失败: %w", err)
|
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)
|
// 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))
|
binary.BigEndian.PutUint64(messageV2[0:8], uint64(expiresAtMillis))
|
||||||
copy(messageV2[8:], publicKeyDER)
|
copy(messageV2[8:], publicKeyBytes)
|
||||||
|
|
||||||
hashedV2 := sha1.Sum(messageV2)
|
publicKeySignatureV2, err := signWithRootKey(yggPrivateKey, messageV2)
|
||||||
signatureV2, err := rsa.SignPKCS1v15(rand.Reader, yggPrivateKey, crypto.SHA1, hashedV2[:])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("V2签名失败: %w", err)
|
return nil, fmt.Errorf("V2签名失败: %w", err)
|
||||||
}
|
}
|
||||||
publicKeySignatureV2 := base64.StdEncoding.EncodeToString(signatureV2)
|
|
||||||
|
|
||||||
return &model.KeyPair{
|
return &model.KeyPair{
|
||||||
PrivateKey: string(privateKeyPEM),
|
PrivateKey: string(privateKeyPEM),
|
||||||
PublicKey: string(publicKeyPEM),
|
PublicKey: string(publicKeyPEM),
|
||||||
PublicKeySignature: publicKeySignature,
|
PublicKeySignature: base64.StdEncoding.EncodeToString(publicKeySignature),
|
||||||
PublicKeySignatureV2: publicKeySignatureV2,
|
PublicKeySignatureV2: base64.StdEncoding.EncodeToString(publicKeySignatureV2),
|
||||||
YggdrasilPublicKey: yggPublicKey,
|
YggdrasilPublicKey: yggPublicKey,
|
||||||
Expiration: expiration,
|
Expiration: expiration,
|
||||||
Refresh: refresh,
|
Refresh: refresh,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOrCreateYggdrasilKeyPair 获取或创建Yggdrasil根密钥对
|
// signWithRootKey 使用根密钥对消息做 SHA1withRSA 签名
|
||||||
func (s *SignatureService) GetOrCreateYggdrasilKeyPair(ctx context.Context) (string, *rsa.PrivateKey, error) {
|
func signWithRootKey(privateKey *rsa.PrivateKey, message []byte) ([]byte, error) {
|
||||||
// 尝试从Redis获取密钥
|
hashed := sha1.Sum(message)
|
||||||
publicKeyPEM, err := s.redis.Get(ctx, PublicKeyRedisKey)
|
return rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
|
||||||
if err == nil && publicKeyPEM != "" {
|
|
||||||
privateKeyPEM, err := s.redis.Get(ctx, PrivateKeyRedisKey)
|
|
||||||
if err == nil && privateKeyPEM != "" {
|
|
||||||
// 检查密钥是否过期
|
|
||||||
expStr, err := s.redis.Get(ctx, KeyExpirationRedisKey)
|
|
||||||
if err == nil && expStr != "" {
|
|
||||||
expTime, err := time.Parse(time.RFC3339, expStr)
|
|
||||||
if err == nil && time.Now().Before(expTime) {
|
|
||||||
// 密钥有效,解析私钥
|
|
||||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
|
||||||
if block != nil {
|
|
||||||
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
||||||
if err == nil {
|
|
||||||
s.logger.Info("从Redis加载Yggdrasil根密钥")
|
|
||||||
return publicKeyPEM, privateKey, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成新的根密钥对
|
// GetOrCreateYggdrasilKeyPair 获取或创建Yggdrasil根密钥对
|
||||||
|
// 优先使用进程内缓存的已解析私钥;缓存未命中时从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()
|
||||||
|
|
||||||
|
// 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 pub, priv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis 没有,生成新的根密钥对
|
||||||
s.logger.Info("生成新的Yggdrasil根密钥对")
|
s.logger.Info("生成新的Yggdrasil根密钥对")
|
||||||
privateKey, err := rsa.GenerateKey(rand.Reader, KeySize)
|
privateKey, err := rsa.GenerateKey(rand.Reader, KeySize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -176,7 +185,7 @@ func (s *SignatureService) GetOrCreateYggdrasilKeyPair(ctx context.Context) (str
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, fmt.Errorf("编码公钥失败: %w", err)
|
return "", nil, fmt.Errorf("编码公钥失败: %w", err)
|
||||||
}
|
}
|
||||||
publicKeyPEM = string(pem.EncodeToMemory(&pem.Block{
|
publicKeyPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||||
Type: "PUBLIC KEY",
|
Type: "PUBLIC KEY",
|
||||||
Bytes: publicKeyBytes,
|
Bytes: publicKeyBytes,
|
||||||
}))
|
}))
|
||||||
@@ -195,9 +204,57 @@ func (s *SignatureService) GetOrCreateYggdrasilKeyPair(ctx context.Context) (str
|
|||||||
s.logger.Warn("保存密钥过期时间到Redis失败", zap.Error(err))
|
s.logger.Warn("保存密钥过期时间到Redis失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 缓存已解析的私钥与公钥PEM
|
||||||
|
s.rootPrivateKey = privateKey
|
||||||
|
s.rootPublicKeyPEM = publicKeyPEM
|
||||||
return publicKeyPEM, privateKey, nil
|
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获取公钥
|
// GetPublicKeyFromRedis 从Redis获取公钥
|
||||||
func (s *SignatureService) GetPublicKeyFromRedis(ctx context.Context) (string, error) {
|
func (s *SignatureService) GetPublicKeyFromRedis(ctx context.Context) (string, error) {
|
||||||
publicKey, err := s.redis.Get(ctx, PublicKeyRedisKey)
|
publicKey, err := s.redis.Get(ctx, PublicKeyRedisKey)
|
||||||
@@ -215,81 +272,16 @@ func (s *SignatureService) GetPublicKeyFromRedis(ctx context.Context) (string, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SignStringWithSHA1withRSA 使用SHA1withRSA签名字符串
|
// SignStringWithSHA1withRSA 使用SHA1withRSA签名字符串
|
||||||
|
// 使用缓存的已解析根私钥;若缓存为空则触发加载/生成。
|
||||||
func (s *SignatureService) SignStringWithSHA1withRSA(ctx context.Context, data string) (string, error) {
|
func (s *SignatureService) SignStringWithSHA1withRSA(ctx context.Context, data string) (string, error) {
|
||||||
|
|
||||||
// 从Redis获取私钥
|
|
||||||
privateKeyPEM, err := s.redis.Get(ctx, PrivateKeyRedisKey)
|
|
||||||
if err != nil || privateKeyPEM == "" {
|
|
||||||
// 如果没有私钥,创建新的密钥对
|
|
||||||
_, privateKey, err := s.GetOrCreateYggdrasilKeyPair(ctx)
|
_, privateKey, err := s.GetOrCreateYggdrasilKeyPair(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("获取私钥失败: %w", err)
|
return "", fmt.Errorf("获取签名私钥失败: %w", err)
|
||||||
}
|
}
|
||||||
// 使用新生成的私钥签名
|
|
||||||
hashed := sha1.Sum([]byte(data))
|
signature, err := signWithRootKey(privateKey, []byte(data))
|
||||||
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("签名失败: %w", err)
|
return "", fmt.Errorf("签名失败: %w", err)
|
||||||
}
|
}
|
||||||
return base64.StdEncoding.EncodeToString(signature), nil
|
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,11 +2,6 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
apperrors "carrotskin/internal/errors"
|
|
||||||
"carrotskin/internal/model"
|
|
||||||
"carrotskin/internal/repository"
|
|
||||||
"carrotskin/pkg/database"
|
|
||||||
"carrotskin/pkg/storage"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -16,6 +11,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
apperrors "carrotskin/internal/errors"
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
"carrotskin/internal/repository"
|
||||||
|
"carrotskin/pkg/database"
|
||||||
|
"carrotskin/pkg/storage"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -235,8 +236,10 @@ func (s *textureService) ToggleFavorite(ctx context.Context, userID, textureID i
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 在事务中执行"切换收藏 + 增减计数"两步,保证数据一致性
|
// 在事务中执行"切换收藏 + 增减计数"两步,保证数据一致性
|
||||||
|
// 注意:s.db 在部分单元测试中可能为 nil,此时跳过事务直接执行
|
||||||
|
// (生产环境 db 永远非空,mock repo 本就不是真事务,降级不影响一致性)
|
||||||
var favorited bool
|
var favorited bool
|
||||||
err = database.TransactionWithTimeout(ctx, s.db, 5*time.Second, func(tx *gorm.DB) error {
|
toggle := func() error {
|
||||||
isFav, err := s.textureRepo.IsFavorited(ctx, userID, textureID)
|
isFav, err := s.textureRepo.IsFavorited(ctx, userID, textureID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -261,7 +264,15 @@ func (s *textureService) ToggleFavorite(ctx context.Context, userID, textureID i
|
|||||||
}
|
}
|
||||||
favorited = true
|
favorited = true
|
||||||
return nil
|
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 {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,19 @@ package service
|
|||||||
import (
|
import (
|
||||||
"carrotskin/internal/model"
|
"carrotskin/internal/model"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// sha256Hex 计算给定数据的 SHA-256 十六进制字符串
|
||||||
|
func sha256Hex(data []byte) string {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
// TestTextureService_TypeValidation 测试材质类型验证
|
// TestTextureService_TypeValidation 测试材质类型验证
|
||||||
func TestTextureService_TypeValidation(t *testing.T) {
|
func TestTextureService_TypeValidation(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -496,82 +504,85 @@ func TestTextureServiceImpl_Create(t *testing.T) {
|
|||||||
cacheManager := NewMockCacheManager()
|
cacheManager := NewMockCacheManager()
|
||||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
|
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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
uploaderID int64
|
uploaderID int64
|
||||||
textureName string
|
textureName string
|
||||||
textureType string
|
textureType string
|
||||||
hash string
|
fileData []byte
|
||||||
wantErr bool
|
wantErr bool
|
||||||
errContains string
|
errContains string
|
||||||
setupMocks func()
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "正常创建SKIN材质",
|
name: " Hash 已存在 -> 复用 URL",
|
||||||
uploaderID: 1,
|
uploaderID: 1,
|
||||||
textureName: "TestSkin",
|
textureName: "TestSkin",
|
||||||
textureType: "SKIN",
|
textureType: "SKIN",
|
||||||
hash: "unique-hash-1",
|
fileData: pngFileData, // hash 命中预置记录
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "正常创建CAPE材质",
|
name: "Hash 不存在且 storage 为 nil -> 存储不可用",
|
||||||
uploaderID: 1,
|
uploaderID: 1,
|
||||||
textureName: "TestCape",
|
textureName: "NewCape",
|
||||||
textureType: "CAPE",
|
textureType: "CAPE",
|
||||||
hash: "unique-hash-2",
|
fileData: make([]byte, 1024), // 不同内容,hash 不会命中
|
||||||
wantErr: false,
|
wantErr: true,
|
||||||
|
errContains: "存储服务不可用",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "用户不存在",
|
name: "用户不存在",
|
||||||
uploaderID: 999,
|
uploaderID: 999,
|
||||||
textureName: "TestTexture",
|
textureName: "TestTexture",
|
||||||
textureType: "SKIN",
|
textureType: "SKIN",
|
||||||
hash: "unique-hash-3",
|
fileData: pngFileData,
|
||||||
wantErr: true,
|
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: "无效的材质类型",
|
name: "无效的材质类型",
|
||||||
uploaderID: 1,
|
uploaderID: 1,
|
||||||
textureName: "InvalidTypeTexture",
|
textureName: "InvalidTypeTexture",
|
||||||
textureType: "INVALID",
|
textureType: "INVALID",
|
||||||
hash: "unique-hash-4",
|
fileData: pngFileData,
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
|
errContains: "无效的材质类型",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "文件过小",
|
||||||
|
uploaderID: 1,
|
||||||
|
textureName: "TooSmall",
|
||||||
|
textureType: "SKIN",
|
||||||
|
fileData: []byte("tiny"),
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "文件大小必须在",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if tt.setupMocks != nil {
|
|
||||||
tt.setupMocks()
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
// UploadTexture需要文件数据,这里创建一个简单的测试数据
|
|
||||||
fileData := []byte("fake png data for testing")
|
|
||||||
texture, err := textureService.UploadTexture(
|
texture, err := textureService.UploadTexture(
|
||||||
ctx,
|
ctx,
|
||||||
tt.uploaderID,
|
tt.uploaderID,
|
||||||
tt.textureName,
|
tt.textureName,
|
||||||
"Test description",
|
"Test description",
|
||||||
tt.textureType,
|
tt.textureType,
|
||||||
fileData,
|
tt.fileData,
|
||||||
"test.png",
|
"test.png",
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
@@ -585,18 +596,18 @@ func TestTextureServiceImpl_Create(t *testing.T) {
|
|||||||
if tt.errContains != "" && !containsString(err.Error(), tt.errContains) {
|
if tt.errContains != "" && !containsString(err.Error(), tt.errContains) {
|
||||||
t.Errorf("错误信息应包含 %q, 实际为: %v", tt.errContains, err.Error())
|
t.Errorf("错误信息应包含 %q, 实际为: %v", tt.errContains, err.Error())
|
||||||
}
|
}
|
||||||
} else {
|
return
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("不期望返回错误: %v", err)
|
t.Errorf("不期望返回错误: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if texture == nil {
|
if texture == nil {
|
||||||
t.Error("返回的Texture不应为nil")
|
t.Fatal("返回的Texture不应为nil")
|
||||||
}
|
}
|
||||||
if texture.Name != tt.textureName {
|
if texture.Name != tt.textureName {
|
||||||
t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName)
|
t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrotskin/internal/model"
|
|
||||||
"carrotskin/internal/repository"
|
|
||||||
"carrotskin/pkg/auth"
|
|
||||||
"carrotskin/pkg/utils"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
"carrotskin/internal/repository"
|
||||||
|
"carrotskin/pkg/auth"
|
||||||
|
"carrotskin/pkg/utils"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
apperrors "carrotskin/internal/errors"
|
apperrors "carrotskin/internal/errors"
|
||||||
"carrotskin/internal/model"
|
"carrotskin/internal/model"
|
||||||
"carrotskin/internal/repository"
|
"carrotskin/internal/repository"
|
||||||
"carrotskin/pkg/auth"
|
"carrotskin/pkg/auth"
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,23 +1,39 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
apperrors "carrotskin/internal/errors"
|
|
||||||
"carrotskin/internal/repository"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
apperrors "carrotskin/internal/errors"
|
||||||
|
"carrotskin/internal/repository"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CertificateService 证书服务接口
|
// CertificateService 证书服务接口
|
||||||
type CertificateService interface {
|
type CertificateService interface {
|
||||||
// GeneratePlayerCertificate 生成玩家证书
|
// GeneratePlayerCertificate 生成玩家证书
|
||||||
GeneratePlayerCertificate(ctx context.Context, uuid string) (map[string]interface{}, error)
|
GeneratePlayerCertificate(ctx context.Context, uuid string) (*PlayerCertificate, error)
|
||||||
// GetPublicKey 获取公钥
|
// GetPublicKey 获取公钥
|
||||||
GetPublicKey(ctx context.Context) (string, error)
|
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 证书服务实现
|
// yggdrasilCertificateService 证书服务实现
|
||||||
type yggdrasilCertificateService struct {
|
type yggdrasilCertificateService struct {
|
||||||
profileRepo repository.ProfileRepository
|
profileRepo repository.ProfileRepository
|
||||||
@@ -39,7 +55,7 @@ func NewCertificateService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GeneratePlayerCertificate 生成玩家证书
|
// 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 == "" {
|
if uuid == "" {
|
||||||
return nil, apperrors.ErrUUIDRequired
|
return nil, apperrors.ErrUUIDRequired
|
||||||
}
|
}
|
||||||
@@ -88,15 +104,15 @@ func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Cont
|
|||||||
expiresAtMillis := keyPair.Expiration.UnixMilli()
|
expiresAtMillis := keyPair.Expiration.UnixMilli()
|
||||||
|
|
||||||
// 返回玩家证书
|
// 返回玩家证书
|
||||||
certificate := map[string]interface{}{
|
certificate := &PlayerCertificate{
|
||||||
"keyPair": map[string]interface{}{
|
KeyPair: PlayerKeyPair{
|
||||||
"privateKey": keyPair.PrivateKey,
|
PrivateKey: keyPair.PrivateKey,
|
||||||
"publicKey": keyPair.PublicKey,
|
PublicKey: keyPair.PublicKey,
|
||||||
},
|
},
|
||||||
"publicKeySignature": keyPair.PublicKeySignature,
|
PublicKeySignature: keyPair.PublicKeySignature,
|
||||||
"publicKeySignatureV2": keyPair.PublicKeySignatureV2,
|
PublicKeySignatureV2: keyPair.PublicKeySignatureV2,
|
||||||
"expiresAt": expiresAtMillis,
|
ExpiresAt: expiresAtMillis,
|
||||||
"refreshedAfter": keyPair.Refresh.UnixMilli(),
|
RefreshedAfter: keyPair.Refresh.UnixMilli(),
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Info("成功生成玩家证书",
|
s.logger.Info("成功生成玩家证书",
|
||||||
@@ -109,4 +125,3 @@ func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Cont
|
|||||||
func (s *yggdrasilCertificateService) GetPublicKey(ctx context.Context) (string, error) {
|
func (s *yggdrasilCertificateService) GetPublicKey(ctx context.Context) (string, error) {
|
||||||
return s.signatureService.GetPublicKeyFromRedis(ctx)
|
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
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrotskin/internal/model"
|
|
||||||
"carrotskin/internal/repository"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
"carrotskin/internal/repository"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,8 +55,8 @@ func (s *yggdrasilSerializationService) SerializeProfile(ctx context.Context, pr
|
|||||||
|
|
||||||
// SerializeProfileWithUnsigned 序列化档案为Yggdrasil格式(支持unsigned参数)
|
// SerializeProfileWithUnsigned 序列化档案为Yggdrasil格式(支持unsigned参数)
|
||||||
func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{} {
|
func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{} {
|
||||||
// 创建基本材质数据
|
// 预分配材质 map(最多 SKIN + CAPE 两个条目,避免运行时扩容)
|
||||||
texturesMap := make(map[string]interface{})
|
texturesMap := make(map[string]interface{}, 2)
|
||||||
textures := map[string]interface{}{
|
textures := map[string]interface{}{
|
||||||
"timestamp": time.Now().UnixMilli(),
|
"timestamp": time.Now().UnixMilli(),
|
||||||
"profileId": profile.UUID,
|
"profileId": profile.UUID,
|
||||||
@@ -63,7 +64,7 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context
|
|||||||
"textures": texturesMap,
|
"textures": texturesMap,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理皮肤
|
// 处理皮肤:metadata 仅对 SKIN 有效,值为 {"model":"slim"};classic/默认省略 metadata
|
||||||
if profile.SkinID != nil {
|
if profile.SkinID != nil {
|
||||||
skin, err := s.textureRepo.FindByID(ctx, *profile.SkinID)
|
skin, err := s.textureRepo.FindByID(ctx, *profile.SkinID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,14 +73,19 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context
|
|||||||
zap.Int64("skinID", *profile.SkinID),
|
zap.Int64("skinID", *profile.SkinID),
|
||||||
)
|
)
|
||||||
} else if skin != nil {
|
} else if skin != nil {
|
||||||
|
// slim 才需要 metadata 字段;classic 直接仅含 url
|
||||||
|
if skin.IsSlim {
|
||||||
texturesMap["SKIN"] = map[string]interface{}{
|
texturesMap["SKIN"] = map[string]interface{}{
|
||||||
"url": skin.URL,
|
"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 {
|
if profile.CapeID != nil {
|
||||||
cape, err := s.textureRepo.FindByID(ctx, *profile.CapeID)
|
cape, err := s.textureRepo.FindByID(ctx, *profile.CapeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -88,15 +94,12 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context
|
|||||||
zap.Int64("capeID", *profile.CapeID),
|
zap.Int64("capeID", *profile.CapeID),
|
||||||
)
|
)
|
||||||
} else if cape != nil {
|
} else if cape != nil {
|
||||||
texturesMap["CAPE"] = map[string]interface{}{
|
texturesMap["CAPE"] = map[string]interface{}{"url": cape.URL}
|
||||||
"url": cape.URL,
|
|
||||||
"metadata": cape.Size,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将 textures 编码为 base64
|
// 将 textures 编码为 base64
|
||||||
bytes, err := json.Marshal(textures)
|
textureBytes, err := json.Marshal(textures)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("序列化textures失败",
|
s.logger.Error("序列化textures失败",
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -105,12 +108,15 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
textureData := base64.StdEncoding.EncodeToString(bytes)
|
textureData := base64.StdEncoding.EncodeToString(textureBytes)
|
||||||
|
|
||||||
// 只有在 unsigned=false 时才签名
|
// 只有在 unsigned=false 时才签名
|
||||||
var signature string
|
property := Property{
|
||||||
|
Name: "textures",
|
||||||
|
Value: textureData,
|
||||||
|
}
|
||||||
if !unsigned {
|
if !unsigned {
|
||||||
signature, err = s.signatureService.SignStringWithSHA1withRSA(ctx, textureData)
|
signature, err := s.signatureService.SignStringWithSHA1withRSA(ctx, textureData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("签名textures失败",
|
s.logger.Error("签名textures失败",
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -118,26 +124,15 @@ func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context
|
|||||||
)
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 构建属性
|
|
||||||
property := Property{
|
|
||||||
Name: "textures",
|
|
||||||
Value: textureData,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只有在 unsigned=false 时才添加签名
|
|
||||||
if !unsigned {
|
|
||||||
property.Signature = signature
|
property.Signature = signature
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建结果
|
// 构建结果
|
||||||
data := map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"id": profile.UUID,
|
"id": profile.UUID,
|
||||||
"name": profile.Name,
|
"name": profile.Name,
|
||||||
"properties": []Property{property},
|
"properties": []Property{property},
|
||||||
}
|
}
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SerializeUser 序列化用户为Yggdrasil格式
|
// SerializeUser 序列化用户为Yggdrasil格式
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"carrotskin/internal/model"
|
"carrotskin/internal/model"
|
||||||
"carrotskin/internal/repository"
|
"carrotskin/internal/repository"
|
||||||
"carrotskin/pkg/redis"
|
"carrotskin/pkg/redis"
|
||||||
"carrotskin/pkg/utils"
|
"carrotskin/pkg/utils"
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -124,7 +125,7 @@ func (s *yggdrasilServiceComposite) SerializeUser(ctx context.Context, user *mod
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GeneratePlayerCertificate 生成玩家证书
|
// 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)
|
return s.certificateService.GeneratePlayerCertificate(ctx, uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
apperrors "carrotskin/internal/errors"
|
|
||||||
"carrotskin/pkg/redis"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
apperrors "carrotskin/internal/errors"
|
||||||
|
"carrotskin/pkg/redis"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ func (s *yggdrasilSessionService) CreateSession(ctx context.Context, serverID, a
|
|||||||
return apperrors.ErrInvalidAccessToken
|
return apperrors.ErrInvalidAccessToken
|
||||||
}
|
}
|
||||||
if username == "" {
|
if username == "" {
|
||||||
return apperrors.ErrUsernameMismatch
|
return apperrors.ErrUsernameRequired
|
||||||
}
|
}
|
||||||
if profileUUID == "" {
|
if profileUUID == "" {
|
||||||
return apperrors.ErrProfileMismatch
|
return apperrors.ErrProfileMismatch
|
||||||
@@ -126,12 +127,8 @@ func (s *yggdrasilSessionService) CreateSession(ctx context.Context, serverID, a
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSession 获取会话数据
|
// GetSession 获取会话数据(内部调用前已对 serverID 校验过,跳过重复校验)
|
||||||
func (s *yggdrasilSessionService) GetSession(ctx context.Context, serverID string) (*SessionData, error) {
|
func (s *yggdrasilSessionService) GetSession(ctx context.Context, serverID string) (*SessionData, error) {
|
||||||
if err := ValidateServerID(serverID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从Redis获取会话数据
|
// 从Redis获取会话数据
|
||||||
sessionKey := SessionKeyPrefix + serverID
|
sessionKey := SessionKeyPrefix + serverID
|
||||||
data, err := s.redis.GetBytes(ctx, sessionKey)
|
data, err := s.redis.GetBytes(ctx, sessionKey)
|
||||||
@@ -162,6 +159,7 @@ func (s *yggdrasilSessionService) ValidateSession(ctx context.Context, serverID,
|
|||||||
return apperrors.ErrSessionMismatch
|
return apperrors.ErrSessionMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateSession 由 HasJoinedServer 调用,serverID 已经过校验,此处无需重复 ValidateServerID
|
||||||
sessionData, err := s.GetSession(ctx, serverID)
|
sessionData, err := s.GetSession(ctx, serverID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.ErrSessionNotFound
|
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.TextureDownloadLog{},
|
||||||
&model.Client{},
|
&model.Client{},
|
||||||
&model.Yggdrasil{},
|
&model.Yggdrasil{},
|
||||||
|
&model.Friend{},
|
||||||
|
&model.PlayerAttributes{},
|
||||||
&model.AuditLog{},
|
&model.AuditLog{},
|
||||||
&model.CasbinRule{},
|
&model.CasbinRule{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -1,29 +1,13 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/x509"
|
|
||||||
"encoding/pem"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"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)
|
// YggdrasilJWTService Yggdrasil JWT服务(使用RSA512)
|
||||||
type YggdrasilJWTService struct {
|
type YggdrasilJWTService struct {
|
||||||
privateKey *rsa.PrivateKey
|
privateKey *rsa.PrivateKey
|
||||||
@@ -122,98 +106,3 @@ func (j *YggdrasilJWTService) ParseAccessToken(accessToken string, stalePolicy S
|
|||||||
func (j *YggdrasilJWTService) GetPublicKey() *rsa.PublicKey {
|
func (j *YggdrasilJWTService) GetPublicKey() *rsa.PublicKey {
|
||||||
return j.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
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"errors"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockRedisClient 模拟Redis客户端
|
// generateTestKeyPair 生成测试用的 RSA 密钥对
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试辅助函数:生成测试用的密钥对
|
|
||||||
func generateTestKeyPair(t *testing.T) *rsa.PrivateKey {
|
func generateTestKeyPair(t *testing.T) *rsa.PrivateKey {
|
||||||
privateKey, err := GenerateKeyPair()
|
t.Helper()
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("生成密钥对失败: %v", err)
|
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) {
|
func TestYggdrasilTokenClaims_EmptyProfileID(t *testing.T) {
|
||||||
privateKey := generateTestKeyPair(t)
|
privateKey := generateTestKeyPair(t)
|
||||||
service := NewYggdrasilJWTService(privateKey, "test-issuer")
|
service := NewYggdrasilJWTService(privateKey, "test-issuer")
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ func setupEnvMappings() {
|
|||||||
viper.BindEnv("server.read_timeout", "SERVER_READ_TIMEOUT")
|
viper.BindEnv("server.read_timeout", "SERVER_READ_TIMEOUT")
|
||||||
viper.BindEnv("server.write_timeout", "SERVER_WRITE_TIMEOUT")
|
viper.BindEnv("server.write_timeout", "SERVER_WRITE_TIMEOUT")
|
||||||
viper.BindEnv("server.swagger_enabled", "SERVER_SWAGGER_ENABLED")
|
viper.BindEnv("server.swagger_enabled", "SERVER_SWAGGER_ENABLED")
|
||||||
|
viper.BindEnv("environment", "ENVIRONMENT")
|
||||||
|
|
||||||
// 数据库配置
|
// 数据库配置
|
||||||
viper.BindEnv("database.driver", "DATABASE_DRIVER")
|
viper.BindEnv("database.driver", "DATABASE_DRIVER")
|
||||||
@@ -306,6 +307,10 @@ func setupEnvMappings() {
|
|||||||
viper.BindEnv("log.level", "LOG_LEVEL")
|
viper.BindEnv("log.level", "LOG_LEVEL")
|
||||||
viper.BindEnv("log.format", "LOG_FORMAT")
|
viper.BindEnv("log.format", "LOG_FORMAT")
|
||||||
viper.BindEnv("log.output", "LOG_OUTPUT")
|
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")
|
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"
|
config.Email.Enabled = emailEnabled == "true" || emailEnabled == "True" || emailEnabled == "TRUE" || emailEnabled == "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理环境配置
|
// 处理环境配置(已由 BindEnv("environment", "ENVIRONMENT") + AutomaticEnv 处理)
|
||||||
if env := os.Getenv("ENVIRONMENT"); env != "" {
|
|
||||||
config.Environment = env
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理安全配置
|
// 处理安全配置
|
||||||
if allowedOrigins := os.Getenv("SECURITY_ALLOWED_ORIGINS"); allowedOrigins != "" {
|
if allowedOrigins := os.Getenv("SECURITY_ALLOWED_ORIGINS"); allowedOrigins != "" {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrotskin/internal/model"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"carrotskin/internal/model"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -41,6 +42,10 @@ func AutoMigrateWithDB(db *gorm.DB, logger *zap.Logger) error {
|
|||||||
// Yggdrasil相关表(在User之后创建,因为它引用User)
|
// Yggdrasil相关表(在User之后创建,因为它引用User)
|
||||||
&model.Yggdrasil{},
|
&model.Yggdrasil{},
|
||||||
|
|
||||||
|
// 好友系统相关表(好友关系、玩家偏好属性)
|
||||||
|
&model.Friend{},
|
||||||
|
&model.PlayerAttributes{},
|
||||||
|
|
||||||
// 审计日志表
|
// 审计日志表
|
||||||
&model.AuditLog{},
|
&model.AuditLog{},
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ func (s *StorageClient) GetBucket(name string) (string, error) {
|
|||||||
return bucket, nil
|
return bucket, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// BuildFileURL 构建文件的公开访问URL
|
// BuildFileURL 构建文件的公开访问URL
|
||||||
func (s *StorageClient) BuildFileURL(bucketName, objectName string) string {
|
func (s *StorageClient) BuildFileURL(bucketName, objectName string) string {
|
||||||
return fmt.Sprintf("%s/%s/%s", s.publicURL, bucketName, objectName)
|
return fmt.Sprintf("%s/%s/%s", s.publicURL, bucketName, objectName)
|
||||||
|
|||||||
Reference in New Issue
Block a user