feat: 互相关注后自动成为好友

一方关注只记关注。双方互关时写入已有双向好友边;取消关注若不再互关只删好友边,不删消息。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-10-18 21:51:18 +08:00
parent 0471749141
commit ce9c7a13aa
13 changed files with 487 additions and 2 deletions

View File

@ -20,13 +20,14 @@ const (
SourceQRCode Source = 1
SourceEmail Source = 2
SourceSparkID Source = 3
SourceFollow Source = 4
)
// Source is friend add origin on the request and the directed edge.
type Source int
func (s Source) Valid() bool {
return s == SourceUnknown || s == SourceQRCode || s == SourceEmail || s == SourceSparkID
return s == SourceUnknown || s == SourceQRCode || s == SourceEmail || s == SourceSparkID || s == SourceFollow
}
const (

View File

@ -0,0 +1,150 @@
package logic
import (
"context"
"errors"
"time"
"spark-server/internal/module/friend/entity"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/i18n"
"spark-server/internal/pkg/reqctx"
)
// FollowView is the viewer's follow state toward one user.
type FollowView struct {
UID int64 `json:"uid"`
Following bool `json:"following"`
FollowedBy bool `json:"followed_by"`
Mutual bool `json:"mutual"`
Friend bool `json:"friend"`
}
func (s *Service) Follow(ctx context.Context, userID, targetID int64) (*FollowView, error) {
if err := s.followReady(ctx, userID, targetID); err != nil {
return nil, err
}
if _, err := s.activeUser(ctx, targetID); err != nil {
return nil, err
}
if err := s.followBlocks(ctx, userID, targetID); err != nil {
return nil, err
}
if err := s.repo.PutFollow(ctx, userID, targetID); err != nil {
return nil, err
}
if err := s.friendIfMutual(ctx, userID, targetID); err != nil {
return nil, err
}
return s.followView(ctx, userID, targetID)
}
func (s *Service) Unfollow(ctx context.Context, userID, targetID int64) (*FollowView, error) {
if err := s.followReady(ctx, userID, targetID); err != nil {
return nil, err
}
mutual, err := s.mutualFollow(ctx, userID, targetID)
if err != nil {
return nil, err
}
if err := s.repo.DeleteFollow(ctx, userID, targetID); err != nil {
return nil, err
}
if mutual {
if err := s.repo.DeleteFriend(ctx, userID, targetID); err != nil && !errors.Is(err, errs.ErrNotFound) {
return nil, err
}
}
return s.followView(ctx, userID, targetID)
}
func (s *Service) GetFollow(ctx context.Context, userID, targetID int64) (*FollowView, error) {
if err := s.followReady(ctx, userID, targetID); err != nil {
return nil, err
}
if _, err := s.activeUser(ctx, targetID); err != nil {
return nil, err
}
return s.followView(ctx, userID, targetID)
}
func (s *Service) followReady(ctx context.Context, userID, targetID int64) error {
if !s.ready() || userID <= 0 {
return errs.ErrUnauthorized
}
if targetID <= 0 {
return errs.ErrInvalidArgument
}
if entity.SameUser(userID, targetID) {
return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendSelf, nil))
}
return nil
}
func (s *Service) followBlocks(ctx context.Context, from, to int64) error {
lang := reqctx.Lang(ctx)
blockedMe, err := s.repo.IsBlocked(ctx, from, to)
if err != nil {
return err
}
if blockedMe {
return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyFriendBlocked, nil))
}
blockedBy, err := s.repo.IsBlocked(ctx, to, from)
if err != nil {
return err
}
if blockedBy {
return errs.ErrNotFound
}
return nil
}
func (s *Service) friendIfMutual(ctx context.Context, userID, targetID int64) error {
mutual, err := s.mutualFollow(ctx, userID, targetID)
if err != nil || !mutual {
return err
}
ok, err := s.AreFriends(ctx, userID, targetID)
if err != nil || ok {
return err
}
now := time.Now()
if err := s.repo.AcceptFriend(ctx, entity.AcceptParams{Edges: []*entity.Relation{
entity.NewFriendEdge(userID, targetID, "", entity.SourceFollow, now),
entity.NewFriendEdge(targetID, userID, "", entity.SourceFollow, now),
}}); err != nil {
return err
}
if s.greeter != nil {
_ = s.greeter.BecameFriends(ctx, userID, targetID)
}
return nil
}
func (s *Service) mutualFollow(ctx context.Context, userID, targetID int64) (bool, error) {
following, err := s.repo.HasFollow(ctx, userID, targetID)
if err != nil || !following {
return false, err
}
return s.repo.HasFollow(ctx, targetID, userID)
}
func (s *Service) followView(ctx context.Context, userID, targetID int64) (*FollowView, error) {
following, err := s.repo.HasFollow(ctx, userID, targetID)
if err != nil {
return nil, err
}
followedBy, err := s.repo.HasFollow(ctx, targetID, userID)
if err != nil {
return nil, err
}
friend, err := s.AreFriends(ctx, userID, targetID)
if err != nil {
return nil, err
}
return &FollowView{
UID: targetID, Following: following, FollowedBy: followedBy,
Mutual: following && followedBy, Friend: friend,
}, nil
}

