18 Commits

Author SHA1 Message Date
WuYuuuub
1ba0e6b2f0 添加了后台管理接口,包括用户、角色、材质管理,管理员可进行删除、查找、设定状态操作,详情在README
Some checks failed
Build / build (pull_request) Successful in 4m14s
Build / build-docker (pull_request) Failing after 3m11s
2026-01-23 00:32:27 +08:00
WuYuuuub
9219e8c6ea 添加了更严格的邮箱格式检查
Some checks failed
Build / build (pull_request) Successful in 4m53s
Build / build-docker (pull_request) Failing after 2m0s
2026-01-14 15:39:38 +08:00
WuYuuuub
133c46c086 添加 HTML 转义,防止邮件内容中的 HTML 注入攻击
All checks were successful
Build / build (pull_request) Successful in 5m5s
Build / build-docker (pull_request) Successful in 3m25s
2026-01-13 18:41:35 +08:00
lafay
3e8b7d150d chore: Refactor Dockerfile and build workflow for improved efficiency
All checks were successful
Build / build (push) Successful in 4m4s
Build / build-docker (push) Successful in 1m16s
- Removed the build stage from the Dockerfile, simplifying the image creation process.
- Updated the Dockerfile to directly copy the pre-built binary instead of using a multi-stage build.
- Modified the build workflow to eliminate unnecessary build arguments, streamlining the configuration.
2026-01-10 05:21:45 +08:00
lafay
fd5a0e8405 chore: Update Docker image tags in build workflow
Some checks failed
Build / build (push) Successful in 3m57s
Build / build-docker (push) Has been cancelled
- Changed Docker image tags in the build workflow to reflect the new repository owner.
- Updated the image references to use 'carrotskin' instead of the previous owner for consistency.
2026-01-10 05:12:51 +08:00
lafay
573c10ed1d chore: Remove Swagger documentation generation from build workflow
Some checks failed
Build / build (push) Successful in 4m0s
Build / build-docker (push) Failing after 41s
- Eliminated the Swagger documentation generation step from the build process.
- Updated the main server file to remove the Swagger documentation import, streamlining the codebase.
2026-01-10 05:00:09 +08:00
lafay
3b8d8bd7a7 chore: Add Swagger documentation generation to build workflow
Some checks failed
Build / build (push) Failing after 55s
Build / build-docker (push) Has been skipped
- Included a step to generate Swagger documentation during the build process.
- This addition enhances API documentation and ensures it is up-to-date with the codebase.
2026-01-10 04:57:56 +08:00
lafay
6338592d27 chore: Remove Go proxy setup from build workflow
Some checks failed
Build / build (push) Failing after 3m8s
Build / build-docker (push) Has been skipped
- Eliminated the Go proxy configuration from the build workflow to streamline the setup process.
- This change simplifies the environment setup for dependency management.
2026-01-10 04:47:14 +08:00
lafay
ef460ec891 chore: Disable caching in Go setup for build workflow
Some checks failed
Build / build (push) Failing after 1m31s
Build / build-docker (push) Has been skipped
- Updated the build workflow to disable caching for the Go setup action.
- This change aims to ensure a clean build environment for better consistency.
2026-01-10 04:42:22 +08:00
lafay
62d9432a2d chore: Add Go proxy setup to build workflow
Some checks failed
Build / build-docker (push) Has been cancelled
Build / build (push) Has been cancelled
- Configured Go proxy settings for improved dependency management.
- Disabled Go checksum database for local development environments.
2026-01-10 04:39:11 +08:00
lafay
e1d79ed445 chore: Update build workflow to include 'dev' branch
Some checks failed
Build / build (push) Failing after 21m16s
Build / build-docker (push) Has been skipped
- Added 'dev' branch to the push and pull_request triggers in the build workflow configuration.
- Ensured that CI/CD processes are aligned for both master and dev branches.
2026-01-10 04:01:20 +08:00
lafay
c5d7e317a4 refactor: Streamline user information retrieval and validation
- Refactored the user information retrieval process to improve efficiency.
- Enhanced validation logic for input parameters in the user handler.
- Updated UserService interface to support new retrieval methods.
- Improved error handling for user status checks before responding.
2026-01-10 03:58:22 +08:00
lafay
06539dc086 feat: Add public user information retrieval endpoint
- Introduced a new endpoint to fetch public user information without authentication.
- Implemented UserToPublicUserInfo function to format user data for the response.
- Updated UserService interface and user service implementation to support fetching users by username.
- Enhanced user handler to validate input parameters and check user status before responding.
2026-01-10 03:52:35 +08:00
lafay
22142db782 fix: Improve texture upload handling and caching logic
- Simplified caching logic by removing unnecessary nil check before setting cache.
- Enhanced error handling in texture upload process to return the original texture object if fetching the uploader information fails or returns nil.
2026-01-10 03:23:26 +08:00
lafay
2c9c6ecfc0 Merge branch 'dev' of https://code.littlelan.cn/CarrotSkin/backend into dev 2026-01-10 03:17:39 +08:00
lafay
c5db489d72 refactor: Enhance texture handling and configuration
- Removed Swagger documentation import from the main server file.
- Updated TextureInfo struct to include UploaderUsername for better texture metadata.
- Modified texture repository methods to preload Uploader information when fetching textures by hash.
- Improved texture service to handle cases where Uploader information is missing, ensuring proper caching and retrieval.
- Added Redis configuration options in the environment variable setup for better flexibility.
2026-01-10 03:15:27 +08:00
d952ddd4ea 增加环境变量是否成功读取的检查,并在控制台中打印日志 2026-01-10 01:58:46 +08:00
e761ff5be5 移除预签名url相关实现 2026-01-04 16:08:23 +08:00
19 changed files with 1081 additions and 138 deletions

View File

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

35
Dockerfile Normal file
View File

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

134
README.md
View File

