commit 85a73e1ae91430d186837c7b95f994ddcfc8ec36 Author: linzhongyan <1577714120@qq.com> Date: Tue Dec 2 18:58:25 2025 +0800 first commit diff --git a/.env b/.env new file mode 100644 index 0000000..75f043e --- /dev/null +++ b/.env @@ -0,0 +1,12 @@ +# .env file + +# 数据库配置 +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=Lzy0916. +DB_NAME=gorm + +# JWT 认证配置 +# 强烈建议在生产环境使用更复杂的密钥 +JWT_SECRET=mySuperSecretKey_todo_api_flash_v1 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..38ecf6f --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Go Gin API 项目总结文档 + +## 🚀 项目概述 + +本项目成功构建了一个基于 **Go 语言 (Gin 框架)** 的 RESTful API 服务,实现了用户认证、待办事项 (Todo) 的 CRUD 操作,以及受保护的文件上传与静态服务功能。整个项目严格遵循 **三层架构(Handler/Service/Repository)** 和 **依赖注入(DI)** 原则,确保了代码的高内聚、低耦合和高可维护性。 + +## ✅ 核心功能与架构成就 + +本项目最关键的成就体现在 **安全性和架构的健壮性** 上: + +### 1. 认证与授权 (Authentication & Authorization) + +- **JWT 机制**:实现了基于 JWT 的用户登录和认证,所有受保护的路由均通过 Auth Middleware 拦截和验证。 +- **权限控制**:将 `AuthService` 注入到中间件中,确保只有携带有效令牌的用户才能访问 `/todos`, `/upload` 等受保护的端点。 + +### 2. 严格的数据隔离 (Data Isolation) + +- **核心安全实现**:通过在 Service 层和 Repository 层强制执行 `WHERE user_id = ?` 约束,确保了用户 B 无法执行任何针对用户 A 数据的操作(包括读取、更新和删除)。 +- **调试成功**:成功解决了分层架构中因 `userID` 传递和 Repository 约束不一致导致的复杂编译和运行时安全漏洞。 + +### 3. 文件服务与解压 (File Service) + +- **受保护上传**:`/upload` 接口受 JWT 保护,仅限登录用户上传文件。 +- **解压和静态服务**:实现了 ZIP 文件的上传、解压,并使用 Gin 的 `router.Static` 路由将解压后的 HTML 文件安全地对外提供服务。 + +### 4. 生产环境准备 (Deployment Readiness) + +- **配置安全**:所有敏感配置(数据库凭证、JWT Secret)已从代码中移除,迁移至 **`.env` 环境变量** 进行管理,极大地提高了项目部署的灵活性和安全性。 +- **专业配置**:项目设置了 **`gin.ReleaseMode`** 和 **`router.SetTrustedProxies`**,优化了生产环境下的性能并解决了关键的安全警告。 +- **统一响应格式**:所有 API 响应均采用 `StandardResponse` 结构体,包含 HTTP 状态码、自定义业务 code 字段和数据 payload,确保了客户端与服务端的通信一致性和可维护性。 + +- **Gin 生产模式配置和代理安全设置**: + - 启用了 `gin.ReleaseMode`,关闭了调试信息,提升了性能。 + - 设置了 `router.SetTrustedProxies`,确保了在反向代理环境下的安全。 + +--- + +## 🛠️ 项目技术栈 + +| 类别 | 技术栈/库 | 作用 | +| :----------- | :----------------------------------- | :------------------------------------------------ | +| **框架** | Gin | 高性能 Web 框架,用于路由和中间件管理。 | +| **数据库** | PostgreSQL (GORM) | 数据库连接与 ORM 操作,支持强大的关系映射和查询。 | +| **认证** | JWT (Go `jwt` library) | 令牌生成、签名和验证。 | +| **配置** | `github.com/joho/godotenv` | 本地 `.env` 文件加载与管理。 | +| **文件操作** | `archive/zip`, `os`, `path/filepath` | 处理 ZIP 文件的上传、解压和存储。 | + +## 📦 最终代码状态 + +项目代码已达到高水平的专业性和稳定性,可以随时进行容器化(如 Docker)并部署到生产环境。 diff --git a/constants/codes.go b/constants/codes.go new file mode 100644 index 0000000..f05ee2c --- /dev/null +++ b/constants/codes.go @@ -0,0 +1,18 @@ +package constants + +// 业务状态码常量 +const ( + CodeSuccess = 1000 // 成功 + CodeInvalidAuth = 2000 // 认证/授权失败 (Token无效或缺失) + CodeResourceNotFound = 3000 // 资源未找到 (如 ID 不存在或不属于用户) + CodeValidationError = 4000 // 输入校验失败 + CodeInternalError = 5000 // 服务器内部错误 + CodeConflictError = 6000 // 资源冲突 (如用户名已存在) +) + +// StandardResponse 定义了统一的 JSON 响应格式 +type StandardResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` // omitempty: 如果为nil则不显示 +} diff --git a/constants/constants.go b/constants/constants.go new file mode 100644 index 0000000..e070a1b --- /dev/null +++ b/constants/constants.go @@ -0,0 +1,5 @@ +// constants/constants.go +package constants + +// UserIDKey 是存储在 Gin Context 中的用户 ID 键名 +const UserIDKey = "userID" diff --git a/dto/README.md b/dto/README.md new file mode 100644 index 0000000..6ab3959 --- /dev/null +++ b/dto/README.md @@ -0,0 +1,14 @@ +# 使用 DTO(数据传输对象) + +你完全抓住了我们在处理 注册/登录 过程中遇到的一个常见的 Go Web 开发陷阱,这和我们在 models/user.go 中设置的 标签冲突 有关。 + +🚨 标签冲突的困境 +你的目标是: + +输入时 (注册/登录): 必须读取 JSON 中的明文密码,所以需要 json:"password"。 + +输出时 (防止信息泄露): 必须阻止密码哈希值被 JSON 序列化并返回给客户端,所以需要 json:"-"。 + +不幸的是,一个字段不能同时拥有两个不同的 json 标签。 + +解决这个困境的最佳和最专业的做法是 为输入/输出定义不同的结构体,将 models.User 结构体专门用于数据库操作,而将输入/输出交给 DTO diff --git a/dto/user_dto.go b/dto/user_dto.go new file mode 100644 index 0000000..040a286 --- /dev/null +++ b/dto/user_dto.go @@ -0,0 +1,13 @@ +package dto + +// LoginInput 用于接收 /login 请求体 +type LoginInput struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// RegisterInput 用于接收 /register 请求体(如果需要更严格的验证,可以在这里添加) +type RegisterInput struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required,min=6"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8b155cf --- /dev/null +++ b/go.mod @@ -0,0 +1,55 @@ +module go-todo-api + +go 1.24.0 + +toolchain go1.24.10 + +require ( + github.com/gin-gonic/gin v1.11.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.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.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.38.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect + gorm.io/driver/postgres v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..73cf31f --- /dev/null +++ b/go.sum @@ -0,0 +1,126 @@ +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/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.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +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/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/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.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/handlers/README.md b/handlers/README.md new file mode 100644 index 0000000..5398770 --- /dev/null +++ b/handlers/README.md @@ -0,0 +1 @@ +# 存放 Gin 路由处理函数 diff --git a/handlers/auth_handler.go b/handlers/auth_handler.go new file mode 100644 index 0000000..95741ba --- /dev/null +++ b/handlers/auth_handler.go @@ -0,0 +1,97 @@ +// handlers/auth_handler.go +package handlers + +import ( + "errors" + "net/http" + "strings" + + "go-todo-api/constants" + "go-todo-api/dto" + "go-todo-api/services" + + "github.com/gin-gonic/gin" +) + +// AuthHandler 依赖于 AuthService 接口 +type AuthHandler struct { + Service services.AuthService +} + +// NewAuthHandler 创建 AuthHandler 的新实例 +func NewAuthHandler(service services.AuthService) *AuthHandler { + return &AuthHandler{Service: service} +} + +// RegisterHandler 处理 POST /register 请求 +func (h *AuthHandler) RegisterHandler(c *gin.Context) { + var input dto.RegisterInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, constants.StandardResponse{ + Code: constants.CodeValidationError, + Message: "Invalid input format or missing fields", + }) + return + } + + newUser, err := h.Service.Register(&input) + + if err != nil { + if strings.Contains(err.Error(), "username already taken") { // 检查 Service 返回的特定错误 + c.JSON(http.StatusConflict, constants.StandardResponse{ + Code: constants.CodeConflictError, + Message: "Username already taken", + }) + return + } + c.JSON(http.StatusInternalServerError, constants.StandardResponse{ + Code: constants.CodeInternalError, + Message: "Failed to register user", + }) + return + } + + // 注册成功,返回脱敏信息 + c.JSON(http.StatusCreated, constants.StandardResponse{ + Code: constants.CodeSuccess, + Message: "User created successfully", + Data: newUser, + }) +} + +// LoginHandler 处理 POST /login 请求 +func (h *AuthHandler) LoginHandler(c *gin.Context) { + var input dto.LoginInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, constants.StandardResponse{ + Code: constants.CodeValidationError, + Message: "Invalid input format or missing fields", + }) + return + } + + token, err := h.Service.Login(&input) + + if err != nil { + // 统一处理登录失败,防止泄露细节 + if errors.Is(err, errors.New("invalid username or password")) { + c.JSON(http.StatusUnauthorized, constants.StandardResponse{ + Code: constants.CodeInvalidAuth, + Message: "Invalid username or password", + }) + return + } + c.JSON(http.StatusInternalServerError, constants.StandardResponse{ + Code: constants.CodeInternalError, + Message: "Failed to login", + }) + return + } + + // 登录成功,返回令牌 + c.JSON(http.StatusOK, constants.StandardResponse{ + Code: constants.CodeSuccess, + Message: "Login successful!", + Data: token, + }) +} diff --git a/handlers/todo_handler.go b/handlers/todo_handler.go new file mode 100644 index 0000000..159e60d --- /dev/null +++ b/handlers/todo_handler.go @@ -0,0 +1,269 @@ +package handlers + +import ( + "errors" + "fmt" + "go-todo-api/constants" + "go-todo-api/models" + "go-todo-api/services" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// 辅助函数:从 Gin Context 中获取用户 ID +// ❗ 修正:将此函数放在包级别 (不在任何结构体内) +func getUserIDFromContext(c *gin.Context) (uint, error) { + // Note: UserIDKey must be imported from the constants package + // ... (Your implementation using constants.UserIDKey) + userIDStr, exists := c.Get(constants.UserIDKey) + if !exists { + return 0, errors.New("User ID not found in context") + } + + // 转换 string 到 uint + userIDUint, err := strconv.ParseUint(userIDStr.(string), 10, 32) + if err != nil { + return 0, errors.New("Invalid user ID format in context") + } + return uint(userIDUint), nil +} + +// TodoHandler 结构体持有 Service 接口,用于处理 HTTP 请求 +type TodoHandler struct { + // Handler 层依赖于 Service 接口 + Service services.TodoService +} + +// NewTodoHandler 实例化一个新的 TodoHandler +func NewTodoHandler(service services.TodoService) *TodoHandler { + return &TodoHandler{Service: service} +} + +// FindAllTodosHandler 处理 GET /todos 请求 +func (h *TodoHandler) FindAllTodosHandler(c *gin.Context) { + userID, err := getUserIDFromContext(c) // 1. 获取 UserID + if err != nil || userID == 0 { + // 401 错误,使用自定义代码 CodeInvalidAuth + c.JSON(http.StatusUnauthorized, constants.StandardResponse{ + Code: constants.CodeInvalidAuth, + Message: "Unauthorized or Invalid User ID", + }) + return + } + // 1. 调用 Service 层获取数据 (Service 层调用 Repository 层) + todos, err := h.Service.FindAllTodos(userID) + + if err != nil { + // 500 内部错误,使用自定义代码 CodeInternalError + c.JSON(http.StatusInternalServerError, constants.StandardResponse{ + Code: constants.CodeInternalError, + Message: fmt.Sprintf("Failed to retrieve todos: %v", err), + }) + return + } + // 200 成功响应,使用 CodeSuccess + // 2. 返回 JSON 响应 (Handler 负责 HTTP 细节) + c.JSON(http.StatusOK, constants.StandardResponse{ + Code: constants.CodeSuccess, + Message: "Successfully retrieved todos", + Data: todos, + }) +} + +// FindTodoByIDHandler 处理 GET /todos/:id 请求 +func (h *TodoHandler) FindTodoByIDHandler(c *gin.Context) { + userID, err := getUserIDFromContext(c) // 1. 获取 UserID + if err != nil || userID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized or Invalid User ID"}) + return + } + // 1. 获取 URL 参数 ID + idStr := c.Param("id") + // 将字符串 ID 转换为无符号整数 (uint) + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Todo ID format"}) + return + } + + // 2. 调用 Service 层获取数据 + todo, err := h.Service.FindTodoByID(uint(id), userID) + + if err != nil { + // 检查是否是“未找到记录”错误 + if errors.Is(err, gorm.ErrRecordNotFound) { + // 404 资源未找到,使用 CodeResourceNotFound + c.JSON(http.StatusNotFound, constants.StandardResponse{ + Code: constants.CodeResourceNotFound, + Message: "Todo item not found", + }) + return + } + // 其他数据库错误 + c.JSON(http.StatusInternalServerError, constants.StandardResponse{ + Code: constants.CodeInternalError, + Message: fmt.Sprintf("Failed to retrieve todo: %v", err), + }) + return + } + + // 3. 返回 JSON 响应 + c.JSON(http.StatusOK, constants.StandardResponse{ + Code: constants.CodeSuccess, + Message: "Successfully retrieved todo", + Data: todo, + }) +} + +// DeleteTodoByIDHandler 处理 DELETE /todos/:id 请求 +func (h *TodoHandler) DeleteTodoByIDHandler(c *gin.Context) { + userID, err := getUserIDFromContext(c) // 1. 获取 UserID + if err != nil || userID == 0 { + // 401 错误,使用自定义代码 CodeInvalidAuth + c.JSON(http.StatusUnauthorized, constants.StandardResponse{ + Code: constants.CodeInvalidAuth, + Message: "Unauthorized or Invalid User ID", + }) + return + } + // 1. 获取并解析 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, constants.StandardResponse{ + Code: constants.CodeInvalidAuth, + Message: "Invalid Todo ID format", + }) + return + } + + // 2. 调用 Service 层执行删除逻辑 + err = h.Service.DeleteTodoByID(uint(id), userID) + + if err != nil { + // 检查是否是“未找到记录”错误(Repository层返回的gorm.ErrRecordNotFound) + if errors.Is(err, gorm.ErrRecordNotFound) { + // 404 资源未找到,使用 CodeResourceNotFound + c.JSON(http.StatusNotFound, constants.StandardResponse{ + Code: constants.CodeResourceNotFound, + Message: "Todo item not found", + }) + return + } + // 其他数据库错误 + c.JSON(http.StatusInternalServerError, constants.StandardResponse{ + Code: constants.CodeInternalError, + Message: fmt.Sprintf("Failed to delete todo item: %v", err), + }) + return + } + + // 3. 返回成功响应 + c.JSON(http.StatusOK, constants.StandardResponse{ + Code: constants.CodeSuccess, + Message: "Todo deleted successfully (soft deleted)", + }) +} + +// CreateTodoHandler 处理 POST /todos 请求,创建新的待办事项 +func (h *TodoHandler) CreateTodoHandler(c *gin.Context) { + var newTodo models.Todo + + // 1. 绑定前端 JSON 数据 + // Todo 结构体中应有 binding:"required" 标签来确保数据完整性 + if err := c.ShouldBindJSON(&newTodo); err != nil { + c.JSON(http.StatusBadRequest, constants.StandardResponse{ + Code: constants.CodeValidationError, + Message: err.Error(), + }) + return + } + userID, err := getUserIDFromContext(c) // 获取 UserID + + if err != nil || userID == 0 { // ❗ 核心修正:新增检查 userID == 0 + // 如果获取失败或者 ID 为 0,视为未授权或无效令牌 + c.JSON(http.StatusUnauthorized, constants.StandardResponse{ + Code: constants.CodeInvalidAuth, + Message: "Unauthorized or Invalid User ID", + }) + return + } + + // 2. 调用 Service 层创建数据 (Service 会调用 Repository) + if err := h.Service.CreateTodo(&newTodo, userID); err != nil { + c.JSON(http.StatusInternalServerError, constants.StandardResponse{ + Code: constants.CodeInternalError, + Message: fmt.Sprintf("Failed to save todo item: %v", err), + }) + return + } + + // 3. 返回成功响应 + // newTodo 现在包含了 GORM 自动生成的 ID 和时间戳 + c.JSON(http.StatusCreated, constants.StandardResponse{ + Code: constants.CodeSuccess, + Message: "Todo created successfully (via Service)", + Data: newTodo, + }) +} + +// UpdateTodoHandler 处理 PATCH /todos/:id 请求 +func (h *TodoHandler) UpdateTodoHandler(c *gin.Context) { + userID, err := getUserIDFromContext(c) // 1. 获取 UserID + if err != nil || userID == 0 { + c.JSON(http.StatusUnauthorized, constants.StandardResponse{ + Code: constants.CodeInvalidAuth, + Message: "Unauthorized or Invalid User ID", + }) + return + } + // 1. 获取并解析 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, constants.StandardResponse{ + Code: constants.CodeValidationError, + Message: "Invalid Todo ID format", + }) + return + } + + // 2. 接收更新的 JSON 数据到 map 中(用于部分更新) + var input map[string]interface{} + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, constants.StandardResponse{ + Code: constants.CodeValidationError, + Message: err.Error(), + }) + return + } + + // 3. 调用 Service 层执行更新逻辑 + // 注意:Service 层将负责查找记录,然后执行更新 + updatedTodo, err := h.Service.UpdateTodo(uint(id), userID, input) + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, constants.StandardResponse{ + Code: constants.CodeResourceNotFound, + Message: "Todo item not found", + }) + return + } + c.JSON(http.StatusInternalServerError, constants.StandardResponse{ + Code: constants.CodeInternalError, + Message: fmt.Sprintf("Failed to update todo item: %v", err), + }) + return + } + + // 4. 返回更新后的记录 + c.JSON(http.StatusOK, constants.StandardResponse{ + Code: constants.CodeSuccess, + Message: "Todo updated successfully", + Data: updatedTodo, + }) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..90cc298 --- /dev/null +++ b/main.go @@ -0,0 +1,266 @@ +package main + +import ( + "archive/zip" // 保持,因为 handleUpload 使用 + "fmt" + "go-todo-api/constants" + "go-todo-api/handlers" + "go-todo-api/models" + "go-todo-api/repositories" + "go-todo-api/services" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" // 保持,用于加载配置 + "gorm.io/driver/postgres" + "gorm.io/gorm" + // Go 标准库 +) + +// DB 是全局数据库连接变量 +var DB *gorm.DB + +// ❗ 新增:在 init() 中加载 .env 文件 +func init() { + if err := godotenv.Load(); err != nil { + log.Println("Note: No .env file found, relying on environment variables.") + } +} + +// 修正 connectDatabase 函数,使其读取环境变量 +func connectDatabase() { + // ❗ 核心修正:使用 os.Getenv 获取配置 + dbHost := os.Getenv("DB_HOST") + dbPort := os.Getenv("DB_PORT") + dbUser := os.Getenv("DB_USER") + dbPassword := os.Getenv("DB_PASSWORD") + dbName := os.Getenv("DB_NAME") + + // 检查关键配置(如果未设置,则使用 os.Getenv 的默认空字符串,这会导致连接失败,但这是期望的安全行为) + if dbHost == "" || dbUser == "" || dbPassword == "" || dbName == "" { + log.Fatal("FATAL: Database environment variables (DB_HOST, DB_USER, etc.) are not fully set.") + } + + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Shanghai", + dbHost, dbUser, dbPassword, dbName, dbPort) + + database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + + if err != nil { + log.Fatal("Failed to connect to PostgreSQL database! \n", err) + } + // ⭐ 核心步骤:自动迁移 + err = database.AutoMigrate(&models.Todo{}, &models.User{}) + if err != nil { + log.Fatal("Failed to auto-migrate database schema! \n", err) + } + + DB = database + log.Println("PostgreSQL connection successful!") // 保持成功提示 +} + +// ❗ 修改 AuthRequired 函数签名,使其接受 AuthService 接口 +func AuthRequired(authService services.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + // 1. 从 Authorization 请求头中提取令牌 + tokenString := c.GetHeader("Authorization") + if tokenString == "" || !strings.HasPrefix(tokenString, "Bearer ") { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid token format"}) + c.Abort() // 阻止请求继续执行 + return + } + + // 移除 "Bearer " 前缀 + tokenString = strings.TrimPrefix(tokenString, "Bearer ") + + // 2. ⭐ 调用 Service 层验证令牌 + // Service 层会处理解析、验证签名和过期时间 + subject, err := authService.AuthenticateToken(tokenString) // ❗ 必须在这里声明 subject + // 3. 处理解析错误 (如签名不匹配、令牌过期等) + if err != nil { + // Service 层的错误信息已经很通用了 + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"}) + c.Abort() + return + } + // 4. ⭐ 核心修改:将用户 ID 存储到 Gin Context 中 + c.Set(constants.UserIDKey, subject) // ❗ 使用 constants.UserIDKey + + // 5. 令牌有效,继续执行下一个 Handler + c.Next() + } +} + +// unzipFile 将 ZIP 文件解压到目标目录 +func unzipFile(src, dest string) error { + r, err := zip.OpenReader(src) + if err != nil { + return err + } + defer r.Close() + + for _, f := range r.File { + // 1. 构建目标路径,确保文件不会逃逸到目标目录之外(安全检查) + fpath := filepath.Join(dest, f.Name) + if !filepath.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) { + return fmt.Errorf("非法文件路径: %s", fpath) + } + + // 2. 如果是目录,创建目录 + if f.FileInfo().IsDir() { + os.MkdirAll(fpath, os.ModePerm) + continue + } + + // 3. 打开文件进行读写 + if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil { + return err + } + + outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } + + rc, err := f.Open() + if err != nil { + outFile.Close() + return err + } + + // 4. 复制文件内容 + _, err = io.Copy(outFile, rc) + + // 确保关闭文件 + outFile.Close() + rc.Close() + + if err != nil { + return err + } + } + return nil +} + +// handleUpload 是处理文件上传的函数 +func handleUpload(c *gin.Context) { + // 1. 获取文本字段 (html_path) + htmlPath := c.PostForm("html_path") + if htmlPath == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Missing html_path field"}) + return + } + + timeDict := c.PostForm("time_dict") + tsNow := time.Now().Format("20060102150405") + if timeDict == "" { + timeDict = tsNow + } + + // 2. 获取上传的文件 (zip_file) + file, err := c.FormFile("zip_file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Missing zip_file or failed to get file"}) + return + } + // 目标解压目录(以当前时间戳或传入 time_dict 作为子目录) + destDir := filepath.Join("D:\\test-html-page", timeDict) + if err := os.MkdirAll(destDir, os.ModePerm); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create dest dir: " + err.Error()}) + return + } + + // 1. 将 ZIP 文件保存到服务器的临时路径 (注意:我们先保存到临时目录) + tempZipPath := filepath.Join(os.TempDir(), file.Filename) + if err := c.SaveUploadedFile(file, tempZipPath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save temp zip file: " + err.Error()}) + return + } + // 确保函数结束时删除临时文件 + defer os.Remove(tempZipPath) + + // 2. ⭐ 调用解压函数 + if err := unzipFile(tempZipPath, destDir); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unzip file: " + err.Error()}) + return + } + + // 3. 构建用户最终访问的 URL + // 为了简化,我们只返回访问路径。完整的访问 URL 需要下一步的 Gin 配置 + accessPath := filepath.ToSlash(filepath.Join("/html", timeDict, htmlPath)) + + c.JSON(http.StatusOK, gin.H{ + "message": "HTML files extracted and ready to serve.", + "final_access_url": accessPath, + }) +} + +// main 函数是 Go 程序的入口 +func main() { + // 1. 切换到 Release Mode (生产模式) + // 这样做可以禁用调试输出,并优化性能。 + gin.SetMode(gin.ReleaseMode) + + connectDatabase() + + // 确保 JWT Secret 的检查和获取逻辑不变 + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + log.Fatal("FATAL: JWT_SECRET environment variable is not set. Please check your .env file.") + } + + router := gin.Default() + + // 2. 解决 "You trusted all proxies" 安全警告 + // 当你的应用部署在 Nginx 或负载均衡器后面时,Gin 需要知道哪些 IP 是安全的代理。 + // 以下配置信任了 Loopback 和所有私有网络范围(RFC1918),这是云部署的常见安全实践。 + // 如果你知道你的代理 IP,使用更严格的配置更好。 + router.SetTrustedProxies([]string{ + "127.0.0.1", + "::1", + "10.0.0.0/8", // 私有网络 A 类 + "172.16.0.0/12", // 私有网络 B 类 + "192.168.0.0/16", // 私有网络 C 类 + }) + // --- 依赖注入 (DI) --- + todoRepo := repositories.NewGormTodoRepository(DB) + todoService := services.NewTodoService(todoRepo) + todoHandler := handlers.NewTodoHandler(todoService) + + userRepo := repositories.NewGormUserRepository(DB) + authService := services.NewAuthService(userRepo, jwtSecret) + authHandler := handlers.NewAuthHandler(authService) + + // 注册公开路由 + router.GET("/hello", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "Hello from Gin, ready for Vue!"}) + }) + + router.Static("/html", "D:/test-html-page") + router.POST("/register", authHandler.RegisterHandler) + router.POST("/login", authHandler.LoginHandler) + + // 注册需要 AuthRequired 中间件的路由 + authMiddleware := AuthRequired(authService) + + router.POST("/upload", authMiddleware, handleUpload) + + // Todo 受保护的 CRUD 路由 + router.POST("/todos", authMiddleware, todoHandler.CreateTodoHandler) + router.GET("/todos", authMiddleware, todoHandler.FindAllTodosHandler) + router.GET("/todos/:id", authMiddleware, todoHandler.FindTodoByIDHandler) + router.PATCH("/todos/:id", authMiddleware, todoHandler.UpdateTodoHandler) + router.DELETE("/todos/:id", authMiddleware, todoHandler.DeleteTodoByIDHandler) + + // 运行服务器 + log.Printf("Server starting on :%s...", "8090") // 格式化输出 + if err := router.Run(":8090"); err != nil { + panic("Server failed to start: " + err.Error()) + } +} diff --git a/models/README.md b/models/README.md new file mode 100644 index 0000000..25e41bd --- /dev/null +++ b/models/README.md @@ -0,0 +1 @@ +# 存放所有 Go 结构体 (Todo, User) diff --git a/models/todo.go b/models/todo.go new file mode 100644 index 0000000..0c0445d --- /dev/null +++ b/models/todo.go @@ -0,0 +1,20 @@ +// models/todo.go +package models + +import ( + // ❗ 删除: "go-todo-api/models" + "gorm.io/gorm" +) + +type Todo struct { + gorm.Model + Title string `json:"title" binding:"required"` + Description string `json:"description"` + Done bool `json:"done"` + + // UserID 是外键 + UserID uint `json:"-" gorm:"index"` + + // ❗ 核心修正:添加 binding:"-" 阻止 Gin 递归验证 User 字段 + User User `json:"-" binding:"-"` +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..8a091bd --- /dev/null +++ b/models/user.go @@ -0,0 +1,10 @@ +package models + +import "gorm.io/gorm" + +type User struct { + gorm.Model + Username string `gorm:"uniqueIndex" json:"username" binding:"required"` + // 恢复 json:"-" 以防泄露,因为我们现在用 DTO 来处理输入 + Password string `json:"-" binding:"required,min=6"` +} diff --git a/repositories/README.md b/repositories/README.md new file mode 100644 index 0000000..6914061 --- /dev/null +++ b/repositories/README.md @@ -0,0 +1 @@ +# 存放数据库操作逻辑 diff --git a/repositories/gorm_todo_repository.go b/repositories/gorm_todo_repository.go new file mode 100644 index 0000000..96a0430 --- /dev/null +++ b/repositories/gorm_todo_repository.go @@ -0,0 +1,72 @@ +package repositories + +import ( + "go-todo-api/models" + + "gorm.io/gorm" +) + +// GormTodoRepository 结构体持有数据库连接,用于实现 TodoRepository 接口 +type GormTodoRepository struct { + DB *gorm.DB +} + +// NewGormTodoRepository 实例化一个新的 GORM 仓库 +func NewGormTodoRepository(db *gorm.DB) *GormTodoRepository { + return &GormTodoRepository{DB: db} +} + +// ❗ 修正 FindAll:接收 userID,并添加 WHERE 约束 +func (r *GormTodoRepository) FindAll(userID uint) ([]models.Todo, error) { + var todos []models.Todo + // 增加 WHERE UserID = ? 约束 + result := r.DB.Where("user_id = ?", userID).Find(&todos) + return todos, result.Error +} + +// ❗ 修正 FindByID:接收 userID,并添加 WHERE 约束 +// ❗ 必须确保这个方法使用了 UserID 来查找和授权 +func (r *GormTodoRepository) FindByID(id uint, userID uint) (*models.Todo, error) { + var todo models.Todo + // 增加 WHERE ID = ? AND UserID = ? 约束 + // ⭐ 核心:WHERE id = ? AND user_id = ? 约束 + result := r.DB.Where("id = ? AND user_id = ?", id, userID).First(&todo) + if result.Error != nil { + return nil, result.Error + } + return &todo, nil +} + +// ❗ 修正 Create 方法签名 +func (r *GormTodoRepository) Create(todo *models.Todo) error { + if result := r.DB.Create(todo); result.Error != nil { + return result.Error + } + return nil +} + +// ❗ 修正 Delete:接收 userID,并添加 WHERE 约束 +func (r *GormTodoRepository) Delete(id uint, userID uint) error { + var todo models.Todo + // 增加 WHERE ID = ? AND UserID = ? 约束 + result := r.DB.Where("id = ? AND user_id = ?", id, userID).Delete(&todo) + + if result.Error != nil { + return result.Error + } + + // 如果没有行受到影响,且没有错误,说明记录未找到(或不属于该用户) + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +// ❗ 修正 Update 方法签名 +func (r *GormTodoRepository) Update(todo *models.Todo, input map[string]interface{}) error { + // GORM 的 Updates 方法可以更新 map 中的字段 + if result := r.DB.Model(todo).Updates(input); result.Error != nil { + return result.Error + } + return nil +} diff --git a/repositories/gorm_user_repository.go b/repositories/gorm_user_repository.go new file mode 100644 index 0000000..e04d99c --- /dev/null +++ b/repositories/gorm_user_repository.go @@ -0,0 +1,37 @@ +// repositories/gorm_user_repository.go +package repositories + +import ( + "go-todo-api/models" + + "gorm.io/gorm" +) + +// GormUserRepository 是 User Repository 接口的 GORM 实现 +type GormUserRepository struct { + DB *gorm.DB +} + +// NewGormUserRepository 创建 GormUserRepository 的新实例 +func NewGormUserRepository(db *gorm.DB) *GormUserRepository { + return &GormUserRepository{DB: db} +} + +// FindByUsername 根据用户名查找用户 +func (r *GormUserRepository) FindByUsername(username string) (*models.User, error) { + var user models.User + result := r.DB.Where("username = ?", username).First(&user) + if result.Error != nil { + // 统一返回 GORM 错误,让上层 Service 处理 + return nil, result.Error + } + return &user, nil +} + +// Create 创建新用户 +func (r *GormUserRepository) Create(user *models.User) error { + if result := r.DB.Create(user); result.Error != nil { + return result.Error + } + return nil +} diff --git a/repositories/todo_repository.go b/repositories/todo_repository.go new file mode 100644 index 0000000..566342a --- /dev/null +++ b/repositories/todo_repository.go @@ -0,0 +1,18 @@ +package repositories + +import "go-todo-api/models" + +type TodoRepository interface { + // ❗ 修正:FindAll, FindByID, Delete 需要 userID 进行查询约束 + FindAll(userID uint) ([]models.Todo, error) + FindByID(id uint, userID uint) (*models.Todo, error) + + // ❗ 修正:Create 不应包含 userID,因为 Service 已经设置了 todo.UserID + Create(todo *models.Todo) error + + // ❗ 修正:Update 不应包含 userID,因为 FindByID 已经完成了授权 + Update(todo *models.Todo, input map[string]interface{}) error + + // 修正:Delete 需要 userID 进行查询约束 + Delete(id uint, userID uint) error +} diff --git a/repositories/user_repository.go b/repositories/user_repository.go new file mode 100644 index 0000000..3aa489b --- /dev/null +++ b/repositories/user_repository.go @@ -0,0 +1,11 @@ +package repositories + +import "go-todo-api/models" + +// UserRepository 定义了与 User 模型相关的数据库操作 +type UserRepository interface { + // FindByUsername 根据用户名查找用户 + FindByUsername(username string) (*models.User, error) + // Create 创建新用户 + Create(user *models.User) error +} diff --git a/services/README.md b/services/README.md new file mode 100644 index 0000000..90798c1 --- /dev/null +++ b/services/README.md @@ -0,0 +1 @@ +# 存放业务逻辑 diff --git a/services/auth_service.go b/services/auth_service.go new file mode 100644 index 0000000..3da4c1d --- /dev/null +++ b/services/auth_service.go @@ -0,0 +1,17 @@ +// services/auth_service.go +package services + +import ( + "go-todo-api/dto" + "go-todo-api/models" +) + +// AuthService 定义了用户认证和授权相关的业务逻辑 +type AuthService interface { + // Register 处理用户注册,返回创建的用户和可能的错误 + Register(input *dto.RegisterInput) (*models.User, error) + // Login 处理用户登录,返回 JWT 令牌和可能的错误 + Login(input *dto.LoginInput) (string, error) + // AuthenticateToken 验证 JWT 令牌,返回用户ID (Subject) + AuthenticateToken(tokenString string) (string, error) +} diff --git a/services/auth_service_impl.go b/services/auth_service_impl.go new file mode 100644 index 0000000..368f194 --- /dev/null +++ b/services/auth_service_impl.go @@ -0,0 +1,123 @@ +// services/auth_service_impl.go +package services + +import ( + "errors" + "fmt" + "time" + + "go-todo-api/dto" + "go-todo-api/models" + "go-todo-api/repositories" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// 定义一个秘密常量,你需要确保在整个应用中统一使用它! +// 建议从配置文件或环境变量读取 +const jwtSecret = "aabbccdd66778899.." + +// AuthServiceImpl 实现了 AuthService 接口 +type AuthServiceImpl struct { + UserRepo repositories.UserRepository + // ❗ 核心修正:添加 JWTSecret 字段 + JWTSecret string +} + +// NewAuthService 创建 AuthServiceImpl 的新实例 +func NewAuthService(userRepo repositories.UserRepository, secret string) *AuthServiceImpl { + return &AuthServiceImpl{UserRepo: userRepo, JWTSecret: secret} +} + +// Register 处理用户注册逻辑 +func (s *AuthServiceImpl) Register(input *dto.RegisterInput) (*models.User, error) { + // 1. 检查用户是否已存在 + _, err := s.UserRepo.FindByUsername(input.Username) + if err == nil { + // 如果 FindByUsername 没有返回错误,说明用户已存在 + return nil, fmt.Errorf("username already taken") + } + // 如果错误不是 gorm.ErrRecordNotFound,则返回其他错误 + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + // 2. 密码哈希 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + // 3. 创建用户模型 + newUser := models.User{ + Username: input.Username, + Password: string(hashedPassword), + } + + // 4. 保存到数据库 + if err := s.UserRepo.Create(&newUser); err != nil { + return nil, fmt.Errorf("failed to create user: %w", err) + } + + return &newUser, nil +} + +// Login 处理用户登录逻辑,返回 JWT 令牌 +func (s *AuthServiceImpl) Login(input *dto.LoginInput) (string, error) { + // 1. 查找用户 + foundUser, err := s.UserRepo.FindByUsername(input.Username) + if err != nil { + // 统一返回授权错误,避免泄露用户不存在的信息 + return "", fmt.Errorf("invalid username or password") + } + + // 2. 密码比较 + err = bcrypt.CompareHashAndPassword([]byte(foundUser.Password), []byte(input.Password)) + if err != nil { + return "", fmt.Errorf("invalid username or password") + } + + // 3. JWT 令牌生成 + claims := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Subject: fmt.Sprintf("%d", foundUser.ID), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }) + + // 4. 签名令牌 + tokenString, err := claims.SignedString([]byte(jwtSecret)) + if err != nil { + return "", fmt.Errorf("failed to generate token: %w", err) + } + + return tokenString, nil +} + +// AuthenticateToken 验证 JWT 令牌 +func (s *AuthServiceImpl) AuthenticateToken(tokenString string) (string, error) { + token, err := jwt.Parse(tokenString, 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 []byte(jwtSecret), nil + }) + + if err != nil || !token.Valid { + return "", fmt.Errorf("invalid or expired token") + } + + // 从 Claims 中提取 Sub (用户 ID) + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "", fmt.Errorf("invalid token claims") + } + + sub, err := claims.GetSubject() + if err != nil { + return "", fmt.Errorf("missing subject in token claims") + } + + return sub, nil // 返回用户 ID 字符串 +} diff --git a/services/todo_implementation.go b/services/todo_implementation.go new file mode 100644 index 0000000..11f5f14 --- /dev/null +++ b/services/todo_implementation.go @@ -0,0 +1,64 @@ +package services + +import ( + "go-todo-api/models" + "go-todo-api/repositories" +) + +// TodoServiceImpl 结构体持有 TodoRepository 接口,实现业务逻辑 +type TodoServiceImpl struct { + // Repo 字段是 Repository 接口类型,而不是具体实现 + Repo repositories.TodoRepository +} + +// NewTodoService 实例化一个新的 TodoService +func NewTodoService(repo repositories.TodoRepository) TodoService { + return &TodoServiceImpl{Repo: repo} +} + +// FindAllTodos 实现了 Service 接口,直接调用 Repository +func (s *TodoServiceImpl) FindAllTodos(userID uint) ([]models.Todo, error) { + // 业务逻辑(例如:权限检查、数据缓存等)会在这里添加 + // ❗ 修正:将 userID 传递给 Repo + return s.Repo.FindAll(userID) +} + +// FindTodoByID 实现了 Service 接口,并处理 Repository 可能返回的错误 +func (s *TodoServiceImpl) FindTodoByID(id uint, userID uint) (*models.Todo, error) { + // 调用 Repository 查找数据 + return s.Repo.FindByID(id, userID) +} + +// DeleteTodoByID 实现了 Service 接口的删除方法 +func (s *TodoServiceImpl) DeleteTodoByID(id uint, userID uint) error { + // ❗ 调用 Repository 层执行删除 + return s.Repo.Delete(id, userID) +} + +// CreateTodo 实现了 Service 接口的创建方法 +func (s *TodoServiceImpl) CreateTodo(todo *models.Todo, userID uint) error { + // 1. 设置 UserID (这是业务逻辑,在 Service 层完成) + todo.UserID = userID + // ❗ 调用 Repository 层执行创建 + return s.Repo.Create(todo) +} + +// UpdateTodo 实现了 Service 接口的更新方法 +func (s *TodoServiceImpl) UpdateTodo(id uint, userID uint, input map[string]interface{}) (*models.Todo, error) { + // 1. 先查找记录(Repository层 FindByID 已经实现了) + // ❗ 修正:将 userID 传递给 FindByID + todo, err := s.Repo.FindByID(id, userID) + if err != nil { + // 如果未找到或有其他错误,直接返回 + return nil, err + } + + // 2. 使用 Repository 层执行更新 + // 2. Repository 层执行更新 (Repository 不需知道 UserID,因为它操作的是已找到的 todo 对象) + if err := s.Repo.Update(todo, input); err != nil { + return nil, err + } + + // 3. 返回更新后的对象 + return todo, nil +} diff --git a/services/todo_service.go b/services/todo_service.go new file mode 100644 index 0000000..fc056f9 --- /dev/null +++ b/services/todo_service.go @@ -0,0 +1,19 @@ +package services + +import "go-todo-api/models" + +type TodoService interface { + // FindAllTodos 返回所有 Todo 记录 + FindAllTodos(userID uint) ([]models.Todo, error) + + // FindTodoByID 根据 ID 返回单条 Todo 记录 + FindTodoByID(id uint, userID uint) (*models.Todo, error) + + // ❗ 新增:用于删除待办事项的方法 + DeleteTodoByID(id uint, userID uint) error // ❗ 确保有此签名 + + // ❗ 新增:创建待办事项时,需要传递用户 ID + CreateTodo(todo *models.Todo, userID uint) error // ❗ 确保有此签名 + + UpdateTodo(id uint, userID uint, input map[string]interface{}) (*models.Todo, error) // ❗ 确保有此签名 +}