View File

@ -0,0 +1,145 @@
package logic
import (
"errors"
"testing"
"spark-server/internal/infrastructure/id"
convlogic "spark-server/internal/module/conversation/logic"
convmodel "spark-server/internal/module/conversation/model"
"spark-server/internal/module/friend/entity"
friendmodel "spark-server/internal/module/friend/model"
msgmodel "spark-server/internal/module/message/model"
userentity "spark-server/internal/module/user/entity"
"spark-server/internal/module/user/logictest"
"spark-server/internal/pkg/errs"
)
func TestOneWayFollowDoesNotFriend(t *testing.T) {
svc, users, g := testFriends(t)
ctx := t.Context()
a, b := twoUsers(t, users)
view, err := svc.Follow(ctx, a.ID, b.ID)
if err != nil || !view.Following || view.FollowedBy || view.Mutual || view.Friend {
t.Fatalf("%+v %v", view, err)
}
if g.n != 0 {
t.Fatalf("greeting %d", g.n)
}
ok, err := svc.AreFriends(ctx, a.ID, b.ID)
if err != nil || ok {
t.Fatalf("friends %v %v", ok, err)
}
}
func TestMutualFollowCreatesFriendsOnce(t *testing.T) {
svc, users, g := testFriends(t)
ctx := t.Context()
a, b := twoUsers(t, users)
if _, err := svc.Follow(ctx, a.ID, b.ID); err != nil {
t.Fatal(err)
}
view, err := svc.Follow(ctx, b.ID, a.ID)
if err != nil || !view.Mutual || !view.Friend || !view.Following || !view.FollowedBy {
t.Fatalf("%+v %v", view, err)
}
if g.n != 1 {
t.Fatalf("greeting %d", g.n)
}
again, err := svc.Follow(ctx, b.ID, a.ID)
if err != nil || !again.Friend || g.n != 1 {
t.Fatalf("repeat %+v greet %d %v", again, g.n, err)
}
rel, err := svc.Relation(ctx, a.ID, b.ID)
if err != nil || rel.Source != entity.SourceFollow {
t.Fatalf("source %+v %v", rel, err)
}
}
func TestUnfollowMutualDropsFriendsKeepsMessage(t *testing.T) {
ids, err := id.New(1)
if err != nil {
t.Fatal(err)
}
users := logictest.Users(ids, nil)
ctx := t.Context()
a, err := users.AuthenticateEmail(ctx, "test01@spark.com", "1", &userentity.LoginDevice{DeviceID: 1, DeviceType: userentity.DeviceIOS})
if err != nil {
t.Fatal(err)
}
b, err := users.AuthenticateEmail(ctx, "test02@spark.com", "1", &userentity.LoginDevice{DeviceID: 2, DeviceType: userentity.DeviceWeb})
if err != nil {
t.Fatal(err)
}
chats := convmodel.NewMemoryP2P()
msgs := msgmodel.NewMemoryRepo()
p2p := convlogic.NewP2PService(chats, ids)
svc := NewService(friendmodel.NewMemoryRepo(), users, ids, NewGreeter(p2p, msgs, ids, nil))
if _, err := svc.Follow(ctx, a.ID, b.ID); err != nil {
t.Fatal(err)
}
if _, err := svc.Follow(ctx, b.ID, a.ID); err != nil {
t.Fatal(err)
}
conv, err := chats.GetP2P(ctx, a.ID, b.ID)
if err != nil || conv == nil || conv.ID <= 0 {
t.Fatalf("conversation %+v %v", conv, err)
}
before, err := msgs.ListByConversation(ctx, conv.ID, a.ID, 0, 20)
if err != nil || len(before) != 1 {
t.Fatalf("before %d %v", len(before), err)
}
view, err := svc.Unfollow(ctx, a.ID, b.ID)
if err != nil || view.Following || view.Mutual || view.Friend || !view.FollowedBy {
t.Fatalf("%+v %v", view, err)
}
after, err := msgs.ListByConversation(ctx, conv.ID, a.ID, 0, 20)
if err != nil || len(after) != 1 || after[0].ID != before[0].ID {
t.Fatalf("after %d %v", len(after), err)
}
ok, err := svc.AreFriends(ctx, a.ID, b.ID)
if err != nil || ok {
t.Fatalf("still friends %v %v", ok, err)
}
}
func TestUnfollowOneWayKeepsAcceptedFriend(t *testing.T) {
svc, users, g := testFriends(t)
ctx := t.Context()
a, b := twoUsers(t, users)
applied, err := svc.Apply(ctx, a.ID, &ApplyInput{UID: b.ID})
if err != nil {
t.Fatal(err)
}
if _, err := svc.Accept(ctx, b.ID, AcceptInput{RequestID: applied.RequestID}); err != nil {
t.Fatal(err)
}
if _, err := svc.Follow(ctx, a.ID, b.ID); err != nil {
t.Fatal(err)
}
view, err := svc.Unfollow(ctx, a.ID, b.ID)
if err != nil || view.Following || !view.Friend || view.Mutual {
t.Fatalf("%+v %v", view, err)
}
if g.n != 1 {
t.Fatalf("greeting %d", g.n)
}
}
func TestFollowRejectsSelfAndBlock(t *testing.T) {
svc, users, _ := testFriends(t)
ctx := t.Context()
a, b := twoUsers(t, users)
if _, err := svc.Follow(ctx, a.ID, a.ID); !errors.Is(err, errs.ErrInvalidArgument) {
t.Fatalf("self %v", err)
}
if err := svc.Block(ctx, a.ID, b.ID); err != nil {
t.Fatal(err)
}
if _, err := svc.Follow(ctx, a.ID, b.ID); !errors.Is(err, errs.ErrForbidden) {
t.Fatalf("blocked by me %v", err)
}
if _, err := svc.Follow(ctx, b.ID, a.ID); !errors.Is(err, errs.ErrNotFound) {
t.Fatalf("blocked by peer %v", err)
}
}

