commit 084d3b0faf774ab34ee7d7a0159a15d969e25cbe Author: nannanwu Date: Mon Feb 23 23:50:04 2026 +0800 lv8girl! diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/MarsCodeWorkspaceAppSettings.xml b/.idea/MarsCodeWorkspaceAppSettings.xml new file mode 100644 index 0000000..e2a065b --- /dev/null +++ b/.idea/MarsCodeWorkspaceAppSettings.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/lv8girl-go.iml b/.idea/lv8girl-go.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/lv8girl-go.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..78ae60f --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..51ee046 --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +# lv8girl - 绿坝娘二次元论坛 + +一个基于 Go + Gin + SQLite 构建的二次元社区论坛系统。 + +## 项目概述 + +lv8girl 是一个功能完整的社区论坛系统,支持用户注册登录、帖子发布审核、评论点赞、私信系统和管理后台等功能。 + +## 技术栈 + +- **语言**: Go 1.21+ +- **Web框架**: Gin +- **数据库**: SQLite (使用 glebarez/sqlite 纯Go实现,无需CGO) +- **ORM**: GORM +- **会话管理**: gin-sessions + +## 目录结构 + +``` +lv8girl/ +├── cmd/ +│ └── server/ +│ └── main.go # 程序入口文件 +│ +├── internal/ # 私有应用代码 +│ ├── config/ # 配置管理 +│ │ └── config.go # 应用配置定义 +│ │ +│ ├── controllers/ # 控制器层 - 处理HTTP请求 +│ │ ├── admin.go # 管理后台控制器 +│ │ ├── auth.go # 认证控制器(登录/注册/登出) +│ │ ├── discussion.go # 帖子发布控制器 +│ │ ├── home.go # 首页/帖子详情控制器 +│ │ ├── message.go # 私信控制器 +│ │ └── user.go # 用户资料控制器 +│ │ +│ ├── middleware/ # 中间件 +│ │ └── auth.go # 认证中间件(登录检查/管理员检查) +│ │ +│ ├── models/ # 数据模型定义 +│ │ └── models.go # User, Discussion, Comment, Like, PrivateMessage +│ │ +│ ├── repositories/ # 数据访问层 - 数据库操作 +│ │ ├── db.go # 数据库初始化 +│ │ ├── comment.go # 评论数据访问 +│ │ ├── discussion.go # 帖子数据访问 +│ │ ├── like.go # 点赞数据访问 +│ │ ├── message.go # 私信数据访问 +│ │ └── user.go # 用户数据访问 +│ │ +│ ├── routes/ # 路由配置 +│ │ └── routes.go # 所有路由定义 +│ │ +│ ├── services/ # 业务逻辑层 +│ │ ├── admin.go # 管理业务逻辑 +│ │ ├── auth.go # 认证业务逻辑 +│ │ ├── discussion.go # 帖子业务逻辑 +│ │ ├── message.go # 私信业务逻辑 +│ │ └── user.go # 用户业务逻辑 +│ │ +│ └── utils/ # 工具函数 +│ └── utils.go # 通用工具函数 +│ +├── templates/ # HTML模板文件 +│ ├── index.html # 首页 +│ ├── login.html # 登录页 +│ ├── register.html # 注册页 +│ ├── post.html # 帖子详情页 +│ ├── post_discussion.html # 发帖页 +│ ├── profile.html # 用户资料页 +│ ├── messages.html # 私信列表页 +│ ├── send_message.html # 发送私信页 +│ ├── admin_dashboard.html # 管理后台-仪表盘 +│ ├── admin_pending_posts.html # 管理后台-待审核帖子 +│ ├── admin_pending_users.html # 管理后台-待审核用户 +│ ├── admin_posts.html # 管理后台-帖子管理 +│ ├── admin_users.html # 管理后台-用户管理 +│ └── admin_comments.html # 管理后台-评论管理 +│ +├── static/ # 静态资源(CSS/JS) +├── uploads/ # 用户上传文件 +│ ├── avatars/ # 用户头像 +│ └── posts/ # 帖子图片 +│ +├── data/ # 数据库文件目录 +│ └── lv8girl.db # SQLite数据库 +│ +├── go.mod # Go模块定义 +├── go.sum # 依赖校验文件 +├── Dockerfile # Docker构建文件 +├── compose.yaml # Docker Compose配置 +└── README.md # 项目说明文档 +``` + +## 架构设计 + +项目采用分层架构,职责分离清晰: + +``` +请求 → 路由(Routes) → 中间件(Middleware) → 控制器(Controllers) → 服务(Services) → 仓库(Repositories) → 数据库 +``` + +### 各层职责 + +| 层级 | 职责 | 示例 | +|------|------|------| +| Routes | 定义URL路由规则,绑定控制器方法 | `r.GET("/", homeCtrl.Index)` | +| Middleware | 请求预处理(认证、权限检查等) | `AuthRequired()`, `AdminRequired()` | +| Controllers | 处理HTTP请求,参数验证,调用Service | 解析表单数据,返回HTML/JSON | +| Services | 业务逻辑处理,事务管理 | 用户注册流程,帖子审核流程 | +| Repositories | 数据库CRUD操作 | `FindByID`, `Create`, `Update` | +| Models | 数据结构定义 | `User`, `Discussion` 结构体 | + +## 功能特性 + +### 用户功能 +- 用户注册(需管理员审核) +- 用户登录/登出 +- 个人资料页 +- 头像上传 + +### 帖子功能 +- 发布帖子(支持图片上传,需审核) +- 帖子列表浏览 +- 帖子详情查看 +- 点赞功能 +- 评论功能 + +### 私信功能 +- 发送私信 +- 私信列表 +- 未读消息提示 + +### 管理后台 +- 仪表盘统计 +- 用户审核管理 +- 帖子审核管理 +- 用户角色管理 +- 评论管理 + +## 快速开始 + +### 本地运行 + +```bash +# 克隆项目 +git clone +cd lv8girl + +# 下载依赖 +go mod tidy + +# 运行 +go run ./cmd/server +``` + +访问 http://localhost:8080 + +### Docker运行 + +```bash +# 构建并运行 +docker-compose up --build +``` + +## 默认账号 + +管理员账号: +- 用户名: `admin` +- 密码: `admin123` + +## 配置说明 + +配置文件位于 `internal/config/config.go`: + +```go +type Config struct { + Server ServerConfig // 服务端口等 + Database DatabaseConfig // 数据库路径 + Session SessionConfig // 会话密钥 + App AppConfig // 应用名称版本 +} +``` + +## 数据库模型 + +### User (用户) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | uint | 主键 | +| username | string | 用户名 | +| email | string | 邮箱 | +| password_hash | string | 密码哈希 | +| avatar | string | 头像路径 | +| role | string | 角色(user/admin/banned) | +| status | string | 状态(pending/approved/rejected) | +| last_active | time | 最后活动时间 | + +### Discussion (帖子) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | uint | 主键 | +| user_id | uint | 作者ID | +| title | string | 标题 | +| content | text | 内容 | +| image_path | string | 图片路径 | +| status | string | 状态(pending/approved/rejected) | +| views | int | 浏览量 | + +### Comment (评论) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | uint | 主键 | +| post_id | uint | 帖子ID | +| user_id | uint | 评论者ID | +| content | text | 内容 | + +### Like (点赞) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | uint | 主键 | +| post_id | uint | 帖子ID | +| user_id | uint | 用户ID | + +### PrivateMessage (私信) +| 字段 | 类型 | 说明 | +|------|------|------| +| id | uint | 主键 | +| from_user_id | uint | 发送者ID | +| to_user_id | uint | 接收者ID | +| content | text | 内容 | +| is_read | bool | 是否已读 | + +## 开发指南 + +### 添加新功能 + +1. 在 `models/` 添加数据模型 +2. 在 `repositories/` 添加数据访问方法 +3. 在 `services/` 添加业务逻辑 +4. 在 `controllers/` 添加控制器 +5. 在 `routes/` 注册路由 +6. 在 `templates/` 添加模板 + +### 代码规范 + +- 遵循 Go 命名规范 +- 每个包有明确的职责 +- 错误要妥善处理 +- 敏感信息不要硬编码 + +## 许可证 + +MIT License diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..d2ef483 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "log" + + "lv8girl/internal/config" + "lv8girl/internal/repositories" + "lv8girl/internal/routes" + "lv8girl/internal/services" +) + +func main() { + cfg := config.GetConfig() + + if err := repositories.Init(cfg.Database.Path); err != nil { + log.Fatalf("Failed to initialize database: %v", err) + } + + authSvc := services.NewAuthService() + authSvc.InitAdmin() + + r := routes.SetupRouter() + + log.Printf("Server starting on %s", cfg.Server.Port) + if err := r.Run(cfg.Server.Port); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..de102f3 --- /dev/null +++ b/go.mod @@ -0,0 +1,51 @@ +module lv8girl + +go 1.21 + +require ( + github.com/gin-contrib/sessions v1.0.1 + github.com/gin-gonic/gin v1.9.1 + github.com/glebarez/sqlite v1.10.0 + golang.org/x/crypto v0.22.0 + gorm.io/gorm v1.25.8 +) + +require ( + github.com/bytedance/sonic v1.11.3 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect + github.com/chenzhuoyu/iasm v0.9.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // 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.19.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/sessions v1.2.2 // 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.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // 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.2.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.7.0 // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..243d193 --- /dev/null +++ b/go.sum @@ -0,0 +1,129 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= +github.com/bytedance/sonic v1.11.3 h1:jRN+yEjakWh8aK5FzrciUHG8OFXK+4/KrAX/ysEtHAA= +github.com/bytedance/sonic v1.11.3/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= +github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= +github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0= +github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= +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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sessions v1.0.1 h1:3hsJyNs7v7N8OtelFmYXFrulAf6zSR7nW/putcPEHxI= +github.com/gin-contrib/sessions v1.0.1/go.mod h1:ouxSFM24/OgIud5MJYQJLpy6AwxQ5EYO9yLhbtObGkM= +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/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.10.0 h1:u4gt8y7OND/cCei/NMHmfbLxF6xP2wgKcT/BJf2pYkc= +github.com/glebarez/sqlite v1.10.0/go.mod h1:IJ+lfSOmiekhQsFTJRx/lHtGYmCdtAiTaf5wI9u5uHA= +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.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4= +github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +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/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= +github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= +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.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +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/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.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo= +github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +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/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc= +golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.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-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +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/gorm v1.25.8 h1:WAGEZ/aEcznN4D03laj8DKnehe1e9gYQAjW8xyPRdeo= +gorm.io/gorm v1.25.8/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..09c909f --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,53 @@ +package config + +import "time" + +type Config struct { + Server ServerConfig + Database DatabaseConfig + Session SessionConfig + App AppConfig +} + +type ServerConfig struct { + Port string + ReadTimeout time.Duration + WriteTimeout time.Duration +} + +type DatabaseConfig struct { + Path string +} + +type SessionConfig struct { + Secret string + Name string +} + +type AppConfig struct { + Name string + Version string +} + +var AppConfigInstance = &Config{ + Server: ServerConfig{ + Port: ":8080", + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + }, + Database: DatabaseConfig{ + Path: "data/lv8girl.db", + }, + Session: SessionConfig{ + Secret: "lv8girl-secret-key-change-in-production", + Name: "lv8girl_session", + }, + App: AppConfig{ + Name: "lv8girl", + Version: "1.0.0", + }, +} + +func GetConfig() *Config { + return AppConfigInstance +} diff --git a/internal/controllers/admin.go b/internal/controllers/admin.go new file mode 100644 index 0000000..7f12ec7 --- /dev/null +++ b/internal/controllers/admin.go @@ -0,0 +1,179 @@ +package controllers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "lv8girl/internal/middleware" + "lv8girl/internal/services" +) + +type AdminController struct { + adminSvc *services.AdminService + messageSvc *services.MessageService +} + +func NewAdminController() *AdminController { + return &AdminController{ + adminSvc: services.NewAdminService(), + messageSvc: services.NewMessageService(), + } +} + +func (c *AdminController) Dashboard(ctx *gin.Context) { + username, _ := ctx.Get("username") + stats, _ := c.adminSvc.GetStats() + + ctx.HTML(http.StatusOK, "admin_dashboard.html", gin.H{ + "Username": username, + "Stats": stats, + "Page": "dashboard", + "Message": ctx.Query("msg"), + }) +} + +func (c *AdminController) PendingPosts(ctx *gin.Context) { + username, _ := ctx.Get("username") + posts, _ := c.adminSvc.GetPendingPosts() + + ctx.HTML(http.StatusOK, "admin_pending_posts.html", gin.H{ + "Username": username, + "Posts": posts, + "Page": "pending_posts", + "Message": ctx.Query("msg"), + }) +} + +func (c *AdminController) ApprovePost(ctx *gin.Context) { + postID := parseUint(ctx.Param("id")) + action := ctx.Param("action") + + if action == "approve" { + c.adminSvc.ApprovePost(postID) + ctx.Redirect(http.StatusFound, "/admin/pending_posts?msg=帖子已通过审核") + } else if action == "reject" { + c.adminSvc.RejectPost(postID) + ctx.Redirect(http.StatusFound, "/admin/pending_posts?msg=帖子已拒绝") + } else { + ctx.Redirect(http.StatusFound, "/admin/pending_posts") + } +} + +func (c *AdminController) PendingUsers(ctx *gin.Context) { + username, _ := ctx.Get("username") + users, _ := c.adminSvc.GetPendingUsers() + + ctx.HTML(http.StatusOK, "admin_pending_users.html", gin.H{ + "Username": username, + "Users": users, + "Page": "pending_users", + "Message": ctx.Query("msg"), + }) +} + +func (c *AdminController) ApproveUser(ctx *gin.Context) { + userID := parseUint(ctx.Param("id")) + action := ctx.Param("action") + adminID, _ := ctx.Get("user_id") + + if action == "approve" { + c.adminSvc.ApproveUser(userID) + c.messageSvc.NotifyUserApproved(adminID.(uint), userID) + ctx.Redirect(http.StatusFound, "/admin/pending_users?msg=用户已通过审核") + } else if action == "reject" { + c.adminSvc.RejectUser(userID) + c.messageSvc.NotifyUserRejected(adminID.(uint), userID) + ctx.Redirect(http.StatusFound, "/admin/pending_users?msg=用户已拒绝") + } else { + ctx.Redirect(http.StatusFound, "/admin/pending_users") + } +} + +func (c *AdminController) Posts(ctx *gin.Context) { + username, _ := ctx.Get("username") + posts, _ := c.adminSvc.GetAllPosts() + + ctx.HTML(http.StatusOK, "admin_posts.html", gin.H{ + "Username": username, + "Posts": posts, + "Page": "posts", + "Message": ctx.Query("msg"), + }) +} + +func (c *AdminController) DeletePost(ctx *gin.Context) { + postID := parseUint(ctx.Param("id")) + c.adminSvc.DeletePost(postID) + ctx.Redirect(http.StatusFound, "/admin/posts?msg=帖子已删除") +} + +func (c *AdminController) Users(ctx *gin.Context) { + username, _ := ctx.Get("username") + currentUserID, _ := ctx.Get("user_id") + users, _ := c.adminSvc.GetAllUsers() + + ctx.HTML(http.StatusOK, "admin_users.html", gin.H{ + "Username": username, + "Users": users, + "CurrentUserID": currentUserID, + "Page": "users", + "Message": ctx.Query("msg"), + }) +} + +func (c *AdminController) UpdateUserRole(ctx *gin.Context) { + userIDStr := ctx.PostForm("user_id") + newRole := ctx.PostForm("new_role") + currentUserID, _ := ctx.Get("user_id") + + userID, _ := strconv.ParseUint(userIDStr, 10, 32) + + if uint(userID) == currentUserID.(uint) { + ctx.Redirect(http.StatusFound, "/admin/users?msg=不能修改自己的角色") + return + } + + c.adminSvc.UpdateUserRole(uint(userID), newRole) + + if newRole == "banned" { + c.messageSvc.NotifyUserBanned(currentUserID.(uint), uint(userID)) + } + + ctx.Redirect(http.StatusFound, "/admin/users?msg=用户角色已更新") +} + +func (c *AdminController) DeleteUser(ctx *gin.Context) { + userID := parseUint(ctx.Param("id")) + currentUserID, _ := ctx.Get("user_id") + + if userID == currentUserID.(uint) { + ctx.Redirect(http.StatusFound, "/admin/users?msg=不能删除自己") + return + } + + c.adminSvc.DeleteUser(userID) + ctx.Redirect(http.StatusFound, "/admin/users?msg=用户已删除") +} + +func (c *AdminController) Comments(ctx *gin.Context) { + username, _ := ctx.Get("username") + comments, _ := c.adminSvc.GetAllComments() + + ctx.HTML(http.StatusOK, "admin_comments.html", gin.H{ + "Username": username, + "Comments": comments, + "Page": "comments", + "Message": ctx.Query("msg"), + }) +} + +func (c *AdminController) DeleteComment(ctx *gin.Context) { + commentID := parseUint(ctx.Param("id")) + c.adminSvc.DeleteComment(commentID) + ctx.Redirect(http.StatusFound, "/admin/comments?msg=评论已删除") +} + +func init() { + _ = middleware.GetCurrentUser +} diff --git a/internal/controllers/auth.go b/internal/controllers/auth.go new file mode 100644 index 0000000..cc63f27 --- /dev/null +++ b/internal/controllers/auth.go @@ -0,0 +1,99 @@ +package controllers + +import ( + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "lv8girl/internal/services" +) + +type AuthController struct { + authService *services.AuthService +} + +func NewAuthController() *AuthController { + return &AuthController{ + authService: services.NewAuthService(), + } +} + +func (c *AuthController) ShowLogin(ctx *gin.Context) { + session := sessions.Default(ctx) + if session.Get("user_id") != nil { + ctx.Redirect(http.StatusFound, "/") + return + } + ctx.HTML(http.StatusOK, "login.html", gin.H{"Error": ""}) +} + +func (c *AuthController) Login(ctx *gin.Context) { + session := sessions.Default(ctx) + if session.Get("user_id") != nil { + ctx.Redirect(http.StatusFound, "/") + return + } + + login := ctx.PostForm("login") + password := ctx.PostForm("password") + + if login == "" || password == "" { + ctx.HTML(http.StatusOK, "login.html", gin.H{"Error": "请输入用户名/邮箱和密码"}) + return + } + + result := c.authService.Login(login, password) + if !result.Success { + ctx.HTML(http.StatusOK, "login.html", gin.H{"Error": result.Error}) + return + } + + session.Set("user_id", result.User.ID) + session.Set("username", result.User.Username) + session.Set("user_role", result.User.Role) + session.Save() + + ctx.Redirect(http.StatusFound, "/") +} + +func (c *AuthController) ShowRegister(ctx *gin.Context) { + session := sessions.Default(ctx) + if session.Get("user_id") != nil { + ctx.Redirect(http.StatusFound, "/") + return + } + ctx.HTML(http.StatusOK, "register.html", gin.H{"Error": "", "Success": ""}) +} + +func (c *AuthController) Register(ctx *gin.Context) { + session := sessions.Default(ctx) + if session.Get("user_id") != nil { + ctx.Redirect(http.StatusFound, "/") + return + } + + username := ctx.PostForm("username") + email := ctx.PostForm("email") + password := ctx.PostForm("password") + confirmPassword := ctx.PostForm("confirm_password") + + if password != confirmPassword { + ctx.HTML(http.StatusOK, "register.html", gin.H{"Error": "两次输入的密码不一致", "Success": ""}) + return + } + + result := c.authService.Register(username, email, password) + if !result.Success { + ctx.HTML(http.StatusOK, "register.html", gin.H{"Error": result.Error, "Success": ""}) + return + } + + ctx.HTML(http.StatusOK, "register.html", gin.H{"Error": "", "Success": "注册成功!您的账号正在等待管理员审核,请耐心等待。"}) +} + +func (c *AuthController) Logout(ctx *gin.Context) { + session := sessions.Default(ctx) + session.Clear() + session.Save() + ctx.Redirect(http.StatusFound, "/") +} diff --git a/internal/controllers/discussion.go b/internal/controllers/discussion.go new file mode 100644 index 0000000..a08af92 --- /dev/null +++ b/internal/controllers/discussion.go @@ -0,0 +1,121 @@ +package controllers + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" + "lv8girl/internal/middleware" + "lv8girl/internal/services" +) + +type DiscussionController struct { + discussionSvc *services.DiscussionService +} + +func NewDiscussionController() *DiscussionController { + return &DiscussionController{ + discussionSvc: services.NewDiscussionService(), + } +} + +func (c *DiscussionController) ShowNewPost(ctx *gin.Context) { + userID, username, userRole, _ := middleware.GetCurrentUser(ctx) + ctx.HTML(http.StatusOK, "post_discussion.html", gin.H{ + "IsLoggedIn": true, + "UserID": userID, + "Username": username, + "UserRole": userRole, + "Error": "", + "Success": "", + }) +} + +func (c *DiscussionController) CreatePost(ctx *gin.Context) { + userID, username, userRole, _ := middleware.GetCurrentUser(ctx) + + title := strings.TrimSpace(ctx.PostForm("title")) + content := strings.TrimSpace(ctx.PostForm("content")) + + if title == "" || content == "" { + ctx.HTML(http.StatusOK, "post_discussion.html", gin.H{ + "IsLoggedIn": true, + "UserID": userID, + "Username": username, + "UserRole": userRole, + "Error": "标题和内容不能为空", + "Success": "", + }) + return + } + + var imagePath string + file, err := ctx.FormFile("image") + if err == nil { + if file.Size > 2*1024*1024 { + ctx.HTML(http.StatusOK, "post_discussion.html", gin.H{ + "IsLoggedIn": true, + "UserID": userID, + "Username": username, + "UserRole": userRole, + "Error": "图片大小不能超过2MB", + "Success": "", + }) + return + } + + ext := strings.ToLower(filepath.Ext(file.Filename)) + filename := "post_" + time.Now().Format("20060102150405") + "_" + randomString(8) + ext + uploadDir := "uploads/posts" + + if _, err := os.Stat(uploadDir); os.IsNotExist(err) { + os.MkdirAll(uploadDir, 0755) + } + + imagePath = filepath.Join(uploadDir, filename) + if err := ctx.SaveUploadedFile(file, imagePath); err != nil { + ctx.HTML(http.StatusOK, "post_discussion.html", gin.H{ + "IsLoggedIn": true, + "UserID": userID, + "Username": username, + "UserRole": userRole, + "Error": "图片保存失败", + "Success": "", + }) + return + } + } + + if err := c.discussionSvc.CreatePost(userID, title, content, imagePath); err != nil { + ctx.HTML(http.StatusOK, "post_discussion.html", gin.H{ + "IsLoggedIn": true, + "UserID": userID, + "Username": username, + "UserRole": userRole, + "Error": "发帖失败,请稍后重试", + "Success": "", + }) + return + } + + ctx.HTML(http.StatusOK, "post_discussion.html", gin.H{ + "IsLoggedIn": true, + "UserID": userID, + "Username": username, + "UserRole": userRole, + "Error": "", + "Success": "帖子已提交,等待管理员审核。", + }) +} + +func randomString(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, n) + for i := range b { + b[i] = letters[time.Now().UnixNano()%int64(len(letters))] + } + return string(b) +} diff --git a/internal/controllers/home.go b/internal/controllers/home.go new file mode 100644 index 0000000..99a243c --- /dev/null +++ b/internal/controllers/home.go @@ -0,0 +1,150 @@ +package controllers + +import ( + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "lv8girl/internal/middleware" + "lv8girl/internal/repositories" + "lv8girl/internal/services" +) + +type HomeController struct { + discussionSvc *services.DiscussionService + userSvc *services.UserService + messageSvc *services.MessageService + discussionRepo *repositories.DiscussionRepository +} + +func NewHomeController() *HomeController { + return &HomeController{ + discussionSvc: services.NewDiscussionService(), + userSvc: services.NewUserService(), + messageSvc: services.NewMessageService(), + discussionRepo: repositories.NewDiscussionRepository(), + } +} + +func (c *HomeController) Index(ctx *gin.Context) { + session := sessions.Default(ctx) + userID, _ := session.Get("user_id").(uint) + username, _ := session.Get("username").(string) + userRole, _ := session.Get("user_role").(string) + isLoggedIn := userID != 0 + + if isLoggedIn { + userRepo := repositories.NewUserRepository() + userRepo.UpdateLastActive(userID) + } + + posts, _ := c.discussionSvc.GetApprovedPosts(30) + + var postCount, userCount, onlineCount int64 + postCount, _ = c.discussionRepo.CountByStatus("approved") + userCount, onlineCount, _ = c.userSvc.GetUserStats() + + var unreadCount int64 + if isLoggedIn { + unreadCount, _ = c.messageSvc.GetUnreadCount(userID) + } + + ctx.HTML(http.StatusOK, "index.html", gin.H{ + "IsLoggedIn": isLoggedIn, + "UserID": userID, + "Username": username, + "UserRole": userRole, + "Posts": posts, + "PostCount": postCount, + "UserCount": userCount, + "OnlineCount": onlineCount, + "UnreadCount": unreadCount, + }) +} + +func (c *HomeController) ShowPost(ctx *gin.Context) { + postID := parseUint(ctx.Param("id")) + userID, username, userRole, isLoggedIn := middleware.GetCurrentUser(ctx) + + session := sessions.Default(ctx) + viewedKey := "viewed_posts" + viewedPosts := session.Get(viewedKey) + var viewedMap map[uint]bool + if viewedPosts == nil { + viewedMap = make(map[uint]bool) + } else { + viewedMap = viewedPosts.(map[uint]bool) + } + + if !viewedMap[postID] { + c.discussionSvc.IncrementViews(postID) + viewedMap[postID] = true + session.Set(viewedKey, viewedMap) + session.Save() + } + + detail, err := c.discussionSvc.GetPostDetail(postID, userID) + if err != nil { + ctx.String(http.StatusNotFound, "帖子不存在") + return + } + + comments, _ := c.discussionSvc.GetComments(postID) + + avatar := "" + if detail.Post.User.Avatar != "" { + avatar = "/" + detail.Post.User.Avatar + } + + ctx.HTML(http.StatusOK, "post.html", gin.H{ + "IsLoggedIn": isLoggedIn, + "UserID": userID, + "Username": username, + "UserRole": userRole, + "Post": detail.Post, + "PostAvatar": avatar, + "Comments": comments, + "LikeCount": detail.LikeCount, + "UserLiked": detail.UserLiked, + "AuthorPostCount": detail.AuthorPostCount, + }) +} + +func (c *HomeController) LikePost(ctx *gin.Context) { + userID, _, _, isLoggedIn := middleware.GetCurrentUser(ctx) + if !isLoggedIn { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + + postID := parseUint(ctx.Param("id")) + c.discussionSvc.AddLike(postID, userID) + ctx.Redirect(http.StatusFound, "/post/"+ctx.Param("id")) +} + +func (c *HomeController) AddComment(ctx *gin.Context) { + userID, _, _, isLoggedIn := middleware.GetCurrentUser(ctx) + if !isLoggedIn { + ctx.Redirect(http.StatusFound, "/login") + return + } + + postID := parseUint(ctx.Param("id")) + content := ctx.PostForm("content") + + if content != "" { + c.discussionSvc.AddComment(postID, userID, content) + } + + ctx.Redirect(http.StatusFound, "/post/"+ctx.Param("id")) +} + +func parseUint(s string) uint { + var result uint + for _, c := range s { + if c >= '0' && c <= '9' { + result = result*10 + uint(c-'0') + } + } + return result +} diff --git a/internal/controllers/message.go b/internal/controllers/message.go new file mode 100644 index 0000000..c6158f1 --- /dev/null +++ b/internal/controllers/message.go @@ -0,0 +1,89 @@ +package controllers + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "lv8girl/internal/middleware" + "lv8girl/internal/repositories" + "lv8girl/internal/services" +) + +type MessageController struct { + messageSvc *services.MessageService + userRepo *repositories.UserRepository +} + +func NewMessageController() *MessageController { + return &MessageController{ + messageSvc: services.NewMessageService(), + userRepo: repositories.NewUserRepository(), + } +} + +func (c *MessageController) ShowMessages(ctx *gin.Context) { + currentUserID, currentUsername, currentUserRole, _ := middleware.GetCurrentUser(ctx) + + conversations, _ := c.messageSvc.GetConversations(currentUserID) + + ctx.HTML(http.StatusOK, "messages.html", gin.H{ + "IsLoggedIn": true, + "UserID": currentUserID, + "Username": currentUsername, + "UserRole": currentUserRole, + "Conversations": conversations, + }) +} + +func (c *MessageController) ShowSendMessage(ctx *gin.Context) { + currentUserID, currentUsername, currentUserRole, _ := middleware.GetCurrentUser(ctx) + + toUserID := parseUint(ctx.Query("to")) + if toUserID == 0 { + ctx.Redirect(http.StatusFound, "/") + return + } + + receiver, err := c.userRepo.FindByID(toUserID) + if err != nil { + ctx.String(http.StatusNotFound, "用户不存在") + return + } + + ctx.HTML(http.StatusOK, "send_message.html", gin.H{ + "IsLoggedIn": true, + "UserID": currentUserID, + "Username": currentUsername, + "UserRole": currentUserRole, + "Receiver": receiver, + "Error": "", + "Success": "", + }) +} + +func (c *MessageController) SendMessage(ctx *gin.Context) { + currentUserID, _, _, _ := middleware.GetCurrentUser(ctx) + + toUserID := parseUint(ctx.Query("to")) + content := strings.TrimSpace(ctx.PostForm("content")) + + receiver, _ := c.userRepo.FindByID(toUserID) + + if content == "" { + ctx.HTML(http.StatusOK, "send_message.html", gin.H{ + "Receiver": receiver, + "Error": "消息内容不能为空", + "Success": "", + }) + return + } + + c.messageSvc.SendMessage(currentUserID, toUserID, content) + + ctx.HTML(http.StatusOK, "send_message.html", gin.H{ + "Receiver": receiver, + "Error": "", + "Success": "消息已发送!", + }) +} diff --git a/internal/controllers/user.go b/internal/controllers/user.go new file mode 100644 index 0000000..21b480c --- /dev/null +++ b/internal/controllers/user.go @@ -0,0 +1,98 @@ +package controllers + +import ( + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "lv8girl/internal/middleware" + "lv8girl/internal/services" +) + +type UserController struct { + userSvc *services.UserService +} + +func NewUserController() *UserController { + return &UserController{ + userSvc: services.NewUserService(), + } +} + +func (c *UserController) ShowProfile(ctx *gin.Context) { + currentUserID, currentUsername, currentUserRole, isLoggedIn := middleware.GetCurrentUser(ctx) + + userIDStr := ctx.Param("id") + var userID uint + if userIDStr == "" { + userID = currentUserID + } else { + userID = parseUint(userIDStr) + } + + if userID == 0 { + ctx.Redirect(http.StatusFound, "/") + return + } + + profile, err := c.userSvc.GetUserProfile(currentUserID, userID) + if err != nil { + ctx.String(http.StatusNotFound, "用户不存在") + return + } + + avatar := "" + if profile.User.Avatar != "" { + avatar = "/" + profile.User.Avatar + } + + ctx.HTML(http.StatusOK, "profile.html", gin.H{ + "IsLoggedIn": isLoggedIn, + "CurrentUserID": currentUserID, + "CurrentUsername": currentUsername, + "CurrentUserRole": currentUserRole, + "User": profile.User, + "UserAvatar": avatar, + "Posts": profile.Posts, + "PostCount": profile.PostCount, + "IsOwner": profile.IsOwner, + "UnreadCount": profile.UnreadCount, + "Message": "", + }) +} + +func (c *UserController) UploadAvatar(ctx *gin.Context) { + userID, _, _, _ := middleware.GetCurrentUser(ctx) + + file, err := ctx.FormFile("avatar") + if err != nil { + ctx.Redirect(http.StatusFound, "/profile?error=上传失败") + return + } + + if file.Size > 2*1024*1024 { + ctx.Redirect(http.StatusFound, "/profile?error=图片大小不能超过2MB") + return + } + + ext := strings.ToLower(filepath.Ext(file.Filename)) + filename := "avatar_" + strconv.FormatUint(uint64(userID), 10) + "_" + time.Now().Format("20060102150405") + ext + uploadDir := "uploads/avatars" + + if _, err := os.Stat(uploadDir); os.IsNotExist(err) { + os.MkdirAll(uploadDir, 0755) + } + + imagePath := filepath.Join(uploadDir, filename) + if err := ctx.SaveUploadedFile(file, imagePath); err != nil { + ctx.Redirect(http.StatusFound, "/profile?error=保存失败") + return + } + + c.userSvc.UpdateAvatar(userID, imagePath) + ctx.Redirect(http.StatusFound, "/profile?success=头像更新成功") +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..a95a417 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,49 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + session := sessions.Default(c) + userID := session.Get("user_id") + if userID == nil { + c.Redirect(http.StatusFound, "/login") + c.Abort() + return + } + c.Set("user_id", userID) + c.Set("username", session.Get("username")) + c.Set("user_role", session.Get("user_role")) + c.Next() + } +} + +func AdminRequired() gin.HandlerFunc { + return func(c *gin.Context) { + session := sessions.Default(c) + userRole := session.Get("user_role") + if userRole != "admin" { + c.Redirect(http.StatusFound, "/") + c.Abort() + return + } + c.Set("user_id", session.Get("user_id")) + c.Set("username", session.Get("username")) + c.Set("user_role", userRole) + c.Next() + } +} + +func GetCurrentUser(c *gin.Context) (uint, string, string, bool) { + session := sessions.Default(c) + userID := session.Get("user_id") + if userID == nil { + return 0, "", "", false + } + return userID.(uint), session.Get("username").(string), session.Get("user_role").(string), true +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..0129d31 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,84 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"size:50;uniqueIndex;not null" json:"username"` + Email string `gorm:"size:100;uniqueIndex;not null" json:"email"` + PasswordHash string `gorm:"size:255;not null" json:"-"` + Avatar string `gorm:"size:255" json:"avatar"` + Role string `gorm:"size:20;default:user" json:"role"` + Status string `gorm:"size:20;default:pending" json:"status"` + LastActive *time.Time `json:"last_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (User) TableName() string { + return "users" +} + +type Discussion struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + User User `gorm:"foreignKey:UserID" json:"user"` + Title string `gorm:"size:200;not null" json:"title"` + Content string `gorm:"type:text;not null" json:"content"` + ImagePath string `gorm:"size:255" json:"image_path"` + Status string `gorm:"size:20;default:pending" json:"status"` + Views int `gorm:"default:0" json:"views"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (Discussion) TableName() string { + return "discussions" +} + +type Comment struct { + ID uint `gorm:"primaryKey" json:"id"` + PostID uint `gorm:"not null;index" json:"post_id"` + UserID uint `gorm:"not null;index" json:"user_id"` + User User `gorm:"foreignKey:UserID" json:"user"` + Content string `gorm:"type:text;not null" json:"content"` + CreatedAt time.Time `json:"created_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (Comment) TableName() string { + return "comments" +} + +type Like struct { + ID uint `gorm:"primaryKey" json:"id"` + PostID uint `gorm:"not null;uniqueIndex:idx_post_user" json:"post_id"` + UserID uint `gorm:"not null;uniqueIndex:idx_post_user" json:"user_id"` + CreatedAt time.Time `json:"created_at"` +} + +func (Like) TableName() string { + return "likes" +} + +type PrivateMessage struct { + ID uint `gorm:"primaryKey" json:"id"` + FromUserID uint `gorm:"not null;index" json:"from_user_id"` + FromUser User `gorm:"foreignKey:FromUserID" json:"from_user"` + ToUserID uint `gorm:"not null;index" json:"to_user_id"` + ToUser User `gorm:"foreignKey:ToUserID" json:"to_user"` + Content string `gorm:"type:text;not null" json:"content"` + IsRead bool `gorm:"default:false" json:"is_read"` + CreatedAt time.Time `json:"created_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (PrivateMessage) TableName() string { + return "private_messages" +} diff --git a/internal/repositories/comment.go b/internal/repositories/comment.go new file mode 100644 index 0000000..e408db5 --- /dev/null +++ b/internal/repositories/comment.go @@ -0,0 +1,47 @@ +package repositories + +import "lv8girl/internal/models" + +type CommentRepository struct{} + +func NewCommentRepository() *CommentRepository { + return &CommentRepository{} +} + +func (r *CommentRepository) FindByPostID(postID uint) ([]models.Comment, error) { + var comments []models.Comment + err := DB.Preload("User").Where("post_id = ?", postID).Order("created_at DESC").Find(&comments).Error + return comments, err +} + +func (r *CommentRepository) FindAll() ([]models.Comment, error) { + var comments []models.Comment + err := DB.Preload("User").Order("created_at DESC").Find(&comments).Error + return comments, err +} + +func (r *CommentRepository) Create(comment *models.Comment) error { + return DB.Create(comment).Error +} + +func (r *CommentRepository) Delete(id uint) error { + return DB.Delete(&models.Comment{}, id).Error +} + +func (r *CommentRepository) Count() (int64, error) { + var count int64 + err := DB.Model(&models.Comment{}).Count(&count).Error + return count, err +} + +func (r *CommentRepository) CountByPostID(postID uint) (int64, error) { + var count int64 + err := DB.Model(&models.Comment{}).Where("post_id = ?", postID).Count(&count).Error + return count, err +} + +func (r *CommentRepository) CountByUserID(userID uint) (int64, error) { + var count int64 + err := DB.Model(&models.Comment{}).Where("user_id = ?", userID).Count(&count).Error + return count, err +} diff --git a/internal/repositories/db.go b/internal/repositories/db.go new file mode 100644 index 0000000..d6f9411 --- /dev/null +++ b/internal/repositories/db.go @@ -0,0 +1,36 @@ +package repositories + +import ( + "time" + + "lv8girl/internal/models" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +func Init(databasePath string) error { + var err error + DB, err = gorm.Open(sqlite.Open(databasePath), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + }) + if err != nil { + return err + } + + err = DB.AutoMigrate( + &models.User{}, + &models.Discussion{}, + &models.Comment{}, + &models.Like{}, + &models.PrivateMessage{}, + ) + if err != nil { + return err + } + + return nil +} diff --git a/internal/repositories/discussion.go b/internal/repositories/discussion.go new file mode 100644 index 0000000..40c6225 --- /dev/null +++ b/internal/repositories/discussion.go @@ -0,0 +1,146 @@ +package repositories + +import ( + "time" + + "gorm.io/gorm" + "lv8girl/internal/models" +) + +type DiscussionRepository struct{} + +func NewDiscussionRepository() *DiscussionRepository { + return &DiscussionRepository{} +} + +func (r *DiscussionRepository) FindByID(id uint) (*models.Discussion, error) { + var discussion models.Discussion + err := DB.Preload("User").First(&discussion, id).Error + return &discussion, err +} + +func (r *DiscussionRepository) FindByIDWithStats(id uint) (*models.Discussion, int64, int64, error) { + discussion, err := r.FindByID(id) + if err != nil { + return nil, 0, 0, err + } + + var likeCount, commentCount int64 + DB.Model(&models.Like{}).Where("post_id = ?", id).Count(&likeCount) + DB.Model(&models.Comment{}).Where("post_id = ?", id).Count(&commentCount) + + return discussion, likeCount, commentCount, nil +} + +func (r *DiscussionRepository) FindApproved(limit int) ([]models.Discussion, error) { + var discussions []models.Discussion + err := DB.Preload("User"). + Where("status = ?", "approved"). + Order("created_at DESC"). + Limit(limit). + Find(&discussions).Error + return discussions, err +} + +func (r *DiscussionRepository) FindByUserID(userID uint) ([]models.Discussion, error) { + var discussions []models.Discussion + err := DB.Where("user_id = ? AND status = ?", userID, "approved"). + Order("created_at DESC"). + Find(&discussions).Error + return discussions, err +} + +func (r *DiscussionRepository) FindPending() ([]models.Discussion, error) { + var discussions []models.Discussion + err := DB.Preload("User"). + Where("status = ?", "pending"). + Order("created_at DESC"). + Find(&discussions).Error + return discussions, err +} + +func (r *DiscussionRepository) FindAll() ([]models.Discussion, error) { + var discussions []models.Discussion + err := DB.Preload("User").Order("created_at DESC").Find(&discussions).Error + return discussions, err +} + +func (r *DiscussionRepository) Create(discussion *models.Discussion) error { + return DB.Create(discussion).Error +} + +func (r *DiscussionRepository) Update(discussion *models.Discussion) error { + return DB.Save(discussion).Error +} + +func (r *DiscussionRepository) UpdateStatus(id uint, status string) error { + return DB.Model(&models.Discussion{}).Where("id = ?", id).Update("status", status).Error +} + +func (r *DiscussionRepository) IncrementViews(id uint) error { + return DB.Model(&models.Discussion{}).Where("id = ?", id). + UpdateColumn("views", gorm.Expr("views + ?", 1)).Error +} + +func (r *DiscussionRepository) Delete(id uint) error { + return DB.Delete(&models.Discussion{}, id).Error +} + +func (r *DiscussionRepository) Count() (int64, error) { + var count int64 + err := DB.Model(&models.Discussion{}).Count(&count).Error + return count, err +} + +func (r *DiscussionRepository) CountByStatus(status string) (int64, error) { + var count int64 + err := DB.Model(&models.Discussion{}).Where("status = ?", status).Count(&count).Error + return count, err +} + +func (r *DiscussionRepository) CountByUserID(userID uint) (int64, error) { + var count int64 + err := DB.Model(&models.Discussion{}).Where("user_id = ? AND status = ?", userID, "approved").Count(&count).Error + return count, err +} + +type PostListItem struct { + ID uint + Title string + Content string + Username string + Avatar string + UserID uint + LikeCount int64 + CommentCount int64 + Views int + CreatedAt time.Time +} + +func (r *DiscussionRepository) GetPostList(limit int) ([]PostListItem, error) { + discussions, err := r.FindApproved(limit) + if err != nil { + return nil, err + } + + var posts []PostListItem + for _, d := range discussions { + var likeCount, commentCount int64 + DB.Model(&models.Like{}).Where("post_id = ?", d.ID).Count(&likeCount) + DB.Model(&models.Comment{}).Where("post_id = ?", d.ID).Count(&commentCount) + + posts = append(posts, PostListItem{ + ID: d.ID, + Title: d.Title, + Content: d.Content, + Username: d.User.Username, + Avatar: d.User.Avatar, + UserID: d.User.ID, + LikeCount: likeCount, + CommentCount: commentCount, + Views: d.Views, + CreatedAt: d.CreatedAt, + }) + } + return posts, nil +} diff --git a/internal/repositories/like.go b/internal/repositories/like.go new file mode 100644 index 0000000..b802294 --- /dev/null +++ b/internal/repositories/like.go @@ -0,0 +1,37 @@ +package repositories + +import "lv8girl/internal/models" + +type LikeRepository struct{} + +func NewLikeRepository() *LikeRepository { + return &LikeRepository{} +} + +func (r *LikeRepository) FindByPostAndUser(postID, userID uint) (*models.Like, error) { + var like models.Like + err := DB.Where("post_id = ? AND user_id = ?", postID, userID).First(&like).Error + return &like, err +} + +func (r *LikeRepository) Exists(postID, userID uint) bool { + var count int64 + DB.Model(&models.Like{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count) + return count > 0 +} + +func (r *LikeRepository) Create(like *models.Like) error { + return DB.Create(like).Error +} + +func (r *LikeRepository) Count() (int64, error) { + var count int64 + err := DB.Model(&models.Like{}).Count(&count).Error + return count, err +} + +func (r *LikeRepository) CountByPostID(postID uint) (int64, error) { + var count int64 + err := DB.Model(&models.Like{}).Where("post_id = ?", postID).Count(&count).Error + return count, err +} diff --git a/internal/repositories/message.go b/internal/repositories/message.go new file mode 100644 index 0000000..059e8d5 --- /dev/null +++ b/internal/repositories/message.go @@ -0,0 +1,69 @@ +package repositories + +import ( + "time" + + "lv8girl/internal/models" +) + +type MessageRepository struct{} + +func NewMessageRepository() *MessageRepository { + return &MessageRepository{} +} + +func (r *MessageRepository) FindByID(id uint) (*models.PrivateMessage, error) { + var msg models.PrivateMessage + err := DB.First(&msg, id).Error + return &msg, err +} + +func (r *MessageRepository) Create(message *models.PrivateMessage) error { + return DB.Create(message).Error +} + +func (r *MessageRepository) MarkAsRead(id uint) error { + return DB.Model(&models.PrivateMessage{}).Where("id = ?", id).Update("is_read", true).Error +} + +func (r *MessageRepository) CountUnread(userID uint) (int64, error) { + var count int64 + err := DB.Model(&models.PrivateMessage{}). + Where("to_user_id = ? AND is_read = ?", userID, false). + Count(&count).Error + return count, err +} + +func (r *MessageRepository) FindConversations(userID uint) ([]models.PrivateMessage, error) { + var messages []models.PrivateMessage + err := DB.Where("from_user_id = ? OR to_user_id = ?", userID, userID). + Order("created_at DESC"). + Find(&messages).Error + return messages, err +} + +func (r *MessageRepository) FindLastMessage(userID, otherUserID uint) (*models.PrivateMessage, error) { + var msg models.PrivateMessage + err := DB.Where( + "(from_user_id = ? AND to_user_id = ?) OR (from_user_id = ? AND to_user_id = ?)", + userID, otherUserID, otherUserID, userID, + ).Order("created_at DESC").First(&msg).Error + return &msg, err +} + +func (r *MessageRepository) CountUnreadFromUser(fromUserID, toUserID uint) (int64, error) { + var count int64 + err := DB.Model(&models.PrivateMessage{}). + Where("from_user_id = ? AND to_user_id = ? AND is_read = ?", fromUserID, toUserID, false). + Count(&count).Error + return count, err +} + +type ConversationSummary struct { + UserID uint + Username string + Avatar string + LastMsg string + Time time.Time + Unread int64 +} diff --git a/internal/repositories/user.go b/internal/repositories/user.go new file mode 100644 index 0000000..844bd5a --- /dev/null +++ b/internal/repositories/user.go @@ -0,0 +1,123 @@ +package repositories + +import ( + "time" + + "golang.org/x/crypto/bcrypt" + "lv8girl/internal/models" +) + +type UserRepository struct{} + +func NewUserRepository() *UserRepository { + return &UserRepository{} +} + +func (r *UserRepository) FindByID(id uint) (*models.User, error) { + var user models.User + err := DB.First(&user, id).Error + return &user, err +} + +func (r *UserRepository) FindByUsernameOrEmail(login string) (*models.User, error) { + var user models.User + err := DB.Where("username = ? OR email = ?", login, login).First(&user).Error + return &user, err +} + +func (r *UserRepository) FindByUsername(username string) (*models.User, error) { + var user models.User + err := DB.Where("username = ?", username).First(&user).Error + return &user, err +} + +func (r *UserRepository) FindByEmail(email string) (*models.User, error) { + var user models.User + err := DB.Where("email = ?", email).First(&user).Error + return &user, err +} + +func (r *UserRepository) ExistsByUsernameOrEmail(username, email string) bool { + var count int64 + DB.Model(&models.User{}).Where("username = ? OR email = ?", username, email).Count(&count) + return count > 0 +} + +func (r *UserRepository) Create(user *models.User) error { + return DB.Create(user).Error +} + +func (r *UserRepository) Update(user *models.User) error { + return DB.Save(user).Error +} + +func (r *UserRepository) UpdateField(id uint, field string, value interface{}) error { + return DB.Model(&models.User{}).Where("id = ?", id).Update(field, value).Error +} + +func (r *UserRepository) UpdateLastActive(id uint) error { + now := time.Now() + return r.UpdateField(id, "last_active", now) +} + +func (r *UserRepository) Delete(id uint) error { + return DB.Delete(&models.User{}, id).Error +} + +func (r *UserRepository) Count() (int64, error) { + var count int64 + err := DB.Model(&models.User{}).Count(&count).Error + return count, err +} + +func (r *UserRepository) CountOnline() (int64, error) { + var count int64 + fiveMinutesAgo := time.Now().Add(-5 * time.Minute) + err := DB.Model(&models.User{}).Where("last_active > ?", fiveMinutesAgo).Count(&count).Error + return count, err +} + +func (r *UserRepository) CountByRole(role string) (int64, error) { + var count int64 + err := DB.Model(&models.User{}).Where("role = ?", role).Count(&count).Error + return count, err +} + +func (r *UserRepository) FindPending() ([]models.User, error) { + var users []models.User + err := DB.Where("status = ?", "pending").Order("created_at ASC").Find(&users).Error + return users, err +} + +func (r *UserRepository) FindAll() ([]models.User, error) { + var users []models.User + err := DB.Order("id").Find(&users).Error + return users, err +} + +func (r *UserRepository) HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +func (r *UserRepository) CheckPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +func (r *UserRepository) CreateAdminIfNotExists() error { + var count int64 + DB.Model(&models.User{}).Where("role = ?", "admin").Count(&count) + if count == 0 { + passwordHash, _ := r.HashPassword("admin123") + admin := models.User{ + Username: "admin", + Email: "admin@lv8girl.local", + PasswordHash: passwordHash, + Role: "admin", + Status: "approved", + } + return DB.Create(&admin).Error + } + return nil +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go new file mode 100644 index 0000000..b621afc --- /dev/null +++ b/internal/routes/routes.go @@ -0,0 +1,72 @@ +package routes + +import ( + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "lv8girl/internal/config" + "lv8girl/internal/controllers" + "lv8girl/internal/middleware" +) + +func SetupRouter() *gin.Engine { + r := gin.Default() + + cfg := config.GetConfig() + store := cookie.NewStore([]byte(cfg.Session.Secret)) + r.Use(sessions.Sessions(cfg.Session.Name, store)) + + r.Static("/static", "./static") + r.Static("/uploads", "./uploads") + r.LoadHTMLGlob("templates/*") + + authCtrl := controllers.NewAuthController() + homeCtrl := controllers.NewHomeController() + discussionCtrl := controllers.NewDiscussionController() + userCtrl := controllers.NewUserController() + messageCtrl := controllers.NewMessageController() + adminCtrl := controllers.NewAdminController() + + r.GET("/", homeCtrl.Index) + + r.GET("/login", authCtrl.ShowLogin) + r.POST("/login", authCtrl.Login) + r.GET("/register", authCtrl.ShowRegister) + r.POST("/register", authCtrl.Register) + r.GET("/logout", authCtrl.Logout) + + r.GET("/post/:id", homeCtrl.ShowPost) + r.POST("/post/:id/like", middleware.AuthRequired(), homeCtrl.LikePost) + r.POST("/post/:id/comment", middleware.AuthRequired(), homeCtrl.AddComment) + + r.GET("/new-post", middleware.AuthRequired(), discussionCtrl.ShowNewPost) + r.POST("/new-post", middleware.AuthRequired(), discussionCtrl.CreatePost) + + r.GET("/profile", middleware.AuthRequired(), userCtrl.ShowProfile) + r.GET("/profile/:id", userCtrl.ShowProfile) + r.POST("/upload-avatar", middleware.AuthRequired(), userCtrl.UploadAvatar) + + r.GET("/messages", middleware.AuthRequired(), messageCtrl.ShowMessages) + r.GET("/send-message", middleware.AuthRequired(), messageCtrl.ShowSendMessage) + r.POST("/send-message", middleware.AuthRequired(), messageCtrl.SendMessage) + + admin := r.Group("/admin") + admin.Use(middleware.AdminRequired()) + { + admin.GET("", adminCtrl.Dashboard) + admin.GET("/", adminCtrl.Dashboard) + admin.GET("/pending_posts", adminCtrl.PendingPosts) + admin.GET("/pending_posts/:action/:id", adminCtrl.ApprovePost) + admin.GET("/pending_users", adminCtrl.PendingUsers) + admin.GET("/pending_users/:action/:id", adminCtrl.ApproveUser) + admin.GET("/posts", adminCtrl.Posts) + admin.GET("/posts/delete/:id", adminCtrl.DeletePost) + admin.GET("/users", adminCtrl.Users) + admin.POST("/users/role", adminCtrl.UpdateUserRole) + admin.GET("/users/delete/:id", adminCtrl.DeleteUser) + admin.GET("/comments", adminCtrl.Comments) + admin.GET("/comments/delete/:id", adminCtrl.DeleteComment) + } + + return r +} diff --git a/internal/services/admin.go b/internal/services/admin.go new file mode 100644 index 0000000..7356929 --- /dev/null +++ b/internal/services/admin.go @@ -0,0 +1,98 @@ +package services + +import ( + "lv8girl/internal/models" + "lv8girl/internal/repositories" +) + +type AdminService struct { + userRepo *repositories.UserRepository + discussionRepo *repositories.DiscussionRepository + commentRepo *repositories.CommentRepository + likeRepo *repositories.LikeRepository +} + +func NewAdminService() *AdminService { + return &AdminService{ + userRepo: repositories.NewUserRepository(), + discussionRepo: repositories.NewDiscussionRepository(), + commentRepo: repositories.NewCommentRepository(), + likeRepo: repositories.NewLikeRepository(), + } +} + +type Stats struct { + Posts int64 + Users int64 + Comments int64 + Likes int64 + Online int64 + Approved int64 + Rejected int64 + Pending int64 +} + +func (s *AdminService) GetStats() (*Stats, error) { + stats := &Stats{} + stats.Posts, _ = s.discussionRepo.Count() + stats.Users, _ = s.userRepo.Count() + stats.Comments, _ = s.commentRepo.Count() + stats.Likes, _ = s.likeRepo.Count() + stats.Online, _ = s.userRepo.CountOnline() + stats.Approved, _ = s.discussionRepo.CountByStatus("approved") + stats.Rejected, _ = s.discussionRepo.CountByStatus("rejected") + stats.Pending, _ = s.discussionRepo.CountByStatus("pending") + return stats, nil +} + +func (s *AdminService) GetPendingPosts() ([]models.Discussion, error) { + return s.discussionRepo.FindPending() +} + +func (s *AdminService) GetPendingUsers() ([]models.User, error) { + return s.userRepo.FindPending() +} + +func (s *AdminService) ApprovePost(id uint) error { + return s.discussionRepo.UpdateStatus(id, "approved") +} + +func (s *AdminService) RejectPost(id uint) error { + return s.discussionRepo.UpdateStatus(id, "rejected") +} + +func (s *AdminService) DeletePost(id uint) error { + return s.discussionRepo.Delete(id) +} + +func (s *AdminService) ApproveUser(id uint) error { + return s.userRepo.UpdateField(id, "status", "approved") +} + +func (s *AdminService) RejectUser(id uint) error { + return s.userRepo.UpdateField(id, "status", "rejected") +} + +func (s *AdminService) UpdateUserRole(id uint, role string) error { + return s.userRepo.UpdateField(id, "role", role) +} + +func (s *AdminService) DeleteUser(id uint) error { + return s.userRepo.Delete(id) +} + +func (s *AdminService) GetAllPosts() ([]models.Discussion, error) { + return s.discussionRepo.FindAll() +} + +func (s *AdminService) GetAllUsers() ([]models.User, error) { + return s.userRepo.FindAll() +} + +func (s *AdminService) GetAllComments() ([]models.Comment, error) { + return s.commentRepo.FindAll() +} + +func (s *AdminService) DeleteComment(id uint) error { + return s.commentRepo.Delete(id) +} diff --git a/internal/services/auth.go b/internal/services/auth.go new file mode 100644 index 0000000..90036a0 --- /dev/null +++ b/internal/services/auth.go @@ -0,0 +1,87 @@ +package services + +import ( + "lv8girl/internal/models" + "lv8girl/internal/repositories" +) + +type AuthService struct { + userRepo *repositories.UserRepository +} + +func NewAuthService() *AuthService { + return &AuthService{ + userRepo: repositories.NewUserRepository(), + } +} + +type LoginResult struct { + Success bool + User *models.User + Error string +} + +func (s *AuthService) Login(login, password string) LoginResult { + user, err := s.userRepo.FindByUsernameOrEmail(login) + if err != nil { + return LoginResult{Success: false, Error: "用户名/邮箱或密码错误"} + } + + if !s.userRepo.CheckPassword(password, user.PasswordHash) { + return LoginResult{Success: false, Error: "用户名/邮箱或密码错误"} + } + + switch user.Status { + case "pending": + return LoginResult{Success: false, Error: "您的账号正在等待管理员审核,请耐心等待。"} + case "rejected": + return LoginResult{Success: false, Error: "您的账号审核未通过,无法登录。如有疑问,请联系管理员。"} + case "approved": + if user.Role == "banned" { + return LoginResult{Success: false, Error: "您的账号已被封禁,请联系管理员"} + } + default: + return LoginResult{Success: false, Error: "账号状态异常,请联系管理员"} + } + + s.userRepo.UpdateLastActive(user.ID) + return LoginResult{Success: true, User: user} +} + +type RegisterResult struct { + Success bool + Error string +} + +func (s *AuthService) Register(username, email, password string) RegisterResult { + if len(username) < 3 || len(username) > 20 { + return RegisterResult{Success: false, Error: "用户名长度必须在3-20个字符之间"} + } + + if s.userRepo.ExistsByUsernameOrEmail(username, email) { + return RegisterResult{Success: false, Error: "用户名或邮箱已被注册"} + } + + passwordHash, err := s.userRepo.HashPassword(password) + if err != nil { + return RegisterResult{Success: false, Error: "注册失败,请稍后重试"} + } + + user := &models.User{ + Username: username, + Email: email, + PasswordHash: passwordHash, + Role: "user", + Status: "pending", + } + + if err := s.userRepo.Create(user); err != nil { + return RegisterResult{Success: false, Error: "注册失败,请稍后重试"} + } + + return RegisterResult{Success: true} +} + +func (s *AuthService) InitAdmin() error { + return s.userRepo.CreateAdminIfNotExists() +} diff --git a/internal/services/discussion.go b/internal/services/discussion.go new file mode 100644 index 0000000..2d1f098 --- /dev/null +++ b/internal/services/discussion.go @@ -0,0 +1,92 @@ +package services + +import ( + "lv8girl/internal/models" + "lv8girl/internal/repositories" +) + +type DiscussionService struct { + discussionRepo *repositories.DiscussionRepository + likeRepo *repositories.LikeRepository + commentRepo *repositories.CommentRepository +} + +func NewDiscussionService() *DiscussionService { + return &DiscussionService{ + discussionRepo: repositories.NewDiscussionRepository(), + likeRepo: repositories.NewLikeRepository(), + commentRepo: repositories.NewCommentRepository(), + } +} + +type PostDetailView struct { + Post *models.Discussion + LikeCount int64 + CommentCount int64 + UserLiked bool + AuthorPostCount int64 +} + +func (s *DiscussionService) GetPostDetail(postID, userID uint) (*PostDetailView, error) { + post, err := s.discussionRepo.FindByID(postID) + if err != nil { + return nil, err + } + + likeCount, _ := s.likeRepo.CountByPostID(postID) + commentCount, _ := s.commentRepo.CountByPostID(postID) + userLiked := s.likeRepo.Exists(postID, userID) + authorPostCount, _ := s.discussionRepo.CountByUserID(post.UserID) + + return &PostDetailView{ + Post: post, + LikeCount: likeCount, + CommentCount: commentCount, + UserLiked: userLiked, + AuthorPostCount: authorPostCount, + }, nil +} + +func (s *DiscussionService) IncrementViews(postID uint) error { + return s.discussionRepo.IncrementViews(postID) +} + +func (s *DiscussionService) AddLike(postID, userID uint) error { + if s.likeRepo.Exists(postID, userID) { + return nil + } + + like := &models.Like{ + PostID: postID, + UserID: userID, + } + return s.likeRepo.Create(like) +} + +func (s *DiscussionService) AddComment(postID, userID uint, content string) error { + comment := &models.Comment{ + PostID: postID, + UserID: userID, + Content: content, + } + return s.commentRepo.Create(comment) +} + +func (s *DiscussionService) GetComments(postID uint) ([]models.Comment, error) { + return s.commentRepo.FindByPostID(postID) +} + +func (s *DiscussionService) CreatePost(userID uint, title, content, imagePath string) error { + post := &models.Discussion{ + UserID: userID, + Title: title, + Content: content, + ImagePath: imagePath, + Status: "pending", + } + return s.discussionRepo.Create(post) +} + +func (s *DiscussionService) GetApprovedPosts(limit int) ([]repositories.PostListItem, error) { + return s.discussionRepo.GetPostList(limit) +} diff --git a/internal/services/message.go b/internal/services/message.go new file mode 100644 index 0000000..68281bd --- /dev/null +++ b/internal/services/message.go @@ -0,0 +1,97 @@ +package services + +import ( + "lv8girl/internal/models" + "lv8girl/internal/repositories" +) + +type MessageService struct { + messageRepo *repositories.MessageRepository + userRepo *repositories.UserRepository +} + +func NewMessageService() *MessageService { + return &MessageService{ + messageRepo: repositories.NewMessageRepository(), + userRepo: repositories.NewUserRepository(), + } +} + +func (s *MessageService) SendMessage(fromUserID, toUserID uint, content string) error { + message := &models.PrivateMessage{ + FromUserID: fromUserID, + ToUserID: toUserID, + Content: content, + IsRead: false, + } + return s.messageRepo.Create(message) +} + +func (s *MessageService) GetUnreadCount(userID uint) (int64, error) { + return s.messageRepo.CountUnread(userID) +} + +type ConversationView struct { + UserID uint + Username string + Avatar string + LastMsg string + Time string + Unread int64 +} + +func (s *MessageService) GetConversations(userID uint) ([]ConversationView, error) { + messages, err := s.messageRepo.FindConversations(userID) + if err != nil { + return nil, err + } + + userMap := make(map[uint]bool) + for _, msg := range messages { + if msg.FromUserID != userID { + userMap[msg.FromUserID] = true + } + if msg.ToUserID != userID { + userMap[msg.ToUserID] = true + } + } + + var conversations []ConversationView + for otherID := range userMap { + otherUser, err := s.userRepo.FindByID(otherID) + if err != nil { + continue + } + + lastMsg, _ := s.messageRepo.FindLastMessage(userID, otherID) + unread, _ := s.messageRepo.CountUnreadFromUser(otherID, userID) + + avatar := "" + if otherUser.Avatar != "" { + avatar = "/" + otherUser.Avatar + } + + conversations = append(conversations, ConversationView{ + UserID: otherUser.ID, + Username: otherUser.Username, + Avatar: avatar, + LastMsg: lastMsg.Content, + Time: lastMsg.CreatedAt.Format("2006-01-02 15:04"), + Unread: unread, + }) + } + + return conversations, nil +} + +func (s *MessageService) NotifyUserApproved(adminID, userID uint) error { + return s.SendMessage(adminID, userID, "恭喜!您的账号已通过管理员审核,现在可以正常登录使用了。") +} + +func (s *MessageService) NotifyUserRejected(adminID, userID uint) error { + return s.SendMessage(adminID, userID, "您的账号审核未通过。如有疑问,请联系管理员。") +} + +func (s *MessageService) NotifyUserBanned(adminID, userID uint) error { + return s.SendMessage(adminID, userID, "您的账号已被管理员封禁。如有疑问,请联系管理员。") +} diff --git a/internal/services/user.go b/internal/services/user.go new file mode 100644 index 0000000..4623591 --- /dev/null +++ b/internal/services/user.go @@ -0,0 +1,94 @@ +package services + +import ( + "lv8girl/internal/models" + "lv8girl/internal/repositories" +) + +type UserService struct { + userRepo *repositories.UserRepository + discussionRepo *repositories.DiscussionRepository + commentRepo *repositories.CommentRepository + messageRepo *repositories.MessageRepository +} + +func NewUserService() *UserService { + return &UserService{ + userRepo: repositories.NewUserRepository(), + discussionRepo: repositories.NewDiscussionRepository(), + commentRepo: repositories.NewCommentRepository(), + messageRepo: repositories.NewMessageRepository(), + } +} + +type UserProfileView struct { + User *models.User + Posts []models.Discussion + PostCount int64 + UnreadCount int64 + IsOwner bool +} + +func (s *UserService) GetUserProfile(viewerID, targetUserID uint) (*UserProfileView, error) { + user, err := s.userRepo.FindByID(targetUserID) + if err != nil { + return nil, err + } + + posts, _ := s.discussionRepo.FindByUserID(targetUserID) + postCount, _ := s.discussionRepo.CountByUserID(targetUserID) + + var unreadCount int64 + if viewerID != 0 { + unreadCount, _ = s.messageRepo.CountUnread(viewerID) + } + + return &UserProfileView{ + User: user, + Posts: posts, + PostCount: postCount, + UnreadCount: unreadCount, + IsOwner: viewerID == targetUserID, + }, nil +} + +func (s *UserService) UpdateAvatar(userID uint, avatarPath string) error { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return err + } + return s.userRepo.UpdateField(user.ID, "avatar", avatarPath) +} + +func (s *UserService) GetUserByID(id uint) (*models.User, error) { + return s.userRepo.FindByID(id) +} + +func (s *UserService) UpdateUserStatus(id uint, status string) error { + return s.userRepo.UpdateField(id, "status", status) +} + +func (s *UserService) UpdateUserRole(id uint, role string) error { + return s.userRepo.UpdateField(id, "role", role) +} + +func (s *UserService) DeleteUser(id uint) error { + return s.userRepo.Delete(id) +} + +func (s *UserService) GetPendingUsers() ([]models.User, error) { + return s.userRepo.FindPending() +} + +func (s *UserService) GetAllUsers() ([]models.User, error) { + return s.userRepo.FindAll() +} + +func (s *UserService) GetUserStats() (int64, int64, error) { + total, err := s.userRepo.Count() + if err != nil { + return 0, 0, err + } + online, err := s.userRepo.CountOnline() + return total, online, err +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..26ae787 --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,9 @@ +package utils + +func Substring(s string, length int) string { + runes := []rune(s) + if len(runes) <= length { + return s + } + return string(runes[:length]) +} diff --git a/templates/admin_comments.html b/templates/admin_comments.html new file mode 100644 index 0000000..427a16c --- /dev/null +++ b/templates/admin_comments.html @@ -0,0 +1,83 @@ + + + + + + 评论管理 · lv8girl + + + +
+ +
+
+

