diff --git a/internal/module/friend/entity/request.go b/internal/module/friend/entity/request.go index 37e719e..b169692 100644 --- a/internal/module/friend/entity/request.go +++ b/internal/module/friend/entity/request.go @@ -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 ( diff --git a/internal/module/friend/logic/follow.go b/internal/module/friend/logic/follow.go new file mode 100644 index 0000000..0344737 --- /dev/null +++ b/internal/module/friend/logic/follow.go @@ -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 +} diff --git a/internal/module/friend/logic/follow_test.go b/internal/module/friend/logic/follow_test.go new file mode 100644 index 0000000..6a57e72 --- /dev/null +++ b/internal/module/friend/logic/follow_test.go @@ -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) + } +} diff --git a/internal/module/friend/logic/service.go b/internal/module/friend/logic/service.go index 45da92c..c06b5a0 100644 --- a/internal/module/friend/logic/service.go +++ b/internal/module/friend/logic/service.go @@ -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 } diff --git a/internal/module/friend/model/friend_relation.go b/internal/module/friend/model/friend_relation.go index da3b83d..c8b04c4 100644 --- a/internal/module/friend/model/friend_relation.go +++ b/internal/module/friend/model/friend_relation.go @@ -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:最近更新时间"` } diff --git a/internal/module/friend/model/memory.go b/internal/module/friend/model/memory.go index ccf26d2..bdcdb6e 100644 --- a/internal/module/friend/model/memory.go +++ b/internal/module/friend/model/memory.go @@ -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{}), } } diff --git a/internal/module/friend/model/purge.go b/internal/module/friend/model/purge.go index 93c31d3..bf3946d 100644 --- a/internal/module/friend/model/purge.go +++ b/internal/module/friend/model/purge.go @@ -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 } diff --git a/internal/module/friend/model/user_follow.go b/internal/module/friend/model/user_follow.go new file mode 100644 index 0000000..c61982d --- /dev/null +++ b/internal/module/friend/model/user_follow.go @@ -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 +} diff --git a/internal/module/meta/logic/enums_friend.go b/internal/module/meta/logic/enums_friend.go index 992a0f3..fd6dd96 100644 --- a/internal/module/meta/logic/enums_friend.go +++ b/internal/module/meta/logic/enums_friend.go @@ -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)), } } diff --git a/internal/pkg/i18n/catalog_enum.go b/internal/pkg/i18n/catalog_enum.go index d11de98..c62372b 100644 --- a/internal/pkg/i18n/catalog_enum.go +++ b/internal/pkg/i18n/catalog_enum.go @@ -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: "不實身分"}, diff --git a/internal/transport/http/handler/friend/follow.go b/internal/transport/http/handler/friend/follow.go new file mode 100644 index 0000000..8479313 --- /dev/null +++ b/internal/transport/http/handler/friend/follow.go @@ -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) +} diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index a1a1b3a..1025e43 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -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) { diff --git a/migrations/0033_user_follows.sql b/migrations/0033_user_follows.sql new file mode 100644 index 0000000..660ed66 --- /dev/null +++ b/migrations/0033_user_follows.sql @@ -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='单向关注。双方互相关注时写入已有双向好友边;取消关注只删关注,不再互关时删好友边,不删消息';