feat: sync chatlist sharing support

This commit is contained in:
A 2026-07-06 21:06:33 +08:00
parent ec6e8fd13d
commit 2a9c12263f
62 changed files with 3408 additions and 212 deletions

View file

@ -0,0 +1,25 @@
package store
import (
"context"
"telesrv/internal/domain"
)
// ChatlistStore persists exported shared-folder links and imported memberships.
type ChatlistStore interface {
CountInvites(ctx context.Context, ownerUserID int64, filterID int) (int, error)
CountActiveInvites(ctx context.Context, ownerUserID int64, filterID int) (int, error)
SaveInvite(ctx context.Context, invite domain.ChatlistInvite) (domain.ChatlistInvite, error)
GetInvite(ctx context.Context, ownerUserID int64, filterID int, slug string) (domain.ChatlistInvite, bool, error)
GetInviteBySlug(ctx context.Context, slug string) (domain.ChatlistInvite, bool, error)
ListInvites(ctx context.Context, ownerUserID int64, filterID int) ([]domain.ChatlistInvite, error)
DeleteInvite(ctx context.Context, ownerUserID int64, filterID int, slug string) (bool, error)
CountMemberships(ctx context.Context, userID int64) (int, error)
SaveMembership(ctx context.Context, membership domain.ChatlistMembership) error
GetMembershipBySlug(ctx context.Context, userID int64, slug string) (domain.ChatlistMembership, bool, error)
GetMembershipByLocalFilter(ctx context.Context, userID int64, localFilterID int) (domain.ChatlistMembership, bool, error)
DeleteMembershipByLocalFilter(ctx context.Context, userID int64, localFilterID int) (bool, error)
SetMembershipHidden(ctx context.Context, userID int64, localFilterID int, hidden bool) (bool, error)
}

View file