评论管理

+ +
+ {{if .Message}}
{{.Message}}
{{end}} +
+ + + + {{range .Comments}} + + + + + + + + + {{end}} + +
ID帖子评论者内容时间操作
{{.ID}}{{slice .Post.Title 0 30}}...{{.User.Username}}{{slice .Content 0 50}}{{if gt (len .Content) 50}}...{{end}}{{.CreatedAt.Format "2006-01-02 15:04"}} + 删除 +
+
+
+
+ + diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html new file mode 100644 index 0000000..950122d --- /dev/null +++ b/templates/admin_dashboard.html @@ -0,0 +1,114 @@ + + + + + + 管理面板 · lv8girl + + + +
+ + +
+
+

仪表盘

+ +
+ {{if .Message}}
{{.Message}}
{{end}} + +
+
+
{{.Stats.Posts}}
+
帖子总数
+
+
+
{{.Stats.Users}}
+
注册用户
+
+
+
{{.Stats.Comments}}
+
评论总数
+
+
+
{{.Stats.Likes}}
+
点赞总数
+
+
+
{{.Stats.Online}}
+
实时在线
+
+
+
{{.Stats.Pending}}
+
待审核帖子
+
+
+
+
+ + diff --git a/templates/admin_pending_posts.html b/templates/admin_pending_posts.html new file mode 100644 index 0000000..d195e2a --- /dev/null +++ b/templates/admin_pending_posts.html @@ -0,0 +1,85 @@ + + + + + + 待审核帖子 · lv8girl + + + +
+ +
+
+

