diff --git a/server/db/adapter.go b/server/db/adapter.go index 95b98f9..98744c8 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -319,9 +319,12 @@ type TenantBusinessAdapter interface { TenantSystemNotificationMarkRead(t.TenantID, t.Uid, string, time.Time) error TenantFriendRequestCreate(t.TenantID, *t.FriendRequest) (*t.FriendRequest, error) + TenantFriendRequestList(t.TenantID, t.Uid, string, string, string, int) ([]t.FriendRequestListItem, string, error) TenantFriendRequestLatestBetween(t.TenantID, t.Uid, t.Uid) (*t.FriendRequest, error) + TenantFriendRequestAccept(t.TenantID, string, t.Uid, time.Time) (*t.FriendRequest, error) TenantFriendRequestResolve(t.TenantID, string, t.Uid, string, time.Time) (*t.FriendRequest, error) TenantFriendshipUpsertPair(t.TenantID, t.Uid, t.Uid, string, time.Time) error + TenantFriendshipList(t.TenantID, t.Uid, string, string, string, int) ([]t.FriendListItem, string, error) TenantFriendshipGet(t.TenantID, t.Uid, t.Uid) (*t.Friendship, error) TenantFriendshipDeletePair(t.TenantID, t.Uid, t.Uid, time.Time) error TenantUserBlockGet(t.TenantID, t.Uid, t.Uid) (*t.UserBlock, error) diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 74901ee..67e757b 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -87,6 +87,14 @@ func (a *adapter) TenantUserGetAll(id t.TenantID, uids ...t.Uid) ([]t.User, erro if err := validTenant(id); err != nil { return nil, err } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + return a.tenantUserGetAllTx(ctx, nil, id, uids...) +} + +func (a *adapter) tenantUserGetAllTx(ctx context.Context, tx *sqlx.Tx, id t.TenantID, uids ...t.Uid) ([]t.User, error) { if len(uids) == 0 { return nil, nil } @@ -100,11 +108,14 @@ func (a *adapter) TenantUserGetAll(id t.TenantID, uids ...t.Uid) ([]t.User, erro return nil, nil } q, params, _ := sqlx.In("SELECT * FROM users WHERE tenant_id=? AND id IN (?) AND state!=?", id, args, t.StateDeleted) - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() + query := a.db.Rebind(q) + var rows *sqlx.Rows + var err error + if tx != nil { + rows, err = tx.QueryxContext(ctx, query, params...) + } else { + rows, err = a.db.QueryxContext(ctx, query, params...) } - rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), params...) if err != nil { return nil, err } @@ -2534,6 +2545,125 @@ func (a *adapter) TenantFriendRequestCreate(id t.TenantID, req *t.FriendRequest) return a.friendRequestByRequestID(ctx, id, req.RequestID) } +func (a *adapter) TenantFriendRequestList(id t.TenantID, uid t.Uid, direction, status, cursor string, limit int) ([]t.FriendRequestListItem, string, error) { + if err := validTenant(id); err != nil || uid.IsZero() { + return nil, "", t.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 20 + } + where := []string{"fr.tenant_id=?"} + args := []any{id} + decodedUID := decodeUID(uid) + switch direction { + case "", "inbound": + where = append(where, "fr.target_id=?") + args = append(args, decodedUID) + case "outbound": + where = append(where, "fr.applicant_id=?") + args = append(args, decodedUID) + case "all": + where = append(where, "(fr.applicant_id=? OR fr.target_id=?)") + args = append(args, decodedUID, decodedUID) + default: + return nil, "", t.ErrMalformed + } + if status != "" && status != "all" { + where = append(where, "fr.status=?") + args = append(args, status) + } + if cursor != "" { + parsed, err := strconv.ParseInt(cursor, 10, 64) + if err != nil || parsed <= 0 { + return nil, "", t.ErrMalformed + } + where = append(where, "fr.id 100 { + limit = 50 + } + where := []string{"fs.tenant_id=?", "fs.user_id=?", "fs.status=?"} + args := []any{id, decodeUID(uid), t.RelationshipFriend} + if cursor != "" { + parsed, err := strconv.ParseInt(cursor, 10, 64) + if err != nil || parsed <= 0 { + return nil, "", t.ErrMalformed + } + where = append(where, "fs.id>?") + args = append(args, parsed) + } + if q := strings.TrimSpace(queryText); q != "" { + like := "%" + q + "%" + where = append(where, "(fs.remark LIKE ? OR CAST(u.public AS CHAR) LIKE ? OR CAST(u.tags AS CHAR) LIKE ?)") + args = append(args, like, like, like) + } + if initial = strings.TrimSpace(initial); initial != "" { + like := initial + "%" + where = append(where, "(fs.remark LIKE ? OR CAST(u.public AS CHAR) LIKE ?)") + args = append(args, like, like) + } + args = append(args, limit+1) + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + rows, err := a.db.QueryxContext(ctx, `SELECT fs.*,u.public,u.trusted + FROM im_friendships AS fs + JOIN users AS u ON u.tenant_id=fs.tenant_id AND u.id=fs.peer_id + WHERE `+strings.Join(where, " AND ")+` + ORDER BY fs.id ASC LIMIT ?`, args...) + if err != nil { + return nil, "", err + } + defer rows.Close() + items := make([]t.FriendListItem, 0, limit) + nextCursor := "" + for rows.Next() { + row := struct { + ID int64 + TenantID t.TenantID `db:"tenant_id"` + UserID int64 `db:"user_id"` + PeerID int64 `db:"peer_id"` + Status string + Remark string + Topic string + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + Public any + Trusted any + }{} + if err = rows.StructScan(&row); err != nil { + return nil, "", err + } + if len(items) == limit { + nextCursor = strconv.FormatInt(row.ID, 10) + continue + } + peerID := store.EncodeUid(row.PeerID) + items = append(items, t.FriendListItem{ + Friendship: t.Friendship{ + TenantID: row.TenantID, + UserID: store.EncodeUid(row.UserID), + PeerID: peerID, + Status: row.Status, + Remark: row.Remark, + Topic: row.Topic, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + }, + Peer: &t.User{ + ObjHeader: t.ObjHeader{Id: peerID.String()}, + TenantID: row.TenantID, + Public: common.FromJSON(row.Public), + Trusted: common.FromJSON(row.Trusted), + }, + }) + } + if err = rows.Err(); err != nil { + return nil, "", err + } + return items, nextCursor, nil +} + func (a *adapter) TenantFriendshipGet(id t.TenantID, userID, peerID t.Uid) (*t.Friendship, error) { if err := validTenant(id); err != nil || userID.IsZero() || peerID.IsZero() { return nil, t.ErrMalformed diff --git a/server/im_http.go b/server/im_http.go index 4c490ea..309a58a 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -1014,10 +1014,39 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) { } func serveIMFriendRequests(wrt http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { + switch req.Method { + case http.MethodGet: + serveIMFriendRequestList(wrt, req) + case http.MethodPost: + serveIMFriendRequestCreate(wrt, req) + default: writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + } +} + +func serveIMFriendRequestList(wrt http.ResponseWriter, req *http.Request) { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { return } + limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100) + items, nextCursor, err := store.Relations.ListFriendRequests(principal.TenantID, principal.Uid, + strings.TrimSpace(req.URL.Query().Get("direction")), + strings.TrimSpace(req.URL.Query().Get("status")), + strings.TrimSpace(req.URL.Query().Get("cursor")), + limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + payload := make([]map[string]any, 0, len(items)) + for _, item := range items { + payload = append(payload, friendRequestListItemPayload(item)) + } + writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) +} + +func serveIMFriendRequestCreate(wrt http.ResponseWriter, req *http.Request) { principal, ok := authenticateIMHTTPBearer(wrt, req) if !ok { return @@ -1060,6 +1089,32 @@ func serveIMFriendRequests(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, friendRequestPayload(created)) } +func serveIMFriends(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100) + items, nextCursor, err := store.Relations.ListFriends(principal.TenantID, principal.Uid, + strings.TrimSpace(req.URL.Query().Get("query")), + strings.TrimSpace(req.URL.Query().Get("initial")), + strings.TrimSpace(req.URL.Query().Get("cursor")), + limit) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + payload := make([]map[string]any, 0, len(items)) + for _, item := range items { + payload = append(payload, friendListItemPayload(item)) + } + writeIMSuccess(wrt, req, map[string]any{"items": payload, "next_cursor": nextCursor}) +} + func serveIMFriendRequestAction(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -1615,7 +1670,9 @@ func friendRequestPayload(req *types.FriendRequest) map[string]any { "status": req.Status, "created_at": req.CreatedAt.UTC().Format(time.RFC3339Nano), "expires_at": req.ExpiresAt.UTC().Format(time.RFC3339Nano), - "topic": req.ApplicantID.P2PName(req.TargetID), + } + if topic := req.ApplicantID.P2PName(req.TargetID); topic != "" { + data["topic"] = topic } if req.SourceTopic != "" { data["source_topic"] = req.SourceTopic @@ -1626,6 +1683,90 @@ func friendRequestPayload(req *types.FriendRequest) map[string]any { return data } +func friendRequestListItemPayload(item types.FriendRequestListItem) map[string]any { + data := friendRequestPayload(&item.Request) + data["direction"] = item.Direction + if item.Peer != nil { + data["peer"] = userSummaryPayload(item.Peer, "") + } + return data +} + +func friendListItemPayload(item types.FriendListItem) map[string]any { + data := map[string]any{ + "user_id": item.Friendship.PeerID.UserId(), + "relationship": item.Friendship.Status, + "remark": item.Friendship.Remark, + "topic": item.Friendship.Topic, + "online_visibility": "unknown", + "created_at": item.Friendship.CreatedAt.UTC().Format(time.RFC3339Nano), + "updated_at": item.Friendship.UpdatedAt.UTC().Format(time.RFC3339Nano), + "capabilities": map[string]any{"can_send": item.Friendship.Status == types.RelationshipFriend, "can_call": item.Friendship.Status == types.RelationshipFriend}, + "display_name_source": "profile", + } + if item.Friendship.Remark != "" { + data["display_name"] = item.Friendship.Remark + data["display_name_source"] = "remark" + } + if item.Peer != nil { + for key, value := range userSummaryPayload(item.Peer, item.Friendship.Remark) { + _, exists := data[key] + if !exists || key == "avatar_file_id" || (key == "display_name" && item.Friendship.Remark == "") { + data[key] = value + } + } + } + data["initial"] = displayInitial(data["display_name"]) + return data +} + +func userSummaryPayload(user *types.User, remark string) map[string]any { + uid := types.ParseUid(user.Id) + userID := uid.UserId() + if uid.IsZero() { + userID = user.Id + } + data := map[string]any{"user_id": userID} + if remark != "" { + data["display_name"] = remark + } + if public, ok := user.Public.(map[string]any); ok { + copyUserPublicSummary(data, public) + } else if public, ok := user.Public.(types.KVMap); ok { + copyUserPublicSummary(data, map[string]any(public)) + } + if _, ok := data["display_name"]; !ok { + data["display_name"] = userID + } + return data +} + +func copyUserPublicSummary(data map[string]any, public map[string]any) { + for _, key := range []string{"display_name", "fn", "name", "nickname"} { + if value, ok := public[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" { + data["display_name"] = value + break + } + } + for _, key := range []string{"avatar_file_id", "photo", "avatar"} { + if value, ok := public[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" { + data["avatar_file_id"] = value + break + } + } +} + +func displayInitial(value any) string { + name := strings.TrimSpace(fmt.Sprint(value)) + if name == "" { + return "#" + } + for _, r := range name { + return strings.ToUpper(string(r)) + } + return "#" +} + func writeIMRelationError(wrt http.ResponseWriter, req *http.Request, err error) { switch { case errors.Is(err, types.ErrNotFound): diff --git a/server/im_http_test.go b/server/im_http_test.go index df74cfe..a3a0a26 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -34,12 +34,14 @@ type testVerificationStore struct { } type testRelationsStore struct { - context *types.RelationshipContext - created *types.FriendRequest - blocked *types.UserBlock - deleted bool - canSend *bool - reason string + context *types.RelationshipContext + created *types.FriendRequest + blocked *types.UserBlock + deleted bool + canSend *bool + reason string + friends []types.FriendListItem + friendRequests []types.FriendRequestListItem } func (s *testRelationsStore) CreateFriendRequest(_ types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error) { @@ -47,6 +49,10 @@ func (s *testRelationsStore) CreateFriendRequest(_ types.TenantID, req *types.Fr return req, nil } +func (s *testRelationsStore) ListFriendRequests(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FriendRequestListItem, string, error) { + return s.friendRequests, "", nil +} + func (s *testRelationsStore) ResolveFriendRequest(_ types.TenantID, requestID string, actor types.Uid, status string, handledAt time.Time) (*types.FriendRequest, error) { return &types.FriendRequest{ TenantID: 7, @@ -61,6 +67,10 @@ func (s *testRelationsStore) ResolveFriendRequest(_ types.TenantID, requestID st }, nil } +func (s *testRelationsStore) ListFriends(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FriendListItem, string, error) { + return s.friends, "", nil +} + func (s *testRelationsStore) GetRelationshipContext(_ types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error) { if s.context != nil { return s.context, nil @@ -984,6 +994,86 @@ func TestServeIMFriendRequestCreate(t *testing.T) { } } +func TestServeIMFriendRequestList(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 1) + oldRelations := store.Relations + now := time.Now().UTC().Round(time.Millisecond) + relations := &testRelationsStore{friendRequests: []types.FriendRequestListItem{{ + Request: types.FriendRequest{ + TenantID: 7, + RequestID: "frq_1", + ApplicantID: 2, + TargetID: 1, + Message: "hi", + Source: "search", + Status: types.FriendRequestPending, + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: now.Add(time.Hour), + }, + Peer: &types.User{ObjHeader: types.ObjHeader{Id: types.Uid(2).String()}, TenantID: 7, Public: map[string]any{"display_name": "Alice"}}, + Direction: "inbound", + }}} + store.Relations = relations + defer func() { store.Relations = oldRelations }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/friend-requests?direction=inbound&status=pending", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMFriendRequests(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + items := data["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["direction"] != "inbound" { + t.Fatalf("unexpected friend request list: %#v", data) + } +} + +func TestServeIMFriendsList(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 1) + oldRelations := store.Relations + now := time.Now().UTC().Round(time.Millisecond) + peer := types.Uid(2) + relations := &testRelationsStore{friends: []types.FriendListItem{{ + Friendship: types.Friendship{ + TenantID: 7, + UserID: 1, + PeerID: peer, + Status: types.RelationshipFriend, + Remark: "小明", + Topic: types.Uid(1).P2PName(peer), + CreatedAt: now, + UpdatedAt: now, + }, + Peer: &types.User{ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, Public: map[string]any{"display_name": "Ming"}}, + }}} + store.Relations = relations + defer func() { store.Relations = oldRelations }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/friends?query=ming", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMFriends(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + items := data["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["display_name"] != "小明" { + t.Fatalf("unexpected friends list: %#v", data) + } +} + func ptrTime(t time.Time) *time.Time { return &t } diff --git a/server/main.go b/server/main.go index 5ce2136..bff249e 100644 --- a/server/main.go +++ b/server/main.go @@ -797,6 +797,7 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/friend-requests", serveIMFriendRequests) mux.HandleFunc(config.ApiPath+"v1/im/friend-requests/", serveIMFriendRequestAction) mux.HandleFunc(config.ApiPath+"v1/im/blocks/", serveIMBlockUser) + mux.HandleFunc(config.ApiPath+"v1/im/friends", serveIMFriends) mux.HandleFunc(config.ApiPath+"v1/im/friends/", serveIMFriendResource) // Handle long polling clients. Enable compression. mux.Handle(config.ApiPath+"v0/channels/lp", gh.CompressHandler(http.HandlerFunc(serveLongPoll))) diff --git a/server/store/store.go b/server/store/store.go index 794bf56..3b1850e 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -997,7 +997,9 @@ func (messagesMapper) Edit(tenantID types.TenantID, topic string, seq int, actor type RelationsPersistenceInterface interface { CreateFriendRequest(tenantID types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error) + ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error) ResolveFriendRequest(tenantID types.TenantID, requestID string, actor types.Uid, status string, handledAt time.Time) (*types.FriendRequest, error) + ListFriends(tenantID types.TenantID, uid types.Uid, query, initial, cursor string, limit int) ([]types.FriendListItem, string, error) GetRelationshipContext(tenantID types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error) CanSendP2P(tenantID types.TenantID, userID, peerID types.Uid) (bool, string, error) BlockUser(tenantID types.TenantID, blockerID, blockedID types.Uid, now time.Time) (*types.UserBlock, error) @@ -1020,6 +1022,30 @@ func (relationsMapper) CreateFriendRequest(tenantID types.TenantID, req *types.F return tenantAdp.TenantFriendRequestCreate(tenantID, req) } +func (relationsMapper) ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error) { + if uid.IsZero() { + return nil, "", types.ErrMalformed + } + switch direction { + case "", "inbound", "outbound", "all": + default: + return nil, "", types.ErrMalformed + } + switch status { + case "", "all", types.FriendRequestPending, types.FriendRequestAccepted, types.FriendRequestRejected, types.FriendRequestExpired: + default: + return nil, "", types.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 20 + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", err + } + return tenantAdp.TenantFriendRequestList(tenantID, uid, direction, status, cursor, limit) +} + func (relationsMapper) ResolveFriendRequest(tenantID types.TenantID, requestID string, actor types.Uid, status string, handledAt time.Time) (*types.FriendRequest, error) { if strings.TrimSpace(requestID) == "" || actor.IsZero() || (status != types.FriendRequestAccepted && status != types.FriendRequestRejected) { return nil, types.ErrMalformed @@ -1028,18 +1054,30 @@ func (relationsMapper) ResolveFriendRequest(tenantID types.TenantID, requestID s if err != nil { return nil, err } + if status == types.FriendRequestAccepted { + return tenantAdp.TenantFriendRequestAccept(tenantID, requestID, actor, handledAt) + } req, err := tenantAdp.TenantFriendRequestResolve(tenantID, requestID, actor, status, handledAt) if err != nil { return nil, err } - if status == types.FriendRequestAccepted { - if err := tenantAdp.TenantFriendshipUpsertPair(tenantID, req.ApplicantID, req.TargetID, "", handledAt); err != nil { - return nil, err - } - } return req, nil } +func (relationsMapper) ListFriends(tenantID types.TenantID, uid types.Uid, query, initial, cursor string, limit int) ([]types.FriendListItem, string, error) { + if uid.IsZero() { + return nil, "", types.ErrMalformed + } + if limit <= 0 || limit > 100 { + limit = 50 + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, "", err + } + return tenantAdp.TenantFriendshipList(tenantID, uid, query, initial, cursor, limit) +} + func (relationsMapper) GetRelationshipContext(tenantID types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error) { if userID.IsZero() || peerID.IsZero() || userID == peerID { return nil, types.ErrMalformed diff --git a/server/store/types/relations.go b/server/store/types/relations.go index 037ff0a..49e6f53 100644 --- a/server/store/types/relations.go +++ b/server/store/types/relations.go @@ -63,3 +63,14 @@ type RelationshipContext struct { PendingStatus string `json:"pending_status,omitempty"` Peer *User `json:"peer,omitempty"` } + +type FriendListItem struct { + Friendship Friendship + Peer *User +} + +type FriendRequestListItem struct { + Request FriendRequest + Peer *User + Direction string +}