8 Commits

Author SHA1 Message Date
lan
9fc1be0ec1 feat(yggdrasil): 实现 MC 1.21.7 单用户名查 UUID 接口
Some checks failed
Build / build-docker (push) Has been cancelled
Build / build (push) Has been cancelled
对应官方 api.minecraftservices.com/minecraft/profile/lookup/name/{name},
1.21.7 的 /whitelist add 等命令会请求该接口(authlib-injector issue #277)。
authlib-injector 把 api.minecraftservices.com override 到 {apiRoot}/minecraftservices,
故路由注册在 mcServices 组下:

  GET /api/yggdrasil/minecraftservices/minecraft/profile/lookup/name/:name

复用 ProfileSearchByNameSingle,返回 {id,name},未找到返回 404。
go build ./... && go test ./... 全部通过。
2026-07-09 21:06:43 +08:00
lan
f2b02682c2 feat(yggdrasil): 实现好友系统与 profiles 查询接口
Some checks failed
Build / build-docker (push) Has been cancelled
Build / build (push) Has been cancelled
按 MinecraftServices 文档实现好友系四组接口与 profiles 服务:

好友系统(/api/yggdrasil/minecraftservices/*)
- Friends(1.3):列表查询、ADD/REMOVE 操作(发请求/接受/拒绝/撤回/删除)
  - 状态枚举+单向记录模型,接受请求时合并两方向避免好友列表重复
- Player Attributes(1.2):friendsPreferences / 脏词过滤 / 聊天偏好读写
  - Upsert 用 map 显式写字段规避 gorm 对带 default 零值布尔字段的忽略
- Presence(1.1):Redis key+TTL 上报与好友在线状态批量查询
- Blocklist(1.6):屏蔽列表查询(含 120s Redis 缓存),Block/Unblock 内部方法

Profiles 服务
- getManyByName(2.1):POST /api/profiles/minecraft,返回 [{id,name}]
  - toLowerCase 规范化、去重、空名过滤、maxBatch=10 超限拒绝
- getByName(2.2):GET /api/users/profiles/minecraft/:name,返回 {id,name},未找到返回 404

路由与基础设施
- 路由按官方 host 前缀一一转发
  - api.mojang.com/* -> /api/yggdrasil/api/*
  - api.minecraftservices.com/* -> /api/yggdrasil/minecraftservices/*
- 新增 Friend/PlayerAttributes 模型与 AutoMigrate 注册
- 新增 FriendsService 与 Container 装配
- 抽 extractBearerToken helper,统一 MinecraftServices 错误响应

修复
- texture_service: ToggleFavorite 在 db 为 nil 时降级非事务执行
  修复 TestTextureServiceImpl_ToggleFavorite 空指针 panic
- texture_service_test: UploadTexture 用例改用真实 SHA-256 命中 mock
  修复 4 个子用例因文件大小/Hash 不匹配的失败
- profile_repository: GetByNames/FindByName 改 LOWER() 大小写不敏感
  与客户端 toLowerCase 规范化对齐

测试
- 好友模型、ADD/REMOVE/接受、属性、屏蔽、Bearer 解析单测全绿
- profiles 查询规范化、去重、超限、大小写不敏感单测全绿
- go build ./... && go test ./... 全部通过
2026-07-09 20:44:41 +08:00
lan
1dc9c36a3a refactor(yggdrasil): 整理与性能优化,修复若干 Bug
Some checks failed
Build / build (push) Failing after 21m45s
Build / build-docker (push) Has been skipped
Bug 修复
- textures metadata:SKIN 仅在 IsSlim 时输出 {"model":"slim"};CAPE 不带 metadata。
  原实现误用文件字节数 skin.Size 作为 metadata,违反 Yggdrasil 协议。
- KeyPair 持久化不全:Profile 新增 rsa_public_key/public_key_signature/
  public_key_signature_v2/key_expires_at/key_refresh_at 列;GetKeyPair/UpdateKeyPair
  读写全部字段。AutoMigrate 自动加列。原实现每次请求都重新生成 RSA-4096。
- GetPlayerCertificates:无效 token 返回 401(先前误用 403 且未判 err)。
- HasJoinedServer:失败返回 204 无 body,符合 Yggdrasil 协议(原用 APIResponse
  + body 违反 204 规范)。

代码质量
- Authenticate 删除冗余 body 读取与回放。
- 证书服务引入具名结构 PlayerCertificate/PlayerKeyPair,替代 map[string]interface{}。
- CreateSession 用户名缺失改用 ErrUsernameRequired(新增)。

性能优化(签名一致性的回归测试已覆盖)
- SignatureService 缓存已解析的根私钥(sync.RWMutex 双重检查),
  快路径仅一次 RLock + RSA 签名,免去每次签名 PEM 解析与 Redis 往返。
- GetOrCreateYggdrasilKeyPair 用 MGet 单次往返取三字段,缓存命中后零 Redis。
- NewKeyPair 消息构造用 strconv.AppendInt 避免 string+拼接分配;V2 复用 DER 字节。
- 序列化服务:texturesMap 预分配 cap(2);slim 分支直接构造完整 map。
- 会话服务 GetSession 移除重复的 ValidateServerID 校验。

死代码清理
- 删除 yggdrasil_validator.go(未被调用的 Validator)。
- 删除 signature_service.FormatPublicKey/SignStringWithProfileRSA。
- 删除 pkg/auth 中未被使用的 YggdrasilJWTManager 与 GenerateKeyPair/
  EncodePrivateKeyToPEM/RedisClient/YggdrasilPrivateKeyRedisKey。
- 删除 yggdrasil_handler.go 中 25 个未使用常量、passwordRegex、
  APIResponse/standardResponse。
- 删除 errors.go 中未被使用的 ErrYggForbiddenOperation/ErrYggIllegalArgument/
  ErrInvalidSignature/ErrInvalidTextureType/ErrCertificateGenerate/ErrInvalidPassword。

测试
- 新增 signature_service_test.go(基于 miniredis):8 个测试 + 基准。
  核心:SignString_MatchesReference 与重构前参考实现逐字节比对签名输出一致。
  -race 检测通过;基准约 7.1ms/op(缓存命中路径)。

附带(与本次任务前已存在的未提交改动)
- handler/captcha_handler:将响应字段 msg 改为 message,与前端约定对齐。
2026-07-09 17:18:26 +08:00
cea22951b9 docs: 更新 README,新增对象存储桶策略与 CORS 配置说明
Some checks failed
Build / build (push) Failing after 39m5s
Build / build-docker (push) Has been skipped
- 新增'对象存储配置'章节:存储桶创建、匿名只读策略、CORS 配置(前端3D渲染必需)
- 明确后端不自动创建存储桶,需手动配置
- 按当前 dev 代码更新:Go 1.25+、Casbin v3、fx 子配置 Provider
- 补充默认管理员账号、RUSTFS_PUBLIC_URL 说明、Docker 部署细节
- 修正启动命令、目录结构、Swagger 产物说明
2026-07-08 17:37:24 +08:00
lan
ae3a569f03 refactor(app): provide sub-config structures from main config
All checks were successful
Build / build (push) Successful in 1m41s
Build / build-docker (push) Successful in 48s
Extract DatabaseConfig, RedisConfig, RustFSConfig, LogConfig, and EmailConfig
from the main Config to enable pkg/* package constructors to receive specific
config types by value, supporting the fx.Lifecycle OnStop hooks pattern.
2026-07-07 15:00:44 +08:00
lafay
b23a169925 chore: import 分组规范 + 文档同步 + config 修复
All checks were successful
Build / build (push) Successful in 7m17s
Build / build-docker (push) Successful in 2m41s
2026-06-15 16:52:20 +08:00
lafay
7d1c78f965 refactor: 移除全局单例、修复分层违规、统一错误与响应
Some checks failed
Build / build (push) Successful in 7m52s
Build / build-docker (push) Has been cancelled
2026-06-15 16:40:36 +08:00
lafay
d9de39a0a3 feat(app): 引入 uber-go/fx 重构 DI 装配层 2026-06-15 16:39:45 +08:00
92 changed files with 3782 additions and 2787 deletions

View File

@@ -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 # 是否压缩旧文件
# ============================================================================= # =============================================================================
# 安全配置 # 安全配置

216
README.md
View File

@@ -8,7 +8,7 @@
- **邮箱与验证码**:验证码发送频率控制、邮箱绑定与变更 - **邮箱与验证码**:验证码发送频率控制、邮箱绑定与变更
- **材质中心**:皮肤/披风上传、搜索、收藏、下载统计、Hash 去重 - **材质中心**:皮肤/披风上传、搜索、收藏、下载统计、Hash 去重
- **角色档案**Minecraft Profile 管理、RSA 密钥对生成、活跃档案切换 - **角色档案**Minecraft Profile 管理、RSA 密钥对生成、活跃档案切换
- **存储与上传**RustFS/MinIO 预签名 URL减轻服务器带宽压力 - **存储与上传**RustFS/MinIOS3 兼容直接上传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 / MinIOS3 兼容) | | 对象存储 | RustFS / MinIOS3 兼容) |
| 权限控制 | 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/
│ ├── handler/ # HTTP Handler 与 Swagger 注解 │ ├── app/ # uber-go/fx 装配层(唯一依赖注入入口)
│ ├── service/ # 业务逻辑 │ ├── module.go # 聚合根 Module
│ ├── repository/ # 数据访问 │ ├── infra_module.go # Logger/DB/Redis/Storage/Email/JWT/Casbin + 子配置 Provider + Lifecycle
│ ├── model/ # GORM 数据模型 │ ├── container_module.go # Container 聚合 + 迁移/路由/HTTP/任务生命周期
│ ├── types/ # 请求/响应 DTO │ ├── repository_module.go # Repository Provider占位Container 模式暂未启用)
│ ├── middleware/ # Gin 中间件 │ ├── service_module.go # Service Provider占位
└── task/ # 定时任务与后台作业 │ ├── handler_module.go # Handler Provider占位
├── pkg/ # 可复用组件config、database、auth、logger、redis、storage 等 ├── server_module.go # HTTP 服务器模块(占位
├── docs/ # swagger 生成产物docs.go / swagger.json / swagger.yaml └── task_module.go # 后台任务模块(占位
├── start.sh # 启动脚本(自动 swag init │ ├── container/ # 过渡期手动 DI 容器fx 迁移期的聚合层
├── docker-compose.yml # 本地容器编排 │ ├── handler/ # HTTP Handler 与 Swagger 注解
├── .env.example # 环境变量示例 │ ├── service/ # 业务逻辑(接口 + 实现)
└── go.mod # Go Module 定义 │ ├── repository/ # 数据访问(接口 + GORM 实现)
│ ├── model/ # GORM 数据模型与响应结构
│ ├── types/ # 请求/响应 DTO
│ ├── errors/ # 统一错误定义apperrors
│ ├── middleware/ # Gin 中间件
│ └── task/ # 定时任务与后台作业
├── pkg/ # 可复用组件config、database、auth、logger、redis、storage、email、utils
├── configs/casbin/ # Casbin RBAC 模型定义rbac_model.conf
├── start.sh # 启动脚本(自动 swag init
├── docker-compose.yml # 本地容器编排(含 rustfs-init 自动建桶)
├── .env.example # 环境变量示例
└── 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 会被判定为 tainted3D 皮肤将加载失败/显示空白。
在 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 Server30s 超时),再关 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
View 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)
}

View File

@@ -10,170 +10,28 @@
package main package main
import ( import (
"context"
"log" "log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"carrotskin/internal/container" "carrotskin/internal/app"
"carrotskin/internal/handler"
"carrotskin/internal/middleware"
"carrotskin/internal/task"
"carrotskin/pkg/auth"
"carrotskin/pkg/config" "carrotskin/pkg/config"
"carrotskin/pkg/database"
"carrotskin/pkg/email"
"carrotskin/pkg/logger"
"carrotskin/pkg/redis"
"carrotskin/pkg/storage"
"github.com/gin-gonic/gin" "go.uber.org/fx"
"go.uber.org/zap"
) )
// main 应用入口。通过 uber-go/fx 装配所有依赖并管理生命周期。
//
// fx.Run() 会:
// 1. 按 fx.Supply/Provide 构建依赖图
// 2. 执行所有 fx.Invoke迁移、路由注册、服务器/任务启动)
// 3. 监听 SIGINT/SIGTERM 信号
// 4. 按依赖逆序执行所有 OnStop 钩子(优雅关闭)
func main() { func main() {
// 初始化配置 // 加载配置(唯一的全局入口,配置本身是无状态的纯数据)
if err := config.Init(); err != nil { cfg, err := config.Load()
if err != nil {
log.Fatalf("配置加载失败: %v", err) log.Fatalf("配置加载失败: %v", err)
} }
cfg := config.MustGetConfig()
// 初始化日志 // 创建并运行 fx 应用
if err := logger.Init(cfg.Log); err != nil { fx.New(app.Module(cfg)).Run()
log.Fatalf("日志初始化失败: %v", err)
}
loggerInstance := logger.MustGetLogger()
defer loggerInstance.Sync()
// 初始化数据库
if err := database.Init(cfg.Database, loggerInstance); err != nil {
loggerInstance.Fatal("数据库初始化失败", zap.Error(err))
}
defer database.Close()
// 执行数据库迁移
if err := database.AutoMigrate(loggerInstance); err != nil {
loggerInstance.Fatal("数据库迁移失败", zap.Error(err))
}
// 初始化种子数据
if err := database.Seed(loggerInstance); err != nil {
loggerInstance.Fatal("种子数据初始化失败", zap.Error(err))
}
// 初始化JWT服务
if err := auth.Init(cfg.JWT); err != nil {
loggerInstance.Fatal("JWT服务初始化失败", zap.Error(err))
}
// 初始化Redis开发/测试环境失败时会自动回退到miniredis
if err := redis.Init(cfg.Redis, loggerInstance); err != nil {
loggerInstance.Fatal("Redis初始化失败", zap.Error(err))
}
defer redis.Close()
// 记录Redis模式
if redis.IsUsingMiniRedis() {
loggerInstance.Info("使用miniredis进行开发/测试")
} else {
loggerInstance.Info("使用生产Redis")
}
// 初始化对象存储 (RustFS - S3兼容)
var storageClient *storage.StorageClient
if err := storage.Init(cfg.RustFS); err != nil {
loggerInstance.Warn("对象存储连接失败,某些功能可能不可用", zap.Error(err))
} else {
storageClient = storage.MustGetClient()
loggerInstance.Info("对象存储连接成功")
}
// 初始化邮件服务
if err := email.Init(cfg.Email, loggerInstance); err != nil {
loggerInstance.Fatal("邮件服务初始化失败", zap.Error(err))
}
emailServiceInstance := email.MustGetService()
// 初始化Casbin权限服务
casbinService, err := auth.NewCasbinService(database.MustGetDB(), cfg.Casbin.ModelPath, loggerInstance)
if err != nil {
loggerInstance.Fatal("Casbin服务初始化失败", zap.Error(err))
}
// 创建依赖注入容器
c := container.NewContainer(
database.MustGetDB(),
redis.MustGetClient(),
loggerInstance,
auth.MustGetJWTService(),
casbinService,
storageClient,
emailServiceInstance,
)
// 设置Gin模式
if cfg.Server.Mode == "production" {
gin.SetMode(gin.ReleaseMode)
}
// 创建路由
router := gin.New()
// 禁用自动重定向允许API路径带或不带/结尾都能正常访问
router.RedirectTrailingSlash = false
router.RedirectFixedPath = false
// 添加中间件
router.Use(middleware.Logger(loggerInstance))
router.Use(middleware.Recovery(loggerInstance))
router.Use(middleware.CORS())
// 使用依赖注入方式注册路由
handler.RegisterRoutesWithDI(router, c)
// 启动后台任务Token已迁移到Redis不再需要清理任务
// 如需使用数据库Token存储可以恢复TokenCleanupTask
taskRunner := task.NewRunner(loggerInstance)
taskCtx, taskCancel := context.WithCancel(context.Background())
defer taskCancel()
taskRunner.Start(taskCtx)
// 创建HTTP服务器
srv := &http.Server{
Addr: cfg.Server.Port,
Handler: router,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
}
// 启动服务器
go func() {
loggerInstance.Info("服务器启动", zap.String("port", cfg.Server.Port))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
loggerInstance.Fatal("服务器启动失败", zap.Error(err))
}
}()
// 等待中断信号优雅关闭
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
loggerInstance.Info("正在关闭服务器...")
// 停止后台任务
taskCancel()
taskRunner.Wait()
// 设置关闭超时
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
loggerInstance.Fatal("服务器强制关闭", zap.Error(err))
}
loggerInstance.Info("服务器已关闭")
} }

6
go.mod
View File

@@ -19,6 +19,7 @@ require (
github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/gin-swagger v1.6.1
github.com/wenlng/go-captcha-assets v1.0.7 github.com/wenlng/go-captcha-assets v1.0.7
github.com/wenlng/go-captcha/v2 v2.0.4 github.com/wenlng/go-captcha/v2 v2.0.4
go.uber.org/fx v1.24.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
gopkg.in/mail.v2 v2.3.1 gopkg.in/mail.v2 v2.3.1
gorm.io/datatypes v1.2.7 gorm.io/datatypes v1.2.7
@@ -69,6 +70,7 @@ require (
github.com/tinylib/msgp v1.6.3 // indirect github.com/tinylib/msgp v1.6.3 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.uber.org/dig v1.19.0 // indirect
go.uber.org/mock v0.6.0 // indirect go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
@@ -102,7 +104,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 github.com/json-iterator/go v1.1.12
@@ -120,7 +122,7 @@ require (
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggo/swag v1.16.6 // indirect github.com/swaggo/swag v1.16.6
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect

4
go.sum
View File

@@ -291,6 +291,10 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=

View File

@@ -0,0 +1,138 @@
package app
import (
"context"
"net/http"
"carrotskin/internal/container"
"carrotskin/internal/handler"
"carrotskin/internal/middleware"
"carrotskin/internal/task"
"carrotskin/pkg/auth"
"carrotskin/pkg/config"
"carrotskin/pkg/database"
"carrotskin/pkg/email"
"carrotskin/pkg/redis"
"carrotskin/pkg/storage"
"github.com/gin-gonic/gin"
"go.uber.org/fx"
"go.uber.org/zap"
"gorm.io/gorm"
)
// BootstrapModule 注册 Container、路由注册、HTTP 服务器与后台任务的生命周期。
//
// 设计说明:当前所有 Handler 仍依赖 *container.Container阶段6将逐步解耦为
// 直接依赖各 Service 接口)。本 Module 由 fx 注入基础设施,聚合为 Container
// 再用它注册路由、启动服务器与后台任务。当 fx.Run() 接收到 SIGINT/SIGTERM 时,
// 会按依赖逆序执行所有 OnStop 钩子,实现优雅关闭。
var BootstrapModule = fx.Options(
// Gin 路由引擎
fx.Provide(provideRouter),
// 由 fx 注入的基础设施聚合为 ContainerContainer 内部会构造 Repository/Service
fx.Provide(provideContainer),
// 数据库迁移与种子数据infra 就绪后执行)
fx.Invoke(runMigrate),
// 注册所有 HTTP 路由
fx.Invoke(registerRoutes),
// HTTP 服务器生命周期(启动/优雅关闭)
fx.Invoke(registerServerLifecycle),
// 后台任务调度器生命周期
fx.Invoke(registerTaskLifecycle),
)
// provideRouter 创建 Gin 引擎并挂载全局中间件。
func provideRouter(cfg *config.Config, logger *zap.Logger) *gin.Engine {
if cfg.Server.Mode == "production" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
// 禁用自动重定向允许API路径带或不带/结尾都能正常访问
router.RedirectTrailingSlash = false
router.RedirectFixedPath = false
router.Use(middleware.Logger(logger))
router.Use(middleware.Recovery(logger))
router.Use(middleware.CORS(cfg))
return router
}
// provideContainer 将所有 fx 管理的基础设施聚合为 Container。
// Container.NewContainer 内部会基于这些 infra 构造 Repository 与 Service。
// 注意email.Service 可能为 nil当 EMAIL_ENABLED=false 时Container 会降级处理。
func provideContainer(
cfg *config.Config,
db *gorm.DB,
redisClient *redis.Client,
logger *zap.Logger,
jwt *auth.JWTService,
casbin *auth.CasbinService,
storageClient *storage.StorageClient,
emailService *email.Service,
) *container.Container {
return container.NewContainer(
cfg, db, redisClient, logger, jwt, casbin, storageClient, emailService,
)
}
// runMigrate 执行数据库迁移与种子数据初始化。
func runMigrate(db *gorm.DB, logger *zap.Logger) error {
if err := database.AutoMigrateWithDB(db, logger); err != nil {
return err
}
return database.SeedWithDB(db, logger)
}
// registerRoutes 注册所有 HTTP 路由。
func registerRoutes(router *gin.Engine, c *container.Container) {
handler.RegisterRoutesWithDI(router, c)
}
// registerServerLifecycle 注册 HTTP 服务器的启动与优雅关闭。
func registerServerLifecycle(lc fx.Lifecycle, router *gin.Engine, cfg *config.Config, logger *zap.Logger) {
srv := &http.Server{
Addr: cfg.Server.Port,
Handler: router,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
}
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
logger.Info("服务器启动", zap.String("port", cfg.Server.Port))
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal("服务器启动失败", zap.Error(err))
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
logger.Info("正在关闭服务器...")
return srv.Shutdown(ctx)
},
})
}
// registerTaskLifecycle 注册后台任务调度器的启动与停止。
func registerTaskLifecycle(lc fx.Lifecycle, logger *zap.Logger) {
runner := task.NewRunner(logger)
ctx, cancel := context.WithCancel(context.Background())
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
runner.Start(ctx)
return nil
},
OnStop: func(_ context.Context) error {
cancel()
runner.Wait()
return nil
},
})
}

View File

@@ -0,0 +1,8 @@
package app
// HandlerModule 已在阶段4移除。
//
// 当前阶段Handler 仍由 internal/container.Container + handler.RegisterRoutesWithDI
// 构造与注册(见 container_module.go 的 registerRoutes
//
// TODO(阶段6): Handler 将改为直接依赖各 Service 接口,由 fx 直接 Provide 与注册。

View File

@@ -0,0 +1,115 @@
package app
import (
"context"
"carrotskin/pkg/auth"
"carrotskin/pkg/config"
"carrotskin/pkg/database"
"carrotskin/pkg/email"
"carrotskin/pkg/logger"
"carrotskin/pkg/redis"
"carrotskin/pkg/storage"
"go.uber.org/fx"
"go.uber.org/zap"
"gorm.io/gorm"
)
// InfraModule 注册所有基础设施依赖Logger/DB/Redis/Storage/Email/JWT/Casbin。
// 每个需要资源释放的组件都通过 fx.Lifecycle 注册 OnStop 钩子,
// 彻底替代 main.go 里分散的 defer xxx.Close()。
var InfraModule = fx.Options(
// 顶层 Config 子结构 Provider让 pkg/* 包的构造函数按值接收细分 Config
fx.Provide(func(c *config.Config) config.DatabaseConfig { return c.Database }),
fx.Provide(func(c *config.Config) config.RedisConfig { return c.Redis }),
fx.Provide(func(c *config.Config) config.RustFSConfig { return c.RustFS }),
fx.Provide(func(c *config.Config) config.LogConfig { return c.Log }),
fx.Provide(func(c *config.Config) config.EmailConfig { return c.Email }),
// Logger
fx.Provide(logger.New),
// Database: 同时暴露 *database.DB封装带连接池统计和 *gorm.DB裸 GORM
fx.Provide(provideDatabase),
// 暴露 *gorm.DB 供 Repository/Casbin 等消费
fx.Provide(func(d *database.DB) *gorm.DB { return d.DB }),
// Redis含 miniredis 测试回退)
fx.Provide(provideRedis),
// 对象存储S3 兼容)
fx.Provide(provideStorage),
// 邮件服务
fx.Provide(email.NewService),
// JWT 服务(应用层认证)
fx.Provide(func(cfg *config.Config) *auth.JWTService {
return auth.NewJWTService(cfg.JWT.Secret, cfg.JWT.ExpireHours)
}),
// Casbin 权限服务
fx.Provide(func(db *gorm.DB, cfg *config.Config, log *zap.Logger) (*auth.CasbinService, error) {
return auth.NewCasbinService(db, cfg.Casbin.ModelPath, log)
}),
)
// provideDatabase 创建数据库连接并注册关闭钩子。
func provideDatabase(cfg *config.Config, lc fx.Lifecycle, log *zap.Logger) (*database.DB, error) {
db, err := database.New(cfg.Database)
if err != nil {
return nil, err
}
lc.Append(fx.Hook{
OnStop: func(ctx context.Context) error {
log.Info("正在关闭数据库连接")
return db.Close()
},
})
return db, nil
}
// provideRedis 创建 Redis 客户端并注册关闭钩子。
// 开发/测试环境下若连接失败会自动回退到 miniredis。
func provideRedis(cfg *config.Config, lc fx.Lifecycle, log *zap.Logger) (*redis.Client, error) {
client, err := redis.New(cfg.Redis, log)
if err != nil {
// 检查是否允许回退到 miniredis仅开发/测试环境)
if redis.AllowFallbackToMiniRedis() {
log.Warn("Redis连接失败尝试使用miniredis回退", zap.Error(err))
client, err = redis.InitMiniRedis(log)
if err != nil {
return nil, err
}
log.Info("已回退到miniredis用于开发/测试环境")
} else {
return nil, err
}
}
lc.Append(fx.Hook{
OnStop: func(ctx context.Context) error {
log.Info("正在关闭 Redis 连接")
return client.Close()
},
})
return client, nil
}
// provideStorage 创建对象存储客户端并注册关闭钩子。
// 存储服务在配置缺失时可能是可选的,失败时返回 error 让调用方决定是否降级。
func provideStorage(cfg *config.Config, lc fx.Lifecycle, log *zap.Logger) (*storage.StorageClient, error) {
client, err := storage.NewStorage(cfg.RustFS)
if err != nil {
return nil, err
}
// MinIO 客户端无需显式 Close但保留钩子以备未来扩展
lc.Append(fx.Hook{
OnStop: func(ctx context.Context) error {
log.Info("对象存储客户端已停止")
return nil
},
})
return client, nil
}

30
internal/app/module.go Normal file
View File

@@ -0,0 +1,30 @@
// Package app 提供 uber-go/fx 应用装配层。
//
// 该包用 fx 的 Provider/Lifecycle 取代了 pkg/*/manager.go 的全局单例和
// internal/container 的手动 DI是整个应用唯一的依赖装配入口。
//
// 使用方式:
//
// cfg, err := config.Load()
// if err != nil { log.Fatal(err) }
// fx.New(app.Module(cfg)).Run()
package app
import (
"carrotskin/pkg/config"
"go.uber.org/fx"
)
// Module 返回应用的根 fx.Option聚合所有子模块。
// cfg 通过 fx.Supply 注入,供各 Provider 消费其子配置结构。
//
// 当前阶段fx 管理 infra + Container 聚合 + 路由/服务器/任务生命周期。
// Container 内部构造 Repository 与 Service阶段6后将逐步解耦为 fx 直接管理 Service
func Module(cfg *config.Config) fx.Option {
return fx.Options(
fx.Supply(cfg),
InfraModule,
BootstrapModule,
)
}

View File

@@ -0,0 +1,13 @@
package app
// RepositoryModule 已在阶段4移除。
//
// 当前阶段Repository 由 internal/container.Container 内部构造。
//
// TODO(阶段6): Container 移除后Repository 将回到此 Module 由 fx 直接管理:
//
// var RepositoryModule = fx.Options(
// fx.Provide(repository.NewUserRepository),
// fx.Provide(repository.NewProfileRepository),
// ...
// )

View File

@@ -0,0 +1,7 @@
package app
// ServerModule 已在阶段4移除。
//
// 当前阶段HTTP 服务器生命周期由 container_module.go 的 registerServerLifecycle 管理。
//
// TODO(阶段6): 此处将作为独立 Module 重新启用。

View File

@@ -0,0 +1,15 @@
package app
// ServiceModule 已在阶段4移除。
//
// 当前阶段Service 由 internal/container.Container 内部构造Container 聚合了
// 所有 fx 注入的基础设施,在其 NewContainer 方法里创建 Repository 与 Service
//
// TODO(阶段6): 当 Handler 解耦为直接依赖各 Service 接口后Container 将被移除,
// 届时 Service 将回到此 Module 由 fx 直接管理:
//
// var ServiceModule = fx.Options(
// fx.Provide(service.NewUserService),
// fx.Provide(service.NewProfileService),
// ...
// )

View File

@@ -0,0 +1,7 @@
package app
// TaskModule 已在阶段4移除。
//
// 当前阶段,后台任务生命周期由 container_module.go 的 registerTaskLifecycle 管理。
//
// TODO(阶段6): 此处将作为独立 Module 重新启用。

View File

@@ -1,14 +1,17 @@
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"
"carrotskin/pkg/config"
"carrotskin/pkg/database" "carrotskin/pkg/database"
"carrotskin/pkg/email" "carrotskin/pkg/email"
"carrotskin/pkg/redis" "carrotskin/pkg/redis"
"carrotskin/pkg/storage" "carrotskin/pkg/storage"
"time"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
@@ -17,6 +20,9 @@ import (
// Container 依赖注入容器 // Container 依赖注入容器
// 集中管理所有依赖,便于测试和维护 // 集中管理所有依赖,便于测试和维护
type Container struct { type Container struct {
// 配置
Config *config.Config
// 基础设施依赖 // 基础设施依赖
DB *gorm.DB DB *gorm.DB
Redis *redis.Client Redis *redis.Client
@@ -27,11 +33,13 @@ type Container struct {
CacheManager *database.CacheManager CacheManager *database.CacheManager
// Repository层 // Repository层
UserRepo repository.UserRepository UserRepo repository.UserRepository
ProfileRepo repository.ProfileRepository ProfileRepo repository.ProfileRepository
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
@@ -39,21 +47,22 @@ 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
SecurityService service.SecurityService
CaptchaService service.CaptchaService CaptchaService service.CaptchaService
SignatureService *service.SignatureService SignatureService *service.SignatureService
} }
// NewContainer 创建依赖容器 // NewContainer 创建依赖容器
func NewContainer( func NewContainer(
cfg *config.Config,
db *gorm.DB, db *gorm.DB,
redisClient *redis.Client, redisClient *redis.Client,
logger *zap.Logger, logger *zap.Logger,
jwtService *auth.JWTService, jwtService *auth.JWTService,
casbinService *auth.CasbinService, casbinService *auth.CasbinService,
storageClient *storage.StorageClient, storageClient *storage.StorageClient,
emailService interface{}, // 接受 email.Service 但使用 interface{} 避免循环依赖 emailService *email.Service,
) *Container { ) *Container {
// 创建缓存管理器 // 创建缓存管理器
cacheManager := database.NewCacheManager(redisClient, database.CacheConfig{ cacheManager := database.NewCacheManager(redisClient, database.CacheConfig{
@@ -71,6 +80,7 @@ func NewContainer(
}) })
c := &Container{ c := &Container{
Config: cfg,
DB: db, DB: db,
Redis: redisClient, Redis: redisClient,
Logger: logger, Logger: logger,
@@ -86,20 +96,25 @@ 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添加到容器中供其他服务使用
c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger) c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger)
// 初始化Service注入缓存管理器 // 初始化Service注入缓存管理器
c.UserService = service.NewUserService(c.UserRepo, jwtService, redisClient, cacheManager, storageClient, logger) c.UserService = service.NewUserService(cfg, c.UserRepo, jwtService, redisClient, cacheManager, storageClient, logger)
c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger) c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger)
c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger) c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger, db)
// 获取Yggdrasil私钥并创建JWT服务TokenService需要 // 获取Yggdrasil私钥并创建JWT服务TokenService需要
// 注意这里仍然需要预先初始化因为TokenService在创建时需要YggdrasilJWT // 注意这里仍然需要预先初始化因为TokenService在创建时需要YggdrasilJWT
// 但SignatureService已经作为依赖注入降低了耦合度 // 但SignatureService已经作为依赖注入降低了耦合度
_, privateKey, err := c.SignatureService.GetOrCreateYggdrasilKeyPair() // 使用后台 ctx启动阶段不依赖请求 ctx
initCtx, cancelInit := context.WithTimeout(context.Background(), 30*time.Second)
_, privateKey, err := c.SignatureService.GetOrCreateYggdrasilKeyPair(initCtx)
cancelInit()
if err != nil { if err != nil {
logger.Fatal("获取Yggdrasil私钥失败", zap.Error(err)) logger.Fatal("获取Yggdrasil私钥失败", zap.Error(err))
} }
@@ -121,149 +136,18 @@ func NewContainer(
c.TokenService = service.NewTokenServiceRedis(tokenStore, c.ClientRepo, c.ProfileRepo, yggdrasilJWT, logger) c.TokenService = service.NewTokenServiceRedis(tokenStore, c.ClientRepo, c.ProfileRepo, yggdrasilJWT, logger)
// 使用组合服务(内部包含认证、会话、序列化、证书服务) // 使用组合服务(内部包含认证、会话、序列化、证书服务)
c.YggdrasilService = service.NewYggdrasilServiceComposite(db, c.UserRepo, c.ProfileRepo, c.YggdrasilRepo, c.SignatureService, redisClient, logger, c.TokenService) c.YggdrasilService = service.NewYggdrasilServiceComposite(c.UserRepo, c.ProfileRepo, c.YggdrasilRepo, c.TextureRepo, c.SignatureService, redisClient, logger, c.TokenService)
// 好友系统服务(依赖 Profile/Redis
c.FriendsService = service.NewFriendsService(c.FriendRepo, c.PlayerAttrRepo, c.ProfileRepo, redisClient, logger)
// 初始化其他服务 // 初始化其他服务
c.SecurityService = service.NewSecurityService(redisClient) c.CaptchaService = service.NewCaptchaService(cfg, redisClient, logger)
c.CaptchaService = service.NewCaptchaService(redisClient, logger)
// 初始化VerificationService需要email.Service // 初始化VerificationService需要email.Service
if emailService != nil { if emailService != nil {
if emailSvc, ok := emailService.(*email.Service); ok { c.VerificationService = service.NewVerificationService(cfg, redisClient, emailService)
c.VerificationService = service.NewVerificationService(redisClient, emailSvc)
}
} }
return c return c
} }
// NewTestContainer 创建测试用容器可注入mock依赖
func NewTestContainer(opts ...Option) *Container {
c := &Container{}
for _, opt := range opts {
opt(c)
}
return c
}
// Option 容器配置选项
type Option func(*Container)
// WithDB 设置数据库连接
func WithDB(db *gorm.DB) Option {
return func(c *Container) {
c.DB = db
}
}
// WithRedis 设置Redis客户端
func WithRedis(redis *redis.Client) Option {
return func(c *Container) {
c.Redis = redis
}
}
// WithLogger 设置日志
func WithLogger(logger *zap.Logger) Option {
return func(c *Container) {
c.Logger = logger
}
}
// WithJWT 设置JWT服务
func WithJWT(jwt *auth.JWTService) Option {
return func(c *Container) {
c.JWT = jwt
}
}
// WithStorage 设置存储客户端
func WithStorage(storage *storage.StorageClient) Option {
return func(c *Container) {
c.Storage = storage
}
}
// WithUserRepo 设置用户仓储
func WithUserRepo(repo repository.UserRepository) Option {
return func(c *Container) {
c.UserRepo = repo
}
}
// WithProfileRepo 设置档案仓储
func WithProfileRepo(repo repository.ProfileRepository) Option {
return func(c *Container) {
c.ProfileRepo = repo
}
}
// WithTextureRepo 设置材质仓储
func WithTextureRepo(repo repository.TextureRepository) Option {
return func(c *Container) {
c.TextureRepo = repo
}
}
// WithUserService 设置用户服务
func WithUserService(svc service.UserService) Option {
return func(c *Container) {
c.UserService = svc
}
}
// WithProfileService 设置档案服务
func WithProfileService(svc service.ProfileService) Option {
return func(c *Container) {
c.ProfileService = svc
}
}
// WithTextureService 设置材质服务
func WithTextureService(svc service.TextureService) Option {
return func(c *Container) {
c.TextureService = svc
}
}
// WithTokenService 设置令牌服务
func WithTokenService(svc service.TokenService) Option {
return func(c *Container) {
c.TokenService = svc
}
}
// WithYggdrasilRepo 设置Yggdrasil仓储
func WithYggdrasilRepo(repo repository.YggdrasilRepository) Option {
return func(c *Container) {
c.YggdrasilRepo = repo
}
}
// WithYggdrasilService 设置Yggdrasil服务
func WithYggdrasilService(svc service.YggdrasilService) Option {
return func(c *Container) {
c.YggdrasilService = svc
}
}
// WithVerificationService 设置验证码服务
func WithVerificationService(svc service.VerificationService) Option {
return func(c *Container) {
c.VerificationService = svc
}
}
// WithSecurityService 设置安全服务
func WithSecurityService(svc service.SecurityService) Option {
return func(c *Container) {
c.SecurityService = svc
}
}
// WithCaptchaService 设置验证码服务
func WithCaptchaService(svc service.CaptchaService) Option {
return func(c *Container) {
c.CaptchaService = svc
}
}

View File

@@ -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("请求参数错误")
@@ -125,23 +118,8 @@ func NewInternalError(message string, err error) *AppError {
return NewAppError(500, message, err) return NewAppError(500, message, err)
} }
// Is 检查错误是否匹配 // 注意:原此处的 Is/As/Wrap 函数(透传标准库)已删除。
func Is(err, target error) bool { // 请直接使用标准库 errors.Is / errors.As / fmt.Errorf。
return errors.Is(err, target)
}
// As 尝试将错误转换为指定类型
func As(err error, target interface{}) bool {
return errors.As(err, target)
}
// Wrap 包装错误
func Wrap(err error, message string) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", message, err)
}
// YggdrasilErrorResponse Yggdrasil协议标准错误响应格式 // YggdrasilErrorResponse Yggdrasil协议标准错误响应格式
type YggdrasilErrorResponse struct { type YggdrasilErrorResponse struct {
@@ -168,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,
}
}