@ -0,0 +1,212 @@
package memory
import (
"context"
"sort"
"sync"
"telesrv/internal/domain"
)
type ChatlistStore struct {
mu sync.RWMutex
nextInvite int64
invites map[string]domain.ChatlistInvite
memberships map[chatlistMembershipKey]domain.ChatlistMembership
}
type chatlistMembershipKey struct {
userID int64
localFilterID int
}
// NewChatlistStore creates an in-memory ChatlistStore.
func NewChatlistStore() *ChatlistStore {
return &ChatlistStore{
nextInvite: 1,
invites: make(map[string]domain.ChatlistInvite),
memberships: make(map[chatlistMembershipKey]domain.ChatlistMembership),
}
}
func (s *ChatlistStore) CountInvites(_ context.Context, ownerUserID int64, filterID int) (int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
count := 0
for _, invite := range s.invites {
if invite.OwnerUserID == ownerUserID && invite.FilterID == filterID && !invite.Deleted {
count++
}
}
return count, nil
}
func (s *ChatlistStore) CountActiveInvites(_ context.Context, ownerUserID int64, filterID int) (int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
count := 0
for _, invite := range s.invites {
if invite.OwnerUserID == ownerUserID && invite.FilterID == filterID && !invite.Deleted && !invite.Revoked {
count++
}
}
return count, nil
}
func (s *ChatlistStore) SaveInvite(_ context.Context, invite domain.ChatlistInvite) (domain.ChatlistInvite, error) {
s.mu.Lock()
defer s.mu.Unlock()
if invite.Slug == "" {
return domain.ChatlistInvite{}, domain.ErrChatlistInviteInvalid
}
if existing, ok := s.invites[invite.Slug]; ok && (existing.OwnerUserID != invite.OwnerUserID || existing.FilterID != invite.FilterID) {
return domain.ChatlistInvite{}, domain.ErrChatlistSlugOccupied
}
if invite.ID == 0 {
if existing, ok := s.invites[invite.Slug]; ok {
invite.ID = existing.ID
if invite.Date == 0 {
invite.Date = existing.Date
}
} else {
invite.ID = s.nextInvite
s.nextInvite++
}
}
invite.Deleted = false
s.invites[invite.Slug] = cloneChatlistInvite(invite)
return cloneChatlistInvite(invite), nil
}
func (s *ChatlistStore) GetInvite(_ context.Context, ownerUserID int64, filterID int, slug string) (domain.ChatlistInvite, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
invite, ok := s.invites[slug]
if !ok || invite.Deleted || invite.OwnerUserID != ownerUserID || invite.FilterID != filterID {
return domain.ChatlistInvite{}, false, nil
}
return cloneChatlistInvite(invite), true, nil
}
func (s *ChatlistStore) GetInviteBySlug(_ context.Context, slug string) (domain.ChatlistInvite, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
invite, ok := s.invites[slug]
if !ok || invite.Deleted || invite.Revoked {
return domain.ChatlistInvite{}, false, nil
}
return cloneChatlistInvite(invite), true, nil
}
func (s *ChatlistStore) ListInvites(_ context.Context, ownerUserID int64, filterID int) ([]domain.ChatlistInvite, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]domain.ChatlistInvite, 0)
for _, invite := range s.invites {
if invite.OwnerUserID == ownerUserID && invite.FilterID == filterID && !invite.Deleted {
out = append(out, cloneChatlistInvite(invite))
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Date != out[j].Date {
return out[i].Date < out[j].Date
}
return out[i].Slug < out[j].Slug
})
return out, nil
}
func (s *ChatlistStore) DeleteInvite(_ context.Context, ownerUserID int64, filterID int, slug string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
invite, ok := s.invites[slug]
if !ok || invite.Deleted || invite.OwnerUserID != ownerUserID || invite.FilterID != filterID {
return false, nil
}
invite.Deleted = true
s.invites[slug] = invite
return true, nil
}
func (s *ChatlistStore) CountMemberships(_ context.Context, userID int64) (int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
count := 0
for key := range s.memberships {
if key.userID == userID {
count++
}
}
return count, nil
}
func (s *ChatlistStore) SaveMembership(_ context.Context, membership domain.ChatlistMembership) error {
s.mu.Lock()
defer s.mu.Unlock()
if membership.UserID == 0 || membership.LocalFilterID == 0 || membership.Slug == "" {
return domain.ErrChatlistInvalid
}
key := chatlistMembershipKey{userID: membership.UserID, localFilterID: membership.LocalFilterID}
for existingKey, existing := range s.memberships {
if existingKey != key && existing.UserID == membership.UserID && existing.Slug == membership.Slug {
delete(s.memberships, existingKey)
}
}
s.memberships[key] = cloneChatlistMembership(membership)
return nil
}
func (s *ChatlistStore) GetMembershipBySlug(_ context.Context, userID int64, slug string) (domain.ChatlistMembership, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, membership := range s.memberships {
if membership.UserID == userID && membership.Slug == slug {
return cloneChatlistMembership(membership), true, nil
}
}
return domain.ChatlistMembership{}, false, nil
}
func (s *ChatlistStore) GetMembershipByLocalFilter(_ context.Context, userID int64, localFilterID int) (domain.ChatlistMembership, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
membership, ok := s.memberships[chatlistMembershipKey{userID: userID, localFilterID: localFilterID}]
if !ok {
return domain.ChatlistMembership{}, false, nil
}
return cloneChatlistMembership(membership), true, nil
}
func (s *ChatlistStore) DeleteMembershipByLocalFilter(_ context.Context, userID int64, localFilterID int) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
key := chatlistMembershipKey{userID: userID, localFilterID: localFilterID}
if _, ok := s.memberships[key]; !ok {
return false, nil
}
delete(s.memberships, key)
return true, nil
}
func (s *ChatlistStore) SetMembershipHidden(_ context.Context, userID int64, localFilterID int, hidden bool) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
key := chatlistMembershipKey{userID: userID, localFilterID: localFilterID}
membership, ok := s.memberships[key]
if !ok {
return false, nil
}
changed := membership.HiddenUpdates != hidden
membership.HiddenUpdates = hidden
s.memberships[key] = membership
return changed, nil
}
func cloneChatlistInvite(invite domain.ChatlistInvite) domain.ChatlistInvite {
invite.Peers = append([]domain.DialogFolderPeer(nil), invite.Peers...)
return invite
}
func cloneChatlistMembership(membership domain.ChatlistMembership) domain.ChatlistMembership {
return membership
}

View file