@@ -144,6 +144,140 @@ golangci-lint run (若已安装)
- 若手动运行,需要保证 `docs/` 下的 `docs.go`、`swagger.json`、`swagger.yaml` 与代码同步
- 通过 `SERVER_SWAGGER_ENABLED=false` 可在生产环境关闭 Swagger UI 暴露
## 🔐 管理后台 API
管理后台接口均需要管理员权限(`role=admin`),所有接口路径前缀为 `/api/v1/admin`。
### 📊 统计信息
| 接口 | 方法 | 说明 |
| --- | --- | --- |
| `/stats` | GET | 获取系统统计数据(用户数、材质数、下载量等) |
**响应示例:**
```json
{
"code": 0,
"message": "success",
"data": {
"total_users": 100,
"active_users": 80,
"banned_users": 5,
"admin_users": 3,
"total_textures": 500,
"public_textures": 300,
"pending_textures": 10,
"total_downloads": 1000,
"total_favorites": 500
}
}
```
### 👥 角色管理
| 接口 | 方法 | 说明 |
| --- | --- | --- |
| `/roles` | GET | 获取所有可用角色列表 |
**响应示例:**
```json
{
"code": 0,
"message": "success",
"data": {
"roles": [
{
"name": "user",
"display_name": "普通用户",
"description": "拥有基本用户权限"
},
{
"name": "admin",
"display_name": "管理员",
"description": "拥有所有管理权限"
}
]
}
}
```
### 👤 用户管理
| 接口 | 方法 | 说明 |
| --- | --- | --- |
| `/users` | GET | 获取用户列表(分页) |
| `/users/search` | GET | 搜索用户(支持关键词、角色、状态筛选、排序) |
| `/users/{id}` | GET | 获取用户详情 |
| `/users/{id}` | DELETE | 删除用户(软删除) |
| `/users/role` | PUT | 设置单个用户角色 |
| `/users/status` | PUT | 设置单个用户状态(封禁/解封) |
| `/users/batch-role` | PUT | 批量设置用户角色 |
| `/users/batch-delete` | DELETE | 批量删除用户 |
**搜索用户请求参数:**
- `keyword` (string): 搜索关键词(用户名或邮箱)
- `role` (string): 角色筛选
- `status` (int): 状态筛选1=正常0=禁用,-1=删除)
- `sort_by` (string): 排序字段
- `sort_desc` (bool): 是否降序
- `page` (int): 页码
- `page_size` (int): 每页数量
**设置用户状态请求示例:**
```json
{
"user_id": 123,
"status": 0
}
```
- `status`: 1=正常0=禁用,-1=删除
**批量设置角色请求示例:**
```json
{
"user_ids": [1, 2, 3],
"role": "admin"
}
```
### 🎨 材质管理
| 接口 | 方法 | 说明 |
| --- | --- | --- |
| `/textures` | GET | 获取材质列表(分页) |
| `/textures/search` | GET | 搜索材质(支持关键词、类型、状态、上传者筛选、排序) |
| `/textures/{id}` | PUT | 更新材质信息(名称、描述、公开状态、审核状态) |
| `/textures/{id}` | DELETE | 删除材质 |
| `/textures/batch-delete` | DELETE | 批量删除材质 |
**搜索材质请求参数:**
- `keyword` (string): 搜索关键词
- `type` (string): 材质类型SKIN/CAPE
- `status` (int): 状态筛选
- `uploader_id` (int): 上传者ID筛选
- `sort_by` (string): 排序字段
- `sort_desc` (bool): 是否降序
- `page` (int): 页码
- `page_size` (int): 每页数量
**更新材质请求示例:**
```json
{
"name": "新皮肤名称",
"description": "新描述",
"is_public": true,
"status": 1
}
```
### 🔒 安全特性
1. **权限保护**:所有管理接口都需要管理员权限(`role=admin`
2. **安全限制**:管理员不能修改/删除自己的角色和状态
3. **批量操作**:支持批量设置角色和批量删除
4. **操作日志**:所有管理操作都会记录日志
5. **封禁功能**:通过 `/users/status` 接口可以封禁/解封用户
## 🤝 贡献指南
1. Fork & Clone

View File

@@ -32,8 +32,6 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
_ "carrotskin/docs" // Swagger docs
)
func main() {

2
go.mod
View File

@@ -112,7 +112,7 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggo/swag v1.16.6
github.com/swaggo/swag v1.16.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.uber.org/multierr v1.11.0 // indirect

View File

@@ -6,6 +6,7 @@ import (
"carrotskin/internal/container"
"carrotskin/internal/model"
"carrotskin/internal/types"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
@@ -365,18 +366,527 @@ func (h *AdminHandler) GetTextureList(c *gin.Context) {
}))
}
// GetPermissions 获取权限列表
// @Summary 获取权限列表
// @Description 管理员获取所有Casbin权限规则
// SearchUsers 搜索用户
// @Summary 搜索用户
// @Description 管理员根据条件搜索用户
// @Tags Admin
// @Produce json
// @Success 200 {object} model.Response{data=map[string]interface{}} "获取成功"
// @Param keyword query string false "搜索关键词(用户名或邮箱)"
// @Param role query string false "角色筛选"
// @Param status query int false "状态筛选"
// @Param sort_by query string false "排序字段"
// @Param sort_desc query bool false "是否降序"
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Success 200 {object} model.Response{data=map[string]interface{}} "搜索成功"
// @Security BearerAuth
// @Router /api/v1/admin/permissions [get]
func (h *AdminHandler) GetPermissions(c *gin.Context) {
// 获取所有权限规则
policies, _ := h.container.Casbin.GetEnforcer().GetPolicy()
// @Router /api/v1/admin/users/search [get]
func (h *AdminHandler) SearchUsers(c *gin.Context) {
var req types.AdminUserSearchRequest
if err := c.ShouldBindQuery(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
if req.Page < 1 {
req.Page = 1
}
if req.PageSize < 1 || req.PageSize > 100 {
req.PageSize = 20
}
db := h.container.DB.Model(&model.User{})
// 关键词搜索
if req.Keyword != "" {
db = db.Where("username LIKE ? OR email LIKE ?", "%"+req.Keyword+"%", "%"+req.Keyword+"%")
}
// 角色筛选
if req.Role != "" {
db = db.Where("role = ?", req.Role)
}
// 状态筛选
if req.Status != nil {
db = db.Where("status = ?", *req.Status)
}
// 排序
sortBy := "id"
if req.SortBy != "" {
sortBy = req.SortBy
}
order := sortBy
if req.SortDesc {
order += " DESC"
}
db = db.Order(order)
// 分页
var total int64
db.Count(&total)
var users []model.User
db.Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&users)
// 构建响应
userList := make([]gin.H, len(users))
for i, u := range users {
userList[i] = gin.H{
"id": u.ID,
"username": u.Username,
"email": u.Email,
"avatar": u.Avatar,
"role": u.Role,
"status": u.Status,
"points": u.Points,
"last_login_at": u.LastLoginAt,
"created_at": u.CreatedAt,
}
}
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"policies": policies,
"users": userList,
"total": total,
"page": req.Page,
"page_size": req.PageSize,
}))
}
// DeleteUser 删除用户
// @Summary 删除用户
// @Description 管理员删除用户(软删除)
// @Tags Admin
// @Produce json
// @Param id path int true "用户ID"
// @Success 200 {object} model.Response{data=map[string]interface{}} "删除成功"
// @Failure 400 {object} model.ErrorResponse "不能删除自己"
// @Failure 404 {object} model.ErrorResponse "用户不存在"
// @Security BearerAuth
// @Router /api/v1/admin/users/{id} [delete]
func (h *AdminHandler) DeleteUser(c *gin.Context) {
userID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的用户ID", err)
return
}
operatorID, _ := c.Get("user_id")
// 不能删除自己
if userID == operatorID.(int64) {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"不能删除自己",
nil,
))
return
}
// 检查用户是否存在
user, err := h.container.UserRepo.FindByID(c.Request.Context(), userID)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"用户不存在",
nil,
))
return
}
// 软删除
if err := h.container.DB.Delete(user).Error; err != nil {
RespondServerError(c, "删除用户失败", err)
return
}
h.container.Logger.Info("管理员删除用户",
zap.Int64("operator_id", operatorID.(int64)),
zap.Int64("deleted_user_id", userID),
zap.String("username", user.Username),
)
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "用户删除成功",
"user_id": userID,
}))
}
// BatchSetUserRole 批量设置用户角色
// @Summary 批量设置用户角色
// @Description 管理员批量设置多个用户的角色
// @Tags Admin
// @Accept json
// @Produce json
// @Param request body map[string]interface{} true "批量设置请求"
// @Success 200 {object} model.Response{data=map[string]interface{}} "设置成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Security BearerAuth
// @Router /api/v1/admin/users/batch-role [put]
func (h *AdminHandler) BatchSetUserRole(c *gin.Context) {
var req struct {
UserIDs []int64 `json:"user_ids" binding:"required,min=1"`
Role string `json:"role" binding:"required,oneof=user admin"`
}
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
operatorID, _ := c.Get("user_id")
// 检查是否包含自己
for _, uid := range req.UserIDs {
if uid == operatorID.(int64) {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"不能修改自己的角色",
nil,
))
return
}
}
// 批量更新
if err := h.container.DB.Model(&model.User{}).
Where("id IN ?", req.UserIDs).
Update("role", req.Role).Error; err != nil {
RespondServerError(c, "批量更新角色失败", err)
return
}
h.container.Logger.Info("管理员批量设置用户角色",
zap.Int64("operator_id", operatorID.(int64)),
zap.Int("count", len(req.UserIDs)),
zap.String("role", req.Role),
)
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "批量设置角色成功",
"count": len(req.UserIDs),
"role": req.Role,
}))
}
// BatchDeleteUsers 批量删除用户
// @Summary 批量删除用户
// @Description 管理员批量删除多个用户
// @Tags Admin
// @Accept json
// @Produce json
// @Param request body map[string]interface{} true "批量删除请求"
// @Success 200 {object} model.Response{data=map[string]interface{}} "删除成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Security BearerAuth
// @Router /api/v1/admin/users/batch-delete [delete]
func (h *AdminHandler) BatchDeleteUsers(c *gin.Context) {
var req struct {
UserIDs []int64 `json:"user_ids" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
operatorID, _ := c.Get("user_id")
// 检查是否包含自己
for _, uid := range req.UserIDs {
if uid == operatorID.(int64) {
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
model.CodeBadRequest,
"不能删除自己",
nil,
))
return
}
}
// 批量软删除
if err := h.container.DB.Where("id IN ?", req.UserIDs).Delete(&model.User{}).Error; err != nil {
RespondServerError(c, "批量删除用户失败", err)
return
}
h.container.Logger.Info("管理员批量删除用户",
zap.Int64("operator_id", operatorID.(int64)),
zap.Int("count", len(req.UserIDs)),
)
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "批量删除用户成功",
"count": len(req.UserIDs),
}))
}
// GetRoles 获取角色列表
// @Summary 获取角色列表
// @Description 管理员获取所有可用角色
// @Tags Admin
// @Produce json
// @Success 200 {object} model.Response{data=types.AdminRoleListResponse} "获取成功"
// @Security BearerAuth
// @Router /api/v1/admin/roles [get]
func (h *AdminHandler) GetRoles(c *gin.Context) {
roles := []types.RoleInfo{
{
Name: "user",
DisplayName: "普通用户",
Description: "拥有基本用户权限",
},
{
Name: "admin",
DisplayName: "管理员",
Description: "拥有所有管理权限",
},
}
c.JSON(http.StatusOK, model.NewSuccessResponse(types.AdminRoleListResponse{
Roles: roles,
}))
}
// SearchTextures 搜索材质
// @Summary 搜索材质
// @Description 管理员根据条件搜索材质
// @Tags Admin
// @Produce json
// @Param keyword query string false "搜索关键词"
// @Param type query string false "材质类型"
// @Param status query int false "状态筛选"
// @Param uploader_id query int false "上传者ID"
// @Param sort_by query string false "排序字段"
// @Param sort_desc query bool false "是否降序"
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Success 200 {object} model.Response{data=map[string]interface{}} "搜索成功"
// @Security BearerAuth
// @Router /api/v1/admin/textures/search [get]
func (h *AdminHandler) SearchTextures(c *gin.Context) {
var req types.AdminTextureSearchRequest
if err := c.ShouldBindQuery(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
if req.Page < 1 {
req.Page = 1
}
if req.PageSize < 1 || req.PageSize > 100 {
req.PageSize = 20
}
db := h.container.DB.Model(&model.Texture{})
// 关键词搜索
if req.Keyword != "" {
db = db.Where("name LIKE ?", "%"+req.Keyword+"%")
}
// 类型筛选
if req.Type != "" {
db = db.Where("type = ?", req.Type)
}
// 状态筛选
if req.Status != nil {
db = db.Where("status = ?", *req.Status)
}
// 上传者筛选
if req.UploaderID != nil {
db = db.Where("uploader_id = ?", *req.UploaderID)
}
// 排序
sortBy := "id"
if req.SortBy != "" {
sortBy = req.SortBy
}
order := sortBy
if req.SortDesc {
order += " DESC"
}
db = db.Order(order)
// 分页
var total int64
db.Count(&total)
var textures []model.Texture
db.Preload("Uploader").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&textures)
// 构建响应
textureList := make([]gin.H, len(textures))
for i, t := range textures {
uploaderName := ""
if t.Uploader != nil {
uploaderName = t.Uploader.Username
}
textureList[i] = gin.H{
"id": t.ID,
"name": t.Name,
"type": t.Type,
"hash": t.Hash,
"uploader_id": t.UploaderID,
"uploader_name": uploaderName,
"is_public": t.IsPublic,
"download_count": t.DownloadCount,
"favorite_count": t.FavoriteCount,
"status": t.Status,
"created_at": t.CreatedAt,
}
}
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"textures": textureList,
"total": total,
"page": req.Page,
"page_size": req.PageSize,
}))
}
// UpdateTexture 更新材质
// @Summary 更新材质
// @Description 管理员更新材质信息
// @Tags Admin
// @Accept json
// @Produce json
// @Param id path int true "材质ID"
// @Param request body types.AdminTextureUpdateRequest true "更新材质请求"
// @Success 200 {object} model.Response{data=map[string]interface{}} "更新成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 404 {object} model.ErrorResponse "材质不存在"
// @Security BearerAuth
// @Router /api/v1/admin/textures/{id} [put]
func (h *AdminHandler) UpdateTexture(c *gin.Context) {
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
RespondBadRequest(c, "无效的材质ID", err)
return
}
var req types.AdminTextureUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
// 检查材质是否存在
var texture model.Texture
if err := h.container.DB.First(&texture, textureID).Error; err != nil {
c.JSON(http.StatusNotFound, model.NewErrorResponse(
model.CodeNotFound,
"材质不存在",
nil,
))
return
}
// 构建更新字段
updates := make(map[string]interface{})
if req.Name != nil {
updates["name"] = *req.Name
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.IsPublic != nil {
updates["is_public"] = *req.IsPublic
}
if req.Status != nil {
updates["status"] = *req.Status
}
// 执行更新
if len(updates) > 0 {
if err := h.container.DB.Model(&texture).Updates(updates).Error; err != nil {
RespondServerError(c, "更新材质失败", err)
return
}
}
operatorID, _ := c.Get("user_id")
h.container.Logger.Info("管理员更新材质",
zap.Int64("operator_id", operatorID.(int64)),
zap.Int64("texture_id", textureID),
)
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "材质更新成功",
"texture_id": textureID,
}))
}
// BatchDeleteTextures 批量删除材质
// @Summary 批量删除材质
// @Description 管理员批量删除多个材质
// @Tags Admin
// @Accept json
// @Produce json
// @Param request body map[string]interface{} true "批量删除请求"
// @Success 200 {object} model.Response{data=map[string]interface{}} "删除成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Security BearerAuth
// @Router /api/v1/admin/textures/batch-delete [delete]
func (h *AdminHandler) BatchDeleteTextures(c *gin.Context) {
var req struct {
TextureIDs []int64 `json:"texture_ids" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&req); err != nil {
RespondBadRequest(c, "参数错误", err)
return
}
operatorID, _ := c.Get("user_id")
// 批量删除
if err := h.container.DB.Where("id IN ?", req.TextureIDs).Delete(&model.Texture{}).Error; err != nil {
RespondServerError(c, "批量删除材质失败", err)
return
}
h.container.Logger.Info("管理员批量删除材质",
zap.Int64("operator_id", operatorID.(int64)),
zap.Int("count", len(req.TextureIDs)),
)
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
"message": "批量删除材质成功",
"count": len(req.TextureIDs),
}))
}
// GetStats 获取统计信息
// @Summary 获取统计信息
// @Description 管理员获取系统统计数据
// @Tags Admin
// @Produce json
// @Success 200 {object} model.Response{data=types.AdminStatsResponse} "获取成功"
// @Security BearerAuth
// @Router /api/v1/admin/stats [get]
func (h *AdminHandler) GetStats(c *gin.Context) {
var stats types.AdminStatsResponse
// 用户统计
h.container.DB.Model(&model.User{}).Count(&stats.TotalUsers)
h.container.DB.Model(&model.User{}).Where("status = ?", 1).Count(&stats.ActiveUsers)
h.container.DB.Model(&model.User{}).Where("status = ?", 0).Count(&stats.BannedUsers)
h.container.DB.Model(&model.User{}).Where("role = ?", "admin").Count(&stats.AdminUsers)
// 材质统计
h.container.DB.Model(&model.Texture{}).Count(&stats.TotalTextures)
h.container.DB.Model(&model.Texture{}).Where("is_public = ?", true).Count(&stats.PublicTextures)
h.container.DB.Model(&model.Texture{}).Where("status = ?", 0).Count(&stats.PendingTextures)
// 下载和收藏统计
h.container.DB.Model(&model.Texture{}).Select("COALESCE(SUM(download_count), 0)").Scan(&stats.TotalDownloads)
h.container.DB.Model(&model.Texture{}).Select("COALESCE(SUM(favorite_count), 0)").Scan(&stats.TotalFavorites)
c.JSON(http.StatusOK, model.NewSuccessResponse(stats))
}