View File

@@ -15,24 +15,12 @@ func TestAppErrorBasics(t *testing.T) {
if got := appErr.Error(); got != "bad: root" { if got := appErr.Error(); got != "bad: root" {
t.Fatalf("unexpected Error(): %s", got) t.Fatalf("unexpected Error(): %s", got)
} }
if !Is(appErr, root) { // 使用标准库 errors.Is/As包级 Is/As 已移除)
if !errors.Is(appErr, root) {
t.Fatalf("Is should match wrapped error") t.Fatalf("Is should match wrapped error")
} }
var target *AppError var target *AppError
if !As(appErr, &target) { if !errors.As(appErr, &target) {
t.Fatalf("As should succeed") t.Fatalf("As should succeed")
} }
} }
func TestWrap(t *testing.T) {
if Wrap(nil, "msg") != nil {
t.Fatalf("Wrap nil should return nil")
}
err := errors.New("base")
wrapped := Wrap(err, "ctx")
if wrapped.Error() != "ctx: base" {
t.Fatalf("wrap message mismatch: %v", wrapped)
}
}

View File

@@ -1,10 +1,12 @@
package handler package handler
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
"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"
@@ -46,11 +48,16 @@ func (h *AdminHandler) SetUserRole(c *gin.Context) {
return return
} }
// 获取当前操作者ID // 获取当前操作者ID(安全类型断言,防止中间件异常时 panic
operatorID, _ := c.Get("user_id") operatorIDVal, _ := c.Get("user_id")
operatorID, ok := operatorIDVal.(int64)
if !ok {
RespondServerError(c, "操作者ID类型错误", nil)
return
}
// 不能修改自己的角色 // 不能修改自己的角色
if req.UserID == operatorID.(int64) { if req.UserID == operatorID {
c.JSON(http.StatusBadRequest, model.NewErrorResponse( c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest, model.CodeBadRequest,
"不能修改自己的角色", "不能修改自己的角色",
@@ -59,9 +66,13 @@ func (h *AdminHandler) SetUserRole(c *gin.Context) {
return return
} }
// 检查目标用户是否存在 // 检查目标用户是否存在(区分 DB 错误与"用户不存在"
targetUser, err := h.container.UserRepo.FindByID(c.Request.Context(), req.UserID) targetUser, err := h.container.UserService.GetByID(c.Request.Context(), req.UserID)
if err != nil || targetUser == nil { if err != nil {
RespondServerError(c, "查询用户失败", err)
return
}
if targetUser == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse( c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound, model.CodeNotFound,
"用户不存在", "用户不存在",
@@ -71,16 +82,13 @@ func (h *AdminHandler) SetUserRole(c *gin.Context) {
} }
// 更新用户角色 // 更新用户角色
err = h.container.UserRepo.UpdateFields(c.Request.Context(), req.UserID, map[string]interface{}{ if err := h.container.UserService.SetRole(c.Request.Context(), req.UserID, req.Role); err != nil {
"role": req.Role,
})
if err != nil {
RespondServerError(c, "更新用户角色失败", err) RespondServerError(c, "更新用户角色失败", err)
return return
} }
h.container.Logger.Info("管理员修改用户角色", h.container.Logger.Info("管理员修改用户角色",
zap.Int64("operator_id", operatorID.(int64)), zap.Int64("operator_id", operatorID),
zap.Int64("target_user_id", req.UserID), zap.Int64("target_user_id", req.UserID),
zap.String("new_role", req.Role), zap.String("new_role", req.Role),
) )
@@ -114,13 +122,12 @@ func (h *AdminHandler) GetUserList(c *gin.Context) {
pageSize = 20 pageSize = 20
} }
// 使用数据库直接查询用户列表 // 通过 Service 层查询用户列表
var users []model.User users, total, err := h.container.UserService.ListUsers(c.Request.Context(), page, pageSize)
var total int64 if err != nil {
RespondServerError(c, "获取用户列表失败", err)
db := h.container.DB return
db.Model(&model.User{}).Count(&total) }
db.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&users)
// 构建响应(隐藏敏感信息) // 构建响应(隐藏敏感信息)
userList := make([]gin.H, len(users)) userList := make([]gin.H, len(users))
@@ -163,7 +170,7 @@ func (h *AdminHandler) GetUserDetail(c *gin.Context) {
return return
} }
user, err := h.container.UserRepo.FindByID(c.Request.Context(), userID) user, err := h.container.UserService.GetByID(c.Request.Context(), userID)
if err != nil || user == nil { if err != nil || user == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse( c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound, model.CodeNotFound,
@@ -212,10 +219,15 @@ func (h *AdminHandler) SetUserStatus(c *gin.Context) {
return return
} }
operatorID, _ := c.Get("user_id") operatorIDVal, _ := c.Get("user_id")
operatorID, ok := operatorIDVal.(int64)
if !ok {
RespondServerError(c, "操作者ID类型错误", nil)
return
}
// 不能修改自己的状态 // 不能修改自己的状态
if req.UserID == operatorID.(int64) { if req.UserID == operatorID {
c.JSON(http.StatusBadRequest, model.NewErrorResponse( c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest, model.CodeBadRequest,
"不能修改自己的状态", "不能修改自己的状态",
@@ -224,9 +236,13 @@ func (h *AdminHandler) SetUserStatus(c *gin.Context) {
return return
} }
// 检查目标用户是否存在 // 检查目标用户是否存在(区分 DB 错误与"用户不存在"
targetUser, err := h.container.UserRepo.FindByID(c.Request.Context(), req.UserID) targetUser, err := h.container.UserService.GetByID(c.Request.Context(), req.UserID)
if err != nil || targetUser == nil { if err != nil {
RespondServerError(c, "查询用户失败", err)
return
}
if targetUser == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse( c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound, model.CodeNotFound,
"用户不存在", "用户不存在",
@@ -236,10 +252,7 @@ func (h *AdminHandler) SetUserStatus(c *gin.Context) {
} }
// 更新用户状态 // 更新用户状态
err = h.container.UserRepo.UpdateFields(c.Request.Context(), req.UserID, map[string]interface{}{ if err := h.container.UserService.SetStatus(c.Request.Context(), req.UserID, req.Status); err != nil {
"status": req.Status,
})
if err != nil {
RespondServerError(c, "更新用户状态失败", err) RespondServerError(c, "更新用户状态失败", err)
return return
} }
@@ -247,7 +260,7 @@ func (h *AdminHandler) SetUserStatus(c *gin.Context) {
statusText := map[int16]string{1: "正常", 0: "禁用", -1: "删除"}[req.Status] statusText := map[int16]string{1: "正常", 0: "禁用", -1: "删除"}[req.Status]
h.container.Logger.Info("管理员修改用户状态", h.container.Logger.Info("管理员修改用户状态",
zap.Int64("operator_id", operatorID.(int64)), zap.Int64("operator_id", operatorID),
zap.Int64("target_user_id", req.UserID), zap.Int64("target_user_id", req.UserID),
zap.Int16("new_status", req.Status), zap.Int16("new_status", req.Status),
) )
@@ -277,30 +290,30 @@ func (h *AdminHandler) DeleteTexture(c *gin.Context) {
return return
} }
operatorID, _ := c.Get("user_id") operatorIDVal, _ := c.Get("user_id")
operatorID, ok := operatorIDVal.(int64)
// 检查材质是否存在 if !ok {
var texture model.Texture RespondServerError(c, "操作者ID类型错误", nil)
if err := h.container.DB.First(&texture, textureID).Error; err != nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"材质不存在",
nil,
))
return return
} }
// 删除材质 // 通过 Service 删除材质Service 会检查存在性)
if err := h.container.DB.Delete(&texture).Error; err != nil { if err := h.container.TextureService.AdminDelete(c.Request.Context(), textureID); err != nil {
if errors.Is(err, apperrors.ErrTextureNotFound) {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"材质不存在",
nil,
))
return
}
RespondServerError(c, "删除材质失败", err) RespondServerError(c, "删除材质失败", err)
return return
} }
h.container.Logger.Info("管理员删除材质", h.container.Logger.Info("管理员删除材质",
zap.Int64("operator_id", operatorID.(int64)), zap.Int64("operator_id", operatorID),
zap.Int64("texture_id", textureID), zap.Int64("texture_id", textureID),
zap.Int64("uploader_id", texture.UploaderID),
zap.String("texture_name", texture.Name),
) )
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{ c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
@@ -330,12 +343,12 @@ func (h *AdminHandler) GetTextureList(c *gin.Context) {
pageSize = 20 pageSize = 20
} }
var textures []model.Texture // 通过 Service 层查询材质列表
var total int64 textures, total, err := h.container.TextureService.ListForAdmin(c.Request.Context(), page, pageSize)
if err != nil {
db := h.container.DB RespondServerError(c, "获取材质列表失败", err)
db.Model(&model.Texture{}).Count(&total) return
db.Preload("Uploader").Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&textures) }
// 构建响应 // 构建响应
textureList := make([]gin.H, len(textures)) textureList := make([]gin.H, len(textures))

