business: add privacy-aware user projection

(cherry picked from commit a636192ef22ee69d792b0ca7db1c6be963be9cb2)
This commit is contained in:
A 2026-06-08 01:07:21 +08:00
parent 14d220c971
commit 8ff3343ae0
29 changed files with 2239 additions and 112 deletions

View file

@ -59,6 +59,98 @@ func (s *ContactStore) Get(ctx context.Context, userID, contactUserID int64) (do
return contact, true, nil
}
func (s *ContactStore) GetMany(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.Contact, error) {
out := make(map[int64]domain.Contact, len(contactUserIDs))
if userID == 0 || len(contactUserIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT
c.contact_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
WHERE c.user_id = $1
AND c.contact_user_id = ANY($2::bigint[])
`, userID, contactUserIDs)
if err != nil {
return nil, fmt.Errorf("get contacts many: %w", err)
}
defer rows.Close()
for rows.Next() {
contact, err := scanContactRows(rows)
if err != nil {
return nil, err
}
out[contact.User.ID] = contact
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (s *ContactStore) GetReverseContacts(ctx context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) {
out := make(map[int64]domain.Contact, len(ownerUserIDs))
if userID == 0 || len(ownerUserIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT
c.user_id AS owner_user_id,
c.mutual,
c.contact_phone,
c.contact_first_name,
c.contact_last_name,
c.note,
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
u.id,
u.access_hash,
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
u.username,
u.country_code,
u.verified,
u.support,
u.last_seen_at
FROM contacts c
JOIN users u ON u.id = c.contact_user_id
WHERE c.contact_user_id = $1
AND c.user_id = ANY($2::bigint[])
`, userID, ownerUserIDs)
if err != nil {
return nil, fmt.Errorf("get reverse contacts: %w", err)
}
defer rows.Close()
for rows.Next() {
ownerID, contact, err := scanReverseContactRows(rows)
if err != nil {
return nil, err
}
out[ownerID] = contact
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (s *ContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
entities, err := encodeMessageEntities(input.NoteEntities)
if err != nil {
@ -295,6 +387,70 @@ func (s *ContactStore) UpdateNote(ctx context.Context, userID, contactUserID int
return contact, true, nil
}
func (s *ContactStore) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) {
tag, err := s.db.Exec(ctx, `
UPDATE contacts
SET personal_photo_id = $3,
personal_photo_date = CASE WHEN $3::bigint = 0 THEN 0 ELSE $4::int END,
updated_at = now()
WHERE user_id = $1
AND contact_user_id = $2
`, userID, contactUserID, photoID, date)
if err != nil {
return domain.Contact{}, false, fmt.Errorf("set contact personal photo: %w", err)
}
if tag.RowsAffected() == 0 {
return domain.Contact{}, false, nil
}
contact, found, err := s.Get(ctx, userID, contactUserID)
return contact, found, err
}
func (s *ContactStore) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
out := make(map[int64]domain.ProfilePhotoRef, len(contactUserIDs))
if userID == 0 || len(contactUserIDs) == 0 {
return out, nil
}
rows, err := s.db.Query(ctx, `
SELECT
c.contact_user_id,
c.personal_photo_id,
ph.dc_id,
ph.sizes::text AS sizes_json
FROM contacts c
JOIN photos ph ON ph.id = c.personal_photo_id
WHERE c.user_id = $1
AND c.contact_user_id = ANY($2::bigint[])
AND c.personal_photo_id <> 0
`, userID, contactUserIDs)
if err != nil {
return nil, fmt.Errorf("list contact personal photos: %w", err)
}
defer rows.Close()
for rows.Next() {
var contactUserID, photoID int64
var dcID int32
var sizesJSON string
if err := rows.Scan(&contactUserID, &photoID, &dcID, &sizesJSON); err != nil {
return nil, err
}
sizes, err := decodePhotoSizes(sizesJSON)
if err != nil {
return nil, err
}
out[contactUserID] = domain.ProfilePhotoRef{
PhotoID: photoID,
DCID: int(dcID),
Stripped: domain.StrippedFromSizes(sizes),
Personal: true,
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (s *ContactStore) Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) {
if len(contactUserIDs) == 0 {
return 0, nil
@ -366,6 +522,107 @@ func contactFromFields(id, accessHash int64, phone, firstName, lastName, usernam
}
}
type contactScanner interface {
Scan(dest ...any) error
}
func scanContactRows(row contactScanner) (domain.Contact, error) {
var (
contactUserID int64
mutual bool
contactPhone string
contactFirstName string
contactLastName string
note string
noteEntitiesJSON string
id int64
accessHash int64
phone string
firstName string
lastName string
username string
countryCode string
verified bool
support bool
lastSeenAt int32
)
if err := row.Scan(
&contactUserID,
&mutual,
&contactPhone,
&contactFirstName,
&contactLastName,
&note,
&noteEntitiesJSON,
&id,
&accessHash,
&phone,
&firstName,
&lastName,
&username,
&countryCode,
&verified,
&support,
&lastSeenAt,
); err != nil {
return domain.Contact{}, err
}
entities, err := decodeMessageEntities(noteEntitiesJSON)
if err != nil {
return domain.Contact{}, err
}
return contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual), nil
}
func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) {
var (
ownerUserID int64
mutual bool
contactPhone string
contactFirstName string
contactLastName string
note string
noteEntitiesJSON string
id int64
accessHash int64
phone string
firstName string
lastName string
username string
countryCode string
verified bool
support bool
lastSeenAt int32
)
if err := row.Scan(
&ownerUserID,
&mutual,
&contactPhone,
&contactFirstName,
&contactLastName,
&note,
&noteEntitiesJSON,
&id,
&accessHash,
&phone,
&firstName,
&lastName,
&username,
&countryCode,
&verified,
&support,
&lastSeenAt,
); err != nil {
return 0, domain.Contact{}, err
}
entities, err := decodeMessageEntities(noteEntitiesJSON)
if err != nil {
return 0, domain.Contact{}, err
}
contact := contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual)
return ownerUserID, contact, nil
}
func (s *ContactStore) Block(ctx context.Context, userID, blockedUserID int64, date int) (bool, error) {
if userID == 0 || blockedUserID == 0 || userID == blockedUserID {
return false, nil

View file

@ -420,27 +420,44 @@ func (s *MediaStore) CountAvailableReactions(ctx context.Context) (int, error) {
// ---- 头像历史 ----
func (s *MediaStore) AddProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) error {
next, err := s.q.NextProfilePhotoOrder(ctx, sqlcgen.NextProfilePhotoOrderParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
})
return s.AddProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoID, date)
}
func (s *MediaStore) AddProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) error {
kind = normalizeProfilePhotoKind(kind)
next, err := s.nextProfilePhotoOrder(ctx, ownerType, ownerID, kind)
if err != nil {
return err
}
return s.q.AddProfilePhoto(ctx, sqlcgen.AddProfilePhotoParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
PhotoID: photoID,
Date: int32(date),
SortOrder: next + 1,
})
_, err = s.db.Exec(ctx, `
INSERT INTO profile_photos (owner_peer_type, owner_peer_id, kind, photo_id, date, active, sort_order)
VALUES ($1, $2, $3, $4, $5, true, $6)
ON CONFLICT (owner_peer_type, owner_peer_id, kind, photo_id) DO UPDATE SET
date = EXCLUDED.date,
active = true,
sort_order = EXCLUDED.sort_order
`, string(ownerType), ownerID, string(kind), photoID, date, next+1)
return err
}
func (s *MediaStore) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (int64, bool, error) {
id, err := s.q.CurrentProfilePhoto(ctx, sqlcgen.CurrentProfilePhotoParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
})
return s.CurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile)
}
func (s *MediaStore) CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, bool, error) {
kind = normalizeProfilePhotoKind(kind)
row := s.db.QueryRow(ctx, `
SELECT photo_id
FROM profile_photos
WHERE owner_peer_type = $1
AND owner_peer_id = $2
AND kind = $3
AND active
ORDER BY sort_order DESC
LIMIT 1
`, string(ownerType), ownerID, string(kind))
var id int64
err := row.Scan(&id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, false, nil
@ -451,59 +468,156 @@ func (s *MediaStore) CurrentProfilePhoto(ctx context.Context, ownerType domain.P
}
func (s *MediaStore) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
return s.CurrentProfilePhotosKind(ctx, ownerType, ownerIDs, domain.ProfilePhotoKindProfile)
}
func (s *MediaStore) CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) {
if len(ownerIDs) == 0 {
return map[int64]domain.ProfilePhotoRef{}, nil
}
rows, err := s.q.CurrentProfilePhotosForOwners(ctx, sqlcgen.CurrentProfilePhotosForOwnersParams{
OwnerPeerType: string(ownerType),
OwnerIds: ownerIDs,
})
kind = normalizeProfilePhotoKind(kind)
rows, err := s.db.Query(ctx, `
SELECT DISTINCT ON (pp.owner_peer_id)
pp.owner_peer_id,
pp.photo_id,
ph.dc_id,
ph.sizes::text AS sizes_json
FROM profile_photos pp
JOIN photos ph ON ph.id = pp.photo_id
WHERE pp.owner_peer_type = $1
AND pp.owner_peer_id = ANY($2::bigint[])
AND pp.kind = $3
AND pp.active
ORDER BY pp.owner_peer_id, pp.sort_order DESC
`, string(ownerType), ownerIDs, string(kind))
if err != nil {
return nil, err
}
out := make(map[int64]domain.ProfilePhotoRef, len(rows))
for _, r := range rows {
sizes, err := decodePhotoSizes(r.SizesJson)
defer rows.Close()
out := make(map[int64]domain.ProfilePhotoRef, len(ownerIDs))
for rows.Next() {
var ownerID, photoID int64
var dcID int32
var sizesJSON string
if err := rows.Scan(&ownerID, &photoID, &dcID, &sizesJSON); err != nil {
return nil, err
}
sizes, err := decodePhotoSizes(sizesJSON)
if err != nil {
return nil, err
}
out[r.OwnerPeerID] = domain.ProfilePhotoRef{
PhotoID: r.PhotoID,
DCID: int(r.DcID),
out[ownerID] = domain.ProfilePhotoRef{
PhotoID: photoID,
DCID: int(dcID),
Stripped: domain.StrippedFromSizes(sizes),
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (s *MediaStore) ListProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]int64, int, error) {
ids, err := s.q.ListProfilePhotos(ctx, sqlcgen.ListProfilePhotosParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
MaxID: maxID,
OffsetCount: int32(offset),
LimitCount: int32(limit),
})
return s.ListProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, offset, limit, maxID)
}
func (s *MediaStore) ListProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]int64, int, error) {
kind = normalizeProfilePhotoKind(kind)
rows, err := s.db.Query(ctx, `
SELECT photo_id
FROM profile_photos
WHERE owner_peer_type = $1
AND owner_peer_id = $2
AND kind = $3
AND active
AND ($4::bigint <= 0 OR photo_id < $4::bigint)
ORDER BY sort_order DESC
OFFSET $5
LIMIT $6
`, string(ownerType), ownerID, string(kind), maxID, offset, limit)
if err != nil {
return nil, 0, err
}
total, err := s.q.CountProfilePhotos(ctx, sqlcgen.CountProfilePhotosParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
})
defer rows.Close()
ids := make([]int64, 0, limit)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, 0, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
var total int
err = s.db.QueryRow(ctx, `
SELECT count(*)::int
FROM profile_photos
WHERE owner_peer_type = $1
AND owner_peer_id = $2
AND kind = $3
AND active
`, string(ownerType), ownerID, string(kind)).Scan(&total)
if err != nil {
return nil, 0, err
}
return ids, int(total), nil
return ids, total, nil
}
func (s *MediaStore) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error) {
return s.DeleteProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoIDs)
}
func (s *MediaStore) DeleteProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) ([]int64, error) {
if len(photoIDs) == 0 {
return nil, nil
}
return s.q.DeactivateProfilePhotos(ctx, sqlcgen.DeactivateProfilePhotosParams{
OwnerPeerType: string(ownerType),
OwnerPeerID: ownerID,
PhotoIds: photoIDs,
})
kind = normalizeProfilePhotoKind(kind)
rows, err := s.db.Query(ctx, `
UPDATE profile_photos
SET active = false
WHERE owner_peer_type = $1
AND owner_peer_id = $2
AND kind = $3
AND photo_id = ANY($4::bigint[])
AND active
RETURNING photo_id
`, string(ownerType), ownerID, string(kind), photoIDs)
if err != nil {
return nil, err
}
defer rows.Close()
deleted := make([]int64, 0, len(photoIDs))
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
deleted = append(deleted, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return deleted, nil
}
func (s *MediaStore) nextProfilePhotoOrder(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, error) {
var maxOrder int64
err := s.db.QueryRow(ctx, `
SELECT COALESCE(MAX(sort_order), 0)::bigint
FROM profile_photos
WHERE owner_peer_type = $1
AND owner_peer_id = $2
AND kind = $3
`, string(ownerType), ownerID, string(kind)).Scan(&maxOrder)
return maxOrder, err
}
func normalizeProfilePhotoKind(kind domain.ProfilePhotoKind) domain.ProfilePhotoKind {
if kind == domain.ProfilePhotoKindFallback {
return kind
}
return domain.ProfilePhotoKindProfile
}

View file

@ -0,0 +1,125 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
var _ store.PrivacyStore = (*PrivacyStore)(nil)
// PrivacyStore persists account privacy rules in PostgreSQL.
type PrivacyStore struct {
db sqlcgen.DBTX
}
func NewPrivacyStore(db sqlcgen.DBTX) *PrivacyStore {
return &PrivacyStore{db: db}
}
func (s *PrivacyStore) GetPrivacyRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, bool, error) {
row := s.db.QueryRow(ctx, `
SELECT rules::text
FROM account_privacy_rules
WHERE owner_user_id = $1
AND privacy_key = $2
`, ownerUserID, string(key))
var raw string
if err := row.Scan(&raw); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.PrivacyRules{}, false, nil
}
return domain.PrivacyRules{}, false, fmt.Errorf("get privacy rules: %w", err)
}
rules, err := decodePrivacyRulesJSON(raw)
if err != nil {
return domain.PrivacyRules{}, false, err
}
return domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: rules}, true, nil
}
func (s *PrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.PrivacyRules) error {
raw, err := json.Marshal(rules.Rules)
if err != nil {
return err
}
_, err = s.db.Exec(ctx, `
INSERT INTO account_privacy_rules (owner_user_id, privacy_key, rules, updated_at)
VALUES ($1, $2, $3::jsonb, NOW())
ON CONFLICT (owner_user_id, privacy_key) DO UPDATE SET
rules = EXCLUDED.rules,
updated_at = EXCLUDED.updated_at
`, rules.OwnerUserID, string(rules.Key), string(raw))
if err != nil {
return fmt.Errorf("set privacy rules: %w", err)
}
return nil
}
func (s *PrivacyStore) ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) {
if len(ownerUserIDs) == 0 || len(keys) == 0 {
return nil, nil
}
rows, err := s.db.Query(ctx, `
SELECT owner_user_id, privacy_key, rules::text
FROM account_privacy_rules
WHERE owner_user_id = ANY($1::bigint[])
AND privacy_key = ANY($2::text[])
`, ownerUserIDs, privacyKeyStrings(keys))
if err != nil {
return nil, fmt.Errorf("list privacy rules: %w", err)
}
defer rows.Close()
out := make([]domain.PrivacyRules, 0)
for rows.Next() {
var ownerUserID int64
var key string
var raw string
if err := rows.Scan(&ownerUserID, &key, &raw); err != nil {
return nil, err
}
rules, err := decodePrivacyRulesJSON(raw)
if err != nil {
return nil, err
}
out = append(out, domain.PrivacyRules{
OwnerUserID: ownerUserID,
Key: domain.PrivacyKey(key),
Rules: rules,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func privacyKeyStrings(keys []domain.PrivacyKey) []string {
out := make([]string, 0, len(keys))
for _, key := range keys {
out = append(out, string(key))
}
return out
}
func decodePrivacyRulesJSON(raw string) ([]domain.PrivacyRule, error) {
if raw == "" {
return nil, nil
}
var rules []domain.PrivacyRule
if err := json.Unmarshal([]byte(raw), &rules); err != nil {
return nil, fmt.Errorf("decode privacy rules: %w", err)
}
for i := range rules {
rules[i].UserIDs = append([]int64(nil), rules[i].UserIDs...)
rules[i].ChatIDs = append([]int64(nil), rules[i].ChatIDs...)
}
return rules, nil
}