Compare commits
113 Commits
37c83d4c12
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d7ba7799fe | |||
| 8bf6ac9e51 | |||
| 2ea9788ec7 | |||
| be729c29a1 | |||
| 584d7fd146 | |||
| 6c9a4414cb | |||
| 864a488ee0 | |||
| fb9724faed | |||
| 58aabb9bf0 | |||
| ae15025b1d | |||
| be8ce04334 | |||
| 4ac4f64f33 | |||
| 2d05da6709 | |||
| 1af9fba291 | |||
| 4442da31c3 | |||
| 0af6f7c971 | |||
| b74ce2da93 | |||
| fcff28ab60 | |||
| ea2f196ff7 | |||
| b0a1ff3c3f | |||
| a806532d25 | |||
| ca21ae7216 | |||
| 7d944cfa35 | |||
| dd6efd0c50 | |||
| fa3b20d1bc | |||
| 543325105c | |||
| 983f539268 | |||
| ee313675ca | |||
| 72c08f3689 | |||
| c73b131b83 | |||
| 2e6e7fc057 | |||
| a8a52f5fd0 | |||
| 746deca754 | |||
| 0614883a4f | |||
| 95e1728c6e | |||
| ab26704049 | |||
| 99d16190a8 | |||
| 5911cee01c | |||
| 7fdea90ded | |||
| 6aacbbab1c | |||
| abe1388f8c | |||
| a83e0840d7 | |||
| 77d8562916 | |||
| 66a802beb7 | |||
| 593b52096f | |||
| 3e0fed80ce | |||
| 3df7b2e672 | |||
| 1cb7a832f1 | |||
| 53f18ca6ee | |||
| 9477155bcf | |||
| ff9886f08b | |||
| 7cdb4d6698 | |||
| 9b315c11cd | |||
| 166e01c7c3 | |||
| c1ad8a0121 | |||
| b9d90fe7ec | |||
| b869602185 | |||
| 256d201bd2 | |||
| 727c92b695 | |||
| 70bd033e6c | |||
| c6da3932f3 | |||
| d9eff2df08 | |||
| 797e9c15f4 | |||
| db2f9206be | |||
| a0df66587b | |||
| bab8e47d63 | |||
| ae98d33edf | |||
| 4d212a8f8a | |||
| 680df7371a | |||
| 2b47380ac5 | |||
| 4815060839 | |||
| 26e557de8a | |||
| 683c9e02d8 | |||
| 43c35ddaf2 | |||
| 36c571b6bb | |||
| b7acc91f8c | |||
| b44d7ca5c3 | |||
| 5248e91a3d | |||
| 610277c8c7 | |||
| 52f4e95414 | |||
| 8cadd19253 | |||
| 8902af420e | |||
| 6542d1e055 | |||
| e1d7c318ab | |||
| 394989b281 | |||
| 209336b429 | |||
| bbcd992614 | |||
| a84fa5da61 | |||
| fdb636f98f | |||
| 91115447e8 | |||
| 0cfbb6911d | |||
| 968e8af8a3 | |||
| b658eeb8e9 | |||
| 99d0891413 | |||
| d252a746f0 | |||
| dae2a38e79 | |||
| b75e1867f6 | |||
| 6f74643227 | |||
| 9fe87a7003 | |||
| a3f787ddaf | |||
| 3f4a079c78 | |||
| 7e50c892ad | |||
| e98bfe193b | |||
| 59af6b2635 | |||
| f0bf450725 | |||
| 891990bb35 | |||
| 2da24e12ff | |||
| a9b6dddf70 | |||
| a57777e7cd | |||
| f036e0bc43 | |||
| 3e26431602 | |||
| 9d6aebbc0d | |||
| dae248590b |
@ -1,3 +1,10 @@
|
|||||||
|
> [!IMPORTANT]
|
||||||
|
> **本项目已迁移至 [MetaZone Community Engine (MCE)](https://git.metazone.cc/MetaZone/mce)**
|
||||||
|
>
|
||||||
|
> MetaLab 已更名为 MCE 并继续开发。此仓库仅为历史存档,不再接受 Pull Request、Issue 或任何代码更新。
|
||||||
|
>
|
||||||
|
> 请前往 👉 **[mce](https://git.metazone.cc/MetaZone/mce)** 获取最新版本。
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go" alt="Go 1.26">
|
<img src="https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go" alt="Go 1.26">
|
||||||
<img src="https://img.shields.io/badge/Gin-1.12-0096D6?style=flat-square&logo=go" alt="Gin 1.12">
|
<img src="https://img.shields.io/badge/Gin-1.12-0096D6?style=flat-square&logo=go" alt="Gin 1.12">
|
||||||
|
|||||||
@ -27,10 +27,15 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("连接数据库失败: %v", err)
|
log.Fatalf("连接数据库失败: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}); err != nil {
|
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.PostReadLog{}, &model.PostGuestReadLog{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}, &model.Folder{}, &model.FolderItem{}); err != nil {
|
||||||
log.Fatalf("数据库迁移失败: %v", err)
|
log.Fatalf("数据库迁移失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化公户行(id=1, balance=0,如已存在则忽略)
|
||||||
|
if err := db.FirstOrCreate(&model.CommunityFund{ID: 1, Balance: 0}).Error; err != nil {
|
||||||
|
log.Fatalf("初始化公户失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 邮箱唯一索引迁移:从全表唯一改为部分唯一(仅 deleted_at IS NULL 的行)
|
// 邮箱唯一索引迁移:从全表唯一改为部分唯一(仅 deleted_at IS NULL 的行)
|
||||||
// 支持软删除用户邮箱复用
|
// 支持软删除用户邮箱复用
|
||||||
migrations := []string{
|
migrations := []string{
|
||||||
@ -70,14 +75,16 @@ func main() {
|
|||||||
r.Static("/uploads/avatars", "./storage/uploads/avatars")
|
r.Static("/uploads/avatars", "./storage/uploads/avatars")
|
||||||
r.Static("/uploads/posts", "./storage/uploads/posts")
|
r.Static("/uploads/posts", "./storage/uploads/posts")
|
||||||
|
|
||||||
// Redis 客户端(可选)
|
// Redis 客户端(可选,不可达时 FallbackStore 自动降级,恢复后自动切回)
|
||||||
var redisClient *redis.Client
|
var redisClient *redis.Client
|
||||||
if cfg.Redis.Enabled {
|
if cfg.Redis.Enabled {
|
||||||
redisClient, err = common.NewRedisClient(cfg.Redis)
|
redisClient, err = common.NewRedisClient(cfg.Redis)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Redis 连接失败: %v", err)
|
log.Printf("Redis 不可达,启动后首次请求将自动降级,后续恢复自动切回: %v", err)
|
||||||
}
|
// 不置 nil:go-redis 自带重连,FallbackStore 负责健康检测与恢复
|
||||||
|
} else {
|
||||||
log.Printf("Redis 已连接 %s:%s (DB=%d)", cfg.Redis.Host, cfg.Redis.Port, cfg.Redis.DB)
|
log.Printf("Redis 已连接 %s:%s (DB=%d)", cfg.Redis.Host, cfg.Redis.Port, cfg.Redis.DB)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Println("Redis 未启用,使用内存存储")
|
log.Println("Redis 未启用,使用内存存储")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,7 @@ session:
|
|||||||
|
|
||||||
# Redis 配置(可选,enabled: false 时使用内存存储)
|
# Redis 配置(可选,enabled: false 时使用内存存储)
|
||||||
redis:
|
redis:
|
||||||
enabled: false # 是否启用 Redis 存储会话
|
enabled: true # 是否启用 Redis 存储会话
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: 6379
|
port: 6379
|
||||||
password: "" # 敏感值由 .env 注入
|
password: "" # 敏感值由 .env 注入
|
||||||
|
|||||||
27
docs/migrations/001_uid_to_id.sql
Normal file
27
docs/migrations/001_uid_to_id.sql
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
-- Migration: 重命名 BaseModel 使用表的主键列 uid → id
|
||||||
|
-- 涉及表: users, audit_submissions, notifications, user_checkins, user_tasks
|
||||||
|
-- 执行前请备份数据库
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- users 表主键及序列重命名
|
||||||
|
ALTER TABLE users RENAME COLUMN uid TO id;
|
||||||
|
ALTER SEQUENCE IF EXISTS users_uid_seq RENAME TO users_id_seq;
|
||||||
|
|
||||||
|
-- audit_submissions 表主键及序列重命名
|
||||||
|
ALTER TABLE audit_submissions RENAME COLUMN uid TO id;
|
||||||
|
ALTER SEQUENCE IF EXISTS audit_submissions_uid_seq RENAME TO audit_submissions_id_seq;
|
||||||
|
|
||||||
|
-- notifications 表主键及序列重命名
|
||||||
|
ALTER TABLE notifications RENAME COLUMN uid TO id;
|
||||||
|
ALTER SEQUENCE IF EXISTS notifications_uid_seq RENAME TO notifications_id_seq;
|
||||||
|
|
||||||
|
-- user_checkins 表主键及序列重命名
|
||||||
|
ALTER TABLE user_checkins RENAME COLUMN uid TO id;
|
||||||
|
ALTER SEQUENCE IF EXISTS user_checkins_uid_seq RENAME TO user_checkins_id_seq;
|
||||||
|
|
||||||
|
-- user_tasks 表主键及序列重命名
|
||||||
|
ALTER TABLE user_tasks RENAME COLUMN uid TO id;
|
||||||
|
ALTER SEQUENCE IF EXISTS user_tasks_uid_seq RENAME TO user_tasks_id_seq;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
2
go.mod
2
go.mod
@ -9,6 +9,7 @@ require (
|
|||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
golang.org/x/crypto v0.52.0
|
golang.org/x/crypto v0.52.0
|
||||||
golang.org/x/image v0.41.0
|
golang.org/x/image v0.41.0
|
||||||
|
golang.org/x/sync v0.20.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
@ -56,7 +57,6 @@ require (
|
|||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/net v0.54.0 // indirect
|
golang.org/x/net v0.54.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
|||||||
55
internal/cache/home_cache.go
vendored
Normal file
55
internal/cache/home_cache.go
vendored
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HomePageCache 首页内存缓存,30 秒 TTL,创建/更新/删除/审核通过时失效
|
||||||
|
type HomePageCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
posts []model.Post
|
||||||
|
total int64
|
||||||
|
expires time.Time
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHomePageCache 创建首页缓存,ttl 为缓存有效期
|
||||||
|
func NewHomePageCache(ttl time.Duration) *HomePageCache {
|
||||||
|
return &HomePageCache{ttl: ttl}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 返回缓存数据,ok=false 表示缓存未命中或已过期
|
||||||
|
func (c *HomePageCache) Get() (posts []model.Post, total int64, ok bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
if time.Now().After(c.expires) {
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
// 返回浅拷贝以防止调用方修改缓存内部数据
|
||||||
|
copied := make([]model.Post, len(c.posts))
|
||||||
|
copy(copied, c.posts)
|
||||||
|
return copied, c.total, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set 写入缓存数据
|
||||||
|
func (c *HomePageCache) Set(posts []model.Post, total int64) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
// 深拷贝一份避免外部修改影响缓存
|
||||||
|
c.posts = make([]model.Post, len(posts))
|
||||||
|
copy(c.posts, posts)
|
||||||
|
c.total = total
|
||||||
|
c.expires = time.Now().Add(c.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate 主动失效缓存
|
||||||
|
func (c *HomePageCache) Invalidate() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.posts = nil
|
||||||
|
c.total = 0
|
||||||
|
c.expires = time.Time{}
|
||||||
|
}
|
||||||
@ -15,8 +15,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// SetSessionCookie 写入会话 ID Cookie
|
// SetSessionCookie 写入会话 ID Cookie
|
||||||
// rememberMe=true → Cookie 持久化(30天),关浏览器后仍保持登录
|
// rememberMe=true → Cookie maxAge=30天,关浏览器后仍保持登录
|
||||||
// rememberMe=false → Cookie session 模式(maxAge=0),关浏览器即清除
|
// rememberMe=false → Cookie maxAge=空闲超时(默认2h),与Redis TTL一致,关浏览器后自动清除
|
||||||
func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.Config) {
|
func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.Config) {
|
||||||
secure := cfg.Server.Mode != "debug"
|
secure := cfg.Server.Mode != "debug"
|
||||||
c.SetSameSite(http.SameSiteLaxMode)
|
c.SetSameSite(http.SameSiteLaxMode)
|
||||||
@ -25,7 +25,7 @@ func SetSessionCookie(c *gin.Context, sid string, rememberMe bool, cfg *config.C
|
|||||||
if rememberMe {
|
if rememberMe {
|
||||||
maxAge = cfg.Session.RememberTimeout * 60 // 分钟 → 秒
|
maxAge = cfg.Session.RememberTimeout * 60 // 分钟 → 秒
|
||||||
} else {
|
} else {
|
||||||
maxAge = 0 // session cookie
|
maxAge = cfg.Session.IdleTimeout * 60 // 分钟 → 秒,与 Redis TTL 一致
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[SetSessionCookie] sid=%s rememberMe=%v maxAge=%ds secure=%v", sid[:16]+"...", rememberMe, maxAge, secure)
|
log.Printf("[SetSessionCookie] sid=%s rememberMe=%v maxAge=%ds secure=%v", sid[:16]+"...", rememberMe, maxAge, secure)
|
||||||
|
|||||||
@ -14,7 +14,7 @@ var (
|
|||||||
ErrUserDeleted = errors.New("该账号正在注销中,登录即撤销注销")
|
ErrUserDeleted = errors.New("该账号正在注销中,登录即撤销注销")
|
||||||
// 统一返回,避免泄露用户存在性、软删除状态等内部信息
|
// 统一返回,避免泄露用户存在性、软删除状态等内部信息
|
||||||
ErrUserLocked = errors.New("邮箱或密码错误")
|
ErrUserLocked = errors.New("邮箱或密码错误")
|
||||||
ErrWeakPassword = errors.New("密码需至少 8 位,且包含字母和数字")
|
ErrWeakPassword = errors.New("密码需至少 8 位,且包含大写字母、小写字母和数字")
|
||||||
ErrTokenExpired = errors.New("登录已过期,请重新登录")
|
ErrTokenExpired = errors.New("登录已过期,请重新登录")
|
||||||
ErrTokenInvalid = errors.New("无效的认证凭据")
|
ErrTokenInvalid = errors.New("无效的认证凭据")
|
||||||
ErrTokenRevoked = errors.New("登录凭证已失效,请重新登录")
|
ErrTokenRevoked = errors.New("登录凭证已失效,请重新登录")
|
||||||
@ -45,4 +45,16 @@ var (
|
|||||||
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
|
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
|
||||||
ErrPostCannotLock = errors.New("待审核帖子不允许锁定")
|
ErrPostCannotLock = errors.New("待审核帖子不允许锁定")
|
||||||
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
|
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
|
||||||
|
|
||||||
|
// 域能相关
|
||||||
|
ErrInvalidEnergyAmount = errors.New("无效的赋能数量")
|
||||||
|
ErrCannotEnergizeSelf = errors.New("不能给自己的文章赋能")
|
||||||
|
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
|
||||||
|
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
|
||||||
|
ErrCreatePostNoExp = errors.New("经验不足,Lv1 解锁投稿")
|
||||||
|
ErrInsufficientFund = errors.New("公户余额不足,无法执行操作")
|
||||||
|
|
||||||
|
// 评论相关
|
||||||
|
ErrCommentNotFound = errors.New("评论不存在")
|
||||||
|
ErrCommentEmpty = errors.New("评论内容不能为空")
|
||||||
)
|
)
|
||||||
|
|||||||
@ -82,10 +82,23 @@ func BuildPageData(c *gin.Context, extra gin.H) gin.H {
|
|||||||
for k, v := range extra {
|
for k, v := range extra {
|
||||||
data[k] = v
|
data[k] = v
|
||||||
}
|
}
|
||||||
|
// 注入 CSP nonce(由 SecurityHeaders 中间件生成)
|
||||||
|
if nonce, exists := c.Get("csp_nonce"); exists {
|
||||||
|
data["CSPNonce"] = nonce
|
||||||
|
}
|
||||||
if username, exists := c.Get("username"); exists {
|
if username, exists := c.Get("username"); exists {
|
||||||
data["IsLoggedIn"] = true
|
data["IsLoggedIn"] = true
|
||||||
data["Username"] = username
|
data["Username"] = username
|
||||||
}
|
}
|
||||||
|
if avatar, exists := c.Get("avatar"); exists {
|
||||||
|
data["Avatar"] = avatar
|
||||||
|
}
|
||||||
|
if expVal, exists := c.Get("exp"); exists {
|
||||||
|
if exp, ok := expVal.(int); ok {
|
||||||
|
data["Exp"] = exp
|
||||||
|
data["Level"] = model.GetLevelByExp(exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
// 注入 CSRF token(由 CSRF 中间件设置到上下文中)
|
// 注入 CSRF token(由 CSRF 中间件设置到上下文中)
|
||||||
if token, exists := c.Get("csrf_token"); exists {
|
if token, exists := c.Get("csrf_token"); exists {
|
||||||
data["CSRFToken"] = token
|
data["CSRFToken"] = token
|
||||||
@ -101,6 +114,10 @@ func BuildPageData(c *gin.Context, extra gin.H) gin.H {
|
|||||||
if isMaintenance, exists := c.Get("is_maintenance"); exists {
|
if isMaintenance, exists := c.Get("is_maintenance"); exists {
|
||||||
data["IsMaintenance"] = isMaintenance
|
data["IsMaintenance"] = isMaintenance
|
||||||
}
|
}
|
||||||
|
// 注入封禁状态
|
||||||
|
if status, exists := c.Get("status"); exists && status == model.StatusBanned {
|
||||||
|
data["IsBanned"] = true
|
||||||
|
}
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,25 +3,33 @@ package common
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/metalab/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewRedisClient 创建 Redis 客户端并验证连接
|
// NewRedisClient 创建 Redis 客户端并尝试验证连接
|
||||||
|
// Redis 不可达时不返回错误,而是返回 client(go-redis 自带自动重连),由调用方决定降级策略
|
||||||
func NewRedisClient(cfg config.RedisConfig) (*redis.Client, error) {
|
func NewRedisClient(cfg config.RedisConfig) (*redis.Client, error) {
|
||||||
client := redis.NewClient(&redis.Options{
|
client := redis.NewClient(&redis.Options{
|
||||||
Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port),
|
Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port),
|
||||||
Password: cfg.Password,
|
Password: cfg.Password,
|
||||||
DB: cfg.DB,
|
DB: cfg.DB,
|
||||||
|
DialTimeout: 1 * time.Second, // 连接超时降低(默认 5s),配合 FallbackStore 快速降级
|
||||||
|
ReadTimeout: 1 * time.Second,
|
||||||
|
WriteTimeout: 1 * time.Second,
|
||||||
|
MaxRetries: 1, // 最多重试 1 次(默认 3 次),减少故障阻塞时间
|
||||||
|
PoolSize: 5, // 会话存储不需要大连接池(默认 10*GOMAXPROCS)
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := client.Ping(ctx).Err(); err != nil {
|
if err := client.Ping(ctx).Err(); err != nil {
|
||||||
return nil, fmt.Errorf("Redis 连接失败: %w", err)
|
log.Printf("[Redis] 连接失败: %v,将降级为内存存储", err)
|
||||||
|
return client, fmt.Errorf("Redis 连接失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return client, nil
|
return client, nil
|
||||||
|
|||||||
67
internal/controller/admin/admin_comment_controller.go
Normal file
67
internal/controller/admin/admin_comment_controller.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminCommentController 后台评论管理
|
||||||
|
type AdminCommentController struct {
|
||||||
|
commentService adminCommentUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminCommentController 构造函数
|
||||||
|
func NewAdminCommentController(cs adminCommentUseCase) *AdminCommentController {
|
||||||
|
return &AdminCommentController{commentService: cs}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentsPage SSR 页面
|
||||||
|
func (ctrl *AdminCommentController) CommentsPage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/comments/index.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "评论管理",
|
||||||
|
"ExtraCSS": "/admin/static/css/comments.css",
|
||||||
|
"ExtraJS": "/admin/static/js/comments.js",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListComments API 列表
|
||||||
|
func (ctrl *AdminCommentController) ListComments(c *gin.Context) {
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
comments, total, err := ctrl.commentService.ListAllComments(keyword, false, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取评论列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if comments == nil {
|
||||||
|
comments = []model.AdminCommentRow{}
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"items": comments,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"total_pages": common.PageCount(total, pageSize),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 软删除评论
|
||||||
|
func (ctrl *AdminCommentController) Delete(c *gin.Context) {
|
||||||
|
commentID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的评论ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ctrl.commentService.Delete(uint(commentID), 0, true); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "删除成功")
|
||||||
|
}
|
||||||
@ -29,10 +29,17 @@ func (ac *AdminController) Dashboard(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
totalUsers = 0
|
totalUsers = 0
|
||||||
}
|
}
|
||||||
|
postStats, err := ac.adminService.GetPostStats()
|
||||||
|
if err != nil {
|
||||||
|
postStats = &service.PostStats{}
|
||||||
|
}
|
||||||
storeMetrics := ac.adminService.GetStoreMetrics()
|
storeMetrics := ac.adminService.GetStoreMetrics()
|
||||||
c.HTML(http.StatusOK, "admin/dashboard/index.html", common.BuildAdminPageData(c, gin.H{
|
c.HTML(http.StatusOK, "admin/dashboard/index.html", common.BuildAdminPageData(c, gin.H{
|
||||||
"Title": "管理首页",
|
"Title": "管理首页",
|
||||||
"TotalUsers": totalUsers,
|
"TotalUsers": totalUsers,
|
||||||
|
"PostTotal": postStats.Total,
|
||||||
|
"PostApproved": postStats.Approved,
|
||||||
|
"PostPending": postStats.Pending,
|
||||||
"StoreType": storeMetrics.Type,
|
"StoreType": storeMetrics.Type,
|
||||||
"StoreHealthy": storeMetrics.Healthy,
|
"StoreHealthy": storeMetrics.Healthy,
|
||||||
"StoreDegraded": storeMetrics.Degraded,
|
"StoreDegraded": storeMetrics.Degraded,
|
||||||
|
|||||||
140
internal/controller/admin/admin_energy_api_controller.go
Normal file
140
internal/controller/admin/admin_energy_api_controller.go
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdjustEnergy 调整用户域能
|
||||||
|
func (ac *AdminEnergyController) AdjustEnergy(c *gin.Context) {
|
||||||
|
operatorUID, ok := c.Get("uid")
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
UserIDs []uint `json:"user_ids" binding:"required,min=1"`
|
||||||
|
Amount int `json:"amount" binding:"required"`
|
||||||
|
Description string `json:"description" binding:"required,min=1,max=500"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误:需要 user_ids、amount 和 description")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认模式为 admin_transfer
|
||||||
|
if req.Mode == "" {
|
||||||
|
req.Mode = model.FundTypeAdminTransfer
|
||||||
|
}
|
||||||
|
|
||||||
|
// system_operation 仅 owner 可用
|
||||||
|
if req.Mode == model.FundTypeSystemOperation {
|
||||||
|
role, _ := c.Get("role")
|
||||||
|
if !model.HasMinRole(role.(string), model.RoleOwner) {
|
||||||
|
common.Error(c, http.StatusForbidden, "仅站长可使用系统操作模式")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ac.energySvc.AdminAdjust(operatorUID.(uint), req.UserIDs, req.Amount, req.Description, req.Mode); err != nil {
|
||||||
|
if err == common.ErrInsufficientFund {
|
||||||
|
common.Error(c, http.StatusBadRequest, "公户余额不足,无法执行操作")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.Error(c, http.StatusInternalServerError, "调整失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "域能调整成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEnergyLogs 查询域能日志
|
||||||
|
func (ac *AdminEnergyController) ListEnergyLogs(c *gin.Context) {
|
||||||
|
energyType := c.Query("type")
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
logs, total, err := ac.energySvc.GetAdminEnergyLogs(energyType, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为展示友好的视图
|
||||||
|
type logView struct {
|
||||||
|
model.EnergyLog
|
||||||
|
EnergyDisplay float64 `json:"energy_display"`
|
||||||
|
TypeName string `json:"type_name"`
|
||||||
|
}
|
||||||
|
views := make([]logView, len(logs))
|
||||||
|
for i, l := range logs {
|
||||||
|
views[i] = logView{
|
||||||
|
EnergyLog: l,
|
||||||
|
EnergyDisplay: float64(l.Amount) / 10,
|
||||||
|
TypeName: model.EnergyLogDisplayNames[l.Type],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"items": views,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
"total_pages": common.PageCount(total, pageSize),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFundBalance 获取公户余额
|
||||||
|
func (ac *AdminEnergyController) GetFundBalance(c *gin.Context) {
|
||||||
|
balance, err := ac.energySvc.GetFundBalance()
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"balance": balance,
|
||||||
|
"balance_display": float64(balance) / 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFundLogs 查询公户流水
|
||||||
|
func (ac *AdminEnergyController) ListFundLogs(c *gin.Context) {
|
||||||
|
logType := c.Query("type")
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
logs, total, err := ac.energySvc.GetFundLogs(logType, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type logView struct {
|
||||||
|
model.FundLog
|
||||||
|
AmountDisplay float64 `json:"amount_display"`
|
||||||
|
TypeName string `json:"type_name"`
|
||||||
|
}
|
||||||
|
views := make([]logView, len(logs))
|
||||||
|
for i, l := range logs {
|
||||||
|
views[i] = logView{
|
||||||
|
FundLog: l,
|
||||||
|
AmountDisplay: float64(l.Amount) / 10,
|
||||||
|
TypeName: model.FundLogDisplayNames[l.Type],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"items": views,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
"total_pages": common.PageCount(total, pageSize),
|
||||||
|
})
|
||||||
|
}
|
||||||
55
internal/controller/admin/admin_energy_controller.go
Normal file
55
internal/controller/admin/admin_energy_controller.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminEnergyController 后台域能管理控制器
|
||||||
|
type AdminEnergyController struct {
|
||||||
|
energySvc adminEnergyUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminEnergyController 构造函数
|
||||||
|
func NewAdminEnergyController(energySvc adminEnergyUseCase) *AdminEnergyController {
|
||||||
|
return &AdminEnergyController{energySvc: energySvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnergyPage 域能管理页面(公户余额 + 用户域能调整)
|
||||||
|
func (ac *AdminEnergyController) EnergyPage(c *gin.Context) {
|
||||||
|
balance := 0
|
||||||
|
if b, err := ac.energySvc.GetFundBalance(); err == nil {
|
||||||
|
balance = b
|
||||||
|
}
|
||||||
|
c.HTML(http.StatusOK, "admin/energy/index.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "域能管理",
|
||||||
|
"ExtraCSS": "/admin/static/css/energy.css",
|
||||||
|
"ExtraJS": "/admin/static/js/energy.js",
|
||||||
|
"FundBalance": balance,
|
||||||
|
"FundBalanceDisplay": float64(balance) / 10,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnergyLogPage 域能日志页面
|
||||||
|
func (ac *AdminEnergyController) EnergyLogPage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/energy/logs.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "域能日志",
|
||||||
|
"ExtraCSS": "/admin/static/css/energy.css",
|
||||||
|
"ExtraJS": "/admin/static/js/energy.js",
|
||||||
|
"LogTypes": model.EnergyLogDisplayNames,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FundLogPage 公户流水页面
|
||||||
|
func (ac *AdminEnergyController) FundLogPage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/energy/fund_logs.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "公户流水",
|
||||||
|
"ExtraCSS": "/admin/static/css/energy.css",
|
||||||
|
"ExtraJS": "/admin/static/js/energy.js",
|
||||||
|
"LogTypes": model.FundLogDisplayNames,
|
||||||
|
}))
|
||||||
|
}
|
||||||
124
internal/controller/admin/admin_post_action_controller.go
Normal file
124
internal/controller/admin/admin_post_action_controller.go
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Approve 审核通过
|
||||||
|
func (ctrl *AdminPostController) Approve(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.Approve(uint(id)); err != nil {
|
||||||
|
if errors.Is(err, common.ErrPostCannotApprove) || errors.Is(err, common.ErrPostNotFound) {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
} else {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "审核失败")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "审核通过")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject 退回
|
||||||
|
func (ctrl *AdminPostController) Reject(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.PostRejectRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "请填写退回理由")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.Reject(uint(id), req.Reason); err != nil {
|
||||||
|
if errors.Is(err, common.ErrPostCannotReject) || errors.Is(err, common.ErrPostNotFound) {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
} else {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "退回失败")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "已退回")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock 锁定
|
||||||
|
func (ctrl *AdminPostController) Lock(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.PostLockRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "请填写锁定理由")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.Lock(uint(id), req.Reason); err != nil {
|
||||||
|
if errors.Is(err, common.ErrPostNotFound) || errors.Is(err, common.ErrPostCannotLock) {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
} else {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "锁定失败")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "已锁定")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock 解锁
|
||||||
|
func (ctrl *AdminPostController) Unlock(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.Unlock(uint(id)); err != nil {
|
||||||
|
if errors.Is(err, common.ErrPostCannotUnlock) || errors.Is(err, common.ErrPostNotFound) {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
} else {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "解锁失败")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "已解锁")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore 恢复软删除
|
||||||
|
func (ctrl *AdminPostController) Restore(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.Restore(uint(id)); err != nil {
|
||||||
|
if errors.Is(err, common.ErrPostNotFound) {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
} else {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "恢复失败")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "已恢复")
|
||||||
|
}
|
||||||
@ -1,12 +1,10 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/metalab/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -61,109 +59,3 @@ func (ctrl *AdminPostController) PostsPage(c *gin.Context) {
|
|||||||
"ExtraCSS": "/admin/static/css/posts.css",
|
"ExtraCSS": "/admin/static/css/posts.css",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approve 审核通过
|
|
||||||
func (ctrl *AdminPostController) Approve(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ctrl.postService.Approve(uint(id)); err != nil {
|
|
||||||
if errors.Is(err, common.ErrPostCannotApprove) || errors.Is(err, common.ErrPostNotFound) {
|
|
||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "审核失败")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common.OkMessage(c, "审核通过")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject 退回
|
|
||||||
func (ctrl *AdminPostController) Reject(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req model.PostRejectRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "请填写退回理由")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ctrl.postService.Reject(uint(id), req.Reason); err != nil {
|
|
||||||
if errors.Is(err, common.ErrPostCannotReject) || errors.Is(err, common.ErrPostNotFound) {
|
|
||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "退回失败")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common.OkMessage(c, "已退回")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock 锁定
|
|
||||||
func (ctrl *AdminPostController) Lock(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ctrl.postService.Lock(uint(id)); err != nil {
|
|
||||||
if errors.Is(err, common.ErrPostNotFound) || errors.Is(err, common.ErrPostCannotLock) {
|
|
||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "锁定失败")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common.OkMessage(c, "已锁定")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlock 解锁
|
|
||||||
func (ctrl *AdminPostController) Unlock(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ctrl.postService.Unlock(uint(id)); err != nil {
|
|
||||||
if errors.Is(err, common.ErrPostCannotUnlock) || errors.Is(err, common.ErrPostNotFound) {
|
|
||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "解锁失败")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common.OkMessage(c, "已解锁")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore 恢复软删除
|
|
||||||
func (ctrl *AdminPostController) Restore(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ctrl.postService.Restore(uint(id)); err != nil {
|
|
||||||
if errors.Is(err, common.ErrPostNotFound) {
|
|
||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "恢复失败")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common.OkMessage(c, "已恢复")
|
|
||||||
}
|
|
||||||
|
|||||||
@ -50,17 +50,17 @@ func (ac *AuditController) ListAudits(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为每个审核记录附加提交者的用户名
|
// 为每个审核记录附加提交者的当前用户名
|
||||||
type auditItem struct {
|
type auditItem struct {
|
||||||
model.AuditSubmission
|
model.AuditSubmission
|
||||||
SubmitterUsername string `json:"submitter_username"`
|
CurrentUsername string `json:"current_username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]auditItem, 0, len(result.Items))
|
items := make([]auditItem, 0, len(result.Items))
|
||||||
for _, a := range result.Items {
|
for _, a := range result.Items {
|
||||||
item := auditItem{AuditSubmission: a}
|
item := auditItem{AuditSubmission: a}
|
||||||
if user, err := ac.userRepo.FindByID(a.UserID); err == nil {
|
if user, err := ac.userRepo.FindByID(a.UserID); err == nil {
|
||||||
item.SubmitterUsername = user.Username
|
item.CurrentUsername = user.Username
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,12 +6,13 @@ import (
|
|||||||
"metazone.cc/metalab/internal/session"
|
"metazone.cc/metalab/internal/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminUseCase AdminController 对 AdminService 的最小依赖(ISP:5 个方法)
|
// adminUseCase AdminController 对 AdminService 的最小依赖(ISP:6 个方法)
|
||||||
type adminUseCase interface {
|
type adminUseCase interface {
|
||||||
ListUsers(params service.ListUsersParams) (*service.ListUsersResult, error)
|
ListUsers(params service.ListUsersParams) (*service.ListUsersResult, error)
|
||||||
UpdateUserStatus(operatorUID, targetUID uint, newStatus string) error
|
UpdateUserStatus(operatorUID, targetUID uint, newStatus string) error
|
||||||
ResetUserToken(operatorUID, targetUID uint) error
|
ResetUserToken(operatorUID, targetUID uint) error
|
||||||
CountUsers() (int64, error)
|
CountUsers() (int64, error)
|
||||||
|
GetPostStats() (*service.PostStats, error)
|
||||||
GetStoreMetrics() session.StoreMetrics
|
GetStoreMetrics() session.StoreMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,7 +42,21 @@ type adminPostUseCase interface {
|
|||||||
ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error)
|
ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error)
|
||||||
Approve(postID uint) error
|
Approve(postID uint) error
|
||||||
Reject(postID uint, reason string) error
|
Reject(postID uint, reason string) error
|
||||||
Lock(postID uint) error
|
Lock(postID uint, reason string) error
|
||||||
Unlock(postID uint) error
|
Unlock(postID uint) error
|
||||||
Restore(postID uint) error
|
Restore(postID uint) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adminCommentUseCase AdminCommentController 对 CommentService 的最小依赖(ISP:2 个方法)
|
||||||
|
type adminCommentUseCase interface {
|
||||||
|
ListAllComments(keyword string, showDeleted bool, page, pageSize int) ([]model.AdminCommentRow, int64, error)
|
||||||
|
Delete(commentID uint, requesterID uint, isAdmin bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminEnergyUseCase AdminEnergyController 对 EnergyService 的最小依赖(ISP:4 个方法)
|
||||||
|
type adminEnergyUseCase interface {
|
||||||
|
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error
|
||||||
|
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
|
||||||
|
GetFundBalance() (int, error)
|
||||||
|
GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error)
|
||||||
|
}
|
||||||
|
|||||||
@ -51,8 +51,6 @@ func (ac *AuthController) Login(c *gin.Context) {
|
|||||||
switch err {
|
switch err {
|
||||||
case common.ErrInvalidCred:
|
case common.ErrInvalidCred:
|
||||||
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
||||||
case common.ErrUserBanned:
|
|
||||||
common.Error(c, http.StatusForbidden, "账号已被封禁")
|
|
||||||
case common.ErrUserLocked:
|
case common.ErrUserLocked:
|
||||||
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
common.Error(c, http.StatusUnauthorized, "邮箱或密码错误")
|
||||||
case common.ErrMaintenanceMode:
|
case common.ErrMaintenanceMode:
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/metalab/internal/common"
|
||||||
@ -17,11 +18,12 @@ type AuthController struct {
|
|||||||
sessionManager *session.Manager
|
sessionManager *session.Manager
|
||||||
rateLimiter rateLimiter
|
rateLimiter rateLimiter
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
siteSettings *config.SiteSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthController 构造函数
|
// NewAuthController 构造函数
|
||||||
func NewAuthController(authService authUseCase, sm *session.Manager, limiter rateLimiter, cfg *config.Config) *AuthController {
|
func NewAuthController(authService authUseCase, sm *session.Manager, limiter rateLimiter, cfg *config.Config, siteSettings *config.SiteSettings) *AuthController {
|
||||||
return &AuthController{authService: authService, sessionManager: sm, rateLimiter: limiter, cfg: cfg}
|
return &AuthController{authService: authService, sessionManager: sm, rateLimiter: limiter, cfg: cfg, siteSettings: siteSettings}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterPage 注册页面(已登录用户重定向到首页)
|
// RegisterPage 注册页面(已登录用户重定向到首页)
|
||||||
@ -30,11 +32,19 @@ func (ac *AuthController) RegisterPage(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/")
|
c.Redirect(http.StatusFound, "/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guidelines, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
|
// 优先从 DB 读取社区准则,为空时 fallback 到静态文件
|
||||||
|
guidelinesHTML := ac.siteSettings.Get("site.guidelines", "")
|
||||||
|
var guidelines template.HTML
|
||||||
|
if guidelinesHTML != "" {
|
||||||
|
guidelines = template.HTML(guidelinesHTML)
|
||||||
|
} else {
|
||||||
|
content, err := theme.LoadContent("templates/MetaLab-2026/guidelines.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "加载准则失败")
|
c.String(http.StatusInternalServerError, "加载准则失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
guidelines = content
|
||||||
|
}
|
||||||
c.HTML(http.StatusOK, "auth/register.html", common.BuildPageData(c, gin.H{
|
c.HTML(http.StatusOK, "auth/register.html", common.BuildPageData(c, gin.H{
|
||||||
"Title": "注册",
|
"Title": "注册",
|
||||||
"ExtraCSS": "/static/css/auth.css",
|
"ExtraCSS": "/static/css/auth.css",
|
||||||
|
|||||||
63
internal/controller/comment_action_controller.go
Normal file
63
internal/controller/comment_action_controller.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delete DELETE /api/comments/:id
|
||||||
|
func (ctrl *CommentController) Delete(c *gin.Context) {
|
||||||
|
uid, role, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
commentID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的评论ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限检查由 service 层统一处理
|
||||||
|
isAdmin := model.HasMinRole(role, model.RoleAdmin)
|
||||||
|
if err := ctrl.commentService.Delete(uint(commentID), uid, isAdmin); err != nil {
|
||||||
|
if errors.Is(err, common.ErrPermissionDenied) {
|
||||||
|
common.Error(c, http.StatusForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "删除成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchUsers GET /api/users/search?q=keyword
|
||||||
|
func (ctrl *CommentController) SearchUsers(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := c.Query("q")
|
||||||
|
if q == "" {
|
||||||
|
common.Ok(c, []model.UserSearchResult{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := ctrl.commentService.SearchUsers(q, uid)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "搜索用户失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, users)
|
||||||
|
}
|
||||||
11
internal/controller/comment_controller.go
Normal file
11
internal/controller/comment_controller.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
// CommentController 评论控制器
|
||||||
|
type CommentController struct {
|
||||||
|
commentService commentUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCommentController 构造函数
|
||||||
|
func NewCommentController(cs commentUseCase) *CommentController {
|
||||||
|
return &CommentController{commentService: cs}
|
||||||
|
}
|
||||||
52
internal/controller/comment_list_controller.go
Normal file
52
internal/controller/comment_list_controller.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListRootComments 获取文章顶级评论 GET /api/posts/:id/comments
|
||||||
|
func (ctrl *CommentController) ListRootComments(c *gin.Context) {
|
||||||
|
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的文章ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
comments, total, err := ctrl.commentService.ListRootComments(uint(postID), page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取评论失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"items": comments,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"total_pages": common.PageCount(total, pageSize),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReplies 获取顶级评论的全部回复 GET /api/posts/:id/comments/:root_id/replies
|
||||||
|
func (ctrl *CommentController) ListReplies(c *gin.Context) {
|
||||||
|
rootID, err := strconv.ParseUint(c.Param("root_id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的评论ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
replies, err := ctrl.commentService.ListReplies(uint(rootID))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取回复失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{"replies": replies})
|
||||||
|
}
|
||||||
119
internal/controller/comment_upload_controller.go
Normal file
119
internal/controller/comment_upload_controller.go
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UploadImage 上传评论图片 POST /api/comments/upload-image
|
||||||
|
// 需要 LV4+(Exp ≥ 1500),复用帖子图片上传的 WebP 转码逻辑
|
||||||
|
func (ctrl *CommentController) UploadImage(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// LV4+ 检查(Exp ≥ 1500)
|
||||||
|
expVal, exists := c.Get("exp")
|
||||||
|
if !exists {
|
||||||
|
common.Error(c, http.StatusForbidden, "无法获取经验值")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exp := expVal.(int)
|
||||||
|
if exp < 1500 {
|
||||||
|
common.Error(c, http.StatusForbidden, "需 Lv4 以上才能上传评论图片")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "请选择文件")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// 检查扩展名
|
||||||
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||||
|
allowedExt := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
|
||||||
|
if !allowedExt[ext] {
|
||||||
|
common.Error(c, http.StatusBadRequest, "仅支持 jpg/jpeg/png/gif/webp 格式")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制 5MB
|
||||||
|
if header.Size > 5<<20 {
|
||||||
|
common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "读取文件失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storageDir := "storage/uploads/posts"
|
||||||
|
if err := os.MkdirAll(storageDir, 0755); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := time.Now().UnixMilli()
|
||||||
|
isJPEGPNG := ext == ".jpg" || ext == ".jpeg" || ext == ".png"
|
||||||
|
|
||||||
|
var savePath, url string
|
||||||
|
|
||||||
|
// JPEG/PNG 转 WebP
|
||||||
|
if isJPEGPNG {
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "图片格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webpData := encodeWebPBinary(img, len(data))
|
||||||
|
if webpData != nil && len(webpData) < len(data) {
|
||||||
|
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ".webp"
|
||||||
|
savePath = filepath.Join(storageDir, filename)
|
||||||
|
os.WriteFile(savePath, webpData, 0644)
|
||||||
|
url = "/uploads/posts/" + filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退存储
|
||||||
|
if savePath == "" {
|
||||||
|
dataOut := data
|
||||||
|
if isJPEGPNG {
|
||||||
|
decImg, _, decErr := image.Decode(bytes.NewReader(data))
|
||||||
|
if decErr == nil {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if ext == ".png" {
|
||||||
|
png.Encode(&buf, decImg)
|
||||||
|
} else {
|
||||||
|
jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
|
||||||
|
}
|
||||||
|
dataOut = buf.Bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ext
|
||||||
|
savePath = filepath.Join(storageDir, filename)
|
||||||
|
os.WriteFile(savePath, dataOut, 0644)
|
||||||
|
url = "/uploads/posts/" + filename
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回标准 JSON(前端将 URL 插入评论文本)
|
||||||
|
common.Ok(c, gin.H{"url": url})
|
||||||
|
}
|
||||||
72
internal/controller/comment_write_controller.go
Normal file
72
internal/controller/comment_write_controller.go
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateRoot POST /api/posts/:id/comments
|
||||||
|
func (ctrl *CommentController) CreateRoot(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的文章ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Body string `json:"body" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "请输入评论内容")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comment, err := ctrl.commentService.CreateRoot(uid, uint(postID), req.Body)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateReply POST /api/comments/:id/reply
|
||||||
|
func (ctrl *CommentController) CreateReply(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parentID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的评论ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Body string `json:"body" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "请输入回复内容")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, err := ctrl.commentService.CreateReply(uid, uint(parentID), req.Body)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, reply)
|
||||||
|
}
|
||||||
162
internal/controller/energy_controller.go
Normal file
162
internal/controller/energy_controller.go
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnergyController 域能 API 控制器
|
||||||
|
type EnergyController struct {
|
||||||
|
energySvc energyUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEnergyController 构造函数
|
||||||
|
func NewEnergyController(energySvc energyUseCase) *EnergyController {
|
||||||
|
return &EnergyController{energySvc: energySvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Energize 赋能文章
|
||||||
|
func (ec *EnergyController) Energize(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
PostID uint `json:"post_id" binding:"required"`
|
||||||
|
Amount int `json:"amount" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expGained, actualAmount, err := ec.energySvc.Energize(uid, req.PostID, req.Amount)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case common.ErrPostNotFound:
|
||||||
|
common.Error(c, http.StatusNotFound, "文章不存在")
|
||||||
|
case common.ErrCannotEnergizeSelf:
|
||||||
|
common.Error(c, http.StatusBadRequest, "不能给自己的文章赋能")
|
||||||
|
case common.ErrInsufficientEnergy:
|
||||||
|
common.Error(c, http.StatusBadRequest, "域能不足,可通过每日签到或创作被赋能获取")
|
||||||
|
case common.ErrEnergizeLimitReached:
|
||||||
|
common.Error(c, http.StatusBadRequest, "对该文章赋能已达上限")
|
||||||
|
case common.ErrInvalidEnergyAmount:
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的赋能数量")
|
||||||
|
default:
|
||||||
|
common.Error(c, http.StatusInternalServerError, "赋能失败")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
truncated := actualAmount != req.Amount
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"exp_gained": expGained,
|
||||||
|
"actual_amount": actualAmount,
|
||||||
|
"truncated": truncated,
|
||||||
|
"message": buildEnergizeMessage(expGained, truncated),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEnergizeMessage 根据获得的经验生成提示消息
|
||||||
|
func buildEnergizeMessage(expGained int, truncated bool) string {
|
||||||
|
if truncated {
|
||||||
|
if expGained > 0 {
|
||||||
|
return "单篇额度已满,实际赋能部分额度,+" + strconv.Itoa(expGained) + " 经验"
|
||||||
|
}
|
||||||
|
return "单篇额度已满,实际赋能部分额度"
|
||||||
|
}
|
||||||
|
if expGained > 0 {
|
||||||
|
return "赋能成功!+" + strconv.Itoa(expGained) + " 经验"
|
||||||
|
}
|
||||||
|
return "赋能成功!今日经验已满,对方仍获得激励"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnergyInfo 获取域能余额和经验上限状态,支持 ?post_id= 查询文章赋能总量
|
||||||
|
func (ec *EnergyController) GetEnergyInfo(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
postIDStr := c.DefaultQuery("post_id", "0")
|
||||||
|
postID, _ := strconv.ParseUint(postIDStr, 10, 64)
|
||||||
|
|
||||||
|
info, err := ec.energySvc.GetEnergyInfo(uid, uint(postID))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取域能信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
energyDisplay := float64(info.Energy) / 10
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"energy": info.Energy,
|
||||||
|
"energy_display": energyDisplay,
|
||||||
|
"daily_energize_exp": info.DailyEnergizeExp,
|
||||||
|
"daily_exp_cap": info.DailyExpCap,
|
||||||
|
"daily_exp_cap_reached": info.DailyExpCapReached,
|
||||||
|
"post_energize_total": info.PostEnergizeTotal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnergyLogs 获取我的域能流水
|
||||||
|
func (ec *EnergyController) GetEnergyLogs(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
days, _ := strconv.Atoi(c.DefaultQuery("days", "7"))
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
logs, total, err := ec.energySvc.GetEnergyLogs(uid, days, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取流水失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"items": toEnergyLogViews(logs),
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
"total_pages": common.PageCount(total, pageSize),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnergyLogView 前端展示用的域能流水视图
|
||||||
|
type EnergyLogView struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Amount int `json:"amount"`
|
||||||
|
AmountDisplay float64 `json:"amount_display"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
TypeName string `json:"type_name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toEnergyLogViews(logs []model.EnergyLog) []EnergyLogView {
|
||||||
|
views := make([]EnergyLogView, len(logs))
|
||||||
|
for i, l := range logs {
|
||||||
|
views[i] = EnergyLogView{
|
||||||
|
ID: l.ID,
|
||||||
|
Amount: l.Amount,
|
||||||
|
AmountDisplay: float64(l.Amount) / 10,
|
||||||
|
Type: l.Type,
|
||||||
|
TypeName: model.EnergyLogDisplayNames[l.Type],
|
||||||
|
Description: l.Description,
|
||||||
|
CreatedAt: l.CreatedAt.Format(common.TimeFormat),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return views
|
||||||
|
}
|
||||||
122
internal/controller/favorite_controller.go
Normal file
122
internal/controller/favorite_controller.go
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FavoriteController 收藏夹 API 控制器
|
||||||
|
type FavoriteController struct {
|
||||||
|
svc favoriteUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFavoriteController 构造函数
|
||||||
|
func NewFavoriteController(svc favoriteUseCase) *FavoriteController {
|
||||||
|
return &FavoriteController{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFolders 我的收藏夹列表 GET /api/folders
|
||||||
|
func (fc *FavoriteController) ListFolders(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := fc.svc.ListFolders(uid)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取收藏夹失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFolder 创建收藏夹 POST /api/folders
|
||||||
|
func (fc *FavoriteController) CreateFolder(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := fc.svc.CreateFolder(uid, req.Name, req.Description, req.IsPublic)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "收藏夹名称已存在" || err.Error() == "收藏夹名称不能为空" || err.Error() == "收藏夹名称不能超过 50 个字符" {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.Error(c, http.StatusInternalServerError, "创建失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkWithMessage(c, f, "收藏夹创建成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFolder 修改收藏夹 PUT /api/folders/:id
|
||||||
|
func (fc *FavoriteController) UpdateFolder(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fc.svc.UpdateFolder(uid, uint(folderID), req.Name, req.Description, req.IsPublic); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "收藏夹已更新")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFolder 删除收藏夹 DELETE /api/folders/:id
|
||||||
|
func (fc *FavoriteController) DeleteFolder(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fc.svc.DeleteFolder(uid, uint(folderID)); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "收藏夹已删除")
|
||||||
|
}
|
||||||
143
internal/controller/favorite_item_controller.go
Normal file
143
internal/controller/favorite_item_controller.go
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListFolderItems 获取收藏夹内文章列表 GET /api/folders/:id/posts
|
||||||
|
func (fc *FavoriteController) ListFolderItems(c *gin.Context) {
|
||||||
|
uid, _, _ := common.GetGinUser(c) // 允许未登录查看公开收藏夹
|
||||||
|
|
||||||
|
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
result, err := fc.svc.ListFolderItems(uid, uint(folderID), page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
if err == common.ErrPermissionDenied {
|
||||||
|
common.Error(c, http.StatusForbidden, "该收藏夹未公开")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddToFolder 收藏文章 POST /api/folders/:id/posts
|
||||||
|
func (fc *FavoriteController) AddToFolder(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
PostID uint `json:"post_id"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.PostID == 0 {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fc.svc.AddFavorite(uid, req.PostID, func() *uint { v := uint(folderID); return &v }()); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "收藏成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveFromFolder 取消收藏 DELETE /api/folders/:id/posts/:postId
|
||||||
|
func (fc *FavoriteController) RemoveFromFolder(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
folderID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的收藏夹 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
postID, err := strconv.ParseUint(c.Param("postId"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的文章 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fc.svc.RemoveFavorite(uid, uint(postID)); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = folderID // 移除不依赖具体收藏夹
|
||||||
|
common.OkMessage(c, "已取消收藏")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPostStatus 查询文章收藏状态 GET /api/posts/:id/folder-status
|
||||||
|
func (fc *FavoriteController) GetPostStatus(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Ok(c, gin.H{"favorited": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的文章 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := fc.svc.GetPostFavoriteStatus(uid, uint(postID))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleFavorite 切换收藏 POST /api/posts/:id/favorite
|
||||||
|
func (fc *FavoriteController) ToggleFavorite(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的文章 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isFavored, err := fc.svc.ToggleFavorite(uid, uint(postID))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"favorited": isFavored,
|
||||||
|
})
|
||||||
|
}
|
||||||
188
internal/controller/follow_controller.go
Normal file
188
internal/controller/follow_controller.go
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
"metazone.cc/metalab/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FollowController 关注 API 控制器
|
||||||
|
type FollowController struct {
|
||||||
|
followSvc followUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFollowController 构造函数
|
||||||
|
func NewFollowController(followSvc followUseCase) *FollowController {
|
||||||
|
return &FollowController{followSvc: followSvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle 切换关注 POST /api/users/:uid/follow
|
||||||
|
func (fc *FollowController) Toggle(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isFollowing, err := fc.followSvc.Toggle(uid, uint(targetUID))
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "不能关注自己" {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最新状态
|
||||||
|
status, _ := fc.followSvc.GetStatus(uid, uint(targetUID))
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"is_following": isFollowing,
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus 查询关注关系 GET /api/users/:uid/follow-status
|
||||||
|
func (fc *FollowController) GetStatus(c *gin.Context) {
|
||||||
|
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uid, _, _ := common.GetGinUser(c) // 允许未登录(返回全是 false)
|
||||||
|
|
||||||
|
status, err := fc.followSvc.GetStatus(uid, uint(targetUID))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Followers 粉丝列表 GET /api/users/:uid/followers
|
||||||
|
func (fc *FollowController) Followers(c *gin.Context) {
|
||||||
|
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
uid, _, _ := common.GetGinUser(c) // 允许未登录
|
||||||
|
|
||||||
|
result, err := fc.followSvc.ListFollowers(uint(targetUID), uid, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Following 关注列表 GET /api/users/:uid/following
|
||||||
|
func (fc *FollowController) Following(c *gin.Context) {
|
||||||
|
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
uid, _, _ := common.GetGinUser(c)
|
||||||
|
|
||||||
|
result, err := fc.followSvc.ListFollowing(uint(targetUID), uid, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FollowPageController 关注/粉丝页面控制器
|
||||||
|
type FollowPageController struct {
|
||||||
|
followSvc followUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFollowPageController 构造函数
|
||||||
|
func NewFollowPageController(followSvc followUseCase) *FollowPageController {
|
||||||
|
return &FollowPageController{followSvc: followSvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FollowersPage 粉丝列表页面
|
||||||
|
func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
|
||||||
|
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
|
||||||
|
uid, _, _ := common.GetGinUser(c)
|
||||||
|
result, err := fpc.followSvc.ListFollowers(uint(targetUID), uid, page, 20)
|
||||||
|
if err != nil {
|
||||||
|
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "follow/followers.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "粉丝列表",
|
||||||
|
"ExtraCSS": "/static/css/follow.css",
|
||||||
|
"Result": result,
|
||||||
|
"TargetUID": targetUID,
|
||||||
|
"Page": result.Page,
|
||||||
|
"TotalPages": result.TotalPages,
|
||||||
|
"HasPrev": result.Page > 1,
|
||||||
|
"HasNext": result.Page < result.TotalPages,
|
||||||
|
"PrevPage": result.Page - 1,
|
||||||
|
"NextPage": result.Page + 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FollowingPage 关注列表页面
|
||||||
|
func (fpc *FollowPageController) FollowingPage(c *gin.Context) {
|
||||||
|
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的用户ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
|
||||||
|
uid, _, _ := common.GetGinUser(c)
|
||||||
|
result, err := fpc.followSvc.ListFollowing(uint(targetUID), uid, page, 20)
|
||||||
|
if err != nil {
|
||||||
|
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "follow/following.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "关注列表",
|
||||||
|
"ExtraCSS": "/static/css/follow.css",
|
||||||
|
"Result": result,
|
||||||
|
"TargetUID": targetUID,
|
||||||
|
"Page": result.Page,
|
||||||
|
"TotalPages": result.TotalPages,
|
||||||
|
"HasPrev": result.Page > 1,
|
||||||
|
"HasNext": result.Page < result.TotalPages,
|
||||||
|
"PrevPage": result.Page - 1,
|
||||||
|
"NextPage": result.Page + 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/middleware"
|
"metazone.cc/metalab/internal/middleware"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
"metazone.cc/metalab/internal/service"
|
"metazone.cc/metalab/internal/service"
|
||||||
@ -25,7 +27,7 @@ type rateLimiter interface {
|
|||||||
ClearIP(ipKey string)
|
ClearIP(ipKey string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// postUseCase PostController 对 PostService 的最小依赖(ISP:7 个方法)
|
// postUseCase PostController 对 PostService 的最小依赖(ISP:8 个方法)
|
||||||
type postUseCase interface {
|
type postUseCase interface {
|
||||||
Create(userID uint, title, body string) (*model.Post, error)
|
Create(userID uint, title, body string) (*model.Post, error)
|
||||||
GetByID(id uint) (*model.Post, error)
|
GetByID(id uint) (*model.Post, error)
|
||||||
@ -33,10 +35,12 @@ type postUseCase interface {
|
|||||||
Update(postID uint, title, body string) error
|
Update(postID uint, title, body string) error
|
||||||
Delete(postID uint) error
|
Delete(postID uint) error
|
||||||
SubmitForAudit(postID uint) error
|
SubmitForAudit(postID uint) error
|
||||||
|
RecordRead(userID, postID uint)
|
||||||
|
RecordGuestRead(visitorID string, postID uint)
|
||||||
IsPostAccessible(userID uint, role string, postUserID uint) bool
|
IsPostAccessible(userID uint, role string, postUserID uint) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// studioUseCase StudioController 对 PostService 的最小依赖(ISP:8 个方法)
|
// studioUseCase StudioController 对 PostService 的最小依赖(ISP:9 个方法)
|
||||||
type studioUseCase interface {
|
type studioUseCase interface {
|
||||||
Create(userID uint, title, body string) (*model.Post, error)
|
Create(userID uint, title, body string) (*model.Post, error)
|
||||||
GetByID(id uint) (*model.Post, error)
|
GetByID(id uint) (*model.Post, error)
|
||||||
@ -46,12 +50,22 @@ type studioUseCase interface {
|
|||||||
ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error)
|
ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error)
|
||||||
GetOverview(userID uint) (*service.StudioOverview, error)
|
GetOverview(userID uint) (*service.StudioOverview, error)
|
||||||
IsPostAccessible(userID uint, role string, postUserID uint) bool
|
IsPostAccessible(userID uint, role string, postUserID uint) bool
|
||||||
|
CanCreatePost(userID uint) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// spaceUseCase SpaceController 对 SpaceService 的最小依赖(ISP:2 个方法)
|
// spaceUseCase SpaceController 对 SpaceService 的最小依赖(ISP:5 个方法)
|
||||||
type spaceUseCase interface {
|
type spaceUseCase interface {
|
||||||
GetSpaceUser(uid uint) (*model.User, error)
|
GetSpaceUser(uid uint) (*model.User, error)
|
||||||
GetPostsByUser(uid uint, page, pageSize int) ([]model.Post, int64, error)
|
GetPostsByUser(uid uint, page, pageSize int) ([]model.Post, int64, error)
|
||||||
|
CountUserPosts(uid uint) (int64, error)
|
||||||
|
GetUserStats(uid uint) (likes, favorites, reads int64, err error)
|
||||||
|
UpdateUser(user *model.User) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// favoriteUseCaseForSpace SpaceController 对 FavoriteService 的最小依赖(ISP:收藏夹相关)
|
||||||
|
type favoriteUseCaseForSpace interface {
|
||||||
|
ListFolders(userID uint) (*service.FavoriteListResult, error)
|
||||||
|
ListFolderItems(userID, folderID uint, page, pageSize int) (*service.FolderItemsResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sessionManager 登录管理对会话管理的最小依赖(ISP:4 个方法)
|
// sessionManager 登录管理对会话管理的最小依赖(ISP:4 个方法)
|
||||||
@ -61,3 +75,114 @@ type sessionManager interface {
|
|||||||
DestroyOtherByUID(uid uint, currentSID string) error
|
DestroyOtherByUID(uid uint, currentSID string) error
|
||||||
UpdateRemark(sid string, uid uint, remark string) error
|
UpdateRemark(sid string, uid uint, remark string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// levelUseCase LevelController 对 LevelService 的最小依赖
|
||||||
|
type levelUseCase interface {
|
||||||
|
CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error)
|
||||||
|
GetLevel(userID uint) (level int, exp int, checkedIn bool, err error)
|
||||||
|
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// energyUseCase EnergyController 对 EnergyService 的最小依赖(ISP:3 个方法)
|
||||||
|
type energyUseCase interface {
|
||||||
|
Energize(energizerID uint, postID uint, amount int) (int, int, error)
|
||||||
|
GetEnergyInfo(userID uint, postID uint) (*service.EnergyInfo, error)
|
||||||
|
GetEnergyLogs(userID uint, days, page, pageSize int) ([]model.EnergyLog, int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// energyDeducter 删稿/改名时域能扣减接口(ISP)
|
||||||
|
type energyDeducter interface {
|
||||||
|
DeductOnDeletePost(authorUserID uint, postID uint) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// energyRenameHandler 改名域能操作接口(ISP)
|
||||||
|
type energyRenameHandler interface {
|
||||||
|
DeductOnRename(userID uint) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖(ISP:4 个方法)
|
||||||
|
type energyAdminUseCase interface {
|
||||||
|
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error
|
||||||
|
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
|
||||||
|
GetFundBalance() (int, error)
|
||||||
|
GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// commentUseCase CommentController 对 CommentService 的最小依赖(ISP:6 个方法)
|
||||||
|
type commentUseCase interface {
|
||||||
|
CreateRoot(userID, postID uint, body string) (*model.Comment, error)
|
||||||
|
CreateReply(userID, parentID uint, body string) (*model.Comment, error)
|
||||||
|
Delete(commentID uint, requesterID uint, isAdmin bool) error
|
||||||
|
ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error)
|
||||||
|
ListReplies(rootID uint) ([]model.Comment, error)
|
||||||
|
SearchUsers(keyword string, followerUID uint) ([]model.UserSearchResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reactionUseCase ReactionController 对 ReactionService 的最小依赖(ISP:4 个方法)
|
||||||
|
type reactionUseCase interface {
|
||||||
|
ToggleLike(userID, postID uint) (bool, error)
|
||||||
|
ToggleDislike(userID, postID uint) (bool, error)
|
||||||
|
GetReaction(userID, postID uint) (service.ReactionType, error)
|
||||||
|
GetPostLikesCount(postID uint) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// followUseCase FollowController 对 FollowService 的最小依赖(ISP:4 个方法)
|
||||||
|
type followUseCase interface {
|
||||||
|
Toggle(followerID, followeeID uint) (bool, error)
|
||||||
|
GetStatus(currentUserID, targetUserID uint) (*service.FollowStatus, error)
|
||||||
|
ListFollowers(userID, currentUserID uint, page, pageSize int) (*service.FollowListResult, error)
|
||||||
|
ListFollowing(userID, currentUserID uint, page, pageSize int) (*service.FollowListResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// favoriteUseCase FavoriteController 对 FavoriteService 的最小依赖(ISP:9 个方法)
|
||||||
|
type favoriteUseCase interface {
|
||||||
|
ListFolders(userID uint) (*service.FavoriteListResult, error)
|
||||||
|
CreateFolder(userID uint, name, description string, isPublic bool) (*model.Folder, error)
|
||||||
|
UpdateFolder(userID, folderID uint, name, description string, isPublic bool) error
|
||||||
|
DeleteFolder(userID, folderID uint) error
|
||||||
|
ListFolderItems(userID, folderID uint, page, pageSize int) (*service.FolderItemsResult, error)
|
||||||
|
AddFavorite(userID, postID uint, folderID *uint) error
|
||||||
|
RemoveFavorite(userID, postID uint) error
|
||||||
|
ToggleFavorite(userID, postID uint) (bool, error)
|
||||||
|
GetPostFavoriteStatus(userID, postID uint) (*service.FavoriteStatus, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileProvider SettingsController 对 AuthService 的最小依赖(ISP:4 个方法)
|
||||||
|
type profileProvider interface {
|
||||||
|
GetProfile(userID uint) (*model.User, error)
|
||||||
|
UpdateProfile(userID uint, username, bio string) error
|
||||||
|
ChangePassword(userID uint, currentPassword, newPassword string) error
|
||||||
|
DeleteAccount(userID uint, password, reason string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// avatarProvider SettingsController 头像上传对 Service 的最小依赖(ISP:2 个方法)
|
||||||
|
type avatarProvider interface {
|
||||||
|
ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
|
||||||
|
ProcessImage(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditSubmittable SettingsController 对 AuditService 的依赖(ISP:4 个方法)
|
||||||
|
type auditSubmittable interface {
|
||||||
|
ShouldAudit(userID uint) (bool, error)
|
||||||
|
SubmitProfileChanges(userID uint, currentUser *model.User, newUsername, newBio string) error
|
||||||
|
Submit(userID uint, auditType, newValue string) error
|
||||||
|
GetPendingTypes(userID uint) ([]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionConfig SettingsController 对配置的最小依赖(ISP:2 个方法)
|
||||||
|
type sessionConfig interface {
|
||||||
|
GetIdleTimeout() int
|
||||||
|
GetRememberTimeout() int
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskCompleter SettingsController 对 LevelService 的依赖(ISP:2 个方法)
|
||||||
|
type taskCompleter interface {
|
||||||
|
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
|
||||||
|
HasCompletedTask(userID uint, taskType string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyPrefReader SettingsController 通知偏好的接口(ISP)
|
||||||
|
type notifyPrefReader interface {
|
||||||
|
GetNotifyPrefs(userID uint) (map[string]bool, error)
|
||||||
|
UpdateNotifyPref(userID uint, key string, enabled bool) error
|
||||||
|
}
|
||||||
|
|||||||
61
internal/controller/level_controller.go
Normal file
61
internal/controller/level_controller.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LevelController 积分/等级/签到 API
|
||||||
|
type LevelController struct {
|
||||||
|
levelSvc levelUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLevelController 构造函数
|
||||||
|
func NewLevelController(levelSvc levelUseCase) *LevelController {
|
||||||
|
return &LevelController{levelSvc: levelSvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckIn 手动签到 API(已废弃前端手动按钮,保留后端接口)
|
||||||
|
func (lc *LevelController) CheckIn(c *gin.Context) {
|
||||||
|
uidVal, exists := c.Get("uid")
|
||||||
|
if !exists {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := uidVal.(uint)
|
||||||
|
|
||||||
|
checkedIn, newExp, levelUp, err := lc.levelSvc.CheckIn(uid)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "签到失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
level, _, _, _ := lc.levelSvc.GetLevel(uid)
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"checked_in": checkedIn,
|
||||||
|
"exp": newExp,
|
||||||
|
"level": level,
|
||||||
|
"level_up": levelUp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLevel 获取当前等级、经验值和签到状态 API
|
||||||
|
func (lc *LevelController) GetLevel(c *gin.Context) {
|
||||||
|
uidVal, exists := c.Get("uid")
|
||||||
|
if !exists {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := uidVal.(uint)
|
||||||
|
|
||||||
|
level, exp, checkedIn, err := lc.levelSvc.GetLevel(uid)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{"level": level, "exp": exp, "checked_in": checkedIn})
|
||||||
|
}
|
||||||
@ -10,9 +10,10 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// notifProvider MessageController 所需的通知服务接口(ISP:4 个方法)
|
// notifProvider MessageController 所需的通知服务接口(ISP:5 个方法)
|
||||||
type notifProvider interface {
|
type notifProvider interface {
|
||||||
List(userID uint, page, pageSize int) (*model.NotificationListResult, error)
|
List(userID uint, page, pageSize int) (*model.NotificationListResult, error)
|
||||||
|
ListByCategory(userID uint, category string, page, pageSize int) (*model.NotificationListResult, error)
|
||||||
CountUnread(userID uint) (int64, error)
|
CountUnread(userID uint) (int64, error)
|
||||||
MarkRead(id, userID uint) error
|
MarkRead(id, userID uint) error
|
||||||
MarkAllRead(userID uint) error
|
MarkAllRead(userID uint) error
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MessagesPage 消息中心页面(需登录,noindex)
|
// MessagesPage 消息中心页面(需登录,noindex,支持分类 TAB)
|
||||||
func (mc *MessageController) MessagesPage(c *gin.Context) {
|
func (mc *MessageController) MessagesPage(c *gin.Context) {
|
||||||
uidVal, exists := c.Get("uid")
|
uidVal, exists := c.Get("uid")
|
||||||
if !exists {
|
if !exists {
|
||||||
@ -19,6 +19,8 @@ func (mc *MessageController) MessagesPage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
uid := uidVal.(uint)
|
uid := uidVal.(uint)
|
||||||
|
|
||||||
|
tab := c.DefaultQuery("tab", "all")
|
||||||
|
|
||||||
// 从 query 取分页参数
|
// 从 query 取分页参数
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
@ -26,7 +28,15 @@ func (mc *MessageController) MessagesPage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
pageSize := 20
|
pageSize := 20
|
||||||
|
|
||||||
result, err := mc.notifService.List(uid, page, pageSize)
|
var result *model.NotificationListResult
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if tab == "all" {
|
||||||
|
result, err = mc.notifService.List(uid, page, pageSize)
|
||||||
|
} else {
|
||||||
|
result, err = mc.notifService.ListByCategory(uid, tab, page, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result = &model.NotificationListResult{Items: []model.Notification{}, Total: 0, Page: 1}
|
result = &model.NotificationListResult{Items: []model.Notification{}, Total: 0, Page: 1}
|
||||||
}
|
}
|
||||||
@ -50,5 +60,6 @@ func (mc *MessageController) MessagesPage(c *gin.Context) {
|
|||||||
"UnreadCount": result.Unread,
|
"UnreadCount": result.Unread,
|
||||||
"NotifyTypeNames": model.NotifyTypeNames,
|
"NotifyTypeNames": model.NotifyTypeNames,
|
||||||
"AuditTypeNames": model.AuditTypeNames,
|
"AuditTypeNames": model.AuditTypeNames,
|
||||||
|
"CurrentTab": tab,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
109
internal/controller/post_api_controller.go
Normal file
109
internal/controller/post_api_controller.go
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Submit API 提交审核
|
||||||
|
func (ctrl *PostController) Submit(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := ctrl.postService.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if post.UserID != uid {
|
||||||
|
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil {
|
||||||
|
if errors.Is(err, common.ErrPostCannotSubmit) || errors.Is(err, common.ErrPostNotFound) {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
} else {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "提交审核失败")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "已提交审核")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAPI 帖子列表 JSON API
|
||||||
|
func (ctrl *PostController) ListAPI(c *gin.Context) {
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取帖子列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, model.PostListResult{
|
||||||
|
Items: posts,
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
TotalPages: common.PageCount(total, pageSize),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowAPI 帖子详情 JSON API
|
||||||
|
func (ctrl *PostController) ShowAPI(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := ctrl.postService.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uid, role, _ := common.GetGinUser(c)
|
||||||
|
|
||||||
|
// 权限控制:非 approved 帖子仅作者和 moderator+ 可见
|
||||||
|
if post.Status != model.PostStatusApproved {
|
||||||
|
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||||
|
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录用户阅读计数(已发布帖子,自动去重+防刷)
|
||||||
|
if uid > 0 && post.Status == model.PostStatusApproved {
|
||||||
|
ctrl.postService.RecordRead(uid, post.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 访客阅读计数(基于 Cookie 标识去重+防刷)
|
||||||
|
if uid == 0 && post.Status == model.PostStatusApproved {
|
||||||
|
vid := ctrl.getVisitorID(c)
|
||||||
|
ctrl.postService.RecordGuestRead(vid, post.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctrl.applyShortcode(post)
|
||||||
|
|
||||||
|
common.Ok(c, post)
|
||||||
|
}
|
||||||
@ -2,21 +2,10 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"crypto/rand"
|
||||||
"fmt"
|
"encoding/hex"
|
||||||
"image"
|
"image"
|
||||||
"image/jpeg"
|
|
||||||
"image/png"
|
|
||||||
_ "image/gif" // 仅注册 GIF 解码器,不重编码(会丢失动画)
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
"metazone.cc/metalab/internal/service"
|
"metazone.cc/metalab/internal/service"
|
||||||
|
|
||||||
@ -35,455 +24,30 @@ func NewPostController(ps postUseCase, scs *service.ShortcodeService) *PostContr
|
|||||||
return &PostController{postService: ps, shortcodeSvc: scs}
|
return &PostController{postService: ps, shortcodeSvc: scs}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListPage 帖子列表页
|
|
||||||
func (ctrl *PostController) ListPage(c *gin.Context) {
|
|
||||||
keyword := c.Query("keyword")
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|
||||||
|
|
||||||
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
|
|
||||||
if err != nil {
|
|
||||||
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "社区帖子",
|
|
||||||
"Error": "加载帖子列表失败",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
totalPages := common.PageCount(total, pageSize)
|
|
||||||
prevPage := page - 1
|
|
||||||
if prevPage < 1 {
|
|
||||||
prevPage = 1
|
|
||||||
}
|
|
||||||
nextPage := page + 1
|
|
||||||
if nextPage > totalPages {
|
|
||||||
nextPage = totalPages
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "社区帖子",
|
|
||||||
"Posts": posts,
|
|
||||||
"Total": total,
|
|
||||||
"Page": page,
|
|
||||||
"TotalPages": totalPages,
|
|
||||||
"PrevPage": prevPage,
|
|
||||||
"NextPage": nextPage,
|
|
||||||
"Keyword": keyword,
|
|
||||||
"ExtraCSS": "/static/css/posts.css",
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowPage 帖子详情页
|
|
||||||
func (ctrl *PostController) ShowPage(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "未找到",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
post, err := ctrl.postService.GetByID(uint(id))
|
|
||||||
if err != nil {
|
|
||||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "未找到",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
uid, role, _ := common.GetGinUser(c)
|
|
||||||
|
|
||||||
// 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态)
|
|
||||||
if post.Status != model.PostStatusApproved && !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
|
||||||
// 未登录用户:引导登录后回到当前帖子
|
|
||||||
if uid == 0 {
|
|
||||||
common.RedirectToLogin(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "未找到",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctrl.applyShortcode(post)
|
|
||||||
|
|
||||||
// Open Graph 社交分享数据
|
|
||||||
ogImage := common.ExtractFirstImage(post.Body)
|
|
||||||
if ogImage != "" && !strings.HasPrefix(ogImage, "http") {
|
|
||||||
prefix := ""
|
|
||||||
if !strings.HasPrefix(ogImage, "/") {
|
|
||||||
prefix = "/"
|
|
||||||
}
|
|
||||||
ogImage = "https://" + c.Request.Host + prefix + ogImage
|
|
||||||
}
|
|
||||||
ogURL := fmt.Sprintf("https://%s/posts/%d", c.Request.Host, id)
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": post.Title,
|
|
||||||
"Post": post,
|
|
||||||
"UID": uid,
|
|
||||||
"StatusNames": common.PostStatusDisplayNames,
|
|
||||||
"ExtraCSS": "/static/css/posts.css",
|
|
||||||
"OgTitle": post.Title,
|
|
||||||
"OgDescription": post.Excerpt,
|
|
||||||
"OgImage": ogImage,
|
|
||||||
"OgURL": ogURL,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPage 发帖页面
|
|
||||||
func (ctrl *PostController) NewPage(c *gin.Context) {
|
|
||||||
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "撰写帖子",
|
|
||||||
"ExtraCSS": "/static/css/posts.css",
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// EditPage 编辑页面
|
|
||||||
func (ctrl *PostController) EditPage(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "未找到",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
post, err := ctrl.postService.GetByID(uint(id))
|
|
||||||
if err != nil || post.Status == model.PostStatusPending || post.IsLocked {
|
|
||||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "未找到或无法编辑",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
uid, role, _ := common.GetGinUser(c)
|
|
||||||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
|
||||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "未找到",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 已通过帖子有待审修订时,编辑页展示待审内容
|
|
||||||
if post.PendingBody != "" {
|
|
||||||
post.Title = post.PendingTitle
|
|
||||||
post.Body = post.PendingBody
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "编辑帖子",
|
|
||||||
"Post": post,
|
|
||||||
"ExtraCSS": "/static/css/posts.css",
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create API 发帖
|
|
||||||
func (ctrl *PostController) Create(c *gin.Context) {
|
|
||||||
uid, _, ok := common.GetGinUser(c)
|
|
||||||
if !ok {
|
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req model.PostCreateRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
post, err := ctrl.postService.Create(uid, req.Title, req.Body)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "发帖失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common.OkWithMessage(c, post, "发布成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update API 编辑帖子
|
|
||||||
func (ctrl *PostController) Update(c *gin.Context) {
|
|
||||||
uid, role, ok := common.GetGinUser(c)
|
|
||||||
if !ok {
|
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req model.PostUpdateRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := ctrl.getPostAndCheckAccess(uint(id), c, uid, role); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil {
|
|
||||||
if errors.Is(err, common.ErrPostCannotEdit) {
|
|
||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "编辑失败")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common.OkMessage(c, "编辑成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete API 删除帖子
|
|
||||||
func (ctrl *PostController) Delete(c *gin.Context) {
|
|
||||||
uid, role, ok := common.GetGinUser(c)
|
|
||||||
if !ok {
|
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := ctrl.getPostAndCheckAccess(uint(id), c, uid, role); !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ctrl.postService.Delete(uint(id)); err != nil {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "删除失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common.OkMessage(c, "删除成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
// getPostAndCheckAccess 获取帖子并检查当前用户权限
|
|
||||||
// 返回 (*model.Post, true) 表示通过;返回 (nil, false) 表示已写入错误响应
|
|
||||||
func (ctrl *PostController) getPostAndCheckAccess(id uint, c *gin.Context, uid uint, role string) (*model.Post, bool) {
|
|
||||||
post, err := ctrl.postService.GetByID(id)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
|
||||||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return post, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Submit API 提交审核
|
|
||||||
func (ctrl *PostController) Submit(c *gin.Context) {
|
|
||||||
uid, _, ok := common.GetGinUser(c)
|
|
||||||
if !ok {
|
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
post, err := ctrl.postService.GetByID(uint(id))
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if post.UserID != uid {
|
|
||||||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil {
|
|
||||||
if errors.Is(err, common.ErrPostCannotSubmit) || errors.Is(err, common.ErrPostNotFound) {
|
|
||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
|
||||||
} else {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "提交审核失败")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common.OkMessage(c, "已提交审核")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAPI 帖子列表 JSON API
|
|
||||||
func (ctrl *PostController) ListAPI(c *gin.Context) {
|
|
||||||
keyword := c.Query("keyword")
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|
||||||
|
|
||||||
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "获取帖子列表失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common.Ok(c, model.PostListResult{
|
|
||||||
Items: posts,
|
|
||||||
Total: total,
|
|
||||||
Page: page,
|
|
||||||
TotalPages: common.PageCount(total, pageSize),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShowAPI 帖子详情 JSON API
|
|
||||||
func (ctrl *PostController) ShowAPI(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
post, err := ctrl.postService.GetByID(uint(id))
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 权限控制:非 approved 帖子仅作者和 moderator+ 可见
|
|
||||||
if post.Status != model.PostStatusApproved {
|
|
||||||
uid, role, _ := common.GetGinUser(c)
|
|
||||||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
|
||||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctrl.applyShortcode(post)
|
|
||||||
|
|
||||||
common.Ok(c, post)
|
|
||||||
}
|
|
||||||
|
|
||||||
// allowedImageExt 允许的图片扩展名
|
// allowedImageExt 允许的图片扩展名
|
||||||
var allowedImageExt = map[string]bool{
|
var allowedImageExt = map[string]bool{
|
||||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadImage 上传帖子图片(需登录,multipart/form-data)
|
// visitorCookieName Cookie 名称
|
||||||
// JPEG/PNG 自动转 WebP quality 80 存储,保留原尺寸不 resize
|
const visitorCookieName = "visitor_id"
|
||||||
func (ctrl *PostController) UploadImage(c *gin.Context) {
|
|
||||||
uid, _, ok := common.GetGinUser(c)
|
|
||||||
if !ok {
|
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
file, header, err := c.Request.FormFile("file")
|
// visitorCookieMaxAge 访客 Cookie 有效期(30 天)
|
||||||
if err != nil {
|
const visitorCookieMaxAge = 30 * 24 * 3600
|
||||||
common.Error(c, http.StatusBadRequest, "请选择文件")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
// 检查扩展名
|
// getVisitorID 获取或创建访客标识 Cookie
|
||||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
func (ctrl *PostController) getVisitorID(c *gin.Context) string {
|
||||||
if !allowedImageExt[ext] {
|
if cookie, err := c.Cookie(visitorCookieName); err == nil && cookie != "" {
|
||||||
common.Error(c, http.StatusBadRequest, "仅支持 jpg / jpeg / png / gif / webp 格式")
|
return cookie
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
// 生成 16 字节随机 hex(32 字符)
|
||||||
// 限制 5MB
|
b := make([]byte, 16)
|
||||||
maxSize := int64(5 << 20)
|
if _, err := rand.Read(b); err != nil {
|
||||||
if header.Size > maxSize {
|
return ""
|
||||||
common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
vid := hex.EncodeToString(b)
|
||||||
// 读取全部数据(后续 WebP 转换需要)
|
c.SetCookie(visitorCookieName, vid, visitorCookieMaxAge, "/", "", false, true)
|
||||||
data, err := io.ReadAll(file)
|
return vid
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "读取文件失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 存储路径
|
|
||||||
storageDir := "storage/uploads/posts"
|
|
||||||
if err := os.MkdirAll(storageDir, 0755); err != nil {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ts := time.Now().UnixMilli()
|
|
||||||
var savePath, url string
|
|
||||||
|
|
||||||
isJPEGPNG := ext == ".jpg" || ext == ".jpeg" || ext == ".png"
|
|
||||||
isWebP := ext == ".webp"
|
|
||||||
isGIF := ext == ".gif"
|
|
||||||
|
|
||||||
// 强制解码验证文件是否为有效图片(防伪扩展名、图片投毒),验证失败直接拒绝
|
|
||||||
// JPEG/PNG:解码后做 WebP 压缩(二分查找质量,目标 ≤ 原文件大小)
|
|
||||||
if isJPEGPNG {
|
|
||||||
img, _, err := image.Decode(bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
webpData := encodeWebPBinary(img, len(data))
|
|
||||||
if webpData != nil && len(webpData) < len(data) {
|
|
||||||
filename := fmt.Sprintf("%d_%d.webp", uid, ts)
|
|
||||||
savePath = filepath.Join(storageDir, filename)
|
|
||||||
if err := os.WriteFile(savePath, webpData, 0644); err == nil {
|
|
||||||
url = "/uploads/posts/" + filename
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GIF:仅解码验证,不转换
|
|
||||||
if isGIF {
|
|
||||||
if _, _, err := image.Decode(bytes.NewReader(data)); err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebP:仅解码验证,不重复编码
|
|
||||||
if isWebP {
|
|
||||||
if _, err := webp.Decode(bytes.NewReader(data)); err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 回退存储:JPEG/PNG 重编码剥离元数据;WebP 已验证直接存;GIF 不重编(丢动画)
|
|
||||||
if savePath == "" {
|
|
||||||
dataOut := data
|
|
||||||
|
|
||||||
if isJPEGPNG {
|
|
||||||
decImg, _, decErr := image.Decode(bytes.NewReader(data))
|
|
||||||
if decErr == nil {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
var encErr error
|
|
||||||
if ext == ".png" {
|
|
||||||
encErr = png.Encode(&buf, decImg)
|
|
||||||
} else {
|
|
||||||
encErr = jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
|
|
||||||
}
|
|
||||||
if encErr == nil && buf.Len() > 0 {
|
|
||||||
dataOut = buf.Bytes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
|
|
||||||
savePath = filepath.Join(storageDir, filename)
|
|
||||||
if err := os.WriteFile(savePath, dataOut, 0644); err != nil {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "保存图片失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
url = "/uploads/posts/" + filename
|
|
||||||
}
|
|
||||||
|
|
||||||
common.VditorUploadOk(c, map[string]string{header.Filename: url})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyShortcode 处理帖子正文中的 shortcode 标记,替换为 HTML 占位符
|
// applyShortcode 处理帖子正文中的 shortcode 标记,替换为 HTML 占位符
|
||||||
@ -523,4 +87,3 @@ func encodeWebPBinary(img image.Image, targetBytes int) []byte {
|
|||||||
}
|
}
|
||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
124
internal/controller/post_page_controller.go
Normal file
124
internal/controller/post_page_controller.go
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListPage 帖子列表页
|
||||||
|
func (ctrl *PostController) ListPage(c *gin.Context) {
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "社区帖子",
|
||||||
|
"Error": "加载帖子列表失败",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages := common.PageCount(total, pageSize)
|
||||||
|
prevPage := page - 1
|
||||||
|
if prevPage < 1 {
|
||||||
|
prevPage = 1
|
||||||
|
}
|
||||||
|
nextPage := page + 1
|
||||||
|
if nextPage > totalPages {
|
||||||
|
nextPage = totalPages
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "社区帖子",
|
||||||
|
"Posts": posts,
|
||||||
|
"Total": total,
|
||||||
|
"Page": page,
|
||||||
|
"TotalPages": totalPages,
|
||||||
|
"PrevPage": prevPage,
|
||||||
|
"NextPage": nextPage,
|
||||||
|
"Keyword": keyword,
|
||||||
|
"ExtraCSS": "/static/css/posts.css",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowPage 帖子详情页
|
||||||
|
func (ctrl *PostController) ShowPage(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "未找到",
|
||||||
|
"ExtraCSS": "/static/css/posts.css",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := ctrl.postService.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "未找到",
|
||||||
|
"ExtraCSS": "/static/css/posts.css",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uid, role, _ := common.GetGinUser(c)
|
||||||
|
|
||||||
|
// 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态)
|
||||||
|
if post.Status != model.PostStatusApproved && !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||||
|
// 未登录用户:引导登录后回到当前帖子
|
||||||
|
if uid == 0 {
|
||||||
|
common.RedirectToLogin(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "未找到",
|
||||||
|
"ExtraCSS": "/static/css/posts.css",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录用户阅读计数(已发布帖子,自动去重+防刷)
|
||||||
|
if uid > 0 && post.Status == model.PostStatusApproved {
|
||||||
|
ctrl.postService.RecordRead(uid, post.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 访客阅读计数(基于 Cookie 标识去重+防刷)
|
||||||
|
if uid == 0 && post.Status == model.PostStatusApproved {
|
||||||
|
vid := ctrl.getVisitorID(c)
|
||||||
|
ctrl.postService.RecordGuestRead(vid, post.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctrl.applyShortcode(post)
|
||||||
|
|
||||||
|
// Open Graph 社交分享数据
|
||||||
|
ogImage := common.ExtractFirstImage(post.Body)
|
||||||
|
if ogImage != "" && !strings.HasPrefix(ogImage, "http") {
|
||||||
|
prefix := ""
|
||||||
|
if !strings.HasPrefix(ogImage, "/") {
|
||||||
|
prefix = "/"
|
||||||
|
}
|
||||||
|
ogImage = "https://" + c.Request.Host + prefix + ogImage
|
||||||
|
}
|
||||||
|
ogURL := fmt.Sprintf("https://%s/posts/%d", c.Request.Host, id)
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": post.Title,
|
||||||
|
"Post": post,
|
||||||
|
"UID": uid,
|
||||||
|
"StatusNames": common.PostStatusDisplayNames,
|
||||||
|
"ExtraCSS": "/static/css/posts.css",
|
||||||
|
"OgTitle": post.Title,
|
||||||
|
"OgDescription": post.Excerpt,
|
||||||
|
"OgImage": ogImage,
|
||||||
|
"OgURL": ogURL,
|
||||||
|
}))
|
||||||
|
}
|
||||||
138
internal/controller/post_upload_controller.go
Normal file
138
internal/controller/post_upload_controller.go
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/gif" // 仅注册 GIF 解码器,不重编码(会丢失动画)
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
|
||||||
|
"github.com/chai2010/webp"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UploadImage 上传帖子图片(需登录,multipart/form-data)
|
||||||
|
// JPEG/PNG 自动转 WebP quality 80 存储,保留原尺寸不 resize
|
||||||
|
func (ctrl *PostController) UploadImage(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "请选择文件")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// 检查扩展名
|
||||||
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||||
|
if !allowedImageExt[ext] {
|
||||||
|
common.Error(c, http.StatusBadRequest, "仅支持 jpg / jpeg / png / gif / webp 格式")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制 5MB
|
||||||
|
maxSize := int64(5 << 20)
|
||||||
|
if header.Size > maxSize {
|
||||||
|
common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取全部数据(后续 WebP 转换需要)
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "读取文件失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储路径
|
||||||
|
storageDir := "storage/uploads/posts"
|
||||||
|
if err := os.MkdirAll(storageDir, 0755); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := time.Now().UnixMilli()
|
||||||
|
var savePath, url string
|
||||||
|
|
||||||
|
isJPEGPNG := ext == ".jpg" || ext == ".jpeg" || ext == ".png"
|
||||||
|
isWebP := ext == ".webp"
|
||||||
|
isGIF := ext == ".gif"
|
||||||
|
|
||||||
|
// 强制解码验证文件是否为有效图片(防伪扩展名、图片投毒),验证失败直接拒绝
|
||||||
|
// JPEG/PNG:解码后做 WebP 压缩(二分查找质量,目标 ≤ 原文件大小)
|
||||||
|
if isJPEGPNG {
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webpData := encodeWebPBinary(img, len(data))
|
||||||
|
if webpData != nil && len(webpData) < len(data) {
|
||||||
|
filename := fmt.Sprintf("%d_%d.webp", uid, ts)
|
||||||
|
savePath = filepath.Join(storageDir, filename)
|
||||||
|
if err := os.WriteFile(savePath, webpData, 0644); err == nil {
|
||||||
|
url = "/uploads/posts/" + filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GIF:仅解码验证,不转换
|
||||||
|
if isGIF {
|
||||||
|
if _, _, err := image.Decode(bytes.NewReader(data)); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebP:仅解码验证,不重复编码
|
||||||
|
if isWebP {
|
||||||
|
if _, err := webp.Decode(bytes.NewReader(data)); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退存储:JPEG/PNG 重编码剥离元数据;WebP 已验证直接存;GIF 不重编(丢动画)
|
||||||
|
if savePath == "" {
|
||||||
|
dataOut := data
|
||||||
|
|
||||||
|
if isJPEGPNG {
|
||||||
|
decImg, _, decErr := image.Decode(bytes.NewReader(data))
|
||||||
|
if decErr == nil {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
var encErr error
|
||||||
|
if ext == ".png" {
|
||||||
|
encErr = png.Encode(&buf, decImg)
|
||||||
|
} else {
|
||||||
|
encErr = jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
|
||||||
|
}
|
||||||
|
if encErr == nil && buf.Len() > 0 {
|
||||||
|
dataOut = buf.Bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
|
||||||
|
savePath = filepath.Join(storageDir, filename)
|
||||||
|
if err := os.WriteFile(savePath, dataOut, 0644); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "保存图片失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
url = "/uploads/posts/" + filename
|
||||||
|
}
|
||||||
|
|
||||||
|
common.VditorUploadOk(c, map[string]string{header.Filename: url})
|
||||||
|
}
|
||||||
108
internal/controller/reaction_controller.go
Normal file
108
internal/controller/reaction_controller.go
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReactionController 赞/踩 API 控制器
|
||||||
|
type ReactionController struct {
|
||||||
|
reactionSvc reactionUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewReactionController 构造函数
|
||||||
|
func NewReactionController(reactionSvc reactionUseCase) *ReactionController {
|
||||||
|
return &ReactionController{reactionSvc: reactionSvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleLike 切换点赞 POST /api/posts/:id/like
|
||||||
|
func (rc *ReactionController) ToggleLike(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的文章ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLiked, err := rc.reactionSvc.ToggleLike(uid, uint(postID))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最新点赞数
|
||||||
|
likesCount, _ := rc.reactionSvc.GetPostLikesCount(uint(postID))
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"liked": isLiked,
|
||||||
|
"likes_count": likesCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleDislike 切换踩 POST /api/posts/:id/dislike
|
||||||
|
func (rc *ReactionController) ToggleDislike(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的文章ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isDisliked, err := rc.reactionSvc.ToggleDislike(uid, uint(postID))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"disliked": isDisliked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReaction 查询当前用户对文章的反应状态 GET /api/posts/:id/reaction
|
||||||
|
func (rc *ReactionController) GetReaction(c *gin.Context) {
|
||||||
|
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的文章ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未登录用户返回 none + 公开计数
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
likesCount, _ := rc.reactionSvc.GetPostLikesCount(uint(postID))
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"reaction": string(service.ReactionNone),
|
||||||
|
"likes_count": likesCount,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reaction, err := rc.reactionSvc.GetReaction(uid, uint(postID))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
likesCount, _ := rc.reactionSvc.GetPostLikesCount(uint(postID))
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"reaction": string(reaction),
|
||||||
|
"likes_count": likesCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
28
internal/controller/settings_api_audit.go
Normal file
28
internal/controller/settings_api_audit.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditStatus 查询当前用户的待审核类型(需登录)
|
||||||
|
func (sc *SettingsController) AuditStatus(c *gin.Context) {
|
||||||
|
uid, exists := c.Get("uid")
|
||||||
|
if !exists {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
types, err := sc.auditService.GetPendingTypes(uid.(uint))
|
||||||
|
if err != nil {
|
||||||
|
common.Ok(c, gin.H{"pending_types": []string{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if types == nil {
|
||||||
|
types = []string{}
|
||||||
|
}
|
||||||
|
common.Ok(c, gin.H{"pending_types": types})
|
||||||
|
}
|
||||||
79
internal/controller/settings_api_notify.go
Normal file
79
internal/controller/settings_api_notify.go
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetNotifyPrefReader 注入通知偏好读写器
|
||||||
|
func (sc *SettingsController) SetNotifyPrefReader(reader notifyPrefReader) {
|
||||||
|
sc.notifyPrefReader = reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotifyPrefs 获取通知偏好 GET /api/settings/notify-prefs
|
||||||
|
func (sc *SettingsController) GetNotifyPrefs(c *gin.Context) {
|
||||||
|
uid, exists := c.Get("uid")
|
||||||
|
if !exists {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if sc.notifyPrefReader == nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "服务不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs, err := sc.notifyPrefReader.GetNotifyPrefs(uid.(uint))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, prefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNotifyPref 更新通知偏好 PUT /api/settings/notify-prefs
|
||||||
|
func (sc *SettingsController) UpdateNotifyPref(c *gin.Context) {
|
||||||
|
uid, exists := c.Get("uid")
|
||||||
|
if !exists {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if sc.notifyPrefReader == nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "服务不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 key 是否合法
|
||||||
|
allowedKeys := map[string]bool{
|
||||||
|
model.NotifyComment: true,
|
||||||
|
model.NotifyCommentReply: true,
|
||||||
|
model.NotifyLikeAggregated: true,
|
||||||
|
model.NotifyFollow: true,
|
||||||
|
}
|
||||||
|
if !allowedKeys[req.Key] {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的通知类型")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sc.notifyPrefReader.UpdateNotifyPref(uid.(uint), req.Key, req.Enabled); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "更新失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "通知偏好已更新")
|
||||||
|
}
|
||||||
@ -43,6 +43,23 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
|
|||||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 改名:首次免费,非首次扣 6 域能
|
||||||
|
if req.Username != "" && req.Username != user.Username && sc.energySvc != nil {
|
||||||
|
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
|
||||||
|
if hasCompleted {
|
||||||
|
if err := sc.energySvc.DeductOnRename(userID); err != nil {
|
||||||
|
switch err {
|
||||||
|
case common.ErrInsufficientEnergy:
|
||||||
|
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
|
||||||
|
default:
|
||||||
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := sc.auditService.SubmitProfileChanges(userID, user, req.Username, req.Bio); err != nil {
|
if err := sc.auditService.SubmitProfileChanges(userID, user, req.Username, req.Bio); err != nil {
|
||||||
handleAuditSubmitError(c, err)
|
handleAuditSubmitError(c, err)
|
||||||
return
|
return
|
||||||
@ -52,11 +69,42 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 管理员及以上直接更新
|
// 管理员及以上直接更新
|
||||||
|
// 先获取当前用户信息,判断是否改名
|
||||||
|
currentUser, err := sc.authService.GetProfile(userID)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改名:首次免费,非首次扣 6 域能
|
||||||
|
if req.Username != "" && req.Username != currentUser.Username && sc.energySvc != nil {
|
||||||
|
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
|
||||||
|
if hasCompleted {
|
||||||
|
if err := sc.energySvc.DeductOnRename(userID); err != nil {
|
||||||
|
switch err {
|
||||||
|
case common.ErrInsufficientEnergy:
|
||||||
|
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
|
||||||
|
default:
|
||||||
|
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := sc.authService.UpdateProfile(userID, req.Username, req.Bio); err != nil {
|
if err := sc.authService.UpdateProfile(userID, req.Username, req.Bio); err != nil {
|
||||||
handleSettingsError(c, err)
|
handleSettingsError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 触发首次任务奖励(静默失败不影响主流程)
|
||||||
|
if req.Username != "" {
|
||||||
|
_, _, _ = sc.levelSvc.CompleteTask(userID, "username")
|
||||||
|
}
|
||||||
|
if req.Bio != "" {
|
||||||
|
_, _, _ = sc.levelSvc.CompleteTask(userID, "bio")
|
||||||
|
}
|
||||||
|
|
||||||
common.OkMessage(c, "个人资料已更新")
|
common.OkMessage(c, "个人资料已更新")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,5 +160,8 @@ func (sc *SettingsController) UploadAvatar(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 触发首次上传头像奖励
|
||||||
|
_, _, _ = sc.levelSvc.CompleteTask(userID, "avatar")
|
||||||
|
|
||||||
common.Ok(c, gin.H{"url": url})
|
common.Ok(c, gin.H{"url": url})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -12,34 +11,6 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// profileProvider SettingsController 对 Service 层的最小依赖(ISP:4 个方法)
|
|
||||||
type profileProvider interface {
|
|
||||||
GetProfile(userID uint) (*model.User, error)
|
|
||||||
UpdateProfile(userID uint, username, bio string) error
|
|
||||||
ChangePassword(userID uint, currentPassword, newPassword string) error
|
|
||||||
DeleteAccount(userID uint, password, reason string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// avatarProvider 头像上传对 Service 层的最小依赖(ISP:2 个方法)
|
|
||||||
type avatarProvider interface {
|
|
||||||
ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
|
|
||||||
ProcessImage(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// auditSubmittable 个人设置对审核服务的依赖(ISP:4 个方法)
|
|
||||||
type auditSubmittable interface {
|
|
||||||
ShouldAudit(userID uint) (bool, error)
|
|
||||||
SubmitProfileChanges(userID uint, currentUser *model.User, newUsername, newBio string) error
|
|
||||||
Submit(userID uint, auditType, newValue string) error
|
|
||||||
GetPendingTypes(userID uint) ([]string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionConfig 登录管理对配置的最小依赖(ISP:2 个方法)
|
|
||||||
type sessionConfig interface {
|
|
||||||
GetIdleTimeout() int // 分钟
|
|
||||||
GetRememberTimeout() int // 分钟
|
|
||||||
}
|
|
||||||
|
|
||||||
// SettingsController 个人设置控制器
|
// SettingsController 个人设置控制器
|
||||||
type SettingsController struct {
|
type SettingsController struct {
|
||||||
authService profileProvider
|
authService profileProvider
|
||||||
@ -47,11 +18,20 @@ type SettingsController struct {
|
|||||||
auditService auditSubmittable
|
auditService auditSubmittable
|
||||||
sessionMgr sessionManager
|
sessionMgr sessionManager
|
||||||
sessionCfg sessionConfig
|
sessionCfg sessionConfig
|
||||||
|
levelSvc taskCompleter
|
||||||
|
energySvc energyRenameHandler
|
||||||
|
notifyPrefReader notifyPrefReader
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSettingsController 构造函数
|
// NewSettingsController 构造函数
|
||||||
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable, sessionMgr sessionManager, sessionCfg sessionConfig) *SettingsController {
|
func NewSettingsController(authService profileProvider, avatarService avatarProvider, auditService auditSubmittable, sessionMgr sessionManager, sessionCfg sessionConfig, levelSvc taskCompleter) *SettingsController {
|
||||||
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr, sessionCfg: sessionCfg}
|
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr, sessionCfg: sessionCfg, levelSvc: levelSvc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithEnergyService 链式注入域能服务
|
||||||
|
func (sc *SettingsController) WithEnergyService(svc energyRenameHandler) *SettingsController {
|
||||||
|
sc.energySvc = svc
|
||||||
|
return sc
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingsPage 个人设置页面(需登录)
|
// SettingsPage 个人设置页面(需登录)
|
||||||
@ -64,7 +44,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
|||||||
uid := uidVal.(uint)
|
uid := uidVal.(uint)
|
||||||
|
|
||||||
tab := c.Param("tab")
|
tab := c.Param("tab")
|
||||||
if tab != "profile" && tab != "account" && tab != "sessions" {
|
if tab != "profile" && tab != "account" && tab != "sessions" && tab != "energy" && tab != "favorites" && tab != "notify" {
|
||||||
c.String(http.StatusNotFound, "页面不存在")
|
c.String(http.StatusNotFound, "页面不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -78,6 +58,13 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
|||||||
// 查询待审核类型(用于前端显示审核提示)
|
// 查询待审核类型(用于前端显示审核提示)
|
||||||
pendingTypes, _ := sc.auditService.GetPendingTypes(uid)
|
pendingTypes, _ := sc.auditService.GetPendingTypes(uid)
|
||||||
|
|
||||||
|
// 是否已完成首次改名(用于前端确认弹窗显示消耗提示)
|
||||||
|
hasCompletedRename := false
|
||||||
|
if sc.levelSvc != nil {
|
||||||
|
completed, _ := sc.levelSvc.HasCompletedTask(uid, "username")
|
||||||
|
hasCompletedRename = completed
|
||||||
|
}
|
||||||
|
|
||||||
data := gin.H{
|
data := gin.H{
|
||||||
"Title": "个人设置",
|
"Title": "个人设置",
|
||||||
"ExtraCSS": "/static/css/settings.css",
|
"ExtraCSS": "/static/css/settings.css",
|
||||||
@ -87,6 +74,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
|||||||
"StatusName": model.StatusDisplayNames[user.Status],
|
"StatusName": model.StatusDisplayNames[user.Status],
|
||||||
"PendingTypes": pendingTypes,
|
"PendingTypes": pendingTypes,
|
||||||
"AuditTypes": model.AuditTypeNames,
|
"AuditTypes": model.AuditTypeNames,
|
||||||
|
"HasCompletedRename": hasCompletedRename,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录管理 tab 需要额外数据
|
// 登录管理 tab 需要额外数据
|
||||||
@ -127,22 +115,3 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
|||||||
|
|
||||||
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, data))
|
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditStatus 查询当前用户的待审核类型(需登录)
|
|
||||||
func (sc *SettingsController) AuditStatus(c *gin.Context) {
|
|
||||||
uid, exists := c.Get("uid")
|
|
||||||
if !exists {
|
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
types, err := sc.auditService.GetPendingTypes(uid.(uint))
|
|
||||||
if err != nil {
|
|
||||||
common.Ok(c, gin.H{"pending_types": []string{}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if types == nil {
|
|
||||||
types = []string{}
|
|
||||||
}
|
|
||||||
common.Ok(c, gin.H{"pending_types": types})
|
|
||||||
}
|
|
||||||
|
|||||||
@ -9,9 +9,11 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SpaceController 用户空间控制器
|
// SpaceController 用户空间控制器(统一页面:文章/关注/粉丝/收藏/设置)
|
||||||
type SpaceController struct {
|
type SpaceController struct {
|
||||||
spaceService spaceUseCase
|
spaceService spaceUseCase
|
||||||
|
followSvc followUseCase
|
||||||
|
favoriteSvc favoriteUseCaseForSpace
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSpaceController 构造函数
|
// NewSpaceController 构造函数
|
||||||
@ -19,6 +21,16 @@ func NewSpaceController(spaceService spaceUseCase) *SpaceController {
|
|||||||
return &SpaceController{spaceService: spaceService}
|
return &SpaceController{spaceService: spaceService}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithFollowService 注入关注服务(可选依赖)
|
||||||
|
func (ctrl *SpaceController) WithFollowService(svc followUseCase) {
|
||||||
|
ctrl.followSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithFavoriteService 注入收藏夹服务(可选依赖)
|
||||||
|
func (ctrl *SpaceController) WithFavoriteService(svc favoriteUseCaseForSpace) {
|
||||||
|
ctrl.favoriteSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
// MySpace 自己的空间(/space)— 需登录,重定向到 /space/{uid}
|
// MySpace 自己的空间(/space)— 需登录,重定向到 /space/{uid}
|
||||||
func (ctrl *SpaceController) MySpace(c *gin.Context) {
|
func (ctrl *SpaceController) MySpace(c *gin.Context) {
|
||||||
uid, _, ok := common.GetGinUser(c)
|
uid, _, ok := common.GetGinUser(c)
|
||||||
@ -29,7 +41,7 @@ func (ctrl *SpaceController) MySpace(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/space/"+strconv.FormatUint(uint64(uid), 10))
|
c.Redirect(http.StatusFound, "/space/"+strconv.FormatUint(uint64(uid), 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShowSpace 查看他人或自己的空间(/space/:uid)— 无需认证
|
// ShowSpace 统一空间页面(/space/:uid?tab=articles|following|followers|collections|settings)
|
||||||
func (ctrl *SpaceController) ShowSpace(c *gin.Context) {
|
func (ctrl *SpaceController) ShowSpace(c *gin.Context) {
|
||||||
uidStr := c.Param("uid")
|
uidStr := c.Param("uid")
|
||||||
uid, err := strconv.ParseUint(uidStr, 10, 64)
|
uid, err := strconv.ParseUint(uidStr, 10, 64)
|
||||||
@ -52,52 +64,58 @@ func (ctrl *SpaceController) ShowSpace(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页
|
// 当前登录用户
|
||||||
|
currentUID, _, _ := common.GetGinUser(c)
|
||||||
|
isOwnSpace := currentUID == uint(uid)
|
||||||
|
|
||||||
|
// 当前激活的 Tab
|
||||||
|
tab := c.DefaultQuery("tab", "articles")
|
||||||
|
|
||||||
|
// 分页参数
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
if page < 1 {
|
if page < 1 { page = 1 }
|
||||||
page = 1
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "18"))
|
||||||
}
|
if pageSize < 1 || pageSize > 50 { pageSize = 18 }
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
|
||||||
if pageSize < 1 || pageSize > 50 {
|
|
||||||
pageSize = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
posts, total, err := ctrl.spaceService.GetPostsByUser(uint(uid), page, pageSize)
|
// 构建基础数据(含用户统计)
|
||||||
if err != nil {
|
postCount, _ := ctrl.spaceService.CountUserPosts(uint(uid))
|
||||||
c.HTML(http.StatusInternalServerError, "space/index.html", common.BuildPageData(c, gin.H{
|
totalLikes, totalFavorites, totalReads, _ := ctrl.spaceService.GetUserStats(uint(uid))
|
||||||
"Title": "加载失败",
|
|
||||||
"Error": "加载帖子失败,请稍后重试",
|
|
||||||
"ExtraCSS": "/static/css/space.css",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
totalPages := common.PageCount(total, pageSize)
|
data := gin.H{
|
||||||
prevPage := page - 1
|
|
||||||
if prevPage < 1 {
|
|
||||||
prevPage = 1
|
|
||||||
}
|
|
||||||
nextPage := page + 1
|
|
||||||
if nextPage > totalPages {
|
|
||||||
nextPage = totalPages
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否是自己的空间
|
|
||||||
isOwnSpace := false
|
|
||||||
if currentUID, _, ok := common.GetGinUser(c); ok {
|
|
||||||
isOwnSpace = currentUID == uint(uid)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "space/index.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": spaceUser.Username + " 的空间",
|
"Title": spaceUser.Username + " 的空间",
|
||||||
"SpaceUser": spaceUser,
|
"SpaceUser": spaceUser,
|
||||||
"Posts": posts,
|
|
||||||
"Total": total,
|
|
||||||
"Page": page,
|
|
||||||
"TotalPages": totalPages,
|
|
||||||
"PrevPage": prevPage,
|
|
||||||
"NextPage": nextPage,
|
|
||||||
"IsOwnSpace": isOwnSpace,
|
"IsOwnSpace": isOwnSpace,
|
||||||
|
"ActiveTab": tab,
|
||||||
|
"CurrentUID": currentUID,
|
||||||
"ExtraCSS": "/static/css/space.css",
|
"ExtraCSS": "/static/css/space.css",
|
||||||
}))
|
"PostCount": postCount,
|
||||||
|
"TotalLikes": totalLikes,
|
||||||
|
"TotalFavorites": totalFavorites,
|
||||||
|
"TotalReads": totalReads,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 根据 Tab 加载对应数据 ----
|
||||||
|
switch tab {
|
||||||
|
case "following":
|
||||||
|
ctrl.loadFollowingData(c, data, uint(uid), currentUID, page, pageSize)
|
||||||
|
case "followers":
|
||||||
|
ctrl.loadFollowersData(c, data, uint(uid), currentUID, page, pageSize)
|
||||||
|
case "collections":
|
||||||
|
if !isOwnSpace {
|
||||||
|
// 访客不可看收藏夹,重定向到文章
|
||||||
|
c.Redirect(http.StatusFound, "/space/"+uidStr+"?tab=articles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctrl.loadCollectionsData(c, data, currentUID, page, pageSize)
|
||||||
|
case "settings":
|
||||||
|
if !isOwnSpace {
|
||||||
|
c.Redirect(http.StatusFound, "/space/"+uidStr+"?tab=articles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 设置页无需额外数据,模板中直接渲染隐私开关
|
||||||
|
default: // articles
|
||||||
|
ctrl.loadArticlesData(c, data, uint(uid), page, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "space/index.html", common.BuildPageData(c, data))
|
||||||
}
|
}
|
||||||
|
|||||||
162
internal/controller/space_loaders.go
Normal file
162
internal/controller/space_loaders.go
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
"metazone.cc/metalab/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadArticlesData 加载文章列表数据
|
||||||
|
func (ctrl *SpaceController) loadArticlesData(c *gin.Context, data gin.H, uid uint, page, pageSize int) {
|
||||||
|
posts, total, err := ctrl.spaceService.GetPostsByUser(uid, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
posts = []model.Post{}
|
||||||
|
total = 0
|
||||||
|
}
|
||||||
|
totalPages := common.PageCount(total, pageSize)
|
||||||
|
data["Posts"] = posts
|
||||||
|
data["Total"] = total
|
||||||
|
data["Page"] = page
|
||||||
|
data["TotalPages"] = totalPages
|
||||||
|
data["PrevPage"] = max(1, page-1)
|
||||||
|
data["NextPage"] = min(totalPages, page+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadFollowingData 加载关注列表数据
|
||||||
|
func (ctrl *SpaceController) loadFollowingData(c *gin.Context, data gin.H, targetUID, currentUID uint, page, pageSize int) {
|
||||||
|
if ctrl.followSvc == nil {
|
||||||
|
data["FollowingResult"] = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := ctrl.followSvc.ListFollowing(targetUID, currentUID, page, pageSize)
|
||||||
|
if err != nil || res == nil {
|
||||||
|
res = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
||||||
|
}
|
||||||
|
data["FollowingResult"] = res
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadFollowersData 加载粉丝列表数据
|
||||||
|
func (ctrl *SpaceController) loadFollowersData(c *gin.Context, data gin.H, targetUID, currentUID uint, page, pageSize int) {
|
||||||
|
if ctrl.followSvc == nil {
|
||||||
|
data["FollowersResult"] = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := ctrl.followSvc.ListFollowers(targetUID, currentUID, page, pageSize)
|
||||||
|
if err != nil || res == nil {
|
||||||
|
res = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
|
||||||
|
}
|
||||||
|
data["FollowersResult"] = res
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadCollectionsData 加载收藏夹数据
|
||||||
|
func (ctrl *SpaceController) loadCollectionsData(c *gin.Context, data gin.H, uid uint, page, pageSize int) {
|
||||||
|
if ctrl.favoriteSvc == nil {
|
||||||
|
data["FoldersResult"] = &service.FavoriteListResult{Folders: []model.Folder{}}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收藏夹列表
|
||||||
|
folders, err := ctrl.favoriteSvc.ListFolders(uid)
|
||||||
|
if err != nil || folders == nil {
|
||||||
|
folders = &service.FavoriteListResult{Folders: []model.Folder{}}
|
||||||
|
}
|
||||||
|
data["FoldersResult"] = folders
|
||||||
|
|
||||||
|
// 默认展示第一个收藏夹的内容
|
||||||
|
folderParam := c.DefaultQuery("folder", "")
|
||||||
|
var activeFolderID uint
|
||||||
|
if folderParam != "" {
|
||||||
|
fid, parseErr := strconv.ParseUint(folderParam, 10, 64)
|
||||||
|
if parseErr == nil {
|
||||||
|
activeFolderID = uint(fid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if activeFolderID == 0 && len(folders.Folders) > 0 {
|
||||||
|
activeFolderID = folders.Folders[0].ID
|
||||||
|
}
|
||||||
|
|
||||||
|
var folderItems *service.FolderItemsResult
|
||||||
|
if activeFolderID > 0 {
|
||||||
|
items, itemErr := ctrl.favoriteSvc.ListFolderItems(uid, activeFolderID, page, pageSize)
|
||||||
|
if itemErr != nil || items == nil {
|
||||||
|
folderItems = &service.FolderItemsResult{Items: []model.FolderItem{}, Total: 0}
|
||||||
|
} else {
|
||||||
|
folderItems = items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if folderItems == nil {
|
||||||
|
folderItems = &service.FolderItemsResult{Items: []model.FolderItem{}, Total: 0}
|
||||||
|
}
|
||||||
|
data["FolderItems"] = folderItems
|
||||||
|
data["ActiveFolderID"] = activeFolderID
|
||||||
|
|
||||||
|
// 找到当前激活的收藏夹对象传给模板
|
||||||
|
var activeFolder *model.Folder
|
||||||
|
for i := range folders.Folders {
|
||||||
|
if folders.Folders[i].ID == activeFolderID {
|
||||||
|
activeFolder = &folders.Folders[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data["ActiveFolder"] = activeFolder
|
||||||
|
|
||||||
|
data["Page"] = page
|
||||||
|
data["TotalPages"] = folderItems.TotalPages
|
||||||
|
data["PrevPage"] = max(1, page-1)
|
||||||
|
data["NextPage"] = min(folderItems.TotalPages, page+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePrivacy PUT /api/space/privacy — 更新隐私设置
|
||||||
|
func (ctrl *SpaceController) UpdatePrivacy(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
Value bool `json:"value"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持的字段映射(仅处理已实现的字段)
|
||||||
|
allowedFields := map[string]string{
|
||||||
|
"follow_list": "follow_list_public",
|
||||||
|
"follower_list": "follower_list_public",
|
||||||
|
}
|
||||||
|
|
||||||
|
column, ok := allowedFields[req.Field]
|
||||||
|
if !ok {
|
||||||
|
common.OkMessage(c, "已保存") // 未实现字段直接返回成功,前端不感知
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := ctrl.spaceService.GetSpaceUser(uid)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
common.Error(c, http.StatusNotFound, "用户不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch column {
|
||||||
|
case "follow_list_public":
|
||||||
|
user.FollowListPublic = req.Value
|
||||||
|
case "follower_list_public":
|
||||||
|
user.FollowerListPublic = req.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.spaceService.UpdateUser(user); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "保存失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "隐私设置已更新")
|
||||||
|
}
|
||||||
133
internal/controller/studio_action_controller.go
Normal file
133
internal/controller/studio_action_controller.go
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delete 删除帖子
|
||||||
|
func (ctrl *StudioController) Delete(c *gin.Context) {
|
||||||
|
uid, role, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
post, getErr := ctrl.postService.GetByID(uint(id))
|
||||||
|
if getErr != nil {
|
||||||
|
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||||
|
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authorID := post.UserID
|
||||||
|
|
||||||
|
if err := ctrl.postService.Delete(uint(id)); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "删除失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删稿扣作者域能(无视负数,有限重试后静默忽略)
|
||||||
|
if ctrl.energySvc != nil {
|
||||||
|
const maxRetries = 3
|
||||||
|
backoff := 100 * time.Millisecond
|
||||||
|
for i := 0; i < maxRetries; i++ {
|
||||||
|
if err := ctrl.energySvc.DeductOnDeletePost(authorID, uint(id)); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if i < maxRetries-1 {
|
||||||
|
time.Sleep(backoff)
|
||||||
|
backoff *= 2
|
||||||
|
} else {
|
||||||
|
log.Printf("删稿扣域能失败(user=%d, post=%d): %v", authorID, id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "删除成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit 提交审核
|
||||||
|
func (ctrl *StudioController) Submit(c *gin.Context) {
|
||||||
|
uid, role, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
post, getErr := ctrl.postService.GetByID(uint(id))
|
||||||
|
if getErr != nil {
|
||||||
|
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||||
|
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "已提交审核")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrendsAPI 创作中心趋势数据 API GET /api/studio/trends?period=7d|30d|90d
|
||||||
|
func (ctrl *StudioController) TrendsAPI(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctrl.trendsSvc == nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "趋势服务不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
period := c.DefaultQuery("period", "7d")
|
||||||
|
var days int
|
||||||
|
switch period {
|
||||||
|
case "7d":
|
||||||
|
days = 7
|
||||||
|
case "30d":
|
||||||
|
days = 30
|
||||||
|
case "90d":
|
||||||
|
days = 90
|
||||||
|
default:
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的时间范围,支持 7d/30d/90d")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
since := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
|
||||||
|
|
||||||
|
result, err := ctrl.trendsSvc.GetTrends(uid, since)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取趋势数据失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, result)
|
||||||
|
}
|
||||||
54
internal/controller/studio_api_controller.go
Normal file
54
internal/controller/studio_api_controller.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OverviewAPI 创作中心数据概览
|
||||||
|
func (ctrl *StudioController) OverviewAPI(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
overview, err := ctrl.postService.GetOverview(uid)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取数据失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, overview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPostsAPI 我的帖子列表(支持状态筛选)
|
||||||
|
func (ctrl *StudioController) ListPostsAPI(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := c.Query("status")
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
posts, total, err := ctrl.postService.ListByUser(uid, status, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"items": posts,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
"total_pages": common.PageCount(total, pageSize),
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -1,18 +1,19 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"metazone.cc/metalab/internal/service"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
|
||||||
"metazone.cc/metalab/internal/model"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// trendsUseCase StudioController 对趋势服务的最小依赖(ISP)
|
||||||
|
type trendsUseCase interface {
|
||||||
|
GetTrends(userID uint, since string) (*service.TrendResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
// StudioController 创作中心控制器(SSR 页面 + API)
|
// StudioController 创作中心控制器(SSR 页面 + API)
|
||||||
type StudioController struct {
|
type StudioController struct {
|
||||||
postService studioUseCase
|
postService studioUseCase
|
||||||
|
energySvc energyDeducter
|
||||||
|
trendsSvc trendsUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStudioController 构造函数
|
// NewStudioController 构造函数
|
||||||
@ -20,349 +21,14 @@ func NewStudioController(ps studioUseCase) *StudioController {
|
|||||||
return &StudioController{postService: ps}
|
return &StudioController{postService: ps}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==============================
|
// WithEnergyService 链式注入域能服务
|
||||||
// SSR 页面方法
|
func (ctrl *StudioController) WithEnergyService(svc energyDeducter) *StudioController {
|
||||||
// ==============================
|
ctrl.energySvc = svc
|
||||||
|
return ctrl
|
||||||
// OverviewPage 创作中心首页(数据概览)
|
|
||||||
func (ctrl *StudioController) OverviewPage(c *gin.Context) {
|
|
||||||
uid, _, ok := common.GetGinUser(c)
|
|
||||||
if !ok {
|
|
||||||
common.RedirectToLogin(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
overview, err := ctrl.postService.GetOverview(uid)
|
|
||||||
if err != nil {
|
|
||||||
c.HTML(http.StatusOK, "studio/overview.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "创作中心",
|
|
||||||
"Error": "加载数据失败",
|
|
||||||
"ActiveTab": "overview",
|
|
||||||
"ExtraCSS": "/static/css/studio.css",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "studio/overview.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "创作中心",
|
|
||||||
"Overview": overview,
|
|
||||||
"StatusNames": common.PostStatusDisplayNames,
|
|
||||||
"ActiveTab": "overview",
|
|
||||||
"ExtraCSS": "/static/css/studio.css",
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostsPage 内容管理页
|
// WithTrendsService 链式注入趋势服务
|
||||||
func (ctrl *StudioController) PostsPage(c *gin.Context) {
|
func (ctrl *StudioController) WithTrendsService(svc trendsUseCase) *StudioController {
|
||||||
uid, _, ok := common.GetGinUser(c)
|
ctrl.trendsSvc = svc
|
||||||
if !ok {
|
return ctrl
|
||||||
common.RedirectToLogin(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
status := c.Query("status")
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|
||||||
|
|
||||||
posts, total, err := ctrl.postService.ListByUser(uid, status, page, pageSize)
|
|
||||||
if err != nil {
|
|
||||||
c.HTML(http.StatusOK, "studio/posts.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "内容管理 - 创作中心",
|
|
||||||
"Error": "加载失败",
|
|
||||||
"ActiveTab": "posts",
|
|
||||||
"ExtraCSS": "/static/css/studio.css",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
overview, _ := ctrl.postService.GetOverview(uid)
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "studio/posts.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "内容管理 - 创作中心",
|
|
||||||
"Posts": posts,
|
|
||||||
"Total": total,
|
|
||||||
"Page": page,
|
|
||||||
"PageSize": pageSize,
|
|
||||||
"TotalPages": common.PageCount(total, pageSize),
|
|
||||||
"CurrentStatus": status,
|
|
||||||
"Overview": overview,
|
|
||||||
"StatusNames": common.PostStatusDisplayNames,
|
|
||||||
"ActiveTab": "posts",
|
|
||||||
"ExtraCSS": "/static/css/studio.css",
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// DraftsPage 草稿箱页面
|
|
||||||
func (ctrl *StudioController) DraftsPage(c *gin.Context) {
|
|
||||||
uid, _, ok := common.GetGinUser(c)
|
|
||||||
if !ok {
|
|
||||||
common.RedirectToLogin(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|
||||||
|
|
||||||
posts, total, err := ctrl.postService.ListByUser(uid, model.PostStatusDraft, page, pageSize)
|
|
||||||
if err != nil {
|
|
||||||
c.HTML(http.StatusOK, "studio/drafts.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "草稿箱 - 创作中心",
|
|
||||||
"Error": "加载失败",
|
|
||||||
"ActiveTab": "drafts",
|
|
||||||
"ExtraCSS": "/static/css/studio.css",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "studio/drafts.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "草稿箱 - 创作中心",
|
|
||||||
"Posts": posts,
|
|
||||||
"Total": total,
|
|
||||||
"Page": page,
|
|
||||||
"PageSize": pageSize,
|
|
||||||
"TotalPages": common.PageCount(total, pageSize),
|
|
||||||
"StatusNames": common.PostStatusDisplayNames,
|
|
||||||
"ActiveTab": "drafts",
|
|
||||||
"ExtraCSS": "/static/css/studio.css",
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// WritePage 写文章页面
|
|
||||||
func (ctrl *StudioController) WritePage(c *gin.Context) {
|
|
||||||
uid, _, ok := common.GetGinUser(c)
|
|
||||||
if !ok {
|
|
||||||
common.RedirectToLogin(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 支持编辑模式:传入 post_id 参数
|
|
||||||
postIDStr := c.Query("id")
|
|
||||||
if postIDStr != "" {
|
|
||||||
id, err := strconv.ParseUint(postIDStr, 10, 64)
|
|
||||||
if err == nil {
|
|
||||||
post, err := ctrl.postService.GetByID(uint(id))
|
|
||||||
if err == nil {
|
|
||||||
_, role, _ := common.GetGinUser(c)
|
|
||||||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
|
||||||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "未找到",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "编辑文章 - 创作中心",
|
|
||||||
"Post": post,
|
|
||||||
"ActiveTab": "write",
|
|
||||||
"ExtraCSS": "/static/css/studio.css",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "写文章 - 创作中心",
|
|
||||||
"ActiveTab": "write",
|
|
||||||
"ExtraCSS": "/static/css/studio.css",
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// AnalyticsPage 数据分析页面
|
|
||||||
func (ctrl *StudioController) AnalyticsPage(c *gin.Context) {
|
|
||||||
uid, _, ok := common.GetGinUser(c)
|
|
||||||
if !ok {
|
|
||||||
common.RedirectToLogin(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
overview, err := ctrl.postService.GetOverview(uid)
|
|
||||||
if err != nil {
|
|
||||||
c.HTML(http.StatusOK, "studio/analytics.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "数据分析 - 创作中心",
|
|
||||||
"Error": "加载失败",
|
|
||||||
"ActiveTab": "analytics",
|
|
||||||
"ExtraCSS": "/static/css/studio.css",
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "studio/analytics.html", common.BuildPageData(c, gin.H{
|
|
||||||
"Title": "数据分析 - 创作中心",
|
|
||||||
"Overview": overview,
|
|
||||||
"ActiveTab": "analytics",
|
|
||||||
"ExtraCSS": "/static/css/studio.css",
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==============================
|
|
||||||
// API 方法
|
|
||||||
// ==============================
|
|
||||||
|
|
||||||
// OverviewAPI 创作中心数据概览
|
|
||||||
func (ctrl *StudioController) OverviewAPI(c *gin.Context) {
|
|
||||||
uid, _, ok := common.GetGinUser(c)
|
|
||||||
if !ok {
|
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
overview, err := ctrl.postService.GetOverview(uid)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "获取数据失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common.Ok(c, overview)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListPostsAPI 我的帖子列表(支持状态筛选)
|
|
||||||
func (ctrl *StudioController) ListPostsAPI(c *gin.Context) {
|
|
||||||
uid, _, ok := common.GetGinUser(c)
|
|
||||||
if !ok {
|
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
status := c.Query("status")
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
|
||||||
|
|
||||||
posts, total, err := ctrl.postService.ListByUser(uid, status, page, pageSize)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "获取列表失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common.Ok(c, gin.H{
|
|
||||||
"items": posts,
|
|
||||||
"total": total,
|
|
||||||
"page": page,
|
|
||||||
"page_size": pageSize,
|
|
||||||
"total_pages": common.PageCount(total, pageSize),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create 创建/发布帖子
|
|
||||||
func (ctrl *StudioController) Create(c *gin.Context) {
|
|
||||||
uid, _, ok := common.GetGinUser(c)
|
|
||||||
if !ok {
|
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req model.PostCreateRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
post, err := ctrl.postService.Create(uid, req.Title, req.Body)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "发布失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common.OkWithMessage(c, post, "发布成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update 编辑帖子
|
|
||||||
func (ctrl *StudioController) Update(c *gin.Context) {
|
|
||||||
uid, role, ok := common.GetGinUser(c)
|
|
||||||
if !ok {
|
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req model.PostUpdateRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
post, getErr := ctrl.postService.GetByID(uint(id))
|
|
||||||
if getErr != nil {
|
|
||||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
|
||||||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "编辑失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common.OkMessage(c, "编辑成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete 删除帖子
|
|
||||||
func (ctrl *StudioController) Delete(c *gin.Context) {
|
|
||||||
uid, role, ok := common.GetGinUser(c)
|
|
||||||
if !ok {
|
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
post, getErr := ctrl.postService.GetByID(uint(id))
|
|
||||||
if getErr != nil {
|
|
||||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
|
||||||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ctrl.postService.Delete(uint(id)); err != nil {
|
|
||||||
common.Error(c, http.StatusInternalServerError, "删除失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common.OkMessage(c, "删除成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Submit 提交审核
|
|
||||||
func (ctrl *StudioController) Submit(c *gin.Context) {
|
|
||||||
uid, role, ok := common.GetGinUser(c)
|
|
||||||
if !ok {
|
|
||||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
post, getErr := ctrl.postService.GetByID(uint(id))
|
|
||||||
if getErr != nil {
|
|
||||||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
|
||||||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil {
|
|
||||||
common.Error(c, http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
common.OkMessage(c, "已提交审核")
|
|
||||||
}
|
}
|
||||||
|
|||||||
118
internal/controller/studio_page_controller.go
Normal file
118
internal/controller/studio_page_controller.go
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OverviewPage 创作中心首页(数据概览)
|
||||||
|
func (ctrl *StudioController) OverviewPage(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.RedirectToLogin(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
overview, err := ctrl.postService.GetOverview(uid)
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(http.StatusOK, "studio/overview.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "创作中心",
|
||||||
|
"Error": "加载数据失败",
|
||||||
|
"ActiveTab": "overview",
|
||||||
|
"ExtraCSS": "/static/css/studio.css",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "studio/overview.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "创作中心",
|
||||||
|
"Overview": overview,
|
||||||
|
"StatusNames": common.PostStatusDisplayNames,
|
||||||
|
"ActiveTab": "overview",
|
||||||
|
"ExtraCSS": "/static/css/studio.css",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostsPage 内容管理页
|
||||||
|
func (ctrl *StudioController) PostsPage(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.RedirectToLogin(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := c.Query("status")
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
posts, total, err := ctrl.postService.ListByUser(uid, status, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(http.StatusOK, "studio/posts.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "内容管理 - 创作中心",
|
||||||
|
"Error": "加载失败",
|
||||||
|
"ActiveTab": "posts",
|
||||||
|
"ExtraCSS": "/static/css/studio.css",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
overview, err2 := ctrl.postService.GetOverview(uid)
|
||||||
|
if err2 != nil {
|
||||||
|
log.Printf("[PostsPage] GetOverview failed for user %d: %v", uid, err2)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "studio/posts.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "内容管理 - 创作中心",
|
||||||
|
"Posts": posts,
|
||||||
|
"Total": total,
|
||||||
|
"Page": page,
|
||||||
|
"PageSize": pageSize,
|
||||||
|
"TotalPages": common.PageCount(total, pageSize),
|
||||||
|
"CurrentStatus": status,
|
||||||
|
"Overview": overview,
|
||||||
|
"StatusNames": common.PostStatusDisplayNames,
|
||||||
|
"ActiveTab": "posts",
|
||||||
|
"ExtraCSS": "/static/css/studio.css",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DraftsPage 草稿箱页面
|
||||||
|
func (ctrl *StudioController) DraftsPage(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.RedirectToLogin(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
posts, total, err := ctrl.postService.ListByUser(uid, model.PostStatusDraft, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(http.StatusOK, "studio/drafts.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "草稿箱 - 创作中心",
|
||||||
|
"Error": "加载失败",
|
||||||
|
"ActiveTab": "drafts",
|
||||||
|
"ExtraCSS": "/static/css/studio.css",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "studio/drafts.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "草稿箱 - 创作中心",
|
||||||
|
"Posts": posts,
|
||||||
|
"Total": total,
|
||||||
|
"Page": page,
|
||||||
|
"PageSize": pageSize,
|
||||||
|
"TotalPages": common.PageCount(total, pageSize),
|
||||||
|
"StatusNames": common.PostStatusDisplayNames,
|
||||||
|
"ActiveTab": "drafts",
|
||||||
|
"ExtraCSS": "/static/css/studio.css",
|
||||||
|
}))
|
||||||
|
}
|
||||||
78
internal/controller/studio_post_api_controller.go
Normal file
78
internal/controller/studio_post_api_controller.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create 创建/发布帖子
|
||||||
|
func (ctrl *StudioController) Create(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// LV0 用户不允许投稿
|
||||||
|
if !ctrl.postService.CanCreatePost(uid) {
|
||||||
|
common.Error(c, http.StatusForbidden, "经验不足,Lv1 解锁投稿")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.PostCreateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := ctrl.postService.Create(uid, req.Title, req.Body)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "发布失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkWithMessage(c, post, "发布成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 编辑帖子
|
||||||
|
func (ctrl *StudioController) Update(c *gin.Context) {
|
||||||
|
uid, role, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.PostUpdateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
post, getErr := ctrl.postService.GetByID(uint(id))
|
||||||
|
if getErr != nil {
|
||||||
|
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||||
|
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "编辑失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "编辑成功")
|
||||||
|
}
|
||||||
77
internal/controller/studio_write_controller.go
Normal file
77
internal/controller/studio_write_controller.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WritePage 写文章页面
|
||||||
|
func (ctrl *StudioController) WritePage(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.RedirectToLogin(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持编辑模式:传入 post_id 参数
|
||||||
|
postIDStr := c.Query("id")
|
||||||
|
if postIDStr != "" {
|
||||||
|
id, err := strconv.ParseUint(postIDStr, 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
post, err := ctrl.postService.GetByID(uint(id))
|
||||||
|
if err == nil {
|
||||||
|
_, role, _ := common.GetGinUser(c)
|
||||||
|
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||||||
|
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "未找到",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "编辑文章 - 创作中心",
|
||||||
|
"Post": post,
|
||||||
|
"ActiveTab": "write",
|
||||||
|
"ExtraCSS": "/static/css/studio.css",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "写文章 - 创作中心",
|
||||||
|
"ActiveTab": "write",
|
||||||
|
"ExtraCSS": "/static/css/studio.css",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnalyticsPage 数据分析页面
|
||||||
|
func (ctrl *StudioController) AnalyticsPage(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.RedirectToLogin(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
overview, err := ctrl.postService.GetOverview(uid)
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(http.StatusOK, "studio/analytics.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "数据分析 - 创作中心",
|
||||||
|
"Error": "加载失败",
|
||||||
|
"ActiveTab": "analytics",
|
||||||
|
"ExtraCSS": "/static/css/studio.css",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "studio/analytics.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "数据分析 - 创作中心",
|
||||||
|
"Overview": overview,
|
||||||
|
"ActiveTab": "analytics",
|
||||||
|
"ExtraCSS": "/static/css/studio.css",
|
||||||
|
}))
|
||||||
|
}
|
||||||
@ -2,18 +2,26 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/metalab/internal/common"
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/metalab/internal/config"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
"metazone.cc/metalab/internal/session"
|
"metazone.cc/metalab/internal/session"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// autoCheckIner AuthMiddleware 对自动签到服务的最小依赖
|
||||||
|
type autoCheckIner interface {
|
||||||
|
CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error)
|
||||||
|
}
|
||||||
|
|
||||||
// AuthMiddleware 认证中间件(结构体模式,持有 SessionManager)
|
// AuthMiddleware 认证中间件(结构体模式,持有 SessionManager)
|
||||||
type AuthMiddleware struct {
|
type AuthMiddleware struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
sessionManager *session.Manager
|
sessionManager *session.Manager
|
||||||
|
levelSvc autoCheckIner
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthMiddleware 构造函数
|
// NewAuthMiddleware 构造函数
|
||||||
@ -21,33 +29,47 @@ func NewAuthMiddleware(cfg *config.Config, sm *session.Manager) *AuthMiddleware
|
|||||||
return &AuthMiddleware{cfg: cfg, sessionManager: sm}
|
return &AuthMiddleware{cfg: cfg, sessionManager: sm}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithLevelService 链式注入等级服务(用于自动签到)
|
||||||
|
func (am *AuthMiddleware) WithLevelService(svc autoCheckIner) *AuthMiddleware {
|
||||||
|
am.levelSvc = svc
|
||||||
|
return am
|
||||||
|
}
|
||||||
|
|
||||||
// Required 登录认证中间件:读 session cookie → 验证会话 → 注入用户信息
|
// Required 登录认证中间件:读 session cookie → 验证会话 → 注入用户信息
|
||||||
// 验证失败 → 清除 cookie → 返回 401
|
// 页面请求(非 /api/ 前缀)→ 验证失败清除 cookie 并重定向到登录页
|
||||||
|
// API 请求 → 验证失败返回 401 JSON
|
||||||
func (am *AuthMiddleware) Required() gin.HandlerFunc {
|
func (am *AuthMiddleware) Required() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
sid, err := c.Cookie(common.SessionCookieName)
|
sid, err := c.Cookie(common.SessionCookieName)
|
||||||
if err != nil || sid == "" {
|
if err != nil || sid == "" {
|
||||||
common.ClearSessionCookie(c, am.cfg)
|
am.abortUnauthorized(c)
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"success": false, "message": "登录已过期,请重新登录",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s, err := am.sessionManager.Validate(sid)
|
s, err := am.sessionManager.Validate(sid)
|
||||||
if err != nil || s == nil {
|
if err != nil || s == nil {
|
||||||
common.ClearSessionCookie(c, am.cfg)
|
am.abortUnauthorized(c)
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
|
||||||
"success": false, "message": "登录已过期,请重新登录",
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
injectSessionContext(c, s)
|
injectSessionContext(c, s)
|
||||||
|
am.tryAutoCheckIn(c, s)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// abortUnauthorized 统一处理未认证:API 返回 JSON,页面请求跳转登录
|
||||||
|
func (am *AuthMiddleware) abortUnauthorized(c *gin.Context) {
|
||||||
|
common.ClearSessionCookie(c, am.cfg)
|
||||||
|
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false, "message": "登录已过期,请重新登录",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
common.RedirectToLogin(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Optional 可选认证:已登录则注入用户信息,未登录也放行
|
// Optional 可选认证:已登录则注入用户信息,未登录也放行
|
||||||
func (am *AuthMiddleware) Optional() gin.HandlerFunc {
|
func (am *AuthMiddleware) Optional() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
@ -65,10 +87,31 @@ func (am *AuthMiddleware) Optional() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
injectSessionContext(c, s)
|
injectSessionContext(c, s)
|
||||||
|
am.tryAutoCheckIn(c, s)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tryAutoCheckIn 尝试自动签到,并同步 session Exp 与 DB(静默失败,不影响主流程)
|
||||||
|
// CheckIn 即使已签到也会返回 DB 中最新的 Exp,这里始终与 session 比较,
|
||||||
|
// 确保 CompleteTask 等非签到路径的 Exp 变化也能同步到 session,避免 NAV 显示旧等级
|
||||||
|
// 被封禁用户跳过自动签到
|
||||||
|
func (am *AuthMiddleware) tryAutoCheckIn(c *gin.Context, s *session.Session) {
|
||||||
|
if am.levelSvc == nil || s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.Status == model.StatusBanned {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, newExp, _, _ := am.levelSvc.CheckIn(s.UserID)
|
||||||
|
// 只允许 Exp 只增不减:DB 异常返回 0 或主从延迟读到旧值时不会回退
|
||||||
|
if newExp > s.Exp {
|
||||||
|
s.Exp = newExp
|
||||||
|
_ = am.sessionManager.UpdateSession(s)
|
||||||
|
c.Set("exp", newExp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// injectSessionContext 将会话中的用户信息注入 gin context
|
// injectSessionContext 将会话中的用户信息注入 gin context
|
||||||
func injectSessionContext(c *gin.Context, s *session.Session) {
|
func injectSessionContext(c *gin.Context, s *session.Session) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
@ -77,6 +120,33 @@ func injectSessionContext(c *gin.Context, s *session.Session) {
|
|||||||
c.Set("uid", s.UserID)
|
c.Set("uid", s.UserID)
|
||||||
c.Set("email", s.Email)
|
c.Set("email", s.Email)
|
||||||
c.Set("username", s.Username)
|
c.Set("username", s.Username)
|
||||||
|
c.Set("avatar", s.Avatar)
|
||||||
c.Set("role", s.Role)
|
c.Set("role", s.Role)
|
||||||
|
c.Set("status", s.Status)
|
||||||
|
c.Set("exp", s.Exp)
|
||||||
c.Set("sid", s.ID)
|
c.Set("sid", s.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BannedWriteGuard 封禁用户写操作守卫
|
||||||
|
// 仅拦截 POST/PUT/DELETE/PATCH 请求,GET/HEAD/OPTIONS 放行
|
||||||
|
// 需在 Required() 之后调用,确保上下文中已有用户 status
|
||||||
|
func (am *AuthMiddleware) BannedWriteGuard() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// 只检查写操作
|
||||||
|
switch c.Request.Method {
|
||||||
|
case "GET", "HEAD", "OPTIONS":
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, _ := c.Get("status")
|
||||||
|
if status == model.StatusBanned {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "账号已被封禁,无法执行此操作",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -46,16 +46,14 @@ func CSRF(cfg *config.Config) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) != 1 {
|
||||||
log.Printf("[CSRF] MISMATCH | path=%s | cookie(len=%d)=%q | header(len=%d)=%q",
|
log.Printf("[CSRF] MISMATCH | path=%s | cookie_len=%d | header_len=%d",
|
||||||
c.Request.URL.Path, len(cookieToken), cookieToken, len(headerToken), headerToken)
|
c.Request.URL.Path, len(cookieToken), len(headerToken))
|
||||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||||
"success": false, "message": "CSRF 验证失败",
|
"success": false, "message": "CSRF 验证失败",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[CSRF] OK | path=%s | token(len=%d)=%q", c.Request.URL.Path, len(cookieToken), cookieToken)
|
|
||||||
|
|
||||||
c.Set(csrfMetaName, cookieToken)
|
c.Set(csrfMetaName, cookieToken)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
|
|||||||
24
internal/middleware/rate.go
Normal file
24
internal/middleware/rate.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SensitiveRateLimit 敏感操作(改密/注销/签到)IP 维度限流中间件。
|
||||||
|
// 1 分钟最多 5 次请求,超限后封禁 5 分钟。
|
||||||
|
func SensitiveRateLimit(rl *RateLimiter) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
result := rl.AllowSensitive(c.ClientIP())
|
||||||
|
if result.Blocked {
|
||||||
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": result.Message,
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,3 +32,8 @@ func (rl *RateLimiter) AllowAccount(email string) RateLimitResult {
|
|||||||
func (rl *RateLimiter) AllowIP(ip string) RateLimitResult {
|
func (rl *RateLimiter) AllowIP(ip string) RateLimitResult {
|
||||||
return rl.try(rl.ipFailures, ip, ipWindow, ipBlockDur, ipMaxFails, false)
|
return rl.try(rl.ipFailures, ip, ipWindow, ipBlockDur, ipMaxFails, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AllowSensitive 敏感操作限流(改密/注销/签到),基于 IP,1 分钟最多 5 次,超限封禁 5 分钟。
|
||||||
|
func (rl *RateLimiter) AllowSensitive(ip string) RateLimitResult {
|
||||||
|
return rl.try(rl.ipFailures, "sensitive:"+ip, sensitiveWindow, sensitiveBlockDur, sensitiveMaxRequests, false)
|
||||||
|
}
|
||||||
|
|||||||
@ -21,6 +21,11 @@ const (
|
|||||||
ipBlockDur = 1 * time.Minute
|
ipBlockDur = 1 * time.Minute
|
||||||
cleanupInterval = 2 * time.Minute
|
cleanupInterval = 2 * time.Minute
|
||||||
maxEntries = 10000
|
maxEntries = 10000
|
||||||
|
|
||||||
|
// 敏感操作限流:改密/注销/签到
|
||||||
|
sensitiveWindow = 1 * time.Minute
|
||||||
|
sensitiveMaxRequests = 5
|
||||||
|
sensitiveBlockDur = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// windowState 单个维度的限流状态
|
// windowState 单个维度的限流状态
|
||||||
|
|||||||
@ -1,19 +1,38 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/config"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// generateNonce 生成 16 字节随机 nonce(base64 编码)
|
||||||
|
func generateNonce() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
// 降级:使用固定长度回退(极度罕见,仅 /dev/urandom 故障时)
|
||||||
|
panic("failed to generate CSP nonce: " + err.Error())
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
// SecurityHeaders 添加安全相关 HTTP 响应头
|
// SecurityHeaders 添加安全相关 HTTP 响应头
|
||||||
// 作为纵深防御,不影响业务逻辑,纯附加
|
// 作为纵深防御,不影响业务逻辑,纯附加
|
||||||
func SecurityHeaders() gin.HandlerFunc {
|
func SecurityHeaders() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
// 生成一次性 nonce,用于 CSP 策略和模板内 <script>/<style> 标签
|
||||||
|
nonce := generateNonce()
|
||||||
|
c.Set("csp_nonce", nonce)
|
||||||
|
|
||||||
// Content-Security-Policy
|
// Content-Security-Policy
|
||||||
// script-src/style-src: 本站 + inline(highlight.js 主题已本地化在 static/vditor/ 下)
|
// script-src/style-src: 本站 + nonce(替代 unsafe-inline)
|
||||||
// img-src: 本站 + data: URI(头像裁切)+ blob:(粘贴图片)
|
// img-src: 本站 + data: URI(头像裁切)+ blob:(粘贴图片)
|
||||||
c.Header("Content-Security-Policy",
|
c.Header("Content-Security-Policy",
|
||||||
"default-src 'self'; "+
|
"default-src 'self'; "+
|
||||||
"script-src 'self' 'unsafe-inline'; "+
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
|
||||||
"style-src 'self' 'unsafe-inline'; "+
|
"style-src 'self' 'unsafe-inline'; "+
|
||||||
"img-src 'self' data: blob:; "+
|
"img-src 'self' data: blob:; "+
|
||||||
"connect-src 'self'")
|
"connect-src 'self'")
|
||||||
@ -27,6 +46,11 @@ func SecurityHeaders() gin.HandlerFunc {
|
|||||||
// 引用策略
|
// 引用策略
|
||||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
|
||||||
|
// HSTS:仅在 release 模式启用,避免开发环境 localhost 证书问题
|
||||||
|
if config.App != nil && config.App.Server.Mode == "release" {
|
||||||
|
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
||||||
|
}
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
internal/model/comment.go
Normal file
65
internal/model/comment.go
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Comment 评论模型
|
||||||
|
type Comment struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||||
|
RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID,顶级评论指向自身
|
||||||
|
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论ID,NULL=顶级评论
|
||||||
|
ReplyToUID uint `gorm:"default:0" json:"reply_to_uid"` // 被回复者UID
|
||||||
|
Body string `gorm:"type:text;not null" json:"body"` // 评论内容(纯文本 + 图片URL)
|
||||||
|
IsDeleted bool `gorm:"default:false;index" json:"is_deleted"`
|
||||||
|
LikesCount int `gorm:"default:0" json:"likes_count"`
|
||||||
|
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见
|
||||||
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// 非数据库字段(联表查询填充)
|
||||||
|
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
|
||||||
|
AuthorAvatar string `gorm:"-:migration;<-:false;column:author_avatar" json:"author_avatar,omitempty"`
|
||||||
|
AuthorLevel int `gorm:"-:migration;<-:false;column:author_level" json:"author_level,omitempty"`
|
||||||
|
// ReplyToName 被回复者当前显示名(JOIN users 获取,动态解析,改名后自动更新)
|
||||||
|
ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"`
|
||||||
|
// RepliesCount 回复数(非DB字段,查询填充)
|
||||||
|
RepliesCount int `gorm:"-:migration;<-:false" json:"replies_count,omitempty"`
|
||||||
|
// Mentions 有效@提及映射 original_username -> {uid, 当前显示名}(非DB字段,批量查询填充)
|
||||||
|
// key=创建时的原始@用户名,value={uid,当前显示名},用户改名后链接仍有效且显示新名
|
||||||
|
Mentions map[string]MentionInfo `gorm:"-" json:"mentions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentMention @提及映射(绑定UID + 原始用户名,支持改名后自动更新显示名)
|
||||||
|
type CommentMention struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CommentID uint `gorm:"uniqueIndex:idx_comment_uid,priority:1;not null" json:"comment_id"`
|
||||||
|
UID uint `gorm:"uniqueIndex:idx_comment_uid,priority:2;not null" json:"uid"`
|
||||||
|
OriginalUsername string `gorm:"type:varchar(64);not null;default:''" json:"original_username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MentionInfo @提及显示信息(前端渲染用)
|
||||||
|
type MentionInfo struct {
|
||||||
|
UID uint `json:"uid"`
|
||||||
|
Name string `json:"name"` // 当前显示名
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserSearchResult @搜索用户结果
|
||||||
|
type UserSearchResult struct {
|
||||||
|
UID uint `json:"uid"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Level int `json:"level"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCommentRow 后台评论列表行
|
||||||
|
type AdminCommentRow struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
PostID uint `json:"post_id"`
|
||||||
|
PostTitle string `json:"post_title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
AuthorName string `json:"author_name"`
|
||||||
|
IsDeleted bool `json:"is_deleted"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
// BaseModel 所有模型的公共字段
|
// BaseModel 所有模型的公共字段
|
||||||
type BaseModel struct {
|
type BaseModel struct {
|
||||||
ID uint `gorm:"primarykey;column:uid" json:"uid"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|||||||
10
internal/model/community_fund.go
Normal file
10
internal/model/community_fund.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// CommunityFund 公户余额(单行表,id=1)
|
||||||
|
type CommunityFund struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Balance int `gorm:"not null;default:0" json:"balance"` // 余额(×10,允许负数)
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
13
internal/model/daily_exp_summary.go
Normal file
13
internal/model/daily_exp_summary.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// DailyExpSummary 每日通过赋能获得的经验汇总(判断是否达 50 上限)
|
||||||
|
type DailyExpSummary struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
UserID uint `gorm:"uniqueIndex:idx_user_date;not null" json:"user_id"`
|
||||||
|
Date time.Time `gorm:"type:date;uniqueIndex:idx_user_date;not null" json:"date"` // Asia/Shanghai 自然日
|
||||||
|
ExpEarned int `gorm:"default:0" json:"exp_earned"` // 当日通过赋能获得的经验值
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
14
internal/model/daily_like_summary.go
Normal file
14
internal/model/daily_like_summary.go
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// DailyLikeSummary 每日点赞汇聚(用于聚合点赞通知)
|
||||||
|
type DailyLikeSummary struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
AuthorUID uint `gorm:"uniqueIndex:idx_author_date;not null" json:"author_uid"` // 被点赞文章的作者
|
||||||
|
Date string `gorm:"type:varchar(10);uniqueIndex:idx_author_date;not null" json:"date"` // 日期 YYYY-MM-DD(Asia/Shanghai)
|
||||||
|
LikerUIDs string `gorm:"type:text" json:"liker_uids"` // 点赞者 UID 列表(追加式,逗号分隔)
|
||||||
|
Notified bool `gorm:"default:false" json:"notified"` // 是否已发送聚合通知
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
@ -47,6 +47,11 @@ type PostRejectRequest struct {
|
|||||||
Reason string `json:"reason" binding:"required,min=1,max=500"`
|
Reason string `json:"reason" binding:"required,min=1,max=500"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PostLockRequest 锁定请求
|
||||||
|
type PostLockRequest struct {
|
||||||
|
Reason string `json:"reason" binding:"required,min=1,max=500"`
|
||||||
|
}
|
||||||
|
|
||||||
// PostListQuery 列表查询参数
|
// PostListQuery 列表查询参数
|
||||||
type PostListQuery struct {
|
type PostListQuery struct {
|
||||||
Keyword string `form:"keyword"`
|
Keyword string `form:"keyword"`
|
||||||
|
|||||||
38
internal/model/energy_log.go
Normal file
38
internal/model/energy_log.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// 域能流水类型常量
|
||||||
|
const (
|
||||||
|
EnergyTypeSignIn = "sign_in"
|
||||||
|
EnergyTypeRename = "rename"
|
||||||
|
EnergyTypeRenameRefund = "rename_refund"
|
||||||
|
EnergyTypeEnergize = "energize"
|
||||||
|
EnergyTypeEnergized = "energized"
|
||||||
|
EnergyTypeDeletePost = "delete_post"
|
||||||
|
EnergyTypeAdminAdjust = "admin_adjust"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnergyLog 域能流水记录
|
||||||
|
type EnergyLog struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
|
Amount int `gorm:"not null" json:"amount"` // 变化量(乘10,正为增加,负为扣减)
|
||||||
|
Type string `gorm:"type:varchar(30);index;not null" json:"type"` // sign_in/rename/rename_refund/energize/energized/delete_post/admin_adjust
|
||||||
|
RelatedType string `gorm:"type:varchar(30);default:''" json:"related_type,omitempty"`
|
||||||
|
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
|
||||||
|
Description string `gorm:"type:varchar(500);default:''" json:"description,omitempty"`
|
||||||
|
OperatorUID *uint `gorm:"default:null" json:"operator_uid,omitempty"` // 操作者UID(后台操作时必填)
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnergyLogDisplayNames 流水类型 → 中文名
|
||||||
|
var EnergyLogDisplayNames = map[string]string{
|
||||||
|
EnergyTypeSignIn: "每日签到",
|
||||||
|
EnergyTypeRename: "改名消耗",
|
||||||
|
EnergyTypeRenameRefund: "改名退款",
|
||||||
|
EnergyTypeEnergize: "赋能消耗",
|
||||||
|
EnergyTypeEnergized: "被赋能",
|
||||||
|
EnergyTypeDeletePost: "删稿消耗",
|
||||||
|
EnergyTypeAdminAdjust: "后台调整",
|
||||||
|
}
|
||||||
30
internal/model/folder.go
Normal file
30
internal/model/folder.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Folder 收藏夹
|
||||||
|
type Folder struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
|
Name string `gorm:"type:varchar(50);not null" json:"name"`
|
||||||
|
Description string `gorm:"type:varchar(200);default:''" json:"description"`
|
||||||
|
IsPublic bool `gorm:"default:false" json:"is_public"`
|
||||||
|
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// 联表查询填充(非数据库字段)
|
||||||
|
ItemCount int64 `gorm:"-:migration;<-:false;column:item_count" json:"item_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FolderItem 收藏记录
|
||||||
|
type FolderItem struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
FolderID uint `gorm:"index;not null" json:"folder_id"`
|
||||||
|
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||||
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
// 联表查询填充
|
||||||
|
PostTitle string `gorm:"-:migration;<-:false;column:post_title" json:"post_title,omitempty"`
|
||||||
|
}
|
||||||
35
internal/model/fund_log.go
Normal file
35
internal/model/fund_log.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// 公户流水类型常量
|
||||||
|
const (
|
||||||
|
FundTypeEnergizeTax = "energize_tax" // 赋能税收(入公户)
|
||||||
|
FundTypeRenameDeduction = "rename_deduction" // 改名扣费(入公户)
|
||||||
|
FundTypeRenameRefund = "rename_refund" // 改名退款(出公户)
|
||||||
|
FundTypeDeletePost = "delete_post_deduction" // 删稿扣费(入公户)
|
||||||
|
FundTypeAdminTransfer = "admin_transfer" // 管理员转账(出公户,受余额约束)
|
||||||
|
FundTypeSystemOperation = "system_operation" // 站长直调(双向,不受余额约束)
|
||||||
|
)
|
||||||
|
|
||||||
|
// FundLog 公户流水记录
|
||||||
|
type FundLog struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Amount int `gorm:"not null" json:"amount"` // +入公户 / –出公户(×10)
|
||||||
|
Type string `gorm:"type:varchar(50);index;not null" json:"type"` // 日志类型
|
||||||
|
RelatedType string `gorm:"type:varchar(50);default:''" json:"related_type,omitempty"`
|
||||||
|
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
|
||||||
|
OperatorUID *uint `gorm:"default:null" json:"operator_uid,omitempty"`
|
||||||
|
Description string `gorm:"type:text;not null" json:"description"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FundLogDisplayNames 公户流水类型 → 中文名
|
||||||
|
var FundLogDisplayNames = map[string]string{
|
||||||
|
FundTypeEnergizeTax: "赋能税收",
|
||||||
|
FundTypeRenameDeduction: "改名扣费",
|
||||||
|
FundTypeRenameRefund: "改名退款",
|
||||||
|
FundTypeDeletePost: "删稿扣费",
|
||||||
|
FundTypeAdminTransfer: "管理转出",
|
||||||
|
FundTypeSystemOperation: "系统操作",
|
||||||
|
}
|
||||||
25
internal/model/level.go
Normal file
25
internal/model/level.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// LevelThresholds 等级阈值表(经验值区间下限)
|
||||||
|
// 索引即等级,如 thresholds[3] = 1500 表示 Lv3 至少需要 1500 经验值
|
||||||
|
var LevelThresholds = []int{0, 1, 200, 1500, 4500, 10800, 28800}
|
||||||
|
|
||||||
|
// GetLevelByExp 根据经验值计算当前等级
|
||||||
|
func GetLevelByExp(exp int) int {
|
||||||
|
for i := len(LevelThresholds) - 1; i >= 0; i-- {
|
||||||
|
if exp >= LevelThresholds[i] {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLevelName 根据等级返回展示文本(如 "Lv3")
|
||||||
|
func GetLevelName(level int) string {
|
||||||
|
if level < 0 {
|
||||||
|
level = 0
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Lv%d", level)
|
||||||
|
}
|
||||||
@ -6,6 +6,14 @@ const (
|
|||||||
NotifyAuditRejected = "audit_rejected"
|
NotifyAuditRejected = "audit_rejected"
|
||||||
NotifyPostApproved = "post_approved"
|
NotifyPostApproved = "post_approved"
|
||||||
NotifyPostRejected = "post_rejected"
|
NotifyPostRejected = "post_rejected"
|
||||||
|
NotifyPostLocked = "post_locked"
|
||||||
|
NotifyPostUnlocked = "post_unlocked"
|
||||||
|
NotifyLevelUp = "level_up"
|
||||||
|
NotifyComment = "comment"
|
||||||
|
NotifyCommentReply = "comment_reply"
|
||||||
|
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
|
||||||
|
NotifyFollow = "follow" // 关注通知
|
||||||
|
NotifyCommentMention = "comment_mention" // 评论@提及通知
|
||||||
)
|
)
|
||||||
|
|
||||||
// Notification 消息/通知模型
|
// Notification 消息/通知模型
|
||||||
@ -17,7 +25,8 @@ type Notification struct {
|
|||||||
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||||
Content string `gorm:"type:text" json:"content"`
|
Content string `gorm:"type:text" json:"content"`
|
||||||
IsRead bool `gorm:"index:idx_user_read,priority:2;default:false;not null" json:"is_read"`
|
IsRead bool `gorm:"index:idx_user_read,priority:2;default:false;not null" json:"is_read"`
|
||||||
RelatedID *uint `gorm:"default:null" json:"related_id,omitempty"` // 关联业务 ID
|
RelatedID *uint `gorm:"default:null" json:"related_id,omitempty"` // 关联业务 ID(文章/评论等)
|
||||||
|
CommentID *uint `gorm:"default:null" json:"comment_id,omitempty"` // 评论 ID(用于跳转高亮目标评论)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotifyTypeNames 通知类型 → 中文名
|
// NotifyTypeNames 通知类型 → 中文名
|
||||||
@ -26,6 +35,30 @@ var NotifyTypeNames = map[string]string{
|
|||||||
NotifyAuditRejected: "资料审核",
|
NotifyAuditRejected: "资料审核",
|
||||||
NotifyPostApproved: "稿件审核",
|
NotifyPostApproved: "稿件审核",
|
||||||
NotifyPostRejected: "稿件审核",
|
NotifyPostRejected: "稿件审核",
|
||||||
|
NotifyPostLocked: "稿件审核",
|
||||||
|
NotifyPostUnlocked: "稿件审核",
|
||||||
|
NotifyLevelUp: "等级提升",
|
||||||
|
NotifyComment: "评论",
|
||||||
|
NotifyCommentReply: "回复",
|
||||||
|
NotifyCommentMention: "@ 提及",
|
||||||
|
NotifyLikeAggregated: "点赞",
|
||||||
|
NotifyFollow: "关注",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyCategory 通知类型所属分类 TAB
|
||||||
|
var NotifyCategory = map[string]string{
|
||||||
|
NotifyAuditApproved: "system",
|
||||||
|
NotifyAuditRejected: "system",
|
||||||
|
NotifyPostApproved: "system",
|
||||||
|
NotifyPostRejected: "system",
|
||||||
|
NotifyPostLocked: "system",
|
||||||
|
NotifyPostUnlocked: "system",
|
||||||
|
NotifyLevelUp: "system",
|
||||||
|
NotifyComment: "mention",
|
||||||
|
NotifyCommentReply: "mention",
|
||||||
|
NotifyCommentMention: "mention",
|
||||||
|
NotifyLikeAggregated: "like",
|
||||||
|
NotifyFollow: "follow",
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationListResult 消息列表查询结果
|
// NotificationListResult 消息列表查询结果
|
||||||
|
|||||||
@ -12,13 +12,20 @@ type Post struct {
|
|||||||
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||||
Body string `gorm:"type:text" json:"body"` // Markdown content
|
Body string `gorm:"type:text" json:"body"` // Markdown content
|
||||||
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary for list
|
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary for list
|
||||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
UserID uint `gorm:"index;index:idx_posts_user_status,priority:1;not null" json:"user_id"`
|
||||||
Status string `gorm:"type:varchar(20);index;default:draft;not null" json:"status"`
|
Status string `gorm:"type:varchar(20);index;index:idx_posts_user_status,priority:2;default:draft;not null" json:"status"`
|
||||||
IsLocked bool `gorm:"default:false" json:"is_locked"`
|
IsLocked bool `gorm:"default:false" json:"is_locked"`
|
||||||
|
LockReason string `gorm:"type:varchar(500);default:''" json:"lock_reason,omitempty"`
|
||||||
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
|
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
|
||||||
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
|
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
|
||||||
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
|
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
|
||||||
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
||||||
|
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
|
||||||
|
TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能(乘10,冗余计数)
|
||||||
|
LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器)
|
||||||
|
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见)
|
||||||
|
FavoritesCount int `gorm:"default:0" json:"favorites_count"` // 收藏数(冗余计数器)
|
||||||
|
ViewsCount int `gorm:"default:0" json:"views_count"` // 阅读数(冗余计数器,已登录去重)
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
10
internal/model/post_dislike.go
Normal file
10
internal/model/post_dislike.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// PostDislike 踩记录(仅后台可见)
|
||||||
|
type PostDislike struct {
|
||||||
|
UserID uint `gorm:"primaryKey;not null" json:"user_id"`
|
||||||
|
PostID uint `gorm:"primaryKey;not null" json:"post_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
12
internal/model/post_energize_log.go
Normal file
12
internal/model/post_energize_log.go
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// PostEnergizeLog 赋能操作记录(校验单用户单文章赋能总量 ≤ 2 域能)
|
||||||
|
type PostEnergizeLog struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
UserID uint `gorm:"index;not null" json:"user_id"` // 赋能者
|
||||||
|
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||||
|
Amount int `gorm:"not null" json:"amount"` // 赋能域能量(乘10,10或20)
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
10
internal/model/post_like.go
Normal file
10
internal/model/post_like.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// PostLike 点赞记录
|
||||||
|
type PostLike struct {
|
||||||
|
UserID uint `gorm:"primaryKey;not null" json:"user_id"`
|
||||||
|
PostID uint `gorm:"primaryKey;not null" json:"post_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
20
internal/model/post_read_log.go
Normal file
20
internal/model/post_read_log.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// PostReadLog 文章阅读记录(已登录用户每篇文章只记录一次)
|
||||||
|
type PostReadLog struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
UserID uint `gorm:"uniqueIndex:idx_user_post_read;not null" json:"user_id"`
|
||||||
|
PostID uint `gorm:"uniqueIndex:idx_user_post_read;not null" json:"post_id"`
|
||||||
|
ReadAt time.Time `gorm:"autoCreateTime" json:"read_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostGuestReadLog 访客阅读记录(未登录用户,基于 Cookie 标识去重)
|
||||||
|
type PostGuestReadLog struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
VisitorID string `gorm:"uniqueIndex:idx_visitor_post_read;type:varchar(64);not null" json:"visitor_id"`
|
||||||
|
PostID uint `gorm:"uniqueIndex:idx_visitor_post_read;not null" json:"post_id"`
|
||||||
|
ReadAt time.Time `gorm:"autoCreateTime" json:"read_at"`
|
||||||
|
}
|
||||||
|
|
||||||
@ -15,6 +15,19 @@ type User struct {
|
|||||||
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
|
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
|
||||||
Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
|
Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
|
||||||
|
|
||||||
|
// 经验值与域能
|
||||||
|
Exp int `gorm:"default:0" json:"exp"` // 经验值(只增不减)
|
||||||
|
Energy int `gorm:"default:0" json:"energy"` // 域能余额(可为负数,乘10存储)
|
||||||
|
|
||||||
|
// 关注系统
|
||||||
|
FollowersCount int `gorm:"default:0" json:"followers_count"` // 粉丝数(冗余计数器)
|
||||||
|
FollowingCount int `gorm:"default:0" json:"following_count"` // 关注数(冗余计数器)
|
||||||
|
FollowListPublic bool `gorm:"default:true" json:"follow_list_public"` // 关注/粉丝列表是否公开
|
||||||
|
FollowerListPublic bool `gorm:"default:true" json:"follower_list_public"` // 粉丝列表是否公开
|
||||||
|
|
||||||
|
// 通知偏好 (JSON string)
|
||||||
|
NotifyPrefs string `gorm:"type:text;default:'{\"comment\":true,\"comment_reply\":true,\"like_aggregated\":true,\"follow\":true}'" json:"-"`
|
||||||
|
|
||||||
// 安全与审计
|
// 安全与审计
|
||||||
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP
|
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP
|
||||||
LastLoginIP string `gorm:"type:varchar(45);default:''" json:"-"` // 最后登录 IP
|
LastLoginIP string `gorm:"type:varchar(45);default:''" json:"-"` // 最后登录 IP
|
||||||
|
|||||||
11
internal/model/user_checkin.go
Normal file
11
internal/model/user_checkin.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// UserCheckIn 用户签到记录(自然日维度,严格防重)
|
||||||
|
type UserCheckIn struct {
|
||||||
|
BaseModel
|
||||||
|
|
||||||
|
UserID uint `gorm:"uniqueIndex:idx_user_checkin_date,priority:1;not null" json:"user_id"`
|
||||||
|
Date time.Time `gorm:"type:date;uniqueIndex:idx_user_checkin_date,priority:2;not null" json:"date"` // Asia/Shanghai 自然日
|
||||||
|
}
|
||||||
18
internal/model/user_follow.go
Normal file
18
internal/model/user_follow.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// UserFollow 关注关系
|
||||||
|
type UserFollow struct {
|
||||||
|
FollowerID uint `gorm:"primaryKey;not null" json:"follower_id"`
|
||||||
|
FolloweeID uint `gorm:"primaryKey;not null" json:"followee_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
// 联表查询填充(非数据库字段)
|
||||||
|
FollowerUsername string `gorm:"-:migration;<-:false;column:follower_username" json:"follower_username,omitempty"`
|
||||||
|
FolloweeUsername string `gorm:"-:migration;<-:false;column:followee_username" json:"followee_username,omitempty"`
|
||||||
|
FollowerAvatar string `gorm:"-:migration;<-:false;column:follower_avatar" json:"follower_avatar,omitempty"`
|
||||||
|
FolloweeAvatar string `gorm:"-:migration;<-:false;column:followee_avatar" json:"followee_avatar,omitempty"`
|
||||||
|
FollowerBio string `gorm:"-:migration;<-:false;column:follower_bio" json:"follower_bio,omitempty"`
|
||||||
|
FolloweeBio string `gorm:"-:migration;<-:false;column:followee_bio" json:"followee_bio,omitempty"`
|
||||||
|
}
|
||||||
23
internal/model/user_task.go
Normal file
23
internal/model/user_task.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// 任务类型常量
|
||||||
|
const (
|
||||||
|
TaskTypeAvatar = "avatar"
|
||||||
|
TaskTypeUsername = "username"
|
||||||
|
TaskTypeBio = "bio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskTypeNames 任务类型 → 中文名
|
||||||
|
var TaskTypeNames = map[string]string{
|
||||||
|
TaskTypeAvatar: "上传头像",
|
||||||
|
TaskTypeUsername: "设置用户名",
|
||||||
|
TaskTypeBio: "设置个性签名",
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserTask 用户首次完成任务记录(严格防重)
|
||||||
|
type UserTask struct {
|
||||||
|
BaseModel
|
||||||
|
|
||||||
|
UserID uint `gorm:"uniqueIndex:idx_user_task,priority:1;not null" json:"user_id"`
|
||||||
|
TaskType string `gorm:"type:varchar(20);uniqueIndex:idx_user_task,priority:2;not null" json:"task_type"`
|
||||||
|
}
|
||||||
184
internal/repository/comment_mention_repo.go
Normal file
184
internal/repository/comment_mention_repo.go
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
"metazone.cc/metalab/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateMention 创建@提及记录
|
||||||
|
func (r *CommentRepo) CreateMention(mention *model.CommentMention) error {
|
||||||
|
return r.db.Create(mention).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindMentionsByComment 查询某条评论的@提及列表
|
||||||
|
func (r *CommentRepo) FindMentionsByComment(commentID uint) ([]model.CommentMention, error) {
|
||||||
|
var list []model.CommentMention
|
||||||
|
err := r.db.Where("comment_id = ?", commentID).Find(&list).Error
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadMentionsForComments 批量加载多条评论的有效@提及映射
|
||||||
|
// key=创建时的原始@用户名, value={uid, 用户当前显示名}
|
||||||
|
// 用户改名后:原始名匹配body中的旧@文本,显示名随users表更新
|
||||||
|
func (r *CommentRepo) LoadMentionsForComments(comments []model.Comment) error {
|
||||||
|
if len(comments) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
commentIDs := make([]uint, len(comments))
|
||||||
|
for i, c := range comments {
|
||||||
|
commentIDs[i] = c.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
type mentionRow struct {
|
||||||
|
CommentID uint
|
||||||
|
UID uint
|
||||||
|
OriginalUsername string
|
||||||
|
CurrentName string
|
||||||
|
}
|
||||||
|
var rows []mentionRow
|
||||||
|
err := r.db.Table("comment_mentions").
|
||||||
|
Select("comment_mentions.comment_id, comment_mentions.uid, comment_mentions.original_username, users.username AS current_name").
|
||||||
|
Joins("LEFT JOIN users ON users.id = comment_mentions.uid").
|
||||||
|
Where("comment_mentions.comment_id IN ?", commentIDs).
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 comment_id 分组,key = original_username, value = MentionInfo
|
||||||
|
mentionMap := make(map[uint]map[string]model.MentionInfo)
|
||||||
|
for _, row := range rows {
|
||||||
|
if mentionMap[row.CommentID] == nil {
|
||||||
|
mentionMap[row.CommentID] = make(map[string]model.MentionInfo)
|
||||||
|
}
|
||||||
|
mentionMap[row.CommentID][row.OriginalUsername] = model.MentionInfo{
|
||||||
|
UID: row.UID,
|
||||||
|
Name: row.CurrentName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range comments {
|
||||||
|
if m, ok := mentionMap[comments[i].ID]; ok {
|
||||||
|
comments[i].Mentions = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchUsersByLevel 搜索指定等级及以上用户(用于@艾特),返回 uid/username/avatar/exp
|
||||||
|
// followerUID > 0 时按已关注优先排序,其次按exp降序
|
||||||
|
func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel, limit int, followerUID uint) ([]struct {
|
||||||
|
UID uint
|
||||||
|
Username string
|
||||||
|
Avatar string
|
||||||
|
Exp int
|
||||||
|
}, error) {
|
||||||
|
type result struct {
|
||||||
|
UID uint
|
||||||
|
Username string
|
||||||
|
Avatar string
|
||||||
|
Exp int
|
||||||
|
}
|
||||||
|
var list []result
|
||||||
|
|
||||||
|
threshold := model.LevelThresholds[minLevel]
|
||||||
|
|
||||||
|
q := r.db.Table("users AS u").
|
||||||
|
Select("u.id AS uid, u.username, u.avatar, u.exp").
|
||||||
|
Where("u.username LIKE ? AND u.exp >= ? AND u.deleted_at IS NULL", "%"+keyword+"%", threshold).
|
||||||
|
Order("u.exp DESC")
|
||||||
|
|
||||||
|
if followerUID > 0 {
|
||||||
|
q = q.Joins("LEFT JOIN user_follows f ON f.followee_id = u.id AND f.follower_id = ?", followerUID).
|
||||||
|
Order("(f.follower_id IS NOT NULL) DESC, u.exp DESC")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := q.Limit(limit).Find(&list).Error
|
||||||
|
|
||||||
|
var res []struct {
|
||||||
|
UID uint
|
||||||
|
Username string
|
||||||
|
Avatar string
|
||||||
|
Exp int
|
||||||
|
}
|
||||||
|
for _, u := range list {
|
||||||
|
res = append(res, struct {
|
||||||
|
UID uint
|
||||||
|
Username string
|
||||||
|
Avatar string
|
||||||
|
Exp int
|
||||||
|
}{UID: u.UID, Username: u.Username, Avatar: u.Avatar, Exp: u.Exp})
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsernameByID 通过 userID 获取用户名(用于通知内容)
|
||||||
|
func (r *CommentRepo) GetUsernameByID(userID uint) (string, error) {
|
||||||
|
var username string
|
||||||
|
err := r.db.Table("users").Select("username").Where("id = ?", userID).Scan(&username).Error
|
||||||
|
return username, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindPostIDByComment 查询评论所属的文章ID
|
||||||
|
func (r *CommentRepo) FindPostIDByComment(commentID uint) (uint, error) {
|
||||||
|
var postID uint
|
||||||
|
err := r.db.Table("comments").Select("post_id").Where("id = ?", commentID).Scan(&postID).Error
|
||||||
|
return postID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRootID 更新评论的 root_id 字段(顶级评论创建后回填)
|
||||||
|
func (r *CommentRepo) UpdateRootID(id, rootID uint) error {
|
||||||
|
return r.db.Model(&model.Comment{}).Where("id = ?", id).Update("root_id", rootID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindPostAuthorID 查询文章的作者 user_id
|
||||||
|
func (r *CommentRepo) FindPostAuthorID(postID uint) (uint, error) {
|
||||||
|
var authorID uint
|
||||||
|
err := r.db.Table("posts").Select("user_id").Where("id = ?", postID).Scan(&authorID).Error
|
||||||
|
return authorID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateCommentsByAuthor 按日期聚合评论量(作者所有文章在时间段内的评论)
|
||||||
|
func (r *CommentRepo) AggregateCommentsByAuthor(userID uint, since string) ([]service.TrendPoint, error) {
|
||||||
|
var results []service.TrendPoint
|
||||||
|
err := r.db.Table("comments").
|
||||||
|
Select("TO_CHAR(comments.created_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value").
|
||||||
|
Joins("JOIN posts ON posts.id = comments.post_id").
|
||||||
|
Where("posts.user_id = ? AND comments.created_at >= ?", userID, since).
|
||||||
|
Group("TO_CHAR(comments.created_at, 'YYYY-MM-DD')").
|
||||||
|
Order("date ASC").
|
||||||
|
Scan(&results).Error
|
||||||
|
if results == nil {
|
||||||
|
results = []service.TrendPoint{}
|
||||||
|
}
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllComments 后台评论列表(支持关键词搜索、分页)
|
||||||
|
func (r *CommentRepo) ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error) {
|
||||||
|
var total int64
|
||||||
|
q := r.db.Table("comments").
|
||||||
|
Joins("LEFT JOIN users ON users.id = comments.user_id").
|
||||||
|
Joins("LEFT JOIN posts ON posts.id = comments.post_id")
|
||||||
|
|
||||||
|
if !showDeleted {
|
||||||
|
q = q.Where("comments.is_deleted = false")
|
||||||
|
}
|
||||||
|
if keyword != "" {
|
||||||
|
like := "%" + keyword + "%"
|
||||||
|
q = q.Where("comments.body LIKE ? OR users.username LIKE ? OR posts.title LIKE ?", like, like, like)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []model.AdminCommentRow
|
||||||
|
err := q.
|
||||||
|
Select("comments.id, comments.post_id, posts.title as post_title, comments.body, users.username as author_name, comments.is_deleted, comments.created_at, comments.updated_at").
|
||||||
|
Order("comments.created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Scan(&rows).Error
|
||||||
|
return rows, total, err
|
||||||
|
}
|
||||||
173
internal/repository/comment_repo.go
Normal file
173
internal/repository/comment_repo.go
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CommentRepo 评论数据访问
|
||||||
|
type CommentRepo struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCommentRepo 构造函数
|
||||||
|
func NewCommentRepo(db *gorm.DB) *CommentRepo {
|
||||||
|
return &CommentRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建评论
|
||||||
|
func (r *CommentRepo) Create(comment *model.Comment) error {
|
||||||
|
return r.db.Create(comment).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID 按 ID 查找评论(含作者信息)
|
||||||
|
func (r *CommentRepo) FindByID(id uint) (*model.Comment, error) {
|
||||||
|
var c model.Comment
|
||||||
|
err := r.db.Table("comments").
|
||||||
|
Select("comments.*, users.username as author_name, users.avatar as author_avatar").
|
||||||
|
Joins("LEFT JOIN users ON users.id = comments.user_id").
|
||||||
|
Where("comments.id = ?", id).
|
||||||
|
First(&c).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindRootComments 分页查询文章的顶级评论(parent_id IS NULL,未删除)
|
||||||
|
func (r *CommentRepo) FindRootComments(postID uint, offset, limit int) ([]model.Comment, int64, error) {
|
||||||
|
var total int64
|
||||||
|
base := r.db.Table("comments").
|
||||||
|
Where("post_id = ? AND parent_id IS NULL AND is_deleted = false", postID)
|
||||||
|
|
||||||
|
if err := base.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []model.Comment
|
||||||
|
err := base.
|
||||||
|
Select("comments.*, users.username as author_name, users.avatar as author_avatar").
|
||||||
|
Joins("LEFT JOIN users ON users.id = comments.user_id").
|
||||||
|
Order("comments.created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Find(&list).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每条顶级评论填充回复数
|
||||||
|
if len(list) > 0 {
|
||||||
|
ids := make([]uint, len(list))
|
||||||
|
for i, c := range list {
|
||||||
|
ids[i] = c.ID
|
||||||
|
}
|
||||||
|
countMap := r.batchCountReplies(ids)
|
||||||
|
for i := range list {
|
||||||
|
list[i].RepliesCount = countMap[list[i].ID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindReplies 查询某条顶级评论的所有回复(不分页,全量返回,未删除)
|
||||||
|
func (r *CommentRepo) FindReplies(rootID uint) ([]model.Comment, error) {
|
||||||
|
var list []model.Comment
|
||||||
|
err := r.db.Table("comments").
|
||||||
|
Select("comments.*, users.username as author_name, users.avatar as author_avatar").
|
||||||
|
Joins("LEFT JOIN users ON users.id = comments.user_id").
|
||||||
|
Where("comments.root_id = ? AND comments.parent_id IS NOT NULL AND comments.is_deleted = false", rootID).
|
||||||
|
Order("comments.created_at ASC").
|
||||||
|
Find(&list).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充 reply_to_name
|
||||||
|
r.fillReplyToNames(list)
|
||||||
|
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillReplyToNames 通过 JOIN users 获取被回复者当前显示名
|
||||||
|
func (r *CommentRepo) fillReplyToNames(list []model.Comment) {
|
||||||
|
if len(list) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type nameRow struct {
|
||||||
|
UID uint
|
||||||
|
Username string
|
||||||
|
}
|
||||||
|
var rows []nameRow
|
||||||
|
r.db.Table("users").
|
||||||
|
Select("id, username").
|
||||||
|
Where("id IN (SELECT reply_to_uid FROM comments WHERE root_id IN (?) AND parent_id IS NOT NULL)", r.rootIDsFromList(list)).
|
||||||
|
Find(&rows)
|
||||||
|
|
||||||
|
m := make(map[uint]string)
|
||||||
|
for _, row := range rows {
|
||||||
|
m[row.UID] = row.Username
|
||||||
|
}
|
||||||
|
for i := range list {
|
||||||
|
if list[i].ReplyToUID != 0 {
|
||||||
|
list[i].ReplyToName = m[list[i].ReplyToUID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CommentRepo) rootIDsFromList(list []model.Comment) []uint {
|
||||||
|
ids := make([]uint, len(list))
|
||||||
|
for i, c := range list {
|
||||||
|
ids[i] = c.RootID
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchCountReplies 批量统计回复数
|
||||||
|
func (r *CommentRepo) batchCountReplies(rootIDs []uint) map[uint]int {
|
||||||
|
type countRow struct {
|
||||||
|
RootID uint
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
var rows []countRow
|
||||||
|
r.db.Table("comments").
|
||||||
|
Select("root_id, COUNT(*) as count").
|
||||||
|
Where("root_id IN ? AND parent_id IS NOT NULL AND is_deleted = false", rootIDs).
|
||||||
|
Group("root_id").
|
||||||
|
Find(&rows)
|
||||||
|
|
||||||
|
m := make(map[uint]int)
|
||||||
|
for _, row := range rows {
|
||||||
|
m[row.RootID] = row.Count
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDelete 软删除评论(设置 is_deleted=true)
|
||||||
|
func (r *CommentRepo) SoftDelete(id uint) error {
|
||||||
|
return r.db.Model(&model.Comment{}).Where("id = ?", id).Update("is_deleted", true).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrCommentsCount 文章评论数+1
|
||||||
|
func (r *CommentRepo) IncrCommentsCount(postID uint) error {
|
||||||
|
return r.db.Model(&model.Post{}).Where("id = ?", postID).
|
||||||
|
UpdateColumn("comments_count", gorm.Expr("comments_count + 1")).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecrCommentsCount 文章评论数-1
|
||||||
|
func (r *CommentRepo) DecrCommentsCount(postID uint) error {
|
||||||
|
return r.db.Model(&model.Post{}).Where("id = ?", postID).
|
||||||
|
UpdateColumn("comments_count", gorm.Expr("GREATEST(comments_count - 1, 0)")).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountRepliesByRootByStatus 统计某条顶级评论的回复数(可选是否统计已删除)
|
||||||
|
func (r *CommentRepo) CountRepliesByRoot(rootID uint, includeDeleted bool) (int, error) {
|
||||||
|
var count int64
|
||||||
|
q := r.db.Model(&model.Comment{}).Where("root_id = ? AND parent_id IS NOT NULL", rootID)
|
||||||
|
if !includeDeleted {
|
||||||
|
q = q.Where("is_deleted = false")
|
||||||
|
}
|
||||||
|
err := q.Count(&count).Error
|
||||||
|
return int(count), err
|
||||||
|
}
|
||||||
182
internal/repository/energy_repo.go
Normal file
182
internal/repository/energy_repo.go
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
"metazone.cc/metalab/internal/service"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnergyRepo 域能数据访问
|
||||||
|
type EnergyRepo struct {
|
||||||
|
db *gorm.DB
|
||||||
|
fundStore service.FundStore // 用于 Transaction 时创建事务范围的 FundStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEnergyRepo 构造函数
|
||||||
|
func NewEnergyRepo(db *gorm.DB) *EnergyRepo {
|
||||||
|
return &EnergyRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFundStore 设置公户仓储引用(用于跨仓库事务)
|
||||||
|
func (r *EnergyRepo) SetFundStore(s service.FundStore) {
|
||||||
|
r.fundStore = s
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTx 基于给定事务连接创建新的 EnergyRepo(确保事务内操作原子性)
|
||||||
|
func (r *EnergyRepo) WithTx(tx *gorm.DB) service.EnergyStore {
|
||||||
|
return &EnergyRepo{db: tx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction 在事务内执行业务逻辑,自动管理 EnergyStore 和 FundStore 的事务范围
|
||||||
|
func (r *EnergyRepo) Transaction(fn func(txEnergy service.EnergyStore, txFund service.FundStore) error) error {
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
txEnergy := r.WithTx(tx)
|
||||||
|
var txFund service.FundStore
|
||||||
|
if r.fundStore != nil {
|
||||||
|
txFund = r.fundStore.WithTx(tx)
|
||||||
|
}
|
||||||
|
return fn(txEnergy, txFund)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindUserByID 查找用户(获取能量余额等)
|
||||||
|
func (r *EnergyRepo) FindUserByID(userID uint) (*model.User, error) {
|
||||||
|
var user model.User
|
||||||
|
err := r.db.First(&user, userID).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindPostByID 查找帖子
|
||||||
|
func (r *EnergyRepo) FindPostByID(postID uint) (*model.Post, error) {
|
||||||
|
var post model.Post
|
||||||
|
err := r.db.First(&post, postID).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddEnergy 原子增减用户域能余额(amount 可为正负)
|
||||||
|
func (r *EnergyRepo) AddEnergy(userID uint, amount int) error {
|
||||||
|
return r.db.Model(&model.User{}).
|
||||||
|
Where("id = ?", userID).
|
||||||
|
UpdateColumn("energy", gorm.Expr("energy + ?", amount)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrPostEnergy 原子增减文章累计被赋能域能
|
||||||
|
func (r *EnergyRepo) IncrPostEnergy(postID uint, amount int) error {
|
||||||
|
return r.db.Model(&model.Post{}).
|
||||||
|
Where("id = ?", postID).
|
||||||
|
UpdateColumn("total_energy_received", gorm.Expr("total_energy_received + ?", amount)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEnergyLog 创建域能流水
|
||||||
|
func (r *EnergyRepo) CreateEnergyLog(log *model.EnergyLog) error {
|
||||||
|
return r.db.Create(log).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SumUserPostEnergy 统计某用户对某文章累计赋能总量(用于 ≤ 20 检查)
|
||||||
|
func (r *EnergyRepo) SumUserPostEnergy(userID, postID uint) (int, error) {
|
||||||
|
var total int
|
||||||
|
err := r.db.Model(&model.PostEnergizeLog{}).
|
||||||
|
Where("user_id = ? AND post_id = ?", userID, postID).
|
||||||
|
Select("COALESCE(SUM(amount), 0)").
|
||||||
|
Scan(&total).Error
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEnergizeLog 创建赋能记录
|
||||||
|
func (r *EnergyRepo) CreateEnergizeLog(log *model.PostEnergizeLog) error {
|
||||||
|
return r.db.Create(log).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDailyEnergizeExp 获取用户当日通过赋能获得的经验值
|
||||||
|
// 无记录时返回 nil, nil(调用方当作 exp=0 处理)
|
||||||
|
func (r *EnergyRepo) GetDailyEnergizeExp(userID uint, date time.Time) (*model.DailyExpSummary, error) {
|
||||||
|
var summaries []model.DailyExpSummary
|
||||||
|
err := r.db.Where("user_id = ? AND date = ?", userID, date).Limit(1).Find(&summaries).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(summaries) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &summaries[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertDailyExp 创建或更新每日经验汇总
|
||||||
|
func (r *EnergyRepo) UpsertDailyExp(summary *model.DailyExpSummary) error {
|
||||||
|
return r.db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "user_id"}, {Name: "date"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"exp_earned", "updated_at"}),
|
||||||
|
}).Create(summary).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDailyExp 原子增加每日赋能经验值
|
||||||
|
func (r *EnergyRepo) AddDailyExp(userID uint, date time.Time, delta int) error {
|
||||||
|
return r.db.Model(&model.DailyExpSummary{}).
|
||||||
|
Where("user_id = ? AND date = ?", userID, date).
|
||||||
|
UpdateColumn("exp_earned", gorm.Expr("exp_earned + ?", delta)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEnergyLogsByUser 分页查询用户域能流水(近 N 天)
|
||||||
|
func (r *EnergyRepo) ListEnergyLogsByUser(userID uint, days int, offset, limit int) ([]model.EnergyLog, error) {
|
||||||
|
var logs []model.EnergyLog
|
||||||
|
query := r.db.Where("user_id = ?", userID).Order("created_at DESC")
|
||||||
|
if days > 0 {
|
||||||
|
since := time.Now().AddDate(0, 0, -days)
|
||||||
|
query = query.Where("created_at >= ?", since)
|
||||||
|
}
|
||||||
|
err := query.Offset(offset).Limit(limit).Find(&logs).Error
|
||||||
|
return logs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountEnergyLogsByUser 统计用户域能流水总数
|
||||||
|
func (r *EnergyRepo) CountEnergyLogsByUser(userID uint, days int) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
query := r.db.Model(&model.EnergyLog{}).Where("user_id = ?", userID)
|
||||||
|
if days > 0 {
|
||||||
|
since := time.Now().AddDate(0, 0, -days)
|
||||||
|
query = query.Where("created_at >= ?", since)
|
||||||
|
}
|
||||||
|
err := query.Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllEnergyLogs 查询全部域能流水(后台,支持筛选)
|
||||||
|
func (r *EnergyRepo) ListAllEnergyLogs(energyType string, offset, limit int) ([]model.EnergyLog, int64, error) {
|
||||||
|
query := r.db.Model(&model.EnergyLog{})
|
||||||
|
if energyType != "" {
|
||||||
|
query = query.Where("type = ?", energyType)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var logs []model.EnergyLog
|
||||||
|
err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error
|
||||||
|
return logs, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateEnergyByAuthor 按日期聚合赋能值(作者所有文章在时间段内的赋能记录)
|
||||||
|
func (r *EnergyRepo) AggregateEnergyByAuthor(userID uint, since string) ([]service.TrendPoint, error) {
|
||||||
|
var results []service.TrendPoint
|
||||||
|
err := r.db.Table("post_energize_logs").
|
||||||
|
Select("TO_CHAR(post_energize_logs.created_at, 'YYYY-MM-DD') AS date, COALESCE(SUM(post_energize_logs.amount), 0) AS value").
|
||||||
|
Joins("JOIN posts ON posts.id = post_energize_logs.post_id").
|
||||||
|
Where("posts.user_id = ? AND post_energize_logs.created_at >= ?", userID, since).
|
||||||
|
Group("TO_CHAR(post_energize_logs.created_at, 'YYYY-MM-DD')").
|
||||||
|
Order("date ASC").
|
||||||
|
Scan(&results).Error
|
||||||
|
if results == nil {
|
||||||
|
results = []service.TrendPoint{}
|
||||||
|
}
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
164
internal/repository/favorite_repo.go
Normal file
164
internal/repository/favorite_repo.go
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
"metazone.cc/metalab/internal/service"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FavoriteRepo 收藏夹数据访问
|
||||||
|
type FavoriteRepo struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFavoriteRepo 构造函数
|
||||||
|
func NewFavoriteRepo(db *gorm.DB) *FavoriteRepo {
|
||||||
|
return &FavoriteRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTx 基于事务连接创建 FavoriteRepo
|
||||||
|
func (r *FavoriteRepo) WithTx(tx *gorm.DB) service.FavoriteStore {
|
||||||
|
return &FavoriteRepo{db: tx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction 在事务内执行业务逻辑
|
||||||
|
func (r *FavoriteRepo) Transaction(fn func(tx service.FavoriteStore) error) error {
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
return fn(r.WithTx(tx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== Folder CRUD ========================
|
||||||
|
|
||||||
|
// CreateFolder 创建收藏夹
|
||||||
|
func (r *FavoriteRepo) CreateFolder(folder *model.Folder) error {
|
||||||
|
return r.db.Create(folder).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindFolderByID 按 ID 查找收藏夹
|
||||||
|
func (r *FavoriteRepo) FindFolderByID(id uint) (*model.Folder, error) {
|
||||||
|
var f model.Folder
|
||||||
|
err := r.db.First(&f, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFoldersByUser 列用户的收藏夹(附项目数)
|
||||||
|
func (r *FavoriteRepo) ListFoldersByUser(userID uint) ([]model.Folder, error) {
|
||||||
|
var folders []model.Folder
|
||||||
|
err := r.db.Table("folders").
|
||||||
|
Select("folders.*, COUNT(folder_items.id) AS item_count").
|
||||||
|
Joins("LEFT JOIN folder_items ON folder_items.folder_id = folders.id").
|
||||||
|
Where("folders.user_id = ?", userID).
|
||||||
|
Group("folders.id").
|
||||||
|
Order("folders.is_default DESC, folders.created_at ASC").
|
||||||
|
Scan(&folders).Error
|
||||||
|
return folders, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFolder 更新收藏夹
|
||||||
|
func (r *FavoriteRepo) UpdateFolder(folder *model.Folder) error {
|
||||||
|
return r.db.Save(folder).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFolder 删除收藏夹
|
||||||
|
func (r *FavoriteRepo) DeleteFolder(id uint) error {
|
||||||
|
return r.db.Where("id = ?", id).Delete(&model.Folder{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteItemsByFolder 删除收藏夹内所有记录
|
||||||
|
func (r *FavoriteRepo) DeleteItemsByFolder(folderID uint) error {
|
||||||
|
return r.db.Where("folder_id = ?", folderID).Delete(&model.FolderItem{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByNameAndUser 检查同用户下名称是否重复(排除自身)
|
||||||
|
func (r *FavoriteRepo) CountByNameAndUser(userID uint, name string, excludeID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
q := r.db.Model(&model.Folder{}).Where("user_id = ? AND name = ?", userID, name)
|
||||||
|
if excludeID > 0 {
|
||||||
|
q = q.Where("id != ?", excludeID)
|
||||||
|
}
|
||||||
|
err := q.Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasDefaultFolder 检查用户是否有默认收藏夹
|
||||||
|
func (r *FavoriteRepo) HasDefaultFolder(userID uint) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&model.Folder{}).Where("user_id = ? AND is_default = ?", userID, true).Count(&count).Error
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== FolderItem CRUD ========================
|
||||||
|
|
||||||
|
// CreateItem 添加收藏
|
||||||
|
func (r *FavoriteRepo) CreateItem(item *model.FolderItem) error {
|
||||||
|
return r.db.Create(item).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteItem 取消收藏
|
||||||
|
func (r *FavoriteRepo) DeleteItem(folderID, postID uint) error {
|
||||||
|
return r.db.Where("folder_id = ? AND post_id = ?", folderID, postID).Delete(&model.FolderItem{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindItemByUserAndPost 查找用户对某文章的收藏记录
|
||||||
|
func (r *FavoriteRepo) FindItemByUserAndPost(userID, postID uint) (*model.FolderItem, error) {
|
||||||
|
var item model.FolderItem
|
||||||
|
err := r.db.Where("user_id = ? AND post_id = ?", userID, postID).First(&item).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListItemsByFolder 列收藏夹内文章(分页,JOIN posts 获取标题)
|
||||||
|
func (r *FavoriteRepo) ListItemsByFolder(folderID uint, offset, limit int) ([]model.FolderItem, int64, error) {
|
||||||
|
var total int64
|
||||||
|
if err := r.db.Model(&model.FolderItem{}).Where("folder_id = ?", folderID).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []model.FolderItem
|
||||||
|
err := r.db.Table("folder_items").
|
||||||
|
Select("folder_items.*, posts.title AS post_title").
|
||||||
|
Joins("LEFT JOIN posts ON posts.id = folder_items.post_id").
|
||||||
|
Where("folder_items.folder_id = ?", folderID).
|
||||||
|
Order("folder_items.created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Scan(&items).Error
|
||||||
|
return items, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountItemsByFolder 统计收藏夹内文章数
|
||||||
|
func (r *FavoriteRepo) CountItemsByFolder(folderID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&model.FolderItem{}).Where("folder_id = ?", folderID).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== Post 计数器 ========================
|
||||||
|
|
||||||
|
// IncrPostFavoritesCount 原子更新文章收藏数
|
||||||
|
func (r *FavoriteRepo) IncrPostFavoritesCount(postID uint, delta int) error {
|
||||||
|
return r.db.Model(&model.Post{}).
|
||||||
|
Where("id = ?", postID).
|
||||||
|
UpdateColumn("favorites_count", gorm.Expr("favorites_count + ?", delta)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== 趋势聚合 ========================
|
||||||
|
|
||||||
|
// AggregateFavoritesByAuthor 按日期聚合收藏量(对于某用户的文章)
|
||||||
|
func (r *FavoriteRepo) AggregateFavoritesByAuthor(userID uint, since string) ([]service.TrendPoint, error) {
|
||||||
|
var results []service.TrendPoint
|
||||||
|
err := r.db.Table("folder_items").
|
||||||
|
Select("TO_CHAR(folder_items.created_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value").
|
||||||
|
Joins("JOIN posts ON posts.id = folder_items.post_id").
|
||||||
|
Where("posts.user_id = ? AND folder_items.created_at >= ?", userID, since).
|
||||||
|
Group("TO_CHAR(folder_items.created_at, 'YYYY-MM-DD')").
|
||||||
|
Order("date ASC").
|
||||||
|
Scan(&results).Error
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
122
internal/repository/follow_repo.go
Normal file
122
internal/repository/follow_repo.go
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
"metazone.cc/metalab/internal/service"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FollowRepo 关注数据访问
|
||||||
|
type FollowRepo struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFollowRepo 构造函数
|
||||||
|
func NewFollowRepo(db *gorm.DB) *FollowRepo {
|
||||||
|
return &FollowRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTx 基于事务连接创建 FollowRepo
|
||||||
|
func (r *FollowRepo) WithTx(tx *gorm.DB) service.FollowStore {
|
||||||
|
return &FollowRepo{db: tx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction 在事务内执行业务逻辑
|
||||||
|
func (r *FollowRepo) Transaction(fn func(tx service.FollowStore) error) error {
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
return fn(r.WithTx(tx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建关注
|
||||||
|
func (r *FollowRepo) Create(follow *model.UserFollow) error {
|
||||||
|
return r.db.Create(follow).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 取消关注
|
||||||
|
func (r *FollowRepo) Delete(followerID, followeeID uint) error {
|
||||||
|
return r.db.Where("follower_id = ? AND followee_id = ?", followerID, followeeID).
|
||||||
|
Delete(&model.UserFollow{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists 检查是否已关注
|
||||||
|
func (r *FollowRepo) Exists(followerID, followeeID uint) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&model.UserFollow{}).
|
||||||
|
Where("follower_id = ? AND followee_id = ?", followerID, followeeID).
|
||||||
|
Count(&count).Error
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountFollowers 粉丝数
|
||||||
|
func (r *FollowRepo) CountFollowers(userID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&model.UserFollow{}).Where("followee_id = ?", userID).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountFollowing 关注数
|
||||||
|
func (r *FollowRepo) CountFollowing(userID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&model.UserFollow{}).Where("follower_id = ?", userID).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFollowers 粉丝列表(分页,JOIN users 获取用户名、头像、简介)
|
||||||
|
func (r *FollowRepo) ListFollowers(userID uint, offset, limit int) ([]model.UserFollow, int64, error) {
|
||||||
|
var total int64
|
||||||
|
if err := r.db.Model(&model.UserFollow{}).Where("followee_id = ?", userID).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var items []model.UserFollow
|
||||||
|
err := r.db.Table("user_follows").
|
||||||
|
Select("user_follows.*, u.username AS follower_username, u.avatar AS follower_avatar, u.bio AS follower_bio").
|
||||||
|
Joins("INNER JOIN users u ON u.id = user_follows.follower_id").
|
||||||
|
Where("user_follows.followee_id = ?", userID).
|
||||||
|
Order("user_follows.created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Scan(&items).Error
|
||||||
|
return items, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFollowing 关注列表(分页,JOIN users 获取用户名、头像、简介)
|
||||||
|
func (r *FollowRepo) ListFollowing(userID uint, offset, limit int) ([]model.UserFollow, int64, error) {
|
||||||
|
var total int64
|
||||||
|
if err := r.db.Model(&model.UserFollow{}).Where("follower_id = ?", userID).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var items []model.UserFollow
|
||||||
|
err := r.db.Table("user_follows").
|
||||||
|
Select("user_follows.*, u.username AS followee_username, u.avatar AS followee_avatar, u.bio AS followee_bio").
|
||||||
|
Joins("INNER JOIN users u ON u.id = user_follows.followee_id").
|
||||||
|
Where("user_follows.follower_id = ?", userID).
|
||||||
|
Order("user_follows.created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Scan(&items).Error
|
||||||
|
return items, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrFollowersCount 原子增减粉丝数
|
||||||
|
func (r *FollowRepo) IncrFollowersCount(userID uint, delta int) error {
|
||||||
|
return r.db.Model(&model.User{}).
|
||||||
|
Where("id = ?", userID).
|
||||||
|
UpdateColumn("followers_count", gorm.Expr("followers_count + ?", delta)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrFollowingCount 原子增减关注数
|
||||||
|
func (r *FollowRepo) IncrFollowingCount(userID uint, delta int) error {
|
||||||
|
return r.db.Model(&model.User{}).
|
||||||
|
Where("id = ?", userID).
|
||||||
|
UpdateColumn("following_count", gorm.Expr("following_count + ?", delta)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFollowListPublic 查询用户关注列表公开性
|
||||||
|
func (r *FollowRepo) GetFollowListPublic(userID uint) (bool, error) {
|
||||||
|
var listPublic bool
|
||||||
|
err := r.db.Model(&model.User{}).
|
||||||
|
Select("follow_list_public").
|
||||||
|
Where("id = ?", userID).
|
||||||
|
Scan(&listPublic).Error
|
||||||
|
return listPublic, err
|
||||||
|
}
|
||||||
71
internal/repository/fund_repo.go
Normal file
71
internal/repository/fund_repo.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
"metazone.cc/metalab/internal/service"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FundRepo 公户数据访问
|
||||||
|
type FundRepo struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFundRepo 构造函数
|
||||||
|
func NewFundRepo(db *gorm.DB) *FundRepo {
|
||||||
|
return &FundRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTx 基于给定事务连接创建新的 FundRepo
|
||||||
|
func (r *FundRepo) WithTx(tx *gorm.DB) service.FundStore {
|
||||||
|
return &FundRepo{db: tx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBalance 查询公户余额
|
||||||
|
func (r *FundRepo) GetBalance() (int, error) {
|
||||||
|
var fund model.CommunityFund
|
||||||
|
err := r.db.First(&fund, 1).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return fund.Balance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockAndGetBalance 加行锁查询公户余额(事务内使用,防止 TOCTOU 竞态)
|
||||||
|
func (r *FundRepo) LockAndGetBalance() (int, error) {
|
||||||
|
var fund model.CommunityFund
|
||||||
|
err := r.db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&fund, 1).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return fund.Balance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrBalance 原子增减公户余额
|
||||||
|
func (r *FundRepo) IncrBalance(amount int) error {
|
||||||
|
return r.db.Model(&model.CommunityFund{}).
|
||||||
|
Where("id = 1").
|
||||||
|
UpdateColumn("balance", gorm.Expr("balance + ?", amount)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLog 创建公户流水
|
||||||
|
func (r *FundRepo) CreateLog(log *model.FundLog) error {
|
||||||
|
return r.db.Create(log).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLogs 分页查询公户流水
|
||||||
|
func (r *FundRepo) ListLogs(logType string, offset, limit int) ([]model.FundLog, int64, error) {
|
||||||
|
query := r.db.Model(&model.FundLog{})
|
||||||
|
if logType != "" {
|
||||||
|
query = query.Where("type = ?", logType)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var logs []model.FundLog
|
||||||
|
err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error
|
||||||
|
return logs, total, err
|
||||||
|
}
|
||||||
75
internal/repository/level_repo.go
Normal file
75
internal/repository/level_repo.go
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LevelRepo 积分/等级/签到/任务数据访问
|
||||||
|
type LevelRepo struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLevelRepo 构造函数
|
||||||
|
func NewLevelRepo(db *gorm.DB) *LevelRepo {
|
||||||
|
return &LevelRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID 按 UID 查找用户(实现 userLevelStore 接口)
|
||||||
|
func (r *LevelRepo) FindByID(id uint) (*model.User, error) {
|
||||||
|
var user model.User
|
||||||
|
err := r.db.First(&user, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddExp 原子增加用户经验值
|
||||||
|
func (r *LevelRepo) AddExp(userID uint, delta int) error {
|
||||||
|
return r.db.Model(&model.User{}).
|
||||||
|
Where("id = ?", userID).
|
||||||
|
UpdateColumn("exp", gorm.Expr("exp + ?", delta)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindCheckIn 查询指定自然日的签到记录
|
||||||
|
func (r *LevelRepo) FindCheckIn(userID uint, date time.Time) (*model.UserCheckIn, error) {
|
||||||
|
var ci model.UserCheckIn
|
||||||
|
err := r.db.Where("user_id = ? AND date = ?", userID, date).First(&ci).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ci, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCheckIn 创建签到记录
|
||||||
|
func (r *LevelRepo) CreateCheckIn(ci *model.UserCheckIn) error {
|
||||||
|
return r.db.Create(ci).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindTask 查询用户是否已完成指定任务
|
||||||
|
func (r *LevelRepo) FindTask(userID uint, taskType string) (*model.UserTask, error) {
|
||||||
|
var t model.UserTask
|
||||||
|
err := r.db.Where("user_id = ? AND task_type = ?", userID, taskType).First(&t).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTask 创建任务完成记录
|
||||||
|
func (r *LevelRepo) CreateTask(task *model.UserTask) error {
|
||||||
|
return r.db.Create(task).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasTask 检查用户是否已完成过指定任务(无副作用查询)
|
||||||
|
func (r *LevelRepo) HasTask(userID uint, taskType string) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&model.UserTask{}).
|
||||||
|
Where("user_id = ? AND task_type = ?", userID, taskType).
|
||||||
|
Count(&count).Error
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
@ -60,3 +60,88 @@ func (r *NotificationRepo) MarkAllRead(userID uint) error {
|
|||||||
Where("user_id = ? AND is_read = false", userID).
|
Where("user_id = ? AND is_read = false", userID).
|
||||||
Update("is_read", true).Error
|
Update("is_read", true).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListByUserAndType 按类型分页查询用户消息
|
||||||
|
func (r *NotificationRepo) ListByUserAndType(userID uint, notifyType string, offset, limit int) ([]model.Notification, error) {
|
||||||
|
var list []model.Notification
|
||||||
|
err := r.db.Where("user_id = ? AND notify_type = ?", userID, notifyType).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Find(&list).Error
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByUserAndType 按类型统计用户消息数
|
||||||
|
func (r *NotificationRepo) CountByUserAndType(userID uint, notifyType string) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&model.Notification{}).Where("user_id = ? AND notify_type = ?", userID, notifyType).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUnreadByType 按类型统计用户未读消息数
|
||||||
|
func (r *NotificationRepo) CountUnreadByType(userID uint, notifyType string) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&model.Notification{}).
|
||||||
|
Where("user_id = ? AND is_read = false AND notify_type = ?", userID, notifyType).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUnnotifiedDailyLikeSummaries 获取未通知的每日点赞汇总
|
||||||
|
func (r *NotificationRepo) GetUnnotifiedDailyLikeSummaries(userID uint) ([]model.DailyLikeSummary, error) {
|
||||||
|
var summaries []model.DailyLikeSummary
|
||||||
|
err := r.db.Where("author_uid = ? AND notified = ?", userID, false).Find(&summaries).Error
|
||||||
|
return summaries, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkDailyLikeSummaryNotified 标记汇总已通知
|
||||||
|
func (r *NotificationRepo) MarkDailyLikeSummaryNotified(id uint) error {
|
||||||
|
return r.db.Model(&model.DailyLikeSummary{}).Where("id = ?", id).Update("notified", true).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsernameByID 根据用户ID获取用户名
|
||||||
|
func (r *NotificationRepo) GetUsernameByID(userID uint) (string, error) {
|
||||||
|
var user model.User
|
||||||
|
err := r.db.Select("username").First(&user, userID).Error
|
||||||
|
return user.Username, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotifyPrefs 获取用户通知偏好(NotifyPrefs 字段原始 JSON)
|
||||||
|
func (r *NotificationRepo) GetNotifyPrefs(userID uint) (string, error) {
|
||||||
|
var prefs string
|
||||||
|
err := r.db.Table("users").Select("notify_prefs").Where("id = ?", userID).Scan(&prefs).Error
|
||||||
|
return prefs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByUserExcludeTypes 分页查询用户消息(排除指定通知类型)
|
||||||
|
func (r *NotificationRepo) ListByUserExcludeTypes(userID uint, excludeTypes []string, offset, limit int) ([]model.Notification, error) {
|
||||||
|
var list []model.Notification
|
||||||
|
q := r.db.Where("user_id = ?", userID)
|
||||||
|
if len(excludeTypes) > 0 {
|
||||||
|
q = q.Where("notify_type NOT IN ?", excludeTypes)
|
||||||
|
}
|
||||||
|
err := q.Order("created_at DESC").Offset(offset).Limit(limit).Find(&list).Error
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByUserExcludeTypes 统计用户消息总数(排除指定通知类型)
|
||||||
|
func (r *NotificationRepo) CountByUserExcludeTypes(userID uint, excludeTypes []string) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
q := r.db.Model(&model.Notification{}).Where("user_id = ?", userID)
|
||||||
|
if len(excludeTypes) > 0 {
|
||||||
|
q = q.Where("notify_type NOT IN ?", excludeTypes)
|
||||||
|
}
|
||||||
|
err := q.Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUnreadExcludeTypes 统计用户未读消息数(排除指定通知类型)
|
||||||
|
func (r *NotificationRepo) CountUnreadExcludeTypes(userID uint, excludeTypes []string) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
q := r.db.Model(&model.Notification{}).Where("user_id = ? AND is_read = false", userID)
|
||||||
|
if len(excludeTypes) > 0 {
|
||||||
|
q = q.Where("notify_type NOT IN ?", excludeTypes)
|
||||||
|
}
|
||||||
|
err := q.Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|||||||
@ -36,7 +36,7 @@ func (r *PostRepo) FindByIDWithAuthor(id uint) (*model.Post, error) {
|
|||||||
var post model.Post
|
var post model.Post
|
||||||
err := r.db.Table("posts").
|
err := r.db.Table("posts").
|
||||||
Select("posts.*, users.username as author_name").
|
Select("posts.*, users.username as author_name").
|
||||||
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
Joins("LEFT JOIN users ON users.id = posts.user_id").
|
||||||
Where("posts.id = ?", id).
|
Where("posts.id = ?", id).
|
||||||
First(&post).Error
|
First(&post).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -49,7 +49,7 @@ func (r *PostRepo) FindByIDWithAuthor(id uint) (*model.Post, error) {
|
|||||||
func (r *PostRepo) buildQuery(keyword, status string) *gorm.DB {
|
func (r *PostRepo) buildQuery(keyword, status string) *gorm.DB {
|
||||||
query := r.db.Table("posts").
|
query := r.db.Table("posts").
|
||||||
Select("posts.*, users.username as author_name").
|
Select("posts.*, users.username as author_name").
|
||||||
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
Joins("LEFT JOIN users ON users.id = posts.user_id").
|
||||||
Where("posts.deleted_at IS NULL")
|
Where("posts.deleted_at IS NULL")
|
||||||
|
|
||||||
if keyword != "" {
|
if keyword != "" {
|
||||||
@ -137,6 +137,24 @@ func (r *PostRepo) SoftDelete(id uint) error {
|
|||||||
return r.db.Delete(&model.Post{}, id).Error
|
return r.db.Delete(&model.Post{}, id).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountPostsByStatus 统计帖子数量(status 为空则统计全部未删除帖子)
|
||||||
|
func (r *PostRepo) CountPostsByStatus(status string) (int64, error) {
|
||||||
|
query := r.buildQuery("", status)
|
||||||
|
var total int64
|
||||||
|
err := query.Count(&total).Error
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountPending 统计待审核帖子数(pending 状态 + approved 但有待审修订)
|
||||||
|
func (r *PostRepo) CountPending() (int64, error) {
|
||||||
|
var total int64
|
||||||
|
err := r.db.Table("posts").
|
||||||
|
Where("deleted_at IS NULL").
|
||||||
|
Where("status = ? OR (status = ? AND pending_body != '')", model.PostStatusPending, model.PostStatusApproved).
|
||||||
|
Count(&total).Error
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
// Restore 恢复软删除
|
// Restore 恢复软删除
|
||||||
func (r *PostRepo) Restore(id uint) error {
|
func (r *PostRepo) Restore(id uint) error {
|
||||||
result := r.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil)
|
result := r.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil)
|
||||||
|
|||||||
134
internal/repository/post_stats_repo.go
Normal file
134
internal/repository/post_stats_repo.go
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
"metazone.cc/metalab/internal/service"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CountUserPosts 统计某用户的已发布文章数
|
||||||
|
func (r *PostRepo) CountUserPosts(userID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&model.Post{}).
|
||||||
|
Where("user_id = ? AND status = ? AND deleted_at IS NULL", userID, model.PostStatusApproved).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserStats 获取用户的文章统计数据(总获赞/总收藏/总阅读)
|
||||||
|
func (r *PostRepo) GetUserStats(userID uint) (likes, favorites, reads int64, err error) {
|
||||||
|
type stats struct {
|
||||||
|
TotalLikes int64
|
||||||
|
TotalFavorites int64
|
||||||
|
TotalReads int64
|
||||||
|
}
|
||||||
|
var s stats
|
||||||
|
err = r.db.Model(&model.Post{}).
|
||||||
|
Select("COALESCE(SUM(likes_count), 0) AS total_likes, COALESCE(SUM(favorites_count), 0) AS total_favorites, COALESCE(SUM(views_count), 0) AS total_reads").
|
||||||
|
Where("user_id = ? AND status = ? AND deleted_at IS NULL", userID, model.PostStatusApproved).
|
||||||
|
Scan(&s).Error
|
||||||
|
return s.TotalLikes, s.TotalFavorites, s.TotalReads, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// overviewRow GetOverviewByUserID 单次 GROUP BY 查询结果行
|
||||||
|
type overviewRow struct {
|
||||||
|
TotalPosts int64
|
||||||
|
DraftCount int64
|
||||||
|
PendingCount int64
|
||||||
|
ApprovedCount int64
|
||||||
|
RejectedCount int64
|
||||||
|
TotalViews int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOverviewByUserID 用单次 GROUP BY 查询聚合用户各状态文章数 + 总阅读量
|
||||||
|
func (r *PostRepo) GetOverviewByUserID(userID uint) (totalPosts, draftCount, pendingCount, approvedCount, rejectedCount, totalViews int64, err error) {
|
||||||
|
var row overviewRow
|
||||||
|
err = r.db.Model(&model.Post{}).
|
||||||
|
Select(`
|
||||||
|
COUNT(*) AS total_posts,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'draft' THEN 1 ELSE 0 END), 0) AS draft_count,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) AS pending_count,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END), 0) AS approved_count,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END), 0) AS rejected_count,
|
||||||
|
COALESCE(SUM(views_count), 0) AS total_views
|
||||||
|
`).
|
||||||
|
Where("user_id = ? AND deleted_at IS NULL", userID).
|
||||||
|
Scan(&row).Error
|
||||||
|
return row.TotalPosts, row.DraftCount, row.PendingCount, row.ApprovedCount, row.RejectedCount, row.TotalViews, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCooldown 两次阅读记录最小间隔
|
||||||
|
const readCooldown = 2 * time.Second
|
||||||
|
|
||||||
|
// RecordRead 记录已登录用户阅读文章(防刷量:取去重+冷却)
|
||||||
|
// 返回 true 表示首次阅读(新记录),false 表示重复或冷却中
|
||||||
|
func (r *PostRepo) RecordRead(userID, postID uint) (bool, error) {
|
||||||
|
// 冷却检查:同用户两次阅读之间至少间隔 2 秒
|
||||||
|
var lastRead model.PostReadLog
|
||||||
|
if err := r.db.Where("user_id = ?", userID).
|
||||||
|
Order("read_at DESC").First(&lastRead).Error; err == nil {
|
||||||
|
if time.Since(lastRead.ReadAt) < readCooldown {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := r.db.Clauses(clause.OnConflict{DoNothing: true}).
|
||||||
|
Create(&model.PostReadLog{UserID: userID, PostID: postID})
|
||||||
|
if result.Error != nil {
|
||||||
|
return false, result.Error
|
||||||
|
}
|
||||||
|
return result.RowsAffected > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordGuestRead 记录访客阅读文章(基于 Cookie visitor_id 去重+冷却)
|
||||||
|
// 返回 true 表示首次阅读(新记录),false 表示重复或冷却中
|
||||||
|
func (r *PostRepo) RecordGuestRead(visitorID string, postID uint) (bool, error) {
|
||||||
|
// 冷却检查:同访客两次阅读之间至少间隔 2 秒
|
||||||
|
var lastRead model.PostGuestReadLog
|
||||||
|
if err := r.db.Where("visitor_id = ?", visitorID).
|
||||||
|
Order("read_at DESC").First(&lastRead).Error; err == nil {
|
||||||
|
if time.Since(lastRead.ReadAt) < readCooldown {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := r.db.Clauses(clause.OnConflict{DoNothing: true}).
|
||||||
|
Create(&model.PostGuestReadLog{VisitorID: visitorID, PostID: postID})
|
||||||
|
if result.Error != nil {
|
||||||
|
return false, result.Error
|
||||||
|
}
|
||||||
|
return result.RowsAffected > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementViewsCount 阅读数 +1
|
||||||
|
func (r *PostRepo) IncrementViewsCount(postID uint) error {
|
||||||
|
return r.db.Model(&model.Post{}).Where("id = ?", postID).
|
||||||
|
Update("views_count", gorm.Expr("views_count + 1")).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateViewsByAuthor 按日期聚合阅读量(作者所有文章在时间段内的阅读记录,含已登录+访客)
|
||||||
|
func (r *PostRepo) AggregateViewsByAuthor(userID uint, since string) ([]service.TrendPoint, error) {
|
||||||
|
var results []service.TrendPoint
|
||||||
|
err := r.db.Raw(`
|
||||||
|
SELECT TO_CHAR(t.read_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value
|
||||||
|
FROM (
|
||||||
|
SELECT prl.read_at FROM post_read_logs prl
|
||||||
|
JOIN posts p ON p.id = prl.post_id
|
||||||
|
WHERE p.user_id = ? AND prl.read_at >= ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT pgrl.read_at FROM post_guest_read_logs pgrl
|
||||||
|
JOIN posts p ON p.id = pgrl.post_id
|
||||||
|
WHERE p.user_id = ? AND pgrl.read_at >= ?
|
||||||
|
) t
|
||||||
|
GROUP BY TO_CHAR(t.read_at, 'YYYY-MM-DD')
|
||||||
|
ORDER BY date ASC
|
||||||
|
`, userID, since, userID, since).Scan(&results).Error
|
||||||
|
if results == nil {
|
||||||
|
results = []service.TrendPoint{}
|
||||||
|
}
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
153
internal/repository/reaction_repo.go
Normal file
153
internal/repository/reaction_repo.go
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
"metazone.cc/metalab/internal/service"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReactionRepo 赞/踩数据访问
|
||||||
|
type ReactionRepo struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewReactionRepo 构造函数
|
||||||
|
func NewReactionRepo(db *gorm.DB) *ReactionRepo {
|
||||||
|
return &ReactionRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTx 基于给定事务连接创建新的 ReactionRepo
|
||||||
|
func (r *ReactionRepo) WithTx(tx *gorm.DB) service.ReactionStore {
|
||||||
|
return &ReactionRepo{db: tx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction 在事务内执行业务逻辑
|
||||||
|
func (r *ReactionRepo) Transaction(fn func(tx service.ReactionStore) error) error {
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
return fn(r.WithTx(tx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPostLikesCount 查询文章点赞数
|
||||||
|
func (r *ReactionRepo) GetPostLikesCount(postID uint) (int, error) {
|
||||||
|
var post model.Post
|
||||||
|
if err := r.db.Select("likes_count").First(&post, postID).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return post.LikesCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindLike 查找点赞记录
|
||||||
|
func (r *ReactionRepo) FindLike(userID, postID uint) (*model.PostLike, error) {
|
||||||
|
var like model.PostLike
|
||||||
|
err := r.db.Where("user_id = ? AND post_id = ?", userID, postID).First(&like).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &like, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLike 创建点赞记录
|
||||||
|
func (r *ReactionRepo) CreateLike(like *model.PostLike) error {
|
||||||
|
return r.db.Create(like).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteLike 删除点赞记录
|
||||||
|
func (r *ReactionRepo) DeleteLike(userID, postID uint) error {
|
||||||
|
return r.db.Where("user_id = ? AND post_id = ?", userID, postID).Delete(&model.PostLike{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindDislike 查找踩记录
|
||||||
|
func (r *ReactionRepo) FindDislike(userID, postID uint) (*model.PostDislike, error) {
|
||||||
|
var dislike model.PostDislike
|
||||||
|
err := r.db.Where("user_id = ? AND post_id = ?", userID, postID).First(&dislike).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &dislike, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDislike 创建踩记录
|
||||||
|
func (r *ReactionRepo) CreateDislike(dislike *model.PostDislike) error {
|
||||||
|
return r.db.Create(dislike).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDislike 删除踩记录
|
||||||
|
func (r *ReactionRepo) DeleteDislike(userID, postID uint) error {
|
||||||
|
return r.db.Where("user_id = ? AND post_id = ?", userID, postID).Delete(&model.PostDislike{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrPostLikes 原子增加文章点赞数
|
||||||
|
func (r *ReactionRepo) IncrPostLikes(postID uint, delta int) error {
|
||||||
|
return r.db.Model(&model.Post{}).
|
||||||
|
Where("id = ?", postID).
|
||||||
|
UpdateColumn("likes_count", gorm.Expr("likes_count + ?", delta)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrPostDislikes 原子增加文章踩数
|
||||||
|
func (r *ReactionRepo) IncrPostDislikes(postID uint, delta int) error {
|
||||||
|
return r.db.Model(&model.Post{}).
|
||||||
|
Where("id = ?", postID).
|
||||||
|
UpdateColumn("dislikes_count", gorm.Expr("dislikes_count + ?", delta)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPostAuthorID 查询文章作者ID
|
||||||
|
func (r *ReactionRepo) GetPostAuthorID(postID uint) (uint, error) {
|
||||||
|
var post model.Post
|
||||||
|
err := r.db.Select("user_id").First(&post, postID).Error
|
||||||
|
return post.UserID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertDailyLikeSummary 追加或创建每日点赞汇总
|
||||||
|
func (r *ReactionRepo) UpsertDailyLikeSummary(authorUID uint, date string, likerUID string) error {
|
||||||
|
var existing model.DailyLikeSummary
|
||||||
|
err := r.db.Where("author_uid = ? AND date = ?", authorUID, date).First(&existing).Error
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return r.db.Create(&model.DailyLikeSummary{
|
||||||
|
AuthorUID: authorUID,
|
||||||
|
Date: date,
|
||||||
|
LikerUIDs: likerUID,
|
||||||
|
Notified: false,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 追加 likerUID
|
||||||
|
newUids := existing.LikerUIDs
|
||||||
|
if newUids == "" {
|
||||||
|
newUids = likerUID
|
||||||
|
} else {
|
||||||
|
newUids = newUids + "," + likerUID
|
||||||
|
}
|
||||||
|
return r.db.Model(&existing).Update("liker_uids", newUids).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUnnotifiedSummaries 查询未通知的每日点赞汇总
|
||||||
|
func (r *ReactionRepo) GetUnnotifiedSummaries(userID uint) ([]model.DailyLikeSummary, error) {
|
||||||
|
var summaries []model.DailyLikeSummary
|
||||||
|
err := r.db.Where("author_uid = ? AND notified = ?", userID, false).Find(&summaries).Error
|
||||||
|
return summaries, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkSummaryNotified 标记汇总已通知
|
||||||
|
func (r *ReactionRepo) MarkSummaryNotified(id uint) error {
|
||||||
|
return r.db.Model(&model.DailyLikeSummary{}).Where("id = ?", id).Update("notified", true).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateLikesByAuthor 按日期聚合点赞量(作者所有文章在时间段内的点赞)
|
||||||
|
func (r *ReactionRepo) AggregateLikesByAuthor(userID uint, since string) ([]service.TrendPoint, error) {
|
||||||
|
var results []service.TrendPoint
|
||||||
|
err := r.db.Table("post_likes").
|
||||||
|
Select("TO_CHAR(post_likes.created_at, 'YYYY-MM-DD') AS date, COUNT(*) AS value").
|
||||||
|
Joins("JOIN posts ON posts.id = post_likes.post_id").
|
||||||
|
Where("posts.user_id = ? AND post_likes.created_at >= ?", userID, since).
|
||||||
|
Group("TO_CHAR(post_likes.created_at, 'YYYY-MM-DD')").
|
||||||
|
Order("date ASC").
|
||||||
|
Scan(&results).Error
|
||||||
|
if results == nil {
|
||||||
|
results = []service.TrendPoint{}
|
||||||
|
}
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
@ -69,16 +69,15 @@ func (r *UserRepo) ExistsByUsername(username string) (bool, error) {
|
|||||||
func (r *UserRepo) ExistsByEmailExclude(email string, excludeUID uint) (bool, error) {
|
func (r *UserRepo) ExistsByEmailExclude(email string, excludeUID uint) (bool, error) {
|
||||||
var count int64
|
var count int64
|
||||||
err := r.db.Model(&model.User{}).
|
err := r.db.Model(&model.User{}).
|
||||||
Where("email = ? AND uid != ?", email, excludeUID).
|
Where("email = ? AND id != ?", email, excludeUID).
|
||||||
Count(&count).Error
|
Count(&count).Error
|
||||||
return count > 0, err
|
return count > 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindByIDForAuth 认证专用查询:返回 uid/email/username/role/status
|
// FindByIDForAuth 认证专用查询:返回 uid/email/username/avatar/role/status
|
||||||
// 比 FindByID 轻量(不查 avatar、bio 等无用字段),避免全量 SELECT *
|
|
||||||
func (r *UserRepo) FindByIDForAuth(userID uint) (*model.User, error) {
|
func (r *UserRepo) FindByIDForAuth(userID uint) (*model.User, error) {
|
||||||
var user model.User
|
var user model.User
|
||||||
err := r.db.Select("uid", "email", "username", "role", "status").
|
err := r.db.Select("id", "email", "username", "avatar", "role", "status", "exp").
|
||||||
First(&user, userID).Error
|
First(&user, userID).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -89,7 +88,7 @@ func (r *UserRepo) FindByIDForAuth(userID uint) (*model.User, error) {
|
|||||||
// FindByIDUnscoped 管理后台专用:含软删除用户,用于解锁/封禁等操作
|
// FindByIDUnscoped 管理后台专用:含软删除用户,用于解锁/封禁等操作
|
||||||
func (r *UserRepo) FindByIDUnscoped(userID uint) (*model.User, error) {
|
func (r *UserRepo) FindByIDUnscoped(userID uint) (*model.User, error) {
|
||||||
var user model.User
|
var user model.User
|
||||||
err := r.db.Unscoped().Select("uid", "email", "username", "role", "status", "deleted_at").
|
err := r.db.Unscoped().Select("id", "email", "username", "role", "status", "deleted_at").
|
||||||
First(&user, userID).Error
|
First(&user, userID).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -99,12 +98,12 @@ func (r *UserRepo) FindByIDUnscoped(userID uint) (*model.User, error) {
|
|||||||
|
|
||||||
// UpdateStatus 更新用户状态(含软删除用户,locked 状态变更需要)
|
// UpdateStatus 更新用户状态(含软删除用户,locked 状态变更需要)
|
||||||
func (r *UserRepo) UpdateStatus(uid uint, status string) error {
|
func (r *UserRepo) UpdateStatus(uid uint, status string) error {
|
||||||
return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", uid).Update("status", status).Error
|
return r.db.Unscoped().Model(&model.User{}).Where("id = ?", uid).Update("status", status).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateRole 更新用户角色(仅 owner 调用)
|
// UpdateRole 更新用户角色(仅 owner 调用)
|
||||||
func (r *UserRepo) UpdateRole(uid uint, role string) error {
|
func (r *UserRepo) UpdateRole(uid uint, role string) error {
|
||||||
return r.db.Model(&model.User{}).Where("uid = ?", uid).Update("role", role).Error
|
return r.db.Model(&model.User{}).Where("id = ?", uid).Update("role", role).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update 全量更新用户信息
|
// Update 全量更新用户信息
|
||||||
@ -114,12 +113,12 @@ func (r *UserRepo) Update(user *model.User) error {
|
|||||||
|
|
||||||
// SoftDelete GORM 软删除(设 DeletedAt),用于 locked 状态释放邮箱
|
// SoftDelete GORM 软删除(设 DeletedAt),用于 locked 状态释放邮箱
|
||||||
func (r *UserRepo) SoftDelete(uid uint) error {
|
func (r *UserRepo) SoftDelete(uid uint) error {
|
||||||
return r.db.Where("uid = ?", uid).Delete(&model.User{}).Error
|
return r.db.Where("id = ?", uid).Delete(&model.User{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore 恢复软删除(清 DeletedAt)
|
// Restore 恢复软删除(清 DeletedAt)
|
||||||
func (r *UserRepo) Restore(uid uint) error {
|
func (r *UserRepo) Restore(uid uint) error {
|
||||||
return r.db.Unscoped().Model(&model.User{}).Where("uid = ?", uid).Update("deleted_at", nil).Error
|
return r.db.Unscoped().Model(&model.User{}).Where("id = ?", uid).Update("deleted_at", nil).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSearchQuery 构建搜索/筛选的公共查询条件
|
// buildSearchQuery 构建搜索/筛选的公共查询条件
|
||||||
@ -142,7 +141,7 @@ func (r *UserRepo) buildSearchQuery(keyword, role, status string) *gorm.DB {
|
|||||||
func (r *UserRepo) SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error) {
|
func (r *UserRepo) SearchUsers(keyword, role, status string, offset, limit int) ([]model.User, error) {
|
||||||
var users []model.User
|
var users []model.User
|
||||||
err := r.buildSearchQuery(keyword, role, status).
|
err := r.buildSearchQuery(keyword, role, status).
|
||||||
Order("uid ASC").Offset(offset).Limit(limit).Find(&users).Error
|
Order("id ASC").Offset(offset).Limit(limit).Find(&users).Error
|
||||||
return users, err
|
return users, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,12 +28,17 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
adminPages.GET("/posts", d.adminPostCtrl.PostsPage)
|
adminPages.GET("/posts", d.adminPostCtrl.PostsPage)
|
||||||
adminPages.GET("/site-settings", d.siteSettingCtrl.SiteSettingsPage,
|
adminPages.GET("/site-settings", d.siteSettingCtrl.SiteSettingsPage,
|
||||||
middleware.RequirePageRole(model.RoleOwner))
|
middleware.RequirePageRole(model.RoleOwner))
|
||||||
|
adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage)
|
||||||
|
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
|
||||||
|
adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage)
|
||||||
|
adminPages.GET("/comments", d.adminCommentCtrl.CommentsPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 管理后台 API ---
|
// --- 管理后台 API ---
|
||||||
adminAPI := r.Group("/api/admin")
|
adminAPI := r.Group("/api/admin")
|
||||||
adminAPI.Use(d.authMdw.Required())
|
adminAPI.Use(d.authMdw.Required())
|
||||||
adminAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
|
adminAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
|
||||||
|
adminAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
adminAPI.Use(middleware.CSRF(cfg))
|
adminAPI.Use(middleware.CSRF(cfg))
|
||||||
{
|
{
|
||||||
adminAPI.GET("/users", d.adminCtrl.ListUsers)
|
adminAPI.GET("/users", d.adminCtrl.ListUsers)
|
||||||
@ -48,6 +53,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
siteSettingsAPI := r.Group("/api/admin/site-settings")
|
siteSettingsAPI := r.Group("/api/admin/site-settings")
|
||||||
siteSettingsAPI.Use(d.authMdw.Required())
|
siteSettingsAPI.Use(d.authMdw.Required())
|
||||||
siteSettingsAPI.Use(middleware.RequireMinRole(model.RoleOwner))
|
siteSettingsAPI.Use(middleware.RequireMinRole(model.RoleOwner))
|
||||||
|
siteSettingsAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
siteSettingsAPI.Use(middleware.CSRF(cfg))
|
siteSettingsAPI.Use(middleware.CSRF(cfg))
|
||||||
{
|
{
|
||||||
siteSettingsAPI.GET("", d.siteSettingCtrl.GetSettings)
|
siteSettingsAPI.GET("", d.siteSettingCtrl.GetSettings)
|
||||||
@ -60,6 +66,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
adminPostAPI := r.Group("/api/admin/posts")
|
adminPostAPI := r.Group("/api/admin/posts")
|
||||||
adminPostAPI.Use(d.authMdw.Required())
|
adminPostAPI.Use(d.authMdw.Required())
|
||||||
adminPostAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
|
adminPostAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
|
||||||
|
adminPostAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
adminPostAPI.Use(middleware.CSRF(cfg))
|
adminPostAPI.Use(middleware.CSRF(cfg))
|
||||||
{
|
{
|
||||||
adminPostAPI.POST("/:id/approve", d.adminPostCtrl.Approve)
|
adminPostAPI.POST("/:id/approve", d.adminPostCtrl.Approve)
|
||||||
@ -68,4 +75,28 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
adminPostAPI.POST("/:id/unlock", d.adminPostCtrl.Unlock)
|
adminPostAPI.POST("/:id/unlock", d.adminPostCtrl.Unlock)
|
||||||
adminPostAPI.POST("/:id/restore", d.adminPostCtrl.Restore)
|
adminPostAPI.POST("/:id/restore", d.adminPostCtrl.Restore)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 域能管理 API(admin+,system_operation 仅 owner) ---
|
||||||
|
adminEnergyAPI := r.Group("/api/admin/energy")
|
||||||
|
adminEnergyAPI.Use(d.authMdw.Required())
|
||||||
|
adminEnergyAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
|
||||||
|
adminEnergyAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
adminEnergyAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
adminEnergyAPI.POST("/adjust", d.adminEnergyCtrl.AdjustEnergy)
|
||||||
|
adminEnergyAPI.GET("/logs", d.adminEnergyCtrl.ListEnergyLogs)
|
||||||
|
adminEnergyAPI.GET("/fund-balance", d.adminEnergyCtrl.GetFundBalance)
|
||||||
|
adminEnergyAPI.GET("/fund-logs", d.adminEnergyCtrl.ListFundLogs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 评论管理 API(admin+) ---
|
||||||
|
adminCommentAPI := r.Group("/api/admin/comments")
|
||||||
|
adminCommentAPI.Use(d.authMdw.Required())
|
||||||
|
adminCommentAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
|
||||||
|
adminCommentAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
adminCommentAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
adminCommentAPI.GET("", d.adminCommentCtrl.ListComments)
|
||||||
|
adminCommentAPI.DELETE("/:id", d.adminCommentCtrl.Delete)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,23 +12,28 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
// --- 设置页 API(需登录) ---
|
// --- 设置页 API(需登录) ---
|
||||||
settingsAPI := r.Group("/api/settings")
|
settingsAPI := r.Group("/api/settings")
|
||||||
settingsAPI.Use(d.authMdw.Required())
|
settingsAPI.Use(d.authMdw.Required())
|
||||||
|
settingsAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
settingsAPI.Use(middleware.CSRF(cfg))
|
settingsAPI.Use(middleware.CSRF(cfg))
|
||||||
{
|
{
|
||||||
settingsAPI.PUT("/profile", d.settingsCtrl.UpdateProfile)
|
settingsAPI.PUT("/profile", d.settingsCtrl.UpdateProfile)
|
||||||
settingsAPI.POST("/avatar", d.settingsCtrl.UploadAvatar)
|
settingsAPI.POST("/avatar", d.settingsCtrl.UploadAvatar)
|
||||||
settingsAPI.PUT("/password", d.settingsCtrl.ChangePassword)
|
settingsAPI.PUT("/password", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.ChangePassword)
|
||||||
settingsAPI.POST("/delete-account", d.settingsCtrl.DeleteAccount)
|
settingsAPI.POST("/delete-account", middleware.SensitiveRateLimit(d.rateLimiter), d.settingsCtrl.DeleteAccount)
|
||||||
settingsAPI.GET("/audit-status", d.settingsCtrl.AuditStatus)
|
settingsAPI.GET("/audit-status", d.settingsCtrl.AuditStatus)
|
||||||
// 登录管理
|
// 登录管理
|
||||||
settingsAPI.GET("/sessions", d.settingsCtrl.ListSessions)
|
settingsAPI.GET("/sessions", d.settingsCtrl.ListSessions)
|
||||||
settingsAPI.DELETE("/sessions/:sid", d.settingsCtrl.DestroySession)
|
settingsAPI.DELETE("/sessions/:sid", d.settingsCtrl.DestroySession)
|
||||||
settingsAPI.POST("/sessions/destroy-others", d.settingsCtrl.DestroyOtherSessions)
|
settingsAPI.POST("/sessions/destroy-others", d.settingsCtrl.DestroyOtherSessions)
|
||||||
settingsAPI.PUT("/sessions/:sid/remark", d.settingsCtrl.UpdateSessionRemark)
|
settingsAPI.PUT("/sessions/:sid/remark", d.settingsCtrl.UpdateSessionRemark)
|
||||||
|
// 通知偏好
|
||||||
|
settingsAPI.GET("/notify-prefs", d.settingsCtrl.GetNotifyPrefs)
|
||||||
|
settingsAPI.PUT("/notify-prefs", d.settingsCtrl.UpdateNotifyPref)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 消息中心 API(需登录) ---
|
// --- 消息中心 API(需登录) ---
|
||||||
messagesAPI := r.Group("/api/messages")
|
messagesAPI := r.Group("/api/messages")
|
||||||
messagesAPI.Use(d.authMdw.Required())
|
messagesAPI.Use(d.authMdw.Required())
|
||||||
|
messagesAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
messagesAPI.Use(middleware.CSRF(cfg))
|
messagesAPI.Use(middleware.CSRF(cfg))
|
||||||
{
|
{
|
||||||
messagesAPI.GET("/unread", d.messageCtrl.UnreadCount)
|
messagesAPI.GET("/unread", d.messageCtrl.UnreadCount)
|
||||||
@ -36,6 +41,16 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
messagesAPI.POST("/read-all", d.messageCtrl.MarkAllRead)
|
messagesAPI.POST("/read-all", d.messageCtrl.MarkAllRead)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 积分/等级 API(需登录) ---
|
||||||
|
levelAPI := r.Group("/api/level")
|
||||||
|
levelAPI.Use(d.authMdw.Required())
|
||||||
|
levelAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
levelAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
levelAPI.POST("/checkin", middleware.SensitiveRateLimit(d.rateLimiter), d.levelCtrl.CheckIn)
|
||||||
|
levelAPI.GET("/info", d.levelCtrl.GetLevel)
|
||||||
|
}
|
||||||
|
|
||||||
// --- 认证 API ---
|
// --- 认证 API ---
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
api.Use(middleware.CSRF(cfg))
|
api.Use(middleware.CSRF(cfg))
|
||||||
@ -55,11 +70,9 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
// --- 帖子 API(需登录) ---
|
// --- 帖子 API(需登录) ---
|
||||||
postsAPI := r.Group("/api/posts")
|
postsAPI := r.Group("/api/posts")
|
||||||
postsAPI.Use(d.authMdw.Required())
|
postsAPI.Use(d.authMdw.Required())
|
||||||
|
postsAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
postsAPI.Use(middleware.CSRF(cfg))
|
postsAPI.Use(middleware.CSRF(cfg))
|
||||||
{
|
{
|
||||||
postsAPI.POST("", d.postCtrl.Create)
|
|
||||||
postsAPI.PUT("/:id", d.postCtrl.Update)
|
|
||||||
postsAPI.DELETE("/:id", d.postCtrl.Delete)
|
|
||||||
postsAPI.POST("/:id/submit", d.postCtrl.Submit)
|
postsAPI.POST("/:id/submit", d.postCtrl.Submit)
|
||||||
postsAPI.POST("/upload-image", d.postCtrl.UploadImage)
|
postsAPI.POST("/upload-image", d.postCtrl.UploadImage)
|
||||||
}
|
}
|
||||||
@ -67,6 +80,7 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
// --- 创作中心 API(需登录) ---
|
// --- 创作中心 API(需登录) ---
|
||||||
studioAPI := r.Group("/api/studio")
|
studioAPI := r.Group("/api/studio")
|
||||||
studioAPI.Use(d.authMdw.Required())
|
studioAPI.Use(d.authMdw.Required())
|
||||||
|
studioAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
studioAPI.Use(middleware.CSRF(cfg))
|
studioAPI.Use(middleware.CSRF(cfg))
|
||||||
{
|
{
|
||||||
studioAPI.GET("/overview", d.studioCtrl.OverviewAPI)
|
studioAPI.GET("/overview", d.studioCtrl.OverviewAPI)
|
||||||
@ -76,4 +90,109 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete)
|
studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete)
|
||||||
studioAPI.POST("/posts/:id/submit", d.studioCtrl.Submit)
|
studioAPI.POST("/posts/:id/submit", d.studioCtrl.Submit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 域能 API(需登录) ---
|
||||||
|
energyAPI := r.Group("/api/energy")
|
||||||
|
energyAPI.Use(d.authMdw.Required())
|
||||||
|
energyAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
energyAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
energyAPI.POST("/energize", d.energyCtrl.Energize)
|
||||||
|
energyAPI.GET("/info", d.energyCtrl.GetEnergyInfo)
|
||||||
|
energyAPI.GET("/logs", d.energyCtrl.GetEnergyLogs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 评论 API(部分需登录) ---
|
||||||
|
// 公开读取
|
||||||
|
commentsPublic := r.Group("/api/posts/:id/comments")
|
||||||
|
{
|
||||||
|
commentsPublic.GET("", d.commentCtrl.ListRootComments)
|
||||||
|
commentsPublic.GET("/:root_id/replies", d.commentCtrl.ListReplies)
|
||||||
|
}
|
||||||
|
// 需要登录的操作
|
||||||
|
commentsAPI := r.Group("/api")
|
||||||
|
commentsAPI.Use(d.authMdw.Required())
|
||||||
|
commentsAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
commentsAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
commentsAPI.POST("/posts/:id/comments", d.commentCtrl.CreateRoot)
|
||||||
|
commentsAPI.POST("/comments/:id/reply", d.commentCtrl.CreateReply)
|
||||||
|
commentsAPI.DELETE("/comments/:id", d.commentCtrl.Delete)
|
||||||
|
commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers)
|
||||||
|
commentsAPI.POST("/comments/upload-image", d.commentCtrl.UploadImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 赞/踩 API ---
|
||||||
|
// 公开:查询反应状态(未登录返回 none + 点赞数)
|
||||||
|
r.GET("/api/posts/:id/reaction", d.reactionCtrl.GetReaction)
|
||||||
|
// 需登录:切换赞/踩
|
||||||
|
reactionAPI := r.Group("/api/posts/:id")
|
||||||
|
reactionAPI.Use(d.authMdw.Required())
|
||||||
|
reactionAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
reactionAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
reactionAPI.POST("/like", d.reactionCtrl.ToggleLike)
|
||||||
|
reactionAPI.POST("/dislike", d.reactionCtrl.ToggleDislike)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 关注 API ---
|
||||||
|
// 公开(Optional 认证以获取当前用户关注状态,未登录返回 false)
|
||||||
|
followPublic := r.Group("/api/users/:uid")
|
||||||
|
followPublic.Use(d.authMdw.Optional())
|
||||||
|
{
|
||||||
|
followPublic.GET("/follow-status", d.followCtrl.GetStatus)
|
||||||
|
followPublic.GET("/followers", d.followCtrl.Followers)
|
||||||
|
followPublic.GET("/following", d.followCtrl.Following)
|
||||||
|
}
|
||||||
|
// 需登录:切换关注
|
||||||
|
followAPI := r.Group("/api/users/:uid")
|
||||||
|
followAPI.Use(d.authMdw.Required())
|
||||||
|
followAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
followAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
followAPI.POST("/follow", d.followCtrl.Toggle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 收藏夹 API ---
|
||||||
|
// 公开:文章收藏状态(未登录返回 false)
|
||||||
|
r.GET("/api/posts/:id/folder-status", d.favoriteCtrl.GetPostStatus)
|
||||||
|
// 公开:查看公开收藏夹内容
|
||||||
|
r.GET("/api/folders/:id/posts", d.favoriteCtrl.ListFolderItems)
|
||||||
|
// 需登录:收藏操作
|
||||||
|
folderAPI := r.Group("/api/folders")
|
||||||
|
folderAPI.Use(d.authMdw.Required())
|
||||||
|
folderAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
folderAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
folderAPI.GET("", d.favoriteCtrl.ListFolders)
|
||||||
|
folderAPI.POST("", d.favoriteCtrl.CreateFolder)
|
||||||
|
folderAPI.PUT("/:id", d.favoriteCtrl.UpdateFolder)
|
||||||
|
folderAPI.DELETE("/:id", d.favoriteCtrl.DeleteFolder)
|
||||||
|
folderAPI.POST("/:id/posts", d.favoriteCtrl.AddToFolder)
|
||||||
|
folderAPI.DELETE("/:id/posts/:postId", d.favoriteCtrl.RemoveFromFolder)
|
||||||
|
}
|
||||||
|
// 需登录:切换收藏(Toggle)
|
||||||
|
favToggleAPI := r.Group("/api/posts/:id")
|
||||||
|
favToggleAPI.Use(d.authMdw.Required())
|
||||||
|
favToggleAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
favToggleAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
favToggleAPI.POST("/favorite", d.favoriteCtrl.ToggleFavorite)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 空间/隐私设置 API(需登录) ---
|
||||||
|
spaceAPI := r.Group("/api/space")
|
||||||
|
spaceAPI.Use(d.authMdw.Required())
|
||||||
|
spaceAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
spaceAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
spaceAPI.PUT("/privacy", d.spaceCtrl.UpdatePrivacy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Studio 趋势 API(需登录) ---
|
||||||
|
trendsAPI := r.Group("/api/studio")
|
||||||
|
trendsAPI.Use(d.authMdw.Required())
|
||||||
|
{
|
||||||
|
trendsAPI.GET("/trends", d.studioCtrl.TrendsAPI)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/cache"
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/metalab/internal/config"
|
||||||
"metazone.cc/metalab/internal/controller"
|
"metazone.cc/metalab/internal/controller"
|
||||||
adminCtrl "metazone.cc/metalab/internal/controller/admin"
|
adminCtrl "metazone.cc/metalab/internal/controller/admin"
|
||||||
@ -19,22 +20,37 @@ import (
|
|||||||
type dependencies struct {
|
type dependencies struct {
|
||||||
authCtrl *controller.AuthController
|
authCtrl *controller.AuthController
|
||||||
authMdw *middleware.AuthMiddleware
|
authMdw *middleware.AuthMiddleware
|
||||||
|
rateLimiter *middleware.RateLimiter
|
||||||
maintenanceMdw *middleware.MaintenanceMiddleware
|
maintenanceMdw *middleware.MaintenanceMiddleware
|
||||||
settingsCtrl *controller.SettingsController
|
settingsCtrl *controller.SettingsController
|
||||||
messageCtrl *controller.MessageController
|
messageCtrl *controller.MessageController
|
||||||
postCtrl *controller.PostController
|
postCtrl *controller.PostController
|
||||||
|
postService *service.PostService
|
||||||
spaceCtrl *controller.SpaceController
|
spaceCtrl *controller.SpaceController
|
||||||
studioCtrl *controller.StudioController
|
studioCtrl *controller.StudioController
|
||||||
adminPostCtrl *adminCtrl.AdminPostController
|
adminPostCtrl *adminCtrl.AdminPostController
|
||||||
adminCtrl *adminCtrl.AdminController
|
adminCtrl *adminCtrl.AdminController
|
||||||
auditCtrl *adminCtrl.AuditController
|
auditCtrl *adminCtrl.AuditController
|
||||||
siteSettingCtrl *adminCtrl.SiteSettingController
|
siteSettingCtrl *adminCtrl.SiteSettingController
|
||||||
|
levelCtrl *controller.LevelController
|
||||||
|
energyCtrl *controller.EnergyController
|
||||||
|
adminEnergyCtrl *adminCtrl.AdminEnergyController
|
||||||
|
energyService *service.EnergyService
|
||||||
|
commentCtrl *controller.CommentController
|
||||||
|
commentService *service.CommentService
|
||||||
|
adminCommentCtrl *adminCtrl.AdminCommentController
|
||||||
|
reactionCtrl *controller.ReactionController
|
||||||
|
followCtrl *controller.FollowController
|
||||||
|
favoriteCtrl *controller.FavoriteController
|
||||||
|
favoriteService *service.FavoriteService
|
||||||
|
trendsService *service.TrendsService
|
||||||
|
homeCache *cache.HomePageCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
|
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
|
||||||
*repository.UserRepo, *session.Manager, *service.AuthService,
|
*repository.UserRepo, *session.Manager, *service.AuthService,
|
||||||
*middleware.RateLimiter, *controller.AuthController,
|
*middleware.RateLimiter, *controller.AuthController,
|
||||||
*service.AvatarService, *middleware.AuthMiddleware, *adminCtrl.AdminController,
|
*service.AvatarService, *middleware.AuthMiddleware,
|
||||||
) {
|
) {
|
||||||
userRepo := repository.NewUserRepo(db)
|
userRepo := repository.NewUserRepo(db)
|
||||||
|
|
||||||
@ -51,7 +67,7 @@ func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSet
|
|||||||
if cfg.Redis.Enabled && redisClient != nil {
|
if cfg.Redis.Enabled && redisClient != nil {
|
||||||
// Redis 模式 + 自动降级保护
|
// Redis 模式 + 自动降级保护
|
||||||
redisStore := session.NewRedisStore(redisClient, idleTimeout, rememberTimeout)
|
redisStore := session.NewRedisStore(redisClient, idleTimeout, rememberTimeout)
|
||||||
sessionStore = session.NewFallbackStore(redisStore, memStore, cleanupInterval)
|
sessionStore = session.NewFallbackStore(redisStore, memStore)
|
||||||
log.Println("[Router] 会话存储: Redis(带内存降级保护)")
|
log.Println("[Router] 会话存储: Redis(带内存降级保护)")
|
||||||
} else {
|
} else {
|
||||||
// 纯内存模式
|
// 纯内存模式
|
||||||
@ -62,9 +78,19 @@ func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSet
|
|||||||
sessionMgr := session.NewManager(sessionStore, userRepo, cfg)
|
sessionMgr := session.NewManager(sessionStore, userRepo, cfg)
|
||||||
authService := service.NewAuthService(userRepo, sessionMgr, cfg, siteSettings)
|
authService := service.NewAuthService(userRepo, sessionMgr, cfg, siteSettings)
|
||||||
rateLimiter := middleware.NewRateLimiter()
|
rateLimiter := middleware.NewRateLimiter()
|
||||||
authCtrl := controller.NewAuthController(authService, sessionMgr, rateLimiter, cfg)
|
authCtrl := controller.NewAuthController(authService, sessionMgr, rateLimiter, cfg, siteSettings)
|
||||||
avatarSvc := service.NewAvatarService(userRepo)
|
avatarSvc := service.NewAvatarService(userRepo)
|
||||||
authMdw := middleware.NewAuthMiddleware(cfg, sessionMgr)
|
authMdw := middleware.NewAuthMiddleware(cfg, sessionMgr)
|
||||||
adminController := adminCtrl.NewAdminController(service.NewAdminService(userRepo, sessionMgr))
|
return userRepo, sessionMgr, authService, rateLimiter, authCtrl, avatarSvc, authMdw
|
||||||
return userRepo, sessionMgr, authService, rateLimiter, authCtrl, avatarSvc, authMdw, adminController
|
}
|
||||||
|
|
||||||
|
// buildDepsLevel 构建等级/签到相关依赖
|
||||||
|
func buildDepsLevel(db *gorm.DB, notifSvc *service.NotificationService, authMdw *middleware.AuthMiddleware) (
|
||||||
|
*repository.LevelRepo, *service.LevelService, *controller.LevelController,
|
||||||
|
) {
|
||||||
|
levelRepo := repository.NewLevelRepo(db)
|
||||||
|
levelSvc := service.NewLevelService(levelRepo, levelRepo, levelRepo, notifSvc)
|
||||||
|
levelCtrl := controller.NewLevelController(levelSvc)
|
||||||
|
authMdw.WithLevelService(levelSvc)
|
||||||
|
return levelRepo, levelSvc, levelCtrl
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/cache"
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/metalab/internal/config"
|
||||||
"metazone.cc/metalab/internal/controller"
|
"metazone.cc/metalab/internal/controller"
|
||||||
adminCtrl "metazone.cc/metalab/internal/controller/admin"
|
adminCtrl "metazone.cc/metalab/internal/controller/admin"
|
||||||
@ -13,17 +16,36 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) *dependencies {
|
func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) *dependencies {
|
||||||
userRepo, sessionMgr, authService, _, authCtrl, avatarSvc, authMdw, adminController := buildDepsCore(db, cfg, siteSettings, redisClient)
|
userRepo, sessionMgr, authService, rateLimiter, authCtrl, avatarSvc, authMdw := buildDepsCore(db, cfg, siteSettings, redisClient)
|
||||||
|
|
||||||
auditRepo := repository.NewAuditRepo(db)
|
auditRepo := repository.NewAuditRepo(db)
|
||||||
notificationRepo := repository.NewNotificationRepo(db)
|
notificationRepo := repository.NewNotificationRepo(db)
|
||||||
notificationService := service.NewNotificationService(notificationRepo)
|
notificationService := service.NewNotificationService(notificationRepo)
|
||||||
auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService)
|
|
||||||
|
// 域能系统
|
||||||
|
energyRepo := repository.NewEnergyRepo(db)
|
||||||
|
fundRepo := repository.NewFundRepo(db)
|
||||||
|
energyRepo.SetFundStore(fundRepo) // 为跨仓库 Transaction 注入 FundStore
|
||||||
|
energyService := service.NewEnergyService(energyRepo, nil, notificationService)
|
||||||
|
energyService.SetFundRepo(fundRepo) // 只读查询
|
||||||
|
|
||||||
|
// 等级/签到/任务系统(链式注入域能服务)
|
||||||
|
_, levelSvc, levelCtrl := buildDepsLevel(db, notificationService, authMdw)
|
||||||
|
levelSvc.WithEnergyService(energyService)
|
||||||
|
|
||||||
|
// EnergyService 也需要 LevelService(用于赋能获得经验),通过 setter 注入
|
||||||
|
energyService.SetExpAdder(levelSvc)
|
||||||
|
|
||||||
|
auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService, levelSvc)
|
||||||
|
auditService.WithEnergyRefundService(energyService)
|
||||||
|
auditService.WithAvatarFileCleaner(avatarSvc)
|
||||||
auditController := adminCtrl.NewAuditController(auditService, userRepo)
|
auditController := adminCtrl.NewAuditController(auditService, userRepo)
|
||||||
|
|
||||||
messageController := controller.NewMessageController(notificationService)
|
messageController := controller.NewMessageController(notificationService)
|
||||||
|
|
||||||
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg)
|
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg, levelSvc)
|
||||||
|
settingsCtrl.WithEnergyService(energyService)
|
||||||
|
settingsCtrl.SetNotifyPrefReader(authService)
|
||||||
siteSettingController := adminCtrl.NewSiteSettingController(
|
siteSettingController := adminCtrl.NewSiteSettingController(
|
||||||
service.NewSiteSettingService(siteSettings),
|
service.NewSiteSettingService(siteSettings),
|
||||||
&service.AuditDefaults{
|
&service.AuditDefaults{
|
||||||
@ -33,25 +55,78 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
)
|
)
|
||||||
|
|
||||||
postRepo := repository.NewPostRepo(db)
|
postRepo := repository.NewPostRepo(db)
|
||||||
postService := service.NewPostService(postRepo, siteSettings, notificationService)
|
postService := service.NewPostService(postRepo, siteSettings, notificationService, userRepo)
|
||||||
shortcodeSvc := service.NewShortcodeService()
|
shortcodeSvc := service.NewShortcodeService()
|
||||||
postCtrl := controller.NewPostController(postService, shortcodeSvc)
|
postCtrl := controller.NewPostController(postService, shortcodeSvc)
|
||||||
adminPostCtrl := adminCtrl.NewAdminPostController(postService)
|
adminPostCtrl := adminCtrl.NewAdminPostController(postService)
|
||||||
|
adminController := adminCtrl.NewAdminController(service.NewAdminService(userRepo, postRepo, sessionMgr))
|
||||||
studioCtrl := controller.NewStudioController(postService)
|
studioCtrl := controller.NewStudioController(postService)
|
||||||
|
studioCtrl.WithEnergyService(energyService)
|
||||||
|
|
||||||
|
// 域能控制器
|
||||||
|
energyCtrl := controller.NewEnergyController(energyService)
|
||||||
|
adminEnergyCtrl := adminCtrl.NewAdminEnergyController(energyService)
|
||||||
|
|
||||||
|
// 评论系统
|
||||||
|
commentRepo := repository.NewCommentRepo(db)
|
||||||
|
commentService := service.NewCommentService(commentRepo, notificationService)
|
||||||
|
commentCtrl := controller.NewCommentController(commentService)
|
||||||
|
|
||||||
|
// 后台评论管理
|
||||||
|
adminCommentCtrl := adminCtrl.NewAdminCommentController(commentService)
|
||||||
|
|
||||||
|
// 赞/踩系统
|
||||||
|
reactionRepo := repository.NewReactionRepo(db)
|
||||||
|
reactionService := service.NewReactionService(reactionRepo)
|
||||||
|
reactionCtrl := controller.NewReactionController(reactionService)
|
||||||
|
|
||||||
|
// 关注系统
|
||||||
|
followRepo := repository.NewFollowRepo(db)
|
||||||
|
followService := service.NewFollowService(followRepo)
|
||||||
|
followService.SetNotifier(notificationService) // 关注通知
|
||||||
|
followCtrl := controller.NewFollowController(followService)
|
||||||
|
|
||||||
|
// 收藏夹系统(需在 SpaceController 之前初始化)
|
||||||
|
favoriteRepo := repository.NewFavoriteRepo(db)
|
||||||
|
favoriteService := service.NewFavoriteService(favoriteRepo)
|
||||||
|
favoriteCtrl := controller.NewFavoriteController(favoriteService)
|
||||||
|
|
||||||
// 用户空间
|
// 用户空间
|
||||||
spaceService := service.NewSpaceService(userRepo, postRepo)
|
spaceService := service.NewSpaceService(userRepo, postRepo)
|
||||||
spaceCtrl := controller.NewSpaceController(spaceService)
|
spaceCtrl := controller.NewSpaceController(spaceService)
|
||||||
|
spaceCtrl.WithFollowService(followService)
|
||||||
|
spaceCtrl.WithFavoriteService(favoriteService)
|
||||||
|
|
||||||
|
// Studio 趋势图
|
||||||
|
trendsService := service.NewTrendsService(energyRepo, commentRepo, reactionRepo, favoriteRepo, postRepo)
|
||||||
|
studioCtrl.WithTrendsService(trendsService)
|
||||||
|
|
||||||
maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr)
|
maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr)
|
||||||
|
|
||||||
|
// 首页内存缓存(30s TTL),文章变更时失效
|
||||||
|
homeCache := cache.NewHomePageCache(30 * time.Second)
|
||||||
|
postService.WithHomePageInvalidator(func() { homeCache.Invalidate() })
|
||||||
|
|
||||||
return &dependencies{
|
return &dependencies{
|
||||||
authCtrl: authCtrl, authMdw: authMdw, maintenanceMdw: maintenanceMdw,
|
authCtrl: authCtrl, authMdw: authMdw, rateLimiter: rateLimiter, maintenanceMdw: maintenanceMdw,
|
||||||
settingsCtrl: settingsCtrl,
|
settingsCtrl: settingsCtrl,
|
||||||
messageCtrl: messageController, postCtrl: postCtrl, spaceCtrl: spaceCtrl,
|
messageCtrl: messageController, postCtrl: postCtrl, postService: postService, spaceCtrl: spaceCtrl,
|
||||||
studioCtrl: studioCtrl,
|
studioCtrl: studioCtrl,
|
||||||
adminPostCtrl: adminPostCtrl,
|
adminPostCtrl: adminPostCtrl,
|
||||||
adminCtrl: adminController, auditCtrl: auditController,
|
adminCtrl: adminController, auditCtrl: auditController,
|
||||||
siteSettingCtrl: siteSettingController,
|
siteSettingCtrl: siteSettingController,
|
||||||
|
levelCtrl: levelCtrl,
|
||||||
|
energyCtrl: energyCtrl,
|
||||||
|
adminEnergyCtrl: adminEnergyCtrl,
|
||||||
|
energyService: energyService,
|
||||||
|
commentCtrl: commentCtrl,
|
||||||
|
commentService: commentService,
|
||||||
|
adminCommentCtrl: adminCommentCtrl,
|
||||||
|
reactionCtrl: reactionCtrl,
|
||||||
|
followCtrl: followCtrl,
|
||||||
|
favoriteCtrl: favoriteCtrl,
|
||||||
|
favoriteService: favoriteService,
|
||||||
|
trendsService: trendsService,
|
||||||
|
homeCache: homeCache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,9 +22,22 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
})
|
})
|
||||||
{
|
{
|
||||||
pages.GET("/", func(c *gin.Context) {
|
pages.GET("/", func(c *gin.Context) {
|
||||||
|
posts, total, ok := d.homeCache.Get()
|
||||||
|
if !ok {
|
||||||
|
var err error
|
||||||
|
posts, total, err = d.postService.List("", 1, 6)
|
||||||
|
if err != nil {
|
||||||
|
posts = nil
|
||||||
|
total = 0
|
||||||
|
} else {
|
||||||
|
d.homeCache.Set(posts, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
c.HTML(http.StatusOK, "home/index.html", common.BuildPageData(c, gin.H{
|
c.HTML(http.StatusOK, "home/index.html", common.BuildPageData(c, gin.H{
|
||||||
"Title": "首页",
|
"Title": "首页",
|
||||||
"ExtraCSS": "/static/css/home.css",
|
"ExtraCSS": "/static/css/home.css",
|
||||||
|
"Posts": posts,
|
||||||
|
"PostCount": total,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -40,9 +53,10 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
|
|
||||||
// 帖子页面
|
// 帖子页面
|
||||||
pages.GET("/posts", d.postCtrl.ListPage)
|
pages.GET("/posts", d.postCtrl.ListPage)
|
||||||
pages.GET("/posts/new", d.authMdw.Required(), d.postCtrl.NewPage)
|
|
||||||
pages.GET("/posts/:id", d.postCtrl.ShowPage)
|
pages.GET("/posts/:id", d.postCtrl.ShowPage)
|
||||||
pages.GET("/posts/:id/edit", d.authMdw.Required(), d.postCtrl.EditPage)
|
pages.GET("/posts/:id/edit", d.authMdw.Required(), func(c *gin.Context) {
|
||||||
|
c.Redirect(http.StatusMovedPermanently, "/studio/write?id="+c.Param("id"))
|
||||||
|
})
|
||||||
|
|
||||||
// 创作中心(需登录)
|
// 创作中心(需登录)
|
||||||
studioPages := pages.Group("/studio")
|
studioPages := pages.Group("/studio")
|
||||||
|
|||||||
@ -8,12 +8,20 @@ import (
|
|||||||
|
|
||||||
type AdminService struct {
|
type AdminService struct {
|
||||||
userRepo userAdminStore
|
userRepo userAdminStore
|
||||||
|
postRepo postAdminStatStore
|
||||||
sessionManager *session.Manager
|
sessionManager *session.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PostStats 帖子统计结果(管理首页用)
|
||||||
|
type PostStats struct {
|
||||||
|
Total int64 // 全部未删除帖子数
|
||||||
|
Approved int64 // 已发布数
|
||||||
|
Pending int64 // 待审核数(pending + 待审修订)
|
||||||
|
}
|
||||||
|
|
||||||
// NewAdminService 构造函数
|
// NewAdminService 构造函数
|
||||||
func NewAdminService(userRepo userAdminStore, sm *session.Manager) *AdminService {
|
func NewAdminService(userRepo userAdminStore, postRepo postAdminStatStore, sm *session.Manager) *AdminService {
|
||||||
return &AdminService{userRepo: userRepo, sessionManager: sm}
|
return &AdminService{userRepo: userRepo, postRepo: postRepo, sessionManager: sm}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUsersParams 用户列表查询参数
|
// ListUsersParams 用户列表查询参数
|
||||||
@ -112,6 +120,23 @@ func (s *AdminService) CountUsers() (int64, error) {
|
|||||||
return s.userRepo.CountSearchUsers("", "", "")
|
return s.userRepo.CountSearchUsers("", "", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPostStats 获取帖子统计(管理首页用)
|
||||||
|
func (s *AdminService) GetPostStats() (*PostStats, error) {
|
||||||
|
total, err := s.postRepo.CountPostsByStatus("")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
approved, err := s.postRepo.CountPostsByStatus(model.PostStatusApproved)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pending, err := s.postRepo.CountPending()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PostStats{Total: total, Approved: approved, Pending: pending}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetStoreMetrics 返回会话存储状态信息(管理首页用)
|
// GetStoreMetrics 返回会话存储状态信息(管理首页用)
|
||||||
func (s *AdminService) GetStoreMetrics() session.StoreMetrics {
|
func (s *AdminService) GetStoreMetrics() session.StoreMetrics {
|
||||||
return s.sessionManager.GetStoreMetrics()
|
return s.sessionManager.GetStoreMetrics()
|
||||||
|
|||||||
128
internal/service/audit_review.go
Normal file
128
internal/service/audit_review.go
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// review 通用审核操作
|
||||||
|
func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool, reason string) error {
|
||||||
|
// 1. 查审核记录
|
||||||
|
submission, err := s.auditRepo.FindByID(submissionID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return common.ErrAuditNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 必须 pending
|
||||||
|
if submission.Status != model.AuditStatusPending {
|
||||||
|
return common.ErrAuditNotPending
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 查审核人信息
|
||||||
|
reviewer, err := s.userRepo.FindByID(reviewerID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 标记审核结果
|
||||||
|
submission.ReviewedBy = &reviewerID
|
||||||
|
submission.ReviewerName = &reviewer.Username
|
||||||
|
if approved {
|
||||||
|
submission.Status = model.AuditStatusApproved
|
||||||
|
// 更新 User 表对应字段
|
||||||
|
if err := s.applyApproval(submission); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 通知用户审核通过
|
||||||
|
if s.notifier != nil {
|
||||||
|
s.notifier.NotifyAuditApproved(submission.UserID, submission.AuditType, submission.ID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
submission.Status = model.AuditStatusRejected
|
||||||
|
submission.RejectReason = &reason
|
||||||
|
|
||||||
|
// 改名审核被拒 → 仅非首次改名(已扣费)才退还域能
|
||||||
|
// 首次改名免费,被拒不退还
|
||||||
|
shouldRefund := false
|
||||||
|
if submission.AuditType == model.AuditTypeUsername && s.energyRefundSvc != nil {
|
||||||
|
if s.levelSvc != nil {
|
||||||
|
hasCompleted, _ := s.levelSvc.HasCompletedTask(submission.UserID, "username")
|
||||||
|
shouldRefund = hasCompleted
|
||||||
|
}
|
||||||
|
if shouldRefund {
|
||||||
|
_ = s.energyRefundSvc.RefundOnRenameReject(submission.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知用户审核被拒(仅实际退款时追加域能返还提示)
|
||||||
|
if s.notifier != nil {
|
||||||
|
reasonMsg := reason
|
||||||
|
if shouldRefund {
|
||||||
|
reasonMsg += "。扣除的域能已被返还"
|
||||||
|
}
|
||||||
|
s.notifier.NotifyAuditRejected(submission.UserID, submission.AuditType, reasonMsg, submission.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 头像审核被拒 → 删除提交的新头像文件
|
||||||
|
if submission.AuditType == model.AuditTypeAvatar && s.avatarCleaner != nil {
|
||||||
|
_ = s.avatarCleaner.DeleteAvatarFile(submission.NewValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.auditRepo.Update(submission)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyApproval 将审核通过的内容写入 User 表
|
||||||
|
// 用户名审批前再次检查冲突(防止提交到审批期间被其他用户抢占)
|
||||||
|
func (s *AuditService) applyApproval(submission *model.AuditSubmission) error {
|
||||||
|
user, err := s.userRepo.FindByID(submission.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
switch submission.AuditType {
|
||||||
|
case model.AuditTypeUsername:
|
||||||
|
exists, err := s.userRepo.ExistsByUsername(submission.NewValue)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return common.ErrUsernameTaken
|
||||||
|
}
|
||||||
|
user.Username = submission.NewValue
|
||||||
|
case model.AuditTypeAvatar:
|
||||||
|
oldAvatar := user.Avatar
|
||||||
|
user.Avatar = submission.NewValue
|
||||||
|
if err := s.userRepo.Update(user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 审核通过,删除旧头像文件
|
||||||
|
if s.avatarCleaner != nil {
|
||||||
|
_ = s.avatarCleaner.DeleteAvatarFile(oldAvatar)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case model.AuditTypeBio:
|
||||||
|
user.Bio = submission.NewValue
|
||||||
|
}
|
||||||
|
if err := s.userRepo.Update(user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发首次任务奖励(静默失败不影响主流程)
|
||||||
|
if s.levelSvc != nil {
|
||||||
|
switch submission.AuditType {
|
||||||
|
case model.AuditTypeUsername:
|
||||||
|
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "username")
|
||||||
|
case model.AuditTypeAvatar:
|
||||||
|
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "avatar")
|
||||||
|
case model.AuditTypeBio:
|
||||||
|
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "bio")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -4,8 +4,6 @@ import (
|
|||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/metalab/internal/common"
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/metalab/internal/config"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// auditRepo AuditService 所需的最小仓储接口(ISP)
|
// auditRepo AuditService 所需的最小仓储接口(ISP)
|
||||||
@ -33,12 +31,29 @@ type siteSettingsChecker interface {
|
|||||||
IsAuditTypeEnabled(auditType string, defaultVal bool) bool
|
IsAuditTypeEnabled(auditType string, defaultVal bool) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// auditNotifier 审核服务所需的通知接口(ISP:审核通过/拒绝时发送通知)
|
// auditNotifier AuditService 所需的通知接口(ISP:审核通过/拒绝时发送通知)
|
||||||
type auditNotifier interface {
|
type auditNotifier interface {
|
||||||
NotifyAuditApproved(userID uint, auditType string, relatedID uint)
|
NotifyAuditApproved(userID uint, auditType string, relatedID uint)
|
||||||
NotifyAuditRejected(userID uint, auditType, reason string, relatedID uint)
|
NotifyAuditRejected(userID uint, auditType, reason string, relatedID uint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auditTaskCompleter 审核通过时触发首次任务奖励的接口
|
||||||
|
type auditTaskCompleter interface {
|
||||||
|
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
|
||||||
|
HasCompletedTask(userID uint, taskType string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// energyRenameRefunder 改名审核被拒时返还域能的接口(ISP)
|
||||||
|
type energyRenameRefunder interface {
|
||||||
|
RefundOnRenameReject(userID uint) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// avatarFileCleaner 头像审核通过/拒绝时清理头像文件的接口(ISP)
|
||||||
|
type avatarFileCleaner interface {
|
||||||
|
CleanOldAvatars(userID uint)
|
||||||
|
DeleteAvatarFile(url string) error
|
||||||
|
}
|
||||||
|
|
||||||
// AuditService 审核业务逻辑
|
// AuditService 审核业务逻辑
|
||||||
type AuditService struct {
|
type AuditService struct {
|
||||||
auditRepo auditRepo
|
auditRepo auditRepo
|
||||||
@ -46,19 +61,35 @@ type AuditService struct {
|
|||||||
settings siteSettingsChecker
|
settings siteSettingsChecker
|
||||||
defaultCfg config.AuditConfig
|
defaultCfg config.AuditConfig
|
||||||
notifier auditNotifier
|
notifier auditNotifier
|
||||||
|
levelSvc auditTaskCompleter
|
||||||
|
energyRefundSvc energyRenameRefunder
|
||||||
|
avatarCleaner avatarFileCleaner
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuditService 构造函数
|
// NewAuditService 构造函数
|
||||||
func NewAuditService(auditRepo auditRepo, userRepo userProfileStore, settings siteSettingsChecker, cfg config.AuditConfig, notifier auditNotifier) *AuditService {
|
func NewAuditService(auditRepo auditRepo, userRepo userProfileStore, settings siteSettingsChecker, cfg config.AuditConfig, notifier auditNotifier, levelSvc auditTaskCompleter) *AuditService {
|
||||||
return &AuditService{
|
return &AuditService{
|
||||||
auditRepo: auditRepo,
|
auditRepo: auditRepo,
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
defaultCfg: cfg,
|
defaultCfg: cfg,
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
|
levelSvc: levelSvc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithEnergyRefundService 链式注入域能退款服务
|
||||||
|
func (s *AuditService) WithEnergyRefundService(svc energyRenameRefunder) *AuditService {
|
||||||
|
s.energyRefundSvc = svc
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAvatarFileCleaner 链式注入头像文件清理服务
|
||||||
|
func (s *AuditService) WithAvatarFileCleaner(svc avatarFileCleaner) *AuditService {
|
||||||
|
s.avatarCleaner = svc
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// ShouldAudit 判断指定用户是否需要走审核流程
|
// ShouldAudit 判断指定用户是否需要走审核流程
|
||||||
// 规则:admin 及以上角色免审
|
// 规则:admin 及以上角色免审
|
||||||
func (s *AuditService) ShouldAudit(userID uint) (bool, error) {
|
func (s *AuditService) ShouldAudit(userID uint) (bool, error) {
|
||||||
@ -150,79 +181,6 @@ func (s *AuditService) Reject(reviewerID uint, submissionID uint, reason string)
|
|||||||
return s.review(reviewerID, submissionID, false, reason)
|
return s.review(reviewerID, submissionID, false, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
// review 通用审核操作
|
|
||||||
func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool, reason string) error {
|
|
||||||
// 1. 查审核记录
|
|
||||||
submission, err := s.auditRepo.FindByID(submissionID)
|
|
||||||
if err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
return common.ErrAuditNotFound
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 必须 pending
|
|
||||||
if submission.Status != model.AuditStatusPending {
|
|
||||||
return common.ErrAuditNotPending
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 查审核人信息
|
|
||||||
reviewer, err := s.userRepo.FindByID(reviewerID)
|
|
||||||
if err != nil {
|
|
||||||
return common.ErrUserNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 标记审核结果
|
|
||||||
submission.ReviewedBy = &reviewerID
|
|
||||||
submission.ReviewerName = &reviewer.Username
|
|
||||||
if approved {
|
|
||||||
submission.Status = model.AuditStatusApproved
|
|
||||||
// 更新 User 表对应字段
|
|
||||||
if err := s.applyApproval(submission); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// 通知用户审核通过
|
|
||||||
if s.notifier != nil {
|
|
||||||
s.notifier.NotifyAuditApproved(submission.UserID, submission.AuditType, submission.ID)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
submission.Status = model.AuditStatusRejected
|
|
||||||
submission.RejectReason = &reason
|
|
||||||
// 通知用户审核被拒
|
|
||||||
if s.notifier != nil {
|
|
||||||
reasonMsg := reason
|
|
||||||
s.notifier.NotifyAuditRejected(submission.UserID, submission.AuditType, reasonMsg, submission.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s.auditRepo.Update(submission)
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyApproval 将审核通过的内容写入 User 表
|
|
||||||
// 用户名审批前再次检查冲突(防止提交到审批期间被其他用户抢占)
|
|
||||||
func (s *AuditService) applyApproval(submission *model.AuditSubmission) error {
|
|
||||||
user, err := s.userRepo.FindByID(submission.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return common.ErrUserNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
switch submission.AuditType {
|
|
||||||
case model.AuditTypeUsername:
|
|
||||||
exists, err := s.userRepo.ExistsByUsername(submission.NewValue)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
return common.ErrUsernameTaken
|
|
||||||
}
|
|
||||||
user.Username = submission.NewValue
|
|
||||||
case model.AuditTypeAvatar:
|
|
||||||
user.Avatar = submission.NewValue
|
|
||||||
case model.AuditTypeBio:
|
|
||||||
user.Bio = submission.NewValue
|
|
||||||
}
|
|
||||||
return s.userRepo.Update(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List 分页查询审核列表
|
// List 分页查询审核列表
|
||||||
func (s *AuditService) List(auditType, status string, page, pageSize int) (*AuditListResult, error) {
|
func (s *AuditService) List(auditType, status string, page, pageSize int) (*AuditListResult, error) {
|
||||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
|
|||||||
129
internal/service/auth_password.go
Normal file
129
internal/service/auth_password.go
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pwLower = regexp.MustCompile(`[a-z]`)
|
||||||
|
var pwUpper = regexp.MustCompile(`[A-Z]`)
|
||||||
|
var pwDigit = regexp.MustCompile(`\d`)
|
||||||
|
|
||||||
|
// dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对
|
||||||
|
var dummyHash []byte
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte("metazone-dummy-hash-2026"), 12)
|
||||||
|
if err != nil {
|
||||||
|
panic("failed to generate bcrypt dummy hash: " + err.Error())
|
||||||
|
}
|
||||||
|
dummyHash = hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatePassword 密码强度:至少 8 位 + 包含大写字母 + 包含小写字母 + 包含数字
|
||||||
|
func validatePassword(pw string) error {
|
||||||
|
if len(pw) < 8 || !pwLower.MatchString(pw) || !pwUpper.MatchString(pw) || !pwDigit.MatchString(pw) {
|
||||||
|
return common.ErrWeakPassword
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword 修改密码
|
||||||
|
// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 删除所有 session(强制重新登录)
|
||||||
|
func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error {
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil {
|
||||||
|
return common.ErrIncorrectPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validatePassword(newPassword); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.PasswordHash = hash
|
||||||
|
if err := s.userRepo.Update(user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除所有 session,强制所有设备重新登录
|
||||||
|
return s.invalidateSessions(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAccount 用户自主注销
|
||||||
|
func (s *AuthService) DeleteAccount(userID uint, password, reason string) error {
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Role == model.RoleOwner {
|
||||||
|
return common.ErrOwnerCannotDelete
|
||||||
|
}
|
||||||
|
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
|
||||||
|
return common.ErrIncorrectPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Status = model.StatusDeleted
|
||||||
|
user.DeleteReason = reason
|
||||||
|
|
||||||
|
if err := s.userRepo.Update(user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.invalidateSessions(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidateSessions 销毁某用户的所有 session(内部方法)
|
||||||
|
func (s *AuthService) invalidateSessions(userID uint) error {
|
||||||
|
return s.sessionManager.DestroyByUID(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateSessions 公开方法:销毁用户所有 session(供 admin 等外部调用)
|
||||||
|
func (s *AuthService) InvalidateSessions(userID uint) error {
|
||||||
|
return s.sessionManager.DestroyByUID(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmRestore 二次确认恢复已注销账号
|
||||||
|
func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error) {
|
||||||
|
user, err := s.userRepo.FindByEmail(req.Email)
|
||||||
|
if err != nil {
|
||||||
|
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
||||||
|
return nil, common.ErrInvalidCred
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Status != model.StatusDeleted {
|
||||||
|
return nil, common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
|
||||||
|
return nil, common.ErrInvalidCred
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复账号
|
||||||
|
user.Status = model.StatusActive
|
||||||
|
user.DeletedAt = gorm.DeletedAt{}
|
||||||
|
now := time.Now()
|
||||||
|
user.LastLoginIP = loginIP
|
||||||
|
user.LastLoginAt = &now
|
||||||
|
if err := s.userRepo.Update(user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"regexp"
|
"regexp"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@ -11,7 +12,6 @@ import (
|
|||||||
"metazone.cc/metalab/internal/session"
|
"metazone.cc/metalab/internal/session"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthService 认证业务逻辑
|
// AuthService 认证业务逻辑
|
||||||
@ -27,31 +27,9 @@ func NewAuthService(userRepo userAuthStore, sm *session.Manager, cfg *config.Con
|
|||||||
return &AuthService{userRepo: userRepo, sessionManager: sm, cfg: cfg, siteSettings: siteSettings}
|
return &AuthService{userRepo: userRepo, sessionManager: sm, cfg: cfg, siteSettings: siteSettings}
|
||||||
}
|
}
|
||||||
|
|
||||||
var pwLetter = regexp.MustCompile(`[a-zA-Z]`)
|
|
||||||
var pwDigit = regexp.MustCompile(`\d`)
|
|
||||||
|
|
||||||
// usernamePattern 用户名合法字符:中文、英文大小写、数字、下划线、连字符
|
// usernamePattern 用户名合法字符:中文、英文大小写、数字、下划线、连字符
|
||||||
var usernamePattern = regexp.MustCompile(`^[\p{Han}a-zA-Z0-9_-]+$`)
|
var usernamePattern = regexp.MustCompile(`^[\p{Han}a-zA-Z0-9_-]+$`)
|
||||||
|
|
||||||
// dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对
|
|
||||||
var dummyHash []byte
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte("metazone-dummy-hash-2026"), 12)
|
|
||||||
if err != nil {
|
|
||||||
panic("failed to generate bcrypt dummy hash: " + err.Error())
|
|
||||||
}
|
|
||||||
dummyHash = hash
|
|
||||||
}
|
|
||||||
|
|
||||||
// validatePassword 密码强度:至少 8 位 + 包含字母 + 包含数字
|
|
||||||
func validatePassword(pw string) error {
|
|
||||||
if len(pw) < 8 || !pwLetter.MatchString(pw) || !pwDigit.MatchString(pw) {
|
|
||||||
return common.ErrWeakPassword
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsRegistrationEnabled 检查是否允许新用户注册
|
// IsRegistrationEnabled 检查是否允许新用户注册
|
||||||
func (s *AuthService) IsRegistrationEnabled() bool {
|
func (s *AuthService) IsRegistrationEnabled() bool {
|
||||||
return s.siteSettings.IsRegistrationEnabled()
|
return s.siteSettings.IsRegistrationEnabled()
|
||||||
@ -148,12 +126,6 @@ func (s *AuthService) Login(req model.LoginRequest, loginIP string) (*model.User
|
|||||||
return nil, common.ErrUserLocked
|
return nil, common.ErrUserLocked
|
||||||
}
|
}
|
||||||
|
|
||||||
// 封禁
|
|
||||||
if user.Status == model.StatusBanned {
|
|
||||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
|
||||||
return nil, common.ErrUserBanned
|
|
||||||
}
|
|
||||||
|
|
||||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
|
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
|
||||||
return nil, common.ErrInvalidCred
|
return nil, common.ErrInvalidCred
|
||||||
}
|
}
|
||||||
@ -169,35 +141,6 @@ func (s *AuthService) Login(req model.LoginRequest, loginIP string) (*model.User
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfirmRestore 二次确认恢复已注销账号
|
|
||||||
func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error) {
|
|
||||||
user, err := s.userRepo.FindByEmail(req.Email)
|
|
||||||
if err != nil {
|
|
||||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
|
||||||
return nil, common.ErrInvalidCred
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.Status != model.StatusDeleted {
|
|
||||||
return nil, common.ErrUserNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
|
|
||||||
return nil, common.ErrInvalidCred
|
|
||||||
}
|
|
||||||
|
|
||||||
// 恢复账号
|
|
||||||
user.Status = model.StatusActive
|
|
||||||
user.DeletedAt = gorm.DeletedAt{}
|
|
||||||
now := time.Now()
|
|
||||||
user.LastLoginIP = loginIP
|
|
||||||
user.LastLoginAt = &now
|
|
||||||
if err := s.userRepo.Update(user); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckEmail 检查邮箱是否已被注册
|
// CheckEmail 检查邮箱是否已被注册
|
||||||
func (s *AuthService) CheckEmail(email string) (bool, error) {
|
func (s *AuthService) CheckEmail(email string) (bool, error) {
|
||||||
return s.userRepo.ExistsByEmail(email)
|
return s.userRepo.ExistsByEmail(email)
|
||||||
@ -208,71 +151,6 @@ func (s *AuthService) GetProfile(userID uint) (*model.User, error) {
|
|||||||
return s.userRepo.FindByID(userID)
|
return s.userRepo.FindByID(userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChangePassword 修改密码
|
|
||||||
// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 删除所有 session(强制重新登录)
|
|
||||||
func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error {
|
|
||||||
user, err := s.userRepo.FindByID(userID)
|
|
||||||
if err != nil {
|
|
||||||
return common.ErrUserNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil {
|
|
||||||
return common.ErrIncorrectPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := validatePassword(newPassword); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
user.PasswordHash = hash
|
|
||||||
if err := s.userRepo.Update(user); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除所有 session,强制所有设备重新登录
|
|
||||||
return s.invalidateSessions(userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteAccount 用户自主注销
|
|
||||||
func (s *AuthService) DeleteAccount(userID uint, password, reason string) error {
|
|
||||||
user, err := s.userRepo.FindByID(userID)
|
|
||||||
if err != nil {
|
|
||||||
return common.ErrUserNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.Role == model.RoleOwner {
|
|
||||||
return common.ErrOwnerCannotDelete
|
|
||||||
}
|
|
||||||
|
|
||||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
|
|
||||||
return common.ErrIncorrectPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
user.Status = model.StatusDeleted
|
|
||||||
user.DeleteReason = reason
|
|
||||||
|
|
||||||
if err := s.userRepo.Update(user); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.invalidateSessions(userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// invalidateSessions 销毁某用户的所有 session(内部方法)
|
|
||||||
func (s *AuthService) invalidateSessions(userID uint) error {
|
|
||||||
return s.sessionManager.DestroyByUID(userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// InvalidateSessions 公开方法:销毁用户所有 session(供 admin 等外部调用)
|
|
||||||
func (s *AuthService) InvalidateSessions(userID uint) error {
|
|
||||||
return s.sessionManager.DestroyByUID(userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateProfile 修改个人资料
|
// UpdateProfile 修改个人资料
|
||||||
func (s *AuthService) UpdateProfile(userID uint, username, bio string) error {
|
func (s *AuthService) UpdateProfile(userID uint, username, bio string) error {
|
||||||
user, err := s.userRepo.FindByID(userID)
|
user, err := s.userRepo.FindByID(userID)
|
||||||
@ -315,3 +193,29 @@ func (s *AuthService) UpdateProfile(userID uint, username, bio string) error {
|
|||||||
}
|
}
|
||||||
return s.userRepo.Update(user)
|
return s.userRepo.Update(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNotifyPrefs 获取用户通知偏好
|
||||||
|
func (s *AuthService) GetNotifyPrefs(userID uint) (map[string]bool, error) {
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
return ParseNotifyPrefs(user.NotifyPrefs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNotifyPref 更新单个通知偏好项
|
||||||
|
func (s *AuthService) UpdateNotifyPref(userID uint, key string, enabled bool) error {
|
||||||
|
user, err := s.userRepo.FindByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs := ParseNotifyPrefs(user.NotifyPrefs)
|
||||||
|
prefs[key] = enabled
|
||||||
|
data, err := json.Marshal(prefs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.NotifyPrefs = string(data)
|
||||||
|
return s.userRepo.Update(user)
|
||||||
|
}
|
||||||
|
|||||||
@ -57,6 +57,9 @@ func (s *AvatarService) ProcessAvatar(userID uint, file io.Reader, contentType s
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 直接更新场景:删除旧头像文件,仅保留新文件
|
||||||
|
s.CleanOldAvatars(userID)
|
||||||
|
|
||||||
user, err := s.userRepo.FindByID(userID)
|
user, err := s.userRepo.FindByID(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("用户不存在")
|
return "", fmt.Errorf("用户不存在")
|
||||||
@ -134,10 +137,8 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
|
|||||||
return "", fmt.Errorf("创建存储目录失败")
|
return "", fmt.Errorf("创建存储目录失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. 删除该用户的旧头像,仅保留最新
|
// 8. 原子写入:先写临时文件,再 rename 到最终路径
|
||||||
s.cleanOldAvatars(userID)
|
// 注意:此处不删除旧头像文件,审核场景下审核通过前旧头像仍需正常显示
|
||||||
|
|
||||||
// 9. 原子写入:先写临时文件,再 rename 到最终路径
|
|
||||||
ts := time.Now().Unix()
|
ts := time.Now().Unix()
|
||||||
filename := fmt.Sprintf("%d_%d.webp", userID, ts)
|
filename := fmt.Sprintf("%d_%d.webp", userID, ts)
|
||||||
tmpPath := filepath.Join(s.storageDir, filename+".tmp")
|
tmpPath := filepath.Join(s.storageDir, filename+".tmp")
|
||||||
@ -153,8 +154,8 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
|
|||||||
return fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts), nil
|
return fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanOldAvatars 删除指定用户的所有旧头像文件(仅保留最新一次上传)
|
// CleanOldAvatars 删除指定用户的所有旧头像文件,调用方负责确保新文件不受影响
|
||||||
func (s *AvatarService) cleanOldAvatars(userID uint) {
|
func (s *AvatarService) CleanOldAvatars(userID uint) {
|
||||||
prefix := strconv.FormatUint(uint64(userID), 10) + "_"
|
prefix := strconv.FormatUint(uint64(userID), 10) + "_"
|
||||||
entries, err := os.ReadDir(s.storageDir)
|
entries, err := os.ReadDir(s.storageDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -167,6 +168,19 @@ func (s *AvatarService) cleanOldAvatars(userID uint) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteAvatarFile 根据头像 URL 删除对应的磁盘文件
|
||||||
|
func (s *AvatarService) DeleteAvatarFile(url string) error {
|
||||||
|
if url == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
filename := filepath.Base(url)
|
||||||
|
if filename == "." || filename == "/" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(s.storageDir, filename)
|
||||||
|
return os.Remove(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
// subImage 从原图中裁切指定正方形区域
|
// subImage 从原图中裁切指定正方形区域
|
||||||
func subImage(img image.Image, x, y, size int) image.Image {
|
func subImage(img image.Image, x, y, size int) image.Image {
|
||||||
cropped := image.NewNRGBA(image.Rect(0, 0, size, size))
|
cropped := image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||||
|
|||||||
244
internal/service/comment_service.go
Normal file
244
internal/service/comment_service.go
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mentionPattern = regexp.MustCompile(`@(\S{1,16})`)
|
||||||
|
|
||||||
|
// CommentService 评论业务逻辑
|
||||||
|
type CommentService struct {
|
||||||
|
repo commentStore
|
||||||
|
notifier commentNotifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCommentService 构造函数
|
||||||
|
func NewCommentService(repo commentStore, notifier commentNotifier) *CommentService {
|
||||||
|
return &CommentService{repo: repo, notifier: notifier}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRoot 创建顶级评论
|
||||||
|
func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*model.Comment, error) {
|
||||||
|
if strings.TrimSpace(body) == "" {
|
||||||
|
return nil, common.ErrCommentEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
postAuthorID, err := s.repo.FindPostAuthorID(postID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询文章失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
comment := &model.Comment{
|
||||||
|
PostID: postID,
|
||||||
|
RootID: 0,
|
||||||
|
Body: body,
|
||||||
|
UserID: userID,
|
||||||
|
}
|
||||||
|
if err := s.repo.Create(comment); err != nil {
|
||||||
|
return nil, fmt.Errorf("创建评论失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RootID 指向自身(UPDATE 而非 INSERT)
|
||||||
|
comment.RootID = comment.ID
|
||||||
|
if err := s.repo.UpdateRootID(comment.ID, comment.ID); err != nil {
|
||||||
|
return nil, fmt.Errorf("更新根评论ID失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文章评论数+1
|
||||||
|
_ = s.repo.IncrCommentsCount(postID)
|
||||||
|
|
||||||
|
// 解析 @ 提及 + 发送通知
|
||||||
|
commenterName, _ := s.repo.GetUsernameByID(userID)
|
||||||
|
if commenterName == "" {
|
||||||
|
commenterName = "用户"
|
||||||
|
}
|
||||||
|
s.parseMentions(comment.ID, postID, userID, commenterName, body)
|
||||||
|
|
||||||
|
// 通知文章作者(评论者 ≠ 作者),RelatedID 存 postID 以便消息页生成跳转链接
|
||||||
|
if s.notifier != nil && userID != postAuthorID {
|
||||||
|
s.notifier.Create(postAuthorID, model.NotifyComment,
|
||||||
|
"新评论",
|
||||||
|
fmt.Sprintf("%s 评论了你的文章", commenterName),
|
||||||
|
&comment.PostID, &comment.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return comment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateReply 创建回复
|
||||||
|
func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (*model.Comment, error) {
|
||||||
|
if strings.TrimSpace(body) == "" {
|
||||||
|
return nil, common.ErrCommentEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
parent, err := s.repo.FindByID(parentID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, common.ErrCommentNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("查询父评论失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
comment := &model.Comment{
|
||||||
|
PostID: parent.PostID,
|
||||||
|
RootID: parent.RootID,
|
||||||
|
ParentID: &parentID,
|
||||||
|
Body: body,
|
||||||
|
UserID: userID,
|
||||||
|
ReplyToUID: parent.UserID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.Create(comment); err != nil {
|
||||||
|
return nil, fmt.Errorf("创建回复失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文章评论数+1
|
||||||
|
_ = s.repo.IncrCommentsCount(parent.PostID)
|
||||||
|
|
||||||
|
// 解析 @ 提及 + 发送通知
|
||||||
|
commenterName, _ := s.repo.GetUsernameByID(userID)
|
||||||
|
if commenterName == "" {
|
||||||
|
commenterName = "用户"
|
||||||
|
}
|
||||||
|
s.parseMentions(comment.ID, parent.PostID, userID, commenterName, body)
|
||||||
|
|
||||||
|
// 通知被回复者(不自己回复自己),RelatedID 存 postID 以便消息页生成跳转链接
|
||||||
|
if s.notifier != nil && parent.UserID != userID {
|
||||||
|
s.notifier.Create(parent.UserID, model.NotifyCommentReply,
|
||||||
|
"新回复",
|
||||||
|
fmt.Sprintf("%s 回复了你的评论", commenterName),
|
||||||
|
&parent.PostID, &comment.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return comment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 软删除评论(requesterID 为请求者,isAdmin 表示是否为管理员)
|
||||||
|
func (s *CommentService) Delete(commentID uint, requesterID uint, isAdmin bool) error {
|
||||||
|
comment, err := s.repo.FindByID(commentID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return common.ErrCommentNotFound
|
||||||
|
}
|
||||||
|
return fmt.Errorf("查询评论失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if comment.IsDeleted {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限检查:仅本人或管理员可删除
|
||||||
|
if !isAdmin && comment.UserID != requesterID {
|
||||||
|
return common.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.SoftDelete(commentID); err != nil {
|
||||||
|
return fmt.Errorf("删除评论失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.repo.DecrCommentsCount(comment.PostID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRootComments 分页获取文章顶级评论
|
||||||
|
func (s *CommentService) ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error) {
|
||||||
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
|
p.DefaultPagination()
|
||||||
|
comments, total, err := s.repo.FindRootComments(postID, p.Offset(), p.PageSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
// 批量加载有效@提及映射,前端据此决定是否为链接
|
||||||
|
if len(comments) > 0 {
|
||||||
|
_ = s.repo.LoadMentionsForComments(comments)
|
||||||
|
}
|
||||||
|
return comments, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReplies 获取某条顶级评论的全部回复
|
||||||
|
func (s *CommentService) ListReplies(rootID uint) ([]model.Comment, error) {
|
||||||
|
list, err := s.repo.FindReplies(rootID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询回复失败: %w", err)
|
||||||
|
}
|
||||||
|
if list == nil {
|
||||||
|
list = []model.Comment{}
|
||||||
|
}
|
||||||
|
// 批量加载有效@提及映射
|
||||||
|
if len(list) > 0 {
|
||||||
|
_ = s.repo.LoadMentionsForComments(list)
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllComments 后台评论列表(分页+搜索)
|
||||||
|
func (s *CommentService) ListAllComments(keyword string, showDeleted bool, page, pageSize int) ([]model.AdminCommentRow, int64, error) {
|
||||||
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
|
p.DefaultPagination()
|
||||||
|
rows, total, err := s.repo.ListAllComments(keyword, showDeleted, p.Offset(), p.PageSize)
|
||||||
|
if rows == nil {
|
||||||
|
rows = []model.AdminCommentRow{}
|
||||||
|
}
|
||||||
|
return rows, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchUsers 搜索LV2+用户(用于@艾特悬浮窗),已关注优先,最多5条
|
||||||
|
func (s *CommentService) SearchUsers(keyword string, followerUID uint) ([]model.UserSearchResult, error) {
|
||||||
|
rows, err := s.repo.SearchUsersByLevel(keyword, 2, 5, followerUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("搜索用户失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
users := make([]model.UserSearchResult, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
users[i] = model.UserSearchResult{
|
||||||
|
UID: row.UID,
|
||||||
|
Username: row.Username,
|
||||||
|
Level: model.GetLevelByExp(row.Exp),
|
||||||
|
Avatar: row.Avatar,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMentions 解析@用户,存储到 comment_mentions 表,并向被@用户发送通知
|
||||||
|
func (s *CommentService) parseMentions(commentID uint, postID uint, commenterID uint, commenterName string, body string) {
|
||||||
|
matches := mentionPattern.FindAllStringSubmatch(body, -1)
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
notified := make(map[uint]bool) // 防止重复通知同一用户
|
||||||
|
|
||||||
|
for _, m := range matches {
|
||||||
|
username := m[1]
|
||||||
|
if seen[username] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[username] = true
|
||||||
|
rows, _ := s.repo.SearchUsersByLevel(username, 2, 1, 0)
|
||||||
|
if len(rows) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mentionedUID := rows[0].UID
|
||||||
|
_ = s.repo.CreateMention(&model.CommentMention{
|
||||||
|
CommentID: commentID,
|
||||||
|
UID: mentionedUID,
|
||||||
|
OriginalUsername: username,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 向被@用户发送通知(排除自己@自己、重复通知)
|
||||||
|
if s.notifier != nil && mentionedUID != commenterID && !notified[mentionedUID] {
|
||||||
|
notified[mentionedUID] = true
|
||||||
|
s.notifier.Create(mentionedUID, model.NotifyCommentMention,
|
||||||
|
"新提及",
|
||||||
|
fmt.Sprintf("%s 在评论中 @ 了你", commenterName),
|
||||||
|
&postID, &commentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user