View File

@@ -4,7 +4,6 @@ import (
"carrotskin/internal/container" "carrotskin/internal/container"
"carrotskin/internal/service" "carrotskin/internal/service"
"carrotskin/internal/types" "carrotskin/internal/types"
"carrotskin/pkg/email"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "go.uber.org/zap"
@@ -177,8 +176,3 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
RespondSuccess(c, gin.H{"message": "密码重置成功"}) RespondSuccess(c, gin.H{"message": "密码重置成功"})
} }
// getEmailService 获取邮件服务(暂时使用全局方式,后续可改为依赖注入)
func (h *AuthHandler) getEmailService() (*email.Service, error) {
return email.GetService()
}

View File

@@ -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) {
@@ -42,14 +43,15 @@ func (h *CaptchaHandler) Generate(c *gin.Context) {
if err != nil { if err != nil {
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,15 +68,15 @@ 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) {
var req CaptchaVerifyRequest var req CaptchaVerifyRequest
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
} }
@@ -86,21 +88,21 @@ func (h *CaptchaHandler) Verify(c *gin.Context) {
zap.Error(err), zap.Error(err),
) )
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"code": 500, "code": 500,
"msg": "验证失败", "message": "验证失败",
}) })
return return
} }
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": "验证失败,请重试",
}) })
} }
} }

View File

@@ -1,12 +1,14 @@
package handler package handler
import ( import (
"carrotskin/internal/container" "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"
) )
@@ -233,9 +235,12 @@ func (h *CustomSkinHandler) GetTexture(c *gin.Context) {
} }
} }
// 增加下载计数(异步) // 增加下载计数(异步,使用独立 ctx 避免请求取消时丢失计数
go func() { go func() {
_ = h.container.TextureRepo.IncrementDownloadCount(ctx, texture.ID) // 复制 ctx 但不传播取消,配合超时防止 Redis 永久阻塞
asyncCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
_ = h.container.TextureService.IncrementDownload(asyncCtx, texture.ID)
}() }()
// 流式传输文件内容 // 流式传输文件内容

View File

@@ -1,11 +1,14 @@
package handler package handler
import ( import (
"carrotskin/internal/errors" "errors"
"carrotskin/internal/model"
"carrotskin/internal/types"
"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"
) )
@@ -19,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) {
@@ -190,31 +220,31 @@ func RespondWithError(c *gin.Context, err error) {
return return
} }
// 使用errors.Is检查预定义错误 // 使用标准库 errors.Is 检查预定义错误
if errors.Is(err, errors.ErrUserNotFound) || if errors.Is(err, apperrors.ErrUserNotFound) ||
errors.Is(err, errors.ErrProfileNotFound) || errors.Is(err, apperrors.ErrProfileNotFound) ||
errors.Is(err, errors.ErrTextureNotFound) || errors.Is(err, apperrors.ErrTextureNotFound) ||
errors.Is(err, errors.ErrNotFound) { errors.Is(err, apperrors.ErrNotFound) {
RespondNotFound(c, err.Error()) RespondNotFound(c, err.Error())
return return
} }
if errors.Is(err, errors.ErrProfileNoPermission) || if errors.Is(err, apperrors.ErrProfileNoPermission) ||
errors.Is(err, errors.ErrTextureNoPermission) || errors.Is(err, apperrors.ErrTextureNoPermission) ||
errors.Is(err, errors.ErrForbidden) { errors.Is(err, apperrors.ErrForbidden) {
RespondForbidden(c, err.Error()) RespondForbidden(c, err.Error())
return return
} }
if errors.Is(err, errors.ErrUnauthorized) || if errors.Is(err, apperrors.ErrUnauthorized) ||
errors.Is(err, errors.ErrInvalidToken) || errors.Is(err, apperrors.ErrInvalidToken) ||
errors.Is(err, errors.ErrTokenExpired) { errors.Is(err, apperrors.ErrTokenExpired) {
RespondUnauthorized(c, err.Error()) RespondUnauthorized(c, err.Error())
return return
} }
// 检查AppError类型 // 检查AppError类型
var appErr *errors.AppError var appErr *apperrors.AppError
if errors.As(err, &appErr) { if errors.As(err, &appErr) {
c.JSON(appErr.Code, model.NewErrorResponse( c.JSON(appErr.Code, model.NewErrorResponse(
appErr.Code, appErr.Code,

View File

@@ -4,7 +4,6 @@ import (
"carrotskin/internal/container" "carrotskin/internal/container"
"carrotskin/internal/middleware" "carrotskin/internal/middleware"
"carrotskin/pkg/auth" "carrotskin/pkg/auth"
"carrotskin/pkg/config"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files" swaggerFiles "github.com/swaggo/files"
@@ -19,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
} }
@@ -32,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),
} }
@@ -40,11 +41,10 @@ func NewHandlers(c *container.Container) *Handlers {
// RegisterRoutesWithDI 使用依赖注入注册所有路由 // RegisterRoutesWithDI 使用依赖注入注册所有路由
func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) { func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
// 健康检查路由 // 健康检查路由
router.GET("/health", HealthCheck) router.GET("/health", NewHealthCheck(c.DB, c.Redis))
// Swagger文档路由 // Swagger文档路由
cfg, _ := config.GetConfig() if c.Config != nil && c.Config.Server.SwaggerEnabled {
if cfg != nil && cfg.Server.SwaggerEnabled {
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
} }
@@ -79,7 +79,7 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
// Yggdrasil API路由组独立于v1路径为 /api/yggdrasil // Yggdrasil API路由组独立于v1路径为 /api/yggdrasil
registerYggdrasilRoutesWithDI(apiGroup, h.Yggdrasil) registerYggdrasilRoutesWithDI(apiGroup, h)
} }
// registerAuthRoutes 注册认证路由 // registerAuthRoutes 注册认证路由
@@ -171,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 查询单用户名 UUIDauthlib-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)
} }
} }
} }

View File

@@ -6,57 +6,57 @@ import (
"net/http" "net/http"
"time" "time"
"carrotskin/pkg/database"
"carrotskin/pkg/redis" "carrotskin/pkg/redis"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
// HealthCheck 健康检查,检查依赖服务状态 // NewHealthCheck 构造健康检查 handler依赖通过参数注入。
func HealthCheck(c *gin.Context) { func NewHealthCheck(db *gorm.DB, redisClient *redis.Client) gin.HandlerFunc {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) return func(c *gin.Context) {
defer cancel() ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
checks := make(map[string]string) checks := make(map[string]string)
status := "ok" status := "ok"
// 检查数据库 // 检查数据库
if err := checkDatabase(ctx); err != nil { if err := checkDatabase(ctx, db); err != nil {
checks["database"] = "unhealthy: " + err.Error() checks["database"] = "unhealthy: " + err.Error()
status = "degraded" status = "degraded"
} else { } else {
checks["database"] = "healthy" checks["database"] = "healthy"
}
// 检查Redis
if err := checkRedis(ctx, redisClient); err != nil {
checks["redis"] = "unhealthy: " + err.Error()
status = "degraded"
} else {
checks["redis"] = "healthy"
}
// 根据状态返回相应的HTTP状态码
httpStatus := http.StatusOK
if status == "degraded" {
httpStatus = http.StatusServiceUnavailable
}
c.JSON(httpStatus, gin.H{
"status": status,
"message": "CarrotSkin API health check",
"checks": checks,
"timestamp": time.Now().Unix(),
})
} }
// 检查Redis
if err := checkRedis(ctx); err != nil {
checks["redis"] = "unhealthy: " + err.Error()
status = "degraded"
} else {
checks["redis"] = "healthy"
}
// 根据状态返回相应的HTTP状态码
httpStatus := http.StatusOK
if status == "degraded" {
httpStatus = http.StatusServiceUnavailable
}
c.JSON(httpStatus, gin.H{
"status": status,
"message": "CarrotSkin API health check",
"checks": checks,
"timestamp": time.Now().Unix(),
})
} }
// checkDatabase 检查数据库连接 // checkDatabase 检查数据库连接
func checkDatabase(ctx context.Context) error { func checkDatabase(ctx context.Context, db *gorm.DB) error {
db, err := database.GetDB() if db == nil {
if err != nil { return errors.New("数据库未初始化")
return err
} }
sqlDB, err := db.DB() sqlDB, err := db.DB()
if err != nil { if err != nil {
return err return err
@@ -77,11 +77,7 @@ func checkDatabase(ctx context.Context) error {
} }
// checkRedis 检查Redis连接 // checkRedis 检查Redis连接
func checkRedis(ctx context.Context) error { func checkRedis(ctx context.Context, client *redis.Client) error {
client, err := redis.GetClient()
if err != nil {
return err
}
if client == nil { if client == nil {
return errors.New("Redis客户端未初始化") return errors.New("Redis客户端未初始化")
} }

View File

@@ -12,7 +12,8 @@ import (
func TestHealthCheck_Degraded(t *testing.T) { func TestHealthCheck_Degraded(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()
router.GET("/health", HealthCheck) // 传入 nil 依赖,模拟服务不可用场景
router.GET("/health", NewHealthCheck(nil, nil))
req := httptest.NewRequest(http.MethodGet, "/health", nil) req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -22,6 +23,3 @@ func TestHealthCheck_Degraded(t *testing.T) {
t.Fatalf("expected 503 when dependencies missing, got %d", w.Code) t.Fatalf("expected 503 when dependencies missing, got %d", w.Code)
} }
} }

View File

@@ -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"

View 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)
}

View 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)
}
})
}
}

View File

@@ -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,11 +124,13 @@ 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.ProfileRepo.FindByName(c.Request.Context(), request.Identifier) profile, err = h.container.ProfileService.GetByProfileName(c.Request.Context(), request.Identifier)
if err != nil { if err != nil {
h.logger.Error("用户名不存在", zap.String("identifier", request.Identifier), zap.Error(err)) h.logger.Error("用户名不存在", zap.String("identifier", request.Identifier), zap.Error(err))
c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse( c.JSON(http.StatusForbidden, errors.NewYggdrasilErrorResponse(
@@ -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 单用户名查 UUIDauthlib-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,
"", "",
)) ))

View File

@@ -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"

View File

@@ -3,6 +3,7 @@ package middleware
import ( import (
"net/http" "net/http"
"carrotskin/internal/model"
"carrotskin/pkg/auth" "carrotskin/pkg/auth"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -15,10 +16,11 @@ func CasbinMiddleware(casbinService *auth.CasbinService, resource, action string
// 从上下文获取用户角色由AuthMiddleware设置 // 从上下文获取用户角色由AuthMiddleware设置
role, exists := c.Get("user_role") role, exists := c.Get("user_role")
if !exists { if !exists {
c.JSON(http.StatusUnauthorized, gin.H{ c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
"success": false, model.CodeUnauthorized,
"message": "未授权访问", model.MsgUnauthorized,
}) nil,
))
c.Abort() c.Abort()
return return
} }
@@ -30,10 +32,11 @@ func CasbinMiddleware(casbinService *auth.CasbinService, resource, action string
// 检查权限 // 检查权限
if !casbinService.CheckPermission(roleStr, resource, action) { if !casbinService.CheckPermission(roleStr, resource, action) {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, model.NewErrorResponse(
"success": false, model.CodeForbidden,
"message": "权限不足", model.MsgForbidden,
}) nil,
))
c.Abort() c.Abort()
return return
} }
@@ -47,20 +50,22 @@ func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
role, exists := c.Get("user_role") role, exists := c.Get("user_role")
if !exists { if !exists {
c.JSON(http.StatusUnauthorized, gin.H{ c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
"success": false, model.CodeUnauthorized,
"message": "未授权访问", model.MsgUnauthorized,
}) nil,
))
c.Abort() c.Abort()
return return
} }
roleStr, ok := role.(string) roleStr, ok := role.(string)
if !ok || roleStr != "admin" { if !ok || roleStr != "admin" {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, model.NewErrorResponse(
"success": false, model.CodeForbidden,
"message": "需要管理员权限", "需要管理员权限",
}) nil,
))
c.Abort() c.Abort()
return return
} }
@@ -74,20 +79,22 @@ func RequireRole(allowedRoles ...string) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
role, exists := c.Get("user_role") role, exists := c.Get("user_role")
if !exists { if !exists {
c.JSON(http.StatusUnauthorized, gin.H{ c.JSON(http.StatusUnauthorized, model.NewErrorResponse(
"success": false, model.CodeUnauthorized,
"message": "未授权访问", model.MsgUnauthorized,
}) nil,
))
c.Abort() c.Abort()
return return
} }
roleStr, ok := role.(string) roleStr, ok := role.(string)
if !ok { if !ok {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, model.NewErrorResponse(
"success": false, model.CodeForbidden,
"message": "权限不足", model.MsgForbidden,
}) nil,
))
c.Abort() c.Abort()
return return
} }
@@ -100,10 +107,11 @@ func RequireRole(allowedRoles ...string) gin.HandlerFunc {
} }
} }
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, model.NewErrorResponse(
"success": false, model.CodeForbidden,
"message": "权限不足", model.MsgForbidden,
}) nil,
))
c.Abort() c.Abort()
} }
} }