待审核帖子

+ +
+ {{if .Message}}
{{.Message}}
{{end}} +
+ + + + {{range .Posts}} + + + + + + + + {{end}} + +
ID标题作者发布时间操作
{{.ID}}{{.Title}}{{.User.Username}}{{.CreatedAt.Format "2006-01-02 15:04"}} + 通过 + 拒绝 +
+
+
+
+ + diff --git a/templates/admin_pending_users.html b/templates/admin_pending_users.html new file mode 100644 index 0000000..b659e00 --- /dev/null +++ b/templates/admin_pending_users.html @@ -0,0 +1,85 @@ + + + + + + 待审核用户 · lv8girl + + + +
+ +
+
+

待审核用户

+ +
+ {{if .Message}}
{{.Message}}
{{end}} +
+ + + + {{range .Users}} + + + + + + + + {{end}} + +
ID用户名邮箱注册时间操作
{{.ID}}{{.Username}}{{.Email}}{{.CreatedAt.Format "2006-01-02 15:04"}} + 通过 + 拒绝 +
+
+
+
+ + diff --git a/templates/admin_posts.html b/templates/admin_posts.html new file mode 100644 index 0000000..7c435df --- /dev/null +++ b/templates/admin_posts.html @@ -0,0 +1,90 @@ + + + + + + 帖子管理 · lv8girl + + + +
+ +
+
+