View File

@ -26,6 +26,9 @@ type Repository interface {
CreateReport(ctx context.Context, r *entity.Report) error
AcceptFriend(ctx context.Context, in entity.AcceptParams) error
DeleteFriend(ctx context.Context, a, b int64) error
PutFollow(ctx context.Context, userID, targetID int64) error
DeleteFollow(ctx context.Context, userID, targetID int64) error
HasFollow(ctx context.Context, userID, targetID int64) (bool, error)
PurgeUser(ctx context.Context, userID int64) error
}

View File

@ -20,7 +20,7 @@ type FriendRelation struct {
Remark string `gorm:"column:remark;comment:备注,仅自己可见"`
Blocked bool `gorm:"column:is_blocked;comment:是否拉黑对方"`
Pinned bool `gorm:"column:is_pinned;comment:会话消息置顶"`
Source int `gorm:"column:source;comment:添加来源:0=未知 1=二维码 2=邮箱 3=spark_id"`
Source int `gorm:"column:source;comment:添加来源:0=未知 1=二维码 2=邮箱 3=spark_id 4=互关"`
CreatedAt time.Time `gorm:"column:created_at;comment:成为好友或建边时间"`
UpdatedAt time.Time `gorm:"column:updated_at;comment:最近更新时间"`
}

View File