View File

@@ -117,6 +117,16 @@ func (h *AuthHandler) SendVerificationCode(c *gin.Context) {
return
}
// 验证邮箱格式
if !isValidEmail(req.Email) {
h.logger.Warn("发送验证码失败:邮箱格式错误",
zap.String("email", req.Email),
)
RespondBadRequest(c, "邮箱格式错误", nil)
return
}
// 调用服务发送验证码
if err := h.container.VerificationService.SendCode(c.Request.Context(), req.Email, req.Type); err != nil {
h.logger.Error("发送验证码失败",
zap.String("email", req.Email),

View File

@@ -5,6 +5,7 @@ import (
"carrotskin/internal/model"
"carrotskin/internal/types"
"net/http"
"regexp"
"strconv"
"github.com/gin-gonic/gin"
@@ -62,6 +63,19 @@ func UserToUserInfo(user *model.User) *types.UserInfo {
}
}
// UserToPublicUserInfo 将 User 模型转换为 PublicUserInfo 响应
func UserToPublicUserInfo(user *model.User) *types.PublicUserInfo {
return &types.PublicUserInfo{
ID: user.ID,
Username: user.Username,
Avatar: user.Avatar,
Points: user.Points,
Role: user.Role,
Status: user.Status,
CreatedAt: user.CreatedAt,
}
}
// ProfileToProfileInfo 将 Profile 模型转换为 ProfileInfo 响应
func ProfileToProfileInfo(profile *model.Profile) *types.ProfileInfo {
return &types.ProfileInfo{
@@ -87,22 +101,28 @@ func ProfilesToProfileInfos(profiles []*model.Profile) []*types.ProfileInfo {
// TextureToTextureInfo 将 Texture 模型转换为 TextureInfo 响应
func TextureToTextureInfo(texture *model.Texture) *types.TextureInfo {
uploaderUsername := ""
if texture.Uploader != nil {
uploaderUsername = texture.Uploader.Username
}
return &types.TextureInfo{
ID: texture.ID,
UploaderID: texture.UploaderID,
Name: texture.Name,
Description: texture.Description,
Type: types.TextureType(texture.Type),
URL: texture.URL,
Hash: texture.Hash,
Size: texture.Size,
IsPublic: texture.IsPublic,
DownloadCount: texture.DownloadCount,
FavoriteCount: texture.FavoriteCount,
IsSlim: texture.IsSlim,
Status: texture.Status,
CreatedAt: texture.CreatedAt,
UpdatedAt: texture.UpdatedAt,
ID: texture.ID,
UploaderID: texture.UploaderID,
UploaderUsername: uploaderUsername,
Name: texture.Name,
Description: texture.Description,
Type: types.TextureType(texture.Type),
URL: texture.URL,
Hash: texture.Hash,
Size: texture.Size,
IsPublic: texture.IsPublic,
DownloadCount: texture.DownloadCount,
FavoriteCount: texture.FavoriteCount,
IsSlim: texture.IsSlim,
Status: texture.Status,
CreatedAt: texture.CreatedAt,
UpdatedAt: texture.UpdatedAt,
}
}
@@ -208,3 +228,14 @@ func RespondWithError(c *gin.Context, err error) {
// 默认返回500错误
RespondServerError(c, err.Error(), err)
}
// isValidEmail 验证邮箱格式
func isValidEmail(email string) bool {
if email == "" {
return false
}
// 更严格的邮箱格式验证
emailRegex := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
matched, _ := regexp.MatchString(emailRegex, email)
return matched
}

View File

@@ -93,6 +93,10 @@ func registerAuthRoutes(v1 *gin.RouterGroup, h *AuthHandler) {
// registerUserRoutes 注册用户路由
func registerUserRoutes(v1 *gin.RouterGroup, h *UserHandler, jwtService *auth.JWTService) {
// 公开用户信息路由(无需认证)
v1.GET("/users/public", h.GetPublicInfo)
// 需要认证的用户路由
userGroup := v1.Group("/user")
userGroup.Use(middleware.AuthMiddleware(jwtService))
{
@@ -199,18 +203,28 @@ func registerAdminRoutes(v1 *gin.RouterGroup, c *container.Container, h *AdminHa
admin.Use(middleware.RequireAdmin())
{
// 统计信息
admin.GET("/stats", h.GetStats)
// 角色管理
admin.GET("/roles", h.GetRoles)
// 用户管理
admin.GET("/users", h.GetUserList)
admin.GET("/users/search", h.SearchUsers)
admin.GET("/users/:id", h.GetUserDetail)
admin.DELETE("/users/:id", h.DeleteUser)
admin.PUT("/users/role", h.SetUserRole)
admin.PUT("/users/status", h.SetUserStatus)
admin.PUT("/users/batch-role", h.BatchSetUserRole)
admin.DELETE("/users/batch-delete", h.BatchDeleteUsers)
// 材质管理(审核)
admin.GET("/textures", h.GetTextureList)
admin.GET("/textures/search", h.SearchTextures)
admin.PUT("/textures/:id", h.UpdateTexture)
admin.DELETE("/textures/:id", h.DeleteTexture)
// 权限管理
admin.GET("/permissions", h.GetPermissions)
admin.DELETE("/textures/batch-delete", h.BatchDeleteTextures)
}
}

View File

@@ -2,6 +2,7 @@ package handler
import (
"carrotskin/internal/container"
"carrotskin/internal/model"
"carrotskin/internal/service"
"carrotskin/internal/types"
@@ -315,3 +316,55 @@ func (h *UserHandler) ResetYggdrasilPassword(c *gin.Context) {
h.logger.Info("Yggdrasil密码重置成功", zap.Int64("userId", userID))
RespondSuccess(c, gin.H{"password": newPassword})
}
// GetPublicInfo 获取用户公开信息
// @Summary 获取用户公开信息
// @Description 根据用户名或用户ID获取用户的公开信息不包含敏感信息如邮箱
// @Tags user
// @Accept json
// @Produce json
// @Param username query string false "用户名"
// @Param id query int false "用户ID"
// @Success 200 {object} model.Response{data=types.PublicUserInfo} "获取成功"
// @Failure 400 {object} model.ErrorResponse "参数错误"
// @Failure 404 {object} model.ErrorResponse "用户不存在"
// @Router /api/v1/users/public [get]
func (h *UserHandler) GetPublicInfo(c *gin.Context) {
username := c.Query("username")
idStr := c.Query("id")
// 至少需要提供一个参数
if username == "" && idStr == "" {
RespondBadRequest(c, "必须提供用户名或用户ID", nil)
return
}
var user *model.User
var err error
// 优先使用用户名查询
if username != "" {
user, err = h.container.UserService.GetByUsername(c.Request.Context(), username)
} else {
// 使用用户ID查询
id := parseIntWithDefault(idStr, 0)
if id == 0 {
RespondBadRequest(c, "无效的用户ID", nil)
return
}
user, err = h.container.UserService.GetByID(c.Request.Context(), int64(id))
}
if err != nil || user == nil {
RespondNotFound(c, "用户不存在")
return
}
// 检查用户状态
if user.Status != 1 {
RespondNotFound(c, "用户不可用")
return
}
RespondSuccess(c, UserToPublicUserInfo(user))
}

View File

@@ -29,13 +29,13 @@ func (r *textureRepository) FindByID(ctx context.Context, id int64) (*model.Text
func (r *textureRepository) FindByHash(ctx context.Context, hash string) (*model.Texture, error) {
var texture model.Texture
err := r.db.WithContext(ctx).Where("hash = ?", hash).First(&texture).Error
err := r.db.WithContext(ctx).Preload("Uploader").Where("hash = ?", hash).First(&texture).Error
return handleNotFoundResult(&texture, err)
}
func (r *textureRepository) FindByHashAndUploaderID(ctx context.Context, hash string, uploaderID int64) (*model.Texture, error) {
var texture model.Texture
err := r.db.WithContext(ctx).Where("hash = ? AND uploader_id = ?", hash, uploaderID).First(&texture).Error
err := r.db.WithContext(ctx).Preload("Uploader").Where("hash = ? AND uploader_id = ?", hash, uploaderID).First(&texture).Error
return handleNotFoundResult(&texture, err)
}

View File

@@ -19,6 +19,7 @@ type UserService interface {
// 用户查询
GetByID(ctx context.Context, id int64) (*model.User, error)
GetByEmail(ctx context.Context, email string) (*model.User, error)
GetByUsername(ctx context.Context, username string) (*model.User, error)
// 用户更新
UpdateInfo(ctx context.Context, user *model.User) error

View File

@@ -55,6 +55,22 @@ func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture,
if texture.Status == -1 {
return nil, errors.New("材质已删除")
}
// 如果缓存中没有 Uploader 信息,重新查询数据库
if texture.Uploader == nil {
texture2, err := s.textureRepo.FindByID(ctx, id)
if err != nil {
return nil, err
}
if texture2 == nil {
return nil, ErrTextureNotFound
}
if texture2.Status == -1 {
return nil, errors.New("材质已删除")
}
// 更新缓存
s.cache.SetAsync(context.Background(), cacheKey, texture2, s.cache.Policy.TextureTTL)
return texture2, nil
}
return &texture, nil
}
@@ -71,9 +87,7 @@ func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture,
}
// 存入缓存(异步)
if texture2 != nil {
s.cache.SetAsync(context.Background(), cacheKey, texture2, s.cache.Policy.TextureTTL)
}
s.cache.SetAsync(context.Background(), cacheKey, texture2, s.cache.Policy.TextureTTL)
return texture2, nil
}
@@ -365,7 +379,17 @@ func (s *textureService) UploadTexture(ctx context.Context, uploaderID int64, na
// 清除用户的 texture 列表缓存(所有分页)
s.cacheInv.BatchInvalidate(ctx, fmt.Sprintf("texture:user:%d:*", uploaderID))
return texture, nil
// 重新查询以预加载 Uploader 关联
textureWithUploader, err := s.textureRepo.FindByID(ctx, texture.ID)
if err != nil {
// 如果查询失败,返回原始创建的 texture 对象(虽然可能没有 Uploader 信息)
return texture, nil
}
if textureWithUploader == nil {
// 如果查询返回 nil极端情况如数据库复制延迟返回原始创建的 texture 对象
return texture, nil
}
return textureWithUploader, nil
}
// parseTextureTypeInternal 解析材质类型

View File

@@ -199,6 +199,14 @@ func (s *userService) GetByEmail(ctx context.Context, email string) (*model.User
}, s.cache.Policy.UserEmailTTL)
}
func (s *userService) GetByUsername(ctx context.Context, username string) (*model.User, error) {
// 使用 Cached 装饰器自动处理缓存
cacheKey := s.cacheKeys.UserByUsername(username)
return database.Cached(ctx, s.cache, cacheKey, func() (*model.User, error) {
return s.userRepo.FindByUsername(ctx, username)
}, s.cache.Policy.UserTTL)
}
func (s *userService) UpdateInfo(ctx context.Context, user *model.User) error {
err := s.userRepo.Update(ctx, user)
if err != nil {

View File

@@ -110,6 +110,18 @@ type UserInfo struct {
UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"`
}
// PublicUserInfo 用户公开信息
// @Description 用户公开信息(不包含敏感信息如邮箱)
type PublicUserInfo struct {
ID int64 `json:"id" example:"1"`
Username string `json:"username" example:"testuser"`
Avatar string `json:"avatar" example:"https://example.com/avatar.png"`
Points int `json:"points" example:"100"`
Role string `json:"role" example:"user"`
Status int16 `json:"status" example:"1"`
CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"`
}
// TextureType 材质类型
type TextureType string
@@ -121,21 +133,22 @@ const (
// TextureInfo 材质信息
// @Description 材质详细信息
type TextureInfo struct {
ID int64 `json:"id" example:"1"`
UploaderID int64 `json:"uploader_id" example:"1"`
Name string `json:"name" example:"My Skin"`
Description string `json:"description,omitempty" example:"A cool skin"`
Type TextureType `json:"type" example:"SKIN"`
URL string `json:"url" example:"https://rustfs.example.com/textures/xxx.png"`
Hash string `json:"hash" example:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`
Size int `json:"size" example:"2048"`
IsPublic bool `json:"is_public" example:"true"`
DownloadCount int `json:"download_count" example:"100"`
FavoriteCount int `json:"favorite_count" example:"50"`
IsSlim bool `json:"is_slim" example:"false"`
Status int16 `json:"status" example:"1"`
CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"`
UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"`
ID int64 `json:"id" example:"1"`
UploaderID int64 `json:"uploader_id" example:"1"`
UploaderUsername string `json:"uploader_username" example:"testuser"`
Name string `json:"name" example:"My Skin"`
Description string `json:"description,omitempty" example:"A cool skin"`
Type TextureType `json:"type" example:"SKIN"`
URL string `json:"url" example:"https://rustfs.example.com/textures/xxx.png"`
Hash string `json:"hash" example:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`
Size int `json:"size" example:"2048"`
IsPublic bool `json:"is_public" example:"true"`
DownloadCount int `json:"download_count" example:"100"`
FavoriteCount int `json:"favorite_count" example:"50"`
IsSlim bool `json:"is_slim" example:"false"`
Status int16 `json:"status" example:"1"`
CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"`
UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"`
}
// ProfileInfo 角色信息
@@ -193,3 +206,84 @@ type SystemConfigResponse struct {
MaxTexturesPerUser int `json:"max_textures_per_user" example:"100"`
MaxProfilesPerUser int `json:"max_profiles_per_user" example:"5"`
}
// AdminUserSearchRequest 管理员用户搜索请求
// @Description 管理员搜索用户请求参数
type AdminUserSearchRequest struct {
PaginationRequest
Keyword string `json:"keyword" form:"keyword" example:"testuser"`
Role string `json:"role" form:"role" binding:"omitempty,oneof=user admin"`
Status *int16 `json:"status" form:"status" binding:"omitempty,oneof=1 0 -1"`
SortBy string `json:"sort_by" form:"sort_by" binding:"omitempty,oneof=id username email points created_at"`
SortDesc bool `json:"sort_desc" form:"sort_desc"`
}
// AdminUserCreateRequest 管理员创建用户请求
// @Description 管理员创建用户请求参数
type AdminUserCreateRequest struct {
Username string `json:"username" binding:"required,min=3,max=50" example:"newuser"`
Email string `json:"email" binding:"required,email" example:"user@example.com"`
Password string `json:"password" binding:"required,min=6,max=128" example:"password123"`
Role string `json:"role" binding:"required,oneof=user admin" example:"user"`
Points int `json:"points" binding:"omitempty,min=0" example:"0"`
}
// AdminUserUpdateRequest 管理员更新用户请求
// @Description 管理员更新用户请求参数
type AdminUserUpdateRequest struct {
Username *string `json:"username" binding:"omitempty,min=3,max=50" example:"newusername"`
Email *string `json:"email" binding:"omitempty,email" example:"newemail@example.com"`
Password *string `json:"password" binding:"omitempty,min=6,max=128" example:"newpassword"`
Role *string `json:"role" binding:"omitempty,oneof=user admin" example:"admin"`
Points *int `json:"points" binding:"omitempty,min=0" example:"100"`
Status *int16 `json:"status" binding:"omitempty,oneof=1 0 -1"`
}
// AdminTextureSearchRequest 管理员材质搜索请求
// @Description 管理员搜索材质请求参数
type AdminTextureSearchRequest struct {
PaginationRequest
Keyword string `json:"keyword" form:"keyword" example:"skin"`
Type TextureType `json:"type" form:"type" binding:"omitempty,oneof=SKIN CAPE"`
Status *int16 `json:"status" form:"status" binding:"omitempty,oneof=1 0 -1"`
UploaderID *int64 `json:"uploader_id" form:"uploader_id" example:"1"`
SortBy string `json:"sort_by" form:"sort_by" binding:"omitempty,oneof=id name download_count favorite_count created_at"`
SortDesc bool `json:"sort_desc" form:"sort_desc"`
}
// AdminTextureUpdateRequest 管理员更新材质请求
// @Description 管理员更新材质请求参数
type AdminTextureUpdateRequest struct {
Name *string `json:"name" binding:"omitempty,min=1,max=100" example:"New Skin Name"`
Description *string `json:"description" binding:"omitempty,max=500" example:"New description"`
IsPublic *bool `json:"is_public" example:"true"`
Status *int16 `json:"status" binding:"omitempty,oneof=1 0 -1"`
}
// AdminRoleListResponse 角色列表响应
// @Description 角色列表响应数据
type AdminRoleListResponse struct {
Roles []RoleInfo `json:"roles"`
}
// RoleInfo 角色信息
// @Description 角色详细信息
type RoleInfo struct {
Name string `json:"name" example:"admin"`
DisplayName string `json:"display_name" example:"管理员"`
Description string `json:"description" example:"拥有所有管理权限"`
}
// AdminStatsResponse 管理员统计信息响应
// @Description 管理员统计信息
type AdminStatsResponse struct {
TotalUsers int64 `json:"total_users" example:"100"`
ActiveUsers int64 `json:"active_users" example:"80"`
BannedUsers int64 `json:"banned_users" example:"5"`
AdminUsers int64 `json:"admin_users" example:"3"`
TotalTextures int64 `json:"total_textures" example:"500"`
PublicTextures int64 `json:"public_textures" example:"300"`
PendingTextures int64 `json:"pending_textures" example:"10"`
TotalDownloads int64 `json:"total_downloads" example:"1000"`
TotalFavorites int64 `json:"total_favorites" example:"500"`
}

View File

@@ -131,13 +131,16 @@ type SecurityConfig struct {
// Load 加载配置 - 完全从环境变量加载不依赖YAML文件
func Load() (*Config, error) {
// 加载.env文件如果存在
_ = godotenv.Load(".env")
if err := godotenv.Load(".env"); err != nil {
fmt.Printf("[Config] 注意: 未加载 .env 文件 (原因: %v)\n", err)
} else {
fmt.Println("[Config] 成功加载 .env 文件")
}
// 设置默认值
setDefaults()
// 设置环境变量前缀
viper.SetEnvPrefix("CARROTSKIN")
// 自动读取环境变量(不设置前缀,因为 BindEnv 已经明确指定了变量名)
viper.AutomaticEnv()
// 手动设置环境变量映射
@@ -152,6 +155,20 @@ func Load() (*Config, error) {
// 从环境变量中覆盖配置
overrideFromEnv(&config)
// 打印关键配置加载状态
fmt.Println("==================================================")
fmt.Println(" CarrotSkin Configuration Check ")
fmt.Println("==================================================")
fmt.Printf("Server Port: %s\n", config.Server.Port)
fmt.Printf("Database Host: %s\n", config.Database.Host)
fmt.Printf("Redis Host: %s\n", config.Redis.Host)
fmt.Printf("Environment: %s\n", config.Environment)
if config.Database.Host == "localhost" && os.Getenv("DATABASE_HOST") != "" && os.Getenv("DATABASE_HOST") != "localhost" {
fmt.Printf("[Warning] Database Host is 'localhost' but env DATABASE_HOST is set to '%s'. Viper binding might have failed.\n", os.Getenv("DATABASE_HOST"))
}
fmt.Println("==================================================")
return &config, nil
}
@@ -302,6 +319,7 @@ func setupEnvMappings() {
// overrideFromEnv 从环境变量中覆盖配置
func overrideFromEnv(config *Config) {
// 处理RustFS存储桶配置
if texturesBucket := os.Getenv("RUSTFS_BUCKET_TEXTURES"); texturesBucket != "" {
if config.RustFS.Buckets == nil {
@@ -342,6 +360,24 @@ func overrideFromEnv(config *Config) {
}
}
// 处理Redis基本配置
if host := os.Getenv("REDIS_HOST"); host != "" {
config.Redis.Host = host
}
if port := os.Getenv("REDIS_PORT"); port != "" {
if val, err := strconv.Atoi(port); err == nil {
config.Redis.Port = val
}
}
if password := os.Getenv("REDIS_PASSWORD"); password != "" {
config.Redis.Password = password
}
if database := os.Getenv("REDIS_DATABASE"); database != "" {
if val, err := strconv.Atoi(database); err == nil {
config.Redis.Database = val
}
}
// 处理Redis连接池配置
if poolSize := os.Getenv("REDIS_POOL_SIZE"); poolSize != "" {
if val, err := strconv.Atoi(poolSize); err == nil {

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net/smtp"
"net/textproto"
"html"
"carrotskin/pkg/config"
@@ -70,8 +71,6 @@ func (s *Service) send(to []string, subject, body string) error {
addr := fmt.Sprintf("%s:%d", s.cfg.SMTPHost, s.cfg.SMTPPort)
// 判断端口决定发送方式
// 465端口使用SSL/TLS隐式TLS
// 587端口使用STARTTLS显式TLS
var err error
if s.cfg.SMTPPort == 465 {
// 使用SSL/TLS连接适用于465端口
@@ -132,6 +131,10 @@ func (s *Service) getBody(code, purpose string) string {
message = "您的验证码为:"
}
// 转义 HTML 特殊字符
escapedMessage := html.EscapeString(message)
escapedCode := html.EscapeString(code)
return fmt.Sprintf(`
<!DOCTYPE html>
<html>
@@ -158,5 +161,5 @@ func (s *Service) getBody(code, purpose string) string {
</div>
</body>
</html>
`, message, code)
`, escapedMessage, escapedCode)
}

View File

@@ -79,57 +79,6 @@ func (s *StorageClient) GetBucket(name string) (string, error) {
return bucket, nil
}
// GeneratePresignedURL 生成预签名上传URL (PUT方法)
func (s *StorageClient) GeneratePresignedURL(ctx context.Context, bucketName, objectName string, expires time.Duration) (string, error) {
url, err := s.client.PresignedPutObject(ctx, bucketName, objectName, expires)
if err != nil {
return "", fmt.Errorf("生成预签名URL失败: %w", err)
}
return url.String(), nil
}
// PresignedPostPolicyResult 预签名POST策略结果
type PresignedPostPolicyResult struct {
PostURL string // POST的URL
FormData map[string]string // 表单数据
FileURL string // 文件的最终访问URL
}
// GeneratePresignedPostURL 生成预签名POST URL (支持表单上传)
// 注意使用时必须确保file字段是表单的最后一个字段
func (s *StorageClient) GeneratePresignedPostURL(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*PresignedPostPolicyResult, error) {
// 创建上传策略
policy := minio.NewPostPolicy()
// 设置策略的基本信息
policy.SetBucket(bucketName)
policy.SetKey(objectName)
policy.SetExpires(time.Now().UTC().Add(expires))
// 设置文件大小限制
if err := policy.SetContentLengthRange(minSize, maxSize); err != nil {
return nil, fmt.Errorf("设置文件大小限制失败: %w", err)
}
// 使用MinIO客户端和策略生成预签名的POST URL和表单数据
postURL, formData, err := s.client.PresignedPostPolicy(ctx, policy)
if err != nil {
return nil, fmt.Errorf("生成预签名POST URL失败: %w", err)
}
// 移除form_data中多余的bucket字段MinIO Go SDK可能会添加这个字段但会导致签名错误
// 注意在Go中直接delete不存在的key是安全的
delete(formData, "bucket")
// 使用配置的公开访问URL构造文件的永久访问URL
fileURL := s.BuildFileURL(bucketName, objectName)
return &PresignedPostPolicyResult{
PostURL: postURL.String(),
FormData: formData,
FileURL: fileURL,
}, nil
}
// BuildFileURL 构建文件的公开访问URL
func (s *StorageClient) BuildFileURL(bucketName, objectName string) string {

View File

@@ -1,9 +1,7 @@
package storage
import (
"context"
"testing"
"time"
"carrotskin/pkg/config"
@@ -41,31 +39,3 @@ func TestNewStorage_SkipConnectWhenNoCreds(t *testing.T) {
t.Fatalf("NewStorage should not error when creds empty: %v", err)
}
}
func TestPresignedHelpers_WithNilClient(t *testing.T) {
s := &StorageClient{
client: (*minio.Client)(nil),
buckets: map[string]string{"textures": "tex-bkt"},
publicURL: "http://localhost:9000",
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// 预期会panicnil client用recover捕获
func() {
defer func() {
if r := recover(); r == nil {
t.Fatalf("GeneratePresignedURL expected panic with nil client")
}
}()
_, _ = s.GeneratePresignedURL(ctx, "tex-bkt", "obj", time.Minute)
}()
func() {
defer func() {
if r := recover(); r == nil {
t.Fatalf("GeneratePresignedPostURL expected panic with nil client")
}
}()
_, _ = s.GeneratePresignedPostURL(ctx, "tex-bkt", "obj", 0, 10, time.Minute)
}()
}