View File

@@ -7,18 +7,14 @@ import (
) )
// CORS 跨域中间件 // CORS 跨域中间件
func CORS() gin.HandlerFunc { func CORS(cfg *config.Config) gin.HandlerFunc {
// 获取配置,如果配置未初始化则使用默认值 // 从注入的配置读取 CORS 设置
var allowedOrigins []string allowedOrigins := cfg.Security.AllowedOrigins
var isTestEnv bool if len(allowedOrigins) == 0 {
if cfg, err := config.GetConfig(); err == nil { // 默认允许所有来源
allowedOrigins = cfg.Security.AllowedOrigins
isTestEnv = cfg.IsTestEnvironment()
} else {
// 默认允许所有来源(向后兼容)
allowedOrigins = []string{"*"} allowedOrigins = []string{"*"}
isTestEnv = false
} }
isTestEnv := cfg.IsTestEnvironment()
return gin.HandlerFunc(func(c *gin.Context) { return gin.HandlerFunc(func(c *gin.Context) {
origin := c.GetHeader("Origin") origin := c.GetHeader("Origin")

View File

@@ -5,15 +5,26 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"carrotskin/pkg/config"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// testCORSConfig 返回测试用的 CORS 配置(通配符模式)
func testCORSConfig() *config.Config {
return &config.Config{
Security: config.SecurityConfig{
AllowedOrigins: []string{"*"},
},
}
}
// TestCORS_Headers 测试CORS中间件设置的响应头 // TestCORS_Headers 测试CORS中间件设置的响应头
func TestCORS_Headers(t *testing.T) { func TestCORS_Headers(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()
router.Use(CORS()) router.Use(CORS(testCORSConfig()))
router.GET("/test", func(c *gin.Context) { router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success"}) c.JSON(http.StatusOK, gin.H{"message": "success"})
}) })
@@ -55,7 +66,7 @@ func TestCORS_OPTIONS(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()
router.Use(CORS()) router.Use(CORS(testCORSConfig()))
router.GET("/test", func(c *gin.Context) { router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success"}) c.JSON(http.StatusOK, gin.H{"message": "success"})
}) })
@@ -76,7 +87,7 @@ func TestCORS_AllowMethods(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()
router.Use(CORS()) router.Use(CORS(testCORSConfig()))
router.GET("/test", func(c *gin.Context) { router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success"}) c.JSON(http.StatusOK, gin.H{"message": "success"})
}) })
@@ -103,7 +114,7 @@ func TestCORS_AllowHeaders(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()
router.Use(CORS()) router.Use(CORS(testCORSConfig()))
router.GET("/test", func(c *gin.Context) { router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success"}) c.JSON(http.StatusOK, gin.H{"message": "success"})
}) })
@@ -130,7 +141,7 @@ func TestCORS_WithSpecificOrigin(t *testing.T) {
// 注意此测试验证的是在配置了具体allowed origins时的行为 // 注意此测试验证的是在配置了具体allowed origins时的行为
// 在没有配置初始化的情况下,默认使用通配符模式 // 在没有配置初始化的情况下,默认使用通配符模式
router := gin.New() router := gin.New()
router.Use(CORS()) router.Use(CORS(testCORSConfig()))
router.GET("/test", func(c *gin.Context) { router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "success"}) c.JSON(http.StatusOK, gin.H{"message": "success"})
}) })

64
internal/model/friends.go Normal file
View 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",
}
}

View 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)
}
}

View File

