package app import ( "context" "database/sql" "encoding/json" "fmt" "math" "net/http" "strconv" "strings" "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"` 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) { var item profileView var birthday sql.NullTime var active sql.NullTime var vip int var onlineVisible, lastActiveVisible, distanceVisible int var lat, lng sql.NullFloat64 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,privacy.distance_visible,l.latitude,l.longitude, (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 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.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, &distanceVisible, &lat, &lng, &item.FollowingCount, &item.FollowerCount, &item.PostCount, &item.LikeCount, &item.Following, &item.Liked) if err != nil { return item, err } // The profile and chat headers label distance the same way the nearby list // does, so one extra lookup here keeps the two readings consistent. if id != viewerID && distanceVisible == 1 && lat.Valid && lng.Valid { var myLat, myLng sql.NullFloat64 _ = a.db.QueryRowContext(r.Context(), `SELECT latitude,longitude FROM user_location_states WHERE user_id=?`, viewerID).Scan(&myLat, &myLng) if myLat.Valid && myLng.Valid { item.Distance = haversine(myLat.Float64, myLng.Float64, lat.Float64, lng.Float64) item.DistanceText = distanceText(item.Distance) } } 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) } } } return item, nil } func (a *App) userProfile(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, 400, 20001, "invalid id") return } who := current(r) if id != who.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 { fail(w, 404, 30001, "用户不存在") return } reply(w, item) } // One kilometre: fine enough that a refresh never trades a neighbour for someone // across the city, coarse enough that there is a real pool to shuffle. const nearbyDistanceBandMetres = "1000" 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) filters := discoveryFiltersFromRequest(r) scope := r.URL.Query().Get("scope") sortMode := r.URL.Query().Get("sort") 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=?))` filterArgs := []any{who.ID, who.ID, who.ID} if scope == "following" { 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 filters.Gender > 0 { fromWhere += ` AND p.gender=?` filterArgs = append(filterArgs, filters.Gender) } 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...) distanceExpression := `CASE WHEN privacy.distance_visible=1 THEN COALESCE(ST_Distance_Sphere(POINT(l.longitude,l.latitude),POINT(?,?)),1000000000000000) ELSE 1000000000000000 END` if byDistance && shuffleSeed > 0 && myLat.Valid && myLng.Valid { // Pull to refresh on 附近 should bring different faces without sending the // reader across town: order by a coarse distance band first, then shuffle // inside it. Everyone within the same kilometre is equally "nearby", so // that is the granularity worth randomising over. Hidden distances land // in the last band, as they do without a seed. query += ` ORDER BY FLOOR(` + distanceExpression + `/` + nearbyDistanceBandMetres + `) ASC,CRC32(CONCAT(u.id,':',?)),u.id DESC LIMIT ? OFFSET ?` args = append(args, myLng.Float64, myLat.Float64, shuffleSeed) } else 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 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()) return } defer rows.Close() items := []profileView{} 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, &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 = 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{} items = append(items, item) } // 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) { var req struct { Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` CityCode string `json:"cityCode"` } if err := decode(r, &req); err != nil || req.Latitude < -90 || req.Latitude > 90 || req.Longitude < -180 || req.Longitude > 180 { fail(w, 400, 20001, "无效的位置") return } if req.CityCode == "" { req.CityCode = "310100" } _, err := a.db.ExecContext(r.Context(), `INSERT INTO user_location_states (user_id,city_code,location_cell,latitude,longitude,source) VALUES (?,?, 'wx4g',?,?,'gps') ON DUPLICATE KEY UPDATE city_code=VALUES(city_code),latitude=VALUES(latitude),longitude=VALUES(longitude),last_location_at=NOW(3),source='gps'`, current(r).ID, req.CityCode, req.Latitude, req.Longitude) if err != nil { fail(w, 500, 50001, "位置更新失败") return } reply(w, map[string]bool{"success": true}) } func (a *App) follow(w http.ResponseWriter, r *http.Request) { a.relationship(w, r, "user_follows", true) } func (a *App) unfollow(w http.ResponseWriter, r *http.Request) { a.relationship(w, r, "user_follows", false) } func (a *App) likeUser(w http.ResponseWriter, r *http.Request) { a.relationship(w, r, "user_likes", true) } func (a *App) unlikeUser(w http.ResponseWriter, r *http.Request) { a.relationship(w, r, "user_likes", false) } func (a *App) relationship(w http.ResponseWriter, r *http.Request, table string, create bool) { target, err := pathID(r) if err != nil || target == current(r).ID { fail(w, 400, 20001, "无效用户") 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" { 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) } } else { _, err = a.db.ExecContext(r.Context(), `DELETE FROM `+table+` WHERE user_id=? AND target_user_id=?`, current(r).ID, target) } if err != nil { 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"` 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 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 ? OFFSET ?` args = append(args, pageSize, offset) rows, err := a.db.QueryContext(r.Context(), query, args...) if err != nil { fail(w, 500, 50001, err.Error()) return } defer rows.Close() items := []postView{} for rows.Next() { var item postView var uid int64 var vip int if rows.Scan(&item.ID, &uid, &item.Content, &item.Location, &item.LikeCount, &item.CommentCount, &item.CreatedAt, &item.User.PublicID, &item.User.Nickname, &item.User.Avatar, &item.User.Gender, &vip, &item.Liked) != nil { continue } 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": total, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) } func (a *App) createPost(w http.ResponseWriter, r *http.Request) { if a.isSanctionActive(r.Context(), current(r).ID, "CONTENT_LIMIT") { fail(w, http.StatusForbidden, 10006, "账号处于内容发布限制期") return } var req struct { Content string `json:"content"` Media []string `json:"media"` Location string `json:"location"` Visibility int `json:"visibility"` } 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 } // 动态和消息共用一套词库:拦截词直接拒绝,转人工的词先发出去但排进审核队列。 moderation := 1 if word, action := a.matchKeyword(r.Context(), "post", req.Content); word != "" { if action == "block" { fail(w, 400, 20001, "动态包含平台不允许的内容") return } moderation = 0 a.recordKeywordRisk(r.Context(), current(r).ID, "post", word) } 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,moderation_status) VALUES (?,?,?,'310100',?,?)`, current(r).ID, req.Content, req.Visibility, req.Location, moderation) if err != nil { _ = tx.Rollback() fail(w, 500, 50001, "发布失败") return } id, _ := result.LastInsertId() for i, url := range req.Media { 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 } reply(w, map[string]any{"id": id}) } func (a *App) likePost(w http.ResponseWriter, r *http.Request) { a.postLike(w, r, true) } func (a *App) unlikePost(w http.ResponseWriter, r *http.Request) { a.postLike(w, r, false) } func (a *App) postLike(w http.ResponseWriter, r *http.Request, create bool) { id, err := pathID(r) if err != nil { fail(w, 400, 20001, "invalid id") return } 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 { 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 { 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, 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 { 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 } } } 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, 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 } defer rows.Close() items := []map[string]any{} for rows.Next() { var cid, uid int64 var content, nick, avatar, replyNickname string var parentID, replyUserID sql.NullInt64 var created time.Time _ = 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, "total": total, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) } func (a *App) createComment(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, 400, 20001, "动态编号无效") return } 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, 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 } 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"` Evidence []string `json:"evidence"` } if decode(r, &req) != nil || req.TargetID == 0 { fail(w, 400, 20001, "举报信息不完整") return } 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 } id, _ := result.LastInsertId() reply(w, map[string]any{"id": id, "status": "PENDING"}) } func age(birthday time.Time) int { now := time.Now() years := now.Year() - birthday.Year() if now.YearDay() < birthday.YearDay() { years-- } return years } func haversine(lat1, lng1, lat2, lng2 float64) float64 { const earth = 6371 dlat := (lat2 - lat1) * math.Pi / 180 dlng := (lng2 - lng1) * math.Pi / 180 a := math.Sin(dlat/2)*math.Sin(dlat/2) + math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Sin(dlng/2)*math.Sin(dlng/2) return earth * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) } func distanceText(value float64) string { if value < 1 { return fmt.Sprintf("%.2fkm", value) } return fmt.Sprintf("%.1fkm", value) } func sortProfilesByDistance(items []profileView) { for i := 0; i < len(items); i++ { for j := i + 1; j < len(items); j++ { if items[j].Distance < items[i].Distance { items[i], items[j] = items[j], items[i] } } } } func jsonBytes(value any) []byte { data, _ := json.Marshal(value); return data }