帖子管理

+ +
+ {{if .Message}}
{{.Message}}
{{end}} +
+ + + + {{range .Posts}} + + + + + + + + + + + + {{end}} + +
ID标题作者状态发布时间阅读点赞评论操作
{{.ID}}{{.Title}}{{.User.Username}}{{if eq .Status "approved"}}已通过{{else if eq .Status "pending"}}待审核{{else}}已拒绝{{end}}{{.CreatedAt.Format "2006-01-02 15:04"}}{{.Views}}{{.LikeCount}}{{.CommentCount}} + 删除 +
+
+
+
+ + diff --git a/templates/admin_users.html b/templates/admin_users.html new file mode 100644 index 0000000..ad5e825 --- /dev/null +++ b/templates/admin_users.html @@ -0,0 +1,109 @@ + + + + + + 用户管理 · lv8girl + + + +
+ +
+
+

用户管理

+ +
+ {{if .Message}}
{{.Message}}
{{end}} +
+ + + + {{range .Users}} + + + + + + + + + + + + + {{end}} + +
ID用户名邮箱角色状态注册时间最后活动帖子评论操作
{{.ID}}{{.Username}}{{.Email}} + {{if eq .ID $.CurrentUserID}} + {{if eq .Role "admin"}}管理员{{else if eq .Role "banned"}}封禁{{else}}用户{{end}} + {{else}} +
+ + +
+ {{end}} +
{{if eq .Status "approved"}}已通过{{else if eq .Status "pending"}}待审核{{else}}已拒绝{{end}}{{.CreatedAt.Format "2006-01-02 15:04"}}{{if .LastActive}}{{.LastActive.Format "2006-01-02 15:04"}}{{else}}从未{{end}}{{.PostCount}}{{.CommentCount}} + {{if ne .ID $.CurrentUserID}} + 删除 + {{else}} + 当前用户 + {{end}} +
+
+
+
+ + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..5e8aa4f --- /dev/null +++ b/templates/index.html @@ -0,0 +1,191 @@ + + + + + + lv8girl · 绿坝娘二次元论坛 + + + +
+
+
+ +
+ {{if .IsLoggedIn}} +
+ {{.Username}} ▼ + +
+ {{else}} + 登录 + 注册 + {{end}} + +
+
+
+

