更新
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -11,29 +12,125 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type latestPostSummary struct {
|
||||
ID int64 `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Media []string `json:"media,omitempty"`
|
||||
MediaThumbnails []string `json:"mediaThumbnails,omitempty"`
|
||||
MediaCount int `json:"mediaCount,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type profileView struct {
|
||||
ID int64 `json:"id"`
|
||||
PublicID string `json:"publicId"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Cover string `json:"cover"`
|
||||
Gender int `json:"gender"`
|
||||
Age int `json:"age"`
|
||||
Height int `json:"height"`
|
||||
City string `json:"city"`
|
||||
Bio string `json:"bio"`
|
||||
VIP bool `json:"vip"`
|
||||
VIPLevel int `json:"vipLevel"`
|
||||
Online bool `json:"online"`
|
||||
Distance float64 `json:"distance"`
|
||||
DistanceText string `json:"distanceText"`
|
||||
FollowingCount int `json:"followingCount"`
|
||||
FollowerCount int `json:"followerCount"`
|
||||
PostCount int `json:"postCount"`
|
||||
LikeCount int `json:"likeCount"`
|
||||
Following bool `json:"following"`
|
||||
Liked bool `json:"liked"`
|
||||
Tags []string `json:"tags"`
|
||||
ID int64 `json:"id"`
|
||||
PublicID string `json:"publicId"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
AvatarThumbnail string `json:"avatarThumbnail"`
|
||||
Cover string `json:"cover"`
|
||||
Gender int `json:"gender"`
|
||||
Age int `json:"age"`
|
||||
Height int `json:"height"`
|
||||
City string `json:"city"`
|
||||
Birthday string `json:"birthday"`
|
||||
Occupation string `json:"occupation"`
|
||||
Education int `json:"education"`
|
||||
RelationshipStatus int `json:"relationshipStatus"`
|
||||
Bio string `json:"bio"`
|
||||
VIP bool `json:"vip"`
|
||||
VIPLevel int `json:"vipLevel"`
|
||||
Online bool `json:"online"`
|
||||
LastActiveAt *time.Time `json:"lastActiveAt,omitempty"`
|
||||
Distance float64 `json:"distance"`
|
||||
DistanceText string `json:"distanceText"`
|
||||
FollowingCount int `json:"followingCount"`
|
||||
FollowerCount int `json:"followerCount"`
|
||||
PostCount int `json:"postCount"`
|
||||
LikeCount int `json:"likeCount"`
|
||||
Following bool `json:"following"`
|
||||
Liked bool `json:"liked"`
|
||||
Tags []string `json:"tags"`
|
||||
LatestPost *latestPostSummary `json:"latestPost,omitempty"`
|
||||
}
|
||||
|
||||
// attachLatestPosts enriches one discovery page with two batch queries.
|
||||
// It intentionally avoids a request/query per user so nearby scrolling remains
|
||||
// fast even when the page size is increased later.
|
||||
func (a *App) attachLatestPosts(ctx context.Context, viewerID int64, items []profileView) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
placeholders := make([]string, len(items))
|
||||
args := make([]any, 0, len(items)+2)
|
||||
indexes := make(map[int64]int, len(items))
|
||||
for index := range items {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, items[index].ID)
|
||||
indexes[items[index].ID] = index
|
||||
}
|
||||
args = append(args, viewerID, viewerID)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT post.id,post.user_id,post.content,post.created_at
|
||||
FROM posts post
|
||||
WHERE post.user_id IN (`+strings.Join(placeholders, ",")+`) AND post.id=(
|
||||
SELECT candidate.id FROM posts candidate
|
||||
WHERE candidate.user_id=post.user_id AND candidate.status=1 AND candidate.moderation_status=1 AND candidate.deleted_at IS NULL
|
||||
AND (candidate.visibility=1 OR candidate.user_id=? OR (candidate.visibility=2 AND EXISTS(
|
||||
SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=candidate.user_id
|
||||
)))
|
||||
ORDER BY candidate.created_at DESC,candidate.id DESC LIMIT 1
|
||||
)`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
summaries := make(map[int64]*latestPostSummary, len(items))
|
||||
for rows.Next() {
|
||||
summary := &latestPostSummary{Media: []string{}, MediaThumbnails: []string{}}
|
||||
var userID int64
|
||||
if err = rows.Scan(&summary.ID, &userID, &summary.Content, &summary.CreatedAt); err != nil {
|
||||
_ = rows.Close()
|
||||
return err
|
||||
}
|
||||
if index, ok := indexes[userID]; ok {
|
||||
items[index].LatestPost = summary
|
||||
summaries[summary.ID] = summary
|
||||
}
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return err
|
||||
}
|
||||
if err = rows.Close(); err != nil || len(summaries) == 0 {
|
||||
return err
|
||||
}
|
||||
mediaPlaceholders := make([]string, 0, len(summaries))
|
||||
mediaArgs := make([]any, 0, len(summaries))
|
||||
for postID := range summaries {
|
||||
mediaPlaceholders = append(mediaPlaceholders, "?")
|
||||
mediaArgs = append(mediaArgs, postID)
|
||||
}
|
||||
mediaRows, err := a.db.QueryContext(ctx, `SELECT post_id,media_url FROM post_media WHERE post_id IN (`+strings.Join(mediaPlaceholders, ",")+`) ORDER BY post_id,sort_order,id`, mediaArgs...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer mediaRows.Close()
|
||||
for mediaRows.Next() {
|
||||
var postID int64
|
||||
var media string
|
||||
if err = mediaRows.Scan(&postID, &media); err != nil {
|
||||
return err
|
||||
}
|
||||
summary := summaries[postID]
|
||||
if summary == nil {
|
||||
continue
|
||||
}
|
||||
summary.MediaCount++
|
||||
if len(summary.Media) >= 9 {
|
||||
continue
|
||||
}
|
||||
summary.Media = append(summary.Media, media)
|
||||
summary.MediaThumbnails = append(summary.MediaThumbnails, mediaThumbnailURL(media))
|
||||
}
|
||||
return mediaRows.Err()
|
||||
}
|
||||
|
||||
func (a *App) loadProfile(r *http.Request, id, viewerID int64) (profileView, error) {
|
||||
@@ -41,20 +138,38 @@ func (a *App) loadProfile(r *http.Request, id, viewerID int64) (profileView, err
|
||||
var birthday sql.NullTime
|
||||
var active sql.NullTime
|
||||
var vip int
|
||||
err := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.public_id,p.nickname,p.avatar_url,p.cover_url,p.gender,p.birthday,COALESCE(p.height_cm,0),p.city_name,p.bio,p.is_vip,p.vip_level,p.last_active_at,
|
||||
var onlineVisible, lastActiveVisible int
|
||||
err := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.public_id,p.nickname,p.avatar_url,p.cover_url,p.gender,p.birthday,COALESCE(p.height_cm,0),p.city_name,p.occupation,p.education,p.relationship_status,p.bio,p.is_vip,p.vip_level,p.last_active_at,privacy.online_visible,privacy.last_active_visible,
|
||||
(SELECT COUNT(*) FROM user_follows WHERE user_id=u.id),(SELECT COUNT(*) FROM user_follows WHERE target_user_id=u.id),(SELECT COUNT(*) FROM posts WHERE user_id=u.id AND status=1),(SELECT COUNT(*) FROM post_likes pl JOIN posts po ON po.id=pl.post_id WHERE po.user_id=u.id),
|
||||
EXISTS(SELECT 1 FROM user_follows WHERE user_id=? AND target_user_id=u.id),EXISTS(SELECT 1 FROM user_likes WHERE user_id=? AND target_user_id=u.id)
|
||||
FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.id=? AND u.status=1`, viewerID, viewerID, id).Scan(
|
||||
&item.ID, &item.PublicID, &item.Nickname, &item.Avatar, &item.Cover, &item.Gender, &birthday, &item.Height, &item.City, &item.Bio, &vip, &item.VIPLevel, &active, &item.FollowingCount, &item.FollowerCount, &item.PostCount, &item.LikeCount, &item.Following, &item.Liked)
|
||||
FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id
|
||||
WHERE u.id=? AND u.status=1 AND (u.id=? OR NOT EXISTS(SELECT 1 FROM user_blocks blocked WHERE (blocked.user_id=? AND blocked.blocked_user_id=u.id) OR (blocked.user_id=u.id AND blocked.blocked_user_id=?)))`, viewerID, viewerID, id, viewerID, viewerID, viewerID).Scan(
|
||||
&item.ID, &item.PublicID, &item.Nickname, &item.Avatar, &item.Cover, &item.Gender, &birthday, &item.Height, &item.City, &item.Occupation, &item.Education, &item.RelationshipStatus, &item.Bio, &vip, &item.VIPLevel, &active, &onlineVisible, &lastActiveVisible, &item.FollowingCount, &item.FollowerCount, &item.PostCount, &item.LikeCount, &item.Following, &item.Liked)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
item.VIP = vip == 1
|
||||
item.AvatarThumbnail = avatarThumbnailURL(item.Avatar)
|
||||
if birthday.Valid {
|
||||
item.Age = age(birthday.Time)
|
||||
item.Birthday = birthday.Time.Format("2006-01-02")
|
||||
}
|
||||
item.Online = id == viewerID || onlineVisible == 1 && a.onlineNow(id)
|
||||
if active.Valid && (id == viewerID || lastActiveVisible == 1) {
|
||||
activeTime := active.Time
|
||||
item.LastActiveAt = &activeTime
|
||||
}
|
||||
item.Tags = []string{}
|
||||
tagRows, tagErr := a.db.QueryContext(r.Context(), `SELECT t.name FROM user_tags ut JOIN tags t ON t.id=ut.tag_id WHERE ut.user_id=? AND t.status=1 ORDER BY t.sort_order,t.id LIMIT 12`, id)
|
||||
if tagErr == nil {
|
||||
defer tagRows.Close()
|
||||
for tagRows.Next() {
|
||||
var name string
|
||||
if tagRows.Scan(&name) == nil {
|
||||
item.Tags = append(item.Tags, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
item.Online = active.Valid && time.Since(active.Time) < 15*time.Minute
|
||||
item.Tags = []string{"摄影爱好者", "旅行达人", "天秤座"}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
@@ -66,7 +181,13 @@ func (a *App) userProfile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
who := current(r)
|
||||
if id != who.ID {
|
||||
_, _ = a.db.ExecContext(r.Context(), `INSERT INTO profile_visits (viewer_user_id,target_user_id,source) VALUES (?,?,'profile')`, who.ID, id)
|
||||
var targetAllows, viewerInvisible int
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT allow_profile_visit_record FROM user_privacy_settings WHERE user_id=?`, id).Scan(&targetAllows)
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT invisible_visit FROM user_privacy_settings WHERE user_id=?`, who.ID).Scan(&viewerInvisible)
|
||||
canHide := viewerInvisible == 1 && a.resolveMembershipEntitlements(r.Context(), who.ID).CanInvisibleVisit
|
||||
if targetAllows == 1 && !canHide {
|
||||
_, _ = a.db.ExecContext(r.Context(), `INSERT INTO profile_visits (viewer_user_id,target_user_id,source) VALUES (?,?,'profile')`, who.ID, id)
|
||||
}
|
||||
}
|
||||
item, err := a.loadProfile(r, id, who.ID)
|
||||
if err != nil {
|
||||
@@ -79,29 +200,119 @@ func (a *App) userProfile(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) discover(w http.ResponseWriter, r *http.Request) { a.discoverList(w, r, false) }
|
||||
func (a *App) nearby(w http.ResponseWriter, r *http.Request) { a.discoverList(w, r, true) }
|
||||
|
||||
type discoveryFilters struct {
|
||||
Gender int
|
||||
Online string
|
||||
MinAge int
|
||||
MaxAge int
|
||||
}
|
||||
|
||||
func discoveryFiltersFromRequest(r *http.Request) discoveryFilters {
|
||||
query := r.URL.Query()
|
||||
gender, _ := strconv.Atoi(query.Get("gender"))
|
||||
if gender != 1 && gender != 2 {
|
||||
gender = 0
|
||||
}
|
||||
minAge, _ := strconv.Atoi(query.Get("minAge"))
|
||||
maxAge, _ := strconv.Atoi(query.Get("maxAge"))
|
||||
if minAge < 18 || minAge > 100 {
|
||||
minAge = 0
|
||||
}
|
||||
if maxAge < 18 || maxAge > 100 {
|
||||
maxAge = 0
|
||||
}
|
||||
if minAge > 0 && maxAge > 0 && minAge > maxAge {
|
||||
minAge, maxAge = maxAge, minAge
|
||||
}
|
||||
online := strings.TrimSpace(query.Get("online"))
|
||||
if online != "0" && online != "1" {
|
||||
online = ""
|
||||
}
|
||||
return discoveryFilters{Gender: gender, Online: online, MinAge: minAge, MaxAge: maxAge}
|
||||
}
|
||||
|
||||
func (a *App) discoverList(w http.ResponseWriter, r *http.Request, byDistance bool) {
|
||||
who := current(r)
|
||||
gender, _ := strconv.Atoi(r.URL.Query().Get("gender"))
|
||||
filters := discoveryFiltersFromRequest(r)
|
||||
scope := r.URL.Query().Get("scope")
|
||||
sortMode := r.URL.Query().Get("sort")
|
||||
query := `SELECT u.id,u.public_id,p.nickname,p.avatar_url,p.cover_url,p.gender,p.birthday,COALESCE(p.height_cm,0),p.city_name,p.bio,p.is_vip,p.vip_level,p.last_active_at,l.latitude,l.longitude,
|
||||
EXISTS(SELECT 1 FROM user_follows f WHERE f.user_id=? AND f.target_user_id=u.id),EXISTS(SELECT 1 FROM user_likes x WHERE x.user_id=? AND x.target_user_id=u.id)
|
||||
FROM users u JOIN user_profiles p ON p.user_id=u.id LEFT JOIN user_location_states l ON l.user_id=u.id
|
||||
shuffleSeed, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("seed")), 10, 64)
|
||||
if shuffleSeed < 1 || shuffleSeed > 2147483647 {
|
||||
shuffleSeed = 0
|
||||
}
|
||||
page, pageSize, offset := pageOptions(r)
|
||||
var myLat, myLng sql.NullFloat64
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT latitude,longitude FROM user_location_states WHERE user_id=?`, who.ID).Scan(&myLat, &myLng)
|
||||
fromWhere := ` FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id LEFT JOIN user_location_states l ON l.user_id=u.id
|
||||
WHERE u.status=1 AND u.id<>? AND NOT EXISTS(SELECT 1 FROM user_blocks b WHERE (b.user_id=? AND b.blocked_user_id=u.id) OR (b.user_id=u.id AND b.blocked_user_id=?))`
|
||||
args := []any{who.ID, who.ID, who.ID, who.ID, who.ID}
|
||||
filterArgs := []any{who.ID, who.ID, who.ID}
|
||||
if scope == "following" {
|
||||
query += ` AND EXISTS(SELECT 1 FROM user_follows mine WHERE mine.user_id=? AND mine.target_user_id=u.id)`
|
||||
args = append(args, who.ID)
|
||||
fromWhere += ` AND EXISTS(SELECT 1 FROM user_follows mine WHERE mine.user_id=? AND mine.target_user_id=u.id)`
|
||||
filterArgs = append(filterArgs, who.ID)
|
||||
}
|
||||
if gender > 0 {
|
||||
query += ` AND p.gender=?`
|
||||
args = append(args, gender)
|
||||
if filters.Gender > 0 {
|
||||
fromWhere += ` AND p.gender=?`
|
||||
filterArgs = append(filterArgs, filters.Gender)
|
||||
}
|
||||
if sortMode == "latest" {
|
||||
query += ` ORDER BY p.last_active_at DESC,u.id DESC LIMIT 50`
|
||||
if filters.MinAge > 0 {
|
||||
fromWhere += ` AND TIMESTAMPDIFF(YEAR,p.birthday,CURDATE())>=?`
|
||||
filterArgs = append(filterArgs, filters.MinAge)
|
||||
}
|
||||
if filters.MaxAge > 0 {
|
||||
fromWhere += ` AND TIMESTAMPDIFF(YEAR,p.birthday,CURDATE())<=?`
|
||||
filterArgs = append(filterArgs, filters.MaxAge)
|
||||
}
|
||||
if filters.Online != "" {
|
||||
onlineUsers := a.hub.onlineUsers()
|
||||
if filters.Online == "1" {
|
||||
fromWhere += ` AND privacy.online_visible=1`
|
||||
if len(onlineUsers) == 0 {
|
||||
fromWhere += ` AND 1=0`
|
||||
} else {
|
||||
placeholders := make([]string, len(onlineUsers))
|
||||
for index, userID := range onlineUsers {
|
||||
placeholders[index] = "?"
|
||||
filterArgs = append(filterArgs, userID)
|
||||
}
|
||||
fromWhere += ` AND u.id IN (` + strings.Join(placeholders, ",") + `)`
|
||||
}
|
||||
} else if len(onlineUsers) > 0 {
|
||||
placeholders := make([]string, len(onlineUsers))
|
||||
for index, userID := range onlineUsers {
|
||||
placeholders[index] = "?"
|
||||
filterArgs = append(filterArgs, userID)
|
||||
}
|
||||
fromWhere += ` AND (privacy.online_visible<>1 OR u.id NOT IN (` + strings.Join(placeholders, ",") + `))`
|
||||
}
|
||||
}
|
||||
if byDistance {
|
||||
fromWhere += ` AND privacy.nearby_visible=1`
|
||||
}
|
||||
var total int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*)`+fromWhere, filterArgs...).Scan(&total); err != nil {
|
||||
fail(w, 500, 50001, "查询推荐用户失败")
|
||||
return
|
||||
}
|
||||
query := `SELECT u.id,u.public_id,p.nickname,p.avatar_url,p.cover_url,p.gender,p.birthday,COALESCE(p.height_cm,0),p.city_name,p.bio,p.is_vip,p.vip_level,p.last_active_at,l.latitude,l.longitude,privacy.distance_visible,privacy.online_visible,privacy.last_active_visible,
|
||||
EXISTS(SELECT 1 FROM user_follows f WHERE f.user_id=? AND f.target_user_id=u.id),EXISTS(SELECT 1 FROM user_likes x WHERE x.user_id=? AND x.target_user_id=u.id)
|
||||
` + fromWhere
|
||||
args := append([]any{who.ID, who.ID}, filterArgs...)
|
||||
if shuffleSeed > 0 {
|
||||
// CRC32 produces a deterministic shuffle for this seed. The client keeps
|
||||
// the seed while paging, so adjacent pages cannot overlap or skip rows.
|
||||
query += ` ORDER BY CRC32(CONCAT(u.id,':',?)),u.id DESC LIMIT ? OFFSET ?`
|
||||
args = append(args, shuffleSeed)
|
||||
} else if byDistance && myLat.Valid && myLng.Valid {
|
||||
// Sort before LIMIT/OFFSET so page two can never contain users closer
|
||||
// than page one. Hidden or missing distances are placed last.
|
||||
query += ` ORDER BY CASE WHEN privacy.distance_visible=1 THEN COALESCE(ST_Distance_Sphere(POINT(l.longitude,l.latitude),POINT(?,?)),1000000000000000) ELSE 1000000000000000 END ASC,p.last_active_at DESC,u.id DESC LIMIT ? OFFSET ?`
|
||||
args = append(args, myLng.Float64, myLat.Float64)
|
||||
} else if sortMode == "latest" || byDistance {
|
||||
query += ` ORDER BY p.last_active_at DESC,u.id DESC LIMIT ? OFFSET ?`
|
||||
} else {
|
||||
query += ` ORDER BY p.is_vip DESC,p.last_active_at DESC LIMIT 50`
|
||||
query += ` ORDER BY COALESCE((SELECT MAX(plan.recommendation_weight) FROM subscriptions sub JOIN membership_plans plan ON plan.id=sub.plan_id WHERE sub.user_id=u.id AND sub.status=1 AND sub.started_at<=NOW(3) AND sub.expires_at>NOW(3)),0) DESC,p.last_active_at DESC LIMIT ? OFFSET ?`
|
||||
}
|
||||
args = append(args, pageSize, offset)
|
||||
rows, err := a.db.QueryContext(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
fail(w, 500, 50001, err.Error())
|
||||
@@ -109,33 +320,37 @@ func (a *App) discoverList(w http.ResponseWriter, r *http.Request, byDistance bo
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []profileView{}
|
||||
var myLat, myLng sql.NullFloat64
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT latitude,longitude FROM user_location_states WHERE user_id=?`, who.ID).Scan(&myLat, &myLng)
|
||||
for rows.Next() {
|
||||
var item profileView
|
||||
var birthday sql.NullTime
|
||||
var active sql.NullTime
|
||||
var lat, lng sql.NullFloat64
|
||||
var distanceVisible, onlineVisible, lastActiveVisible int
|
||||
var vip int
|
||||
if err := rows.Scan(&item.ID, &item.PublicID, &item.Nickname, &item.Avatar, &item.Cover, &item.Gender, &birthday, &item.Height, &item.City, &item.Bio, &vip, &item.VIPLevel, &active, &lat, &lng, &item.Following, &item.Liked); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.PublicID, &item.Nickname, &item.Avatar, &item.Cover, &item.Gender, &birthday, &item.Height, &item.City, &item.Bio, &vip, &item.VIPLevel, &active, &lat, &lng, &distanceVisible, &onlineVisible, &lastActiveVisible, &item.Following, &item.Liked); err != nil {
|
||||
continue
|
||||
}
|
||||
item.VIP = vip == 1
|
||||
item.AvatarThumbnail = avatarThumbnailURL(item.Avatar)
|
||||
if birthday.Valid {
|
||||
item.Age = age(birthday.Time)
|
||||
}
|
||||
item.Online = active.Valid && time.Since(active.Time) < 15*time.Minute
|
||||
if myLat.Valid && myLng.Valid && lat.Valid && lng.Valid {
|
||||
item.Online = onlineVisible == 1 && a.onlineNow(item.ID)
|
||||
if active.Valid && lastActiveVisible == 1 {
|
||||
activeTime := active.Time
|
||||
item.LastActiveAt = &activeTime
|
||||
}
|
||||
if distanceVisible == 1 && myLat.Valid && myLng.Valid && lat.Valid && lng.Valid {
|
||||
item.Distance = haversine(myLat.Float64, myLng.Float64, lat.Float64, lng.Float64)
|
||||
item.DistanceText = distanceText(item.Distance)
|
||||
}
|
||||
item.Tags = []string{"摄影", "旅行"}
|
||||
item.Tags = []string{}
|
||||
items = append(items, item)
|
||||
}
|
||||
if byDistance {
|
||||
sortProfilesByDistance(items)
|
||||
}
|
||||
reply(w, map[string]any{"items": items, "total": len(items)})
|
||||
// Recent content is optional enrichment: a transient failure must not turn
|
||||
// an otherwise valid nearby page into a blank screen.
|
||||
_ = a.attachLatestPosts(r.Context(), who.ID, items)
|
||||
reply(w, map[string]any{"items": items, "total": total, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total, "locationReady": myLat.Valid && myLng.Valid})
|
||||
}
|
||||
|
||||
func (a *App) updateLocation(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -144,7 +359,7 @@ func (a *App) updateLocation(w http.ResponseWriter, r *http.Request) {
|
||||
Longitude float64 `json:"longitude"`
|
||||
CityCode string `json:"cityCode"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil || req.Latitude == 0 || req.Longitude == 0 {
|
||||
if err := decode(r, &req); err != nil || req.Latitude < -90 || req.Latitude > 90 || req.Longitude < -180 || req.Longitude > 180 {
|
||||
fail(w, 400, 20001, "无效的位置")
|
||||
return
|
||||
}
|
||||
@@ -179,8 +394,34 @@ func (a *App) relationship(w http.ResponseWriter, r *http.Request, table string,
|
||||
return
|
||||
}
|
||||
if create {
|
||||
var targetExists int
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM users u WHERE u.id=? AND u.status=1 AND u.deleted_at IS NULL AND NOT EXISTS(SELECT 1 FROM user_blocks b WHERE (b.user_id=? AND b.blocked_user_id=u.id) OR (b.user_id=u.id AND b.blocked_user_id=?)))`, target, current(r).ID, current(r).ID).Scan(&targetExists)
|
||||
if targetExists != 1 {
|
||||
fail(w, http.StatusNotFound, 30001, "用户不存在或不可操作")
|
||||
return
|
||||
}
|
||||
if table == "user_likes" {
|
||||
_, err = a.db.ExecContext(r.Context(), `INSERT IGNORE INTO user_likes (user_id,target_user_id,source) VALUES (?,?,'profile')`, current(r).ID, target)
|
||||
tx, beginErr := a.db.BeginTx(r.Context(), nil)
|
||||
if beginErr != nil {
|
||||
fail(w, 500, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
var exists int
|
||||
_ = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM user_likes WHERE user_id=? AND target_user_id=?)`, current(r).ID, target).Scan(&exists)
|
||||
if exists == 0 {
|
||||
err = a.reserveDailyLike(tx, r, "user", target)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = tx.ExecContext(r.Context(), `INSERT IGNORE INTO user_likes (user_id,target_user_id,source) VALUES (?,?,'profile')`, current(r).ID, target)
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
}
|
||||
if limitErr, ok := err.(*dailyLikeLimitError); ok {
|
||||
fail(w, http.StatusTooManyRequests, 20002, limitErr.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
_, err = a.db.ExecContext(r.Context(), `INSERT IGNORE INTO user_follows (user_id,target_user_id) VALUES (?,?)`, current(r).ID, target)
|
||||
}
|
||||
@@ -191,30 +432,54 @@ func (a *App) relationship(w http.ResponseWriter, r *http.Request, table string,
|
||||
fail(w, 500, 50001, "操作失败")
|
||||
return
|
||||
}
|
||||
if create {
|
||||
typ, title, content := "follow", "新的关注", current(r).Name+" 关注了你"
|
||||
if table == "user_likes" {
|
||||
typ, title, content = "like", "新的喜欢", current(r).Name+" 喜欢了你"
|
||||
}
|
||||
a.notifyUser(r.Context(), target, typ, title, content, "user", current(r).ID)
|
||||
}
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
type postView struct {
|
||||
ID int64 `json:"id"`
|
||||
User profileView `json:"user"`
|
||||
Content string `json:"content"`
|
||||
Location string `json:"location"`
|
||||
LikeCount int `json:"likeCount"`
|
||||
CommentCount int `json:"commentCount"`
|
||||
Liked bool `json:"liked"`
|
||||
Media []string `json:"media"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID int64 `json:"id"`
|
||||
User profileView `json:"user"`
|
||||
Content string `json:"content"`
|
||||
Location string `json:"location"`
|
||||
LikeCount int `json:"likeCount"`
|
||||
CommentCount int `json:"commentCount"`
|
||||
Liked bool `json:"liked"`
|
||||
Media []string `json:"media"`
|
||||
MediaThumbnails []string `json:"mediaThumbnails,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CanEdit bool `json:"canEdit"`
|
||||
CanDelete bool `json:"canDelete"`
|
||||
}
|
||||
|
||||
func (a *App) feed(w http.ResponseWriter, r *http.Request) {
|
||||
who := current(r)
|
||||
page, pageSize, offset := pageOptions(r)
|
||||
scopeFollowing := r.URL.Query().Get("scope") == "following"
|
||||
countQuery := `SELECT COUNT(*) FROM posts p JOIN users u ON u.id=p.user_id JOIN user_profiles pr ON pr.user_id=p.user_id WHERE p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id)))`
|
||||
countArgs := []any{who.ID, who.ID}
|
||||
if scopeFollowing {
|
||||
countQuery += ` AND EXISTS(SELECT 1 FROM user_follows f WHERE f.user_id=? AND f.target_user_id=p.user_id)`
|
||||
countArgs = append(countArgs, who.ID)
|
||||
}
|
||||
var total int
|
||||
if err := a.db.QueryRowContext(r.Context(), countQuery, countArgs...).Scan(&total); err != nil {
|
||||
fail(w, 500, 50001, "查询动态失败")
|
||||
return
|
||||
}
|
||||
query := `SELECT p.id,p.user_id,p.content,p.location_text,p.like_count,p.comment_count,p.created_at,u.public_id,pr.nickname,pr.avatar_url,pr.gender,pr.is_vip,EXISTS(SELECT 1 FROM post_likes l WHERE l.post_id=p.id AND l.user_id=?) FROM posts p JOIN users u ON u.id=p.user_id JOIN user_profiles pr ON pr.user_id=p.user_id WHERE p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id)))`
|
||||
args := []any{who.ID, who.ID, who.ID}
|
||||
if r.URL.Query().Get("scope") == "following" {
|
||||
if scopeFollowing {
|
||||
query += ` AND EXISTS(SELECT 1 FROM user_follows f WHERE f.user_id=? AND f.target_user_id=p.user_id)`
|
||||
args = append(args, who.ID)
|
||||
}
|
||||
query += ` ORDER BY p.created_at DESC LIMIT 50`
|
||||
query += ` ORDER BY p.created_at DESC LIMIT ? OFFSET ?`
|
||||
args = append(args, pageSize, offset)
|
||||
rows, err := a.db.QueryContext(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
fail(w, 500, 50001, err.Error())
|
||||
@@ -231,19 +496,24 @@ func (a *App) feed(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
item.User.ID = uid
|
||||
item.User.VIP = vip == 1
|
||||
item.User.AvatarThumbnail = avatarThumbnailURL(item.User.Avatar)
|
||||
item.CanEdit = uid == who.ID
|
||||
item.CanDelete = uid == who.ID
|
||||
mediaRows, _ := a.db.QueryContext(r.Context(), `SELECT media_url FROM post_media WHERE post_id=? ORDER BY sort_order`, item.ID)
|
||||
item.Media = []string{}
|
||||
item.MediaThumbnails = []string{}
|
||||
if mediaRows != nil {
|
||||
for mediaRows.Next() {
|
||||
var url string
|
||||
_ = mediaRows.Scan(&url)
|
||||
item.Media = append(item.Media, url)
|
||||
item.MediaThumbnails = append(item.MediaThumbnails, mediaThumbnailURL(url))
|
||||
}
|
||||
_ = mediaRows.Close()
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
reply(w, map[string]any{"items": items, "total": len(items)})
|
||||
reply(w, map[string]any{"items": items, "total": total, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total})
|
||||
}
|
||||
|
||||
func (a *App) createPost(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -257,14 +527,33 @@ func (a *App) createPost(w http.ResponseWriter, r *http.Request) {
|
||||
Location string `json:"location"`
|
||||
Visibility int `json:"visibility"`
|
||||
}
|
||||
if decode(r, &req) != nil || strings.TrimSpace(req.Content) == "" {
|
||||
fail(w, 400, 20001, "请输入动态内容")
|
||||
if decode(r, &req) != nil {
|
||||
fail(w, 400, 20001, "动态格式错误")
|
||||
return
|
||||
}
|
||||
req.Content = strings.TrimSpace(req.Content)
|
||||
req.Location = strings.TrimSpace(req.Location)
|
||||
if (req.Content == "" && len(req.Media) == 0) || len([]rune(req.Content)) > 2000 || len(req.Media) > 9 || len([]rune(req.Location)) > 100 {
|
||||
fail(w, 400, 20001, "动态需包含文字或图片,最多 9 张图片")
|
||||
return
|
||||
}
|
||||
if req.Visibility != 2 {
|
||||
req.Visibility = 1
|
||||
}
|
||||
tx, _ := a.db.BeginTx(r.Context(), nil)
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
fail(w, 500, 50001, "发布失败")
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
for _, rawURL := range req.Media {
|
||||
mediaURL := strings.TrimSpace(rawURL)
|
||||
var exists int
|
||||
if mediaURL == "" || tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND status=1 AND moderation_status=1)`, current(r).ID, mediaURL).Scan(&exists) != nil || exists != 1 {
|
||||
fail(w, 400, 20001, "动态图片必须由当前账号上传且通过安全检查")
|
||||
return
|
||||
}
|
||||
}
|
||||
result, err := tx.ExecContext(r.Context(), `INSERT INTO posts (user_id,content,visibility,city_code,location_text) VALUES (?,?,?,'310100',?)`, current(r).ID, req.Content, req.Visibility, req.Location)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
@@ -273,9 +562,15 @@ func (a *App) createPost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
for i, url := range req.Media {
|
||||
_, _ = tx.ExecContext(r.Context(), `INSERT INTO post_media(post_id,media_url,sort_order)VALUES(?,?,?)`, id, url, i)
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO post_media(post_id,media_url,sort_order)VALUES(?,?,?)`, id, url, i); err != nil {
|
||||
fail(w, 500, 50001, "发布失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
if tx.Commit() != nil {
|
||||
fail(w, 500, 50001, "发布失败")
|
||||
return
|
||||
}
|
||||
_ = tx.Commit()
|
||||
reply(w, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
@@ -287,27 +582,82 @@ func (a *App) postLike(w http.ResponseWriter, r *http.Request, create bool) {
|
||||
fail(w, 400, 20001, "invalid id")
|
||||
return
|
||||
}
|
||||
tx, _ := a.db.BeginTx(r.Context(), nil)
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
fail(w, 500, 50001, "点赞失败")
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
var postExists int
|
||||
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM posts p WHERE p.id=? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id))))`, id, current(r).ID, current(r).ID).Scan(&postExists); err != nil || postExists != 1 {
|
||||
fail(w, http.StatusNotFound, 30001, "动态不存在")
|
||||
return
|
||||
}
|
||||
if create {
|
||||
result, _ := tx.ExecContext(r.Context(), `INSERT IGNORE INTO post_likes(post_id,user_id)VALUES(?,?)`, id, current(r).ID)
|
||||
var exists int
|
||||
_ = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM post_likes WHERE post_id=? AND user_id=?)`, id, current(r).ID).Scan(&exists)
|
||||
if exists == 0 {
|
||||
if reserveErr := a.reserveDailyLike(tx, r, "post", id); reserveErr != nil {
|
||||
_ = tx.Rollback()
|
||||
if limitErr, ok := reserveErr.(*dailyLikeLimitError); ok {
|
||||
fail(w, http.StatusTooManyRequests, 20002, limitErr.Error())
|
||||
return
|
||||
}
|
||||
fail(w, 500, 50001, "点赞失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
result, execErr := tx.ExecContext(r.Context(), `INSERT IGNORE INTO post_likes(post_id,user_id)VALUES(?,?)`, id, current(r).ID)
|
||||
if execErr != nil {
|
||||
fail(w, 500, 50001, "点赞失败")
|
||||
return
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected > 0 {
|
||||
_, _ = tx.ExecContext(r.Context(), `UPDATE posts SET like_count=like_count+1 WHERE id=?`, id)
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE posts SET like_count=like_count+1 WHERE id=?`, id); err != nil {
|
||||
fail(w, 500, 50001, "点赞失败")
|
||||
return
|
||||
}
|
||||
_, _ = tx.ExecContext(r.Context(), `INSERT INTO notifications(user_id,type,title,content,biz_type,biz_id) SELECT p.user_id,'like','动态获赞',?,'post',p.id FROM posts p LEFT JOIN user_notification_settings ns ON ns.user_id=p.user_id WHERE p.id=? AND p.user_id<>? AND COALESCE(ns.interaction_enabled,1)=1`, current(r).Name+" 赞了你的动态", id, current(r).ID)
|
||||
}
|
||||
} else {
|
||||
result, _ := tx.ExecContext(r.Context(), `DELETE FROM post_likes WHERE post_id=? AND user_id=?`, id, current(r).ID)
|
||||
result, execErr := tx.ExecContext(r.Context(), `DELETE FROM post_likes WHERE post_id=? AND user_id=?`, id, current(r).ID)
|
||||
if execErr != nil {
|
||||
fail(w, 500, 50001, "取消点赞失败")
|
||||
return
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected > 0 {
|
||||
_, _ = tx.ExecContext(r.Context(), `UPDATE posts SET like_count=GREATEST(like_count-1,0) WHERE id=?`, id)
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE posts SET like_count=GREATEST(like_count-1,0) WHERE id=?`, id); err != nil {
|
||||
fail(w, 500, 50001, "取消点赞失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = tx.Commit()
|
||||
if err = tx.Commit(); err != nil {
|
||||
fail(w, 500, 50001, "保存点赞状态失败")
|
||||
return
|
||||
}
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (a *App) comments(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := pathID(r)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT c.id,c.content,c.created_at,p.user_id,p.nickname,p.avatar_url FROM post_comments c JOIN user_profiles p ON p.user_id=c.user_id WHERE c.post_id=? AND c.status=1 ORDER BY c.created_at`, id)
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, 400, 20001, "动态编号无效")
|
||||
return
|
||||
}
|
||||
if _, err = a.loadPost(r, id); err != nil {
|
||||
fail(w, http.StatusNotFound, 30001, "动态不存在")
|
||||
return
|
||||
}
|
||||
page, pageSize, offset := pageOptions(r)
|
||||
var total int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM post_comments WHERE post_id=? AND status=1`, id).Scan(&total); err != nil {
|
||||
fail(w, 500, 50001, "查询失败")
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT c.id,c.content,c.created_at,p.user_id,p.nickname,p.avatar_url,c.parent_comment_id,c.reply_user_id,COALESCE(reply.nickname,'') FROM post_comments c JOIN user_profiles p ON p.user_id=c.user_id LEFT JOIN user_profiles reply ON reply.user_id=c.reply_user_id WHERE c.post_id=? AND c.status=1 ORDER BY c.created_at LIMIT ? OFFSET ?`, id, pageSize, offset)
|
||||
if err != nil {
|
||||
fail(w, 500, 50001, "查询失败")
|
||||
return
|
||||
@@ -316,48 +666,128 @@ func (a *App) comments(w http.ResponseWriter, r *http.Request) {
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var cid, uid int64
|
||||
var content, nick, avatar string
|
||||
var content, nick, avatar, replyNickname string
|
||||
var parentID, replyUserID sql.NullInt64
|
||||
var created time.Time
|
||||
_ = rows.Scan(&cid, &content, &created, &uid, &nick, &avatar)
|
||||
items = append(items, map[string]any{"id": cid, "content": content, "createdAt": created, "user": map[string]any{"id": uid, "nickname": nick, "avatar": avatar}})
|
||||
_ = rows.Scan(&cid, &content, &created, &uid, &nick, &avatar, &parentID, &replyUserID, &replyNickname)
|
||||
items = append(items, map[string]any{"id": cid, "content": content, "createdAt": created, "parentCommentId": nullableInt64(parentID), "replyUser": map[string]any{"id": nullableInt64(replyUserID), "nickname": replyNickname}, "canDelete": uid == current(r).ID, "user": map[string]any{"id": uid, "nickname": nick, "avatar": avatar, "avatarThumbnail": avatarThumbnailURL(avatar)}})
|
||||
}
|
||||
reply(w, map[string]any{"items": items})
|
||||
reply(w, map[string]any{"items": items, "total": total, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total})
|
||||
}
|
||||
|
||||
func (a *App) createComment(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := pathID(r)
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, 400, 20001, "动态编号无效")
|
||||
return
|
||||
}
|
||||
if decode(r, &req) != nil || req.Content == "" {
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
ParentCommentID *int64 `json:"parentCommentId"`
|
||||
ReplyUserID *int64 `json:"replyUserId"`
|
||||
}
|
||||
req.Content = strings.TrimSpace(req.Content)
|
||||
if decode(r, &req) != nil {
|
||||
fail(w, 400, 20001, "评论格式错误")
|
||||
return
|
||||
}
|
||||
req.Content = strings.TrimSpace(req.Content)
|
||||
if req.Content == "" || len([]rune(req.Content)) > 1000 {
|
||||
fail(w, 400, 20001, "评论不能为空")
|
||||
return
|
||||
}
|
||||
tx, _ := a.db.BeginTx(r.Context(), nil)
|
||||
result, err := tx.ExecContext(r.Context(), `INSERT INTO post_comments(post_id,user_id,content)VALUES(?,?,?)`, id, current(r).ID, req.Content)
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
fail(w, 500, 50001, "评论失败")
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
var postExists int
|
||||
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM posts p WHERE p.id=? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id))))`, id, current(r).ID, current(r).ID).Scan(&postExists); err != nil || postExists != 1 {
|
||||
fail(w, http.StatusNotFound, 30001, "动态不存在")
|
||||
return
|
||||
}
|
||||
if req.ParentCommentID != nil {
|
||||
var replyUserID int64
|
||||
if err := tx.QueryRowContext(r.Context(), `SELECT user_id FROM post_comments WHERE id=? AND post_id=? AND status=1`, *req.ParentCommentID, id).Scan(&replyUserID); err != nil {
|
||||
_ = tx.Rollback()
|
||||
fail(w, 400, 20001, "回复的评论不存在")
|
||||
return
|
||||
}
|
||||
if req.ReplyUserID == nil {
|
||||
req.ReplyUserID = &replyUserID
|
||||
}
|
||||
}
|
||||
result, err := tx.ExecContext(r.Context(), `INSERT INTO post_comments(post_id,user_id,parent_comment_id,reply_user_id,content)VALUES(?,?,?,?,?)`, id, current(r).ID, req.ParentCommentID, req.ReplyUserID, req.Content)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
fail(w, 500, 50001, "评论失败")
|
||||
return
|
||||
}
|
||||
_, _ = tx.ExecContext(r.Context(), `UPDATE posts SET comment_count=comment_count+1 WHERE id=?`, id)
|
||||
_ = tx.Commit()
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE posts SET comment_count=comment_count+1 WHERE id=?`, id); err != nil {
|
||||
fail(w, 500, 50001, "评论失败")
|
||||
return
|
||||
}
|
||||
_, _ = tx.ExecContext(r.Context(), `INSERT INTO notifications(user_id,type,title,content,biz_type,biz_id) SELECT p.user_id,'comment','新的评论',?,'post',p.id FROM posts p LEFT JOIN user_notification_settings ns ON ns.user_id=p.user_id WHERE p.id=? AND p.user_id<>? AND COALESCE(ns.interaction_enabled,1)=1`, current(r).Name+" 评论了你的动态", id, current(r).ID)
|
||||
if err = tx.Commit(); err != nil {
|
||||
fail(w, 500, 50001, "评论失败")
|
||||
return
|
||||
}
|
||||
cid, _ := result.LastInsertId()
|
||||
reply(w, map[string]any{"id": cid})
|
||||
}
|
||||
|
||||
func (a *App) createReport(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
TargetType string `json:"targetType"`
|
||||
TargetID int64 `json:"targetId"`
|
||||
Reason string `json:"reason"`
|
||||
Description string `json:"description"`
|
||||
TargetType string `json:"targetType"`
|
||||
TargetID int64 `json:"targetId"`
|
||||
Reason string `json:"reason"`
|
||||
Description string `json:"description"`
|
||||
Evidence []string `json:"evidence"`
|
||||
}
|
||||
if decode(r, &req) != nil || req.TargetID == 0 {
|
||||
fail(w, 400, 20001, "举报信息不完整")
|
||||
return
|
||||
}
|
||||
result, err := a.db.ExecContext(r.Context(), `INSERT INTO reports(reporter_user_id,target_type,target_id,reason_code,description)VALUES(?,?,?,?,?)`, current(r).ID, req.TargetType, req.TargetID, req.Reason, req.Description)
|
||||
req.TargetType = strings.ToLower(strings.TrimSpace(req.TargetType))
|
||||
req.Reason = strings.ToLower(strings.TrimSpace(req.Reason))
|
||||
req.Description = strings.TrimSpace(req.Description)
|
||||
allowedTargets := map[string]bool{"user": true, "post": true, "comment": true, "message": true}
|
||||
allowedReasons := map[string]bool{"fraud": true, "harassment": true, "pornography": true, "advertising": true, "violence": true, "minor_safety": true, "privacy": true, "other": true}
|
||||
if !allowedTargets[req.TargetType] || !allowedReasons[req.Reason] || len([]rune(req.Description)) > 1000 || len(req.Evidence) > 6 {
|
||||
fail(w, 400, 20001, "举报类型、原因或证据无效")
|
||||
return
|
||||
}
|
||||
var targetExists int
|
||||
switch req.TargetType {
|
||||
case "user":
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM users WHERE id=? AND id<>? AND status=1 AND deleted_at IS NULL)`, req.TargetID, current(r).ID).Scan(&targetExists)
|
||||
case "post":
|
||||
if item, loadErr := a.loadPost(r, req.TargetID); loadErr == nil && item.User.ID != current(r).ID {
|
||||
targetExists = 1
|
||||
}
|
||||
case "comment":
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM post_comments c JOIN posts p ON p.id=c.post_id WHERE c.id=? AND c.status=1 AND c.user_id<>? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id))))`, req.TargetID, current(r).ID, current(r).ID, current(r).ID).Scan(&targetExists)
|
||||
case "message":
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM im_messages message JOIN im_conversation_members member ON member.conversation_id=message.conversation_id AND member.user_id=? AND member.status=1 WHERE message.id=? AND message.sender_id<>?)`, current(r).ID, req.TargetID, current(r).ID).Scan(&targetExists)
|
||||
}
|
||||
if targetExists != 1 {
|
||||
fail(w, http.StatusNotFound, 30001, "举报目标不存在、不可访问或属于当前账号")
|
||||
return
|
||||
}
|
||||
evidence, evidenceErr := a.validateOwnedImageEvidence(r.Context(), current(r).ID, req.Evidence)
|
||||
if evidenceErr != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, evidenceErr.Error())
|
||||
return
|
||||
}
|
||||
var duplicate int
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM reports WHERE reporter_user_id=? AND target_type=? AND target_id=? AND status='PENDING' AND created_at>DATE_SUB(NOW(3),INTERVAL 24 HOUR))`, current(r).ID, req.TargetType, req.TargetID).Scan(&duplicate)
|
||||
if duplicate == 1 {
|
||||
fail(w, http.StatusConflict, 20001, "该内容已举报,请等待处理")
|
||||
return
|
||||
}
|
||||
evidenceJSON, _ := json.Marshal(evidence)
|
||||
result, err := a.db.ExecContext(r.Context(), `INSERT INTO reports(reporter_user_id,target_type,target_id,reason_code,description,evidence_json)VALUES(?,?,?,?,?,?)`, current(r).ID, req.TargetType, req.TargetID, req.Reason, req.Description, evidenceJSON)
|
||||
if err != nil {
|
||||
fail(w, 500, 50001, "提交失败")
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user