@@ -7,15 +7,22 @@ import (
// Profile Minecraft 档案模型 // Profile Minecraft 档案模型
// @Description Minecraft角色档案数据模型 // @Description Minecraft角色档案数据模型
type Profile struct { type Profile struct {
UUID string `gorm:"column:uuid;type:varchar(32);primaryKey" json:"uuid"` UUID string `gorm:"column:uuid;type:varchar(32);primaryKey" json:"uuid"`
UserID int64 `gorm:"column:user_id;not null;index:idx_profiles_user_created,priority:1" json:"user_id"` UserID int64 `gorm:"column:user_id;not null;index:idx_profiles_user_created,priority:1" json:"user_id"`
Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex:idx_profiles_name" json:"name"` // Minecraft 角色名 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 私钥不返回给前端
LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;index:idx_profiles_last_used,sort:desc" json:"last_used_at,omitempty"` RSAPrivateKey string `gorm:"column:rsa_private_key;type:text;not null" json:"-"`
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"` // 玩家证书完整密钥对(持久化以避免每次请求都重新生成 RSA 4096 密钥)
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` RSAPublicKey string `gorm:"column:rsa_public_key;type:text" json:"-"`
PublicKeySignature string `gorm:"column:public_key_signature;type:text" json:"-"`
PublicKeySignatureV2 string `gorm:"column:public_key_signature_v2;type:text" json:"-"`
KeyExpiresAt *time.Time `gorm:"column:key_expires_at;type:timestamp" json:"-"`
KeyRefreshAt *time.Time `gorm:"column:key_refresh_at;type:timestamp" json:"-"`
LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;index:idx_profiles_last_used,sort:desc" json:"last_used_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_profiles_user_created,priority:2,sort:desc" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
// 关联 // 关联
User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"user,omitempty"` User *User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"user,omitempty"`
@@ -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档案 UUID32 位无连字符十六进制)
// - name用户名保持存储的大小写
type NameAndId struct {
ID string `json:"id"`
Name string `json:"name"`
}

View File

@@ -1,9 +1,10 @@
package repository package repository
import ( import (
"carrotskin/internal/model"
"context" "context"
"carrotskin/internal/model"
"gorm.io/gorm" "gorm.io/gorm"
) )

View 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
}

View File

@@ -1,8 +1,9 @@
package repository package repository
import ( import (
"carrotskin/internal/model"
"context" "context"
"carrotskin/internal/model"
) )
// UserRepository 用户仓储接口 // UserRepository 用户仓储接口
@@ -11,7 +12,8 @@ type UserRepository interface {
FindByID(ctx context.Context, id int64) (*model.User, error) FindByID(ctx context.Context, id int64) (*model.User, error)
FindByUsername(ctx context.Context, username string) (*model.User, error) FindByUsername(ctx context.Context, username string) (*model.User, error)
FindByEmail(ctx context.Context, email string) (*model.User, error) FindByEmail(ctx context.Context, email string) (*model.User, error)
FindByIDs(ctx context.Context, ids []int64) ([]*model.User, error) // 批量查询 FindByIDs(ctx context.Context, ids []int64) ([]*model.User, error) // 批量查询
List(ctx context.Context, page, pageSize int) ([]*model.User, int64, error) // 分页列表(管理员)
Update(ctx context.Context, user *model.User) error Update(ctx context.Context, user *model.User) error
UpdateFields(ctx context.Context, id int64, fields map[string]interface{}) error UpdateFields(ctx context.Context, id int64, fields map[string]interface{}) error
BatchUpdate(ctx context.Context, ids []int64, fields map[string]interface{}) (int64, error) // 批量更新 BatchUpdate(ctx context.Context, ids []int64, fields map[string]interface{}) (int64, error) // 批量更新
@@ -64,13 +66,16 @@ type TextureRepository interface {
RemoveFavorite(ctx context.Context, userID, textureID int64) error RemoveFavorite(ctx context.Context, userID, textureID int64) error
GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error)
CountByUploaderID(ctx context.Context, uploaderID int64) (int64, error) CountByUploaderID(ctx context.Context, uploaderID int64) (int64, error)
ListForAdmin(ctx context.Context, page, pageSize int) ([]*model.Texture, int64, error) // 管理员列表(含 Uploader 预加载)
FindByIDForAdmin(ctx context.Context, id int64) (*model.Texture, error) // 管理员查询(无权限过滤)
} }
// YggdrasilRepository Yggdrasil仓储接口 // YggdrasilRepository Yggdrasil仓储接口
type YggdrasilRepository interface { type YggdrasilRepository interface {
GetPasswordByID(ctx context.Context, id int64) (string, error) GetPasswordByID(ctx context.Context, id int64) (string, error)
ResetPassword(ctx context.Context, id int64, password string) error ResetPassword(ctx context.Context, id int64, password string) error
// Create 创建Yggdrasil密码记录用于首次设置密码
Create(ctx context.Context, yggdrasil *model.Yggdrasil) error
} }
// ClientRepository Client仓储接口 // ClientRepository Client仓储接口

View File

@@ -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)

View File

@@ -1,9 +1,10 @@
package repository package repository
import ( import (
"carrotskin/internal/model"
"context" "context"
"carrotskin/internal/model"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -210,3 +211,27 @@ func (r *textureRepository) CountByUploaderID(ctx context.Context, uploaderID in
Count(&count).Error Count(&count).Error
return count, err return count, err
} }
// ListForAdmin 管理员分页查询材质列表(含 Uploader 预加载,无权限过滤)
func (r *textureRepository) ListForAdmin(ctx context.Context, page, pageSize int) ([]*model.Texture, int64, error) {
var textures []*model.Texture
var total int64
db := r.db.WithContext(ctx).Model(&model.Texture{})
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if err := db.Preload("Uploader").Order("id DESC").Offset(offset).Limit(pageSize).Find(&textures).Error; err != nil {
return nil, 0, err
}
return textures, total, nil
}
// FindByIDForAdmin 管理员查询材质(无权限过滤,用于管理员删除前检查存在性)
func (r *textureRepository) FindByIDForAdmin(ctx context.Context, id int64) (*model.Texture, error) {
var texture model.Texture
err := r.db.WithContext(ctx).Where("id = ?", id).First(&texture).Error
return handleNotFoundResult(&texture, err)
}

View File

@@ -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"
) )
@@ -50,6 +51,23 @@ func (r *userRepository) FindByIDs(ctx context.Context, ids []int64) ([]*model.U
return users, err return users, err
} }
// List 分页查询用户列表(管理员,不过滤状态)
func (r *userRepository) List(ctx context.Context, page, pageSize int) ([]*model.User, int64, error) {
var users []*model.User
var total int64
db := r.db.WithContext(ctx).Model(&model.User{})
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if err := db.Order("id DESC").Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
func (r *userRepository) Update(ctx context.Context, user *model.User) error { func (r *userRepository) Update(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Save(user).Error return r.db.WithContext(ctx).Save(user).Error
} }

View File

@@ -1,9 +1,10 @@
package repository package repository
import ( import (
"carrotskin/internal/model"
"context" "context"
"carrotskin/internal/model"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -30,8 +31,7 @@ func (r *yggdrasilRepository) ResetPassword(ctx context.Context, id int64, passw
return r.db.WithContext(ctx).Model(&model.Yggdrasil{}).Where("id = ?", id).Update("password", password).Error return r.db.WithContext(ctx).Model(&model.Yggdrasil{}).Where("id = ?", id).Update("password", password).Error
} }
// Create 创建Yggdrasil密码记录
func (r *yggdrasilRepository) Create(ctx context.Context, yggdrasil *model.Yggdrasil) error {
return r.db.WithContext(ctx).Create(yggdrasil).Error
}

View File

@@ -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"
@@ -72,13 +73,15 @@ type RedisData struct {
// captchaService CaptchaService的实现 // captchaService CaptchaService的实现
type captchaService struct { type captchaService struct {
cfg *config.Config
redis *redis.Client redis *redis.Client
logger *zap.Logger logger *zap.Logger
} }
// NewCaptchaService 创建CaptchaService实例 // NewCaptchaService 创建CaptchaService实例
func NewCaptchaService(redisClient *redis.Client, logger *zap.Logger) CaptchaService { func NewCaptchaService(cfg *config.Config, redisClient *redis.Client, logger *zap.Logger) CaptchaService {
return &captchaService{ return &captchaService{
cfg: cfg,
redis: redisClient, redis: redisClient,
logger: logger, logger: logger,
} }
@@ -157,8 +160,7 @@ func (s *captchaService) Generate(ctx context.Context) (masterImg, tileImg, capt
// Verify 验证验证码 // Verify 验证验证码
func (s *captchaService) Verify(ctx context.Context, dx int, captchaID string) (bool, error) { func (s *captchaService) Verify(ctx context.Context, dx int, captchaID string) (bool, error) {
// 测试环境下直接通过验证 // 测试环境下直接通过验证
cfg, err := config.GetConfig() if s.cfg.IsTestEnvironment() {
if err == nil && cfg.IsTestEnvironment() {
return true, nil return true, nil
} }
@@ -197,8 +199,7 @@ func (s *captchaService) Verify(ctx context.Context, dx int, captchaID string) (
// CheckVerified 检查验证码是否已验证仅检查captcha_id // CheckVerified 检查验证码是否已验证仅检查captcha_id
func (s *captchaService) CheckVerified(ctx context.Context, captchaID string) (bool, error) { func (s *captchaService) CheckVerified(ctx context.Context, captchaID string) (bool, error) {
// 测试环境下直接通过验证 // 测试环境下直接通过验证
cfg, err := config.GetConfig() if s.cfg.IsTestEnvironment() {
if err == nil && cfg.IsTestEnvironment() {
return true, nil return true, nil
} }
@@ -216,8 +217,7 @@ func (s *captchaService) CheckVerified(ctx context.Context, captchaID string) (b
// ConsumeVerified 消耗已验证的验证码(注册成功后调用) // ConsumeVerified 消耗已验证的验证码(注册成功后调用)
func (s *captchaService) ConsumeVerified(ctx context.Context, captchaID string) error { func (s *captchaService) ConsumeVerified(ctx context.Context, captchaID string) error {
// 测试环境下直接返回成功 // 测试环境下直接返回成功
cfg, err := config.GetConfig() if s.cfg.IsTestEnvironment() {
if err == nil && cfg.IsTestEnvironment() {
return nil return nil
} }

View File

@@ -1,18 +1,11 @@
package service package service
import ( // 通用辅助函数。
"errors" //
"fmt" // 注意:错误定义已统一到 internal/errors 包。
) // 原先在此文件重复定义的 ErrProfileNotFound/ErrProfileNoPermission/
// ErrTextureNotFound/ErrTextureNoPermission/ErrUserNotFound 已删除,
// 通用错误 // 请使用 internal/errors别名 apperrors中的对应定义。
var (
ErrProfileNotFound = errors.New("档案不存在")
ErrProfileNoPermission = errors.New("无权操作此档案")
ErrTextureNotFound = errors.New("材质不存在")
ErrTextureNoPermission = errors.New("无权操作此材质")
ErrUserNotFound = errors.New("用户不存在")
)
// NormalizePagination 规范化分页参数 // NormalizePagination 规范化分页参数
func NormalizePagination(page, pageSize int) (int, int) { func NormalizePagination(page, pageSize int) (int, int) {
@@ -27,11 +20,3 @@ func NormalizePagination(page, pageSize int) (int, int) {
} }
return page, pageSize return page, pageSize
} }
// WrapError 包装错误,添加上下文信息
func WrapError(err error, message string) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", message, err)
}

View File

@@ -1,7 +1,6 @@
package service package service
import ( import (
"errors"
"testing" "testing"
) )
@@ -30,21 +29,3 @@ func TestNormalizePagination_Basic(t *testing.T) {
}) })
} }
} }
// TestWrapError 覆盖 WrapError 的 nil 与非 nil 分支
func TestWrapError(t *testing.T) {
if err := WrapError(nil, "msg"); err != nil {
t.Fatalf("WrapError(nil, ...) 应返回 nil, got=%v", err)
}
orig := errors.New("orig")
wrapped := WrapError(orig, "context")
if wrapped == nil {
t.Fatalf("WrapError 应返回非 nil 错误")
}
if wrapped.Error() == orig.Error() {
t.Fatalf("WrapError 应添加上下文信息, got=%v", wrapped)
}
}

View File

@@ -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"
) )
@@ -37,6 +38,11 @@ type UserService interface {
// 配置获取 // 配置获取
GetMaxProfilesPerUser() int GetMaxProfilesPerUser() int
GetMaxTexturesPerUser() int GetMaxTexturesPerUser() int
// 管理员操作
ListUsers(ctx context.Context, page, pageSize int) ([]*model.User, int64, error)
SetRole(ctx context.Context, userID int64, role string) error
SetStatus(ctx context.Context, userID int64, status int16) error
} }
// ProfileService 档案服务接口 // ProfileService 档案服务接口
@@ -54,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 材质服务接口
@@ -73,6 +87,11 @@ type TextureService interface {
// 限制检查 // 限制检查
CheckUploadLimit(ctx context.Context, uploaderID int64, maxTextures int) error CheckUploadLimit(ctx context.Context, uploaderID int64, maxTextures int) error
// 管理员操作
ListForAdmin(ctx context.Context, page, pageSize int) ([]*model.Texture, int64, error)
AdminDelete(ctx context.Context, textureID int64) error
IncrementDownload(ctx context.Context, textureID int64) error
} }
// TokenService 令牌服务接口 // TokenService 令牌服务接口
@@ -123,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 {
// 登录安全 // 登录安全
@@ -151,6 +279,7 @@ type Services struct {
Captcha CaptchaService Captcha CaptchaService
Yggdrasil YggdrasilService Yggdrasil YggdrasilService
Security SecurityService Security SecurityService
Friends FriendsService
} }
// ServiceDeps 服务依赖 // ServiceDeps 服务依赖

View File

@@ -5,6 +5,7 @@ import (
"carrotskin/pkg/database" "carrotskin/pkg/database"
"context" "context"
"errors" "errors"
"strings"
"time" "time"
) )
@@ -130,6 +131,24 @@ func (m *MockUserRepository) FindByIDs(ctx context.Context, ids []int64) ([]*mod
return result, nil return result, nil
} }
// List 分页查询用户列表管理员mock 实现)
func (m *MockUserRepository) List(ctx context.Context, page, pageSize int) ([]*model.User, int64, error) {
var all []*model.User
for _, u := range m.users {
all = append(all, u)
}
total := int64(len(all))
offset := (page - 1) * pageSize
if offset >= len(all) {
return []*model.User{}, total, nil
}
end := offset + pageSize
if end > len(all) {
end = len(all)
}
return all[offset:end], total, nil
}
// MockProfileRepository 模拟ProfileRepository // MockProfileRepository 模拟ProfileRepository
type MockProfileRepository struct { type MockProfileRepository struct {
profiles map[string]*model.Profile profiles map[string]*model.Profile
@@ -172,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
} }
} }
@@ -225,8 +246,10 @@ func (m *MockProfileRepository) UpdateLastUsedAt(ctx context.Context, uuid strin
func (m *MockProfileRepository) GetByNames(ctx context.Context, names []string) ([]*model.Profile, error) { 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)
} }
} }
@@ -474,6 +497,32 @@ func (m *MockTextureRepository) BatchDelete(ctx context.Context, ids []int64) (i
return deleted, nil return deleted, nil
} }
// ListForAdmin 管理员列表mock 实现)
func (m *MockTextureRepository) ListForAdmin(ctx context.Context, page, pageSize int) ([]*model.Texture, int64, error) {
var all []*model.Texture
for _, t := range m.textures {
all = append(all, t)
}
total := int64(len(all))
offset := (page - 1) * pageSize
if offset >= len(all) {
return []*model.Texture{}, total, nil
}
end := offset + pageSize
if end > len(all) {
end = len(all)
}
return all[offset:end], total, nil
}
// FindByIDForAdmin 管理员查询mock 实现,无权限过滤)
func (m *MockTextureRepository) FindByIDForAdmin(ctx context.Context, id int64) (*model.Texture, error) {
if t, ok := m.textures[id]; ok {
return t, nil
}
return nil, nil
}
// ============================================================================ // ============================================================================
// Service Mocks // Service Mocks

View File

@@ -1,10 +1,6 @@
package service package service
import ( import (
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/database"
"carrotskin/pkg/utils"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
@@ -12,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"
@@ -100,7 +103,7 @@ func (s *profileService) GetByUUID(ctx context.Context, uuid string) (*model.Pro
profile2, err := s.profileRepo.FindByUUID(ctx, uuid) profile2, err := s.profileRepo.FindByUUID(ctx, uuid)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrProfileNotFound return nil, apperrors.ErrProfileNotFound
} }
return nil, fmt.Errorf("查询档案失败: %w", err) return nil, fmt.Errorf("查询档案失败: %w", err)
} }
@@ -140,13 +143,13 @@ func (s *profileService) Update(ctx context.Context, uuid string, userID int64,
profile, err := s.profileRepo.FindByUUID(ctx, uuid) profile, err := s.profileRepo.FindByUUID(ctx, uuid)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrProfileNotFound return nil, apperrors.ErrProfileNotFound
} }
return nil, fmt.Errorf("查询档案失败: %w", err) return nil, fmt.Errorf("查询档案失败: %w", err)
} }
if profile.UserID != userID { if profile.UserID != userID {
return nil, ErrProfileNoPermission return nil, apperrors.ErrProfileNoPermission
} }
// 检查角色名是否重复 // 检查角色名是否重复
@@ -187,13 +190,13 @@ func (s *profileService) Delete(ctx context.Context, uuid string, userID int64)
profile, err := s.profileRepo.FindByUUID(ctx, uuid) profile, err := s.profileRepo.FindByUUID(ctx, uuid)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrProfileNotFound return apperrors.ErrProfileNotFound
} }
return fmt.Errorf("查询档案失败: %w", err) return fmt.Errorf("查询档案失败: %w", err)
} }
if profile.UserID != userID { if profile.UserID != userID {
return ErrProfileNoPermission return apperrors.ErrProfileNoPermission
} }
if err := s.profileRepo.Delete(ctx, uuid); err != nil { if err := s.profileRepo.Delete(ctx, uuid); err != nil {
@@ -238,6 +241,80 @@ func (s *profileService) GetByProfileName(ctx context.Context, name string) (*mo
return profile, nil 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)

View File

@@ -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)
}
} }

View File

@@ -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实例
@@ -53,7 +59,7 @@ func NewSignatureService(
} }
// NewKeyPair 生成新的RSA密钥对 // NewKeyPair 生成新的RSA密钥对
func (s *SignatureService) NewKeyPair() (*model.KeyPair, error) { func (s *SignatureService) NewKeyPair(ctx context.Context) (*model.KeyPair, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, KeySize) privateKey, err := rsa.GenerateKey(rand.Reader, KeySize)
if err != nil { if err != nil {
return nil, fmt.Errorf("生成RSA密钥对失败: %w", err) return nil, fmt.Errorf("生成RSA密钥对失败: %w", err)
@@ -85,81 +91,82 @@ func (s *SignatureService) NewKeyPair() (*model.KeyPair, error) {
refresh := now.AddDate(0, 0, RefreshDays) refresh := now.AddDate(0, 0, RefreshDays)
// 获取Yggdrasil根密钥并签名公钥 // 获取Yggdrasil根密钥并签名公钥
yggPublicKey, yggPrivateKey, err := s.GetOrCreateYggdrasilKeyPair() yggPublicKey, yggPrivateKey, err := s.GetOrCreateYggdrasilKeyPair(ctx)
if err != nil { if err != nil {
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() (string, *rsa.PrivateKey, error) { func signWithRootKey(privateKey *rsa.PrivateKey, message []byte) ([]byte, error) {
ctx := context.Background() hashed := sha1.Sum(message)
return rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
}
// 尝试从Redis获取密钥 // GetOrCreateYggdrasilKeyPair 获取或创建Yggdrasil根密钥
publicKeyPEM, err := s.redis.Get(ctx, PublicKeyRedisKey) // 优先使用进程内缓存的已解析私钥缓存未命中时从Redis加载并解析再缓存。
if err == nil && publicKeyPEM != "" { func (s *SignatureService) GetOrCreateYggdrasilKeyPair(ctx context.Context) (string, *rsa.PrivateKey, error) {
privateKeyPEM, err := s.redis.Get(ctx, PrivateKeyRedisKey) // 1. 读缓存(快路径)
if err == nil && privateKeyPEM != "" { s.rootKeyMu.RLock()
// 检查密钥是否过期 if s.rootPrivateKey != nil && s.isRootKeyValid() {
expStr, err := s.redis.Get(ctx, KeyExpirationRedisKey) pub := s.rootPublicKeyPEM
if err == nil && expStr != "" { priv := s.rootPrivateKey
expTime, err := time.Parse(time.RFC3339, expStr) s.rootKeyMu.RUnlock()
if err == nil && time.Now().Before(expTime) { return pub, priv, nil
// 密钥有效,解析私钥 }
block, _ := pem.Decode([]byte(privateKeyPEM)) s.rootKeyMu.RUnlock()
if block != nil {
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) // 2. 缓存未命中,加锁准备从 Redis 加载/生成
if err == nil { s.rootKeyMu.Lock()
s.logger.Info("从Redis加载Yggdrasil根密钥") defer s.rootKeyMu.Unlock()
return publicKeyPEM, privateKey, nil
} // 双重检查:可能在等待锁期间已被其他 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 {
@@ -178,7 +185,7 @@ func (s *SignatureService) GetOrCreateYggdrasilKeyPair() (string, *rsa.PrivateKe
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,
})) }))
@@ -197,19 +204,66 @@ func (s *SignatureService) GetOrCreateYggdrasilKeyPair() (string, *rsa.PrivateKe
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() (string, error) { func (s *SignatureService) GetPublicKeyFromRedis(ctx context.Context) (string, error) {
ctx := context.Background()
publicKey, err := s.redis.Get(ctx, PublicKeyRedisKey) publicKey, err := s.redis.Get(ctx, PublicKeyRedisKey)
if err != nil { if err != nil {
return "", fmt.Errorf("从Redis获取公钥失败: %w", err) return "", fmt.Errorf("从Redis获取公钥失败: %w", err)
} }
if publicKey == "" { if publicKey == "" {
// 如果Redis中没有创建新的密钥对 // 如果Redis中没有创建新的密钥对
publicKey, _, err = s.GetOrCreateYggdrasilKeyPair() publicKey, _, err = s.GetOrCreateYggdrasilKeyPair(ctx)
if err != nil { if err != nil {
return "", fmt.Errorf("创建新密钥对失败: %w", err) return "", fmt.Errorf("创建新密钥对失败: %w", err)
} }
@@ -218,82 +272,16 @@ func (s *SignatureService) GetPublicKeyFromRedis() (string, error) {
} }
// SignStringWithSHA1withRSA 使用SHA1withRSA签名字符串 // SignStringWithSHA1withRSA 使用SHA1withRSA签名字符串
func (s *SignatureService) SignStringWithSHA1withRSA(data string) (string, error) { // 使用缓存的已解析根私钥;若缓存为空则触发加载/生成。
ctx := context.Background() func (s *SignatureService) SignStringWithSHA1withRSA(ctx context.Context, data string) (string, error) {
_, privateKey, err := s.GetOrCreateYggdrasilKeyPair(ctx)
// 从Redis获取私钥
privateKeyPEM, err := s.redis.Get(ctx, PrivateKeyRedisKey)
if err != nil || privateKeyPEM == "" {
// 如果没有私钥,创建新的密钥对
_, privateKey, err := s.GetOrCreateYggdrasilKeyPair()
if err != nil {
return "", fmt.Errorf("获取私钥失败: %w", err)
}
// 使用新生成的私钥签名
hashed := sha1.Sum([]byte(data))
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
if err != nil {
return "", fmt.Errorf("签名失败: %w", err)
}
return base64.StdEncoding.EncodeToString(signature), nil
}
// 解析PEM格式的私钥
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return "", fmt.Errorf("解析PEM私钥失败")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil { if err != nil {
return "", fmt.Errorf("解析RSA私钥失败: %w", err) return "", fmt.Errorf("获取签名私钥失败: %w", err)
} }
// 签名 signature, err := signWithRootKey(privateKey, []byte(data))
hashed := sha1.Sum([]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
}
// 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 return base64.StdEncoding.EncodeToString(signature), nil
} }

View 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("缓存命中后仍写入 Rediskeys 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)
}
}
}

View File

@@ -2,10 +2,6 @@ package service
import ( import (
"bytes" "bytes"
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/database"
"carrotskin/pkg/storage"
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
@@ -13,8 +9,16 @@ import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings" "strings"
"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"
) )
// textureService TextureService的实现 // textureService TextureService的实现
@@ -26,6 +30,7 @@ type textureService struct {
cacheKeys *database.CacheKeyBuilder cacheKeys *database.CacheKeyBuilder
cacheInv *database.CacheInvalidator cacheInv *database.CacheInvalidator
logger *zap.Logger logger *zap.Logger
db *gorm.DB
} }
// NewTextureService 创建TextureService实例 // NewTextureService 创建TextureService实例
@@ -35,6 +40,7 @@ func NewTextureService(
storageClient *storage.StorageClient, storageClient *storage.StorageClient,
cacheManager *database.CacheManager, cacheManager *database.CacheManager,
logger *zap.Logger, logger *zap.Logger,
db *gorm.DB,
) TextureService { ) TextureService {
return &textureService{ return &textureService{
textureRepo: textureRepo, textureRepo: textureRepo,
@@ -44,6 +50,7 @@ func NewTextureService(
cacheKeys: database.NewCacheKeyBuilder(""), cacheKeys: database.NewCacheKeyBuilder(""),
cacheInv: database.NewCacheInvalidator(cacheManager), cacheInv: database.NewCacheInvalidator(cacheManager),
logger: logger, logger: logger,
db: db,
} }
} }
@@ -62,7 +69,7 @@ func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture,
return nil, err return nil, err
} }
if texture2 == nil { if texture2 == nil {
return nil, ErrTextureNotFound return nil, apperrors.ErrTextureNotFound
} }
if texture2.Status == -1 { if texture2.Status == -1 {
return nil, errors.New("材质已删除") return nil, errors.New("材质已删除")
@@ -80,7 +87,7 @@ func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture,
return nil, err return nil, err
} }
if texture2 == nil { if texture2 == nil {
return nil, ErrTextureNotFound return nil, apperrors.ErrTextureNotFound
} }
if texture2.Status == -1 { if texture2.Status == -1 {
return nil, errors.New("材质已删除") return nil, errors.New("材质已删除")
@@ -109,7 +116,7 @@ func (s *textureService) GetByHash(ctx context.Context, hash string) (*model.Tex
return nil, err return nil, err
} }
if texture2 == nil { if texture2 == nil {
return nil, ErrTextureNotFound return nil, apperrors.ErrTextureNotFound
} }
if texture2.Status == -1 { if texture2.Status == -1 {
return nil, errors.New("材质已删除") return nil, errors.New("材质已删除")
@@ -162,10 +169,10 @@ func (s *textureService) Update(ctx context.Context, textureID, uploaderID int64
return nil, err return nil, err
} }
if texture == nil { if texture == nil {
return nil, ErrTextureNotFound return nil, apperrors.ErrTextureNotFound
} }
if texture.UploaderID != uploaderID { if texture.UploaderID != uploaderID {
return nil, ErrTextureNoPermission return nil, apperrors.ErrTextureNoPermission
} }
// 更新字段 // 更新字段
@@ -200,10 +207,10 @@ func (s *textureService) Delete(ctx context.Context, textureID, uploaderID int64
return err return err
} }
if texture == nil { if texture == nil {
return ErrTextureNotFound return apperrors.ErrTextureNotFound
} }
if texture.UploaderID != uploaderID { if texture.UploaderID != uploaderID {
return ErrTextureNoPermission return apperrors.ErrTextureNoPermission
} }
err = s.textureRepo.Delete(ctx, textureID) err = s.textureRepo.Delete(ctx, textureID)
@@ -225,33 +232,51 @@ func (s *textureService) ToggleFavorite(ctx context.Context, userID, textureID i
return false, err return false, err
} }
if texture == nil { if texture == nil {
return false, ErrTextureNotFound return false, apperrors.ErrTextureNotFound
} }
isFavorited, err := s.textureRepo.IsFavorited(ctx, userID, textureID) // 在事务中执行"切换收藏 + 增减计数"两步,保证数据一致性
// 注意s.db 在部分单元测试中可能为 nil此时跳过事务直接执行
// (生产环境 db 永远非空mock repo 本就不是真事务,降级不影响一致性)
var favorited bool
toggle := func() error {
isFav, err := s.textureRepo.IsFavorited(ctx, userID, textureID)
if err != nil {
return err
}
if isFav {
// 已收藏 -> 取消收藏
if err := s.textureRepo.RemoveFavorite(ctx, userID, textureID); err != nil {
return err
}
if err := s.textureRepo.DecrementFavoriteCount(ctx, textureID); err != nil {
return err
}
favorited = false
return nil
}
// 未收藏 -> 添加收藏
if err := s.textureRepo.AddFavorite(ctx, userID, textureID); err != nil {
return err
}
if err := s.textureRepo.IncrementFavoriteCount(ctx, textureID); err != nil {
return err
}
favorited = true
return nil
}
if s.db != nil {
err = database.TransactionWithTimeout(ctx, s.db, 5*time.Second, func(tx *gorm.DB) error {
return toggle()
})
} else {
err = toggle()
}
if err != nil { if err != nil {
return false, err return false, err
} }
return favorited, nil
if isFavorited {
// 已收藏 -> 取消收藏
if err := s.textureRepo.RemoveFavorite(ctx, userID, textureID); err != nil {
return false, err
}
if err := s.textureRepo.DecrementFavoriteCount(ctx, textureID); err != nil {
return false, err
}
return false, nil
}
// 未收藏 -> 添加收藏
if err := s.textureRepo.AddFavorite(ctx, userID, textureID); err != nil {
return false, err
}
if err := s.textureRepo.IncrementFavoriteCount(ctx, textureID); err != nil {
return false, err
}
return true, nil
} }
func (s *textureService) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) { func (s *textureService) GetUserFavorites(ctx context.Context, userID int64, page, pageSize int) ([]*model.Texture, int64, error) {
@@ -277,7 +302,7 @@ func (s *textureService) UploadTexture(ctx context.Context, uploaderID int64, na
// 验证用户存在 // 验证用户存在
user, err := s.userRepo.FindByID(ctx, uploaderID) user, err := s.userRepo.FindByID(ctx, uploaderID)
if err != nil || user == nil { if err != nil || user == nil {
return nil, ErrUserNotFound return nil, apperrors.ErrUserNotFound
} }
// 验证文件大小和扩展名 // 验证文件大小和扩展名
@@ -403,3 +428,28 @@ func parseTextureTypeInternal(textureType string) (model.TextureType, error) {
return "", errors.New("无效的材质类型") return "", errors.New("无效的材质类型")
} }
} }
// ==================== 管理员操作 ====================
// ListForAdmin 分页查询材质列表(管理员,含 Uploader 预加载)
func (s *textureService) ListForAdmin(ctx context.Context, page, pageSize int) ([]*model.Texture, int64, error) {
page, pageSize = NormalizePagination(page, pageSize)
return s.textureRepo.ListForAdmin(ctx, page, pageSize)
}
// AdminDelete 管理员删除材质(无权限校验)
func (s *textureService) AdminDelete(ctx context.Context, textureID int64) error {
texture, err := s.textureRepo.FindByIDForAdmin(ctx, textureID)
if err != nil {
return err
}
if texture == nil {
return apperrors.ErrTextureNotFound
}
return s.textureRepo.Delete(ctx, textureID)
}
// IncrementDownload 增加下载计数CustomSkinAPI 等场景使用)
func (s *textureService) IncrementDownload(ctx context.Context, textureID int64) error {
return s.textureRepo.IncrementDownloadCount(ctx, textureID)
}

View File

@@ -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 {
@@ -494,84 +502,87 @@ func TestTextureServiceImpl_Create(t *testing.T) {
_ = userRepo.Create(context.Background(), testUser) _ = userRepo.Create(context.Background(), testUser)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
// 构造一份符合 service 大小校验(>=512B的 PNG 文件数据
pngFileData := make([]byte, 1024)
for i := range pngFileData {
pngFileData[i] = byte(i % 256)
}
// 计算其真实 SHA-256用于 mock 预置与命中分支测试
existingHash := sha256Hex(pngFileData)
_ = textureRepo.Create(context.Background(), &model.Texture{
ID: 100,
UploaderID: 1,
Name: "ExistingTexture",
Hash: existingHash,
URL: "https://example.com/existing.png",
})
tests := []struct { 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,17 +596,17 @@ 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 { }
t.Errorf("不期望返回错误: %v", err) if err != nil {
return t.Errorf("不期望返回错误: %v", err)
} return
if texture == nil { }
t.Error("返回的Texture不应为nil") if texture == nil {
} t.Fatal("返回的Texture不应为nil")
if texture.Name != tt.textureName { }
t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName) if texture.Name != tt.textureName {
} t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName)
} }
}) })
} }
@@ -617,7 +628,7 @@ func TestTextureServiceImpl_GetByID(t *testing.T) {
_ = textureRepo.Create(context.Background(), testTexture) _ = textureRepo.Create(context.Background(), testTexture)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
tests := []struct { tests := []struct {
name string name string
@@ -675,7 +686,7 @@ func TestTextureServiceImpl_GetByUserID_And_Search(t *testing.T) {
} }
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
ctx := context.Background() ctx := context.Background()
@@ -714,7 +725,7 @@ func TestTextureServiceImpl_Update_And_Delete(t *testing.T) {
_ = textureRepo.Create(context.Background(), texture) _ = textureRepo.Create(context.Background(), texture)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
ctx := context.Background() ctx := context.Background()
@@ -764,7 +775,7 @@ func TestTextureServiceImpl_FavoritesAndLimit(t *testing.T) {
} }
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
ctx := context.Background() ctx := context.Background()
@@ -807,7 +818,7 @@ func TestTextureServiceImpl_ToggleFavorite(t *testing.T) {
_ = textureRepo.Create(context.Background(), testTexture) _ = textureRepo.Create(context.Background(), testTexture)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger, nil)
ctx := context.Background() ctx := context.Background()

View File

@@ -1,16 +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"
"github.com/jackc/pgx/v5" "carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/auth"
"carrotskin/pkg/utils"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -173,8 +173,9 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
} }
if err := s.tokenStore.Store(ctx, accessToken, metadata, ttl); err != nil { if err := s.tokenStore.Store(ctx, accessToken, metadata, ttl); err != nil {
s.logger.Warn("存储Token到Redis失败", zap.Error(err)) // Redis 存储失败必须返回错误:否则 Validate 拿不到元数据Token 立即失效
// 不返回错误因为JWT本身已经生成成功 s.logger.Error("存储Token到Redis失败", zap.Error(err))
return nil, nil, "", "", fmt.Errorf("存储Token失败: %w", err)
} }
return selectedProfileID, availableProfiles, accessToken, clientToken, nil return selectedProfileID, availableProfiles, accessToken, clientToken, nil
@@ -473,7 +474,7 @@ func (s *tokenServiceRedis) validateProfileByUserID(ctx context.Context, userID
profile, err := s.profileRepo.FindByUUID(ctx, UUID) profile, err := s.profileRepo.FindByUUID(ctx, UUID)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if repository.IsNotFound(err) {
return false, errors.New("配置文件不存在") return false, errors.New("配置文件不存在")
} }
return false, fmt.Errorf("验证配置文件失败: %w", err) return false, fmt.Errorf("验证配置文件失败: %w", err)

View File

@@ -26,6 +26,7 @@ import (
// userService UserService的实现 // userService UserService的实现
type userService struct { type userService struct {
cfg *config.Config
userRepo repository.UserRepository userRepo repository.UserRepository
jwtService *auth.JWTService jwtService *auth.JWTService
redis *redis.Client redis *redis.Client
@@ -38,6 +39,7 @@ type userService struct {
// NewUserService 创建UserService实例 // NewUserService 创建UserService实例
func NewUserService( func NewUserService(
cfg *config.Config,
userRepo repository.UserRepository, userRepo repository.UserRepository,
jwtService *auth.JWTService, jwtService *auth.JWTService,
redisClient *redis.Client, redisClient *redis.Client,
@@ -48,6 +50,7 @@ func NewUserService(
// CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀 // CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀
// 这样缓存键的格式为: CacheManager前缀 + CacheKeyBuilder生成的键 // 这样缓存键的格式为: CacheManager前缀 + CacheKeyBuilder生成的键
return &userService{ return &userService{
cfg: cfg,
userRepo: userRepo, userRepo: userRepo,
jwtService: jwtService, jwtService: jwtService,
redis: redisClient, redis: redisClient,
@@ -81,7 +84,7 @@ func (s *userService) Register(ctx context.Context, username, password, email, a
// 加密密码 // 加密密码
hashedPassword, err := auth.HashPassword(password) hashedPassword, err := auth.HashPassword(password)
if err != nil { if err != nil {
return nil, "", errors.New("密码加密失败") return nil, "", fmt.Errorf("密码加密失败: %w", err)
} }
// 确定头像URL // 确定头像URL
@@ -112,7 +115,7 @@ func (s *userService) Register(ctx context.Context, username, password, email, a
// 生成JWT Token // 生成JWT Token
token, err := s.jwtService.GenerateToken(user.ID, user.Username, user.Role) token, err := s.jwtService.GenerateToken(user.ID, user.Username, user.Role)
if err != nil { if err != nil {
return nil, "", errors.New("生成Token失败") return nil, "", fmt.Errorf("生成Token失败: %w", err)
} }
return user, token, nil return user, token, nil
@@ -167,15 +170,20 @@ func (s *userService) Login(ctx context.Context, usernameOrEmail, password, ipAd
// 生成JWT Token // 生成JWT Token
token, err := s.jwtService.GenerateToken(user.ID, user.Username, user.Role) token, err := s.jwtService.GenerateToken(user.ID, user.Username, user.Role)
if err != nil { if err != nil {
return nil, "", errors.New("生成Token失败") return nil, "", fmt.Errorf("生成Token失败: %w", err)
} }
// 更新最后登录时间 // 更新最后登录时间(非关键路径,错误降级为 Warn
now := time.Now() now := time.Now()
user.LastLoginAt = &now user.LastLoginAt = &now
_ = s.userRepo.UpdateFields(ctx, user.ID, map[string]interface{}{ if err := s.userRepo.UpdateFields(ctx, user.ID, map[string]interface{}{
"last_login_at": now, "last_login_at": now,
}) }); err != nil {
s.logger.Warn("更新最后登录时间失败",
zap.Int64("user_id", user.ID),
zap.Error(err),
)
}
// 记录成功登录日志 // 记录成功登录日志
s.logSuccessLogin(ctx, user.ID, ipAddress, userAgent) s.logSuccessLogin(ctx, user.ID, ipAddress, userAgent)
@@ -239,7 +247,10 @@ func (s *userService) UpdateAvatar(ctx context.Context, userID int64, avatarURL
func (s *userService) ChangePassword(ctx context.Context, userID int64, oldPassword, newPassword string) error { func (s *userService) ChangePassword(ctx context.Context, userID int64, oldPassword, newPassword string) error {
user, err := s.userRepo.FindByID(ctx, userID) user, err := s.userRepo.FindByID(ctx, userID)
if err != nil || user == nil { if err != nil {
return fmt.Errorf("查询用户失败: %w", err)
}
if user == nil {
return errors.New("用户不存在") return errors.New("用户不存在")
} }
@@ -249,7 +260,7 @@ func (s *userService) ChangePassword(ctx context.Context, userID int64, oldPassw
hashedPassword, err := auth.HashPassword(newPassword) hashedPassword, err := auth.HashPassword(newPassword)
if err != nil { if err != nil {
return errors.New("密码加密失败") return fmt.Errorf("密码加密失败: %w", err)
} }
err = s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{ err = s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{
@@ -267,13 +278,16 @@ func (s *userService) ChangePassword(ctx context.Context, userID int64, oldPassw
func (s *userService) ResetPassword(ctx context.Context, email, newPassword string) error { func (s *userService) ResetPassword(ctx context.Context, email, newPassword string) error {
user, err := s.userRepo.FindByEmail(ctx, email) user, err := s.userRepo.FindByEmail(ctx, email)
if err != nil || user == nil { if err != nil {
return fmt.Errorf("查询用户失败: %w", err)
}
if user == nil {
return errors.New("用户不存在") return errors.New("用户不存在")
} }
hashedPassword, err := auth.HashPassword(newPassword) hashedPassword, err := auth.HashPassword(newPassword)
if err != nil { if err != nil {
return errors.New("密码加密失败") return fmt.Errorf("密码加密失败: %w", err)
} }
err = s.userRepo.UpdateFields(ctx, user.ID, map[string]interface{}{ err = s.userRepo.UpdateFields(ctx, user.ID, map[string]interface{}{
@@ -350,14 +364,13 @@ func (s *userService) ValidateAvatarURL(ctx context.Context, avatarURL string) e
return errors.New("URL缺少主机名") return errors.New("URL缺少主机名")
} }
// 从配置获取允许的域名列表 // 从注入的配置获取允许的域名列表
cfg, err := config.GetConfig() allowedDomains := s.cfg.Security.AllowedDomains
if err != nil { if len(allowedDomains) == 0 {
allowedDomains := []string{"localhost", "127.0.0.1"} allowedDomains = []string{"localhost", "127.0.0.1"}
return s.checkDomainAllowed(host, allowedDomains)
} }
return s.checkDomainAllowed(host, cfg.Security.AllowedDomains) return s.checkDomainAllowed(host, allowedDomains)
} }
func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error) { func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error) {
@@ -422,29 +435,23 @@ func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData [
} }
func (s *userService) GetMaxProfilesPerUser() int { func (s *userService) GetMaxProfilesPerUser() int {
cfg, err := config.GetConfig() if s.cfg.Site.MaxProfilesPerUser <= 0 {
if err != nil || cfg.Site.MaxProfilesPerUser <= 0 {
return 5 return 5
} }
return cfg.Site.MaxProfilesPerUser return s.cfg.Site.MaxProfilesPerUser
} }
func (s *userService) GetMaxTexturesPerUser() int { func (s *userService) GetMaxTexturesPerUser() int {
cfg, err := config.GetConfig() if s.cfg.Site.MaxTexturesPerUser <= 0 {
if err != nil || cfg.Site.MaxTexturesPerUser <= 0 {
return 50 return 50
} }
return cfg.Site.MaxTexturesPerUser return s.cfg.Site.MaxTexturesPerUser
} }
// 私有辅助方法 // 私有辅助方法
func (s *userService) getDefaultAvatar() string { func (s *userService) getDefaultAvatar() string {
cfg, err := config.GetConfig() return s.cfg.Site.DefaultAvatar
if err != nil {
return ""
}
return cfg.Site.DefaultAvatar
} }
func (s *userService) checkDomainAllowed(host string, allowedDomains []string) error { func (s *userService) checkDomainAllowed(host string, allowedDomains []string) error {
@@ -505,3 +512,21 @@ func (s *userService) logFailedLogin(ctx context.Context, userID int64, ipAddres
} }
_ = s.userRepo.CreateLoginLog(ctx, log) _ = s.userRepo.CreateLoginLog(ctx, log)
} }
// ==================== 管理员操作 ====================
// ListUsers 分页查询用户列表(管理员)
func (s *userService) ListUsers(ctx context.Context, page, pageSize int) ([]*model.User, int64, error) {
page, pageSize = NormalizePagination(page, pageSize)
return s.userRepo.List(ctx, page, pageSize)
}
// SetRole 设置用户角色(管理员)
func (s *userService) SetRole(ctx context.Context, userID int64, role string) error {
return s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{"role": role})
}
// SetStatus 设置用户状态(管理员)
func (s *userService) SetStatus(ctx context.Context, userID int64, status int16) error {
return s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{"status": status})
}

View File

@@ -3,6 +3,7 @@ package service
import ( import (
"carrotskin/internal/model" "carrotskin/internal/model"
"carrotskin/pkg/auth" "carrotskin/pkg/auth"
"carrotskin/pkg/config"
"context" "context"
"testing" "testing"
@@ -14,11 +15,12 @@ func TestUserServiceImpl_Register(t *testing.T) {
userRepo := NewMockUserRepository() userRepo := NewMockUserRepository()
jwtService := auth.NewJWTService("secret", 1) jwtService := auth.NewJWTService("secret", 1)
logger := zap.NewNop() logger := zap.NewNop()
cfg := &config.Config{}
// 初始化Service // 初始化Service
// 注意redisClient 和 storageClient 传入 nil因为 Register 方法中没有使用它们 // 注意redisClient 和 storageClient 传入 nil因为 Register 方法中没有使用它们
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(cfg, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -128,7 +130,8 @@ func TestUserServiceImpl_Login(t *testing.T) {
_ = userRepo.Create(context.Background(), testUser) _ = userRepo.Create(context.Background(), testUser)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) cfg := &config.Config{}
userService := NewUserService(cfg, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -208,7 +211,7 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
_ = userRepo.Create(context.Background(), user) _ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -255,7 +258,7 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
_ = userRepo.Create(context.Background(), user) _ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -289,7 +292,7 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
_ = userRepo.Create(context.Background(), user) _ = userRepo.Create(context.Background(), user)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -316,7 +319,7 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
_ = userRepo.Create(context.Background(), user2) _ = userRepo.Create(context.Background(), user2)
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -338,7 +341,7 @@ func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
logger := zap.NewNop() logger := zap.NewNop()
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
ctx := context.Background() ctx := context.Background()
@@ -374,7 +377,7 @@ func TestUserServiceImpl_MaxLimits(t *testing.T) {
// 未配置时走默认值 // 未配置时走默认值
cacheManager := NewMockCacheManager() cacheManager := NewMockCacheManager()
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger) userService := NewUserService(&config.Config{}, userRepo, jwtService, nil, cacheManager, nil, logger)
if got := userService.GetMaxProfilesPerUser(); got != 5 { if got := userService.GetMaxProfilesPerUser(); got != 5 {
t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got) t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got)
} }

View File

@@ -26,16 +26,19 @@ const (
// verificationService VerificationService的实现 // verificationService VerificationService的实现
type verificationService struct { type verificationService struct {
cfg *config.Config
redis *redis.Client redis *redis.Client
emailService *email.Service emailService *email.Service
} }
// NewVerificationService 创建VerificationService实例 // NewVerificationService 创建VerificationService实例
func NewVerificationService( func NewVerificationService(
cfg *config.Config,
redisClient *redis.Client, redisClient *redis.Client,
emailService *email.Service, emailService *email.Service,
) VerificationService { ) VerificationService {
return &verificationService{ return &verificationService{
cfg: cfg,
redis: redisClient, redis: redisClient,
emailService: emailService, emailService: emailService,
} }
@@ -44,8 +47,7 @@ func NewVerificationService(
// SendCode 发送验证码 // SendCode 发送验证码
func (s *verificationService) SendCode(ctx context.Context, email, codeType string) error { func (s *verificationService) SendCode(ctx context.Context, email, codeType string) error {
// 测试环境下直接跳过,不存储也不发送 // 测试环境下直接跳过,不存储也不发送
cfg, err := config.GetConfig() if s.cfg.IsTestEnvironment() {
if err == nil && cfg.IsTestEnvironment() {
return nil return nil
} }
@@ -89,8 +91,7 @@ func (s *verificationService) SendCode(ctx context.Context, email, codeType stri
// VerifyCode 验证验证码 // VerifyCode 验证验证码
func (s *verificationService) VerifyCode(ctx context.Context, email, code, codeType string) error { func (s *verificationService) VerifyCode(ctx context.Context, email, code, codeType string) error {
// 测试环境下直接通过验证 // 测试环境下直接通过验证
cfg, err := config.GetConfig() if s.cfg.IsTestEnvironment() {
if err == nil && cfg.IsTestEnvironment() {
return nil return nil
} }
@@ -166,8 +167,5 @@ func (s *verificationService) sendEmail(to, code, codeType string) error {
} }
} }
// DeleteVerificationCode 删除验证码(工具函数,保持向后兼容) // DeleteVerificationCode 已移除:重构后无调用方。如需删除验证码,请使用
func DeleteVerificationCode(ctx context.Context, redisClient *redis.Client, email, codeType string) error { // VerificationService 实现内的 redis.Del 路径。
codeKey := fmt.Sprintf("verification:code:%s:%s", codeType, email)
return redisClient.Del(ctx, codeKey)
}

View File

@@ -1,22 +1,20 @@
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"
"gorm.io/gorm"
) )
// yggdrasilAuthService Yggdrasil认证服务实现 // yggdrasilAuthService Yggdrasil认证服务实现
// 负责认证和密码管理 // 负责认证和密码管理
type yggdrasilAuthService struct { type yggdrasilAuthService struct {
db *gorm.DB
userRepo repository.UserRepository userRepo repository.UserRepository
yggdrasilRepo repository.YggdrasilRepository yggdrasilRepo repository.YggdrasilRepository
logger *zap.Logger logger *zap.Logger
@@ -24,13 +22,11 @@ type yggdrasilAuthService struct {
// NewYggdrasilAuthService 创建Yggdrasil认证服务实例内部使用 // NewYggdrasilAuthService 创建Yggdrasil认证服务实例内部使用
func NewYggdrasilAuthService( func NewYggdrasilAuthService(
db *gorm.DB,
userRepo repository.UserRepository, userRepo repository.UserRepository,
yggdrasilRepo repository.YggdrasilRepository, yggdrasilRepo repository.YggdrasilRepository,
logger *zap.Logger, logger *zap.Logger,
) *yggdrasilAuthService { ) *yggdrasilAuthService {
return &yggdrasilAuthService{ return &yggdrasilAuthService{
db: db,
userRepo: userRepo, userRepo: userRepo,
yggdrasilRepo: yggdrasilRepo, yggdrasilRepo: yggdrasilRepo,
logger: logger, logger: logger,
@@ -78,7 +74,7 @@ func (s *yggdrasilAuthService) ResetYggdrasilPassword(ctx context.Context, userI
ID: userID, ID: userID,
Password: hashedPassword, Password: hashedPassword,
} }
if err := s.db.Create(&yggdrasil).Error; err != nil { if err := s.yggdrasilRepo.Create(ctx, &yggdrasil); err != nil {
return "", fmt.Errorf("创建Yggdrasil密码失败: %w", err) return "", fmt.Errorf("创建Yggdrasil密码失败: %w", err)
} }
return plainPassword, nil return plainPassword, nil

View File

@@ -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
} }
@@ -64,7 +80,7 @@ func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Cont
s.logger.Info("为用户创建新的密钥对", s.logger.Info("为用户创建新的密钥对",
zap.String("uuid", uuid), zap.String("uuid", uuid),
) )
keyPair, err = s.signatureService.NewKeyPair() keyPair, err = s.signatureService.NewKeyPair(ctx)
if err != nil { if err != nil {
s.logger.Error("生成玩家证书密钥对失败", s.logger.Error("生成玩家证书密钥对失败",
zap.Error(err), zap.Error(err),
@@ -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("成功生成玩家证书",
@@ -107,6 +123,5 @@ func (s *yggdrasilCertificateService) GeneratePlayerCertificate(ctx context.Cont
// GetPublicKey 获取公钥 // GetPublicKey 获取公钥
func (s *yggdrasilCertificateService) GetPublicKey(ctx context.Context) (string, error) { func (s *yggdrasilCertificateService) GetPublicKey(ctx context.Context) (string, error) {
return s.signatureService.GetPublicKeyFromRedis() return s.signatureService.GetPublicKeyFromRedis(ctx)
} }

View 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.2PUT 成功后返回最新好友列表
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 解析请求目标 UUIDprofileId 优先,否则用 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-redisMGet 通过 s.redis.Client
var _ = redis.Client{}

View 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)
}
}

View File

@@ -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 {
texturesMap["SKIN"] = map[string]interface{}{ // slim 才需要 metadata 字段classic 直接仅含 url
"url": skin.URL, if skin.IsSlim {
"metadata": skin.Size, texturesMap["SKIN"] = map[string]interface{}{
"url": skin.URL,
"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(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格式

View File

@@ -1,17 +1,16 @@
package service package service
import ( import (
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/redis"
"carrotskin/pkg/utils"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"go.uber.org/zap" "carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/redis"
"carrotskin/pkg/utils"
"gorm.io/gorm" "go.uber.org/zap"
) )
// yggdrasilServiceComposite 组合服务,保持接口兼容性 // yggdrasilServiceComposite 组合服务,保持接口兼容性
@@ -28,20 +27,20 @@ type yggdrasilServiceComposite struct {
// NewYggdrasilServiceComposite 创建组合服务实例 // NewYggdrasilServiceComposite 创建组合服务实例
func NewYggdrasilServiceComposite( func NewYggdrasilServiceComposite(
db *gorm.DB,
userRepo repository.UserRepository, userRepo repository.UserRepository,
profileRepo repository.ProfileRepository, profileRepo repository.ProfileRepository,
yggdrasilRepo repository.YggdrasilRepository, yggdrasilRepo repository.YggdrasilRepository,
textureRepo repository.TextureRepository,
signatureService *SignatureService, signatureService *SignatureService,
redisClient *redis.Client, redisClient *redis.Client,
logger *zap.Logger, logger *zap.Logger,
tokenService TokenService, // 新增TokenService接口 tokenService TokenService, // 新增TokenService接口
) YggdrasilService { ) YggdrasilService {
// 创建各个专门的服务 // 创建各个专门的服务
authService := NewYggdrasilAuthService(db, userRepo, yggdrasilRepo, logger) authService := NewYggdrasilAuthService(userRepo, yggdrasilRepo, logger)
sessionService := NewSessionService(redisClient, logger) sessionService := NewSessionService(redisClient, logger)
serializationService := NewSerializationService( serializationService := NewSerializationService(
repository.NewTextureRepository(db), textureRepo,
signatureService, signatureService,
logger, logger,
) )
@@ -126,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)
} }

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -2,13 +2,8 @@ package types
import "time" import "time"
// BaseResponse 基础响应结构 // 注意:BaseResponse 与 PaginationResponse 已删除(与 model.Response /
// @Description 通用API响应结构 // model.PaginationResponse 重复)。统一使用 model 包的响应结构
type BaseResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// PaginationRequest 分页请求 // PaginationRequest 分页请求
// @Description 分页查询参数 // @Description 分页查询参数
@@ -17,16 +12,6 @@ type PaginationRequest struct {
PageSize int `json:"page_size" form:"page_size" binding:"omitempty,min=1,max=100"` PageSize int `json:"page_size" form:"page_size" binding:"omitempty,min=1,max=100"`
} }
// PaginationResponse 分页响应
// @Description 分页查询结果
type PaginationResponse struct {
List interface{} `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
// LoginRequest 登录请求 // LoginRequest 登录请求
// @Description 用户登录请求参数 // @Description 用户登录请求参数
type LoginRequest struct { type LoginRequest struct {

View File

@@ -7,9 +7,9 @@ import (
// TestPaginationRequest_Validation 测试分页请求验证逻辑 // TestPaginationRequest_Validation 测试分页请求验证逻辑
func TestPaginationRequest_Validation(t *testing.T) { func TestPaginationRequest_Validation(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
page int page int
pageSize int pageSize int
wantValid bool wantValid bool
}{ }{
{ {
@@ -63,102 +63,15 @@ func TestTextureType_Constants(t *testing.T) {
} }
} }
// TestPaginationResponse_Structure 测试分页响应结构 // TestPaginationRequest_Defaults 测试分页请求结构
func TestPaginationResponse_Structure(t *testing.T) { // 注意:PaginationResponse 已删除(与 model.PaginationResponse 重复)
resp := PaginationResponse{ func TestPaginationRequest_Defaults(t *testing.T) {
List: []string{"a", "b", "c"}, req := PaginationRequest{Page: 1, PageSize: 20}
Total: 100, if req.Page != 1 {
Page: 1, t.Errorf("Page = %d, want 1", req.Page)
PageSize: 20,
TotalPages: 5,
} }
if req.PageSize != 20 {
if resp.Total != 100 { t.Errorf("PageSize = %d, want 20", req.PageSize)
t.Errorf("Total = %d, want 100", resp.Total)
}
if resp.Page != 1 {
t.Errorf("Page = %d, want 1", resp.Page)
}
if resp.PageSize != 20 {
t.Errorf("PageSize = %d, want 20", resp.PageSize)
}
if resp.TotalPages != 5 {
t.Errorf("TotalPages = %d, want 5", resp.TotalPages)
}
}
// TestPaginationResponse_TotalPagesCalculation 测试总页数计算逻辑
func TestPaginationResponse_TotalPagesCalculation(t *testing.T) {
tests := []struct {
name string
total int64
pageSize int
wantPages int
}{
{
name: "正好整除",
total: 100,
pageSize: 20,
wantPages: 5,
},
{
name: "有余数",
total: 101,
pageSize: 20,
wantPages: 6, // 向上取整
},
{
name: "总数小于每页数量",
total: 10,
pageSize: 20,
wantPages: 1,
},
{
name: "总数为0",
total: 0,
pageSize: 20,
wantPages: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 计算总页数:向上取整
var totalPages int
if tt.total == 0 {
totalPages = 0
} else {
totalPages = int((tt.total + int64(tt.pageSize) - 1) / int64(tt.pageSize))
}
if totalPages != tt.wantPages {
t.Errorf("TotalPages = %d, want %d", totalPages, tt.wantPages)
}
})
}
}
// TestBaseResponse_Structure 测试基础响应结构
func TestBaseResponse_Structure(t *testing.T) {
resp := BaseResponse{
Code: 200,
Message: "success",
Data: "test data",
}
if resp.Code != 200 {
t.Errorf("Code = %d, want 200", resp.Code)
}
if resp.Message != "success" {
t.Errorf("Message = %q, want 'success'", resp.Message)
}
if resp.Data != "test data" {
t.Errorf("Data = %v, want 'test data'", resp.Data)
} }
} }
@@ -170,36 +83,10 @@ func TestLoginRequest_Validation(t *testing.T) {
password string password string
wantValid bool wantValid bool
}{ }{
{ {name: "有效的登录请求", username: "testuser", password: "password123", wantValid: true},
name: "有效的登录请求", {name: "用户名为空", username: "", password: "password123", wantValid: false},
username: "testuser", {name: "密码为空", username: "testuser", password: "", wantValid: false},
password: "password123", {name: "密码长度小于6", username: "testuser", password: "12345", wantValid: false},
wantValid: true,
},
{
name: "用户名为空",
username: "",
password: "password123",
wantValid: false,
},
{
name: "密码为空",
username: "testuser",
password: "",
wantValid: false,
},
{
name: "密码长度小于6",
username: "testuser",
password: "12345",
wantValid: false,
},
{
name: "密码长度超过128",
username: "testuser",
password: string(make([]byte, 129)),
wantValid: false,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -211,174 +98,3 @@ func TestLoginRequest_Validation(t *testing.T) {
}) })
} }
} }
// TestRegisterRequest_Validation 测试注册请求验证逻辑
func TestRegisterRequest_Validation(t *testing.T) {
tests := []struct {
name string
username string
email string
password string
verificationCode string
wantValid bool
}{
{
name: "有效的注册请求",
username: "newuser",
email: "user@example.com",
password: "password123",
verificationCode: "123456",
wantValid: true,
},
{
name: "用户名为空",
username: "",
email: "user@example.com",
password: "password123",
verificationCode: "123456",
wantValid: false,
},
{
name: "用户名长度小于3",
username: "ab",
email: "user@example.com",
password: "password123",
verificationCode: "123456",
wantValid: false,
},
{
name: "用户名长度超过50",
username: string(make([]byte, 51)),
email: "user@example.com",
password: "password123",
verificationCode: "123456",
wantValid: false,
},
{
name: "邮箱格式无效",
username: "newuser",
email: "invalid-email",
password: "password123",
verificationCode: "123456",
wantValid: false,
},
{
name: "验证码长度不是6",
username: "newuser",
email: "user@example.com",
password: "password123",
verificationCode: "12345",
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.username != "" &&
len(tt.username) >= 3 && len(tt.username) <= 50 &&
tt.email != "" && contains(tt.email, "@") &&
len(tt.password) >= 6 && len(tt.password) <= 128 &&
len(tt.verificationCode) == 6
if isValid != tt.wantValid {
t.Errorf("Validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// 辅助函数
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// TestResetPasswordRequest_Validation 测试重置密码请求验证
func TestResetPasswordRequest_Validation(t *testing.T) {
tests := []struct {
name string
email string
verificationCode string
newPassword string
wantValid bool
}{
{
name: "有效的重置密码请求",
email: "user@example.com",
verificationCode: "123456",
newPassword: "newpassword123",
wantValid: true,
},
{
name: "邮箱为空",
email: "",
verificationCode: "123456",
newPassword: "newpassword123",
wantValid: false,
},
{
name: "验证码长度不是6",
email: "user@example.com",
verificationCode: "12345",
newPassword: "newpassword123",
wantValid: false,
},
{
name: "新密码长度小于6",
email: "user@example.com",
verificationCode: "123456",
newPassword: "12345",
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.email != "" &&
len(tt.verificationCode) == 6 &&
len(tt.newPassword) >= 6 && len(tt.newPassword) <= 128
if isValid != tt.wantValid {
t.Errorf("Validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}
// TestCreateProfileRequest_Validation 测试创建档案请求验证
func TestCreateProfileRequest_Validation(t *testing.T) {
tests := []struct {
name string
profileName string
wantValid bool
}{
{
name: "有效的档案名",
profileName: "PlayerName",
wantValid: true,
},
{
name: "档案名为空",
profileName: "",
wantValid: false,
},
{
name: "档案名长度超过16",
profileName: string(make([]byte, 17)),
wantValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.profileName != "" &&
len(tt.profileName) >= 1 && len(tt.profileName) <= 16
if isValid != tt.wantValid {
t.Errorf("Validation failed: got %v, want %v", isValid, tt.wantValid)
}
})
}
}

View File

@@ -1,46 +1,8 @@
package auth package auth
import ( // 本文件原包含基于 sync.Once 的全局单例Init/GetJWTService/MustGetJWTService
"carrotskin/pkg/config" //
"fmt" // 在 DI 迁移阶段4全局单例已被移除。JWT 服务由 fx.Provide 构造
"sync" // (见 internal/app/infra_module.go通过构造函数参数注入到各消费者。
) //
// JWT 服务构造逻辑见 jwt.go 的 NewJWTService()。
var (
// jwtServiceInstance 全局JWT服务实例
jwtServiceInstance *JWTService
// once 确保只初始化一次
once sync.Once
// initError 初始化错误
)
// Init 初始化JWT服务线程安全只会执行一次
func Init(cfg config.JWTConfig) error {
once.Do(func() {
jwtServiceInstance = NewJWTService(cfg.Secret, cfg.ExpireHours)
})
return nil
}
// GetJWTService 获取JWT服务实例线程安全
func GetJWTService() (*JWTService, error) {
if jwtServiceInstance == nil {
return nil, fmt.Errorf("JWT服务未初始化请先调用 auth.Init()")
}
return jwtServiceInstance, nil
}
// MustGetJWTService 获取JWT服务实例如果未初始化则panic
func MustGetJWTService() *JWTService {
service, err := GetJWTService()
if err != nil {
panic(err)
}
return service
}

View File

@@ -1,86 +0,0 @@
package auth
import (
"carrotskin/pkg/config"
"testing"
)
// TestGetJWTService_NotInitialized 测试未初始化时获取JWT服务
func TestGetJWTService_NotInitialized(t *testing.T) {
_, err := GetJWTService()
if err == nil {
t.Error("未初始化时应该返回错误")
}
expectedError := "JWT服务未初始化请先调用 auth.Init()"
if err.Error() != expectedError {
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
}
}
// TestMustGetJWTService_Panic 测试MustGetJWTService在未初始化时panic
func TestMustGetJWTService_Panic(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("MustGetJWTService 应该在未初始化时panic")
}
}()
_ = MustGetJWTService()
}
// TestInit_JWTService 测试JWT服务初始化
func TestInit_JWTService(t *testing.T) {
cfg := config.JWTConfig{
Secret: "test-secret-key",
ExpireHours: 24,
}
err := Init(cfg)
if err != nil {
t.Errorf("Init() 错误 = %v, want nil", err)
}
// 验证可以获取服务
service, err := GetJWTService()
if err != nil {
t.Errorf("GetJWTService() 错误 = %v, want nil", err)
}
if service == nil {
t.Error("GetJWTService() 返回的服务不应为nil")
}
}
// TestInit_JWTService_Once 测试Init只执行一次
func TestInit_JWTService_Once(t *testing.T) {
cfg := config.JWTConfig{
Secret: "test-secret-key-1",
ExpireHours: 24,
}
// 第一次初始化
err1 := Init(cfg)
if err1 != nil {
t.Fatalf("第一次Init() 错误 = %v", err1)
}
service1, _ := GetJWTService()
// 第二次初始化(应该不会改变服务)
cfg2 := config.JWTConfig{
Secret: "test-secret-key-2",
ExpireHours: 48,
}
err2 := Init(cfg2)
if err2 != nil {
t.Fatalf("第二次Init() 错误 = %v", err2)
}
service2, _ := GetJWTService()
// 验证是同一个实例sync.Once保证
if service1 != service2 {
t.Error("Init应该只执行一次返回同一个实例")
}
}

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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 != "" {

View File

@@ -1,71 +1,8 @@
package config package config
import ( // 本文件原包含基于 sync.Once 的全局单例Init/GetConfig/MustGetConfig/GetRustFSConfig
"fmt" //
"sync" // 在 DI 迁移阶段4全局单例已被移除。配置通过 config.Load() 加载,
) // 由 fx.Supply 注入到依赖图中。各消费者通过构造函数参数接收 *Config 或子配置。
//
var ( // 配置加载逻辑见 config.go 的 Load()。
// configInstance 全局配置实例
configInstance *Config
// rustFSConfigInstance 全局RustFS配置实例
rustFSConfigInstance *RustFSConfig
// once 确保只初始化一次
once sync.Once
// initError 初始化错误
initError error
)
// Init 初始化配置(线程安全,只会执行一次)
func Init() error {
once.Do(func() {
configInstance, initError = Load()
if initError != nil {
return
}
rustFSConfigInstance = &configInstance.RustFS
})
return initError
}
// GetConfig 获取配置实例(线程安全)
func GetConfig() (*Config, error) {
if configInstance == nil {
return nil, fmt.Errorf("配置未初始化,请先调用 config.Init()")
}
return configInstance, nil
}
// MustGetConfig 获取配置实例如果未初始化则panic
func MustGetConfig() *Config {
cfg, err := GetConfig()
if err != nil {
panic(err)
}
return cfg
}
// GetRustFSConfig 获取RustFS配置实例线程安全
func GetRustFSConfig() (*RustFSConfig, error) {
if rustFSConfigInstance == nil {
return nil, fmt.Errorf("配置未初始化,请先调用 config.Init()")
}
return rustFSConfigInstance, nil
}
// MustGetRustFSConfig 获取RustFS配置实例如果未初始化则panic
func MustGetRustFSConfig() *RustFSConfig {
cfg, err := GetRustFSConfig()
if err != nil {
panic(err)
}
return cfg
}

View File

@@ -4,67 +4,19 @@ import (
"testing" "testing"
) )
// TestGetConfig_NotInitialized 测试未初始化时获取配置 // TestLoad_Config 验证 Load() 能成功加载配置(从环境变量/默认值)
func TestGetConfig_NotInitialized(t *testing.T) { // 全局单例访问器GetConfig/MustGetConfig已在 DI 迁移中移除,
// 重置全局变量(在实际测试中可能需要更复杂的重置逻辑) // 配置通过 config.Load() 加载并由 fx.Supply 注入。
// 注意:由于使用了 sync.Once这个测试主要验证错误处理逻辑 func TestLoad_Config(t *testing.T) {
cfg, err := Load()
// 测试未初始化时的错误消息 if err != nil {
_, err := GetConfig() t.Fatalf("Load() 返回错误: %v", err)
if err == nil {
t.Error("未初始化时应该返回错误")
} }
if cfg == nil {
expectedError := "配置未初始化,请先调用 config.Init()" t.Fatal("Load() 返回 nil 配置")
if err.Error() != expectedError { }
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError) // 验证默认值生效
if cfg.Server.Port == "" {
t.Error("Server.Port 不应为空")
} }
} }
// TestMustGetConfig_Panic 测试MustGetConfig在未初始化时panic
func TestMustGetConfig_Panic(t *testing.T) {
// 注意这个测试会触发panic需要recover
defer func() {
if r := recover(); r == nil {
t.Error("MustGetConfig 应该在未初始化时panic")
}
}()
// 尝试获取未初始化的配置
_ = MustGetConfig()
}
// TestGetRustFSConfig_NotInitialized 测试未初始化时获取RustFS配置
func TestGetRustFSConfig_NotInitialized(t *testing.T) {
_, err := GetRustFSConfig()
if err == nil {
t.Error("未初始化时应该返回错误")
}
expectedError := "配置未初始化,请先调用 config.Init()"
if err.Error() != expectedError {
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
}
}
// TestMustGetRustFSConfig_Panic 测试MustGetRustFSConfig在未初始化时panic
func TestMustGetRustFSConfig_Panic(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("MustGetRustFSConfig 应该在未初始化时panic")
}
}()
_ = MustGetRustFSConfig()
}
// TestInit_Once 测试Init只执行一次的逻辑
func TestInit_Once(t *testing.T) {
// 注意由于sync.Once的特性这个测试主要验证逻辑
// 实际测试中可能需要重置机制
// 验证Init函数可调用函数不能直接比较nil
// 这里只验证函数存在
_ = Init
}

View File

@@ -122,7 +122,11 @@ func (cm *CacheManager) Set(ctx context.Context, key string, value interface{},
// SetAsync 异步设置缓存,避免在主请求链路阻塞 // SetAsync 异步设置缓存,避免在主请求链路阻塞
func (cm *CacheManager) SetAsync(ctx context.Context, key string, value interface{}, expiration ...time.Duration) { func (cm *CacheManager) SetAsync(ctx context.Context, key string, value interface{}, expiration ...time.Duration) {
go func() { go func() {
_ = cm.Set(ctx, key, value, expiration...) // 使用独立 ctx 配合超时,防止上游 ctx 取消导致缓存写入失败,
// 同时避免 Redis 卡死时 goroutine 永久堆积
asyncCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
_ = cm.Set(asyncCtx, key, value, expiration...)
}() }()
} }

View File

@@ -1,75 +1,27 @@
package database package database
import ( import (
"carrotskin/internal/model"
"carrotskin/pkg/config"
"fmt" "fmt"
"sync"
"carrotskin/internal/model"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
) )
var ( // 本文件原包含基于 sync.Once 的全局单例Init/GetDB/MustGetDB/GetDBWrapper
// dbInstance 全局数据库实例(使用 *DB 封装) //
dbInstance *DB // 在 DI 迁移阶段4全局单例已被移除。数据库连接由 fx.Provide 构造
// once 确保只初始化一次 // (见 internal/app/infra_module.go 的 provideDatabase通过构造函数参数
once sync.Once // 注入 *database.DB 与 *gorm.DB 到各消费者。
// initError 初始化错误 //
initError error // 数据库构造逻辑见 postgres.go 的 New()。
)
// Init 初始化数据库连接(线程安全,只会执行一次)
func Init(cfg config.DatabaseConfig, logger *zap.Logger) error {
once.Do(func() {
dbInstance, initError = New(cfg)
if initError != nil {
logger.Error("数据库初始化失败", zap.Error(initError))
return
}
logger.Info("数据库连接成功")
})
return initError
}
// GetDB 获取数据库实例(线程安全)
// 返回 *gorm.DB 以保持向后兼容
func GetDB() (*gorm.DB, error) {
if dbInstance == nil {
return nil, fmt.Errorf("数据库未初始化,请先调用 database.Init()")
}
return dbInstance.DB, nil
}
// GetDBWrapper 获取数据库封装实例(包含连接池统计功能)
func GetDBWrapper() (*DB, error) {
if dbInstance == nil {
return nil, fmt.Errorf("数据库未初始化,请先调用 database.Init()")
}
return dbInstance, nil
}
// MustGetDB 获取数据库实例如果未初始化则panic
// 返回 *gorm.DB 以保持向后兼容
func MustGetDB() *gorm.DB {
db, err := GetDB()
if err != nil {
panic(err)
}
return db
}
// AutoMigrate 自动迁移数据库表结构
func AutoMigrate(logger *zap.Logger) error {
db, err := GetDB()
if err != nil {
return fmt.Errorf("获取数据库实例失败: %w", err)
}
// AutoMigrateWithDB 使用指定的 *gorm.DB 执行表结构迁移(供 DI 使用)。
// 注意表的创建顺序:先创建被引用的表,再创建引用表。
func AutoMigrateWithDB(db *gorm.DB, logger *zap.Logger) error {
logger.Info("开始执行数据库迁移...") logger.Info("开始执行数据库迁移...")
// 迁移所有表 - 注意顺序:先创建被引用的表,再创建引用表
// 使用分批迁移,避免某些表的问题影响其他表
tables := []interface{}{ tables := []interface{}{
// 用户相关表(先创建,因为其他表可能引用它) // 用户相关表(先创建,因为其他表可能引用它)
&model.User{}, &model.User{},
@@ -90,6 +42,10 @@ func AutoMigrate(logger *zap.Logger) error {
// Yggdrasil相关表在User之后创建因为它引用User // Yggdrasil相关表在User之后创建因为它引用User
&model.Yggdrasil{}, &model.Yggdrasil{},
// 好友系统相关表(好友关系、玩家偏好属性)
&model.Friend{},
&model.PlayerAttributes{},
// 审计日志表 // 审计日志表
&model.AuditLog{}, &model.AuditLog{},
@@ -97,7 +53,6 @@ func AutoMigrate(logger *zap.Logger) error {
&model.CasbinRule{}, &model.CasbinRule{},
} }
// 批量迁移表
if err := db.AutoMigrate(tables...); err != nil { if err := db.AutoMigrate(tables...); err != nil {
logger.Error("数据库迁移失败", zap.Error(err)) logger.Error("数据库迁移失败", zap.Error(err))
return fmt.Errorf("数据库迁移失败: %w", err) return fmt.Errorf("数据库迁移失败: %w", err)
@@ -106,12 +61,3 @@ func AutoMigrate(logger *zap.Logger) error {
logger.Info("数据库迁移完成") logger.Info("数据库迁移完成")
return nil return nil
} }
// Close 关闭数据库连接
func Close() error {
if dbInstance == nil {
return nil
}
return dbInstance.Close()
}

View File

@@ -8,34 +8,16 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// 使用内存 sqlite 验证 AutoMigrate 关键路径,无需真实 Postgres // 使用内存 sqlite 验证 AutoMigrateWithDB 关键路径,无需真实 Postgres
// 全局单例访问器Init/GetDB/MustGetDB已在 DI 迁移中移除。
func TestAutoMigrate_WithSQLite(t *testing.T) { func TestAutoMigrate_WithSQLite(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil { if err != nil {
t.Fatalf("open sqlite err: %v", err) t.Fatalf("open sqlite err: %v", err)
} }
// 创建临时的 *DB 包装器用于测试
// 注意:这里不需要真正的连接池功能,只是测试 AutoMigrate
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get sql.DB err: %v", err)
}
tempDB := &DB{
DB: db,
sqlDB: sqlDB,
}
// 保存原始实例
originalDB := dbInstance
defer func() { dbInstance = originalDB }()
// 替换为测试实例
dbInstance = tempDB
logger := zaptest.NewLogger(t) logger := zaptest.NewLogger(t)
if err := AutoMigrate(logger); err != nil { if err := AutoMigrateWithDB(db, logger); err != nil {
t.Fatalf("AutoMigrate sqlite err: %v", err) t.Fatalf("AutoMigrateWithDB sqlite err: %v", err)
} }
} }

View File

@@ -1,87 +0,0 @@
package database
import (
"carrotskin/pkg/config"
"testing"
"go.uber.org/zap/zaptest"
)
// TestGetDB_NotInitialized 测试未初始化时获取数据库实例
func TestGetDB_NotInitialized(t *testing.T) {
dbInstance = nil
_, err := GetDB()
if err == nil {
t.Error("未初始化时应该返回错误")
}
expectedError := "数据库未初始化,请先调用 database.Init()"
if err.Error() != expectedError {
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
}
}
// TestMustGetDB_Panic 测试MustGetDB在未初始化时panic
func TestMustGetDB_Panic(t *testing.T) {
dbInstance = nil
defer func() {
if r := recover(); r == nil {
t.Error("MustGetDB 应该在未初始化时panic")
}
}()
_ = MustGetDB()
}
// TestInit_Database 测试数据库初始化逻辑
func TestInit_Database(t *testing.T) {
dbInstance = nil
cfg := config.DatabaseConfig{
Driver: "postgres",
Host: "localhost",
Port: 5432,
Username: "postgres",
Password: "password",
Database: "testdb",
SSLMode: "disable",
Timezone: "Asia/Shanghai",
MaxIdleConns: 10,
MaxOpenConns: 100,
ConnMaxLifetime: 0,
}
logger := zaptest.NewLogger(t)
// 验证Init函数存在且可调用
// 注意:实际连接可能失败,这是可以接受的
err := Init(cfg, logger)
if err != nil {
t.Skipf("数据库未运行,跳过连接测试: %v", err)
}
}
// TestAutoMigrate_ErrorHandling 测试AutoMigrate的错误处理逻辑
func TestAutoMigrate_ErrorHandling(t *testing.T) {
logger := zaptest.NewLogger(t)
// 测试未初始化时的错误处理
err := AutoMigrate(logger)
if err == nil {
// 如果数据库已初始化,这是正常的
t.Log("AutoMigrate() 成功(数据库可能已初始化)")
} else {
// 如果数据库未初始化,应该返回错误
if err.Error() == "" {
t.Error("AutoMigrate() 应该返回有意义的错误消息")
}
}
}
// TestClose_NotInitialized 测试未初始化时关闭数据库
func TestClose_NotInitialized(t *testing.T) {
// 未初始化时关闭应该不返回错误
err := Close()
if err != nil {
t.Errorf("Close() 在未初始化时应该返回nil实际返回: %v", err)
}
}

View File

@@ -50,13 +50,8 @@ var defaultCasbinRules = []model.CasbinRule{
{PType: "g", V0: "admin", V1: "user"}, {PType: "g", V0: "admin", V1: "user"},
} }
// Seed 初始化种子数据 // SeedWithDB 使用指定的 *gorm.DB 初始化种子数据(供 DI 使用)
func Seed(logger *zap.Logger) error { func SeedWithDB(db *gorm.DB, logger *zap.Logger) error {
db, err := GetDB()
if err != nil {
return err
}
logger.Info("开始初始化种子数据...") logger.Info("开始初始化种子数据...")
// 初始化默认管理员用户 // 初始化默认管理员用户

View File

@@ -2,7 +2,6 @@ package email
import ( import (
"strings" "strings"
"sync"
"testing" "testing"
"carrotskin/pkg/config" "carrotskin/pkg/config"
@@ -10,25 +9,18 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
func resetEmailOnce() { // 全局单例访问器Init/GetService/MustGetService已在 DI 迁移中移除。
serviceInstance = nil // 本测试改为直接使用构造函数 NewService。
once = sync.Once{}
}
func TestEmailManager_Disabled(t *testing.T) { func TestEmailManager_Disabled(t *testing.T) {
resetEmailOnce()
cfg := config.EmailConfig{Enabled: false} cfg := config.EmailConfig{Enabled: false}
if err := Init(cfg, zap.NewNop()); err != nil { svc := NewService(cfg, zap.NewNop())
t.Fatalf("Init disabled err: %v", err)
}
svc := MustGetService()
if err := svc.SendVerificationCode("to@test.com", "123456", "email_verification"); err == nil { if err := svc.SendVerificationCode("to@test.com", "123456", "email_verification"); err == nil {
t.Fatalf("expected error when disabled") t.Fatalf("expected error when disabled")
} }
} }
func TestEmailManager_SendFailsWithInvalidSMTP(t *testing.T) { func TestEmailManager_SendFailsWithInvalidSMTP(t *testing.T) {
resetEmailOnce()
cfg := config.EmailConfig{ cfg := config.EmailConfig{
Enabled: true, Enabled: true,
SMTPHost: "127.0.0.1", SMTPHost: "127.0.0.1",
@@ -37,8 +29,7 @@ func TestEmailManager_SendFailsWithInvalidSMTP(t *testing.T) {
Password: "pwd", Password: "pwd",
FromName: "name", FromName: "name",
} }
_ = Init(cfg, zap.NewNop()) svc := NewService(cfg, zap.NewNop())
svc := MustGetService()
if err := svc.SendVerificationCode("to@test.com", "123456", "reset_password"); err == nil { if err := svc.SendVerificationCode("to@test.com", "123456", "reset_password"); err == nil {
t.Fatalf("expected send error with invalid smtp") t.Fatalf("expected send error with invalid smtp")
} }

View File

@@ -1,54 +1,8 @@
package email package email
import ( // 本文件原包含基于 sync.Once 的全局单例Init/GetService/MustGetService
"carrotskin/pkg/config" //
"fmt" // 在 DI 迁移阶段4全局单例已被移除。Email 服务由 fx.Provide(email.NewService)
"sync" // 构造,通过构造函数参数注入到各消费者。
//
"go.uber.org/zap" // 邮件服务构造逻辑见 email.go 的 NewService()。
)
var (
// serviceInstance 全局邮件服务实例
serviceInstance *Service
// once 确保只初始化一次
once sync.Once
// initError 初始化错误
initError error
)
// Init 初始化邮件服务(线程安全,只会执行一次)
func Init(cfg config.EmailConfig, logger *zap.Logger) error {
once.Do(func() {
serviceInstance = NewService(cfg, logger)
})
return nil
}
// GetService 获取邮件服务实例(线程安全)
func GetService() (*Service, error) {
if serviceInstance == nil {
return nil, fmt.Errorf("邮件服务未初始化,请先调用 email.Init()")
}
return serviceInstance, nil
}
// MustGetService 获取邮件服务实例如果未初始化则panic
func MustGetService() *Service {
service, err := GetService()
if err != nil {
panic(err)
}
return service
}

View File

@@ -1,69 +0,0 @@
package email
import (
"carrotskin/pkg/config"
"sync"
"testing"
"go.uber.org/zap/zaptest"
)
func resetEmail() {
serviceInstance = nil
once = sync.Once{}
}
// TestGetService_NotInitialized 测试未初始化时获取邮件服务
func TestGetService_NotInitialized(t *testing.T) {
resetEmail()
_, err := GetService()
if err == nil {
t.Error("未初始化时应该返回错误")
}
expectedError := "邮件服务未初始化,请先调用 email.Init()"
if err.Error() != expectedError {
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
}
}
// TestMustGetService_Panic 测试MustGetService在未初始化时panic
func TestMustGetService_Panic(t *testing.T) {
resetEmail()
defer func() {
if r := recover(); r == nil {
t.Error("MustGetService 应该在未初始化时panic")
}
}()
_ = MustGetService()
}
// TestInit_Email 测试邮件服务初始化
func TestInit_Email(t *testing.T) {
resetEmail()
cfg := config.EmailConfig{
Enabled: false,
SMTPHost: "smtp.example.com",
SMTPPort: 587,
Username: "user@example.com",
Password: "password",
FromName: "noreply@example.com",
}
logger := zaptest.NewLogger(t)
err := Init(cfg, logger)
if err != nil {
t.Errorf("Init() 错误 = %v, want nil", err)
}
// 验证可以获取服务
service, err := GetService()
if err != nil {
t.Errorf("GetService() 错误 = %v, want nil", err)
}
if service == nil {
t.Error("GetService() 返回的服务不应为nil")
}
}

View File

@@ -1,57 +1,8 @@
package logger package logger
import ( // 本文件原包含基于 sync.Once 的全局单例Init/GetLogger/MustGetLogger
"carrotskin/pkg/config" //
"fmt" // 在 DI 迁移阶段4全局单例已被移除。Logger 由 fx.Provide(logger.New)
"sync" // 构造,通过构造函数参数注入到各消费者。
//
"go.uber.org/zap" // 日志构造逻辑见 logger.go 的 New()。
)
var (
// loggerInstance 全局日志实例
loggerInstance *zap.Logger
// once 确保只初始化一次
once sync.Once
// initError 初始化错误
initError error
)
// Init 初始化日志记录器(线程安全,只会执行一次)
func Init(cfg config.LogConfig) error {
once.Do(func() {
loggerInstance, initError = New(cfg)
if initError != nil {
return
}
})
return initError
}
// GetLogger 获取日志实例(线程安全)
func GetLogger() (*zap.Logger, error) {
if loggerInstance == nil {
return nil, fmt.Errorf("日志未初始化,请先调用 logger.Init()")
}
return loggerInstance, nil
}
// MustGetLogger 获取日志实例如果未初始化则panic
func MustGetLogger() *zap.Logger {
logger, err := GetLogger()
if err != nil {
panic(err)
}
return logger
}

View File

@@ -1,47 +0,0 @@
package logger
import (
"carrotskin/pkg/config"
"testing"
)
// TestGetLogger_NotInitialized 测试未初始化时获取日志实例
func TestGetLogger_NotInitialized(t *testing.T) {
_, err := GetLogger()
if err == nil {
t.Error("未初始化时应该返回错误")
}
expectedError := "日志未初始化,请先调用 logger.Init()"
if err.Error() != expectedError {
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
}
}
// TestMustGetLogger_Panic 测试MustGetLogger在未初始化时panic
func TestMustGetLogger_Panic(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("MustGetLogger 应该在未初始化时panic")
}
}()
_ = MustGetLogger()
}
// TestInit_Logger 测试日志初始化逻辑
func TestInit_Logger(t *testing.T) {
cfg := config.LogConfig{
Level: "info",
Format: "json",
Output: "stdout",
}
// 验证Init函数存在且可调用
err := Init(cfg)
if err != nil {
// 初始化可能失败(例如缺少依赖),这是可以接受的
t.Logf("Init() 返回错误(可能正常): %v", err)
}
}

View File

@@ -1,75 +1,38 @@
package redis package redis
import ( import (
"carrotskin/pkg/config"
"fmt" "fmt"
"os" "os"
"sync"
"github.com/alicebob/miniredis/v2" "github.com/alicebob/miniredis/v2"
redis9 "github.com/redis/go-redis/v9" redis9 "github.com/redis/go-redis/v9"
"go.uber.org/zap" "go.uber.org/zap"
) )
var ( // 本文件原包含基于 sync.Once 的全局单例Init/GetClient/MustGetClient/Close
// clientInstance 全局Redis客户端实例 //
clientInstance *Client // 在 DI 迁移阶段4全局单例已被移除。Redis 客户端由 fx.Provide 构造
// once 确保只初始化一次 // (见 internal/app/infra_module.go 的 provideRedis包含 miniredis 回退逻辑),
once sync.Once // 通过构造函数参数注入到各消费者。
// initError 初始化错误 //
initError error // Redis 客户端构造逻辑见 redis.go 的 New()。
// miniredisInstance 用于测试/开发环境
miniredisInstance *miniredis.Miniredis
)
// Init 初始化Redis客户端线程安全只会执行一次 // AllowFallbackToMiniRedis 检查当前环境是否允许回退到 miniredis仅开发/测试环境)。
// 如果Redis连接失败且环境为测试/开发则回退到miniredis func AllowFallbackToMiniRedis() bool {
func Init(cfg config.RedisConfig, logger *zap.Logger) error {
var err error
once.Do(func() {
// 尝试连接真实Redis
clientInstance, err = New(cfg, logger)
if err != nil {
logger.Warn("Redis连接失败尝试使用miniredis回退", zap.Error(err))
// 检查是否允许回退到miniredis仅开发/测试环境)
if allowFallbackToMiniRedis() {
clientInstance, err = initMiniRedis(logger)
if err != nil {
initError = fmt.Errorf("Redis和miniredis都初始化失败: %w", err)
logger.Error("miniredis初始化失败", zap.Error(initError))
return
}
logger.Info("已回退到miniredis用于开发/测试环境")
} else {
initError = fmt.Errorf("Redis连接失败且不允许回退: %w", err)
logger.Error("Redis连接失败", zap.Error(initError))
return
}
}
})
return initError
}
// allowFallbackToMiniRedis 检查是否允许回退到miniredis
func allowFallbackToMiniRedis() bool {
// 检查环境变量
env := os.Getenv("ENVIRONMENT") env := os.Getenv("ENVIRONMENT")
return env == "development" || env == "test" || env == "dev" || return env == "development" || env == "test" || env == "dev" ||
os.Getenv("USE_MINIREDIS") == "true" os.Getenv("USE_MINIREDIS") == "true"
} }
// initMiniRedis 初始化miniredis用于开发/测试环境 // InitMiniRedis 初始化 miniredis用于开发/测试环境的 DI 回退)。
func initMiniRedis(logger *zap.Logger) (*Client, error) { func InitMiniRedis(logger *zap.Logger) (*Client, error) {
var err error mr, err := miniredis.Run()
miniredisInstance, err = miniredis.Run()
if err != nil { if err != nil {
return nil, fmt.Errorf("启动miniredis失败: %w", err) return nil, fmt.Errorf("启动miniredis失败: %w", err)
} }
// 创建Redis客户端连接到miniredis
redisClient := redis9.NewClient(&redis9.Options{ redisClient := redis9.NewClient(&redis9.Options{
Addr: miniredisInstance.Addr(), Addr: mr.Addr(),
}) })
client := &Client{ client := &Client{
@@ -77,42 +40,6 @@ func initMiniRedis(logger *zap.Logger) (*Client, error) {
logger: logger, logger: logger,
} }
logger.Info("miniredis已启动", zap.String("addr", miniredisInstance.Addr())) logger.Info("miniredis已启动", zap.String("addr", mr.Addr()))
return client, nil return client, nil
} }
// GetClient 获取Redis客户端实例线程安全
func GetClient() (*Client, error) {
if clientInstance == nil {
return nil, fmt.Errorf("Redis客户端未初始化请先调用 redis.Init()")
}
return clientInstance, nil
}
// MustGetClient 获取Redis客户端实例如果未初始化则panic
func MustGetClient() *Client {
client, err := GetClient()
if err != nil {
panic(err)
}
return client
}
// Close 关闭Redis连接包括miniredis如果使用了
func Close() error {
var err error
if miniredisInstance != nil {
miniredisInstance.Close()
miniredisInstance = nil
}
if clientInstance != nil {
err = clientInstance.Close()
clientInstance = nil
}
return err
}
// IsUsingMiniRedis 检查是否使用了miniredis
func IsUsingMiniRedis() bool {
return miniredisInstance != nil
}

View File

@@ -1,53 +0,0 @@
package redis
import (
"carrotskin/pkg/config"
"testing"
"go.uber.org/zap/zaptest"
)
// TestGetClient_NotInitialized 测试未初始化时获取Redis客户端
func TestGetClient_NotInitialized(t *testing.T) {
_, err := GetClient()
if err == nil {
t.Error("未初始化时应该返回错误")
}
expectedError := "Redis客户端未初始化请先调用 redis.Init()"
if err.Error() != expectedError {
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
}
}
// TestMustGetClient_Panic 测试MustGetClient在未初始化时panic
func TestMustGetClient_Panic(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("MustGetClient 应该在未初始化时panic")
}
}()
_ = MustGetClient()
}
// TestInit_Redis 测试Redis初始化逻辑
func TestInit_Redis(t *testing.T) {
cfg := config.RedisConfig{
Host: "localhost",
Port: 6379,
Password: "",
Database: 0,
PoolSize: 10,
}
logger := zaptest.NewLogger(t)
// 验证Init函数存在且可调用
// 注意:实际连接可能失败,这是可以接受的
err := Init(cfg, logger)
if err != nil {
t.Logf("Init() 返回错误可能正常如果Redis未运行: %v", err)
}
}

View File

@@ -1,55 +1,8 @@
package storage package storage
import ( // 本文件原包含基于 sync.Once 的全局单例Init/GetClient/MustGetClient
"carrotskin/pkg/config" //
"fmt" // 在 DI 迁移阶段4全局单例已被移除。Storage 客户端由 fx.Provide(storage.NewStorage)
"sync" // 构造,通过构造函数参数注入到各消费者。
) //
// 存储客户端构造逻辑见 minio.go 的 NewStorage()。
var (
// clientInstance 全局存储客户端实例
clientInstance *StorageClient
// once 确保只初始化一次
once sync.Once
// initError 初始化错误
initError error
)
// Init 初始化存储客户端(线程安全,只会执行一次)
func Init(cfg config.RustFSConfig) error {
once.Do(func() {
clientInstance, initError = NewStorage(cfg)
if initError != nil {
return
}
})
return initError
}
// GetClient 获取存储客户端实例(线程安全)
func GetClient() (*StorageClient, error) {
if clientInstance == nil {
return nil, fmt.Errorf("存储客户端未初始化,请先调用 storage.Init()")
}
return clientInstance, nil
}
// MustGetClient 获取存储客户端实例如果未初始化则panic
func MustGetClient() *StorageClient {
client, err := GetClient()
if err != nil {
panic(err)
}
return client
}

View File

@@ -1,52 +0,0 @@
package storage
import (
"carrotskin/pkg/config"
"testing"
)
// TestGetClient_NotInitialized 测试未初始化时获取存储客户端
func TestGetClient_NotInitialized(t *testing.T) {
_, err := GetClient()
if err == nil {
t.Error("未初始化时应该返回错误")
}
expectedError := "存储客户端未初始化,请先调用 storage.Init()"
if err.Error() != expectedError {
t.Errorf("错误消息 = %q, want %q", err.Error(), expectedError)
}
}
// TestMustGetClient_Panic 测试MustGetClient在未初始化时panic
func TestMustGetClient_Panic(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("MustGetClient 应该在未初始化时panic")
}
}()
_ = MustGetClient()
}
// TestInit_Storage 测试存储客户端初始化逻辑
func TestInit_Storage(t *testing.T) {
cfg := config.RustFSConfig{
Endpoint: "http://localhost:9000",
AccessKey: "minioadmin",
SecretKey: "minioadmin",
UseSSL: false,
Buckets: map[string]string{
"avatars": "avatars",
"textures": "textures",
},
}
// 验证Init函数存在且可调用
// 注意:实际连接可能失败,这是可以接受的
err := Init(cfg)
if err != nil {
t.Logf("Init() 返回错误(可能正常,如果存储服务未运行): %v", err)
}
}

View File

@@ -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)