欢迎来到 lv8girl 论坛,一个 ACG 爱好者的聚集地。

+
+
🍀 绿坝娘 · 守护你的二次元
+
+ +
+
+
+ {{if not .Posts}} +
暂无帖子,快去发表第一篇吧!
+ {{else}} + {{range .Posts}} +
+ + {{if .Avatar}}avatar{{else}}{{slice .Username 0 1}}{{end}} + +
+
+ + {{.CreatedAt.Format "2006-01-02 15:04"}} +
+
{{.Title}}
+
{{slice .Content 0 100}}{{if gt (len .Content) 100}}...{{end}}
+ +
+
+ {{end}} + {{end}} +
+
+ +
+
+
📊 论坛统计
+
+
{{.PostCount}}
帖子总数
+
{{.UserCount}}
注册用户
+
{{.OnlineCount}}
实时在线
+
+
+
+
+ + +
+ + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..1d8dbec --- /dev/null +++ b/templates/login.html @@ -0,0 +1,84 @@ + + + + + + lv8girl · 登录 + + + + + + + diff --git a/templates/messages.html b/templates/messages.html new file mode 100644 index 0000000..383862d --- /dev/null +++ b/templates/messages.html @@ -0,0 +1,109 @@ + + + + + + 私信列表 - lv8girl + + + +
+
+ + +
+ +

