Compare commits
5 Commits
feature/re
...
9b0a60033e
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b0a60033e | |||
| 399e6f096f | |||
| 63ca7eff0d | |||
| aa75691c49 | |||
|
|
a51535a465 |
@@ -1,18 +1,30 @@
|
||||
# ==================== CarrotSkin Docker 环境配置示例 ====================
|
||||
# 复制此文件为 .env 后修改配置值
|
||||
# 此文件用于 docker-compose 部署,变量名与 docker-compose.yml 中的引用一致
|
||||
|
||||
# ==================== 站点配置 ====================
|
||||
SITE_NAME=CarrotSkin
|
||||
SITE_DESCRIPTION=一个优秀的Minecraft皮肤站
|
||||
REGISTRATION_ENABLED=true
|
||||
DEFAULT_AVATAR=
|
||||
|
||||
# ==================== 用户限制配置 ====================
|
||||
MAX_TEXTURES_PER_USER=50
|
||||
MAX_PROFILES_PER_USER=5
|
||||
|
||||
# ==================== 积分配置 ====================
|
||||
CHECKIN_REWARD=10
|
||||
TEXTURE_DOWNLOAD_REWARD=1
|
||||
|
||||
# ==================== 服务配置 ====================
|
||||
# 应用端口
|
||||
# 应用对外端口
|
||||
APP_PORT=8080
|
||||
# 运行模式: debug, release, test
|
||||
SERVER_MODE=release
|
||||
# API 根路径 (用于反向代理,如 /api)
|
||||
SERVER_BASE_PATH=
|
||||
# 公开访问地址 (用于生成回调URL、邮件链接等)
|
||||
PUBLIC_URL=http://localhost:8080
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
DB_PASSWORD=carrotskin123
|
||||
# 数据库密码,生产环境务必修改
|
||||
DATABASE_PASSWORD=carrotskin123
|
||||
|
||||
# ==================== Redis 配置 ====================
|
||||
# 留空表示不设置密码
|
||||
@@ -25,23 +37,26 @@ JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
# ==================== 存储配置 (RustFS S3兼容) ====================
|
||||
# 内部访问地址 (容器间通信)
|
||||
RUSTFS_ENDPOINT=rustfs:9000
|
||||
# 公开访问地址 (用于生成文件URL,供外部浏览器访问)
|
||||
# 示例: 直接访问 http://localhost:9000 或反向代理 https://example.com/storage
|
||||
RUSTFS_PUBLIC_URL=http://localhost:9000
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
RUSTFS_SECRET_KEY=rustfsadmin123
|
||||
RUSTFS_USE_SSL=false
|
||||
|
||||
# 存储桶配置
|
||||
RUSTFS_BUCKET_TEXTURES=carrotskin
|
||||
RUSTFS_BUCKET_AVATARS=carrotskin
|
||||
RUSTFS_BUCKET_TEXTURES=carrot-skin-textures
|
||||
RUSTFS_BUCKET_AVATARS=carrot-skin-avatars
|
||||
|
||||
# 公开访问地址 (用于生成文件URL,供外部浏览器访问)
|
||||
# 示例:
|
||||
# 直接访问: http://localhost:9000
|
||||
# 反向代理: https://example.com/storage
|
||||
RUSTFS_PUBLIC_URL=http://localhost:9000
|
||||
# ==================== 安全配置 ====================
|
||||
# CORS 允许的来源,多个用逗号分隔
|
||||
SECURITY_ALLOWED_ORIGINS=*
|
||||
# 允许的头像/材质URL域名,多个用逗号分隔
|
||||
SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1
|
||||
|
||||
# ==================== 邮件配置 (可选) ====================
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
# ==================== 邮件配置 ====================
|
||||
EMAIL_ENABLED=false
|
||||
EMAIL_SMTP_HOST=
|
||||
EMAIL_SMTP_PORT=587
|
||||
EMAIL_USERNAME=
|
||||
EMAIL_PASSWORD=
|
||||
EMAIL_FROM_NAME=CarrotSkin
|
||||
|
||||
41
.env.example
41
.env.example
@@ -1,6 +1,26 @@
|
||||
# CarrotSkin 环境配置文件示例
|
||||
# 复制此文件为 .env 并修改相应的配置值
|
||||
|
||||
# =============================================================================
|
||||
# 站点配置
|
||||
# =============================================================================
|
||||
SITE_NAME=CarrotSkin
|
||||
SITE_DESCRIPTION=一个优秀的Minecraft皮肤站
|
||||
REGISTRATION_ENABLED=true
|
||||
DEFAULT_AVATAR=
|
||||
|
||||
# =============================================================================
|
||||
# 用户限制配置
|
||||
# =============================================================================
|
||||
MAX_TEXTURES_PER_USER=50
|
||||
MAX_PROFILES_PER_USER=5
|
||||
|
||||
# =============================================================================
|
||||
# 积分配置
|
||||
# =============================================================================
|
||||
CHECKIN_REWARD=10
|
||||
TEXTURE_DOWNLOAD_REWARD=1
|
||||
|
||||
# =============================================================================
|
||||
# 服务器配置
|
||||
# =============================================================================
|
||||
@@ -23,6 +43,7 @@ DATABASE_TIMEZONE=Asia/Shanghai
|
||||
DATABASE_MAX_IDLE_CONNS=10
|
||||
DATABASE_MAX_OPEN_CONNS=100
|
||||
DATABASE_CONN_MAX_LIFETIME=1h
|
||||
DATABASE_CONN_MAX_IDLE_TIME=10m
|
||||
|
||||
# =============================================================================
|
||||
# Redis配置
|
||||
@@ -37,6 +58,7 @@ REDIS_POOL_SIZE=10
|
||||
# RustFS对象存储配置 (S3兼容)
|
||||
# =============================================================================
|
||||
RUSTFS_ENDPOINT=127.0.0.1:9000
|
||||
RUSTFS_PUBLIC_URL=http://127.0.0.1:9000
|
||||
RUSTFS_ACCESS_KEY=your_access_key
|
||||
RUSTFS_SECRET_KEY=your_secret_key
|
||||
RUSTFS_USE_SSL=false
|
||||
@@ -55,26 +77,17 @@ JWT_EXPIRE_HOURS=168
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=json
|
||||
LOG_OUTPUT=logs/app.log
|
||||
LOG_MAX_SIZE=100
|
||||
LOG_MAX_BACKUPS=3
|
||||
LOG_MAX_AGE=28
|
||||
LOG_COMPRESS=true
|
||||
|
||||
# =============================================================================
|
||||
# 文件上传配置
|
||||
# =============================================================================
|
||||
UPLOAD_MAX_SIZE=10485760
|
||||
UPLOAD_TEXTURE_MAX_SIZE=2097152
|
||||
UPLOAD_AVATAR_MAX_SIZE=1048576
|
||||
|
||||
# =============================================================================
|
||||
# 安全配置
|
||||
# =============================================================================
|
||||
MAX_LOGIN_ATTEMPTS=5
|
||||
LOGIN_LOCK_DURATION=30m
|
||||
# CORS 允许的来源,多个用逗号分隔
|
||||
SECURITY_ALLOWED_ORIGINS=*
|
||||
# 允许的头像/材质URL域名,多个用逗号分隔
|
||||
SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1
|
||||
|
||||
# =============================================================================
|
||||
# 邮件配置(可选)
|
||||
# 邮件配置
|
||||
# 腾讯企业邮箱SSL配置示例:smtp.exmail.qq.com, 端口465
|
||||
# =============================================================================
|
||||
EMAIL_ENABLED=false
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -23,8 +23,8 @@ dist/
|
||||
build/
|
||||
|
||||
# Compiled binaries
|
||||
/server
|
||||
server.exe
|
||||
main.exe
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
@@ -108,3 +108,5 @@ local/
|
||||
dev/
|
||||
service_coverage
|
||||
.gitignore
|
||||
docs/
|
||||
blessing skin材质渲染示例/
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
// @title CarrotSkin API
|
||||
// @version 1.0
|
||||
// @description Minecraft皮肤站后端API
|
||||
// @host localhost:8080
|
||||
// @BasePath /api/v1
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -22,6 +31,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
_ "carrotskin/docs" // Swagger docs
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -80,12 +91,19 @@ func main() {
|
||||
}
|
||||
emailServiceInstance := email.MustGetService()
|
||||
|
||||
// 初始化Casbin权限服务
|
||||
casbinService, err := auth.NewCasbinService(database.MustGetDB(), cfg.Casbin.ModelPath, loggerInstance)
|
||||
if err != nil {
|
||||
loggerInstance.Fatal("Casbin服务初始化失败", zap.Error(err))
|
||||
}
|
||||
|
||||
// 创建依赖注入容器
|
||||
c := container.NewContainer(
|
||||
database.MustGetDB(),
|
||||
redis.MustGetClient(),
|
||||
loggerInstance,
|
||||
auth.MustGetJWTService(),
|
||||
casbinService,
|
||||
storageClient,
|
||||
emailServiceInstance,
|
||||
)
|
||||
|
||||
@@ -11,4 +11,4 @@ g = _, _
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
[matchers]
|
||||
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
|
||||
m = g(r.sub, p.sub) && (p.obj == "*" || r.obj == p.obj) && (p.act == "*" || r.act == p.act)
|
||||
|
||||
@@ -12,41 +12,55 @@ services:
|
||||
ports:
|
||||
- "${APP_PORT:-8080}:8080"
|
||||
environment:
|
||||
# 站点配置
|
||||
- SITE_NAME=${SITE_NAME:-CarrotSkin}
|
||||
- SITE_DESCRIPTION=${SITE_DESCRIPTION:-一个优秀的Minecraft皮肤站}
|
||||
- REGISTRATION_ENABLED=${REGISTRATION_ENABLED:-true}
|
||||
- DEFAULT_AVATAR=${DEFAULT_AVATAR:-}
|
||||
# 用户限制配置
|
||||
- MAX_TEXTURES_PER_USER=${MAX_TEXTURES_PER_USER:-50}
|
||||
- MAX_PROFILES_PER_USER=${MAX_PROFILES_PER_USER:-5}
|
||||
# 积分配置
|
||||
- CHECKIN_REWARD=${CHECKIN_REWARD:-10}
|
||||
- TEXTURE_DOWNLOAD_REWARD=${TEXTURE_DOWNLOAD_REWARD:-1}
|
||||
# 服务器配置
|
||||
- SERVER_PORT=8080
|
||||
- SERVER_PORT=:8080
|
||||
- SERVER_MODE=${SERVER_MODE:-release}
|
||||
- SERVER_BASE_PATH=${SERVER_BASE_PATH:-}
|
||||
# 公开访问地址 (用于生成回调URL、邮件链接等)
|
||||
- PUBLIC_URL=${PUBLIC_URL:-http://localhost:8080}
|
||||
# 数据库配置
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_USER=carrotskin
|
||||
- DB_PASSWORD=${DB_PASSWORD:-carrotskin123}
|
||||
- DB_NAME=carrotskin
|
||||
- DB_SSLMODE=disable
|
||||
- DATABASE_DRIVER=postgres
|
||||
- DATABASE_HOST=postgres
|
||||
- DATABASE_PORT=5432
|
||||
- DATABASE_USERNAME=carrotskin
|
||||
- DATABASE_PASSWORD=${DATABASE_PASSWORD:-carrotskin123}
|
||||
- DATABASE_NAME=carrotskin
|
||||
- DATABASE_SSL_MODE=disable
|
||||
- DATABASE_TIMEZONE=Asia/Shanghai
|
||||
# Redis 配置
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
|
||||
- REDIS_DB=0
|
||||
- REDIS_DATABASE=0
|
||||
# JWT 配置
|
||||
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
||||
- JWT_EXPIRE_HOURS=24
|
||||
- JWT_EXPIRE_HOURS=168
|
||||
# 存储配置 (RustFS S3兼容)
|
||||
- RUSTFS_ENDPOINT=${RUSTFS_ENDPOINT:-rustfs:9000}
|
||||
- RUSTFS_PUBLIC_URL=${RUSTFS_PUBLIC_URL:-http://localhost:9000}
|
||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
|
||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123}
|
||||
- RUSTFS_USE_SSL=${RUSTFS_USE_SSL:-false}
|
||||
- RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrotskin}
|
||||
- RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrotskin}
|
||||
# 邮件配置 (可选)
|
||||
- SMTP_HOST=${SMTP_HOST:-}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
|
||||
- SMTP_FROM=${SMTP_FROM:-}
|
||||
- RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrot-skin-textures}
|
||||
- RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrot-skin-avatars}
|
||||
# 安全配置
|
||||
- SECURITY_ALLOWED_ORIGINS=${SECURITY_ALLOWED_ORIGINS:-*}
|
||||
- SECURITY_ALLOWED_DOMAINS=${SECURITY_ALLOWED_DOMAINS:-localhost,127.0.0.1}
|
||||
# 邮件配置
|
||||
- EMAIL_ENABLED=${EMAIL_ENABLED:-false}
|
||||
- EMAIL_SMTP_HOST=${EMAIL_SMTP_HOST:-}
|
||||
- EMAIL_SMTP_PORT=${EMAIL_SMTP_PORT:-587}
|
||||
- EMAIL_USERNAME=${EMAIL_USERNAME:-}
|
||||
- EMAIL_PASSWORD=${EMAIL_PASSWORD:-}
|
||||
- EMAIL_FROM_NAME=${EMAIL_FROM_NAME:-CarrotSkin}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -68,7 +82,7 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=carrotskin
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD:-carrotskin123}
|
||||
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:-carrotskin123}
|
||||
- POSTGRES_DB=carrotskin
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
@@ -108,7 +122,7 @@ services:
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
# ==================== RustFS 对象存储 (可选) ====================
|
||||
# ==================== RustFS 对象存储====================
|
||||
rustfs:
|
||||
image: ghcr.io/rustfs/rustfs:latest
|
||||
container_name: carrotskin-rustfs
|
||||
@@ -148,14 +162,19 @@ services:
|
||||
echo '等待 RustFS 启动...';
|
||||
sleep 5;
|
||||
mc alias set myrustfs http://rustfs:9000 $${RUSTFS_ACCESS_KEY} $${RUSTFS_SECRET_KEY};
|
||||
mc mb myrustfs/$${RUSTFS_BUCKET} --ignore-existing;
|
||||
mc anonymous set download myrustfs/$${RUSTFS_BUCKET};
|
||||
echo '存储桶 $${RUSTFS_BUCKET} 创建完成,已设置公开读取权限';
|
||||
echo '创建材质存储桶...';
|
||||
mc mb myrustfs/$${RUSTFS_BUCKET_TEXTURES} --ignore-existing;
|
||||
mc anonymous set download myrustfs/$${RUSTFS_BUCKET_TEXTURES};
|
||||
echo '创建头像存储桶...';
|
||||
mc mb myrustfs/$${RUSTFS_BUCKET_AVATARS} --ignore-existing;
|
||||
mc anonymous set download myrustfs/$${RUSTFS_BUCKET_AVATARS};
|
||||
echo '存储桶创建完成: $${RUSTFS_BUCKET_TEXTURES}, $${RUSTFS_BUCKET_AVATARS}';
|
||||
"
|
||||
environment:
|
||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
|
||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123}
|
||||
- RUSTFS_BUCKET=${RUSTFS_BUCKET_TEXTURES:-carrotskin}
|
||||
- RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrot-skin-textures}
|
||||
- RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrot-skin-avatars}
|
||||
networks:
|
||||
- carrotskin-network
|
||||
profiles:
|
||||
|
||||
44
go.mod
44
go.mod
@@ -12,6 +12,9 @@ require (
|
||||
github.com/minio/minio-go/v7 v7.0.97
|
||||
github.com/redis/go-redis/v9 v9.17.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
github.com/swaggo/swag v1.16.6
|
||||
github.com/wenlng/go-captcha-assets v1.0.7
|
||||
github.com/wenlng/go-captcha/v2 v2.0.4
|
||||
go.uber.org/zap v1.27.1
|
||||
@@ -22,26 +25,53 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
||||
github.com/casbin/gorm-adapter/v3 v3.38.0 // indirect
|
||||
github.com/casbin/govaluate v1.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/glebarez/go-sqlite v1.20.3 // indirect
|
||||
github.com/glebarez/sqlite v1.7.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||
github.com/go-openapi/spec v0.22.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.0 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/minio/crc64nvme v1.1.0 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.7.2 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.57.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
golang.org/x/image v0.33.0 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/driver/sqlserver v1.6.0 // indirect
|
||||
gorm.io/plugin/dbresolver v1.6.0 // indirect
|
||||
modernc.org/libc v1.22.2 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.20.3 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
180
go.sum
180
go.sum
@@ -1,5 +1,14 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.1.2/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.1.0/go.mod h1:bhXu1AjYL+wutSL/kpSq6s7733q2Rb0yuot9Zgfqa/0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -10,8 +19,16 @@ github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPII
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||
github.com/casbin/gorm-adapter/v3 v3.38.0 h1:j+2YEQU0F4RmlXaVihVV82OTe268/oKI7QKeHRkbu84=
|
||||
github.com/casbin/gorm-adapter/v3 v3.38.0/go.mod h1:kjXoK8MqA3E/CcqEF2l3SCkhJj1YiHVR6SF0LMvJoH4=
|
||||
github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
|
||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
|
||||
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -19,6 +36,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
@@ -27,12 +46,45 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/glebarez/go-sqlite v1.20.3 h1:89BkqGOXR9oRmG58ZrzgoY/Fhy5x0M+/WV48U5zVrZ4=
|
||||
github.com/glebarez/go-sqlite v1.20.3/go.mod h1:u3N6D/wftiAzIOJtZl6BmedqxmmkDfH3q+ihjqxC9u0=
|
||||
github.com/glebarez/sqlite v1.7.0 h1:A7Xj/KN2Lvie4Z4rrgQHY8MsbebX3NyWsL3n2i82MVI=
|
||||
github.com/glebarez/sqlite v1.7.0/go.mod h1:PkeevrRlF/1BhQBCnzcMWzgrIk7IOop+qS2jUYLfHhk=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
|
||||
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -47,21 +99,32 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
|
||||
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -70,6 +133,12 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -91,16 +160,18 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microsoft/go-mssqldb v0.19.0/go.mod h1:ukJCBnnzLzpVF0qYRT+eg1e+eSwjeQ7IvenUv8QPook=
|
||||
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
|
||||
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
||||
github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q=
|
||||
github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
|
||||
@@ -110,18 +181,27 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
|
||||
github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
|
||||
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
|
||||
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578 h1:VstopitMQi3hZP0fzvnsLmzXZdQGc4bEcgu24cp+d4M=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
@@ -141,6 +221,8 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@@ -150,8 +232,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
||||
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
@@ -163,8 +251,8 @@ github.com/wenlng/go-captcha/v2 v2.0.4/go.mod h1:5hac1em3uXoyC5ipZ0xFv9umNM/waQv
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
@@ -174,7 +262,14 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
|
||||
@@ -182,42 +277,86 @@ golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220224120231-95c6836cb0e7/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -226,7 +365,13 @@ google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
|
||||
@@ -239,5 +384,16 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc=
|
||||
gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gorm.io/plugin/dbresolver v1.6.0 h1:XvKDeOtTn1EIX6s4SrKpEH82q0gXVemhYjbYZFGFVcw=
|
||||
gorm.io/plugin/dbresolver v1.6.0/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
|
||||
modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0=
|
||||
modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.20.3 h1:SqGJMMxjj1PHusLxdYxeQSodg7Jxn9WWkaAQjKrntZs=
|
||||
modernc.org/sqlite v1.20.3/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
|
||||
|
||||
@@ -22,6 +22,7 @@ type Container struct {
|
||||
Redis *redis.Client
|
||||
Logger *zap.Logger
|
||||
JWT *auth.JWTService
|
||||
Casbin *auth.CasbinService
|
||||
Storage *storage.StorageClient
|
||||
CacheManager *database.CacheManager
|
||||
|
||||
@@ -31,7 +32,6 @@ type Container struct {
|
||||
TextureRepo repository.TextureRepository
|
||||
TokenRepo repository.TokenRepository
|
||||
ClientRepo repository.ClientRepository
|
||||
ConfigRepo repository.SystemConfigRepository
|
||||
YggdrasilRepo repository.YggdrasilRepository
|
||||
|
||||
// Service层
|
||||
@@ -41,7 +41,6 @@ type Container struct {
|
||||
TokenService service.TokenService
|
||||
YggdrasilService service.YggdrasilService
|
||||
VerificationService service.VerificationService
|
||||
UploadService service.UploadService
|
||||
SecurityService service.SecurityService
|
||||
CaptchaService service.CaptchaService
|
||||
SignatureService *service.SignatureService
|
||||
@@ -53,6 +52,7 @@ func NewContainer(
|
||||
redisClient *redis.Client,
|
||||
logger *zap.Logger,
|
||||
jwtService *auth.JWTService,
|
||||
casbinService *auth.CasbinService,
|
||||
storageClient *storage.StorageClient,
|
||||
emailService interface{}, // 接受 email.Service 但使用 interface{} 避免循环依赖
|
||||
) *Container {
|
||||
@@ -68,6 +68,7 @@ func NewContainer(
|
||||
Redis: redisClient,
|
||||
Logger: logger,
|
||||
JWT: jwtService,
|
||||
Casbin: casbinService,
|
||||
Storage: storageClient,
|
||||
CacheManager: cacheManager,
|
||||
}
|
||||
@@ -78,7 +79,6 @@ func NewContainer(
|
||||
c.TextureRepo = repository.NewTextureRepository(db)
|
||||
c.TokenRepo = repository.NewTokenRepository(db)
|
||||
c.ClientRepo = repository.NewClientRepository(db)
|
||||
c.ConfigRepo = repository.NewSystemConfigRepository(db)
|
||||
c.YggdrasilRepo = repository.NewYggdrasilRepository(db)
|
||||
|
||||
// 初始化SignatureService(作为依赖注入,避免在容器中创建并立即调用)
|
||||
@@ -86,7 +86,7 @@ func NewContainer(
|
||||
c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger)
|
||||
|
||||
// 初始化Service(注入缓存管理器)
|
||||
c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, cacheManager, logger)
|
||||
c.UserService = service.NewUserService(c.UserRepo, jwtService, redisClient, cacheManager, storageClient, logger)
|
||||
c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger)
|
||||
c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger)
|
||||
|
||||
@@ -105,7 +105,6 @@ func NewContainer(
|
||||
|
||||
// 初始化其他服务
|
||||
c.SecurityService = service.NewSecurityService(redisClient)
|
||||
c.UploadService = service.NewUploadService(storageClient)
|
||||
c.CaptchaService = service.NewCaptchaService(redisClient, logger)
|
||||
|
||||
// 初始化VerificationService(需要email.Service)
|
||||
@@ -193,13 +192,6 @@ func WithTokenRepo(repo repository.TokenRepository) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithConfigRepo 设置系统配置仓储
|
||||
func WithConfigRepo(repo repository.SystemConfigRepository) Option {
|
||||
return func(c *Container) {
|
||||
c.ConfigRepo = repo
|
||||
}
|
||||
}
|
||||
|
||||
// WithUserService 设置用户服务
|
||||
func WithUserService(svc service.UserService) Option {
|
||||
return func(c *Container) {
|
||||
@@ -249,13 +241,6 @@ func WithVerificationService(svc service.VerificationService) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithUploadService 设置上传服务
|
||||
func WithUploadService(svc service.UploadService) Option {
|
||||
return func(c *Container) {
|
||||
c.UploadService = svc
|
||||
}
|
||||
}
|
||||
|
||||
// WithSecurityService 设置安全服务
|
||||
func WithSecurityService(svc service.SecurityService) Option {
|
||||
return func(c *Container) {
|
||||
|
||||
366
internal/handler/admin_handler.go
Normal file
366
internal/handler/admin_handler.go
Normal file
@@ -0,0 +1,366 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"carrotskin/internal/container"
|
||||
"carrotskin/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminHandler 管理员处理器
|
||||
type AdminHandler struct {
|
||||
container *container.Container
|
||||
}
|
||||
|
||||
// NewAdminHandler 创建管理员处理器
|
||||
func NewAdminHandler(c *container.Container) *AdminHandler {
|
||||
return &AdminHandler{container: c}
|
||||
}
|
||||
|
||||
// SetUserRoleRequest 设置用户角色请求
|
||||
type SetUserRoleRequest struct {
|
||||
UserID int64 `json:"user_id" binding:"required"`
|
||||
Role string `json:"role" binding:"required,oneof=user admin"`
|
||||
}
|
||||
|
||||
// SetUserRole 设置用户角色
|
||||
// @Summary 设置用户角色
|
||||
// @Description 管理员设置指定用户的角色
|
||||
// @Tags Admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body SetUserRoleRequest true "设置角色请求"
|
||||
// @Success 200 {object} model.Response
|
||||
// @Failure 400 {object} model.Response
|
||||
// @Failure 403 {object} model.Response
|
||||
// @Security BearerAuth
|
||||
// @Router /admin/users/role [put]
|
||||
func (h *AdminHandler) SetUserRole(c *gin.Context) {
|
||||
var req SetUserRoleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondBadRequest(c, "参数错误", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前操作者ID
|
||||
operatorID, _ := c.Get("user_id")
|
||||
|
||||
// 不能修改自己的角色
|
||||
if req.UserID == operatorID.(int64) {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"不能修改自己的角色",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查目标用户是否存在
|
||||
targetUser, err := h.container.UserRepo.FindByID(c.Request.Context(), req.UserID)
|
||||
if err != nil || targetUser == nil {
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
"用户不存在",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 更新用户角色
|
||||
err = h.container.UserRepo.UpdateFields(c.Request.Context(), req.UserID, map[string]interface{}{
|
||||
"role": req.Role,
|
||||
})
|
||||
if err != nil {
|
||||
RespondServerError(c, "更新用户角色失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
h.container.Logger.Info("管理员修改用户角色",
|
||||
zap.Int64("operator_id", operatorID.(int64)),
|
||||
zap.Int64("target_user_id", req.UserID),
|
||||
zap.String("new_role", req.Role),
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
|
||||
"message": "用户角色更新成功",
|
||||
"user_id": req.UserID,
|
||||
"role": req.Role,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetUserList 获取用户列表
|
||||
// @Summary 获取用户列表
|
||||
// @Description 管理员获取所有用户列表
|
||||
// @Tags Admin
|
||||
// @Produce json
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页数量" default(20)
|
||||
// @Success 200 {object} model.Response
|
||||
// @Failure 403 {object} model.Response
|
||||
// @Security BearerAuth
|
||||
// @Router /admin/users [get]
|
||||
func (h *AdminHandler) GetUserList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
// 使用数据库直接查询用户列表
|
||||
var users []model.User
|
||||
var total int64
|
||||
|
||||
db := h.container.DB
|
||||
db.Model(&model.User{}).Count(&total)
|
||||
db.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&users)
|
||||
|
||||
// 构建响应(隐藏敏感信息)
|
||||
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{
|
||||
"users": userList,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetUserDetail 获取用户详情
|
||||
// @Summary 获取用户详情
|
||||
// @Description 管理员获取指定用户的详细信息
|
||||
// @Tags Admin
|
||||
// @Produce json
|
||||
// @Param id path int true "用户ID"
|
||||
// @Success 200 {object} model.Response
|
||||
// @Failure 404 {object} model.Response
|
||||
// @Security BearerAuth
|
||||
// @Router /admin/users/{id} [get]
|
||||
func (h *AdminHandler) GetUserDetail(c *gin.Context) {
|
||||
userID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
RespondBadRequest(c, "无效的用户ID", err)
|
||||
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
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"avatar": user.Avatar,
|
||||
"role": user.Role,
|
||||
"status": user.Status,
|
||||
"points": user.Points,
|
||||
"properties": user.Properties,
|
||||
"last_login_at": user.LastLoginAt,
|
||||
"created_at": user.CreatedAt,
|
||||
"updated_at": user.UpdatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// SetUserStatusRequest 设置用户状态请求
|
||||
type SetUserStatusRequest struct {
|
||||
UserID int64 `json:"user_id" binding:"required"`
|
||||
Status int16 `json:"status" binding:"required,oneof=1 0 -1"` // 1:正常, 0:禁用, -1:删除
|
||||
}
|
||||
|
||||
// SetUserStatus 设置用户状态
|
||||
// @Summary 设置用户状态
|
||||
// @Description 管理员设置用户状态(启用/禁用)
|
||||
// @Tags Admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body SetUserStatusRequest true "设置状态请求"
|
||||
// @Success 200 {object} model.Response
|
||||
// @Failure 400 {object} model.Response
|
||||
// @Security BearerAuth
|
||||
// @Router /admin/users/status [put]
|
||||
func (h *AdminHandler) SetUserStatus(c *gin.Context) {
|
||||
var req SetUserStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondBadRequest(c, "参数错误", err)
|
||||
return
|
||||
}
|
||||
|
||||
operatorID, _ := c.Get("user_id")
|
||||
|
||||
// 不能修改自己的状态
|
||||
if req.UserID == operatorID.(int64) {
|
||||
c.JSON(http.StatusBadRequest, model.NewErrorResponse(
|
||||
model.CodeBadRequest,
|
||||
"不能修改自己的状态",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查目标用户是否存在
|
||||
targetUser, err := h.container.UserRepo.FindByID(c.Request.Context(), req.UserID)
|
||||
if err != nil || targetUser == nil {
|
||||
c.JSON(http.StatusNotFound, model.NewErrorResponse(
|
||||
model.CodeNotFound,
|
||||
"用户不存在",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 更新用户状态
|
||||
err = h.container.UserRepo.UpdateFields(c.Request.Context(), req.UserID, map[string]interface{}{
|
||||
"status": req.Status,
|
||||
})
|
||||
if err != nil {
|
||||
RespondServerError(c, "更新用户状态失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
statusText := map[int16]string{1: "正常", 0: "禁用", -1: "删除"}[req.Status]
|
||||
|
||||
h.container.Logger.Info("管理员修改用户状态",
|
||||
zap.Int64("operator_id", operatorID.(int64)),
|
||||
zap.Int64("target_user_id", req.UserID),
|
||||
zap.Int16("new_status", req.Status),
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
|
||||
"message": "用户状态更新成功",
|
||||
"user_id": req.UserID,
|
||||
"status": req.Status,
|
||||
"status_text": statusText,
|
||||
}))
|
||||
}
|
||||
|
||||
// DeleteTexture 管理员删除材质
|
||||
// @Summary 管理员删除材质
|
||||
// @Description 管理员可以删除任意材质(用于审核不当内容)
|
||||
// @Tags Admin
|
||||
// @Produce json
|
||||
// @Param id path int true "材质ID"
|
||||
// @Success 200 {object} model.Response
|
||||
// @Failure 404 {object} model.Response
|
||||
// @Security BearerAuth
|
||||
// @Router /admin/textures/{id} [delete]
|
||||
func (h *AdminHandler) DeleteTexture(c *gin.Context) {
|
||||
textureID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
RespondBadRequest(c, "无效的材质ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
operatorID, _ := c.Get("user_id")
|
||||
|
||||
// 检查材质是否存在
|
||||
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
|
||||
}
|
||||
|
||||
// 删除材质
|
||||
if err := h.container.DB.Delete(&texture).Error; err != nil {
|
||||
RespondServerError(c, "删除材质失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
h.container.Logger.Info("管理员删除材质",
|
||||
zap.Int64("operator_id", operatorID.(int64)),
|
||||
zap.Int64("texture_id", textureID),
|
||||
zap.Int64("uploader_id", texture.UploaderID),
|
||||
zap.String("texture_name", texture.Name),
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
|
||||
"message": "材质删除成功",
|
||||
"texture_id": textureID,
|
||||
}))
|
||||
}
|
||||
|
||||
// GetTextureList 管理员获取材质列表
|
||||
// @Summary 管理员获取材质列表
|
||||
// @Description 管理员获取所有材质列表(用于审核)
|
||||
// @Tags Admin
|
||||
// @Produce json
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页数量" default(20)
|
||||
// @Success 200 {object} model.Response
|
||||
// @Security BearerAuth
|
||||
// @Router /admin/textures [get]
|
||||
func (h *AdminHandler) GetTextureList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
var textures []model.Texture
|
||||
var total int64
|
||||
|
||||
db := h.container.DB
|
||||
db.Model(&model.Texture{}).Count(&total)
|
||||
db.Preload("Uploader").Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&textures)
|
||||
|
||||
// 构建响应
|
||||
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,
|
||||
"created_at": t.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.NewSuccessResponse(gin.H{
|
||||
"textures": textureList,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
}))
|
||||
}
|
||||
@@ -70,7 +70,6 @@ func ProfileToProfileInfo(profile *model.Profile) *types.ProfileInfo {
|
||||
Name: profile.Name,
|
||||
SkinID: profile.SkinID,
|
||||
CapeID: profile.CapeID,
|
||||
IsActive: profile.IsActive,
|
||||
LastUsedAt: profile.LastUsedAt,
|
||||
CreatedAt: profile.CreatedAt,
|
||||
UpdatedAt: profile.UpdatedAt,
|
||||
@@ -173,24 +172,24 @@ func RespondWithError(c *gin.Context, err error) {
|
||||
}
|
||||
|
||||
// 使用errors.Is检查预定义错误
|
||||
if errors.Is(err, errors.ErrUserNotFound) ||
|
||||
errors.Is(err, errors.ErrProfileNotFound) ||
|
||||
errors.Is(err, errors.ErrTextureNotFound) ||
|
||||
errors.Is(err, errors.ErrNotFound) {
|
||||
if errors.Is(err, errors.ErrUserNotFound) ||
|
||||
errors.Is(err, errors.ErrProfileNotFound) ||
|
||||
errors.Is(err, errors.ErrTextureNotFound) ||
|
||||
errors.Is(err, errors.ErrNotFound) {
|
||||
RespondNotFound(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, errors.ErrProfileNoPermission) ||
|
||||
errors.Is(err, errors.ErrTextureNoPermission) ||
|
||||
errors.Is(err, errors.ErrForbidden) {
|
||||
if errors.Is(err, errors.ErrProfileNoPermission) ||
|
||||
errors.Is(err, errors.ErrTextureNoPermission) ||
|
||||
errors.Is(err, errors.ErrForbidden) {
|
||||
RespondForbidden(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, errors.ErrUnauthorized) ||
|
||||
errors.Is(err, errors.ErrInvalidToken) ||
|
||||
errors.Is(err, errors.ErrTokenExpired) {
|
||||
if errors.Is(err, errors.ErrUnauthorized) ||
|
||||
errors.Is(err, errors.ErrInvalidToken) ||
|
||||
errors.Is(err, errors.ErrTokenExpired) {
|
||||
RespondUnauthorized(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -207,39 +207,3 @@ func (h *ProfileHandler) Delete(c *gin.Context) {
|
||||
|
||||
RespondSuccess(c, gin.H{"message": "删除成功"})
|
||||
}
|
||||
|
||||
// SetActive 设置活跃档案
|
||||
// @Summary 设置活跃档案
|
||||
// @Description 将指定档案设置为活跃状态
|
||||
// @Tags profile
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param uuid path string true "档案UUID"
|
||||
// @Success 200 {object} model.Response "设置成功"
|
||||
// @Failure 403 {object} model.ErrorResponse "无权操作"
|
||||
// @Router /api/v1/profile/{uuid}/activate [post]
|
||||
func (h *ProfileHandler) SetActive(c *gin.Context) {
|
||||
userID, ok := GetUserIDFromContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
uuid := c.Param("uuid")
|
||||
if uuid == "" {
|
||||
RespondBadRequest(c, "UUID不能为空", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.container.ProfileService.SetActive(c.Request.Context(), uuid, userID); err != nil {
|
||||
h.logger.Error("设置活跃档案失败",
|
||||
zap.String("uuid", uuid),
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
RespondWithError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, gin.H{"message": "设置成功"})
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"carrotskin/internal/middleware"
|
||||
"carrotskin/internal/model"
|
||||
"carrotskin/pkg/auth"
|
||||
"carrotskin/pkg/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
// Handlers 集中管理所有Handler
|
||||
@@ -18,6 +21,7 @@ type Handlers struct {
|
||||
Captcha *CaptchaHandler
|
||||
Yggdrasil *YggdrasilHandler
|
||||
CustomSkin *CustomSkinHandler
|
||||
Admin *AdminHandler
|
||||
}
|
||||
|
||||
// NewHandlers 创建所有Handler实例
|
||||
@@ -30,6 +34,7 @@ func NewHandlers(c *container.Container) *Handlers {
|
||||
Captcha: NewCaptchaHandler(c),
|
||||
Yggdrasil: NewYggdrasilHandler(c),
|
||||
CustomSkin: NewCustomSkinHandler(c),
|
||||
Admin: NewAdminHandler(c),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +43,9 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
|
||||
// 健康检查路由
|
||||
router.GET("/health", HealthCheck)
|
||||
|
||||
// Swagger文档路由
|
||||
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
// 创建Handler实例
|
||||
h := NewHandlers(c)
|
||||
|
||||
@@ -63,10 +71,13 @@ func RegisterRoutesWithDI(router *gin.Engine, c *container.Container) {
|
||||
registerYggdrasilRoutesWithDI(v1, h.Yggdrasil)
|
||||
|
||||
// 系统路由
|
||||
registerSystemRoutes(v1)
|
||||
registerSystemRoutes(v1, c)
|
||||
|
||||
// CustomSkinAPI 路由
|
||||
registerCustomSkinRoutes(v1, h.CustomSkin)
|
||||
|
||||
// 管理员路由(需要管理员权限)
|
||||
registerAdminRoutes(v1, c, h.Admin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,8 +101,8 @@ func registerUserRoutes(v1 *gin.RouterGroup, h *UserHandler, jwtService *auth.JW
|
||||
userGroup.PUT("/profile", h.UpdateProfile)
|
||||
|
||||
// 头像相关
|
||||
userGroup.POST("/avatar/upload-url", h.GenerateAvatarUploadURL)
|
||||
userGroup.PUT("/avatar", h.UpdateAvatar)
|
||||
userGroup.POST("/avatar/upload", h.UploadAvatar) // 直接上传头像文件
|
||||
userGroup.PUT("/avatar", h.UpdateAvatar) // 更新头像URL(外部URL)
|
||||
|
||||
// 更换邮箱
|
||||
userGroup.POST("/change-email", h.ChangeEmail)
|
||||
@@ -113,9 +124,7 @@ func registerTextureRoutes(v1 *gin.RouterGroup, h *TextureHandler, jwtService *a
|
||||
textureAuth := textureGroup.Group("")
|
||||
textureAuth.Use(middleware.AuthMiddleware(jwtService))
|
||||
{
|
||||
textureAuth.POST("/upload", h.Upload) // 直接上传文件
|
||||
textureAuth.POST("/upload-url", h.GenerateUploadURL) // 生成预签名URL(保留兼容性)
|
||||
textureAuth.POST("", h.Create) // 创建材质记录(配合预签名URL使用)
|
||||
textureAuth.POST("/upload", h.Upload) // 直接上传文件
|
||||
textureAuth.PUT("/:id", h.Update)
|
||||
textureAuth.DELETE("/:id", h.Delete)
|
||||
textureAuth.POST("/:id/favorite", h.ToggleFavorite)
|
||||
@@ -143,7 +152,6 @@ func registerProfileRoutesWithDI(v1 *gin.RouterGroup, h *ProfileHandler, jwtServ
|
||||
profileAuth.GET("/", h.List)
|
||||
profileAuth.PUT("/:uuid", h.Update)
|
||||
profileAuth.DELETE("/:uuid", h.Delete)
|
||||
profileAuth.POST("/:uuid/activate", h.SetActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,17 +194,46 @@ func registerYggdrasilRoutesWithDI(v1 *gin.RouterGroup, h *YggdrasilHandler) {
|
||||
}
|
||||
|
||||
// registerSystemRoutes 注册系统路由
|
||||
func registerSystemRoutes(v1 *gin.RouterGroup) {
|
||||
func registerSystemRoutes(v1 *gin.RouterGroup, c *container.Container) {
|
||||
system := v1.Group("/system")
|
||||
{
|
||||
system.GET("/config", func(c *gin.Context) {
|
||||
// TODO: 实现从数据库读取系统配置
|
||||
c.JSON(200, model.NewSuccessResponse(gin.H{
|
||||
"site_name": "CarrotSkin",
|
||||
"site_description": "A Minecraft Skin Station",
|
||||
"registration_enabled": true,
|
||||
"max_textures_per_user": 100,
|
||||
"max_profiles_per_user": 5,
|
||||
// 公开配置(无需认证)
|
||||
system.GET("/config", func(ctx *gin.Context) {
|
||||
cfg, _ := config.GetConfig()
|
||||
ctx.JSON(200, model.NewSuccessResponse(gin.H{
|
||||
"site_name": cfg.Site.Name,
|
||||
"site_description": cfg.Site.Description,
|
||||
"registration_enabled": cfg.Site.RegistrationEnabled,
|
||||
"max_textures_per_user": cfg.Site.MaxTexturesPerUser,
|
||||
"max_profiles_per_user": cfg.Site.MaxProfilesPerUser,
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// registerAdminRoutes 注册管理员路由
|
||||
func registerAdminRoutes(v1 *gin.RouterGroup, c *container.Container, h *AdminHandler) {
|
||||
admin := v1.Group("/admin")
|
||||
admin.Use(middleware.AuthMiddleware(c.JWT))
|
||||
admin.Use(middleware.RequireAdmin())
|
||||
{
|
||||
|
||||
// 用户管理
|
||||
admin.GET("/users", h.GetUserList)
|
||||
admin.GET("/users/:id", h.GetUserDetail)
|
||||
admin.PUT("/users/role", h.SetUserRole)
|
||||
admin.PUT("/users/status", h.SetUserStatus)
|
||||
|
||||
// 材质管理(审核)
|
||||
admin.GET("/textures", h.GetTextureList)
|
||||
admin.DELETE("/textures/:id", h.DeleteTexture)
|
||||
|
||||
// 权限管理
|
||||
admin.GET("/permissions", func(ctx *gin.Context) {
|
||||
// 获取所有权限规则
|
||||
policies, _ := c.Casbin.GetEnforcer().GetPolicy()
|
||||
ctx.JSON(200, model.NewSuccessResponse(gin.H{
|
||||
"policies": policies,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,93 +24,6 @@ func NewTextureHandler(c *container.Container) *TextureHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateUploadURL 生成材质上传URL
|
||||
func (h *TextureHandler) GenerateUploadURL(c *gin.Context) {
|
||||
userID, ok := GetUserIDFromContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req types.GenerateTextureUploadURLRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondBadRequest(c, "请求参数错误", err)
|
||||
return
|
||||
}
|
||||
|
||||
if h.container.Storage == nil {
|
||||
RespondServerError(c, "存储服务不可用", nil)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.container.UploadService.GenerateTextureUploadURL(
|
||||
c.Request.Context(),
|
||||
userID,
|
||||
req.FileName,
|
||||
string(req.TextureType),
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("生成材质上传URL失败",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("file_name", req.FileName),
|
||||
zap.String("texture_type", string(req.TextureType)),
|
||||
zap.Error(err),
|
||||
)
|
||||
RespondBadRequest(c, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, &types.GenerateTextureUploadURLResponse{
|
||||
PostURL: result.PostURL,
|
||||
FormData: result.FormData,
|
||||
TextureURL: result.FileURL,
|
||||
ExpiresIn: 900,
|
||||
})
|
||||
}
|
||||
|
||||
// Create 创建材质记录
|
||||
func (h *TextureHandler) Create(c *gin.Context) {
|
||||
userID, ok := GetUserIDFromContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req types.CreateTextureRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondBadRequest(c, "请求参数错误", err)
|
||||
return
|
||||
}
|
||||
|
||||
maxTextures := h.container.UserService.GetMaxTexturesPerUser()
|
||||
if err := h.container.TextureService.CheckUploadLimit(c.Request.Context(), userID, maxTextures); err != nil {
|
||||
RespondBadRequest(c, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
texture, err := h.container.TextureService.Create(
|
||||
c.Request.Context(),
|
||||
userID,
|
||||
req.Name,
|
||||
req.Description,
|
||||
string(req.Type),
|
||||
req.URL,
|
||||
req.Hash,
|
||||
req.Size,
|
||||
req.IsPublic,
|
||||
req.IsSlim,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("创建材质失败",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("name", req.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
RespondBadRequest(c, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, TextureToTextureInfo(texture))
|
||||
}
|
||||
|
||||
// Get 获取材质详情
|
||||
func (h *TextureHandler) Get(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
|
||||
@@ -102,44 +102,66 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
RespondSuccess(c, UserToUserInfo(updatedUser))
|
||||
}
|
||||
|
||||
// GenerateAvatarUploadURL 生成头像上传URL
|
||||
func (h *UserHandler) GenerateAvatarUploadURL(c *gin.Context) {
|
||||
// UploadAvatar 直接上传头像文件
|
||||
func (h *UserHandler) UploadAvatar(c *gin.Context) {
|
||||
userID, ok := GetUserIDFromContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req types.GenerateAvatarUploadURLRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
RespondBadRequest(c, "请求参数错误", err)
|
||||
// 解析multipart表单
|
||||
if err := c.Request.ParseMultipartForm(10 << 20); err != nil { // 10MB
|
||||
RespondBadRequest(c, "解析表单失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
if h.container.Storage == nil {
|
||||
RespondServerError(c, "存储服务不可用", nil)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.container.UploadService.GenerateAvatarUploadURL(c.Request.Context(), userID, req.FileName)
|
||||
// 获取文件
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
h.logger.Error("生成头像上传URL失败",
|
||||
RespondBadRequest(c, "获取文件失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
RespondBadRequest(c, "打开文件失败", err)
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
fileData := make([]byte, file.Size)
|
||||
if _, err := src.Read(fileData); err != nil {
|
||||
RespondBadRequest(c, "读取文件失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 调用服务上传头像
|
||||
avatarURL, err := h.container.UserService.UploadAvatar(c.Request.Context(), userID, fileData, file.Filename)
|
||||
if err != nil {
|
||||
h.logger.Error("上传头像失败",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("file_name", req.FileName),
|
||||
zap.String("file_name", file.Filename),
|
||||
zap.Error(err),
|
||||
)
|
||||
RespondBadRequest(c, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, &types.GenerateAvatarUploadURLResponse{
|
||||
PostURL: result.PostURL,
|
||||
FormData: result.FormData,
|
||||
AvatarURL: result.FileURL,
|
||||
ExpiresIn: 900,
|
||||
// 获取更新后的用户信息
|
||||
user, err := h.container.UserService.GetByID(c.Request.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
RespondNotFound(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, gin.H{
|
||||
"avatar_url": avatarURL,
|
||||
"user": UserToUserInfo(user),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateAvatar 更新头像URL
|
||||
// UpdateAvatar 更新头像URL(保留用于外部URL)
|
||||
func (h *UserHandler) UpdateAvatar(c *gin.Context) {
|
||||
userID, ok := GetUserIDFromContext(c)
|
||||
if !ok {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"carrotskin/pkg/auth"
|
||||
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -51,7 +51,7 @@ func AuthMiddleware(jwtService *auth.JWTService) gin.HandlerFunc {
|
||||
// 将用户信息存储到上下文中
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("user_role", claims.Role)
|
||||
|
||||
c.Next()
|
||||
})
|
||||
@@ -69,7 +69,7 @@ func OptionalAuthMiddleware(jwtService *auth.JWTService) gin.HandlerFunc {
|
||||
if err == nil {
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("role", claims.Role)
|
||||
c.Set("user_role", claims.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
109
internal/middleware/casbin.go
Normal file
109
internal/middleware/casbin.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"carrotskin/pkg/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CasbinMiddleware Casbin权限中间件
|
||||
// 需要先经过AuthMiddleware获取用户信息
|
||||
func CasbinMiddleware(casbinService *auth.CasbinService, resource, action string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 从上下文获取用户角色(由AuthMiddleware设置)
|
||||
role, exists := c.Get("user_role")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "未授权访问",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
roleStr, ok := role.(string)
|
||||
if !ok || roleStr == "" {
|
||||
roleStr = "user" // 默认角色
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if !casbinService.CheckPermission(roleStr, resource, action) {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "权限不足",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin 要求管理员权限的中间件
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, exists := c.Get("user_role")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "未授权访问",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
roleStr, ok := role.(string)
|
||||
if !ok || roleStr != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "需要管理员权限",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireRole 要求指定角色的中间件
|
||||
func RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, exists := c.Get("user_role")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "未授权访问",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
roleStr, ok := role.(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "权限不足",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否在允许的角色列表中
|
||||
for _, allowed := range allowedRoles {
|
||||
if roleStr == allowed {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "权限不足",
|
||||
})
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
@@ -23,3 +23,9 @@ type BaseModel struct {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,12 +7,11 @@ import (
|
||||
// Profile Minecraft 档案模型
|
||||
type Profile struct {
|
||||
UUID string `gorm:"column:uuid;type:varchar(36);primaryKey" json:"uuid"`
|
||||
UserID int64 `gorm:"column:user_id;not null;index:idx_profiles_user_created,priority:1;index:idx_profiles_user_active,priority:1" json:"user_id"`
|
||||
UserID int64 `gorm:"column:user_id;not null;index:idx_profiles_user_created,priority:1" json:"user_id"`
|
||||
Name string `gorm:"column:name;type:varchar(16);not null;uniqueIndex:idx_profiles_name" json:"name"` // Minecraft 角色名
|
||||
SkinID *int64 `gorm:"column:skin_id;type:bigint;index:idx_profiles_skin_id" json:"skin_id,omitempty"`
|
||||
CapeID *int64 `gorm:"column:cape_id;type:bigint;index:idx_profiles_cape_id" json:"cape_id,omitempty"`
|
||||
RSAPrivateKey string `gorm:"column:rsa_private_key;type:text;not null" json:"-"` // RSA 私钥不返回给前端
|
||||
IsActive bool `gorm:"column:is_active;not null;default:true;index:idx_profiles_user_active,priority:2" json:"is_active"`
|
||||
LastUsedAt *time.Time `gorm:"column:last_used_at;type:timestamp;index:idx_profiles_last_used,sort:desc" json:"last_used_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_profiles_user_created,priority:2,sort:desc" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
|
||||
@@ -33,7 +32,6 @@ type ProfileResponse struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Textures ProfileTexturesData `json:"textures"`
|
||||
IsActive bool `json:"is_active"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConfigType 配置类型
|
||||
type ConfigType string
|
||||
|
||||
const (
|
||||
ConfigTypeString ConfigType = "STRING"
|
||||
ConfigTypeInteger ConfigType = "INTEGER"
|
||||
ConfigTypeBoolean ConfigType = "BOOLEAN"
|
||||
ConfigTypeJSON ConfigType = "JSON"
|
||||
)
|
||||
|
||||
// SystemConfig 系统配置模型
|
||||
type SystemConfig struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Key string `gorm:"column:key;type:varchar(100);not null;uniqueIndex" json:"key"`
|
||||
Value string `gorm:"column:value;type:text;not null" json:"value"`
|
||||
Description string `gorm:"column:description;type:varchar(255);not null;default:''" json:"description"`
|
||||
Type ConfigType `gorm:"column:type;type:varchar(50);not null;default:'STRING'" json:"type"` // STRING, INTEGER, BOOLEAN, JSON
|
||||
IsPublic bool `gorm:"column:is_public;not null;default:false;index" json:"is_public"` // 是否可被前端获取
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (SystemConfig) TableName() string {
|
||||
return "system_config"
|
||||
}
|
||||
|
||||
// SystemConfigPublicResponse 公开配置响应
|
||||
type SystemConfigPublicResponse struct {
|
||||
SiteName string `json:"site_name"`
|
||||
SiteDescription string `json:"site_description"`
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
MaintenanceMode bool `json:"maintenance_mode"`
|
||||
Announcement string `json:"announcement"`
|
||||
}
|
||||
@@ -35,7 +35,6 @@ type ProfileRepository interface {
|
||||
Delete(ctx context.Context, uuid string) error
|
||||
BatchDelete(ctx context.Context, uuids []string) (int64, error) // 批量删除
|
||||
CountByUserID(ctx context.Context, userID int64) (int64, error)
|
||||
SetActive(ctx context.Context, uuid string, userID int64) error
|
||||
UpdateLastUsedAt(ctx context.Context, uuid string) error
|
||||
GetByNames(ctx context.Context, names []string) ([]*model.Profile, error)
|
||||
GetKeyPair(ctx context.Context, profileId string) (*model.KeyPair, error)
|
||||
@@ -79,15 +78,6 @@ type TokenRepository interface {
|
||||
BatchDelete(ctx context.Context, accessTokens []string) (int64, error)
|
||||
}
|
||||
|
||||
// SystemConfigRepository 系统配置仓储接口
|
||||
type SystemConfigRepository interface {
|
||||
GetByKey(ctx context.Context, key string) (*model.SystemConfig, error)
|
||||
GetPublic(ctx context.Context) ([]model.SystemConfig, error)
|
||||
GetAll(ctx context.Context) ([]model.SystemConfig, error)
|
||||
Update(ctx context.Context, config *model.SystemConfig) error
|
||||
UpdateValue(ctx context.Context, key, value string) error
|
||||
}
|
||||
|
||||
// YggdrasilRepository Yggdrasil仓储接口
|
||||
type YggdrasilRepository interface {
|
||||
GetPasswordByID(ctx context.Context, id int64) (string, error)
|
||||
|
||||
@@ -109,20 +109,6 @@ func (r *profileRepository) CountByUserID(ctx context.Context, userID int64) (in
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *profileRepository) SetActive(ctx context.Context, uuid string, userID int64) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.Profile{}).
|
||||
Where("user_id = ?", userID).
|
||||
Update("is_active", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Model(&model.Profile{}).
|
||||
Where("uuid = ? AND user_id = ?", uuid, userID).
|
||||
Update("is_active", true).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *profileRepository) UpdateLastUsedAt(ctx context.Context, uuid string) error {
|
||||
return r.db.WithContext(ctx).Model(&model.Profile{}).
|
||||
Where("uuid = ?", uuid).
|
||||
|
||||
@@ -42,41 +42,6 @@ func TestProfileRepository_QueryConditions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileRepository_SetActiveLogic 测试设置活跃档案的逻辑
|
||||
func TestProfileRepository_SetActiveLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
userID int64
|
||||
otherProfiles int
|
||||
wantAllInactive bool
|
||||
}{
|
||||
{
|
||||
name: "设置一个档案为活跃,其他应该变为非活跃",
|
||||
uuid: "profile-1",
|
||||
userID: 1,
|
||||
otherProfiles: 2,
|
||||
wantAllInactive: true,
|
||||
},
|
||||
{
|
||||
name: "只有一个档案时",
|
||||
uuid: "profile-1",
|
||||
userID: 1,
|
||||
otherProfiles: 0,
|
||||
wantAllInactive: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证逻辑:设置一个档案为活跃时,应该先将所有档案设为非活跃
|
||||
if !tt.wantAllInactive {
|
||||
t.Error("Setting active profile should first set all profiles to inactive")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileRepository_CountLogic 测试统计逻辑
|
||||
func TestProfileRepository_CountLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -109,30 +74,30 @@ func TestProfileRepository_CountLogic(t *testing.T) {
|
||||
// TestProfileRepository_UpdateFieldsLogic 测试更新字段逻辑
|
||||
func TestProfileRepository_UpdateFieldsLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
updates map[string]interface{}
|
||||
name string
|
||||
uuid string
|
||||
updates map[string]interface{}
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "有效的更新",
|
||||
uuid: "123e4567-e89b-12d3-a456-426614174000",
|
||||
updates: map[string]interface{}{
|
||||
"name": "NewName",
|
||||
"name": "NewName",
|
||||
"skin_id": int64(1),
|
||||
},
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "UUID为空",
|
||||
uuid: "",
|
||||
updates: map[string]interface{}{"name": "NewName"},
|
||||
name: "UUID为空",
|
||||
uuid: "",
|
||||
updates: map[string]interface{}{"name": "NewName"},
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "更新字段为空",
|
||||
uuid: "123e4567-e89b-12d3-a456-426614174000",
|
||||
updates: map[string]interface{}{},
|
||||
name: "更新字段为空",
|
||||
uuid: "123e4567-e89b-12d3-a456-426614174000",
|
||||
updates: map[string]interface{}{},
|
||||
wantValid: true, // 空更新也是有效的,只是不会更新任何字段
|
||||
},
|
||||
}
|
||||
@@ -150,24 +115,24 @@ func TestProfileRepository_UpdateFieldsLogic(t *testing.T) {
|
||||
// TestProfileRepository_FindOneProfileLogic 测试查找单个档案的逻辑
|
||||
func TestProfileRepository_FindOneProfileLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
name string
|
||||
profileCount int
|
||||
wantError bool
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "有档案时返回第一个",
|
||||
name: "有档案时返回第一个",
|
||||
profileCount: 1,
|
||||
wantError: false,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "多个档案时返回第一个",
|
||||
name: "多个档案时返回第一个",
|
||||
profileCount: 3,
|
||||
wantError: false,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "没有档案时应该错误",
|
||||
name: "没有档案时应该错误",
|
||||
profileCount: 0,
|
||||
wantError: true,
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -181,4 +146,3 @@ func TestProfileRepository_FindOneProfileLogic(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"carrotskin/internal/model"
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// systemConfigRepository SystemConfigRepository的实现
|
||||
type systemConfigRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSystemConfigRepository 创建SystemConfigRepository实例
|
||||
func NewSystemConfigRepository(db *gorm.DB) SystemConfigRepository {
|
||||
return &systemConfigRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *systemConfigRepository) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error) {
|
||||
var config model.SystemConfig
|
||||
err := r.db.WithContext(ctx).Where("key = ?", key).First(&config).Error
|
||||
return handleNotFoundResult(&config, err)
|
||||
}
|
||||
|
||||
func (r *systemConfigRepository) GetPublic(ctx context.Context) ([]model.SystemConfig, error) {
|
||||
var configs []model.SystemConfig
|
||||
err := r.db.WithContext(ctx).Where("is_public = ?", true).Find(&configs).Error
|
||||
return configs, err
|
||||
}
|
||||
|
||||
func (r *systemConfigRepository) GetAll(ctx context.Context) ([]model.SystemConfig, error) {
|
||||
var configs []model.SystemConfig
|
||||
err := r.db.WithContext(ctx).Find(&configs).Error
|
||||
return configs, err
|
||||
}
|
||||
|
||||
func (r *systemConfigRepository) Update(ctx context.Context, config *model.SystemConfig) error {
|
||||
return r.db.WithContext(ctx).Save(config).Error
|
||||
}
|
||||
|
||||
func (r *systemConfigRepository) UpdateValue(ctx context.Context, key, value string) error {
|
||||
return r.db.WithContext(ctx).Model(&model.SystemConfig{}).Where("key = ?", key).Update("value", value).Error
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSystemConfigRepository_QueryConditions 测试系统配置查询条件逻辑
|
||||
func TestSystemConfigRepository_QueryConditions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
isPublic bool
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "有效的配置键",
|
||||
key: "site_name",
|
||||
isPublic: true,
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "配置键为空",
|
||||
key: "",
|
||||
isPublic: true,
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "公开配置查询",
|
||||
key: "site_name",
|
||||
isPublic: true,
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "私有配置查询",
|
||||
key: "secret_key",
|
||||
isPublic: false,
|
||||
wantValid: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
isValid := tt.key != ""
|
||||
if isValid != tt.wantValid {
|
||||
t.Errorf("Query condition validation failed: got %v, want %v", isValid, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemConfigRepository_PublicConfigLogic 测试公开配置逻辑
|
||||
func TestSystemConfigRepository_PublicConfigLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isPublic bool
|
||||
wantInclude bool
|
||||
}{
|
||||
{
|
||||
name: "只获取公开配置",
|
||||
isPublic: true,
|
||||
wantInclude: true,
|
||||
},
|
||||
{
|
||||
name: "私有配置不应包含",
|
||||
isPublic: false,
|
||||
wantInclude: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证逻辑:GetPublicSystemConfigs应该只返回is_public=true的配置
|
||||
if tt.isPublic != tt.wantInclude {
|
||||
t.Errorf("Public config logic failed: isPublic=%v, wantInclude=%v", tt.isPublic, tt.wantInclude)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemConfigRepository_UpdateValueLogic 测试更新配置值逻辑
|
||||
func TestSystemConfigRepository_UpdateValueLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "有效的键值对",
|
||||
key: "site_name",
|
||||
value: "CarrotSkin",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "键为空",
|
||||
key: "",
|
||||
value: "CarrotSkin",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "值为空(可能有效)",
|
||||
key: "site_name",
|
||||
value: "",
|
||||
wantValid: true, // 空值也可能是有效的
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
isValid := tt.key != ""
|
||||
if isValid != tt.wantValid {
|
||||
t.Errorf("Update value validation failed: got %v, want %v", isValid, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemConfigRepository_ErrorHandling 测试错误处理逻辑
|
||||
func TestSystemConfigRepository_ErrorHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isNotFound bool
|
||||
wantNilConfig bool
|
||||
}{
|
||||
{
|
||||
name: "记录未找到应该返回nil配置",
|
||||
isNotFound: true,
|
||||
wantNilConfig: true,
|
||||
},
|
||||
{
|
||||
name: "找到记录应该返回配置",
|
||||
isNotFound: false,
|
||||
wantNilConfig: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 验证错误处理逻辑:如果是RecordNotFound,返回nil配置
|
||||
if tt.isNotFound != tt.wantNilConfig {
|
||||
t.Errorf("Error handling logic failed: isNotFound=%v, wantNilConfig=%v", tt.isNotFound, tt.wantNilConfig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,3 +29,9 @@ func (r *yggdrasilRepository) GetPasswordByID(ctx context.Context, id int64) (st
|
||||
func (r *yggdrasilRepository) ResetPassword(ctx context.Context, id int64, password string) error {
|
||||
return r.db.WithContext(ctx).Model(&model.Yggdrasil{}).Where("id = ?", id).Update("password", password).Error
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ type UserService interface {
|
||||
ResetPassword(ctx context.Context, email, newPassword string) error
|
||||
ChangeEmail(ctx context.Context, userID int64, newEmail string) error
|
||||
|
||||
// 头像上传
|
||||
UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error)
|
||||
|
||||
// URL验证
|
||||
ValidateAvatarURL(ctx context.Context, avatarURL string) error
|
||||
|
||||
@@ -45,7 +48,6 @@ type ProfileService interface {
|
||||
Delete(ctx context.Context, uuid string, userID int64) error
|
||||
|
||||
// 档案状态
|
||||
SetActive(ctx context.Context, uuid string, userID int64) error
|
||||
CheckLimit(ctx context.Context, userID int64, maxProfiles int) error
|
||||
|
||||
// 批量查询
|
||||
@@ -56,8 +58,7 @@ type ProfileService interface {
|
||||
// TextureService 材质服务接口
|
||||
type TextureService interface {
|
||||
// 材质CRUD
|
||||
Create(ctx context.Context, uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error)
|
||||
UploadTexture(ctx context.Context, uploaderID int64, name, description, textureType string, fileData []byte, fileName string, isPublic, isSlim bool) (*model.Texture, error) // 直接上传材质文件
|
||||
UploadTexture(ctx context.Context, uploaderID int64, name, description, textureType string, fileData []byte, fileName string, isPublic, isSlim bool) (*model.Texture, error)
|
||||
GetByID(ctx context.Context, id int64) (*model.Texture, error)
|
||||
GetByHash(ctx context.Context, hash string) (*model.Texture, error)
|
||||
GetByUserID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error)
|
||||
@@ -99,12 +100,6 @@ type CaptchaService interface {
|
||||
Verify(ctx context.Context, dx int, captchaID string) (bool, error)
|
||||
}
|
||||
|
||||
// UploadService 上传服务接口
|
||||
type UploadService interface {
|
||||
GenerateAvatarUploadURL(ctx context.Context, userID int64, fileName string) (*storage.PresignedPostPolicyResult, error)
|
||||
GenerateTextureUploadURL(ctx context.Context, userID int64, fileName, textureType string) (*storage.PresignedPostPolicyResult, error)
|
||||
}
|
||||
|
||||
// YggdrasilService Yggdrasil服务接口
|
||||
type YggdrasilService interface {
|
||||
// 用户认证
|
||||
@@ -149,7 +144,6 @@ type Services struct {
|
||||
Token TokenService
|
||||
Verification VerificationService
|
||||
Captcha CaptchaService
|
||||
Upload UploadService
|
||||
Yggdrasil YggdrasilService
|
||||
Security SecurityService
|
||||
}
|
||||
|
||||
@@ -214,10 +214,6 @@ func (m *MockProfileRepository) CountByUserID(ctx context.Context, userID int64)
|
||||
return int64(len(m.userProfiles[userID])), nil
|
||||
}
|
||||
|
||||
func (m *MockProfileRepository) SetActive(ctx context.Context, uuid string, userID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockProfileRepository) UpdateLastUsedAt(ctx context.Context, uuid string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -315,6 +311,18 @@ func (m *MockTextureRepository) FindByHash(ctx context.Context, hash string) (*m
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTextureRepository) FindByHashAndUploaderID(ctx context.Context, hash string, uploaderID int64) (*model.Texture, error) {
|
||||
if m.FailFind {
|
||||
return nil, errors.New("mock find error")
|
||||
}
|
||||
for _, texture := range m.textures {
|
||||
if texture.Hash == hash && texture.UploaderID == uploaderID {
|
||||
return texture, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTextureRepository) FindByUploaderID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) {
|
||||
if m.FailFind {
|
||||
return nil, 0, errors.New("mock find error")
|
||||
@@ -557,53 +565,6 @@ func (m *MockTokenRepository) BatchDelete(ctx context.Context, accessTokens []st
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// MockSystemConfigRepository 模拟SystemConfigRepository
|
||||
type MockSystemConfigRepository struct {
|
||||
configs map[string]*model.SystemConfig
|
||||
}
|
||||
|
||||
func NewMockSystemConfigRepository() *MockSystemConfigRepository {
|
||||
return &MockSystemConfigRepository{
|
||||
configs: make(map[string]*model.SystemConfig),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockSystemConfigRepository) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error) {
|
||||
if config, ok := m.configs[key]; ok {
|
||||
return config, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockSystemConfigRepository) GetPublic(ctx context.Context) ([]model.SystemConfig, error) {
|
||||
var result []model.SystemConfig
|
||||
for _, v := range m.configs {
|
||||
result = append(result, *v)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockSystemConfigRepository) GetAll(ctx context.Context) ([]model.SystemConfig, error) {
|
||||
var result []model.SystemConfig
|
||||
for _, v := range m.configs {
|
||||
result = append(result, *v)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockSystemConfigRepository) Update(ctx context.Context, config *model.SystemConfig) error {
|
||||
m.configs[config.Key] = config
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockSystemConfigRepository) UpdateValue(ctx context.Context, key, value string) error {
|
||||
if config, ok := m.configs[key]; ok {
|
||||
config.Value = value
|
||||
return nil
|
||||
}
|
||||
return errors.New("config not found")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Service Mocks
|
||||
// ============================================================================
|
||||
@@ -796,10 +757,6 @@ func (m *MockProfileService) Delete(uuid string, userID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockProfileService) SetActive(uuid string, userID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockProfileService) CheckLimit(userID int64, maxProfiles int) error {
|
||||
count := 0
|
||||
for _, profile := range m.profiles {
|
||||
|
||||
@@ -77,18 +77,12 @@ func (s *profileService) Create(ctx context.Context, userID int64, name string)
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
RSAPrivateKey: privateKey,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := s.profileRepo.Create(ctx, profile); err != nil {
|
||||
return nil, fmt.Errorf("创建档案失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置活跃状态
|
||||
if err := s.profileRepo.SetActive(ctx, profileUUID, userID); err != nil {
|
||||
return nil, fmt.Errorf("设置活跃状态失败: %w", err)
|
||||
}
|
||||
|
||||
// 清除用户的 profile 列表缓存
|
||||
s.cacheInv.OnCreate(ctx, s.cacheKeys.ProfileList(userID))
|
||||
|
||||
@@ -220,34 +214,6 @@ func (s *profileService) Delete(ctx context.Context, uuid string, userID int64)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *profileService) SetActive(ctx context.Context, uuid string, userID int64) error {
|
||||
// 获取档案并验证权限
|
||||
profile, err := s.profileRepo.FindByUUID(ctx, uuid)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrProfileNotFound
|
||||
}
|
||||
return fmt.Errorf("查询档案失败: %w", err)
|
||||
}
|
||||
|
||||
if profile.UserID != userID {
|
||||
return ErrProfileNoPermission
|
||||
}
|
||||
|
||||
if err := s.profileRepo.SetActive(ctx, uuid, userID); err != nil {
|
||||
return fmt.Errorf("设置活跃状态失败: %w", err)
|
||||
}
|
||||
|
||||
if err := s.profileRepo.UpdateLastUsedAt(ctx, uuid); err != nil {
|
||||
return fmt.Errorf("更新使用时间失败: %w", err)
|
||||
}
|
||||
|
||||
// 清除该用户所有 profile 的缓存(因为活跃状态改变了)
|
||||
s.cacheInv.BatchInvalidate(ctx, s.cacheKeys.ProfilePattern(userID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *profileService) CheckLimit(ctx context.Context, userID int64, maxProfiles int) error {
|
||||
count, err := s.profileRepo.CountByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -80,15 +80,6 @@ func TestProfileService_StatusValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileService_IsActiveDefault 测试Profile默认活跃状态
|
||||
func TestProfileService_IsActiveDefault(t *testing.T) {
|
||||
// 新创建的档案默认为活跃状态
|
||||
isActive := true
|
||||
if !isActive {
|
||||
t.Error("新创建的Profile应该默认为活跃状态")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateProfile_PermissionCheck 测试更新Profile的权限检查逻辑
|
||||
func TestUpdateProfile_PermissionCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -191,38 +182,6 @@ func TestDeleteProfile_PermissionCheck(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetActiveProfile_PermissionCheck 测试设置活跃Profile的权限检查
|
||||
func TestSetActiveProfile_PermissionCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
profileUserID int64
|
||||
requestUserID int64
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "用户ID匹配,允许设置",
|
||||
profileUserID: 1,
|
||||
requestUserID: 1,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "用户ID不匹配,拒绝设置",
|
||||
profileUserID: 1,
|
||||
requestUserID: 2,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hasError := tt.profileUserID != tt.requestUserID
|
||||
if hasError != tt.wantErr {
|
||||
t.Errorf("Permission check failed: got %v, want %v", hasError, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckProfileLimit_Logic 测试Profile数量限制检查逻辑
|
||||
func TestCheckProfileLimit_Logic(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -642,8 +601,8 @@ func TestProfileServiceImpl_GetByUserID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileServiceImpl_Update_And_SetActive 测试 Update 与 SetActive
|
||||
func TestProfileServiceImpl_Update_And_SetActive(t *testing.T) {
|
||||
// TestProfileServiceImpl_Update 测试 Update
|
||||
func TestProfileServiceImpl_Update(t *testing.T) {
|
||||
profileRepo := NewMockProfileRepository()
|
||||
userRepo := NewMockUserRepository()
|
||||
logger := zap.NewNop()
|
||||
@@ -686,16 +645,6 @@ func TestProfileServiceImpl_Update_And_SetActive(t *testing.T) {
|
||||
if _, err := svc.Update(ctx, "u1", 1, stringPtr("Duplicate"), nil, nil); err == nil {
|
||||
t.Fatalf("Update 在名称重复时应返回错误")
|
||||
}
|
||||
|
||||
// SetActive 正常
|
||||
if err := svc.SetActive(ctx, "u1", 1); err != nil {
|
||||
t.Fatalf("SetActive 正常情况失败: %v", err)
|
||||
}
|
||||
|
||||
// SetActive 无权限
|
||||
if err := svc.SetActive(ctx, "u1", 2); err == nil {
|
||||
t.Fatalf("SetActive 在无权限时应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileServiceImpl_CheckLimit_And_GetByNames 测试 CheckLimit / GetByNames / GetByProfileName
|
||||
|
||||
@@ -48,57 +48,6 @@ func NewTextureService(
|
||||
}
|
||||
}
|
||||
|
||||
func (s *textureService) Create(ctx context.Context, uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error) {
|
||||
// 验证用户存在
|
||||
user, err := s.userRepo.FindByID(ctx, uploaderID)
|
||||
if err != nil || user == nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
// 检查是否有任何用户上传过相同Hash的皮肤(复用URL,不重复保存文件)
|
||||
existingTexture, err := s.textureRepo.FindByHash(ctx, hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 如果已存在相同Hash的皮肤,复用已存在的URL
|
||||
finalURL := url
|
||||
if existingTexture != nil {
|
||||
finalURL = existingTexture.URL
|
||||
}
|
||||
|
||||
// 转换材质类型
|
||||
textureTypeEnum, err := parseTextureTypeInternal(textureType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建材质记录(即使Hash相同,也创建新的数据库记录)
|
||||
texture := &model.Texture{
|
||||
UploaderID: uploaderID,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Type: textureTypeEnum,
|
||||
URL: finalURL, // 复用已存在的URL或使用新URL
|
||||
Hash: hash,
|
||||
Size: size,
|
||||
IsPublic: isPublic,
|
||||
IsSlim: isSlim,
|
||||
Status: 1,
|
||||
DownloadCount: 0,
|
||||
FavoriteCount: 0,
|
||||
}
|
||||
|
||||
if err := s.textureRepo.Create(ctx, texture); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 清除用户的 texture 列表缓存(所有分页)
|
||||
s.cacheInv.BatchInvalidate(ctx, fmt.Sprintf("texture:user:%d:*", uploaderID))
|
||||
|
||||
return texture, nil
|
||||
}
|
||||
|
||||
func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture, error) {
|
||||
// 尝试从缓存获取
|
||||
cacheKey := s.cacheKeys.Texture(id)
|
||||
|
||||
@@ -478,129 +478,6 @@ func boolPtr(b bool) *bool {
|
||||
// 使用 Mock 的集成测试
|
||||
// ============================================================================
|
||||
|
||||
// TestTextureServiceImpl_Create 测试创建Texture
|
||||
func TestTextureServiceImpl_Create(t *testing.T) {
|
||||
textureRepo := NewMockTextureRepository()
|
||||
userRepo := NewMockUserRepository()
|
||||
logger := zap.NewNop()
|
||||
|
||||
// 预置用户
|
||||
testUser := &model.User{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Status: 1,
|
||||
}
|
||||
_ = userRepo.Create(context.Background(), testUser)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
uploaderID int64
|
||||
textureName string
|
||||
textureType string
|
||||
hash string
|
||||
wantErr bool
|
||||
errContains string
|
||||
setupMocks func()
|
||||
}{
|
||||
{
|
||||
name: "正常创建SKIN材质",
|
||||
uploaderID: 1,
|
||||
textureName: "TestSkin",
|
||||
textureType: "SKIN",
|
||||
hash: "unique-hash-1",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "正常创建CAPE材质",
|
||||
uploaderID: 1,
|
||||
textureName: "TestCape",
|
||||
textureType: "CAPE",
|
||||
hash: "unique-hash-2",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "用户不存在",
|
||||
uploaderID: 999,
|
||||
textureName: "TestTexture",
|
||||
textureType: "SKIN",
|
||||
hash: "unique-hash-3",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "材质Hash已存在",
|
||||
uploaderID: 1,
|
||||
textureName: "DuplicateTexture",
|
||||
textureType: "SKIN",
|
||||
hash: "existing-hash",
|
||||
wantErr: true,
|
||||
errContains: "已存在",
|
||||
setupMocks: func() {
|
||||
_ = textureRepo.Create(context.Background(), &model.Texture{
|
||||
ID: 100,
|
||||
UploaderID: 1,
|
||||
Name: "ExistingTexture",
|
||||
Hash: "existing-hash",
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "无效的材质类型",
|
||||
uploaderID: 1,
|
||||
textureName: "InvalidTypeTexture",
|
||||
textureType: "INVALID",
|
||||
hash: "unique-hash-4",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.setupMocks != nil {
|
||||
tt.setupMocks()
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
texture, err := textureService.Create(
|
||||
ctx,
|
||||
tt.uploaderID,
|
||||
tt.textureName,
|
||||
"Test description",
|
||||
tt.textureType,
|
||||
"http://example.com/texture.png",
|
||||
tt.hash,
|
||||
512,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("期望返回错误,但实际没有错误")
|
||||
return
|
||||
}
|
||||
if tt.errContains != "" && !containsString(err.Error(), tt.errContains) {
|
||||
t.Errorf("错误信息应包含 %q, 实际为: %v", tt.errContains, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("不期望返回错误: %v", err)
|
||||
return
|
||||
}
|
||||
if texture == nil {
|
||||
t.Error("返回的Texture不应为nil")
|
||||
}
|
||||
if texture.Name != tt.textureName {
|
||||
t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTextureServiceImpl_GetByID 测试获取Texture
|
||||
func TestTextureServiceImpl_GetByID(t *testing.T) {
|
||||
textureRepo := NewMockTextureRepository()
|
||||
@@ -617,7 +494,7 @@ func TestTextureServiceImpl_GetByID(t *testing.T) {
|
||||
_ = textureRepo.Create(context.Background(), testTexture)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -675,7 +552,7 @@ func TestTextureServiceImpl_GetByUserID_And_Search(t *testing.T) {
|
||||
}
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -714,7 +591,7 @@ func TestTextureServiceImpl_Update_And_Delete(t *testing.T) {
|
||||
_ = textureRepo.Create(context.Background(), texture)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -764,7 +641,7 @@ func TestTextureServiceImpl_FavoritesAndLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -807,7 +684,7 @@ func TestTextureServiceImpl_ToggleFavorite(t *testing.T) {
|
||||
_ = textureRepo.Create(context.Background(), testTexture)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
textureService := NewTextureService(textureRepo, userRepo, cacheManager, logger)
|
||||
textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -203,10 +203,9 @@ func TestTokenServiceImpl_Create(t *testing.T) {
|
||||
|
||||
// 预置Profile
|
||||
testProfile := &model.Profile{
|
||||
UUID: "test-profile-uuid",
|
||||
UserID: 1,
|
||||
Name: "TestProfile",
|
||||
IsActive: true,
|
||||
UUID: "test-profile-uuid",
|
||||
UserID: 1,
|
||||
Name: "TestProfile",
|
||||
}
|
||||
_ = profileRepo.Create(context.Background(), testProfile)
|
||||
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"carrotskin/pkg/storage"
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileType 文件类型枚举
|
||||
type FileType string
|
||||
|
||||
const (
|
||||
FileTypeAvatar FileType = "avatar"
|
||||
FileTypeTexture FileType = "texture"
|
||||
)
|
||||
|
||||
// UploadConfig 上传配置
|
||||
type UploadConfig struct {
|
||||
AllowedExts map[string]bool // 允许的文件扩展名
|
||||
MinSize int64 // 最小文件大小(字节)
|
||||
MaxSize int64 // 最大文件大小(字节)
|
||||
Expires time.Duration // URL过期时间
|
||||
}
|
||||
|
||||
// uploadService UploadService的实现
|
||||
type uploadService struct {
|
||||
storage *storage.StorageClient
|
||||
}
|
||||
|
||||
// NewUploadService 创建UploadService实例
|
||||
func NewUploadService(storageClient *storage.StorageClient) UploadService {
|
||||
return &uploadService{
|
||||
storage: storageClient,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAvatarUploadURL 生成头像上传URL
|
||||
func (s *uploadService) GenerateAvatarUploadURL(ctx context.Context, userID int64, fileName string) (*storage.PresignedPostPolicyResult, error) {
|
||||
// 1. 验证文件名
|
||||
if err := ValidateFileName(fileName, FileTypeAvatar); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 获取上传配置
|
||||
uploadConfig := GetUploadConfig(FileTypeAvatar)
|
||||
|
||||
// 3. 获取存储桶名称
|
||||
bucketName, err := s.storage.GetBucket("avatars")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取存储桶失败: %w", err)
|
||||
}
|
||||
|
||||
// 4. 生成对象名称(路径)
|
||||
// 格式: user_{userId}/timestamp_{originalFileName}
|
||||
timestamp := time.Now().Format("20060102150405")
|
||||
objectName := fmt.Sprintf("user_%d/%s_%s", userID, timestamp, fileName)
|
||||
|
||||
// 5. 生成预签名POST URL (使用存储客户端内置的 PublicURL)
|
||||
result, err := s.storage.GeneratePresignedPostURL(
|
||||
ctx,
|
||||
bucketName,
|
||||
objectName,
|
||||
uploadConfig.MinSize,
|
||||
uploadConfig.MaxSize,
|
||||
uploadConfig.Expires,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成上传URL失败: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GenerateTextureUploadURL 生成材质上传URL
|
||||
func (s *uploadService) GenerateTextureUploadURL(ctx context.Context, userID int64, fileName, textureType string) (*storage.PresignedPostPolicyResult, error) {
|
||||
// 1. 验证文件名
|
||||
if err := ValidateFileName(fileName, FileTypeTexture); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 验证材质类型
|
||||
if textureType != "SKIN" && textureType != "CAPE" {
|
||||
return nil, fmt.Errorf("无效的材质类型: %s", textureType)
|
||||
}
|
||||
|
||||
// 3. 获取上传配置
|
||||
uploadConfig := GetUploadConfig(FileTypeTexture)
|
||||
|
||||
// 4. 获取存储桶名称
|
||||
bucketName, err := s.storage.GetBucket("textures")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取存储桶失败: %w", err)
|
||||
}
|
||||
|
||||
// 5. 生成对象名称(路径)
|
||||
// 格式: user_{userId}/{textureType}/timestamp_{originalFileName}
|
||||
timestamp := time.Now().Format("20060102150405")
|
||||
textureTypeFolder := strings.ToLower(textureType)
|
||||
objectName := fmt.Sprintf("user_%d/%s/%s_%s", userID, textureTypeFolder, timestamp, fileName)
|
||||
|
||||
// 6. 生成预签名POST URL (使用存储客户端内置的 PublicURL)
|
||||
result, err := s.storage.GeneratePresignedPostURL(
|
||||
ctx,
|
||||
bucketName,
|
||||
objectName,
|
||||
uploadConfig.MinSize,
|
||||
uploadConfig.MaxSize,
|
||||
uploadConfig.Expires,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成上传URL失败: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetUploadConfig 根据文件类型获取上传配置
|
||||
func GetUploadConfig(fileType FileType) *UploadConfig {
|
||||
switch fileType {
|
||||
case FileTypeAvatar:
|
||||
return &UploadConfig{
|
||||
AllowedExts: map[string]bool{
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".gif": true,
|
||||
".webp": true,
|
||||
},
|
||||
MinSize: 512, // 512B
|
||||
MaxSize: 5 * 1024 * 1024, // 5MB
|
||||
Expires: 15 * time.Minute,
|
||||
}
|
||||
case FileTypeTexture:
|
||||
return &UploadConfig{
|
||||
AllowedExts: map[string]bool{
|
||||
".png": true,
|
||||
},
|
||||
MinSize: 512, // 512B
|
||||
MaxSize: 10 * 1024 * 1024, // 10MB
|
||||
Expires: 15 * time.Minute,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateFileName 验证文件名
|
||||
func ValidateFileName(fileName string, fileType FileType) error {
|
||||
if fileName == "" {
|
||||
return fmt.Errorf("文件名不能为空")
|
||||
}
|
||||
|
||||
uploadConfig := GetUploadConfig(fileType)
|
||||
if uploadConfig == nil {
|
||||
return fmt.Errorf("不支持的文件类型")
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
if !uploadConfig.AllowedExts[ext] {
|
||||
return fmt.Errorf("不支持的文件格式: %s", ext)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"carrotskin/pkg/storage"
|
||||
)
|
||||
|
||||
// TestUploadService_FileTypes 测试文件类型常量
|
||||
func TestUploadService_FileTypes(t *testing.T) {
|
||||
if FileTypeAvatar == "" {
|
||||
t.Error("FileTypeAvatar should not be empty")
|
||||
}
|
||||
|
||||
if FileTypeTexture == "" {
|
||||
t.Error("FileTypeTexture should not be empty")
|
||||
}
|
||||
|
||||
if FileTypeAvatar == FileTypeTexture {
|
||||
t.Error("FileTypeAvatar and FileTypeTexture should be different")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUploadConfig 测试获取上传配置
|
||||
func TestGetUploadConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fileType FileType
|
||||
wantConfig bool
|
||||
}{
|
||||
{
|
||||
name: "头像类型返回配置",
|
||||
fileType: FileTypeAvatar,
|
||||
wantConfig: true,
|
||||
},
|
||||
{
|
||||
name: "材质类型返回配置",
|
||||
fileType: FileTypeTexture,
|
||||
wantConfig: true,
|
||||
},
|
||||
{
|
||||
name: "无效类型返回nil",
|
||||
fileType: FileType("invalid"),
|
||||
wantConfig: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := GetUploadConfig(tt.fileType)
|
||||
hasConfig := config != nil
|
||||
if hasConfig != tt.wantConfig {
|
||||
t.Errorf("GetUploadConfig() = %v, want %v", hasConfig, tt.wantConfig)
|
||||
}
|
||||
|
||||
if config != nil {
|
||||
// 验证配置字段
|
||||
if config.MinSize <= 0 {
|
||||
t.Error("MinSize should be greater than 0")
|
||||
}
|
||||
if config.MaxSize <= 0 {
|
||||
t.Error("MaxSize should be greater than 0")
|
||||
}
|
||||
if config.MaxSize < config.MinSize {
|
||||
t.Error("MaxSize should be greater than or equal to MinSize")
|
||||
}
|
||||
if config.Expires <= 0 {
|
||||
t.Error("Expires should be greater than 0")
|
||||
}
|
||||
if len(config.AllowedExts) == 0 {
|
||||
t.Error("AllowedExts should not be empty")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUploadConfig_AvatarConfig 测试头像配置详情
|
||||
func TestGetUploadConfig_AvatarConfig(t *testing.T) {
|
||||
config := GetUploadConfig(FileTypeAvatar)
|
||||
if config == nil {
|
||||
t.Fatal("Avatar config should not be nil")
|
||||
}
|
||||
|
||||
// 验证允许的扩展名
|
||||
expectedExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
for _, ext := range expectedExts {
|
||||
if !config.AllowedExts[ext] {
|
||||
t.Errorf("Avatar config should allow %s extension", ext)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证文件大小限制
|
||||
if config.MinSize != 512 {
|
||||
t.Errorf("Avatar MinSize = %d, want 512", config.MinSize)
|
||||
}
|
||||
|
||||
if config.MaxSize != 5*1024*1024 {
|
||||
t.Errorf("Avatar MaxSize = %d, want 5MB", config.MaxSize)
|
||||
}
|
||||
|
||||
// 验证过期时间
|
||||
if config.Expires != 15*time.Minute {
|
||||
t.Errorf("Avatar Expires = %v, want 15 minutes", config.Expires)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUploadConfig_TextureConfig 测试材质配置详情
|
||||
func TestGetUploadConfig_TextureConfig(t *testing.T) {
|
||||
config := GetUploadConfig(FileTypeTexture)
|
||||
if config == nil {
|
||||
t.Fatal("Texture config should not be nil")
|
||||
}
|
||||
|
||||
// 验证允许的扩展名(材质只允许PNG)
|
||||
if !config.AllowedExts[".png"] {
|
||||
t.Error("Texture config should allow .png extension")
|
||||
}
|
||||
|
||||
// 验证文件大小限制
|
||||
if config.MinSize != 512 {
|
||||
t.Errorf("Texture MinSize = %d, want 512", config.MinSize)
|
||||
}
|
||||
|
||||
if config.MaxSize != 10*1024*1024 {
|
||||
t.Errorf("Texture MaxSize = %d, want 10MB", config.MaxSize)
|
||||
}
|
||||
|
||||
// 验证过期时间
|
||||
if config.Expires != 15*time.Minute {
|
||||
t.Errorf("Texture Expires = %v, want 15 minutes", config.Expires)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateFileName 测试文件名验证
|
||||
func TestValidateFileName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fileName string
|
||||
fileType FileType
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "有效的头像文件名",
|
||||
fileName: "avatar.png",
|
||||
fileType: FileTypeAvatar,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "有效的材质文件名",
|
||||
fileName: "texture.png",
|
||||
fileType: FileTypeTexture,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "文件名为空",
|
||||
fileName: "",
|
||||
fileType: FileTypeAvatar,
|
||||
wantErr: true,
|
||||
errContains: "文件名不能为空",
|
||||
},
|
||||
{
|
||||
name: "不支持的文件扩展名",
|
||||
fileName: "file.txt",
|
||||
fileType: FileTypeAvatar,
|
||||
wantErr: true,
|
||||
errContains: "不支持的文件格式",
|
||||
},
|
||||
{
|
||||
name: "无效的文件类型",
|
||||
fileName: "file.png",
|
||||
fileType: FileType("invalid"),
|
||||
wantErr: true,
|
||||
errContains: "不支持的文件类型",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateFileName(tt.fileName, tt.fileType)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateFileName() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.wantErr && tt.errContains != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("ValidateFileName() error = %v, should contain %s", err, tt.errContains)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateFileName_Extensions 测试各种扩展名
|
||||
func TestValidateFileName_Extensions(t *testing.T) {
|
||||
avatarExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
for _, ext := range avatarExts {
|
||||
fileName := "test" + ext
|
||||
err := ValidateFileName(fileName, FileTypeAvatar)
|
||||
if err != nil {
|
||||
t.Errorf("Avatar file with %s extension should be valid, got error: %v", ext, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 材质只支持PNG
|
||||
textureExts := []string{".png"}
|
||||
for _, ext := range textureExts {
|
||||
fileName := "test" + ext
|
||||
err := ValidateFileName(fileName, FileTypeTexture)
|
||||
if err != nil {
|
||||
t.Errorf("Texture file with %s extension should be valid, got error: %v", ext, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试不支持的扩展名
|
||||
invalidExts := []string{".txt", ".pdf", ".doc"}
|
||||
for _, ext := range invalidExts {
|
||||
fileName := "test" + ext
|
||||
err := ValidateFileName(fileName, FileTypeAvatar)
|
||||
if err == nil {
|
||||
t.Errorf("Avatar file with %s extension should be invalid", ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateFileName_CaseInsensitive 测试扩展名大小写不敏感
|
||||
func TestValidateFileName_CaseInsensitive(t *testing.T) {
|
||||
testCases := []struct {
|
||||
fileName string
|
||||
fileType FileType
|
||||
wantErr bool
|
||||
}{
|
||||
{"test.PNG", FileTypeAvatar, false},
|
||||
{"test.JPG", FileTypeAvatar, false},
|
||||
{"test.JPEG", FileTypeAvatar, false},
|
||||
{"test.GIF", FileTypeAvatar, false},
|
||||
{"test.WEBP", FileTypeAvatar, false},
|
||||
{"test.PnG", FileTypeTexture, false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.fileName, func(t *testing.T) {
|
||||
err := ValidateFileName(tc.fileName, tc.fileType)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("ValidateFileName(%s, %s) error = %v, wantErr %v", tc.fileName, tc.fileType, err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUploadConfig_Structure 测试UploadConfig结构
|
||||
func TestUploadConfig_Structure(t *testing.T) {
|
||||
config := &UploadConfig{
|
||||
AllowedExts: map[string]bool{
|
||||
".png": true,
|
||||
},
|
||||
MinSize: 512,
|
||||
MaxSize: 5 * 1024 * 1024,
|
||||
Expires: 15 * time.Minute,
|
||||
}
|
||||
|
||||
if config.AllowedExts == nil {
|
||||
t.Error("AllowedExts should not be nil")
|
||||
}
|
||||
|
||||
if config.MinSize <= 0 {
|
||||
t.Error("MinSize should be greater than 0")
|
||||
}
|
||||
|
||||
if config.MaxSize <= config.MinSize {
|
||||
t.Error("MaxSize should be greater than MinSize")
|
||||
}
|
||||
|
||||
if config.Expires <= 0 {
|
||||
t.Error("Expires should be greater than 0")
|
||||
}
|
||||
}
|
||||
|
||||
// mockStorageClient 用于单元测试的简单存储客户端假实现
|
||||
// 注意:这里只声明与 upload_service 使用到的方法,避免依赖真实 MinIO 客户端
|
||||
type mockStorageClient struct {
|
||||
getBucketFn func(name string) (string, error)
|
||||
generatePresignedPostURLFn func(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error)
|
||||
}
|
||||
|
||||
func (m *mockStorageClient) GetBucket(name string) (string, error) {
|
||||
if m.getBucketFn != nil {
|
||||
return m.getBucketFn(name)
|
||||
}
|
||||
return "", errors.New("GetBucket not implemented")
|
||||
}
|
||||
|
||||
func (m *mockStorageClient) GeneratePresignedPostURL(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error) {
|
||||
if m.generatePresignedPostURLFn != nil {
|
||||
return m.generatePresignedPostURLFn(ctx, bucketName, objectName, minSize, maxSize, expires)
|
||||
}
|
||||
return nil, errors.New("GeneratePresignedPostURL not implemented")
|
||||
}
|
||||
|
||||
// TestGenerateAvatarUploadURL_Success 测试头像上传URL生成成功
|
||||
func TestGenerateAvatarUploadURL_Success(t *testing.T) {
|
||||
// 由于 mockStorageClient 类型不匹配,跳过该测试
|
||||
t.Skip("This test requires refactoring to work with the new service architecture")
|
||||
|
||||
_ = &mockStorageClient{
|
||||
getBucketFn: func(name string) (string, error) {
|
||||
if name != "avatars" {
|
||||
t.Fatalf("unexpected bucket name: %s", name)
|
||||
}
|
||||
return "avatars-bucket", nil
|
||||
},
|
||||
generatePresignedPostURLFn: func(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error) {
|
||||
if bucketName != "avatars-bucket" {
|
||||
t.Fatalf("unexpected bucketName: %s", bucketName)
|
||||
}
|
||||
if !strings.Contains(objectName, "user_") {
|
||||
t.Fatalf("objectName should contain user_ prefix, got: %s", objectName)
|
||||
}
|
||||
if !strings.Contains(objectName, "avatar.png") {
|
||||
t.Fatalf("objectName should contain original file name, got: %s", objectName)
|
||||
}
|
||||
// 检查大小与过期时间传递
|
||||
if minSize != 512 {
|
||||
t.Fatalf("minSize = %d, want 512", minSize)
|
||||
}
|
||||
if maxSize != 5*1024*1024 {
|
||||
t.Fatalf("maxSize = %d, want 5MB", maxSize)
|
||||
}
|
||||
if expires != 15*time.Minute {
|
||||
t.Fatalf("expires = %v, want 15m", expires)
|
||||
}
|
||||
return &storage.PresignedPostPolicyResult{
|
||||
PostURL: "http://example.com/upload",
|
||||
FormData: map[string]string{"key": objectName},
|
||||
FileURL: "http://example.com/file/" + objectName,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TestGenerateTextureUploadURL_Success 测试材质上传URL生成成功(SKIN/CAPE)
|
||||
func TestGenerateTextureUploadURL_Success(t *testing.T) {
|
||||
// 由于 mockStorageClient 类型不匹配,跳过该测试
|
||||
t.Skip("This test requires refactoring to work with the new service architecture")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
textureType string
|
||||
}{
|
||||
{"SKIN 材质", "SKIN"},
|
||||
{"CAPE 材质", "CAPE"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_ = &mockStorageClient{
|
||||
getBucketFn: func(name string) (string, error) {
|
||||
if name != "textures" {
|
||||
t.Fatalf("unexpected bucket name: %s", name)
|
||||
}
|
||||
return "textures-bucket", nil
|
||||
},
|
||||
generatePresignedPostURLFn: func(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error) {
|
||||
if bucketName != "textures-bucket" {
|
||||
t.Fatalf("unexpected bucketName: %s", bucketName)
|
||||
}
|
||||
if !strings.Contains(objectName, "texture.png") {
|
||||
t.Fatalf("objectName should contain original file name, got: %s", objectName)
|
||||
}
|
||||
if !strings.Contains(objectName, "/"+strings.ToLower(tt.textureType)+"/") {
|
||||
t.Fatalf("objectName should contain texture type folder, got: %s", objectName)
|
||||
}
|
||||
return &storage.PresignedPostPolicyResult{
|
||||
PostURL: "http://example.com/upload",
|
||||
FormData: map[string]string{"key": objectName},
|
||||
FileURL: "http://example.com/file/" + objectName,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +19,7 @@ import (
|
||||
"carrotskin/pkg/config"
|
||||
"carrotskin/pkg/database"
|
||||
"carrotskin/pkg/redis"
|
||||
"carrotskin/pkg/storage"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -22,34 +27,34 @@ import (
|
||||
// userService UserService的实现
|
||||
type userService struct {
|
||||
userRepo repository.UserRepository
|
||||
configRepo repository.SystemConfigRepository
|
||||
jwtService *auth.JWTService
|
||||
redis *redis.Client
|
||||
cache *database.CacheManager
|
||||
cacheKeys *database.CacheKeyBuilder
|
||||
cacheInv *database.CacheInvalidator
|
||||
storage *storage.StorageClient
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUserService 创建UserService实例
|
||||
func NewUserService(
|
||||
userRepo repository.UserRepository,
|
||||
configRepo repository.SystemConfigRepository,
|
||||
jwtService *auth.JWTService,
|
||||
redisClient *redis.Client,
|
||||
cacheManager *database.CacheManager,
|
||||
storageClient *storage.StorageClient,
|
||||
logger *zap.Logger,
|
||||
) UserService {
|
||||
// CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀
|
||||
// 这样缓存键的格式为: CacheManager前缀 + CacheKeyBuilder生成的键
|
||||
return &userService{
|
||||
userRepo: userRepo,
|
||||
configRepo: configRepo,
|
||||
jwtService: jwtService,
|
||||
redis: redisClient,
|
||||
cache: cacheManager,
|
||||
cacheKeys: database.NewCacheKeyBuilder(""),
|
||||
cacheInv: database.NewCacheInvalidator(cacheManager),
|
||||
storage: storageClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
@@ -347,40 +352,91 @@ func (s *userService) ValidateAvatarURL(ctx context.Context, avatarURL string) e
|
||||
return s.checkDomainAllowed(host, cfg.Security.AllowedDomains)
|
||||
}
|
||||
|
||||
func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error) {
|
||||
// 验证文件大小
|
||||
fileSize := len(fileData)
|
||||
const minSize = 512 // 512B
|
||||
const maxSize = 5 * 1024 * 1024 // 5MB
|
||||
if int64(fileSize) < minSize || int64(fileSize) > maxSize {
|
||||
return "", fmt.Errorf("文件大小必须在 %d 到 %d 字节之间", minSize, maxSize)
|
||||
}
|
||||
|
||||
// 验证文件扩展名
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
allowedExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
|
||||
if !allowedExts[ext] {
|
||||
return "", fmt.Errorf("不支持的文件格式: %s,仅支持 jpg/jpeg/png/gif/webp", ext)
|
||||
}
|
||||
|
||||
// 检查存储服务
|
||||
if s.storage == nil {
|
||||
return "", errors.New("存储服务不可用")
|
||||
}
|
||||
|
||||
// 计算文件哈希
|
||||
hashBytes := sha256.Sum256(fileData)
|
||||
hash := hex.EncodeToString(hashBytes[:])
|
||||
|
||||
// 获取存储桶
|
||||
bucketName, err := s.storage.GetBucket("avatars")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取存储桶失败: %w", err)
|
||||
}
|
||||
|
||||
// 生成对象路径: avatars/{hash[:2]}/{hash[2:4]}/{hash}{ext}
|
||||
objectName := fmt.Sprintf("%s/%s/%s%s", hash[:2], hash[2:4], hash, ext)
|
||||
|
||||
// 上传文件
|
||||
reader := bytes.NewReader(fileData)
|
||||
contentType := "image/" + strings.TrimPrefix(ext, ".")
|
||||
if ext == ".jpg" {
|
||||
contentType = "image/jpeg"
|
||||
}
|
||||
if err := s.storage.UploadObject(ctx, bucketName, objectName, reader, int64(fileSize), contentType); err != nil {
|
||||
return "", fmt.Errorf("上传文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 构建文件URL
|
||||
avatarURL := s.storage.BuildFileURL(bucketName, objectName)
|
||||
|
||||
// 更新用户头像
|
||||
if err := s.UpdateAvatar(ctx, userID, avatarURL); err != nil {
|
||||
return "", fmt.Errorf("更新用户头像失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("上传头像成功",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("hash", hash),
|
||||
zap.String("url", avatarURL),
|
||||
)
|
||||
|
||||
return avatarURL, nil
|
||||
}
|
||||
|
||||
func (s *userService) GetMaxProfilesPerUser() int {
|
||||
config, err := s.configRepo.GetByKey(context.Background(), "max_profiles_per_user")
|
||||
if err != nil || config == nil {
|
||||
cfg, err := config.GetConfig()
|
||||
if err != nil || cfg.Site.MaxProfilesPerUser <= 0 {
|
||||
return 5
|
||||
}
|
||||
var value int
|
||||
fmt.Sscanf(config.Value, "%d", &value)
|
||||
if value <= 0 {
|
||||
return 5
|
||||
}
|
||||
return value
|
||||
return cfg.Site.MaxProfilesPerUser
|
||||
}
|
||||
|
||||
func (s *userService) GetMaxTexturesPerUser() int {
|
||||
config, err := s.configRepo.GetByKey(context.Background(), "max_textures_per_user")
|
||||
if err != nil || config == nil {
|
||||
cfg, err := config.GetConfig()
|
||||
if err != nil || cfg.Site.MaxTexturesPerUser <= 0 {
|
||||
return 50
|
||||
}
|
||||
var value int
|
||||
fmt.Sscanf(config.Value, "%d", &value)
|
||||
if value <= 0 {
|
||||
return 50
|
||||
}
|
||||
return value
|
||||
return cfg.Site.MaxTexturesPerUser
|
||||
}
|
||||
|
||||
// 私有辅助方法
|
||||
|
||||
func (s *userService) getDefaultAvatar() string {
|
||||
config, err := s.configRepo.GetByKey(context.Background(), "default_avatar")
|
||||
if err != nil || config == nil || config.Value == "" {
|
||||
cfg, err := config.GetConfig()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return config.Value
|
||||
return cfg.Site.DefaultAvatar
|
||||
}
|
||||
|
||||
func (s *userService) checkDomainAllowed(host string, allowedDomains []string) error {
|
||||
|
||||
@@ -12,14 +12,13 @@ import (
|
||||
func TestUserServiceImpl_Register(t *testing.T) {
|
||||
// 准备依赖
|
||||
userRepo := NewMockUserRepository()
|
||||
configRepo := NewMockSystemConfigRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
|
||||
// 初始化Service
|
||||
// 注意:redisClient 和 cacheManager 传入 nil,因为 Register 方法中没有使用它们
|
||||
// 注意:redisClient 和 storageClient 传入 nil,因为 Register 方法中没有使用它们
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -114,7 +113,6 @@ func TestUserServiceImpl_Register(t *testing.T) {
|
||||
func TestUserServiceImpl_Login(t *testing.T) {
|
||||
// 准备依赖
|
||||
userRepo := NewMockUserRepository()
|
||||
configRepo := NewMockSystemConfigRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
|
||||
@@ -130,7 +128,7 @@ func TestUserServiceImpl_Login(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), testUser)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -197,7 +195,6 @@ func TestUserServiceImpl_Login(t *testing.T) {
|
||||
// TestUserServiceImpl_BasicGetters 测试 GetByID / GetByEmail / UpdateInfo / UpdateAvatar
|
||||
func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
|
||||
userRepo := NewMockUserRepository()
|
||||
configRepo := NewMockSystemConfigRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
|
||||
@@ -211,7 +208,7 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), user)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -246,7 +243,6 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) {
|
||||
// TestUserServiceImpl_ChangePassword 测试 ChangePassword
|
||||
func TestUserServiceImpl_ChangePassword(t *testing.T) {
|
||||
userRepo := NewMockUserRepository()
|
||||
configRepo := NewMockSystemConfigRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
|
||||
@@ -259,7 +255,7 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), user)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -282,7 +278,6 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) {
|
||||
// TestUserServiceImpl_ResetPassword 测试 ResetPassword
|
||||
func TestUserServiceImpl_ResetPassword(t *testing.T) {
|
||||
userRepo := NewMockUserRepository()
|
||||
configRepo := NewMockSystemConfigRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
|
||||
@@ -294,7 +289,7 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), user)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -312,7 +307,6 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) {
|
||||
// TestUserServiceImpl_ChangeEmail 测试 ChangeEmail
|
||||
func TestUserServiceImpl_ChangeEmail(t *testing.T) {
|
||||
userRepo := NewMockUserRepository()
|
||||
configRepo := NewMockSystemConfigRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
|
||||
@@ -322,7 +316,7 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
|
||||
_ = userRepo.Create(context.Background(), user2)
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -340,12 +334,11 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) {
|
||||
// TestUserServiceImpl_ValidateAvatarURL 测试 ValidateAvatarURL
|
||||
func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
|
||||
userRepo := NewMockUserRepository()
|
||||
configRepo := NewMockSystemConfigRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -373,30 +366,19 @@ func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestUserServiceImpl_MaxLimits 测试 GetMaxProfilesPerUser / GetMaxTexturesPerUser
|
||||
// 现在配置从环境变量读取,测试默认值
|
||||
func TestUserServiceImpl_MaxLimits(t *testing.T) {
|
||||
userRepo := NewMockUserRepository()
|
||||
configRepo := NewMockSystemConfigRepository()
|
||||
jwtService := auth.NewJWTService("secret", 1)
|
||||
logger := zap.NewNop()
|
||||
|
||||
// 未配置时走默认值
|
||||
cacheManager := NewMockCacheManager()
|
||||
userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger)
|
||||
userService := NewUserService(userRepo, jwtService, nil, cacheManager, nil, logger)
|
||||
if got := userService.GetMaxProfilesPerUser(); got != 5 {
|
||||
t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got)
|
||||
}
|
||||
if got := userService.GetMaxTexturesPerUser(); got != 50 {
|
||||
t.Fatalf("GetMaxTexturesPerUser 默认值错误, got=%d", got)
|
||||
}
|
||||
|
||||
// 配置有效值
|
||||
_ = configRepo.Update(context.Background(), &model.SystemConfig{Key: "max_profiles_per_user", Value: "10"})
|
||||
_ = configRepo.Update(context.Background(), &model.SystemConfig{Key: "max_textures_per_user", Value: "100"})
|
||||
|
||||
if got := userService.GetMaxProfilesPerUser(); got != 10 {
|
||||
t.Fatalf("GetMaxProfilesPerUser 配置值错误, got=%d", got)
|
||||
}
|
||||
if got := userService.GetMaxTexturesPerUser(); got != 100 {
|
||||
t.Fatalf("GetMaxTexturesPerUser 配置值错误, got=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ type RegisterRequest 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"`
|
||||
VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"` // 邮箱验证码
|
||||
VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"` // 邮箱验证码
|
||||
Avatar string `json:"avatar" binding:"omitempty,url" example:"https://rustfs.example.com/avatars/user_1/avatar.png"` // 可选,用户自定义头像
|
||||
}
|
||||
|
||||
@@ -65,19 +65,6 @@ type ChangeEmailRequest struct {
|
||||
VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"`
|
||||
}
|
||||
|
||||
// GenerateAvatarUploadURLRequest 生成头像上传URL请求
|
||||
type GenerateAvatarUploadURLRequest struct {
|
||||
FileName string `json:"file_name" binding:"required" example:"avatar.png"`
|
||||
}
|
||||
|
||||
// GenerateAvatarUploadURLResponse 生成头像上传URL响应
|
||||
type GenerateAvatarUploadURLResponse struct {
|
||||
PostURL string `json:"post_url" example:"https://rustfs.example.com/avatars"`
|
||||
FormData map[string]string `json:"form_data"`
|
||||
AvatarURL string `json:"avatar_url" example:"https://rustfs.example.com/avatars/user_1/xxx.png"`
|
||||
ExpiresIn int `json:"expires_in" example:"900"` // 秒
|
||||
}
|
||||
|
||||
// CreateProfileRequest 创建档案请求
|
||||
type CreateProfileRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=16" example:"PlayerName"`
|
||||
@@ -90,20 +77,6 @@ type UpdateTextureRequest struct {
|
||||
IsPublic *bool `json:"is_public" example:"true"`
|
||||
}
|
||||
|
||||
// GenerateTextureUploadURLRequest 生成材质上传URL请求
|
||||
type GenerateTextureUploadURLRequest struct {
|
||||
FileName string `json:"file_name" binding:"required" example:"skin.png"`
|
||||
TextureType TextureType `json:"texture_type" binding:"required,oneof=SKIN CAPE" example:"SKIN"`
|
||||
}
|
||||
|
||||
// GenerateTextureUploadURLResponse 生成材质上传URL响应
|
||||
type GenerateTextureUploadURLResponse struct {
|
||||
PostURL string `json:"post_url" example:"https://rustfs.example.com/textures"`
|
||||
FormData map[string]string `json:"form_data"`
|
||||
TextureURL string `json:"texture_url" example:"https://rustfs.example.com/textures/user_1/skin/xxx.png"`
|
||||
ExpiresIn int `json:"expires_in" example:"900"` // 秒
|
||||
}
|
||||
|
||||
// LoginResponse 登录响应
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
@@ -158,7 +131,6 @@ type ProfileInfo struct {
|
||||
Name string `json:"name" example:"PlayerName"`
|
||||
SkinID *int64 `json:"skin_id,omitempty" example:"1"`
|
||||
CapeID *int64 `json:"cape_id,omitempty" example:"2"`
|
||||
IsActive bool `json:"is_active" example:"true"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty" example:"2025-10-01T12:00:00Z"`
|
||||
CreatedAt time.Time `json:"created_at" example:"2025-10-01T10:00:00Z"`
|
||||
UpdatedAt time.Time `json:"updated_at" example:"2025-10-01T10:00:00Z"`
|
||||
@@ -178,18 +150,6 @@ type UploadURLResponse struct {
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// CreateTextureRequest 创建材质请求
|
||||
type CreateTextureRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=100" example:"My Cool Skin"`
|
||||
Description string `json:"description" binding:"max=500" example:"A very cool skin"`
|
||||
Type TextureType `json:"type" binding:"required,oneof=SKIN CAPE" example:"SKIN"`
|
||||
URL string `json:"url" binding:"required,url" example:"https://rustfs.example.com/textures/user_1/skin/xxx.png"`
|
||||
Hash string `json:"hash" binding:"required,len=64" example:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`
|
||||
Size int `json:"size" binding:"required,min=1" example:"2048"`
|
||||
IsPublic bool `json:"is_public" example:"true"`
|
||||
IsSlim bool `json:"is_slim" example:"false"` // Alex模型(细臂)为true,Steve模型(粗臂)为false
|
||||
}
|
||||
|
||||
// SearchTextureRequest 搜索材质请求
|
||||
type SearchTextureRequest struct {
|
||||
PaginationRequest
|
||||
@@ -212,4 +172,4 @@ type SystemConfigResponse struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled" example:"true"`
|
||||
MaxTexturesPerUser int `json:"max_textures_per_user" example:"100"`
|
||||
MaxProfilesPerUser int `json:"max_profiles_per_user" example:"5"`
|
||||
}
|
||||
}
|
||||
|
||||
124
pkg/auth/casbin.go
Normal file
124
pkg/auth/casbin.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/casbin/casbin/v2"
|
||||
gormadapter "github.com/casbin/gorm-adapter/v3"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CasbinService Casbin权限服务
|
||||
type CasbinService struct {
|
||||
enforcer *casbin.Enforcer
|
||||
logger *zap.Logger
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewCasbinService 创建Casbin服务
|
||||
func NewCasbinService(db *gorm.DB, modelPath string, logger *zap.Logger) (*CasbinService, error) {
|
||||
// 使用Gorm适配器,自动使用casbin_rule表
|
||||
adapter, err := gormadapter.NewAdapterByDBUseTableName(db, "", "casbin_rule")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建Casbin适配器失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建Enforcer
|
||||
enforcer, err := casbin.NewEnforcer(modelPath, adapter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建Casbin执行器失败: %w", err)
|
||||
}
|
||||
|
||||
// 加载策略
|
||||
if err := enforcer.LoadPolicy(); err != nil {
|
||||
return nil, fmt.Errorf("加载Casbin策略失败: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("Casbin权限服务初始化成功")
|
||||
|
||||
return &CasbinService{
|
||||
enforcer: enforcer,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Enforce 检查权限
|
||||
// sub: 主体(用户角色), obj: 资源, act: 操作
|
||||
func (s *CasbinService) Enforce(sub, obj, act string) (bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.enforcer.Enforce(sub, obj, act)
|
||||
}
|
||||
|
||||
// CheckPermission 检查用户权限(便捷方法)
|
||||
func (s *CasbinService) CheckPermission(role, resource, action string) bool {
|
||||
allowed, err := s.Enforce(role, resource, action)
|
||||
if err != nil {
|
||||
s.logger.Error("权限检查失败",
|
||||
zap.String("role", role),
|
||||
zap.String("resource", resource),
|
||||
zap.String("action", action),
|
||||
zap.Error(err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
// AddPolicy 添加策略
|
||||
func (s *CasbinService) AddPolicy(sub, obj, act string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return s.enforcer.AddPolicy(sub, obj, act)
|
||||
}
|
||||
|
||||
// RemovePolicy 移除策略
|
||||
func (s *CasbinService) RemovePolicy(sub, obj, act string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return s.enforcer.RemovePolicy(sub, obj, act)
|
||||
}
|
||||
|
||||
// AddRoleForUser 为用户添加角色
|
||||
func (s *CasbinService) AddRoleForUser(user, role string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return s.enforcer.AddRoleForUser(user, role)
|
||||
}
|
||||
|
||||
// GetRolesForUser 获取用户的角色
|
||||
func (s *CasbinService) GetRolesForUser(user string) []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
roles, _ := s.enforcer.GetRolesForUser(user)
|
||||
return roles
|
||||
}
|
||||
|
||||
// GetPermissionsForRole 获取角色的所有权限
|
||||
func (s *CasbinService) GetPermissionsForRole(role string) [][]string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
perms, _ := s.enforcer.GetPermissionsForUser(role)
|
||||
return perms
|
||||
}
|
||||
|
||||
// ReloadPolicy 重新加载策略
|
||||
func (s *CasbinService) ReloadPolicy() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return s.enforcer.LoadPolicy()
|
||||
}
|
||||
|
||||
// GetEnforcer 获取原始Enforcer(用于高级操作)
|
||||
func (s *CasbinService) GetEnforcer() *casbin.Enforcer {
|
||||
return s.enforcer
|
||||
}
|
||||
@@ -38,3 +38,9 @@ func MustGetJWTService() *JWTService {
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
// Config 应用配置结构体
|
||||
type Config struct {
|
||||
Environment string `mapstructure:"environment"`
|
||||
Site SiteConfig `mapstructure:"site"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
@@ -21,11 +22,22 @@ type Config struct {
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Casbin CasbinConfig `mapstructure:"casbin"`
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
Upload UploadConfig `mapstructure:"upload"`
|
||||
Email EmailConfig `mapstructure:"email"`
|
||||
Security SecurityConfig `mapstructure:"security"`
|
||||
}
|
||||
|
||||
// SiteConfig 站点配置
|
||||
type SiteConfig struct {
|
||||
Name string `mapstructure:"name"`
|
||||
Description string `mapstructure:"description"`
|
||||
RegistrationEnabled bool `mapstructure:"registration_enabled"`
|
||||
DefaultAvatar string `mapstructure:"default_avatar"`
|
||||
MaxTexturesPerUser int `mapstructure:"max_textures_per_user"`
|
||||
MaxProfilesPerUser int `mapstructure:"max_profiles_per_user"`
|
||||
CheckinReward int `mapstructure:"checkin_reward"`
|
||||
TextureDownloadReward int `mapstructure:"texture_download_reward"`
|
||||
}
|
||||
|
||||
// ServerConfig 服务器配置
|
||||
type ServerConfig struct {
|
||||
Port string `mapstructure:"port"`
|
||||
@@ -99,14 +111,6 @@ type LogConfig struct {
|
||||
Compress bool `mapstructure:"compress"`
|
||||
}
|
||||
|
||||
// UploadConfig 文件上传配置
|
||||
type UploadConfig struct {
|
||||
MaxSize int64 `mapstructure:"max_size"`
|
||||
AllowedTypes []string `mapstructure:"allowed_types"`
|
||||
TextureMaxSize int64 `mapstructure:"texture_max_size"`
|
||||
AvatarMaxSize int64 `mapstructure:"avatar_max_size"`
|
||||
}
|
||||
|
||||
// EmailConfig 邮件配置
|
||||
type EmailConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
@@ -203,12 +207,6 @@ func setDefaults() {
|
||||
viper.SetDefault("log.max_age", 28)
|
||||
viper.SetDefault("log.compress", true)
|
||||
|
||||
// 文件上传默认配置
|
||||
viper.SetDefault("upload.max_size", 10485760)
|
||||
viper.SetDefault("upload.texture_max_size", 2097152)
|
||||
viper.SetDefault("upload.avatar_max_size", 1048576)
|
||||
viper.SetDefault("upload.allowed_types", []string{"image/png", "image/jpeg"})
|
||||
|
||||
// 邮件默认配置
|
||||
viper.SetDefault("email.enabled", false)
|
||||
viper.SetDefault("email.smtp_port", 587)
|
||||
@@ -216,6 +214,16 @@ func setDefaults() {
|
||||
// 安全默认配置
|
||||
viper.SetDefault("security.allowed_origins", []string{"*"})
|
||||
viper.SetDefault("security.allowed_domains", []string{"localhost", "127.0.0.1"})
|
||||
|
||||
// 站点默认配置
|
||||
viper.SetDefault("site.name", "CarrotSkin")
|
||||
viper.SetDefault("site.description", "一个优秀的Minecraft皮肤站")
|
||||
viper.SetDefault("site.registration_enabled", true)
|
||||
viper.SetDefault("site.default_avatar", "")
|
||||
viper.SetDefault("site.max_textures_per_user", 50)
|
||||
viper.SetDefault("site.max_profiles_per_user", 5)
|
||||
viper.SetDefault("site.checkin_reward", 10)
|
||||
viper.SetDefault("site.texture_download_reward", 1)
|
||||
}
|
||||
|
||||
// setupEnvMappings 设置环境变量映射
|
||||
@@ -277,6 +285,16 @@ func setupEnvMappings() {
|
||||
viper.BindEnv("email.username", "EMAIL_USERNAME")
|
||||
viper.BindEnv("email.password", "EMAIL_PASSWORD")
|
||||
viper.BindEnv("email.from_name", "EMAIL_FROM_NAME")
|
||||
|
||||
// 站点配置
|
||||
viper.BindEnv("site.name", "SITE_NAME")
|
||||
viper.BindEnv("site.description", "SITE_DESCRIPTION")
|
||||
viper.BindEnv("site.registration_enabled", "REGISTRATION_ENABLED")
|
||||
viper.BindEnv("site.default_avatar", "DEFAULT_AVATAR")
|
||||
viper.BindEnv("site.max_textures_per_user", "MAX_TEXTURES_PER_USER")
|
||||
viper.BindEnv("site.max_profiles_per_user", "MAX_PROFILES_PER_USER")
|
||||
viper.BindEnv("site.checkin_reward", "CHECKIN_REWARD")
|
||||
viper.BindEnv("site.texture_download_reward", "TEXTURE_DOWNLOAD_REWARD")
|
||||
}
|
||||
|
||||
// overrideFromEnv 从环境变量中覆盖配置
|
||||
@@ -370,25 +388,6 @@ func overrideFromEnv(config *Config) {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件上传配置
|
||||
if maxSize := os.Getenv("UPLOAD_MAX_SIZE"); maxSize != "" {
|
||||
if val, err := strconv.ParseInt(maxSize, 10, 64); err == nil {
|
||||
config.Upload.MaxSize = val
|
||||
}
|
||||
}
|
||||
|
||||
if textureMaxSize := os.Getenv("UPLOAD_TEXTURE_MAX_SIZE"); textureMaxSize != "" {
|
||||
if val, err := strconv.ParseInt(textureMaxSize, 10, 64); err == nil {
|
||||
config.Upload.TextureMaxSize = val
|
||||
}
|
||||
}
|
||||
|
||||
if avatarMaxSize := os.Getenv("UPLOAD_AVATAR_MAX_SIZE"); avatarMaxSize != "" {
|
||||
if val, err := strconv.ParseInt(avatarMaxSize, 10, 64); err == nil {
|
||||
config.Upload.AvatarMaxSize = val
|
||||
}
|
||||
}
|
||||
|
||||
// 处理邮件配置
|
||||
if emailEnabled := os.Getenv("EMAIL_ENABLED"); emailEnabled != "" {
|
||||
config.Email.Enabled = emailEnabled == "true" || emailEnabled == "True" || emailEnabled == "TRUE" || emailEnabled == "1"
|
||||
|
||||
@@ -63,3 +63,9 @@ func MustGetRustFSConfig() *RustFSConfig {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -81,9 +81,6 @@ func AutoMigrate(logger *zap.Logger) error {
|
||||
// Yggdrasil相关表(在User之后创建,因为它引用User)
|
||||
&model.Yggdrasil{},
|
||||
|
||||
// 系统配置表
|
||||
&model.SystemConfig{},
|
||||
|
||||
// 审计日志表
|
||||
&model.AuditLog{},
|
||||
|
||||
|
||||
@@ -99,3 +99,9 @@ func GetDSN(cfg config.DatabaseConfig) string {
|
||||
cfg.Timezone,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -12,36 +12,41 @@ import (
|
||||
const (
|
||||
defaultAdminUsername = "admin"
|
||||
defaultAdminEmail = "admin@example.com"
|
||||
defaultAdminPassword = "admin123456" // 首次登录后请立即修改
|
||||
defaultAdminPassword = "admin123456" // 首次登录后请立即修改,部署到生产环境后删除
|
||||
)
|
||||
|
||||
// defaultSystemConfigs 默认系统配置
|
||||
var defaultSystemConfigs = []model.SystemConfig{
|
||||
{Key: "site_name", Value: "CarrotSkin", Description: "网站名称", Type: model.ConfigTypeString, IsPublic: true},
|
||||
{Key: "site_description", Value: "一个优秀的Minecraft皮肤站", Description: "网站描述", Type: model.ConfigTypeString, IsPublic: true},
|
||||
{Key: "registration_enabled", Value: "true", Description: "是否允许用户注册", Type: model.ConfigTypeBoolean, IsPublic: true},
|
||||
{Key: "checkin_reward", Value: "10", Description: "签到奖励积分", Type: model.ConfigTypeInteger, IsPublic: true},
|
||||
{Key: "texture_download_reward", Value: "1", Description: "材质被下载奖励积分", Type: model.ConfigTypeInteger, IsPublic: false},
|
||||
{Key: "max_textures_per_user", Value: "50", Description: "每个用户最大材质数量", Type: model.ConfigTypeInteger, IsPublic: false},
|
||||
{Key: "max_profiles_per_user", Value: "5", Description: "每个用户最大角色数量", Type: model.ConfigTypeInteger, IsPublic: false},
|
||||
{Key: "default_avatar", Value: "", Description: "默认头像URL", Type: model.ConfigTypeString, IsPublic: true},
|
||||
}
|
||||
|
||||
// defaultCasbinRules 默认Casbin权限规则
|
||||
// 规则格式: {PType: "p", V0: "角色", V1: "资源", V2: "操作"}
|
||||
// PType "p" 表示策略规则,"g" 表示角色继承
|
||||
var defaultCasbinRules = []model.CasbinRule{
|
||||
// 管理员拥有所有权限
|
||||
// ==================== 管理员权限 ====================
|
||||
// 管理员拥有所有权限(通配符)
|
||||
{PType: "p", V0: "admin", V1: "*", V2: "*"},
|
||||
// 普通用户权限
|
||||
{PType: "p", V0: "user", V1: "texture", V2: "create"},
|
||||
{PType: "p", V0: "user", V1: "texture", V2: "read"},
|
||||
{PType: "p", V0: "user", V1: "texture", V2: "update_own"},
|
||||
{PType: "p", V0: "user", V1: "texture", V2: "delete_own"},
|
||||
{PType: "p", V0: "user", V1: "profile", V2: "create"},
|
||||
{PType: "p", V0: "user", V1: "profile", V2: "read"},
|
||||
{PType: "p", V0: "user", V1: "profile", V2: "update_own"},
|
||||
{PType: "p", V0: "user", V1: "profile", V2: "delete_own"},
|
||||
{PType: "p", V0: "user", V1: "user", V2: "update_own"},
|
||||
// 角色继承:admin 继承 user 的所有权限
|
||||
|
||||
// ==================== 普通用户权限 ====================
|
||||
// --- 用户资源 (user) ---
|
||||
{PType: "p", V0: "user", V1: "user", V2: "read_own"}, // 查看自己的信息
|
||||
{PType: "p", V0: "user", V1: "user", V2: "update_own"}, // 更新自己的信息
|
||||
|
||||
// --- 材质资源 (texture) ---
|
||||
{PType: "p", V0: "user", V1: "texture", V2: "read"}, // 查看材质(公开)
|
||||
{PType: "p", V0: "user", V1: "texture", V2: "create"}, // 上传材质
|
||||
{PType: "p", V0: "user", V1: "texture", V2: "update_own"}, // 更新自己的材质
|
||||
{PType: "p", V0: "user", V1: "texture", V2: "delete_own"}, // 删除自己的材质
|
||||
{PType: "p", V0: "user", V1: "texture", V2: "favorite"}, // 收藏材质
|
||||
|
||||
// --- 档案资源 (profile) ---
|
||||
{PType: "p", V0: "user", V1: "profile", V2: "read"}, // 查看档案(公开)
|
||||
{PType: "p", V0: "user", V1: "profile", V2: "create"}, // 创建档案
|
||||
{PType: "p", V0: "user", V1: "profile", V2: "update_own"}, // 更新自己的档案
|
||||
{PType: "p", V0: "user", V1: "profile", V2: "delete_own"}, // 删除自己的档案
|
||||
|
||||
// --- Yggdrasil资源 (yggdrasil) ---
|
||||
{PType: "p", V0: "user", V1: "yggdrasil", V2: "auth"}, // Yggdrasil认证
|
||||
{PType: "p", V0: "user", V1: "yggdrasil", V2: "reset_password"}, // 重置Yggdrasil密码
|
||||
|
||||
// ==================== 角色继承 ====================
|
||||
// admin 继承 user 的所有权限
|
||||
{PType: "g", V0: "admin", V1: "user"},
|
||||
}
|
||||
|
||||
@@ -59,11 +64,6 @@ func Seed(logger *zap.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 初始化系统配置
|
||||
if err := seedSystemConfigs(db, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 初始化Casbin权限规则
|
||||
if err := seedCasbinRules(db, logger); err != nil {
|
||||
return err
|
||||
@@ -119,23 +119,6 @@ func seedAdminUser(db *gorm.DB, logger *zap.Logger) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedSystemConfigs 初始化系统配置
|
||||
func seedSystemConfigs(db *gorm.DB, logger *zap.Logger) error {
|
||||
for _, config := range defaultSystemConfigs {
|
||||
// 使用 FirstOrCreate 避免重复插入
|
||||
var existing model.SystemConfig
|
||||
result := db.Where("key = ?", config.Key).First(&existing)
|
||||
if result.Error == gorm.ErrRecordNotFound {
|
||||
if err := db.Create(&config).Error; err != nil {
|
||||
logger.Error("创建系统配置失败", zap.String("key", config.Key), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
logger.Info("创建系统配置", zap.String("key", config.Key))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedCasbinRules 初始化Casbin权限规则
|
||||
func seedCasbinRules(db *gorm.DB, logger *zap.Logger) error {
|
||||
for _, rule := range defaultCasbinRules {
|
||||
@@ -153,4 +136,3 @@ func seedCasbinRules(db *gorm.DB, logger *zap.Logger) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -46,3 +46,9 @@ func MustGetService() *Service {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -49,3 +49,9 @@ func MustGetLogger() *zap.Logger {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -49,3 +49,9 @@ func MustGetClient() *Client {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -47,3 +47,9 @@ func MustGetClient() *StorageClient {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -173,18 +173,34 @@ func (s *StorageClient) GetObject(ctx context.Context, bucketName, objectName st
|
||||
}
|
||||
|
||||
// ParseFileURL 从文件URL中解析出bucket和objectName
|
||||
// URL格式: {publicURL}/{bucket}/{objectName}
|
||||
// URL格式: {publicURL}/{bucket}/{objectName}[?query],自动忽略查询参数
|
||||
func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string, err error) {
|
||||
// 移除 publicURL 前缀
|
||||
if !strings.HasPrefix(fileURL, s.publicURL) {
|
||||
u, err := url.Parse(fileURL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("URL解析失败: %w", err)
|
||||
}
|
||||
|
||||
// 校验前缀(协议+主机+端口)
|
||||
public, err := url.Parse(s.publicURL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("publicURL解析失败: %w", err)
|
||||
}
|
||||
if u.Scheme != public.Scheme || u.Host != public.Host {
|
||||
return "", "", fmt.Errorf("URL格式不正确,必须以 %s 开头", s.publicURL)
|
||||
}
|
||||
|
||||
// 移除 publicURL 前缀和开头的 /
|
||||
path := strings.TrimPrefix(fileURL, s.publicURL)
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
// 去掉前缀与开头的斜杠,仅使用路径部分,不包含 query
|
||||
path := strings.TrimPrefix(u.Path, "/")
|
||||
|
||||
// 解析路径
|
||||
// 如果 publicURL 自带路径前缀,移除该前缀
|
||||
pubPath := strings.TrimPrefix(public.Path, "/")
|
||||
if pubPath != "" {
|
||||
if !strings.HasPrefix(path, pubPath) {
|
||||
return "", "", fmt.Errorf("URL格式不正确,缺少前缀 %s", public.Path)
|
||||
}
|
||||
path = strings.TrimPrefix(path, pubPath)
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
}
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
if len(parts) < 2 {
|
||||
return "", "", fmt.Errorf("URL格式不正确,无法解析bucket和objectName")
|
||||
@@ -194,8 +210,7 @@ func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string,
|
||||
objectName = parts[1]
|
||||
|
||||
// URL解码 objectName
|
||||
decoded, err := url.PathUnescape(objectName)
|
||||
if err == nil {
|
||||
if decoded, decErr := url.PathUnescape(objectName); decErr == nil {
|
||||
objectName = decoded
|
||||
}
|
||||
|
||||
|
||||
42
run.bat
42
run.bat
@@ -1,42 +0,0 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ================================
|
||||
echo CarrotSkin Backend Server
|
||||
echo ================================
|
||||
echo.
|
||||
|
||||
echo [1/3] Checking swag tool...
|
||||
where swag >nul 2>nul
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [WARN] swag tool not found, installing...
|
||||
go install github.com/swaggo/swag/cmd/swag@latest
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Failed to install swag
|
||||
echo Please install manually: go install github.com/swaggo/swag/cmd/swag@latest
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] swag tool installed
|
||||
) else (
|
||||
echo [OK] swag tool found
|
||||
)
|
||||
echo.
|
||||
|
||||
echo [2/3] Generating Swagger documentation...
|
||||
swag init -g cmd/server/main.go -o docs --parseDependency --parseInternal
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Failed to generate Swagger docs
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [OK] Swagger docs generated
|
||||
echo.
|
||||
|
||||
echo [3/3] Starting server...
|
||||
echo Server: http://localhost:8080
|
||||
echo Swagger: http://localhost:8080/swagger/index.html
|
||||
echo Health: http://localhost:8080/health
|
||||
echo.
|
||||
echo Press Ctrl+C to stop server
|
||||
echo.
|
||||
go run cmd/server/main.go
|
||||
36
run.sh
36
run.sh
@@ -1,36 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "================================"
|
||||
echo " CarrotSkin Backend Server"
|
||||
echo "================================"
|
||||
echo ""
|
||||
|
||||
echo "[1/3] 检查swag工具..."
|
||||
if ! command -v swag &> /dev/null; then
|
||||
echo "[警告] swag工具未安装,正在安装..."
|
||||
go install github.com/swaggo/swag/cmd/swag@latest
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[错误] swag安装失败,请手动安装: go install github.com/swaggo/swag/cmd/swag@latest"
|
||||
exit 1
|
||||
fi
|
||||
echo "[成功] swag工具安装完成"
|
||||
else
|
||||
echo "[成功] swag工具已安装"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "[2/3] 生成Swagger API文档..."
|
||||
swag init -g cmd/server/main.go -o docs --parseDependency --parseInternal
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[错误] Swagger文档生成失败"
|
||||
exit 1
|
||||
fi
|
||||
echo "[成功] Swagger文档生成完成"
|
||||
echo ""
|
||||
|
||||
echo "[3/3] 启动服务器..."
|
||||
echo "服务地址: http://localhost:8080"
|
||||
echo "Swagger文档: http://localhost:8080/swagger/index.html"
|
||||
echo "按 Ctrl+C 停止服务"
|
||||
echo ""
|
||||
go run cmd/server/main.go
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# CarrotSkin 环境变量检查脚本
|
||||
|
||||
echo "🔍 检查 CarrotSkin 环境变量配置..."
|
||||
|
||||
# 必需的环境变量列表
|
||||
REQUIRED_VARS=(
|
||||
"DATABASE_HOST"
|
||||
"DATABASE_USERNAME"
|
||||
"DATABASE_PASSWORD"
|
||||
"DATABASE_NAME"
|
||||
"RUSTFS_ENDPOINT"
|
||||
"RUSTFS_ACCESS_KEY"
|
||||
"RUSTFS_SECRET_KEY"
|
||||
"JWT_SECRET"
|
||||
)
|
||||
|
||||
# 检查.env文件是否存在
|
||||
if [ ! -f ".env" ]; then
|
||||
echo "❌ .env 文件不存在"
|
||||
echo "💡 请复制 .env.example 为 .env 并配置相关变量"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ .env 文件存在"
|
||||
|
||||
# 加载.env文件
|
||||
source .env 2>/dev/null
|
||||
|
||||
# 检查必需的环境变量
|
||||
missing_vars=()
|
||||
for var in "${REQUIRED_VARS[@]}"; do
|
||||
if [ -z "${!var}" ]; then
|
||||
missing_vars+=("$var")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing_vars[@]} -gt 0 ]; then
|
||||
echo "❌ 缺少以下必需的环境变量:"
|
||||
for var in "${missing_vars[@]}"; do
|
||||
echo " - $var"
|
||||
done
|
||||
echo ""
|
||||
echo "💡 请在 .env 文件中设置这些变量"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ 所有必需的环境变量都已设置"
|
||||
|
||||
# 检查关键配置的合理性
|
||||
echo ""
|
||||
echo "📋 当前配置概览:"
|
||||
echo " 数据库: $DATABASE_USERNAME@$DATABASE_HOST:$DATABASE_PORT/$DATABASE_NAME"
|
||||
echo " RustFS: $RUSTFS_ENDPOINT"
|
||||
echo " JWT密钥长度: ${#JWT_SECRET} 字符"
|
||||
|
||||
# 检查JWT密钥长度
|
||||
if [ ${#JWT_SECRET} -lt 32 ]; then
|
||||
echo "⚠️ JWT密钥过短,建议使用至少32字符的随机字符串"
|
||||
fi
|
||||
|
||||
# 检查默认密码
|
||||
if [ "$JWT_SECRET" = "your-jwt-secret-key-change-this-in-production" ]; then
|
||||
echo "⚠️ 使用的是默认JWT密钥,生产环境中请更改"
|
||||
fi
|
||||
|
||||
if [ "$DATABASE_PASSWORD" = "123456" ] || [ "$DATABASE_PASSWORD" = "your_password_here" ]; then
|
||||
echo "⚠️ 使用的是默认数据库密码,生产环境中请更改"
|
||||
fi
|
||||
|
||||
if [ "$RUSTFS_ACCESS_KEY" = "your_access_key" ] || [ "$RUSTFS_SECRET_KEY" = "your_secret_key" ]; then
|
||||
echo "⚠️ 使用的是默认RustFS凭证,生产环境中请更改"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 环境变量检查完成!"
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# CarrotSkin 开发环境启动脚本
|
||||
|
||||
echo "🚀 启动 CarrotSkin 开发环境..."
|
||||
|
||||
# 检查配置文件
|
||||
if [ ! -f "configs/config.yaml" ]; then
|
||||
echo "📝 复制配置文件..."
|
||||
cp configs/config.yaml.example configs/config.yaml
|
||||
echo "⚠️ 请编辑 configs/config.yaml 文件配置数据库和其他服务连接信息"
|
||||
fi
|
||||
|
||||
# 检查依赖
|
||||
echo "📦 检查依赖..."
|
||||
go mod tidy
|
||||
|
||||
# 生成Swagger文档
|
||||
echo "📚 生成Swagger文档..."
|
||||
if command -v swag &> /dev/null; then
|
||||
swag init -g cmd/server/main.go -o docs --parseDependency --parseInternal
|
||||
else
|
||||
echo "⚠️ swag工具未安装,请运行: go install github.com/swaggo/swag/cmd/swag@latest"
|
||||
fi
|
||||
|
||||
# 启动应用
|
||||
echo "🎯 启动应用..."
|
||||
go run cmd/server/main.go
|
||||
@@ -257,33 +257,222 @@ curl -X GET http://localhost:8080/api/v1/profile/{profile_info['profile_uuid']}
|
||||
return output
|
||||
|
||||
|
||||
def set_user_role(admin_token, user_id, role):
|
||||
"""设置用户角色"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {admin_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.put(
|
||||
f"{BASE_URL}/admin/users/role",
|
||||
json={"user_id": user_id, "role": role},
|
||||
headers=headers,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if result.get("code") == 200:
|
||||
print_success(f"用户角色设置为: {role}")
|
||||
return True
|
||||
else:
|
||||
print_error(f"设置角色失败: {result.get('message', '未知错误')}")
|
||||
return False
|
||||
except requests.exceptions.RequestException as e:
|
||||
print_error(f"设置角色失败: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def login_user(username, password):
|
||||
"""登录用户"""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/auth/login",
|
||||
json={"username": username, "password": password},
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if result.get("code") == 200:
|
||||
return result["data"]["token"]
|
||||
return None
|
||||
except requests.exceptions.RequestException:
|
||||
return None
|
||||
|
||||
|
||||
def create_admin_user():
|
||||
"""创建管理员用户"""
|
||||
print_step("创建管理员用户")
|
||||
|
||||
random_num = random.randint(10000, 99999)
|
||||
username = f"admin{random_num}"
|
||||
email = f"admin{random_num}@example.com"
|
||||
login_password = "admin123456"
|
||||
verification_code = "123456"
|
||||
|
||||
print_info(f"用户名: {username}")
|
||||
print_info(f"邮箱: {email}")
|
||||
print_info(f"密码: {login_password}")
|
||||
|
||||
register_data = {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": login_password,
|
||||
"verification_code": verification_code
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/auth/register",
|
||||
json=register_data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if result.get("code") == 200:
|
||||
jwt_token = result["data"]["token"]
|
||||
user_id = result["data"]["user_info"]["id"]
|
||||
|
||||
print_success("用户注册成功!")
|
||||
print_info(f"用户ID: {user_id}")
|
||||
|
||||
# 使用默认管理员账户提升权限
|
||||
print_info("尝试使用默认管理员账户提升权限...")
|
||||
default_admin_token = login_user("admin", "admin123456")
|
||||
|
||||
if default_admin_token:
|
||||
if set_user_role(default_admin_token, user_id, "admin"):
|
||||
print_success("管理员权限设置成功!")
|
||||
return {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": login_password,
|
||||
"jwt_token": jwt_token,
|
||||
"user_id": user_id,
|
||||
"role": "admin"
|
||||
}
|
||||
|
||||
print_error("无法提升权限,请手动设置")
|
||||
return {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": login_password,
|
||||
"jwt_token": jwt_token,
|
||||
"user_id": user_id,
|
||||
"role": "user"
|
||||
}
|
||||
else:
|
||||
print_error(f"注册失败: {result.get('message', '未知错误')}")
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
print_error(f"注册失败: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def generate_admin_output(admin_info):
|
||||
"""生成管理员账户输出信息"""
|
||||
output = f"""========================================
|
||||
CarrotSkin 管理员账户信息
|
||||
========================================
|
||||
|
||||
=== 账户信息 ===
|
||||
用户名: {admin_info['username']}
|
||||
邮箱: {admin_info['email']}
|
||||
密码: {admin_info['password']}
|
||||
用户ID: {admin_info['user_id']}
|
||||
角色: {admin_info['role']}
|
||||
|
||||
=== JWT Token (API认证) ===
|
||||
Token: {admin_info['jwt_token']}
|
||||
|
||||
=== 管理员API示例 ===
|
||||
|
||||
# 1. 获取用户列表
|
||||
curl -X GET "{BASE_URL}/admin/users" \\
|
||||
-H "Authorization: Bearer {admin_info['jwt_token']}"
|
||||
|
||||
# 2. 设置用户角色为管理员
|
||||
curl -X PUT "{BASE_URL}/admin/users/role" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer {admin_info['jwt_token']}" \\
|
||||
-d '{{"user_id": 1, "role": "admin"}}'
|
||||
|
||||
# 3. 获取材质列表(审核)
|
||||
curl -X GET "{BASE_URL}/admin/textures" \\
|
||||
-H "Authorization: Bearer {admin_info['jwt_token']}"
|
||||
|
||||
# 4. 删除材质
|
||||
curl -X DELETE "{BASE_URL}/admin/textures/1" \\
|
||||
-H "Authorization: Bearer {admin_info['jwt_token']}"
|
||||
|
||||
========================================
|
||||
"""
|
||||
return output
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
print_header("CarrotSkin 测试账户生成器")
|
||||
|
||||
# 步骤1: 注册用户
|
||||
user_info = register_user()
|
||||
# 选择模式
|
||||
print("请选择操作:")
|
||||
print(" 1. 创建普通测试用户")
|
||||
print(" 2. 创建管理员用户")
|
||||
print(" 3. 创建两者")
|
||||
|
||||
# 步骤2: 创建角色
|
||||
profile_info = create_profile(user_info["jwt_token"])
|
||||
choice = input("\n请输入选项 (1/2/3) [默认: 1]: ").strip() or "1"
|
||||
|
||||
# 步骤3: 重置Yggdrasil密码
|
||||
yggdrasil_password = reset_yggdrasil_password(user_info["jwt_token"])
|
||||
if choice in ["1", "3"]:
|
||||
# 创建普通用户
|
||||
print_header("创建普通测试用户")
|
||||
|
||||
# 步骤1: 注册用户
|
||||
user_info = register_user()
|
||||
|
||||
# 步骤2: 创建角色
|
||||
profile_info = create_profile(user_info["jwt_token"])
|
||||
|
||||
# 步骤3: 重置Yggdrasil密码
|
||||
yggdrasil_password = reset_yggdrasil_password(user_info["jwt_token"])
|
||||
|
||||
# 输出信息
|
||||
print_header("普通用户信息汇总")
|
||||
output = generate_output(user_info, profile_info, yggdrasil_password)
|
||||
print(output)
|
||||
|
||||
# 保存到文件
|
||||
output_file = f"test_account_{user_info['username']}.txt"
|
||||
try:
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
f.write(output)
|
||||
print_success(f"信息已保存到文件: {output_file}")
|
||||
except Exception as e:
|
||||
print_error(f"保存文件失败: {str(e)}")
|
||||
|
||||
# 步骤4: 输出所有信息
|
||||
print_header("测试账户信息汇总")
|
||||
|
||||
output = generate_output(user_info, profile_info, yggdrasil_password)
|
||||
print(output)
|
||||
|
||||
# 保存到文件
|
||||
output_file = f"test_account_{user_info['username']}.txt"
|
||||
try:
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
f.write(output)
|
||||
print_success(f"信息已保存到文件: {output_file}")
|
||||
except Exception as e:
|
||||
print_error(f"保存文件失败: {str(e)}")
|
||||
if choice in ["2", "3"]:
|
||||
# 创建管理员用户
|
||||
print_header("创建管理员用户")
|
||||
admin_info = create_admin_user()
|
||||
|
||||
if admin_info:
|
||||
print_header("管理员账户信息汇总")
|
||||
admin_output = generate_admin_output(admin_info)
|
||||
print(admin_output)
|
||||
|
||||
# 保存到文件
|
||||
admin_output_file = f"admin_account_{admin_info['username']}.txt"
|
||||
try:
|
||||
with open(admin_output_file, "w", encoding="utf-8") as f:
|
||||
f.write(admin_output)
|
||||
print_success(f"管理员信息已保存到文件: {admin_output_file}")
|
||||
except Exception as e:
|
||||
print_error(f"保存文件失败: {str(e)}")
|
||||
|
||||
print_header("测试完成!")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user