feat: 互相关注后自动成为好友
一方关注只记关注。双方互关时写入已有双向好友边;取消关注若不再互关只删好友边,不删消息。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0471749141
commit
ce9c7a13aa
@ -20,13 +20,14 @@ const (
|
|||||||
SourceQRCode Source = 1
|
SourceQRCode Source = 1
|
||||||
SourceEmail Source = 2
|
SourceEmail Source = 2
|
||||||
SourceSparkID Source = 3
|
SourceSparkID Source = 3
|
||||||
|
SourceFollow Source = 4
|
||||||
)
|
)
|
||||||
|
|
||||||
// Source is friend add origin on the request and the directed edge.
|
// Source is friend add origin on the request and the directed edge.
|
||||||
type Source int
|
type Source int
|
||||||
|
|
||||||
func (s Source) Valid() bool {
|
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 (
|
const (
|
||||||
|
|||||||
150
internal/module/friend/logic/follow.go
Normal file
150
internal/module/friend/logic/follow.go
Normal 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
|
||||||
|
}
|
||||||
145
internal/module/friend/logic/follow_test.go
Normal file
145
internal/module/friend/logic/follow_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,6 +26,9 @@ type Repository interface {
|
|||||||
CreateReport(ctx context.Context, r *entity.Report) error
|
CreateReport(ctx context.Context, r *entity.Report) error
|
||||||
AcceptFriend(ctx context.Context, in entity.AcceptParams) error
|
AcceptFriend(ctx context.Context, in entity.AcceptParams) error
|
||||||
DeleteFriend(ctx context.Context, a, b int64) 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
|
PurgeUser(ctx context.Context, userID int64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ type FriendRelation struct {
|
|||||||
Remark string `gorm:"column:remark;comment:备注,仅自己可见"`
|
Remark string `gorm:"column:remark;comment:备注,仅自己可见"`
|
||||||
Blocked bool `gorm:"column:is_blocked;comment:是否拉黑对方"`
|
Blocked bool `gorm:"column:is_blocked;comment:是否拉黑对方"`
|
||||||
Pinned bool `gorm:"column:is_pinned;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:成为好友或建边时间"`
|
CreatedAt time.Time `gorm:"column:created_at;comment:成为好友或建边时间"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;comment:最近更新时间"`
|
UpdatedAt time.Time `gorm:"column:updated_at;comment:最近更新时间"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,7 @@ type MemoryRepo struct {
|
|||||||
requests map[int64]*entity.Request
|
requests map[int64]*entity.Request
|
||||||
byPair map[requestKey]int64
|
byPair map[requestKey]int64
|
||||||
reports []*entity.Report
|
reports []*entity.Report
|
||||||
|
follows map[followKey]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMemoryRepo() *MemoryRepo {
|
func NewMemoryRepo() *MemoryRepo {
|
||||||
@ -34,6 +35,7 @@ func NewMemoryRepo() *MemoryRepo {
|
|||||||
relations: make(map[edgeKey]*entity.Relation),
|
relations: make(map[edgeKey]*entity.Relation),
|
||||||
requests: make(map[int64]*entity.Request),
|
requests: make(map[int64]*entity.Request),
|
||||||
byPair: make(map[requestKey]int64),
|
byPair: make(map[requestKey]int64),
|
||||||
|
follows: make(map[followKey]struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
if err := db.Where("user_id = ? OR target_id = ?", userID, userID).Delete(&FriendReport{}).Error; err != nil {
|
||||||
return mapDBErr(err)
|
return mapDBErr(err)
|
||||||
}
|
}
|
||||||
|
if err := db.Where("user_id = ? OR target_id = ?", userID, userID).Delete(&UserFollow{}).Error; err != nil {
|
||||||
|
return mapDBErr(err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,5 +54,10 @@ func (m *MemoryRepo) PurgeUser(_ context.Context, userID int64) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.reports = kept
|
m.reports = kept
|
||||||
|
for key := range m.follows {
|
||||||
|
if key.UserID == userID || key.TargetID == userID {
|
||||||
|
delete(m.follows, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
98
internal/module/friend/model/user_follow.go
Normal file
98
internal/module/friend/model/user_follow.go
Normal 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
|
||||||
|
}
|
||||||
@ -28,6 +28,7 @@ func friendSource(lang i18n.Lang) []entity.Item {
|
|||||||
item(lang, "friend_source", "qrcode", int(friendentity.SourceQRCode)),
|
item(lang, "friend_source", "qrcode", int(friendentity.SourceQRCode)),
|
||||||
item(lang, "friend_source", "email", int(friendentity.SourceEmail)),
|
item(lang, "friend_source", "email", int(friendentity.SourceEmail)),
|
||||||
item(lang, "friend_source", "spark_id", int(friendentity.SourceSparkID)),
|
item(lang, "friend_source", "spark_id", int(friendentity.SourceSparkID)),
|
||||||
|
item(lang, "friend_source", "follow", int(friendentity.SourceFollow)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,7 @@ func init() {
|
|||||||
"enum.friend_source.qrcode": {ZhCN: "二维码", En: "QR code", ZhTW: "二維碼"},
|
"enum.friend_source.qrcode": {ZhCN: "二维码", En: "QR code", ZhTW: "二維碼"},
|
||||||
"enum.friend_source.email": {ZhCN: "邮箱", En: "Email", ZhTW: "電子郵件"},
|
"enum.friend_source.email": {ZhCN: "邮箱", En: "Email", ZhTW: "電子郵件"},
|
||||||
"enum.friend_source.spark_id": {ZhCN: "Spark账号", En: "Spark ID", ZhTW: "Spark帳號"},
|
"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.spam": {ZhCN: "垃圾信息", En: "Spam", ZhTW: "垃圾訊息"},
|
||||||
"enum.report_reason.harassment": {ZhCN: "骚扰", En: "Harassment", ZhTW: "騷擾"},
|
"enum.report_reason.harassment": {ZhCN: "骚扰", En: "Harassment", ZhTW: "騷擾"},
|
||||||
"enum.report_reason.fake": {ZhCN: "不实身份", En: "Fake identity", ZhTW: "不實身分"},
|
"enum.report_reason.fake": {ZhCN: "不实身份", En: "Fake identity", ZhTW: "不實身分"},
|
||||||
|
|||||||
61
internal/transport/http/handler/friend/follow.go
Normal file
61
internal/transport/http/handler/friend/follow.go
Normal 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)
|
||||||
|
}
|
||||||
@ -155,6 +155,10 @@ func registerFriendRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
|||||||
g.PUT("/:uid/block", h.Block)
|
g.PUT("/:uid/block", h.Block)
|
||||||
g.DELETE("/:uid/block", h.Unblock)
|
g.DELETE("/:uid/block", h.Unblock)
|
||||||
g.POST("/:uid/reports", h.Report)
|
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) {
|
func registerGroupRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
||||||
|
|||||||
11
migrations/0033_user_follows.sql
Normal file
11
migrations/0033_user_follows.sql
Normal 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='单向关注。双方互相关注时写入已有双向好友边;取消关注只删关注,不再互关时删好友边,不删消息';
|
||||||
Loading…
Reference in New Issue
Block a user