私信列表

+ +
+ {{if not .Conversations}} +
暂无对话,快去给感兴趣的用户发送私信吧~
+ {{else}} + {{range .Conversations}} +
+
{{if .Avatar}}{{else}}{{slice .Username 0 1}}{{end}}
+
+
{{.Username}}
+
{{slice .LastMsg 0 50}}{{if gt (len .LastMsg) 50}}...{{end}}
+
+
{{.Time.Format "01-02 15:04"}}
+ {{if gt .Unread 0}}
{{.Unread}}
{{end}} +
+ {{end}} + {{end}} +
+ + +
+ + + diff --git a/templates/post.html b/templates/post.html new file mode 100644 index 0000000..d709339 --- /dev/null +++ b/templates/post.html @@ -0,0 +1,228 @@ + + + + + + {{.Post.Title}} - lv8girl + + + +
+
+ + +
+ +
+
+
+
+ + +
+

{{.Post.Title}}

+ {{if .Post.ImagePath}}
post image
{{end}} +
{{.Post.Content}}
+
+ {{if .IsLoggedIn}} +
+ +
+ {{else}} + + {{end}} + + 👁️ {{.Post.Views}} 次阅读 +
+
+ +
+

评论 ({{len .Comments}})

+ {{if .IsLoggedIn}} +
+ + +
+ {{else}} + + {{end}} + {{if not .Comments}} +
暂无评论,快来抢沙发吧~
+ {{else}} + {{range .Comments}} +
+ + {{if index $.CommentAvatars .UserID}}avatar{{else}}{{slice .User.Username 0 1}}{{end}} + +
+
+ {{.User.Username}} + {{.CreatedAt.Format "2006-01-02 15:04"}} +
+
{{.Content}}
+
+
+ {{end}} + {{end}} +
+
+ +
+
+
+ {{if .PostAvatar}}avatar{{else}}{{slice .Post.User.Username 0 1}}{{end}} +
+
{{.Post.User.Username}}
+
身份:{{if eq .Post.User.Role "admin"}}管理员{{else}}用户{{end}}
+
UID: {{.Post.UserID}}
+
+
+
{{.AuthorPostCount}}
+
帖子
+
+
+ 查看个人主页 +
+
+
+ + +
+ + + diff --git a/templates/post_discussion.html b/templates/post_discussion.html new file mode 100644 index 0000000..b285f01 --- /dev/null +++ b/templates/post_discussion.html @@ -0,0 +1,100 @@ + + + + + + 发表新帖 - lv8girl + + + +
+
+ + +
+
+

