From 167c51b20d9765df43cdc64069f98218954f80f7 Mon Sep 17 00:00:00 2001 From: Mikuisnotavailable Date: Thu, 16 Oct 2025 01:59:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=A7=92=E8=89=B2=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E7=BB=84=E4=BB=B6=E4=B8=AD=E7=9A=84=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E4=B8=8D=E5=8C=B9=E9=85=8D=E9=94=99=E8=AF=AF=EF=BC=8C=E6=94=B9?= =?UTF-8?q?=E8=BF=9BskinId=E5=92=8CcapeId=E7=9A=84=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API测试指南.md | 114 +++ backedREADME (1).md | 440 ++++++++++++ profileServiceREADME (1).md | 291 ++++++++ skinserviceREADME .md | 319 +++++++++ src/app/ClientLayout.tsx | 21 + src/app/api-test/page.tsx | 344 +++++++++ src/app/api-tester/page.tsx | 401 +++++++++++ .../CharacterCenterClient.tsx | 663 +++++++++++++----- src/app/dashboard/page.tsx | 120 +++- src/app/layout.tsx | 16 +- src/app/simple-api-test/page.tsx | 209 ++++++ src/app/skins/upload/page.tsx | 54 +- src/app/verify-code-test/page.tsx | 477 +++++++++++++ src/components/ui/scroll-area.tsx | 27 + src/components/ui/select.tsx | 185 +++++ src/components/ui/tabs.tsx | 130 ++++ src/lib/api/actions.ts | 21 +- src/lib/api/auth.ts | 236 ++++--- src/lib/api/profiles.ts | 307 ++++++++ src/lib/api/skins.ts | 484 +++++++++++++ src/middleware.js | 18 + 21 files changed, 4548 insertions(+), 329 deletions(-) create mode 100644 API测试指南.md create mode 100644 backedREADME (1).md create mode 100644 profileServiceREADME (1).md create mode 100644 skinserviceREADME .md create mode 100644 src/app/ClientLayout.tsx create mode 100644 src/app/api-test/page.tsx create mode 100644 src/app/api-tester/page.tsx create mode 100644 src/app/simple-api-test/page.tsx create mode 100644 src/app/verify-code-test/page.tsx create mode 100644 src/components/ui/scroll-area.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/lib/api/profiles.ts create mode 100644 src/middleware.js diff --git a/API测试指南.md b/API测试指南.md new file mode 100644 index 0000000..8346124 --- /dev/null +++ b/API测试指南.md @@ -0,0 +1,114 @@ +# API连接测试指南 + +本指南将帮助您测试前端是否能够成功读取后端数据,以及验证登录和注册功能的实现状态。 + +## 登录和注册功能实现状态 + +根据代码分析,**登录和注册功能已经实现并连接到了真实的后端API**。系统采用了以下实现方式: + +### 登录功能 +- 支持使用用户名或邮箱登录 +- 集成了NextAuth.js进行身份验证管理 +- 在测试环境中提供测试账号(用户名: `test`, 密码: `test`) +- 非测试账号会调用真实的后端API进行认证:`https://code.littlelan.cn/CarrotSkin/APIgateway/api/v1/auth/login` +- 包含验证码验证机制(非测试账号需要) + +### 注册功能 +- 完整的表单验证逻辑(用户名、密码、邮箱、Minecraft用户名不能为空) +- 密码强度检查(至少6位) +- 邮箱格式验证 +- 验证码验证 +- 调用真实的后端注册API:`https://code.littlelan.cn/CarrotSkin/APIgateway/api/v1/auth/register` + +## 如何测试后端数据连接 + +### 方法一:使用API测试页面 + +1. 访问 `http://localhost:3000/api-tester` 打开API测试工具(已修复) +2. 点击"开始API测试"按钮 +3. 查看测试结果,了解各个API端点的连接状态 + +### 方法二:使用简化API测试页面 + +1. 访问 `http://localhost:3000/simple-api-test` 打开简化的API测试工具 +2. 选择要测试的API端点 +3. 输入必要的参数(如用户ID、角色ID等) +4. 点击"测试API"按钮查看返回结果 + +### 方法三:使用浏览器控制台进行测试 + +打开浏览器开发者工具(F12),切换到控制台(Console)选项卡,执行以下代码: + +```javascript +// 测试材质列表API(无需登录) +fetch('/api/textures') + .then(response => response.json()) + .then(data => console.log('材质列表数据:', data)) + .catch(error => console.error('获取材质列表失败:', error)); + +// 如果已登录,可以测试需要认证的API +// 测试用户角色列表 +fetch('/api/user-profiles?userId=1', { credentials: 'include' }) + .then(response => response.json()) + .then(data => console.log('用户角色列表数据:', data)) + .catch(error => console.error('获取用户角色列表失败:', error)); +``` + +## 测试登录功能 + +### 测试测试账号登录 + +1. 访问 `http://localhost:3000/login` 登录页面 +2. 输入用户名:`test`,密码:`test` +3. 点击登录按钮 +4. 成功后会自动跳转到用户主页 `http://localhost:3000/user-home` + +### 测试注册功能 + +1. 访问 `http://localhost:3000/register` 注册页面 +2. 填写完整的注册信息: + - 用户名:自定义 + - 密码:至少6位 + - 邮箱:有效的邮箱地址 + - Minecraft用户名:自定义 +3. 点击获取验证码,输入收到的验证码 +4. 点击注册按钮 +5. 成功后会提示注册成功并自动跳转到登录页面 + +## 常见问题排查 + +1. **API请求失败** + - 检查网络连接是否正常 + - 确认后端服务器是否正在运行 + - 查看浏览器控制台是否有错误信息 + - 检查API端点URL是否正确 + +2. **登录失败** + - 确认用户名和密码是否正确 + - 检查验证码是否输入正确 + - 查看是否有网络错误提示 + +3. **注册失败** + - 确认所有必填项都已填写 + - 检查密码是否满足长度要求 + - 确认邮箱格式是否正确 + - 检查用户名或邮箱是否已被注册 + +## 高级测试技巧 + +### 查看API请求详情 + +1. 打开浏览器开发者工具(F12) +2. 切换到网络(Network)选项卡 +3. 刷新页面或执行API操作 +4. 查看请求和响应详情,包括状态码、请求头、响应内容等 + +### 测试特定API端点 + +使用API测试页面,您可以测试以下主要端点: +- **材质列表**: `/api/textures` - 获取所有材质数据(无需登录) +- **用户角色列表**: `/api/user-profiles?userId={id}` - 获取指定用户的角色列表(需要登录) +- **角色详情**: `/api/profile?profileId={id}` - 获取指定角色的详细信息(需要登录) +- **角色及属性**: `/api/profile-props?profileId={id}` - 获取指定角色及其属性(需要登录) + +通过以上测试方法,您可以全面验证前端与后端的连接状态和数据读取能力。 \ No newline at end of file diff --git a/backedREADME (1).md b/backedREADME (1).md new file mode 100644 index 0000000..919c14d --- /dev/null +++ b/backedREADME (1).md @@ -0,0 +1,440 @@ +# CarrotSkin Backend + +一个功能完善的Minecraft皮肤站后端系统,采用单体架构设计,基于Go语言和Gin框架开发。 + +## ✨ 核心功能 + +- ✅ **用户认证系统** - 注册、登录、JWT认证、积分系统 +- ✅ **邮箱验证系统** - 注册验证、找回密码、更换邮箱(基于Redis的验证码) +- ✅ **材质管理系统** - 皮肤/披风上传、搜索、收藏、下载统计 +- ✅ **角色档案系统** - Minecraft角色创建、管理、RSA密钥生成 +- ✅ **文件存储** - MinIO/RustFS对象存储集成、预签名URL上传 +- ✅ **缓存系统** - Redis缓存、验证码存储、频率限制 +- ✅ **权限管理** - Casbin RBAC权限控制 +- ✅ **数据审计** - 登录日志、操作审计、下载记录 + +## 项目结构 + +``` +backend/ +├── cmd/ # 应用程序入口 +│ └── server/ # 主服务器 +├── internal/ # 私有应用代码 +│ ├── handler/ # HTTP处理器 +│ ├── service/ # 业务逻辑服务 +│ ├── model/ # 数据模型 +│ ├── repository/ # 数据访问层 +│ ├── middleware/ # 中间件 +│ └── types/ # 类型定义 +├── pkg/ # 公共库代码 +│ ├── auth/ # 认证授权 +│ ├── config/ # 配置管理 +│ ├── database/ # 数据库连接 +│ ├── email/ # 邮件服务 +│ ├── logger/ # 日志系统 +│ ├── redis/ # Redis客户端 +│ ├── storage/ # 文件存储(RustFS/MinIO) +│ ├── utils/ # 工具函数 +│ └── validator/ # 数据验证 +├── docs/ # API定义和文档 +├── configs/ # 配置文件 +│ ├── casbin/ # Casbin权限配置 +├── scripts/ # 脚本文件 +│ ├── carrotskin.sql # 数据库初始化 +├── go.mod # Go模块依赖 +├── go.sum # Go模块校验 +├── run.bat # Windows启动脚本 +├── .env # 环境变量配置 +└── README.md # 项目说明 +``` + +## 技术栈 + +- **语言**: Go 1.21+ +- **框架**: Gin Web Framework +- **数据库**: PostgreSQL 15+ +- **缓存**: Redis 6.0+ +- **存储**: RustFS (S3兼容对象存储) +- **权限**: Casbin RBAC +- **日志**: Zap +- **配置**: 环境变量 (.env) +- **文档**: Swagger/OpenAPI 3.0 + +## 快速开始 + +### 环境要求 + +- Go 1.21或更高版本 +- PostgreSQL 15或更高版本 +- Redis 6.0或更高版本 +- RustFS 或其他 S3 兼容对象存储服务 + +### 安装和运行 + +1. **克隆项目** +```bash +git clone +cd CarrotSkin/backend +``` + +2. **安装依赖** +```bash +go mod download +``` + +3. **配置环境** +```bash +# 复制环境变量文件 +cp .env.example .env +# 编辑 .env 文件配置数据库、RustFS等服务连接信息 +``` + +**注意**:项目完全依赖 `.env` 文件进行配置,不再使用 YAML 配置文件,便于 Docker 容器化部署。 + +4. **初始化数据库** +```bash +# 创建数据库 +createdb carrotskin +# 初始化表结构 +psql -d carrotskin -f scripts/carrotskin_postgres.sql +``` + +5. **运行服务** + +Windows系统: +```bash +run.bat +``` + +Linux/Mac系统: +```bash +chmod +x run.sh +./run.sh +``` + +> 💡 **提示**: 启动脚本会自动检查并安装 `swag` 工具,然后生成Swagger API文档,最后启动服务器。 + +服务启动后: +- **服务地址**: http://localhost:8080 +- **Swagger文档**: http://localhost:8080/swagger/index.html +- **健康检查**: http://localhost:8080/health + +## API接口 + +### 认证相关 +- `POST /api/v1/auth/register` - 用户注册(需邮箱验证码) +- `POST /api/v1/auth/login` - 用户登录(支持用户名/邮箱) +- `POST /api/v1/auth/send-code` - 发送验证码(注册/重置密码/更换邮箱) +- `POST /api/v1/auth/reset-password` - 重置密码(需验证码) + +### 用户相关(需认证) +- `GET /api/v1/user/profile` - 获取用户信息 +- `PUT /api/v1/user/profile` - 更新用户信息(头像、密码) +- `POST /api/v1/user/avatar/upload-url` - 生成头像上传URL +- `PUT /api/v1/user/avatar` - 更新头像 +- `POST /api/v1/user/change-email` - 更换邮箱(需验证码) + +### 材质管理 +公开接口: +- `GET /api/v1/texture` - 搜索材质 +- `GET /api/v1/texture/:id` - 获取材质详情 + +认证接口: +- `POST /api/v1/texture/upload-url` - 生成材质上传URL +- `POST /api/v1/texture` - 创建材质记录 +- `PUT /api/v1/texture/:id` - 更新材质 +- `DELETE /api/v1/texture/:id` - 删除材质 +- `POST /api/v1/texture/:id/favorite` - 切换收藏状态 +- `GET /api/v1/texture/my` - 我的材质列表 +- `GET /api/v1/texture/favorites` - 我的收藏列表 + +### 角色档案 +公开接口: +- `GET /api/v1/profile/:uuid` - 获取档案详情 + +认证接口: +- `POST /api/v1/profile` - 创建角色档案(UUID由后端生成) +- `GET /api/v1/profile` - 我的档案列表 +- `PUT /api/v1/profile/:uuid` - 更新档案 +- `DELETE /api/v1/profile/:uuid` - 删除档案 +- `POST /api/v1/profile/:uuid/activate` - 设置活跃档案 + +### 系统配置 +- `GET /api/v1/system/config` - 获取系统配置 + +## 配置管理 + +### 环境变量配置 + +项目**完全依赖环境变量**进行配置,不使用 YAML 配置文件,便于容器化部署: + +1. **配置来源**: 环境变量 或 `.env` 文件 +2. **环境变量格式**: 使用下划线分隔,全大写,如 `DATABASE_HOST` +3. **容器部署**: 直接在容器运行时设置环境变量即可 + +**主要环境变量**: +```bash +# 数据库配置 +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_USERNAME=postgres +DATABASE_PASSWORD=your_password +DATABASE_NAME=carrotskin + +# Redis配置 +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD=your_redis_password +REDIS_DATABASE=0 +REDIS_POOL_SIZE=10 + +# RustFS对象存储配置 (S3兼容) +RUSTFS_ENDPOINT=127.0.0.1:9000 +RUSTFS_ACCESS_KEY=your_access_key +RUSTFS_SECRET_KEY=your_secret_key +RUSTFS_USE_SSL=false +RUSTFS_BUCKET_TEXTURES=carrot-skin-textures +RUSTFS_BUCKET_AVATARS=carrot-skin-avatars + +# JWT配置 +JWT_SECRET=your-jwt-secret-key +JWT_EXPIRE_HOURS=168 + +# 邮件配置 +EMAIL_ENABLED=true +EMAIL_SMTP_HOST=smtp.example.com +EMAIL_SMTP_PORT=587 +EMAIL_USERNAME=noreply@example.com +EMAIL_PASSWORD=your_email_password +EMAIL_FROM_NAME=CarrotSkin +``` + +**动态配置(存储在数据库中)**: +- 积分系统配置(注册奖励、签到积分等) +- 用户限制配置(最大材质数、最大角色数等) +- 网站设置(站点名称、公告、维护模式等) + +完整的环境变量列表请参考 `.env.example` 文件。 + +## 架构设计 + +### 三层架构 + +项目采用标准的三层架构设计,职责清晰,便于维护: + +``` +┌─────────────────────────────────────┐ +│ Handler 层 (HTTP) │ ← 路由、参数验证、响应格式化 +├─────────────────────────────────────┤ +│ Service 层 (业务逻辑) │ ← 业务规则、权限检查、数据验证 +├─────────────────────────────────────┤ +│ Repository 层 (数据访问) │ ← 数据库操作、关联查询 +├──────────────┬──────────────────────┤ +│ PostgreSQL │ Redis │ RustFS │ ← 数据存储层 +└──────────────┴──────────────────────┘ +``` + +### 核心模块 + +1. **认证模块** (`internal/handler/auth_handler.go`) + - JWT令牌生成和验证 + - bcrypt密码加密 + - 邮箱验证码注册 + - 密码重置功能 + - 登录日志记录(支持用户名/邮箱登录) + +2. **用户模块** (`internal/handler/user_handler.go`) + - 用户信息管理 + - 头像上传(预签名URL) + - 密码修改(需原密码验证) + - 邮箱更换(需验证码) + - 积分系统 + +3. **邮箱验证模块** (`internal/service/verification_service.go`) + - 验证码生成(6位数字) + - 验证码存储(Redis,10分钟有效期) + - 发送频率限制(1分钟) + - 邮件发送(HTML格式) + +4. **材质模块** (`internal/handler/texture_handler.go`) + - 材质上传(预签名URL) + - 材质搜索和收藏 + - Hash去重 + - 下载统计 + +5. **档案模块** (`internal/handler/profile_handler.go`) + - Minecraft角色管理 + - RSA密钥生成(RSA-2048) + - 活跃状态管理 + - 档案数量限制 + +### 技术特性 + +- **安全性**: + - bcrypt密码加密、JWT令牌认证 + - 邮箱验证码(注册/重置密码/更换邮箱) + - Casbin RBAC权限控制 + - 频率限制(防暴力破解) + +- **性能**: + - PostgreSQL索引优化 + - Redis缓存(验证码、会话等) + - 预签名URL减轻服务器压力 + - 连接池管理 + +- **可靠性**: + - 事务保证数据一致性 + - 完整的错误处理和日志记录 + - 优雅关闭和资源清理 + +- **可扩展**: + - 清晰的三层架构 + - 依赖注入设计 + - 环境变量配置(便于容器化) + +- **审计**: + - 登录日志(成功/失败) + - 操作审计 + - 下载记录 + +## 开发指南 + +### 代码结构 + +- `cmd/server/` - 应用入口,初始化服务 +- `internal/handler/` - HTTP请求处理 +- `internal/service/` - 业务逻辑实现 +- `internal/repository/` - 数据库操作 +- `internal/model/` - 数据模型定义 +- `internal/types/` - 请求/响应类型定义 +- `internal/middleware/` - 中间件(JWT、CORS、日志等) +- `pkg/` - 可复用的公共库 + +### 开发规范 + +1. **代码风格**: 遵循Go官方代码规范,使用 `gofmt` 格式化 +2. **错误处理**: 使用统一的错误响应格式 (`model.NewErrorResponse`) +3. **日志记录**: 使用 Zap 结构化日志,包含关键字段 +4. **依赖注入**: Repository → Service → Handler 的依赖链 +5. **RESTful API**: 遵循 REST 设计原则,合理使用HTTP方法 + +### 添加新功能 + +1. 在 `internal/model/` 定义数据模型 +2. 在 `internal/repository/` 实现数据访问 +3. 在 `internal/service/` 实现业务逻辑 +4. 在 `internal/handler/` 实现HTTP处理 +5. 在 `internal/handler/routes.go` 注册路由 + +## 部署 + +### 本地开发 + +```bash +# 安装依赖 +go mod download + +# 启动服务 (Windows) +run.bat + +# 启动服务 (Linux/Mac) +go run cmd/server/main.go +``` + +### 生产部署 + +```bash +# 构建二进制文件 +go build -o carrotskin-server cmd/server/main.go + +# 运行服务 +./carrotskin-server +``` + +### Docker部署 + +```bash +# 构建镜像 +docker build -t carrotskin-backend:latest . + +# 启动服务 +docker-compose up -d +``` + +## 故障排查 + +### 常见问题 + +1. **数据库连接失败** + - 检查 `.env` 中的数据库配置 + - 确认PostgreSQL服务已启动 + - 验证数据库用户权限 + - 确认数据库已创建:`createdb carrotskin` + +2. **Redis连接失败** + - 检查Redis服务是否运行:`redis-cli ping` + - 验证 `.env` 中的Redis配置 + - 确认Redis密码是否正确 + - 检查防火墙规则 + +3. **RustFS/MinIO连接失败** + - 检查存储服务是否运行 + - 验证访问密钥是否正确 + - 确认存储桶是否已创建 + - 检查网络连接和端口 + +4. **邮件发送失败** + - 检查 `EMAIL_ENABLED=true` + - 验证SMTP服务器地址和端口 + - 确认邮箱用户名和密码正确 + - 检查邮件服务商是否需要开启SMTP + - 查看日志获取详细错误信息 + +5. **验证码相关问题** + - 验证码过期(10分钟有效期) + - 发送过于频繁(1分钟限制) + - Redis存储失败(检查Redis连接) + - 邮件未收到(检查垃圾邮件) + +6. **JWT验证失败** + - 检查 `JWT_SECRET` 是否配置 + - 验证令牌是否过期(默认168小时) + - 确认请求头中包含 `Authorization: Bearer ` + - Token格式是否正确 + +### 调试技巧 + +1. **查看日志** +```bash +# 实时查看日志 +tail -f logs/app.log + +# 搜索错误日志 +grep "ERROR" logs/app.log +``` + +2. **测试Redis连接** +```bash +redis-cli -h localhost -p 6379 -a your_password +> PING +> KEYS * +``` + +3. **测试数据库连接** +```bash +psql -h localhost -U postgres -d carrotskin +\dt # 查看所有表 +``` + +4. **测试邮件配置** + - 使用Swagger文档测试 `/api/v1/auth/send-code` 接口 + - 检查邮件服务商是否限制发送频率 + +### 开发调试 + +启用详细日志: +```bash +# 在 .env 中设置 +LOG_LEVEL=debug +SERVER_MODE=debug +``` \ No newline at end of file diff --git a/profileServiceREADME (1).md b/profileServiceREADME (1).md new file mode 100644 index 0000000..3556e8b --- /dev/null +++ b/profileServiceREADME (1).md @@ -0,0 +1,291 @@ +# ProfileService - 角色管理服务 + +ProfileService是CarrotSkin皮肤站的角色管理微服务,负责处理Minecraft角色的创建、管理、RSA密钥生成和签名验证等核心功能。 + +## 📋 目录 + +- [功能特性](#功能特性) +- [技术架构](#技术架构) +- [数据模型](#数据模型) +- [API接口](#api接口) +- [部署指南](#部署指南) +- [开发指南](#开发指南) +- [故障排查](#故障排查) + +## 🚀 功能特性 + +### 核心功能 +- **角色管理**: 创建、查询、更新、删除Minecraft角色 +- **RSA密钥管理**: 自动生成RSA-2048密钥对,支持数据签名和验证 +- **皮肤/披风关联**: 管理角色与皮肤、披风的关联关系 +- **用户角色列表**: 支持查询用户下的所有角色 +- **数据验证**: 完整的参数验证和业务逻辑检查 + +### 安全特性 +- **RSA签名**: 支持使用私钥对数据进行签名 +- **签名验证**: 支持使用公钥验证数据签名 +- **权限控制**: 确保用户只能操作自己的角色 +- **数据完整性**: 完整的事务处理和数据一致性保证 + +## 🏗️ 技术架构 + +### 技术栈 +- **框架**: go-zero微服务框架 +- **数据库**: MySQL 8.0+ +- **缓存**: Redis (通过go-zero集成) +- **通信协议**: gRPC +- **密钥算法**: RSA-2048 + +### 服务架构 +``` +ProfileService +├── internal/ +│ ├── config/ # 配置管理 +│ ├── handler/ # gRPC处理器 +│ ├── logic/ # 业务逻辑层 +│ ├── model/ # 数据模型层 +│ └── svc/ # 服务上下文 +├── pb/ # Protocol Buffers定义 +└── docs/ # 文档和SQL脚本 +``` + +## 📊 数据模型 + +### 数据库表结构 + +```sql +CREATE TABLE `profiles` ( + `uuid` VARCHAR(36) NOT NULL COMMENT '角色的UUID,通常为Minecraft玩家的UUID', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '关联的用户ID', + `name` VARCHAR(16) NOT NULL COMMENT '角色名 (Minecraft In-Game Name)', + `skin_id` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '当前使用的皮肤ID', + `cape_id` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '当前使用的披风ID', + `rsa_private_key` TEXT NOT NULL COMMENT 'RSA-2048私钥 (PEM格式)', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`uuid`), + INDEX `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +### 数据类型映射 + +| 字段 | SQL类型 | Go Model类型 | Proto类型 | 说明 | +|------|---------|-------------|-----------|------| +| uuid | VARCHAR(36) | string | string | 角色UUID | +| user_id | BIGINT UNSIGNED | uint64 | int64 | 用户ID | +| name | VARCHAR(16) | string | string | 角色名 | +| skin_id | BIGINT UNSIGNED NULL | sql.NullInt64 | int64 | 皮肤ID (0表示无皮肤) | +| cape_id | BIGINT UNSIGNED NULL | sql.NullInt64 | int64 | 披风ID (0表示无披风) | +| rsa_private_key | TEXT | string | string | RSA私钥 | +| created_at | TIMESTAMP | time.Time | string | 创建时间 | +| updated_at | TIMESTAMP | time.Time | string | 更新时间 | + +## 🔌 API接口 + +### 对外接口 (Public APIs) + +#### 1. CreateProfile - 创建角色 +```protobuf +rpc CreateProfile(CreateProfileRequest) returns (CreateProfileResponse); +``` +- **功能**: 创建新的Minecraft角色 +- **验证**: UUID格式、角色名格式、用户权限 +- **特性**: 自动生成RSA密钥对、角色名去重检查 + +#### 2. GetProfile - 获取角色信息 +```protobuf +rpc GetProfile(GetProfileRequest) returns (GetProfileResponse); +``` +- **功能**: 根据UUID获取角色基本信息 +- **返回**: 不包含私钥的公开信息 + +#### 3. GetProfilesByUserId - 获取用户角色列表 +```protobuf +rpc GetProfilesByUserId(GetProfilesByUserIdRequest) returns (GetProfilesByUserIdResponse); +``` +- **功能**: 获取指定用户的所有角色列表 +- **特性**: 支持空结果返回 + +#### 4. UpdateProfile - 更新角色信息 +```protobuf +rpc UpdateProfile(UpdateProfileRequest) returns (UpdateProfileResponse); +``` +- **功能**: 更新角色名、皮肤ID、披风ID +- **验证**: 角色名重复检查、权限验证 +- **特性**: 支持NULL值处理 (0表示移除) + +#### 5. DeleteProfile - 删除角色 +```protobuf +rpc DeleteProfile(DeleteProfileRequest) returns (DeleteProfileResponse); +``` +- **功能**: 删除指定角色 +- **验证**: 权限检查 + +#### 6. GetProfilePublicKey - 获取角色公钥 +```protobuf +rpc GetProfilePublicKey(GetProfilePublicKeyRequest) returns (GetProfilePublicKeyResponse); +``` +- **功能**: 获取角色的RSA公钥 +- **用途**: 用于验证角色签名 + +#### 7. VerifyProfileSignature - 验证角色签名 +```protobuf +rpc VerifyProfileSignature(VerifyProfileSignatureRequest) returns (VerifyProfileSignatureResponse); +``` +- **功能**: 验证使用角色私钥生成的签名 +- **算法**: RSA-2048 + SHA-256 + +#### 8. GetProfileWithProperties - 查询角色属性 +```protobuf +rpc GetProfileWithProperties(GetProfileWithPropertiesRequest) returns (GetProfileWithPropertiesResponse); +``` +- **功能**: 查询角色完整信息,包含Minecraft协议兼容的属性和签名 +- **用途**: 用于游戏客户端获取皮肤、披风等信息 +- **特性**: 支持生成带签名和不带签名的属性 + +#### 9. GetProfilesByNames - 批量查询角色 +```protobuf +rpc GetProfilesByNames(GetProfilesByNamesRequest) returns (GetProfilesByNamesResponse); +``` +- **功能**: 根据角色名称列表批量查询角色信息 +- **返回**: 简化的角色信息列表(UUID和名称) + +#### 10. GetUserIdByProfileName - 根据角色名获取用户ID +```protobuf +rpc GetUserIdByProfileName(GetUserIdByProfileNameRequest) returns (GetUserIdByProfileNameResponse); +``` +- **功能**: 根据角色名称查找关联的用户ID +- **用途**: 支持使用角色名进行登录等操作 + +### 对内接口 (Internal APIs) + +#### 11. GetProfileInternalInfo - 获取角色内部信息 +```protobuf +rpc GetProfileInternalInfo(GetProfileInternalInfoRequest) returns (ProfileInternalInfo); +``` +- **功能**: 获取包含私钥的完整角色信息 +- **权限**: 仅供内部微服务调用 +- **安全**: 包含敏感信息,需要严格权限控制 + +#### 12. SignData - 使用私钥签名数据 +```protobuf +rpc SignData(SignDataRequest) returns (SignDataResponse); +``` +- **功能**: 使用角色私钥对数据进行签名 +- **算法**: RSA-2048 + SHA-256 +- **返回**: Base64编码的签名结果 + +## 🚀 部署指南 + +### 环境要求 +- Go 1.19+ +- MySQL 8.0+ +- Redis 6.0+ + +### 配置文件示例 + +```yaml +# etc/profile.yaml +Name: profile.rpc +ListenOn: 0.0.0.0:8082 + +#Etcd: +# Hosts: +# - 127.0.0.1:2379 +# Key: profile.rpc + +DataSource: root:password@tcp(localhost:3306)/carrot_skin?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai + +CacheConf: + - Host: 127.0.0.1:6379 + Pass: "" + Type: node +``` + +### 部署步骤 + +1. **数据库初始化** +```bash +mysql -u root -p carrot_skin < docs/profiles.sql +``` + +2. **编译服务** +```bash +go build -o profile-service . +``` + +3. **启动服务** +```bash +./profile-service -f etc/profile.yaml +``` + +## 🛠️ 开发指南 + +### 代码生成 + +使用提供的脚本重新生成代码: + +```bash +# Windows +generate.bat + +# Linux/Mac +chmod +x generate.sh +./generate.sh +``` + +### 业务逻辑扩展 + +所有业务逻辑都在 `internal/logic/` 目录下: + +- `createProfileLogic.go` - 角色创建逻辑 +- `getProfileLogic.go` - 角色查询逻辑 +- `updateProfileLogic.go` - 角色更新逻辑 +- `deleteProfileLogic.go` - 角色删除逻辑 +- 等等... + +### 数据模型扩展 + +如需添加新的数据库方法,在 `internal/model/profilesmodel.go` 中扩展: + +```go +type ProfilesModel interface { + profilesModel + // 添加自定义方法 + FindByUserId(ctx context.Context, userId uint64) ([]*Profiles, error) +} +``` + +## 🔧 故障排查 + +### 常见问题 + +#### 1. 数据类型转换错误 +**问题**: `sql.NullInt64` 类型处理错误 +**解决**: 使用 `nullInt64ToValue()` 函数进行正确转换 + +#### 2. RSA密钥生成失败 +**问题**: RSA密钥生成或解析失败 +**解决**: 检查系统随机数生成器,确保有足够的熵 + +#### 3. 角色名重复 +**问题**: 同一用户下角色名重复 +**解决**: 创建和更新时都会进行重复检查 + +#### 4. UUID格式错误 +**问题**: UUID格式不符合标准 +**解决**: 确保UUID为36位标准格式 (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) + +### 日志级别 + +- **INFO**: 正常业务操作 +- **ERROR**: 业务错误和系统错误 +- **DEBUG**: 详细的调试信息 + +### 性能监控 + +- 数据库连接池状态 +- Redis缓存命中率 +- gRPC请求响应时间 +- RSA密钥操作耗时 \ No newline at end of file diff --git a/skinserviceREADME .md b/skinserviceREADME .md new file mode 100644 index 0000000..a6455b5 --- /dev/null +++ b/skinserviceREADME .md @@ -0,0 +1,319 @@ +# 皮肤服务 (SkinService) + +此项目是一个使用 **Go-zero** 框架构建的、专注于材质管理的纯 RPC 微服务。它为CarrotSkin皮肤站提供了完整的材质上传、存储、查询和管理功能,并集成了基于 MinIO 的高效文件存储系统。 + +## 核心功能 + +- **安全材质上传**: 利用 MinIO 预签名 POST URL,实现客户端直传,支持PNG格式限制和文件大小控制(1KB-1MB) +- **智能去重机制**: 基于 SHA-256 哈希值自动检测并复用相同材质,节省存储空间 +- **完整材质管理**: 提供材质的增、删、改、查(CRUD)等全套操作 +- **分类存储**: 支持皮肤(SKIN)和披风(CAPE)两种材质类型的分类管理 +- **个人皮肤库**: 用户可查看和管理自己上传的所有材质 +- **皮肤广场**: 公开材质展示平台,支持按类型浏览和搜索 +- **收藏夹功能**: 支持用户收藏和管理自己的收藏列表 +- **高性能查询**: 支持分页查询、批量获取和条件搜索 +- **权限控制**: 严格的材质所有权验证,确保用户只能操作自己的材质 + +## 技术特性 + +- **微服务架构**: 基于 gRPC 的高性能服务间通信 +- **对象存储**: MinIO 分布式存储,支持海量文件管理 +- **数据库缓存**: go-zero 内置缓存机制,提升查询性能 +- **类型安全**: 完整的 protobuf 定义和数据验证 +- **容错设计**: 优雅的错误处理和日志记录 +- **代码生成**: 使用 `goctl` 从 `.proto` 和 `.sql` 文件自动生成代码 + +## API 接口参考 + +服务通过 gRPC 暴露,接口定义于 `docs/textures.proto`。 + +### 材质上传流程 + +| 方法名 | 功能描述 | 请求类型 | 响应类型 | +|--------|----------|----------|----------| +| `GenerateTextureUploadURL` | 生成材质上传预签名URL | `GenerateTextureUploadURLRequest` | `GenerateTextureUploadURLResponse` | +| `CreateTexture` | 创建材质记录(上传完成后调用) | `CreateTextureRequest` | `CreateTextureResponse` | + +**上传流程说明**: +1. 客户端调用 `GenerateTextureUploadURL` 获取预签名上传URL和表单数据 +2. 客户端使用返回的 `post_url` 和 `form_data` 直接向MinIO上传文件 +3. 上传成功后,客户端调用 `CreateTexture` 将材质信息记录到数据库 + +### 材质管理接口 + +| 方法名 | 功能描述 | 请求类型 | 响应类型 | +|--------|----------|----------|----------| +| `GetTexture` | 获取单个材质信息 | `GetTextureRequest` | `GetTextureResponse` | +| `UpdateTexture` | 更新材质信息(公开/私有状态) | `UpdateTextureRequest` | `UpdateTextureResponse` | +| `DeleteTexture` | 删除材质(含MinIO文件清理) | `DeleteTextureRequest` | `DeleteTextureResponse` | + +### 查询接口 + +| 方法名 | 功能描述 | 请求类型 | 响应类型 | +|--------|----------|----------|----------| +| `GetUserTextures` | 获取用户个人材质库 | `GetUserTexturesRequest` | `GetUserTexturesResponse` | +| `GetPublicTextures` | 获取皮肤广场公开材质 | `GetPublicTexturesRequest` | `GetPublicTexturesResponse` | +| `SearchTextures` | 搜索材质 | `SearchTexturesRequest` | `SearchTexturesResponse` | + +### 高级功能接口 + +| 方法名 | 功能描述 | 请求类型 | 响应类型 | +|--------|----------|----------|----------| +| `GetTextureByHash` | 根据哈希值查找材质(防重复上传) | `GetTextureByHashRequest` | `GetTextureByHashResponse` | +| `GetTexturesByIds` | 批量获取材质信息 | `GetTexturesByIdsRequest` | `GetTexturesByIdsResponse` | + +### 收藏夹功能接口 + +| 方法名 | 功能描述 | 请求类型 | 响应类型 | +|---|---|---|---| +| `AddFavorite` | 添加材质到收藏夹 | `AddFavoriteRequest` | `AddFavoriteResponse` | +| `RemoveFavorite` | 从收藏夹移除材质 | `RemoveFavoriteRequest` | `RemoveFavoriteResponse` | +| `GetUserFavorites` | 获取用户收藏列表 | `GetUserFavoritesRequest` | `GetUserFavoritesResponse` | +| `CheckFavoriteStatus` | 检查材质收藏状态 | `CheckFavoriteStatusRequest` | `CheckFavoriteStatusResponse` | + +## 数据模型 + +### 材质信息 (TextureInfo) + +```protobuf +message TextureInfo { + int64 id = 1; // 材质ID + int64 uploader_id = 2; // 上传者用户ID + TextureType type = 3; // 材质类型(SKIN/CAPE) + string url = 4; // MinIO中的永久访问URL + string hash = 5; // SHA-256哈希值 + bool is_public = 6; // 是否公开到皮肤广场 + string created_at = 7; // 创建时间 + string updated_at = 8; // 更新时间 +} +``` + +### 材质类型枚举 + +```protobuf +enum TextureType { + SKIN = 0; // 皮肤 + CAPE = 1; // 披风 +} +``` + +### 收藏夹相关数据模型 + +#### 收藏材质信息 (FavoriteTextureInfo) + +```protobuf +message FavoriteTextureInfo { + TextureInfo texture = 1; // 材质信息 + string favorite_at = 2; // 收藏时间 +} +``` + +## 存储结构 + +### MinIO 对象存储结构 + +``` +textures/ # 存储桶根目录 +├── skins/ # 皮肤材质目录 +│ └── user_{userId}/ # 按用户分组 +│ └── {timestamp}_{filename}.png +└── capes/ # 披风材质目录 + └── user_{userId}/ # 按用户分组 + └── {timestamp}_{filename}.png +``` + +### 数据库表结构 + +```sql +CREATE TABLE textures ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + uploader_id BIGINT NOT NULL, + type VARCHAR(10) NOT NULL, -- 'SKIN' 或 'CAPE' + url VARCHAR(500) NOT NULL, -- MinIO访问URL + hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256哈希值 + is_public BOOLEAN DEFAULT FALSE, -- 是否公开 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_uploader_type (uploader_id, type), + INDEX idx_hash (hash), + INDEX idx_public_type (is_public, type, created_at) +); +``` + +### `user_texture_favorites` 表 + +```sql +-- 用户材质收藏表 +CREATE TABLE `user_texture_favorites` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '收藏记录的唯一ID', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID (对应UserService中的users.id)', + `texture_id` BIGINT UNSIGNED NOT NULL COMMENT '收藏的材质ID (对应textures.id)', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '收藏时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_texture` (`user_id`, `texture_id`), + INDEX `idx_user_id` (`user_id`), + INDEX `idx_texture_id` (`texture_id`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户材质收藏表'; +``` + +## 配置说明 + +### 服务配置 (etc/textures.yaml) + +```yaml +Name: textures.rpc +ListenOn: 0.0.0.0:8080 + +# 数据库配置 +DataSource: root:password@tcp(localhost:3306)/carrot_skin?charset=utf8mb4&parseTime=true&loc=Local + +# 缓存配置 +CacheRedis: + - Host: localhost:6379 + Pass: "" + Type: node + +# MinIO配置 +MinIO: + Endpoint: "localhost:9000" + AccessKeyID: "minioadmin" + SecretAccessKey: "minioadmin" + UseSSL: false + Buckets: + Textures: "carrot-skin-textures" +``` + +## 安全特性 + +### 文件上传安全 + +- **格式限制**: 仅允许PNG格式文件 +- **大小限制**: 文件大小限制在1KB-1MB之间 +- **时效控制**: 预签名URL 15分钟过期 +- **内容验证**: MinIO层面的Content-Type验证 + +### 权限控制 + +- **所有权验证**: 用户只能删除/更新自己上传的材质 +- **参数校验**: 所有接口都有完整的输入验证 +- **错误处理**: 不暴露敏感的系统信息 + +### 数据完整性 + +- **哈希去重**: SHA-256确保文件唯一性 +- **事务处理**: 数据库操作的原子性保证 +- **文件同步**: 删除材质时同步清理MinIO文件 + +## 性能优化 + +### 查询优化 + +- **分页查询**: 支持高效的大数据量分页 +- **索引优化**: 针对常用查询场景建立复合索引 +- **缓存机制**: go-zero内置的Redis缓存层 + +### 存储优化 + +- **智能去重**: 相同文件自动复用,节省存储空间 +- **分类存储**: 按材质类型和用户分目录存储 + +## 部署说明 + +### 环境要求 + +- Go 1.19+ +- MySQL 8.0+ +- Redis 6.0+ +- MinIO Server + +### 启动服务 + +```bash +# 1. 安装依赖 +go mod tidy + +# 2. 生成代码(如果修改了proto或sql文件) +goctl rpc protoc docs/textures.proto --go_out=./pb --go-grpc_out=./pb --zrpc_out=. +goctl model mysql ddl --src="docs/textures.sql" --dir="./internal/model" + +# 3. 启动服务 +go run textures.go -f etc/textures.yaml +``` + +### Docker 部署 + +```dockerfile +FROM golang:1.19-alpine AS builder +WORKDIR /app +COPY . . +RUN go mod tidy && go build -o textures textures.go + +FROM alpine:latest +RUN apk --no-cache add ca-certificates +WORKDIR /root/ +COPY --from=builder /app/textures . +COPY --from=builder /app/etc ./etc +CMD ["./textures", "-f", "etc/textures.yaml"] +``` + +## 监控与日志 + +### 日志记录 + +- **操作日志**: 记录所有材质操作的详细信息 +- **错误日志**: 详细的错误堆栈和上下文信息 +- **性能日志**: 查询耗时和系统性能指标 + +### 监控指标 + +- **接口调用量**: 各接口的QPS统计 +- **存储使用量**: MinIO存储空间使用情况 +- **缓存命中率**: Redis缓存效果监控 +- **错误率统计**: 接口错误率和错误类型分析 + +## 开发指南 + +### 添加新接口 + +1. 在 `docs/textures.proto` 中定义新的RPC方法 +2. 运行 `goctl rpc protoc` 生成代码 +3. 在 `internal/logic/` 中实现业务逻辑 +4. 添加相应的数据库查询方法(如需要) + +### 自定义数据库查询 + +在 `internal/model/texturesModel.go` 中添加自定义查询方法: + +```go +// 在 TexturesModel 接口中添加方法声明 +type TexturesModel interface { + texturesModel + // 自定义方法 + FindByCustomCondition(ctx context.Context, condition string) ([]*Textures, error) +} + +// 在 customTexturesModel 中实现方法 +func (m *customTexturesModel) FindByCustomCondition(ctx context.Context, condition string) ([]*Textures, error) { + query := `SELECT * FROM textures WHERE custom_field = ?` + var resp []*Textures + err := m.QueryRowsNoCacheCtx(ctx, &resp, query, condition) + return resp, err +} +``` + +## 故障排查 + +### 常见问题 + +1. **上传失败**: 检查MinIO连接和存储桶配置 +2. **查询缓慢**: 检查数据库索引和Redis缓存 +3. **文件不一致**: 检查MinIO文件清理逻辑 +4. **权限错误**: 检查用户ID和材质所有权 + +### 调试技巧 + +- 启用详细日志: 设置日志级别为 `debug` +- 检查MinIO状态: 使用MinIO控制台查看文件状态 +- 监控数据库: 使用慢查询日志分析性能问题 \ No newline at end of file diff --git a/src/app/ClientLayout.tsx b/src/app/ClientLayout.tsx new file mode 100644 index 0000000..4ca72fd --- /dev/null +++ b/src/app/ClientLayout.tsx @@ -0,0 +1,21 @@ +'use client'; + +import React from 'react'; +import { SessionProvider } from 'next-auth/react'; +import Navbar from '@/components/Navbar'; + +interface ClientLayoutProps { + children: React.ReactNode; + session: any; +} + +const ClientLayout: React.FC = ({ children, session }) => { + return ( + + +
{children}
+
+ ); +}; + +export default ClientLayout; \ No newline at end of file diff --git a/src/app/api-test/page.tsx b/src/app/api-test/page.tsx new file mode 100644 index 0000000..5de3932 --- /dev/null +++ b/src/app/api-test/page.tsx @@ -0,0 +1,344 @@ +// src/app/api-test/page.tsx +// API连接测试页面 +'use client'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { getProfile, getProfilesByUserId, getProfileWithProperties } from '@/lib/api/profiles'; +import { TextureType, searchTextures } from '@/lib/api/skins'; +import { useSession } from 'next-auth/react'; + +// Mock数据,用于演示 +const mockProfileId = 'b9b3c6d7-e8f9-4a5b-6c7d-8e9f4a5b6c7d'; // 示例UUID +const mockUserId = 1; // 示例用户ID + +// 测试类型枚举 +enum TestType { + PROFILE = 'profile', + USER_PROFILES = 'user-profiles', + PROFILE_PROPS = 'profile-props', + TEXTURES = 'textures', + SESSION = 'session' +} + +// 格式化JSON输出 +const formatJson = (data: any) => { + try { + return JSON.stringify(data, null, 2); + } catch (error) { + return '无法格式化数据'; + } +}; + +export default function APITestPage() { + const { data: session } = useSession(); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(''); + const [error, setError] = useState(''); + const [profileId, setProfileId] = useState(mockProfileId); + const [userId, setUserId] = useState(mockUserId.toString()); + const [selectedTest, setSelectedTest] = useState(TestType.PROFILE); + + // 测试获取单个角色信息 + const testGetProfile = async () => { + setLoading(true); + setError(''); + try { + const data = await getProfile(profileId); + setResult(formatJson(data)); + } catch (err) { + setError(`获取角色信息失败: ${err instanceof Error ? err.message : '未知错误'}`); + console.error('API测试错误:', err); + } finally { + setLoading(false); + } + }; + + // 测试获取用户角色列表 + const testGetProfilesByUserId = async () => { + setLoading(true); + setError(''); + try { + const data = await getProfilesByUserId(parseInt(userId)); + setResult(formatJson(data)); + } catch (err) { + setError(`获取用户角色列表失败: ${err instanceof Error ? err.message : '未知错误'}`); + console.error('API测试错误:', err); + } finally { + setLoading(false); + } + }; + + // 测试获取角色完整信息(带属性) + const testGetProfileWithProperties = async () => { + setLoading(true); + setError(''); + try { + const data = await getProfileWithProperties(profileId, true); + setResult(formatJson(data)); + } catch (err) { + setError(`获取角色完整信息失败: ${err instanceof Error ? err.message : '未知错误'}`); + console.error('API测试错误:', err); + } finally { + setLoading(false); + } + }; + + // 测试搜索公共材质 + const testSearchTextures = async () => { + setLoading(true); + setError(''); + try { + const data = await searchTextures({ + keyword: '', // 空关键词表示获取所有公开皮肤 + type: TextureType.SKIN, + page: 1, + pageSize: 5 + }); + setResult(formatJson(data)); + } catch (err) { + setError(`搜索材质失败: ${err instanceof Error ? err.message : '未知错误'}`); + console.error('API测试错误:', err); + } finally { + setLoading(false); + } + }; + + // 测试当前登录用户信息 + const testSessionUser = () => { + if (session) { + setResult(formatJson({ + authenticated: true, + user: session.user, + sessionExpires: session.expires + })); + } else { + setError('未登录,请先登录后再测试'); + } + }; + + // 处理测试按钮点击 + const handleTestClick = () => { + switch (selectedTest) { + case TestType.PROFILE: + testGetProfile(); + break; + case TestType.USER_PROFILES: + testGetProfilesByUserId(); + break; + case TestType.PROFILE_PROPS: + testGetProfileWithProperties(); + break; + case TestType.TEXTURES: + testSearchTextures(); + break; + case TestType.SESSION: + testSessionUser(); + break; + } + }; + + // 清除结果 + const clearResult = () => { + setResult(''); + setError(''); + }; + + // 渲染测试表单 + const renderTestForm = () => { + switch (selectedTest) { + case TestType.PROFILE: + return ( +
+
+ + setProfileId(e.target.value)} + placeholder="输入角色UUID" + /> +
+

