feat: complete im p2p relationship onboarding

This commit is contained in:
richjohnrambo
2026-08-12 15:20:15 +08:00
parent d02041659e
commit 943ebe1397
7 changed files with 666 additions and 17 deletions
+3
View File
@@ -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)
+369 -4
View File
@@ -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<?")
args = append(args, parsed)
}
args = append(args, limit+1)
query := `SELECT fr.*,u.public,u.trusted
FROM im_friend_requests AS fr
JOIN users AS u ON u.tenant_id=fr.tenant_id AND u.id=IF(fr.applicant_id=?,fr.target_id,fr.applicant_id)
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY fr.id DESC LIMIT ?`
args = append([]any{decodedUID}, args...)
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
items := make([]t.FriendRequestListItem, 0, limit)
nextCursor := ""
for rows.Next() {
row := struct {
ID int64
TenantID t.TenantID `db:"tenant_id"`
RequestID string `db:"request_id"`
ApplicantID int64 `db:"applicant_id"`
TargetID int64 `db:"target_id"`
Message string
Source string
SourceTopic string `db:"source_topic"`
Status string
IdempotencyKey string `db:"idempotency_key"`
ExpiresAt time.Time `db:"expires_at"`
HandledAt *time.Time `db:"handled_at"`
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
}
applicant := store.EncodeUid(row.ApplicantID)
target := store.EncodeUid(row.TargetID)
peerID := target
direction := "outbound"
if target == uid {
peerID = applicant
direction = "inbound"
}
items = append(items, t.FriendRequestListItem{
Request: t.FriendRequest{
ID: row.ID,
TenantID: row.TenantID,
RequestID: row.RequestID,
ApplicantID: applicant,
TargetID: target,
Message: row.Message,
Source: row.Source,
SourceTopic: row.SourceTopic,
Status: row.Status,
IdempotencyKey: row.IdempotencyKey,
ExpiresAt: row.ExpiresAt,
HandledAt: row.HandledAt,
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),
},
Direction: direction,
})
}
if err = rows.Err(); err != nil {
return nil, "", err
}
return items, nextCursor, nil
}
func (a *adapter) TenantFriendRequestLatestBetween(id t.TenantID, uid1, uid2 t.Uid) (*t.FriendRequest, error) {
if err := validTenant(id); err != nil || uid1.IsZero() || uid2.IsZero() {
return nil, t.ErrMalformed
@@ -2547,6 +2677,151 @@ func (a *adapter) TenantFriendRequestLatestBetween(id t.TenantID, uid1, uid2 t.U
id, decodeUID(uid1), decodeUID(uid2), decodeUID(uid2), decodeUID(uid1))
}
func (a *adapter) TenantFriendRequestAccept(id t.TenantID, requestID string, actor t.Uid, handledAt time.Time) (*t.FriendRequest, error) {
if err := validTenant(id); err != nil || requestID == "" || actor.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
row := struct {
ID int64
TenantID t.TenantID `db:"tenant_id"`
RequestID string `db:"request_id"`
ApplicantID int64 `db:"applicant_id"`
TargetID int64 `db:"target_id"`
Message string
Source string
SourceTopic string `db:"source_topic"`
Status string
IdempotencyKey string `db:"idempotency_key"`
ExpiresAt time.Time `db:"expires_at"`
HandledAt *time.Time `db:"handled_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
err = tx.GetContext(ctx, &row, "SELECT * FROM im_friend_requests WHERE tenant_id=? AND request_id=? FOR UPDATE", id, requestID)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
if store.EncodeUid(row.TargetID) != actor {
return nil, t.ErrPermissionDenied
}
if row.Status != t.FriendRequestPending && row.Status != t.FriendRequestAccepted {
return &t.FriendRequest{
ID: row.ID,
TenantID: row.TenantID,
RequestID: row.RequestID,
ApplicantID: store.EncodeUid(row.ApplicantID),
TargetID: store.EncodeUid(row.TargetID),
Message: row.Message,
Source: row.Source,
SourceTopic: row.SourceTopic,
Status: row.Status,
IdempotencyKey: row.IdempotencyKey,
ExpiresAt: row.ExpiresAt,
HandledAt: row.HandledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}, nil
}
applicant := store.EncodeUid(row.ApplicantID)
target := store.EncodeUid(row.TargetID)
topic := applicant.P2PName(target)
if topic == "" {
return nil, t.ErrMalformed
}
if row.Status == t.FriendRequestPending {
if _, err = tx.ExecContext(ctx,
"UPDATE im_friend_requests SET status=?,handled_at=?,updated_at=? WHERE tenant_id=? AND request_id=? AND status=?",
t.FriendRequestAccepted, handledAt, handledAt, id, requestID, t.FriendRequestPending); err != nil {
return nil, err
}
row.Status = t.FriendRequestAccepted
row.HandledAt = &handledAt
row.UpdatedAt = handledAt
}
for _, pair := range [][2]t.Uid{{applicant, target}, {target, applicant}} {
if _, err = tx.ExecContext(ctx,
"INSERT INTO im_friendships(tenant_id,user_id,peer_id,status,topic,created_at,updated_at) VALUES(?,?,?,?,?,?,?) "+
"ON DUPLICATE KEY UPDATE status=VALUES(status),topic=VALUES(topic),updated_at=VALUES(updated_at)",
id, decodeUID(pair[0]), decodeUID(pair[1]), t.RelationshipFriend, topic, handledAt, handledAt); err != nil {
return nil, err
}
}
users, err := a.tenantUserGetAllTx(ctx, tx, id, applicant, target)
if err != nil {
return nil, err
}
if len(users) != 2 {
return nil, t.ErrUserNotFound
}
userByID := map[t.Uid]t.User{}
for _, user := range users {
userByID[user.Uid()] = user
}
applicantUser, ok1 := userByID[applicant]
targetUser, ok2 := userByID[target]
if !ok1 || !ok2 {
return nil, t.ErrUserNotFound
}
applicantSub := &t.Subscription{
ObjHeader: t.ObjHeader{CreatedAt: handledAt, UpdatedAt: handledAt},
TenantID: id,
User: applicant.String(),
Topic: topic,
ModeWant: t.ModeCP2P,
ModeGiven: t.ModeCP2P,
}
applicantSub.SetTouchedAt(handledAt)
applicantSub.SetPublic(targetUser.Public)
applicantSub.SetTrusted(targetUser.Trusted)
targetSub := &t.Subscription{
ObjHeader: t.ObjHeader{CreatedAt: handledAt, UpdatedAt: handledAt},
TenantID: id,
User: target.String(),
Topic: topic,
ModeWant: t.ModeCP2P,
ModeGiven: t.ModeCP2P,
}
targetSub.SetTouchedAt(handledAt)
targetSub.SetPublic(applicantUser.Public)
targetSub.SetTrusted(applicantUser.Trusted)
if err = createSubscription(tx, applicantSub, true); err != nil {
return nil, err
}
if err = createSubscription(tx, targetSub, true); err != nil {
return nil, err
}
topicRow := &t.Topic{TenantID: id, ObjHeader: t.ObjHeader{Id: topic, CreatedAt: handledAt, UpdatedAt: handledAt}, TouchedAt: handledAt}
if err = tenantTopicCreate(tx, topicRow); err != nil {
if !isDupe(err) {
return nil, err
}
if _, err = tx.ExecContext(ctx, "UPDATE topics SET updatedat=?,touchedat=?,state=? WHERE tenant_id=? AND name=?",
handledAt, handledAt, t.StateOK, id, topic); err != nil {
return nil, err
}
}
if err = tx.Commit(); err != nil {
return nil, err
}
return a.friendRequestByRequestID(ctx, id, requestID)
}
func (a *adapter) TenantFriendRequestResolve(id t.TenantID, requestID string, actor t.Uid, status string, handledAt time.Time) (*t.FriendRequest, error) {
if err := validTenant(id); err != nil || requestID == "" || actor.IsZero() {
return nil, t.ErrMalformed
@@ -2595,6 +2870,96 @@ func (a *adapter) TenantFriendshipUpsertPair(id t.TenantID, uid1, uid2 t.Uid, to
return nil
}
func (a *adapter) TenantFriendshipList(id t.TenantID, uid t.Uid, queryText, initial, cursor string, limit int) ([]t.FriendListItem, string, error) {
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, "", t.ErrMalformed
}
if limit <= 0 || limit > 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
+143 -2
View File
@@ -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):
+96 -6
View File
@@ -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
}
+1
View File
@@ -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)))
+43 -5
View File
@@ -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
+11
View File
@@ -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
}