512 lines
15 KiB
Go
512 lines
15 KiB
Go
package handler
|
||
|
||
import (
|
||
"database/sql"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"memory/internal/config"
|
||
"memory/internal/middleware"
|
||
"memory/internal/model"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
type PostHandler struct {
|
||
db *sql.DB
|
||
cfg *config.Config
|
||
}
|
||
|
||
func NewPostHandler(db *sql.DB, cfg *config.Config) *PostHandler {
|
||
return &PostHandler{db: db, cfg: cfg}
|
||
}
|
||
|
||
func (h *PostHandler) Create(c *gin.Context) {
|
||
var req model.CreatePostRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
userID := middleware.GetUserID(c)
|
||
|
||
// 验证视频数量(最多2个)
|
||
videoCount := 0
|
||
for _, mediaURL := range req.MediaIDs {
|
||
if isVideoURL(mediaURL) {
|
||
videoCount++
|
||
}
|
||
}
|
||
if videoCount > 2 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "最多只能上传2个视频"})
|
||
return
|
||
}
|
||
|
||
tx, err := h.db.Begin()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
result, err := tx.Exec("INSERT INTO posts (user_id, content, visibility) VALUES (?, ?, ?)", userID, req.Content, req.Visibility)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create post"})
|
||
return
|
||
}
|
||
|
||
postID, _ := result.LastInsertId()
|
||
|
||
// 关联媒体文件
|
||
for i, mediaURL := range req.MediaIDs {
|
||
mediaType := "image"
|
||
if isVideoURL(mediaURL) {
|
||
mediaType = "video"
|
||
}
|
||
_, err := tx.Exec(
|
||
"INSERT INTO post_media (post_id, media_url, media_type, sort_order) VALUES (?, ?, ?, ?)",
|
||
postID, mediaURL, mediaType, i,
|
||
)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to attach media"})
|
||
return
|
||
}
|
||
}
|
||
|
||
// 处理音乐链接
|
||
if req.MusicURL != "" && IsQQMusicURL(req.MusicURL) {
|
||
parser := &QQMusicParser{}
|
||
musicInfo, err := parser.Parse(req.MusicURL)
|
||
if err == nil && musicInfo != nil {
|
||
_, err = tx.Exec(
|
||
"INSERT INTO post_music (post_id, title, artist, cover, share_url, platform) VALUES (?, ?, ?, ?, ?, ?)",
|
||
postID, musicInfo.Title, musicInfo.Artist, musicInfo.Cover, musicInfo.ShareURL, musicInfo.Platform,
|
||
)
|
||
if err != nil {
|
||
// 音乐解析失败不影响帖子创建,只记录日志
|
||
// log.Printf("Failed to save music info: %v", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
if err := tx.Commit(); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusCreated, gin.H{"id": postID})
|
||
}
|
||
|
||
func (h *PostHandler) List(c *gin.Context) {
|
||
userID := middleware.GetUserID(c)
|
||
isSuperAdmin, _ := c.Get("is_superadmin")
|
||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||
offset := (page - 1) * pageSize
|
||
|
||
// 超管可以看到所有帖子,普通用户看不到超管的帖子
|
||
var rows *sql.Rows
|
||
var err error
|
||
|
||
if isSuperAdmin != nil && isSuperAdmin.(bool) {
|
||
// 超管:看到所有帖子
|
||
rows, err = h.db.Query(`
|
||
SELECT p.id, p.user_id, p.content, p.created_at, p.updated_at, COALESCE(p.visibility, 0),
|
||
u.id, u.username, u.nickname, u.avatar_url, COALESCE(u.is_superadmin, 0),
|
||
(SELECT COUNT(*) FROM likes WHERE post_id = p.id) as like_count,
|
||
(SELECT COUNT(*) FROM likes WHERE post_id = p.id AND user_id = ?) as liked,
|
||
(SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comment_count
|
||
FROM posts p
|
||
JOIN users u ON p.user_id = u.id
|
||
ORDER BY p.created_at DESC
|
||
LIMIT ? OFFSET ?
|
||
`, userID, pageSize, offset)
|
||
} else {
|
||
// 普通用户:只能看到公开帖子 + 自己的私密帖子(不能看到别人的私密帖子)
|
||
rows, err = h.db.Query(`
|
||
SELECT p.id, p.user_id, p.content, p.created_at, p.updated_at, COALESCE(p.visibility, 0),
|
||
u.id, u.username, u.nickname, u.avatar_url, COALESCE(u.is_superadmin, 0),
|
||
(SELECT COUNT(*) FROM likes WHERE post_id = p.id) as like_count,
|
||
(SELECT COUNT(*) FROM likes WHERE post_id = p.id AND user_id = ?) as liked,
|
||
(SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comment_count
|
||
FROM posts p
|
||
JOIN users u ON p.user_id = u.id
|
||
WHERE (COALESCE(p.visibility, 0) = 0) OR (COALESCE(p.visibility, 0) = 1 AND p.user_id = ?)
|
||
ORDER BY p.created_at DESC
|
||
LIMIT ? OFFSET ?
|
||
`, userID, userID, pageSize, offset)
|
||
}
|
||
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
posts := []model.Post{}
|
||
for rows.Next() {
|
||
var post model.Post
|
||
var user model.User
|
||
var liked int
|
||
var updatedAt sql.NullTime
|
||
err := rows.Scan(
|
||
&post.ID, &post.UserID, &post.Content, &post.CreatedAt, &updatedAt, &post.Visibility,
|
||
&user.ID, &user.Username, &user.Nickname, &user.AvatarURL, &user.IsSuperAdmin,
|
||
&post.LikeCount, &liked, &post.CommentCount,
|
||
)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
post.User = &user
|
||
post.Liked = liked > 0
|
||
if updatedAt.Valid {
|
||
post.UpdatedAt = &updatedAt.Time
|
||
}
|
||
|
||
// 获取媒体
|
||
post.Media = h.getPostMedia(post.ID)
|
||
// 获取表情反应
|
||
post.Reactions = h.getPostReactions(post.ID, userID)
|
||
// 获取音乐信息
|
||
post.Music = h.getPostMusic(post.ID)
|
||
|
||
posts = append(posts, post)
|
||
}
|
||
|
||
c.JSON(http.StatusOK, posts)
|
||
}
|
||
|
||
func (h *PostHandler) Get(c *gin.Context) {
|
||
postID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
userID := middleware.GetUserID(c)
|
||
|
||
var post model.Post
|
||
var user model.User
|
||
var liked int
|
||
|
||
var updatedAt sql.NullTime
|
||
err := h.db.QueryRow(`
|
||
SELECT p.id, p.user_id, p.content, p.created_at, p.updated_at, COALESCE(p.visibility, 0),
|
||
u.id, u.username, u.nickname, u.avatar_url, COALESCE(u.is_superadmin, 0),
|
||
(SELECT COUNT(*) FROM likes WHERE post_id = p.id) as like_count,
|
||
(SELECT COUNT(*) FROM likes WHERE post_id = p.id AND user_id = ?) as liked,
|
||
(SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comment_count
|
||
FROM posts p
|
||
JOIN users u ON p.user_id = u.id
|
||
WHERE p.id = ?
|
||
`, userID, postID).Scan(
|
||
&post.ID, &post.UserID, &post.Content, &post.CreatedAt, &updatedAt, &post.Visibility,
|
||
&user.ID, &user.Username, &user.Nickname, &user.AvatarURL, &user.IsSuperAdmin,
|
||
&post.LikeCount, &liked, &post.CommentCount,
|
||
)
|
||
|
||
if err == sql.ErrNoRows {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "post not found"})
|
||
return
|
||
}
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||
return
|
||
}
|
||
|
||
post.User = &user
|
||
post.Liked = liked > 0
|
||
if updatedAt.Valid {
|
||
post.UpdatedAt = &updatedAt.Time
|
||
}
|
||
post.Media = h.getPostMedia(post.ID)
|
||
post.Reactions = h.getPostReactions(post.ID, userID)
|
||
post.Music = h.getPostMusic(post.ID)
|
||
|
||
c.JSON(http.StatusOK, post)
|
||
}
|
||
|
||
func (h *PostHandler) Update(c *gin.Context) {
|
||
postID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
userID := middleware.GetUserID(c)
|
||
isAdmin, _ := c.Get("is_admin")
|
||
isAdminBool := isAdmin != nil && isAdmin.(bool)
|
||
|
||
var req model.UpdatePostRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
// 验证视频数量(最多2个)
|
||
if req.MediaIDs != nil {
|
||
videoCount := 0
|
||
for _, mediaURL := range req.MediaIDs {
|
||
if isVideoURL(mediaURL) {
|
||
videoCount++
|
||
}
|
||
}
|
||
if videoCount > 2 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "最多只能上传2个视频"})
|
||
return
|
||
}
|
||
}
|
||
|
||
// 检查权限
|
||
var postUserID int64
|
||
err := h.db.QueryRow("SELECT user_id FROM posts WHERE id = ?", postID).Scan(&postUserID)
|
||
if err == sql.ErrNoRows {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "post not found"})
|
||
return
|
||
}
|
||
|
||
// 帖子作者或管理员可以编辑
|
||
if postUserID != userID && !isAdminBool {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"})
|
||
return
|
||
}
|
||
|
||
tx, err := h.db.Begin()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// 更新帖子内容
|
||
if req.Visibility != nil {
|
||
_, err = tx.Exec("UPDATE posts SET content = ?, visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", req.Content, *req.Visibility, postID)
|
||
} else {
|
||
_, err = tx.Exec("UPDATE posts SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", req.Content, postID)
|
||
}
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update post"})
|
||
return
|
||
}
|
||
|
||
// 如果提供了 media_ids,更新媒体
|
||
if req.MediaIDs != nil {
|
||
// 删除旧的媒体
|
||
_, err = tx.Exec("DELETE FROM post_media WHERE post_id = ?", postID)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update media"})
|
||
return
|
||
}
|
||
|
||
// 添加新的媒体
|
||
for i, mediaURL := range req.MediaIDs {
|
||
mediaType := "image"
|
||
if isVideoURL(mediaURL) {
|
||
mediaType = "video"
|
||
}
|
||
_, err := tx.Exec(
|
||
"INSERT INTO post_media (post_id, media_url, media_type, sort_order) VALUES (?, ?, ?, ?)",
|
||
postID, mediaURL, mediaType, i,
|
||
)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to attach media"})
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理音乐更新
|
||
if req.MusicURL != nil {
|
||
// 先删除旧的音乐
|
||
_, err = tx.Exec("DELETE FROM post_music WHERE post_id = ?", postID)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update music"})
|
||
return
|
||
}
|
||
|
||
// 如果提供了新的音乐链接,解析并保存
|
||
if *req.MusicURL != "" && IsQQMusicURL(*req.MusicURL) {
|
||
parser := &QQMusicParser{}
|
||
musicInfo, err := parser.Parse(*req.MusicURL)
|
||
if err == nil && musicInfo != nil {
|
||
_, err = tx.Exec(
|
||
"INSERT INTO post_music (post_id, title, artist, cover, share_url, platform) VALUES (?, ?, ?, ?, ?, ?)",
|
||
postID, musicInfo.Title, musicInfo.Artist, musicInfo.Cover, musicInfo.ShareURL, musicInfo.Platform,
|
||
)
|
||
if err != nil {
|
||
// 音乐解析失败不影响帖子更新
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if err := tx.Commit(); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"message": "updated"})
|
||
}
|
||
|
||
func (h *PostHandler) Delete(c *gin.Context) {
|
||
postID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
userID := middleware.GetUserID(c)
|
||
isAdmin, _ := c.Get("is_admin")
|
||
isAdminBool := isAdmin != nil && isAdmin.(bool)
|
||
|
||
// 检查权限
|
||
var postUserID int64
|
||
err := h.db.QueryRow("SELECT user_id FROM posts WHERE id = ?", postID).Scan(&postUserID)
|
||
if err == sql.ErrNoRows {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "post not found"})
|
||
return
|
||
}
|
||
|
||
// 帖子作者或管理员可以删除
|
||
if postUserID != userID && !isAdminBool {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"})
|
||
return
|
||
}
|
||
|
||
_, err = h.db.Exec("DELETE FROM posts WHERE id = ?", postID)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete post"})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||
}
|
||
|
||
func (h *PostHandler) Like(c *gin.Context) {
|
||
postID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
userID := middleware.GetUserID(c)
|
||
|
||
result, err := h.db.Exec("INSERT OR IGNORE INTO likes (post_id, user_id) VALUES (?, ?)", postID, userID)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to like"})
|
||
return
|
||
}
|
||
|
||
// 创建通知
|
||
rowsAffected, _ := result.RowsAffected()
|
||
if rowsAffected > 0 {
|
||
var postOwnerID int64
|
||
h.db.QueryRow("SELECT user_id FROM posts WHERE id = ?", postID).Scan(&postOwnerID)
|
||
CreateNotification(h.db, postOwnerID, userID, "like", &postID, nil, "")
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"message": "liked"})
|
||
}
|
||
|
||
func (h *PostHandler) Unlike(c *gin.Context) {
|
||
postID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
userID := middleware.GetUserID(c)
|
||
|
||
_, err := h.db.Exec("DELETE FROM likes WHERE post_id = ? AND user_id = ?", postID, userID)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unlike"})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"message": "unliked"})
|
||
}
|
||
|
||
func (h *PostHandler) AddReaction(c *gin.Context) {
|
||
postID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
userID := middleware.GetUserID(c)
|
||
|
||
var req model.AddReactionRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
result, err := h.db.Exec(
|
||
"INSERT OR IGNORE INTO reactions (post_id, user_id, emoji) VALUES (?, ?, ?)",
|
||
postID, userID, req.Emoji,
|
||
)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add reaction"})
|
||
return
|
||
}
|
||
|
||
// 创建通知
|
||
rowsAffected, _ := result.RowsAffected()
|
||
if rowsAffected > 0 {
|
||
var postOwnerID int64
|
||
h.db.QueryRow("SELECT user_id FROM posts WHERE id = ?", postID).Scan(&postOwnerID)
|
||
CreateNotification(h.db, postOwnerID, userID, "reaction", &postID, nil, req.Emoji)
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"message": "reaction added"})
|
||
}
|
||
|
||
func (h *PostHandler) RemoveReaction(c *gin.Context) {
|
||
postID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
userID := middleware.GetUserID(c)
|
||
emoji := c.Query("emoji")
|
||
|
||
_, err := h.db.Exec(
|
||
"DELETE FROM reactions WHERE post_id = ? AND user_id = ? AND emoji = ?",
|
||
postID, userID, emoji,
|
||
)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove reaction"})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"message": "reaction removed"})
|
||
}
|
||
|
||
func (h *PostHandler) getPostMedia(postID int64) []model.Media {
|
||
rows, err := h.db.Query(
|
||
"SELECT id, post_id, media_url, media_type, sort_order FROM post_media WHERE post_id = ? ORDER BY sort_order",
|
||
postID,
|
||
)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
defer rows.Close()
|
||
|
||
var media []model.Media
|
||
for rows.Next() {
|
||
var m model.Media
|
||
rows.Scan(&m.ID, &m.PostID, &m.MediaURL, &m.MediaType, &m.SortOrder)
|
||
media = append(media, m)
|
||
}
|
||
return media
|
||
}
|
||
|
||
func (h *PostHandler) getPostReactions(postID, userID int64) []model.ReactionGroup {
|
||
rows, err := h.db.Query(`
|
||
SELECT emoji, COUNT(*) as count,
|
||
SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) as reacted
|
||
FROM reactions WHERE post_id = ?
|
||
GROUP BY emoji
|
||
ORDER BY count DESC
|
||
`, userID, postID)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
defer rows.Close()
|
||
|
||
var reactions []model.ReactionGroup
|
||
for rows.Next() {
|
||
var r model.ReactionGroup
|
||
var reacted int
|
||
rows.Scan(&r.Emoji, &r.Count, &reacted)
|
||
r.Reacted = reacted > 0
|
||
reactions = append(reactions, r)
|
||
}
|
||
return reactions
|
||
}
|
||
|
||
func (h *PostHandler) getPostMusic(postID int64) *model.MusicInfo {
|
||
var music model.MusicInfo
|
||
err := h.db.QueryRow(`
|
||
SELECT id, post_id, title, artist, cover, share_url, platform
|
||
FROM post_music WHERE post_id = ?
|
||
`, postID).Scan(&music.ID, &music.PostID, &music.Title, &music.Artist, &music.Cover, &music.ShareURL, &music.Platform)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return &music
|
||
}
|
||
|
||
// isVideoURL 判断URL是否为视频文件
|
||
func isVideoURL(url string) bool {
|
||
return strings.HasSuffix(strings.ToLower(url), ".mp4") ||
|
||
strings.HasSuffix(strings.ToLower(url), ".mov")
|
||
}
|