+ 测试是否能获取指定UUID的角色信息。如果后端没有该UUID的数据,可能会返回错误。 +

+
+ ); + case TestType.USER_PROFILES: + return ( +
+
+ + setUserId(e.target.value)} + placeholder="输入用户ID" + type="number" + /> +
+

+ 测试是否能获取指定用户ID的所有角色列表。 +

+
+ ); + case TestType.PROFILE_PROPS: + return ( +
+
+ + setProfileId(e.target.value)} + placeholder="输入角色UUID" + /> +
+

+ 测试是否能获取角色的完整信息,包括Minecraft协议兼容的属性。 +

+
+ ); + case TestType.TEXTURES: + return ( +
+

+ 测试是否能搜索公共皮肤材质数据。 +

+
+ ); + case TestType.SESSION: + return ( +
+

+ 查看当前登录用户的会话信息。 +

+
+ ); + default: + return null; + } + }; + + return ( +
+
+
+

API连接测试

+

+ 测试与后端API的连接和数据读取功能 +

+
+ + + + 测试工具 + + + {/* 测试类型选择 */} +
+ +
+ + + + + +
+
+ + {/* 测试表单 */} +
+ {renderTestForm()} +
+ + {/* 操作按钮 */} +
+ + +
+ + {/* 结果显示区域 */} +
+ + + {error && ( + + 错误 + {error} + + )} + + {result ? ( +
+ {result} +
+ ) : ( +
+

点击"开始测试"按钮查看结果

+
+ )} +
+
+
+ + {/* 帮助信息 */} +
+ + 测试说明 + +
+ + 如果测试成功,您将在结果区域看到从后端获取的数据 +
+
+ + 如果测试失败,您将看到相应的错误信息 +
+
+ + 对于需要认证的API,请确保您已经登录 +
+
+ + 测试结果中的数据是实时从后端API获取的 +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/api-tester/page.tsx b/src/app/api-tester/page.tsx new file mode 100644 index 0000000..8fc261a --- /dev/null +++ b/src/app/api-tester/page.tsx @@ -0,0 +1,401 @@ +'use client'; +import React, { useState, useRef } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; + +// 定义日志条目类型 +interface LogEntry { + type: 'info' | 'success' | 'error'; + message: string; +} + +const APITesterPage: React.FC = () => { + const [logs, setLogs] = useState([]); + const [isTesting, setIsTesting] = useState(false); + const logsEndRef = useRef(null); + + // 自定义测试参数 + const [customApiEndpoint, setCustomApiEndpoint] = useState('/api/textures'); + const [customUserId, setCustomUserId] = useState('1'); + const [customProfileId, setCustomProfileId] = useState('1'); + + // 验证码测试参数 + const [emailForVerification, setEmailForVerification] = useState(''); + const [testMethod, setTestMethod] = useState<'GET' | 'POST'>('GET'); + + // 添加日志条目 + const addLog = (type: 'info' | 'success' | 'error', message: string) => { + setLogs(prevLogs => [...prevLogs, { type, message }]); + }; + + // 滚动到最新日志 + const scrollToBottom = () => { + logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + // 执行API测试 + const runAPITests = async () => { + // 重置状态 + setLogs([]); + setIsTesting(true); + + // API端点配置 + const apiEndpoints = [ + { + name: '材质列表', + url: '/api/textures', + method: 'GET', + requiresAuth: false + }, + { + name: '用户角色列表', + url: `/api/user-profiles?userId=${customUserId}`, + method: 'GET', + requiresAuth: true + }, + { + name: '角色详情', + url: `/api/profile?profileId=${customProfileId}`, + method: 'GET', + requiresAuth: true + }, + { + name: '角色及属性', + url: `/api/profile-props?profileId=${customProfileId}`, + method: 'GET', + requiresAuth: true + } + ]; + + addLog('info', '开始API测试...'); + + const results = []; + + for (const endpoint of apiEndpoints) { + try { + addLog('info', `\n测试: ${endpoint.name}`); + addLog('info', `请求: ${endpoint.url}`); + + const startTime = Date.now(); + + // 构建请求选项 + const options = { + method: endpoint.method, + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include' // 包含cookie以处理认证 + }; + + // 发送请求 + const response = await fetch(endpoint.url, { + ...options, + credentials: options.credentials as RequestCredentials + }); + const endTime = Date.now(); + + // 记录响应状态 + addLog('info', `状态码: ${response.status}`); + addLog('info', `响应时间: ${endTime - startTime}ms`); + + // 解析响应内容 + const contentType = response.headers.get('content-type'); + let data = null; + + if (contentType && contentType.includes('application/json')) { + try { + data = await response.json(); + addLog('info', `响应数据大小: ${JSON.stringify(data).length} 字节`); + + // 简单验证响应数据结构 + if (Array.isArray(data)) { + addLog('info', `返回数组,长度: ${data.length}`); + // 如果数组长度适中,可以显示第一条数据示例 + if (data.length > 0 && data.length <= 10) { + addLog('success', `数据示例: ${JSON.stringify(data[0], null, 2).substring(0, 100)}...`); + } + } else if (typeof data === 'object' && data !== null) { + addLog('info', `返回对象,包含属性: ${Object.keys(data).join(', ')}`); + // 显示部分数据示例 + addLog('success', `数据示例: ${JSON.stringify(data, null, 2).substring(0, 100)}...`); + } + } catch (jsonError) { + addLog('error', `JSON解析错误: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`); + } + } else { + const text = await response.text(); + addLog('info', `返回非JSON数据,长度: ${text.length} 字节`); + } + + // 记录测试结果 + results.push({ + name: endpoint.name, + status: '成功', + statusCode: response.status + }); + + addLog('success', '测试通过!'); + + } catch (error) { + addLog('error', `测试失败: ${error instanceof Error ? error.message : String(error)}`); + + // 记录错误结果 + results.push({ + name: endpoint.name, + status: '失败', + error: error instanceof Error ? error.message : String(error) + }); + } + } + + // 输出测试总结 + addLog('info', '\n======== 测试总结 ========'); + + const successCount = results.filter(r => r.status === '成功').length; + const failureCount = results.filter(r => r.status === '失败').length; + + addLog('info', `总测试数: ${results.length}`); + addLog('success', `成功: ${successCount}`); + addLog('error', `失败: ${failureCount}`); + + if (failureCount > 0) { + addLog('info', '\n失败的测试:'); + results.filter(r => r.status === '失败').forEach(r => { + addLog('error', `- ${r.name}: ${r.error}`); + }); + } + + addLog('info', '\n测试完成!'); + setIsTesting(false); + + // 滚动到底部 + setTimeout(() => { + scrollToBottom(); + }, 100); + }; + + // 执行单个自定义API测试 + const runCustomAPITest = async () => { + setLogs([{ type: 'info', message: `开始自定义API测试: ${customApiEndpoint}` }]); + setIsTesting(true); + + try { + const startTime = Date.now(); + + // API网关基础URL + const API_BASE_URL = 'https://code.littlelan.cn/CarrotSkin/APIgateway'; + + // 构建完整的请求URL + const fullUrl = customApiEndpoint.startsWith('http') ? customApiEndpoint : `${API_BASE_URL}${customApiEndpoint}`; + addLog('info', `完整URL: ${fullUrl}`); + + // 构建请求选项 + const requestOptions: RequestInit = { + method: testMethod, + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include' + }; + + // 如果是POST请求且是验证码测试,添加请求体 + if (testMethod === 'POST' && fullUrl.includes('/auth/send-code')) { + requestOptions.body = JSON.stringify({ email: emailForVerification }); + addLog('info', `请求体: {"email": "${emailForVerification}"}`); + } + + const response = await fetch(fullUrl, requestOptions); + const endTime = Date.now(); + + addLog('info', `状态码: ${response.status}`); + addLog('info', `响应时间: ${endTime - startTime}ms`); + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + try { + const data = await response.json(); + addLog('info', `响应数据大小: ${JSON.stringify(data).length} 字节`); + addLog('success', `数据: ${JSON.stringify(data, null, 2).substring(0, 300)}...`); + } catch (jsonError) { + addLog('error', `JSON解析错误: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`); + } + } else { + const text = await response.text(); + addLog('info', `返回非JSON数据: ${text.substring(0, 300)}...`); + } + + addLog('success', '自定义API测试完成!'); + } catch (error) { + addLog('error', `测试失败: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsTesting(false); + + // 滚动到底部 + setTimeout(() => { + scrollToBottom(); + }, 100); + } + }; + + return ( +
+

API连接测试工具

+ + + + 验证码发送测试 + + 测试验证码发送API是否正常工作 + + + +
+
+ + setEmailForVerification(e.target.value)} + placeholder="输入您的邮箱地址" + className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-200 hover:border-gray-400" + /> +

