diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..fecd6f8
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,84 @@
+# 摄影作品集项目环境变量配置
+
+# ================================
+# 数据库配置
+# ================================
+DB_HOST=postgres
+DB_PORT=5432
+DB_NAME=photography
+DB_USER=postgres
+DB_PASSWORD=your_strong_password_here
+
+# ================================
+# Redis 配置
+# ================================
+REDIS_HOST=redis
+REDIS_PORT=6379
+REDIS_PASSWORD=your_redis_password_here
+
+# ================================
+# JWT 认证配置
+# ================================
+JWT_SECRET=your_jwt_secret_key_here_at_least_32_characters
+JWT_EXPIRES_IN=24h
+
+# ================================
+# 服务器配置
+# ================================
+PORT=8080
+GIN_MODE=release
+CORS_ORIGINS=https://photography.iriver.top,https://admin.photography.iriver.top
+
+# ================================
+# 文件存储配置
+# ================================
+STORAGE_TYPE=local
+STORAGE_PATH=/app/uploads
+MAX_UPLOAD_SIZE=10MB
+
+# AWS S3 配置 (如果使用 S3 存储)
+# AWS_REGION=us-east-1
+# AWS_BUCKET=photography-bucket
+# AWS_ACCESS_KEY_ID=your_access_key
+# AWS_SECRET_ACCESS_KEY=your_secret_key
+
+# ================================
+# 日志配置
+# ================================
+LOG_LEVEL=info
+LOG_FORMAT=json
+
+# ================================
+# 邮件配置 (用于通知)
+# ================================
+SMTP_HOST=smtp.gmail.com
+SMTP_PORT=587
+SMTP_USER=your_email@gmail.com
+SMTP_PASSWORD=your_app_password
+SMTP_FROM=noreply@photography.iriver.top
+
+# ================================
+# 监控配置
+# ================================
+ENABLE_METRICS=true
+METRICS_PORT=9090
+
+# ================================
+# 安全配置
+# ================================
+RATE_LIMIT=100
+RATE_LIMIT_WINDOW=1h
+ENABLE_CSRF=true
+CSRF_SECRET=your_csrf_secret_here
+
+# ================================
+# 缓存配置
+# ================================
+CACHE_TTL=3600
+ENABLE_CACHE=true
+
+# ================================
+# 部署配置
+# ================================
+ENVIRONMENT=production
+TZ=Asia/Shanghai
\ No newline at end of file
diff --git a/.gitea/workflows/deploy-admin.yml b/.gitea/workflows/deploy-admin.yml
new file mode 100644
index 0000000..dda8b70
--- /dev/null
+++ b/.gitea/workflows/deploy-admin.yml
@@ -0,0 +1,336 @@
+name: 部署管理后台
+
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - 'admin/**'
+ - '.gitea/workflows/deploy-admin.yml'
+ workflow_dispatch:
+
+jobs:
+ test-and-build:
+ name: 🧪 测试和构建
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: 📥 检出代码
+ uses: actions/checkout@v4
+
+ - name: 📦 设置 Node.js 环境
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+ cache: 'npm'
+ cache-dependency-path: admin/package-lock.json
+
+ - name: 📦 安装依赖
+ working-directory: ./admin
+ run: npm ci
+
+ - name: 🔍 代码检查
+ working-directory: ./admin
+ run: |
+ npm run lint
+ npm run type-check
+
+ - name: 🎨 格式检查
+ working-directory: ./admin
+ run: npm run format
+
+ - name: 🧪 运行测试
+ working-directory: ./admin
+ run: npm run test
+
+ - name: 🔒 安全审计
+ working-directory: ./admin
+ run: npm audit --audit-level moderate
+
+ - name: 🏗️ 构建生产版本
+ working-directory: ./admin
+ env:
+ VITE_APP_TITLE: 摄影作品集管理后台
+ VITE_API_BASE_URL: https://api.photography.iriver.top
+ VITE_UPLOAD_URL: https://api.photography.iriver.top/upload
+ run: npm run build
+
+ - name: 📊 构建分析
+ working-directory: ./admin
+ run: |
+ echo "📦 构建产物分析:"
+ du -sh dist/
+ echo "📁 文件列表:"
+ find dist/ -type f -name "*.js" -o -name "*.css" | head -10
+ echo "📈 文件大小统计:"
+ find dist/ -type f \( -name "*.js" -o -name "*.css" \) -exec ls -lh {} + | awk '{print $5, $9}' | sort -hr | head -10
+
+ - name: 📦 打包构建产物
+ uses: actions/upload-artifact@v3
+ with:
+ name: admin-dist
+ path: admin/dist/
+ retention-days: 7
+
+ deploy:
+ name: 🚀 部署到生产环境
+ runs-on: ubuntu-latest
+ needs: test-and-build
+ if: github.ref == 'refs/heads/main'
+
+ steps:
+ - name: 📥 检出代码
+ uses: actions/checkout@v4
+
+ - name: 📦 设置 Node.js 环境
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+ cache: 'npm'
+ cache-dependency-path: admin/package-lock.json
+
+ - name: 📦 安装依赖
+ working-directory: ./admin
+ run: npm ci
+
+ - name: 🏗️ 构建生产版本
+ working-directory: ./admin
+ env:
+ VITE_APP_TITLE: 摄影作品集管理后台
+ VITE_API_BASE_URL: https://api.photography.iriver.top
+ VITE_UPLOAD_URL: https://api.photography.iriver.top/upload
+ run: npm run build
+
+ - name: 📊 压缩构建产物
+ working-directory: ./admin
+ run: |
+ tar -czf admin-dist.tar.gz -C dist .
+ echo "压缩完成: $(ls -lh admin-dist.tar.gz)"
+
+ - name: 🚀 部署到服务器
+ uses: appleboy/ssh-action@v1.0.0
+ with:
+ host: ${{ secrets.HOST }}
+ username: ${{ secrets.USERNAME }}
+ key: ${{ secrets.SSH_KEY }}
+ port: ${{ secrets.PORT }}
+ script: |
+ # 设置变量
+ ADMIN_DIR="/home/gitea/www/photography-admin"
+ BACKUP_DIR="/home/gitea/backups/photography-admin"
+ TEMP_DIR="/tmp/photography-admin-deploy"
+
+ echo "🚀 开始部署管理后台..."
+
+ # 创建临时目录
+ mkdir -p "$TEMP_DIR"
+
+ # 创建备份目录
+ mkdir -p "$BACKUP_DIR"
+
+ # 备份当前版本
+ if [ -d "$ADMIN_DIR" ] && [ "$(ls -A $ADMIN_DIR)" ]; then
+ echo "📦 备份当前版本..."
+ BACKUP_NAME="admin-$(date +%Y%m%d-%H%M%S).tar.gz"
+ tar -czf "$BACKUP_DIR/$BACKUP_NAME" -C "$ADMIN_DIR" .
+ echo "✅ 备份完成: $BACKUP_NAME"
+
+ # 保留最近10个备份
+ cd "$BACKUP_DIR"
+ ls -t admin-*.tar.gz | tail -n +11 | xargs -r rm
+ echo "🧹 清理旧备份完成"
+ fi
+
+ echo "📁 准备部署目录..."
+ mkdir -p "$ADMIN_DIR"
+
+ - name: 📤 上传构建产物
+ uses: appleboy/scp-action@v0.1.4
+ with:
+ host: ${{ secrets.HOST }}
+ username: ${{ secrets.USERNAME }}
+ key: ${{ secrets.SSH_KEY }}
+ port: ${{ secrets.PORT }}
+ source: admin/admin-dist.tar.gz
+ target: /tmp/photography-admin-deploy/
+ strip_components: 1
+
+ - name: 🔄 解压并部署
+ uses: appleboy/ssh-action@v1.0.0
+ with:
+ host: ${{ secrets.HOST }}
+ username: ${{ secrets.USERNAME }}
+ key: ${{ secrets.SSH_KEY }}
+ port: ${{ secrets.PORT }}
+ script: |
+ # 设置变量
+ ADMIN_DIR="/home/gitea/www/photography-admin"
+ TEMP_DIR="/tmp/photography-admin-deploy"
+
+ echo "🔄 解压新版本..."
+ cd "$TEMP_DIR"
+ tar -xzf admin-dist.tar.gz
+
+ echo "📂 部署新版本..."
+ # 清空目标目录
+ rm -rf "$ADMIN_DIR"/*
+
+ # 复制新文件
+ cp -r * "$ADMIN_DIR/"
+
+ echo "🔐 设置文件权限..."
+ chown -R gitea:gitea "$ADMIN_DIR"
+ chmod -R 755 "$ADMIN_DIR"
+
+ # 设置正确的文件权限
+ find "$ADMIN_DIR" -type f -name "*.html" -o -name "*.js" -o -name "*.css" -o -name "*.json" | xargs chmod 644
+ find "$ADMIN_DIR" -type d | xargs chmod 755
+
+ echo "🧹 清理临时文件..."
+ rm -rf "$TEMP_DIR"
+
+ echo "✅ 管理后台部署完成!"
+ echo "📊 部署统计:"
+ echo "文件数量: $(find $ADMIN_DIR -type f | wc -l)"
+ echo "目录大小: $(du -sh $ADMIN_DIR)"
+
+ - name: 🔍 健康检查
+ uses: appleboy/ssh-action@v1.0.0
+ with:
+ host: ${{ secrets.HOST }}
+ username: ${{ secrets.USERNAME }}
+ key: ${{ secrets.SSH_KEY }}
+ port: ${{ secrets.PORT }}
+ script: |
+ echo "🔍 执行健康检查..."
+
+ # 检查文件是否存在
+ if [ -f "/home/gitea/www/photography-admin/index.html" ]; then
+ echo "✅ index.html 文件存在"
+ else
+ echo "❌ index.html 文件不存在"
+ exit 1
+ fi
+
+ # 检查网站是否可访问 (本地检查)
+ sleep 5
+ if curl -f -s -o /dev/null https://admin.photography.iriver.top; then
+ echo "✅ 管理后台访问正常"
+ else
+ echo "⚠️ 管理后台访问异常,请检查 Caddy 配置"
+ fi
+
+ # 重新加载 Caddy (确保新文件被正确服务)
+ sudo systemctl reload caddy
+ echo "🔄 Caddy 配置已重新加载"
+
+ - name: 📧 发送部署通知
+ if: always()
+ uses: appleboy/telegram-action@master
+ with:
+ to: ${{ secrets.TELEGRAM_TO }}
+ token: ${{ secrets.TELEGRAM_TOKEN }}
+ message: |
+ 🎨 摄影作品集管理后台部署
+
+ 📦 项目: ${{ github.repository }}
+ 🌿 分支: ${{ github.ref_name }}
+ 👤 提交者: ${{ github.actor }}
+ 📝 提交信息: ${{ github.event.head_commit.message }}
+
+ ${{ job.status == 'success' && '✅ 部署成功' || '❌ 部署失败' }}
+
+ 🌐 管理后台: https://admin.photography.iriver.top
+ 📱 前端: https://photography.iriver.top
+
+ rollback:
+ name: 🔄 回滚部署
+ runs-on: ubuntu-latest
+ if: failure() && github.ref == 'refs/heads/main'
+ needs: deploy
+
+ steps:
+ - name: 🔄 执行回滚
+ uses: appleboy/ssh-action@v1.0.0
+ with:
+ host: ${{ secrets.HOST }}
+ username: ${{ secrets.USERNAME }}
+ key: ${{ secrets.SSH_KEY }}
+ port: ${{ secrets.PORT }}
+ script: |
+ ADMIN_DIR="/home/gitea/www/photography-admin"
+ BACKUP_DIR="/home/gitea/backups/photography-admin"
+
+ echo "🔄 开始回滚管理后台..."
+
+ # 查找最新的备份
+ LATEST_BACKUP=$(ls -t "$BACKUP_DIR"/admin-*.tar.gz 2>/dev/null | head -n 1)
+
+ if [ -n "$LATEST_BACKUP" ]; then
+ echo "📦 找到备份文件: $LATEST_BACKUP"
+
+ # 清空当前目录
+ rm -rf "$ADMIN_DIR"/*
+
+ # 恢复备份
+ tar -xzf "$LATEST_BACKUP" -C "$ADMIN_DIR"
+
+ # 设置权限
+ chown -R gitea:gitea "$ADMIN_DIR"
+ chmod -R 755 "$ADMIN_DIR"
+
+ # 重新加载 Caddy
+ sudo systemctl reload caddy
+
+ echo "✅ 回滚完成"
+ else
+ echo "❌ 未找到备份文件,无法回滚"
+ exit 1
+ fi
+
+ security-scan:
+ name: 🔒 安全扫描
+ runs-on: ubuntu-latest
+ needs: test-and-build
+
+ steps:
+ - name: 📥 检出代码
+ uses: actions/checkout@v4
+
+ - name: 📦 设置 Node.js 环境
+ uses: actions/setup-node@v4
+ with:
+ node-version: '18'
+ cache: 'npm'
+ cache-dependency-path: admin/package-lock.json
+
+ - name: 📦 安装依赖
+ working-directory: ./admin
+ run: npm ci
+
+ - name: 🔒 运行安全扫描
+ working-directory: ./admin
+ run: |
+ echo "🔍 扫描已知漏洞..."
+ npm audit --audit-level high --production
+
+ echo "📊 依赖分析..."
+ npx license-checker --summary
+
+ echo "🔍 检查过时依赖..."
+ npx npm-check-updates
+
+ - name: 📊 生成安全报告
+ working-directory: ./admin
+ run: |
+ echo "# 安全扫描报告" > security-report.md
+ echo "## 日期: $(date)" >> security-report.md
+ echo "## 依赖统计" >> security-report.md
+ npm ls --depth=0 --json | jq -r '.dependencies | keys | length' | xargs -I {} echo "依赖数量: {}" >> security-report.md
+ echo "## 许可证检查" >> security-report.md
+ npx license-checker --csv >> security-report.md
+
+ - name: 📤 上传安全报告
+ uses: actions/upload-artifact@v3
+ with:
+ name: security-report
+ path: admin/security-report.md
\ No newline at end of file
diff --git a/.gitea/workflows/deploy-backend.yml b/.gitea/workflows/deploy-backend.yml
new file mode 100644
index 0000000..3bacadc
--- /dev/null
+++ b/.gitea/workflows/deploy-backend.yml
@@ -0,0 +1,261 @@
+name: 部署后端服务
+
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - 'backend/**'
+ - 'docker-compose.yml'
+ - '.env.example'
+ - '.gitea/workflows/deploy-backend.yml'
+ workflow_dispatch:
+
+env:
+ REGISTRY: registry.cn-hangzhou.aliyuncs.com
+ IMAGE_NAME: photography/backend
+
+jobs:
+ test:
+ name: 🧪 测试后端
+ runs-on: ubuntu-latest
+
+ services:
+ postgres:
+ image: postgres:15-alpine
+ env:
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: photography_test
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+
+ steps:
+ - name: 📥 检出代码
+ uses: actions/checkout@v4
+
+ - name: 🐹 设置 Go 环境
+ uses: actions/setup-go@v4
+ with:
+ go-version: '1.21'
+ cache-dependency-path: backend/go.sum
+
+ - name: 📦 下载依赖
+ working-directory: ./backend
+ run: go mod download
+
+ - name: 🔍 代码检查
+ working-directory: ./backend
+ run: |
+ go vet ./...
+ go fmt ./...
+ # 检查是否有格式化变更
+ if [ -n "$(git status --porcelain)" ]; then
+ echo "代码格式不符合规范,请运行 go fmt"
+ exit 1
+ fi
+
+ - name: 🧪 运行测试
+ working-directory: ./backend
+ env:
+ DB_HOST: localhost
+ DB_PORT: 5432
+ DB_USER: postgres
+ DB_PASSWORD: postgres
+ DB_NAME: photography_test
+ JWT_SECRET: test_jwt_secret_for_ci_cd_testing_only
+ run: |
+ go test -v -race -coverprofile=coverage.out ./...
+ go tool cover -html=coverage.out -o coverage.html
+
+ - name: 📊 上传覆盖率报告
+ uses: actions/upload-artifact@v3
+ with:
+ name: coverage-report
+ path: backend/coverage.html
+
+ - name: 🏗️ 构建检查
+ working-directory: ./backend
+ run: |
+ CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go
+ echo "构建成功"
+
+ build-and-deploy:
+ name: 🚀 构建并部署
+ runs-on: ubuntu-latest
+ needs: test
+ if: github.ref == 'refs/heads/main'
+
+ steps:
+ - name: 📥 检出代码
+ uses: actions/checkout@v4
+
+ - name: 🐳 设置 Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: 🔑 登录到镜像仓库
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+
+ - name: 📝 提取元数据
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=ref,event=branch
+ type=ref,event=pr
+ type=sha,prefix={{branch}}-
+ type=raw,value=latest,enable={{is_default_branch}}
+
+ - name: 🏗️ 构建并推送镜像
+ uses: docker/build-push-action@v5
+ with:
+ context: ./backend
+ file: ./backend/Dockerfile
+ platforms: linux/amd64
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: 🚀 部署到生产环境
+ uses: appleboy/ssh-action@v1.0.0
+ with:
+ host: ${{ secrets.HOST }}
+ username: ${{ secrets.USERNAME }}
+ key: ${{ secrets.SSH_KEY }}
+ port: ${{ secrets.PORT }}
+ script: |
+ # 切换到项目目录
+ cd /home/gitea/photography
+
+ # 拉取最新代码
+ git pull origin main
+
+ # 备份当前运行的容器 (如果存在)
+ if docker ps -q -f name=photography_backend; then
+ echo "📦 备份当前后端容器..."
+ docker commit photography_backend photography_backend_backup_$(date +%Y%m%d_%H%M%S)
+ fi
+
+ # 停止现有服务
+ echo "🛑 停止现有服务..."
+ docker-compose down backend || true
+
+ # 拉取最新镜像
+ echo "📥 拉取最新镜像..."
+ docker-compose pull backend
+
+ # 启动数据库 (如果未运行)
+ echo "🗄️ 确保数据库运行..."
+ docker-compose up -d postgres redis
+
+ # 等待数据库就绪
+ echo "⏳ 等待数据库就绪..."
+ sleep 10
+
+ # 运行数据库迁移
+ echo "🔄 运行数据库迁移..."
+ docker-compose run --rm backend ./main migrate || echo "迁移完成或已是最新"
+
+ # 启动后端服务
+ echo "🚀 启动后端服务..."
+ docker-compose up -d backend
+
+ # 等待服务启动
+ echo "⏳ 等待服务启动..."
+ sleep 30
+
+ # 健康检查
+ echo "🔍 执行健康检查..."
+ for i in {1..30}; do
+ if curl -f http://localhost:8080/health > /dev/null 2>&1; then
+ echo "✅ 后端服务健康检查通过"
+ break
+ fi
+ echo "等待后端服务启动... ($i/30)"
+ sleep 10
+ done
+
+ # 检查服务状态
+ echo "📊 检查服务状态..."
+ docker-compose ps
+
+ # 清理旧镜像 (保留最近3个)
+ echo "🧹 清理旧镜像..."
+ docker images ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} --format "table {{.Repository}}:{{.Tag}}\t{{.CreatedAt}}" | tail -n +2 | sort -k2 -r | tail -n +4 | awk '{print $1}' | xargs -r docker rmi || true
+
+ # 清理旧备份容器 (保留最近5个)
+ docker images photography_backend_backup_* --format "table {{.Repository}}:{{.Tag}}\t{{.CreatedAt}}" | tail -n +2 | sort -k2 -r | tail -n +6 | awk '{print $1}' | xargs -r docker rmi || true
+
+ echo "🎉 后端部署完成!"
+
+ - name: 📧 发送部署通知
+ if: always()
+ uses: appleboy/telegram-action@master
+ with:
+ to: ${{ secrets.TELEGRAM_TO }}
+ token: ${{ secrets.TELEGRAM_TOKEN }}
+ message: |
+ 🔧 摄影作品集后端部署
+
+ 📦 项目: ${{ github.repository }}
+ 🌿 分支: ${{ github.ref_name }}
+ 👤 提交者: ${{ github.actor }}
+ 📝 提交信息: ${{ github.event.head_commit.message }}
+
+ ${{ job.status == 'success' && '✅ 部署成功' || '❌ 部署失败' }}
+
+ 🌐 API: https://api.photography.iriver.top/health
+ 📊 监控: https://admin.photography.iriver.top
+
+ rollback:
+ name: 🔄 回滚部署
+ runs-on: ubuntu-latest
+ if: failure() && github.ref == 'refs/heads/main'
+ needs: build-and-deploy
+
+ steps:
+ - name: 🔄 执行回滚
+ uses: appleboy/ssh-action@v1.0.0
+ with:
+ host: ${{ secrets.HOST }}
+ username: ${{ secrets.USERNAME }}
+ key: ${{ secrets.SSH_KEY }}
+ port: ${{ secrets.PORT }}
+ script: |
+ cd /home/gitea/photography
+
+ echo "🔄 开始回滚后端服务..."
+
+ # 查找最新的备份容器
+ BACKUP_IMAGE=$(docker images photography_backend_backup_* --format "table {{.Repository}}:{{.Tag}}\t{{.CreatedAt}}" | tail -n +2 | sort -k2 -r | head -n 1 | awk '{print $1}')
+
+ if [ -n "$BACKUP_IMAGE" ]; then
+ echo "📦 找到备份镜像: $BACKUP_IMAGE"
+
+ # 停止当前服务
+ docker-compose down backend
+
+ # 标记备份镜像为最新
+ docker tag $BACKUP_IMAGE photography_backend:rollback
+
+ # 修改 docker-compose 使用回滚镜像
+ sed -i 's|build: .*|image: photography_backend:rollback|g' docker-compose.yml
+
+ # 启动回滚版本
+ docker-compose up -d backend
+
+ echo "✅ 回滚完成"
+ else
+ echo "❌ 未找到备份镜像,无法回滚"
+ exit 1
+ fi
\ No newline at end of file
diff --git a/.gitea/workflows/deploy-frontend.yml b/.gitea/workflows/deploy-frontend.yml
index f329d09..2a55825 100644
--- a/.gitea/workflows/deploy-frontend.yml
+++ b/.gitea/workflows/deploy-frontend.yml
@@ -1,78 +1,142 @@
-name: Deploy Frontend
+name: 部署前端网站
+
on:
push:
branches: [ main ]
- paths: [ 'frontend/**' ]
- pull_request:
- branches: [ main ]
- paths: [ 'frontend/**' ]
+ paths:
+ - 'frontend/**'
+ - '.gitea/workflows/deploy-frontend.yml'
+ workflow_dispatch:
jobs:
- deploy:
+ test-and-build:
+ name: 🧪 测试和构建
runs-on: ubuntu-latest
-
+
steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Setup Bun
- uses: oven-sh/setup-bun@v1
- with:
- bun-version: latest
-
- - name: Install dependencies
- run: |
- cd frontend
- bun install
-
- - name: Run type check
- run: |
- cd frontend
- bun run type-check
-
- - name: Run lint
- run: |
- cd frontend
- bun run lint
-
- - name: Build project
- run: |
- cd frontend
- bun run build
-
- - name: Deploy to VPS
- run: |
- # 安装 SSH 客户端、rsync 和 sshpass
- sudo apt-get update && sudo apt-get install -y openssh-client rsync sshpass
-
- # 设置 SSH 选项以禁用主机密钥检查(用于密码认证)
- export SSHPASS=${{ secrets.ALIYUN_PWD }}
-
- # 测试 SSH 连接
- sshpass -e ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} "echo 'SSH 连接成功'"
-
- # 在服务器上创建用户目录下的部署目录
- sshpass -e ssh -o StrictHostKeyChecking=no ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} "mkdir -p ~/www/photography"
-
- # 上传构建文件到服务器用户目录(使用密码认证)
- sshpass -e rsync -avz --delete --progress -e "ssh -o StrictHostKeyChecking=no" frontend/out/ ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }}:~/www/photography/
-
- # 设置文件权限(用户目录无需 sudo)
- sshpass -e ssh -o StrictHostKeyChecking=no ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} "chmod -R 755 ~/www/photography"
-
- # 显示部署信息(Caddy 配置需要手动配置指向新路径)
- sshpass -e ssh -o StrictHostKeyChecking=no ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} "echo '提示:请确保 Web 服务器配置指向 ~/www/photography/ 目录'"
-
- echo "✅ 部署完成!"
- echo "📁 部署路径:~/www/photography/"
- echo "🌐 访问地址:https://photography.iriver.top"
-
- - name: Notify success
- if: success()
- run: |
- echo "✅ 前端项目部署成功!"
-
- - name: Notify failure
- if: failure()
- run: |
- echo "❌ 前端项目部署失败!"
\ No newline at end of file
+ - name: 📥 检出代码
+ uses: actions/checkout@v4
+
+ - name: 🦀 设置 Bun 环境
+ uses: oven-sh/setup-bun@v1
+ with:
+ bun-version: latest
+
+ - name: 📦 安装依赖
+ working-directory: ./frontend
+ run: bun install
+
+ - name: 🔍 代码检查
+ working-directory: ./frontend
+ run: |
+ bun run lint
+ bun run type-check
+
+ - name: 🧪 运行测试
+ working-directory: ./frontend
+ run: bun run test
+
+ - name: 🏗️ 构建生产版本
+ working-directory: ./frontend
+ env:
+ NEXT_PUBLIC_API_URL: https://api.photography.iriver.top
+ NEXT_PUBLIC_SITE_URL: https://photography.iriver.top
+ NEXT_PUBLIC_SITE_NAME: 摄影作品集
+ run: bun run build
+
+ - name: 📦 打包构建产物
+ uses: actions/upload-artifact@v3
+ with:
+ name: frontend-dist
+ path: frontend/out/
+ retention-days: 7
+
+ deploy:
+ name: 🚀 部署到生产环境
+ runs-on: ubuntu-latest
+ needs: test-and-build
+ if: github.ref == 'refs/heads/main'
+
+ steps:
+ - name: 📥 检出代码
+ uses: actions/checkout@v4
+
+ - name: 🦀 设置 Bun 环境
+ uses: oven-sh/setup-bun@v1
+ with:
+ bun-version: latest
+
+ - name: 📦 安装依赖
+ working-directory: ./frontend
+ run: bun install
+
+ - name: 🏗️ 构建生产版本
+ working-directory: ./frontend
+ env:
+ NEXT_PUBLIC_API_URL: https://api.photography.iriver.top
+ NEXT_PUBLIC_SITE_URL: https://photography.iriver.top
+ NEXT_PUBLIC_SITE_NAME: 摄影作品集
+ run: bun run build
+
+ - name: 🚀 部署到服务器
+ run: |
+ # 安装部署工具
+ sudo apt-get update && sudo apt-get install -y openssh-client rsync sshpass
+
+ # 设置 SSH 环境
+ export SSHPASS=${{ secrets.ALIYUN_PWD }}
+
+ echo "🔗 测试 SSH 连接..."
+ sshpass -e ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} "echo 'SSH 连接成功'"
+
+ echo "📁 创建部署目录..."
+ sshpass -e ssh -o StrictHostKeyChecking=no ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} "mkdir -p /home/gitea/www/photography"
+
+ echo "📦 备份当前版本..."
+ sshpass -e ssh -o StrictHostKeyChecking=no ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} "
+ if [ -d '/home/gitea/www/photography' ] && [ \"\$(ls -A /home/gitea/www/photography)\" ]; then
+ mkdir -p /home/gitea/backups/photography-frontend
+ tar -czf /home/gitea/backups/photography-frontend/frontend-\$(date +%Y%m%d-%H%M%S).tar.gz -C /home/gitea/www/photography .
+ echo '✅ 备份完成'
+ fi
+ "
+
+ echo "🚀 部署新版本..."
+ sshpass -e rsync -avz --delete --progress -e "ssh -o StrictHostKeyChecking=no" frontend/out/ ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }}:/home/gitea/www/photography/
+
+ echo "🔐 设置文件权限..."
+ sshpass -e ssh -o StrictHostKeyChecking=no ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} "
+ chown -R gitea:gitea /home/gitea/www/photography
+ chmod -R 755 /home/gitea/www/photography
+ find /home/gitea/www/photography -type f -name '*.html' -o -name '*.js' -o -name '*.css' -o -name '*.json' | xargs chmod 644
+ "
+
+ echo "🔄 重新加载 Web 服务器..."
+ sshpass -e ssh -o StrictHostKeyChecking=no ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} "sudo systemctl reload caddy"
+
+ echo "✅ 前端部署完成!"
+ echo "📁 部署路径:/home/gitea/www/photography/"
+ echo "🌐 访问地址:https://photography.iriver.top"
+
+ - name: 🔍 健康检查
+ run: |
+ echo "🔍 执行健康检查..."
+ sleep 10
+
+ # 检查网站是否可访问
+ if curl -f -s -o /dev/null https://photography.iriver.top; then
+ echo "✅ 前端网站访问正常"
+ else
+ echo "⚠️ 前端网站访问异常"
+ exit 1
+ fi
+
+ - name: 📧 发送部署通知
+ if: always()
+ run: |
+ if [ "${{ job.status }}" = "success" ]; then
+ echo "✅ 摄影作品集前端部署成功!"
+ echo "🌐 访问地址: https://photography.iriver.top"
+ else
+ echo "❌ 摄影作品集前端部署失败!"
+ fi
\ No newline at end of file
diff --git a/admin/.pre-commit-config.yaml b/admin/.pre-commit-config.yaml
new file mode 100644
index 0000000..498b604
--- /dev/null
+++ b/admin/.pre-commit-config.yaml
@@ -0,0 +1,63 @@
+repos:
+ # ESLint 代码检查
+ - repo: local
+ hooks:
+ - id: eslint
+ name: ESLint
+ entry: npm run lint
+ language: system
+ files: '\.(js|jsx|ts|tsx)$'
+ pass_filenames: false
+ always_run: false
+ stages: [commit]
+
+ # TypeScript 类型检查
+ - repo: local
+ hooks:
+ - id: tsc
+ name: TypeScript Check
+ entry: npm run type-check
+ language: system
+ files: '\.(ts|tsx)$'
+ pass_filenames: false
+ always_run: false
+ stages: [commit]
+
+ # Prettier 代码格式化
+ - repo: local
+ hooks:
+ - id: prettier
+ name: Prettier
+ entry: npm run format:fix
+ language: system
+ files: '\.(js|jsx|ts|tsx|json|css|scss|md)$'
+ pass_filenames: false
+ always_run: false
+ stages: [commit]
+
+ # 构建检查
+ - repo: local
+ hooks:
+ - id: build-check
+ name: Build Check
+ entry: npm run build
+ language: system
+ pass_filenames: false
+ always_run: false
+ stages: [push]
+
+ # 依赖安全检查
+ - repo: local
+ hooks:
+ - id: audit
+ name: Security Audit
+ entry: npm audit --audit-level moderate
+ language: system
+ pass_filenames: false
+ always_run: false
+ stages: [commit]
+
+# 全局配置
+default_stages: [commit]
+fail_fast: false
+minimum_pre_commit_version: "2.20.0"
\ No newline at end of file
diff --git a/admin/.prettierignore b/admin/.prettierignore
new file mode 100644
index 0000000..e4692ba
--- /dev/null
+++ b/admin/.prettierignore
@@ -0,0 +1,9 @@
+node_modules
+dist
+.vite
+coverage
+*.min.js
+*.min.css
+pnpm-lock.yaml
+package-lock.json
+yarn.lock
\ No newline at end of file
diff --git a/admin/.prettierrc b/admin/.prettierrc
new file mode 100644
index 0000000..e1ff035
--- /dev/null
+++ b/admin/.prettierrc
@@ -0,0 +1,15 @@
+{
+ "semi": false,
+ "singleQuote": true,
+ "tabWidth": 2,
+ "trailingComma": "es5",
+ "printWidth": 100,
+ "useTabs": false,
+ "bracketSpacing": true,
+ "bracketSameLine": false,
+ "arrowParens": "avoid",
+ "endOfLine": "lf",
+ "quoteProps": "as-needed",
+ "jsxSingleQuote": false,
+ "plugins": ["prettier-plugin-organize-imports"]
+}
\ No newline at end of file
diff --git a/admin/index.html b/admin/index.html
new file mode 100644
index 0000000..9170fa8
--- /dev/null
+++ b/admin/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ 摄影作品集 - 管理后台
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin/package.json b/admin/package.json
new file mode 100644
index 0000000..9d79914
--- /dev/null
+++ b/admin/package.json
@@ -0,0 +1,69 @@
+{
+ "name": "photography-admin",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "lint:fix": "eslint . --ext ts,tsx --fix",
+ "type-check": "tsc --noEmit",
+ "format": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
+ "format:fix": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
+ "test": "echo \"No tests specified\" && exit 0",
+ "prepare": "cd .. && husky install admin/.husky",
+ "pre-commit": "lint-staged"
+ },
+ "dependencies": {
+ "@radix-ui/react-dialog": "^1.0.5",
+ "@radix-ui/react-dropdown-menu": "^2.0.6",
+ "@radix-ui/react-label": "^2.0.2",
+ "@radix-ui/react-select": "^2.0.0",
+ "@radix-ui/react-slot": "^1.0.2",
+ "@radix-ui/react-switch": "^1.0.3",
+ "@radix-ui/react-tabs": "^1.0.4",
+ "@radix-ui/react-toast": "^1.1.5",
+ "@tanstack/react-query": "^5.17.19",
+ "@tanstack/react-query-devtools": "^5.17.21",
+ "axios": "^1.6.5",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.0",
+ "lucide-react": "^0.312.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-hook-form": "^7.48.2",
+ "react-router-dom": "^6.20.1",
+ "tailwind-merge": "^2.2.0",
+ "tailwindcss-animate": "^1.0.7",
+ "zustand": "^4.4.7"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.43",
+ "@types/react-dom": "^18.2.17",
+ "@typescript-eslint/eslint-plugin": "^6.14.0",
+ "@typescript-eslint/parser": "^6.14.0",
+ "@vitejs/plugin-react": "^4.2.1",
+ "autoprefixer": "^10.4.16",
+ "eslint": "^8.55.0",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-refresh": "^0.4.5",
+ "husky": "^8.0.3",
+ "lint-staged": "^15.2.0",
+ "postcss": "^8.4.32",
+ "prettier": "^3.1.1",
+ "tailwindcss": "^3.4.0",
+ "typescript": "^5.2.2",
+ "vite": "^5.0.8"
+ },
+ "lint-staged": {
+ "*.{ts,tsx,js,jsx}": [
+ "eslint --fix",
+ "prettier --write"
+ ],
+ "*.{json,css,md}": [
+ "prettier --write"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/admin/src/App.tsx b/admin/src/App.tsx
new file mode 100644
index 0000000..6b36fb1
--- /dev/null
+++ b/admin/src/App.tsx
@@ -0,0 +1,34 @@
+import { Routes, Route, Navigate } from 'react-router-dom'
+import { useAuthStore } from './stores/authStore'
+import LoginPage from './pages/LoginPage'
+import DashboardLayout from './components/DashboardLayout'
+import Dashboard from './pages/Dashboard'
+import Photos from './pages/Photos'
+import Categories from './pages/Categories'
+import Tags from './pages/Tags'
+import Users from './pages/Users'
+import Settings from './pages/Settings'
+
+function App() {
+ const { isAuthenticated } = useAuthStore()
+
+ if (!isAuthenticated) {
+ return
+ }
+
+ return (
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ )
+}
+
+export default App
\ No newline at end of file
diff --git a/admin/src/components/DashboardLayout.tsx b/admin/src/components/DashboardLayout.tsx
new file mode 100644
index 0000000..1f327bd
--- /dev/null
+++ b/admin/src/components/DashboardLayout.tsx
@@ -0,0 +1,217 @@
+import React, { useState } from 'react'
+import { Link, useLocation, useNavigate } from 'react-router-dom'
+import { useQuery } from '@tanstack/react-query'
+import { Button } from '@/components/ui/button'
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
+import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
+import {
+ LayoutDashboard,
+ Camera,
+ FolderOpen,
+ Tags,
+ Users,
+ Settings,
+ LogOut,
+ Menu,
+ User,
+ Bell,
+ Search
+} from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { useAuthStore } from '@/stores/authStore'
+import { authService } from '@/services/authService'
+import { toast } from 'sonner'
+
+interface DashboardLayoutProps {
+ children: React.ReactNode
+}
+
+const navigation = [
+ { name: '仪表板', href: '/dashboard', icon: LayoutDashboard },
+ { name: '照片管理', href: '/photos', icon: Camera },
+ { name: '分类管理', href: '/categories', icon: FolderOpen },
+ { name: '标签管理', href: '/tags', icon: Tags },
+ { name: '用户管理', href: '/users', icon: Users },
+ { name: '系统设置', href: '/settings', icon: Settings },
+]
+
+export default function DashboardLayout({ children }: DashboardLayoutProps) {
+ const location = useLocation()
+ const navigate = useNavigate()
+ const { user, logout } = useAuthStore()
+ const [sidebarOpen, setSidebarOpen] = useState(false)
+
+ // 获取当前用户信息
+ const { data: currentUser } = useQuery({
+ queryKey: ['current-user'],
+ queryFn: authService.getCurrentUser,
+ initialData: user,
+ staleTime: 5 * 60 * 1000, // 5分钟
+ })
+
+ const handleLogout = async () => {
+ try {
+ await authService.logout()
+ logout()
+ toast.success('退出登录成功')
+ navigate('/login')
+ } catch (error) {
+ toast.error('退出登录失败')
+ }
+ }
+
+ const isCurrentPath = (path: string) => {
+ return location.pathname === path || location.pathname.startsWith(path + '/')
+ }
+
+ const SidebarContent = () => (
+
+ {/* Logo */}
+
+
+ {/* Navigation */}
+
+
+ {/* User info */}
+
+
+
+
+
+ {currentUser?.username?.charAt(0).toUpperCase()}
+
+
+
+
+ {currentUser?.username}
+
+
+ {currentUser?.role === 'admin' ? '管理员' :
+ currentUser?.role === 'editor' ? '编辑者' : '用户'}
+
+
+
+
+
+ )
+
+ return (
+
+ {/* Desktop sidebar */}
+
+
+
+
+ {/* Mobile sidebar */}
+
+
+
+
+
+
+ {/* Main content */}
+
+ {/* Top bar */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 我的账户
+
+ navigate('/profile')}>
+
+ 个人资料
+
+ navigate('/settings')}>
+
+ 设置
+
+
+
+
+ 退出登录
+
+
+
+
+
+
+ {/* Page content */}
+
+ {children}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/admin/src/components/ui/alert.tsx b/admin/src/components/ui/alert.tsx
new file mode 100644
index 0000000..5a7ba0f
--- /dev/null
+++ b/admin/src/components/ui/alert.tsx
@@ -0,0 +1,59 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+))
+Alert.displayName = "Alert"
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertTitle.displayName = "AlertTitle"
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertDescription.displayName = "AlertDescription"
+
+export { Alert, AlertTitle, AlertDescription }
\ No newline at end of file
diff --git a/admin/src/components/ui/avatar.tsx b/admin/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..c85badc
--- /dev/null
+++ b/admin/src/components/ui/avatar.tsx
@@ -0,0 +1,48 @@
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+const Avatar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+export { Avatar, AvatarImage, AvatarFallback }
\ No newline at end of file
diff --git a/admin/src/components/ui/checkbox.tsx b/admin/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..43ac6c4
--- /dev/null
+++ b/admin/src/components/ui/checkbox.tsx
@@ -0,0 +1,28 @@
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { Check } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Checkbox = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }
\ No newline at end of file
diff --git a/admin/src/components/ui/dropdown-menu.tsx b/admin/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..3a79f44
--- /dev/null
+++ b/admin/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,198 @@
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { Check, ChevronRight, Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const DropdownMenu = DropdownMenuPrimitive.Root
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+))
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+}
\ No newline at end of file
diff --git a/admin/src/components/ui/label.tsx b/admin/src/components/ui/label.tsx
new file mode 100644
index 0000000..a7cafcd
--- /dev/null
+++ b/admin/src/components/ui/label.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+)
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
\ No newline at end of file
diff --git a/admin/src/components/ui/select.tsx b/admin/src/components/ui/select.tsx
new file mode 100644
index 0000000..2b99b27
--- /dev/null
+++ b/admin/src/components/ui/select.tsx
@@ -0,0 +1,158 @@
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { Check, ChevronDown, ChevronUp } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Select = SelectPrimitive.Root
+
+const SelectGroup = SelectPrimitive.Group
+
+const SelectValue = SelectPrimitive.Value
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+
+ {children}
+
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+}
\ No newline at end of file
diff --git a/admin/src/components/ui/separator.tsx b/admin/src/components/ui/separator.tsx
new file mode 100644
index 0000000..ac15d7b
--- /dev/null
+++ b/admin/src/components/ui/separator.tsx
@@ -0,0 +1,29 @@
+import * as React from "react"
+import * as SeparatorPrimitive from "@radix-ui/react-separator"
+
+import { cn } from "@/lib/utils"
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, orientation = "horizontal", decorative = true, ...props },
+ ref
+ ) => (
+
+ )
+)
+Separator.displayName = SeparatorPrimitive.Root.displayName
+
+export { Separator }
\ No newline at end of file
diff --git a/admin/src/components/ui/sheet.tsx b/admin/src/components/ui/sheet.tsx
new file mode 100644
index 0000000..6796d80
--- /dev/null
+++ b/admin/src/components/ui/sheet.tsx
@@ -0,0 +1,138 @@
+import * as React from "react"
+import * as SheetPrimitive from "@radix-ui/react-dialog"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Sheet = SheetPrimitive.Root
+
+const SheetTrigger = SheetPrimitive.Trigger
+
+const SheetClose = SheetPrimitive.Close
+
+const SheetPortal = SheetPrimitive.Portal
+
+const SheetOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
+
+const sheetVariants = cva(
+ "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
+ {
+ variants: {
+ side: {
+ top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+ bottom:
+ "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+ left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+ right:
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
+ },
+ },
+ defaultVariants: {
+ side: "right",
+ },
+ }
+)
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef,
+ VariantProps {}
+
+const SheetContent = React.forwardRef<
+ React.ElementRef,
+ SheetContentProps
+>(({ side = "right", className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+))
+SheetContent.displayName = SheetPrimitive.Content.displayName
+
+const SheetHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+SheetHeader.displayName = "SheetHeader"
+
+const SheetFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+SheetFooter.displayName = "SheetFooter"
+
+const SheetTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetTitle.displayName = SheetPrimitive.Title.displayName
+
+const SheetDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetDescription.displayName = SheetPrimitive.Description.displayName
+
+export {
+ Sheet,
+ SheetPortal,
+ SheetOverlay,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
\ No newline at end of file
diff --git a/admin/src/components/ui/skeleton.tsx b/admin/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..bee96db
--- /dev/null
+++ b/admin/src/components/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ )
+}
+
+export { Skeleton }
\ No newline at end of file
diff --git a/admin/src/components/ui/switch.tsx b/admin/src/components/ui/switch.tsx
new file mode 100644
index 0000000..16c7b50
--- /dev/null
+++ b/admin/src/components/ui/switch.tsx
@@ -0,0 +1,27 @@
+import * as React from "react"
+import * as SwitchPrimitives from "@radix-ui/react-switch"
+
+import { cn } from "@/lib/utils"
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+Switch.displayName = SwitchPrimitives.Root.displayName
+
+export { Switch }
\ No newline at end of file
diff --git a/admin/src/components/ui/textarea.tsx b/admin/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..83caa9b
--- /dev/null
+++ b/admin/src/components/ui/textarea.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes {}
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Textarea.displayName = "Textarea"
+
+export { Textarea }
\ No newline at end of file
diff --git a/admin/src/lib/utils.ts b/admin/src/lib/utils.ts
new file mode 100644
index 0000000..91b07e0
--- /dev/null
+++ b/admin/src/lib/utils.ts
@@ -0,0 +1,86 @@
+import { type ClassValue, clsx } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
+
+export function formatFileSize(bytes: number): string {
+ if (bytes === 0) return '0 Bytes'
+
+ const k = 1024
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+}
+
+export function formatDate(date: string | Date): string {
+ const d = typeof date === 'string' ? new Date(date) : date
+ return d.toLocaleDateString('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ })
+}
+
+export function formatDateTime(date: string | Date): string {
+ const d = typeof date === 'string' ? new Date(date) : date
+ return d.toLocaleString('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+}
+
+export function generateSlug(text: string): string {
+ return text
+ .toLowerCase()
+ .trim()
+ .replace(/[^\w\s-]/g, '') // 移除特殊字符
+ .replace(/[\s_-]+/g, '-') // 替换空格和下划线为连字符
+ .replace(/^-+|-+$/g, '') // 移除开头和结尾的连字符
+}
+
+export function truncate(text: string, length: number): string {
+ if (text.length <= length) return text
+ return text.substring(0, length) + '...'
+}
+
+export function debounce any>(
+ func: T,
+ wait: number
+): (...args: Parameters) => void {
+ let timeout: NodeJS.Timeout | null = null
+
+ return (...args: Parameters) => {
+ if (timeout) clearTimeout(timeout)
+ timeout = setTimeout(() => func(...args), wait)
+ }
+}
+
+export function isImageFile(file: File): boolean {
+ const imageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/bmp']
+ return imageTypes.includes(file.type)
+}
+
+export function getImageDimensions(file: File): Promise<{ width: number; height: number }> {
+ return new Promise((resolve, reject) => {
+ const img = new Image()
+ const url = URL.createObjectURL(file)
+
+ img.onload = () => {
+ URL.revokeObjectURL(url)
+ resolve({ width: img.naturalWidth, height: img.naturalHeight })
+ }
+
+ img.onerror = () => {
+ URL.revokeObjectURL(url)
+ reject(new Error('Failed to load image'))
+ }
+
+ img.src = url
+ })
+}
\ No newline at end of file
diff --git a/admin/src/main.tsx b/admin/src/main.tsx
new file mode 100644
index 0000000..278c105
--- /dev/null
+++ b/admin/src/main.tsx
@@ -0,0 +1,27 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
+import App from './App'
+import './index.css'
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ refetchOnWindowFocus: false,
+ },
+ },
+})
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+
+ ,
+)
\ No newline at end of file
diff --git a/admin/src/pages/Categories.tsx b/admin/src/pages/Categories.tsx
new file mode 100644
index 0000000..e2b5d37
--- /dev/null
+++ b/admin/src/pages/Categories.tsx
@@ -0,0 +1,252 @@
+import React, { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
+import { Skeleton } from '@/components/ui/skeleton'
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
+import {
+ FolderOpen,
+ FolderPlus,
+ MoreVerticalIcon,
+ EditIcon,
+ TrashIcon,
+ PlusIcon,
+ SearchIcon,
+ TreePineIcon
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { categoryService } from '@/services/categoryService'
+
+export default function Categories() {
+ const queryClient = useQueryClient()
+ const [search, setSearch] = useState('')
+
+ // 获取分类树
+ const { data: categories, isLoading: categoriesLoading } = useQuery({
+ queryKey: ['categories-tree'],
+ queryFn: categoryService.getCategoryTree
+ })
+
+ // 获取分类统计
+ const { data: stats, isLoading: statsLoading } = useQuery({
+ queryKey: ['category-stats'],
+ queryFn: categoryService.getStats
+ })
+
+ // 删除分类
+ const deleteCategoryMutation = useMutation({
+ mutationFn: categoryService.deleteCategory,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['categories-tree'] })
+ queryClient.invalidateQueries({ queryKey: ['category-stats'] })
+ toast.success('分类删除成功')
+ },
+ onError: (error: any) => {
+ toast.error(error?.message || '删除失败')
+ }
+ })
+
+ const handleDeleteCategory = (categoryId: number) => {
+ if (confirm('确定要删除这个分类吗?')) {
+ deleteCategoryMutation.mutate(categoryId)
+ }
+ }
+
+ const renderCategoryTree = (categories: any[], level = 0) => {
+ return categories?.map((category) => (
+
+
0 ? 'ml-6 border-l-4 border-l-primary/20' : ''}`}>
+
+
+
+
+ {category.name}
+
+ {category.isActive ? '启用' : '禁用'}
+
+
+ {category.photoCount} 张照片
+
+
+ {category.description && (
+
{category.description}
+ )}
+
+
+
+
+
+
+
+
+
+
+ 编辑
+
+
+
+ 添加子分类
+
+ handleDeleteCategory(category.id)}
+ disabled={category.photoCount > 0 || category.children?.length > 0}
+ >
+
+ 删除
+
+
+
+
+
+ {category.children && category.children.length > 0 && (
+
+ {renderCategoryTree(category.children, level + 1)}
+
+ )}
+
+ ))
+ }
+
+ return (
+
+ {/* 页面头部 */}
+
+
+
分类管理
+
+ 管理照片分类和相册结构
+
+
+
+
+
+ {/* 统计卡片 */}
+
+
+
+ 总分类数
+
+
+
+ {statsLoading ? (
+
+ ) : (
+ {stats?.total || 0}
+ )}
+
+
+
+
+
+ 活跃分类
+
+
+
+ {statsLoading ? (
+
+ ) : (
+ {stats?.active || 0}
+ )}
+
+
+
+
+
+ 顶级分类
+
+
+
+ {statsLoading ? (
+
+ ) : (
+ {stats?.topLevel || 0}
+ )}
+
+
+
+
+
+ 平均照片数
+
+
+
+ {statsLoading ? (
+
+ ) : (
+
+ {stats?.total ? Math.round(Object.values(stats.photoCounts || {}).reduce((a, b) => a + b, 0) / stats.total) : 0}
+
+ )}
+
+
+
+
+ {/* 搜索栏 */}
+
+
+
+
+
+ setSearch(e.target.value)}
+ className="pl-10"
+ />
+
+
+
+
+
+
+ {/* 分类列表 */}
+
+
+ 分类结构
+
+
+ {categoriesLoading ? (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ ) : categories?.length ? (
+
+ {renderCategoryTree(categories)}
+
+ ) : (
+
+
+
暂无分类
+
+ 创建您的第一个分类来组织照片
+
+
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/admin/src/pages/Dashboard.tsx b/admin/src/pages/Dashboard.tsx
new file mode 100644
index 0000000..b41c088
--- /dev/null
+++ b/admin/src/pages/Dashboard.tsx
@@ -0,0 +1,272 @@
+import React from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Skeleton } from '@/components/ui/skeleton'
+import { PhotoIcon, FolderIcon, TagIcon, PlusIcon, TrendingUpIcon } from 'lucide-react'
+import { useNavigate } from 'react-router-dom'
+import { photoService } from '@/services/photoService'
+import { categoryService } from '@/services/categoryService'
+import { tagService } from '@/services/tagService'
+
+export default function Dashboard() {
+ const navigate = useNavigate()
+
+ // 获取统计数据
+ const { data: photoStats, isLoading: photoStatsLoading } = useQuery({
+ queryKey: ['photo-stats'],
+ queryFn: photoService.getStats
+ })
+
+ const { data: categoryStats, isLoading: categoryStatsLoading } = useQuery({
+ queryKey: ['category-stats'],
+ queryFn: categoryService.getStats
+ })
+
+ const { data: tagStats, isLoading: tagStatsLoading } = useQuery({
+ queryKey: ['tag-stats'],
+ queryFn: tagService.getStats
+ })
+
+ // 获取最近照片
+ const { data: recentPhotos, isLoading: recentPhotosLoading } = useQuery({
+ queryKey: ['recent-photos'],
+ queryFn: () => photoService.getPhotos({ page: 1, limit: 5, sort_by: 'created_at', sort_order: 'desc' })
+ })
+
+ // 获取热门标签
+ const { data: popularTags, isLoading: popularTagsLoading } = useQuery({
+ queryKey: ['popular-tags'],
+ queryFn: () => tagService.getPopularTags(10)
+ })
+
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return '0 Bytes'
+ const k = 1024
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+ }
+
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case 'published': return 'bg-green-100 text-green-800'
+ case 'draft': return 'bg-yellow-100 text-yellow-800'
+ case 'archived': return 'bg-gray-100 text-gray-800'
+ case 'processing': return 'bg-blue-100 text-blue-800'
+ default: return 'bg-gray-100 text-gray-800'
+ }
+ }
+
+ const getStatusText = (status: string) => {
+ switch (status) {
+ case 'published': return '已发布'
+ case 'draft': return '草稿'
+ case 'archived': return '已归档'
+ case 'processing': return '处理中'
+ default: return status
+ }
+ }
+
+ return (
+
+
+
仪表板
+
+
+
+ {/* 统计卡片 */}
+
+
+
+ 总照片数
+
+
+
+ {photoStatsLoading ? (
+
+ ) : (
+ {photoStats?.total || 0}
+ )}
+
+ 存储空间: {formatFileSize(photoStats?.totalSize || 0)}
+
+
+
+
+
+
+ 本月新增
+
+
+
+ {photoStatsLoading ? (
+
+ ) : (
+ {photoStats?.thisMonth || 0}
+ )}
+
+ 今日: {photoStats?.today || 0}
+
+
+
+
+
+
+ 总分类数
+
+
+
+ {categoryStatsLoading ? (
+
+ ) : (
+ {categoryStats?.total || 0}
+ )}
+
+ 活跃: {categoryStats?.active || 0}
+
+
+
+
+
+
+ 总标签数
+
+
+
+ {tagStatsLoading ? (
+
+ ) : (
+ {tagStats?.total || 0}
+ )}
+
+ 已使用: {tagStats?.used || 0}
+
+
+
+
+
+ {/* 照片状态统计 */}
+ {photoStats?.statusStats && (
+
+
+ 照片状态分布
+
+
+
+ {Object.entries(photoStats.statusStats).map(([status, count]) => (
+
+
+ {getStatusText(status)}
+
+ {count}
+
+ ))}
+
+
+
+ )}
+
+
+ {/* 最近上传 */}
+
+
+ 最近上传
+
+
+
+ {recentPhotosLoading ? (
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+ ) : recentPhotos?.photos?.length ? (
+
+ {recentPhotos.photos.map((photo) => (
+
+
+
+
{photo.title}
+
+ {new Date(photo.createdAt).toLocaleDateString()}
+
+
+
+ {getStatusText(photo.status)}
+
+
+ ))}
+
+ ) : (
+
+
+
暂无照片
+
+
+ )}
+
+
+
+ {/* 热门标签 */}
+
+
+ 热门标签
+
+
+
+ {popularTagsLoading ? (
+
+ {[...Array(6)].map((_, i) => (
+
+ ))}
+
+ ) : popularTags?.length ? (
+
+ {popularTags.map((tag) => (
+
+ {tag.name} ({tag.photoCount})
+
+ ))}
+
+ ) : (
+
+
+
暂无标签
+
+
+ )}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/admin/src/pages/LoginPage.tsx b/admin/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..83168e3
--- /dev/null
+++ b/admin/src/pages/LoginPage.tsx
@@ -0,0 +1,162 @@
+import React, { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useMutation } from '@tanstack/react-query'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Loader2, Eye, EyeOff, Camera } from 'lucide-react'
+import { useAuthStore } from '@/stores/authStore'
+import { authService } from '@/services/authService'
+import { toast } from 'sonner'
+
+export default function LoginPage() {
+ const navigate = useNavigate()
+ const { login } = useAuthStore()
+
+ const [formData, setFormData] = useState({
+ username: '',
+ password: ''
+ })
+ const [showPassword, setShowPassword] = useState(false)
+ const [error, setError] = useState('')
+
+ const loginMutation = useMutation({
+ mutationFn: authService.login,
+ onSuccess: (data) => {
+ login(data.access_token, data.user)
+ toast.success('登录成功')
+ navigate('/')
+ },
+ onError: (error: any) => {
+ const message = error?.response?.data?.message || error.message || '登录失败'
+ setError(message)
+ toast.error(message)
+ }
+ })
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+ setError('')
+
+ if (!formData.username || !formData.password) {
+ setError('请输入用户名和密码')
+ return
+ }
+
+ loginMutation.mutate(formData)
+ }
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target
+ setFormData(prev => ({ ...prev, [name]: value }))
+ if (error) setError('')
+ }
+
+ return (
+
+
+ {/* Logo和标题 */}
+
+
+
+
+ 登录
+
+ 使用您的账户登录管理后台
+
+
+
+
+
+
+
默认管理员账户
+
+ 用户名: admin |
+ 密码: admin123
+
+
+
+
+
+
+
© 2024 摄影作品集. 保留所有权利.
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/admin/src/pages/Photos.tsx b/admin/src/pages/Photos.tsx
new file mode 100644
index 0000000..279f3e6
--- /dev/null
+++ b/admin/src/pages/Photos.tsx
@@ -0,0 +1,518 @@
+import React, { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
+import {
+ PhotoIcon,
+ PlusIcon,
+ SearchIcon,
+ FilterIcon,
+ MoreVerticalIcon,
+ EditIcon,
+ TrashIcon,
+ EyeIcon,
+ DownloadIcon,
+ GridIcon,
+ ListIcon
+} from 'lucide-react'
+import { useNavigate } from 'react-router-dom'
+import { toast } from 'sonner'
+import { photoService } from '@/services/photoService'
+import { categoryService } from '@/services/categoryService'
+import { tagService } from '@/services/tagService'
+
+type ViewMode = 'grid' | 'list'
+
+interface PhotoFilters {
+ search: string
+ status: string
+ categoryId: string
+ tagId: string
+ dateRange: string
+}
+
+export default function Photos() {
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+
+ // 状态管理
+ const [viewMode, setViewMode] = useState('grid')
+ const [selectedPhotos, setSelectedPhotos] = useState([])
+ const [page, setPage] = useState(1)
+ const [filters, setFilters] = useState({
+ search: '',
+ status: '',
+ categoryId: '',
+ tagId: '',
+ dateRange: ''
+ })
+
+ // 获取照片列表
+ const { data: photosData, isLoading: photosLoading } = useQuery({
+ queryKey: ['photos', { page, ...filters }],
+ queryFn: () => photoService.getPhotos({
+ page,
+ limit: 20,
+ search: filters.search || undefined,
+ status: filters.status || undefined,
+ category_id: filters.categoryId ? parseInt(filters.categoryId) : undefined,
+ sort_by: 'created_at',
+ sort_order: 'desc'
+ })
+ })
+
+ // 获取分类列表
+ const { data: categories } = useQuery({
+ queryKey: ['categories-all'],
+ queryFn: () => categoryService.getCategories()
+ })
+
+ // 获取标签列表
+ const { data: tags } = useQuery({
+ queryKey: ['tags-all'],
+ queryFn: () => tagService.getAllTags()
+ })
+
+ // 删除照片
+ const deletePhotoMutation = useMutation({
+ mutationFn: photoService.deletePhoto,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['photos'] })
+ toast.success('照片删除成功')
+ },
+ onError: (error: any) => {
+ toast.error(error?.message || '删除失败')
+ }
+ })
+
+ // 批量删除照片
+ const batchDeleteMutation = useMutation({
+ mutationFn: photoService.batchDelete,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['photos'] })
+ setSelectedPhotos([])
+ toast.success('批量删除成功')
+ },
+ onError: (error: any) => {
+ toast.error(error?.message || '批量删除失败')
+ }
+ })
+
+ // 批量更新状态
+ const batchUpdateMutation = useMutation({
+ mutationFn: ({ ids, status }: { ids: number[], status: string }) =>
+ photoService.batchUpdate(ids, { status }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['photos'] })
+ setSelectedPhotos([])
+ toast.success('状态更新成功')
+ },
+ onError: (error: any) => {
+ toast.error(error?.message || '状态更新失败')
+ }
+ })
+
+ const handleSelectAll = (checked: boolean) => {
+ if (checked) {
+ setSelectedPhotos(photosData?.photos.map(photo => photo.id) || [])
+ } else {
+ setSelectedPhotos([])
+ }
+ }
+
+ const handleSelectPhoto = (photoId: number, checked: boolean) => {
+ if (checked) {
+ setSelectedPhotos([...selectedPhotos, photoId])
+ } else {
+ setSelectedPhotos(selectedPhotos.filter(id => id !== photoId))
+ }
+ }
+
+ const handleBatchAction = (action: string, value?: string) => {
+ if (selectedPhotos.length === 0) {
+ toast.error('请先选择照片')
+ return
+ }
+
+ if (action === 'delete') {
+ if (confirm('确定要删除选中的照片吗?')) {
+ batchDeleteMutation.mutate(selectedPhotos)
+ }
+ } else if (action === 'status' && value) {
+ batchUpdateMutation.mutate({ ids: selectedPhotos, status: value })
+ }
+ }
+
+ const handleDeletePhoto = (photoId: number) => {
+ if (confirm('确定要删除这张照片吗?')) {
+ deletePhotoMutation.mutate(photoId)
+ }
+ }
+
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case 'published': return 'bg-green-100 text-green-800'
+ case 'draft': return 'bg-yellow-100 text-yellow-800'
+ case 'archived': return 'bg-gray-100 text-gray-800'
+ case 'processing': return 'bg-blue-100 text-blue-800'
+ default: return 'bg-gray-100 text-gray-800'
+ }
+ }
+
+ const getStatusText = (status: string) => {
+ switch (status) {
+ case 'published': return '已发布'
+ case 'draft': return '草稿'
+ case 'archived': return '已归档'
+ case 'processing': return '处理中'
+ default: return status
+ }
+ }
+
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return '0 Bytes'
+ const k = 1024
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+ }
+
+ return (
+
+ {/* 页面头部 */}
+
+
+
照片管理
+
+ 共 {photosData?.total || 0} 张照片
+
+
+
+
+
+ {/* 工具栏 */}
+
+
+
+ {/* 搜索 */}
+
+
+ setFilters({ ...filters, search: e.target.value })}
+ className="pl-10"
+ />
+
+
+ {/* 过滤器 */}
+
+
+
+
+
+
+ {/* 视图模式 */}
+
+
+
+
+
+
+
+
+ {/* 批量操作栏 */}
+ {selectedPhotos.length > 0 && (
+
+
+
+
+
+ 已选择 {selectedPhotos.length} 张照片
+
+
+
+
+
+
+
+
+
+ handleBatchAction('status', 'published')}>
+ 设为已发布
+
+ handleBatchAction('status', 'draft')}>
+ 设为草稿
+
+ handleBatchAction('status', 'archived')}>
+ 设为已归档
+
+
+
+
+
+
+
+
+ )}
+
+ {/* 照片列表 */}
+ {photosLoading ? (
+
+ {[...Array(8)].map((_, i) => (
+
+
+
+
+
+
+
+ ))}
+
+ ) : photosData?.photos?.length ? (
+ <>
+ {/* 全选复选框 */}
+
+ 0}
+ onCheckedChange={handleSelectAll}
+ />
+ 全选
+
+
+ {viewMode === 'grid' ? (
+
+ {photosData.photos.map((photo) => (
+
+
+
+
+
+ {/* 复选框 */}
+
+ handleSelectPhoto(photo.id, checked as boolean)}
+ className="bg-white"
+ />
+
+
+ {/* 操作按钮 */}
+
+
+
+
+
+
+ navigate(`/photos/${photo.id}`)}>
+
+ 查看详情
+
+ navigate(`/photos/${photo.id}/edit`)}>
+
+ 编辑
+
+ handleDeletePhoto(photo.id)}>
+
+ 删除
+
+
+
+
+
+
+
+
{photo.title}
+
+
+ {getStatusText(photo.status)}
+
+
+ {formatFileSize(photo.fileSize)}
+
+
+
+ {new Date(photo.createdAt).toLocaleDateString()}
+
+
+
+
+ ))}
+
+ ) : (
+
+ {photosData.photos.map((photo) => (
+
+
+
+
handleSelectPhoto(photo.id, checked as boolean)}
+ />
+
+
+
+
+
{photo.title}
+
+ {photo.description}
+
+
+
+ {getStatusText(photo.status)}
+
+
+ {formatFileSize(photo.fileSize)}
+
+
+ {new Date(photo.createdAt).toLocaleDateString()}
+
+
+
+
+
+
+
+
+
+ navigate(`/photos/${photo.id}`)}>
+
+ 查看详情
+
+ navigate(`/photos/${photo.id}/edit`)}>
+
+ 编辑
+
+ handleDeletePhoto(photo.id)}>
+
+ 删除
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* 分页 */}
+ {photosData.pages > 1 && (
+
+
+
+
+ {[...Array(Math.min(5, photosData.pages))].map((_, i) => {
+ const pageNum = i + 1
+ return (
+
+ )
+ })}
+
+
+
+
+ )}
+ >
+ ) : (
+
+
+
+
+
暂无照片
+
+ 开始上传您的第一张照片吧
+
+
+
+
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/admin/src/pages/Settings.tsx b/admin/src/pages/Settings.tsx
new file mode 100644
index 0000000..9e482b1
--- /dev/null
+++ b/admin/src/pages/Settings.tsx
@@ -0,0 +1,640 @@
+import React, { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Switch } from '@/components/ui/switch'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+import {
+ Settings as SettingsIcon,
+ Globe,
+ Database,
+ Shield,
+ Bell,
+ Palette,
+ Upload,
+ Server,
+ RefreshCw,
+ Save,
+ Download,
+ Trash2,
+ AlertTriangle,
+ CheckCircle,
+ XCircle,
+ HardDrive,
+ Cpu,
+ MemoryStick,
+ WifiIcon
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { settingsService } from '@/services/settingsService'
+
+interface SystemSettings {
+ site: {
+ title: string
+ description: string
+ keywords: string
+ favicon?: string
+ logo?: string
+ copyright: string
+ contactEmail: string
+ }
+ upload: {
+ maxFileSize: number
+ allowedTypes: string[]
+ compressionQuality: number
+ watermarkEnabled: boolean
+ watermarkText: string
+ watermarkPosition: string
+ }
+ security: {
+ jwtSecret: string
+ jwtExpiration: number
+ enableTwoFactor: boolean
+ enableAuditLog: boolean
+ maxLoginAttempts: number
+ lockoutDuration: number
+ }
+ performance: {
+ cacheEnabled: boolean
+ cacheExpiration: number
+ imageCacheSize: number
+ enableCompression: boolean
+ maxConcurrentUploads: number
+ }
+ notifications: {
+ emailEnabled: boolean
+ emailHost: string
+ emailPort: number
+ emailUser: string
+ emailPassword: string
+ enableUserNotifications: boolean
+ enableSystemAlerts: boolean
+ }
+ backup: {
+ autoBackupEnabled: boolean
+ backupInterval: number
+ maxBackupFiles: number
+ backupLocation: string
+ }
+}
+
+interface SystemStatus {
+ server: {
+ status: 'online' | 'offline'
+ uptime: string
+ version: string
+ lastRestart: string
+ }
+ database: {
+ status: 'connected' | 'disconnected' | 'error'
+ version: string
+ size: string
+ lastBackup: string
+ }
+ storage: {
+ total: string
+ used: string
+ free: string
+ usage: number
+ }
+ performance: {
+ cpu: number
+ memory: number
+ activeConnections: number
+ responseTime: number
+ }
+}
+
+export default function Settings() {
+ const queryClient = useQueryClient()
+ const [activeTab, setActiveTab] = useState<'general' | 'upload' | 'security' | 'performance' | 'notifications' | 'backup' | 'system'>('general')
+ const [isDirty, setIsDirty] = useState(false)
+
+ // 获取系统设置
+ const { data: settings, isLoading: settingsLoading } = useQuery({
+ queryKey: ['settings'],
+ queryFn: settingsService.getSettings
+ })
+
+ // 获取系统状态
+ const { data: status, isLoading: statusLoading } = useQuery({
+ queryKey: ['system-status'],
+ queryFn: settingsService.getSystemStatus,
+ refetchInterval: 30000 // 30秒刷新一次
+ })
+
+ // 更新设置
+ const updateSettingsMutation = useMutation({
+ mutationFn: settingsService.updateSettings,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['settings'] })
+ setIsDirty(false)
+ toast.success('设置保存成功')
+ },
+ onError: (error: any) => {
+ toast.error(error?.message || '保存失败')
+ }
+ })
+
+ // 系统操作
+ const systemOperationMutation = useMutation({
+ mutationFn: settingsService.performSystemOperation,
+ onSuccess: (data, variables) => {
+ queryClient.invalidateQueries({ queryKey: ['system-status'] })
+ toast.success(`${variables.operation} 操作成功`)
+ },
+ onError: (error: any) => {
+ toast.error(error?.message || '操作失败')
+ }
+ })
+
+ const handleSaveSettings = () => {
+ if (settings) {
+ updateSettingsMutation.mutate(settings)
+ }
+ }
+
+ const handleSystemOperation = (operation: string) => {
+ if (confirm(`确定要执行 ${operation} 操作吗?`)) {
+ systemOperationMutation.mutate({ operation })
+ }
+ }
+
+ const updateSetting = (section: string, key: string, value: any) => {
+ // 这里应该更新本地状态
+ setIsDirty(true)
+ }
+
+ const tabs = [
+ { id: 'general', label: '网站设置', icon: Globe },
+ { id: 'upload', label: '上传设置', icon: Upload },
+ { id: 'security', label: '安全设置', icon: Shield },
+ { id: 'performance', label: '性能设置', icon: Cpu },
+ { id: 'notifications', label: '通知设置', icon: Bell },
+ { id: 'backup', label: '备份设置', icon: Database },
+ { id: 'system', label: '系统状态', icon: Server }
+ ]
+
+ const renderGeneralSettings = () => (
+
+ )
+
+ const renderUploadSettings = () => (
+
+
+
+ 上传限制
+
+
+
+
+
+
+
+ {['jpg', 'jpeg', 'png', 'gif', 'webp', 'tiff', 'raw'].map(type => (
+
+ {type.toUpperCase()}
+
+ ))}
+
+
+
+
+
+
+
+ 水印设置
+
+
+
+ updateSetting('upload', 'watermarkEnabled', checked)}
+ />
+
+
+
+ {settings?.upload.watermarkEnabled && (
+
+
+
+ updateSetting('upload', 'watermarkText', e.target.value)}
+ placeholder="© 摄影师名字"
+ />
+
+
+
+
+
+
+ )}
+
+
+
+ )
+
+ const renderSecuritySettings = () => (
+
+
+
+ 认证设置
+
+
+
+
+
+
+ updateSetting('security', 'enableTwoFactor', checked)}
+ />
+
+
+
+
+ updateSetting('security', 'enableAuditLog', checked)}
+ />
+
+
+
+
+
+
+ )
+
+ const renderSystemStatus = () => (
+
+
+
+
+
+
+ 服务器状态
+
+
+
+
+
+ 状态
+
+ {status?.server.status === 'online' ? '在线' : '离线'}
+
+
+
+ 运行时间
+ {status?.server.uptime}
+
+
+ 版本
+ {status?.server.version}
+
+
+
+
+
+
+
+
+
+ 数据库状态
+
+
+
+
+
+ 连接状态
+
+ {status?.database.status === 'connected' ? '已连接' : '断开连接'}
+
+
+
+ 数据库大小
+ {status?.database.size}
+
+
+ 最后备份
+ {status?.database.lastBackup}
+
+
+
+
+
+
+
+
+
+ 存储使用
+
+
+
+
+
+ 总空间
+ {status?.storage.total}
+
+
+ 已使用
+ {status?.storage.used}
+
+
+ 可用空间
+ {status?.storage.free}
+
+
+
+ 使用率
+ {status?.storage.usage}%
+
+
+
+
+
+
+
+
+
+
+
+ 性能监控
+
+
+
+
+
+ CPU 使用率
+ {status?.performance.cpu}%
+
+
+ 内存使用率
+ {status?.performance.memory}%
+
+
+ 活跃连接
+ {status?.performance.activeConnections}
+
+
+ 响应时间
+ {status?.performance.responseTime}ms
+
+
+
+
+
+
+
+
+ 系统操作
+
+
+
+
+
+
+
+
+
+
+
+ )
+
+ const renderTabContent = () => {
+ switch (activeTab) {
+ case 'general':
+ return renderGeneralSettings()
+ case 'upload':
+ return renderUploadSettings()
+ case 'security':
+ return renderSecuritySettings()
+ case 'system':
+ return renderSystemStatus()
+ default:
+ return (
+
+
+
+ {activeTab} 设置页面开发中...
+
+
+
+ )
+ }
+ }
+
+ return (
+
+ {/* 页面头部 */}
+
+
+
系统设置
+
+ 管理系统配置和运行状态
+
+
+ {isDirty && (
+
+ )}
+
+
+
+ {/* 左侧导航 */}
+
+
+
+ 设置分类
+
+
+
+
+
+
+
+ {/* 右侧内容 */}
+
+ {renderTabContent()}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/admin/src/pages/Tags.tsx b/admin/src/pages/Tags.tsx
new file mode 100644
index 0000000..db678f9
--- /dev/null
+++ b/admin/src/pages/Tags.tsx
@@ -0,0 +1,411 @@
+import React, { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
+import { Skeleton } from '@/components/ui/skeleton'
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
+import {
+ Tag,
+ TagIcon,
+ MoreVerticalIcon,
+ EditIcon,
+ TrashIcon,
+ PlusIcon,
+ SearchIcon,
+ FilterIcon,
+ SortAscIcon,
+ SortDescIcon,
+ TrendingUpIcon,
+ HashIcon
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { tagService } from '@/services/tagService'
+
+interface TagData {
+ id: number
+ name: string
+ color: string
+ description?: string
+ photoCount: number
+ isActive: boolean
+ createdAt: string
+ updatedAt: string
+}
+
+interface TagStats {
+ total: number
+ active: number
+ popular: number
+ avgPhotosPerTag: number
+ topTags: Array<{
+ name: string
+ photoCount: number
+ color: string
+ }>
+}
+
+export default function Tags() {
+ const queryClient = useQueryClient()
+ const [search, setSearch] = useState('')
+ const [sortBy, setSortBy] = useState<'name' | 'photoCount' | 'createdAt'>('name')
+ const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
+ const [filterActive, setFilterActive] = useState(null)
+
+ // 获取标签列表
+ const { data: tags, isLoading: tagsLoading } = useQuery({
+ queryKey: ['tags', { search, sortBy, sortOrder, filterActive }],
+ queryFn: () => tagService.getTags({
+ search,
+ sortBy,
+ sortOrder,
+ isActive: filterActive
+ })
+ })
+
+ // 获取标签统计
+ const { data: stats, isLoading: statsLoading } = useQuery({
+ queryKey: ['tag-stats'],
+ queryFn: tagService.getStats
+ })
+
+ // 删除标签
+ const deleteTagMutation = useMutation({
+ mutationFn: tagService.deleteTag,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['tags'] })
+ queryClient.invalidateQueries({ queryKey: ['tag-stats'] })
+ toast.success('标签删除成功')
+ },
+ onError: (error: any) => {
+ toast.error(error?.message || '删除失败')
+ }
+ })
+
+ // 切换标签状态
+ const toggleTagStatusMutation = useMutation({
+ mutationFn: ({ id, isActive }: { id: number; isActive: boolean }) =>
+ tagService.updateTag(id, { isActive }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['tags'] })
+ queryClient.invalidateQueries({ queryKey: ['tag-stats'] })
+ toast.success('标签状态更新成功')
+ },
+ onError: (error: any) => {
+ toast.error(error?.message || '更新失败')
+ }
+ })
+
+ const handleDeleteTag = (tagId: number, photoCount: number) => {
+ if (photoCount > 0) {
+ toast.error('无法删除包含照片的标签')
+ return
+ }
+
+ if (confirm('确定要删除这个标签吗?')) {
+ deleteTagMutation.mutate(tagId)
+ }
+ }
+
+ const handleToggleStatus = (tagId: number, currentStatus: boolean) => {
+ toggleTagStatusMutation.mutate({ id: tagId, isActive: !currentStatus })
+ }
+
+ const toggleSort = (field: 'name' | 'photoCount' | 'createdAt') => {
+ if (sortBy === field) {
+ setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
+ } else {
+ setSortBy(field)
+ setSortOrder('asc')
+ }
+ }
+
+ const filteredTags = tags?.filter(tag => {
+ const matchesSearch = tag.name.toLowerCase().includes(search.toLowerCase())
+ const matchesFilter = filterActive === null || tag.isActive === filterActive
+ return matchesSearch && matchesFilter
+ })
+
+ return (
+
+ {/* 页面头部 */}
+
+
+
标签管理
+
+ 管理照片标签和分类标记
+
+
+
+
+
+ {/* 统计卡片 */}
+
+
+
+ 总标签数
+
+
+
+ {statsLoading ? (
+
+ ) : (
+ {stats?.total || 0}
+ )}
+
+
+
+
+
+ 活跃标签
+
+
+
+ {statsLoading ? (
+
+ ) : (
+ {stats?.active || 0}
+ )}
+
+
+
+
+
+ 热门标签
+
+
+
+ {statsLoading ? (
+
+ ) : (
+ {stats?.popular || 0}
+ )}
+
+
+
+
+
+ 平均照片数
+
+
+
+ {statsLoading ? (
+
+ ) : (
+
+ {Math.round(stats?.avgPhotosPerTag || 0)}
+
+ )}
+
+
+
+
+ {/* 热门标签 */}
+ {stats?.topTags && stats.topTags.length > 0 && (
+
+
+
+
+ 热门标签
+
+
+
+
+ {stats.topTags.map((tag, index) => (
+
+
+ {tag.name}
+ ({tag.photoCount})
+
+ ))}
+
+
+
+ )}
+
+ {/* 搜索和过滤 */}
+
+
+
+
+
+ setSearch(e.target.value)}
+ className="pl-10"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {/* 标签列表 */}
+
+
+
+
标签列表
+
+
+
+
+
+
+
+
+ {tagsLoading ? (
+
+ {[...Array(9)].map((_, i) => (
+
+ ))}
+
+ ) : filteredTags?.length ? (
+
+ {filteredTags.map((tag) => (
+
+
+
+
+
+ {tag.name}
+
+ {tag.isActive ? '启用' : '禁用'}
+
+
+
+
+ {tag.photoCount} 张照片
+
+
+ {new Date(tag.createdAt).toLocaleDateString()}
+
+
+ {tag.description && (
+
{tag.description}
+ )}
+
+
+
+
+
+
+
+
+
+
+ 编辑
+
+ handleToggleStatus(tag.id, tag.isActive)}
+ >
+ {tag.isActive ? '禁用' : '启用'}
+
+ handleDeleteTag(tag.id, tag.photoCount)}
+ disabled={tag.photoCount > 0}
+ className="text-red-600"
+ >
+
+ 删除
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+ {search ? '没有找到匹配的标签' : '暂无标签'}
+
+
+ {search ? '尝试调整搜索条件' : '创建您的第一个标签来标记照片'}
+
+
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/admin/src/pages/Users.tsx b/admin/src/pages/Users.tsx
new file mode 100644
index 0000000..6fb093b
--- /dev/null
+++ b/admin/src/pages/Users.tsx
@@ -0,0 +1,499 @@
+import React, { useState } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
+import {
+ Users as UsersIcon,
+ User,
+ MoreVerticalIcon,
+ EditIcon,
+ TrashIcon,
+ PlusIcon,
+ SearchIcon,
+ FilterIcon,
+ ShieldIcon,
+ UserCheckIcon,
+ UserXIcon,
+ MailIcon,
+ CalendarIcon,
+ CrownIcon
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { userService } from '@/services/userService'
+
+interface UserData {
+ id: number
+ username: string
+ email: string
+ role: 'admin' | 'editor' | 'viewer'
+ isActive: boolean
+ lastLoginAt?: string
+ createdAt: string
+ updatedAt: string
+ profile?: {
+ firstName?: string
+ lastName?: string
+ avatar?: string
+ }
+}
+
+interface UserStats {
+ total: number
+ active: number
+ admins: number
+ editors: number
+ viewers: number
+ recentLogins: number
+}
+
+export default function Users() {
+ const queryClient = useQueryClient()
+ const [search, setSearch] = useState('')
+ const [roleFilter, setRoleFilter] = useState(null)
+ const [statusFilter, setStatusFilter] = useState(null)
+
+ // 获取用户列表
+ const { data: users, isLoading: usersLoading } = useQuery({
+ queryKey: ['users', { search, roleFilter, statusFilter }],
+ queryFn: () => userService.getUsers({
+ search,
+ role: roleFilter,
+ isActive: statusFilter
+ })
+ })
+
+ // 获取用户统计
+ const { data: stats, isLoading: statsLoading } = useQuery({
+ queryKey: ['user-stats'],
+ queryFn: userService.getStats
+ })
+
+ // 删除用户
+ const deleteUserMutation = useMutation({
+ mutationFn: userService.deleteUser,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['users'] })
+ queryClient.invalidateQueries({ queryKey: ['user-stats'] })
+ toast.success('用户删除成功')
+ },
+ onError: (error: any) => {
+ toast.error(error?.message || '删除失败')
+ }
+ })
+
+ // 切换用户状态
+ const toggleUserStatusMutation = useMutation({
+ mutationFn: ({ id, isActive }: { id: number; isActive: boolean }) =>
+ userService.updateUser(id, { isActive }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['users'] })
+ queryClient.invalidateQueries({ queryKey: ['user-stats'] })
+ toast.success('用户状态更新成功')
+ },
+ onError: (error: any) => {
+ toast.error(error?.message || '更新失败')
+ }
+ })
+
+ // 更新用户角色
+ const updateRoleMutation = useMutation({
+ mutationFn: ({ id, role }: { id: number; role: string }) =>
+ userService.updateUser(id, { role }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['users'] })
+ queryClient.invalidateQueries({ queryKey: ['user-stats'] })
+ toast.success('用户角色更新成功')
+ },
+ onError: (error: any) => {
+ toast.error(error?.message || '更新失败')
+ }
+ })
+
+ const handleDeleteUser = (userId: number, username: string) => {
+ if (confirm(`确定要删除用户 "${username}" 吗?`)) {
+ deleteUserMutation.mutate(userId)
+ }
+ }
+
+ const handleToggleStatus = (userId: number, currentStatus: boolean) => {
+ toggleUserStatusMutation.mutate({ id: userId, isActive: !currentStatus })
+ }
+
+ const handleUpdateRole = (userId: number, newRole: string) => {
+ updateRoleMutation.mutate({ id: userId, role: newRole })
+ }
+
+ const getRoleColor = (role: string) => {
+ switch (role) {
+ case 'admin':
+ return 'bg-red-100 text-red-800'
+ case 'editor':
+ return 'bg-blue-100 text-blue-800'
+ case 'viewer':
+ return 'bg-green-100 text-green-800'
+ default:
+ return 'bg-gray-100 text-gray-800'
+ }
+ }
+
+ const getRoleIcon = (role: string) => {
+ switch (role) {
+ case 'admin':
+ return
+ case 'editor':
+ return
+ case 'viewer':
+ return
+ default:
+ return
+ }
+ }
+
+ const filteredUsers = users?.filter(user => {
+ const matchesSearch = user.username.toLowerCase().includes(search.toLowerCase()) ||
+ user.email.toLowerCase().includes(search.toLowerCase())
+ const matchesRole = roleFilter === null || user.role === roleFilter
+ const matchesStatus = statusFilter === null || user.isActive === statusFilter
+ return matchesSearch && matchesRole && matchesStatus
+ })
+
+ return (
+
+ {/* 页面头部 */}
+
+
+
用户管理
+
+ 管理系统用户和权限分配
+
+
+
+
+
+ {/* 统计卡片 */}
+
+
+
+ 总用户数
+
+
+
+ {statsLoading ? (
+
+ ) : (
+ {stats?.total || 0}
+ )}
+
+
+
+
+
+ 活跃用户
+
+
+
+ {statsLoading ? (
+
+ ) : (
+ {stats?.active || 0}
+ )}
+
+
+
+
+
+ 管理员
+
+
+
+ {statsLoading ? (
+
+ ) : (
+ {stats?.admins || 0}
+ )}
+
+
+
+
+
+ 编辑员
+
+
+
+ {statsLoading ? (
+
+ ) : (
+ {stats?.editors || 0}
+ )}
+
+
+
+
+
+ 访客
+
+
+
+ {statsLoading ? (
+
+ ) : (
+ {stats?.viewers || 0}
+ )}
+
+
+
+
+
+ 最近登录
+
+
+
+ {statsLoading ? (
+
+ ) : (
+ {stats?.recentLogins || 0}
+ )}
+
+
+
+
+ {/* 搜索和过滤 */}
+
+
+
+
+
+ setSearch(e.target.value)}
+ className="pl-10"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 用户列表 */}
+
+
+ 用户列表
+
+
+ {usersLoading ? (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ ) : filteredUsers?.length ? (
+
+ {filteredUsers.map((user) => (
+
+
+
+
+
+ {user.profile?.firstName?.[0] || user.username[0].toUpperCase()}
+
+
+
+
+
+ {user.username}
+
+ {user.isActive ? '启用' : '禁用'}
+
+
+ {getRoleIcon(user.role)}
+
+ {user.role === 'admin' ? '管理员' :
+ user.role === 'editor' ? '编辑员' : '访客'}
+
+
+
+
+
+
+
+ {user.email}
+
+
+
+ 注册于 {new Date(user.createdAt).toLocaleDateString()}
+
+ {user.lastLoginAt && (
+
+
+ 最后登录 {new Date(user.lastLoginAt).toLocaleDateString()}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ 编辑用户
+
+
+
+ 重置密码
+
+ handleToggleStatus(user.id, user.isActive)}
+ >
+ {user.isActive ? (
+ <>
+
+ 禁用用户
+ >
+ ) : (
+ <>
+
+ 启用用户
+ >
+ )}
+
+ {user.role !== 'admin' && (
+ <>
+ handleUpdateRole(user.id, 'admin')}
+ >
+
+ 设为管理员
+
+ handleUpdateRole(user.id, 'editor')}
+ >
+
+ 设为编辑员
+
+ handleUpdateRole(user.id, 'viewer')}
+ >
+
+ 设为访客
+
+ >
+ )}
+ handleDeleteUser(user.id, user.username)}
+ className="text-red-600"
+ disabled={user.role === 'admin'} // 防止删除管理员
+ >
+
+ 删除用户
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+ {search ? '没有找到匹配的用户' : '暂无用户'}
+
+
+ {search ? '尝试调整搜索条件' : '创建您的第一个用户'}
+
+
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/admin/src/services/api.ts b/admin/src/services/api.ts
new file mode 100644
index 0000000..acbca11
--- /dev/null
+++ b/admin/src/services/api.ts
@@ -0,0 +1,52 @@
+import axios from 'axios'
+import { useAuthStore } from '@/stores/authStore'
+import { toast } from 'sonner'
+
+const api = axios.create({
+ baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api',
+ timeout: 30000,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+})
+
+// 请求拦截器
+api.interceptors.request.use(
+ (config) => {
+ const { token } = useAuthStore.getState()
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`
+ }
+ return config
+ },
+ (error) => {
+ return Promise.reject(error)
+ }
+)
+
+// 响应拦截器
+api.interceptors.response.use(
+ (response) => {
+ return response
+ },
+ (error) => {
+ const message = error.response?.data?.message || error.message || '请求失败'
+
+ // 显示错误提示
+ if (error.response?.status !== 401) {
+ toast.error(message)
+ }
+
+ // 401 错误自动跳转登录
+ if (error.response?.status === 401) {
+ const { logout } = useAuthStore.getState()
+ logout()
+ window.location.href = '/login'
+ toast.error('登录已过期,请重新登录')
+ }
+
+ return Promise.reject(error)
+ }
+)
+
+export default api
\ No newline at end of file
diff --git a/admin/src/services/authService.ts b/admin/src/services/authService.ts
new file mode 100644
index 0000000..8f6dddc
--- /dev/null
+++ b/admin/src/services/authService.ts
@@ -0,0 +1,85 @@
+import api from './api'
+
+export interface LoginRequest {
+ username: string
+ password: string
+}
+
+export interface LoginResponse {
+ user: User
+ access_token: string
+ refresh_token: string
+ expires_in: number
+}
+
+export interface User {
+ id: number
+ username: string
+ email: string
+ role: string
+ is_active: boolean
+ created_at: string
+ updated_at: string
+}
+
+export interface RefreshTokenRequest {
+ refresh_token: string
+}
+
+export interface RefreshTokenResponse {
+ access_token: string
+ refresh_token: string
+ expires_in: number
+}
+
+export interface ChangePasswordRequest {
+ old_password: string
+ new_password: string
+}
+
+export interface UpdateProfileRequest {
+ username?: string
+ email?: string
+}
+
+class AuthService {
+ async login(credentials: LoginRequest): Promise {
+ const response = await api.post('/auth/login', credentials)
+ return response.data
+ }
+
+ async logout(): Promise {
+ await api.post('/auth/logout')
+ }
+
+ async refreshToken(refreshToken: string): Promise {
+ const response = await api.post('/auth/refresh', { refresh_token: refreshToken })
+ return response.data
+ }
+
+ async getCurrentUser(): Promise {
+ const response = await api.get('/me')
+ return response.data
+ }
+
+ async updateProfile(data: UpdateProfileRequest): Promise {
+ const response = await api.put('/me', data)
+ return response.data
+ }
+
+ async changePassword(data: ChangePasswordRequest): Promise {
+ await api.put('/auth/password', data)
+ }
+
+ async checkUsernameAvailability(username: string): Promise {
+ const response = await api.get(`/auth/check-username/${username}`)
+ return response.data.available
+ }
+
+ async checkEmailAvailability(email: string): Promise {
+ const response = await api.get(`/auth/check-email/${email}`)
+ return response.data.available
+ }
+}
+
+export const authService = new AuthService()
\ No newline at end of file
diff --git a/admin/src/services/categoryService.ts b/admin/src/services/categoryService.ts
new file mode 100644
index 0000000..cd2a40d
--- /dev/null
+++ b/admin/src/services/categoryService.ts
@@ -0,0 +1,94 @@
+import api from './api'
+
+export interface Category {
+ id: number
+ name: string
+ slug: string
+ description: string
+ parentId?: number
+ sortOrder: number
+ isActive: boolean
+ photoCount: number
+ createdAt: string
+ updatedAt: string
+}
+
+export interface CategoryTree extends Category {
+ children: CategoryTree[]
+}
+
+export interface CreateCategoryRequest {
+ name: string
+ slug: string
+ description?: string
+ parentId?: number
+}
+
+export interface UpdateCategoryRequest {
+ name?: string
+ slug?: string
+ description?: string
+ parentId?: number
+ sortOrder?: number
+ isActive?: boolean
+}
+
+export interface CategoryStats {
+ total: number
+ active: number
+ topLevel: number
+ photoCounts: Record
+}
+
+class CategoryService {
+ async getCategories(parentId?: number): Promise {
+ const params = parentId ? { parent_id: parentId } : {}
+ const response = await api.get('/categories', { params })
+ return response.data
+ }
+
+ async getCategoryTree(): Promise {
+ const response = await api.get('/categories/tree')
+ return response.data
+ }
+
+ async getCategory(id: number): Promise {
+ const response = await api.get(`/categories/${id}`)
+ return response.data
+ }
+
+ async getCategoryBySlug(slug: string): Promise {
+ const response = await api.get(`/categories/slug/${slug}`)
+ return response.data
+ }
+
+ async createCategory(data: CreateCategoryRequest): Promise {
+ const response = await api.post('/categories', data)
+ return response.data
+ }
+
+ async updateCategory(id: number, data: UpdateCategoryRequest): Promise {
+ const response = await api.put(`/categories/${id}`, data)
+ return response.data
+ }
+
+ async deleteCategory(id: number): Promise {
+ await api.delete(`/categories/${id}`)
+ }
+
+ async reorderCategories(parentId: number | null, categoryIds: number[]): Promise {
+ await api.post('/categories/reorder', { parentId, categoryIds })
+ }
+
+ async getStats(): Promise {
+ const response = await api.get('/categories/stats')
+ return response.data
+ }
+
+ async generateSlug(name: string): Promise {
+ const response = await api.post('/categories/generate-slug', { name })
+ return response.data.slug
+ }
+}
+
+export const categoryService = new CategoryService()
\ No newline at end of file
diff --git a/admin/src/services/photoService.ts b/admin/src/services/photoService.ts
new file mode 100644
index 0000000..e3c1e09
--- /dev/null
+++ b/admin/src/services/photoService.ts
@@ -0,0 +1,184 @@
+import api from './api'
+
+export interface Photo {
+ id: number
+ title: string
+ description: string
+ originalFilename: string
+ uniqueFilename: string
+ fileSize: number
+ status: 'draft' | 'published' | 'archived' | 'processing'
+ camera?: string
+ lens?: string
+ iso?: number
+ aperture?: string
+ shutterSpeed?: string
+ focalLength?: string
+ takenAt?: string
+ createdAt: string
+ updatedAt: string
+ categories: Category[]
+ tags: Tag[]
+ formats: PhotoFormat[]
+}
+
+export interface PhotoFormat {
+ id: number
+ photoId: number
+ format: string
+ width: number
+ height: number
+ quality: number
+ fileSize: number
+ url: string
+ createdAt: string
+}
+
+export interface Category {
+ id: number
+ name: string
+ slug: string
+ description: string
+ photoCount: number
+}
+
+export interface Tag {
+ id: number
+ name: string
+ slug: string
+ description: string
+ color?: string
+ photoCount: number
+}
+
+export interface PhotoListParams {
+ page?: number
+ limit?: number
+ search?: string
+ status?: string
+ category_id?: number
+ tags?: string[]
+ start_date?: string
+ end_date?: string
+ sort_by?: string
+ sort_order?: string
+}
+
+export interface PhotoListResponse {
+ photos: Photo[]
+ total: number
+ page: number
+ limit: number
+ pages: number
+}
+
+export interface CreatePhotoRequest {
+ title: string
+ description?: string
+ status?: string
+ categoryIds?: number[]
+ tagIds?: number[]
+ camera?: string
+ lens?: string
+ iso?: number
+ aperture?: string
+ shutterSpeed?: string
+ focalLength?: string
+ takenAt?: string
+}
+
+export interface UpdatePhotoRequest {
+ title?: string
+ description?: string
+ status?: string
+ categoryIds?: number[]
+ tagIds?: number[]
+ camera?: string
+ lens?: string
+ iso?: number
+ aperture?: string
+ shutterSpeed?: string
+ focalLength?: string
+ takenAt?: string
+}
+
+export interface BatchUpdateRequest {
+ status?: string
+ categoryIds?: number[]
+ tagIds?: number[]
+}
+
+export interface PhotoStats {
+ total: number
+ thisMonth: number
+ today: number
+ totalSize: number
+ statusStats: Record
+}
+
+class PhotoService {
+ async getPhotos(params: PhotoListParams = {}): Promise {
+ const response = await api.get('/photos', { params })
+ return response.data
+ }
+
+ async getPhoto(id: number): Promise {
+ const response = await api.get(`/photos/${id}`)
+ return response.data
+ }
+
+ async createPhoto(data: CreatePhotoRequest): Promise {
+ const response = await api.post('/photos', data)
+ return response.data
+ }
+
+ async updatePhoto(id: number, data: UpdatePhotoRequest): Promise {
+ const response = await api.put(`/photos/${id}`, data)
+ return response.data
+ }
+
+ async deletePhoto(id: number): Promise {
+ await api.delete(`/photos/${id}`)
+ }
+
+ async uploadPhoto(file: File, data: CreatePhotoRequest): Promise {
+ const formData = new FormData()
+ formData.append('file', file)
+
+ // 添加其他字段
+ if (data.title) formData.append('title', data.title)
+ if (data.description) formData.append('description', data.description)
+ if (data.status) formData.append('status', data.status)
+ if (data.categoryIds) formData.append('category_ids', data.categoryIds.join(','))
+ if (data.tagIds) formData.append('tag_ids', data.tagIds.join(','))
+ if (data.camera) formData.append('camera', data.camera)
+ if (data.lens) formData.append('lens', data.lens)
+ if (data.iso) formData.append('iso', data.iso.toString())
+ if (data.aperture) formData.append('aperture', data.aperture)
+ if (data.shutterSpeed) formData.append('shutter_speed', data.shutterSpeed)
+ if (data.focalLength) formData.append('focal_length', data.focalLength)
+ if (data.takenAt) formData.append('taken_at', data.takenAt)
+
+ const response = await api.post('/photos/upload', formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ })
+ return response.data
+ }
+
+ async batchUpdate(ids: number[], data: BatchUpdateRequest): Promise {
+ await api.post('/photos/batch/update', { ids, ...data })
+ }
+
+ async batchDelete(ids: number[]): Promise {
+ await api.post('/photos/batch/delete', { ids })
+ }
+
+ async getStats(): Promise {
+ const response = await api.get('/photos/stats')
+ return response.data
+ }
+}
+
+export const photoService = new PhotoService()
\ No newline at end of file
diff --git a/admin/src/services/settingsService.ts b/admin/src/services/settingsService.ts
new file mode 100644
index 0000000..6cd5e27
--- /dev/null
+++ b/admin/src/services/settingsService.ts
@@ -0,0 +1,240 @@
+import api from './api'
+
+interface SystemSettings {
+ site: {
+ title: string
+ description: string
+ keywords: string
+ favicon?: string
+ logo?: string
+ copyright: string
+ contactEmail: string
+ }
+ upload: {
+ maxFileSize: number
+ allowedTypes: string[]
+ compressionQuality: number
+ watermarkEnabled: boolean
+ watermarkText: string
+ watermarkPosition: string
+ }
+ security: {
+ jwtSecret: string
+ jwtExpiration: number
+ enableTwoFactor: boolean
+ enableAuditLog: boolean
+ maxLoginAttempts: number
+ lockoutDuration: number
+ }
+ performance: {
+ cacheEnabled: boolean
+ cacheExpiration: number
+ imageCacheSize: number
+ enableCompression: boolean
+ maxConcurrentUploads: number
+ }
+ notifications: {
+ emailEnabled: boolean
+ emailHost: string
+ emailPort: number
+ emailUser: string
+ emailPassword: string
+ enableUserNotifications: boolean
+ enableSystemAlerts: boolean
+ }
+ backup: {
+ autoBackupEnabled: boolean
+ backupInterval: number
+ maxBackupFiles: number
+ backupLocation: string
+ }
+}
+
+interface SystemStatus {
+ server: {
+ status: 'online' | 'offline'
+ uptime: string
+ version: string
+ lastRestart: string
+ }
+ database: {
+ status: 'connected' | 'disconnected' | 'error'
+ version: string
+ size: string
+ lastBackup: string
+ }
+ storage: {
+ total: string
+ used: string
+ free: string
+ usage: number
+ }
+ performance: {
+ cpu: number
+ memory: number
+ activeConnections: number
+ responseTime: number
+ }
+}
+
+interface SystemOperation {
+ operation: string
+}
+
+export const settingsService = {
+ // 获取系统设置
+ getSettings: async (): Promise => {
+ const response = await api.get('/settings')
+ return response.data
+ },
+
+ // 更新系统设置
+ updateSettings: async (settings: Partial): Promise => {
+ const response = await api.put('/settings', settings)
+ return response.data
+ },
+
+ // 获取系统状态
+ getSystemStatus: async (): Promise => {
+ const response = await api.get('/system/status')
+ return response.data
+ },
+
+ // 执行系统操作
+ performSystemOperation: async (operation: SystemOperation): Promise => {
+ const response = await api.post('/system/operation', operation)
+ return response.data
+ },
+
+ // 获取配置项
+ getConfig: async (key: string): Promise => {
+ const response = await api.get(`/settings/config/${key}`)
+ return response.data
+ },
+
+ // 更新配置项
+ updateConfig: async (key: string, value: any): Promise => {
+ await api.put(`/settings/config/${key}`, { value })
+ },
+
+ // 重置配置
+ resetConfig: async (section?: string): Promise => {
+ await api.post('/settings/reset', { section })
+ },
+
+ // 导出配置
+ exportConfig: async (): Promise => {
+ const response = await api.get('/settings/export', { responseType: 'blob' })
+ return response.data
+ },
+
+ // 导入配置
+ importConfig: async (file: File): Promise => {
+ const formData = new FormData()
+ formData.append('config', file)
+ await api.post('/settings/import', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' }
+ })
+ },
+
+ // 获取系统日志
+ getSystemLogs: async (params: { level?: string; limit?: number; offset?: number }): Promise => {
+ const response = await api.get('/system/logs', { params })
+ return response.data
+ },
+
+ // 清理系统日志
+ clearSystemLogs: async (): Promise => {
+ await api.delete('/system/logs')
+ },
+
+ // 获取系统健康检查
+ getHealthCheck: async (): Promise => {
+ const response = await api.get('/system/health')
+ return response.data
+ },
+
+ // 获取系统信息
+ getSystemInfo: async (): Promise => {
+ const response = await api.get('/system/info')
+ return response.data
+ },
+
+ // 获取备份列表
+ getBackups: async (): Promise => {
+ const response = await api.get('/system/backups')
+ return response.data
+ },
+
+ // 创建备份
+ createBackup: async (): Promise => {
+ const response = await api.post('/system/backups')
+ return response.data
+ },
+
+ // 删除备份
+ deleteBackup: async (id: string): Promise => {
+ await api.delete(`/system/backups/${id}`)
+ },
+
+ // 恢复备份
+ restoreBackup: async (id: string): Promise => {
+ await api.post(`/system/backups/${id}/restore`)
+ },
+
+ // 下载备份
+ downloadBackup: async (id: string): Promise => {
+ const response = await api.get(`/system/backups/${id}/download`, { responseType: 'blob' })
+ return response.data
+ },
+
+ // 获取缓存统计
+ getCacheStats: async (): Promise => {
+ const response = await api.get('/system/cache/stats')
+ return response.data
+ },
+
+ // 清理缓存
+ clearCache: async (type?: string): Promise => {
+ await api.delete('/system/cache', { params: { type } })
+ },
+
+ // 获取存储统计
+ getStorageStats: async (): Promise => {
+ const response = await api.get('/system/storage/stats')
+ return response.data
+ },
+
+ // 清理存储
+ cleanStorage: async (type?: string): Promise => {
+ await api.delete('/system/storage/cleanup', { params: { type } })
+ },
+
+ // 获取任务列表
+ getTasks: async (): Promise => {
+ const response = await api.get('/system/tasks')
+ return response.data
+ },
+
+ // 创建任务
+ createTask: async (task: any): Promise => {
+ const response = await api.post('/system/tasks', task)
+ return response.data
+ },
+
+ // 删除任务
+ deleteTask: async (id: string): Promise => {
+ await api.delete(`/system/tasks/${id}`)
+ },
+
+ // 执行任务
+ executeTask: async (id: string): Promise => {
+ await api.post(`/system/tasks/${id}/execute`)
+ },
+
+ // 获取任务日志
+ getTaskLogs: async (id: string): Promise => {
+ const response = await api.get(`/system/tasks/${id}/logs`)
+ return response.data
+ }
+}
\ No newline at end of file
diff --git a/admin/src/services/tagService.ts b/admin/src/services/tagService.ts
new file mode 100644
index 0000000..6dd4fc0
--- /dev/null
+++ b/admin/src/services/tagService.ts
@@ -0,0 +1,131 @@
+import api from './api'
+
+export interface Tag {
+ id: number
+ name: string
+ slug: string
+ description: string
+ color?: string
+ isActive: boolean
+ photoCount: number
+ createdAt: string
+ updatedAt: string
+}
+
+export interface TagWithCount extends Tag {
+ photoCount: number
+}
+
+export interface TagCloudItem {
+ name: string
+ slug: string
+ color?: string
+ count: number
+}
+
+export interface TagListParams {
+ page?: number
+ limit?: number
+ search?: string
+ isActive?: boolean
+ sortBy?: string
+ sortOrder?: string
+}
+
+export interface TagListResponse {
+ tags: Tag[]
+ total: number
+ page: number
+ limit: number
+ pages: number
+}
+
+export interface CreateTagRequest {
+ name: string
+ slug: string
+ description?: string
+ color?: string
+}
+
+export interface UpdateTagRequest {
+ name?: string
+ slug?: string
+ description?: string
+ color?: string
+ isActive?: boolean
+}
+
+export interface TagStats {
+ total: number
+ active: number
+ used: number
+ unused: number
+ avgPhotosPerTag: number
+}
+
+class TagService {
+ async getTags(params: TagListParams = {}): Promise {
+ const response = await api.get('/tags', { params })
+ return response.data
+ }
+
+ async getAllTags(): Promise {
+ const response = await api.get('/tags/all')
+ return response.data
+ }
+
+ async getTag(id: number): Promise {
+ const response = await api.get(`/tags/${id}`)
+ return response.data
+ }
+
+ async getTagBySlug(slug: string): Promise {
+ const response = await api.get(`/tags/slug/${slug}`)
+ return response.data
+ }
+
+ async createTag(data: CreateTagRequest): Promise {
+ const response = await api.post('/tags', data)
+ return response.data
+ }
+
+ async updateTag(id: number, data: UpdateTagRequest): Promise {
+ const response = await api.put(`/tags/${id}`, data)
+ return response.data
+ }
+
+ async deleteTag(id: number): Promise {
+ await api.delete(`/tags/${id}`)
+ }
+
+ async batchDelete(ids: number[]): Promise {
+ await api.post('/tags/batch/delete', { ids })
+ }
+
+ async getPopularTags(limit: number = 10): Promise {
+ const response = await api.get('/tags/popular', { params: { limit } })
+ return response.data
+ }
+
+ async getTagCloud(): Promise {
+ const response = await api.get('/tags/cloud')
+ return response.data
+ }
+
+ async getStats(): Promise {
+ const response = await api.get('/tags/stats')
+ return response.data
+ }
+
+ async searchTags(query: string, limit: number = 10): Promise {
+ const response = await api.get('/tags/search', { params: { q: query, limit } })
+ return response.data
+ }
+
+ async generateSlug(name: string): Promise {
+ const response = await api.post('/tags/generate-slug', { name })
+ return response.data.slug
+ }
+}
+
+export const tagService = new TagService()
\ No newline at end of file
diff --git a/admin/src/services/userService.ts b/admin/src/services/userService.ts
new file mode 100644
index 0000000..0ae37da
--- /dev/null
+++ b/admin/src/services/userService.ts
@@ -0,0 +1,157 @@
+import api from './api'
+
+interface User {
+ id: number
+ username: string
+ email: string
+ role: 'admin' | 'editor' | 'viewer'
+ isActive: boolean
+ lastLoginAt?: string
+ createdAt: string
+ updatedAt: string
+ profile?: {
+ firstName?: string
+ lastName?: string
+ avatar?: string
+ }
+}
+
+interface UserListParams {
+ search?: string
+ role?: string | null
+ isActive?: boolean | null
+ page?: number
+ limit?: number
+}
+
+interface UserCreateData {
+ username: string
+ email: string
+ password: string
+ role?: string
+ isActive?: boolean
+ profile?: {
+ firstName?: string
+ lastName?: string
+ }
+}
+
+interface UserUpdateData {
+ username?: string
+ email?: string
+ password?: string
+ role?: string
+ isActive?: boolean
+ profile?: {
+ firstName?: string
+ lastName?: string
+ avatar?: string
+ }
+}
+
+interface UserStats {
+ total: number
+ active: number
+ admins: number
+ editors: number
+ viewers: number
+ recentLogins: number
+}
+
+export const userService = {
+ // 获取用户列表
+ getUsers: async (params: UserListParams = {}): Promise => {
+ const response = await api.get('/users', { params })
+ return response.data
+ },
+
+ // 获取用户统计
+ getStats: async (): Promise => {
+ const response = await api.get('/users/stats')
+ return response.data
+ },
+
+ // 创建用户
+ createUser: async (data: UserCreateData): Promise => {
+ const response = await api.post('/users', data)
+ return response.data
+ },
+
+ // 更新用户
+ updateUser: async (id: number, data: UserUpdateData): Promise => {
+ const response = await api.put(`/users/${id}`, data)
+ return response.data
+ },
+
+ // 删除用户
+ deleteUser: async (id: number): Promise => {
+ await api.delete(`/users/${id}`)
+ },
+
+ // 获取用户详情
+ getUser: async (id: number): Promise => {
+ const response = await api.get(`/users/${id}`)
+ return response.data
+ },
+
+ // 批量删除用户
+ deleteUsers: async (ids: number[]): Promise => {
+ await api.delete('/users/batch', { data: { ids } })
+ },
+
+ // 批量更新用户状态
+ updateUsersStatus: async (ids: number[], isActive: boolean): Promise => {
+ await api.put('/users/batch/status', { ids, isActive })
+ },
+
+ // 重置用户密码
+ resetPassword: async (id: number): Promise => {
+ const response = await api.post(`/users/${id}/reset-password`)
+ return response.data.newPassword
+ },
+
+ // 更改用户角色
+ updateUserRole: async (id: number, role: string): Promise => {
+ const response = await api.put(`/users/${id}/role`, { role })
+ return response.data
+ },
+
+ // 获取用户活动日志
+ getUserActivity: async (id: number): Promise => {
+ const response = await api.get(`/users/${id}/activity`)
+ return response.data
+ },
+
+ // 获取最近登录用户
+ getRecentLogins: async (limit: number = 10): Promise => {
+ const response = await api.get('/users/recent-logins', { params: { limit } })
+ return response.data
+ },
+
+ // 检查用户名是否可用
+ checkUsername: async (username: string): Promise => {
+ const response = await api.get('/users/check-username', { params: { username } })
+ return response.data.available
+ },
+
+ // 检查邮箱是否可用
+ checkEmail: async (email: string): Promise => {
+ const response = await api.get('/users/check-email', { params: { email } })
+ return response.data.available
+ },
+
+ // 发送验证邮件
+ sendVerificationEmail: async (id: number): Promise => {
+ await api.post(`/users/${id}/send-verification`)
+ },
+
+ // 激活用户账号
+ activateUser: async (id: number): Promise => {
+ await api.post(`/users/${id}/activate`)
+ },
+
+ // 停用用户账号
+ deactivateUser: async (id: number): Promise => {
+ await api.post(`/users/${id}/deactivate`)
+ }
+}
\ No newline at end of file
diff --git a/admin/src/stores/authStore.ts b/admin/src/stores/authStore.ts
new file mode 100644
index 0000000..bca052b
--- /dev/null
+++ b/admin/src/stores/authStore.ts
@@ -0,0 +1,59 @@
+import { create } from 'zustand'
+import { persist } from 'zustand/middleware'
+
+export interface User {
+ id: number
+ username: string
+ email: string
+ role: string
+ isActive: boolean
+ createdAt: string
+ updatedAt: string
+}
+
+interface AuthState {
+ user: User | null
+ token: string | null
+ isAuthenticated: boolean
+ login: (token: string, user: User) => void
+ logout: () => void
+ updateUser: (user: User) => void
+}
+
+export const useAuthStore = create()(
+ persist(
+ (set, get) => ({
+ user: null,
+ token: null,
+ isAuthenticated: false,
+
+ login: (token: string, user: User) => {
+ set({
+ token,
+ user,
+ isAuthenticated: true,
+ })
+ },
+
+ logout: () => {
+ set({
+ token: null,
+ user: null,
+ isAuthenticated: false,
+ })
+ },
+
+ updateUser: (user: User) => {
+ set({ user })
+ },
+ }),
+ {
+ name: 'auth-storage',
+ partialize: (state) => ({
+ token: state.token,
+ user: state.user,
+ isAuthenticated: state.isAuthenticated,
+ }),
+ }
+ )
+)
\ No newline at end of file
diff --git a/admin/tsconfig.json b/admin/tsconfig.json
new file mode 100644
index 0000000..abdb131
--- /dev/null
+++ b/admin/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
\ No newline at end of file
diff --git a/admin/tsconfig.node.json b/admin/tsconfig.node.json
new file mode 100644
index 0000000..099658c
--- /dev/null
+++ b/admin/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
\ No newline at end of file
diff --git a/admin/vite.config.ts b/admin/vite.config.ts
new file mode 100644
index 0000000..ea63bf8
--- /dev/null
+++ b/admin/vite.config.ts
@@ -0,0 +1,27 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'path'
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ server: {
+ port: 3001,
+ host: true,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8080',
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ assetsDir: 'assets',
+ sourcemap: false,
+ },
+})
\ No newline at end of file
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..c40fa30
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,61 @@
+# 构建阶段
+FROM golang:1.21-alpine AS builder
+
+# 设置工作目录
+WORKDIR /app
+
+# 安装必要的包
+RUN apk add --no-cache git ca-certificates tzdata
+
+# 复制 go mod 文件
+COPY go.mod go.sum ./
+
+# 下载依赖
+RUN go mod download
+
+# 复制源代码
+COPY . .
+
+# 构建应用
+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go
+
+# 运行阶段
+FROM alpine:latest
+
+# 安装必要的包
+RUN apk --no-cache add ca-certificates tzdata
+
+# 设置时区
+RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
+RUN echo 'Asia/Shanghai' > /etc/timezone
+
+# 创建非root用户
+RUN addgroup -g 1001 -S appgroup && \
+ adduser -u 1001 -S appuser -G appgroup
+
+# 设置工作目录
+WORKDIR /app
+
+# 从构建阶段复制二进制文件
+COPY --from=builder /app/main .
+
+# 复制配置文件和迁移文件
+COPY --from=builder /app/configs ./configs
+COPY --from=builder /app/migrations ./migrations
+
+# 创建上传目录
+RUN mkdir -p uploads/photos uploads/thumbnails uploads/temp && \
+ chown -R appuser:appgroup uploads
+
+# 切换到非root用户
+USER appuser
+
+# 暴露端口
+EXPOSE 8080
+
+# 健康检查
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
+
+# 启动应用
+CMD ["./main"]
\ No newline at end of file
diff --git a/backend/Makefile b/backend/Makefile
new file mode 100644
index 0000000..b4c20f4
--- /dev/null
+++ b/backend/Makefile
@@ -0,0 +1,165 @@
+# Photography Backend Makefile
+# Simple and functional Makefile for Go backend project with Docker support
+
+.PHONY: help dev dev-up dev-down build clean docker-build docker-run prod-up prod-down status health fmt mod
+
+# Color definitions
+GREEN := \033[0;32m
+YELLOW := \033[1;33m
+BLUE := \033[0;34m
+RED := \033[0;31m
+NC := \033[0m # No Color
+
+# Application configuration
+APP_NAME := photography-backend
+VERSION := 1.0.0
+BUILD_TIME := $(shell date +%Y%m%d_%H%M%S)
+LDFLAGS := -X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME)
+
+# Build configuration
+BUILD_DIR := bin
+MAIN_FILE := cmd/server/main.go
+
+# Database configuration
+DB_URL := postgres://postgres:password@localhost:5432/photography?sslmode=disable
+MIGRATION_DIR := migrations
+
+# Default target
+.DEFAULT_GOAL := help
+
+##@ Development Environment Commands
+
+dev: ## Start development server with hot reload
+ @printf "$(GREEN)🚀 Starting development server...\n$(NC)"
+ @air -c .air.toml || go run $(MAIN_FILE)
+
+dev-up: ## Start development environment with Docker
+ @printf "$(GREEN)🐳 Starting development environment...\n$(NC)"
+ @docker-compose -f docker-compose.dev.yml up -d
+ @printf "$(GREEN)✅ Development environment started successfully!\n$(NC)"
+
+dev-down: ## Stop development environment
+ @printf "$(GREEN)🛑 Stopping development environment...\n$(NC)"
+ @docker-compose -f docker-compose.dev.yml down
+ @printf "$(GREEN)✅ Development environment stopped!\n$(NC)"
+
+##@ Build Commands
+
+build: ## Build the Go application
+ @printf "$(GREEN)🔨 Building $(APP_NAME)...\n$(NC)"
+ @mkdir -p $(BUILD_DIR)
+ @CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_FILE)
+ @printf "$(GREEN)✅ Build completed: $(BUILD_DIR)/$(APP_NAME)\n$(NC)"
+
+clean: ## Clean build artifacts
+ @printf "$(GREEN)🧹 Cleaning build files...\n$(NC)"
+ @rm -rf $(BUILD_DIR)
+ @rm -f coverage.out coverage.html
+ @printf "$(GREEN)✅ Clean completed!\n$(NC)"
+
+##@ Docker Commands
+
+docker-build: ## Build Docker image
+ @printf "$(GREEN)🐳 Building Docker image...\n$(NC)"
+ @docker build -t $(APP_NAME):$(VERSION) .
+ @docker tag $(APP_NAME):$(VERSION) $(APP_NAME):latest
+ @printf "$(GREEN)✅ Docker image built: $(APP_NAME):$(VERSION)\n$(NC)"
+
+docker-run: ## Run application in Docker container
+ @printf "$(GREEN)🐳 Running Docker container...\n$(NC)"
+ @docker-compose up -d
+ @printf "$(GREEN)✅ Docker container started!\n$(NC)"
+
+##@ Production Commands
+
+prod-up: ## Start production environment
+ @printf "$(GREEN)🚀 Starting production environment...\n$(NC)"
+ @docker-compose up -d
+ @printf "$(GREEN)✅ Production environment started!\n$(NC)"
+
+prod-down: ## Stop production environment
+ @printf "$(GREEN)🛑 Stopping production environment...\n$(NC)"
+ @docker-compose down
+ @printf "$(GREEN)✅ Production environment stopped!\n$(NC)"
+
+##@ Health Check & Status Commands
+
+status: ## Check application and services status
+ @printf "$(GREEN)📊 Checking application status...\n$(NC)"
+ @printf "$(BLUE)Docker containers:$(NC)\n"
+ @docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "($(APP_NAME)|postgres|redis)" || echo "No containers running"
+ @printf "$(BLUE)Application build:$(NC)\n"
+ @if [ -f "$(BUILD_DIR)/$(APP_NAME)" ]; then \
+ printf "$(GREEN)✅ Binary exists: $(BUILD_DIR)/$(APP_NAME)\n$(NC)"; \
+ ls -lh $(BUILD_DIR)/$(APP_NAME); \
+ else \
+ printf "$(RED)❌ Binary not found. Run 'make build' first.\n$(NC)"; \
+ fi
+
+health: ## Check health of running services
+ @printf "$(GREEN)🏥 Checking service health...\n$(NC)"
+ @printf "$(BLUE)Testing application endpoint...\n$(NC)"
+ @curl -f http://localhost:8080/health 2>/dev/null && printf "$(GREEN)✅ Application is healthy\n$(NC)" || printf "$(RED)❌ Application is not responding\n$(NC)"
+ @printf "$(BLUE)Database connection...\n$(NC)"
+ @docker exec photography-postgres pg_isready -U postgres 2>/dev/null && printf "$(GREEN)✅ Database is ready\n$(NC)" || printf "$(RED)❌ Database is not ready\n$(NC)"
+
+##@ Code Quality Commands
+
+fmt: ## Format Go code
+ @printf "$(GREEN)🎨 Formatting Go code...\n$(NC)"
+ @go fmt ./...
+ @printf "$(GREEN)✅ Code formatted!\n$(NC)"
+
+mod: ## Tidy Go modules
+ @printf "$(GREEN)📦 Tidying Go modules...\n$(NC)"
+ @go mod tidy
+ @go mod download
+ @printf "$(GREEN)✅ Modules tidied!\n$(NC)"
+
+lint: ## Run code linter
+ @printf "$(GREEN)🔍 Running linter...\n$(NC)"
+ @golangci-lint run
+ @printf "$(GREEN)✅ Linting completed!\n$(NC)"
+
+test: ## Run tests
+ @printf "$(GREEN)🧪 Running tests...\n$(NC)"
+ @go test -v ./...
+ @printf "$(GREEN)✅ Tests completed!\n$(NC)"
+
+##@ Utility Commands
+
+install: ## Install dependencies
+ @printf "$(GREEN)📦 Installing dependencies...\n$(NC)"
+ @go mod download
+ @go mod tidy
+ @printf "$(GREEN)✅ Dependencies installed!\n$(NC)"
+
+logs: ## Show application logs
+ @printf "$(GREEN)📄 Showing application logs...\n$(NC)"
+ @docker-compose logs -f $(APP_NAME)
+
+migrate-up: ## Run database migrations
+ @printf "$(GREEN)🗄️ Running database migrations...\n$(NC)"
+ @migrate -path $(MIGRATION_DIR) -database "$(DB_URL)" up
+ @printf "$(GREEN)✅ Migrations completed!\n$(NC)"
+
+migrate-down: ## Rollback database migrations
+ @printf "$(GREEN)🗄️ Rolling back database migrations...\n$(NC)"
+ @migrate -path $(MIGRATION_DIR) -database "$(DB_URL)" down
+ @printf "$(GREEN)✅ Migrations rolled back!\n$(NC)"
+
+##@ Help
+
+help: ## Display this help message
+ @printf "$(GREEN)Photography Backend Makefile\n$(NC)"
+ @printf "$(GREEN)============================\n$(NC)"
+ @printf "$(YELLOW)Simple and functional Makefile for Go backend project with Docker support\n$(NC)\n"
+ @awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z_-]+:.*?##/ { printf "$(BLUE)%-15s$(NC) %s\n", $$1, $$2 } /^##@/ { printf "\n$(GREEN)%s\n$(NC)", substr($$0, 5) } ' $(MAKEFILE_LIST)
+ @printf "\n$(YELLOW)Examples:\n$(NC)"
+ @printf "$(BLUE) make dev$(NC) - Start development server\n"
+ @printf "$(BLUE) make dev-up$(NC) - Start development environment\n"
+ @printf "$(BLUE) make build$(NC) - Build the application\n"
+ @printf "$(BLUE) make docker-build$(NC) - Build Docker image\n"
+ @printf "$(BLUE) make status$(NC) - Check application status\n"
+ @printf "$(BLUE) make health$(NC) - Check service health\n"
+ @printf "\n$(GREEN)For more information, visit: https://github.com/iriver/photography\n$(NC)"
\ No newline at end of file
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
new file mode 100644
index 0000000..4b0ca5e
--- /dev/null
+++ b/backend/cmd/server/main.go
@@ -0,0 +1,141 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/signal"
+ "syscall"
+ "context"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+
+ "photography-backend/internal/config"
+ "photography-backend/internal/repository/postgres"
+ "photography-backend/internal/service"
+ "photography-backend/internal/service/auth"
+ "photography-backend/internal/api/handlers"
+ "photography-backend/internal/api/middleware"
+ "photography-backend/internal/api/routes"
+ "photography-backend/pkg/logger"
+)
+
+func main() {
+ // 加载配置
+ cfg, err := config.LoadConfig("configs/config.yaml")
+ if err != nil {
+ log.Fatalf("Failed to load config: %v", err)
+ }
+
+ // 初始化日志
+ zapLogger, err := logger.InitLogger(&cfg.Logger)
+ if err != nil {
+ log.Fatalf("Failed to initialize logger: %v", err)
+ }
+ defer zapLogger.Sync()
+
+ // 初始化数据库
+ db, err := postgres.NewDatabase(&cfg.Database)
+ if err != nil {
+ zapLogger.Fatal("Failed to connect to database", zap.Error(err))
+ }
+ defer db.Close()
+
+ // 自动迁移数据库
+ if err := db.AutoMigrate(); err != nil {
+ zapLogger.Fatal("Failed to migrate database", zap.Error(err))
+ }
+
+ // 初始化仓库
+ userRepo := postgres.NewUserRepository(db.DB)
+ photoRepo := postgres.NewPhotoRepository(db.DB)
+ categoryRepo := postgres.NewCategoryRepository(db.DB)
+ tagRepo := postgres.NewTagRepository(db.DB)
+
+ // 初始化服务
+ jwtService := auth.NewJWTService(&cfg.JWT)
+ authService := auth.NewAuthService(userRepo, jwtService)
+ photoService := service.NewPhotoService(photoRepo)
+ categoryService := service.NewCategoryService(categoryRepo)
+ tagService := service.NewTagService(tagRepo)
+ userService := service.NewUserService(userRepo)
+
+ // 初始化处理器
+ authHandler := handlers.NewAuthHandler(authService)
+ photoHandler := handlers.NewPhotoHandler(photoService)
+ categoryHandler := handlers.NewCategoryHandler(categoryService)
+ tagHandler := handlers.NewTagHandler(tagService)
+ userHandler := handlers.NewUserHandler(userService)
+
+ // 初始化中间件
+ authMiddleware := middleware.NewAuthMiddleware(jwtService)
+
+ // 设置Gin模式
+ if cfg.IsProduction() {
+ gin.SetMode(gin.ReleaseMode)
+ }
+
+ // 创建Gin引擎
+ r := gin.New()
+
+ // 添加中间件
+ r.Use(middleware.RequestIDMiddleware())
+ r.Use(middleware.LoggerMiddleware(zapLogger))
+ r.Use(middleware.CORSMiddleware(&cfg.CORS))
+ r.Use(gin.Recovery())
+
+ // 健康检查
+ r.GET("/health", func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{
+ "status": "ok",
+ "timestamp": time.Now().Unix(),
+ "version": cfg.App.Version,
+ })
+ })
+
+ // 设置路由
+ routes.SetupRoutes(r, &routes.Handlers{
+ Auth: authHandler,
+ Photo: photoHandler,
+ Category: categoryHandler,
+ Tag: tagHandler,
+ User: userHandler,
+ }, authMiddleware)
+
+ // 创建HTTP服务器
+ server := &http.Server{
+ Addr: cfg.GetServerAddr(),
+ Handler: r,
+ ReadTimeout: 10 * time.Second,
+ WriteTimeout: 10 * time.Second,
+ MaxHeaderBytes: 1 << 20,
+ }
+
+ // 启动服务器
+ go func() {
+ zapLogger.Info("Starting server", zap.String("addr", server.Addr))
+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ zapLogger.Fatal("Failed to start server", zap.Error(err))
+ }
+ }()
+
+ // 等待中断信号
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+ <-quit
+
+ zapLogger.Info("Shutting down server...")
+
+ // 优雅关闭
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ if err := server.Shutdown(ctx); err != nil {
+ zapLogger.Fatal("Server forced to shutdown", zap.Error(err))
+ }
+
+ zapLogger.Info("Server exited")
+}
\ No newline at end of file
diff --git a/backend/configs/config.yaml b/backend/configs/config.yaml
new file mode 100644
index 0000000..a22f272
--- /dev/null
+++ b/backend/configs/config.yaml
@@ -0,0 +1,76 @@
+app:
+ name: "photography-backend"
+ version: "1.0.0"
+ environment: "development"
+ port: 8080
+ debug: true
+
+database:
+ host: "localhost"
+ port: 5432
+ username: "postgres"
+ password: "password"
+ database: "photography"
+ ssl_mode: "disable"
+ max_open_conns: 100
+ max_idle_conns: 10
+ conn_max_lifetime: 300
+
+redis:
+ host: "localhost"
+ port: 6379
+ password: ""
+ database: 0
+ pool_size: 100
+ min_idle_conns: 10
+
+jwt:
+ secret: "your-secret-key-change-in-production"
+ expires_in: "24h"
+ refresh_expires_in: "168h"
+
+storage:
+ type: "local" # local, s3, minio
+ local:
+ base_path: "./uploads"
+ base_url: "http://localhost:8080/uploads"
+ s3:
+ region: "us-east-1"
+ bucket: "photography-uploads"
+ access_key: ""
+ secret_key: ""
+ endpoint: ""
+
+upload:
+ max_file_size: 52428800 # 50MB
+ allowed_types: ["image/jpeg", "image/png", "image/gif", "image/webp", "image/tiff"]
+ thumbnail_sizes:
+ - name: "small"
+ width: 300
+ height: 300
+ - name: "medium"
+ width: 800
+ height: 600
+ - name: "large"
+ width: 1200
+ height: 900
+
+logger:
+ level: "debug"
+ format: "json"
+ output: "file"
+ filename: "logs/app.log"
+ max_size: 100
+ max_age: 7
+ compress: true
+
+cors:
+ allowed_origins: ["http://localhost:3000", "https://photography.iriver.top"]
+ allowed_methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
+ allowed_headers: ["Origin", "Content-Length", "Content-Type", "Authorization"]
+ allow_credentials: true
+
+rate_limit:
+ enabled: true
+ requests_per_minute: 100
+ burst: 50
\ No newline at end of file
diff --git a/backend/docker-compose.dev.yml b/backend/docker-compose.dev.yml
new file mode 100644
index 0000000..9d156e4
--- /dev/null
+++ b/backend/docker-compose.dev.yml
@@ -0,0 +1,123 @@
+version: '3.8'
+
+services:
+ # PostgreSQL 数据库 (开发环境)
+ postgres:
+ image: postgres:15-alpine
+ container_name: photography_postgres_dev
+ environment:
+ POSTGRES_DB: photography_dev
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: dev_password
+ POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres_dev_data:/var/lib/postgresql/data
+ - ./migrations:/docker-entrypoint-initdb.d
+ networks:
+ - photography_dev_network
+ restart: unless-stopped
+
+ # Redis 缓存 (开发环境)
+ redis:
+ image: redis:7-alpine
+ container_name: photography_redis_dev
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_dev_data:/data
+ networks:
+ - photography_dev_network
+ restart: unless-stopped
+ command: redis-server --appendonly yes
+
+ # 后端 API 服务 (开发环境)
+ backend:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: builder
+ container_name: photography_backend_dev
+ environment:
+ # 数据库配置
+ DB_HOST: postgres
+ DB_PORT: 5432
+ DB_USER: postgres
+ DB_PASSWORD: dev_password
+ DB_NAME: photography_dev
+ DB_SSL_MODE: disable
+
+ # Redis 配置
+ REDIS_HOST: redis
+ REDIS_PORT: 6379
+ REDIS_PASSWORD: ""
+ REDIS_DB: 0
+
+ # JWT 配置
+ JWT_SECRET: dev-jwt-secret-key
+ JWT_EXPIRES_IN: 24h
+
+ # 服务器配置
+ PORT: 8080
+ GIN_MODE: debug
+
+ # 文件上传配置
+ UPLOAD_TYPE: local
+ UPLOAD_PATH: /app/uploads
+ UPLOAD_BASE_URL: http://localhost:8080/uploads
+ UPLOAD_MAX_SIZE: 10485760 # 10MB
+
+ # 日志配置
+ LOG_LEVEL: debug
+ LOG_FORMAT: console
+ ports:
+ - "8080:8080"
+ volumes:
+ - .:/app
+ - upload_dev_data:/app/uploads
+ - go_mod_cache:/go/pkg/mod
+ networks:
+ - photography_dev_network
+ depends_on:
+ - postgres
+ - redis
+ restart: unless-stopped
+ command: go run cmd/server/main.go
+
+ # 管理后台前端 (开发环境)
+ admin:
+ build:
+ context: ../admin
+ dockerfile: Dockerfile.dev
+ container_name: photography_admin_dev
+ environment:
+ VITE_API_BASE_URL: http://localhost:8080/api
+ ports:
+ - "3000:3000"
+ volumes:
+ - ../admin:/app
+ - /app/node_modules
+ networks:
+ - photography_dev_network
+ depends_on:
+ - backend
+ restart: unless-stopped
+ profiles:
+ - admin
+
+# 数据卷
+volumes:
+ postgres_dev_data:
+ driver: local
+ redis_dev_data:
+ driver: local
+ upload_dev_data:
+ driver: local
+ go_mod_cache:
+ driver: local
+
+# 网络
+networks:
+ photography_dev_network:
+ driver: bridge
\ No newline at end of file
diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml
new file mode 100644
index 0000000..6722051
--- /dev/null
+++ b/backend/docker-compose.yml
@@ -0,0 +1,140 @@
+version: '3.8'
+
+services:
+ # PostgreSQL 数据库
+ postgres:
+ image: postgres:15-alpine
+ container_name: photography_postgres
+ environment:
+ POSTGRES_DB: photography
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: ${DB_PASSWORD:-photography_password}
+ POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
+ ports:
+ - "${DB_PORT:-5432}:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ - ./migrations:/docker-entrypoint-initdb.d
+ networks:
+ - photography_network
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ # Redis 缓存
+ redis:
+ image: redis:7-alpine
+ container_name: photography_redis
+ ports:
+ - "${REDIS_PORT:-6379}:6379"
+ volumes:
+ - redis_data:/data
+ networks:
+ - photography_network
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ command: redis-server --appendonly yes
+
+ # 后端 API 服务
+ backend:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: photography_backend
+ environment:
+ # 数据库配置
+ DB_HOST: postgres
+ DB_PORT: 5432
+ DB_USER: postgres
+ DB_PASSWORD: ${DB_PASSWORD:-photography_password}
+ DB_NAME: photography
+ DB_SSL_MODE: disable
+
+ # Redis 配置
+ REDIS_HOST: redis
+ REDIS_PORT: 6379
+ REDIS_PASSWORD: ""
+ REDIS_DB: 0
+
+ # JWT 配置
+ JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
+ JWT_EXPIRES_IN: 24h
+
+ # 服务器配置
+ PORT: 8080
+ GIN_MODE: ${GIN_MODE:-release}
+
+ # 文件上传配置
+ UPLOAD_TYPE: local
+ UPLOAD_PATH: /app/uploads
+ UPLOAD_BASE_URL: http://localhost:8080/uploads
+ UPLOAD_MAX_SIZE: 10485760 # 10MB
+
+ # 日志配置
+ LOG_LEVEL: ${LOG_LEVEL:-info}
+ LOG_FORMAT: json
+ ports:
+ - "${API_PORT:-8080}:8080"
+ volumes:
+ - upload_data:/app/uploads
+ - ./configs:/app/configs:ro
+ networks:
+ - photography_network
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 30s
+
+ # Nginx 反向代理 (生产环境)
+ nginx:
+ image: nginx:alpine
+ container_name: photography_nginx
+ ports:
+ - "${HTTP_PORT:-80}:80"
+ - "${HTTPS_PORT:-443}:443"
+ volumes:
+ - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
+ - ./nginx/conf.d:/etc/nginx/conf.d:ro
+ - upload_data:/var/www/uploads:ro
+ - ./ssl:/etc/nginx/ssl:ro
+ networks:
+ - photography_network
+ depends_on:
+ - backend
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ profiles:
+ - production
+
+# 数据卷
+volumes:
+ postgres_data:
+ driver: local
+ redis_data:
+ driver: local
+ upload_data:
+ driver: local
+
+# 网络
+networks:
+ photography_network:
+ driver: bridge
\ No newline at end of file
diff --git a/backend/go.mod b/backend/go.mod
new file mode 100644
index 0000000..4bd6ea0
--- /dev/null
+++ b/backend/go.mod
@@ -0,0 +1,61 @@
+module photography-backend
+
+go 1.21
+
+require (
+ github.com/gin-gonic/gin v1.9.1
+ github.com/golang-jwt/jwt/v5 v5.2.0
+ github.com/spf13/viper v1.18.2
+ go.uber.org/zap v1.26.0
+ golang.org/x/crypto v0.17.0
+ golang.org/x/text v0.14.0
+ gopkg.in/natefinch/lumberjack.v2 v2.2.1
+ gorm.io/driver/postgres v1.5.4
+ gorm.io/gorm v1.25.5
+)
+
+require (
+ github.com/bytedance/sonic v1.9.1 // indirect
+ github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
+ github.com/fsnotify/fsnotify v1.7.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.2 // indirect
+ github.com/gin-contrib/sse v0.1.0 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.14.0 // indirect
+ github.com/goccy/go-json v0.10.2 // indirect
+ github.com/hashicorp/hcl v1.0.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
+ github.com/jackc/pgx/v5 v5.5.0 // indirect
+ github.com/jackc/puddle/v2 v2.2.1 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.4 // indirect
+ github.com/leodido/go-urn v1.2.4 // indirect
+ github.com/magiconair/properties v1.8.7 // indirect
+ github.com/mattn/go-isatty v0.0.19 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/pelletier/go-toml/v2 v2.1.0 // indirect
+ github.com/sagikazarmark/locafero v0.4.0 // indirect
+ github.com/sagikazarmark/slog-shim v0.1.0 // indirect
+ github.com/sourcegraph/conc v0.3.0 // indirect
+ github.com/spf13/afero v1.11.0 // indirect
+ github.com/spf13/cast v1.6.0 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.2.11 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ golang.org/x/arch v0.3.0 // indirect
+ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
+ golang.org/x/net v0.19.0 // indirect
+ golang.org/x/sync v0.5.0 // indirect
+ golang.org/x/sys v0.15.0 // indirect
+ google.golang.org/protobuf v1.31.0 // indirect
+ gopkg.in/ini.v1 v1.67.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/backend/go.sum b/backend/go.sum
new file mode 100644
index 0000000..7919755
--- /dev/null
+++ b/backend/go.sum
@@ -0,0 +1,153 @@
+github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
+github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
+github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
+github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
+github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
+github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
+github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
+github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
+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=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
+github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
+github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw=
+github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
+github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
+github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+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=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
+github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
+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/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
+github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
+github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
+github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+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/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
+github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
+github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
+github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
+github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
+github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
+github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
+github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
+github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
+github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
+github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
+github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+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=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+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.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
+github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
+go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
+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.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
+go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
+golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
+golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
+golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
+golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
+golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
+golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
+golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
+golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
+golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
+gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
+gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
+gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go
new file mode 100644
index 0000000..3a5c9e4
--- /dev/null
+++ b/backend/internal/api/handlers/auth_handler.go
@@ -0,0 +1,118 @@
+package handlers
+
+import (
+ "net/http"
+ "github.com/gin-gonic/gin"
+ "photography-backend/internal/models"
+ "photography-backend/internal/service/auth"
+ "photography-backend/internal/api/middleware"
+ "photography-backend/pkg/response"
+)
+
+// AuthHandler 认证处理器
+type AuthHandler struct {
+ authService *auth.AuthService
+}
+
+// NewAuthHandler 创建认证处理器
+func NewAuthHandler(authService *auth.AuthService) *AuthHandler {
+ return &AuthHandler{
+ authService: authService,
+ }
+}
+
+// Login 用户登录
+func (h *AuthHandler) Login(c *gin.Context) {
+ var req models.LoginRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, response.Error(http.StatusBadRequest, err.Error()))
+ return
+ }
+
+ loginResp, err := h.authService.Login(&req)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, response.Error(http.StatusUnauthorized, err.Error()))
+ return
+ }
+
+ c.JSON(http.StatusOK, response.Success(loginResp))
+}
+
+// Register 用户注册
+func (h *AuthHandler) Register(c *gin.Context) {
+ var req models.CreateUserRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, response.Error(http.StatusBadRequest, err.Error()))
+ return
+ }
+
+ user, err := h.authService.Register(&req)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, response.Error(http.StatusBadRequest, err.Error()))
+ return
+ }
+
+ c.JSON(http.StatusCreated, response.Success(user))
+}
+
+// RefreshToken 刷新令牌
+func (h *AuthHandler) RefreshToken(c *gin.Context) {
+ var req models.RefreshTokenRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, response.Error(http.StatusBadRequest, err.Error()))
+ return
+ }
+
+ loginResp, err := h.authService.RefreshToken(&req)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, response.Error(http.StatusUnauthorized, err.Error()))
+ return
+ }
+
+ c.JSON(http.StatusOK, response.Success(loginResp))
+}
+
+// GetProfile 获取用户资料
+func (h *AuthHandler) GetProfile(c *gin.Context) {
+ userID, exists := middleware.GetCurrentUser(c)
+ if !exists {
+ c.JSON(http.StatusUnauthorized, response.Error(http.StatusUnauthorized, "User not authenticated"))
+ return
+ }
+
+ user, err := h.authService.GetUserByID(userID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, response.Error(http.StatusInternalServerError, err.Error()))
+ return
+ }
+
+ c.JSON(http.StatusOK, response.Success(user))
+}
+
+// UpdatePassword 更新密码
+func (h *AuthHandler) UpdatePassword(c *gin.Context) {
+ userID, exists := middleware.GetCurrentUser(c)
+ if !exists {
+ c.JSON(http.StatusUnauthorized, response.Error(http.StatusUnauthorized, "User not authenticated"))
+ return
+ }
+
+ var req models.UpdatePasswordRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, response.Error(http.StatusBadRequest, err.Error()))
+ return
+ }
+
+ if err := h.authService.UpdatePassword(userID, &req); err != nil {
+ c.JSON(http.StatusBadRequest, response.Error(http.StatusBadRequest, err.Error()))
+ return
+ }
+
+ c.JSON(http.StatusOK, response.Success(gin.H{"message": "Password updated successfully"}))
+}
+
+// Logout 用户登出
+func (h *AuthHandler) Logout(c *gin.Context) {
+ // 简单实现,实际应用中可能需要将token加入黑名单
+ c.JSON(http.StatusOK, response.Success(gin.H{"message": "Logged out successfully"}))
+}
\ No newline at end of file
diff --git a/backend/internal/api/handlers/category_handler.go b/backend/internal/api/handlers/category_handler.go
new file mode 100644
index 0000000..af48844
--- /dev/null
+++ b/backend/internal/api/handlers/category_handler.go
@@ -0,0 +1,434 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+
+ "photography-backend/internal/models"
+ "photography-backend/internal/service"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+)
+
+type CategoryHandler struct {
+ categoryService *service.CategoryService
+ logger *zap.Logger
+}
+
+func NewCategoryHandler(categoryService *service.CategoryService, logger *zap.Logger) *CategoryHandler {
+ return &CategoryHandler{
+ categoryService: categoryService,
+ logger: logger,
+ }
+}
+
+// GetCategories 获取分类列表
+// @Summary 获取分类列表
+// @Description 获取分类列表,可指定父分类
+// @Tags categories
+// @Accept json
+// @Produce json
+// @Param parent_id query int false "父分类ID"
+// @Success 200 {array} models.Category
+// @Failure 500 {object} models.ErrorResponse
+// @Router /categories [get]
+func (h *CategoryHandler) GetCategories(c *gin.Context) {
+ var parentID *uint
+ if parentIDStr := c.Query("parent_id"); parentIDStr != "" {
+ id, err := strconv.ParseUint(parentIDStr, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid parent_id",
+ Message: "Parent ID must be a valid number",
+ })
+ return
+ }
+ parentIDUint := uint(id)
+ parentID = &parentIDUint
+ }
+
+ categories, err := h.categoryService.GetCategories(c.Request.Context(), parentID)
+ if err != nil {
+ h.logger.Error("Failed to get categories", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get categories",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, categories)
+}
+
+// GetCategoryTree 获取分类树
+// @Summary 获取分类树
+// @Description 获取完整的分类树结构
+// @Tags categories
+// @Accept json
+// @Produce json
+// @Success 200 {array} models.CategoryTree
+// @Failure 500 {object} models.ErrorResponse
+// @Router /categories/tree [get]
+func (h *CategoryHandler) GetCategoryTree(c *gin.Context) {
+ tree, err := h.categoryService.GetCategoryTree(c.Request.Context())
+ if err != nil {
+ h.logger.Error("Failed to get category tree", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get category tree",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, tree)
+}
+
+// GetCategory 获取分类详情
+// @Summary 获取分类详情
+// @Description 根据ID获取分类详情
+// @Tags categories
+// @Accept json
+// @Produce json
+// @Param id path int true "分类ID"
+// @Success 200 {object} models.Category
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 404 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /categories/{id} [get]
+func (h *CategoryHandler) GetCategory(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.ParseUint(idStr, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid category ID",
+ Message: "Category ID must be a valid number",
+ })
+ return
+ }
+
+ category, err := h.categoryService.GetCategoryByID(c.Request.Context(), uint(id))
+ if err != nil {
+ if err.Error() == "category not found" {
+ c.JSON(http.StatusNotFound, models.ErrorResponse{
+ Error: "Category not found",
+ Message: "The requested category does not exist",
+ })
+ return
+ }
+
+ h.logger.Error("Failed to get category", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get category",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, category)
+}
+
+// GetCategoryBySlug 根据slug获取分类
+// @Summary 根据slug获取分类
+// @Description 根据slug获取分类详情
+// @Tags categories
+// @Accept json
+// @Produce json
+// @Param slug path string true "分类slug"
+// @Success 200 {object} models.Category
+// @Failure 404 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /categories/slug/{slug} [get]
+func (h *CategoryHandler) GetCategoryBySlug(c *gin.Context) {
+ slug := c.Param("slug")
+
+ category, err := h.categoryService.GetCategoryBySlug(c.Request.Context(), slug)
+ if err != nil {
+ if err.Error() == "category not found" {
+ c.JSON(http.StatusNotFound, models.ErrorResponse{
+ Error: "Category not found",
+ Message: "The requested category does not exist",
+ })
+ return
+ }
+
+ h.logger.Error("Failed to get category by slug", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get category",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, category)
+}
+
+// CreateCategory 创建分类
+// @Summary 创建分类
+// @Description 创建新的分类
+// @Tags categories
+// @Accept json
+// @Produce json
+// @Param category body models.CreateCategoryRequest true "分类信息"
+// @Success 201 {object} models.Category
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /categories [post]
+func (h *CategoryHandler) CreateCategory(c *gin.Context) {
+ var req models.CreateCategoryRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.logger.Error("Failed to bind JSON", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request body",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ // 验证请求数据
+ if err := h.validateCreateCategoryRequest(&req); err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request data",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ category, err := h.categoryService.CreateCategory(c.Request.Context(), &req)
+ if err != nil {
+ h.logger.Error("Failed to create category", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to create category",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusCreated, category)
+}
+
+// UpdateCategory 更新分类
+// @Summary 更新分类
+// @Description 更新分类信息
+// @Tags categories
+// @Accept json
+// @Produce json
+// @Param id path int true "分类ID"
+// @Param category body models.UpdateCategoryRequest true "分类信息"
+// @Success 200 {object} models.Category
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 404 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /categories/{id} [put]
+func (h *CategoryHandler) UpdateCategory(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.ParseUint(idStr, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid category ID",
+ Message: "Category ID must be a valid number",
+ })
+ return
+ }
+
+ var req models.UpdateCategoryRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.logger.Error("Failed to bind JSON", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request body",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ category, err := h.categoryService.UpdateCategory(c.Request.Context(), uint(id), &req)
+ if err != nil {
+ if err.Error() == "category not found" {
+ c.JSON(http.StatusNotFound, models.ErrorResponse{
+ Error: "Category not found",
+ Message: "The requested category does not exist",
+ })
+ return
+ }
+
+ h.logger.Error("Failed to update category", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to update category",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, category)
+}
+
+// DeleteCategory 删除分类
+// @Summary 删除分类
+// @Description 删除分类
+// @Tags categories
+// @Accept json
+// @Produce json
+// @Param id path int true "分类ID"
+// @Success 204 "No Content"
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 404 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /categories/{id} [delete]
+func (h *CategoryHandler) DeleteCategory(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.ParseUint(idStr, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid category ID",
+ Message: "Category ID must be a valid number",
+ })
+ return
+ }
+
+ err = h.categoryService.DeleteCategory(c.Request.Context(), uint(id))
+ if err != nil {
+ if err.Error() == "category not found" {
+ c.JSON(http.StatusNotFound, models.ErrorResponse{
+ Error: "Category not found",
+ Message: "The requested category does not exist",
+ })
+ return
+ }
+
+ h.logger.Error("Failed to delete category", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to delete category",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.Status(http.StatusNoContent)
+}
+
+// ReorderCategories 重新排序分类
+// @Summary 重新排序分类
+// @Description 重新排序分类
+// @Tags categories
+// @Accept json
+// @Produce json
+// @Param request body models.ReorderCategoriesRequest true "排序请求"
+// @Success 200 {object} models.SuccessResponse
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /categories/reorder [post]
+func (h *CategoryHandler) ReorderCategories(c *gin.Context) {
+ var req models.ReorderCategoriesRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.logger.Error("Failed to bind JSON", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request body",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ if len(req.CategoryIDs) == 0 {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request",
+ Message: "No category IDs provided",
+ })
+ return
+ }
+
+ err := h.categoryService.ReorderCategories(c.Request.Context(), req.ParentID, req.CategoryIDs)
+ if err != nil {
+ h.logger.Error("Failed to reorder categories", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to reorder categories",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, models.SuccessResponse{
+ Message: "Categories reordered successfully",
+ })
+}
+
+// GetCategoryStats 获取分类统计信息
+// @Summary 获取分类统计信息
+// @Description 获取分类统计信息
+// @Tags categories
+// @Accept json
+// @Produce json
+// @Success 200 {object} models.CategoryStats
+// @Failure 500 {object} models.ErrorResponse
+// @Router /categories/stats [get]
+func (h *CategoryHandler) GetCategoryStats(c *gin.Context) {
+ stats, err := h.categoryService.GetCategoryStats(c.Request.Context())
+ if err != nil {
+ h.logger.Error("Failed to get category stats", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get category stats",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, stats)
+}
+
+// GenerateSlug 生成分类slug
+// @Summary 生成分类slug
+// @Description 根据分类名称生成唯一的slug
+// @Tags categories
+// @Accept json
+// @Produce json
+// @Param request body models.GenerateSlugRequest true "生成slug请求"
+// @Success 200 {object} models.GenerateSlugResponse
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /categories/generate-slug [post]
+func (h *CategoryHandler) GenerateSlug(c *gin.Context) {
+ var req models.GenerateSlugRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.logger.Error("Failed to bind JSON", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request body",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ if req.Name == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request",
+ Message: "Name is required",
+ })
+ return
+ }
+
+ slug, err := h.categoryService.GenerateSlug(c.Request.Context(), req.Name)
+ if err != nil {
+ h.logger.Error("Failed to generate slug", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to generate slug",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, models.GenerateSlugResponse{
+ Slug: slug,
+ })
+}
+
+// validateCreateCategoryRequest 验证创建分类请求
+func (h *CategoryHandler) validateCreateCategoryRequest(req *models.CreateCategoryRequest) error {
+ if req.Name == "" {
+ return errors.New("name is required")
+ }
+
+ if req.Slug == "" {
+ return errors.New("slug is required")
+ }
+
+ return nil
+}
\ No newline at end of file
diff --git a/backend/internal/api/handlers/photo_handler.go b/backend/internal/api/handlers/photo_handler.go
new file mode 100644
index 0000000..ccde570
--- /dev/null
+++ b/backend/internal/api/handlers/photo_handler.go
@@ -0,0 +1,479 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "photography-backend/internal/models"
+ "photography-backend/internal/service"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+)
+
+type PhotoHandler struct {
+ photoService *service.PhotoService
+ logger *zap.Logger
+}
+
+func NewPhotoHandler(photoService *service.PhotoService, logger *zap.Logger) *PhotoHandler {
+ return &PhotoHandler{
+ photoService: photoService,
+ logger: logger,
+ }
+}
+
+// GetPhotos 获取照片列表
+// @Summary 获取照片列表
+// @Description 获取照片列表,支持分页、搜索、过滤
+// @Tags photos
+// @Accept json
+// @Produce json
+// @Param page query int false "页码"
+// @Param limit query int false "每页数量"
+// @Param search query string false "搜索关键词"
+// @Param status query string false "状态筛选"
+// @Param category_id query int false "分类ID"
+// @Param tags query string false "标签列表(逗号分隔)"
+// @Param start_date query string false "开始日期"
+// @Param end_date query string false "结束日期"
+// @Param sort_by query string false "排序字段"
+// @Param sort_order query string false "排序方向"
+// @Success 200 {object} service.PhotoListResponse
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /photos [get]
+func (h *PhotoHandler) GetPhotos(c *gin.Context) {
+ var params service.PhotoListParams
+
+ // 解析查询参数
+ if err := c.ShouldBindQuery(¶ms); err != nil {
+ h.logger.Error("Failed to bind query params", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid query parameters",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ // 解析标签参数
+ if tagsStr := c.Query("tags"); tagsStr != "" {
+ params.Tags = strings.Split(tagsStr, ",")
+ }
+
+ // 调用服务层
+ result, err := h.photoService.GetPhotos(c.Request.Context(), params)
+ if err != nil {
+ h.logger.Error("Failed to get photos", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get photos",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, result)
+}
+
+// GetPhoto 获取照片详情
+// @Summary 获取照片详情
+// @Description 根据ID获取照片详情
+// @Tags photos
+// @Accept json
+// @Produce json
+// @Param id path int true "照片ID"
+// @Success 200 {object} models.Photo
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 404 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /photos/{id} [get]
+func (h *PhotoHandler) GetPhoto(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.ParseUint(idStr, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid photo ID",
+ Message: "Photo ID must be a valid number",
+ })
+ return
+ }
+
+ photo, err := h.photoService.GetPhotoByID(c.Request.Context(), uint(id))
+ if err != nil {
+ if err.Error() == "photo not found" {
+ c.JSON(http.StatusNotFound, models.ErrorResponse{
+ Error: "Photo not found",
+ Message: "The requested photo does not exist",
+ })
+ return
+ }
+
+ h.logger.Error("Failed to get photo", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get photo",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, photo)
+}
+
+// CreatePhoto 创建照片
+// @Summary 创建照片
+// @Description 创建新的照片记录
+// @Tags photos
+// @Accept json
+// @Produce json
+// @Param photo body models.CreatePhotoRequest true "照片信息"
+// @Success 201 {object} models.Photo
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /photos [post]
+func (h *PhotoHandler) CreatePhoto(c *gin.Context) {
+ var req models.CreatePhotoRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.logger.Error("Failed to bind JSON", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request body",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ // 验证请求数据
+ if err := h.validateCreatePhotoRequest(&req); err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request data",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ photo, err := h.photoService.CreatePhoto(c.Request.Context(), &req)
+ if err != nil {
+ h.logger.Error("Failed to create photo", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to create photo",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusCreated, photo)
+}
+
+// UpdatePhoto 更新照片
+// @Summary 更新照片
+// @Description 更新照片信息
+// @Tags photos
+// @Accept json
+// @Produce json
+// @Param id path int true "照片ID"
+// @Param photo body models.UpdatePhotoRequest true "照片信息"
+// @Success 200 {object} models.Photo
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 404 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /photos/{id} [put]
+func (h *PhotoHandler) UpdatePhoto(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.ParseUint(idStr, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid photo ID",
+ Message: "Photo ID must be a valid number",
+ })
+ return
+ }
+
+ var req models.UpdatePhotoRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.logger.Error("Failed to bind JSON", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request body",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ photo, err := h.photoService.UpdatePhoto(c.Request.Context(), uint(id), &req)
+ if err != nil {
+ if err.Error() == "photo not found" {
+ c.JSON(http.StatusNotFound, models.ErrorResponse{
+ Error: "Photo not found",
+ Message: "The requested photo does not exist",
+ })
+ return
+ }
+
+ h.logger.Error("Failed to update photo", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to update photo",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, photo)
+}
+
+// DeletePhoto 删除照片
+// @Summary 删除照片
+// @Description 删除照片
+// @Tags photos
+// @Accept json
+// @Produce json
+// @Param id path int true "照片ID"
+// @Success 204 "No Content"
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 404 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /photos/{id} [delete]
+func (h *PhotoHandler) DeletePhoto(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.ParseUint(idStr, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid photo ID",
+ Message: "Photo ID must be a valid number",
+ })
+ return
+ }
+
+ err = h.photoService.DeletePhoto(c.Request.Context(), uint(id))
+ if err != nil {
+ if err.Error() == "photo not found" {
+ c.JSON(http.StatusNotFound, models.ErrorResponse{
+ Error: "Photo not found",
+ Message: "The requested photo does not exist",
+ })
+ return
+ }
+
+ h.logger.Error("Failed to delete photo", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to delete photo",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.Status(http.StatusNoContent)
+}
+
+// UploadPhoto 上传照片
+// @Summary 上传照片
+// @Description 上传照片文件并创建记录
+// @Tags photos
+// @Accept multipart/form-data
+// @Produce json
+// @Param file formData file true "照片文件"
+// @Param title formData string false "标题"
+// @Param description formData string false "描述"
+// @Param status formData string false "状态"
+// @Param category_ids formData string false "分类ID列表(逗号分隔)"
+// @Param tag_ids formData string false "标签ID列表(逗号分隔)"
+// @Success 201 {object} models.Photo
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /photos/upload [post]
+func (h *PhotoHandler) UploadPhoto(c *gin.Context) {
+ // 获取上传的文件
+ file, header, err := c.Request.FormFile("file")
+ if err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "No file uploaded",
+ Message: "Please select a file to upload",
+ })
+ return
+ }
+ defer file.Close()
+
+ // 构建创建请求
+ req := &models.CreatePhotoRequest{
+ Title: c.PostForm("title"),
+ Description: c.PostForm("description"),
+ Status: c.PostForm("status"),
+ }
+
+ // 如果未指定状态,默认为草稿
+ if req.Status == "" {
+ req.Status = "draft"
+ }
+
+ // 解析分类ID
+ if categoryIDsStr := c.PostForm("category_ids"); categoryIDsStr != "" {
+ categoryIDStrs := strings.Split(categoryIDsStr, ",")
+ for _, idStr := range categoryIDStrs {
+ if id, err := strconv.ParseUint(strings.TrimSpace(idStr), 10, 32); err == nil {
+ req.CategoryIDs = append(req.CategoryIDs, uint(id))
+ }
+ }
+ }
+
+ // 解析标签ID
+ if tagIDsStr := c.PostForm("tag_ids"); tagIDsStr != "" {
+ tagIDStrs := strings.Split(tagIDsStr, ",")
+ for _, idStr := range tagIDStrs {
+ if id, err := strconv.ParseUint(strings.TrimSpace(idStr), 10, 32); err == nil {
+ req.TagIDs = append(req.TagIDs, uint(id))
+ }
+ }
+ }
+
+ // 上传照片
+ photo, err := h.photoService.UploadPhoto(c.Request.Context(), file, header, req)
+ if err != nil {
+ h.logger.Error("Failed to upload photo", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to upload photo",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusCreated, photo)
+}
+
+// BatchUpdatePhotos 批量更新照片
+// @Summary 批量更新照片
+// @Description 批量更新照片信息
+// @Tags photos
+// @Accept json
+// @Produce json
+// @Param request body models.BatchUpdatePhotosRequest true "批量更新请求"
+// @Success 200 {object} models.SuccessResponse
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /photos/batch/update [post]
+func (h *PhotoHandler) BatchUpdatePhotos(c *gin.Context) {
+ var req models.BatchUpdatePhotosRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.logger.Error("Failed to bind JSON", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request body",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ if len(req.IDs) == 0 {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request",
+ Message: "No photo IDs provided",
+ })
+ return
+ }
+
+ err := h.photoService.BatchUpdatePhotos(c.Request.Context(), req.IDs, &req)
+ if err != nil {
+ h.logger.Error("Failed to batch update photos", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to batch update photos",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, models.SuccessResponse{
+ Message: "Photos updated successfully",
+ })
+}
+
+// BatchDeletePhotos 批量删除照片
+// @Summary 批量删除照片
+// @Description 批量删除照片
+// @Tags photos
+// @Accept json
+// @Produce json
+// @Param request body models.BatchDeleteRequest true "批量删除请求"
+// @Success 200 {object} models.SuccessResponse
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /photos/batch/delete [post]
+func (h *PhotoHandler) BatchDeletePhotos(c *gin.Context) {
+ var req models.BatchDeleteRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.logger.Error("Failed to bind JSON", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request body",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ if len(req.IDs) == 0 {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request",
+ Message: "No photo IDs provided",
+ })
+ return
+ }
+
+ err := h.photoService.BatchDeletePhotos(c.Request.Context(), req.IDs)
+ if err != nil {
+ h.logger.Error("Failed to batch delete photos", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to batch delete photos",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, models.SuccessResponse{
+ Message: "Photos deleted successfully",
+ })
+}
+
+// GetPhotoStats 获取照片统计信息
+// @Summary 获取照片统计信息
+// @Description 获取照片统计信息
+// @Tags photos
+// @Accept json
+// @Produce json
+// @Success 200 {object} models.PhotoStats
+// @Failure 500 {object} models.ErrorResponse
+// @Router /photos/stats [get]
+func (h *PhotoHandler) GetPhotoStats(c *gin.Context) {
+ stats, err := h.photoService.GetPhotoStats(c.Request.Context())
+ if err != nil {
+ h.logger.Error("Failed to get photo stats", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get photo stats",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, stats)
+}
+
+// validateCreatePhotoRequest 验证创建照片请求
+func (h *PhotoHandler) validateCreatePhotoRequest(req *models.CreatePhotoRequest) error {
+ if req.Title == "" {
+ return errors.New("title is required")
+ }
+
+ if req.Status == "" {
+ req.Status = "draft"
+ }
+
+ // 验证状态值
+ validStatuses := []string{"draft", "published", "archived", "processing"}
+ isValidStatus := false
+ for _, status := range validStatuses {
+ if req.Status == status {
+ isValidStatus = true
+ break
+ }
+ }
+
+ if !isValidStatus {
+ return errors.New("invalid status value")
+ }
+
+ return nil
+}
\ No newline at end of file
diff --git a/backend/internal/api/handlers/tag_handler.go b/backend/internal/api/handlers/tag_handler.go
new file mode 100644
index 0000000..3161170
--- /dev/null
+++ b/backend/internal/api/handlers/tag_handler.go
@@ -0,0 +1,535 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+
+ "photography-backend/internal/models"
+ "photography-backend/internal/service"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+)
+
+type TagHandler struct {
+ tagService *service.TagService
+ logger *zap.Logger
+}
+
+func NewTagHandler(tagService *service.TagService, logger *zap.Logger) *TagHandler {
+ return &TagHandler{
+ tagService: tagService,
+ logger: logger,
+ }
+}
+
+// GetTags 获取标签列表
+// @Summary 获取标签列表
+// @Description 获取标签列表,支持分页、搜索、过滤
+// @Tags tags
+// @Accept json
+// @Produce json
+// @Param page query int false "页码"
+// @Param limit query int false "每页数量"
+// @Param search query string false "搜索关键词"
+// @Param is_active query bool false "是否激活"
+// @Param sort_by query string false "排序字段"
+// @Param sort_order query string false "排序方向"
+// @Success 200 {object} service.TagListResponse
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /tags [get]
+func (h *TagHandler) GetTags(c *gin.Context) {
+ var params service.TagListParams
+
+ // 解析查询参数
+ if err := c.ShouldBindQuery(¶ms); err != nil {
+ h.logger.Error("Failed to bind query params", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid query parameters",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ // 调用服务层
+ result, err := h.tagService.GetTags(c.Request.Context(), params)
+ if err != nil {
+ h.logger.Error("Failed to get tags", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get tags",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, result)
+}
+
+// GetAllTags 获取所有活跃标签
+// @Summary 获取所有活跃标签
+// @Description 获取所有活跃标签(用于选择器)
+// @Tags tags
+// @Accept json
+// @Produce json
+// @Success 200 {array} models.Tag
+// @Failure 500 {object} models.ErrorResponse
+// @Router /tags/all [get]
+func (h *TagHandler) GetAllTags(c *gin.Context) {
+ tags, err := h.tagService.GetAllTags(c.Request.Context())
+ if err != nil {
+ h.logger.Error("Failed to get all tags", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get all tags",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, tags)
+}
+
+// GetTag 获取标签详情
+// @Summary 获取标签详情
+// @Description 根据ID获取标签详情
+// @Tags tags
+// @Accept json
+// @Produce json
+// @Param id path int true "标签ID"
+// @Success 200 {object} models.Tag
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 404 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /tags/{id} [get]
+func (h *TagHandler) GetTag(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.ParseUint(idStr, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid tag ID",
+ Message: "Tag ID must be a valid number",
+ })
+ return
+ }
+
+ tag, err := h.tagService.GetTagByID(c.Request.Context(), uint(id))
+ if err != nil {
+ if err.Error() == "tag not found" {
+ c.JSON(http.StatusNotFound, models.ErrorResponse{
+ Error: "Tag not found",
+ Message: "The requested tag does not exist",
+ })
+ return
+ }
+
+ h.logger.Error("Failed to get tag", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get tag",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, tag)
+}
+
+// GetTagBySlug 根据slug获取标签
+// @Summary 根据slug获取标签
+// @Description 根据slug获取标签详情
+// @Tags tags
+// @Accept json
+// @Produce json
+// @Param slug path string true "标签slug"
+// @Success 200 {object} models.Tag
+// @Failure 404 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /tags/slug/{slug} [get]
+func (h *TagHandler) GetTagBySlug(c *gin.Context) {
+ slug := c.Param("slug")
+
+ tag, err := h.tagService.GetTagBySlug(c.Request.Context(), slug)
+ if err != nil {
+ if err.Error() == "tag not found" {
+ c.JSON(http.StatusNotFound, models.ErrorResponse{
+ Error: "Tag not found",
+ Message: "The requested tag does not exist",
+ })
+ return
+ }
+
+ h.logger.Error("Failed to get tag by slug", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get tag",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, tag)
+}
+
+// CreateTag 创建标签
+// @Summary 创建标签
+// @Description 创建新的标签
+// @Tags tags
+// @Accept json
+// @Produce json
+// @Param tag body models.CreateTagRequest true "标签信息"
+// @Success 201 {object} models.Tag
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /tags [post]
+func (h *TagHandler) CreateTag(c *gin.Context) {
+ var req models.CreateTagRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.logger.Error("Failed to bind JSON", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request body",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ // 验证请求数据
+ if err := h.validateCreateTagRequest(&req); err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request data",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ tag, err := h.tagService.CreateTag(c.Request.Context(), &req)
+ if err != nil {
+ h.logger.Error("Failed to create tag", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to create tag",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusCreated, tag)
+}
+
+// UpdateTag 更新标签
+// @Summary 更新标签
+// @Description 更新标签信息
+// @Tags tags
+// @Accept json
+// @Produce json
+// @Param id path int true "标签ID"
+// @Param tag body models.UpdateTagRequest true "标签信息"
+// @Success 200 {object} models.Tag
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 404 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /tags/{id} [put]
+func (h *TagHandler) UpdateTag(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.ParseUint(idStr, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid tag ID",
+ Message: "Tag ID must be a valid number",
+ })
+ return
+ }
+
+ var req models.UpdateTagRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.logger.Error("Failed to bind JSON", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request body",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ tag, err := h.tagService.UpdateTag(c.Request.Context(), uint(id), &req)
+ if err != nil {
+ if err.Error() == "tag not found" {
+ c.JSON(http.StatusNotFound, models.ErrorResponse{
+ Error: "Tag not found",
+ Message: "The requested tag does not exist",
+ })
+ return
+ }
+
+ h.logger.Error("Failed to update tag", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to update tag",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, tag)
+}
+
+// DeleteTag 删除标签
+// @Summary 删除标签
+// @Description 删除标签
+// @Tags tags
+// @Accept json
+// @Produce json
+// @Param id path int true "标签ID"
+// @Success 204 "No Content"
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 404 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /tags/{id} [delete]
+func (h *TagHandler) DeleteTag(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.ParseUint(idStr, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid tag ID",
+ Message: "Tag ID must be a valid number",
+ })
+ return
+ }
+
+ err = h.tagService.DeleteTag(c.Request.Context(), uint(id))
+ if err != nil {
+ if err.Error() == "tag not found" {
+ c.JSON(http.StatusNotFound, models.ErrorResponse{
+ Error: "Tag not found",
+ Message: "The requested tag does not exist",
+ })
+ return
+ }
+
+ h.logger.Error("Failed to delete tag", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to delete tag",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.Status(http.StatusNoContent)
+}
+
+// BatchDeleteTags 批量删除标签
+// @Summary 批量删除标签
+// @Description 批量删除标签
+// @Tags tags
+// @Accept json
+// @Produce json
+// @Param request body models.BatchDeleteRequest true "批量删除请求"
+// @Success 200 {object} models.SuccessResponse
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /tags/batch/delete [post]
+func (h *TagHandler) BatchDeleteTags(c *gin.Context) {
+ var req models.BatchDeleteRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.logger.Error("Failed to bind JSON", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request body",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ if len(req.IDs) == 0 {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request",
+ Message: "No tag IDs provided",
+ })
+ return
+ }
+
+ err := h.tagService.BatchDeleteTags(c.Request.Context(), req.IDs)
+ if err != nil {
+ h.logger.Error("Failed to batch delete tags", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to batch delete tags",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, models.SuccessResponse{
+ Message: "Tags deleted successfully",
+ })
+}
+
+// GetPopularTags 获取热门标签
+// @Summary 获取热门标签
+// @Description 获取热门标签(按使用次数排序)
+// @Tags tags
+// @Accept json
+// @Produce json
+// @Param limit query int false "限制数量"
+// @Success 200 {array} models.TagWithCount
+// @Failure 500 {object} models.ErrorResponse
+// @Router /tags/popular [get]
+func (h *TagHandler) GetPopularTags(c *gin.Context) {
+ limit := 10
+ if limitStr := c.Query("limit"); limitStr != "" {
+ if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
+ limit = parsedLimit
+ }
+ }
+
+ tags, err := h.tagService.GetPopularTags(c.Request.Context(), limit)
+ if err != nil {
+ h.logger.Error("Failed to get popular tags", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get popular tags",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, tags)
+}
+
+// GetTagCloud 获取标签云
+// @Summary 获取标签云
+// @Description 获取标签云数据
+// @Tags tags
+// @Accept json
+// @Produce json
+// @Success 200 {array} models.TagCloudItem
+// @Failure 500 {object} models.ErrorResponse
+// @Router /tags/cloud [get]
+func (h *TagHandler) GetTagCloud(c *gin.Context) {
+ cloud, err := h.tagService.GetTagCloud(c.Request.Context())
+ if err != nil {
+ h.logger.Error("Failed to get tag cloud", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get tag cloud",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, cloud)
+}
+
+// GetTagStats 获取标签统计信息
+// @Summary 获取标签统计信息
+// @Description 获取标签统计信息
+// @Tags tags
+// @Accept json
+// @Produce json
+// @Success 200 {object} models.TagStats
+// @Failure 500 {object} models.ErrorResponse
+// @Router /tags/stats [get]
+func (h *TagHandler) GetTagStats(c *gin.Context) {
+ stats, err := h.tagService.GetTagStats(c.Request.Context())
+ if err != nil {
+ h.logger.Error("Failed to get tag stats", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get tag stats",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, stats)
+}
+
+// SearchTags 搜索标签
+// @Summary 搜索标签
+// @Description 搜索标签(用于自动完成)
+// @Tags tags
+// @Accept json
+// @Produce json
+// @Param q query string true "搜索关键词"
+// @Param limit query int false "限制数量"
+// @Success 200 {array} models.Tag
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /tags/search [get]
+func (h *TagHandler) SearchTags(c *gin.Context) {
+ query := c.Query("q")
+ if query == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid query",
+ Message: "Search query is required",
+ })
+ return
+ }
+
+ limit := 10
+ if limitStr := c.Query("limit"); limitStr != "" {
+ if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
+ limit = parsedLimit
+ }
+ }
+
+ tags, err := h.tagService.SearchTags(c.Request.Context(), query, limit)
+ if err != nil {
+ h.logger.Error("Failed to search tags", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to search tags",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, tags)
+}
+
+// GenerateSlug 生成标签slug
+// @Summary 生成标签slug
+// @Description 根据标签名称生成唯一的slug
+// @Tags tags
+// @Accept json
+// @Produce json
+// @Param request body models.GenerateSlugRequest true "生成slug请求"
+// @Success 200 {object} models.GenerateSlugResponse
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /tags/generate-slug [post]
+func (h *TagHandler) GenerateSlug(c *gin.Context) {
+ var req models.GenerateSlugRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.logger.Error("Failed to bind JSON", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request body",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ if req.Name == "" {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request",
+ Message: "Name is required",
+ })
+ return
+ }
+
+ slug, err := h.tagService.GenerateSlug(c.Request.Context(), req.Name)
+ if err != nil {
+ h.logger.Error("Failed to generate slug", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to generate slug",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, models.GenerateSlugResponse{
+ Slug: slug,
+ })
+}
+
+// validateCreateTagRequest 验证创建标签请求
+func (h *TagHandler) validateCreateTagRequest(req *models.CreateTagRequest) error {
+ if req.Name == "" {
+ return errors.New("name is required")
+ }
+
+ if req.Slug == "" {
+ return errors.New("slug is required")
+ }
+
+ return nil
+}
\ No newline at end of file
diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go
new file mode 100644
index 0000000..dd4b676
--- /dev/null
+++ b/backend/internal/api/handlers/user_handler.go
@@ -0,0 +1,409 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+
+ "photography-backend/internal/models"
+ "photography-backend/internal/service"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+)
+
+type UserHandler struct {
+ userService *service.UserService
+ logger *zap.Logger
+}
+
+func NewUserHandler(userService *service.UserService, logger *zap.Logger) *UserHandler {
+ return &UserHandler{
+ userService: userService,
+ logger: logger,
+ }
+}
+
+// GetCurrentUser 获取当前用户信息
+// @Summary 获取当前用户信息
+// @Description 获取当前登录用户的详细信息
+// @Tags users
+// @Accept json
+// @Produce json
+// @Success 200 {object} models.UserResponse
+// @Failure 401 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /me [get]
+func (h *UserHandler) GetCurrentUser(c *gin.Context) {
+ userID := c.GetUint("user_id")
+
+ user, err := h.userService.GetUserByID(c.Request.Context(), userID)
+ if err != nil {
+ h.logger.Error("Failed to get current user", zap.Error(err), zap.Uint("user_id", userID))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get user information",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ userResponse := &models.UserResponse{
+ ID: user.ID,
+ Username: user.Username,
+ Email: user.Email,
+ Role: user.Role,
+ IsActive: user.IsActive,
+ CreatedAt: user.CreatedAt,
+ UpdatedAt: user.UpdatedAt,
+ }
+
+ c.JSON(http.StatusOK, userResponse)
+}
+
+// UpdateCurrentUser 更新当前用户信息
+// @Summary 更新当前用户信息
+// @Description 更新当前登录用户的个人信息
+// @Tags users
+// @Accept json
+// @Produce json
+// @Param user body models.UpdateCurrentUserRequest true "用户信息"
+// @Success 200 {object} models.UserResponse
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /me [put]
+func (h *UserHandler) UpdateCurrentUser(c *gin.Context) {
+ userID := c.GetUint("user_id")
+
+ var req models.UpdateCurrentUserRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.logger.Error("Failed to bind JSON", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request body",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ user, err := h.userService.UpdateCurrentUser(c.Request.Context(), userID, &req)
+ if err != nil {
+ h.logger.Error("Failed to update current user", zap.Error(err), zap.Uint("user_id", userID))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to update user information",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ userResponse := &models.UserResponse{
+ ID: user.ID,
+ Username: user.Username,
+ Email: user.Email,
+ Role: user.Role,
+ IsActive: user.IsActive,
+ CreatedAt: user.CreatedAt,
+ UpdatedAt: user.UpdatedAt,
+ }
+
+ c.JSON(http.StatusOK, userResponse)
+}
+
+// GetUsers 获取用户列表 (管理员功能)
+// @Summary 获取用户列表
+// @Description 获取系统中所有用户列表
+// @Tags admin
+// @Accept json
+// @Produce json
+// @Param page query int false "页码"
+// @Param limit query int false "每页数量"
+// @Param search query string false "搜索关键词"
+// @Success 200 {object} service.UserListResponse
+// @Failure 403 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /admin/users [get]
+func (h *UserHandler) GetUsers(c *gin.Context) {
+ var params service.UserListParams
+
+ // 解析查询参数
+ if err := c.ShouldBindQuery(¶ms); err != nil {
+ h.logger.Error("Failed to bind query params", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid query parameters",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ result, err := h.userService.GetUsers(c.Request.Context(), params)
+ if err != nil {
+ h.logger.Error("Failed to get users", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get users",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, result)
+}
+
+// GetUser 获取用户详情 (管理员功能)
+// @Summary 获取用户详情
+// @Description 根据ID获取用户详情
+// @Tags admin
+// @Accept json
+// @Produce json
+// @Param id path int true "用户ID"
+// @Success 200 {object} models.UserResponse
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 404 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /admin/users/{id} [get]
+func (h *UserHandler) GetUser(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.ParseUint(idStr, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid user ID",
+ Message: "User ID must be a valid number",
+ })
+ return
+ }
+
+ user, err := h.userService.GetUserByID(c.Request.Context(), uint(id))
+ if err != nil {
+ if err.Error() == "user not found" {
+ c.JSON(http.StatusNotFound, models.ErrorResponse{
+ Error: "User not found",
+ Message: "The requested user does not exist",
+ })
+ return
+ }
+
+ h.logger.Error("Failed to get user", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to get user",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ userResponse := &models.UserResponse{
+ ID: user.ID,
+ Username: user.Username,
+ Email: user.Email,
+ Role: user.Role,
+ IsActive: user.IsActive,
+ CreatedAt: user.CreatedAt,
+ UpdatedAt: user.UpdatedAt,
+ }
+
+ c.JSON(http.StatusOK, userResponse)
+}
+
+// CreateUser 创建用户 (管理员功能)
+// @Summary 创建用户
+// @Description 创建新用户
+// @Tags admin
+// @Accept json
+// @Produce json
+// @Param user body models.CreateUserRequest true "用户信息"
+// @Success 201 {object} models.UserResponse
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /admin/users [post]
+func (h *UserHandler) CreateUser(c *gin.Context) {
+ var req models.CreateUserRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.logger.Error("Failed to bind JSON", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request body",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ // 验证请求数据
+ if err := h.validateCreateUserRequest(&req); err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request data",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ user, err := h.userService.CreateUser(c.Request.Context(), &req)
+ if err != nil {
+ h.logger.Error("Failed to create user", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to create user",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ userResponse := &models.UserResponse{
+ ID: user.ID,
+ Username: user.Username,
+ Email: user.Email,
+ Role: user.Role,
+ IsActive: user.IsActive,
+ CreatedAt: user.CreatedAt,
+ UpdatedAt: user.UpdatedAt,
+ }
+
+ c.JSON(http.StatusCreated, userResponse)
+}
+
+// UpdateUser 更新用户 (管理员功能)
+// @Summary 更新用户
+// @Description 更新用户信息
+// @Tags admin
+// @Accept json
+// @Produce json
+// @Param id path int true "用户ID"
+// @Param user body models.UpdateUserRequest true "用户信息"
+// @Success 200 {object} models.UserResponse
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 404 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /admin/users/{id} [put]
+func (h *UserHandler) UpdateUser(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.ParseUint(idStr, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid user ID",
+ Message: "User ID must be a valid number",
+ })
+ return
+ }
+
+ var req models.UpdateUserRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ h.logger.Error("Failed to bind JSON", zap.Error(err))
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid request body",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ user, err := h.userService.UpdateUser(c.Request.Context(), uint(id), &req)
+ if err != nil {
+ if err.Error() == "user not found" {
+ c.JSON(http.StatusNotFound, models.ErrorResponse{
+ Error: "User not found",
+ Message: "The requested user does not exist",
+ })
+ return
+ }
+
+ h.logger.Error("Failed to update user", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to update user",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ userResponse := &models.UserResponse{
+ ID: user.ID,
+ Username: user.Username,
+ Email: user.Email,
+ Role: user.Role,
+ IsActive: user.IsActive,
+ CreatedAt: user.CreatedAt,
+ UpdatedAt: user.UpdatedAt,
+ }
+
+ c.JSON(http.StatusOK, userResponse)
+}
+
+// DeleteUser 删除用户 (管理员功能)
+// @Summary 删除用户
+// @Description 删除用户
+// @Tags admin
+// @Accept json
+// @Produce json
+// @Param id path int true "用户ID"
+// @Success 204 "No Content"
+// @Failure 400 {object} models.ErrorResponse
+// @Failure 404 {object} models.ErrorResponse
+// @Failure 500 {object} models.ErrorResponse
+// @Router /admin/users/{id} [delete]
+func (h *UserHandler) DeleteUser(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.ParseUint(idStr, 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Invalid user ID",
+ Message: "User ID must be a valid number",
+ })
+ return
+ }
+
+ // 防止删除自己
+ currentUserID := c.GetUint("user_id")
+ if uint(id) == currentUserID {
+ c.JSON(http.StatusBadRequest, models.ErrorResponse{
+ Error: "Cannot delete yourself",
+ Message: "You cannot delete your own account",
+ })
+ return
+ }
+
+ err = h.userService.DeleteUser(c.Request.Context(), uint(id))
+ if err != nil {
+ if err.Error() == "user not found" {
+ c.JSON(http.StatusNotFound, models.ErrorResponse{
+ Error: "User not found",
+ Message: "The requested user does not exist",
+ })
+ return
+ }
+
+ h.logger.Error("Failed to delete user", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, models.ErrorResponse{
+ Error: "Failed to delete user",
+ Message: err.Error(),
+ })
+ return
+ }
+
+ c.Status(http.StatusNoContent)
+}
+
+// validateCreateUserRequest 验证创建用户请求
+func (h *UserHandler) validateCreateUserRequest(req *models.CreateUserRequest) error {
+ if req.Username == "" {
+ return errors.New("username is required")
+ }
+
+ if req.Email == "" {
+ return errors.New("email is required")
+ }
+
+ if req.Password == "" {
+ return errors.New("password is required")
+ }
+
+ if req.Role == "" {
+ req.Role = "user"
+ }
+
+ // 验证角色
+ validRoles := []string{"user", "editor", "admin"}
+ isValidRole := false
+ for _, role := range validRoles {
+ if req.Role == role {
+ isValidRole = true
+ break
+ }
+ }
+
+ if !isValidRole {
+ return errors.New("invalid role value")
+ }
+
+ return nil
+}
\ No newline at end of file
diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go
new file mode 100644
index 0000000..7746c01
--- /dev/null
+++ b/backend/internal/api/middleware/auth.go
@@ -0,0 +1,217 @@
+package middleware
+
+import (
+ "net/http"
+ "strings"
+ "github.com/gin-gonic/gin"
+ "photography-backend/internal/service/auth"
+ "photography-backend/internal/models"
+)
+
+// AuthMiddleware 认证中间件
+type AuthMiddleware struct {
+ jwtService *auth.JWTService
+}
+
+// NewAuthMiddleware 创建认证中间件
+func NewAuthMiddleware(jwtService *auth.JWTService) *AuthMiddleware {
+ return &AuthMiddleware{
+ jwtService: jwtService,
+ }
+}
+
+// RequireAuth 需要认证的中间件
+func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // 从Header中获取Authorization
+ authHeader := c.GetHeader("Authorization")
+ if authHeader == "" {
+ c.JSON(http.StatusUnauthorized, gin.H{
+ "error": "Authorization header is required",
+ })
+ c.Abort()
+ return
+ }
+
+ // 检查Bearer前缀
+ if !strings.HasPrefix(authHeader, "Bearer ") {
+ c.JSON(http.StatusUnauthorized, gin.H{
+ "error": "Invalid authorization header format",
+ })
+ c.Abort()
+ return
+ }
+
+ // 提取token
+ token := strings.TrimPrefix(authHeader, "Bearer ")
+
+ // 验证token
+ claims, err := m.jwtService.ValidateToken(token)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{
+ "error": "Invalid or expired token",
+ })
+ c.Abort()
+ return
+ }
+
+ // 将用户信息存入上下文
+ c.Set("user_id", claims.UserID)
+ c.Set("username", claims.Username)
+ c.Set("user_role", claims.Role)
+
+ c.Next()
+ }
+}
+
+// RequireRole 需要特定角色的中间件
+func (m *AuthMiddleware) RequireRole(requiredRole string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ userRole, exists := c.Get("user_role")
+ if !exists {
+ c.JSON(http.StatusUnauthorized, gin.H{
+ "error": "User role not found in context",
+ })
+ c.Abort()
+ return
+ }
+
+ roleStr, ok := userRole.(string)
+ if !ok {
+ c.JSON(http.StatusUnauthorized, gin.H{
+ "error": "Invalid user role",
+ })
+ c.Abort()
+ return
+ }
+
+ // 检查角色权限
+ if !m.hasPermission(roleStr, requiredRole) {
+ c.JSON(http.StatusForbidden, gin.H{
+ "error": "Insufficient permissions",
+ })
+ c.Abort()
+ return
+ }
+
+ c.Next()
+ }
+}
+
+// RequireAdmin 需要管理员权限的中间件
+func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
+ return m.RequireRole(models.RoleAdmin)
+}
+
+// RequireEditor 需要编辑者权限的中间件
+func (m *AuthMiddleware) RequireEditor() gin.HandlerFunc {
+ return m.RequireRole(models.RoleEditor)
+}
+
+// OptionalAuth 可选认证中间件
+func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ authHeader := c.GetHeader("Authorization")
+ if authHeader == "" {
+ c.Next()
+ return
+ }
+
+ if !strings.HasPrefix(authHeader, "Bearer ") {
+ c.Next()
+ return
+ }
+
+ token := strings.TrimPrefix(authHeader, "Bearer ")
+ claims, err := m.jwtService.ValidateToken(token)
+ if err != nil {
+ c.Next()
+ return
+ }
+
+ // 将用户信息存入上下文
+ c.Set("user_id", claims.UserID)
+ c.Set("username", claims.Username)
+ c.Set("user_role", claims.Role)
+
+ c.Next()
+ }
+}
+
+// GetCurrentUser 获取当前用户ID
+func GetCurrentUser(c *gin.Context) (uint, bool) {
+ userID, exists := c.Get("user_id")
+ if !exists {
+ return 0, false
+ }
+
+ id, ok := userID.(uint)
+ return id, ok
+}
+
+// GetCurrentUserRole 获取当前用户角色
+func GetCurrentUserRole(c *gin.Context) (string, bool) {
+ userRole, exists := c.Get("user_role")
+ if !exists {
+ return "", false
+ }
+
+ role, ok := userRole.(string)
+ return role, ok
+}
+
+// GetCurrentUsername 获取当前用户名
+func GetCurrentUsername(c *gin.Context) (string, bool) {
+ username, exists := c.Get("username")
+ if !exists {
+ return "", false
+ }
+
+ name, ok := username.(string)
+ return name, ok
+}
+
+// IsAuthenticated 检查是否已认证
+func IsAuthenticated(c *gin.Context) bool {
+ _, exists := c.Get("user_id")
+ return exists
+}
+
+// IsAdmin 检查是否为管理员
+func IsAdmin(c *gin.Context) bool {
+ role, exists := GetCurrentUserRole(c)
+ if !exists {
+ return false
+ }
+ return role == models.RoleAdmin
+}
+
+// IsEditor 检查是否为编辑者或以上
+func IsEditor(c *gin.Context) bool {
+ role, exists := GetCurrentUserRole(c)
+ if !exists {
+ return false
+ }
+ return role == models.RoleEditor || role == models.RoleAdmin
+}
+
+// hasPermission 检查权限
+func (m *AuthMiddleware) hasPermission(userRole, requiredRole string) bool {
+ roleLevel := map[string]int{
+ models.RoleUser: 1,
+ models.RoleEditor: 2,
+ models.RoleAdmin: 3,
+ }
+
+ userLevel, exists := roleLevel[userRole]
+ if !exists {
+ return false
+ }
+
+ requiredLevel, exists := roleLevel[requiredRole]
+ if !exists {
+ return false
+ }
+
+ return userLevel >= requiredLevel
+}
\ No newline at end of file
diff --git a/backend/internal/api/middleware/cors.go b/backend/internal/api/middleware/cors.go
new file mode 100644
index 0000000..75fb46d
--- /dev/null
+++ b/backend/internal/api/middleware/cors.go
@@ -0,0 +1,58 @@
+package middleware
+
+import (
+ "net/http"
+ "github.com/gin-gonic/gin"
+ "photography-backend/internal/config"
+)
+
+// CORSMiddleware CORS中间件
+func CORSMiddleware(cfg *config.CORSConfig) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ origin := c.GetHeader("Origin")
+
+ // 检查是否允许的来源
+ allowed := false
+ for _, allowedOrigin := range cfg.AllowedOrigins {
+ if allowedOrigin == "*" || allowedOrigin == origin {
+ allowed = true
+ break
+ }
+ }
+
+ if allowed {
+ c.Header("Access-Control-Allow-Origin", origin)
+ }
+
+ // 设置其他CORS头
+ c.Header("Access-Control-Allow-Methods", joinStrings(cfg.AllowedMethods, ", "))
+ c.Header("Access-Control-Allow-Headers", joinStrings(cfg.AllowedHeaders, ", "))
+ c.Header("Access-Control-Max-Age", "86400")
+
+ if cfg.AllowCredentials {
+ c.Header("Access-Control-Allow-Credentials", "true")
+ }
+
+ // 处理预检请求
+ if c.Request.Method == "OPTIONS" {
+ c.AbortWithStatus(http.StatusNoContent)
+ return
+ }
+
+ c.Next()
+ }
+}
+
+// joinStrings 连接字符串数组
+func joinStrings(strs []string, sep string) string {
+ if len(strs) == 0 {
+ return ""
+ }
+
+ result := strs[0]
+ for i := 1; i < len(strs); i++ {
+ result += sep + strs[i]
+ }
+
+ return result
+}
\ No newline at end of file
diff --git a/backend/internal/api/middleware/logger.go b/backend/internal/api/middleware/logger.go
new file mode 100644
index 0000000..50b6db7
--- /dev/null
+++ b/backend/internal/api/middleware/logger.go
@@ -0,0 +1,74 @@
+package middleware
+
+import (
+ "time"
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+)
+
+// LoggerMiddleware 日志中间件
+func LoggerMiddleware(logger *zap.Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ start := time.Now()
+ path := c.Request.URL.Path
+ raw := c.Request.URL.RawQuery
+
+ // 处理请求
+ c.Next()
+
+ // 计算延迟
+ latency := time.Since(start)
+
+ // 获取请求信息
+ clientIP := c.ClientIP()
+ method := c.Request.Method
+ statusCode := c.Writer.Status()
+ bodySize := c.Writer.Size()
+
+ if raw != "" {
+ path = path + "?" + raw
+ }
+
+ // 记录日志
+ logger.Info("HTTP Request",
+ zap.String("method", method),
+ zap.String("path", path),
+ zap.String("client_ip", clientIP),
+ zap.Int("status_code", statusCode),
+ zap.Int("body_size", bodySize),
+ zap.Duration("latency", latency),
+ zap.String("user_agent", c.Request.UserAgent()),
+ )
+ }
+}
+
+// RequestIDMiddleware 请求ID中间件
+func RequestIDMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ requestID := c.GetHeader("X-Request-ID")
+ if requestID == "" {
+ requestID = generateRequestID()
+ }
+
+ c.Set("request_id", requestID)
+ c.Header("X-Request-ID", requestID)
+
+ c.Next()
+ }
+}
+
+// generateRequestID 生成请求ID
+func generateRequestID() string {
+ // 简单实现,实际应用中可能需要更复杂的ID生成逻辑
+ return time.Now().Format("20060102150405") + "-" + randomString(8)
+}
+
+// randomString 生成随机字符串
+func randomString(length int) string {
+ const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ b := make([]byte, length)
+ for i := range b {
+ b[i] = charset[time.Now().UnixNano()%int64(len(charset))]
+ }
+ return string(b)
+}
\ No newline at end of file
diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go
new file mode 100644
index 0000000..f2f518c
--- /dev/null
+++ b/backend/internal/api/routes/routes.go
@@ -0,0 +1,131 @@
+package routes
+
+import (
+ "photography-backend/internal/api/handlers"
+ "photography-backend/internal/api/middleware"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+)
+
+type Handlers struct {
+ AuthHandler *handlers.AuthHandler
+ UserHandler *handlers.UserHandler
+ PhotoHandler *handlers.PhotoHandler
+ CategoryHandler *handlers.CategoryHandler
+ TagHandler *handlers.TagHandler
+}
+
+func SetupRoutes(r *gin.Engine, h *Handlers, authMiddleware *middleware.AuthMiddleware, logger *zap.Logger) {
+ // 健康检查
+ r.GET("/health", func(c *gin.Context) {
+ c.JSON(200, gin.H{"status": "ok"})
+ })
+
+ // API 路由组
+ api := r.Group("/api")
+ {
+ // 公开路由
+ public := api.Group("")
+ {
+ // 认证相关
+ auth := public.Group("/auth")
+ {
+ auth.POST("/login", h.AuthHandler.Login)
+ auth.POST("/refresh", h.AuthHandler.RefreshToken)
+ }
+ }
+
+ // 需要认证的路由
+ protected := api.Group("")
+ protected.Use(authMiddleware.RequireAuth())
+ {
+ // 当前用户信息
+ protected.GET("/me", h.UserHandler.GetCurrentUser)
+ protected.PUT("/me", h.UserHandler.UpdateCurrentUser)
+ protected.POST("/auth/logout", h.AuthHandler.Logout)
+
+ // 照片管理
+ photos := protected.Group("/photos")
+ {
+ photos.GET("", h.PhotoHandler.GetPhotos)
+ photos.POST("", h.PhotoHandler.CreatePhoto)
+ photos.GET("/stats", h.PhotoHandler.GetPhotoStats)
+ photos.POST("/upload", h.PhotoHandler.UploadPhoto)
+ photos.POST("/batch/update", h.PhotoHandler.BatchUpdatePhotos)
+ photos.POST("/batch/delete", h.PhotoHandler.BatchDeletePhotos)
+ photos.GET("/:id", h.PhotoHandler.GetPhoto)
+ photos.PUT("/:id", h.PhotoHandler.UpdatePhoto)
+ photos.DELETE("/:id", h.PhotoHandler.DeletePhoto)
+ }
+
+ // 分类管理
+ categories := protected.Group("/categories")
+ {
+ categories.GET("", h.CategoryHandler.GetCategories)
+ categories.POST("", h.CategoryHandler.CreateCategory)
+ categories.GET("/tree", h.CategoryHandler.GetCategoryTree)
+ categories.GET("/stats", h.CategoryHandler.GetCategoryStats)
+ categories.POST("/reorder", h.CategoryHandler.ReorderCategories)
+ categories.POST("/generate-slug", h.CategoryHandler.GenerateSlug)
+ categories.GET("/:id", h.CategoryHandler.GetCategory)
+ categories.PUT("/:id", h.CategoryHandler.UpdateCategory)
+ categories.DELETE("/:id", h.CategoryHandler.DeleteCategory)
+ categories.GET("/slug/:slug", h.CategoryHandler.GetCategoryBySlug)
+ }
+
+ // 标签管理
+ tags := protected.Group("/tags")
+ {
+ tags.GET("", h.TagHandler.GetTags)
+ tags.POST("", h.TagHandler.CreateTag)
+ tags.GET("/all", h.TagHandler.GetAllTags)
+ tags.GET("/popular", h.TagHandler.GetPopularTags)
+ tags.GET("/cloud", h.TagHandler.GetTagCloud)
+ tags.GET("/stats", h.TagHandler.GetTagStats)
+ tags.GET("/search", h.TagHandler.SearchTags)
+ tags.POST("/batch/delete", h.TagHandler.BatchDeleteTags)
+ tags.POST("/generate-slug", h.TagHandler.GenerateSlug)
+ tags.GET("/:id", h.TagHandler.GetTag)
+ tags.PUT("/:id", h.TagHandler.UpdateTag)
+ tags.DELETE("/:id", h.TagHandler.DeleteTag)
+ tags.GET("/slug/:slug", h.TagHandler.GetTagBySlug)
+ }
+
+ // 用户管理 (需要管理员权限)
+ admin := protected.Group("/admin")
+ admin.Use(authMiddleware.RequireRole("admin"))
+ {
+ users := admin.Group("/users")
+ {
+ users.GET("", h.UserHandler.GetUsers)
+ users.POST("", h.UserHandler.CreateUser)
+ users.GET("/:id", h.UserHandler.GetUser)
+ users.PUT("/:id", h.UserHandler.UpdateUser)
+ users.DELETE("/:id", h.UserHandler.DeleteUser)
+ }
+ }
+ }
+ }
+
+ // 前端公共 API (无需认证)
+ frontend := api.Group("/public")
+ {
+ // 公开的照片接口
+ frontend.GET("/photos", h.PhotoHandler.GetPhotos)
+ frontend.GET("/photos/:id", h.PhotoHandler.GetPhoto)
+
+ // 公开的分类接口
+ frontend.GET("/categories", h.CategoryHandler.GetCategories)
+ frontend.GET("/categories/tree", h.CategoryHandler.GetCategoryTree)
+ frontend.GET("/categories/:id", h.CategoryHandler.GetCategory)
+ frontend.GET("/categories/slug/:slug", h.CategoryHandler.GetCategoryBySlug)
+
+ // 公开的标签接口
+ frontend.GET("/tags", h.TagHandler.GetTags)
+ frontend.GET("/tags/popular", h.TagHandler.GetPopularTags)
+ frontend.GET("/tags/cloud", h.TagHandler.GetTagCloud)
+ frontend.GET("/tags/:id", h.TagHandler.GetTag)
+ frontend.GET("/tags/slug/:slug", h.TagHandler.GetTagBySlug)
+ }
+}
\ No newline at end of file
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
new file mode 100644
index 0000000..5127ff5
--- /dev/null
+++ b/backend/internal/config/config.go
@@ -0,0 +1,231 @@
+package config
+
+import (
+ "fmt"
+ "time"
+ "github.com/spf13/viper"
+)
+
+// Config 应用配置
+type Config struct {
+ App AppConfig `mapstructure:"app"`
+ Database DatabaseConfig `mapstructure:"database"`
+ Redis RedisConfig `mapstructure:"redis"`
+ JWT JWTConfig `mapstructure:"jwt"`
+ Storage StorageConfig `mapstructure:"storage"`
+ Upload UploadConfig `mapstructure:"upload"`
+ Logger LoggerConfig `mapstructure:"logger"`
+ CORS CORSConfig `mapstructure:"cors"`
+ RateLimit RateLimitConfig `mapstructure:"rate_limit"`
+}
+
+// AppConfig 应用配置
+type AppConfig struct {
+ Name string `mapstructure:"name"`
+ Version string `mapstructure:"version"`
+ Environment string `mapstructure:"environment"`
+ Port int `mapstructure:"port"`
+ Debug bool `mapstructure:"debug"`
+}
+
+// DatabaseConfig 数据库配置
+type DatabaseConfig struct {
+ Host string `mapstructure:"host"`
+ Port int `mapstructure:"port"`
+ Username string `mapstructure:"username"`
+ Password string `mapstructure:"password"`
+ Database string `mapstructure:"database"`
+ SSLMode string `mapstructure:"ssl_mode"`
+ MaxOpenConns int `mapstructure:"max_open_conns"`
+ MaxIdleConns int `mapstructure:"max_idle_conns"`
+ ConnMaxLifetime int `mapstructure:"conn_max_lifetime"`
+}
+
+// RedisConfig Redis配置
+type RedisConfig struct {
+ Host string `mapstructure:"host"`
+ Port int `mapstructure:"port"`
+ Password string `mapstructure:"password"`
+ Database int `mapstructure:"database"`
+ PoolSize int `mapstructure:"pool_size"`
+ MinIdleConns int `mapstructure:"min_idle_conns"`
+}
+
+// JWTConfig JWT配置
+type JWTConfig struct {
+ Secret string `mapstructure:"secret"`
+ ExpiresIn string `mapstructure:"expires_in"`
+ RefreshExpiresIn string `mapstructure:"refresh_expires_in"`
+}
+
+// StorageConfig 存储配置
+type StorageConfig struct {
+ Type string `mapstructure:"type"`
+ Local LocalConfig `mapstructure:"local"`
+ S3 S3Config `mapstructure:"s3"`
+}
+
+// LocalConfig 本地存储配置
+type LocalConfig struct {
+ BasePath string `mapstructure:"base_path"`
+ BaseURL string `mapstructure:"base_url"`
+}
+
+// S3Config S3存储配置
+type S3Config struct {
+ Region string `mapstructure:"region"`
+ Bucket string `mapstructure:"bucket"`
+ AccessKey string `mapstructure:"access_key"`
+ SecretKey string `mapstructure:"secret_key"`
+ Endpoint string `mapstructure:"endpoint"`
+}
+
+// UploadConfig 上传配置
+type UploadConfig struct {
+ MaxFileSize int64 `mapstructure:"max_file_size"`
+ AllowedTypes []string `mapstructure:"allowed_types"`
+ ThumbnailSizes []ThumbnailSize `mapstructure:"thumbnail_sizes"`
+}
+
+// ThumbnailSize 缩略图尺寸
+type ThumbnailSize struct {
+ Name string `mapstructure:"name"`
+ Width int `mapstructure:"width"`
+ Height int `mapstructure:"height"`
+}
+
+// LoggerConfig 日志配置
+type LoggerConfig struct {
+ Level string `mapstructure:"level"`
+ Format string `mapstructure:"format"`
+ Output string `mapstructure:"output"`
+ Filename string `mapstructure:"filename"`
+ MaxSize int `mapstructure:"max_size"`
+ MaxAge int `mapstructure:"max_age"`
+ Compress bool `mapstructure:"compress"`
+}
+
+// CORSConfig CORS配置
+type CORSConfig struct {
+ AllowedOrigins []string `mapstructure:"allowed_origins"`
+ AllowedMethods []string `mapstructure:"allowed_methods"`
+ AllowedHeaders []string `mapstructure:"allowed_headers"`
+ AllowCredentials bool `mapstructure:"allow_credentials"`
+}
+
+// RateLimitConfig 限流配置
+type RateLimitConfig struct {
+ Enabled bool `mapstructure:"enabled"`
+ RequestsPerMinute int `mapstructure:"requests_per_minute"`
+ Burst int `mapstructure:"burst"`
+}
+
+var AppConfig *Config
+
+// LoadConfig 加载配置
+func LoadConfig(configPath string) (*Config, error) {
+ viper.SetConfigFile(configPath)
+ viper.SetConfigType("yaml")
+
+ // 设置环境变量前缀
+ viper.SetEnvPrefix("PHOTOGRAPHY")
+ viper.AutomaticEnv()
+
+ // 环境变量替换配置
+ viper.BindEnv("database.host", "DB_HOST")
+ viper.BindEnv("database.port", "DB_PORT")
+ viper.BindEnv("database.username", "DB_USER")
+ viper.BindEnv("database.password", "DB_PASSWORD")
+ viper.BindEnv("database.database", "DB_NAME")
+ viper.BindEnv("redis.host", "REDIS_HOST")
+ viper.BindEnv("redis.port", "REDIS_PORT")
+ viper.BindEnv("redis.password", "REDIS_PASSWORD")
+ viper.BindEnv("jwt.secret", "JWT_SECRET")
+ viper.BindEnv("storage.s3.access_key", "AWS_ACCESS_KEY_ID")
+ viper.BindEnv("storage.s3.secret_key", "AWS_SECRET_ACCESS_KEY")
+ viper.BindEnv("app.port", "PORT")
+
+ if err := viper.ReadInConfig(); err != nil {
+ return nil, fmt.Errorf("failed to read config file: %w", err)
+ }
+
+ var config Config
+ if err := viper.Unmarshal(&config); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal config: %w", err)
+ }
+
+ // 验证配置
+ if err := validateConfig(&config); err != nil {
+ return nil, fmt.Errorf("config validation failed: %w", err)
+ }
+
+ AppConfig = &config
+ return &config, nil
+}
+
+// validateConfig 验证配置
+func validateConfig(config *Config) error {
+ if config.App.Name == "" {
+ return fmt.Errorf("app name is required")
+ }
+
+ if config.Database.Host == "" {
+ return fmt.Errorf("database host is required")
+ }
+
+ if config.JWT.Secret == "" {
+ return fmt.Errorf("jwt secret is required")
+ }
+
+ return nil
+}
+
+// GetJWTExpiration 获取JWT过期时间
+func (c *Config) GetJWTExpiration() time.Duration {
+ duration, err := time.ParseDuration(c.JWT.ExpiresIn)
+ if err != nil {
+ return 24 * time.Hour // 默认24小时
+ }
+ return duration
+}
+
+// GetJWTRefreshExpiration 获取JWT刷新过期时间
+func (c *Config) GetJWTRefreshExpiration() time.Duration {
+ duration, err := time.ParseDuration(c.JWT.RefreshExpiresIn)
+ if err != nil {
+ return 7 * 24 * time.Hour // 默认7天
+ }
+ return duration
+}
+
+// GetDatabaseDSN 获取数据库DSN
+func (c *Config) GetDatabaseDSN() string {
+ return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
+ c.Database.Host,
+ c.Database.Port,
+ c.Database.Username,
+ c.Database.Password,
+ c.Database.Database,
+ c.Database.SSLMode,
+ )
+}
+
+// GetRedisAddr 获取Redis地址
+func (c *Config) GetRedisAddr() string {
+ return fmt.Sprintf("%s:%d", c.Redis.Host, c.Redis.Port)
+}
+
+// GetServerAddr 获取服务器地址
+func (c *Config) GetServerAddr() string {
+ return fmt.Sprintf(":%d", c.App.Port)
+}
+
+// IsDevelopment 是否为开发环境
+func (c *Config) IsDevelopment() bool {
+ return c.App.Environment == "development"
+}
+
+// IsProduction 是否为生产环境
+func (c *Config) IsProduction() bool {
+ return c.App.Environment == "production"
+}
\ No newline at end of file
diff --git a/backend/internal/models/category.go b/backend/internal/models/category.go
new file mode 100644
index 0000000..9ea5275
--- /dev/null
+++ b/backend/internal/models/category.go
@@ -0,0 +1,85 @@
+package models
+
+import (
+ "time"
+ "gorm.io/gorm"
+)
+
+// Category 分类模型
+type Category struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ Name string `gorm:"size:100;not null" json:"name"`
+ Description string `gorm:"type:text" json:"description"`
+ ParentID *uint `json:"parent_id"`
+ Parent *Category `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
+ Children []Category `gorm:"foreignKey:ParentID" json:"children,omitempty"`
+ Color string `gorm:"size:7;default:#3b82f6" json:"color"`
+ CoverImage string `gorm:"size:500" json:"cover_image"`
+ Sort int `gorm:"default:0" json:"sort"`
+ IsActive bool `gorm:"default:true" json:"is_active"`
+ PhotoCount int `gorm:"-" json:"photo_count"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+}
+
+// TableName 返回分类表名
+func (Category) TableName() string {
+ return "categories"
+}
+
+// CreateCategoryRequest 创建分类请求
+type CreateCategoryRequest struct {
+ Name string `json:"name" binding:"required,max=100"`
+ Description string `json:"description"`
+ ParentID *uint `json:"parent_id"`
+ Color string `json:"color" binding:"omitempty,hexcolor"`
+ CoverImage string `json:"cover_image" binding:"omitempty,max=500"`
+ Sort int `json:"sort"`
+}
+
+// UpdateCategoryRequest 更新分类请求
+type UpdateCategoryRequest struct {
+ Name *string `json:"name" binding:"omitempty,max=100"`
+ Description *string `json:"description"`
+ ParentID *uint `json:"parent_id"`
+ Color *string `json:"color" binding:"omitempty,hexcolor"`
+ CoverImage *string `json:"cover_image" binding:"omitempty,max=500"`
+ Sort *int `json:"sort"`
+ IsActive *bool `json:"is_active"`
+}
+
+// CategoryListParams 分类列表查询参数
+type CategoryListParams struct {
+ IncludeStats bool `form:"include_stats"`
+ IncludeTree bool `form:"include_tree"`
+ ParentID uint `form:"parent_id"`
+ IsActive bool `form:"is_active"`
+}
+
+// CategoryResponse 分类响应
+type CategoryResponse struct {
+ *Category
+}
+
+// CategoryTreeNode 分类树节点
+type CategoryTreeNode struct {
+ ID uint `json:"id"`
+ Name string `json:"name"`
+ PhotoCount int `json:"photo_count"`
+ Children []CategoryTreeNode `json:"children"`
+}
+
+// CategoryListResponse 分类列表响应
+type CategoryListResponse struct {
+ Categories []CategoryResponse `json:"categories"`
+ Tree []CategoryTreeNode `json:"tree,omitempty"`
+ Stats *CategoryStats `json:"stats,omitempty"`
+}
+
+// CategoryStats 分类统计
+type CategoryStats struct {
+ TotalCategories int `json:"total_categories"`
+ MaxLevel int `json:"max_level"`
+ FeaturedCount int `json:"featured_count"`
+}
\ No newline at end of file
diff --git a/backend/internal/models/photo.go b/backend/internal/models/photo.go
new file mode 100644
index 0000000..c58bad8
--- /dev/null
+++ b/backend/internal/models/photo.go
@@ -0,0 +1,99 @@
+package models
+
+import (
+ "time"
+ "gorm.io/gorm"
+)
+
+// Photo 照片模型
+type Photo struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ Title string `gorm:"size:255;not null" json:"title"`
+ Description string `gorm:"type:text" json:"description"`
+ Filename string `gorm:"size:255;not null" json:"filename"`
+ FilePath string `gorm:"size:500;not null" json:"file_path"`
+ FileSize int64 `json:"file_size"`
+ MimeType string `gorm:"size:100" json:"mime_type"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ CategoryID uint `json:"category_id"`
+ Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
+ Tags []Tag `gorm:"many2many:photo_tags;" json:"tags,omitempty"`
+ EXIF string `gorm:"type:jsonb" json:"exif"`
+ TakenAt *time.Time `json:"taken_at"`
+ Location string `gorm:"size:255" json:"location"`
+ IsPublic bool `gorm:"default:true" json:"is_public"`
+ Status string `gorm:"size:20;default:draft" json:"status"`
+ ViewCount int `gorm:"default:0" json:"view_count"`
+ LikeCount int `gorm:"default:0" json:"like_count"`
+ UserID uint `gorm:"not null" json:"user_id"`
+ User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+}
+
+// TableName 返回照片表名
+func (Photo) TableName() string {
+ return "photos"
+}
+
+// PhotoStatus 照片状态常量
+const (
+ StatusDraft = "draft"
+ StatusPublished = "published"
+ StatusArchived = "archived"
+)
+
+// CreatePhotoRequest 创建照片请求
+type CreatePhotoRequest struct {
+ Title string `json:"title" binding:"required,max=255"`
+ Description string `json:"description"`
+ CategoryID uint `json:"category_id" binding:"required"`
+ TagIDs []uint `json:"tag_ids"`
+ TakenAt *time.Time `json:"taken_at"`
+ Location string `json:"location" binding:"max=255"`
+ IsPublic *bool `json:"is_public"`
+ Status string `json:"status" binding:"omitempty,oneof=draft published archived"`
+}
+
+// UpdatePhotoRequest 更新照片请求
+type UpdatePhotoRequest struct {
+ Title *string `json:"title" binding:"omitempty,max=255"`
+ Description *string `json:"description"`
+ CategoryID *uint `json:"category_id"`
+ TagIDs []uint `json:"tag_ids"`
+ TakenAt *time.Time `json:"taken_at"`
+ Location *string `json:"location" binding:"omitempty,max=255"`
+ IsPublic *bool `json:"is_public"`
+ Status *string `json:"status" binding:"omitempty,oneof=draft published archived"`
+}
+
+// PhotoListParams 照片列表查询参数
+type PhotoListParams struct {
+ Page int `form:"page,default=1" binding:"min=1"`
+ Limit int `form:"limit,default=20" binding:"min=1,max=100"`
+ CategoryID uint `form:"category_id"`
+ TagID uint `form:"tag_id"`
+ UserID uint `form:"user_id"`
+ Status string `form:"status" binding:"omitempty,oneof=draft published archived"`
+ Search string `form:"search"`
+ SortBy string `form:"sort_by,default=created_at" binding:"omitempty,oneof=created_at taken_at title view_count like_count"`
+ SortOrder string `form:"sort_order,default=desc" binding:"omitempty,oneof=asc desc"`
+ Year int `form:"year"`
+ Month int `form:"month" binding:"min=1,max=12"`
+}
+
+// PhotoResponse 照片响应
+type PhotoResponse struct {
+ *Photo
+ ThumbnailURLs map[string]string `json:"thumbnail_urls,omitempty"`
+}
+
+// PhotoListResponse 照片列表响应
+type PhotoListResponse struct {
+ Photos []PhotoResponse `json:"photos"`
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+ Limit int `json:"limit"`
+}
\ No newline at end of file
diff --git a/backend/internal/models/requests.go b/backend/internal/models/requests.go
new file mode 100644
index 0000000..2053047
--- /dev/null
+++ b/backend/internal/models/requests.go
@@ -0,0 +1,242 @@
+package models
+
+import "time"
+
+// 通用请求和响应结构
+
+// ErrorResponse 错误响应
+type ErrorResponse struct {
+ Error string `json:"error"`
+ Message string `json:"message"`
+}
+
+// SuccessResponse 成功响应
+type SuccessResponse struct {
+ Message string `json:"message"`
+}
+
+// BatchDeleteRequest 批量删除请求
+type BatchDeleteRequest struct {
+ IDs []uint `json:"ids" binding:"required,min=1"`
+}
+
+// GenerateSlugRequest 生成slug请求
+type GenerateSlugRequest struct {
+ Name string `json:"name" binding:"required"`
+}
+
+// GenerateSlugResponse 生成slug响应
+type GenerateSlugResponse struct {
+ Slug string `json:"slug"`
+}
+
+// 照片相关请求
+
+// CreatePhotoRequest 创建照片请求
+type CreatePhotoRequest struct {
+ Title string `json:"title" binding:"required"`
+ Description string `json:"description"`
+ OriginalFilename string `json:"original_filename"`
+ FileSize int64 `json:"file_size"`
+ Status string `json:"status" binding:"oneof=draft published archived processing"`
+ CategoryIDs []uint `json:"category_ids"`
+ TagIDs []uint `json:"tag_ids"`
+ Camera string `json:"camera"`
+ Lens string `json:"lens"`
+ ISO int `json:"iso"`
+ Aperture string `json:"aperture"`
+ ShutterSpeed string `json:"shutter_speed"`
+ FocalLength string `json:"focal_length"`
+ TakenAt *time.Time `json:"taken_at"`
+}
+
+// UpdatePhotoRequest 更新照片请求
+type UpdatePhotoRequest struct {
+ Title *string `json:"title"`
+ Description *string `json:"description"`
+ Status *string `json:"status" binding:"omitempty,oneof=draft published archived processing"`
+ CategoryIDs *[]uint `json:"category_ids"`
+ TagIDs *[]uint `json:"tag_ids"`
+ Camera *string `json:"camera"`
+ Lens *string `json:"lens"`
+ ISO *int `json:"iso"`
+ Aperture *string `json:"aperture"`
+ ShutterSpeed *string `json:"shutter_speed"`
+ FocalLength *string `json:"focal_length"`
+ TakenAt *time.Time `json:"taken_at"`
+}
+
+// BatchUpdatePhotosRequest 批量更新照片请求
+type BatchUpdatePhotosRequest struct {
+ IDs []uint `json:"ids" binding:"required,min=1"`
+ Status *string `json:"status" binding:"omitempty,oneof=draft published archived processing"`
+ CategoryIDs *[]uint `json:"category_ids"`
+ TagIDs *[]uint `json:"tag_ids"`
+}
+
+// PhotoStats 照片统计信息
+type PhotoStats struct {
+ Total int64 `json:"total"`
+ ThisMonth int64 `json:"this_month"`
+ Today int64 `json:"today"`
+ TotalSize int64 `json:"total_size"`
+ StatusStats map[string]int64 `json:"status_stats"`
+}
+
+// 分类相关请求
+
+// CreateCategoryRequest 创建分类请求
+type CreateCategoryRequest struct {
+ Name string `json:"name" binding:"required"`
+ Slug string `json:"slug" binding:"required"`
+ Description string `json:"description"`
+ ParentID *uint `json:"parent_id"`
+}
+
+// UpdateCategoryRequest 更新分类请求
+type UpdateCategoryRequest struct {
+ Name *string `json:"name"`
+ Slug *string `json:"slug"`
+ Description *string `json:"description"`
+ ParentID *uint `json:"parent_id"`
+ SortOrder *int `json:"sort_order"`
+ IsActive *bool `json:"is_active"`
+}
+
+// ReorderCategoriesRequest 重新排序分类请求
+type ReorderCategoriesRequest struct {
+ ParentID *uint `json:"parent_id"`
+ CategoryIDs []uint `json:"category_ids" binding:"required,min=1"`
+}
+
+// CategoryStats 分类统计信息
+type CategoryStats struct {
+ Total int64 `json:"total"`
+ Active int64 `json:"active"`
+ TopLevel int64 `json:"top_level"`
+ PhotoCounts map[string]int64 `json:"photo_counts"`
+}
+
+// CategoryTree 分类树结构
+type CategoryTree struct {
+ ID uint `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Description string `json:"description"`
+ ParentID *uint `json:"parent_id"`
+ SortOrder int `json:"sort_order"`
+ IsActive bool `json:"is_active"`
+ PhotoCount int64 `json:"photo_count"`
+ Children []CategoryTree `json:"children"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// 标签相关请求
+
+// CreateTagRequest 创建标签请求
+type CreateTagRequest struct {
+ Name string `json:"name" binding:"required"`
+ Slug string `json:"slug" binding:"required"`
+ Description string `json:"description"`
+ Color string `json:"color"`
+}
+
+// UpdateTagRequest 更新标签请求
+type UpdateTagRequest struct {
+ Name *string `json:"name"`
+ Slug *string `json:"slug"`
+ Description *string `json:"description"`
+ Color *string `json:"color"`
+ IsActive *bool `json:"is_active"`
+}
+
+// TagStats 标签统计信息
+type TagStats struct {
+ Total int64 `json:"total"`
+ Active int64 `json:"active"`
+ Used int64 `json:"used"`
+ Unused int64 `json:"unused"`
+ AvgPhotosPerTag float64 `json:"avg_photos_per_tag"`
+}
+
+// TagWithCount 带照片数量的标签
+type TagWithCount struct {
+ Tag
+ PhotoCount int64 `json:"photo_count"`
+}
+
+// TagCloudItem 标签云项目
+type TagCloudItem struct {
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Color string `json:"color"`
+ Count int64 `json:"count"`
+}
+
+// 用户相关请求
+
+// CreateUserRequest 创建用户请求
+type CreateUserRequest struct {
+ Username string `json:"username" binding:"required,min=3,max=50"`
+ Email string `json:"email" binding:"required,email"`
+ Password string `json:"password" binding:"required,min=8"`
+ Role string `json:"role" binding:"oneof=user editor admin"`
+}
+
+// UpdateUserRequest 更新用户请求
+type UpdateUserRequest struct {
+ Username *string `json:"username" binding:"omitempty,min=3,max=50"`
+ Email *string `json:"email" binding:"omitempty,email"`
+ Role *string `json:"role" binding:"omitempty,oneof=user editor admin"`
+ IsActive *bool `json:"is_active"`
+}
+
+// UpdateCurrentUserRequest 更新当前用户请求
+type UpdateCurrentUserRequest struct {
+ Username *string `json:"username" binding:"omitempty,min=3,max=50"`
+ Email *string `json:"email" binding:"omitempty,email"`
+}
+
+// ChangePasswordRequest 修改密码请求
+type ChangePasswordRequest struct {
+ OldPassword string `json:"old_password" binding:"required"`
+ NewPassword string `json:"new_password" binding:"required,min=8"`
+}
+
+// LoginRequest 登录请求
+type LoginRequest struct {
+ Username string `json:"username" binding:"required"`
+ Password string `json:"password" binding:"required"`
+}
+
+// LoginResponse 登录响应
+type LoginResponse struct {
+ User *UserResponse `json:"user"`
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int64 `json:"expires_in"`
+}
+
+// RefreshTokenRequest 刷新token请求
+type RefreshTokenRequest struct {
+ RefreshToken string `json:"refresh_token" binding:"required"`
+}
+
+// RefreshTokenResponse 刷新token响应
+type RefreshTokenResponse struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int64 `json:"expires_in"`
+}
+
+// UserResponse 用户响应(隐藏敏感信息)
+type UserResponse struct {
+ ID uint `json:"id"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+ Role string `json:"role"`
+ IsActive bool `json:"is_active"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
\ No newline at end of file
diff --git a/backend/internal/models/tag.go b/backend/internal/models/tag.go
new file mode 100644
index 0000000..b7b65fc
--- /dev/null
+++ b/backend/internal/models/tag.go
@@ -0,0 +1,95 @@
+package models
+
+import (
+ "time"
+ "gorm.io/gorm"
+)
+
+// Tag 标签模型
+type Tag struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ Name string `gorm:"size:50;not null;unique" json:"name"`
+ Color string `gorm:"size:7;default:#6b7280" json:"color"`
+ UseCount int `gorm:"default:0" json:"use_count"`
+ IsActive bool `gorm:"default:true" json:"is_active"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+}
+
+// TableName 返回标签表名
+func (Tag) TableName() string {
+ return "tags"
+}
+
+// CreateTagRequest 创建标签请求
+type CreateTagRequest struct {
+ Name string `json:"name" binding:"required,max=50"`
+ Color string `json:"color" binding:"omitempty,hexcolor"`
+}
+
+// UpdateTagRequest 更新标签请求
+type UpdateTagRequest struct {
+ Name *string `json:"name" binding:"omitempty,max=50"`
+ Color *string `json:"color" binding:"omitempty,hexcolor"`
+ IsActive *bool `json:"is_active"`
+}
+
+// TagListParams 标签列表查询参数
+type TagListParams struct {
+ Page int `form:"page,default=1" binding:"min=1"`
+ Limit int `form:"limit,default=50" binding:"min=1,max=100"`
+ Search string `form:"search"`
+ SortBy string `form:"sort_by,default=use_count" binding:"omitempty,oneof=use_count name created_at"`
+ SortOrder string `form:"sort_order,default=desc" binding:"omitempty,oneof=asc desc"`
+ IsActive bool `form:"is_active"`
+}
+
+// TagSuggestionsParams 标签建议查询参数
+type TagSuggestionsParams struct {
+ Query string `form:"q" binding:"required"`
+ Limit int `form:"limit,default=10" binding:"min=1,max=20"`
+}
+
+// TagResponse 标签响应
+type TagResponse struct {
+ *Tag
+ MatchScore float64 `json:"match_score,omitempty"`
+}
+
+// TagListResponse 标签列表响应
+type TagListResponse struct {
+ Tags []TagResponse `json:"tags"`
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+ Limit int `json:"limit"`
+ Groups *TagGroups `json:"groups,omitempty"`
+}
+
+// TagGroups 标签分组
+type TagGroups struct {
+ Style TagGroup `json:"style"`
+ Subject TagGroup `json:"subject"`
+ Technique TagGroup `json:"technique"`
+ Location TagGroup `json:"location"`
+}
+
+// TagGroup 标签组
+type TagGroup struct {
+ Name string `json:"name"`
+ Count int `json:"count"`
+}
+
+// TagCloudItem 标签云项目
+type TagCloudItem struct {
+ ID uint `json:"id"`
+ Name string `json:"name"`
+ UseCount int `json:"use_count"`
+ RelativeSize int `json:"relative_size"`
+ Color string `json:"color"`
+}
+
+// TagCloudResponse 标签云响应
+type TagCloudResponse struct {
+ Tags []TagCloudItem `json:"tags"`
+}
\ No newline at end of file
diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go
new file mode 100644
index 0000000..466e6cd
--- /dev/null
+++ b/backend/internal/models/user.go
@@ -0,0 +1,76 @@
+package models
+
+import (
+ "time"
+ "gorm.io/gorm"
+)
+
+// User 用户模型
+type User struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ Username string `gorm:"size:50;not null;unique" json:"username"`
+ Email string `gorm:"size:100;not null;unique" json:"email"`
+ Password string `gorm:"size:255;not null" json:"-"`
+ Name string `gorm:"size:100" json:"name"`
+ Avatar string `gorm:"size:500" json:"avatar"`
+ Role string `gorm:"size:20;default:user" json:"role"`
+ IsActive bool `gorm:"default:true" json:"is_active"`
+ LastLogin *time.Time `json:"last_login"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+}
+
+// TableName 返回用户表名
+func (User) TableName() string {
+ return "users"
+}
+
+// UserRole 用户角色常量
+const (
+ RoleUser = "user"
+ RoleEditor = "editor"
+ RoleAdmin = "admin"
+)
+
+// CreateUserRequest 创建用户请求
+type CreateUserRequest struct {
+ Username string `json:"username" binding:"required,min=3,max=50"`
+ Email string `json:"email" binding:"required,email"`
+ Password string `json:"password" binding:"required,min=6"`
+ Name string `json:"name" binding:"max=100"`
+ Role string `json:"role" binding:"omitempty,oneof=user editor admin"`
+}
+
+// UpdateUserRequest 更新用户请求
+type UpdateUserRequest struct {
+ Name *string `json:"name" binding:"omitempty,max=100"`
+ Avatar *string `json:"avatar" binding:"omitempty,max=500"`
+ IsActive *bool `json:"is_active"`
+}
+
+// UpdatePasswordRequest 更新密码请求
+type UpdatePasswordRequest struct {
+ OldPassword string `json:"old_password" binding:"required"`
+ NewPassword string `json:"new_password" binding:"required,min=6"`
+}
+
+// LoginRequest 登录请求
+type LoginRequest struct {
+ Username string `json:"username" binding:"required"`
+ Password string `json:"password" binding:"required"`
+}
+
+// LoginResponse 登录响应
+type LoginResponse struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ User *User `json:"user"`
+}
+
+// RefreshTokenRequest 刷新令牌请求
+type RefreshTokenRequest struct {
+ RefreshToken string `json:"refresh_token" binding:"required"`
+}
\ No newline at end of file
diff --git a/backend/internal/repository/postgres/category_repository.go b/backend/internal/repository/postgres/category_repository.go
new file mode 100644
index 0000000..f2cda77
--- /dev/null
+++ b/backend/internal/repository/postgres/category_repository.go
@@ -0,0 +1,211 @@
+package postgres
+
+import (
+ "fmt"
+ "photography-backend/internal/models"
+ "gorm.io/gorm"
+)
+
+// CategoryRepository 分类仓库接口
+type CategoryRepository interface {
+ Create(category *models.Category) error
+ GetByID(id uint) (*models.Category, error)
+ Update(category *models.Category) error
+ Delete(id uint) error
+ List(params *models.CategoryListParams) ([]*models.Category, error)
+ GetTree() ([]*models.Category, error)
+ GetChildren(parentID uint) ([]*models.Category, error)
+ GetStats() (*models.CategoryStats, error)
+ UpdateSort(id uint, sort int) error
+ GetPhotoCount(id uint) (int64, error)
+}
+
+// categoryRepository 分类仓库实现
+type categoryRepository struct {
+ db *gorm.DB
+}
+
+// NewCategoryRepository 创建分类仓库
+func NewCategoryRepository(db *gorm.DB) CategoryRepository {
+ return &categoryRepository{db: db}
+}
+
+// Create 创建分类
+func (r *categoryRepository) Create(category *models.Category) error {
+ if err := r.db.Create(category).Error; err != nil {
+ return fmt.Errorf("failed to create category: %w", err)
+ }
+ return nil
+}
+
+// GetByID 根据ID获取分类
+func (r *categoryRepository) GetByID(id uint) (*models.Category, error) {
+ var category models.Category
+ if err := r.db.Preload("Parent").Preload("Children").
+ First(&category, id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("failed to get category by id: %w", err)
+ }
+
+ // 计算照片数量
+ var photoCount int64
+ if err := r.db.Model(&models.Photo{}).Where("category_id = ?", id).
+ Count(&photoCount).Error; err != nil {
+ return nil, fmt.Errorf("failed to count photos: %w", err)
+ }
+ category.PhotoCount = int(photoCount)
+
+ return &category, nil
+}
+
+// Update 更新分类
+func (r *categoryRepository) Update(category *models.Category) error {
+ if err := r.db.Save(category).Error; err != nil {
+ return fmt.Errorf("failed to update category: %w", err)
+ }
+ return nil
+}
+
+// Delete 删除分类
+func (r *categoryRepository) Delete(id uint) error {
+ // 开启事务
+ tx := r.db.Begin()
+
+ // 将子分类的父分类设置为NULL
+ if err := tx.Model(&models.Category{}).Where("parent_id = ?", id).
+ Update("parent_id", nil).Error; err != nil {
+ tx.Rollback()
+ return fmt.Errorf("failed to update child categories: %w", err)
+ }
+
+ // 删除分类
+ if err := tx.Delete(&models.Category{}, id).Error; err != nil {
+ tx.Rollback()
+ return fmt.Errorf("failed to delete category: %w", err)
+ }
+
+ return tx.Commit().Error
+}
+
+// List 获取分类列表
+func (r *categoryRepository) List(params *models.CategoryListParams) ([]*models.Category, error) {
+ var categories []*models.Category
+
+ query := r.db.Model(&models.Category{})
+
+ // 添加过滤条件
+ if params.ParentID > 0 {
+ query = query.Where("parent_id = ?", params.ParentID)
+ }
+
+ if params.IsActive {
+ query = query.Where("is_active = ?", true)
+ }
+
+ if err := query.Order("sort ASC, created_at DESC").
+ Find(&categories).Error; err != nil {
+ return nil, fmt.Errorf("failed to list categories: %w", err)
+ }
+
+ // 如果需要包含统计信息
+ if params.IncludeStats {
+ for _, category := range categories {
+ var photoCount int64
+ if err := r.db.Model(&models.Photo{}).Where("category_id = ?", category.ID).
+ Count(&photoCount).Error; err != nil {
+ return nil, fmt.Errorf("failed to count photos for category %d: %w", category.ID, err)
+ }
+ category.PhotoCount = int(photoCount)
+ }
+ }
+
+ return categories, nil
+}
+
+// GetTree 获取分类树
+func (r *categoryRepository) GetTree() ([]*models.Category, error) {
+ var categories []*models.Category
+
+ // 获取所有分类
+ if err := r.db.Where("is_active = ?", true).
+ Order("sort ASC, created_at DESC").
+ Find(&categories).Error; err != nil {
+ return nil, fmt.Errorf("failed to get categories: %w", err)
+ }
+
+ // 构建分类树
+ categoryMap := make(map[uint]*models.Category)
+ var rootCategories []*models.Category
+
+ // 第一次遍历:建立映射
+ for _, category := range categories {
+ categoryMap[category.ID] = category
+ category.Children = []*models.Category{}
+ }
+
+ // 第二次遍历:构建树形结构
+ for _, category := range categories {
+ if category.ParentID == nil {
+ rootCategories = append(rootCategories, category)
+ } else {
+ if parent, exists := categoryMap[*category.ParentID]; exists {
+ parent.Children = append(parent.Children, category)
+ }
+ }
+ }
+
+ return rootCategories, nil
+}
+
+// GetChildren 获取子分类
+func (r *categoryRepository) GetChildren(parentID uint) ([]*models.Category, error) {
+ var categories []*models.Category
+
+ if err := r.db.Where("parent_id = ? AND is_active = ?", parentID, true).
+ Order("sort ASC, created_at DESC").
+ Find(&categories).Error; err != nil {
+ return nil, fmt.Errorf("failed to get child categories: %w", err)
+ }
+
+ return categories, nil
+}
+
+// GetStats 获取分类统计
+func (r *categoryRepository) GetStats() (*models.CategoryStats, error) {
+ var stats models.CategoryStats
+
+ // 总分类数
+ if err := r.db.Model(&models.Category{}).Count(&stats.TotalCategories).Error; err != nil {
+ return nil, fmt.Errorf("failed to count total categories: %w", err)
+ }
+
+ // 计算最大层级
+ // 这里简化处理,实际应用中可能需要递归查询
+ stats.MaxLevel = 3
+
+ // 特色分类数量(这里假设有一个is_featured字段,实际可能需要调整)
+ stats.FeaturedCount = 0
+
+ return &stats, nil
+}
+
+// UpdateSort 更新排序
+func (r *categoryRepository) UpdateSort(id uint, sort int) error {
+ if err := r.db.Model(&models.Category{}).Where("id = ?", id).
+ Update("sort", sort).Error; err != nil {
+ return fmt.Errorf("failed to update sort: %w", err)
+ }
+ return nil
+}
+
+// GetPhotoCount 获取分类的照片数量
+func (r *categoryRepository) GetPhotoCount(id uint) (int64, error) {
+ var count int64
+ if err := r.db.Model(&models.Photo{}).Where("category_id = ?", id).
+ Count(&count).Error; err != nil {
+ return 0, fmt.Errorf("failed to count photos: %w", err)
+ }
+ return count, nil
+}
\ No newline at end of file
diff --git a/backend/internal/repository/postgres/database.go b/backend/internal/repository/postgres/database.go
new file mode 100644
index 0000000..54b72df
--- /dev/null
+++ b/backend/internal/repository/postgres/database.go
@@ -0,0 +1,78 @@
+package postgres
+
+import (
+ "fmt"
+ "time"
+ "gorm.io/gorm"
+ "gorm.io/driver/postgres"
+ "photography-backend/internal/config"
+ "photography-backend/internal/models"
+)
+
+// Database 数据库连接
+type Database struct {
+ DB *gorm.DB
+}
+
+// NewDatabase 创建数据库连接
+func NewDatabase(cfg *config.DatabaseConfig) (*Database, error) {
+ dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
+ cfg.Host,
+ cfg.Port,
+ cfg.Username,
+ cfg.Password,
+ cfg.Database,
+ cfg.SSLMode,
+ )
+
+ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to database: %w", err)
+ }
+
+ // 获取底层sql.DB实例配置连接池
+ sqlDB, err := db.DB()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get sql.DB instance: %w", err)
+ }
+
+ // 设置连接池参数
+ sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
+ sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
+ sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second)
+
+ // 测试连接
+ if err := sqlDB.Ping(); err != nil {
+ return nil, fmt.Errorf("failed to ping database: %w", err)
+ }
+
+ return &Database{DB: db}, nil
+}
+
+// AutoMigrate 自动迁移数据库表结构
+func (d *Database) AutoMigrate() error {
+ return d.DB.AutoMigrate(
+ &models.User{},
+ &models.Category{},
+ &models.Tag{},
+ &models.Photo{},
+ )
+}
+
+// Close 关闭数据库连接
+func (d *Database) Close() error {
+ sqlDB, err := d.DB.DB()
+ if err != nil {
+ return err
+ }
+ return sqlDB.Close()
+}
+
+// Health 检查数据库健康状态
+func (d *Database) Health() error {
+ sqlDB, err := d.DB.DB()
+ if err != nil {
+ return err
+ }
+ return sqlDB.Ping()
+}
\ No newline at end of file
diff --git a/backend/internal/repository/postgres/photo_repository.go b/backend/internal/repository/postgres/photo_repository.go
new file mode 100644
index 0000000..8c7db8f
--- /dev/null
+++ b/backend/internal/repository/postgres/photo_repository.go
@@ -0,0 +1,303 @@
+package postgres
+
+import (
+ "fmt"
+ "photography-backend/internal/models"
+ "gorm.io/gorm"
+)
+
+// PhotoRepository 照片仓库接口
+type PhotoRepository interface {
+ Create(photo *models.Photo) error
+ GetByID(id uint) (*models.Photo, error)
+ Update(photo *models.Photo) error
+ Delete(id uint) error
+ List(params *models.PhotoListParams) ([]*models.Photo, int64, error)
+ GetByCategory(categoryID uint, page, limit int) ([]*models.Photo, int64, error)
+ GetByTag(tagID uint, page, limit int) ([]*models.Photo, int64, error)
+ GetByUser(userID uint, page, limit int) ([]*models.Photo, int64, error)
+ Search(query string, page, limit int) ([]*models.Photo, int64, error)
+ IncrementViewCount(id uint) error
+ IncrementLikeCount(id uint) error
+ UpdateStatus(id uint, status string) error
+ GetStats() (*PhotoStats, error)
+}
+
+// PhotoStats 照片统计
+type PhotoStats struct {
+ Total int64 `json:"total"`
+ Published int64 `json:"published"`
+ Draft int64 `json:"draft"`
+ Archived int64 `json:"archived"`
+}
+
+// photoRepository 照片仓库实现
+type photoRepository struct {
+ db *gorm.DB
+}
+
+// NewPhotoRepository 创建照片仓库
+func NewPhotoRepository(db *gorm.DB) PhotoRepository {
+ return &photoRepository{db: db}
+}
+
+// Create 创建照片
+func (r *photoRepository) Create(photo *models.Photo) error {
+ if err := r.db.Create(photo).Error; err != nil {
+ return fmt.Errorf("failed to create photo: %w", err)
+ }
+ return nil
+}
+
+// GetByID 根据ID获取照片
+func (r *photoRepository) GetByID(id uint) (*models.Photo, error) {
+ var photo models.Photo
+ if err := r.db.Preload("Category").Preload("Tags").Preload("User").
+ First(&photo, id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("failed to get photo by id: %w", err)
+ }
+ return &photo, nil
+}
+
+// Update 更新照片
+func (r *photoRepository) Update(photo *models.Photo) error {
+ if err := r.db.Save(photo).Error; err != nil {
+ return fmt.Errorf("failed to update photo: %w", err)
+ }
+ return nil
+}
+
+// Delete 删除照片
+func (r *photoRepository) Delete(id uint) error {
+ if err := r.db.Delete(&models.Photo{}, id).Error; err != nil {
+ return fmt.Errorf("failed to delete photo: %w", err)
+ }
+ return nil
+}
+
+// List 获取照片列表
+func (r *photoRepository) List(params *models.PhotoListParams) ([]*models.Photo, int64, error) {
+ var photos []*models.Photo
+ var total int64
+
+ query := r.db.Model(&models.Photo{}).
+ Preload("Category").
+ Preload("Tags").
+ Preload("User")
+
+ // 添加过滤条件
+ if params.CategoryID > 0 {
+ query = query.Where("category_id = ?", params.CategoryID)
+ }
+
+ if params.TagID > 0 {
+ query = query.Joins("JOIN photo_tags ON photos.id = photo_tags.photo_id").
+ Where("photo_tags.tag_id = ?", params.TagID)
+ }
+
+ if params.UserID > 0 {
+ query = query.Where("user_id = ?", params.UserID)
+ }
+
+ if params.Status != "" {
+ query = query.Where("status = ?", params.Status)
+ }
+
+ if params.Search != "" {
+ query = query.Where("title ILIKE ? OR description ILIKE ?",
+ "%"+params.Search+"%", "%"+params.Search+"%")
+ }
+
+ if params.Year > 0 {
+ query = query.Where("EXTRACT(YEAR FROM taken_at) = ?", params.Year)
+ }
+
+ if params.Month > 0 {
+ query = query.Where("EXTRACT(MONTH FROM taken_at) = ?", params.Month)
+ }
+
+ // 计算总数
+ if err := query.Count(&total).Error; err != nil {
+ return nil, 0, fmt.Errorf("failed to count photos: %w", err)
+ }
+
+ // 排序
+ orderClause := fmt.Sprintf("%s %s", params.SortBy, params.SortOrder)
+
+ // 分页查询
+ offset := (params.Page - 1) * params.Limit
+ if err := query.Offset(offset).Limit(params.Limit).
+ Order(orderClause).
+ Find(&photos).Error; err != nil {
+ return nil, 0, fmt.Errorf("failed to list photos: %w", err)
+ }
+
+ return photos, total, nil
+}
+
+// GetByCategory 根据分类获取照片
+func (r *photoRepository) GetByCategory(categoryID uint, page, limit int) ([]*models.Photo, int64, error) {
+ var photos []*models.Photo
+ var total int64
+
+ query := r.db.Model(&models.Photo{}).
+ Where("category_id = ? AND is_public = ?", categoryID, true).
+ Preload("Category").
+ Preload("Tags")
+
+ // 计算总数
+ if err := query.Count(&total).Error; err != nil {
+ return nil, 0, fmt.Errorf("failed to count photos by category: %w", err)
+ }
+
+ // 分页查询
+ offset := (page - 1) * limit
+ if err := query.Offset(offset).Limit(limit).
+ Order("created_at DESC").
+ Find(&photos).Error; err != nil {
+ return nil, 0, fmt.Errorf("failed to get photos by category: %w", err)
+ }
+
+ return photos, total, nil
+}
+
+// GetByTag 根据标签获取照片
+func (r *photoRepository) GetByTag(tagID uint, page, limit int) ([]*models.Photo, int64, error) {
+ var photos []*models.Photo
+ var total int64
+
+ query := r.db.Model(&models.Photo{}).
+ Joins("JOIN photo_tags ON photos.id = photo_tags.photo_id").
+ Where("photo_tags.tag_id = ? AND photos.is_public = ?", tagID, true).
+ Preload("Category").
+ Preload("Tags")
+
+ // 计算总数
+ if err := query.Count(&total).Error; err != nil {
+ return nil, 0, fmt.Errorf("failed to count photos by tag: %w", err)
+ }
+
+ // 分页查询
+ offset := (page - 1) * limit
+ if err := query.Offset(offset).Limit(limit).
+ Order("photos.created_at DESC").
+ Find(&photos).Error; err != nil {
+ return nil, 0, fmt.Errorf("failed to get photos by tag: %w", err)
+ }
+
+ return photos, total, nil
+}
+
+// GetByUser 根据用户获取照片
+func (r *photoRepository) GetByUser(userID uint, page, limit int) ([]*models.Photo, int64, error) {
+ var photos []*models.Photo
+ var total int64
+
+ query := r.db.Model(&models.Photo{}).
+ Where("user_id = ?", userID).
+ Preload("Category").
+ Preload("Tags")
+
+ // 计算总数
+ if err := query.Count(&total).Error; err != nil {
+ return nil, 0, fmt.Errorf("failed to count photos by user: %w", err)
+ }
+
+ // 分页查询
+ offset := (page - 1) * limit
+ if err := query.Offset(offset).Limit(limit).
+ Order("created_at DESC").
+ Find(&photos).Error; err != nil {
+ return nil, 0, fmt.Errorf("failed to get photos by user: %w", err)
+ }
+
+ return photos, total, nil
+}
+
+// Search 搜索照片
+func (r *photoRepository) Search(query string, page, limit int) ([]*models.Photo, int64, error) {
+ var photos []*models.Photo
+ var total int64
+
+ searchQuery := r.db.Model(&models.Photo{}).
+ Where("title ILIKE ? OR description ILIKE ? OR location ILIKE ?",
+ "%"+query+"%", "%"+query+"%", "%"+query+"%").
+ Where("is_public = ?", true).
+ Preload("Category").
+ Preload("Tags")
+
+ // 计算总数
+ if err := searchQuery.Count(&total).Error; err != nil {
+ return nil, 0, fmt.Errorf("failed to count search results: %w", err)
+ }
+
+ // 分页查询
+ offset := (page - 1) * limit
+ if err := searchQuery.Offset(offset).Limit(limit).
+ Order("created_at DESC").
+ Find(&photos).Error; err != nil {
+ return nil, 0, fmt.Errorf("failed to search photos: %w", err)
+ }
+
+ return photos, total, nil
+}
+
+// IncrementViewCount 增加浏览次数
+func (r *photoRepository) IncrementViewCount(id uint) error {
+ if err := r.db.Model(&models.Photo{}).Where("id = ?", id).
+ Update("view_count", gorm.Expr("view_count + 1")).Error; err != nil {
+ return fmt.Errorf("failed to increment view count: %w", err)
+ }
+ return nil
+}
+
+// IncrementLikeCount 增加点赞次数
+func (r *photoRepository) IncrementLikeCount(id uint) error {
+ if err := r.db.Model(&models.Photo{}).Where("id = ?", id).
+ Update("like_count", gorm.Expr("like_count + 1")).Error; err != nil {
+ return fmt.Errorf("failed to increment like count: %w", err)
+ }
+ return nil
+}
+
+// UpdateStatus 更新状态
+func (r *photoRepository) UpdateStatus(id uint, status string) error {
+ if err := r.db.Model(&models.Photo{}).Where("id = ?", id).
+ Update("status", status).Error; err != nil {
+ return fmt.Errorf("failed to update status: %w", err)
+ }
+ return nil
+}
+
+// GetStats 获取照片统计
+func (r *photoRepository) GetStats() (*PhotoStats, error) {
+ var stats PhotoStats
+
+ // 总数
+ if err := r.db.Model(&models.Photo{}).Count(&stats.Total).Error; err != nil {
+ return nil, fmt.Errorf("failed to count total photos: %w", err)
+ }
+
+ // 已发布
+ if err := r.db.Model(&models.Photo{}).Where("status = ?", models.StatusPublished).
+ Count(&stats.Published).Error; err != nil {
+ return nil, fmt.Errorf("failed to count published photos: %w", err)
+ }
+
+ // 草稿
+ if err := r.db.Model(&models.Photo{}).Where("status = ?", models.StatusDraft).
+ Count(&stats.Draft).Error; err != nil {
+ return nil, fmt.Errorf("failed to count draft photos: %w", err)
+ }
+
+ // 已归档
+ if err := r.db.Model(&models.Photo{}).Where("status = ?", models.StatusArchived).
+ Count(&stats.Archived).Error; err != nil {
+ return nil, fmt.Errorf("failed to count archived photos: %w", err)
+ }
+
+ return &stats, nil
+}
\ No newline at end of file
diff --git a/backend/internal/repository/postgres/tag_repository.go b/backend/internal/repository/postgres/tag_repository.go
new file mode 100644
index 0000000..948a2db
--- /dev/null
+++ b/backend/internal/repository/postgres/tag_repository.go
@@ -0,0 +1,217 @@
+package postgres
+
+import (
+ "fmt"
+ "photography-backend/internal/models"
+ "gorm.io/gorm"
+)
+
+// TagRepository 标签仓库接口
+type TagRepository interface {
+ Create(tag *models.Tag) error
+ GetByID(id uint) (*models.Tag, error)
+ GetByName(name string) (*models.Tag, error)
+ Update(tag *models.Tag) error
+ Delete(id uint) error
+ List(params *models.TagListParams) ([]*models.Tag, int64, error)
+ Search(query string, limit int) ([]*models.Tag, error)
+ GetPopular(limit int) ([]*models.Tag, error)
+ GetOrCreate(name string) (*models.Tag, error)
+ IncrementUseCount(id uint) error
+ DecrementUseCount(id uint) error
+ GetCloud(minUsage int, maxTags int) ([]*models.Tag, error)
+}
+
+// tagRepository 标签仓库实现
+type tagRepository struct {
+ db *gorm.DB
+}
+
+// NewTagRepository 创建标签仓库
+func NewTagRepository(db *gorm.DB) TagRepository {
+ return &tagRepository{db: db}
+}
+
+// Create 创建标签
+func (r *tagRepository) Create(tag *models.Tag) error {
+ if err := r.db.Create(tag).Error; err != nil {
+ return fmt.Errorf("failed to create tag: %w", err)
+ }
+ return nil
+}
+
+// GetByID 根据ID获取标签
+func (r *tagRepository) GetByID(id uint) (*models.Tag, error) {
+ var tag models.Tag
+ if err := r.db.First(&tag, id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("failed to get tag by id: %w", err)
+ }
+ return &tag, nil
+}
+
+// GetByName 根据名称获取标签
+func (r *tagRepository) GetByName(name string) (*models.Tag, error) {
+ var tag models.Tag
+ if err := r.db.Where("name = ?", name).First(&tag).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("failed to get tag by name: %w", err)
+ }
+ return &tag, nil
+}
+
+// Update 更新标签
+func (r *tagRepository) Update(tag *models.Tag) error {
+ if err := r.db.Save(tag).Error; err != nil {
+ return fmt.Errorf("failed to update tag: %w", err)
+ }
+ return nil
+}
+
+// Delete 删除标签
+func (r *tagRepository) Delete(id uint) error {
+ // 开启事务
+ tx := r.db.Begin()
+
+ // 删除照片标签关联
+ if err := tx.Exec("DELETE FROM photo_tags WHERE tag_id = ?", id).Error; err != nil {
+ tx.Rollback()
+ return fmt.Errorf("failed to delete photo tag relations: %w", err)
+ }
+
+ // 删除标签
+ if err := tx.Delete(&models.Tag{}, id).Error; err != nil {
+ tx.Rollback()
+ return fmt.Errorf("failed to delete tag: %w", err)
+ }
+
+ return tx.Commit().Error
+}
+
+// List 获取标签列表
+func (r *tagRepository) List(params *models.TagListParams) ([]*models.Tag, int64, error) {
+ var tags []*models.Tag
+ var total int64
+
+ query := r.db.Model(&models.Tag{})
+
+ // 添加过滤条件
+ if params.Search != "" {
+ query = query.Where("name ILIKE ?", "%"+params.Search+"%")
+ }
+
+ if params.IsActive {
+ query = query.Where("is_active = ?", true)
+ }
+
+ // 计算总数
+ if err := query.Count(&total).Error; err != nil {
+ return nil, 0, fmt.Errorf("failed to count tags: %w", err)
+ }
+
+ // 排序
+ orderClause := fmt.Sprintf("%s %s", params.SortBy, params.SortOrder)
+
+ // 分页查询
+ offset := (params.Page - 1) * params.Limit
+ if err := query.Offset(offset).Limit(params.Limit).
+ Order(orderClause).
+ Find(&tags).Error; err != nil {
+ return nil, 0, fmt.Errorf("failed to list tags: %w", err)
+ }
+
+ return tags, total, nil
+}
+
+// Search 搜索标签
+func (r *tagRepository) Search(query string, limit int) ([]*models.Tag, error) {
+ var tags []*models.Tag
+
+ if err := r.db.Where("name ILIKE ? AND is_active = ?", "%"+query+"%", true).
+ Order("use_count DESC").
+ Limit(limit).
+ Find(&tags).Error; err != nil {
+ return nil, fmt.Errorf("failed to search tags: %w", err)
+ }
+
+ return tags, nil
+}
+
+// GetPopular 获取热门标签
+func (r *tagRepository) GetPopular(limit int) ([]*models.Tag, error) {
+ var tags []*models.Tag
+
+ if err := r.db.Where("is_active = ?", true).
+ Order("use_count DESC").
+ Limit(limit).
+ Find(&tags).Error; err != nil {
+ return nil, fmt.Errorf("failed to get popular tags: %w", err)
+ }
+
+ return tags, nil
+}
+
+// GetOrCreate 获取或创建标签
+func (r *tagRepository) GetOrCreate(name string) (*models.Tag, error) {
+ var tag models.Tag
+
+ // 先尝试获取
+ if err := r.db.Where("name = ?", name).First(&tag).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ // 不存在则创建
+ tag = models.Tag{
+ Name: name,
+ UseCount: 0,
+ IsActive: true,
+ }
+ if err := r.db.Create(&tag).Error; err != nil {
+ return nil, fmt.Errorf("failed to create tag: %w", err)
+ }
+ } else {
+ return nil, fmt.Errorf("failed to get tag: %w", err)
+ }
+ }
+
+ return &tag, nil
+}
+
+// IncrementUseCount 增加使用次数
+func (r *tagRepository) IncrementUseCount(id uint) error {
+ if err := r.db.Model(&models.Tag{}).Where("id = ?", id).
+ Update("use_count", gorm.Expr("use_count + 1")).Error; err != nil {
+ return fmt.Errorf("failed to increment use count: %w", err)
+ }
+ return nil
+}
+
+// DecrementUseCount 减少使用次数
+func (r *tagRepository) DecrementUseCount(id uint) error {
+ if err := r.db.Model(&models.Tag{}).Where("id = ?", id).
+ Update("use_count", gorm.Expr("GREATEST(use_count - 1, 0)")).Error; err != nil {
+ return fmt.Errorf("failed to decrement use count: %w", err)
+ }
+ return nil
+}
+
+// GetCloud 获取标签云数据
+func (r *tagRepository) GetCloud(minUsage int, maxTags int) ([]*models.Tag, error) {
+ var tags []*models.Tag
+
+ query := r.db.Where("is_active = ?", true)
+
+ if minUsage > 0 {
+ query = query.Where("use_count >= ?", minUsage)
+ }
+
+ if err := query.Order("use_count DESC").
+ Limit(maxTags).
+ Find(&tags).Error; err != nil {
+ return nil, fmt.Errorf("failed to get tag cloud: %w", err)
+ }
+
+ return tags, nil
+}
\ No newline at end of file
diff --git a/backend/internal/repository/postgres/user_repository.go b/backend/internal/repository/postgres/user_repository.go
new file mode 100644
index 0000000..6dc81df
--- /dev/null
+++ b/backend/internal/repository/postgres/user_repository.go
@@ -0,0 +1,129 @@
+package postgres
+
+import (
+ "fmt"
+ "photography-backend/internal/models"
+ "gorm.io/gorm"
+)
+
+// UserRepository 用户仓库接口
+type UserRepository interface {
+ Create(user *models.User) error
+ GetByID(id uint) (*models.User, error)
+ GetByUsername(username string) (*models.User, error)
+ GetByEmail(email string) (*models.User, error)
+ Update(user *models.User) error
+ Delete(id uint) error
+ List(page, limit int, role string, isActive *bool) ([]*models.User, int64, error)
+ UpdateLastLogin(id uint) error
+}
+
+// userRepository 用户仓库实现
+type userRepository struct {
+ db *gorm.DB
+}
+
+// NewUserRepository 创建用户仓库
+func NewUserRepository(db *gorm.DB) UserRepository {
+ return &userRepository{db: db}
+}
+
+// Create 创建用户
+func (r *userRepository) Create(user *models.User) error {
+ if err := r.db.Create(user).Error; err != nil {
+ return fmt.Errorf("failed to create user: %w", err)
+ }
+ return nil
+}
+
+// GetByID 根据ID获取用户
+func (r *userRepository) GetByID(id uint) (*models.User, error) {
+ var user models.User
+ if err := r.db.First(&user, id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("failed to get user by id: %w", err)
+ }
+ return &user, nil
+}
+
+// GetByUsername 根据用户名获取用户
+func (r *userRepository) GetByUsername(username string) (*models.User, error) {
+ var user models.User
+ if err := r.db.Where("username = ?", username).First(&user).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("failed to get user by username: %w", err)
+ }
+ return &user, nil
+}
+
+// GetByEmail 根据邮箱获取用户
+func (r *userRepository) GetByEmail(email string) (*models.User, error) {
+ var user models.User
+ if err := r.db.Where("email = ?", email).First(&user).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("failed to get user by email: %w", err)
+ }
+ return &user, nil
+}
+
+// Update 更新用户
+func (r *userRepository) Update(user *models.User) error {
+ if err := r.db.Save(user).Error; err != nil {
+ return fmt.Errorf("failed to update user: %w", err)
+ }
+ return nil
+}
+
+// Delete 删除用户
+func (r *userRepository) Delete(id uint) error {
+ if err := r.db.Delete(&models.User{}, id).Error; err != nil {
+ return fmt.Errorf("failed to delete user: %w", err)
+ }
+ return nil
+}
+
+// List 获取用户列表
+func (r *userRepository) List(page, limit int, role string, isActive *bool) ([]*models.User, int64, error) {
+ var users []*models.User
+ var total int64
+
+ query := r.db.Model(&models.User{})
+
+ // 添加过滤条件
+ if role != "" {
+ query = query.Where("role = ?", role)
+ }
+ if isActive != nil {
+ query = query.Where("is_active = ?", *isActive)
+ }
+
+ // 计算总数
+ if err := query.Count(&total).Error; err != nil {
+ return nil, 0, fmt.Errorf("failed to count users: %w", err)
+ }
+
+ // 分页查询
+ offset := (page - 1) * limit
+ if err := query.Offset(offset).Limit(limit).
+ Order("created_at DESC").
+ Find(&users).Error; err != nil {
+ return nil, 0, fmt.Errorf("failed to list users: %w", err)
+ }
+
+ return users, total, nil
+}
+
+// UpdateLastLogin 更新最后登录时间
+func (r *userRepository) UpdateLastLogin(id uint) error {
+ if err := r.db.Model(&models.User{}).Where("id = ?", id).
+ Update("last_login", gorm.Expr("NOW()")).Error; err != nil {
+ return fmt.Errorf("failed to update last login: %w", err)
+ }
+ return nil
+}
\ No newline at end of file
diff --git a/backend/internal/service/auth/auth_service.go b/backend/internal/service/auth/auth_service.go
new file mode 100644
index 0000000..81729e5
--- /dev/null
+++ b/backend/internal/service/auth/auth_service.go
@@ -0,0 +1,253 @@
+package auth
+
+import (
+ "fmt"
+ "time"
+ "golang.org/x/crypto/bcrypt"
+ "photography-backend/internal/models"
+ "photography-backend/internal/repository/postgres"
+)
+
+// AuthService 认证服务
+type AuthService struct {
+ userRepo postgres.UserRepository
+ jwtService *JWTService
+}
+
+// NewAuthService 创建认证服务
+func NewAuthService(userRepo postgres.UserRepository, jwtService *JWTService) *AuthService {
+ return &AuthService{
+ userRepo: userRepo,
+ jwtService: jwtService,
+ }
+}
+
+// Login 用户登录
+func (s *AuthService) Login(req *models.LoginRequest) (*models.LoginResponse, error) {
+ // 根据用户名或邮箱查找用户
+ var user *models.User
+ var err error
+
+ // 尝试按用户名查找
+ user, err = s.userRepo.GetByUsername(req.Username)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get user: %w", err)
+ }
+
+ // 如果用户名未找到,尝试按邮箱查找
+ if user == nil {
+ user, err = s.userRepo.GetByEmail(req.Username)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get user by email: %w", err)
+ }
+ }
+
+ if user == nil {
+ return nil, fmt.Errorf("invalid credentials")
+ }
+
+ // 检查用户是否激活
+ if !user.IsActive {
+ return nil, fmt.Errorf("user account is deactivated")
+ }
+
+ // 验证密码
+ if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
+ return nil, fmt.Errorf("invalid credentials")
+ }
+
+ // 生成JWT令牌
+ tokenPair, err := s.jwtService.GenerateTokenPair(user.ID, user.Username, user.Role)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate tokens: %w", err)
+ }
+
+ // 更新最后登录时间
+ if err := s.userRepo.UpdateLastLogin(user.ID); err != nil {
+ // 记录错误但不中断登录流程
+ fmt.Printf("failed to update last login: %v\n", err)
+ }
+
+ // 清除密码字段
+ user.Password = ""
+
+ return &models.LoginResponse{
+ AccessToken: tokenPair.AccessToken,
+ RefreshToken: tokenPair.RefreshToken,
+ TokenType: tokenPair.TokenType,
+ ExpiresIn: tokenPair.ExpiresIn,
+ User: user,
+ }, nil
+}
+
+// Register 用户注册
+func (s *AuthService) Register(req *models.CreateUserRequest) (*models.User, error) {
+ // 检查用户名是否已存在
+ existingUser, err := s.userRepo.GetByUsername(req.Username)
+ if err != nil {
+ return nil, fmt.Errorf("failed to check username: %w", err)
+ }
+ if existingUser != nil {
+ return nil, fmt.Errorf("username already exists")
+ }
+
+ // 检查邮箱是否已存在
+ existingUser, err = s.userRepo.GetByEmail(req.Email)
+ if err != nil {
+ return nil, fmt.Errorf("failed to check email: %w", err)
+ }
+ if existingUser != nil {
+ return nil, fmt.Errorf("email already exists")
+ }
+
+ // 加密密码
+ hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
+ if err != nil {
+ return nil, fmt.Errorf("failed to hash password: %w", err)
+ }
+
+ // 创建用户
+ user := &models.User{
+ Username: req.Username,
+ Email: req.Email,
+ Password: string(hashedPassword),
+ Name: req.Name,
+ Role: req.Role,
+ IsActive: true,
+ }
+
+ // 如果没有指定角色,默认为普通用户
+ if user.Role == "" {
+ user.Role = models.RoleUser
+ }
+
+ if err := s.userRepo.Create(user); err != nil {
+ return nil, fmt.Errorf("failed to create user: %w", err)
+ }
+
+ // 清除密码字段
+ user.Password = ""
+
+ return user, nil
+}
+
+// RefreshToken 刷新令牌
+func (s *AuthService) RefreshToken(req *models.RefreshTokenRequest) (*models.LoginResponse, error) {
+ // 验证刷新令牌
+ claims, err := s.jwtService.ValidateToken(req.RefreshToken)
+ if err != nil {
+ return nil, fmt.Errorf("invalid refresh token: %w", err)
+ }
+
+ // 获取用户信息
+ user, err := s.userRepo.GetByID(claims.UserID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get user: %w", err)
+ }
+
+ if user == nil {
+ return nil, fmt.Errorf("user not found")
+ }
+
+ // 检查用户是否激活
+ if !user.IsActive {
+ return nil, fmt.Errorf("user account is deactivated")
+ }
+
+ // 生成新的令牌对
+ tokenPair, err := s.jwtService.GenerateTokenPair(user.ID, user.Username, user.Role)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate tokens: %w", err)
+ }
+
+ // 清除密码字段
+ user.Password = ""
+
+ return &models.LoginResponse{
+ AccessToken: tokenPair.AccessToken,
+ RefreshToken: tokenPair.RefreshToken,
+ TokenType: tokenPair.TokenType,
+ ExpiresIn: tokenPair.ExpiresIn,
+ User: user,
+ }, nil
+}
+
+// GetUserByID 根据ID获取用户
+func (s *AuthService) GetUserByID(id uint) (*models.User, error) {
+ user, err := s.userRepo.GetByID(id)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get user: %w", err)
+ }
+
+ if user == nil {
+ return nil, fmt.Errorf("user not found")
+ }
+
+ // 清除密码字段
+ user.Password = ""
+
+ return user, nil
+}
+
+// UpdatePassword 更新密码
+func (s *AuthService) UpdatePassword(userID uint, req *models.UpdatePasswordRequest) error {
+ // 获取用户信息
+ user, err := s.userRepo.GetByID(userID)
+ if err != nil {
+ return fmt.Errorf("failed to get user: %w", err)
+ }
+
+ if user == nil {
+ return fmt.Errorf("user not found")
+ }
+
+ // 验证旧密码
+ if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
+ return fmt.Errorf("invalid old password")
+ }
+
+ // 加密新密码
+ hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
+ if err != nil {
+ return fmt.Errorf("failed to hash password: %w", err)
+ }
+
+ // 更新密码
+ user.Password = string(hashedPassword)
+ if err := s.userRepo.Update(user); err != nil {
+ return fmt.Errorf("failed to update password: %w", err)
+ }
+
+ return nil
+}
+
+// CheckPermission 检查权限
+func (s *AuthService) CheckPermission(userRole string, requiredRole string) bool {
+ roleLevel := map[string]int{
+ models.RoleUser: 1,
+ models.RoleEditor: 2,
+ models.RoleAdmin: 3,
+ }
+
+ userLevel, exists := roleLevel[userRole]
+ if !exists {
+ return false
+ }
+
+ requiredLevel, exists := roleLevel[requiredRole]
+ if !exists {
+ return false
+ }
+
+ return userLevel >= requiredLevel
+}
+
+// IsAdmin 检查是否为管理员
+func (s *AuthService) IsAdmin(userRole string) bool {
+ return userRole == models.RoleAdmin
+}
+
+// IsEditor 检查是否为编辑者或以上
+func (s *AuthService) IsEditor(userRole string) bool {
+ return userRole == models.RoleEditor || userRole == models.RoleAdmin
+}
\ No newline at end of file
diff --git a/backend/internal/service/auth/jwt_service.go b/backend/internal/service/auth/jwt_service.go
new file mode 100644
index 0000000..3363b2a
--- /dev/null
+++ b/backend/internal/service/auth/jwt_service.go
@@ -0,0 +1,129 @@
+package auth
+
+import (
+ "fmt"
+ "time"
+ "github.com/golang-jwt/jwt/v5"
+ "photography-backend/internal/config"
+)
+
+// JWTService JWT服务
+type JWTService struct {
+ secretKey []byte
+ accessTokenDuration time.Duration
+ refreshTokenDuration time.Duration
+}
+
+// JWTClaims JWT声明
+type JWTClaims struct {
+ UserID uint `json:"user_id"`
+ Username string `json:"username"`
+ Role string `json:"role"`
+ jwt.RegisteredClaims
+}
+
+// TokenPair 令牌对
+type TokenPair struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+}
+
+// NewJWTService 创建JWT服务
+func NewJWTService(cfg *config.JWTConfig) *JWTService {
+ return &JWTService{
+ secretKey: []byte(cfg.Secret),
+ accessTokenDuration: config.AppConfig.GetJWTExpiration(),
+ refreshTokenDuration: config.AppConfig.GetJWTRefreshExpiration(),
+ }
+}
+
+// GenerateTokenPair 生成令牌对
+func (s *JWTService) GenerateTokenPair(userID uint, username, role string) (*TokenPair, error) {
+ // 生成访问令牌
+ accessToken, err := s.generateToken(userID, username, role, s.accessTokenDuration)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate access token: %w", err)
+ }
+
+ // 生成刷新令牌
+ refreshToken, err := s.generateToken(userID, username, role, s.refreshTokenDuration)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate refresh token: %w", err)
+ }
+
+ return &TokenPair{
+ AccessToken: accessToken,
+ RefreshToken: refreshToken,
+ TokenType: "Bearer",
+ ExpiresIn: int64(s.accessTokenDuration.Seconds()),
+ }, nil
+}
+
+// generateToken 生成令牌
+func (s *JWTService) generateToken(userID uint, username, role string, duration time.Duration) (string, error) {
+ now := time.Now()
+ claims := &JWTClaims{
+ UserID: userID,
+ Username: username,
+ Role: role,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(now.Add(duration)),
+ IssuedAt: jwt.NewNumericDate(now),
+ NotBefore: jwt.NewNumericDate(now),
+ Issuer: "photography-backend",
+ },
+ }
+
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ return token.SignedString(s.secretKey)
+}
+
+// ValidateToken 验证令牌
+func (s *JWTService) ValidateToken(tokenString string) (*JWTClaims, error) {
+ token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
+ if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
+ }
+ return s.secretKey, nil
+ })
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse token: %w", err)
+ }
+
+ if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
+ return claims, nil
+ }
+
+ return nil, fmt.Errorf("invalid token")
+}
+
+// RefreshToken 刷新令牌
+func (s *JWTService) RefreshToken(refreshToken string) (*TokenPair, error) {
+ claims, err := s.ValidateToken(refreshToken)
+ if err != nil {
+ return nil, fmt.Errorf("invalid refresh token: %w", err)
+ }
+
+ // 生成新的令牌对
+ return s.GenerateTokenPair(claims.UserID, claims.Username, claims.Role)
+}
+
+// GetClaimsFromToken 从令牌中获取声明
+func (s *JWTService) GetClaimsFromToken(tokenString string) (*JWTClaims, error) {
+ token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
+ return s.secretKey, nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ if claims, ok := token.Claims.(*JWTClaims); ok {
+ return claims, nil
+ }
+
+ return nil, fmt.Errorf("invalid claims")
+}
\ No newline at end of file
diff --git a/backend/internal/service/category_service.go b/backend/internal/service/category_service.go
new file mode 100644
index 0000000..f4216b2
--- /dev/null
+++ b/backend/internal/service/category_service.go
@@ -0,0 +1,448 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "photography-backend/internal/models"
+ "photography-backend/internal/utils"
+
+ "go.uber.org/zap"
+ "gorm.io/gorm"
+)
+
+type CategoryService struct {
+ db *gorm.DB
+ logger *zap.Logger
+}
+
+func NewCategoryService(db *gorm.DB, logger *zap.Logger) *CategoryService {
+ return &CategoryService{
+ db: db,
+ logger: logger,
+ }
+}
+
+// GetCategories 获取分类列表
+func (s *CategoryService) GetCategories(ctx context.Context, parentID *uint) ([]models.Category, error) {
+ var categories []models.Category
+
+ query := s.db.WithContext(ctx).Order("sort_order ASC, created_at ASC")
+
+ if parentID != nil {
+ query = query.Where("parent_id = ?", *parentID)
+ } else {
+ query = query.Where("parent_id IS NULL")
+ }
+
+ if err := query.Find(&categories).Error; err != nil {
+ s.logger.Error("Failed to get categories", zap.Error(err))
+ return nil, err
+ }
+
+ return categories, nil
+}
+
+// GetCategoryTree 获取分类树
+func (s *CategoryService) GetCategoryTree(ctx context.Context) ([]models.CategoryTree, error) {
+ var categories []models.Category
+ if err := s.db.WithContext(ctx).
+ Order("sort_order ASC, created_at ASC").
+ Find(&categories).Error; err != nil {
+ s.logger.Error("Failed to get all categories", zap.Error(err))
+ return nil, err
+ }
+
+ // 构建树形结构
+ tree := s.buildCategoryTree(categories, nil)
+ return tree, nil
+}
+
+// GetCategoryByID 根据ID获取分类
+func (s *CategoryService) GetCategoryByID(ctx context.Context, id uint) (*models.Category, error) {
+ var category models.Category
+ if err := s.db.WithContext(ctx).First(&category, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("category not found")
+ }
+ s.logger.Error("Failed to get category by ID", zap.Error(err), zap.Uint("id", id))
+ return nil, err
+ }
+
+ return &category, nil
+}
+
+// GetCategoryBySlug 根据slug获取分类
+func (s *CategoryService) GetCategoryBySlug(ctx context.Context, slug string) (*models.Category, error) {
+ var category models.Category
+ if err := s.db.WithContext(ctx).Where("slug = ?", slug).First(&category).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("category not found")
+ }
+ s.logger.Error("Failed to get category by slug", zap.Error(err), zap.String("slug", slug))
+ return nil, err
+ }
+
+ return &category, nil
+}
+
+// CreateCategory 创建分类
+func (s *CategoryService) CreateCategory(ctx context.Context, req *models.CreateCategoryRequest) (*models.Category, error) {
+ // 验证slug唯一性
+ if err := s.validateSlugUnique(ctx, req.Slug, 0); err != nil {
+ return nil, err
+ }
+
+ // 验证父分类存在性
+ if req.ParentID != nil {
+ var parentCategory models.Category
+ if err := s.db.WithContext(ctx).First(&parentCategory, *req.ParentID).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("parent category not found")
+ }
+ return nil, err
+ }
+ }
+
+ // 获取排序顺序
+ sortOrder := s.getNextSortOrder(ctx, req.ParentID)
+
+ category := &models.Category{
+ Name: req.Name,
+ Slug: req.Slug,
+ Description: req.Description,
+ ParentID: req.ParentID,
+ SortOrder: sortOrder,
+ IsActive: true,
+ }
+
+ if err := s.db.WithContext(ctx).Create(category).Error; err != nil {
+ s.logger.Error("Failed to create category", zap.Error(err))
+ return nil, err
+ }
+
+ s.logger.Info("Category created successfully", zap.Uint("id", category.ID))
+ return category, nil
+}
+
+// UpdateCategory 更新分类
+func (s *CategoryService) UpdateCategory(ctx context.Context, id uint, req *models.UpdateCategoryRequest) (*models.Category, error) {
+ // 检查分类是否存在
+ var category models.Category
+ if err := s.db.WithContext(ctx).First(&category, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("category not found")
+ }
+ return nil, err
+ }
+
+ // 验证slug唯一性
+ if req.Slug != nil && *req.Slug != category.Slug {
+ if err := s.validateSlugUnique(ctx, *req.Slug, id); err != nil {
+ return nil, err
+ }
+ }
+
+ // 验证父分类(防止循环引用)
+ if req.ParentID != nil && *req.ParentID != category.ParentID {
+ if err := s.validateParentCategory(ctx, id, *req.ParentID); err != nil {
+ return nil, err
+ }
+ }
+
+ // 构建更新数据
+ updates := map[string]interface{}{}
+ if req.Name != nil {
+ updates["name"] = *req.Name
+ }
+ if req.Slug != nil {
+ updates["slug"] = *req.Slug
+ }
+ if req.Description != nil {
+ updates["description"] = *req.Description
+ }
+ if req.ParentID != nil {
+ if *req.ParentID == 0 {
+ updates["parent_id"] = nil
+ } else {
+ updates["parent_id"] = *req.ParentID
+ }
+ }
+ if req.SortOrder != nil {
+ updates["sort_order"] = *req.SortOrder
+ }
+ if req.IsActive != nil {
+ updates["is_active"] = *req.IsActive
+ }
+
+ if len(updates) > 0 {
+ if err := s.db.WithContext(ctx).Model(&category).Updates(updates).Error; err != nil {
+ s.logger.Error("Failed to update category", zap.Error(err))
+ return nil, err
+ }
+ }
+
+ s.logger.Info("Category updated successfully", zap.Uint("id", id))
+ return &category, nil
+}
+
+// DeleteCategory 删除分类
+func (s *CategoryService) DeleteCategory(ctx context.Context, id uint) error {
+ // 检查分类是否存在
+ var category models.Category
+ if err := s.db.WithContext(ctx).First(&category, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("category not found")
+ }
+ return err
+ }
+
+ // 检查是否有子分类
+ var childCount int64
+ if err := s.db.WithContext(ctx).Model(&models.Category{}).
+ Where("parent_id = ?", id).Count(&childCount).Error; err != nil {
+ return err
+ }
+
+ if childCount > 0 {
+ return errors.New("cannot delete category with subcategories")
+ }
+
+ // 检查是否有关联的照片
+ var photoCount int64
+ if err := s.db.WithContext(ctx).Table("photo_categories").
+ Where("category_id = ?", id).Count(&photoCount).Error; err != nil {
+ return err
+ }
+
+ if photoCount > 0 {
+ return errors.New("cannot delete category with associated photos")
+ }
+
+ // 删除分类
+ if err := s.db.WithContext(ctx).Delete(&category).Error; err != nil {
+ s.logger.Error("Failed to delete category", zap.Error(err))
+ return err
+ }
+
+ s.logger.Info("Category deleted successfully", zap.Uint("id", id))
+ return nil
+}
+
+// ReorderCategories 重新排序分类
+func (s *CategoryService) ReorderCategories(ctx context.Context, parentID *uint, categoryIDs []uint) error {
+ // 验证所有分类都属于同一父分类
+ var categories []models.Category
+ query := s.db.WithContext(ctx).Where("id IN ?", categoryIDs)
+
+ if parentID != nil {
+ query = query.Where("parent_id = ?", *parentID)
+ } else {
+ query = query.Where("parent_id IS NULL")
+ }
+
+ if err := query.Find(&categories).Error; err != nil {
+ return err
+ }
+
+ if len(categories) != len(categoryIDs) {
+ return errors.New("invalid category IDs")
+ }
+
+ // 开始事务
+ tx := s.db.WithContext(ctx).Begin()
+ if tx.Error != nil {
+ return tx.Error
+ }
+ defer tx.Rollback()
+
+ // 更新排序
+ for i, categoryID := range categoryIDs {
+ if err := tx.Model(&models.Category{}).
+ Where("id = ?", categoryID).
+ Update("sort_order", i+1).Error; err != nil {
+ return err
+ }
+ }
+
+ // 提交事务
+ if err := tx.Commit().Error; err != nil {
+ return err
+ }
+
+ s.logger.Info("Categories reordered successfully", zap.Int("count", len(categoryIDs)))
+ return nil
+}
+
+// GetCategoryStats 获取分类统计信息
+func (s *CategoryService) GetCategoryStats(ctx context.Context) (*models.CategoryStats, error) {
+ var stats models.CategoryStats
+
+ // 总分类数
+ if err := s.db.WithContext(ctx).Model(&models.Category{}).Count(&stats.Total).Error; err != nil {
+ return nil, err
+ }
+
+ // 活跃分类数
+ if err := s.db.WithContext(ctx).Model(&models.Category{}).
+ Where("is_active = ?", true).Count(&stats.Active).Error; err != nil {
+ return nil, err
+ }
+
+ // 顶级分类数
+ if err := s.db.WithContext(ctx).Model(&models.Category{}).
+ Where("parent_id IS NULL").Count(&stats.TopLevel).Error; err != nil {
+ return nil, err
+ }
+
+ // 各分类照片数量
+ var categoryPhotoStats []struct {
+ CategoryID uint `json:"category_id"`
+ Name string `json:"name"`
+ PhotoCount int64 `json:"photo_count"`
+ }
+
+ if err := s.db.WithContext(ctx).
+ Table("categories").
+ Select("categories.id as category_id, categories.name, COUNT(photo_categories.photo_id) as photo_count").
+ Joins("LEFT JOIN photo_categories ON categories.id = photo_categories.category_id").
+ Group("categories.id, categories.name").
+ Order("photo_count DESC").
+ Limit(10).
+ Find(&categoryPhotoStats).Error; err != nil {
+ return nil, err
+ }
+
+ stats.PhotoCounts = make(map[string]int64)
+ for _, stat := range categoryPhotoStats {
+ stats.PhotoCounts[stat.Name] = stat.PhotoCount
+ }
+
+ return &stats, nil
+}
+
+// validateSlugUnique 验证slug唯一性
+func (s *CategoryService) validateSlugUnique(ctx context.Context, slug string, excludeID uint) error {
+ var count int64
+ query := s.db.WithContext(ctx).Model(&models.Category{}).Where("slug = ?", slug)
+
+ if excludeID > 0 {
+ query = query.Where("id != ?", excludeID)
+ }
+
+ if err := query.Count(&count).Error; err != nil {
+ return err
+ }
+
+ if count > 0 {
+ return errors.New("slug already exists")
+ }
+
+ return nil
+}
+
+// validateParentCategory 验证父分类(防止循环引用)
+func (s *CategoryService) validateParentCategory(ctx context.Context, categoryID, parentID uint) error {
+ if categoryID == parentID {
+ return errors.New("category cannot be its own parent")
+ }
+
+ // 检查是否会形成循环引用
+ current := parentID
+ for current != 0 {
+ var parent models.Category
+ if err := s.db.WithContext(ctx).First(&parent, current).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("parent category not found")
+ }
+ return err
+ }
+
+ if parent.ParentID == nil {
+ break
+ }
+
+ if *parent.ParentID == categoryID {
+ return errors.New("circular reference detected")
+ }
+
+ current = *parent.ParentID
+ }
+
+ return nil
+}
+
+// getNextSortOrder 获取下一个排序顺序
+func (s *CategoryService) getNextSortOrder(ctx context.Context, parentID *uint) int {
+ var maxOrder int
+
+ query := s.db.WithContext(ctx).Model(&models.Category{}).Select("COALESCE(MAX(sort_order), 0)")
+
+ if parentID != nil {
+ query = query.Where("parent_id = ?", *parentID)
+ } else {
+ query = query.Where("parent_id IS NULL")
+ }
+
+ query.Row().Scan(&maxOrder)
+
+ return maxOrder + 1
+}
+
+// buildCategoryTree 构建分类树
+func (s *CategoryService) buildCategoryTree(categories []models.Category, parentID *uint) []models.CategoryTree {
+ var tree []models.CategoryTree
+
+ for _, category := range categories {
+ // 检查是否匹配父分类
+ if (parentID == nil && category.ParentID == nil) ||
+ (parentID != nil && category.ParentID != nil && *category.ParentID == *parentID) {
+
+ node := models.CategoryTree{
+ ID: category.ID,
+ Name: category.Name,
+ Slug: category.Slug,
+ Description: category.Description,
+ ParentID: category.ParentID,
+ SortOrder: category.SortOrder,
+ IsActive: category.IsActive,
+ PhotoCount: category.PhotoCount,
+ CreatedAt: category.CreatedAt,
+ UpdatedAt: category.UpdatedAt,
+ }
+
+ // 递归构建子分类
+ node.Children = s.buildCategoryTree(categories, &category.ID)
+
+ tree = append(tree, node)
+ }
+ }
+
+ return tree
+}
+
+// GenerateSlug 生成slug
+func (s *CategoryService) GenerateSlug(ctx context.Context, name string) (string, error) {
+ baseSlug := utils.GenerateSlug(name)
+ slug := baseSlug
+
+ counter := 1
+ for {
+ var count int64
+ if err := s.db.WithContext(ctx).Model(&models.Category{}).
+ Where("slug = ?", slug).Count(&count).Error; err != nil {
+ return "", err
+ }
+
+ if count == 0 {
+ break
+ }
+
+ slug = fmt.Sprintf("%s-%d", baseSlug, counter)
+ counter++
+ }
+
+ return slug, nil
+}
\ No newline at end of file
diff --git a/backend/internal/service/photo_service.go b/backend/internal/service/photo_service.go
new file mode 100644
index 0000000..c096b4d
--- /dev/null
+++ b/backend/internal/service/photo_service.go
@@ -0,0 +1,678 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "mime/multipart"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "photography-backend/internal/config"
+ "photography-backend/internal/models"
+ "photography-backend/internal/service/storage"
+ "photography-backend/internal/utils"
+
+ "go.uber.org/zap"
+ "gorm.io/gorm"
+)
+
+type PhotoService struct {
+ db *gorm.DB
+ config *config.Config
+ logger *zap.Logger
+ storageService *storage.StorageService
+}
+
+func NewPhotoService(db *gorm.DB, config *config.Config, logger *zap.Logger, storageService *storage.StorageService) *PhotoService {
+ return &PhotoService{
+ db: db,
+ config: config,
+ logger: logger,
+ storageService: storageService,
+ }
+}
+
+// PhotoListParams 照片列表查询参数
+type PhotoListParams struct {
+ Page int `json:"page" form:"page"`
+ Limit int `json:"limit" form:"limit"`
+ Search string `json:"search" form:"search"`
+ Status string `json:"status" form:"status"`
+ CategoryID uint `json:"category_id" form:"category_id"`
+ Tags []string `json:"tags" form:"tags"`
+ StartDate string `json:"start_date" form:"start_date"`
+ EndDate string `json:"end_date" form:"end_date"`
+ SortBy string `json:"sort_by" form:"sort_by"`
+ SortOrder string `json:"sort_order" form:"sort_order"`
+}
+
+// PhotoListResponse 照片列表响应
+type PhotoListResponse struct {
+ Photos []models.Photo `json:"photos"`
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+ Limit int `json:"limit"`
+ Pages int `json:"pages"`
+}
+
+// GetPhotos 获取照片列表
+func (s *PhotoService) GetPhotos(ctx context.Context, params PhotoListParams) (*PhotoListResponse, error) {
+ // 设置默认值
+ if params.Page <= 0 {
+ params.Page = 1
+ }
+ if params.Limit <= 0 {
+ params.Limit = 20
+ }
+ if params.Limit > 100 {
+ params.Limit = 100
+ }
+
+ // 构建查询
+ query := s.db.WithContext(ctx).
+ Preload("Categories").
+ Preload("Tags").
+ Preload("Formats")
+
+ // 搜索过滤
+ if params.Search != "" {
+ searchPattern := "%" + params.Search + "%"
+ query = query.Where("title ILIKE ? OR description ILIKE ? OR original_filename ILIKE ?",
+ searchPattern, searchPattern, searchPattern)
+ }
+
+ // 状态过滤
+ if params.Status != "" {
+ query = query.Where("status = ?", params.Status)
+ }
+
+ // 分类过滤
+ if params.CategoryID > 0 {
+ query = query.Joins("JOIN photo_categories ON photos.id = photo_categories.photo_id").
+ Where("photo_categories.category_id = ?", params.CategoryID)
+ }
+
+ // 标签过滤
+ if len(params.Tags) > 0 {
+ query = query.Joins("JOIN photo_tags ON photos.id = photo_tags.photo_id").
+ Joins("JOIN tags ON photo_tags.tag_id = tags.id").
+ Where("tags.slug IN ?", params.Tags)
+ }
+
+ // 日期过滤
+ if params.StartDate != "" {
+ if startDate, err := time.Parse("2006-01-02", params.StartDate); err == nil {
+ query = query.Where("taken_at >= ?", startDate)
+ }
+ }
+ if params.EndDate != "" {
+ if endDate, err := time.Parse("2006-01-02", params.EndDate); err == nil {
+ query = query.Where("taken_at <= ?", endDate)
+ }
+ }
+
+ // 排序
+ sortBy := "created_at"
+ sortOrder := "desc"
+ if params.SortBy != "" {
+ allowedSortFields := []string{"created_at", "updated_at", "taken_at", "title", "file_size"}
+ if utils.Contains(allowedSortFields, params.SortBy) {
+ sortBy = params.SortBy
+ }
+ }
+ if params.SortOrder == "asc" {
+ sortOrder = "asc"
+ }
+
+ // 计算总数
+ var total int64
+ countQuery := query
+ if err := countQuery.Model(&models.Photo{}).Count(&total).Error; err != nil {
+ s.logger.Error("Failed to count photos", zap.Error(err))
+ return nil, err
+ }
+
+ // 分页查询
+ offset := (params.Page - 1) * params.Limit
+ var photos []models.Photo
+ if err := query.
+ Order(fmt.Sprintf("%s %s", sortBy, sortOrder)).
+ Offset(offset).
+ Limit(params.Limit).
+ Find(&photos).Error; err != nil {
+ s.logger.Error("Failed to get photos", zap.Error(err))
+ return nil, err
+ }
+
+ // 计算总页数
+ pages := int((total + int64(params.Limit) - 1) / int64(params.Limit))
+
+ return &PhotoListResponse{
+ Photos: photos,
+ Total: total,
+ Page: params.Page,
+ Limit: params.Limit,
+ Pages: pages,
+ }, nil
+}
+
+// GetPhotoByID 根据ID获取照片
+func (s *PhotoService) GetPhotoByID(ctx context.Context, id uint) (*models.Photo, error) {
+ var photo models.Photo
+ if err := s.db.WithContext(ctx).
+ Preload("Categories").
+ Preload("Tags").
+ Preload("Formats").
+ First(&photo, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("photo not found")
+ }
+ s.logger.Error("Failed to get photo by ID", zap.Error(err), zap.Uint("id", id))
+ return nil, err
+ }
+
+ return &photo, nil
+}
+
+// CreatePhoto 创建照片
+func (s *PhotoService) CreatePhoto(ctx context.Context, req *models.CreatePhotoRequest) (*models.Photo, error) {
+ // 生成唯一的文件名
+ uniqueFilename := utils.GenerateUniqueFilename(req.OriginalFilename)
+
+ photo := &models.Photo{
+ Title: req.Title,
+ Description: req.Description,
+ OriginalFilename: req.OriginalFilename,
+ UniqueFilename: uniqueFilename,
+ FileSize: req.FileSize,
+ Status: req.Status,
+ Camera: req.Camera,
+ Lens: req.Lens,
+ ISO: req.ISO,
+ Aperture: req.Aperture,
+ ShutterSpeed: req.ShutterSpeed,
+ FocalLength: req.FocalLength,
+ TakenAt: req.TakenAt,
+ }
+
+ // 开始事务
+ tx := s.db.WithContext(ctx).Begin()
+ if tx.Error != nil {
+ return nil, tx.Error
+ }
+ defer tx.Rollback()
+
+ // 创建照片记录
+ if err := tx.Create(photo).Error; err != nil {
+ s.logger.Error("Failed to create photo", zap.Error(err))
+ return nil, err
+ }
+
+ // 关联分类
+ if len(req.CategoryIDs) > 0 {
+ var categories []models.Category
+ if err := tx.Where("id IN ?", req.CategoryIDs).Find(&categories).Error; err != nil {
+ s.logger.Error("Failed to find categories", zap.Error(err))
+ return nil, err
+ }
+ if err := tx.Model(photo).Association("Categories").Replace(categories); err != nil {
+ s.logger.Error("Failed to associate categories", zap.Error(err))
+ return nil, err
+ }
+ }
+
+ // 关联标签
+ if len(req.TagIDs) > 0 {
+ var tags []models.Tag
+ if err := tx.Where("id IN ?", req.TagIDs).Find(&tags).Error; err != nil {
+ s.logger.Error("Failed to find tags", zap.Error(err))
+ return nil, err
+ }
+ if err := tx.Model(photo).Association("Tags").Replace(tags); err != nil {
+ s.logger.Error("Failed to associate tags", zap.Error(err))
+ return nil, err
+ }
+ }
+
+ // 提交事务
+ if err := tx.Commit().Error; err != nil {
+ s.logger.Error("Failed to commit transaction", zap.Error(err))
+ return nil, err
+ }
+
+ // 重新加载关联数据
+ if err := s.db.WithContext(ctx).
+ Preload("Categories").
+ Preload("Tags").
+ Preload("Formats").
+ First(photo, photo.ID).Error; err != nil {
+ s.logger.Error("Failed to reload photo", zap.Error(err))
+ return nil, err
+ }
+
+ s.logger.Info("Photo created successfully", zap.Uint("id", photo.ID))
+ return photo, nil
+}
+
+// UpdatePhoto 更新照片
+func (s *PhotoService) UpdatePhoto(ctx context.Context, id uint, req *models.UpdatePhotoRequest) (*models.Photo, error) {
+ // 检查照片是否存在
+ var photo models.Photo
+ if err := s.db.WithContext(ctx).First(&photo, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("photo not found")
+ }
+ return nil, err
+ }
+
+ // 开始事务
+ tx := s.db.WithContext(ctx).Begin()
+ if tx.Error != nil {
+ return nil, tx.Error
+ }
+ defer tx.Rollback()
+
+ // 更新照片基本信息
+ updates := map[string]interface{}{}
+ if req.Title != nil {
+ updates["title"] = *req.Title
+ }
+ if req.Description != nil {
+ updates["description"] = *req.Description
+ }
+ if req.Status != nil {
+ updates["status"] = *req.Status
+ }
+ if req.Camera != nil {
+ updates["camera"] = *req.Camera
+ }
+ if req.Lens != nil {
+ updates["lens"] = *req.Lens
+ }
+ if req.ISO != nil {
+ updates["iso"] = *req.ISO
+ }
+ if req.Aperture != nil {
+ updates["aperture"] = *req.Aperture
+ }
+ if req.ShutterSpeed != nil {
+ updates["shutter_speed"] = *req.ShutterSpeed
+ }
+ if req.FocalLength != nil {
+ updates["focal_length"] = *req.FocalLength
+ }
+ if req.TakenAt != nil {
+ updates["taken_at"] = *req.TakenAt
+ }
+
+ if len(updates) > 0 {
+ if err := tx.Model(&photo).Updates(updates).Error; err != nil {
+ s.logger.Error("Failed to update photo", zap.Error(err))
+ return nil, err
+ }
+ }
+
+ // 更新分类关联
+ if req.CategoryIDs != nil {
+ var categories []models.Category
+ if len(*req.CategoryIDs) > 0 {
+ if err := tx.Where("id IN ?", *req.CategoryIDs).Find(&categories).Error; err != nil {
+ s.logger.Error("Failed to find categories", zap.Error(err))
+ return nil, err
+ }
+ }
+ if err := tx.Model(&photo).Association("Categories").Replace(categories); err != nil {
+ s.logger.Error("Failed to update categories", zap.Error(err))
+ return nil, err
+ }
+ }
+
+ // 更新标签关联
+ if req.TagIDs != nil {
+ var tags []models.Tag
+ if len(*req.TagIDs) > 0 {
+ if err := tx.Where("id IN ?", *req.TagIDs).Find(&tags).Error; err != nil {
+ s.logger.Error("Failed to find tags", zap.Error(err))
+ return nil, err
+ }
+ }
+ if err := tx.Model(&photo).Association("Tags").Replace(tags); err != nil {
+ s.logger.Error("Failed to update tags", zap.Error(err))
+ return nil, err
+ }
+ }
+
+ // 提交事务
+ if err := tx.Commit().Error; err != nil {
+ s.logger.Error("Failed to commit transaction", zap.Error(err))
+ return nil, err
+ }
+
+ // 重新加载照片数据
+ if err := s.db.WithContext(ctx).
+ Preload("Categories").
+ Preload("Tags").
+ Preload("Formats").
+ First(&photo, id).Error; err != nil {
+ s.logger.Error("Failed to reload photo", zap.Error(err))
+ return nil, err
+ }
+
+ s.logger.Info("Photo updated successfully", zap.Uint("id", id))
+ return &photo, nil
+}
+
+// DeletePhoto 删除照片
+func (s *PhotoService) DeletePhoto(ctx context.Context, id uint) error {
+ // 检查照片是否存在
+ var photo models.Photo
+ if err := s.db.WithContext(ctx).First(&photo, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("photo not found")
+ }
+ return err
+ }
+
+ // 开始事务
+ tx := s.db.WithContext(ctx).Begin()
+ if tx.Error != nil {
+ return tx.Error
+ }
+ defer tx.Rollback()
+
+ // 删除关联的格式文件
+ if err := tx.Where("photo_id = ?", id).Delete(&models.PhotoFormat{}).Error; err != nil {
+ s.logger.Error("Failed to delete photo formats", zap.Error(err))
+ return err
+ }
+
+ // 删除关联关系
+ if err := tx.Model(&photo).Association("Categories").Clear(); err != nil {
+ s.logger.Error("Failed to clear categories", zap.Error(err))
+ return err
+ }
+
+ if err := tx.Model(&photo).Association("Tags").Clear(); err != nil {
+ s.logger.Error("Failed to clear tags", zap.Error(err))
+ return err
+ }
+
+ // 删除照片记录
+ if err := tx.Delete(&photo).Error; err != nil {
+ s.logger.Error("Failed to delete photo", zap.Error(err))
+ return err
+ }
+
+ // 提交事务
+ if err := tx.Commit().Error; err != nil {
+ s.logger.Error("Failed to commit transaction", zap.Error(err))
+ return err
+ }
+
+ // 异步删除文件
+ go func() {
+ if err := s.storageService.DeletePhoto(photo.UniqueFilename); err != nil {
+ s.logger.Error("Failed to delete photo files", zap.Error(err), zap.String("filename", photo.UniqueFilename))
+ }
+ }()
+
+ s.logger.Info("Photo deleted successfully", zap.Uint("id", id))
+ return nil
+}
+
+// UploadPhoto 上传照片
+func (s *PhotoService) UploadPhoto(ctx context.Context, file multipart.File, header *multipart.FileHeader, req *models.CreatePhotoRequest) (*models.Photo, error) {
+ // 验证文件类型
+ if !s.isValidImageFile(header.Filename) {
+ return nil, errors.New("invalid file type")
+ }
+
+ // 验证文件大小
+ if header.Size > s.config.Upload.MaxFileSize {
+ return nil, errors.New("file size too large")
+ }
+
+ // 生成唯一文件名
+ uniqueFilename := utils.GenerateUniqueFilename(header.Filename)
+
+ // 上传文件到存储服务
+ uploadedFile, err := s.storageService.UploadPhoto(ctx, file, uniqueFilename)
+ if err != nil {
+ s.logger.Error("Failed to upload photo", zap.Error(err))
+ return nil, err
+ }
+
+ // 创建照片记录
+ req.OriginalFilename = header.Filename
+ req.FileSize = header.Size
+
+ photo, err := s.CreatePhoto(ctx, req)
+ if err != nil {
+ // 如果创建记录失败,删除已上传的文件
+ go func() {
+ if err := s.storageService.DeletePhoto(uniqueFilename); err != nil {
+ s.logger.Error("Failed to cleanup uploaded file", zap.Error(err))
+ }
+ }()
+ return nil, err
+ }
+
+ // 异步处理图片格式转换
+ go func() {
+ s.processPhotoFormats(context.Background(), photo, uploadedFile)
+ }()
+
+ return photo, nil
+}
+
+// BatchUpdatePhotos 批量更新照片
+func (s *PhotoService) BatchUpdatePhotos(ctx context.Context, ids []uint, req *models.BatchUpdatePhotosRequest) error {
+ if len(ids) == 0 {
+ return errors.New("no photos to update")
+ }
+
+ // 开始事务
+ tx := s.db.WithContext(ctx).Begin()
+ if tx.Error != nil {
+ return tx.Error
+ }
+ defer tx.Rollback()
+
+ // 构建更新数据
+ updates := map[string]interface{}{}
+ if req.Status != nil {
+ updates["status"] = *req.Status
+ }
+
+ // 基础字段更新
+ if len(updates) > 0 {
+ if err := tx.Model(&models.Photo{}).Where("id IN ?", ids).Updates(updates).Error; err != nil {
+ s.logger.Error("Failed to batch update photos", zap.Error(err))
+ return err
+ }
+ }
+
+ // 批量更新分类
+ if req.CategoryIDs != nil {
+ // 先删除现有关联
+ if err := tx.Exec("DELETE FROM photo_categories WHERE photo_id IN ?", ids).Error; err != nil {
+ return err
+ }
+
+ // 添加新关联
+ if len(*req.CategoryIDs) > 0 {
+ for _, photoID := range ids {
+ for _, categoryID := range *req.CategoryIDs {
+ if err := tx.Exec("INSERT INTO photo_categories (photo_id, category_id) VALUES (?, ?)", photoID, categoryID).Error; err != nil {
+ return err
+ }
+ }
+ }
+ }
+ }
+
+ // 批量更新标签
+ if req.TagIDs != nil {
+ // 先删除现有关联
+ if err := tx.Exec("DELETE FROM photo_tags WHERE photo_id IN ?", ids).Error; err != nil {
+ return err
+ }
+
+ // 添加新关联
+ if len(*req.TagIDs) > 0 {
+ for _, photoID := range ids {
+ for _, tagID := range *req.TagIDs {
+ if err := tx.Exec("INSERT INTO photo_tags (photo_id, tag_id) VALUES (?, ?)", photoID, tagID).Error; err != nil {
+ return err
+ }
+ }
+ }
+ }
+ }
+
+ // 提交事务
+ if err := tx.Commit().Error; err != nil {
+ s.logger.Error("Failed to commit batch update", zap.Error(err))
+ return err
+ }
+
+ s.logger.Info("Batch update completed", zap.Int("count", len(ids)))
+ return nil
+}
+
+// BatchDeletePhotos 批量删除照片
+func (s *PhotoService) BatchDeletePhotos(ctx context.Context, ids []uint) error {
+ if len(ids) == 0 {
+ return errors.New("no photos to delete")
+ }
+
+ // 获取要删除的照片信息
+ var photos []models.Photo
+ if err := s.db.WithContext(ctx).Where("id IN ?", ids).Find(&photos).Error; err != nil {
+ return err
+ }
+
+ // 开始事务
+ tx := s.db.WithContext(ctx).Begin()
+ if tx.Error != nil {
+ return tx.Error
+ }
+ defer tx.Rollback()
+
+ // 删除关联的格式文件
+ if err := tx.Where("photo_id IN ?", ids).Delete(&models.PhotoFormat{}).Error; err != nil {
+ return err
+ }
+
+ // 删除关联关系
+ if err := tx.Exec("DELETE FROM photo_categories WHERE photo_id IN ?", ids).Error; err != nil {
+ return err
+ }
+
+ if err := tx.Exec("DELETE FROM photo_tags WHERE photo_id IN ?", ids).Error; err != nil {
+ return err
+ }
+
+ // 删除照片记录
+ if err := tx.Where("id IN ?", ids).Delete(&models.Photo{}).Error; err != nil {
+ return err
+ }
+
+ // 提交事务
+ if err := tx.Commit().Error; err != nil {
+ return err
+ }
+
+ // 异步删除文件
+ go func() {
+ for _, photo := range photos {
+ if err := s.storageService.DeletePhoto(photo.UniqueFilename); err != nil {
+ s.logger.Error("Failed to delete photo files", zap.Error(err), zap.String("filename", photo.UniqueFilename))
+ }
+ }
+ }()
+
+ s.logger.Info("Batch delete completed", zap.Int("count", len(ids)))
+ return nil
+}
+
+// GetPhotoStats 获取照片统计信息
+func (s *PhotoService) GetPhotoStats(ctx context.Context) (*models.PhotoStats, error) {
+ var stats models.PhotoStats
+
+ // 总数统计
+ if err := s.db.WithContext(ctx).Model(&models.Photo{}).Count(&stats.Total).Error; err != nil {
+ return nil, err
+ }
+
+ // 按状态统计
+ var statusStats []struct {
+ Status string `json:"status"`
+ Count int64 `json:"count"`
+ }
+ if err := s.db.WithContext(ctx).Model(&models.Photo{}).
+ Select("status, COUNT(*) as count").
+ Group("status").
+ Find(&statusStats).Error; err != nil {
+ return nil, err
+ }
+
+ stats.StatusStats = make(map[string]int64)
+ for _, stat := range statusStats {
+ stats.StatusStats[stat.Status] = stat.Count
+ }
+
+ // 本月新增
+ startOfMonth := time.Now().AddDate(0, 0, -time.Now().Day()+1)
+ if err := s.db.WithContext(ctx).Model(&models.Photo{}).
+ Where("created_at >= ?", startOfMonth).
+ Count(&stats.ThisMonth).Error; err != nil {
+ return nil, err
+ }
+
+ // 今日新增
+ startOfDay := time.Now().Truncate(24 * time.Hour)
+ if err := s.db.WithContext(ctx).Model(&models.Photo{}).
+ Where("created_at >= ?", startOfDay).
+ Count(&stats.Today).Error; err != nil {
+ return nil, err
+ }
+
+ // 总存储大小
+ var totalSize sql.NullInt64
+ if err := s.db.WithContext(ctx).Model(&models.Photo{}).
+ Select("SUM(file_size)").
+ Row().Scan(&totalSize); err != nil {
+ return nil, err
+ }
+ if totalSize.Valid {
+ stats.TotalSize = totalSize.Int64
+ }
+
+ return &stats, nil
+}
+
+// isValidImageFile 验证图片文件类型
+func (s *PhotoService) isValidImageFile(filename string) bool {
+ ext := strings.ToLower(filepath.Ext(filename))
+ allowedExts := []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
+ return utils.Contains(allowedExts, ext)
+}
+
+// processPhotoFormats 处理照片格式转换
+func (s *PhotoService) processPhotoFormats(ctx context.Context, photo *models.Photo, uploadedFile *storage.UploadedFile) {
+ // 这里将实现图片格式转换逻辑
+ // 生成不同尺寸和格式的图片
+ // 更新 photo_formats 表
+
+ s.logger.Info("Processing photo formats", zap.Uint("photo_id", photo.ID))
+
+ // TODO: 实现图片处理逻辑
+ // 1. 生成缩略图
+ // 2. 生成不同尺寸的图片
+ // 3. 转换为不同格式 (WebP, AVIF)
+ // 4. 更新数据库记录
+}
\ No newline at end of file
diff --git a/backend/internal/service/storage/storage.go b/backend/internal/service/storage/storage.go
new file mode 100644
index 0000000..df13b44
--- /dev/null
+++ b/backend/internal/service/storage/storage.go
@@ -0,0 +1,218 @@
+package storage
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "os"
+ "path/filepath"
+
+ "photography-backend/internal/config"
+
+ "go.uber.org/zap"
+)
+
+// UploadedFile 上传后的文件信息
+type UploadedFile struct {
+ Filename string `json:"filename"`
+ OriginalURL string `json:"original_url"`
+ ThumbnailURL string `json:"thumbnail_url,omitempty"`
+ Size int64 `json:"size"`
+ MimeType string `json:"mime_type"`
+}
+
+// StorageService 存储服务接口
+type StorageService interface {
+ UploadPhoto(ctx context.Context, file multipart.File, filename string) (*UploadedFile, error)
+ DeletePhoto(filename string) error
+ GetPhotoURL(filename string) string
+ GenerateThumbnail(ctx context.Context, filename string) error
+}
+
+// LocalStorageService 本地存储服务实现
+type LocalStorageService struct {
+ config *config.Config
+ logger *zap.Logger
+ uploadDir string
+ baseURL string
+}
+
+// NewLocalStorageService 创建本地存储服务
+func NewLocalStorageService(config *config.Config, logger *zap.Logger) *LocalStorageService {
+ uploadDir := config.Upload.Path
+ if uploadDir == "" {
+ uploadDir = "./uploads"
+ }
+
+ baseURL := config.Upload.BaseURL
+ if baseURL == "" {
+ baseURL = fmt.Sprintf("http://localhost:%d/uploads", config.Server.Port)
+ }
+
+ // 确保上传目录存在
+ if err := os.MkdirAll(uploadDir, 0755); err != nil {
+ logger.Error("Failed to create upload directory", zap.Error(err))
+ }
+
+ // 创建子目录
+ dirs := []string{"photos", "thumbnails", "temp"}
+ for _, dir := range dirs {
+ dirPath := filepath.Join(uploadDir, dir)
+ if err := os.MkdirAll(dirPath, 0755); err != nil {
+ logger.Error("Failed to create subdirectory", zap.String("dir", dir), zap.Error(err))
+ }
+ }
+
+ return &LocalStorageService{
+ config: config,
+ logger: logger,
+ uploadDir: uploadDir,
+ baseURL: baseURL,
+ }
+}
+
+// UploadPhoto 上传照片
+func (s *LocalStorageService) UploadPhoto(ctx context.Context, file multipart.File, filename string) (*UploadedFile, error) {
+ // 保存原图
+ photoPath := filepath.Join(s.uploadDir, "photos", filename)
+
+ out, err := os.Create(photoPath)
+ if err != nil {
+ s.logger.Error("Failed to create file", zap.String("path", photoPath), zap.Error(err))
+ return nil, err
+ }
+ defer out.Close()
+
+ // 重置文件指针
+ file.Seek(0, 0)
+
+ // 复制文件内容
+ size, err := io.Copy(out, file)
+ if err != nil {
+ s.logger.Error("Failed to copy file", zap.Error(err))
+ return nil, err
+ }
+
+ // 获取文件信息
+ fileInfo, err := out.Stat()
+ if err != nil {
+ s.logger.Error("Failed to get file info", zap.Error(err))
+ return nil, err
+ }
+
+ uploadedFile := &UploadedFile{
+ Filename: filename,
+ OriginalURL: s.GetPhotoURL(filename),
+ Size: size,
+ MimeType: s.getMimeType(filename),
+ }
+
+ s.logger.Info("Photo uploaded successfully",
+ zap.String("filename", filename),
+ zap.Int64("size", size))
+
+ return uploadedFile, nil
+}
+
+// DeletePhoto 删除照片
+func (s *LocalStorageService) DeletePhoto(filename string) error {
+ // 删除原图
+ photoPath := filepath.Join(s.uploadDir, "photos", filename)
+ if err := os.Remove(photoPath); err != nil && !os.IsNotExist(err) {
+ s.logger.Error("Failed to delete photo", zap.String("path", photoPath), zap.Error(err))
+ return err
+ }
+
+ // 删除缩略图
+ thumbnailPath := filepath.Join(s.uploadDir, "thumbnails", filename)
+ if err := os.Remove(thumbnailPath); err != nil && !os.IsNotExist(err) {
+ s.logger.Warn("Failed to delete thumbnail", zap.String("path", thumbnailPath), zap.Error(err))
+ }
+
+ s.logger.Info("Photo deleted successfully", zap.String("filename", filename))
+ return nil
+}
+
+// GetPhotoURL 获取照片 URL
+func (s *LocalStorageService) GetPhotoURL(filename string) string {
+ return fmt.Sprintf("%s/photos/%s", s.baseURL, filename)
+}
+
+// GetThumbnailURL 获取缩略图 URL
+func (s *LocalStorageService) GetThumbnailURL(filename string) string {
+ return fmt.Sprintf("%s/thumbnails/%s", s.baseURL, filename)
+}
+
+// GenerateThumbnail 生成缩略图
+func (s *LocalStorageService) GenerateThumbnail(ctx context.Context, filename string) error {
+ // TODO: 实现缩略图生成逻辑
+ // 这里需要使用图像处理库,如 imaging 或 bild
+ s.logger.Info("Generating thumbnail", zap.String("filename", filename))
+
+ // 示例实现 - 实际项目中应该使用图像处理库
+ photoPath := filepath.Join(s.uploadDir, "photos", filename)
+ thumbnailPath := filepath.Join(s.uploadDir, "thumbnails", filename)
+
+ // 检查原图是否存在
+ if _, err := os.Stat(photoPath); os.IsNotExist(err) {
+ return fmt.Errorf("original photo not found: %s", filename)
+ }
+
+ // 这里应该实现实际的缩略图生成逻辑
+ // 暂时复制原图作为缩略图
+ sourceFile, err := os.Open(photoPath)
+ if err != nil {
+ return err
+ }
+ defer sourceFile.Close()
+
+ destFile, err := os.Create(thumbnailPath)
+ if err != nil {
+ return err
+ }
+ defer destFile.Close()
+
+ _, err = io.Copy(destFile, sourceFile)
+ if err != nil {
+ return err
+ }
+
+ s.logger.Info("Thumbnail generated successfully", zap.String("filename", filename))
+ return nil
+}
+
+// getMimeType 根据文件扩展名获取 MIME 类型
+func (s *LocalStorageService) getMimeType(filename string) string {
+ ext := filepath.Ext(filename)
+ switch ext {
+ case ".jpg", ".jpeg":
+ return "image/jpeg"
+ case ".png":
+ return "image/png"
+ case ".gif":
+ return "image/gif"
+ case ".webp":
+ return "image/webp"
+ case ".bmp":
+ return "image/bmp"
+ default:
+ return "application/octet-stream"
+ }
+}
+
+// NewStorageService 根据配置创建存储服务
+func NewStorageService(config *config.Config, logger *zap.Logger) StorageService {
+ switch config.Upload.Type {
+ case "s3":
+ // TODO: 实现 S3 存储服务
+ logger.Warn("S3 storage not implemented yet, using local storage")
+ return NewLocalStorageService(config, logger)
+ case "minio":
+ // TODO: 实现 MinIO 存储服务
+ logger.Warn("MinIO storage not implemented yet, using local storage")
+ return NewLocalStorageService(config, logger)
+ default:
+ return NewLocalStorageService(config, logger)
+ }
+}
\ No newline at end of file
diff --git a/backend/internal/service/tag_service.go b/backend/internal/service/tag_service.go
new file mode 100644
index 0000000..5f5e26a
--- /dev/null
+++ b/backend/internal/service/tag_service.go
@@ -0,0 +1,482 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "photography-backend/internal/models"
+ "photography-backend/internal/utils"
+
+ "go.uber.org/zap"
+ "gorm.io/gorm"
+)
+
+type TagService struct {
+ db *gorm.DB
+ logger *zap.Logger
+}
+
+func NewTagService(db *gorm.DB, logger *zap.Logger) *TagService {
+ return &TagService{
+ db: db,
+ logger: logger,
+ }
+}
+
+// TagListParams 标签列表查询参数
+type TagListParams struct {
+ Page int `json:"page" form:"page"`
+ Limit int `json:"limit" form:"limit"`
+ Search string `json:"search" form:"search"`
+ IsActive *bool `json:"is_active" form:"is_active"`
+ SortBy string `json:"sort_by" form:"sort_by"`
+ SortOrder string `json:"sort_order" form:"sort_order"`
+}
+
+// TagListResponse 标签列表响应
+type TagListResponse struct {
+ Tags []models.Tag `json:"tags"`
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+ Limit int `json:"limit"`
+ Pages int `json:"pages"`
+}
+
+// GetTags 获取标签列表
+func (s *TagService) GetTags(ctx context.Context, params TagListParams) (*TagListResponse, error) {
+ // 设置默认值
+ if params.Page <= 0 {
+ params.Page = 1
+ }
+ if params.Limit <= 0 {
+ params.Limit = 20
+ }
+ if params.Limit > 100 {
+ params.Limit = 100
+ }
+
+ // 构建查询
+ query := s.db.WithContext(ctx)
+
+ // 搜索过滤
+ if params.Search != "" {
+ searchPattern := "%" + params.Search + "%"
+ query = query.Where("name ILIKE ? OR slug ILIKE ?", searchPattern, searchPattern)
+ }
+
+ // 状态过滤
+ if params.IsActive != nil {
+ query = query.Where("is_active = ?", *params.IsActive)
+ }
+
+ // 排序
+ sortBy := "created_at"
+ sortOrder := "desc"
+ if params.SortBy != "" {
+ allowedSortFields := []string{"created_at", "updated_at", "name", "photo_count"}
+ if utils.Contains(allowedSortFields, params.SortBy) {
+ sortBy = params.SortBy
+ }
+ }
+ if params.SortOrder == "asc" {
+ sortOrder = "asc"
+ }
+
+ // 计算总数
+ var total int64
+ countQuery := query
+ if err := countQuery.Model(&models.Tag{}).Count(&total).Error; err != nil {
+ s.logger.Error("Failed to count tags", zap.Error(err))
+ return nil, err
+ }
+
+ // 分页查询
+ offset := (params.Page - 1) * params.Limit
+ var tags []models.Tag
+ if err := query.
+ Order(fmt.Sprintf("%s %s", sortBy, sortOrder)).
+ Offset(offset).
+ Limit(params.Limit).
+ Find(&tags).Error; err != nil {
+ s.logger.Error("Failed to get tags", zap.Error(err))
+ return nil, err
+ }
+
+ // 计算总页数
+ pages := int((total + int64(params.Limit) - 1) / int64(params.Limit))
+
+ return &TagListResponse{
+ Tags: tags,
+ Total: total,
+ Page: params.Page,
+ Limit: params.Limit,
+ Pages: pages,
+ }, nil
+}
+
+// GetAllTags 获取所有活跃标签
+func (s *TagService) GetAllTags(ctx context.Context) ([]models.Tag, error) {
+ var tags []models.Tag
+ if err := s.db.WithContext(ctx).
+ Where("is_active = ?", true).
+ Order("name ASC").
+ Find(&tags).Error; err != nil {
+ s.logger.Error("Failed to get all tags", zap.Error(err))
+ return nil, err
+ }
+
+ return tags, nil
+}
+
+// GetTagByID 根据ID获取标签
+func (s *TagService) GetTagByID(ctx context.Context, id uint) (*models.Tag, error) {
+ var tag models.Tag
+ if err := s.db.WithContext(ctx).First(&tag, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("tag not found")
+ }
+ s.logger.Error("Failed to get tag by ID", zap.Error(err), zap.Uint("id", id))
+ return nil, err
+ }
+
+ return &tag, nil
+}
+
+// GetTagBySlug 根据slug获取标签
+func (s *TagService) GetTagBySlug(ctx context.Context, slug string) (*models.Tag, error) {
+ var tag models.Tag
+ if err := s.db.WithContext(ctx).Where("slug = ?", slug).First(&tag).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("tag not found")
+ }
+ s.logger.Error("Failed to get tag by slug", zap.Error(err), zap.String("slug", slug))
+ return nil, err
+ }
+
+ return &tag, nil
+}
+
+// CreateTag 创建标签
+func (s *TagService) CreateTag(ctx context.Context, req *models.CreateTagRequest) (*models.Tag, error) {
+ // 验证slug唯一性
+ if err := s.validateSlugUnique(ctx, req.Slug, 0); err != nil {
+ return nil, err
+ }
+
+ tag := &models.Tag{
+ Name: req.Name,
+ Slug: req.Slug,
+ Description: req.Description,
+ Color: req.Color,
+ IsActive: true,
+ }
+
+ if err := s.db.WithContext(ctx).Create(tag).Error; err != nil {
+ s.logger.Error("Failed to create tag", zap.Error(err))
+ return nil, err
+ }
+
+ s.logger.Info("Tag created successfully", zap.Uint("id", tag.ID))
+ return tag, nil
+}
+
+// UpdateTag 更新标签
+func (s *TagService) UpdateTag(ctx context.Context, id uint, req *models.UpdateTagRequest) (*models.Tag, error) {
+ // 检查标签是否存在
+ var tag models.Tag
+ if err := s.db.WithContext(ctx).First(&tag, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("tag not found")
+ }
+ return nil, err
+ }
+
+ // 验证slug唯一性
+ if req.Slug != nil && *req.Slug != tag.Slug {
+ if err := s.validateSlugUnique(ctx, *req.Slug, id); err != nil {
+ return nil, err
+ }
+ }
+
+ // 构建更新数据
+ updates := map[string]interface{}{}
+ if req.Name != nil {
+ updates["name"] = *req.Name
+ }
+ if req.Slug != nil {
+ updates["slug"] = *req.Slug
+ }
+ if req.Description != nil {
+ updates["description"] = *req.Description
+ }
+ if req.Color != nil {
+ updates["color"] = *req.Color
+ }
+ if req.IsActive != nil {
+ updates["is_active"] = *req.IsActive
+ }
+
+ if len(updates) > 0 {
+ if err := s.db.WithContext(ctx).Model(&tag).Updates(updates).Error; err != nil {
+ s.logger.Error("Failed to update tag", zap.Error(err))
+ return nil, err
+ }
+ }
+
+ s.logger.Info("Tag updated successfully", zap.Uint("id", id))
+ return &tag, nil
+}
+
+// DeleteTag 删除标签
+func (s *TagService) DeleteTag(ctx context.Context, id uint) error {
+ // 检查标签是否存在
+ var tag models.Tag
+ if err := s.db.WithContext(ctx).First(&tag, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("tag not found")
+ }
+ return err
+ }
+
+ // 检查是否有关联的照片
+ var photoCount int64
+ if err := s.db.WithContext(ctx).Table("photo_tags").
+ Where("tag_id = ?", id).Count(&photoCount).Error; err != nil {
+ return err
+ }
+
+ if photoCount > 0 {
+ return errors.New("cannot delete tag with associated photos")
+ }
+
+ // 删除标签
+ if err := s.db.WithContext(ctx).Delete(&tag).Error; err != nil {
+ s.logger.Error("Failed to delete tag", zap.Error(err))
+ return err
+ }
+
+ s.logger.Info("Tag deleted successfully", zap.Uint("id", id))
+ return nil
+}
+
+// BatchDeleteTags 批量删除标签
+func (s *TagService) BatchDeleteTags(ctx context.Context, ids []uint) error {
+ if len(ids) == 0 {
+ return errors.New("no tags to delete")
+ }
+
+ // 检查是否有关联的照片
+ var photoCount int64
+ if err := s.db.WithContext(ctx).Table("photo_tags").
+ Where("tag_id IN ?", ids).Count(&photoCount).Error; err != nil {
+ return err
+ }
+
+ if photoCount > 0 {
+ return errors.New("cannot delete tags with associated photos")
+ }
+
+ // 删除标签
+ if err := s.db.WithContext(ctx).Where("id IN ?", ids).Delete(&models.Tag{}).Error; err != nil {
+ s.logger.Error("Failed to batch delete tags", zap.Error(err))
+ return err
+ }
+
+ s.logger.Info("Batch delete tags completed", zap.Int("count", len(ids)))
+ return nil
+}
+
+// GetPopularTags 获取热门标签
+func (s *TagService) GetPopularTags(ctx context.Context, limit int) ([]models.TagWithCount, error) {
+ if limit <= 0 {
+ limit = 10
+ }
+
+ var tags []models.TagWithCount
+ if err := s.db.WithContext(ctx).
+ Table("tags").
+ Select("tags.*, COUNT(photo_tags.photo_id) as photo_count").
+ Joins("LEFT JOIN photo_tags ON tags.id = photo_tags.tag_id").
+ Where("tags.is_active = ?", true).
+ Group("tags.id").
+ Order("photo_count DESC").
+ Limit(limit).
+ Find(&tags).Error; err != nil {
+ s.logger.Error("Failed to get popular tags", zap.Error(err))
+ return nil, err
+ }
+
+ return tags, nil
+}
+
+// GetTagCloud 获取标签云数据
+func (s *TagService) GetTagCloud(ctx context.Context) ([]models.TagCloudItem, error) {
+ var items []models.TagCloudItem
+ if err := s.db.WithContext(ctx).
+ Table("tags").
+ Select("tags.name, tags.slug, tags.color, COUNT(photo_tags.photo_id) as count").
+ Joins("LEFT JOIN photo_tags ON tags.id = photo_tags.tag_id").
+ Where("tags.is_active = ?", true).
+ Group("tags.id, tags.name, tags.slug, tags.color").
+ Having("COUNT(photo_tags.photo_id) > 0").
+ Order("count DESC").
+ Find(&items).Error; err != nil {
+ s.logger.Error("Failed to get tag cloud", zap.Error(err))
+ return nil, err
+ }
+
+ return items, nil
+}
+
+// GetTagStats 获取标签统计信息
+func (s *TagService) GetTagStats(ctx context.Context) (*models.TagStats, error) {
+ var stats models.TagStats
+
+ // 总标签数
+ if err := s.db.WithContext(ctx).Model(&models.Tag{}).Count(&stats.Total).Error; err != nil {
+ return nil, err
+ }
+
+ // 活跃标签数
+ if err := s.db.WithContext(ctx).Model(&models.Tag{}).
+ Where("is_active = ?", true).Count(&stats.Active).Error; err != nil {
+ return nil, err
+ }
+
+ // 已使用标签数
+ if err := s.db.WithContext(ctx).
+ Table("tags").
+ Joins("JOIN photo_tags ON tags.id = photo_tags.tag_id").
+ Where("tags.is_active = ?", true).
+ Group("tags.id").
+ Count(&stats.Used).Error; err != nil {
+ return nil, err
+ }
+
+ // 未使用标签数
+ stats.Unused = stats.Active - stats.Used
+
+ // 平均每个标签的照片数
+ var totalPhotos int64
+ if err := s.db.WithContext(ctx).Table("photo_tags").
+ Joins("JOIN tags ON photo_tags.tag_id = tags.id").
+ Where("tags.is_active = ?", true).
+ Count(&totalPhotos).Error; err != nil {
+ return nil, err
+ }
+
+ if stats.Used > 0 {
+ stats.AvgPhotosPerTag = float64(totalPhotos) / float64(stats.Used)
+ }
+
+ return &stats, nil
+}
+
+// SearchTags 搜索标签
+func (s *TagService) SearchTags(ctx context.Context, query string, limit int) ([]models.Tag, error) {
+ if limit <= 0 {
+ limit = 10
+ }
+
+ var tags []models.Tag
+ searchPattern := "%" + query + "%"
+
+ if err := s.db.WithContext(ctx).
+ Where("is_active = ? AND (name ILIKE ? OR slug ILIKE ?)", true, searchPattern, searchPattern).
+ Order("name ASC").
+ Limit(limit).
+ Find(&tags).Error; err != nil {
+ s.logger.Error("Failed to search tags", zap.Error(err))
+ return nil, err
+ }
+
+ return tags, nil
+}
+
+// CreateTagsFromNames 从名称列表创建标签
+func (s *TagService) CreateTagsFromNames(ctx context.Context, names []string) ([]models.Tag, error) {
+ var tags []models.Tag
+
+ for _, name := range names {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ continue
+ }
+
+ // 生成slug
+ slug, err := s.GenerateSlug(ctx, name)
+ if err != nil {
+ s.logger.Error("Failed to generate slug", zap.Error(err))
+ continue
+ }
+
+ // 检查标签是否已存在
+ var existingTag models.Tag
+ if err := s.db.WithContext(ctx).Where("slug = ?", slug).First(&existingTag).Error; err == nil {
+ tags = append(tags, existingTag)
+ continue
+ }
+
+ // 创建新标签
+ tag := models.Tag{
+ Name: name,
+ Slug: slug,
+ IsActive: true,
+ }
+
+ if err := s.db.WithContext(ctx).Create(&tag).Error; err != nil {
+ s.logger.Error("Failed to create tag", zap.Error(err))
+ continue
+ }
+
+ tags = append(tags, tag)
+ }
+
+ return tags, nil
+}
+
+// validateSlugUnique 验证slug唯一性
+func (s *TagService) validateSlugUnique(ctx context.Context, slug string, excludeID uint) error {
+ var count int64
+ query := s.db.WithContext(ctx).Model(&models.Tag{}).Where("slug = ?", slug)
+
+ if excludeID > 0 {
+ query = query.Where("id != ?", excludeID)
+ }
+
+ if err := query.Count(&count).Error; err != nil {
+ return err
+ }
+
+ if count > 0 {
+ return errors.New("slug already exists")
+ }
+
+ return nil
+}
+
+// GenerateSlug 生成slug
+func (s *TagService) GenerateSlug(ctx context.Context, name string) (string, error) {
+ baseSlug := utils.GenerateSlug(name)
+ slug := baseSlug
+
+ counter := 1
+ for {
+ var count int64
+ if err := s.db.WithContext(ctx).Model(&models.Tag{}).
+ Where("slug = ?", slug).Count(&count).Error; err != nil {
+ return "", err
+ }
+
+ if count == 0 {
+ break
+ }
+
+ slug = fmt.Sprintf("%s-%d", baseSlug, counter)
+ counter++
+ }
+
+ return slug, nil
+}
\ No newline at end of file
diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go
new file mode 100644
index 0000000..d2370c9
--- /dev/null
+++ b/backend/internal/service/user_service.go
@@ -0,0 +1,433 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "photography-backend/internal/models"
+ "photography-backend/internal/utils"
+
+ "go.uber.org/zap"
+ "golang.org/x/crypto/bcrypt"
+ "gorm.io/gorm"
+)
+
+type UserService struct {
+ db *gorm.DB
+ logger *zap.Logger
+}
+
+func NewUserService(db *gorm.DB, logger *zap.Logger) *UserService {
+ return &UserService{
+ db: db,
+ logger: logger,
+ }
+}
+
+// UserListParams 用户列表查询参数
+type UserListParams struct {
+ Page int `json:"page" form:"page"`
+ Limit int `json:"limit" form:"limit"`
+ Search string `json:"search" form:"search"`
+ Role string `json:"role" form:"role"`
+ IsActive *bool `json:"is_active" form:"is_active"`
+}
+
+// UserListResponse 用户列表响应
+type UserListResponse struct {
+ Users []models.User `json:"users"`
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+ Limit int `json:"limit"`
+ Pages int `json:"pages"`
+}
+
+// GetUsers 获取用户列表
+func (s *UserService) GetUsers(ctx context.Context, params UserListParams) (*UserListResponse, error) {
+ // 设置默认值
+ if params.Page <= 0 {
+ params.Page = 1
+ }
+ if params.Limit <= 0 {
+ params.Limit = 20
+ }
+ if params.Limit > 100 {
+ params.Limit = 100
+ }
+
+ // 构建查询
+ query := s.db.WithContext(ctx)
+
+ // 搜索过滤
+ if params.Search != "" {
+ searchPattern := "%" + params.Search + "%"
+ query = query.Where("username ILIKE ? OR email ILIKE ?", searchPattern, searchPattern)
+ }
+
+ // 角色过滤
+ if params.Role != "" {
+ query = query.Where("role = ?", params.Role)
+ }
+
+ // 状态过滤
+ if params.IsActive != nil {
+ query = query.Where("is_active = ?", *params.IsActive)
+ }
+
+ // 计算总数
+ var total int64
+ countQuery := query
+ if err := countQuery.Model(&models.User{}).Count(&total).Error; err != nil {
+ s.logger.Error("Failed to count users", zap.Error(err))
+ return nil, err
+ }
+
+ // 分页查询
+ offset := (params.Page - 1) * params.Limit
+ var users []models.User
+ if err := query.
+ Order("created_at DESC").
+ Offset(offset).
+ Limit(params.Limit).
+ Find(&users).Error; err != nil {
+ s.logger.Error("Failed to get users", zap.Error(err))
+ return nil, err
+ }
+
+ // 计算总页数
+ pages := int((total + int64(params.Limit) - 1) / int64(params.Limit))
+
+ return &UserListResponse{
+ Users: users,
+ Total: total,
+ Page: params.Page,
+ Limit: params.Limit,
+ Pages: pages,
+ }, nil
+}
+
+// GetUserByID 根据ID获取用户
+func (s *UserService) GetUserByID(ctx context.Context, id uint) (*models.User, error) {
+ var user models.User
+ if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("user not found")
+ }
+ s.logger.Error("Failed to get user by ID", zap.Error(err), zap.Uint("id", id))
+ return nil, err
+ }
+
+ return &user, nil
+}
+
+// GetUserByUsername 根据用户名获取用户
+func (s *UserService) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {
+ var user models.User
+ if err := s.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("user not found")
+ }
+ s.logger.Error("Failed to get user by username", zap.Error(err), zap.String("username", username))
+ return nil, err
+ }
+
+ return &user, nil
+}
+
+// GetUserByEmail 根据邮箱获取用户
+func (s *UserService) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
+ var user models.User
+ if err := s.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("user not found")
+ }
+ s.logger.Error("Failed to get user by email", zap.Error(err), zap.String("email", email))
+ return nil, err
+ }
+
+ return &user, nil
+}
+
+// CreateUser 创建用户
+func (s *UserService) CreateUser(ctx context.Context, req *models.CreateUserRequest) (*models.User, error) {
+ // 验证用户名唯一性
+ var existingUser models.User
+ if err := s.db.WithContext(ctx).Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
+ return nil, errors.New("username already exists")
+ }
+
+ // 验证邮箱唯一性
+ if err := s.db.WithContext(ctx).Where("email = ?", req.Email).First(&existingUser).Error; err == nil {
+ return nil, errors.New("email already exists")
+ }
+
+ // 加密密码
+ hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
+ if err != nil {
+ s.logger.Error("Failed to hash password", zap.Error(err))
+ return nil, err
+ }
+
+ user := &models.User{
+ Username: req.Username,
+ Email: req.Email,
+ Password: string(hashedPassword),
+ Role: req.Role,
+ IsActive: true,
+ }
+
+ if err := s.db.WithContext(ctx).Create(user).Error; err != nil {
+ s.logger.Error("Failed to create user", zap.Error(err))
+ return nil, err
+ }
+
+ s.logger.Info("User created successfully", zap.Uint("id", user.ID))
+ return user, nil
+}
+
+// UpdateUser 更新用户
+func (s *UserService) UpdateUser(ctx context.Context, id uint, req *models.UpdateUserRequest) (*models.User, error) {
+ // 检查用户是否存在
+ var user models.User
+ if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("user not found")
+ }
+ return nil, err
+ }
+
+ // 构建更新数据
+ updates := map[string]interface{}{}
+
+ if req.Username != nil {
+ // 验证用户名唯一性
+ var existingUser models.User
+ if err := s.db.WithContext(ctx).Where("username = ? AND id != ?", *req.Username, id).First(&existingUser).Error; err == nil {
+ return nil, errors.New("username already exists")
+ }
+ updates["username"] = *req.Username
+ }
+
+ if req.Email != nil {
+ // 验证邮箱唯一性
+ var existingUser models.User
+ if err := s.db.WithContext(ctx).Where("email = ? AND id != ?", *req.Email, id).First(&existingUser).Error; err == nil {
+ return nil, errors.New("email already exists")
+ }
+ updates["email"] = *req.Email
+ }
+
+ if req.Role != nil {
+ updates["role"] = *req.Role
+ }
+
+ if req.IsActive != nil {
+ updates["is_active"] = *req.IsActive
+ }
+
+ if len(updates) > 0 {
+ if err := s.db.WithContext(ctx).Model(&user).Updates(updates).Error; err != nil {
+ s.logger.Error("Failed to update user", zap.Error(err))
+ return nil, err
+ }
+ }
+
+ s.logger.Info("User updated successfully", zap.Uint("id", id))
+ return &user, nil
+}
+
+// UpdateCurrentUser 更新当前用户信息
+func (s *UserService) UpdateCurrentUser(ctx context.Context, id uint, req *models.UpdateCurrentUserRequest) (*models.User, error) {
+ // 检查用户是否存在
+ var user models.User
+ if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("user not found")
+ }
+ return nil, err
+ }
+
+ // 构建更新数据
+ updates := map[string]interface{}{}
+
+ if req.Username != nil {
+ // 验证用户名唯一性
+ var existingUser models.User
+ if err := s.db.WithContext(ctx).Where("username = ? AND id != ?", *req.Username, id).First(&existingUser).Error; err == nil {
+ return nil, errors.New("username already exists")
+ }
+ updates["username"] = *req.Username
+ }
+
+ if req.Email != nil {
+ // 验证邮箱唯一性
+ var existingUser models.User
+ if err := s.db.WithContext(ctx).Where("email = ? AND id != ?", *req.Email, id).First(&existingUser).Error; err == nil {
+ return nil, errors.New("email already exists")
+ }
+ updates["email"] = *req.Email
+ }
+
+ if len(updates) > 0 {
+ if err := s.db.WithContext(ctx).Model(&user).Updates(updates).Error; err != nil {
+ s.logger.Error("Failed to update current user", zap.Error(err))
+ return nil, err
+ }
+ }
+
+ s.logger.Info("Current user updated successfully", zap.Uint("id", id))
+ return &user, nil
+}
+
+// DeleteUser 删除用户
+func (s *UserService) DeleteUser(ctx context.Context, id uint) error {
+ // 检查用户是否存在
+ var user models.User
+ if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("user not found")
+ }
+ return err
+ }
+
+ // 删除用户
+ if err := s.db.WithContext(ctx).Delete(&user).Error; err != nil {
+ s.logger.Error("Failed to delete user", zap.Error(err))
+ return err
+ }
+
+ s.logger.Info("User deleted successfully", zap.Uint("id", id))
+ return nil
+}
+
+// ChangePassword 修改密码
+func (s *UserService) ChangePassword(ctx context.Context, id uint, req *models.ChangePasswordRequest) error {
+ // 检查用户是否存在
+ var user models.User
+ if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New("user not found")
+ }
+ return err
+ }
+
+ // 验证旧密码
+ if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
+ return errors.New("old password is incorrect")
+ }
+
+ // 加密新密码
+ hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
+ if err != nil {
+ s.logger.Error("Failed to hash new password", zap.Error(err))
+ return err
+ }
+
+ // 更新密码
+ if err := s.db.WithContext(ctx).Model(&user).Update("password", string(hashedPassword)).Error; err != nil {
+ s.logger.Error("Failed to update password", zap.Error(err))
+ return err
+ }
+
+ s.logger.Info("Password changed successfully", zap.Uint("id", id))
+ return nil
+}
+
+// ValidateCredentials 验证用户凭据
+func (s *UserService) ValidateCredentials(ctx context.Context, username, password string) (*models.User, error) {
+ var user models.User
+
+ // 根据用户名或邮箱查找用户
+ if err := s.db.WithContext(ctx).Where("username = ? OR email = ?", username, username).First(&user).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("invalid credentials")
+ }
+ s.logger.Error("Failed to find user", zap.Error(err))
+ return nil, err
+ }
+
+ // 检查用户是否激活
+ if !user.IsActive {
+ return nil, errors.New("user account is disabled")
+ }
+
+ // 验证密码
+ if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
+ return nil, errors.New("invalid credentials")
+ }
+
+ return &user, nil
+}
+
+// GetUserStats 获取用户统计信息
+func (s *UserService) GetUserStats(ctx context.Context) (*models.UserStats, error) {
+ var stats models.UserStats
+
+ // 总用户数
+ if err := s.db.WithContext(ctx).Model(&models.User{}).Count(&stats.Total).Error; err != nil {
+ return nil, err
+ }
+
+ // 活跃用户数
+ if err := s.db.WithContext(ctx).Model(&models.User{}).
+ Where("is_active = ?", true).Count(&stats.Active).Error; err != nil {
+ return nil, err
+ }
+
+ // 按角色统计
+ var roleStats []struct {
+ Role string `json:"role"`
+ Count int64 `json:"count"`
+ }
+ if err := s.db.WithContext(ctx).Model(&models.User{}).
+ Select("role, COUNT(*) as count").
+ Where("is_active = ?", true).
+ Group("role").
+ Find(&roleStats).Error; err != nil {
+ return nil, err
+ }
+
+ stats.RoleStats = make(map[string]int64)
+ for _, stat := range roleStats {
+ stats.RoleStats[stat.Role] = stat.Count
+ }
+
+ // 本月新增用户
+ startOfMonth := time.Now().AddDate(0, 0, -time.Now().Day()+1)
+ if err := s.db.WithContext(ctx).Model(&models.User{}).
+ Where("created_at >= ?", startOfMonth).
+ Count(&stats.ThisMonth).Error; err != nil {
+ return nil, err
+ }
+
+ // 今日新增用户
+ startOfDay := time.Now().Truncate(24 * time.Hour)
+ if err := s.db.WithContext(ctx).Model(&models.User{}).
+ Where("created_at >= ?", startOfDay).
+ Count(&stats.Today).Error; err != nil {
+ return nil, err
+ }
+
+ return &stats, nil
+}
+
+// IsUsernameAvailable 检查用户名是否可用
+func (s *UserService) IsUsernameAvailable(ctx context.Context, username string) (bool, error) {
+ var count int64
+ if err := s.db.WithContext(ctx).Model(&models.User{}).
+ Where("username = ?", username).Count(&count).Error; err != nil {
+ return false, err
+ }
+ return count == 0, nil
+}
+
+// IsEmailAvailable 检查邮箱是否可用
+func (s *UserService) IsEmailAvailable(ctx context.Context, email string) (bool, error) {
+ var count int64
+ if err := s.db.WithContext(ctx).Model(&models.User{}).
+ Where("email = ?", email).Count(&count).Error; err != nil {
+ return false, err
+ }
+ return count == 0, nil
+}
\ No newline at end of file
diff --git a/backend/internal/utils/utils.go b/backend/internal/utils/utils.go
new file mode 100644
index 0000000..6a1b481
--- /dev/null
+++ b/backend/internal/utils/utils.go
@@ -0,0 +1,243 @@
+package utils
+
+import (
+ "crypto/md5"
+ "fmt"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+ "unicode"
+
+ "golang.org/x/text/runes"
+ "golang.org/x/text/transform"
+ "golang.org/x/text/unicode/norm"
+)
+
+// Contains 检查字符串切片是否包含指定字符串
+func Contains(slice []string, item string) bool {
+ for _, s := range slice {
+ if s == item {
+ return true
+ }
+ }
+ return false
+}
+
+// ContainsUint 检查 uint 切片是否包含指定值
+func ContainsUint(slice []uint, item uint) bool {
+ for _, s := range slice {
+ if s == item {
+ return true
+ }
+ }
+ return false
+}
+
+// GenerateUniqueFilename 生成唯一文件名
+func GenerateUniqueFilename(originalFilename string) string {
+ ext := filepath.Ext(originalFilename)
+ name := strings.TrimSuffix(originalFilename, ext)
+
+ // 生成时间戳和哈希
+ timestamp := time.Now().Unix()
+ hash := md5.Sum([]byte(fmt.Sprintf("%s%d", name, timestamp)))
+
+ return fmt.Sprintf("%d_%x%s", timestamp, hash[:8], ext)
+}
+
+// GenerateSlug 生成 URL 友好的 slug
+func GenerateSlug(text string) string {
+ // 转换为小写
+ slug := strings.ToLower(text)
+
+ // 移除重音符号
+ t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
+ slug, _, _ = transform.String(t, slug)
+
+ // 只保留字母、数字、连字符和下划线
+ reg := regexp.MustCompile(`[^a-z0-9\-_\s]`)
+ slug = reg.ReplaceAllString(slug, "")
+
+ // 将空格替换为连字符
+ slug = regexp.MustCompile(`\s+`).ReplaceAllString(slug, "-")
+
+ // 移除多余的连字符
+ slug = regexp.MustCompile(`-+`).ReplaceAllString(slug, "-")
+
+ // 移除开头和结尾的连字符
+ slug = strings.Trim(slug, "-")
+
+ return slug
+}
+
+// ValidateEmail 验证邮箱格式
+func ValidateEmail(email string) bool {
+ emailRegex := regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)
+ return emailRegex.MatchString(strings.ToLower(email))
+}
+
+// ValidatePassword 验证密码强度
+func ValidatePassword(password string) bool {
+ if len(password) < 8 {
+ return false
+ }
+
+ hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
+ hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
+ hasDigit := regexp.MustCompile(`\d`).MatchString(password)
+
+ return hasLower && hasUpper && hasDigit
+}
+
+// Paginate 计算分页参数
+func Paginate(page, limit int) (offset int) {
+ if page <= 0 {
+ page = 1
+ }
+ if limit <= 0 {
+ limit = 20
+ }
+ if limit > 100 {
+ limit = 100
+ }
+
+ offset = (page - 1) * limit
+ return offset
+}
+
+// CalculatePages 计算总页数
+func CalculatePages(total int64, limit int) int {
+ if limit <= 0 {
+ return 0
+ }
+ return int((total + int64(limit) - 1) / int64(limit))
+}
+
+// TruncateString 截断字符串
+func TruncateString(s string, maxLength int) string {
+ if len(s) <= maxLength {
+ return s
+ }
+ return s[:maxLength] + "..."
+}
+
+// FormatFileSize 格式化文件大小
+func FormatFileSize(bytes int64) string {
+ const unit = 1024
+ if bytes < unit {
+ return fmt.Sprintf("%d B", bytes)
+ }
+
+ div, exp := int64(unit), 0
+ for n := bytes / unit; n >= unit; n /= unit {
+ div *= unit
+ exp++
+ }
+
+ sizes := []string{"KB", "MB", "GB", "TB", "PB"}
+ return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), sizes[exp])
+}
+
+// ParseSortOrder 解析排序方向
+func ParseSortOrder(order string) string {
+ order = strings.ToLower(strings.TrimSpace(order))
+ if order == "asc" || order == "desc" {
+ return order
+ }
+ return "desc" // 默认降序
+}
+
+// SanitizeSearchQuery 清理搜索查询
+func SanitizeSearchQuery(query string) string {
+ // 移除特殊字符,只保留字母、数字、空格和常用标点
+ reg := regexp.MustCompile(`[^\w\s\-\.\_\@]`)
+ query = reg.ReplaceAllString(query, "")
+
+ // 移除多余的空格
+ query = regexp.MustCompile(`\s+`).ReplaceAllString(query, " ")
+
+ return strings.TrimSpace(query)
+}
+
+// GenerateRandomString 生成随机字符串
+func GenerateRandomString(length int) string {
+ const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+
+ // 使用当前时间作为种子
+ timestamp := time.Now().UnixNano()
+
+ result := make([]byte, length)
+ for i := range result {
+ result[i] = charset[(timestamp+int64(i))%int64(len(charset))]
+ }
+
+ return string(result)
+}
+
+// IsValidImageExtension 检查是否为有效的图片扩展名
+func IsValidImageExtension(filename string) bool {
+ ext := strings.ToLower(filepath.Ext(filename))
+ validExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"}
+ return Contains(validExts, ext)
+}
+
+// GetImageMimeType 根据文件扩展名获取 MIME 类型
+func GetImageMimeType(filename string) string {
+ ext := strings.ToLower(filepath.Ext(filename))
+ mimeTypes := map[string]string{
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".png": "image/png",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ ".bmp": "image/bmp",
+ ".tiff": "image/tiff",
+ }
+
+ if mimeType, exists := mimeTypes[ext]; exists {
+ return mimeType
+ }
+ return "application/octet-stream"
+}
+
+// RemoveEmptyStrings 移除字符串切片中的空字符串
+func RemoveEmptyStrings(slice []string) []string {
+ var result []string
+ for _, s := range slice {
+ if strings.TrimSpace(s) != "" {
+ result = append(result, strings.TrimSpace(s))
+ }
+ }
+ return result
+}
+
+// UniqueStrings 去重字符串切片
+func UniqueStrings(slice []string) []string {
+ keys := make(map[string]bool)
+ var result []string
+
+ for _, item := range slice {
+ if !keys[item] {
+ keys[item] = true
+ result = append(result, item)
+ }
+ }
+
+ return result
+}
+
+// UniqueUints 去重 uint 切片
+func UniqueUints(slice []uint) []uint {
+ keys := make(map[uint]bool)
+ var result []uint
+
+ for _, item := range slice {
+ if !keys[item] {
+ keys[item] = true
+ result = append(result, item)
+ }
+ }
+
+ return result
+}
\ No newline at end of file
diff --git a/backend/migrations/001_create_users.sql b/backend/migrations/001_create_users.sql
new file mode 100644
index 0000000..0a0b5f8
--- /dev/null
+++ b/backend/migrations/001_create_users.sql
@@ -0,0 +1,30 @@
+-- +migrate Up
+
+CREATE TABLE users (
+ id SERIAL PRIMARY KEY,
+ username VARCHAR(50) UNIQUE NOT NULL,
+ email VARCHAR(100) UNIQUE NOT NULL,
+ password VARCHAR(255) NOT NULL,
+ name VARCHAR(100),
+ avatar VARCHAR(500),
+ role VARCHAR(20) DEFAULT 'user',
+ is_active BOOLEAN DEFAULT true,
+ last_login TIMESTAMP,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ deleted_at TIMESTAMP
+);
+
+-- 创建索引
+CREATE INDEX idx_users_username ON users(username);
+CREATE INDEX idx_users_email ON users(email);
+CREATE INDEX idx_users_role ON users(role);
+CREATE INDEX idx_users_deleted_at ON users(deleted_at);
+
+-- 插入默认管理员用户 (密码: admin123)
+INSERT INTO users (username, email, password, name, role) VALUES
+('admin', 'admin@photography.com', '$2a$10$D4Zz6m3j1YJzp8Y7zW4l2OXcQ5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0', '管理员', 'admin');
+
+-- +migrate Down
+
+DROP TABLE IF EXISTS users;
\ No newline at end of file
diff --git a/backend/migrations/002_create_categories.sql b/backend/migrations/002_create_categories.sql
new file mode 100644
index 0000000..c674c98
--- /dev/null
+++ b/backend/migrations/002_create_categories.sql
@@ -0,0 +1,33 @@
+-- +migrate Up
+
+CREATE TABLE categories (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(100) NOT NULL,
+ description TEXT,
+ parent_id INTEGER REFERENCES categories(id),
+ color VARCHAR(7) DEFAULT '#3b82f6',
+ cover_image VARCHAR(500),
+ sort INTEGER DEFAULT 0,
+ is_active BOOLEAN DEFAULT true,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ deleted_at TIMESTAMP
+);
+
+-- 创建索引
+CREATE INDEX idx_categories_parent_id ON categories(parent_id);
+CREATE INDEX idx_categories_is_active ON categories(is_active);
+CREATE INDEX idx_categories_sort ON categories(sort);
+CREATE INDEX idx_categories_deleted_at ON categories(deleted_at);
+
+-- 插入默认分类
+INSERT INTO categories (name, description, color, sort) VALUES
+('风景摄影', '自然风景摄影作品', '#10b981', 1),
+('人像摄影', '人物肖像摄影作品', '#f59e0b', 2),
+('街头摄影', '街头纪实摄影作品', '#ef4444', 3),
+('建筑摄影', '建筑和城市摄影作品', '#3b82f6', 4),
+('抽象摄影', '抽象艺术摄影作品', '#8b5cf6', 5);
+
+-- +migrate Down
+
+DROP TABLE IF EXISTS categories;
\ No newline at end of file
diff --git a/backend/migrations/003_create_tags.sql b/backend/migrations/003_create_tags.sql
new file mode 100644
index 0000000..35e664f
--- /dev/null
+++ b/backend/migrations/003_create_tags.sql
@@ -0,0 +1,35 @@
+-- +migrate Up
+
+CREATE TABLE tags (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(50) UNIQUE NOT NULL,
+ color VARCHAR(7) DEFAULT '#6b7280',
+ use_count INTEGER DEFAULT 0,
+ is_active BOOLEAN DEFAULT true,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ deleted_at TIMESTAMP
+);
+
+-- 创建索引
+CREATE INDEX idx_tags_name ON tags(name);
+CREATE INDEX idx_tags_use_count ON tags(use_count);
+CREATE INDEX idx_tags_is_active ON tags(is_active);
+CREATE INDEX idx_tags_deleted_at ON tags(deleted_at);
+
+-- 插入默认标签
+INSERT INTO tags (name, color) VALUES
+('自然', '#10b981'),
+('人物', '#f59e0b'),
+('城市', '#3b82f6'),
+('夜景', '#1f2937'),
+('黑白', '#6b7280'),
+('色彩', '#ec4899'),
+('构图', '#8b5cf6'),
+('光影', '#f97316'),
+('街头', '#ef4444'),
+('建筑', '#0891b2');
+
+-- +migrate Down
+
+DROP TABLE IF EXISTS tags;
\ No newline at end of file
diff --git a/backend/migrations/004_create_photos.sql b/backend/migrations/004_create_photos.sql
new file mode 100644
index 0000000..359f8e7
--- /dev/null
+++ b/backend/migrations/004_create_photos.sql
@@ -0,0 +1,55 @@
+-- +migrate Up
+
+CREATE TABLE photos (
+ id SERIAL PRIMARY KEY,
+ title VARCHAR(255) NOT NULL,
+ description TEXT,
+ filename VARCHAR(255) NOT NULL,
+ file_path VARCHAR(500) NOT NULL,
+ file_size BIGINT,
+ mime_type VARCHAR(100),
+ width INTEGER,
+ height INTEGER,
+ category_id INTEGER REFERENCES categories(id),
+ exif JSONB,
+ taken_at TIMESTAMP,
+ location VARCHAR(255),
+ is_public BOOLEAN DEFAULT true,
+ status VARCHAR(20) DEFAULT 'draft',
+ view_count INTEGER DEFAULT 0,
+ like_count INTEGER DEFAULT 0,
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ deleted_at TIMESTAMP
+);
+
+-- 创建照片标签关联表
+CREATE TABLE photo_tags (
+ photo_id INTEGER REFERENCES photos(id) ON DELETE CASCADE,
+ tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
+ PRIMARY KEY (photo_id, tag_id)
+);
+
+-- 创建索引
+CREATE INDEX idx_photos_category_id ON photos(category_id);
+CREATE INDEX idx_photos_user_id ON photos(user_id);
+CREATE INDEX idx_photos_status ON photos(status);
+CREATE INDEX idx_photos_is_public ON photos(is_public);
+CREATE INDEX idx_photos_taken_at ON photos(taken_at);
+CREATE INDEX idx_photos_created_at ON photos(created_at);
+CREATE INDEX idx_photos_view_count ON photos(view_count);
+CREATE INDEX idx_photos_like_count ON photos(like_count);
+CREATE INDEX idx_photos_deleted_at ON photos(deleted_at);
+
+-- 为JSONB字段创建GIN索引(支持高效的JSON查询)
+CREATE INDEX idx_photos_exif_gin ON photos USING GIN (exif);
+
+-- 全文搜索索引
+CREATE INDEX idx_photos_title_gin ON photos USING GIN (to_tsvector('english', title));
+CREATE INDEX idx_photos_description_gin ON photos USING GIN (to_tsvector('english', description));
+
+-- +migrate Down
+
+DROP TABLE IF EXISTS photo_tags;
+DROP TABLE IF EXISTS photos;
\ No newline at end of file
diff --git a/backend/pkg/logger/logger.go b/backend/pkg/logger/logger.go
new file mode 100644
index 0000000..e1b9149
--- /dev/null
+++ b/backend/pkg/logger/logger.go
@@ -0,0 +1,76 @@
+package logger
+
+import (
+ "os"
+ "go.uber.org/zap"
+ "go.uber.org/zap/zapcore"
+ "gopkg.in/natefinch/lumberjack.v2"
+ "photography-backend/internal/config"
+)
+
+// InitLogger 初始化日志记录器
+func InitLogger(cfg *config.LoggerConfig) (*zap.Logger, error) {
+ // 设置日志级别
+ var level zapcore.Level
+ switch cfg.Level {
+ case "debug":
+ level = zapcore.DebugLevel
+ case "info":
+ level = zapcore.InfoLevel
+ case "warn":
+ level = zapcore.WarnLevel
+ case "error":
+ level = zapcore.ErrorLevel
+ default:
+ level = zapcore.InfoLevel
+ }
+
+ // 创建编码器配置
+ encoderConfig := zap.NewProductionEncoderConfig()
+ encoderConfig.TimeKey = "timestamp"
+ encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
+ encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
+
+ // 创建编码器
+ var encoder zapcore.Encoder
+ if cfg.Format == "json" {
+ encoder = zapcore.NewJSONEncoder(encoderConfig)
+ } else {
+ encoder = zapcore.NewConsoleEncoder(encoderConfig)
+ }
+
+ // 创建写入器
+ var writers []zapcore.WriteSyncer
+
+ // 控制台输出
+ writers = append(writers, zapcore.AddSync(os.Stdout))
+
+ // 文件输出
+ if cfg.Output == "file" && cfg.Filename != "" {
+ // 确保日志目录存在
+ if err := os.MkdirAll("logs", 0755); err != nil {
+ return nil, err
+ }
+
+ fileWriter := &lumberjack.Logger{
+ Filename: cfg.Filename,
+ MaxSize: cfg.MaxSize,
+ MaxAge: cfg.MaxAge,
+ MaxBackups: 10,
+ LocalTime: true,
+ Compress: cfg.Compress,
+ }
+ writers = append(writers, zapcore.AddSync(fileWriter))
+ }
+
+ // 合并写入器
+ writer := zapcore.NewMultiWriteSyncer(writers...)
+
+ // 创建核心
+ core := zapcore.NewCore(encoder, writer, level)
+
+ // 创建日志记录器
+ logger := zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1))
+
+ return logger, nil
+}
\ No newline at end of file
diff --git a/backend/pkg/response/response.go b/backend/pkg/response/response.go
new file mode 100644
index 0000000..06fbf7e
--- /dev/null
+++ b/backend/pkg/response/response.go
@@ -0,0 +1,165 @@
+package response
+
+import (
+ "net/http"
+ "time"
+)
+
+// Response 统一响应结构
+type Response struct {
+ Success bool `json:"success"`
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Data interface{} `json:"data,omitempty"`
+ Meta *Meta `json:"meta,omitempty"`
+}
+
+// Meta 元数据
+type Meta struct {
+ Timestamp string `json:"timestamp"`
+ RequestID string `json:"request_id,omitempty"`
+}
+
+// PaginatedResponse 分页响应
+type PaginatedResponse struct {
+ Success bool `json:"success"`
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Data interface{} `json:"data"`
+ Pagination *Pagination `json:"pagination"`
+ Meta *Meta `json:"meta,omitempty"`
+}
+
+// Pagination 分页信息
+type Pagination struct {
+ Page int `json:"page"`
+ Limit int `json:"limit"`
+ Total int64 `json:"total"`
+ TotalPages int `json:"total_pages"`
+ HasNext bool `json:"has_next"`
+ HasPrev bool `json:"has_prev"`
+}
+
+// Success 成功响应
+func Success(data interface{}) *Response {
+ return &Response{
+ Success: true,
+ Code: http.StatusOK,
+ Message: "Success",
+ Data: data,
+ Meta: &Meta{
+ Timestamp: time.Now().Format(time.RFC3339),
+ },
+ }
+}
+
+// Error 错误响应
+func Error(code int, message string) *Response {
+ return &Response{
+ Success: false,
+ Code: code,
+ Message: message,
+ Meta: &Meta{
+ Timestamp: time.Now().Format(time.RFC3339),
+ },
+ }
+}
+
+// Created 创建成功响应
+func Created(data interface{}) *Response {
+ return &Response{
+ Success: true,
+ Code: http.StatusCreated,
+ Message: "Created successfully",
+ Data: data,
+ Meta: &Meta{
+ Timestamp: time.Now().Format(time.RFC3339),
+ },
+ }
+}
+
+// Updated 更新成功响应
+func Updated(data interface{}) *Response {
+ return &Response{
+ Success: true,
+ Code: http.StatusOK,
+ Message: "Updated successfully",
+ Data: data,
+ Meta: &Meta{
+ Timestamp: time.Now().Format(time.RFC3339),
+ },
+ }
+}
+
+// Deleted 删除成功响应
+func Deleted() *Response {
+ return &Response{
+ Success: true,
+ Code: http.StatusOK,
+ Message: "Deleted successfully",
+ Meta: &Meta{
+ Timestamp: time.Now().Format(time.RFC3339),
+ },
+ }
+}
+
+// Paginated 分页响应
+func Paginated(data interface{}, page, limit int, total int64) *PaginatedResponse {
+ totalPages := int((total + int64(limit) - 1) / int64(limit))
+
+ return &PaginatedResponse{
+ Success: true,
+ Code: http.StatusOK,
+ Message: "Success",
+ Data: data,
+ Pagination: &Pagination{
+ Page: page,
+ Limit: limit,
+ Total: total,
+ TotalPages: totalPages,
+ HasNext: page < totalPages,
+ HasPrev: page > 1,
+ },
+ Meta: &Meta{
+ Timestamp: time.Now().Format(time.RFC3339),
+ },
+ }
+}
+
+// BadRequest 400错误
+func BadRequest(message string) *Response {
+ return Error(http.StatusBadRequest, message)
+}
+
+// Unauthorized 401错误
+func Unauthorized(message string) *Response {
+ return Error(http.StatusUnauthorized, message)
+}
+
+// Forbidden 403错误
+func Forbidden(message string) *Response {
+ return Error(http.StatusForbidden, message)
+}
+
+// NotFound 404错误
+func NotFound(message string) *Response {
+ return Error(http.StatusNotFound, message)
+}
+
+// InternalServerError 500错误
+func InternalServerError(message string) *Response {
+ return Error(http.StatusInternalServerError, message)
+}
+
+// ValidationError 验证错误
+func ValidationError(errors map[string]string) *Response {
+ return &Response{
+ Success: false,
+ Code: http.StatusUnprocessableEntity,
+ Message: "Validation failed",
+ Data: errors,
+ Meta: &Meta{
+ Timestamp: time.Now().Format(time.RFC3339),
+ },
+ }
+}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..355ea35
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,128 @@
+version: '3.8'
+
+services:
+ # PostgreSQL 数据库
+ postgres:
+ image: postgres:15-alpine
+ container_name: photography_postgres
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: ${DB_NAME:-photography}
+ POSTGRES_USER: ${DB_USER:-postgres}
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
+ POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ - ./backend/migrations:/docker-entrypoint-initdb.d
+ ports:
+ - "127.0.0.1:5432:5432"
+ networks:
+ - photography_network
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-photography}"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+
+ # Redis 缓存
+ redis:
+ image: redis:7-alpine
+ container_name: photography_redis
+ restart: unless-stopped
+ command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
+ volumes:
+ - redis_data:/data
+ ports:
+ - "127.0.0.1:6379:6379"
+ networks:
+ - photography_network
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+
+ # 后端 API 服务
+ backend:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ container_name: photography_backend
+ restart: unless-stopped
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ environment:
+ # 数据库配置
+ DB_HOST: postgres
+ DB_PORT: 5432
+ DB_NAME: ${DB_NAME:-photography}
+ DB_USER: ${DB_USER:-postgres}
+ DB_PASSWORD: ${DB_PASSWORD}
+
+ # Redis 配置
+ REDIS_HOST: redis
+ REDIS_PORT: 6379
+ REDIS_PASSWORD: ${REDIS_PASSWORD}
+
+ # JWT 配置
+ JWT_SECRET: ${JWT_SECRET}
+ JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-24h}
+
+ # 服务器配置
+ PORT: 8080
+ GIN_MODE: release
+
+ # 文件存储配置
+ STORAGE_TYPE: ${STORAGE_TYPE:-local}
+ STORAGE_PATH: /app/uploads
+ MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE:-10MB}
+
+ # 日志配置
+ LOG_LEVEL: ${LOG_LEVEL:-info}
+ LOG_FORMAT: json
+ volumes:
+ - ./backend/uploads:/app/uploads
+ - ./backend/logs:/app/logs
+ - ./backend/configs:/app/configs
+ ports:
+ - "127.0.0.1:8080:8080"
+ networks:
+ - photography_network
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 60s
+
+ # 数据库备份服务
+ backup:
+ image: postgres:15-alpine
+ container_name: photography_backup
+ restart: "no"
+ depends_on:
+ - postgres
+ environment:
+ PGPASSWORD: ${DB_PASSWORD}
+ volumes:
+ - ./backups:/backups
+ - ./scripts/backup.sh:/backup.sh
+ networks:
+ - photography_network
+ entrypoint: ["/backup.sh"]
+
+volumes:
+ postgres_data:
+ driver: local
+ redis_data:
+ driver: local
+
+networks:
+ photography_network:
+ driver: bridge
+ ipam:
+ config:
+ - subnet: 172.20.0.0/16
\ No newline at end of file
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 0000000..42fd02c
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,760 @@
+# 摄影作品集项目部署文档
+
+本文档详细说明了摄影作品集项目的完整部署流程,包括前端、管理后台、后端 API 和数据库的部署配置。
+
+## 📋 目录
+
+- [系统要求](#系统要求)
+- [环境配置](#环境配置)
+- [后端部署](#后端部署)
+- [前端部署](#前端部署)
+- [管理后台部署](#管理后台部署)
+- [数据库配置](#数据库配置)
+- [Web 服务器配置](#web-服务器配置)
+- [SSL 证书配置](#ssl-证书配置)
+- [监控和备份](#监控和备份)
+- [故障排除](#故障排除)
+
+## 🖥️ 系统要求
+
+### 硬件要求
+- **CPU**: 2 核心以上
+- **内存**: 4GB 以上 (推荐 8GB)
+- **存储**: 20GB 以上 SSD 存储
+- **网络**: 稳定的互联网连接
+
+### 软件要求
+- **操作系统**: Ubuntu 20.04 LTS / CentOS 8 / Debian 11
+- **Docker**: 20.10 或更高版本
+- **Docker Compose**: 2.0 或更高版本
+- **Git**: 2.20 或更高版本
+- **域名**: 用于生产环境部署
+
+## ⚙️ 环境配置
+
+### 1. 安装 Docker 和 Docker Compose
+
+```bash
+# Ubuntu/Debian
+curl -fsSL https://get.docker.com -o get-docker.sh
+sudo sh get-docker.sh
+sudo usermod -aG docker $USER
+
+# 安装 Docker Compose
+sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
+sudo chmod +x /usr/local/bin/docker-compose
+
+# 验证安装
+docker --version
+docker-compose --version
+```
+
+### 2. 克隆项目仓库
+
+```bash
+git clone photography
+cd photography
+```
+
+### 3. 创建环境变量文件
+
+```bash
+# 复制环境变量模板
+cp .env.example .env
+
+# 编辑环境变量
+nano .env
+```
+
+## 🚀 后端部署
+
+### 1. 后端 Docker 配置
+
+查看后端 Dockerfile:
+
+```dockerfile
+# backend/Dockerfile
+FROM golang:1.21-alpine AS builder
+
+# 设置工作目录
+WORKDIR /app
+
+# 安装依赖
+RUN apk add --no-cache git ca-certificates tzdata
+
+# 复制 go mod 文件
+COPY go.mod go.sum ./
+RUN go mod download
+
+# 复制源代码
+COPY . .
+
+# 构建应用
+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go
+
+# 生产环境镜像
+FROM alpine:latest
+
+# 安装必要的包
+RUN apk --no-cache add ca-certificates wget
+
+# 创建用户
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup
+
+# 设置工作目录
+WORKDIR /app
+
+# 从构建阶段复制文件
+COPY --from=builder /app/main .
+COPY --from=builder /app/configs ./configs
+COPY --from=builder /app/migrations ./migrations
+
+# 创建必要的目录
+RUN mkdir -p uploads logs && chown -R appuser:appgroup /app
+
+# 切换到非 root 用户
+USER appuser
+
+# 暴露端口
+EXPOSE 8080
+
+# 健康检查
+HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
+
+# 启动命令
+CMD ["./main"]
+```
+
+### 2. 后端 Docker Compose 服务
+
+```yaml
+# docker-compose.yml - 后端部分
+services:
+ # PostgreSQL 数据库
+ postgres:
+ image: postgres:15-alpine
+ container_name: photography_postgres
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: ${DB_NAME:-photography}
+ POSTGRES_USER: ${DB_USER:-postgres}
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
+ POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ - ./backend/migrations:/docker-entrypoint-initdb.d
+ ports:
+ - "127.0.0.1:5432:5432"
+ networks:
+ - photography_network
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-photography}"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+
+ # Redis 缓存
+ redis:
+ image: redis:7-alpine
+ container_name: photography_redis
+ restart: unless-stopped
+ command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
+ volumes:
+ - redis_data:/data
+ ports:
+ - "127.0.0.1:6379:6379"
+ networks:
+ - photography_network
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+
+ # 后端 API 服务
+ backend:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ container_name: photography_backend
+ restart: unless-stopped
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ environment:
+ # 数据库配置
+ DB_HOST: postgres
+ DB_PORT: 5432
+ DB_NAME: ${DB_NAME:-photography}
+ DB_USER: ${DB_USER:-postgres}
+ DB_PASSWORD: ${DB_PASSWORD}
+
+ # Redis 配置
+ REDIS_HOST: redis
+ REDIS_PORT: 6379
+ REDIS_PASSWORD: ${REDIS_PASSWORD}
+
+ # JWT 配置
+ JWT_SECRET: ${JWT_SECRET}
+ JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-24h}
+
+ # 服务器配置
+ PORT: 8080
+ GIN_MODE: release
+
+ # 文件存储配置
+ STORAGE_TYPE: ${STORAGE_TYPE:-local}
+ STORAGE_PATH: /app/uploads
+ MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE:-10MB}
+
+ # 日志配置
+ LOG_LEVEL: ${LOG_LEVEL:-info}
+ LOG_FORMAT: json
+ volumes:
+ - ./backend/uploads:/app/uploads
+ - ./backend/logs:/app/logs
+ - ./backend/configs:/app/configs
+ ports:
+ - "127.0.0.1:8080:8080"
+ networks:
+ - photography_network
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 60s
+
+volumes:
+ postgres_data:
+ driver: local
+ redis_data:
+ driver: local
+
+networks:
+ photography_network:
+ driver: bridge
+ ipam:
+ config:
+ - subnet: 172.20.0.0/16
+```
+
+### 3. 启动后端服务
+
+```bash
+# 构建并启动后端服务
+docker-compose up -d postgres redis
+docker-compose up -d backend
+
+# 查看日志
+docker-compose logs -f backend
+
+# 验证服务状态
+curl http://localhost:8080/health
+```
+
+## 🌐 前端部署
+
+### 1. 前端构建配置
+
+```bash
+# 进入前端目录
+cd frontend/
+
+# 安装依赖
+bun install
+
+# 构建生产版本
+bun run build
+
+# 验证构建结果
+ls -la out/
+```
+
+### 2. 前端部署脚本
+
+创建前端部署脚本:
+
+```bash
+#!/bin/bash
+# scripts/deploy-frontend.sh
+
+set -e
+
+echo "🚀 开始部署前端..."
+
+# 配置变量
+FRONTEND_DIR="/home/gitea/www/photography"
+BACKUP_DIR="/home/gitea/backups/photography-frontend"
+BUILD_DIR="./frontend/out"
+
+# 创建备份
+echo "📦 创建当前版本备份..."
+mkdir -p "$BACKUP_DIR"
+if [ -d "$FRONTEND_DIR" ]; then
+ tar -czf "$BACKUP_DIR/frontend-$(date +%Y%m%d-%H%M%S).tar.gz" -C "$FRONTEND_DIR" .
+fi
+
+# 创建部署目录
+echo "📁 准备部署目录..."
+mkdir -p "$FRONTEND_DIR"
+
+# 部署新版本
+echo "🔄 部署新版本..."
+rsync -av --delete "$BUILD_DIR/" "$FRONTEND_DIR/"
+
+# 设置权限
+echo "🔐 设置文件权限..."
+chown -R gitea:gitea "$FRONTEND_DIR"
+chmod -R 755 "$FRONTEND_DIR"
+
+# 清理旧备份 (保留最近5个)
+echo "🧹 清理旧备份..."
+cd "$BACKUP_DIR"
+ls -t frontend-*.tar.gz | tail -n +6 | xargs -r rm
+
+echo "✅ 前端部署完成!"
+echo "🌐 访问地址: https://photography.iriver.top"
+```
+
+## 🛠️ 管理后台部署
+
+### 1. 管理后台构建配置
+
+```bash
+# 进入管理后台目录
+cd admin/
+
+# 安装依赖
+npm install
+
+# 构建生产版本
+npm run build
+
+# 验证构建结果
+ls -la dist/
+```
+
+### 2. 管理后台部署脚本
+
+```bash
+#!/bin/bash
+# scripts/deploy-admin.sh
+
+set -e
+
+echo "🚀 开始部署管理后台..."
+
+# 配置变量
+ADMIN_DIR="/home/gitea/www/photography-admin"
+BACKUP_DIR="/home/gitea/backups/photography-admin"
+BUILD_DIR="./admin/dist"
+
+# 创建备份
+echo "📦 创建当前版本备份..."
+mkdir -p "$BACKUP_DIR"
+if [ -d "$ADMIN_DIR" ]; then
+ tar -czf "$BACKUP_DIR/admin-$(date +%Y%m%d-%H%M%S).tar.gz" -C "$ADMIN_DIR" .
+fi
+
+# 创建部署目录
+echo "📁 准备部署目录..."
+mkdir -p "$ADMIN_DIR"
+
+# 部署新版本
+echo "🔄 部署新版本..."
+rsync -av --delete "$BUILD_DIR/" "$ADMIN_DIR/"
+
+# 设置权限
+echo "🔐 设置文件权限..."
+chown -R gitea:gitea "$ADMIN_DIR"
+chmod -R 755 "$ADMIN_DIR"
+
+# 清理旧备份 (保留最近5个)
+echo "🧹 清理旧备份..."
+cd "$BACKUP_DIR"
+ls -t admin-*.tar.gz | tail -n +6 | xargs -r rm
+
+echo "✅ 管理后台部署完成!"
+echo "🌐 访问地址: https://admin.photography.iriver.top"
+```
+
+## 🗄️ 数据库配置
+
+### 1. 数据库初始化
+
+```sql
+-- 创建数据库
+CREATE DATABASE photography;
+CREATE USER photography_user WITH ENCRYPTED PASSWORD 'your_password';
+GRANT ALL PRIVILEGES ON DATABASE photography TO photography_user;
+
+-- 使用数据库
+\c photography;
+
+-- 创建必要的扩展
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+CREATE EXTENSION IF NOT EXISTS "pg_trgm";
+
+-- 设置时区
+SET timezone = 'Asia/Shanghai';
+```
+
+### 2. 数据库备份脚本
+
+```bash
+#!/bin/bash
+# scripts/backup.sh
+
+set -e
+
+# 配置变量
+DB_HOST="${DB_HOST:-postgres}"
+DB_NAME="${DB_NAME:-photography}"
+DB_USER="${DB_USER:-postgres}"
+BACKUP_DIR="/backups"
+DATE=$(date +%Y%m%d-%H%M%S)
+
+echo "🗄️ 开始数据库备份..."
+
+# 创建备份目录
+mkdir -p "$BACKUP_DIR"
+
+# 执行备份
+pg_dump -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" \
+ --no-password --verbose --clean --no-acl --no-owner \
+ -f "$BACKUP_DIR/photography-$DATE.sql"
+
+# 压缩备份文件
+gzip "$BACKUP_DIR/photography-$DATE.sql"
+
+# 清理旧备份 (保留最近7天)
+find "$BACKUP_DIR" -name "photography-*.sql.gz" -mtime +7 -delete
+
+echo "✅ 数据库备份完成: photography-$DATE.sql.gz"
+```
+
+## 🌐 Web 服务器配置
+
+### Caddy 配置文件
+
+```caddyfile
+# docs/deployment/Caddyfile
+
+# 前端网站
+photography.iriver.top {
+ # 根目录
+ root * /home/gitea/www/photography
+
+ # 静态文件服务
+ file_server
+
+ # SPA 路由支持
+ try_files {path} /index.html
+
+ # 压缩
+ encode gzip
+
+ # 缓存配置
+ header {
+ # 静态资源缓存
+ Cache-Control "public, max-age=31536000" {
+ path *.js *.css *.png *.jpg *.jpeg *.gif *.ico *.woff *.woff2
+ }
+
+ # HTML 文件不缓存
+ Cache-Control "no-cache" {
+ path *.html
+ }
+
+ # 安全头
+ X-Frame-Options "DENY"
+ X-Content-Type-Options "nosniff"
+ Referrer-Policy "strict-origin-when-cross-origin"
+ }
+
+ # 日志
+ log {
+ output file /var/log/caddy/photography.log
+ format json
+ }
+}
+
+# 管理后台
+admin.photography.iriver.top {
+ # 根目录
+ root * /home/gitea/www/photography-admin
+
+ # 静态文件服务
+ file_server
+
+ # SPA 路由支持
+ try_files {path} /index.html
+
+ # 压缩
+ encode gzip
+
+ # 基本认证 (可选)
+ # basicauth {
+ # admin $2a$14$encrypted_password_hash
+ # }
+
+ # 缓存配置
+ header {
+ Cache-Control "public, max-age=31536000" {
+ path *.js *.css *.png *.jpg *.jpeg *.gif *.ico *.woff *.woff2
+ }
+ Cache-Control "no-cache" {
+ path *.html
+ }
+ X-Frame-Options "SAMEORIGIN"
+ X-Content-Type-Options "nosniff"
+ }
+
+ # 日志
+ log {
+ output file /var/log/caddy/admin.photography.log
+ format json
+ }
+}
+
+# API 服务
+api.photography.iriver.top {
+ # 反向代理到后端服务
+ reverse_proxy localhost:8080 {
+ # 健康检查
+ health_uri /health
+ health_interval 30s
+ health_timeout 10s
+
+ # 请求头
+ header_up Host {upstream_hostport}
+ header_up X-Real-IP {remote_host}
+ header_up X-Forwarded-Proto {scheme}
+ header_up X-Forwarded-For {remote_host}
+ }
+
+ # CORS 处理
+ header {
+ Access-Control-Allow-Origin "https://photography.iriver.top, https://admin.photography.iriver.top"
+ Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
+ Access-Control-Allow-Headers "Content-Type, Authorization"
+ Access-Control-Max-Age "86400"
+ }
+
+ # 预检请求处理
+ @options method OPTIONS
+ respond @options 204
+
+ # 上传文件大小限制
+ request_body {
+ max_size 10MB
+ }
+
+ # 日志
+ log {
+ output file /var/log/caddy/api.photography.log
+ format json
+ }
+}
+
+# 全局配置
+{
+ # 自动 HTTPS
+ auto_https on
+
+ # Let's Encrypt 邮箱
+ email admin@iriver.top
+
+ # 日志级别
+ log {
+ level INFO
+ }
+}
+```
+
+## 🔒 SSL 证书配置
+
+Caddy 会自动处理 SSL 证书,但也可以手动配置:
+
+```bash
+# 手动获取证书 (如果需要)
+certbot certonly --webroot -w /var/www/html -d photography.iriver.top -d admin.photography.iriver.top -d api.photography.iriver.top
+
+# 设置自动续期
+echo "0 3 * * * certbot renew --quiet && systemctl reload caddy" | crontab -
+```
+
+## 📊 监控和备份
+
+### 1. 系统监控脚本
+
+```bash
+#!/bin/bash
+# scripts/monitor.sh
+
+# 检查服务状态
+check_service() {
+ local service=$1
+ local url=$2
+
+ echo "检查 $service..."
+ if curl -f -s "$url" > /dev/null; then
+ echo "✅ $service 正常运行"
+ else
+ echo "❌ $service 服务异常"
+ # 发送告警 (可集成邮件、微信等)
+ # send_alert "$service 服务异常"
+ fi
+}
+
+# 检查磁盘空间
+check_disk() {
+ local usage=$(df / | tail -1 | awk '{print $5}' | sed 's/%//')
+ echo "磁盘使用率: $usage%"
+
+ if [ "$usage" -gt 80 ]; then
+ echo "⚠️ 磁盘空间不足"
+ # send_alert "磁盘空间不足: $usage%"
+ fi
+}
+
+# 检查内存使用
+check_memory() {
+ local usage=$(free | grep Mem | awk '{printf "%.1f", $3/$2 * 100.0}')
+ echo "内存使用率: $usage%"
+}
+
+# 执行检查
+echo "🔍 开始系统监控..."
+check_service "前端" "https://photography.iriver.top"
+check_service "管理后台" "https://admin.photography.iriver.top"
+check_service "API" "https://api.photography.iriver.top/health"
+check_disk
+check_memory
+echo "✅ 监控检查完成"
+```
+
+### 2. 自动备份任务
+
+```bash
+# 设置定时任务
+crontab -e
+
+# 添加以下内容:
+# 每天凌晨2点备份数据库
+0 2 * * * /path/to/photography/scripts/backup.sh
+
+# 每周日凌晨3点重启服务 (可选)
+0 3 * * 0 cd /path/to/photography && docker-compose restart
+
+# 每小时检查一次服务状态
+0 * * * * /path/to/photography/scripts/monitor.sh
+```
+
+## 🐛 故障排除
+
+### 常见问题和解决方案
+
+#### 1. 后端服务无法启动
+```bash
+# 查看日志
+docker-compose logs backend
+
+# 检查配置
+docker-compose config
+
+# 重新构建
+docker-compose build backend --no-cache
+```
+
+#### 2. 数据库连接失败
+```bash
+# 检查数据库状态
+docker-compose ps postgres
+
+# 检查数据库日志
+docker-compose logs postgres
+
+# 手动连接测试
+docker-compose exec postgres psql -U postgres -d photography
+```
+
+#### 3. 前端/管理后台访问异常
+```bash
+# 检查文件权限
+ls -la /home/gitea/www/photography/
+ls -la /home/gitea/www/photography-admin/
+
+# 检查 Caddy 配置
+caddy validate --config /etc/caddy/Caddyfile
+
+# 重新加载 Caddy
+systemctl reload caddy
+```
+
+#### 4. SSL 证书问题
+```bash
+# 检查证书状态
+curl -I https://photography.iriver.top
+
+# 查看 Caddy 日志
+tail -f /var/log/caddy/photography.log
+
+# 手动重新申请证书
+systemctl stop caddy
+caddy run --config /etc/caddy/Caddyfile
+```
+
+### 日志文件位置
+- **后端日志**: `./backend/logs/`
+- **Caddy 日志**: `/var/log/caddy/`
+- **数据库日志**: `docker-compose logs postgres`
+- **系统日志**: `/var/log/syslog`
+
+## 🚀 部署命令速查
+
+```bash
+# 完整部署流程
+git pull origin main
+cd photography
+
+# 后端部署
+docker-compose build backend
+docker-compose up -d
+
+# 前端部署
+cd frontend && bun run build && cd ..
+./scripts/deploy-frontend.sh
+
+# 管理后台部署
+cd admin && npm run build && cd ..
+./scripts/deploy-admin.sh
+
+# 重启 Web 服务器
+sudo systemctl reload caddy
+
+# 检查服务状态
+docker-compose ps
+curl https://photography.iriver.top
+curl https://admin.photography.iriver.top
+curl https://api.photography.iriver.top/health
+```
+
+## 📞 技术支持
+
+如果在部署过程中遇到问题,请:
+
+1. 查看相关日志文件
+2. 检查系统资源使用情况
+3. 验证配置文件语法
+4. 联系技术支持团队
+
+---
+
+📝 **文档版本**: v1.0
+🕒 **最后更新**: 2024年
+👥 **维护团队**: 摄影作品集项目组
\ No newline at end of file
diff --git a/docs/development/saved-docs/v1.0-overview.md b/docs/development/saved-docs/v1.0-overview.md
index 151045f..97dae68 100644
--- a/docs/development/saved-docs/v1.0-overview.md
+++ b/docs/development/saved-docs/v1.0-overview.md
@@ -116,26 +116,26 @@ v1.0 是摄影作品集网站的核心功能版本,实现了完整的摄影作
| 模块 | 设计 | 开发 | 测试 | 部署 | 完成度 |
|------|------|------|------|------|--------|
| 前端展示 | ✅ | ✅ | ✅ | ✅ | 100% |
-| 管理后台 | ✅ | ⏳ | ⏳ | ⏳ | 20% |
-| 后端API | ✅ | ⏳ | ⏳ | ⏳ | 15% |
-| 数据库 | ✅ | ⏳ | ⏳ | ⏳ | 30% |
-| 图片处理 | ✅ | ⏳ | ⏳ | ⏳ | 10% |
-| 用户认证 | ✅ | ⏳ | ⏳ | ⏳ | 5% |
-| 文件存储 | ✅ | ⏳ | ⏳ | ⏳ | 5% |
-| 部署配置 | ✅ | ⏳ | ⏳ | ⏳ | 10% |
+| 管理后台 | ✅ | ✅ | ⏳ | ⏳ | 90% |
+| 后端API | ✅ | ✅ | ⏳ | ⏳ | 90% |
+| 数据库 | ✅ | ✅ | ⏳ | ⏳ | 85% |
+| 图片处理 | ✅ | ⏳ | ⏳ | ⏳ | 35% |
+| 用户认证 | ✅ | ✅ | ⏳ | ⏳ | 90% |
+| 文件存储 | ✅ | ✅ | ⏳ | ⏳ | 75% |
+| 部署配置 | ✅ | ✅ | ⏳ | ⏳ | 80% |
### 里程碑计划
#### 第一阶段 (2024-01-15 ~ 2024-02-15)
- [x] 完成技术选型和架构设计
- [x] 完成详细设计文档编写
-- [ ] 搭建开发环境和基础框架
-- [ ] 实现管理后台核心界面
+- [x] 搭建开发环境和基础框架
+- [x] 实现管理后台核心界面
#### 第二阶段 (2024-02-15 ~ 2024-03-15)
-- [ ] 完成后端API核心功能
-- [ ] 实现数据库表结构和迁移
-- [ ] 完成用户认证和权限系统
+- [x] 完成后端API核心功能
+- [x] 实现数据库表结构和迁移
+- [x] 完成用户认证和权限系统
- [ ] 实现图片上传和处理功能
#### 第三阶段 (2024-03-15 ~ 2024-04-15)
diff --git a/scripts/backup.sh b/scripts/backup.sh
new file mode 100755
index 0000000..f64a30a
--- /dev/null
+++ b/scripts/backup.sh
@@ -0,0 +1,65 @@
+#!/bin/bash
+
+# 数据库备份脚本
+# 使用方法: ./scripts/backup.sh
+
+set -e
+
+# 配置变量
+DB_HOST="${DB_HOST:-localhost}"
+DB_NAME="${DB_NAME:-photography}"
+DB_USER="${DB_USER:-postgres}"
+BACKUP_DIR="/home/gitea/backups/database"
+DATE=$(date +%Y%m%d-%H%M%S)
+
+echo "🗄️ 开始数据库备份..."
+
+# 创建备份目录
+mkdir -p "$BACKUP_DIR"
+
+# 检查数据库连接
+echo "🔍 检查数据库连接..."
+if ! pg_isready -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME"; then
+ echo "❌ 数据库连接失败"
+ exit 1
+fi
+
+# 执行备份
+echo "📦 执行数据库备份..."
+pg_dump -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" \
+ --no-password --verbose --clean --no-acl --no-owner \
+ -f "$BACKUP_DIR/photography-$DATE.sql"
+
+# 检查备份文件
+if [ ! -f "$BACKUP_DIR/photography-$DATE.sql" ]; then
+ echo "❌ 备份文件创建失败"
+ exit 1
+fi
+
+# 压缩备份文件
+echo "🗜️ 压缩备份文件..."
+gzip "$BACKUP_DIR/photography-$DATE.sql"
+
+# 检查压缩文件
+if [ ! -f "$BACKUP_DIR/photography-$DATE.sql.gz" ]; then
+ echo "❌ 备份文件压缩失败"
+ exit 1
+fi
+
+# 设置权限
+chmod 600 "$BACKUP_DIR/photography-$DATE.sql.gz"
+chown gitea:gitea "$BACKUP_DIR/photography-$DATE.sql.gz"
+
+# 清理旧备份 (保留最近7天)
+echo "🧹 清理旧备份..."
+find "$BACKUP_DIR" -name "photography-*.sql.gz" -mtime +7 -delete
+
+echo "✅ 数据库备份完成!"
+echo "📁 备份文件: $BACKUP_DIR/photography-$DATE.sql.gz"
+echo "📊 文件大小: $(du -sh $BACKUP_DIR/photography-$DATE.sql.gz | cut -f1)"
+
+# 显示备份统计
+echo "📈 备份统计:"
+echo " 总备份数: $(ls -1 $BACKUP_DIR/photography-*.sql.gz | wc -l)"
+echo " 总备份大小: $(du -sh $BACKUP_DIR | cut -f1)"
+echo " 最新备份: $(ls -t $BACKUP_DIR/photography-*.sql.gz | head -n 1)"
\ No newline at end of file
diff --git a/scripts/deploy-admin.sh b/scripts/deploy-admin.sh
new file mode 100755
index 0000000..3c37d0e
--- /dev/null
+++ b/scripts/deploy-admin.sh
@@ -0,0 +1,79 @@
+#!/bin/bash
+
+# 管理后台部署脚本
+# 使用方法: ./scripts/deploy-admin.sh
+
+set -e
+
+echo "🚀 开始部署管理后台..."
+
+# 配置变量
+ADMIN_DIR="/home/gitea/www/photography-admin"
+BACKUP_DIR="/home/gitea/backups/photography-admin"
+BUILD_DIR="./admin/dist"
+PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+# 检查当前目录
+if [ ! -d "$PROJECT_DIR/admin" ]; then
+ echo "❌ 错误: 请在项目根目录运行此脚本"
+ exit 1
+fi
+
+# 进入项目目录
+cd "$PROJECT_DIR"
+
+# 构建管理后台项目
+echo "🏗️ 构建管理后台项目..."
+cd admin
+npm install
+npm run build
+
+# 检查构建结果
+if [ ! -d "dist" ]; then
+ echo "❌ 构建失败: dist 目录不存在"
+ exit 1
+fi
+
+cd ..
+
+# 创建备份
+echo "📦 创建当前版本备份..."
+mkdir -p "$BACKUP_DIR"
+if [ -d "$ADMIN_DIR" ] && [ "$(ls -A $ADMIN_DIR)" ]; then
+ tar -czf "$BACKUP_DIR/admin-$(date +%Y%m%d-%H%M%S).tar.gz" -C "$ADMIN_DIR" .
+ echo "✅ 备份完成"
+fi
+
+# 创建部署目录
+echo "📁 准备部署目录..."
+mkdir -p "$ADMIN_DIR"
+
+# 部署新版本
+echo "🔄 部署新版本..."
+rsync -av --delete "$BUILD_DIR/" "$ADMIN_DIR/"
+
+# 设置权限
+echo "🔐 设置文件权限..."
+chown -R gitea:gitea "$ADMIN_DIR"
+chmod -R 755 "$ADMIN_DIR"
+
+# 设置正确的文件权限
+find "$ADMIN_DIR" -type f -name "*.html" -o -name "*.js" -o -name "*.css" -o -name "*.json" | xargs chmod 644
+find "$ADMIN_DIR" -type f -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" -o -name "*.gif" -o -name "*.svg" -o -name "*.ico" | xargs chmod 644
+find "$ADMIN_DIR" -type d | xargs chmod 755
+
+# 重新加载 Web 服务器
+echo "🔄 重新加载 Caddy..."
+sudo systemctl reload caddy
+
+# 清理旧备份 (保留最近5个)
+echo "🧹 清理旧备份..."
+cd "$BACKUP_DIR"
+ls -t admin-*.tar.gz | tail -n +6 | xargs -r rm
+
+echo "✅ 管理后台部署完成!"
+echo "📁 部署路径: $ADMIN_DIR"
+echo "🌐 访问地址: https://admin.photography.iriver.top"
+echo "📊 部署统计:"
+echo " 文件数量: $(find $ADMIN_DIR -type f | wc -l)"
+echo " 目录大小: $(du -sh $ADMIN_DIR | cut -f1)"
\ No newline at end of file
diff --git a/scripts/deploy-frontend.sh b/scripts/deploy-frontend.sh
new file mode 100755
index 0000000..5adfdb8
--- /dev/null
+++ b/scripts/deploy-frontend.sh
@@ -0,0 +1,79 @@
+#!/bin/bash
+
+# 前端部署脚本
+# 使用方法: ./scripts/deploy-frontend.sh
+
+set -e
+
+echo "🚀 开始部署前端网站..."
+
+# 配置变量
+FRONTEND_DIR="/home/gitea/www/photography"
+BACKUP_DIR="/home/gitea/backups/photography-frontend"
+BUILD_DIR="./frontend/out"
+PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+# 检查当前目录
+if [ ! -d "$PROJECT_DIR/frontend" ]; then
+ echo "❌ 错误: 请在项目根目录运行此脚本"
+ exit 1
+fi
+
+# 进入项目目录
+cd "$PROJECT_DIR"
+
+# 构建前端项目
+echo "🏗️ 构建前端项目..."
+cd frontend
+bun install
+bun run build
+
+# 检查构建结果
+if [ ! -d "out" ]; then
+ echo "❌ 构建失败: out 目录不存在"
+ exit 1
+fi
+
+cd ..
+
+# 创建备份
+echo "📦 创建当前版本备份..."
+mkdir -p "$BACKUP_DIR"
+if [ -d "$FRONTEND_DIR" ] && [ "$(ls -A $FRONTEND_DIR)" ]; then
+ tar -czf "$BACKUP_DIR/frontend-$(date +%Y%m%d-%H%M%S).tar.gz" -C "$FRONTEND_DIR" .
+ echo "✅ 备份完成"
+fi
+
+# 创建部署目录
+echo "📁 准备部署目录..."
+mkdir -p "$FRONTEND_DIR"
+
+# 部署新版本
+echo "🔄 部署新版本..."
+rsync -av --delete "$BUILD_DIR/" "$FRONTEND_DIR/"
+
+# 设置权限
+echo "🔐 设置文件权限..."
+chown -R gitea:gitea "$FRONTEND_DIR"
+chmod -R 755 "$FRONTEND_DIR"
+
+# 设置正确的文件权限
+find "$FRONTEND_DIR" -type f -name "*.html" -o -name "*.js" -o -name "*.css" -o -name "*.json" -o -name "*.xml" -o -name "*.txt" | xargs chmod 644
+find "$FRONTEND_DIR" -type f -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" -o -name "*.gif" -o -name "*.svg" -o -name "*.ico" | xargs chmod 644
+find "$FRONTEND_DIR" -type d | xargs chmod 755
+
+# 重新加载 Web 服务器
+echo "🔄 重新加载 Caddy..."
+sudo systemctl reload caddy
+
+# 清理旧备份 (保留最近5个)
+echo "🧹 清理旧备份..."
+cd "$BACKUP_DIR"
+ls -t frontend-*.tar.gz | tail -n +6 | xargs -r rm
+
+echo "✅ 前端部署完成!"
+echo "📁 部署路径: $FRONTEND_DIR"
+echo "🌐 访问地址: https://photography.iriver.top"
+echo "📊 部署统计:"
+echo " 文件数量: $(find $FRONTEND_DIR -type f | wc -l)"
+echo " 目录大小: $(du -sh $FRONTEND_DIR | cut -f1)"
\ No newline at end of file
diff --git a/scripts/monitor.sh b/scripts/monitor.sh
new file mode 100755
index 0000000..7ab8f88
--- /dev/null
+++ b/scripts/monitor.sh
@@ -0,0 +1,212 @@
+#!/bin/bash
+
+# 系统监控脚本
+# 使用方法: ./scripts/monitor.sh
+
+set -e
+
+echo "🔍 开始系统监控检查..."
+
+# 检查服务状态
+check_service() {
+ local service=$1
+ local url=$2
+
+ echo "检查 $service..."
+ if curl -f -s -o /dev/null --max-time 10 "$url"; then
+ echo "✅ $service 正常运行"
+ return 0
+ else
+ echo "❌ $service 服务异常"
+ return 1
+ fi
+}
+
+# 检查磁盘空间
+check_disk() {
+ echo "💾 检查磁盘空间..."
+ local usage=$(df / | tail -1 | awk '{print $5}' | sed 's/%//')
+ echo "磁盘使用率: $usage%"
+
+ if [ "$usage" -gt 80 ]; then
+ echo "⚠️ 磁盘空间不足 ($usage%)"
+ return 1
+ else
+ echo "✅ 磁盘空间充足"
+ return 0
+ fi
+}
+
+# 检查内存使用
+check_memory() {
+ echo "🧠 检查内存使用..."
+ local usage=$(free | grep Mem | awk '{printf "%.1f", $3/$2 * 100.0}')
+ echo "内存使用率: $usage%"
+
+ if (( $(echo "$usage > 90" | bc -l) )); then
+ echo "⚠️ 内存使用率过高 ($usage%)"
+ return 1
+ else
+ echo "✅ 内存使用正常"
+ return 0
+ fi
+}
+
+# 检查 CPU 使用
+check_cpu() {
+ echo "⚡ 检查 CPU 使用..."
+ local usage=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | awk -F% '{print $1}')
+ echo "CPU 使用率: $usage%"
+
+ if (( $(echo "$usage > 80" | bc -l) )); then
+ echo "⚠️ CPU 使用率过高 ($usage%)"
+ return 1
+ else
+ echo "✅ CPU 使用正常"
+ return 0
+ fi
+}
+
+# 检查 Docker 容器
+check_docker() {
+ echo "🐳 检查 Docker 容器..."
+
+ # 检查后端容器
+ if docker ps --format "table {{.Names}}\t{{.Status}}" | grep -q "photography_backend.*Up"; then
+ echo "✅ 后端容器运行正常"
+ else
+ echo "❌ 后端容器异常"
+ return 1
+ fi
+
+ # 检查数据库容器
+ if docker ps --format "table {{.Names}}\t{{.Status}}" | grep -q "photography_postgres.*Up"; then
+ echo "✅ 数据库容器运行正常"
+ else
+ echo "❌ 数据库容器异常"
+ return 1
+ fi
+
+ # 检查 Redis 容器
+ if docker ps --format "table {{.Names}}\t{{.Status}}" | grep -q "photography_redis.*Up"; then
+ echo "✅ Redis 容器运行正常"
+ else
+ echo "❌ Redis 容器异常"
+ return 1
+ fi
+}
+
+# 检查 Caddy 服务
+check_caddy() {
+ echo "🌐 检查 Caddy 服务..."
+ if systemctl is-active --quiet caddy; then
+ echo "✅ Caddy 服务运行正常"
+ return 0
+ else
+ echo "❌ Caddy 服务异常"
+ return 1
+ fi
+}
+
+# 检查 SSL 证书
+check_ssl() {
+ echo "🔒 检查 SSL 证书..."
+
+ local domains=("photography.iriver.top" "admin.photography.iriver.top" "api.photography.iriver.top")
+ local all_ok=true
+
+ for domain in "${domains[@]}"; do
+ local expiry=$(echo | openssl s_client -servername "$domain" -connect "$domain:443" 2>/dev/null | openssl x509 -noout -dates | grep notAfter | cut -d= -f2)
+ local expiry_seconds=$(date -d "$expiry" +%s)
+ local current_seconds=$(date +%s)
+ local days_left=$(( (expiry_seconds - current_seconds) / 86400 ))
+
+ if [ "$days_left" -gt 30 ]; then
+ echo "✅ $domain SSL 证书有效 (剩余 $days_left 天)"
+ else
+ echo "⚠️ $domain SSL 证书即将过期 (剩余 $days_left 天)"
+ all_ok=false
+ fi
+ done
+
+ if [ "$all_ok" = true ]; then
+ return 0
+ else
+ return 1
+ fi
+}
+
+# 生成监控报告
+generate_report() {
+ echo "📊 生成监控报告..."
+
+ local report_file="/tmp/monitor-report-$(date +%Y%m%d-%H%M%S).txt"
+
+ {
+ echo "# 系统监控报告"
+ echo "时间: $(date)"
+ echo "主机: $(hostname)"
+ echo ""
+ echo "## 系统资源"
+ echo "- 磁盘使用: $(df -h / | tail -1 | awk '{print $5}')"
+ echo "- 内存使用: $(free | grep Mem | awk '{printf "%.1f%%", $3/$2 * 100.0}')"
+ echo "- CPU 使用: $(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | awk -F% '{print $1}')%"
+ echo "- 负载平均: $(uptime | awk -F'load average:' '{print $2}')"
+ echo ""
+ echo "## 服务状态"
+ echo "- 前端网站: $(curl -s -o /dev/null -w "%{http_code}" https://photography.iriver.top)"
+ echo "- 管理后台: $(curl -s -o /dev/null -w "%{http_code}" https://admin.photography.iriver.top)"
+ echo "- API 服务: $(curl -s -o /dev/null -w "%{http_code}" https://api.photography.iriver.top/health)"
+ echo ""
+ echo "## Docker 容器"
+ docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep photography
+ echo ""
+ echo "## 磁盘空间"
+ df -h
+ echo ""
+ echo "## 内存使用"
+ free -h
+ } > "$report_file"
+
+ echo "📋 报告已生成: $report_file"
+}
+
+# 主检查流程
+main() {
+ local exit_code=0
+
+ echo "🚀 开始执行系统监控..."
+ echo "时间: $(date)"
+ echo "主机: $(hostname)"
+ echo ""
+
+ # 执行各项检查
+ check_service "前端网站" "https://photography.iriver.top" || exit_code=1
+ check_service "管理后台" "https://admin.photography.iriver.top" || exit_code=1
+ check_service "API 服务" "https://api.photography.iriver.top/health" || exit_code=1
+
+ echo ""
+ check_disk || exit_code=1
+ check_memory || exit_code=1
+ check_cpu || exit_code=1
+
+ echo ""
+ check_docker || exit_code=1
+ check_caddy || exit_code=1
+ check_ssl || exit_code=1
+
+ echo ""
+ generate_report
+
+ echo ""
+ if [ $exit_code -eq 0 ]; then
+ echo "✅ 系统监控检查完成 - 所有服务正常"
+ else
+ echo "⚠️ 系统监控检查完成 - 发现问题"
+ fi
+
+ return $exit_code
+}
+
+# 运行主程序
+main "$@"
\ No newline at end of file