@ -0,0 +1,279 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
type ChatlistStore struct {
db sqlcgen.DBTX
}
func NewChatlistStore(db sqlcgen.DBTX) *ChatlistStore {
return &ChatlistStore{db: db}
}
func (s *ChatlistStore) CountInvites(ctx context.Context, ownerUserID int64, filterID int) (int, error) {
var count int
if err := s.db.QueryRow(ctx, `
SELECT count(*)::int
FROM chatlist_invites
WHERE owner_user_id = $1 AND filter_id = $2 AND NOT deleted`, ownerUserID, filterID).Scan(&count); err != nil {
return 0, fmt.Errorf("count chatlist invites: %w", err)
}
return count, nil
}
func (s *ChatlistStore) CountActiveInvites(ctx context.Context, ownerUserID int64, filterID int) (int, error) {
var count int
if err := s.db.QueryRow(ctx, `
SELECT count(*)::int
FROM chatlist_invites
WHERE owner_user_id = $1 AND filter_id = $2 AND NOT deleted AND NOT revoked`, ownerUserID, filterID).Scan(&count); err != nil {
return 0, fmt.Errorf("count active chatlist invites: %w", err)
}
return count, nil
}
func (s *ChatlistStore) SaveInvite(ctx context.Context, invite domain.ChatlistInvite) (domain.ChatlistInvite, error) {
peers, err := json.Marshal(invite.Peers)
if err != nil {
return domain.ChatlistInvite{}, fmt.Errorf("marshal chatlist invite peers: %w", err)
}
row := s.db.QueryRow(ctx, `
INSERT INTO chatlist_invites (owner_user_id, filter_id, slug, title, peers, revoked, deleted, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5::jsonb, $6, false,
CASE WHEN $7::int > 0 THEN to_timestamp($7::int) ELSE now() END, now())
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
peers = EXCLUDED.peers,
revoked = EXCLUDED.revoked,
deleted = false,
updated_at = now()
WHERE chatlist_invites.owner_user_id = EXCLUDED.owner_user_id
AND chatlist_invites.filter_id = EXCLUDED.filter_id
RETURNING id, owner_user_id, filter_id, slug, title, peers::text, revoked, deleted,
EXTRACT(EPOCH FROM created_at)::int`, invite.OwnerUserID, invite.FilterID, invite.Slug, invite.Title, peers, invite.Revoked, invite.Date)
out, err := scanChatlistInvite(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChatlistInvite{}, domain.ErrChatlistSlugOccupied
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return domain.ChatlistInvite{}, domain.ErrChatlistSlugOccupied
}
return domain.ChatlistInvite{}, fmt.Errorf("save chatlist invite: %w", err)
}
return out, nil
}
func (s *ChatlistStore) GetInvite(ctx context.Context, ownerUserID int64, filterID int, slug string) (domain.ChatlistInvite, bool, error) {
invite, err := scanChatlistInvite(s.db.QueryRow(ctx, `
SELECT id, owner_user_id, filter_id, slug, title, peers::text, revoked, deleted,
EXTRACT(EPOCH FROM created_at)::int
FROM chatlist_invites
WHERE owner_user_id = $1 AND filter_id = $2 AND slug = $3 AND NOT deleted`, ownerUserID, filterID, slug))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChatlistInvite{}, false, nil
}
return domain.ChatlistInvite{}, false, fmt.Errorf("get chatlist invite: %w", err)
}
return invite, true, nil
}
func (s *ChatlistStore) GetInviteBySlug(ctx context.Context, slug string) (domain.ChatlistInvite, bool, error) {
invite, err := scanChatlistInvite(s.db.QueryRow(ctx, `
SELECT id, owner_user_id, filter_id, slug, title, peers::text, revoked, deleted,
EXTRACT(EPOCH FROM created_at)::int
FROM chatlist_invites
WHERE slug = $1 AND NOT deleted AND NOT revoked`, slug))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChatlistInvite{}, false, nil
}
return domain.ChatlistInvite{}, false, fmt.Errorf("get chatlist invite by slug: %w", err)
}
return invite, true, nil
}
func (s *ChatlistStore) ListInvites(ctx context.Context, ownerUserID int64, filterID int) ([]domain.ChatlistInvite, error) {
rows, err := s.db.Query(ctx, `
SELECT id, owner_user_id, filter_id, slug, title, peers::text, revoked, deleted,
EXTRACT(EPOCH FROM created_at)::int
FROM chatlist_invites
WHERE owner_user_id = $1 AND filter_id = $2 AND NOT deleted
ORDER BY created_at ASC, slug ASC`, ownerUserID, filterID)
if err != nil {
return nil, fmt.Errorf("list chatlist invites: %w", err)
}
defer rows.Close()
out := make([]domain.ChatlistInvite, 0)
for rows.Next() {
invite, err := scanChatlistInvite(rows)
if err != nil {
return nil, err
}
out = append(out, invite)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list chatlist invites rows: %w", err)
}
return out, nil
}
func (s *ChatlistStore) DeleteInvite(ctx context.Context, ownerUserID int64, filterID int, slug string) (bool, error) {
tag, err := s.db.Exec(ctx, `
UPDATE chatlist_invites
SET deleted = true, updated_at = now()
WHERE owner_user_id = $1 AND filter_id = $2 AND slug = $3 AND NOT deleted`, ownerUserID, filterID, slug)
if err != nil {
return false, fmt.Errorf("delete chatlist invite: %w", err)
}
return tag.RowsAffected() > 0, nil
}
func (s *ChatlistStore) CountMemberships(ctx context.Context, userID int64) (int, error) {
var count int
if err := s.db.QueryRow(ctx, `
SELECT count(*)::int
FROM chatlist_memberships
WHERE user_id = $1`, userID).Scan(&count); err != nil {
return 0, fmt.Errorf("count chatlist memberships: %w", err)
}
return count, nil
}
func (s *ChatlistStore) SaveMembership(ctx context.Context, membership domain.ChatlistMembership) error {
if membership.Date == 0 {
membership.Date = nowUnix()
}
exec := func(ctx context.Context, db sqlcgen.DBTX) error {
if _, err := db.Exec(ctx, `
DELETE FROM chatlist_memberships
WHERE user_id = $1 AND slug = $2 AND local_filter_id <> $3`, membership.UserID, membership.Slug, membership.LocalFilterID); err != nil {
return fmt.Errorf("dedupe chatlist membership: %w", err)
}
if _, err := db.Exec(ctx, `
INSERT INTO chatlist_memberships (
user_id, local_filter_id, owner_user_id, owner_filter_id, slug, hidden_updates, joined_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
CASE WHEN $7::int > 0 THEN to_timestamp($7::int) ELSE now() END,
now()
)
ON CONFLICT (user_id, local_filter_id) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id,
owner_filter_id = EXCLUDED.owner_filter_id,
slug = EXCLUDED.slug,
hidden_updates = EXCLUDED.hidden_updates,
updated_at = now()`, membership.UserID, membership.LocalFilterID, membership.OwnerUserID, membership.OwnerFilterID, membership.Slug, membership.HiddenUpdates, membership.Date); err != nil {
return fmt.Errorf("save chatlist membership: %w", err)
}
return nil
}
beginner, ok := s.db.(txBeginner)
if !ok {
return exec(ctx, s.db)
}
tx, err := beginner.Begin(ctx)
if err != nil {
return fmt.Errorf("begin save chatlist membership: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if err := exec(ctx, tx); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit save chatlist membership: %w", err)
}
committed = true
return nil
}
func (s *ChatlistStore) GetMembershipBySlug(ctx context.Context, userID int64, slug string) (domain.ChatlistMembership, bool, error) {
membership, err := scanChatlistMembership(s.db.QueryRow(ctx, `
SELECT user_id, local_filter_id, owner_user_id, owner_filter_id, slug, hidden_updates,
EXTRACT(EPOCH FROM joined_at)::int
FROM chatlist_memberships
WHERE user_id = $1 AND slug = $2`, userID, slug))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChatlistMembership{}, false, nil
}
return domain.ChatlistMembership{}, false, fmt.Errorf("get chatlist membership by slug: %w", err)
}
return membership, true, nil
}
func (s *ChatlistStore) GetMembershipByLocalFilter(ctx context.Context, userID int64, localFilterID int) (domain.ChatlistMembership, bool, error) {
membership, err := scanChatlistMembership(s.db.QueryRow(ctx, `
SELECT user_id, local_filter_id, owner_user_id, owner_filter_id, slug, hidden_updates,
EXTRACT(EPOCH FROM joined_at)::int
FROM chatlist_memberships
WHERE user_id = $1 AND local_filter_id = $2`, userID, localFilterID))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChatlistMembership{}, false, nil
}
return domain.ChatlistMembership{}, false, fmt.Errorf("get chatlist membership by local filter: %w", err)
}
return membership, true, nil
}
func (s *ChatlistStore) DeleteMembershipByLocalFilter(ctx context.Context, userID int64, localFilterID int) (bool, error) {
tag, err := s.db.Exec(ctx, `
DELETE FROM chatlist_memberships
WHERE user_id = $1 AND local_filter_id = $2`, userID, localFilterID)
if err != nil {
return false, fmt.Errorf("delete chatlist membership: %w", err)
}
return tag.RowsAffected() > 0, nil
}
func (s *ChatlistStore) SetMembershipHidden(ctx context.Context, userID int64, localFilterID int, hidden bool) (bool, error) {
tag, err := s.db.Exec(ctx, `
UPDATE chatlist_memberships
SET hidden_updates = $3, updated_at = now()
WHERE user_id = $1 AND local_filter_id = $2 AND hidden_updates IS DISTINCT FROM $3`, userID, localFilterID, hidden)
if err != nil {
return false, fmt.Errorf("set chatlist membership hidden: %w", err)
}
return tag.RowsAffected() > 0, nil
}
func scanChatlistInvite(row rowScanner) (domain.ChatlistInvite, error) {
var invite domain.ChatlistInvite
var peersJSON string
if err := row.Scan(&invite.ID, &invite.OwnerUserID, &invite.FilterID, &invite.Slug, &invite.Title, &peersJSON, &invite.Revoked, &invite.Deleted, &invite.Date); err != nil {
return domain.ChatlistInvite{}, err
}
if peersJSON != "" {
if err := json.Unmarshal([]byte(peersJSON), &invite.Peers); err != nil {
return domain.ChatlistInvite{}, fmt.Errorf("decode chatlist invite peers: %w", err)
}
}
return invite, nil
}
func scanChatlistMembership(row rowScanner) (domain.ChatlistMembership, error) {
var membership domain.ChatlistMembership
if err := row.Scan(&membership.UserID, &membership.LocalFilterID, &membership.OwnerUserID, &membership.OwnerFilterID, &membership.Slug, &membership.HiddenUpdates, &membership.Date); err != nil {
return domain.ChatlistMembership{}, err
}
return membership, nil
}