将向此邮箱发送验证码测试

+
+ + + +
+

+ 注意:此测试将调用后端API网关(https://code.littlelan.cn/CarrotSkin/APIgateway)发送验证码, + 请确保输入有效的邮箱地址以接收验证码。 +

+
+
+
+
+ +
+ + + 测试所有API连接 + + 点击下方按钮运行全部API测试,查看系统与后端的连接状态 + + + +
+
+ + setCustomUserId(e.target.value)} + placeholder="输入用户ID" + className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-200 hover:border-gray-400" + /> +
+
+ + setCustomProfileId(e.target.value)} + placeholder="输入角色ID" + className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-200 hover:border-gray-400" + /> +
+
+ + +
+
+
+ +
+ + + 自定义API测试 + + 输入API端点路径,测试特定的API接口 + + + +
+
+ + setCustomApiEndpoint(e.target.value)} + placeholder="例如: /api/textures" + className="md:col-span-3 px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-200 hover:border-gray-400" + /> +
+ + +
+
+
+
+ +
+ + + 测试结果 + + API测试的详细日志和结果 + + + +
+
+ {logs.map((log, index) => ( +
+ {log.message} +
+ ))} +
+
+
+ + +
+
+ ); +}; + +export default APITesterPage; \ No newline at end of file diff --git a/src/app/character-center/CharacterCenterClient.tsx b/src/app/character-center/CharacterCenterClient.tsx index 077b45a..94fb64b 100644 --- a/src/app/character-center/CharacterCenterClient.tsx +++ b/src/app/character-center/CharacterCenterClient.tsx @@ -1,15 +1,42 @@ 'use client'; -import { useState } from 'react'; + + +import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import Canvas2DSkinPreview from '@/components/skins/Canvas2DSkinPreview'; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import Link from 'next/link'; +import { useSession } from 'next-auth/react'; + +// 导入角色服务API +import { + createProfile, + getProfilesByUserId, + updateProfile, + deleteProfile, + getProfileWithProperties +} from '@/lib/api/profiles'; + +// 角色类型定义 +interface Character { + id: string; + name: string; + skinId?: string | null; + capeId?: string | null; + created: string; + level: number; + description?: string; + isActive: boolean; + uuid: string; + userId: number; +} // 角色卡片组件 -function CharacterCard({ character }: { character: any }) { +function CharacterCard({ character, onEdit, onDelete }: { character: Character, onEdit: (character: Character) => void, onDelete: (uuid: string) => void }) { return ( {/* 皮肤预览区域 */} @@ -21,7 +48,7 @@ function CharacterCard({ character }: { character: any }) { {/* 皮肤预览 - 使用Canvas2DSkinPreview组件 */}
@@ -39,20 +66,28 @@ function CharacterCard({ character }: { character: any }) {

- 等级 {character.level} + {character.isActive ? '活跃' : '非活跃'}
{/* 额外的角色信息 */}
- - - 活跃状态 - - 皮肤ID: {character.skinId} + UUID: {character.uuid.substring(0, 8)}... + {character.skinId && ( + + + 皮肤ID: {character.skinId} + + )} + {character.capeId && ( + + + 披风ID: {character.capeId} + + )}
@@ -61,10 +96,24 @@ function CharacterCard({ character }: { character: any }) { - - @@ -77,21 +126,23 @@ function CharacterCard({ character }: { character: any }) { function AddCharacterCard({ onAddClick }: { onAddClick: () => void }) { return ( -
-
+
- 添加新角色 -
+ +
+ + +
+

添加新角色

+

+ 点击创建你的下一个角色 +

+
); } - - -// 主客户端组件 -export default function CharacterCenterClient({ userName, characters }: { userName: string; characters: any[] }) { +export default function CharacterCenterClient({ userName }: { userName: string }) { // 使用简单的状态管理来模拟标签页 const [activeTab, setActiveTab] = useState('my-characters'); @@ -104,24 +155,56 @@ export default function CharacterCenterClient({ userName, characters }: { userNa isActive: true, }); - // 生成UUID的简单方法(不使用外部库) - const generateUUID = () => { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0; - const v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); + // 编辑模式状态 + const [editingCharacterId, setEditingCharacterId] = useState(null); + + // 角色列表状态 + const [characters, setCharacters] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + // 获取会话信息 + const { data: session } = useSession(); + + // 初始化时获取角色列表 + useEffect(() => { + if (session && session.user?.id) { + fetchUserCharacters(parseInt(session.user.id)); + } + }, [session]); + + // 获取用户角色列表 + const fetchUserCharacters = async (userId: number) => { + setIsLoading(true); + setError(null); + try { + const profiles = await getProfilesByUserId(userId); + + // 格式化角色数据以匹配前端需求 + const formattedCharacters: Character[] = profiles.map((profile: any) => ({ + id: profile.uuid, + uuid: profile.uuid, + name: profile.name, + skinId: profile.skinId != null ? String(profile.skinId) : null, + capeId: profile.capeId != null ? String(profile.capeId) : null, + created: new Date(profile.createdAt).toLocaleDateString(), + level: 1, // 简化处理,实际应用可能需要从其他服务获取 + description: '', // 简化处理 + isActive: true, // 简化处理 + userId: profile.userId + })); + + setCharacters(formattedCharacters); + } catch (err) { + console.error('获取角色列表失败:', err); + setError('获取角色列表失败,请稍后重试'); + } finally { + setIsLoading(false); + } }; - // 生成RSA密钥的简单模拟(实际项目中应在服务器端生成) - const generateRSAKey = () => { - // 模拟RSA私钥(实际项目中应使用加密库生成) - return `-----BEGIN RSA PRIVATE KEY----- -MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY ------END RSA PRIVATE KEY-----`; - }; - - // 处理表单输入变化 + // 表单输入处理 const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setCharacterForm(prev => ({ @@ -130,7 +213,7 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY })); }; - // 处理复选框变化 + // 复选框处理 const handleCheckboxChange = (e: React.ChangeEvent) => { setCharacterForm(prev => ({ ...prev, @@ -138,61 +221,188 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY })); }; - // 处理皮肤选择 + // 皮肤选择处理 const handleSkinSelect = (skinId: string) => { setCharacterForm(prev => ({ ...prev, - skinId + skinId: prev.skinId === skinId ? '' : skinId })); }; + // 处理角色编辑 + const handleEditCharacter = (character: Character) => { + // 设置表单为编辑模式 + setCharacterForm({ + name: character.name, + description: character.description || '', + skinId: character.skinId || '', + capeId: character.capeId || '', + isActive: character.isActive, + }); + + // 设置编辑中的角色ID + setEditingCharacterId(character.uuid); + + // 切换到创建/编辑标签 + setActiveTab('create-character'); + + // 滚动到表单顶部 + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + // 处理角色删除 + const handleDeleteCharacter = async (uuid: string) => { + if (!confirm('确定要删除这个角色吗?此操作不可撤销。')) { + return; + } + + setIsLoading(true); + setError(null); + try { + await deleteProfile(uuid); + + // 从本地角色列表中移除 + setCharacters(prev => prev.filter(character => character.uuid !== uuid)); + setSuccessMessage('角色删除成功!'); + + // 3秒后清除成功消息 + setTimeout(() => { + setSuccessMessage(null); + }, 3000); + + } catch (err) { + console.error('删除角色失败:', err); + setError('删除角色失败,请稍后重试'); + } finally { + setIsLoading(false); + } + }; + + // 处理角色更新 + const handleUpdateCharacter = async () => { + if (!characterForm.name.trim()) { + setError('请输入角色名称'); + return; + } + + if (!editingCharacterId) { + setError('未找到要更新的角色'); + return; + } + + setIsLoading(true); + setError(null); + try { + // 调用API更新角色 + const updatedProfile = await updateProfile(editingCharacterId, { + name: characterForm.name.trim(), + description: characterForm.description, + skinId: characterForm.skinId != null && characterForm.skinId !== '' ? String(characterForm.skinId) : null, + capeId: characterForm.capeId != null && characterForm.capeId !== '' ? String(characterForm.capeId) : null, + isActive: characterForm.isActive + }); + + // 更新本地角色列表 + setCharacters(prev => prev.map(character => + character.uuid === editingCharacterId + ? { + ...character, + name: updatedProfile.name, + description: characterForm.description, + skinId: characterForm.skinId != null && characterForm.skinId !== '' ? String(characterForm.skinId) : null, + capeId: characterForm.capeId != null && characterForm.capeId !== '' ? String(characterForm.capeId) : null, + isActive: characterForm.isActive + } + : character + )); + + setSuccessMessage('角色更新成功!'); + + // 重置表单和编辑状态 + resetForm(); + setEditingCharacterId(null); + + // 切换回我的角色标签 + setActiveTab('my-characters'); + + // 3秒后清除成功消息 + setTimeout(() => { + setSuccessMessage(null); + }, 3000); + + } catch (err) { + console.error('更新角色失败:', err); + setError('更新角色失败,请稍后重试'); + } finally { + setIsLoading(false); + } + }; + + // 重置表单函数 + const resetForm = () => { + setCharacterForm({ + name: '', + description: '', + skinId: '', + capeId: '', + isActive: true, + }); + }; + // 处理角色创建 const handleCreateCharacter = async () => { if (!characterForm.name.trim()) { - alert('请输入角色名称'); + setError('请输入角色名称'); return; } + if (!session || !session.user?.id) { + setError('请先登录'); + return; + } + + setIsLoading(true); + setError(null); try { - // 生成角色UUID - const characterUuid = generateUUID(); + // 调用API创建角色 + const createdProfile = await createProfile({ + name: characterForm.name.trim(), + // UUID由服务端生成 + }); - // 生成RSA私钥 - const rsaPrivateKey = generateRSAKey(); - - // 构建角色数据(根据数据库profiles表结构) - const newCharacter = { - uuid: characterUuid, - user_id: 'current-user-id', // 应从会话中获取真实用户ID - name: characterForm.name, - skin_id: characterForm.skinId || null, - cape_id: characterForm.capeId || null, - rsa_private_key: rsaPrivateKey, - is_active: characterForm.isActive, - description: characterForm.description + // 添加到本地角色列表 + const newCharacter: Character = { + id: createdProfile.uuid, + uuid: createdProfile.uuid, + name: createdProfile.name, + skinId: createdProfile.skinId != null ? String(createdProfile.skinId) : null, + capeId: createdProfile.capeId != null ? String(createdProfile.capeId) : null, + created: new Date(createdProfile.createdAt).toLocaleDateString(), + level: 1, + description: characterForm.description, + isActive: characterForm.isActive, + userId: createdProfile.userId }; - // 这里应该调用API发送到服务器 - console.log('创建角色数据:', newCharacter); - - // 模拟成功响应 - alert('角色创建成功!\nUUID: ' + characterUuid.substring(0, 8) + '...'); + setCharacters(prev => [newCharacter, ...prev]); + setSuccessMessage('角色创建成功!'); // 重置表单 - setCharacterForm({ - name: '', - description: '', - skinId: '', - capeId: '', - isActive: true, - }); + resetForm(); // 切换回我的角色标签 setActiveTab('my-characters'); - } catch (error) { - console.error('创建角色失败:', error); - alert('创建角色失败,请重试'); + // 3秒后清除成功消息 + setTimeout(() => { + setSuccessMessage(null); + }, 3000); + + } catch (err) { + console.error('创建角色失败:', err); + setError('创建角色失败,请稍后重试'); + } finally { + setIsLoading(false); } }; @@ -219,20 +429,49 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY {/* 角色管理标签页 */}
+ {/* 消息提示区域 */} + {error && ( + + 错误 + {error} + + )} + + {successMessage && ( + + 成功 + {successMessage} + + )} +
{/* 高级标签按钮组 */}
@@ -244,11 +483,42 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY {/* 我的角色标签内容 */} {activeTab === 'my-characters' && (
+ {isLoading && ( +
+
+
+ )} +
{/* 角色卡片列表 */} - {characters.map((character) => ( - - ))} + {characters.length > 0 ? ( + characters.map((character) => ( + + )) + ) : ( + !isLoading && ( + + +
🎮
+

你还没有创建任何角色

+

+ 点击下方按钮创建你的第一个Minecraft角色 +

+ +
+
+ ) + )} {/* 添加新角色卡片 */} setActiveTab('create-character')} /> @@ -257,123 +527,150 @@ MOCK-RSA-KEY-FOR-DEMO-PURPOSES-ONLY )} {/* 创建角色标签内容 */} - {activeTab === 'create-character' && ( -
-

创建新角色

-
-
-
- - -

- 角色名称将作为Minecraft游戏内的用户名,请确保唯一性 -

-
- -
- -
-
handleSkinSelect('skin1')} - > - +

{editingCharacterId ? '编辑角色' : '创建新角色'}

+
+
+
+ + - {characterForm.skinId === 'skin1' && ( -
- )} +

+ 角色名称将作为Minecraft游戏内的用户名,请确保唯一性 +

-
handleSkinSelect('skin2')} - > - - {characterForm.skinId === 'skin2' && ( -
- )} + +
+ +
+
handleSkinSelect('skin1')} + aria-disabled={isLoading} + > + + {characterForm.skinId === 'skin1' && ( +
+ )} +
+
handleSkinSelect('skin2')} + aria-disabled={isLoading} + > + + {characterForm.skinId === 'skin2' && ( +
+ )} +
+
handleSkinSelect('skin3')} + aria-disabled={isLoading} + > + + {characterForm.skinId === 'skin3' && ( +
+ )} +
+
-
handleSkinSelect('skin3')} - > - + +