发表新帖

+ {{if .Error}}
{{.Error}}
{{end}} + {{if .Success}} +
{{.Success}}
+ {{else}} +
+
+ + +
+
+ + +
+
+ + +
支持 JPEG、PNG、GIF、WEBP 格式
+
+ +
+ {{end}} + +
+
+ + + diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..2bb5b67 --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,195 @@ + + + + + + {{.User.Username}}的个人主页 - lv8girl + + + +
+
+ + +
+ +
+
+ {{if .UserAvatar}}avatar{{else}}{{slice .User.Username 0 1}}{{end}} +
+
+

{{.User.Username}}

+

邮箱:{{.User.Email}}

+

注册时间:{{.User.CreatedAt.Format "2006-01-02"}}

+
+
+
{{.PostCount}}
+
帖子
+
+
+ {{if .IsOwner}} + + + {{else}} + 📩 发送私信 + {{end}} +
+
+ +
+

{{if .IsOwner}}我的帖子{{else}}{{.User.Username}}的帖子{{end}}

+
+ + {{if not .Posts}} +
+

暂无帖子

+ {{if .IsOwner}}

去发表第一篇

{{end}} +
+ {{else}} +
+ {{range .Posts}} +
+
+ {{if $.UserAvatar}}avatar{{else}}{{slice $.User.Username 0 1}}{{end}} +
+
+
+ + {{.CreatedAt.Format "2006-01-02 15:04"}} +
+
{{.Title}}
+
{{slice .Content 0 100}}{{if gt (len .Content) 100}}...{{end}}
+
+
+ {{end}} +
+ {{end}} + + +
+ + + diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..4b03900 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,105 @@ + + + + + + lv8girl · 注册 + + + +
+
+ + +
+
+

注册

+ {{if .Error}}
{{.Error}}
{{end}} + {{if .Success}} +
{{.Success}}
+ {{else}} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + {{end}} +
+
+ + + diff --git a/templates/send_message.html b/templates/send_message.html new file mode 100644 index 0000000..ec639d8 --- /dev/null +++ b/templates/send_message.html @@ -0,0 +1,84 @@ + + + + + + 发送私信 - lv8girl + + + +
+
+ + +
+
+

发送私信给 {{.Receiver.Username}}

+ {{if .Error}}
{{.Error}}
{{end}} + {{if .Success}}
{{.Success}}
{{end}} +
+
+ + +
+ +
+ +
+
+ + +