View file

@ -7,11 +7,13 @@ import (
"context"
"errors"
"fmt"
"net/url"
"reflect"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/links"
"telesrv/internal/store"
)
@ -22,6 +24,10 @@ func baseNow() int {
return int(time.Now().Unix())
}
func conferenceInviteLink(slug string) string {
return links.Build(links.DefaultPublicBaseURL, "call/"+slug, url.Values{"slug": []string{slug}})
}
// GroupCallStoreFactory 为每个用例提供干净的 store 与不冲突的 channel id。
type GroupCallStoreFactory func(t *testing.T) (st store.GroupCallStore, channelID int64)
@ -340,7 +346,7 @@ func contractConferenceChainBlocks(t *testing.T, factory GroupCallStoreFactory)
slug := fmt.Sprintf("contract-chain-%d", channelID)
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
ID: channelID*100 + 51, AccessHash: channelID*100 + 58, CreatorUserID: 1,
InviteSlug: slug, InviteLink: "https://telesrv.net/call/" + slug + "?slug=" + slug,
InviteSlug: slug, InviteLink: conferenceInviteLink(slug),
RandomID: channelID*100 + 51, CreatedAt: now,
})
if err != nil {
@ -388,7 +394,7 @@ func contractConferenceRecipientsTerminalAccess(t *testing.T, factory GroupCallS
slug := fmt.Sprintf("contract-recipient-%d", channelID)
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
ID: channelID*100 + 61, AccessHash: channelID*100 + 68, CreatorUserID: 1,
InviteSlug: slug, InviteLink: "https://telesrv.net/call/" + slug + "?slug=" + slug,
InviteSlug: slug, InviteLink: conferenceInviteLink(slug),
RandomID: channelID*100 + 61, CreatedAt: now,
})
if err != nil {
@ -434,10 +440,11 @@ func contractConferenceEmptyDiscards(t *testing.T, factory GroupCallStoreFactory
st, channelID := factory(t)
ctx := context.Background()
now := baseNow()
emptySlug := fmt.Sprintf("contract-empty-%d", channelID)
call, err := st.CreateConferenceCall(ctx, domain.GroupCall{
ID: channelID*100 + 71, AccessHash: channelID*100 + 78, CreatorUserID: 1,
InviteSlug: fmt.Sprintf("contract-empty-%d", channelID),
InviteLink: fmt.Sprintf("https://telesrv.net/call/contract-empty-%d?slug=contract-empty-%d", channelID, channelID),
InviteSlug: emptySlug,
InviteLink: conferenceInviteLink(emptySlug),
RandomID: channelID*100 + 71,
CreatedAt: now,
})
@ -464,10 +471,11 @@ func contractConferenceEmptyDiscards(t *testing.T, factory GroupCallStoreFactory
t.Fatalf("join empty discarded conference err = %v, want ErrGroupCallDiscarded", err)
}
resetSlug := fmt.Sprintf("contract-reset-empty-%d", channelID)
resetCall, err := st.CreateConferenceCall(ctx, domain.GroupCall{
ID: channelID*100 + 81, AccessHash: channelID*100 + 88, CreatorUserID: 1,
InviteSlug: fmt.Sprintf("contract-reset-empty-%d", channelID),
InviteLink: fmt.Sprintf("https://telesrv.net/call/contract-reset-empty-%d?slug=contract-reset-empty-%d", channelID, channelID),
InviteSlug: resetSlug,
InviteLink: conferenceInviteLink(resetSlug),
RandomID: channelID*100 + 81,
CreatedAt: now + 10,
})