@ -27,6 +27,7 @@ type MemoryRepo struct {
requests map[int64]*entity.Request
byPair map[requestKey]int64
reports []*entity.Report
follows map[followKey]struct{}
}
func NewMemoryRepo() *MemoryRepo {
@ -34,6 +35,7 @@ func NewMemoryRepo() *MemoryRepo {
relations: make(map[edgeKey]*entity.Relation),
requests: make(map[int64]*entity.Request),
byPair: make(map[requestKey]int64),
follows: make(map[followKey]struct{}),
}
}

View File

@ -23,6 +23,9 @@ func (m *Repo) PurgeUser(ctx context.Context, userID int64) error {
if err := db.Where("user_id = ? OR target_id = ?", userID, userID).Delete(&FriendReport{}).Error; err != nil {
return mapDBErr(err)
}
if err := db.Where("user_id = ? OR target_id = ?", userID, userID).Delete(&UserFollow{}).Error; err != nil {
return mapDBErr(err)
}
return nil
}
@ -51,5 +54,10 @@ func (m *MemoryRepo) PurgeUser(_ context.Context, userID int64) error {
}
}
m.reports = kept
for key := range m.follows {
if key.UserID == userID || key.TargetID == userID {
delete(m.follows, key)
}
}
return nil
}

View File

@ -0,0 +1,98 @@
package model
import (
"context"
"time"
"spark-server/internal/pkg/errs"
)
type followKey struct {
UserID int64
TargetID int64
}
// UserFollow maps table t_user_follows (one directed follow per row).
type UserFollow struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增"`
UserID int64 `gorm:"column:user_id;comment:关注人 user_id"`
TargetID int64 `gorm:"column:target_id;comment:被关注人 user_id"`
CreatedAt time.Time `gorm:"column:created_at;comment:关注时间"`
}
func (UserFollow) TableName() string { return "t_user_follows" }
func (m *Repo) PutFollow(ctx context.Context, userID, targetID int64) error {
if userID <= 0 || targetID <= 0 || userID == targetID {
return errs.ErrInvalidArgument
}
db, err := m.session(ctx)
if err != nil {
return err
}
row := UserFollow{UserID: userID, TargetID: targetID, CreatedAt: time.Now()}
err = db.Where("user_id = ? AND target_id = ?", userID, targetID).
Attrs(row).FirstOrCreate(&row).Error
return mapDBErr(err)
}
func (m *Repo) DeleteFollow(ctx context.Context, userID, targetID int64) error {
if userID <= 0 || targetID <= 0 {
return errs.ErrInvalidArgument
}
db, err := m.session(ctx)
if err != nil {
return err
}
err = db.Where("user_id = ? AND target_id = ?", userID, targetID).Delete(&UserFollow{}).Error
return mapDBErr(err)
}
func (m *Repo) HasFollow(ctx context.Context, userID, targetID int64) (bool, error) {
if userID <= 0 || targetID <= 0 {
return false, nil
}
db, err := m.session(ctx)
if err != nil {
return false, err
}
var n int64
err = db.Model(&UserFollow{}).Where("user_id = ? AND target_id = ?", userID, targetID).Count(&n).Error
if err != nil {
return false, mapDBErr(err)
}
return n > 0, nil
}
func (m *MemoryRepo) PutFollow(_ context.Context, userID, targetID int64) error {
if userID <= 0 || targetID <= 0 || userID == targetID {
return errs.ErrInvalidArgument
}
m.mu.Lock()
defer m.mu.Unlock()
if m.follows == nil {
m.follows = make(map[followKey]struct{})
}
m.follows[followKey{UserID: userID, TargetID: targetID}] = struct{}{}
return nil
}
func (m *MemoryRepo) DeleteFollow(_ context.Context, userID, targetID int64) error {
if userID <= 0 || targetID <= 0 {
return errs.ErrInvalidArgument
}
m.mu.Lock()
defer m.mu.Unlock()
delete(m.follows, followKey{UserID: userID, TargetID: targetID})
return nil
}
func (m *MemoryRepo) HasFollow(_ context.Context, userID, targetID int64) (bool, error) {
if userID <= 0 || targetID <= 0 {
return false, nil
}
m.mu.Lock()
defer m.mu.Unlock()
_, ok := m.follows[followKey{UserID: userID, TargetID: targetID}]
return ok, nil
}

View File

@ -28,6 +28,7 @@ func friendSource(lang i18n.Lang) []entity.Item {
item(lang, "friend_source", "qrcode", int(friendentity.SourceQRCode)),
item(lang, "friend_source", "email", int(friendentity.SourceEmail)),
item(lang, "friend_source", "spark_id", int(friendentity.SourceSparkID)),
item(lang, "friend_source", "follow", int(friendentity.SourceFollow)),
}
}

View File

@ -33,6 +33,7 @@ func init() {
"enum.friend_source.qrcode": {ZhCN: "二维码", En: "QR code", ZhTW: "二維碼"},
"enum.friend_source.email": {ZhCN: "邮箱", En: "Email", ZhTW: "電子郵件"},
"enum.friend_source.spark_id": {ZhCN: "Spark账号", En: "Spark ID", ZhTW: "Spark帳號"},
"enum.friend_source.follow": {ZhCN: "互关", En: "Mutual follow", ZhTW: "互關"},
"enum.report_reason.spam": {ZhCN: "垃圾信息", En: "Spam", ZhTW: "垃圾訊息"},
"enum.report_reason.harassment": {ZhCN: "骚扰", En: "Harassment", ZhTW: "騷擾"},
"enum.report_reason.fake": {ZhCN: "不实身份", En: "Fake identity", ZhTW: "不實身分"},

View File

@ -0,0 +1,61 @@
package friendhandler
import (
"github.com/gin-gonic/gin"
"spark-server/internal/transport/http/respond"
)
func (h *Handler) GetFollow(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
targetID, err := pathUID(c)
if err != nil {
respond.JSONError(c, err)
return
}
out, err := h.friends.GetFollow(c.Request.Context(), uid, targetID)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) Follow(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
targetID, err := pathUID(c)
if err != nil {
respond.JSONError(c, err)
return
}
out, err := h.friends.Follow(c.Request.Context(), uid, targetID)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
func (h *Handler) Unfollow(c *gin.Context) {
uid, ok := currentUID(c)
if !ok {
return
}
targetID, err := pathUID(c)
if err != nil {
respond.JSONError(c, err)
return
}
out, err := h.friends.Unfollow(c.Request.Context(), uid, targetID)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}

View File

@ -155,6 +155,10 @@ func registerFriendRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
g.PUT("/:uid/block", h.Block)
g.DELETE("/:uid/block", h.Unblock)
g.POST("/:uid/reports", h.Report)
f := v1.Group("/follows")
f.GET("/:uid", h.GetFollow)
f.PUT("/:uid", h.Follow)
f.DELETE("/:uid", h.Unfollow)
}
func registerGroupRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {

View File

@ -0,0 +1,11 @@
-- One directed follow per pair. Mutual follows become friend edges in logic; unfollowing does not delete messages.
CREATE TABLE IF NOT EXISTS t_user_follows (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增',
user_id BIGINT NOT NULL COMMENT '关注人 user_id',
target_id BIGINT NOT NULL COMMENT '被关注人 user_id',
created_at DATETIME(3) NOT NULL COMMENT '关注时间',
PRIMARY KEY (id),
UNIQUE KEY uk_user_target (user_id, target_id),
KEY idx_target (target_id)
) COMMENT='单向关注。双方互相关注时写入已有双向好友边;取消关注只删关注,不再互关时删好友边,不删消息';